feat(admin-ui): Graph view с d3-force (Stage 3.3)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Generated
+39
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user