Skip to main content

@ttoss/geovis

@ttoss/geovis provides schema-driven geovisualization components for React applications, with a MapLibre engine adapter and a JSON-spec-based runtime.

Installing​

pnpm add @ttoss/geovis

You will also need to install the following peer dependencies:

pnpm add maplibre-gl @ttoss/ui

Getting Started​

Wrap your application (or a section of it) with GeoVisProvider, passing a VisualizationSpec:

import { GeoVisCanvas, GeoVisProvider } from '@ttoss/geovis';

const spec = {
engine: 'maplibre',
view: { center: [-46.6, -23.5], zoom: 10 },
sources: [
{
id: 'points',
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
},
],
layers: [
{
id: 'points-layer',
sourceId: 'points',
geometry: 'point',
},
],
};

const MyMap = () => (
<GeoVisProvider spec={spec}>
<GeoVisCanvas viewId="main" style={{ width: '100%', height: '400px' }} />
</GeoVisProvider>
);

Spec reference​

VisualizationSpec​

Top-level spec object passed to GeoVisProvider.

FieldTypeRequiredDescription
engine'maplibre'✓Engine adapter to use. Currently only 'maplibre' is supported.
sourcesDataSource[]✓Data sources referenced by layers. Supported types: 'geojson', 'vector-tiles', 'raster-tiles', 'raster-dem', 'image', 'video'.
layersVisualizationLayer[]✓Ordered list of layers to render (bottom-to-top).
titlestringHuman-readable title.
descriptionstringHuman-readable description.
mapTypeMapTypeAuto-configuration hint ('choropleth'). When set, layers and legends are auto-generated from mapData — see mapType auto-configuration.
viewViewStateInitial camera state: center, zoom, maxZoomIn, maxZoomOut, pitch, bearing, projection. maxZoomIn caps how far the user can zoom in and maxZoomOut caps how far out (interactive, setView, and programmatic zoom are all clamped); they default to MapLibre's 22 and 0. Omit center/zoom entirely to let the camera auto-fit to data instead.
basemapBaseMapSpecBasemap tile style. Pass visible: false to hide tiles and show only GeoJSON layers. When hidden, the canvas container receives a #fcfcfc background. labels: false hides the basemap's own place/road/POI labels; your own symbol layers are never touched, whichever way labels points — hide those with layer.visible.
legendsLegendSpec[]Shared legend registry. Layers reference entries via activeLegendId.
legendEnabledbooleanControls whether the resolved mapType auto-generates legends. Defaults to true. Has no effect on legends supplied directly via legends.
attributionControlEnabledbooleanControls whether MapLibre mounts its attribution control — the round button in the map’s bottom-right corner that expands into the basemap credits. Defaults to true. Set it to false only when the application shows the same credits elsewhere: basemap and source licences generally require attribution to remain visible.
mapDataMapData[]Attribute datasets joined to GeoJSON sources for choropleth coloring and tooltips.
metadataRecord<string, unknown>Arbitrary consumer metadata; not read by the runtime.
viewPresetsViewPreset[]Named camera positions ({ id, label?, view, animation? }) dispatch({ type: 'set-view-preset' }) can target by id. See AI Action Surface.
controlLayerControlA floating layer-toggle panel, auto-mounted on the map. See Layer Control.

Auto fit-to-data​

When view.center and view.zoom are both omitted, the MapLibre adapter automatically centers the camera on the bounding box of every geojson source's inline data (plus the corners of image/video sources), with a responsive padding of 6% of the container's own width/height on every side. This runs on mount and again whenever a source's geometry changes — never when only mapData (attribute values) changes, so the camera stays put while data is recolored or resized. Set view.center/view.zoom explicitly to opt out and take full manual control of the camera. Sources with no client-side geometry (vector-tiles, raster-tiles, raster-dem) and geojson sources whose data is a URL string are not considered — set view manually for those.

1. No view → auto-fit to data

const spec = {
engine: 'maplibre',
// view omitted entirely — camera fits the bbox of `sources` on mount.
sources: [{ id: 'points', type: 'geojson', data: featureCollection }],
layers: [{ id: 'points-layer', sourceId: 'points', geometry: 'point' }],
};

2. Explicit center/zoom → manual control, auto-fit bypassed

const spec = {
engine: 'maplibre',
view: { center: [-46.6, -23.5], zoom: 10 }, // auto-fit never runs
sources: [{ id: 'points', type: 'geojson', data: featureCollection }],
layers: [{ id: 'points-layer', sourceId: 'points', geometry: 'point' }],
};

3. Only mapData changes → camera preserved

// Re-coloring/resizing features via mapData does NOT refit the camera —
// only a change to a source's own geometry does.
applyPatch({
target: 'mapData',
mapDataId: 'population',
data: updatedValues, // camera stays exactly where it was
});

Migrating from FitBoundsToBbox​

Consumers previously wrapped their spec with a FitBoundsToBbox helper (Storybook-only pattern) to get the same centering behavior. That helper is no longer needed — remove it and omit view instead:

-import { FitBoundsToBbox } from './helpers/FitBoundsToBbox';
-
const spec = {
engine: 'maplibre',
- view: { center: computeCenter(data), zoom: computeZoom(data) },
+ // view omitted — the adapter now computes this itself
sources: [{ id: 'points', type: 'geojson', data }],
layers: [{ id: 'points-layer', sourceId: 'points', geometry: 'point' }],
};

-<FitBoundsToBbox spec={spec}>
- <GeoVisCanvas viewId="main" />
-</FitBoundsToBbox>
+<GeoVisCanvas viewId="main" />

If the spec already sets view.center/view.zoom explicitly (e.g. a saved camera preset), leave it as-is — auto-fit only activates when both are omitted.

LegendSpec​

Each entry in spec.legends (or layer.legends) defines one choropleth legend.

