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

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