Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/isochrones/waypoints.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const mockUpdateTextInput = vi.fn();
const mockReceiveGeocodeResults = vi.fn();
const mockRefetchIsochrones = vi.fn();
const mockNavigate = vi.fn();
const mockUpdateColorPalette = vi.fn();
const mockUpdateOpacity = vi.fn();

vi.mock('@tanstack/react-router', () => ({
useNavigate: vi.fn(() => mockNavigate),
Expand All @@ -28,6 +30,10 @@ vi.mock('@/stores/isochrones-store', () => ({
interval: 10,
denoise: 1,
generalize: 200,
colorPalette: 'current',
opacity: 0.4,
updateColorPalette: mockUpdateColorPalette,
updateOpacity: mockUpdateOpacity,
Comment on lines +33 to +36
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mocks for updateColorPalette and updateOpacity are added, but there are no corresponding test cases that verify the Color Palette selector and Opacity slider are rendered in the settings panel or that interactions with them invoke the correct store actions. The existing tests verify rendering and interaction for all other settings (maxRange, interval, denoise, generalize). Consider adding similar tests for the new controls to maintain consistent coverage.

Copilot uses AI. Check for mistakes.
userInput: 'Berlin',
geocodeResults: [],
receiveGeocodeResults: mockReceiveGeocodeResults,
Expand Down
51 changes: 51 additions & 0 deletions src/components/isochrones/waypoints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,32 @@ import { AccessibleIcon } from '@radix-ui/react-accessible-icon';
import { parseUrlParams } from '@/utils/parse-url-params';
import { useNavigate } from '@tanstack/react-router';
import { useIsochronesQuery } from '@/hooks/use-isochrones-queries';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { ISOCHRONE_PALETTES } from '@/utils/isochrone-colors';

export const Waypoints = () => {
const params = parseUrlParams();
const updateSettings = useIsochronesStore((state) => state.updateSettings);
const updateColorPalette = useIsochronesStore(
(state) => state.updateColorPalette
);
const updateOpacity = useIsochronesStore((state) => state.updateOpacity);
const { refetch: refetchIsochrones } = useIsochronesQuery();
const clearIsos = useIsochronesStore((state) => state.clearIsos);
const updateTextInput = useIsochronesStore((state) => state.updateTextInput);
const maxRange = useIsochronesStore((state) => state.maxRange);
const interval = useIsochronesStore((state) => state.interval);
const denoise = useIsochronesStore((state) => state.denoise);
const generalize = useIsochronesStore((state) => state.generalize);
const colorPalette = useIsochronesStore((state) => state.colorPalette);
const opacity = useIsochronesStore((state) => state.opacity);
const navigate = useNavigate({ from: '/$activeTab' });
const userInput = useIsochronesStore((state) => state.userInput);
const geocodeResults = useIsochronesStore((state) => state.geocodeResults);
Expand Down Expand Up @@ -237,6 +252,42 @@ export const Waypoints = () => {
makeIsochronesRequestDebounced();
}}
/>

<div className="flex flex-col gap-2">
<Label htmlFor="color-palette" className="text-sm font-medium">
Color Palette
</Label>
<Select value={colorPalette} onValueChange={updateColorPalette}>
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Radix Select onValueChange callback provides a plain string, but updateColorPalette expects an IsochronePalette type. While this likely works at runtime since the Select options are restricted to valid palette values, passing updateColorPalette directly is not type-safe. Consider adding a type cast or validation in the handler, e.g., onValueChange={(value) => updateColorPalette(value as IsochronePalette)}.

Suggested change
<Select value={colorPalette} onValueChange={updateColorPalette}>
<Select
value={colorPalette}
onValueChange={(value) => updateColorPalette(value as IsochronePalette)}
>

Copilot uses AI. Check for mistakes.
<SelectTrigger id="color-palette">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ISOCHRONE_PALETTES).map(([key, option]) => (
<SelectItem key={key} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
Comment on lines +256 to +272
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's already a SelectSetting component (src/components/ui/select-setting.tsx) that wraps a Select with a Label, description tooltip (with a help icon), and consistent styling — matching the pattern used by SliderSetting for other settings in this same panel. Consider using SelectSetting here instead of manually assembling Label, Select, SelectTrigger, etc. This would provide a consistent UI (including a description/help tooltip for the color palette setting) and reduce inline code. The ISOCHRONE_PALETTES entries would need to be mapped to SelectOption format ({ key, text, value }).

Copilot uses AI. Check for mistakes.

<SliderSetting
id="opacity"
label="Opacity"
description="The opacity of the isochrone visualization (0 = transparent, 1 = fully opaque)"
min={0}
max={1}
step={0.1}
value={opacity}
onValueChange={(values) => {
const value = values[0] ?? 0.4;
updateOpacity(value);
}}
onInputChange={(values) => {
const value = values[0] ?? 0.4;
updateOpacity(value);
}}
/>
</CollapsibleContent>
</Collapsible>
</div>
Expand Down
12 changes: 9 additions & 3 deletions src/components/map/parts/isochrone-polygons.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ const createMockState = (overrides = {}) => ({
],
],
},
properties: { fill: '#ff0000' },
properties: { fill: '#ff0000', contour: 10 },
},
],
},
show: true,
},
successful: true,
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,
Comment on lines +52 to +54
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: colorPalette, opacity, and maxRange are indented with 4 spaces instead of 2 spaces, which is inconsistent with the rest of the object properties in createMockState (e.g., successful on line 51 uses 2 spaces).

Suggested change
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +54
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isochrone-polygons tests only exercise the 'current' palette. There's no test verifying that the fillColor property on features is computed correctly when using 'viridis' or 'magma' palettes (i.e., that getIsochroneColor is called with the correct normalized value). Consider adding a test case that uses a non-default colorPalette and verifies the resulting feature colors in the Source data.

Copilot uses AI. Check for mistakes.
...overrides,
});

