PageSourceSearch

https://cooljapan.tech/geovault/workstation.js

js cooljapan.tech collected 2026-10-04 08:30:11 UTC 30,632 bytes, 919 lines download raw bytes

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.