Skip to content

Commit f90fd5e

Browse files
aivanchenkAnastasiia Ivanchenko
andauthored
Add loading progress bar for time series fetch (#26) (#56)
* Add loading progress bar for time series fetch (#26) (#50) * Add loading progress bar for the pixel time series fetch (#26) Introduce a reusable ProgressBar (src/components/ui/ProgressBar.tsx) and show it in the sidebar readout while a pixel time series is downloading. It runs in indeterminate mode: the pixel fetch is a single slice request with no per-chunk hook and no known total, so an honest percentage is not available without a byte-streaming store wrapper. The bar therefore shows real activity via a sweeping accent segment rather than a fabricated percent. The component already accepts a `value` prop, so a determinate mode can be wired later if we add that wrapper. Styling uses the editor/accent design tokens with a reduced-motion fallback. Closes #26 once merged. * Rework left panel into a flat inspector with a real progress percentage Redesign the sidebar readout away from three stacked bordered cards into one continuous inspector split by hairline rules: - The clicked coordinate leads, the daily-mean chart is the hero, grid cell and array index drop to a quiet two-up row, history control sits at the foot. - Inviting empty state before a point is picked. Replace the indeterminate loader with a determinate percentage. The reader now fetches the pixel series one time-chunk at a time in parallel and reports completed-of-total as each chunk arrives, so the bar shows a real "n of N chunks · X%". Segments are stitched back in chunk order, so the values are identical to the previous single-slice fetch and the same bytes are downloaded. Falls back to the animated bar until the first count arrives. The chart skeleton becomes a decorative axis frame so the percentage is the single loading status. * Fix stuck progress with byte-level percentage; reorder sidebar sections Address panel feedback: - Move the history-window control above the chart, and group it with the chart so there is no divider between them. - Vertically center the empty "Click the map" state. - The percentage was stuck at 0 because the default window is a single time-chunk, so chunk-counting only ever read 0 of 1. Progress is now byte-level: a custom fetch on the zarr store streams each chunk response and reports loaded/total bytes, so the bar ramps smoothly and shows a real percent plus a MB readout. If a response has no Content-Length it falls back to the animated bar, never blocking the fetch. Verified against the live store that the percent passes through intermediate values and ends at 100%. This reverts the earlier per-time-chunk fetch back to the single-slice pixel fetch, since byte progress no longer needs it. * Harden ProgressBar: drop aria-busy, spread div props, guard NaN (#26) --------- Co-authored-by: Anastasiia Ivanchenko <a.ivanchenko.eu@gmai.com> * Bind byte-progress sink per request to fix concurrency races Capture the active sink synchronously at the start of progressFetch so an in-flight fetch keeps reporting to the request that started it even if a newer request swaps the global sink in mid-await. Guard the reader's cleanup with an identity check via getActiveByteSink so an older request's finally block cannot clear a newer request's active sink. Addresses review feedback on #26. --------- Co-authored-by: Anastasiia Ivanchenko <a.ivanchenko.eu@gmai.com>
1 parent 4f1c9a6 commit f90fd5e

8 files changed

Lines changed: 371 additions & 102 deletions

File tree

src/app/globals.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,27 @@
193193
transform: scale(1);
194194
}
195195
}
196+
197+
/* Indeterminate progress: a segment sweeping across the track while a
198+
download is in flight. We show motion rather than a fabricated percent,
199+
since the pixel fetch is a single slice request with no per-chunk hook. */
200+
@keyframes progressIndeterminate {
201+
from {
202+
transform: translateX(-100%);
203+
}
204+
to {
205+
transform: translateX(250%);
206+
}
207+
}
208+
209+
.progress-bar-indeterminate {
210+
animation: progressIndeterminate 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
211+
}
212+
213+
@media (prefers-reduced-motion: reduce) {
214+
.progress-bar-indeterminate {
215+
animation: none;
216+
width: 100%;
217+
opacity: 0.55;
218+
}
219+
}

