This commit is contained in:
Andras Schmelczer 2026-03-15 17:38:26 +00:00
parent 80c093b7ba
commit f72c43a9fa
101 changed files with 2168 additions and 1177 deletions

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from 'react';
import type { FeatureFilters } from '../types';
import type { TransportMode, TravelTimeEntry } from './useTravelTime';
import type { TransportMode } from './useTravelTime';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
export interface AiTravelTimeFilter {
@ -37,10 +37,7 @@ interface UseAiFiltersResult {
}
/** Build a human-readable summary of the AI result. */
function buildSummary(
filters: FeatureFilters,
travelTimeFilters: AiTravelTimeFilter[]
): string {
function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFilter[]): string {
const parts: string[] = [];
for (const [name, value] of Object.entries(filters)) {

View file

@ -1,6 +1,10 @@
import { useState, useCallback } from 'react';
export function useCollapsibleGroups(): [Set<string>, (name: string) => void, (name: string) => void] {
export function useCollapsibleGroups(): [
Set<string>,
(name: string) => void,
(name: string) => void,
] {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggle = useCallback((name: string) => {

View file

@ -24,13 +24,9 @@ import {
POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
type TravelTimeEntry,
travelFieldKey,
} from './useTravelTime';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
interface UseDeckLayersProps {
data: HexagonData[];
postcodeData: PostcodeFeature[];
@ -314,8 +310,17 @@ export function useDeckLayers({
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];
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,
];
}
}
@ -329,7 +334,12 @@ export function useDeckLayers({
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
return getFeatureFillColor(
ttVal as number,
@ -423,8 +433,17 @@ export function useDeckLayers({
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];
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,
];
}
}
@ -438,7 +457,12 @@ export function useDeckLayers({
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
return getFeatureFillColor(
ttVal as number,
@ -673,8 +697,7 @@ export function useDeckLayers({
id: 'poi-cluster-text',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getText: (d) =>
d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count),
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
getSize: 12,
getColor: [255, 255, 255, 255],
fontWeight: 700,

View file

@ -1,10 +1,7 @@
import { useCallback, useLayoutEffect, useState } from 'react';
import type React from 'react';
export function useDropdownPosition(
anchorRef: React.RefObject<HTMLElement | null>,
open: boolean,
) {
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const update = useCallback(() => {

View file

@ -29,7 +29,12 @@ interface UseHexagonSelectionOptions {
journeyDest?: JourneyDest | null;
}
export function useHexagonSelection({ filters, features, resolution, journeyDest }: UseHexagonSelectionOptions) {
export function useHexagonSelection({
filters,
features,
resolution,
journeyDest,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
@ -39,8 +44,9 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] =
useState<PostcodeGeometry | null>(null);
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
null
);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
@ -204,7 +210,13 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties]);
}, [
selectedHexagon,
properties.length,
loadingProperties,
fetchHexagonProperties,
fetchPostcodeProperties,
]);
const handleLoadMoreProperties = useCallback(() => {
if (!selectedHexagon) return;

View file

@ -19,7 +19,15 @@ function normalizePostcode(s: string): string {
export type SearchResult =
| { type: 'postcode'; label: string }
| { type: 'place'; name: string; slug: 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(mode?: string) {
const [query, setQuery] = useState('');
@ -29,60 +37,63 @@ export function useLocationSearch(mode?: string) {
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleInputChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(-1);
const handleInputChange = useCallback(
(value: string) => {
setQuery(value);
setActiveIndex(-1);
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setOpen(false);
return;
}
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
setOpen(true);
return;
}
if (trimmed.length < 2) {
setResults([]);
setOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
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 }),
);
if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
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);
} catch (err) {
logNonAbortError('places search', err);
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setOpen(false);
return;
}
}, 200);
}, [mode]);
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
setOpen(true);
return;
}
if (trimmed.length < 2) {
setResults([]);
setOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
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 })
);
if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
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);
} catch (err) {
logNonAbortError('places search', err);
}
}, 200);
},
[mode]
);
const close = useCallback(() => setOpen(false), []);
@ -112,7 +123,7 @@ export function useLocationSearch(mode?: string) {
setOpen(false);
}
},
[results, activeIndex, query],
[results, activeIndex, query]
);
// Cleanup on unmount

View file

@ -8,7 +8,14 @@ import type {
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } 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 { type TravelTimeEntry } from './useTravelTime';
@ -243,8 +250,11 @@ export function useMapData({
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
const data =
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
const effectivePostcodeData =
(viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ??
postcodeData;
// Compute p5/p95 from committed data for the viewed feature.
// Always uses rawData/postcodeData (not drag preview data) so the color

View file

@ -46,7 +46,10 @@ export function useSavedProperties(userId: string | null) {
const raw = r as Record<string, unknown>;
let data: SavedPropertyData = {};
try {
data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {};
data =
typeof raw.data === 'string'
? JSON.parse(raw.data)
: (raw.data as SavedPropertyData) || {};
} catch {
// Invalid JSON — use empty data
}

View file

@ -24,10 +24,7 @@ export function useTravelDestinations(mode: TransportMode) {
const controller = new AbortController();
setLoading(true);
fetch(
`/api/travel-destinations?mode=${mode}`,
authHeaders({ signal: controller.signal }),
)
fetch(`/api/travel-destinations?mode=${mode}`, authHeaders({ signal: controller.signal }))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();

View file

@ -21,7 +21,7 @@ export function useTravelModes() {
})
.then((data: { modes: TravelModeInfo[] }) => {
const modes = new Set<TransportMode>(
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode),
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode)
);
setAvailableModes(modes);
})

View file

@ -40,58 +40,39 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
{ mode, slug: '', label: '', timeRange: null, useBest: false },
]);
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
}, []);
const handleRemoveEntry = useCallback((index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleSetDestination = useCallback(
(index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
)
);
},
[]
);
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
)
);
}, []);
const handleTimeRangeChange = useCallback(
(index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, timeRange: range } : entry
)
);
},
[]
);
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
);
}, []);
const handleToggleBest = useCallback(
(index: number) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, useBest: !entry.useBest } : entry
)
);
},
[]
);
const handleToggleBest = useCallback((index: number) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
);
}, []);
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
setEntries(newEntries);
}, []);
/** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''),
[entries]
);
const activeEntries = useMemo(() => entries.filter((e) => e.slug !== ''), [entries]);
return {
entries,

View file

@ -32,8 +32,7 @@ const STEPS: Step[] = [
{
target: '[data-tutorial="search"]',
title: 'Search Locations',
content:
'Search for a place name or postcode to jump directly to that area on the map.',
content: 'Search for a place name or postcode to jump directly to that area on the map.',
placement: 'bottom',
disableBeacon: true,
},