Skip to content

Commit 9214870

Browse files
committed
improved map styling and readability
1 parent e6118fe commit 9214870

3 files changed

Lines changed: 202 additions & 23 deletions

File tree

src/app/components/GtfsVisualizationMap.functions.tsx

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,42 @@ export function extractRouteIds(val: RouteIdsInput): string[] {
4343

4444
export function generateStopColorExpression(
4545
routeIdToColor: Record<string, string>,
46+
mapBgColor: string,
47+
altColor: string,
4648
fallback: string = '#888',
4749
): string | ExpressionSpecification {
4850
const expression: Array<string | ExpressionSpecification> = [];
4951

5052
const isHex = (s: string): boolean =>
5153
/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(s);
5254

55+
const toFullHex = (hex: string): string =>
56+
hex.length === 3
57+
? `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`
58+
: `#${hex}`;
59+
60+
const contrastRatio = (lum1: number, lum2: number): number =>
61+
(Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);
62+
63+
const bgLum = linearLuminance(mapBgColor);
64+
const altLum = linearLuminance(altColor);
65+
5366
for (const [routeId, raw] of Object.entries(routeIdToColor)) {
5467
if (raw == null) continue;
5568
const hex = String(raw).trim().replace(/^#/, '');
5669
if (!isHex(hex)) continue; // skip empty/invalid colors
5770

71+
const fullHex = toFullHex(hex);
72+
const routeLum = linearLuminance(fullHex);
73+
const crBg = contrastRatio(routeLum, bgLum); // route vs map bg
74+
const crAlt = contrastRatio(routeLum, altLum); // route vs alt color
75+
76+
// Same logic as route outline: use route color when it contrasts more against the bg,
77+
// fall back to altColor when altColor contrasts more against the route
78+
const chosenColor = crBg >= crAlt ? fullHex : altColor;
79+
5880
// route_ids is a string of quoted ids; keep your quoted match style
59-
expression.push(['in', `"${routeId}"`, ['get', 'route_ids']], `#${hex}`);
81+
expression.push(['in', `"${routeId}"`, ['get', 'route_ids']], chosenColor);
6082
}
6183

6284
// If nothing valid was added, just use the fallback color directly
@@ -68,6 +90,99 @@ export function generateStopColorExpression(
6890
return ['case', ...expression] as ExpressionSpecification;
6991
}
7092

93+
/**
94+
* Simplified relative luminance (linear approximation, 0–1 range).
95+
* Uses the Rec. 709 coefficients without sRGB gamma correction
96+
* (MapLibre expressions don't support `pow`).
97+
*/
98+
function linearLuminance(hex: string): number {
99+
const r = parseInt(hex.slice(1, 3), 16);
100+
const g = parseInt(hex.slice(3, 5), 16);
101+
const b = parseInt(hex.slice(5, 7), 16);
102+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
103+
}
104+
105+
/**
106+
* Builds a MapLibre expression that picks the outline color for a route line
107+
* by computing simplified contrast ratios against two candidate colours
108+
* (`mapBgColor` and `altOutlineColor`) and choosing the one with the
109+
* higher contrast.
110+
*
111+
* This handles both light-on-light (e.g. white route on white map) and
112+
* dark-on-dark (e.g. black route on dark outline) scenarios.
113+
*/
114+
export function generateRouteOutlineColorExpression(
115+
mapBgColor: string,
116+
altOutlineColor: string,
117+
): ExpressionSpecification {
118+
// Luminance of the two candidate outline colours (precomputed at build time)
119+
const bgLum = linearLuminance(mapBgColor);
120+
const altLum = linearLuminance(altOutlineColor);
121+
122+
// Parse the feature's route_color (stored as hex without '#') into RGBA
123+
const routeColorExpr: ExpressionSpecification = [
124+
'to-color',
125+
['concat', '#', ['get', 'route_color']],
126+
'#000000', // fallback when route_color is missing/invalid
127+
];
128+
129+
// Route luminance computed at render time (0–1)
130+
const lumExpr: ExpressionSpecification = [
131+
'/',
132+
[
133+
'+',
134+
['*', 0.2126, ['at', 0, ['var', 'rgba']]],
135+
[
136+
'+',
137+
['*', 0.7152, ['at', 1, ['var', 'rgba']]],
138+
['*', 0.0722, ['at', 2, ['var', 'rgba']]],
139+
],
140+
],
141+
255,
142+
];
143+
144+
// Contrast ratio: CR = (Lmax + 0.05) / (Lmin + 0.05)
145+
const crBgExpr: ExpressionSpecification = [
146+
'/',
147+
['+', ['max', ['var', 'lum'], bgLum], 0.05],
148+
['+', ['min', ['var', 'lum'], bgLum], 0.05],
149+
];
150+
151+
const crAltExpr: ExpressionSpecification = [
152+
'/',
153+
['+', ['max', ['var', 'lum'], altLum], 0.05],
154+
['+', ['min', ['var', 'lum'], altLum], 0.05],
155+
];
156+
157+
// Bind intermediate values, then pick the outline with the higher contrast
158+
return [
159+
'let',
160+
'rgba',
161+
['to-rgba', routeColorExpr],
162+
[
163+
'let',
164+
'lum',
165+
lumExpr,
166+
[
167+
'let',
168+
'crBg',
169+
crBgExpr,
170+
[
171+
'let',
172+
'crAlt',
173+
crAltExpr,
174+
[
175+
'case',
176+
['>=', ['var', 'crBg'], ['var', 'crAlt']],
177+
mapBgColor,
178+
altOutlineColor,
179+
],
180+
],
181+
],
182+
],
183+
];
184+
}
185+
71186
export const getBoundsFromCoordinates = (
72187
coordinates: LngLatTuple[],
73188
): LngLatBoundsLike => {

src/app/components/GtfsVisualizationMap.layers.tsx

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
type ExpressionSpecification,
66
type LayerSpecification,
77
} from 'maplibre-gl';
8-
import { generateStopColorExpression } from './GtfsVisualizationMap.functions';
8+
import {
9+
generateStopColorExpression,
10+
generateRouteOutlineColorExpression,
11+
} from './GtfsVisualizationMap.functions';
912
import { type Theme } from '@mui/material';
1013

1114
// layer helpers
@@ -40,7 +43,6 @@ export const stopsBaseFilter = (
4043
];
4144
};
4245

43-
// layers
4446
export const RoutesWhiteLayer = (
4547
filteredRouteTypeIds: string[],
4648
theme: Theme,
@@ -52,8 +54,17 @@ export const RoutesWhiteLayer = (
5254
'source-layer': 'routesoutput',
5355
type: 'line',
5456
paint: {
55-
'line-color': theme.palette.background.default,
56-
'line-width': ['match', ['get', 'route_type'], '3', 10, '1', 15, 3],
57+
'line-color': generateRouteOutlineColorExpression(
58+
theme.map.basemapTileOverallColor ?? '#ffffff',
59+
theme.palette.mode === 'light' ? theme.palette.grey[500] : '#ffffff1a',
60+
),
61+
'line-opacity': 1,
62+
'line-width': ['match', ['get', 'route_type'], '3', 5, '1', 10, 7],
63+
},
64+
layout: {
65+
'line-cap': 'round',
66+
'line-join': 'round',
67+
'line-sort-key': ['match', ['get', 'route_type'], '1', 3, '3', 2, 0],
5768
},
5869
};
5970
};
@@ -78,34 +89,50 @@ export const RouteLayer = (
7889
['==', filteredRoutes.length, 0],
7990
['in', ['get', 'route_id'], ['literal', filteredRoutes]],
8091
],
81-
0.4,
92+
['match', ['get', 'route_type'], '1', 0.8, '3', 0.4, 0.5],
8293
0.1,
8394
],
8495
},
8596
layout: {
97+
'line-cap': 'round',
98+
'line-join': 'round',
8699
'line-sort-key': ['match', ['get', 'route_type'], '1', 3, '3', 2, 0],
87100
},
88101
};
89102
};
90103

