+ Twin interpolation (spec 016). This is a twin
+ lookup table: Speed (rows) × Direction
+ (columns), with an extra merged Season column on the left.
+ Summer spans six speed rows (30–80 kts) and
+ Winter spans four (20–60 kts) — really
+ two stacked grids sharing the Direction axis.
+ Enable Grid-Sight and click the S lozenge in the top-left
+ corner to add the synced twin sliders: one shared
+ Direction slider plus a Speed slider in each
+ season block. The Speed sliders are synced by value; in the overlap (30–60)
+ both blocks interpolate and highlight, and when the shared speed leaves a
+ block's range that block's slider disables and its highlight clears. See
+ specs/016-twin-interpolation/investigation.md.
+
+
+
+
Range table (nm) — Speed × Direction, grouped by Season
+
+
+
Season
+
Speed
+
000°
045°
090°
135°
180°
+
+
+
+
Summer
30
18.2
17.4
15.9
14.1
13.0
+
40
16.8
16.0
14.6
12.9
11.8
+
50
15.1
14.4
13.1
11.5
10.5
+
60
13.3
12.7
11.5
10.0
9.1
+
70
11.4
10.9
9.8
8.5
7.7
+
80
9.6
9.1
8.2
7.0
6.3
+
Winter
20
15.0
14.3
13.0
11.4
10.4
+
30
13.6
12.9
11.7
10.2
9.2
+
40
12.0
11.4
10.3
8.9
8.0
+
60
8.7
8.2
7.3
6.2
5.5
+
+
+
+
+
+
+
+
diff --git a/specs/016-twin-interpolation/investigation.md b/specs/016-twin-interpolation/investigation.md
new file mode 100644
index 0000000..0d60e24
--- /dev/null
+++ b/specs/016-twin-interpolation/investigation.md
@@ -0,0 +1,257 @@
+# Investigation: Twin (grouped) interpolation tables
+
+**Branch**: `claude/twin-table-interpolation-rbbvxp`
+**Created**: 2026-07-03
+**Status**: **Implemented** (synced-blocks model, §3.2) — see the modules and
+tests below.
+**Trigger**: A real-world lookup table format spotted in the field — *Speed*
+(rows) × *Direction* (columns) with a merged **Season** column (`Summer` /
+`Winter`) grouping the speed rows into two independent blocks.
+
+> **Implemented in this branch.** Twin detection (`src/core/twin-grid.ts`),
+> per-block interpolation (`src/enrichments/twin-interp.ts`), and the synced
+> twin-slider controller (`src/enrichments/twin-slider.ts`), wired into the
+> lozenge/slider layer (`src/ui/header-utils.ts`). On a twin table the
+> mis-placed H/# lozenges are no longer offered and the corner shows an enabled
+> **S** toggle that adds a shared Direction slider plus a per-block Speed slider,
+> synced by value, with out-of-range blocks disabled and cleared. Covered by
+> unit tests (twin-grid, twin-interp, twin-slider) and an e2e spec
+> (`tests/e2e/twin-interpolation.spec.ts`). **Deferred:** making the *other*
+> enrichments (heatmap, statistics, sort/filter, outlier) group-aware — today
+> they are simply not offered on a twin table rather than mis-applied.
+
+---
+
+## 1. The format ("twin" table)
+
+The table is really **two stacked lookup grids that share one axis**. On the
+left are two label columns — a merged **Season** group column and the numeric
+**Speed** column — followed by the numeric **Direction** columns:
+
+| Season (merged) | Speed | 000° | 045° | 090° | 135° | 180° |
+| --------------- | ----- | ---- | ---- | ---- | ---- | ---- |
+| Summer (rowspan 6) | 30 | … | … | … | … | … |
+| ″ | 40 | … | … | … | … | … |
+| ″ | 50 | … | … | … | … | … |
+| ″ | 60 | … | … | … | … | … |
+| ″ | 70 | … | … | … | … | … |
+| ″ | 80 | … | … | … | … | … |
+| Winter (rowspan 4) | 20 | … | … | … | … | … |
+| ″ | 30 | … | … | … | … | … |
+| ″ | 40 | … | … | … | … | … |
+| ″ | 60 | … | … | … | … | … |
+
+Key structural facts:
+
+- **Summer** covers Speed 30–80 kts (6 rows); **Winter** covers 20–60 kts (4
+ rows). The two speed ranges **overlap** but the *data* is independent.
+- Both blocks share the same **Direction** column axis.
+- A cell is addressed by **(Season, Speed, Direction)** — three coordinates,
+ where Season is categorical and the other two are numeric.
+- Interpolation must stay **inside one season block** — you never interpolate
+ a value that blends Summer and Winter data.
+
+A faithful reproduction lives at
+[`public/demo/twin-table/index.html`](../../public/demo/twin-table/index.html)
+(demo #15, *Twin table (investigation)*).
+
+## 2. What happens today (reproduced, not assumed)
+
+Grid-Sight's slider binding reads exactly **one** row-header column (the first
+source cell of each body row) and treats every other body cell as data
+(`src/enrichments/slider-injection.ts` → `readRawAxisHeaders`,
+`readRawCellMatrix`). Run against the twin table it mis-reads every structural
+element. Observed output from the live addressing layer:
+
+```text
+ROW headers (axis=row): ["Summer","40","50","60","70","80","Winter","30","40","60"]
+COL headers (axis=col): ["Speed","000","045","090","135","180"]
+Cell matrix (ragged): [[30,…],[…],…,[20,…],[…],…] // leading rows carry 5 values, others 4
+buildAxisBinding(row): null
+buildAxisBinding(col): null
+```
+
+Three distinct failures, all rooted in the single-row-header assumption:
+
+1. **Row axis is poisoned by the group cell.** On a group-*leading* row the
+ first source cell is the merged `Season` `
` (`Summer` / `Winter`), so
+ `parseHeaderNumber` hits a non-number and the whole binding aborts. On
+ continuation rows the first cell is the Speed value — so the column offset
+ silently shifts by one between the two row kinds.
+2. **Column axis gains a phantom `Speed` header.** `readRawAxisHeaders('col')`
+ slices off only the *first* header cell (`Season`), leaving `Speed` sitting
+ in the Direction axis.
+3. **The cell matrix is ragged.** Group-leading rows keep an extra leading value
+ (the Speed number leaks into the data row), so rows are 5 or 4 wide
+ depending on whether they start a season.
+
+**Net effect: `buildAxisBinding` returns `null` for both axes → no slider is
+offered at all.** The table is silently un-enrichable — exactly the "we may not
+be able to support this" the format was flagged for. (This is also *safe*: it
+fails closed, it does not produce a wrong interpolated number.)
+
+### 2.1 A second symptom: enrichment lozenges land in the wrong cells
+
+The same single-row-header assumption lives in the lozenge injector
+(`src/ui/header-utils.ts` → `injectPlusIcons`), so the twin table also mounts the
+per-column / per-row H (heatmap) and # (statistics) lozenges on the wrong cells.
+Dumped from the live DOM after enabling Grid-Sight on the fixture:
+
+```text
+header row: Speed[H,#] 000°[H,#] 045°[H,#] … 180°[H,#]
+Summer block row1: "Summer"[H,#] 30 (—) 18.2 … // lozenges on the merged Season cell
+Summer block rowN: 40[H,#] 50[H,#] 60[H,#] 70[H,#] 80[H,#] // on the Speed values
+Winter block row1: "Winter"[H,#] 20 (—) 15.0 …
+Winter block rowN: 30[H,#] 40[H,#] 60[H,#]
+```
+
+Three placement bugs, all the same root cause:
+
+1. **`Speed` (a row-header *label* column) is mistaken for a data column.** The
+ injector treats every header cell after index 0 as a column, so the Speed
+ label header wrongly offers column enrichments (H/#).
+2. **On group-leading rows the row lozenges attach to the merged `Season` cell.**
+ The row pass always reads `gridCells(row)[0]`, which on a leading row is the
+ `Summer`/`Winter` rowspan cell, not the Speed value.
+3. **The first speed of each block (30, 20) gets no lozenge at all.** It sits in
+ `gridCells(row)[1]` of a group-leading row, which the row pass never inspects.
+
+Note the codebase already has *partial* rowspan awareness: `columnHasRowspanBodyCells`
+correctly **suppresses** sort / filter / outlier on the grouped column (which is
+why only H and # mis-mount, not the whole cluster). But that is a defensive
+guard, not structural understanding — it prevents some wrong lozenges without
+placing the right ones. **Twin support is therefore an addressing-layer concern,
+not a slider-only one:** the same `rowGroups` / group-column detection that fixes
+the binding also feeds the lozenge injector, heatmap, and sort/filter suppression.
+
+## 3. Can we offer "twin" interpolation? — Yes, and it is a small extension
+
+A proof-of-concept group-aware binding builder was prototyped against the same
+fixture. It partitions the body rows on the `rowspan` group cell, emits **one
+grid per season** sharing the Direction axis, and feeds each into the existing
+pure `bilinear()` primitive **unchanged**:
+
+```text
+GROUPS:
+ Summer rowHeaders=[30,40,50,60,70,80] colHeaders=[0,45,90,135,180] matrix 6×5 ✓ rectangular
+ Winter rowHeaders=[20,30,40,60] colHeaders=[0,45,90,135,180] matrix 4×5 ✓ rectangular
+bilinear(Summer, speed=35, dir=22.5) = 3.5 // bracketed strictly within the Summer block
+```
+
+So the hard parts — interpolation maths, highlight/readout, persistence, sync —
+are **already done and reusable**. The only genuinely new work is *structural*:
+recognising the group column and slicing the body into per-group sub-grids.
+
+### 3.1 Detecting a twin table
+
+A table is "twin" when a **leading group column** partitions the body rows.
+Robust, DOM-only signal (no new authoring markup required):
+
+- Some body row's **first source cell has `rowSpan > 1`** (the merged
+ `Season` cell). This is the primary detector and it is what the reproduction
+ uses.
+- The **row-header (Speed) column** is then the *first numeric* source column —
+ i.e. the source column immediately after the group column(s).
+- Generalises to *N* leading label columns and *N* groups; the format is not
+ limited to two seasons.
+
+An explicit opt-in/---out attribute (e.g. `data-gs-group-col="0"` or
+`data-gs-twin`) can back-stop the heuristic for tables where `rowspan` is used
+cosmetically rather than structurally.
+
+### 3.2 Interaction model — synced blocks (decided 2026-07-03)
+
+**Chosen model: treat the two blocks as *synced sub-tables*, no selector.** Each
+block gets its own Speed (row) slider; the two are **synced by value** — dragging
+Summer's Speed to 55 moves Winter's to 55 as well — exactly like the existing
+cross-table sync (`deriveSyncKey` / `broadcastSync`, demo #3). The Direction
+(col) axis is shared. Each block renders its own interpolated readout and its own
+four-cell highlight rectangle.
+
+The one behaviour beyond today's sync is **range divergence**: the blocks'
+Speed ranges differ (Summer 30–80, Winter 20–60).
+
+- In the **overlap (30–60)** both blocks are live: both interpolate, both show a
+ highlight rectangle, both sliders track the shared value.
+- When the shared Speed leaves a block's range — e.g. 70 (inside Summer, outside
+ Winter) — that block's Speed slider is **disabled** and its highlight rectangle
+ and interpolated readout are **cleared** (shown as `—`), while the in-range
+ block keeps interpolating. Symmetrically at the low end (e.g. 25 disables
+ Summer, keeps Winter).
+
+This differs from the existing `broadcastSync`, which *clamps* an out-of-range
+partner to its min/max; here an out-of-range partner must **disable + clear**,
+not clamp to a misleading edge value. That is the one new rule the twin
+controller adds on top of the reused sync/interpolation/highlight machinery.
+
+A `Summer | Winter` selector was considered and **rejected** — the synced-blocks
+model reads the way the physical sheet is used (both seasons visible at once) and
+reuses the sync pattern users already know from demo #3.
+
+### 3.3 Where the code changes land
+
+| Area | Change | Size |
+| ---- | ------ | ---- |
+| `src/core/table-grid.ts` | Add group-column detection + a `rowGroups(table)` reader (label + its body rows), reusing `sourceCells`/`cellValue`. Pure DOM read. | small |
+| `src/enrichments/slider-injection.ts` | Generalise `readRawAxisHeaders`/`readRawCellMatrix`/`buildAxisBinding` to take an optional row-group so the row axis skips the group column and the matrix is sliced to the group's rows. | small–medium |
+| `src/enrichments/twin-slider.ts` (NEW) | Self-contained twin controller: per-block Speed slider + shared Direction slider, value-sync across blocks, out-of-range disable + clear (§3.2). Reuses pure `bilinear` + highlight helpers; leaves the single-grid slider path untouched so normal tables can't regress. | medium |
+| `src/ui/header-utils.ts` | Branch the `sliders` descriptor to the twin controller when a twin table is detected; use the group-aware row-header column so H/# lozenges mount on the Speed value (not the merged `Season` cell), skip the label columns, and cover the first speed of each block (fixes §2.1). | small |
+| `src/utils/interpolation.ts` | **None** — `bilinear`/`linear1D` are reused verbatim. | none |
+| Persistence/sync (`slider-persistence`, `sync-key`) | Add the active-group id to the per-slider key so a bookmarked position restores to the right block. | small |
+
+No new runtime dependency; no network; the maths layer is untouched.
+
+## 4. Constitution check (feasibility-level)
+
+| Principle | Verdict | Note |
+| --------- | ------- | ---- |
+| I. Lightweight & minimal deps | ✅ | No new dependency; reuses `bilinear`. Est. bundle delta small (detection + selector); size gate is report-and-warn. |
+| II. Test discipline | ✅ | Pure `rowGroups`/binding builders are unit-testable in isolation; the POC already exercises the partition + bilinear path. Adds a Storybook + e2e on the reproduction fixture. |
+| III. Accessibility | ✅ | Season selector as a keyboard-operable `radiogroup`; colour never the sole channel. |
+| IV. Progressive enhancement | ✅ | Non-twin tables are unaffected (detector is opt-in by structure). A twin table with a non-numeric axis still simply offers no slider — **fails closed today, keeps failing closed** until the feature ships. |
+| V. Cross-browser | ✅ | `rowSpan`, attribute reads, DOM creation only. |
+| VI. Offline-first | ✅ | Pure in-memory reads; zero network. |
+
+## 5. Risks & open questions
+
+- **Detection false-positives.** `rowspan` is sometimes cosmetic. Mitigation:
+ require the group column to sit *before* a numeric row-header column, and offer
+ `data-gs-twin` / `data-gs-no-twin` as explicit overrides.
+- **Overlapping speed ranges.** Summer 30–80 and Winter 20–60 overlap; the
+ selector (design A) removes ambiguity because interpolation is always scoped to
+ the chosen block. Worth an explicit acceptance test.
+- **Ragged blocks / gaps.** Winter skips 50 kts (20, 30, 40, 60). `bilinear`
+ already brackets by header value, so uneven spacing is fine; a block with a
+ <2-row or <2-col axis simply offers no slider on that axis (existing rule).
+- **>2 groups and multi-level headers.** The design generalises to N groups; a
+ *second* group column (nested merges) is out of scope for a first cut.
+- **Heatmap/statistics interaction.** Those enrichments already read via the
+ addressing layer; group-awareness there (per-block heat scaling) is a separate,
+ optional follow-up, not a blocker.
+
+## 6. Recommendation
+
+**Feasible and worth doing.** The blocking work is a small, well-contained
+addressing-layer extension (group detection + per-group sub-grid slicing); the
+interpolation core is reused unchanged. Suggested next step: promote this to a
+full `spec.md` (via `/speckit-specify`) for design **A** (season selector +
+shared, re-ranging sliders), using
+[`public/demo/twin-table/index.html`](../../public/demo/twin-table/index.html)
+as the driving fixture. Until then, the current fail-closed behaviour is correct
+and safe — Grid-Sight offers no slider rather than a wrong number.
+
+## Appendix — how these findings were produced
+
+Both the failure and the proof-of-concept were run against the live jsdom
+addressing layer (not reasoned about on paper):
+
+- **Current behaviour**: constructed the twin DOM, called the real
+ `readRawAxisHeaders` / `readRawCellMatrix` / `buildAxisBinding`
+ (`src/enrichments/slider-injection.ts`) → both bindings `null`, ragged matrix,
+ poisoned row headers (§2).
+- **Twin POC**: a group-aware builder partitioned on the `rowspan` group cell →
+ two rectangular grids (Summer 6×5, Winter 4×5) sharing the Direction axis;
+ `bilinear(Summer, 35, 22.5) = 3.5`, strictly bracketed within Summer (§3).
+
+The probe was a throwaway `vitest` spec and is not committed; the reproduction
+fixture (demo #15) reproduces the same DOM for manual/e2e verification.
diff --git a/src/core/__tests__/twin-grid.test.ts b/src/core/__tests__/twin-grid.test.ts
new file mode 100644
index 0000000..ba5b01a
--- /dev/null
+++ b/src/core/__tests__/twin-grid.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { detectTwin, isTwinTable } from '../twin-grid';
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+});
+
+function twinTable(): HTMLTableElement {
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
+
Season
Speed
000°
045°
090°
135°
180°
+
+
+
Summer
30
18.2
17.4
15.9
14.1
13.0
+
40
16.8
16.0
14.6
12.9
11.8
+
50
15.1
14.4
13.1
11.5
10.5
+
60
13.3
12.7
11.5
10.0
9.1
+
70
11.4
10.9
9.8
8.5
7.7
+
80
9.6
9.1
8.2
7.0
6.3
+
Winter
20
15.0
14.3
13.0
11.4
10.4
+
30
13.6
12.9
11.7
10.2
9.2
+
40
12.0
11.4
10.3
8.9
8.0
+
60
8.7
8.2
7.3
6.2
5.5
+
+ `;
+ document.body.appendChild(tbl);
+ return tbl;
+}
+
+describe('detectTwin', () => {
+ it('models a twin table as two independent sub-grids sharing the column axis', () => {
+ const model = detectTwin(twinTable());
+ expect(model).not.toBeNull();
+ expect(model!.labelColumnCount).toBe(2);
+ expect(model!.colHeaders).toEqual([0, 45, 90, 135, 180]);
+ expect(model!.blocks.map((b) => b.label)).toEqual(['Summer', 'Winter']);
+
+ const [summer, winter] = model!.blocks;
+ expect(summer.rowHeaders).toEqual([30, 40, 50, 60, 70, 80]);
+ expect(winter.rowHeaders).toEqual([20, 30, 40, 60]);
+ expect(summer.matrix.length).toBe(6);
+ expect(winter.matrix.length).toBe(4);
+ expect(summer.matrix[0]).toEqual([18.2, 17.4, 15.9, 14.1, 13.0]);
+ expect(winter.matrix[3]).toEqual([8.7, 8.2, 7.3, 6.2, 5.5]);
+
+ // Every block matrix is rectangular: rowHeaders × colHeaders.
+ for (const b of model!.blocks) {
+ expect(b.matrix.length).toBe(b.rowHeaders.length);
+ expect(b.dataCells.length).toBe(b.rowHeaders.length);
+ for (const r of b.matrix) expect(r.length).toBe(model!.colHeaders.length);
+ }
+ });
+
+ it('links each block to its live DOM cells', () => {
+ const model = detectTwin(twinTable())!;
+ const summer = model.blocks[0];
+ expect(summer.groupCell.textContent?.trim()).toBe('Summer');
+ expect(summer.rows.length).toBe(6);
+ expect(summer.rowHeaderCells[0].textContent?.trim()).toBe('30');
+ expect(summer.dataCells[0][0].textContent?.trim()).toBe('18.2');
+ });
+
+ it('returns null for a plain single-grid table', () => {
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
10
20
30
+
1000
1
2
3
+
2000
4
5
6
+
3000
7
8
9
+ `;
+ document.body.appendChild(tbl);
+ expect(detectTwin(tbl)).toBeNull();
+ expect(isTwinTable(tbl)).toBe(false);
+ });
+
+ it('returns null when a single group covers the whole body', () => {
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
Season
Speed
000
045
+
+
Summer
30
1
2
+
40
3
4
+
50
5
6
+
60
7
8
+
+ `;
+ document.body.appendChild(tbl);
+ // Only one rowspan group, and the trailing row has no group → not a clean twin.
+ expect(detectTwin(tbl)).toBeNull();
+ });
+
+ it('honours the data-gs-no-twin opt-out', () => {
+ const tbl = twinTable();
+ tbl.setAttribute('data-gs-no-twin', '');
+ expect(detectTwin(tbl)).toBeNull();
+ });
+
+ it('returns null for a non-numeric row-header column', () => {
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
Season
Grade
000
045
+
+
Summer
lo
1
2
+
hi
3
4
+
Winter
lo
5
6
+
hi
7
8
+
+ `;
+ document.body.appendChild(tbl);
+ expect(detectTwin(tbl)).toBeNull();
+ });
+});
diff --git a/src/core/twin-grid.ts b/src/core/twin-grid.ts
new file mode 100644
index 0000000..a3f59c7
--- /dev/null
+++ b/src/core/twin-grid.ts
@@ -0,0 +1,149 @@
+/**
+ * Twin (grouped) table model — spec 016-twin-interpolation.
+ *
+ * A "twin" table stacks two or more independent lookup grids that share one
+ * (column) axis. On the left sit label columns — a merged **group** column
+ * (e.g. `Season`: Summer / Winter, authored with `rowspan`) followed by the
+ * numeric **row-header** column (e.g. `Speed`) — then the numeric data columns
+ * (e.g. `Direction`). A cell is addressed by (group, rowHeader, colHeader).
+ *
+ * The canonical addressing layer (`table-grid.ts`) assumes exactly one leading
+ * row-header column and no row grouping, so it mis-reads this shape (see
+ * specs/016-twin-interpolation/investigation.md §2). This module is a PURE,
+ * rowspan-aware read that partitions the body into one sub-grid per group. It
+ * never mutates the DOM.
+ */
+
+import { parseHeaderNumber } from '../utils/sync-key';
+import { isScaffold, isVirtualColumn, cellValue } from './table-grid';
+
+/** One season block: an independent rowHeader × colHeader sub-grid. */
+export interface TwinBlock {
+ /** The group label, e.g. "Summer". */
+ label: string;
+ /** The merged group `
` (rowspan cell) that opens this block. */
+ groupCell: HTMLTableCellElement;
+ /** Body rows belonging to this block, in DOM order. */
+ rows: HTMLTableRowElement[];
+ /** Numeric row-header (Speed) value per block row. */
+ rowHeaders: number[];
+ /** The row-header `
`/`
` per block row (for tagging / highlight). */
+ rowHeaderCells: HTMLTableCellElement[];
+ /** rowHeaders.length × colHeaders.length numeric data matrix. */
+ matrix: number[][];
+ /** rowHeaders.length × colHeaders.length data cells (for tagging / highlight). */
+ dataCells: HTMLTableCellElement[][];
+}
+
+export interface TwinModel {
+ /** Shared numeric column-axis (Direction) values. */
+ colHeaders: number[];
+ /** Number of leading label columns (group + row-header ⇒ 2 in the base case). */
+ labelColumnCount: number;
+ blocks: TwinBlock[];
+}
+
+/** Non-scaffold, non-virtual cells of a row, in DOM order. */
+function realCells(row: HTMLTableRowElement): HTMLTableCellElement[] {
+ return Array.from(row.cells).filter((c) => !isScaffold(c) && !isVirtualColumn(c));
+}
+
+function headerAndBody(table: HTMLTableElement): {
+ header: HTMLTableRowElement | null;
+ body: HTMLTableRowElement[];
+} {
+ const thead = table.tHead;
+ const theadRows = thead ? Array.from(thead.rows).filter((r) => !isScaffold(r)) : [];
+ const tbody = table.tBodies[0];
+ const tbodyRows = tbody ? Array.from(tbody.rows).filter((r) => !isScaffold(r)) : [];
+ if (theadRows.length > 0) return { header: theadRows[0], body: tbodyRows };
+ if (
+ tbodyRows.length > 0 &&
+ Array.from(tbodyRows[0].cells).some((c) => c.tagName === 'TH')
+ ) {
+ return { header: tbodyRows[0], body: tbodyRows.slice(1) };
+ }
+ return { header: tbodyRows[0] ?? null, body: tbodyRows };
+}
+
+/**
+ * Detect and model a twin table, or return null if `table` is not one.
+ *
+ * Recognised shape (the base case the reproduction uses):
+ * - a header row of ≥ 4 source cells;
+ * - a group column at logical index 0 whose body cells carry `rowspan > 1`;
+ * - a numeric row-header column at index 1;
+ * - ≥ 2 numeric data columns after that, shared by every block;
+ * - ≥ 2 groups, each a contiguous run of ≥ 2 body rows.
+ *
+ * Anything more exotic (nested groups, non-numeric row headers, a single group)
+ * returns null so callers fall back to the existing fail-closed behaviour.
+ */
+export function detectTwin(table: HTMLTableElement): TwinModel | null {
+ if (table.hasAttribute('data-gs-ignore') || table.hasAttribute('data-gs-no-twin')) {
+ return null;
+ }
+ const { header, body } = headerAndBody(table);
+ if (!header || body.length < 4) return null;
+
+ const headerCells = realCells(header);
+ if (headerCells.length < 4) return null;
+
+ // Base case: exactly two label columns (group + row-header).
+ const labelColumnCount = 2;
+ const colHeaders = headerCells.slice(labelColumnCount).map((c) => parseHeaderNumber(cellValue(c)));
+ if (colHeaders.length < 2 || colHeaders.some((n) => n === null)) return null;
+ const colHeaderNums = colHeaders as number[];
+
+ // Walk the body, opening a new block whenever a row leads with a rowspan cell.
+ const blocks: TwinBlock[] = [];
+ let current: TwinBlock | null = null;
+ let sawGroupCell = false;
+
+ for (const row of body) {
+ const cells = realCells(row);
+ if (cells.length === 0) return null;
+
+ const leadsWithGroup = (cells[0].rowSpan ?? 1) > 1;
+ let idx = 0;
+ if (leadsWithGroup) {
+ sawGroupCell = true;
+ current = {
+ label: cellValue(cells[0]),
+ groupCell: cells[0],
+ rows: [],
+ rowHeaders: [],
+ rowHeaderCells: [],
+ matrix: [],
+ dataCells: [],
+ };
+ blocks.push(current);
+ idx = 1;
+ }
+ if (!current) return null; // body started without a group cell — not twin
+
+ const rowHeaderCell = cells[idx];
+ const speed = parseHeaderNumber(cellValue(rowHeaderCell));
+ if (speed === null) return null;
+
+ const dataCells = cells.slice(idx + 1);
+ if (dataCells.length !== colHeaderNums.length) return null; // ragged ⇒ not this shape
+ const dataNums = dataCells.map((c) => parseHeaderNumber(cellValue(c)) ?? NaN);
+
+ current.rows.push(row);
+ current.rowHeaders.push(speed);
+ current.rowHeaderCells.push(rowHeaderCell);
+ current.matrix.push(dataNums);
+ current.dataCells.push(dataCells);
+ }
+
+ if (!sawGroupCell || blocks.length < 2) return null;
+ if (blocks.some((b) => b.rowHeaders.length < 2)) return null;
+
+ return { colHeaders: colHeaderNums, labelColumnCount, blocks };
+}
+
+/** True if `table` is a twin table (cheap-ish; builds the model and discards). */
+export function isTwinTable(table: HTMLTableElement): boolean {
+ return detectTwin(table) !== null;
+}
diff --git a/src/enrichments/__tests__/twin-interp.test.ts b/src/enrichments/__tests__/twin-interp.test.ts
new file mode 100644
index 0000000..2fa5cd2
--- /dev/null
+++ b/src/enrichments/__tests__/twin-interp.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from 'vitest';
+import { evalBlock, headerRange } from '../twin-interp';
+
+const SUMMER_ROWS = [30, 40, 50, 60, 70, 80];
+const WINTER_ROWS = [20, 30, 40, 60];
+const COLS = [0, 45, 90, 135, 180];
+// Simple ascending matrices so interpolation is easy to reason about.
+const summer = SUMMER_ROWS.map((_, i) => COLS.map((_, j) => i * 10 + j));
+const winter = WINTER_ROWS.map((_, i) => COLS.map((_, j) => 100 + i * 10 + j));
+
+describe('evalBlock', () => {
+ it('interpolates within range', () => {
+ // Speed 35 (between rows 30/40 ⇒ i=0), Dir 22.5 (between cols 0/45 ⇒ j=0).
+ const e = evalBlock(SUMMER_ROWS, COLS, summer, 35, 22.5);
+ expect(e.inRange).toBe(true);
+ expect(e.rowBracket).toEqual([0, 1]);
+ expect(e.colBracket).toEqual([0, 1]);
+ // corners 0,1,10,11 → bilinear at midpoints = 5.5
+ expect(e.value).toBeCloseTo(5.5, 6);
+ });
+
+ it('is out of range above the block max', () => {
+ // 70 is inside Summer (30–80) but outside Winter (20–60).
+ const e = evalBlock(WINTER_ROWS, COLS, winter, 70, 45);
+ expect(e.inRange).toBe(false);
+ expect(Number.isNaN(e.value)).toBe(true);
+ expect(e.rowBracket).toBeNull();
+ });
+
+ it('is out of range below the block min', () => {
+ // 25 is inside Winter (20–60) but outside Summer (30–80).
+ const e = evalBlock(SUMMER_ROWS, COLS, summer, 25, 45);
+ expect(e.inRange).toBe(false);
+ expect(e.rowBracket).toBeNull();
+ });
+
+ it('both blocks are in range within the overlap (30–60)', () => {
+ const s = evalBlock(SUMMER_ROWS, COLS, summer, 45, 45);
+ const w = evalBlock(WINTER_ROWS, COLS, winter, 45, 45);
+ expect(s.inRange).toBe(true);
+ expect(w.inRange).toBe(true);
+ });
+
+ it('exact header hit interpolates to the literal cell', () => {
+ const e = evalBlock(SUMMER_ROWS, COLS, summer, 50, 90); // row i=2, col j=2 → value 22
+ expect(e.inRange).toBe(true);
+ expect(e.value).toBeCloseTo(22, 6);
+ });
+});
+
+describe('headerRange', () => {
+ it('returns inclusive min/max regardless of order', () => {
+ expect(headerRange([30, 40, 80, 50])).toEqual([30, 80]);
+ expect(headerRange([60, 20, 40, 30])).toEqual([20, 60]);
+ });
+});
diff --git a/src/enrichments/__tests__/twin-slider.test.ts b/src/enrichments/__tests__/twin-slider.test.ts
new file mode 100644
index 0000000..bd5398f
--- /dev/null
+++ b/src/enrichments/__tests__/twin-slider.test.ts
@@ -0,0 +1,151 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ enableTwinSliders,
+ disableTwinSliders,
+ isTwinSlidersActive,
+} from '../twin-slider';
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+});
+
+function twinTable(): HTMLTableElement {
+ const tbl = document.createElement('table');
+ tbl.innerHTML = `
+
+
Season
Speed
000°
045°
090°
135°
180°
+
+
+
Summer
30
18.2
17.4
15.9
14.1
13.0
+
40
16.8
16.0
14.6
12.9
11.8
+
50
15.1
14.4
13.1
11.5
10.5
+
60
13.3
12.7
11.5
10.0
9.1
+
70
11.4
10.9
9.8
8.5
7.7
+
80
9.6
9.1
8.2
7.0
6.3
+
Winter
20
15.0
14.3
13.0
11.4
10.4
+
30
13.6
12.9
11.7
10.2
9.2
+
40
12.0
11.4
10.3
8.9
8.0
+
60
8.7
8.2
7.3
6.2
5.5
+
+ `;
+ document.body.appendChild(tbl);
+ return tbl;
+}
+
+function speedInputs(tbl: HTMLTableElement): HTMLInputElement[] {
+ return Array.from(tbl.querySelectorAll('input[data-gs-twin-input="speed"]'));
+}
+function setValue(input: HTMLInputElement, v: number): void {
+ input.value = String(v);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+describe('twin-slider controller', () => {
+ it('injects one shared direction slider and one speed slider per block', () => {
+ const tbl = twinTable();
+ expect(enableTwinSliders(tbl)).toBe(true);
+ expect(isTwinSlidersActive(tbl)).toBe(true);
+ expect(tbl.querySelectorAll('input[data-gs-twin-input="dir"]').length).toBe(1);
+ expect(speedInputs(tbl).length).toBe(2);
+ // Speed sliders carry each block's own range.
+ const [summer, winter] = speedInputs(tbl);
+ expect([summer.min, summer.max]).toEqual(['30', '80']);
+ expect([winter.min, winter.max]).toEqual(['20', '60']);
+ });
+
+ it('initialises inside the overlap so both blocks are live', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ const [summer, winter] = speedInputs(tbl);
+ expect(summer.disabled).toBe(false);
+ expect(winter.disabled).toBe(false);
+ // Both readouts show a value, not the em-dash.
+ const readouts = tbl.querySelectorAll('.gs-twin-readout');
+ for (const r of readouts) expect(r.textContent).not.toBe('—');
+ // Four highlighted cells per live block.
+ expect(tbl.querySelectorAll('.gs-slider-highlight').length).toBe(8);
+ });
+
+ it('syncs the shared speed across blocks within the overlap', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ const [summer, winter] = speedInputs(tbl);
+ setValue(summer, 45);
+ expect(winter.value).toBe('45'); // synced by value
+ expect(summer.disabled).toBe(false);
+ expect(winter.disabled).toBe(false);
+ });
+
+ it('shows the shared speed and direction in the corner readout', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ const [summer] = speedInputs(tbl);
+ setValue(summer, 50);
+ const corner = tbl.querySelector('tr[data-gs-twin-row] th:first-child .gs-twin-dir-label')!;
+ expect(corner.textContent).toContain('Speed 50');
+ expect(corner.textContent).toContain('Direction');
+ });
+
+ it('disables the out-of-range block and clears its marker/readout', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ const [summer, winter] = speedInputs(tbl);
+ setValue(summer, 70); // inside Summer, outside Winter (20–60)
+ expect(summer.disabled).toBe(false);
+ expect(winter.disabled).toBe(true);
+
+ const winterReadout = winter.closest('[data-gs-twin-block-ui]')!.querySelector('.gs-twin-readout')!;
+ expect(winterReadout.textContent).toBe('—');
+ // Winter's block cells carry no highlight; Summer still has its 4.
+ const winterRow = tbl.querySelectorAll('tbody tr')[9]; // last Winter row (60)
+ expect(winterRow.querySelectorAll('.gs-slider-highlight').length).toBe(0);
+ expect(tbl.querySelectorAll('.gs-slider-highlight').length).toBe(4);
+ });
+
+ it('shows an interpolated-position marker per live block, hidden out of range', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ const markers = () => Array.from(document.querySelectorAll('[data-gs-marker]'));
+ // One marker per block; both visible inside the overlap.
+ expect(markers().length).toBe(2);
+ expect(markers().every((m) => m.style.display !== 'none')).toBe(true);
+
+ // Push out of Winter's range → Summer marker stays, Winter marker hides.
+ const [summer] = speedInputs(tbl);
+ setValue(summer, 70);
+ const [mSummer, mWinter] = markers();
+ expect(mSummer.style.display).not.toBe('none');
+ expect(mWinter.style.display).toBe('none');
+ });
+
+ it('removes the markers on teardown', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ expect(document.querySelectorAll('[data-gs-marker]').length).toBe(2);
+ disableTwinSliders(tbl);
+ expect(document.querySelectorAll('[data-gs-marker]').length).toBe(0);
+ });
+
+ it('re-enables the block when the speed returns to its range', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ const [summer, winter] = speedInputs(tbl);
+ setValue(summer, 75);
+ expect(winter.disabled).toBe(true);
+ setValue(summer, 50);
+ expect(winter.disabled).toBe(false);
+ expect(winter.value).toBe('50');
+ });
+
+ it('tears down cleanly, restoring the group cell and removing highlights', () => {
+ const tbl = twinTable();
+ enableTwinSliders(tbl);
+ disableTwinSliders(tbl);
+ expect(isTwinSlidersActive(tbl)).toBe(false);
+ expect(tbl.querySelectorAll('[data-gs-twin-input]').length).toBe(0);
+ expect(tbl.querySelectorAll('[data-gs-twin-row]').length).toBe(0);
+ expect(tbl.querySelectorAll('.gs-slider-highlight').length).toBe(0);
+ // Group label text survives.
+ expect(tbl.querySelector('th[rowspan="6"]')!.textContent).toContain('Summer');
+ });
+});
diff --git a/src/enrichments/twin-interp.ts b/src/enrichments/twin-interp.ts
new file mode 100644
index 0000000..c943f88
--- /dev/null
+++ b/src/enrichments/twin-interp.ts
@@ -0,0 +1,56 @@
+/**
+ * Pure per-block interpolation for twin tables (spec 016). No DOM.
+ *
+ * A twin block is interpolated exactly like a normal grid via the shared
+ * `bilinear` primitive — the only twin-specific rule is *range divergence*: when
+ * the shared Speed falls outside this block's row-header range (or the Direction
+ * outside the shared column range), the block is "out of range" and must be
+ * cleared (no value, no highlight), NOT clamped to an edge cell.
+ */
+
+import { bilinear } from '../utils/interpolation';
+import { locateSpan } from '../utils/segment';
+
+export interface BlockEval {
+ /** True iff both speed and direction bracket a cell pair inside this block. */
+ inRange: boolean;
+ /** Interpolated value, or NaN when out of range / a bracketing cell is non-finite. */
+ value: number;
+ /** [i, i+1] row indices bracketing the speed, or null when out of range. */
+ rowBracket: [number, number] | null;
+ /** [j, j+1] column indices bracketing the direction, or null when out of range. */
+ colBracket: [number, number] | null;
+}
+
+function bracket(headers: number[], x: number): [number, number] | null {
+ const i = locateSpan(headers, x);
+ return i < 0 ? null : [i, i + 1];
+}
+
+/** Evaluate one twin block at (speed, dir). Out-of-range ⇒ inRange:false, value:NaN. */
+export function evalBlock(
+ rowHeaders: number[],
+ colHeaders: number[],
+ matrix: number[][],
+ speed: number,
+ dir: number,
+): BlockEval {
+ const rowBracket = bracket(rowHeaders, speed);
+ const colBracket = bracket(colHeaders, dir);
+ if (!rowBracket || !colBracket) {
+ return { inRange: false, value: NaN, rowBracket, colBracket };
+ }
+ const value = bilinear(rowHeaders, colHeaders, matrix, speed, dir);
+ return { inRange: true, value, rowBracket, colBracket };
+}
+
+/** Inclusive [min, max] of a numeric header list (order-independent). */
+export function headerRange(headers: number[]): [number, number] {
+ let lo = Infinity;
+ let hi = -Infinity;
+ for (const h of headers) {
+ if (h < lo) lo = h;
+ if (h > hi) hi = h;
+ }
+ return [lo, hi];
+}
diff --git a/src/enrichments/twin-slider.ts b/src/enrichments/twin-slider.ts
new file mode 100644
index 0000000..f814769
--- /dev/null
+++ b/src/enrichments/twin-slider.ts
@@ -0,0 +1,344 @@
+/**
+ * Twin-table slider controller (spec 016 §3.2).
+ *
+ * Renders the two (or more) season blocks as *synced sub-tables*:
+ * - one shared horizontal Direction (column) slider injected at the top;
+ * - one vertical Speed (row) slider per block, injected into that block's
+ * merged group cell, all sharing a single Speed value.
+ *
+ * Dragging any enabled Speed slider updates the shared value, so every block's
+ * slider tracks it. When the shared Speed leaves a block's range that block's
+ * slider is DISABLED and its interpolated readout + highlight rectangle are
+ * cleared (not clamped) — the one twin-specific rule over the reused
+ * `bilinear` + highlight machinery.
+ *
+ * This controller is self-contained: the single-grid slider path (`slider.ts`)
+ * is untouched, so ordinary tables cannot regress.
+ */
+
+import { detectTwin, type TwinModel, type TwinBlock } from '../core/twin-grid';
+import { evalBlock, headerRange, type BlockEval } from './twin-interp';
+import { SLIDER_HIGHLIGHT_CLASSES } from './slider-readout';
+import { formatNumber } from '../ui/slider-control';
+
+const STYLE_ID = 'gs-twin-slider-styles';
+const INJECTED = 'data-gs-injected';
+
+interface BlockUi {
+ block: TwinBlock;
+ input: HTMLInputElement;
+ readout: HTMLElement;
+ /** Circle overlay showing the interpolated point inside the bracket. */
+ marker: HTMLElement;
+ min: number;
+ max: number;
+}
+
+interface TwinController {
+ destroy(): void;
+}
+
+const active = new WeakMap();
+
+/* ── Styles ─────────────────────────────────────────────────────────────── */
+
+function ensureStyles(): void {
+ if (typeof document === 'undefined') return;
+ if (document.getElementById(STYLE_ID)) return;
+ const style = document.createElement('style');
+ style.id = STYLE_ID;
+ style.textContent = `
+ .gs-twin-block-ui { display:flex; flex-direction:column; align-items:center; gap:4px; padding:4px 0; }
+ .gs-twin-block-ui input[type=range][orient=vertical] {
+ /* No 'direction: rtl' — that would put the min at the bottom, inverting the
+ * slider relative to the table rows (row-header min is the TOP row). With
+ * plain vertical-lr the thumb top = min speed = top row (spec 016). */
+ writing-mode: vertical-lr; width:16px; height:84px; accent-color:#1976d2;
+ }
+ .gs-twin-block-ui input[type=range]:disabled { opacity:0.4; cursor:not-allowed; }
+ .gs-twin-readout { font:600 11px/1.3 system-ui,sans-serif; color:#1976d2; text-align:center; font-variant-numeric:tabular-nums; }
+ .gs-twin-readout[data-out-of-range] { color:#999; }
+ .gs-twin-dir { display:flex; align-items:center; gap:8px; padding:4px 8px; }
+ .gs-twin-dir input[type=range] { flex:1; accent-color:#1976d2; }
+ .gs-twin-dir-label { font:600 11px/1 system-ui,sans-serif; color:#444; white-space:nowrap; }
+ `;
+ document.head.appendChild(style);
+}
+
+/* ── Highlight (per block, via direct cell references) ──────────────────── */
+
+function clearBlockHighlight(block: TwinBlock): void {
+ for (const row of block.dataCells) {
+ for (const cell of row) cell.classList.remove(...SLIDER_HIGHLIGHT_CLASSES);
+ }
+}
+
+function highlightBlock(block: TwinBlock, e: BlockEval): void {
+ clearBlockHighlight(block);
+ if (!e.inRange || !e.rowBracket || !e.colBracket) return;
+ const [i0, i1] = e.rowBracket;
+ const [j0, j1] = e.colBracket;
+ const rows = [i0, i1];
+ const cols = [j0, j1];
+ for (let a = 0; a < rows.length; a++) {
+ for (let b = 0; b < cols.length; b++) {
+ const cell = block.dataCells[rows[a]]?.[cols[b]];
+ if (!cell) continue;
+ cell.classList.add('gs-slider-highlight');
+ if (a === 0) cell.classList.add('gs-slider-highlight-t');
+ if (a === rows.length - 1) cell.classList.add('gs-slider-highlight-b');
+ if (b === 0) cell.classList.add('gs-slider-highlight-l');
+ if (b === cols.length - 1) cell.classList.add('gs-slider-highlight-r');
+ }
+ }
+}
+
+/* ── Interpolated-position marker (the circle) ──────────────────────────── */
+
+/** Make the table's positioning ancestor `relative` so absolutely-positioned
+ * markers are placed against it (mirrors heatmap-marker.ts). */
+function ensureParentPositioned(table: HTMLTableElement): HTMLElement | null {
+ const parent = table.parentElement;
+ if (!parent) return null;
+ if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
+ return parent;
+}
+
+function makeMarker(): HTMLElement {
+ const m = document.createElement('div');
+ m.setAttribute('data-gs-marker', '');
+ m.setAttribute('aria-hidden', 'true');
+ m.style.display = 'none';
+ return m;
+}
+
+function frac(a: number, b: number, x: number): number {
+ if (b === a) return 0;
+ return Math.min(1, Math.max(0, (x - a) / (b - a)));
+}
+
+/** Place `marker` at the interpolated (speed, dir) point inside the block's
+ * four-cell bracket, or hide it when the block is out of range. */
+function positionMarker(
+ marker: HTMLElement,
+ block: TwinBlock,
+ colHeaders: number[],
+ speed: number,
+ dir: number,
+ e: BlockEval,
+ table: HTMLTableElement,
+): void {
+ if (!e.inRange || !e.rowBracket || !e.colBracket) {
+ marker.style.display = 'none';
+ return;
+ }
+ const [i0, i1] = e.rowBracket;
+ const [j0, j1] = e.colBracket;
+ const c00 = block.dataCells[i0]?.[j0];
+ const c11 = block.dataCells[i1]?.[j1];
+ const parent = table.parentElement;
+ if (!c00 || !c11 || !parent) {
+ marker.style.display = 'none';
+ return;
+ }
+ const pr = parent.getBoundingClientRect();
+ const r00 = c00.getBoundingClientRect();
+ const r11 = c11.getBoundingClientRect();
+ const tr = frac(block.rowHeaders[i0], block.rowHeaders[i1], speed);
+ const tc = frac(colHeaders[j0], colHeaders[j1], dir);
+ const xL = r00.left + r00.width / 2;
+ const xR = r11.left + r11.width / 2;
+ const yT = r00.top + r00.height / 2;
+ const yB = r11.top + r11.height / 2;
+ marker.style.left = `${xL + tc * (xR - xL) - pr.left}px`;
+ marker.style.top = `${yT + tr * (yB - yT) - pr.top}px`;
+ marker.style.display = '';
+}
+
+/* ── Injection ──────────────────────────────────────────────────────────── */
+
+function makeRange(min: number, max: number, value: number, aria: string, vertical: boolean): HTMLInputElement {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(min);
+ input.max = String(max);
+ input.step = 'any';
+ input.value = String(Math.min(max, Math.max(min, value)));
+ input.setAttribute('aria-label', aria);
+ input.setAttribute('data-gs-twin-input', vertical ? 'speed' : 'dir');
+ if (vertical) input.setAttribute('orient', 'vertical');
+ return input;
+}
+
+function injectDirectionRow(
+ table: HTMLTableElement,
+ model: TwinModel,
+ dir: number,
+ onInput: (v: number) => void,
+): { row: HTMLTableRowElement; readout: HTMLElement } {
+ const [min, max] = headerRange(model.colHeaders);
+ const tr = document.createElement('tr');
+ tr.setAttribute(INJECTED, '');
+ tr.setAttribute('data-gs-twin-row', '');
+
+ const corner = document.createElement('th');
+ corner.setAttribute(INJECTED, '');
+ corner.colSpan = model.labelColumnCount;
+ const readout = document.createElement('div');
+ readout.className = 'gs-twin-dir-label';
+ corner.appendChild(readout);
+
+ const slot = document.createElement('th');
+ slot.setAttribute(INJECTED, '');
+ slot.colSpan = model.colHeaders.length;
+ const wrap = document.createElement('div');
+ wrap.className = 'gs-twin-dir';
+ const label = document.createElement('span');
+ label.className = 'gs-twin-dir-label';
+ label.textContent = 'Direction';
+ const input = makeRange(min, max, dir, 'Direction slider', false);
+ input.addEventListener('input', () => onInput(parseFloat(input.value)));
+ wrap.append(label, input);
+ slot.appendChild(wrap);
+
+ tr.append(corner, slot);
+
+ const body = table.tBodies[0] ?? table;
+ const firstRow = body.rows[0] ?? null;
+ if (firstRow) body.insertBefore(tr, firstRow);
+ else body.appendChild(tr);
+ return { row: tr, readout };
+}
+
+function injectBlockSlider(
+ block: TwinBlock,
+ speed: number,
+ onInput: (v: number) => void,
+): BlockUi {
+ const [min, max] = headerRange(block.rowHeaders);
+ const wrap = document.createElement('div');
+ wrap.className = 'gs-twin-block-ui';
+ wrap.setAttribute('data-gs-twin-block-ui', '');
+
+ const input = makeRange(min, max, speed, `${block.label} speed slider`, true);
+ input.addEventListener('input', () => onInput(parseFloat(input.value)));
+
+ const readout = document.createElement('div');
+ readout.className = 'gs-twin-readout';
+ readout.setAttribute('aria-live', 'polite');
+ readout.textContent = '—';
+
+ wrap.append(input, readout);
+ block.groupCell.appendChild(wrap);
+ return { block, input, readout, marker: makeMarker(), min, max };
+}
+
+/* ── Controller ─────────────────────────────────────────────────────────── */
+
+function overlapMidpoint(model: TwinModel): number {
+ let lo = -Infinity;
+ let hi = Infinity;
+ for (const b of model.blocks) {
+ const [bl, bh] = headerRange(b.rowHeaders);
+ lo = Math.max(lo, bl);
+ hi = Math.min(hi, bh);
+ }
+ if (lo <= hi) return (lo + hi) / 2; // overlap exists
+ // No common overlap — fall back to the union midpoint.
+ let umin = Infinity;
+ let umax = -Infinity;
+ for (const b of model.blocks) {
+ const [bl, bh] = headerRange(b.rowHeaders);
+ umin = Math.min(umin, bl);
+ umax = Math.max(umax, bh);
+ }
+ return (umin + umax) / 2;
+}
+
+/** Enable the twin controller on `table`. No-op (returns existing) if already on. */
+export function enableTwinSliders(table: HTMLTableElement): boolean {
+ if (active.has(table)) return true;
+ const model = detectTwin(table);
+ if (!model) return false;
+ ensureStyles();
+
+ const [dirMin, dirMax] = headerRange(model.colHeaders);
+ const state = { speed: overlapMidpoint(model), dir: (dirMin + dirMax) / 2 };
+ const uis: BlockUi[] = [];
+
+ const render = (): void => {
+ for (const ui of uis) {
+ const e = evalBlock(ui.block.rowHeaders, model.colHeaders, ui.block.matrix, state.speed, state.dir);
+ highlightBlock(ui.block, e);
+ positionMarker(ui.marker, ui.block, model.colHeaders, state.speed, state.dir, e, table);
+ const outOfRange = !e.inRange;
+ ui.input.disabled = outOfRange;
+ if (!outOfRange) ui.input.value = String(Math.min(ui.max, Math.max(ui.min, state.speed)));
+ if (outOfRange) {
+ ui.readout.textContent = '—';
+ ui.readout.setAttribute('data-out-of-range', '');
+ ui.input.title = `${ui.block.label}: ${formatNumber(state.speed)} is outside ${formatNumber(ui.min)}–${formatNumber(ui.max)}`;
+ } else {
+ ui.readout.textContent = isFinite(e.value)
+ ? `${ui.block.label}: ${formatNumber(e.value)}`
+ : '—';
+ ui.readout.removeAttribute('data-out-of-range');
+ ui.input.title = `${ui.block.label} speed`;
+ }
+ }
+ dirReadout.textContent = `Speed ${formatNumber(state.speed)} · Direction ${formatNumber(state.dir)}`;
+ };
+
+ const onSpeed = (v: number): void => {
+ if (!isFinite(v)) return;
+ state.speed = v;
+ render();
+ };
+ const onDir = (v: number): void => {
+ if (!isFinite(v)) return;
+ state.dir = v;
+ render();
+ };
+
+ const { row: dirRow, readout: dirReadout } = injectDirectionRow(table, model, state.dir, onDir);
+ const markerHost = ensureParentPositioned(table);
+ for (const block of model.blocks) {
+ const ui = injectBlockSlider(block, state.speed, onSpeed);
+ markerHost?.appendChild(ui.marker);
+ uis.push(ui);
+ }
+ render();
+
+ // Cell geometry shifts on resize — reposition the markers (highlights are
+ // class-based and need no repositioning).
+ const onResize = (): void => {
+ for (const ui of uis) {
+ const e = evalBlock(ui.block.rowHeaders, model.colHeaders, ui.block.matrix, state.speed, state.dir);
+ positionMarker(ui.marker, ui.block, model.colHeaders, state.speed, state.dir, e, table);
+ }
+ };
+ if (typeof window !== 'undefined') window.addEventListener('resize', onResize);
+
+ const controller: TwinController = {
+ destroy() {
+ if (typeof window !== 'undefined') window.removeEventListener('resize', onResize);
+ dirRow.parentElement?.removeChild(dirRow);
+ for (const ui of uis) {
+ clearBlockHighlight(ui.block);
+ ui.marker.remove();
+ ui.block.groupCell.querySelector('[data-gs-twin-block-ui]')?.remove();
+ }
+ active.delete(table);
+ },
+ };
+ active.set(table, controller);
+ return true;
+}
+
+export function disableTwinSliders(table: HTMLTableElement): void {
+ active.get(table)?.destroy();
+}
+
+export function isTwinSlidersActive(table: HTMLTableElement): boolean {
+ return active.has(table);
+}
diff --git a/src/index.ts b/src/index.ts
index 1fc7dbd..9a5d81a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -49,6 +49,7 @@ import {
} from './enrichments/slider';
import type { GridSightSlider, Axis as SliderAxis } from './enrichments/slider';
import { addThresholdSlider as sliderAddThresholdSlider } from './enrichments/slider-threshold';
+import { disableTwinSliders } from './enrichments/twin-slider';
import { ensureHeatmapMarkerListener } from './ui/heatmap-marker';
// Virtual columns (spec 012-virtual-columns)
@@ -282,6 +283,7 @@ const GridSight = {
try { vcDetachAll(); } catch (e) { /* ignore */ void e; }
for (const table of Array.from(tableRegistry.values())) {
try { removeHeatmap(table); } catch (e) { /* ignore */ void e; }
+ try { disableTwinSliders(table); } catch (e) { /* ignore */ void e; }
const toggle = table.querySelector('.grid-sight-toggle-container');
if (toggle) toggle.remove();
try { unmountFilterChip(table); } catch (e) { /* ignore */ void e; }
diff --git a/src/ui/header-utils.ts b/src/ui/header-utils.ts
index 3da122e..b566067 100644
--- a/src/ui/header-utils.ts
+++ b/src/ui/header-utils.ts
@@ -38,6 +38,12 @@ import {
cellValue,
logicalColIndexOf,
} from '../core/table-grid';
+import { isTwinTable } from '../core/twin-grid';
+import {
+ enableTwinSliders,
+ disableTwinSliders,
+ isTwinSlidersActive,
+} from '../enrichments/twin-slider';
export type HeaderType = 'row' | 'column' | 'table';
@@ -63,6 +69,18 @@ export function injectPlusIcons(table: HTMLTableElement, columnTypes: ColumnType
const headerRow = rows[0];
if (!headerRow) 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
+ // the merged group cell, and the classic enrichments are not group-aware. So
+ // on a twin table we mount ONLY the table-level cluster (the corner cell),
+ // 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];
+ if (corner) addLozengesToHeader(table, corner, 'table', 0);
+ return;
+ }
+
gridCells(headerRow).forEach((cell, colIndex) => {
const isTopLeftCell = colIndex === 0;
const type = columnTypes[colIndex];
@@ -176,10 +194,14 @@ function buildDescriptorAffordances(
colIndex,
columnType,
};
+ // On a twin table only the (twin-aware) sliders affordance is offered; the
+ // classic per-column/row enrichments are not group-aware yet (spec 016 §2.1).
+ const twin = isTwinTable(table);
const out: HTMLElement[] = [];
for (const descriptor of listEnrichmentDescriptors()) {
const behavior = descriptor.behavior;
if (!descriptor.shipped || !behavior) continue;
+ if (twin && descriptor.id !== 'sliders') continue;
if (!enabled.has(descriptor.id)) continue;
if (!behavior.appliesTo(ctx)) continue;
const el = behavior.mount(ctx);
@@ -574,6 +596,8 @@ function heatmapRowIndex(table: HTMLTableElement, tr: HTMLTableRowElement): numb
}
function sliderApplicable(table: HTMLTableElement, type: HeaderType): boolean {
+ // Twin tables always qualify (table-level twin controller); spec 016.
+ if (type === 'table' && isTwinTable(table)) return true;
if (type === 'column') return inspectAxisBinding(table, 'col') !== null;
if (type === 'row') return inspectAxisBinding(table, 'row') !== null;
// Table-wide: at least one axis must qualify.
@@ -581,12 +605,19 @@ function sliderApplicable(table: HTMLTableElement, type: HeaderType): boolean {
}
function sliderIsActive(table: HTMLTableElement, type: HeaderType): boolean {
+ if (type === 'table' && isTwinTable(table)) return isTwinSlidersActive(table);
if (type === 'column') return getSliders(table).some(s => s.kind === 'axis' && s.axis === 'col');
if (type === 'row') return getSliders(table).some(s => s.kind === 'axis' && s.axis === 'row');
return getSliders(table).some(s => s.kind === 'axis');
}
function toggleSliders(table: HTMLTableElement, type: HeaderType): void {
+ // Twin tables: toggle the dedicated synced-blocks controller (spec 016).
+ if (type === 'table' && isTwinTable(table)) {
+ if (isTwinSlidersActive(table)) disableTwinSliders(table);
+ else enableTwinSliders(table);
+ return;
+ }
const axes: Array<'row' | 'col'> = type === 'row'
? ['row']
: type === 'column'
diff --git a/src/ui/toggle-injector.ts b/src/ui/toggle-injector.ts
index 3c16849..ebd62b4 100644
--- a/src/ui/toggle-injector.ts
+++ b/src/ui/toggle-injector.ts
@@ -1,5 +1,6 @@
import { injectPlusIcons, removePlusIcons, plusIconStyles } from './header-utils';
import type { HeaderType } from './header-utils';
+import { disableTwinSliders } from '../enrichments/twin-slider';
import { analyzeTable } from '../core/table-detection';
import { setColumnTypes } from '../core/column-types-cache';
import { toggleHeatmap } from '../enrichments/heatmap';
@@ -469,6 +470,10 @@ export function deactivateToggle(table: HTMLTableElement): void {
toggle.setAttribute('aria-expanded', 'false');
table.classList.remove(TABLE_ENABLED_CLASS);
+ // Tear down the twin-slider adornments (injected direction row + per-block
+ // sliders/highlights) before removing lozenges, so GS-off restores the
+ // original markup on grouped tables too (spec 016).
+ try { disableTwinSliders(table); } catch (e) { /* ignore */ void e; }
removePlusIcons(table);
table.removeEventListener('gridsight:enrichmentSelected', handleEnrichmentSelected as EventListener);
// Removing the `gs-has-plus-icon` marker can leave an empty `class=""`
diff --git a/tests/e2e/twin-interpolation.spec.ts b/tests/e2e/twin-interpolation.spec.ts
new file mode 100644
index 0000000..37a72b2
--- /dev/null
+++ b/tests/e2e/twin-interpolation.spec.ts
@@ -0,0 +1,84 @@
+import { test, expect } from '@playwright/test';
+import { isolateState } from './helpers/isolation';
+
+/**
+ * End-to-end tests for spec 016 — twin (grouped) interpolation.
+ * Drives the offline demo (`public/demo/twin-table/index.html`): enable
+ * Grid-Sight, add the synced twin sliders, and exercise the value-sync +
+ * out-of-range-disable behaviour across the Summer / Winter blocks.
+ */
+
+const SPEED = 'input[data-gs-twin-input="speed"]';
+
+async function enableTwin(page: import('@playwright/test').Page) {
+ await page.goto('/grid-sight/demo/twin-table/index.html');
+ await page.waitForLoadState('domcontentloaded');
+ await page.waitForFunction(() => !!(window as any).gridSight);
+ await page.locator('#twin-table .grid-sight-toggle').first().click();
+ await page.locator('#twin-table [data-gs-lozenge-id="sliders"]').first().click();
+}
+
+test.describe('spec 016: twin interpolation', () => {
+ test.beforeEach(async ({ page }) => {
+ await isolateState(page);
+ });
+
+ test('only the slider lozenge is offered (no misplaced H/#)', async ({ page }) => {
+ await page.goto('/grid-sight/demo/twin-table/index.html');
+ await page.waitForFunction(() => !!(window as any).gridSight);
+ await page.locator('#twin-table .grid-sight-toggle').first().click();
+ await expect(page.locator('#twin-table [data-gs-lozenge-id]')).toHaveCount(1);
+ await expect(page.locator('#twin-table [data-gs-lozenge-id="sliders"]')).toHaveCount(1);
+ });
+
+ test('adds one shared direction slider and one speed slider per block', async ({ page }) => {
+ await enableTwin(page);
+ await expect(page.locator('#twin-table input[data-gs-twin-input="dir"]')).toHaveCount(1);
+ await expect(page.locator(`#twin-table ${SPEED}`)).toHaveCount(2);
+ // Both blocks live inside the initial overlap: 8 highlighted cells (4 each).
+ await expect(page.locator('#twin-table .gs-slider-highlight')).toHaveCount(8);
+ // One interpolated-position marker (circle) per block, both visible.
+ const markers = page.locator('[data-gs-marker]');
+ await expect(markers).toHaveCount(2);
+ for (let i = 0; i < 2; i++) await expect(markers.nth(i)).toBeVisible();
+ });
+
+ test('speed syncs across blocks in the overlap', async ({ page }) => {
+ await enableTwin(page);
+ const speeds = page.locator(`#twin-table ${SPEED}`);
+ await speeds.first().evaluate((el: HTMLInputElement) => {
+ el.value = '50';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await expect(speeds.nth(1)).toHaveJSProperty('value', '50');
+ await expect(speeds.first()).toHaveJSProperty('disabled', false);
+ await expect(speeds.nth(1)).toHaveJSProperty('disabled', false);
+ });
+
+ test('turning Grid-Sight off removes all twin adornments', async ({ page }) => {
+ await enableTwin(page);
+ await expect(page.locator(`#twin-table ${SPEED}`)).toHaveCount(2);
+ // Click the corner GS toggle off.
+ await page.locator('#twin-table .grid-sight-toggle').first().click();
+ await expect(page.locator('#twin-table [data-gs-twin-input]')).toHaveCount(0);
+ await expect(page.locator('#twin-table [data-gs-twin-row]')).toHaveCount(0);
+ await expect(page.locator('#twin-table [data-gs-twin-block-ui]')).toHaveCount(0);
+ await expect(page.locator('#twin-table .gs-slider-highlight')).toHaveCount(0);
+ await expect(page.locator('#twin-table [data-gs-lozenge-id]')).toHaveCount(0);
+ });
+
+ test('out-of-range block disables its slider and clears its marker', async ({ page }) => {
+ await enableTwin(page);
+ const speeds = page.locator(`#twin-table ${SPEED}`);
+ await speeds.first().evaluate((el: HTMLInputElement) => {
+ el.value = '70'; // inside Summer (30–80), outside Winter (20–60)
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await expect(speeds.first()).toHaveJSProperty('disabled', false);
+ await expect(speeds.nth(1)).toHaveJSProperty('disabled', true);
+ // Only Summer's four cells stay highlighted.
+ await expect(page.locator('#twin-table .gs-slider-highlight')).toHaveCount(4);
+ const winterReadout = page.locator('#twin-table .gs-twin-readout[data-out-of-range]');
+ await expect(winterReadout).toHaveText('—');
+ });
+});