From 52fc7beee082895cd4d26dfd8716e5c71bb2bfc8 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Thu, 27 Aug 2026 12:05:07 +0200 Subject: [PATCH] improve map --- apps/www/components/Regions/RegionTooltip.tsx | 33 +++ .../components/Regions/Regions.constants.ts | 3 + .../components/Regions/RegionsExplorer.tsx | 38 +++- apps/www/components/Regions/RegionsList.tsx | 78 ++++--- apps/www/components/Regions/RegionsMap.tsx | 208 ++++++++++++------ apps/www/package.json | 2 + apps/www/pages/regions.tsx | 4 - pnpm-lock.yaml | 6 + 8 files changed, 271 insertions(+), 101 deletions(-) create mode 100644 apps/www/components/Regions/RegionTooltip.tsx diff --git a/apps/www/components/Regions/RegionTooltip.tsx b/apps/www/components/Regions/RegionTooltip.tsx new file mode 100644 index 00000000000..8390b96c9a2 --- /dev/null +++ b/apps/www/components/Regions/RegionTooltip.tsx @@ -0,0 +1,33 @@ +'use client' + +import { X } from 'lucide-react' +import { Button } from 'ui' + +import type { RegionDetails } from './Regions.constants' + +interface Props { + region: RegionDetails + isSelected: boolean + onClear: () => void +} + +export const RegionTooltip = ({ region, isSelected, onClear }: Props) => ( +
+ {/* Flags are SVGs, which next/image can't serve while dangerouslyAllowSVG is off */} + +
+ {region.displayName} + {region.code} +
+ {isSelected && ( +
+) diff --git a/apps/www/components/Regions/Regions.constants.ts b/apps/www/components/Regions/Regions.constants.ts index 3e28b23b8de..6e59d0b5771 100644 --- a/apps/www/components/Regions/Regions.constants.ts +++ b/apps/www/components/Regions/Regions.constants.ts @@ -68,6 +68,9 @@ export const REGIONS: RegionDetails[] = (Object.keys(AWS_REGIONS) as AWS_REGIONS export const REGION_COUNT = REGIONS.length +/** Valid values for the `region` query param */ +export const REGION_CODES = REGIONS.map((region) => region.code) + export const GROUPED_REGIONS = REGION_GROUPS.map((group) => ({ group, regions: REGIONS.filter((region) => region.group === group), diff --git a/apps/www/components/Regions/RegionsExplorer.tsx b/apps/www/components/Regions/RegionsExplorer.tsx index 5a18fb32d1c..f0142e9ebc9 100644 --- a/apps/www/components/Regions/RegionsExplorer.tsx +++ b/apps/www/components/Regions/RegionsExplorer.tsx @@ -1,8 +1,11 @@ 'use client' import dynamic from 'next/dynamic' +import { parseAsStringLiteral, useQueryState } from 'nuqs' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { useState } from 'react' +import { REGION_CODES } from './Regions.constants' import { RegionsList } from './RegionsList' const RegionsMap = dynamic(() => import('./RegionsMap').then((mod) => mod.RegionsMap), { @@ -10,15 +13,40 @@ const RegionsMap = dynamic(() => import('./RegionsMap').then((mod) => mod.Region loading: () => null, }) -export const RegionsExplorer = () => { - const [activeRegionCode, setActiveRegionCode] = useState() +// Unknown values are dropped, so `?region=nonsense` renders as no selection +const regionParser = parseAsStringLiteral(REGION_CODES).withOptions({ history: 'push' }) + +const RegionsExplorerContent = () => { + const [selectedRegionCode, setSelectedRegionCode] = useQueryState('region', regionParser) + const [hoveredRegionCode, setHoveredRegionCode] = useState() + + const handleRegionSelect = (code: string) => + setSelectedRegionCode(code === selectedRegionCode ? null : code) return (
-
- + {/* Matches the map's viewBox ratio, so the map fills the container instead of letterboxing */} +
+ setSelectedRegionCode(null)} + />
- +
) } + +export const RegionsExplorer = () => ( + + + +) diff --git a/apps/www/components/Regions/RegionsList.tsx b/apps/www/components/Regions/RegionsList.tsx index 3b3c031c8d9..5489614a574 100644 --- a/apps/www/components/Regions/RegionsList.tsx +++ b/apps/www/components/Regions/RegionsList.tsx @@ -5,11 +5,18 @@ import { Badge, cn } from 'ui' import { GROUPED_REGIONS } from './Regions.constants' interface Props { - activeRegionCode?: string + selectedRegionCode: string | null + hoveredRegionCode?: string onRegionHover: (code?: string) => void + onRegionSelect: (code: string) => void } -export const RegionsList = ({ activeRegionCode, onRegionHover }: Props) => ( +export const RegionsList = ({ + selectedRegionCode, + hoveredRegionCode, + onRegionHover, + onRegionSelect, +}: Props) => (
{GROUPED_REGIONS.map(({ group, regions }) => (
@@ -17,29 +24,50 @@ export const RegionsList = ({ activeRegionCode, onRegionHover }: Props) => ( {group}
    - {regions.map((region) => ( -
  • onRegionHover(region.code)} - onMouseLeave={() => onRegionHover(undefined)} - className={cn( - 'flex items-center gap-3 px-2 py-2.5 -mx-2 transition-colors', - activeRegionCode === region.code && 'bg-surface-100' - )} - > - {/* Flags are SVGs, which next/image can't serve while dangerouslyAllowSVG is off */} - - {region.displayName} - {region.jurisdiction && {region.jurisdiction}} - - {region.code} - -
  • - ))} + {regions.map((region) => { + const isSelected = region.code === selectedRegionCode + + return ( +
  • + +
  • + ) + })}
))} diff --git a/apps/www/components/Regions/RegionsMap.tsx b/apps/www/components/Regions/RegionsMap.tsx index a7da67d0b7f..ba7e543a81a 100644 --- a/apps/www/components/Regions/RegionsMap.tsx +++ b/apps/www/components/Regions/RegionsMap.tsx @@ -1,85 +1,159 @@ 'use client' +import { geoEqualEarth } from 'd3-geo' import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps' import { cn } from 'ui' import GeographyData from './MapData.json' import { MAPPED_REGIONS } from './Regions.constants' +import { RegionTooltip } from './RegionTooltip' + +/** + * The viewport is sized and framed so the land mass fills it edge to edge — the map is + * letterboxed inside its container otherwise. At scale 155 the world (Antarctica is not in + * MapData.json) projects to 828.3 x 361.5, so MAP_ZOOM shrinks it onto MAP_WIDTH and + * MAP_CENTER is the coordinate at the middle of that bounding box. + */ +const MAP_SCALE = 155 +const MAP_WIDTH = 800 +const MAP_HEIGHT = 349 +const MAP_ZOOM = 0.9659 +const MAP_CENTER: [number, number] = [1.06, 6.89] + +const projection = geoEqualEarth() + .scale(MAP_SCALE) + .translate([MAP_WIDTH / 2, MAP_HEIGHT / 2]) +const [centerX, centerY] = projection(MAP_CENTER) ?? [MAP_WIDTH / 2, MAP_HEIGHT / 2] + +/** + * Where each marker sits as a percentage of the container, mirroring what ZoomableGroup does to + * the SVG: `translate(width / 2 - centre * zoom) scale(zoom)`. The tooltip is positioned with + * these because anything rendered inside the SVG scales with the viewBox — text ends up at half + * size on a phone and twice that on a desktop. + */ +const REGION_POSITIONS = new Map( + MAPPED_REGIONS.map((region) => { + const [x, y] = projection(region.coordinates) ?? [centerX, centerY] + + return [ + region.code, + { + left: ((MAP_WIDTH / 2 + (x - centerX) * MAP_ZOOM) / MAP_WIDTH) * 100, + top: ((MAP_HEIGHT / 2 + (y - centerY) * MAP_ZOOM) / MAP_HEIGHT) * 100, + }, + ] + }) +) interface Props { - activeRegionCode?: string + selectedRegionCode: string | null + hoveredRegionCode?: string onRegionHover: (code?: string) => void + onRegionSelect: (code: string) => void + onRegionClear: () => void } -export const RegionsMap = ({ activeRegionCode, onRegionHover }: Props) => { - const activeRegion = MAPPED_REGIONS.find((region) => region.code === activeRegionCode) +export const RegionsMap = ({ + selectedRegionCode, + hoveredRegionCode, + onRegionHover, + onRegionSelect, + onRegionClear, +}: Props) => { + // Hovering previews a region, so it wins over the selection + const shownRegionCode = hoveredRegionCode ?? selectedRegionCode + const shownRegion = MAPPED_REGIONS.find((region) => region.code === shownRegionCode) + const shownPosition = shownRegion ? REGION_POSITIONS.get(shownRegion.code) : undefined + // Markers in the right half of the map get their tooltip on the left, so it can't run off + const isTooltipOnLeft = (shownPosition?.left ?? 0) > 50 return ( - - false} + <> + - - {({ geographies }) => - geographies.map((geo) => ( - - )) - } - - - {MAPPED_REGIONS.map((region) => { - const isActive = region.code === activeRegionCode - - return ( - onRegionHover(region.code)} - onMouseLeave={() => onRegionHover(undefined)} - > - {isActive && } - - - ) - })} - - {activeRegion && ( - - -
- {/* Flags are SVGs, which next/image can't serve while dangerouslyAllowSVG is off */} - false} + > + + {({ geographies }) => + geographies.map((geo) => ( + -
- {activeRegion.displayName} - - {activeRegion.code} - -
-
-
-
- )} -
-
+ )) + } + + + {MAPPED_REGIONS.map((region) => { + const isSelected = region.code === selectedRegionCode + const isHighlighted = isSelected || region.code === hoveredRegionCode + + return ( + onRegionHover(region.code)} + onMouseLeave={() => onRegionHover(undefined)} + onClick={() => onRegionSelect(region.code)} + > + {isSelected && } + + + ) + })} + + + + {shownRegion && shownPosition && ( +
+ +
+ )} + ) } diff --git a/apps/www/package.json b/apps/www/package.json index dc4473d2a09..2dbec8f9552 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -49,6 +49,7 @@ "common": "workspace:*", "common-tags": "^1.8.2", "config": "workspace:*", + "d3-geo": "^3.1.1", "dayjs": "^1.11.12", "dev-tools": "workspace:*", "eslint-config-supabase": "workspace:*", @@ -106,6 +107,7 @@ "@types/animejs": "^3.1.12", "@types/classnames": "^2.3.1", "@types/common-tags": "^1.8.4", + "@types/d3-geo": "^3.1.0", "@types/mdast": "^4.0.4", "@types/mdx-js__react": "^1.5.6", "@types/parse-numeric-range": "^0.0.1", diff --git a/apps/www/pages/regions.tsx b/apps/www/pages/regions.tsx index 809ab325c19..58cf94c89fa 100644 --- a/apps/www/pages/regions.tsx +++ b/apps/www/pages/regions.tsx @@ -56,10 +56,6 @@ const RegionsPage = () => { - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56a1694bddf..399de9f9508 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1735,6 +1735,9 @@ importers: config: specifier: workspace:* version: link:../../packages/config + d3-geo: + specifier: ^3.1.1 + version: 3.1.1 dayjs: specifier: ^1.11.12 version: 1.11.13 @@ -1901,6 +1904,9 @@ importers: '@types/common-tags': specifier: ^1.8.4 version: 1.8.4 + '@types/d3-geo': + specifier: ^3.1.0 + version: 3.1.0 '@types/mdast': specifier: ^4.0.4 version: 4.0.4