fix: let story graph views fill the canvas column with compact controls

This commit is contained in:
mesalogo
2026-10-03 10:23:09 +08:00
parent baa6defc0c
commit 50481deaa1
10 changed files with 451 additions and 186 deletions
+1 -1
View File
@@ -257,7 +257,7 @@ describe('HeartbeatCenter', () => {
}
expect(within(panel).getByRole('region', { name: t('supervisor.canvas') })).toBeVisible()
expect(within(panel).getByRole('heading', { name: t('supervisor.inspector') })).toBeVisible()
expect(within(panel).getByText(`${t('supervisor.graphScope')}: ${t('center.scope.global')}`)).toBeVisible()
expect(within(panel).getByText(new RegExp(`^${t('supervisor.graphScope')}: ${t('center.scope.global')} · `))).toBeVisible()
}
if (key === 'activity.title') {
await screen.findByText(t('activity.empty'))
+69 -20
View File
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Info, X } from 'lucide-react'
import { AnchoredMenu } from './AnchoredMenu'
import { useTranslation } from 'react-i18next'
import * as THREE from 'three'
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
@@ -32,16 +34,39 @@ const toneVariables = ['--graph-node-1-border', '--graph-node-2-border', '--grap
function cssColor(element: Element, variable: string, fallback: string): string {
return getComputedStyle(element).getPropertyValue(variable).trim() || fallback
}
type PickerItem = { id: string; title: string; detail: string; current: boolean; onPick: () => void }
/** Toolbar picker in the shared anchored-menu style: a title and a secondary line per item. */
function PickerMenu({ label, button, items }: { label: string; button: string; items: PickerItem[] }) {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLButtonElement>(null)
const id = useId()
return <>
<button ref={anchorRef} type="button" className="secondary-button story-graph-3d__picker" aria-haspopup="menu" aria-expanded={open}
aria-controls={open ? id : undefined} aria-label={`${label}:${button}`} onClick={() => setOpen(value => !value)}>
<span>{button}</span><ChevronDown size={14} aria-hidden="true" />
</button>
{open && <AnchoredMenu anchorRef={anchorRef} id={id} label={label} width={320} className="story-graph-3d__menu" onClose={() => setOpen(false)}>
{items.map(item => <button key={item.id} type="button" role="menuitem" aria-current={item.current || undefined}
onClick={() => { setOpen(false); anchorRef.current?.focus(); item.onPick() }}>
<span><strong>{item.title}</strong><small>{item.detail}</small></span>
{item.current && <Check size={14} aria-hidden="true" />}
</button>)}
</AnchoredMenu>}
</>
}
function webglAvailable(): boolean {
try { const canvas = document.createElement('canvas'); return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')) }
catch { return false }
}
export default function StoryGraph3D({ stories, attention, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
export default function StoryGraph3D({ stories, attention: attentionProp, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
const { t, i18n } = useTranslation('heartbeat')
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(null)
const [supported] = useState(webglAvailable)
// Refreshes deliver equal data in new arrays; keep the previous array so the scene is not recreated.
const attentionKey = JSON.stringify(attentionProp)
const attention = useMemo(() => attentionProp, [attentionKey]) // eslint-disable-line react-hooks/exhaustive-deps
// 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)
@@ -50,6 +75,8 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
const focus = findNode(tree, focusId) ?? tree
const [view, setView] = useState<ViewName>('oblique')
const [hovered, setHovered] = useState<string>()
const [legendOpen, setLegendOpen] = useState(false)
const legendId = useId()
const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 })
const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined)
const range = useMemo(() => {
@@ -66,12 +93,18 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
useEffect(() => {
const canvas = canvasRef.current, overlay = overlayRef.current
if (!supported || !canvas || !overlay || !range) return
// Each scene draws on a fresh canvas: a canvas whose context was released on dispose cannot give a working context again.
// `canvas` stays as the focus and pointer surface.
const glCanvas = document.createElement('canvas')
glCanvas.className = 'story-graph-3d__gl'
glCanvas.setAttribute('aria-hidden', 'true')
canvas.before(glCanvas)
// 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 }
try { renderer = new THREE.WebGLRenderer({ canvas: glCanvas, antialias: true, alpha: true }) }
catch (error) { glCanvas.remove(); console.error('GoodBuddy 3D view failed', error); queueMicrotask(() => setFailure('create')); return }
const lost = (event: Event) => { event.preventDefault(); setFailure('lost') }
canvas.addEventListener('webglcontextlost', lost)
glCanvas.addEventListener('webglcontextlost', lost)
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
const scene = new THREE.Scene()
const host = canvas.parentElement!
@@ -241,8 +274,8 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
link: hit.object.userData.link as ExperienceLink | undefined }
},
// 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() }
dispose: () => { canvas.removeEventListener('wheel', zoom); glCanvas.removeEventListener('webglcontextlost', lost); resize.disconnect()
disposables.forEach(item => item.dispose()); renderer.dispose(); renderer.forceContextLoss(); glCanvas.remove() }
}
return () => { engine.current?.dispose(); engine.current = undefined }
// Focus, hover and selection are pushed through update() below; only data, locale and a retry recreate the scene.
@@ -280,6 +313,9 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
<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>
const childKinds = new Set(level.children.map(node => node.level))
const childLevelName = t(`supervisor.graph3d.childLevels.${childKinds.size === 1 ? [...childKinds][0] : 'mixed'}`)
const scale = t('supervisor.graph3d.scale', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })
return <div className="story-graph-3d">
<div className="story-graph-3d__toolbar">
<nav aria-label={t('supervisor.graph3d.levels')}>
@@ -292,6 +328,17 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
<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>)}
<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>
{/* Keyboard and screen-reader access to the same staves and experiences shown on the canvas. */}
<div className="story-graph-3d__pickers">
<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) }))} />
{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>
</div>
<div className="story-graph-3d__viewport">
@@ -334,19 +381,21 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
event.preventDefault(); setView('free'); move[event.key]!(); engine.current?.draw()
}} />
<canvas ref={overlayRef} className="story-graph-3d__overlay" aria-hidden="true" />
{/* Scale stays visible on the canvas; the full explanation opens on demand. */}
<span className="story-graph-3d__scale" aria-hidden="true">{scale}</span>
{legendOpen && <div className="story-graph-3d__legend" id={legendId} role="note">
<div className="story-graph-3d__legend-head">
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<button type="button" className="icon-button" aria-label={t('supervisor.graph3d.legendClose')} title={t('supervisor.graph3d.legendClose')}
onClick={() => setLegendOpen(false)}><X size={14} aria-hidden="true" /></button>
</div>
<ul>
<li>{t('supervisor.graph3d.legendItems.time', { scale })}</li>
<li>{t('supervisor.graph3d.legendItems.radius')}</li>
<li>{t('supervisor.graph3d.legendItems.stave')}</li>
<li>{t('supervisor.graph3d.legendItems.experience')}</li>
</ul>
</div>}
</div>
<p className="supervisor-workspace__muted">{t('supervisor.graph3d.legend', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })}</p>
{/* The same staves as a list, for keyboard and screen readers. */}
{shownLinks.length > 0 && <ul className="story-graph-3d__list" aria-label={t('supervisor.graph3d.experiences')}>
{shownLinks.map(link => <li key={link.id}><button type="button" className="link-button" aria-pressed={link.id === selectedExperienceId}
onClick={() => onSelectExperience?.(link.id)}>
{link.statement} <small>{link.from.stave.name} → {link.to.stave.name}</small>
</button></li>)}
</ul>}
<ul className="story-graph-3d__list" aria-label={t('supervisor.graph3d.staves')}>
{level.children.map(node => <li key={node.id}><button type="button" className="link-button" aria-pressed={node.id === focus.id} onClick={() => open(node)}>
{node.name} <small>{t('supervisor.stories.count', { count: node.events.length })} · {dateText(node.start)} – {dateText(node.end)}</small>
</button></li>)}
</ul>
</div>
}
@@ -306,7 +306,12 @@ describe('SupervisorWorkspace', () => {
expect(within(canvas).queryByRole('heading', { name: '时间事件' })).not.toBeInTheDocument()
expect(within(canvas).getByRole('group', { name: '故事线图谱' })).toHaveAttribute('width', '100%')
expect(canvas.querySelector('.supervisor-workspace__timeline-ring')).toHaveAttribute('marker-end')
// The legend is closed by default and opens on the canvas, so the graph keeps the column.
expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
fireEvent.click(within(canvas).getByRole('button', { name: '图例' }))
expect(within(canvas).getByRole('list', { name: '图谱图例' })).toBeInTheDocument()
fireEvent.click(within(canvas).getByRole('button', { name: '关闭图例' }))
expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
expect(within(canvas).getByRole('slider', { name: '事件浏览' })).toBeDisabled()
expect(graph.querySelector('.supervisor-workspace__entity text')).toHaveTextContent('方案')
expect(graph.querySelector('.supervisor-workspace__node text')).toHaveTextContent('决定')
+87 -117
View File
@@ -1,7 +1,7 @@
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 { BookOpen, ChevronLeft, ChevronRight, Ellipsis, Info, Network, RefreshCw, X } from 'lucide-react'
import { AnchoredMenu } from './AnchoredMenu'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
@@ -42,6 +42,9 @@ type Props = {
onOpenConversation?: (conversationId: string) => void
onTabChange?: (tab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings') => void
}
const GRAPH_MIN_HEIGHT = 660
// Stable empty value: a new array per render would rebuild the 3D scene on every refresh.
const noAttention: NonNullable<SupervisionGraphView['attention']> = []
const emptyGraph: SupervisionGraphView = {
storyLine: null,
events: [],
@@ -84,6 +87,7 @@ export function SupervisorWorkspace({
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
const [graphMode, setGraphMode] = useState<'flat' | 'spiral'>('flat')
const [flatLegendOpen, setFlatLegendOpen] = useState(false)
const [source, setSource] = useState<{
id: string
title: string
@@ -491,7 +495,9 @@ export function SupervisorWorkspace({
: undefined
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
const graphLayoutRef = useRef<HTMLDivElement>(null)
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0)
// 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.
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, GRAPH_MIN_HEIGHT)
// The work review reads the selected result's own scope, so it matches the period shown.
const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id)
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
@@ -694,20 +700,9 @@ export function SupervisorWorkspace({
)}
{tab === 'graph' && (
<>
<div className="supervisor-workspace__action-bar">
{(graphScope || latest) && <div>
{graphScope && (
<p>
{t('supervisor.graphScope')}: {scopeText(graphScope)}
</p>
)}
{latest && (
<p>
{date(latest.timeRange.from)} –{' '}
{date(latest.timeRange.to)} · {date(latest.createdAt)}
</p>
)}
</div>}
{/* With a graph, scope and refresh live in the canvas heading; without one, keep refresh reachable here. */}
{!graph.events.length && <div className="supervisor-workspace__action-bar">
<span />
<button
className="secondary-button"
disabled={busy}
@@ -715,7 +710,7 @@ export function SupervisorWorkspace({
>
{t('center.actions.refresh')}
</button>
</div>
</div>}
{!loading && !loadError && !graph.events.length && (
<EmptyState
icon={<Network size={28} />}
@@ -846,13 +841,26 @@ export function SupervisorWorkspace({
aria-label={t('supervisor.canvas')}
>
<div className="supervisor-workspace__canvas-heading">
<strong>{t('supervisor.canvasTitle')}</strong>
<span>
{t('supervisor.counts', {
events: layout.events.length,
entities: layout.entities.length
})}
</span>
{/* Title, then one line with scope, period, generation time and counts. */}
<div className="supervisor-workspace__canvas-title">
<strong>{t('supervisor.canvasTitle')}</strong>
<span>
{[
graphScope && `${t('supervisor.graphScope')}: ${scopeText(graphScope)}`,
latest && `${date(latest.timeRange.from)} – ${date(latest.timeRange.to)}`,
latest && t('supervisor.generatedAt', { time: date(latest.createdAt) }),
t('supervisor.counts', { events: layout.events.length, entities: layout.entities.length })
].filter(Boolean).join(' · ')}
</span>
</div>
<button type="button" className="icon-button" aria-label={t('center.actions.refresh')} title={t('center.actions.refresh')}
disabled={busy} onClick={() => void refresh()}><RefreshCw size={14} aria-hidden="true" /></button>
{graphMode === 'flat' && <div className="supervisor-workspace__canvas-tools">
{selection && <button type="button" className="link-button" onClick={() => { setSelection(undefined); setSource(undefined) }}>
{t('supervisor.showAll')}</button>}
<button type="button" className="secondary-button" aria-expanded={flatLegendOpen} aria-controls={`${graphId}-legend`}
onClick={() => setFlatLegendOpen(value => !value)}><Info size={14} aria-hidden="true" />{t('supervisor.graph3d.legendTitle')}</button>
</div>}
{storyState.available && <div role="group" aria-label={t('supervisor.graph3d.mode')} className="supervisor-workspace__graph-mode">
{(['flat', 'spiral'] as const).map((mode) => <button key={mode} type="button" className="secondary-button"
aria-pressed={graphMode === mode} onClick={() => setGraphMode(mode)}>{t(`supervisor.graph3d.modes.${mode}`)}</button>)}
@@ -865,7 +873,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={storyState.view.stories} attention={graph.attention ?? []}
<StoryGraph3D stories={storyState.view.stories} attention={graph.attention ?? noAttention}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })}
@@ -1057,79 +1065,42 @@ export function SupervisorWorkspace({
))}
</svg>
</div>
<div className="supervisor-workspace__canvas-note">
{t('supervisor.canvasNote')}
</div>
<details className="supervisor-workspace__reading-guide">
<summary>
{t('supervisor.readingGuide')} ·{' '}
{t('supervisor.visibleCounts', {
events: layout.visibleEvents.length,
entities: layout.visibleEntities.length
})}
</summary>
<p id={`${graphId}-caption`}>
{t('supervisor.canvasCaption')}
</p>
</details>
<p id={`${graphId}-caption`} className="sr-only">{t('supervisor.canvasCaption')}</p>
{/* Reading aids float on the canvas so the graph keeps the whole column. */}
<span className="supervisor-workspace__map-hint" aria-hidden="true">
{t('supervisor.visibleCounts', { events: layout.visibleEvents.length, entities: layout.visibleEntities.length })}
</span>
{flatLegendOpen && <div className="supervisor-workspace__map-legend" id={`${graphId}-legend`} role="note">
<div className="supervisor-workspace__map-legend-head">
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<button type="button" className="icon-button" aria-label={t('supervisor.graph3d.legendClose')}
title={t('supervisor.graph3d.legendClose')} onClick={() => setFlatLegendOpen(false)}><X size={14} aria-hidden="true" /></button>
</div>
<ul className="supervisor-workspace__legend" aria-label={t('supervisor.legendLabel')}>
<li><i className="supervisor-workspace__legend-event" aria-hidden="true" />{t('supervisor.events')}</li>
<li><i className="supervisor-workspace__legend-entity" aria-hidden="true" />{t('supervisor.entities')}</li>
<li><i className="supervisor-workspace__legend-link" aria-hidden="true" />{t('supervisor.eventImpact')}</li>
<li><i className="supervisor-workspace__legend-relation" aria-hidden="true" />{t('supervisor.relations')}</li>
</ul>
<p>{t('supervisor.canvasNote')}</p>
<p>{t('supervisor.canvasCaption')}</p>
</div>}
</figure>
<ul
className="supervisor-workspace__legend"
aria-label={t('supervisor.legendLabel')}
>
<li>
<i
className="supervisor-workspace__legend-event"
aria-hidden="true"
/>
{t('supervisor.events')}
</li>
<li>
<i
className="supervisor-workspace__legend-entity"
aria-hidden="true"
/>
{t('supervisor.entities')}
</li>
<li>
<i
className="supervisor-workspace__legend-link"
aria-hidden="true"
/>
{t('supervisor.eventImpact')}
</li>
<li>
<i
className="supervisor-workspace__legend-relation"
aria-hidden="true"
/>
{t('supervisor.relations')}
</li>
{selection && (
<li>
<button
className="link-button"
onClick={() => {
setSelection(undefined)
setSource(undefined)
}}
>
{t('supervisor.showAll')}
</button>
</li>
)}
</ul>
{stageEvent && (
<div className="supervisor-workspace__playback">
<div className="supervisor-workspace__section-heading">
<label htmlFor={`${graphId}-stage`}>
{t('supervisor.playback')}
</label>
<output htmlFor={`${graphId}-stage`}>
{stage + 1} / {layout.events.length} ·{' '}
{date(stageEvent.occurred_at)}
</output>
</div>
<label htmlFor={`${graphId}-stage`}>
{t('supervisor.playback')}
</label>
<button
type="button"
className="icon-button"
aria-label={t('supervisor.previousStage')}
title={t('supervisor.previousStage')}
disabled={stage <= 0}
onClick={() => selectStage(stage - 1)}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<input
id={`${graphId}-stage`}
type="range"
@@ -1143,28 +1114,27 @@ export function SupervisorWorkspace({
selectStage(Number(event.target.value))
}
/>
<div className="supervisor-workspace__stage-actions">
<button
className="secondary-button"
disabled={stage <= 0}
onClick={() => selectStage(stage - 1)}
>
{t('supervisor.previousStage')}
</button>
<button
className="link-button"
onClick={() => selectStage(stage)}
>
{stageEvent.title}
</button>
<button
className="secondary-button"
disabled={stage >= layout.events.length - 1}
onClick={() => selectStage(stage + 1)}
>
{t('supervisor.nextStage')}
</button>
</div>
<button
type="button"
className="icon-button"
aria-label={t('supervisor.nextStage')}
title={t('supervisor.nextStage')}
disabled={stage >= layout.events.length - 1}
onClick={() => selectStage(stage + 1)}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
<output htmlFor={`${graphId}-stage`}>
{stage + 1} / {layout.events.length} · {date(stageEvent.occurred_at)}
</output>
<button
type="button"
className="link-button"
title={stageEvent.title}
onClick={() => selectStage(stage)}
>
{stageEvent.title}
</button>
</div>
)}
</>}
@@ -90,7 +90,7 @@ export const heartbeat = {
supervisor: {
automatic: 'Smart heartbeat',
projectScope: 'Project: {{names}}',
canvasTitle: 'How the work took shape', canvasNote: 'Read counter-clockwise · Select a node to explore knowledge and sources', readingGuide: 'Reading the graph',
canvasTitle: 'Events and knowledge', generatedAt: 'Generated {{time}}', canvasNote: 'Read counter-clockwise · Select a node to explore knowledge and sources', readingGuide: 'Reading the graph',
visibleCounts: '{{events}} events / {{entities}} entities on canvas', showAll: 'Show all connections', connections: 'Graph connections',
loadingHint: 'Reading saved reviews, events, and sources.', unavailableHint: 'Reopen the application to try again. Saved reviews remain on this device.',
selection: 'Graph selection', canvas: 'Story graph canvas', inspector: 'Details and sources',
@@ -116,11 +116,21 @@ export const heartbeat = {
},
graph3d: {
mode: 'Graph view', modes: { flat: 'Flat', spiral: 'Time spiral' },
levels: 'Stave levels', all: 'All', views: 'Viewpoint', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level; the list below offers the same staves.',
levels: 'Stave levels', all: 'All', views: 'Viewpoint', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level; the toolbar lists offer the same items.',
view: { oblique: 'Oblique', side: 'Side', top: 'Top', free: 'Free' },
staves: 'Staves at this level',
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.',
staves: 'Stories at this level',
experiences: 'Experiences across stories',
scale: 'One turn {{turn}} · {{count}} turns',
legendTitle: 'Legend', legendClose: 'Close legend',
legendItems: {
time: 'Height is time; {{scale}}.',
radius: 'Spiral radius is conversation density at the time and only shows how concentrated the work was.',
stave: 'A stave spans its story from first to last event.',
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' },
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.',
failed: 'The time spiral did not load, possibly because the system is busy. The lists and the flat view still work.',
@@ -87,7 +87,7 @@ export const heartbeat = {
supervisor: {
automatic: '智能心跳',
projectScope: '项目:{{names}}',
canvasTitle: '工作的来路', canvasNote: '沿外圈逆时针阅读 · 选择节点,查看知识与来源', readingGuide: '如何读图',
canvasTitle: '事件与知识', generatedAt: '生成于 {{time}}', canvasNote: '沿外圈逆时针阅读 · 选择节点,查看知识与来源', readingGuide: '如何读图',
visibleCounts: '画布中 {{events}} 个事件 / {{entities}} 个实体', showAll: '显示全部关联', connections: '图谱关联',
loadingHint: '正在读取已保存的回顾、事件与来源。', unavailableHint: '请重新打开应用后重试;已保存的回顾仍保留在本机。',
selection: '图谱选择', canvas: '故事图谱画布', inspector: '详情与来源',
@@ -113,11 +113,21 @@ export const heartbeat = {
},
graph3d: {
mode: '图谱视图', modes: { flat: '平铺', spiral: '时间螺旋' },
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;下方列表提供同样的木片。',
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;工具栏的列表提供同样的内容。',
view: { oblique: '斜俯视', side: '侧视', top: '俯视', free: '自由' },
staves: '当前层的木片',
experiences: '跨木片的经验',
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。桶外的菱形是经验,弧线从形成它的木片连到后来用上它的木片。',
staves: '当前层级的故事',
experiences: '跨故事的经验',
scale: '一圈 {{turn}} · 共 {{count}} 圈',
legendTitle: '图例', legendClose: '关闭图例',
legendItems: {
time: '高度为时间,{{scale}}。',
radius: '螺旋半径为当时的对话密度,只表示投入集中程度。',
stave: '木片上下跨度为故事的起止时间。',
experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。'
},
// Picker buttons name what the list holds at the current level.
childLevels: { project: '项目', feature: '功能', thread: '子线索', cross: '跨项目故事', mixed: '项目与跨项目故事' },
pickChildren: '{{level}} · {{count}}', pickExperience: '经验 · {{count}}',
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。',
failed: '时间螺旋没有加载成功,可能是系统正忙。左侧列表和平铺视图仍可使用。',
+221 -24
View File
@@ -399,7 +399,6 @@
grid-template-rows: var(--fill-height, auto);
grid-auto-rows: auto;
}
.supervisor-workspace__graph-canvas,
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
min-height: 0;
max-height: var(--fill-height, none);
@@ -408,6 +407,39 @@
scrollbar-width: thin;
scrollbar-gutter: stable;
}
/* The middle column never scrolls: the graph takes whatever height is left after the heading and the event bar. */
.supervisor-workspace__graph-canvas {
display: flex;
flex-direction: column;
min-height: 0;
max-height: var(--fill-height, none);
overflow: hidden;
}
.supervisor-workspace__graph-canvas > .supervisor-workspace__figure,
.supervisor-workspace__graph-canvas > .story-graph-3d {
flex: 1 1 0;
min-height: 0;
}
.supervisor-workspace__graph-canvas .supervisor-workspace__map-scroll {
position: absolute;
inset: 0;
overflow: hidden;
}
.supervisor-workspace__graph-canvas .supervisor-workspace__map {
min-width: 0;
width: 100%;
height: 100%;
max-height: none;
aspect-ratio: auto;
}
.supervisor-workspace__graph-canvas .story-graph-3d__viewport {
flex: 1 1 0;
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%;
@@ -431,13 +463,82 @@
padding: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
.supervisor-workspace__canvas-heading span {
.supervisor-workspace__canvas-heading {
align-items: center;
}
.supervisor-workspace__canvas-title {
display: grid;
gap: 2px;
flex: 1 1 240px;
min-width: 0;
}
.supervisor-workspace__canvas-title span {
color: var(--text-muted);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.supervisor-workspace__figure {
position: relative;
margin: 0;
}
.supervisor-workspace__canvas-tools {
display: flex;
align-items: center;
gap: var(--space-2);
margin-inline-start: auto;
}
.supervisor-workspace__canvas-tools + .supervisor-workspace__graph-mode {
margin-inline-start: 0;
}
.supervisor-workspace__canvas-tools > .secondary-button {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.supervisor-workspace__map-hint {
position: absolute;
inset-inline-start: var(--space-3);
inset-block-end: var(--space-3);
padding: 2px var(--space-2);
border-radius: var(--radius-control);
background: color-mix(in srgb, var(--surface-raised) 85%, transparent);
color: var(--text-muted);
font-size: var(--font-caption);
pointer-events: none;
}
.supervisor-workspace__map-legend {
position: absolute;
inset-inline-end: var(--space-3);
inset-block-start: var(--space-3);
z-index: 1;
width: min(300px, calc(100% - 2 * var(--space-3)));
max-height: calc(100% - 2 * var(--space-3));
overflow: auto;
box-sizing: border-box;
padding: var(--space-2) var(--space-3) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.6;
}
.supervisor-workspace__map-legend-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-primary);
}
.supervisor-workspace__map-legend .supervisor-workspace__legend {
padding: var(--space-2) 0;
border-top: 0;
gap: var(--space-2) var(--space-3);
}
.supervisor-workspace__map-legend p {
margin: var(--space-1) 0 0;
}
.supervisor-workspace__figure figcaption {
padding: 0 var(--space-4) var(--space-4);
color: var(--text-secondary);
@@ -505,10 +606,34 @@
border-top: 2px dashed var(--success);
transform: translateY(50%);
}
/* One compact row: label, previous, slider, next, position, current event. */
.supervisor-workspace__playback {
padding: var(--space-4);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1) var(--space-2);
flex-shrink: 0;
padding: var(--space-2) var(--space-4);
border-top: 1px solid var(--border-default);
background: var(--surface-subtle);
font-size: var(--font-caption);
}
.supervisor-workspace__playback label {
color: var(--text-secondary);
}
.supervisor-workspace__playback .link-button {
flex: 1 1 120px;
min-width: 0;
overflow: hidden;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
.supervisor-workspace .supervisor-workspace__playback input {
flex: 0 1 200px;
width: auto;
min-width: 80px;
margin: 0;
}
.supervisor-workspace__playback output {
color: var(--text-secondary);
@@ -756,20 +881,6 @@
line-height: 1.7;
margin-top: var(--space-2);
}
.supervisor-workspace__canvas-note {
padding: var(--space-2) var(--space-6);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.supervisor-workspace__reading-guide {
padding: var(--space-2) var(--space-6) var(--space-4);
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.7;
}
.supervisor-workspace__reading-guide summary {
cursor: pointer;
}
.supervisor-workspace__source {
padding: var(--space-4);
background: var(--surface-subtle);
@@ -1101,18 +1212,104 @@
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.story-graph-3d__overlay {
.story-graph-3d__overlay,
.story-graph-3d__gl {
pointer-events: none;
}
.story-graph-3d__list {
.story-graph-3d__toolbar > div > button {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.story-graph-3d__toolbar > .story-graph-3d__pickers {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
margin: 0;
padding: 0;
list-style: none;
gap: var(--space-2);
flex-basis: 100%;
}
.story-graph-3d__picker {
display: inline-flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
max-width: 100%;
}
.story-graph-3d__picker > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-graph-3d__picker > svg {
flex-shrink: 0;
color: var(--text-secondary);
}
.anchored-menu.story-graph-3d__menu {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-1);
}
.anchored-menu.story-graph-3d__menu [role="menuitem"] {
flex-shrink: 0;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
}
.anchored-menu.story-graph-3d__menu [role="menuitem"] > span {
display: grid;
gap: 2px;
min-width: 0;
line-height: 1.4;
}
.anchored-menu.story-graph-3d__menu [role="menuitem"] strong {
font-weight: 500;
}
.anchored-menu.story-graph-3d__menu [role="menuitem"] small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.story-graph-3d__list small {
.anchored-menu.story-graph-3d__menu [role="menuitem"][aria-current] {
background: var(--accent-subtle);
}
.anchored-menu.story-graph-3d__menu [role="menuitem"] > svg {
color: var(--accent);
}
.story-graph-3d__scale {
position: absolute;
inset-inline-start: var(--space-2);
inset-block-end: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-control);
background: color-mix(in srgb, var(--surface-raised) 85%, transparent);
color: var(--text-muted);
font-size: var(--font-caption);
pointer-events: none;
}
.story-graph-3d__legend {
position: absolute;
inset-inline-end: var(--space-2);
inset-block-start: var(--space-2);
width: min(280px, calc(100% - 2 * var(--space-2)));
max-height: calc(100% - 2 * var(--space-2));
overflow: auto;
box-sizing: border-box;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
font-size: var(--font-caption);
}
.story-graph-3d__legend-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.story-graph-3d__legend ul {
display: grid;
gap: var(--space-1);
margin: var(--space-1) 0 0;
padding-inline-start: 1.1em;
color: var(--text-muted);
}