Skip to content

Commit 54d0e77

Browse files
authored
Merge pull request #2 from ericceg/map-maximize
Map maximize
2 parents 975532c + 8df15d0 commit 54d0e77

5 files changed

Lines changed: 310 additions & 93 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ Prototype (actively evolving).
2828
- In-memory activities list caching for snappy return navigation (no refetch unless filters/data change)
2929
- Activity detail with metrics, route map, and charts
3030
- Continuous, Google Maps-style smooth zooming/panning on map views
31+
- Toggling "Reduced complexity" preserves the current map viewport (pan/zoom)
32+
- Map views support in-place maximize/minimize (with `Esc` to close expanded view)
3133
- Global path-based heatmap page that overlays all matching GPS tracks
3234
- Heatmap filters for time span (presets + custom), category, and sport type
33-
- Heatmap "Reduced complexity" toggle for a grayscale, lower-noise basemap with stronger route contrast
35+
- "Reduced complexity" toggle on map views for a grayscale, lower-noise basemap with stronger route contrast
36+
- Map controls (including reduced-complexity toggles/legend) are overlaid on maps so they remain visible in maximized map mode
3437

3538
## Tech Stack
3639

@@ -72,6 +75,7 @@ npm run tauri dev
7275
- **Heatmap** for a global path heatmap (overlapping GPS tracks; filter by time span/category/sport)
7376
- Enable **Reduced complexity (grayscale)** in Heatmap to declutter the map and emphasize route overlap
7477
- **Activity Detail** for metrics/map/charts
78+
- Use the **Maximize** button on maps (Route + Heatmap) to expand them, then press **Esc** or **Minimize** to close
7579

7680

7781
## Build
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useEffect, useState, type ReactNode } from 'react';
2+
3+
type MaximizableMapFrameProps = {
4+
label: string;
5+
collapsedHeightClassName: string;
6+
children: ReactNode;
7+
topLeftActions?: ReactNode;
8+
};
9+
10+
export function MaximizableMapFrame({
11+
label,
12+
collapsedHeightClassName,
13+
children,
14+
topLeftActions
15+
}: MaximizableMapFrameProps) {
16+
const [isMaximized, setIsMaximized] = useState(false);
17+
18+
useEffect(() => {
19+
if (!isMaximized) {
20+
return undefined;
21+
}
22+
23+
const onKeyDown = (event: KeyboardEvent) => {
24+
if (event.key === 'Escape') {
25+
setIsMaximized(false);
26+
}
27+
};
28+
29+
window.addEventListener('keydown', onKeyDown);
30+
return () => {
31+
window.removeEventListener('keydown', onKeyDown);
32+
};
33+
}, [isMaximized]);
34+
35+
useEffect(() => {
36+
const previousOverflow = document.body.style.overflow;
37+
38+
if (isMaximized) {
39+
document.body.style.overflow = 'hidden';
40+
}
41+
42+
const tick = window.setTimeout(() => {
43+
window.dispatchEvent(new Event('resize'));
44+
}, 16);
45+
46+
return () => {
47+
window.clearTimeout(tick);
48+
document.body.style.overflow = previousOverflow;
49+
};
50+
}, [isMaximized]);
51+
52+
return (
53+
<>
54+
{isMaximized ? (
55+
<button
56+
type="button"
57+
aria-label={`Close expanded ${label}`}
58+
onClick={() => setIsMaximized(false)}
59+
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-[2px]"
60+
/>
61+
) : null}
62+
63+
<div
64+
className={
65+
isMaximized
66+
? 'fixed inset-4 z-50 overflow-hidden rounded-xl border border-border bg-panel shadow-2xl'
67+
: `relative ${collapsedHeightClassName}`
68+
}
69+
>
70+
<div className="absolute left-3 top-3 z-10 flex max-w-[calc(100%-6.5rem)] flex-wrap items-center gap-2">
71+
<button
72+
type="button"
73+
onClick={() => setIsMaximized((value) => !value)}
74+
aria-pressed={isMaximized}
75+
aria-label={isMaximized ? `Minimize ${label}` : `Maximize ${label}`}
76+
className="rounded-md border border-border bg-panel/90 px-3 py-1.5 text-xs font-medium text-foreground shadow-sm backdrop-blur hover:border-accent/50"
77+
>
78+
{isMaximized ? 'Minimize' : 'Maximize'}
79+
</button>
80+
{topLeftActions}
81+
</div>
82+
<div className="h-full w-full">{children}</div>
83+
</div>
84+
</>
85+
);
86+
}

