Skip to content

Commit 54d89c9

Browse files
Add new basemap (#277)
1 parent fced584 commit 54d89c9

File tree

12 files changed

+262
-101
lines changed

12 files changed

+262
-101
lines changed
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"sources": {
1515
"versatiles-shortbread": {
1616
"attribution": "<a href=\"https://map.project-osrm.org/about.html\" target=\"_blank\">About this service and privacy policy</a> | &copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors",
17-
"tiles": ["https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt"],
17+
"tiles": [
18+
"https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt"
19+
],
1820
"type": "vector",
1921
"scheme": "xyz",
2022
"bounds": [-180, -85.0511287798066, 180, 85.0511287798066],

src/components/map/constants.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,26 @@ export const routeObjects = {
1919
name: 'OSM',
2020
},
2121
};
22+
23+
export const MAP_STYLE_STORAGE_KEY = 'selectedMapStyle';
24+
export const CUSTOM_STYLE_STORAGE_KEY = 'customMapStyle';
25+
26+
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path}`;
27+
28+
export const MAP_STYLES = [
29+
{
30+
id: 'shortbread',
31+
label: 'Shortbread',
32+
style: assetPath('styles/versatiles-colorful.json'),
33+
},
34+
{ id: 'carto', label: 'Carto', style: assetPath('styles/carto.json') },
35+
{
36+
id: 'alidade-smooth',
37+
label: 'Alidade Smooth',
38+
style: 'https://tiles.stadiamaps.com/styles/alidade_smooth.json',
39+
},
40+
] as const;
41+
42+
export const DEFAULT_MAP_STYLE = MAP_STYLES[0].style;
43+
export const DEFAULT_MAP_STYLE_ID = MAP_STYLES[0].id;
44+
export const MAP_STYLE_IDS = MAP_STYLES.map((s) => s.id);

src/components/map/custom-styles-dialog.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ import {
1919
} from '../ui/field';
2020
import { FolderIcon, LoaderIcon } from 'lucide-react';
2121
import { Input } from '../ui/input';
22-
import {
23-
CUSTOM_STYLE_STORAGE_KEY,
24-
MAP_STYLE_STORAGE_KEY,
25-
} from './map-style-control';
22+
import { CUSTOM_STYLE_STORAGE_KEY, MAP_STYLE_STORAGE_KEY } from './constants';
2623

2724
const styleUrlSchema = z.string().url('Please enter a valid URL');
2825

src/components/map/edit-styles-dialog.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ import {
1313
import { Button } from '../ui/button';
1414
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
1515
import { Textarea } from '../ui/textarea';
16-
import {
17-
CUSTOM_STYLE_STORAGE_KEY,
18-
MAP_STYLE_STORAGE_KEY,
19-
} from './map-style-control';
16+
import { CUSTOM_STYLE_STORAGE_KEY, MAP_STYLE_STORAGE_KEY } from './constants';
2017

2118
const mapStyleSchema = z.object({
2219
version: z.number(),

src/components/map/index.tsx

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,10 @@ import type { ParsedDirectionsGeometry, Summary } from '@/components/types';
2525
import type { Feature, FeatureCollection, LineString } from 'geojson';
2626
import { Button } from '@/components/ui/button';
2727

28-
import mapStyle from './style.json';
29-
import cartoStyle from './carto.json';
30-
import {
31-
MapStyleControl,
32-
getInitialMapStyle,
33-
getCustomStyle,
34-
type MapStyleType,
35-
} from './map-style-control';
28+
import { MapStyleControl } from './map-style-control';
29+
import { getInitialMapStyle, getCustomStyle, getMapStyleUrl } from './utils';
30+
import { DEFAULT_MAP_STYLE_ID } from './constants';
31+
import type { MapStyleType } from './types';
3632
import { RouteLines } from './parts/route-lines';
3733
import { HighlightSegment } from './parts/highlight-segment';
3834
import { IsochronePolygons } from './parts/isochrone-polygons';
@@ -133,14 +129,10 @@ export const MapComponent = () => {
133129
useState<maplibregl.StyleSpecification | null>(() => getCustomStyle());
134130

135131
const resolvedMapStyle = useMemo(() => {
136-
switch (currentMapStyle) {
137-
case 'custom':
138-
return customStyleData ?? mapStyle;
139-
case 'carto':
140-
return cartoStyle;
141-
default:
142-
return mapStyle;
132+
if (currentMapStyle === 'custom') {
133+
return customStyleData ?? getMapStyleUrl('shortbread');
143134
}
135+
return getMapStyleUrl(currentMapStyle);
144136
}, [
145137
currentMapStyle,
146138
customStyleData,
@@ -161,7 +153,7 @@ export const MapComponent = () => {
161153
setCurrentMapStyle(style);
162154

163155
const url = new URL(window.location.href);
164-
if (style === 'carto' || style === 'custom') {
156+
if (style !== DEFAULT_MAP_STYLE_ID) {
165157
url.searchParams.set('style', style);
166158
} else {
167159
url.searchParams.delete('style');

src/components/map/map-style-control.tsx

Lines changed: 3 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,77 +3,19 @@ import { LayersIcon } from 'lucide-react';
33
import Map, { useMap } from 'react-map-gl/maplibre';
44
import type maplibregl from 'maplibre-gl';
55
import { ControlButton, CustomControl } from './custom-control';
6-
7-
import shortbreadStyle from './style.json';
8-
import cartoStyle from './carto.json';
9-
import type { SearchParamsSchema } from '@/routes';
10-
import { z } from 'zod';
116
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
127
import { CustomStylesDialog } from './custom-styles-dialog';
138
import { EditStylesDialog } from './edit-styles-dialog';
14-
15-
export const MAP_STYLE_STORAGE_KEY = 'selectedMapStyle';
16-
export const CUSTOM_STYLE_STORAGE_KEY = 'customMapStyle';
17-
18-
export type MapStyleType = 'shortbread' | 'carto' | 'custom';
19-
20-
// Map style configurations
21-
const MAP_STYLES = [
22-
{
23-
id: 'shortbread',
24-
label: 'Shortbread',
25-
style: shortbreadStyle,
26-
},
27-
{
28-
id: 'carto',
29-
label: 'Carto',
30-
style: cartoStyle,
31-
},
32-
] as const;
33-
34-
export const getInitialMapStyle = (
35-
urlValue?: SearchParamsSchema['style']
36-
): MapStyleType => {
37-
// check url params first
38-
if (urlValue === 'carto' || urlValue === 'shortbread') return urlValue;
39-
40-
if (urlValue === 'custom') {
41-
const customStyle = localStorage.getItem(CUSTOM_STYLE_STORAGE_KEY);
42-
if (customStyle) return 'custom';
43-
return 'shortbread';
44-
}
45-
46-
// fallback to localStorage
47-
const savedStyle = localStorage.getItem(MAP_STYLE_STORAGE_KEY);
48-
49-
const parsedSavedStyle = z
50-
.enum(['shortbread', 'carto', 'custom'])
51-
.safeParse(savedStyle);
52-
53-
if (parsedSavedStyle.data === 'custom') {
54-
const customStyle = localStorage.getItem(CUSTOM_STYLE_STORAGE_KEY);
55-
if (!customStyle) return 'shortbread';
56-
}
57-
58-
return parsedSavedStyle.success ? parsedSavedStyle.data : 'shortbread';
59-
};
9+
import { MAP_STYLES, MAP_STYLE_STORAGE_KEY } from './constants';
10+
import type { MapStyleType } from './types';
11+
import { getInitialMapStyle } from './utils';
6012

6113
interface MapStyleControlProps {
6214
customStyleData: maplibregl.StyleSpecification | null;
6315
onStyleChange?: (style: MapStyleType) => void;
6416
onCustomStyleLoaded?: (styleData: maplibregl.StyleSpecification) => void;
6517
}
6618

67-
export const getCustomStyle = (): maplibregl.StyleSpecification | null => {
68-
const customStyleJson = localStorage.getItem(CUSTOM_STYLE_STORAGE_KEY);
69-
if (!customStyleJson) return null;
70-
try {
71-
return JSON.parse(customStyleJson) as maplibregl.StyleSpecification;
72-
} catch {
73-
return null;
74-
}
75-
};
76-
7719
interface MapStyleOptionProps {
7820
id: MapStyleType;
7921
label: string;

src/components/map/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import type { MAP_STYLES } from './constants';
2+
13
export interface LastCenterStorageValue {
24
center: [number, number];
35
zoom_level: number;
46
}
7+
8+
export type BuiltInMapStyleId = (typeof MAP_STYLES)[number]['id'];
9+
export type MapStyleType = BuiltInMapStyleId | 'custom';

src/components/map/utils.spec.ts

Lines changed: 146 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import { describe, it, expect } from 'vitest';
2-
import { convertDDToDMS } from './utils';
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import {
3+
convertDDToDMS,
4+
mapStyleSchema,
5+
getMapStyleUrl,
6+
getInitialMapStyle,
7+
getCustomStyle,
8+
} from './utils';
9+
import { MAP_STYLE_STORAGE_KEY, CUSTOM_STYLE_STORAGE_KEY } from './constants';
310

411
describe('convertDDToDMS', () => {
512
it('should convert zero degrees correctly', () => {
@@ -93,10 +100,142 @@ describe('convertDDToDMS', () => {
93100
});
94101

95102
it('should handle typical geographic coordinates', () => {
96-
// Testing some realistic coordinate examples
97-
expect(convertDDToDMS(52.5167)).toBe('52° 31\' 0"'); // Example: Berlin
98-
expect(convertDDToDMS(13.3777)).toBe('13° 22\' 40"'); // Example: Berlin longitude
99-
expect(convertDDToDMS(-33.8688)).toBe('-33° 52\' 8"'); // Example: Sydney
100-
expect(convertDDToDMS(151.2093)).toBe('151° 12\' 33"'); // Example: Sydney longitude
103+
expect(convertDDToDMS(52.5167)).toBe('52° 31\' 0"');
104+
expect(convertDDToDMS(13.3777)).toBe('13° 22\' 40"');
105+
expect(convertDDToDMS(-33.8688)).toBe('-33° 52\' 8"');
106+
expect(convertDDToDMS(151.2093)).toBe('151° 12\' 33"');
107+
});
108+
});
109+
110+
describe('mapStyleSchema', () => {
111+
it('should validate built-in style ids', () => {
112+
expect(mapStyleSchema.safeParse('shortbread').success).toBe(true);
113+
expect(mapStyleSchema.safeParse('carto').success).toBe(true);
114+
expect(mapStyleSchema.safeParse('alidade-smooth').success).toBe(true);
115+
});
116+
117+
it('should validate custom style', () => {
118+
expect(mapStyleSchema.safeParse('custom').success).toBe(true);
119+
});
120+
121+
it('should reject invalid style ids', () => {
122+
expect(mapStyleSchema.safeParse('invalid').success).toBe(false);
123+
expect(mapStyleSchema.safeParse('').success).toBe(false);
124+
expect(mapStyleSchema.safeParse(null).success).toBe(false);
125+
expect(mapStyleSchema.safeParse(undefined).success).toBe(false);
126+
expect(mapStyleSchema.safeParse(123).success).toBe(false);
127+
});
128+
});
129+
130+
describe('getMapStyleUrl', () => {
131+
it('should return correct URL for shortbread style', () => {
132+
expect(getMapStyleUrl('shortbread')).toBe(
133+
'/styles/versatiles-colorful.json'
134+
);
135+
});
136+
137+
it('should return correct URL for carto style', () => {
138+
expect(getMapStyleUrl('carto')).toBe('/styles/carto.json');
139+
});
140+
141+
it('should return correct URL for alidade-smooth style', () => {
142+
expect(getMapStyleUrl('alidade-smooth')).toBe(
143+
'https://tiles.stadiamaps.com/styles/alidade_smooth.json'
144+
);
145+
});
146+
147+
it('should return default style URL for custom style', () => {
148+
expect(getMapStyleUrl('custom')).toBe('/styles/versatiles-colorful.json');
149+
});
150+
});
151+
152+
describe('getInitialMapStyle', () => {
153+
beforeEach(() => {
154+
localStorage.clear();
155+
});
156+
157+
it('should return urlValue if it is a valid built-in style', () => {
158+
expect(getInitialMapStyle('shortbread')).toBe('shortbread');
159+
expect(getInitialMapStyle('carto')).toBe('carto');
160+
expect(getInitialMapStyle('alidade-smooth')).toBe('alidade-smooth');
161+
});
162+
163+
it('should return custom if urlValue is custom and custom style exists in localStorage', () => {
164+
localStorage.setItem(CUSTOM_STYLE_STORAGE_KEY, '{"version":8}');
165+
expect(getInitialMapStyle('custom')).toBe('custom');
166+
});
167+
168+
it('should return default if urlValue is custom but no custom style in localStorage', () => {
169+
expect(getInitialMapStyle('custom')).toBe('shortbread');
170+
});
171+
172+
it('should fallback to localStorage if urlValue is undefined', () => {
173+
localStorage.setItem(MAP_STYLE_STORAGE_KEY, 'carto');
174+
expect(getInitialMapStyle()).toBe('carto');
175+
});
176+
177+
it('should return custom from localStorage if custom style exists', () => {
178+
localStorage.setItem(MAP_STYLE_STORAGE_KEY, 'custom');
179+
localStorage.setItem(CUSTOM_STYLE_STORAGE_KEY, '{"version":8}');
180+
expect(getInitialMapStyle()).toBe('custom');
181+
});
182+
183+
it('should return default if localStorage has custom but no custom style data', () => {
184+
localStorage.setItem(MAP_STYLE_STORAGE_KEY, 'custom');
185+
expect(getInitialMapStyle()).toBe('shortbread');
186+
});
187+
188+
it('should return default if localStorage has invalid value', () => {
189+
localStorage.setItem(MAP_STYLE_STORAGE_KEY, 'invalid-style');
190+
expect(getInitialMapStyle()).toBe('shortbread');
191+
});
192+
193+
it('should return default if no urlValue and no localStorage', () => {
194+
expect(getInitialMapStyle()).toBe('shortbread');
195+
});
196+
197+
it('should return default for invalid urlValue', () => {
198+
expect(getInitialMapStyle('invalid')).toBe('shortbread');
199+
expect(getInitialMapStyle('')).toBe('shortbread');
200+
});
201+
});
202+
203+
describe('getCustomStyle', () => {
204+
beforeEach(() => {
205+
localStorage.clear();
206+
});
207+
208+
it('should return null if no custom style in localStorage', () => {
209+
expect(getCustomStyle()).toBeNull();
210+
});
211+
212+
it('should return parsed style if valid JSON in localStorage', () => {
213+
const styleData = { version: 8, name: 'Test', sources: {}, layers: [] };
214+
localStorage.setItem(CUSTOM_STYLE_STORAGE_KEY, JSON.stringify(styleData));
215+
expect(getCustomStyle()).toEqual(styleData);
216+
});
217+
218+
it('should return null if invalid JSON in localStorage', () => {
219+
localStorage.setItem(CUSTOM_STYLE_STORAGE_KEY, 'invalid-json{');
220+
expect(getCustomStyle()).toBeNull();
221+
});
222+
223+
it('should return complex style object correctly', () => {
224+
const styleData = {
225+
version: 8,
226+
name: 'Complex Style',
227+
sources: {
228+
osm: { type: 'vector', url: 'https://example.com' },
229+
},
230+
layers: [
231+
{
232+
id: 'background',
233+
type: 'background',
234+
paint: { 'background-color': '#fff' },
235+
},
236+
],
237+
};
238+
localStorage.setItem(CUSTOM_STYLE_STORAGE_KEY, JSON.stringify(styleData));
239+
expect(getCustomStyle()).toEqual(styleData);
101240
});
102241
});

0 commit comments

Comments
 (0)