454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
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<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',
|
||
};
|