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 canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const overlayRef = useRef<HTMLCanvasElement>(null)
|
const overlayRef = useRef<HTMLCanvasElement>(null)
|
||||||
const [supported] = useState(webglAvailable)
|
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 tree = useMemo(() => buildStoryTree(stories, new Map()), [stories])
|
||||||
const [focusId, setFocusId] = useState('root')
|
const [focusId, setFocusId] = useState('root')
|
||||||
const focus = findNode(tree, focusId) ?? tree
|
const focus = findNode(tree, focusId) ?? tree
|
||||||
@@ -63,7 +66,12 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current, overlay = overlayRef.current
|
const canvas = canvasRef.current, overlay = overlayRef.current
|
||||||
if (!supported || !canvas || !overlay || !range) return
|
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))
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||||
const scene = new THREE.Scene()
|
const scene = new THREE.Scene()
|
||||||
const host = canvas.parentElement!
|
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,
|
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 }
|
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 }
|
return () => { engine.current?.dispose(); engine.current = undefined }
|
||||||
// Focus, hover and selection are pushed through update() below; only data and locale recreate the scene.
|
// Focus, hover and selection are pushed through update() below; only data, locale and a retry recreate the scene.
|
||||||
}, [supported, range, attention, i18n.resolvedLanguage]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [supported, range, attention, i18n.resolvedLanguage, attempt]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) },
|
useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) },
|
||||||
[focus, hovered, selectedEventId, range, attention, experiences, 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
|
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 (!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>
|
if (!range) return <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>
|
||||||
return <div className="story-graph-3d">
|
return <div className="story-graph-3d">
|
||||||
<div className="story-graph-3d__toolbar">
|
<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 { useTranslation } from 'react-i18next'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { BookOpen, Ellipsis, Network, RefreshCw, X } from 'lucide-react'
|
import { BookOpen, Ellipsis, Network, RefreshCw, X } from 'lucide-react'
|
||||||
import { AnchoredMenu } from './AnchoredMenu'
|
import { AnchoredMenu } from './AnchoredMenu'
|
||||||
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
||||||
@@ -19,7 +20,17 @@ import {
|
|||||||
} from '../../shared/supervision-contracts'
|
} from '../../shared/supervision-contracts'
|
||||||
|
|
||||||
// Three.js loads only when the spiral view is opened.
|
// 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 }
|
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. */
|
/** `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 } }
|
export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph'; focus?: { kind: 'story' | 'experience'; id: string } }
|
||||||
@@ -848,6 +859,11 @@ export function SupervisorWorkspace({
|
|||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
{graphMode === 'spiral' ? (
|
{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>}>
|
<Suspense fallback={<p className="supervisor-workspace__muted" role="status">{t('supervisor.loading')}</p>}>
|
||||||
<StoryGraph3D stories={storyState.view.stories} attention={graph.attention ?? []}
|
<StoryGraph3D stories={storyState.view.stories} attention={graph.attention ?? []}
|
||||||
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
|
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
|
||||||
@@ -857,6 +873,7 @@ export function SupervisorWorkspace({
|
|||||||
selectedExperienceId={selection?.kind === 'experience' ? selection.id : undefined}
|
selectedExperienceId={selection?.kind === 'experience' ? selection.id : undefined}
|
||||||
onSelectExperience={(id) => select({ kind: 'experience', id })} />
|
onSelectExperience={(id) => select({ kind: 'experience', id })} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</SpiralBoundary>
|
||||||
) : <>
|
) : <>
|
||||||
<figure className="supervisor-workspace__figure">
|
<figure className="supervisor-workspace__figure">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ export const heartbeat = {
|
|||||||
experiences: 'Experiences across staves',
|
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.',
|
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' },
|
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: {
|
stories: {
|
||||||
empty: 'No stories yet. After the next review, events are grouped into features and sub-threads under each project.',
|
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: '跨木片的经验',
|
experiences: '跨木片的经验',
|
||||||
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。桶外的菱形是经验,弧线从形成它的木片连到后来用上它的木片。',
|
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。桶外的菱形是经验,弧线从形成它的木片连到后来用上它的木片。',
|
||||||
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
|
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
|
||||||
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。'
|
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。',
|
||||||
|
failed: '时间螺旋没有加载成功,可能是系统正忙。左侧列表和平铺视图仍可使用。',
|
||||||
|
lost: '显卡暂时中断了时间螺旋的绘制。左侧列表和平铺视图仍可使用。',
|
||||||
|
retry: '重新加载时间螺旋'
|
||||||
},
|
},
|
||||||
stories: {
|
stories: {
|
||||||
empty: '还没有故事。下一次回顾完成后,会把事件归入项目下的功能和子线索。',
|
empty: '还没有故事。下一次回顾完成后,会把事件归入项目下的功能和子线索。',
|
||||||
|
|||||||
Reference in New Issue
Block a user