feat(admin-ui): Graph view с d3-force (Stage 3.3)

This commit is contained in:
Александр Зимин
2026-05-11 08:55:53 +00:00
parent 098907a89b
commit ec713f9704
4 changed files with 385 additions and 13 deletions
@@ -0,0 +1,334 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
type Simulation,
type SimulationNodeDatum,
type SimulationLinkDatum,
} from 'd3-force'
import { useDictionaries, dictionaryDependentsQuery } from '@/api/queries'
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
import { LoadingBlock } from '@/ui'
import { cn } from '@/lib/utils'
/**
* Граф связей справочников — d3-force network diagram.
*
* Nodes: справочники (color по bundle, size по recordCount).
* Edges: incoming FK refs (от useDictionaryDependents).
*
* Layout: d3-force simulation
* - forceLink — keeps connected nodes близко
* - forceManyBody (charge) — отталкивает несоседних узлов
* - forceCenter — anchored к (w/2, h/2)
* - forceCollide — prevent overlap node circles
*
* Interaction:
* - Hover node → highlight node + connected edges + neighbors
* - Click node → navigate /dictionaries/$name
* - Drag node — temp pin position, click empty space → unpin all
*
* Performance: 40+ nodes SVG rendering OK. Above ~200 — switch на canvas.
*/
type GraphNode = SimulationNodeDatum & {
id: string
name: string
displayName: string
bundle: string
scope: string
recordCount: number
}
type GraphLink = SimulationLinkDatum<GraphNode> & {
sourceField: string
targetField: string
}
const BUNDLE_COLORS: Record<string, string> = {
cuod: '#b05a2e', // accent (terracotta)
geo: '#4f7038', // green
comms: '#a8762a', // warn
delivery: '#3a2818', // navy
default: '#52483d', // ink-2
}
function colorForBundle(bundle: string): string {
return BUNDLE_COLORS[bundle] ?? BUNDLE_COLORS.default
}
export function DictionaryGraph() {
const dictsQuery = useDictionaries()
const dicts = dictsQuery.data ?? []
// Fetch dependents для всех dicts параллельно (cached между mounts).
const dependentsResults = useQueries({
queries: dicts.map((d) => ({
...dictionaryDependentsQuery(d.name),
enabled: Boolean(d.name),
})),
})
const dependentsByDict = useMemo(() => {
const m = new Map<string, SchemaDependent[]>()
dicts.forEach((d, i) => {
m.set(d.name, dependentsResults[i]?.data ?? [])
})
return m
}, [dicts, dependentsResults])
const allDependentsLoading = dependentsResults.some((r) => r.isLoading)
if (dictsQuery.isLoading) {
return <LoadingBlock size="lg" label="Загрузка справочников…" />
}
if (dictsQuery.error) {
return <div className="text-sm text-pink">Ошибка загрузки: {String(dictsQuery.error)}</div>
}
if (dicts.length === 0) {
return <div className="text-sm text-mute">Справочники не найдены.</div>
}
return (
<GraphCanvas
dicts={dicts}
dependentsByDict={dependentsByDict}
depsLoading={allDependentsLoading}
/>
)
}
function GraphCanvas({
dicts,
dependentsByDict,
depsLoading,
}: {
dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]>
depsLoading: boolean
}) {
const navigate = useNavigate()
const wrapperRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const [size, setSize] = useState({ width: 800, height: 600 })
const [hoveredId, setHoveredId] = useState<string | null>(null)
// Force-rerender tick. Simulation .on('tick') увеличивает это значение,
// React видит state change → перерисовывает SVG с новыми node.x/y. Не
// читаем value напрямую, только инкрементируем — eslint complains, поэтому
// используем void.
const [, setTick] = useState(0)
const simulationRef = useRef<Simulation<GraphNode, GraphLink> | null>(null)
const nodesRef = useRef<GraphNode[]>([])
const linksRef = useRef<GraphLink[]>([])
// Build initial graph data.
const { nodes, links } = useMemo(() => {
const nodes: GraphNode[] = dicts.map((d) => ({
id: d.name,
name: d.name,
displayName: d.displayName ?? d.name,
bundle: d.bundle,
scope: d.scope,
recordCount: d.recordCount ?? 0,
}))
const links: GraphLink[] = []
dicts.forEach((target) => {
const deps = dependentsByDict.get(target.name) ?? []
deps.forEach((dep) => {
// Edge: source dict → target dict (source has FK к target).
if (nodes.find((n) => n.id === dep.sourceDict)) {
links.push({
source: dep.sourceDict,
target: target.name,
sourceField: dep.sourceField,
targetField: dep.targetField,
})
}
})
})
return { nodes, links }
}, [dicts, dependentsByDict])
// Responsive sizing.
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const ro = new ResizeObserver(() => {
setSize({
width: el.clientWidth,
height: Math.max(el.clientHeight, 500),
})
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// Init simulation when nodes/links change.
useEffect(() => {
if (nodes.length === 0) return
nodesRef.current = nodes.map((n) => ({ ...n }))
linksRef.current = links.map((l) => ({ ...l }))
const sim = forceSimulation<GraphNode>(nodesRef.current)
.force(
'link',
forceLink<GraphNode, GraphLink>(linksRef.current)
.id((d) => d.id)
.distance(90)
.strength(0.4),
)
.force('charge', forceManyBody().strength(-280))
.force('center', forceCenter(size.width / 2, size.height / 2))
.force('collide', forceCollide<GraphNode>().radius(28).strength(0.85))
.alpha(1)
.alphaDecay(0.025)
.on('tick', () => setTick((t) => (t + 1) % 1_000_000))
simulationRef.current = sim
return () => {
sim.stop()
}
}, [nodes, links, size.width, size.height])
// Update center force on resize without re-init.
useEffect(() => {
const sim = simulationRef.current
if (!sim) return
sim.force('center', forceCenter(size.width / 2, size.height / 2))
sim.alpha(0.3).restart()
}, [size])
const connectedIds = useMemo(() => {
if (!hoveredId) return new Set<string>()
const out = new Set<string>([hoveredId])
linksRef.current.forEach((l) => {
const s = typeof l.source === 'string' ? l.source : (l.source as GraphNode).id
const t = typeof l.target === 'string' ? l.target : (l.target as GraphNode).id
if (s === hoveredId) out.add(t)
if (t === hoveredId) out.add(s)
})
return out
}, [hoveredId])
return (
<div ref={wrapperRef} className="relative w-full h-[calc(100vh-220px)] min-h-[500px] rounded-lg border border-line bg-surface overflow-hidden">
{depsLoading && (
<div className="absolute top-3 right-3 z-10 text-2xs font-mono uppercase tracking-wider text-mute bg-surface/80 backdrop-blur px-2 py-1 rounded">
Загрузка связей
</div>
)}
<svg
ref={svgRef}
width={size.width}
height={size.height}
viewBox={`0 0 ${size.width} ${size.height}`}
className="block touch-none"
>
<defs>
<marker
id="arrow"
viewBox="0 0 10 10"
refX="20"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-mute)" opacity="0.5" />
</marker>
<marker
id="arrow-hi"
viewBox="0 0 10 10"
refX="20"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-accent)" />
</marker>
</defs>
{/* Edges (под nodes) */}
{linksRef.current.map((l, i) => {
const s = l.source as GraphNode
const t = l.target as GraphNode
if (!s.x || !s.y || !t.x || !t.y) return null
const highlighted =
hoveredId &&
(s.id === hoveredId || t.id === hoveredId)
return (
<line
key={i}
x1={s.x}
y1={s.y}
x2={t.x}
y2={t.y}
stroke={highlighted ? 'var(--color-accent)' : 'var(--color-line)'}
strokeWidth={highlighted ? 1.5 : 1}
opacity={hoveredId && !highlighted ? 0.2 : 0.65}
markerEnd={highlighted ? 'url(#arrow-hi)' : 'url(#arrow)'}
/>
)
})}
{/* Nodes */}
{nodesRef.current.map((n) => {
const r = 10 + Math.min(12, Math.log10((n.recordCount ?? 1) + 1) * 4)
const dim = hoveredId && !connectedIds.has(n.id)
const focused = hoveredId === n.id
return (
<g
key={n.id}
transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
className="cursor-pointer"
opacity={dim ? 0.3 : 1}
onMouseEnter={() => setHoveredId(n.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })}
>
<circle
r={r}
fill={colorForBundle(n.bundle)}
stroke={focused ? 'var(--color-accent)' : 'var(--color-surface)'}
strokeWidth={focused ? 3 : 2}
/>
{/* Label под node */}
<text
y={r + 12}
textAnchor="middle"
fontSize={10}
fontFamily="ui-sans-serif,system-ui,sans-serif"
fill="var(--color-ink)"
className={cn(focused && 'font-semibold')}
>
{n.displayName.length > 18 ? n.displayName.slice(0, 17) + '…' : n.displayName}
</text>
</g>
)
})}
</svg>
{/* Legend */}
<div className="absolute bottom-3 left-3 flex flex-col gap-1 bg-surface/90 backdrop-blur px-3 py-2 rounded-md border border-line text-2xs">
<div className="font-display uppercase tracking-wider text-mute mb-0.5">Bundle</div>
{Object.entries(BUNDLE_COLORS)
.filter(([k]) => k !== 'default')
.map(([bundle, color]) => (
<div key={bundle} className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ background: color }} />
<span className="text-ink-2 font-mono">{bundle}</span>
</div>
))}
</div>
</div>
)
}