Files
mdm-ordinis/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx
T
2026-05-12 12:41:48 +00:00

885 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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
}
// 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<GraphMode>(() => {
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<string, SchemaDependent[]>()
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<string, number>()
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 <LoadingBlock size="lg" label="Загрузка справочников…" />
}
if (dictsQuery.error) {
return <QueryErrorState error={dictsQuery.error} onRetry={() => dictsQuery.refetch()} />
}
if (dicts.length === 0) {
return <div className="text-body text-mute">Справочники не найдены.</div>
}
return (
<GraphCanvas
dicts={dicts}
dependentsByDict={dependentsByDict}
recordCountByDict={recordCountByDict}
depsLoading={allDependentsLoading || allRecordsLoading}
mode={mode}
onModeChange={setMode}
/>
)
}
function GraphCanvas({
dicts,
dependentsByDict,
recordCountByDict,
depsLoading,
mode,
onModeChange,
}: {
dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]>
recordCountByDict: Map<string, number>
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<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const [size, setSize] = useState({ width: 800, height: 600 })
const [hoveredId, setHoveredId] = useState<string | null>(null)
// Zoom/pan state — управляется wheel events + mouse drag на empty space.
// Transform применяется к <g> 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<Simulation<GraphNode, GraphLink> | null>(null)
const nodesRef = useRef<GraphNode[]>([])
const linksRef = useRef<GraphLink[]>([])
// 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<GraphNode>(nodesRef.current)
.force(
'link',
forceLink<GraphNode, GraphLink>(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<GraphNode>().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<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])
// Космос — фон 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 (
<div
ref={wrapperRef}
className="relative w-full h-[calc(100vh-220px)] min-h-[500px] rounded-lg border border-line overflow-hidden"
style={{
// Космос — radial gradient deep navy → near-black, плюс subtle
// accent halo в центре (намёк на star/sun без отвлекающего диска).
background:
'radial-gradient(ellipse at 40% 35%, #1a1742 0%, #0c0a23 45%, #060512 90%)',
}}
>
{depsLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
<div className="flex flex-col items-center gap-3 bg-[#1c1840]/85 backdrop-blur border border-[#3d3863] rounded-lg px-6 py-5 shadow-2xl">
<GlobeLoader size={56} />
<span className="text-mono text-cap uppercase tracking-wider text-[#a09bc8]">
Считаем планеты и связи
</span>
</div>
</div>
)}
{/* Controls overlay — top-right. Stack из 2D/3D toggle + zoom (только в 2D).
* Космос-стиль: dark glass panels с violet-tint border, accent (warm
* orange) для active state. */}
<div className="absolute top-2 right-2 z-10 flex flex-col items-end gap-1">
{/* 2D / 3D mode toggle */}
<div className="inline-flex items-center rounded-md border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur p-0.5 shadow-lg">
<button
type="button"
onClick={() => onModeChange('2d')}
aria-pressed={mode === '2d'}
title="2D режим — SVG force-directed layout"
className={cn(
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
mode === '2d'
? 'bg-[#ff9a64] text-[#1c1840]'
: 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
)}
>
2D
</button>
<button
type="button"
onClick={() => onModeChange('3d')}
aria-pressed={mode === '3d'}
title="3D режим — WebGL three.js (camera orbit)"
className={cn(
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
mode === '3d'
? 'bg-[#ff9a64] text-[#1c1840]'
: 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
)}
>
3D
</button>
</div>
{/* Zoom controls — только в 2D, в 3D camera orbit имеет свой wheel-zoom. */}
{mode === '2d' && (
<div className="inline-flex items-center gap-1 rounded-md border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur p-0.5 shadow-lg">
<button
type="button"
onClick={() =>
setView((v) => ({ ...v, zoom: Math.min(4, v.zoom * 1.25) }))
}
aria-label="Zoom in"
title="Zoom in (+)"
className="size-7 inline-flex items-center justify-center text-[#c5bff0] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors"
>
+
</button>
<button
type="button"
onClick={() =>
setView((v) => ({ ...v, zoom: Math.max(0.2, v.zoom / 1.25) }))
}
aria-label="Zoom out"
title="Zoom out ()"
className="size-7 inline-flex items-center justify-center text-[#c5bff0] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors"
>
</button>
<button
type="button"
onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
aria-label="Reset view"
title="Reset (1:1)"
className="h-7 px-2 inline-flex items-center justify-center text-cap text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors"
>
1:1
</button>
<span className="px-1 text-mono text-cap text-[#a09bc8] tabular-nums">
{Math.round(view.zoom * 100)}%
</span>
</div>
)}
</div>
{mode === '3d' && (
<Graph3DErrorBoundary
resetKey={size.width + size.height}
onError={() => {
// WebGL undefined / hardware accel off → switch back в 2D.
// Banner показывается ~5s, потом сам исчезает.
onModeChange('2d')
setWebglFallbackNotice(true)
setTimeout(() => setWebglFallbackNotice(false), 5000)
}}
fallback={
<div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center max-w-md">
<p className="text-title-md text-[#f0eaff] mb-2">3D-режим недоступен</p>
<p className="text-cell text-[#a09bc8]">
Браузер не смог инициализировать WebGL. Возможно отключено
аппаратное ускорение (chrome://gpu) или используется среда без
поддержки 3D. Возврат к 2D-режиму
</p>
</div>
</div>
}
>
<Suspense
fallback={
<div className="absolute inset-0 flex items-center justify-center">
<LoadingBlock size="lg" label="Загрузка 3D-движка…" />
</div>
}
>
<DictionaryGraph3D
dicts={dicts}
dependentsByDict={dependentsByDict}
recordCountByDict={recordCountByDict}
width={size.width}
height={size.height}
/>
</Suspense>
</Graph3DErrorBoundary>
)}
{webglFallbackNotice && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 max-w-md bg-[#3d2614]/85 border border-[#ff9a64] text-[#ffd5b0] text-cell rounded-md px-3 py-2 shadow-lg backdrop-blur">
3D недоступен в этом браузере переключено на 2D.
</div>
)}
{mode === '2d' && <svg
ref={svgRef}
width={size.width}
height={size.height}
viewBox={`0 0 ${size.width} ${size.height}`}
className="block touch-none"
style={{ cursor: panStartRef.current ? 'grabbing' : 'grab' }}
onWheel={(e) => {
// 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
}}
>
<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="#7a76b8" opacity="0.6" />
</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="#ff9a64" />
</marker>
{/* Planet glow filter — soft blur halo вокруг планет-орбов.
* stdDeviation = размер размытия в SVG-юнитах. Layered:
* blur copy сначала, потом source поверх. */}
<filter id="planet-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* 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'ы определены статически для всех бандлов. */}
<radialGradient id="planet-cuod" cx="35%" cy="30%" r="70%">
<stop offset="0%" stopColor="#ffc8a3" />
<stop offset="40%" stopColor="#d97757" />
<stop offset="100%" stopColor="#5c2810" />
</radialGradient>
<radialGradient id="planet-geo" cx="35%" cy="30%" r="70%">
<stop offset="0%" stopColor="#a8c47a" />
<stop offset="40%" stopColor="#5b8044" />
<stop offset="100%" stopColor="#22341a" />
</radialGradient>
<radialGradient id="planet-comms" cx="35%" cy="30%" r="70%">
<stop offset="0%" stopColor="#e6c97a" />
<stop offset="40%" stopColor="#b8902f" />
<stop offset="100%" stopColor="#4a3a10" />
</radialGradient>
<radialGradient id="planet-delivery" cx="35%" cy="30%" r="70%">
<stop offset="0%" stopColor="#8a6e5a" />
<stop offset="40%" stopColor="#4a3324" />
<stop offset="100%" stopColor="#1a0e08" />
</radialGradient>
<radialGradient id="planet-default" cx="35%" cy="30%" r="70%">
<stop offset="0%" stopColor="#9d93a6" />
<stop offset="40%" stopColor="#5e5560" />
<stop offset="100%" stopColor="#2a242a" />
</radialGradient>
{/* Edge glow — мягкий blur halo вокруг линий связей для космо-вайба.
* stdDeviation=2 — лёгкое размытие; merge с source чтобы исходная
* линия оставалась видимой поверх halo. */}
<filter id="edge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="1.6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Edge gradient — обычная связь идёт от cool violet к warm
* stardust, hovered связь — accent throughout. Это даёт
* direction-сigг'нал даже без arrow markers. */}
<linearGradient id="edge-stroke" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#5b569b" stopOpacity="0.85" />
<stop offset="100%" stopColor="#a09bc8" stopOpacity="0.55" />
</linearGradient>
<linearGradient id="edge-stroke-hi" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#ff9a64" stopOpacity="1" />
<stop offset="100%" stopColor="#ffd5b0" stopOpacity="0.9" />
</linearGradient>
</defs>
{/* Starfield — рендерится первым (под всем), не пересчитывается
* при ticks simulation (useMemo seeded LCG для стабильных позиций).
* Position поверх pan/zoom group? Нет — звёзды должны оставаться
* "fixed" в космосе относительно world, поэтому рендерим ВНУТРИ
* zoom group (двигаются вместе с graph nodes). */}
{/* Zoom/pan transform — все nodes/edges рендерятся внутри <g>. */}
<g transform={`translate(${view.panX} ${view.panY}) scale(${view.zoom})`}>
{/* Starfield — звёзды в space layer, под edges/nodes */}
<g pointerEvents="none">
{starfield.map((st, i) => (
<circle
key={i}
cx={st.x}
cy={st.y}
r={st.r}
fill="#e6e6ff"
opacity={st.o}
/>
))}
</g>
{/* 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 (
<line
key={i}
x1={s.x}
y1={s.y}
x2={t.x}
y2={t.y}
stroke={
highlighted
? 'url(#edge-stroke-hi)'
: dimmed
? '#3d3863'
: 'url(#edge-stroke)'
}
strokeWidth={highlighted ? 1.8 : 1}
strokeDasharray={highlighted ? '4 3' : '2 4'}
opacity={dimmed ? 0.15 : highlighted ? 0.95 : 0.55}
filter={dimmed ? undefined : 'url(#edge-glow)'}
markerEnd={highlighted ? 'url(#arrow-hi)' : 'url(#arrow)'}
className={highlighted ? 'graph-edge-flow' : undefined}
/>
)
})}
{/* 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 (
<g
key={n.id}
transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
className="cursor-pointer"
opacity={dim ? 0.25 : 1}
onMouseEnter={() => 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 */}
<rect
x={-halfW}
y={-halfH}
width={W}
height={H}
rx={8}
fill="#1c1840"
fillOpacity={0.85}
stroke={focused ? '#ff9a64' : '#3d3863'}
strokeWidth={focused ? 1.5 : 1}
className={focused ? 'drop-shadow-[0_0_12px_rgba(255,154,100,0.5)]' : ''}
/>
{/* Planet orb с glow filter + radial gradient (lit top-left).
* Размер orbR пропорционален recordCount (sqrt scale, 7-18px),
* specular highlight тоже шкалируется чтобы выглядеть как
* планета а не точка. */}
<g filter="url(#planet-glow)">
<circle
cx={orbCx}
cy={0}
r={orbR}
fill={`url(#${planetGradId})`}
/>
<ellipse
cx={orbCx - orbR * 0.3}
cy={-orbR * 0.3}
rx={orbR * 0.22}
ry={orbR * 0.14}
fill="#ffffff"
opacity={0.4}
/>
</g>
{/* foreignObject — HTML inside SVG для нативного truncate.
* pointerEvents=none чтобы родительский <g> ловил mouse events. */}
<foreignObject
x={contentX}
y={-halfH + 5}
width={contentW}
height={H - 10}
style={{ pointerEvents: 'none' }}
>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
overflow: 'hidden',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
}}
>
{/* Title — full width, ellipsis на overflow */}
<div
style={{
fontSize: '12px',
fontWeight: 600,
color: '#f0eaff',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={n.displayName}
>
{n.displayName}
</div>
{/* Subtitle row: name · bundle (left, ellipsis) | count (right, no shrink) */}
<div
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: '6px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
}}
>
<span
style={{
fontSize: '10px',
color: '#a09bc8',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
flex: 1,
}}
title={`${n.name} · ${n.bundle}`}
>
{n.name} · {n.bundle}
</span>
<span
style={{
fontSize: '11px',
color: n.recordCount > 0 ? '#ff9a64' : '#7a76b8',
fontWeight: n.recordCount > 0 ? 600 : 400,
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{recordText}
</span>
</div>
</div>
</foreignObject>
</g>
)
})}
</g>
{/* === end zoom/pan transform === */}
</svg>}
{/* Legend — space-themed panel с planet-orb swatches */}
<div className="absolute bottom-3 left-3 flex flex-col gap-1 bg-[#1c1840]/85 backdrop-blur px-3 py-2 rounded-md border border-[#3d3863] text-cell shadow-lg">
<div className="font-display uppercase tracking-wider text-[#a09bc8] 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.5 rounded-full"
style={{
background: color,
boxShadow: `0 0 4px ${color}aa`,
}}
/>
<span className="text-[#c5bff0] font-mono">{bundle}</span>
</div>
))}
</div>
</div>
)
}