Skip to content

Added Colorblind-Accessible Color Palettes and Opacity Control for Isochrones#344

Closed
ritgit24 wants to merge 2 commits intovalhalla:masterfrom
ritgit24:master
Closed

Added Colorblind-Accessible Color Palettes and Opacity Control for Isochrones#344
ritgit24 wants to merge 2 commits intovalhalla:masterfrom
ritgit24:master

Conversation

@ritgit24
Copy link

@ritgit24 ritgit24 commented Mar 5, 2026

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

  • Viridis (Colourblind-Friendly) : A perceptually uniform gradient ranging from dark purple through blue and green to yellow.
  • Magma(Colourblind-Friendly): A high-contrast gradient progressing from light yellow through orange and pink to deep purple and black.
  • Deafult: Ensures no disruption for users familiar with the previous styling

2. Opacity Control

  • User-adjustable slider (0.0 - 1.0)
  • Default: 0.4 (40% opacity)
  • Improves map readability by allowing base map to show through

3. Intuitive UI

  • Controls placed in Isochrones Settings panel
  • Color Palette dropdown selector
  • Opacity slider with real-time preview

Technical Implementation

New Files

  • src/utils/isochrone-colors.ts - Color palette utilities with interpolation functions
  • src/utils/isochrone-colors.spec.ts - Comprehensive tests for color interpolation logic

Modified Files

  • src/stores/isochrones-store.ts - Added colorPalette and opacity state
  • src/components/isochrones/waypoints.tsx - Added UI controls
  • src/components/isochrones/waypoints.spec.tsx - Added missing store mocks for new state fields
  • src/components/map/parts/isochrone-polygons.tsx - Dynamic color application
  • src/components/map/parts/isochrone-polygons.spec.tsx - Updated tests with contour-based mock data

Testing

  • Opacity slider updates visualization in real-time
  • Build compilation passes with no errors
  • Updated isochrone-polygons tests to include contour-based mock data and new store fields

📷 Screenshots

Screenshot 2026-03-04 192628

Copilot AI review requested due to automatic review settings March 5, 2026 09:45
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds colorblind-accessible color palette options and an opacity slider for isochrone visualizations, addressing issue #283. It introduces viridis and magma color palettes as alternatives to the original red-green gradient, and allows users to control the transparency of isochrone polygons through a slider in the Isochrones Settings panel.

Changes:

  • New utility module (isochrone-colors.ts) with color interpolation functions for viridis, magma, and default palettes, along with comprehensive unit tests.
  • Store and UI updates to add colorPalette and opacity state, a dropdown selector for palettes, and an opacity slider in the Isochrones Settings panel.
  • Updated isochrone-polygons.tsx to dynamically compute fill colors based on the selected palette and apply the opacity setting.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/utils/isochrone-colors.ts New file defining color palette utilities with interpolation functions for viridis, magma, and default palettes
src/utils/isochrone-colors.spec.ts New tests for color palette utilities
src/stores/isochrones-store.ts Added colorPalette and opacity state with actions updateColorPalette and updateOpacity
src/components/map/parts/isochrone-polygons.tsx Dynamic fill color computation per palette and opacity application
src/components/map/parts/isochrone-polygons.spec.tsx Updated mock data with contour values and new store fields
src/components/isochrones/waypoints.tsx Added Color Palette dropdown and Opacity slider to settings panel
src/components/isochrones/waypoints.spec.tsx Added mocks for new store fields and actions

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +7 to +119
function getViridisColor(value: number): string {
const v = Math.max(0, Math.min(1, value));

//5 key stops
const colors: [number, string][] = [
[0.0, '#440154'], // dark purple
[0.25, '#31688e'], // blue
[0.5, '#35b779'], // green
[0.75, '#90d743'], // yellow-green
[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);
}


function getMagmaColor(value: number): string {
const v = Math.max(0, Math.min(1, value));

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);
}
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 three color functions (getViridisColor, getCurrentColor, getMagmaColor) contain nearly identical interpolation logic. The only difference is the color stops array. Consider extracting a single generic interpolateColors(value: number, colors: [number, string][]) function and having each palette function simply call it with its specific color stops. This would eliminate ~60 lines of duplicated code and make it easier to add new palettes in the future.

Copilot uses AI. Check for mistakes.
Comment on lines +256 to +272
<div className="flex flex-col gap-2">
<Label htmlFor="color-palette" className="text-sm font-medium">
Color Palette
</Label>
<Select value={colorPalette} onValueChange={updateColorPalette}>
<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>
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.
Comment on lines +166 to +168
current: {
label: 'Default',
value: 'current' as const,
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 palette name 'current' is ambiguous — it could be confused with "currently selected" rather than "the original/legacy color scheme." Consider renaming it to 'default' or 'classic' to more clearly communicate that this is the original red-green gradient palette. The label in the UI already says "Default", so the internal key should match that meaning.

Copilot uses AI. Check for mistakes.
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.
Comment on lines +46 to +83
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);
}


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 getCurrentColor function and its case 'current' path in getIsochroneColor are never actually reached at the call site. In isochrone-polygons.tsx (line 28-29), when colorPalette === 'current', the code uses feature.properties?.fill directly instead of calling getIsochroneColor. This means getCurrentColor is dead code. Consider either:

  1. Removing the 'current' case shortcut in isochrone-polygons.tsx and always going through getIsochroneColor (which would require getCurrentColor to use the server-assigned fill color or be redesigned), or
  2. Removing the getCurrentColor function and the 'current' case from getIsochroneColor since they're not used.
Suggested change
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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +54
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,
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.
<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.
Comment on lines +52 to +54
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,
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.
Comment on lines +33 to +36
colorPalette: 'current',
opacity: 0.4,
updateColorPalette: mockUpdateColorPalette,
updateOpacity: mockUpdateOpacity,
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.
Comment on lines +165 to +179
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;
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_PALETTES object has inconsistent indentation. The current entry uses 5 spaces, magma uses 4 spaces, and viridis uses 2 spaces. These should all use the same indentation level (2 spaces, consistent with the rest of the codebase).

Copilot uses AI. Check for mistakes.
@ritgit24 ritgit24 closed this Mar 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make isochrone color palette user definable

2 participants