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/iCalendarGEOuselat,lon. A bare[46.9, 7.4]invites swap bugs. - The key is
coordinates, not the standards'GEO— and the height iselevation, notalt(which reads as alternative just as readily).lat/lonstay: those are the ordinary words for the thing, not a contraction of one. See the naming rule inbase-schemas.md. - Assumed CRS is WGS84 (EPSG:4326).
- Projects cleanly to vCard
GEO/ iCalGEO/ 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 formattedlabelstring 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
geoinside the sameaddressobject:
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
coordinatessimply 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.manualis 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/endbelong in the summary, not only in the file: the endpoint block inui-ux.mdshows 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
bboxfrom 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.