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>
|
||||
|
||||
Reference in New Issue
Block a user