perfect-postcode/frontend/src/lib/consts.ts
Andras Schmelczer fd2860070a
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 18m12s
CI / Check (push) Failing after 23m38s
lgtm
2026-06-22 22:12:27 +01:00

515 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ViewState } from '../types';
export const INITIAL_RETRY_MS = 1000;
export const MAX_RETRY_MS = 10000;
/** Lower percentile for color-range clipping (0100) */
export const COLOR_RANGE_LOW_PERCENTILE = 5;
/** Upper percentile for color-range clipping (0100) */
export const COLOR_RANGE_HIGH_PERCENTILE = 95;
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
export const MAP_MIN_ZOOM = 5.5;
export const BUFFER_MULTIPLIER = 1;
/**
* Default demo centre (Canary Wharf). Used as the initial map view and the demo
* free-zone centre when the visitor declines the "check your area" prompt. When
* they consent, their GPS location is used instead (see lib/api `setDemoCenter`).
* Keep in sync with FREE_ZONE_FALLBACK_LAT/LON in the Rust server.
*/
export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 };
/** 10 Downing Street — geolocation fallback in dev so the GPS flows stay testable
* without a real fix (e.g. over http, where browsers deny geolocation). */
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
export const INITIAL_VIEW_STATE: ViewState = {
longitude: CANARY_WHARF.longitude,
latitude: CANARY_WHARF.latitude,
zoom: 14,
pitch: 0,
};
/** Demo (unlicensed) users can apply at most this many filters simultaneously. */
export const DEMO_MAX_FILTERS = 5;
/**
* Zoom to H3 resolution mapping thresholds.
* Returns the H3 resolution to use for a given zoom level.
*/
export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 7, resolution: 5 },
{ maxZoom: 9, resolution: 6 },
{ maxZoom: 10.5, resolution: 7 },
{ maxZoom: 11.5, resolution: 8 },
{ maxZoom: 14, resolution: 9 },
] as const;
export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution)
);
// Shared limit at which the zoomed-in detail layers — postcodes AND the map
// overlays (noise, crime, trees, property borders) — start showing. Set just
// past the finest hexagon level so detail appears while still relatively
// zoomed out. (Each overlay additionally can't render below its own tile-data
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
export const POSTCODE_ZOOM_THRESHOLD = 14;
export const POSTCODE_SEARCH_ZOOM = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
{ t: 0.33, color: [241, 196, 15] },
{ t: 0.66, color: [231, 76, 60] },
{ t: 1, color: [142, 68, 173] },
];
export type GradientStop = { t: number; color: [number, number, number] };
function partyGradient(color: [number, number, number]): GradientStop[] {
return [
{ t: 0, color: [255, 255, 255] },
{
t: 0.5,
color: [
Math.round(255 + (color[0] - 255) * 0.45),
Math.round(255 + (color[1] - 255) * 0.45),
Math.round(255 + (color[2] - 255) * 0.45),
],
},
{ t: 1, color },
];
}
/** UK party colours for the 2024 General Election vote-share map layers. */
export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
'% Labour': partyGradient([228, 0, 59]), // Labour red
'% Conservative': partyGradient([0, 135, 220]), // Conservative blue
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
'% Green': partyGradient([106, 176, 35]), // Green Party green
'% Other parties': partyGradient([107, 114, 128]), // neutral color for grouped parties
};
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
Object.entries(PARTY_FEATURE_GRADIENTS).map(([featureName, gradient]) => {
const color = gradient[gradient.length - 1].color;
return [featureName, `rgb(${color[0]}, ${color[1]}, ${color[2]})`];
})
);
export function getFeatureGradient(featureName: string | null | undefined): GradientStop[] {
return featureName
? (PARTY_FEATURE_GRADIENTS[featureName] ?? FEATURE_GRADIENT)
: FEATURE_GRADIENT;
}
/** Number of properties gradient — light mode (cream → orange) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [255, 255, 255] },
{ t: 0.1, color: [248, 233, 211] },
{ t: 0.5, color: [255, 221, 173] },
{ t: 0.8, color: [251, 171, 60] },
{ t: 1, color: [255, 162, 31] },
];
/** Number of properties gradient — dark mode (dark warm → bright amber) */
export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [55, 45, 35] },
{ t: 0.1, color: [85, 65, 40] },
{ t: 0.5, color: [170, 115, 50] },
{ t: 0.8, color: [230, 155, 45] },
{ t: 1, color: [255, 170, 40] },
];
/** Protomaps font glyphs URL (served locally from public/assets/) */
export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
/** Twemoji base URL (served locally from public/assets/) */
export const TWEMOJI_BASE = '/assets/twemoji/';
/** POI group → RGB color for category-coded map markers */
export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
'Public Transport': [59, 130, 246],
Leisure: [249, 115, 22],
Education: [139, 92, 246],
Health: [239, 68, 68],
'Emergency Services': [220, 38, 38],
Other: [107, 114, 128],
Groceries: [34, 197, 94],
'Local Businesses': [245, 158, 11],
Culture: [236, 72, 153],
Services: [6, 182, 212],
Shops: [99, 102, 241],
};
/** POI category → icon/logo URL for branded and transport categories */
export const POI_CATEGORY_LOGOS: Record<string, string> = {
Airport: '/assets/twemoji/2708.png',
Aldi: '/assets/poi-icons/logos/aldi.svg',
Amazon: '/assets/poi-icons/brands_2024/amazon_fresh.svg',
Asda: '/assets/poi-icons/logos/asda.svg',
'Asda Express': '/assets/poi-icons/logos/asda.svg',
'Asda Living': '/assets/poi-icons/logos/asda.svg',
'Asda PFS': '/assets/poi-icons/logos/asda.svg',
'Asda Supercentre': '/assets/poi-icons/logos/asda.svg',
'Asda Supermarket': '/assets/poi-icons/logos/asda.svg',
'Asda Superstore': '/assets/poi-icons/logos/asda.svg',
Bakery: '/assets/twemoji/1f950.png',
Booths: '/assets/poi-icons/brands_2024/booths.svg',
Budgens: '/assets/poi-icons/brands_2024/budgens.svg',
'Bus station': '/assets/twemoji/1f68c.png',
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: '/assets/poi-icons/logos/centra.svg',
'Co-op': '/assets/poi-icons/logos/coop.svg',
COOK: '/assets/poi-icons/brands_2024/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: '/assets/poi-icons/logos/costco.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': '/assets/poi-icons/brands_2024/dunnes_stores.svg',
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
Iceland: '/assets/poi-icons/brands_2024/iceland.svg',
Lidl: '/assets/poi-icons/logos/lidl.svg',
Makro: '/assets/poi-icons/brands_2024/makro.svg',
'M&S': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Clothing': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Food': '/assets/poi-icons/visuals/mns.svg',
'M&S Hospital': '/assets/poi-icons/brands_2024/mns.svg',
'M&S MSA': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Outlet': '/assets/poi-icons/brands_2024/mns.svg',
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
'Rail station': '/assets/twemoji/1f686.png',
School: '/assets/twemoji/1f3eb.png',
'Nursery school': '/assets/twemoji/1f9f8.png',
'Primary school': '/assets/twemoji/1f392.png',
'Secondary school': '/assets/twemoji/1f3eb.png',
'All-through school': '/assets/twemoji/1f3eb.png',
'Sixth form': '/assets/twemoji/1f4da.png',
'Further education college': '/assets/twemoji/1f4da.png',
University: '/assets/twemoji/1f393.png',
'Special school': '/assets/twemoji/1f91d.png',
"Sainsbury's": '/assets/poi-icons/logos/sainsburys.svg',
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
Spar: '/assets/poi-icons/logos/spar.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
Tesco: '/assets/poi-icons/logos/tesco.svg',
'Tesco Express': '/assets/poi-icons/logos/tesco_express.svg',
'Tesco Extra': '/assets/poi-icons/logos/tesco_extra.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
'The Food Warehouse': '/assets/poi-icons/logos/the_food_warehouse.png',
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
Waitrose: '/assets/poi-icons/logos/waitrose.svg',
'Little Waitrose': '/assets/poi-icons/brands_2023/supermarkets/little_waitrose.svg',
'Whole Foods Market': '/assets/poi-icons/brands_2024/wholefoods.svg',
};
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);
/** Zoom level below which minor POI categories are hidden */
export const MINOR_POI_ZOOM_THRESHOLD = 14;
/** Supercluster grouping radius in pixels */
export const POI_CLUSTER_RADIUS = 50;
/** Zoom level at which supercluster stops clustering */
export const POI_CLUSTER_MAX_ZOOM = 15;
/** Zoom level at which individual POI cards are shown without hovering */
export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1;
/** Hard cap on auto POI cards rendered at once. With every category enabled a
* dense area can yield hundreds of overlapping cards — an unreadable wall — so we
* show a spaced subset and rely on the map markers (+ hover) for the rest. */
export const MAX_AUTO_POI_CARDS = 40;
/** Minimum screen-space gap (px) between two auto POI cards before one is culled. */
export const AUTO_POI_CARD_MIN_DX = 130;
export const AUTO_POI_CARD_MIN_DY = 34;
/**
* Groups whose features should be collapsed into stacked bar charts.
* Keyed by feature group name. Each entry defines one stacked chart.
*/
export const STACKED_GROUPS: Record<
string,
{
/** Display label for the chart */
label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
feature?: string;
/** If set, display this feature's mean as the primary value (e.g. per-1k rate) instead of the absolute total. */
rateFeature?: string;
/** Suffix shown after the total value (e.g. "avg/yr") */
unit?: string;
/** Feature names that make up the segments */
components: string[];
}[]
> = {
Crime: [
{
label: 'Serious crime',
feature: 'Serious crime (avg/yr)',
unit: '',
components: [
'Violence and sexual offences (avg/yr)',
'Robbery (avg/yr)',
'Burglary (avg/yr)',
'Possession of weapons (avg/yr)',
],
},
{
label: 'Minor crime',
feature: 'Minor crime (avg/yr)',
unit: '',
components: [
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
'Shoplifting (avg/yr)',
'Bicycle theft (avg/yr)',
'Theft from the person (avg/yr)',
'Other theft (avg/yr)',
'Vehicle crime (avg/yr)',
'Public order (avg/yr)',
'Drugs (avg/yr)',
'Other crime (avg/yr)',
],
},
],
Neighbours: [
{
label: 'Ethnic composition',
unit: '%',
components: [
'% White',
'% South Asian',
'% East Asian',
'% SE Asian',
'% Black',
'% Mixed',
'% Other',
],
},
{
label: 'Qualifications',
unit: '%',
components: [
'% No qualifications',
'% Some GCSEs',
'% Good GCSEs',
'% Apprenticeship',
'% A-levels',
'% Degree or higher',
'% Other qualifications',
],
},
{
label: 'Political vote share',
unit: '%',
components: [
'% Labour',
'% Conservative',
'% Liberal Democrat',
'% Reform UK',
'% Green',
'% Other parties',
],
},
{
label: 'Tenure',
unit: '%',
components: ['% Owner occupied', '% Social rent', '% Private rent'],
},
],
};
/**
* Groups whose enum features should be collapsed into compact multi-row charts.
* Keyed by feature group name. Each entry defines one stacked enum chart.
*/
export const STACKED_ENUM_GROUPS: Record<
string,
{
/** Display label for the chart */
label: string;
/** If set, use this feature for the info popup */
feature?: string;
/** Enum feature names that make up the rows */
components: string[];
/** Value order for consistent segment ordering */
valueOrder: string[];
/** Colors for each value (matches valueOrder) */
valueColors: string[];
}[]
> = {
Property: [
{
label: 'Property type',
feature: 'Property type',
components: ['Property type'],
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flats/Maisonettes', 'Other'],
valueColors: ['#f97316', '#3b82f6', '#22c55e', '#ec4899', '#6b7280'],
},
{
label: 'Leasehold/Freehold',
feature: 'Leasehold/Freehold',
components: ['Leasehold/Freehold'],
valueOrder: ['Freehold', 'Leasehold'],
valueColors: ['#3b82f6', '#f59e0b'],
},
],
};
/**
* Maximally-distinguishable palette for discrete enum features on the map.
* 10 colors chosen for perceptual distinctness in both light and dark modes.
*/
export const ENUM_PALETTE: [number, number, number][] = [
[59, 130, 246], // blue-500
[249, 115, 22], // orange-500
[139, 92, 246], // violet-500
[34, 197, 94], // green-500
[239, 68, 68], // red-500
[6, 182, 212], // cyan-500
[236, 72, 153], // pink-500
[245, 158, 11], // amber-500
[20, 184, 166], // teal-500
[107, 114, 128], // gray-500
];
/**
* Per-feature color definitions for enum values on the map and dashboard.
* Keys are feature names (as returned by the server), values map enum value → RGB.
*/
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
'Property type': {
Detached: [249, 115, 22], // orange
'Semi-Detached': [59, 130, 246], // blue
Terraced: [34, 197, 94], // green
'Flats/Maisonettes': [236, 72, 153], // pink
Other: [107, 114, 128], // gray
},
'Leasehold/Freehold': {
Freehold: [59, 130, 246],
Leasehold: [245, 158, 11],
},
'Former council house': {
Yes: [239, 68, 68],
No: [34, 197, 94],
},
'Within conservation area': {
Yes: [20, 184, 166],
No: [107, 114, 128],
},
'Listed building': {
Yes: [245, 158, 11],
No: [107, 114, 128],
},
'Current energy rating': {
A: [22, 163, 74],
B: [132, 204, 22],
C: [234, 179, 8],
D: [245, 158, 11],
E: [249, 115, 22],
F: [239, 68, 68],
G: [126, 34, 206],
},
'Potential energy rating': {
A: [22, 163, 74],
B: [132, 204, 22],
C: [234, 179, 8],
D: [245, 158, 11],
E: [249, 115, 22],
F: [239, 68, 68],
G: [126, 34, 206],
},
};
/**
* Build the 10-color shader palette for a given enum feature.
* The trailing slots are invisible for features with fewer than 10 enum values.
*/
export function getEnumPaletteForFeature(
featureName: string,
values: string[]
): [number, number, number][] {
const overrides = ENUM_COLOR_OVERRIDES[featureName];
if (!overrides) {
throw new Error(`Missing enum color definitions for '${featureName}'`);
}
const palette: [number, number, number][] = [];
for (let i = 0; i < 10; i++) {
if (i < values.length) {
const color = overrides[values[i]];
if (!color) {
throw new Error(`Missing enum color for '${featureName}' value '${values[i]}'`);
}
palette.push(color);
} else {
palette.push([0, 0, 0]);
}
}
return palette;
}
/** Look up the configured color for a specific enum value. */
export function getEnumValueColor(
featureName: string,
valueName: string
): [number, number, number] {
const color = ENUM_COLOR_OVERRIDES[featureName]?.[valueName];
if (!color) {
throw new Error(`Missing enum color for '${featureName}' value '${valueName}'`);
}
return color;
}
/** Explicit colors for stacked bar segments. */
export const STACKED_SEGMENT_COLORS: Record<string, string> = {
'Violence and sexual offences (avg/yr)': '#ef4444',
'Robbery (avg/yr)': '#f97316',
'Burglary (avg/yr)': '#eab308',
'Possession of weapons (avg/yr)': '#8b5cf6',
'Anti-social behaviour (avg/yr)': '#14b8a6',
'Criminal damage and arson (avg/yr)': '#f97316',
'Shoplifting (avg/yr)': '#ec4899',
'Bicycle theft (avg/yr)': '#22c55e',
'Theft from the person (avg/yr)': '#d946ef',
'Other theft (avg/yr)': '#06b6d4',
'Vehicle crime (avg/yr)': '#3b82f6',
'Public order (avg/yr)': '#8b5cf6',
'Drugs (avg/yr)': '#22c55e',
'Other crime (avg/yr)': '#6b7280',
'% White': '#3b82f6',
'% South Asian': '#f97316',
'% East Asian': '#eab308',
'% SE Asian': '#ec4899',
'% Black': '#8b5cf6',
'% Mixed': '#14b8a6',
'% Other': '#6b7280',
// Qualifications: low-attainment (red) → high-attainment (green) ramp.
'% No qualifications': '#ef4444',
'% Some GCSEs': '#f97316',
'% Good GCSEs': '#eab308',
'% Apprenticeship': '#14b8a6',
'% A-levels': '#3b82f6',
'% Degree or higher': '#22c55e',
'% Other qualifications': '#6b7280',
// Tenure (Census 2021 TS054): owner-occupied (green) → social rent (amber) → private rent (blue).
'% Owner occupied': '#22c55e',
'% Social rent': '#f59e0b',
'% Private rent': '#3b82f6',
'Anti-social': '#14b8a6',
Vehicle: '#3b82f6',
Burglary: '#eab308',
Other: '#6b7280',
};