Skip to content

Commit 768f5e8

Browse files
author
Greg
committed
Map: loader while history streams in; fix first-open centering
A glass 'Loading history…' pill shows on the map while activity pages are still streaming. All map-fit triggers now go through one fitNow() path, so the map-load vs data-arrival race can no longer leave the default view: the first fit is instant and frames what the map shows (featured/shared selection included); later fits animate and never override a selection.
1 parent a9db2cd commit 768f5e8

3 files changed

Lines changed: 53 additions & 9 deletions

File tree

src/web/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ export default function App() {
273273
allActivities={activities}
274274
fitVersion={fitVersion}
275275
visible={mapActs}
276+
loading={historyLoading}
276277
hasSelection={hasSel}
277278
showBadge={hasSel && !panelOpen}
278279
selSummary={selSummary}

src/web/src/components/MapView.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ type Props = {
1515
fitVersion: number
1616
/** what the map actually shows (filters + selection applied) */
1717
visible: Activity[]
18+
/** history pages are still streaming in */
19+
loading: boolean
1820
hasSelection: boolean
1921
showBadge: boolean
2022
selSummary: string
@@ -81,7 +83,7 @@ function endpointEl(color: string): HTMLDivElement {
8183
}
8284

8385
const MapView = forwardRef<MapHandle, Props>(function MapView(
84-
{ styleUrl, allActivities, fitVersion, visible, hasSelection, showBadge, selSummary, units, detailId, panelOpen, onOpenDetail, children },
86+
{ styleUrl, allActivities, fitVersion, visible, loading, hasSelection, showBadge, selSummary, units, detailId, panelOpen, onOpenDetail, children },
8587
ref,
8688
) {
8789
const containerRef = useRef<HTMLDivElement>(null)
@@ -107,6 +109,31 @@ const MapView = forwardRef<MapHandle, Props>(function MapView(
107109
left: panelOpenRef.current ? 410 : base,
108110
})
109111

112+
// Single fit path for every trigger, so map-load vs data-arrival ordering
113+
// doesn't matter: whichever side is ready last completes the pending fit.
114+
// The first fit is instant and frames whatever the map is showing — a
115+
// shared/featured selection included; later fits animate to the full
116+
// dataset and never override a selection the user is presenting.
117+
const didFitRef = useRef(false)
118+
const pendingFitRef = useRef(false)
119+
const fitNow = () => {
120+
const map = mapRef.current
121+
if (!map || !loadedRef.current) {
122+
pendingFitRef.current = true
123+
return
124+
}
125+
if (didFitRef.current && hasSelectionRef.current) return
126+
const b = boundsOf(hasSelectionRef.current ? visibleRef.current : allRef.current)
127+
if (!b) {
128+
pendingFitRef.current = true
129+
return
130+
}
131+
pendingFitRef.current = false
132+
const first = !didFitRef.current
133+
didFitRef.current = true
134+
map.fitBounds(b, { padding: fitPadding(60), duration: first ? 0 : 700 })
135+
}
136+
110137
useEffect(() => {
111138
if (!containerRef.current || mapRef.current) return
112139
const map = new maplibregl.Map({
@@ -150,8 +177,7 @@ const MapView = forwardRef<MapHandle, Props>(function MapView(
150177
;(window as unknown as Record<string, unknown>).__dashMap = map
151178
loadedRef.current = true
152179
render(visibleRef.current, hasSelectionRef.current, detailIdRef.current)
153-
const b = boundsOf(visibleRef.current)
154-
if (b) map.fitBounds(b, { padding: 60, duration: 0 })
180+
fitNow()
155181
})
156182
return () => {
157183
ro.disconnect()
@@ -215,13 +241,9 @@ const MapView = forwardRef<MapHandle, Props>(function MapView(
215241
}, [visible, hasSelection, detailId])
216242

217243
// Re-fit when App asks for it (new range's first page, or history finished
218-
// streaming) — not on every appended page, and never over a selection the
219-
// user is presenting.
244+
// streaming) — not on every appended page.
220245
useEffect(() => {
221-
const map = mapRef.current
222-
if (!map || !loadedRef.current || hasSelectionRef.current) return
223-
const b = boundsOf(allRef.current)
224-
if (b) map.fitBounds(b, { padding: fitPadding(60), duration: 700 })
246+
fitNow()
225247
// eslint-disable-next-line react-hooks/exhaustive-deps
226248
}, [fitVersion])
227249

@@ -243,6 +265,16 @@ const MapView = forwardRef<MapHandle, Props>(function MapView(
243265
<div style={{ flex: 1, position: 'relative', minWidth: 0, background: '#dfe3ea' }}>
244266
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
245267

268+
{loading && (
269+
<div style={{ ...glass, position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 3, display: 'flex', alignItems: 'center', gap: 9, borderRadius: 11, padding: '8px 14px' }}>
270+
<svg className="spin" width="14" height="14" viewBox="0 0 16 16">
271+
<circle cx="8" cy="8" r="6" stroke="var(--muted)" strokeWidth="2.2" fill="none" opacity="0.35" />
272+
<path d="M8 2 a6 6 0 0 1 6 6" stroke="#e0223a" strokeWidth="2.2" fill="none" strokeLinecap="round" />
273+
</svg>
274+
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>Loading history…</span>
275+
</div>
276+
)}
277+
246278
{showBadge && (
247279
<div style={{ position: 'absolute', top: 16, left: 16, zIndex: 2, display: 'flex', alignItems: 'center', gap: 9, background: 'rgba(224,34,58,0.94)', borderRadius: 11, padding: '8px 14px', boxShadow: '0 6px 22px rgba(0,0,0,0.28)' }}>
248280
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#fff', boxShadow: '0 0 0 3px rgba(255,255,255,0.35)' }} />

src/web/src/styles.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,14 @@ button {
141141
transform: translateX(0);
142142
}
143143
}
144+
.spin {
145+
animation: spin 0.9s linear infinite;
146+
}
147+
@keyframes spin {
148+
from {
149+
transform: rotate(0deg);
150+
}
151+
to {
152+
transform: rotate(360deg);
153+
}
154+
}

0 commit comments

Comments
 (0)