perfect-postcode/frontend/src/lib/consts.ts
Andras Schmelczer 2f149503bb
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 7m0s
CI / Check (push) Failing after 7m9s
all is well
2026-05-17 17:20:19 +01:00

454 lines
16 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.5;
/** Demo free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, east: 0.05 };
export const INITIAL_VIEW_STATE: ViewState = {
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
zoom: 14,
pitch: 0,
};
/**
* 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: 13, resolution: 9 },
] as const;
export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution)
);
export const POSTCODE_ZOOM_THRESHOLD = 15;
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',
'Allendale Co-operative Society': '/assets/poi-icons/logos/coop.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',
'Central England Co-operative': '/assets/poi-icons/logos/coop.svg',
'Chelmsford Star Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'Clydebank Co-operative': '/assets/poi-icons/logos/coop.svg',
'Co-op': '/assets/poi-icons/logos/coop.svg',
'Coniston Co-operative Society': '/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',
'East of England Co-operative': '/assets/poi-icons/logos/coop.svg',
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
'Heart of England Co-operative': '/assets/poi-icons/logos/coop.svg',
'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',
'Langdale Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'Lincolnshire Co-operative': '/assets/poi-icons/logos/coop.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',
'Midcounties Co-operative': '/assets/poi-icons/logos/coop.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',
"Sainsbury's": '/assets/poi-icons/logos/sainsburys.svg',
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
'Scottish Midland Co-operative': '/assets/poi-icons/logos/coop.svg',
Spar: '/assets/poi-icons/logos/spar.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
'Tamworth Co-operative Society': '/assets/poi-icons/logos/coop.svg',
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 Radstock Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'The Southern Co-operative': '/assets/poi-icons/logos/coop.svg',
'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;
/**
* 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)',
rateFeature: 'Serious crime per 1k residents (avg/yr)',
unit: 'per 1k/yr',
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)',
rateFeature: 'Minor crime per 1k residents (avg/yr)',
unit: 'per 1k/yr',
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', '% Black', '% Mixed', '% Other'],
},
{
label: 'Political vote share',
unit: '%',
components: [
'% Labour',
'% Conservative',
'% Liberal Democrat',
'% Reform UK',
'% Green',
'% Other parties',
],
},
],
};
/**
* 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],
},
'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],
},
'Max available download speed (Mbps)': {
'10': [107, 114, 128],
'30': [245, 158, 11],
'100': [59, 130, 246],
'300': [20, 184, 166],
'1000': [34, 197, 94],
},
};
/**
* 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',
'% Black': '#8b5cf6',
'% Mixed': '#14b8a6',
'% Other': '#6b7280',
'Anti-social': '#14b8a6',
Vehicle: '#3b82f6',
Burglary: '#eab308',
Other: '#6b7280',
};