1/** 2 * GeoVault â workstation.js 3 * 4 * The analysis side of the clean-room workstation: Leaflet map (no external 5 * basemap â connect-src 'self' means the map shows exactly what you loaded), 6 * GeoTIFF ingest via WasmCogViewer.openBytes, WasmTerrain relief analysis, 7 * the WasmAnomaly workbench, measurement tools, and raster statistics. 8 * 9 * Every user action is appended to the tamper-evident session ledger via 10 * vaultLog() from vault-ui.js. File identity is bound into the ledger as a 11 * blake3 digest (fileDigestHex) at open time. 12 */ 13 14import { 15 WasmCogViewer, 16 WasmTerrain, 17 WasmAnomaly, 18 WasmImageProcessor, 19 fileDigestHex, 20} from './pkg/oxigeo_wasm.js'; 21import { initVault, vaultLog, isSealed } from './vault-ui.js'; 22 23/* ------------------------------------------------------------------ */ 24/* State */ 25/* ------------------------------------------------------------------ */ 26 27const EARTH_RADIUS_M = 6371000; 28const METERS_PER_DEG_LAT = 110540; 29const METERS_PER_DEG_LON_EQ = 111320; 30 31const app = { 32 map: null, 33 baseLayer: null, 34 anomalyLayer: null, 35 viewer: null, 36 scene: null, // { name, width, height, elev, nodata, bounds, cellSize, elevMin, elevMax } 37 canvas: null, // offscreen render target for the base layer 38 terrain: { 39 mode: 'raw', 40 azimuth: 315, 41 altitude: 45, 42 zFactor: 1.0, 43 rerenderTimer: null, 44 }, 45 measure: { 46 mode: null, // 'distance' | 'area' | null 47 points: [], 48 markers: [], 49 shape: null, 50 }, 51 moveLogTimer: null, 52 busy: false, 53}; 54 55/* ------------------------------------------------------------------ */ 56/* Small helpers */ 57/* ------------------------------------------------------------------ */ 58 59function $(id) { 60 return document.getElementById(id); 61} 62 63function setHint(title, detail) { 64 const hint = $('map-hint'); 65 if (!hint) return; 66 if (!title) { 67 hint.classList.add('hidden'); 68 return; 69 } 70 hint.classList.remove('hidden'); 71 hint.textContent = ''; 72 const t = document.createElement('div'); 73 t.className = 'map-hint-title'; 74 t.textContent = title; 75 const d = document.createElement('div'); 76 d.textContent = detail || ''; 77 hint.appendChild(t); 78 hint.appendChild(d); 79} 80 81/** Render `[[k, v], â¦]` pairs into a <dl class="kv"> and reveal its box. */ 82function renderKv(dlId, boxId, pairs) { 83 const dl = $(dlId); 84 if (!dl) return; 85 dl.textContent = ''; 86 for (const [k, v] of pairs) { 87 const dt = document.createElement('dt'); 88 dt.textContent = k; 89 const dd = document.createElement('dd'); 90 dd.textContent = v; 91 dl.appendChild(dt); 92 dl.appendChild(dd); 93 } 94 const box = $(boxId); 95 if (box) box.classList.remove('hidden'); 96} 97 98function fmtMeters(m) { 99 return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${m.toFixed(1)} m`; 100} 101 102function fmtArea(m2) { 103 return m2 >= 1e6 ? `${(m2 / 1e6).toFixed(3)} km²` : `${m2.toFixed(0)} m²`; 104} 105 106/* ------------------------------------------------------------------ */ 107/* Boot */ 108/* ------------------------------------------------------------------ */ 109 110async function boot() { 111 try { 112 await initVault(); 113 } catch (err) { 114 setHint('Failed to start', String(err && err.message ? err.message : err)); 115 return; 116 } 117 118 setupMap(); 119 wireDatasetControls(); 120 wireTerrainControls(); 121 wireAnomalyControls(); 122 wireMeasureControls(); 123 wireStatsControls(); 124 document.addEventListener('geovault:sealed', onSealed); 125 126 await loadExampleIndex(); 127} 128 129function setupMap() { 130 app.map = L.map('map', { 131 center: [46.53, 63.49], 132 zoom: 12, 133 zoomControl: true, 134 attributionControl: false, 135 doubleClickZoom: false, 136 // No basemap on purpose: the clean-room shows only what you loaded. 137 maxZoom: 19, 138 minZoom: 3, 139 }); 140 L.control.scale({ imperial: false }).addTo(app.map); 141 142 app.map.on('moveend', () => { 143 if (!app.scene || isSealed()) return; 144 clearTimeout(app.moveLogTimer); 145 app.moveLogTimer = setTimeout(() => { 146 const c = app.map.getCenter(); 147 vaultLog('view.move', { 148 center: [Number(c.lat.toFixed(5)), Number(c.lng.toFixed(5))], 149 zoom: app.map.getZoom(), 150 }); 151 }, 600); 152 }); 153 154 app.map.on('click', onMapClick); 155 app.map.on('dblclick', onMapDoubleClick); 156} 157 158/* ------------------------------------------------------------------ */ 159/* Dataset loading */ 160/* ------------------------------------------------------------------ */ 161 162async function loadExampleIndex() { 163 let data; 164 try { 165 const res = await fetch('./examples.json'); 166 data = await res.json(); 167 } catch { 168 return; // card list stays empty; drag-drop still works 169 } 170 const host = $('dataset-list'); 171 if (!host || !data || !Array.isArray(data.datasets)) return; 172 host.textContent = ''; 173 for (const ds of data.datasets) { 174 const card = document.createElement('button'); 175 card.className = 'dataset-card'; 176 card.type = 'button'; 177 const name = document.createElement('div'); 178 name.className = 'card-name'; 179 name.textContent = ds.name; 180 const desc = document.createElement('div'); 181 desc.className = 'card-desc'; 182 desc.textContent = ds.description || ''; 183 const tag = document.createElement('span'); 184 tag.className = 'card-tag'; 185 tag.textContent = 'SYNTHETIC · LOCAL'; 186 card.appendChild(name); 187 card.appendChild(desc); 188 card.appendChild(tag); 189 card.addEventListener('click', () => loadExample(ds)); 190 host.appendChild(card); 191 } 192} 193 194async function loadExample(ds) { 195 if (app.busy || isSealed()) return; 196 try { 197 app.busy = true; 198 setHint('Loading', `${ds.name}â¦`); 199 const res = await fetch(ds.url); 200 if (!res.ok) throw new Error(`HTTP ${res.status} for ${ds.url}`); 201 const buffer = await res.arrayBuffer(); 202 await openScene(new Uint8Array(buffer), ds.name, 'sample'); 203 } catch (err) {
204 setHint('Failed to load sample', String(err && err.message ? err.message : err)); 205 } finally { 206 app.busy = false; 207 } 208} 209 210function wireDatasetControls() { 211 const dropZone = $('drop-zone'); 212 const fileInput = $('file-input'); 213 const mapEl = $('map'); 214 215 if (fileInput) { 216 fileInput.addEventListener('change', (e) => { 217 const file = e.target.files && e.target.files[0]; 218 if (file) handleLocalFile(file); 219 e.target.value = ''; 220 }); 221 } 222 for (const target of [dropZone, mapEl].filter(Boolean)) { 223 target.addEventListener('dragover', (e) => { 224 e.preventDefault(); 225 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; 226 if (dropZone) dropZone.classList.add('drop-hover'); 227 }); 228 target.addEventListener('dragleave', () => { 229 if (dropZone) dropZone.classList.remove('drop-hover'); 230 }); 231 target.addEventListener('drop', (e) => { 232 e.preventDefault(); 233 if (dropZone) dropZone.classList.remove('drop-hover'); 234 const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; 235 if (file) handleLocalFile(file); 236 }); 237 } 238} 239 240async function handleLocalFile(file) { 241 if (app.busy || isSealed()) return; 242 const name = file.name || 'local.tif'; 243 if (!/\.(tif|tiff)$/i.test(name)) { 244 setHint('Unsupported file', 'Please choose a GeoTIFF (.tif / .tiff).'); 245 return; 246 } 247 try { 248 app.busy = true; 249 setHint('Decoding', `${name} â in-browser, nothing is uploadedâ¦`); 250 const buffer = await file.arrayBuffer(); 251 await openScene(new Uint8Array(buffer), name, 'local-file'); 252 } catch (err) { 253 setHint('Failed to open file', String(err && err.message ? err.message : err)); 254 } finally { 255 app.busy = false; 256 } 257} 258 259/** 260 * Shared ingest path for sample + dropped files: decode via openBytes, 261 * bind the blake3 digest into the ledger, assemble the elevation grid, 262 * and render the raw DEM. 263 */ 264async function openScene(bytes, name, source) { 265 const viewer = new WasmCogViewer(); 266 viewer.openBytes(bytes, name); // throws on invalid TIFF 267 268 const digest = fileDigestHex(bytes); 269 vaultLog('file.open', { 270 name, 271 size: bytes.length, 272 blake3: digest, 273 source, 274 }); 275 276 const width = Number(viewer.width()); 277 const height = Number(viewer.height()); 278 const nodataInput = $('anomaly-nodata'); 279 const nodata = nodataInput ? Number(nodataInput.value) : NaN; 280 281 const bounds = sceneBounds(viewer, width, height); 282 const elev = await assembleElevation(viewer, width, height); 283 284 // Valid-range scan (single pass): ignores NaN/±inf and the nodata value. 285 let min = Infinity; 286 let max = -Infinity; 287 for (let i = 0; i < elev.length; i++) { 288 const v = elev[i]; 289 if (!Number.isFinite(v) || v === nodata) continue; 290 if (v < min) min = v; 291 if (v > max) max = v; 292 } 293 if (min > max) { 294 min = 0; 295 max = 1; 296 } 297 298 app.viewer = viewer; 299 app.scene = { 300 name, 301 width, 302 height, 303 elev, 304 nodata, 305 bounds, 306 cellSize: cellSizeMeters(viewer, bounds), 307 elevMin: min, 308 elevMax: max, 309 sizeBytes: bytes.length, 310 digest, 311 }; 312 app.canvas = document.createElement('canvas'); 313 app.canvas.width = width; 314 app.canvas.height = height; 315 316 clearAnomalyOverlay(false); 317 clearMeasure(false); 318 app.terrain.mode = 'raw'; 319 updateTerrainButtons(); 320 renderScene(); 321 app.map.fitBounds(bounds); 322 setHint(null); 323 324 renderKv('dataset-info', 'dataset-info-box', [ 325 ['name', name], 326 ['size', `${bytes.length.toLocaleString()} B`], 327 ['dims', `${width}Ã${height}·b${viewer.band_count()}`], 328 ['epsg', String(viewer.epsg_code() ?? 'n/a')], 329 ['overviews', String(viewer.overview_count())], 330 ['elev', `${min.toFixed(1)}â¦${max.toFixed(1)} m`], 331 ['blake3', `${digest.slice(0, 16)}â¦`], 332 ]); 333} 334 335/** Geographic bounds from the geotransform (EPSG:4326 rasters). */ 336function sceneBounds(viewer, width, height) { 337 const psx = viewer.pixel_scale_x(); 338 const psy = viewer.pixel_scale_y(); 339 const gx = viewer.tiepoint_geo_x(); 340 const gy = viewer.tiepoint_geo_y(); 341 if (psx === undefined || psy === undefined || gx === undefined || gy === undefined) { 342 // Ungeoreferenced raster: place it on a unit-degree box at the equator. 343 return [ 344 [0, 0], 345 [height / 3600, width / 3600], 346 ]; 347 } 348 const west = gx; 349 const north = gy; 350 const east = gx + width * psx; 351 const south = gy - height * Math.abs(psy); 352 return [ 353 [south, west], 354 [north, east], 355 ]; 356} 357 358/** Nominal ground cell size in meters at the scene's center latitude. */ 359function cellSizeMeters(viewer, bounds) { 360 const psx = viewer.pixel_scale_x(); 361 if (psx === undefined) return 30; 362 const midLat = (bounds[0][0] + bounds[1][0]) / 2; 363 const mx = psx * METERS_PER_DEG_LON_EQ * Math.cos((midLat * Math.PI) / 180); 364 const my = Math.abs(viewer.pixel_scale_y() ?? psx) * METERS_PER_DEG_LAT; 365 return (mx + my) / 2; 366} 367 368/** Read every level-0 tile as f32 elevation and stitch the full grid. */ 369async function assembleElevation(viewer, width, height) { 370 const tw = viewer.tile_width(); 371 const th = viewer.tile_height(); 372 const tilesX = Math.ceil(width / tw);
373 const tilesY = Math.ceil(height / th); 374 const out = new Float32Array(width * height); 375 for (let ty = 0; ty < tilesY; ty++) { 376 for (let tx = 0; tx < tilesX; tx++) { 377 const tile = await viewer.readTileElevation(0, tx, ty); 378 const copyW = Math.min(tw, width - tx * tw); 379 const copyH = Math.min(th, height - ty * th); 380 for (let row = 0; row < copyH; row++) { 381 const src = row * tw; 382 const dst = (ty * th + row) * width + tx * tw; 383 out.set(tile.subarray(src, src + copyW), dst); 384 } 385 } 386 } 387 return out; 388} 389 390/* ------------------------------------------------------------------ */ 391/* Rendering */ 392/* ------------------------------------------------------------------ */ 393 394/** Draw ImageData to the scene canvas and swap it into the base overlay. */ 395function presentImageData(imageData) { 396 const ctx = app.canvas.getContext('2d'); 397 ctx.putImageData(imageData, 0, 0); 398 const dataUrl = app.canvas.toDataURL('image/png'); 399 if (app.baseLayer) { 400 app.baseLayer.setUrl(dataUrl); 401 } else { 402 app.baseLayer = L.imageOverlay(dataUrl, app.scene.bounds, { 403 opacity: 1, 404 interactive: false, 405 className: 'leaflet-overlay-crisp', 406 }).addTo(app.map); 407 } 408 if (app.anomalyLayer) app.anomalyLayer.bringToFront(); 409} 410 411/** Grayscale RGBA from a value grid with a linear lo..hi stretch. */ 412function grayImageData(values, width, height, lo, hi, nodata) { 413 const rgba = new Uint8ClampedArray(width * height * 4); 414 const range = hi > lo ? hi - lo : 1; 415 for (let i = 0; i < values.length; i++) { 416 const v = values[i]; 417 const o = i * 4; 418 if (!Number.isFinite(v) || v === nodata) { 419 rgba[o + 3] = 0; 420 continue; 421 } 422 const g = Math.max(0, Math.min(255, Math.round(((v - lo) / range) * 255))); 423 rgba[o] = g; 424 rgba[o + 1] = g; 425 rgba[o + 2] = g; 426 rgba[o + 3] = 255; 427 } 428 return new ImageData(rgba, width, height); 429} 430 431/** Grayscale RGBA straight from 0..255 bytes (hillshade output). */ 432function bytesImageData(bytes, width, height) { 433 const rgba = new Uint8ClampedArray(width * height * 4); 434 for (let i = 0; i < bytes.length; i++) { 435 const o = i * 4; 436 rgba[o] = bytes[i]; 437 rgba[o + 1] = bytes[i]; 438 rgba[o + 2] = bytes[i]; 439 rgba[o + 3] = 255; 440 } 441 return new ImageData(rgba, width, height); 442} 443 444/** Re-render the base layer for the current terrain mode. */
445function renderScene() { 446 const s = app.scene; 447 if (!s) return; 448 const t = app.terrain; 449 switch (t.mode) { 450 case 'hillshade': { 451 const shade = WasmTerrain.hillshade( 452 s.elev, s.width, s.height, s.cellSize, t.azimuth, t.altitude, t.zFactor 453 ); 454 presentImageData(bytesImageData(shade, s.width, s.height)); 455 break; 456 } 457 case 'multidir': { 458 const shade = WasmTerrain.hillshadeMultidirectional( 459 s.elev, s.width, s.height, s.cellSize, t.altitude, t.zFactor 460 ); 461 presentImageData(bytesImageData(shade, s.width, s.height)); 462 break; 463 } 464 case 'slope': { 465 const slope = WasmTerrain.slope(s.elev, s.width, s.height, s.cellSize, t.zFactor); 466 presentImageData(grayImageData(slope, s.width, s.height, 0, 60, NaN)); 467 break; 468 } 469 case 'relief': { 470 const img = WasmTerrain.colorReliefShaded( 471 s.elev, s.width, s.height, s.cellSize, 'terrain', 472 s.elevMin, s.elevMax, t.altitude, t.zFactor 473 ); 474 presentImageData(img); 475 break; 476 } 477 default: 478 presentImageData( 479 grayImageData(s.elev, s.width, s.height, s.elevMin, s.elevMax, s.nodata) 480 ); 481 } 482} 483 484/* ------------------------------------------------------------------ */ 485/* Terrain controls */ 486/* ------------------------------------------------------------------ */ 487 488const TERRAIN_BUTTONS = { 489 'terrain-raw': 'raw', 490 'terrain-hillshade': 'hillshade', 491 'terrain-multidir': 'multidir', 492 'terrain-slope': 'slope', 493 'terrain-relief': 'relief', 494}; 495 496const TERRAIN_OPS = { 497 raw: 'terrain.clear', 498 hillshade: 'terrain.hillshade', 499 multidir: 'terrain.hillshade_multidirectional', 500 slope: 'terrain.slope', 501 relief: 'terrain.color_relief', 502}; 503 504function terrainParams(mode) { 505 const s = app.scene; 506 const t = app.terrain; 507 const base = { 508 cell_size_m: Number(s.cellSize.toFixed(2)), 509 z_factor: t.zFactor, 510 }; 511 if (mode === 'hillshade') { 512 return { ...base, azimuth: t.azimuth, altitude: t.altitude }; 513 } 514 if (mode === 'multidir') return { ...base, altitude: t.altitude }; 515 if (mode === 'relief') { 516 return { 517 ...base, 518 palette: 'terrain', 519 min_m: Number(s.elevMin.toFixed(1)), 520 max_m: Number(s.elevMax.toFixed(1)), 521 altitude: t.altitude, 522 }; 523 } 524 if (mode === 'raw') return {}; 525 return base; 526} 527 528function setTerrainMode(mode) { 529 if (!app.scene || isSealed()) return; 530 app.terrain.mode = mode; 531 updateTerrainButtons(); 532 renderScene(); 533 vaultLog(TERRAIN_OPS[mode], terrainParams(mode)); 534} 535 536function updateTerrainButtons() { 537 for (const [id, mode] of Object.entries(TERRAIN_BUTTONS)) { 538 const btn = $(id); 539 if (btn) btn.classList.toggle('btn-active', app.terrain.mode === mode); 540 } 541} 542 543function scheduleTerrainRerender() { 544 clearTimeout(app.terrain.rerenderTimer); 545 app.terrain.rerenderTimer = setTimeout(() => { 546 if (app.scene && app.terrain.mode !== 'raw') renderScene(); 547 }, 120); 548} 549 550function wireTerrainControls() { 551 for (const [id, mode] of Object.entries(TERRAIN_BUTTONS)) { 552 const btn = $(id); 553 if (btn) btn.addEventListener('click', () => setTerrainMode(mode)); 554 } 555 556 const sliders = [ 557 ['terrain-azimuth', 'azimuth', (v) => `${v}°`, parseInt], 558 ['terrain-altitude', 'altitude', (v) => `${v}°`, parseInt], 559 ['terrain-zfactor', 'zFactor', (v) => v.toFixed(1), parseFloat], 560 ]; 561 for (const [id, key, fmt, parse] of sliders) { 562 const el = $(id); 563 if (!el) continue; 564 // Live re-render while dragging; one ledger entry on commit. 565 el.addEventListener('input', (e) => { 566 app.terrain[key] = parse(e.target.value, 10); 567 const label = $(`${id}-value`); 568 if (label) label.textContent = fmt(app.terrain[key]); 569 scheduleTerrainRerender(); 570 }); 571 el.addEventListener('change', () => { 572 if (!app.scene || app.terrain.mode === 'raw' || isSealed()) return; 573 vaultLog( 574 TERRAIN_OPS[app.terrain.mode], 575 terrainParams(app.terrain.mode) 576 ); 577 }); 578 } 579} 580 581/* ------------------------------------------------------------------ */ 582/* Anomaly workbench */ 583/* ------------------------------------------------------------------ */ 584 585const ANOMALY_DEFAULT_THRESHOLD = { 586 zscore: 3.0, 587 iqr: 1.5, 588 modified_zscore: 3.5, 589}; 590 591function runAnomaly() { 592 const s = app.scene; 593 if (!s || isSealed()) return; 594 const method = $('anomaly-method').value; 595 const threshold = Number($('anomaly-threshold').value); 596 const nodata = Number($('anomaly-nodata').value); 597 const sentinel = Number.isFinite(nodata) ? nodata : NaN; 598 599 let mask; 600 switch (method) { 601 case 'iqr': 602 mask = WasmAnomaly.iqrMask(s.elev, threshold, sentinel); 603 break; 604 case 'modified_zscore': 605 mask = WasmAnomaly.modifiedZscoreMask(s.elev, threshold, sentinel); 606 break; 607 default: 608 mask = WasmAnomaly.zscoreMask(s.elev, threshold, sentinel); 609 } 610 const summaryJson = WasmAnomaly.summaryJson(method, s.elev, threshold, sentinel); 611 let summary; 612 try { 613 summary = JSON.parse(summaryJson); 614 } catch { 615 summary = { error: 'unparseable summary' }; 616 } 617 if (summary.error) { 618 renderKv('anomaly-result', 'anomaly-result-box', [['error', summary.error]]); 619 return; 620 } 621 622 // The summary IS the ledger record (plus the sentinel used). 623 vaultLog('anomaly.detect', { ...summary, nodata: sentinel }); 624 625 const img = WasmAnomaly.maskToImageData(mask, s.width, s.height, 224, 82, 82, 235); 626 const cnv = document.createElement('canvas'); 627 cnv.width = s.width; 628 cnv.height = s.height; 629 cnv.getContext('2d').putImageData(img, 0, 0); 630 const url = cnv.toDataURL('image/png'); 631 if (app.anomalyLayer) { 632 app.anomalyLayer.setUrl(url); 633 } else { 634 app.anomalyLayer = L.imageOverlay(url, s.bounds, { 635 opacity: 1, 636 interactive: false, 637 className: 'leaflet-overlay-crisp', 638 }).addTo(app.map); 639 } 640 app.anomalyLayer.bringToFront(); 641 642 const boundsTxt = 643 summary.lower_bound !== null && summary.upper_bound !== null 644 ? `${summary.lower_bound.toFixed(1)}â¦${summary.upper_bound.toFixed(1)} m` 645 : 'n/a'; 646 renderKv('anomaly-result', 'anomaly-result-box', [ 647 ['method', summary.method], 648 ['threshold', String(summary.threshold)], 649 ['flagged', `${summary.anomaly_count} px`], 650 ['share', `${summary.anomaly_pct.toFixed(3)} %`], 651 ['normal range', boundsTxt], 652 ['valid px', String(summary.valid_count)], 653 ]); 654} 655 656function clearAnomalyOverlay(log = true) { 657 if (app.anomalyLayer) { 658 app.map.removeLayer(app.anomalyLayer); 659 app.anomalyLayer = null; 660 if (log && !isSealed()) vaultLog('anomaly.clear', {}); 661 } 662 const box = $('anomaly-result-box'); 663 if (box) box.classList.add('hidden'); 664} 665
666function wireAnomalyControls() { 667 const method = $('anomaly-method'); 668 if (method) { 669 method.addEventListener('change', (e) => { 670 const th = ANOMALY_DEFAULT_THRESHOLD[e.target.value]; 671 if (th !== undefined) $('anomaly-threshold').value = String(th); 672 }); 673 } 674 const run = $('anomaly-run'); 675 if (run) run.addEventListener('click', runAnomaly); 676 const clear = $('anomaly-clear'); 677 if (clear) clear.addEventListener('click', () => clearAnomalyOverlay(true)); 678} 679 680/* ------------------------------------------------------------------ */ 681/* Measure (haversine distances, spherical polygon areas) */ 682/* ------------------------------------------------------------------ */ 683 684function haversineMeters(a, b) { 685 const toRad = (d) => (d * Math.PI) / 180; 686 const dLat = toRad(b.lat - a.lat); 687 const dLon = toRad(b.lng - a.lng); 688 const s = 689 Math.sin(dLat / 2) ** 2 + 690 Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; 691 return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(s)); 692} 693 694/** Spherical shoelace (same formula Leaflet.GeometryUtil uses). */ 695function sphericalAreaSqMeters(points) { 696 if (points.length < 3) return 0; 697 const toRad = (d) => (d * Math.PI) / 180; 698 let sum = 0; 699 for (let i = 0; i < points.length; i++) { 700 const p1 = points[i]; 701 const p2 = points[(i + 1) % points.length]; 702 sum += 703 toRad(p2.lng - p1.lng) * 704 (2 + Math.sin(toRad(p1.lat)) + Math.sin(toRad(p2.lat))); 705 } 706 return Math.abs((sum * EARTH_RADIUS_M * EARTH_RADIUS_M) / 2); 707} 708 709function setMeasureMode(mode) { 710 if (!app.scene || isSealed()) return; 711 clearMeasureShapes(); 712 app.measure.mode = mode; 713 app.measure.points = []; 714 $('measure-distance').classList.toggle('btn-active', mode === 'distance'); 715 $('measure-area').classList.toggle('btn-active', mode === 'area'); 716} 717 718function onMapClick(e) { 719 const m = app.measure; 720 if (!m.mode || isSealed()) return; 721 const last = m.points[m.points.length - 1]; 722 if (last && Math.abs(last.lat - e.latlng.lat) < 1e-9 && Math.abs(last.lng - e.latlng.lng) < 1e-9) { 723 return; // dblclick fires a duplicate click at the same spot 724 } 725 m.points.push(e.latlng); 726 m.markers.push( 727 L.circleMarker(e.latlng, { 728 radius: 4, 729 color: '#5b8dbe', 730 weight: 2, 731 fillOpacity: 0.8, 732 }).addTo(app.map) 733 ); 734 redrawMeasureShape(false); 735} 736 737function onMapDoubleClick() { 738 const m = app.measure; 739 if (!m.mode || isSealed()) return; 740 const need = m.mode === 'area' ? 3 : 2; 741 if (m.points.length < need) return; 742 redrawMeasureShape(true); 743 744 if (m.mode === 'distance') { 745 let total = 0; 746 for (let i = 1; i < m.points.length; i++) { 747 total += haversineMeters(m.points[i - 1], m.points[i]); 748 } 749 vaultLog('measure.distance', { 750 vertices: m.points.length, 751 meters: Number(total.toFixed(1)), 752 }); 753 renderKv('measure-result', 'measure-result-box', [ 754 ['type', 'distance'], 755 ['vertices', String(m.points.length)], 756 ['length', fmtMeters(total)], 757 ]); 758 } else { 759 const area = sphericalAreaSqMeters(m.points); 760 vaultLog('measure.area', { 761 vertices: m.points.length, 762 sq_meters: Number(area.toFixed(1)), 763 }); 764 renderKv('measure-result', 'measure-result-box', [ 765 ['type', 'area'], 766 ['vertices', String(m.points.length)], 767 ['area', fmtArea(area)], 768 ]); 769 } 770 m.mode = null; 771 m.points = []; 772 $('measure-distance').classList.remove('btn-active'); 773 $('measure-area').classList.remove('btn-active'); 774} 775 776function redrawMeasureShape(closed) { 777 const m = app.measure; 778 if (m.shape) { 779 app.map.removeLayer(m.shape); 780 m.shape = null; 781 } 782 if (m.points.length < 2) return; 783 const style = { color: '#7fabd6', weight: 2, dashArray: closed ? null : '4 4' }; 784 m.shape = 785 m.mode === 'area' && m.points.length >= 3 786 ? L.polygon(m.points, { ...style, fillOpacity: 0.08 }) 787 : L.polyline(m.points, style); 788 m.shape.addTo(app.map); 789} 790 791function clearMeasureShapes() { 792 const m = app.measure; 793 for (const marker of m.markers) app.map.removeLayer(marker); 794 m.markers = []; 795 if (m.shape) { 796 app.map.removeLayer(m.shape); 797 m.shape = null; 798 } 799} 800 801function clearMeasure(log = true) { 802 const m = app.measure; 803 const hadSomething = m.markers.length > 0 || m.shape !== null; 804 clearMeasureShapes(); 805 m.mode = null; 806 m.points = []; 807 const box = $('measure-result-box'); 808 if (box) box.classList.add('hidden'); 809 const bd = $('measure-distance'); 810 const ba = $('measure-area'); 811 if (bd) bd.classList.remove('btn-active'); 812 if (ba) ba.classList.remove('btn-active'); 813 if (log && hadSomething && !isSealed()) vaultLog('measure.clear', {}); 814} 815 816function wireMeasureControls() { 817 $('measure-distance').addEventListener('click', () => setMeasureMode('distance')); 818 $('measure-area').addEventListener('click', () => setMeasureMode('area')); 819 $('measure-clear').addEventListener('click', () => clearMeasure(true)); 820} 821 822/* ------------------------------------------------------------------ */ 823/* Statistics + histogram */ 824/* ------------------------------------------------------------------ */ 825 826function runStats() { 827 const s = app.scene; 828 if (!s || isSealed()) return; 829 830 // Stats over the CURRENT rendering (what the analyst is looking at), 831 // via the wasm image kernels; elevation range comes from the grid. 832 const ctx = app.canvas.getContext('2d'); 833 const img = ctx.getImageData(0, 0, s.width, s.height); 834 const rgba = new Uint8Array(img.data.buffer, 0, img.data.length); 835 836 let stats; 837 let hist; 838 try { 839 stats = JSON.parse(WasmImageProcessor.computeStats(rgba, s.width, s.height)); 840 hist = JSON.parse(WasmImageProcessor.computeHistogram(rgba, s.width, s.height)); 841 } catch (err) { 842 renderKv('stats-result', 'stats-result-box', [
843 ['error', String(err && err.message ? err.message : err)], 844 ]); 845 return; 846 } 847 848 const lum = hist.luminance; 849 vaultLog('raster.stats', { 850 view: app.terrain.mode, 851 lum_mean: Number(lum.mean.toFixed(2)), 852 lum_std: Number(lum.std_dev.toFixed(2)), 853 lum_min: lum.min, 854 lum_max: lum.max, 855 elev_min_m: Number(s.elevMin.toFixed(1)), 856 elev_max_m: Number(s.elevMax.toFixed(1)), 857 total_px: hist.total_pixels, 858 }); 859 860 renderKv('stats-result', 'stats-result-box', [ 861 ['view', app.terrain.mode], 862 ['lum mean', lum.mean.toFixed(2)], 863 ['lum Ï', lum.std_dev.toFixed(2)], 864 ['lum range', `${lum.min}â¦${lum.max}`], 865 ['elev range', `${s.elevMin.toFixed(1)}â¦${s.elevMax.toFixed(1)} m`], 866 ['pixels', String(hist.total_pixels)], 867 ]); 868 drawHistogram(lum.bins); 869} 870 871function drawHistogram(bins) { 872 const canvas = $('histogram-canvas'); 873 if (!canvas) return; 874 canvas.classList.remove('hidden'); 875 const ctx = canvas.getContext('2d'); 876 const { width, height } = canvas; 877 ctx.clearRect(0, 0, width, height); 878 const peak = Math.max(1, ...bins); 879 const barW = width / bins.length; 880 ctx.fillStyle = '#5b8dbe'; 881 for (let i = 0; i < bins.length; i++) { 882 const h = Math.round((bins[i] / peak) * (height - 6)); 883 if (h > 0) ctx.fillRect(i * barW, height - h, Math.max(1, barW - 0.5), h); 884 } 885 ctx.fillStyle = '#2e3948'; 886 ctx.fillRect(0, height - 1, width, 1); 887} 888 889function wireStatsControls() { 890 $('stats-run').addEventListener('click', runStats); 891} 892 893/* ------------------------------------------------------------------ */ 894/* Sealed state */ 895/* ------------------------------------------------------------------ */ 896 897function onSealed() { 898 // The ledger is immutable; freeze every state-changing control. The map 899 // stays browsable (pan/zoom no longer logs â the record is closed). 900 const selectors = [ 901 '.tool-btn', 902 '.dataset-card', 903 '#file-input', 904 '#anomaly-method', 905 '#anomaly-threshold', 906 '#anomaly-nodata', 907 '#terrain-azimuth', 908 '#terrain-altitude', 909 '#terrain-zfactor', 910 ]; 911 for (const sel of selectors) { 912 for (const el of document.querySelectorAll(sel)) el.disabled = true; 913 } 914 app.measure.mode = null; 915} 916 917/* ------------------------------------------------------------------ */ 918 919boot();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.