diff --git a/package-lock.json b/package-lock.json index 2aa39df..75cd89a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,15 @@ "dependencies": { "@deck.gl/react": "^9.3.3", "deck.gl": "^9.3.7", + "fflate": "^0.8.3", + "jspdf": "^4.2.1", "maplibre-gl": "^5.24.0", "next": "16.2.12", "react": "19.2.8", "react-dom": "19.2.8", "react-map-gl": "^8.1.2", "recharts": "^3.10.1", + "write-excel-file": "^4.1.1", "zarrita": "^0.7.3" }, "devDependencies": { @@ -318,6 +321,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1613,6 +1625,12 @@ "@loaders.gl/core": "~4.4.0" } }, + "node_modules/@loaders.gl/compression/node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, "node_modules/@loaders.gl/core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.4.3.tgz", @@ -3586,6 +3604,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -3624,8 +3649,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", @@ -4979,6 +5003,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5193,6 +5227,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cartocolor": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/cartocolor/-/cartocolor-5.0.2.tgz", @@ -5451,6 +5505,21 @@ "node": ">=0.10.0" } }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5488,6 +5557,16 @@ "license": "MIT", "peer": true }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssfilter": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", @@ -6291,6 +6370,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -7061,6 +7150,39 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/fast-xml-builder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", @@ -7111,9 +7233,9 @@ } }, "node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, "node_modules/file-entry-cache": { @@ -7572,6 +7694,20 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7710,6 +7846,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -8344,6 +8486,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9172,12 +9331,6 @@ "fflate": "^0.8.0" } }, - "node_modules/numcodecs/node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9513,6 +9666,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9696,6 +9856,16 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -9865,6 +10035,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -9955,6 +10132,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -10473,6 +10660,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -10709,6 +10906,16 @@ "license": "ISC", "peer": true }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -10750,6 +10957,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/texture-compressor": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", @@ -11270,6 +11487,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -11909,6 +12136,18 @@ "node": ">=12.17" } }, + "node_modules/write-excel-file": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/write-excel-file/-/write-excel-file-4.1.1.tgz", + "integrity": "sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/xml-naming": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", diff --git a/package.json b/package.json index f10da8f..3c2aab4 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ "dependencies": { "@deck.gl/react": "^9.3.3", "deck.gl": "^9.3.7", + "fflate": "^0.8.3", + "jspdf": "^4.2.1", "maplibre-gl": "^5.24.0", "next": "16.2.12", "react": "19.2.8", "react-dom": "19.2.8", "react-map-gl": "^8.1.2", "recharts": "^3.10.1", + "write-excel-file": "^4.1.1", "zarrita": "^0.7.3" }, "devDependencies": { diff --git a/src/components/map/DownloadButton.tsx b/src/components/map/DownloadButton.tsx new file mode 100644 index 0000000..5927c87 --- /dev/null +++ b/src/components/map/DownloadButton.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { DownloadIcon } from "@/icons/DownloadIcon"; +import { captureMapForExport } from "@/components/map/ExportMapStage"; +import { capturePlotsForExport } from "@/components/map/ExportStage"; +import { buildSeriesCsv } from "@/lib/export/csv"; +import { downloadBlob } from "@/lib/export/download"; +import { buildReportPdf, type ReportAssets } from "@/lib/export/pdf"; +import { buildProvenance, exportFileBaseName } from "@/lib/export/provenance"; +import { buildSeriesRows } from "@/lib/export/rows"; +import { buildSeriesWorkbook } from "@/lib/export/xlsx"; +import { blobToBytes, buildZip, dataUrlToBytes } from "@/lib/export/zip"; +import type { GridSpec, MapSelection } from "@/types/map"; + +type DownloadButtonProps = { + selection: MapSelection; + gridSpec: GridSpec; + historyYears: number; + values: Float32Array | null; + units: string | null; +}; + +export function DownloadButton({ + selection, + gridSpec, + historyYears, + values, + units, +}: DownloadButtonProps) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const runExport = useCallback(async () => { + if (!values) return; + + setError(null); + setBusy(true); + try { + const prov = buildProvenance({ + selection, + historyYears, + valueCount: values.length, + units, + }); + const base = exportFileBaseName(prov); + + // Both stages mount offscreen and are independent, so the map's tiles + // download while the plots lay out. + const [mapCapture, plots] = await Promise.all([ + captureMapForExport({ cell: selection.grid, gridSpec }), + capturePlotsForExport({ + values, + units, + hoursPerDay: prov.hoursPerDay, + }), + ]); + const assets: ReportAssets = { + map: mapCapture.image, + timeSeries: plots.timeSeries, + fingerprint: plots.fingerprint, + }; + + const rows = buildSeriesRows(values, prov); + const [pdf, workbook] = await Promise.all([ + buildReportPdf({ + prov, + assets, + values, + attribution: mapCapture.attribution, + }), + buildSeriesWorkbook(rows, prov), + ]); + + // Everything but the CSV is compressed already, so only it is worth + // deflating. Every name carries the full stem: extracted loose among + // other downloads they still say which pixel and window they came from. + const archive = await buildZip([ + { name: `${base}.pdf`, data: await blobToBytes(pdf), stored: true }, + { + name: `${base}.xlsx`, + data: await blobToBytes(workbook), + stored: true, + }, + { + name: `${base}.csv`, + data: new TextEncoder().encode(buildSeriesCsv(rows, prov)), + }, + { + name: `${base}_fingerprint.png`, + data: dataUrlToBytes(plots.fingerprintStandalone.dataUrl), + stored: true, + }, + { + name: `${base}_timeseries.png`, + data: dataUrlToBytes(plots.timeSeries.dataUrl), + stored: true, + }, + ]); + + downloadBlob(archive, `${base}.zip`); + } catch (cause) { + console.error("Export failed", cause); + setError("Export failed. Try again."); + } finally { + setBusy(false); + } + }, [gridSpec, historyYears, selection, units, values]); + + return ( +
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/components/map/ExportMapStage.tsx b/src/components/map/ExportMapStage.tsx new file mode 100644 index 0000000..fd508e5 --- /dev/null +++ b/src/components/map/ExportMapStage.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { createRoot } from "react-dom/client"; +import Map from "react-map-gl/maplibre"; +import type { Map as MapLibreMap, MapLibreEvent } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { GlobeSelectionOverlay } from "@/components/map/GlobeSelectionOverlay"; +import { + MAP_BASE_STYLES, + SELECTION_FOCUS_ZOOM, +} from "@/lib/map/viewState"; +import { + canvasToPng, + createOffscreenHost, + whenVisible, + type CapturedImage, +} from "@/lib/export/capture"; +import { collectAttribution } from "@/lib/export/mapSnapshot"; +import type { GridCell, GridSpec, MapViewState } from "@/types/map"; + +/** + * Stage size in CSS px, at the aspect ratio of the report's map box (88mm x + * 47mm). Matching it means the panel prints the whole frame instead of a + * centre crop of one shaped like the screen. + */ +const EXPORT_MAP_WIDTH = 704; +const EXPORT_MAP_HEIGHT = 376; + +/** + * Fixed rather than inherited from the display, so the printed map has the same + * ~400dpi on every machine. 2 is where the labels stay crisp in print without + * the PNG dominating the file size. + */ +const EXPORT_MAP_PIXEL_RATIO = 2; + +/** Style, glyphs and the first tiles, over the network. */ +const LOAD_TIMEOUT_MS = 8000; + +/** Extra grace for the remaining tiles once the map itself is up. */ +const TILE_TIMEOUT_MS = 6000; + +const MAP_CANVAS_ATTRIBUTES = { preserveDrawingBuffer: true } as const; + +export type MapCapture = { + /** Null when the offscreen map could not be drawn; the report prints a note. */ + image: CapturedImage | null; + /** Basemap credit, which the on-screen map hides but a distributed file must carry. */ + attribution: string; +}; + +type CaptureOptions = { + cell: GridCell; + gridSpec: GridSpec; +}; + +/** + * The map as the report wants it, which is not the map on screen: always plan + * view, always the light basemap, always framed on the selected cell. The live + * map can be a dark globe tilted off its axis, and printing that into a light + * A4 report gives a black sphere floating in a white page. + */ +function ExportMapStage({ + cell, + gridSpec, + onReady, +}: CaptureOptions & { onReady: (map: MapLibreMap) => void }) { + const viewState: MapViewState = { + longitude: cell.lon, + latitude: cell.lat, + zoom: SELECTION_FOCUS_ZOOM, + bearing: 0, + pitch: 0, + }; + + return ( + onReady(event.target)} + style={{ width: EXPORT_MAP_WIDTH, height: EXPORT_MAP_HEIGHT }} + > + + + ); +} + +/** + * Resolve once the map has nothing left to draw, or once the grace period runs + * out. A timeout is not a failure: a map missing its outermost tiles still + * prints usefully, so whatever has painted by then is what gets captured. + */ +function waitForIdle(map: MapLibreMap, timeoutMs: number): Promise { + if (map.areTilesLoaded() && map.isStyleLoaded()) return Promise.resolve(); + + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + map.off("idle", finish); + resolve(); + }; + const timer = setTimeout(finish, timeoutMs); + map.on("idle", finish); + }); +} + +/** + * Mount a map offscreen, wait for it to settle, and rasterise it for the + * report. The stage is torn down before this resolves. + * + * Never throws: the report treats a missing map as a layout case, and a failed + * basemap is not a reason to lose the data the user asked to download. + */ +export async function captureMapForExport({ + cell, + gridSpec, +}: CaptureOptions): Promise { + // A hidden tab paints nothing and fetches no tiles, so the load and idle + // budgets below would run out on a map that never had a chance to draw. + await whenVisible(); + + const host = createOffscreenHost(EXPORT_MAP_WIDTH, EXPORT_MAP_HEIGHT); + const root = createRoot(host); + + try { + const map = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), LOAD_TIMEOUT_MS); + root.render( + { + clearTimeout(timer); + resolve(instance); + }} + />, + ); + }); + + if (!map) return { image: null, attribution: "" }; + + map.setPixelRatio(EXPORT_MAP_PIXEL_RATIO); + await waitForIdle(map, TILE_TIMEOUT_MS); + // The buffer holds the last painted frame, and the pixel-ratio resize may + // still be pending, so force one more frame before reading it back. + map.redraw(); + + return { + image: canvasToPng(map.getCanvas()), + attribution: collectAttribution(map.getStyle()?.sources), + }; + } catch (cause) { + console.error("Map capture failed", cause); + return { image: null, attribution: "" }; + } finally { + root.unmount(); + host.remove(); + } +} diff --git a/src/components/map/ExportStage.tsx b/src/components/map/ExportStage.tsx new file mode 100644 index 0000000..10cb1cd --- /dev/null +++ b/src/components/map/ExportStage.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { createRoot } from "react-dom/client"; +import { FingerprintPlot } from "@/components/map/FingerprintPlot"; +import { TimeSeriesPlot } from "@/components/map/TimeSeriesPlot"; +import { + canvasToPng, + createOffscreenHost, + nextFrame, + svgToPng, + waitUntil, + whenVisible, + type CapturedImage, +} from "@/lib/export/capture"; +import { fingerprintPngWithLegend } from "@/lib/export/fingerprintImage"; +import { symmetricAbsMax } from "@/lib/map/fingerprintScale"; +import { FixedThemeProvider } from "@/providers/ThemeProvider"; + +/** + * Stage width in CSS px. The readout sidebar is far narrower than the PDF's + * 182mm content column, so capturing the on-screen plots would print a cramped + * chart stretched wide. Rendering offscreen at this width gives the plots a + * print-appropriate aspect ratio, and it drives the fingerprint's day-axis + * resolution: roughly 700 columns of heatmap across the page. + */ +export const EXPORT_STAGE_WIDTH = 760; + +/** + * Plot height in CSS px for the export, taller than the sidebar's 220. The PDF + * prints each plot at the full 182mm content width, so the capture's aspect + * ratio is what sets its printed height: 760x290 lands at ~69mm per plot, which + * is the tallest pair that still leaves the legend clear of the page footer. + */ +export const EXPORT_PLOT_HEIGHT = 290; + +/** + * Backing-store pixels per CSS pixel for both captures. Pinned rather than + * taken from the display, so a report and its images come out identically sharp + * whoever generated them, and high enough that the heatmap's day columns and + * the chart's labels survive being printed at full page width. + */ +const EXPORT_PIXEL_RATIO = 3; + +type StageProps = { + values: Float32Array; + units?: string | null; + hoursPerDay?: number; +}; + +/** + * Both plots at export size. The fingerprint mounts fresh, so it captures in its + * default state (whole window, hour on the y axis) rather than mirroring any + * flip or year the user has selected on screen. + */ +function ExportStage({ values, units, hoursPerDay }: StageProps) { + return ( + +
+
+ +
+
+ +
+
+
+ ); +} + +export type PlotCaptures = { + timeSeries: CapturedImage; + /** Bare heatmap. The report draws the colour ramp itself, under the plot. */ + fingerprint: CapturedImage; + /** The same heatmap with the ramp baked in, for the image shipped on its own. */ + fingerprintStandalone: CapturedImage; +}; + +/** + * Mount both plots offscreen, wait for them to paint, and rasterise them. + * The stage is torn down before this resolves. + */ +export async function capturePlotsForExport( + props: StageProps, +): Promise { + // Recharts and the fingerprint both size themselves off a ResizeObserver, + // which a hidden tab never delivers. Mounting there would stage plots that + // can never measure. + await whenVisible(); + + const host = createOffscreenHost(EXPORT_STAGE_WIDTH); + const root = createRoot(host); + + const findSvg = () => + host.querySelector('[data-export-plot="line"] svg'); + const findCanvas = () => + host.querySelector( + '[data-export-plot="fingerprint"] canvas', + ); + + try { + root.render(); + + // Recharts measures its container on a resize observation, and the + // fingerprint sizes its backing store in an effect after the same. Neither + // emits an event to wait on, so poll for the results of both. + await waitUntil( + () => { + const svg = findSvg(); + const canvas = findCanvas(); + return ( + !!svg && + svg.getBoundingClientRect().width > 0 && + !!canvas && + canvas.width > 0 + ); + }, + { label: "offscreen plots" }, + ); + + // One more frame so the fingerprint's draw call lands in the backing store. + await nextFrame(); + + const svg = findSvg(); + const canvas = findCanvas(); + if (!svg || !canvas) throw new Error("Export stage lost its plots"); + + return { + timeSeries: await svgToPng(svg, { scale: EXPORT_PIXEL_RATIO }), + fingerprint: canvasToPng(canvas), + fingerprintStandalone: fingerprintPngWithLegend(canvas, { + absMax: symmetricAbsMax(props.values), + units: props.units, + pixelRatio: EXPORT_PIXEL_RATIO, + }), + }; + } finally { + root.unmount(); + host.remove(); + } +} diff --git a/src/components/map/FingerprintPlot.tsx b/src/components/map/FingerprintPlot.tsx index 022973f..358c3f7 100644 --- a/src/components/map/FingerprintPlot.tsx +++ b/src/components/map/FingerprintPlot.tsx @@ -23,6 +23,14 @@ type FingerprintPlotProps = { values: Float32Array; units?: string | null; hoursPerDay?: number; + /** Canvas height in CSS px. The PDF export renders taller than the sidebar does. */ + height?: number; + /** + * Backing-store pixels per CSS pixel. Defaults to the display's own ratio, + * which is right on screen but would make an exported image sharp or soft + * depending on whose laptop drew it, so the export pins its own. + */ + pixelRatio?: number; }; /** @@ -60,6 +68,8 @@ export function FingerprintPlot({ values, units, hoursPerDay = 24, + height = TIME_SERIES_PLOT_HEIGHT, + pixelRatio, }: FingerprintPlotProps) { const { isLight } = useTheme(); const wrapperRef = useRef(null); @@ -122,9 +132,9 @@ export function FingerprintPlot({ const canvas = canvasRef.current; if (!canvas || width === 0 || nDays === 0) return; - const height = TIME_SERIES_PLOT_HEIGHT; const dpr = - typeof window === "undefined" ? 1 : window.devicePixelRatio || 1; + pixelRatio ?? + (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); canvas.style.width = `${width}px`; @@ -231,6 +241,8 @@ export function FingerprintPlot({ hoursPerDay, isLight, width, + height, + pixelRatio, transposed, ]); @@ -246,7 +258,7 @@ export function FingerprintPlot({ const y = event.clientY - rect.top; const axisLeft = axisLeftFor(transposed); const plotW = Math.max(1, width - axisLeft - AXIS_RIGHT); - const plotH = Math.max(1, TIME_SERIES_PLOT_HEIGHT - AXIS_TOP - AXIS_BOTTOM); + const plotH = Math.max(1, height - AXIS_TOP - AXIS_BOTTOM); const inX = x - axisLeft; const inY = y - AXIS_TOP; if (inX < 0 || inX >= plotW || inY < 0 || inY >= plotH) { diff --git a/src/components/map/MapReadout.tsx b/src/components/map/MapReadout.tsx index 1e2c91f..46b264f 100644 --- a/src/components/map/MapReadout.tsx +++ b/src/components/map/MapReadout.tsx @@ -10,6 +10,7 @@ import { TimeSeriesPlotLoading } from "@/components/map/TimeSeriesPlotLoading"; import { FingerprintPlot } from "@/components/map/FingerprintPlot"; import { FingerprintPlotLoading } from "@/components/map/FingerprintPlotLoading"; import { ProgressBar } from "@/components/ui/ProgressBar"; +import { DownloadButton } from "@/components/map/DownloadButton"; type PlotView = "line" | "fingerprint"; @@ -94,11 +95,20 @@ export function MapReadout({ const chart = (
-
- - {plotView === "line" ? "Daily mean" : "Diurnal fingerprint"} - + + {plotView === "line" ? "Daily mean" : "Diurnal fingerprint"} + + + {/* View switch left, download right, on the row between title and plot. */} +
+
{loadingSeries ? ( @@ -151,13 +161,7 @@ export function MapReadout({

- {/* History drives the chart, so they sit together with no divider. */} -
- {historyControl} - {chart} -
- -
+
+ + {/* History drives the chart, so they sit together with no divider. */} +
+ {historyControl} + {chart} +
); } diff --git a/src/components/map/TimeSeriesPlot.tsx b/src/components/map/TimeSeriesPlot.tsx index 99f2c80..d25c254 100644 --- a/src/components/map/TimeSeriesPlot.tsx +++ b/src/components/map/TimeSeriesPlot.tsx @@ -25,12 +25,15 @@ type TimeSeriesPlotProps = { values: Float32Array; units?: string | null; hoursPerDay?: number; + /** Plot height in CSS px. The PDF export renders taller than the sidebar does. */ + height?: number; }; export function TimeSeriesPlot({ values, units, hoursPerDay = 24, + height = TIME_SERIES_PLOT_HEIGHT, }: TimeSeriesPlotProps) { const { isLight } = useTheme(); const data = useMemo( @@ -45,7 +48,7 @@ export function TimeSeriesPlot({ return (
- +