fix(ui): ограничить граф OSPF с сохранением полного контекста

This commit is contained in:
Denozordec committed 2026-10-04 17:25:55 +07:00
1 parent 1a82c1b41f
commit 8d86c5cbb0
10 files changed
+194 -13

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
"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 = 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>
}