All good
Some checks failed
CI / Check (push) Has been cancelled
Build and publish Docker image / build-and-push (push) Has been cancelled

This commit is contained in:
Andras Schmelczer 2026-05-18 21:20:10 +01:00
parent 6ea544a0f6
commit 6cc7288126
45 changed files with 929 additions and 1043 deletions

View file

@ -68,6 +68,34 @@ const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
};
const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']);
const GOOGLE_MAPS_DEPARTURE_TIME_ZONE = 'Europe/London';
const londonDateFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: GOOGLE_MAPS_DEPARTURE_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const londonDateTimeFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: GOOGLE_MAPS_DEPARTURE_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
hourCycle: 'h23',
});
function dateTimeParts(formatter: Intl.DateTimeFormat, date: Date): Record<string, number> {
const parts: Record<string, number> = {};
formatter.formatToParts(date).forEach((part) => {
if (part.type !== 'literal') {
parts[part.type] = Number(part.value);
}
});
return parts;
}
/** Strip trailing parenthesized GTFS route IDs and NaPTAN stop codes (e.g. "(6757261)", "(9400ZZLUCGT1)") */
function stripId(label: string): string {
@ -87,15 +115,48 @@ function getRouteDisplay(mode: string): { label: string; color: string; darkText
return { label: clean, color: '#6b7280', darkText: false };
}
/** Returns a Unix timestamp for the next Monday at 07:30 local time. */
function londonOffsetMs(utcMs: number): number {
const parts = dateTimeParts(londonDateTimeFormatter, new Date(utcMs));
const londonAsUtcMs = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
);
return londonAsUtcMs - utcMs;
}
function londonTimeToUtcMs(
year: number,
month: number,
day: number,
hour: number,
minute: number
): number {
const localAsUtcMs = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
const offsetMs = londonOffsetMs(localAsUtcMs);
const utcMs = localAsUtcMs - offsetMs;
const correctedOffsetMs = londonOffsetMs(utcMs);
return correctedOffsetMs === offsetMs ? utcMs : localAsUtcMs - correctedOffsetMs;
}
/** Returns a Unix timestamp for the next Monday at 07:30 Europe/London time. */
function nextMondayAt730(): number {
const now = new Date();
const day = now.getDay(); // 0=Sun … 6=Sat
const today = dateTimeParts(londonDateFormatter, now);
const day = new Date(Date.UTC(today.year, today.month - 1, today.day)).getUTCDay();
const daysUntil = day === 0 ? 1 : day === 1 ? 7 : 8 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + daysUntil);
monday.setHours(7, 30, 0, 0);
return Math.floor(monday.getTime() / 1000);
const monday = new Date(Date.UTC(today.year, today.month - 1, today.day + daysUntil));
const utcMs = londonTimeToUtcMs(
monday.getUTCFullYear(),
monday.getUTCMonth() + 1,
monday.getUTCDate(),
7,
30
);
return Math.floor(utcMs / 1000);
}
function googleMapsDestination(

View file

@ -419,6 +419,7 @@ export default function MapPage({
const { listings: actualListings } = useActualListings(mapData.bounds, {
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
shareCode,
});
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);

View file

@ -7,11 +7,12 @@ const DEBOUNCE_MS = 200;
interface UseActualListingsOptions {
filterParam?: string;
travelParam?: string;
shareCode?: string;
}
export function useActualListings(
bounds: Bounds | null,
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
{ filterParam = '', travelParam = '', shareCode = '' }: UseActualListingsOptions = {}
) {
const [listings, setListings] = useState<ActualListing[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -38,11 +39,15 @@ export function useActualListings(
const params = new URLSearchParams({ bounds: boundsStr });
if (filterParam) params.set('filters', filterParam);
if (travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const res = await fetch(
apiUrl('actual-listings', params),
authHeaders({ signal: abortControllerRef.current.signal })
);
if (!res.ok) throw new Error(`Actual listings fetch failed: HTTP ${res.status}`);
if (!res.ok) {
if (requestIdRef.current === requestId) setListings([]);
throw new Error(`Actual listings fetch failed: HTTP ${res.status}`);
}
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
@ -57,7 +62,7 @@ export function useActualListings(
};
// listings intentionally excluded — it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds, filterParam, travelParam]);
}, [bounds, filterParam, travelParam, shareCode]);
return { listings };
}

View file

@ -109,9 +109,6 @@ export function useDeckLayers({
listings: actualListings,
zoom,
isDark,
hexagonData: data,
postcodeData,
usePostcodeView,
});
// --- Refs for deck.gl accessors ---

View file

@ -1,9 +1,8 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { getResolution, latLngToCell } from 'h3-js';
import type { ActualListing, HexagonData, PostcodeFeature } from '../types';
import type { ActualListing } from '../types';
import { trackEvent } from '../lib/analytics';
const PRICE_LABEL_MIN_ZOOM = 14;
@ -19,14 +18,6 @@ interface UseListingLayersProps {
listings: ActualListing[];
zoom: number;
isDark: boolean;
hexagonData: HexagonData[];
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
}
function normalizePostcode(value: string | undefined | null): string {
if (!value) return '';
return value.replace(/\s+/g, '').toUpperCase();
}
function formatShortPrice(price: number): string {
@ -35,57 +26,9 @@ function formatShortPrice(price: number): string {
return `£${price}`;
}
export function useListingLayers({
listings,
zoom,
isDark,
hexagonData,
postcodeData,
usePostcodeView,
}: UseListingLayersProps) {
export function useListingLayers({ listings, zoom, isDark }: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
// Split into two memos so the inactive view's data changes don't invalidate
// the active filtered list. (e.g. in postcode view, hexagonData updates must
// not retrigger filtering / downstream layer rebuilds.)
const postcodeFilteredListings = useMemo(() => {
if (!usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}, [listings, postcodeData, usePostcodeView]);
const hexFilteredListings = useMemo(() => {
if (usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
let cellResolution: number | null = null;
for (const cell of hexagonData) {
if (cell.count > 0) {
allowed.add(cell.h3);
if (cellResolution == null) cellResolution = getResolution(cell.h3);
}
}
if (allowed.size === 0 || cellResolution == null) return [];
const resolutionForLookup = cellResolution;
return listings.filter((listing) => {
try {
return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup));
} catch {
return false;
}
});
}, [listings, hexagonData, usePostcodeView]);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
}, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({ x: info.x, y: info.y, listing: info.object });
@ -119,21 +62,21 @@ export function useListingLayers({
() =>
new ScatterplotLayer<ActualListing>({
id: 'actual-listing-shadow',
data: visibleListings,
data: listings,
getPosition: (d) => [d.lon, d.lat],
getRadius: 8,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
pickable: false,
}),
[visibleListings, isDark]
[listings, isDark]
);
const pinLayer = useMemo(
() =>
new ScatterplotLayer<ActualListing>({
id: 'actual-listing-pin',
data: visibleListings,
data: listings,
getPosition: (d) => [d.lon, d.lat],
getRadius: 7,
radiusUnits: 'pixels',
@ -148,12 +91,12 @@ export function useListingLayers({
onHover: stableHover,
onClick: stableClick,
}),
[visibleListings, stableHover, stableClick]
[listings, stableHover, stableClick]
);
const priceLabelLayer = useMemo(() => {
if (zoom < PRICE_LABEL_MIN_ZOOM) return null;
const labeled = visibleListings.filter((l) => l.asking_price && l.asking_price > 0);
const labeled = listings.filter((l) => l.asking_price && l.asking_price > 0);
return new TextLayer<ActualListing>({
id: 'actual-listing-price',
data: labeled,
@ -174,11 +117,11 @@ export function useListingLayers({
sizeMaxPixels: 14,
pickable: false,
});
}, [visibleListings, zoom, isDark]);
}, [listings, zoom, isDark]);
const detailLabelLayer = useMemo(() => {
if (zoom < ADDRESS_LABEL_MIN_ZOOM) return null;
const labeled = visibleListings.filter((l) => l.address || l.bedrooms != null);
const labeled = listings.filter((l) => l.address || l.bedrooms != null);
return new TextLayer<ActualListing>({
id: 'actual-listing-detail',
data: labeled,
@ -205,7 +148,7 @@ export function useListingLayers({
sizeMaxPixels: 12,
pickable: false,
});
}, [visibleListings, zoom, isDark]);
}, [listings, zoom, isDark]);
const listingLayers = useMemo(() => {
const layers: Layer[] = [pinShadowLayer, pinLayer];

View file

@ -132,7 +132,6 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
export const POI_CATEGORY_LOGOS: Record<string, string> = {
Airport: '/assets/twemoji/2708.png',
Aldi: '/assets/poi-icons/logos/aldi.svg',
'Allendale Co-operative Society': '/assets/poi-icons/logos/coop.svg',
Amazon: '/assets/poi-icons/brands_2024/amazon_fresh.svg',
Asda: '/assets/poi-icons/logos/asda.svg',
'Asda Express': '/assets/poi-icons/logos/asda.svg',
@ -148,26 +147,18 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: '/assets/poi-icons/logos/centra.svg',
'Central England Co-operative': '/assets/poi-icons/logos/coop.svg',
'Chelmsford Star Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'Clydebank Co-operative': '/assets/poi-icons/logos/coop.svg',
'Co-op': '/assets/poi-icons/logos/coop.svg',
'Coniston Co-operative Society': '/assets/poi-icons/logos/coop.svg',
COOK: '/assets/poi-icons/brands_2024/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: '/assets/poi-icons/logos/costco.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': '/assets/poi-icons/brands_2024/dunnes_stores.svg',
'East of England Co-operative': '/assets/poi-icons/logos/coop.svg',
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
'Heart of England Co-operative': '/assets/poi-icons/logos/coop.svg',
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
Iceland: '/assets/poi-icons/brands_2024/iceland.svg',
Lidl: '/assets/poi-icons/logos/lidl.svg',
'Langdale Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'Lincolnshire Co-operative': '/assets/poi-icons/logos/coop.svg',
Makro: '/assets/poi-icons/brands_2024/makro.svg',
'M&S': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Clothing': '/assets/poi-icons/brands_2024/mns.svg',
@ -175,7 +166,6 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
'M&S Hospital': '/assets/poi-icons/brands_2024/mns.svg',
'M&S MSA': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Outlet': '/assets/poi-icons/brands_2024/mns.svg',
'Midcounties Co-operative': '/assets/poi-icons/logos/coop.svg',
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
@ -183,16 +173,12 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": '/assets/poi-icons/logos/sainsburys.svg',
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
'Scottish Midland Co-operative': '/assets/poi-icons/logos/coop.svg',
Spar: '/assets/poi-icons/logos/spar.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
'Tamworth Co-operative Society': '/assets/poi-icons/logos/coop.svg',
Tesco: '/assets/poi-icons/logos/tesco.svg',
'Tesco Express': '/assets/poi-icons/logos/tesco_express.svg',
'Tesco Extra': '/assets/poi-icons/logos/tesco_extra.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
'The Radstock Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'The Southern Co-operative': '/assets/poi-icons/logos/coop.svg',
'The Food Warehouse': '/assets/poi-icons/logos/the_food_warehouse.png',
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
Waitrose: '/assets/poi-icons/logos/waitrose.svg',