diff --git a/CHANGELOG.md b/CHANGELOG.md index 396131f..df9b977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All significant changes to ScopeView are documented in this file. ## Unreleased +### Bug fixes + +- `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 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) ### New features diff --git a/development/architecture.md b/development/architecture.md index 65d3363..a4b6977 100644 --- a/development/architecture.md +++ b/development/architecture.md @@ -51,13 +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` 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 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. 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. + +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..866ee61 --- /dev/null +++ b/development/interaction-transactions.md @@ -0,0 +1,77 @@ +# Inspection transactions + +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. + +## Contract before mechanism + +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. + +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. + +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. + +| 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. | + +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. + +## State ownership + +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. + +`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. + +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. + +## Commit, project and publish + +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. + +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. + +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. + +| 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. | + +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. + +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. + +## Geometry and lifecycle + +`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. + +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. + +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. + +## Verification and package boundaries + +| 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. + +## Design references + +- [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 a7afaa4..3763653 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -86,6 +86,23 @@ 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. +## 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 `createReport` returns `Promise`. Its `update` method also returns a Promise: diff --git a/docs/view-state.md b/docs/view-state.md index 9827e8d..ca78356 100644 --- a/docs/view-state.md +++ b/docs/view-state.md @@ -38,6 +38,12 @@ 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. + +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/core/inspection-notifications.ts b/src/core/inspection-notifications.ts index 26d7a0f..e66c357 100644 --- a/src/core/inspection-notifications.ts +++ b/src/core/inspection-notifications.ts @@ -1,3 +1,4 @@ +import { channel, type InteractionChannel, type Interaction } from './interaction.js'; import type { ChartInteraction } from './scene.js'; export type InspectionChannel = 'inspect' | 'select'; @@ -14,134 +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: channel(empty, same), + select: 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; - try { - return send(); - } finally { - try { - for (const pending of this.#pending) pending(); - } 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..bdd43f3 --- /dev/null +++ b/src/core/interaction.ts @@ -0,0 +1,131 @@ +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; + #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 | NotificationPhase = 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; + } + + report( + channel: InteractionChannel, + value: T, + current: () => boolean, + deliver: (value: T) => void, + force = false + ): boolean { + const ready = () => current() && (force || !channel.same(channel.value, value)); + return this.effect( + () => { + channel.value = value; + deliver(value); + }, + channel, + 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; + 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. +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..1958e73 --- /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; + 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 (explicit) { + state.selected = pin ? value : null; + state.selectedSeries = pin ? series : null; + } + if (explicit || 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..451f180 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,19 @@ 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 { + channel, + interactionFor, + readingPhase, + viewStatePhase, + readyPhase +} from '../core/interaction.js'; +import { + restoredKey, + restoreReading, + shareReading, + releaseReading +} from '../core/reading-state.js'; import { ChartSession } from '../core/session.js'; import { InspectionNotifications, @@ -93,7 +107,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 +129,26 @@ 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 = channel([], (a, b) => JSON.stringify(a) === JSON.stringify(b)); + #readingEffect = {}; #stateReason: ViewStateChangeDetail['reason'] = 'reconcile'; - #lastState = ''; + #stateReports = channel( + null, + (a, b) => JSON.stringify(a?.state) === JSON.stringify(b?.state), + viewStatePhase + ); #linkedHighlight: LinkedHighlight | null = null; #stateTransaction = false; #cursorTimeMs: number | null = null; @@ -132,7 +156,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 +188,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 +271,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 +282,18 @@ 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!); + } + ); } /** Ephemeral trusted binding; never serialized into a chart document. */ set rangeSelection(binding: RangeSelectionBinding | null) { @@ -420,13 +471,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 +490,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 +509,39 @@ 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' || + (snapshot && + this.isConnected && + !this.loading && + !this.error && + !this.#error && + scene === this.#plot?.model && + (snapshot.config.mapping.kind === 'aggregate' || sceneSnapshot(scene) === snapshot))) + ) + this.#dispatch(name, detail); + }, + {}, + name === 'scope-ready' ? readyPhase : readingPhase + ); } #dispatch(name: K, detail: ScopeChartEventDetails[K]) { this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })); @@ -471,7 +557,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 +571,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 +615,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 +630,108 @@ 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); + 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); + if (this.#plot) this.#showInspection(null, false, true, batch); + 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 +835,17 @@ 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(); + this.#showInspection(null, false, false, batch); + this.#reportInspection(batch); + this.#stateChanged(reason); + }); } #inspect( value: ChartInteraction | null, @@ -745,44 +855,69 @@ 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 + }); + 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 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 = ( @@ -794,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; @@ -812,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; @@ -824,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 a08fc13..feb35d2 100644 --- a/src/elements/controls.ts +++ b/src/elements/controls.ts @@ -1,3 +1,4 @@ +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'; @@ -89,16 +90,23 @@ export function renderToolbar( export class ControlEvents { #previous = ''; + 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, + controlsPhase ); } } diff --git a/src/elements/hierarchy-element.ts b/src/elements/hierarchy-element.ts index 1166f61..9a0eb4d 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,25 @@ 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 { + 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 { 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'; @@ -90,6 +100,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, @@ -98,15 +112,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; @@ -117,34 +144,42 @@ 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; + #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 = channel({ start: 0, end: 1, scrollOffset: 0 }, sameViewport, viewportPhase); + #stateReports = channel( + null, + (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'; - #lastState = ''; #stateTransaction = false; #linkedHighlight: LinkedHighlight | null = null; - #controlEvents = new ControlEvents(); + #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 = @@ -184,68 +219,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'); - 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.#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 }; @@ -256,11 +283,17 @@ 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!); + } + ); } get loading() { return this.hasAttribute('loading'); @@ -320,23 +353,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 { @@ -355,63 +387,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}`); - 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); @@ -420,15 +450,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' ? readyPhase : readingPhase + ); } #dispatch>( type: K, @@ -437,15 +492,29 @@ export class HierarchyElement< this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })); } #changedViewport() { - if ( - !this.#runtime?.session.pinned && - !this.#runtime?.session.keyboardInspection && - !this.#pendingPin - ) - this.#clearInspection(); - this.#runtime?.refresh(); - this.#emit('scope-viewport-change', { ...this.viewport }); - this.#stateChanged(); + 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 }) + ); } #schedule() { if (!this.isConnected || this.#frame) return; @@ -460,8 +529,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(); } @@ -506,14 +574,37 @@ export class HierarchyElement< }, { signal } ); + const canvas = this.#el('.canvas'); + ownInspectionDismissal(canvas, signal); + canvas.addEventListener( + 'keydown', + (event) => { + if ( + 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)); + }, + { signal } + ); this.#root.addEventListener( 'keydown', (event) => { - if ((event as KeyboardEvent).key === 'Escape' && this.#runtime?.session.active) { - event.preventDefault(); - this.#clearInspection(); - this.#el('.canvas').focus({ preventScroll: true }); - } + if ( + (event as KeyboardEvent).key !== 'Escape' || + event.composedPath().includes(canvas) || + (!this.#session.active && restoredKey(this.#session) === undefined) + ) + return; + event.preventDefault(); + this.#clearInspection(); + canvas.focus({ preventScroll: true }); }, { signal } ); @@ -531,8 +622,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 } @@ -545,14 +635,16 @@ export class HierarchyElement< this.#schedule(); } disconnectedCallback() { - 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; @@ -560,11 +652,16 @@ export class HierarchyElement< else this.refresh(); } #dispose() { - 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; @@ -575,100 +672,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!; - 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; + 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.#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) => 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 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; @@ -689,6 +794,8 @@ export class HierarchyElement< if (compiled !== this.#compiled || !this.isConnected) return; this.#showInspection(null, false, this.#stateReason); if (compiled !== this.#compiled || !this.isConnected) return; + this.#publishViewport(); + if (compiled !== this.#compiled || !this.isConnected) return; this.#stateChanged(this.#stateReason); this.#stateReason = 'reconcile'; this.#emit('scope-ready', { @@ -696,27 +803,6 @@ export class HierarchyElement< nodes: compiled.hierarchy.sources.length }); } - #reveal(target: ChartInteraction) { - const item = this.#view?.byId.get(target.sources[0]?.id); - if (!item) return; - const before = this.viewport; - 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 - ) { - this.#runtime?.paint(); - this.#emit('scope-viewport-change', { ...after }); - this.#stateChanged(); - } - } #detail( reading: InspectionReading, compiled: Compiled | null @@ -733,12 +819,12 @@ export class HierarchyElement< () => (compiled ? project(compiled, active) : null) ); } - #clearInspection() { - this.#notifications.invalidate(); - this.#pendingPin = undefined; - 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, @@ -746,6 +832,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); @@ -776,22 +884,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..39cf0e3 --- /dev/null +++ b/src/families/hierarchy/interaction.ts @@ -0,0 +1,228 @@ +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; +} + +/** Pure hierarchy commands own semantic state; presentation never writes it back. */ +export class HierarchyInteraction> { + readonly viewport: ViewportSession; + #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: ( + document: Document, + collapsed: ReadonlySet + ) => HierarchyView + ) { + this.viewport = new OwnedViewport( + () => ({ ...this.#window }), + (change) => { + const viewport = this.#geometry(change); + this.#window = viewport; + this.#report = {}; + this.#focus = this.#restore = null; + } + ); + } + get compiled() { + return this.#compiled; + } + get view() { + return this.#view; + } + get collapsed(): ReadonlySet { + return this.#collapsed; + } + get desiredViewport() { + return { ...(this.#restore ?? this.#window) }; + } + get report() { + return this.#report; + } + get active() { + return this.#active; + } + + #geometry(change?: (viewport: ViewportSession) => void) { + const viewport = new ViewportSession(); + viewport.configure({ + contentHeight: (this.#view?.rowCount ?? 0) * (this.#view?.rowHeight ?? 24), + visibleHeight: this.#height === null ? 0 : hierarchyViewportHeight(this.#height, this.#axes) + }); + viewport.set(this.#restore ?? this.#window); + change?.(viewport); + return { ...viewport.state }; + } + #adopt() { + 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.#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}`); + 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) + ); + 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.#active = true; + this.#adopt(); + } + deactivate() { + this.#active = false; + this.#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; + } + const view = this.createView(compiled, collapsed); + this.#collapsed = collapsed; + this.#view = view; + this.#adopt(); + 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; + this.#focus = + id && this.#view?.byId.has(id) + ? { id, snapshot: this.#compiled!, intent: readingState(this.session).intent } + : null; + 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.#height = height; + this.#axes = axes; + this.#position(true); + } + #position(measured: boolean) { + 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.#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/runtime/chart-runtime.ts b/src/runtime/chart-runtime.ts index bdfe6c2..b37dfcf 100644 --- a/src/runtime/chart-runtime.ts +++ b/src/runtime/chart-runtime.ts @@ -1,3 +1,6 @@ +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'; 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,32 +454,28 @@ export class ChartRuntime return; } if (this.options.keyboard === false) return; - 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'; - const 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'); - 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); + const target = inspectKey(event, this.session, true); + if (target === undefined) return; + if (target) { + const intent = readingState(this.session).intent; + interactionFor(this.session).effect(() => { + if (!this.signal.aborted && intent === readingState(this.session).intent) + this.options.onNavigate?.(target); + }); + } + 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); + }, + {}, + readingPhase + ); } 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..6c5db1d --- /dev/null +++ b/src/runtime/inspection-input.ts @@ -0,0 +1,40 @@ +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. */ +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..5367abb 100644 --- a/src/runtime/reading-runtime.ts +++ b/src/runtime/reading-runtime.ts @@ -1,12 +1,15 @@ -import { ChartSession } from '../core/session.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'; 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 { 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 +18,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,32 +55,37 @@ export class ReadingRuntime extends RuntimeLifecycle { this.announce(); } protected keyboard(event: KeyboardEvent) { - if (event.metaKey || event.ctrlKey || event.altKey || this.model?.interactive === false) return; - 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'; - const 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'); - 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); + 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); + const target = inspectKey(event, this.session, this.selectable); + if (target === undefined) return; + if (target) { + const intent = readingState(this.session).intent; + interaction.effect(() => { + if (!this.signal.aborted && intent === readingState(this.session).intent) + this.options.onNavigate?.(target); + }); + } + interaction.effect( + () => { + if (this.signal.aborted) return; + this.focus(this.session.active); + this.announce(); + this.options.onInspect?.(this.session.active, null, this.session.pinned); + }, + {}, + readingPhase + ); } 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/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 new file mode 100644 index 0000000..d97783e --- /dev/null +++ b/tests/browser/hierarchy-viewport.spec.ts @@ -0,0 +1,353 @@ +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) { + 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 = hierarchyDocument(family); + await f.settle(); + }, family); +} + +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 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); + }); + } + } + } + + 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('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 + }) => { + 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 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, scenario }) => { + 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) 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 }) + ); + chart.viewport = first; + await f.settle(); + return { early, late, viewport: chart.viewport }; + }, + { first, latest, scenario } + ); + expect(result.early).toEqual([first, latest]); + expect(result.late).toEqual(result.early); + expect(result.viewport).toEqual(latest); + expect(errors).toEqual(scenario === 'throwing' ? ['Viewport observer failure'] : []); + }); + } + }); +} diff --git a/tests/browser/inspection-contract.spec.ts b/tests/browser/inspection-contract.spec.ts new file mode 100644 index 0000000..1cc2101 --- /dev/null +++ b/tests/browser/inspection-contract.spec.ts @@ -0,0 +1,186 @@ +import { test, expect } from '@playwright/test'; +import type { InspectionContext } from '../../src/index.js'; +import { inspectionEntries, inspectionValue } 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.filter((entry) => entry.surface === 'component')) { + 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('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 4ebef94..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) @@ -337,6 +365,453 @@ 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 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) => { + 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'); + }); + + 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]) { + 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; + const chart = f.chart!; + if (!('viewport' in chart)) throw new Error('Expected a hierarchy component'); + f.pin(); + 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'); + const viewportEvents: unknown[] = []; + chart.addEventListener('scope-viewport-change', (event) => + viewportEvents.push((event as CustomEvent).detail) + ); + let restored = false; + f.hook = (notification) => { + if ( + !restored && + notification.channel === 'inspect' && + notification.key?.endsWith(':child') && + notification.context?.snapshotId !== previous?.snapshotId + ) { + restored = true; + chart.viewState = state; + } + }; + f.key('End'); + const immediate = { restored, pin: chart.viewState.pinnedKey }; + await f.settle(); + 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([]); + }); + } + 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/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..87ebbb2 --- /dev/null +++ b/tests/browser/interaction-transactions.spec.ts @@ -0,0 +1,199 @@ +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, + hierarchyDocument + }: typeof import('./fixtures/inspection-matrix.js') = await import(module); + const f = await createInspectionFixture({ + name: 'transactions', + family, + surface: 'component' + }); + window.notificationFixture = f; + f.chart!.document = hierarchyDocument(family); + await f.settle(); + }, family); + }); + + 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 93913bb..433d600 100644 --- a/tests/unit/core/inspection-notifications.test.ts +++ b/tests/unit/core/inspection-notifications.test.ts @@ -1,6 +1,8 @@ +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'; @@ -19,14 +21,82 @@ function fixture() { select: { target: a } }; const read = vi.fn(() => state); - const notifications = new InspectionNotifications(read); + 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', () => { + 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, interaction, notifications } = fixture(); + 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.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(() => { + notifications.capture().emit('inspect', deliver, true); + notifications.capture().emit('inspect', deliver, force); + }); + 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); @@ -68,7 +138,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']); }); @@ -116,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([]); }); @@ -141,17 +218,76 @@ describe('inspection notifications', () => { expect(sent).toEqual(['null', 'b']); }); - it('revalidates after control callbacks and does not commit superseded reports', () => { + it.each([ + 'inspect', + 'select' + ] as const)('drains the other channel when a queued %s callback throws', (channel) => { const { state, notifications, deliver, sent } = fixture(); - const pending = notifications.capture(); - expect( - pending.emit('select', deliver, { - before: () => { + 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); + }, + 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); - } - }) - ).toBe(false); + throw new Error('Queued failure'); + }); + throw error; + }); + } catch (value) { + caught(value); + } + expect(caught).toHaveBeenCalledExactlyOnceWith(error); + expect(sent).toEqual(['null']); + }); + + it('revalidates reports after earlier control effects replace their state', () => { + const { state, interaction, notifications, sent, deliver } = fixture(); + interaction.batch(() => { + const pending = notifications.capture(); + pending.emit('select', deliver); + interaction.effect( + () => { + state.select.target = null; + notifications.capture().emit('select', deliver); + }, + {}, + controlsPhase + ); + }); expect(sent).toEqual([]); expect(notifications.capture().emit('select', deliver)).toBe(false); }); @@ -173,19 +309,8 @@ 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']); }); - - 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 new file mode 100644 index 0000000..ac693ef --- /dev/null +++ b/tests/unit/core/interaction.test.ts @@ -0,0 +1,318 @@ +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, + channel, + interactionFor, + shareInteraction, + 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'; +import { HierarchyInteraction } from '../../../src/families/hierarchy/interaction.js'; +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 = 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('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 = 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 = channel(0, Object.is, viewportPhase); + const sent: number[] = []; + interaction.batch(() => { + interaction.report( + reports, + 1, + () => state === 1, + (value) => sent.push(value) + ); + interaction.effect( + () => { + state = 2; + interaction.report( + reports, + 2, + () => state === 2, + (value) => sent.push(value) + ); + }, + {}, + readingPhase + ); + }); + 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(); + } + }); +});