FieldTypeRequiredDescription
idstring✓Unique legend identifier. Referenced by activeLegendId and GeoVisLegend.
colorByColorBy✓Color-by configuration (categorical or quantitative).
titlestringShort heading rendered above the swatches.
subtitlestringSecondary description rendered below the title.
iconstringIcon shown in a tinted chip beside the title, as a @ttoss/react-icons name (e.g. 'lucide:tractor').
iconColorstringAccent color (hex) tinting the icon chip. Defaults to the legend's first swatch color.
footerValuestringShort value shown at the far right of the footer, e.g. a reference year ('2024').
labelFormatLabelFormatSpecControls how quantitative bin labels are generated. Defaults to 'range' style when omitted. See LabelFormatSpec table below.
normalizationNormalizationSpecStatistical normalisation metadata for the mapped values. Used to append semantic suffixes when labelFormat.extended is true.
positionLegendPositionCorner overlay position: 'top-left', 'top-right', 'bottom-left', 'bottom-right'. When set, GeoVisLegend applies absolute CSS positioning.
offsetnumber | { x?, y? }Distance in px from the anchored edges (only with position). Defaults to 24; a number applies to both edges, an object offsets each axis. Animated on change.
noDataLabelstringLabel for the "no data" swatch at the bottom of the legend. When omitted, no "no data" entry is shown.
referencestringBibliographic attribution below the swatches. Supports {link:visible text|https://example.com} inline link syntax.

LabelFormatSpec​

Controls how quantitative legend bin labels are generated. Set on LegendSpec.labelFormat.

typeExtra fieldsDescription
'range'separator?, unit?, extended?Raw break values joined by a separator. Example: 50k – 100k.
'count'abbreviate?, extended?Compact integer counts with optional SI abbreviation. Example: < 50k.
'percentage'decimals?, denominator?, extended?Percentage values for data already in the [0, 1] range. Example: 0% – 10%.
'stdDev'unit?: 'σ' | 'sd', extended?Standard deviation labels for diverging schemes. Example: < −2σ, +1σ – +2σ.
'labels'labels: string[], extended?Explicit label list. One string per bin, in ascending order. JSON-serialisable. Bins beyond the array length fall back to range style.
'custom'formatter: (lower, upper, index) => string, extended?Runtime formatter function. Not JSON-serialisable; TypeScript-only.

All variants support extended?: boolean. When true, a semantic suffix from the legend's normalization field is appended to every label (e.g. < 50k inhabitants).

The 'labels' type is the recommended choice when label text is known ahead of time — for example, qualitative classification categories ('Low', 'Medium', 'High') or custom range descriptions. It is the only variant besides 'range' and 'count' that is fully JSON-serialisable.

VisualizationLayer​

Each entry in spec.layers describes one rendered layer.

FieldTypeRequiredDescription
idstring✓Unique layer identifier.
sourceIdstring✓References a DataSource.id from spec.sources.
geometryGeoVisGeometryType✓Render type: 'point', 'line', 'polygon', 'raster', 'symbol', 'heatmap'.
sourceLayerstringVector tile source layer name. Required when source.type is 'vector-tiles'.
titlestringHuman-readable layer name.
visiblebooleanWhether the layer is rendered. Defaults to true.
minzoomnumberMinimum zoom level (0–24) at which the layer is visible.
maxzoomnumberMaximum zoom level (0–24) at which the layer is visible.
paintLayerPaintPer-geometry paint properties. See examples below.
legendsLegendSpec[]Alternative legend definitions exposed as runtime toggles.
activeLegendIdstringActive entry from legends[]. Enables choropleth coloring and the hover tooltip.
mapDataIdstringReferences a MapData.mapDataId for per-feature value joining (choropleth / tooltip). When a MapData declares dimension, the adapter auto-discovers color/size.
propertyNamestringReads circle size directly from feature.properties[propertyName] via ['get', propertyName]. Alternative to mapData — mutually exclusive with mapDataId on the same layer (schema-enforced, invalid-schema). See Alternative data source.
hoverPaint{ lineColor?: string; lineWidth?: number }Outline rendered on the hovered feature via a companion MapLibre line layer driven by feature-state.hover.
selectedPaint{ lineColor?: string; lineWidth?: number }Outline rendered on the selected feature via feature-state.selected.
clickAnchor{ iconImage?: string; iconSize?: number; color?: string; offset?: [number, number]; latKey?: string; lngKey?: string }Spec-driven click marker. Use iconImage to render a sprite icon; use color for the built-in SVG pin. For a custom HTML element, use <GeoVisMarker> instead. Set latKey/lngKey to read the anchor position from the clicked feature's properties (surfaced as MapClickInfo.featureLngLat) instead of the click point — avoids drift at high zoom.
sizeBySizeByProportional symbol configuration. Maps a numeric mapData property to circle-radius. See Proportional Symbols.
hoverTooltipHoverTooltipConfigSpec-driven hover tooltip. When present, <GeoVisProvider> renders a <GeoVisHoverTooltip> automatically for features on this layer — no component needed in the tree. Mirrors GeoVisHoverTooltipProps. See Spec-driven hover tooltip.
filterLayerFilterDeclarative predicate ({ property, operator, value }) that hides non-matching features, compiled to the engine's native filter. Reads feature.properties[property], gated by CapabilitySet.dataFeatures.filter. See AI Action Surface.

Paint properties​

The paint field accepts different shapes depending on geometry. The three most common:

Polygon (geometry: 'polygon') — FillPaint

paint: {
fillColor: '#3b82f6', // fill-color
lineColor: '#1d4ed8', // outline color (fill-outline-color)
}

Line (geometry: 'line') — LinePaint

paint: {
lineColor: '#ef4444', // line-color
lineWidth: 2, // line-width (pixels)
}

Point (geometry: 'point') — CirclePaint

paint: {
circleColor: '#10b981', // circle-color
circleRadius: 6, // circle-radius (pixels)
circleStrokeColor: '#065f46', // circle-stroke-color
circleStrokeWidth: 1, // circle-stroke-width (pixels)
}

Symbol (geometry: 'symbol') — SymbolPaint

Labels and icons. paint reaches the style verbatim, so textField and textSize also accept a MapLibre expression when the label has to be computed per feature:

paint: {
textField: ['number-format', ['get', 'count'], { locale: 'pt-BR' }],
textSize: ['step', ['get', 'count'], 11, 100, 14],
textFont: ['Noto Sans Bold'], // text-font, defaults to ['Noto Sans Regular']
textColor: '#ffffff',
textHaloColor: '#14532d',
textHaloWidth: 1.5,
}

textFont defaults to Noto Sans Regular rather than to MapLibre's own default (Open Sans Regular, Arial Unicode MS Regular), which OpenFreeMap and most OpenMapTiles-derived basemaps do not serve: a missing fontstack 404s the glyph request and rasterizes no text at all, which looks like the layer never mounted. Override it only with a fontstack the basemap serves.

Clustered vector tiles​

A dataset large enough to need tiles cannot use MapLibre's cluster: true: that option belongs to geojson sources and runs supercluster over every point in browser memory. Cluster such data when the tiles are generated instead (tippecanoe --cluster-distance), and style the merged features from their own attributes. GeoVis/ClusterTiles in Storybook is a working example, generated by scripts/generateClusterFixtureTiles.ts.

Have the generator write a counter on every input point and accumulate it, so each merged feature reports how many original points it stands for:

tippecanoe --cluster-distance=40 --accumulate-attribute=count:sum \
--minimum-zoom=2 --maximum-zoom=8 --no-tile-compression \
--layer=clusters --output-to-directory=tiles/ points.ndjson

tippecanoe also writes point_count on each cluster, but that counts the features merged at one zoom level — and since every level clusters the previous level's already-merged features, it under-reports the original total. Size and label by the accumulated attribute, not by point_count.

Three layer fields then carry the rendering, with no mapData join — none is possible against a tiled source, whose features carry no stable ids:

{
id: 'clusters',
sourceId: 'points',
sourceLayer: 'clusters', // layer name inside the tiles
geometry: 'point',
propertyName: 'count', // circle-radius reads ['get', 'count']
sizeBy: { mode: 'stepped', range: [9, 34], thresholds: [25, 100, 1000] },
filter: { property: 'count', operator: 'gte', value: 2 },
paint: { circleColor: '#38bdf8' },
}

A symbol layer over it labels each circle — paint.textField is passed to MapLibre verbatim, so its {property} token expands per feature, and an expression can format the number instead (see Paint properties):

paint: { textField: '{count}', textSize: 11 }

Hover, click and selection work on a tiled layer: the adapter addresses feature-state with the layer's sourceLayer, which vector sources require. So clickAnchor and selectedPaint light up from a tile feature's own id — promote one at generation time (tippecanoe --use-attribute-for-id), since there is no mapData join to supply it.

Two limits are worth knowing before designing around this. Colour cannot be driven by a tile attribute: colorBy and the legend pipeline read feature-state, which is geojson-only, so colour bands have to be one layer per band with a static colour. And a LayerFilter holds a single predicate, so a closed range (100 <= count < 1000) is not expressible — stack layers with one-sided gte filters and let the topmost match win.

Tile URL templates must be absolute. MapLibre resolves them inside a Web Worker, where a root-relative path has no base and fails silently, leaving the layer empty.

Data-driven maps​

mapData decouples attribute values from geometry: the GeoJSON source holds shapes while mapData holds per-feature values. The adapter joins them at runtime via setFeatureState, enabling choropleth coloring and hover tooltips without modifying source features.

stateKey and dimension — multiple dimensions on the same source​

By default, mapData writes values as { value: X } in the feature state. When you need multiple independent dimensions (e.g. one for color, another for size) on the same source, use stateKey to give each dataset its own key, and dimension to declare which visual dimension it drives:

{
"mapData": [
{
"mapDataId": "population",
"mapId": "cities",
"stateKey": "pop",
"dimension": "size",
"data": [{ "geometryId": 1, "value": 100000 }]
},
{
"mapDataId": "density",
"mapId": "cities",
"stateKey": "density",
"dimension": "color",
"data": [{ "geometryId": 1, "value": 50 }]
}
]
}

Feature state becomes { pop: 100000, density: 50 }, and each dimension reads its own key via ['feature-state', 'pop'] and ['feature-state', 'density']. The adapter auto-discovers which dataset provides color vs. size based on dimension.

How stateKey resolution works​

The adapter resolves stateKey per dimension via a fallback chain:

  1. Dimension match — find a mapData entry where dimension matches the requested dimension (color/size) and mapId matches the layer's source.
  2. Legacy mapDataId — fall back to the entry referenced by the layer's mapDataId field.
  3. Any entry for source — fall back to any mapData entry whose mapId matches the layer's source.
  4. Default — return 'value'.

When a matching entry exists but omits stateKey, the adapter uses the documented default 'value' (so { value: X } is written to feature state). When no entry matches at all, the chain falls through to the next step.

Important: when two datasets on the same source both omit stateKey, they share feature-state.value and overwrite each other. Use distinct stateKey values for each dimension to keep them independent.

stateKey examples — default vs explicit​

Omitted stateKey — adapter writes { value: X } to feature state:

{
"mapData": [
{
"mapDataId": "population",
"mapId": "cities",
"dimension": "size",
"data": [{ "geometryId": 1, "value": 100000 }]
}
]
}

resolveDimensionStateKey('size', 'cities', ...) finds the entry via dimension: 'size', reads stateKey: undefined, and uses the default 'value'. Feature state becomes { value: 100000 }. The size expression reads ['feature-state', 'value'].

Explicit stateKey — adapter writes { pop: X }:

{
"mapData": [
{
"mapDataId": "population",
"mapId": "cities",
"stateKey": "pop",
"dimension": "size",
"data": [{ "geometryId": 1, "value": 100000 }]
}
]
}

Feature state becomes { pop: 100000 }. The size expression reads ['feature-state', 'pop'].

Two datasets, no stateKey — collision:

{
"mapData": [
{
"mapDataId": "population",
"mapId": "cities",
"dimension": "size",
"data": [{ "geometryId": 1, "value": 100000 }]
},
{
"mapDataId": "density",
"mapId": "cities",
"dimension": "color",
"data": [{ "geometryId": 1, "value": 50 }]
}
]
}

Both default to stateKey: 'value'. Feature state becomes { value: 50 } (the color dataset overwrites the size dataset). Both color and size expressions read the same key — the size dimension is lost. Fix: declare distinct stateKey values on each entry.

Choropleth example​

Declare mapData in the spec, reference it on the layer with mapDataId, and connect a legend via activeLegendId.

import {
GeoVisCanvas,
GeoVisHoverTooltip,
GeoVisLegend,
GeoVisProvider,
} from '@ttoss/geovis';
import districtsGeoJSON from './districts.geojson';

const spec = {
engine: 'maplibre',
view: { center: [-46.6, -23.5], zoom: 10 },
sources: [{ id: 'districts', type: 'geojson', data: districtsGeoJSON }],
mapData: [
{
mapDataId: 'population',
mapId: 'districts', // references sources[].id
// joinKey: 'cd_district' — set when features lack a numeric .id
data: [
{ geometryId: 1, value: 87_000 },
{ geometryId: 2, value: 143_000 },
{ geometryId: 3, value: 210_000 },
],
},
],
legends: [
{
id: 'population-legend',
label: 'Population',
colorBy: {
type: 'quantitative',
property: 'value',
scale: 'threshold',
thresholds: [100_000, 200_000],
colors: ['#bfdbfe', '#3b82f6', '#1d4ed8'],
defaultColor: '#e2e8f0', // features with no joined value
},
},
],
layers: [
{
id: 'districts-layer',
sourceId: 'districts',
geometry: 'polygon',
mapDataId: 'population', // drives setFeatureState
activeLegendId: 'population-legend', // enables coloring + hover
},
],
};

const PopulationMap = () => (
<GeoVisProvider spec={spec}>
<div style={{ width: '100%', height: '500px' }}>
<GeoVisCanvas viewId="main" style={{ width: '100%', height: '100%' }} />
</div>
{/* GeoVisHoverTooltip can be placed anywhere in the tree — */}
{/* it uses position:fixed and viewport coordinates internally. */}
<GeoVisHoverTooltip />
<GeoVisLegend legendId="population-legend" />
</GeoVisProvider>
);

Joining by property instead of feature id​

When GeoJSON features do not have a numeric or string .id, use joinKey to match by a feature property instead:

mapData: [
{
mapDataId: 'population',
mapId: 'districts',
joinKey: 'cd_district', // matches feature.properties.cd_district
data: [
{ geometryId: 'CAMPO_LIMPO', value: 143_000 },
{ geometryId: 'BUTANTA', value: 87_000 },
],
},
],

Accessing data in React​

useMapData exposes the joined dataset in React without touching MapLibre:

import { useMapData } from '@ttoss/geovis';

const DataTable = () => {
const result = useMapData('population');
if (!result) return null;
const { rows } = result;
return (
<ul>
{rows.map((row) => (
<li key={row.geometryId}>
{row.geometryId}: {row.value}
</li>
))}
</ul>
);
};

Must be called inside GeoVisProvider.

Updating data at runtime​

Use applyPatch with target: 'mapData' to replace the full dataset or upsert a single row without re-mounting the spec:

const { applyPatch } = useGeoVis();

// Replace the full dataset — value must be a complete MapData object
applyPatch({
target: 'mapData',
op: 'replace',
path: 'mapData.population',
value: { mapDataId: 'population', mapId: 'states-source', data: newRows }, // MapData
});

// Upsert a single row — path: 'mapData.<mapDataId>.data.<geometryId>'
applyPatch({
target: 'mapData',
op: 'replace',
path: 'mapData.population.data.1',
value: 210_000,
});

mapType auto-configuration​

Setting mapType: 'choropleth' on the spec enables zero-config choropleth maps. The runtime auto-generates polygon + outline layers, a legend with Jenks natural breaks (for numeric data) or categorical mapping (for text data), and picks default colors from built-in palettes.

const spec = {
id: 'auto-map',
engine: 'maplibre',
mapType: 'choropleth',
sources: [{ id: 'regions', type: 'geojson', data: regionsGeoJSON }],
mapData: [
{
mapDataId: 'population',
mapId: 'regions',
data: [
{ geometryId: 'A', value: 120_000 },
{ geometryId: 'B', value: 340_000 },
],
},
],
};

No layers or legends configuration required — they are derived from the data. User-provided layers and legends are preserved and never overridden. Legend formatting (labelFormat, subtitle, reference) and custom colors (colorBy.colors) continue to work as usual.

Customizing paint on dotDensity auto-generated layers​

When mapType: 'dotDensity' is set, the runtime auto-generates a point layer. Any paint property (e.g. circleRadius) can be overridden by providing a layer that matches the auto-generated one by sourceId and geometry. The remaining fields (mapDataId, sizeBy, etc.) are injected from the resolved layer, so only three fields plus your custom paint are needed:

layers: [
{
id: 'points-dots',
sourceId: 'points', // must match the source id used in spec.sources
geometry: 'point',
paint: { circleRadius: 8 },
},
],

This merges your paint over the defaults (circleColor, circleRadius, circleStrokeColor, circleStrokeWidth), leaving unmentioned properties at their dotDensity defaults.

proportionalCircles​

Setting mapType: 'proportionalCircles' auto-generates a point layer whose circle-radius encodes the data magnitude (circle area ∝ value, via a sqrt transform) and a quantitative color legend. Minimal spec:

const spec = {
id: 'cities',
engine: 'maplibre',
mapType: 'proportionalCircles',
sources: [{ id: 'cities', type: 'geojson', data: citiesGeoJSON }],
mapData: [
{
mapDataId: 'population',
mapId: 'cities',
title: 'Total population',
data: [
{ geometryId: 1, value: 87_000 },
{ geometryId: 2, value: 143_000 },
{ geometryId: 3, value: 487_321 },
],
},
],
};

The resolver fills in, with no extra configuration:

  • A point layer with sizeBy: { range: [4, 16], transform: 'sqrt' }.
  • scaleMaxValue — the visual size ceiling. When you omit it, the resolver takes the size dataset's maximum and rounds it up to a nice round number (487 321 → 500 000) so the legend's reference circles use readable values. A value you provide explicitly is always kept.
  • A color legend with a title that names the size dimension (e.g. Circle size = Total population).
  • Compact reference labels in the size key (500k instead of 500,000). GeoVisLegend applies the compact formatter automatically for circle legends; pass formatValue to override it.

Alternative data source: propertyName​

Instead of declaring mapData entries, you can set propertyName on a layer to read values directly from GeoJSON feature properties. The resolver auto-generates circle-radius via ['get', propertyName] and computes scaleMaxValue from the inline GeoJSON data:

const spec = {
id: 'cities',
engine: 'maplibre',
mapType: 'proportionalCircles',
sources: [
{
id: 'cities',
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [-46.6, -23.5] },
properties: { total: 87_000 },
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [-43.2, -22.9] },
properties: { total: 487_321 },
},
],
},
},
],
layers: [
{
id: 'cities-layer',
sourceId: 'cities',
geometry: 'point',
propertyName: 'total',
},
],
};

