This commit is contained in:
Andras Schmelczer 2026-02-18 21:22:15 +00:00
parent 524580eb25
commit ffe080adef
82 changed files with 2652 additions and 2956 deletions

View file

@ -1,123 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, HexagonStatsResponse } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
interface UseAreaSummaryOptions {
stats: HexagonStatsResponse | null;
hexagonId: string | null;
isPostcode: boolean;
filters: FeatureFilters;
features: FeatureMeta[];
}
interface UseAreaSummaryResult {
summary: string;
loading: boolean;
error: string | null;
}
const FORBIDDEN_FEATURES = [
'% White',
'% Black',
'% Asian',
'% Mixed',
'% Other',
'Environmental risk',
'Collapsible deposits risk',
'Compressible ground risk',
'Landslide risk',
'Running sand risk',
'Shrink-swell risk',
'Soluble rocks risk',
];
export function useAreaSummary({
stats,
hexagonId,
isPostcode,
filters,
features,
}: UseAreaSummaryOptions): UseAreaSummaryResult {
const [summary, setSummary] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchSummary = useCallback(async () => {
if (!stats || !hexagonId) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setSummary('');
setLoading(true);
setError(null);
try {
const filterDescriptions: string[] = [];
for (const [name, value] of Object.entries(filters)) {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
filterDescriptions.push(`${name}: ${(value as string[]).join(', ')}`);
} else {
const [min, max] = value as [number, number];
filterDescriptions.push(`${name}: ${min}${max}`);
}
}
const body = {
count: stats.count,
location: hexagonId,
is_postcode: isPostcode,
filters: filterDescriptions,
numeric_stats: stats.numeric_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
counts: f.counts,
})),
};
const url = apiUrl('area-summary');
const response = await fetch(
url,
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
const json = await response.json();
setSummary(json.summary || '');
setLoading(false);
} catch (err) {
if (controller.signal.aborted) return;
logNonAbortError('area-summary', err);
setError(err instanceof Error ? err.message : 'Failed to generate summary');
setLoading(false);
}
}, [stats, hexagonId, isPostcode, filters, features]);
useEffect(() => {
fetchSummary();
return () => {
abortRef.current?.abort();
};
}, [fetchSummary]);
return { summary, loading, error };
}

View file

@ -46,10 +46,9 @@ interface UseDeckLayersProps {
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
export interface PopupInfo {
interface PopupInfo {
x: number;
y: number;
name: string;
@ -57,17 +56,6 @@ export interface PopupInfo {
id: string;
}
/** 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 -1;
}
export function useDeckLayers({
data,
postcodeData,
@ -85,7 +73,6 @@ export function useDeckLayers({
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries = [],
travelTimeColorRanges = new Map(),
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -124,15 +111,6 @@ export function useDeckLayers({
const travelTimeEntriesRef = useRef(travelTimeEntries);
travelTimeEntriesRef.current = travelTimeEntries;
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelIndex = useMemo(
() => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelIndexRef = useRef(primaryTravelIndex);
primaryTravelIndexRef.current = primaryTravelIndex;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -263,11 +241,10 @@ export function useDeckLayers({
const parts: string[] = [];
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]}`);
parts.push(`${i}:${entry.slug}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
}, [travelTimeEntries]);
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
@ -281,37 +258,36 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const pti = primaryTravelIndexRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// 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];
// Dim-filter: all travel entries with timeRange dim hexagons outside range
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];
}
}
// 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];
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
// Travel time feature: dim hexagons with no data
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];
}
}
if (ttClr) {
return getFeatureFillColor(
ttVal as number,
ttVal as number,
ttVal as number,
ttClr,
clr,
null,
0,
densityGradientRef.current,
@ -319,27 +295,27 @@ export function useDeckLayers({
255
);
}
// Regular feature
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255
);
}
}
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
return getFeatureFillColor(
val as number | null | undefined,
minVal,
maxVal,
clr,
fr,
0,
densityGradientRef.current,
dark,
255
);
}
// Density fallback
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
@ -560,6 +536,5 @@ export function useDeckLayers({
colorFeatureMeta,
handleMouseLeave,
hoveredPostcode,
primaryTravelIndex,
};
}

View file

@ -131,6 +131,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);
const handleSetPin = useCallback((name: string) => {
setPinnedFeature(name);
}, []);
const handleCancelPin = useCallback(() => {
setPinnedFeature(null);
}, []);
@ -158,6 +162,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
handleDragChange,
handleDragEnd,
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
};

View file

@ -4,7 +4,7 @@ import { authHeaders, logNonAbortError } from '../lib/api';
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
export function looksLikePostcode(s: string) {
function looksLikePostcode(s: string) {
return POSTCODE_RE.test(s.trim());
}

View file

@ -11,7 +11,7 @@ import type {
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, travelFieldKey } from './useTravelTime';
import { type TravelTimeEntry } from './useTravelTime';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -107,7 +107,7 @@ export function useMapData({
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
@ -133,7 +133,7 @@ export function useMapData({
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
if (travelParam) {
params.set('travel', travelParam);
}
@ -176,14 +176,18 @@ export function useMapData({
// Compute p5/p95 from visible data for the viewed feature
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null;
if (activeFeature && !dragData) return null;
const isTravelTime = viewFeature.startsWith('tt_');
if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null;
if (activeFeature && !dragData) return null;
}
const vals: number[] = [];
if (usePostcodeView) {
if (usePostcodeView && !isTravelTime) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
if (bounds) {
@ -218,6 +222,12 @@ export function useMapData({
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
// Travel time keys: use dataRange directly (no FeatureMeta)
if (viewFeature.startsWith('tt_')) {
return dataRange;
}
const meta = features.find((f) => f.name === viewFeature);
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
@ -229,33 +239,6 @@ export function useMapData({
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
// 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) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges.set(i, [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
]);
}
return ranges;
}, [travelTimeEntries, data, bounds]);
const handleViewChange = useCallback(
({
resolution: newRes,
@ -295,7 +278,6 @@ export function useMapData({
currentView,
usePostcodeView,
colorRange,
travelTimeColorRanges,
handleViewChange,
setInitialView,
licenseRequired,

View file

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { apiUrl } from '../lib/api';
import { apiUrl, authHeaders } from '../lib/api';
export interface SavedSearch {
id: string;
@ -53,7 +53,7 @@ export function useSavedSearches(userId: string | null) {
// Capture a screenshot via the screenshot endpoint
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
const screenshotRes = await fetch(screenshotUrl);
const screenshotRes = await fetch(screenshotUrl, authHeaders());
if (!screenshotRes.ok) {
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
}

View file

@ -18,11 +18,6 @@ export interface TravelTimeEntry {
timeRange: [number, number] | null;
}
/** 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}`;