Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 41 additions & 14 deletions src/components/map/EarthMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import {
import { openZarrStore } from "@/lib/zarr/store";
import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader";
import { DEFAULT_HISTORY_YEARS } from "@/lib/zarr/timeRange";
import type { MapSelection, MapViewMode, MapViewState } from "@/types/map";
import { DEFAULT_GRID_SPEC } from "@/lib/constants/store";
import type {
GridSpec,
MapSelection,
MapViewMode,
MapViewState,
} from "@/types/map";
import { useTheme } from "@/providers/ThemeProvider";
import {
EditorShell,
Expand Down Expand Up @@ -73,10 +79,23 @@ export function EarthMap() {
const [seriesError, setSeriesError] = useState<string | null>(null);
const [seriesValues, setSeriesValues] = useState<Float32Array | null>(null);
const [seriesUnits, setSeriesUnits] = useState<string | null>(null);
const [gridSpec, setGridSpec] = useState<GridSpec>(DEFAULT_GRID_SPEC);

const isSphere = viewMode === "sphere";
const mapStyle = isLight ? MAP_BASE_STYLES.light : MAP_BASE_STYLES.dark;

const ensureReader = useCallback(() => {
if (!readerPromiseRef.current) {
readerPromiseRef.current = openZarrStore()
.then((ds) => new ZarrChunkReader(ds))
.catch((error) => {
readerPromiseRef.current = null;
throw error;
});
}
return readerPromiseRef.current;
}, []);

useEffect(() => {
const node = mapStageRef.current;
if (!node) return;
Expand All @@ -91,6 +110,21 @@ export function EarthMap() {
return () => observer.disconnect();
}, []);

useEffect(() => {
let cancelled = false;
ensureReader()
.then((reader) => reader.getGridSpec())
.then((spec) => {
if (!cancelled) setGridSpec(spec);
})
.catch(() => {
// Keep DEFAULT_GRID_SPEC on failure; time-series loads surface errors.
});
return () => {
cancelled = true;
};
}, [ensureReader]);

const loadTimeSeries = useCallback(
async (nextSelection: MapSelection, years: number) => {
const requestId = ++requestIdRef.current;
Expand All @@ -101,16 +135,7 @@ export function EarthMap() {
setSeriesUnits(null);

try {
if (!readerPromiseRef.current) {
readerPromiseRef.current = openZarrStore()
.then((ds) => new ZarrChunkReader(ds))
.catch((error) => {
readerPromiseRef.current = null;
throw error;
});
}

const reader = await readerPromiseRef.current;
const reader = await ensureReader();

const { values, units } = await reader.getTimeSeries(
nextSelection.grid,
Expand Down Expand Up @@ -140,7 +165,7 @@ export function EarthMap() {
}
}
},
[],
[ensureReader],
);

const flyToView = useCallback((next: MapViewState, duration = 0) => {
Expand Down Expand Up @@ -168,7 +193,7 @@ export function EarthMap() {
(lon: number, lat: number) => {
const nextSelection: MapSelection = {
click: { lon, lat },
grid: geoPointToZarrGrid({ lon, lat }),
grid: geoPointToZarrGrid({ lon, lat }, gridSpec),
};

setSelection(nextSelection);
Expand All @@ -180,7 +205,7 @@ export function EarthMap() {
flyToView(focused, SELECTION_FOCUS_TRANSITION_MS);
void loadTimeSeries(nextSelection, historyYears);
},
[flyToView, historyYears, loadTimeSeries, viewMode, viewState],
[flyToView, gridSpec, historyYears, loadTimeSeries, viewMode, viewState],
);

const handleMapClick = useCallback(
Expand Down Expand Up @@ -260,6 +285,7 @@ export function EarthMap() {
sidebar={
<MapReadout
selection={selection}
gridSpec={gridSpec}
historyYears={historyYears}
onHistoryYearsChange={handleHistoryYearsChange}
loadingSeries={loadingSeries}
Expand Down Expand Up @@ -302,6 +328,7 @@ export function EarthMap() {
{selection && mapSize.width > 0 && mapSize.height > 0 ? (
<GlobeSelectionOverlay
cell={selection.grid}
gridSpec={gridSpec}
viewState={viewState}
mapSize={mapSize}
isLight={isLight}
Expand Down
18 changes: 13 additions & 5 deletions src/components/map/GlobeSelectionOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ import {
gridCellToGuidePaths,
selectionGuideGeoJson,
} from "@/lib/map/geogrid";
import { ZARR_STORE } from "@/lib/constants/store";
import { DEFAULT_GRID_SPEC } from "@/lib/constants/store";
import {
rgba,
SELECTION_CELL_COLOR,
SELECTION_GUIDE_COLOR,
SELECTION_PATCH_COLOR,
} from "@/lib/map/selectionStyle";
import { viewportToGeoBounds } from "@/lib/map/viewportBounds";
import type { GridCell, MapViewState } from "@/types/map";
import type { GridCell, GridSpec, MapViewState } from "@/types/map";

type GlobeSelectionOverlayProps = {
cell: GridCell;
gridSpec?: GridSpec;
viewState: MapViewState;
mapSize: { width: number; height: number };
isLight: boolean;
Expand All @@ -29,6 +30,7 @@ type GlobeSelectionOverlayProps = {

export function GlobeSelectionOverlay({
cell,
gridSpec = DEFAULT_GRID_SPEC,
viewState,
mapSize,
isLight,
Expand All @@ -37,16 +39,22 @@ export function GlobeSelectionOverlay({
}: GlobeSelectionOverlayProps) {
const data = useMemo(() => {
const guidePaths = gridCellToGuidePaths(
gridCellToBounds(cell),
gridCellToBounds(cell, gridSpec),
viewportToGeoBounds(viewState, mapSize.width, mapSize.height),
);
return selectionGuideGeoJson(cell, guidePaths, {
densifyGuides: isSphere,
spec: gridSpec,
patchBounds: showPatch
? chunkPatchBounds(cell, ZARR_STORE.nativeChunks.lat, ZARR_STORE.nativeChunks.lon)
? chunkPatchBounds(
cell,
gridSpec.nativeChunks.lat,
gridSpec.nativeChunks.lon,
gridSpec,
)
: null,
});
}, [cell, isSphere, mapSize.height, mapSize.width, showPatch, viewState]);
}, [cell, gridSpec, isSphere, mapSize.height, mapSize.width, showPatch, viewState]);

const guideColor = rgba(
isLight ? SELECTION_GUIDE_COLOR.light : SELECTION_GUIDE_COLOR.dark,
Expand Down
12 changes: 7 additions & 5 deletions src/components/map/MapReadout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import type { MapSelection } from "@/types/map";
import type { GridSpec, MapSelection } from "@/types/map";
import { formatCoordinate, formatGeoPoint } from "@/lib/map/geogrid";
import { ZARR_STORE } from "@/lib/constants/store";
import { DEFAULT_GRID_SPEC, ZARR_STORE } from "@/lib/constants/store";
import { ZARR_TIME } from "@/lib/zarr/timeRange";
import { TimeSeriesPlot } from "@/components/map/TimeSeriesPlot";
import { TimeSeriesPlotLoading } from "@/components/map/TimeSeriesPlotLoading";
Expand All @@ -12,6 +12,7 @@ type SeriesProgress = { loaded: number; total: number };

type MapReadoutProps = {
selection: MapSelection | null;
gridSpec?: GridSpec;
historyYears: number;
onHistoryYearsChange: (years: number) => void;
loadingSeries: boolean;
Expand All @@ -26,6 +27,7 @@ const META = "font-mono text-[11px] text-editor-fg-tertiary";

export function MapReadout({
selection,
gridSpec = DEFAULT_GRID_SPEC,
historyYears,
onHistoryYearsChange,
loadingSeries,
Expand Down Expand Up @@ -56,8 +58,8 @@ export function MapReadout({
);
}

const patchCells = ZARR_STORE.nativeChunks.lon;
const patchDeg = patchCells * ZARR_STORE.spatialResolutionDeg;
const patchCells = gridSpec.nativeChunks.lon;
const patchDeg = patchCells * gridSpec.spatialResolutionDeg;

const historyControl = (
<section>
Expand Down Expand Up @@ -122,7 +124,7 @@ export function MapReadout({
</h2>
<p className="mt-2 text-[12.5px] leading-[1.5] text-editor-fg-tertiary">
{ZARR_STORE.kicker} · snapped to the nearest{" "}
{ZARR_STORE.spatialResolutionDeg}° cell
{gridSpec.spatialResolutionDeg}° cell
</p>
<p className="mt-2 text-[12.5px] leading-[1.5] text-editor-fg-tertiary">
Each click downloads a {patchCells}×{patchCells} patch ({patchDeg}° ×{" "}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/constants/store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { GridSpec } from "@/types/map";

/** ESDC v3 low-resolution test cube (2.5° × 16-day). */
export const ZARR_STORE = {
id: "nee-d-0.05deg",
Expand Down Expand Up @@ -32,3 +34,15 @@ export const ZARR_STORE = {
} as const;

export type ZarrDataset = typeof ZARR_STORE;

/**
* Fallback grid used for first paint and whenever deriving the spec from the
* remote store fails. Mirrors the hardcoded `ZARR_STORE` values; the live spec
* is fetched at runtime by `@/lib/zarr/gridSpec`.
*/
export const DEFAULT_GRID_SPEC: GridSpec = {
grid: { ...ZARR_STORE.grid },
dimensions: { ...ZARR_STORE.dimensions },
nativeChunks: { ...ZARR_STORE.nativeChunks },
spatialResolutionDeg: ZARR_STORE.spatialResolutionDeg,
};
36 changes: 26 additions & 10 deletions src/lib/map/geogrid.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ZARR_STORE } from "@/lib/constants/store";
import type { GeoPoint, GridCell } from "@/types/map";

const { grid, dimensions } = ZARR_STORE;
import { DEFAULT_GRID_SPEC } from "@/lib/constants/store";
import type { GeoPoint, GridCell, GridSpec } from "@/types/map";

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
Expand All @@ -19,7 +17,11 @@ function snapToAxis(value: number, start: number, step: number, count: number):
}

/** Snap a WGS-84 point to the nearest Zarr grid cell center. */
export function geoPointToZarrGrid(point: GeoPoint): GridCell {
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);

Expand All @@ -39,7 +41,11 @@ export type GridCellBounds = {
};

/** Half-open cell edges around a snapped cell center. */
export function gridCellToBounds(cell: GridCell): GridCellBounds {
export function gridCellToBounds(
cell: GridCell,
spec: GridSpec = DEFAULT_GRID_SPEC,
): GridCellBounds {
const { grid } = spec;
const halfLon = Math.abs(grid.lonStep) / 2;
const halfLat = Math.abs(grid.latStep) / 2;

Expand All @@ -64,8 +70,11 @@ export function boundsToPolygon(bounds: GridCellBounds): GeoPoint[] {
}

/** Closed lon/lat ring for the cell footprint (counter-clockwise). */
export function gridCellToPolygon(cell: GridCell): GeoPoint[] {
return boundsToPolygon(gridCellToBounds(cell));
export function gridCellToPolygon(
cell: GridCell,
spec: GridSpec = DEFAULT_GRID_SPEC,
): GeoPoint[] {
return boundsToPolygon(gridCellToBounds(cell, spec));
}

/**
Expand All @@ -77,7 +86,9 @@ export function chunkPatchBounds(
cell: GridCell,
chunkLat: number,
chunkLon: number,
spec: GridSpec = DEFAULT_GRID_SPEC,
): GridCellBounds {
const { grid, dimensions } = spec;
const firstLatIdx = Math.floor(cell.latIndex / chunkLat) * chunkLat;
const lastLatIdx = Math.min(firstLatIdx + chunkLat, dimensions.lat) - 1;
const firstLonIdx = Math.floor(cell.lonIndex / chunkLon) * chunkLon;
Expand Down Expand Up @@ -179,10 +190,15 @@ function closedRing(corners: GeoPoint[]): LonLatPath {
export function selectionGuideGeoJson(
cell: GridCell,
guidePaths: LonLatPath[],
options?: { densifyGuides?: boolean; patchBounds?: GridCellBounds | null },
options?: {
densifyGuides?: boolean;
patchBounds?: GridCellBounds | null;
spec?: GridSpec;
},
): SelectionGuideGeoJson {
const densifyGuides = options?.densifyGuides ?? true;
const patchBounds = options?.patchBounds ?? null;
const spec = options?.spec ?? DEFAULT_GRID_SPEC;

return {
type: "FeatureCollection",
Expand Down Expand Up @@ -210,7 +226,7 @@ export function selectionGuideGeoJson(
{
type: "Feature",
properties: { kind: "cell" },
geometry: { type: "Polygon", coordinates: [closedRing(gridCellToPolygon(cell))] },
geometry: { type: "Polygon", coordinates: [closedRing(gridCellToPolygon(cell, spec))] },
},
],
};
Expand Down
16 changes: 15 additions & 1 deletion src/lib/zarr/ZarrChunkReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
type ZarrArrayHandle,
type ZarrStore,
} from "@/lib/zarr/store";
import type { GridCell } from "@/types/map";
import { deriveGridSpec } from "@/lib/zarr/gridSpec";
import type { GridCell, GridSpec } from "@/types/map";

type CachedNativeChunk = {
data: Float32Array;
Expand All @@ -46,13 +47,26 @@ export class ZarrChunkReader {
private arrayPromises = new Map<string, Promise<ZarrArray>>();
private chunkLoadsInFlight = new Map<string, Promise<CachedNativeChunk>>();
private prefetchInFlight = new Map<string, Promise<void>>();
private gridSpecPromise?: Promise<GridSpec>;

/** Default holds ~8 pixels of full history (6 native chunks per pixel). */
constructor(ds: ZarrStore, maxCacheSize = 48) {
this.ds = ds;
this.cache = new LRUCache(maxCacheSize);
}

/**
* Derive the dataset's spatial grid from store metadata, memoized so the
* 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<GridSpec> {
if (!this.gridSpecPromise) {
this.gridSpecPromise = deriveGridSpec(this.ds, variable);
}
return this.gridSpecPromise;
}

private getArray(variable: string): Promise<ZarrArray> {
const existing = this.arrayPromises.get(variable);
if (existing) return existing;
Expand Down
Loading
Loading