propertyName and mapDataId are mutually exclusive on the same layer — the schema rejects a layer declaring both (invalid-schema), since they name two different ways of resolving the same value and setting both is always a mistake, never an intentional fallback. The propertyName path requires inline GeoJSON data (not a URL) for scaleMaxValue computation — when the source is a URL, the resolver skips the default ceiling and the adapter falls back to legend-driven sizing.

Disabling the auto-generated legend​

legendEnabled (default true) controls whether proportionalCircles auto-generates its size legend. When true, the auto-generated legend is added alongside any legends you already supply via spec.legends — it never replaces or merges into an unrelated existing legend, only into one that shares its id. Set it to false to suppress the auto-generated legend entirely, e.g. when you render your own legend UI outside of geovis; existing legends in spec.legends are kept untouched either way:

const spec = {
// ...
mapType: 'proportionalCircles',
legendEnabled: false,
};

Circle-size legend rows get extra vertical spacing as their radius grows past 10px, so large reference circles don't crowd the row below them.

Overriding circle paint​

PROPORTIONAL_CIRCLES_DEFAULTS sets the default circleOpacity, circleStrokeWidth, and circleStrokeOpacity. To override any of them (or set circleColor), add a layer to spec.layers with the same sourceId and geometry: 'point' — its paint is merged over the resolved defaults:

const spec = {
// ...
layers: [
{
id: 'my-custom-circles',
sourceId: 'cities',
geometry: 'point',
paint: { circleColor: '#2563eb', circleOpacity: 0.5 },
},
],
};

Color and size are independent​

Color and size are resolved from separate feature-state keys, so styling the color legend never changes circle sizes. For a true bivariate map, declare two datasets on the same source with distinct dimension and stateKey — see Bivariate Maps. The dimension: 'size' dataset drives the radius; the dimension: 'color' dataset drives the fill.

Inputs that are invalid or not handled​

Proportional circles encode magnitude as area, which only has meaning for non-negative quantities. The following inputs are out of scope and will not render as intended:

  • Negative values — clamp to zeroRadiusPx (invisible). A circle cannot represent a negative area. Use a diverging choropleth instead.
  • Mixed negative/positive values — only the positives are sized; negatives vanish. The result misrepresents the data — split the measure or switch map type.
  • Non-numeric / null values — coalesced to 0 and rendered invisible (never NaN). A dataset that is mostly non-numeric produces an empty size legend.
  • sizeBy.range with min >= max or min <= 0 — throws at translation time (sizeBy.range must have min < max and both > 0). Both radii must be positive and ordered.
  • scaleMaxValue <= 0 — has no usable ceiling; leave it unset and let the resolver compute it from the data.

Legend merging differs per mapType​

Each mapType has different needs for how its auto-generated legend internals interacts with a user-supplied spec.legends, so the resolver uses a different merge strategy per type:

mapTypeAuto-generates a legend?Merge strategyWhy
choroplethAlways (1 legend)mergeLegendsThe generated legend is the map's only visual encoding. A user legend with a different id almost certainly means "use my title/format for that same encoding" — so an id mismatch still grafts the resolved colorBy positionally onto the sole user legend.
dotDensityNevern/aDots have no color-by-value encoding, so there is nothing to merge. Any legend you supply in spec.legends passes through untouched.
proportionalCirclesConfigurable (legendEnabled, default true)mergeLegendsByIdOnlyThe size legend is additional, separate information (the reference-circle key) layered next to whatever legend(s) you already use for color. Grafting it positionally onto an unrelated user legend would silently overwrite that legend's own colorBy and lose the size key.

Concretely:

  • mergeLegends (choropleth, dotDensity) first tries an exact id match. If none of the user's legends share the resolved legend's id, it still fills in the missing colorBy on the user's legend positionally — there is only ever one resolved legend, so there is no ambiguity about which one it refers to.
const userLegends = [
{ id: 'custom-legend', title: 'My Legend' } // no colorBy
];

const resolvedLegends = [
{ id: 'pop-legend', title: 'Population', colorBy: { type: 'quantitative', property: 'pop', scale: 'threshold', thresholds: [100, 500], colors: ['#fee5d9', '#fb6a4a', '#a50f15'] } }
];

