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>
This commit is contained in:
ekke 2026-07-23 16:28:45 +02:00
parent 26c941b09a
commit da6f968725
25 changed files with 1117 additions and 71 deletions

4
.gitignore vendored
View File

@ -105,3 +105,7 @@ coverage/
# Microsoft Office lock / owner files (e.g. ~$Document.docx)
~$*
# Python bytecode (etl/ scripts)
__pycache__/
*.pyc

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,67 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1680" height="960" viewBox="0 0 1680 960" font-family="Calibri, Arial, sans-serif">
<!-- Figure 1 of LUPMIS2_Analytical_Tools_Concept.docx, also slide 4 of the TWG deck.
Rendered at 1680 px across ~10.6 in of slide (~158 px/in), so 44 px is about 20 pt.
Labels are deliberately short so the diagram stays legible when projected.
Re-render: rsvg-convert -w 1680 -h 960 this.svg -o out.png -->
<defs>
<marker id="arr" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#1E1A4B"/></marker>
<marker id="arrG" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#10B981"/></marker>
</defs>
<rect width="1680" height="960" fill="#FFFFFF"/>
<text x="60" y="62" font-size="52" font-weight="bold" fill="#1E1A4B">Hybrid architecture</text>
<text x="60" y="112" font-size="36" fill="#6B7280">Light work in the browser · heavy work on the server</text>
<!-- DATA SOURCES -->
<rect x="60" y="146" width="1560" height="92" rx="12" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="90" y="198" font-size="40" font-weight="bold" fill="#1E1A4B">Sources</text>
<text x="290" y="198" font-size="40" fill="#333333">upload · WMS · Earth Engine · DE Africa · PostGIS</text>
<line x1="840" y1="238" x2="840" y2="286" stroke="#1E1A4B" stroke-width="4" marker-end="url(#arr)"/>
<!-- CLIENT -->
<rect x="60" y="286" width="1560" height="286" rx="16" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="90" y="344" font-size="46" font-weight="bold" fill="#1E1A4B">CLIENT — LUPMIS2 PWA (offline-capable)</text>
<rect x="90" y="372" width="480" height="172" rx="12" fill="#FFFFFF" stroke="#C7D2FE" stroke-width="2"/>
<text x="118" y="426" font-size="44" font-weight="bold" fill="#1E1A4B">Map &amp; drawing</text>
<text x="118" y="476" font-size="40" fill="#333333">Analysis panel</text>
<text x="118" y="522" font-size="40" fill="#333333">Job progress</text>
<rect x="600" y="372" width="480" height="172" rx="12" fill="#FFFFFF" stroke="#8FE3C2" stroke-width="3"/>
<text x="628" y="426" font-size="44" font-weight="bold" fill="#0E9F6E">Vector analysis</text>
<text x="628" y="476" font-size="40" fill="#333333">Overlays · zonal</text>
<text x="628" y="522" font-size="40" fill="#333333">Works offline</text>
<rect x="1110" y="372" width="480" height="172" rx="12" fill="#FFFFFF" stroke="#C7D2FE" stroke-width="2"/>
<text x="1138" y="426" font-size="44" font-weight="bold" fill="#1E1A4B">Raster display</text>
<text x="1138" y="476" font-size="40" fill="#333333">COG via WebGL</text>
<text x="1138" y="522" font-size="40" fill="#333333">+ WMS layers</text>
<!-- ARROWS -->
<line x1="560" y1="572" x2="560" y2="646" stroke="#1E1A4B" stroke-width="4" marker-end="url(#arr)"/>
<text x="596" y="618" font-size="36" font-weight="bold" fill="#1E1A4B">Request →</text>
<line x1="1150" y1="646" x2="1150" y2="572" stroke="#10B981" stroke-width="4" marker-end="url(#arrG)"/>
<text x="1114" y="618" font-size="36" font-weight="bold" fill="#0E9F6E" text-anchor="end">← Result</text>
<!-- SERVER -->
<rect x="60" y="646" width="1560" height="252" rx="16" fill="#1E1A4B"/>
<text x="90" y="706" font-size="46" font-weight="bold" fill="#FFFFFF">SERVER — geoprocessing jobs</text>
<rect x="90" y="732" width="480" height="140" rx="12" fill="#2A2660"/>
<text x="118" y="786" font-size="42" font-weight="bold" fill="#8FE3C2">PostGIS + Raster</text>
<text x="118" y="836" font-size="38" fill="#CADCFC">Overlays · zonal</text>
<rect x="600" y="732" width="480" height="140" rx="12" fill="#2A2660"/>
<text x="628" y="786" font-size="42" font-weight="bold" fill="#FFB65C">pgRouting · GDAL</text>
<text x="628" y="836" font-size="38" fill="#CADCFC">Routing · surfaces</text>
<rect x="1110" y="732" width="480" height="140" rx="12" fill="#2A2660"/>
<text x="1138" y="786" font-size="42" font-weight="bold" fill="#FF8A80">WhiteboxTools</text>
<text x="1138" y="836" font-size="38" fill="#CADCFC">Hydrology · flood</text>
<text x="60" y="944" font-size="32" fill="#6B7280">Stage 1 low maintenance · Stage 2 medium · Stage 3 specialist skills</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1680" height="1010" viewBox="0 0 1680 1010" font-family="Calibri, Arial, sans-serif">
<!-- Sized for projection: 1680 px across ~11.7 in (~144 px/in), so
40 px = 20 pt. Labels kept terse for legibility from the back of a room. -->
<defs>
<marker id="b1" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#1E1A4B"/></marker>
<marker id="b2" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#16A34A"/></marker>
</defs>
<rect width="1680" height="1010" fill="#FFFFFF"/>
<text x="60" y="66" font-size="60" font-weight="bold" fill="#1E1A4B">Digital Earth Africa integration</text>
<text x="60" y="118" font-size="36" fill="#6B7280">One route is live today; the other is a batch step</text>
<!-- CATALOGUE -->
<rect x="60" y="150" width="1560" height="132" rx="14" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="90" y="205" font-size="46" font-weight="bold" fill="#1E1A4B">Digital Earth Africa · open and free</text>
<text x="90" y="255" font-size="40" fill="#333333">DEM · slope · imagery · land cover · water · rainfall</text>
<line x1="420" y1="282" x2="420" y2="332" stroke="#16A34A" stroke-width="4" marker-end="url(#b2)"/>
<line x1="1240" y1="282" x2="1240" y2="332" stroke="#1E1A4B" stroke-width="4" marker-end="url(#b1)"/>
<!-- LANE 1 — live WMS -->
<rect x="60" y="332" width="720" height="452" rx="14" fill="#FFFFFF" stroke="#16A34A" stroke-width="3"/>
<rect x="60" y="332" width="720" height="66" rx="14" fill="#16A34A"/>
<text x="90" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">1 — Live WMS</text>
<rect x="92" y="418" width="656" height="78" rx="10" fill="#F0FDF4" stroke="#86EFAC" stroke-width="2"/>
<text x="120" y="470" font-size="40" fill="#166534">DE Africa web service</text>
<line x1="420" y1="496" x2="420" y2="522" stroke="#16A34A" stroke-width="3" marker-end="url(#b2)"/>
<rect x="92" y="522" width="656" height="78" rx="10" fill="#F0FDF4" stroke="#86EFAC" stroke-width="2"/>
<text x="120" y="574" font-size="40" fill="#166534">Added as a map layer</text>
<rect x="92" y="626" width="656" height="78" rx="10" fill="#DCFCE7" stroke="#16A34A" stroke-width="3"/>
<text x="120" y="678" font-size="40" font-weight="bold" fill="#166534">✓ Live in LUPMIS2 today</text>
<text x="120" y="748" font-size="32" fill="#991B1B">A picture only — no pixel values</text>
<!-- LANE 2 — batch ETL -->
<rect x="820" y="332" width="800" height="452" rx="14" fill="#FFFFFF" stroke="#1E1A4B" stroke-width="3"/>
<rect x="820" y="332" width="800" height="66" rx="14" fill="#1E1A4B"/>
<text x="850" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">2 — Batch export</text>
<rect x="852" y="418" width="736" height="70" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="880" y="465" font-size="38" fill="#1E1A4B">1. Load in the Sandbox</text>
<line x1="1220" y1="488" x2="1220" y2="510" stroke="#1E1A4B" stroke-width="3" marker-end="url(#b1)"/>
<rect x="852" y="510" width="736" height="70" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="880" y="557" font-size="38" fill="#1E1A4B">2. write_cog()</text>
<line x1="1220" y1="580" x2="1220" y2="602" stroke="#1E1A4B" stroke-width="3" marker-end="url(#b1)"/>
<rect x="852" y="602" width="736" height="70" rx="10" fill="#E7F6EF" stroke="#16A34A" stroke-width="3"/>
<text x="880" y="649" font-size="38" font-weight="bold" fill="#0E7A50">3. Upload to MinIO</text>
<line x1="1220" y1="672" x2="1220" y2="694" stroke="#1E1A4B" stroke-width="3" marker-end="url(#b1)"/>
<rect x="852" y="694" width="736" height="70" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="880" y="741" font-size="38" fill="#1E1A4B">4. Load as COG</text>
<!-- CONVERGE -->
<line x1="420" y1="784" x2="420" y2="818" stroke="#16A34A" stroke-width="4"/>
<line x1="1220" y1="784" x2="1220" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="420" y1="818" x2="1220" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="820" y1="818" x2="820" y2="852" stroke="#16A34A" stroke-width="5" marker-end="url(#b2)"/>
<rect x="310" y="852" width="1020" height="106" rx="14" fill="#1E1A4B"/>
<text x="346" y="905" font-size="46" font-weight="bold" fill="#FFFFFF">LUPMIS2 PWA</text>
<text x="346" y="943" font-size="34" fill="#CADCFC">WMS to view · COG to view and analyse</text>
<text x="60" y="995" font-size="30" fill="#166534" font-weight="bold">Why:</text>
<text x="146" y="995" font-size="30" fill="#6B7280">open, African, no commercial licence question · Sandbox needs no local setup</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1680" height="1010" viewBox="0 0 1680 1010" font-family="Calibri, Arial, sans-serif">
<!-- Sized for projection: rendered at 1680 px across ~11.7 in of slide
(~144 px/in), so 40 px = 20 pt, 48 px = 24 pt, 60 px = 30 pt.
Labels are deliberately terse — detail belongs in the spoken commentary. -->
<defs>
<marker id="a1" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#1E1A4B"/></marker>
<marker id="a2" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#16A34A"/></marker>
<marker id="a3" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#D97706"/></marker>
</defs>
<rect width="1680" height="1010" fill="#FFFFFF"/>
<text x="60" y="66" font-size="60" font-weight="bold" fill="#1E1A4B">Google Earth Engine integration</text>
<text x="60" y="118" font-size="36" fill="#6B7280">A cloud catalogue that can also compute</text>
<!-- SOURCE -->
<rect x="60" y="150" width="1560" height="132" rx="14" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="90" y="205" font-size="46" font-weight="bold" fill="#1E1A4B">Earth Engine · LUSPA service account</text>
<text x="90" y="255" font-size="40" fill="#333333">DEM · imagery · land cover · water · rainfall</text>
<line x1="440" y1="282" x2="440" y2="332" stroke="#1E1A4B" stroke-width="4" marker-end="url(#a1)"/>
<line x1="1240" y1="282" x2="1240" y2="332" stroke="#D97706" stroke-width="4" marker-end="url(#a3)"/>
<!-- LANE A -->
<rect x="60" y="332" width="760" height="452" rx="14" fill="#FFFFFF" stroke="#1E1A4B" stroke-width="3"/>
<rect x="60" y="332" width="760" height="66" rx="14" fill="#1E1A4B"/>
<text x="90" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">A — Data source</text>
<rect x="92" y="418" width="696" height="78" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="120" y="470" font-size="40" fill="#1E1A4B">1. Clip to district</text>
<line x1="440" y1="496" x2="440" y2="522" stroke="#1E1A4B" stroke-width="3" marker-end="url(#a1)"/>
<rect x="92" y="522" width="696" height="78" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="120" y="574" font-size="40" fill="#1E1A4B">2. Export as COG</text>
<line x1="440" y1="600" x2="440" y2="626" stroke="#1E1A4B" stroke-width="3" marker-end="url(#a1)"/>
<rect x="92" y="626" width="696" height="78" rx="10" fill="#E7F6EF" stroke="#16A34A" stroke-width="3"/>
<text x="120" y="678" font-size="40" font-weight="bold" fill="#0E7A50">3. Publish to MinIO</text>
<text x="120" y="748" font-size="32" fill="#6B7280">Or stream tiles directly</text>
<!-- LANE B -->
<rect x="860" y="332" width="760" height="452" rx="14" fill="#FFFFFF" stroke="#D97706" stroke-width="3"/>
<rect x="860" y="332" width="760" height="66" rx="14" fill="#D97706"/>
<text x="890" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">B — Compute offload</text>
<rect x="892" y="418" width="696" height="78" rx="10" fill="#FFF7ED" stroke="#FDBA74" stroke-width="2"/>
<text x="920" y="470" font-size="40" fill="#9A3412">1. Send the operation</text>
<line x1="1240" y1="496" x2="1240" y2="522" stroke="#D97706" stroke-width="3" marker-end="url(#a3)"/>
<rect x="892" y="522" width="696" height="78" rx="10" fill="#FFF7ED" stroke="#FDBA74" stroke-width="2"/>
<text x="920" y="574" font-size="40" fill="#9A3412">2. Google computes it</text>
<line x1="1240" y1="600" x2="1240" y2="626" stroke="#D97706" stroke-width="3" marker-end="url(#a3)"/>
<rect x="892" y="626" width="696" height="78" rx="10" fill="#FFF7ED" stroke="#FDBA74" stroke-width="2"/>
<text x="920" y="678" font-size="40" fill="#9A3412">3. Return result only</text>
<text x="920" y="748" font-size="32" font-weight="bold" fill="#166534">Less load on our server</text>
<!-- CONVERGE -->
<line x1="440" y1="784" x2="440" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="1240" y1="784" x2="1240" y2="818" stroke="#D97706" stroke-width="4"/>
<line x1="440" y1="818" x2="1240" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="840" y1="818" x2="840" y2="852" stroke="#16A34A" stroke-width="5" marker-end="url(#a2)"/>
<rect x="330" y="852" width="1020" height="106" rx="14" fill="#1E1A4B"/>
<text x="366" y="905" font-size="46" font-weight="bold" fill="#FFFFFF">LUPMIS2 PWA</text>
<text x="366" y="943" font-size="34" fill="#CADCFC">Display · analysis · cached offline</text>
<text x="60" y="995" font-size="30" fill="#DC2626" font-weight="bold">Needs:</text>
<text x="172" y="995" font-size="30" fill="#6B7280">connectivity · Google quotas · licence check for government use</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

