diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx index 2b6934d..59f5bd4 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -46,16 +46,22 @@ const ROUTE_COLORS: Record = { const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']); +/** Strip trailing parenthesized GTFS route IDs and NaPTAN stop codes (e.g. "(6757261)", "(9400ZZLUCGT1)") */ +function stripId(label: string): string { + return label.replace(/\s+\([A-Za-z0-9]+\)$/, ''); +} + function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } { - const known = ROUTE_COLORS[mode]; + const clean = stripId(mode); + const known = ROUTE_COLORS[clean]; if (known) { - const label = NON_TUBE_NAMES.has(mode) || mode.includes('line') ? mode : `${mode} line`; + const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`; return { label, color: known.color, darkText: !!known.darkText }; } - if (/^\d+[A-Za-z]?$/.test(mode.trim())) { - return { label: `Bus ${mode}`, color: '#0d9488', darkText: false }; + if (/^\d+[A-Za-z]?$/.test(clean.trim())) { + return { label: `Bus ${clean}`, color: '#0d9488', darkText: false }; } - return { label: mode, color: '#6b7280', darkText: false }; + return { label: clean, color: '#6b7280', darkText: false }; } function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { @@ -120,8 +126,8 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) { {leg.minutes} min {leg.from && leg.to && ( -
- {leg.from} → {leg.to} +
+ {stripId(leg.from)} → {stripId(leg.to)}
)}
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 9b9b687..9eccc9a 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -19,7 +19,7 @@ import LocationSearch, { type SearchedLocation } from './LocationSearch'; import MapLegend from './MapLegend'; import HoverCard from './HoverCard'; import type { FeatureFilters } from '../../types'; -import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers'; +import { useDeckLayers } from '../../hooks/useDeckLayers'; import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime'; interface MapProps { @@ -295,16 +295,6 @@ export default memo(function Map({ - {osmIdToUrl(popupInfo.id) && ( - - View on OSM - - )} )} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 5aec952..2b1aa90 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -157,7 +157,6 @@ export default function MapPage({ features, viewFeature, activeFeature, - dragValue, travelTimeEntries: travelTime.entries, }); diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index 252ab65..c4fbf80 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -30,15 +30,6 @@ import { } from './useTravelTime'; import { MarchingAntsExtension } from '../lib/MarchingAntsExtension'; -/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */ -function osmIdToUrl(id: string): string | null { - const match = id.match(/^([nwr])(\d+)$/); - if (!match) return null; - const typeMap: Record = { n: 'node', w: 'way', r: 'relation' }; - return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`; -} - -export { osmIdToUrl }; interface UseDeckLayersProps { data: HexagonData[]; diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index 7ebcd24..f48f3c2 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import type { FeatureMeta, FeatureFilters } from '../types'; import { trackEvent } from '../lib/analytics'; @@ -12,6 +12,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { const [activeFeature, setActiveFeature] = useState(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [pinnedFeature, setPinnedFeature] = useState(null); + const pendingDragRef = useRef(null); + const dragActiveRef = useRef(null); + const dragValueRef = useRef<[number, number] | null>(null); const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); @@ -60,30 +63,46 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { (name: string) => { const meta = features.find((f) => f.name === name); if (meta?.type === 'enum') return; - setActiveFeature(name); - const fval = filters[name]; - setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); + pendingDragRef.current = name; }, - [filters, features] + [features] ); const handleDragChange = useCallback((value: [number, number]) => { + if (pendingDragRef.current) { + setActiveFeature(pendingDragRef.current); + dragActiveRef.current = pendingDragRef.current; + pendingDragRef.current = null; + } setDragValue(value); + dragValueRef.current = value; }, []); const handleDragEnd = useCallback(() => { - if (activeFeature && dragValue) { - setFilters((prev) => ({ ...prev, [activeFeature]: dragValue })); + if (pendingDragRef.current) { + // Click without drag — no state was changed, just clear the ref + pendingDragRef.current = null; + return; + } + const af = dragActiveRef.current; + const dv = dragValueRef.current; + if (af && dv) { + setFilters((prev) => ({ ...prev, [af]: dv })); } setActiveFeature(null); setDragValue(null); - }, [activeFeature, dragValue]); + dragActiveRef.current = null; + dragValueRef.current = null; + }, []); const handleSetFilters = useCallback((newFilters: FeatureFilters) => { setFilters(newFilters); setActiveFeature(null); setDragValue(null); setPinnedFeature(null); + pendingDragRef.current = null; + dragActiveRef.current = null; + dragValueRef.current = null; }, []); const handleTogglePin = useCallback((name: string) => { diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 40cf64a..c2f46c0 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -31,7 +31,6 @@ interface UseMapDataOptions { features: FeatureMeta[]; viewFeature: string | null; activeFeature: string | null; - dragValue: [number, number] | null; travelTimeEntries: TravelTimeEntry[]; } @@ -40,7 +39,6 @@ export function useMapData({ features, viewFeature, activeFeature, - dragValue, travelTimeEntries, }: UseMapDataOptions) { const [rawData, setRawData] = useState([]); @@ -75,17 +73,15 @@ export function useMapData({ [filters, features] ); - // Build the travel param string from entries with destinations - // Format: mode:slug|mode:slug:best or mode:slug:min:max|mode:slug:best:min:max + // Build the travel param string from entries with destinations. + // timeRange is NOT included — range filtering is handled purely client-side + // (dimming in useDeckLayers) so slider changes never trigger server refetches. const travelParam = useMemo((): string => { const segments: string[] = []; for (const entry of travelTimeEntries) { if (!entry.slug) continue; let seg = `${entry.mode}:${entry.slug}`; if (entry.useBest) seg += ':best'; - if (entry.timeRange) { - seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`; - } segments.push(seg); } return segments.join('|'); @@ -259,7 +255,6 @@ export function useMapData({ if (!isTravelTime) { const meta = features.find((f) => f.name === viewFeature); if (!meta || meta.type === 'enum') return null; - if (activeFeature && !dragHexData && !dragPostcodeData) return null; } const vals: number[] = []; @@ -294,7 +289,7 @@ export function useMapData({ percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE), ]; - }, [viewFeature, data, dragHexData, dragPostcodeData, effectivePostcodeData, usePostcodeView, features, activeFeature, bounds]); + }, [viewFeature, data, effectivePostcodeData, usePostcodeView, features, bounds]); // Color range for the legend and hex coloring const colorRange = useMemo((): [number, number] | null => { @@ -311,10 +306,9 @@ export function useMapData({ return [0, meta.values.length - 1]; } if (dataRange) return dataRange; - if (activeFeature && dragValue) return dragValue; if (meta.min != null && meta.max != null) return [meta.min, meta.max]; return null; - }, [viewFeature, features, dataRange, activeFeature, dragValue]); + }, [viewFeature, features, dataRange]); const handleViewChange = useCallback( ({ diff --git a/pipeline/transform/merge.py b/pipeline/transform/merge.py index 7a9db47..cc5a518 100644 --- a/pipeline/transform/merge.py +++ b/pipeline/transform/merge.py @@ -209,7 +209,7 @@ def _build( # Broadband: derive max available download speed tier per postcode from # Ofcom availability percentages. Tiers: Gigabit ≥1000, UFBB ≥300, - # UFBB(100) ≥100, SFBB ≥30 Mbps. + # UFBB(100) ≥100, SFBB ≥30 Mbps. Stored as string enum. broadband = ( pl.scan_parquet(broadband_path) .select( @@ -228,6 +228,7 @@ def _build( ) .group_by("bb_postcode") .agg(pl.col("max_download_speed").max()) + .with_columns(pl.col("max_download_speed").cast(pl.Utf8)) ) wide = wide.join(broadband, left_on="postcode", right_on="bb_postcode", how="left")