Map documents
A Sarkoa map document is a JSON description of a custom map: camera, basemap, access settings, and every layer style. The same document works with the CLI, MCP, and the web map builder.
Current version: sarkoa.com/maps/v1
Minimal example
{
"apiVersion": "sarkoa.com/maps/v1",
"kind": "Map",
"metadata": {
"title": "Earthquake impacts",
"description": "Vector and raster layers for response planning",
"category": null,
"tags": ["earthquake"]
},
"spec": {
"view": {
"centerLng": -74.1,
"centerLat": 4.65,
"zoom": 9,
"preferredMapMode": "2d",
"preferredProjection": "mercator"
},
"basemap": {
"basemapId": "default-osm-basemap",
"mapTheme": "default",
"overlays": { "labels": true, "roadsAndRail": false, "boundaries": false },
"buildings3d": false
},
"chrome": { "version": 1, "layersPanel": "visible", "searchBar": "visible" },
"legends": {
"version": 1,
"panel": "visible",
"placement": "bottom-right",
"collapsible": true,
"defaultCollapsed": false,
"order": []
},
"interactions": { "version": 1, "interactions": [] },
"access": {
"accessMode": "organization",
"allowedUserIds": [],
"allowedEmails": []
},
"collaboration": {
"commentsEnabled": true,
"annotationsEnabled": true,
"commentsAudience": "inherit",
"annotationsAudience": "inherit",
"commentAllowedUserIds": [],
"annotationAllowedUserIds": []
},
"downloads": {
"downloadsEnabled": false,
"downloadsAudience": "inherit",
"downloadAllowedUserIds": [],
"downloadAllowedEmails": []
},
"uploads": {
"uploadsEnabled": false,
"uploadsAudience": "inherit",
"uploadAllowedUserIds": [],
"uploadAllowedEmails": []
},
"layers": [
{
"layerId": "faults",
"assetId": "11111111-1111-4111-8111-111111111111",
"visible": true,
"downloadable": false,
"vectorStyle": {
"mode": "simple",
"fillColor": "#35605A",
"fillOpacity": 0.35,
"strokeColor": "#d13c3c",
"strokeOpacity": 1,
"strokeWidth": 2,
"labelField": null
}
}
]
}
}
Layer styles
Vector
- simple: solid fill and outline colors, opacity, stroke width, optional label field
- categorized: color classes from an attribute (up to 50 classes). Use a distinct palette (default) so neighbouring classes stay easy to tell apart, or Category 10 / colour-blind-safe (Okabe-Ito). Shuffle the colours in the layer style editor, with
sarkoa map layer classify --shuffle, or with MCPclassify_vector_style(shuffle/seed). OptionalpaletteandpaletteSeedare stored on the style. - graduated: numeric class breaks with a color ramp (equal interval, quantile, jenks, or manual)
- sizeBy (points and lines): scale point radius or line width from a numeric attribute, independently of colour. Set
attribute,mode(proportionalwithdomain/range, orgraduatedwithbreaks/sizes), and optionaltransform(sqrtby default so area tracks the value, orlinearfor radius). Polygons ignore it. In the layer style editor, open Size and pick the field. - strokeDash (lines only):
solid(default),dashed,dotted, ordash-dot. Polygon outlines and point strokes stay solid. In the layer style editor, this is Line pattern next to stroke width.
Colors use #RRGGBB. Stroke width is 0.5β24. sizeBy radii are 0.5β100. Layer title values show in the layers list. Legend title applies to simple styles as well as categorized and graduated ones.
Each layer needs a layerId: a UUID or a short slug such as faults or layer-smoke (letters, numbers, _, -). Slugs are unique within a map (you can reuse the same slug on another map). Reference them from legends.order and interaction targets.
Layer order. spec.layers runs bottom to top: the first entry is drawn first and sits underneath everything after it, and the last entry is drawn on top. This is the reverse of the map builder's layers panel, which puts the topmost layer in its first row, so a document and the builder read the same stack in opposite directions. legends.order is separate and does run top to bottom, matching the legend panel.
You can add the same file more than once. Give each copy its own layerId and style. Your personal colour preferences for that file still apply to every copy; map styles stay independent.
The sketch { "mode": "categorized", "attribute": "kind" } is not enough to paint the map. Saved styles need class colours: use Classify in the layer style editor, sarkoa map layer classify MAP LAYER --attribute kind, or MCP classify_vector_style. Those assign a distinct palette (or Category 10 / colour-blind-safe) you can shuffle.
To size points (or line width) by a number, add sizeBy next to the colour mode. inspect_asset.sizeChannel tells you whether the layer uses radius, width, or neither:
"vectorStyle": {
"mode": "simple",
"fillColor": "#35605A",
"fillOpacity": 0.5,
"strokeColor": "#312E81",
"strokeOpacity": 1,
"strokeWidth": 1,
"labelField": null,
"sizeBy": {
"attribute": "capacity_mtpa",
"mode": "proportional",
"domain": [0.1, 80],
"range": [3, 24],
"transform": "sqrt"
},
"strokeDash": "dashed"
}
strokeDash only changes real line layers. Polygon outlines and point rings stay solid.
"layers": [
{
"layerId": "by-kind",
"assetId": "11111111-1111-4111-8111-111111111111",
"title": "Sites by type",
"visible": true,
"vectorStyle": { "mode": "categorized", "attribute": "kind" }
},
{
"layerId": "by-date",
"assetId": "11111111-1111-4111-8111-111111111111",
"title": "Sites by year",
"visible": true,
"vectorStyle": { "mode": "graduated", "attribute": "year" }
}
]
Feature popup (vector layers)
When popupConfig is enabled on a vector layer, clicking a feature opens an attribute panel. Set titleField for the heading and fields for the rows to show. A popup shows at most 12 fields; send more and the extras are dropped with a popup_fields_truncated warning, so put the ones you need earliest. Do not repeat titleField inside fields: it renders as the heading and is skipped in the body, so it would spend one of the twelve slots on a row nobody sees. Optional imageFields lists attribute names whose https:// values should render as thumbnails instead of plain text (useful for photo URLs stored in your data). Thumbnails show the whole image, including tall illustrations, rather than cropping to fill the popup.
"popupConfig": {
"enabled": true,
"titleField": "name",
"fields": ["name", "description", "photo_url"],
"imageFields": ["photo_url"]
}
Default view
spec.view.preferredMapMode is 2d (default) or 3d (terrain). spec.view.preferredProjection is mercator (flat map, default) or globe. These control how the map opens; visitors can still switch in the viewer.
Set them from the CLI with sarkoa map display MAP --mode 3d --projection globe, or from MCP with update_map (setView) or create_map / scaffold_map (preferred_map_mode, preferred_projection).
Map chrome
spec.chrome controls viewer UI chrome (version 1).
layersPanel:visible,collapsed, orhiddensearchBar:visible(default) orhidden. When visible, a search button on the map lets viewers find places, paste coordinates, search named features in visible vector layers, and jump to layers by name. Hide it on presentation-only maps where navigation is not needed.
In the map builder, open Display to preview layers panel and search visibility. Live search stays disabled in editor mode.
Layer attribution
Each layer can include attribution: { "text": "Source name", "url": "https://example.com" }. That credit appears in the bottom-right map attribution when the layer is on. Omit it to use the fileβs stored credit, or set attribution to null to hide a credit on this map only.
From the CLI: sarkoa map layer add MAP --asset FILE --attribution-text "Source name" --attribution-url https://example.com. From MCP, pass attribution_text and attribution_url on create_map / scaffold_map layers, or updateLayer.patch.attribution.
Basemap themes
spec.basemap.mapTheme must be one of: default, dark, forest, ocean, minimal-light, warm, satellite, minimal-dark. Unknown values are rejected (they are not silently rewritten).
Raster
- bands: choose RGB band indexes
- palette: grayscale, viridis, inferno, magma, plasma, or turbo
- continuous: ramp with
vmin/vmaxand optional threshold hide
Before choosing attribute names, categories, or value ranges, check what is actually in the file with inspect_asset (MCP) or the file details panel in the app. Guessing an attribute name is the most common reason a style comes back empty.
Interactions
Map-level controls live in spec.interactions (version: 1). The viewer shows them in the Filters panel.
Vector filters
Sliders and dropdowns target a vector layer and a feature property:
{
"id": "range",
"type": "slider",
"label": "Range (mi)",
"min": 50,
"max": 300,
"step": 50,
"default": 150,
"target": { "layerId": "ev-range", "assetId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "sublayers": "all" },
"action": { "kind": "filter", "property": "range_bucket", "operator": "<=" }
}
Toggles switch vector sublayer visibility (action.kind: "visibility").
Raster thresholds
Sliders can threshold single-band measurement rasters that have raster_band_min and raster_band_max metadata. The layer should use rasterStyle.mode: "continuous". Viewers adjust the visible value range in real time; the slider position is not saved back to the map. Target the layer with assetId (the file) and, when the map uses a distinct layer id, layerId as well. Existing maps that only stored assetId still threshold the painted layer.
{
"id": "depth",
"type": "slider",
"label": "Minimum depth",
"min": 0,
"max": 42,
"step": 1,
"default": 10,
"target": { "layerId": "dem", "assetId": "11111111-1111-4111-8111-111111111111" },
"action": { "kind": "rasterThreshold", "op": ">=", "outside": "hide", "band": 1 }
}
Supported operators: >= and <=. At the non-filtering edge (min for >=, max for <=), the control shows the full ramp (any static threshold in rasterStyle still applies).
Access modes
| Mode | Who can open the map |
|---|---|
organization |
Members of your organization |
specific_users |
Listed organization members |
invited_emails |
People invited by email |
public |
Anyone with the link (layers must already be public) |
password |
Anyone with the link and password (Essential+) |
Passwords are write-only. Exports and get_map never return the password value.
Maps do not use file visibility values (private, org, public_link). Change who can open a map with spec.access.accessMode, the CLI (sarkoa map access MAP --mode organization), or MCP update_map (setAccess). Public, password, and email-invite changes need --yes (CLI) or confirm_publish=true (MCP).
Safe updates
Maps keep a revision number. When you change an existing map, send the revision you last read (--expected-revision in the CLI, or expected_revision in MCP/API). If someone else saved first, the server returns a conflict so you can reload and try again.
Related
Warnings
validate_map, create_map and replace_map return valid: true alongside a
warnings array when part of a document could not be used. Nothing failed, and
nothing was written for those keys either. Each warning carries a path (a JSON
pointer into the document), a message, and a code:
| code | what it means |
|---|---|
unknown_vector_style_key |
a vectorStyle key the parser does not recognise; it was dropped |
unknown_raster_style_key |
a rasterStyle key that mode does not read; it was dropped |
popup_fields_truncated |
more than 12 popup fields were sent; the extras were dropped |
popup_field_dropped |
one popup field was unusable or duplicated |
popup_image_field_dropped |
an imageFields entry is not in fields, or is the titleField |
popup_title_field_in_fields |
titleField is repeated in fields, wasting a slot on a row that never renders |
unknown_legend_ref |
a legends.order entry names no layer on the map |
unknown_layer_ref / unknown_asset_ref |
an interaction targets a layer or asset the map does not have |
dropped_layer_config |
a replace_map document omitted a popupConfig or legend the layer had, so it was removed |
static_raster_on_temporal_map |
a static raster sits on a map with a time scrubber |
layer_id_defaulted |
a layer had no layerId, so one was generated |
description_truncated |
the description ran past 4,000 characters and the tail was cut |
category_truncated |
the category ran past 40 characters and was cut |
tag_truncated |
a tag ran past 40 characters and was cut |
layer_title_truncated |
a layer title ran past 200 characters and was cut |
Read them. They exist because each of these used to happen in silence.