Skip to content

Geolocation values — the geo column

Data model series. This page documents a Dédalo v7 data type — the JSON value shape as it is stored, independent of the component that produces it. For the field/UI object that creates this value, see component_geolocation. For where these typed columns live, see Sections — typed-column storage.

What it is

A geolocation value holds two independent things: the viewlat / lon / zoom, the map framing — and the features, the vector shapes (points, circles, polygons, polylines) drawn on the map and stored as GeoJSON in lib_data. alt is an operator-editable altitude that travels with the item.

It has two parts because a single coordinate pair is rarely enough for cultural-heritage description: an excavation site has an extent (a polygon), a findspot has a precise point, a survey transect is a polyline, a monument has a viewshed radius — and the frame an operator needs to see that work is a separate question from the work itself.

The view is a frame, not a feature

lat/lon/zoom say where the map looks from; they assert no feature, and the features do not determine them. An operator frames the space they need to see, so with several points — or with context drawn around one — the useful frame is often nowhere near any single feature. The view is under manual control and it is data: changing it is the operator acting, and it saves.

Both parts are ready for mapping, spatial queries and diffusion to GIS targets.

The value is language-neutral: it never carries lg-* keys. The producing component forces lang = DEDALO_DATA_NOLAN, so a position is the same in every interface language.

Canonical JSON shape

The stored data is an array of items (in practice one item — one map per component). Each item is the view plus the optional drawn features in lib_data:

[{
  "id": 3,
  "lat": 41.562363,
  "lon": 2.012151,
  "zoom": 16,
  "alt": 0,
  "lib_data": [{
    "layer_id": 1,
    "layer_data": {
      "type": "FeatureCollection",
      "features": [{
        "type": "Feature",
        "properties": {"layer_id": 1},
        "geometry": {"type": "Point", "coordinates": [2.012151, 41.562363]}
      }]
    }
  }]
}]

The item fields:

key type meaning
lat float the view's latitude, range [-90, 90] (human-facing, latitude first)
lon float the view's longitude, range [-180, 180]
zoom int the view's Leaflet zoom level (default 16)
alt int altitude / elevation — real data on the installs that use it (default 0)
lib_data array the features: drawn shapes as GeoJSON layers (the rendering-library data)
id int per-item counter id (from the component meta counter)

Coordinate order — read this twice

The item fields lat/lon are stored in human order (latitude first). Inside lib_data the GeoJSON geometry.coordinates follow the GeoJSON convention [lon, lat] (longitude first). The two orders coexist in the same value; do not swap them.

The lib_data layers

lib_data is an array of layers. Each layer is {layer_id, layer_data} where layer_data is a GeoJSON FeatureCollection. Every feature stamps its own layer_id into properties. Non-point shapes carry extra properties:

{
  "layer_id": 1,
  "layer_data": {
    "type": "FeatureCollection",
    "features": [
      {
        "type": "Feature",
        "properties": {"layer_id": 1},
        "geometry": {"type": "Point", "coordinates": [-17.879337, 28.760041]}
      },
      {
        "type": "Feature",
        "properties": {"layer_id": 1, "color": "#3388ff", "shape": "circle", "radius": 284.49},
        "geometry": {"type": "Point", "coordinates": [-17.879723, 28.760324]}
      }
    ]
  }
}
  • shape:"circle" + radius (metres) describe a Leaflet circle (GeoJSON has no native circle, so it is encoded as a Point + radius).
  • color is the stroke/fill colour of any drawn shape.

Layers exist so a transcription component_text_area can pair each drawn shape with a geo tag in the text — the layer is loaded/removed as the tag is inserted/removed.

Absence is structural — there is no magic coordinate

A record with no position stores no geolocation item. Emptiness is the absence of a value, never a particular value. (One pair is refused, but at the publication door only — see the studio default.)

stored meaning
no item for the component tipo no position — nothing is resolved, nothing is published
lat / lon null, undefined, "", or unparseable text no coordinate — the point is not built, and the missing axis is never completed with a zero
any finite lat/lon, 0 included a real coordinate — [0, 0], the equator and the prime meridian are legal positions and publish like any other
lat/lon as a JSON string ("41.5") or number (41.5) identical; both forms occur in the same column

Both axes are required together: a point needs lat and lon.

The opening camera is a view, never a value

Where the map opens when a record has no coordinate is server configuration — DEDALO_GEO_DEFAULT_LAT / DEDALO_GEO_DEFAULT_LON / DEDALO_GEO_DEFAULT_ZOOM, delivered to the editor as context.features.default_view (see Configuration). It is a camera position: it is never stored in the record and never published. Opening a record and saving it stores no coordinate.

