Excel export (from TWG feedback) - src/analysis/xlsx.js: a dependency-free XLSX writer — an .xlsx is a ZIP of XML, so this packs the required parts with a small stored-ZIP writer. Avoids SheetJS (stale npm package with advisories) and ExcelJS (heavy for an offline-first field app), and does not rely on the JSZip that only reaches us transitively via shp-write. Lazy-loaded as a ~5.6 kB chunk. - After a zonal run the Analysis panel offers "Export table (Excel)", writing a two-sheet workbook: Results (figures as real numbers) and Parameters (zone and input layers with feature counts, the "Apply to" scope, membership rule, statistics, numeric field and export time) — so a table can be verified or reproduced later rather than being an unattributed set of numbers. The button is hidden for overlay runs and cleared when the mode changes. - Verified against two independent readers: openpyxl loads it with zero warnings and correct numeric types, and LibreOffice Calc opens it as a spreadsheet. Also exercised end-to-end through the real zonal pipeline. COG raster entry point - Add External Layer gains a COG type alongside WMS/WFS/XYZ, with a URL pre-flight check that distinguishes a web page, a 404, a CORS block and a server without byte-range support — geotiff.js otherwise reports these only as an opaque "AggregateError: Request failed". Digital Earth Africa ETL - etl/deafrica_dem_to_minio.py exports a DE Africa DEM for a district as a COG and uploads it to the LUSPA MinIO bucket raster-objects, then verifies the object is anonymously readable and range-capable. Credentials come from the environment; the existing PHP integration hardcodes them and an earlier key pair reached Gitea. NOTE: not yet run against the Sandbox — start with --list-products to confirm the DEM product name. Documents - Concept note: Digital Earth Africa added as a raster source (§6.3), separating the live WMS route from the batch Sandbox export; corrected the in-house contour table's provenance to OpenTopography (gdal_contour over an SRTM 30 m / Copernicus 30 m DEM), and added the ToR §2.4.2 / FS §2 alignment chapter. - TWG presentation, architecture and two integration workflow charts (SVG sources kept in the repo so they stay editable), and a user guide for the Analyse tools. Service worker v13 -> v14. .gitignore: exclude Python bytecode from etl/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 lines
15 KiB
JavaScript
12 lines
15 KiB
JavaScript
import{_ as ae}from"./geotiff-BaoeLn6q.js";import{M as le}from"./bootstrap-D1-uvFxm.js";import{ax as ie}from"./openlayers-J9qS6Th1.js";import{i as G,f as I,a as ue,b as ce,c as de,d as pe,e as fe,g as me}from"./turf-B-0GbNlN.js";import{s as O}from"./index-Cyeghdft.js";import"./pako-Xa-UToif.js";import"./ol-ext-C5frl4dY.js";import"./shpjs-Doisr2ug.js";const X=4e5,ye=new ie;function L(e,n="EPSG:3857"){const o=[];for(const r of e||[]){if(!(r.getGeometry&&r.getGeometry()))continue;const s=ye.writeFeatureObject(r,{dataProjection:"EPSG:4326",featureProjection:n});s&&s.geometry&&o.push(s)}return o}function A(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 i=l=>{if(typeof l[0]=="number"){l[0]<o&&(o=l[0]),l[0]>a&&(a=l[0]),l[1]<r&&(r=l[1]),l[1]>s&&(s=l[1]);return}for(const u of l)i(u)};return i(n.coordinates),Number.isFinite(o)?[o,r,a,s]:null}function H(e,n){return!e||!n?!1:!(e[2]<n[0]||n[2]<e[0]||e[3]<n[1]||n[3]<e[1])}function he(e,n,o){return e?n>=e[0]&&n<=e[2]&&o>=e[1]&&o<=e[3]:!1}function z(e){return(e||[]).filter(n=>{const o=n?.geometry?.type;return o==="Polygon"||o==="MultiPolygon"})}function ge(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 S(e){return{type:"FeatureCollection",features:e}}function Y(e,n,o={}){const{keepBAttributes:r=!0}=o,a=z(L(e)),s=z(L(n)),i=s.map(A);let l=0;const u=[];for(const d of a){const m=A(d);for(let p=0;p<s.length;p++){if(!H(m,i[p]))continue;if(++l>X)throw q(l);let c=null;try{c=ce(I([d,s[p]]))}catch{c=null}!c||!c.geometry||(c.properties=r?ge(d.properties,s[p].properties):{...d.properties||{}},u.push(c))}}return S(u)}function be(e,n,o={}){return Y(e,n,{...o,keepBAttributes:!1})}function we(e,n){const o=z(L(e)),r=z(L(n)),a=r.map(A);let s=0;const i=[];for(const l of o){const u=A(l);let d=l;for(let m=0;m<r.length&&d;m++)if(H(u,a[m])){if(++s>X)throw q(s);try{const p=ue(I([d,r[m]]));d=p&&p.geometry?p:null}catch{}}d&&d.geometry&&(d.properties={...l.properties||{}},i.push(d))}return S(i)}function ve(e,n=[]){const o=z([...L(e),...L(n)]);if(o.length===0)return S([]);if(o.length===1)return S([o[0]]);let r=null;try{r=G(I(o))}catch{r=o[0];for(let a=1;a<o.length;a++)try{const s=G(I([r,o[a]]));s&&s.geometry&&(r=s)}catch{}}return!r||!r.geometry?S([]):(r.properties={parts:o.length},S([r]))}function q(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 P=[{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 xe(e,n,o){switch(e){case"intersect":return Y(n,o);case"clip":return be(n,o);case"difference":return we(n,o);case"union":return ve(n,o);default:throw new Error(`Unknown overlay operation: ${e}`)}}const Z=4e5,j=[{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)."}],T=[{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 Ee(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 Be(e){if(e==null||e==="")return null;const n=typeof e=="number"?e:Number(e);return Number.isFinite(n)?n:null}function Le({zoneFeatures:e,inputFeatures:n,stats:o=["count"],field:r=null,membership:a="centroid",prefix:s="zs_"}){const i=(L(e)||[]).filter(y=>{const E=y?.geometry?.type;return E==="Polygon"||E==="MultiPolygon"}),l=L(n)||[];if(i.length===0)throw new Error("The zone layer contains no polygons.");const u=a==="centroid"?l.map(y=>{try{return de(y)}catch{return null}}):null,d=a==="centroid"?null:l.map(A),m=i.map(A);let p=0;const c=[];for(let y=0;y<i.length;y++){const E=i[y],D=m[y],M=[];for(let f=0;f<l.length;f++){let g=!1;try{if(a==="centroid"){const x=u[f];if(!x)continue;const[re,se]=x.geometry.coordinates;if(!he(D,re,se))continue;if(++p>Z)throw V(p);g=pe(x,E)}else{if(!H(D,d[f]))continue;if(++p>Z)throw V(p);g=fe(l[f],E)}}catch{g=!1}g&&M.push(l[f])}const v=r?M.map(f=>Be(f.properties?f.properties[r]:null)).filter(f=>f!==null):[],B={};for(const f of o)switch(f){case"count":B[`${s}count`]=M.length;break;case"sum":B[`${s}sum`]=v.length?_(v.reduce((g,x)=>g+x,0)):0;break;case"mean":B[`${s}mean`]=v.length?_(v.reduce((g,x)=>g+x,0)/v.length):null;break;case"min":B[`${s}min`]=v.length?_(Math.min(...v)):null;break;case"max":B[`${s}max`]=v.length?_(Math.max(...v)):null;break;case"area":{let g=0;for(const x of M)try{g+=me(x)}catch{}B[`${s}area_m2`]=Math.round(g);break}}E.properties={...E.properties||{},...B},c.push({...ke(E),...B})}return{geojson:{type:"FeatureCollection",features:i},rows:c}}function V(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 _(e){return Math.round(e*1e3)/1e3}function ke(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 Se=9001,C="Analysis",t={};let F=null,b=null,$=[],w=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",K)),t.op.addEventListener("change",Q),t.membership.addEventListener("change",ee),t.inputLayer.addEventListener("change",J),t.scope.addEventListener("change",ne),t.btnRun.addEventListener("click",Me),t.btnExport.addEventListener("click",Ne)))}function U(e=null){return $.map((n,o)=>`<option value="${o}"${o===e?" selected":""}>${h(n.title)} (${n.count.toLocaleString()})</option>`).join("")}function Ae(){t.op.innerHTML=P.map(e=>`<option value="${e.key}">${h(e.label)}</option>`).join(""),t.membership.innerHTML=j.map(e=>`<option value="${e.key}">${h(e.label)}</option>`).join(""),t.statsBox.innerHTML=T.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}">${h(e.label)}</label>
|
||
</div>`).join("")}function J(){const e=$[Number(t.inputLayer.value)],n=e?e.layer.getSource().getFeatures():[],o=Ee(n);t.field.innerHTML='<option value="">(none — count/area only)</option>'+o.map(r=>`<option value="${h(r)}">${h(r)}</option>`).join(""),t.field.disabled=o.length===0}function R(){return(t.mode.find(e=>e.checked)||{}).value||"overlay"}function K(){k=null,oe();const e=R()==="overlay";t.overlayBox.classList.toggle("d-none",!e),t.zonalBox.classList.toggle("d-none",e),te()}function Q(){const e=P.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)",te()}function ee(){const e=j.find(n=>n.key===t.membership.value);t.memberHint.textContent=e?e.hint:""}function te(){if(t.outName.dataset.touched!=="1")if(R()==="overlay"){const e=t.op.value||"overlay";t.outName.value=`${e}_result`}else t.outName.value="zonal_stats"}function N(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=b.getMap(),s=a.getView().calculateExtent(a.getSize());return o.getFeaturesInExtent(s)}if(r==="selection"){const a=new Set(b.getSelectedFeatures());return o.getFeatures().filter(s=>a.has(s))}return r==="drawn"&&w?o.getFeatures().filter(a=>w.features.has(a)):o.getFeatures()}function ne(){const e=t.scope.value;if(e==="drawn"&&w)t.scopeHint.textContent=`${w.features.size.toLocaleString()} feature(s) caught by the ${w.label}.`;else if(e==="selection"){const n=b?b.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 ze(){let e=b.getLayerGroupByTitle(C);return e||(b.addLayerGroup(Se,C,"Results of client-side spatial analysis"),e=b.getLayerGroupByTitle(C)),e}function Me(){t.result.innerHTML="",t.btnRun.disabled=!0,setTimeout(()=>{try{const e=R(),n=(t.outName.value||"").trim()||(e==="overlay"?"overlay_result":"zonal_stats");let o,r;if(e==="overlay"){k=null;const s=t.op.value,i=N(t.layerA),l=N(t.layerB),u=(P.find(d=>d.key===s)||{}).needsB;if(i.length===0)throw new Error("Layer A has no features.");if(u&&l.length===0)throw new Error("This operation needs a second layer with features.");o=xe(s,i,l),r=`${o.features.length.toLocaleString()} feature(s) produced.`}else{const s=N(t.zoneLayer),i=N(t.inputLayer),l=Array.from(t.statsBox.querySelectorAll("input:checked")).map(c=>c.value),u=t.field.value||null;if(l.length===0)throw new Error("Select at least one statistic.");if(l.some(c=>(T.find(y=>y.key===c)||{}).needsField)&&!u)throw new Error("Sum / mean / min / max need a numeric field.");const m=Le({zoneFeatures:s,inputFeatures:i,stats:l,field:u,membership:t.membership.value});o=m.geojson,r=Ie(m.rows);const p=(j.find(c=>c.key===t.membership.value)||{}).label;k={rows:m.rows,params:{Analysis:"Zonal statistics","Zone layer":W(t.zoneLayer),"Features summarised":W(t.inputLayer),"Applied to":_e(),"Zones analysed":s.length,"Features analysed":i.length,"Counted when":p||t.membership.value,Statistics:l.map(c=>(T.find(y=>y.key===c)||{}).label||c).join(", "),"Numeric field":u||"(none)","Result layer":n}}}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=ze();b.addGeoJSONLayer(o,n,{strokeColor:"#d97706",strokeWidth:2,fillColor:"rgba(217,119,6,0.18)"},a),oe(),t.result.innerHTML=`<div class="alert alert-success py-2 px-3 mb-2">Added layer “${h(n)}”.</div>`+(typeof r=="string"&&r.startsWith("<")?r:`<div class="small text-muted">${h(r)}</div>`),O(`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">${h(e.message||String(e))}</div>`}finally{t.btnRun.disabled=!1}},20)}function W(e){const n=e.options[e.selectedIndex];return n?n.textContent:"(none)"}function _e(){const e=t.scope.options[t.scope.selectedIndex];return e?e.textContent:"Whole layer"}async function Ne(){if(k){t.btnExport.disabled=!0;try{const{exportXlsx:e}=await ae(async()=>{const{exportXlsx:l}=await import("./xlsx-d1LrY7GM.js");return{exportXlsx:l}},[]),{rows:n,params:o}=k,r=Object.keys(n[0]||{}).filter(l=>l!=="zone"),a=[{cells:["Zone",...r],bold:!0},...n.map(l=>["zone"in l?l.zone:"",...r.map(u=>l[u]===null||l[u]===void 0?"":l[u])])],s=[{cells:["LUPMIS2 — Zonal statistics"],bold:!0},[],{cells:["Parameter","Value"],bold:!0},...Object.entries(o).map(([l,u])=>[l,u]),["Exported",new Date().toLocaleString()]],i=(o["Result layer"]||"zonal_stats").toString().trim().replace(/[^A-Za-z0-9_\-]+/g,"_")||"zonal_stats";e([{name:"Parameters",rows:s},{name:"Results",rows:a}],`${i}.xlsx`),O("Table exported to Excel.","success",3e3)}catch(e){console.error("[Analysis] table export failed:",e),O(`Could not export the table: ${e.message}`,"error",6e3)}finally{t.btnExport.disabled=!1}}}function oe(){t.btnExport&&t.btnExport.classList.toggle("d-none",!k)}function Ie(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">${h(a)}</th>`).join(""),r=e.map(a=>`<tr><td class="text-nowrap">${h(String(a.zone))}</td>`+n.map(s=>`<td>${a[s]===null||a[s]===void 0?"—":h(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 Ge(e,n={}){if(b=e,$e(),w=n.scopeFeatures&&n.scopeFeatures.length?{features:new Set(n.scopeFeatures),label:n.scopeLabel||"drawn area"}:null,Oe(),$=b.listVectorLayers({nonEmptyOnly:!0}),$.length===0){O("No vector layers with features are loaded — nothing to analyse yet.","warning",5e3);return}Ae();const o=U(0),r=U($.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="",k=null,t.scope.value=w?"drawn":"all",J(),Q(),ee(),ne(),K(),F=F||new le(t.root),F.show()}function Oe(){const e=t.scope.querySelector('option[value="drawn"]');if(!w){e&&e.remove();return}const n=`From ${w.label} (${w.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 h(e){return String(e).replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n])}export{Ge as openAnalysisModal};
|
||
//# sourceMappingURL=analysis-modal-05Uf_OF7.js.map
|