Extract components
Some checks failed
CI / Check (push) Failing after 3m35s
Build and publish Docker image / build-and-push (push) Failing after 3m49s

This commit is contained in:
Andras Schmelczer 2026-05-09 10:21:32 +01:00
parent a48eb945e0
commit fe46cb3379
30 changed files with 4075 additions and 2610 deletions

View file

@ -0,0 +1,106 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const ETHNICITIES_FILTER_NAME = 'Ethnicities';
export const ETHNICITIES_FILTER_KEY_PREFIX = `${ETHNICITIES_FILTER_NAME}:`;
export const ETHNICITY_FEATURE_NAMES = [
'% White',
'% South Asian',
'% East Asian',
'% Black',
'% Mixed',
'% Other',
] as const;
const ETHNICITY_FEATURE_NAME_SET = new Set<string>(ETHNICITY_FEATURE_NAMES);
export function isEthnicityFeatureName(name: string): boolean {
return ETHNICITY_FEATURE_NAME_SET.has(name);
}
export function isEthnicityFilterName(name: string): boolean {
return isEthnicityFeatureName(name) || name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX);
}
export function createEthnicityFilterKey(featureName: string, id: number | string): string {
return `${ETHNICITIES_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getEthnicityFilterKeyId(name: string): string | null {
if (!name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(ETHNICITIES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseEthnicityFilterKey(name: string): string | null {
if (!name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(ETHNICITIES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isEthnicityFeatureName(decoded) ? decoded : null;
}
export function getEthnicityFeatureName(name: string): string | null {
if (isEthnicityFeatureName(name)) return name;
return parseEthnicityFilterKey(name);
}
export function replaceEthnicityFilterKeySelection(key: string, featureName: string): string {
const id = getEthnicityFilterKeyId(key) ?? '0';
return createEthnicityFilterKey(featureName, id);
}
export function getDefaultEthnicityFeatureName(features: FeatureMeta[]): string | null {
return (
ETHNICITY_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ??
null
);
}
export function normalizeEthnicityFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isEthnicityFeatureName(name)) {
next[createEthnicityFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getEthnicityFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultEthnicityFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: ETHNICITIES_FILTER_NAME,
type: 'numeric',
group: 'Demographics',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 0.1,
description: 'Population percentage by ethnic group',
detail: 'Filter by one Census 2021 ethnicity percentage at a time.',
source: 'ethnicity',
suffix: '%',
};
}
export function clampEthnicityRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}

View file

@ -0,0 +1,291 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const POI_DISTANCE_FILTER_NAME = 'POI distance';
export const POI_COUNT_2KM_FILTER_NAME = 'POIs within 2km';
export const POI_COUNT_5KM_FILTER_NAME = 'POIs within 5km';
export const POI_FILTER_NAMES = [
POI_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
] as const;
export type PoiFilterName = (typeof POI_FILTER_NAMES)[number];
type PoiMetric = 'distance' | 'count_2km' | 'count_5km';
export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
export const POI_DISTANCE_FEATURE_NAMES = [
'Distance to nearest park (km)',
'Distance to nearest grocery store (km)',
'Distance to nearest tube station (km)',
'Distance to nearest rail station (km)',
'Distance to nearest Waitrose (km)',
'Distance to nearest Tesco (km)',
'Distance to nearest cafe (km)',
'Distance to nearest pub (km)',
'Distance to nearest restaurant (km)',
] as const;
const LEGACY_POI_DISTANCE_FEATURE_NAME_SET = new Set<string>(POI_DISTANCE_FEATURE_NAMES);
const LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS = [
'Distance to nearest park (km)',
'Distance to nearest grocery store (km)',
] as const;
const DYNAMIC_DISTANCE_RE = /^Distance to nearest (.+) POI \(km\)$/;
const DYNAMIC_COUNT_RE = /^Number of (.+) POIs within (2|5)km$/;
const POI_FILTER_CONFIGS: Record<
PoiFilterName,
{
metric: PoiMetric;
keyPrefix: string;
description: string;
detail: string;
defaultMax: number;
step: number;
suffix: string;
}
> = {
[POI_DISTANCE_FILTER_NAME]: {
metric: 'distance',
keyPrefix: POI_DISTANCE_FILTER_KEY_PREFIX,
description: 'Distance to nearby points of interest',
detail: 'Filter by distance to one nearby point-of-interest type at a time.',
defaultMax: 5,
step: 0.1,
suffix: ' km',
},
[POI_COUNT_2KM_FILTER_NAME]: {
metric: 'count_2km',
keyPrefix: `${POI_COUNT_2KM_FILTER_NAME}:`,
description: 'Number of nearby points of interest within 2km',
detail: 'Filter by the count of one point-of-interest type within 2km.',
defaultMax: 20,
step: 1,
suffix: '',
},
[POI_COUNT_5KM_FILTER_NAME]: {
metric: 'count_5km',
keyPrefix: `${POI_COUNT_5KM_FILTER_NAME}:`,
description: 'Number of nearby points of interest within 5km',
detail: 'Filter by the count of one point-of-interest type within 5km.',
defaultMax: 50,
step: 1,
suffix: '',
},
};
function isPoiFilterNameValue(name: string): name is PoiFilterName {
return POI_FILTER_NAMES.includes(name as PoiFilterName);
}
function getConfig(filterName: PoiFilterName) {
return POI_FILTER_CONFIGS[filterName];
}
function isDynamicPoiDistanceFeatureName(name: string): boolean {
return DYNAMIC_DISTANCE_RE.test(name);
}
function getPoiMetric(name: string): PoiMetric | null {
if (isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name)) {
return 'distance';
}
const countMatch = name.match(DYNAMIC_COUNT_RE);
if (!countMatch) return null;
return countMatch[2] === '2' ? 'count_2km' : 'count_5km';
}
function getFilterNameForMetric(metric: PoiMetric): PoiFilterName {
if (metric === 'count_2km') return POI_COUNT_2KM_FILTER_NAME;
if (metric === 'count_5km') return POI_COUNT_5KM_FILTER_NAME;
return POI_DISTANCE_FILTER_NAME;
}
export function getPoiFeatureCategory(name: string): string | null {
const distanceMatch = name.match(DYNAMIC_DISTANCE_RE);
if (distanceMatch) return distanceMatch[1];
const countMatch = name.match(DYNAMIC_COUNT_RE);
if (countMatch) return countMatch[1];
return null;
}
export function isPoiDistanceFeatureName(name: string): boolean {
return isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name);
}
export function isPoiFilterFeatureName(name: string): boolean {
return getPoiMetric(name) != null;
}
export function getPoiFilterName(name: string): PoiFilterName | null {
for (const filterName of POI_FILTER_NAMES) {
if (name.startsWith(getConfig(filterName).keyPrefix)) return filterName;
}
const metric = getPoiMetric(name);
return metric ? getFilterNameForMetric(metric) : null;
}
export function isPoiDistanceFilterName(name: string): boolean {
return getPoiFilterName(name) != null;
}
export function createPoiFilterKey(
filterName: PoiFilterName,
featureName: string,
id: number | string
): string {
return `${getConfig(filterName).keyPrefix}${encodeURIComponent(featureName)}:${id}`;
}
export function createPoiDistanceFilterKey(featureName: string, id: number | string): string {
return createPoiFilterKey(POI_DISTANCE_FILTER_NAME, featureName, id);
}
export function getPoiFilterKeyId(name: string): string | null {
const filterName = getPoiFilterName(name);
if (!filterName) return null;
const prefix = getConfig(filterName).keyPrefix;
if (!name.startsWith(prefix)) return null;
const rest = name.substring(prefix.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function getPoiDistanceFilterKeyId(name: string): string | null {
return getPoiFilterKeyId(name);
}
export function parsePoiFilterKey(name: string): string | null {
const filterName = getPoiFilterName(name);
if (!filterName) return null;
const prefix = getConfig(filterName).keyPrefix;
if (!name.startsWith(prefix)) return null;
const rest = name.substring(prefix.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
const metric = getPoiMetric(decoded);
return metric === getConfig(filterName).metric ? decoded : null;
}
export function parsePoiDistanceFilterKey(name: string): string | null {
return parsePoiFilterKey(name);
}
export function getPoiDistanceFeatureName(name: string): string | null {
if (isPoiFilterFeatureName(name)) return name;
return parsePoiFilterKey(name);
}
export function replacePoiFilterKeySelection(key: string, featureName: string): string {
const filterName = getPoiFilterName(key) ?? getFilterNameForMetric(getPoiMetric(featureName)!);
const id = getPoiFilterKeyId(key) ?? '0';
return createPoiFilterKey(filterName, featureName, id);
}
export function replacePoiDistanceFilterKeySelection(key: string, featureName: string): string {
return replacePoiFilterKeySelection(key, featureName);
}
export function getPoiFilterFeatureOptions(
features: FeatureMeta[],
filterName: PoiFilterName
): FeatureMeta[] {
const metric = getConfig(filterName).metric;
const dynamicOptions = features.filter((feature) => {
const featureMetric = getPoiMetric(feature.name);
if (featureMetric !== metric) return false;
return metric !== 'distance' || isDynamicPoiDistanceFeatureName(feature.name);
});
if (dynamicOptions.length > 0 && metric === 'distance') {
const aggregateOptions = LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
return [...dynamicOptions, ...aggregateOptions];
}
if (dynamicOptions.length > 0 || metric !== 'distance') {
return dynamicOptions;
}
return POI_DISTANCE_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
}
export function getDefaultPoiFilterFeatureName(
features: FeatureMeta[],
filterName: PoiFilterName
): string | null {
return getPoiFilterFeatureOptions(features, filterName)[0]?.name ?? null;
}
export function getDefaultPoiDistanceFeatureName(features: FeatureMeta[]): string | null {
return getDefaultPoiFilterFeatureName(features, POI_DISTANCE_FILTER_NAME);
}
export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterName): FeatureMeta {
const sourceFeatureName = getDefaultPoiFilterFeatureName(features, filterName);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
const config = getConfig(filterName);
return {
name: filterName,
type: 'numeric',
group: 'Nearby POIs',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? config.defaultMax,
step: config.step,
description: config.description,
detail: config.detail,
source: sourceFeature?.source ?? 'osm-pois',
suffix: config.suffix,
};
}
export function getPoiDistanceFilterMeta(features: FeatureMeta[]): FeatureMeta {
return getPoiFilterMeta(features, POI_DISTANCE_FILTER_NAME);
}
export function normalizePoiDistanceFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isPoiFilterFeatureName(name)) {
const filterName = getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME;
next[createPoiFilterKey(filterName, name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function clampPoiFilterRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}
export function clampPoiDistanceRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
return clampPoiFilterRange(value, feature);
}