The studio default — the one pair publication refuses

39.462571 / -0.376295 is not a place. It is the studio default: the coordinates of the Dédalo facilities, shipped as the client's factory map position. The v6 client wrote it into the record on save whether or not anyone touched the map, so an item holding exactly that pair is fabricated — nobody frames a map on the studio to six decimals, and a genuinely positioned record carries its own coordinates. Exact equality is therefore a sound test.

It is treated specially at one door only, publication: a fabricated view is never emitted to a consumer. isStudioDefault() (src/core/concepts/geo_coordinate.ts) is the single definition, and this is standing law, not a temporary shim.

Everywhere else — storage, editing, search, import, migration — the pair is an ordinary coordinate with no special handling at all.

A factory default is not a place

Because the pair is a factory default rather than a place, a record merely named after the studio's city holds it as fabricated as any other record does. There is no place-name test anywhere in the engine and there must never be one again.

Legacy data: repairing the fabricated views

An install upgraded from those versions carries items whose stored view nobody chose. Publication already withholds them, but they are still in the store, edited and migrated like real data. The repair is scripts/repair_geolocation_studio_default.ts — dry-run by default; --table and --user are both mandatory, and --apply is refused on any table that has not been authorised in writing. It covers the two stores that carry the pair, the record store and the thesaurus store:

the item what the repair does
the studio-default view and no features the whole item is removed, alt included — it is fabricated in its entirety and the record ends with no geolocation at all
the studio-default view with features the view is fitted to the geometry: lat/lon become the bounding-box centre of every feature across the item's lib_data layers. Features, zoom, alt, id and every other key are untouched. For a single drawn Point the fitted view is that point, verbatim
anything else untouched

Fitting a view to the features is not authoring a location — it is what a view is for.

Two cases are held rather than repaired, and the first surprises people: a fabricated item that shares its component with another item is left alone entirely, because the repair writes the component's whole key at once and removing one item of several is not expressible — so on a record holding several geolocation items you get a reported no-op, not a repair. The other is features from which no coordinate can be extracted: the view is never guessed. The repair is not reversible: assume there is no Time Machine row to restore from.

The view and the features are independent

The two parts of the value do not constrain each other:

  • an item may hold a view and no features — for very many records the view is the only positional data that exists;
  • it may hold features and no view;
  • it may hold both, and the view need not be anywhere near them.

Neither is derived from the other, and neither is dropped in favour of the other. Publication emits both, exactly as stored (the one refusal is the studio default, above). The operator's two gestures map straight onto the two parts: panning and zooming sets the view, and the map_point button reads the coordinate inputs and creates a feature. Both are savable.

Database column

Geolocation values live in the typed geo JSONB column of the matrix table (one row per record; see Sections — the matrix table model). The mapping is resolved through getColumnNameByModel('component_geolocation') (src/core/ontology/resolver.ts), which reads column: 'geo' off component_geolocation/descriptor.ts.

Keyed by component tipo (extra nesting)

Unlike most typed columns, the geo column value is keyed by the originating component tipo — there is an extra object level around the item array:

{
  "numisdata264": [
    { "lat": 41.562363, "lon": 2.012151, "zoom": 16, "alt": 0, "lib_data": [ /* … */ ] }
  ]
}

Because of this nesting, the GIN index and any search query reach the item properties with a wildcard path that crosses both the tipo key and the array:

-- GIN index sample (src/core/db/db_pg_definitions.json)
SELECT * FROM matrix
WHERE jsonb_path_query_array(geo, '$.*[*]') @> '[{"lat":"42.31412288249575"}]'
LIMIT 10;

$.* walks the component-tipo keys and [*] walks the item array, yielding the flat list of point objects to match lat / lon / alt against. The geo column is one of the typed columns that PostgreSQL can index and query independently of the rest of the record payload.

Components that produce / use it

component role translatable
component_geolocation the only producer — edits the position and shapes on a Leaflet map no (lg-nolan)

It is a literal-direct component: it owns its data, with no relation locator, no media file and no language. The component never touches the database directly — the write path (src/core/section/record/save_component.ts) reads and writes the geo column on its behalf.

Server-side handling

There is no dedicated "geo value" class; the shape is produced and consumed entirely through the generic item pipeline. The read side is readComponentItems() / resolveComponentValue() (src/core/resolve/component_data.ts); the geo column itself is declared in MATRIX_JSONB_COLUMNS (src/core/db/matrix.ts) and the model→column entry resolves from component_geolocation/descriptor.ts (column: 'geo').

