fix: let story graph views fill the canvas column with compact controls
This commit is contained in:
@@ -69,7 +69,7 @@
|
||||
实现:
|
||||
|
||||
- `experienceLinks()`(`story-graph-3d-model.ts`):经验在当前层中,从最早形成依据所在的木片,连到之后第一个在另一片木片上的应用。只在同一片木片里用过的、应用不晚于形成的、依据不在当前层的经验不画。每层最多 6 条,按应用到的不同木片数排序。
|
||||
- 时间螺旋:经验画成桶外的菱形,高度在形成与应用两个时间之间,角度在两片木片之间;两段向外弯的弧线分别连到形成木片和应用木片的对应高度。只有选中的经验在画布上显示名称。画布下方新增“跨木片的经验”列表,可用键盘选择;点击菱形或列表项打开经验详情。
|
||||
- 时间螺旋:经验画成桶外的菱形,高度在形成与应用两个时间之间,角度在两片木片之间;两段向外弯的弧线分别连到形成木片和应用木片的对应高度。只有选中的经验在画布上显示名称。提供“跨木片的经验”键盘入口(现为工具栏选择框);点击菱形或选择经验打开详情。
|
||||
|
||||
验证:
|
||||
|
||||
@@ -160,7 +160,11 @@
|
||||
|
||||
- 图谱画布标题右侧新增“平铺 / 时间螺旋”切换。时间螺旋视图(`StoryGraph3D.tsx`)读取故事列表与图谱返回的 `attention`:高度为时间,半径为每圈的对话轮次(一圈一个半径,相邻圈差值受限),木片为故事起止,事件按屏幕距离聚合。层级为 全部 › 项目 › 功能 › 子线索,跨项目故事在根层;点击木片进入下一层并在右侧显示故事详情,单个事件选中后显示事件详情。斜俯视/侧视/俯视/自由视角,拖动、滚轮、方向键、数字键、Esc。
|
||||
- 计算部分在 `story-graph-3d-model.ts`(树、角度、圈长、半径、聚合),渲染只负责显示。圈长从候选档位中选最接近 8 圈且不超过 14 圈的一档。
|
||||
- Three.js `0.186.1` 作为依赖,组件通过 `lazy` 加载,只在打开时间螺旋时下载(生产构建中只出现在 `StoryGraph3D` 分块)。颜色取现有 `--graph-node-*` 令牌。WebGL 不可用时提示改用平铺视图;画布下方以列表重复当前层木片,供键盘与读屏使用。
|
||||
- Three.js `0.186.1` 作为依赖,组件通过 `lazy` 加载,只在打开时间螺旋时下载(生产构建中只出现在 `StoryGraph3D` 分块)。颜色取现有 `--graph-node-*` 令牌。WebGL 不可用时提示改用平铺视图。
|
||||
- 2026-10-02 布局调整:画布下方的图例段落和木片、经验列表移除,中间区域只保留图。图例改为工具栏“图例”按钮,点击后浮在画布右上角,可用 X 关闭;圈长(如“一圈 1 天 · 共 13.1 圈”)常驻画布左下角。木片和经验的键盘与读屏入口改为工具栏中的两个选择菜单(共享 `AnchoredMenu` 样式,每项显示名称和事件数、起止或来源→去向)。按钮按当前层级命名,如“项目与跨项目故事 · 6”“功能 · 15”“子线索 · 9”“经验 · 3”,不再使用“木片”这一内部比喻。菜单内边距 4px,菜单项 8px×12px、行间 2px,两个按钮间距 8px;1440 与 390 宽度下文字无裁切,菜单在窗口内。
|
||||
- 2026-10-03 图谱中间列不再滚动:平铺与时间螺旋都把标题栏以外的高度全部给图。平铺视图的图例、阅读说明移入标题栏“图例”浮层,可见数量常驻画布左下角;“事件浏览”改为单行(上一个、滑块、下一个、位置、当前事件)。图谱行最低 660px,以免平铺图文字小于 11px;更矮的窗口改为页面滚动。Electron 布局测试新增断言:中间列 `overflow: hidden` 且无溢出,图区一直延伸到列底部;1440×1200 下整页无滚动、底部留 33px,图高 625px。真实数据副本下时间螺旋在 700px 行高中填满,无列内滚动。
|
||||
- 2026-10-03 图谱页顶部的“图谱范围 · 全局”和时间行并入画布标题下方一行(范围 · 时间区间 · 生成时间 · 事件/实体数),刷新改为标题栏图标按钮;标题由“工作的来路”改为“事件与知识”。图谱行因此多出约 40px:1440×900 下整页不再滚动,图高 552px。布局测试新增断言:范围行位于标题下方,图谱上方不再有独立的范围栏。
|
||||
- 2026-10-03 修复“时间螺旋没有加载成功”:每次重建场景都复用同一个画布,而上一次释放时已对它调用 `forceContextLoss()`,该画布再也拿不到可用的 WebGL 上下文(Three.js 报 `Cannot read properties of null (reading 'precision')`)。开发模式的 StrictMode 双挂载、父组件刷新时传入新的 `attention` 数组都会触发重建,所以打开就失败。现在每次重建创建独立的绘图画布并在释放时移除,原画布只负责焦点和指针事件;`attention` 按内容保持稳定,刷新不再重建场景。复现脚本(StrictMode + 20 次父组件重渲染)修复前首屏即报错,修复后无报错。真实数据副本的 Electron 检查(1440 浅色、390 深色):画布外无段落和列表,图例浮层位于画布内,选择框逐层打开、视角切换、Esc 返回正常,无报错和横向溢出。
|
||||
|
||||
验证:
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
|
||||
|
||||
高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 420px。三栏时整页不再出现纵向滚动;左侧列表、中间画布、右侧详情各自在这一行内独立滚动,滚动只影响自身,不带动页面。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。
|
||||
高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 660px(再矮平铺图的文字会小于 11px,这时改为页面滚动)。三栏时整页不再出现纵向滚动;左侧列表和右侧详情在这一行内独立滚动,滚动只影响自身,不带动页面。中间画布列不出现滚动条:标题栏和底部单行“事件浏览”栏之外的高度全部给图,平铺与时间螺旋一致。画布标题为“事件与知识”,下方一行小字依次显示图谱范围、时间区间、生成时间和事件/实体数,刷新为标题栏右侧图标按钮;图谱上方不再单独占一行显示范围与时间。没有图谱数据时,刷新按钮保留在页签顶部。图例默认收起,从标题栏“图例”按钮打开,浮在画布右上角,可用 X 关闭;可见事件与实体数量常驻画布左下角。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。
|
||||
|
||||
实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例常显,读图说明可展开。
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -600,7 +600,7 @@ app
|
||||
}
|
||||
await open(false)
|
||||
await js(
|
||||
'document.querySelector(".supervisor-workspace__legend .link-button").click()'
|
||||
'document.querySelector(".supervisor-workspace__canvas-tools .link-button").click()'
|
||||
)
|
||||
await wait(
|
||||
'!document.querySelector(".supervisor-workspace__map.has-selection")'
|
||||
@@ -657,7 +657,7 @@ app
|
||||
report.texts.every(
|
||||
(text) => text.inSvg && text.painted && text.screenFontSize >= 11
|
||||
),
|
||||
'Clipped or invisible SVG text'
|
||||
`Clipped or invisible SVG text at ${width}: ${JSON.stringify(report.texts.filter(text => !(text.inSvg && text.painted && text.screenFontSize >= 11)).slice(0, 3))} svg=${JSON.stringify(report.svg)}`
|
||||
)
|
||||
for (const [index, a] of report.texts.entries()) {
|
||||
for (const b of report.texts.slice(index + 1)) {
|
||||
@@ -825,7 +825,7 @@ app
|
||||
await writeFile(join(artifacts, `dense-bottom-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
// The graph row fills the window down to a steady bottom margin; each column scrolls on its own.
|
||||
for (const [width, height] of [[1440, 900], [1440, 700], [1024, 800]]) {
|
||||
for (const [width, height] of [[1440, 1200], [1440, 900], [1440, 700], [1024, 1100]]) {
|
||||
win.setContentSize(width, height)
|
||||
await settle()
|
||||
const fill = await js(`(() => {
|
||||
@@ -836,24 +836,44 @@ app
|
||||
const columns = [...layout.children].map(column => {
|
||||
const scroller = column.matches('.supervisor-workspace__graph-list') ? column.querySelector('[role=tabpanel]') : column;
|
||||
const r = column.getBoundingClientRect();
|
||||
return { name: column.className.split(' ')[0], top: r.top, bottom: r.bottom, scrolls: getComputedStyle(scroller).overflowY,
|
||||
clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight };
|
||||
return { name: column.className.split(' ')[0], canvas: column.matches('.supervisor-workspace__graph-canvas'), top: r.top, bottom: r.bottom,
|
||||
scrolls: getComputedStyle(scroller).overflowY, clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight };
|
||||
});
|
||||
const canvas = layout.querySelector('.supervisor-workspace__graph-canvas'), map = layout.querySelector('.supervisor-workspace__map').getBoundingClientRect();
|
||||
const playback = layout.querySelector('.supervisor-workspace__playback')?.getBoundingClientRect();
|
||||
const graphArea = { map: { top: map.top, bottom: map.bottom, height: map.height }, playbackBottom: playback?.bottom, canvasBottom: canvas.getBoundingClientRect().bottom };
|
||||
// Bottom of the first grid row (the side-by-side columns), measured from the window bottom.
|
||||
const rowBottom = Math.max(...columns.filter(column => Math.abs(column.top - columns[0].top) < 1).map(column => column.bottom));
|
||||
return { bottomGap: shellBox.bottom - rowBottom, wrapped: columns.length - columns.filter(column => Math.abs(column.top - columns[0].top) < 1).length,
|
||||
height: box.height, pageScroll: shell.scrollHeight - shell.clientHeight, columns };
|
||||
height: box.height, pageScroll: shell.scrollHeight - shell.clientHeight, columns, graphArea };
|
||||
})()`)
|
||||
reports.push({ fill: { width, height, ...fill } })
|
||||
await writeFile(join(artifacts, `fill-${width}x${height}.json`), JSON.stringify({ width, height, bottomGap: Math.round(fill.bottomGap), wrapped: fill.wrapped, pageScroll: fill.pageScroll, rowHeight: Math.round(fill.columns[0].bottom - fill.columns[0].top) }))
|
||||
assert(fill.bottomGap >= 20 && fill.bottomGap <= 40, `Graph must stop above the window bottom: ${JSON.stringify(fill)}`)
|
||||
// Three columns fit the window with no page scroll; at two columns only the wrapped detail extends the page.
|
||||
if (!fill.wrapped) assert(fill.pageScroll <= 1, `Page must not scroll at three columns: ${JSON.stringify(fill)}`)
|
||||
await writeFile(join(artifacts, `fill-${width}x${height}.json`), JSON.stringify({ width, height, bottomGap: Math.round(fill.bottomGap), wrapped: fill.wrapped, pageScroll: fill.pageScroll, rowHeight: Math.round(fill.columns[0].bottom - fill.columns[0].top), mapHeight: Math.round(fill.graphArea.map.height) }))
|
||||
// At the 660px minimum (labels stay ≥ 11px) a short window scrolls the page; otherwise the row ends at the bottom margin.
|
||||
const atMinimum = Math.abs(fill.columns[0].bottom - fill.columns[0].top - 660) < 1
|
||||
if (!atMinimum) {
|
||||
assert(fill.bottomGap >= 20 && fill.bottomGap <= 40, `Graph must stop above the window bottom: ${JSON.stringify(fill)}`)
|
||||
// Three columns fit the window with no page scroll; at two columns only the wrapped detail extends the page.
|
||||
if (!fill.wrapped) assert(fill.pageScroll <= 1, `Page must not scroll at three columns: ${JSON.stringify(fill)}`)
|
||||
}
|
||||
const sideBySide = fill.columns.filter(column => Math.abs(column.top - fill.columns[0].top) < 1)
|
||||
for (const column of sideBySide) {
|
||||
assert(Math.abs(column.bottom - sideBySide[0].bottom) < 1, `Columns must share the row height: ${JSON.stringify(fill)}`)
|
||||
assert(['auto', 'scroll'].includes(column.scrolls), `Column must scroll on its own: ${JSON.stringify(column)}`)
|
||||
// The graph column has no scroll bar: the graph takes the height left after the heading and event bar.
|
||||
if (column.canvas) assert(column.scrollHeight <= column.clientHeight + 1 && column.scrolls === 'hidden', `Graph column must not scroll: ${JSON.stringify(column)}`)
|
||||
else assert(['auto', 'scroll'].includes(column.scrolls), `Column must scroll on its own: ${JSON.stringify(column)}`)
|
||||
}
|
||||
if (width === 1440) {
|
||||
const heading = await js(`(() => {
|
||||
const title = document.querySelector('.supervisor-workspace__canvas-title'), [name, meta] = title.children;
|
||||
return { name: name.textContent, meta: meta.textContent, metaBelow: meta.getBoundingClientRect().top >= name.getBoundingClientRect().bottom - 1,
|
||||
actionBarAbove: !!document.querySelector('[role=tabpanel] > .supervisor-workspace__action-bar'), height: document.querySelector('.supervisor-workspace__canvas-heading').getBoundingClientRect().height };
|
||||
})()`)
|
||||
assert(heading.metaBelow && heading.meta.includes('图谱范围') && !heading.actionBarAbove && heading.height <= 80, `Scope line belongs under the canvas title: ${JSON.stringify(heading)}`)
|
||||
reports.push({ heading })
|
||||
}
|
||||
assert(Math.abs((fill.graphArea.playbackBottom ?? fill.graphArea.map.bottom) - fill.graphArea.canvasBottom) <= 1,
|
||||
`Graph area must reach the column bottom: ${JSON.stringify(fill.graphArea)}`)
|
||||
await writeFile(join(artifacts, `fill-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
win.setContentSize(1440, 1100)
|
||||
|
||||
Reference in New Issue
Block a user