// ── The live map ──────────────────────────────────────────────────────────
//
// R9's page, as PLAN.md §30.2 drew it: the picture of the current map under
// four layers, each with its own switch, polled every ten seconds while the tab
// is visible (D14) and not at all while it is hidden.
//
// **Nothing here decides who may see what.** The server sends only the layers
// this viewer may see — a hidden one is absent from the answer, not present and
// hidden — so the checkboxes below are a reader's convenience and never a
// boundary. A layer the viewer cannot see is still LISTED, disabled, with who
// can: "staff only" explains an empty map where silence would imply an empty
// server (§23.3's shape).
//
// Leaflet arrives in a chunk of its own when this tab first mounts (D120, see
// `lib/leaflet.js`). The page draws with circle and div markers only, so
// Leaflet's image assets are never needed.
import { useEffect, useMemo, useRef, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import usePolled from '../hooks/usePolled.js'
import { ago } from '../lib/format.js'
import { boundsOf, countdown, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
import api, { BASE } from '../api.js'
const STYLE_ID = 'rust-leaflet-css'
/** The narrowest a grid cell may be on screen, in pixels, and still carry its label. */
const LABEL_MIN_CELL_PX = 30
/** The layers, in the order the legend lists them, with their marker colours. */
const LAYERS = [
{ id: 'world', label: 'Monuments & world events' },
{ id: 'events', label: 'Site events' },
{ id: 'players', label: 'Players' },
{ id: 'bases', label: 'Bases' },
]
const COLOURS = {
monument: '#e8d9a8',
cargo: '#4fc3f7',
heli: '#ef5350',
chinook: '#ffa726',
bradley: '#a1887f',
supply: '#66bb6a',
crate: '#ffee58',
event: '#ce93d8',
online: '#ffffff',
sleeping: '#9e9e9e',
tc: '#ff7043',
vending: '#26a69a',
self: '#00e5ff',
mate: '#7cffb2',
}
const WORLD_NAMES = {
cargo: 'Cargo ship',
heli: 'Patrol helicopter',
chinook: 'Chinook',
bradley: 'Bradley APC',
supply: 'Supply drop',
crate: 'Locked crate',
}
const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
export default function MapView({ serverId, online }) {
const { data: meta, loading, error } = useAsync(() => api.servers.map(serverId), [serverId])
const geometry = meta ? meta.geometry : null
const [leaflet, setLeaflet] = useState(null)
const [leafletError, setLeafletError] = useState(null)
const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, mates: true })
useEffect(() => {
let alive = true
import('../lib/leaflet.js')
.then((mod) => {
if (!alive) return
if (typeof document !== 'undefined' && !document.getElementById(STYLE_ID)) {
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = mod.css
document.head.appendChild(style)
}
setLeaflet(mod)
})
.catch((err) => alive && setLeafletError(err))
return () => {
alive = false
}
}, [])
const anyLive = Boolean(meta && (LAYERS.some((l) => meta.layers[l.id].visible) || meta.mates.visible))
const live = usePolled(() => api.servers.mapLive(serverId), {
key: serverId,
intervalMs: (meta && meta.pollMs) || 10_000,
enabled: Boolean(geometry) && anyLive,
})
const container = useRef(null)
const mapRef = useRef(null)
const groups = useRef(null)
// The map itself: made once per server and geometry, torn down with them.
useEffect(() => {
if (!leaflet || !geometry || !container.current) return undefined
const { L } = leaflet
const bounds = boundsOf(geometry)
const map = L.map(container.current, {
crs: L.CRS.Simple,
minZoom: -4,
maxZoom: 2,
zoomSnap: 0.25,
attributionControl: false,
// Some things sail off the edge: the rig's cargo ship spent the probe
// outside the picture entirely. The view may follow them a little way.
maxBounds: L.latLngBounds(bounds).pad(0.5),
})
if (meta.picture) L.imageOverlay(`${BASE}${meta.picture.path}`, bounds).addTo(map)
map.fitBounds(bounds)
const made = {}
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates']) made[id] = L.layerGroup().addTo(map)
// The labels are a layer of their own inside the grid's, shown only when a cell
// is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
// labels overlap into a wall of text (found on the phase 14 walk).
const labels = L.layerGroup()
const cellPx = () => geometry.gridCellSize * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize) * 2 ** map.getZoom()
const fitLabels = () => {
const want = cellPx() >= LABEL_MIN_CELL_PX
if (want && !made.grid.hasLayer(labels)) made.grid.addLayer(labels)
if (!want && made.grid.hasLayer(labels)) made.grid.removeLayer(labels)
}
map.on('zoomend', fitLabels)
const g = grid(geometry)
for (const [[x1, z1], [x2, z2]] of g.lines) {
L.polyline([toLatLng(geometry, x1, z1), toLatLng(geometry, x2, z2)], {
color: '#ffffff',
weight: 1,
opacity: 0.18,
interactive: false,
}).addTo(made.grid)
}
for (const label of g.labels) {
L.marker(toLatLng(geometry, label.x, label.z), {
interactive: false,
keyboard: false,
icon: L.divIcon({
className: '',
html: `${label.text}`,
iconSize: null,
iconAnchor: [0, 0],
}),
}).addTo(labels)
}
fitLabels()
for (const m of meta.monuments || []) {
L.circleMarker(toLatLng(geometry, m.x, m.z), {
radius: 4,
color: '#000',
weight: 1,
fillColor: COLOURS.monument,
fillOpacity: 0.9,
})
.bindTooltip(`${escape(m.label)} · ${escape(m.grid || gridLabel(geometry, m.x, m.z) || '')}`)
.addTo(made.monuments)
}
mapRef.current = map
groups.current = made
return () => {
map.remove()
mapRef.current = null
groups.current = null
}
// `meta` is replaced only when the server changes, with the geometry.
}, [leaflet, geometry]) // eslint-disable-line react-hooks/exhaustive-deps
// What moves: every group cleared and redrawn from the latest answer. The
// counts are small — a busy server is a few hundred markers — and a redraw is
// simpler to get right than a diff.
useEffect(() => {
const made = groups.current
if (!leaflet || !made || !geometry) return
const { L } = leaflet
const answer = live.data || {}
const at = (p) => toLatLng(geometry, p.x, p.z)
for (const id of ['world', 'events', 'players', 'bases', 'mates']) made[id].clearLayers()
for (const w of answer.world || []) {
let tip = WORLD_NAMES[w.kind] || w.kind
if (w.kind === 'crate' && w.hackLeftSec != null) tip += ` — ${countdown(w.hackLeftSec)} left on the hack`
if (w.kind === 'crate' && w.hacked) tip += ' — hacked'
dot(L, at(w), COLOURS[w.kind] || COLOURS.crate, w.kind === 'cargo' ? 7 : 5).bindTooltip(escape(tip)).addTo(made.world)
}
const px = (metres) => metres * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize)
for (const e of answer.events || []) {
if (e.kind === 'zone') {
L.circle(at(e), { radius: px(Number(e.radius) || 0), color: COLOURS.event, weight: 2, fillOpacity: 0.12 })
.bindTooltip(escape(e.name ? `Event zone · ${e.name}` : 'Event zone'))
.addTo(made.events)
} else {
dot(L, at(e), COLOURS.event, 5).bindTooltip(e.kind === 'npc' ? 'Event NPC' : 'Event crate').addTo(made.events)
}
}
for (const p of answer.players || []) {
dot(L, at(p), p.online ? COLOURS.online : COLOURS.sleeping, p.online ? 5 : 4)
.bindTooltip(escape(`${p.name || p.steamId}${p.online ? (p.sleeping ? ' · sleeping' : '') : ' · asleep, offline'}`))
.addTo(made.players)
}
for (const b of answer.bases || []) {
dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
}
for (const m of answer.mates || []) {
dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2)
.bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
.addTo(made.mates)
}
}, [leaflet, geometry, live.data])
// The legend's checkboxes: a group is on the map or off it.
useEffect(() => {
const map = mapRef.current
const made = groups.current
if (!map || !made) return
const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, mates: shown.mates }
for (const [id, on] of Object.entries(want)) {
if (on && !map.hasLayer(made[id])) made[id].addTo(map)
if (!on && map.hasLayer(made[id])) map.removeLayer(made[id])
}
}, [shown, leaflet, geometry])
const status = useMemo(() => liveStatus(live, anyLive, online), [live, anyLive, online])
if (loading) return
This server has no picture of its map, so the layers are drawn on a plain background.
)}{status}