feat : Added Colorblind-Friendly Color Palettes and Opacity Control for Isochrones#343
feat : Added Colorblind-Friendly Color Palettes and Opacity Control for Isochrones#343ritgit24 wants to merge 1 commit intovalhalla:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds colorblind-friendly color palette options (Viridis, Magma) and a user-controlled opacity slider to the isochrone visualization. It introduces a new utility module for color interpolation, extends the isochrone store with two new state fields, and wires up the new UI controls in the Isochrone Settings panel.
Changes:
- New
isochrone-colors.tsutility with three color palettes (Default, Viridis, Magma) and linear interpolation between color stops. isochrones-store.tsextended withcolorPaletteandopacitystate fields and their corresponding update actions.isochrone-polygons.tsxupdated to apply the selected palette and opacity dynamically;waypoints.tsxgains a color palette selector and an opacity slider.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
src/utils/isochrone-colors.ts |
New file: defines color palettes, interpolation functions, and the ISOCHRONE_PALETTES constant |
src/stores/isochrones-store.ts |
Adds colorPalette and opacity state with updateColorPalette and updateOpacity actions |
src/components/map/parts/isochrone-polygons.tsx |
Applies selected palette and opacity to polygon rendering |
src/components/map/parts/isochrone-polygons.spec.tsx |
Updates test mock to include new state fields (contour, colorPalette, opacity, maxRange) |
src/components/isochrones/waypoints.tsx |
Adds Color Palette dropdown and Opacity slider to Isochrone Settings panel |
Comments suppressed due to low confidence (1)
src/components/map/parts/isochrone-polygons.spec.tsx:193
- The updated
IsochronePolygonscomponent now supports three color palettes, but the spec only tests the'current'palette. There are no test cases verifying that whencolorPaletteis set to'viridis'or'magma', thefillColorproperty on features is computed bygetIsochroneColorinstead of usingfeature.properties.fill. Adding at least one test case with a non-default palette (e.g.,colorPalette: 'viridis') would ensure the palette-switching logic is covered.
describe('IsochronePolygons', () => {
beforeEach(() => {
mockSource.mockClear();
mockLayer.mockClear();
mockUseIsochronesStore.mockClear();
});
it('should render nothing when results is null', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = { results: null, successful: false };
return selector(state);
});
const { container } = render(<IsochronePolygons />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when not successful', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = createMockState({ successful: false });
return selector(state);
});
const { container } = render(<IsochronePolygons />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when show is false', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = createMockState({
results: { ...createMockState().results, show: false },
});
return selector(state);
});
const { container } = render(<IsochronePolygons />);
expect(container.firstChild).toBeNull();
});
it('should render Source when data is valid', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = createMockState();
return selector(state);
});
render(<IsochronePolygons />);
expect(mockSource).toHaveBeenCalledWith(
expect.objectContaining({ id: 'isochrones', type: 'geojson' })
);
});
it('should render two layers (fill and outline)', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = createMockState();
return selector(state);
});
render(<IsochronePolygons />);
expect(mockLayer).toHaveBeenCalledTimes(2);
});
it('should render fill layer with correct paint', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = createMockState();
return selector(state);
});
render(<IsochronePolygons />);
expect(mockLayer).toHaveBeenCalledWith(
expect.objectContaining({
id: 'isochrones-fill',
type: 'fill',
paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': 0.4 },
})
);
});
it('should render outline layer with correct paint', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = createMockState();
return selector(state);
});
render(<IsochronePolygons />);
expect(mockLayer).toHaveBeenCalledWith(
expect.objectContaining({
id: 'isochrones-outline',
type: 'line',
paint: { 'line-color': '#fff', 'line-width': 1, 'line-opacity': 1 },
})
);
});
it('should filter only Polygon and MultiPolygon features', () => {
mockUseIsochronesStore.mockImplementation((selector) => {
const state = {
results: {
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [] },
properties: { fill: '#ff0000', contour: 10 },
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [0, 0] },
properties: { type: 'snapped' },
},
],
},
show: true,
},
successful: true,
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,
};
return selector(state);
});
render(<IsochronePolygons />);
const sourceCall = mockSource.mock.calls[0]?.[0];
expect(sourceCall?.data.features).toHaveLength(1);
expect(sourceCall?.data.features[0].geometry.type).toBe('Polygon');
});
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| [0.0, '#440154'], // dark purple | ||
| [0.25, '#31688e'], // blue | ||
| [0.5, '#35b779'], // green | ||
| [0.75, '#fde724'], // yellow |
There was a problem hiding this comment.
The Viridis palette has a duplicate color stop: both index 3 (value 0.75) and index 4 (value 1.0) map to #fde724 (yellow). This means the gradient is flat and doesn't change color in the last quarter of the range (from contour 75% to 100% of maxRange). The typical Viridis palette ends at yellow at value 1.0, so the stop at 0.75 should use a different color (e.g., #90d743 — a yellow-green) to maintain a gradual transition. Consider replacing the 0.75 stop with a distinct intermediate color so there is meaningful visual differentiation across the full range.
| [0.75, '#fde724'], // yellow | |
| [0.75, '#90d743'], // yellow-green |
| } | ||
|
|
||
| function getViridisColor(value: number): string { | ||
| //to keep value between 0 annd 1 |
There was a problem hiding this comment.
The spelling comment says "annd" (double 'n'), which is a typo. It should be "and".
| //to keep value between 0 annd 1 | |
| // to keep value between 0 and 1 |
| case 'magma': | ||
| return getMagmaColor(value); |
There was a problem hiding this comment.
The case 'magma' branch inside getIsochroneColor has inconsistent indentation — it is indented two extra levels compared to the surrounding case 'viridis' and case 'current' branches. This is a code style inconsistency that will look wrong in the editor and may confuse readers.
| case 'magma': | |
| return getMagmaColor(value); | |
| case 'magma': | |
| return getMagmaColor(value); |
| <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); | ||
| }} | ||
| /> |
There was a problem hiding this comment.
The existing waypoints.spec.tsx test mock for useIsochronesStore does not include colorPalette, opacity, updateColorPalette, or updateOpacity. Since Waypoints now reads these new store fields, all existing tests in waypoints.spec.tsx that call render(<Waypoints />) will receive undefined for these values, which may cause errors (e.g., the Select receiving undefined for onValueChange). The waypoints.spec.tsx file should be updated to include these new fields and mocks in the store mock.
| function getViridisColor(value: number): string { | ||
| //to keep value between 0 annd 1 | ||
| const v = Math.max(0, Math.min(1, value)); | ||
|
|
||
| // Simplifying viridis palette with 5 key stops | ||
| const colors: [number, string][] = [ | ||
| [0.0, '#440154'], // dark purple | ||
| [0.25, '#31688e'], // blue | ||
| [0.5, '#35b779'], // green | ||
| [0.75, '#fde724'], // yellow | ||
| [1.0, '#fde724'], // yellow | ||
| ]; | ||
|
|
||
|
|
||
| let lower: [number, string] = colors[0]!; | ||
| let upper: [number, string] = colors[colors.length - 1]!; | ||
|
|
||
| for (let i = 0; i < colors.length - 1; i++) { | ||
| const current = colors[i]; | ||
| const next = colors[i + 1]; | ||
| if (current && next && v >= current[0] && v <= next[0]) { | ||
| lower = current; | ||
| upper = next; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| const range = upper[0] - lower[0]; | ||
| const t = range === 0 ? 0 : (v - lower[0]) / range; | ||
|
|
||
| const color1 = hexToRgb(lower[1]); | ||
| const color2 = hexToRgb(upper[1]); | ||
|
|
||
| const r = Math.round(color1.r + (color2.r - color1.r) * t); | ||
| const g = Math.round(color1.g + (color2.g - color1.g) * t); | ||
| const b = Math.round(color1.b + (color2.b - color1.b) * t); | ||
|
|
||
| return rgbToHex(r, g, b); | ||
| } | ||
|
|
||
| function getCurrentColor(value: number): string { | ||
| const v = Math.max(0, Math.min(1, value)); | ||
|
|
||
|
|
||
| const colors: [number, string][] = [ | ||
| [0.0, '#00ff00'], // green | ||
| [0.33, '#ffff00'], // yellow | ||
| [0.66, '#ff8800'], // orange | ||
| [1.0, '#ff0000'], // red | ||
| ]; | ||
|
|
||
| let lower: [number, string] = colors[0]!; | ||
| let upper: [number, string] = colors[colors.length - 1]!; | ||
|
|
||
| for (let i = 0; i < colors.length - 1; i++) { | ||
| const current = colors[i]; | ||
| const next = colors[i + 1]; | ||
| if (current && next && v >= current[0] && v <= next[0]) { | ||
| lower = current; | ||
| upper = next; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| const range = upper[0] - lower[0]; | ||
| const t = range === 0 ? 0 : (v - lower[0]) / range; | ||
|
|
||
| const color1 = hexToRgb(lower[1]); | ||
| const color2 = hexToRgb(upper[1]); | ||
|
|
||
| const r = Math.round(color1.r + (color2.r - color1.r) * t); | ||
| const g = Math.round(color1.g + (color2.g - color1.g) * t); | ||
| const b = Math.round(color1.b + (color2.b - color1.b) * t); | ||
|
|
||
| return rgbToHex(r, g, b); | ||
| } | ||
|
|
||
| /** | ||
| * Magma color palette - perceptually uniform, colorblind friendly | ||
| * Maps value 0-1 to colors: yellow -> orange -> pink -> purple -> dark | ||
| */ | ||
| function getMagmaColor(value: number): string { | ||
| const v = Math.max(0, Math.min(1, value)); | ||
|
|
||
| // Magma palette: yellow -> orange -> pink -> purple -> almost black | ||
| const colors: [number, string][] = [ | ||
| [0.0, '#fcfdbf'], // light yellow | ||
| [0.25, '#fc8961'], // orange | ||
| [0.5, '#b73779'], // magenta/pink | ||
| [0.75, '#51127c'], // purple | ||
| [1.0, '#000004'], // almost black | ||
| ]; | ||
|
|
||
| let lower: [number, string] = colors[0]!; | ||
| let upper: [number, string] = colors[colors.length - 1]!; | ||
|
|
||
| for (let i = 0; i < colors.length - 1; i++) { | ||
| const current = colors[i]; | ||
| const next = colors[i + 1]; | ||
| if (current && next && v >= current[0] && v <= next[0]) { | ||
| lower = current; | ||
| upper = next; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| const range = upper[0] - lower[0]; | ||
| const t = range === 0 ? 0 : (v - lower[0]) / range; | ||
|
|
||
| const color1 = hexToRgb(lower[1]); | ||
| const color2 = hexToRgb(upper[1]); | ||
|
|
||
| const r = Math.round(color1.r + (color2.r - color1.r) * t); | ||
| const g = Math.round(color1.g + (color2.g - color1.g) * t); | ||
| const b = Math.round(color1.b + (color2.b - color1.b) * t); | ||
|
|
||
| return rgbToHex(r, g, b); | ||
| } |
There was a problem hiding this comment.
The three palette-specific color functions (getViridisColor, getCurrentColor, getMagmaColor) contain significant duplicated logic: they all define a colors array, use the same binary search / interpolation loop, and call the same hexToRgb / rgbToHex helpers. This violates DRY and makes the code harder to maintain. Consider extracting a shared helper function that accepts a palette's color stops array and a value, then call it from each palette function. For example: interpolatePalette(colors: [number, string][], value: number): string.
| current: { | ||
| label: 'Default', | ||
| value: 'current' as const, | ||
| }, | ||
| magma: { | ||
| label: 'Magma (colorblind-friendly)', | ||
| value: 'magma' as const, | ||
| }, | ||
| viridis: { | ||
| label: 'Viridis (colorblind-friendly)', | ||
| value: 'viridis' as const, | ||
| }, | ||
|
|
There was a problem hiding this comment.
The ISOCHRONE_PALETTES constant has inconsistent indentation: the current key is indented with 5 spaces and magma with 4 spaces, while viridis uses 2 spaces (matching the standard). The object should use consistent 2-space indentation for all keys.
| current: { | |
| label: 'Default', | |
| value: 'current' as const, | |
| }, | |
| magma: { | |
| label: 'Magma (colorblind-friendly)', | |
| value: 'magma' as const, | |
| }, | |
| viridis: { | |
| label: 'Viridis (colorblind-friendly)', | |
| value: 'viridis' as const, | |
| }, | |
| current: { | |
| label: 'Default', | |
| value: 'current' as const, | |
| }, | |
| magma: { | |
| label: 'Magma (colorblind-friendly)', | |
| value: 'magma' as const, | |
| }, | |
| viridis: { | |
| label: 'Viridis (colorblind-friendly)', | |
| value: 'viridis' as const, | |
| }, |
| colorPalette: 'current', | ||
| opacity: 0.4, | ||
| maxRange: 10, |
There was a problem hiding this comment.
The createMockState object has inconsistent indentation: successful, colorPalette, opacity, and maxRange fields are mixed between 2-space and 4-space indentation. Specifically colorPalette, opacity, and maxRange use 4-space indentation while successful uses 2-space. All sibling properties in the same object should use the same indentation level.
| colorPalette: 'current', | |
| opacity: 0.4, | |
| maxRange: 10, | |
| colorPalette: 'current', | |
| opacity: 0.4, | |
| maxRange: 10, |
|
|
||
| for (const feature of isoResults.data.features) { | ||
| if (['Polygon', 'MultiPolygon'].includes(feature.geometry.type)) { | ||
| const contourValue = feature.properties?.contour || maxRange; |
There was a problem hiding this comment.
Using the || operator to fall back to maxRange when feature.properties?.contour is falsy is potentially incorrect. If the contour property exists but has the value 0 (which, while unlikely from Valhalla, is a valid falsy number), it will incorrectly be treated as absent and maxRange will be used instead, producing a normalizedValue of 1.0 for that feature. Prefer the nullish coalescing operator ?? to only fall back when the value is null or undefined.
| const contourValue = feature.properties?.contour || maxRange; | |
| const contourValue = feature.properties?.contour ?? maxRange; |
| type ColorPalette = 'viridis' | 'current' | 'magma'; | ||
|
|
||
| interface RGBColor { | ||
| r: number; | ||
| g: number; | ||
| b: number; | ||
| } | ||
|
|
||
| function getViridisColor(value: number): string { | ||
| //to keep value between 0 annd 1 | ||
| const v = Math.max(0, Math.min(1, value)); | ||
|
|
||
| // Simplifying viridis palette with 5 key stops | ||
| const colors: [number, string][] = [ | ||
| [0.0, '#440154'], // dark purple | ||
| [0.25, '#31688e'], // blue | ||
| [0.5, '#35b779'], // green | ||
| [0.75, '#fde724'], // yellow | ||
| [1.0, '#fde724'], // yellow | ||
| ]; | ||
|
|
||
|
|
||
| let lower: [number, string] = colors[0]!; | ||
| let upper: [number, string] = colors[colors.length - 1]!; | ||
|
|
||
| for (let i = 0; i < colors.length - 1; i++) { | ||
| const current = colors[i]; | ||
| const next = colors[i + 1]; | ||
| if (current && next && v >= current[0] && v <= next[0]) { | ||
| lower = current; | ||
| upper = next; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| const range = upper[0] - lower[0]; | ||
| const t = range === 0 ? 0 : (v - lower[0]) / range; | ||
|
|
||
| const color1 = hexToRgb(lower[1]); | ||
| const color2 = hexToRgb(upper[1]); | ||
|
|
||
| const r = Math.round(color1.r + (color2.r - color1.r) * t); | ||
| const g = Math.round(color1.g + (color2.g - color1.g) * t); | ||
| const b = Math.round(color1.b + (color2.b - color1.b) * t); | ||
|
|
||
| return rgbToHex(r, g, b); | ||
| } | ||
|
|
||
| function getCurrentColor(value: number): string { | ||
| const v = Math.max(0, Math.min(1, value)); | ||
|
|
||
|
|
||
| const colors: [number, string][] = [ | ||
| [0.0, '#00ff00'], // green | ||
| [0.33, '#ffff00'], // yellow | ||
| [0.66, '#ff8800'], // orange | ||
| [1.0, '#ff0000'], // red | ||
| ]; | ||
|
|
||
| let lower: [number, string] = colors[0]!; | ||
| let upper: [number, string] = colors[colors.length - 1]!; | ||
|
|
||
| for (let i = 0; i < colors.length - 1; i++) { | ||
| const current = colors[i]; | ||
| const next = colors[i + 1]; | ||
| if (current && next && v >= current[0] && v <= next[0]) { | ||
| lower = current; | ||
| upper = next; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| const range = upper[0] - lower[0]; | ||
| const t = range === 0 ? 0 : (v - lower[0]) / range; | ||
|
|
||
| const color1 = hexToRgb(lower[1]); | ||
| const color2 = hexToRgb(upper[1]); | ||
|
|
||
| const r = Math.round(color1.r + (color2.r - color1.r) * t); | ||
| const g = Math.round(color1.g + (color2.g - color1.g) * t); | ||
| const b = Math.round(color1.b + (color2.b - color1.b) * t); | ||
|
|
||
| return rgbToHex(r, g, b); | ||
| } | ||
|
|
||
| /** | ||
| * Magma color palette - perceptually uniform, colorblind friendly | ||
| * Maps value 0-1 to colors: yellow -> orange -> pink -> purple -> dark | ||
| */ | ||
| function getMagmaColor(value: number): string { | ||
| const v = Math.max(0, Math.min(1, value)); | ||
|
|
||
| // Magma palette: yellow -> orange -> pink -> purple -> almost black | ||
| const colors: [number, string][] = [ | ||
| [0.0, '#fcfdbf'], // light yellow | ||
| [0.25, '#fc8961'], // orange | ||
| [0.5, '#b73779'], // magenta/pink | ||
| [0.75, '#51127c'], // purple | ||
| [1.0, '#000004'], // almost black | ||
| ]; | ||
|
|
||
| let lower: [number, string] = colors[0]!; | ||
| let upper: [number, string] = colors[colors.length - 1]!; | ||
|
|
||
| for (let i = 0; i < colors.length - 1; i++) { | ||
| const current = colors[i]; | ||
| const next = colors[i + 1]; | ||
| if (current && next && v >= current[0] && v <= next[0]) { | ||
| lower = current; | ||
| upper = next; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| const range = upper[0] - lower[0]; | ||
| const t = range === 0 ? 0 : (v - lower[0]) / range; | ||
|
|
||
| const color1 = hexToRgb(lower[1]); | ||
| const color2 = hexToRgb(upper[1]); | ||
|
|
||
| const r = Math.round(color1.r + (color2.r - color1.r) * t); | ||
| const g = Math.round(color1.g + (color2.g - color1.g) * t); | ||
| const b = Math.round(color1.b + (color2.b - color1.b) * t); | ||
|
|
||
| return rgbToHex(r, g, b); | ||
| } | ||
|
|
||
| /** | ||
| * Get color for a value (0-1) based on selected palette | ||
| * @param value - Number between 0 and 1 (0 = closest/easiest, 1 = farthest/hardest) | ||
| * @param palette - Selected color palette | ||
| * @returns Hex color string | ||
| */ | ||
| export function getIsochroneColor( | ||
| value: number, | ||
| palette: ColorPalette = 'current' | ||
| ): string { | ||
| switch (palette) { | ||
| case 'viridis': | ||
| return getViridisColor(value); | ||
| case 'current': | ||
| return getCurrentColor(value); | ||
| case 'magma': | ||
| return getMagmaColor(value); | ||
| default: | ||
| return getCurrentColor(value); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Convert hex color to RGB | ||
| */ | ||
| function hexToRgb(hex: string): RGBColor { | ||
| const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | ||
| return { | ||
| r: parseInt(result?.[1] || '0', 16), | ||
| g: parseInt(result?.[2] || '0', 16), | ||
| b: parseInt(result?.[3] || '0', 16), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Convert RGB to hex color | ||
| */ | ||
| function rgbToHex(r: number, g: number, b: number): string { | ||
| return ( | ||
| '#' + | ||
| [r, g, b] | ||
| .map((x) => { | ||
| const hex = x.toString(16); | ||
| return hex.length === 1 ? '0' + hex : hex; | ||
| }) | ||
| .join('') | ||
| ); | ||
| } | ||
|
|
||
| export const ISOCHRONE_PALETTES = { | ||
| current: { | ||
| label: 'Default', | ||
| value: 'current' as const, | ||
| }, | ||
| magma: { | ||
| label: 'Magma (colorblind-friendly)', | ||
| value: 'magma' as const, | ||
| }, | ||
| viridis: { | ||
| label: 'Viridis (colorblind-friendly)', | ||
| value: 'viridis' as const, | ||
| }, | ||
|
|
||
| } as const; | ||
|
|
||
| export type IsochronePalette = keyof typeof ISOCHRONE_PALETTES; |
There was a problem hiding this comment.
Other utility files in src/utils/ have corresponding spec files (e.g., valhalla.spec.ts, geom.spec.ts, heightgraph.spec.ts). The new isochrone-colors.ts file, which contains non-trivial color interpolation logic, has no test file. Given the bug in the Viridis palette (duplicate stop at 0.75 and 1.0) and the duplicated interpolation logic, unit tests for getIsochroneColor (and the underlying palette functions) would help catch regressions. A corresponding isochrone-colors.spec.ts should be added.
| @@ -0,0 +1,193 @@ | |||
| type ColorPalette = 'viridis' | 'current' | 'magma'; | |||
There was a problem hiding this comment.
The file-local ColorPalette type (line 1) manually lists the same string literals as the keys of ISOCHRONE_PALETTES. This is redundant with IsochronePalette (line 193), which is derived from ISOCHRONE_PALETTES and is the same set. If a new palette is added to ISOCHRONE_PALETTES in the future, ColorPalette must be manually kept in sync, which is error-prone. The getIsochroneColor function's parameter could use IsochronePalette directly (replacing ColorPalette), or ColorPalette could be derived as keyof typeof ISOCHRONE_PALETTES to eliminate the duplication.
| type ColorPalette = 'viridis' | 'current' | 'magma'; | |
| type ColorPalette = keyof typeof ISOCHRONE_PALETTES; |
|
see #340 also, you better follow our gsoc contribution guidelines which are clearly stated in the OSM gsoc page. this is fully AI generated and not declared! |
|
Sincere apologies for that . I will follow the contribution guidelines more carefully going forward. |
Closes #283
Addresses isochrone color customization issue
🛠️ Fixes Issue
The current isochrone visualization uses a red-green color gradient that is not accessible for colorblind users. Additionally, the colors are too opaque, making it difficult to see the underlying map features.
👨💻 Changes proposed
This PR implements:
1. Colorblind-Friendly Color Palettes
2. Opacity Control
3. Intuitive UI
Technical Implementation
New Files
src/utils/isochrone-colors.ts- Color palette utilities with interpolation functionsModified Files
src/stores/isochrones-store.ts- AddedcolorPaletteandopacitystatesrc/components/isochrones/waypoints.tsx- Added UI controlssrc/components/map/parts/isochrone-polygons.tsx- Dynamic color applicationsrc/components/map/parts/isochrone-polygons.spec.tsx- Changed according to the additional store stateTesting
📷 Screenshots