import type { FeatureMeta, FeatureFilters, ViewState } from '../types'; import { TRANSPORT_MODES, type TransportMode, type TravelTimeEntry, type TravelTimeInitial, } from '../hooks/useTravelTime'; 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'; function parseFilters(params: URLSearchParams): FeatureFilters | undefined { const filterParams = params.getAll('filter'); const schoolParams = params.getAll('school'); const crimeParams = params.getAll('crime'); if (filterParams.length === 0 && schoolParams.length === 0 && crimeParams.length === 0) { return undefined; } 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]; }); return Object.keys(filters).length > 0 ? filters : undefined; } export function parseUrlState(): { viewState?: ViewState; filters?: FeatureFilters; poiCategories?: Set; tab?: 'properties' | 'area'; travelTime?: TravelTimeInitial; postcode?: string; share?: string; } { const params = new URLSearchParams(window.location.search); const result: ReturnType = {}; // Share-link code: grants bbox-scoped access to the area the link references // even for unlicensed users. The backend looks the code up against PocketBase. 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 }; } } // Filters: repeated `filter` params result.filters = parseFilters(params); // POI categories: repeated `poi` params const poiParams = params.getAll('poi'); if (poiParams.length > 0) { result.poiCategories = new Set(poiParams.filter(Boolean)); } // 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 }; } } return result; } export function stateToParams( viewState: { latitude: number; longitude: number; zoom: number } | null, filters: FeatureFilters, features: FeatureMeta[], selectedPOICategories: Set, 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 meta = features.find((f) => f.name === name); if (meta?.type === 'enum') { params.append('filter', `${name}:${(value as string[]).join('|')}`); } else { const [min, max] = value as [number, number]; params.append('filter', `${name}:${min}:${max}`); } } 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 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; }; // 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'); if (filterParams.length > 0 || schoolParams.length > 0 || crimeParams.length > 0) { const filterNames = filterParams .map((entry) => { const colonIdx = entry.indexOf(':'); const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry; return isSpecificCrimeFeatureName(name) ? SPECIFIC_CRIMES_FILTER_NAME : 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); } 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(Boolean).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'); }