Emptiness is enforced structurally, in every emitting path. geojsonPointFallbackLayers() (src/diffusion/resolve/ddo_fns.ts) builds a point only when lat and lon both parse to a finite number (comma decimals normalized first); null, undefined, '' and unparseable text yield no layer, and neither axis is ever defaulted to 0. A stored 0 is a coordinate and does build a point. parser_geo::geojson (src/diffusion/parsers/parser_misc.ts) answers identically — see Diffusion parsers. The single parse law is src/core/concepts/geo_coordinate.ts, read by these paths and by the import door. The only pair with a meaning of its own is the studio default, refused at the publication doors and ordinary everywhere else.

That fallback-point builder is wired into one specific diffusion path today: a paired component_text_area's get_geojson_data step (src/diffusion/resolve/resolver.ts) publishes the linked component_geolocation's lib_data layers verbatim, falling back to a single-point FeatureCollection built from lat/lon when lib_data is empty (buildGeojsonLayers(), src/diffusion/resolve/ddo_fns.ts).

Standalone diffusion reshaping and search: not yet implemented

A standalone component_geolocation field (not paired with a text-area geo tag) diffuses as a raw 'geo' atom (src/diffusion/resolve/default_value.ts) — it strips the item id but does not reshape the value into a GeoJSON Point or a layer-wrapped FeatureCollection; it carries essentially the storage shape. There is also no geo-family search builder yet — geolocation values cannot currently be matched by a search query.

That atom publishes what is stored, per item:

  1. an item with a usable view (both axes parse, 0 included) or with features, or both, publishes as it stands, id stripped and every other key — lat, lon, zoom, alt, lib_data — verbatim. The view is never dropped because the item also carries features;
  2. an item whose view is the studio default has that pair withheld: if it carries features they publish without lat/lon; if it carries none, there is no atom;
  3. an item with neither a usable view nor features produces no atom.

The other two emission paths — parser_geo::geojson (src/diffusion/parsers/parser_misc.ts) and buildGeojsonLayers (src/diffusion/resolve/ddo_fns.ts) — publish stored lib_data layers when there are any and build a point from the view otherwise, and they refuse the studio default the same way.

Client-side model

In the datum data layer the value reaches the browser as the same item array, exposed on the component instance as self.data.entries. Each entry is an {lat, lon, zoom, alt, lib_data?} object; the drawn shapes live in entries[0].lib_data.

The component renders an interactive Leaflet map with the Leaflet-Geoman draw editor (client/dedalo/core/component_geolocation/js/component_geolocation.js). The stored value maps onto Leaflet as follows:

// the CAMERA (view only) — the stored coordinate when there is one,
// otherwise the server-configured default view. Never a stored value.
const map_data = self.get_view(0)
self.map = new L.Map(map_container, {
    center : new L.LatLng(map_data.lat, map_data.lon),   // (lat, lon)
    zoom   : map_data.zoom
})

// the default view, delivered by the server (DEDALO_GEO_DEFAULT_*)
self.default_view = self.context.features.default_view   // {lat:20, lon:0, zoom:2}

The component's value is a separate thing from the camera: self.get_stored_entry(0) returns the stored item or null and never fabricates one, so a record the user did not touch has no value to save. The four coordinate inputs render empty on absence — and render 0 when the stored coordinate is zero.

Notes on the client model:

  • Setting the view is data entry. Panning or zooming writes the new view into the component's value and marks it dirty — on an empty record too, where it creates the view. The lat/lon/zoom inputs track the map live, which is how an operator approximates a position by eye and spots bad manual data. The map_point button in the map-inputs row reads those inputs and creates a feature; it does not touch the view.
  • The map never auto-saves: a gesture only marks the component dirty, and only the save button commits. The component's own camera moves are masked (self.camera_is_moving), so a programmatic setView/fitBounds never writes Leaflet's pixel-rounded centre over a typed or stored value.
  • A record whose value is features with no stored view opens fitted to the extent of that geometry rather than on the world view. That fit is a camera move, not a write.
  • Editing the lat/lon/zoom/alt inputs recenters the map through a single non-animated camera move, so a typed coordinate is never overwritten by the animation settling afterwards.
  • Each drawn lib_data layer becomes a Leaflet L.FeatureGroup keyed by layer_id; layer_id is also the overlay name in the layer control. The active layer defaults to 1 (self.active_layer_id = 1).
  • Map libraries (Leaflet, Leaflet-Geoman, Turf for measurements, iro for the colour picker) are lazy-loaded on the first map build.
  • The text-area geo tag form is [geo-n-{id}-data:{…FeatureCollection…}:data]; inserting/removing a tag loads/unloads the matching lib_data layer.