// --- mergeLegends (positional fallback) ---
// findMatchingResolvedLegend: id doesn't match, but resolvedLegends.length === 1
// → returns resolvedLegends[0] because it has colorBy
// Result: userLegends[0] inherits colorBy from resolved[0]
[
{ id: 'custom-legend', title: 'My Legend', colorBy: { type: 'quantitative', ... } }
// ❌ "Population" is gone — absorbed into "custom-legend"
]
  • mergeLegendsByIdOnly (proportionalCircles) only fills in colorBy when a user legend shares the resolved legend's exact id. Otherwise, the resolved size legend is appended as its own separate entry — never merged into an unrelated legend just because it happens to be positioned first. See Disabling the auto-generated legend for how legendEnabled controls whether that entry is generated at all.
// --- mergeLegendsByIdOnly (id match only) ---
// resolvedLegends.find(r => r.id === 'custom-legend') → undefined
// → userLegend kept as-is, resolved is appended at the end
[
{ id: 'custom-legend', title: 'My Legend' },
{ id: 'pop-legend', title: 'Population', colorBy: { type: 'quantitative', ... } }
// ✅ Both preserved
]

Both strategies share one invariant: a resolved legend is only ever appended once. Its id is checked against every already-merged legend before being added, so a legend the user already matched (by id or positional fallback) is never duplicated as an extra unmatched entry.

Architecture note​

resolveSpecFromMapType is called in two places: GeoVisProvider (for React context consumers) and createRuntime.update() (for the adapter). This means the resolution runs twice per spec update. Since resolveSpecFromMapType is idempotent, this is functionally correct. The duplication exists because createRuntime is a public API exported from the package and must remain self-sufficient — it cannot assume the caller already resolved the spec.

mergeResolvedLayers (which injects sizeBy/mapDataId/activeLegendId/ legends defaults into a matching user layer) always returns a new layer object rather than mutating the one in spec.layers — important because the same spec.layers array/objects are often reused across updates (e.g. setSpec((prev) => ({ ...prev, legendEnabled: false }))), and mutating them in place would leak a stale resolved field (like an embedded legends entry) into a later resolution of the same objects.

Boundary Groups​

Boundary groups let you overlay administrative boundaries (states, municipalities, sub-prefectures) on top of a base spec. Each group bundles its own GeoJSON source and line layer so you can toggle visibility without removing or re-adding sources.

Creating a group​

Use createBoundaryGroup to build a group from a URL or inline GeoJSON:

import { createBoundaryGroup } from '@ttoss/geovis';

// URL — MapLibre fetches the GeoJSON internally
const statesGroup = createBoundaryGroup({
id: 'brazil-states',
data: 'https://example.com/estados.geojson',
});

// Inline GeoJSON with custom paint
const districtsGroup = createBoundaryGroup({
id: 'sp-districts',
data: { type: 'FeatureCollection', features: [...] },
paint: { lineColor: '#ef4444', lineWidth: 2 },
});

The factory creates a single GeoJSON source and a companion line layer with sensible defaults (lineColor: '#6b7280', lineWidth: 1).

Appending and toggling (imperative)​

Three pure helpers manipulate groups on a spec without React:

FunctionPurpose
appendBoundaryGroupAppends group sources and layers to a spec (returns new object).
toggleBoundaryGroupSets visible on every layer matching the group's layer IDs.
customizeBoundaryGroupReturns a new group with overridden lineColor/lineWidth.
import { appendBoundaryGroup, toggleBoundaryGroup } from '@ttoss/geovis';

let spec = appendBoundaryGroup(baseSpec, statesGroup);
spec = toggleBoundaryGroup(spec, statesGroup, false); // hide

Toggle hook (React)​

useBoundaryToggle manages visibility state for a set of groups inside React. All groups start visible. Toggling flips layer.visible — sources are never removed or re-added, so there is no map flicker.

import {
createBoundaryGroup,
GeoVisCanvas,
GeoVisProvider,
useBoundaryToggle,
} from '@ttoss/geovis';

const statesGroup = createBoundaryGroup({
id: 'brazil-states',
data: 'https://example.com/estados.geojson',
});

const MyMap = ({ spec }) => {
const {
spec: liveSpec,
toggle,
isVisible,
} = useBoundaryToggle(spec, [statesGroup]);

return (
<GeoVisProvider spec={liveSpec}>
<GeoVisCanvas viewId="main" style={{ width: '100%', height: '400px' }} />
<button onClick={() => toggle(statesGroup)}>
{isVisible(statesGroup) ? 'Hide states' : 'Show states'}
</button>
</GeoVisProvider>
);
};

Important: pass a stable array reference for groups (module constant or useMemo). Changing the array reference re-appends all groups to the spec.

Avoiding unnecessary re-renders and refetches​

When boundary groups are used with dynamic paint overrides (e.g. colour picked from a Storybook control), customizeBoundaryGroup returns a new object on every paint change. If the new object reference is passed directly to useBoundaryToggle, the hook recomputes specWithAll and spec, which triggers runtime.update() and a full source/layer reconciliation cycle — even though the GeoJSON data URLs have not changed.

The groups array must be memoised. Wrap it with useMemo and list only the dependencies that actually change the group identity (the paint values):

const districtsGroup = React.useMemo(
() => customizeBoundaryGroup(baseDistrictsGroup, { lineColor, lineWidth }),
[lineColor, lineWidth]
);

const stateGroup = React.useMemo(
() =>
customizeBoundaryGroup(baseStateGroup, {
lineColor: stateLineColor,
lineWidth: stateLineWidth,
}),
[stateLineColor, stateLineWidth]
);

const boundaryGroups = React.useMemo(
() => [districtsGroup, stateGroup],
[districtsGroup, stateGroup]
);

Toggle effects should depend on isVisible, not on group objects. isVisible is a stable callback whose identity only changes when the hidden set changes — group object references are irrelevant:

const { spec, toggle, isVisible } = useBoundaryToggle(
specInput,
boundaryGroups
);

// Store latest group references in refs so effects always read the current paint
const districtsGroupRef = React.useRef(districtsGroup);
React.useEffect(() => {
districtsGroupRef.current = districtsGroup;
}, [districtsGroup]);

React.useEffect(() => {
if (showDistricts !== isVisible(districtsGroupRef.current))
toggle(districtsGroupRef.current);
// isVisible is the only dep that signals a visibility change;
// group object changes (paint) are read via the ref.
}, [showDistricts, toggle, isVisible]);

Note: useBoundaryToggle tracks visibility by the group's source ID (getBoundaryGroupId), not by object reference. Groups can be recreated (e.g. when paint overrides change) while preserving their visibility state.

Layer Control​

Setting spec.control declares a floating panel of layer-visibility toggles. GeoVisProvider auto-mounts a <GeoVisLayerControl> for it — you never place the component yourself, exactly like the positioned-legend and hover-tooltip overlays. The panel renders a collapsed trigger anchored to a map corner; expanding it (on hover or click) reveals one button per item, and clicking a button flips the visibility of that item's layers via dispatch({ type: 'toggle-layer' }) (validated, no source remount, no flicker).

Below a 640px viewport the panel switches to a narrow layout: it opens away from the anchored edge — above the trigger row for bottom corners, below it for top ones — and sizes itself to its item cards, so a couple of options make a card only as wide as they are. Enough options and it reaches the control's edge gaps and wraps them onto further lines, since one sideways row cannot fit a phone. The positioned legends collapse into a button beside the trigger in that layout, and the two panels take turns: opening one closes the other. Hover stops expanding the panel there whatever trigger says, because the layout targets touch.

All GeoVis map overlays — positioned legends, this layer control, and the hover tooltip — render at z-index: 1, just above the map canvas. Host-app chrome (sidebars, drawers, modals) must use a higher z-index so it always covers the map and its overlays; the reference apps anchor sidebars at z-index: 2.

const spec: VisualizationSpec = {
engine: 'maplibre',
sources: [
/* ... */
],
layers: [
{ id: 'states-line', sourceId: 'states', geometry: 'line' },
{ id: 'kitchens-pts', sourceId: 'kitchens', geometry: 'point' },
],
control: {
id: 'layers',
label: 'Camadas', // trigger text; defaults to 'Layers'
position: 'bottom-left', // reuses the legend corner vocabulary; default 'bottom-left'
trigger: 'hover', // or 'click'; default 'hover'
items: [
{ id: 'kitchens', label: 'Kitchen locations', layers: ['kitchens-pts'] },
{ id: 'states', label: 'State lines', layers: ['states-line'] },
],
},
};

LayerControl fields​

FieldTypeRequiredDescription
idstring✓Unique identifier for the panel.
itemsLayerControlItem[]✓The toggle buttons revealed when the panel is expanded.
positionLegendPositionCorner the panel is anchored to. Defaults to 'bottom-left'.
offsetnumber | { x?: number; y?: number }Distance in pixels from the anchored edges. Defaults to 40. A number applies to both edges; { x, y } offsets each axis independently (each falling back to 40) — e.g. push the control clear of a side panel horizontally without lifting it off the bottom edge. A changed offset animates, so the control slides across rather than jumping.
labelstringAccessible label / tooltip for the icon-only trigger. Defaults to 'Layers'.
iconstringIcon on the collapsed trigger, a @ttoss/react-icons name (e.g. 'lucide:layers'). Defaults to a built-in stacked-sheets glyph.
trigger'hover' | 'click'How the panel expands. 'hover' (default) also opens on click, for touch devices.
maxVisibleItemsnumberItems shown before the rest collapse behind a "Ver mais" card. Omitted (or when every item fits), all items are shown. See Long item lists.

Long item lists​

