Skip to content

Commit fc290ec

Browse files
Anastasiia Ivanchenkocursoragent
andcommitted
Default pixel fetches to the last year and add a history slider.
Slice Zarr reads by the selected window for faster first loads, and harden background prefetch against unhandled rejections. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a9ff465 commit fc290ec

8 files changed

Lines changed: 327 additions & 58 deletions

File tree

src/app/globals.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,47 @@ button {
727727
line-height: 1.55;
728728
}
729729

730+
.map-readout-control {
731+
display: grid;
732+
gap: 10px;
733+
margin-top: 16px;
734+
padding-top: 14px;
735+
border-top: 1px solid var(--border-strong);
736+
}
737+
738+
.map-readout-control-header {
739+
display: flex;
740+
align-items: baseline;
741+
justify-content: space-between;
742+
gap: 12px;
743+
}
744+
745+
.map-readout-control-label {
746+
font-size: 12px;
747+
letter-spacing: 0.06em;
748+
text-transform: uppercase;
749+
color: var(--text-muted);
750+
font-weight: 500;
751+
}
752+
753+
.map-readout-control-value {
754+
font-size: 13px;
755+
color: var(--accent);
756+
font-weight: 600;
757+
}
758+
759+
.map-readout-slider {
760+
width: 100%;
761+
accent-color: var(--accent);
762+
cursor: pointer;
763+
}
764+
765+
.map-readout-control-hint {
766+
color: var(--text-dim);
767+
font-size: 12px;
768+
line-height: 1.45;
769+
}
770+
730771
.map-readout-grid {
731772
display: grid;
732773
gap: 12px;

src/components/map/EarthMap.tsx

Lines changed: 60 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TEAL_ON_DARK_RGB } from "@/lib/constants/theme";
1111
import { DEFAULT_MAP_VIEW, MAP_BASE_STYLES } from "@/lib/map/viewState";
1212
import { openZarrStore } from "@/lib/zarr/store";
1313
import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader";
14+
import { DEFAULT_HISTORY_YEARS } from "@/lib/zarr/timeRange";
1415
import type { MapSelection } from "@/types/map";
1516
import { useTheme } from "@/providers/ThemeProvider";
1617
import { MapReadout } from "@/components/map/MapReadout";
@@ -25,54 +26,70 @@ export function EarthMap({ className }: EarthMapProps) {
2526
const requestIdRef = useRef(0);
2627

2728
const [selection, setSelection] = useState<MapSelection | null>(null);
29+
const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS);
2830
const [loadingSeries, setLoadingSeries] = useState(false);
2931
const [seriesError, setSeriesError] = useState<string | null>(null);
3032
const [seriesLength, setSeriesLength] = useState<number | null>(null);
3133
const [seriesPreview, setSeriesPreview] = useState<number[] | null>(null);
3234
const [seriesUnits, setSeriesUnits] = useState<string | null>(null);
3335

