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:
parent
26c941b09a
commit
da6f968725
4
.gitignore
vendored
4
.gitignore
vendored
@ -105,3 +105,7 @@ coverage/
|
||||
|
||||
# Microsoft Office lock / owner files (e.g. ~$Document.docx)
|
||||
~$*
|
||||
|
||||
# Python bytecode (etl/ scripts)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
BIN
LUPMIS2_Analyse_Tutorial.docx
Normal file
BIN
LUPMIS2_Analyse_Tutorial.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Analytical_Tools_Concept.docx
Normal file
BIN
LUPMIS2_Analytical_Tools_Concept.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Analytical_Tools_TWG.pptx
Normal file
BIN
LUPMIS2_Analytical_Tools_TWG.pptx
Normal file
Binary file not shown.
67
LUPMIS2_Analytics_Architecture.svg
Normal file
67
LUPMIS2_Analytics_Architecture.svg
Normal 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 & 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 |
72
LUPMIS2_Workflow_DEAfrica.svg
Normal file
72
LUPMIS2_Workflow_DEAfrica.svg
Normal 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 |
74
LUPMIS2_Workflow_EarthEngine.svg
Normal file
74
LUPMIS2_Workflow_EarthEngine.svg
Normal 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
11
dist/assets/analysis-modal-05Uf_OF7.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/analysis-modal-05Uf_OF7.js.map
vendored
Normal file
1
dist/assets/analysis-modal-05Uf_OF7.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
11
dist/assets/analysis-modal-CYhcFxIW.js
vendored
11
dist/assets/analysis-modal-CYhcFxIW.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/analysis-modal-CYhcFxIW.js.map
vendored
1
dist/assets/analysis-modal-CYhcFxIW.js.map
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-BYEiD6vv.js.map
vendored
1
dist/assets/index-BYEiD6vv.js.map
vendored
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
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
30
dist/assets/xlsx-d1LrY7GM.js
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
function u(n){return String(n).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").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
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
7
dist/index.html
vendored
@ -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
6
dist/sw.js
vendored
@ -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
83
etl/README.md
Normal 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.
|
||||
298
etl/deafrica_dem_to_minio.py
Normal file
298
etl/deafrica_dem_to_minio.py
Normal 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()
|
||||
@ -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>
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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
222
src/analysis/xlsx.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
// 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user