fix: let story graph views fill the canvas column with compact controls
This commit is contained in:
@@ -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'))
|
||||
|
||||
@@ -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('决定')
|
||||
|
||||
@@ -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: '时间螺旋没有加载成功,可能是系统正忙。左侧列表和平铺视图仍可使用。',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user