Skip to content

Commit cbf6d4b

Browse files
committed
station-detail: prefetch on map hover + spinner overlay on chart
Two-part fix for the slow + jittery station-switch experience on `/s/<slug>` when the user clicks between stations on the map. **Prefetch on hover** (`StationMap.tsx`, `query/stations.ts`): - New `onMarkerHover?: (id) => void` prop on `StationMap`; fires 80 ms after the cursor settles on a circle (debounced via `setTimeout` + `mouseout` cancel). Quick mouse-passes don't trigger requests. - `prefetchStationDetail(qc, slug, fromS, toS, binS)` in the query module chains the `/info` → `/totals` calls the next page will issue. Uses the SAME query keys (`station-info`/`station-avail`) as the live hooks, so the eventual `useStationAvailability` mount hits the warm cache instead of refetching. - `StationDetail.tsx` wires the hook by passing `onMarkerHover={(id) => prefetchStationDetail(qc, id, bufFromS, bufToS, binS)}` to its `StationMap`. **Spinner overlay** (`StationDetail.tsx`): - Wraps `StationAvailabilityChart` in a relative container. While `rangeQuery.isFetching` is true, the chart dims to 40% opacity and an absolute-positioned `<CircularProgress />` floats over it. Pre-existing initial-load spinner (no data yet) is unchanged. **Drops cross-station `keepPreviousData`** (`query/stations.ts`): - `useStationAvailability`'s `placeholderData` is now selective: keeps prev rows only when `gbfsId` is unchanged (same station, new range/bin). On cross-station nav, drops to undefined so the chart shows the spinner instead of the previous station's data with the new station's y-range — the "loaded but wrong" effect the user reported. Verified live via synthetic `mouseover` on a hit-circle: 80 ms later the `/info` + chained `/totals` requests fire with the hovered station's id, before any click. BE perf (uncached `/api/totals?kind=availability` at 2.0–2.8 s) is a separate investigation — see task #64.
1 parent 0745438 commit cbf6d4b

3 files changed

Lines changed: 159 additions & 56 deletions

File tree

