mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 10:25:06 +03:00
improve map
This commit is contained in:
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>
|
||||
)
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Generated
+6
@@ -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
|
||||
|
||||
Reference in new issue
Block a user