Files
Terrata/docs/design/geo.md
T
boxi 3b2c499488
Build CI image / build-image (push) Canceled after 36s
CI / build (push) Canceled after 6s
Merge remote-tracking branch 'origin/main'
# Conflicts:
#	CLAUDE.md
#	docs/design/geo.md
#	docs/design/schema-and-types.md
2026-08-20 00:34:56 +02:00

7.1 KiB

Design: Geospatial data storage

Status: accepted (early design) — how the three kinds of geo data are stored. Read with storage-layout.md (bundles/assets) and tech-stack.md (SQLite R-Tree, MapLibre + PMTiles).

Three geo kinds of very different complexity, handled proportionally:

1. Coordinates — frontmatter, named (not positional)

A single point lives in the frontmatter as a named object, never a positional array:

coordinates:
  lat: 46.9480
  lon: 7.4474
  elevation: 540    # optional, metres above sea level
  • Named beats positional because ordering conventions clash: GeoJSON uses [lon, lat] (x,y), while vCard/iCalendar GEO use lat,lon. A bare [46.9, 7.4] invites swap bugs.
  • The key is coordinates, not the standards' GEO — and the height is elevation, not alt (which reads as alternative just as readily). lat/lon stay: those are the ordinary words for the thing, not a contraction of one. See the naming rule in base-schemas.md.
  • Assumed CRS is WGS84 (EPSG:4326).
  • Projects cleanly to vCard GEO / iCal GEO / a GeoJSON Point.
  • Indexed in the R-Tree as a degenerate bbox (minX=maxX=lon, minY=maxY=lat).

2. Addresses — structured (vCard ADR) with a nested coordinate cache

  • Store structured, matching vCard ADR (po-box, street, locality, region, postcode, country) plus an optional formatted label string for the worldwide variation no schema captures perfectly. This is the nested-structure case from the field index: stored faithfully, indexed shallowly.
  • Geocoding → cached in the file, nested inside the address. The typed parts are the source, the resolved coordinate sits as geo inside the same address object:
address:
  street: Bahnhofstrasse 1
  postcode: "8001"
  locality: Zürich
  country: CH
  geo: { lat: 47.3702, lon: 8.5401 }    # resolved from the parts above

Two things follow from the nesting. It makes the pair one rendering unit (address text beside its mini-map), and it answers "typed or computed?" structurally: everything under address: except geo was typed. No derived flag is needed, and none is used.

  • Why the file and not just the index. The index is per-device and not synced, so an index-only geocode means every device resolves every address again. Against a rate-limited free geocoding service that is the difference between one request per address per vault and one per address per device — so the cache belongs where it can be shared: in the record. (This is also why a stale coordinate is tolerable but a missing one is not.)
  • Two honest caveats: geocoding is best-effort (not every address resolves) and is a privacy/offline concern (it sends the address to a service). For local-first: prefer an offline geocoder or make it explicit opt-in; otherwise the nested coordinates simply stays absent.
  • Resolution is an explicit user action and bidirectional. In the field-edit state (the header pencil, see ui-ux.md) you can enter an address → derive coordinates (geocode) or set coordinates / the map pin directly; coordinates → address (reverse-geocode) is the same pattern. It needs no per-field configuration — the affordance follows from the field type. Resolution happens only on that explicit action, never while rendering — so display stays offline-safe, privacy-preserving and inside the rate limit. (Phrasing it as "on an explicit action" rather than "in edit mode" matters now that the body is always editable — there is no mode that could imply permission to hit the network.)
  • A hand-corrected pin is never silently overwritten. The entry then carries geo: { …, manual: true }; a changed address only offers re-resolving. manual is written only when it applies — its absence means "as resolved".

3. Tracks & bulk geometry — an open-format file, NOT frontmatter

Thousands of trackpoints as YAML would be huge, unreadable and slow — YAML is the wrong tool. And using a file does not violate our principles: the "everything in frontmatter" rule was about fields that project to vCard/iCalendar (to avoid splitting a record's truth). Geometry has no competing frontmatter representation — it is inherently bulk — so there is no dual-truth to avoid.

  • The geometry is an open-format file in the bundle: GPX for tracks (the stated import/export round-trip format), GeoJSON where general geometry/areas are needed (GPX cannot do polygons). Both are open, diffable, GIS-tool-interoperable — the "human-readable & open" principle holds.
  • The frontmatter holds only a summary + reference — lean and indexable:
type: track
title: Hardergrat
track:
  file: hardergrat.gpx                 # asset in the same bundle
  bbox: [7.85, 46.68, 7.92, 46.73]     # minLon, minLat, maxLon, maxLat
  distance_meters: 24300
  duration_seconds: 28800              # elapsed recording time; absent if the GPX has no timestamps
  ascent_meters: 1650
  descent_meters: 1480
  points: 4213
  start: { lat: 46.6863, lon: 7.8632, elevation: 567 }
  end:   { lat: 46.7274, lon: 7.9151, elevation: 1322 }
  • start / end belong in the summary, not only in the file: the endpoint block in ui-ux.md shows coordinates + elevation per endpoint, and a list or map overview must render that without parsing the GPX. Same status as the other summary values — derived, read-only, regenerated on import.

  • The track is edited by uploading the GPX (the geometry file is authoritative); start / end / distance / ascent / descent are shown as a read-only summary derived from the file, not hand-typed. Optional start/end address labels (reverse-geocoded, cached, overridable) may accompany the summary.

  • The summary is a derived cache regenerated from the GPX on import/change — not a second authoritative truth (the two are never independently editable). Authoritative = the geometry file. Note that this is the same pattern as the address: the cached values sit in the same object as their source (file), so the object itself says what is computed (field-types.md, storage rule 5). The payoff here is readability — a person or a foreign tool sees "24,3 km" without parsing 4213 trackpoints.

  • The R-Tree indexes only the bbox from the summary; the full geometry is loaded only when rendering the map. Map/nearby queries stay fast regardless of track size.

Summary

Geo complexity Stored where R-Tree entry
Point (coordinate) frontmatter coordinates: {lat, lon} point = degenerate bbox
Address frontmatter (vCard ADR) with a nested coordinates via that nested coordinates
Track / area open-format file in the bundle + frontmatter summary bbox from the summary

Rule of thumb: point-level geo → fields; multi-point geometry → open file + summary. Rendering uses MapLibre + PMTiles (offline tiles); records' points/tracks are layers on top.