import type { ViewState } from '../types'; export const INITIAL_RETRY_MS = 1000; export const MAX_RETRY_MS = 10000; /** Lower percentile for color-range clipping (0–100) */ export const COLOR_RANGE_LOW_PERCENTILE = 5; /** Upper percentile for color-range clipping (0–100) */ 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 = { '% 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 = 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 = { '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 = { 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> = { '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 = { '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', };