DroneCert
Evidence status. 1253 exam-eligible items, 0 of them read by a person. 93 sequencing edges, 0 confirmed by a person. 0 of 901 knowledge points adopted by a person. What this means

Project notes

The documents that explain this build, rendered from docs/ so they cannot drift from the repository.

62 — How the project uses geospatial data — 62-geospatial-data-architecture.md

Status: describes what is built (2026-10-29). Companion to docs/61, which plans the layers; this document explains the architecture a reader needs in order to trust the map.

One sentence: offline acquisition and pre-processing, static GeoJSON files, on-demand loading in the browser, and spatial computation on the client. External APIs refresh the data and resolve place names; the aerodromes, airspace, protected areas and fire perimeters on the map come from snapshots the project owns.


1. There is no crawler, and the map does not call the sources

Acquisition happens in the build pipeline, never when a reader opens the map.

pipeline/fetch_geo.py          download the raw bytes of each source
        |                      -> knowledge/geo/raw/<source>/<date>/   (git-ignored, ~100 MB)
        v
pipeline/build_geo_layers.py   filter, simplify, validate, write
        |                      -> app/public/data/geo/*.geojson + index.json
        v
pipeline/audit_geo.py          re-hash, re-count, re-check the bbox and the instrument
        |                      -> research/geo-audit.json  (exit 1 on drift)
        v
layer source how it is obtained raw size
Aerodromes OurAirports direct CSV download 12.2 MB
Airspace openAIP REST API, paged 1,000 at a time, 3 pages; key in pipeline/.openaip-key 12.4 MB
National parks Parks Canada ArcGIS feature service query returning GeoJSON 22.8 MB
Built-up areas Natural Earth ZIP download, Shapefile read with pyshp 12.5 MB
Wildfires NRCan CWFIS WFS request returning GeoJSON 6.1 MB
Bird sanctuaries ECCC CPCAD ArcGIS query 33.6 MB

The API, ArcGIS and WFS endpoints are build-time calls. None of them is contacted when a reader opens the map.

2. What actually ships

app/public/data/geo/
├── index.json      3 KB      the manifest: which layers exist, with counts, dates and licences
├── aerodromes      ~1.06 MB
├── airspace        ~2.33 MB
├── parks           ~0.34 MB
├── populated       ~0.24 MB
├── wildfire        ~1.10 MB
└── wildlife        ~0.66 MB

About 5.7 MB, committed to the repository. These are derived files, not the raw downloads.

term meaning here
GeoJSON JSON holding geographic features: points, polygons, and their properties
manifest (index.json) the list of layers with metadata; a layer missing from it is not drawn
static file generated once and read as-is; no per-request query against the source
snapshot the date the data was fetched and built. 2026-10-28 records when, and does not mean it self-updates

3. What happens when a reader sets a position

  1. Page opens — reads /data/geo/index.json and learns which layers exist.
  2. A layer chip is pressed — that layer's GeoJSON is fetched, its entities are created and drawn. A chip switched off fetches nothing and builds nothing (this is the fix described in §5).
  3. Cesium draws it — points become markers, polygons become areas, airspace becomes volumes with floor and ceiling.
  4. Spatial tests run against the loaded layers — is the point inside a polygon; how far is the nearest aerodrome.

Different chips show different markers because each chip is one independent dataset, held in its own Cesium CustomDataSource. The aerodrome points use a clustered collection, so 2,479 of them stay readable at a national view.

4. What still needs the network

request when purpose
OSM raster tiles panning, zooming the basemap: roads, terrain, place labels
Nominatim search typing a place name geocoding: name -> coordinates
Nominatim reverse clicking in the picker reverse geocoding: coordinates -> nearby name
/vendor/cesium/Cesium.js first map open the renderer, ~6 MB, from this project's own origin

Two different operations are often confused: searching a place needs a service that resolves a name to a coordinate; checking an area is local arithmetic over GeoJSON already in the browser. The app's own search box does not search the map at all — it searches the corpus (lessons, sections, quiz items).

5. How the map decides whether a position is restricted

  1. Point in polygon — ray casting tests the coordinate against every loaded polygon: airspace, national park, wildlife area, built-up area, fire perimeter. The answer is which of them contain the point.
  2. Distance to an aerodrome — the Haversine formula gives the great-circle distance to the nearest aerodromes, which is what the 3 NM / 1 NM question needs.

A geometric answer is not a legal one. Being inside a polygon says where you are; the verdict still needs the right instrument, the aerodrome type, the conditions and any authorization. The page says so, and each layer names its own instrument (CAR 901.47, CAR 901.71, Parks Canada, ECCC, NOTAM).

6. Why the map used to freeze

The first version created roughly 6,000 entities when the component mounted, then merely hid the collections for layers the reader had switched off. The browser paid for all of them: construction, memory and per-frame work.

It now exits early per layer: a hidden layer fetches nothing and builds nothing. That is ordinary lazy loading, and it is the difference between a page that scrolls and one that hangs.

7. How the data is updated

python pipeline/fetch_geo.py --source openaip --source cwfis   # only the sources that changed
python pipeline/build_geo_layers.py                             # rebuild every layer file
python pipeline/audit_geo.py                                    # exit 1 on drift
cd app && pnpm run build                                        # the new snapshot date reaches index.json

The build also simplifies geometry (Douglas-Peucker, 0.001-0.01 deg, recorded per layer in the manifest) to keep the files renderable, and it refuses:

  • a vertex outside Canada's bounding box;
  • a polygon ring that does not close;
  • a duplicate feature id;
  • a feature missing a required property, or a layer missing the instrument it must name;
  • a manifest hash that no longer matches the file.

When any of those fails, the audit exits non-zero and tests/test_geo_layers.py fails with it. A bad snapshot cannot reach a release quietly.

8. The architecture in four phases

phase what happens
acquire CSV download, REST API, ArcGIS and WFS, all at build time
prepare clean, filter, simplify, validate, write GeoJSON + manifest
serve the browser fetches only the layers a reader switches on, then computes locally
refresh re-acquire, rebuild, audit, redeploy. Opening the map never triggers this

docs/61 holds the per-layer plan, the schema and the PoC; this document holds the operating model.

9. Reading the map from inside China: the Mapbox switch (2026-10-29)

OpenStreetMap tiles and Nominatim are slow or unreachable from mainland China, which leaves the globe blank and the place search silent. The pano editor solved this by making every map dependency a server-side setting; this app is static, so the equivalent is one build-time public variable.

# app/.env.local   (git-ignored; .env.example documents it)
NEXT_PUBLIC_MAPBOX_TOKEN=pk.eyJ1...
NEXT_PUBLIC_MAPBOX_STYLE=mapbox/streets-v12

With a token present, lib/geo-provider.ts switches three things at once:

dependency without a token with pk. token
basemap OSM raster tiles Mapbox raster tiles, styles/v1/<style>/tiles/512/{z}/{x}/{y}, 512 px, Web Mercator
place search Nominatim search Mapbox Geocoding v6 forward, country=ca, reader's language
reverse geocode Nominatim reverse Mapbox Geocoding v6 reverse

Both geocoders return the same shape, so the components do not know which one answered.

What was copied from the pano editor, and what was not

pano decision here
raster tiles inside Cesium, not Mapbox GL copied - Cesium owns the globe, the basemap is an imagery layer
512 px tiles, one extra zoom level copied - the same endpoint needs the offset or detail stops a level short
Classic-family style (Standard styles return nothing from the tiles API) copied - the default is mapbox/streets-v12
secret token kept server-side, geocoding through a gateway not copied - there is no server; the browser calls Mapbox with a public token, and an sk. token is refused outright
tile budget per session not copied - it exists to cap a bill; this app has no per-session accounting, and the source table names the licence instead

Credit

The visible credit line switches with the provider: basemap © Mapbox and © OpenStreetMap contributors when Mapbox is on, © OpenStreetMap contributors when it is off. Mapbox's own attribution requirements and OSM's both need the text to be present, so it is a build-time choice rather than a runtime one.

Why a public token is acceptable here

Mapbox's pk. tokens are designed to be visible in a browser and are protected by URL restrictions on the Mapbox account, not by secrecy. A sk. token in a bundle would be a full key disclosure, so geo-provider.ts refuses one and falls back to OSM.

63 — The admin tab: what a setting may change, and what it may not — 63-admin-settings.md

Status: built (2026-10-29). Companion to docs/62.

1. Why this exists

Settings were constants scattered across components: the map's opening layer set lived in cesium-view.tsx, its opening zoom was a literal 90000, clustering was true, and the Mapbox switch was a build variable no page could report. The pano editor has an admin surface for exactly this reason - an operator needs one place that says what can be changed, what it affects, and where the value is stored.

The tab is /admin, in the sidebar under ADMIN.

2. The scope model, and why it is not negotiable

This app is a static export. There is no server to hold a setting, so:

scope stored in who it affects
browser localStorage, key dronecert.setting.<id> this reader, this browser
build a NEXT_PUBLIC_* variable in app/.env.local every reader, after a rebuild

Pretending otherwise would be the lie this project keeps refusing to tell. The page prints the distinction on the section heading and, for every build setting, the exact variable and file that set it.

3. What the admin tab owns today

setting type scope default read by
map.layers.aerodrome toggle browser on initialVisible()
map.layers.airspace toggle browser off initialVisible()
map.layers.park toggle browser on initialVisible()
map.layers.populated toggle browser off initialVisible()
map.layers.wildfire toggle browser on initialVisible()
map.layers.wildlife toggle browser on initialVisible()
map.cluster toggle browser on the aerodrome collection
map.openZoomKm number, 2-800 browser 90 the opening camera
Mapbox token build read-only - lib/geo-provider.ts
Map data snapshot build read-only - app/public/data/geo/index.json
Reference links build read-only - research/reference-links.json

The last three are not settings a visitor can change; they are facts about this build that the page reports, because an operator needs them in one place and a reader deserves to see them.

4. What is deliberately NOT configurable

thing why not
exam length, pass mark, timer (35 questions / 90 min / 65%) these numbers describe an official exam. A setting would let a reader bend a benchmark until it passes, which is the opposite of what the app is for. They are constants in app/app/exam/page.tsx, and a change to them is a change to the product, made in review
the citation on a rule a section reference is evidence, not a preference. docs/61 §2b and tests/test_geo_layers.py refuse a layer that claims the wrong instrument
the corpus lessons, quiz items and their provenance are content: they change through the pipeline and review, not through a switch
the language already a first-class choice in the sidebar; duplicating it in settings would create two sources of truth

5. How a new setting is added

  1. Add one line to SETTINGS in app/lib/settings.ts - id, group, type, scope, default, and for a build setting the source.
  2. Add admin.setting.<id> and admin.help.<id> to app/lib/i18n/locales/admin.ts (three locales).
  3. Read it where it is used with getSetting("<id>").
  4. tests/test_admin_settings.py fails if a setting has no scope, a build setting has no source, or the page asks for a string that does not exist.

The registry is the single source of truth: the page renders from it, so a setting that exists in code but not in the registry cannot be reached, and one in the registry but not handled shows up as a control that does nothing - visible, and testable.

6. Moved: the evidence panels now live here (2026-10-29)

Three sections were on /map and belong to the operator, not the pilot in flight:

moved to Settings was
Where the map data comes from (six layers, source, count, snapshot, licence) bottom of /map
Where the answers come from — reference links (five verified links with dates) bottom of /map
Why there is no map drawn here (airspace is not republished; NAV CANADA and NRC are authoritative) /map body

A fourth card was added: How the data reaches this map, the one-paragraph summary of docs/62 with the pipeline scripts named, so the architecture document is reachable from the surface it describes.

/map keeps one line pointing at the tab, and its lede no longer claims the note is at the bottom of the page.

And a rendering failure, found while testing this

The map showed Cesium's own error panel: t[n].queueReprojectionCommands is not a function, rendering stopped. The cause was ours: baseLayer was given a bare imagery provider, and this vendored Cesium throws inside the Viewer constructor when it expects an ImageryLayer. The failure was swallowed by a silent catch, so the page showed a canvas with an error and no indication of which call had failed.

Two changes: the basemap is wrapped in new Cesium.ImageryLayer(...), and the catch now logs VIEWER CONSTRUCTION FAILED with the error. A thrown constructor must never be quiet — a silent catch is how a broken map looks like a working one.

Correction (2026-10-29, same day)

I first put these panels inside the Settings page, which is not what was asked: the settings page is configuration, and provenance is evidence. They now have their own tab.

surface holds
Admin → Settings (/admin) the eight browser settings and the build facts
Admin → Data & evidence (/admin/data) how the data reaches the map, the layer snapshot table, where the map data comes from, why airspace is not drawn, the reference links

/map points at /admin/data and not at /admin. The nav label and the two new strings were added to lib/i18n/locales/admin.ts and merged at init, which is also where the adminData entry had to be added to withAdmin - the screenshot showed the raw key nav.adminData until that line existed.

7. Project notes under Admin, and useful sites on the map (2026-10-29)

Project notes (/admin/docs)

docs/62 and docs/63 are now a third admin sub-tab, rendered from the repository files themselves:

docs/*.md  --scripts/build-docs.mjs (marked)-->  app/lib/docs.json  -->  /admin/docs

The generator runs first in pnpm run build, so the tab cannot show an older document than the one in the repository. The page renders HTML with dangerouslySetInnerHTML because the source is our own file, never reader input or a remote document. The document's own H1 is stripped and used as the panel title, so the heading is not printed twice.

tests/test_admin_docs.py checks that every generated title still matches its markdown, that the architecture document kept its pipeline section, that the tab reads docs.json, and that the build refreshes it.

Useful sites on the map

The owner asked for external links a reader can check themselves, with what each is good for and how far it reaches - and explicitly not a list of where this project's data comes from. The panel is app/lib/useful-sites.json plus strings in lib/i18n/locales/useful.ts:

site what it is good for scope
NAV CANADA - aeronautical information the AIM, the DAH, and the procedures that define a control zone or Class F area Canada-wide, authoritative documents
NAVDrone submitting a flight request and checking whether an authorisation applies Canada-wide; an operational tool
NRC Drone Site Selection Tool screening a location before flying there Canada-wide screening, not a clearance
Transport Canada - drone safety the rules in plain language, registration, certificates the regulator explaining its own rules
Canadian Aviation Regulations - Part IX the text of the law, every section this app cites the only text that settles a dispute

tests/test_useful_sites.py enforces the content rule: the strings may not say data comes from, source of this map, we use or our data is. A panel that quietly reads as an attribution list would be exactly the confusion the owner asked to avoid.