With many items the single row of cards outgrows the map. Set maxVisibleItems to show only the first items, in items order, followed by a "Ver mais" card with the hidden count (+7). Clicking it swaps the row for a larger panel, anchored in the same corner, listing every item in a grid under the control's label — up to five columns, capped at 60vh and scrolling past that; below the compact breakpoint it spans the map's width instead.

The larger panel stays open until the user closes it — its ✕ button, Escape, a click outside the control, or the trigger — even with trigger: 'hover', so a pointer drifting off it does not throw the list away. Closing it collapses the whole control; the next expansion starts from the short row again. When hidden items are on, the "Ver mais" card shows how many in an accent badge.

control: {
id: 'layers',
label: 'Camadas',
maxVisibleItems: 3, // three cards + "Ver mais"
items: [/* ten items */],
},

LayerControlItem fields​

FieldTypeRequiredDescription
idstring✓Stable identity. The on/off state is remembered by this id (see persistence below).
labelstring✓Text on the toggle button.
thumbnailstringImage (URL or data URI) filling the item's card, cropped to cover. Defaults to a built-in map preview.
layersstring[]✓Ids of spec.layers toggled together when the button is clicked.
defaultActivebooleanWhether the layers start visible the first time the item is seen. Defaults to true.

Three item states​

Each button reflects one of three states: active (its layers are shown), inactive (its layers are hidden), and disabled (none of its layers exist in the current spec — the button is greyed and non-interactive). Layer ids are matched leniently: unknown ids are ignored rather than rejected, so a single control can be reused across spec variations where a layer is only present in some of them.

Persistence across spec rebuilds​

The on/off choice is keyed by item.id, not by layer id, and re-applied whenever the spec changes. So when an application rebuilds the spec (for example, switching between map "modes"), a layer you hid stays hidden — even when the concept maps to a different layer id in the new spec (e.g. a point layer in one mode and a proportional-circle layer in another). The control must remain present across the rebuilds for this to hold; because unknown layers only disable an item (never reject the spec), keeping one control declared in every mode is the intended pattern. When a fresh spec starts a layer visible that the remembered choice says should be hidden, reconciliation hides it on the next frame — a brief flash is possible but the source is never remounted.

Spec Validation​

Use validateSpec to validate a visualization spec against the JSON schema before passing it to GeoVisProvider. It returns a GeoVisResult: a resolved status carrying the typed spec, or one of a closed set of failure statuses carrying every issue found in one pass — never only the first — so a repair loop can fix everything in one round trip:

import { validateSpec } from '@ttoss/geovis';

const result = validateSpec(rawSpec);

if (result.status !== 'resolved') {
for (const issue of result.issues) {
console.error(`[${issue.code}] ${issue.message}`, issue.repair);
}
} else {
// result.spec is fully typed as VisualizationSpec
}

Each GeoVisIssue carries a machine-readable code, a subject locating the offending field, a human message, and — only when an alternative is already known at the check site (never guessed) — a repair list of allowed-values or set-value options.

Applying Patches​

applyPatch is a low-level escape hatch, not the primary mutation API. Prefer dispatch() for anything expressible as one of its actions (toggle-layer, select-feature, set-map-data, set-filter, set-view-preset) — it targets stable spec ids instead of internal paint paths, and every call is recorded on the action log. applyPatch stays public and fully supported for the layer-visibility, mapDataId, filter, and paint-property replaces that don't yet have (or will never need) a dedicated action, and for add/remove — the same "available, not primary" role getNativeInstance() plays for direct engine access.

Use useGeoVis to access applyPatch for efficient updates without re-rendering the full spec.

Supported target values:

TargetDescription
'layer'Add, remove, or update a layer
'source'Add, remove, or update a source
'mapData'Update feature-state data bound to the map

Note: view and style changes must be applied via update(spec) (full spec replacement), not via applyPatch. Any other target, or a patch that would produce an invalid spec, is rejected before the adapter is ever called — runtime.applyPatch/runtime.update return a GeoVisResult (unsupported-patch-target for the former), and useGeoVis().result surfaces it; the map is left untouched (ADR-0001).

A SpecPatch has the shape:

type SpecPatch =
| {
target: 'layer' | 'source' | 'mapData';
op: 'replace';
path: string; // dot-separated: "layer.<layerId>.visible"
// "layer.<layerId>.paint.<camelCaseKey>"
// "mapData.<mapDataId>"
// "mapData.<mapDataId>.data.<geometryId>"
value?: unknown; // required for 'replace'
rationale?: string;
}
| {
target: 'layer' | 'source' | 'mapData';
op: 'add' | 'remove';
path?: string; // unused for layer/source add and remove ops
value?: unknown;
rationale?: string;
};

Paint property keys follow spec-level camelCase (e.g. circleOpacity, fillColor, lineWidth).

replace — update an existing paint property​

The most common operation. Updates a single paint property on a live layer without re-mounting the map.

const { applyPatch } = useGeoVis();

// Change the opacity of a circle layer via a range slider
applyPatch({
target: 'layer',
op: 'replace',
path: 'layer.points-layer.paint.circleOpacity',
value: 0.5,
});

// Change the fill color of a polygon layer
applyPatch({
target: 'layer',
op: 'replace',
path: 'layer.regions-layer.paint.fillColor',
value: '#ff0000',
});

Effect: the adapter calls setPaintProperty on the live map without re-mounting — runtime.spec is updated and effectiveSpec in context is refreshed, triggering a lightweight re-render in consumers but no map re-mount.

add — add a new layer or source to the spec​

Use add to append a new VisualizationLayer or DataSource at runtime.

applyPatch({
target: 'layer',
op: 'add',
value: { id: 'new-layer', sourceId: 'points', geometry: 'point' },
});

applyPatch({
target: 'source',
op: 'add',
value: {
id: 'new-source',
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
},
});

Effect: the layer or source is appended to runtime.spec and forwarded to the adapter. No React re-render triggered.

remove — remove a layer or source from the spec​

Pass the target id as value to remove an existing layer or source.

applyPatch({
target: 'layer',
op: 'remove',
value: 'routes-layer', // id of the layer to remove
});

applyPatch({
target: 'source',
op: 'remove',
value: 'routes-source', // id of the source to remove
});

Effect: the entry is removed from runtime.spec and the adapter is notified.

Full interactive example​

import { useGeoVis } from '@ttoss/geovis';

const LayerControls = () => {
const { applyPatch } = useGeoVis();

return (
<>
<label>
Opacity
<input
type="range"
min={0}
max={1}
step={0.1}
defaultValue={1}
onChange={(e) =>
applyPatch({
target: 'layer',
op: 'replace',
path: 'layer.points-layer.paint.circleOpacity',
value: Number(e.target.value),
})
}
/>
</label>
<button
onClick={() =>
applyPatch({
target: 'layer',
op: 'replace',
path: 'layer.points-layer.paint.circleStrokeColor',
value: '#ffffff',
})
}
>
Add stroke
</button>
<button
onClick={() =>
applyPatch({
target: 'layer',
op: 'replace',
path: 'layer.points-layer.paint.circleStrokeColor',
value: undefined, // undefined is treated as a no-op — use op:'remove' to remove a paint property
})
}
>
Remove stroke
</button>
</>
);
};

AI Action Surface (dispatch)​

runtime.dispatch(action) is the recommended way to steer a live map — a closed, typed vocabulary of semantic operations (PRD-002, ADR-0003) that validates against the current spec before compiling to the same SpecPatch/update/setView mechanisms applyPatch uses. Prefer it over hand-written SpecPatches: it targets stable ids instead of internal paint paths, rejects unknown targets with a repairable GeoVisResult, and every call — accepted or rejected — is recorded on the action log for audit.

Currently implemented (the full v1 vocabulary): toggle-layer, select-feature, set-map-data, set-filter, set-view-preset.

const { runtime } = useGeoVis();

// Flips the layer's current visibility
runtime.dispatch({ type: 'toggle-layer', layerId: 'regions-layer' });

// Or set it explicitly, with an optional audit rationale
runtime.dispatch({
type: 'toggle-layer',
layerId: 'regions-layer',
visible: false,
rationale: 'user unchecked "Regions" in the layer panel',
});

An unknown layerId is rejected before touching the adapter or the spec, with the declared layer ids as repair:

const result = runtime.dispatch({ type: 'toggle-layer', layerId: 'ghost' });
// result.status === 'mismatch'
// result.issues[0].code === 'unknown-layer-id'
// result.issues[0].repair[0].values === ['regions-layer', ...]

select-feature selects (or, with featureId: null, clears) a feature on a layer — the same runtime-level state a click on the map produces, so an AI turn and a human click are indistinguishable in the action log and the context packet:

runtime.dispatch({
type: 'select-feature',
layerId: 'regions-layer',
featureId: 'BR',
});

// Clear the selection
runtime.dispatch({
type: 'select-feature',
layerId: 'regions-layer',
featureId: null,
});

runtime.getSelection();
// { layerId: 'regions-layer', featureId: 'BR' } | null

useGeoVisClick() and <GeoVisMarker>-driven click anchors already dispatch select-feature internally — a human click and runtime.dispatch({ type: 'select-feature', ... }) produce the identical selection and action-log entry.

set-map-data rebinds which mapData entry drives a layer's styling — "swap the joined dataset". Since a mapData entry's own dimension ('color' | 'size') travels with it, picking a different entry can also swap which dimension the layer reads, without a separate field:

runtime.dispatch({
type: 'set-map-data',
layerId: 'regions-layer',
mapDataId: 'pop-2020',
rationale: 'AI switched to the 2020 census dataset',
});

Only layerId is checked here directly; whether mapDataId is a declared entry, and whether it shares the layer's source, are validated by the same pass every spec update already runs — an invalid rebind is rejected with the same unknown-map-data-id/source-scope-conflict issues (and repairs) a hand-written SpecPatch would get.