91-
export const StopLayer = (
92-
hideStops: boolean,
93-
allSelectedRouteIds: string[],
94-
stopRadius: number,
104+
export const RoutesWhiteHighlightLayer = (
105+
routeId: string | undefined,
106+
hoverInfo: string[],
107+
filteredRoutes: string[],
108+
theme: Theme,
95109
): LayerSpecification => {
96110
return {
97-
id: 'stops',
98-
filter: stopsBaseFilter(hideStops, allSelectedRouteIds),
99-
source: 'sample',
100-
'source-layer': 'stopsoutput',
101-
type: 'circle',
111+
id: 'routes-white-highlight',
112+
source: 'routes',
113+
'source-layer': 'routesoutput',
114+
type: 'line',
102115
paint: {
103-
'circle-radius': stopRadius,
104-
'circle-color': '#000000',
105-
'circle-opacity': 0.4,
116+
'line-color': generateRouteOutlineColorExpression(
117+
theme.map.basemapTileOverallColor ?? '#ffffff',
118+
theme.palette.mode === 'light'
119+
? theme.palette.grey[500]
120+
: theme.palette.grey[200],
121+
),
122+
'line-opacity': 1,
123+
'line-width': ['match', ['get', 'route_type'], '3', 10, '1', 14, 10],
106124
},
107-
minzoom: 12,
108-
maxzoom: 22,
125+
layout: {
126+
'line-cap': 'round',
127+
'line-join': 'round',
128+
'line-sort-key': ['match', ['get', 'route_type'], '1', 3, '3', 2, 0],
129+
},
130+
filter: [
131+
'any',
132+
['in', ['get', 'route_id'], ['literal', hoverInfo]],
133+
['in', ['get', 'route_id'], ['literal', filteredRoutes]],
134+
['in', ['get', 'route_id'], ['literal', [routeId ?? '']]],
135+
],
109136
};
110137
};
111138

@@ -133,12 +160,35 @@ export const RouteHighlightLayer = (
133160
};
134161
};
135162

