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
- Page opens — reads
/data/geo/index.jsonand learns which layers exist. - 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).
- Cesium draws it — points become markers, polygons become areas, airspace becomes volumes with floor and ceiling.
- 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
- 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.
- 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
- Add one line to
SETTINGSinapp/lib/settings.ts- id, group, type, scope, default, and for a build setting thesource. - Add
admin.setting.<id>andadmin.help.<id>toapp/lib/i18n/locales/admin.ts(three locales). - Read it where it is used with
getSetting("<id>"). tests/test_admin_settings.pyfails 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.