625 lines
21 KiB
TypeScript
625 lines
21 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 { getSchoolBackendFeatureName } from '../lib/school-filter';
|
||
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
|
||
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
|
||
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
|
||
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
|
||
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;
|
||
pinnedFeature: string | null;
|
||
travelTimeEntries: TravelTimeEntry[];
|
||
/** Share-link code from the URL; appended to data fetches so the backend
|
||
* grants bbox-scoped access for unlicensed recipients. */
|
||
shareCode?: string;
|
||
}
|
||
|
||
export function useMapData({
|
||
filters,
|
||
features,
|
||
viewFeature,
|
||
activeFeature,
|
||
pinnedFeature,
|
||
travelTimeEntries,
|
||
shareCode,
|
||
}: 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 latestDataRequestKeyRef = useRef<string>('');
|
||
const latestDragRequestKeyRef = useRef<string>('');
|
||
|
||
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 getBackendFeatureName = useCallback(
|
||
(name: string) =>
|
||
getSchoolBackendFeatureName(name) ??
|
||
getSpecificCrimeFeatureName(name) ??
|
||
getElectionVoteShareFeatureName(name) ??
|
||
getEthnicityFeatureName(name) ??
|
||
getPoiDistanceFeatureName(name) ??
|
||
name,
|
||
[]
|
||
);
|
||
const dataViewFeature = useMemo(
|
||
() => (viewFeature ? getBackendFeatureName(viewFeature) : null),
|
||
[getBackendFeatureName, viewFeature]
|
||
);
|
||
const pinnedDataViewFeature = useMemo(
|
||
() => (pinnedFeature ? getBackendFeatureName(pinnedFeature) : null),
|
||
[getBackendFeatureName, pinnedFeature]
|
||
);
|
||
|
||
// Determine if the current viewFeature is an enum (for enum_dist param)
|
||
const viewFeatureIsEnum = useMemo(
|
||
() =>
|
||
dataViewFeature ? features.find((f) => f.name === dataViewFeature)?.type === 'enum' : false,
|
||
[dataViewFeature, features]
|
||
);
|
||
|
||
const buildFilterParam = useCallback(
|
||
(): string => buildFilterString(filters, features),
|
||
[filters, features]
|
||
);
|
||
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
|
||
|
||
// 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 uses a wide range (0:1440) instead of
|
||
// the committed range. This still filters out rows with no travel data (the server
|
||
// skips rows where minutes=None when any range is set) while including all actual values.
|
||
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 (isExcluded) {
|
||
seg += ':0:1440';
|
||
} else if (entry.timeRange) {
|
||
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||
}
|
||
segments.push(seg);
|
||
}
|
||
return segments.join('|');
|
||
},
|
||
[travelTimeEntries]
|
||
);
|
||
|
||
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
|
||
const boundsParam = useMemo(
|
||
() => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
|
||
[bounds]
|
||
);
|
||
const dataRequestKey = useMemo(
|
||
() =>
|
||
bounds
|
||
? [
|
||
usePostcodeView ? 'postcodes' : 'hexagons',
|
||
resolution,
|
||
boundsParam,
|
||
filtersParam,
|
||
dataViewFeature ?? '',
|
||
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
|
||
travelParam,
|
||
shareCode ?? '',
|
||
].join('|')
|
||
: '',
|
||
[
|
||
bounds,
|
||
boundsParam,
|
||
dataViewFeature,
|
||
filtersParam,
|
||
resolution,
|
||
shareCode,
|
||
travelParam,
|
||
usePostcodeView,
|
||
viewFeatureIsEnum,
|
||
]
|
||
);
|
||
const [loadedDataKey, setLoadedDataKey] = useState<string>('');
|
||
|
||
// Keep activeFeatureRef in sync
|
||
useEffect(() => {
|
||
activeFeatureRef.current = activeFeature;
|
||
}, [activeFeature]);
|
||
|
||
useEffect(() => {
|
||
if (activeFeature) return;
|
||
latestDragRequestKeyRef.current = '';
|
||
dragFeatureRef.current = null;
|
||
setDragHexData(null);
|
||
setDragPostcodeData(null);
|
||
}, [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 dataActiveFeature = getBackendFeatureName(activeFeature);
|
||
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 ? '' : dataActiveFeature;
|
||
const requestKey = [
|
||
usePostcodeView ? 'postcodes' : 'hexagons',
|
||
activeFeature,
|
||
resolution,
|
||
boundsStr,
|
||
filtersStr,
|
||
fieldsParam,
|
||
dragTravelParam,
|
||
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
|
||
shareCode ?? '',
|
||
].join('|');
|
||
latestDragRequestKeyRef.current = requestKey;
|
||
|
||
if (usePostcodeView) {
|
||
const params = new URLSearchParams({ bounds: boundsStr });
|
||
if (filtersStr) params.set('filters', filtersStr);
|
||
params.set('fields', fieldsParam);
|
||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||
if (shareCode) params.set('share', shareCode);
|
||
|
||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||
.then((res) => res.json())
|
||
.then((json: { features: PostcodeFeature[] }) => {
|
||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||
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);
|
||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||
if (shareCode) params.set('share', shareCode);
|
||
|
||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||
.then((res) => res.json())
|
||
.then((json: ApiResponse) => {
|
||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||
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;
|
||
}
|
||
if (latestDragRequestKeyRef.current === requestKey) {
|
||
latestDragRequestKeyRef.current = '';
|
||
}
|
||
};
|
||
}, [
|
||
activeFeature,
|
||
bounds,
|
||
resolution,
|
||
filters,
|
||
features,
|
||
usePostcodeView,
|
||
travelParam,
|
||
buildTravelParam,
|
||
dataViewFeature,
|
||
getBackendFeatureName,
|
||
viewFeatureIsEnum,
|
||
shareCode,
|
||
]);
|
||
|
||
// Fetch hexagons or postcodes when bounds/filters change
|
||
useEffect(() => {
|
||
if (!bounds) {
|
||
latestDataRequestKeyRef.current = '';
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
latestDataRequestKeyRef.current = dataRequestKey;
|
||
setLoading(true);
|
||
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
}
|
||
|
||
debounceRef.current = setTimeout(async () => {
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
}
|
||
abortControllerRef.current = new AbortController();
|
||
|
||
const requestKey = dataRequestKey;
|
||
try {
|
||
if (usePostcodeView) {
|
||
const params = new URLSearchParams({ bounds: boundsParam });
|
||
if (filtersParam) params.set('filters', filtersParam);
|
||
params.set(
|
||
'fields',
|
||
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||
);
|
||
if (travelParam) {
|
||
params.set('travel', travelParam);
|
||
}
|
||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||
if (shareCode) params.set('share', shareCode);
|
||
const res = await fetch(
|
||
apiUrl('postcodes', params),
|
||
authHeaders({
|
||
signal: abortControllerRef.current.signal,
|
||
})
|
||
);
|
||
if (res.status === 403) {
|
||
const errBody = await res.json();
|
||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||
setLicenseRequired(true);
|
||
setFreeZone(errBody.free_zone);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
assertOk(res, 'postcodes');
|
||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||
setLicenseRequired(false);
|
||
const json: { features: PostcodeFeature[] } = await res.json();
|
||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||
setPostcodeData(json.features);
|
||
setRawData([]);
|
||
setLoadedDataKey(requestKey);
|
||
} else {
|
||
const params = new URLSearchParams({
|
||
resolution: resolution.toString(),
|
||
bounds: boundsParam,
|
||
});
|
||
if (filtersParam) params.set('filters', filtersParam);
|
||
params.set(
|
||
'fields',
|
||
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||
);
|
||
if (travelParam) {
|
||
params.set('travel', travelParam);
|
||
}
|
||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||
if (shareCode) params.set('share', shareCode);
|
||
const res = await fetch(
|
||
apiUrl('hexagons', params),
|
||
authHeaders({
|
||
signal: abortControllerRef.current.signal,
|
||
})
|
||
);
|
||
if (res.status === 403) {
|
||
const errBody = await res.json();
|
||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||
setLicenseRequired(true);
|
||
setFreeZone(errBody.free_zone);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
assertOk(res, 'hexagons');
|
||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||
setLicenseRequired(false);
|
||
const json: ApiResponse = await res.json();
|
||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||
setRawData(json.features);
|
||
setPostcodeData([]);
|
||
setLoadedDataKey(requestKey);
|
||
}
|
||
|
||
// 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 (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
|
||
logNonAbortError('Failed to fetch data', err);
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, DEBOUNCE_MS);
|
||
|
||
return () => {
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
}
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
abortControllerRef.current = null;
|
||
}
|
||
};
|
||
}, [
|
||
resolution,
|
||
bounds,
|
||
filters,
|
||
filtersParam,
|
||
boundsParam,
|
||
dataRequestKey,
|
||
dataViewFeature,
|
||
viewFeatureIsEnum,
|
||
usePostcodeView,
|
||
travelParam,
|
||
shareCode,
|
||
]);
|
||
|
||
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||
const data =
|
||
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
|
||
rawData;
|
||
const effectivePostcodeData =
|
||
(activeFeature && 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 (!dataViewFeature) return null;
|
||
|
||
const isTravelTime = dataViewFeature.startsWith('tt_');
|
||
|
||
if (!isTravelTime) {
|
||
const meta = features.find((f) => f.name === dataViewFeature);
|
||
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_${dataViewFeature}`];
|
||
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_${dataViewFeature}`];
|
||
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),
|
||
];
|
||
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
||
|
||
// Live color range for the legend and hex coloring.
|
||
const liveColorRange = useMemo((): [number, number] | null => {
|
||
if (!dataViewFeature) return null;
|
||
|
||
// Travel time keys: use dataRange directly (no FeatureMeta)
|
||
if (dataViewFeature.startsWith('tt_')) {
|
||
return dataRange;
|
||
}
|
||
|
||
const meta = features.find((f) => f.name === dataViewFeature);
|
||
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;
|
||
}, [dataViewFeature, features, dataRange]);
|
||
|
||
const isEyePreviewingPinnedFeature =
|
||
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
|
||
|
||
const [frozenPreviewRange, setFrozenPreviewRange] = useState<{
|
||
feature: string;
|
||
range: [number, number];
|
||
} | null>(null);
|
||
|
||
useEffect(() => {
|
||
setFrozenPreviewRange((prev) => {
|
||
if (!pinnedDataViewFeature) return prev ? null : prev;
|
||
return prev?.feature === pinnedDataViewFeature ? prev : null;
|
||
});
|
||
}, [pinnedDataViewFeature]);
|
||
|
||
useEffect(() => {
|
||
if (!isEyePreviewingPinnedFeature || !pinnedDataViewFeature) return;
|
||
|
||
const meta = pinnedDataViewFeature.startsWith('tt_')
|
||
? null
|
||
: features.find((f) => f.name === pinnedDataViewFeature);
|
||
const rangeToFreeze =
|
||
dataRange && loadedDataKey === dataRequestKey
|
||
? dataRange
|
||
: meta?.type === 'enum' && liveColorRange
|
||
? liveColorRange
|
||
: null;
|
||
if (!rangeToFreeze) return;
|
||
|
||
setFrozenPreviewRange((prev) =>
|
||
prev?.feature === pinnedDataViewFeature
|
||
? prev
|
||
: { feature: pinnedDataViewFeature, range: rangeToFreeze }
|
||
);
|
||
}, [
|
||
dataRange,
|
||
dataRequestKey,
|
||
features,
|
||
isEyePreviewingPinnedFeature,
|
||
loadedDataKey,
|
||
liveColorRange,
|
||
pinnedDataViewFeature,
|
||
]);
|
||
|
||
const colorRange = useMemo((): [number, number] | null => {
|
||
if (
|
||
isEyePreviewingPinnedFeature &&
|
||
frozenPreviewRange &&
|
||
frozenPreviewRange.feature === dataViewFeature
|
||
) {
|
||
return frozenPreviewRange.range;
|
||
}
|
||
return liveColorRange;
|
||
}, [dataViewFeature, frozenPreviewRange, isEyePreviewingPinnedFeature, liveColorRange]);
|
||
|
||
const canResetPreviewScale = useMemo(() => {
|
||
if (
|
||
!isEyePreviewingPinnedFeature ||
|
||
!pinnedDataViewFeature ||
|
||
!liveColorRange ||
|
||
loadedDataKey !== dataRequestKey
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
if (pinnedDataViewFeature.startsWith('tt_')) return true;
|
||
return features.find((f) => f.name === pinnedDataViewFeature)?.type !== 'enum';
|
||
}, [
|
||
dataRequestKey,
|
||
features,
|
||
isEyePreviewingPinnedFeature,
|
||
liveColorRange,
|
||
loadedDataKey,
|
||
pinnedDataViewFeature,
|
||
]);
|
||
|
||
const handleResetPreviewScale = useCallback(() => {
|
||
if (!canResetPreviewScale || !pinnedDataViewFeature || !liveColorRange) return;
|
||
setFrozenPreviewRange({ feature: pinnedDataViewFeature, range: liveColorRange });
|
||
}, [canResetPreviewScale, liveColorRange, pinnedDataViewFeature]);
|
||
|
||
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,
|
||
committedHexagonData: rawData,
|
||
postcodeData: effectivePostcodeData,
|
||
resolution,
|
||
bounds,
|
||
loading,
|
||
zoom,
|
||
currentView,
|
||
usePostcodeView,
|
||
colorRange,
|
||
canResetPreviewScale,
|
||
handleResetPreviewScale,
|
||
handleViewChange,
|
||
setInitialView,
|
||
licenseRequired,
|
||
freeZone,
|
||
};
|
||
}
|