OFM-data-structure-aware web editor for vector layers. Each OFM world has a
raster (or GeoTIFF) basemap and a set of vector layers stored somewhere — this
tool lets you draw, edit, and save those layers on top of the basemap from a
browser, writing back to whatever storage the world's timeline.json declares.
┌─────────────────────────────────┐ ┌──────────────────────────────┐
│ frontend (Netlify-publishable) │ <─REST─>│ backend (FastAPI in Docker) │
│ MapLibre GL + Terra Draw │ │ /srv/ofm mounted at /ofm │
│ loads each world's map.json │ │ PostGIS / SpatiaLite / │
│ directly as a Mapbox-GL style │ │ GeoJSON-file adapters │
└─────────────────────────────────┘ └──────────────────────────────┘
raster-vectorizer/
├── backend/ FastAPI service
│ ├── app.py REST API (/api/worlds/.../layers/...)
│ ├── auth.py HTTP-basic, env-configured single user
│ ├── worlds.py walks /ofm/*/timeline.json
│ ├── storage/
│ │ ├── base.py Adapter protocol
│ │ ├── geojson_file.py writes raw/geojson/<layer>.geojson
│ │ ├── spatialite.py writes <world>/<world>.db (column: the_geom)
│ │ └── postgis.py writes shared PG host (column: geom)
│ └── tests/ pytest suite (API, adapters, PostGIS saves)
├── frontend/ static SPA, Netlify-ready
│ ├── index.html
│ ├── editor.js MapLibre setup + draw lifecycle
│ ├── style.css dark editor theme
│ ├── config.js runtime config (OFM_API_URL)
│ ├── config.js.tmpl envsubst template for Netlify
│ ├── netlify.toml
│ └── _redirects
├── tools/ retained batch CLI (mosaic, palette, segment)
├── Dockerfile multi-stage: `base` (service) + `test`
├── docker-compose.yml dev/test orchestration
└── requirements.txt
cd /srv/ofm/raster-vectorizer
# build + run tests (starts a throwaway PostGIS 12 for the PostGIS save tests).
# Use a separate project name: `down` on the default project would also stop
# the deployed editor and frontend.
docker compose -p rv-test --profile test run --rm --build test
docker compose -p rv-test --profile test down -v
# launch the editor backend (bound to localhost:8765)
docker compose up --build editorEnvironment variables (copy .env.example to .env, which is git-ignored;
docker compose refuses to start the editor without the two passwords):
| var | purpose | default |
|---|---|---|
OFM_EDITOR_USER |
HTTP basic auth user | required |
OFM_EDITOR_PASSWORD |
HTTP basic auth password | required |
OFM_EDITOR_OPEN |
1 = no auth, for local development only |
unset (closed) |
OFM_PG_PASSWORD |
password of OFM_PG_USER on the shared PostGIS |
required |
OFM_CORS_ORIGINS |
comma-separated allowed origins (Netlify URL) | * |
OFM_PG_HOST/PORT/… |
override shared PG host for PostGIS adapter | 51.15.160.236 |
The frontend/ directory is a pure-static SPA. Deploy it standalone:
cd frontend
netlify deploy --dir . --prod
# in Netlify dashboard → site settings → env vars:
# OFM_API_URL = https://your-editor-backend.example.comnetlify.toml does envsubst on config.js.tmpl at build time so the deployed
JS points at your chosen backend.
For local frontend dev:
cd frontend
python3 -m http.server 5173 # any static server works
# open http://localhost:5173, backend must be running on :8765All routes under /api require HTTP basic auth. Without configured credentials
the API refuses every request (503) unless OFM_EDITOR_OPEN=1.
| route | method | purpose |
|---|---|---|
/health |
GET | liveness |
/api/worlds |
GET | list discovered worlds |
/api/worlds/{slug} |
GET | timeline + gaia + map.json descriptor |
/api/worlds/{slug}/style |
GET | the world's Mapbox-GL style |
/api/worlds/{slug}/layers |
GET | layers known to the storage backend |
/api/worlds/{slug}/layers/{layer} |
GET | FeatureCollection |
/api/worlds/{slug}/layers/{layer} |
PUT | save layer (non-destructive, see below); returns what changed |
/api/worlds/{slug}/layers/{layer} |
DELETE | drop the layer |
/api/worlds/{slug}/layers/{layer}/features |
POST | add features (always inserts; the rest of the layer is untouched) |
/api/worlds/{slug}/sources |
GET/PUT | the world's source registry (Cited GeoJSON sources), validated |
/api/worlds/{slug}/scans |
GET/POST | list / upload scanned maps (optionally registering the file under a source, with checksum) |
/api/worlds/{slug}/scans/{name}/image?max=N |
GET | PNG preview; X-Scale header converts preview px to full-resolution px |
/api/worlds/{slug}/scans/{name}/georef/fit |
POST | dry run: fit control points → accuracy, per-point errors, checks, footprint |
/api/worlds/{slug}/scans/{name}/georef |
GET/PUT | the saved Georeference Annotation / fit and save (422 if a check fails) |
/api/worlds/{slug}/scans/{name}/warp |
POST | warp with the saved georeference into cogs/<name>.tif (a basemap) |
/api/assist |
GET | which assistants are available (label reading: provider + model, never the key) |
/api/worlds/{slug}/scans/{name}/trace |
POST | flood-fill from seed → lon/lat polygon citing the traced pixels |
/api/worlds/{slug}/scans/{name}/read |
POST | read the labels in region xywh with the vision model → labels + a citation |
/api/worlds/{slug}/layers/{layer}/cited-geojson |
GET | the layer as a validated Cited GeoJSON document (422 + reasons if it cannot be) |
/api/worlds/{slug}/layers/{layer}/cited-geojson |
PUT | import a Cited GeoJSON document: merge its sources, save its features |
/api/worlds/{slug}/rasters |
GET | list raster sources for this world (pyramids + GeoTIFFs) with zoom/bounds/extension |
/api/worlds/{slug}/rasters/{source}/tiles/{z}/{x}/{y}.{ext} |
GET | serve a tile from a named source (raw pyramid or rendered from GeoTIFF) |
/api/worlds/{slug}/tiles/{z}/{x}/{y}.{ext} |
GET | legacy alias — first raster source |
/api/schemas |
GET | list of manifest kinds (timeline|gaia|map|render) |
/api/schemas/{kind} |
GET | JSON Schema for that kind |
/api/worlds/{slug}/manifest/{kind} |
GET | current contents of that manifest file |
/api/worlds/{slug}/manifest/{kind} |
PUT | validate (422 on schema fail) + atomic write, rolling .bak |
/api/worlds/{slug}/layers/{layer}/schema |
GET | per-layer feature-properties JSON Schema (empty stub if none) |
/api/worlds/{slug}/layers/{layer}/schema |
PUT | replace layer schema (422 if body isn't a valid JSON Schema) |
Selected per-world from timeline.json#mode:
mode: "geojson"(default) — one.geojsonper layer under<world>/raw/geojson/mode: "spatialite"— uses<world>/<world>.dbwith columnthe_geom, SRID 4326mode: "postgis"— connects to the shared OFM PG host (51.15.160.236:45432), columngeom, SRID 4326
If the configured adapter can't connect (e.g. PG unreachable from the container), the API returns 503 with a useful diagnostic message rather than 500.
Features can cite their sources in the Cited GeoJSON format:
- Source registry —
<world>/raw/sources.json, the same object as a Cited GeoJSON document'ssources(keyed by source IRI; for OpenHistoryMap, Zotero item IRIs). Validated against the vendored 0.1 schema (backend/schemas/cited-geojson-0.1.schema.json). - Citations travel as the feature-level
citationsmember. PostGIS and SpatiaLite layers get acitationsJSON column on the first save that needs it; GeoJSON files keep the member as is. Citations are versioned in the edit history like everything else. An absentcitationsmember leaves stored ones alone;[]clears them. - Export assembles a layer as a Cited GeoJSON document with only the
sources it cites (plus their
derivedFromancestors) and the georeferences of the scans it cites, and validates it (schema and cross-references) before returning it. Import merges a document's sources into the registry (existing entries win; differences are reported) and saves its features, keeping their ids.
Scanned maps are uploaded to <world>/raw/scans/. Control points (pixel ↔
lon/lat) are saved as an IIIF Georeference Annotation — the
Allmaps format, and what Cited GeoJSON's georeferences
carries — in <world>/raw/georef/<name>.json (backend/georef.py).
- Transformations: polynomial of order 1–3 (least squares) or thin-plate spline, fitted like Allmaps from pixels to Web Mercator metres, with a separate backward model for warping.
- Accuracy, honestly: per-point residuals and a leave-one-out error (each point predicted by a model fitted without it), both in metres on the ground. Thin-plate splines interpolate exactly, so for them only the leave-one-out figure is published. The accuracy is stored in the annotation and travels into Cited GeoJSON exports.
- Checks: too few or collinear points, an image that maps outside valid
coordinates, a size far from the declared
expectedWidthM(the error that left starfleet-defiant's decks ~1 cm long), images a few centimetres across, a transformation that folds over itself, over-fitting, and whether the world's default view falls inside the image. Error-level checks block saving. - Warping uses the same backward model as the reported accuracy and writes
an EPSG:3857 tiled GeoTIFF with overviews to
<world>/cogs/<name>.tif, which the raster discovery serves as a basemap.
A second page next to the editor (header link georeference ↗), sharing its sign-in:
- Scan ↔ map side by side: upload a scan (optionally registering it under a source), zoom and pan it, and click matching places on the scan and on the map (the world's style or OpenStreetMap) to add control points.
- Live accuracy: every change re-fits and shows the RMSE, the leave-one-out error, per-point errors (the worst point highlighted), the checks, and the image's footprint on the map. Save the georeference, then Warp to basemap.
- Trace: click inside a shape on the scan; the traced polygon appears on both sides and can be added to any layer (or a new one) with its citation.
- Read labels: drag a box around labels; the configured model transcribes
them, and use as name names the traced feature and adds a
transcribedcitation of that region.
In the layer editor, the selected feature's panel lists its citations (source, where, method, what they support) and can add or remove them using the world's source registry. A save sends citations only for features whose citations were edited, so nothing else is rewritten.
- Trace (
backend/trace.py) — plain computer vision: flood-fill from the clicked pixel within a colour tolerance, outline with holes, simplify, map through the scan's georeference. The polygon cites the exact pixels it came from (anSvgSelectorwith the outline and the bounding box as aFragmentSelector). Fills that leak through a gap are flagged. - Read labels (
backend/llm.py) — the region is cropped and sent to a vision model, which transcribes the labels as written (no translation or modernisation) and guesses what each names. The response includes a ready citation (method: "transcribed") of that region, noting which model read it. Geometry never comes from the model.
The model is configured only by environment, through the standard
OpenAI-compatible /chat/completions API, so public and private models are
interchangeable:
| var | default | |
|---|---|---|
OFM_LLM_BASE_URL |
https://openrouter.ai/api/v1 |
any OpenAI-compatible endpoint (OpenRouter, OpenAI, vLLM, Ollama, LM Studio…) |
OFM_LLM_API_KEY |
unset | bearer key; optional for private servers |
OFM_LLM_MODEL |
google/gemini-2.5-flash |
any vision-capable model id on that endpoint |
OFM_LLM_TIMEOUT |
120 |
seconds |
Label reading is enabled when a key is set, or when the base URL points
somewhere other than OpenRouter. The key stays on the server; /api/assist
only reports the provider host and the model.
A save never rewrites the layer wholesale (backend/storage/diff.py):
- Stable ids. Every feature has a permanent
fid(uuid). Older tables get afidcolumn, backfilled, on their first save; until then they load withrow:<pk>ids, so merely reading a layer never alters it. - Only what changed is written. Incoming features are matched by id:
changed ones are updated, new ones inserted, missing ones deleted, identical
ones left alone. A property absent from a feature leaves the stored value
alone; an explicit
nullclears it. - Every column is kept. Properties go to the column of the same name (so
from_time,to_time,subclass,wiki, … survive), otherwise into thepropertiesJSON column when there is one. Anything with no place to go is returned asunstored_attributesand shown in the editor instead of being dropped. - All or nothing. The save is one transaction; a feature whose geometry does not fit the layer (a line in a polygon layer) rejects the whole save with HTTP 422. Single geometries are promoted to Multi when the column is Multi, and Z/M coordinates are dropped (layers are XY).
- History. Every insert, update and delete is recorded with full before and
after images and the editor's user name: in an
_ofm_historytable (PostGIS, SpatiaLite) orraw/history/<layer>.jsonl(GeoJSON files).
The PUT response reports inserted, updated, deleted, unchanged and
unstored_attributes. The editor sends feature ids back, rejoins the parts of
multi-part features it split for editing, and reloads after saving so new
features pick up their permanent ids.
Before this, saving truncated the table and re-inserted only name, class
and properties: on PostGIS/SpatiaLite layers that keep attributes as columns
one save erased them. backend/tests/test_save_nondestructive.py is the
regression suite for that.
Listed 80 worlds, loaded 245-feature locations layer, drew + saved a new
test_polygon, reloaded, file persisted at
/srv/ofm/planetos/raw/geojson/test_polygon.geojson, then deleted cleanly.
Two basemap source kinds are supported per world. Discovery walks the world dir and reports everything it finds.
Pre-baked tile pyramids — folders under raw/tiles/, aerial/, or
tiles/. Two layouts on disk are recognised:
{layer}/{z}/{x}/{y}.{ext}— standard XYZ (planetos, sta, alien){layer}/{z}/{x}_{y}.{ext}— piggyback layout (cyberpunk, zelda-botw)
GeoTIFFs — any .tif/.tiff at the world root, in raw/, or in
cogs/. Rendered to PNG tiles on demand via rio-tiler (which uses the
file's own georeferencing); ideally a Cloud Optimized GeoTIFF for fast
random tile access.
GET /api/worlds/{slug}/rasters returns each source with min_zoom,
max_zoom, ext, plus (for GeoTIFFs) bounds in EPSG:4326 and the
dataset's native crs, so the frontend can position the map and pick
the right tile URL.
The frontend's Basemap dropdown lists all discovered sources; picking one swaps it into the active MapLibre style as a raster layer at the bottom of the stack (vector overlays sit on top).
The editor has a second mode for editing a world's four manifest files
directly (no map). Frontend uses Monaco with the JSON Schema wired in so you
get inline validation + autocomplete + hover-descriptions. Saves go through
/api/worlds/{slug}/manifest/{kind} which re-validates server-side and
rejects (422) with per-path error messages before writing.
Files covered:
| kind | file in <world>/ |
schema (backend/schemas/) |
|---|---|---|
timeline |
timeline.json |
timeline.schema.json |
gaia |
gaia.json |
gaia.schema.json |
map |
map.json |
map.schema.json (Mapbox-GL v8 + ofm extensions) |
render |
render.json |
render.schema.json |
Schemas were derived from a sweep of all 80 worlds — mode enums, the
ofm-extension fields under map.metadata.ofm, the texture/point render
types, and the wiki-block shape all come from observed usage. Required
fields are minimal so older worlds with quirky content still load; new
fields can be added by editing the schemas without breaking anything.
Each save writes atomically (tmp + os.replace) and rotates a single
.bak, so a bad save can always be recovered from <file>.bak.
Each layer can carry a per-layer JSON Schema that describes the typed
shape of its features' properties object. Schemas live in a sidecar
file at <world>/raw/schemas/<layer>.schema.json — independent of the
storage backend, so they work the same for GeoJSON / SpatiaLite / PostGIS
layers.
When a schema is present:
- The map view's feature-property panel renders typed inputs (text /
number / checkbox /
<select>for enums) instead of free-form text. - Property descriptions appear as tooltips on the labels (ⓘ marker).
- The + field button in the side panel prompts for a field name, type
(
string/integer/number/boolean/enum), and optional description, then PUTs the updated schema.
When no schema is present, the property panel falls back to free-form key/value editing — existing layers keep working without modification.
In manifest mode the side has a Raw / Form toggle:
- Raw — Monaco JSON editor with inline schema validation and autocomplete
- Form —
@json-editor/json-editorrenders a form from the schema with typed inputs, enum dropdowns, descriptions, and nested array/object add-row buttons
Switching panes copies the data both ways, so you can start in the form,
swap to raw to paste a snippet, then back to the form. The Form button is
disabled for map.json (Mapbox-GL Style Spec — too complex for a useful
form) and orbitals.json (recursive satellites confuses generators).
The editor honours the metadata.ofm extensions in each world's map.json:
- Togglable groups —
metadata.ofm.togglable[]renders as a checkbox per group in the side panel. Toggling hides/shows the named style layers viasetLayoutProperty(layerId, 'visibility'). Optionallegendarrays render as color swatches under the toggle (used by sta to display the star-class color key). - Time slider — when
metadata.ofm.timelinedeclares a[min, max]date range, a slider + date input appear above the map. Moving them substitutes{atDate}in any source URL that contains the template and callssetData()/setTiles()to re-fetch — so time-bounded layers (e.g. sta'spoliticssource served with?atDate={atDate}) update live as you drag.
The map.json schema mirrors observed usage across the 73 worlds that have
one — togglable items require label/name/layers and optionally
carry height, legend (array of [label, color] pairs), sources.
metadata.ofm.type is an enum: galaxy | map | starbase | world | system | deck.
The full sta/map.json is the canonical reference; it exercises every
extension at once.
tools/azgaar_import.py ingests the 5-file GeoJSON export from Azgaar's
Fantasy Map Generator and produces a fully-furnished OFM world (timeline.json,
gaia.json, map.json with biome-categorical fill / river width-by-discharge /
emoji marker symbols / togglable groups, and per-layer JSON Schemas).
# Drop the GeoJSONs in /srv/ofm/<slug>/to_import/ (or anywhere), then:
docker run --rm \
-v /srv/ofm:/ofm \
-v /srv/ofm/raster-vectorizer/tools:/tools \
python:3.12-slim python3 /tools/azgaar_import.py \
--in /ofm/<slug>/to_import --slug <slug> --name "<Display Name>"It detects layers by case-insensitive filename substring, so Jarciland Cells 2026-05-18-10-44.geojson resolves to cells. Recognised layer names:
cells, routes, rivers, markers, zones, burgs — any subset works,
but cells is required. The world's base lat/lng/zoom comes from the
data's bbox.
The resulting map.json includes 6 togglable groups out of the box:
Biomes (cells) with the Azgaar palette + legend, States (politics) as
a hidden-by-default alternate view that colours cells by state with
deterministic HSL hues, Rivers with widthFactor-driven line widths,
Routes colour-coded by group (roads/trails/searoutes), Zones as
translucent overlays using each zone's own color property, and Markers
that render the literal emoji icon field as text via MapLibre symbols.
OFM accepts arbitrary Unicode in feature properties (emoji marker icons,
non-Latin place names) and serialises it through a custom UnicodeJSONResponse
class — json.dumps(ensure_ascii=False) over the dict, then UTF-8 encode.
Lone surrogate halves (a real-world bug in some Azgaar exports of "ancient
inscription" markers) get scrubbed to U+FFFD before encoding so they don't
crash the encode step.
?storage=override: API currently followstimeline.json#modestrictly. Letting the user pick a non-default backend per-edit is on the roadmap.- Line tracing: click-to-trace fills areas; roads, rivers and walls drawn as lines on a scan are not yet traced to centre lines.
- Citation editing in the layer editor covers source, method, page, quote
and
supports; section and image-region selectors come from the georeferencing workbench only. - Temporal (atDate): OFM supports temporal queries; the editor ignores the date dimension for now.