diff --git a/public/demo/nav.js b/public/demo/nav.js index e239af0..40c0b63 100644 --- a/public/demo/nav.js +++ b/public/demo/nav.js @@ -27,7 +27,8 @@ { path: 'find-in-table/index.html', label: '12. Find in table' }, { path: 'matrix/index.html', label: '13. Matrix fixture' }, { path: 'copy-as-csv/index.html', label: '14. Copy as CSV' }, - { path: 'twin-table/index.html', label: '15. Twin table (investigation)' } + { path: 'twin-table/index.html', label: '15. Twin table (investigation)' }, + { path: 'sliders/merged-headers.html', label: '16. Merged headers' } ]; var STYLE_ID = 'gs-demo-nav-styles'; diff --git a/public/demo/sliders/merged-headers.html b/public/demo/sliders/merged-headers.html new file mode 100644 index 0000000..0ff3130 --- /dev/null +++ b/public/demo/sliders/merged-headers.html @@ -0,0 +1,104 @@ + + + + + + Grid-Sight · Slider · Merged / banner headers + + + + + +

Slider · Merged / banner headers

+

+ Speed-vs-length matrix tables often carry a merged “Length Overall (m)” + banner above the numeric column headers. Grid-Sight peels off the banner and + binds interpolation to the leaf header row, so both author + permutations below offer the sliders. Click the GS toggle, + open the + on the top-left corner cell, choose + Sliders, then drag either axis to interpolate — the + bracketing four cells highlight live. +

+ +

Permutation 1 — full-width colspan banner

+

+ A single merged cell spans the whole header; the leaf row still carries the + Speed Knots corner alongside the length headers. +

+
+ +

Permutation 2 — rowspan corner + colspan banner

+

+ Speed Knots spans both header rows, so the leaf row is + all length headers (no corner cell) with its cells shifted right + under the rowspan. This is the case that previously blocked interpolation. +

+
+ + + + + + diff --git a/public/index.html b/public/index.html index e9eb98c..ee18945 100644 --- a/public/index.html +++ b/public/index.html @@ -223,6 +223,7 @@

Read between the rows

@@ -399,6 +400,10 @@

Alternate calc models

Persistent URL

Slider positions encoded in the URL fragment — bookmark, share, or reload at a value.

+ +

Merged headers

+

Interpolation on a speed-vs-length matrix with a merged banner header — colspan banner and rowspan corner.

+

Live toggles — all on

Pre-ticked enrichment panel: untick any feature and its lozenges vanish within a frame.

