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>