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..3ab7e61d2 --- /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: string = '#888', +): string | ExpressionSpecification { + const expression: Array = []; + + 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]; +}; 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..4994b923b --- /dev/null +++ b/web-app/src/app/components/GtfsVisualizationMap.layers.tsx @@ -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, + '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), + '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, + }, + }; +}; diff --git a/web-app/src/app/components/GtfsVisualizationMap.tsx b/web-app/src/app/components/GtfsVisualizationMap.tsx index f23ad3b3b..cc0ff24b3 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, @@ -7,28 +5,36 @@ 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, 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 { + MapElement, + type MapRouteElement, + type MapStopElement, + type MapElementType, +} 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 { - createPrecomputation, - extendBBoxes, - type RouteIdsInput, -} from '../utils/precompute'; + extractRouteIds, + getBoundsFromCoordinates, +} from './GtfsVisualizationMap.functions'; +import { + RouteHighlightLayer, + RouteLayer, + RoutesWhiteLayer, + StopLayer, + StopsHighlightLayer, + StopsHighlightOuterLayer, + StopsIndexLayer, +} from './GtfsVisualizationMap.layers'; interface LatestDatasetLite { hosted_url?: string; @@ -62,17 +68,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,16 +91,32 @@ 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 != null + ? 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 = {}; 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; } } @@ -121,7 +127,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]); @@ -132,47 +139,15 @@ 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(); 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({ @@ -199,7 +174,7 @@ export const GtfsVisualizationMap = ({ } }; - const handlePopupClose = () => { + const handlePopupClose = (): void => { setMapClickRouteData(null); setMapClickStopData(null); setSelectedStopId(null); @@ -210,7 +185,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 ( @@ -219,40 +194,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); } }); @@ -283,24 +266,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 +281,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 @@ -352,7 +305,6 @@ export const GtfsVisualizationMap = ({ a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), ); setSelectedRouteStops(out); - return; } }, [filteredRoutes]); @@ -373,30 +325,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,20 +358,10 @@ 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 focusStopFromPanel = async (s: MapStopElement): Promise => { const map = mapRef.current?.getMap(); - if (!map) return; + if (map == null) return; // 1) Move the camera map.easeTo({ @@ -453,7 +371,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]); @@ -466,24 +388,20 @@ 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), - ] as any, + 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({ 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 +413,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); }; @@ -528,7 +446,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; @@ -543,7 +461,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 @@ -552,12 +470,11 @@ export const GtfsVisualizationMap = ({ // include isScanning so we run once more when scanning completes }, [filteredRoutes, filteredRouteTypeIds]); - // Handler to reset view - 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 @@ -571,7 +488,7 @@ export const GtfsVisualizationMap = ({ } }, [preview, refocusTrigger]); - function handleCancelScan() { + function handleCancelScan(): void { cancelRequestRef.current = true; setIsScanning(false); // clear all precomputed data @@ -582,7 +499,7 @@ export const GtfsVisualizationMap = ({ // reset map state const map = mapRef.current?.getMap(); - if (map) { + if (map != null) { map.fitBounds(bounds, { padding: 100, duration: 0 }); } } @@ -605,206 +522,31 @@ 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} - - - ); - })} - - - + { + void focusStopFromPanel(stopData); + }} + /> )} - {/* 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 })} - - - - - - + )} handleMouseClick(event)} + onClick={(event) => { + handleMouseClick(event); + }} onLoad={() => { if (didInitRef.current) return; // guard against re-entrancy didInitRef.current = true; @@ -855,205 +597,28 @@ export const GtfsVisualizationMap = ({ minzoom: 0, maxzoom: 22, }, - { - id: 'routes-white', - source: 'routes', - filter: routeTypeFilter, - 'source-layer': 'routesoutput', - type: 'line', - paint: { - 'line-color': theme.palette.background.paper, - 'line-width': [ - 'match', - ['get', 'route_type'], - '3', - 4, - '1', - 15, - 3, - ], - }, - }, - { - id: 'routes', - filter: routeTypeFilter, - 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, - ], - }, - }, - { - id: 'stops', - filter: stopsBaseFilter, - source: 'sample', - 'source-layer': 'stopsoutput', - type: 'circle', - paint: { - 'circle-radius': stopRadius, - 'circle-color': '#000000', - 'circle-opacity': 0.4, - }, - minzoom: 12, - maxzoom: 22, - }, - { - 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', mapClickRouteData?.route_id ?? ''], - ], - ], - }, - { - 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', mapClickStopData?.stop_id ?? ''], - ], - [ - 'any', - ...filteredRoutes.map((id) => { - 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; - }), - ], - ], - }, - { - id: 'stops-index', - source: 'sample', - 'source-layer': 'stopsoutput', - type: 'circle', - paint: { - 'circle-color': 'rgba(0,0,0, 0)', - 'circle-radius': 1, - }, - }, + RoutesWhiteLayer(filteredRouteTypeIds, theme), + 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, + theme, + ), + StopsIndexLayer(), ], }} > 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} + + + ); + })} + + + + ); +};