790 lines
28 KiB
TypeScript
790 lines
28 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 SchoolPhase,
|
|
type SchoolRating,
|
|
} from './school-filter';
|
|
import {
|
|
SPECIFIC_CRIMES_FILTER_NAME,
|
|
createSpecificCrimeFilterKey,
|
|
getSpecificCrimeFeatureName,
|
|
isSpecificCrimeFeatureName,
|
|
isSpecificCrimeFilterName,
|
|
} from './crime-filter';
|
|
import {
|
|
createCrimeSeverityFilterKey,
|
|
getCrimeSeverityFeatureName,
|
|
getCrimeSeverityFilterName,
|
|
isCrimeSeverityFeatureName,
|
|
isCrimeSeverityFilterName,
|
|
} from './crime-severity-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 {
|
|
QUALIFICATIONS_FILTER_NAME,
|
|
createQualificationFilterKey,
|
|
getQualificationFeatureName,
|
|
isQualificationFeatureName,
|
|
isQualificationFilterName,
|
|
} from './qualification-filter';
|
|
import {
|
|
TENURE_FILTER_NAME,
|
|
createTenureFilterKey,
|
|
getTenureFeatureName,
|
|
isTenureFeatureName,
|
|
isTenureFilterName,
|
|
} from './tenure-filter';
|
|
import {
|
|
COUNCIL_FILTER_NAME,
|
|
createCouncilFilterKey,
|
|
getCouncilFeatureName,
|
|
getCouncilWindow,
|
|
isCouncilFeatureName,
|
|
isCouncilFilterName,
|
|
} from './council-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, DEFAULT_OVERLAY_IDS, type OverlayId } from './overlays';
|
|
import { CRIME_TYPES, isCrimeTypeValue } from './crime-types';
|
|
import { isBasemapId, type BasemapId } from './basemaps';
|
|
import {
|
|
DEFAULT_COLOR_OPACITY,
|
|
colorOpacityToPercent,
|
|
normalizeColorOpacity,
|
|
} from './color-opacity';
|
|
import { DEFAULT_LISTINGS_MODE, LISTINGS_MODES, type ListingsMode } from './listings';
|
|
|
|
const POI_NONE_PARAM = '__none';
|
|
const CRIME_TYPES_NONE_PARAM = '__none';
|
|
const OVERLAY_NONE_PARAM = '__none';
|
|
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
|
|
|
/** URL param holding the currently focused postcode (from a search or a map
|
|
* click). It reflects a transient selection, not search criteria, so a live or
|
|
* shared link keeps it but a saved search must not bake it in. */
|
|
export const SELECTED_POSTCODE_PARAM = 'pc';
|
|
|
|
/**
|
|
* Drop the transient selected-postcode param from a serialized query string so a
|
|
* saved search captures the filter criteria, not whichever postcode the user
|
|
* last clicked. Takes and returns a query string without the leading '?'.
|
|
*/
|
|
export function stripSelectedPostcodeParam(params: string): string {
|
|
const parsed = new URLSearchParams(params);
|
|
if (!parsed.has(SELECTED_POSTCODE_PARAM)) return params;
|
|
parsed.delete(SELECTED_POSTCODE_PARAM);
|
|
return parsed.toString();
|
|
}
|
|
|
|
export interface UrlState {
|
|
viewState: ViewState;
|
|
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
|
|
* False on a fresh visit, so the app may centre on the visitor's IP instead. */
|
|
hasExplicitView: boolean;
|
|
filters: FeatureFilters;
|
|
poiCategories: Set<string>;
|
|
overlays: Set<OverlayId>;
|
|
crimeTypes?: Set<string>;
|
|
basemap: BasemapId;
|
|
colorOpacity: number;
|
|
/** Which slice of the live for-sale listings to render (new-build only, etc.). */
|
|
listings: ListingsMode;
|
|
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 crimeSeverityParams = params.getAll('crimeSeverity');
|
|
const voteShareParams = params.getAll('voteShare');
|
|
const ethnicityParams = params.getAll('ethnicity');
|
|
const qualificationParams = params.getAll('qualification');
|
|
const tenureParams = params.getAll('tenure');
|
|
const councilParams = params.getAll('council');
|
|
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 &&
|
|
crimeSeverityParams.length === 0 &&
|
|
voteShareParams.length === 0 &&
|
|
ethnicityParams.length === 0 &&
|
|
qualificationParams.length === 0 &&
|
|
tenureParams.length === 0 &&
|
|
councilParams.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(':');
|
|
// 4 parts is the current phase:rating:min:max form; 5 parts is the legacy
|
|
// phase:rating:distance:min:max form, whose distance segment is ignored.
|
|
if (parts.length !== 4 && parts.length !== 5) return;
|
|
const phase = parts[0] as SchoolPhase;
|
|
const rating = parts[1] as SchoolRating;
|
|
const min = Number(parts[parts.length - 2]);
|
|
const max = Number(parts[parts.length - 1]);
|
|
if (
|
|
(phase !== 'primary' && phase !== 'secondary') ||
|
|
(rating !== 'good' && rating !== 'outstanding') ||
|
|
isNaN(min) ||
|
|
isNaN(max)
|
|
) {
|
|
return;
|
|
}
|
|
filters[createSchoolFilterKey(phase, rating, 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];
|
|
});
|
|
|
|
crimeSeverityParams.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]);
|
|
const filterName = getCrimeSeverityFilterName(featureName);
|
|
if (!isCrimeSeverityFeatureName(featureName) || !filterName || isNaN(min) || isNaN(max)) {
|
|
return;
|
|
}
|
|
filters[createCrimeSeverityFilterKey(filterName, 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];
|
|
});
|
|
|
|
qualificationParams.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 (!isQualificationFeatureName(featureName) || isNaN(min) || isNaN(max)) {
|
|
return;
|
|
}
|
|
filters[createQualificationFilterKey(featureName, index)] = [min, max];
|
|
});
|
|
|
|
tenureParams.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 (!isTenureFeatureName(featureName) || isNaN(min) || isNaN(max)) {
|
|
return;
|
|
}
|
|
filters[createTenureFilterKey(featureName, index)] = [min, max];
|
|
});
|
|
|
|
councilParams.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]);
|
|
// getCouncilWindow recognises all three pill columns, incl. the shared
|
|
// "% Social rent" (which isCouncilFeatureName excludes from bare folding).
|
|
if (getCouncilWindow(featureName) == null || isNaN(min) || isNaN(max)) {
|
|
return;
|
|
}
|
|
filters[createCouncilFilterKey(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,
|
|
hasExplicitView: false,
|
|
filters: parseFilters(params),
|
|
poiCategories: new Set(),
|
|
overlays: new Set(DEFAULT_OVERLAY_IDS),
|
|
basemap: 'standard',
|
|
colorOpacity: DEFAULT_COLOR_OPACITY,
|
|
listings: DEFAULT_LISTINGS_MODE,
|
|
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 };
|
|
result.hasExplicitView = true;
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Overlays default to DEFAULT_OVERLAY_IDS on a fresh visit; explicit `overlay`
|
|
// params override that, and the `__none` sentinel records "all off" so it
|
|
// survives a reload instead of silently restoring the defaults.
|
|
const overlayParams = params.getAll('overlay');
|
|
if (overlayParams.length > 0) {
|
|
result.overlays = overlayParams.includes(OVERLAY_NONE_PARAM)
|
|
? new Set()
|
|
: new Set(overlayParams.filter(isOverlayId));
|
|
}
|
|
|
|
// Crime-type filter: repeated `crimeType` params, or the `__none` sentinel.
|
|
// Absent means "all types" (handled downstream), so leave it undefined.
|
|
const crimeTypeParams = params.getAll('crimeType');
|
|
if (crimeTypeParams.length > 0) {
|
|
result.crimeTypes = crimeTypeParams.includes(CRIME_TYPES_NONE_PARAM)
|
|
? new Set()
|
|
: new Set(crimeTypeParams.filter(isCrimeTypeValue));
|
|
}
|
|
|
|
const basemap = params.get('basemap');
|
|
if (basemap && isBasemapId(basemap)) {
|
|
result.basemap = basemap;
|
|
}
|
|
|
|
const colorOpacity = params.get('colorOpacity');
|
|
if (colorOpacity != null) {
|
|
const colorOpacityPercent = Number(colorOpacity);
|
|
if (!isNaN(colorOpacityPercent)) {
|
|
result.colorOpacity = normalizeColorOpacity(colorOpacityPercent / 100);
|
|
}
|
|
}
|
|
|
|
// Listings mode: which slice of the for-sale listings is shown. Absent means
|
|
// the default ('all'); an explicit value survives a reload.
|
|
const listings = params.get('listings');
|
|
if (listings && (LISTINGS_MODES as readonly string[]).includes(listings)) {
|
|
result.listings = listings as ListingsMode;
|
|
}
|
|
|
|
// Tab: full name
|
|
const tab = params.get('tab');
|
|
if (tab === 'properties' || tab === 'area') {
|
|
result.tab = tab;
|
|
}
|
|
|
|
// Selected postcode. This is also accepted as the historical one-time
|
|
// navigate-to-postcode param used by saved-property links.
|
|
const pc = params.get(SELECTED_POSTCODE_PARAM);
|
|
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
|
|
// | transit-one-change-no-bus. Unknown modes cause the entry to be dropped
|
|
// here (parseServerMode returns null) so we don't silently broaden the 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,
|
|
oneChange: parsedMode.oneChange,
|
|
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,
|
|
selectedCrimeTypes?: Set<string>,
|
|
selectedPostcode?: string,
|
|
colorOpacity?: number,
|
|
listingsMode?: ListingsMode
|
|
): 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}:${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 crimeSeverityFeatureName = getCrimeSeverityFeatureName(name);
|
|
if (crimeSeverityFeatureName && isCrimeSeverityFilterName(name)) {
|
|
const [min, max] = value as [number, number];
|
|
params.append('crimeSeverity', `${crimeSeverityFeatureName}:${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 qualificationFeatureName = getQualificationFeatureName(name);
|
|
if (qualificationFeatureName && isQualificationFilterName(name)) {
|
|
const [min, max] = value as [number, number];
|
|
params.append('qualification', `${qualificationFeatureName}:${min}:${max}`);
|
|
continue;
|
|
}
|
|
|
|
const tenureFeatureName = getTenureFeatureName(name);
|
|
if (tenureFeatureName && isTenureFilterName(name)) {
|
|
const [min, max] = value as [number, number];
|
|
params.append('tenure', `${tenureFeatureName}:${min}:${max}`);
|
|
continue;
|
|
}
|
|
|
|
// After tenure: a "Council housing:" key never matches isTenureFilterName,
|
|
// and a bare "% Social rent" is handled by the tenure branch above.
|
|
const councilFeatureName = getCouncilFeatureName(name);
|
|
if (councilFeatureName && isCouncilFilterName(name)) {
|
|
const [min, max] = value as [number, number];
|
|
params.append('council', `${councilFeatureName}:${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 (selectedPostcode) {
|
|
params.set('pc', selectedPostcode);
|
|
}
|
|
|
|
if (selectedOverlays) {
|
|
if (selectedOverlays.size === 0) {
|
|
// Distinguish a deliberate "all overlays off" from a fresh visit, which
|
|
// would otherwise re-apply the defaults on reload.
|
|
params.append('overlay', OVERLAY_NONE_PARAM);
|
|
} else {
|
|
for (const overlay of selectedOverlays) {
|
|
params.append('overlay', overlay);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Crime-type selection only matters while the crime overlay is on. Emit it
|
|
// only when it deviates from the default (all types selected) to keep URLs
|
|
// clean; a fully-deselected state uses an explicit sentinel.
|
|
if (selectedOverlays?.has(CRIME_OVERLAY_ID) && selectedCrimeTypes) {
|
|
if (selectedCrimeTypes.size === 0) {
|
|
params.append('crimeType', CRIME_TYPES_NONE_PARAM);
|
|
} else if (selectedCrimeTypes.size < CRIME_TYPES.length) {
|
|
for (const crimeType of selectedCrimeTypes) {
|
|
params.append('crimeType', crimeType);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (basemap && basemap !== 'standard') {
|
|
params.set('basemap', basemap);
|
|
}
|
|
|
|
if (colorOpacity != null) {
|
|
const normalizedColorOpacity = normalizeColorOpacity(colorOpacity);
|
|
if (normalizedColorOpacity !== DEFAULT_COLOR_OPACITY) {
|
|
params.set('colorOpacity', String(colorOpacityToPercent(normalizedColorOpacity)));
|
|
}
|
|
}
|
|
|
|
// Emit the listings mode only when it deviates from the default, matching the
|
|
// "only serialize non-default" convention above (keeps default URLs clean).
|
|
if (listingsMode && listingsMode !== DEFAULT_LISTINGS_MODE) {
|
|
params.set('listings', listingsMode);
|
|
}
|
|
|
|
// 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 crimeSeverityParams = params.getAll('crimeSeverity');
|
|
const voteShareParams = params.getAll('voteShare');
|
|
const ethnicityParams = params.getAll('ethnicity');
|
|
const qualificationParams = params.getAll('qualification');
|
|
const tenureParams = params.getAll('tenure');
|
|
const councilParams = params.getAll('council');
|
|
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 ||
|
|
crimeSeverityParams.length > 0 ||
|
|
voteShareParams.length > 0 ||
|
|
ethnicityParams.length > 0 ||
|
|
qualificationParams.length > 0 ||
|
|
tenureParams.length > 0 ||
|
|
councilParams.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 (isCrimeSeverityFeatureName(name)) return getCrimeSeverityFilterName(name) ?? name;
|
|
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
|
|
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
|
|
if (isQualificationFeatureName(name)) return QUALIFICATIONS_FILTER_NAME;
|
|
if (isTenureFeatureName(name)) return TENURE_FILTER_NAME;
|
|
if (isCouncilFeatureName(name)) return COUNCIL_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 (const entry of crimeSeverityParams) {
|
|
const colonIdx = entry.indexOf(':');
|
|
const featureName = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
|
const severityFilterName = getCrimeSeverityFilterName(featureName);
|
|
if (severityFilterName) filterNames.push(severityFilterName);
|
|
}
|
|
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 < qualificationParams.length; i++) {
|
|
filterNames.push(QUALIFICATIONS_FILTER_NAME);
|
|
}
|
|
for (let i = 0; i < tenureParams.length; i++) {
|
|
filterNames.push(TENURE_FILTER_NAME);
|
|
}
|
|
for (let i = 0; i < councilParams.length; i++) {
|
|
filterNames.push(COUNCIL_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');
|
|
}
|