Add travel times
This commit is contained in:
parent
275e5afac6
commit
75950f0b1b
4 changed files with 122 additions and 38 deletions
|
|
@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('price');
|
||||
|
||||
// POI state
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<POICategoryGroup>>(
|
||||
|
|
@ -166,9 +169,11 @@ export default function App() {
|
|||
zoom={zoom}
|
||||
selectedPOICategories={selectedPOICategories}
|
||||
onPOICategoriesChange={setSelectedPOICategories}
|
||||
colorMode={colorMode}
|
||||
onColorModeChange={setColorMode}
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Map data={data} pois={pois} onViewChange={handleViewChange} />
|
||||
<Map data={data} pois={pois} onViewChange={handleViewChange} colorMode={colorMode} />
|
||||
{loading && (
|
||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<POICategoryGroup>;
|
||||
onPOICategoriesChange: (categories: Set<POICategoryGroup>) => void;
|
||||
colorMode: ColorMode;
|
||||
onColorModeChange: (mode: ColorMode) => void;
|
||||
}
|
||||
|
||||
const POI_LABELS: Record<POICategoryGroup, string> = {
|
||||
|
|
@ -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({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 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 className="space-y-2">
|
||||
<Label>Color By</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`flex-1 px-3 py-1.5 text-sm rounded ${colorMode === 'price' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700'}`}
|
||||
onClick={() => onColorModeChange('price')}
|
||||
>
|
||||
Price
|
||||
</button>
|
||||
<button
|
||||
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'}`}
|
||||
onClick={() => onColorModeChange('journey_time')}
|
||||
>
|
||||
Journey Time
|
||||
</button>
|
||||
</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">
|
||||
<Label>Points of Interest</Label>
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue