Map visualizations
Ask Chat to plot geo data on an interactive map — wiring ontology bindings with lat/lng properties to the built-in map block.
Chat can answer geospatial questions with an inline interactive map — markers rendered on a MapLibre GL / OpenStreetMap canvas, with a per-layer toggle and click-to-popup.
Map blocks require the ontology to have at least one object type with latitude and longitude float properties, backed by a silver_table binding to a live data source. See Authoring: Ontologies for how to declare them.
How it works
When Chat calls the query_map tool in response to a geospatial question (e.g. "show the drones and military aircraft near the eastern flank"), it:
- Queries the ontology for the requested object types through the platform's geo query route.
- Normalizes each instance to
{ lat, lng, label, properties }. - Builds a
mapcontent block with one layer per asset category. - Streams the block to the chat panel, where the map renders inline with layer toggles.
No map configuration is required from the operator — the tool picks up whatever latitude/longitude properties exist on the object type and auto-fits the viewport to the data.
Triggering a map
Ask Chat naturally. Phrases that reliably trigger the map tool include:
- "show me the drones and military aircraft near the eastern flank on a map"
- "plot all aircraft positions over the last 15 minutes"
- "map the drone activity in the area of interest"
The tool selects which layers to show based on the question — you can also ask for a single layer ("just the drones"). Your words are matched against the ontology's object types, so you never need exact type slugs: "drones" finds both the Drone entity and its DroneObservation positions, "bases" finds MilitaryBase, and the answer states how each word was matched. Asking for kinds of things plots only those layers; asking for "everything" plots every mappable type.
The map content block
A map block has this shape:
{
"type": "map",
"title": "Airborne activity — Suwałki Gap AOI",
"center": { "lat": 54.1, "lng": 23.0 },
"zoom": 6,
"layers": [
{
"id": "drones",
"label": "Drones",
"color": "#ffb107",
"markers": [
{ "id": "d1", "lat": 54.3, "lng": 23.2, "label": "UAV-ALPHA", "description": "Bayraktar TB2 · friendly" }
]
},
{
"id": "aircraft",
"label": "Aircraft",
"color": "#0069cc",
"markers": [
{ "id": "a1", "lat": 54.0, "lng": 23.5, "label": "NATO01", "description": "30000 ft" }
]
}
]
}The MapView component renders this block. Its props are:
| Prop | Type | Description |
|---|---|---|
layers | MapLayer[] | Required. Each layer has id, label, color, and markers. |
center | { lat, lng } | Optional. If omitted, the viewport auto-fits to the markers. |
zoom | number | Default 6. Initial zoom level (ignored when auto-fitting). |
height | number | string | Default 420. Canvas height in pixels or a CSS value. |
A marker may also carry two optional objects, copied unchanged from the ontology feature it was built from. A marker without them has neither key.
| Marker field | Shape | Meaning |
|---|---|---|
track | { headingDeg?, speedMps?, observedAt? } | Course over ground in degrees clockwise from true north, speed over ground in metres per second, and the ISO-8601 time the fix was observed at source. |
sensor | { azimuthDeg, fieldOfViewDeg, rangeMeters } | The sector a sensor on the object covers: centre bearing, full angular width in degrees, and range in metres. |
The tactical picture
When markers carry a track or a sensor, the map draws them as a tactical picture. A layer of
plain markers looks exactly as before.
| What you see | Drawn when | Meaning |
|---|---|---|
| Direction-of-movement staff | track.headingDeg is set and the track is not stopped | A line from the symbol along the heading. It is longer for faster tracks: short below about 30 kn (or when the speed is unknown), medium below about 195 kn, long above. |
| Motion cone | track has both a heading and a speed above zero | A translucent wedge in the layer colour covering where the track could be within the next five minutes. It widens by one degree for every ten seconds the fix ages, up to 40° either side. |
| Sensor footprint | sensor is set | A dashed, lightly filled sector: the field of view out to its range. |
| Stale track | the fix is older than two minutes, or observedAt is missing or unreadable | The symbol and label fade and the label shows the age, for example · 3m ago, or · time unknown. A fix with no observation time is never shown as current. |
| Trail | the same marker arrives at new positions (live data) | A line through its last 30 positions, fading from the newest to the oldest. |
| Range rings | the marker is on the focus layer, or you chose Show range rings in its popup | Dashed rings at 5, 10 and 20 km, measured on the WGS84 ellipsoid. |
A key for these styles appears under the layer toggles whenever a layer carries tracks or sensors. The map also shows a metric scale bar and, while the pointer is over it, the MGRS grid reference under the cursor. That reference is never more precise than one screen pixel at the current zoom.
Staleness and cone width are re-evaluated once a second, so a track fades on its own when its feed stops. A track value the map cannot draw, such as a zero-width field of view or a latitude beyond 90°, is left off the map; the rest of the map still renders.
Import MapView from @scrydon/ui/map-viewer:
import { MapView } from '@scrydon/ui/map-viewer'
import type { MapLayer } from '@scrydon/ui/map-viewer'Wiring geo data through the ontology
Map blocks are powered by data that flows through the platform's ontology pipeline. The full wiring:
Data source → StarRocks table → silver_table binding → ontology object type
↓
lat/lng properties projected
↓
query_map tool → map blockDefine a data source that writes rows with latitude and longitude columns. The logical table name (e.g. drone_observation) must match byte-for-byte between the source and the binding.
// In a pack (declarative JSON source) or in-platform (code source with produce())
defineDataSource({
kind: 'table',
id: 'my-asset-tracker',
vendor: 'my-vendor',
scope: 'org',
table: {
name: 'asset_position',
columns: [
{ name: 'assetId', dataType: 'string', isPrimaryKey: true },
{ name: 'latitude', dataType: 'decimal' },
{ name: 'longitude', dataType: 'decimal' },
{ name: 'observedAt', dataType: 'timestamp', isPrimaryKey: true },
],
primaryKey: ['assetId', 'observedAt'],
timestampColumn: 'observedAt',
},
ingest: { mode: 'poll', intervalSec: 60, writeMode: 'append' },
})Declare the object type with latitude and longitude float properties in your ontology manifest.
defineObjectType({
slug: 'AssetPosition',
displayName: 'Asset position',
kind: 'transactional',
properties: [
defineProperty({ slug: 'assetId', displayName: 'Asset ID', scalarType: 'string', cardinality: 'required' }),
defineProperty({ slug: 'latitude', displayName: 'Latitude', scalarType: 'float', cardinality: 'required' }),
defineProperty({ slug: 'longitude', displayName: 'Longitude', scalarType: 'float', cardinality: 'required' }),
defineProperty({ slug: 'observedAt', displayName: 'Observed at', scalarType: 'timestamp', cardinality: 'required' }),
],
})Add a silver_table binding that maps the table columns to your object type's properties. The source.table value must match the data source's table.name.
defineBinding({
objectTypeSlug: 'AssetPosition',
kind: 'silver_table',
source: { table: 'asset_position' },
identitySpec: { identityColumns: ['assetId', 'observedAt'] },
columnMap: {
assetId: 'assetId',
latitude: 'latitude',
longitude: 'longitude',
observedAt: 'observedAt',
},
})Add identity rules so instances de-duplicate correctly.
defineIdentityRule({
objectTypeSlug: 'AssetPosition',
deterministicKeys: ['assetId', 'observedAt'],
})Build and install the pack. Once installed, ask Chat to plot the data:
"Show AssetPosition objects on a map."
Chat resolves the ontology binding, projects the lat/lng properties, and renders a map block inline.
Property name conventions
The geo query route resolves coordinates with a multi-name fallback. Use standard names to ensure automatic resolution:
| Coordinate | Accepted property slugs |
|---|---|
| Latitude | latitude, lat |
| Longitude | longitude, lng, lon |
The first matching finite float in the priority order above is used. Properties outside this list are ignored for coordinate resolution (they still appear in marker popups via properties).
Use callsign, droneId, registration, or icao24 as the primary display label — these are resolved first when building the marker label. Any other string property falls back to the instance id.
Using MapView directly
If you are building a custom React surface (e.g. an analytics dashboard or a pack-bundled micro-app), import MapView directly from @scrydon/ui/map-viewer. The component is client-only — it must not be server-rendered.
import { MapView, type MapLayer } from '@scrydon/ui/map-viewer'
const layers: MapLayer[] = [
{
id: 'assets',
label: 'My assets',
color: '#0069cc',
markers: features.map((f) => ({
id: f.id,
lat: f.lat,
lng: f.lng,
label: f.label,
description: f.description,
})),
},
]
return <MapView layers={layers} center={{ lat: 54.1, lng: 23.0 }} zoom={6} height={480} />Add @import "maplibre-gl/dist/maplibre-gl.css" to your app's root stylesheet. Without it the map canvas renders but navigation controls and markers may not display correctly.
The MapView component:
- Uses the CARTO Positron and Dark Matter basemaps, following the app's light or dark theme — no API key required.
- Frames the viewport on the visible markers when
centeris omitted — once, on the first data it receives. Later updates (live positions) never move the camera, so it stays where the operator left it. - Renders a layer toggle bar at the top of the card for multi-layer datasets.
- Cleans up the MapLibre instance on unmount.
Line-geometry layers (routes)
In addition to point markers, MapView can render polyline routes —
useful for cable routes, transit corridors, or any linear feature. A layer may
carry both markers and lines simultaneously; the toggle hides both.
MapLayer.lines
Add a lines array to any layer. Each entry is a MapBlockLine:
import { MapView, type MapLayer, type MapBlockLine } from '@scrydon/ui/map-viewer'
const layers: MapLayer[] = [
{
id: 'cables',
label: 'Submarine cables',
color: '#e84040',
markers: [], // anchor points, if any
lines: [
{
id: 'cable-1',
label: 'North Atlantic Express',
path: [[-73.9, 40.7], [-20.1, 64.1], [-6.3, 53.3]], // [lng, lat] pairs
description: 'Trans-Atlantic cable system',
},
],
},
]
return <MapView layers={layers} height={480} />The path is an array of [longitude, latitude] pairs (GeoJSON coordinate
order). MapView renders each line with the layer color and includes line
vertices when auto-fitting the viewport bounds.
Authoring a line-geometry data source
To expose route geometry through the ontology pipeline, add a pathJson
column (string) to your data source table. The platform's geo query route
recognises the keys pathJson, routeJson, and geometryJson and parses
them as [[lng, lat], …] arrays.
defineDataSource({
kind: 'table',
id: 'my-cable-source',
vendor: 'my-vendor',
scope: 'org',
table: {
name: 'cable_route',
columns: [
{ name: 'cableId', dataType: 'string', isPrimaryKey: true },
{ name: 'cableName', dataType: 'string' },
{ name: 'anchorLatitude', dataType: 'decimal' }, // midpoint for bounds/label
{ name: 'anchorLongitude', dataType: 'decimal' },
{ name: 'pathJson', dataType: 'string' }, // JSON [[lng,lat],…]
{ name: 'updatedAt', dataType: 'timestamp' },
],
primaryKey: ['cableId'],
timestampColumn: 'updatedAt',
},
ingest: { mode: 'poll', intervalSec: 86400, writeMode: 'upsert' },
})Declare pathJson as a string property on the corresponding object type and
include it in the silver_table columnMap. The geo route will parse and
validate the path automatically; malformed or out-of-range coordinates are
silently dropped.
defineObjectType({
slug: 'CableRoute',
displayName: 'Cable route',
kind: 'reference',
properties: [
defineProperty({ slug: 'cableId', displayName: 'Cable ID', scalarType: 'string', cardinality: 'required' }),
defineProperty({ slug: 'cableName', displayName: 'Name', scalarType: 'string', cardinality: 'optional' }),
defineProperty({ slug: 'anchorLatitude', displayName: 'Anchor lat', scalarType: 'float', cardinality: 'optional' }),
defineProperty({ slug: 'anchorLongitude', displayName: 'Anchor lng', scalarType: 'float', cardinality: 'optional' }),
defineProperty({ slug: 'pathJson', displayName: 'Route path', scalarType: 'string', cardinality: 'optional' }),
],
})When a query_map response includes features with a resolved path, those
features are split into a lines array on the layer rather than markers.
No tool configuration is required — the agent's query_map call handles the
split automatically.
ODbL attribution for OpenStreetMap-derived layers
If your route data is derived from OpenStreetMap (e.g. submarine cables extracted via Overpass), you must include attribution wherever the layer is displayed or distributed:
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
The ODbL also requires share-alike: any database derived from ODbL data must be released under the same license. See opendatacommons.org/licenses/odbl for the full text. Non-commercial-only data sources (such as TeleGeography's Submarine Cable Map, licensed CC BY-NC-SA) are not suitable for use in customer deployments — use OSM/ODbL instead.