From f3acce2591ae15af1f5e76b7d02dc115a3e0a7d7 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:15:41 +0200 Subject: [PATCH 01/32] Open any Zarr store, not only FLUXCOM NEE (#132) A Data panel takes a Zarr v2 or v3 URL, with presets, a variable picker and pickers for extra dimensions such as ensemble member or pressure level. The open store is mirrored in the address bar so a link reopens it. The reader finds the time, latitude and longitude dimensions in any order, decodes CF time units and calendars, applies scale factor, offset and fill values, and handles 0 to 360 longitudes and regional grids. Every store is resampled onto days by time-of-day slots so the plots and exports keep working. Stores chunked by map are capped per load with a partial-load option. Work in progress: round 1 testing found calendar, axis and copy bugs that later commits fix. --- src/components/data/DataView.tsx | 10 +- src/components/data/SeriesSummary.tsx | 19 +- src/components/layout/MapToolbar.tsx | 4 +- src/components/map/ReliefMap.tsx | 8 + src/components/panels/CellView.tsx | 74 +++- src/components/panels/DatasetPanel.tsx | 225 +++++++++++ src/components/panels/SettingsPanel.tsx | 9 +- src/components/panels/YearPicker.tsx | 17 +- src/components/plots/FingerprintPlot.tsx | 17 +- src/components/plots/TimeSeriesPlot.tsx | 3 +- src/hooks/useCellSeries.ts | 230 ++++++++--- src/hooks/useSeriesDisplay.ts | 20 +- src/lib/constants/datasets.ts | 23 ++ src/lib/export/fingerprintImage.ts | 12 +- src/lib/export/fingerprintSquareLogo.ts | 23 +- src/lib/export/pdf.ts | 12 +- src/lib/export/provenance.ts | 13 +- src/lib/export/rows.ts | 12 +- src/lib/map/fingerprintScale.test.ts | 12 + src/lib/map/fingerprintScale.ts | 58 ++- src/lib/map/geogrid.test.ts | 39 ++ src/lib/map/geogrid.ts | 99 ++++- src/lib/plots/fingerprint.ts | 18 +- src/lib/plots/lineSeries.ts | 10 +- src/lib/settings/patchWindow.ts | 23 +- src/lib/zarr/ZarrChunkReader.test.ts | 5 +- src/lib/zarr/ZarrChunkReader.ts | 472 +++++++++++++++------ src/lib/zarr/activeSource.ts | 31 ++ src/lib/zarr/cfTime.test.ts | 61 +++ src/lib/zarr/cfTime.ts | 241 +++++++++++ src/lib/zarr/chunk.worker.ts | 89 ++-- src/lib/zarr/chunks.test.ts | 30 ++ src/lib/zarr/chunks.ts | 67 +++ src/lib/zarr/dataset.test.ts | 32 ++ src/lib/zarr/dataset.ts | 495 +++++++++++++++++++++++ src/lib/zarr/localTime.ts | 16 + src/lib/zarr/patch.ts | 68 ++++ src/lib/zarr/sourceUrl.ts | 85 ++++ src/lib/zarr/store.ts | 96 ++++- src/lib/zarr/timeAxis.test.ts | 55 +++ src/lib/zarr/timeAxis.ts | 234 +++++++++++ src/lib/zarr/timeRange.test.ts | 37 +- src/lib/zarr/timeRange.ts | 95 +++-- src/types/map.ts | 3 + 44 files changed, 2778 insertions(+), 424 deletions(-) create mode 100644 src/components/panels/DatasetPanel.tsx create mode 100644 src/lib/constants/datasets.ts create mode 100644 src/lib/zarr/activeSource.ts create mode 100644 src/lib/zarr/cfTime.test.ts create mode 100644 src/lib/zarr/cfTime.ts create mode 100644 src/lib/zarr/chunks.test.ts create mode 100644 src/lib/zarr/dataset.test.ts create mode 100644 src/lib/zarr/dataset.ts create mode 100644 src/lib/zarr/patch.ts create mode 100644 src/lib/zarr/sourceUrl.ts create mode 100644 src/lib/zarr/timeAxis.test.ts create mode 100644 src/lib/zarr/timeAxis.ts diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index e2ebe5c..3d11ba3 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -9,7 +9,11 @@ import type { TimeBasis } from "@/lib/zarr/localTime"; import type { CellSeries } from "@/hooks/useCellSeries"; import { useSeriesDisplay } from "@/hooks/useSeriesDisplay"; import { CellExport } from "@/components/panels/CellExport"; -import { PlotArea, PlotControls } from "@/components/panels/CellView"; +import { + describeRecord, + PlotArea, + PlotControls, +} from "@/components/panels/CellView"; import { ScaleField } from "@/components/panels/ScaleField"; import { TimeBasisField } from "@/components/panels/TimeBasisField"; import { YearPicker } from "@/components/panels/YearPicker"; @@ -134,9 +138,7 @@ export function DataView({ {formatLongitude(selection.grid.lon)},{" "} {formatLatitude(selection.grid.lat)} - - Net ecosystem exchange, 0.05° cell - + {describeRecord(series)}
Years {formatSelectedYearsLabel(selectedYears)} diff --git a/src/components/data/SeriesSummary.tsx b/src/components/data/SeriesSummary.tsx index d7c9d75..1fbf6f4 100644 --- a/src/components/data/SeriesSummary.tsx +++ b/src/components/data/SeriesSummary.tsx @@ -4,7 +4,7 @@ import { useMemo } from "react"; import { formatIsoDate } from "@/lib/map/fingerprintScale"; import { formatSeriesValue } from "@/lib/plots/chartTheme"; import { dailyMeanSeries } from "@/lib/zarr/series"; -import { getSelectedYearsDayMapping, ZARR_TIME } from "@/lib/zarr/timeRange"; +import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; import type { CellSeries } from "@/hooks/useCellSeries"; import type { SeriesDisplay } from "@/hooks/useSeriesDisplay"; import { Card, CardContent } from "@/components/ui/card"; @@ -21,8 +21,8 @@ type Summary = { function summarize( values: Float32Array, selectedYears: number[], + hoursPerDay: number, ): Summary | null { - const hoursPerDay = ZARR_TIME.hoursPerDay; const daily = dailyMeanSeries(values, hoursPerDay); if (daily.length === 0) return null; const { absoluteDays } = getSelectedYearsDayMapping( @@ -53,8 +53,8 @@ function summarize( function summarizeByYear( values: Float32Array, selectedYears: number[], + hoursPerDay: number, ): { year: number; summary: Summary }[] { - const hoursPerDay = ZARR_TIME.hoursPerDay; const { yearIntervals } = getSelectedYearsDayMapping( selectedYears, undefined, @@ -64,6 +64,7 @@ function summarizeByYear( const summary = summarize( values.subarray(startDayLocal * hoursPerDay, (endDayLocal + 1) * hoursPerDay), [year], + hoursPerDay, ); return summary ? [{ year, summary }] : []; }); @@ -115,21 +116,23 @@ export function SeriesSummary({ display, className, }: SeriesSummaryProps) { - const { selectedYears, units } = series; + const { selectedYears, units, slotsPerDay } = series; const values = display.displayValues; const summary = useMemo( () => - values && !display.isEmptyCell ? summarize(values, selectedYears) : null, - [values, display.isEmptyCell, selectedYears], + values && !display.isEmptyCell + ? summarize(values, selectedYears, slotsPerDay) + : null, + [values, display.isEmptyCell, selectedYears, slotsPerDay], ); const byYear = useMemo( () => values && !display.isEmptyCell && selectedYears.length > 1 - ? summarizeByYear(values, selectedYears) + ? summarizeByYear(values, selectedYears, slotsPerDay) : [], - [values, display.isEmptyCell, selectedYears], + [values, display.isEmptyCell, selectedYears, slotsPerDay], ); if (!summary) return null; diff --git a/src/components/layout/MapToolbar.tsx b/src/components/layout/MapToolbar.tsx index 849867c..ffac828 100644 --- a/src/components/layout/MapToolbar.tsx +++ b/src/components/layout/MapToolbar.tsx @@ -5,6 +5,7 @@ import { ChartLine, CircleQuestionMark, Compass, + Database, type LucideIcon, Moon, Settings, @@ -17,9 +18,10 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardTitle } from "@/components/ui/card"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -export type ToolbarPanel = "settings" | "chart" | "help"; +export type ToolbarPanel = "data" | "settings" | "chart" | "help"; const PANELS = [ + { value: "data", label: "Data", Icon: Database }, { value: "settings", label: "Settings", Icon: Settings }, { value: "chart", label: "Chart", Icon: ChartLine }, { value: "help", label: "Help", Icon: CircleQuestionMark }, diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 4a3c648..64905a0 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -42,6 +42,7 @@ import { MapTour } from "@/components/map/MapTour"; import { GlobeSelectionOverlay } from "@/components/map/GlobeSelectionOverlay"; import { UserPositionMarker } from "@/components/map/UserPositionMarker"; import { CellPanel } from "@/components/panels/CellPanel"; +import { DatasetPanel } from "@/components/panels/DatasetPanel"; import { HelpPanel } from "@/components/panels/HelpPanel"; import { SettingsPanel } from "@/components/panels/SettingsPanel"; import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert"; @@ -386,9 +387,16 @@ export function ReliefMap() { onTimeBasisChange={settings.setTimeBasis} /> ) : null} + {openPanel === "data" ? ( + setOpenPanel(null)} + /> + ) : null} {openPanel === "settings" ? (
- Hourly net ecosystem exchange for this 0.05° cell. The dashed box - marks the {patchWindow}×{patchWindow} cells around it kept in - memory. + {describeRecord(series)} The dashed box marks the{" "} + {patchWindow}×{patchWindow} cells around it kept in memory. - {series.error} + + {series.error} + {series.partialOffer ? ( + + ) : null} + ); } @@ -196,21 +233,18 @@ export function PlotArea({ if (display.isEmptyCell) { return ( - - No data at this cell. NEE is estimated over vegetated land, so ocean - and bare-ground cells are empty. Pick a cell over land. - + {emptyCellMessage(series)} ); } if (!display.displayValues) return null; - return display.plotView === "line" ? ( + const plot = display.plotView === "line" ? ( ); + + if (series.coveredDays === null) return plot; + return ( +
+

+ Showing the first {formatDays(series.coveredDays)} of the selection. The + rest would take more downloads than one load allows. +

+ {plot} +
+ ); +} + +function formatDays(days: number): string { + const whole = Math.max(1, Math.round(days)); + return `${whole.toLocaleString("en-US")} day${whole === 1 ? "" : "s"}`; } function formatBytes(bytes: number): string { diff --git a/src/components/panels/DatasetPanel.tsx b/src/components/panels/DatasetPanel.tsx new file mode 100644 index 0000000..f300144 --- /dev/null +++ b/src/components/panels/DatasetPanel.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState } from "react"; +import { LoaderCircle } from "lucide-react"; +import { DATASET_PRESETS } from "@/lib/constants/datasets"; +import { formatSelectedYearsLabel } from "@/lib/zarr/timeRange"; +import type { Dataset } from "@/lib/zarr/dataset"; +import type { CellSeries } from "@/hooks/useCellSeries"; +import { FloatingPanel } from "@/components/panels/FloatingPanel"; +import { formatResolution } from "@/components/panels/CellView"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldSeparator, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type DatasetPanelProps = { + series: CellSeries; + onClose: () => void; +}; + +function dimLabel(dim: Dataset["layout"]["extras"][number], index: number) { + const value = dim.labels?.[index]; + if (value === undefined) return String(index); + return dim.units ? `${value} ${dim.units}` : value; +} + +function Facts({ dataset }: { dataset: Dataset }) { + const { grid, time } = dataset; + const rows: [string, string][] = [ + ["Grid", `${formatResolution(grid.spatialResolutionDeg)} · ${grid.dimensions.lat.toLocaleString()} × ${grid.dimensions.lon.toLocaleString()} cells`], + ["Time", `${time.stepLabel} · ${formatSelectedYearsLabel(time.years)}`], + ]; + if (dataset.units) rows.push(["Units", dataset.units]); + return ( +
+ {rows.map(([term, value]) => ( +
+
{term}
+
{value}
+
+ ))} +
+ ); +} + +export function DatasetPanel({ series, onClose }: DatasetPanelProps) { + const { dataset, datasetLoading, datasetError, openSource } = series; + const [url, setUrl] = useState(dataset?.url ?? ""); + const [variable, setVariable] = useState(""); + const [prevUrl, setPrevUrl] = useState(dataset?.url); + if (dataset?.url !== prevUrl) { + setPrevUrl(dataset?.url); + if (dataset) setUrl(dataset.url); + } + const needsVariable = Boolean(datasetError?.includes("Type the variable")); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (!url.trim()) return; + openSource({ url, variable: needsVariable ? variable : null }); + }; + + return ( + + +
+ + Zarr store + + The address of a public Zarr v2 or v3 store with a time, latitude + and longitude dimension. + +
+ setUrl(event.target.value)} + placeholder="https://…/store.zarr" + spellCheck={false} + autoComplete="off" + inputMode="url" + /> + +
+
+ {needsVariable ? ( + + Variable + setVariable(event.target.value)} + placeholder="e.g. t2m" + spellCheck={false} + autoComplete="off" + /> + + ) : null} + {datasetError ? ( + + {datasetError} + + ) : null} +
+ + {dataset ? ( + <> + +
+
+ + {dataset.title} + + {dataset.title !== (dataset.longName ?? dataset.variable) ? ( + + {dataset.longName ?? dataset.variable} + + ) : null} +
+ +
+ + {dataset.variables.length > 1 ? ( + + Variable + + + ) : null} + + {dataset.layout.extras + .filter((dim) => dim.size > 1) + .map((dim) => ( + + {dim.name} + + + ))} + + ) : null} + + + + + Examples +
+ {DATASET_PRESETS.map((preset) => { + const current = + dataset?.url === preset.url && dataset.variable === preset.variable; + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/src/components/panels/SettingsPanel.tsx b/src/components/panels/SettingsPanel.tsx index d2fb7cc..cbd7a68 100644 --- a/src/components/panels/SettingsPanel.tsx +++ b/src/components/panels/SettingsPanel.tsx @@ -6,7 +6,7 @@ import { PATCH_WINDOW_SIZES, type PatchWindowSize, } from "@/lib/settings/patchWindow"; -import type { MapViewMode } from "@/types/map"; +import type { GridSpec, MapViewMode } from "@/types/map"; import type { MapSettings } from "@/hooks/useMapSettings"; import { FloatingPanel } from "@/components/panels/FloatingPanel"; import { ViewModeTabs } from "@/components/map/ViewModeTabs"; @@ -25,6 +25,7 @@ import { Switch } from "@/components/ui/switch"; type SettingsPanelProps = { settings: MapSettings; + nativeChunks: GridSpec["nativeChunks"]; patchWindow: PatchWindowSize; onPatchWindowChange: (size: PatchWindowSize) => void; viewMode: MapViewMode; @@ -34,6 +35,7 @@ type SettingsPanelProps = { export function SettingsPanel({ settings, + nativeChunks, patchWindow, onPatchWindowChange, viewMode, @@ -87,8 +89,9 @@ export function SettingsPanel({
Cells kept per download - A click downloads a 40 x 40 patch either way. This sets how much of - it is kept, and what every year of it costs in memory. + A click downloads a {nativeChunks.lat} x {nativeChunks.lon} patch + either way. This sets how much of it is kept, and what every year + of it costs in memory. (null); const selected = new Set(selectedYears); + const years = availableYears(); + // Two digits only while every year shares a century, or 1950 and 2050 collide. + const oneCentury = + years.length > 0 && + Math.floor(years[0]! / 100) === Math.floor(years[years.length - 1]! / 100); const handleClick = (year: number, event: React.MouseEvent) => { if (event.shiftKey && lastClickedYear !== null) { const min = Math.min(lastClickedYear, year); const max = Math.max(lastClickedYear, year); setLastClickedYear(year); - onSelectYears(ALL_AVAILABLE_YEARS.filter((y) => y >= min && y <= max)); + onSelectYears(years.filter((y) => y >= min && y <= max)); return; } @@ -50,8 +55,10 @@ export function YearPicker({ Shift+click for a range. Outlined years are cached {cachedYears.size > 0 ? ` (${cachedYears.size})` : ""}. -
- {ALL_AVAILABLE_YEARS.map((year) => { +
42 ? "max-h-48 overflow-y-auto pr-1" : ""}`} + > + {years.map((year) => { const isSelected = selected.has(year); // Other years stay clickable mid-load so they can be dropped at once. const isLocked = isSelected && loading && selectedYears.length === 1; @@ -67,7 +74,7 @@ export function YearPicker({ aria-label={String(year)} className="aria-pressed:bg-brand/15 aria-pressed:text-brand" > - {year.toString().slice(2)} + {oneCentury ? year.toString().slice(2) : year} ); })} diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index a0dbe0a..6bfb5d7 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -30,7 +30,7 @@ import { formatBinLabel, formatBinShortLabel, } from "@/lib/plots/timeBins"; -import { shortTimeBasis } from "@/lib/zarr/localTime"; +import { formatSlotTime, shortTimeBasis } from "@/lib/zarr/localTime"; import { useTheme } from "@/providers/ThemeProvider"; import { anchorBesidePanel, @@ -295,11 +295,11 @@ export function FingerprintPlot({ value: stats.mean[index] as number, spread: count > 0 - ? `Std ${std} across ${count} hour${count === 1 ? "" : "s"}` + ? `Std ${std} across ${count} ${hoursPerDay === 1 ? "day" : "step"}${count === 1 ? "" : "s"}` : undefined, }; } - const time = `${String(index).padStart(2, "0")}:00`; + const time = formatSlotTime(index, hoursPerDay); const suffix = timeBasisLabel ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; return { label: `${time}${suffix}`, @@ -319,7 +319,7 @@ export function FingerprintPlot({ if (!bin || hour < 0) return undefined; const at = col * hoursPerDay + hour; const count = grid.count[at] ?? 0; - const time = `${String(hour).padStart(2, "0")}:00`; + const time = formatSlotTime(hour, hoursPerDay); const suffix = timeBasisLabel ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; return { label: `${formatBinLabel(bin, scale)} · ${time}${suffix}`, @@ -484,21 +484,22 @@ export function FingerprintPlot({ />
- {formatSeriesValue(-extents.negMax)} + {formatSeriesValue(extents.pivot - extents.negMax)} - 0 + {extents.pivot === 0 ? "0" : formatSeriesValue(extents.pivot)} - {formatSeriesValue(extents.posMax)} + {formatSeriesValue(extents.pivot + extents.posMax)}

- {binCountLabel(nCols, scale)} × {hoursPerDay} hours + {binCountLabel(nCols, scale)} × {hoursPerDay}{" "} + {hoursPerDay === 24 ? "hours" : "steps a day"} {timeBasisLabel ? ` · ${timeBasisLabel}` : ""} {units ? ` · ${units}` : ""} {margins ? " · strips show the mean ±1 std of each row and column" : ""} diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx index 9e08e19..b72da06 100644 --- a/src/components/plots/TimeSeriesPlot.tsx +++ b/src/components/plots/TimeSeriesPlot.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useRef, useState, type Ref } from "react"; +import { activeSource } from "@/lib/zarr/activeSource"; import { Area, CartesianGrid, @@ -279,7 +280,7 @@ export function TimeSeriesPlot({ /> ) : null}

- {values.length.toLocaleString()} hourly steps + {values.length.toLocaleString()} {activeSource().stepLabel} steps {resolution === "hour" ? "" : ` · ${series.binCount.toLocaleString()} ${MEANS_LABEL[resolution]} means, band ±1 std`} diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index d710eab..38eea66 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -1,9 +1,17 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { geoPointToZarrGrid } from "@/lib/map/geogrid"; -import { openZarrStore } from "@/lib/zarr/store"; -import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; +import { geoPointToZarrGrid, isPointInGrid } from "@/lib/map/geogrid"; +import { SeriesBudgetError, ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; +import { openDataset, type Dataset } from "@/lib/zarr/dataset"; +import { setActiveSource } from "@/lib/zarr/activeSource"; +import { setActiveTime } from "@/lib/zarr/timeRange"; +import { + defaultYears, + readSourceFromUrl, + writeSourceToUrl, + type SourceRequest, +} from "@/lib/zarr/sourceUrl"; import { cacheBytesFor, DEFAULT_PATCH_WINDOW, @@ -17,12 +25,30 @@ import type { GridSpec, MapSelection } from "@/types/map"; export type SeriesProgress = { loaded: number; total: number }; +function activate(dataset: Dataset) { + setActiveTime(dataset.time); + setActiveSource({ + title: dataset.title, + url: dataset.url, + variable: dataset.variable, + longName: dataset.longName, + resolutionDeg: dataset.grid.spatialResolutionDeg, + stepLabel: dataset.time.stepLabel, + }); +} + export function useCellSeries() { + const readerRef = useRef(null); const readerPromiseRef = useRef | null>(null); const requestIdRef = useRef(0); + const openIdRef = useRef(0); const seriesAbortRef = useRef(null); const patchWindowRef = useRef(DEFAULT_PATCH_WINDOW); + const [dataset, setDataset] = useState(null); + const [datasetLoading, setDatasetLoading] = useState(true); + const [datasetError, setDatasetError] = useState(null); + const [extraIndices, setExtraIndices] = useState>({}); const [gridSpec, setGridSpec] = useState(DEFAULT_GRID_SPEC); const [selection, setSelection] = useState(null); const [selectedYears, setSelectedYears] = useState([2021]); @@ -33,44 +59,14 @@ export function useCellSeries() { const [progress, setProgress] = useState(null); const [error, setError] = useState(null); const [values, setValues] = useState(null); + /** Days a partial load could cover, offered when the full one is refused. */ + const [partialOffer, setPartialOffer] = useState(null); + /** Days a partial load did cover, from the start of the selection. */ + const [coveredDays, setCoveredDays] = useState(null); const [units, setUnits] = useState(null); - const ensureReader = useCallback(() => { - if (!readerPromiseRef.current) { - readerPromiseRef.current = openZarrStore() - .then( - (ds) => - new ZarrChunkReader(ds, { - windowSize: patchWindowRef.current, - maxBytes: cacheBytesFor( - patchWindowRef.current, - deviceCacheBytes(), - ), - }), - ) - .catch((cause) => { - readerPromiseRef.current = null; - throw cause; - }); - } - return readerPromiseRef.current; - }, []); - - useEffect(() => { - let cancelled = false; - ensureReader() - .then((reader) => reader.getGridSpec()) - .then((spec) => { - if (!cancelled) setGridSpec(spec); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [ensureReader]); - const load = useCallback( - async (nextSelection: MapSelection, years: number[]) => { + async (nextSelection: MapSelection, years: number[], partial = false) => { const requestId = ++requestIdRef.current; seriesAbortRef.current?.abort(); const abort = new AbortController(); @@ -80,9 +76,21 @@ export function useCellSeries() { setError(null); setValues(null); setUnits(null); + setPartialOffer(null); + setCoveredDays(null); try { - const reader = await ensureReader(); + const reader = await readerPromiseRef.current; + if (requestId !== requestIdRef.current) return; + if (!reader) { + setError("No dataset is open. Open one from the Data panel."); + return; + } + const spec = reader.getDataset()?.grid ?? DEFAULT_GRID_SPEC; + if (!isPointInGrid(nextSelection.click, spec)) { + setError("This point is outside the area the dataset covers. Pick a point inside it."); + return; + } setCachedYears(new Set(reader.getCachedYears(nextSelection.grid))); const result = await reader.getTimeSeriesForYears( @@ -94,16 +102,21 @@ export function useCellSeries() { setProgress({ loaded, total }); }, abort.signal, + { partial }, ); if (requestId !== requestIdRef.current) return; setValues(result.values); + setCoveredDays(result.coveredDays ?? null); setUnits(result.units ?? null); setCachedYears(new Set(reader.getCachedYears(nextSelection.grid))); } catch (cause) { if (requestId !== requestIdRef.current) return; if (cause instanceof Error && cause.name === "AbortError") return; + if (cause instanceof SeriesBudgetError && cause.partialDays > 0) { + setPartialOffer(cause.partialDays); + } setError( cause instanceof Error ? cause.message @@ -116,7 +129,100 @@ export function useCellSeries() { } } }, - [ensureReader], + [], + ); + + // Kept in refs so opening a store can re-snap and reload whatever is picked. + const selectionRef = useRef(null); + const selectedYearsRef = useRef(selectedYears); + useEffect(() => { + selectionRef.current = selection; + selectedYearsRef.current = selectedYears; + }, [selection, selectedYears]); + + // Sets state only once the store answers, so the first open can run from an + // effect; `openSource` below flags the loading state first. + const startOpen = useCallback( + (request: SourceRequest) => { + const openId = ++openIdRef.current; + const promise = openDataset(request.url, request.variable).then( + ({ dataset: next, store }) => { + const reader = new ZarrChunkReader(store, { + dataset: next, + windowSize: patchWindowRef.current, + maxBytes: cacheBytesFor(patchWindowRef.current, deviceCacheBytes()), + }); + const picks: Record = {}; + for (const dim of next.layout.extras) { + const wanted = request.picks?.[dim.name] ?? 0; + const index = Math.min(Math.max(0, wanted), dim.size - 1); + picks[dim.axis] = index; + reader.setExtraIndex(dim.axis, index); + } + return { next, reader, picks }; + }, + ); + + readerPromiseRef.current = promise + .then(({ reader }) => reader) + .catch(() => readerRef.current as ZarrChunkReader); + + promise + .then(({ next, reader, picks }) => { + if (openId !== openIdRef.current) { + reader.dispose(); + return; + } + readerRef.current?.dispose(); + readerRef.current = reader; + activate(next); + setDataset(next); + setExtraIndices(picks); + setGridSpec(next.grid); + setDatasetLoading(false); + writeSourceToUrl(next, picks); + + const years = defaultYears(next.time.years, selectedYearsRef.current); + setSelectedYears(years); + const previous = selectionRef.current; + if (previous) { + const moved: MapSelection = { + click: previous.click, + grid: geoPointToZarrGrid(previous.click, next.grid), + }; + setSelection(moved); + void load(moved, years); + } + }) + .catch((cause) => { + if (openId !== openIdRef.current) return; + setDatasetLoading(false); + setDatasetError( + cause instanceof Error ? cause.message : "The store could not be opened.", + ); + }); + }, + [load], + ); + + const openSource = useCallback( + (request: SourceRequest) => { + setDatasetLoading(true); + setDatasetError(null); + startOpen(request); + }, + [startOpen], + ); + + useEffect(() => { + startOpen(readSourceFromUrl()); + }, [startOpen]); + + useEffect( + () => () => { + readerRef.current?.dispose(); + }, + [], ); const select = useCallback( @@ -140,15 +246,33 @@ export function useCellSeries() { [load, selection], ); + const loadPartial = useCallback(() => { + if (selection) void load(selection, selectedYears, true); + }, [load, selectedYears, selection]); + + const setExtraIndex = useCallback( + (axis: number, index: number) => { + const reader = readerRef.current; + if (!reader || !dataset) return; + reader.setExtraIndex(axis, index); + const picks = { ...extraIndices, [axis]: index }; + setExtraIndices(picks); + writeSourceToUrl(dataset, picks); + if (selection) void load(selection, selectedYears); + }, + [dataset, extraIndices, load, selectedYears, selection], + ); + useEffect(() => { const stored = loadPatchWindow(); if (stored === patchWindowRef.current) return; patchWindowRef.current = stored; setPatchWindowState(stored); - void readerPromiseRef.current?.then((reader) => { - reader.setWindowSize(stored, cacheBytesFor(stored, deviceCacheBytes())); - }); + readerRef.current?.setWindowSize( + stored, + cacheBytesFor(stored, deviceCacheBytes()), + ); }, []); const setPatchWindow = useCallback( @@ -160,15 +284,20 @@ export function useCellSeries() { savePatchWindow(size); // Cached entries are cut to the old window and cannot be reused. setCachedYears(new Set()); - void readerPromiseRef.current?.then((reader) => { - reader.setWindowSize(size, cacheBytesFor(size, deviceCacheBytes())); - if (selection) void load(selection, selectedYears); - }); + readerRef.current?.setWindowSize(size, cacheBytesFor(size, deviceCacheBytes())); + if (selection) void load(selection, selectedYears); }, [load, selectedYears, selection], ); return { + dataset, + datasetLoading, + datasetError, + openSource, + extraIndices, + setExtraIndex, + slotsPerDay: dataset?.time.slotsPerDay ?? 24, gridSpec, selection, select, @@ -177,11 +306,14 @@ export function useCellSeries() { cachedYears, patchWindow, setPatchWindow, - loading, + loading: loading || (datasetLoading && selection !== null), progress, error, + partialOffer, + coveredDays, + loadPartial, values, - units, + units: units ?? dataset?.units ?? null, }; } diff --git a/src/hooks/useSeriesDisplay.ts b/src/hooks/useSeriesDisplay.ts index 2a295c6..1fea2b0 100644 --- a/src/hooks/useSeriesDisplay.ts +++ b/src/hooks/useSeriesDisplay.ts @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { ZARR_TIME, getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; +import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; import { formatTimeBasis, localHourOffset, @@ -28,7 +28,11 @@ export function useSeriesDisplay( selectedYears, values, loading, - }: Pick, + slotsPerDay, + }: Pick< + CellSeries, + "selection" | "selectedYears" | "values" | "loading" | "slotsPerDay" + >, timeBasis: TimeBasis, ) { const { isLight } = useTheme(); @@ -56,7 +60,9 @@ export function useSeriesDisplay( ); const isEmptyCell = !loading && values !== null && !hasPlottableData; - const utcOffsetHours = localHourOffset(selection?.grid.lon ?? 0); + // The shift moves whole slots, so a 6-hourly store rounds to 6 hours. + const utcOffsetSlots = localHourOffset(selection?.grid.lon ?? 0, slotsPerDay); + const utcOffsetHours = (utcOffsetSlots * 24) / slotsPerDay; const timeBasisLabel = formatTimeBasis(timeBasis, utcOffsetHours); const displayValues = useMemo(() => { @@ -64,14 +70,14 @@ export function useSeriesDisplay( const { absoluteDays } = getSelectedYearsDayMapping( selectedYears, undefined, - Math.floor(values.length / ZARR_TIME.hoursPerDay), + Math.floor(values.length / slotsPerDay), ); return shiftSeriesToLocalTime(values, { - hoursPerDay: ZARR_TIME.hoursPerDay, - offsetHours: utcOffsetHours, + hoursPerDay: slotsPerDay, + offsetHours: utcOffsetSlots, absoluteDays, }); - }, [values, selectedYears, timeBasis, utcOffsetHours]); + }, [values, selectedYears, timeBasis, utcOffsetSlots, slotsPerDay]); return { plotView, diff --git a/src/lib/constants/datasets.ts b/src/lib/constants/datasets.ts new file mode 100644 index 0000000..9f6b820 --- /dev/null +++ b/src/lib/constants/datasets.ts @@ -0,0 +1,23 @@ +import { ZARR_STORE } from "@/lib/constants/store"; +import type { SourceRequest } from "@/lib/zarr/sourceUrl"; + +export type DatasetPreset = SourceRequest & { + label: string; + description: string; +}; + +/** Stores known to open, offered so nobody has to find a URL first. */ +export const DATASET_PRESETS: DatasetPreset[] = [ + { + label: "FLUXCOM-X-BASE NEE", + description: "Net ecosystem exchange, hourly, 0.05°, 2001 to 2021", + url: ZARR_STORE.url, + variable: ZARR_STORE.defaultVariable, + }, + { + label: "ACE2S HiRO v2, 2023", + description: "AI climate emulator, 6-hourly, 0.03°, one ensemble member", + url: "https://huggingface.co/buckets/allenai/ai2cm-xshield/resolve/hirov2/2026-08-05-global-hirov2-ace2s-2023.zarr", + variable: "PRMSL", + }, +]; diff --git a/src/lib/export/fingerprintImage.ts b/src/lib/export/fingerprintImage.ts index 7b601c0..b79bf28 100644 --- a/src/lib/export/fingerprintImage.ts +++ b/src/lib/export/fingerprintImage.ts @@ -26,6 +26,8 @@ type LegendOptions = { /** Extents from `asymmetricExtents`, both non-negative magnitudes. */ negMax: number; posMax: number; + /** Value the ramp is centred on; see `asymmetricExtents`. */ + pivot?: number; colormapId: ColormapId; units?: string | null; /** Backing-store pixels per CSS pixel in the source canvas. */ @@ -39,9 +41,10 @@ function drawLegend( { negMax, posMax, + pivot: centre = 0, colormapId, units, - }: Pick, + }: Pick, ) { ctx.font = LABEL_FONT; ctx.fillStyle = chartTickColor(true); @@ -49,8 +52,8 @@ function drawLegend( // 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(-negMax); - const max = `${formatSeriesValue(posMax)}${units ? ` ${units}` : ""}`; + const min = formatSeriesValue(centre - negMax); + const max = `${formatSeriesValue(centre + posMax)}${units ? ` ${units}` : ""}`; const minW = ctx.measureText(min).width; const maxW = ctx.measureText(max).width; const middle = top + BAR_H / 2; @@ -94,7 +97,7 @@ function drawLegend( */ export function fingerprintPngWithLegend( canvas: HTMLCanvasElement, - { negMax, posMax, colormapId, units, pixelRatio }: LegendOptions, + { negMax, posMax, pivot, colormapId, units, pixelRatio }: LegendOptions, ): CapturedImage { if (canvas.width === 0 || canvas.height === 0) { throw new Error("Canvas has no size to capture"); @@ -121,6 +124,7 @@ export function fingerprintPngWithLegend( drawLegend(ctx, plotW, plotH + GAP_TOP, { negMax, posMax, + pivot, colormapId, units, }); diff --git a/src/lib/export/fingerprintSquareLogo.ts b/src/lib/export/fingerprintSquareLogo.ts index c5829c2..454c5dd 100644 --- a/src/lib/export/fingerprintSquareLogo.ts +++ b/src/lib/export/fingerprintSquareLogo.ts @@ -5,7 +5,7 @@ import { type ColormapId, dayIndexTicks, formatDayTick, - FINGERPRINT_HOUR_TICKS, + fingerprintSlotTicks, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue } from "@/lib/plots/chartTheme"; import { @@ -133,7 +133,7 @@ export function buildSquareFingerprintCanvas( for (let hour = 0; hour < hoursPerDay; hour++) { const val = values[dayOffset * hoursPerDay + hour] as number; - const color = scale(val, extents.negMax, extents.posMax); + const color = scale(val - extents.pivot, extents.negMax, extents.posMax); if (color === "transparent") continue; const x0 = Math.floor(hour * colW); @@ -217,9 +217,9 @@ export function buildSquareFingerprintCanvas( ctx.textAlign = "center"; ctx.textBaseline = "top"; - for (const hour of FINGERPRINT_HOUR_TICKS) { - if (hour === 0) continue; // Skip corner 0h tick - const x = Math.round(((hour + 0.5) * size) / hoursPerDay); + for (const { slot, label: hour } of fingerprintSlotTicks(hoursPerDay)) { + if (slot === 0) continue; // Skip corner 0h tick + const x = Math.round(((slot + 0.5) * size) / hoursPerDay); ctx.beginPath(); ctx.moveTo(x, 0); @@ -406,8 +406,11 @@ export function buildSquareFingerprintCanvas( // different magnitudes whenever uptake and release do. const maxVal = extents.posMax; const minVal = -extents.negMax; - const maxLabel = `+${formatSeriesValue(maxVal)}`; - const minLabel = formatSeriesValue(minVal); + const maxLabel = + extents.pivot === 0 + ? `+${formatSeriesValue(maxVal)}` + : formatSeriesValue(extents.pivot + maxVal); + const minLabel = formatSeriesValue(extents.pivot + minVal); const unitLabel = units || ""; // Zero-crossing fraction along the bar (top = posMax, bottom = negMax). @@ -514,7 +517,11 @@ export function buildSquareFingerprintCanvas( ctx.font = `400 ${Math.round(tickFontSize * 0.85)}px ${MONO_FONT_FAMILY}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; - ctx.fillText("0", zeroTickX, zeroBarY); + ctx.fillText( + extents.pivot === 0 ? "0" : formatSeriesValue(extents.pivot), + zeroTickX, + zeroBarY, + ); // 4. Bottom Number Label (-negMax / Uptake) const botNumY = barTop + vBarH + gap; diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 13a01ce..9488af7 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -2,12 +2,14 @@ import type { jsPDF } from "jspdf"; import { fingerprintColorScale, asymmetricExtents, + type ColorExtents, defaultColormapId, zeroFrac, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue } from "@/lib/plots/chartTheme"; import { formatSelectedYearsLabel } from "@/lib/zarr/timeRange"; import { formatTimeBasis } from "@/lib/zarr/localTime"; +import { activeSource } from "@/lib/zarr/activeSource"; import type { CapturedImage } from "./capture"; import { isoDate, type ExportProvenance } from "./provenance"; @@ -205,7 +207,7 @@ function drawPlot( */ function drawLegend( doc: jsPDF, - { negMax, posMax }: { negMax: number; posMax: number }, + { negMax, posMax, pivot: centre }: ColorExtents, y: number, ): number { // The report always renders light whatever theme the app is in, so it keeps a @@ -245,8 +247,8 @@ function drawLegend( doc.setFont("courier", "normal"); doc.setFontSize(7.5); doc.setTextColor(...INK_SOFT); - doc.text(formatSeriesValue(-negMax), MARGIN, y + barH - 0.3); - doc.text(formatSeriesValue(posMax), barX + barW + 3, y + barH - 0.3); + doc.text(formatSeriesValue(centre - negMax), MARGIN, y + barH - 0.3); + doc.text(formatSeriesValue(centre + posMax), barX + barW + 3, y + barH - 0.3); return y + barH + 4; } @@ -322,7 +324,7 @@ export async function buildReportPdf({ doc, assets.timeSeries, "Daily mean", - `${rowCount.toLocaleString()} hourly steps, ${prov.dayCount.toLocaleString()} daily means${ + `${rowCount.toLocaleString()} ${activeSource().stepLabel} steps, ${prov.dayCount.toLocaleString()} daily means${ prov.units ? ` - ${prov.units}` : "" }`, cursor, @@ -333,7 +335,7 @@ export async function buildReportPdf({ doc, assets.fingerprint, "Diurnal fingerprint", - `${prov.dayCount.toLocaleString()} days x ${prov.hoursPerDay} hours - ${formatTimeBasis( + `${prov.dayCount.toLocaleString()} days x ${prov.hoursPerDay} ${prov.hoursPerDay === 24 ? "hours" : "steps a day"} - ${formatTimeBasis( prov.timeBasis, prov.utcOffsetHours, )}${prov.units ? ` - ${prov.units}` : ""} - strips: mean +/-1 std per row and column`, diff --git a/src/lib/export/provenance.ts b/src/lib/export/provenance.ts index f746b6b..97b5a5b 100644 --- a/src/lib/export/provenance.ts +++ b/src/lib/export/provenance.ts @@ -1,4 +1,4 @@ -import { ZARR_STORE } from "@/lib/constants/store"; +import { activeSource } from "@/lib/zarr/activeSource"; import { ZARR_TIME, dayIndexToUTCDate, @@ -64,7 +64,7 @@ export function buildProvenance({ selectedYears = null, valueCount, units = null, - variable = ZARR_STORE.defaultVariable, + variable = activeSource().variable, hoursPerDay = ZARR_TIME.hoursPerDay, totalDays = ZARR_TIME.totalDays, timeBasis = "local", @@ -93,11 +93,11 @@ export function buildProvenance({ return { generatedAt, - dataset: ZARR_STORE.kicker, - sourceUrl: ZARR_STORE.url, + dataset: activeSource().title, + sourceUrl: activeSource().url, variable, units, - resolutionDeg: ZARR_STORE.spatialResolutionDeg, + resolutionDeg: activeSource().resolutionDeg, click: selection.click, cell: selection.grid, historyYears: resolvedHistoryYears, @@ -105,7 +105,8 @@ export function buildProvenance({ selectedYears: years, hoursPerDay, timeBasis, - utcOffsetHours: localHourOffset(selection.grid.lon, hoursPerDay), + utcOffsetHours: + localHourOffset(selection.grid.lon, hoursPerDay) * (24 / hoursPerDay), dayCount, baseDay: startDay, windowStart, diff --git a/src/lib/export/rows.ts b/src/lib/export/rows.ts index afc223b..ead7ec1 100644 --- a/src/lib/export/rows.ts +++ b/src/lib/export/rows.ts @@ -1,7 +1,4 @@ -import { - ZARR_TIME_ORIGIN_UTC, - getSelectedYearsDayMapping, -} from "@/lib/zarr/timeRange"; +import { activeTime, getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; import type { ExportProvenance } from "./provenance"; export type SeriesRow = { @@ -61,13 +58,16 @@ export function buildSeriesRows( dayCount, ); + const { originMs } = activeTime(); + const hoursPerSlot = 24 / hoursPerDay; + for (let i = 0; i < count; i += 1) { - const hour = i % hoursPerDay; + const hour = (i % hoursPerDay) * hoursPerSlot; const localDay = Math.floor(i / hoursPerDay); const dayIndex = dayMapping.absoluteDays[localDay] ?? baseDay + localDay; const raw = values[i]; const timestamp = new Date( - ZARR_TIME_ORIGIN_UTC + dayIndex * MS_PER_DAY + hour * MS_PER_HOUR, + originMs + dayIndex * MS_PER_DAY + hour * MS_PER_HOUR, ); const timestampLocal = new Date( diff --git a/src/lib/map/fingerprintScale.test.ts b/src/lib/map/fingerprintScale.test.ts index 23fa644..4c96b45 100644 --- a/src/lib/map/fingerprintScale.test.ts +++ b/src/lib/map/fingerprintScale.test.ts @@ -57,6 +57,18 @@ describe("asymmetricExtents", () => { expect(negMax).toBeCloseTo(2); expect(posMax).toBeCloseTo(1); }); + + it("stays on zero for signed data and data that starts near it", () => { + expect(asymmetricExtents([-1, 0, 3]).pivot).toBe(0); + expect(asymmetricExtents([0.1, 4, 9]).pivot).toBe(0); + }); + + it("centres data far from zero on its mean", () => { + const { pivot, negMax, posMax } = asymmetricExtents([1000, 1010, 1020, 1030]); + expect(pivot).toBeCloseTo(1015); + expect(negMax).toBeCloseTo(15); + expect(posMax).toBeCloseTo(15); + }); }); describe("fingerprintColorScale — every palette is centred on zero", () => { diff --git a/src/lib/map/fingerprintScale.ts b/src/lib/map/fingerprintScale.ts index 868c72d..1c059a1 100644 --- a/src/lib/map/fingerprintScale.ts +++ b/src/lib/map/fingerprintScale.ts @@ -298,29 +298,48 @@ export function symmetricAbsMax(values: ArrayLike): number { return max; } +export type ColorExtents = { + negMax: number; + posMax: number; + /** + * The value the ramp is centred on. Zero for signed data such as a flux, and + * for data that starts near zero such as rainfall; the mean for data that + * sits far from zero, such as pressure in hPa or temperature in kelvin, + * which would otherwise fill only the top end of the ramp. + */ + pivot: number; +}; + /** - * For the Flux colormap: compute separate extents for the negative and positive - * halves so each half spans the full ramp independently. + * Separate extents for the two halves around the pivot, so each half spans + * the full ramp independently. Colour a value with + * `scale(value - pivot, negMax, posMax)`. * - * Returns `{ negMax, posMax }` where `negMax ≥ 0` and `posMax ≥ 0`. - * If all values are on one side, the other extent is set to a tiny positive + * `negMax` and `posMax` are both positive: a half with no data gets a tiny * sentinel (1e-10) to avoid a division-by-zero degenerate state. */ -export function asymmetricExtents(values: ArrayLike): { - negMax: number; - posMax: number; -} { - let negMax = 0; - let posMax = 0; +export function asymmetricExtents(values: ArrayLike): ColorExtents { + let min = Infinity; + let max = -Infinity; + let sum = 0; + let count = 0; for (let i = 0; i < values.length; i++) { const v = values[i]; if (!Number.isFinite(v)) continue; - if (v < 0 && -v > negMax) negMax = -v; - if (v > 0 && v > posMax) posMax = v; + if (v < min) min = v; + if (v > max) max = v; + sum += v; + count++; } + if (count === 0) return { negMax: 1e-10, posMax: 1e-10, pivot: 0 }; + + const width = max - min; + const farFromZero = min > 0 ? min > width : max < 0 ? -max > width : false; + const pivot = farFromZero ? sum / count : 0; return { - negMax: negMax > 0 ? negMax : 1e-10, - posMax: posMax > 0 ? posMax : 1e-10, + negMax: Math.max(1e-10, pivot - min), + posMax: Math.max(1e-10, max - pivot), + pivot, }; } @@ -448,6 +467,17 @@ export function fingerprintColorScale( /** Hour-of-day gridlines/labels worth annotating on the hour axis. */ export const FINGERPRINT_HOUR_TICKS = [0, 6, 12, 18] as const; +/** The rows to label, as slots, for a day split into `slotsPerDay`. */ +export function fingerprintSlotTicks( + slotsPerDay: number, +): { slot: number; label: string }[] { + if (slotsPerDay < 4) return []; + return FINGERPRINT_HOUR_TICKS.flatMap((hour) => { + const slot = (hour * slotsPerDay) / 24; + return Number.isInteger(slot) ? [{ slot, label: String(hour) }] : []; + }); +} + const SHORT_MONTHS = [ "Jan", "Feb", diff --git a/src/lib/map/geogrid.test.ts b/src/lib/map/geogrid.test.ts index fae9065..7febfb6 100644 --- a/src/lib/map/geogrid.test.ts +++ b/src/lib/map/geogrid.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; +import type { GridSpec } from "@/types/map"; import { chunkPatchBounds, geoPointToZarrGrid, + isPointInGrid, gridCellToBounds, gridCellToGuidePaths, gridCellToPolygon, @@ -143,3 +145,40 @@ describe("coordinate formatting", () => { expect(formatGeoPoint({ lon: -75.125, lat: -23.475 })).toBe("75.125°W, 23.475°S"); }); }); + +describe("stores with other longitude conventions", () => { + const spec0to360: GridSpec = { + grid: { lonStart: 0.5, lonStep: 1, latStart: -89.5, latStep: 1 }, + dimensions: { time: 1, hour: 1, lat: 180, lon: 360 }, + nativeChunks: { lat: 180, lon: 360 }, + spatialResolutionDeg: 1, + }; + + it("snaps a western click onto a 0..360 grid and keeps it in map space", () => { + const cell = geoPointToZarrGrid({ lon: -10.2, lat: 0.2 }, spec0to360); + expect(cell.lonIndex).toBe(349); + expect(cell.lon).toBeCloseTo(-10.5); + expect(cell.latIndex).toBe(90); + }); + + it("tells a regional grid's outside from its inside", () => { + const europe: GridSpec = { + ...spec0to360, + grid: { lonStart: -10, lonStep: 0.25, latStart: 72, latStep: -0.25 }, + dimensions: { time: 1, hour: 1, lat: 120, lon: 200 }, + }; + expect(isPointInGrid({ lon: 10, lat: 50 }, europe)).toBe(true); + expect(isPointInGrid({ lon: 100, lat: 50 }, europe)).toBe(false); + expect(isPointInGrid({ lon: 10, lat: 20 }, europe)).toBe(false); + }); + + it("snaps to the nearest centre of an uneven axis", () => { + const gaussian: GridSpec = { + ...spec0to360, + dimensions: { ...spec0to360.dimensions, lat: 4 }, + latValues: Float64Array.from([60, 20, -15, -70]), + }; + expect(geoPointToZarrGrid({ lon: 0, lat: 5 }, gaussian).latIndex).toBe(1); + expect(geoPointToZarrGrid({ lon: 0, lat: -40 }, gaussian).latIndex).toBe(2); + }); +}); diff --git a/src/lib/map/geogrid.ts b/src/lib/map/geogrid.ts index e7c501e..e486a15 100644 --- a/src/lib/map/geogrid.ts +++ b/src/lib/map/geogrid.ts @@ -5,10 +5,34 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function snapToAxis(value: number, start: number, step: number, count: number): { - index: number; - coordinate: number; -} { +function nearestIndex(values: Float64Array, value: number): number { + const n = values.length; + const ascending = values[n - 1]! >= values[0]!; + let lo = 0; + let hi = n - 1; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const before = ascending ? values[mid]! < value : values[mid]! > value; + if (before) lo = mid + 1; + else hi = mid; + } + if (lo > 0 && Math.abs(values[lo - 1]! - value) <= Math.abs(values[lo]! - value)) { + return lo - 1; + } + return lo; +} + +function snapToAxis( + value: number, + start: number, + step: number, + count: number, + values?: Float64Array, +): { index: number; coordinate: number } { + if (values && values.length === count) { + const index = nearestIndex(values, value); + return { index, coordinate: values[index]! }; + } const index = clamp(Math.round((value - start) / step), 0, count - 1); return { index, @@ -16,17 +40,66 @@ function snapToAxis(value: number, start: number, step: number, count: number): }; } -/** Snap a WGS-84 point to the nearest Zarr grid cell center. */ +/** Centre of cell `index` along one axis of the store. */ +export function axisCoordinate( + spec: GridSpec, + axis: "lat" | "lon", + index: number, +): number { + const values = axis === "lat" ? spec.latValues : spec.lonValues; + if (values?.[index] !== undefined) return values[index]!; + const start = axis === "lat" ? spec.grid.latStart : spec.grid.lonStart; + const step = axis === "lat" ? spec.grid.latStep : spec.grid.lonStep; + return start + index * step; +} + +/** The store's lon range, edge to edge, in its own convention (-180..180 or 0..360). */ +function lonExtent(spec: GridSpec): { west: number; east: number } { + const first = axisCoordinate(spec, "lon", 0); + const last = axisCoordinate(spec, "lon", spec.dimensions.lon - 1); + const half = Math.abs(spec.grid.lonStep) / 2; + return { west: Math.min(first, last) - half, east: Math.max(first, last) + half }; +} + +/** + * The copy of `lon` (shifted by whole turns) that lands inside the store's + * range, or nearest to it: a 0..360 store is clicked in -180..180. + */ +function lonInStoreRange(lon: number, spec: GridSpec): number { + const { west, east } = lonExtent(spec); + if (east - west >= 359.999) return west + ((((lon - west) % 360) + 360) % 360); + const centre = (west + east) / 2; + return lon + 360 * Math.round((centre - lon) / 360); +} + +/** Whether a point falls on the store's grid at all (regional stores). */ +export function isPointInGrid(point: GeoPoint, spec: GridSpec): boolean { + const { west, east } = lonExtent(spec); + const lon = lonInStoreRange(point.lon, spec); + const firstLat = axisCoordinate(spec, "lat", 0); + const lastLat = axisCoordinate(spec, "lat", spec.dimensions.lat - 1); + const halfLat = Math.abs(spec.grid.latStep) / 2; + const south = Math.min(firstLat, lastLat) - halfLat; + const north = Math.max(firstLat, lastLat) + halfLat; + return lon >= west && lon <= east && point.lat >= south && point.lat <= north; +} + +/** + * Snap a WGS-84 point to the nearest Zarr grid cell center. The cell's `lon` + * stays on the clicked side of the antimeridian, whatever the store's own + * longitude convention, so the map draws it under the pointer. + */ export function geoPointToZarrGrid( point: GeoPoint, spec: GridSpec = DEFAULT_GRID_SPEC, ): GridCell { const { grid, dimensions } = spec; - const lon = snapToAxis(point.lon, grid.lonStart, grid.lonStep, dimensions.lon); - const lat = snapToAxis(point.lat, grid.latStart, grid.latStep, dimensions.lat); + const storeLon = lonInStoreRange(point.lon, spec); + const lon = snapToAxis(storeLon, grid.lonStart, grid.lonStep, dimensions.lon, spec.lonValues); + const lat = snapToAxis(point.lat, grid.latStart, grid.latStep, dimensions.lat, spec.latValues); return { - lon: lon.coordinate, + lon: lon.coordinate - (storeLon - point.lon), lat: lat.coordinate, lonIndex: lon.index, latIndex: lat.index, @@ -94,10 +167,12 @@ export function chunkPatchBounds( const firstLonIdx = Math.floor(cell.lonIndex / chunkLon) * chunkLon; const lastLonIdx = Math.min(firstLonIdx + chunkLon, dimensions.lon) - 1; - const latEdgeA = grid.latStart + firstLatIdx * grid.latStep; - const latEdgeB = grid.latStart + lastLatIdx * grid.latStep; - const lonEdgeA = grid.lonStart + firstLonIdx * grid.lonStep; - const lonEdgeB = grid.lonStart + lastLonIdx * grid.lonStep; + // The cell's lon is in map space; shift the patch the same way. + const lonShift = cell.lon - axisCoordinate(spec, "lon", cell.lonIndex); + const latEdgeA = axisCoordinate(spec, "lat", firstLatIdx); + const latEdgeB = axisCoordinate(spec, "lat", lastLatIdx); + const lonEdgeA = axisCoordinate(spec, "lon", firstLonIdx) + lonShift; + const lonEdgeB = axisCoordinate(spec, "lon", lastLonIdx) + lonShift; const halfLon = Math.abs(grid.lonStep) / 2; const halfLat = Math.abs(grid.latStep) / 2; diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts index 9d037a2..950a47e 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -1,7 +1,7 @@ import { type ColormapId, - FINGERPRINT_HOUR_TICKS, fingerprintColorScale, + fingerprintSlotTicks, formatDayTick, } from "@/lib/map/fingerprintScale"; import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; @@ -38,7 +38,7 @@ export const FINGERPRINT_STRIP_MAX = 280; const LABEL_FONT = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; -export type FingerprintExtents = { negMax: number; posMax: number }; +export type FingerprintExtents = { negMax: number; posMax: number; pivot?: number }; /** Hourly values grouped into columns of days, weeks, months or years. */ export type FingerprintGrid = BinStats & { @@ -324,7 +324,7 @@ export function drawFingerprint( const scale = fingerprintColorScale(colormapId); const cellColor = (col: number, hour: number) => scale( - grid.mean[col * hoursPerDay + hour] as number, + (grid.mean[col * hoursPerDay + hour] as number) - (extents.pivot ?? 0), extents.negMax, extents.posMax, ); @@ -474,10 +474,10 @@ export function drawFingerprint( top + (fromTop * plotH) / hoursPerDay; ctx.textAlign = "right"; ctx.textBaseline = "middle"; - for (const hour of FINGERPRINT_HOUR_TICKS) { - const fromTop = hoursPerDay - 1 - hour; + for (const { slot, label } of fingerprintSlotTicks(hoursPerDay)) { + const fromTop = hoursPerDay - 1 - slot; ctx.fillText( - String(hour), + label, axisLeft - 6, (rowY(fromTop) + rowY(fromTop + 1)) / 2, ); @@ -505,9 +505,9 @@ export function drawFingerprint( } ctx.textAlign = "center"; ctx.textBaseline = "top"; - for (const hour of FINGERPRINT_HOUR_TICKS) { - const x = axisLeft + ((hour + 0.5) * plotW) / hoursPerDay; - ctx.fillText(String(hour), x, top + plotH + 4); + for (const { slot, label } of fingerprintSlotTicks(hoursPerDay)) { + const x = axisLeft + ((slot + 0.5) * plotW) / hoursPerDay; + ctx.fillText(label, x, top + plotH + 4); } } } diff --git a/src/lib/plots/lineSeries.ts b/src/lib/plots/lineSeries.ts index 192131d..20c5c2c 100644 --- a/src/lib/plots/lineSeries.ts +++ b/src/lib/plots/lineSeries.ts @@ -10,6 +10,7 @@ import { timeBins, } from "@/lib/plots/timeBins"; import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; +import { formatSlotTime } from "@/lib/zarr/localTime"; export type LineResolution = "hour" | Exclude; @@ -37,9 +38,6 @@ export type LineSeries = { binCount: number; }; -function hourLabel(hour: number) { - return `${String(hour).padStart(2, "0")}:00`; -} /** Keep the lowest and highest point of each slice, in time order. */ export function keepExtremes( @@ -107,8 +105,8 @@ export function lineSeries( points.push({ x: day + (hour + 0.5) / hoursPerDay, value, - label: `${date} · ${hourLabel(hour)}${suffix}`, - shortLabel: `${date} ${hourLabel(hour)}`, + label: `${date} · ${formatSlotTime(hour, hoursPerDay)}${suffix}`, + shortLabel: `${date} ${formatSlotTime(hour, hoursPerDay)}`, }); } } @@ -125,7 +123,7 @@ export function lineSeries( const std = stats.std[b] as number; const across = resolution === "day" - ? `${count.toLocaleString()} hour${count === 1 ? "" : "s"}` + ? `${count.toLocaleString()} step${count === 1 ? "" : "s"}` : binCountLabel(count, "day"); points.push({ x: (bin.startLocal + bin.endLocal + 1) / 2, diff --git a/src/lib/settings/patchWindow.ts b/src/lib/settings/patchWindow.ts index c03292b..5ba0825 100644 --- a/src/lib/settings/patchWindow.ts +++ b/src/lib/settings/patchWindow.ts @@ -1,5 +1,4 @@ -import { ZARR_STORE } from "@/lib/constants/store"; -import { NATIVE_TIME_CHUNK } from "@/lib/zarr/timeRange"; +import { activeTime } from "@/lib/zarr/timeRange"; /** * How much of a downloaded patch the reader keeps. @@ -33,26 +32,10 @@ const STORAGE_KEY = "earthprints:patch-window"; const BYTES_PER_VALUE = 4; -/** Bytes one cached entry costs: a full time chunk of a `size` x `size` tile. */ -export function entryBytesFor(size: number): number { - return ( - NATIVE_TIME_CHUNK * - ZARR_STORE.dimensions.hour * - size * - size * - BYTES_PER_VALUE - ); -} - /** Bytes the whole archive costs at one spot: every time chunk of one tile. */ export function allYearsBytesFor(size: number): number { - return ( - ZARR_STORE.dimensions.time * - ZARR_STORE.dimensions.hour * - size * - size * - BYTES_PER_VALUE - ); + const { totalDays, slotsPerDay } = activeTime(); + return totalDays * slotsPerDay * size * size * BYTES_PER_VALUE; } /** diff --git a/src/lib/zarr/ZarrChunkReader.test.ts b/src/lib/zarr/ZarrChunkReader.test.ts index dccf804..f45fef4 100644 --- a/src/lib/zarr/ZarrChunkReader.test.ts +++ b/src/lib/zarr/ZarrChunkReader.test.ts @@ -313,14 +313,15 @@ describe("ZarrChunkReader", () => { }); it("evicts least-recently-used patches once the byte budget is spent", async () => { - // A one-byte budget keeps only the most recent patch. + // A one-byte budget keeps only the most recent patch. The one still held + // is read before the evicted one is fetched again, so only that one is. const reader = new ZarrChunkReader(ds, 1); await reader.getTimeSeries(makeGrid(50, 50)); mockGetChunk.mockClear(); await reader.getTimeSeries(makeGrid(50, 50)); - expect(mockGetChunk).toHaveBeenCalledTimes(2); + expect(mockGetChunk).toHaveBeenCalledTimes(1); }); it("keeps both patches when the budget has room", async () => { diff --git a/src/lib/zarr/ZarrChunkReader.ts b/src/lib/zarr/ZarrChunkReader.ts index 0b08449..a4d668a 100644 --- a/src/lib/zarr/ZarrChunkReader.ts +++ b/src/lib/zarr/ZarrChunkReader.ts @@ -2,16 +2,15 @@ import * as zarr from "zarrita"; import { LRUCache } from "@/lib/cache/lru"; import { ZARR_STORE } from "@/lib/constants/store"; import { - alignedSubBlock, - blockCoversChunk, extractPixelFromNativeChunk, + listTimeChunkIndicesForRange, nativeChunkKey, pixelToNativeChunkContext, - sliceBlockFromNativeChunk, stitchTimeSeriesForRange, subBlockIndices, type ArrayChunkSizes, type AxisSlice, + type ChunkPlan, type LocalBlock, type PixelNativeChunkContext, } from "@/lib/zarr/chunks"; @@ -22,25 +21,31 @@ import { import { chunkIndexToStartDay, DEFAULT_HISTORY_YEARS, - NATIVE_TIME_CHUNK, - timeChunkIndexToYears, yearsToContiguousBlocks, yearsToDayRange, yearToDateRange, + ZARR_TIME_ORIGIN_UTC, } from "@/lib/zarr/timeRange"; +import { DatasetError, type Dataset, type DatasetDecode } from "@/lib/zarr/dataset"; +import { readPatch, type ChunkSource } from "@/lib/zarr/patch"; +import { + resampleToDays, + sampleRangeForDays, + type TimeAxis, +} from "@/lib/zarr/timeAxis"; import { abortError, createByteProgressSink, + createConcurrentProgressTracker, isAbortError, createSeriesProgressTracker, - getActiveByteSink, - setActiveAbortSignal, - setActiveByteSink, + withRequestContext, type SeriesProgressTracker, type ZarrArrayHandle, type ZarrStore, } from "@/lib/zarr/store"; import { deriveGridSpec } from "@/lib/zarr/gridSpec"; +import { DEFAULT_GRID_SPEC } from "@/lib/constants/store"; import { cacheBytesFor, DEFAULT_PATCH_WINDOW, @@ -50,8 +55,9 @@ import type { GridCell, GridSpec } from "@/types/map"; /** * One decoded patch: every pixel of a window-aligned tile of a native chunk, - * for one time chunk. A tile is the whole 40x40 chunk at the largest window - * (~224 MB) down to 10x10 at the smallest (~14 MB). + * for one time chunk, as `[time, hour, lat, lon]` Float32. For FLUXCOM-X a + * tile is the whole 40x40 chunk at the largest window (~224 MB) down to 10x10 + * at the smallest (~14 MB). * * Keeping a tile rather than a single cell is what makes exploring cheap: the * map draws the tile as the dashed box, and every cell inside it is served @@ -73,6 +79,62 @@ export type ZarrChunkReaderOptions = { * the patch it is about to ask for again. */ maxBytes?: number; + /** + * What the store holds and how to read it. Without it the store is read as + * FLUXCOM-X: `[time, hour, lat, lon]` with one day per time step. + */ + dataset?: Dataset; +}; + +/** + * Most stored chunks one series may need. A store chunked by map rather than + * by time costs one download per time step for a single pixel, which at + * 6-hourly resolution is 1460 a year; past this the request is refused with + * the reason rather than left to crawl. + */ +export const MAX_CHUNKS_PER_SERIES = 400; + +/** Largest stored chunk, decoded, that one download may unpack. */ +export const MAX_CHUNK_BYTES = 1.5 * 1024 ** 3; + +/** + * Many chunks this small are bound by latency rather than memory, so several + * download at once. A store chunked by map lands here; FLUXCOM-X never does. + */ +const SMALL_CHUNK_BYTES = 32 * 1024 ** 2; +const SMALL_CHUNK_RUN = 8; +const SMALL_CHUNK_CONCURRENCY = 12; + +const PLAIN_DECODE: DatasetDecode = { scale: 1, offset: 0, fill: [] }; + +/** + * A series that needs more downloads than one load may make. `partialDays` is + * how much of the request the budget does cover, read from the start, so the + * caller can offer that instead. + */ +export class SeriesBudgetError extends DatasetError { + constructor( + message: string, + readonly partialDays: number, + ) { + super(message); + this.name = "SeriesBudgetError"; + } +} + +export type SeriesOptions = { + /** Over budget, load what the budget covers from the start instead of failing. */ + partial?: boolean; + /** Downloads left for this load, shared across the blocks of a year selection. */ + budget?: { remaining: number }; +}; + +export type SeriesResult = { + values: Float32Array; + variable: string; + units?: string; + /** Days actually read, from the start, when a partial load stopped short. */ + coveredDays?: number; }; /** @@ -89,14 +151,29 @@ type InFlightDecode = { /** Reports download progress as `loaded` of `total` bytes. */ export type SeriesProgress = (loaded: number, total: number) => void; -type ZarrArray = ZarrArrayHandle & { - getChunk( - chunkCoords: number[], - ): Promise<{ data: Float32Array; shape: number[] }>; -}; +type ZarrArray = ZarrArrayHandle & ChunkSource; + +/** The FLUXCOM-X reading of an array, for a reader given no dataset. */ +function fluxcomTimeAxis(array: ZarrArray): TimeAxis { + const [days = 0, hours = 24] = array.shape; + return { + originMs: ZARR_TIME_ORIGIN_UTC, + totalDays: days, + slotsPerDay: hours, + samplePos: Float64Array.from({ length: days }, (_, i) => i), + hourAxis: true, + span: "slot", + stepDays: 1, + stepLabel: "hourly", + years: [], + }; +} export class ZarrChunkReader { private ds: ZarrStore; + private dataset: Dataset | null; + /** Picked index on each extra axis (ensemble member, level), by axis. */ + private extraIndices = new Map(); private cache: LRUCache; private arrayPromises = new Map>(); private chunkLoadsInFlight = new Map(); @@ -118,6 +195,7 @@ export class ZarrChunkReader { typeof options === "number" ? { maxBytes: options } : options; this.ds = ds; + this.dataset = resolved.dataset ?? null; this.windowSize = resolved.windowSize ?? DEFAULT_PATCH_WINDOW; this.maxBytes = resolved.maxBytes ?? cacheBytesFor(this.windowSize, DESKTOP_CACHE_BYTES); @@ -157,13 +235,60 @@ export class ZarrChunkReader { * coordinate arrays are only read once. Falls back to DEFAULT_GRID_SPEC * inside deriveGridSpec if the store cannot be read. */ - getGridSpec(variable = ZARR_STORE.defaultVariable): Promise { + getGridSpec(variable = this.defaultVariable()): Promise { + if (this.dataset) return Promise.resolve(this.dataset.grid); if (!this.gridSpecPromise) { this.gridSpecPromise = deriveGridSpec(this.ds, variable); } return this.gridSpecPromise; } + getDataset(): Dataset | null { + return this.dataset; + } + + private defaultVariable(): string { + return this.dataset?.variable ?? ZARR_STORE.defaultVariable; + } + + /** + * Pick one index on an extra axis. Patches are keyed by the pick, so other + * members stay cached and switching back is free. + */ + setExtraIndex(axis: number, index: number): void { + this.extraIndices.set(axis, index); + } + + getExtraIndex(axis: number): number { + return this.extraIndices.get(axis) ?? 0; + } + + /** Cache key prefix: the array plus every extra-axis pick. */ + private seriesKey(variable: string): string { + const extras = this.dataset?.layout.extras ?? []; + const picks = extras.map((dim) => `${dim.axis}=${this.getExtraIndex(dim.axis)}`); + return [this.ds.url, variable, ...picks].join("|"); + } + + private plan(array: ZarrArray): ChunkPlan { + const layout = this.dataset?.layout; + if (!layout) return { time: 0, hour: 1, lat: 2, lon: 3, fixed: [] }; + return { + time: layout.time, + hour: layout.hour, + lat: layout.lat, + lon: layout.lon, + fixed: layout.extras.map((dim) => ({ + axis: dim.axis, + local: this.getExtraIndex(dim.axis) % array.chunks[dim.axis]!, + })), + }; + } + + private timeAxis(array: ZarrArray): TimeAxis { + return this.dataset?.time ?? fluxcomTimeAxis(array); + } + /** * Main-thread array handle. Used for metadata (shape, chunks, attrs) and, * where no worker is available, for decoding too. @@ -193,8 +318,31 @@ export class ZarrChunkReader { } private getChunkSizes(array: ZarrArray): ArrayChunkSizes { - const [time, hour, lat, lon] = array.chunks; - return { time, hour, lat, lon }; + const layout = this.dataset?.layout ?? { time: 0, hour: 1, lat: 2, lon: 3 }; + const chunk = (axis: number) => (axis >= 0 ? array.chunks[axis]! : 1); + return { + time: chunk(layout.time), + hour: chunk(layout.hour), + lat: chunk(layout.lat), + lon: chunk(layout.lon), + }; + } + + /** Stored chunk coordinates for one time chunk of the pixel's patch. */ + private chunkCoordsFor( + array: ZarrArray, + context: PixelNativeChunkContext, + timeChunkIdx: number, + ): number[] { + const layout = this.dataset?.layout ?? { time: 0, hour: 1, lat: 2, lon: 3, extras: [] }; + const coords = new Array(array.shape.length).fill(0); + coords[layout.time] = timeChunkIdx; + coords[layout.lat] = context.chunkLatIdx; + coords[layout.lon] = context.chunkLonIdx; + for (const dim of layout.extras) { + coords[dim.axis] = Math.floor(this.getExtraIndex(dim.axis) / array.chunks[dim.axis]!); + } + return coords; } /** Release the decode worker; the reader falls back to inline decoding. */ @@ -229,12 +377,7 @@ export class ZarrChunkReader { return this.join(loadKey, existing, signal); } - const chunkCoords = [ - timeChunkIdx, - 0, - context.chunkLatIdx, - context.chunkLonIdx, - ]; + const chunkCoords = this.chunkCoordsFor(array, context, timeChunkIdx); const controller = new AbortController(); const promise = this.decodeChunk( @@ -330,7 +473,7 @@ export class ZarrChunkReader { timeChunkIdx: number, ): string { const { subLatIdx, subLonIdx } = subBlockIndices(context, this.windowSize); - return nativeChunkKey(variable, { + return nativeChunkKey(this.seriesKey(variable), { timeChunkIdx, hourChunkIdx: 0, latChunkIdx: context.chunkLatIdx, @@ -357,6 +500,12 @@ export class ZarrChunkReader { }; } + /** Bytes one stored chunk takes once decoded, before the window is cut. */ + private rawChunkBytes(array: ZarrArray): number { + const bytesPerValue = this.dataset?.bytesPerValue ?? 4; + return array.chunks.reduce((total, size) => total * size, bytesPerValue); + } + /** Decode in the worker where possible, otherwise inline on this thread. */ private async decodeChunk( array: ZarrArray, @@ -367,17 +516,23 @@ export class ZarrChunkReader { signal: AbortSignal, ): Promise { const worker = this.getWorker(); + const request = { + chunkCoords, + plan: this.plan(array), + decode: this.dataset?.decode ?? PLAIN_DECODE, + windowSize: this.windowSize, + localLat: context.localLat, + localLon: context.localLon, + }; if (worker) { try { return await worker.decode( { + ...request, storeUrl: this.ds.url, variable, - chunkCoords, - windowSize: this.windowSize, - localLat: context.localLat, - localLon: context.localLon, + concurrent: this.rawChunkBytes(array) <= SMALL_CHUNK_BYTES, }, tracker ? (loaded, total) => tracker.update(loaded, total) : undefined, signal, @@ -396,26 +551,7 @@ export class ZarrChunkReader { const sink = tracker ? createByteProgressSink((loaded, total) => tracker.update(loaded, total)) : null; - if (sink) setActiveByteSink(sink); - setActiveAbortSignal(signal); - let chunk: { data: Float32Array; shape: number[] }; - try { - chunk = await array.getChunk(chunkCoords); - } finally { - // Only clear if a newer request has not already swapped in its own sink. - if (sink && getActiveByteSink() === sink) setActiveByteSink(null); - setActiveAbortSignal(null); - } - - // Same cut the worker makes, so a cached patch has one shape whichever - // path decoded it. - const block = alignedSubBlock(context, this.windowSize, chunk.shape); - if (blockCoversChunk(block, chunk.shape)) { - return { data: chunk.data, shape: chunk.shape, block }; - } - - const kept = sliceBlockFromNativeChunk(chunk.data, chunk.shape, block); - return { data: kept.data, shape: kept.shape, block }; + return withRequestContext(sink, signal, (init) => readPatch(array, request, init)); } /** @@ -428,59 +564,71 @@ export class ZarrChunkReader { */ getCachedYears( grid: GridCell, - variable = ZARR_STORE.defaultVariable, - chunkTime: number = NATIVE_TIME_CHUNK, + variable = this.defaultVariable(), ): Set { const cachedYears = new Set(); - const chunkCount = Math.ceil(ZARR_STORE.dimensions.time / chunkTime); - const latChunkIdx = Math.floor( - grid.latIndex / ZARR_STORE.nativeChunks.lat, - ); - const lonChunkIdx = Math.floor( - grid.lonIndex / ZARR_STORE.nativeChunks.lon, - ); - const { subLatIdx, subLonIdx } = subBlockIndices( - { - localLat: grid.latIndex - latChunkIdx * ZARR_STORE.nativeChunks.lat, - localLon: grid.lonIndex - lonChunkIdx * ZARR_STORE.nativeChunks.lon, - }, - this.windowSize, - ); + const dataset = this.dataset; + const spec = dataset?.grid ?? DEFAULT_GRID_SPEC; + const chunkTime = dataset ? dataset.chunks[dataset.layout.time]! : 1461; + const sampleCount = dataset + ? dataset.shape[dataset.layout.time]! + : ZARR_STORE.dimensions.time; + const years = dataset?.time.years ?? ALL_FLUXCOM_YEARS; + const latChunkIdx = Math.floor(grid.latIndex / spec.nativeChunks.lat); + const lonChunkIdx = Math.floor(grid.lonIndex / spec.nativeChunks.lon); + const context = { + chunkLatIdx: latChunkIdx, + chunkLonIdx: lonChunkIdx, + localLat: grid.latIndex - latChunkIdx * spec.nativeChunks.lat, + localLon: grid.lonIndex - lonChunkIdx * spec.nativeChunks.lon, + timeChunkIndices: [], + }; - for (let chunkIdx = 0; chunkIdx < chunkCount; chunkIdx++) { - const key = nativeChunkKey(variable, { - timeChunkIdx: chunkIdx, - hourChunkIdx: 0, - latChunkIdx, - lonChunkIdx, - subLatIdx, - subLonIdx, - }); - if (this.cache.has(key)) { - for (const year of timeChunkIndexToYears(chunkIdx, chunkTime)) { - cachedYears.add(year); - } + for (const year of years) { + const days = yearToDateRange(year); + const samples = dataset ? sampleRangeForDays(dataset.time, days) : days; + const chunks = listTimeChunkIndicesForRange(samples, chunkTime, sampleCount); + if ( + chunks.length > 0 && + chunks.every((chunk) => this.cache.has(this.patchKey(variable, context, chunk))) + ) { + cachedYears.add(year); } } return cachedYears; } + /** + * One pixel's values over the days `[d0, d1)`, laid out `days x slots` + * whatever the store's own time layout. + */ async getTimeSeriesForRange( grid: GridCell, - timeRange: AxisSlice, - variable = ZARR_STORE.defaultVariable, + dayRange: AxisSlice, + variable = this.defaultVariable(), onProgress?: SeriesProgress, signal?: AbortSignal, - ): Promise<{ values: Float32Array; variable: string; units?: string }> { + options: SeriesOptions = {}, + ): Promise { const array = await this.getArray(variable); + const axis = this.timeAxis(array); const chunkSizes = this.getChunkSizes(array); - const [timeCount, hourCount] = array.shape; + const layout = this.dataset?.layout; + const timeCount = array.shape[layout?.time ?? 0]!; + const hourCount = layout ? (layout.hour >= 0 ? array.shape[layout.hour]! : 1) : array.shape[1]!; + if (layout && layout.hour >= 0 && chunkSizes.hour !== hourCount) { + throw new DatasetError( + "The hour axis is split across chunks, which this reader cannot stitch yet.", + ); + } + + let sampleRange = sampleRangeForDays(axis, dayRange); const context = pixelToNativeChunkContext( grid.latIndex, grid.lonIndex, timeCount, chunkSizes, - timeRange, + sampleRange, ); const units = typeof array.attrs.units === "string" ? array.attrs.units : undefined; @@ -488,54 +636,117 @@ export class ZarrChunkReader { const keyFor = (timeChunkIdx: number) => this.patchKey(variable, context, timeChunkIdx); - const missingCount = context.timeChunkIndices.filter( + let missing = context.timeChunkIndices.filter( (timeChunkIdx) => !this.cache.has(keyFor(timeChunkIdx)), - ).length; - - if (onProgress && missingCount === 0) onProgress(1, 1); + ); + this.checkChunkSize(array); + + const budget = options.budget ?? { remaining: MAX_CHUNKS_PER_SERIES }; + let coveredDays: number | undefined; + if (missing.length > budget.remaining) { + // The chunks the budget reaches, in time order, cached ones for free. + const kept: number[] = []; + let paid = 0; + for (const index of context.timeChunkIndices) { + const free = this.cache.has(keyFor(index)); + if (!free && paid === budget.remaining) break; + if (!free) paid += 1; + kept.push(index); + } + const stopSample = Math.min( + sampleRange[1], + ((kept.at(-1) ?? -1) + 1) * chunkSizes.time, + ); + const stopPos = axis.samplePos[stopSample]; + coveredDays = Math.max( + 0, + Math.min(dayRange[1], stopPos === undefined ? dayRange[1] : Math.floor(stopPos)) - + dayRange[0], + ); + if (!options.partial) { + throw new SeriesBudgetError(budgetMessage(missing.length, dayRange), coveredDays); + } + context.timeChunkIndices = kept; + missing = kept.filter((index) => !this.cache.has(keyFor(index))); + sampleRange = [sampleRange[0], Math.max(sampleRange[0], stopSample)]; + } + budget.remaining -= missing.length; + + if (onProgress && missing.length === 0) onProgress(1, 1); + // Large chunks go one at a time, so only one decoded chunk is resident: + // fetching them all at once is what exhausted memory on mobile. Small + // ones are bound by latency instead, so a few run side by side. + const concurrency = + this.rawChunkBytes(array) <= SMALL_CHUNK_BYTES && missing.length >= SMALL_CHUNK_RUN + ? SMALL_CHUNK_CONCURRENCY + : 1; const tracker = - onProgress && missingCount > 0 - ? createSeriesProgressTracker(missingCount, onProgress) + onProgress && missing.length > 0 && concurrency === 1 + ? createSeriesProgressTracker(missing.length, onProgress) + : null; + const concurrentTracker = + onProgress && missing.length > 0 && concurrency > 1 + ? createConcurrentProgressTracker(missing.length, onProgress) : null; - // Sequential on purpose: one decoded chunk resident at a time. Fetching - // all of them at once is what exhausted memory on mobile. - const segments: { values: Float32Array; chunkStartDay: number }[] = []; - for (const timeChunkIdx of context.timeChunkIndices) { - // Stop between chunks too, so an abandoned request does not start the - // next download after the current one was already paid for. - if (signal?.aborted) throw abortError(); + const segments = new Map(); + const take = (timeChunkIdx: number, patch: CachedPatch) => + segments.set(timeChunkIdx, this.pixelSegment(patch, context)); + for (const timeChunkIdx of context.timeChunkIndices) { const cached = this.cache.get(keyFor(timeChunkIdx)); - if (cached) { - segments.push(this.pixelSegment(cached, context)); - continue; + if (cached) take(timeChunkIdx, cached); + } + + const queue = [...missing]; + const run = async () => { + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + // Stop between chunks too, so an abandoned request does not start the + // next download after the current one was already paid for. + if (signal?.aborted) throw abortError(); + const chunkTracker = concurrentTracker?.start() ?? tracker; + const patch = await this.loadPatch( + array, + variable, + context, + next, + chunkSizes, + chunkTracker, + signal, + ); + take(next, patch); + chunkTracker?.complete(); } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, run)); - const patch = await this.loadPatch( - array, - variable, - context, - timeChunkIdx, - chunkSizes, - tracker, - signal, - ); - segments.push(this.pixelSegment(patch, context)); - tracker?.complete(); - } + const ordered = context.timeChunkIndices + .map((index) => segments.get(index)) + .filter((segment) => segment !== undefined); + const samples = stitchTimeSeriesForRange(ordered, sampleRange, hourCount); return { - values: stitchTimeSeriesForRange(segments, timeRange, hourCount), + values: resampleToDays(axis, samples, sampleRange[0], dayRange), variable, units, + coveredDays, }; } + /** Refuse a chunk too big to decode in a tab, saying why. */ + private checkChunkSize(array: ZarrArray) { + const rawBytes = this.rawChunkBytes(array); + if (rawBytes > MAX_CHUNK_BYTES) { + throw new DatasetError( + `One stored chunk of this array is ${formatMB(rawBytes)} once decoded, more than a browser tab can hold. EarthPrints needs smaller chunks.`, + ); + } + } + async getTimeSeriesForYear( grid: GridCell, year: number, - variable = ZARR_STORE.defaultVariable, + variable = this.defaultVariable(), onProgress?: SeriesProgress, signal?: AbortSignal, ): Promise<{ values: Float32Array; variable: string; units?: string }> { @@ -552,13 +763,18 @@ export class ZarrChunkReader { async getTimeSeriesForYears( grid: GridCell, years: number[], - variable = ZARR_STORE.defaultVariable, + variable = this.defaultVariable(), onProgress?: SeriesProgress, signal?: AbortSignal, - ): Promise<{ values: Float32Array; variable: string; units?: string }> { + options: SeriesOptions = {}, + ): Promise { if (years.length === 0) { return { values: new Float32Array(0), variable }; } + const shared: SeriesOptions = { + ...options, + budget: options.budget ?? { remaining: MAX_CHUNKS_PER_SERIES }, + }; const blocks = yearsToContiguousBlocks(years); if (blocks.length === 1) { const block = blocks[0]!; @@ -572,12 +788,14 @@ export class ZarrChunkReader { variable, onProgress, signal, + shared, ); } let totalLen = 0; const results: Float32Array[] = []; let resolvedUnits: string | undefined; + let coveredDays: number | undefined; // Blocks load one after another, so blocks not yet started are estimated // at the average size of the ones that have reported. @@ -622,7 +840,12 @@ export class ZarrChunkReader { } : undefined, signal, + shared, ); + if (res.coveredDays !== undefined && coveredDays === undefined) { + const slotsPerDay = this.dataset?.time.slotsPerDay ?? 24; + coveredDays = totalLen / slotsPerDay + res.coveredDays; + } results.push(res.values); totalLen += res.values.length; if (res.units) resolvedUnits = res.units; @@ -636,21 +859,20 @@ export class ZarrChunkReader { } onProgress?.(1, 1); - return { values: merged, variable, units: resolvedUnits }; + return { values: merged, variable, units: resolvedUnits, coveredDays }; } async getTimeSeries( grid: GridCell, - variable = ZARR_STORE.defaultVariable, + variable = this.defaultVariable(), historyYears?: number, onProgress?: SeriesProgress, signal?: AbortSignal, ): Promise<{ values: Float32Array; variable: string; units?: string }> { const array = await this.getArray(variable); - const [timeCount] = array.shape; const timeRange = yearsToDayRange( historyYears ?? DEFAULT_HISTORY_YEARS, - timeCount, + this.timeAxis(array).totalDays, ); return this.getTimeSeriesForRange( grid, @@ -661,3 +883,15 @@ export class ZarrChunkReader { ); } } + +function budgetMessage(chunkCount: number, dayRange: AxisSlice): string { + const days = dayRange[1] - dayRange[0]; + const perYear = Math.round((chunkCount / Math.max(1, days)) * 365); + return `This store is chunked by map, not by time, so one point's series needs ${chunkCount.toLocaleString("en-US")} downloads for the selected years (about ${perYear.toLocaleString("en-US")} a year). EarthPrints makes at most ${MAX_CHUNKS_PER_SERIES} per load.`; +} + +const ALL_FLUXCOM_YEARS = Array.from({ length: 2021 - 2001 + 1 }, (_, i) => 2001 + i); + +function formatMB(bytes: number): string { + return `${Math.round(bytes / 1024 ** 2).toLocaleString("en-US")} MB`; +} diff --git a/src/lib/zarr/activeSource.ts b/src/lib/zarr/activeSource.ts new file mode 100644 index 0000000..55d2c9e --- /dev/null +++ b/src/lib/zarr/activeSource.ts @@ -0,0 +1,31 @@ +import { ZARR_STORE } from "@/lib/constants/store"; + +/** What exports and captions say about the open store. */ +export type ActiveSource = { + title: string; + url: string; + variable: string; + longName: string | null; + resolutionDeg: number; + /** "hourly", "6-hourly", "daily", ... */ + stepLabel: string; +}; + +const FLUXCOM_SOURCE: ActiveSource = { + title: ZARR_STORE.kicker, + url: ZARR_STORE.url, + variable: ZARR_STORE.defaultVariable, + longName: "Net ecosystem exchange", + resolutionDeg: ZARR_STORE.spatialResolutionDeg, + stepLabel: "hourly", +}; + +let active: ActiveSource = FLUXCOM_SOURCE; + +export function setActiveSource(source: ActiveSource | null): void { + active = source ?? FLUXCOM_SOURCE; +} + +export function activeSource(): ActiveSource { + return active; +} diff --git a/src/lib/zarr/cfTime.test.ts b/src/lib/zarr/cfTime.test.ts new file mode 100644 index 0000000..415b154 --- /dev/null +++ b/src/lib/zarr/cfTime.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { decodeCfTimes, parseCfUnits } from "@/lib/zarr/cfTime"; + +const iso = (ms: number) => new Date(ms).toISOString(); + +describe("parseCfUnits", () => { + it("reads the common spellings", () => { + expect(parseCfUnits("days since 2001-01-01 00:00:00")).not.toBeNull(); + expect(parseCfUnits("hours since 1900-1-1")).not.toBeNull(); + expect(parseCfUnits("seconds since 1970-01-01T00:00:00Z")).not.toBeNull(); + expect(parseCfUnits("microseconds since 1970-01-01")).not.toBeNull(); + expect(parseCfUnits("fortnights since 2000-01-01")).toBeNull(); + expect(parseCfUnits(undefined)).toBeNull(); + }); +}); + +describe("decodeCfTimes", () => { + it("decodes the standard calendar", () => { + const out = decodeCfTimes([0, 365], "days since 2001-01-01 00:00:00", "proleptic_gregorian")!; + expect(iso(out[0]!)).toBe("2001-01-01T00:00:00.000Z"); + expect(iso(out[1]!)).toBe("2002-01-01T00:00:00.000Z"); + }); + + it("decodes bigint microseconds", () => { + const out = decodeCfTimes([BigInt(21_600_000_000)], "microseconds since 1970-01-01", "standard")!; + expect(iso(out[0]!)).toBe("1970-01-01T06:00:00.000Z"); + }); + + it("skips February 29th in a noleap calendar", () => { + const out = decodeCfTimes([58, 59], "days since 2000-01-01", "noleap")!; + expect(iso(out[0]!)).toBe("2000-02-28T00:00:00.000Z"); + expect(iso(out[1]!)).toBe("2000-03-01T00:00:00.000Z"); + }); + + it("gives every 360_day month 30 days", () => { + const out = decodeCfTimes([30, 359, 360], "days since 2000-01-01", "360_day")!; + expect(iso(out[0]!).slice(0, 10)).toBe("2000-02-01"); + expect(iso(out[2]!).slice(0, 10)).toBe("2001-01-01"); + }); + + it("labels julian dates in the julian calendar", () => { + // Julian 1970-01-01 plus a day is julian 1970-01-02. + const out = decodeCfTimes([1], "days since 1970-01-01", "julian")!; + expect(iso(out[0]!).slice(0, 10)).toBe("1970-01-02"); + }); + + it("decodes month counts", () => { + const out = decodeCfTimes([0, 1, 13], "months since 1850-01-15", "360_day")!; + expect(iso(out[1]!).slice(0, 10)).toBe("1850-02-15"); + expect(iso(out[2]!).slice(0, 10)).toBe("1851-02-15"); + }); + + it("keeps years before 100", () => { + const out = decodeCfTimes([0], "days since 0001-01-01", "noleap")!; + expect(new Date(out[0]!).getUTCFullYear()).toBe(1); + }); + + it("rejects unknown calendars", () => { + expect(decodeCfTimes([0], "days since 2000-01-01", "martian")).toBeNull(); + }); +}); diff --git a/src/lib/zarr/cfTime.ts b/src/lib/zarr/cfTime.ts new file mode 100644 index 0000000..8ba0367 --- /dev/null +++ b/src/lib/zarr/cfTime.ts @@ -0,0 +1,241 @@ +/** + * CF time coordinates (` since `, with a `calendar` attribute) + * decoded to UTC milliseconds. + * + * Model calendars have no real-world instants: a `noleap` or `360_day` date is + * only a label. Each value is therefore read in its own calendar as a + * year-month-day plus a time of day, and that label is placed on the + * proleptic Gregorian axis the plots use. A `360_day` February 30th rolls over + * into March, and a `noleap` year simply has no February 29th. + */ + +const MS_PER_DAY = 86_400_000; + +export type CalendarName = + | "standard" + | "julian" + | "noleap" + | "all_leap" + | "360_day"; + +const CALENDAR_ALIASES: Record = { + standard: "standard", + gregorian: "standard", + proleptic_gregorian: "standard", + julian: "julian", + noleap: "noleap", + "365_day": "noleap", + all_leap: "all_leap", + "366_day": "all_leap", + "360_day": "360_day", +}; + +export function normalizeCalendar(calendar: unknown): CalendarName | null { + if (calendar === undefined || calendar === null) return "standard"; + if (typeof calendar !== "string") return null; + return CALENDAR_ALIASES[calendar.trim().toLowerCase()] ?? null; +} + +type Civil = { year: number; month: number; day: number }; + +/** `Date.UTC` reads years 0 to 99 as 1900 to 1999; model runs start at year 1. */ +function utcMidnight(year: number, month: number, day: number): number { + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + return date.getTime(); +} + +const NOLEAP_MONTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +const ALL_LEAP_MONTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +function fixedYearDays(months: readonly number[], { year, month, day }: Civil) { + const yearLength = months.reduce((sum, days) => sum + days, 0); + let days = year * yearLength; + for (let m = 0; m < month - 1; m++) days += months[m]!; + return days + day - 1; +} + +function fixedYearCivil(months: readonly number[], count: number): Civil { + const yearLength = months.reduce((sum, days) => sum + days, 0); + const year = Math.floor(count / yearLength); + let rest = count - year * yearLength; + let month = 0; + while (rest >= months[month]!) { + rest -= months[month]!; + month++; + } + return { year, month: month + 1, day: rest + 1 }; +} + +// Julian day number arithmetic, from Richards (2013), valid for all years. +function julianToJdn({ year, month, day }: Civil): number { + const a = Math.floor((14 - month) / 12); + const y = year + 4800 - a; + const m = month + 12 * a - 3; + return day + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - 32083; +} + +function jdnToJulian(jdn: number): Civil { + const c = jdn + 32082; + const d = Math.floor((4 * c + 3) / 1461); + const e = c - Math.floor((1461 * d) / 4); + const m = Math.floor((5 * e + 2) / 153); + return { + day: e - Math.floor((153 * m + 2) / 5) + 1, + month: m + 3 - 12 * Math.floor(m / 10), + year: d - 4800 + Math.floor(m / 10), + }; +} + +/** Whole days from the calendar's own epoch to a date. */ +function toDayCount(calendar: CalendarName, civil: Civil): number { + switch (calendar) { + case "standard": + return Math.round( + utcMidnight(civil.year, civil.month, civil.day) / MS_PER_DAY, + ); + case "julian": + return julianToJdn(civil); + case "noleap": + return fixedYearDays(NOLEAP_MONTHS, civil); + case "all_leap": + return fixedYearDays(ALL_LEAP_MONTHS, civil); + case "360_day": + return civil.year * 360 + (civil.month - 1) * 30 + civil.day - 1; + } +} + +function fromDayCount(calendar: CalendarName, count: number): Civil { + switch (calendar) { + case "standard": { + const date = new Date(count * MS_PER_DAY); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + }; + } + case "julian": + return jdnToJulian(count); + case "noleap": + return fixedYearCivil(NOLEAP_MONTHS, count); + case "all_leap": + return fixedYearCivil(ALL_LEAP_MONTHS, count); + case "360_day": + return { + year: Math.floor(count / 360), + month: Math.floor((count % 360 + 360) % 360 / 30) + 1, + day: (((count % 30) + 30) % 30) + 1, + }; + } +} + +const UNIT_MS: Record = { + microsecond: 1e-3, + microseconds: 1e-3, + us: 1e-3, + millisecond: 1, + milliseconds: 1, + ms: 1, + second: 1000, + seconds: 1000, + sec: 1000, + secs: 1000, + s: 1000, + minute: 60_000, + minutes: 60_000, + min: 60_000, + mins: 60_000, + hour: 3_600_000, + hours: 3_600_000, + hr: 3_600_000, + hrs: 3_600_000, + h: 3_600_000, + day: MS_PER_DAY, + days: MS_PER_DAY, + d: MS_PER_DAY, + week: 7 * MS_PER_DAY, + weeks: 7 * MS_PER_DAY, +}; + +export type CfTimeUnits = { + /** Length of one unit in ms, or a calendar month/year step. */ + unit: { ms: number } | { months: number }; + reference: Civil & { msOfDay: number }; +}; + +/** Parse `days since 2001-01-01 00:00:00`, `hours since 1900-1-1`, and so on. */ +export function parseCfUnits(units: unknown): CfTimeUnits | null { + if (typeof units !== "string") return null; + const match = units + .trim() + .match( + /^(\w+)\s+since\s+(-?\d{1,6})-(\d{1,2})-(\d{1,2})(?:[T\s]+(\d{1,2}):(\d{1,2})(?::(\d{1,2}(?:\.\d+)?))?)?\s*(Z|UTC|[+-]\d{1,2}(?::?\d{2})?)?\s*$/i, + ); + if (!match) return null; + + const [, rawUnit, y, mo, d, hh, mm, ss, zone] = match; + const unitName = rawUnit!.toLowerCase(); + let unit: CfTimeUnits["unit"]; + if (unitName in UNIT_MS) unit = { ms: UNIT_MS[unitName]! }; + else if (unitName === "month" || unitName === "months") unit = { months: 1 }; + else if (unitName === "year" || unitName === "years") unit = { months: 12 }; + else return null; + + let msOfDay = + (Number(hh ?? 0) * 3600 + Number(mm ?? 0) * 60 + Number(ss ?? 0)) * 1000; + if (zone && !/^(z|utc)$/i.test(zone)) { + const [, sign, zh, zm] = zone.match(/^([+-])(\d{1,2}):?(\d{2})?$/) ?? []; + const offset = (Number(zh ?? 0) * 60 + Number(zm ?? 0)) * 60_000; + msOfDay -= sign === "-" ? -offset : offset; + } + + return { + unit, + reference: { year: Number(y), month: Number(mo), day: Number(d), msOfDay }, + }; +} + +/** + * Decode raw time values to UTC ms. Returns null when the units or calendar + * are not ones we can read. + */ +export function decodeCfTimes( + raw: ArrayLike, + units: unknown, + calendar?: unknown, +): Float64Array | null { + const parsed = parseCfUnits(units); + const cal = normalizeCalendar(calendar); + if (!parsed || !cal) return null; + + const refDays = toDayCount(cal, parsed.reference); + const out = new Float64Array(raw.length); + + for (let i = 0; i < raw.length; i++) { + const value = Number(raw[i]); + if (!Number.isFinite(value)) { + out[i] = NaN; + continue; + } + + if ("months" in parsed.unit) { + const totalMonths = + parsed.reference.month - 1 + Math.round(value * parsed.unit.months); + const year = parsed.reference.year + Math.floor(totalMonths / 12); + const month = (((totalMonths % 12) + 12) % 12) + 1; + out[i] = + utcMidnight(year, month, parsed.reference.day) + + parsed.reference.msOfDay; + continue; + } + + const elapsedMs = parsed.reference.msOfDay + value * parsed.unit.ms; + const dayShift = Math.floor(elapsedMs / MS_PER_DAY); + const msOfDay = elapsedMs - dayShift * MS_PER_DAY; + const civil = fromDayCount(cal, refDays + dayShift); + out[i] = utcMidnight(civil.year, civil.month, civil.day) + msOfDay; + } + + return out; +} diff --git a/src/lib/zarr/chunk.worker.ts b/src/lib/zarr/chunk.worker.ts index db96f49..71883d7 100644 --- a/src/lib/zarr/chunk.worker.ts +++ b/src/lib/zarr/chunk.worker.ts @@ -1,46 +1,37 @@ /// import * as zarr from "zarrita"; -import { - alignedSubBlock, - blockCoversChunk, - sliceBlockFromNativeChunk, - type LocalBlock, -} from "@/lib/zarr/chunks"; +import type { LocalBlock } from "@/lib/zarr/chunks"; +import { readPatch, type ChunkSource, type PatchRequest } from "@/lib/zarr/patch"; import { createByteProgressSink, isAbortError, openZarrStore, - setActiveAbortSignal, - setActiveByteSink, + withRequestContext, type ZarrStore, } from "@/lib/zarr/store"; /** * Decodes native Zarr chunks off the main thread. * - * One chunk of this dataset is 1461 x 24 x 40 x 40 f4, so decompressing it + * One FLUXCOM-X chunk is 1461 x 24 x 40 x 40 f4, so decompressing it * allocates ~224 MB. Doing that on the main thread froze the tab for seconds * at a time and killed it outright on phones. Here it is decompressed off the - * main thread, and the window the caller asked to keep is cut out before the - * handover, so the 224 MB array is dropped here and never reaches the main - * thread's heap. A caller that wants the whole patch gets the decoded array - * transferred as-is, with no copy. + * main thread, and the window the caller asked to keep is cut out and decoded + * to Float32 before the handover, so the raw chunk is dropped here and never + * reaches the main thread's heap. * * Requests are queued and served one at a time, which caps the memory held * during decoding at a single chunk no matter how many pixels are pending. + * Small chunks skip the queue: they are bound by latency, not memory. */ -export type ChunkRequest = { +export type ChunkRequest = PatchRequest & { type: "decode"; id: number; storeUrl: string; variable: string; - chunkCoords: number[]; - /** Cells kept per side, aligned to a tiling of the chunk. */ - windowSize: number; - /** The pixel that must land inside the kept window. */ - localLat: number; - localLon: number; + /** Small enough to skip the one-at-a-time queue. */ + concurrent?: boolean; }; /** Abandon a decode: drop it if queued, abort its download if running. */ @@ -64,16 +55,10 @@ export type ChunkResponse = } | { id: number; type: "error"; message: string }; -type ZarrArray = { - getChunk( - chunkCoords: number[], - ): Promise<{ data: Float32Array; shape: number[] }>; -}; - const scope = self as unknown as DedicatedWorkerGlobalScope; -let storePromise: Promise | null = null; -const arrayPromises = new Map>(); +const storePromises = new Map>(); +const arrayPromises = new Map>(); // Serialises decoding so only one chunk is ever resident. let queue: Promise = Promise.resolve(); // Abort handles for decodes that are running, and ids cancelled before their @@ -82,28 +67,31 @@ const running = new Map(); const cancelledBeforeStart = new Set(); function getStore(url: string): Promise { - if (!storePromise) { - storePromise = openZarrStore(url).catch((error) => { - storePromise = null; + let promise = storePromises.get(url); + if (!promise) { + promise = openZarrStore(url).catch((error) => { + storePromises.delete(url); throw error; }); + storePromises.set(url, promise); } - return storePromise; + return promise; } -function getArray(url: string, variable: string): Promise { - const existing = arrayPromises.get(variable); +function getArray(url: string, variable: string): Promise { + const key = `${url}|${variable}`; + const existing = arrayPromises.get(key); if (existing) return existing; const promise = getStore(url) .then((ds) => zarr.open(ds.root.resolve(variable), { kind: "array" })) - .then((array) => array as unknown as ZarrArray) + .then((array) => array as unknown as ChunkSource) .catch((error) => { - arrayPromises.delete(variable); + arrayPromises.delete(key); throw error; }); - arrayPromises.set(variable, promise); + arrayPromises.set(key, promise); return promise; } @@ -125,26 +113,9 @@ async function handle(request: ChunkRequest): Promise { } satisfies ChunkResponse); }); - setActiveByteSink(sink); - setActiveAbortSignal(controller.signal); - let chunk: { data: Float32Array; shape: number[] }; - try { - chunk = await array.getChunk(request.chunkCoords); - } finally { - setActiveByteSink(null); - setActiveAbortSignal(null); - } - - const block = alignedSubBlock( - { localLat: request.localLat, localLon: request.localLon }, - request.windowSize, - chunk.shape, + const kept = await withRequestContext(sink, controller.signal, (init) => + readPatch(array, request, init), ); - // A window that spans the chunk is the chunk: transfer it rather than - // copying it into an identical array. - const kept = blockCoversChunk(block, chunk.shape) - ? { data: chunk.data, shape: chunk.shape } - : sliceBlockFromNativeChunk(chunk.data, chunk.shape, block); scope.postMessage( { @@ -152,7 +123,7 @@ async function handle(request: ChunkRequest): Promise { type: "result", data: kept.data, shape: kept.shape, - block, + block: kept.block, } satisfies ChunkResponse, [kept.data.buffer], ); @@ -184,5 +155,9 @@ scope.onmessage = (event: MessageEvent) => { return; } + if (message.concurrent) { + void handle(message); + return; + } queue = queue.then(() => handle(message)); }; diff --git a/src/lib/zarr/chunks.test.ts b/src/lib/zarr/chunks.test.ts new file mode 100644 index 0000000..a236069 --- /dev/null +++ b/src/lib/zarr/chunks.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { canonicalizeChunk } from "@/lib/zarr/chunks"; + +describe("canonicalizeChunk", () => { + it("reorders any axis order to [time, hour, lat, lon] and decodes packing", () => { + // Stored as [lat=2, time=3, member=2, lon=2], row-major. + const shape = [2, 3, 2, 2]; + const stride = [12, 4, 2, 1]; + const data = Int16Array.from({ length: 24 }, (_, i) => i); + data[stride[0]! * 1 + stride[1]! * 2 + stride[2]! * 1 + 1] = -999; + const out = canonicalizeChunk( + data, + shape, + stride, + { time: 1, hour: -1, lat: 0, lon: 3, fixed: [{ axis: 2, local: 1 }] }, + { localLatStart: 1, localLatCount: 1, localLonStart: 0, localLonCount: 2 }, + { scale: 0.5, offset: 10, fill: [-999] }, + ); + expect(out.shape).toEqual([3, 1, 1, 2]); + const raw = (t: number, lon: number) => 12 + t * 4 + 2 + lon; + expect(Array.from(out.data)).toEqual([ + raw(0, 0) * 0.5 + 10, + raw(0, 1) * 0.5 + 10, + raw(1, 0) * 0.5 + 10, + raw(1, 1) * 0.5 + 10, + raw(2, 0) * 0.5 + 10, + NaN, + ]); + }); +}); diff --git a/src/lib/zarr/chunks.ts b/src/lib/zarr/chunks.ts index 262507f..5731290 100644 --- a/src/lib/zarr/chunks.ts +++ b/src/lib/zarr/chunks.ts @@ -309,3 +309,70 @@ export function sliceBlockFromNativeChunk( shape: [timeCount, hourCount, localLatCount, localLonCount], }; } + +/** Which axis of a stored chunk plays which role, and where the others are pinned. */ +export type ChunkPlan = { + time: number; + /** -1 when the store has no hour-of-day axis. */ + hour: number; + lat: number; + lon: number; + /** Chunk-local index kept on every other axis (ensemble member, level). */ + fixed: { axis: number; local: number }[]; +}; + +export type ChunkDecode = { + scale: number; + offset: number; + fill: number[]; +}; + +/** + * Turn a decoded chunk of any axis order, dtype and packing into the reader's + * own layout: a `[time, hour, lat, lon]` Float32Array holding only `block`, + * with fill values as NaN and CF scale and offset applied. `stride` is the + * chunk's own, so column-major (`order: "F"`) chunks read correctly too. + */ +export function canonicalizeChunk( + data: ArrayLike, + shape: readonly number[], + stride: readonly number[], + plan: ChunkPlan, + block: LocalBlock, + decode: ChunkDecode, +): { data: Float32Array; shape: number[] } { + const timeCount = shape[plan.time] ?? 1; + const hourCount = plan.hour >= 0 ? (shape[plan.hour] ?? 1) : 1; + const { localLatStart, localLatCount, localLonStart, localLonCount } = block; + const out = new Float32Array(timeCount * hourCount * localLatCount * localLonCount); + + let base = 0; + for (const { axis, local } of plan.fixed) base += local * (stride[axis] ?? 0); + const tStride = stride[plan.time] ?? 0; + const hStride = plan.hour >= 0 ? (stride[plan.hour] ?? 0) : 0; + const latStride = stride[plan.lat] ?? 0; + const lonStride = stride[plan.lon] ?? 0; + const fill = decode.fill; + const plain = decode.scale === 1 && decode.offset === 0 && fill.length === 0; + + let cursor = 0; + for (let t = 0; t < timeCount; t++) { + for (let h = 0; h < hourCount; h++) { + const plane = base + t * tStride + h * hStride; + for (let lat = 0; lat < localLatCount; lat++) { + let index = plane + (localLatStart + lat) * latStride + localLonStart * lonStride; + for (let lon = 0; lon < localLonCount; lon++) { + const raw = Number(data[index]); + out[cursor++] = plain + ? raw + : Number.isNaN(raw) || fill.includes(raw) + ? NaN + : raw * decode.scale + decode.offset; + index += lonStride; + } + } + } + } + + return { data: out, shape: [timeCount, hourCount, localLatCount, localLonCount] }; +} diff --git a/src/lib/zarr/dataset.test.ts b/src/lib/zarr/dataset.test.ts new file mode 100644 index 0000000..31847f5 --- /dev/null +++ b/src/lib/zarr/dataset.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { describeAxis, inferLayout, normalizeStoreUrl } from "@/lib/zarr/dataset"; + +describe("normalizeStoreUrl", () => { + it("accepts links to metadata files and cloud URIs", () => { + expect(normalizeStoreUrl("https://x.org/a.zarr/zarr.json")).toBe("https://x.org/a.zarr"); + expect(normalizeStoreUrl("https://x.org/a.zarr/.zmetadata ")).toBe("https://x.org/a.zarr"); + expect(normalizeStoreUrl("s3://bucket/path/a.zarr/")).toBe("https://bucket.s3.amazonaws.com/path/a.zarr"); + expect(normalizeStoreUrl("gs://bucket/a.zarr")).toBe("https://storage.googleapis.com/bucket/a.zarr"); + }); +}); + +describe("inferLayout", () => { + it("finds the roles by name in any order and keeps the rest as extras", () => { + expect(inferLayout(["member", "time", "level", "latitude", "longitude"], [5, 10, 3, 4, 8])).toEqual({ + rank: 5, time: 1, hour: -1, lat: 3, lon: 4, extraAxes: [0, 2], + }); + expect(inferLayout(["time", "hour", "lat", "lon"], [7670, 24, 3600, 7200])).toMatchObject({ hour: 1 }); + }); + + it("refuses arrays without a time or a grid", () => { + expect(inferLayout(["lat", "lon"], [3, 4])).toBeNull(); + expect(inferLayout(["x", "y", "band"], [3, 4, 5])).toBeNull(); + }); +}); + +describe("describeAxis", () => { + it("keeps the values only for an uneven axis", () => { + expect(describeAxis(Float64Array.from([0, 1, 2, 3])).values).toBeNull(); + expect(describeAxis(Float64Array.from([0, 1, 3, 7])).values).not.toBeNull(); + }); +}); diff --git a/src/lib/zarr/dataset.ts b/src/lib/zarr/dataset.ts new file mode 100644 index 0000000..9b95e5e --- /dev/null +++ b/src/lib/zarr/dataset.ts @@ -0,0 +1,495 @@ +import * as zarr from "zarrita"; +import { decodeCfTimes } from "@/lib/zarr/cfTime"; +import { openZarrStore, type ZarrStore } from "@/lib/zarr/store"; +import { buildTimeAxis, type TimeAxis } from "@/lib/zarr/timeAxis"; +import type { GridSpec } from "@/types/map"; + +/** A non-spatial, non-time axis the user picks one index of (ensemble, level). */ +export type ExtraDim = { + axis: number; + name: string; + size: number; + /** Coordinate values as text, when the store has them. */ + labels: string[] | null; + units: string | null; +}; + +/** Which array axis plays which role. `hour` is -1 when there is none. */ +export type DatasetLayout = { + rank: number; + time: number; + hour: number; + lat: number; + lon: number; + extras: ExtraDim[]; +}; + +/** CF packing: `value = raw * scale + offset`, with `fill` read as missing. */ +export type DatasetDecode = { + scale: number; + offset: number; + fill: number[]; +}; + +export type VariableInfo = { + path: string; + longName: string | null; + units: string | null; + shape: number[]; +}; + +export type Dataset = { + /** Store root, normalised. */ + url: string; + /** Array path inside the store, e.g. `NEE` or `atmos/tas`. */ + variable: string; + /** The store's own title, or its last path segment. */ + title: string; + longName: string | null; + units: string | null; + /** Every array in the store that can be read as a map of time series. */ + variables: VariableInfo[]; + layout: DatasetLayout; + shape: number[]; + chunks: number[]; + bytesPerValue: number; + decode: DatasetDecode; + grid: GridSpec; + time: TimeAxis; +}; + +/** Thrown with a message that can be shown to the user as is. */ +export class DatasetError extends Error { + constructor(message: string) { + super(message); + this.name = "DatasetError"; + } +} + +const LAT_NAMES = /^(lat|lats|latitude|nav_lat|grid_yt|rlat|y|lat_\d+)$/i; +const LON_NAMES = /^(lon|lons|long|longitude|nav_lon|grid_xt|rlon|x|lon_\d+)$/i; +const TIME_NAMES = /^(time|times|valid_time|t|date|time_counter|time\d*)$/i; +const HOUR_NAMES = /^(hour|hours|hour_of_day)$/i; + +const BYTES: Record = { + int8: 1, + uint8: 1, + int16: 2, + uint16: 2, + float16: 2, + int32: 4, + uint32: 4, + float32: 4, + int64: 8, + uint64: 8, + float64: 8, +}; + +type OpenedArray = zarr.Array; + +/** + * Accept what people paste: a store root, a link to its `zarr.json` or + * `.zmetadata`, an `s3://` or `gs://` URI, or a Hugging Face "blob" link. + */ +export function normalizeStoreUrl(input: string): string { + let url = input.trim(); + const s3 = url.match(/^s3:\/\/([^/]+)\/?(.*)$/i); + if (s3) url = `https://${s3[1]}.s3.amazonaws.com/${s3[2]}`; + const gs = url.match(/^gs:\/\/([^/]+)\/?(.*)$/i); + if (gs) url = `https://storage.googleapis.com/${gs[1]}/${gs[2]}`; + url = url.replace(/^(https:\/\/huggingface\.co\/.+?)\/blob\//, "$1/resolve/"); + url = url.replace(/[?#].*$/, ""); + url = url.replace(/\/(zarr\.json|\.zmetadata|\.zattrs|\.zgroup|\.zarray)$/i, ""); + return url.replace(/\/+$/, ""); +} + +function stringAttr(attrs: Record, key: string): string | null { + const value = attrs[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function numberAttr(attrs: Record, key: string): number | null { + const value = attrs[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (Array.isArray(value) && typeof value[0] === "number") return value[0]; + return null; +} + +function parentPath(path: string): string { + const index = path.lastIndexOf("/"); + return index <= 0 ? "" : path.slice(0, index); +} + +function baseName(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +async function openArray(ds: ZarrStore, path: string): Promise { + return zarr.open(ds.root.resolve(path), { kind: "array" }); +} + +function dimensionNames(array: OpenedArray): string[] | null { + const names = array.dimensionNames; + if (Array.isArray(names) && names.length === array.shape.length) return names; + return null; +} + +/** Roles from dimension names, or from the shape alone when it is unambiguous. */ +export function inferLayout( + names: readonly string[] | null, + shape: readonly number[], +): Omit & { extraAxes: number[] } | null { + const rank = shape.length; + if (rank < 3) return null; + + if (!names) { + if (rank === 3) return { rank, time: 0, hour: -1, lat: 1, lon: 2, extraAxes: [] }; + if (rank === 4 && shape[1] === 24) { + return { rank, time: 0, hour: 1, lat: 2, lon: 3, extraAxes: [] }; + } + return null; + } + + const find = (pattern: RegExp, taken: number[]) => + names.findIndex((name, axis) => !taken.includes(axis) && pattern.test(name)); + const lat = find(LAT_NAMES, []); + const lon = find(LON_NAMES, [lat]); + const time = find(TIME_NAMES, [lat, lon]); + const hour = find(HOUR_NAMES, [lat, lon, time]); + if (lat < 0 || lon < 0 || time < 0) return null; + + const roles = [lat, lon, time, hour]; + const extraAxes = names + .map((_, axis) => axis) + .filter((axis) => !roles.includes(axis)); + return { rank, time, hour, lat, lon, extraAxes }; +} + +function toNumbers(data: ArrayLike): Float64Array { + const out = new Float64Array(data.length); + for (let i = 0; i < data.length; i++) out[i] = Number(data[i]); + return out; +} + +/** A 1-D coordinate next to the variable, or at the store root. */ +async function readCoordinate( + ds: ZarrStore, + variablePath: string, + name: string, +): Promise<{ values: Float64Array; attrs: Record } | null> { + const group = parentPath(variablePath); + const candidates = [group ? `${group}/${name}` : name, name]; + for (const path of [...new Set(candidates)]) { + try { + const array = await openArray(ds, path); + if (array.shape.length !== 1) continue; + const result = await zarr.get(array); + return { + values: toNumbers(result.data as ArrayLike), + attrs: array.attrs as Record, + }; + } catch { + // Not there; try the next place. + } + } + return null; +} + +type Axis1D = { start: number; step: number; values: Float64Array | null }; + +/** Start and step of a 1-D axis, keeping the values when it is not regular. */ +export function describeAxis(values: Float64Array): Axis1D { + const n = values.length; + const start = values[0]!; + const step = n > 1 ? (values[n - 1]! - start) / (n - 1) : 1; + let regular = true; + for (let i = 0; i < n; i++) { + if (Math.abs(values[i]! - (start + i * step)) > Math.abs(step) * 0.01) { + regular = false; + break; + } + } + return { start, step, values: regular ? null : values }; +} + +function decodeFor(array: OpenedArray): DatasetDecode { + const attrs = array.attrs as Record; + const fill = new Set(); + const add = (value: unknown) => { + const number = typeof value === "bigint" ? Number(value) : value; + if (typeof number === "number" && Number.isFinite(number)) fill.add(number); + }; + add(array.fillValue); + add(attrs._FillValue); + const missing = attrs.missing_value; + if (Array.isArray(missing)) missing.forEach(add); + else add(missing); + + return { + scale: numberAttr(attrs, "scale_factor") ?? 1, + offset: numberAttr(attrs, "add_offset") ?? 0, + fill: [...fill], + }; +} + +async function readExtraDim( + ds: ZarrStore, + variable: string, + axis: number, + name: string, + size: number, +): Promise { + const coordinate = size <= 5000 ? await readCoordinate(ds, variable, name) : null; + const labels = + coordinate && coordinate.values.length === size + ? Array.from(coordinate.values, (v) => + Number.isFinite(v) ? String(Number(v.toPrecision(6))) : "?", + ) + : null; + return { + axis, + name, + size, + labels, + units: coordinate ? stringAttr(coordinate.attrs, "units") : null, + }; +} + +/** Arrays that look like a time series on a lat/lon grid. */ +async function listVariables(ds: ZarrStore): Promise { + const listable = ds.store as { contents?: () => { path: string; kind: string }[] }; + if (typeof listable.contents !== "function") return []; + + const paths = listable + .contents() + .filter((entry) => entry.kind === "array") + .map((entry) => entry.path.replace(/^\//, "")); + + const found: VariableInfo[] = []; + for (const path of paths) { + try { + const array = await openArray(ds, path); + if (!inferLayout(dimensionNames(array), array.shape)) continue; + if (!(array.dtype in BYTES)) continue; + const attrs = array.attrs as Record; + found.push({ + path, + longName: stringAttr(attrs, "long_name") ?? stringAttr(attrs, "standard_name"), + units: stringAttr(attrs, "units"), + shape: array.shape, + }); + } catch { + // Unreadable metadata for one array should not hide the others. + } + } + return found; +} + +/** Prefer arrays with more cells, then the first listed. */ +function defaultVariable(variables: VariableInfo[]): string | null { + if (variables.length === 0) return null; + const size = (v: VariableInfo) => v.shape.reduce((a, b) => a * b, 1); + return [...variables].sort((a, b) => size(b) - size(a))[0]!.path; +} + +function validLatitudes(values: Float64Array): boolean { + return values.every((v) => Number.isFinite(v) && v >= -90.5 && v <= 90.5); +} + +function validLongitudes(values: Float64Array): boolean { + return values.every((v) => Number.isFinite(v) && v >= -360 && v <= 720); +} + +async function describeVariable( + ds: ZarrStore, + variable: string, + variables: VariableInfo[], +): Promise> { + let array: OpenedArray; + try { + array = await openArray(ds, variable); + } catch { + throw new DatasetError(`There is no array called "${variable}" in this store.`); + } + + if (!(array.dtype in BYTES)) { + throw new DatasetError( + `"${variable}" holds ${array.dtype} values. Only numeric arrays can be plotted.`, + ); + } + + const names = dimensionNames(array); + const inferred = inferLayout(names, array.shape); + if (!inferred) { + throw new DatasetError( + names + ? `"${variable}" has the dimensions ${names.join(", ")}. EarthPrints needs a time, a latitude and a longitude dimension.` + : `"${variable}" has no dimension names, so its time, latitude and longitude axes cannot be told apart.`, + ); + } + + const dimName = (axis: number, fallback: string) => names?.[axis] ?? fallback; + const [latCoord, lonCoord, timeCoord] = await Promise.all([ + readCoordinate(ds, variable, dimName(inferred.lat, "lat")), + readCoordinate(ds, variable, dimName(inferred.lon, "lon")), + readCoordinate(ds, variable, dimName(inferred.time, "time")), + ]); + + if (!latCoord || !lonCoord) { + throw new DatasetError( + `"${variable}" has no 1-D latitude and longitude coordinates. Curvilinear and projected grids are not supported yet.`, + ); + } + if (!validLatitudes(latCoord.values) || !validLongitudes(lonCoord.values)) { + throw new DatasetError( + `The ${dimName(inferred.lat, "lat")} and ${dimName(inferred.lon, "lon")} coordinates are not in degrees. Projected grids are not supported yet.`, + ); + } + if (!timeCoord) { + throw new DatasetError(`"${variable}" has no time coordinate to read dates from.`); + } + + const times = decodeCfTimes( + timeCoord.values, + timeCoord.attrs.units, + timeCoord.attrs.calendar, + ); + if (!times) { + const units = stringAttr(timeCoord.attrs, "units"); + throw new DatasetError( + units + ? `The time units "${units}" (calendar ${String(timeCoord.attrs.calendar ?? "standard")}) cannot be read.` + : "The time coordinate has no units, so its dates cannot be read.", + ); + } + + const hourCount = inferred.hour >= 0 ? array.shape[inferred.hour]! : null; + const time = buildTimeAxis(times, hourCount); + + const extras = await Promise.all( + inferred.extraAxes.map((axis) => + readExtraDim(ds, variable, axis, dimName(axis, `dim_${axis}`), array.shape[axis]!), + ), + ); + + const lat = describeAxis(latCoord.values); + const lon = describeAxis(lonCoord.values); + const attrs = array.attrs as Record; + + const grid: GridSpec = { + grid: { + lonStart: lon.start, + lonStep: lon.step, + latStart: lat.start, + latStep: lat.step, + }, + dimensions: { + time: time.totalDays, + hour: time.slotsPerDay, + lat: array.shape[inferred.lat]!, + lon: array.shape[inferred.lon]!, + }, + nativeChunks: { + lat: array.chunks[inferred.lat]!, + lon: array.chunks[inferred.lon]!, + }, + spatialResolutionDeg: Math.abs(lon.step), + latValues: lat.values ?? undefined, + lonValues: lon.values ?? undefined, + }; + + return { + variable, + longName: stringAttr(attrs, "long_name") ?? stringAttr(attrs, "standard_name"), + units: stringAttr(attrs, "units"), + variables, + layout: { + rank: inferred.rank, + time: inferred.time, + hour: inferred.hour, + lat: inferred.lat, + lon: inferred.lon, + extras, + }, + shape: array.shape, + chunks: array.chunks, + bytesPerValue: BYTES[array.dtype]!, + decode: decodeFor(array), + grid, + time, + }; +} + +export type OpenDatasetResult = { dataset: Dataset; store: ZarrStore }; + +/** + * Open a store and describe one of its variables. Without `variable`, the + * largest array that reads as a map of time series is picked. + */ +export async function openDataset( + input: string, + variable?: string | null, +): Promise { + let url = normalizeStoreUrl(input); + if (!/^https?:\/\//i.test(url)) { + throw new DatasetError("Enter an http(s), s3:// or gs:// address."); + } + + let ds: ZarrStore; + try { + ds = await openZarrStore(url); + } catch (cause) { + throw new DatasetError(storeErrorMessage(cause)); + } + + let target = variable?.trim().replace(/^\/+|\/+$/g, "") || null; + + // A link straight to an array: open its parent and pick the array. + try { + const node = await zarr.open(ds.root); + if (node.kind === "array") { + const parent = url.slice(0, url.lastIndexOf("/")); + target = target ?? baseName(url); + url = parent; + ds = await openZarrStore(parent); + } + } catch (cause) { + if (!target) throw new DatasetError(storeErrorMessage(cause)); + } + + const variables = await listVariables(ds); + target = target ?? defaultVariable(variables); + if (!target) { + throw new DatasetError( + variables.length === 0 && typeof (ds.store as { contents?: unknown }).contents !== "function" + ? "This store has no consolidated metadata, so its arrays cannot be listed. Type the variable's name." + : "No array in this store has time, latitude and longitude dimensions.", + ); + } + + const described = await describeVariable(ds, target, variables); + const rootAttrs = await zarr + .open(ds.root, { kind: "group" }) + .then((group) => group.attrs as Record) + .catch(() => ({}) as Record); + + return { + store: ds, + dataset: { + ...described, + url, + title: stringAttr(rootAttrs, "title") ?? baseName(url).replace(/\.zarr$/i, ""), + }, + }; +} + +function storeErrorMessage(cause: unknown): string { + const message = cause instanceof Error ? cause.message : String(cause); + if (/failed to fetch|networkerror|load failed|cors/i.test(message)) { + return "The store could not be reached. It may be offline, or its server may not allow requests from other websites (CORS)."; + } + if (/not ?found|404/i.test(message)) { + return "No Zarr metadata was found at this address. Check that it points at the store's root."; + } + return `The store could not be opened: ${message}`; +} diff --git a/src/lib/zarr/localTime.ts b/src/lib/zarr/localTime.ts index 05094ee..6cd6a48 100644 --- a/src/lib/zarr/localTime.ts +++ b/src/lib/zarr/localTime.ts @@ -45,6 +45,22 @@ export function localHourOffset(lon: number, hoursPerDay = 24): number { return Math.round(wrapped / (360 / hoursPerDay)); } +/** Clock time at the start of slot `slot` of a day split into `slotsPerDay`. */ +export function formatSlotTime(slot: number, slotsPerDay = 24): string { + const minutes = Math.round((slot * 24 * 60) / slotsPerDay); + const hh = String(Math.floor(minutes / 60)).padStart(2, "0"); + const mm = String(minutes % 60).padStart(2, "0"); + return `${hh}:${mm}`; +} + +/** "1 hour", "6 hours", "30 minutes": the length of one slot. */ +export function slotLengthLabel(slotsPerDay = 24): string { + const minutes = (24 * 60) / slotsPerDay; + if (minutes % 60 !== 0) return `${Math.round(minutes)} minutes`; + const hours = minutes / 60; + return hours === 1 ? "1 hour" : `${hours} hours`; +} + /** `UTC+10`, `UTC-4`, `UTC`. */ export function formatUtcOffset(offsetHours: number): string { if (offsetHours === 0) return "UTC"; diff --git a/src/lib/zarr/patch.ts b/src/lib/zarr/patch.ts new file mode 100644 index 0000000..9282aa5 --- /dev/null +++ b/src/lib/zarr/patch.ts @@ -0,0 +1,68 @@ +import { + alignedSubBlock, + canonicalizeChunk, + type ChunkDecode, + type ChunkPlan, + type LocalBlock, +} from "@/lib/zarr/chunks"; + +export type PatchRequest = { + chunkCoords: number[]; + plan: ChunkPlan; + decode: ChunkDecode; + /** Cells kept per side, aligned to a tiling of the chunk. */ + windowSize: number; + /** The pixel that must land inside the kept window. */ + localLat: number; + localLon: number; +}; + +export type DecodedPatch = { + data: Float32Array; + /** `[time, hour, lat, lon]` of what is kept. */ + shape: number[]; + /** Where that window sits in the chunk, for rebasing pixel offsets. */ + block: LocalBlock; +}; + +export type ChunkSource = { + getChunk(chunkCoords: number[], options?: RequestInit): Promise<{ + data: ArrayLike; + shape: number[]; + stride?: number[]; + }>; +}; + +function rowMajorStride(shape: readonly number[]): number[] { + const stride = new Array(shape.length); + let step = 1; + for (let axis = shape.length - 1; axis >= 0; axis--) { + stride[axis] = step; + step *= shape[axis]!; + } + return stride; +} + +/** Read one stored chunk and keep the window around a pixel, decoded. */ +export async function readPatch( + array: ChunkSource, + request: PatchRequest, + init?: RequestInit, +): Promise { + const chunk = await array.getChunk(request.chunkCoords, init); + const { plan } = request; + const block = alignedSubBlock( + { localLat: request.localLat, localLon: request.localLon }, + request.windowSize, + [1, 1, chunk.shape[plan.lat] ?? 1, chunk.shape[plan.lon] ?? 1], + ); + const kept = canonicalizeChunk( + chunk.data, + chunk.shape, + chunk.stride ?? rowMajorStride(chunk.shape), + plan, + block, + request.decode, + ); + return { ...kept, block }; +} diff --git a/src/lib/zarr/sourceUrl.ts b/src/lib/zarr/sourceUrl.ts new file mode 100644 index 0000000..5c6c6b7 --- /dev/null +++ b/src/lib/zarr/sourceUrl.ts @@ -0,0 +1,85 @@ +import { ZARR_STORE } from "@/lib/constants/store"; +import type { Dataset } from "@/lib/zarr/dataset"; + +/** What to open: a store, optionally one array in it and an index per extra axis. */ +export type SourceRequest = { + url: string; + variable?: string | null; + /** Index on each extra axis, by dimension name. */ + picks?: Record; +}; + +export const DEFAULT_SOURCE: SourceRequest = { + url: ZARR_STORE.url, + variable: ZARR_STORE.defaultVariable, +}; + +const STORE_PARAM = "store"; +const VARIABLE_PARAM = "var"; +const PICKS_PARAM = "sel"; + +/** `ensemble:0,level:3` */ +function parsePicks(value: string | null): Record { + const picks: Record = {}; + for (const part of (value ?? "").split(",")) { + const [name, index] = part.split(":"); + const parsed = Number(index); + if (name && Number.isInteger(parsed) && parsed >= 0) picks[name] = parsed; + } + return picks; +} + +/** The store in the address bar, so a link opens the same data. */ +export function readSourceFromUrl(): SourceRequest { + if (typeof window === "undefined") return DEFAULT_SOURCE; + const params = new URLSearchParams(window.location.search); + const url = params.get(STORE_PARAM); + if (!url) return DEFAULT_SOURCE; + return { + url, + variable: params.get(VARIABLE_PARAM), + picks: parsePicks(params.get(PICKS_PARAM)), + }; +} + +/** + * Mirror the open store into the address bar. The default store leaves it + * clean. `replaceState` keeps the history entry the data view pushed. + */ +export function writeSourceToUrl( + dataset: Dataset, + picks: Record, +): void { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + const isDefault = + dataset.url === DEFAULT_SOURCE.url && dataset.variable === DEFAULT_SOURCE.variable; + url.searchParams.delete(STORE_PARAM); + url.searchParams.delete(VARIABLE_PARAM); + url.searchParams.delete(PICKS_PARAM); + if (!isDefault) { + url.searchParams.set(STORE_PARAM, dataset.url); + url.searchParams.set(VARIABLE_PARAM, dataset.variable); + const sel = dataset.layout.extras + .filter((dim) => dim.size > 1) + .map((dim) => `${dim.name}:${picks[dim.axis] ?? 0}`) + .join(","); + if (sel) url.searchParams.set(PICKS_PARAM, sel); + } + try { + window.history.replaceState(window.history.state, "", url); + } catch { + // Sandboxed frames can refuse; the store still opens. + } +} + +/** + * Keep the years already picked where the new store has them, otherwise + * start on its last year. + */ +export function defaultYears(available: number[], current: number[]): number[] { + const kept = current.filter((year) => available.includes(year)); + if (kept.length > 0) return kept; + const last = available.at(-1); + return last === undefined ? [] : [last]; +} diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts index 422f6c8..6a02c2a 100644 --- a/src/lib/zarr/store.ts +++ b/src/lib/zarr/store.ts @@ -62,6 +62,34 @@ export function getActiveAbortSignal(): AbortSignal | null { return activeAbortSignal; } +/** + * Per-request progress and abort, for downloads that overlap. The swapped + * globals above only work one request at a time; here the request carries a + * tag that `progressFetch` resolves to its own sink and strips again before + * the request leaves, so it never reaches the server or triggers a CORS + * preflight. + */ +const REQUEST_TAG = "x-earthprints-request"; +const taggedSinks = new Map(); +let nextTag = 0; + +export async function withRequestContext( + sink: ByteSink | null, + signal: AbortSignal | null, + run: (init: RequestInit) => Promise, +): Promise { + const tag = String(++nextTag); + taggedSinks.set(tag, sink); + try { + return await run({ + headers: { [REQUEST_TAG]: tag }, + ...(signal ? { signal } : {}), + }); + } finally { + taggedSinks.delete(tag); + } +} + /** The rejection used when a caller abandons a decode. */ export function abortError(): Error { const error = new Error("Chunk decode aborted"); @@ -128,13 +156,19 @@ export async function fetchWithRetry(request: Request): Promise { * untouched, so data loading never depends on the instrumentation. */ async function progressFetch(request: Request): Promise { + const tag = request.headers.get(REQUEST_TAG); + if (tag !== null) { + const headers = new Headers(request.headers); + headers.delete(REQUEST_TAG); + request = new Request(request, { headers }); + } // Capture the sink synchronously, before the await, so this fetch binds to // 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 sink = tag !== null ? (taggedSinks.get(tag) ?? null) : activeByteSink; // Bind the signal onto the Request so fetchWithRetry's clones keep it and // its own aborted check sees it. - const signal = activeAbortSignal; + const signal = tag !== null ? null : activeAbortSignal; const response = await fetchWithRetry( signal ? new Request(request, { signal }) : request, ); @@ -196,7 +230,9 @@ export function metadataOnlyKey( export async function openZarrStore(url: string = ZARR_STORE.url) { const raw = new zarr.FetchStore(url, { fetch: progressFetch }); - const consolidated = await zarr.withConsolidatedMetadata(raw); + // Stores without consolidated metadata still open; they just cannot list + // their arrays. + const consolidated = await zarr.withMaybeConsolidatedMetadata(raw); const store = zarr.withByteCaching(consolidated, { keyFor: metadataOnlyKey }); return { store, @@ -288,3 +324,57 @@ export async function fetchPixelTimeSeries( units, }; } + +/** + * Byte progress for chunks downloading side by side. Chunks not yet started + * are estimated at the average size of the finished ones. + */ +export function createConcurrentProgressTracker( + chunkCount: number, + onProgress: (loaded: number, total: number) => void, +) { + const inflight = new Map(); + let completedBytes = 0; + let completedChunks = 0; + let nextId = 0; + + function emit() { + let loaded = completedBytes; + let known = completedBytes; + let sizes = 0; + for (const bytes of inflight.values()) { + loaded += bytes.loaded; + known += Math.max(bytes.total, bytes.loaded); + if (bytes.total > 0) sizes += 1; + } + const average = + completedChunks > 0 + ? completedBytes / completedChunks + : sizes > 0 + ? (known - completedBytes) / sizes + : 0; + const pending = Math.max(0, chunkCount - completedChunks - inflight.size); + onProgress(loaded, Math.max(loaded, known + average * pending)); + } + + return { + /** A tracker for one chunk, shaped like `createSeriesProgressTracker`'s. */ + start(): SeriesProgressTracker { + const id = nextId++; + inflight.set(id, { loaded: 0, total: 0 }); + return { + update(loaded: number, total: number) { + inflight.set(id, { loaded, total }); + emit(); + }, + complete() { + const bytes = inflight.get(id); + inflight.delete(id); + completedBytes += bytes ? Math.max(bytes.total, bytes.loaded) : 0; + completedChunks += 1; + emit(); + }, + }; + }, + }; +} diff --git a/src/lib/zarr/timeAxis.test.ts b/src/lib/zarr/timeAxis.test.ts new file mode 100644 index 0000000..f44f94d --- /dev/null +++ b/src/lib/zarr/timeAxis.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildTimeAxis, resampleToDays, sampleRangeForDays } from "@/lib/zarr/timeAxis"; + +const H = 3_600_000; +const D = 24 * H; + +describe("buildTimeAxis", () => { + it("reads a 6-hourly axis as 4 slots a day", () => { + const start = Date.UTC(2023, 0, 1); + const times = Float64Array.from({ length: 8 }, (_, i) => start + i * 6 * H); + const axis = buildTimeAxis(times); + expect(axis.slotsPerDay).toBe(4); + expect(axis.totalDays).toBe(2); + expect(axis.stepLabel).toBe("6-hourly"); + expect(axis.years).toEqual([2023]); + }); + + it("anchors a monthly axis on the first of the month", () => { + const times = Float64Array.from([Date.UTC(2000, 0, 15), Date.UTC(2000, 1, 15), Date.UTC(2000, 2, 15)]); + const axis = buildTimeAxis(times); + expect(axis.span).toBe("month"); + expect(axis.originMs).toBe(Date.UTC(2000, 0, 1)); + expect(axis.totalDays).toBe(31 + 29 + 31); + }); + + it("keeps a separate hour axis as the day's slots", () => { + const times = Float64Array.from({ length: 3 }, (_, i) => Date.UTC(2001, 0, 1) + i * D); + const axis = buildTimeAxis(times, 24); + expect(axis.hourAxis).toBe(true); + expect(axis.slotsPerDay).toBe(24); + expect(axis.totalDays).toBe(3); + }); +}); + +describe("resampleToDays", () => { + it("places sub-daily samples in their slots and leaves gaps NaN", () => { + const start = Date.UTC(2023, 0, 1); + // The second day's 12:00 sample is missing. + const times = Float64Array.from([0, 6, 12, 18, 24, 30, 42].map((h) => start + h * H)); + const axis = buildTimeAxis(times); + const range = sampleRangeForDays(axis, [1, 2]); + expect(range).toEqual([4, 7]); + const out = resampleToDays(axis, Float32Array.from([4, 5, 6]), range[0], [1, 2]); + expect(Array.from(out)).toEqual([4, 5, NaN, 6]); + }); + + it("fills every day of a monthly sample", () => { + const times = Float64Array.from([Date.UTC(2001, 0, 1), Date.UTC(2001, 1, 1)]); + const axis = buildTimeAxis(times); + const range = sampleRangeForDays(axis, [30, 33]); + const out = resampleToDays(axis, Float32Array.from([1, 2]), range[0], [30, 33]); + expect(range[0]).toBe(0); + expect(Array.from(out)).toEqual([1, 2, 2]); + }); +}); diff --git a/src/lib/zarr/timeAxis.ts b/src/lib/zarr/timeAxis.ts new file mode 100644 index 0000000..3893384 --- /dev/null +++ b/src/lib/zarr/timeAxis.ts @@ -0,0 +1,234 @@ +import type { AxisSlice } from "@/lib/zarr/chunks"; + +const MS_PER_DAY = 86_400_000; +const EPSILON = 1e-6; + +/** + * Everything the plots need to know about a store's time axis. + * + * Every dataset is resampled onto one shape, `days x slotsPerDay`, so the + * plots, exports and local-time shift never see the store's own layout. A + * day index counts whole UTC days from `originMs`. + */ +export type TimeAxis = { + /** UTC midnight of day 0. */ + originMs: number; + totalDays: number; + /** Values per day: 24 for hourly, 4 for 6-hourly, 1 for daily or coarser. */ + slotsPerDay: number; + /** Days since the origin at which each store sample starts. */ + samplePos: Float64Array; + /** The store carries the hour of day as its own axis (FLUXCOM-X). */ + hourAxis: boolean; + /** How a sample coarser than a day is spread over the days it covers. */ + span: "slot" | "month" | "year" | "days"; + /** Typical days per sample, for `span: "days"`. */ + stepDays: number; + /** "hourly", "6-hourly", "daily", "monthly". */ + stepLabel: string; + /** Calendar years with at least one day on the axis. */ + years: number[]; +}; + +function median(values: number[]): number { + if (values.length === 0) return NaN; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]!; +} + +function stepLabelFor(slotsPerDay: number, span: TimeAxis["span"], stepDays: number) { + if (span === "month") return "monthly"; + if (span === "year") return "yearly"; + if (span === "days") { + if (Math.abs(stepDays - 7) < 0.5) return "weekly"; + return `${Math.round(stepDays)}-daily`; + } + if (slotsPerDay === 1) return "daily"; + if (slotsPerDay === 24) return "hourly"; + if (slotsPerDay === 48) return "half-hourly"; + const hours = 24 / slotsPerDay; + return Number.isInteger(hours) ? `${hours}-hourly` : `${slotsPerDay} per day`; +} + +function yearsBetween(originMs: number, totalDays: number): number[] { + if (totalDays <= 0) return []; + const first = new Date(originMs).getUTCFullYear(); + const last = new Date(originMs + (totalDays - 1) * MS_PER_DAY).getUTCFullYear(); + return Array.from({ length: last - first + 1 }, (_, i) => first + i); +} + +function startOfUtcDay(ms: number): number { + return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY; +} + +function spanFor(stepDays: number): TimeAxis["span"] { + if (stepDays < 1 + EPSILON) return "slot"; + if (stepDays >= 27.5 && stepDays <= 31.5) return "month"; + if (stepDays >= 360 && stepDays <= 367) return "year"; + return "days"; +} + +/** Day 0 sits on the first sample's day, month or year, so bins line up. */ +function originFor(firstMs: number, span: TimeAxis["span"]): number { + const date = new Date(firstMs); + if (span === "month") { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); + } + if (span === "year") return Date.UTC(date.getUTCFullYear(), 0, 1); + return startOfUtcDay(firstMs); +} + +/** + * Build the axis from decoded sample times. `hourCount` is the size of a + * separate hour-of-day axis when the store has one. + */ +export function buildTimeAxis( + times: Float64Array, + hourCount: number | null = null, +): TimeAxis { + let firstMs = NaN; + let lastMs = NaN; + for (const t of times) { + if (!Number.isFinite(t)) continue; + if (!Number.isFinite(firstMs)) firstMs = t; + lastMs = t; + } + if (!Number.isFinite(firstMs)) { + throw new Error("The time coordinate has no valid dates."); + } + + const steps: number[] = []; + for (let i = 1; i < times.length && steps.length < 2000; i++) { + const step = (times[i]! - times[i - 1]!) / MS_PER_DAY; + if (Number.isFinite(step) && step > 0) steps.push(step); + } + const stepDays = hourCount !== null ? 1 : steps.length > 0 ? median(steps) : 1; + const span = hourCount !== null ? "slot" : spanFor(stepDays); + const originMs = originFor(firstMs, span); + const samplePos = Float64Array.from(times, (t) => (t - originMs) / MS_PER_DAY); + const lastPos = (lastMs - originMs) / MS_PER_DAY; + + const slotsPerDay = + hourCount ?? (stepDays < 1 - EPSILON ? Math.max(1, Math.round(1 / stepDays)) : 1); + + let totalDays: number; + if (span === "slot") { + totalDays = Math.floor(lastPos + EPSILON) + 1; + } else { + const last = new Date(lastMs); + const coveredUntil = + span === "month" + ? Date.UTC(last.getUTCFullYear(), last.getUTCMonth() + 1, 1) + : span === "year" + ? Date.UTC(last.getUTCFullYear() + 1, 0, 1) + : lastMs + stepDays * MS_PER_DAY; + totalDays = Math.max(1, Math.round((coveredUntil - originMs) / MS_PER_DAY)); + } + + return { + originMs, + totalDays, + slotsPerDay, + samplePos, + hourAxis: hourCount !== null, + span, + stepDays, + stepLabel: stepLabelFor(slotsPerDay, span, stepDays), + years: yearsBetween(originMs, totalDays), + }; +} + +/** First index whose position is at or after `day`. */ +function lowerBound(positions: Float64Array, day: number): number { + let lo = 0; + let hi = positions.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (positions[mid]! < day - EPSILON) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** Store samples that contribute to the days `[d0, d1)`. */ +export function sampleRangeForDays(axis: TimeAxis, [d0, d1]: AxisSlice): AxisSlice { + const count = axis.samplePos.length; + if (d1 <= d0 || count === 0) return [0, 0]; + if (axis.hourAxis || axis.span === "slot") { + return [lowerBound(axis.samplePos, d0), lowerBound(axis.samplePos, d1)]; + } + // A coarse sample that starts before d0 can still cover it. + const start = Math.max(0, lowerBound(axis.samplePos, d0 + EPSILON) - 1); + return [start, Math.max(start, lowerBound(axis.samplePos, d1))]; +} + +function coverEnd(axis: TimeAxis, index: number): number { + const pos = axis.samplePos[index]!; + const next = axis.samplePos[index + 1]; + if (axis.span === "month" || axis.span === "year") { + const date = new Date(axis.originMs + pos * MS_PER_DAY); + const end = + axis.span === "month" + ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) + : Date.UTC(date.getUTCFullYear() + 1, 0, 1); + return (end - axis.originMs) / MS_PER_DAY; + } + return next !== undefined && Number.isFinite(next) ? next : pos + axis.stepDays; +} + +function coverStart(axis: TimeAxis, index: number): number { + const pos = axis.samplePos[index]!; + if (axis.span === "month" || axis.span === "year") { + const date = new Date(axis.originMs + pos * MS_PER_DAY); + const start = + axis.span === "month" + ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1) + : Date.UTC(date.getUTCFullYear(), 0, 1); + return (start - axis.originMs) / MS_PER_DAY; + } + return pos; +} + +/** + * Lay store samples `[sampleStart, sampleStart + n)` onto the days + * `[d0, d1)`. `values` holds `hoursPerSample` values per sample (24 for a + * store with an hour axis, 1 otherwise). Days or slots with no sample stay + * NaN; a sample coarser than a day fills every day it covers. + */ +export function resampleToDays( + axis: TimeAxis, + values: Float32Array, + sampleStart: number, + [d0, d1]: AxisSlice, +): Float32Array { + const slots = axis.slotsPerDay; + const out = new Float32Array(Math.max(0, d1 - d0) * slots).fill(NaN); + const perSample = axis.hourAxis ? slots : 1; + const sampleCount = Math.floor(values.length / perSample); + + for (let k = 0; k < sampleCount; k++) { + const index = sampleStart + k; + const pos = axis.samplePos[index]; + if (pos === undefined || !Number.isFinite(pos)) continue; + + if (axis.hourAxis) { + const day = Math.round(pos) - d0; + if (day < 0 || day >= d1 - d0) continue; + out.set(values.subarray(k * slots, (k + 1) * slots), day * slots); + continue; + } + + const value = values[k]!; + if (axis.span === "slot") { + const slot = Math.floor((pos - d0) * slots + EPSILON); + if (slot >= 0 && slot < out.length) out[slot] = value; + continue; + } + + const from = Math.max(d0, Math.round(coverStart(axis, index))); + const to = Math.min(d1, Math.round(coverEnd(axis, index))); + for (let day = from; day < to; day++) out[day - d0] = value; + } + + return out; +} diff --git a/src/lib/zarr/timeRange.test.ts b/src/lib/zarr/timeRange.test.ts index d9ad22d..d127b9a 100644 --- a/src/lib/zarr/timeRange.test.ts +++ b/src/lib/zarr/timeRange.test.ts @@ -5,14 +5,14 @@ import { stitchTimeSeriesForRange, } from "@/lib/zarr/chunks"; import { - ALL_AVAILABLE_YEARS, + availableYears, + dayIndexToUTCDate, formatSelectedYearsLabel, - timeChunkIndexToYears, + setActiveTime, yearsToContiguousBlocks, yearsToDateRange, yearsToDayRange, yearToDateRange, - yearToTimeChunkIndex, } from "@/lib/zarr/timeRange"; describe("yearsToDayRange", () => { @@ -27,9 +27,9 @@ describe("yearsToDayRange", () => { describe("yearToDateRange and chunk mapping", () => { it("covers 2001 to 2021", () => { - expect(ALL_AVAILABLE_YEARS).toHaveLength(21); - expect(ALL_AVAILABLE_YEARS[0]).toBe(2001); - expect(ALL_AVAILABLE_YEARS[20]).toBe(2021); + expect(availableYears()).toHaveLength(21); + expect(availableYears()[0]).toBe(2001); + expect(availableYears()[20]).toBe(2021); }); it("correctly calculates day range for 2001 and 2021", () => { @@ -37,18 +37,6 @@ describe("yearToDateRange and chunk mapping", () => { expect(yearToDateRange(2021)).toEqual([7305, 7670]); }); - it("maps 4-year chunks to years correctly", () => { - expect(yearToTimeChunkIndex(2001)).toBe(0); - expect(yearToTimeChunkIndex(2004)).toBe(0); - expect(yearToTimeChunkIndex(2005)).toBe(1); - expect(yearToTimeChunkIndex(2020)).toBe(4); - expect(yearToTimeChunkIndex(2021)).toBe(5); - - expect(timeChunkIndexToYears(0)).toEqual([2001, 2002, 2003, 2004]); - expect(timeChunkIndexToYears(4)).toEqual([2017, 2018, 2019, 2020]); - expect(timeChunkIndexToYears(5)).toEqual([2021]); - }); - it("calculates contiguous blocks and multi-year range slices", () => { expect(yearsToContiguousBlocks([2018, 2019, 2020])).toEqual([[2018, 2019, 2020]]); expect(yearsToContiguousBlocks([2002, 2003, 2005, 2007, 2008])).toEqual([ @@ -63,6 +51,19 @@ describe("yearToDateRange and chunk mapping", () => { }); }); +describe("another store's time axis", () => { + it("indexes days and years from that store's origin", () => { + setActiveTime({ originMs: Date.UTC(1979, 0, 1), totalDays: 366 + 365, slotsPerDay: 4 }); + try { + expect(availableYears()).toEqual([1979, 1980]); + expect(yearToDateRange(1980)).toEqual([365, 731]); + expect(dayIndexToUTCDate(365).toISOString().slice(0, 10)).toBe("1980-01-01"); + } finally { + setActiveTime(null); + } + }); +}); + describe("listTimeChunkIndicesForRange", () => { it("returns only chunks overlapping the last year", () => { expect(listTimeChunkIndicesForRange([7305, 7670], 1461, 7670)).toEqual([5]); diff --git a/src/lib/zarr/timeRange.ts b/src/lib/zarr/timeRange.ts index 48d143d..c2f90c8 100644 --- a/src/lib/zarr/timeRange.ts +++ b/src/lib/zarr/timeRange.ts @@ -3,30 +3,75 @@ import type { AxisSlice } from "@/lib/zarr/chunks"; export const DEFAULT_HISTORY_YEARS = 1; -/** Days per native time chunk, matching the store's `[1461, 24, 40, 40]`. */ -export const NATIVE_TIME_CHUNK = 1461; - /** - * Calendar origin of the time axis, mirroring the store's time coordinate - * (`units: "days since 2001-01-01"`). Kept as a constant alongside the other - * hardcoded time facts; a day index maps to `origin + index days`. + * Calendar origin of the FLUXCOM-X time axis (`units: "days since + * 2001-01-01"`), and the default until another store is opened. */ export const ZARR_TIME_ORIGIN_UTC = Date.UTC(2001, 0, 1); const MS_PER_DAY = 86_400_000; +/** The time facts of the open store, as day indices from its origin. */ +export type ActiveTime = { + originMs: number; + totalDays: number; + /** Values per day; "hours" for FLUXCOM-X, 4 for a 6-hourly store. */ + slotsPerDay: number; + years: number[]; +}; + +function yearsOf(originMs: number, totalDays: number): number[] { + const first = new Date(originMs).getUTCFullYear(); + const last = new Date(originMs + (totalDays - 1) * MS_PER_DAY).getUTCFullYear(); + return Array.from({ length: last - first + 1 }, (_, i) => first + i); +} + +const FLUXCOM_TIME: ActiveTime = { + originMs: ZARR_TIME_ORIGIN_UTC, + totalDays: ZARR_STORE.dimensions.time, + slotsPerDay: ZARR_STORE.dimensions.hour, + years: yearsOf(ZARR_TIME_ORIGIN_UTC, ZARR_STORE.dimensions.time), +}; + +// One store is open at a time, and every day index in the app is relative to +// its origin. Module state rather than a prop, because the helpers below are +// called from plots, exports and tooltips alike. +let active: ActiveTime = FLUXCOM_TIME; + +export function setActiveTime(time: Omit | null): void { + active = time + ? { ...time, years: yearsOf(time.originMs, time.totalDays) } + : FLUXCOM_TIME; +} + +export function activeTime(): ActiveTime { + return active; +} + +/** Calendar years the open store has data for. */ +export function availableYears(): number[] { + return active.years; +} + /** Absolute day index (0 = origin) to its UTC calendar date. */ export function dayIndexToUTCDate(absoluteDay: number): Date { - return new Date(ZARR_TIME_ORIGIN_UTC + absoluteDay * MS_PER_DAY); + return new Date(active.originMs + absoluteDay * MS_PER_DAY); } export const ZARR_TIME = { - totalDays: ZARR_STORE.dimensions.time, - hoursPerDay: ZARR_STORE.dimensions.hour, + get totalDays() { + return active.totalDays; + }, + /** Values per day. Hours for hourly data, but 4 for 6-hourly, 1 for daily. */ + get hoursPerDay() { + return active.slotsPerDay; + }, defaultHistoryYears: DEFAULT_HISTORY_YEARS, /** Whole years covered by the archive (~21 for FLUXCOM-X NEE). */ - maxHistoryYears: Math.ceil(ZARR_STORE.dimensions.time / 365.25), -} as const; + get maxHistoryYears() { + return Math.ceil(active.totalDays / 365.25); + }, +}; /** Map a "last N years" control value to a half-open day slice `[start, stop)`. */ export function yearsToDayRange( @@ -43,11 +88,6 @@ export function yearsToDayRange( return [start, stop]; } -export const ALL_AVAILABLE_YEARS = Array.from( - { length: 2021 - 2001 + 1 }, - (_, index) => 2001 + index, -); - /** Convert a calendar year into an absolute `[startDay, stopDay)` slice. */ export function yearToDateRange( year: number, @@ -55,34 +95,15 @@ export function yearToDateRange( ): AxisSlice { const start = Math.max( 0, - Math.round((Date.UTC(year, 0, 1) - ZARR_TIME_ORIGIN_UTC) / MS_PER_DAY), + Math.round((Date.UTC(year, 0, 1) - active.originMs) / MS_PER_DAY), ); const stop = Math.min( totalDays, - Math.round((Date.UTC(year + 1, 0, 1) - ZARR_TIME_ORIGIN_UTC) / MS_PER_DAY), + Math.round((Date.UTC(year + 1, 0, 1) - active.originMs) / MS_PER_DAY), ); return [start, stop]; } -/** Get the native temporal chunk index (0..N) containing the given year. */ -export function yearToTimeChunkIndex( - year: number, - chunkTime = 1461, -): number { - const [start] = yearToDateRange(year); - return Math.floor(start / chunkTime); -} - -/** Get all available calendar years that fall into a specific time chunk index. */ -export function timeChunkIndexToYears( - chunkIdx: number, - chunkTime = 1461, -): number[] { - return ALL_AVAILABLE_YEARS.filter( - (year) => yearToTimeChunkIndex(year, chunkTime) === chunkIdx, - ); -} - export function chunkIndexToStartDay(chunkIdx: number, chunkTime: number): number { return chunkIdx * chunkTime; } diff --git a/src/types/map.ts b/src/types/map.ts index 660fd7a..2e49ed1 100644 --- a/src/types/map.ts +++ b/src/types/map.ts @@ -37,6 +37,9 @@ export type GridSpec = { }; /** Cell size in degrees (absolute lon step). */ spatialResolutionDeg: number; + /** Cell centres, kept only when an axis is not evenly spaced. */ + latValues?: Float64Array; + lonValues?: Float64Array; }; export type MapSelection = { From acb656899f20383c3a6abf8382dfaf95ee1dbfe8 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:21:07 +0200 Subject: [PATCH 02/32] Readable numbers and fitted axes for any variable Values printed with two fixed decimals, so rainfall in kg m-2 s-1 read as 0.00 everywhere. They now keep about three significant digits, with exponents for very small or large values, while the NEE range keeps its two decimals. The line and strip axes always reached down to zero, which flattened pressure or temperature in kelvin into a line along the top. They now fit the data, and keep zero only when the data crosses it or sits close to it. Tiny negative noise in one-sided data such as rainfall no longer spreads over half the colour ramp; it is clamped to the neutral centre. --- src/components/plots/FingerprintPlot.tsx | 14 +++--- src/components/plots/TimeSeriesPlot.tsx | 24 +++++++++- src/lib/map/fingerprintScale.test.ts | 7 +++ src/lib/map/fingerprintScale.ts | 22 +++++++-- src/lib/plots/chartTheme.test.ts | 57 ++++++++++++++++++++++++ src/lib/plots/chartTheme.ts | 37 ++++++++++++++- src/lib/plots/fingerprint.ts | 8 +--- src/lib/plots/fingerprintMargins.test.ts | 16 ++++++- src/lib/plots/fingerprintMargins.ts | 11 +++-- 9 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 src/lib/plots/chartTheme.test.ts diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index 6bfb5d7..2a2801e 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -486,12 +486,14 @@ export function FingerprintPlot({ {formatSeriesValue(extents.pivot - extents.negMax)} - - {extents.pivot === 0 ? "0" : formatSeriesValue(extents.pivot)} - + {legend.zeroPct > 12 && legend.zeroPct < 88 ? ( + + {extents.pivot === 0 ? "0" : formatSeriesValue(extents.pivot)} + + ) : null} {formatSeriesValue(extents.pivot + extents.posMax)} diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx index b72da06..afe108c 100644 --- a/src/components/plots/TimeSeriesPlot.tsx +++ b/src/components/plots/TimeSeriesPlot.tsx @@ -13,9 +13,12 @@ import { } from "recharts"; import { formatSeriesValue, + formatTickValue, TIME_SERIES_CHART_MARGIN, TIME_SERIES_PLOT_HEIGHT, + valueDomain, } from "@/lib/plots/chartTheme"; +import { niceTicks } from "@/lib/plots/fingerprintMargins"; import { lineSeries, type LinePoint, @@ -96,6 +99,19 @@ export function TimeSeriesPlot({ [values, hoursPerDay, selectedYears, resolution, timeBasisSuffix], ); const data = series.points; + const yAxis = useMemo(() => { + let lo = Infinity; + let hi = -Infinity; + for (const point of data) { + const [bandLo, bandHi] = point.band ?? [point.value, point.value]; + lo = Math.min(lo, point.value, bandLo); + hi = Math.max(hi, point.value, bandHi); + } + const domain = valueDomain(lo, hi); + const ticks = niceTicks(domain[0], domain[1], 5); + const longest = Math.max(...ticks.map((t) => formatTickValue(t).length)); + return { domain, ticks, width: Math.max(32, longest * 7 + 10) }; + }, [data]); const desktop = useDesktop(); const wrapperRef = useRef(null); @@ -168,10 +184,14 @@ export function TimeSeriesPlot({ axisLine={false} /> {series.yearBreaks.map((x) => ( { expect(asymmetricExtents([0.1, 4, 9]).pivot).toBe(0); }); + it("treats tiny negative noise in one-sided data as zero", () => { + const { negMax, posMax, pivot } = asymmetricExtents([-1e-7, 0, 5e-6, 2e-5]); + expect(pivot).toBe(0); + expect(negMax).toBeLessThan(1e-9); + expect(posMax).toBeCloseTo(2e-5); + }); + it("centres data far from zero on its mean", () => { const { pivot, negMax, posMax } = asymmetricExtents([1000, 1010, 1020, 1030]); expect(pivot).toBeCloseTo(1015); diff --git a/src/lib/map/fingerprintScale.ts b/src/lib/map/fingerprintScale.ts index 1c059a1..369c3d2 100644 --- a/src/lib/map/fingerprintScale.ts +++ b/src/lib/map/fingerprintScale.ts @@ -318,6 +318,9 @@ export type ColorExtents = { * `negMax` and `posMax` are both positive: a half with no data gets a tiny * sentinel (1e-10) to avoid a division-by-zero degenerate state. */ +/** Share of the data range below which values of the other sign count as noise. */ +const ONE_SIDED_NOISE = 0.02; + export function asymmetricExtents(values: ArrayLike): ColorExtents { let min = Infinity; let max = -Infinity; @@ -335,11 +338,22 @@ export function asymmetricExtents(values: ArrayLike): ColorExtents { const width = max - min; const farFromZero = min > 0 ? min > width : max < 0 ? -max > width : false; - const pivot = farFromZero ? sum / count : 0; + if (farFromZero) { + const pivot = sum / count; + return { + negMax: Math.max(1e-10, pivot - min), + posMax: Math.max(1e-10, max - pivot), + pivot, + }; + } + // Rainfall or wind speed carry tiny negative noise around zero; spreading + // that over a whole half of the ramp paints it as strong stripes. Such data + // uses one half only, with the noise clamped to the neutral centre. + const noise = ONE_SIDED_NOISE * width; return { - negMax: Math.max(1e-10, pivot - min), - posMax: Math.max(1e-10, max - pivot), - pivot, + negMax: -min <= noise ? 1e-10 : -min, + posMax: max <= noise ? 1e-10 : max, + pivot: 0, }; } diff --git a/src/lib/plots/chartTheme.test.ts b/src/lib/plots/chartTheme.test.ts new file mode 100644 index 0000000..1488da4 --- /dev/null +++ b/src/lib/plots/chartTheme.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + formatSeriesValue, + formatTickValue, + valueDomain, +} from "@/lib/plots/chartTheme"; + +describe("formatSeriesValue", () => { + it("keeps two decimals in the NEE range", () => { + expect(formatSeriesValue(5.234)).toBe("5.23"); + expect(formatSeriesValue(-12.5)).toBe("-12.50"); + }); + + it("keeps small values readable", () => { + expect(formatSeriesValue(1.6e-5)).toBe("1.6e-5"); + expect(formatSeriesValue(0.0523)).toBe("0.0523"); + }); + + it("drops decimals on large values", () => { + expect(formatSeriesValue(1013.25)).toBe("1013"); + expect(formatSeriesValue(273.15)).toBe("273.1"); + expect(formatSeriesValue(101325)).toBe("101325"); + expect(formatSeriesValue(2.5e7)).toBe("2.5e+7"); + }); + + it("prints zero plainly", () => { + expect(formatSeriesValue(0)).toBe("0"); + expect(formatSeriesValue(-1e-13)).toBe("0"); + }); +}); + +describe("formatTickValue", () => { + it("drops trailing zeros", () => { + expect(formatTickValue(2)).toBe("2"); + expect(formatTickValue(0.5)).toBe("0.5"); + expect(formatTickValue(1e-5)).toBe("1e-5"); + }); +}); + +describe("valueDomain", () => { + it("keeps zero for data that crosses it", () => { + const [lo, hi] = valueDomain(-5, 10); + expect(lo).toBeLessThan(-5); + expect(hi).toBeGreaterThan(10); + }); + + it("starts at zero for data close to it", () => { + expect(valueDomain(1e-7, 2e-5)[0]).toBe(0); + }); + + it("fits data far from zero", () => { + const [lo, hi] = valueDomain(990, 1035); + expect(lo).toBeGreaterThan(980); + expect(lo).toBeLessThan(990); + expect(hi).toBeGreaterThan(1035); + }); +}); diff --git a/src/lib/plots/chartTheme.ts b/src/lib/plots/chartTheme.ts index 75cc997..d992429 100644 --- a/src/lib/plots/chartTheme.ts +++ b/src/lib/plots/chartTheme.ts @@ -7,8 +7,43 @@ export const TIME_SERIES_CHART_MARGIN = { bottom: 0, } as const; +/** + * About three significant digits whatever the magnitude, so rainfall in + * kg m-2 s-1 (1e-5) and pressure in Pa (1e5) stay readable. Values from 1 to + * 100 keep two decimals, as NEE always showed them. + */ export function formatSeriesValue(value: number): string { - return value.toFixed(2); + if (!Number.isFinite(value)) return String(value); + const abs = Math.abs(value); + if (abs < 1e-12) return "0"; + if (abs < 1e-3 || abs >= 1e6) return trimZeros(value.toExponential(2)); + if (abs >= 1000) return value.toFixed(0); + if (abs >= 100) return value.toFixed(1); + if (abs >= 1) return value.toFixed(2); + return value.toFixed(2 - Math.floor(Math.log10(abs))); +} + +/** Axis ticks are round numbers, so their trailing zeros are noise. */ +export function formatTickValue(value: number): string { + return trimZeros(formatSeriesValue(value)); +} + +function trimZeros(text: string): string { + return text.replace(/(\.\d*?)0+(?=$|e)/, "$1").replace(/\.(?=$|e)/, ""); +} + +/** + * Value axis limits that fit the data. Zero stays in view when the data + * crosses it or sits within one data range of it, as a flux or rainfall + * does; pressure or temperature in kelvin get an axis around their values. + */ +export function valueDomain(lo: number, hi: number): [number, number] { + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [0, 1]; + const width = hi - lo; + if (lo > 0 && lo <= width) lo = 0; + if (hi < 0 && -hi <= width) hi = 0; + const pad = (hi - lo || Math.abs(hi) || 1) * 0.05; + return [lo === 0 ? 0 : lo - pad, hi === 0 ? 0 : hi + pad]; } export function chartTickColor(isLight: boolean): string { diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts index 950a47e..efe29ee 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -5,7 +5,7 @@ import { formatDayTick, } from "@/lib/map/fingerprintScale"; import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; -import { chartTickColor } from "@/lib/plots/chartTheme"; +import { chartTickColor, formatTickValue } from "@/lib/plots/chartTheme"; import { bandRange, type FingerprintMargins, @@ -194,10 +194,6 @@ function stripValuePos(stats: BinStats, box: StripBox, alongX: boolean) { : box.x + ((value - lo) / (hi - lo)) * box.w; } -function formatTick(value: number): string { - return String(Number(value.toFixed(2))); -} - /** * One mean line with a ±std band. `alongX` strips run with the columns and * grow upwards; the others run with the rows and grow to the right. @@ -279,7 +275,7 @@ function drawStrip( } ctx.restore(); - return ticks.map((tick) => ({ label: formatTick(tick), at: valuePos(tick) })); + return ticks.map((tick) => ({ label: formatTickValue(tick), at: valuePos(tick) })); } function clampLabelX(x: number, plotW: number, axisLeft: number): number { diff --git a/src/lib/plots/fingerprintMargins.test.ts b/src/lib/plots/fingerprintMargins.test.ts index d1b9dc9..4b4d13c 100644 --- a/src/lib/plots/fingerprintMargins.test.ts +++ b/src/lib/plots/fingerprintMargins.test.ts @@ -25,13 +25,25 @@ describe("fingerprintMargins", () => { }); describe("bandRange", () => { - it("covers the band and zero", () => { + it("covers the band and zero when the data starts near zero", () => { const stats = { mean: new Float32Array([2, 4, NaN]), std: new Float32Array([1, 1, NaN]), count: new Uint32Array([1, 1, 0]), }; - expect(bandRange(stats)).toEqual([0, 5]); + expect(bandRange(stats)).toEqual([0, 5.25]); + }); + + it("fits data far from zero without reaching down to it", () => { + const stats = { + mean: new Float32Array([280, 290]), + std: new Float32Array([1, 1]), + count: new Uint32Array([1, 1]), + }; + const [lo, hi] = bandRange(stats); + expect(lo).toBeGreaterThan(270); + expect(lo).toBeLessThan(279); + expect(hi).toBeGreaterThan(291); }); it("opens up a flat range", () => { diff --git a/src/lib/plots/fingerprintMargins.ts b/src/lib/plots/fingerprintMargins.ts index 78d6cd6..b06be1f 100644 --- a/src/lib/plots/fingerprintMargins.ts +++ b/src/lib/plots/fingerprintMargins.ts @@ -3,6 +3,7 @@ import { emptyStats, RunningStats, } from "@/lib/plots/timeBins"; +import { valueDomain } from "@/lib/plots/chartTheme"; export type FingerprintMargins = { /** One entry per column, over its hours: the seasonal course. */ @@ -40,8 +41,8 @@ export function fingerprintMargins( /** Range covering mean ± std, widened to include zero. */ export function bandRange(stats: BinStats): [number, number] { - let lo = 0; - let hi = 0; + let lo = Infinity; + let hi = -Infinity; for (let i = 0; i < stats.mean.length; i++) { const mean = stats.mean[i]!; const std = stats.std[i]!; @@ -49,10 +50,12 @@ export function bandRange(stats: BinStats): [number, number] { lo = Math.min(lo, mean - std); hi = Math.max(hi, mean + std); } - return lo === hi ? [lo - 1, hi + 1] : [lo, hi]; + if (lo > hi) return [-1, 1]; + const [from, to] = valueDomain(lo, hi); + return from === to ? [from - 1, to + 1] : [from, to]; } -/** Round tick values inside `[lo, hi]`, at least two, always with zero. */ +/** Round tick values inside `[lo, hi]`, at least two. */ export function niceTicks(lo: number, hi: number, count = 3): number[] { const rough = (hi - lo) / Math.max(1, count - 1); const exponent = Math.floor(Math.log10(rough)); From bafe4f67cb3be2e96945ecc4d207cbcf7e307412 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:25:44 +0200 Subject: [PATCH 03/32] Place coarse and model-calendar steps on the right days An 8-daily or weekly value filled days from its timestamp forward, ran on into the next year and past the end of the record, and added an empty year to the picker. Such steps are now taken as centred on their timestamp, stop halfway to their neighbours, and never cross into another year. A 360_day year was mapped date by date, so February 29th and 30th rolled into March and overwrote it, and seven 31sts a year stayed empty. It is now stretched over the real year by day of year: no value is lost and the spare days are spread out. Captions counted filled days as steps ("365 monthly steps"). They now count the values the store holds for the selected years, with singular forms. --- src/components/panels/CellView.tsx | 1 + src/components/plots/FingerprintPlot.tsx | 6 +- src/components/plots/TimeSeriesPlot.tsx | 11 +++- src/hooks/useCellSeries.ts | 21 ++++++- src/lib/zarr/cfTime.test.ts | 14 +++-- src/lib/zarr/cfTime.ts | 21 ++++++- src/lib/zarr/timeAxis.test.ts | 35 ++++++++++- src/lib/zarr/timeAxis.ts | 78 ++++++++++++++++-------- 8 files changed, 150 insertions(+), 37 deletions(-) diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 160aa70..fd3f9e1 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -248,6 +248,7 @@ export function PlotArea({ selectedYears={series.selectedYears} resolution={display.lineResolution} timeBasisSuffix={shortTimeBasis(display.timeBasisLabel)} + stepCount={series.stepCount} height={height} pinLimit={pinLimit} /> diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index 2a2801e..049fb85 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -501,7 +501,11 @@ export function FingerprintPlot({

{binCountLabel(nCols, scale)} × {hoursPerDay}{" "} - {hoursPerDay === 24 ? "hours" : "steps a day"} + {hoursPerDay === 24 + ? "hours" + : hoursPerDay === 1 + ? "step a day" + : "steps a day"} {timeBasisLabel ? ` · ${timeBasisLabel}` : ""} {units ? ` · ${units}` : ""} {margins ? " · strips show the mean ±1 std of each row and column" : ""} diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx index afe108c..f778e46 100644 --- a/src/components/plots/TimeSeriesPlot.tsx +++ b/src/components/plots/TimeSeriesPlot.tsx @@ -52,6 +52,10 @@ const MEANS_LABEL: Record, string> = { month: "monthly", }; +function plural(count: number, noun: string): string { + return `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`; +} + type Readout = TooltipAnchor & PinPosition & { index: number }; const sameDay = (a: Readout, b: Readout) => a.index === b.index; @@ -69,6 +73,8 @@ type TimeSeriesPlotProps = { selectedYears: number[]; resolution?: LineResolution; timeBasisSuffix?: string; + /** Values the store holds for the selection; defaults to the grid's length. */ + stepCount?: number | null; height?: number; width?: number; pinLimit?: number; @@ -82,6 +88,7 @@ export function TimeSeriesPlot({ selectedYears, resolution = "day", timeBasisSuffix, + stepCount, height = TIME_SERIES_PLOT_HEIGHT, width, pinLimit = 1, @@ -300,10 +307,10 @@ export function TimeSeriesPlot({ /> ) : null}

- {values.length.toLocaleString()} {activeSource().stepLabel} steps + {plural(stepCount ?? values.length, `${activeSource().stepLabel} step`)} {resolution === "hour" ? "" - : ` · ${series.binCount.toLocaleString()} ${MEANS_LABEL[resolution]} means, band ±1 std`} + : ` · ${plural(series.binCount, `${MEANS_LABEL[resolution]} mean`)}, band ±1 std`} {units ? ` · ${units}` : ""}

diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index 38eea66..69683ea 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -1,11 +1,12 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { geoPointToZarrGrid, isPointInGrid } from "@/lib/map/geogrid"; import { SeriesBudgetError, ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; import { openDataset, type Dataset } from "@/lib/zarr/dataset"; import { setActiveSource } from "@/lib/zarr/activeSource"; -import { setActiveTime } from "@/lib/zarr/timeRange"; +import { setActiveTime, yearToDateRange } from "@/lib/zarr/timeRange"; +import { samplesInDays } from "@/lib/zarr/timeAxis"; import { defaultYears, readSourceFromUrl, @@ -290,6 +291,21 @@ export function useCellSeries() { [load, selectedYears, selection], ); + // Coarse stores fill several days per sample, so the filled grid's length + // says nothing about how many values the store holds for these years. + const stepCount = useMemo(() => { + if (!dataset) return null; + return selectedYears.reduce( + (sum, year) => + sum + + samplesInDays( + dataset.time, + yearToDateRange(year, dataset.time.totalDays), + ), + 0, + ); + }, [dataset, selectedYears]); + return { dataset, datasetLoading, @@ -298,6 +314,7 @@ export function useCellSeries() { extraIndices, setExtraIndex, slotsPerDay: dataset?.time.slotsPerDay ?? 24, + stepCount, gridSpec, selection, select, diff --git a/src/lib/zarr/cfTime.test.ts b/src/lib/zarr/cfTime.test.ts index 415b154..8f8b68c 100644 --- a/src/lib/zarr/cfTime.test.ts +++ b/src/lib/zarr/cfTime.test.ts @@ -32,10 +32,16 @@ describe("decodeCfTimes", () => { expect(iso(out[1]!)).toBe("2000-03-01T00:00:00.000Z"); }); - it("gives every 360_day month 30 days", () => { - const out = decodeCfTimes([30, 359, 360], "days since 2000-01-01", "360_day")!; - expect(iso(out[0]!).slice(0, 10)).toBe("2000-02-01"); - expect(iso(out[2]!).slice(0, 10)).toBe("2001-01-01"); + it("stretches a 360_day year over the real one without collisions", () => { + const raw = Array.from({ length: 361 }, (_, i) => i); + const out = decodeCfTimes(raw, "days since 2000-01-01", "360_day")!; + const days = Array.from(out.subarray(0, 360), (ms) => iso(ms).slice(0, 10)); + expect(new Set(days).size).toBe(360); + expect(days[0]).toBe("2000-01-01"); + // Model February 30th (day 59) stays in February. + expect(days[59]!.slice(0, 7)).toBe("2000-02"); + expect(days[359]).toBe("2000-12-30"); + expect(iso(out[360]!).slice(0, 10)).toBe("2001-01-01"); }); it("labels julian dates in the julian calendar", () => { diff --git a/src/lib/zarr/cfTime.ts b/src/lib/zarr/cfTime.ts index 8ba0367..5e81218 100644 --- a/src/lib/zarr/cfTime.ts +++ b/src/lib/zarr/cfTime.ts @@ -5,8 +5,11 @@ * Model calendars have no real-world instants: a `noleap` or `360_day` date is * only a label. Each value is therefore read in its own calendar as a * year-month-day plus a time of day, and that label is placed on the - * proleptic Gregorian axis the plots use. A `360_day` February 30th rolls over - * into March, and a `noleap` year simply has no February 29th. + * proleptic Gregorian axis the plots use. A `noleap` year simply has no + * February 29th. A `360_day` year is stretched over the real one by day of + * year, as cftime's `convert_calendar(align_on="year")` does: its 360 days + * keep their order, none collide, and the five or six spare real days are + * spread through the year instead of piling up on the 31sts. */ const MS_PER_DAY = 86_400_000; @@ -45,6 +48,10 @@ function utcMidnight(year: number, month: number, day: number): number { return date.getTime(); } +function isGregorianLeap(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + const NOLEAP_MONTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; const ALL_LEAP_MONTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; @@ -233,7 +240,15 @@ export function decodeCfTimes( const elapsedMs = parsed.reference.msOfDay + value * parsed.unit.ms; const dayShift = Math.floor(elapsedMs / MS_PER_DAY); const msOfDay = elapsedMs - dayShift * MS_PER_DAY; - const civil = fromDayCount(cal, refDays + dayShift); + const count = refDays + dayShift; + if (cal === "360_day") { + const year = Math.floor(count / 360); + const realDays = isGregorianLeap(year) ? 366 : 365; + const dayOfYear = Math.floor(((count - year * 360) * realDays) / 360); + out[i] = utcMidnight(year, 1, 1 + dayOfYear) + msOfDay; + continue; + } + const civil = fromDayCount(cal, count); out[i] = utcMidnight(civil.year, civil.month, civil.day) + msOfDay; } diff --git a/src/lib/zarr/timeAxis.test.ts b/src/lib/zarr/timeAxis.test.ts index f44f94d..87501b3 100644 --- a/src/lib/zarr/timeAxis.test.ts +++ b/src/lib/zarr/timeAxis.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildTimeAxis, resampleToDays, sampleRangeForDays } from "@/lib/zarr/timeAxis"; +import { + buildTimeAxis, + resampleToDays, + sampleRangeForDays, + samplesInDays, +} from "@/lib/zarr/timeAxis"; const H = 3_600_000; const D = 24 * H; @@ -52,4 +57,32 @@ describe("resampleToDays", () => { expect(range[0]).toBe(0); expect(Array.from(out)).toEqual([1, 2, 2]); }); + + it("keeps an 8-daily window inside its year and the record", () => { + // ESDC stamps each 8-day window at its centre and restarts in January. + const times = Float64Array.from([ + Date.UTC(2014, 11, 28), + Date.UTC(2015, 0, 5), + Date.UTC(2015, 0, 13), + ]); + const axis = buildTimeAxis(times); + expect(axis.span).toBe("days"); + expect(axis.years).toEqual([2014, 2015]); + const out = resampleToDays(axis, Float32Array.from([1, 2, 3]), 0, [0, axis.totalDays]); + const day = (y: number, m: number, d: number) => + Math.round((Date.UTC(y, m, d) - axis.originMs) / D); + expect(out[day(2014, 11, 31)]).toBe(1); + expect(out[day(2015, 0, 1)]).toBe(2); + expect(out[day(2015, 0, 13)]).toBe(3); + expect(axis.totalDays).toBe(day(2015, 0, 17)); + }); +}); + +describe("samplesInDays", () => { + it("counts store samples, not filled days", () => { + const times = Float64Array.from({ length: 12 }, (_, m) => Date.UTC(2001, m, 15)); + const axis = buildTimeAxis(times); + expect(samplesInDays(axis, [0, axis.totalDays])).toBe(12); + expect(axis.years).toEqual([2001]); + }); }); diff --git a/src/lib/zarr/timeAxis.ts b/src/lib/zarr/timeAxis.ts index 3893384..b718084 100644 --- a/src/lib/zarr/timeAxis.ts +++ b/src/lib/zarr/timeAxis.ts @@ -69,8 +69,18 @@ function spanFor(stepDays: number): TimeAxis["span"] { } /** Day 0 sits on the first sample's day, month or year, so bins line up. */ -function originFor(firstMs: number, span: TimeAxis["span"]): number { +function originFor( + firstMs: number, + span: TimeAxis["span"], + stepDays: number, +): number { const date = new Date(firstMs); + if (span === "days") { + const yearStart = Date.UTC(date.getUTCFullYear(), 0, 1); + return startOfUtcDay( + Math.max(yearStart, firstMs - (stepDays / 2) * MS_PER_DAY), + ); + } if (span === "month") { return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); } @@ -104,7 +114,7 @@ export function buildTimeAxis( } const stepDays = hourCount !== null ? 1 : steps.length > 0 ? median(steps) : 1; const span = hourCount !== null ? "slot" : spanFor(stepDays); - const originMs = originFor(firstMs, span); + const originMs = originFor(firstMs, span, stepDays); const samplePos = Float64Array.from(times, (t) => (t - originMs) / MS_PER_DAY); const lastPos = (lastMs - originMs) / MS_PER_DAY; @@ -121,8 +131,11 @@ export function buildTimeAxis( ? Date.UTC(last.getUTCFullYear(), last.getUTCMonth() + 1, 1) : span === "year" ? Date.UTC(last.getUTCFullYear() + 1, 0, 1) - : lastMs + stepDays * MS_PER_DAY; - totalDays = Math.max(1, Math.round((coveredUntil - originMs) / MS_PER_DAY)); + : Math.min( + lastMs + (stepDays / 2) * MS_PER_DAY, + Date.UTC(last.getUTCFullYear() + 1, 0, 1), + ); + totalDays = Math.max(1, Math.ceil((coveredUntil - originMs) / MS_PER_DAY - EPSILON)); } return { @@ -162,31 +175,47 @@ export function sampleRangeForDays(axis: TimeAxis, [d0, d1]: AxisSlice): AxisSli return [start, Math.max(start, lowerBound(axis.samplePos, d1))]; } -function coverEnd(axis: TimeAxis, index: number): number { - const pos = axis.samplePos[index]!; - const next = axis.samplePos[index + 1]; - if (axis.span === "month" || axis.span === "year") { - const date = new Date(axis.originMs + pos * MS_PER_DAY); - const end = - axis.span === "month" - ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) - : Date.UTC(date.getUTCFullYear() + 1, 0, 1); - return (end - axis.originMs) / MS_PER_DAY; - } - return next !== undefined && Number.isFinite(next) ? next : pos + axis.stepDays; +function yearBounds(axis: TimeAxis, pos: number): [number, number] { + const year = new Date(axis.originMs + pos * MS_PER_DAY).getUTCFullYear(); + return [ + (Date.UTC(year, 0, 1) - axis.originMs) / MS_PER_DAY, + (Date.UTC(year + 1, 0, 1) - axis.originMs) / MS_PER_DAY, + ]; } -function coverStart(axis: TimeAxis, index: number): number { +/** + * The days `[start, end)` one coarse sample stands for. A month or year + * fills its calendar period. Other steps (8-daily, weekly) are taken as + * centred on their timestamp, as aggregated products usually stamp them, and + * stop halfway to their neighbours and at the end of their year, since such + * products restart their windows every January. + */ +function coverRange(axis: TimeAxis, index: number): [number, number] { const pos = axis.samplePos[index]!; if (axis.span === "month" || axis.span === "year") { const date = new Date(axis.originMs + pos * MS_PER_DAY); - const start = + const y = date.getUTCFullYear(); + const m = date.getUTCMonth(); + const [from, to] = axis.span === "month" - ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1) - : Date.UTC(date.getUTCFullYear(), 0, 1); - return (start - axis.originMs) / MS_PER_DAY; + ? [Date.UTC(y, m, 1), Date.UTC(y, m + 1, 1)] + : [Date.UTC(y, 0, 1), Date.UTC(y + 1, 0, 1)]; + return [(from - axis.originMs) / MS_PER_DAY, (to - axis.originMs) / MS_PER_DAY]; } - return pos; + const half = axis.stepDays / 2; + const prev = axis.samplePos[index - 1]; + const next = axis.samplePos[index + 1]; + const [yearStart, yearEnd] = yearBounds(axis, pos); + let start = Math.max(pos - half, yearStart); + let end = Math.min(pos + half, yearEnd); + if (prev !== undefined && Number.isFinite(prev)) start = Math.max(start, (prev + pos) / 2); + if (next !== undefined && Number.isFinite(next)) end = Math.min(end, (pos + next) / 2); + return [start, end]; +} + +/** Store samples whose timestamp falls on the days `[d0, d1)`. */ +export function samplesInDays(axis: TimeAxis, [d0, d1]: AxisSlice): number { + return lowerBound(axis.samplePos, d1) - lowerBound(axis.samplePos, d0); } /** @@ -225,8 +254,9 @@ export function resampleToDays( continue; } - const from = Math.max(d0, Math.round(coverStart(axis, index))); - const to = Math.min(d1, Math.round(coverEnd(axis, index))); + const [start, end] = coverRange(axis, index); + const from = Math.max(d0, Math.round(start)); + const to = Math.min(d1, Math.round(end)); for (let day = from; day < to; day++) out[day - d0] = value; } From 0774c6da40cebb38001a33ddde49006440b43f33 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:27:00 +0200 Subject: [PATCH 04/32] Read finely chunked coordinates and honour the CF valid range A time axis split into a thousand tiny chunks, as in MUR SST, started every request at once; the browser ran out of connections and the store was reported as having no time coordinate. Coordinates now load eight chunks at a time, and a real read failure is reported instead of being taken for a missing coordinate. Packed values outside valid_min, valid_max or valid_range are now missing, as CF defines, instead of plotting as spikes. --- src/lib/zarr/chunks.test.ts | 12 +++++++ src/lib/zarr/chunks.ts | 17 ++++++++-- src/lib/zarr/dataset.ts | 66 +++++++++++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/lib/zarr/chunks.test.ts b/src/lib/zarr/chunks.test.ts index a236069..738e985 100644 --- a/src/lib/zarr/chunks.test.ts +++ b/src/lib/zarr/chunks.test.ts @@ -27,4 +27,16 @@ describe("canonicalizeChunk", () => { NaN, ]); }); + + it("reads stored values outside the CF valid range as missing", () => { + const out = canonicalizeChunk( + Int16Array.from([100, 32000, -32000, 200]), + [4, 1, 1], + [1, 1, 1], + { time: 0, hour: -1, lat: 1, lon: 2, fixed: [] }, + { localLatStart: 0, localLatCount: 1, localLonStart: 0, localLonCount: 1 }, + { scale: 0.01, offset: 300, fill: [], validMin: -30000, validMax: 30000 }, + ); + expect(Array.from(out.data)).toEqual([301, NaN, NaN, 302]); + }); }); diff --git a/src/lib/zarr/chunks.ts b/src/lib/zarr/chunks.ts index 5731290..93c9a79 100644 --- a/src/lib/zarr/chunks.ts +++ b/src/lib/zarr/chunks.ts @@ -325,6 +325,9 @@ export type ChunkDecode = { scale: number; offset: number; fill: number[]; + /** CF valid range, in the stored (packed) units; values outside are missing. */ + validMin?: number; + validMax?: number; }; /** @@ -353,7 +356,14 @@ export function canonicalizeChunk( const latStride = stride[plan.lat] ?? 0; const lonStride = stride[plan.lon] ?? 0; const fill = decode.fill; - const plain = decode.scale === 1 && decode.offset === 0 && fill.length === 0; + const validMin = decode.validMin ?? -Infinity; + const validMax = decode.validMax ?? Infinity; + const plain = + decode.scale === 1 && + decode.offset === 0 && + fill.length === 0 && + validMin === -Infinity && + validMax === Infinity; let cursor = 0; for (let t = 0; t < timeCount; t++) { @@ -365,7 +375,10 @@ export function canonicalizeChunk( const raw = Number(data[index]); out[cursor++] = plain ? raw - : Number.isNaN(raw) || fill.includes(raw) + : Number.isNaN(raw) || + fill.includes(raw) || + raw < validMin || + raw > validMax ? NaN : raw * decode.scale + decode.offset; index += lonStride; diff --git a/src/lib/zarr/dataset.ts b/src/lib/zarr/dataset.ts index 9b95e5e..ef8bb11 100644 --- a/src/lib/zarr/dataset.ts +++ b/src/lib/zarr/dataset.ts @@ -24,11 +24,16 @@ export type DatasetLayout = { extras: ExtraDim[]; }; -/** CF packing: `value = raw * scale + offset`, with `fill` read as missing. */ +/** + * CF packing: `value = raw * scale + offset`, with `fill` and anything outside + * `validMin`..`validMax` read as missing. + */ export type DatasetDecode = { scale: number; offset: number; fill: number[]; + validMin?: number; + validMax?: number; }; export type VariableInfo = { @@ -180,21 +185,62 @@ async function readCoordinate( const group = parentPath(variablePath); const candidates = [group ? `${group}/${name}` : name, name]; for (const path of [...new Set(candidates)]) { + let array: OpenedArray; try { - const array = await openArray(ds, path); - if (array.shape.length !== 1) continue; - const result = await zarr.get(array); + array = await openArray(ds, path); + } catch { + continue; + } + if (array.shape.length !== 1) continue; + try { + const result = await zarr.get(array, null, { + createQueue: () => limitedQueue(COORDINATE_FETCHES), + }); return { values: toNumbers(result.data as ArrayLike), attrs: array.attrs as Record, }; - } catch { - // Not there; try the next place. + } catch (cause) { + throw new DatasetError( + `The "${name}" coordinate could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, + ); } } return null; } +/** + * Parallel chunk reads for one coordinate. Some stores split a time axis into + * a thousand tiny chunks, and starting them all at once runs the browser out + * of connections. + */ +const COORDINATE_FETCHES = 8; + +function limitedQueue(limit: number) { + const results: Promise[] = []; + const waiting: (() => void)[] = []; + let running = 0; + const next = () => { + running--; + waiting.shift()?.(); + }; + return { + add(fn: () => Promise) { + results.push( + new Promise((resolve, reject) => { + const start = () => { + running++; + fn().then(resolve, reject).finally(next); + }; + if (running < limit) start(); + else waiting.push(start); + }), + ); + }, + onIdle: () => Promise.all(results), + }; +} + type Axis1D = { start: number; step: number; values: Float64Array | null }; /** Start and step of a 1-D axis, keeping the values when it is not regular. */ @@ -225,10 +271,18 @@ function decodeFor(array: OpenedArray): DatasetDecode { if (Array.isArray(missing)) missing.forEach(add); else add(missing); + const range = Array.isArray(attrs.valid_range) ? attrs.valid_range : null; + const validMin = + numberAttr(attrs, "valid_min") ?? (typeof range?.[0] === "number" ? range[0] : null); + const validMax = + numberAttr(attrs, "valid_max") ?? (typeof range?.[1] === "number" ? range[1] : null); + return { scale: numberAttr(attrs, "scale_factor") ?? 1, offset: numberAttr(attrs, "add_offset") ?? 0, fill: [...fill], + ...(validMin !== null && { validMin }), + ...(validMax !== null && { validMax }), }; } From bf54cfc43ae4260c9548e36e0d420c7eb8382051 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:27:43 +0200 Subject: [PATCH 05/32] Print an empty colour bar half as zero One-sided data keeps a tiny placeholder extent on its empty half so the scale never divides by zero. Legends printed it as -1e-10; they now read 0. --- src/components/plots/FingerprintPlot.tsx | 5 +++-- src/lib/export/fingerprintImage.ts | 5 +++-- src/lib/export/fingerprintSquareLogo.ts | 5 +++-- src/lib/export/pdf.ts | 5 +++-- src/lib/map/fingerprintScale.ts | 21 ++++++++++++++++----- 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index 049fb85..55eda4d 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -6,6 +6,7 @@ import { COLORMAPS, type ColormapId, fingerprintColorScale, + labelExtent, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue, @@ -484,7 +485,7 @@ export function FingerprintPlot({ />
- {formatSeriesValue(extents.pivot - extents.negMax)} + {formatSeriesValue(extents.pivot - labelExtent(extents.negMax))} {legend.zeroPct > 12 && legend.zeroPct < 88 ? ( ) : null} - {formatSeriesValue(extents.pivot + extents.posMax)} + {formatSeriesValue(extents.pivot + labelExtent(extents.posMax))}
diff --git a/src/lib/export/fingerprintImage.ts b/src/lib/export/fingerprintImage.ts index b79bf28..9076054 100644 --- a/src/lib/export/fingerprintImage.ts +++ b/src/lib/export/fingerprintImage.ts @@ -2,6 +2,7 @@ import { type ColormapId, fingerprintRampSamples, zeroFrac, + labelExtent, } from "@/lib/map/fingerprintScale"; import { chartTickColor, formatSeriesValue } from "@/lib/plots/chartTheme"; import type { CapturedImage } from "./capture"; @@ -52,8 +53,8 @@ function drawLegend( // 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(centre - negMax); - const max = `${formatSeriesValue(centre + posMax)}${units ? ` ${units}` : ""}`; + const min = formatSeriesValue(centre - labelExtent(negMax)); + const max = `${formatSeriesValue(centre + labelExtent(posMax))}${units ? ` ${units}` : ""}`; const minW = ctx.measureText(min).width; const maxW = ctx.measureText(max).width; const middle = top + BAR_H / 2; diff --git a/src/lib/export/fingerprintSquareLogo.ts b/src/lib/export/fingerprintSquareLogo.ts index 454c5dd..d0c5465 100644 --- a/src/lib/export/fingerprintSquareLogo.ts +++ b/src/lib/export/fingerprintSquareLogo.ts @@ -6,6 +6,7 @@ import { dayIndexTicks, formatDayTick, fingerprintSlotTicks, + labelExtent, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue } from "@/lib/plots/chartTheme"; import { @@ -404,8 +405,8 @@ export function buildSquareFingerprintCanvas( const legendNumFontSize = standardFontSize; // Both extents are real data values, so the two ends of the bar carry // different magnitudes whenever uptake and release do. - const maxVal = extents.posMax; - const minVal = -extents.negMax; + const maxVal = labelExtent(extents.posMax); + const minVal = -labelExtent(extents.negMax); const maxLabel = extents.pivot === 0 ? `+${formatSeriesValue(maxVal)}` diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 9488af7..97fde7b 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -5,6 +5,7 @@ import { type ColorExtents, defaultColormapId, zeroFrac, + labelExtent, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue } from "@/lib/plots/chartTheme"; import { formatSelectedYearsLabel } from "@/lib/zarr/timeRange"; @@ -247,8 +248,8 @@ function drawLegend( doc.setFont("courier", "normal"); doc.setFontSize(7.5); doc.setTextColor(...INK_SOFT); - doc.text(formatSeriesValue(centre - negMax), MARGIN, y + barH - 0.3); - doc.text(formatSeriesValue(centre + posMax), barX + barW + 3, y + barH - 0.3); + doc.text(formatSeriesValue(centre - labelExtent(negMax)), MARGIN, y + barH - 0.3); + doc.text(formatSeriesValue(centre + labelExtent(posMax)), barX + barW + 3, y + barH - 0.3); return y + barH + 4; } diff --git a/src/lib/map/fingerprintScale.ts b/src/lib/map/fingerprintScale.ts index 369c3d2..f5750b5 100644 --- a/src/lib/map/fingerprintScale.ts +++ b/src/lib/map/fingerprintScale.ts @@ -318,6 +318,17 @@ export type ColorExtents = { * `negMax` and `posMax` are both positive: a half with no data gets a tiny * sentinel (1e-10) to avoid a division-by-zero degenerate state. */ +/** + * An extent for a half of the ramp with no data. It keeps the scale away from + * dividing by zero, but labels must print it as nothing. + */ +export const EMPTY_HALF = 1e-10; + +/** An extent as a label should use it: an empty half reads as zero. */ +export function labelExtent(extent: number): number { + return extent <= EMPTY_HALF ? 0 : extent; +} + /** Share of the data range below which values of the other sign count as noise. */ const ONE_SIDED_NOISE = 0.02; @@ -334,15 +345,15 @@ export function asymmetricExtents(values: ArrayLike): ColorExtents { sum += v; count++; } - if (count === 0) return { negMax: 1e-10, posMax: 1e-10, pivot: 0 }; + if (count === 0) return { negMax: EMPTY_HALF, posMax: EMPTY_HALF, pivot: 0 }; const width = max - min; const farFromZero = min > 0 ? min > width : max < 0 ? -max > width : false; if (farFromZero) { const pivot = sum / count; return { - negMax: Math.max(1e-10, pivot - min), - posMax: Math.max(1e-10, max - pivot), + negMax: Math.max(EMPTY_HALF, pivot - min), + posMax: Math.max(EMPTY_HALF, max - pivot), pivot, }; } @@ -351,8 +362,8 @@ export function asymmetricExtents(values: ArrayLike): ColorExtents { // uses one half only, with the noise clamped to the neutral centre. const noise = ONE_SIDED_NOISE * width; return { - negMax: -min <= noise ? 1e-10 : -min, - posMax: max <= noise ? 1e-10 : max, + negMax: -min <= noise ? EMPTY_HALF : -min, + posMax: max <= noise ? EMPTY_HALF : max, pivot: 0, }; } From 49afe17898ea2a71e87acf51dc8490b1600f3d2c Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:30:48 +0200 Subject: [PATCH 06/32] Show daily and coarser stores as a years by season fingerprint With one value a day or fewer there is no daily cycle, so the fingerprint was a single row of stripes. Such stores now get the usual year by season heatmap: one row per selected year, one column per day, week or month of the year, with the strips showing the mean of each year and the mean of each position in the year across years. The yearly scale and the exported daily-cycle table keep the old layout. --- src/components/plots/FingerprintPlot.tsx | 57 ++++++----- src/lib/export/margins.ts | 2 +- src/lib/plots/fingerprint.test.ts | 25 +++++ src/lib/plots/fingerprint.ts | 117 ++++++++++++++++++++++- 4 files changed, 173 insertions(+), 28 deletions(-) diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index 55eda4d..b7d0432 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -14,7 +14,9 @@ import { } from "@/lib/plots/chartTheme"; import { drawFingerprint, + columnLabel, fingerprintGrid, + rowLabel, type FingerprintLayout, FINGERPRINT_STRIP, FINGERPRINT_STRIP_MAX, @@ -28,10 +30,9 @@ import { import { binCountLabel, type BinUnit, - formatBinLabel, formatBinShortLabel, } from "@/lib/plots/timeBins"; -import { formatSlotTime, shortTimeBasis } from "@/lib/zarr/localTime"; +import { shortTimeBasis } from "@/lib/zarr/localTime"; import { useTheme } from "@/providers/ThemeProvider"; import { anchorBesidePanel, @@ -279,6 +280,11 @@ export function FingerprintPlot({ setHover(target ? { target, left, top, flip, anchor } : null); }; + const rows = grid.hoursPerDay; + const seasonal = grid.seasonal !== undefined; + const suffix = + timeBasisLabel && !seasonal ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; + const stripPointAt = ( kind: "column" | "hour", index: number, @@ -290,21 +296,22 @@ export function FingerprintPlot({ const std = formatSeriesValue(stats.std[index] as number); if (kind === "column") { const bin = grid.bins[index]!; + const across = seasonal + ? binCountLabel(count, "year") + : `${count} ${rows === 1 ? "day" : "step"}${count === 1 ? "" : "s"}`; return { - label: formatBinLabel(bin, scale), - shortLabel: formatBinShortLabel(bin, scale), + label: columnLabel(grid, index), + shortLabel: seasonal + ? columnLabel(grid, index) + : formatBinShortLabel(bin, scale), value: stats.mean[index] as number, - spread: - count > 0 - ? `Std ${std} across ${count} ${hoursPerDay === 1 ? "day" : "step"}${count === 1 ? "" : "s"}` - : undefined, + spread: count > 0 ? `Std ${std} across ${across}` : undefined, }; } - const time = formatSlotTime(index, hoursPerDay); - const suffix = timeBasisLabel ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; + const row = rowLabel(grid, index); return { - label: `${time}${suffix}`, - shortLabel: time, + label: `${row}${suffix}`, + shortLabel: row, value: stats.mean[index] as number, spread: count > 0 ? `Std ${std} across ${binCountLabel(count, scale)}` : undefined, @@ -318,14 +325,15 @@ export function FingerprintPlot({ ): TooltipPoint | undefined => { const bin = grid.bins[col]; if (!bin || hour < 0) return undefined; - const at = col * hoursPerDay + hour; + const at = col * rows + hour; const count = grid.count[at] ?? 0; - const time = formatSlotTime(hour, hoursPerDay); - const suffix = timeBasisLabel ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; + const row = rowLabel(grid, hour); + const column = columnLabel(grid, col); + const columnShort = seasonal ? column : formatBinShortLabel(bin, scale); return { - label: `${formatBinLabel(bin, scale)} · ${time}${suffix}`, + label: seasonal ? `${column} ${row}` : `${column} · ${row}${suffix}`, shortLabel: - short === "time" ? time : `${formatBinShortLabel(bin, scale)} ${time}`, + short === "time" ? row : seasonal ? `${column} ${row}` : `${columnShort} ${row}`, value: grid.mean[at] as number, spread: scale !== "day" && count > 0 @@ -344,7 +352,7 @@ export function FingerprintPlot({ if (target.kind === "hour") return stripPointAt("hour", target.hour - 1); return target.hour > 0 ? pointAt(target.col, target.hour - 1, "time") - : pointAt(target.col - 1, hoursPerDay - 1, "full"); + : pointAt(target.col - 1, rows - 1, "full"); }; // A change against a pin only reads sensibly between like targets. const comparable = (a: Hover, b: Hover) => a.target.kind === b.target.kind; @@ -501,13 +509,12 @@ export function FingerprintPlot({

- {binCountLabel(nCols, scale)} × {hoursPerDay}{" "} - {hoursPerDay === 24 - ? "hours" - : hoursPerDay === 1 - ? "step a day" - : "steps a day"} - {timeBasisLabel ? ` · ${timeBasisLabel}` : ""} + {grid.seasonal + ? `${binCountLabel(grid.seasonal.years.length, "year")} × ${binCountLabel(nCols, scale)} of the year` + : `${binCountLabel(nCols, scale)} × ${rows} ${ + rows === 24 ? "hours" : rows === 1 ? "step a day" : "steps a day" + }`} + {timeBasisLabel && !grid.seasonal ? ` · ${timeBasisLabel}` : ""} {units ? ` · ${units}` : ""} {margins ? " · strips show the mean ±1 std of each row and column" : ""}

diff --git a/src/lib/export/margins.ts b/src/lib/export/margins.ts index c562402..8da2583 100644 --- a/src/lib/export/margins.ts +++ b/src/lib/export/margins.ts @@ -40,7 +40,7 @@ export function buildMarginTables( values: Float32Array, prov: ExportProvenance, ): MarginTables { - const grid = fingerprintGrid(values, prov.hoursPerDay, prov.selectedYears); + const grid = fingerprintGrid(values, prov.hoursPerDay, prov.selectedYears, "day", "hours"); return { dailyCycle: rowsOf(grid.margins.byHour, (hour) => hour), seasonalCourse: rowsOf(grid.margins.byColumn, (col) => diff --git a/src/lib/plots/fingerprint.test.ts b/src/lib/plots/fingerprint.test.ts index b1f4057..21c122b 100644 --- a/src/lib/plots/fingerprint.test.ts +++ b/src/lib/plots/fingerprint.test.ts @@ -54,3 +54,28 @@ describe("fingerprintTargetPoint", () => { expect(fingerprintTargetPoint(layout, { kind: "column", col: 4 })).toBeNull(); }); }); + +describe("fingerprintGrid for one value a day", () => { + // Daily values for 2001 and 2002 on the default axis: year * 100 + month. + const daily = Float32Array.from({ length: 730 }, (_, day) => { + const date = new Date(Date.UTC(2001, 0, 1 + day)); + return (date.getUTCFullYear() - 2001) * 100 + date.getUTCMonth(); + }); + + it("lays years out as rows and months as columns", () => { + const season = fingerprintGrid(daily, 1, [2001, 2002], "month"); + expect(season.seasonal?.years).toEqual([2001, 2002]); + expect(season.hoursPerDay).toBe(2); + expect(season.bins).toHaveLength(12); + const cell = (month: number, row: number) => season.mean[month * 2 + row]; + expect(cell(0, 0)).toBe(0); + expect(cell(0, 1)).toBe(100); + expect(cell(11, 1)).toBe(111); + }); + + it("keeps the day-of-cycle layout when asked to", () => { + const flat = fingerprintGrid(daily, 1, [2001, 2002], "month", "hours"); + expect(flat.seasonal).toBeUndefined(); + expect(flat.bins).toHaveLength(24); + }); +}); diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts index efe29ee..45b5e60 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -15,11 +15,15 @@ import { import { binHourGrid, type BinStats, + emptyStats, + formatBinLabel, monthStartTicks, type BinUnit, type TimeBin, timeBins, + WEEKS_PER_YEAR, } from "@/lib/plots/timeBins"; +import { formatSlotTime } from "@/lib/zarr/localTime"; const AXIS_LEFT_DEFAULT = 34; const AXIS_LEFT_TRANSPOSED = 54; @@ -44,8 +48,21 @@ export type FingerprintExtents = { negMax: number; posMax: number; pivot?: numbe export type FingerprintGrid = BinStats & { unit: BinUnit; bins: TimeBin[]; + /** Rows: slots of the day, or the selected years for a seasonal grid. */ hoursPerDay: number; margins: FingerprintMargins; + /** + * With one value a day or fewer there is no daily cycle to show, so the + * rows are the selected years and each column is one position in the year + * (a day, week or month), the usual year by season heatmap. + */ + seasonal?: { years: number[] }; +}; + +const SEASON_COLUMNS: Record, number> = { + day: 366, + week: WEEKS_PER_YEAR, + month: 12, }; export function fingerprintGrid( @@ -53,6 +70,7 @@ export function fingerprintGrid( hoursPerDay: number, selectedYears: number[] | null, unit: BinUnit = "day", + layout: "auto" | "hours" = "auto", ): FingerprintGrid { const nDays = Math.floor(values.length / hoursPerDay); const { absoluteDays } = getSelectedYearsDayMapping( @@ -61,6 +79,9 @@ export function fingerprintGrid( nDays, ); const bins = timeBins(absoluteDays.slice(0, nDays), unit); + if (hoursPerDay === 1 && unit !== "year" && layout === "auto") { + return seasonGrid(values, bins, unit); + } const stats = binHourGrid(values, hoursPerDay, bins); return { ...stats, @@ -71,6 +92,85 @@ export function fingerprintGrid( }; } +function seasonGrid( + values: Float32Array, + bins: TimeBin[], + unit: Exclude, +): FingerprintGrid { + const means = binHourGrid(values, 1, bins); + const years = [...new Set(bins.map((bin) => bin.year))]; + const nRows = years.length; + const nCols = SEASON_COLUMNS[unit]; + const stats = emptyStats(nCols * nRows); + stats.mean.fill(NaN); + stats.std.fill(NaN); + const columns: TimeBin[] = Array.from({ length: nCols }, (_, index) => ({ + year: years[0]!, + index, + startLocal: -1, + endLocal: -1, + firstDay: NaN, + lastDay: NaN, + })); + bins.forEach((bin, b) => { + const at = bin.index * nRows + years.indexOf(bin.year); + stats.mean[at] = means.mean[b]!; + stats.std[at] = means.std[b]!; + stats.count[at] = means.count[b]!; + const column = columns[bin.index]!; + if (!Number.isFinite(column.firstDay)) { + column.firstDay = bin.firstDay; + column.lastDay = bin.lastDay; + } + }); + return { + ...stats, + unit, + bins: columns, + hoursPerDay: nRows, + margins: fingerprintMargins(stats.mean, nCols, nRows), + seasonal: { years }, + }; +} + +const SHORT_MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +] as const; + +/** A position in the year, without the year: "Jun", "week 23", "13 Jun". */ +function seasonLabel(index: number, unit: BinUnit): string { + if (unit === "month") return SHORT_MONTHS[index] ?? ""; + if (unit === "week") return `week ${index + 1}`; + // Day of year in a leap year, so all 366 positions have a date. + const date = new Date(Date.UTC(2000, 0, 1 + index)); + return `${date.getUTCDate()} ${SHORT_MONTHS[date.getUTCMonth()]}`; +} + +/** The label of one column, for tooltips. */ +export function columnLabel(grid: FingerprintGrid, col: number): string { + const bin = grid.bins[col]; + if (!bin) return ""; + return grid.seasonal ? seasonLabel(col, grid.unit) : formatBinLabel(bin, grid.unit); +} + +/** The label of one row: a time of day, or a year in a seasonal grid. */ +export function rowLabel(grid: FingerprintGrid, row: number): string { + return grid.seasonal + ? String(grid.seasonal.years[row] ?? "") + : formatSlotTime(row, grid.hoursPerDay); +} + +/** Rows worth a label on the row axis. */ +function rowTicks(grid: FingerprintGrid): { slot: number; label: string }[] { + if (!grid.seasonal) return fingerprintSlotTicks(grid.hoursPerDay); + const { years } = grid.seasonal; + const step = Math.max(1, Math.ceil(years.length / 6)); + return years.flatMap((year, slot) => + slot % step === 0 ? [{ slot, label: String(year) }] : [], + ); +} + export type FingerprintLayout = { grid: FingerprintGrid; width: number; @@ -129,6 +229,19 @@ function yearBreaks(bins: readonly TimeBin[]): number[] { /** Tick positions in columns, measured from the plot's leading edge. */ function columnAxisTicks({ grid }: FingerprintLayout) { const { bins } = grid; + if (grid.seasonal) { + // Every other month start, as a column position in this grid's unit. + return [0, 2, 4, 6, 8, 10].map((month) => { + const dayOfYear = (Date.UTC(2000, month, 1) - Date.UTC(2000, 0, 1)) / 86_400_000; + const at = + grid.unit === "month" + ? month + 0.5 + : grid.unit === "week" + ? dayOfYear / 7 + : dayOfYear; + return { at, label: SHORT_MONTHS[month]! }; + }); + } const breaks = yearBreaks(bins); if (breaks.length > 0 || grid.unit === "year") { const starts = [0, ...breaks]; @@ -470,7 +583,7 @@ export function drawFingerprint( top + (fromTop * plotH) / hoursPerDay; ctx.textAlign = "right"; ctx.textBaseline = "middle"; - for (const { slot, label } of fingerprintSlotTicks(hoursPerDay)) { + for (const { slot, label } of rowTicks(grid)) { const fromTop = hoursPerDay - 1 - slot; ctx.fillText( label, @@ -501,7 +614,7 @@ export function drawFingerprint( } ctx.textAlign = "center"; ctx.textBaseline = "top"; - for (const { slot, label } of fingerprintSlotTicks(hoursPerDay)) { + for (const { slot, label } of rowTicks(grid)) { const x = axisLeft + ((slot + 0.5) * plotW) / hoursPerDay; ctx.fillText(label, x, top + plotH + 4); } From bf97b8d84b0af802a844762fc0f1dcddcf8e1d2e Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:33:45 +0200 Subject: [PATCH 07/32] Fit the controls and copy to the store's time step Line resolutions and fingerprint scales finer than the store's own step are no longer offered, the finest option carries the step's name ("6-hourly"), and a monthly store opens on its monthly view. The Local/UTC switch shows only for hourly or finer data: shifting a 6-hourly store by whole slots labelled Tokyo UTC+12. Summary cards and the per-year table count and date months for monthly stores ("Lowest month 2014-01") instead of the days a month fills. Export copy and the CSV header name the real step, the cell caption keeps title-case names and does not repeat the step, and the kept-in-memory box size is capped at the store's chunk size. --- src/components/data/DataView.tsx | 10 +-- src/components/data/SeriesSummary.tsx | 63 ++++++++++++------- src/components/panels/CellExport.tsx | 6 +- src/components/panels/CellView.tsx | 14 +++-- src/components/panels/ScaleField.tsx | 23 +------ src/hooks/useCellSeries.ts | 2 + src/hooks/useSeriesDisplay.ts | 89 +++++++++++++++++++++++++-- src/lib/export/csv.ts | 2 +- src/lib/export/provenance.ts | 3 + src/lib/zarr/localTime.ts | 2 +- 10 files changed, 155 insertions(+), 59 deletions(-) diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 3d11ba3..65a9c75 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -120,10 +120,12 @@ export function DataView({ onSelectYears={series.selectYears} /> - + {display.canUseLocalTime ? ( + + ) : null} diff --git a/src/components/data/SeriesSummary.tsx b/src/components/data/SeriesSummary.tsx index 1fbf6f4..6a20bee 100644 --- a/src/components/data/SeriesSummary.tsx +++ b/src/components/data/SeriesSummary.tsx @@ -18,10 +18,13 @@ type Summary = { days: number; }; +type Period = "day" | "month"; + function summarize( values: Float32Array, selectedYears: number[], hoursPerDay: number, + period: Period, ): Summary | null { const daily = dailyMeanSeries(values, hoursPerDay); if (daily.length === 0) return null; @@ -30,22 +33,32 @@ function summarize( undefined, Math.floor(values.length / hoursPerDay), ); - const dateOf = (day: number) => - formatIsoDate(absoluteDays[day - 1] ?? day - 1); + // A monthly store fills every day of its month with one value, so its + // summary counts and dates months rather than those repeated days. + const labelOf = (day: number) => { + const date = formatIsoDate(absoluteDays[day - 1] ?? day - 1); + return period === "month" ? date.slice(0, 7) : date; + }; + const points: { value: number; label: string }[] = []; + for (const point of daily) { + const label = labelOf(point.day); + if (period === "month" && points.at(-1)?.label === label) continue; + points.push({ value: point.value, label }); + } let sum = 0; - let lowest = daily[0]; - let highest = daily[0]; - for (const point of daily) { + let lowest = points[0]!; + let highest = points[0]!; + for (const point of points) { sum += point.value; if (point.value < lowest.value) lowest = point; if (point.value > highest.value) highest = point; } return { - mean: sum / daily.length, - lowest: { value: lowest.value, date: dateOf(lowest.day) }, - highest: { value: highest.value, date: dateOf(highest.day) }, - days: daily.length, + mean: sum / points.length, + lowest: { value: lowest.value, date: lowest.label }, + highest: { value: highest.value, date: highest.label }, + days: points.length, }; } @@ -54,6 +67,7 @@ function summarizeByYear( values: Float32Array, selectedYears: number[], hoursPerDay: number, + period: Period, ): { year: number; summary: Summary }[] { const { yearIntervals } = getSelectedYearsDayMapping( selectedYears, @@ -65,6 +79,7 @@ function summarizeByYear( values.subarray(startDayLocal * hoursPerDay, (endDayLocal + 1) * hoursPerDay), [year], hoursPerDay, + period, ); return summary ? [{ year, summary }] : []; }); @@ -118,21 +133,25 @@ export function SeriesSummary({ }: SeriesSummaryProps) { const { selectedYears, units, slotsPerDay } = series; const values = display.displayValues; + const period: Period = + series.timeSpan === "month" || series.timeSpan === "year" ? "month" : "day"; + const word = period === "month" ? "month" : "day"; + const Word = period === "month" ? "Month" : "Day"; const summary = useMemo( () => values && !display.isEmptyCell - ? summarize(values, selectedYears, slotsPerDay) + ? summarize(values, selectedYears, slotsPerDay, period) : null, - [values, display.isEmptyCell, selectedYears, slotsPerDay], + [values, display.isEmptyCell, selectedYears, slotsPerDay, period], ); const byYear = useMemo( () => values && !display.isEmptyCell && selectedYears.length > 1 - ? summarizeByYear(values, selectedYears, slotsPerDay) + ? summarizeByYear(values, selectedYears, slotsPerDay, period) : [], - [values, display.isEmptyCell, selectedYears, slotsPerDay], + [values, display.isEmptyCell, selectedYears, slotsPerDay, period], ); if (!summary) return null; @@ -140,7 +159,7 @@ export function SeriesSummary({ return (

- Daily means over the selected years + {period === "month" ? "Monthly" : "Daily"} means over the selected years

{byYear.length > 0 ? ( - + ) : null}
); @@ -185,10 +204,12 @@ function YearTable({ rows, total, units, + word, }: { rows: { year: number; summary: Summary }[]; total: Summary; units?: string | null; + word: string; }) { const row = (label: string, summary: Summary, isTotal = false) => ( - Lowest day + Lowest {word} - Highest day + Highest {word} - Days with data + {word === "month" ? "Months" : "Days"} with data diff --git a/src/components/panels/CellExport.tsx b/src/components/panels/CellExport.tsx index 17175b4..d08301e 100644 --- a/src/components/panels/CellExport.tsx +++ b/src/components/panels/CellExport.tsx @@ -43,7 +43,7 @@ const FORMATS: { value: ExportFormat; title: string; description: string }[] = [ { value: "csv", title: "Series data (.csv)", - description: "The hourly values as stored", + description: "The {step} values as stored", }, { value: "badge", @@ -92,7 +92,9 @@ export function CellExport({ series, display }: CellExportProps) { {option.title} - {option.description} + + {option.description.replace("{step}", series.stepLabel)} + {describeRecord(series)} The dashed box marks the{" "} - {patchWindow}×{patchWindow} cells around it kept in memory. + {Math.min(patchWindow, series.gridSpec.nativeChunks.lat)}× + {Math.min(patchWindow, series.gridSpec.nativeChunks.lon)} cells + around it kept in memory. - {desktop ? null : ( + {desktop || !display.canUseLocalTime ? null : ( )} @@ -114,11 +116,13 @@ export function describeRecord(series: CellSeries): string { const name = dataset ? (dataset.longName ?? dataset.variable) : "net ecosystem exchange"; const step = dataset?.time.stepLabel ?? "hourly"; const resolution = formatResolution(series.gridSpec.spatialResolutionDeg); - // Lowercase "Net ecosystem exchange", but leave "NEE" and "PRMSL" alone. - const lowered = /^[A-Z][a-z]/.test(name) + // Lowercase a sentence-case name ("Net ecosystem exchange"), but leave + // acronyms ("NEE") and title case ("Gross Primary Productivity") alone. + const lowered = /^[A-Z][a-z]*(?:[\s-][a-z0-9][^\s]*)*$/.test(name) ? `${name.charAt(0).toLowerCase()}${name.slice(1)}` : name; - const phrase = `${step} ${lowered}`; + const repeatsStep = lowered.toLowerCase().includes(step.toLowerCase()); + const phrase = repeatsStep ? lowered : `${step} ${lowered}`; return `${phrase.charAt(0).toUpperCase()}${phrase.slice(1)} for this ${resolution} cell.`; } diff --git a/src/components/panels/ScaleField.tsx b/src/components/panels/ScaleField.tsx index 3796ddc..6d992a0 100644 --- a/src/components/panels/ScaleField.tsx +++ b/src/components/panels/ScaleField.tsx @@ -6,13 +6,6 @@ import type { SeriesDisplay } from "@/hooks/useSeriesDisplay"; import { FieldLegend, FieldSet } from "@/components/ui/field"; import { SegmentedTabs } from "@/components/ui/segmented-tabs"; -const LINE_RESOLUTIONS = [ - { value: "hour", label: "Hourly" }, - { value: "day", label: "Daily" }, - { value: "week", label: "Weekly" }, - { value: "month", label: "Monthly" }, -] as const; - export function ScaleField({ display }: { display: SeriesDisplay }) { const disabled = display.isEmptyCell; @@ -23,7 +16,7 @@ export function ScaleField({ display }: { display: SeriesDisplay }) { value={display.lineResolution} onChange={display.setLineResolution} - options={LINE_RESOLUTIONS} + options={display.lineResolutions} disabled={disabled} aria-label="Line resolution" /> @@ -31,25 +24,13 @@ export function ScaleField({ display }: { display: SeriesDisplay }) { ); } - const fingerprintScales = [ - { value: "day", label: "Daily" }, - { value: "week", label: "Weekly" }, - { value: "month", label: "Monthly" }, - { - value: "year", - label: "Yearly", - disabled: !display.canShowYearly, - title: display.canShowYearly ? undefined : "Pick two or more years", - }, - ] as const; - return (
Fingerprint scale value={display.fingerprintScale} onChange={display.setFingerprintScale} - options={fingerprintScales} + options={display.fingerprintScales} disabled={disabled} aria-label="Fingerprint scale" /> diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index 69683ea..527323c 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -314,6 +314,8 @@ export function useCellSeries() { extraIndices, setExtraIndex, slotsPerDay: dataset?.time.slotsPerDay ?? 24, + timeSpan: dataset?.time.span ?? "slot", + stepLabel: dataset?.time.stepLabel ?? "hourly", stepCount, gridSpec, selection, diff --git a/src/hooks/useSeriesDisplay.ts b/src/hooks/useSeriesDisplay.ts index 1fea2b0..10f293d 100644 --- a/src/hooks/useSeriesDisplay.ts +++ b/src/hooks/useSeriesDisplay.ts @@ -19,9 +19,52 @@ import { useTheme } from "@/providers/ThemeProvider"; import type { BinUnit } from "@/lib/plots/timeBins"; import type { LineResolution } from "@/lib/plots/lineSeries"; import type { CellSeries } from "@/hooks/useCellSeries"; +import type { TimeAxis } from "@/lib/zarr/timeAxis"; + +type TimeSpan = TimeAxis["span"]; export type PlotView = "line" | "fingerprint"; +const RANK: Record = { + hour: 0, + day: 1, + week: 2, + month: 3, + year: 4, +}; + +const LINE_RESOLUTIONS: { value: LineResolution; label: string }[] = [ + { value: "hour", label: "Hourly" }, + { value: "day", label: "Daily" }, + { value: "week", label: "Weekly" }, + { value: "month", label: "Monthly" }, +]; + +const FINGERPRINT_SCALES: { + value: BinUnit; + label: string; + disabled?: boolean; + title?: string; +}[] = [ + { value: "day", label: "Daily" }, + { value: "week", label: "Weekly" }, + { value: "month", label: "Monthly" }, + { value: "year", label: "Yearly" }, +]; + +function finestResolution( + slotsPerDay: number, + span: TimeSpan, +): LineResolution { + if (span === "month" || span === "year") return "month"; + if (span === "days") return "day"; + return slotsPerDay > 1 ? "hour" : "day"; +} + +function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + export function useSeriesDisplay( { selection, @@ -29,20 +72,55 @@ export function useSeriesDisplay( values, loading, slotsPerDay, + timeSpan, + stepLabel, }: Pick< CellSeries, - "selection" | "selectedYears" | "values" | "loading" | "slotsPerDay" + | "selection" + | "selectedYears" + | "values" + | "loading" + | "slotsPerDay" + | "timeSpan" + | "stepLabel" >, - timeBasis: TimeBasis, + requestedTimeBasis: TimeBasis, ) { const { isLight } = useTheme(); const [plotView, setPlotView] = useState("line"); const [transposed, setTransposed] = useState(false); - const [lineResolution, setLineResolution] = useState("day"); + const [lineChoice, setLineResolution] = useState("day"); const [scaleChoice, setFingerprintScale] = useState("day"); const canShowYearly = selectedYears.length > 1; + + // Nothing finer than the store's own step is offered, and the finest + // option carries the step's name ("6-hourly", "8-daily"). + const finest = finestResolution(slotsPerDay, timeSpan); + const stepName = capitalize(stepLabel); + const lineResolutions = LINE_RESOLUTIONS.filter( + (option) => RANK[option.value] >= RANK[finest], + ).map((option) => + option.value === finest ? { ...option, label: stepName } : option, + ); + const lineResolution = + RANK[lineChoice] >= RANK[finest] ? lineChoice : finest; + const finestScale: BinUnit = finest === "hour" ? "day" : finest; + const fingerprintScales = FINGERPRINT_SCALES.filter( + (option) => RANK[option.value] >= RANK[finestScale], + ).map((option) => + option.value === "year" && !canShowYearly + ? { ...option, disabled: true, title: "Pick two or more years" } + : option, + ); + const allowedScale = + RANK[scaleChoice] >= RANK[finestScale] ? scaleChoice : finestScale; const fingerprintScale = - scaleChoice === "year" && !canShowYearly ? "day" : scaleChoice; + allowedScale === "year" && !canShowYearly ? finestScale : allowedScale; + + // Shifting to local time moves whole slots, so it is only honest when a + // slot is an hour or less; a 6-hourly store would label Tokyo UTC+12. + const canUseLocalTime = slotsPerDay >= 24; + const timeBasis: TimeBasis = canUseLocalTime ? requestedTimeBasis : "utc"; const [palette, setPaletteState] = useState( () => loadPalette() ?? DEFAULT_PALETTE, ); @@ -86,9 +164,12 @@ export function useSeriesDisplay( setTransposed, lineResolution, setLineResolution, + lineResolutions, fingerprintScale, setFingerprintScale, + fingerprintScales, canShowYearly, + canUseLocalTime, timeBasis, utcOffsetHours, timeBasisLabel, diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts index 6987346..5c60aca 100644 --- a/src/lib/export/csv.ts +++ b/src/lib/export/csv.ts @@ -16,7 +16,7 @@ function buildHeader(rowCount: number, prov: ExportProvenance): string[] { "EarthPrints export", `generated: ${prov.generatedAt.toISOString()}`, `dataset: ${prov.dataset}`, - `grid: ${prov.resolutionDeg} deg, hourly`, + `grid: ${prov.resolutionDeg} deg, ${prov.stepLabel}`, `source: ${prov.sourceUrl}`, `variable: ${prov.variable}`, `units: ${prov.units ?? "unspecified"}`, diff --git a/src/lib/export/provenance.ts b/src/lib/export/provenance.ts index 97b5a5b..c5b190e 100644 --- a/src/lib/export/provenance.ts +++ b/src/lib/export/provenance.ts @@ -20,6 +20,8 @@ export type ExportProvenance = { variable: string; units: string | null; resolutionDeg: number; + /** The store's time step: "hourly", "6-hourly", "monthly". */ + stepLabel: string; click: GeoPoint; cell: GridCell; historyYears: number; @@ -98,6 +100,7 @@ export function buildProvenance({ variable, units, resolutionDeg: activeSource().resolutionDeg, + stepLabel: activeSource().stepLabel, click: selection.click, cell: selection.grid, historyYears: resolvedHistoryYears, diff --git a/src/lib/zarr/localTime.ts b/src/lib/zarr/localTime.ts index 6cd6a48..20cbe8d 100644 --- a/src/lib/zarr/localTime.ts +++ b/src/lib/zarr/localTime.ts @@ -70,7 +70,7 @@ export function formatUtcOffset(offsetHours: number): string { /** Caption and tooltip wording for the basis in force. */ export function formatTimeBasis(basis: TimeBasis, offsetHours: number): string { if (basis === "utc") return "UTC"; - return `local solar time (${formatUtcOffset(offsetHours)})`; + return `local solar time (${offsetHours === 0 ? "UTC±0" : formatUtcOffset(offsetHours)})`; } /** The time basis as a short suffix for an hour readout: "local" or "UTC". */ From ef1476d370b133fa923e418d4b735b5759ec9ccc Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:35:55 +0200 Subject: [PATCH 08/32] Recover cleanly from stores that fail to open A store that never answered locked the Data panel until reload. Opening now gives up after 30 seconds, and Open and the examples stay usable while a store is loading, so another one can replace it. The address field follows the store last asked for, so a failed or slow ?store= link shows its URL instead of an empty field. A mistyped variable keeps the variable field instead of hiding it. Errors are styled as errors, say which store is still showing, and read plainly for refused access (401, 403, 409), answers that are not Zarr metadata, and grids that are not regular latitude and longitude. A click outside a regional store no longer snaps to its edge cell: the title keeps the clicked point and no cell is drawn. The outermost rows of a global grid now reach the poles, so polar clicks on a Gaussian grid load. --- src/components/map/ReliefMap.tsx | 6 ++- src/components/panels/DatasetPanel.tsx | 40 ++++++++++++------- src/hooks/useCellSeries.ts | 54 +++++++++++++++++++------- src/lib/map/geogrid.test.ts | 11 ++++++ src/lib/map/geogrid.ts | 11 ++++-- src/lib/zarr/dataset.ts | 11 +++++- src/types/map.ts | 5 +++ 7 files changed, 105 insertions(+), 33 deletions(-) diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 64905a0..0c71915 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -149,7 +149,9 @@ export function ReliefMap() { (lon: number, lat: number, options?: { fly?: boolean }) => { const selection = series.select(lon, lat); if (!showsData) setOpenPanel("chart"); - if (options?.fly ?? settings.autoZoom) flyToCell(selection.grid); + if (!selection.outside && (options?.fly ?? settings.autoZoom)) { + flyToCell(selection.grid); + } }, [flyToCell, series, settings.autoZoom, showsData], ); @@ -314,7 +316,7 @@ export function ReliefMap() { attributionControl={false} style={{ width: "100%", height: "100%" }} > - {selection && mapSize.width > 0 && mapSize.height > 0 ? ( + {selection && !selection.outside && mapSize.width > 0 && mapSize.height > 0 ? ( { event.preventDefault(); if (!url.trim()) return; - openSource({ url, variable: needsVariable ? variable : null }); + openSource({ url: url.trim(), variable: needsVariable ? variable : null }); }; return ( @@ -93,7 +103,7 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { autoComplete="off" inputMode="url" /> - ) : null} @@ -290,6 +291,7 @@ function formatDays(days: number): string { } function formatBytes(bytes: number): string { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`; if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; return `${bytes} B`; diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index 20659f6..b3ddb7f 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -87,7 +87,7 @@ export function useCellSeries() { const [error, setError] = useState(null); const [values, setValues] = useState(null); /** Days a partial load could cover, offered when the full one is refused. */ - const [partialOffer, setPartialOffer] = useState(null); + const [partialOffer, setPartialOffer] = useState<{ days: number; bytes: number } | null>(null); /** Days a partial load did cover, from the start of the selection. */ const [coveredDays, setCoveredDays] = useState(null); const [units, setUnits] = useState(null); @@ -141,7 +141,7 @@ export function useCellSeries() { if (requestId !== requestIdRef.current) return; if (cause instanceof Error && cause.name === "AbortError") return; if (cause instanceof SeriesBudgetError && cause.partialDays > 0) { - setPartialOffer(cause.partialDays); + setPartialOffer({ days: cause.partialDays, bytes: cause.partialBytes }); } setError( cause instanceof Error diff --git a/src/lib/zarr/ZarrChunkReader.ts b/src/lib/zarr/ZarrChunkReader.ts index a4d668a..ce762c6 100644 --- a/src/lib/zarr/ZarrChunkReader.ts +++ b/src/lib/zarr/ZarrChunkReader.ts @@ -94,6 +94,13 @@ export type ZarrChunkReaderOptions = { */ export const MAX_CHUNKS_PER_SERIES = 400; +/** + * Most data, decoded, one series may download. Counting requests alone let a + * store with whole-globe chunks offer tens of gigabytes within 400 downloads; + * all 21 FLUXCOM-X years stay well under this. + */ +export const MAX_SERIES_BYTES = 2 * 1024 ** 3; + /** Largest stored chunk, decoded, that one download may unpack. */ export const MAX_CHUNK_BYTES = 1.5 * 1024 ** 3; @@ -116,6 +123,8 @@ export class SeriesBudgetError extends DatasetError { constructor( message: string, readonly partialDays: number, + /** Upper bound, decoded, of what loading those days downloads. */ + readonly partialBytes: number, ) { super(message); this.name = "SeriesBudgetError"; @@ -126,9 +135,16 @@ export type SeriesOptions = { /** Over budget, load what the budget covers from the start instead of failing. */ partial?: boolean; /** Downloads left for this load, shared across the blocks of a year selection. */ - budget?: { remaining: number }; + budget?: SeriesBudget; }; +/** Downloads and decoded bytes left for one load. */ +type SeriesBudget = { remaining: number; bytes: number }; + +function freshBudget(): SeriesBudget { + return { remaining: MAX_CHUNKS_PER_SERIES, bytes: MAX_SERIES_BYTES }; +} + export type SeriesResult = { values: Float32Array; variable: string; @@ -641,15 +657,20 @@ export class ZarrChunkReader { ); this.checkChunkSize(array); - const budget = options.budget ?? { remaining: MAX_CHUNKS_PER_SERIES }; + const budget = options.budget ?? freshBudget(); + const chunkBytes = this.rawChunkBytes(array); + const allowance = Math.min( + budget.remaining, + Math.floor(budget.bytes / chunkBytes), + ); let coveredDays: number | undefined; - if (missing.length > budget.remaining) { + if (missing.length > allowance) { // The chunks the budget reaches, in time order, cached ones for free. const kept: number[] = []; let paid = 0; for (const index of context.timeChunkIndices) { const free = this.cache.has(keyFor(index)); - if (!free && paid === budget.remaining) break; + if (!free && paid === allowance) break; if (!free) paid += 1; kept.push(index); } @@ -664,13 +685,18 @@ export class ZarrChunkReader { dayRange[0], ); if (!options.partial) { - throw new SeriesBudgetError(budgetMessage(missing.length, dayRange), coveredDays); + throw new SeriesBudgetError( + budgetMessage(missing.length, missing.length * chunkBytes), + coveredDays, + paid * chunkBytes, + ); } context.timeChunkIndices = kept; missing = kept.filter((index) => !this.cache.has(keyFor(index))); sampleRange = [sampleRange[0], Math.max(sampleRange[0], stopSample)]; } budget.remaining -= missing.length; + budget.bytes -= missing.length * chunkBytes; if (onProgress && missing.length === 0) onProgress(1, 1); // Large chunks go one at a time, so only one decoded chunk is resident: @@ -773,7 +799,7 @@ export class ZarrChunkReader { } const shared: SeriesOptions = { ...options, - budget: options.budget ?? { remaining: MAX_CHUNKS_PER_SERIES }, + budget: options.budget ?? freshBudget(), }; const blocks = yearsToContiguousBlocks(years); if (blocks.length === 1) { @@ -884,10 +910,13 @@ export class ZarrChunkReader { } } -function budgetMessage(chunkCount: number, dayRange: AxisSlice): string { - const days = dayRange[1] - dayRange[0]; - const perYear = Math.round((chunkCount / Math.max(1, days)) * 365); - return `This store is chunked by map, not by time, so one point's series needs ${chunkCount.toLocaleString("en-US")} downloads for the selected years (about ${perYear.toLocaleString("en-US")} a year). EarthPrints makes at most ${MAX_CHUNKS_PER_SERIES} per load.`; +function budgetMessage(chunkCount: number, bytes: number): string { + return `This store keeps few time steps in each chunk, so one point's series for the selected years needs ${chunkCount.toLocaleString("en-US")} downloads, up to ${formatBytes(bytes)} of data. EarthPrints loads at most ${MAX_CHUNKS_PER_SERIES} downloads or ${formatBytes(MAX_SERIES_BYTES)} at a time.`; +} + +function formatBytes(bytes: number): string { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`; + return `${Math.max(1, Math.round(bytes / 1024 ** 2)).toLocaleString("en-US")} MB`; } const ALL_FLUXCOM_YEARS = Array.from({ length: 2021 - 2001 + 1 }, (_, i) => 2001 + i); From daf69692863fa4e6781fba6f4a6128c29a61d147 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:39:40 +0200 Subject: [PATCH 11/32] Keep the data view clear of the corner map The corner map floated over the plot's right edge and the fourth summary card as the data view scrolled. Large screens now reserve its width as a right gutter whenever the page margin is narrower than the map. Also count FLUXCOM-X steps per hour again: its hour of day is its own axis, so each time sample holds 24 values. --- src/components/data/DataView.tsx | 2 +- src/hooks/useCellSeries.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 65a9c75..5618077 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -95,7 +95,7 @@ export function DataView({ aria-hidden="true" className="pointer-events-none fixed inset-x-0 top-0 z-10 h-25 bg-linear-to-b from-editor-bg-depth from-55% to-transparent opacity-0 max-md:hidden" /> -
+
{selection ? (
diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index b3ddb7f..0973452 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -322,7 +322,9 @@ export function useCellSeries() { // says nothing about how many values the store holds for these years. const stepCount = useMemo(() => { if (!dataset) return null; - return selectedYears.reduce( + // A store with its own hour axis holds a day's slots in each time sample. + const perSample = dataset.time.hourAxis ? dataset.time.slotsPerDay : 1; + return perSample * selectedYears.reduce( (sum, year) => sum + samplesInDays( From f94af322a83a1995b1cf8cd1f42cc69c6102db56 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:40:25 +0200 Subject: [PATCH 12/32] Thin crowded axis labels and keep unlisted variables editable Fingerprint labels ran into each other in the compact panel ("Jan '2Mar '21") and on long records; a label that would touch the previous one is now skipped. Records spanning many years label only round years (every 2, 5, 10, 25, ... years) on both plots instead of irregular ones. A store that cannot list its arrays keeps its variable as a text field after opening, so it can be switched or corrected. Stores are titled from a name or dataset_id attribute when they carry no title. --- src/components/panels/DatasetPanel.tsx | 9 +++++++-- src/lib/plots/fingerprint.ts | 28 +++++++++++++++++--------- src/lib/plots/lineSeries.ts | 12 +++++++---- src/lib/plots/timeBins.ts | 11 ++++++++++ src/lib/zarr/dataset.ts | 5 +++-- 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/components/panels/DatasetPanel.tsx b/src/components/panels/DatasetPanel.tsx index c4fb38b..4d2ae83 100644 --- a/src/components/panels/DatasetPanel.tsx +++ b/src/components/panels/DatasetPanel.tsx @@ -63,7 +63,9 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { // still opening shows its address rather than an empty field. const shownSource = requestedSource ?? dataset; const [url, setUrl] = useState(shownSource?.url ?? ""); - const [variable, setVariable] = useState(requestedSource?.variable ?? ""); + const [variable, setVariable] = useState( + requestedSource?.variable ?? dataset?.variable ?? "", + ); const [prevSource, setPrevSource] = useState(shownSource); if (shownSource !== prevSource) { setPrevSource(shownSource); @@ -75,7 +77,10 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { ); if (promptsForVariable && !askedForVariable) setAskedForVariable(true); if (dataset && !datasetError && askedForVariable) setAskedForVariable(false); - const needsVariable = promptsForVariable || askedForVariable; + // A store that cannot list its arrays has no picker, so its variable stays + // a text field to switch or correct. + const unlisted = Boolean(dataset && dataset.variables.length === 0); + const needsVariable = promptsForVariable || askedForVariable || unlisted; const submit = (event: React.FormEvent) => { event.preventDefault(); diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts index 45b5e60..554c882 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -18,6 +18,7 @@ import { emptyStats, formatBinLabel, monthStartTicks, + roundYearStep, type BinUnit, type TimeBin, timeBins, @@ -245,10 +246,12 @@ function columnAxisTicks({ grid }: FingerprintLayout) { const breaks = yearBreaks(bins); if (breaks.length > 0 || grid.unit === "year") { const starts = [0, ...breaks]; - return starts.map((start, i) => ({ - at: (start + (starts[i + 1] ?? bins.length)) / 2, - label: `${bins[start]!.year}`, - })); + const step = roundYearStep(starts.length); + return starts.flatMap((start, i) => { + const year = bins[start]!.year; + if (step > 1 && year % step !== 0) return []; + return [{ at: (start + (starts[i + 1] ?? bins.length)) / 2, label: `${year}` }]; + }); } const days = bins.flatMap((bin) => Array.from( @@ -593,24 +596,29 @@ export function drawFingerprint( } ctx.textAlign = "center"; ctx.textBaseline = "top"; + // A narrow panel or a long record has more ticks than room; skip any + // label that would run into the one before it. + let lastRight = -Infinity; for (const { at, label } of ticks) { const x = clampLabelX( axisLeft + colFrac(at) * plotW, plotW, axisLeft, ); + const half = ctx.measureText(label).width / 2; + if (x - half < lastRight + 6) continue; ctx.fillText(label, x, top + plotH + 4); + lastRight = x + half; } } else { ctx.textAlign = "right"; ctx.textBaseline = "middle"; + let lastBottom = -Infinity; for (const { at, label } of ticks) { - const y = top + colFrac(at) * plotH; - ctx.fillText( - label, - axisLeft - 6, - Math.max(top + 6, Math.min(top + plotH - 6, y)), - ); + const y = Math.max(top + 6, Math.min(top + plotH - 6, top + colFrac(at) * plotH)); + if (y - 6 < lastBottom + 2) continue; + ctx.fillText(label, axisLeft - 6, y); + lastBottom = y + 6; } ctx.textAlign = "center"; ctx.textBaseline = "top"; diff --git a/src/lib/plots/lineSeries.ts b/src/lib/plots/lineSeries.ts index 20c5c2c..4262509 100644 --- a/src/lib/plots/lineSeries.ts +++ b/src/lib/plots/lineSeries.ts @@ -7,6 +7,7 @@ import { formatBinLabel, formatBinShortLabel, monthStartTicks, + roundYearStep, timeBins, } from "@/lib/plots/timeBins"; import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; @@ -81,12 +82,15 @@ export function lineSeries( ).absoluteDays.slice(0, nDays); const years = timeBins(absoluteDays, "year"); const yearBreaks = years.slice(1).map((year) => year.startLocal); + const yearStep = roundYearStep(years.length); const ticks: LineTick[] = years.length > 1 - ? years.map((year) => ({ - x: (year.startLocal + year.endLocal + 1) / 2, - label: String(year.year), - })) + ? years + .filter((year) => yearStep === 1 || year.year % yearStep === 0) + .map((year) => ({ + x: (year.startLocal + year.endLocal + 1) / 2, + label: String(year.year), + })) : monthStartTicks(absoluteDays).map((day) => ({ x: day, label: formatDayTick(absoluteDays[day] ?? 0), diff --git a/src/lib/plots/timeBins.ts b/src/lib/plots/timeBins.ts index c0c4d15..6697029 100644 --- a/src/lib/plots/timeBins.ts +++ b/src/lib/plots/timeBins.ts @@ -214,3 +214,14 @@ export function binCountLabel(count: number, unit: BinUnit): string { if (unit === "week") return plural(count, "week"); return plural(count, "day"); } + +/** + * Years worth a tick on a long axis: every year for a few, otherwise only + * round ones (every 2, 5, 10, 25, 50, ... years) so labels stay evenly spaced. + */ +export function roundYearStep(yearCount: number, maxTicks = 8): number { + for (const step of [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000]) { + if (yearCount / step <= maxTicks) return step; + } + return Math.ceil(yearCount / maxTicks); +} diff --git a/src/lib/zarr/dataset.ts b/src/lib/zarr/dataset.ts index 251ace0..f0ea437 100644 --- a/src/lib/zarr/dataset.ts +++ b/src/lib/zarr/dataset.ts @@ -538,11 +538,12 @@ export async function openDataset( } /** - * The store's own title, else the CMIP facets ("CESM2 · piControl · + * The store's own title or name, else the CMIP facets ("CESM2 · piControl · * r1i1p1f1"), else the last part of the address that is not a version tag. */ function storeTitle(attrs: Record, url: string): string { - const title = stringAttr(attrs, "title"); + const title = + stringAttr(attrs, "title") ?? stringAttr(attrs, "name") ?? stringAttr(attrs, "dataset_id"); if (title) return title; const facets = ["source_id", "experiment_id", "variant_label"] .map((key) => stringAttr(attrs, key)) From a4ce54c646485f13af4f19deca3cfbd8b4c0b692 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:41:37 +0200 Subject: [PATCH 13/32] Show changes in the variable's units and fix sub-daily export tables Tooltips compared two points as a percentage, which means nothing for kelvin, degrees Celsius or a signed flux and blows up near zero. They now give the difference in the variable's own units. A daily point with a single value no longer reports "Std 0 across 1 step". The exported mean daily cycle keyed its rows by slot index, so a 3-hourly peak at 09:00 read as hour 3. Rows are now keyed by the hour each slot starts, and stores with one value a day leave the table out. --- src/components/plots/MapSideTooltip.tsx | 13 +++++++++---- src/hooks/useCellExport.ts | 26 ++++++++++++++----------- src/lib/export/margins.test.ts | 26 +++++++++++++++++++++++++ src/lib/export/margins.ts | 8 ++++++-- src/lib/export/xlsx.ts | 14 ++++++++----- src/lib/plots/lineSeries.ts | 2 +- 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/components/plots/MapSideTooltip.tsx b/src/components/plots/MapSideTooltip.tsx index 2c217fb..aaeaeb8 100644 --- a/src/components/plots/MapSideTooltip.tsx +++ b/src/components/plots/MapSideTooltip.tsx @@ -84,11 +84,16 @@ export type TooltipPoint = { spread?: string; }; +/** + * The difference in the variable's own units. A percentage means nothing for + * kelvin, degrees Celsius or a signed flux, and blows up near zero. + */ function formatChange(point: TooltipPoint, base?: TooltipPoint) { - if (!base || base.value === 0 || !Number.isFinite(base.value)) return null; - if (!Number.isFinite(point.value)) return null; - const change = ((point.value - base.value) / Math.abs(base.value)) * 100; - return `${change >= 0 ? "+" : "−"}${Math.abs(change).toFixed(1)}% vs ${base.shortLabel}`; + if (!base || !Number.isFinite(base.value) || !Number.isFinite(point.value)) { + return null; + } + const change = point.value - base.value; + return `${change >= 0 ? "+" : "−"}${formatSeriesValue(Math.abs(change))} vs ${base.shortLabel}`; } function PointRows({ diff --git a/src/hooks/useCellExport.ts b/src/hooks/useCellExport.ts index e2a3db4..2dcc29b 100644 --- a/src/hooks/useCellExport.ts +++ b/src/hooks/useCellExport.ts @@ -144,17 +144,21 @@ export function useCellExport({ name: `${base}.csv`, data: new TextEncoder().encode(buildSeriesCsv(rows, prov)), }, - { - name: `${base}_daily_cycle.csv`, - data: new TextEncoder().encode( - buildMarginCsv( - margins.dailyCycle, - columns.dailyCycle, - "mean daily cycle, each hour across all days", - prov, - ), - ), - }, + ...(margins.dailyCycle.length > 0 + ? [ + { + name: `${base}_daily_cycle.csv`, + data: new TextEncoder().encode( + buildMarginCsv( + margins.dailyCycle, + columns.dailyCycle, + "mean daily cycle, each time of day across all days", + prov, + ), + ), + }, + ] + : []), { name: `${base}_seasonal_course.csv`, data: new TextEncoder().encode( diff --git a/src/lib/export/margins.test.ts b/src/lib/export/margins.test.ts index 118a61c..594d727 100644 --- a/src/lib/export/margins.test.ts +++ b/src/lib/export/margins.test.ts @@ -73,3 +73,29 @@ describe("buildMarginTables", () => { expect(sheets.seasonalCourse[1]).toEqual(["2019-01-01", 12, expect.any(Number), 23]); }); }); + +describe("buildMarginTables for other time steps", () => { + const tablesFor = (hoursPerDay: number) => { + const prov = { + ...buildProvenance({ + selection: SELECTION, + historyYears: 1, + selectedYears: [2019], + valueCount: 2 * hoursPerDay, + units: "K", + timeBasis: "utc", + }), + hoursPerDay, + }; + const values = Float32Array.from({ length: 2 * hoursPerDay }, (_, i) => i); + return buildMarginTables(values, prov); + }; + + it("keys a 3-hourly cycle by the hour each slot starts", () => { + expect(tablesFor(8).dailyCycle.map((row) => row.key)).toEqual([0, 3, 6, 9, 12, 15, 18, 21]); + }); + + it("has no daily cycle for one value a day", () => { + expect(tablesFor(1).dailyCycle).toEqual([]); + }); +}); diff --git a/src/lib/export/margins.ts b/src/lib/export/margins.ts index 8da2583..c4621fc 100644 --- a/src/lib/export/margins.ts +++ b/src/lib/export/margins.ts @@ -40,9 +40,13 @@ export function buildMarginTables( values: Float32Array, prov: ExportProvenance, ): MarginTables { - const grid = fingerprintGrid(values, prov.hoursPerDay, prov.selectedYears, "day", "hours"); + const slots = prov.hoursPerDay; + const grid = fingerprintGrid(values, slots, prov.selectedYears, "day", "hours"); return { - dailyCycle: rowsOf(grid.margins.byHour, (hour) => hour), + // Keyed by the hour the slot starts, so a 3-hourly store reads 0, 3, 6. + // A store with one value a day has no daily cycle to tabulate. + dailyCycle: + slots > 1 ? rowsOf(grid.margins.byHour, (slot) => (slot * 24) / slots) : [], seasonalCourse: rowsOf(grid.margins.byColumn, (col) => formatIsoDate(grid.bins[col]!.firstDay), ), diff --git a/src/lib/export/xlsx.ts b/src/lib/export/xlsx.ts index 0526a22..92de97c 100644 --- a/src/lib/export/xlsx.ts +++ b/src/lib/export/xlsx.ts @@ -132,11 +132,15 @@ export async function buildSeriesWorkbook( }, ...(marginSheets ? [ - { - data: marginSheets.dailyCycle, - sheet: XLSX_DAILY_CYCLE_SHEET, - columns: statColumns, - }, + ...(marginSheets.dailyCycle.length > 1 + ? [ + { + data: marginSheets.dailyCycle, + sheet: XLSX_DAILY_CYCLE_SHEET, + columns: statColumns, + }, + ] + : []), { data: marginSheets.seasonalCourse, sheet: XLSX_SEASONAL_COURSE_SHEET, diff --git a/src/lib/plots/lineSeries.ts b/src/lib/plots/lineSeries.ts index 4262509..20d8df9 100644 --- a/src/lib/plots/lineSeries.ts +++ b/src/lib/plots/lineSeries.ts @@ -135,7 +135,7 @@ export function lineSeries( band: [mean - std, mean + std], label: formatBinLabel(bin, resolution), shortLabel: formatBinShortLabel(bin, resolution), - spread: `Std ${formatSeriesValue(std)} across ${across}`, + spread: count > 1 ? `Std ${formatSeriesValue(std)} across ${across}` : undefined, }); }); } From ffc11f51cfb19959feed3eca5875623193c9c1db Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 18:45:17 +0200 Subject: [PATCH 14/32] Frame coarse cells after a pick instead of flying to street zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focus zoom suited 0.05° cells; a 2.8° model cell then filled far more than the screen and no outline was visible. A coarse cell is now framed at the zoom where it spans about 120 px. --- src/components/map/ReliefMap.tsx | 9 +++++++-- src/lib/map/viewState.test.ts | 15 +++++++++++++++ src/lib/map/viewState.ts | 19 +++++++++++++++++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 0c71915..cda2c92 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -133,7 +133,12 @@ export function ReliefMap() { (cell: GridCell) => { const map = mapRef.current; if (!map) return; - const target = viewStateFocusedOnCell(readViewState(map), cell, viewMode); + const target = viewStateFocusedOnCell( + readViewState(map), + cell, + viewMode, + series.gridSpec.spatialResolutionDeg, + ); map.flyTo({ center: [target.longitude, target.latitude], zoom: target.zoom, @@ -142,7 +147,7 @@ export function ReliefMap() { duration: SELECTION_FOCUS_TRANSITION_MS, }); }, - [viewMode], + [viewMode, series.gridSpec.spatialResolutionDeg], ); const pick = useCallback( diff --git a/src/lib/map/viewState.test.ts b/src/lib/map/viewState.test.ts index 9be506c..a0d172d 100644 --- a/src/lib/map/viewState.test.ts +++ b/src/lib/map/viewState.test.ts @@ -51,3 +51,18 @@ describe("viewStateFocusedOnCell", () => { expect(SPHERE_SELECTION_FOCUS_ZOOM).toBeGreaterThan(SELECTION_FOCUS_ZOOM); }); }); + +describe("viewStateFocusedOnCell for coarse grids", () => { + const cell = { lon: 0, lat: 51.6, lonIndex: 0, latIndex: 50 }; + + it("frames a 2.8° cell instead of flying to street zoom", () => { + const next = viewStateFocusedOnCell(DEFAULT_MAP_VIEW, cell, "sphere", 2.8125); + expect(next.zoom).toBeGreaterThan(4); + expect(next.zoom).toBeLessThan(6); + }); + + it("keeps the usual focus zoom for a fine grid", () => { + const next = viewStateFocusedOnCell(DEFAULT_MAP_VIEW, cell, "sphere", 0.05); + expect(next.zoom).toBe(10); + }); +}); diff --git a/src/lib/map/viewState.ts b/src/lib/map/viewState.ts index fbbf57b..0cb88f4 100644 --- a/src/lib/map/viewState.ts +++ b/src/lib/map/viewState.ts @@ -58,15 +58,30 @@ export const SELECTION_FOCUS_TRANSITION_MS = 1000; const selectionFocusInterpolator = new FlyToInterpolator(); -/** Center on a snapped cell and zoom in only if the view is still too wide. */ +/** + * The zoom at which a cell `cellDeg` wide spans about 120 px: 512 px tiles + * cover 360° at zoom 0, so the width doubles with each level. + */ +function zoomFittingCell(cellDeg: number): number { + return Math.log2((120 * 360) / (512 * cellDeg)); +} + +/** + * Center on a snapped cell and zoom in only if the view is still too wide. + * A coarse cell (a 2.8° model grid) would fill far more than the screen at + * the usual focus zoom, so it is framed at the zoom that fits it instead. + */ export function viewStateFocusedOnCell( current: MapViewState, cell: GridCell, mode: MapViewMode = "2d", + cellDeg = 0, ): MapViewState { const focusZoom = mode === "sphere" ? SPHERE_SELECTION_FOCUS_ZOOM : SELECTION_FOCUS_ZOOM; - const nextZoom = Math.max(current.zoom, focusZoom); + const fitZoom = cellDeg > 0 ? zoomFittingCell(cellDeg) : Infinity; + const nextZoom = + fitZoom < focusZoom ? fitZoom : Math.max(current.zoom, focusZoom); const isChangingView = current.longitude !== cell.lon || current.latitude !== cell.lat || From edb06f9f1737fc0b97ef0bd4a9bab2f4d214d8cc Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:23:25 +0200 Subject: [PATCH 15/32] Keep gappy daily values on their own day and open stores on their last year A daily record with most days missing has a typical step of two days, so each value was spread over two days and the series gained days it never had. Steps shorter than five days are now read as daily data with gaps; only weekly and coarser steps are treated as windows. A store opened on the app's starting year (2021) even when its record ran to 2026. It now opens on its own last year, while years the reader picked still carry over when switching stores. --- src/hooks/useCellSeries.ts | 9 ++++++++- src/lib/zarr/timeAxis.test.ts | 14 ++++++++++++++ src/lib/zarr/timeAxis.ts | 9 ++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index 0973452..2db9891 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -161,6 +161,7 @@ export function useCellSeries() { // Kept in refs so opening a store can re-snap and reload whatever is picked. const selectionRef = useRef(null); const selectedYearsRef = useRef(selectedYears); + const pickedYearsRef = useRef(false); useEffect(() => { selectionRef.current = selection; selectedYearsRef.current = selectedYears; @@ -212,7 +213,12 @@ export function useCellSeries() { setDatasetLoading(false); writeSourceToUrl(next, picks); - const years = defaultYears(next.time.years, selectedYearsRef.current); + // Years the reader picked carry over to the new store; the + // starting 2021 does not, so a store opens on its own last year. + const years = defaultYears( + next.time.years, + pickedYearsRef.current ? selectedYearsRef.current : [], + ); setSelectedYears(years); const previous = selectionRef.current; if (previous) { @@ -268,6 +274,7 @@ export function useCellSeries() { const selectYears = useCallback( (years: number[]) => { + pickedYearsRef.current = true; setSelectedYears(years); if (selection) void load(selection, years); }, diff --git a/src/lib/zarr/timeAxis.test.ts b/src/lib/zarr/timeAxis.test.ts index d59ab33..345f169 100644 --- a/src/lib/zarr/timeAxis.test.ts +++ b/src/lib/zarr/timeAxis.test.ts @@ -97,3 +97,17 @@ describe("records before year 100", () => { expect(new Date(axis.originMs).getUTCFullYear()).toBe(1); }); }); + +describe("gappy daily records", () => { + it("keeps each value on its own day instead of spreading it", () => { + // Daily stamps with most days missing: the median step is 2 days. + const days = [0, 2, 4, 5, 8, 10, 12, 15, 16]; + const times = Float64Array.from(days, (d) => Date.UTC(2019, 0, 1) + d * D); + const axis = buildTimeAxis(times); + expect(axis.span).toBe("slot"); + expect(axis.stepLabel).toBe("daily"); + const out = resampleToDays(axis, Float32Array.from(days), 0, [0, axis.totalDays]); + expect(Array.from(out).filter(Number.isFinite)).toHaveLength(days.length); + expect(Number.isNaN(out[1]!)).toBe(true); + }); +}); diff --git a/src/lib/zarr/timeAxis.ts b/src/lib/zarr/timeAxis.ts index f485b7d..8c90fd5 100644 --- a/src/lib/zarr/timeAxis.ts +++ b/src/lib/zarr/timeAxis.ts @@ -62,8 +62,15 @@ function startOfUtcDay(ms: number): number { return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY; } +/** + * Below this typical step a coarse-looking axis is daily data with gaps (a + * satellite product missing most days), not windows: each value keeps its + * own day instead of being spread over its neighbours'. + */ +const MIN_WINDOW_DAYS = 5; + function spanFor(stepDays: number): TimeAxis["span"] { - if (stepDays < 1 + EPSILON) return "slot"; + if (stepDays < MIN_WINDOW_DAYS) return "slot"; if (stepDays >= 27.5 && stepDays <= 31.5) return "month"; if (stepDays >= 360 && stepDays <= 367) return "year"; return "days"; From 4d36cefa43ea154fd1762a3c92807757a8406f2e Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:26:20 +0200 Subject: [PATCH 16/32] Frame regional stores, show coverage, and avoid empty default years A regional store now opens with the map framed on its area, and the Data panel lists its coverage; units read "not given in the store" when absent. Stores often allocate their time axis years ahead (ERA5 to 2050, MERRA-2 to 2029), so the last-year default landed on an empty year. It now stops at the present year, and an untouched default that comes back empty for the chosen variable steps back up to two years. The address field kept snapping back to the previous store after a failed open; it now shows each new request's address and only the resolved root of a store that actually opened. --- src/components/map/ReliefMap.tsx | 21 +++- src/components/panels/DatasetPanel.tsx | 29 +++++- src/hooks/useCellSeries.ts | 135 ++++++++++++++++++------- src/lib/map/geogrid.ts | 19 +++- src/lib/zarr/sourceUrl.ts | 13 ++- 5 files changed, 172 insertions(+), 45 deletions(-) diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index cda2c92..9ab0b0e 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -10,7 +10,7 @@ import type { Map as MapLibreMap, MapLibreEvent } from "maplibre-gl"; import { ArrowLeft, Maximize2, X } from "lucide-react"; import "maplibre-gl/dist/maplibre-gl.css"; import "@/lib/map/initMaplibre"; -import { geoPointToZarrGrid } from "@/lib/map/geogrid"; +import { geoPointToZarrGrid, regionalExtent } from "@/lib/map/geogrid"; import { readConnectionHint, shouldWarmCache, @@ -129,6 +129,25 @@ export function ReliefMap() { useEffect(() => () => detachWheelZoomRef.current?.(), []); + // A regional store opens framed on its area, so the first click lands in it. + const framedStoreRef = useRef(null); + useEffect(() => { + const dataset = series.dataset; + const map = mapRef.current; + if (!dataset || !map || framedStoreRef.current === dataset.url) return; + framedStoreRef.current = dataset.url; + const extent = regionalExtent(dataset.grid); + if (!extent || series.selection) return; + const shift = extent.west >= 180 ? -360 : 0; + map.fitBounds( + [ + [extent.west + shift, extent.south], + [extent.east + shift, extent.north], + ], + { padding: 64, duration: SELECTION_FOCUS_TRANSITION_MS }, + ); + }, [series.dataset, series.selection]); + const flyToCell = useCallback( (cell: GridCell) => { const map = mapRef.current; diff --git a/src/components/panels/DatasetPanel.tsx b/src/components/panels/DatasetPanel.tsx index 4d2ae83..741740b 100644 --- a/src/components/panels/DatasetPanel.tsx +++ b/src/components/panels/DatasetPanel.tsx @@ -8,6 +8,11 @@ import type { Dataset } from "@/lib/zarr/dataset"; import type { CellSeries } from "@/hooks/useCellSeries"; import { FloatingPanel } from "@/components/panels/FloatingPanel"; import { formatResolution } from "@/components/panels/CellView"; +import { + formatLatitude, + formatLongitude, + regionalExtent, +} from "@/lib/map/geogrid"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -43,7 +48,14 @@ function Facts({ dataset }: { dataset: Dataset }) { ["Grid", `${formatResolution(grid.spatialResolutionDeg)} · ${grid.dimensions.lat.toLocaleString()} × ${grid.dimensions.lon.toLocaleString()} cells`], ["Time", `${time.stepLabel} · ${formatSelectedYearsLabel(time.years)}`], ]; - if (dataset.units) rows.push(["Units", dataset.units]); + const extent = regionalExtent(grid); + if (extent) { + rows.push([ + "Coverage", + `${formatLatitude(extent.south, 1)} to ${formatLatitude(extent.north, 1)}, ${formatLongitude(extent.west > 180 ? extent.west - 360 : extent.west, 1)} to ${formatLongitude(extent.east > 180 ? extent.east - 360 : extent.east, 1)}`, + ]); + } + rows.push(["Units", dataset.units ?? "not given in the store"]); return (
{rows.map(([term, value]) => ( @@ -66,10 +78,17 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { const [variable, setVariable] = useState( requestedSource?.variable ?? dataset?.variable ?? "", ); - const [prevSource, setPrevSource] = useState(shownSource); - if (shownSource !== prevSource) { - setPrevSource(shownSource); - if (shownSource) setUrl(dataset && !datasetError ? dataset.url : shownSource.url); + // A new request shows its own address; a store that then opens shows the + // root it resolved to. A failed open leaves the requested address alone. + const [prevRequest, setPrevRequest] = useState(requestedSource); + const [prevDataset, setPrevDataset] = useState(dataset); + if (requestedSource !== prevRequest) { + setPrevRequest(requestedSource); + if (requestedSource) setUrl(requestedSource.url); + } + if (dataset !== prevDataset) { + setPrevDataset(dataset); + if (dataset) setUrl(dataset.url); } const [askedForVariable, setAskedForVariable] = useState(false); const promptsForVariable = Boolean( diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index 2db9891..503de44 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -7,6 +7,7 @@ import { openDataset, type Dataset } from "@/lib/zarr/dataset"; import { setActiveSource } from "@/lib/zarr/activeSource"; import { setActiveTime, yearToDateRange } from "@/lib/zarr/timeRange"; import { samplesInDays } from "@/lib/zarr/timeAxis"; +import { hasFiniteValues } from "@/lib/zarr/series"; import { defaultYears, readSourceFromUrl, @@ -28,7 +29,14 @@ export type SeriesProgress = { loaded: number; total: number }; const OPEN_TIMEOUT_MS = 30_000; -function withTimeout(promise: Promise, ms: number, message: string): Promise { +/** Empty default years tried before reporting a cell as empty. */ +const MAX_STEPS_BACK = 2; + +function withTimeout( + promise: Promise, + ms: number, + message: string, +): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(message)), ms); promise.then( @@ -47,7 +55,11 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi function selectionAt(click: GeoPoint, spec: GridSpec): MapSelection { const grid = geoPointToZarrGrid(click, spec); if (isPointInGrid(click, spec)) return { click, grid }; - return { click, grid: { ...grid, lon: click.lon, lat: click.lat }, outside: true }; + return { + click, + grid: { ...grid, lon: click.lon, lat: click.lat }, + outside: true, + }; } function activate(dataset: Dataset) { @@ -74,7 +86,9 @@ export function useCellSeries() { const [datasetLoading, setDatasetLoading] = useState(true); const [datasetError, setDatasetError] = useState(null); /** The store last asked for, so a failed or slow open can still show it. */ - const [requestedSource, setRequestedSource] = useState(null); + const [requestedSource, setRequestedSource] = useState( + null, + ); const [extraIndices, setExtraIndices] = useState>({}); const [gridSpec, setGridSpec] = useState(DEFAULT_GRID_SPEC); const [selection, setSelection] = useState(null); @@ -87,13 +101,30 @@ export function useCellSeries() { const [error, setError] = useState(null); const [values, setValues] = useState(null); /** Days a partial load could cover, offered when the full one is refused. */ - const [partialOffer, setPartialOffer] = useState<{ days: number; bytes: number } | null>(null); + const [partialOffer, setPartialOffer] = useState<{ + days: number; + bytes: number; + } | null>(null); /** Days a partial load did cover, from the start of the selection. */ const [coveredDays, setCoveredDays] = useState(null); const [units, setUnits] = useState(null); + const pickedYearsRef = useRef(false); + const retryRef = useRef< + ( + selection: MapSelection, + years: number[], + partial: boolean, + stepsBack: number, + ) => void + >(() => {}); const load = useCallback( - async (nextSelection: MapSelection, years: number[], partial = false) => { + async ( + nextSelection: MapSelection, + years: number[], + partial = false, + stepsBack = 0, + ) => { const requestId = ++requestIdRef.current; seriesAbortRef.current?.abort(); const abort = new AbortController(); @@ -114,7 +145,9 @@ export function useCellSeries() { return; } if (nextSelection.outside) { - setError("This point is outside the area the dataset covers. Pick a point inside it."); + setError( + "This point is outside the area the dataset covers. Pick a point inside it.", + ); return; } setCachedYears(new Set(reader.getCachedYears(nextSelection.grid))); @@ -133,6 +166,25 @@ export function useCellSeries() { if (requestId !== requestIdRef.current) return; + // One variable can end before the store's axis does (ESDC GPP stops + // a year early), so an untouched default year that comes back empty + // steps back to an earlier one. + if ( + !pickedYearsRef.current && + years.length === 1 && + stepsBack < MAX_STEPS_BACK && + !hasFiniteValues(result.values) + ) { + const earlier = (reader.getDataset()?.time.years ?? []) + .filter((year) => year < years[0]!) + .at(-1); + if (earlier !== undefined) { + setSelectedYears([earlier]); + retryRef.current(nextSelection, [earlier], partial, stepsBack + 1); + return; + } + } + setValues(result.values); setCoveredDays(result.coveredDays ?? null); setUnits(result.units ?? null); @@ -141,7 +193,10 @@ export function useCellSeries() { if (requestId !== requestIdRef.current) return; if (cause instanceof Error && cause.name === "AbortError") return; if (cause instanceof SeriesBudgetError && cause.partialDays > 0) { - setPartialOffer({ days: cause.partialDays, bytes: cause.partialBytes }); + setPartialOffer({ + days: cause.partialDays, + bytes: cause.partialBytes, + }); } setError( cause instanceof Error @@ -158,10 +213,14 @@ export function useCellSeries() { [], ); + useEffect(() => { + retryRef.current = (selection, years, partial, stepsBack) => + void load(selection, years, partial, stepsBack); + }, [load]); + // Kept in refs so opening a store can re-snap and reload whatever is picked. const selectionRef = useRef(null); const selectedYearsRef = useRef(selectedYears); - const pickedYearsRef = useRef(false); useEffect(() => { selectionRef.current = selection; selectedYearsRef.current = selectedYears; @@ -176,23 +235,21 @@ export function useCellSeries() { openDataset(request.url, request.variable), OPEN_TIMEOUT_MS, "The store did not answer within 30 seconds. It may be offline or very slow.", - ).then( - ({ dataset: next, store }) => { - const reader = new ZarrChunkReader(store, { - dataset: next, - windowSize: patchWindowRef.current, - maxBytes: cacheBytesFor(patchWindowRef.current, deviceCacheBytes()), - }); - const picks: Record = {}; - for (const dim of next.layout.extras) { - const wanted = request.picks?.[dim.name] ?? 0; - const index = Math.min(Math.max(0, wanted), dim.size - 1); - picks[dim.axis] = index; - reader.setExtraIndex(dim.axis, index); - } - return { next, reader, picks }; - }, - ); + ).then(({ dataset: next, store }) => { + const reader = new ZarrChunkReader(store, { + dataset: next, + windowSize: patchWindowRef.current, + maxBytes: cacheBytesFor(patchWindowRef.current, deviceCacheBytes()), + }); + const picks: Record = {}; + for (const dim of next.layout.extras) { + const wanted = request.picks?.[dim.name] ?? 0; + const index = Math.min(Math.max(0, wanted), dim.size - 1); + picks[dim.axis] = index; + reader.setExtraIndex(dim.axis, index); + } + return { next, reader, picks }; + }); readerPromiseRef.current = promise .then(({ reader }) => reader) @@ -231,7 +288,9 @@ export function useCellSeries() { if (openId !== openIdRef.current) return; setDatasetLoading(false); setDatasetError( - cause instanceof Error ? cause.message : "The store could not be opened.", + cause instanceof Error + ? cause.message + : "The store could not be opened.", ); }); }, @@ -319,7 +378,10 @@ export function useCellSeries() { savePatchWindow(size); // Cached entries are cut to the old window and cannot be reused. setCachedYears(new Set()); - readerRef.current?.setWindowSize(size, cacheBytesFor(size, deviceCacheBytes())); + readerRef.current?.setWindowSize( + size, + cacheBytesFor(size, deviceCacheBytes()), + ); if (selection) void load(selection, selectedYears); }, [load, selectedYears, selection], @@ -331,14 +393,17 @@ export function useCellSeries() { if (!dataset) return null; // A store with its own hour axis holds a day's slots in each time sample. const perSample = dataset.time.hourAxis ? dataset.time.slotsPerDay : 1; - return perSample * selectedYears.reduce( - (sum, year) => - sum + - samplesInDays( - dataset.time, - yearToDateRange(year, dataset.time.totalDays), - ), - 0, + return ( + perSample * + selectedYears.reduce( + (sum, year) => + sum + + samplesInDays( + dataset.time, + yearToDateRange(year, dataset.time.totalDays), + ), + 0, + ) ); }, [dataset, selectedYears]); diff --git a/src/lib/map/geogrid.ts b/src/lib/map/geogrid.ts index ee1f903..c2f911a 100644 --- a/src/lib/map/geogrid.ts +++ b/src/lib/map/geogrid.ts @@ -54,13 +54,30 @@ export function axisCoordinate( } /** The store's lon range, edge to edge, in its own convention (-180..180 or 0..360). */ -function lonExtent(spec: GridSpec): { west: number; east: number } { +export function lonExtent(spec: GridSpec): { west: number; east: number } { const first = axisCoordinate(spec, "lon", 0); const last = axisCoordinate(spec, "lon", spec.dimensions.lon - 1); const half = Math.abs(spec.grid.lonStep) / 2; return { west: Math.min(first, last) - half, east: Math.max(first, last) + half }; } +/** + * The area a store covers, edge to edge, or null for a global grid, which + * needs no outline or framing. + */ +export function regionalExtent( + spec: GridSpec, +): { west: number; east: number; south: number; north: number } | null { + const { west, east } = lonExtent(spec); + const first = axisCoordinate(spec, "lat", 0); + const last = axisCoordinate(spec, "lat", spec.dimensions.lat - 1); + const half = Math.abs(spec.grid.latStep) / 2; + const south = Math.max(-90, Math.min(first, last) - half); + const north = Math.min(90, Math.max(first, last) + half); + const global = east - west >= 355 && north - south >= 150; + return global ? null : { west, east, south, north }; +} + /** * The copy of `lon` (shifted by whole turns) that lands inside the store's * range, or nearest to it: a 0..360 store is clicked in -180..180. diff --git a/src/lib/zarr/sourceUrl.ts b/src/lib/zarr/sourceUrl.ts index 5c6c6b7..f12b42a 100644 --- a/src/lib/zarr/sourceUrl.ts +++ b/src/lib/zarr/sourceUrl.ts @@ -75,11 +75,18 @@ export function writeSourceToUrl( /** * Keep the years already picked where the new store has them, otherwise - * start on its last year. + * start on its last year up to the present. */ -export function defaultYears(available: number[], current: number[]): number[] { +export function defaultYears( + available: number[], + current: number[], + thisYear = new Date().getUTCFullYear(), +): number[] { const kept = current.filter((year) => available.includes(year)); if (kept.length > 0) return kept; - const last = available.at(-1); + // Stores often allocate their time axis years ahead (ERA5 runs to 2050), + // and those years hold no data yet. + const past = available.filter((year) => year <= thisYear); + const last = past.at(-1) ?? available.at(-1); return last === undefined ? [] : [last]; } From 7d815ecd7dd568b75b2f4fceed9bbda8e9fd8300 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:28:21 +0200 Subject: [PATCH 17/32] Restore FLUXCOM-X names, read regular time axes quickly, guard the URL field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exports and the Data panel named the default store "NEE" at 0.049999999999999996°, where they read FLUXCOM-X-BASE at 0.05° before the refactor. The resolution is now rounded, and the default store carries its product name and "net ecosystem exchange" again. Exported cell coordinates drop float noise too. A time coordinate split into more than 64 chunks is read from its first chunk and its last value when those show an even spacing, so MUR SST opens in seconds instead of timing out. A variable typed for one store is no longer sent with a different address, typing in the address field is not overwritten by a store that finishes opening late, and a failed first open says so in the chart panel instead of describing FLUXCOM. Coverage for stores spanning all longitudes reads "all longitudes", and such stores are not framed on open. --- src/components/map/ReliefMap.tsx | 2 +- src/components/panels/CellView.tsx | 5 +-- src/components/panels/DatasetPanel.tsx | 24 +++++++++++--- src/hooks/useCellSeries.ts | 8 ++++- src/lib/export/csv.ts | 4 +-- src/lib/map/geogrid.ts | 13 ++++++-- src/lib/zarr/dataset.ts | 46 +++++++++++++++++++++++++- 7 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 9ab0b0e..5fb8140 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -137,7 +137,7 @@ export function ReliefMap() { if (!dataset || !map || framedStoreRef.current === dataset.url) return; framedStoreRef.current = dataset.url; const extent = regionalExtent(dataset.grid); - if (!extent || series.selection) return; + if (!extent || extent.allLongitudes || series.selection) return; const shift = extent.west >= 180 ? -360 : 0; map.fitBounds( [ diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 172fdd5..5e697c9 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -113,8 +113,9 @@ export function CellView({ /** "Hourly net ecosystem exchange for this 0.05° cell." */ export function describeRecord(series: CellSeries): string { const { dataset } = series; - const name = dataset ? (dataset.longName ?? dataset.variable) : "net ecosystem exchange"; - const step = dataset?.time.stepLabel ?? "hourly"; + if (!dataset) return series.datasetError ? "No store is open." : "Opening the store."; + const name = dataset.longName ?? dataset.variable; + const step = dataset.time.stepLabel; const resolution = formatResolution(series.gridSpec.spatialResolutionDeg); // Lowercase a sentence-case name ("Net ecosystem exchange"), but leave // acronyms ("NEE") and title case ("Gross Primary Productivity") alone. diff --git a/src/components/panels/DatasetPanel.tsx b/src/components/panels/DatasetPanel.tsx index 741740b..f732d89 100644 --- a/src/components/panels/DatasetPanel.tsx +++ b/src/components/panels/DatasetPanel.tsx @@ -52,7 +52,11 @@ function Facts({ dataset }: { dataset: Dataset }) { if (extent) { rows.push([ "Coverage", - `${formatLatitude(extent.south, 1)} to ${formatLatitude(extent.north, 1)}, ${formatLongitude(extent.west > 180 ? extent.west - 360 : extent.west, 1)} to ${formatLongitude(extent.east > 180 ? extent.east - 360 : extent.east, 1)}`, + `${formatLatitude(extent.south, 1)} to ${formatLatitude(extent.north, 1)}, ${ + extent.allLongitudes + ? "all longitudes" + : `${formatLongitude(extent.west > 180 ? extent.west - 360 : extent.west, 1)} to ${formatLongitude(extent.east > 180 ? extent.east - 360 : extent.east, 1)}` + }`, ]); } rows.push(["Units", dataset.units ?? "not given in the store"]); @@ -86,9 +90,11 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { setPrevRequest(requestedSource); if (requestedSource) setUrl(requestedSource.url); } + // Typing since the last Open wins over a store that finishes opening late. + const [edited, setEdited] = useState(false); if (dataset !== prevDataset) { setPrevDataset(dataset); - if (dataset) setUrl(dataset.url); + if (dataset && !edited) setUrl(dataset.url); } const [askedForVariable, setAskedForVariable] = useState(false); const promptsForVariable = Boolean( @@ -104,7 +110,14 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { const submit = (event: React.FormEvent) => { event.preventDefault(); if (!url.trim()) return; - openSource({ url: url.trim(), variable: needsVariable ? variable : null }); + // The variable field belongs to the store that asked for it; a different + // address starts without one. + const sameStore = url.trim() === (requestedSource?.url ?? dataset?.url); + setEdited(false); + openSource({ + url: url.trim(), + variable: needsVariable && sameStore ? variable : null, + }); }; return ( @@ -121,7 +134,10 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { setUrl(event.target.value)} + onChange={(event) => { + setUrl(event.target.value); + setEdited(true); + }} placeholder="https://…/store.zarr" spellCheck={false} autoComplete="off" diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index 503de44..eb8b792 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -85,6 +85,10 @@ export function useCellSeries() { const [dataset, setDataset] = useState(null); const [datasetLoading, setDatasetLoading] = useState(true); const [datasetError, setDatasetError] = useState(null); + const datasetErrorRef = useRef(null); + useEffect(() => { + datasetErrorRef.current = datasetError; + }, [datasetError]); /** The store last asked for, so a failed or slow open can still show it. */ const [requestedSource, setRequestedSource] = useState( null, @@ -141,7 +145,9 @@ export function useCellSeries() { const reader = await readerPromiseRef.current; if (requestId !== requestIdRef.current) return; if (!reader) { - setError("No dataset is open. Open one from the Data panel."); + setError( + `${datasetErrorRef.current ?? "No store is open."} Open one from the Data panel.`, + ); return; } if (nextSelection.outside) { diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts index 5c60aca..c5108d2 100644 --- a/src/lib/export/csv.ts +++ b/src/lib/export/csv.ts @@ -22,8 +22,8 @@ function buildHeader(rowCount: number, prov: ExportProvenance): string[] { `units: ${prov.units ?? "unspecified"}`, `click_lat: ${prov.click.lat}`, `click_lon: ${prov.click.lon}`, - `cell_lat: ${prov.cell.lat}`, - `cell_lon: ${prov.cell.lon}`, + `cell_lat: ${Number(prov.cell.lat.toPrecision(8))}`, + `cell_lon: ${Number(prov.cell.lon.toPrecision(8))}`, `lat_index: ${prov.cell.latIndex}`, `lon_index: ${prov.cell.lonIndex}`, // The store's hour axis is UTC. The local columns are that axis moved by the diff --git a/src/lib/map/geogrid.ts b/src/lib/map/geogrid.ts index c2f911a..b0c4c1b 100644 --- a/src/lib/map/geogrid.ts +++ b/src/lib/map/geogrid.ts @@ -67,15 +67,22 @@ export function lonExtent(spec: GridSpec): { west: number; east: number } { */ export function regionalExtent( spec: GridSpec, -): { west: number; east: number; south: number; north: number } | null { +): { + west: number; + east: number; + south: number; + north: number; + allLongitudes: boolean; +} | null { const { west, east } = lonExtent(spec); const first = axisCoordinate(spec, "lat", 0); const last = axisCoordinate(spec, "lat", spec.dimensions.lat - 1); const half = Math.abs(spec.grid.latStep) / 2; const south = Math.max(-90, Math.min(first, last) - half); const north = Math.min(90, Math.max(first, last) + half); - const global = east - west >= 355 && north - south >= 150; - return global ? null : { west, east, south, north }; + const allLongitudes = east - west >= 355; + if (allLongitudes && north - south >= 150) return null; + return { west, east, south, north, allLongitudes }; } /** diff --git a/src/lib/zarr/dataset.ts b/src/lib/zarr/dataset.ts index f0ea437..4b3aa7a 100644 --- a/src/lib/zarr/dataset.ts +++ b/src/lib/zarr/dataset.ts @@ -1,4 +1,5 @@ import * as zarr from "zarrita"; +import { ZARR_STORE } from "@/lib/constants/store"; import { decodeCfTimes } from "@/lib/zarr/cfTime"; import { openZarrStore, type ZarrStore } from "@/lib/zarr/store"; import { buildTimeAxis, type TimeAxis } from "@/lib/zarr/timeAxis"; @@ -193,6 +194,10 @@ async function readCoordinate( } if (array.shape.length !== 1) continue; try { + const regular = await readRegularCoordinate(array); + if (regular) { + return { values: regular, attrs: array.attrs as Record }; + } const result = await zarr.get(array, null, { createQueue: () => limitedQueue(COORDINATE_FETCHES), }); @@ -209,6 +214,37 @@ async function readCoordinate( return null; } +/** Above this many chunks a coordinate is first tried as a regular axis. */ +const MANY_COORDINATE_CHUNKS = 64; + +/** + * A finely chunked coordinate (MUR SST splits its time into 1289 chunks of 5) + * takes minutes to read in full. When its first chunk is evenly spaced and + * the last value lands where that spacing predicts, the axis is regular and + * is rebuilt from those two reads instead. + */ +async function readRegularCoordinate( + array: OpenedArray, +): Promise { + const n = array.shape[0]!; + const chunk = array.chunks[0]!; + if (Math.ceil(n / chunk) <= MANY_COORDINATE_CHUNKS || chunk < 2) return null; + const head = toNumbers( + (await zarr.get(array, [zarr.slice(0, chunk)])).data as ArrayLike, + ); + const tail = toNumbers( + (await zarr.get(array, [zarr.slice(n - 1, n)])).data as ArrayLike, + ); + const step = head[1]! - head[0]!; + if (!Number.isFinite(step) || step === 0) return null; + for (let i = 2; i < head.length; i++) { + if (Math.abs(head[i]! - head[i - 1]! - step) > Math.abs(step) * 1e-9) return null; + } + const predicted = head[0]! + (n - 1) * step; + if (Math.abs(tail[0]! - predicted) > Math.abs(step) * 1e-6) return null; + return Float64Array.from({ length: n }, (_, i) => head[0]! + i * step); +} + /** * Parallel chunk reads for one coordinate. Some stores split a time axis into * a thousand tiny chunks, and starting them all at once runs the browser out @@ -447,7 +483,8 @@ async function describeVariable( lat: array.chunks[inferred.lat]!, lon: array.chunks[inferred.lon]!, }, - spatialResolutionDeg: Math.abs(lon.step), + // The step comes out of float coordinates (0.049999999999999996). + spatialResolutionDeg: Number(Math.abs(lon.step).toPrecision(6)), latValues: lat.values ?? undefined, lonValues: lon.values ?? undefined, }; @@ -533,6 +570,7 @@ export async function openDataset( ...described, url, title: storeTitle(rootAttrs, url), + ...(url === ZARR_STORE.url && FLUXCOM_NAMES), }, }; } @@ -554,6 +592,12 @@ function storeTitle(attrs: Record, url: string): string { return named.replace(/\.zarr$/i, ""); } +/** The default store carries no title or long name of its own. */ +const FLUXCOM_NAMES = { + title: ZARR_STORE.kicker, + longName: "Net ecosystem exchange", +}; + function storeErrorMessage(cause: unknown): string { const message = cause instanceof Error ? cause.message : String(cause); if (/failed to fetch|networkerror|load failed|cors/i.test(message)) { From b35d32593f054eb119641e64953c261ea748e8a1 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:29:10 +0200 Subject: [PATCH 18/32] Break the line over missing data instead of bridging it The line joined the values on either side of a gap, so missing days or a masked period looked like a straight change. Each run of empty bins (or empty slots at hourly resolution) now carries one empty point, which the line does not draw across. --- src/components/plots/TimeSeriesPlot.tsx | 3 ++- src/lib/plots/lineSeries.test.ts | 12 ++++++++- src/lib/plots/lineSeries.ts | 33 +++++++++++++++++++------ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx index f778e46..057962f 100644 --- a/src/components/plots/TimeSeriesPlot.tsx +++ b/src/components/plots/TimeSeriesPlot.tsx @@ -61,7 +61,7 @@ type Readout = TooltipAnchor & PinPosition & { index: number }; const sameDay = (a: Readout, b: Readout) => a.index === b.index; function tooltipPoint(point: LinePoint | undefined): TooltipPoint | undefined { - if (!point) return undefined; + if (!point || point.value === null) return undefined; const { label, shortLabel, value, spread } = point; return { label, shortLabel, value, spread }; } @@ -110,6 +110,7 @@ export function TimeSeriesPlot({ let lo = Infinity; let hi = -Infinity; for (const point of data) { + if (point.value === null) continue; const [bandLo, bandHi] = point.band ?? [point.value, point.value]; lo = Math.min(lo, point.value, bandLo); hi = Math.max(hi, point.value, bandHi); diff --git a/src/lib/plots/lineSeries.test.ts b/src/lib/plots/lineSeries.test.ts index da421ee..d6f673f 100644 --- a/src/lib/plots/lineSeries.test.ts +++ b/src/lib/plots/lineSeries.test.ts @@ -32,7 +32,7 @@ describe("lineSeries", () => { expect(points).toHaveLength(365); expect(points[0]).toMatchObject({ label: "2019-01-01", x: 0.5 }); expect(points[0]!.value).toBeCloseTo(11.5); - expect(points[0]!.band![1] - points[0]!.value).toBeCloseTo( + expect(points[0]!.band![1] - points[0]!.value!).toBeCloseTo( Math.sqrt((24 * 24 - 1) / 12), ); }); @@ -61,3 +61,13 @@ describe("lineSeries", () => { expect(ticks.map((tick) => tick.label)).toEqual(["2003", "2010"]); }); }); + +describe("lineSeries gaps", () => { + it("breaks the line over missing days instead of bridging them", () => { + const values = Float32Array.from({ length: 10 }, (_, day) => (day >= 3 && day < 6 ? NaN : day)); + const { points } = lineSeries(values, 1, [2019], "day"); + const gaps = points.filter((point) => point.value === null); + expect(gaps).toHaveLength(1); + expect(points.filter((point) => point.value !== null)).toHaveLength(7); + }); +}); diff --git a/src/lib/plots/lineSeries.ts b/src/lib/plots/lineSeries.ts index 20d8df9..36dd56d 100644 --- a/src/lib/plots/lineSeries.ts +++ b/src/lib/plots/lineSeries.ts @@ -21,7 +21,8 @@ export const MAX_LINE_POINTS = 4000; export type LinePoint = { /** Position in days from the start of the selection, at the bin's centre. */ x: number; - value: number; + /** Null marks a run of empty bins, so the line breaks instead of bridging it. */ + value: number | null; band?: [number, number]; label: string; shortLabel: string; @@ -41,7 +42,7 @@ export type LineSeries = { /** Keep the lowest and highest point of each slice, in time order. */ -export function keepExtremes( +export function keepExtremes( points: readonly T[], maxPoints: number, ): T[] { @@ -56,9 +57,9 @@ export function keepExtremes( let hi = -1; for (let i = start; i < end; i++) { const value = points[i]!.value; - if (!Number.isFinite(value)) continue; - if (lo < 0 || value < points[lo]!.value) lo = i; - if (hi < 0 || value > points[hi]!.value) hi = i; + if (value === null || !Number.isFinite(value)) continue; + if (lo < 0 || value < points[lo]!.value!) lo = i; + if (hi < 0 || value > points[hi]!.value!) hi = i; } if (lo < 0) continue; kept.push(points[Math.min(lo, hi)]!); @@ -105,7 +106,12 @@ export function lineSeries( const date = formatIsoDate(absoluteDays[day] ?? 0); for (let hour = 0; hour < hoursPerDay; hour++) { const value = values[day * hoursPerDay + hour] as number; - if (!Number.isFinite(value)) continue; + if (!Number.isFinite(value)) { + if (points.length > 0 && points.at(-1)!.value !== null) { + points.push({ x: day + (hour + 0.5) / hoursPerDay, value: null, label: "", shortLabel: "" }); + } + continue; + } points.push({ x: day + (hour + 0.5) / hoursPerDay, value, @@ -120,9 +126,22 @@ export function lineSeries( const bins = timeBins(absoluteDays, resolution); const stats = binLineStats(values, hoursPerDay, bins, resolution); binCount = bins.length; + let gapOpen = false; bins.forEach((bin, b) => { const count = stats.count[b] ?? 0; - if (count === 0) return; + if (count === 0) { + if (!gapOpen && points.length > 0) { + points.push({ + x: (bin.startLocal + bin.endLocal + 1) / 2, + value: null, + label: "", + shortLabel: "", + }); + } + gapOpen = true; + return; + } + gapOpen = false; const mean = stats.mean[b] as number; const std = stats.std[b] as number; const across = From 50cb69aa5a983d9b75223b31644a3720e329ed6f Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:30:28 +0200 Subject: [PATCH 19/32] Pick a store's variable by searching its name Stores like ERA5 (273 arrays) or MERRA-2 (about 470) listed every variable in a plain select with no way to search, and long group paths were cut off at the end. The variable is now a search field that matches the name, the long name or the group path, and each option shows the array name first with its groups and long name wrapping beneath. --- src/components/panels/DatasetPanel.tsx | 123 ++++++++++++++++++++----- 1 file changed, 99 insertions(+), 24 deletions(-) diff --git a/src/components/panels/DatasetPanel.tsx b/src/components/panels/DatasetPanel.tsx index f732d89..49d2c7d 100644 --- a/src/components/panels/DatasetPanel.tsx +++ b/src/components/panels/DatasetPanel.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { LoaderCircle } from "lucide-react"; import { DATASET_PRESETS } from "@/lib/constants/datasets"; import { formatSelectedYearsLabel } from "@/lib/zarr/timeRange"; -import type { Dataset } from "@/lib/zarr/dataset"; +import type { Dataset, VariableInfo } from "@/lib/zarr/dataset"; import type { CellSeries } from "@/hooks/useCellSeries"; import { FloatingPanel } from "@/components/panels/FloatingPanel"; import { formatResolution } from "@/components/panels/CellView"; @@ -23,6 +23,14 @@ import { FieldSeparator, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; import { Select, SelectContent, @@ -193,29 +201,12 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) {
{dataset.variables.length > 1 ? ( - - Variable - - + openSource({ url: dataset.url, variable: next })} + /> ) : null} {dataset.layout.extras @@ -277,3 +268,87 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { ); } + +/** "atmosphere/tas" as its leaf and the groups it sits in. */ +function splitPath(path: string): { leaf: string; groups: string } { + const at = path.lastIndexOf("/"); + return at < 0 + ? { leaf: path, groups: "" } + : { leaf: path.slice(at + 1), groups: path.slice(0, at) }; +} + +/** + * Some stores hold hundreds of arrays (ERA5 has 273), so the variable is + * picked by typing part of its name, long name or group path. + */ +function VariablePicker({ + variables, + value, + disabled, + onChange, +}: { + variables: VariableInfo[]; + value: string; + disabled: boolean; + onChange: (path: string) => void; +}) { + const selected = variables.find((option) => option.path === value) ?? null; + return ( + + Variable + + items={variables} + value={selected} + onValueChange={(next) => { + if (next && next.path !== value) onChange(next.path); + }} + itemToStringLabel={(option) => option.path} + isItemEqualToValue={(a, b) => a.path === b.path} + filter={(option, query) => { + const needle = query.trim().toLowerCase(); + return ( + !needle || + option.path.toLowerCase().includes(needle) || + (option.longName?.toLowerCase().includes(needle) ?? false) + ); + }} + disabled={disabled} + autoHighlight + > + + + No variable matches. + + {(option: VariableInfo) => { + const { leaf, groups } = splitPath(option.path); + return ( + + + + {leaf} + {groups ? ( + in {groups} + ) : null} + + {option.longName ? ( + + {option.longName} + + ) : null} + + + ); + }} + + + + + ); +} From f5153f3ceae40c54cd5f1f36bc72442dfcaf91ed Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:31:58 +0200 Subject: [PATCH 20/32] Start a new store's variable field empty and spell out long year runs The variable field for a store that does not list its arrays came up holding the previous store's variable. It now follows each request. Year buttons drop to two digits only for a short run within one century that does not start on a century year, so a 2000 to 2026 store no longer shows 00 to 26. --- src/components/panels/DatasetPanel.tsx | 5 ++++- src/components/panels/YearPicker.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/panels/DatasetPanel.tsx b/src/components/panels/DatasetPanel.tsx index 49d2c7d..0422099 100644 --- a/src/components/panels/DatasetPanel.tsx +++ b/src/components/panels/DatasetPanel.tsx @@ -96,7 +96,10 @@ export function DatasetPanel({ series, onClose }: DatasetPanelProps) { const [prevDataset, setPrevDataset] = useState(dataset); if (requestedSource !== prevRequest) { setPrevRequest(requestedSource); - if (requestedSource) setUrl(requestedSource.url); + if (requestedSource) { + setUrl(requestedSource.url); + setVariable(requestedSource.variable ?? ""); + } } // Typing since the last Open wins over a store that finishes opening late. const [edited, setEdited] = useState(false); diff --git a/src/components/panels/YearPicker.tsx b/src/components/panels/YearPicker.tsx index 32afaf5..c860375 100644 --- a/src/components/panels/YearPicker.tsx +++ b/src/components/panels/YearPicker.tsx @@ -25,10 +25,13 @@ export function YearPicker({ const selected = new Set(selectedYears); const years = availableYears(); // Two digits only while every year shares a century, or 1950 and 2050 - // collide, and only for a full grid: a lone "20" does not read as a year. + // collide, and only for a short full grid: a lone "20", a "00" or a long + // run of two-digit buttons does not read as years. const oneCentury = years.length > 7 && + years.length <= 25 && years[0]! >= 1000 && + years[0]! % 100 !== 0 && Math.floor(years[0]! / 100) === Math.floor(years[years.length - 1]! / 100); // Long records scroll; keep the first selected year in view. From 155688406b20e0fe4fabed63e16002c90185dba0 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:35:39 +0200 Subject: [PATCH 21/32] Open stores in their own Zarr format instead of probing both Every open logged a handful of 404s: zarrita, left to guess, tried the v2 files (.zmetadata, .zgroup, .zarray, .zattrs) on v3 stores and an array at the root of v2 ones. The store's format is now read once from its root and every node is opened in that format, and a root that consolidated metadata already lists as a group is not probed as an array. --- src/lib/zarr/chunk.worker.ts | 4 ++-- src/lib/zarr/dataset.ts | 17 +++++++++++++---- src/lib/zarr/store.ts | 31 ++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/lib/zarr/chunk.worker.ts b/src/lib/zarr/chunk.worker.ts index 71883d7..6003fde 100644 --- a/src/lib/zarr/chunk.worker.ts +++ b/src/lib/zarr/chunk.worker.ts @@ -1,10 +1,10 @@ /// -import * as zarr from "zarrita"; import type { LocalBlock } from "@/lib/zarr/chunks"; import { readPatch, type ChunkSource, type PatchRequest } from "@/lib/zarr/patch"; import { createByteProgressSink, isAbortError, + openArrayNode, openZarrStore, withRequestContext, type ZarrStore, @@ -84,7 +84,7 @@ function getArray(url: string, variable: string): Promise { if (existing) return existing; const promise = getStore(url) - .then((ds) => zarr.open(ds.root.resolve(variable), { kind: "array" })) + .then((ds) => openArrayNode(ds, variable)) .then((array) => array as unknown as ChunkSource) .catch((error) => { arrayPromises.delete(key); diff --git a/src/lib/zarr/dataset.ts b/src/lib/zarr/dataset.ts index 4b3aa7a..7ad88f5 100644 --- a/src/lib/zarr/dataset.ts +++ b/src/lib/zarr/dataset.ts @@ -1,7 +1,12 @@ import * as zarr from "zarrita"; import { ZARR_STORE } from "@/lib/constants/store"; import { decodeCfTimes } from "@/lib/zarr/cfTime"; -import { openZarrStore, type ZarrStore } from "@/lib/zarr/store"; +import { + openArrayNode, + openRootNode, + openZarrStore, + type ZarrStore, +} from "@/lib/zarr/store"; import { buildTimeAxis, type TimeAxis } from "@/lib/zarr/timeAxis"; import type { GridSpec } from "@/types/map"; @@ -131,7 +136,7 @@ function baseName(path: string): string { } async function openArray(ds: ZarrStore, path: string): Promise { - return zarr.open(ds.root.resolve(path), { kind: "array" }); + return openArrayNode(ds, path); } function dimensionNames(array: OpenedArray): string[] | null { @@ -535,9 +540,13 @@ export async function openDataset( let target = variable?.trim().replace(/^\/+|\/+$/g, "") || null; - // A link straight to an array: open its parent and pick the array. + // A link straight to an array: open its parent and pick the array. A store + // that lists its root as a group needs no probe. + const listing = (ds.store as { contents?: () => { path: string; kind: string }[] }) + .contents?.(); + const rootIsGroup = listing?.some((node) => node.path === "/" && node.kind === "group"); try { - const node = await zarr.open(ds.root); + const node = rootIsGroup ? { kind: "group" } : await openRootNode(ds); if (node.kind === "array") { const parent = url.slice(0, url.lastIndexOf("/")); target = target ?? baseName(url); diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts index 6a02c2a..1a660d4 100644 --- a/src/lib/zarr/store.ts +++ b/src/lib/zarr/store.ts @@ -232,16 +232,45 @@ export async function openZarrStore(url: string = ZARR_STORE.url) { const raw = new zarr.FetchStore(url, { fetch: progressFetch }); // Stores without consolidated metadata still open; they just cannot list // their arrays. - const consolidated = await zarr.withMaybeConsolidatedMetadata(raw); + // Asking for the format up front saves a v3 store the round of v2 probes + // (.zmetadata, .zgroup, .zarray) that each log a 404 in the console. + const isV3 = (await raw.get("/zarr.json")) !== undefined; + const consolidated = await zarr.withMaybeConsolidatedMetadata(raw, { + format: isV3 ? "v3" : "v2", + }); const store = zarr.withByteCaching(consolidated, { keyFor: metadataOnlyKey }); return { store, root: zarr.root(store), // The decode worker opens its own store against the same dataset. url, + version: (isV3 ? "v3" : "v2") as "v2" | "v3", }; } +type VersionedRoot = { + root: zarr.Location; + version: "v2" | "v3"; +}; + +/** + * Open nodes in the store's own format. Left to guess, zarrita probes the + * other format first and each miss logs a 404 in the console. + */ +export function openArrayNode( + ds: VersionedRoot, + path: string, +) { + const location = ds.root.resolve(path); + return ds.version === "v3" + ? zarr.open.v3(location, { kind: "array" }) + : zarr.open.v2(location, { kind: "array" }); +} + +export function openRootNode(ds: VersionedRoot) { + return ds.version === "v3" ? zarr.open.v3(ds.root) : zarr.open.v2(ds.root); +} + /** * Aggregate byte progress across the several native chunks one time series * needs. Chunks download one at a time, so the denominator is extrapolated From 65f20e22c6ea35580bcd34851a0af8acf23b4cc0 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:36:07 +0200 Subject: [PATCH 22/32] Count only filled bins and keep strip and half-hour labels apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line caption counted empty days as daily means ("720 daily steps · 731 daily means" for a 360-day calendar); it now counts bins with data. Labels under the left strip of the fingerprint skip one that would touch its neighbour instead of running together, and the exported local hour keeps its half hour for sub-hourly stores instead of truncating to the hour. --- src/lib/export/rows.ts | 2 +- src/lib/plots/fingerprint.ts | 4 ++++ src/lib/plots/lineSeries.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/export/rows.ts b/src/lib/export/rows.ts index ead7ec1..4d45f93 100644 --- a/src/lib/export/rows.ts +++ b/src/lib/export/rows.ts @@ -81,7 +81,7 @@ export function buildSeriesRows( date: timestamp.toISOString().slice(0, 10), dayIndex, hour, - hourLocal: timestampLocal.getUTCHours(), + hourLocal: timestampLocal.getUTCHours() + timestampLocal.getUTCMinutes() / 60, dateLocal: timestampLocal.toISOString().slice(0, 10), value: Number.isFinite(raw) ? roundFloat32(raw) : null, }; diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts index 554c882..e8ca945 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -572,9 +572,13 @@ export function drawFingerprint( } ctx.textAlign = "center"; ctx.textBaseline = "top"; + let stripRight = -Infinity; for (const { label, at } of labelled(margins.left, leftTicks)) { const x = Math.max(leftBox.x + 10, Math.min(leftBox.x + leftBox.w - 10, at)); + const half = ctx.measureText(label).width / 2; + if (x - half < stripRight + 4) continue; ctx.fillText(label, x, top + plotH + 4); + stripRight = x + half; } } diff --git a/src/lib/plots/lineSeries.ts b/src/lib/plots/lineSeries.ts index 36dd56d..9326808 100644 --- a/src/lib/plots/lineSeries.ts +++ b/src/lib/plots/lineSeries.ts @@ -125,7 +125,7 @@ export function lineSeries( } else { const bins = timeBins(absoluteDays, resolution); const stats = binLineStats(values, hoursPerDay, bins, resolution); - binCount = bins.length; + binCount = stats.count.filter((count) => count > 0).length; let gapOpen = false; bins.forEach((bin, b) => { const count = stats.count[b] ?? 0; From ebfb04bc40bc4965e6d04d51c6f7a30685a9288e Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:36:34 +0200 Subject: [PATCH 23/32] Word the empty-cell message for any store's mask Stores other than FLUXCOM-X were told an empty cell might be over ocean, which is backwards for an ocean product like MUR SST. The message now names a land or ocean mask, or years outside the record. FLUXCOM-X is recognised by its address, so its vegetated-land wording no longer depends on the variable being called NEE. --- src/components/panels/CellView.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 5e697c9..43f4c23 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -1,5 +1,6 @@ "use client"; +import { ZARR_STORE } from "@/lib/constants/store"; import { useState } from "react"; import { Collapsible } from "@base-ui/react/collapsible"; import { ArrowLeftRight, ChevronDown } from "lucide-react"; @@ -132,10 +133,10 @@ export function formatResolution(degrees: number): string { } function emptyCellMessage(series: CellSeries): string { - const isFluxcom = !series.dataset || series.dataset.variable === "NEE"; + const isFluxcom = !series.dataset || series.dataset.url === ZARR_STORE.url; return isFluxcom ? "No data at this cell. NEE is estimated over vegetated land, so ocean and bare-ground cells are empty. Pick a cell over land." - : "No data at this cell for the selected years. The dataset may not cover it, for example over ocean or outside its mask. Pick another cell or other years."; + : "No data at this cell for the selected years. The store leaves it empty, for example outside its land or ocean mask, or the years fall outside its record. Pick another cell or other years."; } const activeTriggerClass = From 83aa92d2821b0f58442c32561e8b8b91dd0d4be8 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Fri, 30 Oct 2026 22:42:55 +0200 Subject: [PATCH 24/32] Count only the loaded steps after a partial load After loading the first 100 days of a store chunked by map, the caption still gave the whole year's step count (1,453 for ACE). It now counts the steps the partial load covered. --- src/hooks/useCellSeries.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index eb8b792..4749950 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -399,6 +399,12 @@ export function useCellSeries() { if (!dataset) return null; // A store with its own hour axis holds a day's slots in each time sample. const perSample = dataset.time.hourAxis ? dataset.time.slotsPerDay : 1; + // A partial load covers only its first days, counted from the start of + // the earliest selected year. + if (coveredDays !== null && selectedYears.length > 0) { + const start = yearToDateRange(Math.min(...selectedYears), dataset.time.totalDays)[0]; + return perSample * samplesInDays(dataset.time, [start, start + coveredDays]); + } return ( perSample * selectedYears.reduce( @@ -411,7 +417,7 @@ export function useCellSeries() { 0, ) ); - }, [dataset, selectedYears]); + }, [dataset, selectedYears, coveredDays]); return { dataset, From 22c54ea712faa9adff1031327c9974214511a9bd Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 1 Nov 2026 15:13:42 +0200 Subject: [PATCH 25/32] Show a failed store link's error instead of the default store's chrome --- src/components/map/ReliefMap.tsx | 11 ++++++++++- src/components/panels/CellView.tsx | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/map/ReliefMap.tsx b/src/components/map/ReliefMap.tsx index 5fb8140..96c1bdf 100644 --- a/src/components/map/ReliefMap.tsx +++ b/src/components/map/ReliefMap.tsx @@ -129,6 +129,15 @@ export function ReliefMap() { useEffect(() => () => detachWheelZoomRef.current?.(), []); + // A link to a store that fails to open says so on arrival, not only once a + // point is picked. + const shownOpenErrorRef = useRef(false); + useEffect(() => { + if (series.dataset || !series.datasetError || shownOpenErrorRef.current) return; + shownOpenErrorRef.current = true; + setOpenPanel((current) => current ?? "data"); + }, [series.dataset, series.datasetError]); + // A regional store opens framed on its area, so the first click lands in it. const framedStoreRef = useRef(null); useEffect(() => { @@ -340,7 +349,7 @@ export function ReliefMap() { attributionControl={false} style={{ width: "100%", height: "100%" }} > - {selection && !selection.outside && mapSize.width > 0 && mapSize.height > 0 ? ( + {selection && !selection.outside && series.dataset && mapSize.width > 0 && mapSize.height > 0 ? ( + + {formatLongitude(selection.grid.lon)},{" "} + {formatLatitude(selection.grid.lat)} + + +
+ ); + } + return (
Date: Sun, 1 Nov 2026 15:13:42 +0200 Subject: [PATCH 26/32] Time out only silent hosts and keep empty cells off oversized earlier years --- src/hooks/useCellSeries.ts | 53 +++++++++++++++++++++++++++++++------- src/lib/zarr/store.ts | 18 +++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/hooks/useCellSeries.ts b/src/hooks/useCellSeries.ts index 4749950..1f6d756 100644 --- a/src/hooks/useCellSeries.ts +++ b/src/hooks/useCellSeries.ts @@ -3,7 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { geoPointToZarrGrid, isPointInGrid } from "@/lib/map/geogrid"; import { SeriesBudgetError, ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; -import { openDataset, type Dataset } from "@/lib/zarr/dataset"; +import { + normalizeStoreUrl, + openDataset, + type Dataset, +} from "@/lib/zarr/dataset"; +import { lastAnswerFrom } from "@/lib/zarr/store"; import { setActiveSource } from "@/lib/zarr/activeSource"; import { setActiveTime, yearToDateRange } from "@/lib/zarr/timeRange"; import { samplesInDays } from "@/lib/zarr/timeAxis"; @@ -32,20 +37,30 @@ const OPEN_TIMEOUT_MS = 30_000; /** Empty default years tried before reporting a cell as empty. */ const MAX_STEPS_BACK = 2; -function withTimeout( +/** + * Rejects once the host has gone `ms` without answering any request, so a + * slow host that keeps answering is not cut off, only a silent one. + */ +function withIdleTimeout( promise: Promise, + url: string, ms: number, message: string, ): Promise { + const started = Date.now(); return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(message)), ms); + const timer = setInterval(() => { + if (Date.now() - Math.max(started, lastAnswerFrom(url)) < ms) return; + clearInterval(timer); + reject(new Error(message)); + }, 1000); promise.then( (value) => { - clearTimeout(timer); + clearInterval(timer); resolve(value); }, (cause) => { - clearTimeout(timer); + clearInterval(timer); reject(cause); }, ); @@ -114,6 +129,9 @@ export function useCellSeries() { const [units, setUnits] = useState(null); const pickedYearsRef = useRef(false); + const emptyYearRef = useRef<{ years: number[]; values: Float32Array } | null>( + null, + ); const retryRef = useRef< ( selection: MapSelection, @@ -133,6 +151,7 @@ export function useCellSeries() { seriesAbortRef.current?.abort(); const abort = new AbortController(); seriesAbortRef.current = abort; + if (stepsBack === 0) emptyYearRef.current = null; setLoading(true); setProgress(null); setError(null); @@ -185,6 +204,7 @@ export function useCellSeries() { .filter((year) => year < years[0]!) .at(-1); if (earlier !== undefined) { + emptyYearRef.current = { years, values: result.values }; setSelectedYears([earlier]); retryRef.current(nextSelection, [earlier], partial, stepsBack + 1); return; @@ -198,6 +218,14 @@ export function useCellSeries() { } catch (cause) { if (requestId !== requestIdRef.current) return; if (cause instanceof Error && cause.name === "AbortError") return; + // An earlier year too big to load says nothing about this cell; the + // empty default year it stepped back from does. + const empty = emptyYearRef.current; + if (stepsBack > 0 && cause instanceof SeriesBudgetError && empty) { + setSelectedYears(empty.years); + setValues(empty.values); + return; + } if (cause instanceof SeriesBudgetError && cause.partialDays > 0) { setPartialOffer({ days: cause.partialDays, @@ -237,10 +265,11 @@ export function useCellSeries() { const startOpen = useCallback( (request: SourceRequest) => { const openId = ++openIdRef.current; - const promise = withTimeout( + const promise = withIdleTimeout( openDataset(request.url, request.variable), + normalizeStoreUrl(request.url), OPEN_TIMEOUT_MS, - "The store did not answer within 30 seconds. It may be offline or very slow.", + "The store stopped answering for 30 seconds. It may be offline or very slow.", ).then(({ dataset: next, store }) => { const reader = new ZarrChunkReader(store, { dataset: next, @@ -331,10 +360,16 @@ export function useCellSeries() { (lon: number, lat: number): MapSelection => { const nextSelection = selectionAt({ lon, lat }, gridSpec); setSelection(nextSelection); - void load(nextSelection, selectedYears); + // A year stepped back to for an empty cell is not kept for the next one. + const years = + pickedYearsRef.current || !dataset + ? selectedYears + : defaultYears(dataset.time.years, []); + setSelectedYears(years); + void load(nextSelection, years); return nextSelection; }, - [gridSpec, load, selectedYears], + [dataset, gridSpec, load, selectedYears], ); const selectYears = useCallback( diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts index 1a660d4..29f4cb2 100644 --- a/src/lib/zarr/store.ts +++ b/src/lib/zarr/store.ts @@ -125,6 +125,22 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +const lastAnswerByOrigin = new Map(); + +function originOf(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +/** When the host serving `url` last answered a request, in ms since epoch. */ +export function lastAnswerFrom(url: string): number { + const origin = originOf(url); + return (origin && lastAnswerByOrigin.get(origin)) || 0; +} + /** * `fetch` with a couple of retries for the errors that clear on their own: * dropped connections, and the throttling and gateway statuses the store @@ -137,6 +153,8 @@ export async function fetchWithRetry(request: Request): Promise { try { // A Request can only be sent once, so each attempt gets its own copy. const response = await fetch(last ? request : request.clone()); + const origin = originOf(request.url); + if (origin) lastAnswerByOrigin.set(origin, Date.now()); if (last || !isRetryableStatus(response.status)) return response; // Nothing will read this body; release the connection before retrying. await response.body?.cancel(); From 991991eeb852bc1dd01fab09a5133b9c2db082d8 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 1 Nov 2026 15:17:11 +0200 Subject: [PATCH 27/32] Print whole values without decimals --- src/lib/plots/chartTheme.test.ts | 6 ++++++ src/lib/plots/chartTheme.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/plots/chartTheme.test.ts b/src/lib/plots/chartTheme.test.ts index 1488da4..6d19095 100644 --- a/src/lib/plots/chartTheme.test.ts +++ b/src/lib/plots/chartTheme.test.ts @@ -23,6 +23,12 @@ describe("formatSeriesValue", () => { expect(formatSeriesValue(2.5e7)).toBe("2.5e+7"); }); + it("prints whole values without decimals", () => { + expect(formatSeriesValue(90)).toBe("90"); + expect(formatSeriesValue(130)).toBe("130"); + expect(formatSeriesValue(104.8)).toBe("104.8"); + }); + it("prints zero plainly", () => { expect(formatSeriesValue(0)).toBe("0"); expect(formatSeriesValue(-1e-13)).toBe("0"); diff --git a/src/lib/plots/chartTheme.ts b/src/lib/plots/chartTheme.ts index d992429..6eb2ada 100644 --- a/src/lib/plots/chartTheme.ts +++ b/src/lib/plots/chartTheme.ts @@ -17,7 +17,8 @@ export function formatSeriesValue(value: number): string { const abs = Math.abs(value); if (abs < 1e-12) return "0"; if (abs < 1e-3 || abs >= 1e6) return trimZeros(value.toExponential(2)); - if (abs >= 1000) return value.toFixed(0); + // Whole values (integer-typed data, counts, flags) print without decimals. + if (abs >= 1000 || Number.isInteger(value)) return value.toFixed(0); if (abs >= 100) return value.toFixed(1); if (abs >= 1) return value.toFixed(2); return value.toFixed(2 - Math.floor(Math.log10(abs))); From 4a6a075d43d00c411771a18cf5c879414b12d575 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 1 Nov 2026 15:17:11 +0200 Subject: [PATCH 28/32] End the line axis where a partial load stops --- src/components/panels/CellView.tsx | 1 + src/components/plots/TimeSeriesPlot.tsx | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 191dc96..793ddeb 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -268,6 +268,7 @@ export function PlotArea({ resolution={display.lineResolution} timeBasisSuffix={shortTimeBasis(display.timeBasisLabel)} stepCount={series.stepCount} + coveredDays={series.coveredDays} height={height} pinLimit={pinLimit} /> diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx index 057962f..fcb5cd9 100644 --- a/src/components/plots/TimeSeriesPlot.tsx +++ b/src/components/plots/TimeSeriesPlot.tsx @@ -75,6 +75,8 @@ type TimeSeriesPlotProps = { timeBasisSuffix?: string; /** Values the store holds for the selection; defaults to the grid's length. */ stepCount?: number | null; + /** Days a partial load covered; the axis stops there. */ + coveredDays?: number | null; height?: number; width?: number; pinLimit?: number; @@ -89,6 +91,7 @@ export function TimeSeriesPlot({ resolution = "day", timeBasisSuffix, stepCount, + coveredDays, height = TIME_SERIES_PLOT_HEIGHT, width, pinLimit = 1, @@ -105,7 +108,18 @@ export function TimeSeriesPlot({ ), [values, hoursPerDay, selectedYears, resolution, timeBasisSuffix], ); - const data = series.points; + const xMax = Math.max( + 1, + coveredDays == null ? series.nDays : Math.min(series.nDays, Math.ceil(coveredDays)), + ); + const data = useMemo( + () => + xMax < series.nDays + ? series.points.filter((point) => point.x <= xMax) + : series.points, + [series, xMax], + ); + const xTicks = series.ticks.filter((tick) => tick.x <= xMax); const yAxis = useMemo(() => { let lo = Infinity; let hi = -Infinity; @@ -182,8 +196,8 @@ export function TimeSeriesPlot({ tick.x)} + domain={[0, xMax]} + ticks={xTicks.map((tick) => tick.x)} tickFormatter={(x: number) => series.ticks.find((tick) => tick.x === x)?.label ?? "" } From ac19c5e203752460da7951dad967d2cd46853015 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 1 Nov 2026 15:17:11 +0200 Subject: [PATCH 29/32] Leave half-filled local-time edge days out of the summary --- src/components/data/SeriesSummary.tsx | 50 ++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/data/SeriesSummary.tsx b/src/components/data/SeriesSummary.tsx index 6a20bee..b399458 100644 --- a/src/components/data/SeriesSummary.tsx +++ b/src/components/data/SeriesSummary.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { formatIsoDate } from "@/lib/map/fingerprintScale"; import { formatSeriesValue } from "@/lib/plots/chartTheme"; +import { contiguousDayBlocks } from "@/lib/zarr/localTime"; import { dailyMeanSeries } from "@/lib/zarr/series"; import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange"; import type { CellSeries } from "@/hooks/useCellSeries"; @@ -20,11 +21,38 @@ type Summary = { type Period = "day" | "month"; +/** + * Days the local-time shift leaves only partly filled: the first day of each + * run east of Greenwich, the last one west of it. Their means would stand for + * a few hours, not a day. + */ +function partialLocalDays( + values: Float32Array, + selectedYears: number[], + hoursPerDay: number, + utcOffsetHours: number, +): Set { + if (utcOffsetHours === 0) return new Set(); + const dayCount = Math.floor(values.length / hoursPerDay); + const { absoluteDays } = getSelectedYearsDayMapping( + selectedYears, + undefined, + dayCount, + ); + return new Set( + contiguousDayBlocks(absoluteDays, dayCount).map((block) => + utcOffsetHours > 0 ? block.start : block.start + block.dayCount - 1, + ), + ); +} + function summarize( values: Float32Array, selectedYears: number[], hoursPerDay: number, period: Period, + skipped: Set = new Set(), + firstDay = 0, ): Summary | null { const daily = dailyMeanSeries(values, hoursPerDay); if (daily.length === 0) return null; @@ -41,11 +69,13 @@ function summarize( }; const points: { value: number; label: string }[] = []; for (const point of daily) { + if (skipped.has(firstDay + point.day - 1)) continue; const label = labelOf(point.day); if (period === "month" && points.at(-1)?.label === label) continue; points.push({ value: point.value, label }); } + if (points.length === 0) return null; let sum = 0; let lowest = points[0]!; let highest = points[0]!; @@ -68,6 +98,7 @@ function summarizeByYear( selectedYears: number[], hoursPerDay: number, period: Period, + skipped: Set, ): { year: number; summary: Summary }[] { const { yearIntervals } = getSelectedYearsDayMapping( selectedYears, @@ -80,6 +111,8 @@ function summarizeByYear( [year], hoursPerDay, period, + skipped, + startDayLocal, ); return summary ? [{ year, summary }] : []; }); @@ -138,20 +171,29 @@ export function SeriesSummary({ const word = period === "month" ? "month" : "day"; const Word = period === "month" ? "Month" : "Day"; + const offset = display.timeBasis === "local" ? display.utcOffsetHours : 0; + const skipped = useMemo( + () => + values + ? partialLocalDays(values, selectedYears, slotsPerDay, offset) + : new Set(), + [values, selectedYears, slotsPerDay, offset], + ); + const summary = useMemo( () => values && !display.isEmptyCell - ? summarize(values, selectedYears, slotsPerDay, period) + ? summarize(values, selectedYears, slotsPerDay, period, skipped) : null, - [values, display.isEmptyCell, selectedYears, slotsPerDay, period], + [values, display.isEmptyCell, selectedYears, slotsPerDay, period, skipped], ); const byYear = useMemo( () => values && !display.isEmptyCell && selectedYears.length > 1 - ? summarizeByYear(values, selectedYears, slotsPerDay, period) + ? summarizeByYear(values, selectedYears, slotsPerDay, period, skipped) : [], - [values, display.isEmptyCell, selectedYears, slotsPerDay, period], + [values, display.isEmptyCell, selectedYears, slotsPerDay, period, skipped], ); if (!summary) return null; From 1e886a62004fcba17b6637a68b858c0806ece359 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 1 Nov 2026 20:39:15 +0200 Subject: [PATCH 30/32] Count hourly daily-mean tooltips in hours --- src/lib/plots/lineSeries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/plots/lineSeries.ts b/src/lib/plots/lineSeries.ts index 9326808..27bb960 100644 --- a/src/lib/plots/lineSeries.ts +++ b/src/lib/plots/lineSeries.ts @@ -146,7 +146,7 @@ export function lineSeries( const std = stats.std[b] as number; const across = resolution === "day" - ? `${count.toLocaleString()} step${count === 1 ? "" : "s"}` + ? `${count.toLocaleString()} ${hoursPerDay === 24 ? "hour" : "step"}${count === 1 ? "" : "s"}` : binCountLabel(count, "day"); points.push({ x: (bin.startLocal + bin.endLocal + 1) / 2, From 4b9ab367c4c466a5126354ead1c0b5e494083626 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Mon, 2 Nov 2026 15:46:32 +0200 Subject: [PATCH 31/32] Show temperatures in kelvin, Celsius or Fahrenheit and plainer caption copy --- src/components/data/DataView.tsx | 5 +- src/components/data/SeriesSummary.tsx | 3 +- src/components/panels/CellExport.tsx | 6 +- src/components/panels/CellView.tsx | 10 ++-- .../panels/TemperatureUnitField.tsx | 24 ++++++++ src/hooks/useSeriesDisplay.ts | 47 +++++++++++++-- src/lib/settings/temperatureUnit.ts | 23 ++++++++ src/lib/zarr/temperature.test.ts | 37 ++++++++++++ src/lib/zarr/temperature.ts | 58 +++++++++++++++++++ 9 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 src/components/panels/TemperatureUnitField.tsx create mode 100644 src/lib/settings/temperatureUnit.ts create mode 100644 src/lib/zarr/temperature.test.ts create mode 100644 src/lib/zarr/temperature.ts diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 5618077..83af1ba 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -15,6 +15,7 @@ import { PlotControls, } from "@/components/panels/CellView"; import { ScaleField } from "@/components/panels/ScaleField"; +import { TemperatureUnitField } from "@/components/panels/TemperatureUnitField"; import { TimeBasisField } from "@/components/panels/TimeBasisField"; import { YearPicker } from "@/components/panels/YearPicker"; import { SeriesSummary } from "@/components/data/SeriesSummary"; @@ -77,7 +78,8 @@ export function DataView({ const plotHeight = usePlotHeight(); const wide = useSidebarLayout(); const [detailsOpen, setDetailsOpen] = useState(false); - const { selection, selectedYears, units } = series; + const { selection, selectedYears } = series; + const { units } = display; return (
+ {display.canUseLocalTime ? (
- {describeRecord(series)} The dashed box marks the{" "} + {describeRecord(series)} The dashed box shows the{" "} {Math.min(patchWindow, series.gridSpec.nativeChunks.lat)}× {Math.min(patchWindow, series.gridSpec.nativeChunks.lon)} cells - around it kept in memory. + downloaded with it, so picks inside it load at once. + {desktop || !display.canUseLocalTime ? null : ( )} @@ -262,7 +264,7 @@ export function PlotArea({ const plot = display.plotView === "line" ? ( + Temperature in + + value={display.temperatureUnit} + onChange={display.setTemperatureUnit} + options={display.temperatureUnits.map((unit) => ({ + value: unit, + label: unit, + }))} + aria-label="Temperature in" + /> +
+ ); +} diff --git a/src/hooks/useSeriesDisplay.ts b/src/hooks/useSeriesDisplay.ts index 10f293d..77bf948 100644 --- a/src/hooks/useSeriesDisplay.ts +++ b/src/hooks/useSeriesDisplay.ts @@ -15,6 +15,16 @@ import { type Palette, } from "@/lib/map/fingerprintScale"; import { loadPalette, savePalette } from "@/lib/settings/colormap"; +import { + loadTemperatureUnit, + saveTemperatureUnit, +} from "@/lib/settings/temperatureUnit"; +import { + convertTemperatures, + temperatureUnitOf, + TEMPERATURE_UNITS, + type TemperatureUnit, +} from "@/lib/zarr/temperature"; import { useTheme } from "@/providers/ThemeProvider"; import type { BinUnit } from "@/lib/plots/timeBins"; import type { LineResolution } from "@/lib/plots/lineSeries"; @@ -74,6 +84,7 @@ export function useSeriesDisplay( slotsPerDay, timeSpan, stepLabel, + units, }: Pick< CellSeries, | "selection" @@ -83,6 +94,7 @@ export function useSeriesDisplay( | "slotsPerDay" | "timeSpan" | "stepLabel" + | "units" >, requestedTimeBasis: TimeBasis, ) { @@ -131,6 +143,27 @@ export function useSeriesDisplay( savePalette(next); }; + // Temperatures can be shown in another scale; everything downstream + // (plots, summary, exports) sees the converted values and their unit. + const storedTemperature = temperatureUnitOf(units); + const [temperatureChoice, setTemperatureChoice] = + useState(() => loadTemperatureUnit()); + const temperatureUnit = storedTemperature + ? (temperatureChoice ?? storedTemperature) + : null; + const setTemperatureUnit = (next: TemperatureUnit) => { + setTemperatureChoice(next); + saveTemperatureUnit(next); + }; + const shownUnits = temperatureUnit ?? units; + const convertedValues = useMemo( + () => + values && storedTemperature && temperatureUnit + ? convertTemperatures(values, storedTemperature, temperatureUnit) + : values, + [values, storedTemperature, temperatureUnit], + ); + // Checked before the local-time shift, which blanks a few edge hours. const hasPlottableData = useMemo( () => values !== null && hasFiniteValues(values), @@ -144,18 +177,18 @@ export function useSeriesDisplay( const timeBasisLabel = formatTimeBasis(timeBasis, utcOffsetHours); const displayValues = useMemo(() => { - if (!values || timeBasis === "utc") return values; + if (!convertedValues || timeBasis === "utc") return convertedValues; const { absoluteDays } = getSelectedYearsDayMapping( selectedYears, undefined, - Math.floor(values.length / slotsPerDay), + Math.floor(convertedValues.length / slotsPerDay), ); - return shiftSeriesToLocalTime(values, { + return shiftSeriesToLocalTime(convertedValues, { hoursPerDay: slotsPerDay, offsetHours: utcOffsetSlots, absoluteDays, }); - }, [values, selectedYears, timeBasis, utcOffsetSlots, slotsPerDay]); + }, [convertedValues, selectedYears, timeBasis, utcOffsetSlots, slotsPerDay]); return { plotView, @@ -178,7 +211,13 @@ export function useSeriesDisplay( colormapId, hasPlottableData, isEmptyCell, + /** The series in the shown unit, on the UTC clock. */ + values: convertedValues, displayValues, + units: shownUnits, + temperatureUnit, + temperatureUnits: temperatureUnit ? TEMPERATURE_UNITS : [], + setTemperatureUnit, }; } diff --git a/src/lib/settings/temperatureUnit.ts b/src/lib/settings/temperatureUnit.ts new file mode 100644 index 0000000..da22101 --- /dev/null +++ b/src/lib/settings/temperatureUnit.ts @@ -0,0 +1,23 @@ +import { TEMPERATURE_UNITS, type TemperatureUnit } from "@/lib/zarr/temperature"; + +export const TEMPERATURE_UNIT_STORAGE_KEY = "earthprints:temperature-unit"; + +/** Read the stored temperature unit preference, or `null` if none. */ +export function loadTemperatureUnit(): TemperatureUnit | null { + if (typeof window === "undefined") return null; + try { + const stored = localStorage.getItem(TEMPERATURE_UNIT_STORAGE_KEY); + return TEMPERATURE_UNITS.find((unit) => unit === stored) ?? null; + } catch { + // Private-browsing or quota errors; ignore. + return null; + } +} + +export function saveTemperatureUnit(unit: TemperatureUnit): void { + try { + localStorage.setItem(TEMPERATURE_UNIT_STORAGE_KEY, unit); + } catch { + // Ignore storage errors. + } +} diff --git a/src/lib/zarr/temperature.test.ts b/src/lib/zarr/temperature.test.ts new file mode 100644 index 0000000..5d1a613 --- /dev/null +++ b/src/lib/zarr/temperature.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { convertTemperatures, temperatureUnitOf } from "@/lib/zarr/temperature"; + +describe("temperatureUnitOf", () => { + it("reads CF and common spellings", () => { + expect(temperatureUnitOf("K")).toBe("K"); + expect(temperatureUnitOf("kelvin")).toBe("K"); + expect(temperatureUnitOf("degC")).toBe("°C"); + expect(temperatureUnitOf("degrees_Celsius")).toBe("°C"); + expect(temperatureUnitOf("degF")).toBe("°F"); + }); + + it("ignores other units", () => { + expect(temperatureUnitOf("kg m-2 s-1")).toBeNull(); + expect(temperatureUnitOf("Pa")).toBeNull(); + expect(temperatureUnitOf(null)).toBeNull(); + }); +}); + +describe("convertTemperatures", () => { + it("converts between the three scales", () => { + const kelvin = Float32Array.of(273.15, 373.15); + const celsius = convertTemperatures(kelvin, "K", "°C"); + expect(celsius[0]).toBeCloseTo(0, 3); + expect(celsius[1]).toBeCloseTo(100, 3); + const fahrenheit = convertTemperatures(kelvin, "K", "°F"); + expect(fahrenheit[0]).toBeCloseTo(32, 3); + expect(fahrenheit[1]).toBeCloseTo(212, 3); + expect(convertTemperatures(Float32Array.of(32), "°F", "°C")[0]).toBeCloseTo(0, 4); + }); + + it("keeps gaps and returns the input when nothing changes", () => { + const values = Float32Array.of(NaN, 280); + expect(Number.isNaN(convertTemperatures(values, "K", "°C")[0])).toBe(true); + expect(convertTemperatures(values, "K", "K")).toBe(values); + }); +}); diff --git a/src/lib/zarr/temperature.ts b/src/lib/zarr/temperature.ts new file mode 100644 index 0000000..020b6c2 --- /dev/null +++ b/src/lib/zarr/temperature.ts @@ -0,0 +1,58 @@ +export type TemperatureUnit = "K" | "°C" | "°F"; + +export const TEMPERATURE_UNITS: TemperatureUnit[] = ["K", "°C", "°F"]; + +// CF and common spellings of each scale, compared lowercased. +const SPELLINGS: Record = { + K: ["k", "kelvin", "kelvins", "degk", "deg_k", "degrees_k"], + "°C": [ + "°c", + "degc", + "deg_c", + "deg c", + "celsius", + "degree_celsius", + "degrees_celsius", + "degree celsius", + "degrees celsius", + ], + "°F": [ + "°f", + "degf", + "deg_f", + "fahrenheit", + "degree_fahrenheit", + "degrees_fahrenheit", + ], +}; + +/** The temperature scale a units string names, or null for anything else. */ +export function temperatureUnitOf(units: string | null | undefined): TemperatureUnit | null { + if (!units) return null; + const key = units.trim().toLowerCase(); + for (const unit of TEMPERATURE_UNITS) { + if (SPELLINGS[unit].includes(key)) return unit; + } + return null; +} + +function toKelvin(value: number, from: TemperatureUnit): number { + if (from === "°C") return value + 273.15; + if (from === "°F") return (value - 32) * (5 / 9) + 273.15; + return value; +} + +function fromKelvin(value: number, to: TemperatureUnit): number { + if (to === "°C") return value - 273.15; + if (to === "°F") return (value - 273.15) * (9 / 5) + 32; + return value; +} + +export function convertTemperatures( + values: Float32Array, + from: TemperatureUnit, + to: TemperatureUnit, +): Float32Array { + if (from === to) return values; + return values.map((value) => fromKelvin(toKelvin(value, from), to)); +} From f83e4f75f31f3cbb5502d5d4d60bd5a4f8e707e6 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Mon, 2 Nov 2026 15:46:32 +0200 Subject: [PATCH 32/32] Name private stores in the reach error and space the line's x labels --- src/components/plots/TimeSeriesPlot.tsx | 1 + src/lib/zarr/dataset.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx index fcb5cd9..bb4f58a 100644 --- a/src/components/plots/TimeSeriesPlot.tsx +++ b/src/components/plots/TimeSeriesPlot.tsx @@ -204,6 +204,7 @@ export function TimeSeriesPlot({ interval="preserveStartEnd" tickLine={false} axisLine={false} + tickMargin={8} />