Skip to content

Commit 0b4e1ef

Browse files
Feat: gtfs visualization refactor, UX enhancements and bug fixes (#1419)
* gtfs visualization refactor * linting fixed * fix route select bug * PR comments from AI * stops click target changed
1 parent 9677c91 commit 0b4e1ef

File tree

5 files changed

+704
-560
lines changed

5 files changed

+704
-560
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { type RouteIdsInput } from '../utils/precompute';
2+
import {
3+
type ExpressionSpecification,
4+
type LngLatBoundsLike,
5+
} from 'maplibre-gl';
6+
7+
// Extract route_ids list from the PMTiles property (stringified JSON)
8+
export function extractRouteIds(val: RouteIdsInput): string[] {
9+
if (Array.isArray(val)) return val.map(String);
10+
if (typeof val === 'string') {
11+
try {
12+
const parsed = JSON.parse(val);
13+
if (Array.isArray(parsed)) return parsed.map(String);
14+
} catch {}
15+
// fallback: pull "quoted" tokens
16+
const out: string[] = [];
17+
val.replace(/"([^"]+)"/g, (_: unknown, id: string) => {
18+
out.push(id);
19+
return '';
20+
});
21+
if (out.length > 0) return out;
22+
// fallback2: CSV-ish
23+
return val
24+
.split(',')
25+
.map((s: string) => s.trim())
26+
.filter(Boolean);
27+
}
28+
return [];
29+
}
30+
31+
export function generateStopColorExpression(
32+
routeIdToColor: Record<string, string>,
33+
fallback: string = '#888',
34+
): string | ExpressionSpecification {
35+
const expression: Array<string | ExpressionSpecification> = [];
36+
37+
const isHex = (s: string): boolean =>
38+
/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(s);
39+
40+
for (const [routeId, raw] of Object.entries(routeIdToColor)) {
41+
if (raw == null) continue;
42+
const hex = String(raw).trim().replace(/^#/, '');
43+
if (!isHex(hex)) continue; // skip empty/invalid colors
44+
45+
// route_ids is a string of quoted ids; keep your quoted match style
46+
expression.push(['in', `"${routeId}"`, ['get', 'route_ids']], `#${hex}`);
47+
}
48+
49+
// If nothing valid was added, just use the fallback color directly
50+
if (expression.length === 0) {
51+
return fallback;
52+
}
53+
54+
expression.push(fallback);
55+
return ['case', ...expression] as ExpressionSpecification;
56+
}
57+
58+
export const getBoundsFromCoordinates = (
59+
coordinates: Array<[number, number]>,
60+
): LngLatBoundsLike => {
61+
let minLng = Number.POSITIVE_INFINITY;
62+
let minLat = Number.POSITIVE_INFINITY;
63+
let maxLng = Number.NEGATIVE_INFINITY;
64+
let maxLat = Number.NEGATIVE_INFINITY;
65+
66+
coordinates.forEach(([lat, lng]) => {
67+
minLat = Math.min(minLat, lat);
68+
maxLat = Math.max(maxLat, lat);
69+
minLng = Math.min(minLng, lng);
70+
maxLng = Math.max(maxLng, lng);
71+
});
72+
73+
return [minLng, minLat, maxLng, maxLat];
74+
};
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/* eslint-disable no-useless-escape */
2+
/** Rule disabled due to data being stored with " that need to be escaped */
3+
4+
import {
5+
type ExpressionSpecification,
6+
type LayerSpecification,
7+
} from 'maplibre-gl';
8+
import { generateStopColorExpression } from './GtfsVisualizationMap.functions';
9+
import { type Theme } from '@mui/material';
10+
11+
// layer helpers
12+
13+
export const routeTypeFilter = (
14+
filteredRouteTypeIds: string[],
15+
): ExpressionSpecification | boolean =>
16+
filteredRouteTypeIds.length > 0
17+
? ['in', ['get', 'route_type'], ['literal', filteredRouteTypeIds]]
18+
: true; // if no filter applied, show all
19+
20+
// Base filter for visible stops (main "stops" layer)
21+
export const stopsBaseFilter = (
22+
hideStops: boolean,
23+
allSelectedRouteIds: string[],
24+
): ExpressionSpecification | boolean => {
25+
// Base filter for visible stops (main "stops" layer)
26+
return hideStops
27+
? false
28+
: allSelectedRouteIds.length === 0
29+
? true // no filters → show all
30+
: [
31+
'any',
32+
...allSelectedRouteIds.map(
33+
(id) =>
34+
[
35+
'in',
36+
`\"${id}\"`,
37+
['get', 'route_ids'],
38+
] as ExpressionSpecification, // route_ids stored as quoted-string list
39+
),
40+
];
41+
};
42+
43+
// layers
44+
export const RoutesWhiteLayer = (
45+
filteredRouteTypeIds: string[],
46+
theme: Theme,
47+
): LayerSpecification => {
48+
return {
49+
id: 'routes-white',
50+
source: 'routes',
51+
filter: routeTypeFilter(filteredRouteTypeIds),
52+
'source-layer': 'routesoutput',
53+
type: 'line',
54+
paint: {
55+
'line-color': theme.palette.background.default,
56+
'line-width': ['match', ['get', 'route_type'], '3', 10, '1', 15, 3],
57+
},
58+
};
59+
};
60+
61+
export const RouteLayer = (
62+
filteredRoutes: string[],
63+
filteredRouteTypeIds: string[],
64+
): LayerSpecification => {
65+
return {
66+
id: 'routes',
67+
filter: routeTypeFilter(filteredRouteTypeIds),
68+
source: 'routes',
69+
'source-layer': 'routesoutput',
70+
type: 'line',
71+
paint: {
72+
'line-color': ['concat', '#', ['get', 'route_color']],
73+
'line-width': ['match', ['get', 'route_type'], '3', 1, '1', 4, 3],
74+
'line-opacity': [
75+
'case',
76+
[
77+
'any',
78+
['==', filteredRoutes.length, 0],
79+
['in', ['get', 'route_id'], ['literal', filteredRoutes]],
80+
],
81+
0.4,
82+
0.1,
83+
],
84+
},
85+
layout: {
86+
'line-sort-key': ['match', ['get', 'route_type'], '1', 3, '3', 2, 0],
87+
},
88+
};
89+
};
90+
91+
export const StopLayer = (
92+
hideStops: boolean,
93+
allSelectedRouteIds: string[],
94+
stopRadius: number,
95+
): LayerSpecification => {
96+
return {
97+
id: 'stops',
98+
filter: stopsBaseFilter(hideStops, allSelectedRouteIds),
99+
source: 'sample',
100+
'source-layer': 'stopsoutput',
101+
type: 'circle',
102+
paint: {
103+
'circle-radius': stopRadius,
104+
'circle-color': '#000000',
105+
'circle-opacity': 0.4,
106+
},
107+
minzoom: 12,
108+
maxzoom: 22,
109+
};
110+
};
111+
112+
export const RouteHighlightLayer = (
113+
routeId: string | undefined,
114+
hoverInfo: string[],
115+
filteredRoutes: string[],
116+
): LayerSpecification => {
117+
return {
118+
id: 'routes-highlight',
119+
source: 'routes',
120+
'source-layer': 'routesoutput',
121+
type: 'line',
122+
paint: {
123+
'line-color': ['concat', '#', ['get', 'route_color']],
124+
'line-opacity': 1,
125+
'line-width': ['match', ['get', 'route_type'], '3', 5, '1', 6, 3],
126+
},
127+
filter: [
128+
'any',
129+
['in', ['get', 'route_id'], ['literal', hoverInfo]],
130+
['in', ['get', 'route_id'], ['literal', filteredRoutes]],
131+
['in', ['get', 'route_id'], ['literal', [routeId ?? '']]],
132+
],
133+
};
134+
};
135+
136+
export const StopsHighlightLayer = (
137+
hoverInfo: string[],
138+
hideStops: boolean,
139+
filteredRoutes: string[],
140+
stopId: string | undefined,
141+
stopHighlightColorMap: Record<string, string>,
142+
): LayerSpecification => {
143+
return {
144+
id: 'stops-highlight',
145+
source: 'sample',
146+
'source-layer': 'stopsoutput',
147+
type: 'circle',
148+
paint: {
149+
'circle-radius': 7,
150+
'circle-color': generateStopColorExpression(stopHighlightColorMap),
151+
'circle-opacity': 1,
152+
},
153+
minzoom: 10,
154+
maxzoom: 22,
155+
filter: hideStops
156+
? !hideStops
157+
: [
158+
'any',
159+
['in', ['get', 'stop_id'], ['literal', hoverInfo]],
160+
['==', ['get', 'stop_id'], ['literal', stopId ?? '']],
161+
[
162+
'any',
163+
...filteredRoutes.map((id) => {
164+
return [
165+
'in',
166+
`\"${id}\"`,
167+
['get', 'route_ids'],
168+
] as ExpressionSpecification;
169+
}),
170+
],
171+
[
172+
'any',
173+
...hoverInfo.map((id) => {
174+
return [
175+
'in',
176+
`\"${id}\"`,
177+
['get', 'route_ids'],
178+
] as ExpressionSpecification;
179+
}),
180+
],
181+
],
182+
};
183+
};
184+
185+
export const StopsHighlightOuterLayer = (
186+
hoverInfo: string[],
187+
hideStops: boolean,
188+
filteredRoutes: string[],
189+
theme: Theme,
190+
): LayerSpecification => {
191+
return {
192+
id: 'stops-highlight-outer',
193+
source: 'sample',
194+
'source-layer': 'stopsoutput',
195+
type: 'circle',
196+
paint: {
197+
'circle-radius': 3,
198+
'circle-color': theme.palette.background.paper,
199+
'circle-opacity': 1,
200+
},
201+
filter: hideStops
202+
? !hideStops
203+
: [
204+
'any',
205+
['in', ['get', 'stop_id'], ['literal', hoverInfo]],
206+
[
207+
'any',
208+
...filteredRoutes.map((id) => {
209+
return [
210+
'in',
211+
`\"${id}\"`,
212+
['get', 'route_ids'],
213+
] as ExpressionSpecification;
214+
}),
215+
],
216+
[
217+
'any',
218+
...hoverInfo.map((id) => {
219+
return [
220+
'in',
221+
`\"${id}\"`,
222+
['get', 'route_ids'],
223+
] as ExpressionSpecification;
224+
}),
225+
],
226+
],
227+
};
228+
};
229+
230+
export const StopsIndexLayer = (): LayerSpecification => {
231+
return {
232+
id: 'stops-index',
233+
source: 'sample',
234+
'source-layer': 'stopsoutput',
235+
type: 'circle',
236+
paint: {
237+
'circle-opacity': 0,
238+
'circle-radius': 1,
239+
},
240+
};
241+
};

0 commit comments

Comments
 (0)