Skip to content
getgeolens.com

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.

  • Credential. Use a full API key or a JWT. A read_only key can only read, so it cannot publish. See API Authentication.
  • Role. The upload capability for the ingest calls, and edit_metadata to 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 as public_blocked_by_policy.
  • CORS. A desktop program is not bound by CORS. A browser client on another origin that sends X-Api-Key or Authorization needs its origin in CORS_ALLOWED_ORIGINS. See Browser clients and CORS.

A publisher sends the same four things in the same order.

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.

  1. POST /ingest/upload (or POST /ingest/upload/url with {"url": "..."} for a COG URL, adding filename, with an allowed extension, when the URL does not end in the file name; poll the returned job until it is pending, and stop on failed or cancelled), then POST /ingest/preview/{job_id}, then POST /ingest/commit/{job_id} with at least {"title": "..."} (1 to 500 characters).
  2. 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, each queued or failed. A failed result has no new_job_id, so poll only the queued ones and report the failed layers to the user.
  3. Poll GET /jobs/{job_id} until the status is complete, failed, cancelled or fanned_out. A cancelled job is terminal, so surface it instead of waiting.
  4. Take the dataset_id from 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.

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:

FieldLimit
title500 characters
summary, attribution5000 characters
license, source_organization, usage_constraints1000 characters
source_url2000 characters
language35 characters
data_vintage_start, data_vintage_enddates, YYYY-MM-DD
visibilityprivate, 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.

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.

Put the report in the map’s notes. See Loss report.

layers is a list of MapLayerInput. The fields a publisher fills:

FieldFromRule
dataset_idthe job’s dataset_idRequired.
display_namethe layer nameAt most 255 characters. Truncate longer names before the PUT.
sort_orderposition in the source layer treeThe bottom layer is 0.
visible, opacitythe source layer
layer_typevector_geolens for ingested vectors, raster_geolens for ingested rastersNever geojson. That type is for small inline collections the builder creates.
paint, layoutcompiled from the source styleValid MapLibre paint and layout keys for the layer’s geometry. layout._minzoom and layout._maxzoom carry the zoom range.
filterthe source subset or rule filterOnly when it fits the MapLibre filter grammar. Otherwise filter the data before ingest and record filter_not_representable.
style_configthe builder vocabulary below
label_configone field label
popup_configthe source popup or field visibility
show_in_legendtrue 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.

FieldLimit
layers200 per map
name, display_name255 characters
description2000 characters
notes50,000 characters
basemap_style2000 characters
sort_order0 to 32767
opacity0 to 1
zoom0 to 24
bearing-180 to 180
pitch0 to 85
popup_config.expression500 characters
popup_config.visible_fields100 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.

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 _ in layout are private to the builder and are stripped from exported MapLibre paint and layout. _minzoom and _maxzoom still apply in the builder.
  • Legacy builder keys in paint, such as _colormap, are moved into style_config.builder on write.

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.

{
"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"
]
}
}
{
"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"
}
}
{
"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"
}
}
{
"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
]
}
}
{
"layer_type": "vector_geolens",
"paint": {},
"style_config": {
"render_mode": "heatmap",
"builder": {
"heatmapRamp": "inferno",
"heatmapWeightColumn": "magnitude"
}
}
}
{
"layer_type": "vector_geolens",
"paint": {
"circle-color": "#2563eb"
},
"style_config": {
"render_mode": "cluster",
"builder": {
"clusterRadius": 50,
"clusterColor": "#2563eb",
"clusterTextColor": "#ffffff"
}
}
}
{
"layer_type": "vector_geolens",
"paint": {},
"style_config": {
"render_mode": "symbol",
"symbol": {
"iconImage": "marker",
"iconSize": 1,
"iconRotation": 0
}
}
}
{
"layer_type": "vector_geolens",
"paint": {
"fill-color": "#a3a3a3"
},
"style_config": {
"builder": {
"heightColumn": "height_m",
"heightScale": 1,
"extrusionOpacity": 0.9
}
}
}
{
"layer_type": "raster_geolens",
"style_config": {
"builder": {
"colormap": "viridis",
"stretch": "minmax"
}
}
}
{
"layer_type": "raster_geolens",
"style_config": {
"render_mode": "hillshade"
}
}
  • Scale to zoom. zoom = log2(591657550.5 / scale_denominator) - 1, which is log2(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 _minzoom and 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 record unit_not_representable.
  • Colours. Send #rrggbb and set opacity in the matching -opacity paint property. MapLibre also accepts rgba(), but the builder stores opacity separately. Leave the layer opacity at 1 unless the source has a layer-level opacity.

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.

KindMeaning
unsupported_rendererThe renderer has no equivalent. The layer is published with a single symbol.
rule_based_approximatedRules were flattened into a case expression or categories.
expression_not_representableAn expression does not parse as MapLibre.
expression_materializedAn expression was evaluated per feature into a column and referenced from the paint.
filter_not_representableA filter does not fit the MapLibre filter grammar.
unit_not_representableA unit other than pixels, millimetres or points.
symbol_layer_droppedAn extra or unsupported symbol layer was left out.
effect_droppedA draw effect, blend mode or similar was left out.
label_approximatedA label option has no equivalent.
icon_not_uploadedAn icon could not be uploaded, for example because POST /maps/icons answered 403.
nested_groupNested groups were flattened.
external_layerA web service layer cannot be ingested.
basemap_replacedThe source basemap was replaced by the GeoLens default.
unsupported_layer_typeThe layer type cannot be published.
local_raster_not_publishableA local raster has no file or URL GeoLens can ingest.
non_spatial_layerA layer without geometry.
file_too_largeThe file exceeds the upload limit.
public_blocked_by_policyThe instance does not allow the requested public visibility.
raster_renderer_approximatedA raster ramp or option was mapped to the nearest one GeoLens has.
scale_range_clampedA scale range that would hide the layer at every zoom was widened.