changes
This commit is contained in:
parent
524580eb25
commit
ffe080adef
82 changed files with 2652 additions and 2956 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (0–100) 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,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue