diff --git a/api/handlers/topology.go b/api/handlers/topology.go index 424669af1..61bfff64f 100644 --- a/api/handlers/topology.go +++ b/api/handlers/topology.go @@ -34,6 +34,8 @@ type Device struct { Status string `json:"status"` DeviceType string `json:"device_type"` MetroPK string `json:"metro_pk"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` ContributorPK string `json:"contributor_pk"` ContributorCode string `json:"contributor_code"` UserCount uint64 `json:"user_count"` @@ -238,11 +240,14 @@ func (a *API) FetchTopologyData(ctx context.Context) (TopologyResponse, error) { WHEN ts.total_lamports > 0 THEN COALESCE(ds.stake_sol, 0) * 1e9 / ts.total_lamports * 100 ELSE 0 END as stake_share, + COALESCE(f.lat, 0) as latitude, + COALESCE(f.lng, 0) as longitude, COALESCE(d.interfaces, '[]') as interfaces FROM dz_devices_current d CROSS JOIN total_stake ts LEFT JOIN device_stats ds ON d.pk = ds.device_pk LEFT JOIN dz_contributors_current c ON d.contributor_pk = c.pk + LEFT JOIN dz_facilities_current f ON d.location_pk = f.pk WHERE d.status = 'activated' ` rows, err := a.envDB(ctx).Query(ctx, query) @@ -254,7 +259,7 @@ func (a *API) FetchTopologyData(ctx context.Context) (TopologyResponse, error) { for rows.Next() { var d Device var interfacesJSON string - if err := rows.Scan(&d.PK, &d.Code, &d.Status, &d.DeviceType, &d.MetroPK, &d.ContributorPK, &d.ContributorCode, &d.UserCount, &d.UnicastUsersCount, &d.MulticastSubscribersCount, &d.MulticastPublishersCount, &d.MaxUnicastUsers, &d.MaxMulticastSubscribers, &d.MaxMulticastPublishers, &d.ValidatorCount, &d.StakeSol, &d.StakeShare, &interfacesJSON); err != nil { + if err := rows.Scan(&d.PK, &d.Code, &d.Status, &d.DeviceType, &d.MetroPK, &d.ContributorPK, &d.ContributorCode, &d.UserCount, &d.UnicastUsersCount, &d.MulticastSubscribersCount, &d.MulticastPublishersCount, &d.MaxUnicastUsers, &d.MaxMulticastSubscribers, &d.MaxMulticastPublishers, &d.ValidatorCount, &d.StakeSol, &d.StakeShare, &d.Latitude, &d.Longitude, &interfacesJSON); err != nil { return err } if err := json.Unmarshal([]byte(interfacesJSON), &d.Interfaces); err != nil { diff --git a/web/src/components/topology-globe.tsx b/web/src/components/topology-globe.tsx index 1a2fa681f..99d4d9f85 100644 --- a/web/src/components/topology-globe.tsx +++ b/web/src/components/topology-globe.tsx @@ -5,6 +5,7 @@ import Globe from 'react-globe.gl' import type { GlobeInstance } from 'react-globe.gl' import { useQuery } from '@tanstack/react-query' import type { TopologyMetro, TopologyDevice, TopologyLink, TopologyValidator, MultiPathResponse, SimulateLinkRemovalResponse, SimulateLinkAdditionResponse, WhatIfRemovalResponse, MetroDevicePathsResponse } from '@/lib/api' +import { computeDevicePositions } from './topology/devicePositions' import { fetchISISPaths, fetchISISTopology, fetchCriticalLinks, fetchSimulateLinkRemoval, fetchSimulateLinkAddition, fetchWhatIfRemoval, fetchLinkHealth, fetchTopologyCompare, fetchMetroDevicePaths } from '@/lib/api' import { useTopology, useMulticastState, TopologyControlBar, TopologyPanel, DeviceDetails, LinkDetails, MetroDetails, ValidatorDetails, EntityLink as TopologyEntityLink, PathModePanel, MetroPathModePanel, CriticalityPanel, WhatIfRemovalPanel, WhatIfAdditionPanel, ImpactPanel, ComparePanel, StakeOverlayPanel, LinkHealthOverlayPanel, TrafficFlowOverlayPanel, MetroClusteringOverlayPanel, ContributorsOverlayPanel, ValidatorsOverlayPanel, DeviceTypeOverlayPanel, LinkTypeOverlayPanel, MulticastTreesOverlayPanel, LINK_TYPE_COLORS, MULTICAST_PUBLISHER_COLORS, type DeviceOption, type MetroOption } from '@/components/topology' import type { LinkInfo, SelectedItemData } from '@/components/topology' @@ -281,22 +282,6 @@ function arcAnimateTime(avgLatencyUs: number): number { } // Calculate device position with radial offset around metro center -function calculateDevicePosition( - metroLat: number, - metroLng: number, - deviceIndex: number, - totalDevices: number -): { lat: number; lng: number } { - if (totalDevices === 1) { - return { lat: metroLat, lng: metroLng } - } - const radius = 0.3 - const angle = (2 * Math.PI * deviceIndex) / totalDevices - const latOffset = radius * Math.cos(angle) - const lngOffset = radius * Math.sin(angle) / Math.cos(metroLat * Math.PI / 180) - return { lat: metroLat + latOffset, lng: metroLng + lngOffset } -} - export function TopologyGlobe({ metros, devices, links, validators }: TopologyGlobeProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -600,17 +585,16 @@ export function TopologyGlobe({ metros, devices, links, validators }: TopologyGl }, [links]) // Device positions + // Facility-anchored when the precise-locations toggle is on, otherwise a tight + // metro-centroid fanout (#652). Shared helper returns [lng, lat]; globe uses {lat, lng}. const devicePositions = useMemo(() => { const positions = new Map() - for (const [metroPk, metroDevices] of devicesByMetro) { - const metro = metroMap.get(metroPk) - if (!metro) continue - metroDevices.forEach((device, index) => { - positions.set(device.pk, calculateDevicePosition(metro.latitude, metro.longitude, index, metroDevices.length)) - }) + const tuples = computeDevicePositions(devices, metroMap, overlays.preciseLocations ? 'facility' : 'fanout') + for (const [pk, [lng, lat]] of tuples) { + positions.set(pk, { lat, lng }) } return positions - }, [devicesByMetro, metroMap]) + }, [devices, metroMap, overlays.preciseLocations]) // ─── Derived overlay maps ──────────────────────────────────────────── diff --git a/web/src/components/topology-map.tsx b/web/src/components/topology-map.tsx index 8a0ee7a49..5816b2a95 100644 --- a/web/src/components/topology-map.tsx +++ b/web/src/components/topology-map.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { useTheme } from '@/hooks/use-theme' import type { TopologyMetro, TopologyDevice, TopologyLink, TopologyValidator, MultiPathResponse, SimulateLinkRemovalResponse, SimulateLinkAdditionResponse, WhatIfRemovalResponse, MetroDevicePathsResponse } from '@/lib/api' import { fetchISISPaths, fetchISISTopology, fetchCriticalLinks, fetchSimulateLinkRemoval, fetchSimulateLinkAddition, fetchWhatIfRemoval, fetchLinkHealth, fetchTopologyCompare, fetchMetroDevicePaths } from '@/lib/api' +import { computeDevicePositions } from './topology/devicePositions' import { useTopology, useMulticastState, TopologyControlBar, TopologyPanel, DeviceDetails, LinkDetails, MetroDetails, ValidatorDetails, EntityLink as TopologyEntityLink, PathModePanel, MetroPathModePanel, CriticalityPanel, WhatIfRemovalPanel, WhatIfAdditionPanel, ImpactPanel, ComparePanel, StakeOverlayPanel, LinkHealthOverlayPanel, TrafficFlowOverlayPanel, MetroClusteringOverlayPanel, ContributorsOverlayPanel, ValidatorsOverlayPanel, DeviceTypeOverlayPanel, LinkTypeOverlayPanel, MulticastTreesOverlayPanel, FlexAlgoOverlayPanel, LINK_TYPE_COLORS, MULTICAST_PUBLISHER_COLORS, type DeviceOption, type MetroOption } from '@/components/topology' import { useActiveOpsTickets } from '@/hooks/use-ops-tickets' import { opsTicketUrl } from '@/lib/ops-api' @@ -252,27 +253,6 @@ type SelectedItem = | { type: 'metro'; data: HoveredMetroInfo } | { type: 'validator'; data: HoveredValidatorInfo } -// Calculate device position with radial offset for multiple devices at same metro -function calculateDevicePosition( - metroLat: number, - metroLng: number, - deviceIndex: number, - totalDevices: number -): [number, number] { - if (totalDevices === 1) { - return [metroLng, metroLat] - } - - // Distribute devices in a circle around metro center - const radius = 0.3 // degrees offset - const angle = (2 * Math.PI * deviceIndex) / totalDevices - const latOffset = radius * Math.cos(angle) - // Adjust for latitude distortion - const lngOffset = radius * Math.sin(angle) / Math.cos(metroLat * Math.PI / 180) - - return [metroLng + lngOffset, metroLat + latOffset] -} - // Calculate curved path between two points (returns GeoJSON coordinates [lng, lat]) function calculateCurvedPath( start: [number, number], @@ -924,27 +904,12 @@ export function TopologyMap({ metros, devices, links, validators }: TopologyMapP return map }, [links]) - // Calculate device positions - const devicePositions = useMemo(() => { - const positions = new Map() - - for (const [metroPk, metroDevices] of devicesByMetro) { - const metro = metroMap.get(metroPk) - if (!metro) continue - - metroDevices.forEach((device, index) => { - const pos = calculateDevicePosition( - metro.latitude, - metro.longitude, - index, - metroDevices.length - ) - positions.set(device.pk, pos) - }) - } - - return positions - }, [devicesByMetro, metroMap]) + // Calculate device positions ([lng, lat]). Facility-anchored when the + // precise-locations toggle is on, otherwise a tight metro-centroid fanout (#652). + const devicePositions = useMemo( + () => computeDevicePositions(devices, metroMap, overlays.preciseLocations ? 'facility' : 'fanout'), + [devices, metroMap, overlays.preciseLocations], + ) // Map style based on theme const mapStyle = useMemo(() => createMapStyle(isDark), [isDark]) diff --git a/web/src/components/topology/TopologyContext.tsx b/web/src/components/topology/TopologyContext.tsx index 227259f2b..70879ab8f 100644 --- a/web/src/components/topology/TopologyContext.tsx +++ b/web/src/components/topology/TopologyContext.tsx @@ -47,6 +47,7 @@ export interface OverlayState { // Independent overlays multicastTrees: boolean // Multicast tree visualization userCounts: boolean // Show U/S/P user count badges on devices + preciseLocations: boolean // Anchor device markers at real facility coordinates (vs metro-centroid fanout) } // Context value type @@ -125,6 +126,7 @@ function parseOverlaysFromUrl(param: string | null, view: 'map' | 'graph' | 'glo flexAlgo: false, multicastTrees: false, userCounts: false, + preciseLocations: false, } if (!param) return defaultState @@ -145,6 +147,7 @@ function parseOverlaysFromUrl(param: string | null, view: 'map' | 'graph' | 'glo flexAlgo: false, multicastTrees: false, userCounts: false, + preciseLocations: false, } const activeOverlays = param.split(',').filter(Boolean) for (const overlay of activeOverlays) { diff --git a/web/src/components/topology/TopologyControlBar.tsx b/web/src/components/topology/TopologyControlBar.tsx index 30a1faf24..d7709ad0e 100644 --- a/web/src/components/topology/TopologyControlBar.tsx +++ b/web/src/components/topology/TopologyControlBar.tsx @@ -16,6 +16,7 @@ import { Activity, BarChart3, MapPin, + LocateFixed, GitCompare, ChevronLeft, ChevronRight, @@ -491,6 +492,18 @@ export function TopologyControlBar({ /> )} + {(view === 'map' || view === 'globe') && ( + } + label="Precise locations" + tooltip="Place devices at their real facility coordinates instead of spreading them around the metro center" + onClick={() => handleToggleOverlay('preciseLocations')} + active={overlays.preciseLocations} + activeColor="blue" + collapsed={collapsed} + /> + )} + {/* Link Overlays */} diff --git a/web/src/components/topology/devicePositions.test.ts b/web/src/components/topology/devicePositions.test.ts new file mode 100644 index 000000000..76ac89a3a --- /dev/null +++ b/web/src/components/topology/devicePositions.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { + computeDevicePositions, + FANOUT_RADIUS_DEG, + FACILITY_JITTER_DEG, + type PositionableDevice, + type PositionableMetro, +} from './devicePositions' + +const metroMap = new Map([ + ['metro-nyc', { latitude: 40.779, longitude: -74.072 }], +]) + +function dev(pk: string, over: Partial = {}): PositionableDevice { + return { pk, metro_pk: 'metro-nyc', latitude: 0, longitude: 0, ...over } +} + +function dist(a: [number, number], b: [number, number]): number { + return Math.hypot(a[0] - b[0], a[1] - b[1]) +} + +describe('computeDevicePositions — fanout mode', () => { + it('places a lone device at the metro center', () => { + const pos = computeDevicePositions([dev('d1')], metroMap, 'fanout') + expect(pos.get('d1')).toEqual([-74.072, 40.779]) + }) + + it('spreads co-metro devices within the (small) fanout radius and keeps them distinct', () => { + const devices = [dev('d1'), dev('d2'), dev('d3'), dev('d4')] + const pos = computeDevicePositions(devices, metroMap, 'fanout') + const center: [number, number] = [-74.072, 40.779] + const coords = devices.map(d => pos.get(d.pk)!) + // every marker stays close to the metro center (latitude offset bounded by the radius) + for (const c of coords) { + expect(Math.abs(c[1] - center[1])).toBeLessThanOrEqual(FANOUT_RADIUS_DEG + 1e-9) + expect(c).not.toEqual(center) + } + // markers are distinct from one another + const keys = new Set(coords.map(c => `${c[0]},${c[1]}`)) + expect(keys.size).toBe(4) + }) +}) + +describe('computeDevicePositions — facility mode', () => { + it('anchors a lone device exactly at its facility coordinates', () => { + const pos = computeDevicePositions( + [dev('d1', { latitude: 40.7968, longitude: -74.03088 })], + metroMap, + 'facility', + ) + expect(pos.get('d1')).toEqual([-74.03088, 40.7968]) + }) + + it('jitters co-located devices around the shared facility, within the jitter radius', () => { + const fac = { latitude: 40.7968, longitude: -74.03088 } + const devices = [ + dev('d1', fac), + dev('d2', fac), + dev('d3', fac), + ] + const pos = computeDevicePositions(devices, metroMap, 'facility') + const anchor: [number, number] = [fac.longitude, fac.latitude] + const coords = devices.map(d => pos.get(d.pk)!) + for (const c of coords) { + // stays within the small jitter radius of the real facility (not flung away) + expect(dist(c, anchor)).toBeLessThanOrEqual(FACILITY_JITTER_DEG / Math.cos(fac.latitude * Math.PI / 180) + 1e-6) + } + const keys = new Set(coords.map(c => `${c[0]},${c[1]}`)) + expect(keys.size).toBe(3) + }) + + it('falls back to the metro fanout when a device has no facility coordinates', () => { + // facility coords 0/0 => missing; should NOT end up at [0,0] + const pos = computeDevicePositions([dev('d1')], metroMap, 'facility') + expect(pos.get('d1')).toEqual([-74.072, 40.779]) + }) +}) diff --git a/web/src/components/topology/devicePositions.ts b/web/src/components/topology/devicePositions.ts new file mode 100644 index 000000000..d2d880014 --- /dev/null +++ b/web/src/components/topology/devicePositions.ts @@ -0,0 +1,113 @@ +// Shared device map/globe positioning. +// +// Device markers are positioned one of two ways (#652): +// - 'fanout' (default): synthetic spread around the metro centroid, kept tight +// so co-located devices stay separated without landing in the ocean. +// - 'facility' (toggle): anchored at the device's real facility coordinates, with a +// small jitter so devices sharing a facility remain individually clickable. +// Devices with no facility coordinates fall back to the metro fanout. +// +// Returns positions as [lng, lat] tuples (GeoJSON order); the globe adapts to {lat, lng}. + +export type DevicePositionMode = 'fanout' | 'facility' + +export interface PositionableDevice { + pk: string + metro_pk: string + // Facility coordinates; 0/0 means unknown (no facility coords available). + latitude: number + longitude: number +} + +export interface PositionableMetro { + latitude: number + longitude: number +} + +// Fanout spread around the metro centroid (~3 mi). Small enough that coastal +// metros no longer fling markers offshore, large enough to separate devices. +export const FANOUT_RADIUS_DEG = 0.04 + +// Jitter around a shared facility for co-located devices (~0.5 mi). +export const FACILITY_JITTER_DEG = 0.008 + +function hasFacilityCoords(d: PositionableDevice): boolean { + return d.latitude !== 0 || d.longitude !== 0 +} + +// Distribute `total` points evenly on a circle of `radius` degrees around a center. +// A lone point sits exactly on the center. Returns [lng, lat]. +function radialOffset( + centerLat: number, + centerLng: number, + index: number, + total: number, + radius: number, +): [number, number] { + if (total <= 1) { + return [centerLng, centerLat] + } + const angle = (2 * Math.PI * index) / total + const latOffset = radius * Math.cos(angle) + // Correct for longitude compression away from the equator. + const lngOffset = (radius * Math.sin(angle)) / Math.cos((centerLat * Math.PI) / 180) + return [centerLng + lngOffset, centerLat + latOffset] +} + +// Spread devices around their metro centroids and write the results into `out`. +function fanoutByMetro( + devices: PositionableDevice[], + metroMap: Map, + out: Map, +): void { + const byMetro = new Map() + for (const d of devices) { + const group = byMetro.get(d.metro_pk) + if (group) group.push(d) + else byMetro.set(d.metro_pk, [d]) + } + for (const [metroPk, group] of byMetro) { + const metro = metroMap.get(metroPk) + if (!metro) continue + group.forEach((d, i) => { + out.set(d.pk, radialOffset(metro.latitude, metro.longitude, i, group.length, FANOUT_RADIUS_DEG)) + }) + } +} + +// Compute [lng, lat] positions for every device, keyed by device pk. +export function computeDevicePositions( + devices: PositionableDevice[], + metroMap: Map, + mode: DevicePositionMode, +): Map { + const positions = new Map() + + if (mode === 'facility') { + // Devices with real facility coords are jittered around the shared facility. + const byFacility = new Map() + const missing: PositionableDevice[] = [] + for (const d of devices) { + if (!hasFacilityCoords(d)) { + missing.push(d) + continue + } + const key = `${d.latitude},${d.longitude}` + const group = byFacility.get(key) + if (group) group.push(d) + else byFacility.set(key, [d]) + } + for (const group of byFacility.values()) { + const { latitude, longitude } = group[0] + group.forEach((d, i) => { + positions.set(d.pk, radialOffset(latitude, longitude, i, group.length, FACILITY_JITTER_DEG)) + }) + } + // Devices without facility coords fall back to the metro fanout. + fanoutByMetro(missing, metroMap, positions) + return positions + } + + fanoutByMetro(devices, metroMap, positions) + return positions +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 36a0257e8..aaf6eaf78 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1619,6 +1619,9 @@ export interface TopologyDevice { status: string device_type: string metro_pk: string + // Facility coordinates (0/0 when the device has no facility coords). + latitude: number + longitude: number contributor_pk: string contributor_code: string user_count: number