fix: retain historical events in the story spiral

Render every selected review event, including events without current story membership, and label current grouping without inventing historical assignments. Keep history selection inside the central canvas heading, use shared camera controls, and fit the default scene within the viewport.

Validation: 46 focused tests, synthetic and read-only portable-data Electron checks, Node/Agent/Web typechecks, targeted ESLint, and diff checks passed. All 21 reported events remain represented at wide and narrow widths; no live data writes or model calls.

Release note: Historical story spirals now retain unassigned events. Review history sits inside the canvas column, and camera controls have consistent spacing and framing.
This commit is contained in:
mesalogo
2026-10-04 21:09:32 +08:00
parent 49fd0d3054
commit b9186a616c
15 changed files with 262 additions and 44 deletions
+21 -13
View File
@@ -1,9 +1,10 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Info, X } from 'lucide-react'
import { AnchoredMenu } from './AnchoredMenu'
import { SegmentedControl } from './WorkspacePrimitives'
import { useTranslation } from 'react-i18next'
import * as THREE from 'three'
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
import type { SupervisionAttentionSlot, SupervisionGraphView } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model'
@@ -14,6 +15,8 @@ import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels,
*/
type Props = {
stories: SupervisionStory[]
events: SupervisionGraphView['events']
projectNames: Map<string, string>
attention: SupervisionAttentionSlot[]
timeRange?: { from: string; to: string }
selectedEventId?: string
@@ -60,7 +63,7 @@ function webglAvailable(): boolean {
catch { return false }
}
export default function StoryGraph3D({ stories, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
export default function StoryGraph3D({ stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
const { t, i18n } = useTranslation('heartbeat')
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(null)
@@ -71,7 +74,8 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
// 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, projectNames, { events,
unassigned: t('supervisor.graph3d.unassigned'), unknownProject: t('supervisor.graph3d.unknownProject') }), [stories, events, projectNames, t])
const [focusId, setFocusId] = useState('root')
const focus = findNode(tree, focusId) ?? tree
const [view, setView] = useState<ViewName>('oblique')
@@ -145,7 +149,7 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
const groups = new Map<string, StoryCluster[]>()
const wisdom = cssColor(host, '--graph-node-4-border', '#b45309')
const world = new THREE.Group(); scene.add(world)
const clusterSpan = () => CLUSTER_PX / (HEIGHT * Math.min(width / 500, height / 560) * camera.current.zoom) * (range.to - range.from)
const clusterSpan = () => CLUSTER_PX / (HEIGHT * Math.min(width / 560, height / 660) * camera.current.zoom) * (range.to - range.from)
let builtSpan = -1
const rebuild = () => {
world.clear()
@@ -173,12 +177,11 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
const active = cluster.events.some(event => event.id === state.selected)
world.add(tube(arc, (Math.min(6, 1.3 + Math.log2(cluster.events.length) * 1.1) + (active ? 2 : 0)) * 0.45, solid(color, emphasis === 'dim' ? 0.3 : 0.9), 20))
}
// Connectors and the story's own colour on the helix only for the focused or hovered stave.
if (emphasis !== 'focus') continue
// Every event is represented at the root; connectors emphasize focus and selection only.
for (const cluster of clusters) {
const p = helix(cluster.t), active = cluster.events.some(event => event.id === state.selected)
world.add(tube([p, on(cluster.t, center(stave))], active ? 0.9 : 0.5, solid(color, active ? 1 : 0.6), 2))
const dot = new THREE.Mesh(own(new THREE.SphereGeometry(Math.min(6, 2.2 + Math.log2(cluster.events.length)) + (active ? 1.5 : 0), 16, 12)), solid(color))
if (emphasis === 'focus' || active) world.add(tube([p, on(cluster.t, center(stave))], active ? 0.9 : 0.5, solid(color, active ? 1 : 0.6), 2))
const dot = new THREE.Mesh(own(new THREE.SphereGeometry(Math.min(6, 2.2 + Math.log2(cluster.events.length)) + (active ? 1.5 : 0), 16, 12)), solid(color, emphasis === 'dim' ? 0.3 : 1))
dot.position.copy(p); dot.userData.cluster = cluster; dot.userData.node = stave
world.add(dot); meshes.push(dot)
}
@@ -207,7 +210,8 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
view3d.position.copy(target).add(new THREE.Vector3(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST))
view3d.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st)
view3d.lookAt(target)
view3d.fov = 2 * Math.atan(height / Math.min(width / 500, height / 560) / 2 / DIST) * 180 / Math.PI
// Leave room for the near rim in the default tilted perspective, including its lower edge.
view3d.fov = 2 * Math.atan(height / Math.min(width / 560, height / 660) / 2 / DIST) * 180 / Math.PI
view3d.aspect = width / height; view3d.zoom = zoom; view3d.updateProjectionMatrix()
}
const draw = () => {
@@ -300,7 +304,7 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
}
const choose = (name: ViewName) => { setView(name); if (name !== 'free') animate({ ...VIEWS[name], zoom: 1, pan: 0 }) }
const open = (node: StoryNode) => {
if (node.level !== 'project') onSelectStory(node.id)
if (node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)
setFocusId(node.id)
setHovered(undefined)
}
@@ -327,9 +331,9 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
: <button type="button" className="link-button" onClick={() => setFocusId(node.id)}>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</button>}
</span>)}
</nav>
<div role="group" aria-label={t('supervisor.graph3d.views')}>
{(['oblique', 'side', 'top', 'free'] as const).map(name => <button key={name} type="button" className="secondary-button"
aria-pressed={view === name} onClick={() => choose(name)}>{t(`supervisor.graph3d.view.${name}`)}</button>)}
<div className="story-graph-3d__views">
<SegmentedControl ariaLabel={t('supervisor.graph3d.views')} value={view} onChange={choose}
options={(['oblique', 'side', 'top', 'free'] as const).map(value => ({ value, label: t(`supervisor.graph3d.view.${value}`) }))} />
<button type="button" className="secondary-button" aria-expanded={legendOpen} aria-controls={legendId}
onClick={() => setLegendOpen(value => !value)}><Info size={14} aria-hidden="true" />{t('supervisor.graph3d.legendTitle')}</button>
</div>
@@ -338,10 +342,14 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
<PickerMenu label={t('supervisor.graph3d.staves')} button={t('supervisor.graph3d.pickChildren', { level: childLevelName, count: level.children.length })}
items={level.children.map(node => ({ id: node.id, title: node.name, detail: `${t('supervisor.stories.count', { count: node.events.length })} · ${dateText(node.start)} – ${dateText(node.end)}`,
current: node.id === focus.id, onPick: () => open(node) }))} />
<PickerMenu label={t('supervisor.events')} button={t('supervisor.graph3d.pickEvents', { count: focus.events.length })}
items={focus.events.map(event => ({ id: event.id, title: event.title, detail: dateText(event.t),
current: event.id === selectedEventId, onPick: () => onSelectEvent(event.id) }))} />
{shownLinks.length > 0 && <PickerMenu label={t('supervisor.graph3d.experiences')} button={t('supervisor.graph3d.pickExperience', { count: shownLinks.length })}
items={shownLinks.map(link => ({ id: link.id, title: link.statement, detail: `${link.from.stave.name} → ${link.to.stave.name}`,
current: link.id === selectedExperienceId, onPick: () => onSelectExperience?.(link.id) }))} />}
</div>
<span className="supervisor-workspace__muted">{t('supervisor.graph3d.membershipNote')}</span>
</div>
<div className="story-graph-3d__viewport">
<canvas ref={canvasRef} tabIndex={0} aria-label={t('supervisor.graph3d.canvas')}
@@ -44,6 +44,7 @@ describe('SupervisorWorkspace', () => {
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={navigation} />)
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
expect(screen.getByLabelText('历史结果').closest('.supervisor-workspace__graph-canvas')).not.toBeNull()
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ ...navigation }} />)
+5 -8
View File
@@ -474,11 +474,7 @@ export function SupervisorWorkspace({
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
: undefined
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
// Story membership is current and scope-wide; only plot events selectable in this review.
const spiralStories = useMemo(() => {
const ids = new Set(graph.events.map(event => event.id))
return storyState.view.stories.map(story => ({ ...story, events: story.events.filter(event => ids.has(event.id)) }))
}, [storyState.view.stories, graph.events])
const projectNames = useMemo(() => new Map(projects.map(project => [project.id, project.name])), [projects])
const graphLayoutRef = useRef<HTMLDivElement>(null)
// The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat
// graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead.
@@ -654,8 +650,8 @@ export function SupervisorWorkspace({
)}
{tab === 'graph' && (
<>
{historySelector}
{/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */}
{!graph.events.length && historySelector}
{!loading && !loadError && !graph.events.length && (
<div className="supervisor-workspace__graph-empty" ref={graphEmptyRef}>
<EmptyState
@@ -797,8 +793,9 @@ export function SupervisorWorkspace({
aria-label={t('supervisor.canvas')}
>
<div className="supervisor-workspace__canvas-heading">
{/* Title, then one line with scope, period, generation time and counts. */}
{/* History stays inside the canvas column with its title and review metadata. */}
<div className="supervisor-workspace__canvas-title">
{historySelector}
<strong>{t('supervisor.canvasTitle')}</strong>
<span>
{[
@@ -829,7 +826,7 @@ export function SupervisorWorkspace({
<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={spiralStories} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
<StoryGraph3D stories={storyState.view.stories} events={graph.events} projectNames={projectNames} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })}
@@ -146,7 +146,10 @@ export const heartbeat = {
experience: 'Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.'
},
// Picker buttons name what the list holds at the current level.
childLevels: { project: 'Projects', feature: 'Features', thread: 'Sub-threads', cross: 'Cross-project stories', mixed: 'Projects and cross-project stories' },
unassigned: 'No current story membership', unknownProject: 'Project unavailable',
membershipNote: 'Events from this review, grouped by current story membership. Historical membership was not saved.',
pickEvents: 'Events · {{count}}',
childLevels: { project: 'Projects', feature: 'Features', thread: 'Sub-threads', cross: 'Cross-project stories', unassigned: 'Unassigned events', mixed: 'Groups' },
pickChildren: '{{level}} · {{count}}', pickExperience: 'Experiences · {{count}}',
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.',
@@ -143,7 +143,10 @@ export const heartbeat = {
experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。'
},
// Picker buttons name what the list holds at the current level.
childLevels: { project: '项目', feature: '功能', thread: '子线索', cross: '跨项目故事', mixed: '项目与跨项目故事' },
unassigned: '当前无故事归属', unknownProject: '项目范围不可用',
membershipNote: '本次回顾事件按当前故事归属分组;未保存当时的故事归属。',
pickEvents: '事件 · {{count}}',
childLevels: { project: '项目', feature: '功能', thread: '子线索', cross: '跨项目故事', unassigned: '未归属事件', mixed: '分组' },
pickChildren: '{{level}} · {{count}}', pickExperience: '经验 · {{count}}',
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。',
@@ -12,6 +12,25 @@ const story = (id: string, level: SupervisionStory['level'], days: number[], ext
})
describe('story graph 3d model', () => {
it('retains historical IDs and genuinely unassigned events without borrowing current replacements', () => {
const saved = (id: string, project_id: string | null, d: number) => ({ id, project_id, title: 'Same title', description: '', occurred_at: at(d), started_at: at(d - 0.5) })
const events = [saved('old-extraction', 'p1', 1), saved('never-assigned', 'p1', 2), saved('unknown-project', null, 3), saved('exact-member', 'p1', 4)]
const current = story('feature', 'feature', [1, 4], { events: [
{ id: 'replacement', title: 'Same title', projectId: 'p1', startedAt: at(1), endedAt: at(1), primary: true, userSet: false },
{ id: 'exact-member', title: 'Current title', projectId: 'p1', startedAt: at(9), endedAt: at(9), primary: true, userSet: false }
] })
const tree = buildStoryTree([current], new Map([['p1', 'Project']]), { events, unassigned: 'Unassigned', unknownProject: 'Unknown' })
expect(tree.events.map(event => event.id)).toEqual(events.map(event => event.id))
expect(tree.children[0]!.children.map(node => [node.level, node.events.map(event => event.id)])).toEqual([
['feature', ['exact-member']], ['unassigned', ['old-extraction', 'never-assigned']]
])
expect(tree.children[1]!.name).toBe('Unknown')
expect(tree.events.at(-1)).toEqual({ id: 'exact-member', title: 'Same title', t: Date.parse(at(3.5)) })
const beforeStories = buildStoryTree([], new Map([['p1', 'Project']]), { events, unassigned: 'Unassigned', unknownProject: 'Unknown' })
expect(beforeStories.events).toEqual(tree.events)
expect(beforeStories.children.flatMap(project => project.children).every(node => node.level === 'unassigned')).toBe(true)
})
it('builds project › feature › thread with cross stories at the root and spans from primary events', () => {
const tree = buildStoryTree([story('feature', 'feature', [3, 1]), story('thread', 'thread', [5], { parentId: 'feature' }),
story('empty', 'feature', []), story('cross', 'cross', [2, 8])], new Map())
+33 -5
View File
@@ -1,4 +1,4 @@
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
import type { SupervisionAttentionSlot, SupervisionGraphView } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
/*
@@ -10,7 +10,7 @@ import type { SupervisionExperience, SupervisionStory } from '../../shared/super
export type StoryNode = {
id: string
name: string
level: 'root' | 'project' | 'feature' | 'thread' | 'cross'
level: 'root' | 'project' | 'feature' | 'thread' | 'cross' | 'unassigned'
parent?: StoryNode
children: StoryNode[]
/** Primary events of this node and its descendants; for cross stories, the events they link. */
@@ -28,11 +28,18 @@ const TAU = Math.PI * 2
export const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760]
const TARGET_TURNS = 8, MAX_TURNS = 14, TRANSITION = 0.3
/** Project › feature › thread, plus cross stories as root-level staves. Unassigned events are not staves. */
export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<string, string>): StoryNode {
/** Current memberships of exact review IDs; missing memberships never imply replacement events. */
export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<string, string>, review?: {
events: SupervisionGraphView['events']; unassigned: string; unknownProject: string
}): StoryNode {
const events = review && new Map(review.events.map(event => [event.id, event]))
if (events) stories = stories.map(story => ({ ...story, events: story.events.filter(event => events.has(event.id)) }))
const root: StoryNode = { id: 'root', name: '', level: 'root', children: [], events: [], start: 0, end: 0, a0: 0, a1: TAU, concluded: false }
const node = (story: SupervisionStory, parent: StoryNode): StoryNode => ({ id: story.id, name: story.name, level: story.level, parent, children: [],
events: story.events.filter(event => story.level === 'cross' || event.primary).map(event => ({ id: event.id, title: event.title, t: Date.parse(event.startedAt) })),
events: story.events.filter(event => story.level === 'cross' || event.primary).map(event => {
const saved = events?.get(event.id)
return { id: event.id, title: saved?.title ?? event.title, t: Date.parse(saved?.started_at ?? saved?.occurred_at ?? event.startedAt) }
}),
start: 0, end: 0, a0: 0, a1: 0, concluded: story.state === 'concluded' })
const projects = new Map<string, StoryNode>()
for (const story of stories.filter(item => item.level === 'feature' && item.projectId)) {
@@ -48,6 +55,27 @@ export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<st
project.children.push(feature)
}
root.children.push(...stories.filter(item => item.level === 'cross').map(story => node(story, root)))
if (review) {
const assigned = new Set(stories.filter(story => story.level !== 'cross').flatMap(story => story.events.filter(event => event.primary).map(event => event.id)))
for (const event of review.events) {
if (assigned.has(event.id)) continue
const projectId = event.project_id ?? ''
let project = projects.get(projectId)
if (!project) {
project = { id: `project:${projectId}`, name: projectNames.get(projectId) ?? review.unknownProject, level: 'project',
parent: root, children: [], events: [], start: 0, end: 0, a0: 0, a1: 0, concluded: false }
projects.set(projectId, project)
root.children.push(project)
}
let unassigned = project.children.find(child => child.level === 'unassigned')
if (!unassigned) {
unassigned = { id: `unassigned:${projectId}`, name: review.unassigned, level: 'unassigned', parent: project,
children: [], events: [], start: 0, end: 0, a0: 0, a1: 0, concluded: false }
project.children.push(unassigned)
}
unassigned.events.push({ id: event.id, title: event.title, t: Date.parse(event.started_at ?? event.occurred_at) })
}
}
finish(root)
layout(root, 0, TAU)
return root
+18 -7
View File
@@ -367,11 +367,17 @@
width: 100%;
min-width: 0;
}
.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation {
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation {
margin-top: 0;
}
.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation label {
flex: 0 1 480px;
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation label {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation select {
flex: 1;
}
.supervisor-workspace__recap li {
margin: var(--space-2) 0;
@@ -440,9 +446,6 @@
min-height: 0;
aspect-ratio: auto;
}
.supervisor-workspace__graph-canvas .story-graph-3d {
padding: 0 var(--space-4) var(--space-4);
}
.supervisor-workspace__graph-list {
contain: none;
height: 100%;
@@ -1211,6 +1214,7 @@
flex-direction: column;
gap: var(--space-2);
min-width: 0;
padding: var(--space-4);
}
.story-graph-3d__toolbar {
display: flex;
@@ -1223,7 +1227,14 @@
.story-graph-3d__toolbar > div {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
gap: var(--space-2);
min-width: 0;
}
.story-graph-3d__views {
margin-inline-start: auto;
align-items: center;
justify-content: flex-end;
max-width: 100%;
}
.story-graph-3d__viewport {
position: relative;