34-
const loadTimeSeries = useCallback(async (nextSelection: MapSelection) => {
35-
const requestId = ++requestIdRef.current;
36-
setLoadingSeries(true);
37-
setSeriesError(null);
38-
setSeriesLength(null);
39-
setSeriesPreview(null);
40-
setSeriesUnits(null);
41-
42-
try {
43-
if (!readerPromiseRef.current) {
44-
readerPromiseRef.current = openZarrStore()
45-
.then((ds) => new ZarrChunkReader(ds))
46-
.catch((error) => {
47-
readerPromiseRef.current = null;
48-
throw error;
49-
});
36+
const loadTimeSeries = useCallback(
37+
async (nextSelection: MapSelection, years: number) => {
38+
const requestId = ++requestIdRef.current;
39+
setLoadingSeries(true);
40+
setSeriesError(null);
41+
setSeriesLength(null);
42+
setSeriesPreview(null);
43+
setSeriesUnits(null);
44+
45+
try {
46+
if (!readerPromiseRef.current) {
47+
readerPromiseRef.current = openZarrStore()
48+
.then((ds) => new ZarrChunkReader(ds))
49+
.catch((error) => {
50+
readerPromiseRef.current = null;
51+
throw error;
52+
});
53+
}
54+
55+
const reader = await readerPromiseRef.current;
56+
57+
const { values, units } = await reader.getTimeSeries(
58+
nextSelection.grid,
59+
undefined,
60+
years,
61+
);
62+
63+
if (requestId !== requestIdRef.current) return;
64+
65+
setSeriesLength(values.length);
66+
setSeriesPreview(Array.from(values.subarray(0, 3)));
67+
setSeriesUnits(units ?? null);
68+
} catch (error) {
69+
if (requestId !== requestIdRef.current) return;
70+
setSeriesError(
71+
error instanceof Error
72+
? error.message
73+
: "Could not load the Zarr time series.",
74+
);
75+
} finally {
76+
if (requestId === requestIdRef.current) {
77+
setLoadingSeries(false);
78+
}
5079
}
80+
},
81+
[],
82+
);
5183

52-
const reader = await readerPromiseRef.current;
53-
54-
const { values, units } = await reader.getTimeSeries(
55-
nextSelection.grid,
56-
);
57-
58-
if (requestId !== requestIdRef.current) return;
59-
60-
setSeriesLength(values.length);
61-
setSeriesPreview(Array.from(values.subarray(0, 3)));
62-
setSeriesUnits(units ?? null);
63-
} catch (error) {
64-
if (requestId !== requestIdRef.current) return;
65-
setSeriesError(
66-
error instanceof Error
67-
? error.message
68-
: "Could not load the Zarr time series.",
69-
);
70-
} finally {
71-
if (requestId === requestIdRef.current) {
72-
setLoadingSeries(false);
84+
const handleHistoryYearsChange = useCallback(
85+
(years: number) => {
86+
setHistoryYears(years);
87+
if (selection) {
88+
void loadTimeSeries(selection, years);
7389
}
74-
}
75-
}, []);
90+
},
91+
[selection, loadTimeSeries],
92+
);
7693

7794
const handleClick = useCallback(
7895
(info: PickingInfo) => {
@@ -85,9 +102,9 @@ export function EarthMap({ className }: EarthMapProps) {
85102
};
86103

87104
setSelection(nextSelection);
88-
void loadTimeSeries(nextSelection);
105+
void loadTimeSeries(nextSelection, historyYears);
89106
},
90-
[loadTimeSeries],
107+
[historyYears, loadTimeSeries],
91108
);
92109

93110
const layers = useMemo(() => {
@@ -130,6 +147,8 @@ export function EarthMap({ className }: EarthMapProps) {
130147

131148
<MapReadout
132149
selection={selection}
150+
historyYears={historyYears}
151+
onHistoryYearsChange={handleHistoryYearsChange}
133152
loadingSeries={loadingSeries}
134153
seriesError={seriesError}
135154
seriesLength={seriesLength}

src/components/map/MapReadout.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import type { MapSelection } from "@/types/map";
44
import { formatGeoPoint } from "@/lib/map/geogrid";
55
import { ZARR_STORE } from "@/lib/constants/store";
6+
import { ZARR_TIME } from "@/lib/zarr/timeRange";
67

78
type MapReadoutProps = {
89
selection: MapSelection | null;
10+
historyYears: number;
11+
onHistoryYearsChange: (years: number) => void;
912
loadingSeries: boolean;
1013
seriesError: string | null;
1114
seriesLength: number | null;
@@ -15,12 +18,17 @@ type MapReadoutProps = {
1518

1619
export function MapReadout({
1720
selection,
21+
historyYears,
22+
onHistoryYearsChange,
1823
loadingSeries,
1924
seriesError,
2025
seriesLength,
2126
seriesPreview,
2227
seriesUnits,
2328
}: MapReadoutProps) {
29+
const historyLabel =
30+
historyYears === 1 ? "Last 1 year" : `Last ${historyYears} years`;
31+
2432
return (
2533
<aside className="map-readout" aria-live="polite">
2634
<p className="map-readout-kicker mono">{ZARR_STORE.kicker}</p>
@@ -30,6 +38,30 @@ export function MapReadout({
3038
grid used by the Zarr Store.
3139
</p>
3240

41+
<div className="map-readout-control">
42+
<div className="map-readout-control-header">
43+
<label className="map-readout-control-label" htmlFor="history-years">
44+
History window
45+
</label>
46+
<span className="map-readout-control-value mono">{historyLabel}</span>
47+
</div>
48+
<input
49+
id="history-years"
50+
className="map-readout-slider"
51+
type="range"
52+
min={ZARR_TIME.defaultHistoryYears}
53+
max={ZARR_TIME.maxHistoryYears}
54+
step={1}
55+
value={historyYears}
56+
onChange={(event) =>
57+
onHistoryYearsChange(Number(event.currentTarget.value))
58+
}
59+
/>
60+
<p className="map-readout-control-hint">
61+
Fetch only the most recent window to keep pixel loads fast.
62+
</p>
63+
</div>
64+
3365
{selection ? (
3466
<dl className="map-readout-grid mono">
3567
<div>

src/lib/zarr/ZarrChunkReader.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ import {
55
extractPixelFromNativeChunk,
66
nativeChunkKey,
77
pixelToNativeChunkContext,
8-
stitchTimeSeries,
8+
stitchTimeSeriesForRange,
99
type ArrayChunkSizes,
10+
type AxisSlice,
1011
type LocalOffset,
1112
type PixelNativeChunkContext,
1213
} from "@/lib/zarr/chunks";
14+
import {
15+
chunkIndexToStartDay,
16+
DEFAULT_HISTORY_YEARS,
17+
yearsToDayRange,
18+
} from "@/lib/zarr/timeRange";
1319
import {
1420
fetchPixelTimeSeries,
1521
type ZarrArrayHandle,
@@ -117,10 +123,6 @@ export class ZarrChunkReader {
117123
this.cache.set(key, entry);
118124
return entry;
119125
})
120-
.catch((error: unknown) => {
121-
this.chunkLoadsInFlight.delete(key);
122-
throw error;
123-
})
124126
.finally(() => {
125127
this.chunkLoadsInFlight.delete(key);
126128
});
@@ -132,6 +134,9 @@ export class ZarrChunkReader {
132134
private buildFromNativeCache(
133135
variable: string,
134136
context: PixelNativeChunkContext,
137+
timeRange: AxisSlice,
138+
hourCount: number,
139+
chunkTime: number,
135140
units?: string,
136141
): { values: Float32Array; variable: string; units?: string } {
137142
const localOffset: LocalOffset = {
@@ -145,15 +150,18 @@ export class ZarrChunkReader {
145150
this.nativeCoords(context, timeChunkIdx),
146151
);
147152
const chunk = this.cache.get(key)!;
148-
return extractPixelFromNativeChunk(
149-
chunk.data,
150-
chunk.shape,
151-
localOffset,
152-
);
153+
return {
154+
values: extractPixelFromNativeChunk(
155+
chunk.data,
156+
chunk.shape,
157+
localOffset,
158+
),
159+
chunkStartDay: chunkIndexToStartDay(timeChunkIdx, chunkTime),
160+
};
153161
});
154162

155163
return {
156-
values: stitchTimeSeries(segments),
164+
values: stitchTimeSeriesForRange(segments, timeRange, hourCount),
157165
variable,
158166
units,
159167
};
@@ -187,6 +195,9 @@ export class ZarrChunkReader {
187195
),
188196
)
189197
.then(() => undefined)
198+
.catch(() => {
199+
// Prefetch is best-effort; explicit requests retry failed chunks.
200+
})
190201
.finally(() => {
191202
this.prefetchInFlight.delete(prefetchKey);
192203
});
@@ -197,24 +208,34 @@ export class ZarrChunkReader {
197208
async getTimeSeries(
198209
grid: GridCell,
199210
variable = ZARR_STORE.defaultVariable,
211+
historyYears?: number,
200212
): Promise<{ values: Float32Array; variable: string; units?: string }> {
201213
const array = await this.getArray(variable);
202214
const chunkSizes = this.getChunkSizes(array);
203-
const [timeCount] = array.shape;
215+
const [timeCount, hourCount] = array.shape;
216+
const timeRange = yearsToDayRange(historyYears ?? DEFAULT_HISTORY_YEARS, timeCount);
204217
const context = pixelToNativeChunkContext(
205218
grid.latIndex,
206219
grid.lonIndex,
207220
timeCount,
208221
chunkSizes,
222+
timeRange,
209223
);
210224
const units =
211225
typeof array.attrs.units === "string" ? array.attrs.units : undefined;
212226

213227
if (this.hasAllNativeChunks(variable, context)) {
214-
return this.buildFromNativeCache(variable, context, units);
228+
return this.buildFromNativeCache(
229+
variable,
230+
context,
231+
timeRange,
232+
hourCount,
233+
chunkSizes.time,
234+
units,
235+
);
215236
}
216237

217-
const pixel = await fetchPixelTimeSeries(array, grid, variable);
238+
const pixel = await fetchPixelTimeSeries(array, grid, variable, timeRange);
218239
this.prefetchNativeChunks(array, variable, context);
219240
return pixel;
220241
}

0 commit comments

Comments
 (0)