All good
This commit is contained in:
parent
6ea544a0f6
commit
6cc7288126
45 changed files with 929 additions and 1043 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -419,6 +419,7 @@ export default function MapPage({
|
|||
const { listings: actualListings } = useActualListings(mapData.bounds, {
|
||||
filterParam: actualListingsFilterParam,
|
||||
travelParam: actualListingsTravelParam,
|
||||
shareCode,
|
||||
});
|
||||
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,9 +109,6 @@ export function useDeckLayers({
|
|||
listings: actualListings,
|
||||
zoom,
|
||||
isDark,
|
||||
hexagonData: data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
});
|
||||
|
||||
// --- Refs for deck.gl accessors ---
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue