pwaLUPMIS2/dist/assets/analysis-modal-05Uf_OF7.js
ekke da6f968725 Zonal-statistics Excel export, COG layer entry point, DE Africa ETL
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>
2026-07-23 16:28:45 +02:00

12 lines
15 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{_ 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 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 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[n])}export{Ge as openAnalysisModal};
//# sourceMappingURL=analysis-modal-05Uf_OF7.js.map