Analytical tools Stage 1 + drawing-tool fixes

Stage 1 of the GIS Analytical Tools concept — client-side spatial analysis:

- src/analysis/overlay.js: vector overlays (intersect / clip / difference /
  union-dissolve) on Turf.js, reprojecting to WGS84 and merging attributes
  (intersect keeps both layers', clip keeps only A's).
- src/analysis/zonal.js: vector-in-vector zonal statistics — count / sum /
  mean / min / max / total area per zone, with centroid-in-zone (default) or
  any-overlap membership.
- Bounding-box pre-filtering in both: only genuinely overlapping pairs reach
  the expensive geometry test. 43 zones x 25,004 parcels now completes in
  ~106 ms; previously it was refused as too large.
- src/analysis-modal.js + markup: Analysis panel with "Apply to" scoping —
  whole layer, current map view, selected features, or the catch of a drawn
  Circle/Area. Reached from a new "Analyse" dock button.
- MapView.addCOGLayer() for Cloud-Optimized GeoTIFF display (WebGLTile +
  GeoTIFF source, imported lazily); listVectorLayers(); getSelectedFeatures().
- Circle/Area analysis popup: one "Export" button (PDF folded into the export
  modal as a fourth format, field-rename table hidden for it) plus an
  "Analyse" button that opens the panel pre-scoped to the intersecting
  features.
- vite.config.js: code-split turf, geotiff and pako so the eager bundle is
  unchanged (~283 kB). Giving pako its own chunk also fixes a circular chunk
  between jspdf and geotiff, which share it via fast-png.

Drawing-tool fixes carried in the same working tree:

- Delete/Backspace key deletes the selection via the EditBar's Delete
  interaction (same undoable block as the button).
- Multi-select: shift-click toggles, Ctrl/Cmd-drag box-selects; helper layers
  (vertex overlay, GPS) excluded.
- Undo: split/merge/divide wrapped in undo blocks so one press reverses the
  whole operation; vertex-overlay churn no longer pollutes the undo stack;
  sources are re-scanned and the stack cleared when edit mode is entered, so
  deletes on sub-grouped layers are undoable. NOTE: the undo behaviour is not
  yet confirmed on-device.
- ol-ext TouchCursor gated to touch-only devices, so hybrid touchscreen
  laptops keep the normal cursor.

Service worker v12 -> v13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-07-21 14:09:06 +02:00
parent 1d5c460f70
commit 26c941b09a
46 changed files with 2880 additions and 947 deletions

11
dist/assets/analysis-modal-CYhcFxIW.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

81
dist/assets/geotiff-BaoeLn6q.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/geotiff-BaoeLn6q.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-BYEiD6vv.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

172
dist/assets/jspdf-FJgf4ijI.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/jspdf-FJgf4ijI.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

645
dist/assets/openlayers-J9qS6Th1.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/assets/pako-Xa-UToif.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/pako-Xa-UToif.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{E as f,a as b}from"./jspdf-BTK_8o8D.js";import"./openlayers-D8ReJJOp.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./app-icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function v({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),p=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${p}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const y=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:y,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const S=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,S);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{v as exportAnalysisPDF};
//# sourceMappingURL=pdf-export-BG6jqfsR.js.map

2
dist/assets/pdf-export-BONdNTHD.js vendored Normal file
View File

@ -0,0 +1,2 @@
import{E as f,a as b}from"./jspdf-FJgf4ijI.js";import"./pako-Xa-UToif.js";import"./geotiff-BaoeLn6q.js";import"./openlayers-J9qS6Th1.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./app-icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function F({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),p=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${p}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const y=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:y,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const S=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,S);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{F as exportAnalysisPDF};
//# sourceMappingURL=pdf-export-BONdNTHD.js.map

File diff suppressed because one or more lines are too long

6
dist/assets/shpjs-Doisr2ug.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/shpjs-Doisr2ug.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/assets/turf-B-0GbNlN.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/turf-B-0GbNlN.js.map vendored Normal file

File diff suppressed because one or more lines are too long

132
dist/index.html vendored
View File

@ -1601,11 +1601,13 @@
} }
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-DfnW4iL8.js"></script> <script type="module" crossorigin src="/assets/index-BYEiD6vv.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.js"> <link rel="modulepreload" crossorigin href="/assets/openlayers-J9qS6Th1.js">
<link rel="modulepreload" crossorigin href="/assets/pako-Xa-UToif.js">
<link rel="modulepreload" crossorigin href="/assets/geotiff-BaoeLn6q.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js"> <link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js"> <link rel="modulepreload" crossorigin href="/assets/ol-ext-C5frl4dY.js">
<link rel="modulepreload" crossorigin href="/assets/shpjs-iyObTF9J.js"> <link rel="modulepreload" crossorigin href="/assets/shpjs-Doisr2ug.js">
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css"> <link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css"> <link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
<link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css"> <link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css">
@ -1722,6 +1724,11 @@
<span>✏️</span> <span>✏️</span>
<span class="dock-btn-label">Digitise</span> <span class="dock-btn-label">Digitise</span>
</button> </button>
<button class="dock-btn" type="button" id="dock-btn-analysis"
title="Spatial Analysis — overlays and zonal statistics">
<span>🧮</span>
<span class="dock-btn-label">Analyse</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements"> <button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
<span>🗑️</span> <span>🗑️</span>
<span class="dock-btn-label">Clear</span> <span class="dock-btn-label">Clear</span>
@ -1824,6 +1831,11 @@
<label class="btn btn-outline-primary" for="export-gis-fmt-kml"> <label class="btn btn-outline-primary" for="export-gis-fmt-kml">
KML KML
</label> </label>
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-pdf" value="pdf">
<label class="btn btn-outline-primary" for="export-gis-fmt-pdf">
PDF report
</label>
</div> </div>
<div class="form-text" id="export-gis-format-hint"> <div class="form-text" id="export-gis-format-hint">
GeoJSON keeps all attributes as-is and is the safest default. GeoJSON keeps all attributes as-is and is the safest default.
@ -1839,8 +1851,9 @@
value="area_analysis"> value="area_analysis">
</div> </div>
<!-- Field rename table --> <!-- Field rename table (hidden for the PDF report, which exports the
<div> analysis summary figures rather than feature attributes) -->
<div id="export-gis-fields-wrap">
<label class="form-label fw-bold mb-1">Field names</label> <label class="form-label fw-bold mb-1">Field names</label>
<div class="form-text mb-2"> <div class="form-text mb-2">
Each source attribute on the left; rename it on the right Each source attribute on the left; rename it on the right
@ -1874,6 +1887,113 @@
</div> </div>
</div> </div>
<!-- Analysis panel — Stage 1 client-side spatial analysis (overlays + zonal
statistics). Runs in-browser via Turf.js, so it works offline. Results
are added as a new layer in the "Analysis" group. -->
<div class="modal fade" id="analysisModal" tabindex="-1"
aria-labelledby="analysisModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="analysisModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Spatial Analysis
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Mode switch -->
<div class="btn-group w-100 mb-3" role="group" aria-label="Analysis type">
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-overlay"
value="overlay" checked>
<label class="btn btn-outline-primary" for="analysis-mode-overlay">
<i class="bi bi-intersect me-1"></i>Overlay
</label>
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-zonal"
value="zonal">
<label class="btn btn-outline-primary" for="analysis-mode-zonal">
<i class="bi bi-table me-1"></i>Zonal statistics
</label>
</div>
<!-- Scope: keeps big layers workable by restricting the inputs -->
<div class="mb-3">
<label for="analysis-scope" class="form-label fw-bold">Apply to</label>
<select class="form-select form-select-sm" id="analysis-scope">
<option value="all" selected>Whole layer</option>
<option value="view">Features in current map view</option>
<option value="selection">Selected features only</option>
</select>
<div class="form-text" id="analysis-scope-hint"></div>
</div>
<!-- ---------- Overlay ---------- -->
<div id="analysis-overlay-box">
<div class="mb-3">
<label for="analysis-op" class="form-label fw-bold">Operation</label>
<select class="form-select form-select-sm" id="analysis-op"></select>
<div class="form-text" id="analysis-op-hint"></div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="analysis-layer-a" class="form-label fw-bold">Layer A</label>
<select class="form-select form-select-sm" id="analysis-layer-a"></select>
</div>
<div class="col-md-6" id="analysis-layer-b-wrap">
<label for="analysis-layer-b" class="form-label fw-bold">Layer B</label>
<select class="form-select form-select-sm" id="analysis-layer-b"></select>
</div>
</div>
</div>
<!-- ---------- Zonal statistics ---------- -->
<div id="analysis-zonal-box" class="d-none">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-zone-layer" class="form-label fw-bold">Zone layer</label>
<select class="form-select form-select-sm" id="analysis-zone-layer"></select>
<div class="form-text">Polygons the results are summarised into.</div>
</div>
<div class="col-md-6">
<label for="analysis-input-layer" class="form-label fw-bold">Features to summarise</label>
<select class="form-select form-select-sm" id="analysis-input-layer"></select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-membership" class="form-label fw-bold">Counted when</label>
<select class="form-select form-select-sm" id="analysis-membership"></select>
<div class="form-text" id="analysis-membership-hint"></div>
</div>
<div class="col-md-6">
<label for="analysis-field" class="form-label fw-bold">Numeric field</label>
<select class="form-select form-select-sm" id="analysis-field"></select>
<div class="form-text">Needed for sum, mean, minimum and maximum.</div>
</div>
</div>
<label class="form-label fw-bold d-block mb-1">Statistics</label>
<div id="analysis-stats-box" class="mb-1"><!-- populated at runtime --></div>
</div>
<hr>
<div class="mb-3">
<label for="analysis-output-name" class="form-label fw-bold">Result layer name</label>
<input type="text" class="form-control form-control-sm" id="analysis-output-name">
</div>
<div id="analysis-result"><!-- summary / table / errors --></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="analysis-run">
<i class="bi bi-play-fill me-1"></i>Run analysis
</button>
</div>
</div>
</div>
</div>
<!-- Import spinner — shown while a dropped file is being parsed and staged <!-- Import spinner — shown while a dropped file is being parsed and staged
(Shapefile decompression in particular can take several seconds). (Shapefile decompression in particular can take several seconds).
Hidden once the mapping modal opens or an error occurs. --> Hidden once the mapping modal opens or an error occurs. -->

15
dist/sw.js vendored
View File