163+
export const StopLayer = (
164+
hideStops: boolean,
165+
allSelectedRouteIds: string[],
166+
stopRadius: number,
167+
theme: Theme,
168+
): LayerSpecification => {
169+
return {
170+
id: 'stops',
171+
filter: stopsBaseFilter(hideStops, allSelectedRouteIds),
172+
source: 'sample',
173+
'source-layer': 'stopsoutput',
174+
type: 'circle',
175+
paint: {
176+
'circle-radius': stopRadius,
177+
'circle-color': theme.palette.text.primary,
178+
'circle-opacity': 0.4,
179+
},
180+
minzoom: 12,
181+
maxzoom: 22,
182+
};
183+
};
184+
136185
export const StopsHighlightLayer = (
137186
hoverInfo: string[],
138187
hideStops: boolean,
139188
filteredRoutes: string[],
140189
stopId: string | undefined,
141190
stopHighlightColorMap: Record<string, string>,
191+
theme: Theme,
142192
): LayerSpecification => {
143193
return {
144194
id: 'stops-highlight',
@@ -147,10 +197,14 @@ export const StopsHighlightLayer = (
147197
type: 'circle',
148198
paint: {
149199
'circle-radius': 7,
150-
'circle-color': generateStopColorExpression(stopHighlightColorMap),
200+
'circle-color': generateStopColorExpression(
201+
stopHighlightColorMap,
202+
theme.map.basemapTileOverallColor ?? '#ffffff',
203+
theme.palette.text.primary,
204+
),
151205
'circle-opacity': 1,
152206
},
153-
minzoom: 10,
207+
minzoom: 11,
154208
maxzoom: 22,
155209
filter: hideStops
156210
? !hideStops
@@ -198,6 +252,8 @@ export const StopsHighlightOuterLayer = (
198252
'circle-color': theme.palette.background.paper,
199253
'circle-opacity': 1,
200254
},
255+
minzoom: 11,
256+
maxzoom: 22,
201257
filter: hideStops
202258
? !hideStops
203259
: [

src/app/components/GtfsVisualizationMap.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import {
3333
RouteHighlightLayer,
3434
RouteLayer,
35+
RoutesWhiteHighlightLayer,
3536
RoutesWhiteLayer,
3637
StopLayer,
3738
StopsHighlightLayer,
@@ -590,7 +591,13 @@ export const GtfsVisualizationMap = ({
590591
},
591592
RoutesWhiteLayer(filteredRouteTypeIds, theme),
592593
RouteLayer(filteredRoutes, filteredRouteTypeIds),
593-
StopLayer(hideStops, allSelectedRouteIds, stopRadius),
594+
RoutesWhiteHighlightLayer(
595+
mapClickRouteData?.route_id,
596+
hoverInfo,
597+
filteredRoutes,
598+
theme,
599+
),
600+
StopLayer(hideStops, allSelectedRouteIds, stopRadius, theme),
594601
RouteHighlightLayer(
595602
mapClickRouteData?.route_id,
596603
hoverInfo,
@@ -602,6 +609,7 @@ export const GtfsVisualizationMap = ({
602609
filteredRoutes,
603610
mapClickStopData?.stop_id,
604611
stopHighlightColorMap,
612+
theme,
605613
),
606614
StopsHighlightOuterLayer(
607615
hoverInfo,

0 commit comments

Comments
 (0)