From 75950f0b1bcd409ca9040674184c41240d392cfe Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 28 Jan 2026 21:10:18 +0000 Subject: [PATCH] Add travel times --- frontend/src/App.tsx | 7 ++- frontend/src/components/Filters.tsx | 71 +++++++++++++++++++++------ frontend/src/components/Map.tsx | 76 ++++++++++++++++++++--------- frontend/src/types.ts | 6 +++ 4 files changed, 122 insertions(+), 38 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e96967..b72b2b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import type { POI, POIResponse, POICategoryGroup, + ColorMode, } from './types'; const DEBOUNCE_MS = 150; @@ -50,6 +51,8 @@ export default function App() { const debounceRef = useRef | null>(null); const abortControllerRef = useRef(null); + const [colorMode, setColorMode] = useState('price'); + // POI state const [pois, setPois] = useState([]); const [selectedPOICategories, setSelectedPOICategories] = useState>( @@ -166,9 +169,11 @@ export default function App() { zoom={zoom} selectedPOICategories={selectedPOICategories} onPOICategoriesChange={setSelectedPOICategories} + colorMode={colorMode} + onColorModeChange={setColorMode} />
- + {loading && (
Loading...
)} diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index a99be49..9e078f9 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -1,7 +1,7 @@ import { Slider } from './ui/slider'; import { Label } from './ui/label'; import { YEAR_MIN, YEAR_MAX, YEAR_STEP, PRICE_MIN, PRICE_MAX, PRICE_STEP } from '../lib/constants'; -import type { Filters as FiltersType, POICategoryGroup } from '../types'; +import type { Filters as FiltersType, POICategoryGroup, ColorMode } from '../types'; import { POI_CATEGORY_GROUPS } from '../types'; interface FiltersProps { @@ -10,6 +10,8 @@ interface FiltersProps { zoom: number; selectedPOICategories: Set; onPOICategoriesChange: (categories: Set) => void; + colorMode: ColorMode; + onColorModeChange: (mode: ColorMode) => void; } const POI_LABELS: Record = { @@ -27,6 +29,8 @@ export default function Filters({ zoom, selectedPOICategories, onPOICategoriesChange, + colorMode, + onColorModeChange, }: FiltersProps) { const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value }); @@ -81,23 +85,60 @@ export default function Filters({ />
-
-
Average Price
-
-
- £0 - £200k - £400k - £800k+ +
+ +
+ +
+ {colorMode === 'price' ? ( +
+
Average Price
+
+
+ £0 + £200k + £400k + £800k+ +
+
+ ) : ( +
+
Journey Time to Bank
+
+
+ 0 min + 30 min + 60 min + 120+ min +
+
+ )} +
diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index be5a074..0c52f12 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -5,12 +5,13 @@ import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { IconLayer } from '@deck.gl/layers'; import type { PickingInfo } from '@deck.gl/core'; import 'maplibre-gl/dist/maplibre-gl.css'; -import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI } from '../types'; +import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, ColorMode } from '../types'; interface MapProps { data: HexagonData[]; pois: POI[]; onViewChange: (params: ViewChangeParams) => void; + colorMode: ColorMode; } // Twemoji CDN base URL @@ -119,26 +120,41 @@ function interpolateColor( ]; } -function priceToColor(price: number | null | undefined): [number, number, number] { - if (price == null || isNaN(price)) return [128, 128, 128]; // Gray for missing data +function scaleToColor( + value: number | null | undefined, + scale: ColorStop[] +): [number, number, number] { + if (value == null || isNaN(value)) return [128, 128, 128]; - // Clamp to scale range - if (price <= COLOR_SCALE[0].price) return COLOR_SCALE[0].color; - if (price >= COLOR_SCALE[COLOR_SCALE.length - 1].price) { - return COLOR_SCALE[COLOR_SCALE.length - 1].color; - } + if (value <= scale[0].price) return scale[0].color; + if (value >= scale[scale.length - 1].price) return scale[scale.length - 1].color; - // Find the two colors to interpolate between - for (let i = 0; i < COLOR_SCALE.length - 1; i++) { - const lower = COLOR_SCALE[i]; - const upper = COLOR_SCALE[i + 1]; - if (price >= lower.price && price <= upper.price) { - const t = (price - lower.price) / (upper.price - lower.price); + for (let i = 0; i < scale.length - 1; i++) { + const lower = scale[i]; + const upper = scale[i + 1]; + if (value >= lower.price && value <= upper.price) { + const t = (value - lower.price) / (upper.price - lower.price); return interpolateColor(lower.color, upper.color, t); } } - return COLOR_SCALE[COLOR_SCALE.length - 1].color; + return scale[scale.length - 1].color; +} + +function priceToColor(price: number | null | undefined): [number, number, number] { + return scaleToColor(price, COLOR_SCALE); +} + +// Journey time color scale: green (short) -> yellow -> orange -> red (long) +const JOURNEY_COLOR_SCALE: ColorStop[] = [ + { price: 0, color: [46, 204, 113] }, // Green + { price: 30, color: [241, 196, 15] }, // Yellow + { price: 60, color: [231, 76, 60] }, // Red + { price: 120, color: [142, 68, 173] }, // Purple +]; + +function journeyTimeToColor(minutes: number | null | undefined): [number, number, number] { + return scaleToColor(minutes, JOURNEY_COLOR_SCALE); } function zoomToResolution(zoom: number): number { @@ -193,7 +209,7 @@ interface Dimensions { height: number; } -export default function Map({ data, pois, onViewChange }: MapProps) { +export default function Map({ data, pois, onViewChange, colorMode }: MapProps) { const containerRef = useRef(null); const [viewState, setViewState] = useState(INITIAL_VIEW); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); @@ -256,7 +272,13 @@ export default function Map({ data, pois, onViewChange }: MapProps) { id: 'h3-hexagons', data, getHexagon: (d) => d.h3, - getFillColor: (d) => priceToColor(d.avg_price), + getFillColor: (d) => + colorMode === 'journey_time' + ? journeyTimeToColor(d.median_journey_minutes) + : priceToColor(d.avg_price), + updateTriggers: { + getFillColor: colorMode, + }, extruded: false, pickable: true, opacity: 0.5, @@ -278,15 +300,26 @@ export default function Map({ data, pois, onViewChange }: MapProps) { onHover: handlePoiHover, }), ], - [data, pois, handlePoiHover] + [data, pois, handlePoiHover, colorMode] ); - // Tooltip for hexagons only (POIs use MapLibre popup) const getTooltip = useCallback(({ object }: { object?: HexagonData }) => { if (!object || !('h3' in object)) return null; const hex = object as HexagonData; + const journeyLines: string[] = []; + if (hex.median_pt_quick_minutes != null) + journeyLines.push(`🚇 Quick PT: ${hex.median_pt_quick_minutes} min`); + if (hex.median_pt_easy_minutes != null) + journeyLines.push(`🚌 Easy PT: ${hex.median_pt_easy_minutes} min`); + if (hex.median_cycling_minutes != null) + journeyLines.push(`🚲 Cycling: ${hex.median_cycling_minutes} min`); + const journeyTimeHtml = + journeyLines.length > 0 + ? `
${journeyLines.join('
')}
` + : ''; + return { html: `
Avg: £${hex.avg_price?.toLocaleString() || 'N/A'} @@ -294,6 +327,7 @@ export default function Map({ data, pois, onViewChange }: MapProps) { ${hex.count} sales
Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()}
+ ${journeyTimeHtml}
`, style: { backgroundColor: 'white', @@ -327,9 +361,7 @@ export default function Map({ data, pois, onViewChange }: MapProps) { {getTooltipEmoji(popupInfo.category)} {popupInfo.name} -
- {popupInfo.category.replace(/_/g, ' ')} -
+
{popupInfo.category.replace(/_/g, ' ')}
)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b094d4a..16d0a3f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -19,8 +19,14 @@ export interface HexagonData { median_price: number; min_price: number; max_price: number; + median_journey_minutes: number | null; + median_pt_easy_minutes: number | null; + median_pt_quick_minutes: number | null; + median_cycling_minutes: number | null; } +export type ColorMode = 'price' | 'journey_time'; + export interface ViewState { longitude: number; latitude: number;