set-filter sets (or, with filter: null, clears) a declarative predicate that hides features not matching it, compiled to the engine's native filter expression. It reads feature.properties[property] directly — the same access path propertyName uses elsewhere, not the mapData-joined feature-state value:

runtime.dispatch({
type: 'set-filter',
layerId: 'regions-layer',
filter: { property: 'status', operator: 'eq', value: 'active' },
rationale: 'AI narrowed the view to active regions',
});

// Clear it
runtime.dispatch({
type: 'set-filter',
layerId: 'regions-layer',
filter: null,
});

LayerFilter supports eq / neq / gt / gte / lt / lte (scalar value) and in / not-in (array value). Filtering is gated by CapabilitySet.dataFeatures.filter per source type — declared ['geojson'] on the MapLibre adapter today; a layer whose source type isn't declared is rejected with unsupported-data-feature, the same way an unsupported source or layer geometry is.

set-view-preset moves the camera to a named position declared in spec.viewPresets — bounded to positions the application actually curated, instead of raw coordinates an AI would otherwise have to invent:

const spec = {
// ...
viewPresets: [
{
id: 'country',
label: 'Country view',
view: { center: [-51.9, -14.2], zoom: 3.5 },
},
{
id: 'capital',
label: 'Capital',
view: { center: [-47.9, -15.8], zoom: 10, pitch: 30 },
},
],
};

runtime.dispatch({
type: 'set-view-preset',
presetId: 'capital',
rationale: 'AI zoomed in on the capital',
});

Compiles to the same runtime.setView() mechanism a UI camera control already uses — no new engine code. An unknown presetId is rejected with the declared preset ids as repair. Only center/zoom/pitch/bearing are applied; view.projection isn't — setView()'s imperative camera move never supported switching projection (a pre-existing limitation, not introduced by this action); use update(spec) for that.

A preset can also say how the camera travels to it, which matters when the trip is long enough to be worth watching — the flight is what shows where the destination sits relative to where the map was:

{
id: 'capital',
view: { center: [-47.9, -15.8], zoom: 10 },
animation: { duration: 2200, curve: 1.6, essential: true },
}

duration (ms), curve (how far the camera pulls back on the way) and speed shape the flight; animate: false makes it an instant cut instead. Left out, the engine derives a flight from the distance.

essential is the one to think about: MapLibre turns a flyTo into a jump for a viewer whose system asks for reduced motion, and essential: true overrides that. It belongs on presets where the flight carries the meaning, not on every preset — it is an accessibility preference the viewer set deliberately.

The flight describes the trip, not the destination, so it is handed to the adapter and left out of spec.view, which stays a ViewState.

A camera move is applied once, by the adapter, and is never pushed back through update(). That matters for two reasons. A move that animates would otherwise be overtaken one render later by the same camera arriving declaratively, and update() applies a changed view with setCenter/setZoom — so every flight would land as an instant cut. And the adapter keeps comparing against the view the spec last declared, so an app that rebuilds its spec from state and re-declares the same spec.view does not yank the camera back to it on every rebuild. useGeoVis().spec still reads the moved camera; only the push is skipped.

spec.view is therefore the camera the app declares — the first paint, and any later framing it changes deliberately — while setView and the actions compiling to it are how the camera moves in between.

fit-feature frames one feature instead of going to a position, which is what an extent asks for — how close the camera ends up is the size of the thing, not a number chosen in advance:

runtime.dispatch({
type: 'fit-feature',
layerId: 'municipios',
featureId: 3550308,
animation: { duration: 2400, essential: true },
});

Like every other action it carries no geometry: featureId is the same stable id mapData rows, clicks and select-feature already key on, and the bounds are read off the source the layer draws — addressed by the feature's own id, or by the mapData joinKey when there is one. estimateMaxZoom caps how close the result may come, so a small feature is framed with its surroundings rather than filling the screen with one shape.

It compiles to setView({ bounds, padding, maxZoom }), which SetViewOptions also accepts directly. bounds wins over center/zoom — the two answer the same question and the box is the more specific answer — and the flight fields apply either way, since fitBounds is a flyTo that works out its own destination. A bounds fit leaves spec.view alone: where it lands is the engine's answer to the box, known only once the camera settles.

A source with no client-side geometry — URL-referenced or tiled — has no box to compute, and is rejected with unknown-feature-id rather than silently framing nothing; the data has to be fetched first, the way auto-fit does it.

Pairing it with select-feature is what turns a search into a result the viewer can see: the layer's selectedPaint draws a companion outline wherever feature-state.selected is set, so one dispatch frames the shape and the other marks it.

Action log​

runtime.getActionLog();
// ReadonlyArray<{ action: GeoVisAction; result: GeoVisResult; timestamp: number }>

Every dispatched action is recorded, accepted or rejected, with its rationale preserved — the substrate for surfacing "why did the map change" in a workspace UI. Undo/redo itself is not built on top of this log yet; that's a "Should" item left for a workspace-level consumer (e.g. @ttoss/geovis-workspace) to derive.

Context packet​

runtime.getContextPacket() returns a versioned, read-only, metadata-only summary of the current map (ADR-0004) — never GeoJSON geometry, mapData rows, or full color/threshold lists. It names the same stable ids dispatch() accepts, so an AI (or any consumer) can decide what to do next without reading the full spec:

runtime.getContextPacket();
// {
// schemaVersion: 1,
// mapType: 'choropleth',
// sources: [{ id: 'regions-source', type: 'geojson' }],
// layers: [{
// id: 'regions-layer', geometry: 'polygon', visible: true,
// mapDataId: 'pop-2020', dimension: 'color',
// filter: { property: 'status', operator: 'eq', value: 'active' },
// }],
// legends: [{ id: 'pop-legend', scaleKind: 'threshold', domain: [10, 90], unit: 'inhabitants' }],
// viewPresets: [{ id: 'capital', label: 'Capital' }],
// selection: { layerId: 'regions-layer', featureId: 'BR' },
// allowedActions: ['toggle-layer', 'select-feature', 'set-map-data', 'set-filter', 'set-view-preset'],
// warnings: [],
// lastResult: { status: 'resolved', spec, warnings: [] },
// }

allowedActions is the vocabulary above filtered to what the current spec and active adapter actually support (e.g. toggle-layer only appears once the spec has at least one layer; set-filter only appears once the adapter declares filter support for a source type present in the spec; set-view-preset only appears once the spec declares at least one entry in viewPresets). viewPresets in the packet lists only id/label — never the presets' raw view camera values.

Proportional Symbols (sizeBy)​

sizeBy maps a numeric mapData property to circle-radius via MapLibre expressions, enabling bivariate visualization (color + size). Configure it on point layers to represent data magnitude through symbol size.

Basic usage​

const spec = {
id: 'cities-map',
engine: 'maplibre',
sources: [{ id: 'cities', type: 'geojson', data: citiesGeoJSON }],
mapData: [
{
mapDataId: 'population',
mapId: 'cities',
data: [
{ geometryId: 1, value: 87_000 },
{ geometryId: 2, value: 143_000 },
{ geometryId: 3, value: 210_000 },
],
},
],
legends: [
{
id: 'pop-legend',
colorBy: {
type: 'quantitative',
property: 'value',
scale: 'threshold',
thresholds: [100_000, 200_000],
colors: ['#fee5d9', '#fcae91', '#fb6a4a', '#cb181d'],
},
},
],
layers: [
{
id: 'cities-points',
sourceId: 'cities',
geometry: 'point',
mapDataId: 'population',
activeLegendId: 'pop-legend',
sizeBy: {
range: [3, 20], // [minRadius, maxRadius] in pixels
},
},
],
};

SizeBy configuration​

FieldTypeRequiredDescription
range[number, number]✓Output radius range [minRadius, maxRadius] in pixels. Both must be > 0.
mode'continuous' | 'stepped'Interpolation mode. Default: 'continuous'.
thresholdsnumber[]Explicit break points for stepped mode. When omitted, inherits from legend.
transform'linear' | 'sqrt'Radius transform. 'sqrt' makes circle area proportional to the value.

Modes​

Continuous (mode: 'continuous' or omitted): Each value produces a different radius via linear interpolation between the data bounds and the pixel range.

Stepped (mode: 'stepped'): Values are grouped into bins; each bin receives a fixed radius. Thresholds can be explicit or inherited from the active legend's colorBy.thresholds.

Range × Transform reference​

The table below shows how different range values behave under linear and sqrt transforms, assuming a data range of 0–100 000.

rangeTransformradius@0radius@50kradius@100kBehavior
[4, 20]linear41220Balanced; mid-value gets 60 % of max radius
[4, 20]sqrt415.320Mid-value gets 75 % of max radius; area ∝ value
[2, 12]linear2712Subtle; good when size is secondary to color
[2, 12]sqrt29.112Compact; sqrt prevents small values from disappearing
[8, 32]linear82032Bold; large circles dominate the map
[8, 32]sqrt825.032Large spread; small values still visible at floor
[1, 40]linear120.540Extreme spread; small dots vs huge circles
[1, 40]sqrt128.640Max contrast; area-proportional across full range

Key differences:

  • Linear — radius = lerp(value, dataMin, dataMax) → [min, max]. Circle area grows faster than the value at the high end (area ∝ radius²), so large values appear disproportionately bigger.
  • Sqrt — radius = lerp(sqrt(value), sqrt(dataMin), sqrt(dataMax)) → [min, max]. Both the input value and the data bounds are transformed to sqrt space, so output radii always stay within [minRadius, maxRadius] while circle area is proportional to the value.
  • At value = dataMin, both linear and sqrt produce minRadius (the minimum circle remains visible).
  • sqrt is only available in mode: 'continuous' — it is not allowed in stepped mode.

How sqrt guarantees output stays in [minRadius, maxRadius]​

The sqrt transform applies to both the input value and the interpolation stops (data bounds), keeping the entire expression in sqrt space:

interpolate(linear, sqrt(value), sqrt(dataMin) → minRadius, sqrt(dataMax) → maxRadius)

Because sqrt is monotonically increasing and sqrt(dataMin) ≤ sqrt(value) ≤ sqrt(dataMax) when dataMin ≤ value ≤ dataMax, the interpolated output is always in [minRadius, maxRadius]. The data bounds passed to interpolate are Math.sqrt(dataMin) and Math.sqrt(dataMax), not the raw values — this is what prevents radii from collapsing to minRadius when the raw data range is large (e.g. sqrt(50_000) ≈ 223 would fall far below a raw stop at 50_000).

Bivariate Maps​

Set dimension on each MapData entry to declare which visual dimension it drives. The adapter auto-discovers which dataset provides color vs. size — no layer-level references needed.

Example​

{
"mapData": [
{
"mapDataId": "population",
"mapId": "cities",
"stateKey": "pop",
"dimension": "size",
"data": [{ "geometryId": 1, "value": 100000 }]
},
{
"mapDataId": "density",
"mapId": "cities",
"stateKey": "density",
"dimension": "color",
"data": [{ "geometryId": 1, "value": 50 }]
}
],
"layers": [
{
"id": "cities",
"geometry": "point",
"sourceId": "cities",
"activeLegendId": "pop-legend",
"sizeBy": { "range": [3, 20] }
}
]
}

The color expression reads ['feature-state', 'density'] while the size expression reads ['feature-state', 'pop'], giving each dimension independent data. Two datasets on the same source must use different dimension values.

API​

GeoVisProvider​

Provides a GeoVis runtime context for child components. Resolves the appropriate engine adapter based on spec.engine, initializes the runtime, and keeps it in sync with spec updates.

PropTypeDescription
specVisualizationSpecThe visualization spec (memoize with useMemo to avoid redundant updates).
childrenReact.ReactNodeChild components, typically GeoVisCanvas.

Error handling: if the engine adapter throws during initialization, GeoVisProvider re-throws the error. Wrap it with an <ErrorBoundary> to catch adapter errors and display a fallback UI instead of a blank screen.

<ErrorBoundary fallback={<p>Map failed to load.</p>}>
<GeoVisProvider spec={spec}>
<GeoVisCanvas viewId="main" style={{ width: '100%', height: '400px' }} />
</GeoVisProvider>
</ErrorBoundary>

GeoVisCanvas​

Renders the map inside a div container mounted by the active engine. Must be used inside GeoVisProvider.

PropTypeDescription
viewIdstringUnique identifier for this canvas view.
styleReact.CSSPropertiesOptional inline styles for the container element.
classNamestringOptional CSS class name for the container element.

useGeoVis​

Returns the current GeoVisContextValue. Must be called inside GeoVisProvider.

Return valueTypeDescription
specVisualizationSpecLast spec the runtime accepted. Unchanged while result is a failure — nothing renders on failure (ADR-0001).
applyPatch(patch: SpecPatch) => voidDispatch a patch without re-mounting the map.
setView(options: SetViewOptions) => voidImperatively move the camera (center, zoom, pitch, bearing).
resultGeoVisResultLatest validation outcome (schema, references, capabilities) plus cartography policy warnings. See Spec Validation.
runtimeGeoVisRuntime | nullLow-level runtime instance. Avoid direct access in app code.

SetViewOptions fields: center?: LngLat, zoom?: number, pitch?: number, bearing?: number, animate?: boolean (defaults true — smooth flyTo).

const { setView } = useGeoVis();

// Fly to a new center/zoom
setView({ center: [-46.6, -23.5], zoom: 12 });

// Instant jump (no animation)
setView({ center: [-43.1, -22.9], zoom: 10, animate: false });

Before the map has mounted, setView has no effect on the rendered map but still updates spec.view in the runtime. The camera will not move until a view is mounted.

useGeoVisHover​

Returns the live MapHoverInfo | null snapshot for the feature currently hovered on a polygon layer with activeLegendId. Lives in a dedicated context so high-frequency hover updates do not re-render useGeoVis() consumers. Must be called inside GeoVisProvider.

useGeoVisClick​

Returns the last clicked feature on the active map as MapClickInfo | null (null when no feature is selected). Unlike hover state, click state persists until dismissed — it clears to null when the user presses Escape or clicks outside a tracked layer/feature.

Tracking: useGeoVisClick only populates when the user clicks a feature on a layer configured with activeLegendId and the feature has an id (numeric or string). Clicks on untracked layers or features without ids are treated as outside-clicks and clear the selection.

Lives in a dedicated context (GeoVisClickContext) so click-state changes do not re-render useGeoVis() consumers. Must be called inside GeoVisProvider.

Internally, every click dispatches select-feature on the runtime (see AI Action Surface) — runtime.getSelection() and getContextPacket().selection reflect the same selection this hook reports, and the action shows up in getActionLog() like any other dispatched action.

MapClickInfo fields​

FieldTypeDescription
layerIdstringLayer id that received the click.
sourceIdstringSource id backing the layer.
featureIdstring | numberClicked feature's id (typically geometryId from mapData).
valuenumber | string | nullfeature-state.value at click time; same semantics as MapHoverInfo.value.
lngLat[number, number]Geographic coordinates [lng, lat] of the click. Useful for anchoring popups.
featureLngLat[number, number]Feature's own position: from clickAnchor.latKey/lngKey properties when set, else the geometry centroid for Point features; undefined for lines/polygons. Prefer featureLngLat ?? lngLat when anchoring a marker.
point{ x: number; y: number }Canvas-relative pixel coordinates of the click.

Example — center map on clicked feature​

import { useGeoVis, useGeoVisClick } from '@ttoss/geovis';
import { useEffect } from 'react';

const CenterOnClick = () => {
const { setView } = useGeoVis();
const click = useGeoVisClick();

useEffect(() => {
if (click) {
setView({ center: click.lngLat, zoom: 14 });
}
}, [click, setView]);

return null;
};

// Place <CenterOnClick /> anywhere inside <GeoVisProvider>

useDismissGeoVisClick​

Returns a stable () => void that clears the current click selection — the exact same reset Escape or clicking outside a tracked layer/feature already trigger (feature-state included). Use it to wire a custom dismiss control (e.g. a close button on a selection panel) without re-implementing the reset, so all three dismiss paths stay in sync. Must be called inside GeoVisProvider.

import { useDismissGeoVisClick, useGeoVisClick } from '@ttoss/geovis';

const SelectionPanel = () => {
const click = useGeoVisClick();
const dismiss = useDismissGeoVisClick();

if (!click) return null;

return (
<div>
<button onClick={dismiss}>Close</button>
<p>{click.featureId}</p>
</div>
);
};

useMapData​

Returns indexed map data for a mapDataId as { mapDataId, mapId, joinKey, values, rows }. Must be called inside GeoVisProvider.

GeoVisLegend​