Examples

A point with a drawn circle

{
  "numisdata264": [{
    "id": 3,
    "alt": 16,
    "lat": 28.760289075631214,
    "lon": -17.87981450557709,
    "zoom": 17,
    "lib_data": [{
      "layer_id": 1,
      "layer_data": {
        "type": "FeatureCollection",
        "features": [
          {
            "type": "Feature",
            "properties": {"layer_id": 1},
            "geometry": {"type": "Point", "coordinates": [-17.879337, 28.760041]}
          },
          {
            "type": "Feature",
            "properties": {"layer_id": 1, "color": "#3388ff", "shape": "circle", "radius": 284.49},
            "geometry": {"type": "Point", "coordinates": [-17.879723, 28.760324]}
          }
        ]
      }
    }]
  }]
}

Import shapes

The import value is the bare data array (no lang keys). The model has its own conform step (src/core/tools/import_conform.ts, reached through conformImportData()), and all four documented shapes are converted into the canonical item shape:

// a full array of items — parses and passes through as-is
[{"lat": 39.4625, "lon": -0.3762, "zoom": 16, "alt": 0}]
// a single bare item — wrapped into a one-item array
{"lat": 39.4625, "lon": -0.3762}
// a bare GeoJSON FeatureCollection — the center is its first Point feature,
// the whole collection is stored as lib_data layer 1
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[-0.3762,39.4625]}}]}
a flat text cell, latitude first, dot decimals: lat, lon[, zoom[, alt]]
39.4625, -0.3762, 16

Absence is structural at the import door too, and a refusal is not a clear. The import never fabricates a coordinate — no default center, no default framing — and the three outcomes are kept distinct:

the source cell outcome
a usable item stored
an item that states there is no location — both coordinate keys present and blank, or an item carrying a lib_data key — or an empty cell the component is cleared: the source said so
anything else with no usable coordinate: one axis without the other, a typo'd header, {}, {"zoom":12} refused, reported in the run's failed rows, and the stored value is left untouched

An item with no coordinate but with drawn lib_data features is a value in its own right and imports as geometry-only. zoom defaults to 16 and alt to 0 on a coordinate item; comma decimals are normalized in the JSON shapes (the flat cell uses the comma as its own separator, so it needs dot decimals); and this is the one door that enforces the coordinate ranges (lat −90..90, lon −180..180) — by refusing loudly, never by silently dropping a value. A field with any refused value is not written at all, including its values that did conform; the run report says so.

See the full import definition in Importing data and the round-trip raw format in Exporting data.

v7 consolidation / evolution

  • Typed column, not the legacy blob. In v7 the position lives in its own GIN-indexed geo JSONB column rather than buried in a monolithic data blob, so it can be spatially queried (jsonb_path_query_array(geo,'$.*[*]')) without decoding the whole record. See the typed-column storage model.
  • Standard GeoJSON inside. Drawn shapes are stored as plain GeoJSON FeatureCollections under lib_data — no Dédalo-specific geometry format — which is what makes them reusable by the paired text-area geo-tag diffusion path (see Server-side handling).
  • Single-point-per-component model. Although the value is an array, the editor manages one map (key 0); multiple positions are expressed as multiple GeoJSON features inside lib_data, not as multiple array items.
  • No magic coordinate in the store. Emptiness is the absence of a value, not a reserved position: an untouched map stores nothing, so no place on earth is unrecordable and 0 is a legal coordinate. The opening camera moved out of the data and into configuration (DEDALO_GEO_DEFAULT_LAT / DEDALO_GEO_DEFAULT_LON / DEDALO_GEO_DEFAULT_ZOOM), delivered as context.features.default_view — a camera, operator-configurable per install and per component, never a value. The one pair with a meaning left is the studio default, and it means something at the publication door only.
  • 0 publishes. The v6 guards were falsy-based, so a stored lat: 0 or lon: 0 published nothing while a blank axis was coerced to a literal 0 and published an invented point. Both are gone: a record on the equator or the prime meridian now publishes the position it holds, and a blank axis publishes nothing. This changes the published bytes of such records, deliberately.
  • The view and the features are both published. The three publication paths agree: nothing is dropped in favour of the other part of the value.

See also