From 02129465b15fedf1884a0a3f9cdb4dd0edd6bc96 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 24 Oct 2025 11:38:58 -0400 Subject: [PATCH 1/5] gtfs visualization refactor --- .../GtfsVisualizationMap.functions.tsx | 74 +++ .../GtfsVisualizationMap.layers.tsx | 203 ++++++ .../app/components/GtfsVisualizationMap.tsx | 579 ++---------------- .../app/components/Map/ScanningOverlay.tsx | 134 ++++ .../Map/SelectedRoutesStopsPanel.tsx | 130 ++++ 5 files changed, 608 insertions(+), 512 deletions(-) create mode 100644 web-app/src/app/components/GtfsVisualizationMap.functions.tsx create mode 100644 web-app/src/app/components/GtfsVisualizationMap.layers.tsx create mode 100644 web-app/src/app/components/Map/ScanningOverlay.tsx create mode 100644 web-app/src/app/components/Map/SelectedRoutesStopsPanel.tsx diff --git a/web-app/src/app/components/GtfsVisualizationMap.functions.tsx b/web-app/src/app/components/GtfsVisualizationMap.functions.tsx new file mode 100644 index 000000000..77dbc5d5f --- /dev/null +++ b/web-app/src/app/components/GtfsVisualizationMap.functions.tsx @@ -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, + fallback = '#888', +): ExpressionSpecification { + const expression: any[] = ['case']; + + const isHex = (s: string) => /^[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 === 1) { + return fallback as unknown as ExpressionSpecification; + } + + expression.push(fallback); + return 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]; +}; diff --git a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx new file mode 100644 index 000000000..2d7d25bac --- /dev/null +++ b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx @@ -0,0 +1,203 @@ +import { type ExpressionSpecification, type LayerSpecification } from 'maplibre-gl'; +import { generateStopColorExpression } from './GtfsVisualizationMap.functions'; +import { useTheme } 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 any, // route_ids stored as quoted-string list + ), + ]); +}; + +// layers +export const RoutesWhiteLayer = ( + filteredRouteTypeIds: string[], +): LayerSpecification => { + const theme = useTheme(); + return { + id: 'routes-white', + source: 'routes', + filter: routeTypeFilter(filteredRouteTypeIds), + 'source-layer': 'routesoutput', + type: 'line', + paint: { + 'line-color': theme.palette.background.default, + 'line-width': ['match', ['get', 'route_type'], '3', 10, '1', 15, 3], + }, + }; +}; + +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, +): LayerSpecification => { + return { + id: 'stops-highlight', + source: 'sample', + 'source-layer': 'stopsoutput', + type: 'circle', + paint: { + 'circle-radius': 7, + 'circle-color': generateStopColorExpression( + stopHighlightColorMap, + ) as ExpressionSpecification, + '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 any; + }), + ], + [ + 'any', + ...hoverInfo.map((id) => { + return ['in', `\"${id}\"`, ['get', 'route_ids']] as any; + }), + ], + ], + }; +}; + +export const StopsHighlightOuterLayer = ( + hoverInfo: string[], + hideStops: boolean, + filteredRoutes: string[], +): LayerSpecification => { + const theme = useTheme(); + 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 any; + }), + ], + [ + 'any', + ...hoverInfo.map((id) => { + return ['in', `\"${id}\"`, ['get', 'route_ids']] as any; + }), + ], + ], + }; +}; diff --git a/web-app/src/app/components/GtfsVisualizationMap.tsx b/web-app/src/app/components/GtfsVisualizationMap.tsx index f23ad3b3b..e7ca2be96 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.tsx @@ -1,5 +1,3 @@ -/* eslint-disable */ - import { useEffect, useMemo, useRef, useState } from 'react'; import Map, { MapProvider, @@ -14,21 +12,27 @@ import maplibregl, { import 'maplibre-gl/dist/maplibre-gl.css'; import { Protocol } from 'pmtiles'; import { type LatLngExpression } from 'leaflet'; -import { Box, Button, Typography, useTheme } from '@mui/material'; -import Draggable from 'react-draggable'; - -import { LinearProgress, CircularProgress } from '@mui/material'; +import { Box, useTheme } from '@mui/material'; import type { MapElementType } from './MapElement'; import { MapElement, MapRouteElement, MapStopElement } from './MapElement'; import { MapDataPopup } from './Map/MapDataPopup'; import type { GtfsRoute } from '../types'; -import { useTranslation } from 'react-i18next'; +import { createPrecomputation, extendBBoxes } from '../utils/precompute'; +import { SelectedRoutesStopsPanel } from './Map/SelectedRoutesStopsPanel'; +import { ScanningOverlay } from './Map/ScanningOverlay'; +import { + extractRouteIds, + getBoundsFromCoordinates, +} from './GtfsVisualizationMap.functions'; import { - createPrecomputation, - extendBBoxes, - type RouteIdsInput, -} from '../utils/precompute'; + RouteHighlightLayer, + RouteLayer, + RoutesWhiteLayer, + StopLayer, + StopsHighlightLayer, + StopsHighlightOuterLayer, +} from './GtfsVisualizationMap.layers'; interface LatestDatasetLite { hosted_url?: string; @@ -62,17 +66,7 @@ export const GtfsVisualizationMap = ({ stopRadius = 3, preview = true, }: GtfsVisualizationMapProps): JSX.Element => { - const { stopsPmtilesUrl, routesPmtilesUrl } = useMemo(() => { - const baseUrl = latestDataset?.hosted_url - ? latestDataset.hosted_url.replace(/[^/]+$/, '') - : undefined; - const stops = `${baseUrl}/pmtiles/stops.pmtiles`; - const routes = `${baseUrl}/pmtiles/routes.pmtiles`; - return { stopsPmtilesUrl: stops, routesPmtilesUrl: routes }; - }, [latestDataset?.id, latestDataset?.stable_id]); - const theme = useTheme(); - const { t } = useTranslation('feeds'); const [hoverInfo, setHoverInfo] = useState([]); const [mapElements, setMapElements] = useState([]); const [mapClickRouteData, setMapClickRouteData] = useState | null>(null); - // Stable list of all stops matched to selected routes (independent of hover) const [selectedRouteStops, setSelectedRouteStops] = useState< MapStopElement[] >([]); - - const mapRef = useRef(null); - const didInitRef = useRef(false); - const routeStopsPanelNodeRef = useRef(null); - // Scanning overlay state const [isScanning, setIsScanning] = useState(false); const [scannedTiles, setScannedTiles] = useState(0); @@ -101,9 +89,19 @@ export const GtfsVisualizationMap = ({ rows: number; cols: number; } | null>(null); - // Selected stop id from the panel (for cute highlight) const [selectedStopId, setSelectedStopId] = useState(null); + const { stopsPmtilesUrl, routesPmtilesUrl } = useMemo(() => { + const baseUrl = latestDataset?.hosted_url + ? latestDataset.hosted_url.replace(/[^/]+$/, '') + : undefined; + const stops = `${baseUrl}/pmtiles/stops.pmtiles`; + const routes = `${baseUrl}/pmtiles/routes.pmtiles`; + return { stopsPmtilesUrl: stops, routesPmtilesUrl: routes }; + }, [latestDataset?.id, latestDataset?.stable_id]); + + const mapRef = useRef(null); + const didInitRef = useRef(false); // Build routeId -> color map from the currently shown hover/click panels const routeIdToColorMap: Record = {}; @@ -132,36 +130,6 @@ export const GtfsVisualizationMap = ({ ...filteredRouteColors, }; - function generateStopColorExpression( - routeIdToColor: Record, - fallback = '#888', - ): ExpressionSpecification { - const expression: any[] = ['case']; - - const isHex = (s: string) => /^[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 === 1) { - return fallback as unknown as ExpressionSpecification; - } - - expression.push(fallback); - return expression as ExpressionSpecification; - } - - const routeTypeFilter: ExpressionSpecification | boolean = - filteredRouteTypeIds.length > 0 - ? ['in', ['get', 'route_type'], ['literal', filteredRouteTypeIds]] - : true; // if no filter applied, show all const handleMouseClick = (event: maplibregl.MapLayerMouseEvent): void => { const map = mapRef.current?.getMap(); @@ -210,7 +178,7 @@ export const GtfsVisualizationMap = ({ const next: MapElementType[] = []; if (map != undefined) { const features = map.queryRenderedFeatures(event.point, { - layers: ['stops', 'routes'], + layers: ['stops', 'routes', 'routes-white'], }); if ( @@ -283,24 +251,6 @@ export const GtfsVisualizationMap = ({ }; }, []); - 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]; // Matches LngLatBoundsLike format - }; - const bounds: LngLatBoundsLike = getBoundsFromCoordinates( polygon as Array<[number, number]>, ); @@ -316,18 +266,6 @@ export const GtfsVisualizationMap = ({ // union of explicit route IDs + those implied by selected types const allSelectedRouteIds = [...filteredRoutes, ...routeIdsFromTypes]; - // Base filter for visible stops (main "stops" layer) - const stopsBaseFilter: ExpressionSpecification | boolean = hideStops - ? false - : allSelectedRouteIds.length === 0 - ? true // no filters → show all - : ([ - 'any', - ...allSelectedRouteIds.map( - (id) => ['in', `\"${id}\"`, ['get', 'route_ids']] as any, // route_ids stored as quoted-string list - ), - ] as any); - // --- SELECTED ROUTE STOPS PANEL --- useEffect(() => { // If no route-id filter, clear and exit @@ -373,30 +311,6 @@ export const GtfsVisualizationMap = ({ return m; }, [routes]); - // Extract route_ids list from the PMTiles property (stringified JSON) - 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, (_: any, id: string) => { - out.push(id); - return ''; - }); - if (out.length) return out; - // fallback2: CSV-ish - return val - .split(',') - .map((s: string) => s.trim()) - .filter(Boolean); - } - return []; - } - // --- instantiate the extracted precomputation with identical behavior --- const cancelRequestRef = useRef(false); const precomp = useMemo( @@ -430,16 +344,6 @@ export const GtfsVisualizationMap = ({ ], ); - // Helper values for overlay - const progressPct = - totalTiles > 0 - ? Math.min(100, Math.round((scannedTiles / totalTiles) * 100)) - : 0; - const isLarge = totalTiles >= 80; - const rowsColsText = scanRowsCols - ? `${scanRowsCols.rows} rows × ${scanRowsCols.cols} cols` - : undefined; - // Helper to focus & stick a stop from the panel const focusStopFromPanel = async (s: MapStopElement) => { const map = mapRef.current?.getMap(); @@ -470,7 +374,7 @@ export const GtfsVisualizationMap = ({ '==', ['to-string', ['get', 'stop_id']], String(s.stopId), - ] as any, + ], }); const stopFeature = features[0]; @@ -481,9 +385,9 @@ export const GtfsVisualizationMap = ({ stop_id: s.stopId, stop_name: s.name, location_type: String(s.locationType ?? 0), - longitude: s.stopLon, - latitude: s.stopLat, - } as any); + longitude: String(s.stopLon), + latitude: String(s.stopLat), + }); setSelectedStopId(s.stopId); return; } @@ -495,9 +399,9 @@ export const GtfsVisualizationMap = ({ stop_id: s.stopId, stop_name: s.name, location_type: String(s.locationType ?? 0), - longitude: s.stopLon, - latitude: s.stopLat, - } as any); + longitude: String(s.stopLon), + latitude: String(s.stopLat), + }); setSelectedStopId(s.stopId); }; @@ -552,7 +456,6 @@ export const GtfsVisualizationMap = ({ // include isScanning so we run once more when scanning completes }, [filteredRoutes, filteredRouteTypeIds]); - // Handler to reset view const resetView = () => { const map = mapRef.current?.getMap(); if (!map) return; @@ -605,202 +508,23 @@ export const GtfsVisualizationMap = ({ dataDisplayLimit={dataDisplayLimit} /> - {/* Selected route stops panel */} {filteredRoutes.length > 0 && selectedRouteStops.length > 0 && ( - - - - - {t('selectedRouteStops.title', { - count: filteredRoutes.length, - })}{' '} - ({selectedRouteStops.length}) - - - {t('selectedRouteStops.routeIds', { - count: filteredRoutes.length, - })} - : {filteredRoutes.join(' | ')} - - - - {selectedRouteStops.map((s) => { - const isActive = selectedStopId === s.stopId; - return ( - focusStopFromPanel(s)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') - focusStopFromPanel(s); // NEW: keyboard support - }} - sx={{ - py: 0.9, - px: 1.1, - mb: 0.5, - borderRadius: '10px', - border: isActive - ? `2px solid ${theme.palette.primary.main}` - : `1px solid ${theme.palette.divider}`, - backgroundColor: isActive - ? theme.palette.action.selected - : 'transparent', - transition: - 'background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease', - cursor: 'pointer', - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - boxShadow: isActive - ? '0 0 0 2px rgba(0,0,0,0.06) inset' - : 'none', - }} - > - - {s.name} - - - {t('selectedRouteStops.stopId')} {s.stopId} - - - ); - })} - - - + )} - {/* Scanning overlay */} {isScanning && ( - - - - - - {isLarge ? t('scanning.titleLarge') : t('scanning.title')} - - - - - {isLarge ? t('scanning.bodyLarge') : t('scanning.body')} - - - {rowsColsText && ( - - {t('scanning.gridTile', { - grid: rowsColsText, - tile: Math.min(scannedTiles, totalTiles), - total: totalTiles, - })} - - )} - - - - - {t('scanning.percentComplete', { percent: progressPct })} - - - - - - + )} { - return [ - 'in', - `\"${id}\"`, - ['get', 'route_ids'], - ] as any; - }), - ], - [ - 'any', - ...hoverInfo.map((id) => { - return [ - 'in', - `\"${id}\"`, - ['get', 'route_ids'], - ] as any; - }), - ], - ], - }, - { - 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 any; - }), - ], - [ - 'any', - ...hoverInfo.map((id) => { - return [ - 'in', - `\"${id}\"`, - ['get', 'route_ids'], - ] as any; - }), - ], - ], - }, + RoutesWhiteLayer(filteredRouteTypeIds), + RouteLayer(filteredRoutes, filteredRouteTypeIds), + StopLayer(hideStops, allSelectedRouteIds, stopRadius), + RouteHighlightLayer( + mapClickRouteData?.route_id, + hoverInfo, + filteredRoutes, + ), + StopsHighlightLayer( + hoverInfo, + hideStops, + filteredRoutes, + mapClickStopData?.stop_id, + stopHighlightColorMap, + ), + StopsHighlightOuterLayer( + hoverInfo, + hideStops, + filteredRoutes, + ), { id: 'stops-index', source: 'sample', diff --git a/web-app/src/app/components/Map/ScanningOverlay.tsx b/web-app/src/app/components/Map/ScanningOverlay.tsx new file mode 100644 index 000000000..ae2a266ff --- /dev/null +++ b/web-app/src/app/components/Map/ScanningOverlay.tsx @@ -0,0 +1,134 @@ +import { + Box, + Button, + CircularProgress, + LinearProgress, + Typography, + useTheme, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +interface ScanningOverlayProps { + totalTiles: number; + scannedTiles: number; + scanRowsCols: { + rows: number; + cols: number; + } | null; + handleCancelScan: () => void; + cancelRequestRef: React.MutableRefObject; +} + +export const ScanningOverlay = ( + props: React.PropsWithChildren, +): JSX.Element => { + const { + totalTiles, + scannedTiles, + scanRowsCols, + handleCancelScan, + cancelRequestRef, + } = props; + const theme = useTheme(); + const { t } = useTranslation('feeds'); + const progressPct = + totalTiles > 0 + ? Math.min(100, Math.round((scannedTiles / totalTiles) * 100)) + : 0; + const isLarge = totalTiles >= 80; + const rowsColsText = + scanRowsCols != null + ? `${scanRowsCols.rows} rows × ${scanRowsCols.cols} cols` + : undefined; + return ( + + + + + + {isLarge ? t('scanning.titleLarge') : t('scanning.title')} + + + + + {isLarge ? t('scanning.bodyLarge') : t('scanning.body')} + + + {rowsColsText != null && ( + + {t('scanning.gridTile', { + grid: rowsColsText, + tile: Math.min(scannedTiles, totalTiles), + total: totalTiles, + })} + + )} + + + + + {t('scanning.percentComplete', { percent: progressPct })} + + + + + + + ); +}; diff --git a/web-app/src/app/components/Map/SelectedRoutesStopsPanel.tsx b/web-app/src/app/components/Map/SelectedRoutesStopsPanel.tsx new file mode 100644 index 000000000..31a282aba --- /dev/null +++ b/web-app/src/app/components/Map/SelectedRoutesStopsPanel.tsx @@ -0,0 +1,130 @@ +import { Box, Typography, useTheme } from '@mui/material'; +import { useRef } from 'react'; +import Draggable from 'react-draggable'; +import { useTranslation } from 'react-i18next'; +import { type MapStopElement } from '../MapElement'; + +interface SelectedRoutesStopsPanelProps { + filteredRoutes: string[]; + selectedRouteStops: MapStopElement[]; + selectedStopId: string | null; + focusStopFromPanel: (stop: MapStopElement) => void; +} + +export const SelectedRoutesStopsPanel = ( + props: React.PropsWithChildren, +): JSX.Element => { + const { + filteredRoutes, + selectedRouteStops, + selectedStopId, + focusStopFromPanel, + } = props; + const theme = useTheme(); + const routeStopsPanelNodeRef = useRef(null); + const { t } = useTranslation('feeds'); + return ( + + + + + {t('selectedRouteStops.title', { + count: filteredRoutes.length, + })}{' '} + ({selectedRouteStops.length}) + + + {t('selectedRouteStops.routeIds', { + count: filteredRoutes.length, + })} + : {filteredRoutes.join(' | ')} + + + + {selectedRouteStops.map((s) => { + const isActive = selectedStopId === s.stopId; + return ( + { + focusStopFromPanel(s); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') focusStopFromPanel(s); + }} + sx={{ + py: 0.9, + px: 1.1, + mb: 0.5, + borderRadius: '10px', + border: isActive + ? `2px solid ${theme.palette.primary.main}` + : `1px solid ${theme.palette.divider}`, + backgroundColor: isActive + ? theme.palette.action.selected + : 'transparent', + transition: + 'background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease', + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + boxShadow: isActive + ? '0 0 0 2px rgba(0,0,0,0.06) inset' + : 'none', + }} + > + + {s.name} + + + {t('selectedRouteStops.stopId')} {s.stopId} + + + ); + })} + + + + ); +}; From 2609c763ecb5ae57e087bafc97d2c3d193f73f71 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 27 Oct 2025 10:03:58 -0400 Subject: [PATCH 2/5] linting fixed --- .../GtfsVisualizationMap.functions.tsx | 16 +-- .../GtfsVisualizationMap.layers.tsx | 60 ++++++++-- .../app/components/GtfsVisualizationMap.tsx | 112 +++++++++--------- 3 files changed, 113 insertions(+), 75 deletions(-) diff --git a/web-app/src/app/components/GtfsVisualizationMap.functions.tsx b/web-app/src/app/components/GtfsVisualizationMap.functions.tsx index 77dbc5d5f..3ab7e61d2 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.functions.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.functions.tsx @@ -30,11 +30,12 @@ export function extractRouteIds(val: RouteIdsInput): string[] { export function generateStopColorExpression( routeIdToColor: Record, - fallback = '#888', -): ExpressionSpecification { - const expression: any[] = ['case']; + fallback: string = '#888', +): string | ExpressionSpecification { + const expression: Array = []; - const isHex = (s: string) => /^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(s); + 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; @@ -46,15 +47,14 @@ export function generateStopColorExpression( } // If nothing valid was added, just use the fallback color directly - if (expression.length === 1) { - return fallback as unknown as ExpressionSpecification; + if (expression.length === 0) { + return fallback; } expression.push(fallback); - return expression as ExpressionSpecification; + return ['case', ...expression] as ExpressionSpecification; } - export const getBoundsFromCoordinates = ( coordinates: Array<[number, number]>, ): LngLatBoundsLike => { diff --git a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx index 2d7d25bac..c479c4673 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx @@ -1,4 +1,10 @@ -import { type ExpressionSpecification, type LayerSpecification } from 'maplibre-gl'; +/* 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 { useTheme } from '@mui/material'; @@ -21,12 +27,17 @@ export const stopsBaseFilter = ( ? false : allSelectedRouteIds.length === 0 ? true // no filters → show all - : ([ + : [ 'any', ...allSelectedRouteIds.map( - (id) => ['in', `\"${id}\"`, ['get', 'route_ids']] as any, // route_ids stored as quoted-string list + (id) => + [ + 'in', + `\"${id}\"`, + ['get', 'route_ids'], + ] as ExpressionSpecification, // route_ids stored as quoted-string list ), - ]); + ]; }; // layers @@ -136,9 +147,7 @@ export const StopsHighlightLayer = ( type: 'circle', paint: { 'circle-radius': 7, - 'circle-color': generateStopColorExpression( - stopHighlightColorMap, - ) as ExpressionSpecification, + 'circle-color': generateStopColorExpression(stopHighlightColorMap), 'circle-opacity': 1, }, minzoom: 10, @@ -152,13 +161,21 @@ export const StopsHighlightLayer = ( [ 'any', ...filteredRoutes.map((id) => { - return ['in', `\"${id}\"`, ['get', 'route_ids']] as any; + return [ + 'in', + `\"${id}\"`, + ['get', 'route_ids'], + ] as ExpressionSpecification; }), ], [ 'any', ...hoverInfo.map((id) => { - return ['in', `\"${id}\"`, ['get', 'route_ids']] as any; + return [ + 'in', + `\"${id}\"`, + ['get', 'route_ids'], + ] as ExpressionSpecification; }), ], ], @@ -189,15 +206,36 @@ export const StopsHighlightOuterLayer = ( [ 'any', ...filteredRoutes.map((id) => { - return ['in', `\"${id}\"`, ['get', 'route_ids']] as any; + return [ + 'in', + `\"${id}\"`, + ['get', 'route_ids'], + ] as ExpressionSpecification; }), ], [ 'any', ...hoverInfo.map((id) => { - return ['in', `\"${id}\"`, ['get', 'route_ids']] as any; + 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': 5, + }, + }; +}; diff --git a/web-app/src/app/components/GtfsVisualizationMap.tsx b/web-app/src/app/components/GtfsVisualizationMap.tsx index e7ca2be96..16cd72156 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.tsx @@ -5,17 +5,18 @@ import Map, { NavigationControl, ScaleControl, } from 'react-map-gl/maplibre'; -import maplibregl, { - type ExpressionSpecification, - type LngLatBoundsLike, -} from 'maplibre-gl'; +import maplibregl, { type LngLatBoundsLike } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { Protocol } from 'pmtiles'; import { type LatLngExpression } from 'leaflet'; import { Box, useTheme } from '@mui/material'; -import type { MapElementType } from './MapElement'; -import { MapElement, MapRouteElement, MapStopElement } from './MapElement'; +import { + MapElement, + type MapRouteElement, + type MapStopElement, + type MapElementType, +} from './MapElement'; import { MapDataPopup } from './Map/MapDataPopup'; import type { GtfsRoute } from '../types'; import { createPrecomputation, extendBBoxes } from '../utils/precompute'; @@ -32,6 +33,7 @@ import { StopLayer, StopsHighlightLayer, StopsHighlightOuterLayer, + StopsIndexLayer, } from './GtfsVisualizationMap.layers'; interface LatestDatasetLite { @@ -92,9 +94,10 @@ export const GtfsVisualizationMap = ({ // Selected stop id from the panel (for cute highlight) const [selectedStopId, setSelectedStopId] = useState(null); const { stopsPmtilesUrl, routesPmtilesUrl } = useMemo(() => { - const baseUrl = latestDataset?.hosted_url - ? latestDataset.hosted_url.replace(/[^/]+$/, '') - : undefined; + const baseUrl = + latestDataset?.hosted_url != null + ? latestDataset.hosted_url.replace(/[^/]+$/, '') + : undefined; const stops = `${baseUrl}/pmtiles/stops.pmtiles`; const routes = `${baseUrl}/pmtiles/routes.pmtiles`; return { stopsPmtilesUrl: stops, routesPmtilesUrl: routes }; @@ -108,7 +111,7 @@ export const GtfsVisualizationMap = ({ mapElements.forEach((el) => { if (!el.isStop) { const routeElement: MapRouteElement = el as MapRouteElement; - if (routeElement.routeId && routeElement.routeColor) { + if (routeElement.routeId !== '' && routeElement.routeColor !== '') { routeIdToColorMap[routeElement.routeId] = routeElement.routeColor; } } @@ -119,7 +122,8 @@ export const GtfsVisualizationMap = ({ for (const rid of filteredRoutes) { const r = (routes ?? []).find((rr) => String(rr.routeId) === String(rid)); // strip leading '#' because generateStopColorExpression expects raw hex - if (r?.color) m[String(rid)] = String(r.color).replace(/^#/, ''); + if (r?.color != null && r.color !== '') + m[String(rid)] = String(r.color).replace(/^#/, ''); } return m; }, [filteredRoutes, routes]); @@ -130,7 +134,6 @@ export const GtfsVisualizationMap = ({ ...filteredRouteColors, }; - const handleMouseClick = (event: maplibregl.MapLayerMouseEvent): void => { const map = mapRef.current?.getMap(); if (map != undefined) { @@ -138,7 +141,6 @@ export const GtfsVisualizationMap = ({ const features = map.queryRenderedFeatures(event.point, { layers: ['stops-index', 'routes-highlight'], }); - const selectedStop = features.find( (feature) => feature.layer.id === 'stops-index', ); @@ -167,7 +169,7 @@ export const GtfsVisualizationMap = ({ } }; - const handlePopupClose = () => { + const handlePopupClose = (): void => { setMapClickRouteData(null); setMapClickStopData(null); setSelectedStopId(null); @@ -187,40 +189,48 @@ export const GtfsVisualizationMap = ({ mapClickStopData != null ) { if (mapClickRouteData != null) { - next.push({ + const routeData: MapRouteElement = { isStop: false, name: mapClickRouteData.route_long_name, routeType: Number(mapClickRouteData.route_type), routeColor: mapClickRouteData.route_color, routeTextColor: mapClickRouteData.route_text_color, routeId: mapClickRouteData.route_id, - } as MapRouteElement); + }; + next.push(routeData); } if (mapClickStopData != null) { - next.push({ + const stopData: MapStopElement = { isStop: true, name: mapClickStopData.stop_name, locationType: Number(mapClickStopData.location_type), stopId: mapClickStopData.stop_id, - } as MapStopElement); + stopLat: Number(mapClickStopData.latitude), + stopLon: Number(mapClickStopData.longitude), + }; + next.push(stopData); } features.forEach((feature) => { if (feature.layer.id === 'stops') { - next.push({ + const stopData: MapStopElement = { isStop: true, name: feature.properties.stop_name, locationType: Number(feature.properties.location_type), stopId: feature.properties.stop_id, - } as MapStopElement); + stopLat: Number(feature.properties.stop_lat), + stopLon: Number(feature.properties.stop_lon), + }; + next.push(stopData); } else { - next.push({ + const routeData: MapRouteElement = { isStop: false, name: feature.properties.route_long_name, routeType: feature.properties.route_type, routeColor: feature.properties.route_color, routeTextColor: feature.properties.route_text_color, routeId: feature.properties.route_id, - } as MapRouteElement); + }; + next.push(routeData); } }); @@ -290,7 +300,6 @@ export const GtfsVisualizationMap = ({ a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), ); setSelectedRouteStops(out); - return; } }, [filteredRoutes]); @@ -345,9 +354,9 @@ export const GtfsVisualizationMap = ({ ); // Helper to focus & stick a stop from the panel - const focusStopFromPanel = async (s: MapStopElement) => { + const focusStopFromPanel = async (s: MapStopElement): Promise => { const map = mapRef.current?.getMap(); - if (!map) return; + if (map == null) return; // 1) Move the camera map.easeTo({ @@ -357,7 +366,11 @@ export const GtfsVisualizationMap = ({ }); // 2) Wait for the move to finish so the render tree is up-to-date - await new Promise((resolve) => map.once('moveend', () => resolve())); + await new Promise((resolve) => { + void map.once('moveend', () => { + resolve(); + }); + }); // 3) Build a small bbox around the stop's screen point for robust picking const pt = map.project([s.stopLon, s.stopLat]); @@ -370,15 +383,11 @@ export const GtfsVisualizationMap = ({ // 4) Query rendered features, filtering by exact stop_id const features = map.queryRenderedFeatures(bbox, { layers: ['stops-index'], - filter: [ - '==', - ['to-string', ['get', 'stop_id']], - String(s.stopId), - ], + filter: ['==', ['to-string', ['get', 'stop_id']], String(s.stopId)], }); const stopFeature = features[0]; - if (!stopFeature) { + if (stopFeature == null) { // fallback: still open popup with what we have setMapClickRouteData(null); setMapClickStopData({ @@ -432,7 +441,7 @@ export const GtfsVisualizationMap = ({ useEffect(() => { if (preview) return; // honor preview mode const map = mapRef.current?.getMap(); - if (!map) return; + if (map == null) return; // Wait until precomputation has filled the BBox refs if (!precomputedReadyRef.current) return; @@ -447,7 +456,7 @@ export const GtfsVisualizationMap = ({ } const target = computeTargetBounds(); - if (target) { + if (target != null) { map.fitBounds(target, { padding: 60, duration: 600 }); } else { // If BBoxes are missing for the selection, fall back to dataset bounds @@ -456,11 +465,11 @@ export const GtfsVisualizationMap = ({ // include isScanning so we run once more when scanning completes }, [filteredRoutes, filteredRouteTypeIds]); - const resetView = () => { + const resetView = (): void => { const map = mapRef.current?.getMap(); - if (!map) return; + if (map == null) return; const target = computeTargetBounds(); - if (target) { + if (target != null) { map.fitBounds(target, { padding: 60, duration: 500 }); } else { // fallback to dataset bounds @@ -474,7 +483,7 @@ export const GtfsVisualizationMap = ({ } }, [preview, refocusTrigger]); - function handleCancelScan() { + function handleCancelScan(): void { cancelRequestRef.current = true; setIsScanning(false); // clear all precomputed data @@ -485,7 +494,7 @@ export const GtfsVisualizationMap = ({ // reset map state const map = mapRef.current?.getMap(); - if (map) { + if (map != null) { map.fitBounds(bounds, { padding: 100, duration: 0 }); } } @@ -513,7 +522,9 @@ export const GtfsVisualizationMap = ({ filteredRoutes={filteredRoutes} selectedRouteStops={selectedRouteStops} selectedStopId={selectedStopId} - focusStopFromPanel={focusStopFromPanel} + focusStopFromPanel={(stopData) => { + void focusStopFromPanel(stopData); + }} /> )} @@ -528,7 +539,9 @@ export const GtfsVisualizationMap = ({ )} handleMouseClick(event)} + onClick={(event) => { + handleMouseClick(event); + }} onLoad={() => { if (didInitRef.current) return; // guard against re-entrancy didInitRef.current = true; @@ -594,21 +607,8 @@ export const GtfsVisualizationMap = ({ mapClickStopData?.stop_id, stopHighlightColorMap, ), - StopsHighlightOuterLayer( - hoverInfo, - hideStops, - filteredRoutes, - ), - { - id: 'stops-index', - source: 'sample', - 'source-layer': 'stopsoutput', - type: 'circle', - paint: { - 'circle-color': 'rgba(0,0,0, 0)', - 'circle-radius': 1, - }, - }, + StopsHighlightOuterLayer(hoverInfo, hideStops, filteredRoutes), + StopsIndexLayer(), ], }} > From ec6b09da161e7159b3cc00f21a67b5dba090631c Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 27 Oct 2025 10:15:25 -0400 Subject: [PATCH 3/5] fix route select bug --- web-app/src/app/components/GtfsVisualizationMap.layers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx index c479c4673..c79513782 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx @@ -128,7 +128,7 @@ export const RouteHighlightLayer = ( 'any', ['in', ['get', 'route_id'], ['literal', hoverInfo]], ['in', ['get', 'route_id'], ['literal', filteredRoutes]], - ['in', ['get', 'route_id'], ['literal', routeId ?? '']], + ['in', ['get', 'route_id'], ['literal', [routeId ?? '']]], ], }; }; From 00b43142245ba3bc30005c4872182b1b04101ecf Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 27 Oct 2025 13:39:14 -0400 Subject: [PATCH 4/5] PR comments from AI --- .../components/GtfsVisualizationMap.layers.tsx | 6 +++--- .../src/app/components/GtfsVisualizationMap.tsx | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx index c79513782..123b3dd24 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx @@ -6,7 +6,7 @@ import { type LayerSpecification, } from 'maplibre-gl'; import { generateStopColorExpression } from './GtfsVisualizationMap.functions'; -import { useTheme } from '@mui/material'; +import { type Theme } from '@mui/material'; // layer helpers @@ -43,8 +43,8 @@ export const stopsBaseFilter = ( // layers export const RoutesWhiteLayer = ( filteredRouteTypeIds: string[], + theme: Theme, ): LayerSpecification => { - const theme = useTheme(); return { id: 'routes-white', source: 'routes', @@ -186,8 +186,8 @@ export const StopsHighlightOuterLayer = ( hoverInfo: string[], hideStops: boolean, filteredRoutes: string[], + theme: Theme, ): LayerSpecification => { - const theme = useTheme(); return { id: 'stops-highlight-outer', source: 'sample', diff --git a/web-app/src/app/components/GtfsVisualizationMap.tsx b/web-app/src/app/components/GtfsVisualizationMap.tsx index 16cd72156..e40530ed2 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.tsx @@ -111,7 +111,12 @@ export const GtfsVisualizationMap = ({ mapElements.forEach((el) => { if (!el.isStop) { const routeElement: MapRouteElement = el as MapRouteElement; - if (routeElement.routeId !== '' && routeElement.routeColor !== '') { + if ( + routeElement?.routeId != null && + routeElement.routeId !== '' && + routeElement.routeColor != null && + routeElement.routeColor !== '' + ) { routeIdToColorMap[routeElement.routeId] = routeElement.routeColor; } } @@ -592,7 +597,7 @@ export const GtfsVisualizationMap = ({ minzoom: 0, maxzoom: 22, }, - RoutesWhiteLayer(filteredRouteTypeIds), + RoutesWhiteLayer(filteredRouteTypeIds, theme), RouteLayer(filteredRoutes, filteredRouteTypeIds), StopLayer(hideStops, allSelectedRouteIds, stopRadius), RouteHighlightLayer( @@ -607,7 +612,12 @@ export const GtfsVisualizationMap = ({ mapClickStopData?.stop_id, stopHighlightColorMap, ), - StopsHighlightOuterLayer(hoverInfo, hideStops, filteredRoutes), + StopsHighlightOuterLayer( + hoverInfo, + hideStops, + filteredRoutes, + theme, + ), StopsIndexLayer(), ], }} From af6c1b6c1e89612df8c37f78cf35ff7bd2f78857 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 27 Oct 2025 14:20:35 -0400 Subject: [PATCH 5/5] stops click target changed --- web-app/src/app/components/GtfsVisualizationMap.layers.tsx | 2 +- web-app/src/app/components/GtfsVisualizationMap.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx index 123b3dd24..4994b923b 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.layers.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx @@ -235,7 +235,7 @@ export const StopsIndexLayer = (): LayerSpecification => { type: 'circle', paint: { 'circle-opacity': 0, - 'circle-radius': 5, + 'circle-radius': 1, }, }; }; diff --git a/web-app/src/app/components/GtfsVisualizationMap.tsx b/web-app/src/app/components/GtfsVisualizationMap.tsx index e40530ed2..cc0ff24b3 100644 --- a/web-app/src/app/components/GtfsVisualizationMap.tsx +++ b/web-app/src/app/components/GtfsVisualizationMap.tsx @@ -144,10 +144,10 @@ export const GtfsVisualizationMap = ({ if (map != undefined) { // Get the features under the mouse pointer const features = map.queryRenderedFeatures(event.point, { - layers: ['stops-index', 'routes-highlight'], + layers: ['stops-highlight', 'routes-highlight'], }); const selectedStop = features.find( - (feature) => feature.layer.id === 'stops-index', + (feature) => feature.layer.id === 'stops-highlight', ); if (selectedStop != undefined) { setMapClickStopData({