A research repository into geolabels, not for wide use yet
1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>Terradots Label Store — Specification</title>
7<style>
8/* ═══════════════════════════════════════════════════════════
9 CSS — self-contained, no external dependencies
10 ═══════════════════════════════════════════════════════════ */
11:root {
12 --fg: #1a1a2e;
13 --bg: #ffffff;
14 --bg-alt: #f8f9fc;
15 --muted: #6b7280;
16 --border: #e2e8f0;
17 --accent: #2563eb;
18 --accent-light: #eff6ff;
19 --code-bg: #f1f5f9;
20 --header-bg: #0f172a;
21 --header-fg: #f1f5f9;
22
23 /* layer colours */
24 --c-camera: #ea580c;
25 --c-gps: #2563eb;
26 --c-gbif: #16a34a;
27 --c-inat: #0d9488;
28 --c-iucn: #dc2626;
29 --c-sim: #7c3aed;
30 --c-habitat: #65a30d;
31 --c-habitat-fill: rgba(101,163,13,0.25);
32 --c-range: #3b82f6;
33 --c-aoh: #16a34a;
34 --c-aoh-fill: rgba(22,163,74,0.3);
35
36 --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
37 --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
38}
39
40* { margin: 0; padding: 0; box-sizing: border-box; }
41
42body {
43 font-family: var(--font-sans);
44 color: var(--fg);
45 background: var(--bg);
46 line-height: 1.7;
47 font-size: 15px;
48}
49
50/* ── Header ────────────────────────────────────────────── */
51header {
52 background: var(--header-bg);
53 color: var(--header-fg);
54 padding: 2.5rem 2rem 2rem;
55}
56header .inner {
57 max-width: 1200px;
58 margin: 0 auto;
59}
60header h1 {
61 font-size: 1.8rem;
62 font-weight: 700;
63 letter-spacing: -0.02em;
64}
65header .subtitle {
66 color: #94a3b8;
67 font-size: 1rem;
68 margin-top: 0.4rem;
69}
70header .meta {
71 margin-top: 0.8rem;
72 font-size: 0.8rem;
73 color: #64748b;
74}
75
76/* ── Layout ────────────────────────────────────────────── */
77main {
78 max-width: 1200px;
79 margin: 0 auto;
80 padding: 0 2rem 6rem;
81}
82section {
83 margin-top: 2.5rem;
84}
85h2 {
86 font-size: 1.35rem;
87 font-weight: 700;
88 margin-bottom: 0.8rem;
89 padding-bottom: 0.4rem;
90 border-bottom: 2px solid var(--border);
91 letter-spacing: -0.01em;
92}
93h3 {
94 font-size: 1.05rem;
95 font-weight: 600;
96 margin-top: 1.5rem;
97 margin-bottom: 0.5rem;
98 color: #334155;
99}
100h4 {
101 font-size: 0.95rem;
102 font-weight: 600;
103 margin-top: 1.2rem;
104 margin-bottom: 0.4rem;
105}
106p { margin-bottom: 0.9rem; }
107a { color: var(--accent); text-decoration: none; }
108a:hover { text-decoration: underline; }
109ul, ol { margin-bottom: 0.9rem; padding-left: 1.5rem; }
110li { margin-bottom: 0.3rem; }
111strong { font-weight: 600; }
112
113/* ── Code blocks ───────────────────────────────────────── */
114code {
115 font-family: var(--font-mono);
116 font-size: 0.88em;
117 background: var(--code-bg);
118 padding: 0.15em 0.4em;
119 border-radius: 4px;
120}
121pre {
122 background: var(--code-bg);
123 border: 1px solid var(--border);
124 border-radius: 6px;
125 padding: 1rem 1.2rem;
126 overflow-x: auto;
127 font-family: var(--font-mono);
128 font-size: 0.82rem;
129 line-height: 1.6;
130 margin-bottom: 1rem;
131}
132pre code {
133 background: none;
134 padding: 0;
135}
136
137/* ── Tables ────────────────────────────────────────────── */
138table {
139 width: 100%;
140 border-collapse: collapse;
141 margin-bottom: 1rem;
142 font-size: 0.88rem;
143}
144th, td {
145 text-align: left;
146 padding: 0.5rem 0.8rem;
147 border-bottom: 1px solid var(--border);
148}
149th {
150 font-weight: 600;
151 background: var(--bg-alt);
152}
153td code {
154 font-size: 0.85em;
155}
156
157/* ── Map container ─────────────────────────────────────── */
158.map-container {
159 display: grid;
160 grid-template-columns: 1fr 280px;
161 gap: 0;
162 border: 1px solid var(--border);
163 border-radius: 8px;
164 overflow: hidden;
165 background: var(--bg-alt);
166 margin-bottom: 1.5rem;
167}
168@media (max-width: 800px) {
169 .map-container { grid-template-columns: 1fr; }
170}
171
172.map-svg-wrap {
173 position: relative;
174 min-height: 520px;
175 background: #f0f4f8;
176}
177.map-svg-wrap svg {
178 width: 100%;
179 height: 100%;
180 display: block;
181}
182
183.map-sidebar {
184 background: #fff;
185 border-left: 1px solid var(--border);
186 padding: 1rem;
187 overflow-y: auto;
188 max-height: 620px;
189 font-size: 0.82rem;
190}
191.map-sidebar h3 {
192 font-size: 0.95rem;
193 margin-top: 0;
194 margin-bottom: 0.6rem;
195 color: var(--fg);
196}
197
198/* ── Layer toggles ─────────────────────────────────────── */
199.layer-controls {
200 display: flex;
201 flex-wrap: wrap;
202 gap: 0.3rem 0.8rem;
203 padding: 0.7rem 1rem;
204 background: #fff;
205 border-bottom: 1px solid var(--border);
206 font-size: 0.82rem;
207}
208.layer-controls label {
209 display: inline-flex;
210 align-items: center;
211 gap: 0.3rem;
212 cursor: pointer;
213 white-space: nowrap;
214}
215.layer-controls .swatch {
216 display: inline-block;
217 width: 12px;
218 height: 12px;
219 border-radius: 3px;
220 border: 1px solid rgba(0,0,0,0.15);
221}
222
223/* ── Stats bar ─────────────────────────────────────────── */
224.stats-grid {
225 display: grid;
226 grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
227 gap: 0.5rem;
228 margin-bottom: 1rem;
229}
230.stat-card {
231 background: var(--bg-alt);
232 border: 1px solid var(--border);
233 border-radius: 6px;
234 padding: 0.6rem 0.7rem;
235 text-align: center;
236}
237.stat-card .val {
238 font-size: 1.3rem;
239 font-weight: 700;
240 color: var(--accent);
241}
242.stat-card .lbl {
243 font-size: 0.72rem;
244 color: var(--muted);
245 text-transform: uppercase;
246 letter-spacing: 0.04em;
247}
248
249/* ── Detail panel ──────────────────────────────────────── */
250.detail-panel {
251 background: #fff;
252 border: 1px solid var(--border);
253 border-radius: 6px;
254 padding: 0.8rem;
255 margin-top: 0.5rem;
256 font-size: 0.8rem;
257 line-height: 1.5;
258 display: none;
259}
260.detail-panel.show { display: block; }
261.detail-panel .dp-title {
262 font-weight: 700;
263 font-size: 0.9rem;
264 margin-bottom: 0.4rem;
265 color: var(--fg);
266}
267.detail-panel .dp-row {
268 display: flex;
269 gap: 0.5rem;
270 padding: 0.2rem 0;
271 border-bottom: 1px solid #f1f5f9;
272}
273.detail-panel .dp-key {
274 font-weight: 600;
275 min-width: 90px;
276 color: #475569;
277 font-family: var(--font-mono);
278 font-size: 0.78rem;
279}
280.detail-panel .dp-val {
281 color: #334155;
282 word-break: break-all;
283}
284
285/* ── SVG interactive ───────────────────────────────────── */
286svg .map-point { cursor: pointer; transition: r 0.15s; }
287svg .map-point:hover { r: 7; }
288svg .map-poly { cursor: pointer; }
289svg .map-poly:hover { opacity: 0.85; }
290
291/* ── Provenance DAG ────────────────────────────────────── */
292.dag-container {
293 background: var(--bg-alt);
294 border: 1px solid var(--border);
295 border-radius: 8px;
296 overflow-x: auto;
297 padding: 1rem;
298 margin-bottom: 1.5rem;
299}
300.dag-container svg {
301 display: block;
302 margin: 0 auto;
303}
304
305/* ── Training cycle diagram ────────────────────────────── */
306.cycle-diagram {
307 background: var(--bg-alt);
308 border: 1px solid var(--border);
309 border-radius: 8px;
310 overflow-x: auto;
311 padding: 1rem;
312 margin-bottom: 1.5rem;
313}
314
315/* ── Badges ────────────────────────────────────────────── */
316.badge {
317 display: inline-block;
318 font-size: 0.7rem;
319 font-family: var(--font-mono);
320 padding: 0.15em 0.5em;
321 border-radius: 3px;
322 vertical-align: middle;
323}
324.badge-measured { background: #dbeafe; color: #1e40af; }
325.badge-derived { background: #fef3c7; color: #92400e; }
326.badge-simulated { background: #ede9fe; color: #5b21b6; }
327.badge-abstract { background: #f1f5f9; color: #475569; }
328
329/* ── TOC ───────────────────────────────────────────────── */
330.toc {
331 background: var(--bg-alt);
332 border: 1px solid var(--border);
333 border-radius: 8px;
334 padding: 1.2rem 1.5rem;
335 margin-bottom: 2rem;
336}
337.toc h3 {
338 font-size: 0.9rem;
339 margin-top: 0;
340 margin-bottom: 0.6rem;
341 text-transform: uppercase;
342 letter-spacing: 0.05em;
343 color: var(--muted);
344}
345.toc ol {
346 padding-left: 1.2rem;
347 font-size: 0.88rem;
348}
349.toc li {
350 margin-bottom: 0.2rem;
351}
352.toc a {
353 color: var(--fg);
354}
355.toc a:hover {
356 color: var(--accent);
357}
358
359/* ── Principle box ─────────────────────────────────────── */
360.principle {
361 background: var(--accent-light);
362 border-left: 3px solid var(--accent);
363 padding: 0.8rem 1rem;
364 margin-bottom: 1rem;
365 border-radius: 0 6px 6px 0;
366}
367.principle strong {
368 color: var(--accent);
369}
370</style>
371</head>
372<body>
373
374<!-- ═══════════════════════════════════════════════════════
375 HEADER
376 ═══════════════════════════════════════════════════════ -->
377<header>
378 <div class="inner">
379 <h1>Terradots Label Store — Specification</h1>
380 <div class="subtitle">A data model for geospatial labels with full provenance, uncertainty, and spatial indexing</div>
381 <div class="meta">Worked example: Area of Habitat for <em>Panthera leo</em> in the Serengeti · 23 labels · 10 activities · CRS EPSG:4326 · Hilbert level 12</div>
382 </div>
383</header>
384
385<main>
386
387<!-- ═══════════════════════════════════════════════════════
388 TABLE OF CONTENTS
389 ═══════════════════════════════════════════════════════ -->
390<section>
391<div class="toc">
392 <h3>Contents</h3>
393 <ol>
394 <li><a href="#map">Interactive Map — AOH Worked Example</a></li>
395 <li><a href="#provenance">Provenance Graph</a></li>
396 <li><a href="#training-cycle">Training Cycle</a></li>
397 <li><a href="#design">Design Principles</a></li>
398 <li><a href="#types">Type Specification</a></li>
399 <li><a href="#constructors">Constructors</a></li>
400 <li><a href="#accessors">Accessors</a></li>
401 <li><a href="#fingerprinting">Fingerprinting</a></li>
402 <li><a href="#storage">Storage Layer</a></li>
403 </ol>
404</div>
405</section>
406
407<!-- ═══════════════════════════════════════════════════════
408 1. INTERACTIVE MAP
409 ═══════════════════════════════════════════════════════ -->
410<section id="map">
411<h2>1. Interactive Map — AOH Worked Example</h2>
412
413<p>This map shows all 23 labels from the <em>Panthera leo</em> Area of Habitat example plotted at their real
414WGS 84 coordinates in the Serengeti ecosystem. Click any label to see its full metadata. Use the
415layer toggles below to show or hide each data source.</p>
416
417<!-- Statistics -->
418<div class="stats-grid">
419 <div class="stat-card"><div class="val">23</div><div class="lbl">Total Labels</div></div>
420 <div class="stat-card"><div class="val">14</div><div class="lbl">Measured</div></div>
421 <div class="stat-card"><div class="val">3</div><div class="lbl">Simulated</div></div>
422 <div class="stat-card"><div class="val">6</div><div class="lbl">Derived</div></div>
423 <div class="stat-card"><div class="val">3,420</div><div class="lbl">AOH km²</div></div>
424 <div class="stat-card"><div class="val">70.5%</div><div class="lbl">Habitat</div></div>
425</div>
426
427<!-- Layer toggles -->
428<div class="layer-controls" id="layerControls">
429 <label><input type="checkbox" data-layer="camera" checked><span class="swatch" style="background:var(--c-camera)"></span> Camera Traps</label>
430 <label><input type="checkbox" data-layer="gps" checked><span class="swatch" style="background:var(--c-gps)"></span> GPS Collars</label>
431 <label><input type="checkbox" data-layer="gbif" checked><span class="swatch" style="background:var(--c-gbif)"></span> GBIF</label>
432 <label><input type="checkbox" data-layer="inat" checked><span class="swatch" style="background:var(--c-inat)"></span> iNaturalist</label>
433 <label><input type="checkbox" data-layer="iucn" checked><span class="swatch" style="background:var(--c-iucn)"></span> IUCN Range</label>
434 <label><input type="checkbox" data-layer="iucn-hab" checked><span class="swatch" style="background:var(--c-iucn)"></span> IUCN Habitat</label>
435 <label><input type="checkbox" data-layer="sim" checked><span class="swatch" style="background:var(--c-sim)"></span> Simulated (LV)</label>
436 <label><input type="checkbox" data-layer="habitat" checked><span class="swatch" style="background:var(--c-habitat)"></span> Habitat Tiles</label>
437 <label><input type="checkbox" data-layer="range" checked><span class="swatch" style="background:var(--c-range)"></span> Species Range</label>
438 <label><input type="checkbox" data-layer="aoh" checked><span class="swatch" style="background:var(--c-aoh)"></span> AOH Patches</label>
439</div>
440
441<!-- Map + sidebar -->
442<div class="map-container">
443 <div class="map-svg-wrap" id="mapWrap">
444 <svg id="mapSvg" viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg">
445 </svg>
446 </div>
447 <div class="map-sidebar" id="mapSidebar">
448 <h3>Label Details</h3>
449 <p style="color:var(--muted); font-size:0.82rem;">Click a label on the map to view its metadata, origin, classification, and properties.</p>
450 <div class="detail-panel" id="detailPanel"></div>
451 </div>
452</div>
453</section>
454
455<!-- ═══════════════════════════════════════════════════════
456 2. PROVENANCE GRAPH
457 ═══════════════════════════════════════════════════════ -->
458<section id="provenance">
459<h2>2. Provenance Graph</h2>
460<p>The provenance DAG traces how the AOH label (<code>aoh-001</code>) was derived from upstream
461sources. Every arrow means “was computed from”. Measured labels (leaves) have no incoming edges.
462Simulated labels are marked with dashed borders. Derived labels show their method.</p>
463
464<div class="dag-container" id="dagContainer">
465 <svg id="dagSvg" xmlns="http://www.w3.org/2000/svg"></svg>
466</div>
467
468<p>The full provenance tree as text:</p>
469<pre><code>AOH polygon (aoh-001) method: aoh:iucn-2022:range-intersect-habitat
470 +-- species_range (range-001) method: alpha-shape:alpha-0.005
471 | +-- ct-001 Camera trap (34.82, -2.33) Measured
472 | +-- ct-002 Camera trap (34.83, -2.32) Measured
473 | +-- ct-003 Camera trap (35.01, -2.15) Measured
474 | +-- gps-001 GPS leo-007 (34.81, -2.34) Measured (via Movebank)
475 | +-- gps-002 GPS leo-007 (34.84, -2.31) Measured (via Movebank)
476 | +-- gps-003 GPS leo-007 (34.91, -2.28) Measured (via Movebank)
477 | +-- gps-004 GPS leo-012 (35.05, -2.10) Measured (via Movebank)
478 | +-- gbif-001 GBIF (34.85, -2.35) Measured (via GBIF)
479 | +-- gbif-002 GBIF (35.40, -2.50) Measured (via GBIF)
480 | +-- inat-001 iNaturalist (34.95, -2.20) Measured (via iNat)
481 +-- iucn-range-001 IUCN expert range Measured (via IUCN)
482 +-- hab-001 Habitat tile: core savanna Derived from IUCN hab prefs
483 | +-- iucn-hab-001 Savanna preference Measured (via IUCN)
484 | +-- iucn-hab-002 Shrubland preference Measured (via IUCN)
485 +-- hab-002 Habitat tile: savanna-shrubland Derived from IUCN hab prefs
486 +-- iucn-hab-001 (shared)
487 +-- iucn-hab-002 (shared)</code></pre>
488
489<p>Note: The training set (<code>ts-001</code>) feeds into the habitat classifier but is not a direct
490source of the AOH label. It includes all measured observations plus 3 synthetic (Lotka-Volterra)
491labels for a 23% synthetic fraction.</p>
492</section>
493
494<!-- ═══════════════════════════════════════════════════════
495 3. TRAINING CYCLE
496 ═══════════════════════════════════════════════════════ -->
497<section id="training-cycle">
498<h2>3. Training Cycle</h2>
499<p>Labels flow through a <strong>training/inference cycle</strong>, not a streaming recomputation
500pipeline. The cycle has discrete phases, each producing labels that become inputs to the next.</p>
501
502<div class="cycle-diagram" id="cycleContainer">
503 <svg id="cycleSvg" xmlns="http://www.w3.org/2000/svg"></svg>
504</div>
505
506<h3>Cycle Phases</h3>
507<ol>
508 <li><strong>Observations accumulate.</strong> Camera traps trigger, GPS collars record fixes, citizen
509 scientists upload sightings, museum records are digitised. Each produces a <span class="badge badge-measured">Measured</span> label.
510 Registry imports (GBIF, Movebank, iNaturalist) carry their <code>via</code> URI for provenance.</li>
511
512 <li><strong>Training set assembled.</strong> A <span class="badge badge-derived">Derived</span> label
513 (<code>ts-001</code>) records exactly which observations were selected, plus any
514 <span class="badge badge-simulated">Simulated</span> augmentation. The synthetic fraction (here 23%)
515 is stored in <code>properties</code> for transparency.</li>
516
517 <li><strong>Model trained.</strong> The TESSERA habitat classifier trains on the assembled set.
518 The activity record links the training run to its notebook, parameters, and timestamp.</li>
519
520 <li><strong>Habitat classified.</strong> Each landscape tile receives a suitability classification
521 (savanna 78%, shrubland 13%, etc.) expressed as <code>class_dist</code>. Tiles above the threshold
522 become suitable; those below (cropland, settlement) are excluded.</li>
523
524 <li><strong>Species range computed.</strong> An alpha-shape from <em>measured-only</em> observations
525 (simulated labels excluded via <code>is_simulated</code>). This ensures the range reflects where
526 lions have actually been observed.</li>
527
528 <li><strong>AOH computed.</strong> The intersection of species range with suitable habitat tiles,
529 validated against the IUCN expert range. Result: 3,420 km² of suitable habitat out of
530 4,850 km² total range (70.5%).</li>
531</ol>
532
533<h3>Recomputation</h3>
534<p>When new observations arrive or the model retrains (TESSERA v3.1 → v3.2), downstream
535derivations recompute. Each recomputation produces a <em>new</em> label with a new activity record;
536the old version is retained for comparison. This is not streaming — it is a deliberate
537batch cycle where each phase completes before the next begins.</p>
538</section>
539
540<!-- ═══════════════════════════════════════════════════════
541 4. DESIGN PRINCIPLES
542 ═══════════════════════════════════════════════════════ -->
543<section id="design">
544<h2>4. Design Principles</h2>
545
546<div class="principle">
547 <strong>Coordinates live in CRS space.</strong>
548 All coordinates are in the document's native Coordinate Reference System. Pixel-space mapping
549 (affine transforms, SVG viewBox) is a serialisation concern, not a data model concern. The CRS
550 is specified per document as any string that <a href="https://proj.org/">PROJ</a> can resolve:
551 EPSG codes (<code>"EPSG:4326"</code>), WKT2 strings, or PROJ pipeline definitions.
552</div>
553
554<div class="principle">
555 <strong>Origin distinguishes measured, derived, and simulated.</strong>
556 Every label records how it was produced. <em>Measured</em> labels come from direct observation or
557 registry import. <em>Derived</em> labels are computed from other labels (convex hulls, buffers,
558 merges). <em>Simulated</em> labels come from theoretical models and must remain identifiable as
559 synthetic — they augment training data but do not represent real-world observations.
560</div>
561
562<div class="principle">
563 <strong>URIs identify observers and registries.</strong>
564 Observers (sensors, humans) and external registries are identified by URI. The URI scheme encodes
565 the kind of source. Adding a new kind requires no code changes — just use a new URI scheme.
566</div>
567
568<table>
569 <thead><tr><th>URI</th><th>Meaning</th></tr></thead>
570 <tbody>
571 <tr><td><code>orcid:0000-0001-2345-6789</code></td><td>Human observer (ORCID)</td></tr>
572 <tr><td><code>https://ror.org/035dkdb55</code></td><td>Institution (ROR)</td></tr>
573 <tr><td><code>urn:sensor:gps:trimble-r12-0042</code></td><td>GPS receiver</td></tr>
574 <tr><td><code>urn:sensor:camera-trap:ct-0042</code></td><td>Camera trap</td></tr>
575 <tr><td><code>gbif:4023589127</code></td><td>GBIF occurrence record</td></tr>
576 <tr><td><code>inaturalist:observation/12345</code></td><td>iNaturalist observation</td></tr>
577 <tr><td><code>osm:node/123456</code></td><td>OpenStreetMap node</td></tr>
578 <tr><td><code>movebank:study/1234/individual/leo-007/event/98001</code></td><td>Movebank GPS event</td></tr>
579 <tr><td><code>iucn:redlist:22/Panthera-leo:range:2024.1</code></td><td>IUCN range polygon</td></tr>
580 <tr><td><code>fairground:notebook/lotka-volterra-serengeti:v4</code></td><td>Simulation model</td></tr>
581 </tbody>
582</table>
583
584<div class="principle">
585 <strong>Identity and spatial indexing are separate.</strong>
586 A label has two name components: <code>cell</code> (Hilbert curve cell, recomputed on reprojection)
587 and <code>id</code> (UUID, stable forever). Concatenating <code>cell-id</code> gives a spatially-sortable
588 unique name. Any sorted index gets spatial clustering for free.
589</div>
590
591<div class="principle">
592 <strong>Classification is a probability distribution.</strong>
593 A label's class is expressed through <code>class_dist</code>, a list of
594 <code>(class_name, probability)</code> pairs ordered by decreasing probability.
595 A definite classification is <code>[("Panthera leo", 1.0)]</code>.
596 An uncertain classification distributes probability across candidates.
597 An unclassified label has an empty list.
598</div>
599
600<div class="principle">
601 <strong>Temporal data follows Darwin Core.</strong>
602 The <code>event_date</code> field follows the Darwin Core temporal interpretation convention
603 (ISO 8601-1:2019). It records when the observation was made, not when the label was imported.
604 Supported formats: precise dates (<code>"2023-09-18"</code>), imprecise dates (<code>"2023-09"</code>
605 or <code>"2023"</code>), date-times (<code>"2023-09-18T13:27:00Z"</code>), and intervals
606 (<code>"2023-09-05/2023-09-18"</code>).
607</div>
608
609<div class="principle">
610 <strong>Deduplication is a derivation.</strong>
611 Labels imported from multiple sources may refer to the same real-world feature. Dedup is modelled
612 as a derivation: find candidate matches (same Hilbert cell, class agreement, temporal overlap),
613 let an expert decide, then merge via <code>Derived { sources = [a; b]; method_ = "manual-merge" }</code>.
614 Both originals are kept for full provenance.
615</div>
616
617<div class="principle">
618 <strong>Training cycle, not streaming recomputation.</strong>
619 Downstream derivations (habitat classification, species range, AOH) are batch operations that
620 recompute when inputs change. Each run gets a new activity record. Old and new versions coexist
621 for comparison. This is deliberate: ecological models need stable training windows, not
622 continuous flux.
623</div>
624</section>
625
626<!-- ═══════════════════════════════════════════════════════
627 5. TYPE SPECIFICATION
628 ═══════════════════════════════════════════════════════ -->
629<section id="types">
630<h2>5. Type Specification</h2>
631<p>All types are defined in the <code>Terradots</code> OCaml module. The data model is independent of
632any serialisation format (SVG, GeoJSON, GeoParquet, etc.).</p>
633
634<h3 id="type-crs">Coordinate Reference Systems</h3>
635<pre><code>(** Any string that PROJ can resolve: EPSG codes, WKT2, PROJ pipelines. *)
636type crs = string
637
638val wgs84 : crs (* "EPSG:4326" -- lon/lat in degrees *)
639val web_mercator : crs (* "EPSG:3857" -- metres, for web tiles *)</code></pre>
640<p>The CRS determines the units and meaning of all <code>point</code> coordinates in the document.
641For EPSG:4326, <code>x</code> is longitude (degrees east), <code>y</code> is latitude (degrees north).
642For projected CRS (UTM, Web Mercator), <code>x</code> is easting (metres), <code>y</code> is
643northing (metres).</p>
644
645<h3 id="type-temporal">Temporal</h3>
646<pre><code>(** Abstract type -- construct with event_date_of_string,
647 inspect with string_of_event_date. *)
648type event_date
649
650val event_date_of_string : string -> event_date
651val string_of_event_date : event_date -> string</code></pre>
652<p>Valid forms: precise dates (<code>"2023-09-18"</code>), imprecise dates (<code>"2023-09"</code>,
653<code>"2023"</code>), date-times (<code>"2023-09-18T13:27:00Z"</code>), intervals
654(<code>"2023-09-05/2023-09-18"</code>). The abstraction boundary allows future parsing, validation,
655and temporal overlap queries. <span class="badge badge-abstract">abstract</span></p>
656
657<h3 id="type-cell">Spatial Indexing (Hilbert Cell)</h3>
658<pre><code>(** Abstract type -- a hex-encoded Hilbert curve cell index. *)
659type cell
660
661val cell_of_string : string -> cell
662val string_of_cell : cell -> string</code></pre>
663<p>The Hilbert curve maps 2D coordinates to a 1D index preserving spatial locality. Nearby points
664in CRS space get nearby cell values. <span class="badge badge-abstract">abstract</span></p>
665
666<h4>Hilbert Level Table (EPSG:4326)</h4>
667<table>
668 <thead><tr><th>Level</th><th>Cell size</th><th>Hex chars</th><th>Use case</th></tr></thead>
669 <tbody>
670 <tr><td>8</td><td>~1.4 km</td><td>2</td><td>Coarse regional indexing</td></tr>
671 <tr><td>12</td><td>~88 m</td><td>3</td><td>Standard (this example)</td></tr>
672 <tr><td>16</td><td>~5.5 m</td><td>4</td><td>High-resolution surveys</td></tr>
673 <tr><td>20</td><td>~0.3 m</td><td>5</td><td>Sub-metre precision</td></tr>
674 </tbody>
675</table>
676
677<h3 id="type-geometry">Geometry</h3>
678<pre><code>(** A point in the document's native CRS. *)
679type point = { x : float; y : float }
680
681(** Follows OGC Simple Features / ISO 19125. *)
682type geometry =
683 | Point of point
684 | Polygon of point list (* exterior ring, closed *)
685 | Multi of geometry list (* GeometryCollection / Multi* *)
686
687(** Representative point for spatial indexing. *)
688val centroid : geometry -> point</code></pre>
689<p>Centroid computation: <strong>Point</strong> returns itself. <strong>Polygon</strong> returns the
690arithmetic mean of ring vertices. <strong>Multi</strong> returns the centroid of centroids
691(unweighted — sufficient for indexing, not for area-weighted analysis).</p>
692
693<h3 id="type-origin">Origin</h3>
694<pre><code>type origin =
695 | Measured of {
696 observer : string option; (* URI of observer *)
697 via : string option; (* URI of registry record *)
698 license : string option; (* SPDX identifier *)
699 accuracy_m : float option; (* positional uncertainty, metres *)
700 }
701 | Derived of {
702 sources : string list; (* IDs of source labels *)
703 method_ : string; (* algorithm identifier *)
704 }
705 | Simulated of {
706 model : string; (* URI of simulation model *)
707 run_id : string; (* unique run identifier *)
708 }</code></pre>
709
710<table>
711 <thead><tr><th>Variant</th><th>Fields</th><th>Description</th></tr></thead>
712 <tbody>
713 <tr>
714 <td><span class="badge badge-measured">Measured</span></td>
715 <td><code>observer</code>, <code>via</code>, <code>license</code>, <code>accuracy_m</code></td>
716 <td>Direct observation or registry import. <code>observer</code> is required for direct obs, optional for imports.
717 <code>via</code> is the registry URI (GBIF, Movebank, iNat). <code>accuracy_m</code> is positional uncertainty in metres.</td>
718 </tr>
719 <tr>
720 <td><span class="badge badge-derived">Derived</span></td>
721 <td><code>sources</code>, <code>method_</code></td>
722 <td>Computed from other labels. <code>sources</code> are label IDs within the same document.
723 <code>method_</code> identifies the algorithm (e.g. <code>"convex-hull"</code>, <code>"manual-merge"</code>,
724 <code>"alpha-shape:alpha-0.005"</code>).</td>
725 </tr>
726 <tr>
727 <td><span class="badge badge-simulated">Simulated</span></td>
728 <td><code>model</code>, <code>run_id</code></td>
729 <td>Produced by a theoretical model. <code>model</code> URI identifies the code (e.g. a Fairground notebook).
730 <code>run_id</code> links all labels from the same execution.</td>
731 </tr>
732 </tbody>
733</table>
734
735<h3 id="type-activity">Activity (Provenance Audit Record)</h3>
736<pre><code>type activity = {
737 activity_id : string;
738 agent : string; (* who/what: URI, email, tool *)
739 date : string; (* ISO 8601 *)
740 description : string option; (* free-text note *)
741}</code></pre>
742<p>An activity captures the “who” and “when” of label creation or derivation. Multiple labels may
743share the same activity (e.g. a batch import). Labels reference activities via their
744<code>activity</code> field.</p>
745
746<h3 id="type-label">Label</h3>
747<pre><code>type label = {
748 cell : cell; (* Hilbert cell index *)
749 id : string; (* unique identifier *)
750 geometry : geometry; (* spatial extent *)
751 origin : origin; (* how produced *)
752 event_date : event_date option; (* when observed *)
753 confidence : float option; (* semantic confidence in [0,1] *)
754 class_dist : (string * float) list; (* probability distribution *)
755 activity : string option; (* activity ID *)
756 properties : (string * string) list; (* extensible metadata *)
757}
758
759val label_name : label -> string (* cell ^ "-" ^ id *)</code></pre>
760<p>The <code>label</code> type is the central data structure. All fields except <code>cell</code>,
761<code>id</code>, <code>geometry</code>, and <code>origin</code> are optional or may be empty.</p>
762
763<h3 id="type-annotation">Annotation</h3>
764<pre><code>type annotation = {
765 id : string;
766 text : string; (* free-text content *)
767 anchors : string list; (* label IDs this annotates *)
768}</code></pre>
769<p>Annotations provide commentary, corrections, or contextual notes without modifying labels.
770An annotation may span multiple labels.</p>
771
772<h3 id="type-group">Group</h3>
773<pre><code>type group = {
774 id : string;
775 activity : string option; (* activity that created this group *)
776 members : string list; (* label IDs *)
777}</code></pre>
778<p>Groups organise labels into logical collections (field campaigns, seasonal surveys, thematic subsets).
779Purely organisational — they do not affect label semantics. A label may belong to multiple groups.</p>
780
781<h3 id="type-document">Document</h3>
782<pre><code>type document = {
783 crs : crs;
784 level : int; (* Hilbert curve level *)
785 provenance : activity list;
786 labels : label list;
787 annotations : annotation list;
788 groups : group list;
789}
790
791val empty_document : crs:crs -> ?level:int -> unit -> document</code></pre>
792<p>The top-level container: a set of labels in a common CRS, with provenance records, annotations,
793and groups. The <code>level</code> parameter defaults to 12 (~88 m cells for EPSG:4326).</p>
794</section>
795
796<!-- ═══════════════════════════════════════════════════════
797 6. CONSTRUCTORS
798 ═══════════════════════════════════════════════════════ -->
799<section id="constructors">
800<h2>6. Constructors</h2>
801<p>Convenience functions that enforce common patterns. All require <code>~cell</code> and <code>~id</code>.
802Classification is always via <code>~class_dist</code>.</p>
803
804<h3>make_point</h3>
805<pre><code>val make_point :
806 cell:cell -> id:string ->
807 x:float -> y:float ->
808 observer:string ->
809 ?accuracy_m:float ->
810 ?event_date:event_date -> ?confidence:float ->
811 ?class_dist:(string * float) list ->
812 ?activity:string ->
813 ?properties:(string * string) list ->
814 unit -> label</code></pre>
815<p>Construct a measured point label from a direct observation. Requires an <code>observer</code> URI.</p>
816
817<h3>make_polygon</h3>
818<pre><code>val make_polygon :
819 cell:cell -> id:string ->
820 ring:point list ->
821 observer:string ->
822 ?accuracy_m:float ->
823 ?event_date:event_date -> ?confidence:float ->
824 ?class_dist:(string * float) list ->
825 ?activity:string ->
826 ?properties:(string * string) list ->
827 unit -> label</code></pre>
828<p>Construct a measured polygon label. The ring must be closed (last point = first point).</p>
829
830<h3>make_imported</h3>
831<pre><code>val make_imported :
832 cell:cell -> id:string ->
833 geometry:geometry ->
834 via:string ->
835 ?observer:string -> ?license:string ->
836 ?accuracy_m:float ->
837 ?event_date:event_date -> ?confidence:float ->
838 ?class_dist:(string * float) list ->
839 ?activity:string ->
840 ?properties:(string * string) list ->
841 unit -> label</code></pre>
842<p>Construct a label imported from an external registry. The <code>via</code> URI identifies the
843registry record. Observer is optional (many registries do not expose the original collector).</p>
844
845<h3>make_derived</h3>
846<pre><code>val make_derived :
847 cell:cell -> id:string ->
848 geometry:geometry ->
849 sources:string list ->
850 method_:string ->
851 ?event_date:event_date -> ?confidence:float ->
852 ?class_dist:(string * float) list ->
853 ?activity:string ->
854 ?properties:(string * string) list ->
855 unit -> label</code></pre>
856<p>Construct a derived label. Deduplication merges are a special case:
857<code>make_derived ~sources:["a";"b"] ~method_:"manual-merge" ...</code></p>
858
859<h3>make_simulated</h3>
860<pre><code>val make_simulated :
861 cell:cell -> id:string ->
862 geometry:geometry ->
863 model:string ->
864 run_id:string ->
865 ?event_date:event_date -> ?confidence:float ->
866 ?class_dist:(string * float) list ->
867 ?activity:string ->
868 ?properties:(string * string) list ->
869 unit -> label</code></pre>
870<p>Construct a simulated label. The <code>model</code> URI identifies the simulation code;
871<code>run_id</code> links all labels from the same execution.</p>
872</section>
873
874<!-- ═══════════════════════════════════════════════════════
875 7. ACCESSORS
876 ═══════════════════════════════════════════════════════ -->
877<section id="accessors">
878<h2>7. Accessors</h2>
879<pre><code>(** Most likely class from class_dist, or None if empty. *)
880val primary_class : label -> string option
881
882(** Positional accuracy in metres, if Measured. *)
883val accuracy_of : label -> float option
884
885(** Source label IDs, if Derived. Empty otherwise. *)
886val sources_of : label -> string list
887
888(** Registry URI, if imported via a registry. *)
889val via_of : label -> string option
890
891(** True for Simulated labels. *)
892val is_simulated : label -> bool</code></pre>
893<p>These accessors provide safe pattern-matching over the <code>origin</code> variant without
894exposing internal structure. <code>is_simulated</code> is used by the species-range pipeline to
895exclude synthetic observations.</p>
896</section>
897
898<!-- ═══════════════════════════════════════════════════════
899 8. FINGERPRINTING
900 ═══════════════════════════════════════════════════════ -->
901<section id="fingerprinting">
902<h2>8. Fingerprinting</h2>
903<pre><code>(** Coarse key for deduplication candidates.
904 Returns cell ^ "|" ^ primary_class (or "_" if unclassified). *)
905val fingerprint : label -> string</code></pre>
906<p>A fingerprint combines the Hilbert cell (spatial locality) with the primary class. Two labels
907with the same fingerprint are worth comparing for potential deduplication. Different fingerprints
908guarantee the labels are either spatially distant or differently classified.</p>
909<p>The <code>event_date</code> is deliberately excluded: the same real-world feature observed at
910different times should still match as a candidate, so a human reviewer can decide whether they
911represent the same feature.</p>
912</section>
913
914<!-- ═══════════════════════════════════════════════════════
915 9. STORAGE LAYER
916 ═══════════════════════════════════════════════════════ -->
917<section id="storage">
918<h2>9. Storage Layer</h2>
919<p>The data model is independent of how labels are stored and indexed. This section specifies the
920contract between the core types and a storage backend.</p>
921
922<h3>Hilbert Cell Computation</h3>
923<p>The <code>cell</code> field on each label is a hex-encoded Hilbert curve cell index, computed
924from the label's <code>centroid</code> at the document's <code>level</code>. The storage layer
925must provide:</p>
926<pre><code>val hilbert_cell : level:int -> crs:crs -> point -> cell</code></pre>
927
928<h3>Why Hilbert, not Geohash</h3>
929<p>Geohash uses a Z-order (Morton) curve. Z-order curves have discontinuities at certain cell
930boundaries: two points close in 2D space can receive very different hash values when they fall
931on opposite sides of a major subdivision. The Hilbert curve avoids this — adjacent cells on
932the curve are <em>always</em> spatially adjacent. This gives more uniform spatial clustering and
933fewer edge-case misses in proximity queries.</p>
934
935<h3>Reprojection</h3>
936<p>When a document's CRS changes, all <code>cell</code> values must be recomputed from the
937(reprojected) geometries. The <code>id</code> fields remain stable — identity is independent
938of coordinate system.</p>
939
940<h3>Sorted Keys</h3>
941<p>Concatenating <code>cell ^ "-" ^ id</code> (via <code>label_name</code>) produces a key that
942sorts spatially. Any system that maintains sorted order (B-tree, LSM tree, lexicographic file
943listing) gets spatial clustering for free: a prefix scan on a cell value retrieves all labels
944in that spatial neighbourhood.</p>
945</section>
946
947</main>
948
949<!-- ═══════════════════════════════════════════════════════
950 JAVASCRIPT -- all interactive behaviour
951 ═══════════════════════════════════════════════════════ -->
952<script>
953(function() {
954"use strict";
955
956// ═══════════════════════════════════════════════════════
957// LABEL DATA -- all 23 labels from the AOH example
958// ═══════════════════════════════════════════════════════
959
960var labels = [
961 // Camera traps
962 {id:"ct-001", layer:"camera", cell:"b7a", type:"point", x:34.82, y:-2.33,
963 origin:"Measured", observer:"urn:sensor:camera-trap:serengeti-node-17",
964 accuracy_m:5.0, confidence:0.97,
965 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-12T05:42:00Z",
966 activity:"act-field-2024",
967 props:{image_uri:"s3://slp/ct17/IMG_4821.jpg", individual_count:"3", behaviour:"resting"}},
968 {id:"ct-002", layer:"camera", cell:"b7a", type:"point", x:34.83, y:-2.32,
969 origin:"Measured", observer:"urn:sensor:camera-trap:serengeti-node-17",
970 accuracy_m:5.0, confidence:0.92,
971 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-14T19:15:00Z",
972 activity:"act-field-2024",
973 props:{image_uri:"s3://slp/ct17/IMG_4903.jpg", individual_count:"1", behaviour:"walking"}},
974 {id:"ct-003", layer:"camera", cell:"b7c", type:"point", x:35.01, y:-2.15,
975 origin:"Measured", observer:"urn:sensor:camera-trap:serengeti-node-42",
976 accuracy_m:5.0, confidence:0.88,
977 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-18T03:22:00Z",
978 activity:"act-field-2024",
979 props:{image_uri:"s3://slp/ct42/IMG_1207.jpg", individual_count:"2"}},
980 {id:"ct-004", layer:"camera", cell:"b7d", type:"point", x:35.22, y:-2.45,
981 origin:"Measured", observer:"urn:sensor:camera-trap:serengeti-node-55",
982 accuracy_m:null, confidence:null,
983 class_dist:[], event_date:"2024-06-20T22:10:00Z",
984 activity:"act-field-2024",
985 props:{image_uri:"s3://slp/ct55/IMG_0891.jpg", trigger:"motion", species_detected:"none"}},
986
987 // GPS collars -- leo-007
988 {id:"gps-001", layer:"gps", cell:"b7a", type:"point", x:34.81, y:-2.34,
989 origin:"Measured", observer:"urn:sensor:gps:vectronic-vertex-plus-007",
990 via:"movebank:study/1234/individual/leo-007/event/98001", license:"CC-BY-NC-4.0",
991 accuracy_m:3.5, confidence:null,
992 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-10T06:00:00Z",
993 activity:"act-movebank-import",
994 props:{individual_id:"leo-007", fix_type:"3D", hdop:"0.9"}},
995 {id:"gps-002", layer:"gps", cell:"b7a", type:"point", x:34.84, y:-2.31,
996 origin:"Measured", observer:"urn:sensor:gps:vectronic-vertex-plus-007",
997 via:"movebank:study/1234/individual/leo-007/event/98002", license:"CC-BY-NC-4.0",
998 accuracy_m:4.2, confidence:null,
999 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-10T12:00:00Z",
1000 activity:"act-movebank-import",
1001 props:{individual_id:"leo-007", fix_type:"3D", hdop:"1.1"}},
1002 {id:"gps-003", layer:"gps", cell:"b7b", type:"point", x:34.91, y:-2.28,
1003 origin:"Measured", observer:"urn:sensor:gps:vectronic-vertex-plus-007",
1004 via:"movebank:study/1234/individual/leo-007/event/98003", license:"CC-BY-NC-4.0",
1005 accuracy_m:5.1, confidence:null,
1006 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-11T06:00:00Z",
1007 activity:"act-movebank-import",
1008 props:{individual_id:"leo-007", fix_type:"3D", hdop:"1.4"}},
1009
1010 // GPS collar -- leo-012
1011 {id:"gps-004", layer:"gps", cell:"b7c", type:"point", x:35.05, y:-2.10,
1012 origin:"Measured", observer:"urn:sensor:gps:vectronic-vertex-plus-012",
1013 via:"movebank:study/1234/individual/leo-012/event/98501", license:"CC-BY-NC-4.0",
1014 accuracy_m:3.0, confidence:null,
1015 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-12T06:00:00Z",
1016 activity:"act-movebank-import",
1017 props:{individual_id:"leo-012"}},
1018
1019 // GBIF
1020 {id:"gbif-001", layer:"gbif", cell:"b7a", type:"point", x:34.85, y:-2.35,
1021 origin:"Measured", via:"gbif:4023589127", license:"CC-BY-4.0",
1022 accuracy_m:100.0, confidence:null,
1023 class_dist:[["Panthera leo",1.0]], event_date:"2022-08-14",
1024 activity:"act-gbif-import",
1025 props:{gbif_dataset:"serengeti-biodiversity-survey", basis_of_record:"HUMAN_OBSERVATION",
1026 recorded_by:"Tanzania Wildlife Research Institute"}},
1027 {id:"gbif-002", layer:"gbif", cell:"b7e", type:"point", x:35.40, y:-2.50,
1028 origin:"Measured", via:"gbif:4023589999", license:"CC-BY-4.0",
1029 accuracy_m:500.0, confidence:null,
1030 class_dist:[["Panthera leo",1.0]], event_date:"2021",
1031 activity:"act-gbif-import",
1032 props:{gbif_dataset:"ngorongoro-mammal-survey", basis_of_record:"HUMAN_OBSERVATION"}},
1033
1034 // iNaturalist
1035 {id:"inat-001", layer:"inat", cell:"b7b", type:"point", x:34.95, y:-2.20,
1036 origin:"Measured", observer:"inaturalist:user/safari_dave",
1037 via:"inaturalist:observation/182345678", license:"CC-BY-NC-4.0",
1038 accuracy_m:50.0, confidence:0.95,
1039 class_dist:[["Panthera leo",1.0]], event_date:"2023-07-22T16:30:00Z",
1040 activity:"act-inat-import",
1041 props:{quality_grade:"research", num_identifications:"5"}},
1042
1043 // IUCN range
1044 {id:"iucn-range-001", layer:"iucn", cell:"b70", type:"polygon",
1045 ring:[[34,-3],[36,-3],[36,-1],[34,-1],[34,-3]],
1046 origin:"Measured", via:"iucn:redlist:22/Panthera-leo:range:2024.1", license:"CC-BY-NC-4.0",
1047 accuracy_m:null, confidence:null,
1048 class_dist:[["Panthera leo",1.0]], event_date:"2024",
1049 activity:"act-iucn-import",
1050 props:{iucn_status:"VU", iucn_criteria:"A2abcd", population_trend:"decreasing",
1051 range_type:"extant:resident", habitat_codes:"1.5;1.6;2;3;14.1"}},
1052
1053 // IUCN habitat preferences (point markers at 35,-2)
1054 {id:"iucn-hab-001", layer:"iucn-hab", cell:"b70", type:"point", x:35.0, y:-2.0,
1055 origin:"Measured", via:"iucn:redlist:22/Panthera-leo:habitat:2", license:"CC-BY-NC-4.0",
1056 accuracy_m:null, confidence:0.95,
1057 class_dist:[["habitat-preference:savanna",1.0]], event_date:null,
1058 activity:"act-iucn-import",
1059 props:{iucn_habitat_code:"2", suitability:"Suitable", major_importance:"Yes"}},
1060 {id:"iucn-hab-002", layer:"iucn-hab", cell:"b70", type:"point", x:35.0, y:-2.0,
1061 origin:"Measured", via:"iucn:redlist:22/Panthera-leo:habitat:3", license:"CC-BY-NC-4.0",
1062 accuracy_m:null, confidence:0.70,
1063 class_dist:[["habitat-preference:shrubland",1.0]], event_date:null,
1064 activity:"act-iucn-import",
1065 props:{iucn_habitat_code:"3", suitability:"Suitable", major_importance:"No"}},
1066
1067 // Simulated (Lotka-Volterra)
1068 {id:"sim-001", layer:"sim", cell:"b7d", type:"point", x:35.20, y:-2.50,
1069 origin:"Simulated", model:"fairground:notebook/lotka-volterra-serengeti:v4",
1070 run_id:"lv-run-42",
1071 accuracy_m:null, confidence:0.60,
1072 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-15T00:00:00Z",
1073 activity:"act-sim-lv-001",
1074 props:{scenario:"baseline-2024", time_step:"150", prey_density_km2:"45.2", seed:"42"}},
1075 {id:"sim-002", layer:"sim", cell:"b7d", type:"point", x:35.18, y:-2.48,
1076 origin:"Simulated", model:"fairground:notebook/lotka-volterra-serengeti:v4",
1077 run_id:"lv-run-42",
1078 accuracy_m:null, confidence:0.60,
1079 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-15T06:00:00Z",
1080 activity:"act-sim-lv-001",
1081 props:{scenario:"baseline-2024", time_step:"151", prey_density_km2:"44.8", seed:"42"}},
1082 {id:"sim-003", layer:"sim", cell:"b7e", type:"point", x:35.45, y:-2.55,
1083 origin:"Simulated", model:"fairground:notebook/lotka-volterra-serengeti:v4",
1084 run_id:"lv-run-42",
1085 accuracy_m:null, confidence:0.55,
1086 class_dist:[["Panthera leo",1.0]], event_date:"2024-06-16T00:00:00Z",
1087 activity:"act-sim-lv-001",
1088 props:{scenario:"drought-2024", time_step:"152", prey_density_km2:"28.1", seed:"42"}},
1089
1090 // Habitat tiles (derived)
1091 {id:"hab-001", layer:"habitat", cell:"b7a", type:"polygon",
1092 ring:[[34.80,-2.40],[34.90,-2.40],[34.90,-2.30],[34.80,-2.30],[34.80,-2.40]],
1093 origin:"Derived", sources:["iucn-hab-001","iucn-hab-002"],
1094 method_:"habitat-classify:tessera-v3.1:threshold-0.6",
1095 accuracy_m:null, confidence:0.91,
1096 class_dist:[["savanna",0.78],["shrubland",0.13],["other",0.09]], event_date:null,
1097 activity:"act-habitat-2024",
1098 props:{tessera_tile:"b7a:034.80:-002.40", dominant_landcover:"savanna"}},
1099 {id:"hab-002", layer:"habitat", cell:"b7d", type:"polygon",
1100 ring:[[35.10,-2.60],[35.20,-2.60],[35.20,-2.50],[35.10,-2.50],[35.10,-2.60]],
1101 origin:"Derived", sources:["iucn-hab-001","iucn-hab-002"],
1102 method_:"habitat-classify:tessera-v3.1:threshold-0.6",
1103 accuracy_m:null, confidence:0.68,
1104 class_dist:[["savanna",0.45],["shrubland",0.30],["cropland",0.25]], event_date:null,
1105 activity:"act-habitat-2024",
1106 props:{tessera_tile:"b7d:035.10:-002.60", dominant_landcover:"savanna-shrubland-mosaic"}},
1107 {id:"hab-003", layer:"habitat", cell:"b7f", type:"polygon",
1108 ring:[[35.80,-1.20],[35.90,-1.20],[35.90,-1.10],[35.80,-1.10],[35.80,-1.20]],
1109 origin:"Derived", sources:["iucn-hab-001","iucn-hab-002"],
1110 method_:"habitat-classify:tessera-v3.1:threshold-0.6",
1111 accuracy_m:null, confidence:0.12,
1112 class_dist:[["cropland",0.72],["settlement",0.18],["savanna",0.10]], event_date:null,
1113 activity:"act-habitat-2024",
1114 props:{tessera_tile:"b7f:035.80:-001.20", dominant_landcover:"cropland"}},
1115
1116 // Species range (derived)
1117 {id:"range-001", layer:"range", cell:"b70", type:"polygon",
1118 ring:[[34.75,-2.60],[35.50,-2.60],[35.50,-2.00],[35.10,-1.90],[34.75,-2.10],[34.75,-2.60]],
1119 origin:"Derived",
1120 sources:["ct-001","ct-002","ct-003","gps-001","gps-002","gps-003","gps-004","gbif-001","gbif-002","inat-001"],
1121 method_:"alpha-shape:alpha-0.005",
1122 accuracy_m:null, confidence:null,
1123 class_dist:[["range:Panthera leo",1.0]], event_date:null,
1124 activity:"act-range-2024",
1125 props:{range_km2:"4850", n_occurrences:"10", excludes_synthetic:"true"}},
1126
1127 // AOH (derived, multi-polygon)
1128 {id:"aoh-001", layer:"aoh", cell:"b70", type:"multi",
1129 patches:[
1130 [[34.80,-2.40],[35.20,-2.40],[35.20,-2.10],[34.80,-2.10],[34.80,-2.40]],
1131 [[35.10,-2.60],[35.40,-2.60],[35.40,-2.40],[35.10,-2.40],[35.10,-2.60]]
1132 ],
1133 origin:"Derived",
1134 sources:["range-001","iucn-range-001","hab-001","hab-002"],
1135 method_:"aoh:iucn-2022:range-intersect-habitat",
1136 accuracy_m:null, confidence:null,
1137 class_dist:[["aoh:Panthera leo",1.0]], event_date:null,
1138 activity:"act-aoh-2024",
1139 props:{aoh_km2:"3420", range_km2:"4850", habitat_proportion:"0.705",
1140 unsuitable_excluded_km2:"1430", dominant_exclusion:"cropland",
1141 iucn_status:"VU", iucn_criteria:"A2abcd", population_trend:"decreasing",
1142 tessera_model:"tessera:v3.1:east-africa",
1143 synthetic_in_sdm_training:"true", synthetic_fraction_in_training:"0.23"}},
1144
1145 // Training set (derived) -- not shown on map but in data
1146 {id:"ts-001", layer:"_none", cell:"b70", type:"polygon",
1147 ring:[[34,-3],[36,-3],[36,-1],[34,-1],[34,-3]],
1148 origin:"Derived",
1149 sources:["ct-001","ct-002","ct-003","gps-001","gps-002","gps-003","gps-004","gbif-001","gbif-002","inat-001","sim-001","sim-002","sim-003"],
1150 method_:"training-set:balanced-spatial-sample",
1151 accuracy_m:null, confidence:null,
1152 class_dist:[["training-set:Panthera-leo:sdm-2024",1.0]], event_date:null,
1153 activity:"act-training-2024",
1154 props:{n_measured:"10", n_synthetic:"3", synthetic_fraction:"0.23",
1155 spatial_extent:"34.0,-3.0,36.0,-1.0", temporal_window:"2021/2024",
1156 tessera_model:"tessera:v3.1:east-africa"}}
1157];
1158
1159// ═══════════════════════════════════════════════════════
1160// MAP RENDERING
1161// ═══════════════════════════════════════════════════════
1162
1163// Map extent (WGS84)
1164var mapExtent = { minLon: 33.8, maxLon: 36.2, minLat: -3.2, maxLat: -0.8 };
1165var svgW = 800, svgH = 600;
1166var pad = 30;
1167
1168function lonToX(lon) {
1169 return pad + (lon - mapExtent.minLon) / (mapExtent.maxLon - mapExtent.minLon) * (svgW - 2*pad);
1170}
1171function latToY(lat) {
1172 return pad + (mapExtent.maxLat - lat) / (mapExtent.maxLat - mapExtent.minLat) * (svgH - 2*pad);
1173}
1174function ringToPoints(ring) {
1175 return ring.map(function(c) { return lonToX(c[0]) + "," + latToY(c[1]); }).join(" ");
1176}
1177
1178function buildMap() {
1179 var svg = document.getElementById("mapSvg");
1180 svg.innerHTML = "";
1181
1182 function el(tag, attrs) {
1183 var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
1184 for (var k in attrs) e.setAttribute(k, attrs[k]);
1185 return e;
1186 }
1187
1188 // Background
1189 svg.appendChild(el("rect", {x:0, y:0, width:svgW, height:svgH, fill:"#e8edf3"}));
1190
1191 // Water hint
1192 svg.appendChild(el("ellipse", {cx: lonToX(33.9), cy: latToY(-2.0), rx: 30, ry: 50, fill:"#b3d4f0", opacity:"0.5"}));
1193
1194 // Graticule
1195 var grat = el("g", {stroke:"#c8d0dc", "stroke-width":"0.5", fill:"none", opacity:"0.6"});
1196 for (var lon = 34; lon <= 36; lon += 0.5) {
1197 grat.appendChild(el("line", {x1:lonToX(lon), y1:latToY(mapExtent.minLat), x2:lonToX(lon), y2:latToY(mapExtent.maxLat)}));
1198 }
1199 for (var lat = -3; lat <= -1; lat += 0.5) {
1200 grat.appendChild(el("line", {x1:lonToX(mapExtent.minLon), y1:latToY(lat), x2:lonToX(mapExtent.maxLon), y2:latToY(lat)}));
1201 }
1202 svg.appendChild(grat);
1203
1204 // Axis labels
1205 var axG = el("g", {"font-size":"9", fill:"#6b7280", "font-family":"var(--font-mono)"});
1206 for (var alon = 34; alon <= 36; alon += 0.5) {
1207 var t = el("text", {x:lonToX(alon), y:svgH-8, "text-anchor":"middle"});
1208 t.textContent = alon.toFixed(1) + "\u00B0E";
1209 axG.appendChild(t);
1210 }
1211 for (var alat = -3; alat <= -1; alat += 0.5) {
1212 var t2 = el("text", {x:12, y:latToY(alat)+3, "text-anchor":"middle"});
1213 t2.textContent = Math.abs(alat).toFixed(1) + "\u00B0S";
1214 axG.appendChild(t2);
1215 }
1216 svg.appendChild(axG);
1217
1218 // Title on map
1219 var title = el("text", {x:svgW/2, y:20, "text-anchor":"middle", "font-size":"13",
1220 "font-weight":"600", fill:"#334155", "font-family":"var(--font-sans)"});
1221 title.textContent = "Serengeti Ecosystem \u2014 Panthera leo AOH";
1222 svg.appendChild(title);
1223
1224 // Layer groups (render order: back to front)
1225 var layerGroups = {};
1226 var layerOrder = ["iucn","aoh","range","habitat","iucn-hab","sim","gbif","inat","gps","camera"];
1227 layerOrder.forEach(function(ly) {
1228 layerGroups[ly] = el("g", {"data-layer": ly});
1229 });
1230
1231 labels.forEach(function(lb) {
1232 if (lb.layer === "_none") return;
1233 var g = layerGroups[lb.layer];
1234 if (!g) return;
1235 var col = {camera:"#ea580c", gps:"#2563eb", gbif:"#16a34a", inat:"#0d9488",
1236 iucn:"#dc2626", "iucn-hab":"#dc2626", sim:"#7c3aed",
1237 habitat:"#65a30d", range:"#3b82f6", aoh:"#16a34a"}[lb.layer] || "#888";
1238
1239 if (lb.type === "polygon" && lb.ring) {
1240 var fillOp = "0.15", sw = "1.5", sd = "";
1241 if (lb.layer === "iucn") { fillOp = "0.06"; sw = "2"; sd = "6,3"; }
1242 if (lb.layer === "range") { fillOp = "0.08"; sw = "2"; sd = "4,2"; }
1243 if (lb.layer === "habitat") { fillOp = "0.3"; }
1244
1245 var poly = el("polygon", {
1246 points: ringToPoints(lb.ring),
1247 fill: col, "fill-opacity": fillOp,
1248 stroke: col, "stroke-width": sw, "stroke-opacity": "0.8",
1249 "class": "map-poly", "data-id": lb.id
1250 });
1251 if (sd) poly.setAttribute("stroke-dasharray", sd);
1252 (function(label) {
1253 poly.addEventListener("click", function() { showDetail(label); });
1254 })(lb);
1255 g.appendChild(poly);
1256
1257 // Habitat tile label
1258 if (lb.layer === "habitat") {
1259 var cx = lb.ring.reduce(function(a,c){return a+c[0];},0)/lb.ring.length;
1260 var cy = lb.ring.reduce(function(a,c){return a+c[1];},0)/lb.ring.length;
1261 var txt = el("text", {x:lonToX(cx), y:latToY(cy)+3, "text-anchor":"middle",
1262 "font-size":"8", fill:"#3f6212", "font-weight":"600",
1263 "pointer-events":"none"});
1264 txt.textContent = lb.id;
1265 g.appendChild(txt);
1266 }
1267 }
1268 else if (lb.type === "multi" && lb.patches) {
1269 lb.patches.forEach(function(patch, pi) {
1270 var poly = el("polygon", {
1271 points: ringToPoints(patch),
1272 fill: col, "fill-opacity": "0.35",
1273 stroke: col, "stroke-width": "2.5", "stroke-opacity": "0.9",
1274 "class": "map-poly", "data-id": lb.id
1275 });
1276 (function(label) {
1277 poly.addEventListener("click", function() { showDetail(label); });
1278 })(lb);
1279 g.appendChild(poly);
1280
1281 var cx2 = patch.reduce(function(a,c){return a+c[0];},0)/patch.length;
1282 var cy2 = patch.reduce(function(a,c){return a+c[1];},0)/patch.length;
1283 var txt2 = el("text", {x:lonToX(cx2), y:latToY(cy2)+3, "text-anchor":"middle",
1284 "font-size":"9", fill:"#065f46", "font-weight":"700",
1285 "pointer-events":"none"});
1286 txt2.textContent = "AOH P" + (pi+1);
1287 g.appendChild(txt2);
1288 });
1289 }
1290 else if (lb.type === "point") {
1291 var r = 5, sd2 = "";
1292 if (lb.layer === "sim") { sd2 = "2,2"; }
1293 if (lb.layer === "iucn-hab") { r = 7; }
1294
1295 var circ = el("circle", {
1296 cx: lonToX(lb.x), cy: latToY(lb.y), r: r,
1297 fill: col, "fill-opacity": "0.85",
1298 stroke: "#fff", "stroke-width": "1.5",
1299 "class": "map-point", "data-id": lb.id
1300 });
1301 if (sd2) {
1302 circ.setAttribute("stroke", col);
1303 circ.setAttribute("stroke-dasharray", sd2);
1304 circ.setAttribute("fill-opacity", "0.5");
1305 circ.setAttribute("fill", "#ede9fe");
1306 }
1307 if (lb.layer === "iucn-hab") {
1308 circ.setAttribute("fill", "#fecdd3");
1309 circ.setAttribute("stroke", "#dc2626");
1310 circ.setAttribute("stroke-width", "2");
1311 }
1312 (function(label) {
1313 circ.addEventListener("click", function() { showDetail(label); });
1314 })(lb);
1315 g.appendChild(circ);
1316
1317 var ptLabel = el("text", {
1318 x: lonToX(lb.x) + (lb.layer === "iucn-hab" ? 10 : 8),
1319 y: latToY(lb.y) + 3,
1320 "font-size": "8", fill: "#475569",
1321 "font-family": "var(--font-mono)",
1322 "pointer-events": "none"
1323 });
1324 ptLabel.textContent = lb.id;
1325 g.appendChild(ptLabel);
1326 }
1327 });
1328
1329 // GPS track line (leo-007)
1330 var gpsTrack = el("polyline", {
1331 points: [lonToX(34.81)+","+latToY(-2.34),
1332 lonToX(34.84)+","+latToY(-2.31),
1333 lonToX(34.91)+","+latToY(-2.28)].join(" "),
1334 fill: "none", stroke: "#2563eb", "stroke-width": "1.5",
1335 "stroke-dasharray": "4,3", opacity: "0.6"
1336 });
1337 layerGroups["gps"].insertBefore(gpsTrack, layerGroups["gps"].firstChild);
1338
1339 // Append layer groups in order
1340 layerOrder.forEach(function(ly) { svg.appendChild(layerGroups[ly]); });
1341
1342 // Scale bar
1343 var scaleG = el("g", {transform: "translate(" + (svgW - 120) + "," + (svgH - 30) + ")"});
1344 var degPx = (svgW - 2*pad) / (mapExtent.maxLon - mapExtent.minLon);
1345 var km50 = (50/111.32) * degPx;
1346 scaleG.appendChild(el("line", {x1:0, y1:0, x2:km50, y2:0, stroke:"#334155", "stroke-width":"2"}));
1347 scaleG.appendChild(el("line", {x1:0, y1:-3, x2:0, y2:3, stroke:"#334155", "stroke-width":"2"}));
1348 scaleG.appendChild(el("line", {x1:km50, y1:-3, x2:km50, y2:3, stroke:"#334155", "stroke-width":"2"}));
1349 var scaleText = el("text", {x:km50/2, y:-5, "text-anchor":"middle", "font-size":"9", fill:"#334155"});
1350 scaleText.textContent = "50 km";
1351 scaleG.appendChild(scaleText);
1352 svg.appendChild(scaleG);
1353}
1354
1355// ═══════════════════════════════════════════════════════
1356// DETAIL PANEL
1357// ═══════════════════════════════════════════════════════
1358
1359function showDetail(lb) {
1360 var panel = document.getElementById("detailPanel");
1361 panel.className = "detail-panel show";
1362
1363 var badge = lb.origin === "Simulated" ? "badge-simulated" :
1364 lb.origin === "Derived" ? "badge-derived" : "badge-measured";
1365
1366 var html = '<div class="dp-title">' + escHtml(lb.id) + ' <span class="badge ' + badge + '">' + escHtml(lb.origin) + '</span></div>';
1367
1368 function row(k, v) {
1369 if (v === null || v === undefined || v === "") return "";
1370 return '<div class="dp-row"><span class="dp-key">' + escHtml(k) + '</span><span class="dp-val">' + escHtml(String(v)) + '</span></div>';
1371 }
1372
1373 html += row("cell", lb.cell);
1374 html += row("label_name", lb.cell + "-" + lb.id);
1375
1376 if (lb.type === "point") {
1377 html += row("geometry", "Point(" + lb.x + ", " + lb.y + ")");
1378 } else if (lb.type === "polygon" && lb.ring) {
1379 html += row("geometry", "Polygon [" + lb.ring.length + " vertices]");
1380 } else if (lb.type === "multi" && lb.patches) {
1381 html += row("geometry", "Multi [" + lb.patches.length + " patches]");
1382 }
1383
1384 if (lb.observer) html += row("observer", lb.observer);
1385 if (lb.via) html += row("via", lb.via);
1386 if (lb.license) html += row("license", lb.license);
1387 if (lb.model) html += row("model", lb.model);
1388 if (lb.run_id) html += row("run_id", lb.run_id);
1389 if (lb.sources) html += row("sources", lb.sources.join(", "));
1390 if (lb.method_) html += row("method", lb.method_);
1391
1392 if (lb.class_dist && lb.class_dist.length > 0) {
1393 var cdStr = lb.class_dist.map(function(cd) { return cd[0] + ": " + cd[1].toFixed(2); }).join("; ");
1394 html += row("class_dist", cdStr);
1395 } else {
1396 html += row("class_dist", "(empty -- unclassified)");
1397 }
1398
1399 html += row("confidence", lb.confidence !== null ? lb.confidence : null);
1400 html += row("accuracy_m", lb.accuracy_m !== null ? lb.accuracy_m + " m" : null);
1401 html += row("event_date", lb.event_date);
1402 html += row("activity", lb.activity);
1403
1404 if (lb.props && Object.keys(lb.props).length > 0) {
1405 html += '<div style="margin-top:0.5rem;font-weight:600;font-size:0.78rem;color:#475569;">Properties</div>';
1406 for (var pk in lb.props) {
1407 html += row(pk, lb.props[pk]);
1408 }
1409 }
1410
1411 panel.innerHTML = html;
1412}
1413
1414function escHtml(s) {
1415 var d = document.createElement("div");
1416 d.appendChild(document.createTextNode(s));
1417 return d.innerHTML;
1418}
1419
1420// ═══════════════════════════════════════════════════════
1421// LAYER TOGGLES
1422// ═══════════════════════════════════════════════════════
1423
1424function setupToggles() {
1425 var controls = document.querySelectorAll("#layerControls input[data-layer]");
1426 controls.forEach(function(cb) {
1427 cb.addEventListener("change", function() {
1428 var layer = this.getAttribute("data-layer");
1429 var groups = document.querySelectorAll("#mapSvg g[data-layer='" + layer + "']");
1430 groups.forEach(function(g) {
1431 g.style.display = cb.checked ? "" : "none";
1432 });
1433 });
1434 });
1435}
1436
1437// ═══════════════════════════════════════════════════════
1438// PROVENANCE DAG
1439// ═══════════════════════════════════════════════════════
1440
1441function buildDAG() {
1442 var dagSvg = document.getElementById("dagSvg");
1443 var W = 940, H = 520;
1444 dagSvg.setAttribute("viewBox", "0 0 " + W + " " + H);
1445 dagSvg.setAttribute("width", "100%");
1446 dagSvg.setAttribute("height", H);
1447 dagSvg.innerHTML = "";
1448
1449 function el(tag, attrs) {
1450 var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
1451 for (var k in attrs) e.setAttribute(k, attrs[k]);
1452 return e;
1453 }
1454
1455 // Node positions
1456 var nodes = {
1457 "aoh-001": {x:370, y:20, w:200, h:32, color:"#16a34a", label:"aoh-001 (AOH)", dash:false},
1458 "range-001": {x:130, y:100, w:200, h:28, color:"#3b82f6", label:"range-001 (Species Range)", dash:false},
1459 "iucn-range-001":{x:400, y:100, w:200, h:28, color:"#dc2626", label:"iucn-range-001 (IUCN Range)", dash:false},
1460 "hab-001": {x:650, y:100, w:180, h:28, color:"#65a30d", label:"hab-001 (Savanna tile)", dash:false},
1461 "hab-002": {x:650, y:150, w:200, h:28, color:"#65a30d", label:"hab-002 (Mosaic tile)", dash:false},
1462 "ts-001": {x:370, y:280, w:200, h:28, color:"#a16207", label:"ts-001 (Training Set)", dash:false},
1463 "iucn-hab-001": {x:740, y:230, w:150, h:26, color:"#dc2626", label:"iucn-hab-001", dash:false},
1464 "iucn-hab-002": {x:740, y:270, w:150, h:26, color:"#dc2626", label:"iucn-hab-002", dash:false},
1465 "ct-001": {x:10, y:210, w:82, h:24, color:"#ea580c", label:"ct-001", dash:false},
1466 "ct-002": {x:100, y:210, w:82, h:24, color:"#ea580c", label:"ct-002", dash:false},
1467 "ct-003": {x:190, y:210, w:82, h:24, color:"#ea580c", label:"ct-003", dash:false},
1468 "gps-001": {x:10, y:250, w:82, h:24, color:"#2563eb", label:"gps-001", dash:false},
1469 "gps-002": {x:100, y:250, w:82, h:24, color:"#2563eb", label:"gps-002", dash:false},
1470 "gps-003": {x:190, y:250, w:82, h:24, color:"#2563eb", label:"gps-003", dash:false},
1471 "gps-004": {x:10, y:290, w:82, h:24, color:"#2563eb", label:"gps-004", dash:false},
1472 "gbif-001": {x:100, y:290, w:82, h:24, color:"#16a34a", label:"gbif-001", dash:false},
1473 "gbif-002": {x:190, y:290, w:82, h:24, color:"#16a34a", label:"gbif-002", dash:false},
1474 "inat-001": {x:100, y:330, w:82, h:24, color:"#0d9488", label:"inat-001", dash:false},
1475 "sim-001": {x:330, y:390, w:82, h:24, color:"#7c3aed", label:"sim-001", dash:true},
1476 "sim-002": {x:420, y:390, w:82, h:24, color:"#7c3aed", label:"sim-002", dash:true},
1477 "sim-003": {x:510, y:390, w:82, h:24, color:"#7c3aed", label:"sim-003", dash:true}
1478 };
1479
1480 // Edges
1481 var edges = [
1482 ["range-001", "aoh-001"],
1483 ["iucn-range-001", "aoh-001"],
1484 ["hab-001", "aoh-001"],
1485 ["hab-002", "aoh-001"],
1486 ["ct-001","range-001"], ["ct-002","range-001"], ["ct-003","range-001"],
1487 ["gps-001","range-001"], ["gps-002","range-001"], ["gps-003","range-001"],
1488 ["gps-004","range-001"],
1489 ["gbif-001","range-001"], ["gbif-002","range-001"],
1490 ["inat-001","range-001"],
1491 ["iucn-hab-001","hab-001"], ["iucn-hab-002","hab-001"],
1492 ["iucn-hab-001","hab-002"], ["iucn-hab-002","hab-002"],
1493 ["ct-001","ts-001"], ["ct-002","ts-001"], ["ct-003","ts-001"],
1494 ["gps-001","ts-001"], ["gps-002","ts-001"], ["gps-003","ts-001"], ["gps-004","ts-001"],
1495 ["gbif-001","ts-001"], ["gbif-002","ts-001"], ["inat-001","ts-001"],
1496 ["sim-001","ts-001"], ["sim-002","ts-001"], ["sim-003","ts-001"],
1497 ["iucn-hab-001","ts-001"], ["iucn-hab-002","ts-001"]
1498 ];
1499
1500 // Arrowhead defs
1501 var defs = el("defs", {});
1502 var marker = el("marker", {id:"arrowhead", markerWidth:"8", markerHeight:"6",
1503 refX:"8", refY:"3", orient:"auto"});
1504 marker.appendChild(el("polygon", {points:"0 0, 8 3, 0 6", fill:"#94a3b8"}));
1505 defs.appendChild(marker);
1506 var marker2 = el("marker", {id:"arrowhead-light", markerWidth:"6", markerHeight:"5",
1507 refX:"6", refY:"2.5", orient:"auto"});
1508 marker2.appendChild(el("polygon", {points:"0 0, 6 2.5, 0 5", fill:"#cbd5e1"}));
1509 defs.appendChild(marker2);
1510 dagSvg.appendChild(defs);
1511
1512 // Draw edges
1513 var edgeG = el("g", {});
1514 edges.forEach(function(e) {
1515 var from = nodes[e[0]], to = nodes[e[1]];
1516 if (!from || !to) return;
1517 var x1 = from.x + from.w/2, y1 = from.y;
1518 var x2 = to.x + to.w/2, y2 = to.y + to.h;
1519 var isTS = e[1] === "ts-001";
1520 var line = el("line", {
1521 x1:x1, y1:y1, x2:x2, y2:y2,
1522 stroke: isTS ? "#e2e8f0" : "#94a3b8",
1523 "stroke-width": isTS ? "1" : "1.5",
1524 "marker-end": isTS ? "url(#arrowhead-light)" : "url(#arrowhead)"
1525 });
1526 if (isTS) line.setAttribute("stroke-dasharray", "3,3");
1527 edgeG.appendChild(line);
1528 });
1529 dagSvg.appendChild(edgeG);
1530
1531 // Draw nodes
1532 var nodeG = el("g", {});
1533 for (var nid in nodes) {
1534 var n = nodes[nid];
1535 var rect = el("rect", {
1536 x: n.x, y: n.y, width: n.w, height: n.h,
1537 rx: "4", ry: "4",
1538 fill: "#fff", stroke: n.color, "stroke-width": n.dash ? "2" : "1.5"
1539 });
1540 if (n.dash) rect.setAttribute("stroke-dasharray", "4,3");
1541 nodeG.appendChild(rect);
1542
1543 nodeG.appendChild(el("rect", {
1544 x: n.x, y: n.y, width: "4", height: n.h,
1545 rx: "2", fill: n.color
1546 }));
1547
1548 var txt = el("text", {
1549 x: n.x + n.w/2, y: n.y + n.h/2 + 4,
1550 "text-anchor": "middle", "font-size": n.h > 26 ? "10" : "9",
1551 fill: "#334155", "font-family": "var(--font-mono)"
1552 });
1553 txt.textContent = n.label;
1554 nodeG.appendChild(txt);
1555 }
1556 dagSvg.appendChild(nodeG);
1557
1558 // Legend
1559 var legG = el("g", {transform: "translate(20," + (H - 100) + ")"});
1560 legG.appendChild(el("rect", {x:0, y:0, width:200, height:90, rx:6, fill:"#fff", stroke:"#e2e8f0"}));
1561 var legTitle = el("text", {x:10, y:16, "font-size":"10", "font-weight":"600", fill:"#334155"});
1562 legTitle.textContent = "Legend";
1563 legG.appendChild(legTitle);
1564
1565 function legItem(y, col, text, dash) {
1566 var r = el("rect", {x:10, y:y, width:20, height:12, rx:2, fill:"#fff", stroke:col, "stroke-width":"1.5"});
1567 if (dash) r.setAttribute("stroke-dasharray", "3,2");
1568 legG.appendChild(r);
1569 var t = el("text", {x:36, y:y+10, "font-size":"9", fill:"#475569"});
1570 t.textContent = text;
1571 legG.appendChild(t);
1572 }
1573 legItem(26, "#ea580c", "Camera trap (Measured)", false);
1574 legItem(42, "#2563eb", "GPS collar (Measured)", false);
1575 legItem(58, "#16a34a", "Registry import (Measured)", false);
1576 legItem(74, "#7c3aed", "Simulated (LV)", true);
1577 dagSvg.appendChild(legG);
1578
1579 // Method annotations
1580 var methG = el("g", {"font-size":"8", fill:"#6b7280", "font-style":"italic"});
1581 function methodLabel(x, y, text) {
1582 var t = el("text", {x:x, y:y, "text-anchor":"middle"});
1583 t.textContent = text;
1584 methG.appendChild(t);
1585 }
1586 methodLabel(470, 68, "aoh:iucn-2022:range-intersect-habitat");
1587 methodLabel(180, 190, "alpha-shape:alpha-0.005");
1588 methodLabel(700, 142, "habitat-classify:tessera-v3.1");
1589 methodLabel(470, 268, "training-set:balanced-spatial-sample");
1590 dagSvg.appendChild(methG);
1591}
1592
1593// ═══════════════════════════════════════════════════════
1594// TRAINING CYCLE DIAGRAM
1595// ═══════════════════════════════════════════════════════
1596
1597function buildCycleDiagram() {
1598 var svg = document.getElementById("cycleSvg");
1599 var W = 880, H = 310;
1600 svg.setAttribute("viewBox", "0 0 " + W + " " + H);
1601 svg.setAttribute("width", "100%");
1602 svg.setAttribute("height", H);
1603 svg.innerHTML = "";
1604
1605 function el(tag, attrs) {
1606 var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
1607 for (var k in attrs) e.setAttribute(k, attrs[k]);
1608 return e;
1609 }
1610
1611 // Arrow defs
1612 var defs = el("defs", {});
1613 var m = el("marker", {id:"cycle-arrow", markerWidth:"10", markerHeight:"7",
1614 refX:"10", refY:"3.5", orient:"auto"});
1615 m.appendChild(el("polygon", {points:"0 0, 10 3.5, 0 7", fill:"#64748b"}));
1616 defs.appendChild(m);
1617 var m2 = el("marker", {id:"cycle-arrow-red", markerWidth:"10", markerHeight:"7",
1618 refX:"10", refY:"3.5", orient:"auto"});
1619 m2.appendChild(el("polygon", {points:"0 0, 10 3.5, 0 7", fill:"#dc2626"}));
1620 defs.appendChild(m2);
1621 svg.appendChild(defs);
1622
1623 // Phases
1624 var phases = [
1625 {x:20, y:80, w:120, h:80, color:"#ea580c", title:"Observations", sub:"Accumulate",
1626 detail:["Camera traps, GPS,","GBIF, iNat, IUCN"]},
1627 {x:170, y:80, w:120, h:80, color:"#7c3aed", title:"Synthetic", sub:"Augment",
1628 detail:["Lotka-Volterra","simulation"]},
1629 {x:320, y:80, w:120, h:80, color:"#a16207", title:"Training Set", sub:"Assemble",
1630 detail:["Balanced sample","23% synthetic"]},
1631 {x:470, y:80, w:120, h:80, color:"#0891b2", title:"Train Model", sub:"TESSERA v3.1",
1632 detail:["Habitat classifier","from embeddings"]},
1633 {x:620, y:80, w:120, h:80, color:"#65a30d", title:"Classify", sub:"Habitat tiles",
1634 detail:["Savanna, shrubland,","cropland tiles"]},
1635 {x:770, y:80, w:90, h:80, color:"#16a34a", title:"AOH", sub:"Compute",
1636 detail:["Range \u2229 suitable","habitat"]}
1637 ];
1638
1639 phases.forEach(function(p, i) {
1640 svg.appendChild(el("rect", {
1641 x:p.x+2, y:p.y+2, width:p.w, height:p.h, rx:8, fill:"#e2e8f0"
1642 }));
1643 svg.appendChild(el("rect", {
1644 x:p.x, y:p.y, width:p.w, height:p.h, rx:8,
1645 fill:"#fff", stroke:p.color, "stroke-width":"2"
1646 }));
1647 svg.appendChild(el("rect", {
1648 x:p.x, y:p.y, width:p.w, height:6, rx:3, fill:p.color
1649 }));
1650
1651 var t = el("text", {x:p.x+p.w/2, y:p.y+28, "text-anchor":"middle",
1652 "font-size":"12", "font-weight":"700", fill:p.color});
1653 t.textContent = p.title;
1654 svg.appendChild(t);
1655
1656 var s = el("text", {x:p.x+p.w/2, y:p.y+42, "text-anchor":"middle",
1657 "font-size":"9", fill:"#64748b"});
1658 s.textContent = p.sub;
1659 svg.appendChild(s);
1660
1661 p.detail.forEach(function(line, li) {
1662 var d = el("text", {x:p.x+p.w/2, y:p.y+56+li*12, "text-anchor":"middle",
1663 "font-size":"8", fill:"#94a3b8"});
1664 d.textContent = line;
1665 svg.appendChild(d);
1666 });
1667
1668 // Phase number
1669 var numC = el("circle", {cx:p.x+12, cy:p.y-10, r:10, fill:p.color});
1670 svg.appendChild(numC);
1671 var numT = el("text", {x:p.x+12, y:p.y-6, "text-anchor":"middle",
1672 "font-size":"10", "font-weight":"700", fill:"#fff"});
1673 numT.textContent = String(i+1);
1674 svg.appendChild(numT);
1675 });
1676
1677 // Forward arrows
1678 [[0,2],[1,2],[2,3],[3,4],[4,5]].forEach(function(pair) {
1679 var from = phases[pair[0]], to = phases[pair[1]];
1680 svg.appendChild(el("line", {
1681 x1: from.x + from.w + 4, y1: from.y + from.h/2,
1682 x2: to.x - 4, y2: to.y + to.h/2,
1683 stroke:"#64748b", "stroke-width":"2",
1684 "marker-end":"url(#cycle-arrow)"
1685 }));
1686 });
1687
1688 // Species range bypass arc
1689 var obs = phases[0], aoh = phases[5];
1690 svg.appendChild(el("path", {
1691 d: "M " + (obs.x + obs.w/2) + " " + (obs.y + obs.h) +
1692 " Q " + (obs.x + obs.w/2) + " " + (obs.y + obs.h + 65) +
1693 " " + 620 + " " + (obs.y + obs.h + 65) +
1694 " Q " + (aoh.x + aoh.w/2) + " " + (obs.y + obs.h + 65) +
1695 " " + (aoh.x + aoh.w/2) + " " + (aoh.y + aoh.h),
1696 fill:"none", stroke:"#3b82f6", "stroke-width":"1.5", "stroke-dasharray":"5,3",
1697 "marker-end":"url(#cycle-arrow)"
1698 }));
1699 var rangeLabel = el("text", {x:400, y:obs.y + obs.h + 60, "text-anchor":"middle",
1700 "font-size":"9", fill:"#3b82f6", "font-weight":"600"});
1701 rangeLabel.textContent = "Species Range (measured-only, alpha-shape)";
1702 svg.appendChild(rangeLabel);
1703
1704 // Recomputation feedback
1705 svg.appendChild(el("path", {
1706 d: "M " + (aoh.x + aoh.w) + " " + (aoh.y + 20) +
1707 " C " + (aoh.x + aoh.w + 30) + " " + (aoh.y + 20) +
1708 " " + (aoh.x + aoh.w + 30) + " 22 " +
1709 " " + (phases[2].x + phases[2].w/2) + " 22" +
1710 " L " + (phases[2].x + phases[2].w/2) + " " + (phases[2].y - 2),
1711 fill:"none", stroke:"#dc2626", "stroke-width":"1.5", "stroke-dasharray":"4,3",
1712 "marker-end":"url(#cycle-arrow-red)"
1713 }));
1714 var recompLabel = el("text", {x:590, y:16, "text-anchor":"middle",
1715 "font-size":"9", fill:"#dc2626", "font-weight":"600"});
1716 recompLabel.textContent = "Recompute when new observations arrive or model retrains";
1717 svg.appendChild(recompLabel);
1718
1719 // Origin type labels
1720 [
1721 {x:80, y:175, text:"Measured", color:"#ea580c"},
1722 {x:230, y:175, text:"Simulated", color:"#7c3aed"},
1723 {x:380, y:175, text:"Derived", color:"#a16207"}
1724 ].forEach(function(ol) {
1725 var t = el("text", {x:ol.x, y:ol.y, "text-anchor":"middle",
1726 "font-size":"8", fill:ol.color, "font-weight":"600"});
1727 t.textContent = ol.text;
1728 svg.appendChild(t);
1729 });
1730
1731 // Bottom note
1732 var note = el("text", {x:W/2, y:H - 15, "text-anchor":"middle",
1733 "font-size":"10", fill:"#64748b", "font-style":"italic"});
1734 note.textContent = "Each phase produces labels that become inputs to the next. Old versions are retained for comparison.";
1735 svg.appendChild(note);
1736}
1737
1738// ═══════════════════════════════════════════════════════
1739// INITIALISE
1740// ═══════════════════════════════════════════════════════
1741
1742document.addEventListener("DOMContentLoaded", function() {
1743 buildMap();
1744 setupToggles();
1745 buildDAG();
1746 buildCycleDiagram();
1747});
1748
1749})();
1750</script>
1751
1752</body>
1753</html>