From 8674ca4df239dcd161148a5cbdc92697e0eb0438 Mon Sep 17 00:00:00 2001 From: ekke Date: Wed, 23 Sep 2026 13:40:13 +0200 Subject: [PATCH] Build: stop the lazy geotiff and shapefile chunks loading at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.config.js splits geotiff and shpjs out so they load only when a user opens a Cloud-Optimized GeoTIFF or imports a shapefile, but the built index.html preloaded both on every visit — about 428 kB (152 kB gzipped) nobody had asked for. Two separate causes, both visible in the entry chunk's first line: - it imported Vite's preload helper, used by every dynamic import(), from the geotiff chunk. The helper is a virtual module with no node_modules path, so Rollup had parked it there; needing it at startup dragged the whole chunk in. It now has a chunk of its own, about 1 kB. - it imported JSZip from the shpjs chunk. JSZip reached the bundle through shp-write, which gis-export.js imports for Shapefile export, so it is needed at startup whatever happens; filing it under shpjs pulled that chunk in too. It is now its own chunk, costing the 31 kB it actually is. dist/ rebuilt: index.html now preloads vite-helpers and jszip instead. Co-Authored-By: Claude Opus 5 --- dist/assets/analysis-modal-CXnHlVxO.js | 11 ----- dist/assets/analysis-modal-D9UrhqgN.js | 11 +++++ ....js.map => analysis-modal-D9UrhqgN.js.map} | 2 +- dist/assets/geotiff-B9aROX49.js.map | 1 - ...eotiff-B9aROX49.js => geotiff-DGVlFsHY.js} | 20 ++++----- dist/assets/geotiff-DGVlFsHY.js.map | 1 + dist/assets/index-BzlrAJOS.js.map | 1 - .../{index-BzlrAJOS.js => index-D79Zl-eh.js} | 44 +++++++++---------- dist/assets/index-D79Zl-eh.js.map | 1 + ...ex.es-CW5N1l0t.js => index.es-CHB65B7S.js} | 6 +-- ...5N1l0t.js.map => index.es-CHB65B7S.js.map} | 2 +- .../{jspdf-BsOD2LDR.js => jspdf-BpioTcs4.js} | 8 ++-- ...-BsOD2LDR.js.map => jspdf-BpioTcs4.js.map} | 2 +- dist/assets/jszip-Dgg-11DP.js | 3 ++ dist/assets/jszip-Dgg-11DP.js.map | 1 + dist/assets/pdf-export-C0eAmANf.js | 2 - dist/assets/pdf-export-Cq3nXOgJ.js | 2 + ...mANf.js.map => pdf-export-Cq3nXOgJ.js.map} | 2 +- dist/assets/shpjs-CNrRgkgn.js | 5 +++ dist/assets/shpjs-CNrRgkgn.js.map | 1 + dist/assets/shpjs-Doisr2ug.js | 6 --- dist/assets/shpjs-Doisr2ug.js.map | 1 - dist/assets/vite-helpers-BlTxHScW.js | 2 + dist/assets/vite-helpers-BlTxHScW.js.map | 1 + dist/index.html | 12 ++--- vite.config.js | 23 ++++++++-- 26 files changed, 97 insertions(+), 74 deletions(-) delete mode 100644 dist/assets/analysis-modal-CXnHlVxO.js create mode 100644 dist/assets/analysis-modal-D9UrhqgN.js rename dist/assets/{analysis-modal-CXnHlVxO.js.map => analysis-modal-D9UrhqgN.js.map} (99%) delete mode 100644 dist/assets/geotiff-B9aROX49.js.map rename dist/assets/{geotiff-B9aROX49.js => geotiff-DGVlFsHY.js} (79%) create mode 100644 dist/assets/geotiff-DGVlFsHY.js.map delete mode 100644 dist/assets/index-BzlrAJOS.js.map rename dist/assets/{index-BzlrAJOS.js => index-D79Zl-eh.js} (88%) create mode 100644 dist/assets/index-D79Zl-eh.js.map rename dist/assets/{index.es-CW5N1l0t.js => index.es-CHB65B7S.js} (80%) rename dist/assets/{index.es-CW5N1l0t.js.map => index.es-CHB65B7S.js.map} (99%) rename dist/assets/{jspdf-BsOD2LDR.js => jspdf-BpioTcs4.js} (96%) rename dist/assets/{jspdf-BsOD2LDR.js.map => jspdf-BpioTcs4.js.map} (99%) create mode 100644 dist/assets/jszip-Dgg-11DP.js create mode 100644 dist/assets/jszip-Dgg-11DP.js.map delete mode 100644 dist/assets/pdf-export-C0eAmANf.js create mode 100644 dist/assets/pdf-export-Cq3nXOgJ.js rename dist/assets/{pdf-export-C0eAmANf.js.map => pdf-export-Cq3nXOgJ.js.map} (98%) create mode 100644 dist/assets/shpjs-CNrRgkgn.js create mode 100644 dist/assets/shpjs-CNrRgkgn.js.map delete mode 100644 dist/assets/shpjs-Doisr2ug.js delete mode 100644 dist/assets/shpjs-Doisr2ug.js.map create mode 100644 dist/assets/vite-helpers-BlTxHScW.js create mode 100644 dist/assets/vite-helpers-BlTxHScW.js.map diff --git a/dist/assets/analysis-modal-CXnHlVxO.js b/dist/assets/analysis-modal-CXnHlVxO.js deleted file mode 100644 index 0addf2e..0000000 --- a/dist/assets/analysis-modal-CXnHlVxO.js +++ /dev/null @@ -1,11 +0,0 @@ -import{_ as de}from"./geotiff-B9aROX49.js";import{M as pe}from"./bootstrap-D1-uvFxm.js";import{ax as ye}from"./openlayers-D5-VSKzq.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-BzlrAJOS.js";import"./pako-Xa-UToif.js";import"./ol-ext-DV70ljNY.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=>` -
- - -
`).join("")}function ne(){const e=M[Number(t.inputLayer.value)],n=e?e.layer.getSource().getFeatures():[],o=$e(n);t.field.innerHTML=''+o.map(s=>``).join(""),t.field.disabled=o.length===0}function V(){return(t.mode.find(e=>e.checked)||{}).value||"overlay"}function oe(){k=null,ie();const e=V()==="overlay";t.overlayBox.classList.toggle("d-none",!e),t.zonalBox.classList.toggle("d-none",e),ae()}function re(){const e=G.find(o=>o.key===t.op.value);t.opHint.textContent=e?e.hint:"";const n=e?e.needsB:!0;t.layerBWrap.querySelector("label").textContent=n?"Layer B":"Layer B (optional)",ae()}function se(){const e=Z.find(n=>n.key===t.membership.value);t.memberHint.textContent=e?e.hint:""}function ae(){if(t.outName.dataset.touched!=="1")if(V()==="overlay"){const e=t.op.value||"overlay";t.outName.value=`${e}_result`}else t.outName.value="zonal_stats"}function T(e){const n=M[Number(e.value)];if(!n)return[];const o=n.layer.getSource(),s=t.scope?t.scope.value:"all";if(s==="view"){const r=v.getMap(),a=r.getView().calculateExtent(r.getSize());return o.getFeaturesInExtent(a)}if(s==="selection"){const r=new Set(v.getSelectedFeatures());return o.getFeatures().filter(a=>r.has(a))}return s==="drawn"&&x?o.getFeatures().filter(r=>x.features.has(r)):o.getFeatures()}function le(){const e=t.scope.value;if(e==="drawn"&&x)t.scopeHint.textContent=`${x.features.size.toLocaleString()} feature(s) caught by the ${x.label}.`;else if(e==="selection"){const n=v?v.getSelectedFeatures().length:0;t.scopeHint.textContent=n===0?"Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.":`${n.toLocaleString()} feature(s) currently selected.`}else e==="view"?t.scopeHint.textContent="Only features inside the current map view are analysed — zoom to the area of interest first.":t.scopeHint.textContent="Every feature in the chosen layer. Narrow this if a large layer is slow."}function Oe(){let e=v.getLayerGroupByTitle(H);return e||(v.addLayerGroup(Ne,H,"Results of client-side spatial analysis"),e=v.getLayerGroupByTitle(H)),e}function Ce(){t.result.innerHTML="",t.btnRun.disabled=!0,setTimeout(()=>{try{const e=V(),n=(t.outName.value||"").trim()||(e==="overlay"?"overlay_result":"zonal_stats");let o,s,r=[];if(e==="overlay"){k=null;const l=t.op.value,i=T(t.layerA),u=T(t.layerB),f=(G.find(d=>d.key===l)||{}).needsB;if(i.length===0)throw new Error("Layer A has no features.");if(f&&u.length===0)throw new Error("This operation needs a second layer with features.");o=Se(l,i,u),s=`${o.features.length.toLocaleString()} feature(s) produced.`}else{const l=T(t.zoneLayer),i=T(t.inputLayer),u=Array.from(t.statsBox.querySelectorAll("input:checked")).map(p=>p.value),f=t.field.value||null;if(u.length===0)throw new Error("Select at least one statistic.");if(u.some(p=>(R.find(L=>L.key===p)||{}).needsField)&&!f)throw new Error("Sum / mean / min / max need a numeric field.");const y=ze({zoneFeatures:l,inputFeatures:i,stats:u,field:f,membership:t.membership.value});o=y.geojson,r=y.warnings||[],s=Fe(y.rows);const m=(Z.find(p=>p.key===t.membership.value)||{}).label;k={rows:y.rows,warnings:y.warnings||[],params:{Analysis:"Zonal statistics","Zone layer":K(t.zoneLayer),"Features summarised":K(t.inputLayer),"Applied to":Te(),"Zones analysed":l.length,"Features analysed":i.length,"Counted when":m||t.membership.value,Statistics:u.map(p=>(R.find(L=>L.key===p)||{}).label||p).join(", "),"Numeric field":f||"(none)","Result layer":n}}}if(!o.features.length){t.result.innerHTML='
No result features — the layers may not overlap.
';return}const a=Oe();v.addGeoJSONLayer(o,n,{strokeColor:"#d97706",strokeWidth:2,fillColor:"rgba(217,119,6,0.18)"},a),ie();const c=r.length?'
'+r.map(l=>`
${g(l)}
`).join("")+"
":"";t.result.innerHTML=`
Added layer “${g(n)}”.
`+c+(typeof s=="string"&&s.startsWith("<")?s:`
${g(s)}
`),F(`Analysis complete — “${n}” added to the Analysis group.`,"success",4e3)}catch(e){console.error("[Analysis] run failed:",e),t.result.innerHTML=`
${g(e.message||String(e))}
`}finally{t.btnRun.disabled=!1}},20)}function K(e){const n=e.options[e.selectedIndex];return n?n.textContent:"(none)"}function Te(){const e=t.scope.options[t.scope.selectedIndex];return e?e.textContent:"Whole layer"}async function je(){if(k){t.btnExport.disabled=!0;try{const{exportXlsx:e}=await de(async()=>{const{exportXlsx:i}=await import("./xlsx-d1LrY7GM.js");return{exportXlsx:i}},[]),{rows:n,params:o,warnings:s}=k,r=Object.keys(n[0]||{}).filter(i=>i!=="zone"),a=[{cells:["Zone",...r],bold:!0},...n.map(i=>["zone"in i?i.zone:"",...r.map(u=>i[u]===null||i[u]===void 0?"":i[u])])],c=[{cells:["LUPMIS2 — Zonal statistics"],bold:!0},[],{cells:["Parameter","Value"],bold:!0},...Object.entries(o).map(([i,u])=>[i,u]),["Exported",new Date().toLocaleString()],...s&&s.length?[[],{cells:["Notes"],bold:!0},...s.map(i=>["",i])]:[]],l=(o["Result layer"]||"zonal_stats").toString().trim().replace(/[^A-Za-z0-9_\-]+/g,"_")||"zonal_stats";e([{name:"Parameters",rows:c},{name:"Results",rows:a}],`${l}.xlsx`),F("Table exported to Excel.","success",3e3)}catch(e){console.error("[Analysis] table export failed:",e),F(`Could not export the table: ${e.message}`,"error",6e3)}finally{t.btnExport.disabled=!1}}}function ie(){t.btnExport&&t.btnExport.classList.toggle("d-none",!k)}function Fe(e){if(!e||!e.length)return"";const n=Object.keys(e[0]).filter(r=>r!=="zone"),o=["Zone",...n.map(r=>r.replace(/^zs_/,""))].map(r=>`${g(r)}`).join(""),s=e.map(r=>`${g(String(r.zone))}`+n.map(a=>`${r[a]===null||r[a]===void 0?"—":g(String(r[a]))}`).join("")+"").join("");return`
- - ${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-CXnHlVxO.js.map diff --git a/dist/assets/analysis-modal-D9UrhqgN.js b/dist/assets/analysis-modal-D9UrhqgN.js new file mode 100644 index 0000000..c5efd01 --- /dev/null +++ b/dist/assets/analysis-modal-D9UrhqgN.js @@ -0,0 +1,11 @@ +import{_ as de}from"./vite-helpers-BlTxHScW.js";import{M as pe}from"./bootstrap-D1-uvFxm.js";import{ax as ye}from"./openlayers-D5-VSKzq.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-D79Zl-eh.js";import"./ol-ext-DV70ljNY.js";import"./jszip-Dgg-11DP.js";import"./pako-Xa-UToif.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=>` +
+ + +
`).join("")}function ne(){const e=M[Number(t.inputLayer.value)],n=e?e.layer.getSource().getFeatures():[],o=$e(n);t.field.innerHTML=''+o.map(s=>``).join(""),t.field.disabled=o.length===0}function V(){return(t.mode.find(e=>e.checked)||{}).value||"overlay"}function oe(){k=null,ie();const e=V()==="overlay";t.overlayBox.classList.toggle("d-none",!e),t.zonalBox.classList.toggle("d-none",e),ae()}function re(){const e=G.find(o=>o.key===t.op.value);t.opHint.textContent=e?e.hint:"";const n=e?e.needsB:!0;t.layerBWrap.querySelector("label").textContent=n?"Layer B":"Layer B (optional)",ae()}function se(){const e=Z.find(n=>n.key===t.membership.value);t.memberHint.textContent=e?e.hint:""}function ae(){if(t.outName.dataset.touched!=="1")if(V()==="overlay"){const e=t.op.value||"overlay";t.outName.value=`${e}_result`}else t.outName.value="zonal_stats"}function T(e){const n=M[Number(e.value)];if(!n)return[];const o=n.layer.getSource(),s=t.scope?t.scope.value:"all";if(s==="view"){const r=v.getMap(),a=r.getView().calculateExtent(r.getSize());return o.getFeaturesInExtent(a)}if(s==="selection"){const r=new Set(v.getSelectedFeatures());return o.getFeatures().filter(a=>r.has(a))}return s==="drawn"&&x?o.getFeatures().filter(r=>x.features.has(r)):o.getFeatures()}function le(){const e=t.scope.value;if(e==="drawn"&&x)t.scopeHint.textContent=`${x.features.size.toLocaleString()} feature(s) caught by the ${x.label}.`;else if(e==="selection"){const n=v?v.getSelectedFeatures().length:0;t.scopeHint.textContent=n===0?"Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.":`${n.toLocaleString()} feature(s) currently selected.`}else e==="view"?t.scopeHint.textContent="Only features inside the current map view are analysed — zoom to the area of interest first.":t.scopeHint.textContent="Every feature in the chosen layer. Narrow this if a large layer is slow."}function Oe(){let e=v.getLayerGroupByTitle(H);return e||(v.addLayerGroup(Ne,H,"Results of client-side spatial analysis"),e=v.getLayerGroupByTitle(H)),e}function Ce(){t.result.innerHTML="",t.btnRun.disabled=!0,setTimeout(()=>{try{const e=V(),n=(t.outName.value||"").trim()||(e==="overlay"?"overlay_result":"zonal_stats");let o,s,r=[];if(e==="overlay"){k=null;const l=t.op.value,i=T(t.layerA),u=T(t.layerB),f=(G.find(d=>d.key===l)||{}).needsB;if(i.length===0)throw new Error("Layer A has no features.");if(f&&u.length===0)throw new Error("This operation needs a second layer with features.");o=Se(l,i,u),s=`${o.features.length.toLocaleString()} feature(s) produced.`}else{const l=T(t.zoneLayer),i=T(t.inputLayer),u=Array.from(t.statsBox.querySelectorAll("input:checked")).map(p=>p.value),f=t.field.value||null;if(u.length===0)throw new Error("Select at least one statistic.");if(u.some(p=>(R.find(L=>L.key===p)||{}).needsField)&&!f)throw new Error("Sum / mean / min / max need a numeric field.");const y=ze({zoneFeatures:l,inputFeatures:i,stats:u,field:f,membership:t.membership.value});o=y.geojson,r=y.warnings||[],s=Fe(y.rows);const m=(Z.find(p=>p.key===t.membership.value)||{}).label;k={rows:y.rows,warnings:y.warnings||[],params:{Analysis:"Zonal statistics","Zone layer":K(t.zoneLayer),"Features summarised":K(t.inputLayer),"Applied to":Te(),"Zones analysed":l.length,"Features analysed":i.length,"Counted when":m||t.membership.value,Statistics:u.map(p=>(R.find(L=>L.key===p)||{}).label||p).join(", "),"Numeric field":f||"(none)","Result layer":n}}}if(!o.features.length){t.result.innerHTML='
No result features — the layers may not overlap.
';return}const a=Oe();v.addGeoJSONLayer(o,n,{strokeColor:"#d97706",strokeWidth:2,fillColor:"rgba(217,119,6,0.18)"},a),ie();const c=r.length?'
'+r.map(l=>`
${g(l)}
`).join("")+"
":"";t.result.innerHTML=`
Added layer “${g(n)}”.
`+c+(typeof s=="string"&&s.startsWith("<")?s:`
${g(s)}
`),F(`Analysis complete — “${n}” added to the Analysis group.`,"success",4e3)}catch(e){console.error("[Analysis] run failed:",e),t.result.innerHTML=`
${g(e.message||String(e))}
`}finally{t.btnRun.disabled=!1}},20)}function K(e){const n=e.options[e.selectedIndex];return n?n.textContent:"(none)"}function Te(){const e=t.scope.options[t.scope.selectedIndex];return e?e.textContent:"Whole layer"}async function je(){if(k){t.btnExport.disabled=!0;try{const{exportXlsx:e}=await de(async()=>{const{exportXlsx:i}=await import("./xlsx-d1LrY7GM.js");return{exportXlsx:i}},[]),{rows:n,params:o,warnings:s}=k,r=Object.keys(n[0]||{}).filter(i=>i!=="zone"),a=[{cells:["Zone",...r],bold:!0},...n.map(i=>["zone"in i?i.zone:"",...r.map(u=>i[u]===null||i[u]===void 0?"":i[u])])],c=[{cells:["LUPMIS2 — Zonal statistics"],bold:!0},[],{cells:["Parameter","Value"],bold:!0},...Object.entries(o).map(([i,u])=>[i,u]),["Exported",new Date().toLocaleString()],...s&&s.length?[[],{cells:["Notes"],bold:!0},...s.map(i=>["",i])]:[]],l=(o["Result layer"]||"zonal_stats").toString().trim().replace(/[^A-Za-z0-9_\-]+/g,"_")||"zonal_stats";e([{name:"Parameters",rows:c},{name:"Results",rows:a}],`${l}.xlsx`),F("Table exported to Excel.","success",3e3)}catch(e){console.error("[Analysis] table export failed:",e),F(`Could not export the table: ${e.message}`,"error",6e3)}finally{t.btnExport.disabled=!1}}}function ie(){t.btnExport&&t.btnExport.classList.toggle("d-none",!k)}function Fe(e){if(!e||!e.length)return"";const n=Object.keys(e[0]).filter(r=>r!=="zone"),o=["Zone",...n.map(r=>r.replace(/^zs_/,""))].map(r=>`${g(r)}`).join(""),s=e.map(r=>`${g(String(r.zone))}`+n.map(a=>`${r[a]===null||r[a]===void 0?"—":g(String(r[a]))}`).join("")+"").join("");return`
+ + ${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-D9UrhqgN.js.map diff --git a/dist/assets/analysis-modal-CXnHlVxO.js.map b/dist/assets/analysis-modal-D9UrhqgN.js.map similarity index 99% rename from dist/assets/analysis-modal-CXnHlVxO.js.map rename to dist/assets/analysis-modal-D9UrhqgN.js.map index 6dcd36b..53d3b64 100644 --- a/dist/assets/analysis-modal-CXnHlVxO.js.map +++ b/dist/assets/analysis-modal-D9UrhqgN.js.map @@ -1 +1 @@ -{"version":3,"mappings":"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","names":["MAX_OVERLAY_CANDIDATES","fmt","GeoJSON","toGeoJSONFeatures","features","mapProjection","out","f","gj","bboxOf","feature","geom","minX","minY","maxX","maxY","walk","c","sub","bboxOverlaps","a","b","bboxContainsPoint","box","x","y","assertPolygons","polygons","original","role","types","found","polygonsOnly","gjFeatures","t","mergeProps","propsA","propsB","prefixB","k","v","fc","intersectFeatures","aFeatures","bFeatures","opts","keepBAttributes","gjA","gjB","A","B","bBoxes","candidates","aBox","i","candidateOverflow","piece","intersect","featureCollection","clipFeatures","clipFeatures_","differenceFeatures","remainder","next","difference","unionFeatures","polyA","polyB","all","merged","union","count","OVERLAY_OPS","runOverlay","op","MAX_ZONAL_CANDIDATES","MEMBERSHIP_MODES","STATS","listNumericFields","counts","props","num","value","zonalStatistics","zoneFeatures","inputFeatures","stats","field","membership","prefix","zones","z","inputs","warnings","inputTypes","inputsHavePolygons","inputsAreLines","centroids","centroid","inputBoxes","zoneBoxes","rows","zone","zBox","members","inside","cx","cy","booleanPointInPolygon","booleanIntersects","values","result","s","round","total","m","area","labelOf","n","p","ANALYSIS_GROUP_ID","ANALYSIS_GROUP_TITLE","els","modal","mapView","layerChoices","drawnScope","lastZonal","cacheEls","r","onModeChange","onOpChange","onMembershipChange","refreshFieldOptions","onScopeChange","onRun","onExportTable","layerOptionsHtml","selectedIdx","escapeHtml","populateOps","o","choice","feats","fields","currentMode","syncExportButton","overlay","suggestName","needsB","featuresOf","select","source","scope","map","extent","selected","ensureAnalysisGroup","group","mode","name","geojson","summary","zonalWarnings","res","renderRows","membershipLabel","labelOfSelect","scopeLabel","warnHtml","w","showToast","err","opt","exportXlsx","__vitePreload","params","keys","resultRows","paramRows","base","head","h","body","openAnalysisModal","view","syncDrawnScopeOption","optsA","optsB","Modal","existing","label"],"ignoreList":[],"sources":["../../src/analysis/overlay.js","../../src/analysis/zonal.js","../../src/analysis-modal.js"],"sourcesContent":["/**\n * overlay.js — client-side vector overlay operations (Stage 1 of the\n * LUPMIS2 GIS Analytical Tools concept).\n *\n * Pure geometry helpers built on Turf.js. No DOM, no map — the caller hands\n * in OpenLayers features and gets a GeoJSON FeatureCollection back, ready for\n * MapView.addGeoJSONLayer().\n *\n * All Turf work happens in EPSG:4326 (Turf's area/length functions assume\n * WGS84), so features are reprojected out of the map's EPSG:3857 on the way\n * in and the result is handed back as WGS84 GeoJSON — the same convention\n * gis-export.js uses.\n *\n * intersectFeatures(a, b) → parts of A that fall inside B (attrs: A + B)\n * clipFeatures(a, b) → parts of A that fall inside B (attrs: A only)\n * differenceFeatures(a, b) → parts of A that fall OUTSIDE B (attrs: A)\n * unionFeatures(a[, b]) → dissolved outline of A (and B)\n */\n\nimport GeoJSON from 'ol/format/GeoJSON';\nimport intersect from '@turf/intersect';\nimport union from '@turf/union';\nimport difference from '@turf/difference';\nimport { featureCollection } from '@turf/helpers';\n\n/**\n * Guard: only *candidate* pairs (those whose bounding boxes actually overlap)\n * reach the expensive geometry test, so this ceiling is about real work, not\n * the raw n×m product. District-scale data (tens of thousands of parcels\n * against a few dozen zones) sits far below it.\n */\nexport const MAX_OVERLAY_CANDIDATES = 400000;\n\nconst fmt = new GeoJSON();\n\n/**\n * Convert OpenLayers features (map projection) to WGS84 GeoJSON features.\n * @param {Array} features\n * @param {string} [mapProjection='EPSG:3857']\n * @returns {Array} GeoJSON Features\n */\nexport function toGeoJSONFeatures(features, mapProjection = 'EPSG:3857') {\n const out = [];\n for (const f of features || []) {\n const geom = f.getGeometry && f.getGeometry();\n if (!geom) continue;\n const gj = fmt.writeFeatureObject(f, {\n dataProjection: 'EPSG:4326',\n featureProjection: mapProjection,\n });\n if (gj && gj.geometry) out.push(gj);\n }\n return out;\n}\n\n/**\n * Bounding box of a GeoJSON feature as [minX, minY, maxX, maxY].\n * Walks the coordinate arrays directly — no extra dependency, and fast enough\n * to run over tens of thousands of features.\n * @param {Object} feature GeoJSON Feature\n * @returns {number[]|null}\n */\nexport function bboxOf(feature) {\n const geom = feature && feature.geometry;\n if (!geom || !geom.coordinates) return null;\n let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n const walk = (c) => {\n if (typeof c[0] === 'number') {\n if (c[0] < minX) minX = c[0];\n if (c[0] > maxX) maxX = c[0];\n if (c[1] < minY) minY = c[1];\n if (c[1] > maxY) maxY = c[1];\n return;\n }\n for (const sub of c) walk(sub);\n };\n walk(geom.coordinates);\n return Number.isFinite(minX) ? [minX, minY, maxX, maxY] : null;\n}\n\n/** Do two bounding boxes overlap? Cheap pre-filter before real geometry work. */\nexport function bboxOverlaps(a, b) {\n if (!a || !b) return false;\n return !(a[2] < b[0] || b[2] < a[0] || a[3] < b[1] || b[3] < a[1]);\n}\n\n/** Is a point [x, y] inside a bounding box? */\nexport function bboxContainsPoint(box, x, y) {\n if (!box) return false;\n return x >= box[0] && x <= box[2] && y >= box[1] && y <= box[3];\n}\n\n/**\n * Reject a non-polygon input with a message that says what is actually wrong.\n *\n * Overlay operations are polygon-only, but the inputs are silently filtered by\n * polygonsOnly() — so a line layer (contours, roads) used to produce an empty\n * result and the misleading \"the layers may not overlap\". Failing loudly here\n * tells the user the real reason.\n *\n * @param {Array} polygons the surviving polygonal features\n * @param {Array} original everything that was passed in\n * @param {string} role 'Layer A' / 'Layer B'\n */\nfunction assertPolygons(polygons, original, role) {\n if (polygons.length > 0 || original.length === 0) return;\n const types = [...new Set(original.map((f) => f?.geometry?.type).filter(Boolean))];\n const found = types.length ? types.join(', ') : 'no geometry';\n throw new Error(\n `${role} contains no polygons (found ${found}). Overlay operations work on ` +\n `polygon layers only — line layers such as contours or roads cannot be used here. ` +\n `To summarise a line layer per zone, use Zonal statistics instead.`\n );\n}\n\n/** Keep only polygonal features — overlay is undefined for points/lines here. */\nfunction polygonsOnly(gjFeatures) {\n return (gjFeatures || []).filter((f) => {\n const t = f?.geometry?.type;\n return t === 'Polygon' || t === 'MultiPolygon';\n });\n}\n\n/** Merge two property bags, prefixing B's keys on collision so nothing is lost. */\nfunction mergeProps(propsA, propsB, prefixB = 'b_') {\n const out = { ...(propsA || {}) };\n for (const [k, v] of Object.entries(propsB || {})) {\n out[Object.prototype.hasOwnProperty.call(out, k) ? `${prefixB}${k}` : k] = v;\n }\n return out;\n}\n\nfunction fc(features) {\n return { type: 'FeatureCollection', features };\n}\n\n/**\n * Intersection: every piece of A that also lies inside B.\n * Result carries A's attributes merged with the matching B feature's.\n *\n * @param {Array} aFeatures OL features (layer A)\n * @param {Array} bFeatures OL features (layer B)\n * @param {Object} [opts]\n * @param {boolean} [opts.keepBAttributes=true] merge B's properties into the result\n * @returns {Object} GeoJSON FeatureCollection (WGS84)\n */\nexport function intersectFeatures(aFeatures, bFeatures, opts = {}) {\n const { keepBAttributes = true } = opts;\n const gjA = toGeoJSONFeatures(aFeatures);\n const gjB = toGeoJSONFeatures(bFeatures);\n const A = polygonsOnly(gjA);\n const B = polygonsOnly(gjB);\n assertPolygons(A, gjA, 'Layer A');\n assertPolygons(B, gjB, 'Layer B');\n\n // Pre-compute B's boxes once, then only test pairs that can possibly meet.\n const bBoxes = B.map(bboxOf);\n let candidates = 0;\n const out = [];\n for (const a of A) {\n const aBox = bboxOf(a);\n for (let i = 0; i < B.length; i++) {\n if (!bboxOverlaps(aBox, bBoxes[i])) continue;\n if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);\n let piece = null;\n try { piece = intersect(featureCollection([a, B[i]])); } catch { piece = null; }\n if (!piece || !piece.geometry) continue;\n piece.properties = keepBAttributes\n ? mergeProps(a.properties, B[i].properties)\n : { ...(a.properties || {}) };\n out.push(piece);\n }\n }\n return fc(out);\n}\n\n/**\n * Clip: same geometry result as intersect, but the output keeps ONLY layer A's\n * attributes — the usual \"cut this layer to that boundary\" operation.\n */\nexport function clipFeatures(aFeatures, clipFeatures_, opts = {}) {\n return intersectFeatures(aFeatures, clipFeatures_, { ...opts, keepBAttributes: false });\n}\n\n/**\n * Difference: the parts of A that do NOT fall inside any feature of B.\n * Each A feature is reduced by every overlapping B feature in turn; features\n * fully covered by B drop out of the result.\n *\n * @returns {Object} GeoJSON FeatureCollection (WGS84)\n */\nexport function differenceFeatures(aFeatures, bFeatures) {\n const gjA = toGeoJSONFeatures(aFeatures);\n const gjB = toGeoJSONFeatures(bFeatures);\n const A = polygonsOnly(gjA);\n const B = polygonsOnly(gjB);\n assertPolygons(A, gjA, 'Layer A');\n assertPolygons(B, gjB, 'Layer B');\n\n const bBoxes = B.map(bboxOf);\n let candidates = 0;\n const out = [];\n for (const a of A) {\n const aBox = bboxOf(a);\n let remainder = a;\n for (let i = 0; i < B.length; i++) {\n if (!remainder) break;\n // Only subtract a B feature that can actually touch what's left.\n if (!bboxOverlaps(aBox, bBoxes[i])) continue;\n if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);\n try {\n const next = difference(featureCollection([remainder, B[i]]));\n remainder = next && next.geometry ? next : null;\n } catch {\n // A malformed pair shouldn't kill the whole run — keep what we have.\n }\n }\n if (remainder && remainder.geometry) {\n remainder.properties = { ...(a.properties || {}) };\n out.push(remainder);\n }\n }\n return fc(out);\n}\n\n/**\n * Union / dissolve: merge everything into a single outline. Pass only `a` to\n * dissolve one layer, or both to merge two layers together.\n *\n * @returns {Object} GeoJSON FeatureCollection with 0 or 1 feature (WGS84)\n */\nexport function unionFeatures(aFeatures, bFeatures = []) {\n // Check each layer separately rather than the combined set: if only the\n // second layer had polygons, a combined check would pass and the first\n // layer's features would be dropped without the user being told.\n const gjA = toGeoJSONFeatures(aFeatures);\n const gjB = toGeoJSONFeatures(bFeatures);\n const polyA = polygonsOnly(gjA);\n const polyB = polygonsOnly(gjB);\n assertPolygons(polyA, gjA, 'Layer A');\n assertPolygons(polyB, gjB, 'Layer B'); // no-op when no second layer is given\n const all = [...polyA, ...polyB];\n if (all.length === 0) return fc([]);\n if (all.length === 1) return fc([all[0]]);\n\n let merged = null;\n try {\n merged = union(featureCollection(all));\n } catch {\n // Fall back to a pairwise fold if the bulk union chokes on bad geometry.\n merged = all[0];\n for (let i = 1; i < all.length; i++) {\n try {\n const next = union(featureCollection([merged, all[i]]));\n if (next && next.geometry) merged = next;\n } catch { /* skip the offending feature */ }\n }\n }\n if (!merged || !merged.geometry) return fc([]);\n merged.properties = { parts: all.length };\n return fc([merged]);\n}\n\n/** @private Raised only when genuinely overlapping work exceeds the budget. */\nfunction candidateOverflow(count) {\n return new Error(\n `This overlay has more than ${count.toLocaleString()} genuinely overlapping feature pairs, ` +\n `which is too much to run in the browser. Narrow the inputs using “Apply to” ` +\n `(current map view or selected features), or run it server-side.`\n );\n}\n\n/** Operation registry used by the Analysis panel. */\nexport const OVERLAY_OPS = [\n { key: 'intersect', label: 'Intersect', needsB: true,\n hint: 'Keep the parts of layer A that fall inside layer B (attributes from both).' },\n { key: 'clip', label: 'Clip', needsB: true,\n hint: 'Cut layer A to the boundary of layer B (keeps only A’s attributes).' },\n { key: 'difference', label: 'Difference', needsB: true,\n hint: 'Keep the parts of layer A that fall outside layer B.' },\n { key: 'union', label: 'Union / Dissolve', needsB: false,\n hint: 'Merge features into a single outline (optionally with a second layer).' },\n];\n\n/**\n * Run an overlay operation by key.\n * @param {string} op one of OVERLAY_OPS keys\n * @param {Array} aFeatures\n * @param {Array} bFeatures\n * @returns {Object} GeoJSON FeatureCollection (WGS84)\n */\nexport function runOverlay(op, aFeatures, bFeatures) {\n switch (op) {\n case 'intersect': return intersectFeatures(aFeatures, bFeatures);\n case 'clip': return clipFeatures(aFeatures, bFeatures);\n case 'difference': return differenceFeatures(aFeatures, bFeatures);\n case 'union': return unionFeatures(aFeatures, bFeatures);\n default: throw new Error(`Unknown overlay operation: ${op}`);\n }\n}\n","/**\n * zonal.js — vector-in-vector zonal statistics (Stage 1 of the LUPMIS2 GIS\n * Analytical Tools concept).\n *\n * Answers questions like \"how many parcels are in each collector zone?\" or\n * \"what is the mean plot size per zone?\" — the classic *summarize within*\n * operation. Raster × vector zonal statistics arrives with the raster pipeline\n * in Stage 2; this module is pure vector and runs fully offline.\n *\n * Membership rules:\n * 'centroid' — a feature belongs to the zone containing its centroid\n * (default; each feature counts exactly once)\n * 'intersects' — a feature belongs to every zone it touches\n * (features straddling a boundary are counted more than once)\n */\n\nimport booleanPointInPolygon from '@turf/boolean-point-in-polygon';\nimport booleanIntersects from '@turf/boolean-intersects';\nimport centroid from '@turf/centroid';\nimport area from '@turf/area';\nimport { toGeoJSONFeatures, bboxOf, bboxOverlaps, bboxContainsPoint } from './overlay.js';\n\n/**\n * Guard on *candidate* pairs only — a bounding-box pre-filter means a feature\n * is geometry-tested against a zone only when it could plausibly fall in it.\n * A district of 25,000 parcels across 40-odd zones produces roughly one\n * candidate per parcel, so real workloads sit far below this ceiling.\n */\nexport const MAX_ZONAL_CANDIDATES = 400000;\n\nexport const MEMBERSHIP_MODES = [\n { key: 'centroid', label: 'Centroid inside zone',\n hint: 'Each feature is counted once, in the zone containing its centre.' },\n { key: 'intersects', label: 'Any overlap with zone',\n hint: 'A feature is counted in every zone it touches (may double-count).' },\n];\n\n/** Statistics offered by the Analysis panel. */\nexport const STATS = [\n { key: 'count', label: 'Count', needsField: false },\n { key: 'sum', label: 'Sum', needsField: true },\n { key: 'mean', label: 'Mean', needsField: true },\n { key: 'min', label: 'Minimum', needsField: true },\n { key: 'max', label: 'Maximum', needsField: true },\n { key: 'area', label: 'Total area (m²)', needsField: false },\n];\n\n/**\n * List the numeric attribute names available across a set of OL features, so\n * the panel can offer only fields that can actually be summed or averaged.\n * @param {Array} features OL features\n * @returns {string[]}\n */\nexport function listNumericFields(features) {\n const counts = new Map();\n for (const f of features || []) {\n const props = f.getProperties ? f.getProperties() : {};\n for (const [k, v] of Object.entries(props)) {\n if (k === 'geometry' || k.startsWith('_')) continue;\n const n = typeof v === 'number' ? v : (v !== '' && v != null && !Number.isNaN(Number(v)) ? Number(v) : null);\n if (n === null) continue;\n counts.set(k, (counts.get(k) || 0) + 1);\n }\n }\n return Array.from(counts.keys()).sort();\n}\n\n/** Coerce a property to a finite number, or null when it isn't numeric. */\nfunction num(value) {\n if (value === null || value === undefined || value === '') return null;\n const n = typeof value === 'number' ? value : Number(value);\n return Number.isFinite(n) ? n : null;\n}\n\n/**\n * Compute zonal statistics.\n *\n * @param {Object} params\n * @param {Array} params.zoneFeatures OL features defining the zones (polygons)\n * @param {Array} params.inputFeatures OL features to summarise\n * @param {string[]} params.stats keys from STATS\n * @param {string} [params.field] numeric attribute (for sum/mean/min/max)\n * @param {string} [params.membership] 'centroid' | 'intersects'\n * @param {string} [params.prefix='zs_'] prefix for the new attribute names\n * @returns {{ geojson: Object, rows: Array }}\n * geojson — the zones with statistics attached (WGS84), ready to add as a layer\n * rows — a plain table of the same numbers, for display/export\n * warnings — figures that are valid but easy to misread (e.g. area over a\n * line layer); shown to the user rather than thrown\n */\nexport function zonalStatistics({\n zoneFeatures,\n inputFeatures,\n stats = ['count'],\n field = null,\n membership = 'centroid',\n prefix = 'zs_',\n}) {\n const zones = (toGeoJSONFeatures(zoneFeatures) || []).filter((z) => {\n const t = z?.geometry?.type;\n return t === 'Polygon' || t === 'MultiPolygon';\n });\n const inputs = toGeoJSONFeatures(inputFeatures) || [];\n\n if (zones.length === 0) throw new Error('The zone layer contains no polygons.');\n\n // Warnings are returned alongside the result rather than thrown: the run is\n // still valid and useful, but some figures would be silently meaningless\n // without an explanation.\n const warnings = [];\n const inputTypes = new Set(inputs.map((f) => f?.geometry?.type).filter(Boolean));\n const inputsHavePolygons = inputTypes.has('Polygon') || inputTypes.has('MultiPolygon');\n\n // Lines and points have no area, so \"Total area\" would report a silent 0.\n if (stats.includes('area') && !inputsHavePolygons && inputs.length > 0) {\n warnings.push(\n `Total area is 0 because the features being summarised are ` +\n `${[...inputTypes].join('/')} — only polygons have an area.`\n );\n }\n\n // Line inputs only. Averaging a field over line segments is not an\n // area-weighted average of the surface those lines describe — the classic\n // trap being \"mean elevation per parcel\" from contours, where segment count\n // has no relation to the area at each elevation. (Averaging over points is a\n // normal statistic, so it is not flagged.)\n const inputsAreLines = inputTypes.has('LineString') || inputTypes.has('MultiLineString');\n if (stats.includes('mean') && inputsAreLines && !inputsHavePolygons && field) {\n warnings.push(\n `Mean is the average of “${field}” across the individual line features in ` +\n `each zone, not an area-weighted average of the surface they describe. ` +\n `For contour data, Minimum and Maximum (the elevation range) are the ` +\n `figures you can rely on.`\n );\n }\n\n // Pre-compute once: each input's centroid (centroid mode) or bbox\n // (intersects mode), and every zone's bbox. The bbox comparisons below are\n // cheap arithmetic; only survivors reach the real geometry test.\n const centroids = membership === 'centroid'\n ? inputs.map((f) => { try { return centroid(f); } catch { return null; } })\n : null;\n const inputBoxes = membership === 'centroid' ? null : inputs.map(bboxOf);\n const zoneBoxes = zones.map(bboxOf);\n\n let candidates = 0;\n const rows = [];\n for (let z = 0; z < zones.length; z++) {\n const zone = zones[z];\n const zBox = zoneBoxes[z];\n const members = [];\n for (let i = 0; i < inputs.length; i++) {\n let inside = false;\n try {\n if (membership === 'centroid') {\n const c = centroids[i];\n if (!c) continue;\n const [cx, cy] = c.geometry.coordinates;\n if (!bboxContainsPoint(zBox, cx, cy)) continue; // cheap reject\n if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);\n inside = booleanPointInPolygon(c, zone);\n } else {\n if (!bboxOverlaps(zBox, inputBoxes[i])) continue; // cheap reject\n if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);\n inside = booleanIntersects(inputs[i], zone);\n }\n } catch { inside = false; }\n if (inside) members.push(inputs[i]);\n }\n\n const values = field\n ? members.map((f) => num(f.properties ? f.properties[field] : null)).filter((v) => v !== null)\n : [];\n\n const result = {};\n for (const s of stats) {\n switch (s) {\n case 'count':\n result[`${prefix}count`] = members.length; break;\n case 'sum':\n result[`${prefix}sum`] = values.length ? round(values.reduce((a, b) => a + b, 0)) : 0; break;\n case 'mean':\n result[`${prefix}mean`] = values.length\n ? round(values.reduce((a, b) => a + b, 0) / values.length) : null; break;\n case 'min':\n result[`${prefix}min`] = values.length ? round(Math.min(...values)) : null; break;\n case 'max':\n result[`${prefix}max`] = values.length ? round(Math.max(...values)) : null; break;\n case 'area': {\n let total = 0;\n for (const m of members) { try { total += area(m); } catch { /* skip */ } }\n result[`${prefix}area_m2`] = Math.round(total); break;\n }\n default: break;\n }\n }\n\n zone.properties = { ...(zone.properties || {}), ...result };\n rows.push({ ...labelOf(zone), ...result });\n }\n\n return { geojson: { type: 'FeatureCollection', features: zones }, rows, warnings };\n}\n\n/** @private Raised only when genuinely plausible pairs exceed the budget. */\nfunction candidateOverflow(count) {\n return new Error(\n `More than ${count.toLocaleString()} features fall within the zones' bounding boxes, ` +\n `which is too much to summarise in the browser. Narrow the inputs using “Apply to” ` +\n `(current map view or selected features).`\n );\n}\n\n/** Round to 3 decimals to keep attribute tables readable. */\nfunction round(n) {\n return Math.round(n * 1000) / 1000;\n}\n\n/**\n * Pick a human label for a zone row — first of the usual naming fields,\n * else a generic index.\n */\nfunction labelOf(zone) {\n const p = zone.properties || {};\n for (const k of ['zone_name', 'name', 'ZONE_NAME', 'NAME', 'upn', 'UPN', 'id', 'ID']) {\n if (p[k] !== undefined && p[k] !== null && String(p[k]).trim() !== '') {\n return { zone: String(p[k]) };\n }\n }\n return { zone: '(unnamed)' };\n}\n","/**\n * Analysis panel controller — Stage 1 of the GIS Analytical Tools concept.\n *\n * openAnalysisModal(mapView)\n *\n * Drives two families of client-side analysis:\n * • Overlays — intersect / clip / difference / union between layers\n * • Zonal statistics — summarise features (or a numeric field) per zone\n *\n * Both run entirely in the browser via Turf.js, so they work offline. Results\n * are added to the map as a new styled layer in an \"Analysis\" group, from\n * where they can be exported with the existing GIS export tools.\n *\n * Same Bootstrap modal pattern as src/export-gis-modal.js and src/import-modal.js.\n */\n\nimport { Modal } from 'bootstrap';\nimport { runOverlay, OVERLAY_OPS } from './analysis/overlay.js';\nimport { zonalStatistics, listNumericFields, STATS, MEMBERSHIP_MODES } from './analysis/zonal.js';\nimport { showToast } from './toast.js';\n\nconst ANALYSIS_GROUP_ID = 9001;\nconst ANALYSIS_GROUP_TITLE = 'Analysis';\n\nconst els = {};\nlet modal = null;\nlet mapView = null;\nlet layerChoices = []; // [{ layer, title, count }]\nlet drawnScope = null; // { features: Set, label } when opened from a Circle/Area popup\nlet lastZonal = null; // { rows, params } — the most recent zonal run, for export\n\n// ---------------------------------------------------------------------------\n// Element wiring\n// ---------------------------------------------------------------------------\n\nfunction cacheEls() {\n if (els.root) return;\n els.root = document.getElementById('analysisModal');\n els.mode = Array.from(document.querySelectorAll('input[name=\"analysis-mode\"]'));\n els.overlayBox = document.getElementById('analysis-overlay-box');\n els.zonalBox = document.getElementById('analysis-zonal-box');\n els.op = document.getElementById('analysis-op');\n els.opHint = document.getElementById('analysis-op-hint');\n els.layerA = document.getElementById('analysis-layer-a');\n els.layerB = document.getElementById('analysis-layer-b');\n els.layerBWrap = document.getElementById('analysis-layer-b-wrap');\n els.zoneLayer = document.getElementById('analysis-zone-layer');\n els.inputLayer = document.getElementById('analysis-input-layer');\n els.membership = document.getElementById('analysis-membership');\n els.memberHint = document.getElementById('analysis-membership-hint');\n els.field = document.getElementById('analysis-field');\n els.statsBox = document.getElementById('analysis-stats-box');\n els.outName = document.getElementById('analysis-output-name');\n els.result = document.getElementById('analysis-result');\n els.btnRun = document.getElementById('analysis-run');\n els.btnExport = document.getElementById('analysis-export-table');\n els.scope = document.getElementById('analysis-scope');\n els.scopeHint = document.getElementById('analysis-scope-hint');\n\n if (!els.root.dataset.wired) {\n els.root.dataset.wired = '1';\n els.mode.forEach((r) => r.addEventListener('change', onModeChange));\n els.op.addEventListener('change', onOpChange);\n els.membership.addEventListener('change', onMembershipChange);\n els.inputLayer.addEventListener('change', refreshFieldOptions);\n els.scope.addEventListener('change', onScopeChange);\n els.btnRun.addEventListener('click', onRun);\n els.btnExport.addEventListener('click', onExportTable);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Population\n// ---------------------------------------------------------------------------\n\nfunction layerOptionsHtml(selectedIdx = null) {\n return layerChoices.map((c, i) =>\n ``\n ).join('');\n}\n\nfunction populateOps() {\n els.op.innerHTML = OVERLAY_OPS\n .map((o) => ``).join('');\n els.membership.innerHTML = MEMBERSHIP_MODES\n .map((m) => ``).join('');\n els.statsBox.innerHTML = STATS.map((s) => `\n
\n \n \n
`).join('');\n}\n\nfunction refreshFieldOptions() {\n const choice = layerChoices[Number(els.inputLayer.value)];\n const feats = choice ? choice.layer.getSource().getFeatures() : [];\n const fields = listNumericFields(feats);\n els.field.innerHTML = '' +\n fields.map((f) => ``).join('');\n els.field.disabled = fields.length === 0;\n}\n\n// ---------------------------------------------------------------------------\n// UI reactions\n// ---------------------------------------------------------------------------\n\nfunction currentMode() {\n return (els.mode.find((r) => r.checked) || {}).value || 'overlay';\n}\n\nfunction onModeChange() {\n // A previous zonal table doesn't belong to a new overlay run.\n lastZonal = null; syncExportButton();\n const overlay = currentMode() === 'overlay';\n els.overlayBox.classList.toggle('d-none', !overlay);\n els.zonalBox.classList.toggle('d-none', overlay);\n suggestName();\n}\n\nfunction onOpChange() {\n const op = OVERLAY_OPS.find((o) => o.key === els.op.value);\n els.opHint.textContent = op ? op.hint : '';\n // Union can run on a single layer; the others need a second one.\n const needsB = op ? op.needsB : true;\n els.layerBWrap.querySelector('label').textContent =\n needsB ? 'Layer B' : 'Layer B (optional)';\n suggestName();\n}\n\nfunction onMembershipChange() {\n const m = MEMBERSHIP_MODES.find((x) => x.key === els.membership.value);\n els.memberHint.textContent = m ? m.hint : '';\n}\n\nfunction suggestName() {\n if (els.outName.dataset.touched === '1') return;\n if (currentMode() === 'overlay') {\n const op = els.op.value || 'overlay';\n els.outName.value = `${op}_result`;\n } else {\n els.outName.value = 'zonal_stats';\n }\n}\n\n// ---------------------------------------------------------------------------\n// Run\n// ---------------------------------------------------------------------------\n\n/**\n * Features of the chosen layer, narrowed by the \"Apply to\" scope.\n * 'view' uses the source's spatial index, so it stays fast on large layers.\n */\nfunction featuresOf(select) {\n const choice = layerChoices[Number(select.value)];\n if (!choice) return [];\n const source = choice.layer.getSource();\n const scope = els.scope ? els.scope.value : 'all';\n\n if (scope === 'view') {\n const map = mapView.getMap();\n const extent = map.getView().calculateExtent(map.getSize());\n return source.getFeaturesInExtent(extent);\n }\n if (scope === 'selection') {\n const selected = new Set(mapView.getSelectedFeatures());\n return source.getFeatures().filter((f) => selected.has(f));\n }\n if (scope === 'drawn' && drawnScope) {\n return source.getFeatures().filter((f) => drawnScope.features.has(f));\n }\n return source.getFeatures();\n}\n\nfunction onScopeChange() {\n const scope = els.scope.value;\n if (scope === 'drawn' && drawnScope) {\n els.scopeHint.textContent =\n `${drawnScope.features.size.toLocaleString()} feature(s) caught by the ${drawnScope.label}.`;\n } else if (scope === 'selection') {\n const n = mapView ? mapView.getSelectedFeatures().length : 0;\n els.scopeHint.textContent = n === 0\n ? 'Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.'\n : `${n.toLocaleString()} feature(s) currently selected.`;\n } else if (scope === 'view') {\n els.scopeHint.textContent = 'Only features inside the current map view are analysed — zoom to the area of interest first.';\n } else {\n els.scopeHint.textContent = 'Every feature in the chosen layer. Narrow this if a large layer is slow.';\n }\n}\n\nfunction ensureAnalysisGroup() {\n let group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n if (!group) {\n mapView.addLayerGroup(ANALYSIS_GROUP_ID, ANALYSIS_GROUP_TITLE,\n 'Results of client-side spatial analysis');\n group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n }\n return group;\n}\n\nfunction onRun() {\n els.result.innerHTML = '';\n els.btnRun.disabled = true;\n // Yield a frame so the button state paints before a heavy synchronous run.\n setTimeout(() => {\n try {\n const mode = currentMode();\n const name = (els.outName.value || '').trim() ||\n (mode === 'overlay' ? 'overlay_result' : 'zonal_stats');\n\n let geojson, summary;\n let zonalWarnings = [];\n if (mode === 'overlay') {\n lastZonal = null;\n const op = els.op.value;\n const a = featuresOf(els.layerA);\n const b = featuresOf(els.layerB);\n const needsB = (OVERLAY_OPS.find((o) => o.key === op) || {}).needsB;\n if (a.length === 0) throw new Error('Layer A has no features.');\n if (needsB && b.length === 0) throw new Error('This operation needs a second layer with features.');\n geojson = runOverlay(op, a, b);\n summary = `${geojson.features.length.toLocaleString()} feature(s) produced.`;\n } else {\n const zones = featuresOf(els.zoneLayer);\n const inputs = featuresOf(els.inputLayer);\n const stats = Array.from(els.statsBox.querySelectorAll('input:checked')).map((i) => i.value);\n const field = els.field.value || null;\n if (stats.length === 0) throw new Error('Select at least one statistic.');\n const needsField = stats.some((s) => (STATS.find((x) => x.key === s) || {}).needsField);\n if (needsField && !field) throw new Error('Sum / mean / min / max need a numeric field.');\n\n const res = zonalStatistics({\n zoneFeatures: zones, inputFeatures: inputs,\n stats, field, membership: els.membership.value,\n });\n geojson = res.geojson;\n zonalWarnings = res.warnings || [];\n summary = renderRows(res.rows);\n\n // Keep the table AND the settings that produced it, so the Excel export\n // is self-describing — a table of numbers with no record of how it was\n // produced can't be checked or reproduced later.\n const membershipLabel = (MEMBERSHIP_MODES.find((m) => m.key === els.membership.value) || {}).label;\n lastZonal = {\n rows: res.rows,\n warnings: res.warnings || [],\n params: {\n 'Analysis': 'Zonal statistics',\n 'Zone layer': labelOfSelect(els.zoneLayer),\n 'Features summarised': labelOfSelect(els.inputLayer),\n 'Applied to': scopeLabel(),\n 'Zones analysed': zones.length,\n 'Features analysed': inputs.length,\n 'Counted when': membershipLabel || els.membership.value,\n 'Statistics': stats.map((k) => (STATS.find((x) => x.key === k) || {}).label || k).join(', '),\n 'Numeric field': field || '(none)',\n 'Result layer': name,\n },\n };\n }\n\n if (!geojson.features.length) {\n els.result.innerHTML =\n `
No result features — the layers may not overlap.
`;\n return;\n }\n\n const group = ensureAnalysisGroup();\n mapView.addGeoJSONLayer(geojson, name, {\n strokeColor: '#d97706', strokeWidth: 2, fillColor: 'rgba(217,119,6,0.18)',\n }, group);\n\n syncExportButton();\n const warnHtml = zonalWarnings.length\n ? `
` +\n zonalWarnings.map((w) => `
${escapeHtml(w)}
`).join('') + `
`\n : '';\n els.result.innerHTML =\n `
Added layer “${escapeHtml(name)}”.
` +\n warnHtml +\n (typeof summary === 'string' && summary.startsWith('<') ? summary : `
${escapeHtml(summary)}
`);\n showToast(`Analysis complete — “${name}” added to the Analysis group.`, 'success', 4000);\n } catch (err) {\n console.error('[Analysis] run failed:', err);\n els.result.innerHTML =\n `
${escapeHtml(err.message || String(err))}
`;\n } finally {\n els.btnRun.disabled = false;\n }\n }, 20);\n}\n\n/** Human label of a layer \n \n `).join('');\n}\n\nfunction refreshFieldOptions() {\n const choice = layerChoices[Number(els.inputLayer.value)];\n const feats = choice ? choice.layer.getSource().getFeatures() : [];\n const fields = listNumericFields(feats);\n els.field.innerHTML = '' +\n fields.map((f) => ``).join('');\n els.field.disabled = fields.length === 0;\n}\n\n// ---------------------------------------------------------------------------\n// UI reactions\n// ---------------------------------------------------------------------------\n\nfunction currentMode() {\n return (els.mode.find((r) => r.checked) || {}).value || 'overlay';\n}\n\nfunction onModeChange() {\n // A previous zonal table doesn't belong to a new overlay run.\n lastZonal = null; syncExportButton();\n const overlay = currentMode() === 'overlay';\n els.overlayBox.classList.toggle('d-none', !overlay);\n els.zonalBox.classList.toggle('d-none', overlay);\n suggestName();\n}\n\nfunction onOpChange() {\n const op = OVERLAY_OPS.find((o) => o.key === els.op.value);\n els.opHint.textContent = op ? op.hint : '';\n // Union can run on a single layer; the others need a second one.\n const needsB = op ? op.needsB : true;\n els.layerBWrap.querySelector('label').textContent =\n needsB ? 'Layer B' : 'Layer B (optional)';\n suggestName();\n}\n\nfunction onMembershipChange() {\n const m = MEMBERSHIP_MODES.find((x) => x.key === els.membership.value);\n els.memberHint.textContent = m ? m.hint : '';\n}\n\nfunction suggestName() {\n if (els.outName.dataset.touched === '1') return;\n if (currentMode() === 'overlay') {\n const op = els.op.value || 'overlay';\n els.outName.value = `${op}_result`;\n } else {\n els.outName.value = 'zonal_stats';\n }\n}\n\n// ---------------------------------------------------------------------------\n// Run\n// ---------------------------------------------------------------------------\n\n/**\n * Features of the chosen layer, narrowed by the \"Apply to\" scope.\n * 'view' uses the source's spatial index, so it stays fast on large layers.\n */\nfunction featuresOf(select) {\n const choice = layerChoices[Number(select.value)];\n if (!choice) return [];\n const source = choice.layer.getSource();\n const scope = els.scope ? els.scope.value : 'all';\n\n if (scope === 'view') {\n const map = mapView.getMap();\n const extent = map.getView().calculateExtent(map.getSize());\n return source.getFeaturesInExtent(extent);\n }\n if (scope === 'selection') {\n const selected = new Set(mapView.getSelectedFeatures());\n return source.getFeatures().filter((f) => selected.has(f));\n }\n if (scope === 'drawn' && drawnScope) {\n return source.getFeatures().filter((f) => drawnScope.features.has(f));\n }\n return source.getFeatures();\n}\n\nfunction onScopeChange() {\n const scope = els.scope.value;\n if (scope === 'drawn' && drawnScope) {\n els.scopeHint.textContent =\n `${drawnScope.features.size.toLocaleString()} feature(s) caught by the ${drawnScope.label}.`;\n } else if (scope === 'selection') {\n const n = mapView ? mapView.getSelectedFeatures().length : 0;\n els.scopeHint.textContent = n === 0\n ? 'Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.'\n : `${n.toLocaleString()} feature(s) currently selected.`;\n } else if (scope === 'view') {\n els.scopeHint.textContent = 'Only features inside the current map view are analysed — zoom to the area of interest first.';\n } else {\n els.scopeHint.textContent = 'Every feature in the chosen layer. Narrow this if a large layer is slow.';\n }\n}\n\nfunction ensureAnalysisGroup() {\n let group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n if (!group) {\n mapView.addLayerGroup(ANALYSIS_GROUP_ID, ANALYSIS_GROUP_TITLE,\n 'Results of client-side spatial analysis');\n group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);\n }\n return group;\n}\n\nfunction onRun() {\n els.result.innerHTML = '';\n els.btnRun.disabled = true;\n // Yield a frame so the button state paints before a heavy synchronous run.\n setTimeout(() => {\n try {\n const mode = currentMode();\n const name = (els.outName.value || '').trim() ||\n (mode === 'overlay' ? 'overlay_result' : 'zonal_stats');\n\n let geojson, summary;\n let zonalWarnings = [];\n if (mode === 'overlay') {\n lastZonal = null;\n const op = els.op.value;\n const a = featuresOf(els.layerA);\n const b = featuresOf(els.layerB);\n const needsB = (OVERLAY_OPS.find((o) => o.key === op) || {}).needsB;\n if (a.length === 0) throw new Error('Layer A has no features.');\n if (needsB && b.length === 0) throw new Error('This operation needs a second layer with features.');\n geojson = runOverlay(op, a, b);\n summary = `${geojson.features.length.toLocaleString()} feature(s) produced.`;\n } else {\n const zones = featuresOf(els.zoneLayer);\n const inputs = featuresOf(els.inputLayer);\n const stats = Array.from(els.statsBox.querySelectorAll('input:checked')).map((i) => i.value);\n const field = els.field.value || null;\n if (stats.length === 0) throw new Error('Select at least one statistic.');\n const needsField = stats.some((s) => (STATS.find((x) => x.key === s) || {}).needsField);\n if (needsField && !field) throw new Error('Sum / mean / min / max need a numeric field.');\n\n const res = zonalStatistics({\n zoneFeatures: zones, inputFeatures: inputs,\n stats, field, membership: els.membership.value,\n });\n geojson = res.geojson;\n zonalWarnings = res.warnings || [];\n summary = renderRows(res.rows);\n\n // Keep the table AND the settings that produced it, so the Excel export\n // is self-describing — a table of numbers with no record of how it was\n // produced can't be checked or reproduced later.\n const membershipLabel = (MEMBERSHIP_MODES.find((m) => m.key === els.membership.value) || {}).label;\n lastZonal = {\n rows: res.rows,\n warnings: res.warnings || [],\n params: {\n 'Analysis': 'Zonal statistics',\n 'Zone layer': labelOfSelect(els.zoneLayer),\n 'Features summarised': labelOfSelect(els.inputLayer),\n 'Applied to': scopeLabel(),\n 'Zones analysed': zones.length,\n 'Features analysed': inputs.length,\n 'Counted when': membershipLabel || els.membership.value,\n 'Statistics': stats.map((k) => (STATS.find((x) => x.key === k) || {}).label || k).join(', '),\n 'Numeric field': field || '(none)',\n 'Result layer': name,\n },\n };\n }\n\n if (!geojson.features.length) {\n els.result.innerHTML =\n `
No result features — the layers may not overlap.
`;\n return;\n }\n\n const group = ensureAnalysisGroup();\n mapView.addGeoJSONLayer(geojson, name, {\n strokeColor: '#d97706', strokeWidth: 2, fillColor: 'rgba(217,119,6,0.18)',\n }, group);\n\n syncExportButton();\n const warnHtml = zonalWarnings.length\n ? `
` +\n zonalWarnings.map((w) => `
${escapeHtml(w)}
`).join('') + `
`\n : '';\n els.result.innerHTML =\n `
Added layer “${escapeHtml(name)}”.
` +\n warnHtml +\n (typeof summary === 'string' && summary.startsWith('<') ? summary : `
${escapeHtml(summary)}
`);\n showToast(`Analysis complete — “${name}” added to the Analysis group.`, 'success', 4000);\n } catch (err) {\n console.error('[Analysis] run failed:', err);\n els.result.innerHTML =\n `
${escapeHtml(err.message || String(err))}
`;\n } finally {\n els.btnRun.disabled = false;\n }\n }, 20);\n}\n\n/** Human label of a layer \n \n `;\n }\n\n const html = `\n
\n ✏️ Edit Parcel\n \n
\n
\n ${fieldsHtml}\n
\n \n \n
\n
\n `;\n\n this.parcelEditElement.innerHTML = html;\n this.parcelEditPopup.setPosition(coordinate);\n\n // Close / Cancel handlers\n this.parcelEditElement.querySelector('.parcel-edit-close').addEventListener('click', () => {\n this.hideParcelEditPopup();\n });\n this.parcelEditElement.querySelector('.parcel-edit-cancel').addEventListener('click', () => {\n this.hideParcelEditPopup();\n });\n\n // Form submit handler\n const form = this.parcelEditElement.querySelector('.parcel-edit-form');\n form.addEventListener('submit', (e) => {\n e.preventDefault();\n\n // Collect all edited values\n const formData = new FormData(form);\n const updatedProps = {};\n for (const [key, value] of formData.entries()) {\n updatedProps[key] = value;\n }\n\n // Restore internal properties that were excluded from the form\n updatedProps._layerType = 'parcel';\n\n // Update the feature's properties in-place\n for (const [key, value] of Object.entries(updatedProps)) {\n this._parcelEditFeature.set(key, value);\n }\n\n // Notify external listeners\n for (const cb of this._parcelEditCallbacks) {\n cb(this._parcelEditFeature, updatedProps);\n }\n\n this.hideParcelEditPopup();\n });\n }\n\n /**\n * Hide the parcel edit popup.\n */\n hideParcelEditPopup() {\n this.parcelEditPopup.setPosition(undefined);\n this._parcelEditFeature = null;\n }\n\n /**\n * Register a callback for when a parcel edit is saved.\n * Callback receives (feature, updatedProperties).\n *\n * @param {Function} callback\n */\n onParcelEdit(callback) {\n this._parcelEditCallbacks.push(callback);\n }\n\n // ============================================================================\n // Merge Identifier (UPN) Chooser Popup\n // ============================================================================\n\n /**\n * Create the merge identifier popup overlay.\n * Shown after two parcels are merged so the user can choose which UPN to keep.\n */\n createMergePopup() {\n this.mergePopupElement = document.createElement('div');\n this.mergePopupElement.className = 'map-merge-popup';\n this.mergePopupElement.style.cssText = `\n position: absolute;\n background: var(--card, #fff);\n color: var(--card-foreground, #1e1a4b);\n border-radius: 10px;\n box-shadow: 0 4px 20px rgba(0,0,0,0.3);\n font-family: var(--font-body, 'Exo', sans-serif);\n font-size: 13px;\n min-width: 280px;\n max-width: 360px;\n z-index: 1002;\n border: 2px solid #10b981;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n `;\n\n this.mergePopup = new Overlay({\n element: this.mergePopupElement,\n positioning: 'bottom-center',\n offset: [0, -10],\n stopEvent: true,\n autoPan: true,\n autoPanAnimation: { duration: 250 },\n });\n\n this.map.addOverlay(this.mergePopup);\n }\n\n /**\n * Show the merge identifier popup so the user can pick which parcel's\n * attributes (including UPN) the merged polygon should inherit.\n *\n * @param {Feature} mergedFeature The newly created merged feature\n * @param {Object} propsA Properties from original parcel A\n * @param {Object} propsB Properties from original parcel B\n * @param {Array} coordinate Map coordinate [x, y] for popup placement\n */\n showMergeIdentifierPopup(mergedFeature, propsA, propsB, coordinate) {\n // Extract identifiers — try common parcel ID field names\n const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];\n const getLabel = (props) => {\n for (const field of idFields) {\n if (props[field] !== undefined && props[field] !== null && String(props[field]).trim()) {\n return { field, value: String(props[field]) };\n }\n }\n return { field: 'id', value: 'Unknown' };\n };\n\n const labelA = getLabel(propsA);\n const labelB = getLabel(propsB);\n\n const html = `\n
\n 🔗 Merged Parcel — Choose Identifier\n \n
\n
\n

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

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

\n Enter the number of equal pieces:\n

\n \n
\n \n \n
\n
\n `;\n\n this.dividePopupElement.innerHTML = html;\n this.dividePopup.setPosition(coordinate);\n\n const input = this.dividePopupElement.querySelector('.divide-input');\n input.focus();\n input.select();\n\n // Close / Cancel\n const cancel = () => {\n this.hideDividePopup();\n this._polygonDivideInteraction.cancelDivide();\n };\n this.dividePopupElement.querySelector('.divide-popup-close').addEventListener('click', cancel);\n this.dividePopupElement.querySelector('.divide-popup-cancel').addEventListener('click', cancel);\n\n // Confirm\n this.dividePopupElement.querySelector('.divide-popup-confirm').addEventListener('click', () => {\n const n = parseInt(input.value, 10);\n if (!n || n < 2) {\n input.style.borderColor = '#ef4444';\n return;\n }\n this.hideDividePopup();\n this._polygonDivideInteraction.performDivide(n);\n });\n\n // Allow Enter key to confirm\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') {\n e.preventDefault();\n this.dividePopupElement.querySelector('.divide-popup-confirm').click();\n }\n });\n }\n\n /**\n * Hide the divide popup.\n */\n hideDividePopup() {\n this.dividePopup.setPosition(undefined);\n }\n\n // ============================================================================\n // Drawn Polygon Attribute Popup\n // ============================================================================\n\n /**\n * Create the drawn polygon attribute popup overlay.\n * Shown after the area measurement polygon is completed so the user can\n * attach parcel-like attributes to the drawn polygon.\n */\n createDrawnPolygonPopup() {\n this.drawnPolygonElement = document.createElement('div');\n this.drawnPolygonElement.className = 'map-drawn-polygon-popup';\n this.drawnPolygonElement.style.cssText = `\n position: absolute;\n background: var(--card, #fff);\n border-radius: var(--radius-xl, 0.75rem);\n box-shadow: 0 4px 20px rgba(0,0,0,0.2);\n font-family: var(--font-body, 'Exo', sans-serif);\n font-size: 13px;\n min-width: 280px;\n max-width: 360px;\n max-height: 420px;\n z-index: 1002;\n border: 2px solid var(--success, #006b3f);\n overflow: hidden;\n display: flex;\n flex-direction: column;\n `;\n\n this.drawnPolygonPopup = new Overlay({\n element: this.drawnPolygonElement,\n positioning: 'bottom-center',\n offset: [0, -10],\n stopEvent: true,\n autoPan: true,\n autoPanAnimation: { duration: 250 },\n });\n\n this.map.addOverlay(this.drawnPolygonPopup);\n this._drawnPolygonCallbacks = [];\n this._drawnPolygonFeature = null;\n }\n\n /**\n * Get attribute keys from existing parcel features on the map.\n * Scans the overlay group for the first feature with _layerType='parcel'\n * and returns its property key names (excluding internal keys).\n *\n * @returns {string[]} Array of attribute key names\n */\n getParcelAttributeKeys() {\n const skipKeys = ['geometry', '_layerType'];\n const keys = [];\n\n const scanGroup = (group) => {\n if (keys.length > 0) return;\n group.getLayers().forEach((layer) => {\n if (keys.length > 0) return;\n if (layer instanceof LayerGroup) {\n scanGroup(layer);\n } else if (layer instanceof VectorLayer) {\n const source = layer.getSource();\n if (!source) return;\n for (const f of source.getFeatures()) {\n if (f.get('_layerType') !== 'parcel') continue;\n const props = f.getProperties();\n for (const key of Object.keys(props)) {\n if (!skipKeys.includes(key)) keys.push(key);\n }\n return; // one parcel is enough for the schema\n }\n }\n });\n };\n\n scanGroup(this.overlayGroup);\n return keys;\n }\n\n /**\n * Show the drawn polygon attribute popup.\n * Discovers attribute keys from existing parcel features and creates\n * a blank form with those fields.\n *\n * @param {Feature} feature - The drawn polygon feature\n * @param {Array} coordinate - Map coordinate [x, y] for popup placement\n */\n showDrawnPolygonPopup(feature, coordinate) {\n this._drawnPolygonFeature = feature;\n\n // Discover attribute keys from existing parcels\n const attributeKeys = this.getParcelAttributeKeys();\n\n if (attributeKeys.length === 0) {\n console.warn('[MapView] No parcel attributes found — cannot build form');\n return;\n }\n\n // Build form fields (all blank)\n let fieldsHtml = '';\n for (const key of attributeKeys) {\n const escapedKey = this.escapeHtml(key);\n fieldsHtml += `\n
\n \n \n
\n `;\n }\n\n // Area display\n const geom = feature.getGeometry();\n const areaSqm = getArea(geom, { projection: 'EPSG:3857' });\n const areaFormatted = formatArea(areaSqm);\n\n const html = `\n
\n 📐 Polygon Attributes\n \n
\n
\n Area: ${areaFormatted}\n
\n
\n ${fieldsHtml}\n
\n \n \n
\n
\n `;\n\n this.drawnPolygonElement.innerHTML = html;\n this.drawnPolygonPopup.setPosition(coordinate);\n\n // Close / Cancel handlers\n this.drawnPolygonElement.querySelector('.drawn-polygon-close').addEventListener('click', () => {\n this.hideDrawnPolygonPopup();\n });\n this.drawnPolygonElement.querySelector('.drawn-polygon-cancel').addEventListener('click', () => {\n this.hideDrawnPolygonPopup();\n });\n\n // Form submit handler\n const form = this.drawnPolygonElement.querySelector('.drawn-polygon-form');\n form.addEventListener('submit', (e) => {\n e.preventDefault();\n\n const formData = new FormData(form);\n const props = {};\n for (const [key, value] of formData.entries()) {\n props[key] = value;\n }\n\n // Set properties on the feature\n for (const [key, value] of Object.entries(props)) {\n this._drawnPolygonFeature.set(key, value);\n }\n\n // Tag as parcel so it integrates with existing parcel tools\n this._drawnPolygonFeature.set('_layerType', 'parcel');\n\n // Notify listeners\n for (const cb of this._drawnPolygonCallbacks) {\n cb(this._drawnPolygonFeature, props);\n }\n\n this.hideDrawnPolygonPopup();\n });\n }\n\n /**\n * Hide the drawn polygon attribute popup.\n */\n hideDrawnPolygonPopup() {\n this.drawnPolygonPopup.setPosition(undefined);\n this._drawnPolygonFeature = null;\n }\n\n /**\n * Register a callback for when drawn polygon attributes are saved.\n * Callback receives (feature, properties).\n *\n * @param {Function} callback\n */\n onDrawnPolygonSave(callback) {\n this._drawnPolygonCallbacks.push(callback);\n }\n\n /**\n * Register a callback fired after the user finishes modifying a feature\n * with the EditBar Modify interaction. Callback receives the OL feature\n * whose geometry just changed. Consumers (e.g. the import-staging code in\n * main.js) inspect the feature's tags (_externalImportId / _clientUuid)\n * to decide whether to react. Multiple callbacks are supported.\n *\n * @param {Function} callback - (feature) => void | Promise\n */\n onFeatureModified(callback) {\n if (!this._featureModifiedCallbacks) this._featureModifiedCallbacks = [];\n this._featureModifiedCallbacks.push(callback);\n }\n\n /**\n * Register a callback fired when the officer places a point with the\n * Draw ▸ Point tool. Callback receives the map coordinate; the sketch itself\n * has already been discarded, so the callback owns what happens next — in\n * this application, opening the Add Location form.\n *\n * @param {Function} callback - (coordinate) => void\n */\n onDrawnPoint(callback) {\n if (!this._drawnPointCallbacks) this._drawnPointCallbacks = [];\n this._drawnPointCallbacks.push(callback);\n }\n\n /**\n * Register a double-click callback.\n * Callback receives (lon, lat, feature, event).\n * Feature is the first feature found at the click pixel across all overlay layers,\n * or null if no feature was hit.\n * When a feature is hit, the default double-click-zoom is suppressed.\n */\n onDblClick(callback) {\n this.dblClickCallbacks.push(callback);\n\n // Set up the listener once\n if (this.dblClickCallbacks.length === 1) {\n this.map.on('dblclick', (evt) => {\n const [lon, lat] = toLonLat(evt.coordinate);\n\n // Find any feature at the clicked pixel (overlay layers, not just markers)\n let clickedFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n clickedFeature = feature;\n return true; // stop at first hit\n });\n\n // If a feature was hit, prevent the default double-click zoom\n if (clickedFeature) {\n evt.preventDefault();\n evt.stopPropagation();\n }\n\n // Call all registered callbacks\n for (const cb of this.dblClickCallbacks) {\n cb(lon, lat, clickedFeature, evt);\n }\n\n // Return false to suppress DoubleClickZoom interaction when on a feature\n if (clickedFeature) return false;\n });\n }\n\n return () => {\n const idx = this.dblClickCallbacks.indexOf(callback);\n if (idx > -1) this.dblClickCallbacks.splice(idx, 1);\n };\n }\n\n /**\n * Escape HTML to prevent XSS\n */\n escapeHtml(text) {\n if (!text) return '';\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Create the Add Location popup form overlay\n */\n createAddLocationPopup() {\n // Create popup container element\n this.addLocationPopupElement = document.createElement('div');\n this.addLocationPopupElement.className = 'map-add-location-popup';\n this.addLocationPopupElement.innerHTML = `\n
\n ➕ Add Location\n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n 📍 \n
\n \n
\n `;\n\n // Create the overlay\n this.addLocationPopup = new Overlay({\n element: this.addLocationPopupElement,\n positioning: 'bottom-center',\n offset: [0, -10],\n stopEvent: true, // Prevent click from propagating\n autoPan: true,\n autoPanAnimation: {\n duration: 250,\n },\n });\n\n this.map.addOverlay(this.addLocationPopup);\n\n // Store clicked coordinates\n this.addLocationCoords = null;\n\n // Set up close button handler\n const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');\n closeBtn.addEventListener('click', () => {\n this.hideAddLocationPopup();\n });\n\n // Store form submit callbacks\n this.addLocationCallbacks = [];\n }\n\n /**\n * Show the Add Location popup at the specified coordinate\n */\n showAddLocationPopup(coordinate) {\n const [lon, lat] = toLonLat(coordinate);\n this.addLocationCoords = { lon, lat };\n\n // Update coordinates display\n const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');\n coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;\n\n // Reset form\n const form = this.addLocationPopupElement.querySelector('#map-add-location-form');\n form.reset();\n\n // Position and show popup\n this.addLocationPopup.setPosition(coordinate);\n }\n\n /**\n * Hide the Add Location popup\n */\n hideAddLocationPopup() {\n this.addLocationPopup.setPosition(undefined);\n this.addLocationCoords = null;\n // The popup closing is the end of that point's life either way: on save the\n // marker has already been added and now takes its place, and on cancel there\n // is nothing to keep.\n this._discardPendingLocationSketch();\n }\n\n /**\n * Remove the sketch left by Draw ▸ Point once its Add Location form is done.\n *\n * Kept off the undo stack to match the add — the pair is scaffolding for\n * placing a marker, not an edit the officer should be able to step back into.\n * @private\n */\n _discardPendingLocationSketch() {\n const sketch = this._pendingLocationSketch;\n if (!sketch) return;\n this._pendingLocationSketch = null;\n this._withoutUndoRecording(() => {\n try {\n this.drawingsSource.removeFeature(sketch);\n } catch {\n // Already gone (e.g. the layer was cleared) — nothing to do.\n }\n });\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 if (!CARTO_API_KEY) {\n console.warn(\n '[MapView] VITE_CARTO_API_KEY is not set — Carto Light and Carto Dark tiles '\n + 'will carry an \"API KEY REQUIRED\" watermark. Add it to .env.local and rebuild.'\n );\n }\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: cartoTileUrl('light_all'),\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: cartoTileUrl('dark_all'),\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 // Kept off the undo stack. Entering edit mode calls UndoRedo._watchSources(),\n // which picks up every vector source including this one — so reloading the\n // Locations list would otherwise register as an undoable edit, and Undo\n // after saving a location would strip the markers off the map while leaving\n // the row in the database. Undo belongs to drawing, not to the locations list.\n this._withoutUndoRecording(() => 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 // _silentClearSource rather than clear(): ol-ext patches VectorSource.clear\n // to fire clearstart/clearend, which drive UndoRedo's block events and are\n // NOT gated by the record flag. See the note on addMarkers for why the\n // markers source stays out of the undo history.\n this._withoutUndoRecording(() => this._silentClearSource(this.markerSource));\n this.selectedFeature = null;\n }\n\n /**\n * Remove a specific marker by feature or ID\n */\n removeMarker(featureOrId) {\n if (typeof featureOrId === 'object') {\n this.markerSource.removeFeature(featureOrId);\n } else {\n const feature = this.markerSource.getFeatures().find(\n f => f.get('id') === featureOrId\n );\n if (feature) {\n this.markerSource.removeFeature(feature);\n }\n }\n }\n\n /**\n * Get all markers\n */\n getMarkers() {\n return this.markerSource.getFeatures();\n }\n\n /**\n * Find marker by ID\n */\n findMarker(id) {\n return this.markerSource.getFeatures().find(f => f.get('id') === id);\n }\n\n /**\n * Select a marker (highlights it)\n */\n /**\n * Make the Markers layer visible.\n *\n * The layer starts hidden so a fresh map is uncluttered, and the LayerSwitcher\n * is the only thing that ever turned it on. That is fine until the officer\n * creates or focuses a location, at which point an invisible layer means the\n * point they just placed is simply not there — the record is written, the map\n * pans to it, and nothing is drawn. Callers that act on a specific location\n * call this so the result of the action can actually be seen.\n */\n showMarkers() {\n if (this.markersLayer && !this.markersLayer.getVisible()) {\n this.markersLayer.setVisible(true);\n console.log('[MapView] Markers layer switched on to show a location');\n }\n }\n\n selectMarker(featureOrId) {\n if (typeof featureOrId === 'object') {\n this.selectedFeature = featureOrId;\n } else {\n this.selectedFeature = this.findMarker(featureOrId);\n }\n this.markerSource.changed();\n return this.selectedFeature;\n }\n\n /**\n * Clear selection\n */\n clearSelection() {\n this.selectedFeature = null;\n this.markerSource.changed();\n }\n\n /**\n * Zoom to a specific location\n */\n zoomTo(lon, lat, zoom = 15) {\n this.map.getView().animate({\n center: fromLonLat([lon, lat]),\n zoom: zoom,\n duration: 500,\n });\n }\n\n /**\n * Fit view to show all markers\n */\n fitToMarkers(padding = 50) {\n const extent = this.markerSource.getExtent();\n if (extent && extent[0] !== Infinity) {\n this.map.getView().fit(extent, {\n padding: [padding, padding, padding, padding],\n duration: 500,\n maxZoom: 16,\n });\n }\n }\n\n /**\n * Get current map center in lon/lat\n */\n getCenter() {\n const center = this.map.getView().getCenter();\n return toLonLat(center);\n }\n\n /**\n * Get current zoom level\n */\n getZoom() {\n return this.map.getView().getZoom();\n }\n\n /**\n * Set map center\n */\n setCenter(lon, lat) {\n this.map.getView().setCenter(fromLonLat([lon, lat]));\n }\n\n /**\n * Set zoom level\n */\n setZoom(zoom) {\n this.map.getView().setZoom(zoom);\n }\n\n /**\n * Register click callback\n * Callback receives (lon, lat, feature, event)\n *\n * Single-click is delayed by 300 ms so that a double-click can cancel it.\n * If the click lands on an overlay feature (e.g. district boundary) the\n * single-click is suppressed entirely — only double-click will fire.\n */\n onClick(callback) {\n this.clickCallbacks.push(callback);\n\n // Set up click handler if this is the first callback\n if (this.clickCallbacks.length === 1) {\n this._clickTimer = null;\n\n // Double-click cancels any pending single-click\n this.map.on('dblclick', () => {\n if (this._clickTimer) {\n clearTimeout(this._clickTimer);\n this._clickTimer = null;\n }\n });\n\n this.map.on('click', (evt) => {\n // Cancel any previous pending click\n if (this._clickTimer) {\n clearTimeout(this._clickTimer);\n this._clickTimer = null;\n }\n\n // When NOT in edit / draw mode, immediately clear any feature\n // the Select interaction may have grabbed on this click so the\n // user never sees a selection flash.\n if (!this._editBarActive && this._selectInteraction) {\n this._selectInteraction.getFeatures().clear();\n }\n\n // Check what features sit under the click pixel\n let hasOverlayFeature = false;\n let hasParcelFeature = false;\n let markerFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n if (feature.get('_layerType') === 'parcel') {\n hasParcelFeature = true;\n }\n if (feature.get('name')) {\n markerFeature = feature;\n }\n hasOverlayFeature = true;\n });\n\n // If an overlay feature was hit, suppress single-click\n // UNLESS it's a parcel or a location marker\n if (hasOverlayFeature && !hasParcelFeature && !markerFeature) {\n return;\n }\n\n // Delay the single-click to allow double-click to cancel it\n const [lon, lat] = toLonLat(evt.coordinate);\n this._clickTimer = setTimeout(() => {\n this._clickTimer = null;\n\n // Find location marker at pixel\n let clickedFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n if (feature.get('name')) {\n clickedFeature = feature;\n return true;\n }\n });\n\n for (const cb of this.clickCallbacks) {\n cb(lon, lat, clickedFeature, evt);\n }\n }, 300);\n });\n }\n\n // Return unsubscribe function\n return () => {\n const index = this.clickCallbacks.indexOf(callback);\n if (index > -1) {\n this.clickCallbacks.splice(index, 1);\n }\n };\n }\n\n /**\n * Register pointer move callback (for hover effects)\n */\n onPointerMove(callback) {\n this.map.on('pointermove', (evt) => {\n if (evt.dragging) return;\n\n const [lon, lat] = toLonLat(evt.coordinate);\n\n // Only find location markers (features with 'name' property)\n let hoveredFeature = null;\n this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {\n if (feature.get('name')) {\n hoveredFeature = feature;\n return true;\n }\n });\n\n // Change cursor\n this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : '';\n\n callback(lon, lat, hoveredFeature, evt);\n });\n }\n\n /**\n * Enable cursor change on marker hover\n * Note: This is now handled automatically by the popup system\n */\n enableHoverCursor() {\n // Cursor changes are now handled by setupHoverPopup()\n // This method is kept for backwards compatibility\n }\n\n /**\n * Add a GeoJSON layer (visible in LayerSwitcher).\n * By default the layer is added to the root overlay group.\n * Pass a targetGroup (LayerGroup) to nest it inside a specific group.\n *\n * @param {Object} geojson - GeoJSON FeatureCollection or Feature\n * @param {string} title - Layer title for the LayerSwitcher\n * @param {Object} [styleOptions] - Optional style configuration\n * @param {string} [styleOptions.strokeColor='#3b82f6'] - Stroke color\n * @param {number} [styleOptions.strokeWidth=2] - Stroke width\n * @param {string} [styleOptions.fillColor='rgba(59,130,246,0.1)'] - Fill color\n * @param {number[]} [styleOptions.strokeDash] - Dash pattern for the stroke\n * (passed straight to ol/style/Stroke#lineDash, e.g. [4,4]). Useful for\n * contextual overlays (grids, draft outlines) so they read differently\n * from solid property boundaries.\n * @param {LayerGroup} [targetGroup] - Optional group to add the layer to\n * @returns {VectorLayer} The created layer\n */\n addGeoJSONLayer(geojson, title, styleOptions = {}, targetGroup = null) {\n const {\n strokeColor = '#3b82f6',\n strokeWidth = 2,\n strokeDash = null,\n fillColor = 'rgba(59,130,246,0.1)',\n // Optional line \"casing\": a thicker darker stroke drawn UNDERNEATH the\n // main stroke. Used for road-like layers to make light-colored lines\n // visible on any base map. Set lineCasingColor to enable; the casing\n // width defaults to strokeWidth + 2.\n lineCasingColor = null,\n lineCasingWidth = null,\n pointRadius = 5,\n pointFillColor = null, // defaults to strokeColor\n pointStrokeColor = '#ffffff',\n pointStrokeWidth = 1.5,\n } = styleOptions;\n\n const source = new VectorSource({\n features: new GeoJSON().readFeatures(geojson, {\n featureProjection: 'EPSG:3857',\n }),\n });\n\n // Build per-geometry styles. OpenLayers picks `image` for Point /\n // MultiPoint, `stroke`+`fill` for Polygon / MultiPolygon, and `stroke`\n // alone for LineString / MultiLineString. Putting all three on a single\n // Style is enough — but a Style with only stroke+fill leaves Points\n // invisible, which is what was happening on shapefile import.\n const fillStyle = new Fill({ color: fillColor });\n const pointStyle = new Circle({\n radius: pointRadius,\n fill: new Fill({ color: pointFillColor || strokeColor }),\n stroke: new Stroke({ color: pointStrokeColor, width: pointStrokeWidth }),\n });\n\n // If a line casing is requested, return an array of two Styles per\n // feature: the casing renders first (underneath), then the inner stroke.\n // For polygons the casing also outlines them; for points the casing has\n // no effect (Point geometries only render `image`).\n const mainStroke = new Stroke({\n color: strokeColor,\n width: strokeWidth,\n ...(strokeDash ? { lineDash: strokeDash } : {}),\n });\n\n let layerStyle;\n if (lineCasingColor) {\n const casingW = lineCasingWidth != null ? lineCasingWidth : strokeWidth + 2;\n layerStyle = [\n new Style({\n stroke: new Stroke({ color: lineCasingColor, width: casingW }),\n }),\n new Style({\n stroke: mainStroke,\n fill: fillStyle,\n image: pointStyle,\n }),\n ];\n } else {\n layerStyle = new Style({\n stroke: mainStroke,\n fill: fillStyle,\n image: pointStyle,\n });\n }\n\n const layer = new VectorLayer({\n title: title,\n source: source,\n style: layerStyle,\n // Decluttering can only be set at construction time (there is no\n // setDeclutter), so it has to come through the style options. Layers with\n // dense labels — contours especially — need it to avoid a pile-up of\n // overlapping text.\n ...(styleOptions.declutter ? { declutter: styleOptions.declutter } : {}),\n });\n layer.set('typeTag', styleOptions.typeTag || 'VEC');\n\n // Derive a friendly \"Vector / Polygon\" / \"Vector / Line\" / \"Vector / Point\"\n // subtitle from the first feature's geometry type, unless the caller\n // already supplied one in styleOptions.\n //\n // Layers created EMPTY (parcels, OSM_roads, …, populated later from the\n // API) leave the subtitle absent until the first feature arrives —\n // see the `addfeature` listener below.\n const describeFromGeom = (geomType) => {\n if (!geomType) return null;\n if (geomType.includes('Polygon')) return 'Vector / Polygon';\n if (geomType.includes('LineString')) return 'Vector / Line';\n if (geomType.includes('Point')) return 'Vector / Point';\n return 'Vector';\n };\n\n if (styleOptions.typeDescription) {\n layer.set('typeDescription', styleOptions.typeDescription);\n } else {\n const feats = source.getFeatures();\n const initial = describeFromGeom(feats[0]?.getGeometry?.()?.getType?.());\n if (initial) {\n layer.set('typeDescription', initial);\n } else {\n // Source is empty — wait for the first feature and set then.\n const once = (ev) => {\n const desc = describeFromGeom(ev.feature.getGeometry?.()?.getType?.());\n if (desc) layer.set('typeDescription', desc);\n source.un('addfeature', once);\n };\n source.on('addfeature', once);\n }\n }\n\n const group = targetGroup || this.overlayGroup;\n group.getLayers().push(layer);\n\n console.log('[MapView] GeoJSON layer added:', title, '→', source.getFeatures().length, 'features',\n targetGroup ? `(in group \"${targetGroup.get('title')}\")` : '');\n return layer;\n }\n\n /**\n * Add a LayerGroup to the overlay group.\n * Used to create layer categories from the remote catalogue;\n * individual vector layers will be added into these groups later.\n *\n * @param {number|string} id - Unique layer group id (from the API)\n * @param {string} title - Group title for the LayerSwitcher\n * @param {string} [description=''] - Group description (stored as property)\n * @returns {LayerGroup} The created (empty) layer group\n */\n addLayerGroup(id, title, description = '') {\n const group = new LayerGroup({\n title: title.trim(),\n });\n\n // Store metadata for later use\n group.set('layerId', id);\n group.set('description', description);\n\n this.overlayGroup.getLayers().push(group);\n\n console.log('[MapView] Layer group added:', title.trim(), '(id:', id + ')');\n return group;\n }\n\n /**\n * Add a WMS layer to a layer group.\n *\n * @param {string} groupTitle Title of the target LayerGroup (e.g. 'Biophysical Environment')\n * @param {string} title Display title for the layer\n * @param {string} url WMS server URL\n * @param {string} layers WMS LAYERS parameter\n * @param {Object} [options] Extra options\n * @param {string} [options.serverType='geoserver'] Server type hint ('geoserver'|'mapserver'|'qgis'|null)\n * @param {string} [options.style] WMS STYLES parameter (e.g. 'colours' for DEAfrica DEM)\n * @param {boolean} [options.visible=true] Initial visibility\n * @param {string} [options.attributions] Attribution HTML\n * @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers.\n * @param {number} [options.zIndex] Render z-index. Use negative values (e.g. -10) to force the\n * layer behind all default-z-index layers regardless of group order.\n * @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.\n * @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on\n * while offline, explaining that the layer requires connectivity.\n * @returns {TileLayer|null} The created layer, or null if group not found\n */\n addWMSLayer(groupTitle, title, url, layers, options = {}) {\n const group = this.getLayerGroupByTitle(groupTitle);\n if (!group) {\n console.warn(`[MapView] Layer group \"${groupTitle}\" not found — cannot add WMS layer \"${title}\"`);\n return null;\n }\n\n const params = { LAYERS: layers, TILED: true, WIDTH: 256, HEIGHT: 256 };\n if (options.style !== undefined) params.STYLES = options.style;\n\n const wmsSource = new TileWMS({\n url,\n params,\n serverType: options.serverType !== undefined ? options.serverType : 'geoserver',\n crossOrigin: 'anonymous',\n hidpi: false,\n attributions: options.attributions,\n });\n\n const wmsLayer = new TileLayer({\n title,\n visible: options.visible !== undefined ? options.visible : true,\n source: wmsSource,\n opacity: options.opacity !== undefined ? options.opacity : 1,\n zIndex: options.zIndex,\n });\n wmsLayer.set('typeTag', 'WMS');\n wmsLayer.set('typeDescription', 'WMS / Raster');\n\n // Show toast on tile load errors (e.g. server rejects request)\n wmsSource.on('tileloaderror', () => {\n showToast(`WMS layer \"${title}\" — tile load error. Check the URL and layer name.`, 'warning', 5000);\n });\n\n group.getLayers().push(wmsLayer);\n\n // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher\n if (options.legendUrl) {\n try {\n this._registerLegend(wmsLayer, title, options.legendUrl);\n } catch (err) {\n console.warn(`[MapView] Could not register legend for \"${title}\":`, err);\n }\n }\n\n // Online-only warning: when the user toggles the layer on while offline,\n // surface a toast explaining why nothing will render.\n if (options.onlineOnly) {\n this._attachOnlineOnlyHandler(wmsLayer, title);\n }\n\n console.log(`[MapView] WMS layer added: \"${title}\" → group \"${groupTitle}\"`);\n return wmsLayer;\n }\n\n /**\n * Add an XYZ tile layer to a layer group.\n *\n * @param {string} groupTitle Title of the target LayerGroup\n * @param {string} title Display title for the layer\n * @param {string} url XYZ tile URL template (with {z}/{x}/{y} placeholders)\n * @param {Object} [options] Extra options\n * @param {boolean} [options.visible=true] Initial visibility\n * @param {string} [options.attributions] Attribution HTML\n * @param {number} [options.maxZoom=19] Maximum zoom level\n * @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers.\n * @param {number} [options.zIndex] Render z-index. Use negative values to force behind other layers.\n * @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.\n * @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on\n * while offline, explaining that the layer requires connectivity.\n * @returns {TileLayer|null} The created layer, or null if group not found\n */\n addXYZLayer(groupTitle, title, url, options = {}) {\n const group = this.getLayerGroupByTitle(groupTitle);\n if (!group) {\n console.warn(`[MapView] Layer group \"${groupTitle}\" not found — cannot add XYZ layer \"${title}\"`);\n return null;\n }\n\n const xyzSource = new XYZ({\n url,\n crossOrigin: 'anonymous',\n maxZoom: options.maxZoom !== undefined ? options.maxZoom : 19,\n attributions: options.attributions,\n });\n\n const xyzLayer = new TileLayer({\n title,\n visible: options.visible !== undefined ? options.visible : true,\n source: xyzSource,\n opacity: options.opacity !== undefined ? options.opacity : 1,\n zIndex: options.zIndex,\n });\n xyzLayer.set('typeTag', 'XYZ');\n xyzLayer.set('typeDescription', 'XYZ / Tile');\n\n // Show toast on tile load errors\n xyzSource.on('tileloaderror', () => {\n showToast(`XYZ layer \"${title}\" — tile load error. Check the URL.`, 'warning', 5000);\n });\n\n group.getLayers().push(xyzLayer);\n\n // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher\n if (options.legendUrl) {\n try {\n this._registerLegend(xyzLayer, title, options.legendUrl);\n } catch (err) {\n console.warn(`[MapView] Could not register legend for \"${title}\":`, err);\n }\n }\n\n // Online-only warning: when the user toggles the layer on while offline,\n // surface a toast explaining why nothing will render.\n if (options.onlineOnly) {\n this._attachOnlineOnlyHandler(xyzLayer, title);\n }\n\n console.log(`[MapView] XYZ layer added: \"${title}\" → group \"${groupTitle}\"`);\n return xyzLayer;\n }\n\n /**\n * Add a Cloud-Optimized GeoTIFF (COG) raster layer.\n *\n * COGs are streamed by range-request and rendered on the GPU via\n * WebGLTile, so a large DEM or satellite scene can be displayed without\n * downloading the whole file. This is the display half of Stage 1's raster\n * support (analysis on rasters runs server-side — see the concept note).\n *\n * @param {string} groupTitle Title of the target LayerGroup\n * @param {string} title Display title for the layer\n * @param {string} url URL of the .tif (must be a valid COG, CORS-enabled)\n * @param {Object} [options]\n * @param {boolean} [options.visible=true]\n * @param {number} [options.opacity=1]\n * @param {number} [options.zIndex]\n * @param {boolean} [options.normalize=true] Auto-stretch values to 0–1 for display\n * @param {number} [options.min] Explicit min for contrast stretch\n * @param {number} [options.max] Explicit max for contrast stretch\n * @param {Object} [options.style] OpenLayers WebGLTile style (e.g. a colour ramp)\n * @param {string} [options.attributions]\n * @returns {Promise} the created layer (async: the COG libraries\n * are code-split and fetched on first use)\n */\n async addCOGLayer(groupTitle, title, url, options = {}) {\n const group = this.getLayerGroupByTitle(groupTitle);\n if (!group) {\n console.warn(`[MapView] Layer group \"${groupTitle}\" not found — cannot add COG layer \"${title}\"`);\n return null;\n }\n\n // Pre-flight the URL before handing it to geotiff.js. Without this, a bad\n // URL (a web page, a 404, a CORS block) surfaces only as an unhandled\n // AggregateError from the library's internal range-request machinery,\n // which tells the user nothing. A one-byte range request is cheap and\n // distinguishes the common failures precisely.\n const problem = await this._probeCOGUrl(url);\n if (problem) {\n console.warn(`[MapView] COG pre-flight failed for \"${title}\":`, problem);\n showToast(problem, 'error', 8000);\n return null;\n }\n\n // Lazy-load the GeoTIFF stack only when a raster is actually opened.\n let WebGLTileLayer, GeoTIFFSource;\n try {\n [{ default: WebGLTileLayer }, { default: GeoTIFFSource }] = await Promise.all([\n import('ol/layer/WebGLTile'),\n import('ol/source/GeoTIFF'),\n ]);\n } catch (err) {\n console.error('[MapView] Could not load COG support:', err);\n showToast('Raster (COG) support could not be loaded — check your connection.', 'error', 6000);\n return null;\n }\n\n let cogLayer = null;\n try {\n const sourceOpts = {\n sources: [{\n url,\n ...(options.min !== undefined ? { min: options.min } : {}),\n ...(options.max !== undefined ? { max: options.max } : {}),\n }],\n // normalize=true stretches band values into 0–1 so single-band rasters\n // (DEMs, indices) render without an explicit style.\n normalize: options.normalize !== undefined ? options.normalize : true,\n attributions: options.attributions,\n };\n const source = new GeoTIFFSource(sourceOpts);\n\n cogLayer = new WebGLTileLayer({\n title,\n source,\n visible: options.visible !== undefined ? options.visible : true,\n opacity: options.opacity !== undefined ? options.opacity : 1,\n zIndex: options.zIndex,\n ...(options.style ? { style: options.style } : {}),\n });\n cogLayer.set('typeTag', 'COG');\n cogLayer.set('typeDescription', 'Cloud-Optimized GeoTIFF');\n\n // Surface load failures — a non-COG or a CORS-blocked file is the usual cause.\n source.on('error', (evt) => {\n console.warn(`[MapView] COG \"${title}\" source error:`, evt);\n showToast(`Raster \"${title}\" could not be loaded. Check that the file is a valid COG and is CORS-enabled.`,\n 'warning', 6000);\n });\n\n group.getLayers().push(cogLayer);\n } catch (err) {\n console.error(`[MapView] Failed to add COG layer \"${title}\":`, err);\n showToast(`Could not add raster \"${title}\": ${err.message}`, 'error', 6000);\n return null;\n }\n\n if (options.onlineOnly) this._attachOnlineOnlyHandler(cogLayer, title);\n\n console.log(`[MapView] COG layer added: \"${title}\" → group \"${groupTitle}\"`);\n return cogLayer;\n }\n\n /**\n * Check that a URL can plausibly serve a Cloud-Optimized GeoTIFF.\n *\n * Issues a 1-byte range request and inspects the response. Returns a\n * human-readable problem description, or null when the URL looks usable.\n * This exists because geotiff.js reports these failures as an opaque\n * \"AggregateError: Request failed\" from deep inside its block reader.\n *\n * @param {string} url\n * @returns {Promise}\n * @private\n */\n async _probeCOGUrl(url) {\n let res;\n try {\n res = await fetch(url, { headers: { Range: 'bytes=0-0' } });\n } catch (err) {\n // Thrown for CORS rejections and network failures alike — the browser\n // deliberately doesn't tell us which, so say so rather than guess.\n return 'Could not reach that file. It may be offline, or the server may not ' +\n 'allow cross-origin (CORS) requests from this app.';\n }\n\n if (res.status === 404) return 'No file at that URL (404). Check the address.';\n if (!res.ok && res.status !== 206) {\n return `The server refused the request (HTTP ${res.status}).`;\n }\n\n const type = (res.headers.get('content-type') || '').toLowerCase();\n if (type.includes('text/html')) {\n return 'That URL is a web page, not a GeoTIFF. Paste the direct link to the ' +\n '.tif file itself — a documentation or viewer page will not work.';\n }\n if (type.startsWith('text/') || type.includes('json')) {\n return `That URL returns ${type.split(';')[0]}rather than image data. ` +\n 'Paste the direct link to the .tif file.';\n }\n // 200 instead of 206 means range requests are ignored: the file still\n // loads, but the whole thing is downloaded. Worth warning about, not fatal.\n if (res.status === 200) {\n showToast('This server does not support byte-range requests, so the whole raster ' +\n 'will be downloaded. It may be slow for a large file.', 'warning', 7000);\n }\n return null;\n }\n\n /**\n * The features currently selected with the Select tool (shift-click or\n * Ctrl/Cmd box-select). Used by the Analysis panel to restrict an analysis\n * to a hand-picked subset.\n * @returns {Array}\n */\n getSelectedFeatures() {\n if (!this._selectInteraction) return [];\n return this._selectInteraction.getFeatures().getArray().slice();\n }\n\n /**\n * List the vector layers a user can pick as analysis inputs.\n *\n * Walks the layer tree (recursing into groups) and returns the real,\n * feature-bearing layers — excluding helper overlays (vertex handles, GPS\n * trail/position, snap guides) which are never meaningful analysis inputs.\n *\n * @param {Object} [opts]\n * @param {boolean} [opts.visibleOnly=false] only include currently-visible layers\n * @param {boolean} [opts.nonEmptyOnly=true] skip layers with no features loaded\n * @returns {Array<{layer: VectorLayer, title: string, count: number}>}\n */\n listVectorLayers(opts = {}) {\n const { visibleOnly = false, nonEmptyOnly = true } = opts;\n const out = [];\n\n const walk = (layer) => {\n if (!layer) return;\n if (typeof layer.getLayers === 'function') {\n layer.getLayers().forEach(walk);\n return;\n }\n if (!(layer instanceof VectorLayer)) return;\n if (layer === this._vertexOverlayLayer ||\n layer === this._gpsTrailLayer ||\n layer === this._gpsPositionLayer ||\n layer.get('displayInLayerSwitcher') === false) return;\n if (visibleOnly && layer.getVisible && !layer.getVisible()) return;\n\n const source = layer.getSource && layer.getSource();\n if (!source || typeof source.getFeatures !== 'function') return;\n const count = source.getFeatures().length;\n if (nonEmptyOnly && count === 0) return;\n\n out.push({ layer, title: layer.get('title') || '(untitled layer)', count });\n };\n\n this.map.getLayers().forEach(walk);\n return out;\n }\n\n // ============================================================================\n // Add External Layer Dialog\n // ============================================================================\n\n /**\n * Create the add-layer dialog overlay (hidden by default).\n * Appended to the map target element so it stays within the map viewport.\n */\n _createAddLayerDialog() {\n this._addLayerDialog = document.createElement('div');\n this._addLayerDialog.className = 'map-add-layer-dialog';\n this._addLayerDialog.style.cssText = `\n display:none;position:absolute;top:0;left:0;right:0;bottom:0;\n z-index:1100;background:rgba(0,0,0,0.4);\n align-items:center;justify-content:center;\n `;\n\n const card = document.createElement('div');\n card.style.cssText = `\n background:var(--card, #fff);color:var(--card-foreground, #1e1a4b);\n border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.35);\n font-family:var(--font-body, 'Exo', sans-serif);font-size:13px;\n width:340px;max-width:90vw;border:2px solid #10b981;overflow:hidden;\n `;\n\n card.innerHTML = `\n
\n Add External Layer\n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n WMS LAYERS parameter (e.g. workspace:layer)\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n `;\n\n this._addLayerDialog.appendChild(card);\n this.map.getTargetElement().appendChild(this._addLayerDialog);\n\n // Type radio change — toggle layer name row visibility\n const nameRow = card.querySelector('.add-layer-name-row');\n const nameHint = card.querySelector('.add-layer-name-hint');\n const urlInput = card.querySelector('.add-layer-url');\n const typeHint = card.querySelector('.add-layer-type-hint');\n card.querySelectorAll('input[name=\"add-layer-type\"]').forEach((radio) => {\n radio.addEventListener('change', () => {\n const type = radio.value;\n typeHint.textContent = type === 'cog'\n ? 'Cloud-Optimized GeoTIFF — the server must allow CORS and byte-range requests.'\n : '';\n if (type === 'cog') {\n // A COG is a single file, so there is no layer/typename to pick.\n nameRow.style.display = 'none';\n urlInput.placeholder = 'https://example.com/data/elevation_cog.tif';\n } else if (type === 'xyz') {\n nameRow.style.display = 'none';\n urlInput.placeholder = 'https://example.com/tiles/{z}/{x}/{y}.png';\n } else {\n nameRow.style.display = '';\n urlInput.placeholder = type === 'wms'\n ? 'https://example.com/wms'\n : 'https://example.com/wfs';\n nameHint.textContent = type === 'wms'\n ? 'WMS LAYERS parameter (e.g. workspace:layer)'\n : 'WFS typename (e.g. workspace:layer)';\n }\n });\n });\n\n // Close / Cancel\n const close = () => this._hideAddLayerDialog();\n card.querySelector('.add-layer-close').addEventListener('click', close);\n card.querySelector('.add-layer-cancel').addEventListener('click', close);\n this._addLayerDialog.addEventListener('click', (e) => {\n if (e.target === this._addLayerDialog) close();\n });\n\n // Confirm\n card.querySelector('.add-layer-confirm').addEventListener('click', () => {\n const type = card.querySelector('input[name=\"add-layer-type\"]:checked').value;\n const url = card.querySelector('.add-layer-url').value.trim();\n const layerName = card.querySelector('.add-layer-name').value.trim();\n const title = card.querySelector('.add-layer-title').value.trim();\n\n if (!url) {\n card.querySelector('.add-layer-url').style.borderColor = '#ef4444';\n return;\n }\n if ((type === 'wms' || type === 'wfs') && !layerName) {\n card.querySelector('.add-layer-name').style.borderColor = '#ef4444';\n return;\n }\n if (!title) {\n card.querySelector('.add-layer-title').style.borderColor = '#ef4444';\n return;\n }\n\n this._addExternalLayer(type, url, layerName, title);\n this._hideAddLayerDialog();\n });\n\n // Enter key to confirm\n card.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') {\n e.preventDefault();\n card.querySelector('.add-layer-confirm').click();\n }\n if (e.key === 'Escape') {\n e.preventDefault();\n close();\n }\n });\n }\n\n /**\n * Show the add-layer dialog.\n */\n showAddLayerDialog() {\n const dlg = this._addLayerDialog;\n // Reset form\n dlg.querySelector('.add-layer-url').value = '';\n dlg.querySelector('.add-layer-name').value = '';\n dlg.querySelector('.add-layer-title').value = '';\n dlg.querySelectorAll('input[name=\"add-layer-type\"]')[0].checked = true;\n dlg.querySelector('.add-layer-name-row').style.display = '';\n dlg.querySelector('.add-layer-url').placeholder = 'https://example.com/wms';\n dlg.querySelector('.add-layer-name-hint').textContent = 'WMS LAYERS parameter (e.g. workspace:layer)';\n const typeHintEl = dlg.querySelector('.add-layer-type-hint');\n if (typeHintEl) typeHintEl.textContent = '';\n\n // Reset border colours\n dlg.querySelectorAll('input[type=\"text\"]').forEach((inp) => {\n inp.style.borderColor = 'var(--border, #1e1a4b1f)';\n });\n\n dlg.style.display = 'flex';\n dlg.querySelector('.add-layer-url').focus();\n }\n\n /**\n * Hide the add-layer dialog.\n */\n _hideAddLayerDialog() {\n this._addLayerDialog.style.display = 'none';\n }\n\n /**\n * Add an external layer to the \"External Source\" group.\n *\n * @param {string} type 'wms' | 'wfs' | 'xyz'\n * @param {string} url Server URL\n * @param {string} layerName WMS LAYERS / WFS typename (ignored for XYZ)\n * @param {string} title Display title in layer switcher\n */\n _addExternalLayer(type, url, layerName, title) {\n const group = this._externalSourceGroup;\n if (!group) {\n showToast('Layer group \"External Source\" not found.', 'error', 4000);\n return;\n }\n\n let layer;\n\n switch (type) {\n case 'wms': {\n const wmsSrc = new TileWMS({\n url,\n params: { LAYERS: layerName, TILED: true, WIDTH: 256, HEIGHT: 256 },\n serverType: 'geoserver',\n crossOrigin: 'anonymous',\n hidpi: false,\n });\n layer = new TileLayer({\n title,\n visible: true,\n source: wmsSrc,\n });\n wmsSrc.on('tileloaderror', () => {\n showToast(`WMS \"${title}\" — tile load error. Check URL and layer name.`, 'warning', 5000);\n });\n break;\n }\n\n case 'wfs': {\n const wfsUrl = `${url}${url.includes('?') ? '&' : '?'}` +\n `service=WFS&version=1.1.0&request=GetFeature` +\n `&typename=${encodeURIComponent(layerName)}` +\n `&outputFormat=application/json&srsname=EPSG:3857`;\n\n const wfsSource = new VectorSource({\n url: wfsUrl,\n format: new GeoJSON(),\n });\n wfsSource.on('featuresloaderror', () => {\n showToast(`WFS \"${title}\" — load error. Check URL and layer name.`, 'warning', 5000);\n });\n\n layer = new VectorLayer({\n title,\n visible: true,\n source: wfsSource,\n style: new Style({\n stroke: new Stroke({ color: '#e11d48', width: 2 }),\n fill: new Fill({ color: 'rgba(225,29,72,0.15)' }),\n }),\n });\n break;\n }\n\n case 'xyz':\n layer = new TileLayer({\n title,\n visible: true,\n source: new XYZ({\n url,\n crossOrigin: 'anonymous',\n }),\n });\n layer.getSource().on('tileloaderror', () => {\n showToast(`XYZ \"${title}\" — tile load error. Check the URL template.`, 'warning', 5000);\n });\n break;\n\n case 'cog': {\n // A Cloud-Optimized GeoTIFF is a single file streamed by byte-range\n // request. addCOGLayer() lazy-loads the GeoTIFF stack, pushes the layer\n // into the group itself and reports its own failures, so we return\n // early rather than falling through to the shared push below.\n const groupTitle = group.get('title') || 'External Source';\n this.addCOGLayer(groupTitle, title, url).then((cogLayer) => {\n if (!cogLayer) return;\n cogLayer.set('removable', true);\n showToast(`Layer \"${title}\" added to External Source.`, 'success', 3000);\n console.log(`[MapView] External COG layer added: \"${title}\"`);\n });\n return;\n }\n\n default:\n showToast(`Unknown layer type: ${type}`, 'error', 4000);\n return;\n }\n\n // Tag for the LayerSwitcher chip\n layer.set('typeTag', type.toUpperCase()); // 'WMS' | 'WFS' | 'XYZ'\n layer.set('typeDescription', {\n wms: 'WMS / Raster',\n wfs: 'WFS / Vector',\n xyz: 'XYZ / Tile',\n }[type] || type.toUpperCase());\n\n // User-added external layers ARE removable — they're not part of the\n // app's built-in data model.\n layer.set('removable', true);\n\n group.getLayers().push(layer);\n showToast(`Layer \"${title}\" added to External Source.`, 'success', 3000);\n console.log(`[MapView] External ${type.toUpperCase()} layer added: \"${title}\"`);\n }\n\n // ============================================================================\n // LayerSwitcher decoration (Option A — visual refresh)\n // ============================================================================\n\n /**\n * Decorate a layer's
  • after ol-ext renders it:\n * • inject a type-tag chip next to the layer label\n * • inject the green \"+\" button on the External Source group header\n *\n * Idempotent — safe to call repeatedly; each injected element checks\n * whether it already exists in the row.\n */\n _decorateLayerListItem(layer, li) {\n // 1. Type-tag chip (e.g. WMS / XYZ / VEC) next to the layer name.\n // Inserted INSIDE the label's so it doesn't collide with the\n // label's left padding (where ol-ext draws the checkbox via ::before).\n const tag = layer.get('typeTag'); // 'WMS' | 'WFS' | 'XYZ' | 'VEC' | 'GEO' | 'BASE'\n if (tag) {\n const labelSpan = li.querySelector(':scope > .li-content > label > span');\n if (labelSpan && !labelSpan.querySelector(':scope > .ls-type-tag')) {\n const chip = document.createElement('span');\n chip.className = `ls-type-tag ls-type-tag-${String(tag).toLowerCase()}`;\n chip.textContent = String(tag);\n chip.title = `${tag} layer`;\n labelSpan.appendChild(chip);\n }\n }\n\n // 2. Replace ol-ext's bar-drawn +/- chevron with the GeoView chevron SVG.\n // The SVG uses stroke=\"currentColor\", so CSS `color` (set in\n // layerswitcher.css) tints it. Rotation handles the open/closed state.\n const btnBar = li.querySelector(':scope > .ol-layerswitcher-buttons');\n if (btnBar) {\n const chevronEl = btnBar.querySelector(':scope > .expend-layers, :scope > .collapse-layers');\n if (chevronEl && !chevronEl.querySelector(':scope > svg.ls-chevron-svg')) {\n chevronEl.innerHTML =\n '' +\n '' +\n '';\n }\n }\n\n // 3. Layer-type subtitle (\"Vector / Polygon\", \"WMS / Raster\", …) below\n // the layer name. Only rendered when layer.get('typeDescription') is\n // set. For layers that start empty and gain features later (the API\n // loaders), we listen for `change:typeDescription` and update or\n // insert the subtitle then.\n const content = li.querySelector(':scope > .li-content');\n const ensureSubtitle = () => {\n if (!content) return;\n const text = layer.get('typeDescription');\n let sub = content.querySelector(':scope > .ls-layer-subtitle');\n if (!text) {\n if (sub) sub.remove();\n return;\n }\n if (!sub) {\n sub = document.createElement('div');\n sub.className = 'ls-layer-subtitle';\n const label = content.querySelector(':scope > label');\n if (label && label.nextSibling) {\n content.insertBefore(sub, label.nextSibling);\n } else {\n content.appendChild(sub);\n }\n }\n sub.textContent = text;\n };\n ensureSubtitle();\n if (!layer._lsSubtitleHooked) {\n layer._lsSubtitleHooked = true;\n layer.on('change:typeDescription', () => {\n // The
  • may not exist any more (panel re-rendered between events)\n // — guard with a fresh lookup via the LayerSwitcher next time it draws.\n // For now we just call ensureSubtitle bound to the original li.\n ensureSubtitle();\n });\n }\n\n // 4. Per-layer Remove button — only for layers explicitly marked\n // `removable: true` (external sources, imported files, …). Built-in\n // layers (Parcels, OSM_roads, district boundary, …) are NOT removable\n // so the user can't accidentally delete them.\n if (layer.get('removable') === true && btnBar && !btnBar.querySelector(':scope > .ls-remove-btn')) {\n const removeBtn = document.createElement('button');\n removeBtn.type = 'button';\n removeBtn.className = 'ls-remove-btn';\n removeBtn.title = 'Remove this layer';\n removeBtn.setAttribute('aria-label', 'Remove layer');\n removeBtn.innerHTML =\n '' +\n '' +\n '';\n removeBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n this._removeLayer(layer);\n });\n btnBar.appendChild(removeBtn);\n }\n\n // 5b. Import-state chip on layers staged via the external-dataset import\n // flow (LUPMIS2_Import_Upload_Design.docx §3.2). The chip's text and\n // behaviour depend on `_externalImportStatus`:\n // 'mapped' → \"Upload N\" (click → upload)\n // 'uploading' → spinner (disabled)\n // 'submitted' → \"✓ submitted\" (in upload_tmp, awaiting review)\n // 'migrated' → \"✓ live\" (supervisor promoted to lu_parcels)\n // 'failed' → \"N errors — fix?\"\n // 'other'/null→ no chip\n // Clicks dispatch a window-level CustomEvent so main.js can react\n // without MapView knowing anything about staging.\n const importId = layer.get('_externalImportId');\n if (importId != null) {\n const labelSpan = li.querySelector(':scope > .li-content > label > span');\n let chip = labelSpan ? labelSpan.querySelector(':scope > .ls-import-chip') : null;\n const status = layer.get('_externalImportStatus') || 'mapped';\n const featureCount = layer.getSource()?.getFeatures().length ?? 0;\n const errorCount = layer.get('_externalImportErrorCount') ?? 0;\n\n // Decide chip appearance.\n const chipSpec = (() => {\n switch (status) {\n case 'mapped':\n return { text: `Upload ${featureCount}`, cls: 'ls-import-chip-mapped',\n title: 'Upload this dataset to the database', clickable: true };\n case 'uploading':\n return { text: '…', cls: 'ls-import-chip-uploading',\n title: 'Uploading…', clickable: false };\n case 'submitted':\n return { text: '✓ submitted', cls: 'ls-import-chip-submitted',\n title: 'Uploaded — awaiting supervisor review', clickable: false };\n case 'migrated':\n return { text: '✓ live', cls: 'ls-import-chip-migrated',\n title: 'Approved by supervisor and live on the server', clickable: false };\n case 'failed':\n return { text: `${errorCount} errors — fix?`, cls: 'ls-import-chip-failed',\n title: 'Some rows failed; click to review', clickable: true };\n case 'other':\n case null:\n case undefined:\n default:\n return null;\n }\n })();\n\n if (!chipSpec) {\n if (chip) chip.remove();\n } else if (labelSpan) {\n if (!chip) {\n chip = document.createElement('span');\n chip.className = 'ls-import-chip';\n labelSpan.appendChild(chip);\n }\n chip.className = `ls-import-chip ${chipSpec.cls}`;\n chip.textContent = chipSpec.text;\n chip.title = chipSpec.title;\n chip.style.cursor = chipSpec.clickable ? 'pointer' : 'default';\n chip.style.opacity = chipSpec.clickable ? '1' : '0.85';\n\n // Replace any prior listener by cloning the node.\n const fresh = chip.cloneNode(true);\n chip.replaceWith(fresh);\n chip = fresh;\n\n if (chipSpec.clickable) {\n chip.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n window.dispatchEvent(new CustomEvent('lupmis:import-chip-click', {\n detail: { importId, status, layer },\n }));\n });\n }\n }\n }\n\n // 5. \"+\" button on the External Source group\n const groupTitle = (layer.get('title') || '').toLowerCase();\n if (groupTitle.includes('external')) {\n this._externalSourceGroup = layer;\n // btnBar already resolved above (same .ol-layerswitcher-buttons element)\n if (btnBar && !btnBar.querySelector('.ol-add-layer')) {\n const addBtn = document.createElement('span');\n addBtn.className = 'ol-add-layer';\n addBtn.title = 'Add external layer';\n addBtn.textContent = '+';\n addBtn.style.cssText = `\n display:inline-flex !important;align-items:center;justify-content:center;\n width:22px !important;height:22px !important;border-radius:50%;\n background:#41b6a6 !important;color:#fff !important;\n font-size:15px !important;font-weight:700;\n cursor:pointer;line-height:1 !important;\n margin:0 4px 0 0;vertical-align:middle;\n transition:background 0.2s;box-sizing:border-box;border:none;\n `;\n addBtn.addEventListener('mouseenter', () => { addBtn.style.background = '#329686'; });\n addBtn.addEventListener('mouseleave', () => { addBtn.style.background = '#41b6a6'; });\n addBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n this.showAddLayerDialog();\n });\n btnBar.prepend(addBtn);\n }\n }\n }\n\n /**\n * Remove a layer from its parent group, after confirmation. Only called\n * from the per-layer × button injected by `_decorateLayerListItem` — and\n * that button is only injected for layers marked `removable: true`, so\n * built-in layers (Parcels, OSM_roads, …) can never reach this path.\n *\n * @param {Layer} layer\n */\n _removeLayer(layer) {\n const title = layer.get('title') || 'this layer';\n if (!confirm(`Remove \"${title}\" from the map?\\n\\nThis only affects the current session — built-in layers cannot be removed.`)) {\n return;\n }\n\n // Find the parent group that owns this layer and call .remove() on its\n // collection. Walk recursively from the overlay group.\n const visit = (group) => {\n const layers = group.getLayers();\n if (layers.getArray().includes(layer)) {\n layers.remove(layer);\n return true;\n }\n let removed = false;\n layers.forEach((child) => {\n if (!removed && child.getLayers) {\n removed = visit(child);\n }\n });\n return removed;\n };\n\n const ok = visit(this.overlayGroup);\n if (ok) {\n console.log(`[MapView] Removed layer \"${title}\"`);\n showToast(`Removed \"${title}\" from the map.`, 'info', 3000);\n } else {\n console.warn(`[MapView] Could not find layer \"${title}\" in any group`);\n }\n }\n\n /**\n * Inject (or refresh) the panel chrome — an \"active count\" badge at the top\n * and a footer row with \"Reset overlays\" button at the bottom.\n *\n * The chrome lives in `.panel-container` (the wrapping
    ), not inside\n * the `