improve map

This commit is contained in:
Francesco Sansalvadore committed 2026-08-27 12:05:07 +02:00
1 parent 1fac076f5e
commit 52fc7beee0
8 files changed
+271 -101

No files matched your search

@@ -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) => (
<div className="flex items-center gap-1.5 rounded-md border bg-surface-100/95 px-1.5 py-1 shadow-xs backdrop-blur-sm">
{/* Flags are SVGs, which next/image can't serve while dangerouslyAllowSVG is off */}
<img alt="" className="w-3.5 shrink-0 rounded-xs" src={`/images/regions/${region.code}.svg`} />
<div className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-[11px] text-foreground">{region.displayName}</span>
<span className="truncate font-mono text-[10px] text-foreground-lighter">{region.code}</span>
</div>
{isSelected && (
<Button
variant="text"
size="tiny"
icon={<X size={12} />}
onClick={onClear}
aria-label="Clear selected region"
className="pointer-events-auto ml-0.5 h-4 w-4 p-0"
/>
)}
</div>
)
@@ -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),
@@ -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<string>()
// 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<string>()
const handleRegionSelect = (code: string) =>
setSelectedRegionCode(code === selectedRegionCode ? null : code)
return (
<div className="flex flex-col gap-8 lg:gap-12">
<div className="relative h-[280px] w-full overflow-hidden rounded-lg border bg-alternative sm:h-[360px] lg:h-[540px]">
<RegionsMap activeRegionCode={activeRegionCode} onRegionHover={setActiveRegionCode} />
{/* Matches the map's viewBox ratio, so the map fills the container instead of letterboxing */}
<div className="relative aspect-800/349 w-full overflow-hidden rounded-lg border bg-alternative">
<RegionsMap
selectedRegionCode={selectedRegionCode}
hoveredRegionCode={hoveredRegionCode}
onRegionHover={setHoveredRegionCode}
onRegionSelect={handleRegionSelect}
onRegionClear={() => setSelectedRegionCode(null)}
/>
</div>
<RegionsList activeRegionCode={activeRegionCode} onRegionHover={setActiveRegionCode} />
<RegionsList
selectedRegionCode={selectedRegionCode}
hoveredRegionCode={hoveredRegionCode}
onRegionHover={setHoveredRegionCode}
onRegionSelect={handleRegionSelect}
/>
</div>
)
}
export const RegionsExplorer = () => (
<NuqsAdapter>
<RegionsExplorerContent />
</NuqsAdapter>
)
+53 -25
View File
@@ -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) => (
<div className="grid grid-cols-1 gap-8 md:grid-cols-3 lg:gap-12">
{GROUPED_REGIONS.map(({ group, regions }) => (
<div key={group} className="flex flex-col gap-3">
@@ -17,29 +24,50 @@ export const RegionsList = ({ activeRegionCode, onRegionHover }: Props) => (
{group}
</h3>
<ul className="flex flex-col divide-y divide-border-muted border-t border-border-muted">
{regions.map((region) => (
<li
key={region.code}
onMouseEnter={() => 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 */}
<img
alt=""
className="w-4 shrink-0 rounded-xs"
src={`/images/regions/${region.code}.svg`}
/>
<span className="text-sm text-foreground">{region.displayName}</span>
{region.jurisdiction && <Badge>{region.jurisdiction}</Badge>}
<code className="ml-auto shrink-0 font-mono text-xs text-foreground-lighter">
{region.code}
</code>
</li>
))}
{regions.map((region) => {
const isSelected = region.code === selectedRegionCode
return (
<li key={region.code}>
<button
type="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={() => onRegionSelect(region.code)}
onMouseEnter={() => onRegionHover(region.code)}
onMouseLeave={() => onRegionHover(undefined)}
onFocus={() => onRegionHover(region.code)}
onBlur={() => onRegionHover(undefined)}
className={cn(
// Bleeds the hover background 8px each side so row content stays aligned with the heading
'-mx-2 flex w-[calc(100%+1rem)] items-center gap-3 rounded-sm px-2 py-2.5 text-left transition-colors',
'hover:bg-surface-100 focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:outline-none',
isSelected && 'bg-surface-100',
!isSelected && region.code === hoveredRegionCode && 'bg-surface-100'
)}
>
{/* Flags are SVGs, which next/image can't serve while dangerouslyAllowSVG is off */}
<img
alt=""
className="w-4 shrink-0 rounded-xs"
src={`/images/regions/${region.code}.svg`}
/>
<span
className={cn(
'text-xs',
isSelected ? 'text-foreground' : 'text-foreground-light'
)}
>
{region.displayName}
</span>
{region.jurisdiction && <Badge>{region.jurisdiction}</Badge>}
<code className="ml-auto shrink-0 font-mono text-xs text-foreground-lighter">
{region.code}
</code>
</button>
</li>
)
})}
</ul>
</div>
))}
+141 -67
View File
@@ -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 (
<ComposableMap projectionConfig={{ scale: 155 }} className="w-full h-full">
<ZoomableGroup
center={[14, 12]}
zoom={1.3}
// The map is a static illustration: no panning, no zooming
filterZoomEvent={() => false}
<>
<ComposableMap
width={MAP_WIDTH}
height={MAP_HEIGHT}
projectionConfig={{ scale: MAP_SCALE }}
className="absolute inset-0 h-full w-full"
>
<Geographies geography={GeographyData}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
strokeWidth={0.3}
pointerEvents="none"
className="fill-background-surface-300 stroke-border-strong"
/>
))
}
</Geographies>
{MAPPED_REGIONS.map((region) => {
const isActive = region.code === activeRegionCode
return (
<Marker
key={region.key}
coordinates={region.coordinates}
onMouseEnter={() => onRegionHover(region.code)}
onMouseLeave={() => onRegionHover(undefined)}
>
{isActive && <circle r={4} className="animate-ping fill-brand" />}
<circle
r={3.5}
strokeWidth={1}
className={cn(
'cursor-pointer transition-colors',
isActive ? 'fill-brand stroke-brand-500' : 'fill-brand-500 stroke-brand-400'
)}
/>
</Marker>
)
})}
{activeRegion && (
<Marker coordinates={activeRegion.coordinates}>
<foreignObject x={-85} y={-46} width={170} height={40} className="pointer-events-none">
<div className="flex items-center gap-2 rounded-md border bg-surface-100/90 px-2 py-1.5 backdrop-blur-xs">
{/* Flags are SVGs, which next/image can't serve while dangerouslyAllowSVG is off */}
<img
alt=""
className="w-4 rounded-xs"
src={`/images/regions/${activeRegion.code}.svg`}
<ZoomableGroup
center={MAP_CENTER}
zoom={MAP_ZOOM}
minZoom={MAP_ZOOM}
maxZoom={MAP_ZOOM}
// The map is a static illustration: no panning, no zooming
filterZoomEvent={() => false}
>
<Geographies geography={GeographyData}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
strokeWidth={0.3}
pointerEvents="none"
className="fill-foreground-muted/10 stroke-border-strong dark:fill-background-surface-300 dark:stroke-border-strong"
/>
<div className="flex flex-col leading-tight">
<span className="text-[10px] text-foreground">{activeRegion.displayName}</span>
<span className="font-mono text-[9px] text-foreground-lighter">
{activeRegion.code}
</span>
</div>
</div>
</foreignObject>
</Marker>
)}
</ZoomableGroup>
</ComposableMap>
))
}
</Geographies>
{MAPPED_REGIONS.map((region) => {
const isSelected = region.code === selectedRegionCode
const isHighlighted = isSelected || region.code === hoveredRegionCode
return (
<Marker
key={region.key}
coordinates={region.coordinates}
onMouseEnter={() => onRegionHover(region.code)}
onMouseLeave={() => onRegionHover(undefined)}
onClick={() => onRegionSelect(region.code)}
>
{isSelected && <circle r={4} className="animate-ping fill-brand" />}
<circle
r={isHighlighted ? 4 : 3.5}
strokeWidth={1}
className={cn(
'cursor-pointer transition-colors',
isSelected
? 'fill-brand-500 stroke-brand-600'
: isHighlighted
? 'fill-brand stroke-brand-600'
: 'fill-brand-600 stroke-brand-400'
)}
/>
</Marker>
)
})}
</ZoomableGroup>
</ComposableMap>
{shownRegion && shownPosition && (
<div
className="pointer-events-none absolute z-10 -translate-y-1/2"
style={{
// Anchored to the free side of the marker, capped at the space there is on that side
// so a tooltip never runs past the edge, and kept a half-height clear of the top and
// bottom edges
...(isTooltipOnLeft
? {
right: `calc(${100 - shownPosition.left}% + 0.625rem)`,
maxWidth: `calc(${shownPosition.left}% - 1rem)`,
}
: {
left: `calc(${shownPosition.left}% + 0.625rem)`,
maxWidth: `calc(${100 - shownPosition.left}% - 1rem)`,
}),
top: `clamp(1.125rem, ${shownPosition.top}%, calc(100% - 1.125rem))`,
}}
>
<RegionTooltip
region={shownRegion}
isSelected={shownRegion.code === selectedRegionCode}
onClear={onRegionClear}
/>
</div>
)}
</>
)
}
+2
View File
@@ -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",
-4
View File
@@ -56,10 +56,6 @@ const RegionsPage = () => {
</SectionContainer>
<SectionContainer className="flex flex-col gap-8 lg:gap-12">
<SectionHeading
title="Available regions"
description="Every region runs on AWS. You pick one when you create a project."
/>
<RegionsExplorer />
</SectionContainer>
+6
View File
@@ -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