diff --git a/src/components/tiles/tiles.spec.tsx b/src/components/tiles/tiles.spec.tsx index c7f70483..05be7074 100644 --- a/src/components/tiles/tiles.spec.tsx +++ b/src/components/tiles/tiles.spec.tsx @@ -16,6 +16,8 @@ const createMockLayers = () => [ const createMockMap = (layers = createMockLayers()) => { const layerVisibility: Record = {}; + const sources: Record = {}; + const dynamicLayers: Record = {}; return { getStyle: vi.fn(() => ({ layers })), @@ -34,6 +36,20 @@ const createMockMap = (layers = createMockLayers()) => { ), on: vi.fn(), off: vi.fn(), + getSource: vi.fn((id: string) => sources[id]), + addSource: vi.fn((id: string, spec: unknown) => { + sources[id] = spec; + }), + removeSource: vi.fn((id: string) => { + delete sources[id]; + }), + getLayer: vi.fn((id: string) => dynamicLayers[id]), + addLayer: vi.fn((layer: { id: string }) => { + dynamicLayers[layer.id] = layer; + }), + removeLayer: vi.fn((id: string) => { + delete dynamicLayers[id]; + }), }; }; @@ -360,7 +376,7 @@ describe('TilesControl', () => { render(); const groupSwitches = screen.getAllByRole('switch'); - const roadsGroupSwitch = groupSwitches[1]!; + const roadsGroupSwitch = groupSwitches[2]!; await user.click(roadsGroupSwitch); @@ -386,7 +402,7 @@ describe('TilesControl', () => { render(); const groupSwitches = screen.getAllByRole('switch'); - const waterGroupSwitch = groupSwitches[0]!; + const waterGroupSwitch = groupSwitches[1]!; await user.click(waterGroupSwitch); await user.click(waterGroupSwitch); @@ -431,12 +447,14 @@ describe('TilesControl', () => { expect(mockMap.getStyle).toHaveBeenCalled(); const initialCallCount = mockMap.getStyle.mock.calls.length; - const styleDataHandler = mockMap.on.mock.calls.find( - (call) => call[0] === 'styledata' - )?.[1]; + const styleDataHandlers = mockMap.on.mock.calls + .filter((call) => call[0] === 'styledata') + .map((call) => call[1]); await act(async () => { - styleDataHandler?.(); + for (const handler of styleDataHandlers) { + handler?.(); + } }); await waitFor(() => { @@ -456,12 +474,14 @@ describe('TilesControl', () => { expect(screen.getByText('water-fill')).toBeInTheDocument(); }); - const styleDataHandler = mockMap.on.mock.calls.find( - (call) => call[0] === 'styledata' - )?.[1]; + const styleDataHandlers = mockMap.on.mock.calls + .filter((call) => call[0] === 'styledata') + .map((call) => call[1]); await act(async () => { - styleDataHandler?.(); + for (const handler of styleDataHandlers) { + handler?.(); + } }); await waitFor(() => { diff --git a/src/components/tiles/tiles.tsx b/src/components/tiles/tiles.tsx index 967c8986..b48638db 100644 --- a/src/components/tiles/tiles.tsx +++ b/src/components/tiles/tiles.tsx @@ -10,11 +10,15 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible'; import { ChevronDown, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ValhallaLayersToggle } from './valhalla-layers-toggle'; +import { VALHALLA_SOURCE_ID } from './valhalla-layers'; interface LayerInfo { id: string; type: string; sourceLayer?: string; + source?: string; } interface GroupedLayers { @@ -67,6 +71,7 @@ export const TilesControl = () => { type: layer.type, sourceLayer: 'source-layer' in layer ? layer['source-layer'] : undefined, + source: 'source' in layer ? (layer.source as string) : undefined, })); // eslint-disable-next-line react-hooks/exhaustive-deps -- styleVersion is used to invalidate cache on style changes }, [mapReady, mainMap, styleVersion]); @@ -163,8 +168,15 @@ export const TilesControl = () => { return visibleCount > 0 && visibleCount < layersInGroup.length; }; + const isValhallaGroup = (sourceLayer: string) => { + const layersInGroup = groupedLayers.grouped[sourceLayer] || []; + return layersInGroup.some((layer) => layer.source === VALHALLA_SOURCE_ID); + }; + return (
+ + { open={expandedGroups.has(sourceLayer)} onOpenChange={() => toggleExpanded(sourceLayer)} > -
+
{expandedGroups.has(sourceLayer) ? ( @@ -191,6 +209,11 @@ export const TilesControl = () => { ({groupLayers.length}) + {isValhallaGroup(sourceLayer) && ( + + Valhalla + + )} { + const sources: Record = {}; + const layers: Record = {}; + + return { + getSource: vi.fn((id: string) => sources[id]), + addSource: vi.fn((id: string, spec: unknown) => { + sources[id] = spec; + }), + removeSource: vi.fn((id: string) => { + delete sources[id]; + }), + getLayer: vi.fn((id: string) => layers[id]), + addLayer: vi.fn((layer: { id: string }) => { + layers[layer.id] = layer; + }), + removeLayer: vi.fn((id: string) => { + delete layers[id]; + }), + on: vi.fn(), + off: vi.fn(), + _sources: sources, + _layers: layers, + }; +}; + +let mockMap = createMockMap(); +let mockMapReady = true; + +vi.mock('react-map-gl/maplibre', () => ({ + useMap: vi.fn(() => ({ + mainMap: { + getMap: () => mockMap, + }, + })), +})); + +vi.mock('@/stores/common-store', () => ({ + useCommonStore: vi.fn((selector) => + selector({ + mapReady: mockMapReady, + }) + ), +})); + +describe('ValhallaLayersToggle', () => { + beforeEach(() => { + mockMap = createMockMap(); + mockMapReady = true; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + it('should render without crashing', () => { + expect(() => render()).not.toThrow(); + }); + + it('should render toggle label', () => { + render(); + + expect(screen.getByText('Append Valhalla layers')).toBeInTheDocument(); + }); + + it('should render description text', () => { + render(); + + expect( + screen.getByText(/Overlay Valhalla routing graph tiles/) + ).toBeInTheDocument(); + }); + + it('should render Tile API link', () => { + render(); + + const link = screen.getByRole('link', { name: 'Tile API' }); + expect(link).toHaveAttribute( + 'href', + 'https://valhalla.github.io/valhalla/api/tile/api-reference/' + ); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('should render switch in unchecked state by default', () => { + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).not.toBeChecked(); + }); + + it('should not render when map is not ready', () => { + mockMapReady = false; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('toggle functionality', () => { + it('should add source when toggled on', async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('switch'); + await user.click(toggle); + + expect(mockMap.addSource).toHaveBeenCalledWith( + VALHALLA_SOURCE_ID, + expect.objectContaining({ + type: 'vector', + tiles: expect.any(Array), + }) + ); + }); + + it('should add all layers when toggled on', async () => { + const user = userEvent.setup(); + render(); + + 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); + } + }); + + it('should remove all layers when toggled off', async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('switch'); + await user.click(toggle); + await user.click(toggle); + + expect(mockMap.removeLayer).toHaveBeenCalledWith(VALHALLA_EDGES_LAYER_ID); + expect(mockMap.removeLayer).toHaveBeenCalledWith(VALHALLA_NODES_LAYER_ID); + }); + + it('should remove source when toggled off', async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('switch'); + await user.click(toggle); + await user.click(toggle); + + expect(mockMap.removeSource).toHaveBeenCalledWith(VALHALLA_SOURCE_ID); + }); + + it('should not add source if already exists', async () => { + const user = userEvent.setup(); + mockMap._sources[VALHALLA_SOURCE_ID] = { type: 'vector' }; + + render(); + + const toggle = screen.getByRole('switch'); + await user.click(toggle); + + expect(mockMap.addSource).not.toHaveBeenCalled(); + }); + + it('should not add layer if already exists', async () => { + const user = userEvent.setup(); + mockMap._layers[VALHALLA_EDGES_LAYER_ID] = { + id: VALHALLA_EDGES_LAYER_ID, + }; + + render(); + + const toggle = screen.getByRole('switch'); + await user.click(toggle); + + expect(mockMap.addLayer).toHaveBeenCalledTimes(1); + }); + + it('should update checked state when toggled', async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + + expect(toggle).toBeChecked(); + }); + }); + + describe('style change handling', () => { + it('should register styledata event listener', () => { + render(); + + expect(mockMap.on).toHaveBeenCalledWith( + 'styledata', + expect.any(Function) + ); + }); + + it('should unregister styledata event listener on unmount', () => { + const { unmount } = render(); + + unmount(); + + expect(mockMap.off).toHaveBeenCalledWith( + 'styledata', + expect.any(Function) + ); + }); + + it('should sync enabled state with source existence on style change', async () => { + render(); + + const styleDataHandler = mockMap.on.mock.calls.find( + (call) => call[0] === 'styledata' + )?.[1]; + + mockMap._sources[VALHALLA_SOURCE_ID] = { type: 'vector' }; + + await act(async () => { + styleDataHandler?.(); + }); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeChecked(); + }); + }); + + it('should set enabled to false when source is removed on style change', async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('switch'); + await user.click(toggle); + + expect(toggle).toBeChecked(); + + const styleDataHandler = mockMap.on.mock.calls.find( + (call) => call[0] === 'styledata' + )?.[1]; + + delete mockMap._sources[VALHALLA_SOURCE_ID]; + + await act(async () => { + styleDataHandler?.(); + }); + + await waitFor(() => { + expect(screen.getByRole('switch')).not.toBeChecked(); + }); + }); + }); +}); diff --git a/src/components/tiles/valhalla-layers-toggle.tsx b/src/components/tiles/valhalla-layers-toggle.tsx new file mode 100644 index 00000000..c9fcf75f --- /dev/null +++ b/src/components/tiles/valhalla-layers-toggle.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from 'react'; +import { useMap } from 'react-map-gl/maplibre'; +import { useCommonStore } from '@/stores/common-store'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + VALHALLA_SOURCE_ID, + VALHALLA_LAYERS, + getValhallaSourceSpec, +} from './valhalla-layers'; + +export const ValhallaLayersToggle = () => { + const { mainMap } = useMap(); + const mapReady = useCommonStore((state) => state.mapReady); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + if (!mainMap) return; + + const map = mainMap.getMap(); + + const handleStyleData = () => { + const hasSource = !!map.getSource(VALHALLA_SOURCE_ID); + setEnabled(hasSource); + }; + + map.on('styledata', handleStyleData); + + return () => { + map.off('styledata', handleStyleData); + }; + }, [mainMap]); + + const handleToggle = (checked: boolean) => { + if (!mainMap || !mapReady) return; + + const map = mainMap.getMap(); + setEnabled(checked); + + if (checked) { + if (!map.getSource(VALHALLA_SOURCE_ID)) { + map.addSource(VALHALLA_SOURCE_ID, getValhallaSourceSpec()); + } + for (const layer of VALHALLA_LAYERS) { + if (!map.getLayer(layer.id)) { + map.addLayer(layer); + } + } + } else { + for (const layer of VALHALLA_LAYERS) { + if (map.getLayer(layer.id)) { + map.removeLayer(layer.id); + } + } + + if (map.getSource(VALHALLA_SOURCE_ID)) { + map.removeSource(VALHALLA_SOURCE_ID); + } + } + }; + + if (!mapReady) { + return null; + } + + return ( +
+
+ + +
+

+ Overlay Valhalla routing graph tiles showing edges and nodes. Uses the{' '} + + Tile API + {' '} + to visualize the routing network with color-coded tile levels. +

+
+ ); +}; diff --git a/src/components/tiles/valhalla-layers.spec.ts b/src/components/tiles/valhalla-layers.spec.ts new file mode 100644 index 00000000..145035e1 --- /dev/null +++ b/src/components/tiles/valhalla-layers.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { + LineLayerSpecification, + CircleLayerSpecification, + VectorSourceSpecification, +} from 'maplibre-gl'; +import { + VALHALLA_SOURCE_ID, + VALHALLA_EDGES_LAYER_ID, + VALHALLA_NODES_LAYER_ID, + VALHALLA_EDGES_LAYER, + VALHALLA_NODES_LAYER, + VALHALLA_LAYERS, + getValhallaTileUrl, + getValhallaSourceSpec, +} from './valhalla-layers'; + +vi.mock('@/utils/base-url', () => ({ + getBaseUrl: vi.fn(() => 'https://valhalla.example.com'), + normalizeBaseUrl: vi.fn((url: string) => url.replace(/\/$/, '')), +})); + +describe('valhalla-layers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('constants', () => { + it('should export correct source ID', () => { + expect(VALHALLA_SOURCE_ID).toBe('valhalla-tiles'); + }); + + it('should export correct layer IDs', () => { + expect(VALHALLA_EDGES_LAYER_ID).toBe('valhalla-edges'); + expect(VALHALLA_NODES_LAYER_ID).toBe('valhalla-nodes'); + }); + + it('should export VALHALLA_LAYERS array with both layers', () => { + expect(VALHALLA_LAYERS).toHaveLength(2); + expect(VALHALLA_LAYERS).toContain(VALHALLA_EDGES_LAYER); + expect(VALHALLA_LAYERS).toContain(VALHALLA_NODES_LAYER); + }); + }); + + describe('VALHALLA_EDGES_LAYER', () => { + const edgesLayer = VALHALLA_EDGES_LAYER as LineLayerSpecification; + + it('should have correct id', () => { + expect(edgesLayer.id).toBe(VALHALLA_EDGES_LAYER_ID); + }); + + it('should be a line type layer', () => { + expect(edgesLayer.type).toBe('line'); + }); + + it('should reference correct source', () => { + expect(edgesLayer.source).toBe(VALHALLA_SOURCE_ID); + }); + + it('should have edges source-layer', () => { + 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'); + }); + }); + + 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'); + }); + }); + + describe('getValhallaTileUrl', () => { + it('should return correctly formatted tile URL', () => { + const url = getValhallaTileUrl(); + + expect(url).toContain('https://valhalla.example.com/tile?json='); + }); + + it('should include encoded JSON with xyz placeholders', () => { + const url = getValhallaTileUrl(); + + expect(url).toContain('{z}'); + expect(url).toContain('{x}'); + expect(url).toContain('{y}'); + }); + + it('should have properly encoded JSON structure', () => { + const url = getValhallaTileUrl(); + const expectedEncoded = + '%7B%22tile%22%3A%7B%22z%22%3A{z}%2C%22x%22%3A{x}%2C%22y%22%3A{y}%7D%7D'; + + expect(url).toContain(expectedEncoded); + }); + }); + + describe('getValhallaSourceSpec', () => { + it('should return vector source type', () => { + const spec = getValhallaSourceSpec() as VectorSourceSpecification; + + expect(spec.type).toBe('vector'); + }); + + it('should include tile URL', () => { + const spec = getValhallaSourceSpec() as VectorSourceSpecification; + + expect(spec.tiles).toHaveLength(1); + expect(spec.tiles![0]).toContain('https://valhalla.example.com/tile'); + }); + + it('should have correct zoom range', () => { + const spec = getValhallaSourceSpec() as VectorSourceSpecification; + + expect(spec.minzoom).toBe(7); + expect(spec.maxzoom).toBe(14); + }); + + it('should use xyz scheme', () => { + const spec = getValhallaSourceSpec() as VectorSourceSpecification; + + expect(spec.scheme).toBe('xyz'); + }); + }); +}); diff --git a/src/components/tiles/valhalla-layers.ts b/src/components/tiles/valhalla-layers.ts new file mode 100644 index 00000000..38e6eb10 --- /dev/null +++ b/src/components/tiles/valhalla-layers.ts @@ -0,0 +1,89 @@ +import type { LayerSpecification, SourceSpecification } from 'maplibre-gl'; +import { getBaseUrl, normalizeBaseUrl } from '@/utils/base-url'; + +export const VALHALLA_SOURCE_ID = 'valhalla-tiles'; +export const VALHALLA_EDGES_LAYER_ID = 'valhalla-edges'; +export const VALHALLA_NODES_LAYER_ID = 'valhalla-nodes'; + +// Pre-encoded JSON: {"tile":{"z":{z},"x":{x},"y":{y}}} +// Placeholders {z}, {x}, {y} remain unencoded for MapLibre to replace +const TILE_JSON_ENCODED = + '%7B%22tile%22%3A%7B%22z%22%3A{z}%2C%22x%22%3A{x}%2C%22y%22%3A{y}%7D%7D'; + +export function getValhallaTileUrl(): string { + const baseUrl = normalizeBaseUrl(getBaseUrl()); + return `${baseUrl}/tile?json=${TILE_JSON_ENCODED}`; +} + +export function getValhallaSourceSpec(): SourceSpecification { + return { + type: 'vector', + tiles: [getValhallaTileUrl()], + minzoom: 7, + maxzoom: 14, + scheme: 'xyz', + }; +} + +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, + }, +}; + +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, + }, +}; + +export const VALHALLA_LAYERS: LayerSpecification[] = [ + VALHALLA_EDGES_LAYER, + VALHALLA_NODES_LAYER, +];