pwaLUPMIS2/dist/assets/analysis-modal-CYhcFxIW.js
ekke 26c941b09a Analytical tools Stage 1 + drawing-tool fixes
Stage 1 of the GIS Analytical Tools concept — client-side spatial analysis:

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

Drawing-tool fixes carried in the same working tree:

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

Service worker v12 -> v13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:09:06 +02:00

12 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import{M as oe}from"./bootstrap-D1-uvFxm.js";import{ax as re}from"./openlayers-J9qS6Th1.js";import{i as j,f as I,a as se,b as ae,c as ie,d as le,e as ue,g as ce}from"./turf-B-0GbNlN.js";import{s as D}from"./index-BYEiD6vv.js";import"./geotiff-BaoeLn6q.js";import"./pako-Xa-UToif.js";import"./ol-ext-C5frl4dY.js";import"./shpjs-Doisr2ug.js";const Y=4e5,de=new re;function L(e,n="EPSG:3857"){const o=[];for(const r of e||[]){if(!(r.getGeometry&&r.getGeometry()))continue;const s=de.writeFeatureObject(r,{dataProjection:"EPSG:4326",featureProjection:n});s&&s.geometry&&o.push(s)}return o}function S(e){const n=e&&e.geometry;if(!n||!n.coordinates)return null;let o=1/0,r=1/0,a=-1/0,s=-1/0;const l=i=>{if(typeof i[0]=="number"){i[0]<o&&(o=i[0]),i[0]>a&&(a=i[0]),i[1]<r&&(r=i[1]),i[1]>s&&(s=i[1]);return}for(const d of i)l(d)};return l(n.coordinates),Number.isFinite(o)?[o,r,a,s]:null}function F(e,n){return!e||!n?!1:!(e[2]<n[0]||n[2]<e[0]||e[3]<n[1]||n[3]<e[1])}function fe(e,n,o){return e?n>=e[0]&&n<=e[2]&&o>=e[1]&&o<=e[3]:!1}function A(e){return(e||[]).filter(n=>{const o=n?.geometry?.type;return o==="Polygon"||o==="MultiPolygon"})}function ye(e,n,o="b_"){const r={...e||{}};for(const[a,s]of Object.entries(n||{}))r[Object.prototype.hasOwnProperty.call(r,a)?`${o}${a}`:a]=s;return r}function k(e){return{type:"FeatureCollection",features:e}}function q(e,n,o={}){const{keepBAttributes:r=!0}=o,a=A(L(e)),s=A(L(n)),l=s.map(S);let i=0;const d=[];for(const c of a){const m=S(c);for(let u=0;u<s.length;u++){if(!F(m,l[u]))continue;if(++i>Y)throw U(i);let p=null;try{p=ae(I([c,s[u]]))}catch{p=null}!p||!p.geometry||(p.properties=r?ye(c.properties,s[u].properties):{...c.properties||{}},d.push(p))}}return k(d)}function me(e,n,o={}){return q(e,n,{...o,keepBAttributes:!1})}function pe(e,n){const o=A(L(e)),r=A(L(n)),a=r.map(S);let s=0;const l=[];for(const i of o){const d=S(i);let c=i;for(let m=0;m<r.length&&c;m++)if(F(d,a[m])){if(++s>Y)throw U(s);try{const u=se(I([c,r[m]]));c=u&&u.geometry?u:null}catch{}}c&&c.geometry&&(c.properties={...i.properties||{}},l.push(c))}return k(l)}function he(e,n=[]){const o=A([...L(e),...L(n)]);if(o.length===0)return k([]);if(o.length===1)return k([o[0]]);let r=null;try{r=j(I(o))}catch{r=o[0];for(let a=1;a<o.length;a++)try{const s=j(I([r,o[a]]));s&&s.geometry&&(r=s)}catch{}}return!r||!r.geometry?k([]):(r.properties={parts:o.length},k([r]))}function U(e){return new Error(`This overlay has more than ${e.toLocaleString()} genuinely overlapping feature pairs, which is too much to run in the browser. Narrow the inputs using “Apply to” (current map view or selected features), or run it server-side.`)}const _=[{key:"intersect",label:"Intersect",needsB:!0,hint:"Keep the parts of layer A that fall inside layer B (attributes from both)."},{key:"clip",label:"Clip",needsB:!0,hint:"Cut layer A to the boundary of layer B (keeps only As attributes)."},{key:"difference",label:"Difference",needsB:!0,hint:"Keep the parts of layer A that fall outside layer B."},{key:"union",label:"Union / Dissolve",needsB:!1,hint:"Merge features into a single outline (optionally with a second layer)."}];function ge(e,n,o){switch(e){case"intersect":return q(n,o);case"clip":return me(n,o);case"difference":return pe(n,o);case"union":return he(n,o);default:throw new Error(`Unknown overlay operation: ${e}`)}}const P=4e5,V=[{key:"centroid",label:"Centroid inside zone",hint:"Each feature is counted once, in the zone containing its centre."},{key:"intersects",label:"Any overlap with zone",hint:"A feature is counted in every zone it touches (may double-count)."}],W=[{key:"count",label:"Count",needsField:!1},{key:"sum",label:"Sum",needsField:!0},{key:"mean",label:"Mean",needsField:!0},{key:"min",label:"Minimum",needsField:!0},{key:"max",label:"Maximum",needsField:!0},{key:"area",label:"Total area (m²)",needsField:!1}];function be(e){const n=new Map;for(const o of e||[]){const r=o.getProperties?o.getProperties():{};for(const[a,s]of Object.entries(r))a==="geometry"||a.startsWith("_")||(typeof s=="number"?s:s!==""&&s!=null&&!Number.isNaN(Number(s))?Number(s):null)===null||n.set(a,(n.get(a)||0)+1)}return Array.from(n.keys()).sort()}function ve(e){if(e==null||e==="")return null;const n=typeof e=="number"?e:Number(e);return Number.isFinite(n)?n:null}function we({zoneFeatures:e,inputFeatures:n,stats:o=["count"],field:r=null,membership:a="centroid",prefix:s="zs_"}){const l=(L(e)||[]).filter(B=>{const E=B?.geometry?.type;return E==="Polygon"||E==="MultiPolygon"}),i=L(n)||[];if(l.length===0)throw new Error("The zone layer contains no polygons.");const d=a==="centroid"?i.map(B=>{try{return ie(B)}catch{return null}}):null,c=a==="centroid"?null:i.map(S),m=l.map(S);let u=0;const p=[];for(let B=0;B<l.length;B++){const E=l[B],H=m[B],M=[];for(let f=0;f<i.length;f++){let h=!1;try{if(a==="centroid"){const w=d[f];if(!w)continue;const[te,ne]=w.geometry.coordinates;if(!fe(H,te,ne))continue;if(++u>P)throw G(u);h=le(w,E)}else{if(!F(H,c[f]))continue;if(++u>P)throw G(u);h=ue(i[f],E)}}catch{h=!1}h&&M.push(i[f])}const v=r?M.map(f=>ve(f.properties?f.properties[r]:null)).filter(f=>f!==null):[],x={};for(const f of o)switch(f){case"count":x[`${s}count`]=M.length;break;case"sum":x[`${s}sum`]=v.length?N(v.reduce((h,w)=>h+w,0)):0;break;case"mean":x[`${s}mean`]=v.length?N(v.reduce((h,w)=>h+w,0)/v.length):null;break;case"min":x[`${s}min`]=v.length?N(Math.min(...v)):null;break;case"max":x[`${s}max`]=v.length?N(Math.max(...v)):null;break;case"area":{let h=0;for(const w of M)try{h+=ce(w)}catch{}x[`${s}area_m2`]=Math.round(h);break}}E.properties={...E.properties||{},...x},p.push({...Be(E),...x})}return{geojson:{type:"FeatureCollection",features:l},rows:p}}function G(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 N(e){return Math.round(e*1e3)/1e3}function Be(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 Ee=9001,O="Analysis",t={};let C=null,g=null,$=[],b=null;function xe(){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.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",J)),t.op.addEventListener("change",Z),t.membership.addEventListener("change",K),t.inputLayer.addEventListener("change",X),t.scope.addEventListener("change",ee),t.btnRun.addEventListener("click",$e)))}function R(e=null){return $.map((n,o)=>`<option value="${o}"${o===e?" selected":""}>${y(n.title)} (${n.count.toLocaleString()})</option>`).join("")}function Le(){t.op.innerHTML=_.map(e=>`<option value="${e.key}">${y(e.label)}</option>`).join(""),t.membership.innerHTML=V.map(e=>`<option value="${e.key}">${y(e.label)}</option>`).join(""),t.statsBox.innerHTML=W.map(e=>`
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" value="${e.key}"
id="analysis-stat-${e.key}"${e.key==="count"?" checked":""}>
<label class="form-check-label" for="analysis-stat-${e.key}">${y(e.label)}</label>
</div>`).join("")}function X(){const e=$[Number(t.inputLayer.value)],n=e?e.layer.getSource().getFeatures():[],o=be(n);t.field.innerHTML='<option value="">(none — count/area only)</option>'+o.map(r=>`<option value="${y(r)}">${y(r)}</option>`).join(""),t.field.disabled=o.length===0}function T(){return(t.mode.find(e=>e.checked)||{}).value||"overlay"}function J(){const e=T()==="overlay";t.overlayBox.classList.toggle("d-none",!e),t.zonalBox.classList.toggle("d-none",e),Q()}function Z(){const e=_.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)",Q()}function K(){const e=V.find(n=>n.key===t.membership.value);t.memberHint.textContent=e?e.hint:""}function Q(){if(t.outName.dataset.touched!=="1")if(T()==="overlay"){const e=t.op.value||"overlay";t.outName.value=`${e}_result`}else t.outName.value="zonal_stats"}function z(e){const n=$[Number(e.value)];if(!n)return[];const o=n.layer.getSource(),r=t.scope?t.scope.value:"all";if(r==="view"){const a=g.getMap(),s=a.getView().calculateExtent(a.getSize());return o.getFeaturesInExtent(s)}if(r==="selection"){const a=new Set(g.getSelectedFeatures());return o.getFeatures().filter(s=>a.has(s))}return r==="drawn"&&b?o.getFeatures().filter(a=>b.features.has(a)):o.getFeatures()}function ee(){const e=t.scope.value;if(e==="drawn"&&b)t.scopeHint.textContent=`${b.features.size.toLocaleString()} feature(s) caught by the ${b.label}.`;else if(e==="selection"){const n=g?g.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 ke(){let e=g.getLayerGroupByTitle(O);return e||(g.addLayerGroup(Ee,O,"Results of client-side spatial analysis"),e=g.getLayerGroupByTitle(O)),e}function $e(){t.result.innerHTML="",t.btnRun.disabled=!0,setTimeout(()=>{try{const e=T(),n=(t.outName.value||"").trim()||(e==="overlay"?"overlay_result":"zonal_stats");let o,r;if(e==="overlay"){const s=t.op.value,l=z(t.layerA),i=z(t.layerB),d=(_.find(c=>c.key===s)||{}).needsB;if(l.length===0)throw new Error("Layer A has no features.");if(d&&i.length===0)throw new Error("This operation needs a second layer with features.");o=ge(s,l,i),r=`${o.features.length.toLocaleString()} feature(s) produced.`}else{const s=z(t.zoneLayer),l=z(t.inputLayer),i=Array.from(t.statsBox.querySelectorAll("input:checked")).map(u=>u.value),d=t.field.value||null;if(i.length===0)throw new Error("Select at least one statistic.");if(i.some(u=>(W.find(p=>p.key===u)||{}).needsField)&&!d)throw new Error("Sum / mean / min / max need a numeric field.");const m=we({zoneFeatures:s,inputFeatures:l,stats:i,field:d,membership:t.membership.value});o=m.geojson,r=Se(m.rows)}if(!o.features.length){t.result.innerHTML='<div class="alert alert-warning py-2 px-3 mb-0">No result features — the layers may not overlap.</div>';return}const a=ke();g.addGeoJSONLayer(o,n,{strokeColor:"#d97706",strokeWidth:2,fillColor:"rgba(217,119,6,0.18)"},a),t.result.innerHTML=`<div class="alert alert-success py-2 px-3 mb-2">Added layer “${y(n)}”.</div>`+(typeof r=="string"&&r.startsWith("<")?r:`<div class="small text-muted">${y(r)}</div>`),D(`Analysis complete — “${n}” added to the Analysis group.`,"success",4e3)}catch(e){console.error("[Analysis] run failed:",e),t.result.innerHTML=`<div class="alert alert-danger py-2 px-3 mb-0">${y(e.message||String(e))}</div>`}finally{t.btnRun.disabled=!1}},20)}function Se(e){if(!e||!e.length)return"";const n=Object.keys(e[0]).filter(a=>a!=="zone"),o=["Zone",...n.map(a=>a.replace(/^zs_/,""))].map(a=>`<th class="text-nowrap">${y(a)}</th>`).join(""),r=e.map(a=>`<tr><td class="text-nowrap">${y(String(a.zone))}</td>`+n.map(s=>`<td>${a[s]===null||a[s]===void 0?"—":y(String(a[s]))}</td>`).join("")+"</tr>").join("");return`<div class="table-responsive" style="max-height:220px;overflow:auto;">
<table class="table table-sm table-striped mb-0" style="font-size:0.85rem;">
<thead class="table-light" style="position:sticky;top:0;"><tr>${o}</tr></thead>
<tbody>${r}</tbody>
</table></div>`}function Te(e,n={}){if(g=e,xe(),b=n.scopeFeatures&&n.scopeFeatures.length?{features:new Set(n.scopeFeatures),label:n.scopeLabel||"drawn area"}:null,Ae(),$=g.listVectorLayers({nonEmptyOnly:!0}),$.length===0){D("No vector layers with features are loaded — nothing to analyse yet.","warning",5e3);return}Le();const o=R(0),r=R($.length>1?1:0);t.layerA.innerHTML=o,t.layerB.innerHTML=r,t.zoneLayer.innerHTML=o,t.inputLayer.innerHTML=r,t.outName.dataset.touched="",t.outName.addEventListener("input",()=>{t.outName.dataset.touched="1"},{once:!0}),t.result.innerHTML="",t.scope.value=b?"drawn":"all",X(),Z(),K(),ee(),J(),C=C||new oe(t.root),C.show()}function Ae(){const e=t.scope.querySelector('option[value="drawn"]');if(!b){e&&e.remove();return}const n=`From ${b.label} (${b.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 y(e){return String(e).replace(/[&<>"']/g,n=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[n])}export{Te as openAnalysisModal};
//# sourceMappingURL=analysis-modal-CYhcFxIW.js.map