@ -55,7 +55,20 @@
// session district changes) + district-keyed boundary cache; GPS read-out // session district changes) + district-keyed boundary cache; GPS read-out
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only // UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
// devices. New hashed bundle + updated index.php shell. // devices. New hashed bundle + updated index.php shell.
const CACHE_VERSION = 'v12'; // v13: Analytical tools Stage 1 — client-side spatial analysis. New "Analyse"
// dock button opens an Analysis panel offering vector overlays
// (intersect / clip / difference / union, via Turf.js) and vector-in-vector
// zonal statistics (count / sum / mean / min / max / area per zone); results
// are added as layers in a new "Analysis" group and work fully offline.
// Analysis can be scoped via "Apply to": whole layer, current map view,
// selected features, or the catch of a drawn Circle/Area. The Circle/Area
// analysis popup now has one "Export" button (PDF folded into the export
// modal as a fourth format) plus an "Analyse" button that opens the panel
// pre-scoped to the intersecting features.
// Raster display gains Cloud-Optimized GeoTIFF support (MapView.addCOGLayer).
// Turf, the analysis panel, geotiff.js and pako are all code-split and
// fetched on first use, so the eager bundle is unchanged.
const CACHE_VERSION = 'v13';
const SHELL_CACHE = `shell-${CACHE_VERSION}`; const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`; const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`; const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -1713,6 +1713,11 @@
<span>✏️</span> <span>✏️</span>
<span class="dock-btn-label">Digitise</span> <span class="dock-btn-label">Digitise</span>
</button> </button>
<button class="dock-btn" type="button" id="dock-btn-analysis"
title="Spatial Analysis — overlays and zonal statistics">
<span>🧮</span>
<span class="dock-btn-label">Analyse</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements"> <button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
<span>🗑️</span> <span>🗑️</span>
<span class="dock-btn-label">Clear</span> <span class="dock-btn-label">Clear</span>
@ -1815,6 +1820,11 @@
<label class="btn btn-outline-primary" for="export-gis-fmt-kml"> <label class="btn btn-outline-primary" for="export-gis-fmt-kml">
KML KML
</label> </label>
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-pdf" value="pdf">
<label class="btn btn-outline-primary" for="export-gis-fmt-pdf">
PDF report
</label>
</div> </div>
<div class="form-text" id="export-gis-format-hint"> <div class="form-text" id="export-gis-format-hint">
GeoJSON keeps all attributes as-is and is the safest default. GeoJSON keeps all attributes as-is and is the safest default.
@ -1830,8 +1840,9 @@
value="area_analysis"> value="area_analysis">
</div> </div>
<!-- Field rename table --> <!-- Field rename table (hidden for the PDF report, which exports the
<div> analysis summary figures rather than feature attributes) -->
<div id="export-gis-fields-wrap">
<label class="form-label fw-bold mb-1">Field names</label> <label class="form-label fw-bold mb-1">Field names</label>
<div class="form-text mb-2"> <div class="form-text mb-2">
Each source attribute on the left; rename it on the right Each source attribute on the left; rename it on the right
@ -1865,6 +1876,113 @@
</div> </div>
</div> </div>
<!-- Analysis panel — Stage 1 client-side spatial analysis (overlays + zonal
statistics). Runs in-browser via Turf.js, so it works offline. Results
are added as a new layer in the "Analysis" group. -->
<div class="modal fade" id="analysisModal" tabindex="-1"
aria-labelledby="analysisModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="analysisModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Spatial Analysis
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Mode switch -->
<div class="btn-group w-100 mb-3" role="group" aria-label="Analysis type">
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-overlay"
value="overlay" checked>
<label class="btn btn-outline-primary" for="analysis-mode-overlay">
<i class="bi bi-intersect me-1"></i>Overlay
</label>
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-zonal"
value="zonal">
<label class="btn btn-outline-primary" for="analysis-mode-zonal">
<i class="bi bi-table me-1"></i>Zonal statistics
</label>
</div>
<!-- Scope: keeps big layers workable by restricting the inputs -->
<div class="mb-3">
<label for="analysis-scope" class="form-label fw-bold">Apply to</label>
<select class="form-select form-select-sm" id="analysis-scope">
<option value="all" selected>Whole layer</option>
<option value="view">Features in current map view</option>
<option value="selection">Selected features only</option>
</select>
<div class="form-text" id="analysis-scope-hint"></div>
</div>
<!-- ---------- Overlay ---------- -->
<div id="analysis-overlay-box">
<div class="mb-3">
<label for="analysis-op" class="form-label fw-bold">Operation</label>
<select class="form-select form-select-sm" id="analysis-op"></select>
<div class="form-text" id="analysis-op-hint"></div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="analysis-layer-a" class="form-label fw-bold">Layer A</label>
<select class="form-select form-select-sm" id="analysis-layer-a"></select>
</div>
<div class="col-md-6" id="analysis-layer-b-wrap">
<label for="analysis-layer-b" class="form-label fw-bold">Layer B</label>
<select class="form-select form-select-sm" id="analysis-layer-b"></select>
</div>
</div>
</div>
<!-- ---------- Zonal statistics ---------- -->
<div id="analysis-zonal-box" class="d-none">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-zone-layer" class="form-label fw-bold">Zone layer</label>
<select class="form-select form-select-sm" id="analysis-zone-layer"></select>
<div class="form-text">Polygons the results are summarised into.</div>
</div>
<div class="col-md-6">
<label for="analysis-input-layer" class="form-label fw-bold">Features to summarise</label>
<select class="form-select form-select-sm" id="analysis-input-layer"></select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-membership" class="form-label fw-bold">Counted when</label>
<select class="form-select form-select-sm" id="analysis-membership"></select>
<div class="form-text" id="analysis-membership-hint"></div>
</div>
<div class="col-md-6">
<label for="analysis-field" class="form-label fw-bold">Numeric field</label>
<select class="form-select form-select-sm" id="analysis-field"></select>
<div class="form-text">Needed for sum, mean, minimum and maximum.</div>
</div>
</div>
<label class="form-label fw-bold d-block mb-1">Statistics</label>
<div id="analysis-stats-box" class="mb-1"><!-- populated at runtime --></div>
</div>
<hr>
<div class="mb-3">
<label for="analysis-output-name" class="form-label fw-bold">Result layer name</label>
<input type="text" class="form-control form-control-sm" id="analysis-output-name">
</div>
<div id="analysis-result"><!-- summary / table / errors --></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="analysis-run">
<i class="bi bi-play-fill me-1"></i>Run analysis
</button>
</div>
</div>
</div>
</div>
<!-- Import spinner — shown while a dropped file is being parsed and staged <!-- Import spinner — shown while a dropped file is being parsed and staged
(Shapefile decompression in particular can take several seconds). (Shapefile decompression in particular can take several seconds).
Hidden once the mapping modal opens or an error occurs. --> Hidden once the mapping modal opens or an error occurs. -->

42
main.js
View File

