383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import type {
|
||
FeatureMeta,
|
||
FeatureFilters,
|
||
Bounds,
|
||
HexagonData,
|
||
PostcodeFeature,
|
||
ViewChangeParams,
|
||
ApiResponse,
|
||
} from '../types';
|
||
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';
|
||
|
||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||
function percentile(sorted: number[], p: number): number {
|
||
if (sorted.length === 0) return 0;
|
||
if (sorted.length === 1) return sorted[0];
|
||
const idx = (p / 100) * (sorted.length - 1);
|
||
const lo = Math.floor(idx);
|
||
const hi = Math.ceil(idx);
|
||
if (lo === hi) return sorted[lo];
|
||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
||
}
|
||
|
||
const DEBOUNCE_MS = 150;
|
||
|
||
interface UseMapDataOptions {
|
||
filters: FeatureFilters;
|
||
features: FeatureMeta[];
|
||
viewFeature: string | null;
|
||
activeFeature: string | null;
|
||
travelTimeEntries: TravelTimeEntry[];
|
||
}
|
||
|
||
export function useMapData({
|
||
filters,
|
||
features,
|
||
viewFeature,
|
||
activeFeature,
|
||
travelTimeEntries,
|
||
}: UseMapDataOptions) {
|
||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
|
||
const [resolution, setResolution] = useState<number>(8);
|
||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
const [zoom, setZoom] = useState<number>(10);
|
||
const [currentView, setCurrentView] = useState<{
|
||
latitude: number;
|
||
longitude: number;
|
||
zoom: number;
|
||
} | null>(null);
|
||
const [licenseRequired, setLicenseRequired] = useState(false);
|
||
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
||
|
||
// Drag preview state
|
||
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
||
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
|
||
const dragFeatureRef = useRef<string | null>(null);
|
||
const dragAbortRef = useRef<AbortController | null>(null);
|
||
const activeFeatureRef = useRef<string | null>(null);
|
||
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const abortControllerRef = useRef<AbortController | null>(null);
|
||
const prevBoundsRef = useRef<string>('');
|
||
|
||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||
|
||
const buildFilterParam = useCallback(
|
||
(): string => buildFilterString(filters, features),
|
||
[filters, features]
|
||
);
|
||
|
||
// Build the travel param string from entries with destinations.
|
||
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
|
||
// When excludeFieldKey is set, that entry's time range is omitted (for drag preview).
|
||
const buildTravelParam = useCallback(
|
||
(excludeFieldKey?: string): string => {
|
||
const segments: string[] = [];
|
||
for (const entry of travelTimeEntries) {
|
||
if (!entry.slug) continue;
|
||
let seg = `${entry.mode}:${entry.slug}`;
|
||
if (entry.useBest) seg += ':best';
|
||
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
|
||
if (entry.timeRange && !isExcluded) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||
segments.push(seg);
|
||
}
|
||
return segments.join('|');
|
||
},
|
||
[travelTimeEntries]
|
||
);
|
||
|
||
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
|
||
|
||
// Keep activeFeatureRef in sync
|
||
useEffect(() => {
|
||
activeFeatureRef.current = activeFeature;
|
||
}, [activeFeature]);
|
||
|
||
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
|
||
// For regular filters: excludes the filter from the filter string.
|
||
// For travel time: excludes the time range from that entry's travel param segment.
|
||
useEffect(() => {
|
||
if (!activeFeature || !bounds) return;
|
||
|
||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||
dragAbortRef.current = new AbortController();
|
||
|
||
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||
const isTravelTimeDrag = activeFeature.startsWith('tt_');
|
||
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
|
||
// Travel time fields are computed from the travel param, not regular feature columns.
|
||
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
|
||
const fieldsParam = isTravelTimeDrag ? '' : activeFeature;
|
||
|
||
if (usePostcodeView) {
|
||
const params = new URLSearchParams({ bounds: boundsStr });
|
||
if (filtersStr) params.set('filters', filtersStr);
|
||
params.set('fields', fieldsParam);
|
||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||
|
||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||
.then((res) => res.json())
|
||
.then((json: { features: PostcodeFeature[] }) => {
|
||
setDragPostcodeData(json.features);
|
||
setDragHexData(null);
|
||
dragFeatureRef.current = activeFeature;
|
||
})
|
||
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
|
||
} else {
|
||
const params = new URLSearchParams({
|
||
resolution: resolution.toString(),
|
||
bounds: boundsStr,
|
||
});
|
||
if (filtersStr) params.set('filters', filtersStr);
|
||
params.set('fields', fieldsParam);
|
||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||
|
||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||
.then((res) => res.json())
|
||
.then((json: ApiResponse) => {
|
||
setDragHexData(json.features);
|
||
setDragPostcodeData(null);
|
||
dragFeatureRef.current = activeFeature;
|
||
})
|
||
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
|
||
}
|
||
|
||
return () => {
|
||
if (dragAbortRef.current) {
|
||
dragAbortRef.current.abort();
|
||
dragAbortRef.current = null;
|
||
}
|
||
};
|
||
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
|
||
|
||
// Fetch hexagons or postcodes when bounds/filters change
|
||
useEffect(() => {
|
||
if (!bounds) return;
|
||
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
}
|
||
|
||
debounceRef.current = setTimeout(async () => {
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
}
|
||
abortControllerRef.current = new AbortController();
|
||
|
||
setLoading(true);
|
||
try {
|
||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||
const filtersStr = buildFilterParam();
|
||
|
||
if (usePostcodeView) {
|
||
const params = new URLSearchParams({ bounds: boundsStr });
|
||
if (filtersStr) params.set('filters', filtersStr);
|
||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
||
if (travelParam) {
|
||
params.set('travel', travelParam);
|
||
}
|
||
const res = await fetch(
|
||
apiUrl('postcodes', params),
|
||
authHeaders({
|
||
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);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
assertOk(res, 'postcodes');
|
||
setLicenseRequired(false);
|
||
const json: { features: PostcodeFeature[] } = await res.json();
|
||
setPostcodeData(json.features);
|
||
setRawData([]);
|
||
} else {
|
||
const params = new URLSearchParams({
|
||
resolution: resolution.toString(),
|
||
bounds: boundsStr,
|
||
});
|
||
if (filtersStr) params.set('filters', filtersStr);
|
||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
||
if (travelParam) {
|
||
params.set('travel', travelParam);
|
||
}
|
||
const res = await fetch(
|
||
apiUrl('hexagons', params),
|
||
authHeaders({
|
||
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);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
assertOk(res, 'hexagons');
|
||
setLicenseRequired(false);
|
||
const json: ApiResponse = await res.json();
|
||
setRawData(json.features);
|
||
setPostcodeData([]);
|
||
}
|
||
|
||
// Clear drag data when committed fetch completes and we're not mid-drag
|
||
if (!activeFeatureRef.current) {
|
||
setDragHexData(null);
|
||
setDragPostcodeData(null);
|
||
dragFeatureRef.current = null;
|
||
}
|
||
setLoading(false);
|
||
} catch (err) {
|
||
if (!isAbortError(err)) {
|
||
logNonAbortError('Failed to fetch data', err);
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, DEBOUNCE_MS);
|
||
|
||
return () => {
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
}
|
||
};
|
||
}, [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;
|
||
|
||
// Compute p5/p95 from committed data for the viewed feature.
|
||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||
// scale stays stable while dragging a filter slider.
|
||
const dataRange = useMemo((): [number, number] | null => {
|
||
if (!viewFeature) return null;
|
||
|
||
const isTravelTime = viewFeature.startsWith('tt_');
|
||
|
||
if (!isTravelTime) {
|
||
const meta = features.find((f) => f.name === viewFeature);
|
||
if (!meta || meta.type === 'enum') return null;
|
||
}
|
||
|
||
const vals: number[] = [];
|
||
|
||
if (usePostcodeView) {
|
||
if (postcodeData.length === 0) return null;
|
||
for (const feat of postcodeData) {
|
||
if (bounds) {
|
||
const [lng, lat] = feat.properties.centroid as [number, number];
|
||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||
continue;
|
||
}
|
||
const val = feat.properties[`avg_${viewFeature}`];
|
||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||
}
|
||
} else {
|
||
if (rawData.length === 0) return null;
|
||
for (const item of rawData) {
|
||
if (bounds) {
|
||
const { lat, lon } = item;
|
||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||
continue;
|
||
}
|
||
const val = item[`avg_${viewFeature}`];
|
||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||
}
|
||
}
|
||
|
||
if (vals.length === 0) return null;
|
||
vals.sort((a, b) => a - b);
|
||
return [
|
||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||
];
|
||
}, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
||
|
||
// 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) {
|
||
return [0, meta.values.length - 1];
|
||
}
|
||
if (dataRange) return dataRange;
|
||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||
return null;
|
||
}, [viewFeature, features, dataRange]);
|
||
|
||
const handleViewChange = useCallback(
|
||
({
|
||
resolution: newRes,
|
||
bounds: newBounds,
|
||
zoom: newZoom,
|
||
latitude,
|
||
longitude,
|
||
}: ViewChangeParams) => {
|
||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||
if (boundsKey !== prevBoundsRef.current) {
|
||
prevBoundsRef.current = boundsKey;
|
||
setResolution(newRes);
|
||
setBounds(newBounds);
|
||
}
|
||
setZoom(newZoom);
|
||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||
},
|
||
[]
|
||
);
|
||
|
||
const setInitialView = useCallback(
|
||
(view: { latitude: number; longitude: number; zoom: number }) => {
|
||
setCurrentView(view);
|
||
setZoom(view.zoom);
|
||
},
|
||
[]
|
||
);
|
||
|
||
return {
|
||
data,
|
||
postcodeData: effectivePostcodeData,
|
||
resolution,
|
||
bounds,
|
||
loading,
|
||
zoom,
|
||
currentView,
|
||
usePostcodeView,
|
||
colorRange,
|
||
handleViewChange,
|
||
setInitialView,
|
||
licenseRequired,
|
||
freeZone,
|
||
};
|
||
}
|