From 5967b99a73a1b9b5e3d452522ea5b912d81e07d9 Mon Sep 17 00:00:00 2001 From: ekke Date: Fri, 21 Aug 2026 09:45:58 +0200 Subject: [PATCH] Keep the placed point on screen until it becomes the marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placing a point with Draw > Point removed it immediately and opened the Add Location form, so the officer filled in a form with nothing at the spot they had just tapped, and a marker then appeared from nowhere. Nothing visibly turned into anything, which is what made it read as broken. The sketch now stays until the form closes. On save the marker has already been added by then, so the point is replaced by it; on cancel, or on leaving the drawing tools with the form still open, it is simply discarded. Placing a second point while a form is open replaces the pending sketch rather than leaving the first one behind. Marker loading is also taken out of the undo history. Entering edit mode calls UndoRedo._watchSources(), which picks up every vector source including the markers — so reloading the Locations list registered as two undoable edits, and Undo after saving a location stripped the markers off the map while leaving the row in the database. That is the same "my point disappeared" symptom by another route. clearMarkers() uses _silentClearSource for the reason already documented on the vertex overlay: ol-ext patches VectorSource.clear to fire block events that the record flag does not gate. Verified all three endings — save, cancel, and abandoning by leaving Digitise — plus placing a second point mid-form, and that the undo stack stays empty through the whole sequence with recording switched back on afterwards. Service Worker v21 -> v22. Co-Authored-By: Claude Opus 4.8 --- ..._rBzLxQd.js => analysis-modal-D9nxP8v6.js} | 4 +- ....js.map => analysis-modal-D9nxP8v6.js.map} | 2 +- dist/assets/index-BQfxXCp7.js.map | 1 - .../{index-BQfxXCp7.js => index-D2LXdMvQ.js} | 14 ++-- dist/assets/index-D2LXdMvQ.js.map | 1 + dist/index.html | 2 +- dist/sw.js | 9 ++- public/sw.js | 9 ++- src/components/MapView.js | 69 +++++++++++++++---- 9 files changed, 83 insertions(+), 28 deletions(-) rename dist/assets/{analysis-modal-_rBzLxQd.js => analysis-modal-D9nxP8v6.js} (99%) rename dist/assets/{analysis-modal-_rBzLxQd.js.map => analysis-modal-D9nxP8v6.js.map} (99%) delete mode 100644 dist/assets/index-BQfxXCp7.js.map rename dist/assets/{index-BQfxXCp7.js => index-D2LXdMvQ.js} (90%) create mode 100644 dist/assets/index-D2LXdMvQ.js.map diff --git a/dist/assets/analysis-modal-_rBzLxQd.js b/dist/assets/analysis-modal-D9nxP8v6.js similarity index 99% rename from dist/assets/analysis-modal-_rBzLxQd.js rename to dist/assets/analysis-modal-D9nxP8v6.js index db9a4e5..e7b7fe7 100644 --- a/dist/assets/analysis-modal-_rBzLxQd.js +++ b/dist/assets/analysis-modal-D9nxP8v6.js @@ -1,4 +1,4 @@ -import{_ as de}from"./geotiff-BaoeLn6q.js";import{M as pe}from"./bootstrap-D1-uvFxm.js";import{ax as ye}from"./openlayers-J9qS6Th1.js";import{i as X,f as j,a as fe,b as me,c as he,d as ge,e as be,g as we}from"./turf-B-0GbNlN.js";import{s as F}from"./index-BQfxXCp7.js";import"./pako-Xa-UToif.js";import"./ol-ext-C5frl4dY.js";import"./shpjs-Doisr2ug.js";const Q=4e5,ve=new ye;function $(e,n="EPSG:3857"){const o=[];for(const s of e||[]){if(!(s.getGeometry&&s.getGeometry()))continue;const a=ve.writeFeatureObject(s,{dataProjection:"EPSG:4326",featureProjection:n});a&&a.geometry&&o.push(a)}return o}function N(e){const n=e&&e.geometry;if(!n||!n.coordinates)return null;let o=1/0,s=1/0,r=-1/0,a=-1/0;const c=l=>{if(typeof l[0]=="number"){l[0]r&&(r=l[0]),l[1]a&&(a=l[1]);return}for(const i of l)c(i)};return c(n.coordinates),Number.isFinite(o)?[o,s,r,a]:null}function D(e,n){return!e||!n?!1:!(e[2]=e[0]&&n<=e[2]&&o>=e[1]&&o<=e[3]:!1}function _(e,n,o){if(e.length>0||n.length===0)return;const s=[...new Set(n.map(a=>a?.geometry?.type).filter(Boolean))],r=s.length?s.join(", "):"no geometry";throw new Error(`${o} contains no polygons (found ${r}). Overlay operations work on polygon layers only — line layers such as contours or roads cannot be used here. To summarise a line layer per zone, use Zonal statistics instead.`)}function I(e){return(e||[]).filter(n=>{const o=n?.geometry?.type;return o==="Polygon"||o==="MultiPolygon"})}function Be(e,n,o="b_"){const s={...e||{}};for(const[r,a]of Object.entries(n||{}))s[Object.prototype.hasOwnProperty.call(s,r)?`${o}${r}`:r]=a;return s}function z(e){return{type:"FeatureCollection",features:e}}function ee(e,n,o={}){const{keepBAttributes:s=!0}=o,r=$(e),a=$(n),c=I(r),l=I(a);_(c,r,"Layer A"),_(l,a,"Layer B");const i=l.map(N);let u=0;const f=[];for(const d of c){const y=N(d);for(let m=0;mQ)throw te(u);let p=null;try{p=me(j([d,l[m]]))}catch{p=null}!p||!p.geometry||(p.properties=s?Be(d.properties,l[m].properties):{...d.properties||{}},f.push(p))}}return z(f)}function Ee(e,n,o={}){return ee(e,n,{...o,keepBAttributes:!1})}function Le(e,n){const o=$(e),s=$(n),r=I(o),a=I(s);_(r,o,"Layer A"),_(a,s,"Layer B");const c=a.map(N);let l=0;const i=[];for(const u of r){const f=N(u);let d=u;for(let y=0;yQ)throw te(l);try{const m=fe(j([d,a[y]]));d=m&&m.geometry?m:null}catch{}}d&&d.geometry&&(d.properties={...u.properties||{}},i.push(d))}return z(i)}function Ae(e,n=[]){const o=$(e),s=$(n),r=I(o),a=I(s);_(r,o,"Layer A"),_(a,s,"Layer B");const c=[...r,...a];if(c.length===0)return z([]);if(c.length===1)return z([c[0]]);let l=null;try{l=X(j(c))}catch{l=c[0];for(let i=1;i{const A=w?.geometry?.type;return A==="Polygon"||A==="MultiPolygon"}),l=$(n)||[];if(c.length===0)throw new Error("The zone layer contains no polygons.");const i=[],u=new Set(l.map(w=>w?.geometry?.type).filter(Boolean)),f=u.has("Polygon")||u.has("MultiPolygon");o.includes("area")&&!f&&l.length>0&&i.push(`Total area is 0 because the features being summarised are ${[...u].join("/")} — only polygons have an area.`);const d=u.has("LineString")||u.has("MultiLineString");o.includes("mean")&&d&&!f&&s&&i.push(`Mean is the average of “${s}” across the individual line features in each zone, not an area-weighted average of the surface they describe. For contour data, Minimum and Maximum (the elevation range) are the figures you can rely on.`);const y=r==="centroid"?l.map(w=>{try{return he(w)}catch{return null}}):null,m=r==="centroid"?null:l.map(N),p=c.map(N);let L=0;const W=[];for(let w=0;wY)throw q(L);b=ge(E,A)}else{if(!D(U,m[h]))continue;if(++L>Y)throw q(L);b=be(l[h],A)}}catch{b=!1}b&&O.push(l[h])}const B=s?O.map(h=>ke(h.properties?h.properties[s]:null)).filter(h=>h!==null):[],S={};for(const h of o)switch(h){case"count":S[`${a}count`]=O.length;break;case"sum":S[`${a}sum`]=B.length?C(B.reduce((b,E)=>b+E,0)):0;break;case"mean":S[`${a}mean`]=B.length?C(B.reduce((b,E)=>b+E,0)/B.length):null;break;case"min":S[`${a}min`]=B.length?C(Math.min(...B)):null;break;case"max":S[`${a}max`]=B.length?C(Math.max(...B)):null;break;case"area":{let b=0;for(const E of O)try{b+=we(E)}catch{}S[`${a}area_m2`]=Math.round(b);break}}A.properties={...A.properties||{},...S},W.push({...Me(A),...S})}return{geojson:{type:"FeatureCollection",features:c},rows:W,warnings:i}}function q(e){return new Error(`More than ${e.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).`)}function C(e){return Math.round(e*1e3)/1e3}function Me(e){const n=e.properties||{};for(const o of["zone_name","name","ZONE_NAME","NAME","upn","UPN","id","ID"])if(n[o]!==void 0&&n[o]!==null&&String(n[o]).trim()!=="")return{zone:String(n[o])};return{zone:"(unnamed)"}}const Ne=9001,H="Analysis",t={};let P=null,v=null,M=[],x=null,k=null;function _e(){t.root||(t.root=document.getElementById("analysisModal"),t.mode=Array.from(document.querySelectorAll('input[name="analysis-mode"]')),t.overlayBox=document.getElementById("analysis-overlay-box"),t.zonalBox=document.getElementById("analysis-zonal-box"),t.op=document.getElementById("analysis-op"),t.opHint=document.getElementById("analysis-op-hint"),t.layerA=document.getElementById("analysis-layer-a"),t.layerB=document.getElementById("analysis-layer-b"),t.layerBWrap=document.getElementById("analysis-layer-b-wrap"),t.zoneLayer=document.getElementById("analysis-zone-layer"),t.inputLayer=document.getElementById("analysis-input-layer"),t.membership=document.getElementById("analysis-membership"),t.memberHint=document.getElementById("analysis-membership-hint"),t.field=document.getElementById("analysis-field"),t.statsBox=document.getElementById("analysis-stats-box"),t.outName=document.getElementById("analysis-output-name"),t.result=document.getElementById("analysis-result"),t.btnRun=document.getElementById("analysis-run"),t.btnExport=document.getElementById("analysis-export-table"),t.scope=document.getElementById("analysis-scope"),t.scopeHint=document.getElementById("analysis-scope-hint"),t.root.dataset.wired||(t.root.dataset.wired="1",t.mode.forEach(e=>e.addEventListener("change",oe)),t.op.addEventListener("change",re),t.membership.addEventListener("change",se),t.inputLayer.addEventListener("change",ne),t.scope.addEventListener("change",le),t.btnRun.addEventListener("click",Ce),t.btnExport.addEventListener("click",je)))}function J(e=null){return M.map((n,o)=>``).join("")}function Ie(){t.op.innerHTML=G.map(e=>``).join(""),t.membership.innerHTML=Z.map(e=>``).join(""),t.statsBox.innerHTML=R.map(e=>` +import{_ as de}from"./geotiff-BaoeLn6q.js";import{M as pe}from"./bootstrap-D1-uvFxm.js";import{ax as ye}from"./openlayers-J9qS6Th1.js";import{i as X,f as j,a as fe,b as me,c as he,d as ge,e as be,g as we}from"./turf-B-0GbNlN.js";import{s as F}from"./index-D2LXdMvQ.js";import"./pako-Xa-UToif.js";import"./ol-ext-C5frl4dY.js";import"./shpjs-Doisr2ug.js";const Q=4e5,ve=new ye;function $(e,n="EPSG:3857"){const o=[];for(const s of e||[]){if(!(s.getGeometry&&s.getGeometry()))continue;const a=ve.writeFeatureObject(s,{dataProjection:"EPSG:4326",featureProjection:n});a&&a.geometry&&o.push(a)}return o}function N(e){const n=e&&e.geometry;if(!n||!n.coordinates)return null;let o=1/0,s=1/0,r=-1/0,a=-1/0;const c=l=>{if(typeof l[0]=="number"){l[0]r&&(r=l[0]),l[1]a&&(a=l[1]);return}for(const i of l)c(i)};return c(n.coordinates),Number.isFinite(o)?[o,s,r,a]:null}function D(e,n){return!e||!n?!1:!(e[2]=e[0]&&n<=e[2]&&o>=e[1]&&o<=e[3]:!1}function _(e,n,o){if(e.length>0||n.length===0)return;const s=[...new Set(n.map(a=>a?.geometry?.type).filter(Boolean))],r=s.length?s.join(", "):"no geometry";throw new Error(`${o} contains no polygons (found ${r}). Overlay operations work on polygon layers only — line layers such as contours or roads cannot be used here. To summarise a line layer per zone, use Zonal statistics instead.`)}function I(e){return(e||[]).filter(n=>{const o=n?.geometry?.type;return o==="Polygon"||o==="MultiPolygon"})}function Be(e,n,o="b_"){const s={...e||{}};for(const[r,a]of Object.entries(n||{}))s[Object.prototype.hasOwnProperty.call(s,r)?`${o}${r}`:r]=a;return s}function z(e){return{type:"FeatureCollection",features:e}}function ee(e,n,o={}){const{keepBAttributes:s=!0}=o,r=$(e),a=$(n),c=I(r),l=I(a);_(c,r,"Layer A"),_(l,a,"Layer B");const i=l.map(N);let u=0;const f=[];for(const d of c){const y=N(d);for(let m=0;mQ)throw te(u);let p=null;try{p=me(j([d,l[m]]))}catch{p=null}!p||!p.geometry||(p.properties=s?Be(d.properties,l[m].properties):{...d.properties||{}},f.push(p))}}return z(f)}function Ee(e,n,o={}){return ee(e,n,{...o,keepBAttributes:!1})}function Le(e,n){const o=$(e),s=$(n),r=I(o),a=I(s);_(r,o,"Layer A"),_(a,s,"Layer B");const c=a.map(N);let l=0;const i=[];for(const u of r){const f=N(u);let d=u;for(let y=0;yQ)throw te(l);try{const m=fe(j([d,a[y]]));d=m&&m.geometry?m:null}catch{}}d&&d.geometry&&(d.properties={...u.properties||{}},i.push(d))}return z(i)}function Ae(e,n=[]){const o=$(e),s=$(n),r=I(o),a=I(s);_(r,o,"Layer A"),_(a,s,"Layer B");const c=[...r,...a];if(c.length===0)return z([]);if(c.length===1)return z([c[0]]);let l=null;try{l=X(j(c))}catch{l=c[0];for(let i=1;i{const A=w?.geometry?.type;return A==="Polygon"||A==="MultiPolygon"}),l=$(n)||[];if(c.length===0)throw new Error("The zone layer contains no polygons.");const i=[],u=new Set(l.map(w=>w?.geometry?.type).filter(Boolean)),f=u.has("Polygon")||u.has("MultiPolygon");o.includes("area")&&!f&&l.length>0&&i.push(`Total area is 0 because the features being summarised are ${[...u].join("/")} — only polygons have an area.`);const d=u.has("LineString")||u.has("MultiLineString");o.includes("mean")&&d&&!f&&s&&i.push(`Mean is the average of “${s}” across the individual line features in each zone, not an area-weighted average of the surface they describe. For contour data, Minimum and Maximum (the elevation range) are the figures you can rely on.`);const y=r==="centroid"?l.map(w=>{try{return he(w)}catch{return null}}):null,m=r==="centroid"?null:l.map(N),p=c.map(N);let L=0;const W=[];for(let w=0;wY)throw q(L);b=ge(E,A)}else{if(!D(U,m[h]))continue;if(++L>Y)throw q(L);b=be(l[h],A)}}catch{b=!1}b&&O.push(l[h])}const B=s?O.map(h=>ke(h.properties?h.properties[s]:null)).filter(h=>h!==null):[],S={};for(const h of o)switch(h){case"count":S[`${a}count`]=O.length;break;case"sum":S[`${a}sum`]=B.length?C(B.reduce((b,E)=>b+E,0)):0;break;case"mean":S[`${a}mean`]=B.length?C(B.reduce((b,E)=>b+E,0)/B.length):null;break;case"min":S[`${a}min`]=B.length?C(Math.min(...B)):null;break;case"max":S[`${a}max`]=B.length?C(Math.max(...B)):null;break;case"area":{let b=0;for(const E of O)try{b+=we(E)}catch{}S[`${a}area_m2`]=Math.round(b);break}}A.properties={...A.properties||{},...S},W.push({...Me(A),...S})}return{geojson:{type:"FeatureCollection",features:c},rows:W,warnings:i}}function q(e){return new Error(`More than ${e.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).`)}function C(e){return Math.round(e*1e3)/1e3}function Me(e){const n=e.properties||{};for(const o of["zone_name","name","ZONE_NAME","NAME","upn","UPN","id","ID"])if(n[o]!==void 0&&n[o]!==null&&String(n[o]).trim()!=="")return{zone:String(n[o])};return{zone:"(unnamed)"}}const Ne=9001,H="Analysis",t={};let P=null,v=null,M=[],x=null,k=null;function _e(){t.root||(t.root=document.getElementById("analysisModal"),t.mode=Array.from(document.querySelectorAll('input[name="analysis-mode"]')),t.overlayBox=document.getElementById("analysis-overlay-box"),t.zonalBox=document.getElementById("analysis-zonal-box"),t.op=document.getElementById("analysis-op"),t.opHint=document.getElementById("analysis-op-hint"),t.layerA=document.getElementById("analysis-layer-a"),t.layerB=document.getElementById("analysis-layer-b"),t.layerBWrap=document.getElementById("analysis-layer-b-wrap"),t.zoneLayer=document.getElementById("analysis-zone-layer"),t.inputLayer=document.getElementById("analysis-input-layer"),t.membership=document.getElementById("analysis-membership"),t.memberHint=document.getElementById("analysis-membership-hint"),t.field=document.getElementById("analysis-field"),t.statsBox=document.getElementById("analysis-stats-box"),t.outName=document.getElementById("analysis-output-name"),t.result=document.getElementById("analysis-result"),t.btnRun=document.getElementById("analysis-run"),t.btnExport=document.getElementById("analysis-export-table"),t.scope=document.getElementById("analysis-scope"),t.scopeHint=document.getElementById("analysis-scope-hint"),t.root.dataset.wired||(t.root.dataset.wired="1",t.mode.forEach(e=>e.addEventListener("change",oe)),t.op.addEventListener("change",re),t.membership.addEventListener("change",se),t.inputLayer.addEventListener("change",ne),t.scope.addEventListener("change",le),t.btnRun.addEventListener("click",Ce),t.btnExport.addEventListener("click",je)))}function J(e=null){return M.map((n,o)=>``).join("")}function Ie(){t.op.innerHTML=G.map(e=>``).join(""),t.membership.innerHTML=Z.map(e=>``).join(""),t.statsBox.innerHTML=R.map(e=>`
@@ -8,4 +8,4 @@ import{_ as de}from"./geotiff-BaoeLn6q.js";import{M as pe}from"./bootstrap-D1-uv ${o} ${s}
`}function Xe(e,n={}){if(v=e,_e(),x=n.scopeFeatures&&n.scopeFeatures.length?{features:new Set(n.scopeFeatures),label:n.scopeLabel||"drawn area"}:null,He(),M=v.listVectorLayers({nonEmptyOnly:!0}),M.length===0){F("No vector layers with features are loaded — nothing to analyse yet.","warning",5e3);return}Ie();const o=J(0),s=J(M.length>1?1:0);t.layerA.innerHTML=o,t.layerB.innerHTML=s,t.zoneLayer.innerHTML=o,t.inputLayer.innerHTML=s,t.outName.dataset.touched="",t.outName.addEventListener("input",()=>{t.outName.dataset.touched="1"},{once:!0}),t.result.innerHTML="",k=null,t.scope.value=x?"drawn":"all",ne(),re(),se(),le(),oe(),P=P||new pe(t.root),P.show()}function He(){const e=t.scope.querySelector('option[value="drawn"]');if(!x){e&&e.remove();return}const n=`From ${x.label} (${x.features.size.toLocaleString()} features)`;if(e)e.textContent=n;else{const o=document.createElement("option");o.value="drawn",o.textContent=n,t.scope.insertBefore(o,t.scope.firstChild)}}function g(e){return String(e).replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n])}export{Xe as openAnalysisModal}; -//# sourceMappingURL=analysis-modal-_rBzLxQd.js.map +//# sourceMappingURL=analysis-modal-D9nxP8v6.js.map diff --git a/dist/assets/analysis-modal-_rBzLxQd.js.map b/dist/assets/analysis-modal-D9nxP8v6.js.map similarity index 99% rename from dist/assets/analysis-modal-_rBzLxQd.js.map rename to dist/assets/analysis-modal-D9nxP8v6.js.map index 7adcfe8..5aa255c 100644 --- a/dist/assets/analysis-modal-_rBzLxQd.js.map +++ b/dist/assets/analysis-modal-D9nxP8v6.js.map @@ -1 +1 @@ -{"version":3,"mappings":"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","names":["MAX_OVERLAY_CANDIDATES","fmt","GeoJSON","toGeoJSONFeatures","features","mapProjection","out","f","gj","bboxOf","feature","geom","minX","minY","maxX","maxY","walk","c","sub","bboxOverlaps","a","b","bboxContainsPoint","box","x","y","assertPolygons","polygons","original","role","types","found","polygonsOnly","gjFeatures","t","mergeProps","propsA","propsB","prefixB","k","v","fc","intersectFeatures","aFeatures","bFeatures","opts","keepBAttributes","gjA","gjB","A","B","bBoxes","candidates","aBox","i","candidateOverflow","piece","intersect","featureCollection","clipFeatures","clipFeatures_","differenceFeatures","remainder","next","difference","unionFeatures","polyA","polyB","all","merged","union","count","OVERLAY_OPS","runOverlay","op","MAX_ZONAL_CANDIDATES","MEMBERSHIP_MODES","STATS","listNumericFields","counts","props","num","value","zonalStatistics","zoneFeatures","inputFeatures","stats","field","membership","prefix","zones","z","inputs","warnings","inputTypes","inputsHavePolygons","inputsAreLines","centroids","centroid","inputBoxes","zoneBoxes","rows","zone","zBox","members","inside","cx","cy","booleanPointInPolygon","booleanIntersects","values","result","s","round","total","m","area","labelOf","n","p","ANALYSIS_GROUP_ID","ANALYSIS_GROUP_TITLE","els","modal","mapView","layerChoices","drawnScope","lastZonal","cacheEls","r","onModeChange","onOpChange","onMembershipChange","refreshFieldOptions","onScopeChange","onRun","onExportTable","layerOptionsHtml","selectedIdx","escapeHtml","populateOps","o","choice","feats","fields","currentMode","syncExportButton","overlay","suggestName","needsB","featuresOf","select","source","scope","map","extent","selected","ensureAnalysisGroup","group","mode","name","geojson","summary","zonalWarnings","res","renderRows","membershipLabel","labelOfSelect","scopeLabel","warnHtml","w","showToast","err","opt","exportXlsx","__vitePreload","params","keys","resultRows","paramRows","base","head","h","body","openAnalysisModal","view","syncDrawnScopeOption","optsA","optsB","Modal","existing","label"],"ignoreList":[],"sources":["../../src/analysis/overlay.js","../../src/analysis/zonal.js","../../src/analysis-modal.js"],"sourcesContent":["/**\n * overlay.js — client-side vector overlay operations (Stage 1 of the\n * LUPMIS2 GIS Analytical Tools concept).\n *\n * Pure geometry helpers built on Turf.js. No DOM, no map — the caller hands\n * in OpenLayers features and gets a GeoJSON FeatureCollection back, ready for\n * MapView.addGeoJSONLayer().\n *\n * All Turf work happens in EPSG:4326 (Turf's area/length functions assume\n * WGS84), so features are reprojected out of the map's EPSG:3857 on the way\n * in and the result is handed back as WGS84 GeoJSON — the same convention\n * gis-export.js uses.\n *\n * intersectFeatures(a, b) → parts of A that fall inside B (attrs: A + B)\n * clipFeatures(a, b) → parts of A that fall inside B (attrs: A only)\n * differenceFeatures(a, b) → parts of A that fall OUTSIDE B (attrs: A)\n * unionFeatures(a[, b]) → dissolved outline of A (and B)\n */\n\nimport GeoJSON from 'ol/format/GeoJSON';\nimport intersect from '@turf/intersect';\nimport union from '@turf/union';\nimport difference from '@turf/difference';\nimport { featureCollection } from '@turf/helpers';\n\n/**\n * Guard: only *candidate* pairs (those whose bounding boxes actually overlap)\n * reach the expensive geometry test, so this ceiling is about real work, not\n * the raw n×m product. District-scale data (tens of thousands of parcels\n * against a few dozen zones) sits far below it.\n */\nexport const MAX_OVERLAY_CANDIDATES = 400000;\n\nconst fmt = new GeoJSON();\n\n/**\n * Convert OpenLayers features (map projection) to WGS84 GeoJSON features.\n * @param {Array} features\n * @param {string} [mapProjection='EPSG:3857']\n * @returns {Array} GeoJSON Features\n */\nexport function toGeoJSONFeatures(features, mapProjection = 'EPSG:3857') {\n const out = [];\n for (const f of features || []) {\n const geom = f.getGeometry && f.getGeometry();\n if (!geom) continue;\n const gj = fmt.writeFeatureObject(f, {\n dataProjection: 'EPSG:4326',\n featureProjection: mapProjection,\n });\n if (gj && gj.geometry) out.push(gj);\n }\n return out;\n}\n\n/**\n * Bounding box of a GeoJSON feature as [minX, minY, maxX, maxY].\n * Walks the coordinate arrays directly — no extra dependency, and fast enough\n * to run over tens of thousands of features.\n * @param {Object} feature GeoJSON Feature\n * @returns {number[]|null}\n */\nexport function bboxOf(feature) {\n const geom = feature && feature.geometry;\n if (!geom || !geom.coordinates) return null;\n let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n const walk = (c) => {\n if (typeof c[0] === 'number') {\n if (c[0] < minX) minX = c[0];\n if (c[0] > maxX) maxX = c[0];\n if (c[1] < minY) minY = c[1];\n if (c[1] > maxY) maxY = c[1];\n return;\n }\n for (const sub of c) walk(sub);\n };\n walk(geom.coordinates);\n return Number.isFinite(minX) ? [minX, minY, maxX, maxY] : null;\n}\n\n/** Do two bounding boxes overlap? Cheap pre-filter before real geometry work. */\nexport function bboxOverlaps(a, b) {\n if (!a || !b) return false;\n return !(a[2] < b[0] || b[2] < a[0] || a[3] < b[1] || b[3] < a[1]);\n}\n\n/** Is a point [x, y] inside a bounding box? */\nexport function bboxContainsPoint(box, x, y) {\n if (!box) return false;\n return x >= box[0] && x <= box[2] && y >= box[1] && y <= box[3];\n}\n\n/**\n * Reject a non-polygon input with a message that says what is actually wrong.\n *\n * Overlay operations are polygon-only, but the inputs are silently filtered by\n * polygonsOnly() — so a line layer (contours, roads) used to produce an empty\n * result and the misleading \"the layers may not overlap\". Failing loudly here\n * tells the user the real reason.\n *\n * @param {Array} polygons the surviving polygonal features\n * @param {Array} original everything that was passed in\n * @param {string} role 'Layer A' / 'Layer B'\n */\nfunction assertPolygons(polygons, original, role) {\n if (polygons.length > 0 || original.length === 0) return;\n const types = [...new Set(original.map((f) => f?.geometry?.type).filter(Boolean))];\n const found = types.length ? types.join(', ') : 'no geometry';\n throw new Error(\n `${role} contains no polygons (found ${found}). Overlay operations work on ` +\n `polygon layers only — line layers such as contours or roads cannot be used here. ` +\n `To summarise a line layer per zone, use Zonal statistics instead.`\n );\n}\n\n/** Keep only polygonal features — overlay is undefined for points/lines here. */\nfunction polygonsOnly(gjFeatures) {\n return (gjFeatures || []).filter((f) => {\n const t = f?.geometry?.type;\n return t === 'Polygon' || t === 'MultiPolygon';\n });\n}\n\n/** Merge two property bags, prefixing B's keys on collision so nothing is lost. */\nfunction mergeProps(propsA, propsB, prefixB = 'b_') {\n const out = { ...(propsA || {}) };\n for (const [k, v] of Object.entries(propsB || {})) {\n out[Object.prototype.hasOwnProperty.call(out, k) ? `${prefixB}${k}` : k] = v;\n }\n return out;\n}\n\nfunction fc(features) {\n return { type: 'FeatureCollection', features };\n}\n\n/**\n * Intersection: every piece of A that also lies inside B.\n * Result carries A's attributes merged with the matching B feature's.\n *\n * @param {Array} aFeatures OL features (layer A)\n * @param {Array} bFeatures OL features (layer B)\n * @param {Object} [opts]\n * @param {boolean} [opts.keepBAttributes=true] merge B's properties into the result\n * @returns {Object} GeoJSON FeatureCollection (WGS84)\n */\nexport function intersectFeatures(aFeatures, bFeatures, opts = {}) {\n const { keepBAttributes = true } = opts;\n const gjA = toGeoJSONFeatures(aFeatures);\n const gjB = toGeoJSONFeatures(bFeatures);\n const A = polygonsOnly(gjA);\n const B = polygonsOnly(gjB);\n assertPolygons(A, gjA, 'Layer A');\n assertPolygons(B, gjB, 'Layer B');\n\n // Pre-compute B's boxes once, then only test pairs that can possibly meet.\n const bBoxes = B.map(bboxOf);\n let candidates = 0;\n const out = [];\n for (const a of A) {\n const aBox = bboxOf(a);\n for (let i = 0; i < B.length; i++) {\n if (!bboxOverlaps(aBox, bBoxes[i])) continue;\n if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);\n let piece = null;\n try { piece = intersect(featureCollection([a, B[i]])); } catch { piece = null; }\n if (!piece || !piece.geometry) continue;\n piece.properties = keepBAttributes\n ? mergeProps(a.properties, B[i].properties)\n : { ...(a.properties || {}) };\n out.push(piece);\n }\n }\n return fc(out);\n}\n\n/**\n * Clip: same geometry result as intersect, but the output keeps ONLY layer A's\n * attributes — the usual \"cut this layer to that boundary\" operation.\n */\nexport function clipFeatures(aFeatures, clipFeatures_, opts = {}) {\n return intersectFeatures(aFeatures, clipFeatures_, { ...opts, keepBAttributes: false });\n}\n\n/**\n * Difference: the parts of A that do NOT fall inside any feature of B.\n * Each A feature is reduced by every overlapping B feature in turn; features\n * fully covered by B drop out of the result.\n *\n * @returns {Object} GeoJSON FeatureCollection (WGS84)\n */\nexport function differenceFeatures(aFeatures, bFeatures) {\n const gjA = toGeoJSONFeatures(aFeatures);\n const gjB = toGeoJSONFeatures(bFeatures);\n const A = polygonsOnly(gjA);\n const B = polygonsOnly(gjB);\n assertPolygons(A, gjA, 'Layer A');\n assertPolygons(B, gjB, 'Layer B');\n\n const bBoxes = B.map(bboxOf);\n let candidates = 0;\n const out = [];\n for (const a of A) {\n const aBox = bboxOf(a);\n let remainder = a;\n for (let i = 0; i < B.length; i++) {\n if (!remainder) break;\n // Only subtract a B feature that can actually touch what's left.\n if (!bboxOverlaps(aBox, bBoxes[i])) continue;\n if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);\n try {\n const next = difference(featureCollection([remainder, B[i]]));\n remainder = next && next.geometry ? next : null;\n } catch {\n // A malformed pair shouldn't kill the whole run — keep what we have.\n }\n }\n if (remainder && remainder.geometry) {\n remainder.properties = { ...(a.properties || {}) };\n out.push(remainder);\n }\n }\n return fc(out);\n}\n\n/**\n * Union / dissolve: merge everything into a single outline. Pass only `a` to\n * dissolve one layer, or both to merge two layers together.\n *\n * @returns {Object} GeoJSON FeatureCollection with 0 or 1 feature (WGS84)\n */\nexport function unionFeatures(aFeatures, bFeatures = []) {\n // Check each layer separately rather than the combined set: if only the\n // second layer had polygons, a combined check would pass and the first\n // layer's features would be dropped without the user being told.\n const gjA = toGeoJSONFeatures(aFeatures);\n const gjB = toGeoJSONFeatures(bFeatures);\n const polyA = polygonsOnly(gjA);\n const polyB = polygonsOnly(gjB);\n assertPolygons(polyA, gjA, 'Layer A');\n assertPolygons(polyB, gjB, 'Layer B'); // no-op when no second layer is given\n const all = [...polyA, ...polyB];\n if (all.length === 0) return fc([]);\n if (all.length === 1) return fc([all[0]]);\n\n let merged = null;\n try {\n merged = union(featureCollection(all));\n } catch {\n // Fall back to a pairwise fold if the bulk union chokes on bad geometry.\n merged = all[0];\n for (let i = 1; i < all.length; i++) {\n try {\n const next = union(featureCollection([merged, all[i]]));\n if (next && next.geometry) merged = next;\n } catch { /* skip the offending feature */ }\n }\n }\n if (!merged || !merged.geometry) return fc([]);\n merged.properties = { parts: all.length };\n return fc([merged]);\n}\n\n/** @private Raised only when genuinely overlapping work exceeds the budget. */\nfunction candidateOverflow(count) {\n return new Error(\n `This overlay has more than ${count.toLocaleString()} genuinely overlapping feature pairs, ` +\n `which is too much to run in the browser. Narrow the inputs using “Apply to” ` +\n `(current map view or selected features), or run it server-side.`\n );\n}\n\n/** Operation registry used by the Analysis panel. */\nexport const OVERLAY_OPS = [\n { key: 'intersect', label: 'Intersect', needsB: true,\n hint: 'Keep the parts of layer A that fall inside layer B (attributes from both).' },\n { key: 'clip', label: 'Clip', needsB: true,\n hint: 'Cut layer A to the boundary of layer B (keeps only A’s attributes).' },\n { key: 'difference', label: 'Difference', needsB: true,\n hint: 'Keep the parts of layer A that fall outside layer B.' },\n { key: 'union', label: 'Union / Dissolve', needsB: false,\n hint: 'Merge features into a single outline (optionally with a second layer).' },\n];\n\n/**\n * Run an overlay operation by key.\n * @param {string} op one of OVERLAY_OPS keys\n * @param {Array} aFeatures\n * @param {Array} bFeatures\n * @returns {Object} GeoJSON FeatureCollection (WGS84)\n */\nexport function runOverlay(op, aFeatures, bFeatures) {\n switch (op) {\n case 'intersect': return intersectFeatures(aFeatures, bFeatures);\n case 'clip': return clipFeatures(aFeatures, bFeatures);\n case 'difference': return differenceFeatures(aFeatures, bFeatures);\n case 'union': return unionFeatures(aFeatures, bFeatures);\n default: throw new Error(`Unknown overlay operation: ${op}`);\n }\n}\n","/**\n * zonal.js — vector-in-vector zonal statistics (Stage 1 of the LUPMIS2 GIS\n * Analytical Tools concept).\n *\n * Answers questions like \"how many parcels are in each collector zone?\" or\n * \"what is the mean plot size per zone?\" — the classic *summarize within*\n * operation. Raster × vector zonal statistics arrives with the raster pipeline\n * in Stage 2; this module is pure vector and runs fully offline.\n *\n * Membership rules:\n * 'centroid' — a feature belongs to the zone containing its centroid\n * (default; each feature counts exactly once)\n * 'intersects' — a feature belongs to every zone it touches\n * (features straddling a boundary are counted more than once)\n */\n\nimport booleanPointInPolygon from '@turf/boolean-point-in-polygon';\nimport booleanIntersects from '@turf/boolean-intersects';\nimport centroid from '@turf/centroid';\nimport area from '@turf/area';\nimport { toGeoJSONFeatures, bboxOf, bboxOverlaps, bboxContainsPoint } from './overlay.js';\n\n/**\n * Guard on *candidate* pairs only — a bounding-box pre-filter means a feature\n * is geometry-tested against a zone only when it could plausibly fall in it.\n * A district of 25,000 parcels across 40-odd zones produces roughly one\n * candidate per parcel, so real workloads sit far below this ceiling.\n */\nexport const MAX_ZONAL_CANDIDATES = 400000;\n\nexport const MEMBERSHIP_MODES = [\n { key: 'centroid', label: 'Centroid inside zone',\n hint: 'Each feature is counted once, in the zone containing its centre.' },\n { key: 'intersects', label: 'Any overlap with zone',\n hint: 'A feature is counted in every zone it touches (may double-count).' },\n];\n\n/** Statistics offered by the Analysis panel. */\nexport const STATS = [\n { key: 'count', label: 'Count', needsField: false },\n { key: 'sum', label: 'Sum', needsField: true },\n { key: 'mean', label: 'Mean', needsField: true },\n { key: 'min', label: 'Minimum', needsField: true },\n { key: 'max', label: 'Maximum', needsField: true },\n { key: 'area', label: 'Total area (m²)', needsField: false },\n];\n\n/**\n * List the numeric attribute names available across a set of OL features, so\n * the panel can offer only fields that can actually be summed or averaged.\n * @param {Array} features OL features\n * @returns {string[]}\n */\nexport function listNumericFields(features) {\n const counts = new Map();\n for (const f of features || []) {\n const props = f.getProperties ? f.getProperties() : {};\n for (const [k, v] of Object.entries(props)) {\n if (k === 'geometry' || k.startsWith('_')) continue;\n const n = typeof v === 'number' ? v : (v !== '' && v != null && !Number.isNaN(Number(v)) ? Number(v) : null);\n if (n === null) continue;\n counts.set(k, (counts.get(k) || 0) + 1);\n }\n }\n return Array.from(counts.keys()).sort();\n}\n\n/** Coerce a property to a finite number, or null when it isn't numeric. */\nfunction num(value) {\n if (value === null || value === undefined || value === '') return null;\n const n = typeof value === 'number' ? value : Number(value);\n return Number.isFinite(n) ? n : null;\n}\n\n/**\n * Compute zonal statistics.\n *\n * @param {Object} params\n * @param {Array} params.zoneFeatures OL features defining the zones (polygons)\n * @param {Array} params.inputFeatures OL features to summarise\n * @param {string[]} params.stats keys from STATS\n * @param {string} [params.field] numeric attribute (for sum/mean/min/max)\n * @param {string} [params.membership] 'centroid' | 'intersects'\n * @param {string} [params.prefix='zs_'] prefix for the new attribute names\n * @returns {{ geojson: Object, rows: Array }}\n * geojson — the zones with statistics attached (WGS84), ready to add as a layer\n * rows — a plain table of the same numbers, for display/export\n * warnings — figures that are valid but easy to misread (e.g. area over a\n * line layer); shown to the user rather than thrown\n */\nexport function zonalStatistics({\n zoneFeatures,\n inputFeatures,\n stats = ['count'],\n field = null,\n membership = 'centroid',\n prefix = 'zs_',\n}) {\n const zones = (toGeoJSONFeatures(zoneFeatures) || []).filter((z) => {\n const t = z?.geometry?.type;\n return t === 'Polygon' || t === 'MultiPolygon';\n });\n const inputs = toGeoJSONFeatures(inputFeatures) || [];\n\n if (zones.length === 0) throw new Error('The zone layer contains no polygons.');\n\n // Warnings are returned alongside the result rather than thrown: the run is\n // still valid and useful, but some figures would be silently meaningless\n // without an explanation.\n const warnings = [];\n const inputTypes = new Set(inputs.map((f) => f?.geometry?.type).filter(Boolean));\n const inputsHavePolygons = inputTypes.has('Polygon') || inputTypes.has('MultiPolygon');\n\n // Lines and points have no area, so \"Total area\" would report a silent 0.\n if (stats.includes('area') && !inputsHavePolygons && inputs.length > 0) {\n warnings.push(\n `Total area is 0 because the features being summarised are ` +\n `${[...inputTypes].join('/')} — only polygons have an area.`\n );\n }\n\n // Line inputs only. Averaging a field over line segments is not an\n // area-weighted average of the surface those lines describe — the classic\n // trap being \"mean elevation per parcel\" from contours, where segment count\n // has no relation to the area at each elevation. (Averaging over points is a\n // normal statistic, so it is not flagged.)\n const inputsAreLines = inputTypes.has('LineString') || inputTypes.has('MultiLineString');\n if (stats.includes('mean') && inputsAreLines && !inputsHavePolygons && field) {\n warnings.push(\n `Mean is the average of “${field}” across the individual line features in ` +\n `each zone, not an area-weighted average of the surface they describe. ` +\n `For contour data, Minimum and Maximum (the elevation range) are the ` +\n `figures you can rely on.`\n );\n }\n\n // Pre-compute once: each input's centroid (centroid mode) or bbox\n // (intersects mode), and every zone's bbox. The bbox comparisons below are\n // cheap arithmetic; only survivors reach the real geometry test.\n const centroids = membership === 'centroid'\n ? inputs.map((f) => { try { return centroid(f); } catch { return null; } })\n : null;\n const inputBoxes = membership === 'centroid' ? null : inputs.map(bboxOf);\n const zoneBoxes = zones.map(bboxOf);\n\n let candidates = 0;\n const rows = [];\n for (let z = 0; z < zones.length; z++) {\n const zone = zones[z];\n const zBox = zoneBoxes[z];\n const members = [];\n for (let i = 0; i < inputs.length; i++) {\n let inside = false;\n try {\n if (membership === 'centroid') {\n const c = centroids[i];\n if (!c) continue;\n const [cx, cy] = c.geometry.coordinates;\n if (!bboxContainsPoint(zBox, cx, cy)) continue; // cheap reject\n if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);\n inside = booleanPointInPolygon(c, zone);\n } else {\n if (!bboxOverlaps(zBox, inputBoxes[i])) continue; // cheap reject\n if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);\n inside = booleanIntersects(inputs[i], zone);\n }\n } catch { inside = false; }\n if (inside) members.push(inputs[i]);\n }\n\n const values = field\n ? members.map((f) => num(f.properties ? f.properties[field] : null)).filter((v) => v !== null)\n : [];\n\n const result = {};\n for (const s of stats) {\n switch (s) {\n case 'count':\n result[`${prefix}count`] = members.length; break;\n case 'sum':\n result[`${prefix}sum`] = values.length ? round(values.reduce((a, b) => a + b, 0)) : 0; break;\n case 'mean':\n result[`${prefix}mean`] = values.length\n ? round(values.reduce((a, b) => a + b, 0) / values.length) : null; break;\n case 'min':\n result[`${prefix}min`] = values.length ? round(Math.min(...values)) : null; break;\n case 'max':\n result[`${prefix}max`] = values.length ? round(Math.max(...values)) : null; break;\n case 'area': {\n let total = 0;\n for (const m of members) { try { total += area(m); } catch { /* skip */ } }\n result[`${prefix}area_m2`] = Math.round(total); break;\n }\n default: break;\n }\n }\n\n zone.properties = { ...(zone.properties || {}), ...result };\n rows.push({ ...labelOf(zone), ...result });\n }\n\n return { geojson: { type: 'FeatureCollection', features: zones }, rows, warnings };\n}\n\n/** @private Raised only when genuinely plausible pairs exceed the budget. */\nfunction candidateOverflow(count) {\n return new Error(\n `More than ${count.toLocaleString()} features fall within the zones' bounding boxes, ` +\n `which is too much to summarise in the browser. Narrow the inputs using “Apply to” ` +\n `(current map view or selected features).`\n );\n}\n\n/** Round to 3 decimals to keep attribute tables readable. */\nfunction round(n) {\n return Math.round(n * 1000) / 1000;\n}\n\n/**\n * Pick a human label for a zone row — first of the usual naming fields,\n * else a generic index.\n */\nfunction labelOf(zone) {\n const p = zone.properties || {};\n for (const k of ['zone_name', 'name', 'ZONE_NAME', 'NAME', 'upn', 'UPN', 'id', 'ID']) {\n if (p[k] !== undefined && p[k] !== null && String(p[k]).trim() !== '') {\n return { zone: String(p[k]) };\n }\n }\n return { zone: '(unnamed)' };\n}\n","/**\n * Analysis panel controller — Stage 1 of the GIS Analytical Tools concept.\n *\n * openAnalysisModal(mapView)\n *\n * Drives two families of client-side analysis:\n * • Overlays — intersect / clip / difference / union between layers\n * • Zonal statistics — summarise features (or a numeric field) per zone\n *\n * Both run entirely in the browser via Turf.js, so they work offline. Results\n * are added to the map as a new styled layer in an \"Analysis\" group, from\n * where they can be exported with the existing GIS export tools.\n *\n * Same Bootstrap modal pattern as src/export-gis-modal.js and src/import-modal.js.\n */\n\nimport { Modal } from 'bootstrap';\nimport { runOverlay, OVERLAY_OPS } from './analysis/overlay.js';\nimport { zonalStatistics, listNumericFields, STATS, MEMBERSHIP_MODES } from './analysis/zonal.js';\nimport { showToast } from './toast.js';\n\nconst ANALYSIS_GROUP_ID = 9001;\nconst ANALYSIS_GROUP_TITLE = 'Analysis';\n\nconst els = {};\nlet modal = null;\nlet mapView = null;\nlet layerChoices = []; // [{ layer, title, count }]\nlet drawnScope = null; // { features: Set, label } when opened from a Circle/Area popup\nlet lastZonal = null; // { rows, params } — the most recent zonal run, for export\n\n// ---------------------------------------------------------------------------\n// Element wiring\n// ---------------------------------------------------------------------------\n\nfunction cacheEls() {\n if (els.root) return;\n els.root = document.getElementById('analysisModal');\n els.mode = Array.from(document.querySelectorAll('input[name=\"analysis-mode\"]'));\n els.overlayBox = document.getElementById('analysis-overlay-box');\n els.zonalBox = document.getElementById('analysis-zonal-box');\n els.op = document.getElementById('analysis-op');\n els.opHint = document.getElementById('analysis-op-hint');\n els.layerA = document.getElementById('analysis-layer-a');\n els.layerB = document.getElementById('analysis-layer-b');\n els.layerBWrap = document.getElementById('analysis-layer-b-wrap');\n els.zoneLayer = document.getElementById('analysis-zone-layer');\n els.inputLayer = document.getElementById('analysis-input-layer');\n els.membership = document.getElementById('analysis-membership');\n els.memberHint = document.getElementById('analysis-membership-hint');\n els.field = document.getElementById('analysis-field');\n els.statsBox = document.getElementById('analysis-stats-box');\n els.outName = document.getElementById('analysis-output-name');\n els.result = document.getElementById('analysis-result');\n els.btnRun = document.getElementById('analysis-run');\n els.btnExport = document.getElementById('analysis-export-table');\n els.scope = document.getElementById('analysis-scope');\n els.scopeHint = document.getElementById('analysis-scope-hint');\n\n if (!els.root.dataset.wired) {\n els.root.dataset.wired = '1';\n els.mode.forEach((r) => r.addEventListener('change', onModeChange));\n els.op.addEventListener('change', onOpChange);\n els.membership.addEventListener('change', onMembershipChange);\n els.inputLayer.addEventListener('change', refreshFieldOptions);\n els.scope.addEventListener('change', onScopeChange);\n els.btnRun.addEventListener('click', onRun);\n els.btnExport.addEventListener('click', onExportTable);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Population\n// ---------------------------------------------------------------------------\n\nfunction layerOptionsHtml(selectedIdx = null) {\n return layerChoices.map((c, i) =>\n ``\n ).join('');\n}\n\nfunction populateOps() {\n els.op.innerHTML = OVERLAY_OPS\n .map((o) => ``).join('');\n els.membership.innerHTML = MEMBERSHIP_MODES\n .map((m) => ``).join('');\n els.statsBox.innerHTML = STATS.map((s) => `\n
\n \n \n
`).join('');\n}\n\nfunction refreshFieldOptions() {\n const choice = layerChoices[Number(els.inputLayer.value)];\n const feats = choice ? choice.layer.getSource().getFeatures() : [];\n const fields = listNumericFields(feats);\n els.field.innerHTML = '' +\n fields.map((f) => ``).join('');\n els.field.disabled = fields.length === 0;\n}\n\n// ---------------------------------------------------------------------------\n// UI reactions\n// ---------------------------------------------------------------------------\n\nfunction currentMode() {\n return (els.mode.find((r) => r.checked) || {}).value || 'overlay';\n}\n\nfunction onModeChange() {\n // A previous zonal table doesn't belong to a new overlay run.\n lastZonal = null; syncExportButton();\n const overlay = currentMode() === 'overlay';\n els.overlayBox.classList.toggle('d-none', !overlay);\n els.zonalBox.classList.toggle('d-none', overlay);\n suggestName();\n}\n\nfunction onOpChange() {\n const op = OVERLAY_OPS.find((o) => o.key === els.op.value);\n els.opHint.textContent = op ? op.hint : '';\n // Union can run on a single layer; the others need a second one.\n const needsB = op ? op.needsB : true;\n els.layerBWrap.querySelector('label').textContent =\n needsB ? 'Layer B' : 'Layer B (optional)';\n suggestName();\n}\n\nfunction onMembershipChange() {\n const m = MEMBERSHIP_MODES.find((x) => x.key === els.membership.value);\n els.memberHint.textContent = m ? m.hint : '';\n}\n\nfunction suggestName() {\n if (els.outName.dataset.touched === '1') return;\n if (currentMode() === 'overlay') {\n const op = els.op.value || 'overlay';\n els.outName.value = `${op}_result`;\n } else {\n els.outName.value = 'zonal_stats';\n }\n}\n\n// ---------------------------------------------------------------------------\n// Run\n// ---------------------------------------------------------------------------\n\n/**\n * Features of the chosen layer, narrowed by the \"Apply to\" scope.\n * 'view' uses the source's spatial index, so it stays fast on large layers.\n */\nfunction featuresOf(select) {\n const choice = layerChoices[Number(select.value)];\n if (!choice) return [];\n const source = choice.layer.getSource();\n const scope = els.scope ? els.scope.value : 'all';\n\n if (scope === 'view') {\n const map = mapView.getMap();\n const extent = map.getView().calculateExtent(map.getSize());\n return source.getFeaturesInExtent(extent);\n }\n if (scope === 'selection') {\n const selected = new Set(mapView.getSelectedFeatures());\n return source.getFeatures().filter((f) => selected.has(f));\n }\n if (scope === 'drawn' && drawnScope) {\n return source.getFeatures().filter((f) => drawnScope.features.has(f));\n }\n return source.getFeatures();\n}\n\nfunction onScopeChange() {\n const scope = els.scope.value;\n if (scope === 'drawn' && drawnScope) {\n els.scopeHint.textContent =\n `${drawnScope.features.size.toLocaleString()} feature(s) caught by the ${drawnScope.label}.`;\n } else if (scope === 'selection') {\n const n = mapView ? mapView.getSelectedFeatures().length : 0;\n els.scopeHint.textContent = n === 0\n ? 'Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.'\n : `${n.toLocaleString()} feature(s) currently selected.`;\n } else if (scope === 'view') {\n els.scopeHint.textContent = 'Only features inside the current map view are analysed — zoom to the area of interest first.';\n } else {\n els.scopeHint.textContent = 'Every feature in the chosen layer. Narrow this if a large layer is slow.';\n }\n}\n\nfunction ensureAnalysisGroup() {\n let group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n if (!group) {\n mapView.addLayerGroup(ANALYSIS_GROUP_ID, ANALYSIS_GROUP_TITLE,\n 'Results of client-side spatial analysis');\n group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n }\n return group;\n}\n\nfunction onRun() {\n els.result.innerHTML = '';\n els.btnRun.disabled = true;\n // Yield a frame so the button state paints before a heavy synchronous run.\n setTimeout(() => {\n try {\n const mode = currentMode();\n const name = (els.outName.value || '').trim() ||\n (mode === 'overlay' ? 'overlay_result' : 'zonal_stats');\n\n let geojson, summary;\n let zonalWarnings = [];\n if (mode === 'overlay') {\n lastZonal = null;\n const op = els.op.value;\n const a = featuresOf(els.layerA);\n const b = featuresOf(els.layerB);\n const needsB = (OVERLAY_OPS.find((o) => o.key === op) || {}).needsB;\n if (a.length === 0) throw new Error('Layer A has no features.');\n if (needsB && b.length === 0) throw new Error('This operation needs a second layer with features.');\n geojson = runOverlay(op, a, b);\n summary = `${geojson.features.length.toLocaleString()} feature(s) produced.`;\n } else {\n const zones = featuresOf(els.zoneLayer);\n const inputs = featuresOf(els.inputLayer);\n const stats = Array.from(els.statsBox.querySelectorAll('input:checked')).map((i) => i.value);\n const field = els.field.value || null;\n if (stats.length === 0) throw new Error('Select at least one statistic.');\n const needsField = stats.some((s) => (STATS.find((x) => x.key === s) || {}).needsField);\n if (needsField && !field) throw new Error('Sum / mean / min / max need a numeric field.');\n\n const res = zonalStatistics({\n zoneFeatures: zones, inputFeatures: inputs,\n stats, field, membership: els.membership.value,\n });\n geojson = res.geojson;\n zonalWarnings = res.warnings || [];\n summary = renderRows(res.rows);\n\n // Keep the table AND the settings that produced it, so the Excel export\n // is self-describing — a table of numbers with no record of how it was\n // produced can't be checked or reproduced later.\n const membershipLabel = (MEMBERSHIP_MODES.find((m) => m.key === els.membership.value) || {}).label;\n lastZonal = {\n rows: res.rows,\n warnings: res.warnings || [],\n params: {\n 'Analysis': 'Zonal statistics',\n 'Zone layer': labelOfSelect(els.zoneLayer),\n 'Features summarised': labelOfSelect(els.inputLayer),\n 'Applied to': scopeLabel(),\n 'Zones analysed': zones.length,\n 'Features analysed': inputs.length,\n 'Counted when': membershipLabel || els.membership.value,\n 'Statistics': stats.map((k) => (STATS.find((x) => x.key === k) || {}).label || k).join(', '),\n 'Numeric field': field || '(none)',\n 'Result layer': name,\n },\n };\n }\n\n if (!geojson.features.length) {\n els.result.innerHTML =\n `
No result features — the layers may not overlap.
`;\n return;\n }\n\n const group = ensureAnalysisGroup();\n mapView.addGeoJSONLayer(geojson, name, {\n strokeColor: '#d97706', strokeWidth: 2, fillColor: 'rgba(217,119,6,0.18)',\n }, group);\n\n syncExportButton();\n const warnHtml = zonalWarnings.length\n ? `
` +\n zonalWarnings.map((w) => `
${escapeHtml(w)}
`).join('') + `
`\n : '';\n els.result.innerHTML =\n `
Added layer “${escapeHtml(name)}”.
` +\n warnHtml +\n (typeof summary === 'string' && summary.startsWith('<') ? summary : `
${escapeHtml(summary)}
`);\n showToast(`Analysis complete — “${name}” added to the Analysis group.`, 'success', 4000);\n } catch (err) {\n console.error('[Analysis] run failed:', err);\n els.result.innerHTML =\n `
${escapeHtml(err.message || String(err))}
`;\n } finally {\n els.btnRun.disabled = false;\n }\n }, 20);\n}\n\n/** Human label of a layer \n \n `).join('');\n}\n\nfunction refreshFieldOptions() {\n const choice = layerChoices[Number(els.inputLayer.value)];\n const feats = choice ? choice.layer.getSource().getFeatures() : [];\n const fields = listNumericFields(feats);\n els.field.innerHTML = '' +\n fields.map((f) => ``).join('');\n els.field.disabled = fields.length === 0;\n}\n\n// ---------------------------------------------------------------------------\n// UI reactions\n// ---------------------------------------------------------------------------\n\nfunction currentMode() {\n return (els.mode.find((r) => r.checked) || {}).value || 'overlay';\n}\n\nfunction onModeChange() {\n // A previous zonal table doesn't belong to a new overlay run.\n lastZonal = null; syncExportButton();\n const overlay = currentMode() === 'overlay';\n els.overlayBox.classList.toggle('d-none', !overlay);\n els.zonalBox.classList.toggle('d-none', overlay);\n suggestName();\n}\n\nfunction onOpChange() {\n const op = OVERLAY_OPS.find((o) => o.key === els.op.value);\n els.opHint.textContent = op ? op.hint : '';\n // Union can run on a single layer; the others need a second one.\n const needsB = op ? op.needsB : true;\n els.layerBWrap.querySelector('label').textContent =\n needsB ? 'Layer B' : 'Layer B (optional)';\n suggestName();\n}\n\nfunction onMembershipChange() {\n const m = MEMBERSHIP_MODES.find((x) => x.key === els.membership.value);\n els.memberHint.textContent = m ? m.hint : '';\n}\n\nfunction suggestName() {\n if (els.outName.dataset.touched === '1') return;\n if (currentMode() === 'overlay') {\n const op = els.op.value || 'overlay';\n els.outName.value = `${op}_result`;\n } else {\n els.outName.value = 'zonal_stats';\n }\n}\n\n// ---------------------------------------------------------------------------\n// Run\n// ---------------------------------------------------------------------------\n\n/**\n * Features of the chosen layer, narrowed by the \"Apply to\" scope.\n * 'view' uses the source's spatial index, so it stays fast on large layers.\n */\nfunction featuresOf(select) {\n const choice = layerChoices[Number(select.value)];\n if (!choice) return [];\n const source = choice.layer.getSource();\n const scope = els.scope ? els.scope.value : 'all';\n\n if (scope === 'view') {\n const map = mapView.getMap();\n const extent = map.getView().calculateExtent(map.getSize());\n return source.getFeaturesInExtent(extent);\n }\n if (scope === 'selection') {\n const selected = new Set(mapView.getSelectedFeatures());\n return source.getFeatures().filter((f) => selected.has(f));\n }\n if (scope === 'drawn' && drawnScope) {\n return source.getFeatures().filter((f) => drawnScope.features.has(f));\n }\n return source.getFeatures();\n}\n\nfunction onScopeChange() {\n const scope = els.scope.value;\n if (scope === 'drawn' && drawnScope) {\n els.scopeHint.textContent =\n `${drawnScope.features.size.toLocaleString()} feature(s) caught by the ${drawnScope.label}.`;\n } else if (scope === 'selection') {\n const n = mapView ? mapView.getSelectedFeatures().length : 0;\n els.scopeHint.textContent = n === 0\n ? 'Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.'\n : `${n.toLocaleString()} feature(s) currently selected.`;\n } else if (scope === 'view') {\n els.scopeHint.textContent = 'Only features inside the current map view are analysed — zoom to the area of interest first.';\n } else {\n els.scopeHint.textContent = 'Every feature in the chosen layer. Narrow this if a large layer is slow.';\n }\n}\n\nfunction ensureAnalysisGroup() {\n let group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n if (!group) {\n mapView.addLayerGroup(ANALYSIS_GROUP_ID, ANALYSIS_GROUP_TITLE,\n 'Results of client-side spatial analysis');\n group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n }\n return group;\n}\n\nfunction onRun() {\n els.result.innerHTML = '';\n els.btnRun.disabled = true;\n // Yield a frame so the button state paints before a heavy synchronous run.\n setTimeout(() => {\n try {\n const mode = currentMode();\n const name = (els.outName.value || '').trim() ||\n (mode === 'overlay' ? 'overlay_result' : 'zonal_stats');\n\n let geojson, summary;\n let zonalWarnings = [];\n if (mode === 'overlay') {\n lastZonal = null;\n const op = els.op.value;\n const a = featuresOf(els.layerA);\n const b = featuresOf(els.layerB);\n const needsB = (OVERLAY_OPS.find((o) => o.key === op) || {}).needsB;\n if (a.length === 0) throw new Error('Layer A has no features.');\n if (needsB && b.length === 0) throw new Error('This operation needs a second layer with features.');\n geojson = runOverlay(op, a, b);\n summary = `${geojson.features.length.toLocaleString()} feature(s) produced.`;\n } else {\n const zones = featuresOf(els.zoneLayer);\n const inputs = featuresOf(els.inputLayer);\n const stats = Array.from(els.statsBox.querySelectorAll('input:checked')).map((i) => i.value);\n const field = els.field.value || null;\n if (stats.length === 0) throw new Error('Select at least one statistic.');\n const needsField = stats.some((s) => (STATS.find((x) => x.key === s) || {}).needsField);\n if (needsField && !field) throw new Error('Sum / mean / min / max need a numeric field.');\n\n const res = zonalStatistics({\n zoneFeatures: zones, inputFeatures: inputs,\n stats, field, membership: els.membership.value,\n });\n geojson = res.geojson;\n zonalWarnings = res.warnings || [];\n summary = renderRows(res.rows);\n\n // Keep the table AND the settings that produced it, so the Excel export\n // is self-describing — a table of numbers with no record of how it was\n // produced can't be checked or reproduced later.\n const membershipLabel = (MEMBERSHIP_MODES.find((m) => m.key === els.membership.value) || {}).label;\n lastZonal = {\n rows: res.rows,\n warnings: res.warnings || [],\n params: {\n 'Analysis': 'Zonal statistics',\n 'Zone layer': labelOfSelect(els.zoneLayer),\n 'Features summarised': labelOfSelect(els.inputLayer),\n 'Applied to': scopeLabel(),\n 'Zones analysed': zones.length,\n 'Features analysed': inputs.length,\n 'Counted when': membershipLabel || els.membership.value,\n 'Statistics': stats.map((k) => (STATS.find((x) => x.key === k) || {}).label || k).join(', '),\n 'Numeric field': field || '(none)',\n 'Result layer': name,\n },\n };\n }\n\n if (!geojson.features.length) {\n els.result.innerHTML =\n `
No result features — the layers may not overlap.
`;\n return;\n }\n\n const group = ensureAnalysisGroup();\n mapView.addGeoJSONLayer(geojson, name, {\n strokeColor: '#d97706', strokeWidth: 2, fillColor: 'rgba(217,119,6,0.18)',\n }, group);\n\n syncExportButton();\n const warnHtml = zonalWarnings.length\n ? `
` +\n zonalWarnings.map((w) => `
${escapeHtml(w)}
`).join('') + `
`\n : '';\n els.result.innerHTML =\n `
Added layer “${escapeHtml(name)}”.
` +\n warnHtml +\n (typeof summary === 'string' && summary.startsWith('<') ? summary : `
${escapeHtml(summary)}
`);\n showToast(`Analysis complete — “${name}” added to the Analysis group.`, 'success', 4000);\n } catch (err) {\n console.error('[Analysis] run failed:', err);\n els.result.innerHTML =\n `
${escapeHtml(err.message || String(err))}
`;\n } finally {\n els.btnRun.disabled = false;\n }\n }, 20);\n}\n\n/** Human label of a layer \n \n `;\n }\n\n const html = `\n
\n ✏️ Edit Parcel\n \n
\n
\n ${fieldsHtml}\n
\n \n \n
\n
\n `;\n\n this.parcelEditElement.innerHTML = html;\n this.parcelEditPopup.setPosition(coordinate);\n\n // Close / Cancel handlers\n this.parcelEditElement.querySelector('.parcel-edit-close').addEventListener('click', () => {\n this.hideParcelEditPopup();\n });\n this.parcelEditElement.querySelector('.parcel-edit-cancel').addEventListener('click', () => {\n this.hideParcelEditPopup();\n });\n\n // Form submit handler\n const form = this.parcelEditElement.querySelector('.parcel-edit-form');\n form.addEventListener('submit', (e) => {\n e.preventDefault();\n\n // Collect all edited values\n const formData = new FormData(form);\n const updatedProps = {};\n for (const [key, value] of formData.entries()) {\n updatedProps[key] = value;\n }\n\n // Restore internal properties that were excluded from the form\n updatedProps._layerType = 'parcel';\n\n // Update the feature's properties in-place\n for (const [key, value] of Object.entries(updatedProps)) {\n this._parcelEditFeature.set(key, value);\n }\n\n // Notify external listeners\n for (const cb of this._parcelEditCallbacks) {\n cb(this._parcelEditFeature, updatedProps);\n }\n\n this.hideParcelEditPopup();\n });\n }\n\n /**\n * Hide the parcel edit popup.\n */\n hideParcelEditPopup() {\n this.parcelEditPopup.setPosition(undefined);\n this._parcelEditFeature = null;\n }\n\n /**\n * Register a callback for when a parcel edit is saved.\n * Callback receives (feature, updatedProperties).\n *\n * @param {Function} callback\n */\n onParcelEdit(callback) {\n this._parcelEditCallbacks.push(callback);\n }\n\n // ============================================================================\n // Merge Identifier (UPN) Chooser Popup\n // ============================================================================\n\n /**\n * Create the merge identifier popup overlay.\n * Shown after two parcels are merged so the user can choose which UPN to keep.\n */\n createMergePopup() {\n this.mergePopupElement = document.createElement('div');\n this.mergePopupElement.className = 'map-merge-popup';\n this.mergePopupElement.style.cssText = `\n position: absolute;\n background: var(--card, #fff);\n color: var(--card-foreground, #1e1a4b);\n border-radius: 10px;\n box-shadow: 0 4px 20px rgba(0,0,0,0.3);\n font-family: var(--font-body, 'Exo', sans-serif);\n font-size: 13px;\n min-width: 280px;\n max-width: 360px;\n z-index: 1002;\n border: 2px solid #10b981;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n `;\n\n this.mergePopup = new Overlay({\n element: this.mergePopupElement,\n positioning: 'bottom-center',\n offset: [0, -10],\n stopEvent: true,\n autoPan: true,\n autoPanAnimation: { duration: 250 },\n });\n\n this.map.addOverlay(this.mergePopup);\n }\n\n /**\n * Show the merge identifier popup so the user can pick which parcel's\n * attributes (including UPN) the merged polygon should inherit.\n *\n * @param {Feature} mergedFeature The newly created merged feature\n * @param {Object} propsA Properties from original parcel A\n * @param {Object} propsB Properties from original parcel B\n * @param {Array} coordinate Map coordinate [x, y] for popup placement\n */\n showMergeIdentifierPopup(mergedFeature, propsA, propsB, coordinate) {\n // Extract identifiers — try common parcel ID field names\n const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];\n const getLabel = (props) => {\n for (const field of idFields) {\n if (props[field] !== undefined && props[field] !== null && String(props[field]).trim()) {\n return { field, value: String(props[field]) };\n }\n }\n return { field: 'id', value: 'Unknown' };\n };\n\n const labelA = getLabel(propsA);\n const labelB = getLabel(propsB);\n\n const html = `\n
\n 🔗 Merged Parcel — Choose Identifier\n \n
\n
\n

