feat(graph): 3D mode toggle — three.js + WebGL fallback
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user