Publishing maps from a desktop GIS
This page describes what a program has to send to publish a styled map to GeoLens: the datasets, their metadata, the map with its layers, and a report of what could not be carried over. It is the contract for any publisher, whether a desktop GIS plugin or a script. It is not a guide to a specific plugin. A GeoLibre plugin is proposed in opengeos/GeoLibre#3106, and a QGIS plugin is not planned until there is demand.
Every endpoint named here exists in GeoLens 1.23.1. The vocabulary below is version 1 of the publish contract. From 1.24.0 a core test pins it, and the API field descriptions link here.
Requirements
Section titled “Requirements”- Credential. Use a
fullAPI key or a JWT. Aread_onlykey can only read, so it cannot publish. See API Authentication. - Role. The
uploadcapability for the ingest calls, andedit_metadatato patch datasets and create or save maps. Editors and admins have both by default. See User management & RBAC. - Ownership. Patching a dataset needs its owner or an admin.
- Public visibility. An instance can restrict who may set
public. A refused request is recorded aspublic_blocked_by_policy. - CORS. A desktop program is not bound by CORS. A browser client on another
origin that sends
X-Api-KeyorAuthorizationneeds its origin inCORS_ALLOWED_ORIGINS. See Browser clients and CORS.
The four steps
Section titled “The four steps”A publisher sends the same four things in the same order.
1. Datasets through ingest
Section titled “1. Datasets through ingest”Send each source layer as a file: a vector as GeoPackage or GeoJSON, a raster as GeoTIFF or a COG URL. One upload per file.
POST /ingest/upload(orPOST /ingest/upload/urlwith{"url": "..."}for a COG URL, addingfilename, with an allowed extension, when the URL does not end in the file name; poll the returned job until it ispending, and stop onfailedorcancelled), thenPOST /ingest/preview/{job_id}, thenPOST /ingest/commit/{job_id}with at least{"title": "..."}(1 to 500 characters).- For a multi-layer GeoPackage, call
POST /ingest/commit-fan-out/{job_id}after the preview instead of the commit, with{"layers": [{"layer_name": "..."}]}as the body. It creates one dataset per requested layer. It takes at most 50 layers per request, and the upload’s job is finished once it succeeds, so upload the file again for any further layers. The response lists a result per layer, eachqueuedorfailed. Afailedresult has nonew_job_id, so poll only the queued ones and report the failed layers to the user. - Poll
GET /jobs/{job_id}until the status iscomplete,failed,cancelledorfanned_out. A cancelled job is terminal, so surface it instead of waiting. - Take the
dataset_idfrom each completed job.
Writing to the catalog describes each call.
Column names used later come from GET /datasets/{id} after ingest, never from
the source field names.
2. Dataset metadata
Section titled “2. Dataset metadata”PATCH /datasets/{id} with title, summary and visibility, plus whatever
the source carries of license, attribution, source_organization,
source_url, usage_constraints, data_vintage_start, data_vintage_end,
language and theme_category. theme_category is a JSON list of ISO topic
category codes, even when there is only one.
Respect the field limits, truncating longer values before the call:
| Field | Limit |
|---|---|
title | 500 characters |
summary, attribution | 5000 characters |
license, source_organization, usage_constraints | 1000 characters |
source_url | 2000 characters |
language | 35 characters |
data_vintage_start, data_vintage_end | dates, YYYY-MM-DD |
visibility | private, restricted, internal or public |
For an elevation raster that GeoLens did not detect as a DEM, also send
is_dem: true. Terrain and hillshade rendering depend on that flag.
3. The map
Section titled “3. The map”POST /maps/ with a name (1 to 255 characters) and a description (up to 2000 characters)
creates the map. PUT /maps/{id}
then sets the viewport (center_lng, center_lat, zoom, bearing and pitch),
basemap_style, visibility and layers. A map’s visibility is private,
internal or public; restricted is a dataset value only. Send pitch
(0 to 85) when the source view is tilted.
For a source scene with 3D terrain, also send terrain_config with
enabled: true, source_dataset_id (the DEM dataset) and exaggeration
(0 to 3). Without it the map opens tilted but flat.
A public map shows only the layers whose datasets anonymous viewers can read,
which takes a public visibility and a published record status. New datasets start
as drafts, so walk each one to the published state with
PATCH /datasets/{id}/target-status/ with {"status": "published"} when the user
means to share it publicly. Before you report a public publish as successful,
call GET /maps/{id}/visibility-check/ and tell the user which layers
anonymous viewers will not see. That check reports only non-public visibility,
not the record status, so confirm the published state yourself.
A map takes at most 200 layers. Choose, drop or split
layers before the call, because a rejected PUT leaves the new map empty.
PUT with layers replaces every layer. To republish, skip the POST and send
the same PUT again.
GET /maps/{id} returns the stored layers. GET /maps/{id}/style.json returns
MapLibre style JSON, with the cleaned style_config, label_config and
popup_config of each layer under metadata.geolens.
4. The loss report
Section titled “4. The loss report”Put the report in the map’s notes. See Loss report.
Layer fields
Section titled “Layer fields”layers is a list of MapLayerInput. The fields a publisher fills:
| Field | From | Rule |
|---|---|---|
dataset_id | the job’s dataset_id | Required. |
display_name | the layer name | At most 255 characters. Truncate longer names before the PUT. |
sort_order | position in the source layer tree | The bottom layer is 0. |
visible, opacity | the source layer | |
layer_type | vector_geolens for ingested vectors, raster_geolens for ingested rasters | Never geojson. That type is for small inline collections the builder creates. |
paint, layout | compiled from the source style | Valid MapLibre paint and layout keys for the layer’s geometry. layout._minzoom and layout._maxzoom carry the zoom range. |
filter | the source subset or rule filter | Only when it fits the MapLibre filter grammar. Otherwise filter the data before ingest and record filter_not_representable. |
style_config | the builder vocabulary below | |
label_config | one field label | |
popup_config | the source popup or field visibility | |
show_in_legend | true unless the source hides the layer |
Reject or truncate values over these limits locally. One invalid layer fails the
whole PUT, and a first publish then leaves an empty map.
| Field | Limit |
|---|---|
layers | 200 per map |
name, display_name | 255 characters |
description | 2000 characters |
notes | 50,000 characters |
basemap_style | 2000 characters |
sort_order | 0 to 32767 |
opacity | 0 to 1 |
zoom | 0 to 24 |
bearing | -180 to 180 |
pitch | 0 to 85 |
popup_config.expression | 500 characters |
popup_config.visible_fields | 100 entries of 1 to 128 characters |
Column names in paint, filter, style_config.column, label_config.column
and popup_config.visible_fields are the dataset’s column names.
Vocabulary
Section titled “Vocabulary”Keys outside these lists are accepted on write but dropped when the map is exported as MapLibre style JSON.
style_config: breaks, builder, categories, classCount,
colorLabel, colors, column, heatmapPaint, legendLabel, method,
mode, ramp, render_mode, reversed, savedCirclePaint, sizeLabel,
sizeRange, sizes, symbol, target.
style_config.builder: stored in snake_case: arrow_color, arrow_size,
arrow_spacing, cluster_color, cluster_color_ramp, cluster_max_zoom,
cluster_radius, cluster_show_counts, cluster_text_color,
cluster_text_size, extrusion_min_zoom, extrusion_opacity,
fill_color_saved, fill_disabled, fill_opacity_saved,
folder_group_expanded, folder_group_id, folder_group_name,
heatmap_ramp, heatmap_reversed, heatmap_weight_column, height_column,
height_scale, outline_color, outline_width, outline_width_saved,
stroke_disabled. The camelCase spelling of each, such as outlineColor, is
accepted and rewritten to snake_case on write. Reads return snake_case. Raster
and DEM builder keys (colormap, stretch, pmin, pmax, sigma,
hypso_*) share the same object.
style_config.symbol: categories, categoryColumn, iconAnchor,
iconImage, iconOffset, iconRotation, iconSize.
iconImage must name an icon in the GeoLens sprite. Call GET /maps/icons to
list the default and uploaded icons and reuse a match. For a custom SVG or PNG,
upload it with POST /maps/icons (multipart file) and use the returned
sprite_id. Uploading needs a role the instance allows to manage icons, and a
403 means the icon cannot be uploaded: fall back to a circle layer and record
icon_not_uploaded. See the
Maps API reference for the icon
operations.
label_config: allowOverlap, column, fontSize, haloColor,
haloWidth, maxZoom, minZoom, placement, textAnchor, textColor,
textOffset, textOpacity. Bounds: haloWidth 0 to 20, minZoom and
maxZoom 0 to 24, textOpacity 0 to 1, and fontSize either a number above 0
up to 512 or a MapLibre zoom expression.
popup_config: enabled (required whenever popup_config is sent),
expression and visible_fields. Other keys are
rejected. visible_fields set to null shows every field, [] shows none, and
an ordered list shows those fields in that order, up to 100 and unique.
Two behaviors matter to a publisher:
- Keys that start with
_inlayoutare private to the builder and are stripped from exported MapLibre paint and layout._minzoomand_maxzoomstill apply in the builder. - Legacy builder keys in
paint, such as_colormap, are moved intostyle_config.builderon write.
Examples by renderer mode
Section titled “Examples by renderer mode”Each example shows the fields that differ by mode. Add the shared fields from the table above. A complete layer looks like this:
{ "dataset_id": "00000000-0000-4000-8000-000000000001", "display_name": "Layer", "sort_order": 0, "visible": true, "opacity": 1.0, "layer_type": "vector_geolens", "show_in_legend": true, "paint": { "fill-color": "#3b82f6", "fill-opacity": 0.7 }}Raster examples use "layer_type": "raster_geolens" and no paint.
single (fill)
Section titled “single (fill)”{ "layer_type": "vector_geolens", "paint": { "fill-color": "#3b82f6", "fill-opacity": 0.7 }, "layout": { "_minzoom": 4, "_maxzoom": 18 }, "style_config": { "builder": { "outlineColor": "#1e3a8a", "outlineWidth": 1 } }, "label_config": { "column": "name", "fontSize": 12, "textColor": "#111111", "haloColor": "#ffffff", "haloWidth": 1.5, "minZoom": 8, "placement": "point" }, "popup_config": { "enabled": true, "visible_fields": [ "name" ] }}categorical
Section titled “categorical”{ "layer_type": "vector_geolens", "paint": { "fill-color": [ "match", [ "get", "landuse" ], "residential", "#e41a1c", "commercial", "#377eb8", "#999999" ], "fill-opacity": 0.8 }, "style_config": { "mode": "categorical", "column": "landuse", "categories": [ { "value": "residential", "label": "Residential", "color": "#e41a1c" }, { "value": "commercial", "label": "Commercial", "color": "#377eb8" } ], "legendLabel": "Land use" }}graduated (color)
Section titled “graduated (color)”{ "layer_type": "vector_geolens", "paint": { "fill-color": [ "step", [ "get", "pop" ], "#fee5d9", 1000, "#fcae91", 5000, "#de2d26" ] }, "style_config": { "mode": "graduated", "column": "pop", "breaks": [ 1000, 5000 ], "colors": [ "#fee5d9", "#fcae91", "#de2d26" ], "classCount": 3, "method": "quantile" }}graduated (radius)
Section titled “graduated (radius)”{ "layer_type": "vector_geolens", "paint": { "circle-color": "#2563eb", "circle-radius": [ "step", [ "get", "pop" ], 3, 1000, 6, 5000, 10 ] }, "style_config": { "mode": "graduated", "target": "radius", "column": "pop", "breaks": [ 1000, 5000 ], "sizes": [ 3, 6, 10 ], "colors": [ "#2563eb", "#2563eb", "#2563eb" ], "classCount": 3, "method": "manual", "sizeRange": [ 3, 10 ] }}heatmap
Section titled “heatmap”{ "layer_type": "vector_geolens", "paint": {}, "style_config": { "render_mode": "heatmap", "builder": { "heatmapRamp": "inferno", "heatmapWeightColumn": "magnitude" } }}cluster
Section titled “cluster”{ "layer_type": "vector_geolens", "paint": { "circle-color": "#2563eb" }, "style_config": { "render_mode": "cluster", "builder": { "clusterRadius": 50, "clusterColor": "#2563eb", "clusterTextColor": "#ffffff" } }}symbol
Section titled “symbol”{ "layer_type": "vector_geolens", "paint": {}, "style_config": { "render_mode": "symbol", "symbol": { "iconImage": "marker", "iconSize": 1, "iconRotation": 0 } }}extrusion (2.5D)
Section titled “extrusion (2.5D)”{ "layer_type": "vector_geolens", "paint": { "fill-color": "#a3a3a3" }, "style_config": { "builder": { "heightColumn": "height_m", "heightScale": 1, "extrusionOpacity": 0.9 } }}raster singleband
Section titled “raster singleband”{ "layer_type": "raster_geolens", "style_config": { "builder": { "colormap": "viridis", "stretch": "minmax" } }}raster hillshade
Section titled “raster hillshade”{ "layer_type": "raster_geolens", "style_config": { "render_mode": "hillshade" }}Constants
Section titled “Constants”- Scale to zoom.
zoom = log2(591657550.5 / scale_denominator) - 1, which islog2(295828775.25 / scale_denominator), rounded to one decimal and clamped to 0 to 24. The constant is the zoom-0 scale of a 256-pixel world at 96 DPI, and MapLibre’s zoom 0 is a 512-pixel world, hence the- 1. A scale of 1:100,000 gives 11.5. The zoomed-out limit of a scale range becomes_minzoomand the zoomed-in limit becomes_maxzoom. A scale of 0 means unbounded. - Lengths. Millimetres to pixels at 96 DPI:
px = mm * 96 / 25.4. Points to pixels:px = pt * 96 / 72. A source that measures in map units or metres cannot be converted; keep the value as pixels and recordunit_not_representable. - Colours. Send
#rrggbband set opacity in the matching-opacitypaint property. MapLibre also acceptsrgba(), but the builder stores opacity separately. Leave the layeropacityat 1 unless the source has a layer-level opacity.
Loss report
Section titled “Loss report”Whatever the source style has that GeoLens cannot represent goes in the map’s
notes, so a reader can see what changed. The first line is
Published from <tool> <version> on <YYYY-MM-DD>. Each following line is one
item:
<path>: <kind> (<detail>)notes holds up to 50,000 characters. Keep the whole value, suffix included, within the
limit: reserve room for ... <n> more entries, cut at the last whole line that
still fits, then append it. A publisher should show the same report
to its user.
Publishers that also keep the report as data use this shape:
{ "items": [ {"path": "layers[3].style.renderer", "kind": "unsupported_renderer", "detail": "2.5D renderer"}, {"path": "layers[7]", "kind": "external_layer", "detail": "WMTS https://..."} ], "summary": {"unsupported_renderer": 1, "external_layer": 1}}Use only these kinds.
| Kind | Meaning |
|---|---|
unsupported_renderer | The renderer has no equivalent. The layer is published with a single symbol. |
rule_based_approximated | Rules were flattened into a case expression or categories. |
expression_not_representable | An expression does not parse as MapLibre. |
expression_materialized | An expression was evaluated per feature into a column and referenced from the paint. |
filter_not_representable | A filter does not fit the MapLibre filter grammar. |
unit_not_representable | A unit other than pixels, millimetres or points. |
symbol_layer_dropped | An extra or unsupported symbol layer was left out. |
effect_dropped | A draw effect, blend mode or similar was left out. |
label_approximated | A label option has no equivalent. |
icon_not_uploaded | An icon could not be uploaded, for example because POST /maps/icons answered 403. |
nested_group | Nested groups were flattened. |
external_layer | A web service layer cannot be ingested. |
basemap_replaced | The source basemap was replaced by the GeoLens default. |
unsupported_layer_type | The layer type cannot be published. |
local_raster_not_publishable | A local raster has no file or URL GeoLens can ingest. |
non_spatial_layer | A layer without geometry. |
file_too_large | The file exceeds the upload limit. |
public_blocked_by_policy | The instance does not allow the requested public visibility. |
raster_renderer_approximated | A raster ramp or option was mapped to the nearest one GeoLens has. |
scale_range_clamped | A scale range that would hide the layer at every zoom was widened. |
See also
Section titled “See also”- Writing to the catalog: the ingest calls
- API Authentication: API keys and CORS
- Map Builder: the same vocabulary in the app