\n Select which parcel's attributes the merged polygon should keep:\n

\n \n \n
\n \n \n
\n
\n `;\n\n this.mergePopupElement.innerHTML = html;\n this.mergePopup.setPosition(coordinate);\n\n // Close / Cancel — keep parcel A properties (the default from clone)\n const close = () => {\n this.mergePopup.setPosition(undefined);\n };\n this.mergePopupElement.querySelector('.merge-popup-close').addEventListener('click', close);\n this.mergePopupElement.querySelector('.merge-popup-cancel').addEventListener('click', close);\n\n // Confirm — apply chosen parcel's properties\n this.mergePopupElement.querySelector('.merge-popup-confirm').addEventListener('click', () => {\n const choice = this.mergePopupElement.querySelector('input[name=\"merge-choice\"]:checked').value;\n const chosenProps = choice === 'A' ? propsA : propsB;\n\n // Copy all properties (except geometry) onto the merged feature\n const skipKeys = ['geometry'];\n for (const [key, value] of Object.entries(chosenProps)) {\n if (skipKeys.includes(key)) continue;\n mergedFeature.set(key, value);\n }\n // Ensure _layerType is preserved\n mergedFeature.set('_layerType', 'parcel');\n\n // Notify parcel edit callbacks\n for (const cb of this._parcelEditCallbacks) {\n cb(mergedFeature, chosenProps);\n }\n\n close();\n });\n\n // Highlight radio labels on selection\n const labels = this.mergePopupElement.querySelectorAll('label');\n const radios = this.mergePopupElement.querySelectorAll('input[name=\"merge-choice\"]');\n const updateHighlight = () => {\n labels.forEach((lbl) => {\n const radio = lbl.querySelector('input');\n lbl.style.borderColor = radio.checked ? (radio.value === 'A' ? '#0ea5e9' : '#f59e0b') : 'var(--border, #1e1a4b1f)';\n });\n };\n radios.forEach((r) => r.addEventListener('change', updateHighlight));\n updateHighlight();\n }\n\n // ============================================================================\n // Divide Polygon Popup (number input)\n // ============================================================================\n\n /**\n * Create the divide polygon popup overlay.\n * Shown after the user selects a polygon with the Divide tool, so they\n * can enter the number of equal pieces.\n */\n createDividePopup() {\n this.dividePopupElement = document.createElement('div');\n this.dividePopupElement.className = 'map-divide-popup';\n this.dividePopupElement.style.cssText = `\n position: absolute;\n background: var(--card, #fff);\n color: var(--card-foreground, #1e1a4b);\n border-radius: 10px;\n box-shadow: 0 4px 20px rgba(0,0,0,0.3);\n font-family: var(--font-body, 'Exo', sans-serif);\n font-size: 13px;\n min-width: 260px;\n max-width: 320px;\n z-index: 1002;\n border: 2px solid #8b5cf6;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n `;\n\n this.dividePopup = new Overlay({\n element: this.dividePopupElement,\n positioning: 'bottom-center',\n offset: [0, -10],\n stopEvent: true,\n autoPan: true,\n autoPanAnimation: { duration: 250 },\n });\n\n this.map.addOverlay(this.dividePopup);\n }\n\n /**\n * Show the divide popup so the user can enter the number of divisions.\n *\n * @param {Feature} feature The selected polygon feature\n * @param {VectorSource} source The source containing the feature\n * @param {Array} coordinate Map coordinate [x, y] for popup placement\n */\n showDividePopup(feature, source, coordinate) {\n const html = `\n
\n Divide Polygon\n \n
\n
\n

\n Enter the number of equal pieces:\n

\n \n
\n \n \n
\n
\n `;\n\n this.dividePopupElement.innerHTML = html;\n this.dividePopup.setPosition(coordinate);\n\n const input = this.dividePopupElement.querySelector('.divide-input');\n input.focus();\n input.select();\n\n // Close / Cancel\n const cancel = () => {\n this.hideDividePopup();\n this._polygonDivideInteraction.cancelDivide();\n };\n this.dividePopupElement.querySelector('.divide-popup-close').addEventListener('click', cancel);\n this.dividePopupElement.querySelector('.divide-popup-cancel').addEventListener('click', cancel);\n\n // Confirm\n this.dividePopupElement.querySelector('.divide-popup-confirm').addEventListener('click', () => {\n const n = parseInt(input.value, 10);\n if (!n || n < 2) {\n input.style.borderColor = '#ef4444';\n return;\n }\n this.hideDividePopup();\n this._polygonDivideInteraction.performDivide(n);\n });\n\n // Allow Enter key to confirm\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') {\n e.preventDefault();\n this.dividePopupElement.querySelector('.divide-popup-confirm').click();\n }\n });\n }\n\n /**\n * Hide the divide popup.\n */\n hideDividePopup() {\n this.dividePopup.setPosition(undefined);\n }\n\n // ============================================================================\n // Drawn Polygon Attribute Popup\n // ============================================================================\n\n /**\n * Create the drawn polygon attribute popup overlay.\n * Shown after the area measurement polygon is completed so the user can\n * attach parcel-like attributes to the drawn polygon.\n */\n createDrawnPolygonPopup() {\n this.drawnPolygonElement = document.createElement('div');\n this.drawnPolygonElement.className = 'map-drawn-polygon-popup';\n this.drawnPolygonElement.style.cssText = `\n position: absolute;\n background: var(--card, #fff);\n border-radius: var(--radius-xl, 0.75rem);\n box-shadow: 0 4px 20px rgba(0,0,0,0.2);\n font-family: var(--font-body, 'Exo', sans-serif);\n font-size: 13px;\n min-width: 280px;\n max-width: 360px;\n max-height: 420px;\n z-index: 1002;\n border: 2px solid var(--success, #006b3f);\n overflow: hidden;\n display: flex;\n flex-direction: column;\n `;\n\n this.drawnPolygonPopup = new Overlay({\n element: this.drawnPolygonElement,\n positioning: 'bottom-center',\n offset: [0, -10],\n stopEvent: true,\n autoPan: true,\n autoPanAnimation: { duration: 250 },\n });\n\n this.map.addOverlay(this.drawnPolygonPopup);\n this._drawnPolygonCallbacks = [];\n this._drawnPolygonFeature = null;\n }\n\n /**\n * Get attribute keys from existing parcel features on the map.\n * Scans the overlay group for the first feature with _layerType='parcel'\n * and returns its property key names (excluding internal keys).\n *\n * @returns {string[]} Array of attribute key names\n */\n getParcelAttributeKeys() {\n const skipKeys = ['geometry', '_layerType'];\n const keys = [];\n\n const scanGroup = (group) => {\n if (keys.length > 0) return;\n group.getLayers().forEach((layer) => {\n if (keys.length > 0) return;\n if (layer instanceof LayerGroup) {\n scanGroup(layer);\n } else if (layer instanceof VectorLayer) {\n const source = layer.getSource();\n if (!source) return;\n for (const f of source.getFeatures()) {\n if (f.get('_layerType') !== 'parcel') continue;\n const props = f.getProperties();\n for (const key of Object.keys(props)) {\n if (!skipKeys.includes(key)) keys.push(key);\n }\n return; // one parcel is enough for the schema\n }\n }\n });\n };\n\n scanGroup(this.overlayGroup);\n return keys;\n }\n\n /**\n * Show the drawn polygon attribute popup.\n * Discovers attribute keys from existing parcel features and creates\n * a blank form with those fields.\n *\n * @param {Feature} feature - The drawn polygon feature\n * @param {Array} coordinate - Map coordinate [x, y] for popup placement\n */\n showDrawnPolygonPopup(feature, coordinate) {\n this._drawnPolygonFeature = feature;\n\n // Discover attribute keys from existing parcels\n const attributeKeys = this.getParcelAttributeKeys();\n\n if (attributeKeys.length === 0) {\n console.warn('[MapView] No parcel attributes found — cannot build form');\n return;\n }\n\n // Build form fields (all blank)\n let fieldsHtml = '';\n for (const key of attributeKeys) {\n const escapedKey = this.escapeHtml(key);\n fieldsHtml += `\n
\n \n \n
\n `;\n }\n\n // Area display\n const geom = feature.getGeometry();\n const areaSqm = getArea(geom, { projection: 'EPSG:3857' });\n const areaFormatted = formatArea(areaSqm);\n\n const html = `\n
\n 📐 Polygon Attributes\n \n
\n
\n Area: ${areaFormatted}\n
\n
\n ${fieldsHtml}\n
\n \n \n
\n
\n `;\n\n this.drawnPolygonElement.innerHTML = html;\n this.drawnPolygonPopup.setPosition(coordinate);\n\n // Close / Cancel handlers\n this.drawnPolygonElement.querySelector('.drawn-polygon-close').addEventListener('click', () => {\n this.hideDrawnPolygonPopup();\n });\n this.drawnPolygonElement.querySelector('.drawn-polygon-cancel').addEventListener('click', () => {\n this.hideDrawnPolygonPopup();\n });\n\n // Form submit handler\n const form = this.drawnPolygonElement.querySelector('.drawn-polygon-form');\n form.addEventListener('submit', (e) => {\n e.preventDefault();\n\n const formData = new FormData(form);\n const props = {};\n for (const [key, value] of formData.entries()) {\n props[key] = value;\n }\n\n // Set properties on the feature\n for (const [key, value] of Object.entries(props)) {\n this._drawnPolygonFeature.set(key, value);\n }\n\n // Tag as parcel so it integrates with existing parcel tools\n this._drawnPolygonFeature.set('_layerType', 'parcel');\n\n // Notify listeners\n for (const cb of this._drawnPolygonCallbacks) {\n cb(this._drawnPolygonFeature, props);\n }\n\n this.hideDrawnPolygonPopup();\n });\n }\n\n /**\n * Hide the drawn polygon attribute popup.\n */\n hideDrawnPolygonPopup() {\n this.drawnPolygonPopup.setPosition(undefined);\n this._drawnPolygonFeature = null;\n }\n\n /**\n * Register a callback for when drawn polygon attributes are saved.\n * Callback receives (feature, properties).\n *\n * @param {Function} callback\n */\n onDrawnPolygonSave(callback) {\n this._drawnPolygonCallbacks.push(callback);\n }\n\n /**\n * Register a callback fired after the user finishes modifying a feature\n * with the EditBar Modify interaction. Callback receives the OL feature\n * whose geometry just changed. Consumers (e.g. the import-staging code in\n * main.js) inspect the feature's tags (_externalImportId / _clientUuid)\n * to decide whether to react. Multiple callbacks are supported.\n *\n * @param {Function} callback - (feature) => void | Promise\n */\n onFeatureModified(callback) {\n if (!this._featureModifiedCallbacks) this._featureModifiedCallbacks = [];\n this._featureModifiedCallbacks.push(callback);\n }\n\n /**\n * Register a callback fired when the officer places a point with the\n * Draw ▸ Point tool. Callback receives the map coordinate; the sketch itself\n * has already been discarded, so the callback owns what happens next — in\n * this application, opening the Add Location form.\n *\n * @param {Function} callback - (coordinate) => void\n */\n onDrawnPoint(callback) {\n if (!this._drawnPointCallbacks) this._drawnPointCallbacks = [];\n this._drawnPointCallbacks.push(callback);\n }\n\n /**\n * Register a double-click callback.\n * Callback receives (lon, lat, feature, event).\n * Feature is the first feature found at the click pixel across all overlay layers,\n * or null if no feature was hit.\n * When a feature is hit, the default double-click-zoom is suppressed.\n */\n onDblClick(callback) {\n this.dblClickCallbacks.push(callback);\n\n // Set up the listener once\n if (this.dblClickCallbacks.length === 1) {\n this.map.on('dblclick', (evt) => {\n const [lon, lat] = toLonLat(evt.coordinate);\n\n // Find any feature at the clicked pixel (overlay layers, not just markers)\n let clickedFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n clickedFeature = feature;\n return true; // stop at first hit\n });\n\n // If a feature was hit, prevent the default double-click zoom\n if (clickedFeature) {\n evt.preventDefault();\n evt.stopPropagation();\n }\n\n // Call all registered callbacks\n for (const cb of this.dblClickCallbacks) {\n cb(lon, lat, clickedFeature, evt);\n }\n\n // Return false to suppress DoubleClickZoom interaction when on a feature\n if (clickedFeature) return false;\n });\n }\n\n return () => {\n const idx = this.dblClickCallbacks.indexOf(callback);\n if (idx > -1) this.dblClickCallbacks.splice(idx, 1);\n };\n }\n\n /**\n * Escape HTML to prevent XSS\n */\n escapeHtml(text) {\n if (!text) return '';\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Create the Add Location popup form overlay\n */\n createAddLocationPopup() {\n // Create popup container element\n this.addLocationPopupElement = document.createElement('div');\n this.addLocationPopupElement.className = 'map-add-location-popup';\n this.addLocationPopupElement.innerHTML = `\n
\n ➕ Add Location\n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n 📍 \n
\n \n
\n `;\n\n // Create the overlay\n this.addLocationPopup = new Overlay({\n element: this.addLocationPopupElement,\n positioning: 'bottom-center',\n offset: [0, -10],\n stopEvent: true, // Prevent click from propagating\n autoPan: true,\n autoPanAnimation: {\n duration: 250,\n },\n });\n\n this.map.addOverlay(this.addLocationPopup);\n\n // Store clicked coordinates\n this.addLocationCoords = null;\n\n // Set up close button handler\n const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');\n closeBtn.addEventListener('click', () => {\n this.hideAddLocationPopup();\n });\n\n // Store form submit callbacks\n this.addLocationCallbacks = [];\n }\n\n /**\n * Show the Add Location popup at the specified coordinate\n */\n showAddLocationPopup(coordinate) {\n const [lon, lat] = toLonLat(coordinate);\n this.addLocationCoords = { lon, lat };\n\n // Update coordinates display\n const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');\n coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;\n\n // Reset form\n const form = this.addLocationPopupElement.querySelector('#map-add-location-form');\n form.reset();\n\n // Position and show popup\n this.addLocationPopup.setPosition(coordinate);\n }\n\n /**\n * Hide the Add Location popup\n */\n hideAddLocationPopup() {\n this.addLocationPopup.setPosition(undefined);\n this.addLocationCoords = null;\n }\n\n /**\n * Register a callback for when a location is submitted via the map popup\n * Callback receives: { name, category, description, lon, lat }\n */\n onAddLocation(callback) {\n this.addLocationCallbacks.push(callback);\n\n // Set up form submit handler (only once)\n if (this.addLocationCallbacks.length === 1) {\n const form = this.addLocationPopupElement.querySelector('#map-add-location-form');\n form.addEventListener('submit', (e) => {\n e.preventDefault();\n\n if (!this.addLocationCoords) return;\n\n const formData = new FormData(form);\n const data = {\n name: formData.get('name'),\n category: formData.get('category'),\n description: formData.get('description'),\n lon: this.addLocationCoords.lon,\n lat: this.addLocationCoords.lat,\n };\n\n // Call all registered callbacks\n this.addLocationCallbacks.forEach(cb => cb(data));\n\n // Hide popup after submission\n this.hideAddLocationPopup();\n });\n }\n }\n\n /**\n * Create base layers group for LayerSwitcher\n */\n createBaseLayers(defaultBasemap) {\n\n\n const topoLayer = new TileLayer({\n title: 'Topographic',\n type: 'base',\n zIndex: -100,\n visible: defaultBasemap === 'topo',\n source: new XYZ({\n url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png',\n attributions: 'Map data: © OpenTopoMap',\n maxZoom: 17,\n crossOrigin: 'anonymous',\n }),\n });\n topoLayer.set('basemapKey', 'topo');\n\n const cartoLightLayer = new TileLayer({\n title: 'Carto Light',\n type: 'base',\n zIndex: -100,\n visible: defaultBasemap === 'carto-light',\n source: new XYZ({\n url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',\n attributions: '© CARTO',\n maxZoom: 19,\n crossOrigin: 'anonymous',\n }),\n });\n cartoLightLayer.set('basemapKey', 'carto-light');\n\n const cartoDarkLayer = new TileLayer({\n title: 'Carto Dark',\n type: 'base',\n zIndex: -100,\n visible: defaultBasemap === 'carto-dark',\n source: new XYZ({\n url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',\n attributions: '© CARTO',\n maxZoom: 19,\n crossOrigin: 'anonymous',\n }),\n });\n cartoDarkLayer.set('basemapKey', 'carto-dark');\n\n const osmCycleLayer = new TileLayer({\n title: 'OSM Cycle map',\n type: 'base',\n zIndex: -100,\n visible: false, //defaultBasemap === 'osm',\n source: new OSM({\n \t\t\t\t\t\"url\" : \"https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760\"\n\t\t\t\t\t\t}),\n });\n\n osmCycleLayer.set('basemapKey', 'cycle');\n\n const satelliteLayer = new TileLayer({\n title: 'Satellite',\n type: 'base',\n zIndex: -100,\n visible: defaultBasemap === 'satellite',\n source: new XYZ({\n url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',\n attributions: 'Tiles © Esri',\n maxZoom: 19,\n crossOrigin: 'anonymous',\n }),\n });\n satelliteLayer.set('basemapKey', 'satellite');\n const googleLayer = new TileLayer({\n title: 'Google Sat',\n type: 'base',\n zIndex: -100,\n visible: defaultBasemap === 'googlesat',\n source: new XYZ({\n// url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',\n url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga',\n attributions: 'Tiles © Google',\n maxZoom: 19,\n crossOrigin: 'anonymous',\n }),\n });\n googleLayer.set('basemapKey', 'googlesat');\n\n const osmLayer = new TileLayer({\n title: 'OpenStreetMap',\n type: 'base',\n zIndex: -100,\n visible: defaultBasemap === 'osm',\n source: new OSM(),\n });\n osmLayer.set('basemapKey', 'osm');\n\n // Remember the base-map layers so setBaseMap() can toggle visibility later\n this._baseMapLayers = [\n cartoLightLayer, cartoDarkLayer, osmCycleLayer,\n satelliteLayer, googleLayer, osmLayer, topoLayer,\n ];\n\n\n // Return LayerGroup. Hidden from the main LayerSwitcher — base maps are\n // managed by the dedicated base-map picker (see _createBaseMapPicker)\n // accessed via the layers-stack icon above the My Location button.\n const baseGroup = new LayerGroup({\n title: 'Base Maps',\n layers: [\n cartoLightLayer,\n cartoDarkLayer,\n satelliteLayer,\n osmCycleLayer,\n googleLayer,\n osmLayer,\n topoLayer,\n ],\n });\n baseGroup.set('displayInLayerSwitcher', false);\n return baseGroup;\n }\n\n /**\n * Switch the active base map by key.\n * Sets exactly one base layer visible; hides all others.\n *\n * @param {string} key Basemap key: 'none' | 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle'\n * @returns {boolean} true if the key matched a known base layer (or 'none')\n */\n setBaseMap(key) {\n if (!this._baseMapLayers) return false;\n // 'none' switches the base map off entirely — hide every base layer so the\n // map renders on a blank background (useful over imagery overlays / when a\n // full-coverage overlay should stand alone).\n if (key === 'none') {\n for (const layer of this._baseMapLayers) layer.setVisible(false);\n console.log('[MapView] Base map switched off (none)');\n this.map.dispatchEvent({ type: 'basemapchange', key: 'none' });\n return true;\n }\n let matched = false;\n for (const layer of this._baseMapLayers) {\n const on = layer.get('basemapKey') === key;\n layer.setVisible(on);\n if (on) matched = true;\n }\n if (matched) {\n console.log('[MapView] Base map switched to:', key);\n // Notify external UIs (Settings dropdown, base-map picker, …) so they\n // can keep their visible state in sync.\n this.map.dispatchEvent({ type: 'basemapchange', key });\n }\n return matched;\n }\n\n /**\n * Build the floating \"Base Map\" picker — a small icon button stacked\n * directly above the My Location control, plus a slide-out card with\n * thumbnail chips for every selectable base map.\n *\n * Hidden in tandem with the main LayerSwitcher: clicking outside the\n * picker (or making a selection) closes it.\n *\n * Two-way sync with the existing Settings dropdown is via the\n * `basemapchange` event fired from setBaseMap().\n */\n _createBaseMapPicker() {\n // Configuration — must match the basemapKey set in createBaseLayers.\n // The colour gradients hint at each base map's character so the chip is\n // recognisable without rendering an actual tile preview.\n const OPTIONS = [\n { key: 'topo', label: 'Topographic', grad: 'linear-gradient(135deg,#e8d5b7,#a67c52)' },\n { key: 'osm', label: 'OpenStreetMap',grad: 'linear-gradient(135deg,#d4e6f1,#85c1e9)' },\n { key: 'satellite', label: 'Satellite', grad: 'linear-gradient(135deg,#1b4332,#40916c)' },\n { key: 'googlesat', label: 'Google Sat', grad: 'linear-gradient(135deg,#2a5d3d,#4a8c5a)' },\n { key: 'carto-light', label: 'Carto Light', grad: 'linear-gradient(135deg,#f5f5f5,#d4d4d4)' },\n { key: 'carto-dark', label: 'Carto Dark', grad: 'linear-gradient(135deg,#1a1a2e,#0f3460)' },\n // \"None\" turns the base map off — checkerboard hints at a blank/transparent background.\n { key: 'none', label: 'None', grad: 'repeating-conic-gradient(#e5e7eb 0 25%, #fff 0 50%) 50% / 12px 12px' },\n ];\n\n const target = this.map.getTargetElement();\n if (!target) return;\n\n // ---------- Toggle button ----------\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'ls-basemap-toggle';\n btn.title = 'Switch base map';\n btn.setAttribute('aria-label', 'Switch base map');\n btn.innerHTML =\n '' +\n '' +\n '' +\n '' +\n '';\n target.appendChild(btn);\n\n // ---------- Picker panel ----------\n const panel = document.createElement('div');\n panel.className = 'ls-basemap-panel';\n panel.innerHTML =\n '
Base Map
' +\n '
' +\n OPTIONS.map((opt) => `\n \n `).join('') +\n '
';\n target.appendChild(panel);\n\n this._basemapPanel = panel;\n this._basemapToggle = btn;\n\n /** Mark the radio matching the currently-visible base layer. */\n const syncSelection = (key) => {\n const k = key || this._baseMapLayers?.find((l) => l.getVisible())?.get('basemapKey');\n panel.querySelectorAll('input[name=\"lupmis-basemap\"]').forEach((r) => {\n r.checked = (r.value === k);\n });\n };\n syncSelection();\n\n // ---------- Events ----------\n\n // Toggle button → open / close the panel\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n const open = !panel.classList.contains('open');\n panel.classList.toggle('open', open);\n btn.classList.toggle('active', open);\n if (open) syncSelection();\n });\n\n // Click outside → close\n document.addEventListener('click', (e) => {\n if (!panel.classList.contains('open')) return;\n if (panel.contains(e.target) || btn.contains(e.target)) return;\n panel.classList.remove('open');\n btn.classList.remove('active');\n });\n\n // Selection → apply, persist, close\n panel.addEventListener('change', (e) => {\n const radio = e.target.closest('input[type=radio][name=\"lupmis-basemap\"]');\n if (!radio) return;\n const key = radio.value;\n this.setBaseMap(key);\n try { localStorage.setItem('default-basemap', key); } catch {}\n panel.classList.remove('open');\n btn.classList.remove('active');\n });\n\n // Keep the radio state synced when other UIs (Settings dropdown) change it\n this.map.on('basemapchange', (evt) => syncSelection(evt.key));\n }\n\n // ============================================================================\n // GPS: current-position + trail rendering, and the expandable Location control\n //\n // NOTE: MapView deliberately knows nothing about the GeoTracker engine,\n // SQLocal, or sync. It only (a) renders what it's told and (b) emits UI\n // intents via callbacks. main.js wires those intents to the GeoTracker so the\n // map stays reusable/decoupled.\n // ============================================================================\n\n /** Create the vector layers used to draw the live position and the trail. */\n _initGpsRendering() {\n this._gpsPositionSource = new VectorSource();\n this._gpsTrailSource = new VectorSource();\n this._gpsTrailCoords = []; // [ [x,y], ... ] in map projection\n\n // Trail line (drawn under the position marker)\n this._gpsTrailLayer = new VectorLayer({\n source: this._gpsTrailSource,\n zIndex: 940,\n style: new Style({\n stroke: new Stroke({ color: '#ff6d00', width: 4, lineCap: 'round', lineJoin: 'round' }),\n }),\n properties: { title: 'GPS Trail', displayInLayerSwitcher: false },\n });\n\n // Current position: accuracy halo + solid dot\n this._gpsPositionLayer = new VectorLayer({\n source: this._gpsPositionSource,\n zIndex: 950,\n style: (feature) => {\n if (feature.get('_kind') === 'accuracy') {\n return new Style({\n fill: new Fill({ color: 'rgba(0,94,184,0.12)' }),\n stroke: new Stroke({ color: 'rgba(0,94,184,0.35)', width: 1 }),\n });\n }\n return new Style({\n image: new Circle({\n radius: 7,\n fill: new Fill({ color: '#005eb8' }),\n stroke: new Stroke({ color: '#ffffff', width: 2.5 }),\n }),\n });\n },\n properties: { title: 'GPS Position', displayInLayerSwitcher: false },\n });\n\n this.map.addLayer(this._gpsTrailLayer);\n this.map.addLayer(this._gpsPositionLayer);\n\n this._gpsCallbacks = { locate: [], record: [] };\n this._gpsRecording = false;\n }\n\n /** Register a callback fired when the user taps \"Locate Me\". */\n onLocateMe(cb) { this._gpsCallbacks.locate.push(cb); }\n /** Register a callback fired when the user toggles trail recording. Receives the desired state (true=start). */\n onToggleRecording(cb) { this._gpsCallbacks.record.push(cb); }\n\n /**\n * Draw / move the current-position marker and accuracy halo.\n * @param {number} lon\n * @param {number} lat\n * @param {number|null} [accuracy] horizontal accuracy in metres\n */\n showCurrentPosition(lon, lat, accuracy = null) {\n if (lon == null || lat == null) return;\n const center = fromLonLat([lon, lat]);\n this._gpsPositionSource.clear();\n\n if (accuracy && accuracy > 0) {\n // Approximate the accuracy circle in projected units. Good enough for a\n // visual halo at typical zoom levels.\n const resAtLat = accuracy / Math.cos((lat * Math.PI) / 180);\n const halo = new Feature({ geometry: new PolygonGeom([this._circleRing(center, resAtLat)]) });\n halo.set('_kind', 'accuracy');\n this._gpsPositionSource.addFeature(halo);\n }\n const dot = new Feature({ geometry: new Point(center) });\n dot.set('_kind', 'dot');\n this._gpsPositionSource.addFeature(dot);\n }\n\n /** @private build a ring of coordinates approximating a circle (metres → projected). */\n _circleRing(center, radiusMeters, segments = 48) {\n // Convert metres to projected units (Web Mercator) roughly via the\n // resolution at the centre latitude.\n const ring = [];\n const metersPerUnit = 1; // EPSG:3857 units are metres (approx near equator/locally)\n const r = radiusMeters / metersPerUnit;\n for (let i = 0; i <= segments; i++) {\n const a = (i / segments) * 2 * Math.PI;\n ring.push([center[0] + r * Math.cos(a), center[1] + r * Math.sin(a)]);\n }\n return ring;\n }\n\n /** Smoothly center the view on a coordinate. */\n centerOn(lon, lat, zoom = 16) {\n const view = this.map.getView();\n view.animate({ center: fromLonLat([lon, lat]), zoom, duration: 500 });\n }\n\n /** Reset the trail line (call when a new recording starts). */\n startTrailRender() {\n this._gpsTrailCoords = [];\n this._gpsTrailSource.clear();\n }\n\n /** Append a coordinate to the growing trail line. */\n appendTrailPoint(lon, lat) {\n if (lon == null || lat == null) return;\n this._gpsTrailCoords.push(fromLonLat([lon, lat]));\n this._gpsTrailSource.clear();\n if (this._gpsTrailCoords.length >= 2) {\n this._gpsTrailSource.addFeature(new Feature({ geometry: new LineString(this._gpsTrailCoords) }));\n }\n }\n\n /** Remove the rendered trail (does not affect stored data). */\n clearTrailRender() {\n this._gpsTrailCoords = [];\n this._gpsTrailSource.clear();\n }\n\n /** Reflect recording state on the control button. */\n setRecordingState(active) {\n this._gpsRecording = !!active;\n if (this._recordBtn) {\n this._recordBtn.classList.toggle('recording', this._gpsRecording);\n this._recordBtn.title = this._gpsRecording ? 'Stop trail recording' : 'Record GPS trail';\n this._recordBtn.innerHTML = this._gpsRecording\n ? ''\n : '';\n }\n if (this._locateToggle) this._locateToggle.classList.toggle('recording', this._gpsRecording);\n }\n\n /**\n * Build the expandable \"My Location\" control: a main button that reveals two\n * sub-buttons (Locate Me, Record Trail). Anchored at the same spot the old\n * ol-ext GeolocationButton occupied (bottom-right), so the base-map picker\n * still lines up above it.\n */\n _createLocationControl() {\n const target = this.map.getTargetElement();\n if (!target) return;\n\n // Main toggle\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'ls-locate-toggle';\n toggle.title = 'My Location';\n toggle.setAttribute('aria-label', 'My Location');\n toggle.innerHTML = '';\n target.appendChild(toggle);\n\n // Sub-button cluster (hidden until the main button is tapped)\n const actions = document.createElement('div');\n actions.className = 'ls-locate-actions';\n actions.innerHTML =\n '' +\n '';\n target.appendChild(actions);\n\n this._locateToggle = toggle;\n this._locateActions = actions;\n this._locateMeBtn = actions.querySelector('.ls-locate-me');\n this._recordBtn = actions.querySelector('.ls-locate-record');\n\n const close = () => { actions.classList.remove('open'); toggle.classList.remove('active'); };\n const open = () => { actions.classList.add('open'); toggle.classList.add('active'); };\n\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n actions.classList.contains('open') ? close() : open();\n });\n\n // Tap outside closes the cluster (but never while recording, so the stop\n // button stays reachable).\n document.addEventListener('click', (e) => {\n if (!actions.classList.contains('open')) return;\n if (actions.contains(e.target) || toggle.contains(e.target)) return;\n if (this._gpsRecording) return;\n close();\n });\n\n this._locateMeBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n for (const cb of this._gpsCallbacks.locate) { try { cb(); } catch (err) { console.error(err); } }\n if (!this._gpsRecording) close();\n });\n\n this._recordBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const next = !this._gpsRecording;\n for (const cb of this._gpsCallbacks.record) { try { cb(next); } catch (err) { console.error(err); } }\n });\n }\n\n /**\n * Get style for a feature (handles selection state)\n */\n getFeatureStyle(feature) {\n const category = feature.get('category') || 'default';\n const emoji = this.getEmoji(category);\n\n if (feature === this.selectedFeature) {\n // Return selected style with the correct emoji and highlight\n return [\n // Background highlight circle\n new Style({\n image: new Circle({\n radius: 22,\n fill: new Fill({ color: 'rgba(220, 38, 38, 0.25)' }),\n stroke: new Stroke({ color: '#dc2626', width: 3 }),\n }),\n }),\n // Emoji on top, larger\n new Style({\n text: new Text({\n text: emoji,\n font: '40px sans-serif',\n textBaseline: 'bottom',\n textAlign: 'center',\n offsetY: -5,\n }),\n }),\n ];\n }\n\n // Check for custom style\n const customStyle = feature.get('style');\n if (customStyle) {\n return customStyle;\n }\n\n // Return category-based emoji style\n if (this.categoryStyles[category]) {\n return this.categoryStyles[category];\n }\n\n return this.defaultStyle;\n }\n\n /**\n * Set category-based styles with emojis\n * @param {Object} styles - Map of category to config { emoji, label, fontSize }\n */\n setCategoryStyles(styles) {\n for (const [category, config] of Object.entries(styles)) {\n // Update category mapping if provided\n if (config.emoji) {\n if (!this.categoryEmojis[category]) {\n this.categoryEmojis[category] = { emoji: config.emoji, label: config.label || category };\n } else {\n this.categoryEmojis[category].emoji = config.emoji;\n if (config.label) {\n this.categoryEmojis[category].label = config.label;\n }\n }\n }\n\n // Create/update style\n const emoji = this.getEmoji(category);\n const fontSize = config.fontSize || 28;\n\n this.categoryStyles[category] = this.createEmojiStyle(emoji, fontSize);\n }\n\n // Refresh markers\n this.markerSource.changed();\n }\n\n /**\n * Add a single marker\n */\n addMarker(lon, lat, properties = {}) {\n console.log('[MapView] Adding marker at', lon, lat, 'with properties:', properties);\n\n const feature = new Feature({\n geometry: new Point(fromLonLat([lon, lat])),\n ...properties,\n });\n\n // Store original coordinates for easy access\n feature.set('lon', lon);\n feature.set('lat', lat);\n\n this.markerSource.addFeature(feature);\n console.log('[MapView] Marker added, total features:', this.markerSource.getFeatures().length);\n return feature;\n }\n\n /**\n * Add multiple markers from an array of location objects\n */\n addMarkers(locations) {\n console.log('[MapView] Adding', locations.length, 'markers');\n\n const features = locations.map((loc) => {\n const feature = new Feature({\n geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),\n id: loc.id,\n name: loc.name,\n description: loc.description,\n category: loc.category,\n lon: loc.longitude,\n lat: loc.latitude,\n });\n return feature;\n });\n\n this.markerSource.addFeatures(features);\n console.log('[MapView] Markers added, total features:', this.markerSource.getFeatures().length);\n return features;\n }\n\n /**\n * Clear all markers\n */\n clearMarkers() {\n this.markerSource.clear();\n this.selectedFeature = null;\n }\n\n /**\n * Remove a specific marker by feature or ID\n */\n removeMarker(featureOrId) {\n if (typeof featureOrId === 'object') {\n this.markerSource.removeFeature(featureOrId);\n } else {\n const feature = this.markerSource.getFeatures().find(\n f => f.get('id') === featureOrId\n );\n if (feature) {\n this.markerSource.removeFeature(feature);\n }\n }\n }\n\n /**\n * Get all markers\n */\n getMarkers() {\n return this.markerSource.getFeatures();\n }\n\n /**\n * Find marker by ID\n */\n findMarker(id) {\n return this.markerSource.getFeatures().find(f => f.get('id') === id);\n }\n\n /**\n * Select a marker (highlights it)\n */\n /**\n * Make the Markers layer visible.\n *\n * The layer starts hidden so a fresh map is uncluttered, and the LayerSwitcher\n * is the only thing that ever turned it on. That is fine until the officer\n * creates or focuses a location, at which point an invisible layer means the\n * point they just placed is simply not there — the record is written, the map\n * pans to it, and nothing is drawn. Callers that act on a specific location\n * call this so the result of the action can actually be seen.\n */\n showMarkers() {\n if (this.markersLayer && !this.markersLayer.getVisible()) {\n this.markersLayer.setVisible(true);\n console.log('[MapView] Markers layer switched on to show a location');\n }\n }\n\n selectMarker(featureOrId) {\n if (typeof featureOrId === 'object') {\n this.selectedFeature = featureOrId;\n } else {\n this.selectedFeature = this.findMarker(featureOrId);\n }\n this.markerSource.changed();\n return this.selectedFeature;\n }\n\n /**\n * Clear selection\n */\n clearSelection() {\n this.selectedFeature = null;\n this.markerSource.changed();\n }\n\n /**\n * Zoom to a specific location\n */\n zoomTo(lon, lat, zoom = 15) {\n this.map.getView().animate({\n center: fromLonLat([lon, lat]),\n zoom: zoom,\n duration: 500,\n });\n }\n\n /**\n * Fit view to show all markers\n */\n fitToMarkers(padding = 50) {\n const extent = this.markerSource.getExtent();\n if (extent && extent[0] !== Infinity) {\n this.map.getView().fit(extent, {\n padding: [padding, padding, padding, padding],\n duration: 500,\n maxZoom: 16,\n });\n }\n }\n\n /**\n * Get current map center in lon/lat\n */\n getCenter() {\n const center = this.map.getView().getCenter();\n return toLonLat(center);\n }\n\n /**\n * Get current zoom level\n */\n getZoom() {\n return this.map.getView().getZoom();\n }\n\n /**\n * Set map center\n */\n setCenter(lon, lat) {\n this.map.getView().setCenter(fromLonLat([lon, lat]));\n }\n\n /**\n * Set zoom level\n */\n setZoom(zoom) {\n this.map.getView().setZoom(zoom);\n }\n\n /**\n * Register click callback\n * Callback receives (lon, lat, feature, event)\n *\n * Single-click is delayed by 300 ms so that a double-click can cancel it.\n * If the click lands on an overlay feature (e.g. district boundary) the\n * single-click is suppressed entirely — only double-click will fire.\n */\n onClick(callback) {\n this.clickCallbacks.push(callback);\n\n // Set up click handler if this is the first callback\n if (this.clickCallbacks.length === 1) {\n this._clickTimer = null;\n\n // Double-click cancels any pending single-click\n this.map.on('dblclick', () => {\n if (this._clickTimer) {\n clearTimeout(this._clickTimer);\n this._clickTimer = null;\n }\n });\n\n this.map.on('click', (evt) => {\n // Cancel any previous pending click\n if (this._clickTimer) {\n clearTimeout(this._clickTimer);\n this._clickTimer = null;\n }\n\n // When NOT in edit / draw mode, immediately clear any feature\n // the Select interaction may have grabbed on this click so the\n // user never sees a selection flash.\n if (!this._editBarActive && this._selectInteraction) {\n this._selectInteraction.getFeatures().clear();\n }\n\n // Check what features sit under the click pixel\n let hasOverlayFeature = false;\n let hasParcelFeature = false;\n let markerFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n if (feature.get('_layerType') === 'parcel') {\n hasParcelFeature = true;\n }\n if (feature.get('name')) {\n markerFeature = feature;\n }\n hasOverlayFeature = true;\n });\n\n // If an overlay feature was hit, suppress single-click\n // UNLESS it's a parcel or a location marker\n if (hasOverlayFeature && !hasParcelFeature && !markerFeature) {\n return;\n }\n\n // Delay the single-click to allow double-click to cancel it\n const [lon, lat] = toLonLat(evt.coordinate);\n this._clickTimer = setTimeout(() => {\n this._clickTimer = null;\n\n // Find location marker at pixel\n let clickedFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n if (feature.get('name')) {\n clickedFeature = feature;\n return true;\n }\n });\n\n for (const cb of this.clickCallbacks) {\n cb(lon, lat, clickedFeature, evt);\n }\n }, 300);\n });\n }\n\n // Return unsubscribe function\n return () => {\n const index = this.clickCallbacks.indexOf(callback);\n if (index > -1) {\n this.clickCallbacks.splice(index, 1);\n }\n };\n }\n\n /**\n * Register pointer move callback (for hover effects)\n */\n onPointerMove(callback) {\n this.map.on('pointermove', (evt) => {\n if (evt.dragging) return;\n\n const [lon, lat] = toLonLat(evt.coordinate);\n\n // Only find location markers (features with 'name' property)\n let hoveredFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n if (feature.get('name')) {\n hoveredFeature = feature;\n return true;\n }\n });\n\n // Change cursor\n this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : '';\n\n callback(lon, lat, hoveredFeature, evt);\n });\n }\n\n /**\n * Enable cursor change on marker hover\n * Note: This is now handled automatically by the popup system\n */\n enableHoverCursor() {\n // Cursor changes are now handled by setupHoverPopup()\n // This method is kept for backwards compatibility\n }\n\n /**\n * Add a GeoJSON layer (visible in LayerSwitcher).\n * By default the layer is added to the root overlay group.\n * Pass a targetGroup (LayerGroup) to nest it inside a specific group.\n *\n * @param {Object} geojson - GeoJSON FeatureCollection or Feature\n * @param {string} title - Layer title for the LayerSwitcher\n * @param {Object} [styleOptions] - Optional style configuration\n * @param {string} [styleOptions.strokeColor='#3b82f6'] - Stroke color\n * @param {number} [styleOptions.strokeWidth=2] - Stroke width\n * @param {string} [styleOptions.fillColor='rgba(59,130,246,0.1)'] - Fill color\n * @param {number[]} [styleOptions.strokeDash] - Dash pattern for the stroke\n * (passed straight to ol/style/Stroke#lineDash, e.g. [4,4]). Useful for\n * contextual overlays (grids, draft outlines) so they read differently\n * from solid property boundaries.\n * @param {LayerGroup} [targetGroup] - Optional group to add the layer to\n * @returns {VectorLayer} The created layer\n */\n addGeoJSONLayer(geojson, title, styleOptions = {}, targetGroup = null) {\n const {\n strokeColor = '#3b82f6',\n strokeWidth = 2,\n strokeDash = null,\n fillColor = 'rgba(59,130,246,0.1)',\n // Optional line \"casing\": a thicker darker stroke drawn UNDERNEATH the\n // main stroke. Used for road-like layers to make light-colored lines\n // visible on any base map. Set lineCasingColor to enable; the casing\n // width defaults to strokeWidth + 2.\n lineCasingColor = null,\n lineCasingWidth = null,\n pointRadius = 5,\n pointFillColor = null, // defaults to strokeColor\n pointStrokeColor = '#ffffff',\n pointStrokeWidth = 1.5,\n } = styleOptions;\n\n const source = new VectorSource({\n features: new GeoJSON().readFeatures(geojson, {\n featureProjection: 'EPSG:3857',\n }),\n });\n\n // Build per-geometry styles. OpenLayers picks `image` for Point /\n // MultiPoint, `stroke`+`fill` for Polygon / MultiPolygon, and `stroke`\n // alone for LineString / MultiLineString. Putting all three on a single\n // Style is enough — but a Style with only stroke+fill leaves Points\n // invisible, which is what was happening on shapefile import.\n const fillStyle = new Fill({ color: fillColor });\n const pointStyle = new Circle({\n radius: pointRadius,\n fill: new Fill({ color: pointFillColor || strokeColor }),\n stroke: new Stroke({ color: pointStrokeColor, width: pointStrokeWidth }),\n });\n\n // If a line casing is requested, return an array of two Styles per\n // feature: the casing renders first (underneath), then the inner stroke.\n // For polygons the casing also outlines them; for points the casing has\n // no effect (Point geometries only render `image`).\n const mainStroke = new Stroke({\n color: strokeColor,\n width: strokeWidth,\n ...(strokeDash ? { lineDash: strokeDash } : {}),\n });\n\n let layerStyle;\n if (lineCasingColor) {\n const casingW = lineCasingWidth != null ? lineCasingWidth : strokeWidth + 2;\n layerStyle = [\n new Style({\n stroke: new Stroke({ color: lineCasingColor, width: casingW }),\n }),\n new Style({\n stroke: mainStroke,\n fill: fillStyle,\n image: pointStyle,\n }),\n ];\n } else {\n layerStyle = new Style({\n stroke: mainStroke,\n fill: fillStyle,\n image: pointStyle,\n });\n }\n\n const layer = new VectorLayer({\n title: title,\n source: source,\n style: layerStyle,\n // Decluttering can only be set at construction time (there is no\n // setDeclutter), so it has to come through the style options. Layers with\n // dense labels — contours especially — need it to avoid a pile-up of\n // overlapping text.\n ...(styleOptions.declutter ? { declutter: styleOptions.declutter } : {}),\n });\n layer.set('typeTag', styleOptions.typeTag || 'VEC');\n\n // Derive a friendly \"Vector / Polygon\" / \"Vector / Line\" / \"Vector / Point\"\n // subtitle from the first feature's geometry type, unless the caller\n // already supplied one in styleOptions.\n //\n // Layers created EMPTY (parcels, OSM_roads, …, populated later from the\n // API) leave the subtitle absent until the first feature arrives —\n // see the `addfeature` listener below.\n const describeFromGeom = (geomType) => {\n if (!geomType) return null;\n if (geomType.includes('Polygon')) return 'Vector / Polygon';\n if (geomType.includes('LineString')) return 'Vector / Line';\n if (geomType.includes('Point')) return 'Vector / Point';\n return 'Vector';\n };\n\n if (styleOptions.typeDescription) {\n layer.set('typeDescription', styleOptions.typeDescription);\n } else {\n const feats = source.getFeatures();\n const initial = describeFromGeom(feats[0]?.getGeometry?.()?.getType?.());\n if (initial) {\n layer.set('typeDescription', initial);\n } else {\n // Source is empty — wait for the first feature and set then.\n const once = (ev) => {\n const desc = describeFromGeom(ev.feature.getGeometry?.()?.getType?.());\n if (desc) layer.set('typeDescription', desc);\n source.un('addfeature', once);\n };\n source.on('addfeature', once);\n }\n }\n\n const group = targetGroup || this.overlayGroup;\n group.getLayers().push(layer);\n\n console.log('[MapView] GeoJSON layer added:', title, '→', source.getFeatures().length, 'features',\n targetGroup ? `(in group \"${targetGroup.get('title')}\")` : '');\n return layer;\n }\n\n /**\n * Add a LayerGroup to the overlay group.\n * Used to create layer categories from the remote catalogue;\n * individual vector layers will be added into these groups later.\n *\n * @param {number|string} id - Unique layer group id (from the API)\n * @param {string} title - Group title for the LayerSwitcher\n * @param {string} [description=''] - Group description (stored as property)\n * @returns {LayerGroup} The created (empty) layer group\n */\n addLayerGroup(id, title, description = '') {\n const group = new LayerGroup({\n title: title.trim(),\n });\n\n // Store metadata for later use\n group.set('layerId', id);\n group.set('description', description);\n\n this.overlayGroup.getLayers().push(group);\n\n console.log('[MapView] Layer group added:', title.trim(), '(id:', id + ')');\n return group;\n }\n\n /**\n * Add a WMS layer to a layer group.\n *\n * @param {string} groupTitle Title of the target LayerGroup (e.g. 'Biophysical Environment')\n * @param {string} title Display title for the layer\n * @param {string} url WMS server URL\n * @param {string} layers WMS LAYERS parameter\n * @param {Object} [options] Extra options\n * @param {string} [options.serverType='geoserver'] Server type hint ('geoserver'|'mapserver'|'qgis'|null)\n * @param {string} [options.style] WMS STYLES parameter (e.g. 'colours' for DEAfrica DEM)\n * @param {boolean} [options.visible=true] Initial visibility\n * @param {string} [options.attributions] Attribution HTML\n * @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers.\n * @param {number} [options.zIndex] Render z-index. Use negative values (e.g. -10) to force the\n * layer behind all default-z-index layers regardless of group order.\n * @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.\n * @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on\n * while offline, explaining that the layer requires connectivity.\n * @returns {TileLayer|null} The created layer, or null if group not found\n */\n addWMSLayer(groupTitle, title, url, layers, options = {}) {\n const group = this.getLayerGroupByTitle(groupTitle);\n if (!group) {\n console.warn(`[MapView] Layer group \"${groupTitle}\" not found — cannot add WMS layer \"${title}\"`);\n return null;\n }\n\n const params = { LAYERS: layers, TILED: true, WIDTH: 256, HEIGHT: 256 };\n if (options.style !== undefined) params.STYLES = options.style;\n\n const wmsSource = new TileWMS({\n url,\n params,\n serverType: options.serverType !== undefined ? options.serverType : 'geoserver',\n crossOrigin: 'anonymous',\n hidpi: false,\n attributions: options.attributions,\n });\n\n const wmsLayer = new TileLayer({\n title,\n visible: options.visible !== undefined ? options.visible : true,\n source: wmsSource,\n opacity: options.opacity !== undefined ? options.opacity : 1,\n zIndex: options.zIndex,\n });\n wmsLayer.set('typeTag', 'WMS');\n wmsLayer.set('typeDescription', 'WMS / Raster');\n\n // Show toast on tile load errors (e.g. server rejects request)\n wmsSource.on('tileloaderror', () => {\n showToast(`WMS layer \"${title}\" — tile load error. Check the URL and layer name.`, 'warning', 5000);\n });\n\n group.getLayers().push(wmsLayer);\n\n // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher\n if (options.legendUrl) {\n try {\n this._registerLegend(wmsLayer, title, options.legendUrl);\n } catch (err) {\n console.warn(`[MapView] Could not register legend for \"${title}\":`, err);\n }\n }\n\n // Online-only warning: when the user toggles the layer on while offline,\n // surface a toast explaining why nothing will render.\n if (options.onlineOnly) {\n this._attachOnlineOnlyHandler(wmsLayer, title);\n }\n\n console.log(`[MapView] WMS layer added: \"${title}\" → group \"${groupTitle}\"`);\n return wmsLayer;\n }\n\n /**\n * Add an XYZ tile layer to a layer group.\n *\n * @param {string} groupTitle Title of the target LayerGroup\n * @param {string} title Display title for the layer\n * @param {string} url XYZ tile URL template (with {z}/{x}/{y} placeholders)\n * @param {Object} [options] Extra options\n * @param {boolean} [options.visible=true] Initial visibility\n * @param {string} [options.attributions] Attribution HTML\n * @param {number} [options.maxZoom=19] Maximum zoom level\n * @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers.\n * @param {number} [options.zIndex] Render z-index. Use negative values to force behind other layers.\n * @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.\n * @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on\n * while offline, explaining that the layer requires connectivity.\n * @returns {TileLayer|null} The created layer, or null if group not found\n */\n addXYZLayer(groupTitle, title, url, options = {}) {\n const group = this.getLayerGroupByTitle(groupTitle);\n if (!group) {\n console.warn(`[MapView] Layer group \"${groupTitle}\" not found — cannot add XYZ layer \"${title}\"`);\n return null;\n }\n\n const xyzSource = new XYZ({\n url,\n crossOrigin: 'anonymous',\n maxZoom: options.maxZoom !== undefined ? options.maxZoom : 19,\n attributions: options.attributions,\n });\n\n const xyzLayer = new TileLayer({\n title,\n visible: options.visible !== undefined ? options.visible : true,\n source: xyzSource,\n opacity: options.opacity !== undefined ? options.opacity : 1,\n zIndex: options.zIndex,\n });\n xyzLayer.set('typeTag', 'XYZ');\n xyzLayer.set('typeDescription', 'XYZ / Tile');\n\n // Show toast on tile load errors\n xyzSource.on('tileloaderror', () => {\n showToast(`XYZ layer \"${title}\" — tile load error. Check the URL.`, 'warning', 5000);\n });\n\n group.getLayers().push(xyzLayer);\n\n // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher\n if (options.legendUrl) {\n try {\n this._registerLegend(xyzLayer, title, options.legendUrl);\n } catch (err) {\n console.warn(`[MapView] Could not register legend for \"${title}\":`, err);\n }\n }\n\n // Online-only warning: when the user toggles the layer on while offline,\n // surface a toast explaining why nothing will render.\n if (options.onlineOnly) {\n this._attachOnlineOnlyHandler(xyzLayer, title);\n }\n\n console.log(`[MapView] XYZ layer added: \"${title}\" → group \"${groupTitle}\"`);\n return xyzLayer;\n }\n\n /**\n * Add a Cloud-Optimized GeoTIFF (COG) raster layer.\n *\n * COGs are streamed by range-request and rendered on the GPU via\n * WebGLTile, so a large DEM or satellite scene can be displayed without\n * downloading the whole file. This is the display half of Stage 1's raster\n * support (analysis on rasters runs server-side — see the concept note).\n *\n * @param {string} groupTitle Title of the target LayerGroup\n * @param {string} title Display title for the layer\n * @param {string} url URL of the .tif (must be a valid COG, CORS-enabled)\n * @param {Object} [options]\n * @param {boolean} [options.visible=true]\n * @param {number} [options.opacity=1]\n * @param {number} [options.zIndex]\n * @param {boolean} [options.normalize=true] Auto-stretch values to 0–1 for display\n * @param {number} [options.min] Explicit min for contrast stretch\n * @param {number} [options.max] Explicit max for contrast stretch\n * @param {Object} [options.style] OpenLayers WebGLTile style (e.g. a colour ramp)\n * @param {string} [options.attributions]\n * @returns {Promise} the created layer (async: the COG libraries\n * are code-split and fetched on first use)\n */\n async addCOGLayer(groupTitle, title, url, options = {}) {\n const group = this.getLayerGroupByTitle(groupTitle);\n if (!group) {\n console.warn(`[MapView] Layer group \"${groupTitle}\" not found — cannot add COG layer \"${title}\"`);\n return null;\n }\n\n // Pre-flight the URL before handing it to geotiff.js. Without this, a bad\n // URL (a web page, a 404, a CORS block) surfaces only as an unhandled\n // AggregateError from the library's internal range-request machinery,\n // which tells the user nothing. A one-byte range request is cheap and\n // distinguishes the common failures precisely.\n const problem = await this._probeCOGUrl(url);\n if (problem) {\n console.warn(`[MapView] COG pre-flight failed for \"${title}\":`, problem);\n showToast(problem, 'error', 8000);\n return null;\n }\n\n // Lazy-load the GeoTIFF stack only when a raster is actually opened.\n let WebGLTileLayer, GeoTIFFSource;\n try {\n [{ default: WebGLTileLayer }, { default: GeoTIFFSource }] = await Promise.all([\n import('ol/layer/WebGLTile'),\n import('ol/source/GeoTIFF'),\n ]);\n } catch (err) {\n console.error('[MapView] Could not load COG support:', err);\n showToast('Raster (COG) support could not be loaded — check your connection.', 'error', 6000);\n return null;\n }\n\n let cogLayer = null;\n try {\n const sourceOpts = {\n sources: [{\n url,\n ...(options.min !== undefined ? { min: options.min } : {}),\n ...(options.max !== undefined ? { max: options.max } : {}),\n }],\n // normalize=true stretches band values into 0–1 so single-band rasters\n // (DEMs, indices) render without an explicit style.\n normalize: options.normalize !== undefined ? options.normalize : true,\n attributions: options.attributions,\n };\n const source = new GeoTIFFSource(sourceOpts);\n\n cogLayer = new WebGLTileLayer({\n title,\n source,\n visible: options.visible !== undefined ? options.visible : true,\n opacity: options.opacity !== undefined ? options.opacity : 1,\n zIndex: options.zIndex,\n ...(options.style ? { style: options.style } : {}),\n });\n cogLayer.set('typeTag', 'COG');\n cogLayer.set('typeDescription', 'Cloud-Optimized GeoTIFF');\n\n // Surface load failures — a non-COG or a CORS-blocked file is the usual cause.\n source.on('error', (evt) => {\n console.warn(`[MapView] COG \"${title}\" source error:`, evt);\n showToast(`Raster \"${title}\" could not be loaded. Check that the file is a valid COG and is CORS-enabled.`,\n 'warning', 6000);\n });\n\n group.getLayers().push(cogLayer);\n } catch (err) {\n console.error(`[MapView] Failed to add COG layer \"${title}\":`, err);\n showToast(`Could not add raster \"${title}\": ${err.message}`, 'error', 6000);\n return null;\n }\n\n if (options.onlineOnly) this._attachOnlineOnlyHandler(cogLayer, title);\n\n console.log(`[MapView] COG layer added: \"${title}\" → group \"${groupTitle}\"`);\n return cogLayer;\n }\n\n /**\n * Check that a URL can plausibly serve a Cloud-Optimized GeoTIFF.\n *\n * Issues a 1-byte range request and inspects the response. Returns a\n * human-readable problem description, or null when the URL looks usable.\n * This exists because geotiff.js reports these failures as an opaque\n * \"AggregateError: Request failed\" from deep inside its block reader.\n *\n * @param {string} url\n * @returns {Promise}\n * @private\n */\n async _probeCOGUrl(url) {\n let res;\n try {\n res = await fetch(url, { headers: { Range: 'bytes=0-0' } });\n } catch (err) {\n // Thrown for CORS rejections and network failures alike — the browser\n // deliberately doesn't tell us which, so say so rather than guess.\n return 'Could not reach that file. It may be offline, or the server may not ' +\n 'allow cross-origin (CORS) requests from this app.';\n }\n\n if (res.status === 404) return 'No file at that URL (404). Check the address.';\n if (!res.ok && res.status !== 206) {\n return `The server refused the request (HTTP ${res.status}).`;\n }\n\n const type = (res.headers.get('content-type') || '').toLowerCase();\n if (type.includes('text/html')) {\n return 'That URL is a web page, not a GeoTIFF. Paste the direct link to the ' +\n '.tif file itself — a documentation or viewer page will not work.';\n }\n if (type.startsWith('text/') || type.includes('json')) {\n return `That URL returns ${type.split(';')[0]}rather than image data. ` +\n 'Paste the direct link to the .tif file.';\n }\n // 200 instead of 206 means range requests are ignored: the file still\n // loads, but the whole thing is downloaded. Worth warning about, not fatal.\n if (res.status === 200) {\n showToast('This server does not support byte-range requests, so the whole raster ' +\n 'will be downloaded. It may be slow for a large file.', 'warning', 7000);\n }\n return null;\n }\n\n /**\n * The features currently selected with the Select tool (shift-click or\n * Ctrl/Cmd box-select). Used by the Analysis panel to restrict an analysis\n * to a hand-picked subset.\n * @returns {Array}\n */\n getSelectedFeatures() {\n if (!this._selectInteraction) return [];\n return this._selectInteraction.getFeatures().getArray().slice();\n }\n\n /**\n * List the vector layers a user can pick as analysis inputs.\n *\n * Walks the layer tree (recursing into groups) and returns the real,\n * feature-bearing layers — excluding helper overlays (vertex handles, GPS\n * trail/position, snap guides) which are never meaningful analysis inputs.\n *\n * @param {Object} [opts]\n * @param {boolean} [opts.visibleOnly=false] only include currently-visible layers\n * @param {boolean} [opts.nonEmptyOnly=true] skip layers with no features loaded\n * @returns {Array<{layer: VectorLayer, title: string, count: number}>}\n */\n listVectorLayers(opts = {}) {\n const { visibleOnly = false, nonEmptyOnly = true } = opts;\n const out = [];\n\n const walk = (layer) => {\n if (!layer) return;\n if (typeof layer.getLayers === 'function') {\n layer.getLayers().forEach(walk);\n return;\n }\n if (!(layer instanceof VectorLayer)) return;\n if (layer === this._vertexOverlayLayer ||\n layer === this._gpsTrailLayer ||\n layer === this._gpsPositionLayer ||\n layer.get('displayInLayerSwitcher') === false) return;\n if (visibleOnly && layer.getVisible && !layer.getVisible()) return;\n\n const source = layer.getSource && layer.getSource();\n if (!source || typeof source.getFeatures !== 'function') return;\n const count = source.getFeatures().length;\n if (nonEmptyOnly && count === 0) return;\n\n out.push({ layer, title: layer.get('title') || '(untitled layer)', count });\n };\n\n this.map.getLayers().forEach(walk);\n return out;\n }\n\n // ============================================================================\n // Add External Layer Dialog\n // ============================================================================\n\n /**\n * Create the add-layer dialog overlay (hidden by default).\n * Appended to the map target element so it stays within the map viewport.\n */\n _createAddLayerDialog() {\n this._addLayerDialog = document.createElement('div');\n this._addLayerDialog.className = 'map-add-layer-dialog';\n this._addLayerDialog.style.cssText = `\n display:none;position:absolute;top:0;left:0;right:0;bottom:0;\n z-index:1100;background:rgba(0,0,0,0.4);\n align-items:center;justify-content:center;\n `;\n\n const card = document.createElement('div');\n card.style.cssText = `\n background:var(--card, #fff);color:var(--card-foreground, #1e1a4b);\n border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.35);\n font-family:var(--font-body, 'Exo', sans-serif);font-size:13px;\n width:340px;max-width:90vw;border:2px solid #10b981;overflow:hidden;\n `;\n\n card.innerHTML = `\n
\n Add External Layer\n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n WMS LAYERS parameter (e.g. workspace:layer)\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n `;\n\n this._addLayerDialog.appendChild(card);\n this.map.getTargetElement().appendChild(this._addLayerDialog);\n\n // Type radio change — toggle layer name row visibility\n const nameRow = card.querySelector('.add-layer-name-row');\n const nameHint = card.querySelector('.add-layer-name-hint');\n const urlInput = card.querySelector('.add-layer-url');\n const typeHint = card.querySelector('.add-layer-type-hint');\n card.querySelectorAll('input[name=\"add-layer-type\"]').forEach((radio) => {\n radio.addEventListener('change', () => {\n const type = radio.value;\n typeHint.textContent = type === 'cog'\n ? 'Cloud-Optimized GeoTIFF — the server must allow CORS and byte-range requests.'\n : '';\n if (type === 'cog') {\n // A COG is a single file, so there is no layer/typename to pick.\n nameRow.style.display = 'none';\n urlInput.placeholder = 'https://example.com/data/elevation_cog.tif';\n } else if (type === 'xyz') {\n nameRow.style.display = 'none';\n urlInput.placeholder = 'https://example.com/tiles/{z}/{x}/{y}.png';\n } else {\n nameRow.style.display = '';\n urlInput.placeholder = type === 'wms'\n ? 'https://example.com/wms'\n : 'https://example.com/wfs';\n nameHint.textContent = type === 'wms'\n ? 'WMS LAYERS parameter (e.g. workspace:layer)'\n : 'WFS typename (e.g. workspace:layer)';\n }\n });\n });\n\n // Close / Cancel\n const close = () => this._hideAddLayerDialog();\n card.querySelector('.add-layer-close').addEventListener('click', close);\n card.querySelector('.add-layer-cancel').addEventListener('click', close);\n this._addLayerDialog.addEventListener('click', (e) => {\n if (e.target === this._addLayerDialog) close();\n });\n\n // Confirm\n card.querySelector('.add-layer-confirm').addEventListener('click', () => {\n const type = card.querySelector('input[name=\"add-layer-type\"]:checked').value;\n const url = card.querySelector('.add-layer-url').value.trim();\n const layerName = card.querySelector('.add-layer-name').value.trim();\n const title = card.querySelector('.add-layer-title').value.trim();\n\n if (!url) {\n card.querySelector('.add-layer-url').style.borderColor = '#ef4444';\n return;\n }\n if ((type === 'wms' || type === 'wfs') && !layerName) {\n card.querySelector('.add-layer-name').style.borderColor = '#ef4444';\n return;\n }\n if (!title) {\n card.querySelector('.add-layer-title').style.borderColor = '#ef4444';\n return;\n }\n\n this._addExternalLayer(type, url, layerName, title);\n this._hideAddLayerDialog();\n });\n\n // Enter key to confirm\n card.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') {\n e.preventDefault();\n card.querySelector('.add-layer-confirm').click();\n }\n if (e.key === 'Escape') {\n e.preventDefault();\n close();\n }\n });\n }\n\n /**\n * Show the add-layer dialog.\n */\n showAddLayerDialog() {\n const dlg = this._addLayerDialog;\n // Reset form\n dlg.querySelector('.add-layer-url').value = '';\n dlg.querySelector('.add-layer-name').value = '';\n dlg.querySelector('.add-layer-title').value = '';\n dlg.querySelectorAll('input[name=\"add-layer-type\"]')[0].checked = true;\n dlg.querySelector('.add-layer-name-row').style.display = '';\n dlg.querySelector('.add-layer-url').placeholder = 'https://example.com/wms';\n dlg.querySelector('.add-layer-name-hint').textContent = 'WMS LAYERS parameter (e.g. workspace:layer)';\n const typeHintEl = dlg.querySelector('.add-layer-type-hint');\n if (typeHintEl) typeHintEl.textContent = '';\n\n // Reset border colours\n dlg.querySelectorAll('input[type=\"text\"]').forEach((inp) => {\n inp.style.borderColor = 'var(--border, #1e1a4b1f)';\n });\n\n dlg.style.display = 'flex';\n dlg.querySelector('.add-layer-url').focus();\n }\n\n /**\n * Hide the add-layer dialog.\n */\n _hideAddLayerDialog() {\n this._addLayerDialog.style.display = 'none';\n }\n\n /**\n * Add an external layer to the \"External Source\" group.\n *\n * @param {string} type 'wms' | 'wfs' | 'xyz'\n * @param {string} url Server URL\n * @param {string} layerName WMS LAYERS / WFS typename (ignored for XYZ)\n * @param {string} title Display title in layer switcher\n */\n _addExternalLayer(type, url, layerName, title) {\n const group = this._externalSourceGroup;\n if (!group) {\n showToast('Layer group \"External Source\" not found.', 'error', 4000);\n return;\n }\n\n let layer;\n\n switch (type) {\n case 'wms': {\n const wmsSrc = new TileWMS({\n url,\n params: { LAYERS: layerName, TILED: true, WIDTH: 256, HEIGHT: 256 },\n serverType: 'geoserver',\n crossOrigin: 'anonymous',\n hidpi: false,\n });\n layer = new TileLayer({\n title,\n visible: true,\n source: wmsSrc,\n });\n wmsSrc.on('tileloaderror', () => {\n showToast(`WMS \"${title}\" — tile load error. Check URL and layer name.`, 'warning', 5000);\n });\n break;\n }\n\n case 'wfs': {\n const wfsUrl = `${url}${url.includes('?') ? '&' : '?'}` +\n `service=WFS&version=1.1.0&request=GetFeature` +\n `&typename=${encodeURIComponent(layerName)}` +\n `&outputFormat=application/json&srsname=EPSG:3857`;\n\n const wfsSource = new VectorSource({\n url: wfsUrl,\n format: new GeoJSON(),\n });\n wfsSource.on('featuresloaderror', () => {\n showToast(`WFS \"${title}\" — load error. Check URL and layer name.`, 'warning', 5000);\n });\n\n layer = new VectorLayer({\n title,\n visible: true,\n source: wfsSource,\n style: new Style({\n stroke: new Stroke({ color: '#e11d48', width: 2 }),\n fill: new Fill({ color: 'rgba(225,29,72,0.15)' }),\n }),\n });\n break;\n }\n\n case 'xyz':\n layer = new TileLayer({\n title,\n visible: true,\n source: new XYZ({\n url,\n crossOrigin: 'anonymous',\n }),\n });\n layer.getSource().on('tileloaderror', () => {\n showToast(`XYZ \"${title}\" — tile load error. Check the URL template.`, 'warning', 5000);\n });\n break;\n\n case 'cog': {\n // A Cloud-Optimized GeoTIFF is a single file streamed by byte-range\n // request. addCOGLayer() lazy-loads the GeoTIFF stack, pushes the layer\n // into the group itself and reports its own failures, so we return\n // early rather than falling through to the shared push below.\n const groupTitle = group.get('title') || 'External Source';\n this.addCOGLayer(groupTitle, title, url).then((cogLayer) => {\n if (!cogLayer) return;\n cogLayer.set('removable', true);\n showToast(`Layer \"${title}\" added to External Source.`, 'success', 3000);\n console.log(`[MapView] External COG layer added: \"${title}\"`);\n });\n return;\n }\n\n default:\n showToast(`Unknown layer type: ${type}`, 'error', 4000);\n return;\n }\n\n // Tag for the LayerSwitcher chip\n layer.set('typeTag', type.toUpperCase()); // 'WMS' | 'WFS' | 'XYZ'\n layer.set('typeDescription', {\n wms: 'WMS / Raster',\n wfs: 'WFS / Vector',\n xyz: 'XYZ / Tile',\n }[type] || type.toUpperCase());\n\n // User-added external layers ARE removable — they're not part of the\n // app's built-in data model.\n layer.set('removable', true);\n\n group.getLayers().push(layer);\n showToast(`Layer \"${title}\" added to External Source.`, 'success', 3000);\n console.log(`[MapView] External ${type.toUpperCase()} layer added: \"${title}\"`);\n }\n\n // ============================================================================\n // LayerSwitcher decoration (Option A — visual refresh)\n // ============================================================================\n\n /**\n * Decorate a layer's
  • after ol-ext renders it:\n * • inject a type-tag chip next to the layer label\n * • inject the green \"+\" button on the External Source group header\n *\n * Idempotent — safe to call repeatedly; each injected element checks\n * whether it already exists in the row.\n */\n _decorateLayerListItem(layer, li) {\n // 1. Type-tag chip (e.g. WMS / XYZ / VEC) next to the layer name.\n // Inserted INSIDE the label's so it doesn't collide with the\n // label's left padding (where ol-ext draws the checkbox via ::before).\n const tag = layer.get('typeTag'); // 'WMS' | 'WFS' | 'XYZ' | 'VEC' | 'GEO' | 'BASE'\n if (tag) {\n const labelSpan = li.querySelector(':scope > .li-content > label > span');\n if (labelSpan && !labelSpan.querySelector(':scope > .ls-type-tag')) {\n const chip = document.createElement('span');\n chip.className = `ls-type-tag ls-type-tag-${String(tag).toLowerCase()}`;\n chip.textContent = String(tag);\n chip.title = `${tag} layer`;\n labelSpan.appendChild(chip);\n }\n }\n\n // 2. Replace ol-ext's bar-drawn +/- chevron with the GeoView chevron SVG.\n // The SVG uses stroke=\"currentColor\", so CSS `color` (set in\n // layerswitcher.css) tints it. Rotation handles the open/closed state.\n const btnBar = li.querySelector(':scope > .ol-layerswitcher-buttons');\n if (btnBar) {\n const chevronEl = btnBar.querySelector(':scope > .expend-layers, :scope > .collapse-layers');\n if (chevronEl && !chevronEl.querySelector(':scope > svg.ls-chevron-svg')) {\n chevronEl.innerHTML =\n '' +\n '' +\n '';\n }\n }\n\n // 3. Layer-type subtitle (\"Vector / Polygon\", \"WMS / Raster\", …) below\n // the layer name. Only rendered when layer.get('typeDescription') is\n // set. For layers that start empty and gain features later (the API\n // loaders), we listen for `change:typeDescription` and update or\n // insert the subtitle then.\n const content = li.querySelector(':scope > .li-content');\n const ensureSubtitle = () => {\n if (!content) return;\n const text = layer.get('typeDescription');\n let sub = content.querySelector(':scope > .ls-layer-subtitle');\n if (!text) {\n if (sub) sub.remove();\n return;\n }\n if (!sub) {\n sub = document.createElement('div');\n sub.className = 'ls-layer-subtitle';\n const label = content.querySelector(':scope > label');\n if (label && label.nextSibling) {\n content.insertBefore(sub, label.nextSibling);\n } else {\n content.appendChild(sub);\n }\n }\n sub.textContent = text;\n };\n ensureSubtitle();\n if (!layer._lsSubtitleHooked) {\n layer._lsSubtitleHooked = true;\n layer.on('change:typeDescription', () => {\n // The
  • may not exist any more (panel re-rendered between events)\n // — guard with a fresh lookup via the LayerSwitcher next time it draws.\n // For now we just call ensureSubtitle bound to the original li.\n ensureSubtitle();\n });\n }\n\n // 4. Per-layer Remove button — only for layers explicitly marked\n // `removable: true` (external sources, imported files, …). Built-in\n // layers (Parcels, OSM_roads, district boundary, …) are NOT removable\n // so the user can't accidentally delete them.\n if (layer.get('removable') === true && btnBar && !btnBar.querySelector(':scope > .ls-remove-btn')) {\n const removeBtn = document.createElement('button');\n removeBtn.type = 'button';\n removeBtn.className = 'ls-remove-btn';\n removeBtn.title = 'Remove this layer';\n removeBtn.setAttribute('aria-label', 'Remove layer');\n removeBtn.innerHTML =\n '' +\n '' +\n '';\n removeBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n this._removeLayer(layer);\n });\n btnBar.appendChild(removeBtn);\n }\n\n // 5b. Import-state chip on layers staged via the external-dataset import\n // flow (LUPMIS2_Import_Upload_Design.docx §3.2). The chip's text and\n // behaviour depend on `_externalImportStatus`:\n // 'mapped' → \"Upload N\" (click → upload)\n // 'uploading' → spinner (disabled)\n // 'submitted' → \"✓ submitted\" (in upload_tmp, awaiting review)\n // 'migrated' → \"✓ live\" (supervisor promoted to lu_parcels)\n // 'failed' → \"N errors — fix?\"\n // 'other'/null→ no chip\n // Clicks dispatch a window-level CustomEvent so main.js can react\n // without MapView knowing anything about staging.\n const importId = layer.get('_externalImportId');\n if (importId != null) {\n const labelSpan = li.querySelector(':scope > .li-content > label > span');\n let chip = labelSpan ? labelSpan.querySelector(':scope > .ls-import-chip') : null;\n const status = layer.get('_externalImportStatus') || 'mapped';\n const featureCount = layer.getSource()?.getFeatures().length ?? 0;\n const errorCount = layer.get('_externalImportErrorCount') ?? 0;\n\n // Decide chip appearance.\n const chipSpec = (() => {\n switch (status) {\n case 'mapped':\n return { text: `Upload ${featureCount}`, cls: 'ls-import-chip-mapped',\n title: 'Upload this dataset to the database', clickable: true };\n case 'uploading':\n return { text: '…', cls: 'ls-import-chip-uploading',\n title: 'Uploading…', clickable: false };\n case 'submitted':\n return { text: '✓ submitted', cls: 'ls-import-chip-submitted',\n title: 'Uploaded — awaiting supervisor review', clickable: false };\n case 'migrated':\n return { text: '✓ live', cls: 'ls-import-chip-migrated',\n title: 'Approved by supervisor and live on the server', clickable: false };\n case 'failed':\n return { text: `${errorCount} errors — fix?`, cls: 'ls-import-chip-failed',\n title: 'Some rows failed; click to review', clickable: true };\n case 'other':\n case null:\n case undefined:\n default:\n return null;\n }\n })();\n\n if (!chipSpec) {\n if (chip) chip.remove();\n } else if (labelSpan) {\n if (!chip) {\n chip = document.createElement('span');\n chip.className = 'ls-import-chip';\n labelSpan.appendChild(chip);\n }\n chip.className = `ls-import-chip ${chipSpec.cls}`;\n chip.textContent = chipSpec.text;\n chip.title = chipSpec.title;\n chip.style.cursor = chipSpec.clickable ? 'pointer' : 'default';\n chip.style.opacity = chipSpec.clickable ? '1' : '0.85';\n\n // Replace any prior listener by cloning the node.\n const fresh = chip.cloneNode(true);\n chip.replaceWith(fresh);\n chip = fresh;\n\n if (chipSpec.clickable) {\n chip.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n window.dispatchEvent(new CustomEvent('lupmis:import-chip-click', {\n detail: { importId, status, layer },\n }));\n });\n }\n }\n }\n\n // 5. \"+\" button on the External Source group\n const groupTitle = (layer.get('title') || '').toLowerCase();\n if (groupTitle.includes('external')) {\n this._externalSourceGroup = layer;\n // btnBar already resolved above (same .ol-layerswitcher-buttons element)\n if (btnBar && !btnBar.querySelector('.ol-add-layer')) {\n const addBtn = document.createElement('span');\n addBtn.className = 'ol-add-layer';\n addBtn.title = 'Add external layer';\n addBtn.textContent = '+';\n addBtn.style.cssText = `\n display:inline-flex !important;align-items:center;justify-content:center;\n width:22px !important;height:22px !important;border-radius:50%;\n background:#41b6a6 !important;color:#fff !important;\n font-size:15px !important;font-weight:700;\n cursor:pointer;line-height:1 !important;\n margin:0 4px 0 0;vertical-align:middle;\n transition:background 0.2s;box-sizing:border-box;border:none;\n `;\n addBtn.addEventListener('mouseenter', () => { addBtn.style.background = '#329686'; });\n addBtn.addEventListener('mouseleave', () => { addBtn.style.background = '#41b6a6'; });\n addBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n this.showAddLayerDialog();\n });\n btnBar.prepend(addBtn);\n }\n }\n }\n\n /**\n * Remove a layer from its parent group, after confirmation. Only called\n * from the per-layer × button injected by `_decorateLayerListItem` — and\n * that button is only injected for layers marked `removable: true`, so\n * built-in layers (Parcels, OSM_roads, …) can never reach this path.\n *\n * @param {Layer} layer\n */\n _removeLayer(layer) {\n const title = layer.get('title') || 'this layer';\n if (!confirm(`Remove \"${title}\" from the map?\\n\\nThis only affects the current session — built-in layers cannot be removed.`)) {\n return;\n }\n\n // Find the parent group that owns this layer and call .remove() on its\n // collection. Walk recursively from the overlay group.\n const visit = (group) => {\n const layers = group.getLayers();\n if (layers.getArray().includes(layer)) {\n layers.remove(layer);\n return true;\n }\n let removed = false;\n layers.forEach((child) => {\n if (!removed && child.getLayers) {\n removed = visit(child);\n }\n });\n return removed;\n };\n\n const ok = visit(this.overlayGroup);\n if (ok) {\n console.log(`[MapView] Removed layer \"${title}\"`);\n showToast(`Removed \"${title}\" from the map.`, 'info', 3000);\n } else {\n console.warn(`[MapView] Could not find layer \"${title}\" in any group`);\n }\n }\n\n /**\n * Inject (or refresh) the panel chrome — an \"active count\" badge at the top\n * and a footer row with \"Reset overlays\" button at the bottom.\n *\n * The chrome lives in `.panel-container` (the wrapping
    ), not inside\n * the `
      ` — that way the badge and footer are siblings of\n * the layer list rather than malformed children of a `
        `.\n *\n * Called from drawlist via queueMicrotask, so it runs once per redraw cycle\n * regardless of how many layers are in the panel.\n */\n _refreshLayerSwitcherChrome(layerSwitcher) {\n const panelContainer = layerSwitcher.element?.querySelector('.panel-container');\n const ul = layerSwitcher.element?.querySelector('ul.panel');\n if (!panelContainer || !ul) return;\n\n // --- Active-count badge (top of panel-container, before the
          ) ---\n let badge = panelContainer.querySelector(':scope > .ls-active-badge');\n if (!badge) {\n badge = document.createElement('div');\n badge.className = 'ls-active-badge';\n badge.innerHTML = `\n Layers\n 0 active\n `;\n panelContainer.insertBefore(badge, ul);\n }\n\n // --- Footer row (bottom of panel-container, after the
            ) ---\n let footer = panelContainer.querySelector(':scope > .ls-footer-row');\n if (!footer) {\n footer = document.createElement('div');\n footer.className = 'ls-footer-row';\n footer.innerHTML = `\n — layers total\n \n `;\n panelContainer.appendChild(footer);\n\n footer.querySelector('.ls-footer-btn').addEventListener('click', (e) => {\n e.stopPropagation();\n this._resetAllOverlays();\n });\n }\n\n // --- Update counters ---\n const counts = this._countLayers();\n badge.querySelector('.ls-active-badge-count').textContent =\n `${counts.activeOverlays} active`;\n footer.querySelector('.ls-footer-note').textContent =\n `${counts.totalOverlays} overlay${counts.totalOverlays === 1 ? '' : 's'}`;\n }\n\n /**\n * Walk the overlay group recursively, returning the number of *leaf* layers\n * (i.e. not groups) and how many of them are currently visible.\n * Excludes base maps and internal layers (Markers, Drawings, vertex overlay).\n */\n _countLayers() {\n let totalOverlays = 0;\n let activeOverlays = 0;\n\n const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);\n\n const visit = (group) => {\n group.getLayers().forEach((layer) => {\n if (layer.get('displayInLayerSwitcher') === false) return;\n if (HIDDEN_INTERNAL.has(layer.get('title'))) return;\n\n if (layer.getLayers) {\n visit(layer);\n } else {\n totalOverlays++;\n if (layer.getVisible()) activeOverlays++;\n }\n });\n };\n if (this.overlayGroup) visit(this.overlayGroup);\n return { totalOverlays, activeOverlays };\n }\n\n /**\n * Hide every overlay layer (base maps stay on). Wired to the footer\n * \"Reset overlays\" button.\n */\n _resetAllOverlays() {\n const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);\n const visit = (group) => {\n group.getLayers().forEach((layer) => {\n if (layer.get('displayInLayerSwitcher') === false) return;\n if (HIDDEN_INTERNAL.has(layer.get('title'))) return;\n\n if (layer.getLayers) {\n visit(layer);\n } else {\n layer.setVisible(false);\n }\n });\n };\n if (this.overlayGroup) visit(this.overlayGroup);\n console.log('[MapView] Reset overlays — all hidden');\n }\n\n /**\n * Hook `change:visible` on every overlay leaf so the active-count badge\n * stays in sync even when ol-ext doesn't re-fire drawlist (e.g. ticking\n * a checkbox just toggles visibility without rebuilding the list).\n * Also re-hooks when a new layer is added.\n */\n _wireLayerSwitcherVisibilityHooks(layerSwitcher) {\n const refresh = () => this._refreshLayerSwitcherChrome(layerSwitcher);\n\n const hookLayer = (layer) => {\n if (layer._lsVisHooked) return;\n layer._lsVisHooked = true;\n layer.on('change:visible', refresh);\n };\n\n const visit = (group) => {\n group.getLayers().forEach((layer) => {\n if (layer.getLayers) {\n visit(layer);\n // Listen for layers added later to this group\n if (!group._lsAddHooked) {\n group._lsAddHooked = true;\n group.getLayers().on('add', (ev) => {\n const added = ev.element;\n if (added.getLayers) visit(added); else hookLayer(added);\n refresh();\n });\n }\n } else {\n hookLayer(layer);\n }\n });\n };\n\n if (this.overlayGroup) visit(this.overlayGroup);\n }\n\n // ============================================================================\n // Online-Only Layer Helper\n // ============================================================================\n\n /**\n * Attach a `change:visible` listener that shows an info toast when the user\n * toggles a layer ON while the device is offline. Used for layers that fetch\n * tiles or features from a remote service and therefore have no useful\n * cached state.\n *\n * The check uses navigator.onLine, which is the same signal as the rest of\n * the app's online detection.\n *\n * @param {Layer} layer\n * @param {string} title Display title used in the toast message\n */\n _attachOnlineOnlyHandler(layer, title) {\n layer.set('onlineOnly', true);\n layer.on('change:visible', () => {\n if (layer.getVisible() && !navigator.onLine) {\n showToast(\n `\"${title}\" requires an internet connection. Connect to view this layer.`,\n 'info',\n 5000,\n );\n }\n });\n }\n\n // ============================================================================\n // Legend Panel — shows legend images for visible layers that have one\n // ============================================================================\n\n /**\n * Create the legend panel, positioned bottom-right inside the map target.\n * Hidden when no visible layers have a registered legend.\n */\n _createLegendPanel() {\n this._legendPanel = document.createElement('div');\n this._legendPanel.className = 'map-legend-panel';\n this._legendPanel.style.cssText = `\n position:absolute;right:10px;bottom:40px;z-index:900;\n display:none;flex-direction:column;gap:6px;\n background:var(--card, #fff);color:var(--card-foreground, #1e1a4b);\n border:1px solid var(--border, #1e1a4b1f);border-radius:8px;\n box-shadow:0 4px 12px rgba(0,0,0,0.15);\n font-family:var(--font-body, 'Exo', sans-serif);font-size:11px;\n max-width:220px;max-height:60%;overflow-y:auto;\n padding:8px 10px;\n `;\n this.map.getTargetElement().appendChild(this._legendPanel);\n\n // Map of layer → { wrapper, title, imgUrl }\n this._legendEntries = new Map();\n }\n\n /**\n * Register a layer's legend image and wire up visibility tracking.\n * Called from addWMSLayer / addXYZLayer when a legendUrl is supplied.\n *\n * @param {Layer} layer The OpenLayers layer\n * @param {string} title Display title for the legend header\n * @param {string} legendUrl URL of the legend image\n */\n _registerLegend(layer, title, legendUrl) {\n if (!this._legendPanel) return;\n\n // Build the legend entry — a div with header + image\n const wrapper = document.createElement('div');\n wrapper.className = 'map-legend-entry';\n wrapper.style.cssText = 'border-bottom:1px solid var(--border, #1e1a4b1f);padding-bottom:6px;';\n wrapper.innerHTML = `\n
            \n ${this._escapeHtml(title)}\n
            \n \"${this._escapeHtml(title)}\n `;\n\n this._legendEntries.set(layer, wrapper);\n\n // Listen for visibility changes. Wrap in try/catch so a DOM error here\n // cannot break the LayerSwitcher's click handler (which fires change:visible\n // synchronously and relies on a subsequent setTimeout to update the checkbox).\n const update = () => {\n try { this._updateLegendPanel(); }\n catch (err) { console.warn('[MapView] legend panel update failed:', err); }\n };\n layer.on('change:visible', update);\n\n // Trigger initial state\n update();\n }\n\n /**\n * Refresh the legend panel contents: include entries for each visible\n * registered layer, and show/hide the panel based on whether any are visible.\n */\n _updateLegendPanel() {\n if (!this._legendPanel) return;\n\n // Rebuild children from scratch in a stable order (Map iteration order = insertion order)\n const children = [];\n for (const [layer, wrapper] of this._legendEntries) {\n if (layer.getVisible()) children.push(wrapper);\n }\n\n // Remove trailing bottom-border on the last entry for a clean look\n this._legendEntries.forEach((w) => {\n w.style.borderBottom = '1px solid var(--border, #1e1a4b1f)';\n w.style.paddingBottom = '6px';\n });\n if (children.length > 0) {\n children[children.length - 1].style.borderBottom = 'none';\n children[children.length - 1].style.paddingBottom = '0';\n }\n\n // Swap the DOM children\n this._legendPanel.replaceChildren(...children);\n this._legendPanel.style.display = children.length > 0 ? 'flex' : 'none';\n }\n\n /**\n * Escape HTML special characters for safe text insertion.\n */\n _escapeHtml(str) {\n return String(str)\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n /**\n * Find a LayerGroup inside the overlay group by its layerId.\n *\n * @param {number|string} id - The layerId to search for\n * @returns {LayerGroup|null} The matching group, or null\n */\n getLayerGroup(id) {\n let found = null;\n this.overlayGroup.getLayers().forEach((layer) => {\n if (layer.get('layerId') === id) {\n found = layer;\n }\n });\n return found;\n }\n\n /**\n * Find a LayerGroup inside the overlay group by its title.\n *\n * @param {string} title - The group title to search for\n * @returns {LayerGroup|null} The matching group, or null\n */\n getLayerGroupByTitle(title) {\n let found = null;\n this.overlayGroup.getLayers().forEach((layer) => {\n if (layer.get('title') === title) {\n found = layer;\n }\n });\n return found;\n }\n\n /**\n * Get the overlay LayerGroup for advanced usage\n */\n getOverlayGroup() {\n return this.overlayGroup;\n }\n\n /**\n * Get the OpenLayers map instance for advanced usage\n */\n getMap() {\n return this.map;\n }\n\n // ============================================================================\n // Extent Helpers (used by offline-tile downloader)\n // ============================================================================\n\n /**\n * Get the current map view's visible extent in EPSG:3857 (Web Mercator).\n * @returns {Array} [minX, minY, maxX, maxY]\n */\n getCurrentViewExtent() {\n const view = this.map.getView();\n const size = this.map.getSize();\n if (!size) return null;\n return view.calculateExtent(size);\n }\n\n /**\n * Get the bounding extent of the District Boundary layer (if present).\n * Searches the overlay group for a vector layer titled \"District Boundary\"\n * and returns the extent of its source.\n *\n * @returns {{ extent: Array, title: string } | null}\n */\n getDistrictBoundaryExtent() {\n let found = null;\n const visit = (group) => {\n group.getLayers().forEach((layer) => {\n if (layer.getLayers) {\n visit(layer); // sub-group\n } else if (layer.get('title') === 'District Boundary') {\n const src = layer.getSource && layer.getSource();\n if (src && typeof src.getExtent === 'function') {\n const ex = src.getExtent();\n if (ex && Number.isFinite(ex[0])) {\n found = { extent: ex, title: layer.get('title') };\n }\n }\n }\n });\n };\n visit(this.overlayGroup);\n return found;\n }\n\n /**\n * Get the marker source for advanced usage\n */\n getMarkerSource() {\n return this.markerSource;\n }\n\n /**\n * Get the markers layer for advanced usage\n */\n getMarkersLayer() {\n return this.markersLayer;\n }\n\n /**\n * Update map size (call after container resize)\n */\n updateSize() {\n this.map.updateSize();\n }\n\n /**\n * Register a callback for when a search result is selected\n * Callback receives: { coordinate, lonLat: [lon, lat], name, searchResult }\n * Navigation to the location happens automatically\n */\n onSearchSelect(callback) {\n this.searchSelectCallbacks.push(callback);\n }\n\n /**\n * Navigate/fly to a specific location\n * @param {number} lon - Longitude\n * @param {number} lat - Latitude\n * @param {number} zoom - Zoom level (default: 14)\n * @param {number} duration - Animation duration in ms (default: 500)\n */\n navigateTo(lon, lat, zoom = 14, duration = 500) {\n const coordinate = fromLonLat([lon, lat]);\n this.map.getView().animate({\n center: coordinate,\n zoom: zoom,\n duration: duration,\n });\n }\n}\n\n// Export OpenLayers utilities for convenience\nexport { fromLonLat, toLonLat };\n\nexport default MapView;\n","/**\n * MapTools - Drawing and Measurement Tools\n * \n * Provides:\n * - Circle measurement tool with radius/area tooltip\n * - Control bar with drawing tools\n * - Line and polygon measurement\n * \n * Refactored from olmapstuffgis.js for LUPMIS PWA\n */\n\nimport { Draw } from 'ol/interaction';\nimport { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style';\nimport { Vector as VectorLayer } from 'ol/layer';\nimport { Vector as VectorSource } from 'ol/source';\nimport Overlay from 'ol/Overlay';\nimport { LineString, Circle, Polygon } from 'ol/geom';\nimport { getLength, getArea } from 'ol/sphere';\nimport Feature from 'ol/Feature';\nimport { unByKey } from 'ol/Observable';\nimport { formatLength, formatArea, formatCircleExtent } from '../units.js';\n\n// ol-ext imports\nimport EditBar from 'ol-ext/control/EditBar';\nimport Toggle from 'ol-ext/control/Toggle';\nimport Button from 'ol-ext/control/Button';\n\nexport class MapTools {\n constructor(map, options = {}) {\n this.map = map;\n this.options = options;\n \n // Create measurement layer\n this.measureSource = new VectorSource();\n this.measureLayer = new VectorLayer({\n source: this.measureSource,\n style: this.getMeasureStyle(),\n title: 'Measurements',\n zIndex: 100,\n // Measurements are read-only. Without this the Digitise tool treats a\n // measured circle like any other geometry: it shows vertex handles, and\n // dragging one silently reshapes the measurement — the intersection test\n // follows the new shape while the reported radius and area do not.\n selectable: false,\n });\n\n // Create drawing layer (utility layer for temporary draw interactions;\n // hidden from LayerSwitcher since the EditBar has its own \"Drawings\" group)\n this.drawSource = new VectorSource();\n this.drawLayer = new VectorLayer({\n source: this.drawSource,\n style: this.getDrawStyle(),\n title: 'Draw sketches',\n displayInLayerSwitcher: false,\n zIndex: 99,\n });\n\n // Insert both layers just before the Overlays group so the LayerSwitcher\n // order becomes: Overlays > Measurements > Markers > Base Maps. Locate the\n // Overlays group by title rather than assuming it is the last layer —\n // other layers (e.g. the GPS trail/position layers) may sit on top of it.\n const layers = this.map.getLayers();\n let overlayIdx = layers.getArray().findIndex((l) => l.get('title') === 'Overlays');\n if (overlayIdx < 0) overlayIdx = layers.getLength();\n layers.insertAt(overlayIdx, this.drawLayer);\n layers.insertAt(overlayIdx, this.measureLayer);\n \n // Active interaction\n this.activeInteraction = null;\n this.measureTooltip = null;\n this.measureTooltipElement = null;\n \n // Callbacks\n this.onMeasureCompleteCallbacks = [];\n this.onDrawCompleteCallbacks = [];\n }\n \n /**\n * Get style for measurement features\n */\n getMeasureStyle() {\n return new Style({\n fill: new Fill({\n color: 'rgba(255, 233, 106, 0.2)'\n }),\n stroke: new Stroke({\n color: '#8B008B',\n lineDash: [10, 10],\n width: 2\n }),\n image: new CircleStyle({\n radius: 5,\n stroke: new Stroke({\n color: '#8B008B'\n }),\n fill: new Fill({\n color: 'rgba(255, 233, 106, 0.5)'\n })\n })\n });\n }\n \n /**\n * Get style for drawing features\n */\n getDrawStyle() {\n return new Style({\n fill: new Fill({\n color: 'rgba(255, 233, 106, 0.3)'\n }),\n stroke: new Stroke({\n color: '#8B008B',\n width: 2\n }),\n image: new CircleStyle({\n radius: 6,\n stroke: new Stroke({\n color: '#8B008B',\n width: 2\n }),\n fill: new Fill({\n color: '#FFE96A'\n })\n })\n });\n }\n \n /**\n * Create measurement tooltip overlay\n */\n createMeasureTooltip() {\n if (this.measureTooltipElement) {\n this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);\n }\n this.measureTooltipElement = document.createElement('div');\n this.measureTooltipElement.className = 'measure-tooltip';\n this.measureTooltip = new Overlay({\n element: this.measureTooltipElement,\n offset: [15, 0],\n positioning: 'center-left',\n stopEvent: false,\n });\n this.map.addOverlay(this.measureTooltip);\n }\n \n /**\n * Remove any active interaction\n */\n deactivate() {\n if (this.activeInteraction) {\n this.map.removeInteraction(this.activeInteraction);\n this.activeInteraction = null;\n }\n if (this.measureTooltip) {\n this.map.removeOverlay(this.measureTooltip);\n this.measureTooltip = null;\n }\n if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {\n this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);\n this.measureTooltipElement = null;\n }\n }\n \n /**\n * Start circle measurement tool\n * Draws a circle and shows radius + area in tooltip\n */\n startCircleMeasure() {\n this.deactivate();\n this.createMeasureTooltip();\n \n const drawCircle = new Draw({\n source: this.measureSource,\n type: 'Circle',\n style: new Style({\n fill: new Fill({\n color: 'rgba(255, 233, 106, 0.2)'\n }),\n stroke: new Stroke({\n color: 'rgba(139, 0, 139, 0.7)',\n lineDash: [10, 10],\n width: 2\n }),\n image: new CircleStyle({\n radius: 5,\n stroke: new Stroke({\n color: 'rgba(139, 0, 139, 0.7)'\n }),\n fill: new Fill({\n color: 'rgba(255, 233, 106, 0.5)'\n })\n })\n })\n });\n \n this.activeInteraction = drawCircle;\n this.map.addInteraction(drawCircle);\n \n let listener;\n \n drawCircle.on('drawstart', (evt) => {\n const sketch = evt.feature;\n \n listener = sketch.getGeometry().on('change', (e) => {\n const geom = e.target;\n \n if (geom instanceof Circle) {\n const radius = geom.getRadius();\n const area = formatCircleExtent(radius);\n const radiusFormatted = formatLength(radius);\n \n const output = `${radiusFormatted}
            ${area}`;\n \n this.measureTooltipElement.innerHTML = output;\n this.measureTooltip.setPosition(geom.getLastCoordinate());\n }\n });\n });\n \n drawCircle.on('drawend', (evt) => {\n const feature = evt.feature;\n const geom = feature.getGeometry();\n const center = geom.getCenter();\n const radius = geom.getRadius();\n\n // Tag the circle feature so the dblclick handler can identify it\n feature.set('_layerType', 'measure_circle');\n // Snapshot only — NOT the source of truth. Anything reporting the radius\n // must read it from the geometry (getRadius()), otherwise the figure can\n // drift from the shape actually being analysed.\n feature.set('_radius', radius);\n feature.set('_center', center);\n\n // Create radius line for visualization\n const radiusLine = new Feature({\n geometry: new LineString([\n center,\n [center[0] + radius, center[1]]\n ])\n });\n radiusLine.set('_layerType', 'measure_circle_radius');\n this.measureSource.addFeature(radiusLine);\n \n // Make tooltip static\n this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';\n this.measureTooltip.setOffset([0, -7]);\n \n // Create new tooltip for next measurement\n this.measureTooltipElement = null;\n this.createMeasureTooltip();\n \n unByKey(listener);\n \n // Trigger callbacks\n const result = {\n type: 'circle',\n center: center,\n radius: radius,\n area: Math.PI * radius * radius,\n feature: feature,\n };\n this.onMeasureCompleteCallbacks.forEach(cb => cb(result));\n });\n \n return drawCircle;\n }\n \n /**\n * Start line measurement tool\n */\n startLineMeasure() {\n this.deactivate();\n this.createMeasureTooltip();\n \n const drawLine = new Draw({\n source: this.measureSource,\n type: 'LineString',\n style: this.getMeasureStyle(),\n });\n \n this.activeInteraction = drawLine;\n this.map.addInteraction(drawLine);\n \n let listener;\n \n drawLine.on('drawstart', (evt) => {\n const sketch = evt.feature;\n \n listener = sketch.getGeometry().on('change', (e) => {\n const geom = e.target;\n const length = getLength(geom);\n const output = formatLength(length);\n \n this.measureTooltipElement.innerHTML = output;\n this.measureTooltip.setPosition(geom.getLastCoordinate());\n });\n });\n \n drawLine.on('drawend', (evt) => {\n const feature = evt.feature;\n const geom = feature.getGeometry();\n const length = getLength(geom);\n \n this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';\n this.measureTooltipElement = null;\n this.createMeasureTooltip();\n \n unByKey(listener);\n \n const result = {\n type: 'line',\n length: length,\n feature: feature,\n };\n this.onMeasureCompleteCallbacks.forEach(cb => cb(result));\n });\n \n return drawLine;\n }\n \n /**\n * Start polygon/area measurement tool\n */\n startAreaMeasure() {\n this.deactivate();\n this.createMeasureTooltip();\n \n const drawPolygon = new Draw({\n source: this.measureSource,\n type: 'Polygon',\n style: this.getMeasureStyle(),\n });\n \n this.activeInteraction = drawPolygon;\n this.map.addInteraction(drawPolygon);\n \n let listener;\n \n drawPolygon.on('drawstart', (evt) => {\n const sketch = evt.feature;\n \n listener = sketch.getGeometry().on('change', (e) => {\n const geom = e.target;\n const area = getArea(geom);\n const output = formatArea(area);\n \n this.measureTooltipElement.innerHTML = output;\n this.measureTooltip.setPosition(geom.getInteriorPoint().getCoordinates());\n });\n });\n \n drawPolygon.on('drawend', (evt) => {\n const feature = evt.feature;\n const geom = feature.getGeometry();\n const area = getArea(geom);\n\n // Tag so the double-click handler can identify it\n feature.set('_layerType', 'measure_area');\n feature.set('_area', area);\n\n this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';\n this.measureTooltipElement = null;\n this.createMeasureTooltip();\n\n unByKey(listener);\n\n const result = {\n type: 'polygon',\n area: area,\n feature: feature,\n coordinate: geom.getInteriorPoint().getCoordinates(),\n };\n this.onMeasureCompleteCallbacks.forEach(cb => cb(result));\n });\n \n return drawPolygon;\n }\n \n /**\n * Start point drawing tool\n */\n startDrawPoint() {\n this.deactivate();\n \n const drawPoint = new Draw({\n source: this.drawSource,\n type: 'Point',\n style: this.getDrawStyle(),\n });\n \n this.activeInteraction = drawPoint;\n this.map.addInteraction(drawPoint);\n \n drawPoint.on('drawend', (evt) => {\n const result = {\n type: 'point',\n feature: evt.feature,\n };\n this.onDrawCompleteCallbacks.forEach(cb => cb(result));\n });\n \n return drawPoint;\n }\n \n /**\n * Start line drawing tool\n */\n startDrawLine() {\n this.deactivate();\n \n const drawLine = new Draw({\n source: this.drawSource,\n type: 'LineString',\n style: this.getDrawStyle(),\n });\n \n this.activeInteraction = drawLine;\n this.map.addInteraction(drawLine);\n \n drawLine.on('drawend', (evt) => {\n const result = {\n type: 'line',\n feature: evt.feature,\n };\n this.onDrawCompleteCallbacks.forEach(cb => cb(result));\n });\n \n return drawLine;\n }\n \n /**\n * Start polygon drawing tool\n */\n startDrawPolygon() {\n this.deactivate();\n \n const drawPolygon = new Draw({\n source: this.drawSource,\n type: 'Polygon',\n style: this.getDrawStyle(),\n });\n \n this.activeInteraction = drawPolygon;\n this.map.addInteraction(drawPolygon);\n \n drawPolygon.on('drawend', (evt) => {\n const result = {\n type: 'polygon',\n feature: evt.feature,\n };\n this.onDrawCompleteCallbacks.forEach(cb => cb(result));\n });\n \n return drawPolygon;\n }\n \n /**\n * Clear all measurements\n */\n clearMeasurements() {\n this.measureSource.clear();\n // Remove static tooltips\n const tooltips = document.querySelectorAll('.measure-tooltip-static');\n tooltips.forEach(el => el.parentNode.removeChild(el));\n }\n \n /**\n * Clear all drawings\n */\n clearDrawings() {\n this.drawSource.clear();\n }\n \n /**\n * Clear all (measurements + drawings)\n */\n clearAll() {\n this.clearMeasurements();\n this.clearDrawings();\n }\n \n /**\n * Register callback for measurement completion\n */\n onMeasureComplete(callback) {\n this.onMeasureCompleteCallbacks.push(callback);\n }\n \n /**\n * Register callback for drawing completion\n */\n onDrawComplete(callback) {\n this.onDrawCompleteCallbacks.push(callback);\n }\n \n /**\n * Create a control bar with measurement and drawing tools\n * Returns the ol-ext Bar control\n */\n createControlBar(options = {}) {\n const position = options.position || 'top-left';\n \n // Main control bar\n const mainBar = new EditBar({\n group: true,\n className: 'map-tools-bar',\n });\n \n // Measurement toggle group\n const measureBar = new EditBar({\n toggleOne: true,\n group: true,\n });\n \n // Circle measure button\n const circleBtn = new Toggle({\n html: '',\n title: 'Measure Circle (radius & area)',\n className: 'measure-circle-btn',\n onToggle: (active) => {\n if (active) {\n this.startCircleMeasure();\n } else {\n this.deactivate();\n }\n }\n });\n measureBar.addControl(circleBtn);\n \n // Line measure button\n const lineBtn = new Toggle({\n html: '📏',\n title: 'Measure Distance',\n className: 'measure-line-btn',\n onToggle: (active) => {\n if (active) {\n this.startLineMeasure();\n } else {\n this.deactivate();\n }\n }\n });\n measureBar.addControl(lineBtn);\n \n // Area measure button\n const areaBtn = new Toggle({\n html: '',\n title: 'Measure Area',\n className: 'measure-area-btn',\n onToggle: (active) => {\n if (active) {\n this.startAreaMeasure();\n } else {\n this.deactivate();\n }\n }\n });\n measureBar.addControl(areaBtn);\n \n // Clear measurements button\n const clearBtn = new Button({\n html: '🗑️',\n title: 'Clear Measurements',\n className: 'clear-measure-btn',\n handleClick: () => {\n this.clearMeasurements();\n // Deactivate any active toggle\n circleBtn.setActive(false);\n lineBtn.setActive(false);\n areaBtn.setActive(false);\n }\n });\n measureBar.addControl(clearBtn);\n \n mainBar.addControl(measureBar);\n \n return mainBar;\n }\n \n /**\n * Get the measure layer\n */\n getMeasureLayer() {\n return this.measureLayer;\n }\n \n /**\n * Get the draw layer\n */\n getDrawLayer() {\n return this.drawLayer;\n }\n \n /**\n * Get the measure source\n */\n getMeasureSource() {\n return this.measureSource;\n }\n \n /**\n * Get the draw source\n */\n getDrawSource() {\n return this.drawSource;\n }\n \n /**\n * Check if any tool is currently active\n */\n isActive() {\n return this.activeInteraction !== null;\n }\n}\n\nexport default MapTools;\n","/**\n * PWA Module\n * \n * Handles Progressive Web App functionality:\n * - Service Worker registration\n * - Install prompt handling\n * - Offline detection\n * - Update notifications\n * \n * Note: The Service Worker (sw.js) handles caching.\n * The SharedWorker (shared-db-worker.js) handles database.\n * They are separate workers with different purposes.\n */\n\n// ============================================================================\n// Service Worker Registration\n// ============================================================================\n\nlet swRegistration = null;\n\nexport async function registerServiceWorker() {\n if (!('serviceWorker' in navigator)) {\n console.warn('[PWA] Service Workers not supported');\n return null;\n }\n \n try {\n swRegistration = await navigator.serviceWorker.register('/sw.js', {\n scope: '/'\n });\n \n console.log('[PWA] Service Worker registered:', swRegistration.scope);\n \n // Handle updates\n swRegistration.addEventListener('updatefound', () => {\n const newWorker = swRegistration.installing;\n \n newWorker.addEventListener('statechange', () => {\n if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {\n // New version available\n console.log('[PWA] New version available');\n showUpdateNotification();\n }\n });\n });\n \n return swRegistration;\n \n } catch (error) {\n console.error('[PWA] Service Worker registration failed:', error);\n return null;\n }\n}\n\n// ============================================================================\n// Install Prompt\n// ============================================================================\n\nlet deferredPrompt = null;\nlet installButton = null;\n\n/**\n * Initialize install prompt handling\n * @param {string|HTMLElement} buttonSelector - Button element or selector\n */\nexport function initInstallPrompt(buttonSelector = '#install-btn') {\n installButton = typeof buttonSelector === 'string' \n ? document.querySelector(buttonSelector)\n : buttonSelector;\n \n if (!installButton) {\n console.warn('[PWA] Install button not found:', buttonSelector);\n return;\n }\n \n // Initially hide the button\n installButton.style.display = 'none';\n \n // Listen for the beforeinstallprompt event\n window.addEventListener('beforeinstallprompt', (e) => {\n e.preventDefault();\n deferredPrompt = e;\n \n // Show the install button\n installButton.style.display = 'block';\n console.log('[PWA] Install prompt ready');\n });\n \n // Handle install button click\n installButton.addEventListener('click', async () => {\n if (!deferredPrompt) {\n // Show manual instructions for Safari\n showManualInstallInstructions();\n return;\n }\n \n deferredPrompt.prompt();\n const { outcome } = await deferredPrompt.userChoice;\n \n console.log('[PWA] Install prompt outcome:', outcome);\n \n deferredPrompt = null;\n installButton.style.display = 'none';\n });\n \n // Hide button if app is already installed\n window.addEventListener('appinstalled', () => {\n console.log('[PWA] App installed');\n deferredPrompt = null;\n installButton.style.display = 'none';\n });\n \n // Check if running as installed PWA\n if (window.matchMedia('(display-mode: standalone)').matches) {\n installButton.style.display = 'none';\n }\n}\n\nfunction showManualInstallInstructions() {\n const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);\n const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);\n \n let message = 'To install this app:\\n\\n';\n \n if (isIOS) {\n message += '1. Tap the Share button (square with arrow)\\n';\n message += '2. Scroll down and tap \"Add to Home Screen\"';\n } else if (isSafari) {\n message += '1. Click File menu\\n';\n message += '2. Click \"Add to Dock\"';\n } else {\n message += '1. Click the menu button (three dots)\\n';\n message += '2. Click \"Install\" or \"Add to Home Screen\"';\n }\n \n alert(message);\n}\n\n// ============================================================================\n// Offline Detection\n// ============================================================================\n\nlet offlineIndicator = null;\nconst offlineListeners = new Set();\n\n/**\n * Initialize offline detection\n * @param {string|HTMLElement} indicatorSelector - Element to show when offline\n */\nexport function initOfflineDetection(indicatorSelector = '#offline-indicator') {\n offlineIndicator = typeof indicatorSelector === 'string'\n ? document.querySelector(indicatorSelector)\n : indicatorSelector;\n \n // Set initial state\n updateOfflineUI(!navigator.onLine);\n \n // Listen for online/offline events\n window.addEventListener('online', () => {\n console.log('[PWA] Back online');\n updateOfflineUI(false);\n notifyOfflineListeners(false);\n });\n \n window.addEventListener('offline', () => {\n console.log('[PWA] Gone offline');\n updateOfflineUI(true);\n notifyOfflineListeners(true);\n });\n}\n\nfunction updateOfflineUI(isOffline) {\n if (offlineIndicator) {\n offlineIndicator.style.display = isOffline ? 'block' : 'none';\n }\n \n // Also toggle a class on body for CSS styling\n document.body.classList.toggle('is-offline', isOffline);\n}\n\n/**\n * Subscribe to offline state changes\n * @param {Function} listener - Callback(isOffline: boolean)\n * @returns {Function} Unsubscribe function\n */\nexport function onOfflineChange(listener) {\n offlineListeners.add(listener);\n // Immediately call with current state\n listener(!navigator.onLine);\n return () => offlineListeners.delete(listener);\n}\n\nfunction notifyOfflineListeners(isOffline) {\n for (const listener of offlineListeners) {\n try {\n listener(isOffline);\n } catch (e) {\n console.error('[PWA] Offline listener error:', e);\n }\n }\n}\n\n/**\n * Check if currently online\n */\nexport function isOnline() {\n return navigator.onLine;\n}\n\n// ============================================================================\n// Update Handling\n// ============================================================================\n\nlet updateCallback = null;\n\n/**\n * Set callback for when updates are available\n * @param {Function} callback - Called when new version is ready\n */\nexport function onUpdateAvailable(callback) {\n updateCallback = callback;\n}\n\nfunction showUpdateNotification() {\n if (updateCallback) {\n updateCallback();\n return;\n }\n \n // Default behavior\n if (confirm('A new version is available. Reload now?')) {\n applyUpdate();\n }\n}\n\n/**\n * Apply pending update (reload with new version)\n */\nexport function applyUpdate() {\n if (swRegistration?.waiting) {\n swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });\n }\n window.location.reload();\n}\n\n// ============================================================================\n// Communication with Service Worker\n// ============================================================================\n\n/**\n * Send a message to the service worker\n * @param {Object} message - Message to send\n */\nexport function postToServiceWorker(message) {\n navigator.serviceWorker.controller?.postMessage(message);\n}\n\n/**\n * Request the service worker to cache specific modules\n * @param {string[]} moduleNames - Array of module names to cache\n */\nexport function cacheModules(moduleNames) {\n postToServiceWorker({\n type: 'CACHE_MODULES',\n payload: { modules: moduleNames }\n });\n}\n\n/**\n * Request the service worker to clear user-specific caches\n * (Call this on logout)\n */\nexport function clearUserCaches() {\n postToServiceWorker({\n type: 'CLEAR_USER_CACHE'\n });\n}\n\n/**\n * Get the Service Worker we can postMessage to. Resolves with:\n * • `navigator.serviceWorker.controller` if it's already in control of the\n * page (fastest path), or\n * • `registration.active` once `navigator.serviceWorker.ready` resolves\n * (covers the first-load case before the SW has claimed the page).\n *\n * Rejects after `timeoutMs` if no SW becomes available — which would only\n * happen in a private/incognito context, an unsupported browser, or when\n * registration genuinely failed.\n *\n * @param {{ timeoutMs?: number }} [opts]\n * @returns {Promise}\n */\nexport async function getActiveServiceWorker({ timeoutMs = 10000 } = {}) {\n if (!('serviceWorker' in navigator)) {\n throw new Error('Service Workers not supported in this browser');\n }\n\n // Fastest path — page is already SW-controlled\n if (navigator.serviceWorker.controller) {\n return navigator.serviceWorker.controller;\n }\n\n // Otherwise wait for the registration to become ready (active SW exists\n // for this scope, even if it hasn't claimed THIS page yet)\n const ready = navigator.serviceWorker.ready;\n const timeout = new Promise((_, reject) =>\n setTimeout(() => reject(new Error('Service-worker readiness timeout')), timeoutMs)\n );\n\n const registration = await Promise.race([ready, timeout]);\n\n // The controller may have appeared while we were waiting; otherwise use\n // the registration's active worker (we can still postMessage to it — caches\n // are shared across the origin)\n const sw = navigator.serviceWorker.controller || registration.active;\n if (!sw) {\n throw new Error('No active service worker available');\n }\n return sw;\n}\n\n/**\n * Subscribe to controller-change events. The callback fires whenever a new\n * Service Worker takes control of the page (e.g. after an SW update or first\n * activation on initial load). Useful for re-querying SW-backed state once\n * the SW has actually taken over.\n *\n * @param {() => void} callback\n * @returns {() => void} unsubscribe function\n */\nexport function onServiceWorkerControllerChange(callback) {\n if (!('serviceWorker' in navigator)) return () => {};\n const handler = () => {\n try { callback(); } catch (e) { console.error('[PWA] controllerchange handler error:', e); }\n };\n navigator.serviceWorker.addEventListener('controllerchange', handler);\n return () => navigator.serviceWorker.removeEventListener('controllerchange', handler);\n}\n\n/**\n * Send a message to the service worker and wait for a single reply with the\n * given response type. Waits for the SW to become available if it isn't yet.\n * Resolves with the reply payload, or rejects after a timeout.\n *\n * @template T\n * @param {string} requestType - Message type to send (e.g. 'GET_TILE_STATS')\n * @param {string} responseType - Message type expected back (e.g. 'TILE_STATS')\n * @param {Object} [extra={}] - Extra fields merged into the outgoing message\n * @param {number} [timeoutMs=5000] Reply timeout (after the SW is available)\n * @param {number} [readyTimeoutMs=10000] Timeout for the SW to be available\n * @returns {Promise}\n */\nasync function requestFromServiceWorker(requestType, responseType, extra = {}, timeoutMs = 5000, readyTimeoutMs = 10000) {\n const sw = await getActiveServiceWorker({ timeoutMs: readyTimeoutMs });\n\n return new Promise((resolve, reject) => {\n const channel = new MessageChannel();\n const timer = setTimeout(() => {\n channel.port1.close();\n reject(new Error(`Service-worker reply \"${responseType}\" timed out`));\n }, timeoutMs);\n\n channel.port1.onmessage = (event) => {\n if (event.data?.type === responseType) {\n clearTimeout(timer);\n channel.port1.close();\n const { type, ...rest } = event.data;\n resolve(rest);\n }\n };\n\n sw.postMessage({ type: requestType, ...extra }, [channel.port2]);\n });\n}\n\n/**\n * Get statistics about tiles cached locally on this device, broken down by\n * provider. Waits up to `readyTimeoutMs` for the service worker to become\n * available. Returns null only if the SW genuinely cannot be reached\n * (private mode, registration failure, or timeout).\n *\n * @returns {Promise<{\n * totals: { count: number, estBytes: number },\n * byProvider: Array<{ key: string, label: string, count: number, limit: number, estBytes: number }>\n * } | null>}\n */\nexport async function getTileCacheStats() {\n try {\n const reply = await requestFromServiceWorker('GET_TILE_STATS', 'TILE_STATS');\n return reply.stats;\n } catch (err) {\n console.warn('[PWA] getTileCacheStats failed:', err);\n return null;\n }\n}\n\n/**\n * Delete every cached tile from this device. Doesn't touch the app shell,\n * modules, or API caches — only the per-provider tile buckets.\n * Waits for the SW to be available before sending the request.\n *\n * @returns {Promise} true if the request was acknowledged\n */\nexport async function clearTileCaches() {\n try {\n await requestFromServiceWorker('CLEAR_TILE_CACHES', 'TILE_CACHES_CLEARED');\n return true;\n } catch (err) {\n console.warn('[PWA] clearTileCaches failed:', err);\n return false;\n }\n}\n\n/**\n * Delete cached tiles for a single provider. `cacheName` must match one of\n * the per-provider caches reported by `getTileCacheStats()` (e.g.\n * `tiles-osm-v4`, `tiles-topo-v4`). Unknown names are rejected by the SW.\n *\n * @param {string} cacheName\n * @returns {Promise} true if the cache was actually deleted\n */\nexport async function clearTileCacheForProvider(cacheName) {\n if (!cacheName) return false;\n try {\n const reply = await requestFromServiceWorker(\n 'CLEAR_TILE_CACHE', 'TILE_CACHE_CLEARED',\n { cacheName },\n );\n return !!reply.deleted;\n } catch (err) {\n console.warn(`[PWA] clearTileCacheForProvider(${cacheName}) failed:`, err);\n return false;\n }\n}\n\n/**\n * Get total disk used by this origin (Cache API + IndexedDB + OPFS).\n * Returns null if the Storage API is not available.\n *\n * @returns {Promise<{ usage: number, quota: number } | null>}\n */\nexport async function getStorageEstimate() {\n if (!navigator.storage?.estimate) return null;\n try {\n const { usage, quota } = await navigator.storage.estimate();\n return { usage: usage || 0, quota: quota || 0 };\n } catch (err) {\n console.warn('[PWA] getStorageEstimate failed:', err);\n return null;\n }\n}\n\n// ============================================================================\n// Auto-initialization\n// ============================================================================\n\n/**\n * Initialize all PWA features\n * @param {Object} options\n */\nexport async function initPWA(options = {}) {\n const {\n installButton = '#install-btn',\n offlineIndicator = '#offline-indicator',\n autoRegisterSW = true\n } = options;\n \n if (autoRegisterSW) {\n await registerServiceWorker();\n }\n \n initInstallPrompt(installButton);\n initOfflineDetection(offlineIndicator);\n \n console.log('[PWA] Initialized');\n}\n\n// Export for direct use\nexport default {\n registerServiceWorker,\n initInstallPrompt,\n initOfflineDetection,\n initPWA,\n isOnline,\n onOfflineChange,\n onUpdateAvailable,\n applyUpdate,\n postToServiceWorker,\n cacheModules,\n clearUserCaches,\n getTileCacheStats,\n clearTileCaches,\n clearTileCacheForProvider,\n getStorageEstimate,\n getActiveServiceWorker,\n onServiceWorkerControllerChange,\n};\n","/**\n * Offline Tile Downloader\n *\n * Pre-fetches map tiles for a given extent and zoom range so they are stored\n * in the Service Worker's per-host tile cache for offline use.\n *\n * The downloader simply issues `fetch()` calls; the existing SW intercepts\n * them and routes to the right cache bucket. No direct Cache API access is\n * needed here — the SW is the single source of truth for storage.\n *\n * Throttling defaults are conservative to respect tile-server usage policies:\n * • 2 concurrent requests\n * • 50 ms inter-batch delay\n * • Standard browser User-Agent / Referer headers\n *\n * Usage:\n * const downloader = new OfflineTileDownloader({\n * baseMap: 'topo',\n * extent3857: [minX, minY, maxX, maxY], // EPSG:3857\n * minZoom: 10,\n * maxZoom: 15,\n * onProgress: (s) => console.log(s),\n * });\n * await downloader.start();\n * downloader.cancel(); // any time\n */\n\n// ============================================================================\n// Base-map URL templates\n// ============================================================================\n\n/**\n * Tile URL templates for base maps that may be downloaded for offline use.\n *\n * The SW recognises these hosts in `getTileCacheName()` and routes them to\n * the matching `tiles-*-vN` cache. If you add a new entry here, also add\n * the host to the SW's classifier or the tiles will not be cached.\n */\nexport const BASEMAP_TEMPLATES = {\n topo: {\n url: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',\n label: 'Topographic',\n maxZoom: 17,\n cacheKey: 'tiles-topo',\n },\n osm: {\n url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',\n label: 'OpenStreetMap',\n maxZoom: 19,\n cacheKey: 'tiles-osm',\n },\n};\n\n// Approximate bytes per raster tile — used for storage estimates.\nexport const AVG_TILE_BYTES = 30 * 1024;\n\n// ============================================================================\n// Tile coordinate math (Web Mercator XYZ scheme)\n// ============================================================================\n\nconst ORIGIN_SHIFT = 2 * Math.PI * 6378137 / 2; // 20037508.342789244\n\n/** Convert Web Mercator metres → (lon, lat) in degrees. */\nfunction metersToLonLat(x, y) {\n const lon = (x / ORIGIN_SHIFT) * 180;\n let lat = (y / ORIGIN_SHIFT) * 180;\n lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2);\n return [lon, lat];\n}\n\n/** Tile (x, y) in XYZ scheme for a given lon/lat at zoom z. */\nfunction lonLatToTile(lon, lat, z) {\n const n = Math.pow(2, z);\n const x = Math.floor((lon + 180) / 360 * n);\n const latRad = lat * Math.PI / 180;\n const y = Math.floor(\n (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n\n );\n return { x, y };\n}\n\n/** Tile range covering an EPSG:3857 extent at a given zoom level. */\nexport function tileRangeForExtent(extent3857, z) {\n const [minX, minY, maxX, maxY] = extent3857;\n const [minLon, minLat] = metersToLonLat(minX, minY);\n const [maxLon, maxLat] = metersToLonLat(maxX, maxY);\n\n const tl = lonLatToTile(minLon, maxLat, z); // top-left in XYZ (NW)\n const br = lonLatToTile(maxLon, minLat, z); // bottom-right (SE)\n\n const n = Math.pow(2, z);\n const minTileX = Math.max(0, Math.min(tl.x, br.x));\n const maxTileX = Math.min(n - 1, Math.max(tl.x, br.x));\n const minTileY = Math.max(0, Math.min(tl.y, br.y));\n const maxTileY = Math.min(n - 1, Math.max(tl.y, br.y));\n\n return {\n z,\n minX: minTileX, maxX: maxTileX,\n minY: minTileY, maxY: maxTileY,\n count: (maxTileX - minTileX + 1) * (maxTileY - minTileY + 1),\n };\n}\n\n/** Total tile count for an extent across a zoom range (inclusive). */\nexport function countTiles(extent3857, minZ, maxZ) {\n let total = 0;\n for (let z = minZ; z <= maxZ; z++) {\n total += tileRangeForExtent(extent3857, z).count;\n }\n return total;\n}\n\n/**\n * Enumerate every tile in an extent across a zoom range.\n * Returns an array of { z, x, y } objects. For very large ranges this can be\n * large — the caller is expected to validate the count first.\n */\nexport function enumerateTiles(extent3857, minZ, maxZ) {\n const out = [];\n for (let z = minZ; z <= maxZ; z++) {\n const r = tileRangeForExtent(extent3857, z);\n for (let x = r.minX; x <= r.maxX; x++) {\n for (let y = r.minY; y <= r.maxY; y++) {\n out.push({ z, x, y });\n }\n }\n }\n return out;\n}\n\n/**\n * Format a tile URL for a given coordinate using a {z}/{x}/{y} template.\n */\nexport function formatTileUrl(template, { z, x, y }) {\n return template\n .replace('{z}', z)\n .replace('{x}', x)\n .replace('{y}', y);\n}\n\n// ============================================================================\n// OfflineTileDownloader\n// ============================================================================\n\n/**\n * Concurrent, throttled tile downloader. Issues `fetch()` per tile; the\n * service worker handles caching transparently.\n *\n * Events via `onProgress` callback:\n * { phase: 'running' | 'done' | 'cancelled' | 'error',\n * done, total, ok, failed, cached,\n * elapsedMs, etaMs }\n */\nexport class OfflineTileDownloader {\n constructor({\n baseMap, // 'topo' | 'osm'\n extent3857, // [minX, minY, maxX, maxY]\n minZoom,\n maxZoom,\n concurrency = 2, // OSM ToS-friendly default\n interBatchDelayMs = 50,\n onProgress = () => {},\n }) {\n const tpl = BASEMAP_TEMPLATES[baseMap];\n if (!tpl) throw new Error(`Unknown base map: ${baseMap}`);\n if (maxZoom > tpl.maxZoom) {\n console.warn(`[OfflineTiles] ${baseMap}: maxZoom ${maxZoom} > supported ${tpl.maxZoom}; clamping`);\n maxZoom = tpl.maxZoom;\n }\n\n this.baseMap = baseMap;\n this.template = tpl.url;\n this.extent = extent3857;\n this.minZoom = minZoom;\n this.maxZoom = maxZoom;\n this.concurrency = Math.max(1, Math.min(concurrency, 6));\n this.interBatchDelayMs = interBatchDelayMs;\n this.onProgress = onProgress;\n\n this._abortCtrl = null;\n this._cancelled = false;\n }\n\n /**\n * Begin downloading. Returns a Promise that resolves with the final stats\n * when complete, or when cancelled.\n */\n async start() {\n if (this._abortCtrl) throw new Error('Downloader already started');\n this._abortCtrl = new AbortController();\n this._cancelled = false;\n\n const tiles = enumerateTiles(this.extent, this.minZoom, this.maxZoom);\n const total = tiles.length;\n const startedAt = Date.now();\n\n let done = 0, ok = 0, failed = 0, cached = 0;\n\n const emit = (phase) => {\n const elapsedMs = Date.now() - startedAt;\n const etaMs = done > 0 ? Math.round((elapsedMs / done) * (total - done)) : null;\n this.onProgress({ phase, done, total, ok, failed, cached, elapsedMs, etaMs });\n };\n\n emit('running');\n\n // Process in chunks of `concurrency`\n for (let i = 0; i < tiles.length; i += this.concurrency) {\n if (this._cancelled) break;\n\n const batch = tiles.slice(i, i + this.concurrency);\n await Promise.all(batch.map(async (t) => {\n if (this._cancelled) return;\n const url = formatTileUrl(this.template, t);\n\n try {\n const res = await fetch(url, {\n signal: this._abortCtrl.signal,\n // Hint the SW that this is a passive prefetch\n cache: 'default',\n });\n\n if (res.ok) {\n ok++;\n // Detect \"served from SW cache\" via headers — not reliable across\n // implementations, so we just count all 200s as ok. Reading the body\n // (or cancelling it) lets the browser GC the response promptly.\n if (res.body) res.body.cancel().catch(() => {});\n } else if (res.status === 408) {\n // Our SW returns 408 when offline AND nothing cached. Treat as failed.\n failed++;\n } else {\n failed++;\n }\n } catch (err) {\n if (err.name === 'AbortError') {\n // Cancellation — don't count\n } else {\n failed++;\n }\n }\n done++;\n }));\n\n emit('running');\n\n if (this.interBatchDelayMs > 0 && i + this.concurrency < tiles.length) {\n await new Promise((r) => setTimeout(r, this.interBatchDelayMs));\n }\n }\n\n emit(this._cancelled ? 'cancelled' : 'done');\n\n return {\n phase: this._cancelled ? 'cancelled' : 'done',\n done, total, ok, failed, cached,\n elapsedMs: Date.now() - startedAt,\n };\n }\n\n /**\n * Cancel an in-flight download. Resolves on the next batch boundary.\n */\n cancel() {\n this._cancelled = true;\n if (this._abortCtrl) this._abortCtrl.abort();\n }\n}\n\n// ============================================================================\n// Predefined extents\n// ============================================================================\n\n/**\n * Whole-of-Ghana bounding box in EPSG:3857.\n * Approximate: -3.3°W → 1.2°E, 4.5°N → 11.2°N.\n */\nexport const GHANA_EXTENT_3857 = (() => {\n const lonLatToMeters = (lon, lat) => {\n const x = lon * ORIGIN_SHIFT / 180;\n const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180);\n return [x, y * ORIGIN_SHIFT / 180];\n };\n const sw = lonLatToMeters(-3.3, 4.5);\n const ne = lonLatToMeters(1.2, 11.2);\n return [sw[0], sw[1], ne[0], ne[1]];\n})();\n\n// Useful for size estimates\nexport function estimatedSizeBytes(tileCount) {\n return tileCount * AVG_TILE_BYTES;\n}\n","/**\n * Remote Database Module\n *\n * Handles all API communication with the PostgreSQL backend server.\n * Provides GET and POST methods for fetching and pushing data.\n *\n * Usage:\n * import { remoteGet, remotePost, getDistrictBoundary } from './remotedb.js';\n *\n * const boundary = await getDistrictBoundary();\n */\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\n// Most endpoints live under the /spatial_planning module, but not all — the\n// contours endpoint sits in its own module. Endpoint names containing a slash\n// are resolved against API_ROOT instead (see resolveEndpoint below).\nconst API_ROOT = 'https://api.lupmis4luspa.org/api';\nconst API_BASE = `${API_ROOT}/spatial_planning`;\n\n/**\n * Build the full URL for an endpoint.\n * A bare filename (\"get_layers.php\") resolves inside /spatial_planning;\n * a path (\"contours/get_by_district.php\") resolves against the API root.\n * @param {string} endpoint\n * @returns {string}\n */\nfunction resolveEndpoint(endpoint) {\n return endpoint.includes('/') ? `${API_ROOT}/${endpoint}` : `${API_BASE}/${endpoint}`;\n}\n\n/**\n * Per-request credentials sent with every API call.\n *\n * `district_id` is resolved dynamically — when the PWA is loaded via the PHP\n * entry point (public/index.php), the SSO session is injected into the page\n * as `window.LUPMIS_SESSION` and we read the authenticated user's district\n * from there. In local development (Vite serves index.html directly without\n * PHP), the global is undefined and we fall back to the hard-coded test\n * district below.\n *\n * `api_token` is currently a single global app token — not per-user.\n */\nconst FALLBACK_DISTRICT_ID = '1';\nconst API_TOKEN = '1c46538c712e9b5b';\n\n/**\n * Returns the authenticated user's district_id.\n *\n * - No SSO session at all (window.LUPMIS_SESSION undefined): we're in local\n * development → fall back to the hard-coded test district.\n * - Session present but no district_id: the user is authenticated but not\n * assigned to any district → return null. The bootstrap in main.js detects\n * this case BEFORE any API call and shows a blocking message; this null\n * is defence-in-depth so we never silently send district_id=1 for an\n * authenticated user.\n *\n * The getter runs on each spread of API_CREDENTIALS, so a session change at\n * runtime takes effect immediately.\n */\nfunction resolveDistrictId() {\n try {\n if (typeof window === 'undefined') return FALLBACK_DISTRICT_ID;\n const session = window.LUPMIS_SESSION;\n if (!session || typeof session !== 'object') return FALLBACK_DISTRICT_ID;\n const id = session.district_id;\n if (id === null || id === undefined || String(id).length === 0) return null;\n return String(id);\n } catch { /* no-op */ }\n return FALLBACK_DISTRICT_ID;\n}\n\nconst API_CREDENTIALS = {\n get district_id() { return resolveDistrictId(); },\n api_token: API_TOKEN,\n};\n\n/**\n * Get the full session payload (or null if not authenticated).\n * Exposed for UI code that wants to display the user's name, email, etc.\n *\n * Dev-mode helper: setting `localStorage['dev-session']` to a JSON object\n * (e.g. via `lupmisDevSession({...})` in the console) overrides the real\n * session — useful when running against `localhost:5173` to test the\n * authenticated UI without standing up a PHP server.\n */\nexport function getSession() {\n // 1. Real session injected by index.php (production)\n if (typeof window !== 'undefined' && window.LUPMIS_SESSION && window.LUPMIS_SESSION.user_id) {\n return window.LUPMIS_SESSION;\n }\n // 2. Dev-mode override (developer's own localStorage tweak)\n try {\n const raw = localStorage.getItem('dev-session');\n if (raw) {\n const parsed = JSON.parse(raw);\n if (parsed && parsed.user_id) return parsed;\n }\n } catch { /* ignore */ }\n return null;\n}\n\n// Console helper — set a fake session for dev work. Reload to apply.\n// lupmisDevSession({ user_id: 42, district_id: '1', full_name: 'Test User', ... })\n// Clear it via lupmisDevSession(null) or localStorage.removeItem('dev-session').\nif (typeof window !== 'undefined') {\n window.lupmisDevSession = (payload) => {\n if (payload == null) {\n localStorage.removeItem('dev-session');\n console.log('[Dev] Session override cleared. Reload to apply.');\n } else {\n localStorage.setItem('dev-session', JSON.stringify(payload));\n console.log('[Dev] Session override saved. Reload to apply:', payload);\n }\n };\n}\n\n// ============================================================================\n// Server Reachability\n// ============================================================================\n\n/** Default timeout for API requests (ms) */\nconst REQUEST_TIMEOUT = 30_000;\n\n/** Timeout for the fast reachability probe (ms) */\nconst PING_TIMEOUT = 5_000;\n\n/** Cached result of the last reachability check */\nlet _serverReachable = null;\n\n/**\n * Quick probe to determine if the API server is responding.\n * Sends a small POST to a lightweight endpoint with a short timeout.\n * The result is cached so subsequent calls within the same page load\n * return immediately.\n *\n * @param {boolean} [force=false] - Re-check even if a cached result exists\n * @returns {Promise} true if the server responded in time\n */\nexport async function checkServerReachable(force = false) {\n if (_serverReachable !== null && !force) return _serverReachable;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), PING_TIMEOUT);\n\n try {\n const response = await fetch(`${API_BASE}/get_layers.php`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n body: JSON.stringify(API_CREDENTIALS),\n signal: controller.signal,\n });\n _serverReachable = response.ok;\n } catch {\n _serverReachable = false;\n } finally {\n clearTimeout(timer);\n }\n\n console.log('[RemoteDB] Server reachable:', _serverReachable);\n return _serverReachable;\n}\n\n/**\n * Returns the cached server-reachability flag (synchronous).\n * Returns null if checkServerReachable() has not been called yet.\n * @returns {boolean|null}\n */\nexport function isServerReachable() {\n return _serverReachable;\n}\n\n// ============================================================================\n// Core Request Helpers\n// ============================================================================\n\n/**\n * Create an AbortController that auto-aborts after `ms` milliseconds.\n * If the caller already supplied a signal in `options`, it is combined\n * so that either the caller's abort or the timeout will cancel the request.\n */\nfunction withTimeout(options, ms = REQUEST_TIMEOUT) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), ms);\n\n // If the caller provided their own signal, chain it\n if (options.signal) {\n options.signal.addEventListener('abort', () => controller.abort());\n }\n\n return {\n signal: controller.signal,\n clear: () => clearTimeout(timer),\n };\n}\n\n/**\n * Perform a GET request to the remote API.\n * Credentials are sent as URL query parameters.\n * Automatically times out after REQUEST_TIMEOUT ms.\n *\n * @param {string} endpoint - API endpoint filename (e.g. 'get_district_boundary.php')\n * @param {Object} [params={}] - Additional query parameters\n * @param {Object} [options={}] - Extra fetch options\n * @returns {Promise} Parsed JSON response\n */\nexport async function remoteGet(endpoint, params = {}, options = {}) {\n const url = new URL(resolveEndpoint(endpoint));\n\n // Attach credentials and any extra params as query string\n const allParams = { ...API_CREDENTIALS, ...params };\n for (const [key, value] of Object.entries(allParams)) {\n url.searchParams.set(key, value);\n }\n\n console.log('[RemoteDB] GET', url.toString());\n\n const timeout = withTimeout(options);\n try {\n const response = await fetch(url.toString(), {\n method: 'GET',\n headers: {\n 'Accept': 'application/json'\n },\n ...options,\n signal: timeout.signal,\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const data = await response.json();\n console.log('[RemoteDB] GET response:', endpoint, '→', typeof data === 'object' ? `${Array.isArray(data) ? data.length + ' items' : 'object'}` : data);\n return data;\n\n } catch (error) {\n if (error.name === 'AbortError') {\n console.error('[RemoteDB] GET timed out:', endpoint);\n throw new Error(`Request timed out: ${endpoint}`);\n }\n console.error('[RemoteDB] GET failed:', endpoint, error);\n throw error;\n } finally {\n timeout.clear();\n }\n}\n\n/**\n * Perform a POST request to the remote API.\n * Credentials are included in the JSON body.\n * Automatically times out after REQUEST_TIMEOUT ms.\n *\n * @param {string} endpoint - API endpoint filename (e.g. 'some_endpoint.php')\n * @param {Object} [body={}] - Request payload (credentials are merged in)\n * @param {Object} [options={}] - Extra fetch options\n * @returns {Promise} Parsed JSON response\n */\nexport async function remotePost(endpoint, body = {}, options = {}) {\n const url = resolveEndpoint(endpoint);\n\n const payload = { ...API_CREDENTIALS, ...body };\n\n console.log('[RemoteDB] POST', url);\n\n const timeout = withTimeout(options);\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json'\n },\n body: JSON.stringify(payload),\n ...options,\n signal: timeout.signal,\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const data = await response.json();\n console.log('[RemoteDB] POST response:', endpoint, '→', typeof data === 'object' ? `${Array.isArray(data) ? data.length + ' items' : 'object'}` : data);\n return data;\n\n } catch (error) {\n if (error.name === 'AbortError') {\n console.error('[RemoteDB] POST timed out:', endpoint);\n throw new Error(`Request timed out: ${endpoint}`);\n }\n console.error('[RemoteDB] POST failed:', endpoint, error);\n throw error;\n } finally {\n timeout.clear();\n }\n}\n\n// ============================================================================\n// Spatial Planning Endpoints\n// ============================================================================\n\n/**\n * Fetch district boundary geometry from the server.\n *\n * @returns {Promise} District boundary GeoJSON or API response\n */\nexport async function getDistrictBoundary() {\n return remotePost('get_district_boundary.php');\n}\n\n/**\n * Fetch the list of available map layer categories from the server.\n *\n * Response format:\n * { success: true, data: [{ id, name, description, createdt, editdt }, ...] }\n *\n * @returns {Promise} Layer categories list\n */\nexport async function getLayers() {\n return remotePost('get_layers.php');\n}\n\n/**\n * Fetch all collector zones for the current district.\n *\n * Expected response:\n * { success: true, data: [{ id, zone_name, boundary: \"MULTIPOLYGON(...)\", ... }, ...] }\n *\n * @returns {Promise} Collector zones list\n */\nexport async function getCollectorZones() {\n return remotePost('get_all_collector_zone_per_district.php');\n}\n\n/**\n * Fetch all parcels for the current district.\n *\n * Expected response:\n * { success: true, data: [{ id, ..., polygon: \"POLYGON(...)\" | \"MULTIPOLYGON(...)\", ... }, ...] }\n *\n * @returns {Promise} Parcels list\n */\nexport async function getDistrictParcels() {\n return remotePost('get_parcels_per_district.php');\n}\n\n/**\n * Fetch all building footprints for the current district.\n *\n * Expected response:\n * { success: true, data: [{ id, ..., polygon: \"POLYGON(...)\" | \"MULTIPOLYGON(...)\", ... }, ...] }\n *\n * @returns {Promise} Building footprints list\n */\nexport async function getBuildingFootprints() {\n return remotePost('get_all_footprint_per_district.php');\n}\n\n/**\n * Fetch the Contours hillshade elevation layer from the server.\n *\n * Endpoint: POST /api/contours/get_by_district.php\n * Source: table `spatial.be_contours_hillshade` — elevation contours produced\n * with gdal_contour from OpenTopography's SRTM 30 m / Copernicus 30 m\n * DEM, scoped per district.\n *\n * Response rows carry the elevation in `elev` (metres) and the geometry as WKT\n * in `geom`:\n * { fid, id, elev: 180, districtid, geom: \"LINESTRING(lon lat, …)\" }\n *\n * @returns {Promise} Contours hillshade list\n */\nexport async function getContoursHillshade() {\n // This endpoint lives in its own /contours module and names the district\n // parameter `districtid` (no underscore, numeric), unlike the\n // /spatial_planning endpoints which use `district_id`. remotePost still\n // merges in the standard credentials, so api_token is sent as usual.\n const districtId = API_CREDENTIALS.district_id;\n return remotePost('contours/get_by_district.php', {\n districtid: districtId == null ? null : Number(districtId),\n });\n}\n\n/**\n * Fetch the OSM roads layer from the server.\n *\n * Source: table `pi_osm_roads` in the local PostgreSQL `public` schema\n * (imported from OpenStreetMap road network for the district).\n *\n * Expected response:\n * { success: true, data: [{ id, ..., geom: \"LINESTRING(...)\" | \"MULTILINESTRING(...)\", ... }, ...] }\n *\n * @returns {Promise} OSM roads list\n */\nexport async function getOSMRoads() {\n return remotePost('get_osm_roads.php');\n}\n\n/**\n * Fetch the UPN-grid (district sub-division) for the current district.\n *\n * The grid is static per district, so callers cache it locally and only\n * re-fetch when the user becomes associated with a different district\n * (see saveUpnGrid / getLocalUpnGrid in database.js and loadUpnGrid in\n * main.js).\n *\n * Source: spatial.upn_grid; endpoint runs ST_AsText(geom_4326) AS polygon.\n *\n * Expected response:\n * { success: true,\n * data: [{ polygon: \"POLYGON((...))\" | \"MULTIPOLYGON(((...)))\",\n * districtid: 122,\n * upn_prefix: \"AB\" }, ...] }\n *\n * @returns {Promise}\n */\nexport async function getUpnGrid() {\n return remotePost('get_upn_grid_per_district.php');\n}\n\n/**\n * Push a recorded GPS trail (with all its points) to the server.\n *\n * Implements the GeoTracker \"sync adapter\" contract: store-and-forward — the\n * whole trail is uploaded once recording stops (and retried when back online).\n * `district_id` and `api_token` are attached automatically by remotePost().\n *\n * ── SERVER SIDE (NOT YET CREATED) ───────────────────────────────────────\n * Proposed endpoint: `save_gps_trail.php`\n * Proposed PostgreSQL/PostGIS tables (SRID 4326):\n *\n * CREATE TABLE be_gps_trail (\n * id SERIAL PRIMARY KEY,\n * client_uuid TEXT UNIQUE, -- de-dupe re-syncs\n * district_id INTEGER,\n * name TEXT,\n * started_at TIMESTAMPTZ,\n * ended_at TIMESTAMPTZ,\n * point_count INTEGER,\n * distance_m DOUBLE PRECISION,\n * track GEOMETRY(LineStringZ, 4326), -- optional aggregate line\n * createdt TIMESTAMPTZ DEFAULT now()\n * );\n * CREATE TABLE be_gps_trail_point (\n * id SERIAL PRIMARY KEY,\n * trail_id INTEGER REFERENCES be_gps_trail(id) ON DELETE CASCADE,\n * seq INTEGER,\n * geom GEOMETRY(PointZ, 4326),\n * accuracy DOUBLE PRECISION,\n * altitude DOUBLE PRECISION,\n * heading DOUBLE PRECISION,\n * speed DOUBLE PRECISION,\n * satellites INTEGER, -- nullable (web has no sat count)\n * recorded_at TIMESTAMPTZ\n * );\n *\n * Request body (JSON, plus injected credentials):\n * { client_uuid, name, started_at, ended_at, point_count, distance_m,\n * points: [ { seq, longitude, latitude, altitude, accuracy,\n * altitude_accuracy, heading, speed, satellites, recorded_at } ] }\n *\n * Expected response: { success: true, id: }\n * Should be idempotent on client_uuid (INSERT ... ON CONFLICT DO UPDATE).\n * ─────────────────────────────────────────────────────────────────────────\n *\n * @param {Object} trail local trail row (client_uuid, name, started_at, …)\n * @param {Array} points local point rows (seq, longitude, latitude, …)\n * @returns {Promise<{ remoteId: (string|number|null) }>}\n */\nexport async function pushGpsTrail(trail, points) {\n const payload = {\n client_uuid: trail.client_uuid,\n name: trail.name ?? null,\n started_at: trail.started_at,\n ended_at: trail.ended_at,\n point_count: trail.point_count ?? points.length,\n distance_m: trail.distance_m ?? 0,\n points: (points || []).map((p) => ({\n seq: p.seq,\n longitude: p.longitude,\n latitude: p.latitude,\n altitude: p.altitude ?? null,\n accuracy: p.accuracy ?? null,\n altitude_accuracy: p.altitude_accuracy ?? null,\n heading: p.heading ?? null,\n speed: p.speed ?? null,\n satellites: p.satellites ?? null,\n recorded_at: p.recorded_at,\n })),\n };\n const res = await remotePost('save_gps_trail.php', payload);\n return { remoteId: res?.id ?? res?.remote_id ?? null };\n}\n\n// ============================================================================\n// Exports\n// ============================================================================\n\nexport default {\n getSession,\n checkServerReachable,\n isServerReachable,\n remoteGet,\n remotePost,\n getDistrictBoundary,\n getLayers,\n getDistrictParcels,\n getCollectorZones,\n getBuildingFootprints,\n getContoursHillshade,\n getOSMRoads,\n getUpnGrid,\n pushGpsTrail,\n};\n","/**\n * geo-utils.js — pure, dependency-free geospatial helpers for the GeoTracker\n * module. No browser APIs, no framework imports — safe to reuse anywhere\n * (including Node, web workers, or other projects).\n *\n * @module geotracker/geo-utils\n */\n\nconst EARTH_RADIUS_M = 6371008.8; // mean Earth radius (metres)\nconst DEG2RAD = Math.PI / 180;\n\n/**\n * Great-circle distance between two lon/lat points using the haversine\n * formula.\n *\n * @param {number} lon1\n * @param {number} lat1\n * @param {number} lon2\n * @param {number} lat2\n * @returns {number} distance in metres\n */\nexport function haversineMeters(lon1, lat1, lon2, lat2) {\n const dLat = (lat2 - lat1) * DEG2RAD;\n const dLon = (lon2 - lon1) * DEG2RAD;\n const a =\n Math.sin(dLat / 2) ** 2 +\n Math.cos(lat1 * DEG2RAD) * Math.cos(lat2 * DEG2RAD) * Math.sin(dLon / 2) ** 2;\n return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));\n}\n\n/**\n * Total length of a polyline expressed as an array of points.\n * @param {Array<{lon:number, lat:number}>} points\n * @returns {number} metres\n */\nexport function pathLengthMeters(points) {\n let total = 0;\n for (let i = 1; i < points.length; i++) {\n total += haversineMeters(points[i - 1].lon, points[i - 1].lat, points[i].lon, points[i].lat);\n }\n return total;\n}\n\n/**\n * Format a latitude or longitude for compact display.\n * @param {number} value\n * @param {number} [decimals=5] ~1.1 m precision at 5 dp\n * @returns {string}\n */\nexport function formatCoord(value, decimals = 5) {\n if (value == null || Number.isNaN(value)) return '—';\n return value.toFixed(decimals);\n}\n\n// ---------------------------------------------------------------------------\n// UTM (Universal Transverse Mercator) — WGS84 forward projection\n//\n// Self-contained implementation of the standard USGS series-expansion forward\n// formulas (Snyder, \"Map Projections — A Working Manual\"). Accurate to a few\n// millimetres within a zone, which is far beyond what a consumer GPS fix\n// needs. Ghana spans UTM zones 30N and 31N, but this handles the whole globe.\n// No proj4 / OpenLayers dependency so it stays usable from the pure GeoTracker\n// layer and from plain display code.\n// ---------------------------------------------------------------------------\n\nconst UTM_A = 6378137.0; // WGS84 semi-major axis (metres)\nconst UTM_F = 1 / 298.257223563; // WGS84 flattening\nconst UTM_K0 = 0.9996; // UTM scale factor on the central meridian\nconst UTM_E2 = UTM_F * (2 - UTM_F); // first eccentricity squared\nconst UTM_EP2 = UTM_E2 / (1 - UTM_E2); // second eccentricity squared (e'^2)\n\n/**\n * Convert WGS84 latitude/longitude to UTM.\n *\n * @param {number} lat latitude in decimal degrees (−80 … 84 is the UTM band)\n * @param {number} lon longitude in decimal degrees (−180 … 180)\n * @returns {{zone:number, hemisphere:'N'|'S', easting:number, northing:number}|null}\n * null when the input is missing / not finite.\n */\nexport function latLonToUTM(lat, lon) {\n if (lat == null || lon == null || Number.isNaN(lat) || Number.isNaN(lon)) return null;\n\n // Normalise longitude to [−180, 180) so the zone maths is well-defined.\n let lonNorm = ((lon + 180) % 360 + 360) % 360 - 180;\n\n const zone = Math.floor((lonNorm + 180) / 6) + 1;\n const lon0 = (zone - 1) * 6 - 180 + 3; // central meridian of the zone (deg)\n\n const phi = lat * DEG2RAD;\n const lambda = lonNorm * DEG2RAD;\n const lambda0 = lon0 * DEG2RAD;\n\n const sinPhi = Math.sin(phi);\n const cosPhi = Math.cos(phi);\n const tanPhi = Math.tan(phi);\n\n const N = UTM_A / Math.sqrt(1 - UTM_E2 * sinPhi * sinPhi);\n const T = tanPhi * tanPhi;\n const C = UTM_EP2 * cosPhi * cosPhi;\n const A = cosPhi * (lambda - lambda0);\n\n // Meridional arc length from the equator to the given latitude.\n const M = UTM_A * (\n (1 - UTM_E2 / 4 - 3 * UTM_E2 ** 2 / 64 - 5 * UTM_E2 ** 3 / 256) * phi\n - (3 * UTM_E2 / 8 + 3 * UTM_E2 ** 2 / 32 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(2 * phi)\n + (15 * UTM_E2 ** 2 / 256 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(4 * phi)\n - (35 * UTM_E2 ** 3 / 3072) * Math.sin(6 * phi)\n );\n\n let easting = UTM_K0 * N * (\n A\n + (1 - T + C) * A ** 3 / 6\n + (5 - 18 * T + T * T + 72 * C - 58 * UTM_EP2) * A ** 5 / 120\n ) + 500000; // false easting\n\n let northing = UTM_K0 * (\n M + N * tanPhi * (\n A * A / 2\n + (5 - T + 9 * C + 4 * C * C) * A ** 4 / 24\n + (61 - 58 * T + T * T + 600 * C - 330 * UTM_EP2) * A ** 6 / 720\n )\n );\n\n const hemisphere = lat >= 0 ? 'N' : 'S';\n if (hemisphere === 'S') northing += 10000000; // false northing (southern hemi)\n\n return {\n zone,\n hemisphere,\n easting: Math.round(easting),\n northing: Math.round(northing),\n };\n}\n\n/**\n * Format a latitude/longitude as a compact UTM string for display, e.g.\n * `30N 706123E 619876N`. Returns the em-dash placeholder when the position\n * is unavailable.\n *\n * @param {number} lat\n * @param {number} lon\n * @returns {string}\n */\nexport function formatUTM(lat, lon) {\n const u = latLonToUTM(lat, lon);\n if (!u) return '—';\n return `${u.zone}${u.hemisphere} ${u.easting}E ${u.northing}N`;\n}\n\n/**\n * Format a distance in metres into a friendly string (m below 1 km, km above).\n * @param {number} meters\n * @returns {string}\n */\nexport function formatDistance(meters) {\n if (meters == null || Number.isNaN(meters)) return '—';\n if (meters < 1000) return `${Math.round(meters)} m`;\n return `${(meters / 1000).toFixed(2)} km`;\n}\n\n/**\n * Format a horizontal accuracy (metres) into a friendly ± string.\n * @param {number|null} meters\n * @returns {string}\n */\nexport function formatAccuracy(meters) {\n if (meters == null || Number.isNaN(meters)) return '—';\n return `±${Math.round(meters)} m`;\n}\n\n/**\n * Classify a horizontal accuracy into a qualitative fix-quality bucket. Useful\n * for colour-coding the UI without needing satellite count.\n * @param {number|null} meters\n * @returns {'good'|'fair'|'poor'|'none'}\n */\nexport function accuracyQuality(meters) {\n if (meters == null || Number.isNaN(meters)) return 'none';\n if (meters <= 10) return 'good';\n if (meters <= 30) return 'fair';\n return 'poor';\n}\n","/**\n * GeoTracker.js — a framework-agnostic GPS live-position + trail-recording\n * engine. It has **no** dependency on OpenLayers, Bootstrap, SQLocal, or any\n * LUPMIS code, so it can be dropped into any web project. Persistence and\n * server sync are provided by the host through small adapter objects.\n *\n * ─────────────────────────────────────────────────────────────────────────\n * STORAGE ADAPTER (required for recording) — all methods may be async:\n * createTrail(meta) -> trailId // meta: {uuid,name,startedAt,...}\n * addPoint(trailId, point) -> void // point: normalized fix (see below)\n * finishTrail(trailId, summary)-> void // summary: {endedAt,pointCount,distanceM}\n * getUnsyncedTrails() -> Array // trails with synced=0 and completed\n * getTrailPoints(trailId) -> Array\n * markTrailSynced(trailId, remoteId) -> void\n *\n * SYNC ADAPTER (optional) — store-and-forward:\n * pushTrail(trail, points) -> { remoteId } | throws\n * isOnline?() -> boolean // optional connectivity probe\n *\n * NORMALIZED FIX shape emitted on 'position' and stored via addPoint:\n * { lon, lat, accuracy, altitude, altitudeAccuracy, heading, speed,\n * satellites:null, timestamp }\n * (satellites is always null on the web Geolocation API — kept for parity\n * with native builds that can populate it.)\n * ─────────────────────────────────────────────────────────────────────────\n *\n * @module geotracker/GeoTracker\n */\n\nimport { haversineMeters } from './geo-utils.js';\n\n/** @typedef {'idle'|'watching'|'recording'} GeoTrackerState */\n\nconst DEFAULTS = {\n /** Minimum metres between two recorded trail points. */\n minDistanceM: 5,\n /** Ignore fixes arriving faster than this (throttle, ms). */\n minIntervalMs: 1000,\n /** Record a point at least this often even when stationary (heartbeat, ms). */\n heartbeatMs: 20000,\n /** Drop fixes worse than this horizontal accuracy (metres). 0 = accept all. */\n maxAccuracyM: 50,\n /** navigator.geolocation options. */\n enableHighAccuracy: true,\n timeoutMs: 15000,\n maximumAgeMs: 0,\n};\n\nexport class GeoTracker {\n /**\n * @param {object} [options]\n * @param {object} [options.storage] storage adapter (see module docs)\n * @param {object} [options.sync] sync adapter (see module docs)\n * @param {Geolocation} [options.geolocation] inject navigator.geolocation (for tests)\n * @param {number} [options.minDistanceM]\n * @param {number} [options.minIntervalMs]\n * @param {number} [options.heartbeatMs]\n * @param {number} [options.maxAccuracyM]\n * @param {boolean} [options.enableHighAccuracy]\n */\n constructor(options = {}) {\n this.opts = { ...DEFAULTS, ...options };\n this.storage = options.storage || null;\n this.sync = options.sync || null;\n this._geo = options.geolocation ||\n (typeof navigator !== 'undefined' ? navigator.geolocation : null);\n\n /** @type {GeoTrackerState} */\n this._state = 'idle';\n this._watchId = null;\n this._live = false; // live readout requested\n this._recording = false; // recording in progress\n\n this._activeTrailId = null;\n this._activeTrailUuid = null;\n this._lastRecorded = null; // last point actually written {lon,lat,timestamp}\n this._lastRecordedAt = 0;\n this._distanceM = 0;\n this._pointCount = 0;\n this._lastFix = null; // most recent normalized fix (any quality)\n\n /** @type {Record>} */\n this._listeners = Object.create(null);\n }\n\n // ── Events ────────────────────────────────────────────────────────────\n\n /**\n * Subscribe to an event. Returns an unsubscribe function.\n * Events: 'position' | 'point' | 'statechange' | 'trailstart' |\n * 'trailstop' | 'error' | 'syncstatus'\n * @param {string} event\n * @param {Function} cb\n * @returns {() => void}\n */\n on(event, cb) {\n (this._listeners[event] || (this._listeners[event] = new Set())).add(cb);\n return () => this._listeners[event]?.delete(cb);\n }\n\n _emit(event, payload) {\n const set = this._listeners[event];\n if (!set) return;\n for (const cb of set) {\n try { cb(payload); } catch (err) { console.error(`[GeoTracker] listener for \"${event}\" threw`, err); }\n }\n }\n\n // ── Public state ──────────────────────────────────────────────────────\n\n /** @returns {GeoTrackerState} */\n get state() { return this._state; }\n get isRecording() { return this._recording; }\n get lastFix() { return this._lastFix; }\n get isSupported() { return !!this._geo; }\n\n _setState(s) {\n if (this._state === s) return;\n this._state = s;\n this._emit('statechange', s);\n }\n\n // ── Live readout (watch without recording) ──────────────────────────────\n\n /**\n * Begin a position watch purely for the live readout (no trail is recorded).\n * Safe to call repeatedly.\n */\n startLive() {\n if (!this._geo) { this._emit('error', new Error('Geolocation not supported')); return; }\n this._live = true;\n this._ensureWatch();\n }\n\n /** Stop the live readout. Has no effect while a recording is in progress. */\n stopLive() {\n this._live = false;\n if (!this._recording) this._teardownWatch();\n }\n\n /**\n * One-shot position request (e.g. for a \"Locate me\" button). Resolves with a\n * normalized fix. Does not start/stop the watch.\n * @returns {Promise}\n */\n getCurrentPosition() {\n return new Promise((resolve, reject) => {\n if (!this._geo) { reject(new Error('Geolocation not supported')); return; }\n this._geo.getCurrentPosition(\n (pos) => {\n const fix = GeoTracker.normalize(pos);\n this._lastFix = fix;\n this._emit('position', fix);\n resolve(fix);\n },\n (err) => { this._emit('error', err); reject(err); },\n {\n enableHighAccuracy: this.opts.enableHighAccuracy,\n timeout: this.opts.timeoutMs,\n maximumAge: this.opts.maximumAgeMs,\n }\n );\n });\n }\n\n // ── Recording ───────────────────────────────────────────────────────────\n\n /**\n * Start recording a new trail. Creates the trail in storage, then records\n * filtered points as the device moves.\n * @param {object} [meta] e.g. { name, districtId }\n * @returns {Promise<{trailId:*, uuid:string}>}\n */\n async startRecording(meta = {}) {\n if (!this._geo) throw new Error('Geolocation not supported');\n if (!this.storage) throw new Error('GeoTracker: no storage adapter configured');\n if (this._recording) return { trailId: this._activeTrailId, uuid: this._activeTrailUuid };\n\n const uuid = GeoTracker.uuid();\n const startedAt = new Date().toISOString();\n const trailMeta = { uuid, name: meta.name || null, startedAt, ...meta };\n const trailId = await this.storage.createTrail(trailMeta);\n\n this._activeTrailId = trailId;\n this._activeTrailUuid = uuid;\n this._lastRecorded = null;\n this._lastRecordedAt = 0;\n this._distanceM = 0;\n this._pointCount = 0;\n this._recording = true;\n\n this._ensureWatch();\n this._setState('recording');\n this._emit('trailstart', { trailId, uuid, startedAt });\n return { trailId, uuid };\n }\n\n /**\n * Stop the active recording, finalise the trail summary, and (if a sync\n * adapter is present) attempt to push it immediately.\n * @returns {Promise<{trailId:*, pointCount:number, distanceM:number, synced:boolean}>}\n */\n async stopRecording() {\n if (!this._recording) return null;\n const trailId = this._activeTrailId;\n const endedAt = new Date().toISOString();\n const summary = { endedAt, pointCount: this._pointCount, distanceM: this._distanceM };\n\n this._recording = false;\n if (!this._live) this._teardownWatch();\n this._setState(this._live ? 'watching' : 'idle');\n\n try {\n await this.storage.finishTrail(trailId, summary);\n } catch (err) {\n this._emit('error', err);\n }\n this._emit('trailstop', { trailId, ...summary });\n\n let synced = false;\n if (this.sync) {\n try { synced = await this._syncTrail(trailId); }\n catch (err) { this._emit('error', err); }\n }\n\n this._activeTrailId = null;\n this._activeTrailUuid = null;\n return { trailId, pointCount: summary.pointCount, distanceM: summary.distanceM, synced };\n }\n\n // ── Sync (store-and-forward) ────────────────────────────────────────────\n\n /**\n * Push all completed-but-unsynced trails to the server via the sync adapter.\n * Call on app start and whenever connectivity returns.\n * @returns {Promise<{pushed:number, failed:number}>}\n */\n async syncPending() {\n if (!this.sync || !this.storage) return { pushed: 0, failed: 0 };\n if (this.sync.isOnline && !this.sync.isOnline()) return { pushed: 0, failed: 0 };\n\n let pushed = 0, failed = 0;\n const trails = await this.storage.getUnsyncedTrails();\n for (const trail of trails) {\n try {\n const ok = await this._syncTrail(trail.id ?? trail.trailId, trail);\n ok ? pushed++ : failed++;\n } catch (err) {\n failed++;\n this._emit('error', err);\n }\n }\n this._emit('syncstatus', { pushed, failed });\n return { pushed, failed };\n }\n\n /** @private push a single trail by id. */\n async _syncTrail(trailId, trailRow) {\n const points = await this.storage.getTrailPoints(trailId);\n const trail = trailRow || { id: trailId };\n const result = await this.sync.pushTrail(trail, points);\n const remoteId = result && (result.remoteId ?? result.id ?? null);\n await this.storage.markTrailSynced(trailId, remoteId);\n return true;\n }\n\n // ── Internal watch handling ──────────────────────────────────────────────\n\n /** @private start the geolocation watch if not already running. */\n _ensureWatch() {\n if (this._watchId != null || !this._geo) {\n if (this._state === 'idle' && this._live) this._setState('watching');\n return;\n }\n this._watchId = this._geo.watchPosition(\n (pos) => this._onFix(pos),\n (err) => this._emit('error', err),\n {\n enableHighAccuracy: this.opts.enableHighAccuracy,\n timeout: this.opts.timeoutMs,\n maximumAge: this.opts.maximumAgeMs,\n }\n );\n if (!this._recording) this._setState('watching');\n }\n\n /** @private stop the geolocation watch. */\n _teardownWatch() {\n if (this._watchId != null && this._geo) {\n this._geo.clearWatch(this._watchId);\n }\n this._watchId = null;\n }\n\n /** @private handle a raw Geolocation fix. */\n async _onFix(pos) {\n const fix = GeoTracker.normalize(pos);\n this._lastFix = fix;\n this._emit('position', fix); // always emit for the live readout\n\n if (!this._recording) return;\n\n const { minIntervalMs, minDistanceM, heartbeatMs, maxAccuracyM } = this.opts;\n const now = fix.timestamp;\n\n // Throttle very frequent fixes.\n if (this._lastRecordedAt && (now - this._lastRecordedAt) < minIntervalMs) return;\n // Drop low-quality fixes (unless this is the very first point).\n if (maxAccuracyM > 0 && fix.accuracy != null && fix.accuracy > maxAccuracyM && this._lastRecorded) return;\n\n let keep = false;\n let stepM = 0;\n if (!this._lastRecorded) {\n keep = true; // always record the first point\n } else {\n stepM = haversineMeters(this._lastRecorded.lon, this._lastRecorded.lat, fix.lon, fix.lat);\n const elapsed = now - this._lastRecordedAt;\n if (stepM >= minDistanceM || elapsed >= heartbeatMs) keep = true;\n }\n if (!keep) return;\n\n if (this._lastRecorded) this._distanceM += stepM;\n this._pointCount += 1;\n this._lastRecorded = { lon: fix.lon, lat: fix.lat, timestamp: now };\n this._lastRecordedAt = now;\n\n try {\n await this.storage.addPoint(this._activeTrailId, { ...fix, seq: this._pointCount });\n this._emit('point', {\n trailId: this._activeTrailId,\n seq: this._pointCount,\n point: fix,\n distanceM: this._distanceM,\n pointCount: this._pointCount,\n });\n } catch (err) {\n this._emit('error', err);\n }\n }\n\n // ── Static helpers ────────────────────────────────────────────────────────\n\n /** Normalize a browser GeolocationPosition into the module's fix shape. */\n static normalize(pos) {\n const c = pos.coords || {};\n const num = (v) => (v != null && !Number.isNaN(v) ? v : null);\n return {\n lon: c.longitude,\n lat: c.latitude,\n accuracy: num(c.accuracy),\n altitude: num(c.altitude),\n altitudeAccuracy: num(c.altitudeAccuracy),\n heading: num(c.heading),\n speed: num(c.speed),\n satellites: null, // not exposed by the web Geolocation API\n timestamp: pos.timestamp || Date.now(),\n };\n }\n\n /** RFC4122-ish UUID, using crypto when available. */\n static uuid() {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {\n const r = (Math.random() * 16) | 0;\n const v = ch === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n }\n}\n\nexport default GeoTracker;\n","/**\n * geotracker-lupmis.js — LUPMIS2 integration layer for the reusable GeoTracker.\n *\n * This is the ONLY place that couples the generic src/geotracker/ engine to\n * LUPMIS specifics (SQLocal storage, the PHP sync endpoint, district id,\n * online checks). To reuse GeoTracker in another app, copy src/geotracker/ and\n * write a file like this one with that app's storage + sync adapters.\n *\n * @module geotracker-lupmis\n */\n\nimport { GeoTracker } from './geotracker/GeoTracker.js';\nimport {\n createGpsTrail,\n addGpsTrailPoint,\n finishGpsTrail,\n getUnsyncedGpsTrails,\n getGpsTrailPoints,\n markGpsTrailSynced,\n} from './database.js';\nimport { pushGpsTrail } from './remotedb.js';\nimport { isOnline } from './pwa.js';\nimport { getSession } from './remotedb.js';\n\n/**\n * Storage adapter — maps the GeoTracker contract onto the SQLocal helpers in\n * database.js. The district id is stamped onto each trail at creation time.\n */\nconst sqlocalStorage = {\n async createTrail(meta) {\n const districtId = meta.districtId\n ?? getSession()?.district_id\n ?? null;\n return createGpsTrail({ ...meta, districtId: districtId != null ? String(districtId) : null });\n },\n addPoint: (trailId, point) => addGpsTrailPoint(trailId, point),\n finishTrail: (trailId, summary) => finishGpsTrail(trailId, summary),\n getUnsyncedTrails: () => getUnsyncedGpsTrails(),\n getTrailPoints: (trailId) => getGpsTrailPoints(trailId),\n markTrailSynced: (trailId, remote) => markGpsTrailSynced(trailId, remote),\n};\n\n/**\n * Sync adapter — store-and-forward upload via the PHP endpoint. `isOnline()`\n * lets the tracker skip pushes while offline (it retries later).\n */\nconst remoteSync = {\n pushTrail: (trail, points) => pushGpsTrail(trail, points),\n isOnline: () => isOnline(),\n};\n\n/**\n * The configured, app-wide tracker instance. Tunables chosen for field\n * walking/driving: a point every ~5 m, throttled to ≥1 s, with a 20 s\n * heartbeat so stationary pauses still leave a breadcrumb, dropping fixes\n * worse than 50 m accuracy.\n */\nexport const geoTracker = new GeoTracker({\n storage: sqlocalStorage,\n sync: remoteSync,\n minDistanceM: 5,\n minIntervalMs: 1000,\n heartbeatMs: 20000,\n maxAccuracyM: 50,\n enableHighAccuracy: true,\n});\n\nexport default geoTracker;\n","/**\n * LUPMIS2 — iframe embed bridge\n *\n * Implements the postMessage contract defined in\n * LUPMIS2_Permit_Map_Integration.docx §2\n * which itself follows the iframe channel from\n * LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4.\n *\n * Outbound (embed → host):\n * { type: 'ready' }\n * { type: 'parcel:select', upn, parcel_id, lon, lat,\n * zone_code, zone_name, landuse,\n * min_height, max_height }\n * { type: 'parcel:cleared' }\n * { type: 'error', code, message }\n *\n * Inbound (host → embed):\n * { type: 'set:view', lon, lat, zoom }\n * { type: 'set:selected', upn }\n * { type: 'clear:selected' }\n * { type: 'set:basemap', key }\n *\n * The bridge is framework-agnostic apart from the OpenLayers imports — it\n * lives one level above the proposed `map-core` library so once that library\n * is extracted (concept §3.1) this file can be lifted into it unchanged.\n *\n * Note on security: outbound messages are sent with target origin `*` because\n * the embed cannot know its parent's origin in advance and the payload is\n * non-sensitive (selection metadata only). The HOST is expected to verify\n * `event.origin === ''` before trusting any message, as\n * documented in §2.2 of the integration doc. Inbound commands are\n * type-checked against a strict whitelist before being acted on.\n */\n\nimport { fromLonLat, toLonLat } from 'ol/proj.js';\nimport { getCenter } from 'ol/extent.js';\nimport VectorLayer from 'ol/layer/Vector.js';\nimport VectorSource from 'ol/source/Vector.js';\nimport { Style, Stroke, Fill } from 'ol/style.js';\n\nconst KNOWN_COMMANDS = new Set([\n 'set:view',\n 'set:selected',\n 'clear:selected',\n 'set:basemap',\n]);\n\n/**\n * Create and install the embed bridge.\n *\n * @param {Object} opts\n * @param {Object} opts.mapView — the MapView instance (uses onClick,\n * getMap, setBaseMap).\n * @param {Object} opts.embedConfig — window.LUPMIS_EMBED contents.\n * @returns {{ attachParcelsLayer: (layer) => void, emitError: (code, msg) => void }}\n */\nexport function createEmbedBridge({ mapView, embedConfig }) {\n const map = mapView.getMap();\n const parent = (window.parent && window.parent !== window) ? window.parent : null;\n\n // -------------------------------------------------------------------------\n // Highlight layer — visual cue for the currently-selected parcel.\n // -------------------------------------------------------------------------\n const highlightSource = new VectorSource();\n const highlightLayer = new VectorLayer({\n source: highlightSource,\n zIndex: 9999,\n style: new Style({\n stroke: new Stroke({ color: '#f97316', width: 3 }),\n fill: new Fill({ color: 'rgba(249,115,22,0.18)' }),\n }),\n properties: {\n title: 'Permit selection',\n displayInLayerSwitcher: false,\n },\n });\n map.addLayer(highlightLayer);\n\n let parcelsLayer = null;\n let pendingSelectUpn = embedConfig?.upn ? String(embedConfig.upn) : null;\n let readyEmitted = false;\n\n // -------------------------------------------------------------------------\n // Outbound\n // -------------------------------------------------------------------------\n function send(message) {\n if (!parent) {\n console.warn('[embed-bridge] No parent window — would have sent:', message);\n return;\n }\n try {\n parent.postMessage(message, '*');\n } catch (e) {\n console.warn('[embed-bridge] postMessage failed:', e);\n }\n }\n\n function emitError(code, message) {\n send({ type: 'error', code, message });\n }\n\n function emitReady() {\n if (readyEmitted) return;\n readyEmitted = true;\n send({ type: 'ready' });\n }\n\n /** Build a `parcel:select` payload from a feature and (optionally) the click point. */\n function parcelPayload(feature, lon, lat) {\n const p = feature.getProperties();\n let outLon = lon, outLat = lat;\n if (outLon == null || outLat == null) {\n const ext = feature.getGeometry()?.getExtent();\n if (ext) {\n const [cx, cy] = toLonLat(getCenter(ext));\n outLon = cx; outLat = cy;\n }\n }\n return {\n type: 'parcel:select',\n upn: p.upn ?? null,\n parcel_id: p.id ?? null,\n lon: outLon ?? null,\n lat: outLat ?? null,\n zone_code: p.zone_code ?? null,\n zone_name: p.zone_name ?? null,\n landuse: p.landuse ?? null,\n min_height: p.min_height ?? null,\n max_height: p.max_height ?? null,\n };\n }\n\n function highlightFeature(feature) {\n highlightSource.clear();\n if (feature) {\n const clone = feature.clone();\n highlightSource.addFeature(clone);\n }\n }\n\n // -------------------------------------------------------------------------\n // Click → parcel:select / parcel:cleared\n // -------------------------------------------------------------------------\n mapView.onClick((lon, lat, _markerFeature, evt) => {\n let parcelFeature = null;\n map.forEachFeatureAtPixel(evt.pixel, (f) => {\n if (f.get('_layerType') === 'parcel') {\n parcelFeature = f;\n return true;\n }\n });\n if (parcelFeature) {\n highlightFeature(parcelFeature);\n send(parcelPayload(parcelFeature, lon, lat));\n } else {\n highlightFeature(null);\n send({ type: 'parcel:cleared' });\n }\n });\n\n // -------------------------------------------------------------------------\n // Inbound commands\n // -------------------------------------------------------------------------\n window.addEventListener('message', (event) => {\n const msg = event.data;\n if (!msg || typeof msg !== 'object' || !KNOWN_COMMANDS.has(msg.type)) return;\n try {\n switch (msg.type) {\n case 'set:view': {\n if (typeof msg.lon === 'number' && typeof msg.lat === 'number') {\n const view = map.getView();\n view.setCenter(fromLonLat([msg.lon, msg.lat]));\n if (typeof msg.zoom === 'number') view.setZoom(msg.zoom);\n }\n break;\n }\n case 'set:selected':\n if (msg.upn) selectByUpn(String(msg.upn));\n break;\n case 'clear:selected':\n highlightFeature(null);\n pendingSelectUpn = null;\n break;\n case 'set:basemap':\n if (msg.key && typeof mapView.setBaseMap === 'function') {\n mapView.setBaseMap(msg.key);\n }\n break;\n }\n } catch (e) {\n emitError('COMMAND_FAILED', `Failed to handle ${msg.type}: ${e.message}`);\n }\n });\n\n /**\n * Find the parcel with the given UPN, highlight it, fit the view to it,\n * and emit a synthesized parcel:select so the host receives the metadata.\n * If the parcels haven't finished loading yet, the UPN is queued and the\n * lookup is retried as features stream in.\n */\n function selectByUpn(upn) {\n if (!parcelsLayer) { pendingSelectUpn = upn; return; }\n const features = parcelsLayer.getSource().getFeatures();\n const feature = features.find((f) => String(f.get('upn') ?? '') === upn);\n if (!feature) { pendingSelectUpn = upn; return; }\n\n pendingSelectUpn = null;\n highlightFeature(feature);\n const ext = feature.getGeometry()?.getExtent();\n if (ext) {\n map.getView().fit(ext, { padding: [50, 50, 50, 50], duration: 400, maxZoom: 17 });\n }\n send(parcelPayload(feature, null, null));\n }\n\n // -------------------------------------------------------------------------\n // Parcels attached → emit `ready`, drain any pending set:selected\n // -------------------------------------------------------------------------\n function attachParcelsLayer(layer) {\n parcelsLayer = layer;\n const source = layer.getSource();\n\n const drain = () => {\n // Microtask hop so a batch addFeatures() finishes before we react.\n queueMicrotask(() => {\n if (pendingSelectUpn) selectByUpn(pendingSelectUpn);\n emitReady();\n });\n };\n\n if (source.getFeatures().length > 0) {\n drain();\n } else {\n // Stay subscribed: parcels may arrive in waves (cache then API refresh),\n // and a pending UPN may only resolve after the second wave.\n let scheduled = false;\n source.on('addfeature', () => {\n if (scheduled) return;\n scheduled = true;\n queueMicrotask(() => {\n scheduled = false;\n if (pendingSelectUpn) selectByUpn(pendingSelectUpn);\n emitReady();\n });\n });\n }\n }\n\n // -------------------------------------------------------------------------\n // Apply initial config: basemap + view (UPN is handled after parcels load)\n // -------------------------------------------------------------------------\n if (embedConfig?.basemap && typeof mapView.setBaseMap === 'function') {\n mapView.setBaseMap(embedConfig.basemap);\n }\n if (typeof embedConfig?.lon === 'number' && typeof embedConfig?.lat === 'number') {\n const view = map.getView();\n view.setCenter(fromLonLat([embedConfig.lon, embedConfig.lat]));\n view.setZoom(typeof embedConfig?.zoom === 'number' ? embedConfig.zoom : 15);\n }\n\n return { attachParcelsLayer, emitError };\n}\n","/**\n * Target-type auto-detect + field auto-mapping for imported FeatureCollections.\n *\n * Pure helpers — no DOM, no map, no DB. The mapping modal calls these once\n * after the file has been parsed to seed its initial state, and re-uses them\n * whenever the user changes target type so the field map updates accordingly.\n *\n * Contract per LUPMIS2_Import_Upload_Design.docx §3.\n *\n * detectTargetType(fc) → 'parcels'|'collector_zones'|'osm_roads'\n * |'building_footprints'|'other'\n * autoMapFields(fc, targetType) → { [lupmisColumn]: sourceField | null }\n * TARGET_TYPES → metadata used by the modal dropdown\n * TARGET_FIELDS[targetType] → ordered list of LUPMIS2 columns the\n * upload payload must populate\n */\n\n// ---------------------------------------------------------------------------\n// Target-type registry\n// ---------------------------------------------------------------------------\n\nexport const TARGET_TYPES = [\n { key: 'parcels', label: 'Parcels', geometryFamily: 'polygon' },\n { key: 'collector_zones', label: 'Collector Zones', geometryFamily: 'polygon' },\n { key: 'building_footprints', label: 'Building Footprints', geometryFamily: 'polygon' },\n { key: 'osm_roads', label: 'OSM Roads', geometryFamily: 'line' },\n { key: 'other', label: 'Other (view only)', geometryFamily: 'any' },\n];\n\n// LUPMIS2 column lists per target type, in the order the mapping modal\n// should display them. Subset of the typed-table schemas the user actually\n// edits — system columns (id, created_at, updated_at, fetched_at, status)\n// are NOT user-mapped.\nexport const TARGET_FIELDS = {\n parcels: [\n 'upn', 'landuse', 'zone_code', 'zone_name', 'sector', 'block',\n 'parcel_no', 'prop_no', 'st_name', 'prop_add', 'fac_name',\n 'min_height', 'max_height', 'eff_date', 'lp_name', 'locality',\n 'mmda', 'last_update', 'remarks',\n ],\n collector_zones: [\n 'zone_name',\n ],\n building_footprints: [\n // The remote table is a thin schema today; we leave room for the\n // database team to add columns later (the modal will accept more).\n ],\n osm_roads: [\n 'osm_id', 'name', 'highway',\n ],\n other: [],\n};\n\n// ---------------------------------------------------------------------------\n// Type-detect signals\n// ---------------------------------------------------------------------------\n\n/**\n * Inspect every feature's geometry type. We return the dominant family\n * because shapefiles can sometimes mix (rare but possible).\n *\n * @returns {'polygon'|'line'|'point'|'mixed'|'none'}\n */\nfunction dominantGeometryFamily(fc) {\n if (!fc?.features?.length) return 'none';\n let polys = 0, lines = 0, points = 0;\n for (const f of fc.features) {\n const t = f?.geometry?.type;\n if (!t) continue;\n if (t === 'Polygon' || t === 'MultiPolygon') polys++;\n else if (t === 'LineString' || t === 'MultiLineString') lines++;\n else if (t === 'Point' || t === 'MultiPoint') points++;\n }\n const total = polys + lines + points;\n if (total === 0) return 'none';\n const max = Math.max(polys, lines, points);\n if (max < total * 0.85) return 'mixed';\n if (polys === max) return 'polygon';\n if (lines === max) return 'line';\n return 'point';\n}\n\n/**\n * Build a normalised attribute-name set across the first N features (so\n * inconsistent rows don't skew detection).\n */\nfunction attributeFingerprint(fc, sampleSize = 50) {\n const set = new Set();\n const n = Math.min(sampleSize, fc.features.length);\n for (let i = 0; i < n; i++) {\n const props = fc.features[i]?.properties;\n if (!props || typeof props !== 'object') continue;\n for (const key of Object.keys(props)) {\n set.add(String(key).toLowerCase());\n }\n }\n return set;\n}\n\n/** Does the fingerprint contain at least one of the given candidates? */\nfunction has(fp, ...candidates) {\n for (const c of candidates) if (fp.has(c)) return true;\n return false;\n}\n\n/**\n * Suggest a target type based on geometry family + attribute fingerprint.\n * Conservative: returns 'other' whenever the signal isn't strong enough,\n * leaving the user to choose explicitly.\n */\nexport function detectTargetType(fc) {\n const family = dominantGeometryFamily(fc);\n if (family === 'none' || family === 'mixed') return 'other';\n const fp = attributeFingerprint(fc);\n\n if (family === 'line') {\n if (has(fp, 'osm_id', 'highway')) return 'osm_roads';\n return 'other';\n }\n\n if (family === 'polygon') {\n // Parcels are the most attribute-rich polygon: UPN is the strongest tell.\n if (has(fp, 'upn', 'parcel_no', 'landuse', 'lu_code', 'zone_code') &&\n // a single \"zone_name\" without UPN is more likely a zone overlay\n has(fp, 'upn', 'parcel_no', 'landuse', 'lu_code')) {\n return 'parcels';\n }\n if (has(fp, 'zone_name', 'colzonename', 'colzonenr')) return 'collector_zones';\n if (has(fp, 'building', 'building:levels', 'building_levels', 'height',\n 'min_height', 'max_height') &&\n !has(fp, 'upn', 'parcel_no')) {\n return 'building_footprints';\n }\n return 'other';\n }\n\n // Points have no upload target in the current design.\n return 'other';\n}\n\n// ---------------------------------------------------------------------------\n// Field auto-mapping\n// ---------------------------------------------------------------------------\n\n/**\n * Per-LUPMIS2-column candidate source field names. Lower-case, ordered by\n * preference. Matching is case-insensitive and ignores spaces / underscores\n * / hyphens.\n */\nconst FIELD_CANDIDATES = {\n // Parcels (lu_parcels)\n upn: ['upn', 'unique_parcel_no', 'parcel_id', 'pid'],\n landuse: ['landuse', 'land_use', 'lu', 'lu_code'],\n zone_code: ['zone_code', 'zonecode', 'zone'],\n zone_name: ['zone_name', 'zonename', 'colzonename'],\n sector: ['sector', 'sec'],\n block: ['block', 'blk'],\n parcel_no: ['parcel_no', 'parcelno', 'plot_no', 'plotno'],\n prop_no: ['prop_no', 'propertyno', 'property_no'],\n st_name: ['st_name', 'street', 'street_name', 'road'],\n prop_add: ['prop_add', 'address', 'addr'],\n fac_name: ['fac_name', 'facility', 'facilityname'],\n min_height: ['min_height', 'minheight', 'h_min'],\n max_height: ['max_height', 'maxheight', 'h_max', 'height'],\n eff_date: ['eff_date', 'effectivedate', 'effdate'],\n lp_name: ['lp_name', 'lpname', 'localplan'],\n locality: ['locality', 'town', 'settlement'],\n mmda: ['mmda', 'district', 'assembly'],\n last_update:['last_update', 'lastupdate', 'updated'],\n remarks: ['remarks', 'notes', 'comments'],\n\n // OSM roads\n osm_id: ['osm_id', 'osmid', 'id'],\n name: ['name', 'street_name', 'st_name'],\n highway: ['highway', 'road_class', 'class'],\n};\n\n/** Normalise a field name for matching (lowercase, strip _ - and spaces). */\nfunction normaliseKey(s) {\n return String(s).toLowerCase().replace(/[\\s_\\-]+/g, '');\n}\n\n/**\n * Suggest a source-field → LUPMIS2-column mapping for the given target type.\n * Unmapped columns get null so the modal can show \"(none)\" and let the user\n * pick from a dropdown of available source fields.\n *\n * @param {Object} fc — parsed FeatureCollection\n * @param {string} targetType — one of TARGET_TYPES keys\n * @returns {Object} { [lupmisColumn]: sourceField|null }\n */\nexport function autoMapFields(fc, targetType) {\n const columns = TARGET_FIELDS[targetType] || [];\n if (columns.length === 0) return {};\n\n const fp = attributeFingerprint(fc, 50);\n // Build normalised → original lookup for available source fields\n const sourceByNorm = new Map();\n for (const key of fp) {\n sourceByNorm.set(normaliseKey(key), key);\n }\n\n const mapping = {};\n for (const col of columns) {\n const candidates = FIELD_CANDIDATES[col] || [col];\n let matched = null;\n for (const cand of candidates) {\n const n = normaliseKey(cand);\n if (sourceByNorm.has(n)) {\n matched = sourceByNorm.get(n);\n break;\n }\n }\n mapping[col] = matched;\n }\n return mapping;\n}\n\n/**\n * Apply a mapping to one source-feature properties object and return the\n * LUPMIS2-shaped properties (column-name keys). Source fields not in the\n * mapping are dropped. Used during the staging-row insert.\n */\nexport function applyFieldMapping(sourceProps, mapping) {\n const out = {};\n for (const [targetCol, sourceKey] of Object.entries(mapping || {})) {\n if (sourceKey == null) continue;\n if (sourceProps && Object.prototype.hasOwnProperty.call(sourceProps, sourceKey)) {\n out[targetCol] = sourceProps[sourceKey];\n }\n }\n return out;\n}\n\n/**\n * Return the list of distinct source-property names (the union across all\n * features), preserving discovery order — used to populate the per-column\n * dropdown in the mapping modal.\n */\nexport function listSourceFields(fc) {\n const seen = new Map();\n for (const f of fc.features || []) {\n const props = f?.properties;\n if (!props || typeof props !== 'object') continue;\n for (const k of Object.keys(props)) {\n if (!seen.has(k)) seen.set(k, true);\n }\n }\n return Array.from(seen.keys());\n}\n\n/**\n * For each source-property name in the FeatureCollection, return one sample\n * value drawn from the first feature that has a non-empty value for it.\n * Used by the mapping modal to show users what each attribute actually\n * contains — e.g. dropdown options become `upn [12345-6789]` rather than\n * just `upn`. Empty / null / undefined values are skipped while scanning;\n * arrays and objects are JSON-stringified so something readable surfaces.\n *\n * @param {Object} fc — parsed FeatureCollection\n * @param {number} [maxLen] — max sample length (truncated with `…`)\n * @returns {Object} { [sourceField]: sample }\n */\nexport function sampleSourceValues(fc, maxLen = 35) {\n const samples = {};\n for (const feature of fc.features || []) {\n const props = feature?.properties;\n if (!props || typeof props !== 'object') continue;\n for (const [key, value] of Object.entries(props)) {\n if (samples[key] !== undefined) continue; // already have one\n if (value === null || value === undefined || value === '') continue;\n\n let str;\n if (typeof value === 'object') {\n try { str = JSON.stringify(value); } catch { str = String(value); }\n } else {\n str = String(value);\n }\n // Collapse whitespace so a multi-line cell value still renders inline.\n str = str.replace(/\\s+/g, ' ').trim();\n if (!str) continue;\n if (str.length > maxLen) str = str.slice(0, maxLen - 1) + '…';\n samples[key] = str;\n }\n }\n return samples;\n}\n","/**\n * Import-mapping modal controller.\n *\n * openImportMappingModal({ importId, filename, fc, onResult })\n *\n * Populates the modal from the parsed FeatureCollection, lets the user pick\n * a target type and adjust the field map, then calls onResult with one of:\n *\n * { action: 'cancel' } — keep as Other / view only\n * { action: 'save', targetType, mapping }\n * { action: 'upload', targetType, mapping }\n *\n * The caller is responsible for updating external_imports + the staged\n * features (this module knows nothing about the DB or the map).\n *\n * See LUPMIS2_Import_Upload_Design.docx §3.1.\n */\n\nimport { Modal } from 'bootstrap';\nimport {\n TARGET_TYPES,\n TARGET_FIELDS,\n detectTargetType,\n autoMapFields,\n listSourceFields,\n sampleSourceValues,\n} from './import-detect.js';\n\nconst els = {}; // cached DOM lookups\nlet modal = null;\nlet state = null; // { importId, filename, fc, sourceFields, mapping, targetType, onResult }\n\nfunction cacheEls() {\n if (els.root) return;\n els.root = document.getElementById('importMappingModal');\n els.filename = document.getElementById('import-modal-filename');\n els.summary = document.getElementById('import-modal-summary');\n els.target = document.getElementById('import-modal-target');\n els.targetHint = document.getElementById('import-modal-target-hint');\n els.fieldsWrap = document.getElementById('import-modal-fields-wrap');\n els.tbody = document.getElementById('import-modal-fields-tbody');\n els.btnSave = document.getElementById('import-modal-save');\n els.btnSaveUpload = document.getElementById('import-modal-save-upload');\n els.btnCancel = document.getElementById('import-modal-cancel');\n\n // Populate the target-type dropdown once.\n if (els.target && !els.target.dataset.populated) {\n els.target.innerHTML = TARGET_TYPES\n .map((t) => ``)\n .join('');\n els.target.dataset.populated = '1';\n }\n\n // Event wiring (idempotent).\n if (els.target && !els.target.dataset.wired) {\n els.target.dataset.wired = '1';\n els.target.addEventListener('change', onTargetChange);\n }\n if (els.btnSave && !els.btnSave.dataset.wired) {\n els.btnSave.dataset.wired = '1';\n els.btnSave.addEventListener('click', () => finish('save'));\n }\n if (els.btnSaveUpload && !els.btnSaveUpload.dataset.wired) {\n els.btnSaveUpload.dataset.wired = '1';\n els.btnSaveUpload.addEventListener('click', () => finish('upload'));\n }\n // Cancel uses Bootstrap's data-bs-dismiss; we hook the hidden event so\n // closing via × / ESC / Cancel all behave the same: a 'cancel' result.\n if (els.root && !els.root.dataset.wired) {\n els.root.dataset.wired = '1';\n els.root.addEventListener('hidden.bs.modal', () => {\n if (state?.onResult && !state._resolved) {\n state._resolved = true;\n state.onResult({ action: 'cancel' });\n }\n state = null;\n });\n }\n}\n\n/** Render the field-mapping table for the current targetType. */\nfunction renderFieldsTable() {\n const targetType = state.targetType;\n const columns = TARGET_FIELDS[targetType] || [];\n\n // \"Other (view only)\" → no fields to map.\n if (targetType === 'other' || columns.length === 0) {\n els.fieldsWrap.style.display = 'none';\n return;\n }\n els.fieldsWrap.style.display = '';\n\n // Each dropdown option shows the source field name AND a one-feature\n // sample value so the user can recognise the attribute by its content,\n // not just its name. Sample is computed once in state.sourceSamples; see\n // sampleSourceValues() in import-detect.js.\n //\n // The field name is rendered with Unicode mathematical-bold characters\n // (U+1D400 / U+1D41A / U+1D7CE blocks) because HTML / CSS bold doesn't\n // render inside ']\n .concat(state.sourceFields.map((s) =>\n ``))\n .join('');\n\n els.tbody.innerHTML = columns.map((col) => {\n const current = state.mapping[col] || '';\n // Mark the matching option as selected. The opening `