+ );
+}
diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx
index 553984e..d4bc343 100644
--- a/src/components/map/EarthMap.tsx
+++ b/src/components/map/EarthMap.tsx
@@ -37,6 +37,14 @@ import { MapSideControls } from "@/components/map/MapSideControls";
import { MapSearch } from "@/components/map/MapSearch";
import { MapReadout } from "@/components/map/MapReadout";
import { GlobeSelectionOverlay } from "@/components/map/GlobeSelectionOverlay";
+import {
+ collectAttribution,
+ type MapSnapshot,
+} from "@/lib/export/mapSnapshot";
+
+// Hoisted so the object identity is stable: maplibre only reads these at
+// context creation, and a fresh literal each render churns the prop diff.
+const MAP_CANVAS_ATTRIBUTES = { preserveDrawingBuffer: true } as const;
function toMapViewState(
viewState: {
@@ -268,6 +276,22 @@ export function EarthMap() {
[applyDarkMapLabelColors],
);
+ /**
+ * Hand the live map canvas to the PDF export. `redraw` forces a synchronous
+ * frame first: with `preserveDrawingBuffer` the buffer holds the last painted
+ * frame, and reading it right after a pan would otherwise catch a stale one.
+ */
+ const getMapSnapshot = useCallback((): MapSnapshot | null => {
+ const map = mapRef.current?.getMap();
+ if (!map) return null;
+
+ map.redraw();
+ return {
+ canvas: map.getCanvas(),
+ attribution: collectAttribution(map.getStyle()?.sources),
+ };
+ }, []);
+
return (
}
preview={
@@ -322,6 +347,9 @@ export function EarthMap() {
onLoad={handleMapLoad}
onStyleData={handleStyleData}
attributionControl={false}
+ // Lets the PDF export read the rendered frame back off the WebGL
+ // canvas. Without it the buffer is cleared after each paint.
+ canvasContextAttributes={MAP_CANVAS_ATTRIBUTES}
cursor="crosshair"
style={{ width: "100%", height: "100%" }}
>
diff --git a/src/components/map/ExportStage.tsx b/src/components/map/ExportStage.tsx
new file mode 100644
index 0000000..e1b5998
--- /dev/null
+++ b/src/components/map/ExportStage.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { createRoot } from "react-dom/client";
+import { FingerprintPlot } from "@/components/map/FingerprintPlot";
+import { TimeSeriesPlot } from "@/components/map/TimeSeriesPlot";
+import {
+ canvasToPng,
+ nextFrame,
+ svgToPng,
+ waitUntil,
+ type CapturedImage,
+} from "@/lib/export/capture";
+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;
+
+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 (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function createHost(): HTMLDivElement {
+ const host = document.createElement("div");
+ host.setAttribute("aria-hidden", "true");
+ Object.assign(host.style, {
+ position: "fixed",
+ top: "0",
+ // Offscreen rather than hidden: `display:none` and `visibility:hidden` both
+ // stop ResizeObserver from reporting a width, which the plots need to draw.
+ left: "-20000px",
+ width: `${EXPORT_STAGE_WIDTH}px`,
+ background: "#ffffff",
+ pointerEvents: "none",
+ });
+ document.body.appendChild(host);
+ return host;
+}
+
+/**
+ * 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<{ timeSeries: CapturedImage; fingerprint: CapturedImage }> {
+ const host = createHost();
+ 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),
+ fingerprint: canvasToPng(canvas),
+ };
+ } finally {
+ root.unmount();
+ host.remove();
+ }
+}
diff --git a/src/components/map/MapReadout.tsx b/src/components/map/MapReadout.tsx
index 1e2c91f..366b676 100644
--- a/src/components/map/MapReadout.tsx
+++ b/src/components/map/MapReadout.tsx
@@ -10,6 +10,8 @@ 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 { DownloadMenu } from "@/components/map/DownloadMenu";
+import type { MapSnapshot } from "@/lib/export/mapSnapshot";
type PlotView = "line" | "fingerprint";
@@ -25,6 +27,8 @@ type MapReadoutProps = {
seriesError: string | null;
seriesValues: Float32Array | null;
seriesUnits: string | null;
+ /** Reads the live map canvas for the PDF report's map preview. */
+ getMapSnapshot?: () => MapSnapshot | null;
};
const SECTION_LABEL = "text-[13px] font-semibold text-editor-fg-primary";
@@ -40,6 +44,7 @@ export function MapReadout({
seriesError,
seriesValues,
seriesUnits,
+ getMapSnapshot,
}: MapReadoutProps) {
const [plotView, setPlotView] = useState("line");
const historyLabel =
@@ -94,11 +99,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 ? (
diff --git a/src/icons/DownloadIcon.tsx b/src/icons/DownloadIcon.tsx
new file mode 100644
index 0000000..0049abb
--- /dev/null
+++ b/src/icons/DownloadIcon.tsx
@@ -0,0 +1,19 @@
+export function DownloadIcon() {
+ return (
+
+ );
+}
diff --git a/src/lib/export/capture.ts b/src/lib/export/capture.ts
new file mode 100644
index 0000000..130fd90
--- /dev/null
+++ b/src/lib/export/capture.ts
@@ -0,0 +1,116 @@
+export type CapturedImage = {
+ dataUrl: string;
+ width: number;
+ height: number;
+};
+
+/** Serialized SVG loses the page stylesheet, so the font has to travel with it. */
+const EXPORT_FONT_STACK =
+ "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
+
+export function nextFrame(): Promise {
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
+}
+
+/**
+ * Poll on animation frames until `predicate` holds. The offscreen plots need a
+ * layout pass plus a paint before their SVG/canvas has real dimensions, and
+ * there is no event that fires for "Recharts has finished measuring".
+ */
+export async function waitUntil(
+ predicate: () => boolean,
+ { timeoutMs = 3000, label = "render" }: { timeoutMs?: number; label?: string } = {},
+): Promise {
+ const deadline = performance.now() + timeoutMs;
+
+ while (!predicate()) {
+ if (performance.now() > deadline) {
+ throw new Error(`Timed out waiting for ${label}`);
+ }
+ await nextFrame();
+ }
+}
+
+function drawToPng(
+ source: CanvasImageSource,
+ width: number,
+ height: number,
+ scale: number,
+ background: string | null,
+): CapturedImage {
+ const target = document.createElement("canvas");
+ target.width = Math.round(width * scale);
+ target.height = Math.round(height * scale);
+
+ const ctx = target.getContext("2d");
+ if (!ctx) throw new Error("Could not get a 2D context for export");
+
+ if (background) {
+ ctx.fillStyle = background;
+ ctx.fillRect(0, 0, target.width, target.height);
+ }
+ ctx.drawImage(source, 0, 0, target.width, target.height);
+
+ return { dataUrl: target.toDataURL("image/png"), width, height };
+}
+
+/**
+ * Rasterise a live `
diff --git a/src/components/map/FingerprintPlot.tsx b/src/components/map/FingerprintPlot.tsx
index 022973f..468da2c 100644
--- a/src/components/map/FingerprintPlot.tsx
+++ b/src/components/map/FingerprintPlot.tsx
@@ -23,6 +23,8 @@ 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;
};
/**
@@ -60,6 +62,7 @@ export function FingerprintPlot({
values,
units,
hoursPerDay = 24,
+ height = TIME_SERIES_PLOT_HEIGHT,
}: FingerprintPlotProps) {
const { isLight } = useTheme();
const wrapperRef = useRef(null);
@@ -122,7 +125,6 @@ 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;
canvas.width = Math.round(width * dpr);
@@ -231,6 +233,7 @@ export function FingerprintPlot({
hoursPerDay,
isLight,
width,
+ height,
transposed,
]);
@@ -246,7 +249,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 366b676..46b264f 100644
--- a/src/components/map/MapReadout.tsx
+++ b/src/components/map/MapReadout.tsx
@@ -10,8 +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 { DownloadMenu } from "@/components/map/DownloadMenu";
-import type { MapSnapshot } from "@/lib/export/mapSnapshot";
+import { DownloadButton } from "@/components/map/DownloadButton";
type PlotView = "line" | "fingerprint";
@@ -27,8 +26,6 @@ type MapReadoutProps = {
seriesError: string | null;
seriesValues: Float32Array | null;
seriesUnits: string | null;
- /** Reads the live map canvas for the PDF report's map preview. */
- getMapSnapshot?: () => MapSnapshot | null;
};
const SECTION_LABEL = "text-[13px] font-semibold text-editor-fg-primary";
@@ -44,7 +41,6 @@ export function MapReadout({
seriesError,
seriesValues,
seriesUnits,
- getMapSnapshot,
}: MapReadoutProps) {
const [plotView, setPlotView] = useState("line");
const historyLabel =
@@ -106,12 +102,12 @@ export function MapReadout({
{/* View switch left, download right, on the row between title and plot. */}
-
@@ -165,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 (
-
+ {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
+/**
+ * A container parked off the left edge of the page for the export-only React
+ * roots to mount into. Offscreen rather than hidden: `display:none` and
+ * `visibility:hidden` both stop ResizeObserver from reporting a size, which
+ * every plot and the map need before they will draw.
+ *
+ * The caller removes it.
+ */
+export function createOffscreenHost(
+ width: number,
+ height?: number,
+): HTMLDivElement {
+ const host = document.createElement("div");
+ host.setAttribute("aria-hidden", "true");
+ Object.assign(host.style, {
+ position: "fixed",
+ top: "0",
+ left: "-20000px",
+ width: `${width}px`,
+ ...(height === undefined ? {} : { height: `${height}px` }),
+ background: "#ffffff",
+ pointerEvents: "none",
+ });
+ document.body.appendChild(host);
+ return host;
+}
+
/**
* Poll on animation frames until `predicate` holds. The offscreen plots need a
* layout pass plus a paint before their SVG/canvas has real dimensions, and
diff --git a/src/lib/export/csv.test.ts b/src/lib/export/csv.test.ts
deleted file mode 100644
index 693d012..0000000
--- a/src/lib/export/csv.test.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { CSV_COLUMNS, buildSeriesCsv } from "@/lib/export/csv";
-import { buildProvenance, exportFileBaseName } from "@/lib/export/provenance";
-import { buildSeriesRows } from "@/lib/export/rows";
-import { ZARR_TIME } from "@/lib/zarr/timeRange";
-import type { MapSelection } from "@/types/map";
-
-const SELECTION: MapSelection = {
- click: { lon: 11.5669, lat: 50.9128 },
- grid: { lon: 11.575, lat: 50.925, lonIndex: 3831, latIndex: 780 },
-};
-
-function csvForDays(days: number, mutate?: (values: Float32Array) => void) {
- const prov = buildProvenance({
- selection: SELECTION,
- historyYears: 1,
- valueCount: days * ZARR_TIME.hoursPerDay,
- units: "gC m-2 d-1",
- });
- const values = new Float32Array(days * ZARR_TIME.hoursPerDay).fill(1.5);
- mutate?.(values);
-
- return {
- prov,
- text: buildSeriesCsv(buildSeriesRows(values, prov), prov),
- };
-}
-
-describe("buildSeriesCsv", () => {
- it("keeps provenance in comment lines above the column header", () => {
- const { text } = csvForDays(2);
- const lines = text.split("\n");
- const headerIndex = lines.indexOf(CSV_COLUMNS);
-
- expect(headerIndex).toBeGreaterThan(0);
- expect(lines.slice(0, headerIndex).every((l) => l.startsWith("# "))).toBe(
- true,
- );
- });
-
- it("records the pixel and window a reader would need to reproduce it", () => {
- const { text, prov } = csvForDays(2);
-
- expect(text).toContain(`# cell_lat: ${SELECTION.grid.lat}`);
- expect(text).toContain(`# cell_lon: ${SELECTION.grid.lon}`);
- expect(text).toContain(`# lat_index: ${SELECTION.grid.latIndex}`);
- expect(text).toContain(`# units: gC m-2 d-1`);
- expect(text).toContain(
- `# window_start: ${prov.windowStart.toISOString().slice(0, 10)}`,
- );
- });
-
- it("writes one data line per hour, plus a trailing newline", () => {
- const days = 3;
- const { text } = csvForDays(days);
- const lines = text.split("\n");
- const dataLines = lines.slice(lines.indexOf(CSV_COLUMNS) + 1, -1);
-
- expect(text.endsWith("\n")).toBe(true);
- expect(dataLines).toHaveLength(days * ZARR_TIME.hoursPerDay);
- expect(text).toContain(`# rows: ${days * ZARR_TIME.hoursPerDay}`);
- });
-
- it("writes missing values as an empty field", () => {
- const { text } = csvForDays(1, (values) => {
- values[3] = Number.NaN;
- });
- const dataLines = text.split("\n").slice(-25, -1);
-
- expect(dataLines[3].endsWith(",")).toBe(true);
- expect(dataLines[3].split(",")).toHaveLength(4);
- });
-
- it("says unspecified rather than null when units are absent", () => {
- const prov = buildProvenance({
- selection: SELECTION,
- historyYears: 1,
- valueCount: ZARR_TIME.hoursPerDay,
- });
-
- expect(buildSeriesCsv([], prov)).toContain("# units: unspecified");
- });
-});
-
-describe("exportFileBaseName", () => {
- it("names files by variable, hemisphere-tagged cell, and window", () => {
- const { prov } = csvForDays(2);
-
- expect(exportFileBaseName(prov)).toMatch(
- /^earthprints_NEE_50\.925N_11\.575E_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}$/,
- );
- });
-
- it("tags southern and western coordinates without a minus sign", () => {
- const prov = buildProvenance({
- selection: {
- click: { lon: -60.1, lat: -3.2 },
- grid: { lon: -60.125, lat: -3.225, lonIndex: 2397, latIndex: 1864 },
- },
- historyYears: 1,
- valueCount: ZARR_TIME.hoursPerDay,
- });
-
- expect(exportFileBaseName(prov)).toContain("3.225S_60.125W");
- });
-});
diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts
deleted file mode 100644
index 93776a2..0000000
--- a/src/lib/export/csv.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { isoDate, type ExportProvenance } from "./provenance";
-import type { SeriesRow } from "./rows";
-
-export const CSV_COLUMNS = "timestamp_utc,day_index,hour,value";
-
-/**
- * Provenance rides along as `#` comment lines. A file of bare numbers is
- * useless six months later, and every common reader skips these:
- * `pandas.read_csv(path, comment="#")`, `readr::read_csv(comment = "#")`.
- */
-function buildHeader(rowCount: number, prov: ExportProvenance): string[] {
- return [
- "EarthPrints export",
- `generated: ${prov.generatedAt.toISOString()}`,
- `dataset: ${prov.dataset}`,
- `grid: ${prov.resolutionDeg} deg, hourly`,
- `source: ${prov.sourceUrl}`,
- `variable: ${prov.variable}`,
- `units: ${prov.units ?? "unspecified"}`,
- `click_lat: ${prov.click.lat}`,
- `click_lon: ${prov.click.lon}`,
- `cell_lat: ${prov.cell.lat}`,
- `cell_lon: ${prov.cell.lon}`,
- `lat_index: ${prov.cell.latIndex}`,
- `lon_index: ${prov.cell.lonIndex}`,
- `history_years: ${prov.historyYears}`,
- `window_start: ${isoDate(prov.windowStart)}`,
- `window_end: ${isoDate(prov.windowEnd)}`,
- `rows: ${rowCount}`,
- ].map((line) => `# ${line}`);
-}
-
-/**
- * Serialise the hourly series. Every column is numeric or an ISO timestamp, so
- * no field can contain a comma or quote and no escaping is required.
- * Missing values are written empty, which reads back as NaN.
- */
-export function buildSeriesCsv(
- rows: SeriesRow[],
- prov: ExportProvenance,
-): string {
- const lines = buildHeader(rows.length, prov);
- lines.push(CSV_COLUMNS);
-
- for (const row of rows) {
- lines.push(
- `${row.timestamp.toISOString()},${row.dayIndex},${row.hour},${
- row.value ?? ""
- }`,
- );
- }
-
- return `${lines.join("\n")}\n`;
-}
diff --git a/src/lib/export/download.ts b/src/lib/export/download.ts
index 59ce447..80e5905 100644
--- a/src/lib/export/download.ts
+++ b/src/lib/export/download.ts
@@ -14,12 +14,3 @@ export function downloadBlob(blob: Blob, filename: string): void {
// click settle first.
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
-
-export function downloadText(
- text: string,
- filename: string,
- mimeType: string,
-): void {
- // The BOM keeps Excel from mangling non-ASCII when it opens a CSV directly.
- downloadBlob(new Blob(["", text], { type: mimeType }), filename);
-}
diff --git a/src/lib/export/mapSnapshot.ts b/src/lib/export/mapSnapshot.ts
index 440d8dd..a069b4b 100644
--- a/src/lib/export/mapSnapshot.ts
+++ b/src/lib/export/mapSnapshot.ts
@@ -4,11 +4,6 @@ import type { StyleSpecification } from "maplibre-gl";
const FALLBACK_ATTRIBUTION =
"OpenFreeMap, OpenMapTiles, OpenStreetMap contributors";
-export type MapSnapshot = {
- canvas: HTMLCanvasElement;
- attribution: string;
-};
-
/** Style attributions ship as HTML anchors; a PDF footer wants the words only. */
export function plainText(html: string): string {
return html
diff --git a/src/lib/export/pdf.test.ts b/src/lib/export/pdf.test.ts
new file mode 100644
index 0000000..39a950e
--- /dev/null
+++ b/src/lib/export/pdf.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { buildProvenance } from "@/lib/export/provenance";
+import { buildReportPdf, type ReportAssets } from "@/lib/export/pdf";
+import type { CapturedImage } from "@/lib/export/capture";
+import type { MapSelection } from "@/types/map";
+
+/** 1x1 red PNG. jsPDF only needs something it can decode. */
+const PNG =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
+
+const image = (width: number, height: number): CapturedImage => ({
+ dataUrl: PNG,
+ width,
+ height,
+});
+
+const SELECTION: MapSelection = {
+ click: { lat: 52.058, lon: 15.862 },
+ grid: { lat: 52.075, lon: 15.875, latIndex: 758, lonIndex: 3917 },
+};
+
+function fixture(map: CapturedImage | null) {
+ const values = new Float32Array(24 * 365);
+ for (let i = 0; i < values.length; i += 1) {
+ values[i] = Math.sin(i / 24) * 3;
+ }
+
+ const prov = buildProvenance({
+ selection: SELECTION,
+ historyYears: 1,
+ valueCount: values.length,
+ units: "umolCO2 m-2 s-1",
+ });
+
+ const assets: ReportAssets = {
+ map,
+ timeSeries: image(1520, 440),
+ fingerprint: image(1520, 620),
+ };
+
+ return { prov, assets, values };
+}
+
+describe("buildReportPdf", () => {
+ it("assembles a one-page report", async () => {
+ const { prov, assets, values } = fixture(image(1600, 900));
+
+ const blob = await buildReportPdf({
+ prov,
+ assets,
+ values,
+ attribution: "OpenFreeMap, OpenStreetMap contributors",
+ });
+
+ expect(blob.type).toBe("application/pdf");
+ expect(blob.size).toBeGreaterThan(1000);
+ });
+
+ // The map preview is the one asset that can legitimately go missing, when the
+ // WebGL buffer cannot be read back.
+ it("still renders when the map capture failed", async () => {
+ const { prov, assets, values } = fixture(null);
+
+ const blob = await buildReportPdf({ prov, assets, values, attribution: "" });
+
+ expect(blob.size).toBeGreaterThan(1000);
+ });
+
+ // A tall, narrow map has to be cropped to fill the box rather than letterboxed,
+ // and the clip that does the cropping must be balanced or jsPDF throws.
+ it("crops a map whose aspect ratio does not match the box", async () => {
+ const { prov, assets, values } = fixture(image(600, 1400));
+
+ const blob = await buildReportPdf({ prov, assets, values, attribution: "" });
+
+ expect(blob.size).toBeGreaterThan(1000);
+ });
+});
diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts
index 917f0b3..4b17889 100644
--- a/src/lib/export/pdf.ts
+++ b/src/lib/export/pdf.ts
@@ -14,6 +14,9 @@ const CONTENT_W = PAGE_W - MARGIN * 2;
const COL_GAP = 6;
const COL_W = (CONTENT_W - COL_GAP) / 2;
const HEAD_ROW_H = 54;
+/** Both head-row columns hang their label and content off these, so the two align. */
+const LABEL_DY = 3;
+const CONTENT_DY = 7;
type Rgb = [number, number, number];
@@ -54,15 +57,18 @@ function parseRgb(value: string): Rgb {
return [Number(parts[0]), Number(parts[1]), Number(parts[2])];
}
-/** Fit `image` inside the box, centred, without cropping or distorting it. */
-function containRect(
+/**
+ * Scale `image` to fill the box completely, centred, overflowing on whichever
+ * axis is proportionally longer. The caller clips to the box.
+ */
+function coverRect(
image: CapturedImage,
x: number,
y: number,
w: number,
h: number,
) {
- const scale = Math.min(w / image.width, h / image.height);
+ const scale = Math.max(w / image.width, h / image.height);
const drawW = image.width * scale;
const drawH = image.height * scale;
return {
@@ -73,12 +79,6 @@ function containRect(
};
}
-function drawPanel(doc: jsPDF, x: number, y: number, w: number, h: number) {
- doc.setDrawColor(...BORDER);
- doc.setLineWidth(0.3);
- doc.roundedRect(x, y, w, h, 1.5, 1.5, "S");
-}
-
function drawSectionLabel(doc: jsPDF, text: string, x: number, y: number) {
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
@@ -86,18 +86,14 @@ function drawSectionLabel(doc: jsPDF, text: string, x: number, y: number) {
doc.text(text.toUpperCase(), x, y);
}
-/** Left panel of the head row: what was selected, and from where. */
+/** Left column of the head row: what was selected, and from where. Bare text, no frame. */
function drawFacts(
doc: jsPDF,
prov: ExportProvenance,
rowCount: number,
x: number,
y: number,
- w: number,
- h: number,
) {
- drawPanel(doc, x, y, w, h);
-
const rows: [string, string][] = [
["Cell centre", formatLatLon(prov.cell.lat, prov.cell.lon)],
["Clicked", formatLatLon(prov.click.lat, prov.click.lon)],
@@ -108,26 +104,29 @@ function drawFacts(
["Coverage", `${prov.dayCount} days, ${rowCount.toLocaleString()} hours`],
];
- const padX = 4;
- let cursor = y + 8;
- drawSectionLabel(doc, "Selection", x + padX, cursor);
- cursor += 5.5;
+ drawSectionLabel(doc, "Selection", x, y + LABEL_DY);
+ let cursor = y + CONTENT_DY + 3;
for (const [label, value] of rows) {
doc.setFont("helvetica", "normal");
- doc.setFontSize(7.5);
+ doc.setFontSize(11);
doc.setTextColor(...INK_SOFT);
- doc.text(label, x + padX, cursor);
+ doc.text(label, x, cursor);
doc.setFont("courier", "normal");
- doc.setFontSize(8);
+ doc.setFontSize(11);
doc.setTextColor(...INK);
- doc.text(value, x + padX + 24, cursor);
+ doc.text(value, x + 24, cursor);
cursor += 5.6;
}
}
+/**
+ * Right column of the head row: the label sits above the image, and the image
+ * fills the box edge to edge. Cropping the overflow needs a clip, since jsPDF
+ * would otherwise let the oversized image bleed across the page.
+ */
function drawMapPanel(
doc: jsPDF,
map: CapturedImage | null,
@@ -136,27 +135,28 @@ function drawMapPanel(
w: number,
h: number,
) {
- drawPanel(doc, x, y, w, h);
+ drawSectionLabel(doc, "Map view", x, y + LABEL_DY);
- const padX = 4;
- drawSectionLabel(doc, "Map view", x + padX, y + 8);
-
- const boxY = y + 11;
- const boxH = h - 15;
+ const boxY = y + CONTENT_DY;
+ const boxH = h - CONTENT_DY;
if (!map) {
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.setTextColor(...INK_SOFT);
- doc.text("Map preview unavailable", x + padX, boxY + boxH / 2);
+ doc.text("Map preview unavailable", x, boxY + boxH / 2);
return;
}
- const fit = containRect(map, x + padX, boxY, w - padX * 2, boxH);
+ const fit = coverRect(map, x, boxY, w, boxH);
+ doc.saveGraphicsState();
+ // The null style matters: without it jsPDF strokes the clip rect, outlining
+ // the image with a border the layout is not supposed to have.
+ doc.rect(x, boxY, w, boxH, null);
+ doc.clip();
+ doc.discardPath();
doc.addImage(map.dataUrl, "PNG", fit.x, fit.y, fit.w, fit.h);
- doc.setDrawColor(...BORDER);
- doc.setLineWidth(0.2);
- doc.rect(fit.x, fit.y, fit.w, fit.h, "S");
+ doc.restoreGraphicsState();
}
/**
@@ -225,9 +225,11 @@ function drawFooter(doc: jsPDF, prov: ExportProvenance, attribution: string) {
const lines = [
`Source: ${prov.sourceUrl}`,
- `Basemap: ${attribution}`,
+ // Nothing to credit when the map could not be drawn, and an empty
+ // "Basemap:" reads as a missing value rather than an absent one.
+ attribution ? `Basemap: ${attribution}` : null,
`Generated ${prov.generatedAt.toISOString()} by EarthPrints`,
- ];
+ ].filter((line): line is string => line !== null);
let cursor = pageH - 13.5;
for (const line of lines) {
@@ -266,7 +268,7 @@ export async function buildReportPdf({
const headY = MARGIN + 14;
const rowCount = prov.dayCount * prov.hoursPerDay;
- drawFacts(doc, prov, rowCount, MARGIN, headY, COL_W, HEAD_ROW_H);
+ drawFacts(doc, prov, rowCount, MARGIN, headY);
drawMapPanel(
doc,
assets.map,
diff --git a/src/lib/export/provenance.ts b/src/lib/export/provenance.ts
index ea6a55e..2f0d968 100644
--- a/src/lib/export/provenance.ts
+++ b/src/lib/export/provenance.ts
@@ -4,8 +4,8 @@ import type { GeoPoint, GridCell, MapSelection } from "@/types/map";
/**
* Everything an export needs to describe where its numbers came from. Built
- * once per export and shared by the PDF, CSV and XLSX writers so the three can
- * never disagree about the pixel, the window or the units.
+ * once per export and shared by the PDF and XLSX writers so the two can never
+ * disagree about the pixel, the window or the units.
*/
export type ExportProvenance = {
generatedAt: Date;
@@ -84,7 +84,8 @@ function coordinateTag(value: number, positive: string, negative: string): strin
}
/**
- * Shared stem for every download, so a PDF and its CSV sort next to each other:
+ * Shared stem for every download, so a PDF and its workbook sort next to each
+ * other:
* `earthprints_NEE_50.913N_11.567E_2025-01-01_2025-12-31`.
*/
export function exportFileBaseName(prov: ExportProvenance): string {
diff --git a/src/lib/export/rows.ts b/src/lib/export/rows.ts
index effe894..1cd11d8 100644
--- a/src/lib/export/rows.ts
+++ b/src/lib/export/rows.ts
@@ -25,7 +25,7 @@ function roundFloat32(value: number): number {
/**
* Flatten the loaded `[day, hour]` series into dated rows. The single row
- * builder behind both CSV and XLSX, so the two cannot drift apart.
+ * builder behind the workbook, and the seam a second table format would reuse.
*/
export function buildSeriesRows(
values: Float32Array,
diff --git a/src/lib/export/xlsx.test.ts b/src/lib/export/xlsx.test.ts
index 211cae1..3b99aa1 100644
--- a/src/lib/export/xlsx.test.ts
+++ b/src/lib/export/xlsx.test.ts
@@ -51,7 +51,7 @@ describe("buildWorkbookSheets", () => {
expect(data[2][3]).toBe(1.5);
});
- it("carries the same provenance the CSV header does, on its own sheet", () => {
+ it("carries the provenance facts on its own sheet", () => {
const { metadata, prov } = sheetsForDays(2);
const facts = new Map(
metadata.slice(1).map((row) => [row[0] as string, row[1]]),
diff --git a/src/lib/export/xlsx.ts b/src/lib/export/xlsx.ts
index 00cce08..974d2c4 100644
--- a/src/lib/export/xlsx.ts
+++ b/src/lib/export/xlsx.ts
@@ -11,9 +11,9 @@ const BOLD = { fontWeight: "bold" } as const;
* Shape the two sheets. Split out from the writer so the layout can be tested
* without pulling the xlsx bundle into the test run.
*
- * The workbook earns its place over the CSV precisely here: provenance sits on
- * its own sheet rather than in `#` comment lines, and timestamps are real date
- * cells rather than text that Excel re-parses by locale.
+ * The workbook earns its place over a plain CSV precisely here: provenance sits
+ * on its own sheet rather than in `#` comment lines, and timestamps are real
+ * date cells rather than text that Excel re-parses by locale.
*/
export function buildWorkbookSheets(
rows: SeriesRow[],
diff --git a/src/lib/zarr/store.test.ts b/src/lib/zarr/store.test.ts
index fc9f3db..d435492 100644
--- a/src/lib/zarr/store.test.ts
+++ b/src/lib/zarr/store.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it, vi } from "vitest";
-import { createByteProgressSink } from "@/lib/zarr/store";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createByteProgressSink, fetchWithRetry } from "@/lib/zarr/store";
describe("createByteProgressSink", () => {
it("reports running loaded/total as bytes arrive", () => {
@@ -28,3 +28,121 @@ describe("createByteProgressSink", () => {
expect(onProgress).toHaveBeenLastCalledWith(50, 200);
});
});
+
+describe("fetchWithRetry", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ /**
+ * Drive the backoff sleeps so the whole retry sequence runs in one tick.
+ * Both are awaited together so a rejection is never momentarily unhandled,
+ * which the runner reports as a failure of its own.
+ */
+ async function settle(pending: Promise): Promise {
+ const [response] = await Promise.all([
+ pending,
+ vi.advanceTimersByTimeAsync(5_000),
+ ]);
+ return response;
+ }
+
+ function stubFetch(...responses: (Response | Error)[]) {
+ const fetchMock = vi.fn(async () => {
+ const next = responses.shift();
+ if (next instanceof Error) throw next;
+ return next ?? new Response(null, { status: 200 });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
+ }
+
+ it("retries a gateway error and returns the response that lands", async () => {
+ const fetchMock = stubFetch(
+ new Response("busy", { status: 503 }),
+ new Response("chunk", { status: 200 }),
+ );
+
+ const response = await settle(
+ fetchWithRetry(new Request("https://example.com/c/0/0")),
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(await response.text()).toBe("chunk");
+ });
+
+ it("retries a dropped connection", async () => {
+ const fetchMock = stubFetch(
+ new TypeError("Failed to fetch"),
+ new Response("chunk", { status: 200 }),
+ );
+
+ const response = await settle(
+ fetchWithRetry(new Request("https://example.com/c/0/0")),
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(response.status).toBe(200);
+ });
+
+ it("gives up after three attempts and hands back the last response", async () => {
+ const fetchMock = stubFetch(
+ new Response(null, { status: 500 }),
+ new Response(null, { status: 500 }),
+ new Response(null, { status: 500 }),
+ );
+
+ const response = await settle(
+ fetchWithRetry(new Request("https://example.com/c/0/0")),
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(response.status).toBe(500);
+ });
+
+ it("rethrows when every attempt throws", async () => {
+ const fetchMock = stubFetch(
+ new TypeError("Failed to fetch"),
+ new TypeError("Failed to fetch"),
+ new TypeError("Failed to fetch"),
+ );
+
+ await expect(
+ settle(fetchWithRetry(new Request("https://example.com/c/0/0"))),
+ ).rejects.toThrow("Failed to fetch");
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ // zarrita reads a 404 as a missing chunk, so retrying one only delays a
+ // legitimate answer.
+ it("passes a 404 straight back", async () => {
+ const fetchMock = stubFetch(new Response(null, { status: 404 }));
+
+ const response = await settle(
+ fetchWithRetry(new Request("https://example.com/c/9/9")),
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(response.status).toBe(404);
+ });
+
+ it("does not retry a request the caller aborted", async () => {
+ const controller = new AbortController();
+ const request = new Request("https://example.com/c/0/0", {
+ signal: controller.signal,
+ });
+ const fetchMock = vi.fn(async () => {
+ controller.abort();
+ throw new Error("Aborted");
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(settle(fetchWithRetry(request))).rejects.toThrow("Aborted");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts
index 2963f13..4d7dca1 100644
--- a/src/lib/zarr/store.ts
+++ b/src/lib/zarr/store.ts
@@ -50,6 +50,53 @@ export function getActiveByteSink(): ByteSink | null {
return activeByteSink;
}
+/**
+ * Attempts per request, the first included. A picked coordinate fans out into
+ * one request per native chunk, and a single flaky one fails the whole series,
+ * so a transient error is retried here rather than bubbling up as "could not
+ * load" for the user to click through again.
+ */
+const FETCH_ATTEMPTS = 3;
+
+/** Doubles per retry: 400ms, then 800ms. */
+const RETRY_BASE_DELAY_MS = 400;
+
+/**
+ * Statuses worth a second look. 404 is deliberately absent: zarrita reads it as
+ * a missing chunk, which is a legitimate answer rather than a failure.
+ */
+function isRetryableStatus(status: number): boolean {
+ return status === 408 || status === 425 || status === 429 || status >= 500;
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * `fetch` with a couple of retries for the errors that clear on their own:
+ * dropped connections, and the throttling and gateway statuses the store
+ * would otherwise throw on.
+ */
+export async function fetchWithRetry(request: Request): Promise {
+ for (let attempt = 1; ; attempt += 1) {
+ const last = attempt === FETCH_ATTEMPTS;
+
+ try {
+ // A Request can only be sent once, so each attempt gets its own copy.
+ const response = await fetch(last ? request : request.clone());
+ if (last || !isRetryableStatus(response.status)) return response;
+ // Nothing will read this body; release the connection before retrying.
+ await response.body?.cancel();
+ } catch (error) {
+ // An abort is the caller's decision, not a transient failure.
+ if (last || request.signal?.aborted) throw error;
+ }
+
+ await delay(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
+ }
+}
+
/**
* A `fetch` for zarrita's store that tees each chunk response through a
* counting stream so callers can show real download progress. Anything it
@@ -61,7 +108,7 @@ async function progressFetch(request: Request): Promise {
// the request that was active when it started even if another request swaps
// the global sink in while we await the response.
const sink = activeByteSink;
- const response = await fetch(request);
+ const response = await fetchWithRetry(request);
const total = Number(response.headers.get("content-length"));
if (
From 14f74ba0eaab0e512678e7f034919317dc2d320e Mon Sep 17 00:00:00 2001
From: Anastasiia Ivanchenko
Date: Mon, 31 Aug 2026 23:23:45 +0300
Subject: [PATCH 3/3] Ship one zip per pick, with the plots as images
Review asked for a CSV and standalone images of the plots alongside the
workbook, and for them to arrive together rather than as a menu of
options. One click now produces a single archive, flat, every entry
carrying the full stem so they stay identifiable if extracted loose:
.pdf .xlsx .csv
_fingerprint.png _timeseries.png
fflate does the packing, already in the tree three times over and
already what write-excel-file uses for the xlsx we ship. Only the CSV
deflates; the rest carry their own compression. With one file there is
no second download for a popup blocker to eat, so the 400ms stagger
goes too.
CSV comes back as it was, minus the BOM. It was there so Excel would
not mangle non-ASCII on a direct open, but the rows are ASCII, Excel
users have the workbook, and a BOM ahead of the first `#` line is what
trips pandas.read_csv(comment="#").
Images:
- Both captures render at a pinned pixel ratio of 3 rather than the
display's own, so the output is as sharp on one machine as the next.
FingerprintPlot takes the ratio as a prop.
- The heatmap's colour ramp lives in HTML beside the canvas, so a raw
copy of it is a field of colour with nothing to read it against. The
standalone PNG gets the ramp and its end labels painted in. The PDF
still takes the bare canvas, since jsPDF draws its own legend.
Also fix an export that could hang forever. waitUntil only checked its
deadline between animation frames, which a hidden tab never delivers,
so switching tabs mid-export left the button on "Preparing..." with no
way out. Frames now fall back to a timer, the deadline stops counting
time the tab spent hidden, and both offscreen stages wait for the tab
to be rendering before they mount, since nothing lays out or loads
tiles there anyway.
---
package-lock.json | 31 +++----
package.json | 1 +
src/components/map/DownloadButton.tsx | 51 ++++++++---
src/components/map/ExportMapStage.tsx | 5 ++
src/components/map/ExportStage.tsx | 52 ++++++-----
src/components/map/FingerprintPlot.tsx | 11 ++-
src/lib/export/capture.ts | 45 +++++++++-
src/lib/export/csv.test.ts | 106 +++++++++++++++++++++++
src/lib/export/csv.ts | 54 ++++++++++++
src/lib/export/fingerprintImage.ts | 114 +++++++++++++++++++++++++
src/lib/export/zip.test.ts | 81 ++++++++++++++++++
src/lib/export/zip.ts | 60 +++++++++++++
12 files changed, 553 insertions(+), 58 deletions(-)
create mode 100644 src/lib/export/csv.test.ts
create mode 100644 src/lib/export/csv.ts
create mode 100644 src/lib/export/fingerprintImage.ts
create mode 100644 src/lib/export/zip.test.ts
create mode 100644 src/lib/export/zip.ts
diff --git a/package-lock.json b/package-lock.json
index eb48f3d..e337181 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"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.11",
@@ -1624,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",
@@ -7246,9 +7253,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": {
@@ -8516,12 +8523,6 @@
"html2canvas": "^1.0.0-rc.5"
}
},
- "node_modules/jspdf/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/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -9350,12 +9351,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",
@@ -11900,12 +11895,6 @@
"node": ">=18"
}
},
- "node_modules/write-excel-file/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/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 bb66dad..c0e6e0d 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"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.11",
diff --git a/src/components/map/DownloadButton.tsx b/src/components/map/DownloadButton.tsx
index b92e228..5927c87 100644
--- a/src/components/map/DownloadButton.tsx
+++ b/src/components/map/DownloadButton.tsx
@@ -4,11 +4,13 @@ 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 = {
@@ -19,12 +21,6 @@ type DownloadButtonProps = {
units: string | null;
};
-/**
- * Two files land back to back. Browsers treat a rapid second download as a
- * popup, so the workbook waits a beat rather than racing the report.
- */
-const SECOND_FILE_DELAY_MS = 400;
-
export function DownloadButton({
selection,
gridSpec,
@@ -59,8 +55,13 @@ export function DownloadButton({
hoursPerDay: prov.hoursPerDay,
}),
]);
- const assets: ReportAssets = { map: mapCapture.image, ...plots };
+ 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,
@@ -68,14 +69,36 @@ export function DownloadButton({
values,
attribution: mapCapture.attribution,
}),
- buildSeriesWorkbook(buildSeriesRows(values, prov), prov),
+ 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(pdf, `${base}.pdf`);
- await new Promise((resolve) =>
- setTimeout(resolve, SECOND_FILE_DELAY_MS),
- );
- downloadBlob(workbook, `${base}.xlsx`);
+ downloadBlob(archive, `${base}.zip`);
} catch (cause) {
console.error("Export failed", cause);
setError("Export failed. Try again.");
@@ -90,7 +113,7 @@ export function DownloadButton({
type="button"
disabled={!values || busy}
onClick={() => void runExport()}
- title="Downloads a PDF report and an Excel table"
+ title="Downloads a zip: PDF report, Excel and CSV tables, and both plots as images"
className="inline-flex items-center gap-1.5 rounded-md border border-editor-border px-2 py-1 text-[11.5px] font-semibold text-editor-fg-secondary transition-colors hover:border-editor-border-strong hover:text-editor-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
diff --git a/src/components/map/ExportMapStage.tsx b/src/components/map/ExportMapStage.tsx
index 7dde193..fd508e5 100644
--- a/src/components/map/ExportMapStage.tsx
+++ b/src/components/map/ExportMapStage.tsx
@@ -12,6 +12,7 @@ import {
import {
canvasToPng,
createOffscreenHost,
+ whenVisible,
type CapturedImage,
} from "@/lib/export/capture";
import { collectAttribution } from "@/lib/export/mapSnapshot";
@@ -125,6 +126,10 @@ 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);
diff --git a/src/components/map/ExportStage.tsx b/src/components/map/ExportStage.tsx
index 40683c0..10cb1cd 100644
--- a/src/components/map/ExportStage.tsx
+++ b/src/components/map/ExportStage.tsx
@@ -5,11 +5,15 @@ 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";
/**
@@ -29,6 +33,14 @@ export const EXPORT_STAGE_WIDTH = 760;
*/
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;
@@ -58,6 +70,7 @@ function ExportStage({ values, units, hoursPerDay }: StageProps) {
units={units}
hoursPerDay={hoursPerDay}
height={EXPORT_PLOT_HEIGHT}
+ pixelRatio={EXPORT_PIXEL_RATIO}
/>
@@ -65,22 +78,13 @@ function ExportStage({ values, units, hoursPerDay }: StageProps) {
);
}
-function createHost(): HTMLDivElement {
- const host = document.createElement("div");
- host.setAttribute("aria-hidden", "true");
- Object.assign(host.style, {
- position: "fixed",
- top: "0",
- // Offscreen rather than hidden: `display:none` and `visibility:hidden` both
- // stop ResizeObserver from reporting a width, which the plots need to draw.
- left: "-20000px",
- width: `${EXPORT_STAGE_WIDTH}px`,
- background: "#ffffff",
- pointerEvents: "none",
- });
- document.body.appendChild(host);
- return host;
-}
+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.
@@ -88,8 +92,13 @@ function createHost(): HTMLDivElement {
*/
export async function capturePlotsForExport(
props: StageProps,
-): Promise<{ timeSeries: CapturedImage; fingerprint: CapturedImage }> {
- const host = createHost();
+): 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 = () =>
@@ -127,8 +136,13 @@ export async function capturePlotsForExport(
if (!svg || !canvas) throw new Error("Export stage lost its plots");
return {
- timeSeries: await svgToPng(svg),
+ 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();
diff --git a/src/components/map/FingerprintPlot.tsx b/src/components/map/FingerprintPlot.tsx
index 468da2c..358c3f7 100644
--- a/src/components/map/FingerprintPlot.tsx
+++ b/src/components/map/FingerprintPlot.tsx
@@ -25,6 +25,12 @@ type FingerprintPlotProps = {
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;
};
/**
@@ -63,6 +69,7 @@ export function FingerprintPlot({
units,
hoursPerDay = 24,
height = TIME_SERIES_PLOT_HEIGHT,
+ pixelRatio,
}: FingerprintPlotProps) {
const { isLight } = useTheme();
const wrapperRef = useRef(null);
@@ -126,7 +133,8 @@ export function FingerprintPlot({
if (!canvas || width === 0 || nDays === 0) return;
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`;
@@ -234,6 +242,7 @@ export function FingerprintPlot({
isLight,
width,
height,
+ pixelRatio,
transposed,
]);
diff --git a/src/lib/export/capture.ts b/src/lib/export/capture.ts
index 719a52e..21c21b0 100644
--- a/src/lib/export/capture.ts
+++ b/src/lib/export/capture.ts
@@ -8,8 +8,43 @@ export type CapturedImage = {
const EXPORT_FONT_STACK =
"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
+/** How long to wait for a frame that is not coming before carrying on anyway. */
+const FRAME_FALLBACK_MS = 200;
+
+/**
+ * Resolve on the next animation frame, or on a timer once it is clear no frame
+ * is coming. A hidden tab is served no frames at all, and a loop that waits on
+ * one there never turns again, so an export left in the background would sit on
+ * "Preparing…" for good rather than finishing or failing.
+ */
export function nextFrame(): Promise {
- return new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ return new Promise((resolve) => {
+ const settle = () => {
+ clearTimeout(timer);
+ cancelAnimationFrame(frame);
+ resolve();
+ };
+ const frame = requestAnimationFrame(settle);
+ const timer = setTimeout(settle, FRAME_FALLBACK_MS);
+ });
+}
+
+/**
+ * Resolve once the tab is being rendered again. Nothing offscreen lays out,
+ * paints, or loads map tiles while the tab is hidden, so an export started
+ * there can only wait for the user to come back.
+ */
+export function whenVisible(): Promise {
+ if (!document.hidden) return Promise.resolve();
+
+ return new Promise((resolve) => {
+ const onChange = () => {
+ if (document.hidden) return;
+ document.removeEventListener("visibilitychange", onChange);
+ resolve();
+ };
+ document.addEventListener("visibilitychange", onChange);
+ });
}
/**
@@ -48,13 +83,17 @@ export async function waitUntil(
predicate: () => boolean,
{ timeoutMs = 3000, label = "render" }: { timeoutMs?: number; label?: string } = {},
): Promise {
- const deadline = performance.now() + timeoutMs;
+ let spent = 0;
while (!predicate()) {
- if (performance.now() > deadline) {
+ if (spent > timeoutMs) {
throw new Error(`Timed out waiting for ${label}`);
}
+ const before = performance.now();
await nextFrame();
+ // Time the tab spent hidden does not count against the budget: nothing was
+ // being rendered, so the wait is the user's tab switch, not a stall.
+ if (!document.hidden) spent += performance.now() - before;
}
}
diff --git a/src/lib/export/csv.test.ts b/src/lib/export/csv.test.ts
new file mode 100644
index 0000000..693d012
--- /dev/null
+++ b/src/lib/export/csv.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest";
+import { CSV_COLUMNS, buildSeriesCsv } from "@/lib/export/csv";
+import { buildProvenance, exportFileBaseName } from "@/lib/export/provenance";
+import { buildSeriesRows } from "@/lib/export/rows";
+import { ZARR_TIME } from "@/lib/zarr/timeRange";
+import type { MapSelection } from "@/types/map";
+
+const SELECTION: MapSelection = {
+ click: { lon: 11.5669, lat: 50.9128 },
+ grid: { lon: 11.575, lat: 50.925, lonIndex: 3831, latIndex: 780 },
+};
+
+function csvForDays(days: number, mutate?: (values: Float32Array) => void) {
+ const prov = buildProvenance({
+ selection: SELECTION,
+ historyYears: 1,
+ valueCount: days * ZARR_TIME.hoursPerDay,
+ units: "gC m-2 d-1",
+ });
+ const values = new Float32Array(days * ZARR_TIME.hoursPerDay).fill(1.5);
+ mutate?.(values);
+
+ return {
+ prov,
+ text: buildSeriesCsv(buildSeriesRows(values, prov), prov),
+ };
+}
+
+describe("buildSeriesCsv", () => {
+ it("keeps provenance in comment lines above the column header", () => {
+ const { text } = csvForDays(2);
+ const lines = text.split("\n");
+ const headerIndex = lines.indexOf(CSV_COLUMNS);
+
+ expect(headerIndex).toBeGreaterThan(0);
+ expect(lines.slice(0, headerIndex).every((l) => l.startsWith("# "))).toBe(
+ true,
+ );
+ });
+
+ it("records the pixel and window a reader would need to reproduce it", () => {
+ const { text, prov } = csvForDays(2);
+
+ expect(text).toContain(`# cell_lat: ${SELECTION.grid.lat}`);
+ expect(text).toContain(`# cell_lon: ${SELECTION.grid.lon}`);
+ expect(text).toContain(`# lat_index: ${SELECTION.grid.latIndex}`);
+ expect(text).toContain(`# units: gC m-2 d-1`);
+ expect(text).toContain(
+ `# window_start: ${prov.windowStart.toISOString().slice(0, 10)}`,
+ );
+ });
+
+ it("writes one data line per hour, plus a trailing newline", () => {
+ const days = 3;
+ const { text } = csvForDays(days);
+ const lines = text.split("\n");
+ const dataLines = lines.slice(lines.indexOf(CSV_COLUMNS) + 1, -1);
+
+ expect(text.endsWith("\n")).toBe(true);
+ expect(dataLines).toHaveLength(days * ZARR_TIME.hoursPerDay);
+ expect(text).toContain(`# rows: ${days * ZARR_TIME.hoursPerDay}`);
+ });
+
+ it("writes missing values as an empty field", () => {
+ const { text } = csvForDays(1, (values) => {
+ values[3] = Number.NaN;
+ });
+ const dataLines = text.split("\n").slice(-25, -1);
+
+ expect(dataLines[3].endsWith(",")).toBe(true);
+ expect(dataLines[3].split(",")).toHaveLength(4);
+ });
+
+ it("says unspecified rather than null when units are absent", () => {
+ const prov = buildProvenance({
+ selection: SELECTION,
+ historyYears: 1,
+ valueCount: ZARR_TIME.hoursPerDay,
+ });
+
+ expect(buildSeriesCsv([], prov)).toContain("# units: unspecified");
+ });
+});
+
+describe("exportFileBaseName", () => {
+ it("names files by variable, hemisphere-tagged cell, and window", () => {
+ const { prov } = csvForDays(2);
+
+ expect(exportFileBaseName(prov)).toMatch(
+ /^earthprints_NEE_50\.925N_11\.575E_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}$/,
+ );
+ });
+
+ it("tags southern and western coordinates without a minus sign", () => {
+ const prov = buildProvenance({
+ selection: {
+ click: { lon: -60.1, lat: -3.2 },
+ grid: { lon: -60.125, lat: -3.225, lonIndex: 2397, latIndex: 1864 },
+ },
+ historyYears: 1,
+ valueCount: ZARR_TIME.hoursPerDay,
+ });
+
+ expect(exportFileBaseName(prov)).toContain("3.225S_60.125W");
+ });
+});
diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts
new file mode 100644
index 0000000..93776a2
--- /dev/null
+++ b/src/lib/export/csv.ts
@@ -0,0 +1,54 @@
+import { isoDate, type ExportProvenance } from "./provenance";
+import type { SeriesRow } from "./rows";
+
+export const CSV_COLUMNS = "timestamp_utc,day_index,hour,value";
+
+/**
+ * Provenance rides along as `#` comment lines. A file of bare numbers is
+ * useless six months later, and every common reader skips these:
+ * `pandas.read_csv(path, comment="#")`, `readr::read_csv(comment = "#")`.
+ */
+function buildHeader(rowCount: number, prov: ExportProvenance): string[] {
+ return [
+ "EarthPrints export",
+ `generated: ${prov.generatedAt.toISOString()}`,
+ `dataset: ${prov.dataset}`,
+ `grid: ${prov.resolutionDeg} deg, hourly`,
+ `source: ${prov.sourceUrl}`,
+ `variable: ${prov.variable}`,
+ `units: ${prov.units ?? "unspecified"}`,
+ `click_lat: ${prov.click.lat}`,
+ `click_lon: ${prov.click.lon}`,
+ `cell_lat: ${prov.cell.lat}`,
+ `cell_lon: ${prov.cell.lon}`,
+ `lat_index: ${prov.cell.latIndex}`,
+ `lon_index: ${prov.cell.lonIndex}`,
+ `history_years: ${prov.historyYears}`,
+ `window_start: ${isoDate(prov.windowStart)}`,
+ `window_end: ${isoDate(prov.windowEnd)}`,
+ `rows: ${rowCount}`,
+ ].map((line) => `# ${line}`);
+}
+
+/**
+ * Serialise the hourly series. Every column is numeric or an ISO timestamp, so
+ * no field can contain a comma or quote and no escaping is required.
+ * Missing values are written empty, which reads back as NaN.
+ */
+export function buildSeriesCsv(
+ rows: SeriesRow[],
+ prov: ExportProvenance,
+): string {
+ const lines = buildHeader(rows.length, prov);
+ lines.push(CSV_COLUMNS);
+
+ for (const row of rows) {
+ lines.push(
+ `${row.timestamp.toISOString()},${row.dayIndex},${row.hour},${
+ row.value ?? ""
+ }`,
+ );
+ }
+
+ return `${lines.join("\n")}\n`;
+}
diff --git a/src/lib/export/fingerprintImage.ts b/src/lib/export/fingerprintImage.ts
new file mode 100644
index 0000000..5de4779
--- /dev/null
+++ b/src/lib/export/fingerprintImage.ts
@@ -0,0 +1,114 @@
+import {
+ fingerprintLegendStops,
+} from "@/lib/map/fingerprintScale";
+import {
+ formatSeriesValue,
+ timeSeriesChartTheme,
+} from "@/components/map/timeSeriesChartConfig";
+import type { CapturedImage } from "./capture";
+
+/**
+ * Legend block in CSS px, mirroring the markup under the on-screen canvas:
+ * a 12px gap, an 8px bar with 8px between it and its end labels, and a little
+ * air before the image edge.
+ */
+const GAP_TOP = 12;
+const BAR_H = 8;
+const LABEL_GAP = 8;
+const GAP_BOTTOM = 6;
+const INSET = 4;
+const BLOCK_H = GAP_TOP + BAR_H + GAP_BOTTOM;
+
+/** Same as the axis labels the canvas draws for itself. */
+const LABEL_FONT = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
+
+type LegendOptions = {
+ absMax: number;
+ units?: string | null;
+ /** Backing-store pixels per CSS pixel in the source canvas. */
+ pixelRatio: number;
+};
+
+function drawLegend(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ top: number,
+ { absMax, units }: Pick,
+) {
+ // The export always renders light, like the report, whatever theme the app
+ // is in.
+ const stops = fingerprintLegendStops(true);
+
+ ctx.font = LABEL_FONT;
+ ctx.fillStyle = timeSeriesChartTheme(true).tick;
+ ctx.textBaseline = "middle";
+
+ // Units ride on the upper end. The on-screen legend leaves them to the
+ // caption below it, which a standalone image does not have.
+ const min = formatSeriesValue(-absMax);
+ const max = `${formatSeriesValue(absMax)}${units ? ` ${units}` : ""}`;
+ const minW = ctx.measureText(min).width;
+ const maxW = ctx.measureText(max).width;
+ const middle = top + BAR_H / 2;
+
+ ctx.textAlign = "left";
+ ctx.fillText(min, INSET, middle);
+ ctx.textAlign = "right";
+ ctx.fillText(max, width - INSET, middle);
+
+ const barX = INSET + minW + LABEL_GAP;
+ const barW = Math.max(1, width - INSET - maxW - LABEL_GAP - barX);
+
+ const ramp = ctx.createLinearGradient(barX, 0, barX + barW, 0);
+ ramp.addColorStop(0, stops.uptake);
+ ramp.addColorStop(0.5, stops.mid);
+ ramp.addColorStop(1, stops.release);
+
+ ctx.fillStyle = ramp;
+ ctx.beginPath();
+ ctx.roundRect(barX, top, barW, BAR_H, BAR_H / 2);
+ ctx.fill();
+}
+
+/**
+ * Copy the heatmap and paint its colour scale underneath it.
+ *
+ * The canvas draws its own axes into its gutters, but the diverging ramp lives
+ * in HTML beside it, so a straight copy is a field of colour with nothing to
+ * read it against. The report redraws the same ramp in jsPDF; a shared image
+ * needs it baked into the pixels.
+ */
+export function fingerprintPngWithLegend(
+ canvas: HTMLCanvasElement,
+ { absMax, units, pixelRatio }: LegendOptions,
+): CapturedImage {
+ if (canvas.width === 0 || canvas.height === 0) {
+ throw new Error("Canvas has no size to capture");
+ }
+
+ const plotW = canvas.width / pixelRatio;
+ const plotH = canvas.height / pixelRatio;
+ const height = plotH + BLOCK_H;
+
+ const target = document.createElement("canvas");
+ target.width = canvas.width;
+ target.height = Math.round(height * pixelRatio);
+
+ const ctx = target.getContext("2d");
+ if (!ctx) throw new Error("Could not get a 2D context for export");
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
+
+ // Missing pixels are drawn transparent so gaps read as gaps, which against a
+ // viewer's dark background would come back as black.
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, plotW, height);
+ ctx.drawImage(canvas, 0, 0, plotW, plotH);
+
+ drawLegend(ctx, plotW, plotH + GAP_TOP, { absMax, units });
+
+ return {
+ dataUrl: target.toDataURL("image/png"),
+ width: target.width,
+ height: target.height,
+ };
+}
diff --git a/src/lib/export/zip.test.ts b/src/lib/export/zip.test.ts
new file mode 100644
index 0000000..aa58b57
--- /dev/null
+++ b/src/lib/export/zip.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { unzipSync } from "fflate";
+import { blobToBytes, buildZip, dataUrlToBytes } from "@/lib/export/zip";
+
+/** 1x1 red PNG, the same fixture the PDF test decodes. */
+const PNG =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
+
+const text = (value: string) => new TextEncoder().encode(value);
+
+async function entriesOf(blob: Blob) {
+ return unzipSync(await blobToBytes(blob));
+}
+
+describe("dataUrlToBytes", () => {
+ it("recovers the PNG signature from a data URL", () => {
+ const bytes = dataUrlToBytes(PNG);
+
+ expect(Array.from(bytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);
+ });
+
+ it("refuses a string that is not a data URL", () => {
+ expect(() => dataUrlToBytes("iVBORw0KGgo")).toThrow("Not a data URL");
+ });
+});
+
+describe("buildZip", () => {
+ it("hands back an archive the browser will save as a zip", async () => {
+ const blob = await buildZip([{ name: "a.txt", data: text("hello") }]);
+ const bytes = await blobToBytes(blob);
+
+ expect(blob.type).toBe("application/zip");
+ expect(Array.from(bytes.slice(0, 4))).toEqual([0x50, 0x4b, 0x03, 0x04]);
+ });
+
+ it("round-trips every entry byte for byte", async () => {
+ const csv = text("timestamp_utc,value\n2025-01-01T00:00:00.000Z,1.5\n");
+ const png = dataUrlToBytes(PNG);
+
+ const unzipped = await entriesOf(
+ await buildZip([
+ { name: "series.csv", data: csv },
+ { name: "plot.png", data: png, stored: true },
+ ]),
+ );
+
+ expect(Object.keys(unzipped).sort()).toEqual(["plot.png", "series.csv"]);
+ expect(unzipped["series.csv"]).toEqual(csv);
+ expect(unzipped["plot.png"]).toEqual(png);
+ });
+
+ // Storing the already-compressed entries is the whole reason `stored` exists,
+ // so it has to actually reach fflate rather than silently deflate anyway.
+ it("stores an entry verbatim when asked, and deflates when not", async () => {
+ const repetitive = text("x".repeat(4096));
+
+ const [deflated, stored] = await Promise.all([
+ buildZip([{ name: "d", data: repetitive }]),
+ buildZip([{ name: "d", data: repetitive, stored: true }]),
+ ]);
+
+ expect(stored.size).toBeGreaterThan(repetitive.length);
+ expect(deflated.size).toBeLessThan(repetitive.length);
+ expect((await entriesOf(stored))["d"]).toEqual(repetitive);
+ expect((await entriesOf(deflated))["d"]).toEqual(repetitive);
+ });
+
+ it("keeps the export's flat layout, with no enclosing folder", async () => {
+ const base = "earthprints_NEE_50.925N_11.575E_2025-01-01_2025-12-31";
+ const unzipped = await entriesOf(
+ await buildZip([
+ { name: `${base}.csv`, data: text("a") },
+ { name: `${base}_fingerprint.png`, data: text("b") },
+ ]),
+ );
+
+ expect(Object.keys(unzipped).every((name) => !name.includes("/"))).toBe(
+ true,
+ );
+ });
+});
diff --git a/src/lib/export/zip.ts b/src/lib/export/zip.ts
new file mode 100644
index 0000000..3db8f1d
--- /dev/null
+++ b/src/lib/export/zip.ts
@@ -0,0 +1,60 @@
+import type { ZipOptions, Zippable } from "fflate";
+
+export type ZipEntry = {
+ /** Path inside the archive. Flat here: no directory separators. */
+ name: string;
+ data: Uint8Array;
+ /**
+ * Skip deflate. PDF, XLSX and PNG all carry their own compression, so
+ * re-deflating them costs time and wins back almost nothing; the CSV is the
+ * only entry worth squeezing.
+ */
+ stored?: boolean;
+};
+
+/** fflate's level scale: 0 stores verbatim, 6 is its default deflate. */
+const STORE = 0;
+const DEFLATE = 6;
+
+/**
+ * Pull the bytes back out of one of the `image/png` data URLs `CapturedImage`
+ * carries, so a plot already rasterised for the report can go into the archive
+ * without being drawn a second time.
+ */
+export function dataUrlToBytes(dataUrl: string): Uint8Array {
+ const comma = dataUrl.indexOf(",");
+ if (comma === -1) throw new Error("Not a data URL");
+
+ const binary = atob(dataUrl.slice(comma + 1));
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index);
+ }
+ return bytes;
+}
+
+/** The builders hand back Blobs; the zip wants bytes. */
+export async function blobToBytes(blob: Blob): Promise {
+ return new Uint8Array(await blob.arrayBuffer());
+}
+
+/**
+ * Pack the entries into a single archive. Imports fflate lazily, the way the
+ * PDF and workbook builders import theirs, to keep it off the map route's
+ * first load.
+ *
+ * `zipSync` rather than the async worker-backed `zip`: only the CSV actually
+ * deflates, so the main thread pause is small and predictable, and there is no
+ * inlined worker for the bundler to get wrong.
+ */
+export async function buildZip(entries: ZipEntry[]): Promise {
+ const { zipSync } = await import("fflate");
+
+ const files: Zippable = {};
+ for (const entry of entries) {
+ const options: ZipOptions = { level: entry.stored ? STORE : DEFLATE };
+ files[entry.name] = [entry.data, options];
+ }
+
+ return new Blob([zipSync(files)], { type: "application/zip" });
+}