Files
kemal.earth de3524034f feat(studio): add ability to drag top section map (#50904)
## Problem

Top section map view on project overview wasn't very usable on smaller
window sizes. No ability to drag and see any instances on west coast
without stretching viewport or it being cut off.

## Solution

Adds dragging to map area so you can bring into view locations out of
the bounding box.

## Review instructions

1. Switch to map view and **drag the map** — it should pan, and the
previously cut-off regions should be reachable.
2. The drag should **track the cursor 1:1**, with no rubber-band lag.
(The 300ms transform transition is what caused that; it's now suppressed
mid-drag.)
3. Drag hard toward each edge — the map should **stop at the frame
bounds** rather than sliding off into empty background.
4. Cursor should read `grab`, and `grabbing` while dragging.

### Regressions to rule out

5. **Wheel/trackpad scroll over the map** must not zoom it, and the page
behind it must still scroll normally. Trackpad pinch must not zoom
either.
6. **Double-click** on the map must not zoom.
7. **Click a region marker** — still recenters, zooms to 2.0, and opens
the region detail panel bottom-right. The recenter should still animate
smoothly.
8. **Close** in that panel still resets to the default center and zoom.
9. **Hover a marker** at default zoom — the country/database tooltip
still appears; the dashed lines between primary and replicas still
render.
10. Toggle back to **flow view** — unchanged.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Map view now supports dragging to pan, with cursor changes to indicate
when panning is available or in progress.
* **Bug Fixes**
* Map zoom input is filtered based on event type, modifier keys, and
mouse-button state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-25 17:27:54 +01:00

354 lines
14 KiB
TypeScript

import { useParams } from 'common'
import dayjs from 'dayjs'
import { partition, uniqBy } from 'lodash'
import { MoreVertical } from 'lucide-react'
import Link from 'next/link'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
ComposableMap,
Geographies,
Geography,
Line,
Marker,
ZoomableGroup,
} from 'react-simple-maps'
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
ScrollArea,
} from 'ui'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants'
import GeographyData from './MapData.json'
import { getReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { RegionFlag } from '@/components/ui/RegionFlag'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
const MAP_WIDTH = 800
const MAP_HEIGHT = 600
const MapView = () => {
const { ref } = useParams()
const dbSelectorState = useDatabaseSelectorStateSnapshot()
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
'project_homepage:show_instance_size',
])
const [mount, setMount] = useState(false)
const [isPanning, setIsPanning] = useState(false)
const [zoom, setZoom] = useState<number>(1.5)
const [center, setCenter] = useState<[number, number]>([14, 7])
const [tooltip, setTooltip] = useState<{
x: number
y: number
region: { key: string; country?: string; name?: string; region?: string }
}>()
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
const { data } = useReadReplicasQuery({ projectRef: ref })
const databases = data ?? []
const [[primary], replicas] = partition(databases, (db) => db.identifier === ref)
const primaryCoordinates = AVAILABLE_REPLICA_REGIONS.find((region) =>
primary.region.includes(region.region)
)?.coordinates ?? [0, 0]
const uniqueRegionsByReplicas = uniqBy(replicas, (r) => {
return AVAILABLE_REPLICA_REGIONS.find((region) => r.region.includes(region.region))?.key
})
const selectedRegionKey =
AVAILABLE_REPLICA_REGIONS.find((region) => region.coordinates === center)?.region ?? ''
const showRegionDetails = zoom === 2.0 && selectedRegionKey !== undefined
const selectedRegion = AVAILABLE_REPLICA_REGIONS.find(
(region) => region.region === selectedRegionKey
)
const databasesInSelectedRegion = databases
.filter((database) => database.region.includes(selectedRegionKey))
.sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0))
.sort((database) => (database.identifier === ref ? -1 : 0))
useEffect(() => {
setTimeout(() => setMount(true), 100)
}, [])
const filterZoomEvent = useCallback((event: SVGElement) => {
const { type, ctrlKey, button } = event as unknown as MouseEvent
return type !== 'wheel' && type !== 'dblclick' && !ctrlKey && !button
}, [])
const onMoveStart = useCallback(() => setIsPanning(true), [])
const onMoveEnd = useCallback(() => setIsPanning(false), [])
return (
<div className="bg-studio h-full relative cursor-grab active:cursor-grabbing">
<ComposableMap
width={MAP_WIDTH}
height={MAP_HEIGHT}
projectionConfig={{ scale: 155 }}
className="w-full h-full"
>
<ZoomableGroup
className={mount && !isPanning ? 'transition-all duration-300' : ''}
center={center}
zoom={zoom}
minZoom={1.5}
maxZoom={2.0}
translateExtent={[
[0, 0],
[MAP_WIDTH, MAP_HEIGHT],
]}
filterZoomEvent={filterZoomEvent}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
>
<Geographies geography={GeographyData}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
strokeWidth={0.3}
pointerEvents="none"
className="fill-gray-800 stroke-gray-900 dark:fill-gray-300 dark:stroke-gray-200"
/>
))
}
</Geographies>
{uniqueRegionsByReplicas.map((database) => {
const coordinates = AVAILABLE_REPLICA_REGIONS.find((region) =>
database.region.includes(region.region)
)?.coordinates
if (coordinates !== primaryCoordinates) {
return (
<Line
key={`line-${database.identifier}-${primary.identifier}`}
from={coordinates}
to={primaryCoordinates}
stroke="white"
strokeWidth={1}
strokeLinecap="round"
strokeOpacity={0.2}
strokeDasharray={'3, 3'}
className="map-path"
/>
)
} else {
return null
}
})}
{AVAILABLE_REPLICA_REGIONS.map((region) => {
const dbs =
databases.filter((database) => database.region.includes(region.region)) ?? []
const coordinates = AVAILABLE_REPLICA_REGIONS.find(
(r) => r.region === region.region
)?.coordinates
const hasNoDatabases = dbs.length === 0
const hasPrimary = dbs.some((database) => database.identifier === ref)
const replicas = dbs.filter((database) => database.identifier !== ref) ?? []
return (
<Marker
key={region.key}
coordinates={coordinates}
onMouseEnter={() => {
setTooltip({
x: coordinates![0],
y: coordinates![1],
region: {
key: region.key,
country: region.name,
region: region.region,
name: hasNoDatabases
? undefined
: hasPrimary
? `Primary Database${
replicas.length > 0
? ` + ${replicas.length} replica${replicas.length > 1 ? 's' : ''} `
: ''
}`
: `${replicas.length} Read Replica${
replicas.length > 1 ? 's' : ''
} deployed`,
},
})
}}
onMouseLeave={() => setTooltip(undefined)}
onClick={() => {
if (coordinates) {
setCenter(coordinates)
setZoom(2.0)
}
}}
>
{selectedRegionKey === region.region && (
<circle
r={4}
className={`animate-ping ${
hasNoDatabases ? 'fill-border-stronger' : 'fill-primary-bright'
}`}
/>
)}
<circle
r={4}
className={`cursor-pointer ${
hasNoDatabases
? 'fill-background-surface-300 stroke-border-stronger'
: hasPrimary
? 'fill-primary-bright stroke-brand-500'
: 'fill-brand-500 stroke-brand-400'
}`}
/>
</Marker>
)
})}
{tooltip !== undefined && zoom === 1.5 && (
<Marker coordinates={[tooltip.x - 47, tooltip.y - 5]}>
<foreignObject width={220} height={66.25}>
<div className="bg-studio/50 rounded-sm border">
<div className="px-3 py-2 flex flex-col">
<div className="flex items-center gap-x-2">
<RegionFlag className="w-4" region={tooltip.region.region ?? ''} />
<p className="text-[10px]">{tooltip.region.country}</p>
</div>
<p
className={`text-[10px] ${
tooltip.region.name === undefined ? 'text-foreground-light' : ''
}`}
>
{tooltip.region.name ?? 'No databases deployed'}
</p>
</div>
</div>
</foreignObject>
</Marker>
)}
</ZoomableGroup>
</ComposableMap>
{showRegionDetails && selectedRegion && (
<div className="absolute bottom-4 right-4 flex flex-col bg-studio/50 backdrop-blur-xs border rounded-sm w-[400px]">
<div className="flex items-center justify-between py-4 px-4 border-b">
<div>
<p className="text-xs text-foreground-light">
{databasesInSelectedRegion.length} database
{databasesInSelectedRegion.length > 1 ? 's' : ''} deployed in
</p>
<p className="text-sm">{selectedRegion.name}</p>
</div>
<RegionFlag className="w-10" region={selectedRegion.region} />
</div>
{databasesInSelectedRegion.length > 0 && (
<ScrollArea style={{ height: databasesInSelectedRegion.length > 2 ? '180px' : 'auto' }}>
<ul className={`flex flex-col divide-y`}>
{databasesInSelectedRegion.map((database) => {
const created = dayjs(database.inserted_at).format('DD MMM YYYY')
return (
<li
key={database.identifier}
className="text-sm px-4 py-2 flex items-center justify-between"
>
<div className="flex flex-col gap-y-1">
<p className="flex items-center gap-x-2">
{database.identifier === ref
? 'Primary Database'
: `Read Replica ${
database.identifier.length > 0 &&
`(ID: ${formatDatabaseID(database.identifier)})`
}`}
{database.status === REPLICA_STATUS.ACTIVE_HEALTHY ? (
<Badge variant="success">Healthy</Badge>
) : database.status === REPLICA_STATUS.COMING_UP ? (
<Badge>Coming up</Badge>
) : database.status === REPLICA_STATUS.RESTARTING ? (
<Badge>Restarting</Badge>
) : database.status === REPLICA_STATUS.RESIZING ? (
<Badge>Resizing</Badge>
) : (
<Badge variant="warning">Unhealthy</Badge>
)}
</p>
<div>
<p className="text-xs text-foreground-light">
AWS{projectHomepageShowInstanceSize ? ` • ${database.size}` : ''}
</p>
{database.identifier !== ref && (
<p className="text-xs text-foreground-light">
Created on:{' '}
<TimestampInfo label={created} utcTimestamp={database.inserted_at} />
</p>
)}
</div>
</div>
{database.identifier !== ref && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="text" icon={<MoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" side="bottom" align="end">
<DropdownMenuItem
className="gap-x-2"
disabled={database.status !== REPLICA_STATUS.ACTIVE_HEALTHY}
onClick={() => {
setShowConnect(true)
dbSelectorState.setSelectedDatabaseId(database.identifier)
}}
>
View connection string
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" asChild>
<Link href={getReadReplicaPath(ref, database.identifier)}>
Manage replica
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</li>
)
})}
</ul>
</ScrollArea>
)}
<div
className={`flex items-center justify-end gap-x-2 px-4 py-2 ${
databasesInSelectedRegion.length > 0 ? 'border-t' : ''
}`}
>
<Button
onClick={() => {
setCenter([14, 7])
setZoom(1.5)
}}
>
Close
</Button>
</div>
</div>
)}
</div>
)
}
export default MapView