perfect-postcode/frontend/src/lib/url-state.ts
2026-05-28 21:48:35 +01:00

540 lines
18 KiB
TypeScript

import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
import {
MAX_TRAVEL_MINUTES,
parseServerMode,
resolveTransitVariant,
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
import { INITIAL_VIEW_STATE } from './consts';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
import {
SPECIFIC_CRIMES_FILTER_NAME,
createSpecificCrimeFilterKey,
getSpecificCrimeFeatureName,
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from './crime-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
createElectionVoteShareFilterKey,
getElectionVoteShareFeatureName,
isElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from './election-filter';
import {
ETHNICITIES_FILTER_NAME,
createEthnicityFilterKey,
getEthnicityFeatureName,
isEthnicityFeatureName,
isEthnicityFilterName,
} from './ethnicity-filter';
import {
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
createPoiFilterKey,
getPoiDistanceFeatureName,
getPoiFilterName,
isPoiDistanceFilterName,
type PoiFilterName,
} from './poi-distance-filter';
import { dedupeTravelTimeEntries } from './travel-params';
import { isOverlayId, type OverlayId } from './overlays';
import { isBasemapId, type BasemapId } from './basemaps';
const POI_NONE_PARAM = '__none';
export interface UrlState {
viewState: ViewState;
filters: FeatureFilters;
poiCategories: Set<string>;
overlays: Set<OverlayId>;
basemap: BasemapId;
tab: 'properties' | 'area';
travelTime?: TravelTimeInitial;
postcode?: string;
share?: string;
}
function parseFilters(params: URLSearchParams): FeatureFilters {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
filterParams.length === 0 &&
schoolParams.length === 0 &&
crimeParams.length === 0 &&
voteShareParams.length === 0 &&
ethnicityParams.length === 0 &&
amenityDistanceParams.length === 0 &&
transportDistanceParams.length === 0 &&
amenityCount2KmParams.length === 0 &&
amenityCount5KmParams.length === 0
) {
return {};
}
const filters: FeatureFilters = {};
for (const entry of filterParams) {
const colonIdx = entry.indexOf(':');
if (colonIdx === -1) continue;
const name = entry.substring(0, colonIdx);
const rest = entry.substring(colonIdx + 1);
if (rest.includes(':')) {
const [minStr, maxStr] = rest.split(':');
const min = Number(minStr);
const max = Number(maxStr);
if (!isNaN(min) && !isNaN(max)) {
filters[name] = [min, max];
}
} else if (rest.includes('|')) {
filters[name] = rest.split('|');
} else {
filters[name] = [rest];
}
}
schoolParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length !== 5) return;
const phase = parts[0] as SchoolPhase;
const rating = parts[1] as SchoolRating;
const distance = Number(parts[2]) as SchoolDistance;
const min = Number(parts[3]);
const max = Number(parts[4]);
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
(distance !== 2 && distance !== 5) ||
isNaN(min) ||
isNaN(max)
) {
return;
}
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
});
crimeParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isSpecificCrimeFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
});
voteShareParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isElectionVoteShareFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createElectionVoteShareFilterKey(featureName, index)] = [min, max];
});
ethnicityParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isEthnicityFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
});
const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
entries.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = decodeURIComponent(parts.slice(0, -2).join(':'));
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
const targetFilterName = getPoiFilterName(featureName);
if (!targetFilterName || targetFilterName !== filterName || isNaN(min) || isNaN(max)) {
return;
}
filters[createPoiFilterKey(targetFilterName, featureName, startIndex + index)] = [min, max];
});
};
const parsePoiCountParams = (
entries: string[],
filterName: PoiFilterName,
startIndex: number
) => {
parsePoiParams(entries, filterName, startIndex);
};
parsePoiParams(amenityDistanceParams, POI_DISTANCE_FILTER_NAME, 0);
parsePoiParams(
transportDistanceParams,
TRANSPORT_DISTANCE_FILTER_NAME,
amenityDistanceParams.length
);
parsePoiCountParams(
amenityCount2KmParams,
POI_COUNT_2KM_FILTER_NAME,
amenityDistanceParams.length + transportDistanceParams.length
);
parsePoiCountParams(
amenityCount5KmParams,
POI_COUNT_5KM_FILTER_NAME,
amenityDistanceParams.length + transportDistanceParams.length + amenityCount2KmParams.length
);
return filters;
}
export function parseUrlState(): UrlState {
const params = new URLSearchParams(window.location.search);
const result: UrlState = {
viewState: INITIAL_VIEW_STATE,
filters: parseFilters(params),
poiCategories: new Set(),
overlays: new Set(),
basemap: 'standard',
tab: 'area',
};
// Share-link code: may grant bbox-scoped access when the backend record
// contains an explicit server-created grant.
const share = params.get('share');
if (share && /^[a-z0-9]{1,20}$/i.test(share)) {
result.share = share;
}
// View state: separate lat/lon/zoom params
const lat = params.get('lat');
const lon = params.get('lon');
const zoom = params.get('zoom');
if (lat && lon && zoom) {
const latN = Number(lat);
const lonN = Number(lon);
const zoomN = Number(zoom);
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
}
}
// POI categories: repeated `poi` params
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
if (poiParams.includes(POI_NONE_PARAM)) {
result.poiCategories = new Set();
} else {
result.poiCategories = new Set(
poiParams.filter((value) => value && value !== POI_NONE_PARAM)
);
}
}
const overlayParams = params.getAll('overlay');
if (overlayParams.length > 0) {
result.overlays = new Set(overlayParams.filter(isOverlayId));
}
const basemap = params.get('basemap');
if (basemap && isBasemapId(basemap)) {
result.basemap = basemap;
}
// Tab: full name
const tab = params.get('tab');
if (tab === 'properties' || tab === 'area') {
result.tab = tab;
}
// Navigate-to-postcode: one-time param for opening a saved property
const pc = params.get('pc');
if (pc) {
result.postcode = pc;
}
// Travel time: repeated `tt` params
// Format: serverMode:slug:label[:b][:min:max]
// serverMode is one of: car | bicycle | walking | transit | transit-no-bus
// | transit-no-change | transit-no-change-no-bus. transit-one-change[-no-bus]
// variants are server-side only and will cause the entry to be dropped here
// (parseServerMode returns null) so we don't silently broaden the user's filter.
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const entries: TravelTimeEntry[] = [];
for (const tt of ttParams) {
const parts = tt.split(':');
if (parts.length < 3) continue;
const parsedMode = parseServerMode(parts[0]);
if (!parsedMode) continue;
const slug = parts[1];
const label = decodeURIComponent(parts[2]);
const useBest = parts.length >= 4 && parts[3] === 'b';
const rangeOffset = useBest ? 1 : 0;
let timeRange: [number, number] | null = null;
if (parts.length >= 5 + rangeOffset) {
const min = Number(parts[3 + rangeOffset]);
const max = Number(parts[4 + rangeOffset]);
if (!isNaN(min) && !isNaN(max)) {
// Clamp loaded max-time to the data ceiling. Older shared URLs
// may have max=120 from the previous slider range; no data exists
// above MAX_TRAVEL_MINUTES so the result is identical.
timeRange = [min, Math.min(max, MAX_TRAVEL_MINUTES)];
}
}
entries.push({
mode: parsedMode.mode,
slug,
label,
timeRange,
useBest,
noChange: parsedMode.noChange,
noBuses: parsedMode.noBuses,
});
}
if (entries.length > 0) {
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
}
}
return result;
}
export function stateToParams(
viewState: { latitude: number; longitude: number; zoom: number } | null,
filters: FeatureFilters,
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntry[],
share?: string,
selectedOverlays?: Set<OverlayId>,
basemap?: BasemapId
): URLSearchParams {
const params = new URLSearchParams();
if (share) {
params.set('share', share);
}
if (viewState) {
params.set('lat', viewState.latitude.toFixed(4));
params.set('lon', viewState.longitude.toFixed(4));
params.set('zoom', viewState.zoom.toFixed(1));
}
for (const [name, value] of Object.entries(filters)) {
const schoolConfig = getSchoolFilterConfig(name);
if (schoolConfig && isSchoolFilterName(name)) {
const [min, max] = value as [number, number];
params.append(
'school',
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
);
continue;
}
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
if (specificCrimeFeatureName && isSpecificCrimeFilterName(name)) {
const [min, max] = value as [number, number];
params.append('crime', `${specificCrimeFeatureName}:${min}:${max}`);
continue;
}
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
if (electionVoteShareFeatureName && isElectionVoteShareFilterName(name)) {
const [min, max] = value as [number, number];
params.append('voteShare', `${electionVoteShareFeatureName}:${min}:${max}`);
continue;
}
const ethnicityFeatureName = getEthnicityFeatureName(name);
if (ethnicityFeatureName && isEthnicityFilterName(name)) {
const [min, max] = value as [number, number];
params.append('ethnicity', `${ethnicityFeatureName}:${min}:${max}`);
continue;
}
const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
const [min, max] = value as [number, number];
const filterName = getPoiFilterName(name);
const paramName =
filterName === POI_COUNT_2KM_FILTER_NAME
? 'amenityCount2km'
: filterName === POI_COUNT_5KM_FILTER_NAME
? 'amenityCount5km'
: filterName === TRANSPORT_DISTANCE_FILTER_NAME
? 'transportDistance'
: 'amenityDistance';
params.append(paramName, `${encodeURIComponent(amenityDistanceFeatureName)}:${min}:${max}`);
continue;
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum' || typeof value[0] === 'string') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
} else {
const [min, max] = value as [number, number];
params.append('filter', `${name}:${min}:${max}`);
}
}
if (selectedPOICategories.size === 0) {
params.append('poi', POI_NONE_PARAM);
} else {
for (const category of selectedPOICategories) {
params.append('poi', category);
}
}
if (rightPaneTab === 'properties') {
params.set('tab', 'properties');
}
if (selectedOverlays) {
for (const overlay of selectedOverlays) {
params.append('overlay', overlay);
}
}
if (basemap && basemap !== 'standard') {
params.set('basemap', basemap);
}
// Travel time: repeated `tt` params
if (travelTimeEntries) {
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
if (!entry.slug) continue;
const serverMode = resolveTransitVariant(entry);
let val = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) val += ':b';
if (entry.timeRange) {
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
params.append('tt', val);
}
}
return params;
}
export function summarizeParams(queryString: string): string {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => string;
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
const params = new URLSearchParams(queryString);
const parts: string[] = [];
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
filterParams.length > 0 ||
schoolParams.length > 0 ||
crimeParams.length > 0 ||
voteShareParams.length > 0 ||
ethnicityParams.length > 0 ||
amenityDistanceParams.length > 0 ||
transportDistanceParams.length > 0 ||
amenityCount2KmParams.length > 0 ||
amenityCount5KmParams.length > 0
) {
const filterNames = filterParams
.map((entry) => {
const colonIdx = entry.indexOf(':');
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
const poiFilterName = getPoiFilterName(name);
if (poiFilterName) return poiFilterName;
return name;
})
.filter((n) => n);
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
for (let i = 0; i < crimeParams.length; i++) {
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
}
for (let i = 0; i < voteShareParams.length; i++) {
filterNames.push(ELECTION_VOTE_SHARE_FILTER_NAME);
}
for (let i = 0; i < ethnicityParams.length; i++) {
filterNames.push(ETHNICITIES_FILTER_NAME);
}
for (let i = 0; i < amenityDistanceParams.length; i++) {
filterNames.push(POI_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < transportDistanceParams.length; i++) {
filterNames.push(TRANSPORT_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < amenityCount2KmParams.length; i++) {
filterNames.push(POI_COUNT_2KM_FILTER_NAME);
}
for (let i = 0; i < amenityCount5KmParams.length; i++) {
filterNames.push(POI_COUNT_5KM_FILTER_NAME);
}
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2
? filterNames.map((n) => ts(n)).join(', ')
: i18n.t('format.nFilters', { count: filterNames.length })
);
}
}
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
const count = poiParams.filter((value) => value && value !== POI_NONE_PARAM).length;
if (count > 0) {
parts.push(
count === 1
? i18n.t('format.poiCategory', { count })
: i18n.t('format.poiCategories', { count })
);
}
}
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const count = ttParams.filter(Boolean).length;
if (count > 0) {
parts.push(
count === 1
? i18n.t('format.travelDestination', { count })
: i18n.t('format.travelDestinations', { count })
);
}
}
return parts.length > 0 ? parts.join(' + ') : i18n.t('format.noFilters');
}