fix: release the spiral view's WebGL context and keep its failures local
Each open of the time spiral created a WebGL context that was never released, so repeated use hit the browser limit and lost the oldest context. The view now releases it on close, shows a local retry on creation failure or context loss, and the lazy chunk retries once before an in-canvas error.
This commit is contained in:
@@ -42,6 +42,9 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null)
|
||||
const [supported] = useState(webglAvailable)
|
||||
// Context creation failed or the GPU dropped the context; `attempt` recreates the scene on retry.
|
||||
const [failure, setFailure] = useState<'create' | 'lost'>()
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const tree = useMemo(() => buildStoryTree(stories, new Map()), [stories])
|
||||
const [focusId, setFocusId] = useState('root')
|
||||
const focus = findNode(tree, focusId) ?? tree
|
||||
@@ -63,7 +66,12 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current, overlay = overlayRef.current
|
||||
if (!supported || !canvas || !overlay || !range) return
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true })
|
||||
// Under load the browser can refuse a new WebGL context; show a local error instead of failing the page.
|
||||
let renderer: THREE.WebGLRenderer
|
||||
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }) }
|
||||
catch (error) { console.error('GoodBuddy 3D view failed', error); queueMicrotask(() => setFailure('create')); return }
|
||||
const lost = (event: Event) => { event.preventDefault(); setFailure('lost') }
|
||||
canvas.addEventListener('webglcontextlost', lost)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
const scene = new THREE.Scene()
|
||||
const host = canvas.parentElement!
|
||||
@@ -232,11 +240,13 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
|
||||
return hit && { node: hit.object.userData.node as StoryNode | undefined, cluster: hit.object.userData.cluster as StoryCluster | undefined,
|
||||
link: hit.object.userData.link as ExperienceLink | undefined }
|
||||
},
|
||||
dispose: () => { canvas.removeEventListener('wheel', zoom); resize.disconnect(); disposables.forEach(item => item.dispose()); renderer.dispose() }
|
||||
// Release the GPU context too: browsers keep only a few, and reopening the view must not use them up.
|
||||
dispose: () => { canvas.removeEventListener('wheel', zoom); canvas.removeEventListener('webglcontextlost', lost); resize.disconnect()
|
||||
disposables.forEach(item => item.dispose()); renderer.dispose(); renderer.forceContextLoss() }
|
||||
}
|
||||
return () => { engine.current?.dispose(); engine.current = undefined }
|
||||
// Focus, hover and selection are pushed through update() below; only data and locale recreate the scene.
|
||||
}, [supported, range, attention, i18n.resolvedLanguage]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Focus, hover and selection are pushed through update() below; only data, locale and a retry recreate the scene.
|
||||
}, [supported, range, attention, i18n.resolvedLanguage, attempt]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) },
|
||||
[focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId])
|
||||
|
||||
@@ -265,6 +275,10 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
|
||||
return [event.clientX - rect.left, event.clientY - rect.top] as const
|
||||
}
|
||||
if (!supported) return <p className="supervisor-workspace__muted" role="status">{t('supervisor.graph3d.unsupported')}</p>
|
||||
if (failure) return <div className="supervisor-workspace__inline-error" role="alert">
|
||||
<strong>{t(failure === 'lost' ? 'supervisor.graph3d.lost' : 'supervisor.graph3d.failed')}</strong>
|
||||
<button type="button" className="secondary-button" onClick={() => { setFailure(undefined); setAttempt(value => value + 1) }}>{t('supervisor.graph3d.retry')}</button>
|
||||
</div>
|
||||
if (!range) return <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>
|
||||
return <div className="story-graph-3d">
|
||||
<div className="story-graph-3d__toolbar">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { Component, lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { BookOpen, Ellipsis, Network, RefreshCw, X } from 'lucide-react'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
||||
@@ -19,7 +20,17 @@ import {
|
||||
} from '../../shared/supervision-contracts'
|
||||
|
||||
// Three.js loads only when the spiral view is opened.
|
||||
const StoryGraph3D = lazy(() => import('./StoryGraph3D'))
|
||||
// A failed chunk load (busy disk or renderer, interrupted update) is retried once before reporting.
|
||||
const StoryGraph3D = lazy(() => import('./StoryGraph3D').catch(() => new Promise<typeof import('./StoryGraph3D')>((resolve, reject) =>
|
||||
setTimeout(() => { import('./StoryGraph3D').then(resolve, reject) }, 800))))
|
||||
|
||||
/** Keeps a failure of the spiral view inside the canvas; the flat view, lists and page stay usable. */
|
||||
class SpiralBoundary extends Component<{ children: ReactNode; fallback: (retry: () => void) => ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError(): { failed: boolean } { return { failed: true } }
|
||||
componentDidCatch(error: unknown): void { console.error('GoodBuddy spiral view failed', error) }
|
||||
render(): ReactNode { return this.state.failed ? this.props.fallback(() => this.setState({ failed: false })) : this.props.children }
|
||||
}
|
||||
type Selection = { kind: 'event' | 'entity' | 'relation' | 'story' | 'experience'; id: string }
|
||||
/** `focus` opens a story or experience in the graph lists, such as from a heartbeat suggestion. */
|
||||
export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph'; focus?: { kind: 'story' | 'experience'; id: string } }
|
||||
@@ -848,6 +859,11 @@ export function SupervisorWorkspace({
|
||||
</div>}
|
||||
</div>
|
||||
{graphMode === 'spiral' ? (
|
||||
<SpiralBoundary fallback={(retry) => <div className="supervisor-workspace__inline-error" role="alert">
|
||||
<strong>{t('supervisor.graph3d.failed')}</strong>
|
||||
<button type="button" className="secondary-button" onClick={retry}>{t('supervisor.graph3d.retry')}</button>
|
||||
<button type="button" className="link-button" onClick={() => setGraphMode('flat')}>{t('supervisor.graph3d.modes.flat')}</button>
|
||||
</div>}>
|
||||
<Suspense fallback={<p className="supervisor-workspace__muted" role="status">{t('supervisor.loading')}</p>}>
|
||||
<StoryGraph3D stories={storyState.view.stories} attention={graph.attention ?? []}
|
||||
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
|
||||
@@ -857,6 +873,7 @@ export function SupervisorWorkspace({
|
||||
selectedExperienceId={selection?.kind === 'experience' ? selection.id : undefined}
|
||||
onSelectExperience={(id) => select({ kind: 'experience', id })} />
|
||||
</Suspense>
|
||||
</SpiralBoundary>
|
||||
) : <>
|
||||
<figure className="supervisor-workspace__figure">
|
||||
<div
|
||||
|
||||
@@ -122,7 +122,10 @@ export const heartbeat = {
|
||||
experiences: 'Experiences across staves',
|
||||
legend: 'Height is time, one turn is {{turn}} ({{count}} turns); radius is conversation density at the time and only shows how concentrated the work was. A stave spans its story from first to last event. Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.',
|
||||
turns: { 3: '3 hours', 6: '6 hours', 12: '12 hours', 24: '1 day', 168: '1 week', 720: '1 month', 2160: '1 quarter', 8760: '1 year' },
|
||||
unsupported: 'This device cannot show 3D. Use the flat view and the story list.'
|
||||
unsupported: 'This device cannot show 3D. Use the flat view and the story list.',
|
||||
failed: 'The time spiral did not load, possibly because the system is busy. The lists and the flat view still work.',
|
||||
lost: 'The graphics card interrupted the time spiral. The lists and the flat view still work.',
|
||||
retry: 'Reload the time spiral'
|
||||
},
|
||||
stories: {
|
||||
empty: 'No stories yet. After the next review, events are grouped into features and sub-threads under each project.',
|
||||
|
||||
@@ -119,7 +119,10 @@ export const heartbeat = {
|
||||
experiences: '跨木片的经验',
|
||||
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。桶外的菱形是经验,弧线从形成它的木片连到后来用上它的木片。',
|
||||
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
|
||||
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。'
|
||||
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。',
|
||||
failed: '时间螺旋没有加载成功,可能是系统正忙。左侧列表和平铺视图仍可使用。',
|
||||
lost: '显卡暂时中断了时间螺旋的绘制。左侧列表和平铺视图仍可使用。',
|
||||
retry: '重新加载时间螺旋'
|
||||
},
|
||||
stories: {
|
||||
empty: '还没有故事。下一次回顾完成后,会把事件归入项目下的功能和子线索。',
|
||||
|
||||
Reference in New Issue
Block a user