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

@ -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,
};
}