fix(ui): ограничить граф OSPF с сохранением полного контекста
This commit is contained in:
1 parent
1a82c1b41f
commit
8d86c5cbb0
10 files changed
+194
-13
No files matched your search
@@ -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>
|
||||
}
|
||||
Reference in new issue
Block a user