Skip to content

Commit b243c63

Browse files
Add switcher for carto and shortbread styles (#264)
1 parent 916eed0 commit b243c63

File tree

6 files changed

+317
-1
lines changed

6 files changed

+317
-1
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"d3": "^7.9.0",
3434
"date-fns": "^2.29.3",
3535
"json-format": "^1.0.1",
36+
"lucide-react": "^0.552.0",
3637
"maplibre-gl": "^5.8.0",
3738
"prop-types": "^15.8.1",
3839
"ramda": "^0.28.0",

src/map/carto.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"version": 8,
3+
"sources": {
4+
"raster-tiles": {
5+
"type": "raster",
6+
"tiles": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
7+
"tileSize": 256,
8+
"minzoom": 0,
9+
"maxzoom": 19
10+
}
11+
},
12+
"layers": [
13+
{
14+
"id": "simple-tiles",
15+
"type": "raster",
16+
"source": "raster-tiles",
17+
"attribution": "© OpenStreetMap contributors"
18+
}
19+
],
20+
"id": "blank"
21+
}

src/map/custom-control.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { createPortal } from 'react-dom';
4+
5+
import type { PropsWithChildren, ReactNode } from 'react';
6+
7+
type CustomControlProps = {
8+
position: keyof typeof controlPositions;
9+
};
10+
11+
const controlPositions = {
12+
topLeft: '.maplibregl-ctrl-top-left',
13+
topRight: '.maplibregl-ctrl-top-right',
14+
bottomLeft: '.maplibregl-ctrl-bottom-left',
15+
bottomRight: '.maplibregl-ctrl-bottom-right',
16+
};
17+
18+
export const CustomControl = ({
19+
position,
20+
children,
21+
}: PropsWithChildren<CustomControlProps>) => {
22+
const [groupContainer, setGroupContainer] = useState<HTMLDivElement | null>(
23+
null
24+
);
25+
26+
useEffect(() => {
27+
const parentElement = document.querySelector(controlPositions[position]);
28+
const groupDiv = document.createElement('div');
29+
30+
if (parentElement) {
31+
groupDiv.classList.add('maplibregl-ctrl', 'maplibregl-ctrl-group');
32+
33+
setGroupContainer(groupDiv);
34+
parentElement.appendChild(groupDiv);
35+
}
36+
37+
return () => {
38+
if (parentElement) {
39+
parentElement.removeChild(groupDiv);
40+
}
41+
};
42+
}, []);
43+
44+
if (!groupContainer) return null;
45+
46+
return createPortal(<>{children}</>, groupContainer);
47+
};
48+
49+
type ControlButtonProps = {
50+
title: string;
51+
onClick: () => void;
52+
icon: ReactNode;
53+
};
54+
55+
export const ControlButton = ({ title, onClick, icon }: ControlButtonProps) => {
56+
return (
57+
<button type="button" aria-label={title} title={title} onClick={onClick}>
58+
<span className="flex justify-center items-center" aria-hidden={true}>
59+
{icon}
60+
</span>
61+
</button>
62+
);
63+
};

src/map/index.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import type { Feature, FeatureCollection, LineString } from 'geojson';
5252

5353
// Import the style JSON
5454
import mapStyle from './style.json';
55+
import cartoStyle from './carto.json';
56+
import { ResetBoundsControl, getInitialMapStyle } from './map-style-control';
5557

5658
const centerCoords = process.env.REACT_APP_CENTER_COORDS!.split(',');
5759

@@ -181,6 +183,9 @@ const MapComponent = ({
181183
latitude: center[1],
182184
zoom: zoom_initial,
183185
});
186+
const [currentMapStyle, setCurrentMapStyle] = useState<
187+
'shortbread' | 'carto'
188+
>(getInitialMapStyle);
184189