Renders a static, non-interactive legend resolved from the active spec. Resolution looks up legendId in spec.legends first, then in each layer.legends. Categorical legends emit one swatch per mapping entry (or a single fallback swatch when mapping is empty, mirroring the adapter's ['literal', fallbackColor] paint output). Quantitative legends emit one swatch per breaks[] bin and use the same fallback chain as the adapter (defaultColor ?? palette[0] ?? DEFAULT_MISSING_COLOR). Must be rendered inside GeoVisProvider.

PropTypeDescription
legendIdstringId of the legend entry to render.
breaksnumber[]Externally computed thresholds for quantitative legends. Optional.
formatValue(value: number) => stringFormatter for quantitative break labels. Defaults to String(value).
classNamestringOptional CSS class for the legend container.

GeoVisHoverTooltip​

Renders a floating tooltip over the map whenever the user hovers a polygon feature on a layer that has an activeLegendId. Uses position: fixed and viewport-relative coordinates internally, so it can be placed anywhere in the React tree — no shared container with <GeoVisCanvas> required. Internally subscribes to GeoVisHoverContext via useGeoVisHover(), so high-frequency hover updates do not re-render useGeoVis() consumers.

PropTypeDescription
render(info: MapHoverInfo) => React.ReactNodeCustom tooltip renderer. When omitted, a default two-line layout is used.
formatValue(value: number | string) => stringFormatter applied to info.value when no render prop is provided.
classNamestringOptional CSS class for the tooltip container.
styleReact.CSSPropertiesOptional inline style overrides merged on top of the default tooltip style.
offset{ x: number; y: number }Pixel offset from the cursor. Defaults to { x: 12, y: 12 }.
emptyValueLabelstringLabel shown when info.value is null (no mapData for the feature). Defaults to 'No data'.

Spec-driven hover tooltip​

Instead of placing <GeoVisHoverTooltip> in the tree, declare the tooltip inline on the layer via hoverTooltip. <GeoVisProvider> then renders the tooltip automatically for features hovered on that layer — picking the config of whichever layer is under the cursor, so different layers can have different tooltips. An empty object opts in to the default layout (Feature #<id> + value). The config (HoverTooltipConfig) mirrors the component props above (minus children).

import type { HoverTooltipConfig } from '@ttoss/geovis';

const spec = {
// ...sources, mapData, legends as in the choropleth example
layers: [
{
id: 'districts-layer',
sourceId: 'districts',
geometry: 'polygon',
mapDataId: 'population',
activeLegendId: 'population-legend',
hoverTooltip: {
formatValue: (v) => new Intl.NumberFormat('pt-BR').format(Number(v)),
render: (info) => <strong>Feature #{String(info.featureId)}</strong>,
},
},
],
};

// No <GeoVisHoverTooltip> needed — the provider renders it from the spec.
const Map = () => (
<GeoVisProvider spec={spec}>
<GeoVisCanvas viewId="main" />
</GeoVisProvider>
);

Because render holds a function, a spec carrying hoverTooltip is no longer JSON-serializable. This is intended for specs built in the frontend; if your specs come from a serialized source, keep using the <GeoVisHoverTooltip> component instead.

Do not use both at once for the same layer. If you declare layer.hoverTooltip and also mount <GeoVisHoverTooltip> manually, two tooltips render on top of each other. Pick one approach per layer: the spec-driven hoverTooltip (provider renders it) or the manual component.

GeoVisMarker​

Renders a DOM-based click marker anchored to the last clicked feature. It is the React-component alternative to the spec-driven layer.clickAnchor field.

When to use <GeoVisMarker> vs layer.clickAnchor:

ApproachBest for
layer.clickAnchor (spec-driven)Sprite icon rendered entirely by MapLibre as a companion symbol layer. Zero React overhead; serialisable in JSON specs.
<GeoVisMarker>Custom HTML/React content anchored via a maplibregl.Marker. Use when you need arbitrary DOM elements (e.g. a styled callout, rich tooltip, or SVG pin).

Must be rendered inside GeoVisProvider.

PropTypeDescription
childrenReact.ReactNodeReact content rendered inside the marker container. When provided, color and element are ignored.
classNamestringCSS class applied to a wrapper div inside the marker container (portal content). Only used when children is provided.
colorstringAccent colour for the built-in SVG pin. Used when neither children nor element is given.
elementHTMLElementPre-existing DOM element passed directly to maplibregl.Marker. Ignored when children is provided.
offset[number, number]Pixel offset [x, y] applied to the DOM marker.
import { GeoVisCanvas, GeoVisMarker, GeoVisProvider } from '@ttoss/geovis';

<GeoVisProvider spec={spec}>
<GeoVisCanvas />
{/* Marker anchors to the clicked feature automatically */}
<GeoVisMarker>
<div className="my-pin">📍</div>
</GeoVisMarker>
</GeoVisProvider>;

createBoundaryGroup​

Creates a BoundaryGroup containing a single GeoJSON source and a companion line layer.

ParamTypeRequiredDescription
idstring✓Source ID — referenced by the layer's sourceId.
datastring | GeoJSONObject✓Inline GeoJSON object or a URL string that MapLibre will fetch.
layerIdstringLayer ID. Defaults to ${id}-line.
paint{ lineColor?: string; lineWidth?: number }Line paint overrides. Defaults: lineColor '#6b7280', lineWidth 1.

Returns a BoundaryGroup ready for appendBoundaryGroup, toggleBoundaryGroup, or useBoundaryToggle.

appendBoundaryGroup​

Merges a BoundaryGroup into a VisualizationSpec. Appends the group's sources and layers to the spec's arrays. Returns a new spec object — the original is not mutated.

ParamTypeDescription
specVisualizationSpecThe base spec (without the group).
groupBoundaryGroupThe boundary group to append.

toggleBoundaryGroup​

Sets the visible flag on every layer in spec whose id matches any layer ID in group. When visible is true, the property is omitted (default-visible). When false, it is explicitly set. Returns a new spec.

ParamTypeDescription
specVisualizationSpecThe spec containing the layers to toggle.
groupBoundaryGroupThe boundary group whose layers should be toggled.
visiblebooleantrue to show, false to hide.

customizeBoundaryGroup​

Returns a new BoundaryGroup with overridden paint properties on every line layer. Non-line layers are returned unchanged.

ParamTypeDescription
groupBoundaryGroupThe boundary group to customize.
overrides{ lineColor?: string; lineWidth?: number }Partial paint properties to apply.

useBoundaryToggle​

React hook that manages visibility state for a set of BoundaryGroups over a base spec. All groups start visible. The hook appends groups once and then drives layer.visible via toggleBoundaryGroup — sources are never removed or re-added, so there is no map flicker.

Must be called inside GeoVisProvider (or with a spec that will be passed to one).

ParamTypeDescription
baseSpecVisualizationSpecSpec without any boundary groups.
groupsReadonlyArray<BoundaryGroup>Ordered list of groups to manage. Must be a stable reference (module constant or useMemo).

Returns BoundaryToggleResult:

FieldTypeDescription
specVisualizationSpecSpec with all groups appended and visibility synchronized.
toggle(group: BoundaryGroup) => voidToggles the visibility of the specified group.
isVisible(group: BoundaryGroup) => booleanReturns true when the group is visible.

validateSpec​

Validates a plain object against the @ttoss/geovis JSON schema and cross-field referential rules, aggregating every issue found in one pass.

function validateSpec(
input: unknown,
capabilities?: CapabilitySet
): GeoVisResult;

Returns a GeoVisResult: { status: 'resolved', spec: VisualizationSpec, warnings: GeoVisIssue[] } or { status: 'invalid' | 'mismatch' | 'unsupported' | 'insufficient-data' | 'needs-clarification', issues: GeoVisIssue[] }. insufficient-data and needs-clarification are reserved for future checks (no v1 check produces them yet).

capabilities is optional so validateSpec stays usable standalone (CI, authoring tools) without an adapter. GeoVisProvider/createRuntime always pass the active adapter's getCapabilities(); without it, only the hardcoded default (feature-state joining restricted to geojson) is enforced.

Each GeoVisIssue is { code, subject: { path, id? }, message, repair? }:

codeFailure statusMeaning
invalid-schemainvalidvalue fails the JSON Schema
invalid-schema-versioninvalidschemaVersion is declared but doesn't match SPEC_SCHEMA_VERSION
invalid-threshold-orderinvalidlegend/sizeBy thresholds not strictly ascending
invalid-threshold-valueinvalidnon-finite threshold value
invalid-size-rangeinvalidsizeBy.range not finite, or min >= max, or min <= 0
invalid-size-modeinvalidstepped sizeBy without thresholds or an active threshold legend
duplicate-map-data-idmismatchnon-unique mapData.mapDataId
unknown-map-data-idmismatchlayer references an undeclared mapDataId
unknown-sourcemismatchlayer or mapData references an undeclared source
source-scope-conflictmismatchlayer's sourceId doesn't match its mapDataId's source
duplicate-dimensionmismatchtwo mapData entries claim the same dimension on one source
state-key-collisionmismatchdimensioned mapData entries share a stateKey
unsupported-source-typeunsupportedsource type isn't feature-state-capable, or isn't declared by the active adapter
unsupported-layer-typeunsupportedlayer geometry isn't declared by the active adapter
unsupported-view-featureunsupportedview.pitch/view.bearing set but not declared by the active adapter
unsupported-engineunsupportedspec.engine doesn't match the active adapter's declared CapabilitySet.engine
unsupported-patch-targetunsupportedapplyPatch called with a target other than layer/source/mapData
missing-source-layermismatchlayer mounted on a vector-tiles source without declaring sourceLayer
missing-map-data-for-map-typemismatchmapType is set but no mapData entry maps to a declared source
policy-violationwarningcartography policy violation (never blocks rendering — see GeoVisResult.resolved.warnings)

repair is present only when the check already has the correct alternative in hand (e.g. the declared mapDataId/source ids, the adapter's declared capability list, or the other side of a scope mismatch) — never an invented or guessed value. Its entries are { kind: 'allowed-values', path, values } or { kind: 'set-value', path, value, label? }.

Two related checks are deliberately not implemented, pending a product decision: (1) requiring a legend to cover "the painted variable" whenever mapType is set — VisualizationSpec also allows manual paint via ['get', ...] expressions with no mapType set, and there is no general way to detect "this paint expression encodes a variable" without parsing paint expressions; (2) rejecting a manual legend that duplicates the proportionalCircles auto-generated size legend for the same dataset ("double-legend") — there is no precise definition yet of "same variable" across a manual and an auto-generated legend. Both would need a defined heuristic before they can be added as GeoVisIssueCodes.

Capabilities (CapabilitySet)​

EngineAdapter.getCapabilities() returns a structured, introspectable tree instead of the four dead booleans GeoVis started with:

interface CapabilitySet {
engine: EngineAdapter['id'];
sourceTypes: DataSource['type'][];
layerGeometries: GeoVisGeometryType[];
dataFeatures: { featureState: DataSource['type'][] };
viewFeatures: { pitch: boolean; bearing: boolean };
}

Declared means tested: an entry is only listed if the package's test suite (or an official fixture) actually exercises it — an untested capability is indistinguishable from a hallucinated one. The MapLibre adapter currently declares:

CategoryDeclared
engine'maplibre'
sourceTypesgeojson, vector-tiles, raster-tiles, raster-dem, image, video
layerGeometriespolygon, line, point, symbol, heatmap, raster
dataFeatures.featureStategeojson only — mapData/sizeBy depend on stable per-feature ids
viewFeatures.pitch/.bearingboth true — genuinely applied to the camera (applySetView)

validateSpec/createRuntime reject anything the spec requires but the active adapter doesn't declare, before mount — a spec requiring an unsupported capability never reaches the engine. This includes engine itself: unsupported-engine fires when spec.engine doesn't match CapabilitySet.engine — in practice this only happens when validating a spec against a different adapter's capabilities than the one it targets, since v1 has exactly one adapter (maplibre) and the schema only allows that engine value today.

Legend Type Surface​

VisualizationLayer exposes optional legends and activeLegendId fields, and VisualizationSpec exposes optional legends for shared legend registries. colorBy lives on LegendSpec (not on the layer), and the color and legend types are part of the public spec/types contract, so consumers can type legend-aware specs without reaching into internal files.

For more on product development principles that guide our approach, see Product Development Principles.