A research repository into geolabels, not for wide use yet
at main 1753 lines 78 kB view raw
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 &mdash; 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 &middot; 23 labels &middot; 10 activities &middot; CRS EPSG:4326 &middot; 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 &mdash; 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 &mdash; 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&sup2;</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 &ldquo;was computed from&rdquo;. 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&sup2; of suitable habitat out of 530 4,850 km&sup2; 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 &rarr; 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 &mdash; 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 &mdash; 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 &mdash; 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 -&gt; event_date 651val string_of_event_date : event_date -&gt; 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 -&gt; cell 662val string_of_cell : cell -&gt; 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 -&gt; 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 &mdash; 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 &ldquo;who&rdquo; and &ldquo;when&rdquo; 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 -&gt; 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 &mdash; 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 -&gt; ?level:int -&gt; unit -&gt; 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 -&gt; id:string -&gt; 807 x:float -&gt; y:float -&gt; 808 observer:string -&gt; 809 ?accuracy_m:float -&gt; 810 ?event_date:event_date -&gt; ?confidence:float -&gt; 811 ?class_dist:(string * float) list -&gt; 812 ?activity:string -&gt; 813 ?properties:(string * string) list -&gt; 814 unit -&gt; 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 -&gt; id:string -&gt; 820 ring:point list -&gt; 821 observer:string -&gt; 822 ?accuracy_m:float -&gt; 823 ?event_date:event_date -&gt; ?confidence:float -&gt; 824 ?class_dist:(string * float) list -&gt; 825 ?activity:string -&gt; 826 ?properties:(string * string) list -&gt; 827 unit -&gt; 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 -&gt; id:string -&gt; 833 geometry:geometry -&gt; 834 via:string -&gt; 835 ?observer:string -&gt; ?license:string -&gt; 836 ?accuracy_m:float -&gt; 837 ?event_date:event_date -&gt; ?confidence:float -&gt; 838 ?class_dist:(string * float) list -&gt; 839 ?activity:string -&gt; 840 ?properties:(string * string) list -&gt; 841 unit -&gt; 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 -&gt; id:string -&gt; 848 geometry:geometry -&gt; 849 sources:string list -&gt; 850 method_:string -&gt; 851 ?event_date:event_date -&gt; ?confidence:float -&gt; 852 ?class_dist:(string * float) list -&gt; 853 ?activity:string -&gt; 854 ?properties:(string * string) list -&gt; 855 unit -&gt; 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 -&gt; id:string -&gt; 862 geometry:geometry -&gt; 863 model:string -&gt; 864 run_id:string -&gt; 865 ?event_date:event_date -&gt; ?confidence:float -&gt; 866 ?class_dist:(string * float) list -&gt; 867 ?activity:string -&gt; 868 ?properties:(string * string) list -&gt; 869 unit -&gt; 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 -&gt; string option 881 882(** Positional accuracy in metres, if Measured. *) 883val accuracy_of : label -&gt; float option 884 885(** Source label IDs, if Derived. Empty otherwise. *) 886val sources_of : label -&gt; string list 887 888(** Registry URI, if imported via a registry. *) 889val via_of : label -&gt; string option 890 891(** True for Simulated labels. *) 892val is_simulated : label -&gt; 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 -&gt; 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 -&gt; crs:crs -&gt; point -&gt; 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 &mdash; 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 &mdash; 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>