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

@ -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<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ 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
? `<div style="color: #0066cc; margin-top: 4px; font-size: 12px;">${journeyLines.join('<br/>')}</div>`
: '';
return {
html: `<div style="padding: 8px; font-size: 14px;">
<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/>
Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()}
</div>
${journeyTimeHtml}
</div>`,
style: {
backgroundColor: 'white',
@ -327,9 +361,7 @@ export default function Map({ data, pois, onViewChange }: MapProps) {
<strong>
{getTooltipEmoji(popupInfo.category)} {popupInfo.name}
</strong>
<div className="text-gray-500 text-xs">
{popupInfo.category.replace(/_/g, ' ')}
</div>
<div className="text-gray-500 text-xs">{popupInfo.category.replace(/_/g, ' ')}</div>
</div>
)}
</div>