diff --git a/.githooks/ARCHITECTURE.md b/.githooks/ARCHITECTURE.md index 4326d60..822569c 100644 --- a/.githooks/ARCHITECTURE.md +++ b/.githooks/ARCHITECTURE.md @@ -1,5 +1,6 @@ -The `.githooks/` directory contains local git hooks that keep generated source in sync before a commit is created. +The `.githooks/` directory contains local git hooks that run publish-friendly +checks before a commit is created. -The current pre-commit hook runs `npm run sync:linear-layout-examples`. That command rewrites the baked linear-layout examples in the demo extension from the Python `demo_linear_layout.py` source when that source is present. This matters in the LL-viz checkout because the static demo must show the same examples as the Python script without asking contributors to edit generated TypeScript by hand. - -Standalone `tensor-viz` checkouts may not include the LL-viz Python demo source. In that case the sync tool leaves the baked examples unchanged, so normal tensor-viz commits are not blocked by a file that only exists in the parent project. +The current pre-commit hook runs `npm run check:ts-docs:staged`. That audits +staged TypeScript files for the documentation and helper-use rules in +`AGENTS.md` without forcing a full codebase audit on every small commit. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ac5e944..cc1342a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -5,6 +5,3 @@ repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" npm run check:ts-docs:staged -npm run sync:linear-layout-examples -unset GIT_DIR GIT_WORK_TREE -git -C "$repo_root" add packages/viewer-demo/src/extensions/linear-layout/linear-layout.ts diff --git a/.gitignore b/.gitignore index 5db6197..98fb333 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ python/src/*.egg-info/ python/src/tensor_viz/static/ packages/*/dist/ +packages/*/lib/ packages/*/node_modules/ docs/_build/ docs/_extra/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cc9fb04..7f99363 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ The `tensor-viz/` repository is a small monorepo because the project has three j First, `packages/viewer-core/` is the reusable viewer engine. It knows how to store tensors, parse tensor-view expressions, compute visible coordinates, and render the result. If a behavior should work in any host application, it belongs in core. -Second, `packages/viewer-demo/` is the browser application. It turns the core viewer into a complete UI with tabs, menus, widgets, a command palette, and optional extensions. The demo can add workflows such as linear-layout presets without teaching core about GPU instruction families. +Second, `packages/viewer-demo/` is the browser application and shell package. It turns the core viewer into a complete UI with tabs, menus, widgets, a command palette, and optional extension hooks. The stock tensor-viz app registers no domain-specific extensions; downstream packages such as LL-viz pass extension factories into the shell at startup. Third, `python/src/tensor_viz/` is the Python transport layer. It converts Python arrays or metadata into the same manifest format that the TypeScript viewer loads, then serves the built frontend and tensor bytes locally. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fc7cf8..13d0d8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,8 +62,7 @@ npm run check:ts-docs npm run check:ts-docs:staged ``` -The pre-commit hook runs `npm run check:ts-docs:staged` and -`npm run sync:linear-layout-examples`. Enable it with: +The pre-commit hook runs `npm run check:ts-docs:staged`. Enable it with: ```bash git config core.hooksPath .githooks @@ -74,7 +73,7 @@ useful. It requires `OPENAI_API_KEY` unless you pass `--print-prompt`. | Goal | Command | | --- | --- | -| Test the linear-layout parser example | `npm run check:ts-docs:llm-example` | +| Test the tensor-view parser example | `npm run check:ts-docs:llm-example` | | Test the example and auto-apply suggested JSDoc replacements | `npm run check:ts-docs:llm-example -- --apply` | | Audit staged TypeScript files that have no extra unstaged edits | `npm run check:ts-docs:llm` | | Audit all staged TypeScript blobs from the git index | `npm run check:ts-docs:llm -- --staged` | @@ -102,8 +101,8 @@ reads git-index blobs that may not match the working tree. Other useful LLM audit options: ```bash ---file=packages/viewer-demo/src/extensions/linear-layout/linear-layout-parser.ts ---symbol=parseLayoutSpecs +--file=packages/viewer-core/src/view.ts +--symbol=parseTensorView --include-direct-helpers --limit=10 --batch-size=4 @@ -115,9 +114,7 @@ Other useful LLM audit options: - `packages/viewer-core/src/`: reusable viewer engine, layout math, session model, rendering, and core tests. - `packages/viewer-demo/src/`: browser demo shell, command palette, widget - lifecycle, extension registry, and app tests. -- `packages/viewer-demo/src/extensions/linear-layout/`: linear-layout extension, - parser/model code, preset catalog, widgets, and tests. + lifecycle, extension registry, extension API exports, and app tests. - `python/src/tensor_viz/`: Python package, session builder, local server, and built frontend assets. - `python/tests/`: Python API and documentation-example tests. @@ -131,9 +128,6 @@ Architecture docs live next to the code they describe. Start with: - [Repository architecture](./ARCHITECTURE.md) - [Viewer core](./packages/viewer-core/src/ARCHITECTURE.md) - [Demo app shell](./packages/viewer-demo/src/ARCHITECTURE.md) -- [Linear layout extension](./packages/viewer-demo/src/extensions/linear-layout/ARCHITECTURE.md) -- [Linear layout presets](./packages/viewer-demo/src/extensions/linear-layout/presets/ARCHITECTURE.md) -- [Linear layout widgets](./packages/viewer-demo/src/extensions/linear-layout/widgets/ARCHITECTURE.md) - [Python package](./python/src/tensor_viz/ARCHITECTURE.md) - [Maintenance tools](./tools/ARCHITECTURE.md) - [Browser e2e tests](./packages/viewer-demo/e2e/ARCHITECTURE.md) diff --git a/package.json b/package.json index b107a2c..ea93ad3 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "check:ts-docs:llm": "node tools/check-ts-docs-llm.mjs", "check:ts-docs:llm-example": "node tools/llm-doc-audit-example.mjs", "check:ts-docs:staged": "node tools/check-ts-docs.mjs --staged", - "sync:linear-layout-examples": "python tools/sync-linear-layout-examples.py", "test": "npm run test:unit && npm run test:e2e", "test:e2e": "playwright test", "test:unit": "npm run test --workspace @tensor-viz/viewer-core && npm run test --workspace @tensor-viz/viewer-demo && PYTHONPATH=python/src python -m unittest discover -s python/tests -p 'test_*.py'", diff --git a/packages/viewer-core/src/viewer-graphics.ts b/packages/viewer-core/src/viewer-graphics.ts index ce319c1..cadc7dd 100644 --- a/packages/viewer-core/src/viewer-graphics.ts +++ b/packages/viewer-core/src/viewer-graphics.ts @@ -12,7 +12,7 @@ import { Vector3, } from 'three'; import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'; -import helvetikerBoldFont from 'three/examples/fonts/helvetiker_bold.typeface.json'; +import helvetikerBoldFont from 'three/examples/fonts/helvetiker_bold.typeface.json' with { type: 'json' }; import { VIEWER_LIMITS } from './validation.js'; const LABEL_FONT = new FontLoader().parse(helvetikerBoldFont as never); diff --git a/packages/viewer-demo/e2e/viewer-smoke.spec.ts b/packages/viewer-demo/e2e/viewer-smoke.spec.ts index 2def74d..92c4a76 100644 --- a/packages/viewer-demo/e2e/viewer-smoke.spec.ts +++ b/packages/viewer-demo/e2e/viewer-smoke.spec.ts @@ -28,14 +28,10 @@ test('viewer demo boots, paints tensors, and exposes core controls', async ({ pa // startup await expect(page.locator('.ribbon')).toBeVisible(); await expect(page.locator('#viewport')).toBeVisible(); - expect(await page.locator('.tab-button').count()).toBeGreaterThan(0); - // extension widgets - await expect(page.locator('#linear-layout-preset-widget')).toBeVisible(); - await expect(page.locator('#linear-layout-widget')).toBeVisible(); + // core widgets await expect(page.locator('#tensor-view-widget')).toBeVisible(); - await expect(page.getByText('Preset', { exact: true })).toBeVisible(); - await expect(page.getByText('Layout Specs', { exact: true })).toBeVisible(); + await expect(page.locator('#inspector-widget')).toBeVisible(); // viewport paint await page.waitForFunction(() => ( diff --git a/packages/viewer-demo/index.html b/packages/viewer-demo/index.html index e04adba..45b7977 100644 --- a/packages/viewer-demo/index.html +++ b/packages/viewer-demo/index.html @@ -4,7 +4,7 @@ - Linear Layout Visualizer + Tensor Viz diff --git a/packages/viewer-demo/package.json b/packages/viewer-demo/package.json index dae2ef0..249ae2c 100644 --- a/packages/viewer-demo/package.json +++ b/packages/viewer-demo/package.json @@ -1,14 +1,29 @@ { "name": "@tensor-viz/viewer-demo", "version": "0.1.0", - "private": true, "type": "module", + "main": "lib/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js" + }, + "./extension-api": { + "types": "./lib/extension-api.d.ts", + "import": "./lib/extension-api.js" + }, + "./styles.css": "./lib/styles.css" }, + "files": [ + "lib" + ], "scripts": { - "build": "vite build", + "build": "npm run build:lib && vite build", + "build:lib": "tsc -p tsconfig.lib.json && node ../../tools/copy-viewer-demo-lib-assets.mjs", "dev": "vite", + "prepack": "npm run build:lib", "test": "vitest run src", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/viewer-demo/src/ARCHITECTURE.md b/packages/viewer-demo/src/ARCHITECTURE.md index ec44e95..8505ede 100644 --- a/packages/viewer-demo/src/ARCHITECTURE.md +++ b/packages/viewer-demo/src/ARCHITECTURE.md @@ -1,15 +1,9 @@ The `viewer-demo/src/` directory is the browser app that turns tensor-viz's reusable viewer engine into a mountable demo shell. -The demo now has two layers. `app-entry.ts`, `app-shell.ts`, `app-extension.ts`, `registered-extensions.ts`, and `main.ts` create the page, command palette, sidebar, tab state, viewer instance, and extension lifecycle hooks. Feature-specific behavior lives under `extensions/` and registers widgets, controls, session migration, tensor-view contributions, inspector rows, and render hooks through the extension API. +The demo now has two layers. `app-entry.ts`, `app-shell.ts`, `app-extension.ts`, `registered-extensions.ts`, and `main.ts` create the page, command palette, sidebar, tab state, viewer instance, and extension lifecycle hooks. Feature-specific behavior should live in downstream packages and register widgets, controls, session migration, tensor-view contributions, inspector rows, and render hooks through the extension API. -The stock app currently registers one extension: `extensions/linear-layout/`. That extension owns the linear-layout model, preset catalog, sidebar widgets, hover popup, selection synchronization, and baked fallback tabs. Keep new linear-layout behavior there instead of adding new branches to `app-entry.ts`. +The stock app currently registers no domain-specific extensions. LL-viz imports `startDemoApp(...)` from `@tensor-viz/viewer-demo` and passes its linear-layout extension factory at startup, which keeps GPU instruction families and preset catalogs out of tensor-viz. `registered-extensions.ts` is the app's extension registry. New demo features should contribute a factory there with widget slots and a `create(...)` function. `app-entry.ts` should stay shell-shaped: it may ask extensions whether a widget is visible, let them capture tab state, collect extra tensor-view sliders, collect inspector coordinate rows, or forward viewer events, but it should not know instruction families, preset fields, or linear-layout tensor metadata. That keeps future extensions from needing to edit the same central file for every new behavior. -The important local guides are: - -- `extensions/linear-layout/ARCHITECTURE.md` explains the linear-layout extension boundary. -- `extensions/linear-layout/presets/ARCHITECTURE.md` explains how preset data and selector fields are added. -- `extensions/linear-layout/widgets/ARCHITECTURE.md` explains how sidebar widgets are split and where UI changes belong. - -When changing shell behavior, add the smallest app-level test that protects the extension contract. When changing linear-layout parsing or composition, update `extensions/linear-layout/linear-layout.test.ts`. +When changing shell behavior, add the smallest app-level test that protects the extension contract. When changing a downstream workflow, update that workflow's own package and tests instead of adding branches here. diff --git a/packages/viewer-demo/src/app-entry.ts b/packages/viewer-demo/src/app-entry.ts index 80feda6..f5562c2 100644 --- a/packages/viewer-demo/src/app-entry.ts +++ b/packages/viewer-demo/src/app-entry.ts @@ -26,16 +26,47 @@ import { labelWithInfo, selectionEnabled, } from './app-format.js'; -import type { CommandAction, DemoAppExtension, DemoExtensionContext, DemoTensorViewContribution, DemoWidgetSpec } from './app-extension.js'; +import type { CommandAction, DemoAppExtension, DemoExtensionContext, DemoExtensionFactory, DemoTensorViewContribution, DemoWidgetSpec } from './app-extension.js'; import { getAppRoot, mountAppShell, renderWebglUnavailable, supportsWebGL, type AppShellWidgetSlot } from './app-shell.js'; import { controlIcons, renderControlDockControls, type ControlSpec } from './control-dock.js'; -import { DEMO_EXTENSION_FACTORIES } from './registered-extensions.js'; import './styles.css'; // this file owns the generic demo shell: tabs, widgets, command routing, and // session loading. feature-specific behavior should enter through DemoAppExtension // hooks so adding a preset family or widget does not require new shell branches. -const app = getAppRoot(); +/** + * Runtime options for mounting the generic tensor-viz demo shell. + * + * @param root - Optional existing application root. When omitted, the shell uses the standard `#app` lookup and creates it if needed. + * @param extensionFactories - Extension factories supplied by the host package. LL-viz passes its linear-layout factory here while standalone tensor-viz starts with an empty extension list. + * @example + * startDemoApp({ + * root: document.querySelector('#app')!, + * extensionFactories: [linearLayoutExtensionFactory], + * }); + */ +export type DemoAppRuntimeOptions = { + root?: HTMLDivElement; + extensionFactories?: readonly DemoExtensionFactory[]; +}; + +/** + * Mount the generic tensor-viz demo shell into the document and wire any host-supplied extensions. + * + * @param options - Optional root element and extension factories supplied by the embedding package. + * @returns Nothing. The call mutates the chosen DOM root by inserting the viewer shell and starts async session/fallback loading. + * @noThrows Startup itself handles WebGL fallback rendering and catches asynchronous session-loading failures by seeding demo tensors; synchronous DOM failures still indicate an invalid host document. + * @example + * startDemoApp(); + * // The standalone tensor-viz app starts with no workflow-specific extension widgets. + * + * @example + * startDemoApp({ extensionFactories: [linearLayoutExtensionFactory] }); + * // LL-viz receives the same shell plus its linear-layout widgets, controls, and tab hooks. + */ +export function startDemoApp(options: DemoAppRuntimeOptions = {}): void { +const app = options.root ?? getAppRoot(); +const extensionFactories = [...(options.extensionFactories ?? [])]; if (!supportsWebGL()) { renderWebglUnavailable(app); @@ -47,7 +78,7 @@ const CORE_WIDGET_SLOTS = [ { id: 'selection' }, { id: 'advanced-settings' }, ] satisfies AppShellWidgetSlot[]; -const EXTENSION_WIDGET_SLOTS = DEMO_EXTENSION_FACTORIES.flatMap((factory) => factory.widgetSlots); +const EXTENSION_WIDGET_SLOTS = extensionFactories.flatMap((factory) => factory.widgetSlots); const { viewport, tabStrip, @@ -238,7 +269,7 @@ const coreWidgetSpecs: DemoWidgetSpec[] = [ }, ]; -const extensions: DemoAppExtension[] = DEMO_EXTENSION_FACTORIES.map((factory) => factory.create(extensionContext)); +const extensions: DemoAppExtension[] = extensionFactories.map((factory) => factory.create(extensionContext)); const widgetSpecs = [...extensions.flatMap((extension) => extension.widgets), ...coreWidgetSpecs]; const widgetSpecById = new Map(widgetSpecs.map((spec) => [spec.id, spec])); // widgets are looked up once from shell slots, then driven by DemoWidgetSpec. @@ -3585,3 +3616,4 @@ tryLoadSession().then(async (loaded) => { seedDemoTensor(); }); } +} diff --git a/packages/viewer-demo/src/extension-api.ts b/packages/viewer-demo/src/extension-api.ts new file mode 100644 index 0000000..3c05d31 --- /dev/null +++ b/packages/viewer-demo/src/extension-api.ts @@ -0,0 +1,18 @@ +// side-effect-light package surface for external demo extensions. +// unlike index.ts, this module never imports app-entry, so unit tests and model +// code can use extension contracts without booting the viewer or renderer. +export { escapeHtml, escapeInfo, infoButton, labelWithInfo } from './app-format.js'; +export { controlIcons, renderControlDockControls } from './control-dock.js'; +export type { AppShellWidgetSlot } from './app-shell.js'; +export type { ControlSpec } from './control-dock.js'; +export type { + CommandAction, + DemoAppExtension, + DemoExtensionContext, + DemoExtensionFactory, + DemoInspectorCoordEntry, + DemoTensorViewContribution, + DemoTensorViewSliderSpec, + DemoWidgetSpec, + LoadedSessionTab, +} from './app-extension.js'; diff --git a/packages/viewer-demo/src/extensions/linear-layout/ARCHITECTURE.md b/packages/viewer-demo/src/extensions/linear-layout/ARCHITECTURE.md deleted file mode 100644 index bd0d7b0..0000000 --- a/packages/viewer-demo/src/extensions/linear-layout/ARCHITECTURE.md +++ /dev/null @@ -1,14 +0,0 @@ -The `extensions/linear-layout/` directory is the LL-viz feature package inside the tensor-viz demo shell. - -The extension exists so linear-layout work can grow without making the generic viewer app know about GPU instruction families, compose-layout syntax, propagated labels, or multi-input hover behavior. `extension.ts` is the only file the shell imports. It registers sidebar widgets, the Propagate Outputs toolbar control, tab/session lifecycle hooks, baked fallback tabs, hover popup behavior, tensor-view axis labels, multi-input sliders, inspector coordinate rows, and selection synchronization. - -`linear-layout.ts` is the center of the model. It owns compose-layout parsing, operation evaluation, preset normalization, matrix previews, generated Python, and metadata embedded into viewer tabs. If a change affects layout syntax, composition semantics, output labels, matrix blocks, or the session metadata emitted for a rendered layout, start there. - -`linear-layout-state.ts` bridges saved viewer tabs and browser storage back into live sidebar state. It should stay focused on cloning, validation, tab synchronization, and persistence. `linear-layout-viewer-sync.ts` bridges in the other direction: it translates current runtime metadata into viewer labels, colors, selection, hover popups, and multi-input display state. - -The two subdirectories keep contributor workflows local: - -- `presets/` contains instruction-family preset data and selector metadata. -- `widgets/` contains sidebar UI split by workflow. - -When changing parsing or composition, update `linear-layout.test.ts` with the smallest test that captures the behavior. Good tests here build a `ComposeRuntime`, inspect emitted metadata or generated Python, then assert the mapping behavior that would break in the UI. diff --git a/packages/viewer-demo/src/extensions/linear-layout/extension.ts b/packages/viewer-demo/src/extensions/linear-layout/extension.ts deleted file mode 100644 index 890e4ed..0000000 --- a/packages/viewer-demo/src/extensions/linear-layout/extension.ts +++ /dev/null @@ -1,689 +0,0 @@ -import type { - LoadedBundleDocument, - TensorViewSnapshot, - ViewerSnapshot, -} from '@tensor-viz/viewer-core'; -import type { - DemoAppExtension, - DemoExtensionContext, - DemoExtensionFactory, - DemoWidgetSpec, - LoadedSessionTab, -} from '../../app-extension.js'; -import type { AppShellWidgetSlot } from '../../app-shell.js'; -import { controlIcons, type ControlSpec } from '../../control-dock.js'; -import { escapeInfo } from '../../app-format.js'; -import { - composeLayoutStateFromLegacySpec, - createComposeLayoutDocument, - isComposeLayoutMeta, - type ComposeLayoutMeta, -} from './linear-layout.js'; -import { - applyLinearLayoutCellText, - cloneLinearLayoutCellTextState, - cloneLinearLayoutMultiInputState, - cloneLinearLayoutState, - cloneLinearLayoutTensorViewsState, - composeLayoutMetaForTab, - defaultLinearLayoutCellTextState, - defaultLinearLayoutMultiInputState, - emptyLinearLayoutState, - inspectorCoordEntries, - isLinearLayoutCellTextState, - isLinearLayoutMultiInputState, - isLinearLayoutState, - isLinearLayoutTab, - linearLayoutHoverPopupEntries, - linearLayoutMultiInputModel, - linearLayoutSelectionMapForTab, - loadBakedLinearLayoutTabs, - loadLinearLayoutState, - preservedLinearLayoutTensorViews, - renderCellTextWidget, - renderLinearLayoutEditorWidgets, - renderLinearLayoutColorWidget, - renderLinearLayoutVisibleTensorsWidget, - renderLinearLayoutWidget, - renderLinearLayoutPresetWidget, - snapshotTensorViews, - syncLinearLayoutCellTextState, - syncLinearLayoutMultiInputState, - syncLinearLayoutSelection, - syncLinearLayoutSelectionPreview, - syncLinearLayoutState, - syncLinearLayoutViewFilters, - toggleLinearLayoutPropagateOutputs, - type LinearLayoutCellTextState, - type LinearLayoutFormState, - type LinearLayoutMultiInputState, - type LinearLayoutSelectionMap, - type LinearLayoutTensorViewsState, - type LinearLayoutUiContext, - type LinearLayoutUiState, -} from './linear-layout-ui.js'; -import { linearLayoutPropagateOutputsInfo } from './widgets/linear-layout-color-widget.js'; - -// extension.ts is the bridge between the generic demo shell and the -// linear-layout workflow. -// model files parse layouts and compute tensor data; widget files render forms. -// this file wires those pieces into the host extension lifecycle: widgets, -// tab creation, session load/save, hover/selection synchronization, and control -// dock commands. -// keep new linear-layout behavior data-driven in model/widget files whenever -// possible. Code added here should usually mean the host application needs a -// new lifecycle hook or a new connection between existing lifecycle hooks. -// tab-local maps below mirror the host tab ids because a single app session can -// switch between ordinary tensor tabs and compose-layout tabs without tearing -// down the extension. -// hover, selection, and tensor-view hooks all call back into state/viewer-sync -// modules so this lifecycle file does not duplicate mapping math. -// session load supports both current compose-layout snapshots and older -// linearLayoutSpec snapshots; remove neither path without a migration. -// widget ids must stay aligned with LINEAR_LAYOUT_WIDGET_SLOTS or the app shell -// cannot hand the extension real DOM hosts during startup. -// controls are optional host commands, while widgets are always declared here. -// use runtime.widgets for re-render loops so future widget additions do not -// need another hard-coded render list. - -/** - * Runtime contract returned by the linear-layout extension factory after it wires - * the demo shell hooks to the extension's UI state and DOM controls. - * - * The shell treats this as a DemoAppExtension while extension internals use the - * state, ui, and isTab members to synchronize saved tabs, sidebar widgets, hover - * popups, tensor labels, and selection behavior owned by the linear-layout feature. - * - * @example - * function syncIfLinearLayout(runtime: LinearLayoutExtensionRuntime, tab: LoadedBundleDocument | undefined) { - * if (!runtime.isTab(tab)) return false; - * - * runtime.ui.setStatus('Linear-layout tab selected.'); - * return true; - * } - */ -export type LinearLayoutExtensionRuntime = DemoAppExtension & { - state: LinearLayoutUiState; - ui: LinearLayoutUiContext; - isTab: (tab: LoadedBundleDocument | undefined) => boolean; -}; - -const LINEAR_LAYOUT_WIDGETS = [ - 'linear-layout-preset', - 'linear-layout', - 'linear-layout-visible-tensors', - 'linear-layout-color', - 'cell-text', -] as const; - -export const LINEAR_LAYOUT_WIDGET_SLOTS = [ - { id: 'linear-layout-preset', beforeHeader: true }, - { id: 'linear-layout', beforeHeader: true }, - { id: 'linear-layout-visible-tensors', beforeHeader: true }, - { id: 'linear-layout-color', beforeHeader: true }, - { id: 'cell-text', beforeHeader: true }, -] satisfies AppShellWidgetSlot[]; - -/** - * Look up a registered linear-layout sidebar widget element and fail fast when - * the demo shell did not provide that widget slot. - * - * @param ctx - Demo extension context whose `widgets` map is populated by the - * sidebar host with HTMLElement entries keyed by linear-layout widget id. - * @param widgetId - One of the `LINEAR_LAYOUT_WIDGETS` ids to retrieve from - * `ctx.widgets`. - * @returns The HTMLElement registered for `widgetId`, so callers can render or - * update that specific sidebar widget container. - * @throws Error when `ctx.widgets[widgetId]` is missing; the message is - * `Missing ${widgetId} widget.`. - * @example - * const presetElement = document.createElement('section'); - * const ctx = { widgets: { 'linear-layout-preset': presetElement } } as DemoExtensionContext; - * - * expect(requireWidget(ctx, 'linear-layout-preset')).toBe(presetElement); - * - * @example - * const ctx = { widgets: {} } as DemoExtensionContext; - * - * expect(() => requireWidget(ctx, 'linear-layout')).toThrow('Missing linear-layout widget.'); - */ -function requireWidget(ctx: DemoExtensionContext, widgetId: typeof LINEAR_LAYOUT_WIDGETS[number]): HTMLElement { - const widget = ctx.widgets[widgetId]; - if (!widget) throw new Error(`Missing ${widgetId} widget.`); - return widget; -} - -/** - * Return the inline SVG used as the sidebar icon for a linear-layout widget id. - * Unknown widget ids intentionally render no icon. - * - * @param widgetId - Widget id such as `linear-layout-preset`, - * `linear-layout-visible-tensors`, `linear-layout-color`, or `cell-text`. - * @returns SVG markup for the matching sidebar widget icon, or an empty string - * when the id is not one of the linear-layout icon cases. - * @noThrows The function only switches on the supplied string and returns string - * literals; unrecognized ids are handled by the default empty-string branch. - * @example - * expect(linearLayoutWidgetIcon('linear-layout-visible-tensors')).toContain(' - - - - `; - case 'linear-layout': - return ` - - - - - - - - - - `; - case 'linear-layout-visible-tensors': - return ` - - - - - `; - case 'linear-layout-color': - return ` - - - - - - - - - - - - T - - `; - case 'cell-text': - return ` - - - T:0 - - `; - default: - return ''; - } -} - -/** - * Build the sidebar widget specifications registered by the linear-layout - * extension, including labels, icons, collapse defaults, visibility predicate, - * and render callbacks for each widget panel. - * - * @param ui - Linear-layout UI context captured by the widget render callbacks - * so they can read and update the extension state when the sidebar host renders - * a panel. - * @returns Five DemoWidgetSpec entries for Preset, Linear Layout Specifications, - * Visible Tensors, Cell Color/Text, and Cell Text; the demo shell registers - * these specs to decide which panels are shown and which render function to call. - * @noThrows The function only assembles widget metadata and closures. It does not - * invoke the render callbacks or inspect the active tab while building the array. - * @example - * const widgets = linearLayoutWidgets(ui); - * - * expect(widgets.map((widget) => widget.id)).toEqual([ - * 'linear-layout-preset', - * 'linear-layout', - * 'linear-layout-visible-tensors', - * 'linear-layout-color', - * 'cell-text', - * ]); - * expect(widgets[0]?.defaultCollapsed).toBe(false); - * expect(widgets[2]?.defaultCollapsed).toBe(true); - */ -function linearLayoutWidgets(ui: LinearLayoutUiContext): DemoWidgetSpec[] { - /** - * Report whether the sidebar host should show linear-layout widgets for the - * currently selected tab. - * - * @param ctx - Demo extension context that supplies `getActiveTab()`, whose - * result is tested with `isLinearLayoutTab`. - * @returns `true` when the active tab contains linear-layout metadata; `false` - * when there is no active tab or the active tab belongs to another viewer flow. - * @noThrows A missing active tab is converted to `false`, and the predicate only - * performs a boolean check on the tab returned by the context. - * @example - * const linearCtx = { getActiveTab: () => linearLayoutTab } as DemoExtensionContext; - * const emptyCtx = { getActiveTab: () => null } as DemoExtensionContext; - * - * expect(active(linearCtx)).toBe(true); - * expect(active(emptyCtx)).toBe(false); - */ - const active = (ctx: DemoExtensionContext): boolean => { - const tab = ctx.getActiveTab(); - return Boolean(tab && isLinearLayoutTab(tab)); - }; - return [ - { - id: 'linear-layout-preset', - label: 'Preset', - icon: linearLayoutWidgetIcon('linear-layout-preset'), - defaultCollapsed: false, - visible: active, - render: () => { renderLinearLayoutPresetWidget(ui); }, - }, - { - id: 'linear-layout', - label: 'Linear Layout Specifications', - icon: linearLayoutWidgetIcon('linear-layout'), - defaultCollapsed: false, - visible: active, - render: () => { renderLinearLayoutWidget(ui); }, - }, - { - id: 'linear-layout-visible-tensors', - label: 'Visible Tensors', - icon: linearLayoutWidgetIcon('linear-layout-visible-tensors'), - defaultCollapsed: true, - visible: active, - render: () => { renderLinearLayoutVisibleTensorsWidget(ui); }, - }, - { - id: 'linear-layout-color', - label: 'Cell Color/Text', - icon: linearLayoutWidgetIcon('linear-layout-color'), - defaultCollapsed: true, - visible: active, - render: () => { renderLinearLayoutColorWidget(ui); }, - }, - { - id: 'cell-text', - label: 'Cell Text', - icon: linearLayoutWidgetIcon('cell-text'), - defaultCollapsed: true, - visible: active, - render: () => { renderCellTextWidget(ui); }, - }, - ]; -} - -/** - * Builds the linear-layout demo extension runtime, including sidebar widgets, tab hooks, hover-popup DOM, tensor-view sliders, inspector rows, and selection synchronization. - * - * @param ctx - Demo extension host context with the viewer instance, viewport element, widget lookup/title helpers, active-tab accessors, session-tab mutators, and tab-loading callback used by the linear-layout UI. - * @returns Runtime registered under the `linear-layout` id; the demo shell uses it to mount widgets, recognize linear-layout tabs, contribute tensor-view metadata, react to pointer/hover/selection events, and load baked fallback tabs. - * @throws Error when a required linear-layout widget slot such as `linear-layout-preset`, `linear-layout`, `linear-layout-visible-tensors`, `cell-text`, or `linear-layout-color` is absent from the supplied demo context. - * @example - * const viewport = document.createElement('div'); - * const ctx = makeDemoExtensionContextWithWidgets(viewport); - * const runtime = createLinearLayoutExtension(ctx); - * - * expect(runtime.id).toBe('linear-layout'); - * expect(runtime.widgets.length).toBeGreaterThan(0); - * expect(viewport.querySelector('.linear-layout-hover-popup.hidden')).not.toBeNull(); - * @example - * const ctx = makeDemoExtensionContextWithWidgets(document.createElement('div'), { - * omitWidget: 'linear-layout-color', - * }); - * - * expect(() => createLinearLayoutExtension(ctx)).toThrow(/linear-layout-color/); - */ -export function createLinearLayoutExtension(ctx: DemoExtensionContext): LinearLayoutExtensionRuntime { - const hoverPopup = document.createElement('div'); - hoverPopup.className = 'linear-layout-hover-popup hidden'; - ctx.viewport.appendChild(hoverPopup); - // popup placement is tracked in viewport-local pixels so scrolling the page - // does not move the popup away from the hovered canvas cell. - let hoverPopupPointer = { x: 16, y: 16 }; - let lastActiveTensorId: string | null = null; - const state: LinearLayoutUiState = { - linearLayoutState: loadLinearLayoutState(), - linearLayoutStates: new Map(), - linearLayoutCellTextState: defaultLinearLayoutCellTextState(), - linearLayoutCellTextStates: new Map(), - linearLayoutMultiInputState: defaultLinearLayoutMultiInputState(), - linearLayoutMultiInputStates: new Map(), - linearLayoutTensorViewsStates: new Map(), - linearLayoutSelectionMaps: new Map(), - linearLayoutNotice: null, - linearLayoutMatrixPreview: '', - showLinearLayoutMatrix: false, - syncingLinearLayoutSelection: false, - }; - const ui: LinearLayoutUiContext = { - viewer: ctx.viewer, - viewport: ctx.viewport, - linearLayoutPresetWidget: requireWidget(ctx, 'linear-layout-preset'), - linearLayoutWidget: requireWidget(ctx, 'linear-layout'), - linearLayoutVisibleTensorsWidget: requireWidget(ctx, 'linear-layout-visible-tensors'), - cellTextWidget: requireWidget(ctx, 'cell-text'), - linearLayoutColorWidget: requireWidget(ctx, 'linear-layout-color'), - state, - widgetTitle: ctx.widgetTitle, - getActiveTab: ctx.getActiveTab, - getActiveTabId: ctx.getActiveTabId, - getSessionTabs: ctx.getSessionTabs, - setSessionTabs: ctx.setSessionTabs, - loadTab: ctx.loadTab, - renderLinearLayoutEditorWidgets: () => { renderLinearLayoutEditorWidgets(ui); }, - }; - /** - * Rebuilds the linear-layout hover popup for the active tab by reading the viewer's live hovered cell and the tab's selection map, then showing matching input-cell labels and colors. - * - * @returns Void; callers observe the popup element becoming hidden with empty content when no linear-layout hover entries exist, or becoming visible with escaped input-cell rows when entries are available. - * @noThrows The normal path only reads the active tab, live hover, and selection map, then updates the already-created popup element; absent tabs or missing hover entries are handled by hiding the popup. - * @example - * viewer.setLiveHover({ tensorId: 'accumulator', coord: [0, 1] }); - * setActiveLinearLayoutTab(tabWithSelectionMapForInputCells(['a[0,1]', 'b[0,1]'])); - * - * renderHoverPopup(); - * - * expect(hoverPopup.classList.contains('hidden')).toBe(false); - * expect(hoverPopup.textContent).toContain('Input Cells'); - * expect(hoverPopup.textContent).toContain('a[0,1]'); - * @example - * viewer.setLiveHover(null); - * - * renderHoverPopup(); - * - * expect(hoverPopup.classList.contains('hidden')).toBe(true); - * expect(hoverPopup.innerHTML).toBe(''); - */ - const renderHoverPopup = (): void => { - const tab = ctx.getActiveTab(); - const linearLayoutTab = tab && isLinearLayoutTab(tab) ? tab : null; - const hover = ctx.viewer.getLiveHover(); - const selectionMap = linearLayoutTab ? linearLayoutSelectionMapForTab(ui, linearLayoutTab) : null; - const entries = linearLayoutHoverPopupEntries(ui, hover, selectionMap); - if (entries.length === 0) { - hoverPopup.classList.add('hidden'); - hoverPopup.innerHTML = ''; - return; - } - hoverPopup.innerHTML = ` -
Input Cells
-
${entries.map((entry) => ` -
- - ${escapeInfo(entry.text).replace(/\n/g, '
')}
-
- `).join('')}
- `; - hoverPopup.classList.remove('hidden'); - placeHoverPopup(); - }; - /** - * Positions the visible hover popup near the last viewport-local pointer location while clamping it inside the viewport's bottom and right padding. - * - * @returns Void; callers observe `hoverPopup.style.left` and `hoverPopup.style.top` updated for visible popups, while hidden popups are left unchanged. - * @noThrows The routine only reads viewport/popup geometry and writes CSS pixel offsets; a hidden popup returns before any layout calculations are needed. - * @example - * mockViewportRect({ width: 200, height: 100 }); - * mockPopupSize(80, 40); - * hoverPopup.classList.remove('hidden'); - * hoverPopupPointer = { x: 190, y: 90 }; - * - * placeHoverPopup(); - * - * expect(hoverPopup.style.left).toBe('108px'); - * expect(hoverPopup.style.top).toBe('48px'); - * @example - * hoverPopup.classList.add('hidden'); - * hoverPopup.style.left = '24px'; - * - * placeHoverPopup(); - * - * expect(hoverPopup.style.left).toBe('24px'); - */ - const placeHoverPopup = (): void => { - if (hoverPopup.classList.contains('hidden')) return; - const rect = ctx.viewport.getBoundingClientRect(); - const width = hoverPopup.offsetWidth; - const height = hoverPopup.offsetHeight; - const maxLeft = Math.max(12, rect.width - width - 12); - const maxTop = Math.max(12, rect.height - height - 12); - hoverPopup.style.left = `${Math.min(maxLeft, hoverPopupPointer.x + 18)}px`; - hoverPopup.style.top = `${Math.min(maxTop, hoverPopupPointer.y + 18)}px`; - }; - const runtime: LinearLayoutExtensionRuntime = { - id: 'linear-layout', - widgets: linearLayoutWidgets(ui), - state, - ui, - isTab: (tab) => Boolean(tab && isLinearLayoutTab(tab)), - tensorView: (_tensorViewCtx, { tab, tensorId }) => { - if (!tab || !isLinearLayoutTab(tab)) return null; - const meta = composeLayoutMetaForTab(tab); - const selectionMap = linearLayoutSelectionMapForTab(ui, tab); - const multiInput = selectionMap ? linearLayoutMultiInputModel(ui, selectionMap) : null; - const axisLabels = meta?.tensors.find((tensor) => tensor.id === tensorId)?.axisLabels; - // multi-input sliders appear only for focused non-injective cells; - // the model returns null for ordinary one-root cells. - return { - axisLabels, - sliders: multiInput ? [{ - id: 'linear-layout-multi-input', - label: 'Multi-Input', - min: -1, - max: Math.max(0, multiInput.size - 1), - value: multiInput.value, - onChange: (value) => { - state.linearLayoutMultiInputState[multiInput.focusedTensorId] = value; - const activeTabId = ctx.getActiveTabId(); - if (activeTabId) state.linearLayoutMultiInputStates.set(activeTabId, cloneLinearLayoutMultiInputState(state.linearLayoutMultiInputState)); - syncLinearLayoutViewFilters(ui); - }, - }] : [], - }; - }, - afterTensorViewChange: () => { syncLinearLayoutViewFilters(ui); }, - inspectorCoords: (_inspectorCtx, { hover, hoveredStatus }) => { - const tab = ctx.getActiveTab(); - const linearLayoutTab = tab && isLinearLayoutTab(tab) ? tab : null; - if (!linearLayoutTab) return []; - return inspectorCoordEntries(ui, hover, hoveredStatus, linearLayoutSelectionMapForTab(ui, linearLayoutTab)); - }, - controls: (controlCtx, snapshot): ControlSpec[] => { - const tab = controlCtx.getActiveTab(); - const active = Boolean(tab && isLinearLayoutTab(tab)); - const injective = tab && isLinearLayoutTab(tab) - ? (composeLayoutMetaForTab(tab)?.injective ?? true) - : true; - return [{ - id: 'propagate-outputs', - label: 'Propagate Outputs', - description: active - ? linearLayoutPropagateOutputsInfo(injective) - : 'Propagate Outputs is available for linear-layout tabs.', - shortcut: 'N/A', - active: state.linearLayoutState.propagateOutputs, - disabled: !active, - content: controlIcons.propagateOutputs, - onClick: async () => { - await toggleLinearLayoutPropagateOutputs(ui); - }, - }]; - }, - createTab: (_tabCtx, id, title, snapshot) => { - state.linearLayoutState = emptyLinearLayoutState(); - const document = createComposeLayoutDocument(state.linearLayoutState, snapshot, title); - const meta = composeLayoutMetaForTab(document); - // new tabs snapshot the viewer immediately so later tensor-view - // edits can be restored when switching away and back. - state.linearLayoutCellTextState = defaultLinearLayoutCellTextState(meta?.rootInputLabels ?? []); - state.linearLayoutMultiInputState = defaultLinearLayoutMultiInputState(); - state.linearLayoutStates.set(id, cloneLinearLayoutState(state.linearLayoutState)); - state.linearLayoutCellTextStates.set(id, cloneLinearLayoutCellTextState(state.linearLayoutCellTextState)); - state.linearLayoutMultiInputStates.set(id, cloneLinearLayoutMultiInputState(state.linearLayoutMultiInputState)); - state.linearLayoutTensorViewsStates.set(id, snapshotTensorViews(document.manifest.viewer)); - return { ...document, id, title }; - }, - captureSnapshot: (_tabCtx, tab, snapshot) => { - if (!isLinearLayoutTab(tab)) return; - const extendedSnapshot = snapshot as ViewerSnapshot & { - composeLayoutMeta?: ComposeLayoutMeta; - composeLayoutState?: LinearLayoutFormState; - linearLayoutCellTextState?: LinearLayoutCellTextState; - linearLayoutMultiInputState?: LinearLayoutMultiInputState; - composeLayoutTensorViews?: LinearLayoutTensorViewsState; - }; - const cloned = cloneLinearLayoutState(state.linearLayoutState); - const clonedCellText = cloneLinearLayoutCellTextState(state.linearLayoutCellTextState); - const clonedMultiInput = cloneLinearLayoutMultiInputState(state.linearLayoutMultiInputState); - const tensorViews = preservedLinearLayoutTensorViews(ui, tab.id); - // write both tab-local caches and the serialized snapshot because a - // save can be followed by either in-session tab switching or reload. - state.linearLayoutStates.set(tab.id, cloned); - state.linearLayoutCellTextStates.set(tab.id, clonedCellText); - state.linearLayoutMultiInputStates.set(tab.id, clonedMultiInput); - state.linearLayoutTensorViewsStates.set(tab.id, tensorViews); - extendedSnapshot.composeLayoutState = cloned; - extendedSnapshot.linearLayoutCellTextState = clonedCellText; - extendedSnapshot.linearLayoutMultiInputState = clonedMultiInput; - extendedSnapshot.composeLayoutTensorViews = cloneLinearLayoutTensorViewsState(tensorViews); - const composeLayoutMeta = composeLayoutMetaForTab(tab); - if (composeLayoutMeta) extendedSnapshot.composeLayoutMeta = composeLayoutMeta; - }, - clearTab: (_tabCtx, tabId) => { - state.linearLayoutStates.delete(tabId); - state.linearLayoutCellTextStates.delete(tabId); - state.linearLayoutMultiInputStates.delete(tabId); - state.linearLayoutTensorViewsStates.delete(tabId); - state.linearLayoutSelectionMaps.delete(tabId); - }, - cloneTab: (_tabCtx, fromTabId, toTabId) => { - const linearLayoutState = state.linearLayoutStates.get(fromTabId); - if (linearLayoutState) state.linearLayoutStates.set(toTabId, cloneLinearLayoutState(linearLayoutState)); - const cellTextState = state.linearLayoutCellTextStates.get(fromTabId); - if (cellTextState) state.linearLayoutCellTextStates.set(toTabId, cloneLinearLayoutCellTextState(cellTextState)); - const multiInputState = state.linearLayoutMultiInputStates.get(fromTabId); - if (multiInputState) state.linearLayoutMultiInputStates.set(toTabId, cloneLinearLayoutMultiInputState(multiInputState)); - const tensorViewsState = state.linearLayoutTensorViewsStates.get(fromTabId); - if (tensorViewsState) state.linearLayoutTensorViewsStates.set(toTabId, cloneLinearLayoutTensorViewsState(tensorViewsState)); - state.linearLayoutSelectionMaps.delete(toTabId); - }, - beforeSessionLoad: () => { - state.linearLayoutMultiInputStates.clear(); - state.linearLayoutSelectionMaps.clear(); - }, - loadSessionTab: async (tabCtx, tab: LoadedSessionTab) => { - const legacySpec = (tab.viewer as { linearLayoutSpec?: unknown }).linearLayoutSpec; - const storedComposeState = (tab.viewer as { composeLayoutState?: unknown }).composeLayoutState; - const storedTensorViews = (tab.viewer as { composeLayoutTensorViews?: unknown }).composeLayoutTensorViews; - const composeMeta = (tab.viewer as { composeLayoutMeta?: unknown }).composeLayoutMeta; - const storedMultiInputState = (tab.viewer as { linearLayoutMultiInputState?: unknown }).linearLayoutMultiInputState; - if (legacySpec) { - // older demos stored one linearLayoutSpec field instead of the - // compose-layout state object; keep that path so saved examples - // and external links remain loadable. - const linearLayoutState = isLinearLayoutState(storedComposeState) - ? cloneLinearLayoutState(storedComposeState) - : composeLayoutStateFromLegacySpec(legacySpec, tab.title); - const document = createComposeLayoutDocument(linearLayoutState, { - ...tab.viewer, - showSelectionPanel: false, - }, tab.title); - state.linearLayoutStates.set(tab.id, cloneLinearLayoutState(linearLayoutState)); - if (storedTensorViews && typeof storedTensorViews === 'object') { - state.linearLayoutTensorViewsStates.set(tab.id, cloneLinearLayoutTensorViewsState(storedTensorViews as Record)); - } else { - state.linearLayoutTensorViewsStates.set(tab.id, snapshotTensorViews(document.manifest.viewer)); - } - if (isLinearLayoutMultiInputState(storedMultiInputState)) { - state.linearLayoutMultiInputStates.set(tab.id, cloneLinearLayoutMultiInputState(storedMultiInputState)); - } - return { ...document, id: tab.id, title: tab.title }; - } - const isLinearLayout = isComposeLayoutMeta(composeMeta); - if (!isLinearLayout) return null; - // loaded compose-layout tabs intentionally hide the generic - // selection panel because selection is mirrored across all tensors. - const viewerState = { - ...tab.viewer, - dimensionMappingScheme: tab.viewer.dimensionMappingScheme ?? 'contiguous', - showSelectionPanel: false, - }; - const storedLinearLayoutState = (viewerState as { composeLayoutState?: unknown }).composeLayoutState; - if (isLinearLayoutState(storedLinearLayoutState)) { - state.linearLayoutStates.set(tab.id, cloneLinearLayoutState(storedLinearLayoutState)); - } - if (storedTensorViews && typeof storedTensorViews === 'object') { - state.linearLayoutTensorViewsStates.set(tab.id, cloneLinearLayoutTensorViewsState(storedTensorViews as LinearLayoutTensorViewsState)); - } else { - state.linearLayoutTensorViewsStates.set(tab.id, snapshotTensorViews(viewerState)); - } - const storedCellTextState = (viewerState as { linearLayoutCellTextState?: unknown }).linearLayoutCellTextState; - if (isLinearLayoutCellTextState(storedCellTextState)) { - state.linearLayoutCellTextStates.set(tab.id, cloneLinearLayoutCellTextState(storedCellTextState)); - } - if (isLinearLayoutMultiInputState(storedMultiInputState)) { - state.linearLayoutMultiInputStates.set(tab.id, cloneLinearLayoutMultiInputState(storedMultiInputState)); - } - return { - id: tab.id, - title: tab.title, - manifest: { version: 1, viewer: viewerState, tensors: tab.tensors }, - tensors: await tabCtx.loadTabTensors(tab.tensors), - }; - }, - afterLoadTab: (_tabCtx, tab) => { - // tab load is the single place where form state, cell text, - // multi-input sliders, and viewer filters are all rehydrated. - syncLinearLayoutState(ui, tab); - syncLinearLayoutCellTextState(ui, tab); - syncLinearLayoutMultiInputState(ui, tab); - runtime.widgets.forEach((widget) => widget.render(ctx, ctx.viewer.getSnapshot())); - syncLinearLayoutViewFilters(ui); - applyLinearLayoutCellText(ui); - syncLinearLayoutSelectionPreview(ui, new Map()); - }, - beforeRender: (_renderCtx, snapshot) => { - const tab = ctx.getActiveTab(); - const activeTensorId = tab && isLinearLayoutTab(tab) ? (snapshot.activeTensorId ?? null) : null; - if (activeTensorId === lastActiveTensorId) return false; - lastActiveTensorId = activeTensorId; - if (!activeTensorId) return false; - // active tensor changes can expose a different multi-input slider - // without changing the underlying layout document. - syncLinearLayoutViewFilters(ui); - return true; - }, - afterRender: () => { - renderHoverPopup(); - }, - loadFallback: async () => loadBakedLinearLayoutTabs(ui), - pointerMove: (_pointerCtx, event) => { - const rect = ctx.viewport.getBoundingClientRect(); - hoverPopupPointer = { - x: Math.max(12, event.clientX - rect.left), - y: Math.max(12, event.clientY - rect.top), - }; - placeHoverPopup(); - }, - pointerLeave: () => { - hoverPopup.classList.add('hidden'); - }, - hover: () => { renderHoverPopup(); }, - selectionPreview: (_selectionCtx, selection) => { - syncLinearLayoutSelectionPreview(ui, selection); - }, - selection: (_selectionCtx, selection) => { - syncLinearLayoutSelection(ui, selection); - }, - }; - return runtime; -} - -export const linearLayoutExtensionFactory = { - widgetSlots: LINEAR_LAYOUT_WIDGET_SLOTS, - create: createLinearLayoutExtension, -} satisfies DemoExtensionFactory; diff --git a/packages/viewer-demo/src/extensions/linear-layout/linear-layout-multi-input.ts b/packages/viewer-demo/src/extensions/linear-layout/linear-layout-multi-input.ts deleted file mode 100644 index 9ae3ee2..0000000 --- a/packages/viewer-demo/src/extensions/linear-layout/linear-layout-multi-input.ts +++ /dev/null @@ -1,628 +0,0 @@ -import { - coordFromKey, - coordKey, - parseTensorView, - serializeTensorViewEditor, - visibleTensorCoords, - type LoadedBundleDocument, -} from '@tensor-viz/viewer-core'; -import { rootColorsForLayoutState } from './linear-layout.js'; -import { composeLayoutMetaForTab, type LinearLayoutSelectionMap, type LinearLayoutUiContext } from './linear-layout-state.js'; - -/** - * Display-time index map for a linear-layout selection, linking each tensor's visible coordinates to the root input cells currently shown in the viewer and to any ghosted duplicate roots. - * - * The multi-input hover and slider code uses this model to decide which root indexes are visible in the active slice, which root index each tensor coordinate represents, and which duplicate cells should be drawn as ghost overlays. - * - * @example - * const display: LinearLayoutDisplayModel = { - * rootIndexes: new Set([0, 1, 2]), - * sliceRootIndexes: new Set([0, 2]), - * displayedRootIndexByTensor: new Map([ - * ['accumulator', [0, null, 2]], - * ]), - * visibleCoordsByTensor: new Map([ - * ['accumulator', [[0, 0], [0, 1], [0, 2]]], - * ]), - * ghostRootIndexesByTensor: new Map([ - * ['accumulator', [{ coord: [0, 2], rootIndex: 1, layer: 0 }]], - * ]), - * }; - * - * expect(display.displayedRootIndexByTensor.get('accumulator')?.[2]).toBe(2); - * expect(display.ghostRootIndexesByTensor.get('accumulator')?.[0].coord).toEqual([0, 2]); - */ -export type LinearLayoutDisplayModel = { - rootIndexes: Set; - sliceRootIndexes: Set | null; - displayedRootIndexByTensor: Map>; - visibleCoordsByTensor: Map; - ghostRootIndexesByTensor: Map>; -}; - -/** - * Describes the optional slider shown when the focused linear-layout tensor cell - * represents multiple root inputs. `null` means the UI should hide the slider, - * either because no tensor is focused, output propagation is active, no mapping - * is available, or every visible cell maps to at most one root input. - * - * @example - * const visibleSlider: LinearLayoutMultiInputModel = { - * focusedTensorId: 'compose-step-2', - * value: 0, - * size: 4, - * }; - * - * const hiddenSlider: LinearLayoutMultiInputModel = null; - */ -export type LinearLayoutMultiInputModel = { - focusedTensorId: string; - value: number; - size: number; -} | null; - -/** - * Builds the coordinate lookup tables used to keep linear-layout tensor hovers, - * selections, colors, and ghost layers aligned with the root input space and the - * final propagated output space for a loaded tab. - * - * @param tab - Loaded bundle document whose manifest tensor ids and embedded compose-layout metadata are inspected. - * @returns A selection map containing root-input labels, final-output labels, tensor coordinate indexes, and loaded tensor ids, or `null` when the tab has no compose-layout metadata or the metadata contains no tensors. - * @noThrows Missing or empty compose-layout metadata is treated as an unsupported tab and reported with `null`; the function only derives in-memory arrays and maps from the loaded document. - * @example - * const mapping = linearLayoutSelectionMapForMeta(tab); - * if (mapping) { - * expect(mapping.orderedTensorIds).toContain('compose-step-1'); - * expect(mapping.rootKeyToIndex.get('0')).toBe(0); - * } else { - * expect(mapping).toBeNull(); - * } - */ -export function linearLayoutSelectionMapForMeta( - tab: LoadedBundleDocument, -): LinearLayoutSelectionMap | null { - const meta = composeLayoutMetaForTab(tab); - if (!meta || meta.tensors.length === 0) return null; - const loadedTensorIds = new Set(tab.manifest.tensors.map((tensor) => tensor.id)); - const finalOutputShape = meta.finalOutputBitCounts.map((bits) => bits === 0 ? 1 : 2 ** bits); - const rootInputShape = meta.rootInputBitCounts.map((bits) => bits === 0 ? 1 : 2 ** bits); - const rootKeys = meta.tensors[0]!.rootToTensor.map((coord) => coordKey(coord)); - const rootToFinalKeys = meta.tensors[0]!.tensorToFinal.map((coord) => coord ? coordKey(coord) : ''); - const tensors = new Map ? T : never>(); - meta.tensors.forEach((tensorMeta) => { - if (!loadedTensorIds.has(tensorMeta.id)) return; - const rootToTensorKeys = tensorMeta.rootToTensor.map((coord) => coordKey(coord)); - const coordKeyToFlatIndex = new Map(); - const cellRootIndexes = Array.from({ length: tensorMeta.shape.reduce((total, value) => total * value, 1) }, () => [] as number[]); - // non-injective tensors can map many root inputs into one cell. Keep - // all roots by flat cell so hover, selection, and ghost layers agree. - rootToTensorKeys.forEach((tensorKey, rootIndex) => { - const flat = coordFromKey(tensorKey).reduce((index, value, axis) => (index * tensorMeta.shape[axis]!) + value, 0); - coordKeyToFlatIndex.set(tensorKey, flat); - cellRootIndexes[flat]!.push(rootIndex); - }); - tensors.set(tensorMeta.id, { meta: tensorMeta, rootToTensorKeys, coordKeyToFlatIndex, cellRootIndexes }); - }); - return { - injective: meta.injective, - rootInputLabels: meta.rootInputLabels.slice(), - rootInputShape, - rootKeys: rootKeys.slice(), - rootKeyToIndex: new Map(rootKeys.map((key, index) => [key, index])), - finalOutputLabels: meta.finalOutputLabels.slice(), - finalOutputShape, - rootToFinalKeys, - tensors, - orderedTensorIds: meta.tensors.map((tensor) => tensor.id).filter((id) => tensors.has(id)), - }; -} - -/** - * Decides whether the focused tensor needs the multi-input slider and, when it - * does, returns the slider range and selected root-input offset for that tensor. - * - * @param ctx - Linear-layout UI context that provides the active tensor id, the propagate-outputs flag, and saved per-tensor slider positions. - * @param mapping - Selection map for the active linear-layout tab, or `null` when the tab cannot provide linear-layout coordinate metadata. - * @returns Slider state for the focused non-injective tensor cell: `focusedTensorId`, `size` as the largest number of root inputs sharing one tensor cell, and `value` clamped into `[-1, size - 1]`; returns `null` when no slider should be rendered. - * @noThrows Missing mapping, missing focus, propagated-output mode, unknown tensor ids, and one-to-one mappings are normal UI states and return `null` instead of raising an error. - * @example - * const model = linearLayoutMultiInputModel(ctx, mapping); - * expect(model).toEqual({ - * focusedTensorId: 'compose-step-2', - * size: 4, - * value: 0, - * }); - * - * ctx.state.linearLayoutState.propagateOutputs = true; - * expect(linearLayoutMultiInputModel(ctx, mapping)).toBeNull(); - */ -export function linearLayoutMultiInputModel( - ctx: LinearLayoutUiContext, - mapping: LinearLayoutSelectionMap | null, -): LinearLayoutMultiInputModel { - const focusedTensorId = ctx.viewer.getState().activeTensorId; - if (!mapping || !focusedTensorId) return null; - if (ctx.state.linearLayoutState?.propagateOutputs) return null; - const tensor = mapping.tensors.get(focusedTensorId); - if (!tensor) return null; - const size = Math.max(0, ...tensor.cellRootIndexes.map((roots) => roots.length)); - // the slider exists only for many-to-one cells; injective or currently - // one-to-one views should not expose an extra control. - if (size <= 1) return null; - const storedValue = ctx.state.linearLayoutMultiInputState[focusedTensorId] ?? -1; - const value = storedValue < 0 ? -1 : Math.min(size - 1, storedValue); - return { focusedTensorId, value, size }; -} - -/** - * Synchronizes the active linear-layout tab into the tensor viewer by writing - * per-cell root indexes, RGB colors, visible coordinates, and ghost layers for - * every loaded tensor in the layout mapping. - * - * @param ctx - Linear-layout UI context that supplies the active tab, layout state, cached selection maps, and viewer rendering methods such as `setTensorData`, `colorTensor`, `setTensorVisibleCoords`, and `setTensorGhostLayers`. - * @returns Nothing; callers observe the update through the viewer tensors being recolored, sliced, and assigned ghost-layer annotations. - * @noThrows If there is no active tab or the tab has no linear-layout selection map, the function returns before touching the viewer; otherwise it performs deterministic viewer API calls from existing metadata and UI state. - * @example - * applyLinearLayoutDisplay(ctx); - * - * expect(ctx.viewer.setTensorData).toHaveBeenCalledWith( - * 'compose-step-1', - * expect.any(Float32Array), - * 'float32', - * ); - * expect(ctx.viewer.colorTensor).toHaveBeenCalledWith('compose-step-1', expect.any(Float32Array)); - * expect(ctx.viewer.setTensorVisibleCoords).toHaveBeenCalledWith('compose-step-1', expect.any(Array)); - */ -export function applyLinearLayoutDisplay(ctx: LinearLayoutUiContext): void { - const tab = ctx.getActiveTab(); - if (!tab) return; - const mapping = linearLayoutSelectionMapForTab(ctx, tab); - if (!mapping) return; - const display = linearLayoutDisplayModel(ctx, mapping); - const [colorLabels, colorShape] = ctx.state.linearLayoutState.propagateOutputs - ? [mapping.finalOutputLabels, mapping.finalOutputShape] - : [mapping.rootInputLabels, mapping.rootInputShape]; - const colors = rootColorsForLayoutState( - colorLabels, - colorShape, - ctx.state.linearLayoutState, - ); - mapping.orderedTensorIds.forEach((tensorId) => { - const tensor = mapping.tensors.get(tensorId)!; - const displayed = display.displayedRootIndexByTensor.get(tensorId) ?? []; - const data = new Float32Array(tensor.meta.shape.reduce((total, value) => total * value, 1)).fill(-1); - const rgb = new Float32Array(data.length * 3); - displayed.forEach((rootIndex, flat) => { - if (rootIndex === null) return; - data[flat] = rootIndex; - rgb.set(colors[propagatedIndexForRoot(mapping, rootIndex, ctx.state.linearLayoutState.propagateOutputs)]!, flat * 3); - }); - // data, colors, visible coords, and ghost layers are updated together so - // rendering cannot show stale hidden roots after slicing or slider edits. - ctx.viewer.setTensorData(tensorId, data, 'float32'); - ctx.viewer.colorTensor(tensorId, rgb); - ctx.viewer.setTensorVisibleCoords(tensorId, display.visibleCoordsByTensor.get(tensorId) ?? []); - ctx.viewer.setTensorGhostLayers(tensorId, ctx.state.linearLayoutState.propagateOutputs ? null : display.ghostRootIndexesByTensor.get(tensorId)?.map((entry) => ({ - coord: entry.coord, - color: colors[propagatedIndexForRoot(mapping, entry.rootIndex, ctx.state.linearLayoutState.propagateOutputs)]! - .map((value) => Math.round(value * 255)) as [number, number, number], - bias: [entry.layer * 0.18, -(entry.layer * 0.18)] as const, - layer: entry.layer, - text: linearLayoutGhostText( - propagatedCoordForRoot(mapping, entry.rootIndex, ctx.state.linearLayoutState.propagateOutputs), - ctx.state.linearLayoutState.propagateOutputs ? mapping.finalOutputLabels : mapping.rootInputLabels, - ctx.state.linearLayoutCellTextState, - ), - })) ?? null); - }); -} - -/** - * Builds the linear-layout render model that decides which compose-root indexes are visible in each tensor view. - * - * The model intersects active tensor-view slice selections, optionally narrows the result to the root selected by the focused multi-input slider, and records the root displayed in each tensor cell. When a tensor cell maps to multiple roots, the first root is rendered as the main cell and the remaining roots are returned as ghost layers. - * - * @param ctx - Linear-layout UI context containing the viewer slice state, focused tensor-view slider state, and current extension settings. - * @param mapping - Selection map produced from linear-layout metadata, including ordered tensor ids, root keys, tensor shapes, coordinate lookup tables, and per-cell root-index memberships. - * @returns Display model used by render and selection synchronization code: the visible root-index set, the slice-only root-index set, displayed root indexes by tensor cell, visible coordinates by tensor, and ghost root layers for non-injective cells. - * @noThrows The function only reads Maps, Sets, arrays, and context state; missing optional focus/slice state is treated as an absent filter rather than as an exceptional condition. - * @example - * const display = linearLayoutDisplayModel(ctx, mapping); - * - * expect(Array.from(display.rootIndexes)).toEqual([2]); - * expect(display.displayedRootIndexByTensor.get('compose-root')).toEqual([null, null, 2, null]); - */ -export function linearLayoutDisplayModel( - ctx: LinearLayoutUiContext, - mapping: LinearLayoutSelectionMap, -): LinearLayoutDisplayModel { - const sliceVisibleRootIndexes = sliceVisibleRootIndexesByTensor(ctx, mapping); - const slicedRoots = intersectRootIndexes(sliceVisibleRootIndexes.values(), mapping.rootKeys.length); - const multiInput = linearLayoutMultiInputModel(ctx, mapping); - // visibility is the intersection of active tensor-view slices, then - // optionally narrowed to one many-to-one member by the focused tensor slider. - const focusedRoots = multiInput - ? focusedRootIndexes(mapping, multiInput.focusedTensorId, multiInput.value, sliceVisibleRootIndexes) - : null; - const rootIndexes = focusedRoots ?? slicedRoots ?? new Set(Array.from({ length: mapping.rootKeys.length }, (_entry, index) => index)); - const displayedRootIndexByTensor = new Map>(); - const visibleCoordsByTensor = new Map(); - const ghostRootIndexesByTensor = new Map>(); - mapping.orderedTensorIds.forEach((tensorId) => { - const tensor = mapping.tensors.get(tensorId)!; - const visibleRoots = tensor.cellRootIndexes.map((roots) => roots.filter((rootIndex) => rootIndexes.has(rootIndex))); - const displayed = visibleRoots.map((roots) => roots[0] ?? null); - displayedRootIndexByTensor.set(tensorId, displayed); - visibleCoordsByTensor.set(tensorId, displayed.flatMap((rootIndex, flat) => ( - rootIndex === null ? [] : [unravelIndex(flat, tensor.meta.shape)] - ))); - ghostRootIndexesByTensor.set(tensorId, visibleRoots.flatMap((roots, flat) => ( - // root zero is rendered as the main cell; additional roots become - // offset ghost layers so non-injective cells remain inspectable. - roots.slice(1).map((rootIndex, layer) => ({ - coord: unravelIndex(flat, tensor.meta.shape), - rootIndex, - layer: layer + 1, - })) - ))); - }); - return { rootIndexes, sliceRootIndexes: slicedRoots, displayedRootIndexByTensor, visibleCoordsByTensor, ghostRootIndexesByTensor }; -} - -/** - * Converts selected tensor coordinates into the compose-root indexes represented by those cells. - * - * This is used when a user selects cells in one tensor view and the linear-layout extension needs the shared root indexes to project that selection into the other tensor views. - * - * @param mapping - Linear-layout selection map containing the tensor coordinate-to-flat-index table and the root indexes attached to each tensor cell. - * @param tensorId - Identifier of the tensor whose coordinates were selected, such as `compose-step-1` or `compose-root`. - * @param coords - Tensor coordinates from the viewer selection, with each entry matching the tensor rank, for example `[[0]]` for a one-dimensional cell. - * @returns Set of root indexes attached to the requested coordinates; missing tensor ids, coordinates outside the tensor, and cells without roots contribute no entries. - * @noThrows The tensor and coordinate lookups are guarded: an unknown tensor id or coordinate key returns an empty contribution instead of throwing. - * @example - * const selectedRoots = rootIndexesForCoords(mapping, 'compose-step-1', [[0]]); - * - * expect(Array.from(selectedRoots)).toEqual([0, 2]); - * expect(Array.from(rootIndexesForCoords(mapping, 'missing-tensor', [[0]]))).toEqual([]); - */ -export function rootIndexesForCoords( - mapping: LinearLayoutSelectionMap, - tensorId: string, - coords: number[][], -): Set { - const tensor = mapping.tensors.get(tensorId); - if (!tensor) return new Set(); - return new Set(coords.flatMap((coord) => { - const flat = tensor.coordKeyToFlatIndex.get(coordKey(coord)); - return flat === undefined ? [] : tensor.cellRootIndexes[flat] ?? []; - })); -} - -/** - * Projects compose-root selections back into the coordinates of a tensor view. - * - * A tensor coordinate is returned when any root attached to that tensor cell is in `selectedRootIndexes`. When `visibleRootIndexes` is provided, the coordinate must also contain at least one root that survives the current slice filter. - * - * @param mapping - Linear-layout selection map containing tensor shapes and each cell's compose-root memberships. - * @param tensorId - Identifier of the tensor view to project into, such as `compose-root` or an intermediate compose step. - * @param selectedRootIndexes - Root indexes gathered from the source selection and propagated through the linear-layout graph. - * @param visibleRootIndexes - Optional slice-filter root set from the display model; pass `null` to include matching coordinates even when they are outside the current slice filter. - * @returns Tensor coordinates whose cells match the selected roots and, when supplied, the visible-root filter; returns an empty array for unknown tensors or an empty selected-root set. - * @noThrows The function checks for a missing tensor and empty selection before reading cell data, and the optional visibility filter is handled as a normal `null` case. - * @example - * const selectedRoots = new Set([0, 2]); - * - * expect(coordsForRootIndexes(mapping, 'compose-root', selectedRoots, null)).toEqual([[0], [2]]); - * expect(coordsForRootIndexes(mapping, 'compose-root', selectedRoots, new Set([2]))).toEqual([[2]]); - */ -export function coordsForRootIndexes( - mapping: LinearLayoutSelectionMap, - tensorId: string, - selectedRootIndexes: Set, - visibleRootIndexes: Set | null = null, -): number[][] { - const tensor = mapping.tensors.get(tensorId); - if (!tensor || selectedRootIndexes.size === 0) return []; - return tensor.cellRootIndexes.flatMap((roots, flat) => { - const matchesSelection = roots.some((rootIndex) => selectedRootIndexes.has(rootIndex)); - const matchesVisible = visibleRootIndexes === null || roots.some((rootIndex) => visibleRootIndexes.has(rootIndex)); - return matchesSelection && matchesVisible ? [unravelIndex(flat, tensor.meta.shape)] : []; - }); -} - -/** - * Reads the root index currently rendered for one tensor cell in a linear-layout display model. - * - * Hover popups and inspector rows use this helper to connect a visible tensor coordinate back to the compose-root entry that the display model chose for that cell. - * - * @param display - Display model returned by `linearLayoutDisplayModel`, including the per-tensor array of displayed root indexes. - * @param mapping - Linear-layout selection map used to translate the tensor coordinate into the flat cell index used by the display arrays. - * @param tensorId - Identifier of the tensor that owns the coordinate being inspected. - * @param coord - Tensor coordinate to inspect, with one number per tensor axis. - * @returns The displayed compose-root index for the cell, or `null` when the tensor id is unknown, the coordinate is outside the tensor, or the display model has no root for that cell. - * @noThrows Unknown tensor ids, unknown coordinate keys, and missing display entries are all checked with guarded lookups and return `null`. - * @example - * const display = linearLayoutDisplayModel(ctx, mapping); - * - * expect(displayedRootIndexForCoord(display, mapping, 'compose-root', [2])).toBe(2); - * expect(displayedRootIndexForCoord(display, mapping, 'compose-root', [99])).toBeNull(); - */ -export function displayedRootIndexForCoord( - display: LinearLayoutDisplayModel, - mapping: LinearLayoutSelectionMap, - tensorId: string, - coord: number[], -): number | null { - const tensor = mapping.tensors.get(tensorId); - if (!tensor) return null; - const flat = tensor.coordKeyToFlatIndex.get(coordKey(coord)); - if (flat === undefined) return null; - return display.displayedRootIndexByTensor.get(tensorId)?.[flat] ?? null; -} - -/** - * Narrows the visible linear-layout roots to the member selected by the focused tensor's multi-input slider. - * - * Each tensor cell can map to several root indexes. This picks the `index`th root from each focused-tensor cell, - * after applying any tensor-view slice filter for that tensor, so hover and display synchronization can show one - * many-to-one member at a time. - * - * @param mapping - Linear-layout selection map whose `tensors` entry contains `focusedTensorId` and per-cell root-index lists. - * @param focusedTensorId - Tensor id for the slider that currently controls the many-to-one focus. - * @param index - Zero-based slider position to select from each focused tensor cell's root list; negative values disable focus. - * @param sliceVisibleRootIndexes - Root indexes still visible for each tensor after tensor-view slicing. - * @returns A set of selected root indexes, or `null` when the index is negative or the focused tensor is not present in the mapping. - * @noThrows Missing focused tensors and disabled slider indexes are represented as `null`; missing slice filters mean all roots in the focused tensor remain eligible. - * @example - * const mapping = { - * tensors: new Map([ - * ['rhs', { cellRootIndexes: [[0, 2], [1, 3]] }], - * ]), - * } as LinearLayoutSelectionMap; - * const visibleByTensor = new Map([['rhs', new Set([2, 3])]]); - * - * Array.from(focusedRootIndexes(mapping, 'rhs', 0, visibleByTensor)!).sort(); - * // => [2, 3] - * - * focusedRootIndexes(mapping, 'rhs', -1, visibleByTensor); - * // => null - */ -function focusedRootIndexes( - mapping: LinearLayoutSelectionMap, - focusedTensorId: string, - index: number, - sliceVisibleRootIndexes: Map>, -): Set | null { - if (index < 0) return null; - const tensor = mapping.tensors.get(focusedTensorId); - if (!tensor) return null; - const visibleRoots = sliceVisibleRootIndexes.get(focusedTensorId) ?? null; - return new Set(tensor.cellRootIndexes.flatMap((roots) => { - const filteredRoots = visibleRoots ? roots.filter((rootIndex) => visibleRoots.has(rootIndex)) : roots; - const rootIndex = filteredRoots[index]; - return rootIndex === undefined ? [] : [rootIndex]; - })); -} - -/** - * Builds the per-tensor root visibility filters implied by each tensor's current tensor-view slice. - * - * The display model uses this map to hide linear-layout roots that are outside the active slices before applying - * multi-input focus. Tensors whose view does not resolve to any visible roots are omitted. - * - * @param ctx - Linear-layout UI context whose viewer provides tensor status, tensor-view editor snapshots, and hidden indices. - * @param mapping - Selection map that orders tensor ids and maps visible tensor coordinates back to linear-layout root indexes. - * @returns A map from tensor id to the root indexes visible in that tensor's parsed slice; tensors with no visible roots are absent. - * @noThrows Invalid tensor-view text is converted by `slicedTensorCoords` into `null`, which contributes an empty root set that is filtered out. - * @example - * const visibleByTensor = sliceVisibleRootIndexesByTensor(ctx, mapping); - * - * visibleByTensor.get('lhs'); - * // => Set containing the root indexes for coordinates still visible in the lhs tensor view - * - * visibleByTensor.has('tensor-with-invalid-view'); - * // => false - */ -function sliceVisibleRootIndexesByTensor( - ctx: LinearLayoutUiContext, - mapping: LinearLayoutSelectionMap, -): Map> { - return new Map(mapping.orderedTensorIds.map((tensorId) => { - const coords = slicedTensorCoords(ctx, tensorId); - return [tensorId, coords ? rootIndexesForCoords(mapping, tensorId, coords) : new Set()] as const; - }).filter(([_tensorId, roots]) => roots.size > 0)); -} - -/** - * Finds the linear-layout root indexes that remain visible in every active tensor-view slice. - * - * The result is the shared visibility mask used before optional focused-tensor narrowing. An empty iterable means no - * tensor-view slice is constraining the display. - * - * @param sets - Visible root-index sets produced for each tensor that currently has an active slice filter. - * @param rootCount - Total number of roots in the mapping; accepted for call-site symmetry with visibility calculations but not needed to compute the intersection. - * @returns A set containing only root indexes present in every supplied set, or `null` when no sets were supplied. - * @noThrows The function only iterates the supplied sets and allocates result sets; absence of slice filters is reported as `null` rather than an exception. - * @example - * const visibleRoots = intersectRootIndexes([ - * new Set([0, 1, 3]), - * new Set([1, 3, 4]), - * ], 5); - * - * Array.from(visibleRoots!).sort(); - * // => [1, 3] - * - * intersectRootIndexes([], 5); - * // => null - */ -function intersectRootIndexes(sets: Iterable>, rootCount: number): Set | null { - let intersection: Set | null = null; - for (const set of sets) { - intersection = intersection - ? new Set(Array.from(intersection).filter((rootIndex) => set.has(rootIndex))) - : new Set(set); - } - if (intersection) return intersection; - return null; -} - -/** - * Converts a tensor's current tensor-view editor state into the coordinates that remain visible after slicing. - * - * The linear-layout extension uses these coordinates to map viewer slices back to composed root indexes for - * multi-input visibility and hover synchronization. - * - * @param ctx - Linear-layout UI context whose viewer can read the tensor status and current tensor-view snapshot. - * @param tensorId - Id of the tensor whose shape, axis labels, hidden indices, and editor text should be parsed. - * @returns Visible tensor coordinates as index arrays, or `null` when the tensor-view editor text cannot be parsed for the tensor shape. - * @noThrows Tensor-view syntax errors are returned as `null` parse results, letting callers treat invalid editor state as no slice-derived visibility filter. - * @example - * const coords = slicedTensorCoords(ctx, 'lhs'); - * - * coords; - * // => [[0, 0], [0, 1]] for a 2-D tensor view that leaves the first row visible - * - * // If the lhs tensor-view editor contains invalid syntax: - * slicedTensorCoords(ctx, 'lhs'); - * // => null - */ -function slicedTensorCoords(ctx: LinearLayoutUiContext, tensorId: string): number[][] | null { - const status = ctx.viewer.getTensorStatus(tensorId); - const snapshot = ctx.viewer.getTensorView(tensorId); - const parsed = parseTensorView( - status.shape.slice(), - serializeTensorViewEditor(snapshot.editor), - snapshot.hiddenIndices, - status.axisLabels, - ); - return !parsed.ok ? null : visibleTensorCoords(parsed.spec); -} - -/** - * Decodes a row-major flat tensor position into one coordinate per axis of the supplied shape. - * - * @param index - Zero-based flat position in storage order for a tensor with the supplied dimensions. - * @param shape - Tensor dimensions ordered from outermost axis to innermost axis; an empty shape represents a scalar. - * @returns Coordinate tuple for the flat position, with `coord.length === shape.length`; scalars return an empty tuple. - * @noThrows Uses only array allocation and arithmetic on caller-supplied numbers; scalar shapes return before indexing. - * @example - * unravelIndex(5, [2, 3]); - * // => [1, 2] - * - * unravelIndex(0, []); - * // => [] - */ -function unravelIndex(index: number, shape: number[]): number[] { - if (shape.length === 0) return []; - const coord = new Array(shape.length).fill(0); - let remainder = index; - for (let axis = shape.length - 1; axis >= 0; axis -= 1) { - const size = shape[axis] ?? 1; - coord[axis] = remainder % size; - remainder = Math.floor(remainder / size); - } - return coord; -} - -/** - * Builds the hover ghost text that shows enabled linear-layout axis labels beside their coordinate values. - * - * @param coord - Coordinate tuple for the hovered root or propagated tensor cell. - * @param labels - Axis labels in the same order as `coord`; labels past the coordinate length are ignored. - * @param state - Map whose truthy entries mark which axis labels should be shown in the hover text. - * @returns Newline-delimited `label:value` lines for enabled labels, or `null` when no enabled label has a coordinate. - * @noThrows Only filters and formats the provided arrays and object; missing coordinate entries default to `0`. - * @example - * linearLayoutGhostText([3, 1, 0], ['m', 'n', 'k'], { m: true, n: false, k: true }); - * // => 'm:3\nk:0' - * - * linearLayoutGhostText([3, 1], ['m', 'n'], { m: false, n: false }); - * // => null - */ -function linearLayoutGhostText(coord: number[], labels: string[], state: Record): string | null { - const text = labels - .flatMap((label, axis) => (state[label] && axis < coord.length ? [`${label}:${coord[axis] ?? 0}`] : [])) - .join('\n'); - return text || null; -} - -/** - * Resolves the coordinate displayed for a root input cell, either in root-input space or after propagation to final-output space. - * - * @param mapping - Linear-layout selection map containing `rootKeys` for input coordinates and `rootToFinalKeys` for propagated output coordinates. - * @param rootIndex - Zero-based index of the root input cell whose selection or hover coordinate is being displayed. - * @param propagateOutputs - Whether the Propagate Outputs control is enabled and final-output coordinates should be used. - * @returns Coordinate decoded from the selected mapping key; missing keys resolve to the empty coordinate key. - * @noThrows Reads mapping arrays with fallback to an empty key before decoding, so absent entries do not throw. - * @example - * const mapping = { - * rootKeys: ['0,0', '0,1'], - * rootToFinalKeys: ['1,0', '1,1'], - * } as LinearLayoutSelectionMap; - * - * propagatedCoordForRoot(mapping, 1, false); - * // => [0, 1] - * - * propagatedCoordForRoot(mapping, 1, true); - * // => [1, 1] - */ -function propagatedCoordForRoot(mapping: LinearLayoutSelectionMap, rootIndex: number, propagateOutputs: boolean): number[] { - const key = propagateOutputs ? mapping.rootToFinalKeys[rootIndex] : mapping.rootKeys[rootIndex]; - return coordFromKey(key ?? ''); -} - -/** - * Converts a root cell's displayed coordinate into the row-major flat index used to look up propagated selection colors. - * - * @param mapping - Linear-layout selection map containing root/final coordinate keys and their corresponding input/output shapes. - * @param rootIndex - Zero-based index of the root input cell whose color-buffer position is needed. - * @param propagateOutputs - Whether to flatten the propagated final-output coordinate instead of the original root-input coordinate. - * @returns Row-major flat index within `rootInputShape` when propagation is off, or within `finalOutputShape` when it is on. - * @noThrows Delegates coordinate lookup to `propagatedCoordForRoot` and reduces over the returned coordinate; missing keys reduce to `0`. - * @example - * const mapping = { - * rootInputShape: [2, 3], - * finalOutputShape: [3, 2], - * rootKeys: ['0,0', '0,1'], - * rootToFinalKeys: ['1,0', '1,1'], - * } as LinearLayoutSelectionMap; - * - * propagatedIndexForRoot(mapping, 1, false); - * // => 1 - * - * propagatedIndexForRoot(mapping, 1, true); - * // => 3 - */ -function propagatedIndexForRoot(mapping: LinearLayoutSelectionMap, rootIndex: number, propagateOutputs: boolean): number { - const shape = propagateOutputs ? mapping.finalOutputShape : mapping.rootInputShape; - return propagatedCoordForRoot(mapping, rootIndex, propagateOutputs) - .reduce((index, value, axis) => (index * shape[axis]!) + value, 0); -} - -/** - * Retrieves the selection-synchronization map for a loaded linear-layout tab, reusing the per-tab cache before parsing the tab metadata. - * - * Hover popups, multi-input sliders, and inspector coordinate rows use this map to translate viewer selections back to propagated linear-layout labels. - * - * @param ctx - Linear-layout UI context whose state owns the `linearLayoutSelectionMaps` cache keyed by tab id. - * @param tab - Loaded viewer tab that may contain compose-layout metadata embedded when the layout was rendered. - * @returns The cached or newly parsed selection map for `tab`, or `null` when the tab metadata does not describe a linear-layout selection mapping. - * @noThrows Cache lookup and metadata probing are guarded by null checks; tabs without usable mapping metadata are reported as `null` instead of raising an error. - * @example - * const first = linearLayoutSelectionMapForTab(ctx, linearLayoutTab); - * if (first) { - * console.assert(ctx.state.linearLayoutSelectionMaps.get(linearLayoutTab.id) === first); - * console.assert(linearLayoutSelectionMapForTab(ctx, linearLayoutTab) === first); - * } - * - * const missing = linearLayoutSelectionMapForTab(ctx, ordinaryTensorTab); - * console.assert(missing === null); - */ -function linearLayoutSelectionMapForTab(ctx: LinearLayoutUiContext, tab: LoadedBundleDocument): LinearLayoutSelectionMap | null { - const cached = ctx.state.linearLayoutSelectionMaps.get(tab.id); - if (cached) return cached; - const mapping = linearLayoutSelectionMapForMeta(tab); - if (!mapping) return null; - ctx.state.linearLayoutSelectionMaps.set(tab.id, mapping); - return mapping; -} diff --git a/packages/viewer-demo/src/extensions/linear-layout/linear-layout-parser.ts b/packages/viewer-demo/src/extensions/linear-layout/linear-layout-parser.ts deleted file mode 100644 index 5b71042..0000000 --- a/packages/viewer-demo/src/extensions/linear-layout/linear-layout-parser.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Parsed representation of one named compose-layout basis block from the specs editor. - * - * `inputs` and `outputs` preserve the labels from the signature line, while `bases[inputIndex][bitIndex][outputIndex]` stores the numeric contribution of each input bit to each output label. - * - * @example - * const spec: NamedLayoutSpec = { - * name: 'mma', - * inputs: ['A', 'B'], - * outputs: ['M', 'N'], - * bases: [ - * [[1, 0], [0, 1]], - * [[1, 1]], - * ], - * }; - * console.assert(spec.inputs[0] === 'A'); - * console.assert(spec.bases[0][1][1] === 1); - */ -export type NamedLayoutSpec = { - name: string; - inputs: string[]; - outputs: string[]; - bases: number[][][]; -}; - -/** - * Parses the linear-layout specs editor notation into named basis blocks. - * - * The notation uses a signature line such as `mma: [A,B] -> [M,N]` followed by one `: ` basis row for each input label. Blank lines and `#` comments are ignored before syntax parsing. - * - * @param text - Raw contents of the layout specs textarea, including signature lines, labeled JSON basis rows, blank lines, and optional `#` comments. - * @returns Parsed layout specs in editor order; each spec contains the signature name, input labels, output labels, and basis rows reordered to match the signature input order. - * @throws Error when a required basis row is missing, a row does not use `