Expand Down Expand Up @@ -130,7 +133,7 @@ describe('IsochronePolygons', () => {
expect.objectContaining({
id: 'isochrones-fill',
type: 'fill',
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.4 },
paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': 0.4 },
})
);
});
Expand Down Expand Up @@ -162,7 +165,7 @@ describe('IsochronePolygons', () => {
{
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [] },
properties: { fill: '#ff0000' },
properties: { fill: '#ff0000', contour: 10 },
},
{
type: 'Feature',
Expand All @@ -174,6 +177,9 @@ describe('IsochronePolygons', () => {
show: true,
},
successful: true,
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,
};
return selector(state);
});
Expand Down
23 changes: 19 additions & 4 deletions src/components/map/parts/isochrone-polygons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { useMemo } from 'react';
import { Source, Layer } from 'react-map-gl/maplibre';
import { useIsochronesStore } from '@/stores/isochrones-store';
import type { Feature, FeatureCollection } from 'geojson';
import { getIsochroneColor } from '@/utils/isochrone-colors';

export function IsochronePolygons() {
const isoResults = useIsochronesStore((state) => state.results);
const isoSuccessful = useIsochronesStore((state) => state.successful);
const colorPalette = useIsochronesStore((state) => state.colorPalette);
const opacity = useIsochronesStore((state) => state.opacity);
const maxRange = useIsochronesStore((state) => state.maxRange);

const data = useMemo(() => {
if (!isoResults || !isoSuccessful) return null;
Expand All @@ -18,11 +22,22 @@ export function IsochronePolygons() {

for (const feature of isoResults.data.features) {
if (['Polygon', 'MultiPolygon'].includes(feature.geometry.type)) {
const contourValue = feature.properties?.contour ?? maxRange;
let fillColor: string;

if (colorPalette === 'current') {
fillColor = feature.properties?.fill || '#6200ea';
} else {
const normalizedValue = contourValue / maxRange;
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential division by zero: if maxRange is ever 0, contourValue / maxRange would produce Infinity/NaN, which would then be passed to getIsochroneColor. While the UI slider enforces min={1}, the store doesn't validate maxRange > 0 in updateSettings, so this could occur if state is manipulated from URL params or programmatically. Consider adding a guard such as const normalizedValue = maxRange > 0 ? contourValue / maxRange : 0;.

Suggested change
const normalizedValue = contourValue / maxRange;
const normalizedValue = maxRange > 0 ? contourValue / maxRange : 0;

Copilot uses AI. Check for mistakes.
fillColor = getIsochroneColor(normalizedValue, colorPalette);
}

features.push({
...feature,
properties: {
...feature.properties,
fillColor: feature.properties?.fill || '#6200ea',
fillColor,
contourValue,
},
});
}
Expand All @@ -32,7 +47,7 @@ export function IsochronePolygons() {
type: 'FeatureCollection',
features,
} as FeatureCollection;
}, [isoResults, isoSuccessful]);
}, [isoResults, isoSuccessful, colorPalette, maxRange]);

if (!data) return null;

Expand All @@ -42,8 +57,8 @@ export function IsochronePolygons() {
id="isochrones-fill"
type="fill"
paint={{
'fill-color': ['get', 'fill'],
'fill-opacity': 0.4,
'fill-color': ['get', 'fillColor'],
'fill-opacity': opacity,
}}
/>
<Layer
Expand Down
25 changes: 25 additions & 0 deletions src/stores/isochrones-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ActiveWaypoint,
ValhallaIsochroneResponse,
} from '@/components/types';
import type { IsochronePalette } from '@/utils/isochrone-colors';

interface IsochroneResult {
data: ValhallaIsochroneResponse | null;
Expand All @@ -20,6 +21,8 @@ interface IsochroneState {
interval: number;
denoise: number;
generalize: number;
colorPalette: IsochronePalette;
opacity: number;
results: IsochroneResult;
}

Expand All @@ -34,6 +37,8 @@ interface IsochroneActions {
name: 'maxRange' | 'interval' | 'denoise' | 'generalize';
value: number;
}) => void;
updateColorPalette: (palette: IsochronePalette) => void;
updateOpacity: (opacity: number) => void;
receiveGeocodeResults: (addresses: ActiveWaypoint[]) => void;
}

