885 lines
37 KiB
TypeScript
885 lines
37 KiB
TypeScript
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>
|
||
)
|
||
}
|