From e7c38f7b7714acf50be045c37296f64a4a1f1d89 Mon Sep 17 00:00:00 2001 From: leiysky Date: Thu, 29 Oct 2026 13:15:39 +0800 Subject: [PATCH 1/8] fix: preserve inspection state across nested callbacks and navigation --- CHANGELOG.md | 1 + development/architecture.md | 2 + docs/headless.md | 2 + docs/view-state.md | 2 + src/core/inspection-notifications.ts | 23 +- src/elements/hierarchy-element.ts | 29 ++- .../browser/inspection-notifications.spec.ts | 196 ++++++++++++++++++ .../core/inspection-notifications.test.ts | 56 +++++ 8 files changed, 300 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba382ce..29f90b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All significant changes to ScopeView are documented in this file. ## Unreleased +- Keep queued inspection notifications flowing when a callback throws, and prevent older trace/profile pin restoration from overriding new keyboard navigation or dismissal. - Include snapshot-scoped exact source context (`MetricInspectionContext`, `TraceInspectionContext`, `ProfileInspectionContext`) in headless `onInspect`/`onSelect` callbacks and component `scope-inspect`/`scope-select` events. - Reduce exact-reading formatting and narrow-label layout costs while preserving source values and chart geometry. diff --git a/development/architecture.md b/development/architecture.md index 65d3363..e9baf35 100644 --- a/development/architecture.md +++ b/development/architecture.md @@ -59,6 +59,8 @@ Family-owned inspection context projectors expose exact source values, field rol The DOM-free `InspectionNotifications` coordinator in `core/inspection-notifications.ts` owns notification identity (`snapshot`, `key`, `series`, `pinned`), deduplication and reentrancy across all families and browser entries. It retains the last delivered state across loading and document replacements so reconciled selections report updated snapshots and cleared, failed or disconnected states clear once. Delivery commits before invoking host callbacks, and nested state changes wait for the current broadcast to finish before revalidating and draining in order. +A throwing callback does not discard valid queued reports, including work added during draining; the first thrown value propagates after the queue drains. Hierarchy components cancel an older pending pin restoration before navigation can paint or Escape can notify observers; a new restoration requested by a callback remains authoritative. Escape also cancels restoration while loading has released the runtime. + Views own their child DOM; components release runtime resources on disconnect. Shared tokens and the [accepted design baseline](design-baseline.md) govern paint and reading behavior. See [custom rendering](../docs/canvas-infra.md) for low-level consumers and [development](../CONTRIBUTING.md) for tests, scripts and packaging. diff --git a/docs/headless.md b/docs/headless.md index a7afaa4..f43fa39 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -86,6 +86,8 @@ Metric timestamps and numeric cells retain the supplied strings, including sub-m `snapshotId` remains stable across inspection, selection, resizing, themes and visibility changes for one compiled document, and changes when data is replaced. A compatible persistent selection reconciles to the replacement and reports the new `snapshotId` and values; clearing or dismissing a reading or selection reports `null` for both arguments. The [headless example](../examples/headless.html) displays the callback's JSON beside each family. +If a callback throws, valid queued notifications still finish before the first thrown value propagates. Disposing the view cancels pending callbacks. + ## Load layouts on demand `createReport` returns `Promise`. Its `update` method also returns a Promise: diff --git a/docs/view-state.md b/docs/view-state.md index 9827e8d..85f9735 100644 --- a/docs/view-state.md +++ b/docs/view-state.md @@ -38,6 +38,8 @@ export function restoreView(chart: ScopeChartElement, saved: string) { Use `updateDocument(input, { preserveViewState: true })` for a new snapshot of the same logical source. Compatible pins, visibility, collapsed nodes and viewport survive. Missing sources are cleared; a pin is not moved to the next row. A pin below a collapsed ancestor is also cleared. Assigning `document` directly resets state. +In trace/profile components, accepted keyboard navigation or Escape supersedes an older pending pin restoration. This applies to both preserved document state and an assigned `viewState`; Escape also cancels a pin retained during loading. A new restoration requested by a notification callback takes precedence over the earlier keyboard action. + Identity continuity is the caller's responsibility. A reused span ID in another trace is not the same source. Metric row-index keys are local to their snapshot, so row reordering may make preservation inappropriate. Keep source identity alongside the saved view. ## Share semantic state diff --git a/src/core/inspection-notifications.ts b/src/core/inspection-notifications.ts index 26d7a0f..26f50a0 100644 --- a/src/core/inspection-notifications.ts +++ b/src/core/inspection-notifications.ts @@ -71,15 +71,24 @@ export class InspectionNotifications { return true; } this.#delivering = true; - try { - return send(); - } finally { + const errors: unknown[] = []; + const attempt = (callback: () => boolean) => { try { - for (const pending of this.#pending) pending(); - } finally { - this.#pending.length = 0; - this.#delivering = false; + return callback(); + } catch (error) { + errors.push(error); + return false; } + }; + try { + const sent = attempt(send); + for (const pending of this.#pending) attempt(pending); + // Finish valid nested reports before propagating the first host failure. + if (errors.length) throw errors[0]; + return sent; + } finally { + this.#pending.length = 0; + this.#delivering = false; } } diff --git a/src/elements/hierarchy-element.ts b/src/elements/hierarchy-element.ts index 1166f61..b0f836e 100644 --- a/src/elements/hierarchy-element.ts +++ b/src/elements/hierarchy-element.ts @@ -509,13 +509,30 @@ export class HierarchyElement< this.#root.addEventListener( 'keydown', (event) => { - if ((event as KeyboardEvent).key === 'Escape' && this.#runtime?.session.active) { + const key = event as KeyboardEvent; + if ( + key.key !== 'Escape' || + (!this.#runtime?.session.active && this.#pendingPin === undefined) + ) + return; + const canvas = this.#el('.canvas'); + if ( + this.#runtime && + this.scene?.interactive !== false && + !key.metaKey && + !key.ctrlKey && + !key.altKey && + event.composedPath().includes(canvas) + ) { + // Cancel restoration before the runtime reports Escape; its callbacks may restore again. + this.#pendingPin = undefined; + } else { event.preventDefault(); this.#clearInspection(); - this.#el('.canvas').focus({ preventScroll: true }); + canvas.focus({ preventScroll: true }); } }, - { signal } + { capture: true, signal } ); const outside = (event: Event) => { if (this.#ui.dismissOnOutside !== false && !event.composedPath().includes(this)) @@ -647,7 +664,11 @@ export class HierarchyElement< this.#renderedCompiled = this.#compiled; return scene; }, - onNavigate: (target) => this.#reveal(target), + onNavigate: (target) => { + // A committed keyboard action supersedes restoration before reveal can paint. + this.#pendingPin = undefined; + this.#reveal(target); + }, inputConsumed: () => this.#input?.consumed ?? false, onInspect: (_value, position) => this.#showInspection(position, true), onError: (error) => { diff --git a/tests/browser/inspection-notifications.spec.ts b/tests/browser/inspection-notifications.spec.ts index 4ebef94..da5875f 100644 --- a/tests/browser/inspection-notifications.spec.ts +++ b/tests/browser/inspection-notifications.spec.ts @@ -337,6 +337,202 @@ for (const entry of inspectionEntries) { }); } + if (entry.surface !== 'component') { + test('a failing queued selection callback does not discard the inspection clear', async ({ + page + }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + f.pin(); + f.key('End'); + f.events.length = 0; + let dismissed = false; + f.hook = (notification) => { + if (notification.channel !== 'select') return; + if (!notification.context) throw new Error('Queued selection failure'); + dismissed = true; + f.dismiss(); + }; + f.key('Enter'); + await f.settle(); + const events = f.events.splice(0); + f.repaint(); + await f.settle(); + return { + dismissed, + events, + repeated: f.events, + latest: f.latest, + hidden: f.tooltipHidden + }; + }); + expect(errors).toEqual(['Queued selection failure']); + expect(result.dismissed).toBe(true); + expect(result.events.map((event) => [event.channel, event.key])).toEqual([ + ['select', result.events[0].key], + ['select', null], + ['inspect', null] + ]); + expect(result.latest).toEqual({ inspect: null, select: null }); + expect(result.hidden).toBe(true); + expect(result.repeated).toEqual([]); + }); + } + + if (entry.surface === 'component' && entry.family !== 'metric') { + for (const restore of ['document', 'view-state', 'loading'] as const) { + test(`Escape cancels a pending ${restore} pin restoration`, async ({ page }) => { + const result = await page.evaluate(async (restore) => { + const f = window.notificationFixture; + f.pin(); + const state = f.chart!.viewState; + f.events.length = 0; + if (restore === 'document') await f.replace('200'); + else if (restore === 'view-state') f.chart!.viewState = state; + else { + f.chart!.loading = true; + await f.settle(); + } + f.key('Escape'); + const immediate = { ...f.latest, pin: f.chart!.viewState.pinnedKey }; + f.chart!.loading = false; + await f.settle(); + return { + immediate, + latest: f.latest, + pin: f.chart!.viewState.pinnedKey, + events: f.events + }; + }, restore); + expect(result.immediate).toEqual({ inspect: null, select: null, pin: null }); + expect(result.latest).toEqual({ inspect: null, select: null }); + expect(result.pin).toBeNull(); + expect(result.events.map((event) => [event.channel, event.context])).toEqual([ + ['inspect', null], + ['select', null] + ]); + }); + } + + test('a restoration requested by the Escape callback survives the earlier dismissal', async ({ + page + }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + f.pin(); + const state = f.chart!.viewState; + await f.replace('200'); + let restored = false; + f.hook = (notification) => { + if (!restored && notification.channel === 'inspect' && !notification.context) { + restored = true; + f.chart!.viewState = state; + } + }; + f.key('Escape'); + const immediate = { restored, pin: f.chart!.viewState.pinnedKey }; + await f.settle(); + return { state, immediate, latest: f.latest, pin: f.chart!.viewState.pinnedKey }; + }); + expect(result.immediate).toEqual({ restored: true, pin: result.state.pinnedKey }); + expect(result.pin).toBe(result.state.pinnedKey); + expect(inspectionValue(result.latest.select)).toBe('200'); + }); + + for (const restore of ['document', 'view-state'] as const) { + for (const paint of [false, true]) { + for (const pin of [false, true]) { + test(`new keyboard ${pin ? 'selection' : 'navigation'} supersedes ${restore} restoration before ${paint ? 'synchronous' : 'scheduled'} paint`, async ({ + page + }) => { + const result = await page.evaluate( + async ({ restore, paint, pin }) => { + const f = window.notificationFixture; + f.pin(); + const previous = f.latest.select; + const state = f.chart!.viewState; + f.events.length = 0; + if (restore === 'document') await f.replace('200'); + else f.chart!.viewState = state; + if (paint && 'viewport' in f.chart!) + f.chart.viewport = { + start: f.entry.family === 'trace' ? 0.5 : 0, + end: f.entry.family === 'trace' ? 1 : 0.5, + scrollOffset: 0 + }; + let ready = 0; + f.chart!.addEventListener('scope-ready', () => ready++); + f.key('End'); + if (pin) f.key('Enter'); + const immediate = { ...f.latest, pin: f.chart!.viewState.pinnedKey, ready }; + await f.settle(); + return { + previous, + immediate, + latest: f.latest, + pin: f.chart!.viewState.pinnedKey, + events: f.events + }; + }, + { restore, paint, pin } + ); + const child = `${entry.family}:child`; + expect(result.immediate.ready > 0).toBe(paint); + expect(result.immediate.inspect?.key).toBe(child); + expect(result.immediate.select?.key ?? null).toBe(pin ? child : null); + expect(result.immediate.pin).toBe(pin ? child : null); + expect(result.latest.inspect?.key).toBe(child); + expect(inspectionValue(result.latest.inspect)).toBe('0'); + expect(result.latest.select?.key ?? null).toBe(pin ? child : null); + expect(result.pin).toBe(pin ? child : null); + expect(result.events.every((event) => event.key !== result.previous?.key)).toBe(true); + if (restore === 'document') + expect(result.latest.inspect?.snapshotId).not.toBe(result.previous?.snapshotId); + else expect(result.latest.inspect?.snapshotId).toBe(result.previous?.snapshotId); + }); + } + } + } + + test('a restore requested during navigation paint supersedes the older keyboard action', async ({ + page + }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + f.pin(); + const state = f.chart!.viewState; + await f.replace('200'); + if ('viewport' in f.chart!) + f.chart.viewport = { + start: f.entry.family === 'trace' ? 0.5 : 0, + end: f.entry.family === 'trace' ? 1 : 0.5, + scrollOffset: 0 + }; + let restored = false; + f.hook = (notification) => { + if ( + !restored && + notification.channel === 'inspect' && + notification.key?.endsWith(':child') + ) { + restored = true; + f.chart!.viewState = state; + } + }; + f.key('End'); + const immediate = { restored, pin: f.chart!.viewState.pinnedKey }; + await f.settle(); + return { state, immediate, latest: f.latest, pin: f.chart!.viewState.pinnedKey }; + }); + expect(result.immediate).toEqual({ restored: true, pin: result.state.pinnedKey }); + expect(result.latest.select?.key).toBe(result.state.pinnedKey); + expect(inspectionValue(result.latest.select)).toBe('200'); + expect(result.pin).toBe(result.state.pinnedKey); + }); + } + if (entry.surface === 'component') { for (const channel of ['inspect', 'select'] as const) { test(`independent ${channel} listeners receive a nested dismissal in broadcast order`, async ({ diff --git a/tests/unit/core/inspection-notifications.test.ts b/tests/unit/core/inspection-notifications.test.ts index 93913bb..a50a478 100644 --- a/tests/unit/core/inspection-notifications.test.ts +++ b/tests/unit/core/inspection-notifications.test.ts @@ -141,6 +141,62 @@ describe('inspection notifications', () => { expect(sent).toEqual(['null', 'b']); }); + it.each([ + 'inspect', + 'select' + ] as const)('drains the other channel when a queued %s callback throws', (channel) => { + const { state, notifications, deliver, sent } = fixture(); + const other = channel === 'inspect' ? 'select' : 'inspect'; + const initial = notifications.capture(); + initial.emit('inspect', deliver); + initial.emit('select', deliver); + sent.length = 0; + const error = new Error('Queued failure'); + expect(() => + notifications.capture().emit( + 'inspect', + () => { + state.inspect.target = state.select.target = null; + notifications.invalidate(); + const cleared = notifications.capture(); + cleared.emit(channel, () => { + throw error; + }); + cleared.emit(other, deliver); + }, + { force: true } + ) + ).toThrow(error); + expect(sent).toEqual(['null']); + expect(notifications.capture().emit(channel, deliver)).toBe(false); + expect(notifications.capture().emit(other, deliver)).toBe(false); + state[other].target = target('b'); + notifications.capture().emit(other, deliver); + expect(sent).toEqual(['null', 'b']); + }); + + it.each([ + new Error('Outer failure'), + undefined + ])('preserves the first thrown value (%s) and drains work added by a failing queued callback', (error) => { + const { state, notifications, deliver, sent } = fixture(); + const caught = vi.fn(); + try { + notifications.capture().emit('select', () => { + notifications.capture().emit('inspect', () => { + state.select.target = null; + notifications.capture().emit('select', deliver); + throw new Error('Queued failure'); + }); + throw error; + }); + } catch (value) { + caught(value); + } + expect(caught).toHaveBeenCalledExactlyOnceWith(error); + expect(sent).toEqual(['null']); + }); + it('revalidates after control callbacks and does not commit superseded reports', () => { const { state, notifications, deliver, sent } = fixture(); const pending = notifications.capture(); From 3a663ad18681bff591f014e568b162f34edcb9d7 Mon Sep 17 00:00:00 2001 From: leiysky Date: Thu, 29 Oct 2026 13:41:53 +0800 Subject: [PATCH 2/8] fix: respect toolbar Escape and reconcile navigation viewports --- CHANGELOG.md | 2 +- development/architecture.md | 2 + docs/view-state.md | 2 + src/elements/hierarchy-element.ts | 65 +++-- .../browser/inspection-notifications.spec.ts | 270 +++++++++++++++++- 5 files changed, 303 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f90b9..c8fd951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All significant changes to ScopeView are documented in this file. ## Unreleased -- Keep queued inspection notifications flowing when a callback throws, and prevent older trace/profile pin restoration from overriding new keyboard navigation or dismissal. +- Keep queued inspection notifications flowing when a callback throws, preserve newer hierarchy input over pending state restoration, and respect toolbar Escape handlers. - Include snapshot-scoped exact source context (`MetricInspectionContext`, `TraceInspectionContext`, `ProfileInspectionContext`) in headless `onInspect`/`onSelect` callbacks and component `scope-inspect`/`scope-select` events. - Reduce exact-reading formatting and narrow-label layout costs while preserving source values and chart geometry. diff --git a/development/architecture.md b/development/architecture.md index e9baf35..09f8dd0 100644 --- a/development/architecture.md +++ b/development/architecture.md @@ -61,6 +61,8 @@ The DOM-free `InspectionNotifications` coordinator in `core/inspection-notificat A throwing callback does not discard valid queued reports, including work added during draining; the first thrown value propagates after the queue drains. Hierarchy components cancel an older pending pin restoration before navigation can paint or Escape can notify observers; a new restoration requested by a callback remains authoritative. Escape also cancels restoration while loading has released the runtime. +Early Escape invalidation stays on the Canvas input boundary; shell dismissal bubbles after toolbar handlers and skips that same Canvas event, including nested input and newer callback restores. Navigation applies a saved viewport against the current row bounds before revealing its target, so a subsequent layout cannot restore an older position over the new reading. + Views own their child DOM; components release runtime resources on disconnect. Shared tokens and the [accepted design baseline](design-baseline.md) govern paint and reading behavior. See [custom rendering](../docs/canvas-infra.md) for low-level consumers and [development](../CONTRIBUTING.md) for tests, scripts and packaging. diff --git a/docs/view-state.md b/docs/view-state.md index 85f9735..ef4e326 100644 --- a/docs/view-state.md +++ b/docs/view-state.md @@ -40,6 +40,8 @@ Use `updateDocument(input, { preserveViewState: true })` for a new snapshot of t In trace/profile components, accepted keyboard navigation or Escape supersedes an older pending pin restoration. This applies to both preserved document state and an assigned `viewState`; Escape also cancels a pin retained during loading. A new restoration requested by a notification callback takes precedence over the earlier keyboard action. +Navigation applies the saved viewport and current row bounds before revealing the requested target. Custom toolbar widgets can stop Escape propagation to retain their own focus and the chart's reading; an unconsumed Escape still dismisses the chart. + Identity continuity is the caller's responsibility. A reused span ID in another trace is not the same source. Metric row-index keys are local to their snapshot, so row reordering may make preservation inappropriate. Keep source identity alongside the saved view. ## Share semantic state diff --git a/src/elements/hierarchy-element.ts b/src/elements/hierarchy-element.ts index b0f836e..dee8fed 100644 --- a/src/elements/hierarchy-element.ts +++ b/src/elements/hierarchy-element.ts @@ -132,6 +132,7 @@ export class HierarchyElement< #stateTransaction = false; #linkedHighlight: LinkedHighlight | null = null; #controlEvents = new ControlEvents(); + #canvasEscapes = new WeakSet(); constructor( readonly kind: Context['kind'], private compile: (input: unknown) => Compiled, @@ -506,34 +507,42 @@ export class HierarchyElement< }, { signal } ); - this.#root.addEventListener( + const canvas = this.#el('.canvas'); + canvas.addEventListener( 'keydown', (event) => { - const key = event as KeyboardEvent; - if ( - key.key !== 'Escape' || - (!this.#runtime?.session.active && this.#pendingPin === undefined) - ) - return; - const canvas = this.#el('.canvas'); + this.#canvasEscapes.delete(event); if ( + event.key === 'Escape' && this.#runtime && this.scene?.interactive !== false && - !key.metaKey && - !key.ctrlKey && - !key.altKey && - event.composedPath().includes(canvas) + !event.metaKey && + !event.ctrlKey && + !event.altKey ) { - // Cancel restoration before the runtime reports Escape; its callbacks may restore again. + // Cancel once before runtime callbacks, retaining any newer restore they request. + this.#canvasEscapes.add(event); this.#pendingPin = undefined; - } else { - event.preventDefault(); - this.#clearInspection(); - canvas.focus({ preventScroll: true }); } }, { capture: true, signal } ); + this.#root.addEventListener( + 'keydown', + (event) => { + if ( + (event as KeyboardEvent).key !== 'Escape' || + (this.#canvasEscapes.delete(event) && event.composedPath().includes(canvas)) || + (!this.#runtime?.session.active && this.#pendingPin === undefined) + ) + return; + // Toolbar widgets can consume Escape before it reaches the chart shell. + event.preventDefault(); + this.#clearInspection(); + canvas.focus({ preventScroll: true }); + }, + { signal } + ); const outside = (event: Event) => { if (this.#ui.dismissOnOutside !== false && !event.composedPath().includes(this)) this.#clearInspection(); @@ -645,14 +654,7 @@ export class HierarchyElement< this.#runtime = new ChartRuntime(canvas, this.#el('.decoration'), { layout: (width, height, measure) => { const current = this.#view!; - this.#viewport.configure({ - contentHeight: current.rowCount * current.rowHeight, - visibleHeight: hierarchyViewportHeight(height, this.#ui.axes ?? 'full') - }); - if (this.#pendingViewport) { - this.#viewport.set(this.#pendingViewport); - this.#pendingViewport = null; - } + this.#prepareViewport(height); const scene = layoutHierarchy( current, width, @@ -717,10 +719,23 @@ export class HierarchyElement< nodes: compiled.hierarchy.sources.length }); } + #prepareViewport(height: number) { + const view = this.#view!; + this.#viewport.configure({ + contentHeight: view.rowCount * view.rowHeight, + visibleHeight: hierarchyViewportHeight(height, this.#ui.axes ?? 'full') + }); + if (this.#pendingViewport) { + this.#viewport.set(this.#pendingViewport); + this.#pendingViewport = null; + } + } #reveal(target: ChartInteraction) { const item = this.#view?.byId.get(target.sources[0]?.id); if (!item) return; const before = this.viewport; + // Reconcile the saved viewport and new row bounds before choosing a visible position. + this.#prepareViewport(this.#el('.canvas').getBoundingClientRect().height); this.#viewport.reveal( item.start, item.end, diff --git a/tests/browser/inspection-notifications.spec.ts b/tests/browser/inspection-notifications.spec.ts index da5875f..33d024a 100644 --- a/tests/browser/inspection-notifications.spec.ts +++ b/tests/browser/inspection-notifications.spec.ts @@ -382,6 +382,187 @@ for (const entry of inspectionEntries) { } if (entry.surface === 'component' && entry.family !== 'metric') { + for (const replace of [false, true]) { + test(`toolbar handles Escape before chart dismissal${replace ? ' while replacing data' : ''}`, async ({ + page + }) => { + const previous = await page.evaluate(async (replace) => { + const f = window.notificationFixture; + const toolbar = document.createElement('div'); + toolbar.slot = 'toolbar'; + const input = document.createElement('input'); + input.id = 'toolbar-editor'; + input.dataset.handled = '0'; + input.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || input.dataset.bubble) return; + input.dataset.handled = String(Number(input.dataset.handled) + 1); + if (replace) void f.replace('200'); + event.preventDefault(); + event.stopPropagation(); + }); + toolbar.append(input); + f.chart!.append(toolbar); + await f.settle(); + f.pin(); + input.focus(); + f.events.length = 0; + return f.latest.select; + }, replace); + await page.keyboard.press('Escape'); + const handled = await page.evaluate(async () => { + const f = window.notificationFixture; + await f.settle(); + return { + latest: f.latest, + pin: f.chart!.viewState.pinnedKey, + events: f.events.splice(0) + }; + }); + await expect(page.locator('#toolbar-editor')).toBeFocused(); + await expect(page.locator('#toolbar-editor')).toHaveAttribute('data-handled', '1'); + expect(handled.pin).toBe(previous?.key); + expect(handled.latest.select?.key).toBe(previous?.key); + expect(inspectionValue(handled.latest.select)).toBe(replace ? '200' : '100'); + expect(handled.events.every((event) => event.context)).toBe(true); + if (!replace) expect(handled.events).toEqual([]); + await page.locator('#toolbar-editor').evaluate((input) => { + input.dataset.bubble = 'true'; + }); + await page.keyboard.press('Escape'); + const dismissed = await page.evaluate(() => { + const f = window.notificationFixture; + return { + latest: f.latest, + pin: f.chart!.viewState.pinnedKey, + focused: f.root.activeElement === f.canvas, + events: f.events.map((event) => [event.channel, event.context]) + }; + }); + expect(dismissed).toEqual({ + latest: { inspect: null, select: null }, + pin: null, + focused: true, + events: [ + ['inspect', null], + ['select', null] + ] + }); + }); + } + + for (const restore of ['document', 'view-state'] as const) { + for (const pin of [false, true]) { + test(`navigation reveals its target in a pending ${restore} viewport${pin ? ' before pinning' : ''}`, async ({ + page + }) => { + const result = await page.evaluate( + async ({ restore, pin }) => { + const f = window.notificationFixture; + const chart = f.chart!; + if (!('viewport' in chart)) throw new Error('Expected a hierarchy component'); + f.pin(); + chart.viewport = { + start: f.entry.family === 'trace' ? 0.5 : 0, + end: f.entry.family === 'trace' ? 1 : 0.5, + scrollOffset: 0 + }; + await f.settle(); + const state = chart.viewState; + const previous = f.latest.select; + if (restore === 'document') await f.replace('200'); + else chart.viewState = state; + f.key('End'); + if (pin) f.key('Enter'); + const immediate = { viewport: chart.viewport, state: chart.viewState }; + await f.settle(); + return { + previous, + immediate, + latest: f.latest, + state: chart.viewState, + viewport: chart.viewport, + painted: chart.scene!.interactions.map((target) => target.key) + }; + }, + { restore, pin } + ); + const child = `${entry.family}:child`; + const viewport = { + start: entry.family === 'trace' ? 0 : 0.5, + end: entry.family === 'trace' ? 0.5 : 1, + scrollOffset: 0 + }; + expect(result.immediate.viewport).toEqual(viewport); + expect(result.immediate.state.viewport).toEqual(viewport); + expect(result.viewport).toEqual(viewport); + expect(result.state.viewport).toEqual(viewport); + expect(result.latest.inspect?.key).toBe(child); + expect(inspectionValue(result.latest.inspect)).toBe('0'); + expect(result.latest.select?.key ?? null).toBe(pin ? child : null); + expect(result.state.pinnedKey).toBe(pin ? child : null); + expect(result.painted).toContain(child); + if (restore === 'document') + expect(result.latest.inspect?.snapshotId).not.toBe(result.previous?.snapshotId); + }); + } + } + + test('navigation uses replacement row bounds before restoring the viewport', async ({ + page + }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const chart = f.chart!; + if (!('viewport' in chart)) throw new Error('Expected a hierarchy component'); + f.pin(); + const nodes = [ + { id: 'a', name: 'a' }, + ...Array.from({ length: 18 }, (_, i) => ({ + id: `middle-${i}`, + name: 'middle', + parentId: i ? `middle-${i - 1}` : 'a' + })), + { id: 'child', name: 'child', parentId: 'middle-17' } + ]; + const source = + f.entry.family === 'trace' + ? { + version: 1, + kind: 'trace', + spans: nodes.map((node, i) => ({ + ...node, + startTimeNs: '0', + endTimeNs: i ? '0' : '200' + })), + options: { rowHeight: 32 } + } + : { + version: 1, + kind: 'profile', + unit: 'samples', + frames: nodes.map((node, i) => ({ + ...node, + selfWeight: i ? '0' : '200' + })), + options: { rowHeight: 32 } + }; + chart.updateDocument(source, { preserveViewState: true }); + f.key('End'); + await f.settle(); + return { + latest: f.latest, + viewport: chart.viewport, + state: chart.viewState, + painted: chart.scene!.interactions.map((target) => target.key) + }; + }); + expect(result.latest.inspect?.key).toBe(`${entry.family}:child`); + expect(result.latest.select).toBeNull(); + expect(result.viewport.scrollOffset).toBe(20 * 32 - (240 - 64)); + expect(result.state.viewport).toEqual(result.viewport); + expect(result.painted).toContain(`${entry.family}:child`); + }); + for (const restore of ['document', 'view-state', 'loading'] as const) { test(`Escape cancels a pending ${restore} pin restoration`, async ({ page }) => { const result = await page.evaluate(async (restore) => { @@ -441,6 +622,64 @@ for (const entry of inspectionEntries) { expect(inspectionValue(result.latest.select)).toBe('200'); }); + test('nested Escape callbacks retain the latest restoration', async ({ page }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const chart = f.chart!; + if (!('viewport' in chart)) throw new Error('Expected a hierarchy component'); + f.pin(); + const state = { + ...chart.viewState, + viewport: { start: 0.25, end: 0.75, scrollOffset: 0 } + }; + await f.replace('200'); + let phase = 0; + f.hook = (notification) => { + if (notification.channel !== 'inspect' || notification.context || phase === 2) return; + phase++; + chart.viewState = state; + if (phase === 1) f.key('Escape'); + }; + f.key('Escape'); + const immediate = { phase, pin: chart.viewState.pinnedKey }; + await f.settle(); + return { immediate, state: chart.viewState, latest: f.latest }; + }); + expect(result.immediate).toEqual({ phase: 2, pin: `${entry.family}:a` }); + expect(result.state.pinnedKey).toBe(`${entry.family}:a`); + expect(result.state.viewport).toEqual({ start: 0.25, end: 0.75, scrollOffset: 0 }); + expect(inspectionValue(result.latest.select)).toBe('200'); + }); + + test('Escape still dismisses after reusing an event across loading', async ({ page }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + f.pin(); + f.canvas.dispatchEvent(escapeEvent); + f.pin(); + f.chart!.loading = true; + await f.settle(); + f.events.length = 0; + f.canvas.dispatchEvent(escapeEvent); + f.chart!.loading = false; + await f.settle(); + return { + latest: f.latest, + pin: f.chart!.viewState.pinnedKey, + events: f.events.map((event) => [event.channel, event.context]) + }; + }); + expect(result).toEqual({ + latest: { inspect: null, select: null }, + pin: null, + events: [ + ['inspect', null], + ['select', null] + ] + }); + }); + for (const restore of ['document', 'view-state'] as const) { for (const paint of [false, true]) { for (const pin of [false, true]) { @@ -501,35 +740,42 @@ for (const entry of inspectionEntries) { }) => { const result = await page.evaluate(async () => { const f = window.notificationFixture; + const chart = f.chart!; + if (!('viewport' in chart)) throw new Error('Expected a hierarchy component'); f.pin(); - const state = f.chart!.viewState; + const previous = f.latest.select; + const state = { + ...chart.viewState, + viewport: { start: 0.25, end: 0.75, scrollOffset: 0 } + }; + chart.viewport = { + start: f.entry.family === 'trace' ? 0.5 : 0, + end: f.entry.family === 'trace' ? 1 : 0.5, + scrollOffset: 0 + }; + await f.settle(); await f.replace('200'); - if ('viewport' in f.chart!) - f.chart.viewport = { - start: f.entry.family === 'trace' ? 0.5 : 0, - end: f.entry.family === 'trace' ? 1 : 0.5, - scrollOffset: 0 - }; let restored = false; f.hook = (notification) => { if ( !restored && notification.channel === 'inspect' && - notification.key?.endsWith(':child') + notification.key?.endsWith(':child') && + notification.context?.snapshotId !== previous?.snapshotId ) { restored = true; - f.chart!.viewState = state; + chart.viewState = state; } }; f.key('End'); - const immediate = { restored, pin: f.chart!.viewState.pinnedKey }; + const immediate = { restored, pin: chart.viewState.pinnedKey }; await f.settle(); - return { state, immediate, latest: f.latest, pin: f.chart!.viewState.pinnedKey }; + return { state, immediate, latest: f.latest, restored: chart.viewState }; }); expect(result.immediate).toEqual({ restored: true, pin: result.state.pinnedKey }); expect(result.latest.select?.key).toBe(result.state.pinnedKey); expect(inspectionValue(result.latest.select)).toBe('200'); - expect(result.pin).toBe(result.state.pinnedKey); + expect(result.restored).toEqual(result.state); }); } From 3b0d1141dc132bfb4b10eec6ccf430a92fac2493 Mon Sep 17 00:00:00 2001 From: leiysky Date: Thu, 29 Oct 2026 14:37:18 +0800 Subject: [PATCH 3/8] fix: preserve hidden focus and serialize viewport reports --- CHANGELOG.md | 16 +- development/architecture.md | 2 + docs/view-state.md | 2 + src/elements/hierarchy-element.ts | 89 ++++- tests/browser/hierarchy-viewport.spec.ts | 363 ++++++++++++++++++ .../browser/inspection-notifications.spec.ts | 7 +- 6 files changed, 461 insertions(+), 18 deletions(-) create mode 100644 tests/browser/hierarchy-viewport.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8fd951..276010b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,20 @@ All significant changes to ScopeView are documented in this file. ## Unreleased -- Keep queued inspection notifications flowing when a callback throws, preserve newer hierarchy input over pending state restoration, and respect toolbar Escape handlers. -- Include snapshot-scoped exact source context (`MetricInspectionContext`, `TraceInspectionContext`, `ProfileInspectionContext`) in headless `onInspect`/`onSelect` callbacks and component `scope-inspect`/`scope-select` events. +### Bug fixes + +- `scope-trace` and `scope-flame` honor newer keyboard input and `focusNode()` calls over pending state restoration, and keep focused nodes visible when initialized hidden charts are shown or resized. +- Headless `onInspect` and `onSelect` callbacks continue delivering valid queued clears after another callback throws, so hosts do not retain dismissed readings or selections. +- `scope-viewport-change` observers receive current windows in consistent order when render, control or event callbacks move the viewport, restore state or replace documents. + +## v0.1.1 (2026-10-29) + +### New features + +- Include snapshot-scoped exact source context (`MetricInspectionContext`, `TraceInspectionContext`, and `ProfileInspectionContext`) in headless `onInspect` and `onSelect` callbacks and Web Component `scope-inspect` and `scope-select` events. + +### Improvements + - Reduce exact-reading formatting and narrow-label layout costs while preserving source values and chart geometry. ## v0.1.0 (2026-10-28) diff --git a/development/architecture.md b/development/architecture.md index 09f8dd0..ca742b2 100644 --- a/development/architecture.md +++ b/development/architecture.md @@ -63,6 +63,8 @@ A throwing callback does not discard valid queued reports, including work added Early Escape invalidation stays on the Canvas input boundary; shell dismissal bubbles after toolbar handlers and skips that same Canvas event, including nested input and newer callback restores. Navigation applies a saved viewport against the current row bounds before revealing its target, so a subsequent layout cannot restore an older position over the new reading. +Hierarchy viewport reports capture a revision before render or control callbacks and read current coordinates after revalidation. Viewport commands, document changes and state restores supersede older reports. Nested events finish the current broadcast before synchronously reporting the latest valid window, preserving observer order. Hidden focus uses the last valid scene height and replays positioning when measurement resumes; newer dismissal, viewport, document or restored state cancels that deferred positioning. + Views own their child DOM; components release runtime resources on disconnect. Shared tokens and the [accepted design baseline](design-baseline.md) govern paint and reading behavior. See [custom rendering](../docs/canvas-infra.md) for low-level consumers and [development](../CONTRIBUTING.md) for tests, scripts and packaging. diff --git a/docs/view-state.md b/docs/view-state.md index ef4e326..ca78356 100644 --- a/docs/view-state.md +++ b/docs/view-state.md @@ -42,6 +42,8 @@ In trace/profile components, accepted keyboard navigation or Escape supersedes a Navigation applies the saved viewport and current row bounds before revealing the requested target. Custom toolbar widgets can stop Escape propagation to retain their own focus and the chart's reading; an unconsumed Escape still dismisses the chart. +For an initialized chart hidden by its container, `focusNode()` retains valid viewport measurements and reveals the node again at the current size when the chart becomes visible. A newer dismissal, viewport command, document or restored state cancels the older deferred positioning. `scope-viewport-change` listeners observe nested changes in broadcast order; callbacks that replace the viewport, view state or document cancel older pending reports. + Identity continuity is the caller's responsibility. A reused span ID in another trace is not the same source. Metric row-index keys are local to their snapshot, so row reordering may make preservation inappropriate. Keep source identity alongside the saved view. ## Share semantic state diff --git a/src/elements/hierarchy-element.ts b/src/elements/hierarchy-element.ts index dee8fed..ac64006 100644 --- a/src/elements/hierarchy-element.ts +++ b/src/elements/hierarchy-element.ts @@ -90,6 +90,10 @@ interface CompiledEntity { hierarchy: Hierarchy; } +function sameViewport(a: Readonly, b: Readonly) { + return a.start === b.start && a.end === b.end && a.scrollOffset === b.scrollOffset; +} + /** Shared shell for entity families. Family compilers own semantics; runtime owns paint/input. */ export class HierarchyElement< Source extends Entity, @@ -127,6 +131,9 @@ export class HierarchyElement< })); #pendingPin: string | null | undefined; #pendingViewport: ViewportState | null = null; + #pendingReveal: string | null = null; + #renderedViewport: (() => boolean) | null = null; + #viewportEvents = { revision: 0, notifying: false, pending: null as number | null }; #stateReason: ViewStateChangeDetail['reason'] = 'reconcile'; #lastState = ''; #stateTransaction = false; @@ -192,6 +199,7 @@ export class HierarchyElement< object(options, 'options', ['preserveViewState']); if (options.preserveViewState !== undefined) boolean(options.preserveViewState, 'options.preserveViewState'); + this.#viewportEvents.revision++; const previous = options.preserveViewState ? this.viewState : null; this.#stateTransaction = true; this.#propertySet = true; @@ -242,6 +250,8 @@ export class HierarchyElement< this.refresh(); } #applyState(state: HierarchyViewState) { + this.#viewportEvents.revision++; + this.#pendingReveal = null; this.#collapsed = new Set( state.collapsed.filter((id) => this.#compiled?.hierarchy.children.get(id)?.length) ); @@ -359,6 +369,8 @@ export class HierarchyElement< this.#input?.cancel(); const tree = this.#compiled?.hierarchy; if (!tree?.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); + this.#pendingPin = undefined; + this.#pendingReveal = null; let parent = tree.byId.get(id)?.parentId, expanded = false; while (parent && tree.byId.has(parent)) { @@ -438,6 +450,8 @@ export class HierarchyElement< this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })); } #changedViewport() { + this.#pendingReveal = null; + const notify = this.#captureViewport(); if ( !this.#runtime?.session.pinned && !this.#runtime?.session.keyboardInspection && @@ -445,8 +459,32 @@ export class HierarchyElement< ) this.#clearInspection(); this.#runtime?.refresh(); - this.#emit('scope-viewport-change', { ...this.viewport }); - this.#stateChanged(); + if (notify()) this.#stateChanged(); + } + #captureViewport() { + const events = this.#viewportEvents; + const revision = ++events.revision; + return () => { + if (revision !== events.revision) return false; + events.pending = revision; + if (events.notifying) return true; + events.notifying = true; + try { + while (events.pending !== null) { + const current = events.pending; + events.pending = null; + if (current !== events.revision) continue; + this.#notifyControls(); + // Host callbacks may replace the document, restore state or move the viewport again. + if (current === events.revision) + this.#dispatch('scope-viewport-change', { ...this.viewport }); + } + return revision === events.revision; + } finally { + events.notifying = false; + events.pending = null; + } + }; } #schedule() { if (!this.isConnected || this.#frame) return; @@ -571,6 +609,7 @@ export class HierarchyElement< this.#schedule(); } disconnectedCallback() { + this.#viewportEvents.revision++; cancelAnimationFrame(this.#frame); this.#frame = 0; this.#mutation?.disconnect(); @@ -586,6 +625,7 @@ export class HierarchyElement< else this.refresh(); } #dispose() { + this.#renderedViewport = null; this.#runtime?.dispose(); this.#runtime = null; this.#renderedCompiled = null; @@ -654,7 +694,16 @@ export class HierarchyElement< this.#runtime = new ChartRuntime(canvas, this.#el('.decoration'), { layout: (width, height, measure) => { const current = this.#view!; + const before = this.viewport; + const reveal = this.#pendingReveal; + this.#pendingReveal = null; + this.#renderedViewport = null; this.#prepareViewport(height); + if (reveal && this.#runtime?.session.active?.sources[0]?.id === reveal) { + this.#positionNode(reveal); + if (!sameViewport(before, this.viewport)) + this.#renderedViewport = this.#captureViewport(); + } const scene = layoutHierarchy( current, width, @@ -683,6 +732,8 @@ export class HierarchyElement< } else this.#runtime.refresh(); } #rendered() { + const notify = this.#renderedViewport; + this.#renderedViewport = null; const scene = this.scene, view = this.#view, compiled = this.#renderedCompiled; @@ -712,6 +763,8 @@ export class HierarchyElement< if (compiled !== this.#compiled || !this.isConnected) return; this.#showInspection(null, false, this.#stateReason); if (compiled !== this.#compiled || !this.isConnected) return; + notify?.(); + if (compiled !== this.#compiled || !this.isConnected) return; this.#stateChanged(this.#stateReason); this.#stateReason = 'reconcile'; this.#emit('scope-ready', { @@ -730,27 +783,32 @@ export class HierarchyElement< this.#pendingViewport = null; } } - #reveal(target: ChartInteraction) { - const item = this.#view?.byId.get(target.sources[0]?.id); + #positionNode(id: string) { + const item = this.#view?.byId.get(id); if (!item) return; - const before = this.viewport; - // Reconcile the saved viewport and new row bounds before choosing a visible position. - this.#prepareViewport(this.#el('.canvas').getBoundingClientRect().height); this.#viewport.reveal( item.start, item.end, item.row * this.#view!.rowHeight, (item.row + 1) * this.#view!.rowHeight ); - const after = this.viewport; - if ( - before.start !== after.start || - before.end !== after.end || - before.scrollOffset !== after.scrollOffset - ) { + } + #reveal(target: ChartInteraction) { + const id = target.sources[0]?.id; + if (!id || !this.#view?.byId.has(id)) return; + const bounds = this.#el('.canvas').getBoundingClientRect(); + const hidden = bounds.width <= 0 || bounds.height <= 0; + this.#pendingReveal = hidden ? id : null; + // Hidden hosts retain the last valid measurement and replay positioning when visible. + const height = bounds.height > 0 ? bounds.height : this.scene?.height; + if (!height) return; + const before = this.viewport; + this.#prepareViewport(height); + this.#positionNode(id); + if (!sameViewport(before, this.viewport)) { + const notify = this.#captureViewport(); this.#runtime?.paint(); - this.#emit('scope-viewport-change', { ...after }); - this.#stateChanged(); + if (notify()) this.#stateChanged(); } } #detail( @@ -772,6 +830,7 @@ export class HierarchyElement< #clearInspection() { this.#notifications.invalidate(); this.#pendingPin = undefined; + this.#pendingReveal = null; this.#runtime?.session.clearInspection(); this.#runtime?.focus(null); this.#showInspection(null, false); diff --git a/tests/browser/hierarchy-viewport.spec.ts b/tests/browser/hierarchy-viewport.spec.ts new file mode 100644 index 0000000..322ddd3 --- /dev/null +++ b/tests/browser/hierarchy-viewport.spec.ts @@ -0,0 +1,363 @@ +import { test, expect, type Page } from '@playwright/test'; +import type { ViewportState } from '../../src/index.js'; +import type { ScopeTraceElement, ScopeFlameElement } from '../../src/element.js'; + +type Family = 'trace' | 'profile'; +type Hierarchy = ScopeTraceElement | ScopeFlameElement; +const first = { start: 0.1, end: 0.6, scrollOffset: 0 }; +const latest = { start: 0.25, end: 0.75, scrollOffset: 0 }; + +async function deepHierarchy(page: Page, family: Family) { + const nodes = Array.from({ length: 50 }, (_, i) => ({ + id: `node-${i}`, + name: `node-${i}`, + ...(i ? { parentId: `node-${i - 1}` } : {}) + })); + const document = + family === 'trace' + ? { + version: 1, + kind: 'trace', + spans: nodes.map((node) => ({ ...node, startTimeNs: '0', endTimeNs: '10000' })) + } + : { + version: 1, + kind: 'profile', + unit: 'samples', + frames: nodes.map((node) => ({ ...node, selfWeight: '1' })) + }; + await page.evaluate(async (document) => { + const f = window.notificationFixture; + f.chart!.document = document; + await f.settle(); + }, document); +} + +for (const family of ['trace', 'profile'] as const) { + test.describe(`component/${family} viewport`, () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + await page.evaluate(async (family) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { createInspectionFixture }: typeof import('./fixtures/inspection-matrix.js') = + await import(module); + window.notificationFixture = await createInspectionFixture({ + family, + surface: 'component', + name: 'viewport' + }); + }, family); + }); + + for (const pin of [false, true]) { + for (const height of [240, 120]) { + test(`hidden focusNode remains visible after showing at ${height}px, pin=${pin}`, async ({ + page + }) => { + await deepHierarchy(page, family); + const result = await page.evaluate( + async ({ pin, height }) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + const snapshot = chart.scene; + f.host.style.display = 'none'; + await f.settle(); + chart.focusNode('node-20', { pin }); + const hidden = { + viewport: chart.viewport, + pin: chart.viewState.pinnedKey, + key: f.latest.inspect?.key + }; + chart.style.setProperty('--scope-chart-height', `${height}px`); + f.host.style.display = ''; + await f.settle(); + await f.settle(); + const scene = chart.scene!; + const clip = scene.clipRect ?? scene.rect!; + const key = `${f.entry.family}:node-20`; + const mark = scene.marks.find((mark) => mark.kind === 'rect' && mark.key === key); + const visible = + mark?.kind === 'rect' && + Math.min(mark.y + mark.height, clip.y + clip.height) > Math.max(mark.y, clip.y) && + Math.min(mark.x + mark.width, clip.x + clip.width) > Math.max(mark.x, clip.x); + return { + hidden, + visible, + pin: chart.viewState.pinnedKey, + key: f.latest.inspect?.key, + snapshotPainted: scene !== snapshot + }; + }, + { pin, height } + ); + const key = `${family}:node-20`; + expect(result.hidden).toEqual({ + viewport: { start: 0, end: 1, scrollOffset: 328 }, + pin: pin ? key : null, + key + }); + expect(result.snapshotPainted).toBe(true); + expect(result.visible).toBe(true); + expect(result.key).toBe(key); + expect(result.pin).toBe(pin ? key : null); + }); + } + } + + for (const action of ['dismiss', 'viewport', 'restore', 'document'] as const) { + test(`a newer ${action} cancels hidden focusNode positioning`, async ({ page }) => { + await deepHierarchy(page, family); + const result = await page.evaluate(async (action) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + f.host.style.display = 'none'; + await f.settle(); + chart.focusNode('node-20', { pin: true }); + if (action === 'dismiss') f.dismiss(); + else if (action === 'viewport') chart.viewport = { start: 0, end: 1, scrollOffset: 0 }; + else if (action === 'restore') + chart.viewState = { + ...chart.viewState, + pinnedKey: null, + viewport: { start: 0, end: 1, scrollOffset: 0 } + }; + else chart.updateDocument(chart.document); + const intended = chart.viewState.viewport; + f.host.style.display = ''; + await f.settle(); + await f.settle(); + return { intended, viewport: chart.viewport, pin: chart.viewState.pinnedKey }; + }, action); + expect(result.viewport).toEqual(result.intended); + expect(result.pin).toBe(action === 'viewport' ? `${family}:node-20` : null); + }); + } + + test('hidden focusNode supersedes a pin waiting for replacement paint', async ({ page }) => { + await deepHierarchy(page, family); + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + chart.focusNode('node-0', { pin: true }); + const snapshot = f.latest.select?.snapshotId; + f.host.style.display = 'none'; + await f.settle(); + chart.updateDocument(chart.document, { preserveViewState: true }); + chart.focusNode('node-20', { pin: true }); + const hidden = { pin: chart.viewState.pinnedKey, snapshot: f.latest.select?.snapshotId }; + chart.style.setProperty('--scope-chart-height', '120px'); + f.host.style.display = ''; + await f.settle(); + await f.settle(); + const scene = chart.scene!; + const clip = scene.clipRect ?? scene.rect!; + const key = `${f.entry.family}:node-20`; + const mark = scene.marks.find((mark) => mark.kind === 'rect' && mark.key === key); + const visible = + mark?.kind === 'rect' && mark.y >= clip.y && mark.y + mark.height <= clip.y + clip.height; + return { + snapshot, + hidden, + visible, + pin: chart.viewState.pinnedKey, + selected: f.latest.select + }; + }); + expect(result.hidden).toEqual({ pin: `${family}:node-20`, snapshot: result.snapshot }); + expect(result.visible).toBe(true); + expect(result.pin).toBe(`${family}:node-20`); + expect(result.selected?.key).toBe(`${family}:node-20`); + expect(result.selected?.snapshotId).not.toBe(result.snapshot); + }); + + test('a viewport changed by the navigation reading callback is the last published window', async ({ + page + }) => { + const result = await page.evaluate(async (latest) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + const rootModule = '/src/index.ts'; + const elementModule = '/src/element.ts'; + const { ChartLinkGroup }: typeof import('../../src/index.js') = await import(rootModule); + const { bindChartLinks }: typeof import('../../src/element.js') = await import( + elementModule + ); + f.pin(); + chart.viewport = { + start: f.entry.family === 'trace' ? 0.5 : 0, + end: f.entry.family === 'trace' ? 1 : 0.5, + scrollOffset: 0 + }; + await f.settle(); + const events: ViewportState[] = []; + chart.addEventListener('scope-viewport-change', (event) => + events.push({ ...(event as CustomEvent).detail }) + ); + const group = new ChartLinkGroup(); + const binding = bindChartLinks(chart, group, { + id: 'viewport', + project: () => null, + viewportWindow: (detail) => { + const viewport = detail as ViewportState; + return { + startTimeNs: String(viewport.start * 1000), + endTimeNs: String(viewport.end * 1000) + }; + } + }); + const previous = f.latest.select; + let changed = false; + f.hook = (notification) => { + if ( + !changed && + notification.channel === 'inspect' && + notification.key?.endsWith(':child') && + notification.context?.snapshotId !== previous?.snapshotId + ) { + changed = true; + chart.viewport = latest; + } + }; + await f.replace('200'); + f.key('End'); + await f.settle(); + const result = { changed, events, viewport: chart.viewport, window: group.state.window }; + binding.disconnect(); + return result; + }, latest); + expect(result.changed).toBe(true); + expect(result.events).toEqual([latest]); + expect(result.viewport).toEqual(latest); + expect(result.window).toEqual({ startTimeNs: '250', endTimeNs: '750' }); + }); + + test('a control callback supersedes a captured viewport notification', async ({ page }) => { + const result = await page.evaluate( + async ({ first, latest }) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + f.pin(); + const events: ViewportState[] = []; + chart.addEventListener('scope-viewport-change', (event) => + events.push({ ...(event as CustomEvent).detail }) + ); + let changed = false; + chart.addEventListener('scope-controls-change', () => { + if (!changed) { + changed = true; + chart.viewport = latest; + } + }); + chart.configure({ toolbar: { actions: ['inspect'] } }); + chart.viewport = first; + await f.settle(); + return { changed, events, viewport: chart.viewport }; + }, + { first, latest } + ); + expect(result.changed).toBe(true); + expect(result.events).toEqual([latest]); + expect(result.viewport).toEqual(latest); + }); + + test('a document replaced by a control callback cancels the old viewport report', async ({ + page + }) => { + const result = await page.evaluate(async (first) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + f.pin(); + const events: ViewportState[] = []; + chart.addEventListener('scope-viewport-change', (event) => + events.push({ ...(event as CustomEvent).detail }) + ); + let replaced = false; + chart.addEventListener('scope-controls-change', () => { + if (!replaced) { + replaced = true; + chart.updateDocument(chart.document); + } + }); + chart.configure({ toolbar: { actions: ['inspect'] } }); + chart.viewport = first; + await f.settle(); + return { replaced, events, viewport: chart.viewport, pin: chart.viewState.pinnedKey }; + }, first); + expect(result).toEqual({ + replaced: true, + events: [], + viewport: { start: 0, end: 1, scrollOffset: 0 }, + pin: null + }); + }); + + for (const throws of [false, true]) { + test(`independent viewport observers receive nested changes in order, throws=${throws}`, async ({ + page + }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + const result = await page.evaluate( + async ({ first, latest, throws }) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + f.pin(); + const early: ViewportState[] = [], + late: ViewportState[] = []; + let changed = false; + chart.addEventListener('scope-viewport-change', (event) => { + early.push({ ...(event as CustomEvent).detail }); + if (!changed) { + changed = true; + chart.viewport = latest; + if (throws) throw new Error('Viewport observer failure'); + } + }); + f.host.addEventListener('scope-viewport-change', (event) => + late.push({ ...(event as CustomEvent).detail }) + ); + chart.viewport = first; + await f.settle(); + return { early, late, viewport: chart.viewport }; + }, + { first, latest, throws } + ); + expect(result.early).toEqual([first, latest]); + expect(result.late).toEqual(result.early); + expect(result.viewport).toEqual(latest); + expect(errors).toEqual(throws ? ['Viewport observer failure'] : []); + }); + } + + test('nested viewport changes coalesce to the latest window', async ({ page }) => { + const result = await page.evaluate( + async ({ first, latest }) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + f.pin(); + const early: ViewportState[] = [], + late: ViewportState[] = []; + let changed = false; + chart.addEventListener('scope-viewport-change', (event) => { + early.push({ ...(event as CustomEvent).detail }); + if (!changed) { + changed = true; + chart.viewport = { start: 0, end: 0.5, scrollOffset: 0 }; + chart.viewport = latest; + } + }); + f.host.addEventListener('scope-viewport-change', (event) => + late.push({ ...(event as CustomEvent).detail }) + ); + chart.viewport = first; + await f.settle(); + return { early, late, viewport: chart.viewport }; + }, + { first, latest } + ); + expect(result.early).toEqual([first, latest]); + expect(result.late).toEqual(result.early); + expect(result.viewport).toEqual(latest); + }); + }); +} diff --git a/tests/browser/inspection-notifications.spec.ts b/tests/browser/inspection-notifications.spec.ts index 33d024a..22eac37 100644 --- a/tests/browser/inspection-notifications.spec.ts +++ b/tests/browser/inspection-notifications.spec.ts @@ -755,6 +755,10 @@ for (const entry of inspectionEntries) { }; await f.settle(); await f.replace('200'); + const viewportEvents: unknown[] = []; + chart.addEventListener('scope-viewport-change', (event) => + viewportEvents.push((event as CustomEvent).detail) + ); let restored = false; f.hook = (notification) => { if ( @@ -770,12 +774,13 @@ for (const entry of inspectionEntries) { f.key('End'); const immediate = { restored, pin: chart.viewState.pinnedKey }; await f.settle(); - return { state, immediate, latest: f.latest, restored: chart.viewState }; + return { state, immediate, latest: f.latest, restored: chart.viewState, viewportEvents }; }); expect(result.immediate).toEqual({ restored: true, pin: result.state.pinnedKey }); expect(result.latest.select?.key).toBe(result.state.pinnedKey); expect(inspectionValue(result.latest.select)).toBe('200'); expect(result.restored).toEqual(result.state); + expect(result.viewportEvents).toEqual([]); }); } From 4ca62edbcdf1c8152df26cad3874264d4f73c8ef Mon Sep 17 00:00:00 2001 From: leiysky Date: Thu, 29 Oct 2026 14:43:55 +0800 Subject: [PATCH 4/8] fix: retain saved focus when runtime targets are unavailable --- src/elements/hierarchy-element.ts | 8 ++++++-- tests/browser/hierarchy-viewport.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/elements/hierarchy-element.ts b/src/elements/hierarchy-element.ts index ac64006..f50d039 100644 --- a/src/elements/hierarchy-element.ts +++ b/src/elements/hierarchy-element.ts @@ -369,8 +369,12 @@ export class HierarchyElement< this.#input?.cancel(); const tree = this.#compiled?.hierarchy; if (!tree?.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); - this.#pendingPin = undefined; - this.#pendingReveal = null; + const key = this.#view?.byId.get(id)?.interaction.key; + const model = this.scene; + if (key && (model?.navigation ?? model)?.interactionByKey.has(key)) { + this.#pendingPin = undefined; + this.#pendingReveal = null; + } let parent = tree.byId.get(id)?.parentId, expanded = false; while (parent && tree.byId.has(parent)) { diff --git a/tests/browser/hierarchy-viewport.spec.ts b/tests/browser/hierarchy-viewport.spec.ts index 322ddd3..c96765b 100644 --- a/tests/browser/hierarchy-viewport.spec.ts +++ b/tests/browser/hierarchy-viewport.spec.ts @@ -170,6 +170,27 @@ for (const family of ['trace', 'profile'] as const) { expect(result.selected?.snapshotId).not.toBe(result.snapshot); }); + test('focusNode during loading retains the saved state until a runtime is ready', async ({ + page + }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + f.pin(); + const saved = chart.viewState; + chart.loading = true; + await f.settle(); + chart.focusNode('child', { pin: true }); + const loading = chart.viewState; + chart.loading = false; + await f.settle(); + return { saved, loading, restored: chart.viewState, selected: f.latest.select?.key }; + }); + expect(result.loading).toEqual(result.saved); + expect(result.restored).toEqual(result.saved); + expect(result.selected).toBe(result.saved.pinnedKey); + }); + test('a viewport changed by the navigation reading callback is the last published window', async ({ page }) => { From a7865b967cc2096b1a129305ce7058f7bf1d3c8b Mon Sep 17 00:00:00 2001 From: leiysky Date: Thu, 29 Oct 2026 20:54:54 +0800 Subject: [PATCH 5/8] refactor: unify inspection command and notification ownership --- CHANGELOG.md | 6 +- development/architecture.md | 14 +- development/interaction-transactions.md | 82 ++ scripts/build.mjs | 4 +- src/core/inspection-notifications.ts | 148 ++-- src/core/interaction.ts | 112 +++ src/core/reading-session.ts | 123 +++ src/core/reading-state.ts | 71 ++ src/core/scene-snapshot.ts | 13 + src/core/session.ts | 187 ++--- src/elements/chart-element.ts | 485 ++++++----- src/elements/controls.ts | 27 +- src/elements/hierarchy-element.ts | 754 +++++++++--------- src/elements/plot.ts | 4 +- src/families/hierarchy/interaction.ts | 272 +++++++ src/headless.ts | 5 +- src/runtime/chart-runtime.ts | 51 +- src/runtime/inspection-input.ts | 11 + src/runtime/lifecycle.ts | 114 ++- src/runtime/reading-runtime.ts | 61 +- src/views/chart-type.ts | 32 +- src/views/hierarchy.ts | 41 +- src/views/interactions.ts | 2 +- src/views/metric-plot.ts | 41 + src/views/metric.ts | 80 +- src/views/view.ts | 134 ++-- .../inspection-transactions-matrix.spec.ts | 127 +++ .../browser/interaction-transactions.spec.ts | 261 ++++++ tests/browser/runtime-lifecycle.spec.ts | 90 +++ .../core/inspection-notifications.test.ts | 33 +- tests/unit/core/interaction.test.ts | 304 +++++++ 31 files changed, 2663 insertions(+), 1026 deletions(-) create mode 100644 development/interaction-transactions.md create mode 100644 src/core/interaction.ts create mode 100644 src/core/reading-session.ts create mode 100644 src/core/reading-state.ts create mode 100644 src/core/scene-snapshot.ts create mode 100644 src/families/hierarchy/interaction.ts create mode 100644 src/runtime/inspection-input.ts create mode 100644 src/views/metric-plot.ts create mode 100644 tests/browser/inspection-transactions-matrix.spec.ts create mode 100644 tests/browser/interaction-transactions.spec.ts create mode 100644 tests/unit/core/interaction.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 276010b..147728d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ All significant changes to ScopeView are documented in this file. ### Bug fixes -- `scope-trace` and `scope-flame` honor newer keyboard input and `focusNode()` calls over pending state restoration, and keep focused nodes visible when initialized hidden charts are shown or resized. -- Headless `onInspect` and `onSelect` callbacks continue delivering valid queued clears after another callback throws, so hosts do not retain dismissed readings or selections. -- `scope-viewport-change` observers receive current windows in consistent order when render, control or event callbacks move the viewport, restore state or replace documents. +- `scope-trace` and `scope-flame` resolve `focusNode()` below collapsed ancestors while initialized charts are hidden, retain pin behavior when measurement resumes, and honor newer nested focus, keyboard, dismissal, viewport and restored-state commands. +- Viewport and collapse observers can acknowledge the current value without recursive events. Changing-value callback cycles yield to the browser, and nested viewport broadcasts reach listeners in consistent order. +- Inspection callbacks retain exact source snapshots across document replacement and continue valid queued clears after an observer throws. Reentrant layout and cleanup callbacks cannot overwrite newer data, and removed frames cannot emit a stale ready event. ## v0.1.1 (2026-10-29) diff --git a/development/architecture.md b/development/architecture.md index ca742b2..5627231 100644 --- a/development/architecture.md +++ b/development/architecture.md @@ -51,19 +51,17 @@ Keep data and compilation independent of browser globals. Views share Canvas and ## State and lifecycle -Reading, persistent selection, keyboard focus, ranges and viewports have distinct owners. Updates reconcile compatible targets; removed sources cannot retain stale selection. Invalid headless updates preserve the previous figure. Asynchronous report updates validate before loading and prevent stale downloads from overwriting newer data. +Each mounted chart has one DOM-free interaction owner for canonical reading state, command boundaries and publication. Session objects are compatibility facades into that owner. Pure reducers commit semantic keys, selection, hover and intent before any effect invokes application code. Hierarchy family state owns compiled target resolution, collapse, viewport and deferred geometry; it shares the component's session and pump rather than copying selection into an independent runtime. -The internal `RuntimeLifecycle` base owns Canvas measurement, layout invalidation, frame scheduling, resize/font/DPR listeners, error recovery and resource disposal. `ReadingRuntime` and `ChartRuntime` retain their own sessions, input handling and paint-state projection. The base does not import range, link or viewport controllers, so sharing lifecycle code does not pull optional interaction capabilities into headless views. +The internal `RuntimeLifecycle` base owns Canvas measurement, layout invalidation, frame scheduling, resize/font/DPR listeners, error recovery and resource disposal. `ReadingRuntime` and `ChartRuntime` translate input and project committed state into paint. Layout revisions and semantic model versions reject stale custom layout results and errors. Consumed Canvas providers and detached borrowed session facades keep old runtime lifetimes from mutating a new mounted chart. Optional ranges, links and viewport controllers stay outside the base and small headless runtime. -Family-owned inspection context projectors expose exact source values, field roles and ancestry to headless callbacks and component events. Shared context types and opaque document snapshot identity live in `core/inspection-context.ts`. Scalar metadata is attached directly, while source objects and ancestor chains are cloned on first read so unread hover events avoid deep copies and retained contexts keep their original snapshot. +Family-owned inspection context projectors expose exact source values, field roles and ancestry. Context types and opaque document identity live in `core/inspection-context.ts`; completed scenes retain their compiled source through `core/scene-snapshot.ts`. A pending document does not relabel an older painted frame. Lazy source clones retain exact original values and ancestry when hosts keep a context after later updates. -The DOM-free `InspectionNotifications` coordinator in `core/inspection-notifications.ts` owns notification identity (`snapshot`, `key`, `series`, `pinned`), deduplication and reentrancy across all families and browser entries. It retains the last delivered state across loading and document replacements so reconciled selections report updated snapshots and cleared, failed or disconnected states clear once. Delivery commits before invoking host callbacks, and nested state changes wait for the current broadcast to finish before revalidating and draining in order. +`Interaction` in `core/interaction.ts` runs keyed, validated presentation effects before controls, reading reports, viewport reports, portable state and ready. `InspectionNotifications` adapts inspect/select identity (`snapshot`, `key`, `series`, `pinned`) and independent channel invalidation onto that same pump. Delivery records identity before invoking the host; nested commands commit immediately while their reports wait for the current broadcast. Same-value acknowledgements quiesce, changing-value feedback yields after bounded work, and observer failures preserve other valid reports before propagating. -A throwing callback does not discard valid queued reports, including work added during draining; the first thrown value propagates after the queue drains. Hierarchy components cancel an older pending pin restoration before navigation can paint or Escape can notify observers; a new restoration requested by a callback remains authoritative. Escape also cancels restoration while loading has released the runtime. +Hierarchy focus resolves against compiled semantic targets and expands ancestors before accepting the command. Hidden charts retain the last positive measurement and a complete positioning intent. Newer dismissal, viewport, restore, collapse or document commands cancel older reveal work. One explicit Canvas Escape owner commits dismissal; runtime and shell fallback skip that Canvas path so other listeners can restore newer state without a second clear. -Early Escape invalidation stays on the Canvas input boundary; shell dismissal bubbles after toolbar handlers and skips that same Canvas event, including nested input and newer callback restores. Navigation applies a saved viewport against the current row bounds before revealing its target, so a subsequent layout cannot restore an older position over the new reading. - -Hierarchy viewport reports capture a revision before render or control callbacks and read current coordinates after revalidation. Viewport commands, document changes and state restores supersede older reports. Nested events finish the current broadcast before synchronously reporting the latest valid window, preserving observer order. Hidden focus uses the last valid scene height and replays positioning when measurement resumes; newer dismissal, viewport, document or restored state cancels that deferred positioning. +The implemented [interaction transaction design](interaction-transactions.md) records ownership, publication phases, API compatibility and regression coverage. Invalid headless updates preserve the previous figure. Asynchronous report updates validate before loading and prevent stale downloads from overwriting newer data. Component loading/disconnection and silent headless disposal preserve their entry-specific lifecycle policies. Views own their child DOM; components release runtime resources on disconnect. Shared tokens and the [accepted design baseline](design-baseline.md) govern paint and reading behavior. diff --git a/development/interaction-transactions.md b/development/interaction-transactions.md new file mode 100644 index 0000000..faade6b --- /dev/null +++ b/development/interaction-transactions.md @@ -0,0 +1,82 @@ +# Interaction transactions + +Status: implemented. The transaction owner replaces separate inspection queues, pending pin/reveal fields and viewport revision queues. Public factory signatures, session/runtime methods, component events and inspection context types remain unchanged. This document records the internal ownership and the tested behavioral contract. + +## Ownership + +Each mounted chart has one DOM-free `Interaction` in `src/core/interaction.ts`. Its canonical reading record stores semantic keys, hover, selection, series, keyboard mode, model version and reading intent. `ReadingSession` implements pure synchronous reducers over that record; the public `ChartSession` adds the existing freeze/compare, lock, visibility and restoration policies. A component and its runtime share the record through session facades rather than copying selection between independent sessions. + +`HierarchyInteraction` in `src/families/hierarchy/interaction.ts` owns the compiled hierarchy, expanded navigation, collapsed nodes, desired viewport, last positive measurement and complete focus intent. It uses the same session and transaction owner as its component. Family geometry stays in the existing layout and viewport primitives. Runtime adapters translate input and measurements; component shells own controls and DOM presentation. + +Canvas providers are consumed once when a runtime is constructed. Releasing a borrowed runtime facade detaches it before resource disposal, so a retained old session cannot mutate the component's next lifecycle. An unrelated runtime subsequently created on the same Canvas receives its own owner. + +## Commit and effect boundary + +```mermaid +flowchart LR + Input[Methods and DOM input] --> Reduce[Pure semantic reducers] + Reduce --> Commit[Commit logical state] + Commit --> Effects[Validated layout and DOM effects] + Effects --> Reports[Captured channel reports] + Reports --> Host[Host callbacks] + Host -->|Nested command| Reduce +``` + +`Interaction.batch()` encloses a complete command. Reducers do not call application code, manipulate DOM or publish reports. Logical getters reflect the accepted state synchronously. Effects wait until the outer command finishes. A nested command issued by a callback commits immediately; its effects and reports run after the current callback or DOM broadcast completes. Older commands therefore have no semantic write left to resume after notification. + +Effects carry a key and a currentness predicate. Admission rejects already stale work before it can replace a valid keyed effect. Execution revalidates the predicate. Operations that can call application code, including custom layout/renderer hooks and DOM focus/popover work, require revalidation before applying further results. Model versions protect against a custom layout adopting a newer semantic model without changing the runtime's own layout revision. Stale layout results and failures cannot replace that newer model. + +The pump chooses the earliest nonempty phase after each effect, so nested presentation work finishes before subsequent reports. Existing entry-specific inspect/select ordering remains in the adapters. + +| Phase | Responsibility | +| ----- | --------------------------------------------------- | +| 0 | Resource cleanup, layout and reading presentation | +| 1 | Component controls | +| 2 | Inspect, select, collapse, visibility and errors | +| 3 | Hierarchy viewport reports | +| 4 | Portable view-state reports | +| 5 | Ready reports for a still-current completed frame | + +A report marks its observable identity as published before invoking the host. Repeated same-value viewport or collapse acknowledgements terminate through value comparison, even if accepting them changes an internal intent. Toggle actions retain their existing semantics. Explicit inspection input can force a report where the existing API requires it; a quiet repaint cannot displace that pending report merely by capturing the same reading. + +The pump runs at most 64 queued effects per synchronous flush. A host that keeps issuing different states yields to a timer task, allowing input, painting and other tasks to run. This bounds library draining; it does not attempt to terminate arbitrary host recursion inside a callback. A throwing observer does not roll back the committed state or abandon other valid work. Remaining work is scheduled before the first thrown value propagates, including when that value is `undefined`. + +## Semantic targets and hidden geometry + +`focusNode()` resolves identifiers against the compiled hierarchy and expands ancestors before adopting canonical navigation. It applies the existing pin policy and commits the full focus intent before painting, control events or viewport callbacks. A node absent from the old painted scene can therefore be accepted. Nested focus or keyboard commands replace the older reading intent before its remaining effects execute. + +Zero dimensions are not a measurement. An initialized hidden chart retains its last positive height and unresolved positioning intent. A positive measurement derives reveal bounds from the current hierarchy and surviving focus intent. Newer viewport commands, dismissal, restoration, collapse or document replacement cancel obsolete positioning. The intent does not depend on finding the requested node in an old scene. + +Loading retains its existing public policy: a component with an unavailable runtime preserves its saved portable state, and `focusNode()` does not invent a new active runtime. This differs from an initialized hidden chart, whose semantic model remains available. Before-first-paint, loading, disconnection and silent headless disposal keep their existing compatibility fixtures. + +Canvas has one explicit Escape owner. It commits dismissal even for a noncancelable event, without suppressing other Canvas listeners that may restore newer state. The runtime skips dismissal when the Canvas owner is present. Shell fallback bubbles after toolbar widgets and skips the Canvas path, so it cannot clear a newer restore a second time. + +## Snapshot and channel integrity + +`captureScene()` stamps each completed scene with its compiled source snapshot. The `scene` getter continues to describe the last completed frame while a new document awaits paint. Pointer readings and exact context projection use the snapshot of that frame. A semantic target introduced by a new document waits for a compatible frame before a frame-dependent report; identical keys never cause old raw values to be reinterpreted against new data. + +`InspectionNotifications` adapts inspect/select onto the shared pump. Identity includes snapshot, target key, series and observable pin state. Empty readings have one stable identity. Each channel has its own invalidation version, so unrelated hover changes cannot cancel a valid selection report. Captured payloads retain their original snapshot and exact values when listeners keep them after later updates. + +Component viewport, controls and view-state adapters use the same publication protocol with their existing payloads. All listeners complete one DOM broadcast before the next report begins. A nested command can update getters immediately, while superseded pending intermediate reports are dropped. Ready delivery additionally validates connection, loading/error state and the completed source frame. A control callback that removes or replaces that frame cannot leave a successful ready event behind. + +Document replacement commits the new source and clears controlled bindings before cleanup effects invoke host callbacks. A cleanup callback can submit another document; no older compile or render tail may overwrite it. Invalid headless updates continue to preserve the existing figure. + +## Compatibility and package boundaries + +The shared kernel, reading state and scene snapshot registry remain internal and DOM-free. Public `ChartSession` retains its constructor, getters, methods and pin/toggle behavior. Runtime and component API names and event details remain unchanged. Metric factory facades retain detached `update`, `refresh` and `dispose` calls and their own enumerable `theme` accessor; declaration compatibility alone would not cover those JavaScript behaviors. + +Fixed metric entries share the plotted metric owner without retaining unreachable aggregate switching. The general metric entry alone switches between HTML aggregates and plots. Scalar remains HTML-only. Headless entries do not import component shells, registration stays explicit, and the standalone bundle consumes the built headless entry. Painter tokens, source colors, geometry and reading appearance are unchanged. Fixed bundled-consumer limits remain acceptance gates. + +## Regression coverage + +| Layer | Coverage | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| `tests/unit/core/interaction.test.ts` | Pump ordering, same-value feedback, stale admission, exceptions, session lifetime and 48 seeded trace/profile command runs | +| Inspection coordinator and session units | Independent channels, exact identity, explicit input, pin/lock/compare policy and restoration | +| `interaction-transactions.spec.ts` | Hidden/collapsed focus, two heights, pin modes, nested focus/keyboard, viewport order, feedback, Escape and ready cancellation | +| `inspection-transactions-matrix.spec.ts` | Popover callback replacement across all 17 browser entries, committed cleanup state and detached metric facade methods | +| `runtime-lifecycle.spec.ts` | Stale custom layout results/errors, reused Canvas lifetime, keyboard reentry, hidden measurement and disposal | +| Existing browser family/entry matrices | Exact readings, snapshots, source identity, API lifecycle, navigation and presentation compatibility | +| Type consumers and bundled/package tests | Public declarations, DOM-free root, explicit registration, optional capabilities and installed consumers | + +The seeded command tests use an independent last-intent reference projection across replacements, collapse, focus, viewport commands, zero/positive measurements, restoration, lifecycle and nested callback commands. Browser regressions exercise actual Canvas, popover, focus and event ordering. These checks establish concrete invariants and observed counterexamples; they are not a claim that future application code cannot introduce a new defect. diff --git a/scripts/build.mjs b/scripts/build.mjs index 21dd9ad..01f1e68 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -25,7 +25,7 @@ await build({ lib: { entry: entries, formats: ['es'], fileName: (_, name) => `${name}.js` } } }); -// Offline HTML and classic scripts use the same all-family browser implementation. +// Offline HTML and classic scripts consume the built public entry, just like installed hosts. await build({ configFile: false, build: { @@ -34,7 +34,7 @@ await build({ cssTarget, minify: true, lib: { - entry: entries.headless, + entry: resolve('dist/headless.js'), name: 'ScopeView', formats: ['iife'], fileName: () => 'scopeview.js' diff --git a/src/core/inspection-notifications.ts b/src/core/inspection-notifications.ts index 26f50a0..090c8cc 100644 --- a/src/core/inspection-notifications.ts +++ b/src/core/inspection-notifications.ts @@ -1,3 +1,4 @@ +import type { InteractionChannel, Interaction } from './interaction.js'; import type { ChartInteraction } from './scene.js'; export type InspectionChannel = 'inspect' | 'select'; @@ -14,143 +15,102 @@ export interface InspectionState { select: InspectionReading; } -interface Identity { +interface ReportedReading { snapshot: object | null; - key: string | null; + target: ChartInteraction | null; series: string | null; pinned: boolean; } - -const empty = (): Identity => ({ snapshot: null, key: null, series: null, pinned: false }); +const empty: ReportedReading = { snapshot: null, target: null, series: null, pinned: false }; const channels = ['inspect', 'select'] as const; -function reading(value: InspectionReading, selected = false) { - return { - target: value.target, - series: value.target ? (value.series ?? null) : null, - pinned: !!value.target && (selected || value.pinned === true) - }; -} - -function identity(snapshot: object | null, value: InspectionReading): Identity { +function reading( + value: InspectionReading, + snapshot: object | null, + selected = false +): ReportedReading { return value.target ? { snapshot, - key: value.target.key, + target: value.target, series: value.series ?? null, - pinned: value.pinned === true + pinned: selected || value.pinned === true } - : empty(); + : empty; } -function same(a: Identity, b: Identity) { +function same(a: ReportedReading, b: ReportedReading) { return ( - a.snapshot === b.snapshot && a.key === b.key && a.series === b.series && a.pinned === b.pinned + a.snapshot === b.snapshot && + a.target?.key === b.target?.key && + a.series === b.series && + a.pinned === b.pinned ); } /** DOM-free delivery protocol shared by headless callbacks and component events. */ export class InspectionNotifications { - #reported = { inspect: empty(), select: empty() }; + #reports: Record>>; #versions = { inspect: 0, select: 0 }; - #closed = false; - #delivering = false; - #pending: (() => boolean)[] = []; + #read: (() => InspectionState) | null; + readonly #interaction: Interaction; - constructor(private readonly read: () => InspectionState) {} + constructor(read: () => InspectionState, interaction: Interaction) { + this.#read = read; + this.#interaction = interaction; + this.#reports = { + inspect: interaction.channel(empty, same), + select: interaction.channel(empty, same) + }; + } /** Cancel captured work without forgetting the state already delivered to the host. */ invalidate(channel?: InspectionChannel) { for (const name of channel ? [channel] : channels) this.#versions[name]++; } - /** Finish the current broadcast before notifying observers of a nested state change. */ - #deliver(send: () => boolean): boolean { - if (this.#delivering) { - this.#pending.push(send); - return true; - } - this.#delivering = true; - const errors: unknown[] = []; - const attempt = (callback: () => boolean) => { - try { - return callback(); - } catch (error) { - errors.push(error); - return false; - } - }; - try { - const sent = attempt(send); - for (const pending of this.#pending) attempt(pending); - // Finish valid nested reports before propagating the first host failure. - if (errors.length) throw errors[0]; - return sent; - } finally { - this.#pending.length = 0; - this.#delivering = false; - } - } - capture() { - const state: InspectionState = this.#closed - ? { snapshot: null, inspect: { target: null }, select: { target: null } } - : this.read(); - const values = { - inspect: reading(state.inspect), - select: reading(state.select, true) + const state = this.#read?.() ?? { + snapshot: null, + inspect: empty, + select: empty }; - const identities = { - inspect: identity(state.snapshot, values.inspect), - select: identity(state.snapshot, values.select) + const values = { + snapshot: state.snapshot, + inspect: reading(state.inspect, state.snapshot), + select: reading(state.select, state.snapshot, true) }; const versions = { ...this.#versions }; - const reported = { ...this.#reported }; - const current = (channel?: InspectionChannel): boolean => { - if (!channel) return channels.every((name) => current(name)); - if ( - this.#closed || - versions[channel] !== this.#versions[channel] || - reported[channel] !== this.#reported[channel] - ) - return false; - const live = this.read(); - return same( - identities[channel], - identity(live.snapshot, reading(live[channel], channel === 'select')) - ); + const current = (channel: InspectionChannel): boolean => { + if (!this.#read || versions[channel] !== this.#versions[channel]) return false; + const live = this.#read(); + return same(values[channel], reading(live[channel], live.snapshot, channel === 'select')); }; return { - state: { snapshot: state.snapshot, ...values }, + state: values, current, emit: ( channel: InspectionChannel, deliver: (value: (typeof values)[InspectionChannel], snapshot: Snapshot | null) => void, - options: { force?: boolean; before?(): void } = {} + force = false ): boolean => { - const ready = () => - current(channel) && - (options.force || !same(identities[channel], this.#reported[channel])); - if (!ready()) return false; - return this.#deliver(() => { - if (!ready()) return false; - options.before?.(); - if (!current(channel)) return false; - // Commit before user code; queued reports are revalidated when their turn arrives. - const report = { ...identities[channel] }; - this.#reported[channel] = reported[channel] = report; - deliver(values[channel], state.snapshot); - return true; - }); + return this.#interaction.report( + this.#reports[channel], + values[channel], + () => current(channel), + (value) => { + versions[channel] = ++this.#versions[channel]; + deliver(value, values.snapshot); + }, + force + ); } }; } /** Permanent headless disposal is silent and cancels every pending callback. */ dispose() { - this.#closed = true; - this.invalidate(); - this.#pending.length = 0; - this.#reported = { inspect: empty(), select: empty() }; + this.#read = null; + for (const channel of channels) this.#reports[channel].value = empty; } } diff --git a/src/core/interaction.ts b/src/core/interaction.ts new file mode 100644 index 0000000..555e6a1 --- /dev/null +++ b/src/core/interaction.ts @@ -0,0 +1,112 @@ +type Effect = readonly [run: () => void, current: (() => boolean) | undefined]; +const nonempty = (queue?: ReadonlyMap) => queue?.size; + +/** One commit/effect boundary per chart. State transitions never call observers. */ +export class Interaction { + declare reading?: unknown; + #busy = 0; + #work: Map[] = []; + #scheduled = false; + + batch(commit: () => T): T { + this.#busy++; + try { + return commit(); + } finally { + if (!--this.#busy) this.#flush(); + } + } + + /** A key coalesces obsolete effects. A broadcast always runs to completion. */ + effect(run: () => void, key: object = {}, phase = 0, current?: () => boolean): boolean { + if (current && !current()) return false; + this.#work[phase] ??= new Map(); + this.#work[phase].set(key, [run, current]); + this.#flush(); + return true; + } + + channel(value: T, same: (a: T, b: T) => boolean): InteractionChannel { + return { value, same }; + } + report( + channel: InteractionChannel, + value: T, + current: () => boolean, + deliver: (value: T) => void, + force = false, + phase = 2 + ): boolean { + const ready = () => current() && (force || !channel.same(channel.value, value)); + return this.effect( + () => { + channel.value = value; + deliver(value); + }, + channel, + phase, + ready + ); + } + + #flush() { + if (this.#busy || this.#scheduled) return; + this.#busy = 1; + const errors: unknown[] = []; + try { + // A changing-value observer cycle must yield to input, timers and painting. + for (let budget = 64; budget; budget--) { + const queue = this.#work.find(nonempty); + if (!queue) break; + const [key, [run, current]] = queue.entries().next().value!; + queue.delete(key); + try { + if (!current || current()) run(); + } catch (error) { + errors.push(error); + } + } + } finally { + this.#busy = 0; + if (this.#work.find(nonempty)) { + this.#scheduled = true; + setTimeout(() => { + this.#scheduled = false; + this.#flush(); + }, 0); + } + } + if (errors.length) throw errors[0]; + } +} + +export interface InteractionChannel { + value: T; + same(a: T, b: T): boolean; +} + +// Public session objects are facades; sharing a session never duplicates its state owner. +const owners = new WeakMap(); +export function interactionFor(owner: object): Interaction { + let interaction = owners.get(owner); + if (!interaction) { + interaction = new Interaction(); + owners.set(owner, interaction); + } + return interaction; +} + +export function shareInteraction(owner: object, interaction: Interaction) { + owners.set(owner, interaction); +} + +/** Canvas providers are consumed once; an unrelated runtime never inherits a disposed session. */ +export function claimInteraction(owner: object, provider: object) { + const interaction = owners.get(provider); + owners.delete(provider); + if (interaction) owners.set(owner, interaction); +} + +export function detachInteraction(owner: object) { + owners.delete(owner); +} diff --git a/src/core/reading-session.ts b/src/core/reading-session.ts new file mode 100644 index 0000000..6ff75dc --- /dev/null +++ b/src/core/reading-session.ts @@ -0,0 +1,123 @@ +import type { ResultRow } from '../data/result.js'; +import type { ChartInteraction, RenderModel } from './scene.js'; +import { readingState, clearReading, editReading } from './reading-state.js'; + +export type InspectionNavigation = 'Home' | 'End' | 'next' | 'previous'; + +/** Pure reading reducers shared by the public facade and small headless views. */ +export class ReadingSession { + #target(key: string | null) { + return key === null + ? null + : (readingState(this).model?.interactionByKey.get(key) ?? null); + } + get active(): ChartInteraction | null { + return this.#target(readingState(this).active); + } + /** Pointer feedback remains independent of a pinned or locked reading. */ + get hovered(): ChartInteraction | null { + return this.#target(readingState(this).hovered); + } + get pinned(): boolean { + const state = readingState(this); + return state.selected !== null && state.active === state.selected; + } + get selected(): ChartInteraction | null { + return this.#target(readingState(this).selected); + } + get series(): string | null { + return readingState(this).series; + } + get selectedSeries(): string | null { + return readingState(this).selectedSeries; + } + /** Keyboard reading survives passive pointer leave and focus-induced page scrolling. */ + get keyboardInspection(): boolean { + return readingState(this).keyboard; + } + get hidden(): Set { + return new Set(); + } + /** Reconcile by stable identity when layout, size or visible marks change. */ + setModel(model: RenderModel | null) { + const state = editReading(this); + if (model === state.model) return; + state.model = model; + state.modelVersion++; + state.indices = new Map(model?.interactions.map((value, index) => [value.key, index])); + const present = (key: string | null) => + key !== null && model?.interactionByKey.has(key) ? key : null; + state.hovered = present(state.hovered); + state.selected = present(state.selected); + const previousKey = state.active; + state.active = present(state.active) ?? state.selected; + if (previousKey !== state.active) state.series = state.selectedSeries; + if (state.series && !this.active?.tooltip.rows.some((row) => row.label === state.series)) + state.series = null; + if ( + state.selectedSeries && + !this.selected?.tooltip.rows.some((row) => row.label === state.selectedSeries) + ) + state.selectedSeries = null; + if (!state.active) { + state.intent = {}; + state.series = null; + state.keyboard = false; + } + } + /** Return whether reading input was accepted; passive hover remains independent. */ + inspect( + key: string | null, + pin = false, + keyboard = false, + series: string | null = null + ): boolean { + const state = editReading(this); + const value = key && state.model?.interactionByKey.has(key) ? key : null; + state.hovered = pin || keyboard ? null : value; + if (state.keyboard && key === null && !pin && !keyboard) return false; + if (pin && value === state.selected) clearReading(state); + else { + if (pin || keyboard) { + state.selected = pin ? value : null; + state.selectedSeries = pin ? series : null; + state.intent = {}; + } + if (state.active !== (value ?? state.selected)) state.intent = {}; + state.active = value ?? state.selected; + state.series = value ? series : state.selectedSeries; + state.keyboard = keyboard; + } + return true; + } + clearInspection() { + clearReading(editReading(this)); + } + /** Choose the next canonical target without relying on painted hit regions. */ + next(direction: InspectionNavigation): ChartInteraction | null { + const state = readingState(this); + const values = state.model?.interactions ?? []; + if (!values.length) return null; + const index = state.active === null ? -1 : (state.indices!.get(state.active) ?? -1); + const forward = direction === 'next'; + const target = + direction === 'Home' + ? 0 + : direction === 'End' + ? values.length - 1 + : index < 0 + ? forward + ? 0 + : values.length - 1 + : (index + (forward ? 1 : -1) + values.length) % values.length; + return values[target]; + } + /** New documents and disconnection cannot retain stale targets or source objects. */ + reset() { + const state = editReading(this); + clearReading(state); + state.model = null; + state.modelVersion++; + state.indices?.clear(); + } +} diff --git a/src/core/reading-state.ts b/src/core/reading-state.ts new file mode 100644 index 0000000..c01edce --- /dev/null +++ b/src/core/reading-state.ts @@ -0,0 +1,71 @@ +import { interactionFor, shareInteraction, detachInteraction } from './interaction.js'; +import type { RenderModel } from './scene.js'; + +export interface ReadingState { + model: RenderModel | null; + modelVersion: number; + indices?: Map; + active: string | null; + hovered: string | null; + selected: string | null; + series: string | null; + selectedSeries: string | null; + lock?: object; + keyboard: boolean; + hidden?: Set; + restore?: { key: string | null; intent: object }; + intent: object; +} + +export function readingState(owner: object): Readonly> { + const interaction = interactionFor(owner); + interaction.reading ??= { + model: null, + modelVersion: 0, + active: null, + hovered: null, + selected: null, + series: null, + selectedSeries: null, + keyboard: false, + intent: {} + }; + return interaction.reading as Readonly>; +} + +/** Mutable access is restricted to synchronous reducers; runtime effects read snapshots. */ +export const editReading = readingState as (owner: object) => ReadingState; + +export function clearReading(state: ReadingState): void { + state.active = state.hovered = state.selected = null; + state.series = state.selectedSeries = null; + state.keyboard = false; + state.intent = {}; +} + +export function restoredKey(owner: object): string | null | undefined { + const state = readingState(owner); + return state.restore?.intent === state.intent ? state.restore.key : undefined; +} + +/** Controlled restoration is a semantic intent, including before the first model exists. */ +export function restoreReading(owner: object, key: string | null) { + const state = editReading(owner); + clearReading(state); + const target = key === null ? null : state.model?.interactionByKey.get(key); + state.restore = { key, intent: state.intent }; + state.active = state.selected = target?.key ?? null; +} + +const borrowed = new WeakSet(); + +/** A newly constructed runtime uses its host's existing state, not a second session. */ +export function shareReading(owner: object, source: object) { + shareInteraction(owner, interactionFor(source)); + borrowed.add(owner); +} + +/** Retained runtime facades must not write into a host's next lifecycle. */ +export function releaseReading(owner: object) { + if (borrowed.delete(owner)) detachInteraction(owner); +} diff --git a/src/core/scene-snapshot.ts b/src/core/scene-snapshot.ts new file mode 100644 index 0000000..9c87727 --- /dev/null +++ b/src/core/scene-snapshot.ts @@ -0,0 +1,13 @@ +import type { ChartScene } from './scene.js'; + +const snapshots = new WeakMap(); + +/** A frame keeps the source that actually produced it, including while new data waits. */ +export function captureScene(scene: ChartScene, snapshot: object) { + snapshots.set(scene, snapshot); + return scene; +} + +export function sceneSnapshot(scene: object | null | undefined): object | null { + return scene ? (snapshots.get(scene) ?? null) : null; +} diff --git a/src/core/session.ts b/src/core/session.ts index 5c92c8d..e3d77cd 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -1,168 +1,77 @@ +import type { RenderModel } from './scene.js'; import type { ResultRow } from '../data/result.js'; -import type { ChartInteraction, RenderModel } from './scene.js'; +import { ReadingSession } from './reading-session.js'; +import { readingState, clearReading, editReading, restoredKey } from './reading-state.js'; +export type { InspectionNavigation } from './reading-session.js'; -export type InspectionNavigation = 'Home' | 'End' | 'next' | 'previous'; - -/** Ephemeral state shared by Web Components, framework hosts and chart families. */ -export class ChartSession { - #model: RenderModel | null = null; - #active: ChartInteraction | null = null; - #hovered: ChartInteraction | null = null; - #selected: ChartInteraction | null = null; - #series: string | null = null; - #selectedSeries: string | null = null; - #tooltipLocked = false; - #keyboardInspection = false; - #hidden = new Set(); - #indices = new Map(); - - /** Freeze retains the original pin contract; compare separates hover and selection. */ - constructor(readonly inspectionMode: 'freeze' | 'compare' = 'freeze') {} - - get active(): ChartInteraction | null { - return this.#active; - } - /** Pointer feedback remains independent of a pinned or locked reading. */ - get hovered(): ChartInteraction | null { - return this.#hovered; - } - get pinned(): boolean { - return this.#selected !== null && this.#active?.key === this.#selected.key; - } - get selected(): ChartInteraction | null { - return this.#selected; - } - get series(): string | null { - return this.#series; - } - get selectedSeries(): string | null { - return this.#selectedSeries; - } - get tooltipLocked(): boolean { - return this.#tooltipLocked; - } - lockTooltip(locked: boolean) { - this.#tooltipLocked = locked && this.#active !== null; - } - /** Keyboard reading survives passive pointer leave and focus-induced page scrolling. */ - get keyboardInspection(): boolean { - return this.#keyboardInspection; - } - get hidden(): Set { - return new Set(this.#hidden); - } - /** Reconcile by stable identity when layout, size or visible marks change. */ - setModel(model: RenderModel | null) { - if (model === this.#model) return; - this.#model = model; - this.#hovered = this.#hovered ? (model?.interactionByKey.get(this.#hovered.key) ?? null) : null; - this.#indices = new Map(model?.interactions.map((value, index) => [value.key, index]) ?? []); - this.#selected = this.#selected - ? (model?.interactionByKey.get(this.#selected.key) ?? null) - : null; - const previousKey = this.#active?.key; - this.#active = this.#active - ? (model?.interactionByKey.get(this.#active.key) ?? this.#selected) - : this.#selected; - if (previousKey !== this.#active?.key) this.#series = this.#selectedSeries; - if (this.#series && !this.#active?.tooltip.rows.some((row) => row.label === this.#series)) - this.#series = null; - if ( - this.#selectedSeries && - !this.#selected?.tooltip.rows.some((row) => row.label === this.#selectedSeries) - ) - this.#selectedSeries = null; - if (!this.#active) { - this.#tooltipLocked = false; - this.#series = null; - this.#keyboardInspection = false; +/** Public session facade; controlled visibility and restoration stay optional for views. */ +export class ChartSession extends ReadingSession { + constructor(readonly inspectionMode: 'freeze' | 'compare' = 'freeze') { + super(); + } + get tooltipLocked() { + const state = readingState(this); + return state.lock === state.intent && state.active !== null; + } + override setModel(model: RenderModel | null) { + const restored = restoredKey(this); + super.setModel(model); + if (restored !== undefined) { + const state = editReading(this); + const target = restored === null ? null : model?.interactionByKey.get(restored); + clearReading(state); + state.active = state.selected = target?.key ?? null; } } - /** Returns whether reading input was accepted. Pointer feedback updates even when a - * pin or lock rejects a passive reading change. */ - inspect( + override inspect( key: string | null, pin = false, keyboard = false, series: string | null = null ): boolean { - const value = key ? (this.#model?.interactionByKey.get(key) ?? null) : null; - this.#hovered = pin || keyboard ? null : value; + const state = editReading(this); if ( !pin && !keyboard && - (this.#tooltipLocked || (this.inspectionMode === 'freeze' && this.#selected)) - ) + (this.tooltipLocked || (this.inspectionMode === 'freeze' && state.selected)) + ) { + state.hovered = key && state.model?.interactionByKey.has(key) ? key : null; return false; - if (this.#keyboardInspection && key === null && !pin && !keyboard) return false; - if (pin && value?.key === this.#selected?.key) { - this.clearInspection(); - return true; - } - if (pin || keyboard) { - this.#selected = pin ? value : null; - this.#selectedSeries = pin ? series : null; - this.#tooltipLocked = false; } - this.#active = value ?? this.#selected; - this.#series = value ? series : this.#selectedSeries; - this.#keyboardInspection = keyboard; - return true; + return super.inspect(key, pin, keyboard, series); } - clearInspection() { - this.#active = null; - this.#hovered = null; - this.#selected = null; - this.#series = null; - this.#selectedSeries = null; - this.#tooltipLocked = false; - this.#keyboardInspection = false; + override get hidden(): Set { + return new Set(readingState(this).hidden); } - /** Choose the next canonical target; inspection also works without pointer input. */ - next(direction: InspectionNavigation): ChartInteraction | null { - const values = this.#model?.interactions ?? []; - if (!values.length) return null; - const index = this.#active ? (this.#indices.get(this.#active.key) ?? -1) : -1; - const forward = direction === 'next'; - const target = - direction === 'Home' - ? 0 - : direction === 'End' - ? values.length - 1 - : index < 0 - ? forward - ? 0 - : values.length - 1 - : (index + (forward ? 1 : -1) + values.length) % values.length; - return values[target]; + override reset() { + super.reset(); + editReading(this).hidden?.clear(); + } + lockTooltip(locked: boolean) { + const state = editReading(this); + state.lock = locked && state.active !== null ? state.intent : undefined; } toggleSeries(name: string, names: readonly string[], isolate = false) { if (!names.includes(name)) return; - if (isolate) this.#hidden = new Set(names.filter((value) => value !== name)); - else if (this.#hidden.has(name)) this.#hidden.delete(name); - else this.#hidden.add(name); + const state = editReading(this); + state.hidden = new Set(isolate ? names.filter((value) => value !== name) : state.hidden); + if (!isolate) { + if (state.hidden.has(name)) state.hidden.delete(name); + else state.hidden.add(name); + } } showAll() { - this.#hidden.clear(); + this.setHidden([]); } /** Controlled visibility is copied; callers reconcile it against their current series. */ setHidden(names: Iterable) { - this.#hidden = new Set(names); + editReading(this).hidden = new Set(names); } /** Idempotent programmatic restore; unlike inspect(), restoring the same pin never toggles it. */ restorePin(key: string | null) { - this.clearInspection(); - const target = key === null ? null : this.#model?.interactionByKey.get(key); - if (target) { - this.#active = target; - this.#selected = target; - } - } - /** New documents and disconnection cannot retain stale targets or source objects. */ - reset() { - this.clearInspection(); - this.#hidden.clear(); - this.#model = null; - this.#indices.clear(); + const state = editReading(this); + const target = key === null ? null : state.model?.interactionByKey.get(key); + clearReading(state); + state.active = state.selected = target?.key ?? null; } } diff --git a/src/elements/chart-element.ts b/src/elements/chart-element.ts index 05b54c1..159d655 100644 --- a/src/elements/chart-element.ts +++ b/src/elements/chart-element.ts @@ -1,3 +1,4 @@ +import { captureScene, sceneSnapshot } from '../core/scene-snapshot.js'; import { object, boolean } from '../data/validation.js'; import { compileChart, @@ -10,6 +11,13 @@ import { prepareAggregateValues } from '../families/metrics/prepared.js'; import { legendReadings } from '../families/metrics/readings.js'; import type { ChartRenderer } from './plot-contract.js'; import type { ChartInteraction, Inspect, Position } from '../core/scene.js'; +import { interactionFor } from '../core/interaction.js'; +import { + restoredKey, + restoreReading, + shareReading, + releaseReading +} from '../core/reading-state.js'; import { ChartSession } from '../core/session.js'; import { InspectionNotifications, @@ -93,7 +101,9 @@ export type ScopeChartEventMap = ScopeEventMap; export class ScopeChartElement extends HTMLElement { static observedAttributes = ['loading', 'error', 'theme', 'style', 'class']; #compiled: CompiledChart | null = null; - #renderedCompiled: CompiledChart | null = null; + get #renderedCompiled() { + return sceneSnapshot(this.#plot?.model) as CompiledChart | null; + } #ui: MetricComponentOptions = {}; get #options() { return { @@ -113,18 +123,28 @@ export class ScopeChartElement extends HTMLElement { #events: AbortController | null = null; #page = 0; #propertySet = false; - #pendingPin: string | null | undefined; - #notifications = new InspectionNotifications(() => ({ - snapshot: this.#renderedCompiled, - inspect: { - target: this.#session.active, - series: this.#session.series, - pinned: this.#session.pinned - }, - select: { target: this.#session.selected, series: this.#session.selectedSeries } - })); + #notifications = new InspectionNotifications( + () => ({ + snapshot: this.#renderedCompiled, + inspect: { + target: this.#session.active, + series: this.#session.series, + pinned: this.#session.pinned + }, + select: { target: this.#session.selected, series: this.#session.selectedSeries } + }), + interactionFor(this.#session) + ); + #visibilityReports = interactionFor(this.#session).channel( + [], + (a, b) => JSON.stringify(a) === JSON.stringify(b) + ); + #readingEffect = {}; #stateReason: ViewStateChangeDetail['reason'] = 'reconcile'; - #lastState = ''; + #stateReports = interactionFor(this.#session).channel( + null, + (a, b) => JSON.stringify(a?.state) === JSON.stringify(b?.state) + ); #linkedHighlight: LinkedHighlight | null = null; #stateTransaction = false; #cursorTimeMs: number | null = null; @@ -132,7 +152,7 @@ export class ScopeChartElement extends HTMLElement { #selectedNeighbors: string[] = []; #root: ShadowRoot; #appearance = new ElementTheme(this); - #controlEvents = new ControlEvents(); + #controlEvents = new ControlEvents(interactionFor(this.#session)); constructor() { super(); this.#root = this.attachShadow({ mode: 'open' }); @@ -164,57 +184,77 @@ export class ScopeChartElement extends HTMLElement { } /** Patch host UI without resetting the document or interaction state. */ configure(options: MetricComponentOptions) { - this.#ui = { ...this.#ui, ...parseComponentOptions(options, 'metric') }; - this.#buildLegend(); - this.#table(); - this.refresh(); + interactionFor(this.#session).batch(() => { + this.#ui = { ...this.#ui, ...parseComponentOptions(options, 'metric') }; + this.#buildLegend(); + this.#table(); + this.refresh(); + }); } /** Replace data explicitly; preservation requires compatible family-owned identities. */ updateDocument(value: unknown, options: DocumentUpdateOptions = {}) { - object(options, 'options', ['preserveViewState']); - if (options.preserveViewState !== undefined) - boolean(options.preserveViewState, 'options.preserveViewState'); - const previous = options.preserveViewState ? this.viewState : null; - this.#stateTransaction = true; - this.#propertySet = true; - this.#clearInspection(); - this.#session.reset(); - this.#linkedHighlight = null; - if (this.#plot) this.#plot.linkedHighlight = null; - this.rangeSelection = null; - this.#page = 0; - try { - this.#compiled = value === null ? null : compileChart(value); - this.#error = null; - } catch (error) { - this.#compiled = null; - this.#error = error instanceof Error ? error.message : 'Invalid chart document.'; - } - if (previous && this.#compiled) this.#applyState(previous); - this.#stateReason = 'document'; - this.#buildLegend(); - this.#legendState(); - this.#table(); - this.#stateTransaction = false; - this.refresh(); + interactionFor(this.#session).batch(() => { + object(options, 'options', ['preserveViewState']); + if (options.preserveViewState !== undefined) + boolean(options.preserveViewState, 'options.preserveViewState'); + const previous = options.preserveViewState ? this.viewState : null; + this.#stateTransaction = true; + this.#propertySet = true; + this.#clearInspection(); + this.#session.reset(); + this.#linkedHighlight = null; + this.#rangeSelection = null; + this.#page = 0; + try { + this.#compiled = value === null ? null : compileChart(value); + this.#error = null; + } catch (error) { + this.#compiled = null; + this.#error = error instanceof Error ? error.message : 'Invalid chart document.'; + } + if (previous && this.#compiled) this.#applyState(previous); + this.#stateReason = 'document'; + this.#buildLegend(); + this.#legendState(); + this.#table(); + this.#stateTransaction = false; + this.refresh(); + const revision = this.#revision, + plot = this.#plot; + if (plot) + interactionFor(this.#session).effect( + () => { + plot.linkedHighlight = null; + if (revision === this.#revision && plot === this.#plot) + plot.rangeSelection = this.#rangeSelection; + }, + plot, + 0, + () => revision === this.#revision && plot === this.#plot + ); + }); } get viewState(): MetricViewState { return { version: 1, kind: 'metric', pinnedKey: - this.#pendingPin !== undefined ? this.#pendingPin : (this.#session.selected?.key ?? null), + restoredKey(this.#session) !== undefined + ? restoredKey(this.#session)! + : (this.#session.selected?.key ?? null), hiddenSeries: [...this.#session.hidden].sort() }; } set viewState(value: MetricViewState) { - const state = parseViewState(value); - if (state.kind !== 'metric') throw new TypeError('A metric chart needs metric view state.'); - this.#notifications.invalidate(); - this.#applyState(state); - this.#stateReason = 'restore'; - this.#legendState(); - this.refresh(); + interactionFor(this.#session).batch(() => { + const state = parseViewState(value); + if (state.kind !== 'metric') throw new TypeError('A metric chart needs metric view state.'); + this.#notifications.invalidate(); + this.#applyState(state); + this.#stateReason = 'restore'; + this.#legendState(); + this.refresh(); + }); } /** Explicit reading lock, independent of persistent selection and portable view state. */ get tooltipLocked(): boolean { @@ -227,7 +267,7 @@ export class ScopeChartElement extends HTMLElement { #applyState(state: MetricViewState) { const names = new Set(this.#compiled?.series.map((series) => series.name)); this.#session.setHidden(state.hiddenSeries.filter((name) => names.has(name))); - this.#pendingPin = state.pinnedKey; + restoreReading(this.#session, state.pinnedKey); } get linkedHighlight() { return this.#linkedHighlight && { ...this.#linkedHighlight }; @@ -238,11 +278,20 @@ export class ScopeChartElement extends HTMLElement { } #stateChanged(reason: ViewStateChangeDetail['reason'] = 'interaction') { if (this.#stateTransaction) return; - const state = this.viewState, - key = JSON.stringify(state); - if (key === this.#lastState) return; - this.#lastState = key; - this.#emit('scope-view-state-change', { state, reason } satisfies ViewStateChangeDetail); + const state = this.viewState; + const snapshot = this.#compiled; + const key = JSON.stringify(state); + this.#notifyControls(); + interactionFor(this.#session).report( + this.#stateReports, + { state, reason }, + () => snapshot === this.#compiled && key === JSON.stringify(this.viewState), + (detail) => { + this.#dispatch('scope-view-state-change', detail!); + }, + false, + 4 + ); } /** Ephemeral trusted binding; never serialized into a chart document. */ set rangeSelection(binding: RangeSelectionBinding | null) { @@ -420,13 +469,15 @@ export class ScopeChartElement extends HTMLElement { this.refresh(); } disconnectedCallback() { - this.#revision++; - cancelAnimationFrame(this.#frame); - this.#frame = 0; - this.#mutation?.disconnect(); - this.#events?.abort(); - this.#clearInspection(); - this.#dispose(); + interactionFor(this.#session).batch(() => { + this.#revision++; + cancelAnimationFrame(this.#frame); + this.#frame = 0; + this.#mutation?.disconnect(); + this.#events?.abort(); + this.#clearInspection(); + this.#dispose(); + }); } attributeChangedCallback() { this.refresh(); @@ -437,7 +488,9 @@ export class ScopeChartElement extends HTMLElement { if (!this.isConnected || this.#frame) return; this.#frame = requestAnimationFrame(() => { this.#frame = 0; - void this.#render(this.#revision); + interactionFor(this.#session).batch(() => { + void this.#render(this.#revision); + }); }); } #readJSON() { @@ -454,8 +507,37 @@ export class ScopeChartElement extends HTMLElement { this.#propertySet = false; } #emit(name: K, detail: ScopeChartEventDetails[K]) { + const snapshot = this.#compiled; + const scene = this.#plot?.model; this.#notifyControls(); - this.#dispatch(name, detail); + if (name === 'scope-visibility-change') { + const value = (detail as { hidden: string[] }).hidden; + interactionFor(this.#session).report( + this.#visibilityReports, + value, + () => + snapshot === this.#compiled && + JSON.stringify(value) === JSON.stringify([...this.#session.hidden]), + (hidden) => this.#dispatch('scope-visibility-change', { hidden: [...hidden] }) + ); + return; + } + interactionFor(this.#session).effect( + () => { + if ( + snapshot === this.#compiled && + (name !== 'scope-ready' || + (this.isConnected && + !this.loading && + !this.error && + !this.#error && + scene === this.#plot?.model)) + ) + this.#dispatch(name, detail); + }, + {}, + name === 'scope-ready' ? 5 : 2 + ); } #dispatch(name: K, detail: ScopeChartEventDetails[K]) { this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })); @@ -471,7 +553,7 @@ export class ScopeChartElement extends HTMLElement { channel, (value, snapshot) => this.#dispatch(`scope-${channel}`, this.#inspectionDetail(value, snapshot, cursorTimeMs)), - { force: channel === 'inspect' && force, before: () => this.#notifyControls() } + channel === 'inspect' && force ); } #status(state: string, message: string | null) { @@ -485,10 +567,12 @@ export class ScopeChartElement extends HTMLElement { this.#notifyControls(); } #dispose() { - this.#plot?.dispose(); + const plot = this.#plot; this.#plot = null; - this.#renderedCompiled = null; + if (plot && 'session' in plot && plot.session && typeof plot.session === 'object') + releaseReading(plot.session); this.#session.setModel(null); + interactionFor(this.#session).effect(() => plot?.dispose()); } /** Load the browser renderer only when a plotted chart is needed. */ protected async renderer() { @@ -527,7 +611,7 @@ export class ScopeChartElement extends HTMLElement { this.#dispose(); if (saved) this.#applyState(saved); this.#stateTransaction = false; - if (!compiled || error) this.#pendingPin = undefined; + this.#stateChanged(this.#stateReason); if (error) { this.#status('error', error); @@ -542,84 +626,114 @@ export class ScopeChartElement extends HTMLElement { ); return; } - try { - if (compiled.config.mapping.kind === 'aggregate') { - this.#el('.inspect').disabled = true; - this.#clearInspection(this.#stateReason); - this.#dispose(); - const metrics = this.#el('.metrics'); - metrics.replaceChildren(); - metrics.hidden = false; - for (const value of prepareAggregateValues(compiled.dataset, compiled.config.mapping)) { - const metric = node('div', 'metric'); - const column = compiled.dataset.columns.find((c) => c.id === value.id)!; - const display = compiled.displays.get(value.id); - const raw = compiled.document.data.rows[0][column.columnIndex]; - const compact = - value.value === null || display - ? formatQuantity(raw, display) - : compactFormat(value.value), - exact = formatQuantity(raw, display); - metric.append( - node('div', 'metric-label', value.label), - node('div', 'metric-value', compact) - ); - if (compact !== exact) metric.append(node('div', 'metric-exact', exact)); - metrics.append(metric); - } - } else { - if (!this.#plot) { - this.#status('loading', 'Loading chart…'); - const create = await this.renderer(); - if (revision !== this.#revision || !this.isConnected) return; - this.#plot = create( - this.#el('.canvas'), - this.#el('.decoration'), - (value, position, pin, series, neighbors) => - this.#inspect(value, position, pin, false, series, neighbors) - ); - } - this.#plot.update(compiled, this.#session.hidden, this.#ui.axes ?? 'full'); - this.#renderedCompiled = compiled; - this.#plot.linkedHighlight = this.#linkedHighlight; - this.#plot.rangeSelection = this.#rangeSelection; - this.#session.setModel(this.#plot.model); - if (this.#pendingPin !== undefined) { - this.#session.restorePin(interactive ? this.#pendingPin : null); - this.#pendingPin = undefined; + if (compiled.config.mapping.kind !== 'aggregate' && !this.#plot) { + try { + this.#status('loading', 'Loading chart…'); + const create = await this.renderer(); + if (revision !== this.#revision || !this.isConnected) return; + let adopted = false; + const plot = create( + this.#el('.canvas'), + this.#el('.decoration'), + (value, position, pin, series, neighbors) => { + if (adopted && this.#plot === plot) + this.#inspect(value, position, pin, false, series, neighbors); + } + ); + if (revision !== this.#revision || !this.isConnected) { + plot.dispose(); + return; } - if (this.#session.active) { - this.#showInspection(null); + if ('session' in plot && plot.session && typeof plot.session === 'object') + shareReading(plot.session, this.#session); + this.#plot = plot; + adopted = true; + } catch (error) { + if (revision === this.#revision && this.isConnected) + interactionFor(this.#session).batch(() => this.#renderError(error)); + return; + } + } + interactionFor(this.#session).batch(() => { + try { + if (compiled.config.mapping.kind === 'aggregate') { + this.#el('.inspect').disabled = true; + this.#clearInspection(this.#stateReason); + this.#dispose(); + const metrics = this.#el('.metrics'); + metrics.replaceChildren(); + metrics.hidden = false; + for (const value of prepareAggregateValues(compiled.dataset, compiled.config.mapping)) { + const metric = node('div', 'metric'); + const column = compiled.dataset.columns.find((c) => c.id === value.id)!; + const display = compiled.displays.get(value.id); + const raw = compiled.document.data.rows[0][column.columnIndex]; + const compact = + value.value === null || display + ? formatQuantity(raw, display) + : compactFormat(value.value), + exact = formatQuantity(raw, display); + metric.append( + node('div', 'metric-label', value.label), + node('div', 'metric-value', compact) + ); + if (compact !== exact) metric.append(node('div', 'metric-exact', exact)); + metrics.append(metric); + } } else { - this.#plot.focus(null); - hideInspection(this.#el('.tooltip')); - this.#el('.inspect').removeAttribute('data-active'); + const plot = this.#plot; + if (!plot) return; + const previousScene = plot.model; + plot.update(compiled, this.#session.hidden, this.#ui.axes ?? 'full'); + if (compiled !== this.#compiled || revision !== this.#revision || !this.isConnected) + return; + if (plot.model && (plot.model !== previousScene || !sceneSnapshot(plot.model))) + captureScene(plot.model, compiled); + plot.linkedHighlight = this.#linkedHighlight; + if (compiled !== this.#compiled || revision !== this.#revision || !this.isConnected) + return; + plot.rangeSelection = this.#rangeSelection; + const model = plot.model; + if (compiled !== this.#compiled || revision !== this.#revision || !this.isConnected) + return; + this.#session.setModel(model); + if (this.#session.active) { + this.#showInspection(null); + } else { + plot.focus(null); + hideInspection(this.#el('.tooltip')); + this.#el('.inspect').removeAttribute('data-active'); + } + this.#el('.viewport').setAttribute( + 'aria-label', + title || plot.model?.ariaLabel || 'ScopeDB chart' + ); + this.#el('.inspect').disabled = + !interactive || !plot.model?.interactions.length; } - this.#el('.viewport').setAttribute( - 'aria-label', - title || this.#plot.model?.ariaLabel || 'ScopeDB chart' - ); - this.#el('.inspect').disabled = - !interactive || !this.#plot.model?.interactions.length; + const batch = this.#notifications.capture(); + this.#status('ready', null); + this.#reportInspection(batch); + if (compiled !== this.#compiled || !this.isConnected) return; + this.#stateChanged(this.#stateReason); + this.#stateReason = 'reconcile'; + this.#emit('scope-ready', { + visualization: compiled.config.displayType, + rows: compiled.dataset.rows.length + }); + } catch (error) { + if (compiled === this.#compiled && revision === this.#revision && this.isConnected) + this.#renderError(error); } - const batch = this.#notifications.capture(); - this.#status('ready', null); - this.#reportInspection(batch); - if (compiled !== this.#compiled || !this.isConnected) return; - this.#stateChanged(this.#stateReason); - this.#stateReason = 'reconcile'; - this.#emit('scope-ready', { - visualization: compiled.config.displayType, - rows: compiled.dataset.rows.length - }); - } catch (error) { - this.#el('.inspect').disabled = true; - this.#clearInspection(); - this.#dispose(); - const message = error instanceof Error ? error.message : 'Chart rendering failed.'; - this.#status('error', message); - this.#emit('scope-error', { message }); - } + }); + } + #renderError(error: unknown) { + this.#el('.inspect').disabled = true; + this.#clearInspection(); + this.#dispose(); + const message = error instanceof Error ? error.message : 'Chart rendering failed.'; + this.#status('error', message); + this.#emit('scope-error', { message }); } #buildLegend() { const legend = this.#el('.legend'); @@ -723,19 +837,22 @@ export class ScopeChartElement extends HTMLElement { ); } #clearInspection(reason: ViewStateChangeDetail['reason'] = 'interaction') { - this.#notifications.invalidate(); - this.#pendingPin = undefined; - this.#cursorTimeMs = null; - this.#readingNeighbors = []; - this.#selectedNeighbors = []; - this.#session.clearInspection(); - const batch = this.#notifications.capture(); - this.#plot?.focus(null); - this.#el('.inspect').removeAttribute('data-active'); - hideInspection(this.#el('.tooltip')); - this.#el('.live').textContent = ''; - this.#reportInspection(batch); - this.#stateChanged(reason); + interactionFor(this.#session).batch(() => { + this.#notifications.invalidate(); + this.#cursorTimeMs = null; + this.#readingNeighbors = []; + this.#selectedNeighbors = []; + this.#session.clearInspection(); + const batch = this.#notifications.capture(); + interactionFor(this.#session).effect(() => { + this.#plot?.focus(null); + this.#el('.inspect').removeAttribute('data-active'); + hideInspection(this.#el('.tooltip')); + this.#el('.live').textContent = ''; + }, this.#readingEffect); + this.#reportInspection(batch); + this.#stateChanged(reason); + }); } #inspect( value: ChartInteraction | null, @@ -745,31 +862,39 @@ export class ScopeChartElement extends HTMLElement { series: string | null = null, neighbors?: readonly ChartInteraction[] ) { - const previousKey = this.#session.active?.key; - if (!this.#session.inspect(value?.key ?? null, pin, keyboard, series)) return; - this.#readingNeighbors = keyboard ? [] : (neighbors?.map((target) => target.key) ?? []); - if (pin || keyboard) - this.#selectedNeighbors = this.#session.selected ? this.#readingNeighbors : []; - this.#cursorTimeMs = value - ? (this.#plot?.model?.interactionByKey.get(value.key)?.time?.start ?? null) - : null; - if (!value) position = null; - value = this.#session.active; - if (!value) { - this.#clearInspection(); - return; - } - const batch = this.#notifications.capture(); - if (pin) - inspectionFocus(this.#root).focus({ - preventScroll: true - }); - if (batch.current('inspect')) - this.#showInspection(position, keyboard, position !== null || previousKey !== value.key); - this.#reportInspection(batch, true); - this.#stateChanged(); + interactionFor(this.#session).batch(() => { + const previousKey = this.#session.active?.key; + if (!this.#session.inspect(value?.key ?? null, pin, keyboard, series)) return; + this.#readingNeighbors = keyboard ? [] : (neighbors?.map((target) => target.key) ?? []); + if (pin || keyboard) + this.#selectedNeighbors = this.#session.selected ? this.#readingNeighbors : []; + this.#cursorTimeMs = value + ? (this.#plot?.model?.interactionByKey.get(value.key)?.time?.start ?? null) + : null; + if (!value) position = null; + value = this.#session.active; + if (!value) { + this.#clearInspection(); + return; + } + const batch = this.#notifications.capture(); + if (pin) + inspectionFocus(this.#root).focus({ + preventScroll: true + }); + if (batch.current('inspect')) + this.#showInspection(position, keyboard, position !== null || previousKey !== value.key); + this.#reportInspection(batch, true); + this.#stateChanged(); + }); } #showInspection(position: Position | null, keyboard = false, place = true) { + const batch = this.#notifications.capture(); + interactionFor(this.#session).effect(() => { + if (batch.current('inspect')) this.#presentInspection(position, keyboard, place); + }, this.#readingEffect); + } + #presentInspection(position: Position | null, keyboard: boolean, place: boolean) { const value = this.#session.active; if (!value) return; this.#plot?.focus( diff --git a/src/elements/controls.ts b/src/elements/controls.ts index a08fc13..d94f7f1 100644 --- a/src/elements/controls.ts +++ b/src/elements/controls.ts @@ -1,3 +1,4 @@ +import type { Interaction } from '../core/interaction.js'; import type { ComponentOptions, ScopeChartAction } from '../families/component-options.js'; export type { ScopeChartAction } from '../families/component-options.js'; @@ -89,16 +90,24 @@ export function renderToolbar( export class ControlEvents { #previous = ''; + #key = {}; + constructor(private readonly interaction: Interaction) {} notify(host: HTMLElement, controls: ScopeChartControl[]) { - const current = JSON.stringify(controls); - if (current === this.#previous) return; - this.#previous = current; - host.dispatchEvent( - new CustomEvent('scope-controls-change', { - detail: { controls }, - bubbles: true, - composed: true - }) + this.interaction.effect( + () => { + const current = JSON.stringify(controls); + if (current === this.#previous) return; + this.#previous = current; + host.dispatchEvent( + new CustomEvent('scope-controls-change', { + detail: { controls }, + bubbles: true, + composed: true + }) + ); + }, + this.#key, + 1 ); } } diff --git a/src/elements/hierarchy-element.ts b/src/elements/hierarchy-element.ts index f50d039..61076c4 100644 --- a/src/elements/hierarchy-element.ts +++ b/src/elements/hierarchy-element.ts @@ -1,3 +1,5 @@ +import { ownInspectionDismissal } from '../runtime/inspection-input.js'; +import { captureScene, sceneSnapshot } from '../core/scene-snapshot.js'; import { object, boolean } from '../data/validation.js'; import type { ChartInteraction, ChartScene, Position } from '../core/scene.js'; import { inspectionText } from '../core/labels.js'; @@ -9,17 +11,17 @@ import { withInspectionContext, type HierarchyInspectionContext } from '../core/inspection-context.js'; -import { ViewportSession, type ViewportState } from '../core/viewport.js'; +import type { ViewportState } from '../core/viewport.js'; +import { ChartSession } from '../core/session.js'; +import { readingState, restoredKey, shareReading, releaseReading } from '../core/reading-state.js'; +import { interactionFor, shareInteraction } from '../core/interaction.js'; +import { HierarchyInteraction } from '../families/hierarchy/interaction.js'; import type { Entity, EntityDocument, Hierarchy } from '../families/hierarchy/model.js'; import { parseComponentOptions, type EntityComponentOptions } from '../families/component-options.js'; -import { - hierarchyViewportHeight, - layoutHierarchy, - type HierarchyView -} from '../families/hierarchy/layout.js'; +import { layoutHierarchy, type HierarchyView } from '../families/hierarchy/layout.js'; import { SCOPE_CHART_STYLES } from '../theme/styles.js'; import { ChartRuntime } from '../runtime/chart-runtime.js'; import { ViewportController } from '../runtime/viewport-controller.js'; @@ -102,15 +104,28 @@ export class HierarchyElement< Context extends HierarchyInspectionContext = HierarchyInspectionContext > extends HTMLElement { static observedAttributes = ['loading', 'error', 'theme', 'style', 'class']; - #compiled: Compiled | null = null; - #renderedCompiled: Compiled | null = null; + #session = new ChartSession(); + #interaction: HierarchyInteraction; + #kernel = interactionFor(this.#session); + get #compiled() { + return this.#interaction.compiled; + } + get #renderedCompiled() { + return sceneSnapshot(this.scene) as Compiled | null; + } #ui: EntityComponentOptions = {}; get #options() { return { ...this.#ui, interactive: this.#compiled?.document.options?.interactive }; } - #view: HierarchyView | null = null; - #viewport = new ViewportSession(); - #collapsed = new Set(); + get #view() { + return this.#interaction.view; + } + get #viewport() { + return this.#interaction.viewport; + } + get #collapsed() { + return this.#interaction.collapsed; + } #runtime: ChartRuntime | null = null; #input: ViewportController | null = null; #events: AbortController | null = null; @@ -121,38 +136,44 @@ export class HierarchyElement< #propertySet = false; #frame = 0; #page = 0; - #notifications = new InspectionNotifications(() => ({ - snapshot: this.#renderedCompiled, - inspect: { - target: this.#runtime?.session.active ?? null, - pinned: this.#runtime?.session.pinned ?? false - }, - select: { target: this.#runtime?.session.selected ?? null } - })); - #pendingPin: string | null | undefined; - #pendingViewport: ViewportState | null = null; - #pendingReveal: string | null = null; - #renderedViewport: (() => boolean) | null = null; - #viewportEvents = { revision: 0, notifying: false, pending: null as number | null }; + #notifications = new InspectionNotifications(() => { + const snapshot = this.#renderedCompiled; + const frame = this.scene; + const project = (target: ChartInteraction | null) => + target && snapshot !== this.#compiled + ? ((frame?.navigation ?? frame)?.interactionByKey.get(target.key) ?? null) + : target; + return { + snapshot, + inspect: { target: project(this.#session.active), pinned: this.#session.pinned }, + select: { target: project(this.#session.selected) } + }; + }, this.#kernel); + #viewportReports = this.#kernel.channel({ start: 0, end: 1, scrollOffset: 0 }, sameViewport); + #stateReports = this.#kernel.channel( + null, + (a, b) => JSON.stringify(a?.state) === JSON.stringify(b?.state) + ); + #collapseReports = this.#kernel.channel( + [], + (a, b) => JSON.stringify(a) === JSON.stringify(b) + ); + #readingEffect = {}; #stateReason: ViewStateChangeDetail['reason'] = 'reconcile'; - #lastState = ''; #stateTransaction = false; #linkedHighlight: LinkedHighlight | null = null; - #controlEvents = new ControlEvents(); - #canvasEscapes = new WeakSet(); + #controlEvents = new ControlEvents(interactionFor(this.#session)); constructor( readonly kind: Context['kind'], private compile: (input: unknown) => Compiled, - private createView: ( - compiled: Compiled, - collapsed: ReadonlySet - ) => HierarchyView, + createView: (compiled: Compiled, collapsed: ReadonlySet) => HierarchyView, private inspectionContext: ( compiled: Compiled, target: ChartInteraction | null ) => Context | null ) { super(); + this.#interaction = new HierarchyInteraction(this.#session, createView); this.#root = this.attachShadow({ mode: 'open' }); const css = node('style'); css.textContent = @@ -192,71 +213,60 @@ export class HierarchyElement< } /** Patch host UI while keeping the viewport, collapsed nodes and selection. */ configure(options: EntityComponentOptions) { - this.#ui = { ...this.#ui, ...parseComponentOptions(options, 'hierarchy') }; - this.#schedule(); + this.#kernel.batch(() => { + this.#ui = { ...this.#ui, ...parseComponentOptions(options, 'hierarchy') }; + this.#schedule(); + }); } updateDocument(value: unknown, options: DocumentUpdateOptions = {}) { - object(options, 'options', ['preserveViewState']); - if (options.preserveViewState !== undefined) - boolean(options.preserveViewState, 'options.preserveViewState'); - this.#viewportEvents.revision++; - const previous = options.preserveViewState ? this.viewState : null; - this.#stateTransaction = true; - this.#propertySet = true; - this.#input?.cancel(); - this.#clearInspection(); - this.#collapsed.clear(); - this.#viewport.reset(); - this.#pendingViewport = null; - this.#linkedHighlight = null; - if (this.#runtime) this.#runtime.linkedHighlight = null; - this.#page = 0; - try { - this.#compiled = this.compile(value); - this.#error = null; - } catch (error) { - this.#compiled = null; - this.#error = error instanceof Error ? error.message : 'Invalid document.'; - } - if (previous && this.#compiled) this.#applyState(previous); - this.#stateReason = 'document'; - this.#rebuild(); - this.#stateTransaction = false; - this.#schedule(); + this.#kernel.batch(() => { + object(options, 'options', ['preserveViewState']); + if (options.preserveViewState !== undefined) + boolean(options.preserveViewState, 'options.preserveViewState'); + const previous = options.preserveViewState ? this.viewState : null; + this.#stateTransaction = true; + this.#propertySet = true; + this.#kernel.effect(() => this.#input?.cancel()); + this.#clearInspection(); + this.#linkedHighlight = null; + if (this.#runtime) this.#runtime.linkedHighlight = null; + this.#page = 0; + try { + this.#interaction.replace(this.compile(value)); + this.#error = null; + } catch (error) { + this.#interaction.replace(null); + this.#error = error instanceof Error ? error.message : 'Invalid document.'; + } + if (previous && this.#compiled) this.#applyState(previous); + this.#stateReason = 'document'; + this.#stateTransaction = false; + this.#schedule(); + }); } get viewState(): HierarchyViewState { return { version: 1, kind: this.kind, - pinnedKey: - this.#pendingPin !== undefined - ? this.#pendingPin - : this.#runtime?.session.pinned - ? (this.#runtime.session.active?.key ?? null) - : null, - viewport: { ...(this.#pendingViewport ?? this.viewport) }, + pinnedKey: restoredKey(this.#session) ?? this.#session.selected?.key ?? null, + viewport: this.#interaction.desiredViewport, collapsed: this.collapsed.sort() }; } set viewState(value: HierarchyViewState) { - const state = parseViewState(value); - if (state.kind === 'metric' || state.kind !== this.kind) - throw new TypeError(`Expected ${this.kind} view state.`); - this.#notifications.invalidate(); - this.#input?.cancel(); - this.#applyState(state); - this.#stateReason = 'restore'; - this.#rebuild(); - this.refresh(); + this.#kernel.batch(() => { + const state = parseViewState(value); + if (state.kind === 'metric' || state.kind !== this.kind) + throw new TypeError(`Expected ${this.kind} view state.`); + this.#notifications.invalidate(); + this.#kernel.effect(() => this.#input?.cancel()); + this.#applyState(state); + this.#stateReason = 'restore'; + this.refresh(); + }); } #applyState(state: HierarchyViewState) { - this.#viewportEvents.revision++; - this.#pendingReveal = null; - this.#collapsed = new Set( - state.collapsed.filter((id) => this.#compiled?.hierarchy.children.get(id)?.length) - ); - this.#pendingPin = state.pinnedKey; - this.#pendingViewport = { ...state.viewport }; + this.#interaction.restore(state); } get linkedHighlight() { return this.#linkedHighlight && { ...this.#linkedHighlight }; @@ -267,11 +277,19 @@ export class HierarchyElement< } #stateChanged(reason: ViewStateChangeDetail['reason'] = 'interaction') { if (this.#stateTransaction) return; - const state = this.viewState, - key = JSON.stringify(state); - if (key === this.#lastState) return; - this.#lastState = key; - this.#emit('scope-view-state-change', { state, reason } satisfies ViewStateChangeDetail); + const state = this.viewState; + const snapshot = this.#compiled; + const key = JSON.stringify(state); + this.#kernel.report( + this.#stateReports, + { state, reason }, + () => snapshot === this.#compiled && key === JSON.stringify(this.viewState), + (detail) => { + this.#dispatch('scope-view-state-change', detail!); + }, + false, + 4 + ); } get loading() { return this.hasAttribute('loading'); @@ -331,23 +349,22 @@ export class HierarchyElement< return this.#viewport.state; } set viewport(value: ViewportState) { - this.#input?.cancel(); - this.#viewport.set(value); - this.#pendingViewport = null; - this.#changedViewport(); + this.#kernel.batch(() => { + this.#kernel.effect(() => this.#input?.cancel()); + this.#viewport.set(value); + this.#changedViewport(); + }); } get collapsed(): string[] { return [...this.#collapsed]; } set collapsed(value: Iterable) { - const values = new Set(value); - for (const id of values) - if (!this.#compiled?.hierarchy.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); - this.#collapsed = values; - this.#input?.cancel(); - this.#rebuild(); - this.refresh(); - this.#emit('scope-collapse-change', { collapsed: this.collapsed }); + this.#kernel.batch(() => { + this.#interaction.collapse(value); + this.#kernel.effect(() => this.#input?.cancel()); + this.refresh(); + this.#emit('scope-collapse-change', { collapsed: this.collapsed }); + }); } /** Current immutable scene; painted interactions are culled, navigation retains the visible tree. */ get scene(): ChartScene | null { @@ -366,69 +383,61 @@ export class HierarchyElement< focusNode(id: string, options: { pin?: boolean } = {}) { object(options, 'options', ['pin']); if (options.pin !== undefined) boolean(options.pin, 'options.pin'); - this.#input?.cancel(); - const tree = this.#compiled?.hierarchy; - if (!tree?.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); - const key = this.#view?.byId.get(id)?.interaction.key; - const model = this.scene; - if (key && (model?.navigation ?? model)?.interactionByKey.has(key)) { - this.#pendingPin = undefined; - this.#pendingReveal = null; - } - let parent = tree.byId.get(id)?.parentId, - expanded = false; - while (parent && tree.byId.has(parent)) { - expanded = this.#collapsed.delete(parent) || expanded; - parent = tree.byId.get(parent)?.parentId; - } - if (expanded) { - this.#rebuild(); - this.#emit('scope-collapse-change', { collapsed: this.collapsed }); - } - this.#runtime?.paint(); - const target = this.#view?.byId.get(id)?.interaction; - if (!target || !this.#runtime || this.#view?.document.options?.interactive === false) return; - this.#reveal(target); - this.#runtime.session.inspect(target.key, options.pin ?? false, true); - this.#runtime.focus(this.#runtime.session.active); - this.#showInspection(null, true); - this.#el('.canvas').focus({ preventScroll: true }); + this.#kernel.batch(() => { + this.#kernel.effect(() => this.#input?.cancel()); + const collapsed = this.collapsed.join('\0'); + if (!this.#interaction.focus(id, options.pin ?? false)) return; + const intent = readingState(this.#session).intent; + if (collapsed !== this.collapsed.join('\0')) + this.#emit('scope-collapse-change', { collapsed: this.collapsed }); + this.#kernel.effect(() => { + if (intent !== readingState(this.#session).intent || !this.#runtime) return; + this.#runtime.paint(); + if (intent !== readingState(this.#session).intent || !this.#runtime) return; + this.#showInspection(null, true); + this.#el('.canvas').focus({ preventScroll: true }); + this.#publishViewport(); + }); + }); } resetViewport() { - this.#input?.cancel(); - this.#viewport.reset(); - this.#pendingViewport = null; - this.#changedViewport(); + this.#kernel.batch(() => { + this.#kernel.effect(() => this.#input?.cancel()); + this.#viewport.reset(); + this.#changedViewport(); + }); } get controls() { return visibleControls(this.#root, hierarchyControls, this.#options); } performAction(action: ScopeChartAction): boolean { - if ( - !readControls(this.#root, hierarchyControls, this.#options).some( - (control) => control.action === action && !control.disabled + return this.#kernel.batch(() => { + if ( + !readControls(this.#root, hierarchyControls, this.#options).some( + (control) => control.action === action && !control.disabled + ) ) - ) - return false; - if (action === 'inspect') { - const id = this.#view?.items[0]?.id; - if (id) this.focusNode(id); - } else if (action === 'zoom-in' || action === 'zoom-out') { - this.#viewport.zoom(action === 'zoom-in' ? 2 : 0.5); - this.#changedViewport(); - } else if (action === 'reset-view') this.resetViewport(); - else if (action === 'toggle-collapse') { - const id = this.#runtime?.session.active?.sources[0]?.id; - if (id) this.toggleNode(id); - } else if (action === 'toggle-data') { - const raw = this.#el('.raw'); - raw.hidden = !raw.hidden; - this.#el('.data').setAttribute('aria-expanded', String(!raw.hidden)); - this.#el('.data').textContent = raw.hidden ? 'View data' : 'Hide data'; - this.#table(); - } - this.#notifyControls(); - return true; + return false; + if (action === 'inspect') { + const id = this.#view?.items[0]?.id; + if (id) this.focusNode(id); + } else if (action === 'zoom-in' || action === 'zoom-out') { + this.#viewport.zoom(action === 'zoom-in' ? 2 : 0.5); + this.#changedViewport(); + } else if (action === 'reset-view') this.resetViewport(); + else if (action === 'toggle-collapse') { + const id = this.#session.active?.sources[0]?.id; + if (id) this.toggleNode(id); + } else if (action === 'toggle-data') { + const raw = this.#el('.raw'); + raw.hidden = !raw.hidden; + this.#el('.data').setAttribute('aria-expanded', String(!raw.hidden)); + this.#el('.data').textContent = raw.hidden ? 'View data' : 'Hide data'; + this.#table(); + } + this.#notifyControls(); + return true; + }); } #notifyControls() { this.#controlEvents.notify(this, this.controls); @@ -437,15 +446,40 @@ export class HierarchyElement< if (this.#runtime) this.#runtime.refresh(); else this.#schedule(); } - #rebuild() { - this.#view = this.#compiled ? this.createView(this.#compiled, this.#collapsed) : null; - } #emit>( type: K, detail: HierarchyEventDetails[K] ) { + const snapshot = this.#compiled; + const scene = this.scene; this.#notifyControls(); - this.#dispatch(type, detail); + if (type === 'scope-collapse-change') { + const value = (detail as HierarchyCollapseChangeDetail).collapsed; + this.#kernel.report( + this.#collapseReports, + value, + () => + snapshot === this.#compiled && JSON.stringify(value) === JSON.stringify(this.collapsed), + (collapsed) => this.#dispatch('scope-collapse-change', { collapsed: [...collapsed] }) + ); + return; + } + this.#kernel.effect( + () => { + if ( + snapshot === this.#compiled && + (type !== 'scope-ready' || + (this.isConnected && + !this.loading && + !this.error && + !this.#error && + scene === this.scene)) + ) + this.#dispatch(type, detail); + }, + {}, + type === 'scope-ready' ? 5 : 2 + ); } #dispatch>( type: K, @@ -454,41 +488,31 @@ export class HierarchyElement< this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })); } #changedViewport() { - this.#pendingReveal = null; - const notify = this.#captureViewport(); - if ( - !this.#runtime?.session.pinned && - !this.#runtime?.session.keyboardInspection && - !this.#pendingPin - ) - this.#clearInspection(); - this.#runtime?.refresh(); - if (notify()) this.#stateChanged(); - } - #captureViewport() { - const events = this.#viewportEvents; - const revision = ++events.revision; - return () => { - if (revision !== events.revision) return false; - events.pending = revision; - if (events.notifying) return true; - events.notifying = true; - try { - while (events.pending !== null) { - const current = events.pending; - events.pending = null; - if (current !== events.revision) continue; - this.#notifyControls(); - // Host callbacks may replace the document, restore state or move the viewport again. - if (current === events.revision) - this.#dispatch('scope-viewport-change', { ...this.viewport }); - } - return revision === events.revision; - } finally { - events.notifying = false; - events.pending = null; - } - }; + this.#kernel.batch(() => { + if (!this.#session.pinned && !this.#session.keyboardInspection && !restoredKey(this.#session)) + this.#clearInspection(); + this.#runtime?.refresh(); + this.#publishViewport(); + this.#stateChanged(); + }); + } + #publishViewport() { + const snapshot = this.#compiled; + const report = this.#interaction.report; + if (!report) return; + const value = { ...this.viewport }; + this.#notifyControls(); + this.#kernel.report( + this.#viewportReports, + value, + () => + snapshot === this.#compiled && + report === this.#interaction.report && + sameViewport(value, this.viewport), + (viewport) => this.#dispatch('scope-viewport-change', { ...viewport }), + false, + 3 + ); } #schedule() { if (!this.isConnected || this.#frame) return; @@ -503,8 +527,7 @@ export class HierarchyElement< try { this.document = JSON.parse(json.textContent ?? ''); } catch { - this.#compiled = null; - this.#view = null; + this.#interaction.replace(null); this.#error = 'Invalid chart JSON.'; this.#schedule(); } @@ -550,35 +573,33 @@ export class HierarchyElement< { signal } ); const canvas = this.#el('.canvas'); + ownInspectionDismissal(canvas, signal); canvas.addEventListener( 'keydown', (event) => { - this.#canvasEscapes.delete(event); if ( - event.key === 'Escape' && - this.#runtime && - this.scene?.interactive !== false && - !event.metaKey && - !event.ctrlKey && - !event.altKey - ) { - // Cancel once before runtime callbacks, retaining any newer restore they request. - this.#canvasEscapes.add(event); - this.#pendingPin = undefined; - } + event.defaultPrevented || + event.key !== 'Escape' || + event.metaKey || + event.ctrlKey || + event.altKey || + this.scene?.interactive === false + ) + return; + event.preventDefault(); + this.#kernel.batch(() => this.#clearInspection('interaction', true)); }, - { capture: true, signal } + { signal } ); this.#root.addEventListener( 'keydown', (event) => { if ( (event as KeyboardEvent).key !== 'Escape' || - (this.#canvasEscapes.delete(event) && event.composedPath().includes(canvas)) || - (!this.#runtime?.session.active && this.#pendingPin === undefined) + event.composedPath().includes(canvas) || + (!this.#session.active && restoredKey(this.#session) === undefined) ) return; - // Toolbar widgets can consume Escape before it reaches the chart shell. event.preventDefault(); this.#clearInspection(); canvas.focus({ preventScroll: true }); @@ -599,8 +620,7 @@ export class HierarchyElement< event.composedPath().includes(this.#el('.tree-scroll')) ) return; - if (this.#runtime?.session.pinned || this.#runtime?.session.keyboardInspection) - this.#placeTooltip(); + if (this.#session.pinned || this.#session.keyboardInspection) this.#placeTooltip(); else this.#clearInspection(); }, { capture: true, signal } @@ -613,15 +633,16 @@ export class HierarchyElement< this.#schedule(); } disconnectedCallback() { - this.#viewportEvents.revision++; - cancelAnimationFrame(this.#frame); - this.#frame = 0; - this.#mutation?.disconnect(); - this.#mutation = null; - this.#events?.abort(); - this.#events = null; - this.#clearInspection(); - this.#dispose(); + this.#kernel.batch(() => { + cancelAnimationFrame(this.#frame); + this.#frame = 0; + this.#mutation?.disconnect(); + this.#mutation = null; + this.#events?.abort(); + this.#events = null; + this.#clearInspection(); + this.#dispose(); + }); } attributeChangedCallback(name: string) { if (!this.isConnected) return; @@ -629,12 +650,16 @@ export class HierarchyElement< else this.refresh(); } #dispose() { - this.#renderedViewport = null; - this.#runtime?.dispose(); + const runtime = this.#runtime, + input = this.#input; this.#runtime = null; - this.#renderedCompiled = null; - this.#input?.dispose(); this.#input = null; + if (runtime) releaseReading(runtime.session); + this.#interaction.deactivate(); + this.#kernel.effect(() => { + runtime?.dispose(); + input?.dispose(); + }); } #status(state: string, message: string | null) { this.dataset.state = state; @@ -645,108 +670,108 @@ export class HierarchyElement< this.#notifyControls(); } #render() { - const compiled = this.#compiled, - view = this.#view; - const title = compiled?.document.title ?? '', - description = compiled?.document.description ?? ''; - this.#el('header').hidden = this.#ui.header === false || (!title && !description); - this.#el('h2').textContent = title; - this.#el('.description').textContent = description; - this.#el('.description').hidden = !description; - renderToolbar(this.#root, this.#options, hierarchyControls); - this.#el('.viewport').style.setProperty( - '--scope-chart-content-height', - `${(view?.rowCount ?? 0) * (view?.rowHeight ?? 24) + 64}px` - ); - const message = this.error ?? this.#error; - const blocked = this.loading || message !== null || !compiled || !view?.items.length; - for (const button of this.#root.querySelectorAll('.toolbar button')) - button.disabled = blocked; - this.#table(); - if (blocked) { - const saved = this.loading && compiled && message === null ? this.viewState : null; - if (saved) this.#stateTransaction = true; - this.#clearInspection(); - this.#dispose(); - if (saved) this.#applyState(saved); - this.#stateTransaction = false; - this.#el('.tree-scroll').hidden = true; - this.#el('.summary').hidden = true; - const state = - message !== null ? 'error' : this.loading ? 'loading' : compiled ? 'empty' : 'idle'; - this.#status( - state, - message !== null - ? message - : this.loading - ? 'Loading chart…' - : compiled - ? 'No nodes returned.' - : `Provide a ${this.kind} document.` + this.#kernel.batch(() => { + const compiled = this.#compiled, + view = this.#view; + const title = compiled?.document.title ?? '', + description = compiled?.document.description ?? ''; + this.#el('header').hidden = this.#ui.header === false || (!title && !description); + this.#el('h2').textContent = title; + this.#el('.description').textContent = description; + this.#el('.description').hidden = !description; + renderToolbar(this.#root, this.#options, hierarchyControls); + this.#el('.viewport').style.setProperty( + '--scope-chart-content-height', + `${(view?.rowCount ?? 0) * (view?.rowHeight ?? 24) + 64}px` ); - this.#stateChanged(this.#stateReason); - if (message !== null) this.#emit('scope-error', { message }); - return; - } - if (!this.#runtime) { - const canvas = this.#el('.canvas'); - this.#input = new ViewportController(canvas, this.#viewport, { - rect: () => this.scene?.rect, - enabled: () => this.#view?.document.options?.interactive !== false, - onChange: () => this.#changedViewport() - }); - this.#runtime = new ChartRuntime(canvas, this.#el('.decoration'), { - layout: (width, height, measure) => { - const current = this.#view!; - const before = this.viewport; - const reveal = this.#pendingReveal; - this.#pendingReveal = null; - this.#renderedViewport = null; - this.#prepareViewport(height); - if (reveal && this.#runtime?.session.active?.sources[0]?.id === reveal) { - this.#positionNode(reveal); + const message = this.error ?? this.#error; + const blocked = this.loading || message !== null || !compiled || !view?.items.length; + for (const button of this.#root.querySelectorAll('.toolbar button')) + button.disabled = blocked; + this.#table(); + if (blocked) { + const saved = this.loading && compiled && message === null ? this.viewState : null; + if (saved) this.#stateTransaction = true; + this.#clearInspection(); + this.#dispose(); + if (saved) this.#applyState(saved); + this.#stateTransaction = false; + this.#el('.tree-scroll').hidden = true; + this.#el('.summary').hidden = true; + const state = + message !== null ? 'error' : this.loading ? 'loading' : compiled ? 'empty' : 'idle'; + this.#status( + state, + message !== null + ? message + : this.loading + ? 'Loading chart…' + : compiled + ? 'No nodes returned.' + : `Provide a ${this.kind} document.` + ); + this.#stateChanged(this.#stateReason); + if (message !== null) this.#emit('scope-error', { message }); + return; + } + if (!this.#runtime) { + const canvas = this.#el('.canvas'); + shareInteraction(canvas, this.#kernel); + this.#input = new ViewportController(canvas, this.#viewport, { + rect: () => this.scene?.rect, + enabled: () => this.#view?.document.options?.interactive !== false, + onChange: () => this.#changedViewport() + }); + const runtime = new ChartRuntime(canvas, this.#el('.decoration'), { + layout: (width, height, measure) => { + const current = this.#view!; + if (this.#runtime === runtime) + this.#interaction.measure(height, this.#ui.axes ?? 'full'); + const scene = layoutHierarchy( + current, + width, + height, + measure, + this.viewport, + this.#ui.axes ?? 'full' + ); + return captureScene(scene, this.#compiled!); + }, + onNavigate: (target) => { + if (this.#runtime !== runtime) return; + const before = this.viewport; + this.#interaction.navigate(target); if (!sameViewport(before, this.viewport)) - this.#renderedViewport = this.#captureViewport(); + this.#kernel.effect(() => { + this.#runtime?.paint(); + this.#publishViewport(); + }); + }, + inputConsumed: () => this.#input?.consumed ?? false, + onInspect: (_value, position) => { + if (this.#runtime === runtime) this.#showInspection(position, true); + }, + onError: (error) => { + if (this.#runtime !== runtime) return; + this.#error = error instanceof Error ? error.message : 'Chart rendering failed.'; + this.#schedule(); + }, + onRender: () => { + if (this.#runtime === runtime) this.#rendered(); } - const scene = layoutHierarchy( - current, - width, - height, - measure, - this.viewport, - this.#ui.axes ?? 'full' - ); - this.#renderedCompiled = this.#compiled; - return scene; - }, - onNavigate: (target) => { - // A committed keyboard action supersedes restoration before reveal can paint. - this.#pendingPin = undefined; - this.#reveal(target); - }, - inputConsumed: () => this.#input?.consumed ?? false, - onInspect: (_value, position) => this.#showInspection(position, true), - onError: (error) => { - this.#error = error instanceof Error ? error.message : 'Chart rendering failed.'; - this.#schedule(); - }, - onRender: () => this.#rendered() - }); - this.#runtime.linkedHighlight = this.#linkedHighlight; - } else this.#runtime.refresh(); + }); + this.#runtime = runtime; + shareReading(runtime.session, this.#session); + this.#interaction.activate(); + this.#runtime.linkedHighlight = this.#linkedHighlight; + } else this.#runtime.refresh(); + }); } #rendered() { - const notify = this.#renderedViewport; - this.#renderedViewport = null; const scene = this.scene, view = this.#view, compiled = this.#renderedCompiled; if (!scene || !view || !compiled) return; - if (this.#pendingPin !== undefined) { - this.#runtime!.session.restorePin(scene.interactive === false ? null : this.#pendingPin); - this.#pendingPin = undefined; - this.#runtime!.focus(this.#runtime!.session.active); - } const rail = this.#el('.tree-scroll'); rail.hidden = scene.interactive === false || view.rowCount * view.rowHeight <= scene.rect!.height; @@ -767,7 +792,7 @@ export class HierarchyElement< if (compiled !== this.#compiled || !this.isConnected) return; this.#showInspection(null, false, this.#stateReason); if (compiled !== this.#compiled || !this.isConnected) return; - notify?.(); + this.#publishViewport(); if (compiled !== this.#compiled || !this.isConnected) return; this.#stateChanged(this.#stateReason); this.#stateReason = 'reconcile'; @@ -776,45 +801,6 @@ export class HierarchyElement< nodes: compiled.hierarchy.sources.length }); } - #prepareViewport(height: number) { - const view = this.#view!; - this.#viewport.configure({ - contentHeight: view.rowCount * view.rowHeight, - visibleHeight: hierarchyViewportHeight(height, this.#ui.axes ?? 'full') - }); - if (this.#pendingViewport) { - this.#viewport.set(this.#pendingViewport); - this.#pendingViewport = null; - } - } - #positionNode(id: string) { - const item = this.#view?.byId.get(id); - if (!item) return; - this.#viewport.reveal( - item.start, - item.end, - item.row * this.#view!.rowHeight, - (item.row + 1) * this.#view!.rowHeight - ); - } - #reveal(target: ChartInteraction) { - const id = target.sources[0]?.id; - if (!id || !this.#view?.byId.has(id)) return; - const bounds = this.#el('.canvas').getBoundingClientRect(); - const hidden = bounds.width <= 0 || bounds.height <= 0; - this.#pendingReveal = hidden ? id : null; - // Hidden hosts retain the last valid measurement and replay positioning when visible. - const height = bounds.height > 0 ? bounds.height : this.scene?.height; - if (!height) return; - const before = this.viewport; - this.#prepareViewport(height); - this.#positionNode(id); - if (!sameViewport(before, this.viewport)) { - const notify = this.#captureViewport(); - this.#runtime?.paint(); - if (notify()) this.#stateChanged(); - } - } #detail( reading: InspectionReading, compiled: Compiled | null @@ -831,13 +817,12 @@ export class HierarchyElement< () => (compiled ? project(compiled, active) : null) ); } - #clearInspection() { - this.#notifications.invalidate(); - this.#pendingPin = undefined; - this.#pendingReveal = null; - this.#runtime?.session.clearInspection(); - this.#runtime?.focus(null); - this.#showInspection(null, false); + #clearInspection(reason: ViewStateChangeDetail['reason'] = 'interaction', input = false) { + this.#kernel.batch(() => { + this.#notifications.invalidate(); + this.#interaction.dismiss(); + this.#showInspection(null, input, reason); + }); } #showInspection( position: Position | null, @@ -845,6 +830,28 @@ export class HierarchyElement< reason: ViewStateChangeDetail['reason'] = 'interaction' ) { const batch = this.#notifications.capture(); + const silent = this.#stateTransaction; + this.#kernel.effect( + () => this.#presentInspection(batch, position), + this.#readingEffect, + 0, + () => batch.current('inspect') + ); + if (!silent && (!this.#session.active || batch.state.inspect.target)) { + for (const channel of ['inspect', 'select'] as const) + batch.emit( + channel, + (value, snapshot) => this.#dispatch(`scope-${channel}`, this.#detail(value, snapshot)), + channel === 'inspect' && emit + ); + } + this.#stateChanged(reason); + } + #presentInspection( + batch: ReturnType['capture']>, + position: Position | null + ) { + this.#runtime?.focus(this.#session.active); const { target: active, pinned } = batch.state.inspect; const tooltip = this.#el('.tooltip'); this.#el('.inspect').toggleAttribute('data-active', pinned); @@ -875,22 +882,13 @@ export class HierarchyElement< if (this.#el('.live').textContent !== reading) this.#el('.live').textContent = reading; } } - if (!this.#stateTransaction) { - for (const channel of ['inspect', 'select'] as const) - batch.emit( - channel, - (value, snapshot) => this.#dispatch(`scope-${channel}`, this.#detail(value, snapshot)), - { force: channel === 'inspect' && emit, before: () => this.#notifyControls() } - ); - } - this.#stateChanged(reason); } #placeTooltip(position: Position | null = null) { - const active = this.#runtime?.session.active; + const active = this.#session.active; if (!active || this.#el('.tooltip').hidden) return; const tooltip = this.#el('.tooltip'); // Persistent details belong to this component's flow, never guessed free viewport space. - if (this.#runtime?.session.pinned) return; + if (this.#session.pinned) return; const bounds = this.#el('.canvas').getBoundingClientRect(); const anchor = position ?? this.#runtime?.anchor(active) ?? { x: bounds.width / 2, y: 24 }; positionInspection(tooltip, { x: bounds.left + anchor.x, y: bounds.top + anchor.y }); diff --git a/src/elements/plot.ts b/src/elements/plot.ts index 86199f1..b8bcc73 100644 --- a/src/elements/plot.ts +++ b/src/elements/plot.ts @@ -1,3 +1,4 @@ +import { captureScene } from '../core/scene-snapshot.js'; import type { CompiledChart } from '../families/metrics/document.js'; import { layoutChart } from '../families/metrics/layout.js'; import type { ChartRenderer } from './plot-contract.js'; @@ -24,7 +25,8 @@ export class Plot extends ChartRuntime implements ChartRenderer { ? 'paint' : 'layout'; this.paint( - (width, height, measure) => layoutChart(compiled, hidden, width, height, measure, axes), + (width, height, measure) => + captureScene(layoutChart(compiled, hidden, width, height, measure, axes), compiled), reason ); this.compiled = compiled; diff --git a/src/families/hierarchy/interaction.ts b/src/families/hierarchy/interaction.ts new file mode 100644 index 0000000..6585404 --- /dev/null +++ b/src/families/hierarchy/interaction.ts @@ -0,0 +1,272 @@ +import { readingState, restoreReading } from '../../core/reading-state.js'; +import type { ChartSession } from '../../core/session.js'; +import type { ChartInteraction, AxisMode } from '../../core/scene.js'; +import { ViewportSession, type ViewportState, type ViewportBounds } from '../../core/viewport.js'; +import type { HierarchyViewState } from '../../core/view-state.js'; +import { hierarchyViewportHeight, type HierarchyView } from './layout.js'; +import type { Entity, EntityDocument, Hierarchy } from './model.js'; + +interface Compiled { + document: EntityDocument; + hierarchy: Hierarchy; +} +interface Focus { + id: string; + snapshot: object; + intent: object; +} + +interface State { + compiled: Document | null; + view: HierarchyView | null; + collapsed: Set; + viewport: ViewportState; + restore: ViewportState | null; + focus: Focus | null; + height: number | null; + axes: AxisMode; + report: object | null; + active: boolean; +} + +/** Family semantic state belongs to the same owner as its public session facade. */ +export class HierarchyInteraction> { + readonly viewport: ViewportSession; + #state: State; + constructor( + private readonly session: ChartSession, + private readonly createView: ( + document: Document, + collapsed: ReadonlySet + ) => HierarchyView + ) { + this.#state = { + compiled: null as Document | null, + view: null as HierarchyView | null, + collapsed: new Set(), + viewport: { start: 0, end: 1, scrollOffset: 0 } as ViewportState, + restore: null as ViewportState | null, + focus: null as Focus | null, + height: null as number | null, + axes: 'full' as AxisMode, + report: null as object | null, + active: false + }; + this.viewport = new OwnedViewport( + () => ({ ...this.#state.viewport }), + (change) => + this.#reduce((state) => ({ + ...state, + report: {}, + focus: null, + restore: null, + viewport: this.#geometry(state, change) + })) + ); + } + #reduce(reduce: (state: State) => State) { + this.#state = reduce(this.#state); + } + get compiled() { + return this.#state.compiled; + } + get view() { + return this.#state.view; + } + get collapsed(): ReadonlySet { + return this.#state.collapsed; + } + get desiredViewport() { + const state = this.#state; + return { ...(state.restore ?? state.viewport) }; + } + get report() { + return this.#state.report; + } + get active() { + return this.#state.active; + } + #geometry( + state: { + view: HierarchyView | null; + viewport: ViewportState; + restore: ViewportState | null; + height: number | null; + axes: AxisMode; + }, + change?: (viewport: ViewportSession) => void + ) { + const viewport = new ViewportSession(); + viewport.configure({ + contentHeight: (state.view?.rowCount ?? 0) * (state.view?.rowHeight ?? 24), + visibleHeight: state.height === null ? 0 : hierarchyViewportHeight(state.height, state.axes) + }); + viewport.set(state.restore ?? state.viewport); + change?.(viewport); + return { ...viewport.state }; + } + #adopt() { + if (this.active) this.session.setModel(this.view?.navigation ?? null); + } + replace(compiled: Document | null) { + this.session.clearInspection(); + this.#reduce((state) => ({ + ...state, + compiled, + view: compiled ? this.createView(compiled, new Set()) : null, + collapsed: new Set(), + viewport: { start: 0, end: 1, scrollOffset: 0 }, + restore: null, + focus: null, + report: null + })); + this.#adopt(); + } + collapse(collapsed: Iterable) { + const values = new Set(collapsed); + for (const id of values) + if (!this.compiled?.hierarchy.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); + this.#reduce((state) => ({ + ...state, + collapsed: values, + focus: null, + view: state.compiled ? this.createView(state.compiled, values) : null + })); + this.#adopt(); + } + restore(state: HierarchyViewState) { + const collapsed = new Set( + state.collapsed.filter((id) => this.compiled?.hierarchy.children.get(id)?.length) + ); + this.#reduce((previous) => ({ + ...previous, + collapsed, + view: previous.compiled ? this.createView(previous.compiled, collapsed) : null, + viewport: { ...state.viewport }, + restore: { ...state.viewport }, + focus: null, + report: null + })); + this.#adopt(); + restoreReading(this.session, state.pinnedKey); + } + activate() { + this.#reduce((state) => ({ ...state, active: true })); + this.#adopt(); + } + deactivate() { + this.#reduce((state) => ({ ...state, active: false, focus: null })); + this.session.setModel(null); + } + focus(id: string, pin: boolean) { + const compiled = this.compiled; + if (!compiled?.hierarchy.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); + if (!this.active || compiled.document.options?.interactive === false) return false; + const collapsed = new Set(this.collapsed); + let parent = compiled.hierarchy.byId.get(id)?.parentId; + while (parent && compiled.hierarchy.byId.has(parent)) { + collapsed.delete(parent); + parent = compiled.hierarchy.byId.get(parent)?.parentId; + } + this.#reduce((state) => ({ + ...state, + collapsed, + view: this.createView(compiled, collapsed) + })); + this.#adopt(); + this.session.inspect(this.view!.byId.get(id)!.interaction.key, pin, true); + this.navigate(this.session.active); + return true; + } + navigate(target: ChartInteraction | null) { + const id = target?.sources[0]?.id; + const focus = + id && this.view?.byId.has(id) + ? { + id, + snapshot: this.compiled!, + intent: readingState(this.session).intent + } + : null; + this.#reduce((state) => ({ ...state, focus, restore: null })); + // The previous valid measurement can position a hidden chart provisionally. + this.#position(false); + } + measure(height: number, axes: AxisMode) { + if (!Number.isFinite(height) || height <= 0) return; + this.#reduce((state) => ({ ...state, height, axes })); + this.#position(true); + } + #position(measured: boolean) { + this.#reduce((state) => { + const focus = state.focus; + const current = + !!focus && + focus.snapshot === state.compiled && + focus.intent === readingState(this.session).intent; + const item = current ? state.view?.byId.get(focus.id) : null; + if (state.height === null) return state; + const viewport = this.#geometry( + state, + item + ? (viewport) => + viewport.reveal( + item.start, + item.end, + item.row * state.view!.rowHeight, + (item.row + 1) * state.view!.rowHeight + ) + : undefined + ); + const changed = + viewport.start !== state.viewport.start || + viewport.end !== state.viewport.end || + viewport.scrollOffset !== state.viewport.scrollOffset; + return { + ...state, + viewport, + report: item && changed ? {} : state.report, + restore: null, + focus: measured || !current ? null : focus + }; + }); + } + dismiss() { + this.#reduce((state) => ({ ...state, focus: null })); + this.session.clearInspection(); + } +} + +/** Existing viewport controllers retain their API while all writes enter the owner. */ +class OwnedViewport extends ViewportSession { + constructor( + private readonly read: () => ViewportState, + private readonly change: (change: (viewport: ViewportSession) => void) => void + ) { + super(); + } + override get state() { + return this.read(); + } + override configure(bounds: ViewportBounds) { + this.change((viewport) => viewport.configure(bounds)); + } + override set(value: ViewportState) { + this.change((viewport) => viewport.set(value)); + } + override zoom(factor: number, anchor = 0.5) { + this.change((viewport) => viewport.zoom(factor, anchor)); + } + override pan(fraction: number) { + this.change((viewport) => viewport.pan(fraction)); + } + override scroll(pixels: number) { + this.change((viewport) => viewport.scroll(pixels)); + } + override reveal(start: number, end: number, top: number, bottom: number) { + this.change((viewport) => viewport.reveal(start, end, top, bottom)); + } + override reset() { + this.change((viewport) => viewport.reset()); + } +} diff --git a/src/headless.ts b/src/headless.ts index 1fbe174..2cffed6 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -23,7 +23,6 @@ export function createVisualization( ): ChartView { const kind = document && typeof document === 'object' && 'kind' in document ? document.kind : undefined; - if (kind === 'trace') return createTrace(host, document, options); - if (kind === 'profile') return createFlame(host, document, options); - return createChart(host, document, options); + const create = kind === 'trace' ? createTrace : kind === 'profile' ? createFlame : createChart; + return create(host, document, options); } diff --git a/src/runtime/chart-runtime.ts b/src/runtime/chart-runtime.ts index bdfe6c2..496e68f 100644 --- a/src/runtime/chart-runtime.ts +++ b/src/runtime/chart-runtime.ts @@ -1,3 +1,6 @@ +import { ownsInspectionDismissal } from './inspection-input.js'; +import { interactionFor, claimInteraction } from '../core/interaction.js'; +import { readingState, releaseReading } from '../core/reading-state.js'; import { ChartSession } from '../core/session.js'; import { RangeSelectionSession, type RangeSelectionBinding } from '../core/range.js'; import type { ChartInteraction, ChartScene, Inspect, Position } from '../core/scene.js'; @@ -72,11 +75,14 @@ export class ChartRuntime decoration, (target, position, pin, series, neighbors) => { if (this.#gesture?.consumed || this.options.inputConsumed?.()) return; - this.#inspect(target, position, pin, series, neighbors); + interactionFor(this.session).batch(() => + this.#inspect(target, position, pin, series, neighbors) + ); }, { ...options } ); this.session = new ChartSession(options.inspectionMode); + claimInteraction(this.session, canvas); this.#originalTouchAction = canvas.style.touchAction; this.#originalAttributes = ['tabindex', 'role', 'aria-label', 'aria-hidden'].map( (name) => [name, canvas.getAttribute(name)] as const @@ -400,7 +406,18 @@ export class ChartRuntime } } protected keyboard(event: KeyboardEvent) { - if (event.metaKey || event.ctrlKey || event.altKey || this.model?.interactive === false) return; + interactionFor(this.session).batch(() => this.#keyboard(event)); + } + #keyboard(event: KeyboardEvent) { + if (event.key === 'Escape' && ownsInspectionDismissal(this.canvas)) return; + if ( + event.defaultPrevented || + event.metaKey || + event.ctrlKey || + event.altKey || + this.model?.interactive === false + ) + return; const range = this.range; const endpoint = (event.target as HTMLElement).dataset?.endpoint as 'start' | 'end' | undefined; const adjusting = range.enabled && (event.shiftKey || endpoint || range.preview); @@ -437,6 +454,7 @@ export class ChartRuntime return; } if (this.options.keyboard === false) return; + let target: ChartInteraction | null = null; if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { event.preventDefault(); const key = @@ -445,24 +463,37 @@ export class ChartRuntime : event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? 'previous' : 'next'; - const target = this.session.next(key); + target = this.session.next(key); this.session.inspect(target?.key ?? null, false, true); - if (target) this.options.onNavigate?.(target); } else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - const target = this.session.active ?? this.session.next('Home'); + target = this.session.active ?? this.session.next('Home'); this.session.inspect(target?.key ?? null, true, true); - if (target) this.options.onNavigate?.(target); } else if (event.key === 'Escape') { event.preventDefault(); this.session.clearInspection(); } else return; - if (this.signal.aborted) return; - this.focus(this.session.active); - this.announce(); - this.options.onInspect?.(this.session.active, null, this.session.pinned); + if (target) { + const navigate = target; + const intent = readingState(this.session).intent; + interactionFor(this.session).effect(() => { + if (!this.signal.aborted && intent === readingState(this.session).intent) + this.options.onNavigate?.(navigate); + }); + } + interactionFor(this.session).effect( + () => { + if (this.signal.aborted) return; + this.focus(this.session.active); + this.announce(); + this.options.onInspect?.(this.session.active, null, this.session.pinned); + }, + {}, + 2 + ); } protected release() { + releaseReading(this.session); this.#cancel(); this.#binding = null; this.range.reset(); diff --git a/src/runtime/inspection-input.ts b/src/runtime/inspection-input.ts new file mode 100644 index 0000000..81ab285 --- /dev/null +++ b/src/runtime/inspection-input.ts @@ -0,0 +1,11 @@ +const dismissalOwners = new WeakSet(); + +/** The shell owns Canvas Escape even without a paint runtime or a cancelable event. */ +export function ownInspectionDismissal(canvas: HTMLElement, signal: AbortSignal) { + dismissalOwners.add(canvas); + signal.addEventListener('abort', () => dismissalOwners.delete(canvas), { once: true }); +} + +export function ownsInspectionDismissal(canvas: HTMLElement) { + return dismissalOwners.has(canvas); +} diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index d40fe86..350b94c 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -1,3 +1,5 @@ +import { readingState } from '../core/reading-state.js'; +import { interactionFor } from '../core/interaction.js'; import type { PaintInvalidation } from '../core/painter.js'; import type { ChartInteraction, @@ -32,6 +34,7 @@ export abstract class RuntimeLifecycle { #resize: ResizeObserver; #measure = new CanvasSurface('measure'); #frame = 0; + #revision = 0; // Cleared measurements leave layout pending, including while the host is hidden. #measurement: { width: number; height: number; font: string } | null = null; @@ -77,54 +80,97 @@ export abstract class RuntimeLifecycle { ); } #invalidate(reason: PaintInvalidation = 'layout') { - if (reason === 'layout') this.#measurement = null; + if (reason === 'layout') { + this.#measurement = null; + this.#revision++; + } if (this.options.invalidate) this.options.invalidate(reason); else this.refresh(reason); } /** Layout invalidation wins when several requests share a frame. */ refresh(reason: PaintInvalidation = 'layout') { if (this.signal.aborted) return; - if (reason === 'layout') this.#measurement = null; + if (reason === 'layout') { + this.#measurement = null; + this.#revision++; + } if (this.#frame) return; this.#frame = requestAnimationFrame(() => { this.#frame = 0; - try { - this.paint(this.options.layout, 'paint'); - } catch (error) { - this.#measurement = null; - this.painter.model = null; - this.session.setModel(null); - this.canvas.replaceChildren(); - this.canvas.setAttribute('aria-label', 'Chart rendering failed.'); - this.options.onError?.(error); - } + const interaction = interactionFor(this.session); + interaction.batch(() => { + try { + this.paint(this.options.layout, 'paint'); + } catch (error) { + this.#measurement = null; + this.painter.model = null; + this.session.setModel(null); + this.canvas.replaceChildren(); + this.canvas.setAttribute('aria-label', 'Chart rendering failed.'); + const revision = this.#revision; + const version = readingState(this.session).modelVersion; + interaction.effect( + () => this.options.onError?.(error), + {}, + 0, + () => + !this.signal.aborted && + revision === this.#revision && + version === readingState(this.session).modelVersion + ); + } + }); }); } /** Synchronous render for a host's coordinated state transaction. */ paint(layout = this.options.layout, reason: PaintInvalidation = 'layout') { if (this.signal.aborted || !layout) return; - this.options.layout = layout; - if (reason === 'layout') this.#measurement = null; - const { width, height } = this.canvas.getBoundingClientRect(); - if (width <= 0 || height <= 0) return; - const font = canvasFont(this.canvas); - let scene = this.painter.model; - if ( - !scene || - width !== this.#measurement?.width || - height !== this.#measurement?.height || - font !== this.#measurement?.font - ) { - const context = this.#measure.context; - context.font = font; - const measure: MeasureText = (text) => context.measureText(text).width; - measure.fontSize = parseFloat(font); - scene = layout(width, height, measure, this.session.hidden); - this.#measurement = { width, height, font }; - } - this.session.setModel(scene.navigation ?? scene); - this.draw(scene); - this.options.onRender?.(scene); + const interaction = interactionFor(this.session); + interaction.batch(() => { + this.options.layout = layout; + if (reason === 'layout') { + this.#measurement = null; + this.#revision++; + } + const revision = this.#revision; + const modelVersion = readingState(this.session).modelVersion; + const current = () => + !this.signal.aborted && + revision === this.#revision && + modelVersion === readingState(this.session).modelVersion; + const { width, height } = this.canvas.getBoundingClientRect(); + if (width <= 0 || height <= 0) return; + const font = canvasFont(this.canvas); + let scene = this.painter.model; + if ( + !scene || + width !== this.#measurement?.width || + height !== this.#measurement?.height || + font !== this.#measurement?.font + ) { + const context = this.#measure.context; + context.font = font; + const measure: MeasureText = (text) => context.measureText(text).width; + measure.fontSize = parseFloat(font); + try { + scene = layout(width, height, measure, this.session.hidden); + } catch (error) { + if (!current()) return; + throw error; + } + if (!current()) return; + this.#measurement = { width, height, font }; + } + this.session.setModel(scene.navigation ?? scene); + this.draw(scene); + const rendered = scene; + interaction.effect( + () => this.options.onRender?.(rendered), + {}, + 0, + () => !this.signal.aborted && this.painter.model === rendered + ); + }); } anchor(target: ChartInteraction) { return this.painter.anchor(target); diff --git a/src/runtime/reading-runtime.ts b/src/runtime/reading-runtime.ts index 1a82e6d..3c5272e 100644 --- a/src/runtime/reading-runtime.ts +++ b/src/runtime/reading-runtime.ts @@ -1,4 +1,6 @@ -import { ChartSession } from '../core/session.js'; +import { interactionFor } from '../core/interaction.js'; +import { readingState } from '../core/reading-state.js'; +import { ReadingSession } from '../core/reading-session.js'; import type { ChartInteraction, ChartScene } from '../core/scene.js'; import { RuntimeLifecycle } from './lifecycle.js'; import type { ReadingOptions } from './types.js'; @@ -6,7 +8,7 @@ import type { ReadingOptions } from './types.js'; /** Report lifecycle and transient reading. Range, links and viewport controllers stay optional. */ export class ReadingRuntime extends RuntimeLifecycle { protected declare readonly options: ReadingOptions; - readonly session = new ChartSession('compare'); + readonly session = new ReadingSession(); selectable = false; constructor(canvas: HTMLElement, decoration: HTMLElement, options: ReadingOptions) { @@ -15,11 +17,13 @@ export class ReadingRuntime extends RuntimeLifecycle { decoration, (target, position, pin, series) => { if (options.inputConsumed?.()) return; - if (!this.session.inspect(target?.key ?? null, pin && this.selectable, false, series)) - return; - this.focus(this.session.active); - this.announce(); - options.onInspect?.(this.session.active, position, this.session.pinned, series); + interactionFor(this.session).batch(() => { + if (!this.session.inspect(target?.key ?? null, pin && this.selectable, false, series)) + return; + this.focus(this.session.active); + this.announce(); + options.onInspect?.(this.session.active, position, this.session.pinned, series); + }); }, options ); @@ -50,7 +54,19 @@ export class ReadingRuntime extends RuntimeLifecycle { this.announce(); } protected keyboard(event: KeyboardEvent) { - if (event.metaKey || event.ctrlKey || event.altKey || this.model?.interactive === false) return; + interactionFor(this.session).batch(() => this.#keyboard(event)); + } + #keyboard(event: KeyboardEvent) { + if ( + event.defaultPrevented || + event.metaKey || + event.ctrlKey || + event.altKey || + this.model?.interactive === false + ) + return; + const interaction = interactionFor(this.session); + let target: ChartInteraction | null = null; if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { event.preventDefault(); const direction = @@ -59,23 +75,34 @@ export class ReadingRuntime extends RuntimeLifecycle { : event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? 'previous' : 'next'; - const target = this.session.next(direction); + target = this.session.next(direction); this.session.inspect(target?.key ?? null, false, true); - if (target) this.options.onNavigate?.(target); } else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - const target = this.session.active ?? this.session.next('Home'); + target = this.session.active ?? this.session.next('Home'); this.session.inspect(target?.key ?? null, this.selectable, true); - if (target) this.options.onNavigate?.(target); } else if (event.key === 'Escape') { event.preventDefault(); this.session.clearInspection(); } else return; - // Navigation may paint synchronously and let host callbacks dismiss or dispose this reading. - if (this.signal.aborted) return; - this.focus(this.session.active); - this.announce(); - this.options.onInspect?.(this.session.active, null, this.session.pinned); + if (target) { + const navigate = target; + const intent = readingState(this.session).intent; + interaction.effect(() => { + if (!this.signal.aborted && intent === readingState(this.session).intent) + this.options.onNavigate?.(navigate); + }); + } + interaction.effect( + () => { + if (this.signal.aborted) return; + this.focus(this.session.active); + this.announce(); + this.options.onInspect?.(this.session.active, null, this.session.pinned); + }, + {}, + 2 + ); } protected release() { this.session.reset(); diff --git a/src/views/chart-type.ts b/src/views/chart-type.ts index 8e16db3..38d8f31 100644 --- a/src/views/chart-type.ts +++ b/src/views/chart-type.ts @@ -1,14 +1,16 @@ +import { interactionHosts } from './interactions.js'; +import { parseViewOptions } from './options.js'; import type { CompiledChart } from '../families/metrics/document.js'; import { metricLayoutKind } from '../families/metrics/layout-kind.js'; import type { MetricLayout } from '../families/metrics/layout-common.js'; import type { ResultRow } from '../data/result.js'; import type { MetricInspectionContext } from '../core/inspection-context.js'; -import { createMetric } from './metric.js'; +import { createMetricPlot } from './metric-plot.js'; import type { ChartView, ViewOptions } from './view.js'; /** Fixed-type entries can omit the other layouts and the automatic download router. */ export function chartFactory( - kind: ReturnType, + kind: Exclude, 'aggregate'>, layout: MetricLayout, compile: (document: unknown) => CompiledChart ) { @@ -25,6 +27,30 @@ export function chartFactory( ); return compiled; }; - return createMetric(host, prepare(document), layout, prepare, options).view; + const { view, replace } = createMetricPlot( + host, + prepare(document), + layout, + parseViewOptions(options) + ); + const handle: ChartView = { + update(document) { + view.assertActive(); + replace(prepare(document)); + }, + get theme() { + return view.theme; + }, + set theme(value) { + view.theme = value; + }, + refresh: () => view.refresh(), + dispose() { + view.dispose(); + interactionHosts.delete(handle); + } + }; + interactionHosts.set(handle, interactionHosts.get(view)); + return handle; }; } diff --git a/src/views/hierarchy.ts b/src/views/hierarchy.ts index 5cd4022..1a486bf 100644 --- a/src/views/hierarchy.ts +++ b/src/views/hierarchy.ts @@ -1,3 +1,5 @@ +import { captureScene } from '../core/scene-snapshot.js'; +import { interactionFor } from '../core/interaction.js'; import { parseViewOptions } from './options.js'; import { hierarchyViewportHeight, @@ -25,32 +27,30 @@ export function createHierarchy< ): ChartView { options = parseViewOptions(options); let compiled = compile(document); - let rendered = compiled; let model = createView(compiled); const view: View = new View( host, options, '', (width, height, measure) => { - rendered = compiled; const state = view.viewport?.state( model.rowCount * model.rowHeight, hierarchyViewportHeight(height, options.axes ?? 'minimal') ); - return layoutHierarchy( - model, - width, - height, - measure, - state, - options.axes ?? 'minimal', - !view.viewport + return captureScene( + layoutHierarchy( + model, + width, + height, + measure, + state, + options.axes ?? 'minimal', + !view.viewport + ), + compiled ); }, - { - snapshot: () => rendered, - contextFor: (target) => context(rendered, target) - }, + (target, _series, snapshot) => context(snapshot as Compiled, target), { inputConsumed: () => view.viewport?.consumed ?? false, onNavigate(target) { @@ -87,13 +87,14 @@ export function createHierarchy< view.assertActive(); const next = compile(document); const nextView = createView(next); - view.viewport?.cancel(); - compiled = next; - model = nextView; - view.status(model.items.length ? null : 'No data'); - if (!view.disposed) view.changed(); + interactionFor(view.runtime.session).batch(() => { + view.viewport?.cancel(); + compiled = next; + model = nextView; + view.status(nextView.items.length ? null : 'No data'); + if (!view.disposed) view.changed(); + }); }; view.status(model.items.length ? null : 'No data'); - view.changed(); return view; } diff --git a/src/views/interactions.ts b/src/views/interactions.ts index 2514037..b404a37 100644 --- a/src/views/interactions.ts +++ b/src/views/interactions.ts @@ -20,4 +20,4 @@ export interface InteractionHost { cleanup(callback: () => void): void; } /** Shared by the ESM view entries and the explicitly imported interaction module. */ -export const interactionHosts = new WeakMap(); +export const interactionHosts = new WeakMap(); diff --git a/src/views/metric-plot.ts b/src/views/metric-plot.ts new file mode 100644 index 0000000..7a30c3e --- /dev/null +++ b/src/views/metric-plot.ts @@ -0,0 +1,41 @@ +import { captureScene } from '../core/scene-snapshot.js'; +import { interactionFor } from '../core/interaction.js'; +import type { CompiledChart } from '../families/metrics/document.js'; +import type { MetricLayout } from '../families/metrics/layout-common.js'; +import { metricInspectionContext } from '../families/metrics/inspection-context.js'; +import type { ResultRow } from '../data/result.js'; +import type { MetricInspectionContext } from '../core/inspection-context.js'; +import { View, type ViewOptions } from './view.js'; +import tokens from '../theme/metric-tokens.css?inline'; + +/** Plotted metrics share one transaction owner; fixed entries exclude scalar switching. */ +export function createMetricPlot( + host: HTMLElement, + compiled: CompiledChart, + layout: MetricLayout, + options: ViewOptions +) { + const view = new View( + host, + options, + tokens, + (width, height, measure, hidden) => + captureScene( + layout(compiled, hidden, width, height, measure, options.axes ?? 'minimal'), + compiled + ), + (target, series, snapshot) => metricInspectionContext(snapshot as CompiledChart, target, series) + ); + const interaction = interactionFor(view.runtime.session); + const replace = (next: CompiledChart, nextLayout: MetricLayout = layout) => { + view.assertActive(); + interaction.batch(() => { + compiled = next; + layout = nextLayout; + view.status(next.document.data.rows.length ? null : 'No data'); + view.changed(); + }); + }; + view.status(compiled.document.data.rows.length ? null : 'No data'); + return { view, replace }; +} diff --git a/src/views/metric.ts b/src/views/metric.ts index 931668e..748c41d 100644 --- a/src/views/metric.ts +++ b/src/views/metric.ts @@ -3,26 +3,24 @@ import type { CompiledChart } from '../families/metrics/document.js'; import type { MetricLayout } from '../families/metrics/layout-common.js'; import type { ResultRow } from '../data/result.js'; import type { MetricInspectionContext } from '../core/inspection-context.js'; +import { createMetricPlot } from './metric-plot.js'; import { scalarView, type ScalarValue } from './scalar.js'; -import { View, type ViewOptions, type ChartView } from './view.js'; +import type { ViewOptions, ChartView } from './view.js'; import { interactionHosts } from './interactions.js'; -import { metricInspectionContext } from '../families/metrics/inspection-context.js'; -import tokens from '../theme/metric-tokens.css?inline'; -/** Aggregate documents use HTML; plot documents instantiate the report Canvas runtime. */ +/** The general entry alone owns transitions between HTML aggregates and plotted metrics. */ export function createMetric( host: HTMLElement, compiled: CompiledChart, layout: MetricLayout, compile: (document: unknown) => CompiledChart, - options: ViewOptions = {} + options: ViewOptions ) { options = parseViewOptions(options); - let inner: ChartView; - let disposed = false; let revision = 0; - let theme = options.theme ?? 'light'; - let rendered = compiled; + let disposed = false; + let inner: ChartView; + let plot: ReturnType | null = null; const scalarValues = (value: CompiledChart): ScalarValue[] => { const mapping = value.config.mapping; if (mapping.kind !== 'aggregate' || !value.document.data.rows.length) return []; @@ -35,39 +33,28 @@ export function createMetric( }; }); }; - const mount = () => { - if (compiled.config.mapping.kind === 'aggregate') - return scalarView(host, scalarValues(compiled), { theme }); - const view = new View( - host, - { ...options, theme }, - tokens, - (width, height, measure, hidden) => { - rendered = compiled; - return layout(compiled, hidden, width, height, measure, options.axes ?? 'minimal'); - }, - { - snapshot: () => rendered, - contextFor: (target, series) => metricInspectionContext(rendered, target, series) - } - ); - view.status(compiled.document.data.rows.length ? null : 'No data'); - view.changed(); - return view; + const mount = ( + next: CompiledChart, + nextLayout: MetricLayout, + theme = options.theme ?? 'light' + ): ChartView => { + plot = + next.config.mapping.kind === 'aggregate' + ? null + : createMetricPlot(host, next, nextLayout, { ...options, theme }); + return plot?.view ?? scalarView(host, scalarValues(next), { theme }); }; - inner = mount(); + inner = mount(compiled, layout); const handle: ChartView = { update(document) { if (disposed) throw new Error('This chart has been disposed.'); - const next = compile(document); - replace(next); + replace(compile(document)); }, get theme() { - return theme; + return inner.theme; }, set theme(value) { inner.theme = value; - theme = inner.theme; }, refresh: () => inner.refresh(), dispose() { @@ -78,31 +65,20 @@ export function createMetric( } }; function bind() { - const binding = interactionHosts.get(inner); - if (binding) interactionHosts.set(handle, binding); - else interactionHosts.delete(handle); + interactionHosts.set(handle, interactionHosts.get(inner)); } - function replace(next: CompiledChart, nextLayout: MetricLayout = layout) { + function replace(next: CompiledChart, nextLayout?: MetricLayout) { if (disposed) throw new Error('This chart has been disposed.'); const current = ++revision; - layout = nextLayout; - const aggregate = next.config.mapping.kind === 'aggregate'; - if (aggregate !== (compiled.config.mapping.kind === 'aggregate')) { - if (inner instanceof View) inner.clearInspection(); + if ((next.config.mapping.kind !== 'aggregate') !== !!plot) { + plot?.view.clearInspection(); if (disposed || current !== revision) return; + const theme = inner.theme; inner.dispose(); - compiled = next; - inner = mount(); + inner = mount(next, nextLayout ?? layout, theme); bind(); - } else { - compiled = next; - if (inner instanceof View) { - const view = inner; - view.status(compiled.document.data.rows.length ? null : 'No data'); - if (disposed || current !== revision) return; - view.changed(); - } else inner.update(scalarValues(compiled)); - } + } else if (plot) plot.replace(next, nextLayout); + else inner.update(scalarValues(next)); } bind(); return { view: handle, replace }; diff --git a/src/views/view.ts b/src/views/view.ts index adfb7f2..6b3cbd5 100644 --- a/src/views/view.ts +++ b/src/views/view.ts @@ -1,3 +1,5 @@ +import { sceneSnapshot } from '../core/scene-snapshot.js'; +import { interactionFor } from '../core/interaction.js'; import type { ChartInteraction, Position } from '../core/scene.js'; import type { InspectionContext } from '../core/inspection-context.js'; import { InspectionNotifications } from '../core/inspection-notifications.js'; @@ -13,56 +15,43 @@ import { interactionHosts, type ViewportDriver } from './interactions.js'; export type { ChartTheme, ChartView, ThemeConfig, ThemeMode } from './host.js'; import type { ViewOptions } from './options.js'; export type { ViewOptions } from './options.js'; +type ContextProjector = ( + target: ChartInteraction | null, + series: string | null, + snapshot: object +) => Context | null; + /** A canvas and transient reading. Application UI stays outside this owned child. */ export class View extends ViewHost implements ChartView { readonly canvas = node('div', 'canvas'); readonly runtime: ReadingRuntime; - readonly signal: AbortSignal; viewport?: ViewportDriver; - update: (document: unknown) => void = () => {}; + declare update: (document: unknown) => void; #tooltip = node('div', 'tooltip'); #status = node('div', 'status'); #statusMessage: string | null = null; #readingVisible = true; #position: Position | null = null; #cleanups: (() => void)[] = []; - #notifications = new InspectionNotifications(() => { - const session = this.runtime.session; - return { - snapshot: this.inspection.snapshot(), - inspect: { - target: - !this.#readingVisible || this.runtime.model?.interactive === false - ? null - : session.keyboardInspection - ? session.active - : this.#position - ? session.hovered - : null, - series: session.series - }, - select: { target: session.selected, series: session.selectedSeries } - }; - }); + #notifications: InspectionNotifications; + readonly #contextFor: ContextProjector; constructor( host: HTMLElement, readonly options: ViewOptions, tokens: string, layout: SceneLayout, - private readonly inspection: { - snapshot(): object | null; - contextFor(target: ChartInteraction | null, series: string | null): Context | null; - }, - input: { + contextFor: ContextProjector, + input?: { onNavigate?(target: ChartInteraction): void; inputConsumed?(): boolean; - } = {} + } ) { super(host, styles, baseTokens + tokens, options.theme); - this.signal = this.events.signal; + this.#contextFor = contextFor; + const signal = this.events.signal; const decoration = node('div', 'decoration'); - this.#status.hidden = this.#tooltip.hidden = true; + this.#tooltip.hidden = true; this.root.append(this.canvas, decoration, this.#status, this.#tooltip); this.runtime = new ReadingRuntime(this.canvas, decoration, { layout, @@ -83,6 +72,24 @@ export class View extends ViewHost im options.onError?.(error); } }); + this.#notifications = new InspectionNotifications(() => { + const session = this.runtime.session; + return { + snapshot: sceneSnapshot(this.runtime.model), + inspect: { + target: + !this.#readingVisible || this.runtime.model?.interactive === false + ? null + : session.keyboardInspection + ? session.active + : this.#position + ? session.hovered + : null, + series: session.series + }, + select: { target: session.selected, series: session.selectedSeries } + }; + }, interactionFor(this.runtime.session)); this.repaint = () => this.runtime.refresh('paint'); interactionHosts.set(this, { select: (enabled) => { @@ -90,27 +97,29 @@ export class View extends ViewHost im this.runtime.selectable = enabled; if (!enabled) this.clearInspection(); }, - cleanup: (callback) => this.cleanup(callback) + cleanup: (callback) => this.#cleanups.push(callback) }); const dismiss = (event: Event) => { if (event.composedPath().includes(this.element)) return; this.clearInspection(); }; - document.addEventListener('pointerdown', dismiss, { signal: this.signal }); - document.addEventListener('focusin', dismiss, { signal: this.signal }); + document.addEventListener('pointerdown', dismiss, { signal }); + document.addEventListener('focusin', dismiss, { signal }); window.addEventListener( 'scroll', () => { if (this.runtime.session.keyboardInspection) this.#reading(); else this.hideReading(); }, - { capture: true, signal: this.signal } + { capture: true, signal } ); } #reading() { - if (this.disposed) return; this.#readingVisible = true; - const batch = this.#notifications.capture(); + this.#report(); + } + #presentReading(batch: ReturnType['capture']>) { + this.runtime.focus(this.runtime.session.active); const { target, series } = batch.state.inspect; if (!target || this.options.tooltip === false) { hideInspection(this.#tooltip); @@ -125,29 +134,38 @@ export class View extends ViewHost im }); else hideInspection(this.#tooltip); } - this.#report(batch); } - #report(batch: ReturnType['capture']>) { - batch.emit('select', (value) => - this.options.onSelect?.(value.target, this.inspection.contextFor(value.target, value.series)) - ); - batch.emit('inspect', (value) => - this.options.onInspect?.(value.target, this.inspection.contextFor(value.target, value.series)) + #report() { + const batch = this.#notifications.capture(); + interactionFor(this.runtime.session).effect( + () => this.#presentReading(batch), + this.#tooltip, + 0, + () => batch.current('inspect') ); + for (const channel of ['select', 'inspect'] as const) + batch.emit(channel, (value, snapshot) => + this.options[channel === 'select' ? 'onSelect' : 'onInspect']?.( + value.target, + snapshot ? this.#contextFor(value.target, value.series, snapshot) : null + ) + ); } + hideReading() { - this.#readingVisible = false; - this.#position = null; - this.#notifications.invalidate('inspect'); - hideInspection(this.#tooltip); - this.#report(this.#notifications.capture()); + this.#dismiss(false); } clearInspection() { - this.#notifications.invalidate(); - this.runtime.session.clearInspection(); - this.runtime.focus(null); - this.#position = null; - this.#reading(); + this.#dismiss(true); + } + #dismiss(clear: boolean) { + interactionFor(this.runtime.session).batch(() => { + this.#notifications.invalidate(clear ? undefined : 'inspect'); + if (clear) this.runtime.session.clearInspection(); + this.#position = null; + this.#readingVisible = clear; + this.#report(); + }); } status(message: string | null) { this.#statusMessage = message; @@ -160,14 +178,12 @@ export class View extends ViewHost im if (message) this.hideReading(); } changed() { - this.assertActive(); - this.#notifications.invalidate(); - this.hideReading(); - this.runtime.session.inspect(null); - this.runtime.refresh(); - } - cleanup(callback: () => void) { - this.#cleanups.push(callback); + interactionFor(this.runtime.session).batch(() => { + this.#notifications.invalidate(); + this.hideReading(); + this.runtime.session.inspect(null); + this.runtime.refresh(); + }); } dispose() { if (this.disposed) return; diff --git a/tests/browser/inspection-transactions-matrix.spec.ts b/tests/browser/inspection-transactions-matrix.spec.ts new file mode 100644 index 0000000..7468c62 --- /dev/null +++ b/tests/browser/inspection-transactions-matrix.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; +import type { ScopeChartElement } from '../../src/element.js'; +import { inspectionEntries } from './fixtures/inspection-matrix.js'; + +for (const entry of inspectionEntries) { + test(`${entry.name} commits data before a popover effect can request a newer snapshot`, async ({ + page + }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async (entry) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { + createInspectionFixture, + inspectionValue + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture(entry); + f.key('Home'); + const tooltip = f.root.querySelector('.tooltip')!; + const floating = tooltip.matches(':popover-open'); + const previous = f.latest.inspect; + let replaced = false; + tooltip.addEventListener('beforetoggle', (event) => { + if (replaced || (event as ToggleEvent).newState !== 'closed') return; + replaced = true; + void f.replace('300', false); + }); + f.events.length = 0; + await f.replace('200', false); + await f.settle(); + f.key('Home'); + return { + floating, + replaced, + current: inspectionValue(f.latest.inspect), + changedSnapshot: previous?.snapshotId !== f.latest.inspect?.snapshotId, + values: f.events.map((event) => inspectionValue(event.context)) + }; + }, entry); + expect(result.floating).toBe(true); + expect(result.replaced).toBe(true); + expect(result.current).toBe('300'); + expect(result.changedSnapshot).toBe(true); + expect(result.values).not.toContain('200'); + }); +} + +test('metric document cleanup observes committed data and cannot overwrite a nested replacement', async ({ + page +}) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async () => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { + createInspectionFixture, + inspectionValue + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture({ + name: 'range cleanup', + family: 'metric', + surface: 'component' + }); + const chart = f.chart as ScopeChartElement; + let armed = false, + observed: unknown; + chart.rangeSelection = { + snapshot: {}, + value: null, + onCommit() {}, + onPreview() { + if (!armed) return; + armed = false; + observed = { + value: chart.document?.data.rows[0][1], + cleared: chart.rangeSelection === null + }; + void f.replace('300', false); + } + }; + armed = true; + f.events.length = 0; + await f.replace('200', false); + await f.settle(); + f.key('Home'); + return { + observed, + current: inspectionValue(f.latest.inspect), + values: f.events.map((event) => inspectionValue(event.context)) + }; + }); + expect(result.observed).toEqual({ value: '200', cleared: true }); + expect(result.current).toBe('300'); + expect(result.values).not.toContain('200'); +}); + +for (const entry of inspectionEntries.filter( + (entry) => entry.family === 'metric' && entry.surface === 'direct' +)) { + test(`${entry.name} keeps detached facade methods and its own theme accessor compatible`, async ({ + page + }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async (entry) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { + createInspectionFixture, + inspectionDocument, + inspectionValue + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture(entry); + const view = f.view!; + const { update, refresh, dispose } = view; + update(inspectionDocument(entry, '200')); + view.theme = 'dark'; + refresh(); + await f.settle(); + f.key('Home'); + const result = { + value: inspectionValue(f.latest.inspect), + theme: view.theme, + ownTheme: Object.prototype.propertyIsEnumerable.call(view, 'theme') + }; + dispose(); + return { ...result, removed: !f.host.querySelector('.scopeview-view') }; + }, entry); + expect(result).toEqual({ value: '200', theme: 'dark', ownTheme: true, removed: true }); + }); +} diff --git a/tests/browser/interaction-transactions.spec.ts b/tests/browser/interaction-transactions.spec.ts new file mode 100644 index 0000000..d2614f1 --- /dev/null +++ b/tests/browser/interaction-transactions.spec.ts @@ -0,0 +1,261 @@ +import { test, expect } from '@playwright/test'; +import type { ScopeTraceElement, ScopeFlameElement } from '../../src/element.js'; +import type { ViewportState } from '../../src/index.js'; + +type Hierarchy = ScopeTraceElement | ScopeFlameElement; + +for (const family of ['trace', 'profile'] as const) { + test.describe(`${family} interaction transactions`, () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + await page.evaluate(async (family) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { createInspectionFixture }: typeof import('./fixtures/inspection-matrix.js') = + await import(module); + const f = await createInspectionFixture({ + name: 'transactions', + family, + surface: 'component' + }); + window.notificationFixture = f; + const nodes = Array.from({ length: 50 }, (_, index) => ({ + id: `node-${index}`, + name: `node-${index}`, + ...(index ? { parentId: `node-${index - 1}` } : {}) + })); + f.chart!.document = + family === 'trace' + ? { + version: 1, + kind: family, + spans: nodes.map((node) => ({ ...node, startTimeNs: '0', endTimeNs: '10000' })) + } + : { + version: 1, + kind: family, + unit: 'samples', + frames: nodes.map((node) => ({ ...node, selfWeight: '1' })) + }; + await f.settle(); + }, family); + }); + + for (const height of [120, 240]) { + for (const pin of [false, true]) { + test(`hidden focus below a collapsed ancestor survives ${height}px measurement, pin=${pin}`, async ({ + page + }) => { + const result = await page.evaluate( + async ({ height, pin }) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + chart.collapsed = ['node-0']; + await f.settle(); + chart.focusNode('node-0', { pin: true }); + f.host.style.display = 'none'; + await f.settle(); + chart.focusNode('node-20', { pin }); + const accepted = { + key: f.latest.inspect?.key, + pin: chart.viewState.pinnedKey, + collapsed: chart.collapsed + }; + chart.style.setProperty('--scope-chart-height', `${height}px`); + f.host.style.display = ''; + await f.settle(); + const scene = chart.scene!; + const target = scene.interactionByKey.get(`${f.entry.family}:node-20`); + const rect = scene.rect!; + return { + accepted, + key: f.latest.inspect?.key, + pin: chart.viewState.pinnedKey, + visible: + !!target?.anchor && + target.anchor.y >= rect.y && + target.anchor.y <= rect.y + rect.height + }; + }, + { height, pin } + ); + const key = `${family}:node-20`; + expect(result.accepted).toEqual({ key, pin: pin ? key : null, collapsed: [] }); + expect(result.key).toBe(key); + expect(result.pin).toBe(pin ? key : null); + expect(result.visible).toBe(true); + }); + } + } + + test('same-window acknowledgements quiesce without another notification', async ({ page }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + let notifications = 0; + chart.addEventListener('scope-viewport-change', (event) => { + notifications++; + chart.viewport = { ...(event as CustomEvent).detail }; + }); + chart.viewport = { start: 0.25, end: 0.75, scrollOffset: 50 }; + const immediate = notifications; + await f.settle(); + return { immediate, notifications, viewport: chart.viewport }; + }); + expect(result).toEqual({ + immediate: 1, + notifications: 1, + viewport: { start: 0.25, end: 0.75, scrollOffset: 50 } + }); + }); + + test('changing-window feedback yields to the event loop', async ({ page }) => { + const result = await page.evaluate(async () => { + const chart = window.notificationFixture.chart as Hierarchy; + let count = 0; + chart.addEventListener('scope-viewport-change', () => { + if (++count < 200) + chart.viewport = { + start: count % 2 ? 0.1 : 0.2, + end: count % 2 ? 0.6 : 0.7, + scrollOffset: 0 + }; + }); + chart.viewport = { start: 0.2, end: 0.7, scrollOffset: 0 }; + const immediate = count; + await new Promise((resolve) => setTimeout(resolve, 0)); + const yielded = count; + while (count < 200) await new Promise((resolve) => setTimeout(resolve, 5)); + return { immediate, yielded, settled: count }; + }); + expect(result.immediate).toBeGreaterThan(0); + expect(result.immediate).toBeLessThan(200); + expect(result.yielded).toBeLessThan(200); + expect(result.settled).toBe(200); + }); + + test('collapse acknowledgements quiesce without another notification', async ({ page }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + let count = 0; + chart.addEventListener('scope-collapse-change', (event) => { + count++; + chart.collapsed = (event as CustomEvent<{ collapsed: string[] }>).detail.collapsed; + }); + chart.collapsed = ['node-0']; + await f.settle(); + return { count, collapsed: chart.collapsed }; + }); + expect(result).toEqual({ count: 1, collapsed: ['node-0'] }); + }); + + for (const cancelable of [false, true]) { + test(`Canvas listeners can restore after the sole Escape handler, cancelable=${cancelable}`, async ({ + page + }) => { + const result = await page.evaluate(async (cancelable) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + chart.focusNode('node-0', { pin: true }); + const saved = chart.viewState; + let observed = 0; + f.canvas.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + observed++; + chart.viewState = saved; + } + }); + f.canvas.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable }) + ); + const immediate = chart.viewState.pinnedKey; + await f.settle(); + return { observed, immediate, selected: f.latest.select?.key }; + }, cancelable); + expect(result).toEqual({ + observed: 1, + immediate: `${family}:node-0`, + selected: `${family}:node-0` + }); + }); + } + + for (const command of ['focusNode', 'keyboard'] as const) { + test(`a nested ${command} owns selection and geometry before the outer call returns`, async ({ + page + }) => { + const result = await page.evaluate((command) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + const early: number[] = []; + const delegated: number[] = []; + let nested = false; + let synchronous: string | null = null; + chart.addEventListener('scope-viewport-change', (event) => { + early.push((event as CustomEvent).detail.scrollOffset); + if (nested) return; + nested = true; + if (command === 'focusNode') chart.focusNode('node-30', { pin: true }); + else { + f.key('End'); + f.key('Enter'); + } + synchronous = chart.viewState.pinnedKey; + }); + f.host.addEventListener('scope-viewport-change', (event) => + delegated.push((event as CustomEvent).detail.scrollOffset) + ); + chart.focusNode('node-20', { pin: true }); + const key = `${f.entry.family}:node-${command === 'focusNode' ? 30 : 49}`; + return { + key, + synchronous, + pin: chart.viewState.pinnedKey, + selected: f.latest.select?.key, + inspected: f.latest.inspect?.key, + painted: chart.scene!.interactionByKey.has(key), + early, + delegated + }; + }, command); + expect(result.synchronous).toBe(result.key); + expect(result.pin).toBe(result.key); + expect(result.selected).toBe(result.key); + expect(result.inspected).toBe(result.key); + expect(result.painted).toBe(true); + expect(result.early).toEqual([328, command === 'focusNode' ? 568 : 1024]); + expect(result.delegated).toEqual(result.early); + }); + } + }); +} + +for (const family of ['metric', 'trace', 'profile'] as const) { + test(`${family} discards ready when a controls observer disconnects the completed frame`, async ({ + page + }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async (family) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { createInspectionFixture }: typeof import('./fixtures/inspection-matrix.js') = + await import(module); + const f = await createInspectionFixture({ name: 'lifecycle', family, surface: 'component' }); + const chart = f.chart!; + chart.loading = true; + await f.settle(); + let ready = 0, + removed = false; + chart.addEventListener('scope-ready', () => ready++); + chart.addEventListener('scope-controls-change', () => { + if (chart.dataset.state === 'ready' && !removed) { + removed = true; + chart.remove(); + } + }); + chart.loading = false; + await f.settle(); + return { removed, connected: chart.isConnected, ready }; + }, family); + expect(result).toEqual({ removed: true, connected: false, ready: 0 }); + }); +} diff --git a/tests/browser/runtime-lifecycle.spec.ts b/tests/browser/runtime-lifecycle.spec.ts index 7353da2..3882d39 100644 --- a/tests/browser/runtime-lifecycle.spec.ts +++ b/tests/browser/runtime-lifecycle.spec.ts @@ -87,6 +87,96 @@ async function mount(page: Page, kind: 'reading' | 'chart', delegated = false) { await expect(page.locator('#lifecycle-fixture canvas[data-layer=marks]')).toBeVisible(); } +for (const kind of ['reading', 'chart'] as const) { + test(`${kind} keeps a retained disposed session independent of the next runtime on its Canvas`, async ({ + page + }) => { + await mount(page, kind); + const result = await page.evaluate(async (kind) => { + const old = window.lifecycleFixture.runtime; + const oldSession = old.session; + const root = document.getElementById('lifecycle-fixture')!.shadowRoot!; + const canvas = root.querySelector('.canvas')!; + const decoration = root.querySelector('.decoration')!; + const frame = old.model!; + const scene = (key: string): ChartScene => { + const target = { + key, + sources: [{ value: key }], + tooltip: { rows: [] }, + anchor: { x: 50, y: 50 }, + points: [] + }; + return { + ...frame, + marks: [], + interactions: [target], + interactionByKey: new Map([[key, target]]) + }; + }; + old.dispose(); + const module = + kind === 'chart' ? '/src/runtime/chart-runtime.ts' : '/src/runtime/reading-runtime.ts'; + const api = await import(module); + const next: ChartRuntime | ReadingRuntime = + kind === 'chart' + ? new api.ChartRuntime(canvas, decoration, { layout: () => scene('new') }) + : new api.ReadingRuntime(canvas, decoration, { layout: () => scene('new') }); + next.paint(); + next.session.inspect('new', true, true); + oldSession.setModel(scene('old')); + oldSession.inspect('old', true, true); + const result = { current: next.session.selected?.key, retained: oldSession.selected?.key }; + next.dispose(); + return result; + }, kind); + expect(result).toEqual({ current: 'new', retained: 'old' }); + }); + + for (const throws of [false, true]) { + test(`${kind} discards a layout superseded by a semantic model command, throws=${throws}`, async ({ + page + }) => { + await mount(page, kind); + const result = await page.evaluate((throws) => { + const runtime = window.lifecycleFixture.runtime; + const previous = runtime.model!; + const scene = (key: string): ChartScene => { + const target = { + key, + sources: [{ value: key }], + tooltip: { rows: [] }, + anchor: { x: 50, y: 50 }, + points: [] + }; + return { + ...previous, + marks: [], + ariaLabel: key, + interactions: [target], + interactionByKey: new Map([[key, target]]) + }; + }; + const latest = scene('latest'); + runtime.paint(() => { + runtime.session.setModel(latest); + runtime.session.inspect('latest', true, true); + if (throws) throw new Error('Obsolete layout'); + return scene('obsolete'); + }); + const staleDiscarded = runtime.model === previous; + runtime.paint(() => latest); + return { + staleDiscarded, + selected: runtime.session.selected?.key, + painted: runtime.model?.ariaLabel + }; + }, throws); + expect(result).toEqual({ staleDiscarded: true, selected: 'latest', painted: 'latest' }); + }); + } +} + for (const kind of ['reading', 'chart'] as const) { for (const key of ['Enter', 'End']) { test(`${kind} commits ${key} before navigation can paint and dismiss the reading`, async ({ diff --git a/tests/unit/core/inspection-notifications.test.ts b/tests/unit/core/inspection-notifications.test.ts index a50a478..8ff4946 100644 --- a/tests/unit/core/inspection-notifications.test.ts +++ b/tests/unit/core/inspection-notifications.test.ts @@ -1,3 +1,4 @@ +import { Interaction } from '../../../src/core/interaction.js'; import { describe, expect, it, vi } from 'vitest'; import { InspectionNotifications, @@ -19,7 +20,7 @@ function fixture() { select: { target: a } }; const read = vi.fn(() => state); - const notifications = new InspectionNotifications(read); + const notifications = new InspectionNotifications(read, new Interaction()); const sent: string[] = []; const deliver = (value: { target: ChartInteraction | null }) => sent.push(value.target?.key ?? 'null'); @@ -68,7 +69,8 @@ describe('inspection notifications', () => { cleared.emit('select', deliver); }); expect(pending.emit('select', deliver)).toBe(false); - expect(pending.current()).toBe(false); + expect(pending.current('inspect')).toBe(false); + expect(pending.current('select')).toBe(false); expect(sent).toEqual(['a', 'null', 'null']); }); @@ -164,7 +166,7 @@ describe('inspection notifications', () => { }); cleared.emit(other, deliver); }, - { force: true } + true ) ).toThrow(error); expect(sent).toEqual(['null']); @@ -197,17 +199,22 @@ describe('inspection notifications', () => { expect(sent).toEqual(['null']); }); - it('revalidates after control callbacks and does not commit superseded reports', () => { - const { state, notifications, deliver, sent } = fixture(); - const pending = notifications.capture(); - expect( - pending.emit('select', deliver, { - before: () => { + it('revalidates reports after earlier control effects replace their state', () => { + const { state, sent, deliver } = fixture(); + const interaction = new Interaction(); + const notifications = new InspectionNotifications(() => state, interaction); + interaction.batch(() => { + const pending = notifications.capture(); + pending.emit('select', deliver); + interaction.effect( + () => { state.select.target = null; notifications.capture().emit('select', deliver); - } - }) - ).toBe(false); + }, + {}, + 1 + ); + }); expect(sent).toEqual([]); expect(notifications.capture().emit('select', deliver)).toBe(false); }); @@ -229,7 +236,7 @@ describe('inspection notifications', () => { initial.emit('inspect', deliver); initial.emit('select', deliver); const pointer = notifications.capture(); - pointer.emit('inspect', deliver, { force: true }); + pointer.emit('inspect', deliver, true); expect(pointer.emit('select', deliver)).toBe(false); expect(sent).toEqual(['a', 'a', 'a']); }); diff --git a/tests/unit/core/interaction.test.ts b/tests/unit/core/interaction.test.ts new file mode 100644 index 0000000..5fe4a4f --- /dev/null +++ b/tests/unit/core/interaction.test.ts @@ -0,0 +1,304 @@ +import { shareReading, releaseReading } from '../../../src/core/reading-state.js'; +import { + compileProfile, + createProfileView, + type CompiledProfile +} from '../../../src/families/profile.js'; +import type { Entity } from '../../../src/families/hierarchy/model.js'; +import { describe, expect, it, vi } from 'vitest'; +import { + Interaction, + interactionFor, + shareInteraction, + claimInteraction +} from '../../../src/core/interaction.js'; +import { InspectionNotifications } from '../../../src/core/inspection-notifications.js'; +import { ChartSession } from '../../../src/core/session.js'; +import { HierarchyInteraction } from '../../../src/families/hierarchy/interaction.js'; +import { compileTrace, createTraceView, type CompiledTrace } from '../../../src/families/trace.js'; + +describe('interaction transactions', () => { + it('deduplicates acknowledgements and finishes each broadcast before its nested successor', () => { + const interaction = new Interaction(); + let state = 0; + const reports = interaction.channel(0, Object.is); + const early: number[] = [], + late: number[] = []; + const commit = (value: number) => + interaction.batch(() => { + state = value; + interaction.report( + reports, + value, + () => state === value, + (value) => { + early.push(value); + commit(value); + if (value === 1) { + commit(2); + commit(3); + } + late.push(value); + } + ); + }); + commit(1); + expect(state).toBe(3); + expect(early).toEqual([1, 3]); + expect(late).toEqual(early); + }); + + it('rejects stale keyed effects before they can replace a valid projection', () => { + const interaction = new Interaction(), + key = {}; + const seen: string[] = []; + interaction.batch(() => { + interaction.effect( + () => seen.push('current'), + key, + 0, + () => true + ); + expect( + interaction.effect( + () => seen.push('stale'), + key, + 0, + () => false + ) + ).toBe(false); + }); + expect(seen).toEqual(['current']); + }); + + it('preserves an explicit input report when a quiet repaint captures the same reading', () => { + const interaction = new Interaction(); + const target = { key: 'a', sources: ['a'], tooltip: { rows: [] } }; + const state = { snapshot: {}, inspect: { target }, select: { target: null } }; + const reports = new InspectionNotifications(() => state, interaction); + const sent: string[] = []; + const deliver = (value: { target: { key: string } | null }) => { + sent.push(value.target?.key ?? 'null'); + }; + reports.capture().emit('inspect', deliver); + sent.length = 0; + interaction.batch(() => { + reports.capture().emit('inspect', deliver, true); + reports.capture().emit('inspect', deliver); + }); + expect(sent).toEqual(['a']); + }); + + it('consumes a Canvas provider once and detaches borrowed facades without resetting the host', () => { + const parent = new ChartSession(); + const target = { key: 'a', sources: ['a'], tooltip: { rows: [] } }; + parent.setModel({ + interactions: [target], + interactionByKey: new Map([['a', target]]), + ariaLabel: 'a' + }); + parent.inspect('a', true); + const canvas = {}, + facade = new ChartSession(); + shareInteraction(canvas, interactionFor(parent)); + claimInteraction(facade, canvas); + shareReading(facade, parent); + expect(facade.selected).toBe(parent.selected); + releaseReading(facade); + facade.reset(); + expect(parent.selected).toBe(target); + expect(facade.selected).toBeNull(); + const next = new ChartSession(); + claimInteraction(next, canvas); + expect(next.selected).toBeNull(); + }); + + it('bounds changing-value feedback and schedules remaining work even when an observer throws', () => { + vi.useFakeTimers(); + try { + const interaction = new Interaction(); + const reports = interaction.channel(0, Object.is); + let value = 0; + const deliver = (next: number) => { + value = next; + if (next < 200) interaction.report(reports, next + 1, () => true, deliver); + if (next === 1) throw undefined; + }; + let failed = false; + try { + interaction.report(reports, 1, () => true, deliver); + } catch (error) { + failed = true; + expect(error).toBeUndefined(); + } + expect(failed).toBe(true); + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(200); + expect(vi.getTimerCount()).toBe(1); + vi.runAllTimers(); + expect(value).toBe(200); + } finally { + vi.useRealTimers(); + } + }); + + it('orders geometry, controls and readings before a viewport report, discarding a superseded window', () => { + const interaction = new Interaction(); + let state = 1; + const reports = interaction.channel(0, Object.is); + const sent: number[] = []; + interaction.batch(() => { + interaction.report( + reports, + 1, + () => state === 1, + (value) => sent.push(value), + false, + 3 + ); + interaction.effect( + () => { + state = 2; + interaction.report( + reports, + 2, + () => state === 2, + (value) => sent.push(value), + false, + 3 + ); + }, + {}, + 2 + ); + }); + expect(sent).toEqual([2]); + }); + + // This reference tracks only the last accepted semantic action. It does not reproduce layout. + it.each( + (['trace', 'profile'] as const).flatMap((family) => + Array.from({ length: 24 }, (_, index) => ({ family, seed: index + 1 })) + ) + )('matches a last-intent model across generated nested commands ($family, seed $seed)', ({ + family, + seed + }) => { + let random = seed; + const next = () => (random = (Math.imul(random, 1664525) + 1013904223) >>> 0); + const session = new ChartSession(); + const hierarchy = new HierarchyInteraction( + session, + (compiled, collapsed) => + compiled.kind === 'trace' + ? createTraceView(compiled, collapsed) + : createProfileView(compiled, collapsed) + ); + const interaction = interactionFor(session); + const compiled = () => { + const nodes = Array.from({ length: 30 }, (_, i) => ({ + id: `${i}`, + name: `${i}`, + ...(i ? { parentId: `${i - 1}` } : {}) + })); + return family === 'trace' + ? compileTrace({ + version: 1, + kind: family, + spans: nodes.map((node) => ({ ...node, startTimeNs: '0', endTimeNs: `${seed}` })) + }) + : compileProfile({ + version: 1, + kind: family, + unit: 'samples', + frames: nodes.map((node) => ({ ...node, selfWeight: `${seed}` })) + }); + }; + hierarchy.replace(compiled()); + hierarchy.activate(); + hierarchy.measure(120, 'full'); + let active: string | null = null, + selected: string | null = null; + let revision = 0; + const reports = new InspectionNotifications( + () => ({ + snapshot: hierarchy.compiled, + inspect: { target: session.active, pinned: session.pinned }, + select: { target: session.selected } + }), + interaction + ); + const check = () => { + expect(session.active?.key ?? null).toBe(active); + expect(session.selected?.key ?? null).toBe(selected); + if (session.active) + expect(session.active.sources[0]).toBe( + hierarchy.compiled!.hierarchy.byId.get(session.active.sources[0].id) + ); + }; + const command = (choice: number) => { + revision++; + if (choice % 6 === 0) { + hierarchy.dismiss(); + active = selected = null; + } else if (choice % 6 === 1) { + hierarchy.replace(compiled()); + active = selected = null; + } else if (choice % 6 === 2) { + hierarchy.viewport.set({ start: 0, end: 1, scrollOffset: choice % 300 }); + } else if (choice % 6 === 3) { + const key = `${family}:${choice % 30}`; + hierarchy.restore({ + version: 1, + kind: family, + pinnedKey: key, + collapsed: [], + viewport: { ...hierarchy.viewport.state } + }); + active = selected = key; + } else { + if (choice % 7 === 0) { + hierarchy.collapse(['0']); + selected = selected === `${family}:0` ? selected : null; + active = active === `${family}:0` ? active : selected; + } + const id = `${choice % 30}`, + pin = choice % 2 === 0; + hierarchy.focus(id, pin); + const key = `${family}:${id}`; + if (pin && selected === key) active = selected = null; + else { + active = key; + selected = pin ? key : null; + } + } + if (choice % 11 === 0) { + const saved = selected; + const collapsed = [...hierarchy.collapsed]; + const viewport = { ...hierarchy.viewport.state }; + hierarchy.deactivate(); + active = selected = null; + hierarchy.restore({ version: 1, kind: family, pinnedKey: saved, collapsed, viewport }); + hierarchy.focus('29', true); + check(); + hierarchy.activate(); + active = selected = saved; + } + hierarchy.measure(choice % 3 ? (choice % 2 ? 120 : 240) : 0, 'full'); + check(); + }; + for (let step = 0; step < 60; step++) { + interaction.batch(() => { + command(next()); + const accepted = revision; + reports.capture().emit('inspect', () => { + if (revision === accepted && next() % 3 === 0) { + command(next()); + reports.capture().emit('select', check); + } + }); + }); + check(); + } + }); +}); From 573b8b2161fecb7ae0328f520e72e86752715958 Mon Sep 17 00:00:00 2001 From: leiysky Date: Thu, 29 Oct 2026 23:11:10 +0800 Subject: [PATCH 6/8] refactor: define inspection contracts and simplify publication --- CHANGELOG.md | 2 +- development/architecture.md | 2 +- development/interaction-transactions.md | 105 ++++---- docs/api.md | 2 + docs/headless.md | 17 +- src/core/inspection-notifications.ts | 6 +- src/core/interaction.ts | 33 ++- src/core/reading-session.ts | 10 +- src/elements/chart-element.ts | 114 ++++---- src/elements/controls.ts | 4 +- src/elements/hierarchy-element.ts | 32 +-- src/families/hierarchy/interaction.ts | 238 +++++++---------- src/runtime/chart-runtime.ts | 4 +- src/runtime/reading-runtime.ts | 4 +- tests/browser/inspection-contract.spec.ts | 252 ++++++++++++++++++ .../core/inspection-notifications.test.ts | 74 ++++- tests/unit/core/interaction.test.ts | 54 +++- 17 files changed, 655 insertions(+), 298 deletions(-) create mode 100644 tests/browser/inspection-contract.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 147728d..df9b977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All significant changes to ScopeView are documented in this file. - `scope-trace` and `scope-flame` resolve `focusNode()` below collapsed ancestors while initialized charts are hidden, retain pin behavior when measurement resumes, and honor newer nested focus, keyboard, dismissal, viewport and restored-state commands. - Viewport and collapse observers can acknowledge the current value without recursive events. Changing-value callback cycles yield to the browser, and nested viewport broadcasts reach listeners in consistent order. -- Inspection callbacks retain exact source snapshots across document replacement and continue valid queued clears after an observer throws. Reentrant layout and cleanup callbacks cannot overwrite newer data, and removed frames cannot emit a stale ready event. +- Inspection callbacks retain exact source snapshots across document replacement and continue valid queued clears after an observer throws. Reentrant rendering and cleanup callbacks cannot apply obsolete state or geometry, and removed frames cannot emit a stale ready event. ## v0.1.1 (2026-10-29) diff --git a/development/architecture.md b/development/architecture.md index 5627231..f144bfd 100644 --- a/development/architecture.md +++ b/development/architecture.md @@ -57,7 +57,7 @@ The internal `RuntimeLifecycle` base owns Canvas measurement, layout invalidatio Family-owned inspection context projectors expose exact source values, field roles and ancestry. Context types and opaque document identity live in `core/inspection-context.ts`; completed scenes retain their compiled source through `core/scene-snapshot.ts`. A pending document does not relabel an older painted frame. Lazy source clones retain exact original values and ancestry when hosts keep a context after later updates. -`Interaction` in `core/interaction.ts` runs keyed, validated presentation effects before controls, reading reports, viewport reports, portable state and ready. `InspectionNotifications` adapts inspect/select identity (`snapshot`, `key`, `series`, `pinned`) and independent channel invalidation onto that same pump. Delivery records identity before invoking the host; nested commands commit immediately while their reports wait for the current broadcast. Same-value acknowledgements quiesce, changing-value feedback yields after bounded work, and observer failures preserve other valid reports before propagating. +`Interaction` in `core/interaction.ts` runs keyed, validated presentation effects before controls, reading reports, viewport reports, portable state and ready. Channels bind this fixed compatibility order at creation; individual state reports do not assign scheduling priorities. `InspectionNotifications` adapts inspect/select identity (`snapshot`, `key`, `series`, `pinned`) and independent invalidation onto that same pump. Delivery records identity before invoking the host; nested commands commit immediately while reports wait for the current broadcast. Captured context can therefore describe an earlier state than current getters. Pending reports coalesce per channel, without a total inspect/select order across nested commands. Same-value acknowledgements quiesce, changing-value feedback yields after bounded work, and observer failures preserve other valid reports before propagating. Hierarchy focus resolves against compiled semantic targets and expands ancestors before accepting the command. Hidden charts retain the last positive measurement and a complete positioning intent. Newer dismissal, viewport, restore, collapse or document commands cancel older reveal work. One explicit Canvas Escape owner commits dismissal; runtime and shell fallback skip that Canvas path so other listeners can restore newer state without a second clear. diff --git a/development/interaction-transactions.md b/development/interaction-transactions.md index faade6b..c5ee1b6 100644 --- a/development/interaction-transactions.md +++ b/development/interaction-transactions.md @@ -1,82 +1,77 @@ -# Interaction transactions +# Inspection transactions -Status: implemented. The transaction owner replaces separate inspection queues, pending pin/reveal fields and viewport revision queues. Public factory signatures, session/runtime methods, component events and inspection context types remain unchanged. This document records the internal ownership and the tested behavioral contract. +Status: implemented. The public [inspection notification contract](../docs/headless.md#inspection-notifications) is the compatibility boundary for headless callbacks and component events. This document maps that contract to state ownership, delivery and regression coverage. Factory signatures, session/runtime methods, event details and context types are unchanged. -## Ownership +## Contract before mechanism -Each mounted chart has one DOM-free `Interaction` in `src/core/interaction.ts`. Its canonical reading record stores semantic keys, hover, selection, series, keyboard mode, model version and reading intent. `ReadingSession` implements pure synchronous reducers over that record; the public `ChartSession` adds the existing freeze/compare, lock, visibility and restoration policies. A component and its runtime share the record through session facades rather than copying selection between independent sessions. +Inspection has three different notions of time: the accepted logical state, the last completed frame and a notification's captured source snapshot. Synchronous setters update logical getters immediately, including inside a callback. A frame describes what actually painted. A notification's context retains the source snapshot it captured, even when an earlier listener has already accepted another document. A getter is therefore allowed to return B while the current event still describes A. -`HierarchyInteraction` in `src/families/hierarchy/interaction.ts` owns the compiled hierarchy, expanded navigation, collapsed nodes, desired viewport, last positive measurement and complete focus intent. It uses the same session and transaction owner as its component. Family geometry stays in the existing layout and viewport primitives. Runtime adapters translate input and measurements; component shells own controls and DOM presentation. +The library completes one callback or DOM broadcast before publishing its nested successor. It can discard superseded pending state reports, but does not rewrite a broadcast already in progress. Inspect and select have independent identities and invalidation. Hover cannot cancel an otherwise current selection report. A fresh command preserves its entry's inspect/select enqueue order; reentry can replace a pending value in an older queue position, so different commands do not promise a total inspect/select order or an atomic notification pair. Quiet repaints do not invent source changes. Explicit component input can repeat a reading; headless callbacks deduplicate unchanged identity. Both remain coalescible state reports rather than an action log. -Canvas providers are consumed once when a runtime is constructed. Releasing a borrowed runtime facade detaches it before resource disposal, so a retained old session cannot mutate the component's next lifecycle. An unrelated runtime subsequently created on the same Canvas receives its own owner. +The contract deliberately retains synchronous nested setters. A strict actor mailbox would postpone their effects on getters and change existing behavior. Svelte's synchronous state write with ordered subscriber delivery is a useful reference for this choice. ECharts separately establishes an update-cycle boundary before user-visible events. Neither reference determines ScopeView's exact lifecycle policy or compatibility order; those are recorded below and tested through the public entries. -## Commit and effect boundary +| Domain | Identity and guarantee | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Source | A compiled document owns its exact strings and an opaque snapshot identity; an equal key in another document does not identify the same data. | +| Reading | Inspect/select compare snapshot, key, series and observable pin state; empty readings have one stable identity. | +| Intent | Accepted focus, keyboard, restoration or dismissal can supersede old work even when the resulting reading identity is unchanged. | +| Frame | A completed scene retains the compiled source that produced it; a pending document never relabels that frame. | +| Presentation | DOM and geometry project accepted state; stale presentation cannot restore an older semantic selection or viewport. | +| Lifetime | Loading, component disconnection and silent headless disposal retain their existing entry-specific policies. | -```mermaid -flowchart LR - Input[Methods and DOM input] --> Reduce[Pure semantic reducers] - Reduce --> Commit[Commit logical state] - Commit --> Effects[Validated layout and DOM effects] - Effects --> Reports[Captured channel reports] - Reports --> Host[Host callbacks] - Host -->|Nested command| Reduce -``` +A context contains detached source values, not a live lookup into the latest chart. Its lazy fields close over the captured source owner. Geometry on an interaction target is not the context's source identity. Consumers may mutate their copied context, but the library does not clone an event detail separately for each listener of that same DOM event. -`Interaction.batch()` encloses a complete command. Reducers do not call application code, manipulate DOM or publish reports. Logical getters reflect the accepted state synchronously. Effects wait until the outer command finishes. A nested command issued by a callback commits immediately; its effects and reports run after the current callback or DOM broadcast completes. Older commands therefore have no semantic write left to resume after notification. +## State ownership -Effects carry a key and a currentness predicate. Admission rejects already stale work before it can replace a valid keyed effect. Execution revalidates the predicate. Operations that can call application code, including custom layout/renderer hooks and DOM focus/popover work, require revalidation before applying further results. Model versions protect against a custom layout adopting a newer semantic model without changing the runtime's own layout revision. Stale layout results and failures cannot replace that newer model. +Each mounted chart has one DOM-free `Interaction` in `src/core/interaction.ts`. Its canonical reading record stores keys, hover, selection, series, keyboard mode, model version and intent. `ReadingSession` implements synchronous reducers; the public `ChartSession` adds freeze/compare, lock, visibility and restoration policy. Component and runtime session facades address the same record. -The pump chooses the earliest nonempty phase after each effect, so nested presentation work finishes before subsequent reports. Existing entry-specific inspect/select ordering remains in the adapters. +`HierarchyInteraction` owns compiled target resolution, expanded navigation, collapsed nodes, viewport, positive measurement and full positioning intent. Its private fields are mutated only by trusted synchronous reducers. Preparation that can fail completes before assigning the derived view. Shallow-copying an internal record on every field change provides no isolation: isolation belongs to captured outward payloads and validated effect tokens. Public sets, view state and viewport results remain copied or read-only projections. -| Phase | Responsibility | -| ----- | --------------------------------------------------- | -| 0 | Resource cleanup, layout and reading presentation | -| 1 | Component controls | -| 2 | Inspect, select, collapse, visibility and errors | -| 3 | Hierarchy viewport reports | -| 4 | Portable view-state reports | -| 5 | Ready reports for a still-current completed frame | +Canvas providers are consumed once at runtime construction. A borrowed facade detaches before disposal, so a retained old session cannot write into the component's next lifecycle. The scene snapshot registry is a separate frame projection, not another selection owner. -A report marks its observable identity as published before invoking the host. Repeated same-value viewport or collapse acknowledgements terminate through value comparison, even if accepting them changes an internal intent. Toggle actions retain their existing semantics. Explicit inspection input can force a report where the existing API requires it; a quiet repaint cannot displace that pending report merely by capturing the same reading. +## Commit, project and publish -The pump runs at most 64 queued effects per synchronous flush. A host that keeps issuing different states yields to a timer task, allowing input, painting and other tasks to run. This bounds library draining; it does not attempt to terminate arbitrary host recursion inside a callback. A throwing observer does not roll back the committed state or abandon other valid work. Remaining work is scheduled before the first thrown value propagates, including when that value is `undefined`. +A command runs in `Interaction.batch()`. State reducers finish before queued effects or reports invoke host code. A nested callback command can commit immediately; its projection and reports wait for the current delivery to return. There is no older semantic write left after that command's notifications. -## Semantic targets and hidden geometry +Effects validate both before admission and before execution. A rejected stale effect cannot overwrite a valid keyed projection. Adapters revalidate after operations that may invoke host code, including custom renderer hooks, focus and popover work. Metric dismissal, refresh and reading use the same captured presentation path; showing and clearing do not maintain different queues or recapture a newer reading on behalf of an obsolete caller. -`focusNode()` resolves identifiers against the compiled hierarchy and expands ancestors before adopting canonical navigation. It applies the existing pin policy and commits the full focus intent before painting, control events or viewport callbacks. A node absent from the old painted scene can therefore be accepted. Nested focus or keyboard commands replace the older reading intent before its remaining effects execute. +The notification order is finite compatibility policy, not an extensible priority scheduler. A state channel binds its phase once when created. Individual reports supply values and validity, not a new ordering choice. The pump revisits pending projection work after each callback, before continuing to later reports. -Zero dimensions are not a measurement. An initialized hidden chart retains its last positive height and unresolved positioning intent. A positive measurement derives reveal bounds from the current hierarchy and surviving focus intent. Newer viewport commands, dismissal, restoration, collapse or document replacement cancel obsolete positioning. The intent does not depend on finding the requested node in an old scene. +| Phase | Responsibility | Observable reason to retain the boundary | +| ----- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| 0 | Cleanup, layout and reading presentation | Project the accepted state before observers inspect the corresponding UI. | +| 1 | Controls | A controls callback can dismiss or replace a reading before its pending report starts. | +| 2 | Reading and component change/error events | Preserve fresh entry-specific enqueue order and independent per-channel coalescing during reentry. | +| 3 | Viewport | Earlier reading callbacks can supersede a focus or viewport; observers receive only a valid window. | +| 4 | Portable view state | Report state after preceding selection, collapse and viewport callbacks have had their opportunity to change it. | +| 5 | Ready | Earlier callbacks can remove or replace the frame and cancel its success notification. | -Loading retains its existing public policy: a component with an unavailable runtime preserves its saved portable state, and `focusNode()` does not invent a new active runtime. This differs from an initialized hidden chart, whose semantic model remains available. Before-first-paint, loading, disconnection and silent headless disposal keep their existing compatibility fixtures. +The shared pump records a channel's published identity before calling the host. Inspection invalidation versions remain per channel because an equal-value restoration can cancel a captured projection without changing the last value already delivered. This is distinct from value deduplication. Controls and lifecycle adapters retain their existing payloads and policies. -Canvas has one explicit Escape owner. It commits dismissal even for a noncancelable event, without suppressing other Canvas listeners that may restore newer state. The runtime skips dismissal when the Canvas owner is present. Shell fallback bubbles after toolbar widgets and skips the Canvas path, so it cannot clear a newer restore a second time. +Ordinary work settles synchronously. The current pump yields to a timer task after 64 jobs; that threshold is an internal fairness choice, not public API or a proof that changing-value host feedback converges. Same-value acknowledgements quiesce by equality. Observer errors preserve committed state and other eligible work; remaining jobs are scheduled before the first thrown value propagates. DOM listener exceptions follow browser dispatch semantics. -## Snapshot and channel integrity +## Geometry and lifecycle -`captureScene()` stamps each completed scene with its compiled source snapshot. The `scene` getter continues to describe the last completed frame while a new document awaits paint. Pointer readings and exact context projection use the snapshot of that frame. A semantic target introduced by a new document waits for a compatible frame before a frame-dependent report; identical keys never cause old raw values to be reinterpreted against new data. +`focusNode()` resolves against the compiled hierarchy and expands ancestors before committing the existing pin behavior. It does not need the target to exist in the old scene. Zero dimensions do not replace a valid measurement. An initialized hidden chart retains its last positive height and an intent containing target, source snapshot and reading intent. Positive measurement applies geometry only while that intent remains current. -`InspectionNotifications` adapts inspect/select onto the shared pump. Identity includes snapshot, target key, series and observable pin state. Empty readings have one stable identity. Each channel has its own invalidation version, so unrelated hover changes cannot cancel a valid selection report. Captured payloads retain their original snapshot and exact values when listeners keep them after later updates. +Newer viewport, dismissal, restoration, collapse or document commands cancel obsolete positioning. Loading with an unavailable runtime preserves the existing saved-state policy; it does not behave like an initialized hidden chart. Before-first-paint, loading and disconnection keep their compatibility fixtures. One explicit Canvas Escape owner accepts dismissal, including noncancelable events, while runtime and shell fallback skip that Canvas path so later listeners can restore a newer state. -Component viewport, controls and view-state adapters use the same publication protocol with their existing payloads. All listeners complete one DOM broadcast before the next report begins. A nested command can update getters immediately, while superseded pending intermediate reports are dropped. Ready delivery additionally validates connection, loading/error state and the completed source frame. A control callback that removes or replaces that frame cannot leave a successful ready event behind. +Runtime layout revisions and semantic model versions reject stale custom layout results and failures. Document replacement commits the new source and clears controlled bindings before cleanup callbacks run. Ready additionally validates the completed frame, current source, connection and loading/error state. Headless disposal cancels pending callbacks silently; component disconnection preserves final clears. Already retained contexts remain readable after either lifecycle ends. -Document replacement commits the new source and clears controlled bindings before cleanup effects invoke host callbacks. A cleanup callback can submit another document; no older compile or render tail may overwrite it. Invalid headless updates continue to preserve the existing figure. +## Verification and package boundaries -## Compatibility and package boundaries +| Contract | Regression coverage | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Snapshot versus current getters | `inspection-contract.spec.ts` checks two independent component listeners and all 17 entries retaining unread context after nested replacement/disposal. | +| State publication and order | Coordinator/kernel units cover immediate nested writes, coalescing, channel independence, fixed channel order, exceptions and bounded feedback. | +| Semantic focus and stale geometry | `interaction-transactions.spec.ts`, `hierarchy-viewport.spec.ts` and 48 seeded independent last-intent reference runs cover hidden/collapsed focus, measurements and superseding commands. | +| Projection and lifecycle | Popover/renderer regressions, hidden replacement readiness, ready cancellation, stale custom layouts and runtime reuse cover effects that can reenter or outlive their source. | +| API and entry compatibility | Existing family matrices, public type consumers, exported-member comparison and installed-package acceptance cover signatures and JavaScript facade behavior. | -The shared kernel, reading state and scene snapshot registry remain internal and DOM-free. Public `ChartSession` retains its constructor, getters, methods and pin/toggle behavior. Runtime and component API names and event details remain unchanged. Metric factory facades retain detached `update`, `refresh` and `dispose` calls and their own enumerable `theme` accessor; declaration compatibility alone would not cover those JavaScript behaviors. +The kernel, sessions and frame registry remain DOM-free. Shared reading DOM stays in `src/ui/`; headless views do not import component shells. Fixed metric entries omit aggregate switching, scalar stays HTML-only, registration is explicit and all existing gzip limits remain gates. Painter tokens, source colors, geometry and reading appearance are unchanged. -Fixed metric entries share the plotted metric owner without retaining unreachable aggregate switching. The general metric entry alone switches between HTML aggregates and plots. Scalar remains HTML-only. Headless entries do not import component shells, registration stays explicit, and the standalone bundle consumes the built headless entry. Painter tokens, source colors, geometry and reading appearance are unchanged. Fixed bundled-consumer limits remain acceptance gates. +## Design references -## Regression coverage - -| Layer | Coverage | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -| `tests/unit/core/interaction.test.ts` | Pump ordering, same-value feedback, stale admission, exceptions, session lifetime and 48 seeded trace/profile command runs | -| Inspection coordinator and session units | Independent channels, exact identity, explicit input, pin/lock/compare policy and restoration | -| `interaction-transactions.spec.ts` | Hidden/collapsed focus, two heights, pin modes, nested focus/keyboard, viewport order, feedback, Escape and ready cancellation | -| `inspection-transactions-matrix.spec.ts` | Popover callback replacement across all 17 browser entries, committed cleanup state and detached metric facade methods | -| `runtime-lifecycle.spec.ts` | Stale custom layout results/errors, reused Canvas lifetime, keyboard reentry, hidden measurement and disposal | -| Existing browser family/entry matrices | Exact readings, snapshots, source identity, API lifecycle, navigation and presentation compatibility | -| Type consumers and bundled/package tests | Public declarations, DOM-free root, explicit registration, optional capabilities and installed consumers | - -The seeded command tests use an independent last-intent reference projection across replacements, collapse, focus, viewport commands, zero/positive measurements, restoration, lifecycle and nested callback commands. Browser regressions exercise actual Canvas, popover, focus and event ordering. These checks establish concrete invariants and observed counterexamples; they are not a claim that future application code cannot introduce a new defect. +- [Svelte store implementation](https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/store/shared/index.js): synchronous writes and ordered subscriber delivery. +- [ECharts update-cycle rules](https://github.com/apache/echarts/blob/master/src/core/echarts.ts): a defined update boundary before user-visible events and delayed actions during updates. +- [Redux subscription contract](https://redux.js.org/api/store#subscribelistener): nested updates and the distinction between observing state and seeing every intermediate action. diff --git a/docs/api.md b/docs/api.md index c7927aa..6ea9417 100644 --- a/docs/api.md +++ b/docs/api.md @@ -101,6 +101,8 @@ chart.addEventListener('scope-error', (event) => { For every family, `scope-select` describes the persistent selection: its `key` and `context` are `null` and `pinned` is `false` when keyboard navigation or dismissal clears selection, even while `scope-inspect` reports a transient reading. When data is replaced, preserved selections emit updated context after the replacement paints, and removed or cleared selections emit `null`. Track inspection events rather than treating `scope-ready`, which also fires after repainting, as a selection invalidation signal. +The shared [inspection notification contract](headless.md#inspection-notifications) defines snapshot lifetime, synchronous nested setters, event/getter consistency, coalescing, callback failures and notification order. In particular, an event keeps its captured context even when an earlier listener has already committed a newer state that public getters now expose. + | Event | Detail | | ------------------------------- | ------------------------------------------------------------------ | | `scope-ready` | Rendered visualization and row/node counts; may fire after repaint | diff --git a/docs/headless.md b/docs/headless.md index f43fa39..3763653 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -86,7 +86,22 @@ Metric timestamps and numeric cells retain the supplied strings, including sub-m `snapshotId` remains stable across inspection, selection, resizing, themes and visibility changes for one compiled document, and changes when data is replaced. A compatible persistent selection reconciles to the replacement and reports the new `snapshotId` and values; clearing or dismissing a reading or selection reports `null` for both arguments. The [headless example](../examples/headless.html) displays the callback's JSON beside each family. -If a callback throws, valid queued notifications still finish before the first thrown value propagates. Disposing the view cancels pending callbacks. +## Inspection notifications + +The following contract also applies to the Web Components' `scope-inspect` and `scope-select` events. A context describes the source snapshot captured for that notification; public getters describe the latest accepted logical state. The two can differ during a callback that submits a newer command. Synchronous setters and methods commit before returning; painting and asynchronous report downloads have their own completion boundaries. + +In headless callbacks, `target` is a library-owned interaction record. Use the separate, detached `context` value for retaining or mutating application-owned source data. + +For example, listener 1 receives a notification for A and synchronously restores B. Its next `viewState` read sees B. Listener 2 still receives the same event for A, and its `viewState` read also sees B. Notifications for B follow after the current callback and DOM broadcast finish. Read the event's context to interpret that event, and use getters when you need the latest state. + +- A context's key, raw values, series/time fields and ancestors belong to one `snapshotId`. Retaining it, including reading lazy source fields after an update or disposal, keeps that snapshot. Reusing a key in another document does not reuse its snapshot identity. Treat event details as snapshot data; consumers that mutate a detail share that mutation with other listeners of the same event. +- Inspection and selection are state notifications, not a log of every input or intermediate transition. Superseded pending values can be coalesced. Once a notification starts, the library finishes that callback or DOM broadcast before delivering its successor. Selection is independent of transient hover, so changing hover does not cancel an otherwise current selection report. +- Quiet layout, theme and size changes do not repeat an unchanged source reading. Explicit component reading input can repeat `scope-inspect` even when its identity is unchanged; headless callbacks deduplicate that identity. Pending repeated reports can still coalesce. Same-value viewport or collapse acknowledgements do not create another change event. +- An already painted frame keeps its originating snapshot while a replacement awaits paint. A report never combines an old frame's source with the replacement's fields or ancestry. Compatible selections reconcile according to the entry's existing update policy and publish replacement context after a compatible paint. +- A callback may dismiss, restore, update or dispose its chart. Those commands invalidate older pending work in the affected channels. Headless disposal is silent and cancels pending callbacks; components preserve their final-clear behavior on disconnection. Retained contexts remain usable. +- Headless callback failures do not roll back committed state. Other eligible notifications in the same synchronous flush run before the first thrown value propagates. Changing-value feedback yields to a later task after bounded work; remaining work is scheduled before an error propagates. DOM listener exceptions follow browser event-dispatch behavior. The queue does not guarantee that a host loop producing different values will converge. + +Component notifications preserve this relative order: controls, inspection/selection and collapse/visibility, viewport, portable view state, then ready. For a fresh reading, components enqueue inspect before select; headless views enqueue select before inspect. Reentry can replace a pending channel's value while retaining its earlier queue position, so different commands do not have a total inspect/select order or an atomic pair of notifications. Ready follows a completed frame from the current source and can repeat on repaint; a hidden container must paint its replacement before reporting that replacement as ready. Track inspection events for selection invalidation. ## Load layouts on demand diff --git a/src/core/inspection-notifications.ts b/src/core/inspection-notifications.ts index 090c8cc..e66c357 100644 --- a/src/core/inspection-notifications.ts +++ b/src/core/inspection-notifications.ts @@ -1,4 +1,4 @@ -import type { InteractionChannel, Interaction } from './interaction.js'; +import { channel, type InteractionChannel, type Interaction } from './interaction.js'; import type { ChartInteraction } from './scene.js'; export type InspectionChannel = 'inspect' | 'select'; @@ -59,8 +59,8 @@ export class InspectionNotifications { this.#read = read; this.#interaction = interaction; this.#reports = { - inspect: interaction.channel(empty, same), - select: interaction.channel(empty, same) + inspect: channel(empty, same), + select: channel(empty, same) }; } diff --git a/src/core/interaction.ts b/src/core/interaction.ts index 555e6a1..bdd43f3 100644 --- a/src/core/interaction.ts +++ b/src/core/interaction.ts @@ -1,6 +1,14 @@ type Effect = readonly [run: () => void, current: (() => boolean) | undefined]; const nonempty = (queue?: ReadonlyMap) => queue?.size; +// Fixed compatibility order, not caller-defined scheduling priorities. +export const controlsPhase = 1, + readingPhase = 2, + viewportPhase = 3, + viewStatePhase = 4, + readyPhase = 5; +type NotificationPhase = 1 | 2 | 3 | 4 | 5; + /** One commit/effect boundary per chart. State transitions never call observers. */ export class Interaction { declare reading?: unknown; @@ -18,7 +26,12 @@ export class Interaction { } /** A key coalesces obsolete effects. A broadcast always runs to completion. */ - effect(run: () => void, key: object = {}, phase = 0, current?: () => boolean): boolean { + effect( + run: () => void, + key: object = {}, + phase: 0 | NotificationPhase = 0, + current?: () => boolean + ): boolean { if (current && !current()) return false; this.#work[phase] ??= new Map(); this.#work[phase].set(key, [run, current]); @@ -26,16 +39,12 @@ export class Interaction { return true; } - channel(value: T, same: (a: T, b: T) => boolean): InteractionChannel { - return { value, same }; - } report( channel: InteractionChannel, value: T, current: () => boolean, deliver: (value: T) => void, - force = false, - phase = 2 + force = false ): boolean { const ready = () => current() && (force || !channel.same(channel.value, value)); return this.effect( @@ -44,7 +53,7 @@ export class Interaction { deliver(value); }, channel, - phase, + channel.phase, ready ); } @@ -83,6 +92,16 @@ export class Interaction { export interface InteractionChannel { value: T; same(a: T, b: T): boolean; + readonly phase: NotificationPhase; +} + +/** A channel owns its published value, comparison and fixed delivery phase. */ +export function channel( + value: T, + same: (a: T, b: T) => boolean, + phase: NotificationPhase = readingPhase +): InteractionChannel { + return { value, same, phase }; } // Public session objects are facades; sharing a session never duplicates its state owner. diff --git a/src/core/reading-session.ts b/src/core/reading-session.ts index 6ff75dc..1958e73 100644 --- a/src/core/reading-session.ts +++ b/src/core/reading-session.ts @@ -74,16 +74,16 @@ export class ReadingSession { ): boolean { const state = editReading(this); const value = key && state.model?.interactionByKey.has(key) ? key : null; - state.hovered = pin || keyboard ? null : value; - if (state.keyboard && key === null && !pin && !keyboard) return false; + const explicit = pin || keyboard; + state.hovered = explicit ? null : value; + if (state.keyboard && key === null && !explicit) return false; if (pin && value === state.selected) clearReading(state); else { - if (pin || keyboard) { + if (explicit) { state.selected = pin ? value : null; state.selectedSeries = pin ? series : null; - state.intent = {}; } - if (state.active !== (value ?? state.selected)) state.intent = {}; + if (explicit || state.active !== (value ?? state.selected)) state.intent = {}; state.active = value ?? state.selected; state.series = value ? series : state.selectedSeries; state.keyboard = keyboard; diff --git a/src/elements/chart-element.ts b/src/elements/chart-element.ts index 159d655..451f180 100644 --- a/src/elements/chart-element.ts +++ b/src/elements/chart-element.ts @@ -11,7 +11,13 @@ import { prepareAggregateValues } from '../families/metrics/prepared.js'; import { legendReadings } from '../families/metrics/readings.js'; import type { ChartRenderer } from './plot-contract.js'; import type { ChartInteraction, Inspect, Position } from '../core/scene.js'; -import { interactionFor } from '../core/interaction.js'; +import { + channel, + interactionFor, + readingPhase, + viewStatePhase, + readyPhase +} from '../core/interaction.js'; import { restoredKey, restoreReading, @@ -135,15 +141,13 @@ export class ScopeChartElement extends HTMLElement { }), interactionFor(this.#session) ); - #visibilityReports = interactionFor(this.#session).channel( - [], - (a, b) => JSON.stringify(a) === JSON.stringify(b) - ); + #visibilityReports = channel([], (a, b) => JSON.stringify(a) === JSON.stringify(b)); #readingEffect = {}; #stateReason: ViewStateChangeDetail['reason'] = 'reconcile'; - #stateReports = interactionFor(this.#session).channel( + #stateReports = channel( null, - (a, b) => JSON.stringify(a?.state) === JSON.stringify(b?.state) + (a, b) => JSON.stringify(a?.state) === JSON.stringify(b?.state), + viewStatePhase ); #linkedHighlight: LinkedHighlight | null = null; #stateTransaction = false; @@ -288,9 +292,7 @@ export class ScopeChartElement extends HTMLElement { () => snapshot === this.#compiled && key === JSON.stringify(this.viewState), (detail) => { this.#dispatch('scope-view-state-change', detail!); - }, - false, - 4 + } ); } /** Ephemeral trusted binding; never serialized into a chart document. */ @@ -527,16 +529,18 @@ export class ScopeChartElement extends HTMLElement { if ( snapshot === this.#compiled && (name !== 'scope-ready' || - (this.isConnected && + (snapshot && + this.isConnected && !this.loading && !this.error && !this.#error && - scene === this.#plot?.model)) + scene === this.#plot?.model && + (snapshot.config.mapping.kind === 'aggregate' || sceneSnapshot(scene) === snapshot))) ) this.#dispatch(name, detail); }, {}, - name === 'scope-ready' ? 5 : 2 + name === 'scope-ready' ? readyPhase : readingPhase ); } #dispatch(name: K, detail: ScopeChartEventDetails[K]) { @@ -697,13 +701,6 @@ export class ScopeChartElement extends HTMLElement { if (compiled !== this.#compiled || revision !== this.#revision || !this.isConnected) return; this.#session.setModel(model); - if (this.#session.active) { - this.#showInspection(null); - } else { - plot.focus(null); - hideInspection(this.#el('.tooltip')); - this.#el('.inspect').removeAttribute('data-active'); - } this.#el('.viewport').setAttribute( 'aria-label', title || plot.model?.ariaLabel || 'ScopeDB chart' @@ -713,6 +710,7 @@ export class ScopeChartElement extends HTMLElement { } const batch = this.#notifications.capture(); this.#status('ready', null); + if (this.#plot) this.#showInspection(null, false, true, batch); this.#reportInspection(batch); if (compiled !== this.#compiled || !this.isConnected) return; this.#stateChanged(this.#stateReason); @@ -844,12 +842,7 @@ export class ScopeChartElement extends HTMLElement { this.#selectedNeighbors = []; this.#session.clearInspection(); const batch = this.#notifications.capture(); - interactionFor(this.#session).effect(() => { - this.#plot?.focus(null); - this.#el('.inspect').removeAttribute('data-active'); - hideInspection(this.#el('.tooltip')); - this.#el('.live').textContent = ''; - }, this.#readingEffect); + this.#showInspection(null, false, false, batch); this.#reportInspection(batch); this.#stateChanged(reason); }); @@ -882,32 +875,49 @@ export class ScopeChartElement extends HTMLElement { inspectionFocus(this.#root).focus({ preventScroll: true }); - if (batch.current('inspect')) - this.#showInspection(position, keyboard, position !== null || previousKey !== value.key); + this.#showInspection( + position, + keyboard, + position !== null || previousKey !== value.key, + batch + ); this.#reportInspection(batch, true); this.#stateChanged(); }); } - #showInspection(position: Position | null, keyboard = false, place = true) { - const batch = this.#notifications.capture(); - interactionFor(this.#session).effect(() => { - if (batch.current('inspect')) this.#presentInspection(position, keyboard, place); - }, this.#readingEffect); - } - #presentInspection(position: Position | null, keyboard: boolean, place: boolean) { - const value = this.#session.active; - if (!value) return; - this.#plot?.focus( - value, - this.#session.pinned, - this.#session.series, - this.#session.selected?.key ?? null + #showInspection( + position: Position | null, + keyboard = false, + place = true, + batch = this.#notifications.capture() + ) { + interactionFor(this.#session).effect( + () => this.#presentInspection(batch, position, keyboard, place), + this.#readingEffect, + 0, + () => batch.current('inspect') ); + } + #presentInspection( + batch: ReturnType['capture']>, + position: Position | null, + keyboard: boolean, + place: boolean + ) { + const { target: value, pinned, series } = batch.state.inspect; + this.#plot?.focus(value, pinned, series, this.#session.selected?.key ?? null); + if (!batch.current('inspect')) return; this.#el('.inspect').toggleAttribute('data-active', this.#session.selected !== null); const tooltip = this.#el('.tooltip'); + if (!value) { + hideInspection(tooltip); + if (batch.current('inspect')) this.#el('.live').textContent = ''; + return; + } if (this.#ui.tooltip === false) { hideInspection(tooltip); - if (keyboard) this.#el('.live').textContent = inspectionText(value); + if (keyboard && batch.current('inspect')) + this.#el('.live').textContent = inspectionText(value); return; } const neighbors = ( @@ -919,16 +929,17 @@ export class ScopeChartElement extends HTMLElement { return target ? [target] : []; }); dockInspection(tooltip, this.#session.pinned && neighbors.length > 1); + if (!batch.current('inspect')) return; renderInspection( tooltip, value, - this.#session.pinned, + pinned, () => { this.#clearInspection(); inspectionFocus(this.#root).focus(); }, { - series: this.#session.series, + series, locked: this.#session.tooltipLocked, onLock: () => { this.tooltipLocked = !this.tooltipLocked; @@ -937,8 +948,9 @@ export class ScopeChartElement extends HTMLElement { onChoose: (key) => this.#chooseObservation(key, neighbors) } ); - if (place || keyboard) this.#placeTooltip(position); - if (keyboard) this.#el('.live').textContent = inspectionText(value); + if (!batch.current('inspect')) return; + if (place || keyboard) this.#placeTooltip(position, batch); + if (keyboard && batch.current('inspect')) this.#el('.live').textContent = inspectionText(value); } #chooseObservation(key: string, neighbors: readonly ChartInteraction[]) { if (key === this.#session.selected?.key) return; @@ -949,11 +961,13 @@ export class ScopeChartElement extends HTMLElement { .find((button) => button.dataset.key === key) ?.focus({ preventScroll: true }); } - #placeTooltip(position: Position | null = null) { - if (!this.#session.active || this.#el('.tooltip').hidden) return; + #placeTooltip(position: Position | null = null, batch = this.#notifications.capture()) { + const value = batch.state.inspect.target; + if (!value || this.#el('.tooltip').hidden) return; const tooltip = this.#el('.tooltip'); if (tooltip.classList.contains('docked')) return; - const anchor = position ?? this.#plot?.anchor(this.#session.active!) ?? { x: 0, y: 0 }; + const anchor = position ?? this.#plot?.anchor(value) ?? { x: 0, y: 0 }; + if (!batch.current('inspect')) return; const canvas = this.#el('.canvas').getBoundingClientRect(); const rect = this.#plot?.model?.rect; positionInspection( diff --git a/src/elements/controls.ts b/src/elements/controls.ts index d94f7f1..e4c91b1 100644 --- a/src/elements/controls.ts +++ b/src/elements/controls.ts @@ -1,4 +1,4 @@ -import type { Interaction } from '../core/interaction.js'; +import { controlsPhase, type Interaction } from '../core/interaction.js'; import type { ComponentOptions, ScopeChartAction } from '../families/component-options.js'; export type { ScopeChartAction } from '../families/component-options.js'; @@ -107,7 +107,7 @@ export class ControlEvents { ); }, this.#key, - 1 + controlsPhase ); } } diff --git a/src/elements/hierarchy-element.ts b/src/elements/hierarchy-element.ts index 61076c4..9a0eb4d 100644 --- a/src/elements/hierarchy-element.ts +++ b/src/elements/hierarchy-element.ts @@ -14,7 +14,15 @@ import { import type { ViewportState } from '../core/viewport.js'; import { ChartSession } from '../core/session.js'; import { readingState, restoredKey, shareReading, releaseReading } from '../core/reading-state.js'; -import { interactionFor, shareInteraction } from '../core/interaction.js'; +import { + channel, + interactionFor, + shareInteraction, + readingPhase, + viewportPhase, + viewStatePhase, + readyPhase +} from '../core/interaction.js'; import { HierarchyInteraction } from '../families/hierarchy/interaction.js'; import type { Entity, EntityDocument, Hierarchy } from '../families/hierarchy/model.js'; import { @@ -149,15 +157,13 @@ export class HierarchyElement< select: { target: project(this.#session.selected) } }; }, this.#kernel); - #viewportReports = this.#kernel.channel({ start: 0, end: 1, scrollOffset: 0 }, sameViewport); - #stateReports = this.#kernel.channel( + #viewportReports = channel({ start: 0, end: 1, scrollOffset: 0 }, sameViewport, viewportPhase); + #stateReports = channel( null, - (a, b) => JSON.stringify(a?.state) === JSON.stringify(b?.state) - ); - #collapseReports = this.#kernel.channel( - [], - (a, b) => JSON.stringify(a) === JSON.stringify(b) + (a, b) => JSON.stringify(a?.state) === JSON.stringify(b?.state), + viewStatePhase ); + #collapseReports = channel([], (a, b) => JSON.stringify(a) === JSON.stringify(b)); #readingEffect = {}; #stateReason: ViewStateChangeDetail['reason'] = 'reconcile'; #stateTransaction = false; @@ -286,9 +292,7 @@ export class HierarchyElement< () => snapshot === this.#compiled && key === JSON.stringify(this.viewState), (detail) => { this.#dispatch('scope-view-state-change', detail!); - }, - false, - 4 + } ); } get loading() { @@ -478,7 +482,7 @@ export class HierarchyElement< this.#dispatch(type, detail); }, {}, - type === 'scope-ready' ? 5 : 2 + type === 'scope-ready' ? readyPhase : readingPhase ); } #dispatch>( @@ -509,9 +513,7 @@ export class HierarchyElement< snapshot === this.#compiled && report === this.#interaction.report && sameViewport(value, this.viewport), - (viewport) => this.#dispatch('scope-viewport-change', { ...viewport }), - false, - 3 + (viewport) => this.#dispatch('scope-viewport-change', { ...viewport }) ); } #schedule() { diff --git a/src/families/hierarchy/interaction.ts b/src/families/hierarchy/interaction.ts index 6585404..39cf0e3 100644 --- a/src/families/hierarchy/interaction.ts +++ b/src/families/hierarchy/interaction.ts @@ -16,23 +16,20 @@ interface Focus { intent: object; } -interface State { - compiled: Document | null; - view: HierarchyView | null; - collapsed: Set; - viewport: ViewportState; - restore: ViewportState | null; - focus: Focus | null; - height: number | null; - axes: AxisMode; - report: object | null; - active: boolean; -} - -/** Family semantic state belongs to the same owner as its public session facade. */ +/** Pure hierarchy commands own semantic state; presentation never writes it back. */ export class HierarchyInteraction> { readonly viewport: ViewportSession; - #state: State; + #compiled: Document | null = null; + #view: HierarchyView | null = null; + #collapsed = new Set(); + #window: ViewportState = { start: 0, end: 1, scrollOffset: 0 }; + #restore: ViewportState | null = null; + #focus: Focus | null = null; + #height: number | null = null; + #axes: AxisMode = 'full'; + #report: object | null = null; + #active = false; + constructor( private readonly session: ChartSession, private readonly createView: ( @@ -40,199 +37,158 @@ export class HierarchyInteraction ) => HierarchyView ) { - this.#state = { - compiled: null as Document | null, - view: null as HierarchyView | null, - collapsed: new Set(), - viewport: { start: 0, end: 1, scrollOffset: 0 } as ViewportState, - restore: null as ViewportState | null, - focus: null as Focus | null, - height: null as number | null, - axes: 'full' as AxisMode, - report: null as object | null, - active: false - }; this.viewport = new OwnedViewport( - () => ({ ...this.#state.viewport }), - (change) => - this.#reduce((state) => ({ - ...state, - report: {}, - focus: null, - restore: null, - viewport: this.#geometry(state, change) - })) + () => ({ ...this.#window }), + (change) => { + const viewport = this.#geometry(change); + this.#window = viewport; + this.#report = {}; + this.#focus = this.#restore = null; + } ); } - #reduce(reduce: (state: State) => State) { - this.#state = reduce(this.#state); - } get compiled() { - return this.#state.compiled; + return this.#compiled; } get view() { - return this.#state.view; + return this.#view; } get collapsed(): ReadonlySet { - return this.#state.collapsed; + return this.#collapsed; } get desiredViewport() { - const state = this.#state; - return { ...(state.restore ?? state.viewport) }; + return { ...(this.#restore ?? this.#window) }; } get report() { - return this.#state.report; + return this.#report; } get active() { - return this.#state.active; + return this.#active; } - #geometry( - state: { - view: HierarchyView | null; - viewport: ViewportState; - restore: ViewportState | null; - height: number | null; - axes: AxisMode; - }, - change?: (viewport: ViewportSession) => void - ) { + + #geometry(change?: (viewport: ViewportSession) => void) { const viewport = new ViewportSession(); viewport.configure({ - contentHeight: (state.view?.rowCount ?? 0) * (state.view?.rowHeight ?? 24), - visibleHeight: state.height === null ? 0 : hierarchyViewportHeight(state.height, state.axes) + contentHeight: (this.#view?.rowCount ?? 0) * (this.#view?.rowHeight ?? 24), + visibleHeight: this.#height === null ? 0 : hierarchyViewportHeight(this.#height, this.#axes) }); - viewport.set(state.restore ?? state.viewport); + viewport.set(this.#restore ?? this.#window); change?.(viewport); return { ...viewport.state }; } #adopt() { - if (this.active) this.session.setModel(this.view?.navigation ?? null); + if (this.#active) this.session.setModel(this.#view?.navigation ?? null); } replace(compiled: Document | null) { + const collapsed = new Set(); + const view = compiled ? this.createView(compiled, collapsed) : null; this.session.clearInspection(); - this.#reduce((state) => ({ - ...state, - compiled, - view: compiled ? this.createView(compiled, new Set()) : null, - collapsed: new Set(), - viewport: { start: 0, end: 1, scrollOffset: 0 }, - restore: null, - focus: null, - report: null - })); + this.#compiled = compiled; + this.#view = view; + this.#collapsed = collapsed; + this.#window = { start: 0, end: 1, scrollOffset: 0 }; + this.#restore = this.#focus = this.#report = null; this.#adopt(); } collapse(collapsed: Iterable) { const values = new Set(collapsed); for (const id of values) - if (!this.compiled?.hierarchy.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); - this.#reduce((state) => ({ - ...state, - collapsed: values, - focus: null, - view: state.compiled ? this.createView(state.compiled, values) : null - })); + if (!this.#compiled?.hierarchy.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); + const view = this.#compiled ? this.createView(this.#compiled, values) : null; + this.#collapsed = values; + this.#view = view; + this.#focus = null; this.#adopt(); } restore(state: HierarchyViewState) { const collapsed = new Set( - state.collapsed.filter((id) => this.compiled?.hierarchy.children.get(id)?.length) + state.collapsed.filter((id) => this.#compiled?.hierarchy.children.get(id)?.length) ); - this.#reduce((previous) => ({ - ...previous, - collapsed, - view: previous.compiled ? this.createView(previous.compiled, collapsed) : null, - viewport: { ...state.viewport }, - restore: { ...state.viewport }, - focus: null, - report: null - })); + const view = this.#compiled ? this.createView(this.#compiled, collapsed) : null; + this.#collapsed = collapsed; + this.#view = view; + this.#window = { ...state.viewport }; + this.#restore = { ...state.viewport }; + this.#focus = this.#report = null; this.#adopt(); restoreReading(this.session, state.pinnedKey); } activate() { - this.#reduce((state) => ({ ...state, active: true })); + this.#active = true; this.#adopt(); } deactivate() { - this.#reduce((state) => ({ ...state, active: false, focus: null })); + this.#active = false; + this.#focus = null; this.session.setModel(null); } focus(id: string, pin: boolean) { - const compiled = this.compiled; + const compiled = this.#compiled; if (!compiled?.hierarchy.byId.has(id)) throw new RangeError(`Unknown node: ${id}`); - if (!this.active || compiled.document.options?.interactive === false) return false; - const collapsed = new Set(this.collapsed); + if (!this.#active || compiled.document.options?.interactive === false) return false; + const collapsed = new Set(this.#collapsed); let parent = compiled.hierarchy.byId.get(id)?.parentId; while (parent && compiled.hierarchy.byId.has(parent)) { collapsed.delete(parent); parent = compiled.hierarchy.byId.get(parent)?.parentId; } - this.#reduce((state) => ({ - ...state, - collapsed, - view: this.createView(compiled, collapsed) - })); + const view = this.createView(compiled, collapsed); + this.#collapsed = collapsed; + this.#view = view; this.#adopt(); - this.session.inspect(this.view!.byId.get(id)!.interaction.key, pin, true); + this.session.inspect(view.byId.get(id)!.interaction.key, pin, true); this.navigate(this.session.active); return true; } navigate(target: ChartInteraction | null) { const id = target?.sources[0]?.id; - const focus = - id && this.view?.byId.has(id) - ? { - id, - snapshot: this.compiled!, - intent: readingState(this.session).intent - } + this.#focus = + id && this.#view?.byId.has(id) + ? { id, snapshot: this.#compiled!, intent: readingState(this.session).intent } : null; - this.#reduce((state) => ({ ...state, focus, restore: null })); - // The previous valid measurement can position a hidden chart provisionally. + this.#restore = null; + // A previous valid measurement positions hidden charts provisionally. this.#position(false); } measure(height: number, axes: AxisMode) { if (!Number.isFinite(height) || height <= 0) return; - this.#reduce((state) => ({ ...state, height, axes })); + this.#height = height; + this.#axes = axes; this.#position(true); } #position(measured: boolean) { - this.#reduce((state) => { - const focus = state.focus; - const current = - !!focus && - focus.snapshot === state.compiled && - focus.intent === readingState(this.session).intent; - const item = current ? state.view?.byId.get(focus.id) : null; - if (state.height === null) return state; - const viewport = this.#geometry( - state, - item - ? (viewport) => - viewport.reveal( - item.start, - item.end, - item.row * state.view!.rowHeight, - (item.row + 1) * state.view!.rowHeight - ) - : undefined - ); - const changed = - viewport.start !== state.viewport.start || - viewport.end !== state.viewport.end || - viewport.scrollOffset !== state.viewport.scrollOffset; - return { - ...state, - viewport, - report: item && changed ? {} : state.report, - restore: null, - focus: measured || !current ? null : focus - }; - }); + if (this.#height === null) return; + const focus = this.#focus, + view = this.#view; + const current = + !!focus && + focus.snapshot === this.#compiled && + focus.intent === readingState(this.session).intent; + const item = current ? view?.byId.get(focus.id) : null; + const viewport = this.#geometry( + item + ? (viewport) => + viewport.reveal( + item.start, + item.end, + item.row * view!.rowHeight, + (item.row + 1) * view!.rowHeight + ) + : undefined + ); + if ( + item && + (viewport.start !== this.#window.start || + viewport.end !== this.#window.end || + viewport.scrollOffset !== this.#window.scrollOffset) + ) + this.#report = {}; + this.#window = viewport; + this.#restore = null; + if (measured || !current) this.#focus = null; } dismiss() { - this.#reduce((state) => ({ ...state, focus: null })); + this.#focus = null; this.session.clearInspection(); } } diff --git a/src/runtime/chart-runtime.ts b/src/runtime/chart-runtime.ts index 496e68f..f79ada8 100644 --- a/src/runtime/chart-runtime.ts +++ b/src/runtime/chart-runtime.ts @@ -1,5 +1,5 @@ import { ownsInspectionDismissal } from './inspection-input.js'; -import { interactionFor, claimInteraction } from '../core/interaction.js'; +import { interactionFor, claimInteraction, readingPhase } from '../core/interaction.js'; import { readingState, releaseReading } from '../core/reading-state.js'; import { ChartSession } from '../core/session.js'; import { RangeSelectionSession, type RangeSelectionBinding } from '../core/range.js'; @@ -489,7 +489,7 @@ export class ChartRuntime this.options.onInspect?.(this.session.active, null, this.session.pinned); }, {}, - 2 + readingPhase ); } protected release() { diff --git a/src/runtime/reading-runtime.ts b/src/runtime/reading-runtime.ts index 3c5272e..bf3d675 100644 --- a/src/runtime/reading-runtime.ts +++ b/src/runtime/reading-runtime.ts @@ -1,4 +1,4 @@ -import { interactionFor } from '../core/interaction.js'; +import { interactionFor, readingPhase } from '../core/interaction.js'; import { readingState } from '../core/reading-state.js'; import { ReadingSession } from '../core/reading-session.js'; import type { ChartInteraction, ChartScene } from '../core/scene.js'; @@ -101,7 +101,7 @@ export class ReadingRuntime extends RuntimeLifecycle { this.options.onInspect?.(this.session.active, null, this.session.pinned); }, {}, - 2 + readingPhase ); } protected release() { diff --git a/tests/browser/inspection-contract.spec.ts b/tests/browser/inspection-contract.spec.ts new file mode 100644 index 0000000..1be49d5 --- /dev/null +++ b/tests/browser/inspection-contract.spec.ts @@ -0,0 +1,252 @@ +import { test, expect } from '@playwright/test'; +import type { InspectionContext } from '../../src/index.js'; +import { inspectionEntries } from './fixtures/inspection-matrix.js'; + +test('metric presentation rejects an anchor result after its callback dismisses the reading', async ({ + page +}) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async () => { + const entry = '/src/element.ts'; + const { ScopeChartElement }: typeof import('../../src/element.js') = await import(entry); + const fixture = '/tests/browser/fixtures/inspection-matrix.ts'; + const { inspectionDocument }: typeof import('./fixtures/inspection-matrix.js') = await import( + fixture + ); + const outside = document.createElement('button'); + outside.textContent = 'Outside'; + let cancelled = false; + let tooltip: HTMLElement; + class ReentrantChart extends ScopeChartElement { + protected override async renderer() { + const create = await super.renderer(); + return (...args: Parameters) => { + const renderer = create(...args); + return new Proxy(renderer, { + get(target, key) { + if (key === 'anchor') + return (...args: Parameters) => { + const anchor = target.anchor(...args); + if (!cancelled) { + cancelled = true; + tooltip.style.left = '19px'; + tooltip.style.top = '23px'; + outside.focus(); + } + return anchor; + }; + const value = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + set(target, key, value) { + return Reflect.set(target, key, value, target); + } + }); + }; + } + } + customElements.define('scope-contract-chart', ReentrantChart); + const chart = new ReentrantChart(); + chart.style.cssText = 'width:600px;--scope-chart-height:240px'; + document.body.append(outside, chart); + const ready = new Promise((resolve, reject) => { + chart.addEventListener('scope-ready', resolve, { once: true }); + chart.addEventListener('scope-error', (event) => reject(new Error(event.detail.message)), { + once: true + }); + }); + chart.document = inspectionDocument( + { name: 'anchor', family: 'metric', surface: 'component' }, + '100' + ); + await ready; + const root = chart.shadowRoot!; + tooltip = root.querySelector('.tooltip')!; + let openings = 0; + tooltip.addEventListener('beforetoggle', (event) => { + if (cancelled && (event as ToggleEvent).newState === 'open') openings++; + }); + const keys: (string | null)[] = []; + chart.addEventListener('scope-inspect', (event) => keys.push(event.detail.key)); + root + .querySelector('.viewport')! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); + return { + cancelled, + openings, + hidden: tooltip.hidden, + left: tooltip.style.left, + top: tooltip.style.top, + live: root.querySelector('.live')!.textContent, + keys + }; + }); + expect(result.cancelled).toBe(true); + expect(result.openings).toBe(0); + expect(result.hidden).toBe(true); + expect(result.left).toBe('19px'); + expect(result.top).toBe('23px'); + expect(result.live).toBe(''); + expect(result.keys.every((key) => key === null)).toBe(true); +}); + +for (const entry of inspectionEntries) { + test(`${entry.name} preserves its repeated reading notification policy`, async ({ page }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async (entry) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { + createInspectionFixture, + inspectionValue + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture(entry); + f.events.length = 0; + f.key('Home'); + f.key('Home'); + return { + inspected: f.events + .filter((event) => event.channel === 'inspect') + .map((event) => ({ + value: inspectionValue(event.context), + snapshot: event.context?.snapshotId + })), + selections: f.events.filter((event) => event.channel === 'select').length + }; + }, entry); + expect(result.inspected).toHaveLength(entry.surface === 'component' ? 2 : 1); + expect(result.inspected.every((reading) => reading.value === '100')).toBe(true); + expect(new Set(result.inspected.map((reading) => reading.snapshot)).size).toBe(1); + expect(result.selections).toBe(0); + }); + + test(`${entry.name} retains unread context when its selection callback replaces the same key`, async ({ + page + }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async (entry) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { + createInspectionFixture, + inspectionValue + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture(entry); + let retained: InspectionContext | null = null; + let replacement: Promise | undefined; + f.hook = (notification) => { + if (notification.channel !== 'select' || !notification.context || retained) return; + retained = notification.context; + replacement = f.replace('200'); + }; + f.pin(); + await replacement; + await f.settle(); + f.key('Home'); + const old = retained as InspectionContext | null; + const latest = f.latest.inspect; + f.dispose(); + return { + oldValue: inspectionValue(old), + latestValue: inspectionValue(latest), + sameKey: old?.key === latest?.key, + distinctSnapshots: old?.snapshotId !== latest?.snapshotId + }; + }, entry); + expect(result).toEqual({ + oldValue: '100', + latestValue: '200', + sameKey: true, + distinctSnapshots: true + }); + }); +} + +for (const entry of inspectionEntries.filter((entry) => entry.surface === 'component')) { + test(`${entry.name} waits for the replacement frame before reporting ready from a hidden container`, async ({ + page + }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async (entry) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { + createInspectionFixture, + inspectionValue + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture(entry); + f.key('Home'); + const previous = f.latest.inspect?.snapshotId; + f.host.style.display = 'none'; + await f.settle(); + let ready = 0; + f.chart!.addEventListener('scope-ready', () => ready++); + await f.replace('200', false); + await f.settle(); + const hiddenReady = ready; + const shown = new Promise((resolve) => + f.chart!.addEventListener('scope-ready', resolve, { once: true }) + ); + f.host.style.display = ''; + await shown; + f.key('Home'); + return { + hiddenReady, + ready, + value: inspectionValue(f.latest.inspect), + distinctSnapshot: previous !== f.latest.inspect?.snapshotId + }; + }, entry); + expect(result.hiddenReady).toBe(0); + expect(result.ready).toBeGreaterThan(0); + expect(result.value).toBe('200'); + expect(result.distinctSnapshot).toBe(true); + }); + + test(`${entry.name} broadcasts one captured selection while getters expose the nested document`, async ({ + page + }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + const result = await page.evaluate(async (entry) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { + createInspectionFixture, + inspectionValue + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture(entry); + const chart = f.chart!; + const currentValue = () => { + const doc = chart.document!; + return 'data' in doc + ? doc.data.rows[0][1] + : 'spans' in doc + ? doc.spans[0].endTimeNs + : doc.frames[0].selfWeight; + }; + const observed: { listener: number; value: string | null; current: string | null }[] = []; + const afterSetter: (string | null)[] = []; + let replacement: Promise | undefined; + for (const listener of [1, 2]) + chart.addEventListener('scope-select', (event) => { + const { context } = (event as CustomEvent<{ context: InspectionContext | null }>).detail; + if (!context) return; + const value = inspectionValue(context); + observed.push({ listener, value, current: currentValue() }); + if (listener === 1 && value === '100') { + replacement = f.replace('200'); + afterSetter.push(currentValue()); + } + }); + f.pin(); + await replacement; + await f.settle(); + return { observed, afterSetter }; + }, entry); + expect(result).toEqual({ + observed: [ + { listener: 1, value: '100', current: '100' }, + { listener: 2, value: '100', current: '200' }, + { listener: 1, value: '200', current: '200' }, + { listener: 2, value: '200', current: '200' } + ], + afterSetter: ['200'] + }); + }); +} diff --git a/tests/unit/core/inspection-notifications.test.ts b/tests/unit/core/inspection-notifications.test.ts index 8ff4946..4d46161 100644 --- a/tests/unit/core/inspection-notifications.test.ts +++ b/tests/unit/core/inspection-notifications.test.ts @@ -1,7 +1,8 @@ -import { Interaction } from '../../../src/core/interaction.js'; +import { Interaction, controlsPhase } from '../../../src/core/interaction.js'; import { describe, expect, it, vi } from 'vitest'; import { InspectionNotifications, + type InspectionChannel, type InspectionState } from '../../../src/core/inspection-notifications.js'; import type { ChartInteraction } from '../../../src/index.js'; @@ -28,6 +29,75 @@ function fixture() { } describe('inspection notifications', () => { + it.each([ + 'inspect', + 'select' + ] as const)('preserves fresh %s-first order and independently coalesces nested channel values', (first: InspectionChannel) => { + const other = first === 'inspect' ? 'select' : 'inspect'; + const { state } = fixture(); + const interaction = new Interaction(); + const notifications = new InspectionNotifications(() => state, interaction); + const seen: string[] = []; + const emit = () => { + const captured = notifications.capture(); + for (const channel of [first, other] as const) + captured.emit(channel, (value) => { + seen.push(`${channel}:${value.target?.key}`); + if (channel === first && value.target?.key === 'a') { + state.inspect.target = state.select.target = target('b'); + emit(); + } + }); + }; + interaction.batch(emit); + expect(seen[0]).toBe(`${first}:a`); + expect(seen.slice(1).sort()).toEqual(['inspect:b', 'select:b']); + expect(seen).not.toContain(`${other}:a`); + + // With no nested command, both adapters preserve their original enqueue order. + const stable: InspectionChannel[] = []; + state.snapshot = {}; + interaction.batch(() => { + const captured = notifications.capture(); + for (const channel of [first, other] as const) + captured.emit(channel, () => stable.push(channel)); + }); + expect(stable).toEqual([first, other]); + }); + + it('publishes captured readings while synchronous getters expose a nested command immediately', () => { + const interaction = new Interaction(); + const state = { snapshot: {}, inspect: { target: target('a') }, select: { target: null } }; + const notifications = new InspectionNotifications(() => state, interaction); + const seen: string[] = []; + const publish = (key: string) => + interaction.batch(() => { + state.inspect.target = target(key); + notifications.capture().emit('inspect', broadcast); + }); + const broadcast = (value: { target: ChartInteraction | null }) => { + seen.push(`first:${value.target?.key}/${state.inspect.target.key}`); + if (value.target?.key === 'a') { + publish('b'); + seen.push(`after:${value.target.key}/${state.inspect.target.key}`); + } + seen.push(`second:${value.target?.key}/${state.inspect.target.key}`); + }; + publish('a'); + expect(seen).toEqual(['first:a/a', 'after:a/b', 'second:a/b', 'first:b/b', 'second:b/b']); + }); + + it('coalesces pending explicit reading input as state rather than an action history', () => { + const { state, deliver, sent } = fixture(); + const interaction = new Interaction(); + const reports = new InspectionNotifications(() => state, interaction); + interaction.batch(() => { + reports.capture().emit('inspect', deliver, true); + reports.capture().emit('inspect', deliver, true); + }); + expect(sent).toEqual(['a']); + }); + it('deduplicates geometry changes and refreshes exact snapshot, series and pin identities', () => { const { state, notifications, deliver, sent } = fixture(); notifications.capture().emit('select', deliver); @@ -212,7 +282,7 @@ describe('inspection notifications', () => { notifications.capture().emit('select', deliver); }, {}, - 1 + controlsPhase ); }); expect(sent).toEqual([]); diff --git a/tests/unit/core/interaction.test.ts b/tests/unit/core/interaction.test.ts index 5fe4a4f..6db255b 100644 --- a/tests/unit/core/interaction.test.ts +++ b/tests/unit/core/interaction.test.ts @@ -8,9 +8,15 @@ import type { Entity } from '../../../src/families/hierarchy/model.js'; import { describe, expect, it, vi } from 'vitest'; import { Interaction, + channel, interactionFor, shareInteraction, - claimInteraction + claimInteraction, + controlsPhase, + readingPhase, + viewportPhase, + viewStatePhase, + readyPhase } from '../../../src/core/interaction.js'; import { InspectionNotifications } from '../../../src/core/inspection-notifications.js'; import { ChartSession } from '../../../src/core/session.js'; @@ -18,10 +24,40 @@ import { HierarchyInteraction } from '../../../src/families/hierarchy/interactio import { compileTrace, createTraceView, type CompiledTrace } from '../../../src/families/trace.js'; describe('interaction transactions', () => { + it("uses each channel's fixed public delivery order regardless of enqueue order", () => { + const interaction = new Interaction(); + const delivered: string[] = []; + interaction.batch(() => { + for (const [name, phase] of [ + ['ready', readyPhase], + ['state', viewStatePhase], + ['viewport', viewportPhase], + ['reading', readingPhase], + ['controls', controlsPhase] + ] as const) { + interaction.report( + channel(false, Object.is, phase), + true, + () => true, + () => delivered.push(name) + ); + } + interaction.effect(() => delivered.push('presentation')); + }); + expect(delivered).toEqual([ + 'presentation', + 'controls', + 'reading', + 'viewport', + 'state', + 'ready' + ]); + }); + it('deduplicates acknowledgements and finishes each broadcast before its nested successor', () => { const interaction = new Interaction(); let state = 0; - const reports = interaction.channel(0, Object.is); + const reports = channel(0, Object.is); const early: number[] = [], late: number[] = []; const commit = (value: number) => @@ -117,7 +153,7 @@ describe('interaction transactions', () => { vi.useFakeTimers(); try { const interaction = new Interaction(); - const reports = interaction.channel(0, Object.is); + const reports = channel(0, Object.is); let value = 0; const deliver = (next: number) => { value = next; @@ -145,16 +181,14 @@ describe('interaction transactions', () => { it('orders geometry, controls and readings before a viewport report, discarding a superseded window', () => { const interaction = new Interaction(); let state = 1; - const reports = interaction.channel(0, Object.is); + const reports = channel(0, Object.is, viewportPhase); const sent: number[] = []; interaction.batch(() => { interaction.report( reports, 1, () => state === 1, - (value) => sent.push(value), - false, - 3 + (value) => sent.push(value) ); interaction.effect( () => { @@ -163,13 +197,11 @@ describe('interaction transactions', () => { reports, 2, () => state === 2, - (value) => sent.push(value), - false, - 3 + (value) => sent.push(value) ); }, {}, - 2 + readingPhase ); }); expect(sent).toEqual([2]); From c03ae0c7e22b1a44c1e2d3ed8c627571b63282dd Mon Sep 17 00:00:00 2001 From: leiysky Date: Thu, 29 Oct 2026 23:33:08 +0800 Subject: [PATCH 7/8] refactor: share inspection input and consolidate regression coverage --- development/architecture.md | 2 +- development/interaction-transactions.md | 14 +- src/elements/controls.ts | 3 +- src/runtime/chart-runtime.ts | 26 +- src/runtime/inspection-input.ts | 29 +++ src/runtime/reading-runtime.ts | 25 +- tests/browser/fixtures/inspection-matrix.ts | 20 ++ tests/browser/hierarchy-viewport.spec.ts | 199 ++++++-------- tests/browser/inspection-contract.spec.ts | 246 +++++++----------- .../browser/inspection-notifications.spec.ts | 38 ++- .../browser/interaction-transactions.spec.ts | 72 +---- .../core/inspection-notifications.test.ts | 46 ++-- tests/unit/core/interaction.test.ts | 18 -- 13 files changed, 297 insertions(+), 441 deletions(-) diff --git a/development/architecture.md b/development/architecture.md index f144bfd..a4b6977 100644 --- a/development/architecture.md +++ b/development/architecture.md @@ -53,7 +53,7 @@ Keep data and compilation independent of browser globals. Views share Canvas and Each mounted chart has one DOM-free interaction owner for canonical reading state, command boundaries and publication. Session objects are compatibility facades into that owner. Pure reducers commit semantic keys, selection, hover and intent before any effect invokes application code. Hierarchy family state owns compiled target resolution, collapse, viewport and deferred geometry; it shares the component's session and pump rather than copying selection into an independent runtime. -The internal `RuntimeLifecycle` base owns Canvas measurement, layout invalidation, frame scheduling, resize/font/DPR listeners, error recovery and resource disposal. `ReadingRuntime` and `ChartRuntime` translate input and project committed state into paint. Layout revisions and semantic model versions reject stale custom layout results and errors. Consumed Canvas providers and detached borrowed session facades keep old runtime lifetimes from mutating a new mounted chart. Optional ranges, links and viewport controllers stay outside the base and small headless runtime. +The internal `RuntimeLifecycle` base owns Canvas measurement, layout invalidation, frame scheduling, resize/font/DPR listeners, error recovery and resource disposal. `ReadingRuntime` and `ChartRuntime` share keyboard reading transitions in `runtime/inspection-input.ts` and project committed state into paint. Layout revisions and semantic model versions reject stale custom layout results and errors. Consumed Canvas providers and detached borrowed session facades keep old runtime lifetimes from mutating a new mounted chart. Optional ranges, links and viewport controllers stay outside the base and small headless runtime. Family-owned inspection context projectors expose exact source values, field roles and ancestry. Context types and opaque document identity live in `core/inspection-context.ts`; completed scenes retain their compiled source through `core/scene-snapshot.ts`. A pending document does not relabel an older painted frame. Lazy source clones retain exact original values and ancestry when hosts keep a context after later updates. diff --git a/development/interaction-transactions.md b/development/interaction-transactions.md index c5ee1b6..866ee61 100644 --- a/development/interaction-transactions.md +++ b/development/interaction-transactions.md @@ -60,13 +60,13 @@ Runtime layout revisions and semantic model versions reject stale custom layout ## Verification and package boundaries -| Contract | Regression coverage | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Snapshot versus current getters | `inspection-contract.spec.ts` checks two independent component listeners and all 17 entries retaining unread context after nested replacement/disposal. | -| State publication and order | Coordinator/kernel units cover immediate nested writes, coalescing, channel independence, fixed channel order, exceptions and bounded feedback. | -| Semantic focus and stale geometry | `interaction-transactions.spec.ts`, `hierarchy-viewport.spec.ts` and 48 seeded independent last-intent reference runs cover hidden/collapsed focus, measurements and superseding commands. | -| Projection and lifecycle | Popover/renderer regressions, hidden replacement readiness, ready cancellation, stale custom layouts and runtime reuse cover effects that can reenter or outlive their source. | -| API and entry compatibility | Existing family matrices, public type consumers, exported-member comparison and installed-package acceptance cover signatures and JavaScript facade behavior. | +| Contract | Regression coverage | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Snapshot versus current getters | `inspection-contract.spec.ts` checks independent component listeners; `inspection-notifications.spec.ts` checks unread context after nested replacement/disposal in both channels across all 17 entries. | +| State publication and order | Coordinator/kernel units cover immediate nested writes, coalescing, channel independence, fixed channel order, exceptions and bounded feedback. | +| Semantic focus and stale geometry | `interaction-transactions.spec.ts`, `hierarchy-viewport.spec.ts` and 48 seeded independent last-intent reference runs cover hidden/collapsed focus, measurements and superseding commands. | +| Projection and lifecycle | Popover/renderer regressions, hidden replacement readiness, ready cancellation, stale custom layouts and runtime reuse cover effects that can reenter or outlive their source. | +| API and entry compatibility | Existing family matrices, public type consumers, exported-member comparison and installed-package acceptance cover signatures and JavaScript facade behavior. | The kernel, sessions and frame registry remain DOM-free. Shared reading DOM stays in `src/ui/`; headless views do not import component shells. Fixed metric entries omit aggregate switching, scalar stays HTML-only, registration is explicit and all existing gzip limits remain gates. Painter tokens, source colors, geometry and reading appearance are unchanged. diff --git a/src/elements/controls.ts b/src/elements/controls.ts index e4c91b1..feb35d2 100644 --- a/src/elements/controls.ts +++ b/src/elements/controls.ts @@ -90,7 +90,6 @@ export function renderToolbar( export class ControlEvents { #previous = ''; - #key = {}; constructor(private readonly interaction: Interaction) {} notify(host: HTMLElement, controls: ScopeChartControl[]) { this.interaction.effect( @@ -106,7 +105,7 @@ export class ControlEvents { }) ); }, - this.#key, + this, controlsPhase ); } diff --git a/src/runtime/chart-runtime.ts b/src/runtime/chart-runtime.ts index f79ada8..b37dfcf 100644 --- a/src/runtime/chart-runtime.ts +++ b/src/runtime/chart-runtime.ts @@ -1,4 +1,4 @@ -import { ownsInspectionDismissal } from './inspection-input.js'; +import { inspectKey, ownsInspectionDismissal } from './inspection-input.js'; import { interactionFor, claimInteraction, readingPhase } from '../core/interaction.js'; import { readingState, releaseReading } from '../core/reading-state.js'; import { ChartSession } from '../core/session.js'; @@ -454,31 +454,13 @@ export class ChartRuntime return; } if (this.options.keyboard === false) return; - let target: ChartInteraction | null = null; - if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { - event.preventDefault(); - const key = - event.key === 'Home' || event.key === 'End' - ? event.key - : event.key === 'ArrowLeft' || event.key === 'ArrowUp' - ? 'previous' - : 'next'; - target = this.session.next(key); - this.session.inspect(target?.key ?? null, false, true); - } else if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - target = this.session.active ?? this.session.next('Home'); - this.session.inspect(target?.key ?? null, true, true); - } else if (event.key === 'Escape') { - event.preventDefault(); - this.session.clearInspection(); - } else return; + const target = inspectKey(event, this.session, true); + if (target === undefined) return; if (target) { - const navigate = target; const intent = readingState(this.session).intent; interactionFor(this.session).effect(() => { if (!this.signal.aborted && intent === readingState(this.session).intent) - this.options.onNavigate?.(navigate); + this.options.onNavigate?.(target); }); } interactionFor(this.session).effect( diff --git a/src/runtime/inspection-input.ts b/src/runtime/inspection-input.ts index 81ab285..6c5db1d 100644 --- a/src/runtime/inspection-input.ts +++ b/src/runtime/inspection-input.ts @@ -1,3 +1,32 @@ +import type { ReadingSession } from '../core/reading-session.js'; +import type { ChartInteraction } from '../core/scene.js'; + +/** Accept common reading keys; undefined leaves an unrelated key to its owner. */ +export function inspectKey( + event: KeyboardEvent, + session: ReadingSession, + selectable: boolean +): ChartInteraction | null | undefined { + const key = event.key; + const direction = + key === 'Home' || key === 'End' + ? key + : key === 'ArrowLeft' || key === 'ArrowUp' + ? 'previous' + : key === 'ArrowRight' || key === 'ArrowDown' + ? 'next' + : null; + if (!direction && key !== 'Enter' && key !== ' ' && key !== 'Escape') return; + event.preventDefault(); + if (key === 'Escape') { + session.clearInspection(); + return null; + } + const target = direction ? session.next(direction) : (session.active ?? session.next('Home')); + session.inspect(target?.key ?? null, !direction && selectable, true); + return target; +} + const dismissalOwners = new WeakSet(); /** The shell owns Canvas Escape even without a paint runtime or a cancelable event. */ diff --git a/src/runtime/reading-runtime.ts b/src/runtime/reading-runtime.ts index bf3d675..5367abb 100644 --- a/src/runtime/reading-runtime.ts +++ b/src/runtime/reading-runtime.ts @@ -4,6 +4,7 @@ import { ReadingSession } from '../core/reading-session.js'; import type { ChartInteraction, ChartScene } from '../core/scene.js'; import { RuntimeLifecycle } from './lifecycle.js'; import type { ReadingOptions } from './types.js'; +import { inspectKey } from './inspection-input.js'; /** Report lifecycle and transient reading. Range, links and viewport controllers stay optional. */ export class ReadingRuntime extends RuntimeLifecycle { @@ -66,31 +67,13 @@ export class ReadingRuntime extends RuntimeLifecycle { ) return; const interaction = interactionFor(this.session); - let target: ChartInteraction | null = null; - if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { - event.preventDefault(); - const direction = - event.key === 'Home' || event.key === 'End' - ? event.key - : event.key === 'ArrowLeft' || event.key === 'ArrowUp' - ? 'previous' - : 'next'; - target = this.session.next(direction); - this.session.inspect(target?.key ?? null, false, true); - } else if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - target = this.session.active ?? this.session.next('Home'); - this.session.inspect(target?.key ?? null, this.selectable, true); - } else if (event.key === 'Escape') { - event.preventDefault(); - this.session.clearInspection(); - } else return; + const target = inspectKey(event, this.session, this.selectable); + if (target === undefined) return; if (target) { - const navigate = target; const intent = readingState(this.session).intent; interaction.effect(() => { if (!this.signal.aborted && intent === readingState(this.session).intent) - this.options.onNavigate?.(navigate); + this.options.onNavigate?.(target); }); } interaction.effect( diff --git a/tests/browser/fixtures/inspection-matrix.ts b/tests/browser/fixtures/inspection-matrix.ts index 2bb5278..e3ccbb4 100644 --- a/tests/browser/fixtures/inspection-matrix.ts +++ b/tests/browser/fixtures/inspection-matrix.ts @@ -94,6 +94,26 @@ export function inspectionDocument(entry: InspectionEntry, value: string, empty }; } +export function hierarchyDocument(family: 'trace' | 'profile') { + const nodes = Array.from({ length: 50 }, (_, i) => ({ + id: `node-${i}`, + name: `node-${i}`, + ...(i ? { parentId: `node-${i - 1}` } : {}) + })); + return family === 'trace' + ? { + version: 1, + kind: family, + spans: nodes.map((node) => ({ ...node, startTimeNs: '0', endTimeNs: '10000' })) + } + : { + version: 1, + kind: family, + unit: 'samples', + frames: nodes.map((node) => ({ ...node, selfWeight: '1' })) + }; +} + export function inspectionValue(context: InspectionContext | null) { return !context ? null diff --git a/tests/browser/hierarchy-viewport.spec.ts b/tests/browser/hierarchy-viewport.spec.ts index c96765b..d97783e 100644 --- a/tests/browser/hierarchy-viewport.spec.ts +++ b/tests/browser/hierarchy-viewport.spec.ts @@ -8,29 +8,15 @@ const first = { start: 0.1, end: 0.6, scrollOffset: 0 }; const latest = { start: 0.25, end: 0.75, scrollOffset: 0 }; async function deepHierarchy(page: Page, family: Family) { - const nodes = Array.from({ length: 50 }, (_, i) => ({ - id: `node-${i}`, - name: `node-${i}`, - ...(i ? { parentId: `node-${i - 1}` } : {}) - })); - const document = - family === 'trace' - ? { - version: 1, - kind: 'trace', - spans: nodes.map((node) => ({ ...node, startTimeNs: '0', endTimeNs: '10000' })) - } - : { - version: 1, - kind: 'profile', - unit: 'samples', - frames: nodes.map((node) => ({ ...node, selfWeight: '1' })) - }; - await page.evaluate(async (document) => { + await page.evaluate(async (family) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { hierarchyDocument }: typeof import('./fixtures/inspection-matrix.js') = await import( + module + ); const f = window.notificationFixture; - f.chart!.document = document; + f.chart!.document = hierarchyDocument(family); await f.settle(); - }, document); + }, family); } for (const family of ['trace', 'profile'] as const) { @@ -49,58 +35,73 @@ for (const family of ['trace', 'profile'] as const) { }, family); }); - for (const pin of [false, true]) { - for (const height of [240, 120]) { - test(`hidden focusNode remains visible after showing at ${height}px, pin=${pin}`, async ({ - page - }) => { - await deepHierarchy(page, family); - const result = await page.evaluate( - async ({ pin, height }) => { - const f = window.notificationFixture; - const chart = f.chart as Hierarchy; - const snapshot = chart.scene; - f.host.style.display = 'none'; - await f.settle(); - chart.focusNode('node-20', { pin }); - const hidden = { - viewport: chart.viewport, - pin: chart.viewState.pinnedKey, - key: f.latest.inspect?.key - }; - chart.style.setProperty('--scope-chart-height', `${height}px`); - f.host.style.display = ''; - await f.settle(); - await f.settle(); - const scene = chart.scene!; - const clip = scene.clipRect ?? scene.rect!; - const key = `${f.entry.family}:node-20`; - const mark = scene.marks.find((mark) => mark.kind === 'rect' && mark.key === key); - const visible = - mark?.kind === 'rect' && - Math.min(mark.y + mark.height, clip.y + clip.height) > Math.max(mark.y, clip.y) && - Math.min(mark.x + mark.width, clip.x + clip.width) > Math.max(mark.x, clip.x); - return { - hidden, - visible, - pin: chart.viewState.pinnedKey, - key: f.latest.inspect?.key, - snapshotPainted: scene !== snapshot - }; - }, - { pin, height } - ); - const key = `${family}:node-20`; - expect(result.hidden).toEqual({ - viewport: { start: 0, end: 1, scrollOffset: 328 }, - pin: pin ? key : null, - key + for (const collapsed of [false, true]) { + for (const pin of [false, true]) { + for (const height of [240, 120]) { + test(`hidden focusNode remains visible at ${height}px, pin=${pin}, collapsed=${collapsed}`, async ({ + page + }) => { + await deepHierarchy(page, family); + const result = await page.evaluate( + async ({ pin, height, collapsed }) => { + const f = window.notificationFixture; + const chart = f.chart as Hierarchy; + if (collapsed) { + chart.collapsed = ['node-0']; + await f.settle(); + chart.focusNode('node-0', { pin: true }); + } + const snapshot = chart.scene; + f.host.style.display = 'none'; + await f.settle(); + chart.focusNode('node-20', { pin }); + const hidden = { + collapsed: chart.collapsed, + viewport: chart.viewport, + pin: chart.viewState.pinnedKey, + key: f.latest.inspect?.key + }; + chart.style.setProperty('--scope-chart-height', `${height}px`); + f.host.style.display = ''; + await f.settle(); + await f.settle(); + const scene = chart.scene!; + const clip = scene.clipRect ?? scene.rect!; + const key = `${f.entry.family}:node-20`; + const target = scene.interactionByKey.get(key); + const mark = scene.marks.find((mark) => mark.kind === 'rect' && mark.key === key); + const visible = + mark?.kind === 'rect' && + Math.min(mark.y + mark.height, clip.y + clip.height) > Math.max(mark.y, clip.y) && + Math.min(mark.x + mark.width, clip.x + clip.width) > Math.max(mark.x, clip.x); + return { + hidden, + visible, + anchorVisible: + !!target?.anchor && + target.anchor.y >= clip.y && + target.anchor.y <= clip.y + clip.height, + pin: chart.viewState.pinnedKey, + key: f.latest.inspect?.key, + snapshotPainted: scene !== snapshot + }; + }, + { pin, height, collapsed } + ); + const key = `${family}:node-20`; + expect(result.hidden).toEqual({ + collapsed: [], + viewport: { start: 0, end: 1, scrollOffset: 328 }, + pin: pin ? key : null, + key + }); + expect(result.snapshotPainted).toBe(true); + expect(result.visible).toBe(true); + expect(result.anchorVisible).toBe(true); + expect(result.key).toBe(key); + expect(result.pin).toBe(pin ? key : null); }); - expect(result.snapshotPainted).toBe(true); - expect(result.visible).toBe(true); - expect(result.key).toBe(key); - expect(result.pin).toBe(pin ? key : null); - }); + } } } @@ -312,14 +313,12 @@ for (const family of ['trace', 'profile'] as const) { }); }); - for (const throws of [false, true]) { - test(`independent viewport observers receive nested changes in order, throws=${throws}`, async ({ - page - }) => { + for (const scenario of ['single', 'coalesced', 'throwing'] as const) { + test(`nested viewport broadcasts stay ordered: ${scenario}`, async ({ page }) => { const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); const result = await page.evaluate( - async ({ first, latest, throws }) => { + async ({ first, latest, scenario }) => { const f = window.notificationFixture; const chart = f.chart as Hierarchy; f.pin(); @@ -328,11 +327,12 @@ for (const family of ['trace', 'profile'] as const) { let changed = false; chart.addEventListener('scope-viewport-change', (event) => { early.push({ ...(event as CustomEvent).detail }); - if (!changed) { - changed = true; - chart.viewport = latest; - if (throws) throw new Error('Viewport observer failure'); - } + if (changed) return; + changed = true; + if (scenario === 'coalesced') + chart.viewport = { start: 0, end: 0.5, scrollOffset: 0 }; + chart.viewport = latest; + if (scenario === 'throwing') throw new Error('Viewport observer failure'); }); f.host.addEventListener('scope-viewport-change', (event) => late.push({ ...(event as CustomEvent).detail }) @@ -341,44 +341,13 @@ for (const family of ['trace', 'profile'] as const) { await f.settle(); return { early, late, viewport: chart.viewport }; }, - { first, latest, throws } + { first, latest, scenario } ); expect(result.early).toEqual([first, latest]); expect(result.late).toEqual(result.early); expect(result.viewport).toEqual(latest); - expect(errors).toEqual(throws ? ['Viewport observer failure'] : []); + expect(errors).toEqual(scenario === 'throwing' ? ['Viewport observer failure'] : []); }); } - - test('nested viewport changes coalesce to the latest window', async ({ page }) => { - const result = await page.evaluate( - async ({ first, latest }) => { - const f = window.notificationFixture; - const chart = f.chart as Hierarchy; - f.pin(); - const early: ViewportState[] = [], - late: ViewportState[] = []; - let changed = false; - chart.addEventListener('scope-viewport-change', (event) => { - early.push({ ...(event as CustomEvent).detail }); - if (!changed) { - changed = true; - chart.viewport = { start: 0, end: 0.5, scrollOffset: 0 }; - chart.viewport = latest; - } - }); - f.host.addEventListener('scope-viewport-change', (event) => - late.push({ ...(event as CustomEvent).detail }) - ); - chart.viewport = first; - await f.settle(); - return { early, late, viewport: chart.viewport }; - }, - { first, latest } - ); - expect(result.early).toEqual([first, latest]); - expect(result.late).toEqual(result.early); - expect(result.viewport).toEqual(latest); - }); }); } diff --git a/tests/browser/inspection-contract.spec.ts b/tests/browser/inspection-contract.spec.ts index 1be49d5..1cc2101 100644 --- a/tests/browser/inspection-contract.spec.ts +++ b/tests/browser/inspection-contract.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; import type { InspectionContext } from '../../src/index.js'; -import { inspectionEntries } from './fixtures/inspection-matrix.js'; +import { inspectionEntries, inspectionValue } from './fixtures/inspection-matrix.js'; test('metric presentation rejects an anchor result after its callback dismisses the reading', async ({ page @@ -90,163 +90,97 @@ test('metric presentation rejects an anchor result after its callback dismisses expect(result.keys.every((key) => key === null)).toBe(true); }); -for (const entry of inspectionEntries) { - test(`${entry.name} preserves its repeated reading notification policy`, async ({ page }) => { - await page.goto('/tests/browser/fixtures/blank.html'); - const result = await page.evaluate(async (entry) => { - const module = '/tests/browser/fixtures/inspection-matrix.ts'; - const { - createInspectionFixture, - inspectionValue - }: typeof import('./fixtures/inspection-matrix.js') = await import(module); - const f = await createInspectionFixture(entry); - f.events.length = 0; - f.key('Home'); - f.key('Home'); - return { - inspected: f.events - .filter((event) => event.channel === 'inspect') - .map((event) => ({ - value: inspectionValue(event.context), - snapshot: event.context?.snapshotId - })), - selections: f.events.filter((event) => event.channel === 'select').length - }; - }, entry); - expect(result.inspected).toHaveLength(entry.surface === 'component' ? 2 : 1); - expect(result.inspected.every((reading) => reading.value === '100')).toBe(true); - expect(new Set(result.inspected.map((reading) => reading.snapshot)).size).toBe(1); - expect(result.selections).toBe(0); - }); - - test(`${entry.name} retains unread context when its selection callback replaces the same key`, async ({ - page - }) => { - await page.goto('/tests/browser/fixtures/blank.html'); - const result = await page.evaluate(async (entry) => { - const module = '/tests/browser/fixtures/inspection-matrix.ts'; - const { - createInspectionFixture, - inspectionValue - }: typeof import('./fixtures/inspection-matrix.js') = await import(module); - const f = await createInspectionFixture(entry); - let retained: InspectionContext | null = null; - let replacement: Promise | undefined; - f.hook = (notification) => { - if (notification.channel !== 'select' || !notification.context || retained) return; - retained = notification.context; - replacement = f.replace('200'); - }; - f.pin(); - await replacement; - await f.settle(); - f.key('Home'); - const old = retained as InspectionContext | null; - const latest = f.latest.inspect; - f.dispose(); - return { - oldValue: inspectionValue(old), - latestValue: inspectionValue(latest), - sameKey: old?.key === latest?.key, - distinctSnapshots: old?.snapshotId !== latest?.snapshotId - }; - }, entry); - expect(result).toEqual({ - oldValue: '100', - latestValue: '200', - sameKey: true, - distinctSnapshots: true - }); - }); -} - for (const entry of inspectionEntries.filter((entry) => entry.surface === 'component')) { - test(`${entry.name} waits for the replacement frame before reporting ready from a hidden container`, async ({ - page - }) => { - await page.goto('/tests/browser/fixtures/blank.html'); - const result = await page.evaluate(async (entry) => { - const module = '/tests/browser/fixtures/inspection-matrix.ts'; - const { - createInspectionFixture, - inspectionValue - }: typeof import('./fixtures/inspection-matrix.js') = await import(module); - const f = await createInspectionFixture(entry); - f.key('Home'); - const previous = f.latest.inspect?.snapshotId; - f.host.style.display = 'none'; - await f.settle(); - let ready = 0; - f.chart!.addEventListener('scope-ready', () => ready++); - await f.replace('200', false); - await f.settle(); - const hiddenReady = ready; - const shown = new Promise((resolve) => - f.chart!.addEventListener('scope-ready', resolve, { once: true }) - ); - f.host.style.display = ''; - await shown; - f.key('Home'); - return { - hiddenReady, - ready, - value: inspectionValue(f.latest.inspect), - distinctSnapshot: previous !== f.latest.inspect?.snapshotId - }; - }, entry); - expect(result.hiddenReady).toBe(0); - expect(result.ready).toBeGreaterThan(0); - expect(result.value).toBe('200'); - expect(result.distinctSnapshot).toBe(true); - }); + test.describe(entry.name, () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/browser/fixtures/blank.html'); + await page.evaluate(async (entry) => { + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { createInspectionFixture }: typeof import('./fixtures/inspection-matrix.js') = + await import(module); + window.notificationFixture = await createInspectionFixture(entry); + }, entry); + }); + test('waits for the replacement frame before reporting ready from a hidden container', async ({ + page + }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + f.key('Home'); + const previous = f.latest.inspect?.snapshotId; + f.host.style.display = 'none'; + await f.settle(); + let ready = 0; + f.chart!.addEventListener('scope-ready', () => ready++); + await f.replace('200', false); + await f.settle(); + const hiddenReady = ready; + const shown = new Promise((resolve) => + f.chart!.addEventListener('scope-ready', resolve, { once: true }) + ); + f.host.style.display = ''; + await shown; + f.key('Home'); + return { + hiddenReady, + ready, + context: f.latest.inspect, + distinctSnapshot: previous !== f.latest.inspect?.snapshotId + }; + }); + expect(result.hiddenReady).toBe(0); + expect(result.ready).toBeGreaterThan(0); + expect(inspectionValue(result.context)).toBe('200'); + expect(result.distinctSnapshot).toBe(true); + }); - test(`${entry.name} broadcasts one captured selection while getters expose the nested document`, async ({ - page - }) => { - await page.goto('/tests/browser/fixtures/blank.html'); - const result = await page.evaluate(async (entry) => { - const module = '/tests/browser/fixtures/inspection-matrix.ts'; - const { - createInspectionFixture, - inspectionValue - }: typeof import('./fixtures/inspection-matrix.js') = await import(module); - const f = await createInspectionFixture(entry); - const chart = f.chart!; - const currentValue = () => { - const doc = chart.document!; - return 'data' in doc - ? doc.data.rows[0][1] - : 'spans' in doc - ? doc.spans[0].endTimeNs - : doc.frames[0].selfWeight; - }; - const observed: { listener: number; value: string | null; current: string | null }[] = []; - const afterSetter: (string | null)[] = []; - let replacement: Promise | undefined; - for (const listener of [1, 2]) - chart.addEventListener('scope-select', (event) => { - const { context } = (event as CustomEvent<{ context: InspectionContext | null }>).detail; - if (!context) return; - const value = inspectionValue(context); - observed.push({ listener, value, current: currentValue() }); - if (listener === 1 && value === '100') { - replacement = f.replace('200'); - afterSetter.push(currentValue()); - } - }); - f.pin(); - await replacement; - await f.settle(); - return { observed, afterSetter }; - }, entry); - expect(result).toEqual({ - observed: [ - { listener: 1, value: '100', current: '100' }, - { listener: 2, value: '100', current: '200' }, - { listener: 1, value: '200', current: '200' }, - { listener: 2, value: '200', current: '200' } - ], - afterSetter: ['200'] + test('broadcasts one captured selection while getters expose the nested document', async ({ + page + }) => { + const result = await page.evaluate(async () => { + const f = window.notificationFixture; + const chart = f.chart!; + const module = '/tests/browser/fixtures/inspection-matrix.ts'; + const { inspectionValue }: typeof import('./fixtures/inspection-matrix.js') = await import( + module + ); + const currentValue = () => { + const doc = chart.document!; + return 'data' in doc + ? doc.data.rows[0][1] + : 'spans' in doc + ? doc.spans[0].endTimeNs + : doc.frames[0].selfWeight; + }; + const observed: { listener: number; value: string | null; current: string | null }[] = []; + const afterSetter: (string | null)[] = []; + let replacement: Promise | undefined; + for (const listener of [1, 2]) + chart.addEventListener('scope-select', (event) => { + const { context } = (event as CustomEvent<{ context: InspectionContext | null }>) + .detail; + if (!context) return; + const value = inspectionValue(context); + observed.push({ listener, value, current: currentValue() }); + if (listener === 1 && value === '100') { + replacement = f.replace('200'); + afterSetter.push(currentValue()); + } + }); + f.pin(); + await replacement; + await f.settle(); + return { observed, afterSetter }; + }); + expect(result).toEqual({ + observed: [ + { listener: 1, value: '100', current: '100' }, + { listener: 2, value: '100', current: '200' }, + { listener: 1, value: '200', current: '200' }, + { listener: 2, value: '200', current: '200' } + ], + afterSetter: ['200'] + }); }); }); } diff --git a/tests/browser/inspection-notifications.spec.ts b/tests/browser/inspection-notifications.spec.ts index 22eac37..5510f4c 100644 --- a/tests/browser/inspection-notifications.spec.ts +++ b/tests/browser/inspection-notifications.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import type { InspectionContext } from '../../src/index.js'; import { inspectionEntries, inspectionValue } from './fixtures/inspection-matrix.js'; for (const entry of inspectionEntries) { @@ -13,6 +14,20 @@ for (const entry of inspectionEntries) { }, entry); }); + test('preserves its repeated reading notification policy', async ({ page }) => { + const result = await page.evaluate(() => { + const f = window.notificationFixture; + f.events.length = 0; + f.key('Home'); + f.key('Home'); + return f.events; + }); + expect(result).toHaveLength(entry.surface === 'component' ? 2 : 1); + expect(result.every((event) => event.channel === 'inspect')).toBe(true); + expect(result.every((event) => inspectionValue(event.context) === '100')).toBe(true); + expect(new Set(result.map((event) => event.context?.snapshotId)).size).toBe(1); + }); + test('pointer and keyboard deliver matching targets, exact snapshots and independent selections', async ({ page }) => { @@ -104,7 +119,9 @@ for (const entry of inspectionEntries) { expect(result.repeated).toEqual([]); }); - test(`${channel} callback replacement supersedes the earlier snapshot`, async ({ page }) => { + test(`${channel} callback replacement supersedes the earlier snapshot and retains unread context`, async ({ + page + }) => { const result = await page.evaluate(async (channel) => { const f = window.notificationFixture; const module = '/tests/browser/fixtures/inspection-matrix.ts'; @@ -112,21 +129,29 @@ for (const entry of inspectionEntries) { await import(module); f.pin(); f.events.length = 0; + const previous = f.latest.select!.snapshotId; + let retained: InspectionContext | null = null; let replacement: Promise | undefined; f.hook = (notification) => { if ( - !replacement && + !retained && notification.channel === channel && - inspectionValue(notification.context) === '200' - ) + notification.context && + notification.context.snapshotId !== previous + ) { + retained = notification.context; replacement = f.replace('300'); + } }; await f.replace('200'); await f.settle(); await replacement; await f.settle(); + const latest = { ...f.latest }; + f.dispose(); return { - latest: f.latest, + latest, + retained: retained as InspectionContext | null, values: f.events.map((event) => ({ channel: event.channel, value: inspectionValue(event.context) @@ -134,6 +159,9 @@ for (const entry of inspectionEntries) { }; }, channel); expect(inspectionValue(result.latest.select)).toBe('300'); + expect(inspectionValue(result.retained)).toBe('200'); + expect(result.retained?.key).toBe(result.latest.select?.key); + expect(result.retained?.snapshotId).not.toBe(result.latest.select?.snapshotId); for (const name of ['inspect', 'select']) { const values = result.values .filter((event) => event.channel === name) diff --git a/tests/browser/interaction-transactions.spec.ts b/tests/browser/interaction-transactions.spec.ts index d2614f1..87ebbb2 100644 --- a/tests/browser/interaction-transactions.spec.ts +++ b/tests/browser/interaction-transactions.spec.ts @@ -10,83 +10,21 @@ for (const family of ['trace', 'profile'] as const) { await page.goto('/tests/browser/fixtures/blank.html'); await page.evaluate(async (family) => { const module = '/tests/browser/fixtures/inspection-matrix.ts'; - const { createInspectionFixture }: typeof import('./fixtures/inspection-matrix.js') = - await import(module); + const { + createInspectionFixture, + hierarchyDocument + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); const f = await createInspectionFixture({ name: 'transactions', family, surface: 'component' }); window.notificationFixture = f; - const nodes = Array.from({ length: 50 }, (_, index) => ({ - id: `node-${index}`, - name: `node-${index}`, - ...(index ? { parentId: `node-${index - 1}` } : {}) - })); - f.chart!.document = - family === 'trace' - ? { - version: 1, - kind: family, - spans: nodes.map((node) => ({ ...node, startTimeNs: '0', endTimeNs: '10000' })) - } - : { - version: 1, - kind: family, - unit: 'samples', - frames: nodes.map((node) => ({ ...node, selfWeight: '1' })) - }; + f.chart!.document = hierarchyDocument(family); await f.settle(); }, family); }); - for (const height of [120, 240]) { - for (const pin of [false, true]) { - test(`hidden focus below a collapsed ancestor survives ${height}px measurement, pin=${pin}`, async ({ - page - }) => { - const result = await page.evaluate( - async ({ height, pin }) => { - const f = window.notificationFixture; - const chart = f.chart as Hierarchy; - chart.collapsed = ['node-0']; - await f.settle(); - chart.focusNode('node-0', { pin: true }); - f.host.style.display = 'none'; - await f.settle(); - chart.focusNode('node-20', { pin }); - const accepted = { - key: f.latest.inspect?.key, - pin: chart.viewState.pinnedKey, - collapsed: chart.collapsed - }; - chart.style.setProperty('--scope-chart-height', `${height}px`); - f.host.style.display = ''; - await f.settle(); - const scene = chart.scene!; - const target = scene.interactionByKey.get(`${f.entry.family}:node-20`); - const rect = scene.rect!; - return { - accepted, - key: f.latest.inspect?.key, - pin: chart.viewState.pinnedKey, - visible: - !!target?.anchor && - target.anchor.y >= rect.y && - target.anchor.y <= rect.y + rect.height - }; - }, - { height, pin } - ); - const key = `${family}:node-20`; - expect(result.accepted).toEqual({ key, pin: pin ? key : null, collapsed: [] }); - expect(result.key).toBe(key); - expect(result.pin).toBe(pin ? key : null); - expect(result.visible).toBe(true); - }); - } - } - test('same-window acknowledgements quiesce without another notification', async ({ page }) => { const result = await page.evaluate(async () => { const f = window.notificationFixture; diff --git a/tests/unit/core/inspection-notifications.test.ts b/tests/unit/core/inspection-notifications.test.ts index 4d46161..433d600 100644 --- a/tests/unit/core/inspection-notifications.test.ts +++ b/tests/unit/core/inspection-notifications.test.ts @@ -21,11 +21,12 @@ function fixture() { select: { target: a } }; const read = vi.fn(() => state); - const notifications = new InspectionNotifications(read, new Interaction()); + const interaction = new Interaction(); + const notifications = new InspectionNotifications(read, interaction); const sent: string[] = []; const deliver = (value: { target: ChartInteraction | null }) => sent.push(value.target?.key ?? 'null'); - return { a, state, read, notifications, sent, deliver }; + return { a, state, read, interaction, notifications, sent, deliver }; } describe('inspection notifications', () => { @@ -34,9 +35,7 @@ describe('inspection notifications', () => { 'select' ] as const)('preserves fresh %s-first order and independently coalesces nested channel values', (first: InspectionChannel) => { const other = first === 'inspect' ? 'select' : 'inspect'; - const { state } = fixture(); - const interaction = new Interaction(); - const notifications = new InspectionNotifications(() => state, interaction); + const { state, interaction, notifications } = fixture(); const seen: string[] = []; const emit = () => { const captured = notifications.capture(); @@ -87,13 +86,13 @@ describe('inspection notifications', () => { expect(seen).toEqual(['first:a/a', 'after:a/b', 'second:a/b', 'first:b/b', 'second:b/b']); }); - it('coalesces pending explicit reading input as state rather than an action history', () => { - const { state, deliver, sent } = fixture(); - const interaction = new Interaction(); - const reports = new InspectionNotifications(() => state, interaction); + it.each([true, false])('coalesces explicit input followed by a forced=%s report', (force) => { + const { interaction, notifications, deliver, sent } = fixture(); + notifications.capture().emit('inspect', deliver); + sent.length = 0; interaction.batch(() => { - reports.capture().emit('inspect', deliver, true); - reports.capture().emit('inspect', deliver, true); + notifications.capture().emit('inspect', deliver, true); + notifications.capture().emit('inspect', deliver, force); }); expect(sent).toEqual(['a']); }); @@ -188,12 +187,18 @@ describe('inspection notifications', () => { expect(sent).toEqual(['c']); }); - it('cancels queued deliveries on disposal', () => { - const { state, notifications, deliver, sent } = fixture(); + it('cancels queued deliveries and releases the reader on permanent disposal', () => { + const { state, notifications, deliver, sent, read } = fixture(); + const pending = notifications.capture(); notifications.capture().emit('inspect', () => { state.select.target = target('b'); notifications.capture().emit('select', deliver); notifications.dispose(); + read.mockImplementation(() => { + throw new Error('Disposed reader'); + }); + expect(pending.emit('select', deliver)).toBe(false); + expect(notifications.capture().emit('inspect', deliver)).toBe(false); }); expect(sent).toEqual([]); }); @@ -270,9 +275,7 @@ describe('inspection notifications', () => { }); it('revalidates reports after earlier control effects replace their state', () => { - const { state, sent, deliver } = fixture(); - const interaction = new Interaction(); - const notifications = new InspectionNotifications(() => state, interaction); + const { state, interaction, notifications, sent, deliver } = fixture(); interaction.batch(() => { const pending = notifications.capture(); pending.emit('select', deliver); @@ -310,15 +313,4 @@ describe('inspection notifications', () => { expect(pointer.emit('select', deliver)).toBe(false); expect(sent).toEqual(['a', 'a', 'a']); }); - - it('silences pending work and releases the reader on permanent disposal', () => { - const { notifications, deliver, read } = fixture(); - const pending = notifications.capture(); - notifications.dispose(); - read.mockImplementation(() => { - throw new Error('Disposed reader'); - }); - expect(pending.emit('select', deliver)).toBe(false); - expect(notifications.capture().emit('inspect', deliver)).toBe(false); - }); }); diff --git a/tests/unit/core/interaction.test.ts b/tests/unit/core/interaction.test.ts index 6db255b..ac693ef 100644 --- a/tests/unit/core/interaction.test.ts +++ b/tests/unit/core/interaction.test.ts @@ -107,24 +107,6 @@ describe('interaction transactions', () => { expect(seen).toEqual(['current']); }); - it('preserves an explicit input report when a quiet repaint captures the same reading', () => { - const interaction = new Interaction(); - const target = { key: 'a', sources: ['a'], tooltip: { rows: [] } }; - const state = { snapshot: {}, inspect: { target }, select: { target: null } }; - const reports = new InspectionNotifications(() => state, interaction); - const sent: string[] = []; - const deliver = (value: { target: { key: string } | null }) => { - sent.push(value.target?.key ?? 'null'); - }; - reports.capture().emit('inspect', deliver); - sent.length = 0; - interaction.batch(() => { - reports.capture().emit('inspect', deliver, true); - reports.capture().emit('inspect', deliver); - }); - expect(sent).toEqual(['a']); - }); - it('consumes a Canvas provider once and detaches borrowed facades without resetting the host', () => { const parent = new ChartSession(); const target = { key: 'a', sources: ['a'], tooltip: { rows: [] } }; From 63774a86f32151204bc539fb498e449329602c71 Mon Sep 17 00:00:00 2001 From: leiysky Date: Fri, 30 Oct 2026 00:00:11 +0800 Subject: [PATCH 8/8] refactor: restore standalone build and family dispatch --- scripts/build.mjs | 4 ++-- src/headless.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 01f1e68..21dd9ad 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -25,7 +25,7 @@ await build({ lib: { entry: entries, formats: ['es'], fileName: (_, name) => `${name}.js` } } }); -// Offline HTML and classic scripts consume the built public entry, just like installed hosts. +// Offline HTML and classic scripts use the same all-family browser implementation. await build({ configFile: false, build: { @@ -34,7 +34,7 @@ await build({ cssTarget, minify: true, lib: { - entry: resolve('dist/headless.js'), + entry: entries.headless, name: 'ScopeView', formats: ['iife'], fileName: () => 'scopeview.js' diff --git a/src/headless.ts b/src/headless.ts index 2cffed6..1fbe174 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -23,6 +23,7 @@ export function createVisualization( ): ChartView { const kind = document && typeof document === 'object' && 'kind' in document ? document.kind : undefined; - const create = kind === 'trace' ? createTrace : kind === 'profile' ? createFlame : createChart; - return create(host, document, options); + if (kind === 'trace') return createTrace(host, document, options); + if (kind === 'profile') return createFlame(host, document, options); + return createChart(host, document, options); }