diff --git a/dist/assets/analysis-modal-D1gi9me1.js b/dist/assets/analysis-modal-DX_KiCnZ.js similarity index 99% rename from dist/assets/analysis-modal-D1gi9me1.js rename to dist/assets/analysis-modal-DX_KiCnZ.js index 652be3c..2de8a7e 100644 --- a/dist/assets/analysis-modal-D1gi9me1.js +++ b/dist/assets/analysis-modal-DX_KiCnZ.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-C3r_p8o6.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-BM5P5Dc7.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-D1gi9me1.js.map +//# sourceMappingURL=analysis-modal-DX_KiCnZ.js.map diff --git a/dist/assets/analysis-modal-D1gi9me1.js.map b/dist/assets/analysis-modal-DX_KiCnZ.js.map similarity index 99% rename from dist/assets/analysis-modal-D1gi9me1.js.map rename to dist/assets/analysis-modal-DX_KiCnZ.js.map index 5e2fbba..fb4be68 100644 --- a/dist/assets/analysis-modal-D1gi9me1.js.map +++ b/dist/assets/analysis-modal-DX_KiCnZ.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 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 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 `