495 lines
16 KiB
TypeScript
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');
|
|
}
|