Skip to content

feat : Added Colorblind-Friendly Color Palettes and Opacity Control for Isochrones#343

Closed
ritgit24 wants to merge 1 commit intovalhalla:masterfrom
ritgit24:master
Closed

feat : Added Colorblind-Friendly Color Palettes and Opacity Control for Isochrones#343
ritgit24 wants to merge 1 commit intovalhalla:masterfrom
ritgit24:master

Conversation

@ritgit24
Copy link

@ritgit24 ritgit24 commented Mar 4, 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

Modified Files

  • src/stores/isochrones-store.ts - Added colorPalette and opacity state
  • src/components/isochrones/waypoints.tsx - Added UI controls
  • src/components/map/parts/isochrone-polygons.tsx - Dynamic color application
  • src/components/map/parts/isochrone-polygons.spec.tsx - Changed according to the additional store state

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 4, 2026 14:01
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-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.ts utility with three color palettes (Default, Viridis, Magma) and linear interpolation between color stops.
  • isochrones-store.ts extended with colorPalette and opacity state fields and their corresponding update actions.
  • isochrone-polygons.tsx updated to apply the selected palette and opacity dynamically; waypoints.tsx gains 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 IsochronePolygons component now supports three color palettes, but the spec only tests the 'current' palette. There are no test cases verifying that when colorPalette is set to 'viridis' or 'magma', the fillColor property on features is computed by getIsochroneColor instead of using feature.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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
[0.75, '#fde724'], // yellow
[0.75, '#90d743'], // yellow-green

Copilot uses AI. Check for mistakes.
}

function getViridisColor(value: number): string {
//to keep value between 0 annd 1
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The spelling comment says "annd" (double 'n'), which is a typo. It should be "and".

Suggested change
//to keep value between 0 annd 1
// to keep value between 0 and 1

Copilot uses AI. Check for mistakes.
Comment on lines +143 to +144
case 'magma':
return getMagmaColor(value);
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
case 'magma':
return getMagmaColor(value);
case 'magma':
return getMagmaColor(value);

Copilot uses AI. Check for mistakes.
Comment on lines +274 to +290
<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);
}}
/>
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +126
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);
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +190
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,
},

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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,
},

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +54
colorPalette: 'current',
opacity: 0.4,
maxRange: 10,
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

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

Copilot uses AI. Check for mistakes.

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

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
const contourValue = feature.properties?.contour || maxRange;
const contourValue = feature.properties?.contour ?? maxRange;

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +193
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;
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,193 @@
type ColorPalette = 'viridis' | 'current' | 'magma';
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
type ColorPalette = 'viridis' | 'current' | 'magma';
type ColorPalette = keyof typeof ISOCHRONE_PALETTES;

Copilot uses AI. Check for mistakes.
@nilsnolde
Copy link
Member

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!

@nilsnolde nilsnolde closed this Mar 5, 2026
@ritgit24
Copy link
Author

ritgit24 commented Mar 5, 2026

Sincere apologies for that . I will follow the contribution guidelines more carefully going forward.

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

3 participants