// ── 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 if (error) return if (!geometry) { return ( ) } if (leafletError) return return (
{!meta.picture && (

This server has no picture of its map, so the layers are drawn on a plain background.

)}
{!leaflet && (
)}

{status}

setShown((s) => ({ ...s, [id]: !s[id] }))} />
) } function Legend({ meta, live, shown, onToggle }) { const row = (id, label, swatches, visible, note) => (
  • {swatches.map((c) => ( {note && {note}}
  • ) const hiddenNote = (layer) => { const words = AUDIENCE_WORDS[layer.audience] || AUDIENCE_WORDS.staff if (layer.audience === 'signed_in') return 'Sign in to see this layer.' return `Shown to ${words}.` } const swatches = { world: [COLOURS.monument, COLOURS.cargo, COLOURS.heli, COLOURS.crate], events: [COLOURS.event], players: [COLOURS.online, COLOURS.sleeping], bases: [COLOURS.tc, COLOURS.vending], } return (
      {row('grid', 'Grid', [], true, null)} {LAYERS.map((l) => { const layer = meta.layers[l.id] let note = layer.visible ? null : hiddenNote(layer) if (l.id === 'players' && layer.cappedByPresence && !layer.visible) { note = `${note} Limited by who may see who is online.` } if (layer.visible && l.id === 'players' && live && live.playersTruncated) note = 'Not every sleeper is shown.' if (layer.visible && l.id === 'bases' && live && live.basesTruncated) note = 'Not every base is shown.' return row(l.id, l.label, swatches[l.id], layer.visible, note) })} {meta.mates.visible && row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], true, 'Your own position, and clan mates who are online.')} {!meta.mates.visible && meta.mates.on && meta.mates.signedIn && !meta.mates.linked && row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], false, 'Link your Steam account to see yourself and your clan here.')}
    ) } function dot(L, latlng, colour, radius, weight = 1) { return L.circleMarker(latlng, { radius, color: '#000', weight, fillColor: colour, fillOpacity: 0.95 }) } /** Tooltips are HTML in Leaflet, and a player's name is text they typed. */ function escape(text) { return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) } function liveStatus(live, anyLive, online) { if (!anyLive) return 'No moving layers are shown to you on this server.' if (live.loading) return 'Asking the server where things are…' const data = live.data if (data && data.live === false) { return online ? 'The server did not say where things are just now; it is asked again every ten seconds.' : 'The server is offline, so nothing is moving on its map.' } if (live.error && !data) return 'Positions could not be loaded.' return live.at ? `Positions as of ${ago(new Date(live.at).toISOString())}, refreshed every ten seconds while this tab is open.` : '' }