www/src/components/StationMap.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from 'react'
1+
import { useEffect, useMemo, useRef, useState } from 'react'
22
import { Circle, MapContainer, Pane, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet'
33
import 'leaflet/dist/leaflet.css'
44
import { useTheme } from '../contexts/ThemeContext'
@@ -72,6 +72,7 @@ function StationMarkers({
7272
setSelectedId,
7373
pinnedId,
7474
onPin,
75+
onMarkerHover,
7576
pairCounts,
7677
colors,
7778
stationColors,
@@ -82,6 +83,7 @@ function StationMarkers({
8283
setSelectedId?: (id: string | undefined) => void
8384
pinnedId?: string
8485
onPin?: (id: string | undefined) => void
86+
onMarkerHover?: (id: string) => void
8587
pairCounts?: StationPairCounts | null
8688
colors: TileColors
8789
stationColors?: Record<string, string> | null
@@ -93,6 +95,25 @@ function StationMarkers({
9395
const selectedStation = selectedId ? stations[selectedId] : undefined
9496
const mPerPx = useMemo(() => getMetersPerPixel(map), [map, zoom])
9597

98+
// Hover-prefetch: fire `onMarkerHover(id)` 80 ms after the cursor settles on
99+
// a circle, cancelling if the cursor leaves first. Quick mouse-passes don't
100+
// trigger requests; deliberate hovers warm the cache for an imminent click.
101+
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
102+
useEffect(() => () => {
103+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current)
104+
}, [])
105+
const scheduleHoverPrefetch = (id: string) => {
106+
if (!onMarkerHover) return
107+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current)
108+
hoverTimerRef.current = setTimeout(() => onMarkerHover(id), 80)
109+
}
110+
const cancelHoverPrefetch = () => {
111+
if (hoverTimerRef.current) {
112+
clearTimeout(hoverTimerRef.current)
113+
hoverTimerRef.current = null
114+
}
115+
}
116+
96117
// Suppress the permanent selected-station tooltip while the cursor is over a
97118
// destination line — otherwise both tooltips pile up at the source station,
98119
// overlapping awkwardly. (Only used in unpinned/hover mode; when pinned we
@@ -212,6 +233,17 @@ function StationMarkers({
212233
// Hit-test circle: invisible (fillOpacity 0, weight 0), but with
213234
// a clickable radius of at least HIT_RADIUS_PX. Carries the
214235
// eventHandlers + tooltip.
236+
const eventHandlers: Record<string, () => void> = {}
237+
if (onPin || setSelectedId) {
238+
eventHandlers.click = () => (onPin ?? setSelectedId)?.(id)
239+
}
240+
if (onMarkerHover || (hoverToSelect && setSelectedId)) {
241+
eventHandlers.mouseover = () => {
242+
if (hoverToSelect && setSelectedId && id !== selectedId) setSelectedId(id)
243+
scheduleHoverPrefetch(id)
244+
}
245+
eventHandlers.mouseout = cancelHoverPrefetch
246+
}
215247
const hit = (
216248
<Circle
217249
key={`${id}-hit`}
@@ -220,10 +252,7 @@ function StationMarkers({
220252
fillOpacity={0}
221253
weight={0}
222254
bubblingMouseEvents={false}
223-
eventHandlers={(onPin || setSelectedId) ? {
224-
click: () => (onPin ?? setSelectedId)?.(id),
225-
...(hoverToSelect && setSelectedId ? { mouseover: () => { if (id !== selectedId) setSelectedId(id) } } : {}),
226-
} : undefined}
255+
eventHandlers={Object.keys(eventHandlers).length ? eventHandlers : undefined}
227256
>
228257
{/* Suppress base-circle tooltips for other stations while a
229258
station is pinned — the pinned TT stays put, nothing else
@@ -240,7 +269,7 @@ function StationMarkers({
240269
})}
241270
</Pane>
242271
)
243-
}, [stations, selectedId, setSelectedId, onPin, colors, stationColors, hoverToSelect, isPinned, hitRadius])
272+
}, [stations, selectedId, setSelectedId, onPin, onMarkerHover, colors, stationColors, hoverToSelect, isPinned, hitRadius])
244273

245274
// Permanent tooltip on the destination station while hovering an edge that
246275
// ends there. Anchored at the dst's lat/lng with a tiny invisible Circle
@@ -320,6 +349,10 @@ export interface StationMapProps {
320349
* expected to toggle / update `pinnedId`. If omitted, clicks
321350
* fall back to `setSelectedId`. */
322351
onPin?: (id: string | undefined) => void
352+
/** Fired ~80ms after the cursor settles on a circle. Use to warm
353+
* caches for an imminent click (e.g. prefetch the station-detail
354+
* data the click will navigate to). */
355+
onMarkerHover?: (id: string) => void
323356
pairCounts?: StationPairCounts | null
324357
stationColors?: Record<string, string> | null
325358

@@ -352,6 +385,7 @@ export default function StationMap({
352385
setSelectedId,
353386
pinnedId,
354387
onPin,
388+
onMarkerHover,
355389
pairCounts,
356390
stationColors,
357391
center,
@@ -399,6 +433,7 @@ export default function StationMap({
399433
setSelectedId={setSelectedId}
400434
pinnedId={pinnedId}
401435
onPin={onPin}
436+
onMarkerHover={onMarkerHover}
402437
pairCounts={pairCounts}
403438
colors={colors}
404439
stationColors={stationColors}

www/src/pages/StationDetail.tsx

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { useNavigate, useParams } from 'react-router-dom'
33
import { Box, CircularProgress, Typography } from '@mui/material'
44
import css from '../index.module.css'
@@ -16,8 +16,9 @@ import { Checkbox } from '../components/Checkbox'
1616
import { Checklist } from '../components/Checklist'
1717
import { Radios } from '../components/Radios'
1818
import { useStationTrips } from '../hooks/useStationTrips'
19+
import { useQueryClient } from '@tanstack/react-query'
1920
import {
20-
pickAvailBinAuto,
21+
pickAvailBinAuto, prefetchStationDetail,
2122
useStationInfo, useStationAvailability,
2223
} from '../query/stations'
2324
import { useRollupQuery, type Side } from '../query/rollups'
@@ -197,6 +198,18 @@ export default function StationDetail() {
197198
const data = rangeQuery.data ?? null
198199
const error = rangeQuery.error ? String(rangeQuery.error) : null
199200

201+
// Prefetch handler for hovered map circles. Reuses the current page's
202+
// (bufFromS, bufToS, binS) so the prefetched cache entries match the next
203+
// page's query keys exactly — the click navigates and `useStationAvailability`
204+
// there hits the warmed cache for an instant render.
205+
const queryClient = useQueryClient()
206+
const onMarkerHover = useCallback((id: string) => {
207+
const binS = binMs > 0
208+
? Math.floor(binMs / 1000)
209+
: pickAvailBinAuto(bufToS - bufFromS, availViewportPx)
210+
void prefetchStationDetail(queryClient, id, bufFromS, bufToS, binS)
211+
}, [queryClient, bufFromS, bufToS, binMs, availViewportPx])
212+
200213
// Load monthly trip history from the public DVX cache
201214
const { rows: tripsRows } = useStationTrips(info?.short_name)
202215

@@ -478,23 +491,36 @@ export default function StationDetail() {
478491
)}
479492

480493
{data && data.rows.length > 0 && (
481-
<StationAvailabilityChart
482-
rows={data.rows}
483-
capacity={info?.capacity ?? null}
484-
visibleFromS={fromS}
485-
visibleToS={toS}
486-
binS={data.binS}
487-
onPan={(minS, maxS) => {
488-
const duration = roundDuration((maxS - minS) * 1000)
489-
const nowS = Date.now() / 1000
490-
// Snap to Latest when drag ends within 10 min of now (matches awair UX).
491-
const snapToLatest = maxS >= nowS - 10 * 60
492-
setRange({
493-
timestamp: snapToLatest ? null : new Date(maxS * 1000),
494-
duration,
495-
})
496-
}}
497-
/>
494+
<Box sx={{ position: 'relative' }}>
495+
<Box sx={{ opacity: rangeQuery.isFetching ? 0.4 : 1, transition: 'opacity 120ms' }}>
496+
<StationAvailabilityChart
497+
rows={data.rows}
498+
capacity={info?.capacity ?? null}
499+
visibleFromS={fromS}
500+
visibleToS={toS}
501+
binS={data.binS}
502+
onPan={(minS, maxS) => {
503+
const duration = roundDuration((maxS - minS) * 1000)
504+
const nowS = Date.now() / 1000
505+
// Snap to Latest when drag ends within 10 min of now (matches awair UX).
506+
const snapToLatest = maxS >= nowS - 10 * 60
507+
setRange({
508+
timestamp: snapToLatest ? null : new Date(maxS * 1000),
509+
duration,
510+
})
511+
}}
512+
/>
513+
</Box>
514+
{rangeQuery.isFetching && (
515+
<Box sx={{
516+
position: 'absolute', inset: 0,
517+
display: 'flex', alignItems: 'center', justifyContent: 'center',
518+
pointerEvents: 'none',
519+
}}>
520+
<CircularProgress />
521+
</Box>
522+
)}
523+
</Box>
498524
)}
499525

500526
{data && data.rows.length === 0 && (
@@ -522,6 +548,7 @@ export default function StationDetail() {
522548
stations={mapStations}
523549
selectedId={mapShortName}
524550
setSelectedId={(sid) => { if (sid && sid !== mapShortName) navigate(`/s/${sid}`) }}
551+
onMarkerHover={onMarkerHover}
525552
pairCounts={pairCounts}
526553
center={mapCenter}
527554
zoom={15}

www/src/query/stations.ts

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* binned per `binOverrideS` or `pickAvailBinAuto`. Single shape regardless
77
* of window size (the worker handles tier selection: mo1/d1/h1/raw).
88
*/
9-
import { keepPreviousData, useQuery } from '@tanstack/react-query'
9+
import { keepPreviousData, type QueryClient, useQuery } from '@tanstack/react-query'
1010

1111
// Override at build/dev time with `VITE_API_BASE=http://localhost:51896 pnpm dev`.
1212
const API_BASE = import.meta.env.VITE_API_BASE ?? 'https://ctbk-gbfs-api.ryan-0dc.workers.dev'
@@ -54,15 +54,18 @@ export interface StationRangeResponse {
5454
last_polled_at: number | null
5555
}
5656

57+
const stationInfoKey = (id: string) => ['station-info', id] as const
58+
const stationInfoFn = async (id: string): Promise<StationInfo | null> => {
59+
const res = await fetch(`${API_BASE}/api/stations/${encodeURIComponent(id)}/info`)
60+
if (!res.ok) return null
61+
return res.json()
62+
}
63+
5764
export function useStationInfo(id: string | undefined) {
5865
return useQuery<StationInfo | null>({
59-
queryKey: ['station-info', id],
66+
queryKey: stationInfoKey(id ?? ''),
6067
enabled: !!id,
61-
queryFn: async () => {
62-
const res = await fetch(`${API_BASE}/api/stations/${encodeURIComponent(id!)}/info`)
63-
if (!res.ok) return null
64-
return res.json()
65-
},
68+
queryFn: () => stationInfoFn(id!),
6669
})
6770
}
6871

@@ -178,6 +181,35 @@ function totalsRowsToAvailabilityRows(
178181
* newly-arrived polls should appear without a manual reload). Worker
179182
* stitches today's WAL into in-progress bins, so a refetch returns rows
180183
* through `now`. */
184+
const stationAvailKey = (gbfsId: string, fromS: number, toS: number, binS: number) =>
185+
['station-avail', gbfsId, fromS, toS, `bin${binS}`] as const
186+
187+
const stationAvailFn = async (
188+
gbfsId: string, fromS: number, toS: number, binS: number, capacityHint: number | null,
189+
): Promise<StationRangeResponse & { binS: number }> => {
190+
const url = new URL(`${API_BASE}/api/totals`)
191+
url.searchParams.set('kind', 'availability')
192+
url.searchParams.set('metric', 'all')
193+
url.searchParams.set('scope', 'stations')
194+
url.searchParams.set('from', String(fromS))
195+
url.searchParams.set('to', String(toS))
196+
url.searchParams.set('filter.station_id', gbfsId)
197+
url.searchParams.set('agg', 'mean')
198+
url.searchParams.set('bin', String(binS))
199+
const res = await fetch(url.toString())
200+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
201+
const data = await res.json() as { rows: AvailabilityOverviewRow[] }
202+
return {
203+
station_id: gbfsId,
204+
from: fromS,
205+
to: toS,
206+
capacity: capacityHint,
207+
last_polled_at: null,
208+
rows: totalsRowsToAvailabilityRows(data.rows),
209+
binS,
210+
}
211+
}
212+
181213
export function useStationAvailability(
182214
gbfsId: string | null | undefined, // canonical UUID — required for /totals
183215
fromS: number,
@@ -191,36 +223,45 @@ export function useStationAvailability(
191223
) {
192224
const binS = binOverrideS ?? pickAvailBinAuto(toS - fromS, viewportPx)
193225
return useQuery<StationRangeResponse & { binS: number }>({
194-
queryKey: ['station-avail', gbfsId, fromS, toS, `bin${binS}`],
226+
queryKey: stationAvailKey(gbfsId ?? '', fromS, toS, binS),
195227
enabled: !!gbfsId,
196-
queryFn: async () => {
197-
const url = new URL(`${API_BASE}/api/totals`)
198-
url.searchParams.set('kind', 'availability')
199-
url.searchParams.set('metric', 'all')
200-
url.searchParams.set('scope', 'stations')
201-
url.searchParams.set('from', String(fromS))
202-
url.searchParams.set('to', String(toS))
203-
url.searchParams.set('filter.station_id', gbfsId!)
204-
url.searchParams.set('agg', 'mean')
205-
url.searchParams.set('bin', String(binS))
206-
const res = await fetch(url.toString())
207-
if (!res.ok) throw new Error(`HTTP ${res.status}`)
208-
const data = await res.json() as { rows: AvailabilityOverviewRow[] }
209-
return {
210-
station_id: gbfsId!,
211-
from: fromS,
212-
to: toS,
213-
capacity: capacityHint,
214-
last_polled_at: null,
215-
rows: totalsRowsToAvailabilityRows(data.rows),
216-
binS,
217-
}
228+
queryFn: () => stationAvailFn(gbfsId!, fromS, toS, binS, capacityHint),
229+
// Keep stale data ONLY when the station_id hasn't changed (i.e. the user
230+
// toggled range/bin on the same station). On cross-station nav, drop to
231+
// `undefined` so the chart shows a spinner instead of the previous
232+
// station's data — which is misleading when the new station has different
233+
// capacity / activity profile.
234+
placeholderData: (prev, prevQuery) => {
235+
if (!prev || !prevQuery) return undefined
236+
const prevGbfsId = (prevQuery.queryKey as readonly unknown[])[1]
237+
return prevGbfsId === gbfsId ? prev : undefined
218238
},
219-
placeholderData: keepPreviousData,
220239
refetchInterval: liveRefresh ? 60_000 : false,
221240
})
222241
}
223242

243+
/** Warm the cache for a station-detail navigation. Called from map hover
244+
* handlers. Idempotent — repeat calls within the cache window are no-ops.
245+
* Chains `info` → `avail` so the avail-query keys match the eventual page
246+
* request (`gbfs_station_id` is only known after `/info`). */
247+
export async function prefetchStationDetail(
248+
qc: QueryClient,
249+
slugOrShortName: string,
250+
fromS: number,
251+
toS: number,
252+
binS: number,
253+
): Promise<void> {
254+
const info = await qc.fetchQuery({
255+
queryKey: stationInfoKey(slugOrShortName),
256+
queryFn: () => stationInfoFn(slugOrShortName),
257+
})
258+
if (!info?.gbfs_station_id) return
259+
await qc.prefetchQuery({
260+
queryKey: stationAvailKey(info.gbfs_station_id, fromS, toS, binS),
261+
queryFn: () => stationAvailFn(info.gbfs_station_id!, fromS, toS, binS, info.capacity ?? null),
262+
})
263+
}
264+
224265
/** Fetch binned availability aggregation for one station. `gbfsId` is the
225266
* GBFS UUID (not slug or short_name). Returns `mean` per `binS`-sized bucket
226267
* by default; pass `agg='p50'` etc. for percentile reducers. */

0 commit comments

Comments
 (0)