From 6ccdd59aea32e7bbb1495ea921e2ac1ef2395b41 Mon Sep 17 00:00:00 2001 From: leeliu103 Date: Tue, 25 Nov 2025 00:28:06 +0000 Subject: [PATCH] Add Linear Layout visualization with matrix editor and fix UI issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LinearLayoutTab with interactive matrix editor for tensor layout configuration - Add LinearLayoutMatrixEditor with draggable dialog and click-to-toggle cells - Fix matrix editor cell sizing: maintain 32px minimum even with 256+ rows - Implement modal overlay that blocks page interaction while allowing canvas events - Preserve matrix data when renaming dimensions (only reset on structural changes) - Restore Playwright configuration with environment variable support - Upgrade @playwright/test from 1.48.0 to 1.56.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- index.html | 14 +- package-lock.json | 6 +- package.json | 2 +- playwright.config.ts | 17 +- src/main.tabs.test.ts | 12 + src/main.ts | 2 + src/styles.css | 613 +++++++++++++++- src/tabs/LinearLayoutTab.ts | 418 +++++++++++ src/ui/LinearLayoutMatrixEditor.ts | 1041 ++++++++++++++++++++++++++++ 9 files changed, 2109 insertions(+), 16 deletions(-) create mode 100644 src/tabs/LinearLayoutTab.ts create mode 100644 src/ui/LinearLayoutMatrixEditor.ts diff --git a/index.html b/index.html index a8361e1..2e83c08 100644 --- a/index.html +++ b/index.html @@ -232,7 +232,19 @@

Operand