Expand All @@ -50,6 +55,8 @@ export const useIsochronesStore = create<IsochroneStore>()(
interval: 10,
denoise: 0.1,
generalize: 0,
colorPalette: 'current' as IsochronePalette,
opacity: 0.4,
results: { data: null, show: true },

clearIsos: () =>
Expand Down Expand Up @@ -103,6 +110,24 @@ export const useIsochronesStore = create<IsochroneStore>()(
'updateSettings'
),

updateColorPalette: (palette) =>
set(
(state) => {
state.colorPalette = palette;
},
undefined,
'updateColorPalette'
),

updateOpacity: (opacity) =>
set(
(state) => {
state.opacity = Math.max(0, Math.min(1, opacity));
},
undefined,
'updateOpacity'
),

receiveGeocodeResults: (addresses) =>
set(
(state) => {
Expand Down
133 changes: 133 additions & 0 deletions src/utils/isochrone-colors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { getIsochroneColor, ISOCHRONE_PALETTES } from './isochrone-colors';

describe('isochrone-colors', () => {
describe('getIsochroneColor', () => {
it('should return a valid hex color for any value between 0 and 1', () => {
const testValues = [0, 0.25, 0.5, 0.75, 1.0];
for (const value of testValues) {
const color = getIsochroneColor(value, 'viridis');
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
}
});

it('should handle values outside [0, 1] by clamping them', () => {
const colorNegative = getIsochroneColor(-0.5, 'viridis');
const colorZero = getIsochroneColor(0, 'viridis');
const colorOver = getIsochroneColor(1.5, 'viridis');
const colorOne = getIsochroneColor(1.0, 'viridis');

expect(colorNegative).toBe(colorZero);

expect(colorOver).toBe(colorOne);
});

describe('viridis palette', () => {
it('should start with dark purple at value 0', () => {
const color = getIsochroneColor(0, 'viridis');

expect(color).toMatch(/^#[0-9a-f]{6}$/i);
});

it('should end with yellow at value 1.0', () => {
const color = getIsochroneColor(1.0, 'viridis');
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
});

it('should return different colors across the range', () => {
const colors = [0, 0.25, 0.5, 0.75, 1.0].map((v) =>
getIsochroneColor(v, 'viridis')
);
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(colors.length);
});

it('should have smooth interpolation (no flat regions)', () => {
const color74 = getIsochroneColor(0.74, 'viridis');
const color76 = getIsochroneColor(0.76, 'viridis');
expect(color74).not.toBe(color76);
});
});

describe('current palette', () => {
it('should return colors for all values', () => {
const testValues = [0, 0.25, 0.5, 0.75, 1.0];
for (const value of testValues) {
const color = getIsochroneColor(value, 'current');
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
}
});

it('should return different colors across the range', () => {
const colors = [0, 0.33, 0.66, 1.0].map((v) =>
getIsochroneColor(v, 'current')
);
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBeGreaterThan(1);
});
});

describe('magma palette', () => {
it('should return colors for all values', () => {
const testValues = [0, 0.25, 0.5, 0.75, 1.0];
for (const value of testValues) {
const color = getIsochroneColor(value, 'magma');
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
}
});

it('should return different colors across the range', () => {
const colors = [0, 0.25, 0.5, 0.75, 1.0].map((v) =>
getIsochroneColor(v, 'magma')
);
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(colors.length);
});
});

it('should use current palette as default', () => {
const colorDefault = getIsochroneColor(0.5);
const colorExplicit = getIsochroneColor(0.5, 'current');
expect(colorDefault).toBe(colorExplicit);
});

it('should return current palette for invalid palette name', () => {
const colorInvalid = getIsochroneColor(0.5, 'invalid' as any);
const colorCurrent = getIsochroneColor(0.5, 'current');
expect(colorInvalid).toBe(colorCurrent);
});

it('should return different colors for different palettes', () => {
const color1 = getIsochroneColor(0.5, 'viridis');
const color2 = getIsochroneColor(0.5, 'magma');
const color3 = getIsochroneColor(0.5, 'current');

// At least some should be different
const uniqueColors = new Set([color1, color2, color3]);
expect(uniqueColors.size).toBeGreaterThan(1);
});
});

describe('ISOCHRONE_PALETTES', () => {
it('should define all required palettes', () => {
expect(ISOCHRONE_PALETTES).toHaveProperty('viridis');
expect(ISOCHRONE_PALETTES).toHaveProperty('magma');
expect(ISOCHRONE_PALETTES).toHaveProperty('current');
});

it('should have correct structure for each palette', () => {
Object.entries(ISOCHRONE_PALETTES).forEach(([key, palette]) => {
expect(palette).toHaveProperty('label');
expect(palette).toHaveProperty('value');
expect(typeof palette.label).toBe('string');
expect(palette.value).toBe(key);
});
});

it('should have non-empty labels', () => {
Object.values(ISOCHRONE_PALETTES).forEach((palette) => {
expect(palette.label.length).toBeGreaterThan(0);
});
});
});
});
Loading
Loading