perfect-postcode/frontend/src/lib/consts.ts
2026-05-06 23:13:58 +01:00

389 lines
14 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 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 fallback 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],
};
/** Default color for unknown POI groups */
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
/** 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/brands/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/logos/iceland.svg',
Lidl: '/assets/poi-icons/logos/lidl.svg',
Makro: '/assets/poi-icons/brands_2024/makro.svg',
'M&S': '/assets/poi-icons/brands/mns.svg',
'M&S Clothing': '/assets/poi-icons/brands/mns_high_street.svg',
'M&S Food': '/assets/poi-icons/brands/mns_food.svg',
'M&S Hospital': '/assets/poi-icons/brands/mns_hospital.svg',
'M&S MSA': '/assets/poi-icons/brands/mns_moto.svg',
'M&S Outlet': '/assets/poi-icons/brands/mns_outlet.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',
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/iceland.svg',
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
Waitrose: '/assets/poi-icons/logos/waitrose.svg',
'Little Waitrose': '/assets/poi-icons/brands/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)',
],
},
],
Demographics: [
{
label: 'Ethnic composition',
unit: '%',
components: ['% White', '% South Asian', '% East Asian', '% Black', '% Mixed', '% Other'],
},
],
Politics: [
{
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 overrides for enum values on the map and dashboard.
* Keys are feature names (as returned by the server), values map enum value → RGB.
* Any value not listed falls back to ENUM_PALETTE by index.
*/
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
},
};
/**
* Build a 10-color palette for a given feature, using overrides where defined.
* Returns the default ENUM_PALETTE when no overrides exist.
*/
export function getEnumPaletteForFeature(
featureName: string | null,
values?: string[]
): [number, number, number][] {
if (!featureName || !values) return ENUM_PALETTE;
const overrides = ENUM_COLOR_OVERRIDES[featureName];
if (!overrides) return ENUM_PALETTE;
const palette: [number, number, number][] = [];
for (let i = 0; i < 10; i++) {
if (i < values.length && overrides[values[i]]) {
palette.push(overrides[values[i]]);
} else {
palette.push(ENUM_PALETTE[i % ENUM_PALETTE.length]);
}
}
return palette;
}
/** Look up override color for a specific enum value, or null if none. */
export function getEnumValueColor(
featureName: string,
valueName: string
): [number, number, number] | null {
return ENUM_COLOR_OVERRIDES[featureName]?.[valueName] ?? null;
}
/** Colors for stacked bar segments */
export const SEGMENT_COLORS = [
'#ef4444', // red-500
'#f97316', // orange-500
'#eab308', // yellow-500
'#22c55e', // green-500
'#14b8a6', // teal-500
'#06b6d4', // cyan-500
'#3b82f6', // blue-500
'#8b5cf6', // violet-500
'#d946ef', // fuchsia-500
'#ec4899', // pink-500
];