perfect-postcode/frontend/src/lib/url-state.ts
Andras Schmelczer 3fa95819e3
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 2m43s
CI / Check (push) Failing after 3m7s
lgtm 2
2026-05-14 22:39:41 +01:00

495 lines
16 KiB
TypeScript

import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
import {
TRANSPORT_MODES,
type TransportMode,
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';
const POI_NONE_PARAM = '__none';
export interface UrlState {
viewState: ViewState;
filters: FeatureFilters;
poiCategories: Set<string>;
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(),
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)
);
}
}
// 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: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
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 mode = parts[0] as TransportMode;
if (!TRANSPORT_MODES.includes(mode)) 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)) {
timeRange = [min, max];
}
}
entries.push({ mode, slug, label, timeRange, useBest });
}
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
): 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');
}
// Travel time: repeated `tt` params
if (travelTimeEntries) {
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
if (!entry.slug) continue;
let val = `${entry.mode}:${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');
}