diff --git a/.env b/.env index 150dfdb..0a959dc 100644 --- a/.env +++ b/.env @@ -3,6 +3,7 @@ VITE_VALHALLA_URL=https://valhalla1.openstreetmap.de VITE_NOMINATIM_URL=https://nominatim.openstreetmap.org VITE_TILE_SERVER_URL="https://tile.openstreetmap.org/{z}/{x}/{y}.png" VITE_CENTER_COORDS="52.51831,13.393707" +VALHALLA_DEFAULT_STYLE_URL="https://raw.githubusercontent.com/valhalla/valhalla/master/docs/docs/api/tile/default_style.json" # Possible values: auto, bicycle, pedestrian, car, truck, bus, motor_scooter, motorcycle VITE_DEFAULT_COSTING_MODEL=bicycle diff --git a/src/components/tiles/valhalla-layers-toggle.spec.tsx b/src/components/tiles/valhalla-layers-toggle.spec.tsx index 82c4f4b..12fada0 100644 --- a/src/components/tiles/valhalla-layers-toggle.spec.tsx +++ b/src/components/tiles/valhalla-layers-toggle.spec.tsx @@ -3,9 +3,10 @@ import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { LayerSpecification } from 'maplibre-gl'; import { ValhallaLayersToggle } from './valhalla-layers-toggle'; +import * as valhallaLayers from './valhalla-layers'; import { VALHALLA_SOURCE_ID, - VALHALLA_LAYERS, + VALHALLA_LAYER_IDS, VALHALLA_EDGES_LAYER_ID, VALHALLA_NODES_LAYER_ID, VALHALLA_SHORTCUTS_LAYER_ID, @@ -13,6 +14,18 @@ import { VALHALLA_ACCESS_RESTRICTIONS_TIMED_LAYER_ID, } from './valhalla-layers'; +vi.mock('./valhalla-layers', async () => { + const actual = + await vi.importActual( + './valhalla-layers' + ); + + return { + ...actual, + getValhallaLayers: vi.fn(), + }; +}); + const createMockMap = () => { const sources: Record = {}; const layers: Record = {}; @@ -60,12 +73,35 @@ vi.mock('@/stores/common-store', () => ({ })); const noCustomLayers: { layer: LayerSpecification; visible: boolean }[] = []; +const mockHostedLayers: LayerSpecification[] = [ + { + id: VALHALLA_EDGES_LAYER_ID, + type: 'line', + source: VALHALLA_SOURCE_ID, + 'source-layer': 'edges', + } as LayerSpecification, + { + id: VALHALLA_SHORTCUTS_LAYER_ID, + type: 'line', + source: VALHALLA_SOURCE_ID, + 'source-layer': 'shortcuts', + } as LayerSpecification, + { + id: VALHALLA_NODES_LAYER_ID, + type: 'circle', + source: VALHALLA_SOURCE_ID, + 'source-layer': 'nodes', + } as LayerSpecification, +]; describe('ValhallaLayersToggle', () => { beforeEach(() => { mockMap = createMockMap(); mockMapReady = true; vi.clearAllMocks(); + vi.mocked(valhallaLayers.getValhallaLayers).mockResolvedValue( + mockHostedLayers + ); }); afterEach(() => { @@ -111,13 +147,15 @@ describe('ValhallaLayersToggle', () => { const toggle = screen.getByRole('switch'); await user.click(toggle); - expect(mockMap.addSource).toHaveBeenCalledWith( - VALHALLA_SOURCE_ID, - expect.objectContaining({ - type: 'vector', - tiles: expect.any(Array), - }) - ); + await waitFor(() => { + expect(mockMap.addSource).toHaveBeenCalledWith( + VALHALLA_SOURCE_ID, + expect.objectContaining({ + type: 'vector', + tiles: expect.any(Array), + }) + ); + }); }); it('should add all layers when toggled on', async () => { @@ -127,10 +165,18 @@ describe('ValhallaLayersToggle', () => { const toggle = screen.getByRole('switch'); await user.click(toggle); - expect(mockMap.addLayer).toHaveBeenCalledTimes(VALHALLA_LAYERS.length); - for (const layer of VALHALLA_LAYERS) { - expect(mockMap.addLayer).toHaveBeenCalledWith(layer); - } + await waitFor(() => { + expect(mockMap.addLayer).toHaveBeenCalledTimes( + VALHALLA_LAYER_IDS.length + ); + }); + + const addLayerIds = mockMap.addLayer.mock.calls.map( + (call: [{ id: string }]) => call[0].id + ); + expect(addLayerIds).toContain(VALHALLA_EDGES_LAYER_ID); + expect(addLayerIds).toContain(VALHALLA_SHORTCUTS_LAYER_ID); + expect(addLayerIds).toContain(VALHALLA_NODES_LAYER_ID); }); it('should remove all layers when toggled off', async () => { @@ -188,9 +234,11 @@ describe('ValhallaLayersToggle', () => { const toggle = screen.getByRole('switch'); await user.click(toggle); - expect(mockMap.addLayer).toHaveBeenCalledTimes( - VALHALLA_LAYERS.length - 1 - ); + await waitFor(() => { + expect(mockMap.addLayer).toHaveBeenCalledTimes( + VALHALLA_LAYER_IDS.length - 1 + ); + }); }); it('should update checked state when toggled', async () => { @@ -291,9 +339,11 @@ describe('ValhallaLayersToggle', () => { const toggle = screen.getByRole('switch'); await user.click(toggle); - expect(mockMap.addLayer).toHaveBeenCalledWith( - expect.objectContaining({ id: 'custom-valhalla-layer' }) - ); + await waitFor(() => { + expect(mockMap.addLayer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-valhalla-layer' }) + ); + }); }); it('should set visibility none for an invisible custom valhalla layer when re-added', async () => { @@ -314,11 +364,13 @@ describe('ValhallaLayersToggle', () => { const toggle = screen.getByRole('switch'); await user.click(toggle); - expect(mockMap.setLayoutProperty).toHaveBeenCalledWith( - 'custom-hidden-layer', - 'visibility', - 'none' - ); + await waitFor(() => { + expect(mockMap.setLayoutProperty).toHaveBeenCalledWith( + 'custom-hidden-layer', + 'visibility', + 'none' + ); + }); }); it('should not re-add a custom layer that uses a different source', async () => { diff --git a/src/components/tiles/valhalla-layers-toggle.tsx b/src/components/tiles/valhalla-layers-toggle.tsx index 9e23fd7..b007727 100644 --- a/src/components/tiles/valhalla-layers-toggle.tsx +++ b/src/components/tiles/valhalla-layers-toggle.tsx @@ -6,7 +6,8 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { VALHALLA_SOURCE_ID, - VALHALLA_LAYERS, + VALHALLA_LAYER_IDS, + getValhallaLayers, getValhallaSourceSpec, } from './valhalla-layers'; @@ -38,7 +39,7 @@ export const ValhallaLayersToggle = ({ }; }, [mainMap]); - const handleToggle = (checked: boolean) => { + const handleToggle = async (checked: boolean) => { if (!mainMap || !mapReady) return; const map = mainMap.getMap(); @@ -48,11 +49,14 @@ export const ValhallaLayersToggle = ({ if (!map.getSource(VALHALLA_SOURCE_ID)) { map.addSource(VALHALLA_SOURCE_ID, getValhallaSourceSpec()); } - for (const layer of VALHALLA_LAYERS) { + + const valhallaLayers = await getValhallaLayers(); + for (const layer of valhallaLayers) { if (!map.getLayer(layer.id)) { map.addLayer(layer); } } + for (const entry of customLayers) { const layerSource = 'source' in entry.layer ? entry.layer.source : undefined; @@ -71,9 +75,9 @@ export const ValhallaLayersToggle = ({ } } } else { - for (const layer of VALHALLA_LAYERS) { - if (map.getLayer(layer.id)) { - map.removeLayer(layer.id); + for (const layerId of VALHALLA_LAYER_IDS) { + if (map.getLayer(layerId)) { + map.removeLayer(layerId); } } diff --git a/src/components/tiles/valhalla-layers.spec.ts b/src/components/tiles/valhalla-layers.spec.ts index 8a2efe1..d567188 100644 --- a/src/components/tiles/valhalla-layers.spec.ts +++ b/src/components/tiles/valhalla-layers.spec.ts @@ -1,14 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { - LineLayerSpecification, - CircleLayerSpecification, - VectorSourceSpecification, -} from 'maplibre-gl'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { VectorSourceSpecification } from 'maplibre-gl'; import { VALHALLA_SOURCE_ID, VALHALLA_EDGES_LAYER_ID, VALHALLA_SHORTCUTS_LAYER_ID, VALHALLA_NODES_LAYER_ID, + VALHALLA_LAYER_IDS, + VALHALLA_DEFAULT_STYLE_URL, VALHALLA_ACCESS_RESTRICTIONS_PERMANENT_LAYER_ID, VALHALLA_ACCESS_RESTRICTIONS_TIMED_LAYER_ID, VALHALLA_EDGES_LAYER, @@ -19,6 +17,7 @@ import { VALHALLA_LAYERS, getValhallaTileUrl, getValhallaSourceSpec, + getValhallaLayers, } from './valhalla-layers'; vi.mock('@/utils/base-url', () => ({ @@ -31,6 +30,10 @@ describe('valhalla-layers', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + describe('constants', () => { it('should export correct source ID', () => { expect(VALHALLA_SOURCE_ID).toBe('valhalla-tiles'); @@ -48,6 +51,12 @@ describe('valhalla-layers', () => { ); }); + it('should export app layer IDs in expected order', () => { + expect(VALHALLA_LAYER_IDS).toEqual([ + VALHALLA_EDGES_LAYER_ID, + VALHALLA_SHORTCUTS_LAYER_ID, + VALHALLA_NODES_LAYER_ID, + ]); it('should export VALHALLA_LAYERS array with all layers', () => { expect(VALHALLA_LAYERS).toHaveLength(5); expect(VALHALLA_LAYERS).toContain(VALHALLA_EDGES_LAYER); @@ -81,81 +90,10 @@ describe('valhalla-layers', () => { expect(edgesLayer['source-layer']).toBe('edges'); }); - it('should have correct zoom range', () => { - expect(edgesLayer.minzoom).toBe(7); - expect(edgesLayer.maxzoom).toBe(22); - }); - - it('should have visible layout', () => { - expect(edgesLayer.layout).toEqual({ visibility: 'visible' }); - }); - - it('should have paint properties', () => { - expect(edgesLayer.paint).toBeDefined(); - expect(edgesLayer.paint).toHaveProperty('line-color'); - expect(edgesLayer.paint).toHaveProperty('line-width'); - expect(edgesLayer.paint).toHaveProperty('line-opacity'); - }); - }); - - //Shortcut is now a separate map layer. - //It uses the same styling as edges. - describe('VALHALLA_SHORTCUTS_LAYER', () => { - const shortcutsLayer = VALHALLA_SHORTCUTS_LAYER as LineLayerSpecification; - - it('should have correct id', () => { - expect(shortcutsLayer.id).toBe(VALHALLA_SHORTCUTS_LAYER_ID); - }); - - it('should be a line type layer', () => { - expect(shortcutsLayer.type).toBe('line'); - }); - - it('should reference correct source', () => { - expect(shortcutsLayer.source).toBe(VALHALLA_SOURCE_ID); - }); - - it('should have shortcuts source-layer', () => { - expect(shortcutsLayer['source-layer']).toBe('shortcuts'); - }); - - it('should clone edges paint and layout styling', () => { - expect(shortcutsLayer.paint).toEqual(VALHALLA_EDGES_LAYER.paint); - expect(shortcutsLayer.layout).toEqual(VALHALLA_EDGES_LAYER.layout); - }); - }); - - describe('VALHALLA_NODES_LAYER', () => { - const nodesLayer = VALHALLA_NODES_LAYER as CircleLayerSpecification; - - it('should have correct id', () => { - expect(nodesLayer.id).toBe(VALHALLA_NODES_LAYER_ID); - }); - - it('should be a circle type layer', () => { - expect(nodesLayer.type).toBe('circle'); - }); - - it('should reference correct source', () => { - expect(nodesLayer.source).toBe(VALHALLA_SOURCE_ID); - }); - - it('should have nodes source-layer', () => { - expect(nodesLayer['source-layer']).toBe('nodes'); - }); - - it('should have correct zoom range', () => { - expect(nodesLayer.minzoom).toBe(16); - expect(nodesLayer.maxzoom).toBe(22); - }); - - it('should have paint properties', () => { - expect(nodesLayer.paint).toBeDefined(); - expect(nodesLayer.paint).toHaveProperty('circle-radius'); - expect(nodesLayer.paint).toHaveProperty('circle-color'); - expect(nodesLayer.paint).toHaveProperty('circle-stroke-color'); - expect(nodesLayer.paint).toHaveProperty('circle-stroke-width'); - expect(nodesLayer.paint).toHaveProperty('circle-opacity'); + it('should export hosted default style url', () => { + expect(VALHALLA_DEFAULT_STYLE_URL).toContain( + 'raw.githubusercontent.com/valhalla/valhalla/master/docs/docs/api/tile/default_style.json' + ); }); }); @@ -277,4 +215,80 @@ describe('valhalla-layers', () => { expect(spec.scheme).toBe('xyz'); }); }); + + describe('getValhallaLayers', () => { + const makeStyleResponse = () => ({ + layers: [ + { + id: 'edges', + type: 'line', + source: 'valhalla', + 'source-layer': 'edges', + paint: { 'line-color': '#ff0000' }, + }, + { + id: 'shortcuts', + type: 'line', + source: 'valhalla', + 'source-layer': 'shortcuts', + paint: { 'line-color': '#ff8800' }, + }, + { + id: 'nodes', + type: 'circle', + source: 'valhalla', + 'source-layer': 'nodes', + paint: { 'circle-color': '#0088ff' }, + }, + { + id: 'background', + type: 'background', + paint: { 'background-color': '#fff' }, + }, + ], + }); + + it('should fetch hosted default style and map layer ids/source', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => makeStyleResponse(), + })); + vi.stubGlobal('fetch', fetchMock); + + const layers = await getValhallaLayers(); + + expect(fetchMock).toHaveBeenCalledWith(VALHALLA_DEFAULT_STYLE_URL); + expect(layers).toHaveLength(3); + expect(layers.map((l) => l.id)).toEqual([ + VALHALLA_EDGES_LAYER_ID, + VALHALLA_SHORTCUTS_LAYER_ID, + VALHALLA_NODES_LAYER_ID, + ]); + expect( + layers.every((l) => 'source' in l && l.source === VALHALLA_SOURCE_ID) + ).toBe(true); + }); + + it('should fetch on each call', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => makeStyleResponse(), + })); + vi.stubGlobal('fetch', fetchMock); + + await getValhallaLayers(); + await getValhallaLayers(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should throw when fetch fails', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 500 })); + vi.stubGlobal('fetch', fetchMock); + + await expect(getValhallaLayers()).rejects.toThrow( + 'Failed to fetch Valhalla default style: 500' + ); + }); + }); }); diff --git a/src/components/tiles/valhalla-layers.ts b/src/components/tiles/valhalla-layers.ts index 69a3906..ed66f7a 100644 --- a/src/components/tiles/valhalla-layers.ts +++ b/src/components/tiles/valhalla-layers.ts @@ -5,6 +5,20 @@ export const VALHALLA_SOURCE_ID = 'valhalla-tiles'; export const VALHALLA_EDGES_LAYER_ID = 'valhalla-edges'; export const VALHALLA_SHORTCUTS_LAYER_ID = 'valhalla-shortcuts'; export const VALHALLA_NODES_LAYER_ID = 'valhalla-nodes'; +export const VALHALLA_DEFAULT_STYLE_URL = + import.meta.env.VITE_VALHALLA_DEFAULT_STYLE_URL || + 'https://raw.githubusercontent.com/valhalla/valhalla/master/docs/docs/api/tile/default_style.json'; +export const VALHALLA_LAYER_IDS = [ + VALHALLA_EDGES_LAYER_ID, + VALHALLA_SHORTCUTS_LAYER_ID, + VALHALLA_NODES_LAYER_ID, +] as const; + +const VALHALLA_SOURCE_LAYER_TO_MAP_LAYER_ID: Record = { + edges: VALHALLA_EDGES_LAYER_ID, + shortcuts: VALHALLA_SHORTCUTS_LAYER_ID, + nodes: VALHALLA_NODES_LAYER_ID, +}; export const VALHALLA_ACCESS_RESTRICTIONS_PERMANENT_LAYER_ID = 'valhalla-access-restrictions-permanent'; export const VALHALLA_ACCESS_RESTRICTIONS_TIMED_LAYER_ID = @@ -30,108 +44,49 @@ export function getValhallaSourceSpec(): SourceSpecification { }; } -export const VALHALLA_EDGES_LAYER: LayerSpecification = { - id: VALHALLA_EDGES_LAYER_ID, - type: 'line', - source: VALHALLA_SOURCE_ID, - 'source-layer': 'edges', - minzoom: 7, - maxzoom: 22, - filter: ['all'], - layout: { visibility: 'visible' }, - paint: { - 'line-color': [ - 'match', - ['get', 'tile_level'], - 0, - '#ff0000', - 1, - '#ff8800', - 2, - '#ffdd00', - '#ff00ff', - ], - 'line-width': [ - 'interpolate', - ['exponential', 1.5], - ['zoom'], - 12, - ['match', ['get', 'tile_level'], 0, 3, 1, 2, 2, 1, 2], - 14, - ['match', ['get', 'tile_level'], 0, 4, 1, 3, 2, 2, 3], - 16, - ['match', ['get', 'tile_level'], 0, 6, 1, 4, 2, 3, 4], - 18, - ['match', ['get', 'tile_level'], 0, 8, 1, 6, 2, 4, 6], - 20, - ['match', ['get', 'tile_level'], 0, 10, 1, 8, 2, 6, 8], - 22, - ['match', ['get', 'tile_level'], 0, 12, 1, 10, 2, 8, 10], - ], - 'line-opacity': 0.8, - }, -}; +function isTargetValhallaLayer( + layer: LayerSpecification +): layer is LayerSpecification & { 'source-layer': string } { + if (!('source-layer' in layer)) { + return false; + } -// Shortcuts is now a separate tile layer. -// and It uses the same line style as edges. -export const VALHALLA_SHORTCUTS_LAYER: LayerSpecification = { - id: VALHALLA_SHORTCUTS_LAYER_ID, - type: 'line', - source: VALHALLA_SOURCE_ID, - 'source-layer': 'shortcuts', - minzoom: 7, - maxzoom: 22, - filter: ['all'], - layout: { visibility: 'visible' }, - paint: { - 'line-color': [ - 'match', - ['get', 'tile_level'], - 0, - '#ff0000', - 1, - '#ff8800', - 2, - '#ffdd00', - '#ff00ff', - ], - 'line-width': [ - 'interpolate', - ['exponential', 1.5], - ['zoom'], - 12, - ['match', ['get', 'tile_level'], 0, 3, 1, 2, 2, 1, 2], - 14, - ['match', ['get', 'tile_level'], 0, 4, 1, 3, 2, 2, 3], - 16, - ['match', ['get', 'tile_level'], 0, 6, 1, 4, 2, 3, 4], - 18, - ['match', ['get', 'tile_level'], 0, 8, 1, 6, 2, 4, 6], - 20, - ['match', ['get', 'tile_level'], 0, 10, 1, 8, 2, 6, 8], - 22, - ['match', ['get', 'tile_level'], 0, 12, 1, 10, 2, 8, 10], - ], - 'line-opacity': 0.8, - }, -}; + const sourceLayer = layer['source-layer']; + return ( + typeof sourceLayer === 'string' && + sourceLayer in VALHALLA_SOURCE_LAYER_TO_MAP_LAYER_ID + ); +} -export const VALHALLA_NODES_LAYER: LayerSpecification = { - id: VALHALLA_NODES_LAYER_ID, - type: 'circle', - source: VALHALLA_SOURCE_ID, - 'source-layer': 'nodes', - minzoom: 16, - maxzoom: 22, - paint: { - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 16, 2, 18, 4, 20, 6], - 'circle-color': ['case', ['get', 'traffic_signal'], '#ff0000', '#0088ff'], - 'circle-stroke-color': '#ffffff', - 'circle-stroke-width': 1, - 'circle-opacity': 0.8, - }, -}; +function toAppLayer( + layer: LayerSpecification & { 'source-layer': string } +): LayerSpecification { + const sourceLayer = layer['source-layer']; + return { + ...layer, + id: VALHALLA_SOURCE_LAYER_TO_MAP_LAYER_ID[sourceLayer] || layer.id, + source: VALHALLA_SOURCE_ID, + } as LayerSpecification; +} + +export async function getValhallaLayers(): Promise { + const response = await fetch(VALHALLA_DEFAULT_STYLE_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch Valhalla default style: ${response.status}` + ); + } + + const style = (await response.json()) as { + layers?: LayerSpecification[]; + }; + + if (!Array.isArray(style.layers)) { + throw new Error('Invalid Valhalla default style: missing layers array'); + } + return style.layers.filter(isTargetValhallaLayer).map(toAppLayer); +} export const VALHALLA_ACCESS_RESTRICTIONS_PERMANENT_LAYER: LayerSpecification = { id: VALHALLA_ACCESS_RESTRICTIONS_PERMANENT_LAYER_ID, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a676131..4aa4bc2 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { readonly VITE_CENTER_COORDS?: string; readonly VITE_NOMINATIM_URL?: string; readonly VITE_VALHALLA_URL?: string; + readonly VITE_VALHALLA_DEFAULT_STYLE_URL?: string; readonly VITE_DEFAULT_COSTING_MODEL?: string; }