Schema
How to configure schemas for new projects
What is a Schema?
The Schema is the bridge between your raw data and the Envisioning App. It tells the app how to interpret your data by defining what each piece of data represents (entities, tags, clusters, metrics, or scopes) and how they relate to each other.
Think of it as a mapping layer that transforms arbitrary JSON data into a structured format the app understands.
Data Structure
Your data should be a JSON object where keys contain arrays of objects (entries):
{
"allCountries": [
{ "id": "BRA", "title": "Brazil", "region": "Americas", "population": 212559409 },
{ "id": "USA", "title": "United States", "region": "Americas", "population": 331002651 }
],
"allRegions": [
{ "id": "Americas", "title": "Americas" },
{ "id": "Europe", "title": "Europe" }
],
"allLanguages": [
{ "id": "por", "title": "Portuguese" },
{ "id": "eng", "title": "English" }
]
}The schema then describes which keys represent what type of data.
Schema Structure
const schema = {
skipAutoJoin: false, // Enable/disable automatic join creation
objects: {
// Define your objects here (entity, tag, cluster, metric, scope)
}
}Common Properties
All object types share these base properties:
| Property | Type | Description |
|---|---|---|
type | "entity" | "tag" | "cluster" | "metric" | "scope" | The object type |
src | string | JSON path to the data array (e.g., "allCountries") |
identifier | string | Path to the unique ID field. Default: "id" |
titlePath | string | Path to the display title. Default: "title" |
label | I18n | Display label for the object type |
singular | I18n | Singular form (e.g., "Country") |
plural | I18n | Plural form (e.g., "Countries") |
description | I18n | Optional description |
filter | object | null | MongoDB-style filter to apply to the data |
joins | Record<string, Join> | Relationships to other objects |
I18n format: Can be a simple string "Country" or an object with language keys { en: "Country", pt: "País" }.
Object Types
Entity
The entity is your main data type - the primary items displayed in visualizations. You need exactly one entity type per project.
entities: {
type: "entity",
src: "allCountries",
identifier: "id", // Default: "id"
titlePath: "title",
label: { en: "Country" },
singular: { en: "Country" },
plural: { en: "Countries" },
filter: null,
joins: { /* ... */ }
}Use case: Countries, technologies, companies, products - whatever your main dataset represents.
Tag
Tags are categories that can be assigned to entities in a many-to-many relationship. An entity can have multiple tags, and a tag can belong to multiple entities.
language: {
type: "tag",
src: "allLanguages",
identifier: "id",
titlePath: "title",
label: { en: "Languages" },
singular: { en: "Language" },
plural: { en: "Languages" },
filter: null,
colorSpec: { // Optional: color configuration
type: "fixed",
colors: ["#ff0000", "#00ff00"]
}
}Use case: Categories, skills, features, topics - any classification where items can have multiple values.
Cluster
Clusters group entities into mutually exclusive categories. Each entity belongs to exactly one cluster.
collection: {
type: "cluster",
src: "allRegions",
identifier: "id",
titlePath: "title",
label: { en: "Regions" },
singular: { en: "Region" },
plural: { en: "Regions" },
filter: null
}Use case: Geographic regions, departments, status levels - any grouping where items belong to only one category.
Metric
Metrics are numerical values associated with entities. They define a measurable property with a specific domain (range of possible values).
population: {
type: "metric",
src: "self", // "self" means metricResults is the source
identifier: "value", // Default for metrics: "value"
titlePath: "title",
label: { en: "Population" },
singular: { en: "Population" },
plural: { en: "Populations" },
filter: null,
metricDomain: [0, 10_000_000_000], // [min, max] possible values
metricRange: [0, 100], // Optional: normalized range for display
metricOrigin: 0, // Optional: zero point (useful for -10 to 10 scales)
metricType: "CONTINUOUS", // "CONTINUOUS" or "DISCRETE"
unitLabel: { en: "people" }, // Optional: unit label
metricResults: [ // Optional: define value buckets
{ value: [0, 1_000_000], label: { en: "Small" } },
{ value: [1_000_000, 100_000_000], label: { en: "Medium" } },
{ value: [100_000_000, 10_000_000_000], label: { en: "Large" } }
]
}Metric Types:
CONTINUOUS: Any number within the range (e.g., temperature, percentage)DISCRETE: Distinct whole numbers (e.g., count, level 1-5)
src: "self": This value is exclusive to metrics. When src is "self", the metric doesn't fetch data from an external array in your data. Instead, the metricResults array becomes the metric's source. Each entity's raw value (e.g., population: 212559409) is matched against the metricResults[].value ranges to determine which bucket it belongs to.
Scope
Scopes are similar to clusters but are used for filtering the entire dataset. They provide a way to view subsets of data.
year: {
type: "scope",
src: "allYears",
identifier: "id",
titlePath: "title",
label: { en: "Year" },
singular: { en: "Year" },
plural: { en: "Years" },
filter: null,
noScopeLabel: { en: "All Years" } // Label when no scope is selected (null to hide option)
}Use case: Time periods, data versions, geographic scopes - any dimension to filter the entire view.
Joins
Joins define relationships between objects. They tell the app how to connect entity data with tags, clusters, metrics, and scopes.
Join Properties
| Property | Type | Description |
|---|---|---|
from | string | Schema object key to join from |
localPath | string | Path in the entity where the relationship data lives |
cardinality | "single" | "multiple" | One-to-one or one-to-many relationship |
localIdentifier | string | How to extract the ID from localPath data |
Understanding localIdentifier
"id"- The local data is an array of objects withidfield:[{ id: "eng" }, { id: "por" }]"value"- The local data contains the value directly (for metrics):{ trl: 5 }"."- The local data IS the identifier (primitive value):"Americas"or["BRA", "ARG"]
Example Joins
joins: {
// Single cluster relationship: entity.region = "Americas" -> cluster.id = "Americas"
collection: {
from: "collection", // Schema key for regions cluster
localPath: "region", // Entity field containing the region
cardinality: "single", // Each entity has one region
localIdentifier: "." // The value itself is the identifier
},
// Multiple tags relationship: entity.languages = ["eng", "por"] -> tag.id
language: {
from: "language",
localPath: "languages",
cardinality: "multiple",
localIdentifier: "."
},
// Metric relationship: entity.population = 212559409 -> metric bucket
population: {
from: "population",
localPath: "population",
cardinality: "single",
localIdentifier: "."
},
// Self-referential join: entity.borders = ["ARG", "BOL"] -> other entities
borders: {
from: "entities",
localPath: "borders",
cardinality: "multiple",
localIdentifier: "."
}
}Auto Join
When skipAutoJoin: false (default), the app automatically creates joins based on naming conventions:
- If your entity has a field named
languageand you have a schema object with keylanguage, a join is created automatically - Tags get
cardinality: "multiple"withlocalIdentifier: "id" - Clusters and Scopes get
cardinality: "single"withlocalIdentifier: "id" - Metrics get
cardinality: "single"withlocalIdentifier: "value"
When to use manual joins:
- Field names don't match schema keys (e.g.,
regionfield →collectionschema) - You need
localIdentifier: "."for primitive values - You want different join behavior than the defaults
Set skipAutoJoin: true if you want full control over all joins.
Complete Example
Here's a full schema for a countries database:
import type { Config } from "@envisioning/app";
export const schema = {
skipAutoJoin: false,
objects: {
entities: {
type: "entity",
src: "allCountries",
titlePath: "title",
label: { en: "Country" },
singular: { en: "Country" },
plural: { en: "Countries" },
filter: null,
identifier: "id",
joins: {
collection: {
from: "collection",
localPath: "region",
cardinality: "single",
localIdentifier: ".",
},
population: {
from: "population",
localPath: "population",
cardinality: "single",
localIdentifier: ".",
},
},
},
language: {
type: "tag",
src: "allLanguages",
titlePath: "title",
label: { en: "Languages" },
singular: { en: "Language" },
plural: { en: "Languages" },
filter: null,
identifier: "id",
},
collection: {
type: "cluster",
src: "allRegions",
titlePath: "title",
label: { en: "Regions" },
singular: { en: "Region" },
plural: { en: "Regions" },
filter: null,
identifier: "id",
},
population: {
type: "metric",
src: "self",
filter: null,
titlePath: "title",
label: { en: "Population" },
singular: { en: "Population" },
plural: { en: "Populations" },
identifier: "value",
metricDomain: [0, 10_000_000_000],
metricResults: [
{ value: [0, 1_000_000], label: { en: "Small" } },
{ value: [1_000_000, 100_000_000], label: { en: "Medium" } },
{ value: [100_000_000, 10_000_000_000], label: { en: "Large" } },
],
},
},
} satisfies Config["schema"];Data Transformation Tips
Your raw data often needs transformation before it matches the schema. Common patterns:
- Normalize IDs: Ensure all items have a consistent
idfield - Extract relationships: Convert nested objects to arrays of IDs
- Create lookup tables: Generate separate arrays for tags, clusters from entity data if needed.
// Example: Transform raw country data
const transformedData = {
allCountries: rawCountries.map(country => ({
id: country.cca3,
title: country.name.common,
region: country.region,
languages: Object.keys(country.languages), // Extract IDs from object
population: country.population,
})),
allRegions: [...new Set(rawCountries.map(c => c.region))]
.map(region => ({ id: region, title: region })),
allLanguages: extractUniqueLanguages(rawCountries),
};