@ -126,6 +126,9 @@ function hideImportSpinner() {
// GIS export from the analysis popups (Area / Circle) // GIS export from the analysis popups (Area / Circle)
import { openExportGisModal } from './src/export-gis-modal.js'; import { openExportGisModal } from './src/export-gis-modal.js';
// NOTE: the Analysis panel (and the Turf.js geometry library behind it) is
// imported dynamically on first use — see the dock button handler in initUI().
// Turf adds ~70 kB, which field users who never run an analysis shouldn't pay.
// Map instance (global for access across functions) // Map instance (global for access across functions)
let mapView = null; let mapView = null;
@ -663,6 +666,22 @@ function initUI() {
const measureAreaBtn = document.getElementById('dock-btn-measure-area'); const measureAreaBtn = document.getElementById('dock-btn-measure-area');
const drawBtn = document.getElementById('dock-btn-draw'); const drawBtn = document.getElementById('dock-btn-draw');
const clearBtn = document.getElementById('dock-btn-clear'); const clearBtn = document.getElementById('dock-btn-clear');
const analysisBtn = document.getElementById('dock-btn-analysis');
// Spatial Analysis panel — opens a dialog rather than switching map mode, so
// it is deliberately NOT part of modeButtons (it doesn't own map clicks).
if (analysisBtn) {
analysisBtn.addEventListener('click', async () => {
if (!mapView) return;
try {
const { openAnalysisModal } = await import('./src/analysis-modal.js');
openAnalysisModal(mapView);
} catch (err) {
console.error('[App] Could not load the Analysis panel:', err);
showError('Analysis tools could not be loaded — check your connection and try again.');
}
});
}
// Debug: Check if buttons are found // Debug: Check if buttons are found
console.log('[initUI] Buttons found:', { console.log('[initUI] Buttons found:', {
@ -2704,6 +2723,29 @@ window.addEventListener('lupmis:export-gis', (e) => {
openExportGisModal(e.detail || {}); openExportGisModal(e.detail || {});
}); });
// "Analyse" on the Circle/Area analysis popup — open the Analysis panel scoped
// to just the features the drawn shape caught, so overlays and zonal statistics
// run on that area of interest rather than the whole layer.
window.addEventListener('lupmis:analyse-features', async (e) => {
if (!mapView) return;
const ctx = e.detail || {};
const scopeFeatures = [
...(ctx.parcelFeatures || []),
...(ctx.zoneFeatures || []),
...Object.values(ctx.otherByLayer || {}).flat(),
];
try {
const { openAnalysisModal } = await import('./src/analysis-modal.js');
openAnalysisModal(mapView, {
scopeFeatures,
scopeLabel: ctx.title || 'drawn area',
});
} catch (err) {
console.error('[App] Could not load the Analysis panel:', err);
showError('Analysis tools could not be loaded — check your connection and try again.');
}
});
// LayerSwitcher chip click — dispatched as a window CustomEvent by MapView's // LayerSwitcher chip click — dispatched as a window CustomEvent by MapView's
// _decorateLayerListItem. We only act on the 'mapped' state today (upload); // _decorateLayerListItem. We only act on the 'mapped' state today (upload);
// 'failed' will open an error-review modal once the server endpoints exist. // 'failed' will open an error-review modal once the server endpoints exist.

274
package-lock.json generated
View File

@ -9,6 +9,14 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@turf/area": "^7.3.5",
"@turf/boolean-intersects": "^7.3.5",
"@turf/boolean-point-in-polygon": "^7.3.5",
"@turf/centroid": "^7.3.5",
"@turf/difference": "^7.3.5",
"@turf/helpers": "^7.3.5",
"@turf/intersect": "^7.3.5",
"@turf/union": "^7.3.5",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@ -801,6 +809,205 @@
"sqlite-wasm": "bin/index.js" "sqlite-wasm": "bin/index.js"
} }
}, },
"node_modules/@turf/area": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.5.tgz",
"integrity": "sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/boolean-disjoint": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-7.3.5.tgz",
"integrity": "sha512-Pz1GGUC6iL6xGVqhyo+fYg35kl4j/HONMEoC1voN3DcBOCcVlzq+ljvMpvE+oQiR7Q38xLhIxZneLCqMp5YrQA==",
"license": "MIT",
"dependencies": {
"@turf/boolean-point-in-polygon": "7.3.5",
"@turf/helpers": "7.3.5",
"@turf/line-intersect": "7.3.5",
"@turf/meta": "7.3.5",
"@turf/polygon-to-line": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/boolean-intersects": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-7.3.5.tgz",
"integrity": "sha512-Z6GPYjozrmTuzWQD0x7o8RPm+4HC7hz9q23hdB3U1+Qahesv8Mtc+wo82tO4CG6/NRnJ9u79DlEhmR1DxUU4iQ==",
"license": "MIT",
"dependencies": {
"@turf/boolean-disjoint": "7.3.5",
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/boolean-point-in-polygon": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz",
"integrity": "sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/invariant": "7.3.5",
"@types/geojson": "^7946.0.10",
"point-in-polygon-hao": "^1.1.0",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/centroid": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.5.tgz",
"integrity": "sha512-hkWaqwGFdOn6Tf0EWfn2yn1XZ1FWE1h2C5ZWstDMu/FxYO5DB+YjlmOFPl4K6SmSOEgdV07eK2vDCyPeTHqKGA==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/difference": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/difference/-/difference-7.3.5.tgz",
"integrity": "sha512-iDyWYCvgS3vgB8W1ai2cvfJaTq9bOt1QghiVkCNIyccF97CgtYJzenREVVUlp8qU9lJlbQ/ameyKvXPA1uOAlA==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"polyclip-ts": "^0.16.8",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/helpers": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz",
"integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/intersect": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-7.3.5.tgz",
"integrity": "sha512-v11Do9ySbsE08ffiwboQeFKvYByyxzvAz0ls837A9T3rSC+8vKMmK815S+C5v8CBMLNhuBCSgqnOIV3zonNICQ==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"polyclip-ts": "^0.16.8",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/invariant": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.5.tgz",
"integrity": "sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/line-intersect": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-7.3.5.tgz",
"integrity": "sha512-2Cl4oPsjaDdfIwz/5IRDdG2fNdfp3W6atICm81vnzl/GwURoVP+CLjXJ64QWWzpzIbgX2XprJQTmamByDt5MDw==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"sweepline-intersections": "^1.5.0",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/meta": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz",
"integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/polygon-to-line": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-7.3.5.tgz",
"integrity": "sha512-Mat5tvJcW3grpXCNFcMvjHL3d8hO4eoIgF3qYpXj25BHx/S7SJUOgyCV5x3arC0rCfM/cB71VmNDm9k57ec7bw==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/invariant": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/union": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/union/-/union-7.3.5.tgz",
"integrity": "sha512-/FSKhl+LX4+M7L/Trmiln0CDPWS8vCneGnQktt1o5XbCY/zIpH1JdxHEBFXhFZg4beAyXCz0uuxRyW9N/DH+KA==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"polyclip-ts": "^0.16.8",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -808,6 +1015,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/pako": { "node_modules/@types/pako": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@ -856,6 +1069,15 @@
"node": ">= 0.6.0" "node": ">= 0.6.0"
} }
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.8", "version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@ -1275,6 +1497,25 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/point-in-polygon-hao": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz",
"integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/polyclip-ts": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz",
"integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.1.0",
"splaytree-ts": "^1.0.2"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -1392,6 +1633,12 @@
"node": ">= 0.8.15" "node": ">= 0.8.15"
} }
}, },
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.55.3", "version": "4.55.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz",
@ -1469,6 +1716,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/splaytree-ts": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz",
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
"license": "BDS-3-Clause"
},
"node_modules/sqlocal": { "node_modules/sqlocal": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/sqlocal/-/sqlocal-0.16.0.tgz", "resolved": "https://registry.npmjs.org/sqlocal/-/sqlocal-0.16.0.tgz",
@ -1531,6 +1784,15 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/sweepline-intersections": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz",
"integrity": "sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==",
"license": "MIT",
"dependencies": {
"tinyqueue": "^2.0.0"
}
},
"node_modules/text-segmentation": { "node_modules/text-segmentation": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@ -1558,6 +1820,18 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyqueue": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
"license": "ISC"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/utrie": { "node_modules/utrie": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",

View File

@ -10,6 +10,14 @@
}, },
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@turf/area": "^7.3.5",
"@turf/boolean-intersects": "^7.3.5",
"@turf/boolean-point-in-polygon": "^7.3.5",
"@turf/centroid": "^7.3.5",
"@turf/difference": "^7.3.5",
"@turf/helpers": "^7.3.5",
"@turf/intersect": "^7.3.5",
"@turf/union": "^7.3.5",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",

View File

@ -55,7 +55,20 @@
// session district changes) + district-keyed boundary cache; GPS read-out // session district changes) + district-keyed boundary cache; GPS read-out
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only // UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
// devices. New hashed bundle + updated index.php shell. // devices. New hashed bundle + updated index.php shell.
const CACHE_VERSION = 'v12'; // v13: Analytical tools Stage 1 — client-side spatial analysis. New "Analyse"
// dock button opens an Analysis panel offering vector overlays
// (intersect / clip / difference / union, via Turf.js) and vector-in-vector
// zonal statistics (count / sum / mean / min / max / area per zone); results
// are added as layers in a new "Analysis" group and work fully offline.
// Analysis can be scoped via "Apply to": whole layer, current map view,
// selected features, or the catch of a drawn Circle/Area. The Circle/Area
// analysis popup now has one "Export" button (PDF folded into the export
// modal as a fourth format) plus an "Analyse" button that opens the panel
// pre-scoped to the intersecting features.
// Raster display gains Cloud-Optimized GeoTIFF support (MapView.addCOGLayer).
// Turf, the analysis panel, geotiff.js and pako are all code-split and
// fetched on first use, so the eager bundle is unchanged.
const CACHE_VERSION = 'v13';
const SHELL_CACHE = `shell-${CACHE_VERSION}`; const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`; const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`; const API_CACHE = `api-${CACHE_VERSION}`;

349
src/analysis-modal.js Normal file
View File