11
dist/assets/analysis-modal-05Uf_OF7.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-Cyeghdft.js.map vendored Normal file

File diff suppressed because one or more lines are too long

30
dist/assets/xlsx-d1LrY7GM.js vendored Normal file
View File

@ -0,0 +1,30 @@
function u(n){return String(n).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function g(n){let o="",e=n;for(;e>=0;)o=String.fromCharCode(e%26+65)+o,e=Math.floor(e/26)-1;return o}function F(n){return n&&!Array.isArray(n)&&Array.isArray(n.cells)?{cells:n.cells,bold:!!n.bold}:{cells:Array.isArray(n)?n:[n],bold:!1}}function b(n){const o=[];return n.forEach((e,r)=>{const{cells:l,bold:c}=F(e),i=c?' s="1"':"",m=l.map((t,a)=>{const f=`${g(a)}${r+1}`;return t==null||t===""?`<c r="${f}"${i}/>`:typeof t=="number"&&Number.isFinite(t)?`<c r="${f}"${i}><v>${t}</v></c>`:`<c r="${f}"${i} t="inlineStr"><is><t xml:space="preserve">${u(t)}</t></is></c>`}).join("");o.push(`<row r="${r+1}">${m}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${o.join("")}</sheetData></worksheet>`}const k=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`,T=(()=>{const n=new Uint32Array(256);for(let o=0;o<256;o++){let e=o;for(let r=0;r<8;r++)e=e&1?3988292384^e>>>1:e>>>1;n[o]=e>>>0}return n})();function I(n){let o=4294967295;for(let e=0;e<n.length;e++)o=T[(o^n[e])&255]^o>>>8;return(o^4294967295)>>>0}function $(n){const o=new TextEncoder,e=Object.entries(n).map(([s,p])=>({name:s,nameBytes:o.encode(s),data:o.encode(p)})),r=0,l=20513,c=[],i=[];let m=0;const t=s=>[s&255,s>>>8&255],a=s=>[s&255,s>>>8&255,s>>>16&255,s>>>24&255];for(const s of e){const p=I(s.data),d=s.data.length,x=[...a(67324752),...t(20),...t(0),...t(0),...t(r),...t(l),...a(p),...a(d),...a(d),...t(s.nameBytes.length),...t(0)];c.push(new Uint8Array(x),s.nameBytes,s.data),i.push([...a(33639248),...t(20),...t(20),...t(0),...t(0),...t(r),...t(l),...a(p),...a(d),...a(d),...t(s.nameBytes.length),...t(0),...t(0),...t(0),...t(0),...a(0),...a(m)]),i.push(s.nameBytes),m+=x.length+s.nameBytes.length+d}const f=[];let h=0;for(const s of i){const p=s instanceof Uint8Array?s:new Uint8Array(s);f.push(p),h+=p.length}const y=new Uint8Array([...a(101010256),...t(0),...t(0),...t(e.length),...t(e.length),...a(h),...a(m),...t(0)]);return new Blob([...c,...f,y],{type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"})}function w(n){const o=(n||[]).filter(l=>l&&Array.isArray(l.rows));if(o.length===0)throw new Error("No sheets to export.");const e={},r=o.map((l,c)=>{const i=`xl/worksheets/sheet${c+1}.xml`;e[i]=b(l.rows);const m=String(l.name||`Sheet${c+1}`).replace(/[:\\/?*[\]]/g," ").slice(0,31);return{id:c+1,name:m,file:i}});return e["[Content_Types].xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${r.map(l=>`<Override PartName="/${l.file}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join(`
`)}
</Types>`,e["_rels/.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`,e["xl/workbook.xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${r.map(l=>`<sheet name="${u(l.name)}" sheetId="${l.id}" r:id="rId${l.id}"/>`).join("")}</sheets>
</workbook>`,e["xl/_rels/workbook.xml.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${r.map(l=>`<Relationship Id="rId${l.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${l.id}.xml"/>`).join(`
`)}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,e["xl/styles.xml"]=k,$(e)}function S(n,o){const e=URL.createObjectURL(n),r=document.createElement("a");r.href=e,r.download=o,document.body.appendChild(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(e),1e3)}function U(n,o){S(w(n),o)}export{w as buildXlsxBlob,S as downloadBlob,U as exportXlsx};
//# sourceMappingURL=xlsx-d1LrY7GM.js.map

1
dist/assets/xlsx-d1LrY7GM.js.map vendored Normal file

File diff suppressed because one or more lines are too long

7
dist/index.html vendored
View File

@ -1601,7 +1601,7 @@
}
}
</style>
<script type="module" crossorigin src="/assets/index-BYEiD6vv.js"></script>
<script type="module" crossorigin src="/assets/index-Cyeghdft.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-J9qS6Th1.js">
<link rel="modulepreload" crossorigin href="/assets/pako-Xa-UToif.js">
<link rel="modulepreload" crossorigin href="/assets/geotiff-BaoeLn6q.js">
@ -1986,6 +1986,11 @@
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Close</button>
<!-- Shown only after a zonal run — exports the results table plus the
parameters that produced it, as a two-sheet Excel workbook. -->
<button type="button" class="btn btn-outline-primary d-none" id="analysis-export-table">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export table (Excel)
</button>
<button type="button" class="btn btn-primary" id="analysis-run">
<i class="bi bi-play-fill me-1"></i>Run analysis
</button>

6
dist/sw.js vendored
View File

@ -68,7 +68,11 @@
// Raster display gains Cloud-Optimized GeoTIFF support (MapView.addCOGLayer).
// Turf, the analysis panel, geotiff.js and pako are all code-split and
// fetched on first use, so the eager bundle is unchanged.
const CACHE_VERSION = 'v13';
// v14: Zonal statistics results can be exported as a two-sheet Excel workbook
// (Results + the Parameters that produced them) via a dependency-free
// XLSX writer in src/analysis/xlsx.js; Add External Layer gains a COG
// option with a URL pre-flight check.
const CACHE_VERSION = 'v14';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

83
etl/README.md Normal file
View File

@ -0,0 +1,83 @@
# LUPMIS2 raster ETL
One-off batch jobs that prepare raster data for LUPMIS2 and publish it to the
LUSPA MinIO object store, from where the PWA streams it as a Cloud-Optimized
GeoTIFF (COG).
This mirrors the existing OpenTopography contour workflow: a **preparation
step run occasionally**, not a live connection from the app.
```
DE Africa (Sandbox) → write_cog() → MinIO raster-objects → LUPMIS2 map
```
## Target infrastructure
| | |
|---|---|
| S3 API endpoint | `https://minioapi.lupmis4luspa.org` (path-style) |
| Bucket | `raster-objects` (anonymous read enabled) |
| Console | `https://minio.lupmis4luspa.org` — admin UI only, **not** the API |
Verified working for COG streaming: anonymous unsigned `GET` returns 200,
range requests return `206 Partial Content`, and CORS allows the PWA origin
with `Range` on preflight and `Content-Range` exposed.
## Credentials
Supplied via environment variables — **never** hardcode them:
```bash
export MINIO_KEY=...
export MINIO_SECRET=...
```
The PHP integration under `FromKwesi/minio-uploads` embeds its keys in source,
and an earlier key pair was committed to Gitea. Keep these out of git.
## `deafrica_dem_to_minio.py`
Exports a Digital Earth Africa DEM for an area of interest and uploads it.
Run it in the **DE Africa Sandbox** — the Open Data Cube is configured there.
Locally, DE Africa's docs note that `dc.load` / `load_ard` need additional
configuration (a database), so the Sandbox is the lower-maintenance choice.
```bash
# 1. confirm which DEM products exist before the first real run
python deafrica_dem_to_minio.py --list-products
# 2. export + upload a pilot district
python deafrica_dem_to_minio.py --district koforidua
# preferred for production: clip to the authoritative boundary
python deafrica_dem_to_minio.py --geojson koforidua.geojson --name koforidua
# inspect the output without uploading
python deafrica_dem_to_minio.py --district tamale --dry-run
```
The script writes `<name>_dem.tif`, uploads it to `dem/<name>_dem.tif`, then
verifies the public URL really is anonymously readable and range-capable. It
also emits `<name>_dem.manifest.json` recording the URL, product, bbox, CRS and
resolution.
### Loading the result in LUPMIS2
**Add External Layer → COG →** paste the object URL, e.g.
```
https://minioapi.lupmis4luspa.org/raster-objects/dem/koforidua_dem.tif
```
### Notes and caveats
- **Preset district bounding boxes are approximate.** Use `--geojson` with the
real district boundary from PostGIS for anything beyond a first test.
- **Output CRS defaults to EPSG:3857** so the browser does no reprojection.
For Stage 3 hydrology and slope, re-export in a metric CRS (UTM 30N =
`EPSG:32630` west, 31N = `EPSG:32631` east) — Web Mercator distorts distance
with latitude and will bias slope and flow-accumulation results.
- **Verify the product name** with `--list-products` first. The default is
`dem_srtm`; DE Africa also publishes derivatives (`dem_srtm_deriv`), which is
the source of the slope WMS layer LUPMIS2 already consumes.

View File

@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
deafrica_dem_to_minio.py
========================
One-off ETL: Digital Earth Africa DEM Cloud-Optimized GeoTIFF LUSPA MinIO.
This is Stage 1 of the LUPMIS2 GIS Analytical Tools concept: get a real raster
into the object store so the PWA can display it via MapView.addCOGLayer().
Designed to run inside the **DE Africa Sandbox** (which has the Open Data Cube
already configured). It can also run locally, but note DE Africa's own docs:
locally, `dc.load` / `load_ard` need extra configuration (a database) use the
Sandbox unless exports become routine.
# 0. see which DEM products actually exist (do this first)
python deafrica_dem_to_minio.py --list-products
# 1. export + upload for a pilot district
export MINIO_KEY=... # never hardcode these
export MINIO_SECRET=...
python deafrica_dem_to_minio.py --district koforidua
# or an explicit area / precise boundary
python deafrica_dem_to_minio.py --bbox -0.35 6.00 -0.15 6.20 --name koforidua
python deafrica_dem_to_minio.py --geojson district.geojson --name koforidua
Credentials come from the environment (MINIO_KEY / MINIO_SECRET) on purpose:
the existing PHP integration hardcodes them, and an earlier key pair ended up
committed to Gitea. Keep them out of this file and out of git.
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
MINIO_ENDPOINT = "https://minioapi.lupmis4luspa.org"
MINIO_REGION = "gh-greater-accra-luspa"
BUCKET = "raster-objects"
KEY_PREFIX = "dem" # objects land at dem/<name>_dem.tif
# Approximate bounding boxes (EPSG:4326: min_lon, min_lat, max_lon, max_lat).
# These are convenience defaults only — for production use --geojson with the
# authoritative district boundary from PostGIS so the clip matches the data.
DISTRICTS = {
"koforidua": (-0.35, 6.00, -0.15, 6.20), # New Juaben / Koforidua area
"tamale": (-1.10, 9.20, -0.60, 9.60), # Tamale metropolitan area
}
# DE Africa DEM product. Verify against --list-products before a real run:
# DE Africa publishes SRTM ('dem_srtm') and its derivatives ('dem_srtm_deriv',
# the source of the slope WMS layer LUPMIS2 already uses).
DEFAULT_PRODUCT = "dem_srtm"
# EPSG:3857 matches the map view, so the browser does no reprojection.
# NOTE: for Stage 3 hydrology/slope, prefer a metric CRS (UTM 30N = EPSG:32630
# for western Ghana, 31N = EPSG:32631 for the east) — Web Mercator distorts
# distance with latitude and will bias slope and flow calculations.
DEFAULT_CRS = "EPSG:3857"
DEFAULT_RES = 30 # metres — SRTM native resolution
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def list_products():
"""Print datacube products whose name mentions elevation/DEM/SRTM."""
import datacube
dc = datacube.Datacube(app="lupmis2_dem_discovery")
products = dc.list_products()
mask = products["name"].str.contains("dem|srtm|elev", case=False, na=False)
hits = products[mask][["name", "description"]]
if hits.empty:
print("No DEM-like products found. All available products:\n")
print(products[["name", "description"]].to_string())
else:
print("DEM-related products:\n")
print(hits.to_string(index=False))
def bbox_from_geojson(path):
"""Bounding box (min_lon, min_lat, max_lon, max_lat) of a GeoJSON file."""
with open(path) as fh:
gj = json.load(fh)
xs, ys = [], []
def walk(coords):
if isinstance(coords[0], (int, float)):
xs.append(coords[0]); ys.append(coords[1]); return
for c in coords:
walk(c)
feats = gj.get("features", [gj])
for f in feats:
geom = f.get("geometry", f)
if geom and geom.get("coordinates"):
walk(geom["coordinates"])
if not xs:
raise SystemExit(f"No coordinates found in {path}")
return (min(xs), min(ys), max(xs), max(ys))
def load_dem(bbox, product, crs, res):
"""Load the DEM for a bounding box and return a 2-D DataArray."""
import datacube
dc = datacube.Datacube(app="lupmis2_dem_etl")
min_lon, min_lat, max_lon, max_lat = bbox
print(f" loading '{product}' for bbox {bbox} at {res} m in {crs}")
ds = dc.load(
product=product,
x=(min_lon, max_lon),
y=(min_lat, max_lat),
output_crs=crs,
resolution=(-res, res),
# DEMs are static, so any single observation is the whole story.
dask_chunks={},
)
if not ds.data_vars:
raise SystemExit(
f"'{product}' returned no data for this area. "
f"Check the product name with --list-products and confirm coverage."
)
# Take the first band (SRTM DEMs expose a single elevation band) and drop
# the time dimension if the product carries one.
band = list(ds.data_vars)[0]
da = ds[band]
if "time" in da.dims:
da = da.isel(time=0)
print(f" band '{band}', shape {tuple(da.shape)}")
return da.compute() if hasattr(da, "compute") else da
def upload(local_path, key, endpoint, bucket, region):
"""Upload to MinIO. Credentials come from the environment."""
import boto3
from botocore.client import Config
access = os.environ.get("MINIO_KEY")
secret = os.environ.get("MINIO_SECRET")
if not access or not secret:
raise SystemExit(
"Set MINIO_KEY and MINIO_SECRET in the environment.\n"
" export MINIO_KEY=...\n export MINIO_SECRET=..."
)
s3 = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access,
aws_secret_access_key=secret,
region_name=region,
config=Config(s3={"addressing_style": "path"}), # MinIO is path-style
)
size_mb = os.path.getsize(local_path) / 1e6
print(f" uploading {size_mb:.1f} MB → s3://{bucket}/{key}")
s3.upload_file(
local_path, bucket, key,
# Serve a real raster content-type; the PWA's COG pre-flight check
# rejects text/html and reports the type it actually received.
ExtraArgs={"ContentType": "image/tiff"},
)
return f"{endpoint}/{bucket}/{key}"
def verify(url):
"""Confirm the object is anonymously readable and supports range requests."""
import urllib.request
import urllib.error
def probe(headers=None):
req = urllib.request.Request(url, headers=headers or {})
return urllib.request.urlopen(req, timeout=30)
try:
r = probe()
ok_get = r.status == 200
ctype = r.headers.get("content-type")
except urllib.error.HTTPError as e:
print(f" ✗ anonymous GET failed: HTTP {e.code} — is the bucket policy public?")
return False
except Exception as e: # noqa: BLE001
print(f" ✗ anonymous GET failed: {e}")
return False
try:
r2 = probe({"Range": "bytes=0-9"})
ok_range = r2.status == 206
except urllib.error.HTTPError as e:
ok_range = e.code == 206
except Exception: # noqa: BLE001
ok_range = False
print(f" {'' if ok_get else ''} anonymous GET (content-type: {ctype})")
print(f" {'' if ok_range else ''} range requests (206 Partial Content)")
return ok_get and ok_range
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--list-products", action="store_true",
help="list DEM-related datacube products and exit")
ap.add_argument("--district", choices=sorted(DISTRICTS),
help="use a preset (approximate) district bounding box")
ap.add_argument("--bbox", nargs=4, type=float,
metavar=("MIN_LON", "MIN_LAT", "MAX_LON", "MAX_LAT"))
ap.add_argument("--geojson", help="clip to the bounding box of this GeoJSON (preferred)")
ap.add_argument("--name", help="short name used in the object key")
ap.add_argument("--product", default=DEFAULT_PRODUCT)
ap.add_argument("--crs", default=DEFAULT_CRS)
ap.add_argument("--res", type=float, default=DEFAULT_RES, help="resolution in metres")
ap.add_argument("--outdir", default=".")
ap.add_argument("--dry-run", action="store_true",
help="write the COG locally but do not upload")
args = ap.parse_args()
if args.list_products:
list_products()
return
# ---- resolve the area of interest ----
if args.geojson:
bbox = bbox_from_geojson(args.geojson)
name = args.name or os.path.splitext(os.path.basename(args.geojson))[0]
elif args.district:
bbox = DISTRICTS[args.district]
name = args.name or args.district
print("NOTE: preset bounding boxes are approximate. For production use "
"--geojson with the authoritative district boundary.")
elif args.bbox:
bbox = tuple(args.bbox)
name = args.name or "aoi"
else:
ap.error("choose an area: --district, --bbox or --geojson")
name = name.lower().replace(" ", "_")
fname = os.path.join(args.outdir, f"{name}_dem.tif")
key = f"{KEY_PREFIX}/{name}_dem.tif"
print(f"\nLUPMIS2 · DE Africa DEM → MinIO")
print(f" area '{name}' bbox={bbox}")
# ---- load + write COG ----
from datacube.utils.cog import write_cog
da = load_dem(bbox, args.product, args.crs, args.res)
print(f" writing COG → {fname}")
out = write_cog(da, fname=fname, overwrite=True)
if hasattr(out, "compute"): # dask-backed writes are lazy
out.compute()
if args.dry_run:
print(f"\nDry run complete: {fname} ({os.path.getsize(fname)/1e6:.1f} MB)")
return
# ---- upload + verify ----
url = upload(fname, key, MINIO_ENDPOINT, BUCKET, MINIO_REGION)
print(f"\n object URL: {url}")
ok = verify(url)
manifest = {
"name": f"{name.title()} DEM (SRTM 30 m, DE Africa)",
"url": url,
"key": key,
"product": args.product,
"bbox_4326": list(bbox),
"crs": args.crs,
"resolution_m": args.res,
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"verified": bool(ok),
}
mpath = os.path.join(args.outdir, f"{name}_dem.manifest.json")
with open(mpath, "w") as fh:
json.dump(manifest, fh, indent=2)
print(f" manifest → {mpath}")
if ok:
print("\nReady. In LUPMIS2: Add External Layer → COG → paste the object URL above.")
else:
print("\nUploaded, but the public read/range check failed — the PWA will not "
"be able to stream it until that is resolved.")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -1975,6 +1975,11 @@
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Close</button>
<!-- Shown only after a zonal run — exports the results table plus the
parameters that produced it, as a two-sheet Excel workbook. -->
<button type="button" class="btn btn-outline-primary d-none" id="analysis-export-table">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export table (Excel)
</button>
<button type="button" class="btn btn-primary" id="analysis-run">
<i class="bi bi-play-fill me-1"></i>Run analysis
</button>

View File

@ -68,7 +68,11 @@
// Raster display gains Cloud-Optimized GeoTIFF support (MapView.addCOGLayer).
// Turf, the analysis panel, geotiff.js and pako are all code-split and
// fetched on first use, so the eager bundle is unchanged.
const CACHE_VERSION = 'v13';
// v14: Zonal statistics results can be exported as a two-sheet Excel workbook
// (Results + the Parameters that produced them) via a dependency-free
// XLSX writer in src/analysis/xlsx.js; Add External Layer gains a COG
// option with a URL pre-flight check.
const CACHE_VERSION = 'v14';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -27,6 +27,7 @@ let modal = null;
let mapView = null;
let layerChoices = []; // [{ layer, title, count }]
let drawnScope = null; // { features: Set, label } when opened from a Circle/Area popup
let lastZonal = null; // { rows, params } — the most recent zonal run, for export
// ---------------------------------------------------------------------------
// Element wiring
@ -52,6 +53,7 @@ function cacheEls() {
els.outName = document.getElementById('analysis-output-name');
els.result = document.getElementById('analysis-result');
els.btnRun = document.getElementById('analysis-run');
els.btnExport = document.getElementById('analysis-export-table');
els.scope = document.getElementById('analysis-scope');
els.scopeHint = document.getElementById('analysis-scope-hint');
@ -63,6 +65,7 @@ function cacheEls() {
els.inputLayer.addEventListener('change', refreshFieldOptions);
els.scope.addEventListener('change', onScopeChange);
els.btnRun.addEventListener('click', onRun);
els.btnExport.addEventListener('click', onExportTable);
}
}
@ -108,6 +111,8 @@ function currentMode() {
}
function onModeChange() {
// A previous zonal table doesn't belong to a new overlay run.
lastZonal = null; syncExportButton();
const overlay = currentMode() === 'overlay';
els.overlayBox.classList.toggle('d-none', !overlay);
els.zonalBox.classList.toggle('d-none', overlay);
@ -207,6 +212,7 @@ function onRun() {
let geojson, summary;
if (mode === 'overlay') {
lastZonal = null;
const op = els.op.value;
const a = featuresOf(els.layerA);
const b = featuresOf(els.layerB);
@ -230,6 +236,26 @@ function onRun() {
});
geojson = res.geojson;
summary = renderRows(res.rows);
// Keep the table AND the settings that produced it, so the Excel export
// is self-describing — a table of numbers with no record of how it was
// produced can't be checked or reproduced later.
const membershipLabel = (MEMBERSHIP_MODES.find((m) => m.key === els.membership.value) || {}).label;
lastZonal = {
rows: res.rows,
params: {
'Analysis': 'Zonal statistics',
'Zone layer': labelOfSelect(els.zoneLayer),
'Features summarised': labelOfSelect(els.inputLayer),
'Applied to': scopeLabel(),
'Zones analysed': zones.length,
'Features analysed': inputs.length,
'Counted when': membershipLabel || els.membership.value,
'Statistics': stats.map((k) => (STATS.find((x) => x.key === k) || {}).label || k).join(', '),
'Numeric field': field || '(none)',
'Result layer': name,
},
};
}
if (!geojson.features.length) {
@ -243,6 +269,7 @@ function onRun() {
strokeColor: '#d97706', strokeWidth: 2, fillColor: 'rgba(217,119,6,0.18)',
}, group);
syncExportButton();
els.result.innerHTML =
`<div class="alert alert-success py-2 px-3 mb-2">Added layer “${escapeHtml(name)}”.</div>` +
(typeof summary === 'string' && summary.startsWith('<') ? summary : `<div class="small text-muted">${escapeHtml(summary)}</div>`);
@ -257,6 +284,64 @@ function onRun() {
}, 20);
}
/** Human label of a layer <select>, e.g. "Parcels (25,004)". */
function labelOfSelect(select) {
const opt = select.options[select.selectedIndex];
return opt ? opt.textContent : '(none)';
}
/** Human label of the current "Apply to" scope. */
function scopeLabel() {
const opt = els.scope.options[els.scope.selectedIndex];
return opt ? opt.textContent : 'Whole layer';
}
/**
* Export the most recent zonal table to .xlsx two sheets, the figures plus
* the parameters that produced them. The writer is loaded on demand.
*/
async function onExportTable() {
if (!lastZonal) return;
els.btnExport.disabled = true;
try {
const { exportXlsx } = await import('./analysis/xlsx.js');
const { rows, params } = lastZonal;
const keys = Object.keys(rows[0] || {}).filter((k) => k !== 'zone');
const resultRows = [
{ cells: ['Zone', ...keys], bold: true },
...rows.map((r) => ['zone' in r ? r.zone : '', ...keys.map((k) => (r[k] === null || r[k] === undefined ? '' : r[k]))]),
];
const paramRows = [
{ cells: ['LUPMIS2 — Zonal statistics'], bold: true },
[],
{ cells: ['Parameter', 'Value'], bold: true },
...Object.entries(params).map(([k, v]) => [k, v]),
['Exported', new Date().toLocaleString()],
];
const base = (params['Result layer'] || 'zonal_stats')
.toString().trim().replace(/[^A-Za-z0-9_\-]+/g, '_') || 'zonal_stats';
exportXlsx(
[{ name: 'Parameters', rows: paramRows }, { name: 'Results', rows: resultRows }],
`${base}.xlsx`
);
showToast('Table exported to Excel.', 'success', 3000);
} catch (err) {
console.error('[Analysis] table export failed:', err);
showToast(`Could not export the table: ${err.message}`, 'error', 6000);
} finally {
els.btnExport.disabled = false;
}
}
/** Show or hide the export button depending on whether a table exists. */
function syncExportButton() {
if (!els.btnExport) return;
els.btnExport.classList.toggle('d-none', !lastZonal);
}
function renderRows(rows) {
if (!rows || !rows.length) return '';
const keys = Object.keys(rows[0]).filter((k) => k !== 'zone');
@ -310,6 +395,7 @@ export function openAnalysisModal(view, opts = {}) {
els.outName.addEventListener('input', () => { els.outName.dataset.touched = '1'; }, { once: true });
els.result.innerHTML = '';
lastZonal = null;
els.scope.value = drawnScope ? 'drawn' : 'all';
refreshFieldOptions();
onOpChange();

222
src/analysis/xlsx.js Normal file
View File

@ -0,0 +1,222 @@
/**
* xlsx.js a minimal, dependency-free XLSX writer.
*
* An .xlsx file is a ZIP archive of XML parts. This module builds the few
* parts Excel actually requires and packs them with a small STORED (uncompressed)
* ZIP writer readers accept stored entries, and an analysis table is far too
* small for compression to matter.
*
* Why not a library: SheetJS's npm package is stale and carries advisories,
* ExcelJS is heavy for an offline-first field app, and the JSZip in node_modules
* is only a transitive dependency of shp-write (so it could disappear). This is
* ~200 lines we control, adds nothing to the dependency tree, and works offline.
*
* buildXlsxBlob([{ name: 'Results', rows: [['Zone','Count'], ['North', 3]] }])
*
* Values are written as numbers when typeof === 'number' (and finite), otherwise
* as inline strings which avoids needing a sharedStrings part. Passing a row
* as { cells: [...], bold: true } renders that row in bold.
*/
// ---------------------------------------------------------------------------
// XML helpers
// ---------------------------------------------------------------------------
function esc(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;')
// Strip control characters Excel rejects outside tab/newline.
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
}
/** 0 → A, 25 → Z, 26 → AA … */
function colName(index) {
let s = '';
let n = index;
while (n >= 0) {
s = String.fromCharCode((n % 26) + 65) + s;
n = Math.floor(n / 26) - 1;
}
return s;
}
function normaliseRow(row) {
if (row && !Array.isArray(row) && Array.isArray(row.cells)) {
return { cells: row.cells, bold: !!row.bold };
}
return { cells: Array.isArray(row) ? row : [row], bold: false };
}
function sheetXml(rows) {
const out = [];
rows.forEach((raw, r) => {
const { cells, bold } = normaliseRow(raw);
const style = bold ? ' s="1"' : '';
const cellXml = cells.map((v, c) => {
const ref = `${colName(c)}${r + 1}`;
if (v === null || v === undefined || v === '') return `<c r="${ref}"${style}/>`;
if (typeof v === 'number' && Number.isFinite(v)) {
return `<c r="${ref}"${style}><v>${v}</v></c>`;
}
return `<c r="${ref}"${style} t="inlineStr"><is><t xml:space="preserve">${esc(v)}</t></is></c>`;
}).join('');
out.push(`<row r="${r + 1}">${cellXml}</row>`);
});
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${out.join('')}</sheetData></worksheet>`;
}
/** Excel requires fills[0]=none and fills[1]=gray125; keep the rest minimal. */
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`;
// ---------------------------------------------------------------------------
// Minimal STORED-ZIP writer
// ---------------------------------------------------------------------------
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/** Pack files (name → string) into an uncompressed ZIP Blob. */
function zipStore(files) {
const enc = new TextEncoder();
const entries = Object.entries(files).map(([name, text]) => ({
name, nameBytes: enc.encode(name), data: enc.encode(text),
}));
// DOS time/date — a fixed timestamp keeps output deterministic.
const time = 0, date = ((2020 - 1980) << 9) | (1 << 5) | 1;
const chunks = [];
const central = [];
let offset = 0;
const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
for (const e of entries) {
const crc = crc32(e.data);
const size = e.data.length;
const local = [
...u32(0x04034b50), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0),
];
chunks.push(new Uint8Array(local), e.nameBytes, e.data);
central.push([
...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u32(offset),
]);
central.push(e.nameBytes);
offset += local.length + e.nameBytes.length + size;
}
const cdParts = [];
let cdSize = 0;
for (const part of central) {
const arr = part instanceof Uint8Array ? part : new Uint8Array(part);
cdParts.push(arr); cdSize += arr.length;
}
const end = new Uint8Array([
...u32(0x06054b50), ...u16(0), ...u16(0),
...u16(entries.length), ...u16(entries.length),
...u32(cdSize), ...u32(offset), ...u16(0),
]);
return new Blob([...chunks, ...cdParts, end],
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Build an .xlsx Blob from one or more sheets.
* @param {Array<{name: string, rows: Array}>} sheets
* @returns {Blob}
*/
export function buildXlsxBlob(sheets) {
const list = (sheets || []).filter((s) => s && Array.isArray(s.rows));
if (list.length === 0) throw new Error('No sheets to export.');
const files = {};
const sheetEntries = list.map((s, i) => {
const file = `xl/worksheets/sheet${i + 1}.xml`;
files[file] = sheetXml(s.rows);
// Excel sheet names: max 31 chars, and : \ / ? * [ ] are illegal.
const safe = String(s.name || `Sheet${i + 1}`).replace(/[:\\/?*[\]]/g, ' ').slice(0, 31);
return { id: i + 1, name: safe, file };
});
files['[Content_Types].xml'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${sheetEntries.map((s) => `<Override PartName="/${s.file}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('\n')}
</Types>`;
files['_rels/.rels'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`;
files['xl/workbook.xml'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${sheetEntries.map((s) => `<sheet name="${esc(s.name)}" sheetId="${s.id}" r:id="rId${s.id}"/>`).join('')}</sheets>
</workbook>`;
files['xl/_rels/workbook.xml.rels'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${sheetEntries.map((s) => `<Relationship Id="rId${s.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${s.id}.xml"/>`).join('\n')}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
files['xl/styles.xml'] = STYLES_XML;
return zipStore(files);
}
/** Trigger a browser download of a Blob. */
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** Convenience: build and download in one call. */
export function exportXlsx(sheets, filename) {
downloadBlob(buildXlsxBlob(sheets), filename);
}

View File

@ -3800,6 +3800,18 @@ export class MapView {
return null;
}
// Pre-flight the URL before handing it to geotiff.js. Without this, a bad
// URL (a web page, a 404, a CORS block) surfaces only as an unhandled
// AggregateError from the library's internal range-request machinery,
// which tells the user nothing. A one-byte range request is cheap and
// distinguishes the common failures precisely.
const problem = await this._probeCOGUrl(url);
if (problem) {
console.warn(`[MapView] COG pre-flight failed for "${title}":`, problem);
showToast(problem, 'error', 8000);
return null;
}
// Lazy-load the GeoTIFF stack only when a raster is actually opened.
let WebGLTileLayer, GeoTIFFSource;
try {
@ -3859,6 +3871,52 @@ export class MapView {
return cogLayer;
}
/**
* Check that a URL can plausibly serve a Cloud-Optimized GeoTIFF.
*
* Issues a 1-byte range request and inspects the response. Returns a
* human-readable problem description, or null when the URL looks usable.
* This exists because geotiff.js reports these failures as an opaque
* "AggregateError: Request failed" from deep inside its block reader.
*
* @param {string} url
* @returns {Promise<string|null>}
* @private
*/
async _probeCOGUrl(url) {
let res;
try {
res = await fetch(url, { headers: { Range: 'bytes=0-0' } });
} catch (err) {
// Thrown for CORS rejections and network failures alike — the browser
// deliberately doesn't tell us which, so say so rather than guess.
return 'Could not reach that file. It may be offline, or the server may not ' +
'allow cross-origin (CORS) requests from this app.';
}
if (res.status === 404) return 'No file at that URL (404). Check the address.';
if (!res.ok && res.status !== 206) {
return `The server refused the request (HTTP ${res.status}).`;
}
const type = (res.headers.get('content-type') || '').toLowerCase();
if (type.includes('text/html')) {
return 'That URL is a web page, not a GeoTIFF. Paste the direct link to the ' +
'.tif file itself — a documentation or viewer page will not work.';
}
if (type.startsWith('text/') || type.includes('json')) {
return `That URL returns ${type.split(';')[0]}rather than image data. ` +
'Paste the direct link to the .tif file.';
}
// 200 instead of 206 means range requests are ignored: the file still
// loads, but the whole thing is downloaded. Worth warning about, not fatal.
if (res.status === 200) {
showToast('This server does not support byte-range requests, so the whole raster ' +
'will be downloaded. It may be slow for a large file.', 'warning', 7000);
}
return null;
}
/**
* The features currently selected with the Select tool (shift-click or
* Ctrl/Cmd box-select). Used by the Analysis panel to restrict an analysis
@ -3954,7 +4012,11 @@ export class MapView {
<label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
<input type="radio" name="add-layer-type" value="xyz" style="accent-color:#10b981;"> XYZ
</label>
<label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
<input type="radio" name="add-layer-type" value="cog" style="accent-color:#10b981;"> COG
</label>
</div>
<div style="font-size:11px;color:var(--muted-foreground, #7a7a7a);margin-top:4px;" class="add-layer-type-hint"></div>
</div>
<div>
<label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Server URL</label>
@ -3992,10 +4054,18 @@ export class MapView {
const nameRow = card.querySelector('.add-layer-name-row');
const nameHint = card.querySelector('.add-layer-name-hint');
const urlInput = card.querySelector('.add-layer-url');
const typeHint = card.querySelector('.add-layer-type-hint');
card.querySelectorAll('input[name="add-layer-type"]').forEach((radio) => {
radio.addEventListener('change', () => {
const type = radio.value;
if (type === 'xyz') {
typeHint.textContent = type === 'cog'
? 'Cloud-Optimized GeoTIFF — the server must allow CORS and byte-range requests.'
: '';
if (type === 'cog') {
// A COG is a single file, so there is no layer/typename to pick.
nameRow.style.display = 'none';
urlInput.placeholder = 'https://example.com/data/elevation_cog.tif';
} else if (type === 'xyz') {
nameRow.style.display = 'none';
urlInput.placeholder = 'https://example.com/tiles/{z}/{x}/{y}.png';
} else {
@ -4068,6 +4138,8 @@ export class MapView {
dlg.querySelector('.add-layer-name-row').style.display = '';
dlg.querySelector('.add-layer-url').placeholder = 'https://example.com/wms';
dlg.querySelector('.add-layer-name-hint').textContent = 'WMS LAYERS parameter (e.g. workspace:layer)';
const typeHintEl = dlg.querySelector('.add-layer-type-hint');
if (typeHintEl) typeHintEl.textContent = '';
// Reset border colours
dlg.querySelectorAll('input[type="text"]').forEach((inp) => {
@ -4162,6 +4234,21 @@ export class MapView {
});
break;
case 'cog': {
// A Cloud-Optimized GeoTIFF is a single file streamed by byte-range
// request. addCOGLayer() lazy-loads the GeoTIFF stack, pushes the layer
// into the group itself and reports its own failures, so we return
// early rather than falling through to the shared push below.
const groupTitle = group.get('title') || 'External Source';
this.addCOGLayer(groupTitle, title, url).then((cogLayer) => {
if (!cogLayer) return;
cogLayer.set('removable', true);
showToast(`Layer "${title}" added to External Source.`, 'success', 3000);
console.log(`[MapView] External COG layer added: "${title}"`);
});
return;
}
default:
showToast(`Unknown layer type: ${type}`, 'error', 4000);
return;