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>
12 lines
14 KiB
JavaScript
12 lines
14 KiB
JavaScript
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 A’s 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n])}export{Te as openAnalysisModal};
|
||
//# sourceMappingURL=analysis-modal-CYhcFxIW.js.map
|