@ -0,0 +1,349 @@
/**
* Analysis panel controller Stage 1 of the GIS Analytical Tools concept.
*
* openAnalysisModal(mapView)
*
* Drives two families of client-side analysis:
* Overlays intersect / clip / difference / union between layers
* Zonal statistics summarise features (or a numeric field) per zone
*
* Both run entirely in the browser via Turf.js, so they work offline. Results
* are added to the map as a new styled layer in an "Analysis" group, from
* where they can be exported with the existing GIS export tools.
*
* Same Bootstrap modal pattern as src/export-gis-modal.js and src/import-modal.js.
*/
import { Modal } from 'bootstrap';
import { runOverlay, OVERLAY_OPS } from './analysis/overlay.js';
import { zonalStatistics, listNumericFields, STATS, MEMBERSHIP_MODES } from './analysis/zonal.js';
import { showToast } from './toast.js';
const ANALYSIS_GROUP_ID = 9001;
const ANALYSIS_GROUP_TITLE = 'Analysis';
const els = {};
let modal = null;
let mapView = null;
let layerChoices = []; // [{ layer, title, count }]
let drawnScope = null; // { features: Set, label } when opened from a Circle/Area popup
// ---------------------------------------------------------------------------
// Element wiring
// ---------------------------------------------------------------------------
function cacheEls() {
if (els.root) return;
els.root = document.getElementById('analysisModal');
els.mode = Array.from(document.querySelectorAll('input[name="analysis-mode"]'));
els.overlayBox = document.getElementById('analysis-overlay-box');
els.zonalBox = document.getElementById('analysis-zonal-box');
els.op = document.getElementById('analysis-op');
els.opHint = document.getElementById('analysis-op-hint');
els.layerA = document.getElementById('analysis-layer-a');
els.layerB = document.getElementById('analysis-layer-b');
els.layerBWrap = document.getElementById('analysis-layer-b-wrap');
els.zoneLayer = document.getElementById('analysis-zone-layer');
els.inputLayer = document.getElementById('analysis-input-layer');
els.membership = document.getElementById('analysis-membership');
els.memberHint = document.getElementById('analysis-membership-hint');
els.field = document.getElementById('analysis-field');
els.statsBox = document.getElementById('analysis-stats-box');
els.outName = document.getElementById('analysis-output-name');
els.result = document.getElementById('analysis-result');
els.btnRun = document.getElementById('analysis-run');
els.scope = document.getElementById('analysis-scope');
els.scopeHint = document.getElementById('analysis-scope-hint');
if (!els.root.dataset.wired) {
els.root.dataset.wired = '1';
els.mode.forEach((r) => r.addEventListener('change', onModeChange));
els.op.addEventListener('change', onOpChange);
els.membership.addEventListener('change', onMembershipChange);
els.inputLayer.addEventListener('change', refreshFieldOptions);
els.scope.addEventListener('change', onScopeChange);
els.btnRun.addEventListener('click', onRun);
}
}
// ---------------------------------------------------------------------------
// Population
// ---------------------------------------------------------------------------
function layerOptionsHtml(selectedIdx = null) {
return layerChoices.map((c, i) =>
`<option value="${i}"${i === selectedIdx ? ' selected' : ''}>` +
`${escapeHtml(c.title)} (${c.count.toLocaleString()})</option>`
).join('');
}
function populateOps() {
els.op.innerHTML = OVERLAY_OPS
.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('');
els.membership.innerHTML = MEMBERSHIP_MODES
.map((m) => `<option value="${m.key}">${escapeHtml(m.label)}</option>`).join('');
els.statsBox.innerHTML = STATS.map((s) => `
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" value="${s.key}"
id="analysis-stat-${s.key}"${s.key === 'count' ? ' checked' : ''}>
<label class="form-check-label" for="analysis-stat-${s.key}">${escapeHtml(s.label)}</label>
</div>`).join('');
}
function refreshFieldOptions() {
const choice = layerChoices[Number(els.inputLayer.value)];
const feats = choice ? choice.layer.getSource().getFeatures() : [];
const fields = listNumericFields(feats);
els.field.innerHTML = '<option value="">(none — count/area only)</option>' +
fields.map((f) => `<option value="${escapeHtml(f)}">${escapeHtml(f)}</option>`).join('');
els.field.disabled = fields.length === 0;
}
// ---------------------------------------------------------------------------
// UI reactions
// ---------------------------------------------------------------------------
function currentMode() {
return (els.mode.find((r) => r.checked) || {}).value || 'overlay';
}
function onModeChange() {
const overlay = currentMode() === 'overlay';
els.overlayBox.classList.toggle('d-none', !overlay);
els.zonalBox.classList.toggle('d-none', overlay);
suggestName();
}
function onOpChange() {
const op = OVERLAY_OPS.find((o) => o.key === els.op.value);
els.opHint.textContent = op ? op.hint : '';
// Union can run on a single layer; the others need a second one.
const needsB = op ? op.needsB : true;
els.layerBWrap.querySelector('label').textContent =
needsB ? 'Layer B' : 'Layer B (optional)';
suggestName();
}
function onMembershipChange() {
const m = MEMBERSHIP_MODES.find((x) => x.key === els.membership.value);
els.memberHint.textContent = m ? m.hint : '';
}
function suggestName() {
if (els.outName.dataset.touched === '1') return;
if (currentMode() === 'overlay') {
const op = els.op.value || 'overlay';
els.outName.value = `${op}_result`;
} else {
els.outName.value = 'zonal_stats';
}
}
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
/**
* Features of the chosen layer, narrowed by the "Apply to" scope.
* 'view' uses the source's spatial index, so it stays fast on large layers.
*/
function featuresOf(select) {
const choice = layerChoices[Number(select.value)];
if (!choice) return [];
const source = choice.layer.getSource();
const scope = els.scope ? els.scope.value : 'all';
if (scope === 'view') {
const map = mapView.getMap();
const extent = map.getView().calculateExtent(map.getSize());
return source.getFeaturesInExtent(extent);
}
if (scope === 'selection') {
const selected = new Set(mapView.getSelectedFeatures());
return source.getFeatures().filter((f) => selected.has(f));
}
if (scope === 'drawn' && drawnScope) {
return source.getFeatures().filter((f) => drawnScope.features.has(f));
}
return source.getFeatures();
}
function onScopeChange() {
const scope = els.scope.value;
if (scope === 'drawn' && drawnScope) {
els.scopeHint.textContent =
`${drawnScope.features.size.toLocaleString()} feature(s) caught by the ${drawnScope.label}.`;
} else if (scope === 'selection') {
const n = mapView ? mapView.getSelectedFeatures().length : 0;
els.scopeHint.textContent = n === 0
? 'Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.'
: `${n.toLocaleString()} feature(s) currently selected.`;
} else if (scope === 'view') {
els.scopeHint.textContent = 'Only features inside the current map view are analysed — zoom to the area of interest first.';
} else {
els.scopeHint.textContent = 'Every feature in the chosen layer. Narrow this if a large layer is slow.';
}
}
function ensureAnalysisGroup() {
let group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);
if (!group) {
mapView.addLayerGroup(ANALYSIS_GROUP_ID, ANALYSIS_GROUP_TITLE,
'Results of client-side spatial analysis');
group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);
}
return group;
}
function onRun() {
els.result.innerHTML = '';
els.btnRun.disabled = true;
// Yield a frame so the button state paints before a heavy synchronous run.
setTimeout(() => {
try {
const mode = currentMode();
const name = (els.outName.value || '').trim() ||
(mode === 'overlay' ? 'overlay_result' : 'zonal_stats');
let geojson, summary;
if (mode === 'overlay') {
const op = els.op.value;
const a = featuresOf(els.layerA);
const b = featuresOf(els.layerB);
const needsB = (OVERLAY_OPS.find((o) => o.key === op) || {}).needsB;
if (a.length === 0) throw new Error('Layer A has no features.');
if (needsB && b.length === 0) throw new Error('This operation needs a second layer with features.');
geojson = runOverlay(op, a, b);
summary = `${geojson.features.length.toLocaleString()} feature(s) produced.`;
} else {
const zones = featuresOf(els.zoneLayer);
const inputs = featuresOf(els.inputLayer);
const stats = Array.from(els.statsBox.querySelectorAll('input:checked')).map((i) => i.value);
const field = els.field.value || null;
if (stats.length === 0) throw new Error('Select at least one statistic.');
const needsField = stats.some((s) => (STATS.find((x) => x.key === s) || {}).needsField);
if (needsField && !field) throw new Error('Sum / mean / min / max need a numeric field.');
const res = zonalStatistics({
zoneFeatures: zones, inputFeatures: inputs,
stats, field, membership: els.membership.value,
});
geojson = res.geojson;
summary = renderRows(res.rows);
}
if (!geojson.features.length) {
els.result.innerHTML =
`<div class="alert alert-warning py-2 px-3 mb-0">No result features — the layers may not overlap.</div>`;
return;
}
const group = ensureAnalysisGroup();
mapView.addGeoJSONLayer(geojson, name, {
strokeColor: '#d97706', strokeWidth: 2, fillColor: 'rgba(217,119,6,0.18)',
}, group);
els.result.innerHTML =
`<div class="alert alert-success py-2 px-3 mb-2">Added layer “${escapeHtml(name)}”.</div>` +
(typeof summary === 'string' && summary.startsWith('<') ? summary : `<div class="small text-muted">${escapeHtml(summary)}</div>`);
showToast(`Analysis complete — “${name}” added to the Analysis group.`, 'success', 4000);
} catch (err) {
console.error('[Analysis] run failed:', err);
els.result.innerHTML =
`<div class="alert alert-danger py-2 px-3 mb-0">${escapeHtml(err.message || String(err))}</div>`;
} finally {
els.btnRun.disabled = false;
}
}, 20);
}
function renderRows(rows) {
if (!rows || !rows.length) return '';
const keys = Object.keys(rows[0]).filter((k) => k !== 'zone');
const head = ['Zone', ...keys.map((k) => k.replace(/^zs_/, ''))]
.map((h) => `<th class="text-nowrap">${escapeHtml(h)}</th>`).join('');
const body = rows.map((r) =>
`<tr><td class="text-nowrap">${escapeHtml(String(r.zone))}</td>` +
keys.map((k) => `<td>${r[k] === null || r[k] === undefined ? '—' : escapeHtml(String(r[k]))}</td>`).join('') +
'</tr>').join('');
return `<div class="table-responsive" style="max-height:220px;overflow:auto;">
<table class="table table-sm table-striped mb-0" style="font-size:0.85rem;">
<thead class="table-light" style="position:sticky;top:0;"><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table></div>`;
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/**
* Open the Analysis panel.
* @param {import('./components/MapView.js').default} view
*/
export function openAnalysisModal(view, opts = {}) {
mapView = view;
cacheEls();
// When launched from a Circle/Area analysis popup, offer that catch as a
// scope and pre-select it — the whole point of starting from a drawn shape.
drawnScope = (opts.scopeFeatures && opts.scopeFeatures.length)
? { features: new Set(opts.scopeFeatures), label: opts.scopeLabel || 'drawn area' }
: null;
syncDrawnScopeOption();
layerChoices = mapView.listVectorLayers({ nonEmptyOnly: true });
if (layerChoices.length === 0) {
showToast('No vector layers with features are loaded — nothing to analyse yet.', 'warning', 5000);
return;
}
populateOps();
const optsA = layerOptionsHtml(0);
const optsB = layerOptionsHtml(layerChoices.length > 1 ? 1 : 0);
els.layerA.innerHTML = optsA;
els.layerB.innerHTML = optsB;
els.zoneLayer.innerHTML = optsA;
els.inputLayer.innerHTML = optsB;
els.outName.dataset.touched = '';
els.outName.addEventListener('input', () => { els.outName.dataset.touched = '1'; }, { once: true });
els.result.innerHTML = '';
els.scope.value = drawnScope ? 'drawn' : 'all';
refreshFieldOptions();
onOpChange();
onMembershipChange();
onScopeChange();
onModeChange();
modal = modal || new Modal(els.root);
modal.show();
}
/**
* Add or remove the "From drawn area" scope option depending on whether the
* panel was opened from a Circle/Area popup.
* @private
*/
function syncDrawnScopeOption() {
const existing = els.scope.querySelector('option[value="drawn"]');
if (!drawnScope) {
if (existing) existing.remove();
return;
}
const label = `From ${drawnScope.label} (${drawnScope.features.size.toLocaleString()} features)`;
if (existing) {
existing.textContent = label;
} else {
const opt = document.createElement('option');
opt.value = 'drawn';
opt.textContent = label;
els.scope.insertBefore(opt, els.scope.firstChild);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

263
src/analysis/overlay.js Normal file
View File

@ -0,0 +1,263 @@
/**
* overlay.js client-side vector overlay operations (Stage 1 of the
* LUPMIS2 GIS Analytical Tools concept).
*
* Pure geometry helpers built on Turf.js. No DOM, no map the caller hands
* in OpenLayers features and gets a GeoJSON FeatureCollection back, ready for
* MapView.addGeoJSONLayer().
*
* All Turf work happens in EPSG:4326 (Turf's area/length functions assume
* WGS84), so features are reprojected out of the map's EPSG:3857 on the way
* in and the result is handed back as WGS84 GeoJSON the same convention
* gis-export.js uses.
*
* intersectFeatures(a, b) parts of A that fall inside B (attrs: A + B)
* clipFeatures(a, b) parts of A that fall inside B (attrs: A only)
* differenceFeatures(a, b) parts of A that fall OUTSIDE B (attrs: A)
* unionFeatures(a[, b]) dissolved outline of A (and B)
*/
import GeoJSON from 'ol/format/GeoJSON';
import intersect from '@turf/intersect';
import union from '@turf/union';
import difference from '@turf/difference';
import { featureCollection } from '@turf/helpers';
/**
* Guard: only *candidate* pairs (those whose bounding boxes actually overlap)
* reach the expensive geometry test, so this ceiling is about real work, not
* the raw n×m product. District-scale data (tens of thousands of parcels
* against a few dozen zones) sits far below it.
*/
export const MAX_OVERLAY_CANDIDATES = 400000;
const fmt = new GeoJSON();
/**
* Convert OpenLayers features (map projection) to WGS84 GeoJSON features.
* @param {Array<import('ol/Feature').default>} features
* @param {string} [mapProjection='EPSG:3857']
* @returns {Array<Object>} GeoJSON Features
*/
export function toGeoJSONFeatures(features, mapProjection = 'EPSG:3857') {
const out = [];
for (const f of features || []) {
const geom = f.getGeometry && f.getGeometry();
if (!geom) continue;
const gj = fmt.writeFeatureObject(f, {
dataProjection: 'EPSG:4326',
featureProjection: mapProjection,
});
if (gj && gj.geometry) out.push(gj);
}
return out;
}
/**
* Bounding box of a GeoJSON feature as [minX, minY, maxX, maxY].
* Walks the coordinate arrays directly no extra dependency, and fast enough
* to run over tens of thousands of features.
* @param {Object} feature GeoJSON Feature
* @returns {number[]|null}
*/
export function bboxOf(feature) {
const geom = feature && feature.geometry;
if (!geom || !geom.coordinates) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const walk = (c) => {
if (typeof c[0] === 'number') {
if (c[0] < minX) minX = c[0];
if (c[0] > maxX) maxX = c[0];
if (c[1] < minY) minY = c[1];
if (c[1] > maxY) maxY = c[1];
return;
}
for (const sub of c) walk(sub);
};
walk(geom.coordinates);
return Number.isFinite(minX) ? [minX, minY, maxX, maxY] : null;
}
/** Do two bounding boxes overlap? Cheap pre-filter before real geometry work. */
export function bboxOverlaps(a, b) {
if (!a || !b) return false;
return !(a[2] < b[0] || b[2] < a[0] || a[3] < b[1] || b[3] < a[1]);
}
/** Is a point [x, y] inside a bounding box? */
export function bboxContainsPoint(box, x, y) {
if (!box) return false;
return x >= box[0] && x <= box[2] && y >= box[1] && y <= box[3];
}
/** Keep only polygonal features — overlay is undefined for points/lines here. */
function polygonsOnly(gjFeatures) {
return (gjFeatures || []).filter((f) => {
const t = f?.geometry?.type;
return t === 'Polygon' || t === 'MultiPolygon';
});
}
/** Merge two property bags, prefixing B's keys on collision so nothing is lost. */
function mergeProps(propsA, propsB, prefixB = 'b_') {
const out = { ...(propsA || {}) };
for (const [k, v] of Object.entries(propsB || {})) {
out[Object.prototype.hasOwnProperty.call(out, k) ? `${prefixB}${k}` : k] = v;
}
return out;
}
function fc(features) {
return { type: 'FeatureCollection', features };
}
/**
* Intersection: every piece of A that also lies inside B.
* Result carries A's attributes merged with the matching B feature's.
*
* @param {Array} aFeatures OL features (layer A)
* @param {Array} bFeatures OL features (layer B)
* @param {Object} [opts]
* @param {boolean} [opts.keepBAttributes=true] merge B's properties into the result
* @returns {Object} GeoJSON FeatureCollection (WGS84)
*/
export function intersectFeatures(aFeatures, bFeatures, opts = {}) {
const { keepBAttributes = true } = opts;
const A = polygonsOnly(toGeoJSONFeatures(aFeatures));
const B = polygonsOnly(toGeoJSONFeatures(bFeatures));
// Pre-compute B's boxes once, then only test pairs that can possibly meet.
const bBoxes = B.map(bboxOf);
let candidates = 0;
const out = [];
for (const a of A) {
const aBox = bboxOf(a);
for (let i = 0; i < B.length; i++) {
if (!bboxOverlaps(aBox, bBoxes[i])) continue;
if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);
let piece = null;
try { piece = intersect(featureCollection([a, B[i]])); } catch { piece = null; }
if (!piece || !piece.geometry) continue;
piece.properties = keepBAttributes
? mergeProps(a.properties, B[i].properties)
: { ...(a.properties || {}) };
out.push(piece);
}
}
return fc(out);
}
/**
* Clip: same geometry result as intersect, but the output keeps ONLY layer A's
* attributes the usual "cut this layer to that boundary" operation.
*/
export function clipFeatures(aFeatures, clipFeatures_, opts = {}) {
return intersectFeatures(aFeatures, clipFeatures_, { ...opts, keepBAttributes: false });
}
/**
* Difference: the parts of A that do NOT fall inside any feature of B.
* Each A feature is reduced by every overlapping B feature in turn; features
* fully covered by B drop out of the result.
*
* @returns {Object} GeoJSON FeatureCollection (WGS84)
*/
export function differenceFeatures(aFeatures, bFeatures) {
const A = polygonsOnly(toGeoJSONFeatures(aFeatures));
const B = polygonsOnly(toGeoJSONFeatures(bFeatures));
const bBoxes = B.map(bboxOf);
let candidates = 0;
const out = [];
for (const a of A) {
const aBox = bboxOf(a);
let remainder = a;
for (let i = 0; i < B.length; i++) {
if (!remainder) break;
// Only subtract a B feature that can actually touch what's left.
if (!bboxOverlaps(aBox, bBoxes[i])) continue;
if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);
try {
const next = difference(featureCollection([remainder, B[i]]));
remainder = next && next.geometry ? next : null;
} catch {
// A malformed pair shouldn't kill the whole run — keep what we have.
}
}
if (remainder && remainder.geometry) {
remainder.properties = { ...(a.properties || {}) };
out.push(remainder);
}
}
return fc(out);
}
/**
* Union / dissolve: merge everything into a single outline. Pass only `a` to
* dissolve one layer, or both to merge two layers together.
*
* @returns {Object} GeoJSON FeatureCollection with 0 or 1 feature (WGS84)
*/
export function unionFeatures(aFeatures, bFeatures = []) {
const all = polygonsOnly([
...toGeoJSONFeatures(aFeatures),
...toGeoJSONFeatures(bFeatures),
]);
if (all.length === 0) return fc([]);
if (all.length === 1) return fc([all[0]]);
let merged = null;
try {
merged = union(featureCollection(all));
} catch {
// Fall back to a pairwise fold if the bulk union chokes on bad geometry.
merged = all[0];
for (let i = 1; i < all.length; i++) {
try {
const next = union(featureCollection([merged, all[i]]));
if (next && next.geometry) merged = next;
} catch { /* skip the offending feature */ }
}
}
if (!merged || !merged.geometry) return fc([]);
merged.properties = { parts: all.length };
return fc([merged]);
}
/** @private Raised only when genuinely overlapping work exceeds the budget. */
function candidateOverflow(count) {
return new Error(
`This overlay has more than ${count.toLocaleString()} genuinely overlapping feature pairs, ` +
`which is too much to run in the browser. Narrow the inputs using “Apply to” ` +
`(current map view or selected features), or run it server-side.`
);
}
/** Operation registry used by the Analysis panel. */
export const OVERLAY_OPS = [
{ key: 'intersect', label: 'Intersect', needsB: true,
hint: 'Keep the parts of layer A that fall inside layer B (attributes from both).' },
{ key: 'clip', label: 'Clip', needsB: true,
hint: 'Cut layer A to the boundary of layer B (keeps only As attributes).' },
{ key: 'difference', label: 'Difference', needsB: true,
hint: 'Keep the parts of layer A that fall outside layer B.' },
{ key: 'union', label: 'Union / Dissolve', needsB: false,
hint: 'Merge features into a single outline (optionally with a second layer).' },
];
/**
* Run an overlay operation by key.
* @param {string} op one of OVERLAY_OPS keys
* @param {Array} aFeatures
* @param {Array} bFeatures
* @returns {Object} GeoJSON FeatureCollection (WGS84)
*/
export function runOverlay(op, aFeatures, bFeatures) {
switch (op) {
case 'intersect': return intersectFeatures(aFeatures, bFeatures);
case 'clip': return clipFeatures(aFeatures, bFeatures);
case 'difference': return differenceFeatures(aFeatures, bFeatures);
case 'union': return unionFeatures(aFeatures, bFeatures);
default: throw new Error(`Unknown overlay operation: ${op}`);
}
}

199
src/analysis/zonal.js Normal file
View File

@ -0,0 +1,199 @@
/**
* zonal.js vector-in-vector zonal statistics (Stage 1 of the LUPMIS2 GIS
* Analytical Tools concept).
*
* Answers questions like "how many parcels are in each collector zone?" or
* "what is the mean plot size per zone?" the classic *summarize within*
* operation. Raster × vector zonal statistics arrives with the raster pipeline
* in Stage 2; this module is pure vector and runs fully offline.
*
* Membership rules:
* 'centroid' a feature belongs to the zone containing its centroid
* (default; each feature counts exactly once)
* 'intersects' a feature belongs to every zone it touches
* (features straddling a boundary are counted more than once)
*/
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import booleanIntersects from '@turf/boolean-intersects';
import centroid from '@turf/centroid';
import area from '@turf/area';
import { toGeoJSONFeatures, bboxOf, bboxOverlaps, bboxContainsPoint } from './overlay.js';
/**
* Guard on *candidate* pairs only a bounding-box pre-filter means a feature
* is geometry-tested against a zone only when it could plausibly fall in it.
* A district of 25,000 parcels across 40-odd zones produces roughly one
* candidate per parcel, so real workloads sit far below this ceiling.
*/
export const MAX_ZONAL_CANDIDATES = 400000;
export const MEMBERSHIP_MODES = [
{ key: 'centroid', label: 'Centroid inside zone',
hint: 'Each feature is counted once, in the zone containing its centre.' },
{ key: 'intersects', label: 'Any overlap with zone',
hint: 'A feature is counted in every zone it touches (may double-count).' },
];
/** Statistics offered by the Analysis panel. */
export const STATS = [
{ key: 'count', label: 'Count', needsField: false },
{ key: 'sum', label: 'Sum', needsField: true },
{ key: 'mean', label: 'Mean', needsField: true },
{ key: 'min', label: 'Minimum', needsField: true },
{ key: 'max', label: 'Maximum', needsField: true },
{ key: 'area', label: 'Total area (m²)', needsField: false },
];
/**
* List the numeric attribute names available across a set of OL features, so
* the panel can offer only fields that can actually be summed or averaged.
* @param {Array} features OL features
* @returns {string[]}
*/
export function listNumericFields(features) {
const counts = new Map();
for (const f of features || []) {
const props = f.getProperties ? f.getProperties() : {};
for (const [k, v] of Object.entries(props)) {
if (k === 'geometry' || k.startsWith('_')) continue;
const n = typeof v === 'number' ? v : (v !== '' && v != null && !Number.isNaN(Number(v)) ? Number(v) : null);
if (n === null) continue;
counts.set(k, (counts.get(k) || 0) + 1);
}
}
return Array.from(counts.keys()).sort();
}
/** Coerce a property to a finite number, or null when it isn't numeric. */
function num(value) {
if (value === null || value === undefined || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* Compute zonal statistics.
*
* @param {Object} params
* @param {Array} params.zoneFeatures OL features defining the zones (polygons)
* @param {Array} params.inputFeatures OL features to summarise
* @param {string[]} params.stats keys from STATS
* @param {string} [params.field] numeric attribute (for sum/mean/min/max)
* @param {string} [params.membership] 'centroid' | 'intersects'
* @param {string} [params.prefix='zs_'] prefix for the new attribute names
* @returns {{ geojson: Object, rows: Array<Object> }}
* geojson the zones with statistics attached (WGS84), ready to add as a layer
* rows a plain table of the same numbers, for display/export
*/
export function zonalStatistics({
zoneFeatures,
inputFeatures,
stats = ['count'],
field = null,
membership = 'centroid',
prefix = 'zs_',
}) {
const zones = (toGeoJSONFeatures(zoneFeatures) || []).filter((z) => {
const t = z?.geometry?.type;
return t === 'Polygon' || t === 'MultiPolygon';
});
const inputs = toGeoJSONFeatures(inputFeatures) || [];
if (zones.length === 0) throw new Error('The zone layer contains no polygons.');
// Pre-compute once: each input's centroid (centroid mode) or bbox
// (intersects mode), and every zone's bbox. The bbox comparisons below are
// cheap arithmetic; only survivors reach the real geometry test.
const centroids = membership === 'centroid'
? inputs.map((f) => { try { return centroid(f); } catch { return null; } })
: null;
const inputBoxes = membership === 'centroid' ? null : inputs.map(bboxOf);
const zoneBoxes = zones.map(bboxOf);
let candidates = 0;
const rows = [];
for (let z = 0; z < zones.length; z++) {
const zone = zones[z];
const zBox = zoneBoxes[z];
const members = [];
for (let i = 0; i < inputs.length; i++) {
let inside = false;
try {
if (membership === 'centroid') {
const c = centroids[i];
if (!c) continue;
const [cx, cy] = c.geometry.coordinates;
if (!bboxContainsPoint(zBox, cx, cy)) continue; // cheap reject
if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);
inside = booleanPointInPolygon(c, zone);
} else {
if (!bboxOverlaps(zBox, inputBoxes[i])) continue; // cheap reject
if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);
inside = booleanIntersects(inputs[i], zone);
}
} catch { inside = false; }
if (inside) members.push(inputs[i]);
}
const values = field
? members.map((f) => num(f.properties ? f.properties[field] : null)).filter((v) => v !== null)
: [];
const result = {};
for (const s of stats) {
switch (s) {
case 'count':
result[`${prefix}count`] = members.length; break;
case 'sum':
result[`${prefix}sum`] = values.length ? round(values.reduce((a, b) => a + b, 0)) : 0; break;
case 'mean':
result[`${prefix}mean`] = values.length
? round(values.reduce((a, b) => a + b, 0) / values.length) : null; break;
case 'min':
result[`${prefix}min`] = values.length ? round(Math.min(...values)) : null; break;
case 'max':
result[`${prefix}max`] = values.length ? round(Math.max(...values)) : null; break;
case 'area': {
let total = 0;
for (const m of members) { try { total += area(m); } catch { /* skip */ } }
result[`${prefix}area_m2`] = Math.round(total); break;
}
default: break;
}
}
zone.properties = { ...(zone.properties || {}), ...result };
rows.push({ ...labelOf(zone), ...result });
}
return { geojson: { type: 'FeatureCollection', features: zones }, rows };
}
/** @private Raised only when genuinely plausible pairs exceed the budget. */
function candidateOverflow(count) {
return new Error(
`More than ${count.toLocaleString()} features fall within the zones' bounding boxes, ` +
`which is too much to summarise in the browser. Narrow the inputs using “Apply to” ` +
`(current map view or selected features).`
);
}
/** Round to 3 decimals to keep attribute tables readable. */
function round(n) {
return Math.round(n * 1000) / 1000;
}
/**
* Pick a human label for a zone row first of the usual naming fields,
* else a generic index.
*/
function labelOf(zone) {
const p = zone.properties || {};
for (const k of ['zone_name', 'name', 'ZONE_NAME', 'NAME', 'upn', 'UPN', 'id', 'ID']) {
if (p[k] !== undefined && p[k] !== null && String(p[k]).trim() !== '') {
return { zone: String(p[k]) };
}
}
return { zone: '(unnamed)' };
}

View File

@ -27,6 +27,10 @@ import VectorImageLayer from 'ol/layer/VectorImage';
import VectorSource from 'ol/source/Vector'; import VectorSource from 'ol/source/Vector';
import ImageWMS from 'ol/source/ImageWMS'; import ImageWMS from 'ol/source/ImageWMS';
import TileWMS from 'ol/source/TileWMS'; import TileWMS from 'ol/source/TileWMS';
// NOTE: Cloud-Optimized GeoTIFF support (ol/source/GeoTIFF + ol/layer/WebGLTile)
// is imported lazily inside addCOGLayer(). The underlying geotiff.js library adds
// ~100 kB to the bundle, which shouldn't be paid by field users who never open a
// raster on a metered connection.
import OSM from 'ol/source/OSM'; import OSM from 'ol/source/OSM';
import XYZ from 'ol/source/XYZ'; import XYZ from 'ol/source/XYZ';
import { fromLonLat, toLonLat } from 'ol/proj'; import { fromLonLat, toLonLat } from 'ol/proj';
@ -64,7 +68,8 @@ import SnapGuides from 'ol-ext/interaction/SnapGuides';
// ol Select interaction (for custom multi-layer Select) // ol Select interaction (for custom multi-layer Select)
import Select from 'ol/interaction/Select'; import Select from 'ol/interaction/Select';
import { click as clickCondition } from 'ol/events/condition'; import DragBox from 'ol/interaction/DragBox';
import { click as clickCondition, platformModifierKeyOnly, shiftKeyOnly } from 'ol/events/condition';
// ol-ext Split interaction (for line splitting) and Toggle control // ol-ext Split interaction (for line splitting) and Toggle control
import Split from 'ol-ext/interaction/Split'; import Split from 'ol-ext/interaction/Split';
@ -381,14 +386,41 @@ export class MapView {
// 2. Create a Select interaction that works on ALL vector layers. // 2. Create a Select interaction that works on ALL vector layers.
// It starts INACTIVE so it doesn't steal clicks from normal handlers. // It starts INACTIVE so it doesn't steal clicks from normal handlers.
// Multi-select: a plain click selects a single feature (replacing the
// selection); Shift-click toggles a feature in/out of the selection so
// the user can build up a set to delete or move together. Shift-click is
// the ol default toggleCondition, but we set it explicitly for clarity.
this._selectInteraction = new Select({ this._selectInteraction = new Select({
condition: clickCondition, condition: clickCondition,
toggleCondition: shiftKeyOnly,
filter: (feature, layer) => !!layer, filter: (feature, layer) => !!layer,
layers: (layer) => layer instanceof VectorLayer, layers: (layer) => layer instanceof VectorLayer,
}); });
this._selectInteraction.setActive(false); this._selectInteraction.setActive(false);
this.map.addInteraction(this._selectInteraction); this.map.addInteraction(this._selectInteraction);
// 2b. Box-select — Ctrl/Cmd + drag draws a rectangle and adds every
// intersecting feature to the selection. This is the discoverable
// "select multiple" gesture (Shift-click also works, one at a time).
// Plain drag still pans the map; Shift-drag is left free. The box is
// only live while the Select tool itself is active (see the
// change:active wiring below) so it never interferes with drawing.
this._dragBoxSelect = new DragBox({ condition: platformModifierKeyOnly });
this._dragBoxSelect.setActive(false);
this.map.addInteraction(this._dragBoxSelect);
this._dragBoxSelect.on('boxend', () => {
const extent = this._dragBoxSelect.getGeometry().getExtent();
const selected = this._selectInteraction.getFeatures();
// Walk every visible vector layer the Select interaction would accept
// and add features whose geometry intersects the box.
this.map.getLayers().forEach((layer) => this._boxAddFromLayer(layer, extent, selected));
this._refreshVertexOverlay?.();
});
// Keep box-select armed only while the Select sub-tool is active.
this._selectInteraction.on('change:active', () => {
this._dragBoxSelect.setActive(this._selectInteraction.getActive());
});
// 3. Create a ModifyFeature interaction bound to the selection. // 3. Create a ModifyFeature interaction bound to the selection.
// Also starts inactive. // Also starts inactive.
this._modifyInteraction = new ModifyFeature({ this._modifyInteraction = new ModifyFeature({
@ -718,9 +750,70 @@ export class MapView {
this.showDrawnPolygonPopup(feature, coordinate); this.showDrawnPolygonPopup(feature, coordinate);
}); });
// 10. Keyboard Delete — pressing Delete or Backspace while edit mode is
// active deletes the currently-selected feature(s), mirroring the
// EditBar's Delete button (same undoable deletestart/deleteend block).
// Guarded so it never fires while the user is typing in a form field.
this._editKeyHandler = (e) => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (!this._editBarActive) return;
const t = e.target;
const tag = t && t.tagName;
if (t?.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (this._deleteSelectedFeatures()) {
e.preventDefault();
}
};
document.addEventListener('keydown', this._editKeyHandler);
console.log('[MapView] EditBar initialised with Drawings group, UndoRedo and SnapGuides (default:', this._snapGuidesEnabled ? 'ON' : 'OFF', ')'); console.log('[MapView] EditBar initialised with Drawings group, UndoRedo and SnapGuides (default:', this._snapGuidesEnabled ? 'ON' : 'OFF', ')');
} }
/**
* Delete the currently-selected feature(s) via the EditBar's Delete
* interaction, so the removal is recorded as a single undoable block
* (deletestart/deleteend) exactly like the Delete button. Returns true if
* anything was deleted.
* @returns {boolean}
*/
_deleteSelectedFeatures() {
if (!this._editBarActive) return false;
const del = this.editBar?.getInteraction('Delete');
const features = this._selectInteraction?.getFeatures();
if (!del || !features || features.getLength() === 0) return false;
del.delete(features); // removes from all map sources; undoable
features.clear(); // drop the now-deleted features from selection
this._refreshVertexOverlay?.();
return true;
}
/**
* Box-select helper: add every feature of a (visible, Select-eligible) vector
* layer whose geometry intersects `extent` to the `selected` collection,
* skipping duplicates. Recurses into LayerGroups.
* @private
*/
_boxAddFromLayer(layer, extent, selected) {
if (!layer) return;
if (typeof layer.getLayers === 'function') {
layer.getLayers().forEach((l) => this._boxAddFromLayer(l, extent, selected));
return;
}
if (!(layer instanceof VectorLayer)) return;
if (layer.getVisible && !layer.getVisible()) return;
// Skip non-selectable helper layers: the vertex-edit overlay, GPS trail /
// position, and anything explicitly hidden from the layer switcher.
if (layer === this._vertexOverlayLayer ||
layer === this._gpsTrailLayer ||
layer === this._gpsPositionLayer ||
layer.get('displayInLayerSwitcher') === false) return;
const source = layer.getSource && layer.getSource();
if (!source || typeof source.forEachFeatureIntersectingExtent !== 'function') return;
source.forEachFeatureIntersectingExtent(extent, (feature) => {
if (!selected.getArray().includes(feature)) selected.push(feature);
});
}
/** /**
* Dispatch a custom edit event (e.g. 'save'). * Dispatch a custom edit event (e.g. 'save').
* External code can listen via mapView.onEditEvent('save', callback). * External code can listen via mapView.onEditEvent('save', callback).
@ -756,8 +849,25 @@ export class MapView {
* @param {boolean} active * @param {boolean} active
*/ */
setEditMode(active) { setEditMode(active) {
const wasActive = this._editBarActive;
this._editBarActive = !!active; this._editBarActive = !!active;
// On entering edit mode, (1) re-scan vector sources so UndoRedo watches
// every layer loaded since the EditBar was built — parcels, zones and
// imports are pushed into sub-groups AFTER init, and ol-ext's UndoRedo only
// auto-watches TOP-LEVEL map layer changes, so those sources would
// otherwise be invisible to undo (a delete records an empty block and undo
// does nothing); and (2) reset the undo history so it covers only this
// editing session and can't reach back into data-loading operations.
if (this._editBarActive && !wasActive && this._undoRedo) {
try {
this._undoRedo._watchSources();
this._undoRedo.clear();
} catch (err) {
console.warn('[MapView] UndoRedo re-watch/clear failed:', err);
}
}
if (this.editBar) { if (this.editBar) {
this.editBar.setVisible(this._editBarActive); this.editBar.setVisible(this._editBarActive);
@ -790,9 +900,10 @@ export class MapView {
this.touchCursor.setActive(this._editBarActive); this.touchCursor.setActive(this._editBarActive);
} }
// Clear persistent vertex highlights when leaving edit mode // Clear persistent vertex highlights when leaving edit mode (excluded
// from the undo stack — see _withoutUndoRecording / _silentClearSource).
if (!this._editBarActive && this._vertexOverlaySource) { if (!this._editBarActive && this._vertexOverlaySource) {
this._vertexOverlaySource.clear(); this._withoutUndoRecording(() => this._silentClearSource(this._vertexOverlaySource));
} }
console.log('[MapView] Edit mode:', this._editBarActive ? 'ON' : 'OFF'); console.log('[MapView] Edit mode:', this._editBarActive ? 'ON' : 'OFF');
@ -927,13 +1038,55 @@ export class MapView {
this._selectInteraction.on('select', () => this._refreshVertexOverlay()); this._selectInteraction.on('select', () => this._refreshVertexOverlay());
} }
/**
* Run `fn` with the UndoRedo interaction's recording temporarily paused, so
* the vector-source mutations it performs are NOT pushed onto the undo stack.
*
* The vertex-overlay layer is a plain VectorLayer, and ol-ext's UndoRedo
* watches every vector source in the map so without this, each selection's
* vertex-dot add/clear would land on the undo stack and undo would reverse
* those instead of the user's actual edit (the "first undo does nothing,
* second shows vertices" symptom). Our overlay source is a plain ol
* VectorSource (it never emits ol-ext's clearstart/clearend block events),
* so gating `_record` is sufficient to fully exclude it.
* @private
*/
_withoutUndoRecording(fn) {
const ur = this._undoRedo;
if (!ur) return fn();
const prev = ur._record;
ur._record = false;
try { return fn(); }
finally { ur._record = prev; }
}
/**
* Clear a vector source WITHOUT firing the clearstart/clearend events that
* ol-ext patches onto ol/source/Vector.prototype.clear. Those events drive
* UndoRedo's blockStart/blockEnd, which are NOT gated by the record flag so
* a plain `source.clear()` on a helper layer pushes a stray (empty) block
* onto the undo stack even inside _withoutUndoRecording. Removing features
* one by one only fires `removefeature`, which IS gated by the record flag,
* so wrapped in _withoutUndoRecording this leaves the undo stack untouched.
* @private
*/
_silentClearSource(source) {
if (!source) return;
const feats = source.getFeatures();
for (let i = feats.length - 1; i >= 0; i--) {
source.removeFeature(feats[i]);
}
}
/** /**
* Rebuild the vertex overlay from the current Select interaction's features. * Rebuild the vertex overlay from the current Select interaction's features.
* No-ops when not in edit mode. * No-ops when not in edit mode.
*/ */
_refreshVertexOverlay() { _refreshVertexOverlay() {
if (!this._vertexOverlaySource) return; if (!this._vertexOverlaySource) return;
this._vertexOverlaySource.clear(); // All overlay-source mutations are excluded from the undo stack.
this._withoutUndoRecording(() => {
this._silentClearSource(this._vertexOverlaySource);
// Detach change listeners from previously-tracked features // Detach change listeners from previously-tracked features
if (this._vertexTrackedFeatures) { if (this._vertexTrackedFeatures) {
@ -961,6 +1114,7 @@ export class MapView {
feat.on('change', this._onSelectedFeatureGeomChange); feat.on('change', this._onSelectedFeatureGeomChange);
this._vertexTrackedFeatures.add(feat); this._vertexTrackedFeatures.add(feat);
} }
});
} }
/** /**
@ -1400,13 +1554,13 @@ export class MapView {
</table> </table>
</div> </div>
<div style="padding:2px 8px 8px;display:flex;justify-content:flex-end;gap:6px;flex-shrink:0;border-top:1px solid var(--border, #1e1a4b1f);"> <div style="padding:2px 8px 8px;display:flex;justify-content:flex-end;gap:6px;flex-shrink:0;border-top:1px solid var(--border, #1e1a4b1f);">
<button id="info-popup-analyse"
style="background:#fff;color:var(--brand-navy,#1e1a4b);border:1px solid var(--brand-navy,#1e1a4b);border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
🧮 Analyse
</button>
<button id="info-popup-export-gis" <button id="info-popup-export-gis"
style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;"> style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
🗺 Export GIS Export
</button>
<button id="info-popup-export-pdf"
style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
📄 Export PDF
</button> </button>
</div>`; </div>`;
} }
@ -1433,19 +1587,36 @@ export class MapView {
this.hideInfoPopup(); this.hideInfoPopup();
}); });
// PDF export — dynamic import so jspdf is only loaded on demand // Summary rows, cleaned for the PDF report (HTML stripped, spacers removed).
this.infoPopupElement.querySelector('#info-popup-export-pdf')?.addEventListener('click', () => { // These travel with the export event so the export modal can offer PDF
// Strip HTML from values and remove the color/empty keys for the PDF // alongside the GIS formats — one "Export" entry point instead of two.
const pdfRows = dataRows const pdfRows = dataRows
.filter(r => !r.empty) .filter((r) => !r.empty)
.map(r => ({ label: r.label, value: r.value.replace(/<[^>]*>/g, '') })); .map((r) => ({ label: r.label, value: String(r.value).replace(/<[^>]*>/g, '') }));
import('../pdf-export.js').then(({ exportAnalysisPDF }) => { // Analyse — hand the intersecting features to the Analysis panel so the
exportAnalysisPDF({ title, rows: pdfRows }); // user can run overlays / zonal statistics on just what the circle or
}).catch(err => { // area caught. main.js owns the panel import.
console.error('[MapView] PDF export failed:', err); const analyseBtn = this.infoPopupElement.querySelector('#info-popup-analyse');
}); if (analyseBtn) {
const totalFeatures = exportContext
? exportContext.parcelFeatures.length
+ exportContext.zoneFeatures.length
+ Object.values(exportContext.otherByLayer).reduce((s, arr) => s + arr.length, 0)
: 0;
if (!exportContext || totalFeatures === 0) {
analyseBtn.disabled = true;
analyseBtn.style.opacity = '0.5';
analyseBtn.style.cursor = 'not-allowed';
analyseBtn.title = 'No intersecting features to analyse';
} else {
analyseBtn.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('lupmis:analyse-features', {
detail: { title, ...exportContext },
}));
}); });
}
}
// GIS export — dispatches a window-level CustomEvent so main.js can open // GIS export — dispatches a window-level CustomEvent so main.js can open
// the format/field-rename modal without MapView pulling in the writers. // the format/field-rename modal without MapView pulling in the writers.
@ -1464,7 +1635,7 @@ export class MapView {
} else { } else {
gisBtn.addEventListener('click', () => { gisBtn.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('lupmis:export-gis', { window.dispatchEvent(new CustomEvent('lupmis:export-gis', {
detail: { title, ...exportContext }, detail: { title, pdfRows, ...exportContext },
})); }));
}); });
} }
@ -3599,6 +3770,147 @@ export class MapView {
return xyzLayer; return xyzLayer;
} }
/**
* Add a Cloud-Optimized GeoTIFF (COG) raster layer.
*
* COGs are streamed by range-request and rendered on the GPU via
* WebGLTile, so a large DEM or satellite scene can be displayed without
* downloading the whole file. This is the display half of Stage 1's raster
* support (analysis on rasters runs server-side see the concept note).
*
* @param {string} groupTitle Title of the target LayerGroup
* @param {string} title Display title for the layer
* @param {string} url URL of the .tif (must be a valid COG, CORS-enabled)
* @param {Object} [options]
* @param {boolean} [options.visible=true]
* @param {number} [options.opacity=1]
* @param {number} [options.zIndex]
* @param {boolean} [options.normalize=true] Auto-stretch values to 01 for display
* @param {number} [options.min] Explicit min for contrast stretch
* @param {number} [options.max] Explicit max for contrast stretch
* @param {Object} [options.style] OpenLayers WebGLTile style (e.g. a colour ramp)
* @param {string} [options.attributions]
* @returns {Promise<Object|null>} the created layer (async: the COG libraries
* are code-split and fetched on first use)
*/
async addCOGLayer(groupTitle, title, url, options = {}) {
const group = this.getLayerGroupByTitle(groupTitle);
if (!group) {
console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add COG layer "${title}"`);
return null;
}
// Lazy-load the GeoTIFF stack only when a raster is actually opened.
let WebGLTileLayer, GeoTIFFSource;
try {
[{ default: WebGLTileLayer }, { default: GeoTIFFSource }] = await Promise.all([
import('ol/layer/WebGLTile'),
import('ol/source/GeoTIFF'),
]);
} catch (err) {
console.error('[MapView] Could not load COG support:', err);
showToast('Raster (COG) support could not be loaded — check your connection.', 'error', 6000);
return null;
}
let cogLayer = null;
try {
const sourceOpts = {
sources: [{
url,
...(options.min !== undefined ? { min: options.min } : {}),
...(options.max !== undefined ? { max: options.max } : {}),
}],
// normalize=true stretches band values into 01 so single-band rasters
// (DEMs, indices) render without an explicit style.
normalize: options.normalize !== undefined ? options.normalize : true,
attributions: options.attributions,
};
const source = new GeoTIFFSource(sourceOpts);
cogLayer = new WebGLTileLayer({
title,
source,
visible: options.visible !== undefined ? options.visible : true,
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
...(options.style ? { style: options.style } : {}),
});
cogLayer.set('typeTag', 'COG');
cogLayer.set('typeDescription', 'Cloud-Optimized GeoTIFF');
// Surface load failures — a non-COG or a CORS-blocked file is the usual cause.
source.on('error', (evt) => {
console.warn(`[MapView] COG "${title}" source error:`, evt);
showToast(`Raster "${title}" could not be loaded. Check that the file is a valid COG and is CORS-enabled.`,
'warning', 6000);
});
group.getLayers().push(cogLayer);
} catch (err) {
console.error(`[MapView] Failed to add COG layer "${title}":`, err);
showToast(`Could not add raster "${title}": ${err.message}`, 'error', 6000);
return null;
}
if (options.onlineOnly) this._attachOnlineOnlyHandler(cogLayer, title);
console.log(`[MapView] COG layer added: "${title}" → group "${groupTitle}"`);
return cogLayer;
}
/**
* The features currently selected with the Select tool (shift-click or
* Ctrl/Cmd box-select). Used by the Analysis panel to restrict an analysis
* to a hand-picked subset.
* @returns {Array<import('ol/Feature').default>}
*/
getSelectedFeatures() {
if (!this._selectInteraction) return [];
return this._selectInteraction.getFeatures().getArray().slice();
}
/**
* List the vector layers a user can pick as analysis inputs.
*
* Walks the layer tree (recursing into groups) and returns the real,
* feature-bearing layers excluding helper overlays (vertex handles, GPS
* trail/position, snap guides) which are never meaningful analysis inputs.
*
* @param {Object} [opts]
* @param {boolean} [opts.visibleOnly=false] only include currently-visible layers
* @param {boolean} [opts.nonEmptyOnly=true] skip layers with no features loaded
* @returns {Array<{layer: VectorLayer, title: string, count: number}>}
*/
listVectorLayers(opts = {}) {
const { visibleOnly = false, nonEmptyOnly = true } = opts;
const out = [];
const walk = (layer) => {
if (!layer) return;
if (typeof layer.getLayers === 'function') {
layer.getLayers().forEach(walk);
return;
}
if (!(layer instanceof VectorLayer)) return;
if (layer === this._vertexOverlayLayer ||
layer === this._gpsTrailLayer ||
layer === this._gpsPositionLayer ||
layer.get('displayInLayerSwitcher') === false) return;
if (visibleOnly && layer.getVisible && !layer.getVisible()) return;
const source = layer.getSource && layer.getSource();
if (!source || typeof source.getFeatures !== 'function') return;
const count = source.getFeatures().length;
if (nonEmptyOnly && count === 0) return;
out.push({ layer, title: layer.get('title') || '(untitled layer)', count });
};
this.map.getLayers().forEach(walk);
return out;
}
// ============================================================================ // ============================================================================
// Add External Layer Dialog // Add External Layer Dialog
// ============================================================================ // ============================================================================

View File

@ -28,6 +28,7 @@ function cacheEls() {
els.filename = document.getElementById('export-gis-filename'); els.filename = document.getElementById('export-gis-filename');
els.tbody = document.getElementById('export-gis-fields-tbody'); els.tbody = document.getElementById('export-gis-fields-tbody');
els.fmtHint = document.getElementById('export-gis-format-hint'); els.fmtHint = document.getElementById('export-gis-format-hint');
els.fieldsWrap = document.getElementById('export-gis-fields-wrap');
els.btnGo = document.getElementById('export-gis-go'); els.btnGo = document.getElementById('export-gis-go');
els.fmtInputs = Array.from(document.querySelectorAll('input[name="export-gis-format"]')); els.fmtInputs = Array.from(document.querySelectorAll('input[name="export-gis-format"]'));
@ -127,7 +128,14 @@ function onFormatChange() {
'all zipped into one download.', 'all zipped into one download.',
kml: 'KML preserves attribute names; the first non-empty renamed field is used ' + kml: 'KML preserves attribute names; the first non-empty renamed field is used ' +
'as each feature\'s <name> in Google Earth.', 'as each feature\'s <name> in Google Earth.',
pdf: 'A one-page <strong>summary report</strong> of the analysis figures — not the ' +
'feature geometry. Field renaming does not apply.',
}[fmt]; }[fmt];
// The PDF is a report of the summary figures, so the per-field rename table
// and the geometry-oriented filename are irrelevant to it.
const isPdf = fmt === 'pdf';
if (els.fieldsWrap) els.fieldsWrap.classList.toggle('d-none', isPdf);
renderFieldsTable(); renderFieldsTable();
} }
@ -145,12 +153,22 @@ async function onExportClick() {
els.btnGo.disabled = true; els.btnGo.disabled = true;
try { try {
if (format === 'pdf') {
// The PDF is the analysis summary report. jspdf is imported on demand.
const rows = state.pdfRows || [];
if (rows.length === 0) {
throw new Error('No summary figures available for a PDF report.');
}
const { exportAnalysisPDF } = await import('./pdf-export.js');
await exportAnalysisPDF({ title: state.title || 'Analysis', rows });
} else {
await exportFeaturesToGis({ await exportFeaturesToGis({
features: state.features, features: state.features,
rename: state.rename, rename: state.rename,
format, format,
filenameBase, filenameBase,
}); });
}
modal.hide(); modal.hide();
} catch (err) { } catch (err) {
console.error('[ExportGIS] failed:', err); console.error('[ExportGIS] failed:', err);
@ -179,7 +197,9 @@ export function openExportGisModal(ctx) {
const keys = unionAttributeKeys(features); const keys = unionAttributeKeys(features);
const rename = Object.fromEntries(keys.map((k) => [k, k])); const rename = Object.fromEntries(keys.map((k) => [k, k]));
state = { features, keys, rename }; // pdfRows / title travel with the popup event so the PDF report option can
// be offered here instead of a separate button on the analysis popup.
state = { features, keys, rename, pdfRows: ctx.pdfRows || [], title: ctx.title };
els.summary.textContent = els.summary.textContent =
`${features.length} feature${features.length === 1 ? '' : 's'} ` + `${features.length} feature${features.length === 1 ? '' : 's'} ` +
`intersecting the ${ctx.kind === 'circle' ? 'circle' : 'area'}`; `intersecting the ${ctx.kind === 'circle' ? 'circle' : 'area'}`;

View File

@ -370,11 +370,16 @@ export class PolygonDivideInteraction extends ol_interaction_Interaction {
this.dispatchEvent(evtData); this.dispatchEvent(evtData);
source.dispatchEvent({ ...evtData }); source.dispatchEvent({ ...evtData });
// Replace original with pieces // Replace original with pieces. Group the remove + all the adds into a
// single UndoRedo block (map-level undoblockstart/undoblockend) so the
// whole divide is reversed by ONE undo press regardless of piece count.
const divideMap = this.getMap();
divideMap?.dispatchEvent('undoblockstart');
source.removeFeature(feature); source.removeFeature(feature);
for (const f of newFeatures) { for (const f of newFeatures) {
source.addFeature(f); source.addFeature(f);
} }
divideMap?.dispatchEvent('undoblockend');
// Dispatch afterdivide // Dispatch afterdivide
const afterEvt = { const afterEvt = {

View File

@ -386,10 +386,15 @@ export class PolygonMergeInteraction extends ol_interaction_Interaction {
sourceB.dispatchEvent({ ...evtData }); sourceB.dispatchEvent({ ...evtData });
} }
// Replace originals with merged // Replace originals with merged. Group the two removes + one add into a
// single UndoRedo block (map-level undoblockstart/undoblockend) so the
// whole merge — across both source layers — is reversed by ONE undo press.
const mergeMap = this.getMap();
mergeMap?.dispatchEvent('undoblockstart');
sourceA.removeFeature(featureA); sourceA.removeFeature(featureA);
sourceB.removeFeature(featureB); sourceB.removeFeature(featureB);
sourceA.addFeature(mergedFeature); sourceA.addFeature(mergedFeature);
mergeMap?.dispatchEvent('undoblockend');
// Dispatch aftermerge events // Dispatch aftermerge events
const afterEvt = { const afterEvt = {

View File

@ -315,10 +315,16 @@ export class PolygonSplitInteraction extends ol_interaction_Interaction {
features: splitFeatures, features: splitFeatures,
}); });
// Replace the original feature // Replace the original feature. Group the remove + two adds into a single
// UndoRedo block (via the map-level undoblockstart/undoblockend events the
// ol-ext UndoRedo interaction listens for) so the whole split is reversed
// by ONE undo press instead of three.
const splitMap = this.getMap();
splitMap?.dispatchEvent('undoblockstart');
source.removeFeature(feature); source.removeFeature(feature);
source.addFeature(featureA); source.addFeature(featureA);
source.addFeature(featureB); source.addFeature(featureB);
splitMap?.dispatchEvent('undoblockend');
// Dispatch aftersplit // Dispatch aftersplit
this.dispatchEvent({ this.dispatchEvent({

View File

@ -46,6 +46,20 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id) { manualChunks(id) {
// pako (zlib) is pulled in by BOTH geotiff and jspdf (via fast-png).
// Without its own chunk, Rollup assigns it to one of them and the two
// chunks reference each other ("Circular chunk: jspdf -> geotiff").
if (id.includes('node_modules/pako/')) return 'pako';
// Turf.js — only needed when the Analysis panel is opened, which
// main.js imports dynamically. Keep it out of the eager bundle.
if (id.includes('node_modules/@turf/')) return 'turf';
// geotiff.js + the OL modules that use it — only needed when the user
// opens a Cloud-Optimized GeoTIFF layer, which MapView.addCOGLayer()
// imports dynamically. Must be tested BEFORE the generic ol/ rule so
// it isn't swallowed into the eagerly-loaded openlayers chunk.
if (id.includes('node_modules/geotiff/') ||
id.includes('node_modules/ol/source/GeoTIFF') ||
id.includes('node_modules/ol/layer/WebGLTile')) return 'geotiff';
if (id.includes('node_modules/ol/')) return 'openlayers'; if (id.includes('node_modules/ol/')) return 'openlayers';
if (id.includes('node_modules/ol-ext/')) return 'ol-ext'; if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
if (id.includes('node_modules/bootstrap/')) return 'bootstrap'; if (id.includes('node_modules/bootstrap/')) return 'bootstrap';