src/lib/useManagedMapLibre.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useEffect, useRef } from 'react';
2+
import maplibregl from 'maplibre-gl';
3+
4+
import { getMapStyle } from '@/lib/mapStyles';
5+
6+
type UseManagedMapLibreOptions = {
7+
reducedComplexity: boolean;
8+
initialCenter: [number, number];
9+
initialZoom: number;
10+
};
11+
12+
type MapViewState = {
13+
center: [number, number];
14+
zoom: number;
15+
};
16+
17+
export function useManagedMapLibre({
18+
reducedComplexity,
19+
initialCenter,
20+
initialZoom
21+
}: UseManagedMapLibreOptions) {
22+
const containerRef = useRef<HTMLDivElement | null>(null);
23+
const mapRef = useRef<maplibregl.Map | null>(null);
24+
const viewRef = useRef<MapViewState>({
25+
center: initialCenter,
26+
zoom: initialZoom
27+
});
28+
29+
useEffect(() => {
30+
if (!containerRef.current) {
31+
return undefined;
32+
}
33+
34+
const map = new maplibregl.Map({
35+
container: containerRef.current,
36+
style: getMapStyle(reducedComplexity),
37+
center: viewRef.current.center,
38+
zoom: viewRef.current.zoom,
39+
pitchWithRotate: false,
40+
dragRotate: false
41+
});
42+
mapRef.current = map;
43+
44+
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
45+
map.scrollZoom.setWheelZoomRate(1 / 520);
46+
map.scrollZoom.setZoomRate(1 / 130);
47+
48+
return () => {
49+
const center = map.getCenter();
50+
viewRef.current = {
51+
center: [center.lng, center.lat],
52+
zoom: map.getZoom()
53+
};
54+
55+
map.remove();
56+
mapRef.current = null;
57+
};
58+
}, [initialCenter, initialZoom, reducedComplexity]);
59+
60+
useEffect(() => {
61+
const container = containerRef.current;
62+
const map = mapRef.current;
63+
if (!container || !map) {
64+
return undefined;
65+
}
66+
67+
const observer = new ResizeObserver(() => {
68+
map.resize();
69+
});
70+
observer.observe(container);
71+
72+
return () => {
73+
observer.disconnect();
74+
};
75+
}, [reducedComplexity]);
76+
77+
return { containerRef, mapRef };
78+
}

src/pages/ActivityDetailPage.tsx

Lines changed: 83 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22
import { Link, useParams } from 'react-router-dom';
33
import {
44
CartesianGrid,
@@ -20,7 +20,9 @@ import {
2020
formatPaceMinKm,
2121
formatSpeedKmh
2222
} from '@/lib/format';
23-
import { US_DEFAULT_CENTER, US_DEFAULT_ZOOM, getMapStyle } from '@/lib/mapStyles';
23+
import { US_DEFAULT_CENTER, US_DEFAULT_ZOOM } from '@/lib/mapStyles';
24+
import { useManagedMapLibre } from '@/lib/useManagedMapLibre';
25+
import { MaximizableMapFrame } from '@/components/MaximizableMapFrame';
2426
import { MetricCard } from '@/components/MetricCard';
2527
import type { ActivityDetail, TrackPoint } from '@/types';
2628

@@ -82,36 +84,20 @@ function fitMapToTrack(map: maplibregl.Map, track: TrackPoint[]) {
8284
});
8385
}
8486