diff --git a/src/core/__tests__/table-grid.test.ts b/src/core/__tests__/table-grid.test.ts index abef3bf..95e5936 100644 --- a/src/core/__tests__/table-grid.test.ts +++ b/src/core/__tests__/table-grid.test.ts @@ -15,6 +15,7 @@ import { isVirtualColumn, gridRows, headerRow, + headerRows, bodyRows, gridCells, sourceCells, @@ -23,6 +24,7 @@ import { cellAt, columnCells, headerCellFor, + dataHeaderCells, logicalColIndexOf, logicalRowIndexOf, cellValue, @@ -124,6 +126,83 @@ describe('row access', () => { }); }); +/* ── Merged / banner headers ────────────────────────────────────────── */ + +describe('merged banner headers', () => { + it('picks the leaf header row past a full-width banner (permutation 1)', () => { + const table = document.createElement('table'); + table.innerHTML = ` + + Length Overall (m) + Speed Knots102030 + + + 10123 + 20456 + + `; + document.body.appendChild(table); + expect(headerRow(table)!.cells[0].textContent).toBe('Speed Knots'); + expect(headerRows(table)).toHaveLength(2); + expect(bodyRows(table)).toHaveLength(2); + // Column headers drop the corner label, keep the numeric leaf headers. + expect(dataHeaderCells(table).map((c) => c.textContent)).toEqual([ + '10', + '20', + '30', + ]); + }); + + it('resolves a rowspan corner + banner: leaf cells are shifted right (permutation 2)', () => { + const table = document.createElement('table'); + table.innerHTML = ` + + Speed KnotsLength Overall (m) + 102030 + + + 10123 + 20456 + + `; + document.body.appendChild(table); + // Leaf row is the numeric header row; it has NO corner cell of its own. + expect(headerRow(table)!.cells[0].textContent).toBe('10'); + expect(headerRows(table)).toHaveLength(2); + // Occupancy-aware: none of the three leaf cells is dropped as a row label. + expect(dataHeaderCells(table).map((c) => c.textContent)).toEqual([ + '10', + '20', + '30', + ]); + }); + + it('leaves a plain single-row header unchanged (dataHeaderCells == slice(1))', () => { + const { table } = buildNumericGrid(); + const head = headerRow(table)!; + expect(headerRows(table)).toHaveLength(1); + expect(dataHeaderCells(table)).toEqual(sourceCells(head).slice(1)); + }); + + it('skips a no- leading banner row', () => { + const table = document.createElement('table'); + table.innerHTML = ` + Length Overall (m) + Speed Knots102030 + 10123 + 20456 + `; + document.body.appendChild(table); + expect(headerRow(table)!.cells[1].textContent).toBe('10'); + expect(bodyRows(table)).toHaveLength(2); + expect(dataHeaderCells(table).map((c) => c.textContent)).toEqual([ + '10', + '20', + '30', + ]); + }); +}); + /* ── Cells + counts ─────────────────────────────────────────────────── */ describe('cell views and counts', () => { diff --git a/src/core/table-grid.ts b/src/core/table-grid.ts index e848249..985b540 100644 --- a/src/core/table-grid.ts +++ b/src/core/table-grid.ts @@ -75,7 +75,27 @@ function footerRowSet(table: HTMLTableElement): Set { } /** - * Partition the non-scaffold rows into header + body. + * True when `row` is a merged "banner"/grouping super-header sitting above the + * real (leaf) column-header row: it has fewer logical cells than the row below + * it AND at least one of its cells spans multiple columns. The classic cases are + * a single `` title (e.g. "Length Overall (m)") drawn above the + * numeric column headers, and a corner label that spans down with `rowspan`. The + * colspan requirement guards against mistaking a genuinely short header/data row + * for a banner. `next` is the row immediately following, measured with the same + * source-cell rule so both counts are comparable. + */ +function isBannerRow( + row: HTMLTableRowElement, + next: HTMLTableRowElement, +): boolean { + const cells = sourceCells(row); + if (cells.length >= sourceCells(next).length) return false; + return cells.some((c) => Math.max(1, c.colSpan || 1) > 1); +} + +/** + * Resolve the header region into leading banner/grouping rows, the leaf + * column-header row, and the body rows. * * Mirrors the header-detection rule in * `src/utils/original-order.ts::getDataRows` (header is `table.rows[0]` iff it @@ -85,8 +105,15 @@ function footerRowSet(table: HTMLTableElement): Set { * a `` corner ahead of the real header, so running the heuristic over the * raw rows (as `getDataRows` does) would mistake that scaffold row for the * header. We mirror rather than reuse `getDataRows` for exactly this reason. + * + * Leading merged banner/grouping rows (a full-width `` title, or + * a `rowspan` corner + colspan banner above the numeric column headers) are + * peeled off so `header` is the LEAF column-header row that aligns 1:1 with the + * data columns. That is what lets sliders/interpolation bind to the numeric + * column headers rather than to the banner (see `isBannerRow`, `dataHeaderCells`). */ -function partitionRows(table: HTMLTableElement): { +function resolveHeader(table: HTMLTableElement): { + banners: HTMLTableRowElement[]; header: HTMLTableRowElement | null; body: HTMLTableRowElement[]; } { @@ -100,27 +127,138 @@ function partitionRows(table: HTMLTableElement): { : []; if (theadRows.length > 0) { - // Explicit head: header = first head row; body = all tbody data rows. - return { header: theadRows[0], body: tbodyRows }; + // Explicit head: the leaf header is the LAST head row; any earlier rows are + // banner/grouping rows. body = all tbody data rows. + const leaf = theadRows.length - 1; + return { + banners: theadRows.slice(0, leaf), + header: theadRows[leaf], + body: tbodyRows, + }; } - // No : the de-facto header is the first tbody row iff it has a . - if ( - tbodyRows.length > 0 && - Array.from(tbodyRows[0].cells).some((c) => c.tagName === 'TH') + // No : peel off leading merged banner rows, then the de-facto header is + // the first remaining row iff it has a . + let start = 0; + while ( + start + 1 < tbodyRows.length && + isBannerRow(tbodyRows[start], tbodyRows[start + 1]) ) { - return { header: tbodyRows[0], body: tbodyRows.slice(1) }; + start++; } - return { header: tbodyRows[0] ?? null, body: tbodyRows }; + const first = tbodyRows[start]; + if (first && Array.from(first.cells).some((c) => c.tagName === 'TH')) { + return { + banners: tbodyRows.slice(0, start), + header: first, + body: tbodyRows.slice(start + 1), + }; + } + return { banners: [], header: tbodyRows[0] ?? null, body: tbodyRows }; } -/** The header row (first non-scaffold row; reuses original-order header rule). */ +/** The (leaf) header row: the row whose cells align 1:1 with the data columns. + * Reuses the original-order header rule and peels off any merged banner rows. */ export function headerRow(table: HTMLTableElement): HTMLTableRowElement | null { - return partitionRows(table).header; + return resolveHeader(table).header; +} + +/** The full header block — leading banner/grouping rows followed by the leaf + * column-header row — top-to-bottom, non-scaffold. Single-row headers return a + * one-element array. */ +export function headerRows(table: HTMLTableElement): HTMLTableRowElement[] { + const { banners, header } = resolveHeader(table); + return header ? [...banners, header] : []; } /** Non-scaffold data rows after the header, excluding . Dimmed rows kept. */ export function bodyRows(table: HTMLTableElement): HTMLTableRowElement[] { - return partitionRows(table).body; + return resolveHeader(table).body; +} + +/** + * Occupancy-aware sweep of the header block (leading banner/grouping rows + the + * leaf column-header row), honouring both colspan and rowspan. Returns, for each + * logical SOURCE column, the header cell covering it AT the leaf row — which is + * the leaf cell itself, or a corner label that spans DOWN into the leaf row from + * a banner row (e.g. `Speed Knots`). Also reports which + * leaf-row cell (if any) originates each logical column, so the row-label + * column can be told apart from the data-column headers. + */ +function sweepHeaderColumns(table: HTMLTableElement): { + perColumn: HTMLTableCellElement[]; + leafOriginCols: Set; +} { + const rows = headerRows(table); + if (rows.length === 0) return { perColumn: [], leafOriginCols: new Set() }; + const leafIdx = rows.length - 1; + // grid[r][c] = the cell covering logical column c of header row r (from that + // row, or a rowspan spilling down from above). + const grid: (HTMLTableCellElement | undefined)[][] = rows.map(() => []); + const leafOriginCols = new Set(); + for (let r = 0; r < rows.length; r++) { + let c = 0; + for (const cell of sourceCells(rows[r])) { + while (grid[r][c]) c++; + const cs = Math.max(1, cell.colSpan || 1); + const rs = Math.max(1, cell.rowSpan || 1); + if (r === leafIdx) leafOriginCols.add(c); + for (let dr = 0; dr < rs && r + dr < rows.length; dr++) { + for (let dc = 0; dc < cs; dc++) grid[r + dr][c + dc] = cell; + } + c += cs; + } + } + const leaf = grid[leafIdx]; + const perColumn: HTMLTableCellElement[] = []; + for (let c = 0; c < leaf.length; c++) if (leaf[c]) perColumn.push(leaf[c]!); + return { perColumn, leafOriginCols }; +} + +/** + * The header cell for every logical SOURCE column, in order — the row-label + * corner first, then one per data column. Occupancy-aware over a multi-row + * (banner) header. For a plain single-row header this equals `sourceCells` + * expanded by colspan; callers that need the untouched single-row behaviour + * should gate on `headerRows(table).length > 1`. + */ +export function sourceHeaderColumns( + table: HTMLTableElement, +): HTMLTableCellElement[] { + return sweepHeaderColumns(table).perColumn; +} + +/** + * The leaf header cells that head the DATA columns — every source cell of the + * leaf header row except the one in the leading row-label column. + * + * Occupancy-aware over the whole header block (colspan + rowspan): a corner + * label that spans DOWN into the leaf row from a banner row (e.g. a + * `Speed Knots` beside a `` banner) shifts + * the leaf row's cells to the right, so its first physical cell is correctly + * recognised as a data-column header rather than dropped as the row label. For a + * plain single-row header this is exactly `sourceCells(header).slice(1)`. + */ +export function dataHeaderCells( + table: HTMLTableElement, +): HTMLTableCellElement[] { + const { perColumn, leafOriginCols } = sweepHeaderColumns(table); + // Logical column 0 is the row-label column. Keep the leaf-originated cells to + // its right (a corner that spans down from a banner row is not a leaf cell, so + // it is excluded automatically). + return perColumn.filter((_, col) => col >= 1 && leafOriginCols.has(col)); +} + +/** + * Author-text matrix aligned to logical SOURCE columns, for column-type + * detection / suitability on a table with a multi-row (banner) header. Row 0 is + * the leaf header aligned by logical column (occupancy-aware, so a rowspan + * corner keeps the numeric leaf headers in their true columns); the remaining + * rows are the body. Virtual columns are excluded. + */ +export function sourceColumnMatrix(table: HTMLTableElement): string[][] { + const header = sourceHeaderColumns(table).map(cellValue); + const body = bodyRows(table).map((row) => sourceCells(row).map(cellValue)); + return [header, ...body]; } /* ── Cells within a row ─────────────────────────────────────────────── */ diff --git a/src/core/type-detection.ts b/src/core/type-detection.ts index 9c2c803..bd2b466 100644 --- a/src/core/type-detection.ts +++ b/src/core/type-detection.ts @@ -1,4 +1,10 @@ -import { gridRows, sourceCells, cellValue } from './table-grid'; +import { + gridRows, + sourceCells, + cellValue, + headerRows, + sourceColumnMatrix, +} from './table-grid'; /** * Type definitions for column type detection @@ -227,6 +233,10 @@ export function analyzeTable( */ export function extractTableData(table: HTMLTableElement): string[][] { if (!table.rows) return []; + // A multi-row (banner) header must be flattened by logical column so a merged + // banner row / rowspan corner doesn't collapse the column count and hide the + // table's numeric columns from type detection. + if (headerRows(table).length > 1) return sourceColumnMatrix(table); // Read author source cells via the canonical addressing layer: scaffold rows // and virtual columns are excluded and injected UI is stripped, so column-type // detection sees only author data regardless of active enrichments (013). diff --git a/src/enrichments/__tests__/slider.test.ts b/src/enrichments/__tests__/slider.test.ts index b43ae15..773d59c 100644 --- a/src/enrichments/__tests__/slider.test.ts +++ b/src/enrichments/__tests__/slider.test.ts @@ -64,6 +64,95 @@ describe('buildAxisBinding', () => { document.body.appendChild(tbl); expect(buildAxisBinding(tbl, 'col')).toBeNull(); }); + + it('binds the column axis past a full-width merged banner row (permutation 1)', () => { + // A single merged banner cell drawn above the numeric column headers; the + // leaf header row still carries the "Speed Knots" corner + length headers. + const tbl = document.createElement('table'); + tbl.innerHTML = ` + + Length Overall (m) + Speed Knots102030 + + + 10123 + 20456 + 30789 + + `; + document.body.appendChild(tbl); + const col = buildAxisBinding(tbl, 'col'); + expect(col).not.toBeNull(); + expect(col!.headerValues).toEqual([10, 20, 30]); + const row = buildAxisBinding(tbl, 'row'); + expect(row).not.toBeNull(); + expect(row!.headerValues).toEqual([10, 20, 30]); + }); + + it('binds the column axis with a rowspan corner + banner row (permutation 2)', () => { + // "Speed Knots" spans both header rows; the leaf row is ALL length headers + // (no corner cell to drop) and its cells are shifted right by the rowspan. + const tbl = document.createElement('table'); + tbl.innerHTML = ` + + Speed KnotsLength Overall (m) + 102030 + + + 10123 + 20456 + 30789 + + `; + document.body.appendChild(tbl); + const col = buildAxisBinding(tbl, 'col'); + expect(col).not.toBeNull(); + expect(col!.headerValues).toEqual([10, 20, 30]); + const row = buildAxisBinding(tbl, 'row'); + expect(row).not.toBeNull(); + expect(row!.headerValues).toEqual([10, 20, 30]); + }); +}); + +describe('sliders on merged-header tables', () => { + function bannerTable(): HTMLTableElement { + const tbl = document.createElement('table'); + tbl.innerHTML = ` + + Speed KnotsLength Overall (m) + 102030 + + + 10123 + 20456 + 30789 + + `; + document.body.appendChild(tbl); + return tbl; + } + + it('creates both axis sliders and interpolates on a rowspan-corner table', () => { + const tbl = bannerTable(); + const rowCount = tbl.rows.length; + const rowSlider = addSlider(tbl, 'row'); + const colSlider = addSlider(tbl, 'col'); + expect(getSliders(tbl).length).toBe(2); + // Injection added a col-slider row and per-row/header slots. + expect(tbl.querySelectorAll('[data-gs-injected]').length).toBeGreaterThan(0); + rowSlider.setPosition(15); // between speeds 10 and 20 + colSlider.setPosition(25); // between lengths 20 and 30 + const readout = colSlider.handle.readoutInterpolated.textContent; + expect(readout).not.toBe('—'); + expect(readout).not.toBe(''); + removeAllSliders(tbl); + expect(getSliders(tbl).length).toBe(0); + // Injection tears down cleanly even though the row-slider header cell was + // inserted into the banner row spanning the whole header block: no injected + // scaffolding remains and the original row count is restored. + expect(tbl.querySelectorAll('[data-gs-injected]').length).toBe(0); + expect(tbl.rows.length).toBe(rowCount); + }); }); describe('addSlider', () => { diff --git a/src/enrichments/slider-injection.ts b/src/enrichments/slider-injection.ts index 7718936..da862f1 100644 --- a/src/enrichments/slider-injection.ts +++ b/src/enrichments/slider-injection.ts @@ -13,8 +13,9 @@ import type { EquationSnapshot } from './equation-panel'; import { gridRows, bodyRows, - headerRow as gridHeaderRow, + headerRows as gridHeaderRows, sourceCells, + dataHeaderCells, cellValue, } from '../core/table-grid'; @@ -68,9 +69,10 @@ function cellText(cell: HTMLTableCellElement): string { export function readRawAxisHeaders(table: HTMLTableElement, axis: Axis): string[] { if (axis === 'col') { - const header = gridHeaderRow(table); - if (!header) return []; - return sourceCells(header).slice(1).map(cellText); + // Occupancy-aware read of the leaf column headers: robust to a merged + // banner row above the numeric headers and to a rowspan corner label that + // shifts the leaf cells right (spec: merged-header interpolation). + return dataHeaderCells(table).map(cellText); } // axis === 'row' return bodyRows(table) @@ -237,10 +239,13 @@ export function ensureTopRow(ctx: TableContext): HTMLTableRowElement { return tr; } -function buildRowHeaderCell(): HTMLTableCellElement { +function buildRowHeaderCell(headerRowSpan: number): HTMLTableCellElement { const headerCell = document.createElement('th'); headerCell.setAttribute('data-gs-injected', ''); headerCell.setAttribute('data-gs-row-header', ''); + // Span the whole header block so the injected gutter column lines up with the + // body when the header has a merged banner row above the leaf headers. + headerCell.rowSpan = Math.max(1, headerRowSpan); headerCell.style.padding = '6px'; headerCell.style.verticalAlign = 'middle'; headerCell.style.textAlign = 'center'; @@ -262,10 +267,13 @@ function buildRowSliderCell(rowSpan: number): HTMLTableCellElement { export function ensureRowSliderSlot(ctx: TableContext): HTMLTableCellElement { if (ctx.rowSliderCell) return ctx.rowSliderCell; - const headerRow = gridHeaderRow(ctx.table); + const headerBlock = gridHeaderRows(ctx.table); + const headerRow = headerBlock[0]; if (!headerRow) throw new Error('No original header row found'); - const headerCell = buildRowHeaderCell(); + // Insert into the TOP header row and span the whole header block; for a plain + // single-row header this is the header row with rowSpan 1 (unchanged). + const headerCell = buildRowHeaderCell(headerBlock.length); headerRow.insertBefore(headerCell, headerRow.firstChild); const cell = buildRowSliderCell(ctx.dataRowCount); diff --git a/src/ui/__tests__/toggle-injector.banner-header.test.ts b/src/ui/__tests__/toggle-injector.banner-header.test.ts new file mode 100644 index 0000000..7190000 --- /dev/null +++ b/src/ui/__tests__/toggle-injector.banner-header.test.ts @@ -0,0 +1,96 @@ +/** + * Merged-header interpolation: a table with a banner/grouping header row (speed + * vs length matrix) must still be recognised as suitable and offer the slider + * (interpolation) enrichment through the real activation path. + * + * Two author permutations are covered: + * 1. A full-width `` banner above the numeric column headers; + * the leaf header row carries the "Speed Knots" corner + length headers. + * 2. A `Speed Knots` corner beside a `` + * banner; the leaf header row is ALL length headers (no corner cell), with + * its cells shifted right by the rowspan. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { injectToggle, activateToggle, deactivateToggle } from '../toggle-injector'; +import { setPageConfig, setVisitorOverride } from '../../core/enabled-set-state'; +import { removeAllSliders } from '../../enrichments/slider'; + +beforeEach(() => { + document.body.innerHTML = ''; + setVisitorOverride(undefined); + setPageConfig({ enrichments: undefined, showToggleUi: false, tables: [] }); +}); + +afterEach(() => { + const t = document.querySelector('table'); + if (t) removeAllSliders(t); +}); + +function bannerColspanTable(): HTMLTableElement { + const t = document.createElement('table'); + t.id = 'perm1'; + t.innerHTML = ` + + Length Overall (m) + Speed Knots102030 + + + 10123 + 20456 + 30789 + `; + document.body.appendChild(t); + return t; +} + +function rowspanCornerTable(): HTMLTableElement { + const t = document.createElement('table'); + t.id = 'perm2'; + t.innerHTML = ` + + Speed KnotsLength Overall (m) + 102030 + + + 10123 + 20456 + 30789 + `; + document.body.appendChild(t); + return t; +} + +function sliderLozenge(t: HTMLTableElement): HTMLElement | null { + return t.querySelector('[data-gs-lozenge-id="sliders"]'); +} + +describe('interpolation on merged-header tables', () => { + it('offers the slider lozenge for a full-width banner header (permutation 1)', () => { + const t = bannerColspanTable(); + injectToggle(t); + activateToggle(t); + const slider = sliderLozenge(t); + expect(slider).not.toBeNull(); + // Enabled (not the "no numeric axis" disabled state). + expect(slider!.getAttribute('aria-disabled')).not.toBe('true'); + }); + + it('offers the slider lozenge for a rowspan corner + banner header (permutation 2)', () => { + const t = rowspanCornerTable(); + injectToggle(t); + activateToggle(t); + const slider = sliderLozenge(t); + expect(slider).not.toBeNull(); + expect(slider!.getAttribute('aria-disabled')).not.toBe('true'); + }); + + it('activate → deactivate restores byte-identical markup (INV-4)', () => { + const t = rowspanCornerTable(); + injectToggle(t); + const baseline = t.innerHTML; + activateToggle(t); + deactivateToggle(t); + expect(t.innerHTML).toBe(baseline); + }); +}); diff --git a/src/ui/header-utils.ts b/src/ui/header-utils.ts index b566067..8121ab7 100644 --- a/src/ui/header-utils.ts +++ b/src/ui/header-utils.ts @@ -37,6 +37,8 @@ import { columnCells, cellValue, logicalColIndexOf, + headerRows, + sourceHeaderColumns, } from '../core/table-grid'; import { isTwinTable } from '../core/twin-grid'; import { @@ -65,9 +67,19 @@ export function injectPlusIcons(table: HTMLTableElement, columnTypes: ColumnType ensureRowVisibilityStyles(); ensureVirtualColumnStyles(); - const rows = gridRows(table); - const headerRow = rows[0]; - if (!headerRow) return; + // Multi-row (banner) header: place per-column lozenges on the leaf header + // cells resolved by logical column (occupancy-aware over colspan + rowspan), + // and row lozenges on the body rows only — the banner/grouping rows carry no + // per-column affordances. A plain single-row header keeps the original path + // (which also covers virtual columns and author colspan headers unchanged). + const multiRowHeader = headerRows(table).length > 1; + const headerCells = multiRowHeader + ? sourceHeaderColumns(table) + : (() => { + const h = gridRows(table)[0]; + return h ? gridCells(h) : []; + })(); + if (!headerCells.length) return; // Twin (grouped) tables (spec 016): the single-row-header addressing behind // the per-column / per-row passes mis-places lozenges on the label columns and @@ -76,12 +88,12 @@ export function injectPlusIcons(table: HTMLTableElement, columnTypes: ColumnType // which carries the twin-aware slider toggle; the mis-placed H/# are not // offered until those enrichments learn the group structure. if (isTwinTable(table)) { - const corner = gridCells(headerRow)[0]; + const corner = headerCells[0]; if (corner) addLozengesToHeader(table, corner, 'table', 0); return; } - gridCells(headerRow).forEach((cell, colIndex) => { + headerCells.forEach((cell, colIndex) => { const isTopLeftCell = colIndex === 0; const type = columnTypes[colIndex]; if (type === 'numeric' || type === 'categorical') { @@ -89,8 +101,11 @@ export function injectPlusIcons(table: HTMLTableElement, columnTypes: ColumnType } }); - for (let i = 1; i < rows.length; i++) { - const cells = gridCells(rows[i]); + const rowLozengeRows = multiRowHeader + ? bodyRows(table) + : gridRows(table).slice(1); + for (const row of rowLozengeRows) { + const cells = gridCells(row); if (!cells.length) continue; addLozengesToHeader(table, cells[0], 'row', 0); } diff --git a/src/ui/toggle-injector.ts b/src/ui/toggle-injector.ts index ebd62b4..bfd71c8 100644 --- a/src/ui/toggle-injector.ts +++ b/src/ui/toggle-injector.ts @@ -15,6 +15,8 @@ import { cellValue, logicalColIndexOf, logicalRowIndexOf, + headerRows, + sourceColumnMatrix, } from '../core/table-grid'; import { onVisibleRowsChange, visibleBodyRows } from '../utils/visible-rows'; import { StatisticsPopup } from './statistics-popup'; @@ -443,9 +445,14 @@ export function activateToggle(table: HTMLTableElement): void { // spec 014). Using raw textContent here would let an annotated numeric cell // ("1200" + note) read as non-numeric and suppress a column's lozenges // (spec 013: scaffold/UI is never the logical grid). - const rows = Array.from(table.rows) - .filter(row => !row.hasAttribute('data-gs-injected')) - .map(row => Array.from(row.cells).map(cell => cellValue(cell))); + // A multi-row (banner) header is flattened by logical column so a merged + // banner / rowspan corner doesn't collapse the detected column count; a plain + // header keeps the raw per-cell read (which also covers virtual columns). + const rows = headerRows(table).length > 1 + ? sourceColumnMatrix(table) + : Array.from(table.rows) + .filter(row => !row.hasAttribute('data-gs-injected')) + .map(row => Array.from(row.cells).map(cell => cellValue(cell))); const { columnTypes } = analyzeTable(rows); // Cache column types for the toggle-panel refresh path (spec 012 R-10). setColumnTypes(table, columnTypes);