fix: synchronize graph review selection and attention radii
Share review history selection between overview and graph while preserving explicit navigation and stale-response guards. Return attention for the selected review scope and interval, and align spiral events and time with that review. Normalize radii from message rates, including partial turns, without lifting quiet turns toward peaks. Preserve one continuous helix, uniform time height, round turn sections, and smooth transitions; equal densities retain equal radii. Document current-message and hourly-bucket limitations. Validation: 51 focused unit tests, 2 production IPC tests, Electron DOM and geometry checks, Node/Agent/Web typecheck, targeted ESLint, and diff checks passed. Release note: Story graphs now offer synchronized review history selection, and spiral radii reflect recorded message density within the selected review.
This commit is contained in:
@@ -9308,10 +9308,17 @@ export class AssistantDatabase {
|
||||
if (!story) return { storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }
|
||||
const id = (story as { id: string }).id
|
||||
if (input.resultId) {
|
||||
const row = database.prepare('SELECT graph_snapshot_json FROM supervision_results WHERE id = ?').get(input.resultId) as { graph_snapshot_json: string }
|
||||
const row = database.prepare(`SELECT r.graph_snapshot_json, sr.scope_json, sr.time_range_json
|
||||
FROM supervision_results r JOIN supervision_runs sr ON sr.id = r.run_id WHERE r.id = ?`).get(input.resultId) as {
|
||||
graph_snapshot_json: string; scope_json: string; time_range_json: string
|
||||
}
|
||||
const snapshot = JSON.parse(row.graph_snapshot_json) as { entities: unknown[]; relations: Array<{ confirmation_state: string }> }
|
||||
const scope = JSON.parse(row.scope_json) as SupervisionRunRequest['scope']
|
||||
const range = JSON.parse(row.time_range_json) as SupervisionRunRequest['timeRange']
|
||||
return {
|
||||
storyLine: story, ...snapshot, relations: snapshot.relations.filter((relation) => relation.confirmation_state !== 'revoked'),
|
||||
attention: supervisionAttention(database, scope.kind === 'projects' ? scope.projectIds : undefined,
|
||||
new Date(range.from).toISOString(), new Date(range.to).toISOString()),
|
||||
events: database.prepare('SELECT * FROM supervision_events WHERE result_id = ? ORDER BY occurred_at').all(input.resultId),
|
||||
sources: database.prepare('SELECT * FROM supervision_sources WHERE result_id = ?').all(input.resultId),
|
||||
eventEntities: database.prepare(`SELECT ee.event_id, ee.entity_id FROM supervision_event_entities ee JOIN supervision_events e ON e.id = ee.event_id WHERE e.result_id = ?`).all(input.resultId),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ASSISTANT_DATABASE_SCHEMA_VERSION, AssistantDatabase } from './assistan
|
||||
import { SupervisorService, type SupervisorSummarizerRequest } from './supervisor-service'
|
||||
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import type { ReviewConfiguration } from './supervision-review-store'
|
||||
import { supervisionGraphViewSchema } from '../../shared/supervision-contracts'
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup() })
|
||||
@@ -53,6 +54,24 @@ async function fixture(crossProject = false) {
|
||||
|
||||
const current = (sql: DatabaseSync) => sql.prepare('SELECT * FROM supervision_events WHERE superseded_by IS NULL ORDER BY started_at').all()
|
||||
|
||||
it('supplies real message attention on the review-specific graph, bounded by its project and time range', async () => {
|
||||
const f = await fixture()
|
||||
await f.run({ kind: 'projects', projectIds: [f.a.id] })
|
||||
const result = f.db.listSupervisionResults()[0]!
|
||||
// Additional messages outside the selected review must not expand its attention span.
|
||||
f.db.saveLocalConversations([{ header: { id: randomUUID(), projectId: f.a.id, title: 'Outside review', updatedAt: base },
|
||||
messages: [
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Before', createdAt: Date.parse('2026-09-18T08:00:00Z'), state: 'complete' },
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Reply', createdAt: Date.parse('2026-09-20T08:15:00Z'), state: 'complete' },
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Outside', createdAt: Date.parse('2026-09-23T08:00:00Z'), state: 'complete' }
|
||||
] }])
|
||||
const graph = supervisionGraphViewSchema.parse(f.db.getSupervisionGraph({ resultId: result.id, storyLineId: result.storyLineId }))
|
||||
expect(graph.attention).toEqual([
|
||||
{ start: '2026-09-20T08:00:00.000Z', turns: 2, characters: 35 },
|
||||
{ start: '2026-09-20T09:00:00.000Z', turns: 1, characters: 31 }
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts each source once for every scope: a project review then global reads only the other project', async () => {
|
||||
const f = await fixture()
|
||||
await f.run({ kind: 'projects', projectIds: [f.a.id] })
|
||||
|
||||
@@ -6646,6 +6646,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
expect(database.getSupervisionSource(overview[0]!.sourceId)?.sourceId).toBe(conversationId)
|
||||
const graph = await electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: overview[0]!.storyLineId }) as { sources: unknown[] }
|
||||
expect(graph.sources).toHaveLength(evidence.length)
|
||||
expect(graph).toHaveProperty('attention', [{ start: from, turns: 2, characters: 14 }])
|
||||
expect(() => electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).toThrow('不匹配')
|
||||
expect(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId })).toThrow('不匹配')
|
||||
const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to))
|
||||
|
||||
@@ -15,6 +15,7 @@ import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels,
|
||||
type Props = {
|
||||
stories: SupervisionStory[]
|
||||
attention: SupervisionAttentionSlot[]
|
||||
timeRange?: { from: string; to: string }
|
||||
selectedEventId?: string
|
||||
onSelectEvent: (id: string) => void
|
||||
onSelectStory: (id: string) => void
|
||||
@@ -59,7 +60,7 @@ function webglAvailable(): boolean {
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
export default function StoryGraph3D({ stories, attention: attentionProp, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
|
||||
export default function StoryGraph3D({ stories, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null)
|
||||
@@ -80,9 +81,10 @@ export default function StoryGraph3D({ stories, attention: attentionProp, select
|
||||
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(() => {
|
||||
if (timeRange) return storyWindow(Date.parse(timeRange.from), Date.parse(timeRange.to))
|
||||
const times = tree.events.map(event => event.t)
|
||||
return times.length ? storyWindow(Math.min(...times) - 1_800_000, Math.max(...times) + 1_800_000) : undefined
|
||||
}, [tree])
|
||||
}, [tree, timeRange])
|
||||
const level = visibleLevel(focus)
|
||||
const shownLinks = useMemo(() => experienceLinks(experiences, level.children), [experiences, level])
|
||||
const trail: StoryNode[] = []
|
||||
|
||||
@@ -11,6 +11,46 @@ const render = (ui: React.ReactNode) => {
|
||||
}
|
||||
|
||||
describe('SupervisorWorkspace', () => {
|
||||
it('defaults the graph history to latest and synchronizes selection in both directions, including empty graphs', async () => {
|
||||
const old = { ...result, id: 'old', createdAt: '2026-08-01T00:00:00.000Z', summary: 'Old review' }
|
||||
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result, old], graph } } as never
|
||||
const view = render(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(screen.getByLabelText('历史结果')).toHaveValue(result.id)
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: result.id, storyLineId: result.storyLineId })
|
||||
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: old.id } })
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
view.rerender(<SupervisorWorkspace tab="overview" />)
|
||||
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
|
||||
expect(screen.getByText('Old review')).toBeVisible()
|
||||
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
expect(screen.getByLabelText('历史结果')).toHaveValue(result.id)
|
||||
expect(graph).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each(['story', 'experience'] as const)('preserves explicit %s navigation over the latest default and history changes', async kind => {
|
||||
const old = { ...result, id: 'old' }
|
||||
const story = { id: 'target', name: 'Target story', projectId: 'p', projectName: 'P', parentId: null, level: 'feature',
|
||||
description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: null, endedAt: null, events: [] }
|
||||
const experience = { id: 'target', statement: 'Target experience', conditions: '', boundaries: '', userEdited: false, events: [] }
|
||||
const graph = vi.fn(async () => ({ storyLine: { id: 'story', scope_json: '{"kind":"global"}' },
|
||||
events: [{ id: 'e', title: 'Event', description: '', occurred_at: result.createdAt }], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result, old], graph,
|
||||
stories: async () => ({ stories: [story], experiences: [experience], unassigned: 0, canUndo: false }) } } as never
|
||||
const navigation = { resultId: old.id, focus: { kind, id: 'target' } }
|
||||
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={navigation} />)
|
||||
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
|
||||
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
|
||||
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ ...navigation }} />)
|
||||
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
|
||||
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
|
||||
})
|
||||
|
||||
it('keeps source lookup failures retryable through notifications', async () => {
|
||||
const onNotify = vi.fn()
|
||||
const source = vi.fn().mockRejectedValueOnce(new TypeError('terminated')).mockResolvedValueOnce({ title: 'Source', content: 'Saved source', occurredAt: result.createdAt })
|
||||
|
||||
@@ -474,6 +474,11 @@ export function SupervisorWorkspace({
|
||||
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
|
||||
: undefined
|
||||
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
|
||||
// Story membership is current and scope-wide; only plot events selectable in this review.
|
||||
const spiralStories = useMemo(() => {
|
||||
const ids = new Set(graph.events.map(event => event.id))
|
||||
return storyState.view.stories.map(story => ({ ...story, events: story.events.filter(event => ids.has(event.id)) }))
|
||||
}, [storyState.view.stories, graph.events])
|
||||
const graphLayoutRef = useRef<HTMLDivElement>(null)
|
||||
// The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat
|
||||
// graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead.
|
||||
@@ -486,6 +491,21 @@ export function SupervisorWorkspace({
|
||||
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
|
||||
const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined
|
||||
const busy = !!api && (loading || pending !== undefined)
|
||||
const historySelector = results.length > 0 && <div className="supervisor-workspace__result-navigation">
|
||||
<label>
|
||||
{t('supervisor.history')}
|
||||
<select value={resultId ?? ''} disabled={busy}
|
||||
onChange={(event) => { setSelection(undefined); setSource(undefined); void refresh(event.target.value) }}>
|
||||
{!results.some((item) => item.id === resultId) && <option value={resultId ?? ''}>{t('supervisor.loading')}</option>}
|
||||
{results.map((item) => <option key={item.id} value={item.id}>
|
||||
{date(item.createdAt)} · {scopeText(item.scope)}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{tab === 'overview' && latest && <button className="secondary-button" onClick={() => onTabChange?.('graph')}>
|
||||
{t('supervisor.graph')}
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="supervisor-workspace" data-view={tab} aria-busy={busy}>
|
||||
@@ -586,26 +606,7 @@ export function SupervisorWorkspace({
|
||||
<button type="button" className="primary-button" onClick={() => void run(true)}>{t('supervisor.reanalyzeConfirm')}</button>
|
||||
</div>
|
||||
</div>}
|
||||
{results.length > 0 && <div className="supervisor-workspace__result-navigation">
|
||||
<label>
|
||||
{t('supervisor.history')}
|
||||
<select
|
||||
value={resultId ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => void refresh(event.target.value)}
|
||||
>
|
||||
{!results.some((item) => item.id === resultId) && <option value={resultId ?? ''}>{t('supervisor.loading')}</option>}
|
||||
{results.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{date(item.createdAt)} · {scopeText(item.scope)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{latest && <button className="secondary-button" onClick={() => onTabChange?.('graph')}>
|
||||
{t('supervisor.graph')}
|
||||
</button>}
|
||||
</div>}
|
||||
{historySelector}
|
||||
{latest && recapStories.available && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
|
||||
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
|
||||
{latest ? (
|
||||
@@ -653,6 +654,7 @@ export function SupervisorWorkspace({
|
||||
)}
|
||||
{tab === 'graph' && (
|
||||
<>
|
||||
{historySelector}
|
||||
{/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */}
|
||||
{!loading && !loadError && !graph.events.length && (
|
||||
<div className="supervisor-workspace__graph-empty" ref={graphEmptyRef}>
|
||||
@@ -827,7 +829,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 ?? noAttention}
|
||||
<StoryGraph3D stories={spiralStories} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
|
||||
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
|
||||
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
|
||||
onSelectStory={(id) => select({ kind: 'story', id })}
|
||||
|
||||
@@ -141,7 +141,7 @@ export const heartbeat = {
|
||||
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.',
|
||||
radius: 'Radius compares user and assistant messages per hour within this review. Equal rates have equal radii; no recorded messages gives the minimum radius. It does not measure time spent or productivity.',
|
||||
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.'
|
||||
},
|
||||
|
||||
@@ -138,7 +138,7 @@ export const heartbeat = {
|
||||
legendTitle: '图例', legendClose: '关闭图例',
|
||||
legendItems: {
|
||||
time: '高度为时间,{{scale}}。',
|
||||
radius: '螺旋半径为当时的对话密度,只表示投入集中程度。',
|
||||
radius: '半径比较本次回顾内每小时的用户与助手消息数。密度相同则半径相同,无消息记录时取最小半径,不代表投入时长或工作效率。',
|
||||
stave: '木片上下跨度为故事的起止时间。',
|
||||
experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。'
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('story graph 3d model', () => {
|
||||
expect(storyWindow(0, 3 * day, 3).turnHours).toBe(6)
|
||||
})
|
||||
|
||||
it('keeps each turn round: one radius per turn, bounded change between turns', () => {
|
||||
it('keeps each turn round with continuous transitions and actual density differences', () => {
|
||||
const window = storyWindow(Date.parse(at(0)), Date.parse(at(8)))
|
||||
const attention = [{ start: at(0.5), turns: 100, characters: 1 }, { start: at(4.5), turns: 1, characters: 1 }]
|
||||
const level = radiusLevels(window, attention)
|
||||
@@ -47,7 +47,35 @@ describe('story graph 3d model', () => {
|
||||
expect(level(0.5 / window.turns)).toBe(1)
|
||||
let previous = level(0), largest = 0
|
||||
for (let i = 1; i <= 800; i++) { const next = level(i / 800); largest = Math.max(largest, Math.abs(next - previous)); previous = next }
|
||||
expect(largest).toBeLessThan(0.05)
|
||||
expect(largest).toBeLessThan(0.06)
|
||||
expect(level(4.5 / window.turns)).toBeCloseTo(0.1)
|
||||
expect(level(2.5 / window.turns)).toBe(0)
|
||||
for (let d = 1; d < window.turns; d++) {
|
||||
expect(level((d - 1e-8) / window.turns)).toBeCloseTo(level((d + 1e-8) / window.turns), 6)
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes hourly rates including partial turns without inventing variation for equal densities', () => {
|
||||
const window = storyWindow(Date.parse(at(0)), Date.parse(at(0)) + 7.5 * 3_600_000, 3)
|
||||
const attention = Array.from({ length: 8 }, (_, hour) => ({
|
||||
start: new Date(window.from + hour * 3_600_000).toISOString(), turns: hour === 7 ? 2 : 4, characters: 0
|
||||
}))
|
||||
const level = radiusLevels(window, attention)
|
||||
for (const u of [0, 0.2, 0.5, 0.8, 1]) expect(level(u)).toBeCloseTo(1)
|
||||
expect(radiusLevels(window, [])(0.5)).toBe(0)
|
||||
expect(storyWindow(0, 1_800_000).turns).toBeCloseTo(1 / 6)
|
||||
})
|
||||
|
||||
it('retains a clipped boundary bucket and ignores attention outside the selected interval', () => {
|
||||
const from = Date.parse(at(0)) + 50 * 60_000
|
||||
const window = storyWindow(from, from + 6 * 3_600_000, 3)
|
||||
const level = radiusLevels(window, [
|
||||
{ start: at(-1), turns: 10000, characters: 0 },
|
||||
{ start: at(0), turns: 4, characters: 0 },
|
||||
{ start: new Date(from + 4 * 3_600_000).toISOString(), turns: 1, characters: 0 }
|
||||
])
|
||||
expect(level(0.25)).toBe(1)
|
||||
expect(level(0.75)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('merges events closer than the span and keeps the rest apart', () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ export type StoryWindow = { from: number; to: number; turnHours: number; turns:
|
||||
|
||||
const TAU = Math.PI * 2
|
||||
export const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760]
|
||||
const TARGET_TURNS = 8, MAX_TURNS = 14, MAX_STEP = 0.22, TRANSITION = 0.3
|
||||
const TARGET_TURNS = 8, MAX_TURNS = 14, TRANSITION = 0.3
|
||||
|
||||
/** Project › feature › thread, plus cross stories as root-level staves. Unassigned events are not staves. */
|
||||
export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<string, string>): StoryNode {
|
||||
@@ -78,32 +78,38 @@ export function layout(node: StoryNode, from: number, to: number): void {
|
||||
|
||||
/** Turn length keeps about 8 turns, at most 14, for any span from hours to years. */
|
||||
export function storyWindow(from: number, to: number, turnHours?: number): StoryWindow {
|
||||
const span = Math.max(1, (to - from) / 3_600_000)
|
||||
const span = Math.max(1 / 3_600_000, (to - from) / 3_600_000)
|
||||
// Closest to the target among steps that stay within the maximum (13 days → 1 day per turn, not 1.9 weekly turns).
|
||||
const fitting = TURN_STEPS.filter(step => span / step <= MAX_TURNS)
|
||||
let hours = turnHours ?? fitting.reduce((best, step) => Math.abs(span / step - TARGET_TURNS) < Math.abs(span / best - TARGET_TURNS) ? step : best, fitting[0] ?? TURN_STEPS.at(-1)!)
|
||||
if (span / hours > MAX_TURNS) hours = fitting[0] ?? TURN_STEPS.at(-1)!
|
||||
return { from, to: Math.max(to, from + 1), turnHours: hours, turns: Math.max(0.5, span / hours) }
|
||||
return { from, to: Math.max(to, from + 1), turnHours: hours, turns: span / hours }
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius level 0..1 at position u (0..1) along the window. Each turn holds one radius from its
|
||||
* attention, so it reads as a circle; neighbouring turns differ by at most MAX_STEP and ease at the boundary.
|
||||
* messages per hour, so it reads as a circle; neighbouring turns ease at the boundary.
|
||||
* Hourly buckets are distributed by overlap; their within-hour timing is unavailable.
|
||||
*/
|
||||
export function radiusLevels(window: StoryWindow, attention: SupervisionAttentionSlot[]): (u: number) => number {
|
||||
const n = Math.max(1, Math.ceil(window.turns - 1e-6))
|
||||
const perTurn = new Array<number>(n).fill(0)
|
||||
const duration = (window.to - window.from) / window.turns
|
||||
for (const slot of attention) {
|
||||
const t = Date.parse(slot.start) + 1_800_000
|
||||
if (t < window.from || t > window.to) continue
|
||||
perTurn[Math.min(n - 1, Math.floor((t - window.from) / (window.to - window.from) * window.turns))]! += slot.turns
|
||||
// The review query includes its upper endpoint, including an exact hourly boundary.
|
||||
if (Date.parse(slot.start) === window.to) { perTurn[n - 1]! += slot.turns; continue }
|
||||
const start = Math.max(window.from, Date.parse(slot.start))
|
||||
const end = Math.min(window.to, Date.parse(slot.start) + 3_600_000)
|
||||
if (end <= start) continue
|
||||
for (let d = 0; d < n; d++) {
|
||||
const overlap = Math.max(0, Math.min(end, window.from + (d + 1) * duration) - Math.max(start, window.from + d * duration))
|
||||
perTurn[d]! += slot.turns * overlap / (end - start)
|
||||
}
|
||||
}
|
||||
// Normalize rates, not totals: the last partial turn has a shorter exposure.
|
||||
for (let d = 0; d < n; d++) perTurn[d]! /= Math.min(duration, window.to - window.from - d * duration) / 3_600_000
|
||||
const top = Math.max(1e-6, ...perTurn)
|
||||
const level = perTurn.map(value => Math.sqrt(value / top))
|
||||
for (let pass = 0; pass < n; pass++) for (let d = 1; d < n; d++) {
|
||||
if (level[d]! - level[d - 1]! > MAX_STEP) level[d - 1] = level[d]! - MAX_STEP
|
||||
if (level[d - 1]! - level[d]! > MAX_STEP) level[d] = level[d - 1]! - MAX_STEP
|
||||
}
|
||||
return (u: number) => {
|
||||
const x = Math.max(0, Math.min(1, u)) * window.turns
|
||||
const d = Math.min(n - 1, Math.floor(x)), f = x - d, half = TRANSITION / 2
|
||||
@@ -175,4 +181,4 @@ export function experienceLinks(experiences: SupervisionExperience[], staves: St
|
||||
angle: a + delta / 2, t: (formed.t + to.t) / 2 }, weight: new Set(applied.map(item => item.stave)).size })
|
||||
}
|
||||
return links.sort((x, y) => y.weight - x.weight || x.link.from.t - y.link.from.t).slice(0, limit).map(item => item.link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +367,12 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation {
|
||||
margin-top: 0;
|
||||
}
|
||||
.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation label {
|
||||
flex: 0 1 480px;
|
||||
}
|
||||
.supervisor-workspace__recap li {
|
||||
margin: var(--space-2) 0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
Reference in New Issue
Block a user