Add travel times

This commit is contained in:
Andras Schmelczer 2026-01-28 21:10:18 +00:00
parent 275e5afac6
commit 75950f0b1b
4 changed files with 122 additions and 38 deletions

View file

@ -11,6 +11,7 @@ import type {
POI, POI,
POIResponse, POIResponse,
POICategoryGroup, POICategoryGroup,
ColorMode,
} from './types'; } from './types';
const DEBOUNCE_MS = 150; const DEBOUNCE_MS = 150;
@ -50,6 +51,8 @@ export default function App() {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const [colorMode, setColorMode] = useState<ColorMode>('price');
// POI state // POI state
const [pois, setPois] = useState<POI[]>([]); const [pois, setPois] = useState<POI[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<POICategoryGroup>>( const [selectedPOICategories, setSelectedPOICategories] = useState<Set<POICategoryGroup>>(
@ -166,9 +169,11 @@ export default function App() {
zoom={zoom} zoom={zoom}
selectedPOICategories={selectedPOICategories} selectedPOICategories={selectedPOICategories}
onPOICategoriesChange={setSelectedPOICategories} onPOICategoriesChange={setSelectedPOICategories}
colorMode={colorMode}
onColorModeChange={setColorMode}
/> />
<div className="flex-1 relative"> <div className="flex-1 relative">
<Map data={data} pois={pois} onViewChange={handleViewChange} /> <Map data={data} pois={pois} onViewChange={handleViewChange} colorMode={colorMode} />
{loading && ( {loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div> <div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
)} )}

View file

@ -1,7 +1,7 @@
import { Slider } from './ui/slider'; import { Slider } from './ui/slider';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { YEAR_MIN, YEAR_MAX, YEAR_STEP, PRICE_MIN, PRICE_MAX, PRICE_STEP } from '../lib/constants'; 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'; import { POI_CATEGORY_GROUPS } from '../types';
interface FiltersProps { interface FiltersProps {
@ -10,6 +10,8 @@ interface FiltersProps {
zoom: number; zoom: number;
selectedPOICategories: Set<POICategoryGroup>; selectedPOICategories: Set<POICategoryGroup>;
onPOICategoriesChange: (categories: Set<POICategoryGroup>) => void; onPOICategoriesChange: (categories: Set<POICategoryGroup>) => void;
colorMode: ColorMode;
onColorModeChange: (mode: ColorMode) => void;
} }
const POI_LABELS: Record<POICategoryGroup, string> = { const POI_LABELS: Record<POICategoryGroup, string> = {
@ -27,6 +29,8 @@ export default function Filters({
zoom, zoom,
selectedPOICategories, selectedPOICategories,
onPOICategoriesChange, onPOICategoriesChange,
colorMode,
onColorModeChange,
}: FiltersProps) { }: FiltersProps) {
const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value }); const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value });
@ -81,23 +85,60 @@ export default function Filters({
/> />
</div> </div>
<div className="mt-6 p-3 bg-slate-100 rounded text-xs"> <div className="space-y-2">
<div className="mb-2 font-medium">Average Price</div> <Label>Color By</Label>
<div <div className="flex gap-2">
className="h-4 rounded" <button
style={{ className={`flex-1 px-3 py-1.5 text-sm rounded ${colorMode === 'price' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700'}`}
background: onClick={() => onColorModeChange('price')}
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))', >
}} Price
></div> </button>
<div className="flex justify-between mt-1"> <button
<span>£0</span> className={`flex-1 px-3 py-1.5 text-sm rounded ${colorMode === 'journey_time' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700'}`}
<span>£200k</span> onClick={() => onColorModeChange('journey_time')}
<span>£400k</span> >
<span>£800k+</span> Journey Time
</button>
</div> </div>
</div> </div>
{colorMode === 'price' ? (
<div className="p-3 bg-slate-100 rounded text-xs">
<div className="mb-2 font-medium">Average Price</div>
<div
className="h-4 rounded"
style={{
background:
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
}}
></div>
<div className="flex justify-between mt-1">
<span>£0</span>
<span>£200k</span>
<span>£400k</span>
<span>£800k+</span>
</div>
</div>
) : (
<div className="p-3 bg-slate-100 rounded text-xs">
<div className="mb-2 font-medium">Journey Time to Bank</div>
<div
className="h-4 rounded"
style={{
background:
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
}}
></div>
<div className="flex justify-between mt-1">
<span>0 min</span>
<span>30 min</span>
<span>60 min</span>
<span>120+ min</span>
</div>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label>Points of Interest</Label> <Label>Points of Interest</Label>
<div className="space-y-1"> <div className="space-y-1">

View file

@ -5,12 +5,13 @@ import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { IconLayer } from '@deck.gl/layers'; import { IconLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core'; import type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css'; 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 { interface MapProps {
data: HexagonData[]; data: HexagonData[];
pois: POI[]; pois: POI[];
onViewChange: (params: ViewChangeParams) => void; onViewChange: (params: ViewChangeParams) => void;
colorMode: ColorMode;
} }
// Twemoji CDN base URL // Twemoji CDN base URL
@ -119,26 +120,41 @@ function interpolateColor(
]; ];
} }
function priceToColor(price: number | null | undefined): [number, number, number] { function scaleToColor(
if (price == null || isNaN(price)) return [128, 128, 128]; // Gray for missing data value: number | null | undefined,
scale: ColorStop[]
): [number, number, number] {
if (value == null || isNaN(value)) return [128, 128, 128];
// Clamp to scale range if (value <= scale[0].price) return scale[0].color;
if (price <= COLOR_SCALE[0].price) return COLOR_SCALE[0].color; if (value >= scale[scale.length - 1].price) return scale[scale.length - 1].color;
if (price >= COLOR_SCALE[COLOR_SCALE.length - 1].price) {
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
}
// Find the two colors to interpolate between for (let i = 0; i < scale.length - 1; i++) {
for (let i = 0; i < COLOR_SCALE.length - 1; i++) { const lower = scale[i];
const lower = COLOR_SCALE[i]; const upper = scale[i + 1];
const upper = COLOR_SCALE[i + 1]; if (value >= lower.price && value <= upper.price) {
if (price >= lower.price && price <= upper.price) { const t = (value - lower.price) / (upper.price - lower.price);
const t = (price - lower.price) / (upper.price - lower.price);
return interpolateColor(lower.color, upper.color, t); 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 { function zoomToResolution(zoom: number): number {
@ -193,7 +209,7 @@ interface Dimensions {
height: number; height: number;
} }
export default function Map({ data, pois, onViewChange }: MapProps) { export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW); const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 }); const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
@ -256,7 +272,13 @@ export default function Map({ data, pois, onViewChange }: MapProps) {
id: 'h3-hexagons', id: 'h3-hexagons',
data, data,
getHexagon: (d) => d.h3, 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, extruded: false,
pickable: true, pickable: true,
opacity: 0.5, opacity: 0.5,
@ -278,15 +300,26 @@ export default function Map({ data, pois, onViewChange }: MapProps) {
onHover: handlePoiHover, onHover: handlePoiHover,
}), }),
], ],
[data, pois, handlePoiHover] [data, pois, handlePoiHover, colorMode]
); );
// Tooltip for hexagons only (POIs use MapLibre popup) // Tooltip for hexagons only (POIs use MapLibre popup)
const getTooltip = useCallback(({ object }: { object?: HexagonData }) => { const getTooltip = useCallback(({ object }: { object?: HexagonData }) => {
if (!object || !('h3' in object)) return null; if (!object || !('h3' in object)) return null;
const hex = object as HexagonData; 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
? `<div style="color: #0066cc; margin-top: 4px; font-size: 12px;">${journeyLines.join('<br/>')}</div>`
: '';
return { return {
html: `<div style="padding: 8px; font-size: 14px;"> html: `<div style="padding: 8px; font-size: 14px;">
<strong>Avg: £${hex.avg_price?.toLocaleString() || 'N/A'}</strong> <strong>Avg: £${hex.avg_price?.toLocaleString() || 'N/A'}</strong>
@ -294,6 +327,7 @@ export default function Map({ data, pois, onViewChange }: MapProps) {
${hex.count} sales<br/> ${hex.count} sales<br/>
Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()} Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()}
</div> </div>
${journeyTimeHtml}
</div>`, </div>`,
style: { style: {
backgroundColor: 'white', backgroundColor: 'white',
@ -327,9 +361,7 @@ export default function Map({ data, pois, onViewChange }: MapProps) {
<strong> <strong>
{getTooltipEmoji(popupInfo.category)} {popupInfo.name} {getTooltipEmoji(popupInfo.category)} {popupInfo.name}
</strong> </strong>
<div className="text-gray-500 text-xs"> <div className="text-gray-500 text-xs">{popupInfo.category.replace(/_/g, ' ')}</div>
{popupInfo.category.replace(/_/g, ' ')}
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -19,8 +19,14 @@ export interface HexagonData {
median_price: number; median_price: number;
min_price: number; min_price: number;
max_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 { export interface ViewState {
longitude: number; longitude: number;
latitude: number; latitude: number;