71 lines
5.0 KiB
TypeScript
71 lines
5.0 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState, type ReactNode } from "react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { ospfNodeState, type OspfNeighborState } from "@/lib/ospf-state"
|
||
|
||
export interface OspfTopologyNode { id: string; label: string; short: string; site: string; x: number; y: number }
|
||
export interface OspfTopologyEdge { from: string; to: string; state: OspfNeighborState; cost: number | null }
|
||
interface TopologyView {
|
||
nodes: OspfTopologyNode[]
|
||
edges: OspfTopologyEdge[]
|
||
nodeStates: ReadonlyMap<string, ReturnType<typeof ospfNodeState>>
|
||
}
|
||
|
||
export function OspfTopologyScope({ nodes, edges, selectedId, onSelect, width, height, children }: {
|
||
nodes: OspfTopologyNode[]; edges: OspfTopologyEdge[]; selectedId: string | null
|
||
onSelect: (id: string | null) => void; width: number; height: number
|
||
children: (view: TopologyView) => ReactNode
|
||
}) {
|
||
const focal = nodes.find(node => node.id === selectedId) ?? nodes[0]
|
||
const [context, setContext] = useState(focal?.id)
|
||
const [page, setPage] = useState(0)
|
||
const [search, setSearch] = useState("")
|
||
const incident = useMemo(() => edges.filter(edge => edge.from === focal?.id || edge.to === focal?.id), [edges, focal?.id])
|
||
const pageCount = Math.max(1, Math.ceil(incident.length / 8))
|
||
if (context !== focal?.id) { setContext(focal?.id); setPage(0) }
|
||
if (page > pageCount - 1) setPage(pageCount - 1)
|
||
const currentPage = context === focal?.id ? Math.min(page, pageCount - 1) : 0
|
||
const nodeStates = useMemo(() => {
|
||
const states = new Map<string, OspfNeighborState[]>()
|
||
for (const edge of edges) for (const id of new Set([edge.from, edge.to])) {
|
||
const values = states.get(id) ?? []; values.push(edge.state); states.set(id, values)
|
||
}
|
||
return new Map(nodes.map(node => [node.id, ospfNodeState(states.get(node.id) ?? [])]))
|
||
}, [nodes, edges])
|
||
const view = useMemo(() => {
|
||
if (nodes.length <= 12 && edges.length <= 12) return { nodes, edges, nodeStates }
|
||
const visibleEdges = incident.slice(currentPage * 8, (currentPage + 1) * 8)
|
||
const ids = new Set([focal?.id, ...visibleEdges.flatMap(edge => [edge.from, edge.to])])
|
||
const peers = nodes.filter(node => node.id !== focal?.id && ids.has(node.id))
|
||
const radiusX = width * 0.39, radiusY = height * 0.30
|
||
const positioned = peers.map((node, index) => {
|
||
const angle = peers.length === 1 ? 0 : 2 * Math.PI * index / peers.length - Math.PI / 2
|
||
return { ...node, x: width / 2 + radiusX * Math.cos(angle), y: height / 2 + radiusY * Math.sin(angle) }
|
||
})
|
||
return { nodes: focal ? [{ ...focal, x: width / 2, y: height / 2 }, ...positioned] : [], edges: visibleEdges, nodeStates }
|
||
}, [nodes, edges, nodeStates, focal, incident, currentPage, width, height])
|
||
const matches = useMemo(() => {
|
||
const query = search.trim().toLowerCase()
|
||
return nodes.filter(node => !query || `${node.label} ${node.id}`.toLowerCase().includes(query))
|
||
}, [nodes, search])
|
||
if (nodes.length <= 12 && edges.length <= 12) return children(view)
|
||
return <section aria-label="Область топологии OSPF" className="flex min-w-0 flex-col gap-3">
|
||
<div className="rounded-xl border border-border bg-background px-4 py-3 flex min-w-0 flex-col gap-3">
|
||
<p className="text-sm font-medium">Связи узла: <span className="font-mono break-all">{focal?.label}</span></p>
|
||
<p role="status" className="text-xs text-muted-foreground">Показаны {view.nodes.length} из {nodes.length} узлов. Связи узла: {incident.length ? currentPage * 8 + 1 : 0}–{Math.min((currentPage + 1) * 8, incident.length)} из {incident.length}. Всего в топологии {edges.length} связей.</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущая страница связей OSPF" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Назад</Button>
|
||
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующая страница связей OSPF" disabled={currentPage >= pageCount - 1} onClick={() => setPage(currentPage + 1)}>Далее</Button>
|
||
</div>
|
||
<Input aria-label="Найти узел топологии" placeholder="Имя или Router ID узла…" value={search} onChange={event => setSearch(event.target.value)} />
|
||
<p className="text-xs text-muted-foreground" aria-live="polite">Найдено {matches.length}; показаны первые {Math.min(5, matches.length)}. Уточните поиск, чтобы найти другой узел.</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{matches.slice(0, 5).map(node => <Button key={node.id} type="button" variant="outline" className="min-h-11 max-w-full" aria-label={`Открыть узел топологии ${node.label} (${node.id})`} onClick={() => onSelect(node.id)}><span className="truncate font-mono">{node.label}</span></Button>)}
|
||
</div>
|
||
</div>
|
||
{children(view)}
|
||
</section>
|
||
}
|