import { Component, Suspense, lazy, useEffect, useMemo, useRef, useState, type ReactNode } 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, recordsQuery } from '@/api/queries' import type { DictionaryDefinition, SchemaDependent } from '@/api/client' import { GlobeLoader, LoadingBlock, QueryErrorState } from '@/ui' import { cn } from '@/lib/utils' // 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не // попадают в default chunk. Vite автоматически создаёт отдельный chunk для // dynamic import. Suspense fallback показывается во время загрузки модуля. const DictionaryGraph3D = lazy(() => import('./DictionaryGraph3D')) type GraphMode = '2d' | '3d' /** * Scoped error boundary для 3D Suspense — three.js'у нужен WebGL, и если * браузер его не поддерживает (corp VM, headless без --use-gl=*, hardware * accel выключен) — `WebGLRenderer` бросает ошибку в render-цикле. Без * этого ErrorBoundary fallback'a ошибка bubble'ит до AppErrorBoundary и * вся page становится "Что-то пошло не так". Здесь мы её ловим локально, * показываем friendly fallback + предлагаем вернуться в 2D режим. * * `onError` callback позволяет parent auto-reset'ить mode → '2d', чтобы * пользователь не залипал на ошибочном состоянии. Recovery через * componentDidUpdate (resetKey changes → state.hasError = false). */ class Graph3DErrorBoundary extends Component< { children: ReactNode; onError: () => void; resetKey: number; fallback: ReactNode }, { hasError: boolean } > { state = { hasError: false } static getDerivedStateFromError() { return { hasError: true } } componentDidCatch() { // Auto-notify parent (will switch mode → '2d') чтобы next render не // пытался монтировать 3D снова. Defer через setTimeout — синхронный // setState из catch ломает React reconciliation. setTimeout(() => this.props.onError(), 0) } componentDidUpdate(prev: { resetKey: number }) { if (prev.resetKey !== this.props.resetKey && this.state.hasError) { this.setState({ hasError: false }) } } render() { if (this.state.hasError) return this.props.fallback return this.props.children } } /** * Граф связей справочников — 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 } // colorForBundle helper больше не используется — карточки рендерят // planet-orbs через radial gradients (defs id=planet-{bundle}), а легенда // читает BUNDLE_COLORS напрямую через Object.entries. export function DictionaryGraph() { const dictsQuery = useDictionaries() const dicts = dictsQuery.data ?? [] // localStorage persist чтобы preference сохранился между переходами. // SSR-safe guard на window — vitest setup использует jsdom где window есть, // но defensive чтобы не упасть на SSR в будущем. const [mode, setMode] = useState(() => { if (typeof window === 'undefined') return '2d' return (localStorage.getItem('graph.mode') as GraphMode) === '3d' ? '3d' : '2d' }) useEffect(() => { if (typeof window !== 'undefined') localStorage.setItem('graph.mode', mode) }, [mode]) // 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]) // Records counts per dict — backend /dictionaries listing НЕ возвращает // recordCount field (см. catalog/dictionaries.index.tsx, тот же подход). // Fetch records per dict через useQueries; TanStack Query кэширует между // catalog ↔ graph переходами, так что если user уже видел catalog — // counts уже в cache, мгновенно. Иначе 37 параллельных queries на mount. const recordsResults = useQueries({ queries: dicts.map((d) => ({ ...recordsQuery(d.name, 'PUBLIC,INTERNAL,RESTRICTED'), enabled: Boolean(d.name), })), }) const recordCountByDict = useMemo(() => { const m = new Map() dicts.forEach((d, i) => { const records = recordsResults[i]?.data if (Array.isArray(records)) m.set(d.name, records.length) }) return m }, [dicts, recordsResults]) const allDependentsLoading = dependentsResults.some((r) => r.isLoading) const allRecordsLoading = recordsResults.some((r) => r.isLoading) if (dictsQuery.isLoading) { return } if (dictsQuery.error) { return dictsQuery.refetch()} /> } if (dicts.length === 0) { return
Справочники не найдены.
} return ( ) } function GraphCanvas({ dicts, dependentsByDict, recordCountByDict, depsLoading, mode, onModeChange, }: { dicts: DictionaryDefinition[] dependentsByDict: Map recordCountByDict: Map depsLoading: boolean mode: GraphMode onModeChange: (m: GraphMode) => void }) { // WebGL-fallback banner — показывается несколько секунд после авто-возврата // в 2D из-за ошибки 3D renderer'а (нет WebGL / hardware accel выключено). const [webglFallbackNotice, setWebglFallbackNotice] = useState(false) const navigate = useNavigate() const wrapperRef = useRef(null) const svgRef = useRef(null) const [size, setSize] = useState({ width: 800, height: 600 }) const [hoveredId, setHoveredId] = useState(null) // Zoom/pan state — управляется wheel events + mouse drag на empty space. // Transform применяется к wrapper'у, эффективно scaling всех children. const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | 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 | null>(null) const nodesRef = useRef([]) const linksRef = useRef([]) // Build initial graph data. recordCount приходит из per-dict useRecords // (см. parent component DictionaryGraph) — backend /dictionaries listing // не отдаёт count, поэтому fetch'аем records и берём .length. 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: recordCountByDict.get(d.name) ?? 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, recordCountByDict]) // 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 })) // Force tuning под card geometry (W=200, H=56). // - link distance 180 — расстояние центров двух соединённых cards; // с шириной 200 это лёгкое касание краёв (визуально связные). // - charge -540 — умеренное repulsion для 200px nodes; orphan nodes // рассыпаются вокруг центра, не убегают за viewport. // - collide radius 110 — halfW 100 + 10px padding. const sim = forceSimulation(nodesRef.current) .force( 'link', forceLink(linksRef.current) .id((d) => d.id) .distance(180) .strength(0.4), ) .force('charge', forceManyBody().strength(-540)) .force('center', forceCenter(size.width / 2, size.height / 2)) .force('collide', forceCollide().radius(110).strength(0.9)) .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]) // Космос — фон graph container'а: глубокий радиальный градиент от deep // navy в центре к чёрному по краям + starfield 120 точек поверх. Stars // мемоизированы через useMemo чтобы случайные позиции были stable между // re-renders'ами (иначе звёзды бы мерцали при каждом tick simulation). // Звёзды distribuируются на 2000×2000 виртуальном poле — больше viewport, // чтобы pan'инг показывал новые звёзды а не пустоту по краям. const starfield = useMemo(() => { const pts: Array<{ x: number; y: number; r: number; o: number }> = [] const seed = 137 let s = seed const rand = () => { // Простой LCG — детерминированный без библиотеки seedrandom. s = (s * 1103515245 + 12345) & 0x7fffffff return s / 0x7fffffff } for (let i = 0; i < 140; i++) { const isLarge = rand() > 0.85 pts.push({ x: rand() * 2400 - 200, y: rand() * 1800 - 200, r: isLarge ? 1.2 + rand() * 0.8 : 0.4 + rand() * 0.6, o: 0.3 + rand() * 0.6, }) } return pts }, []) return (
{depsLoading && (
Считаем планеты и связи…
)} {/* Controls overlay — top-right. Stack из 2D/3D toggle + zoom (только в 2D). * Космос-стиль: dark glass panels с violet-tint border, accent (warm * orange) для active state. */}
{/* 2D / 3D mode toggle */}
{/* Zoom controls — только в 2D, в 3D camera orbit имеет свой wheel-zoom. */} {mode === '2d' && (
{Math.round(view.zoom * 100)}%
)}
{mode === '3d' && ( { // WebGL undefined / hardware accel off → switch back в 2D. // Banner показывается ~5s, потом сам исчезает. onModeChange('2d') setWebglFallbackNotice(true) setTimeout(() => setWebglFallbackNotice(false), 5000) }} fallback={

3D-режим недоступен

Браузер не смог инициализировать WebGL. Возможно отключено аппаратное ускорение (chrome://gpu) или используется среда без поддержки 3D. Возврат к 2D-режиму…

} >
} > )} {webglFallbackNotice && (
3D недоступен в этом браузере — переключено на 2D.
)} {mode === '2d' && { // Zoom toward cursor — scale factor based on wheel delta. // Negative deltaY (scroll up) = zoom in. e.preventDefault() const rect = e.currentTarget.getBoundingClientRect() const mx = e.clientX - rect.left const my = e.clientY - rect.top setView((v) => { const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1 const newZoom = Math.min(4, Math.max(0.2, v.zoom * factor)) // Anchor zoom вокруг cursor: viewport position под cursor должна // остаться той же относительно world coordinates. const worldX = (mx - v.panX) / v.zoom const worldY = (my - v.panY) / v.zoom return { zoom: newZoom, panX: mx - worldX * newZoom, panY: my - worldY * newZoom, } }) }} onMouseDown={(e) => { // Pan только если клик НЕ на ноде. Узлы свои onMouseDown handlers. if ((e.target as SVGElement).tagName === 'svg') { panStartRef.current = { x: e.clientX, y: e.clientY, panX: view.panX, panY: view.panY, } } }} onMouseMove={(e) => { // Захватываем start snapshot СИНХРОННО — setView updater запускается // асинхронно, и к тому моменту panStartRef.current может стать null // (mouseup/mouseleave). Раньше `panStartRef.current!.panX + dx` // внутри updater'а падал с "Cannot read properties of null (reading // 'panX')". Локальная const живёт в closure → стабильна. const start = panStartRef.current if (!start) return const dx = e.clientX - start.x const dy = e.clientY - start.y setView((v) => ({ ...v, panX: start.panX + dx, panY: start.panY + dy, })) }} onMouseUp={() => { panStartRef.current = null }} onMouseLeave={() => { panStartRef.current = null }} > {/* Planet glow filter — soft blur halo вокруг планет-орбов. * stdDeviation = размер размытия в SVG-юнитах. Layered: * blur copy сначала, потом source поверх. */} {/* Per-bundle radial gradient — даёт 3D-сферу feel (lit на * top-left, shadow на bottom-right). Цвета: lit edge = brighter * mix of bundle + white, base = bundle, shadow = darker mix. * Gradient'ы определены статически для всех бандлов. */} {/* Edge glow — мягкий blur halo вокруг линий связей для космо-вайба. * stdDeviation=2 — лёгкое размытие; merge с source чтобы исходная * линия оставалась видимой поверх halo. */} {/* Edge gradient — обычная связь идёт от cool violet к warm * stardust, hovered связь — accent throughout. Это даёт * direction-сigг'нал даже без arrow markers. */} {/* Starfield — рендерится первым (под всем), не пересчитывается * при ticks simulation (useMemo seeded LCG для стабильных позиций). * Position поверх pan/zoom group? Нет — звёзды должны оставаться * "fixed" в космосе относительно world, поэтому рендерим ВНУТРИ * zoom group (двигаются вместе с graph nodes). */} {/* Zoom/pan transform — все nodes/edges рендерятся внутри . */} {/* Starfield — звёзды в space layer, под edges/nodes */} {starfield.map((st, i) => ( ))} {/* Edges (под nodes) — космические "потоки" между планетами. * Стиль: * - Default: cool violet → mute violet gradient stroke с лёгким * glow filter + dashed pattern (orbital track feel). * - Highlighted: accent (warm orange) с brighter glow, particles * ставим на 2D через stroke-dashoffset CSS animation (см. * @keyframes edge-flow в styles.css). * - Dimmed (не connected): opacity 0.12, no glow (фокус на * выделенной цепи). */} {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) const dimmed = hoveredId && !highlighted return ( ) })} {/* Nodes — планет-карточки: glowing orb (radial gradient sphere) + * dark glass card с title/count/subtitle (HTML inside foreignObject * → нативный text-overflow:ellipsis, никаких manual char-caps). * Layout: row1 = title (full width with ellipsis), row2 = subtitle * слева | record count справа. Раньше title и count были в одном * row → длинные имена дрезались за count и за edge карточки. */} {nodesRef.current.map((n) => { const dim = hoveredId && !connectedIds.has(n.id) const focused = hoveredId === n.id // Card 200×56 — расширил с 180 чтобы reserve area для самой // большой планеты (orbR_max=18) не съедала text content. const W = 200 const H = 56 const halfW = W / 2 const halfH = H / 2 // Planet radius шкалируется по record count: sqrt чтобы 5→14 // записей давали заметную разницу (не log который сжал бы их в // 2-3px diff). Min 7 (0 записей — планета всё ещё видна), max // 18 (cap чтобы не разорвать card layout). // records=0 → 7px // records=5 → 10.1px // records=14 → 12.2px // records=50 → 16.9px // records≥100 → 18px (cap) const orbR = Math.min(18, Math.max(7, 7 + Math.sqrt(n.recordCount) * 1.4)) // Orb center остаётся в фиксированном x для consistent left margin — // меняется только радиус (планета "растёт" наружу). const orbCx = -halfW + 26 const bundleKey = n.bundle in BUNDLE_COLORS ? n.bundle : 'default' const planetGradId = `planet-${bundleKey}` // Content area стартует за reserved orb area (фиксированно для // max orb size), чтобы title не дёргался когда счётчики // обновляются из useRecords. const contentX = orbCx + 18 + 8 // max orbR (18) + padding 8 const contentW = halfW - contentX - 8 const recordText = n.recordCount > 0 ? `${n.recordCount} зап` : '—' return ( setHoveredId(n.id)} onMouseLeave={() => setHoveredId(null)} onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })} > {/* Card body — dark glass с violet-ish border, focused → accent border + glow */} {/* Planet orb с glow filter + radial gradient (lit top-left). * Размер orbR пропорционален recordCount (sqrt scale, 7-18px), * specular highlight тоже шкалируется чтобы выглядеть как * планета а не точка. */} {/* foreignObject — HTML inside SVG для нативного truncate. * pointerEvents=none чтобы родительский ловил mouse events. */}
{/* Title — full width, ellipsis на overflow */}
{n.displayName}
{/* Subtitle row: name · bundle (left, ellipsis) | count (right, no shrink) */}
{n.name} · {n.bundle} 0 ? '#ff9a64' : '#7a76b8', fontWeight: n.recordCount > 0 ? 600 : 400, whiteSpace: 'nowrap', flexShrink: 0, }} > {recordText}
) })}
{/* === end zoom/pan transform === */}
} {/* Legend — space-themed panel с planet-orb swatches */}
Bundle
{Object.entries(BUNDLE_COLORS) .filter(([k]) => k !== 'default') .map(([bundle, color]) => (
{bundle}
))}
) }