Geo map A map is the right chart only when the geography is the question. Population usually explains the pattern, so a choropleth of raw counts is a map of where people live redrawn with your metric's name on it. Tokens this needs: --foreground, --card, --muted, --border, --chart-1, --scale-seq-3 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // GeoMap.tsx ──────────────────────────────────────────────────────────────────────── /** * Markers for discrete located things. They fail by overlapping, so past a * pixel radius they cluster, and a cluster carries its count and scales with * it: five and five hundred have to look different. The base map is one * outline in a muted stroke, because it is context and should recede. * * Plain equirectangular projection. Good enough for a country; wrong for a * globe, where you want a real projection library. */ export type Point = { id: string; lat: number; lng: number; label: string }; export type Bounds = { lat: [number, number]; lng: [number, number] }; export type Cluster = { x: number; y: number; points: Point[] }; export function cluster(points: { x: number; y: number; point: Point }[], radius: number): Cluster[] { const out: Cluster[] = []; for (const p of points) { const near = out.find((c) => Math.hypot(c.x - p.x, c.y - p.y) <= radius); if (!near) { out.push({ x: p.x, y: p.y, points: [p.point] }); continue; } const n = near.points.length; near.x = (near.x * n + p.x) / (n + 1); near.y = (near.y * n + p.y) / (n + 1); near.points.push(p.point); } return out; } export function GeoMap({ points, bounds, outline, width = 320, height = 240, clusterRadius = 24, onSelect }: { points: Point[]; /** The viewport, as a latitude range and a longitude range. */ bounds: Bounds; /** Coastline or region border as lat/lng vertices, drawn under the data. */ outline?: [number, number][]; width?: number; height?: number; /** Pixel distance under which markers merge. */ clusterRadius?: number; /** A marker gives one point; a cluster gives all of them. */ onSelect?: (points: Point[]) => void; }) { const [lat0, lat1] = bounds.lat, [lng0, lng1] = bounds.lng; const squeeze = Math.cos(((lat0 + lat1) / 2) * Math.PI / 180); const k = Math.min(width / ((lng1 - lng0) * squeeze), height / (lat1 - lat0)); const ox = (width - (lng1 - lng0) * squeeze * k) / 2, oy = (height - (lat1 - lat0) * k) / 2; const project = (lat: number, lng: number) => ({ x: ox + (lng - lng0) * squeeze * k, y: oy + (lat1 - lat) * k }); const clusters = cluster(points.map((p) => ({ ...project(p.lat, p.lng), point: p })), clusterRadius); const r = (n: number) => (n === 1 ? 4 : 4 + 1.6 * Math.sqrt(n)); return ( ); } ──────────────────────────────────────────────────────────────────────── // stores.ts ──────────────────────────────────────────────────────────────────────── import type { Point } from "./GeoMap"; /** Forty-six stores: forty-two inside Greater London and four elsewhere. */ export const STORES: Point[] = [ ["oxford-st", 51.515, -0.141, "Oxford Street"], ["covent-garden", 51.512, -0.123, "Covent Garden"], ["stratford", 51.543, -0.008, "Stratford"], ["white-city", 51.507, -0.221, "White City"], ["canary-wharf", 51.505, -0.019, "Canary Wharf"], ["kings-cross", 51.531, -0.124, "King's Cross"], ["camden", 51.539, -0.143, "Camden"], ["angel", 51.532, -0.106, "Angel"], ["hackney", 51.545, -0.055, "Hackney"], ["brixton", 51.462, -0.115, "Brixton"], ["clapham", 51.462, -0.138, "Clapham"], ["wimbledon", 51.421, -0.206, "Wimbledon"], ["richmond", 51.461, -0.304, "Richmond"], ["kingston", 51.41, -0.306, "Kingston"], ["croydon", 51.372, -0.099, "Croydon"], ["bromley", 51.406, 0.014, "Bromley"], ["greenwich", 51.478, 0.0, "Greenwich"], ["lewisham", 51.465, -0.013, "Lewisham"], ["peckham", 51.474, -0.069, "Peckham"], ["ealing", 51.513, -0.305, "Ealing"], ["hammersmith", 51.492, -0.223, "Hammersmith"], ["kensington", 51.501, -0.192, "Kensington"], ["chelsea", 51.487, -0.169, "Chelsea"], ["victoria", 51.496, -0.144, "Victoria"], ["waterloo", 51.503, -0.113, "Waterloo"], ["london-bridge", 51.505, -0.086, "London Bridge"], ["liverpool-st", 51.518, -0.081, "Liverpool Street"], ["shoreditch", 51.526, -0.078, "Shoreditch"], ["bethnal-green", 51.527, -0.055, "Bethnal Green"], ["walthamstow", 51.583, -0.02, "Walthamstow"], ["wood-green", 51.597, -0.11, "Wood Green"], ["finchley", 51.599, -0.187, "Finchley"], ["hampstead", 51.556, -0.178, "Hampstead"], ["harrow", 51.579, -0.335, "Harrow"], ["uxbridge", 51.546, -0.478, "Uxbridge"], ["hounslow", 51.468, -0.361, "Hounslow"], ["sutton", 51.361, -0.194, "Sutton"], ["ilford", 51.559, 0.07, "Ilford"], ["romford", 51.575, 0.183, "Romford"], ["barking", 51.54, 0.081, "Barking"], ["woolwich", 51.491, 0.069, "Woolwich"], ["enfield", 51.652, -0.081, "Enfield"], ["manchester", 53.48, -2.24, "Manchester"], ["birmingham", 52.48, -1.9, "Birmingham"], ["bristol", 51.45, -2.59, "Bristol"], ["edinburgh", 55.95, -3.19, "Edinburgh"], ].map(([id, lat, lng, label]) => ({ id, lat, lng, label } as Point)); /** A coarse Great Britain coastline, enough to place the markers and no more. */ export const GREAT_BRITAIN: [number, number][] = [ [50.07, -5.7], [50.37, -4.14], [50.52, -2.45], [50.82, -0.14], [51.13, 1.32], [51.38, 1.44], [51.96, 1.35], [52.48, 1.75], [52.93, 1.3], [52.9, 0.2], [53.15, 0.34], [53.58, 0.11], [54.12, -0.08], [54.49, -0.61], [54.69, -1.21], [55.0, -1.4], [55.77, -2.0], [56.0, -2.5], [56.05, -3.3], [56.4, -2.8], [57.15, -2.1], [57.7, -2.0], [57.5, -4.2], [58.44, -3.1], [58.62, -5.0], [57.9, -5.2], [57.3, -6.3], [56.8, -5.1], [55.3, -5.8], [55.46, -4.63], [54.9, -3.6], [54.5, -3.6], [54.1, -3.2], [53.8, -3.05], [53.4, -3.0], [53.4, -4.6], [52.8, -4.7], [52.4, -4.1], [51.9, -5.3], [51.6, -4.0], [51.45, -3.2], [51.35, -3.0], [51.2, -3.5], [51.0, -4.5], [50.55, -4.95], ]; ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { GeoMap } from "./GeoMap"; import { GREAT_BRITAIN, STORES } from "./stores"; /** * Forty-six stores on a map of Great Britain. At this zoom the London stores * sit within a few pixels of each other, so they merge into one cluster that * carries the count; the four elsewhere stay as single markers. */ export default function Demo() { return (