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 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 fallback 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], }; /** 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 = { 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> = { '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 ];