Compass¶
An interactive map of ocean-focused research institutes, NGOs and intergovernmental bodies, driven by a SHACL-validated RDF ontology.
Table of contents¶
- What Compass provides
- Use-cases
- Code structure
- Configuration
- Deployment
- Generate/update the map data
- Development: run it locally
- Widget requirements
- Tests
- Attribution and licences
What Compass provides¶
Knowledge modeling via the COMPASS ontology¶
Compass models a conservation organisation’s partners, projects, networks, and
international forums as RDF instances, tagged with bilingual SKOS vocabularies
(work area, conservation focus, topic, pollution, species, and country/area).
Schema and validation live in SHACL (src/ontology/shapes.ttl); instance and
vocabulary Turtle are generated from an editorial spreadsheet.
A data modeling and data generator friendly to non-semantic experts¶
Editors maintain the map in a spreadsheet — no Turtle or SPARQL required. A generator turns that workbook into RDF and refuses to publish unless it passes SHACL. See Generate/update the map data.
An interactive map¶
A self-contained <compass-map> web component (Svelte + MapLibre) plots those
entities, shades related regions, and offers filter, list, and detail views,
without third-party map traffic at runtime. Embedding and accessibility
constraints are under Widget requirements.
SHACL-driven filters and queries¶
Filter chips and entity queries are projected from SHACL at runtime, so the UI stays aligned with the ontology. Adding a filter dimension is documented under Generate/update the map data.
A backend designed for integration into an existing website¶
Embed <compass-map>, point its apiurl at the FastAPI ontology service, and
adapt languages and story links in use-case config. Same-origin deploy is
covered under Deployment; CORS and related settings under
Configuration.
Use-cases¶
OceanCare¶
Website: https://www.oceancare.org
OceanCare is an international marine conservation NGO founded in Switzerland in 1989. Compass’s first deployment maps their partners, projects, research networks, and international policy forums, with deep links into OceanCare’s Stories & News, so visitors can explore where and how OceanCare works worldwide.
Code Structure¶
src/ontology/ – SHACL shapes, template workbook; use-case data under subdirs
src/ontology/oceancare/ – OceanCare source-data.ods and generated Turtle
src/frontend/ – Svelte + MapLibre widget; scripts/ builds regions, basemap, tiles
src/backend/ – FastAPI service: SPARQL over the ontology, filter schema, reload
src/turtle-generator/ – ODS → RDF generator and its tests
tools/nix/ – the Nix flake providing the dev shell
share/ – standalone demo page; needs an apiurl to point at
tools/docker/ – Dockerfiles, nginx config and the compose entry page
docs/ – MkDocs site (Overview is this README; Backend + Turtle generator sections)
Configuration¶
Data & Ontology¶
See ontology configuration
for the step-by-step checklist to set up a use-case folder, fill
source-data.ods, and run just data::generate.
Backend: settings¶
See backend configuration
for how to configure the backend and adapt it for a new Compass use-case. It
covers API metadata, the stories provider, language support, and deployment
settings such as COMPASS_RELOAD_TOKEN and COMPASS_CORS_ORIGINS.
Frontend¶
Pre-render bathymetry tiles (optional; the map falls back to the vector basemap if tiles are absent):
just map::tiles
The output is gitignored and belongs on the server. The script lives at
src/frontend/scripts/build-tiles.mjs.
Deployment¶
just deploy
Open http://localhost:8780.
Generate/Update the Map Data¶
Start from src/ontology/template-source-data.ods when modeling knowledge for a
new use-case: it has the three sheets and column headers the generator expects,
with no data rows. Copy it into a use-case folder under src/ontology/ (named
to match COMPASS_USE_CASE, for example src/ontology/oceancare/source-data.ods)
and fill it in.
COMPASS_USE_CASE (default oceancare, set in .env) selects which subdirectory
under src/ontology/ the generator and the API use for source-data.ods,
compass.ttl, and vocab.ttl. Shared files (shapes.ttl, the template) stay at
the ontology root.
Upload the workbook (src/ontology/<COMPASS_USE_CASE>/source-data.ods) to Google Sheets to edit it (the three sheets import as tabs),
then download it back as .ods.
compass.ttl and vocab.ttl then get generated into that same use-case folder:
just data::generate
After regenerating Turtle (and shipping the updated files into the ontology directory the API reads), trigger a reload so the running service picks them up without a restart:
curl -X POST -H "X-Reload-Token: $COMPASS_RELOAD_TOKEN" \
http://localhost:8780/api/v1/admin/reload
Set COMPASS_RELOAD_TOKEN on the API (empty disables the endpoint). A rejected
reload leaves the previous ontology serving. See
backend configuration.
| File | Purpose |
|---|---|
src/ontology/template-source-data.ods |
Starting point — empty workbook (headers only) for modeling a new use-case |
src/ontology/<COMPASS_USE_CASE>/source-data.ods |
Source of truth — schemes (the six tag dimensions), concepts (one row per tag term), pins (one row per thing on the map) |
src/ontology/shapes.ttl |
SHACL shapes — drive the filter UI, the SPARQL query, and instance validation |
src/ontology/shacl-shacl.ttl |
Meta-shapes validating that shapes.ttl is well-formed |
src/ontology/<COMPASS_USE_CASE>/compass.ttl |
Generated — instance data (the pins on the map) |
src/ontology/<COMPASS_USE_CASE>/vocab.ttl |
Generated — SKOS controlled vocabularies (topics, species, regions, …) |
Every row carries its own id, and pins link to other rows by id in a
links column. The predicate a link becomes is decided by what it points
at: a link to a Species concept becomes compass:species, one to an
InternationalForum becomes compass:forum. So adding a tag to a pin means
adding an id to its links cell — nothing else. There is no configuration file
and no mapping to keep in step.
Concepts never link out: the concepts sheet has no links column, so a tag is
recorded once, on the pin that carries it. Country/Area concepts are the visible
consequence — a region is shaded on the map only because some pin passing the
active filters points at it, which is also why shading means "matching pins are
in here" rather than something maintained by hand. A region no pin refers to
stays a filter value that matches nothing.
Adding a filter dimension means adding a property shape to shapes.ttl — the
filter panel and the query follow automatically.
A link to an id that does not exist fails the run, naming the sheet, the row and the id. Mistakes are collected across the whole run rather than reported one per attempt.
Country and marine boundary polygons are built separately by just map::regions
(needs network). It reads compass:isoCode out of vocab.ttl, so adding a
region with a code needs no change there; the MARINE table for seas is still
maintained by hand in src/frontend/scripts/build-regions.mjs. A new region also
needs a pin pointing at it before anything shades.
Development: Run it locally¶
Setup¶
Pick one option. Every command in the rest of this README is the same either way.
Option A — uv and Node¶
Works on macOS, Linux and Windows (WSL). Install uv and Node 20 or newer:
curl -LsSf https://astral.sh/uv/install.sh | sh # uv
uv --version
node --version # expect v20 or newer
No system Python needed — uv fetches its own Python 3.11, pinned in src/backend/.python-version.
Option B — Nix on Linux and MacOS¶
Supplies uv, Node 22 and Python 3.11 in one shell:
nix develop ./tools/nix # then run the commands below normally
To run a single command without entering the shell:
cd src/frontend && nix develop ../../tools/nix --command npm run dev
On NixOS this option is required. pyoxigraph ships as a manylinux wheel that links libstdc++.so.6, which NixOS does not provide globally; the flake sets the LD_LIBRARY_PATH that makes it loadable. Outside the shell, any Python command fails.
Local Build¶
For local development without Docker, run the API and widget together:
just dev-up
Open http://localhost:5173; index.html already passes
apiurl="http://localhost:8780" and serves bathymetry from the Vite origin
(tileurl=""). Run just map::tiles once so src/frontend/tiles/ exists;
without it the map falls back to the vector basemap.
Widget Requirements¶
Three constraints the widget has to satisfy wherever it is embedded.
No third-party requests at runtime¶
The widget contacts nothing but its own origin, so embedding it leaks no
visitor data. The basemap is drawn from Natural Earth land and border geometry
bundled into the build (src/frontend/src/map/basemap.json, rebuilt with
just map::basemap), not from a tile service, and it carries no labels — labels
would need glyph files from a font server, and every label the map does show
comes from the ontology anyway. Cluster tallies and the OceanCare star are
drawn on a canvas at runtime for the same reason.
The bathymetry is pre-rendered by just map::tiles into src/frontend/tools/ (1365 JPEG
tiles, ~53 MB, gitignored) and served by nginx from a read-only mount. The map
probes one tile on load and only adds the raster if it resolves, so a
deployment that skipped just map::tiles falls back to the vector basemap.
just check::frontend-standalone fails if any new host appears in the widget source. The allowlist
in src/frontend/scripts/check-offline.mjs holds only inert entries: RDF
namespace IRIs, which are identifiers and never fetched, and oceancare.org,
which the visitor reaches by clicking a link.
The one deliberate exception is the story counter, which calls the API origin
passed in as the apiurl attribute.
Accessibility¶
Targeting the German BFSG criteria, which follow WCAG 2.1 AA.
The map is a canvas and carries nothing for a screen reader, so the same
results are always rendered as a table in the accessibility tree — the map view
includes an off-screen ListView, the very component the list view shows. One
renderer means the alternative cannot drift from what the map displays. The map
itself is a labelled role="application" region describing where that
alternative is.
Filtering changes results without a page load, so the result count is announced
through a polite live region. Every control has a localised accessible name,
text meets the 4.5:1 contrast floor, focus is always visible, and
prefers-reduced-motion suppresses the fly-to animation.
Bilingual content¶
Language variants are RDF language tags, not separate records: one subject
carries "Whales"@en and "Wale"@de, and the SPARQL layer filters on the
requested language. In the workbook a translatable field is a column pair —
name_en/name_de, description_en/description_de,
location_en/location_de, definition_en/definition_de — and every pair
reaches the RDF in both languages.
An empty German cell takes the English text so a German reader never sees a
blank where an English one sees prose. just data::generate reports every substitution,
so a missing translation is visible rather than silently shipped; a clean run
prints every German cell is filled. Some pairs are legitimately identical:
Caracas, Venezuela reads the same in both, and registered names such as
British Divers Marine Life Rescue are not translated.
Tests¶
just check::all # lint, frontend-standalone, tests, then format
just check::tests # backend (API, SHACL, SPARQL builder, ontology contract),
# generator, and the widget's map logic
just check::frontend-standalone # Svelte + TypeScript, and the no-third-party-hosts gate
just check::lint # ruff over both Python projects, ESLint + Prettier over the widget
just check::format # rewrite every source file in the project's style
just data::check # fail if the committed Turtle is stale
Python style is one shared tools/configs/ruff.toml; the widget's ESLint and
Prettier configs sit next to its tsconfig.json, and both read the repository
.editorconfig.
Attribution and licences¶
Two data sources carry obligations, and both are surfaced in the map's attribution control at runtime rather than only in the repository:
- Natural Earth (land, borders, lakes, rivers) is public domain; credit is requested, not required, and is given.
- GEBCO bathymetry is free to use with attribution. The map states
Imagery reproduced from the GEBCO_2026 Grid, GEBCO Compilation Groupand carries GEBCO's condition that it is not to be used for navigation or any purpose relating to safety at sea.
attributionControl is set explicitly in Map.svelte; leaving a licence
obligation resting on a library default would be a mistake.
The widget bundle carries third-party code under BSD-3-Clause, MIT and ISC,
all of which require their notice to accompany a distribution. Two things
satisfy that: esbuild's legalComments: 'eof' keeps the packages' own banners
inside the minified file, and THIRD-PARTY-NOTICES.md is generated from
node_modules on every npm run build and served next to the bundle. Embed
the widget elsewhere and that file has to travel with it.