85-
function ActivityRouteMap({ track }: { track: TrackPoint[] }) {
86-
const containerRef = useRef<HTMLDivElement | null>(null);
87-
const mapRef = useRef<maplibregl.Map | null>(null);
87+
function ActivityRouteMap({
88+
track,
89+
reducedComplexity
90+
}: {
91+
track: TrackPoint[];
92+
reducedComplexity: boolean;
93+
}) {
94+
const { containerRef, mapRef } = useManagedMapLibre({
95+
reducedComplexity,
96+
initialCenter: US_DEFAULT_CENTER,
97+
initialZoom: US_DEFAULT_ZOOM
98+
});
8899
const trackSource = useMemo(() => toRouteFeatureCollection(track), [track]);
89100

90-
useEffect(() => {
91-
if (!containerRef.current) {
92-
return undefined;
93-
}
94-
95-
const map = new maplibregl.Map({
96-
container: containerRef.current,
97-
style: getMapStyle(false),
98-
center: US_DEFAULT_CENTER,
99-
zoom: US_DEFAULT_ZOOM,
100-
pitchWithRotate: false,
101-
dragRotate: false
102-
});
103-
mapRef.current = map;
104-
105-
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
106-
map.scrollZoom.setWheelZoomRate(1 / 520);
107-
map.scrollZoom.setZoomRate(1 / 130);
108-
109-
return () => {
110-
map.remove();
111-
mapRef.current = null;
112-
};
113-
}, []);
114-
115101
useEffect(() => {
116102
const map = mapRef.current;
117103
if (!map) {
@@ -145,7 +131,6 @@ function ActivityRouteMap({ track }: { track: TrackPoint[] }) {
145131
});
146132
}
147133

148-
fitMapToTrack(map, track);
149134
};
150135

151136
if (map.isStyleLoaded()) {
@@ -157,16 +142,68 @@ function ActivityRouteMap({ track }: { track: TrackPoint[] }) {
157142
return () => {
158143
map.off('load', syncTrack);
159144
};
160-
}, [track, trackSource]);
145+
}, [track, trackSource, reducedComplexity]);
146+
147+
useEffect(() => {
148+
const map = mapRef.current;
149+
if (!map) {
150+
return undefined;
151+
}
152+
153+
const fitTrack = () => {
154+
fitMapToTrack(map, track);
155+
};
156+
157+
if (map.isStyleLoaded()) {
158+
fitTrack();
159+
return undefined;
160+
}
161+
162+
map.once('load', fitTrack);
163+
return () => {
164+
map.off('load', fitTrack);
165+
};
166+
}, [track]);
161167

162168
return <div ref={containerRef} className="h-full w-full" />;
163169
}
164170

171+
function ReducedComplexityMapToggle({
172+
enabled,
173+
onChange
174+
}: {
175+
enabled: boolean;
176+
onChange: (enabled: boolean) => void;
177+
}) {
178+
return (
179+
<button
180+
type="button"
181+
onClick={() => onChange(!enabled)}
182+
aria-pressed={enabled}
183+
className={`flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs shadow-sm backdrop-blur transition-colors ${
184+
enabled
185+
? 'border-accent/60 bg-panel/90 text-foreground'
186+
: 'border-border bg-panel/80 text-muted hover:text-foreground'
187+
}`}
188+
>
189+
<span
190+
className={`inline-flex h-3.5 w-3.5 items-center justify-center rounded-sm border text-[10px] leading-none ${
191+
enabled ? 'border-accent bg-accent text-white' : 'border-border bg-bg/90 text-transparent'
192+
}`}
193+
>
194+
195+
</span>
196+
Reduced complexity
197+
</button>
198+
);
199+
}
200+
165201
export function ActivityDetailPage() {
166202
const { id } = useParams<{ id: string }>();
167203
const [detail, setDetail] = useState<ActivityDetail | null>(null);
168204
const [loading, setLoading] = useState(true);
169205
const [error, setError] = useState<string | null>(null);
206+
const [reducedMapComplexity, setReducedMapComplexity] = useState(false);
170207

171208
useEffect(() => {
172209
if (!id) {
@@ -270,15 +307,26 @@ export function ActivityDetailPage() {
270307
<div className="border-b border-border px-4 py-3">
271308
<h3 className="text-lg font-semibold text-foreground">Route</h3>
272309
</div>
273-
<div className="h-80">
310+
<MaximizableMapFrame
311+
label="route map"
312+
collapsedHeightClassName="h-80"
313+
topLeftActions={
314+
detail.track.length > 0 ? (
315+
<ReducedComplexityMapToggle
316+
enabled={reducedMapComplexity}
317+
onChange={setReducedMapComplexity}
318+
/>
319+
) : null
320+
}
321+
>
274322
{detail.track.length === 0 ? (
275323
<div className="flex h-full items-center justify-center text-sm text-muted">
276324
No GPS track available
277325
</div>
278326
) : (
279-
<ActivityRouteMap track={detail.track} />
327+
<ActivityRouteMap track={detail.track} reducedComplexity={reducedMapComplexity} />
280328
)}
281-
</div>
329+
</MaximizableMapFrame>
282330
</section>
283331

284332
<div className="grid gap-6 xl:grid-cols-3">

0 commit comments

Comments
 (0)