import { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' import { api } from '../../api/client.js' // ── The spawn atlas ───────────────────────────────────────────────────────── // // What the shard CONTAINS, as opposed to what it is doing: which creatures // spawn, where, and which champion altars are configured. There is no live feed // here and no `connected` indicator, deliberately — this is parsed from the // shard's own files and stays complete while the shard is down. // // Facet names come from the shard's data, never from a list in this file. A // shard running custom maps gets its own names in the filter with no code // change (docs/link/v3.md §6.1 R2). const PAGE = 50 const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—') const TABS = [ { key: 'creatures', label: 'Creatures' }, { key: 'champions', label: 'Champion altars' }, { key: 'places', label: 'Places' }, ] function Chip({ active, onClick, children }) { return ( ) } function CreatureCard({ creature }) { const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1]) return (
{creature.name}
{facets.length === 0 ? '—' : facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
{num(creature.total)}
{num(creature.points)} spawners
) } // The creature list owns its own paging rather than going through useAsync: a // "load more" appends to what is already on screen, which a hook that resets to // `{ loading: true, data: null }` on every dependency change cannot express. function Creatures({ q, facet }) { const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 }) const [more, setMore] = useState(false) const load = useCallback( async (offset) => { const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset }) return page }, [q, facet], ) useEffect(() => { let alive = true setState({ loading: true, error: null, items: [], total: 0 }) load(0) .then((page) => { if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 }) }) .catch((error) => alive && setState({ loading: false, error, items: [], total: 0 })) return () => { alive = false } }, [load]) const loadMore = async () => { setMore(true) try { const page = await load(state.items.length) setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total })) } catch { // A failed "load more" leaves what is already on screen alone; the button // simply stays available to retry. } finally { setMore(false) } } if (state.loading) return if (state.error) return if (state.items.length === 0) { return Nothing in the atlas matches that. } return ( <>

Showing {num(state.items.length)} of {num(state.total)}

{state.items.map((c) => ( ))}
{state.items.length < state.total && (
)} ) } // The CONFIGURED altar roster — where the altars are and what each summons. The // live board ("it is on level 3 right now") is a different page, /site/champs, // fed by the sidecar. Both exist; they are not the same thing. function Champions({ facet }) { const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet]) if (loading) return if (error) return if (!data || data.length === 0) return No champion altars are configured. return (
{data.map((champ) => (
{champ.label || champ.name}
{champ.facet} {champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
{champ.randomType ? 'Random champion' : champ.type || '—'}
))}
) } // Regions and landmarks together: both answer "where is that?", and splitting // them into two tabs would make the visitor guess which list a name lives in. function Places({ q, facet }) { const { loading, error, data } = useAsync( () => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]), [q, facet], ) const rows = useMemo(() => { if (!data) return [] const [regions, landmarks] = data return [ ...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })), ...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })), ].sort((a, b) => a.name.localeCompare(b.name)) }, [data]) if (loading) return if (error) return if (rows.length === 0) return No regions or landmarks match that. return (
{rows.map((row) => (
{row.name} {row.facet} · {row.detail} {row.kind}
))}
) } export default function Atlas() { const [tab, setTab] = useState('creatures') const [input, setInput] = useState('') const [q, setQ] = useState('') const [facet, setFacet] = useState('') const meta = useAsync(() => api.atlas.meta()) // Debounced: typing "lizardman" should be one request, not nine. useEffect(() => { const timer = setTimeout(() => setQ(input.trim()), 250) return () => clearTimeout(timer) }, [input]) const facets = meta.data?.facets || [] const counts = meta.data?.counts || null const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null return (
{/* The atlas is only as good as its placement rate, so the page states it rather than implying every spawner resolved to a named place. */} {counts && (

{num(counts.creatures)} creatures across {num(counts.points)} spawners {Number.isFinite(counts.unresolvedPoints) && counts.points ? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark` : ''} {imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}

)}
{TABS.map((t) => ( setTab(t.key)}> {t.label} ))}
{tab !== 'champions' && ( setInput(e.target.value)} placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'} style={{ width: '100%', marginBottom: 12 }} /> )} {facets.length > 0 && (
setFacet('')}> All facets {facets.map((f) => ( setFacet(f)}> {f} ))}
)} {meta.error && } {!meta.error && !meta.loading && !imported && ( The spawn atlas has not been imported yet. )} {!meta.error && imported && ( <> {tab === 'creatures' && } {tab === 'champions' && } {tab === 'places' && } )}
) }