Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions web-app/src/app/components/GtfsVisualizationMap.functions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { type RouteIdsInput } from '../utils/precompute';
import {
type ExpressionSpecification,
type LngLatBoundsLike,
} from 'maplibre-gl';

// Extract route_ids list from the PMTiles property (stringified JSON)
export function extractRouteIds(val: RouteIdsInput): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') {
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {}
// fallback: pull "quoted" tokens
const out: string[] = [];
val.replace(/"([^"]+)"/g, (_: unknown, id: string) => {
out.push(id);
return '';
});
if (out.length > 0) return out;
// fallback2: CSV-ish
return val
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
}
return [];
}

export function generateStopColorExpression(
routeIdToColor: Record<string, string>,
fallback: string = '#888',
): string | ExpressionSpecification {
const expression: Array<string | ExpressionSpecification> = [];

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

for (const [routeId, raw] of Object.entries(routeIdToColor)) {
if (raw == null) continue;
const hex = String(raw).trim().replace(/^#/, '');
if (!isHex(hex)) continue; // skip empty/invalid colors

// route_ids is a string of quoted ids; keep your quoted match style
expression.push(['in', `"${routeId}"`, ['get', 'route_ids']], `#${hex}`);
}

// If nothing valid was added, just use the fallback color directly
if (expression.length === 0) {
return fallback;
}

expression.push(fallback);
return ['case', ...expression] as ExpressionSpecification;
}

export const getBoundsFromCoordinates = (
coordinates: Array<[number, number]>,
): LngLatBoundsLike => {
let minLng = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLng = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;

coordinates.forEach(([lat, lng]) => {
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLng = Math.min(minLng, lng);
maxLng = Math.max(maxLng, lng);
});

return [minLng, minLat, maxLng, maxLat];
};
241 changes: 241 additions & 0 deletions web-app/src/app/components/GtfsVisualizationMap.layers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/* eslint-disable no-useless-escape */
/** Rule disabled due to data being stored with " that need to be escaped */

import {
type ExpressionSpecification,
type LayerSpecification,
} from 'maplibre-gl';
import { generateStopColorExpression } from './GtfsVisualizationMap.functions';
import { type Theme } from '@mui/material';

// layer helpers

export const routeTypeFilter = (
filteredRouteTypeIds: string[],
): ExpressionSpecification | boolean =>
filteredRouteTypeIds.length > 0
? ['in', ['get', 'route_type'], ['literal', filteredRouteTypeIds]]
: true; // if no filter applied, show all

// Base filter for visible stops (main "stops" layer)
export const stopsBaseFilter = (
hideStops: boolean,
allSelectedRouteIds: string[],
): ExpressionSpecification | boolean => {
// Base filter for visible stops (main "stops" layer)
return hideStops
? false
: allSelectedRouteIds.length === 0
? true // no filters → show all
: [
'any',
...allSelectedRouteIds.map(
(id) =>
[
'in',
`\"${id}\"`,
['get', 'route_ids'],
] as ExpressionSpecification, // route_ids stored as quoted-string list
),
];
};

// layers
export const RoutesWhiteLayer = (
filteredRouteTypeIds: string[],
theme: Theme,
): LayerSpecification => {
return {
id: 'routes-white',
source: 'routes',
filter: routeTypeFilter(filteredRouteTypeIds),
'source-layer': 'routesoutput',
type: 'line',
paint: {
'line-color': theme.palette.background.default,
Copy link

Copilot AI Oct 27, 2025

Choose a reason for hiding this comment

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

React hooks like useTheme() cannot be called inside regular functions. The theme should be passed as a parameter to RoutesWhiteLayer instead of being called within the function body.

Copilot uses AI. Check for mistakes.
'line-width': ['match', ['get', 'route_type'], '3', 10, '1', 15, 3],
Copy link

Copilot AI Oct 27, 2025

Choose a reason for hiding this comment

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

The line width values (10, 15, 3) differ from the original code which used (4, 15, 3). This change to route type '3' width from 4 to 10 is a significant visual change that isn't mentioned in the PR description. Consider documenting this intentional change or verifying it's correct.

Suggested change
'line-width': ['match', ['get', 'route_type'], '3', 10, '1', 15, 3],
'line-width': ['match', ['get', 'route_type'], '3', 4, '1', 15, 3],

Copilot uses AI. Check for mistakes.
},
};
};

export const RouteLayer = (
filteredRoutes: string[],
filteredRouteTypeIds: string[],
): LayerSpecification => {
return {
id: 'routes',
filter: routeTypeFilter(filteredRouteTypeIds),
source: 'routes',
'source-layer': 'routesoutput',
type: 'line',
paint: {
'line-color': ['concat', '#', ['get', 'route_color']],
'line-width': ['match', ['get', 'route_type'], '3', 1, '1', 4, 3],
'line-opacity': [
'case',
[
'any',
['==', filteredRoutes.length, 0],
['in', ['get', 'route_id'], ['literal', filteredRoutes]],
],
0.4,
0.1,
],
},
layout: {
'line-sort-key': ['match', ['get', 'route_type'], '1', 3, '3', 2, 0],
},
};
};

export const StopLayer = (
hideStops: boolean,
allSelectedRouteIds: string[],
stopRadius: number,
): LayerSpecification => {
return {
id: 'stops',
filter: stopsBaseFilter(hideStops, allSelectedRouteIds),
source: 'sample',
'source-layer': 'stopsoutput',
type: 'circle',
paint: {
'circle-radius': stopRadius,
'circle-color': '#000000',
'circle-opacity': 0.4,
},
minzoom: 12,
maxzoom: 22,
};
};

export const RouteHighlightLayer = (
routeId: string | undefined,
hoverInfo: string[],
filteredRoutes: string[],
): LayerSpecification => {
return {
id: 'routes-highlight',
source: 'routes',
'source-layer': 'routesoutput',
type: 'line',
paint: {
'line-color': ['concat', '#', ['get', 'route_color']],
'line-opacity': 1,
'line-width': ['match', ['get', 'route_type'], '3', 5, '1', 6, 3],
},
filter: [
'any',
['in', ['get', 'route_id'], ['literal', hoverInfo]],
['in', ['get', 'route_id'], ['literal', filteredRoutes]],
['in', ['get', 'route_id'], ['literal', [routeId ?? '']]],
],
};
};

export const StopsHighlightLayer = (
hoverInfo: string[],
hideStops: boolean,
filteredRoutes: string[],
stopId: string | undefined,
stopHighlightColorMap: Record<string, string>,
): LayerSpecification => {
return {
id: 'stops-highlight',
source: 'sample',
'source-layer': 'stopsoutput',
type: 'circle',
paint: {
'circle-radius': 7,
'circle-color': generateStopColorExpression(stopHighlightColorMap),
'circle-opacity': 1,
},
minzoom: 10,
maxzoom: 22,
filter: hideStops
? !hideStops
: [
'any',
['in', ['get', 'stop_id'], ['literal', hoverInfo]],
['==', ['get', 'stop_id'], ['literal', stopId ?? '']],
[
'any',
...filteredRoutes.map((id) => {
return [
'in',
`\"${id}\"`,
['get', 'route_ids'],
] as ExpressionSpecification;
}),
],
[
'any',
...hoverInfo.map((id) => {
return [
'in',
`\"${id}\"`,
['get', 'route_ids'],
] as ExpressionSpecification;
}),
],
],
};
};

export const StopsHighlightOuterLayer = (
hoverInfo: string[],
hideStops: boolean,
filteredRoutes: string[],
theme: Theme,
): LayerSpecification => {
return {
id: 'stops-highlight-outer',
source: 'sample',
'source-layer': 'stopsoutput',
type: 'circle',
paint: {
'circle-radius': 3,
'circle-color': theme.palette.background.paper,
'circle-opacity': 1,
},
filter: hideStops
? !hideStops
: [
'any',
['in', ['get', 'stop_id'], ['literal', hoverInfo]],
[
'any',
...filteredRoutes.map((id) => {
return [
'in',
`\"${id}\"`,
['get', 'route_ids'],
] as ExpressionSpecification;
}),
],
[
'any',
...hoverInfo.map((id) => {
return [
'in',
`\"${id}\"`,
['get', 'route_ids'],
] as ExpressionSpecification;
}),
],
],
};
};

export const StopsIndexLayer = (): LayerSpecification => {
return {
id: 'stops-index',
source: 'sample',
'source-layer': 'stopsoutput',
type: 'circle',
paint: {
'circle-opacity': 0,
'circle-radius': 1,
},
};
};
Loading
Loading