src/components/map/EarthMap.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export function EarthMap() {
6060
const [showPatch, setShowPatch] = useState(true);
6161
const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS);
6262
const [loadingSeries, setLoadingSeries] = useState(false);
63+
const [seriesProgress, setSeriesProgress] = useState<{
64+
loaded: number;
65+
total: number;
66+
} | null>(null);
6367
const [seriesError, setSeriesError] = useState<string | null>(null);
6468
const [seriesValues, setSeriesValues] = useState<Float32Array | null>(null);
6569
const [seriesUnits, setSeriesUnits] = useState<string | null>(null);
@@ -85,6 +89,7 @@ export function EarthMap() {
8589
async (nextSelection: MapSelection, years: number) => {
8690
const requestId = ++requestIdRef.current;
8791
setLoadingSeries(true);
92+
setSeriesProgress(null);
8893
setSeriesError(null);
8994
setSeriesValues(null);
9095
setSeriesUnits(null);
@@ -105,6 +110,10 @@ export function EarthMap() {
105110
nextSelection.grid,
106111
undefined,
107112
years,
113+
(loaded, total) => {
114+
if (requestId !== requestIdRef.current) return;
115+
setSeriesProgress({ loaded, total });
116+
},
108117
);
109118

110119
if (requestId !== requestIdRef.current) return;
@@ -121,6 +130,7 @@ export function EarthMap() {
121130
} finally {
122131
if (requestId === requestIdRef.current) {
123132
setLoadingSeries(false);
133+
setSeriesProgress(null);
124134
}
125135
}
126136
},
@@ -241,6 +251,7 @@ export function EarthMap() {
241251
historyYears={historyYears}
242252
onHistoryYearsChange={handleHistoryYearsChange}
243253
loadingSeries={loadingSeries}
254+
seriesProgress={seriesProgress}
244255
seriesError={seriesError}
245256
seriesValues={seriesValues}
246257
seriesUnits={seriesUnits}

src/components/map/MapReadout.tsx

Lines changed: 149 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,196 @@
11
"use client";
22

33
import type { MapSelection } from "@/types/map";
4-
import { formatGeoPoint } from "@/lib/map/geogrid";
4+
import { formatCoordinate, formatGeoPoint } from "@/lib/map/geogrid";
55
import { ZARR_STORE } from "@/lib/constants/store";
66
import { ZARR_TIME } from "@/lib/zarr/timeRange";
77
import { TimeSeriesPlot } from "@/components/map/TimeSeriesPlot";
88
import { TimeSeriesPlotLoading } from "@/components/map/TimeSeriesPlotLoading";
9+
import { ProgressBar } from "@/components/ui/ProgressBar";
910

10-
const PANEL_CLASS =
11-
"grid gap-3 rounded-editor-md border border-editor-border bg-editor-bg-secondary p-4 shadow-editor";
12-
const HINT_CLASS = "text-[13px] leading-[1.55] text-editor-fg-tertiary";
13-
const LABEL_CLASS = "text-[13px] font-medium text-editor-fg-secondary";
11+
type SeriesProgress = { loaded: number; total: number };
1412

1513
type MapReadoutProps = {
1614
selection: MapSelection | null;
1715
historyYears: number;
1816
onHistoryYearsChange: (years: number) => void;
1917
loadingSeries: boolean;
18+
seriesProgress: SeriesProgress | null;
2019
seriesError: string | null;
2120
seriesValues: Float32Array | null;
2221
seriesUnits: string | null;
2322
};
2423

24+
const SECTION_LABEL = "text-[13px] font-semibold text-editor-fg-primary";
25+
const META = "font-mono text-[11px] text-editor-fg-tertiary";
26+
2527
export function MapReadout({
2628
selection,
2729
historyYears,
2830
onHistoryYearsChange,
2931
loadingSeries,
32+
seriesProgress,
3033
seriesError,
3134
seriesValues,
3235
seriesUnits,
3336
}: MapReadoutProps) {
3437
const historyLabel =
3538
historyYears === 1 ? "Last 1 year" : `Last ${historyYears} years`;
3639

40+
if (!selection) {
41+
return (
42+
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-8 text-center">
43+
<div
44+
className="grid h-9 w-9 place-items-center rounded-full border border-dashed border-editor-border-strong"
45+
aria-hidden="true"
46+
>
47+
<span className="block h-2 w-2 rounded-full bg-accent" />
48+
</div>
49+
<p className="text-[13.5px] font-semibold text-editor-fg-secondary">
50+
Click the map
51+
</p>
52+
<p className="text-[12.5px] text-editor-fg-tertiary">
53+
Pick a point to load its record
54+
</p>
55+
</div>
56+
);
57+
}
58+
3759
const patchCells = ZARR_STORE.nativeChunks.lon;
3860
const patchDeg = patchCells * ZARR_STORE.spatialResolutionDeg;
3961

62+
const historyControl = (
63+
<section>
64+
<div className="mb-3 flex items-baseline justify-between gap-3">
65+
<label className={SECTION_LABEL} htmlFor="history-years">
66+
History window
67+
</label>
68+
<span className="font-mono text-[12.5px] font-semibold text-accent">
69+
{historyLabel}
70+
</span>
71+
</div>
72+
<input
73+
id="history-years"
74+
className="w-full cursor-pointer [accent-color:var(--accent)]"
75+
type="range"
76+
min={ZARR_TIME.defaultHistoryYears}
77+
max={ZARR_TIME.maxHistoryYears}
78+
step={1}
79+
value={historyYears}
80+
onChange={(event) =>
81+
onHistoryYearsChange(Number(event.currentTarget.value))
82+
}
83+
/>
84+
</section>
85+
);
86+
87+
const chart = (
88+
<section aria-live="polite">
89+
<div className="mb-3 flex items-baseline justify-between gap-3">
90+
<span className={SECTION_LABEL}>Daily mean</span>
91+
{!loadingSeries && !seriesError && seriesValues && seriesUnits ? (
92+
<span className={META}>{seriesUnits}</span>
93+
) : null}
94+
</div>
95+
96+
{loadingSeries ? (
97+
<div className="grid gap-3">
98+
<SeriesLoader progress={seriesProgress} />
99+
<TimeSeriesPlotLoading historyYears={historyYears} />
100+
</div>
101+
) : seriesError ? (
102+
<p className="text-[13px] leading-[1.55] text-editor-fg-tertiary">
103+
{seriesError}
104+
</p>
105+
) : seriesValues ? (
106+
<TimeSeriesPlot
107+
values={seriesValues}
108+
units={seriesUnits}
109+
hoursPerDay={ZARR_TIME.hoursPerDay}
110+
/>
111+
) : null}
112+
</section>
113+
);
114+
40115
return (
41-
<>
42-
<section className={PANEL_CLASS}>
43-
<header className="flex items-baseline justify-between gap-3">
44-
<h2 className="text-[15px] font-semibold tracking-[-0.02em] text-editor-fg-primary">
45-
Pixel location
116+
<div className="flex flex-col divide-y divide-editor-border">
117+
<section className="pb-4">
118+
<div className="flex items-baseline justify-between gap-3">
119+
<h2 className="font-mono text-[20px] leading-none tracking-tight tabular-nums text-editor-fg-primary">
120+
{formatCoordinate(selection.click.lat)}
121+
<span className="text-editor-fg-tertiary">, </span>
122+
{formatCoordinate(selection.click.lon)}
46123
</h2>
47-
<span className="flex-shrink-0 font-mono text-[11px] text-editor-fg-tertiary">
48-
{ZARR_STORE.kicker}
49-
</span>
50-
</header>
51-
<p className={HINT_CLASS}>
52-
Click the map to sample a {ZARR_STORE.spatialResolutionDeg}° cell.
124+
<span className={`flex-shrink-0 ${META}`}>{ZARR_STORE.kicker}</span>
125+
</div>
126+
<p className="mt-2 text-[12.5px] leading-[1.5] text-editor-fg-tertiary">
127+
Snapped to the nearest {ZARR_STORE.spatialResolutionDeg}° cell ·
128+
variable {ZARR_STORE.defaultVariable}
53129
</p>
54-
<p className={HINT_CLASS}>
130+
<p className="mt-2 text-[12.5px] leading-[1.5] text-editor-fg-tertiary">
55131
Each click downloads a {patchCells}×{patchCells} patch ({patchDeg}° ×{" "}
56132
{patchDeg}°), drawn as the dashed box. Toggle it with the patch button
57133
in the top bar.
58134
</p>
59135
</section>
60136

61-
<section className={PANEL_CLASS}>
62-
<div className="flex items-baseline justify-between gap-3">
63-
<label className={LABEL_CLASS} htmlFor="history-years">
64-
History window
65-
</label>
66-
<span className="font-mono text-[13px] font-semibold text-accent">
67-
{historyLabel}
68-
</span>
69-
</div>
70-
<input
71-
id="history-years"
72-
className="w-full cursor-pointer [accent-color:var(--accent)]"
73-
type="range"
74-
min={ZARR_TIME.defaultHistoryYears}
75-
max={ZARR_TIME.maxHistoryYears}
76-
step={1}
77-
value={historyYears}
78-
onChange={(event) =>
79-
onHistoryYearsChange(Number(event.currentTarget.value))
80-
}
81-
/>
82-
</section>
137+
{/* History drives the chart, so they sit together with no divider. */}
138+
<div className="grid gap-4 py-4">
139+
{historyControl}
140+
{chart}
141+
</div>
83142

84-
<section className={`${PANEL_CLASS} flex-1`} aria-live="polite">
85-
{selection ? (
86-
<>
87-
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 [&_dd]:font-mono [&_dd]:text-[13px] [&_dd]:font-medium [&_dd]:leading-[1.45] [&_dd]:text-editor-fg-secondary [&_div]:grid [&_div]:min-w-0 [&_div]:gap-1 [&_dt]:text-[12px] [&_dt]:text-editor-fg-tertiary">
88-
<div>
89-
<dt>Click</dt>
90-
<dd>{formatGeoPoint(selection.click)}</dd>
91-
</div>
92-
<div>
93-
<dt>Grid cell</dt>
94-
<dd>{formatGeoPoint(selection.grid)}</dd>
95-
</div>
96-
<div>
97-
<dt>Indices</dt>
98-
<dd>
99-
lon {selection.grid.lonIndex}, lat {selection.grid.latIndex}
100-
</dd>
101-
</div>
102-
<div>
103-
<dt>Variable</dt>
104-
<dd>{ZARR_STORE.defaultVariable}</dd>
105-
</div>
106-
</dl>
107-
108-
<section
109-
className="grid min-w-0 gap-3 pt-4"
110-
aria-label="Time series"
111-
>
112-
<p className={LABEL_CLASS}>Time series</p>
113-
{loadingSeries && (
114-
<TimeSeriesPlotLoading historyYears={historyYears} />
115-
)}
116-
{!loadingSeries && seriesError && (
117-
<p className={HINT_CLASS}>{seriesError}</p>
118-
)}
119-
{!loadingSeries && !seriesError && seriesValues && (
120-
<TimeSeriesPlot
121-
values={seriesValues}
122-
units={seriesUnits}
123-
hoursPerDay={ZARR_TIME.hoursPerDay}
124-
/>
125-
)}
126-
</section>
127-
</>
128-
) : (
129-
<p className={HINT_CLASS}>Pick a point on the map to begin.</p>
130-
)}
143+
<section className="pt-4">
144+
<dl className="grid grid-cols-2 gap-x-4 gap-y-3">
145+
<Fact label="Grid cell" value={formatGeoPoint(selection.grid)} />
146+
<Fact
147+
label="Array index"
148+
value={`lon ${selection.grid.lonIndex} · lat ${selection.grid.latIndex}`}
149+
/>
150+
</dl>
131151
</section>
132-
</>
152+
</div>
153+
);
154+
}
155+
156+
function Fact({ label, value }: { label: string; value: string }) {
157+
return (
158+
<div className="grid min-w-0 gap-0.5">
159+
<dt className="text-[11.5px] text-editor-fg-tertiary">{label}</dt>
160+
<dd className="font-mono text-[13px] tabular-nums text-editor-fg-secondary">
161+
{value}
162+
</dd>
163+
</div>
164+
);
165+
}
166+
167+
function formatBytes(bytes: number): string {
168+
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
169+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
170+
return `${bytes} B`;
171+
}
172+
173+
function SeriesLoader({ progress }: { progress: SeriesProgress | null }) {
174+
const hasBytes = progress !== null && progress.total > 0;
175+
const value = hasBytes ? progress.loaded / progress.total : undefined;
176+
const pct = value === undefined ? null : Math.min(100, Math.round(value * 100));
177+
178+
return (
179+
<div className="grid gap-2">
180+
<div className="flex items-baseline justify-between gap-3">
181+
<span className="text-[12.5px] text-editor-fg-secondary">
182+
Loading time series
183+
</span>
184+
<span className="font-mono text-[12.5px] font-semibold tabular-nums text-accent">
185+
{pct === null ? "…" : `${pct}%`}
186+
</span>
187+
</div>
188+
<ProgressBar value={value} label="Fetching time series" />
189+
{hasBytes ? (
190+
<p className={META}>
191+
{formatBytes(progress.loaded)} / {formatBytes(progress.total)}
192+
</p>
193+
) : null}
194+
</div>
133195
);
134196
}

src/components/map/TimeSeriesPlotLoading.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,7 @@ export function TimeSeriesPlotLoading({
2828
const dayCount = dayCountForHistory(historyYears);
2929

3030
return (
31-
<div
32-
className="relative w-full min-w-0"
33-
role="status"
34-
aria-live="polite"
35-
aria-busy="true"
36-
aria-label="Loading time series"
37-
>
31+
<div className="relative w-full min-w-0" aria-hidden="true">
3832
<ResponsiveContainer width="100%" height={TIME_SERIES_PLOT_HEIGHT}>
3933
<LineChart data={[]} margin={TIME_SERIES_CHART_MARGIN}>
4034
<CartesianGrid stroke={grid} vertical={false} />
@@ -65,9 +59,6 @@ export function TimeSeriesPlotLoading({
6559
/>
6660
</LineChart>
6761
</ResponsiveContainer>
68-
<p className="pointer-events-none absolute inset-[12px_8px_28px_48px] m-0 grid place-items-center text-[13px] text-editor-fg-tertiary">
69-
Loading…
70-
</p>
7162
</div>
7263
);
7364
}

0 commit comments

Comments
 (0)