Merge branch 'feat/v1.2.0-graph-and-catalog' into 'main'

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

See merge request 2-6/2-6-4/terravault/ordinis!35
This commit is contained in:
Александр Зимин
2026-05-11 08:55:53 +00:00
4 changed files with 385 additions and 13 deletions
+2
View File
@@ -31,6 +31,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3-force": "^3.0.0",
"i18next": "^26.0.3", "i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.0.2", "i18next-browser-languagedetector": "^8.0.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@@ -51,6 +52,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3-force": "^3.0.10",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
+39
View File
@@ -68,6 +68,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
d3-force:
specifier: ^3.0.0
version: 3.0.0
i18next: i18next:
specifier: ^26.0.3 specifier: ^26.0.3
version: 26.0.8(typescript@5.9.3) version: 26.0.8(typescript@5.9.3)
@@ -123,6 +126,9 @@ importers:
'@testing-library/user-event': '@testing-library/user-event':
specifier: ^14.6.1 specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1) version: 14.6.1(@testing-library/dom@10.4.1)
'@types/d3-force':
specifier: ^3.0.10
version: 3.0.10
'@types/leaflet': '@types/leaflet':
specifier: ^1.9.21 specifier: ^1.9.21
version: 1.9.21 version: 1.9.21
@@ -1437,6 +1443,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1611,6 +1620,22 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-force@3.0.0:
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
engines: {node: '>=12'}
d3-quadtree@3.0.1:
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-urls@7.0.0: data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -3536,6 +3561,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@types/d3-force@3.0.10': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {} '@types/geojson@7946.0.16': {}
@@ -3731,6 +3758,18 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-dispatch@3.0.1: {}
d3-force@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-quadtree@3.0.1: {}
d3-timer@3.0.1: {}
data-urls@7.0.0: data-urls@7.0.0:
dependencies: dependencies:
whatwg-mimetype: 5.0.0 whatwg-mimetype: 5.0.0
@@ -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>
)
}
+10 -13
View File
@@ -1,19 +1,20 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { GitBranch } from 'lucide-react'
import { PageHeader } from '@/ui' import { PageHeader } from '@/ui'
import { EmptyState } from '@/ui/components/empty-state' import { DictionaryGraph } from '@/components/graph/DictionaryGraph'
/** /**
* Граф связей справочников — d3-force network diagram. * Граф связей справочников — d3-force network diagram.
* *
* Stage 3.3 placeholder. Compute graph from {@code useDictionaries() + * Render: SVG с force-directed layout. Nodes = справочники (color по bundle,
* useQueries(dependents)}: nodes = dicts, edges = FK refs. Layout — * size по recordCount). Edges = incoming FK refs (через useDictionaryDependents
* d3-force с link/charge/center forces. Hover на node → highlight * для каждого dict'а, параллельно).
* incoming + outgoing. Click → navigate to dict.
* *
* Dependency: `d3-force` (без full d3, ~30kb gz). Render через canvas * Interactions:
* для 40+ nodes performance. * - hover node → highlight node + connected edges + neighbors (dim others)
* - click node → navigate /dictionaries/$name
*
* Performance: 40+ nodes SVG OK. >200 — переключать на canvas.
*/ */
export const Route = createFileRoute('/graph')({ export const Route = createFileRoute('/graph')({
component: GraphPage, component: GraphPage,
@@ -24,11 +25,7 @@ function GraphPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader title={t('graph.title')} description={t('graph.description')} /> <PageHeader title={t('graph.title')} description={t('graph.description')} />
<EmptyState <DictionaryGraph />
icon={<GitBranch strokeWidth={1.5} />}
title={t('graph.comingSoon.title')}
description={t('graph.comingSoon.description')}
/>
</div> </div>
) )
} }