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>
223 lines
9.3 KiB
JavaScript
223 lines
9.3 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|