-
+
+ + +
+ +
+
diff --git a/package-lock.json b/package-lock.json index 4f03792..e50e016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { - "name": "triton-layout-visualizer", + "name": "gpu-layout-visualizer", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "triton-layout-visualizer", + "name": "gpu-layout-visualizer", "version": "1.0.0", "devDependencies": { - "@playwright/test": "^1.48.0", + "@playwright/test": "^1.56.1", "@types/node": "^22.10.2", "@vitest/coverage-v8": "^2.1.8", "@vitest/ui": "^2.1.8", diff --git a/package.json b/package.json index 3d36771..87691f7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:coverage": "vitest --coverage" }, "devDependencies": { - "@playwright/test": "^1.48.0", + "@playwright/test": "^1.56.1", "@types/node": "^22.10.2", "@vitest/coverage-v8": "^2.1.8", "@vitest/ui": "^2.1.8", diff --git a/playwright.config.ts b/playwright.config.ts index 2d79005..ba9d480 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,28 +1,27 @@ import { defineConfig, devices } from '@playwright/test' +const HOST = process.env.PLAYWRIGHT_WEB_SERVER_HOST ?? '127.0.0.1' +const PORT = Number(process.env.PLAYWRIGHT_WEB_SERVER_PORT ?? 4173) +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${HOST}:${PORT}` + export default defineConfig({ testDir: './tests', fullyParallel: true, - forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: 'html', + reporter: process.env.CI ? 'dot' : 'list', use: { - baseURL: 'http://localhost:3000', + baseURL: BASE_URL, trace: 'on-first-retry', - screenshot: 'only-on-failure', }, - projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], - webServer: { - command: 'npm run dev', - url: 'http://localhost:3000', + command: `npm run dev -- --host ${HOST} --port ${PORT}`, + url: BASE_URL, reuseExistingServer: !process.env.CI, }, }) diff --git a/src/main.tabs.test.ts b/src/main.tabs.test.ts index bd94933..ffd8dc5 100644 --- a/src/main.tabs.test.ts +++ b/src/main.tabs.test.ts @@ -84,6 +84,16 @@ vi.mock('./tabs/MFMALayoutTab', () => ({ MFMALayoutTab: MFMALayoutTabMock, })) +const LinearLayoutTabMock = vi.fn().mockImplementation(() => ({ + activate: vi.fn(), + deactivate: vi.fn(), + resize: vi.fn(), +})) + +vi.mock('./tabs/LinearLayoutTab', () => ({ + LinearLayoutTab: LinearLayoutTabMock, +})) + const setupDom = () => { document.body.innerHTML = '' vi.stubGlobal('alert', vi.fn()) @@ -359,6 +369,8 @@ describe('main tab switching', () => { onInputMock.mockReset() setupDom() + + LinearLayoutTabMock.mockClear() }) afterEach(() => { diff --git a/src/main.ts b/src/main.ts index 1221243..939f4bf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import { BlockLayoutTab } from './tabs/BlockLayoutTab' import { WMMALayoutTab } from './tabs/WMMALayoutTab' import { MFMALayoutTab } from './tabs/MFMALayoutTab' +import { LinearLayoutTab } from './tabs/LinearLayoutTab' type TabController = { activate(): void @@ -22,6 +23,7 @@ const controllers: Map = new Map() controllers.set('block-layout', new BlockLayoutTab('block-layout')) controllers.set('wmma-layout', new WMMALayoutTab('wmma-layout')) controllers.set('mfma-layout', new MFMALayoutTab('mfma-layout')) +controllers.set('linear-layout', new LinearLayoutTab('linear-layout')) let currentTabId: string | null = null diff --git a/src/styles.css b/src/styles.css index 7173acf..7f37d9c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -45,6 +45,54 @@ header p { padding: 1.5rem; overflow-y: auto; box-shadow: 2px 0 4px rgba(0,0,0,0.05); + scrollbar-width: thin; + scrollbar-color: #cbd5e0 #f8f9fa; + position: relative; +} + +.sidebar::-webkit-scrollbar { + width: 10px; +} + +.sidebar::-webkit-scrollbar-track { + background: #f8f9fa; + border-radius: 4px; +} + +.sidebar::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 4px; + border: 2px solid #f8f9fa; +} + +.sidebar::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +.sidebar.matrix-editor-locked { + cursor: not-allowed; + filter: grayscale(0.25); +} + +.sidebar.matrix-editor-locked::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 8px; + background: rgba(248, 250, 252, 0.8); + box-shadow: inset 0 0 0 1px rgba(52, 152, 219, 0.15); + pointer-events: none; + z-index: 1; +} + +.sidebar.matrix-editor-locked > * { + position: relative; + z-index: 0; +} + +.sidebar.matrix-editor-locked * { + pointer-events: none !important; + user-select: none; } .sidebar h2 { @@ -66,18 +114,20 @@ header p { .form-row { display: flex; gap: 0.5rem; + align-items: flex-end; } -.form-row label { - flex: 1; +label { display: flex; flex-direction: column; gap: 0.25rem; + flex: 1; font-size: 0.9rem; color: #555; } input[type="number"], +input[type="text"], select { padding: 0.5rem; border: 1px solid #ddd; @@ -87,6 +137,7 @@ select { } input[type="number"]:focus, +input[type="text"]:focus, select:focus { outline: none; border-color: #3498db; @@ -114,6 +165,163 @@ button:active { background-color: #21618c; } +.dimension-add { + width: 100%; + padding: 0.5rem; + font-size: 0.9rem; + background-color: #27ae60; + margin-bottom: 0; + margin-top: 0.5rem; +} + +.dimension-add:hover { + background-color: #229954; +} + +.dimension-add:active { + background-color: #1e8449; +} + +.dimension-add:disabled { + background-color: #95a5a6; + cursor: not-allowed; + opacity: 0.6; +} + +.dimension-add:disabled:hover { + background-color: #95a5a6; +} + +.dimension-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 0.5rem; + max-height: 400px; + overflow-y: auto; + padding-right: 0.25rem; + scrollbar-width: thin; + scrollbar-color: #cbd5e0 #f8f9fa; +} + +.dimension-list::-webkit-scrollbar { + width: 6px; +} + +.dimension-list::-webkit-scrollbar-track { + background: #f8f9fa; + border-radius: 3px; +} + +.dimension-list::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 3px; +} + +.dimension-list::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +.dimension-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; + flex-wrap: wrap; + padding: 0.75rem; + padding-top: 2rem; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; + position: relative; +} + +.dimension-row label { + flex: 1; + min-width: 120px; + font-weight: 500; +} + +.dimension-row label::after { + content: ''; + display: block; + height: 0.15rem; + background: linear-gradient(90deg, #3498db 0%, transparent 100%); + margin-top: 0.15rem; + opacity: 0; + transition: opacity 0.2s ease; + margin-bottom: -0.15rem; +} + +.dimension-row label:focus-within::after { + opacity: 1; +} + +.dimension-hint { + font-size: 0.8rem; + color: #7f8c8d; + margin-top: 0.35rem; +} + +.dimension-error { + font-size: 0.8rem; + color: #c0392b; + margin-top: 0.25rem; + display: none; +} + +.dimension-error.visible { + display: block; +} + +.dimension-row .dimension-error { + flex: 1 1 100%; +} + +.dimension-remove { + position: absolute; + top: 0.35rem; + right: 0.35rem; + width: 1.5rem; + height: 1.5rem; + padding: 0; + font-size: 0.9rem; + font-weight: 700; + line-height: 1; + background-color: transparent; + color: #95a5a6; + border: none; + border-radius: 3px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; +} + +.dimension-remove:hover { + background-color: #e74c3c; + color: #ffffff; + transform: scale(1.1); +} + +.dimension-remove:active { + background-color: #c0392b; + color: #ffffff; + transform: scale(1.05); +} + +.dimension-remove:disabled { + color: #cbd5e0; + cursor: not-allowed; + opacity: 0.4; +} + +.dimension-remove:disabled:hover { + background-color: transparent; + color: #cbd5e0; + transform: none; +} + .controls { margin-top: 2rem; padding-top: 1.5rem; @@ -146,6 +354,25 @@ button:active { padding: 0.25rem 0; } +.linear-operations { + margin-top: 1rem; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 4px; + font-size: 0.85rem; + line-height: 1.6; + border: 1px solid #dee2e6; +} + +.linear-operations > div { + padding: 0.5rem 0; +} + +.linear-operations > div:not(:last-child) { + border-bottom: 1px solid #e9ecef; + padding-bottom: 0.75rem; +} + .validation-errors, .validation-warnings { margin-bottom: 1rem; @@ -225,6 +452,34 @@ button:active { justify-content: center; padding: 2rem; background-color: #fafafa; + overflow: auto; +} + +.linear-visualization { + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 1rem; + padding: 1.25rem; +} + +.linear-visualization canvas { + align-self: center; + max-width: 100%; +} + +.visualization canvas { + border: 2px solid #ddd; + border-radius: 4px; + background-color: white; + cursor: grab; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + display: block; + max-width: 100%; +} + +.visualization canvas:active { + cursor: grabbing; } #canvas { @@ -239,6 +494,360 @@ button:active { cursor: grabbing; } +.matrix-editor-overlay { + position: fixed; + inset: 0; + background-color: rgba(15, 23, 42, 0.45); + display: none; + z-index: 2000; + pointer-events: none; + touch-action: none; + overscroll-behavior: contain; +} + +.matrix-editor-overlay.visible { + display: block; + pointer-events: auto; +} + +.matrix-editor-dialog { + position: fixed; + background-color: #ffffff; + border-radius: 8px; + border: 1px solid #cbd5e0; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + min-width: 380px; + min-height: 280px; + overflow: hidden; + transition: box-shadow 0.2s ease; + pointer-events: auto; + --matrix-row-header-width: 105px; + --matrix-cell-min-size: 32px; + --matrix-cell-max-size: 48px; + --matrix-cell-size-effective: clamp( + var(--matrix-cell-min-size), + var(--matrix-cell-size, var(--matrix-cell-max-size)), + var(--matrix-cell-max-size) + ); +} + +.matrix-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.65rem 1rem; + background: linear-gradient(135deg, #3498db, #5dade2); + color: #fff; + cursor: move; + user-select: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); +} + +.matrix-editor-title { + font-size: 1rem; + font-weight: 600; +} + +.matrix-editor-actions { + display: flex; + gap: 0.5rem; +} + +.matrix-editor-close { + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 4px; + padding: 0.25rem 0.6rem; + cursor: pointer; + color: #fff; + font-weight: 600; + width: auto; +} + +.matrix-editor-close:hover { + background: rgba(255, 255, 255, 0.3); +} + +.matrix-editor-body { + flex: 1; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + background-color: #f8f9fa; + position: relative; + overflow: hidden; + min-height: 0; +} + +.matrix-editor-dialog.matrix-auto-fit .matrix-editor-body { + align-items: flex-start; +} + +.matrix-editor-dialog.matrix-auto-fit .matrix-grid-table { + align-self: flex-start; + flex: 0 1 auto; + width: min(100%, max-content); + max-width: 100%; + max-height: 100%; + grid-template-columns: var(--matrix-row-header-width, 100px) minmax(0, max-content); +} + +.matrix-editor-dialog.matrix-auto-fit .matrix-grid-scroll { + width: 100%; + max-width: 100%; + max-height: 100%; + min-width: 0; +} + +.matrix-editor-dialog.matrix-auto-fit .matrix-grid { + min-width: max-content; + min-height: max-content; +} + +.matrix-grid-table { + display: grid; + grid-template-columns: var(--matrix-row-header-width, 100px) minmax(0, 1fr); + grid-template-rows: var(--matrix-column-header-height, 70px) minmax(0, 1fr); + background-color: #ffffff; + border: 1px solid #dee2e6; + border-radius: 6px; + overflow: hidden; + width: 100%; + flex: 1; + min-height: 0; + max-height: 100%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.matrix-grid-table.hidden { + display: none; +} + +.matrix-label-corner { + border-bottom: 1px solid #dee2e6; + border-right: 1px solid #dee2e6; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + color: #6c757d; + background-color: #f8f9fa; + padding: 0.3rem 0.2rem; + gap: 0.15rem; + line-height: 1.2; +} + +.matrix-column-headers { + border-bottom: 1px solid #dee2e6; + overflow: hidden; + background-color: #f8f9fa; +} + +.matrix-row-headers { + border-right: 1px solid #dee2e6; + overflow: hidden; + background-color: #f8f9fa; +} + +.matrix-column-track { + display: grid; + gap: 2px; + padding: 0; + margin: 0; + min-width: max-content; + height: 100%; + justify-content: start; + align-content: stretch; + align-items: stretch; + grid-auto-flow: column; + grid-auto-columns: var(--matrix-cell-size-effective, 48px); +} + +.matrix-row-track { + display: grid; + gap: 2px; + padding: 0; + margin: 0; + width: 100%; + min-height: max-content; + grid-template-columns: minmax(0, 1fr); + justify-content: start; + justify-items: stretch; + align-content: start; + align-items: stretch; + grid-auto-rows: var(--matrix-cell-size-effective, 48px); +} + +.matrix-grid-scroll { + overflow: auto; + background-color: #ffffff; + scrollbar-width: thin; + scrollbar-color: #cbd5e0 #f1f3f5; + min-height: 0; + min-width: 0; + max-height: 100%; + padding: 0; +} + +.matrix-grid-scroll::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.matrix-grid-scroll::-webkit-scrollbar-track { + background: #f1f3f5; + border-radius: 4px; +} + +.matrix-grid-scroll::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 4px; + border: 2px solid #f1f3f5; +} + +.matrix-grid-scroll::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +.matrix-grid-scroll::-webkit-scrollbar-corner { + background: #f1f3f5; +} + +.matrix-grid { + display: grid; + gap: 2px; + padding: 0; + margin: 0; + width: max-content; + min-width: 100%; + min-height: 100%; + grid-auto-rows: var(--matrix-cell-size-effective, 48px); + grid-auto-columns: var(--matrix-cell-size-effective, 48px); + justify-content: start; + align-content: start; +} + +.matrix-header-cell { + background-color: #ffffff; + border: 1px solid #dee2e6; + border-radius: 3px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.15rem; + text-align: center; + transition: background-color 0.15s ease; + margin: 0; + padding: 0.3rem; + box-sizing: border-box; +} + +.matrix-column-cell { + width: var(--matrix-cell-size-effective, 48px); + min-width: var(--matrix-cell-size-effective, 48px); + max-width: var(--matrix-cell-size-effective, 48px); + height: 100%; +} + +.matrix-row-cell { + width: 100%; + max-width: 100%; + height: 100%; +} + +.matrix-header-cell:hover { + background-color: #f8f9fa; +} + +.matrix-header-name { + font-size: 0.7rem; + font-weight: 600; + color: #2c3e50; + line-height: 1.1; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; + text-align: center; + white-space: normal; + hyphens: auto; +} + +.matrix-header-bit { + font-size: 0.65rem; + color: #6c757d; + margin-top: 0; +} + +.matrix-cell { + width: var(--matrix-cell-size-effective, 48px); + height: var(--matrix-cell-size-effective, 48px); + border-radius: 4px; + border: 1.5px solid #d1e7fd; + background-color: #e3f2fd; + color: #1565c0; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + box-sizing: border-box; + flex-shrink: 0; +} + +.matrix-cell:hover { + background-color: #bbdefb; + border-color: #90caf9; + transform: scale(1.05); +} + +.matrix-cell.active { + background-color: #3498db; + color: #ffffff; + border-color: #2980b9; + box-shadow: 0 2px 4px rgba(52, 152, 219, 0.3); +} + +.matrix-cell.active:hover { + background-color: #2980b9; + transform: scale(1.05); +} + +.matrix-empty-state { + display: none; + font-size: 0.9rem; + color: #475569; + text-align: center; + padding: 1rem; + border: 1px dashed #cbd5f5; + border-radius: 8px; + background-color: #fff; +} + +.matrix-empty-state.visible { + display: block; +} + +.matrix-resize-handle { + position: absolute; + width: 16px; + height: 16px; + right: 8px; + bottom: 8px; + cursor: nwse-resize; + background: linear-gradient(135deg, transparent 50%, rgba(37, 99, 235, 0.5) 50%); + border-radius: 3px; +} + /* Tab Navigation */ .tabs { display: flex; diff --git a/src/tabs/LinearLayoutTab.ts b/src/tabs/LinearLayoutTab.ts new file mode 100644 index 0000000..5ffbfb0 --- /dev/null +++ b/src/tabs/LinearLayoutTab.ts @@ -0,0 +1,418 @@ +import { LinearLayout } from '../core/LinearLayout' +import type { BlockLayoutParams } from '../validation/InputValidator' +import { CanvasRenderer, type PositionResolver } from '../visualization/CanvasRenderer' +import { LinearLayoutMatrixEditor, type MatrixEditorDimensions } from '../ui/LinearLayoutMatrixEditor' +import { renderSharedControls } from '../ui/renderSharedControls' +import { CanvasTab, type CanvasTabElements } from './CanvasTab' + +type DimensionType = 'input' | 'output' + +interface LinearDimension { + id: string + name: string + size: number +} + +/** + * Restored Linear Layout tab that re-introduces the dimension controls while + * continuing to render the simplified visualization until matrix wiring is required. + */ +export class LinearLayoutTab extends CanvasTab { + private readonly params: BlockLayoutParams + private readonly layout: LinearLayout + private readonly form: HTMLFormElement + private readonly sidebar: HTMLElement + private readonly dimensionLists: Record + private readonly dimensionAddButtons: Record + private readonly matrixButton: HTMLButtonElement + private readonly matrixEditor: LinearLayoutMatrixEditor + private dimensionState: Record + + constructor(tabId: string) { + const tabContent = document.getElementById(tabId) + if (!tabContent) { + throw new Error(`LinearLayoutTab container not found: ${tabId}`) + } + + const visualizationContainer = tabContent.querySelector('.visualization') + if (!(visualizationContainer instanceof HTMLElement)) { + throw new Error('LinearLayoutTab visualization container not found') + } + + const canvas = visualizationContainer.querySelector('canvas') + if (!(canvas instanceof HTMLCanvasElement)) { + throw new Error('LinearLayoutTab canvas element not found') + } + + const sidebar = tabContent.querySelector('.sidebar') + if (!(sidebar instanceof HTMLElement)) { + throw new Error('LinearLayoutTab sidebar not found') + } + + const form = tabContent.querySelector('#linearLayoutForm') + if (!(form instanceof HTMLFormElement)) { + throw new Error('LinearLayoutTab form element not found') + } + + const controlsContainer = tabContent.querySelector('[data-controls]') + if (!(controlsContainer instanceof HTMLElement)) { + throw new Error('LinearLayoutTab controls container not found') + } + const resetButton = renderSharedControls(controlsContainer, { resetButtonId: 'linear-reset' }) + + const elements: CanvasTabElements = { + root: tabContent, + canvas, + visualizationContainer, + resetButton, + } + + super(elements) + + this.sidebar = sidebar + this.form = form + this.dimensionState = { + input: [ + { id: this.createDimensionId(), name: 'register', size: 4 }, + { id: this.createDimensionId(), name: 'thread', size: 32 }, + ], + output: [ + { id: this.createDimensionId(), name: 'outdim1', size: 16 }, + { id: this.createDimensionId(), name: 'outdim2', size: 256 }, + ], + } + + this.form.innerHTML = this.buildFormMarkup() + + const inputList = this.form.querySelector('[data-dimension-list="input"]') + const outputList = this.form.querySelector('[data-dimension-list="output"]') + const inputAdd = this.form.querySelector('[data-add-dimension="input"]') + const outputAdd = this.form.querySelector('[data-add-dimension="output"]') + const matrixButton = this.form.querySelector('#linear-edit-matrix') + + if (!inputList || !outputList || !inputAdd || !outputAdd || !matrixButton) { + throw new Error('LinearLayoutTab dimension controls failed to initialize') + } + + this.dimensionLists = { + input: inputList, + output: outputList, + } + this.dimensionAddButtons = { + input: inputAdd, + output: outputAdd, + } + this.matrixButton = matrixButton + this.matrixEditor = new LinearLayoutMatrixEditor() + this.matrixEditor.onVisibilityChange((isOpen) => { + this.toggleSidebarInteractivity(isOpen) + }) + + inputAdd.addEventListener('click', () => this.addDimension('input')) + outputAdd.addEventListener('click', () => this.addDimension('output')) + matrixButton.addEventListener('click', () => this.handleMatrixEditorClick()) + + this.renderDimensionRows('input', { showErrors: false }) + this.renderDimensionRows('output', { showErrors: false }) + this.renderOperationsInfo(controlsContainer) + + this.params = { + sizePerThread: [1, 1], + threadsPerWarp: [8, 4], + warpsPerCTA: [2, 1], + order: [0, 1], + tensorShape: [8, 8], + } + + const totalThreads = + this.params.threadsPerWarp[0] * + this.params.threadsPerWarp[1] * + this.params.warpsPerCTA[0] * + this.params.warpsPerCTA[1] + + this.layout = LinearLayout.identity1D(totalThreads, 'thread', 'logical') + + this.initializeRenderer() + } + + private initializeRenderer(): void { + const positionResolver: PositionResolver = (_layout, threadId) => { + const columns = this.params.tensorShape[1] + const row = Math.floor(threadId / columns) + const column = threadId % columns + return [ + { + pos: [row, column], + registerId: threadId, + sourcePos: [row, column], + }, + ] + } + + this.resizeCanvas() + const renderer = new CanvasRenderer(this.canvas, this.layout, this.params, positionResolver, { + colorGrouping: 'thread', + }) + this.setRenderer(renderer) + renderer.render() + } + + protected handleHover(event: MouseEvent): void { + const renderer = this.getRenderer() + if (!renderer) { + this.hideTooltip() + return + } + + const rect = this.canvas.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + const gridPos = renderer.screenToGrid(x, y) + const cellInfo = renderer.getCellInfo(gridPos.row, gridPos.col) + + if (!cellInfo) { + this.hideTooltip() + return + } + + const warpColor = renderer.getWarpColor(cellInfo.warpId) + const tooltipContent = ` +
Position: (${cellInfo.position[0]}, ${cellInfo.position[1]})
+
+ Warp: ${cellInfo.warpId} + +
+
Thread: ${cellInfo.threadId}
+
Register: ${cellInfo.registerId}
+ ` + + this.tooltip.show(tooltipContent, event.clientX, event.clientY) + } + + protected resetHover(): void { + this.hideTooltip() + } + + private buildFormMarkup(): string { + return ` +
+

Input Dimensions

+
+ +
+ +
+

Output Dimensions

+
+ +

At most two output dimensions are allowed.

+
+ + + ` + } + + private renderDimensionRows( + type: DimensionType, + options: { showErrors?: boolean } = { showErrors: true } + ): void { + const list = this.dimensionLists[type] + list.innerHTML = '' + + const dimensions = this.dimensionState[type] + dimensions.forEach((dimension) => { + const row = document.createElement('div') + row.className = 'dimension-row' + row.dataset.dimensionId = dimension.id + + const nameLabel = document.createElement('label') + nameLabel.textContent = 'Name:' + const nameInput = document.createElement('input') + nameInput.type = 'text' + nameInput.value = dimension.name + nameInput.addEventListener('input', (event) => { + dimension.name = (event.target as HTMLInputElement).value + this.refreshValidation(true) + }) + nameLabel.appendChild(nameInput) + + const sizeLabel = document.createElement('label') + sizeLabel.textContent = 'Size:' + const sizeInput = document.createElement('input') + sizeInput.type = 'number' + sizeInput.min = '2' + sizeInput.value = dimension.size.toString() + sizeInput.addEventListener('input', (event) => { + const value = Number((event.target as HTMLInputElement).value) + dimension.size = Number.isFinite(value) ? value : 0 + this.refreshValidation(true) + }) + sizeLabel.appendChild(sizeInput) + + const removeButton = document.createElement('button') + removeButton.type = 'button' + removeButton.className = 'dimension-remove' + removeButton.textContent = '×' + removeButton.setAttribute('aria-label', 'Remove dimension') + removeButton.title = 'Remove dimension' + removeButton.disabled = dimensions.length <= 1 + removeButton.addEventListener('click', () => { + this.removeDimension(type, dimension.id) + }) + + const error = document.createElement('div') + error.className = 'dimension-error' + error.dataset.errorFor = dimension.id + + row.appendChild(nameLabel) + row.appendChild(sizeLabel) + row.appendChild(removeButton) + row.appendChild(error) + + list.appendChild(row) + }) + + this.updateAddButtonState(type) + this.refreshValidation(options.showErrors ?? true) + } + + private toggleSidebarInteractivity(isLocked: boolean): void { + this.sidebar.classList.toggle('matrix-editor-locked', isLocked) + if (isLocked) { + this.sidebar.setAttribute('inert', '') + } else { + this.sidebar.removeAttribute('inert') + } + } + + private refreshValidation(showErrors: boolean): void { + const inputsValid = this.dimensionState.input.every((dimension) => + this.validateDimension(dimension, showErrors) + ) + const outputsValid = this.dimensionState.output.every((dimension) => + this.validateDimension(dimension, showErrors) + ) + + const canEditMatrix = inputsValid && outputsValid && this.dimensionState.output.length > 0 + this.matrixButton.disabled = !canEditMatrix + } + + private validateDimension(dimension: LinearDimension, showErrors: boolean): boolean { + let errorMessage = '' + const trimmedName = dimension.name.trim() + if (!trimmedName) { + errorMessage = 'Name is required.' + } else if (!Number.isInteger(dimension.size)) { + errorMessage = 'Size must be an integer.' + } else if (dimension.size < 2) { + errorMessage = 'Size must be at least 2.' + } else if (!this.isPowerOfTwo(dimension.size)) { + errorMessage = 'Size must be a power of two.' + } + + if (showErrors) { + this.setRowError(dimension.id, errorMessage) + } else if (errorMessage === '') { + this.clearRowError(dimension.id) + } + + return errorMessage === '' + } + + private setRowError(dimensionId: string, message: string): void { + const errorElement = this.sidebar.querySelector( + `.dimension-error[data-error-for="${dimensionId}"]` + ) + if (!errorElement) { + return + } + errorElement.textContent = message + errorElement.classList.toggle('visible', Boolean(message)) + } + + private clearRowError(dimensionId: string): void { + const errorElement = this.sidebar.querySelector( + `.dimension-error[data-error-for="${dimensionId}"]` + ) + if (!errorElement) { + return + } + errorElement.textContent = '' + errorElement.classList.remove('visible') + } + + private addDimension(type: DimensionType): void { + if (type === 'output' && this.dimensionState.output.length >= 2) { + return + } + + const newDimension: LinearDimension = { + id: this.createDimensionId(), + name: this.getDefaultName(type), + size: 2, + } + this.dimensionState[type] = [...this.dimensionState[type], newDimension] + this.renderDimensionRows(type, { showErrors: false }) + } + + private removeDimension(type: DimensionType, id: string): void { + if (this.dimensionState[type].length <= 1) { + return + } + this.dimensionState[type] = this.dimensionState[type].filter((dimension) => dimension.id !== id) + this.renderDimensionRows(type, { showErrors: true }) + } + + private updateAddButtonState(type: DimensionType): void { + if (type === 'output') { + this.dimensionAddButtons.output.disabled = this.dimensionState.output.length >= 2 + } + } + + private renderOperationsInfo(controlsContainer: HTMLElement): void { + const infoBlock = document.createElement('div') + infoBlock.className = 'linear-operations' + infoBlock.innerHTML = ` +
+ Addition: XOR (⊕)
+ 0 ⊕ 0 = 0
+ 0 ⊕ 1 = 1
+ 1 ⊕ 0 = 1
+ 1 ⊕ 1 = 0 +
+
+ Multiplication: AND (×)
+ 0 × 0 = 0
+ 0 × 1 = 0
+ 1 × 0 = 0
+ 1 × 1 = 1 +
+ ` + + this.sidebar.insertBefore(infoBlock, controlsContainer) + } + + private getDefaultName(type: DimensionType): string { + const prefix = type === 'input' ? 'input' : 'output' + return `${prefix}${this.dimensionState[type].length + 1}` + } + + private createDimensionId(): string { + return `dim-${Math.random().toString(36).slice(2, 10)}` + } + + private isPowerOfTwo(value: number): boolean { + return value > 0 && (value & (value - 1)) === 0 + } + + private handleMatrixEditorClick(): void { + this.matrixEditor.open(this.getMatrixDimensions()) + } + + private getMatrixDimensions(): MatrixEditorDimensions { + return { + input: this.dimensionState.input.map((dimension) => ({ ...dimension })), + output: this.dimensionState.output.map((dimension) => ({ ...dimension })), + } + } +} diff --git a/src/ui/LinearLayoutMatrixEditor.ts b/src/ui/LinearLayoutMatrixEditor.ts new file mode 100644 index 0000000..b29c966 --- /dev/null +++ b/src/ui/LinearLayoutMatrixEditor.ts @@ -0,0 +1,1041 @@ +interface MatrixEditorDimension { + id: string + name: string + size: number +} + +export interface MatrixEditorDimensions { + input: MatrixEditorDimension[] + output: MatrixEditorDimension[] +} + +interface BitDescriptor { + dimensionId: string + dimensionName: string + bitIndex: number +} + +interface DialogSize { + width: number + height: number +} + +type ForwardableMouseEventType = + | 'mousemove' + | 'mouseleave' + | 'mousedown' + | 'mouseup' + | 'click' + | 'dblclick' + | 'contextmenu' + +/** + * Floating, draggable, and resizable modal that renders the binary matrix used + * by the linear layout editor. The class owns its own DOM tree so it can be + * reused without polluting the tab markup. + */ +export class LinearLayoutMatrixEditor { + private readonly overlay: HTMLDivElement + private readonly dialog: HTMLDivElement + private readonly header: HTMLDivElement + private readonly body: HTMLDivElement + private readonly closeButton: HTMLButtonElement + private readonly resizeHandle: HTMLDivElement + private readonly columnHeaders: HTMLDivElement + private readonly columnTrack: HTMLDivElement + private readonly rowHeaders: HTMLDivElement + private readonly rowTrack: HTMLDivElement + private readonly gridScroll: HTMLDivElement + private readonly grid: HTMLDivElement + private readonly gridTable: HTMLDivElement + private readonly emptyState: HTMLDivElement + + private readonly minSize: DialogSize = { width: 500, height: 400 } + private readonly defaultSize: DialogSize = { width: 800, height: 600 } + private effectiveMinSize: DialogSize = { width: 500, height: 400 } + private readonly maxCellSize = 48 + private readonly minCellSize = 32 + private pendingAutoFitCellSize: number | null = null + + private matrixValues: number[][] = [] + private rowBits: BitDescriptor[] = [] + private columnBits: BitDescriptor[] = [] + private signature: string | null = null + private isOpen = false + private needsRender = true + private hasPosition = false + private currentDimensions: MatrixEditorDimensions = { input: [], output: [] } + + private dragOffset: { x: number; y: number } | null = null + private resizeState: { startX: number; startY: number; width: number; height: number } | null = null + + private resizeObserver: ResizeObserver | null = null + private pendingAnimation: number | null = null + private readonly keydownHandler: (event: KeyboardEvent) => void + private readonly visibilityListeners = new Set<(isOpen: boolean) => void>() + private readonly autoFitClass = 'matrix-auto-fit' + private forwardedHoverTarget: HTMLCanvasElement | null = null + + constructor() { + this.overlay = document.createElement('div') + this.overlay.className = 'matrix-editor-overlay' + this.overlay.style.display = 'none' + + this.dialog = document.createElement('div') + this.dialog.className = 'matrix-editor-dialog' + this.dialog.tabIndex = -1 + this.dialog.setAttribute('role', 'dialog') + + this.header = document.createElement('div') + this.header.className = 'matrix-editor-header' + + const title = document.createElement('h3') + title.className = 'matrix-editor-title' + title.textContent = 'Edit Matrix' + + const actionGroup = document.createElement('div') + actionGroup.className = 'matrix-editor-actions' + + this.closeButton = document.createElement('button') + this.closeButton.type = 'button' + this.closeButton.className = 'matrix-editor-close' + this.closeButton.setAttribute('aria-label', 'Close matrix editor') + this.closeButton.textContent = 'X' + + actionGroup.appendChild(this.closeButton) + this.header.appendChild(title) + this.header.appendChild(actionGroup) + + this.body = document.createElement('div') + this.body.className = 'matrix-editor-body' + + const gridTable = document.createElement('div') + gridTable.className = 'matrix-grid-table' + this.gridTable = gridTable + + const corner = document.createElement('div') + corner.className = 'matrix-label-corner' + corner.innerHTML = `OutputsInputs` + + this.columnHeaders = document.createElement('div') + this.columnHeaders.className = 'matrix-column-headers' + this.columnTrack = document.createElement('div') + this.columnTrack.className = 'matrix-column-track' + this.columnHeaders.appendChild(this.columnTrack) + + this.rowHeaders = document.createElement('div') + this.rowHeaders.className = 'matrix-row-headers' + this.rowTrack = document.createElement('div') + this.rowTrack.className = 'matrix-row-track' + this.rowHeaders.appendChild(this.rowTrack) + + this.gridScroll = document.createElement('div') + this.gridScroll.className = 'matrix-grid-scroll' + this.grid = document.createElement('div') + this.grid.className = 'matrix-grid' + this.gridScroll.appendChild(this.grid) + + gridTable.appendChild(corner) + gridTable.appendChild(this.columnHeaders) + gridTable.appendChild(this.rowHeaders) + gridTable.appendChild(this.gridScroll) + + this.emptyState = document.createElement('div') + this.emptyState.className = 'matrix-empty-state' + this.emptyState.textContent = 'Configure at least one input and one output dimension to edit the matrix.' + + this.body.appendChild(gridTable) + this.body.appendChild(this.emptyState) + + this.resizeHandle = document.createElement('div') + this.resizeHandle.className = 'matrix-resize-handle' + + this.dialog.appendChild(this.header) + this.dialog.appendChild(this.body) + this.dialog.appendChild(this.resizeHandle) + this.overlay.appendChild(this.dialog) + document.body.appendChild(this.overlay) + + this.overlay.addEventListener('mousemove', (event) => { + if (this.isOpen) { + this.handleOverlayMouseMove(event) + } + }) + + ;(['mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu'] as const).forEach((eventType) => { + this.overlay.addEventListener(eventType, (event) => { + if (this.isOpen) { + this.handleOverlayMouseEvent(event as MouseEvent) + } + }) + }) + + this.overlay.addEventListener( + 'wheel', + (event) => { + if (this.isOpen) { + this.handleOverlayWheelEvent(event) + } + }, + { passive: false } + ) + + this.overlay.addEventListener('mouseleave', (event) => { + if (this.isOpen) { + this.releaseForwardedHover(event) + } + }) + + this.closeButton.addEventListener('click', () => this.close()) + + this.header.addEventListener('mousedown', (event) => { + if (event.button !== 0) { + return + } + if ((event.target as HTMLElement).closest('.matrix-editor-actions')) { + return + } + this.startDrag(event) + }) + + this.resizeHandle.addEventListener('mousedown', (event) => { + event.preventDefault() + if (event.button !== 0) { + return + } + this.startResize(event) + }) + + this.gridScroll.addEventListener('scroll', () => { + this.columnTrack.style.transform = `translateX(-${this.gridScroll.scrollLeft}px)` + this.rowTrack.style.transform = `translateY(-${this.gridScroll.scrollTop}px)` + }) + + document.addEventListener('mousemove', (event) => { + if (this.dragOffset) { + this.handleDrag(event) + } else if (this.resizeState) { + this.handleResize(event) + } + }) + + document.addEventListener('mouseup', () => { + this.dragOffset = null + this.resizeState = null + }) + + this.keydownHandler = (event: KeyboardEvent) => { + if (!this.isOpen) { + return + } + if (event.key === 'Escape') { + event.preventDefault() + this.close() + } + } + + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => { + this.scheduleCellSizeUpdate() + }) + this.resizeObserver.observe(this.dialog) + } + } + + /** + * Update the internal dimension snapshot without showing the modal. + */ + public updateDimensions(dimensions: MatrixEditorDimensions): void { + this.currentDimensions = { + input: dimensions.input.map((dim) => ({ ...dim })), + output: dimensions.output.map((dim) => ({ ...dim })), + } + const shapeChanged = this.rebuildMatrixIfNeeded() + if (shapeChanged) { + this.autoFitToMatrix() + } + if (this.isOpen) { + this.renderMatrix() + } else { + this.needsRender = true + } + } + + /** + * Open the modal. Dimensions are synced before rendering to ensure the grid + * reflects the latest sidebar values. + */ + public open(dimensions: MatrixEditorDimensions): void { + const wasOpen = this.isOpen + this.updateDimensions(dimensions) + + // Always recalculate optimal size when opening + if (!this.hasPosition) { + this.initializeDialogFrame() + } else { + // Recalculate size based on current matrix, but don't recenter + this.autoFitToMatrix({ recenter: false }) + } + + this.overlay.style.display = 'block' + this.overlay.classList.add('visible') + this.isOpen = true + if (!wasOpen) { + window.addEventListener('keydown', this.keydownHandler) + } + this.ensureMatrixRendered() + this.clampDialogWithinViewport() + this.dialog.focus() + if (!wasOpen) { + this.notifyVisibilityChange(true) + } + } + + /** + * Close the modal and hide the overlay. + */ + public close(): void { + if (!this.isOpen) { + return + } + this.releaseForwardedHover() + this.overlay.classList.remove('visible') + this.overlay.style.display = 'none' + this.isOpen = false + window.removeEventListener('keydown', this.keydownHandler) + this.notifyVisibilityChange(false) + } + + /** + * Return a copy of the current matrix values for future integrations. + */ + public getMatrix(): number[][] { + return this.matrixValues.map((row) => [...row]) + } + + private ensureMatrixRendered(): void { + if (!this.needsRender) { + this.scheduleCellSizeUpdate() + return + } + this.renderMatrix() + this.needsRender = false + // Ensure scrollbars appear correctly after initial render + this.scheduleCellSizeUpdate() + } + + private rebuildMatrixIfNeeded(): boolean { + const rowBits = this.buildBitDescriptors(this.currentDimensions.output) + const columnBits = this.buildBitDescriptors(this.currentDimensions.input) + const signature = this.computeSignature(this.currentDimensions) + const shapeChanged = + rowBits.length !== this.matrixValues.length || + (this.matrixValues[0]?.length ?? 0) !== columnBits.length + + if (signature !== this.signature || shapeChanged) { + this.matrixValues = Array.from({ length: rowBits.length }, () => + Array.from({ length: columnBits.length }, () => 0) + ) + this.signature = signature + } + + this.rowBits = rowBits + this.columnBits = columnBits + return shapeChanged + } + + private renderMatrix(): void { + this.columnTrack.innerHTML = '' + this.rowTrack.innerHTML = '' + this.grid.innerHTML = '' + this.gridScroll.scrollTop = 0 + this.gridScroll.scrollLeft = 0 + this.columnTrack.style.transform = 'translateX(0)' + this.rowTrack.style.transform = 'translateY(0)' + + if (this.rowBits.length === 0 || this.columnBits.length === 0) { + this.emptyState.classList.add('visible') + this.gridTable.classList.add('hidden') + return + } + + this.emptyState.classList.remove('visible') + this.gridTable.classList.remove('hidden') + + this.columnTrack.style.gridTemplateColumns = `repeat(${this.columnBits.length}, var(--matrix-cell-size-effective, 48px))` + this.rowTrack.style.gridTemplateRows = `repeat(${this.rowBits.length}, var(--matrix-cell-size-effective, 48px))` + this.grid.style.gridTemplateColumns = `repeat(${this.columnBits.length}, var(--matrix-cell-size-effective, 48px))` + this.grid.style.gridTemplateRows = `repeat(${this.rowBits.length}, var(--matrix-cell-size-effective, 48px))` + + this.columnBits.forEach((descriptor) => { + const cell = document.createElement('div') + cell.className = 'matrix-header-cell matrix-column-cell' + const nameSpan = document.createElement('span') + nameSpan.className = 'matrix-header-name' + nameSpan.textContent = descriptor.dimensionName + const bitSpan = document.createElement('span') + bitSpan.className = 'matrix-header-bit' + bitSpan.textContent = `bit ${descriptor.bitIndex}` + cell.append(nameSpan, bitSpan) + this.columnTrack.appendChild(cell) + }) + + this.rowBits.forEach((descriptor) => { + const cell = document.createElement('div') + cell.className = 'matrix-header-cell matrix-row-cell' + const nameSpan = document.createElement('span') + nameSpan.className = 'matrix-header-name' + nameSpan.textContent = descriptor.dimensionName + const bitSpan = document.createElement('span') + bitSpan.className = 'matrix-header-bit' + bitSpan.textContent = `bit ${descriptor.bitIndex}` + cell.append(nameSpan, bitSpan) + this.rowTrack.appendChild(cell) + }) + + this.matrixValues.forEach((row, rowIdx) => { + row.forEach((value, colIdx) => { + const button = document.createElement('button') + button.type = 'button' + button.className = 'matrix-cell' + button.textContent = value.toString() + button.setAttribute('aria-pressed', value === 1 ? 'true' : 'false') + if (value === 1) { + button.classList.add('active') + } + button.addEventListener('click', () => { + this.toggleCell(rowIdx, colIdx, button) + }) + this.grid.appendChild(button) + }) + }) + + this.scheduleCellSizeUpdate() + } + + private toggleCell(row: number, col: number, button: HTMLButtonElement): void { + const current = this.matrixValues[row]?.[col] ?? 0 + const next = current === 1 ? 0 : 1 + if (!this.matrixValues[row]) { + return + } + this.matrixValues[row][col] = next + button.textContent = next.toString() + button.setAttribute('aria-pressed', next === 1 ? 'true' : 'false') + button.classList.toggle('active', next === 1) + } + + private handleOverlayMouseMove(event: MouseEvent): void { + if (event.target !== this.overlay) { + this.releaseForwardedHover(event) + return + } + event.preventDefault() + event.stopPropagation() + if (!this.forwardMouseEventToCanvas('mousemove', event)) { + this.releaseForwardedHover(event) + } + } + + private handleOverlayMouseEvent(event: MouseEvent): void { + if (event.target !== this.overlay) { + return + } + event.preventDefault() + event.stopPropagation() + const eventType = event.type as ForwardableMouseEventType + if (!this.forwardMouseEventToCanvas(eventType, event)) { + this.releaseForwardedHover(event) + } + } + + private handleOverlayWheelEvent(event: WheelEvent): void { + if (event.target !== this.overlay) { + return + } + event.preventDefault() + event.stopPropagation() + if (!this.forwardWheelEventToCanvas(event)) { + this.releaseForwardedHover(event) + } + } + + private forwardMouseEventToCanvas( + type: ForwardableMouseEventType, + event: MouseEvent + ): boolean { + const canvas = this.findCanvasUnderPointer(event.clientX, event.clientY) + if (!canvas) { + return false + } + this.dispatchSyntheticMouseEvent(type, event, canvas) + if (type === 'mousemove') { + this.forwardedHoverTarget = canvas + } + return true + } + + private forwardWheelEventToCanvas(event: WheelEvent): boolean { + const canvas = this.findCanvasUnderPointer(event.clientX, event.clientY) + if (!canvas) { + return false + } + this.dispatchSyntheticWheelEvent(event, canvas) + return true + } + + private findCanvasUnderPointer(clientX: number, clientY: number): HTMLCanvasElement | null { + const previousPointerEvents = this.overlay.style.pointerEvents + this.overlay.style.pointerEvents = 'none' + let underlying: Element | null = null + try { + underlying = document.elementFromPoint(clientX, clientY) + } finally { + this.overlay.style.pointerEvents = previousPointerEvents + } + return underlying instanceof HTMLCanvasElement ? underlying : null + } + + private releaseForwardedHover(event?: MouseEvent): void { + if (!this.forwardedHoverTarget) { + return + } + this.dispatchSyntheticMouseEvent('mouseleave', event ?? null, this.forwardedHoverTarget) + this.forwardedHoverTarget = null + } + + private dispatchSyntheticMouseEvent( + type: ForwardableMouseEventType, + sourceEvent: MouseEvent | null, + target: HTMLCanvasElement + ): void { + if (typeof window === 'undefined') { + return + } + const eventInit = this.buildMouseEventInit(type, sourceEvent, target) + target.dispatchEvent(new MouseEvent(type, eventInit)) + } + + private dispatchSyntheticWheelEvent( + sourceEvent: WheelEvent | null, + target: HTMLCanvasElement + ): void { + if (typeof window === 'undefined') { + return + } + const eventInit: WheelEventInit = sourceEvent + ? { + bubbles: true, + cancelable: sourceEvent.cancelable, + clientX: sourceEvent.clientX, + clientY: sourceEvent.clientY, + screenX: sourceEvent.screenX, + screenY: sourceEvent.screenY, + deltaX: sourceEvent.deltaX, + deltaY: sourceEvent.deltaY, + deltaZ: sourceEvent.deltaZ, + deltaMode: sourceEvent.deltaMode, + ctrlKey: sourceEvent.ctrlKey, + altKey: sourceEvent.altKey, + metaKey: sourceEvent.metaKey, + shiftKey: sourceEvent.shiftKey, + view: sourceEvent.view ?? window, + } + : { + bubbles: true, + cancelable: true, + clientX: target.getBoundingClientRect().left, + clientY: target.getBoundingClientRect().top, + deltaX: 0, + deltaY: 0, + deltaZ: 0, + deltaMode: 0, + view: window, + } + target.dispatchEvent(new WheelEvent('wheel', eventInit)) + } + + private buildMouseEventInit( + type: ForwardableMouseEventType, + sourceEvent: MouseEvent | null, + target: HTMLElement + ): MouseEventInit { + if (sourceEvent) { + return { + bubbles: type !== 'mouseleave', + cancelable: sourceEvent.cancelable, + clientX: sourceEvent.clientX, + clientY: sourceEvent.clientY, + screenX: sourceEvent.screenX, + screenY: sourceEvent.screenY, + button: sourceEvent.button, + buttons: sourceEvent.buttons, + altKey: sourceEvent.altKey, + ctrlKey: sourceEvent.ctrlKey, + metaKey: sourceEvent.metaKey, + shiftKey: sourceEvent.shiftKey, + relatedTarget: type === 'mouseleave' ? this.overlay : sourceEvent.relatedTarget, + view: sourceEvent.view ?? window, + detail: sourceEvent.detail, + } + } + + const rect = target.getBoundingClientRect() + return { + bubbles: type !== 'mouseleave', + cancelable: type !== 'mouseleave', + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + relatedTarget: type === 'mouseleave' ? this.overlay : null, + button: 0, + buttons: 0, + view: window, + } + } + + private buildBitDescriptors(dimensions: MatrixEditorDimension[]): BitDescriptor[] { + const descriptors: BitDescriptor[] = [] + dimensions.forEach((dimension) => { + const bitCount = Math.round(Math.log2(dimension.size)) + for (let bit = 0; bit < bitCount; bit++) { + descriptors.push({ + dimensionId: dimension.id, + dimensionName: dimension.name, + bitIndex: bit, + }) + } + }) + return descriptors + } + + private computeSignature(dimensions: MatrixEditorDimensions): string { + // Preserve existing matrix values when only dimension names change. + const serialize = (list: MatrixEditorDimension[]): string => + list.map((dim) => `${dim.id}:${dim.size}`).join('|') + return `${serialize(dimensions.input)}->${serialize(dimensions.output)}` + } + + private initializeDialogFrame(): void { + this.autoFitToMatrix({ recenter: true }) + } + + private computeOptimalDialogSize(): DialogSize { + const rows = this.rowBits.length + const cols = this.columnBits.length + + if (rows === 0 || cols === 0) { + return this.defaultSize + } + + const rowHeaderWidth = this.readNumericStyle( + this.gridTable, + '--matrix-row-header-width', + 110 + ) + const columnHeaderHeight = this.readNumericStyle( + this.gridTable, + '--matrix-column-header-height', + 70 + ) + const gridHorizontalPadding = + this.readNumericStyle(this.grid, 'padding-left', 4) + + this.readNumericStyle(this.grid, 'padding-right', 4) + const gridVerticalPadding = + this.readNumericStyle(this.grid, 'padding-top', 4) + + this.readNumericStyle(this.grid, 'padding-bottom', 4) + const columnGap = this.readNumericStyle( + this.grid, + 'column-gap', + this.readNumericStyle(this.grid, 'gap', 2) + ) + const rowGap = this.readNumericStyle( + this.grid, + 'row-gap', + this.readNumericStyle(this.grid, 'gap', 2) + ) + const bodyHorizontalPadding = + this.readNumericStyle(this.body, 'padding-left', 8) + + this.readNumericStyle(this.body, 'padding-right', 8) + const bodyVerticalPadding = + this.readNumericStyle(this.body, 'padding-top', 8) + + this.readNumericStyle(this.body, 'padding-bottom', 8) + const dialogBorderWidth = + this.readNumericStyle(this.dialog, 'border-left-width', 1) + + this.readNumericStyle(this.dialog, 'border-right-width', 1) + const dialogBorderHeight = + this.readNumericStyle(this.dialog, 'border-top-width', 1) + + this.readNumericStyle(this.dialog, 'border-bottom-width', 1) + const gridBorderWidth = + this.readNumericStyle(this.gridTable, 'border-left-width', 1) + + this.readNumericStyle(this.gridTable, 'border-right-width', 1) + const gridBorderHeight = + this.readNumericStyle(this.gridTable, 'border-top-width', 1) + + this.readNumericStyle(this.gridTable, 'border-bottom-width', 1) + const bodyGap = this.readNumericStyle(this.body, 'row-gap', 6) + const headerHeight = + this.header.offsetHeight || + this.readNumericStyle(this.header, 'height', 52) || + 52 + + const viewportWidthLimit = + typeof window !== 'undefined' + ? Math.min(Math.max(window.innerWidth - 32, 320), 1400) + : this.defaultSize.width + const viewportHeightLimit = + typeof window !== 'undefined' + ? Math.min(Math.max(window.innerHeight - 32, 320), 1000) + : this.defaultSize.height + + const chromeWidth = + bodyHorizontalPadding + dialogBorderWidth + gridBorderWidth + rowHeaderWidth + const chromeHeight = + bodyVerticalPadding + + dialogBorderHeight + + gridBorderHeight + + bodyGap + + headerHeight + + columnHeaderHeight + + const columnsGapWidth = Math.max(cols - 1, 0) * columnGap + const rowsGapHeight = Math.max(rows - 1, 0) * rowGap + + const widthSpaceForCells = + viewportWidthLimit - chromeWidth - gridHorizontalPadding - columnsGapWidth + const heightSpaceForCells = + viewportHeightLimit - chromeHeight - gridVerticalPadding - rowsGapHeight + + const widthLimitedCellSize = widthSpaceForCells / cols + const heightLimitedCellSize = heightSpaceForCells / rows + const targetCellSize = this.clampCellSize( + Math.min(widthLimitedCellSize, heightLimitedCellSize, this.maxCellSize) + ) + this.pendingAutoFitCellSize = targetCellSize + + const gridWidth = + cols * targetCellSize + columnsGapWidth + gridHorizontalPadding + const gridHeight = + rows * targetCellSize + rowsGapHeight + gridVerticalPadding + + const matrixWidth = rowHeaderWidth + gridWidth + const matrixHeight = columnHeaderHeight + gridHeight + + const idealWidth = + matrixWidth + bodyHorizontalPadding + dialogBorderWidth + gridBorderWidth + + const idealHeight = + matrixHeight + + bodyVerticalPadding + + dialogBorderHeight + + gridBorderHeight + + headerHeight + + bodyGap + + return { + width: Math.min(idealWidth, viewportWidthLimit), + height: Math.min(idealHeight, viewportHeightLimit), + } + } + + private autoFitToMatrix(options: { recenter?: boolean } = {}): void { + const size = this.computeOptimalDialogSize() + this.applyDialogSize(size) + this.enableAutoFitMode() + if (this.pendingAutoFitCellSize !== null) { + this.applyCellSize(this.pendingAutoFitCellSize) + this.pendingAutoFitCellSize = null + } + + if (!this.hasPosition || options.recenter) { + this.positionDialogAtCenter(size) + } else if (this.isOpen) { + this.clampDialogWithinViewport() + } + + // Use double rAF to ensure proper layout and scrollbar calculation + if (this.isOpen) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.scheduleCellSizeUpdate() + }) + }) + } else { + this.scheduleCellSizeUpdate() + } + } + + private enableAutoFitMode(): void { + this.dialog.classList.add(this.autoFitClass) + } + + private disableAutoFitMode(): void { + this.dialog.classList.remove(this.autoFitClass) + } + + private applyDialogSize(size: DialogSize): void { + this.dialog.style.width = `${size.width}px` + this.dialog.style.height = `${size.height}px` + this.effectiveMinSize = { + width: Math.min(size.width, this.minSize.width), + height: Math.min(size.height, this.minSize.height), + } + } + + private positionDialogAtCenter(size: DialogSize): void { + const left = Math.max((window.innerWidth - size.width) / 2, 16) + const top = Math.max((window.innerHeight - size.height) / 2, 16) + this.dialog.style.left = `${left}px` + this.dialog.style.top = `${top}px` + this.hasPosition = true + } + + private clampDialogWithinViewport(): void { + if (!this.hasPosition) { + return + } + const maxLeft = Math.max(window.innerWidth - this.dialog.offsetWidth, 0) + const maxTop = Math.max(window.innerHeight - this.dialog.offsetHeight, 0) + const clampedLeft = Math.min(Math.max(this.getDialogLeft(), 0), maxLeft) + const clampedTop = Math.min(Math.max(this.getDialogTop(), 0), maxTop) + this.dialog.style.left = `${clampedLeft}px` + this.dialog.style.top = `${clampedTop}px` + } + + private startDrag(event: MouseEvent): void { + event.preventDefault() + const rect = this.dialog.getBoundingClientRect() + this.dragOffset = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + } + } + + private handleDrag(event: MouseEvent): void { + if (!this.dragOffset) { + return + } + const newLeft = event.clientX - this.dragOffset.x + const newTop = event.clientY - this.dragOffset.y + const clampedLeft = Math.min( + Math.max(newLeft, 0), + Math.max(window.innerWidth - this.dialog.offsetWidth, 0) + ) + const clampedTop = Math.min( + Math.max(newTop, 0), + Math.max(window.innerHeight - this.dialog.offsetHeight, 0) + ) + this.dialog.style.left = `${clampedLeft}px` + this.dialog.style.top = `${clampedTop}px` + } + + private startResize(event: MouseEvent): void { + this.disableAutoFitMode() + this.resizeState = { + startX: event.clientX, + startY: event.clientY, + width: this.dialog.offsetWidth, + height: this.dialog.offsetHeight, + } + } + + private handleResize(event: MouseEvent): void { + if (!this.resizeState) { + return + } + const deltaX = event.clientX - this.resizeState.startX + const deltaY = event.clientY - this.resizeState.startY + let width = this.resizeState.width + deltaX + let height = this.resizeState.height + deltaY + const currentLeft = this.getDialogLeft() + const currentTop = this.getDialogTop() + + const maxWidth = window.innerWidth - currentLeft - 16 + const maxHeight = window.innerHeight - currentTop - 16 + + const minWidth = this.effectiveMinSize.width + const minHeight = this.effectiveMinSize.height + const clampedMaxWidth = Math.max(maxWidth, minWidth) + const clampedMaxHeight = Math.max(maxHeight, minHeight) + + width = Math.min(Math.max(width, minWidth), clampedMaxWidth) + height = Math.min(Math.max(height, minHeight), clampedMaxHeight) + + this.dialog.style.width = `${width}px` + this.dialog.style.height = `${height}px` + this.scheduleCellSizeUpdate() + } + + private scheduleCellSizeUpdate(): void { + if (!this.isOpen) { + return + } + const raf = window.requestAnimationFrame?.bind(window) + if (!raf) { + this.updateCellSize() + return + } + if (this.pendingAnimation) { + window.cancelAnimationFrame?.(this.pendingAnimation) + } + this.pendingAnimation = raf(() => { + this.pendingAnimation = null + this.updateCellSize() + // Force a second layout pass to ensure scrollbars appear correctly + raf(() => { + this.forceScrollbarUpdate() + }) + }) + } + + private updateCellSize(): void { + if (!this.isOpen || this.rowBits.length === 0 || this.columnBits.length === 0) { + return + } + + // Force layout recalculation before measuring + void this.gridScroll.offsetHeight + + const columns = this.columnBits.length + const rows = this.rowBits.length + const gridHorizontalPadding = + this.readNumericStyle(this.grid, 'padding-left', 0) + + this.readNumericStyle(this.grid, 'padding-right', 0) + const gridVerticalPadding = + this.readNumericStyle(this.grid, 'padding-top', 0) + + this.readNumericStyle(this.grid, 'padding-bottom', 0) + const columnGap = this.readNumericStyle( + this.grid, + 'column-gap', + this.readNumericStyle(this.grid, 'gap', 2) + ) + const rowGap = this.readNumericStyle( + this.grid, + 'row-gap', + this.readNumericStyle(this.grid, 'gap', 2) + ) + + const widthSpaceForCells = + this.gridScroll.clientWidth - + gridHorizontalPadding - + Math.max(columns - 1, 0) * columnGap + const heightSpaceForCells = + this.gridScroll.clientHeight - + gridVerticalPadding - + Math.max(rows - 1, 0) * rowGap + + const widthPerCell = widthSpaceForCells / columns + const heightPerCell = heightSpaceForCells / rows + + const cellSize = this.clampCellSize(Math.min(widthPerCell, heightPerCell)) + this.applyCellSize(cellSize) + } + + private clampCellSize(value: number): number { + if (!Number.isFinite(value)) { + return this.minCellSize + } + return Math.max(this.minCellSize, Math.min(this.maxCellSize, value)) + } + + private applyCellSize(cellSize: number): void { + this.dialog.style.setProperty('--matrix-cell-size', `${cellSize}px`) + + const columns = this.columnBits.length + const rows = this.rowBits.length + + if (columns > 0) { + const columnTrackPadding = + this.readNumericStyle(this.columnTrack, 'padding-left', 0) + + this.readNumericStyle(this.columnTrack, 'padding-right', 0) + const columnTrackGap = this.readNumericStyle( + this.columnTrack, + 'column-gap', + this.readNumericStyle(this.columnTrack, 'gap', 2) + ) + const headerWidth = + columns * cellSize + + Math.max(columns - 1, 0) * columnTrackGap + + columnTrackPadding + this.columnTrack.style.width = `${headerWidth}px` + } + + if (rows > 0) { + const rowTrackPadding = + this.readNumericStyle(this.rowTrack, 'padding-top', 0) + + this.readNumericStyle(this.rowTrack, 'padding-bottom', 0) + const rowTrackGap = this.readNumericStyle( + this.rowTrack, + 'row-gap', + this.readNumericStyle(this.rowTrack, 'gap', 2) + ) + const headerHeight = + rows * cellSize + + Math.max(rows - 1, 0) * rowTrackGap + + rowTrackPadding + this.rowTrack.style.height = `${headerHeight}px` + } + } + + private readNumericStyle(element: HTMLElement, property: string, fallback = 0): number { + if (typeof window === 'undefined' || !element) { + return fallback + } + const styles = window.getComputedStyle(element) + const rawValue = styles.getPropertyValue(property) + const value = parseFloat(rawValue) + return Number.isFinite(value) ? value : fallback + } + + private getDialogLeft(): number { + const value = parseFloat(this.dialog.style.left || '0') + return Number.isFinite(value) ? value : 0 + } + + private getDialogTop(): number { + const value = parseFloat(this.dialog.style.top || '0') + return Number.isFinite(value) ? value : 0 + } + + public onVisibilityChange(listener: (isOpen: boolean) => void): () => void { + this.visibilityListeners.add(listener) + return () => { + this.visibilityListeners.delete(listener) + } + } + + private forceScrollbarUpdate(): void { + if (!this.isOpen || this.rowBits.length === 0 || this.columnBits.length === 0) { + return + } + // Force the browser to recalculate scrollbar visibility + const scrollLeft = this.gridScroll.scrollLeft + const scrollTop = this.gridScroll.scrollTop + this.gridScroll.style.overflowX = 'hidden' + this.gridScroll.style.overflowY = 'hidden' + void this.gridScroll.offsetHeight + this.gridScroll.style.overflowX = 'auto' + this.gridScroll.style.overflowY = 'auto' + void this.gridScroll.offsetHeight + + const epsilon = 0.5 + const horizontalOverflow = + this.grid.scrollWidth - this.gridScroll.clientWidth > epsilon + const verticalOverflow = + this.grid.scrollHeight - this.gridScroll.clientHeight > epsilon + + this.gridScroll.style.overflowX = horizontalOverflow ? 'auto' : 'hidden' + this.gridScroll.style.overflowY = verticalOverflow ? 'auto' : 'hidden' + + this.gridScroll.scrollLeft = horizontalOverflow ? scrollLeft : 0 + this.gridScroll.scrollTop = verticalOverflow ? scrollTop : 0 + } + + private notifyVisibilityChange(isOpen: boolean): void { + this.visibilityListeners.forEach((listener) => { + try { + listener(isOpen) + } catch { + // Ignore listener errors to prevent UI lock-ups + } + }) + } +}