Skip to content
Merged
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
53 changes: 52 additions & 1 deletion src/components/isochrones/isochrone-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,32 @@ import {
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { MetricItem } from '@/components/ui/metric-item';
import { SelectSetting } from '@/components/ui/select-setting';
import { SliderSetting } from '@/components/ui/slider-setting';
import {
ISOCHRONE_PALETTES,
isPaletteId,
DEFAULT_OPACITY,
} from '@/utils/isochrone-palettes';

interface IsochronesCardProps {
data: ValhallaIsochroneResponse;
showOnMap: boolean;
}

const paletteOptions = ISOCHRONE_PALETTES.map((p) => ({
key: p.id,
value: p.id,
text: p.label,
}));

export const IsochroneCard = ({ data, showOnMap }: IsochronesCardProps) => {
const toggleShowOnMap = useIsochronesStore((state) => state.toggleShowOnMap);

const colorPalette = useIsochronesStore((state) => state.colorPalette);
const opacity = useIsochronesStore((state) => state.opacity);
const updateVisualization = useIsochronesStore(
(state) => state.updateVisualization
);
const handleChange = (checked: boolean) => {
toggleShowOnMap(checked);
};
Expand All @@ -48,6 +65,40 @@ export const IsochroneCard = ({ data, showOnMap }: IsochronesCardProps) => {
<Label htmlFor="show-on-map">Show on map</Label>
</div>
</div>
<div className="flex flex-col gap-1">
<SelectSetting
id="colorPalette"
label="Color Palette"
description="Choose a color palette for the isochrone polygons. Viridis is a colorblind-friendly option."
value={colorPalette}
options={paletteOptions}
onValueChange={(value) => {
if (isPaletteId(value)) {
updateVisualization({ colorPalette: value });
}
}}
/>
<SliderSetting
id="opacity"
label="Opacity"
description="Controls the transparency of the isochrone fill. Lower values make the map underneath more visible."
min={0}
max={1}
step={0.05}
value={opacity}
onValueChange={(values) => {
const value = values[0] ?? DEFAULT_OPACITY;
updateVisualization({ opacity: value });
}}
onInputChange={(values) => {
let value = values[0] ?? DEFAULT_OPACITY;
value = isNaN(value)
? DEFAULT_OPACITY
: Math.min(1, Math.max(0, value));
updateVisualization({ opacity: value });
}}
/>
</div>
<div className="flex flex-col justify-between gap-2">
{data.features
.filter((feature) => !feature.properties?.type)
Expand Down
3 changes: 3 additions & 0 deletions src/components/map/parts/isochrone-polygons.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ const createMockState = (overrides = {}) => ({
show: true,
},
successful: true,
colorPalette: 'default',
opacity: 0.4,
maxRange: 30,
...overrides,
});

Expand Down
54 changes: 40 additions & 14 deletions src/components/map/parts/isochrone-polygons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ 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 {
ISOCHRONE_PALETTES,
getPaletteColor,
} from '@/utils/isochrone-palettes';

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 data = useMemo(() => {
if (!isoResults || !isoSuccessful) return null;
Expand All @@ -14,25 +20,45 @@ export function IsochronePolygons() {
const hasNoFeatures = Object.keys(isoResults.data).length === 0;
if (hasNoFeatures) return null;

const features: Feature[] = [];

for (const feature of isoResults.data.features) {
if (['Polygon', 'MultiPolygon'].includes(feature.geometry.type)) {
features.push({
...feature,
properties: {
...feature.properties,
fillColor: feature.properties?.fill || '#6200ea',
},
});
}
const palette =
ISOCHRONE_PALETTES.find((p) => p.id === colorPalette) ??
ISOCHRONE_PALETTES[0];
const selectedPaletteColors = palette?.colors ?? null;

const polygonFeatures = isoResults.data.features.filter((f) =>
['Polygon', 'MultiPolygon'].includes(f.geometry.type)
);

if (selectedPaletteColors === null) {
return {
type: 'FeatureCollection',
features: polygonFeatures,
} as FeatureCollection;
}

const actualMax = polygonFeatures.reduce(
(m, f) => Math.max(m, f.properties?.contour ?? 0),
0
);

const features: Feature[] = polygonFeatures.map((feature) => ({
...feature,
properties: {
...feature.properties,
fill: getPaletteColor(
selectedPaletteColors,
actualMax > 0 ? (feature.properties?.contour ?? 0) / actualMax : 1
),
},
}));

features.reverse();

return {
type: 'FeatureCollection',
features,
} as FeatureCollection;
}, [isoResults, isoSuccessful]);
}, [isoResults, isoSuccessful, colorPalette]);

if (!data) return null;

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

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

interface IsochroneActions {
Expand All @@ -34,6 +38,10 @@ interface IsochroneActions {
name: 'maxRange' | 'interval' | 'denoise' | 'generalize';
value: number;
}) => void;
updateVisualization: (params: {
colorPalette?: PaletteId;
opacity?: number;
}) => void;
receiveGeocodeResults: (addresses: ActiveWaypoint[]) => void;
}

Expand All @@ -51,6 +59,8 @@ export const useIsochronesStore = create<IsochroneStore>()(
denoise: 0.1,
generalize: 0,
results: { data: null, show: true },
colorPalette: 'default',
opacity: DEFAULT_OPACITY,

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

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

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

const BINARY = ['#000000', '#ffffff'];

const VIRIDIS = ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725'];

describe('getPaletteColor', () => {
it('returns DEFAULT_FILL for an empty palette', () => {
expect(getPaletteColor([], 0)).toBe(DEFAULT_FILL);
});

it('returns the only color when the palette has one entry', () => {
expect(getPaletteColor(['#aabbcc'], 0.5)).toBe('#aabbcc');
});

it('maps t=1 (largest contour) to the palette end', () => {
expect(getPaletteColor(VIRIDIS, 1)).toBe('#fde725');
expect(getPaletteColor(BINARY, 1)).toBe('#ffffff');
});

it('maps t=0 to the palette start', () => {
expect(getPaletteColor(VIRIDIS, 0)).toBe('#440154');
expect(getPaletteColor(BINARY, 0)).toBe('#000000');
});

it('is stable: same t always produces the same color', () => {
const t = 30 / 30;
expect(getPaletteColor(VIRIDIS, t)).toBe('#fde725');

const tMid = 10 / 30;
expect(getPaletteColor(VIRIDIS, tMid)).toBe(getPaletteColor(VIRIDIS, tMid));
});

it('returns exact palette colors at t=0 and t=1', () => {
expect(getPaletteColor(BINARY, 0)).toBe('#000000');
expect(getPaletteColor(BINARY, 1)).toBe('#ffffff');
});

it('interpolates correctly at t=0.5 on a two-stop palette', () => {
expect(getPaletteColor(BINARY, 0.5)).toBe('#808080');
});

it('largest contour is always palette end across different maxRange scenarios', () => {
expect(getPaletteColor(VIRIDIS, 30 / 30)).toBe('#fde725');
expect(getPaletteColor(VIRIDIS, 60 / 60)).toBe('#fde725');
});
});
61 changes: 61 additions & 0 deletions src/utils/isochrone-palettes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export type PaletteId = 'default' | 'viridis' | 'plasma' | 'blues';

export const DEFAULT_FILL = '#6200ea';

export const DEFAULT_OPACITY = 0.4;

export interface IsochronePalette {
id: PaletteId;
label: string;
colors: string[] | null;
}

export const ISOCHRONE_PALETTES: IsochronePalette[] = [
{
id: 'default',
label: 'Default',
colors: null,
},
{
id: 'viridis',
label: 'Viridis (colorblind-friendly)',
colors: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725'],
},
{
id: 'plasma',
label: 'Plasma',
colors: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921'],
},
{
id: 'blues',
label: 'Blues',
colors: ['#084594', '#2171b5', '#4292c6', '#9ecae1', '#deebf7'],
},
];

export function isPaletteId(value: string): value is PaletteId {
return ISOCHRONE_PALETTES.some((p) => p.id === value);
}

function interpolateHex(c1: string, c2: string, t: number): string {
const r1 = parseInt(c1.slice(1, 3), 16);
const g1 = parseInt(c1.slice(3, 5), 16);
const b1 = parseInt(c1.slice(5, 7), 16);
const r2 = parseInt(c2.slice(1, 3), 16);
const g2 = parseInt(c2.slice(3, 5), 16);
const b2 = parseInt(c2.slice(5, 7), 16);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}

export function getPaletteColor(colors: string[], t: number): string {
if (colors.length === 0) return DEFAULT_FILL;
if (colors.length === 1) return colors[0]!;
const rawIdx = t * (colors.length - 1);
const lower = Math.floor(rawIdx);
const upper = Math.min(lower + 1, colors.length - 1);
if (lower === upper) return colors[lower]!;
return interpolateHex(colors[lower]!, colors[upper]!, rawIdx - lower);
}