From ec713f970452ed5dcfb77f654647e80161ba4faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 11 May 2026 08:55:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20Graph=20view=20=D1=81=20d3-fo?= =?UTF-8?q?rce=20(Stage=203.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/package.json | 2 + ordinis-admin-ui/pnpm-lock.yaml | 39 ++ .../src/components/graph/DictionaryGraph.tsx | 334 ++++++++++++++++++ ordinis-admin-ui/src/routes/graph.tsx | 23 +- 4 files changed, 385 insertions(+), 13 deletions(-) create mode 100644 ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index 7348405..f9f9e39 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -31,6 +31,7 @@ "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-force": "^3.0.0", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.0.2", "leaflet": "^1.9.4", @@ -51,6 +52,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/d3-force": "^3.0.10", "@types/leaflet": "^1.9.21", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index c61935d..132e12b 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 i18next: specifier: ^26.0.3 version: 26.0.8(typescript@5.9.3) @@ -123,6 +126,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/leaflet': specifier: ^1.9.21 version: 1.9.21 @@ -1437,6 +1443,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1611,6 +1620,22 @@ packages: csstype@3.2.3: 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: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3536,6 +3561,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-force@3.0.10': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -3731,6 +3758,18 @@ snapshots: 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: dependencies: whatwg-mimetype: 5.0.0 diff --git a/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx new file mode 100644 index 0000000..c1ad7fc --- /dev/null +++ b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx @@ -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 & { + sourceField: string + targetField: string +} + +const BUNDLE_COLORS: Record = { + 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() + 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 + } + + if (dictsQuery.error) { + return
Ошибка загрузки: {String(dictsQuery.error)}
+ } + + if (dicts.length === 0) { + return
Справочники не найдены.
+ } + + return ( + + ) +} + +function GraphCanvas({ + dicts, + dependentsByDict, + depsLoading, +}: { + dicts: DictionaryDefinition[] + dependentsByDict: Map + depsLoading: boolean +}) { + const navigate = useNavigate() + const wrapperRef = useRef(null) + const svgRef = useRef(null) + const [size, setSize] = useState({ width: 800, height: 600 }) + const [hoveredId, setHoveredId] = useState(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 | null>(null) + const nodesRef = useRef([]) + const linksRef = useRef([]) + + // 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(nodesRef.current) + .force( + 'link', + forceLink(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().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() + const out = new Set([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 ( +
+ {depsLoading && ( +
+ Загрузка связей… +
+ )} + + + + + + + + + + + {/* 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 ( + + ) + })} + + {/* 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 ( + setHoveredId(n.id)} + onMouseLeave={() => setHoveredId(null)} + onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })} + > + + {/* Label под node */} + + {n.displayName.length > 18 ? n.displayName.slice(0, 17) + '…' : n.displayName} + + + ) + })} + + {/* Legend */} +
+
Bundle
+ {Object.entries(BUNDLE_COLORS) + .filter(([k]) => k !== 'default') + .map(([bundle, color]) => ( +
+ + {bundle} +
+ ))} +
+
+ ) +} diff --git a/ordinis-admin-ui/src/routes/graph.tsx b/ordinis-admin-ui/src/routes/graph.tsx index 779e03e..464540d 100644 --- a/ordinis-admin-ui/src/routes/graph.tsx +++ b/ordinis-admin-ui/src/routes/graph.tsx @@ -1,19 +1,20 @@ import { createFileRoute } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { GitBranch } from 'lucide-react' import { PageHeader } from '@/ui' -import { EmptyState } from '@/ui/components/empty-state' +import { DictionaryGraph } from '@/components/graph/DictionaryGraph' /** * Граф связей справочников — d3-force network diagram. * - * Stage 3.3 placeholder. Compute graph from {@code useDictionaries() + - * useQueries(dependents)}: nodes = dicts, edges = FK refs. Layout — - * d3-force с link/charge/center forces. Hover на node → highlight - * incoming + outgoing. Click → navigate to dict. + * Render: SVG с force-directed layout. Nodes = справочники (color по bundle, + * size по recordCount). Edges = incoming FK refs (через useDictionaryDependents + * для каждого dict'а, параллельно). * - * Dependency: `d3-force` (без full d3, ~30kb gz). Render через canvas - * для 40+ nodes performance. + * Interactions: + * - 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')({ component: GraphPage, @@ -24,11 +25,7 @@ function GraphPage() { return (
- } - title={t('graph.comingSoon.title')} - description={t('graph.comingSoon.description')} - /> +
) }