185190
const mapRef = useRef<MapRef>(null);
186191
const drawRef = useRef<MaplibreTerradrawControl | null>(null);
@@ -191,6 +196,21 @@ const MapComponent = ({
191196
[]
192197
);
193198

199+
// Handle map style changes
200+
const handleStyleChange = useCallback((style: 'shortbread' | 'carto') => {
201+
setCurrentMapStyle(style);
202+
203+
// Update URL params (only add 'style' param if not shortbread)
204+
const url = new URL(window.location.href);
205+
if (style === 'carto') {
206+
url.searchParams.set('style', 'carto');
207+
} else {
208+
// Remove style param for shortbread (it's the default)
209+
url.searchParams.delete('style');
210+
}
211+
window.history.replaceState({}, '', url.toString());
212+
}, []);
213+
194214
const updateExcludePolygons = useCallback(() => {
195215
if (!drawRef.current) return;
196216
const terraDrawInstance = drawRef.current.getTerraDrawInstance();
@@ -1011,7 +1031,11 @@ const MapComponent = ({
10111031
onMouseMove={handleMouseMove}
10121032
onMouseLeave={handleMouseLeave}
10131033
interactiveLayerIds={['routes-line']}
1014-
mapStyle={mapStyle as unknown as maplibregl.StyleSpecification}
1034+
mapStyle={
1035+
(currentMapStyle === 'carto'
1036+
? cartoStyle
1037+
: mapStyle) as unknown as maplibregl.StyleSpecification
1038+
}
10151039
style={{ width: '100%', height: '100vh' }}
10161040
maxBounds={maxBounds}
10171041
minZoom={2}
@@ -1024,6 +1048,7 @@ const MapComponent = ({
10241048
onUpdate={updateExcludePolygons}
10251049
controlRef={drawRef}
10261050
/>
1051+
<ResetBoundsControl onStyleChange={handleStyleChange} />
10271052

10281053
{/* Route lines */}
10291054
{routeGeoJSON && (

src/map/map-style-control.tsx

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { useState, useEffect, useMemo, memo } from 'react';
2+
import { Popup } from 'semantic-ui-react';
3+
import { LayersIcon } from 'lucide-react';
4+
import Map, { useMap } from 'react-map-gl/maplibre';
5+
import type maplibregl from 'maplibre-gl';
6+
import { ControlButton, CustomControl } from './custom-control';
7+
8+
import shortbreadStyle from './style.json';
9+
import cartoStyle from './carto.json';
10+
11+
export const MAP_STYLE_STORAGE_KEY = 'selectedMapStyle';
12+
13+
type MapStyleType = 'shortbread' | 'carto';
14+
15+
// Map style configurations
16+
const MAP_STYLES = [
17+
{
18+
id: 'shortbread',
19+
label: 'Shortbread',
20+
style: shortbreadStyle,
21+
},
22+
{
23+
id: 'carto',
24+
label: 'Carto',
25+
style: cartoStyle,
26+
},
27+
] as const;
28+
29+
export const getInitialMapStyle = (): MapStyleType => {
30+
// check url params first
31+
const urlParams = new URLSearchParams(window.location.search);
32+
const styleParam = urlParams.get('style');
33+
if (styleParam === 'carto' || styleParam === 'shortbread') {
34+
return styleParam;
35+
}
36+
37+
// fallback to localStorage
38+
const savedStyle = localStorage.getItem(MAP_STYLE_STORAGE_KEY);
39+
return savedStyle === 'shortbread' || savedStyle === 'carto'
40+
? savedStyle
41+
: 'shortbread';
42+
};
43+
44+
interface ResetBoundsControlProps {
45+
onStyleChange?: (style: MapStyleType) => void;
46+
}
47+
48+
interface MapStyleOptionProps {
49+
id: MapStyleType;
50+
label: string;
51+
style: typeof shortbreadStyle | typeof cartoStyle;
52+
isSelected: boolean;
53+
onSelect: (id: MapStyleType) => void;
54+
mapCenter: { lng: number; lat: number } | undefined;
55+
zoom: number | undefined;
56+
}
57+
58+
const MapStyleOption = memo(
59+
({
60+
id,
61+
label,
62+
style,
63+
isSelected,
64+
onSelect,
65+
mapCenter,
66+
zoom,
67+
}: MapStyleOptionProps) => {
68+
// Memoize the map style to prevent unnecessary re-renders
69+
const memoizedMapStyle = useMemo(
70+
() => style as unknown as maplibregl.StyleSpecification,
71+
[style]
72+
);
73+
74+
return (
75+
<div>
76+
<div
77+
onClick={() => onSelect(id)}
78+
style={{
79+
border: `3px solid ${isSelected ? '#2185d0' : 'transparent'}`,
80+
borderRadius: '4px',
81+
cursor: 'pointer',
82+
transition: 'border-color 0.2s ease',
83+
overflow: 'hidden',
84+
}}
85+
>
86+
<Map
87+
id={`${id}-map`}
88+
onMove={() => {}}
89+
longitude={mapCenter?.lng}
90+
latitude={mapCenter?.lat}
91+
zoom={zoom}
92+
attributionControl={false}
93+
style={{
94+
width: '226px',
95+
height: '64px',
96+
display: 'block',
97+
}}
98+
mapStyle={memoizedMapStyle}
99+
boxZoom={false}
100+
doubleClickZoom={false}
101+
dragPan={false}
102+
dragRotate={false}
103+
interactive={false}
104+
/>
105+
</div>
106+
<div
107+
style={{
108+
marginTop: '6px',
109+
fontSize: '13px',
110+
fontWeight: isSelected ? 'bold' : 'normal',
111+
color: isSelected ? '#2185d0' : '#666',
112+
textAlign: 'center',
113+
}}
114+
>
115+
{label}
116+
</div>
117+
</div>
118+
);
119+
}
120+
);
121+
122+
MapStyleOption.displayName = 'MapStyleOption';
123+
124+
export const ResetBoundsControl = ({
125+
onStyleChange,
126+
}: ResetBoundsControlProps) => {
127+
const [isOpen, setIsOpen] = useState(false);
128+
const [selectedStyle, setSelectedStyle] =
129+
useState<MapStyleType>(getInitialMapStyle);
130+
131+
const { current: map } = useMap();
132+
const mapCenter = map?.getCenter();
133+
const zoom = map?.getZoom();
134+
135+
// Save to localStorage whenever selectedStyle changes
136+
useEffect(() => {
137+
localStorage.setItem(MAP_STYLE_STORAGE_KEY, selectedStyle);
138+
onStyleChange?.(selectedStyle);
139+
}, [selectedStyle, onStyleChange]);
140+
141+
const toggleDrawer = () => {
142+
setIsOpen((prevState) => !prevState);
143+
};
144+
145+
// Memoize the map options to prevent re-creating them on every render
146+
const mapOptions = useMemo(
147+
() =>
148+
MAP_STYLES.map((mapStyle) => ({
149+
...mapStyle,
150+
isSelected: selectedStyle === mapStyle.id,
151+
})),
152+
[selectedStyle]
153+
);
154+
155+
return (
156+
<Popup
157+
trigger={
158+
<CustomControl position="topRight">
159+
<ControlButton
160+
title="Map Styles"
161+
onClick={toggleDrawer}
162+
icon={<LayersIcon size={17} />}
163+
/>
164+
</CustomControl>
165+
}
166+
content={
167+
isOpen ? (
168+
<div
169+
style={{
170+
display: 'flex',
171+
flexDirection: 'column',
172+
gap: '10px',
173+
}}
174+
>
175+
{mapOptions.map((mapOption) => (
176+
<MapStyleOption
177+
key={mapOption.id}
178+
id={mapOption.id}
179+
label={mapOption.label}
180+
style={mapOption.style}
181+
isSelected={mapOption.isSelected}
182+
onSelect={setSelectedStyle}
183+
mapCenter={mapCenter}
184+
zoom={zoom}
185+
/>
186+
))}
187+
</div>
188+
) : null
189+
}
190+
on="click"
191+
open={isOpen}
192+
onClose={() => setIsOpen(false)}
193+
onOpen={() => setIsOpen(true)}
194+
/>
195+
);
196+
};

0 commit comments

Comments
 (0)