feat(graph): 3D mode toggle — three.js + WebGL fallback

This commit is contained in:
Александр Зимин
2026-05-11 22:11:41 +00:00
parent fc9db66c7c
commit fe18befb2a
5 changed files with 687 additions and 39 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
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 {
@@ -16,6 +16,50 @@ import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
import { LoadingBlock } 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.
*
@@ -65,6 +109,16 @@ function colorForBundle(bundle: string): string {
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({
@@ -101,6 +155,8 @@ export function DictionaryGraph() {
dicts={dicts}
dependentsByDict={dependentsByDict}
depsLoading={allDependentsLoading}
mode={mode}
onModeChange={setMode}
/>
)
}
@@ -109,11 +165,18 @@ function GraphCanvas({
dicts,
dependentsByDict,
depsLoading,
mode,
onModeChange,
}: {
dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]>
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)
@@ -230,45 +293,128 @@ function GraphCanvas({
Загрузка связей
</div>
)}
{/* Zoom controls overlay top-right. */}
<div className="absolute top-2 right-2 z-10 inline-flex items-center gap-1 rounded-md border border-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
<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-ink-2 hover:text-ink hover:bg-surface-2 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-ink-2 hover:text-ink hover:bg-surface-2 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-mute hover:text-ink hover:bg-surface-2 rounded-sm transition-colors"
>
1:1
</button>
<span className="px-1 text-mono text-cap text-mute tabular-nums">
{Math.round(view.zoom * 100)}%
</span>
{/* Controls overlay top-right. Stack из 2D/3D toggle + zoom (только в 2D). */}
<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-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
<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-accent text-white'
: 'text-mute hover:text-ink hover:bg-surface-2',
)}
>
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-accent text-white'
: 'text-mute hover:text-ink hover:bg-surface-2',
)}
>
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-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
<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-ink-2 hover:text-ink hover:bg-surface-2 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-ink-2 hover:text-ink hover:bg-surface-2 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-mute hover:text-ink hover:bg-surface-2 rounded-sm transition-colors"
>
1:1
</button>
<span className="px-1 text-mono text-cap text-mute tabular-nums">
{Math.round(view.zoom * 100)}%
</span>
</div>
)}
</div>
<svg
{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-ink mb-2">3D-режим недоступен</p>
<p className="text-cell text-mute">
Браузер не смог инициализировать 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}
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-warn/10 border border-warn text-ink-2 text-cell rounded-md px-3 py-2 shadow-sm backdrop-blur">
3D недоступен в этом браузере переключено на 2D.
</div>
)}
{mode === '2d' && <svg
ref={svgRef}
width={size.width}
height={size.height}
@@ -412,7 +558,7 @@ function GraphCanvas({
})}
</g>
{/* === end zoom/pan transform === */}
</svg>
</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-cell">
<div className="font-display uppercase tracking-wider text-mute mb-0.5">Bundle</div>
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import ForceGraph3D, { type ForceGraphMethods } from 'react-force-graph-3d'
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
/**
* 3D-режим графа справочников через react-force-graph-3d (three.js + d3-force-3d).
*
* Этот компонент динамически импортируется из DictionaryGraph, чтобы three.js
* (~650kb minified) не попадал в default chunk. Lazy-loaded только когда
* пользователь явно переключает в 3D режим.
*
* Поведение:
* - Те же nodes/links что в 2D-моде (общая data shape)
* - Camera orbit controls (drag = rotate, wheel = zoom)
* - Hover → tooltip с displayName + record count
* - Click → navigate /dictionaries/$name
* - Цвета и размеры узлов идентичны 2D (single source of truth)
* - Directional particles на edges — лёгкая визуализация направления FK
*
* Performance: 3D-граф handles 200+ nodes легко (GPU rendering), но default
* configuration уже good enough для current ~37 dicts.
*/
type Node3D = {
id: string
name: string
displayName: string
bundle: string
scope: string
recordCount: number
// Internal three coords — populated by force engine
x?: number
y?: number
z?: number
}
type Link3D = {
source: string | Node3D
target: string | Node3D
sourceField: string
targetField: string
}
const BUNDLE_COLORS: Record<string, string> = {
cuod: '#b05a2e',
geo: '#4f7038',
comms: '#a8762a',
delivery: '#3a2818',
default: '#52483d',
}
const colorForBundle = (bundle: string): string =>
BUNDLE_COLORS[bundle] ?? BUNDLE_COLORS.default
type Props = {
dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]>
width: number
height: number
}
export default function DictionaryGraph3D({
dicts,
dependentsByDict,
width,
height,
}: Props) {
const navigate = useNavigate()
const graphRef = useRef<ForceGraphMethods<Node3D, Link3D> | undefined>(undefined)
const [hoverId, setHoverId] = useState<string | null>(null)
// Build graph data — same logic as 2D mode, без simulation refs (three
// worker handles physics).
const graphData = useMemo(() => {
const nodes: Node3D[] = 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 nodeIds = new Set(nodes.map((n) => n.id))
const links: Link3D[] = []
dicts.forEach((target) => {
const deps = dependentsByDict.get(target.name) ?? []
deps.forEach((dep) => {
if (nodeIds.has(dep.sourceDict)) {
links.push({
source: dep.sourceDict,
target: target.name,
sourceField: dep.sourceField,
targetField: dep.targetField,
})
}
})
})
return { nodes, links }
}, [dicts, dependentsByDict])
// Tune camera + force layout once after mount.
useEffect(() => {
const fg = graphRef.current
if (!fg) return
// Подальше отвести камеру для просторного 3D-обзора.
fg.cameraPosition({ x: 0, y: 0, z: 400 })
}, [])
// CSS variable resolution — three не понимает var(--color-*),
// grab raw hex values from document root для accent + ink.
const accentHex = '#b05a2e'
const lineHex = '#e6e1d4'
return (
<div className="relative w-full h-full">
<ForceGraph3D<Node3D, Link3D>
ref={graphRef}
graphData={graphData}
width={width}
height={height}
backgroundColor="rgba(0,0,0,0)"
nodeId="id"
nodeLabel={(n) =>
`<div style="background:#fff;color:#1a1714;padding:6px 10px;border-radius:4px;border:1px solid ${lineHex};font:13px ui-sans-serif"><strong>${n.displayName}</strong><br/><span style="color:#666;font-size:11px">${n.bundle} · ${n.recordCount} записей</span></div>`
}
nodeColor={(n) => (hoverId === n.id ? accentHex : colorForBundle(n.bundle))}
nodeVal={(n) => 1 + Math.log10((n.recordCount ?? 1) + 1) * 2}
nodeOpacity={hoverId ? 0.85 : 0.95}
nodeResolution={16}
linkColor={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id
const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id
return hoverId && (sid === hoverId || tid === hoverId) ? accentHex : lineHex
}}
linkOpacity={hoverId ? 0.4 : 0.5}
linkWidth={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id
const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id
return hoverId && (sid === hoverId || tid === hoverId) ? 1.5 : 0.5
}}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
linkDirectionalArrowColor={() => accentHex}
linkDirectionalParticles={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id
const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id
return hoverId && (sid === hoverId || tid === hoverId) ? 2 : 0
}}
linkDirectionalParticleSpeed={0.006}
linkDirectionalParticleWidth={2}
onNodeHover={(n) => setHoverId(n?.id ?? null)}
onNodeClick={(n) => navigate({ to: '/dictionaries/$name', params: { name: n.name } })}
// Slight cooldown extension чтобы layout успел осесть.
cooldownTicks={120}
/>
</div>
)
}