diff --git a/index.html b/index.html
index a8361e1..2e83c08 100644
--- a/index.html
+++ b/index.html
@@ -232,7 +232,19 @@
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 `
+
+
+
+
+
+ `
+ }
+
+ 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
+ }
+ })
+ }
+}