This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

View file

@ -117,7 +117,7 @@ export function useAreaSummary({
return () => {
abortRef.current?.abort();
};
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [fetchSummary]);
return { summary, loading, error };
}

View file

@ -7,6 +7,7 @@ export interface AuthUser {
verified: boolean;
isAdmin: boolean;
subscription: string;
newsletter: boolean;
}
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
@ -19,6 +20,7 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
verified: typeof record.verified === 'boolean' ? record.verified : false,
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
};
}
@ -115,8 +117,32 @@ export function useAuth() {
}, []);
const refreshAuth = useCallback(async () => {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Auth refresh failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const requestVerification = useCallback(async (email: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').requestVerification(email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Verification request failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const clearError = useCallback(() => {
@ -132,6 +158,7 @@ export function useAuth() {
loginWithOAuth,
logout,
requestPasswordReset,
requestVerification,
refreshAuth,
clearError,
};

View file

@ -1,6 +1,6 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@ -14,9 +14,8 @@ import type {
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeEntry,
travelFieldKey,
} from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
@ -46,8 +45,8 @@ interface UseDeckLayersProps {
theme: 'light' | 'dark';
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
export interface PopupInfo {
@ -58,15 +57,15 @@ export interface PopupInfo {
id: string;
}
/** Find the primary travel mode: first mode (in canonical order) with a destination and color range. */
function getPrimaryTravelMode(
entries: TravelTimeEntries,
colorRanges: Partial<Record<TransportMode, [number, number]>>
): TransportMode | null {
for (const mode of TRANSPORT_MODES) {
if (entries[mode]?.destination && colorRanges[mode]) return mode;
/** Find the primary travel time entry: first entry with a slug and color range. */
function getPrimaryTravelIndex(
entries: TravelTimeEntry[],
colorRanges: Map<number, [number, number]>
): number {
for (let i = 0; i < entries.length; i++) {
if (entries[i].slug && colorRanges.has(i)) return i;
}
return null;
return -1;
}
export function useDeckLayers({
@ -85,8 +84,8 @@ export function useDeckLayers({
theme,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries = {},
travelTimeColorRanges = {},
travelTimeEntries = [],
travelTimeColorRanges = new Map(),
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -105,7 +104,7 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
// --- Refs for deck.gl accessors (avoid re-creating layers on every change) ---
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const colorRangeRef = useRef(colorRange);
@ -128,12 +127,12 @@ export function useDeckLayers({
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelMode = useMemo(
() => getPrimaryTravelMode(travelTimeEntries, travelTimeColorRanges),
const primaryTravelIndex = useMemo(
() => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelModeRef = useRef(primaryTravelMode);
primaryTravelModeRef.current = primaryTravelMode;
const primaryTravelIndexRef = useRef(primaryTravelIndex);
primaryTravelIndexRef.current = primaryTravelIndex;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -260,13 +259,12 @@ export function useDeckLayers({
}, []);
// --- Color triggers ---
// Build travel time trigger from all entries
const ttTrigger = useMemo(() => {
const parts: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
const cr = travelTimeColorRanges[mode];
parts.push(`${mode}:${entry?.destination?.[0]}|${entry?.destination?.[1]}|${cr?.[0]}|${cr?.[1]}|${entry?.timeRange?.[0]}|${entry?.timeRange?.[1]}`);
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
const cr = travelTimeColorRanges.get(i);
parts.push(`${i}:${entry.slug}|${cr?.[0]}|${cr?.[1]}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
@ -283,23 +281,26 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const pm = primaryTravelModeRef.current;
const pti = primaryTravelIndexRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// Travel time coloring: primary mode colors, others dim-filter
if (pm) {
const ttVal = d[`travel_time_${pm}`];
const ttClr = colorRanges[pm];
// Travel time coloring: primary entry colors, others dim-filter
if (pti >= 0) {
const primaryEntry = entries[pti];
const fieldKey = travelFieldKey(primaryEntry);
const ttVal = d[`avg_${fieldKey}`];
const ttClr = colorRanges.get(pti);
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
// Check all modes with time ranges as filters (including primary)
for (const mode of TRANSPORT_MODES) {
const entry = entries[mode];
if (!entry?.timeRange) continue;
const modeVal = d[`travel_time_${mode}`];
// Check all entries with time ranges as filters
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
@ -504,7 +505,7 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Marching ants highlight layer for selected postcode (click or search)
// Marching ants highlight layer for selected postcode
const marchingAntsLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
return new GeoJsonLayer({
@ -527,42 +528,12 @@ export function useDeckLayers({
});
}, [selectedPostcodeGeometry, marchTime]);
// Destination markers: one red dot per mode with a destination
const destinationMarkerData = useMemo(() => {
const points: { position: [number, number] }[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
points.push({ position: [entry.destination[1], entry.destination[0]] });
}
}
return points;
}, [travelTimeEntries]);
const destinationMarkerLayer = useMemo(() => {
if (destinationMarkerData.length === 0) return null;
return new ScatterplotLayer({
id: 'travel-time-destinations',
data: destinationMarkerData,
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 8,
getFillColor: [239, 68, 68, 220],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
lineWidthUnits: 'pixels' as const,
radiusUnits: 'pixels' as const,
stroked: true,
pickable: false,
});
}, [destinationMarkerData]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
return baseLayers;
}, [
usePostcodeView,
@ -571,7 +542,6 @@ export function useDeckLayers({
postcodeLabelsLayer,
poiLayer,
marchingAntsLayer,
destinationMarkerLayer,
]);
const handleMouseLeave = useCallback(() => {
@ -590,6 +560,6 @@ export function useDeckLayers({
colorFeatureMeta,
handleMouseLeave,
hoveredPostcode,
primaryTravelMode,
primaryTravelIndex,
};
}

View file

@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../lib/api';
export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const startCheckout = useCallback(async (referralCode?: string) => {
setCheckingOut(true);
setError(null);
try {
const body: Record<string, string> = {};
if (referralCode) body.referral_code = referralCode;
const res = await fetch(apiUrl('checkout'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
});
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
window.location.href = data.url;
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Checkout failed';
setError(msg);
throw err;
} finally {
setCheckingOut(false);
}
}, []);
return { startCheckout, checkingOut, error };
}

View file

@ -10,9 +10,9 @@ export function looksLikePostcode(s: string) {
export type SearchResult =
| { type: 'postcode'; label: string }
| { type: 'place'; name: string; place_type: string; lat: number; lon: number; city?: string };
| { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string };
export function useLocationSearch() {
export function useLocationSearch(mode?: string) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
@ -34,7 +34,7 @@ export function useLocationSearch() {
return;
}
if (looksLikePostcode(trimmed)) {
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
setOpen(true);
return;
@ -51,6 +51,7 @@ export function useLocationSearch() {
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '7' });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal }),
@ -59,7 +60,12 @@ export function useLocationSearch() {
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
...p,
name: p.name,
slug: p.slug,
place_type: p.place_type,
lat: p.lat,
lon: p.lon,
city: p.city,
}));
setResults(placeResults);
setOpen(placeResults.length > 0);
@ -67,7 +73,7 @@ export function useLocationSearch() {
logNonAbortError('places search', err);
}
}, 200);
}, []);
}, [mode]);
const close = useCallback(() => setOpen(false), []);

View file

@ -8,10 +8,10 @@ import type {
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { TRANSPORT_MODES, type TransportMode, type TravelTimeEntries } from './useTravelTime';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -33,7 +33,7 @@ interface UseMapDataOptions {
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEntries: TravelTimeEntries;
travelTimeEntries: TravelTimeEntry[];
}
export function useMapData({
@ -56,6 +56,8 @@ export function useMapData({
longitude: number;
zoom: number;
} | null>(null);
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@ -69,13 +71,16 @@ export function useMapData({
);
// Build the travel param string from entries with destinations
// Format: mode:slug|mode:slug or mode:slug:min:max|mode:slug
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
segments.push(`${entry.destination[0]},${entry.destination[1]},${mode}`);
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(seg);
}
return segments.join('|');
}, [travelTimeEntries]);
@ -109,7 +114,16 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
return;
}
}
assertOk(res, 'postcodes');
setLicenseRequired(false);
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features);
setRawData([]);
@ -129,13 +143,22 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
return;
}
}
assertOk(res, 'hexagons');
setLicenseRequired(false);
const json: ApiResponse = await res.json();
setRawData(json.features);
setPostcodeData([]);
}
} catch (err) {
logNonAbortError('Failed to fetch data', err);
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
} finally {
setLoading(false);
}
@ -151,7 +174,6 @@ export function useMapData({
const data = dragData ?? rawData;
// Compute p5/p95 from visible data for the viewed feature
// Only considers hexagons/postcodes whose center falls within the viewport bounds
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
@ -207,13 +229,13 @@ export function useMapData({
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
// Color ranges for travel time per mode (computed from response data)
const travelTimeColorRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
const fieldName = `travel_time_${mode}`;
// Color ranges for travel time per entry (computed from response data)
const travelTimeColorRanges = useMemo((): Map<number, [number, number]> => {
const ranges = new Map<number, [number, number]>();
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
@ -226,10 +248,10 @@ export function useMapData({
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges[mode] = [
ranges.set(i, [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
]);
}
return ranges;
}, [travelTimeEntries, data, bounds]);
@ -276,5 +298,7 @@ export function useMapData({
travelTimeColorRanges,
handleViewChange,
setInitialView,
licenseRequired,
freeZone,
};
}

View file

@ -38,6 +38,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
signal: abortControllerRef.current.signal,
})
);
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
const json: POIResponse = await res.json();
setPois(json.pois || []);
} catch (err) {

View file

@ -12,71 +12,73 @@ export const MODE_LABELS: Record<TransportMode, string> = {
};
export interface TravelTimeEntry {
destination: [number, number] | null; // [lat, lon]
destinationLabel: string;
mode: TransportMode;
slug: string;
label: string;
timeRange: [number, number] | null;
}
export type TravelTimeEntries = Partial<Record<TransportMode, TravelTimeEntry>>;
/** Unique key for a travel time entry */
export function travelEntryKey(entry: TravelTimeEntry): string {
return `${entry.mode}:${entry.slug}`;
}
/** Field key matching the backend response: tt_{mode}_{slug} */
export function travelFieldKey(entry: TravelTimeEntry): string {
return `tt_${entry.mode}_${entry.slug}`;
}
export interface TravelTimeInitial {
entries?: TravelTimeEntries;
entries?: TravelTimeEntry[];
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [entries, setEntries] = useState<TravelTimeEntries>(initial?.entries ?? {});
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in entries),
[entries]
);
const modesWithDestination = useMemo(
() => TRANSPORT_MODES.filter((m) => entries[m]?.destination != null),
[entries]
);
const handleEnableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => ({
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
[mode]: { destination: null, destinationLabel: '', timeRange: null },
}));
{ mode, slug: '', label: '', timeRange: null },
]);
}, []);
const handleDisableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => {
const next = { ...prev };
delete next[mode];
return next;
});
const handleRemoveEntry = useCallback((index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleSetDestination = useCallback(
(mode: TransportMode, lat: number, lon: number, label: string) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], destination: [lat, lon] as [number, number], destinationLabel: label },
}));
(index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: null } : entry
)
);
},
[]
);
const handleTimeRangeChange = useCallback(
(mode: TransportMode, range: [number, number]) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], timeRange: range },
}));
(index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, timeRange: range } : entry
)
);
},
[]
);
/** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''),
[entries]
);
return {
entries,
activeModes,
modesWithDestination,
handleEnableMode,
handleDisableMode,
activeEntries,
handleAddEntry,
handleRemoveEntry,
handleSetDestination,
handleTimeRangeChange,
};

View file

@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state';
import type { TravelTimeEntries } from './useTravelTime';
import type { TravelTimeEntry } from './useTravelTime';
const URL_DEBOUNCE_MS = 300;
@ -11,7 +11,7 @@ export function useUrlSync(
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntries
travelTimeEntries?: TravelTimeEntry[]
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);