Extract components
This commit is contained in:
parent
a48eb945e0
commit
fe46cb3379
30 changed files with 4075 additions and 2610 deletions
106
frontend/src/lib/ethnicity-filter.ts
Normal file
106
frontend/src/lib/ethnicity-filter.ts
Normal 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))];
|
||||
}
|
||||
291
frontend/src/lib/poi-distance-filter.ts
Normal file
291
frontend/src/lib/poi-distance-filter.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue