From 0d7808c3e1a5b9e075ac78e23215100f1da9ac9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 09:52:30 +0000 Subject: [PATCH 1/6] Investigate twin (grouped) interpolation tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduce a real-world "twin" lookup format — Speed (rows) x Direction (columns) with a merged Season column (Summer/Winter) grouping the speed rows into two independent, stacked grids that share the Direction axis — and investigate whether Grid-Sight can offer interpolation over it. - public/demo/twin-table/index.html: faithful reproduction fixture (Summer 30-80 kts x 6 rows, Winter 20-60 kts x 4 rows), registered as demo #15 in the shared demo nav. - specs/016-twin-interpolation/investigation.md: feasibility write-up. Confirms (against the live addressing layer) that the current single row-header slider binding fails closed on this shape — both axes bind to null, so no slider is offered rather than a wrong number. Prototyped a group-aware binding that partitions on the rowspan group cell into two rectangular sub-grids and feeds the existing bilinear() unchanged; proves twin interpolation is a small, well-contained addressing-layer extension. Recommends a season-selector + shared re-ranging sliders design. Verified end-to-end in Chromium: the fixture renders both merged group cells, Grid-Sight mounts without error, and zero sliders appear (fail closed). No src changes; interpolation core untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DQkMTSjZd2fWS8H45WLcsh --- public/demo/nav.js | 3 +- public/demo/twin-table/index.html | 71 +++++++ specs/016-twin-interpolation/investigation.md | 199 ++++++++++++++++++ 3 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 public/demo/twin-table/index.html create mode 100644 specs/016-twin-interpolation/investigation.md diff --git a/public/demo/nav.js b/public/demo/nav.js index bcf1c8e..e239af0 100644 --- a/public/demo/nav.js +++ b/public/demo/nav.js @@ -26,7 +26,8 @@ { path: 'summary-row/index.html', label: '11. Summary row' }, { 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: 'copy-as-csv/index.html', label: '14. Copy as CSV' }, + { path: 'twin-table/index.html', label: '15. Twin table (investigation)' } ]; var STYLE_ID = 'gs-demo-nav-styles'; diff --git a/public/demo/twin-table/index.html b/public/demo/twin-table/index.html new file mode 100644 index 0000000..b572d9f --- /dev/null +++ b/public/demo/twin-table/index.html @@ -0,0 +1,71 @@ + + + + + + Grid-Sight · Twin (grouped) interpolation table — reproduction + + + + + +

Twin (grouped) interpolation table — reproduction

+ +

+ Investigation fixture (spec 016). This reproduces 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). + It is really two stacked grids sharing the Direction axis. + Grid-Sight's current single-grid slider binding cannot bind either axis on + this shape — see specs/016-twin-interpolation/investigation.md. + The heatmap enrichment is offered here so the two blocks are + visible; sliders are intentionally shown to demonstrate that no axis binding + is produced today. +

+ + + + + + + + + + + + + + + + + + + + + + +
Range table (nm) — Speed × Direction, grouped by Season
SeasonSpeed000°045°090°135°180°
Summer3018.217.415.914.113.0
4016.816.014.612.911.8
5015.114.413.111.510.5
6013.312.711.510.09.1
7011.410.99.88.57.7
809.69.18.27.06.3
Winter2015.014.313.011.410.4
3013.612.911.710.29.2
4012.011.410.38.98.0
608.78.27.36.25.5
+ + + + + + diff --git a/specs/016-twin-interpolation/investigation.md b/specs/016-twin-interpolation/investigation.md new file mode 100644 index 0000000..f10a3b9 --- /dev/null +++ b/specs/016-twin-interpolation/investigation.md @@ -0,0 +1,199 @@ +# Investigation: Twin (grouped) interpolation tables + +**Branch**: `claude/twin-table-interpolation-rbbvxp` +**Created**: 2026-07-03 +**Status**: Investigation / feasibility — not yet scheduled for implementation +**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. + +--- + +## 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 | 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.) + +## 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 (the real design question) + +Two UX shapes, both buildable on the existing slider: + +- **A. Season selector + shared sliders (recommended).** One extra control — a + small segmented `Summer | Winter` selector rendered in the corner cluster — + chooses the active block; the existing Speed (row) and Direction (col) sliders + then bind to that block's sub-grid. Speed slider min/max **re-ranges** when the + season changes (30–80 vs 20–60). One readout, one mental model, minimal new + surface. This matches how a human reads the sheet: "pick the season, then read + off speed and bearing." +- **B. Two independent slider sets.** Bind a full row+col slider pair to each + block simultaneously (two readouts). More literal to the "twin" name but + doubles the on-table UI and the axis-sync questions; better as a later option + than a first cut. + +Recommendation: **ship A first.** B is a superset that can be added behind the +same binding model if demand appears. + +### 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/slider.ts` + `src/ui/` | Season selector control; re-range the row slider on group change; thread the active group into `refreshTable`. | medium | +| `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. From 33623d09d20ffed56ffb87567a7b3840554f3307 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 09:57:04 +0000 Subject: [PATCH 2/6] Fix markdown table formatting in twin-interpolation investigation Normalise pipe spacing and fill the reproduction table's cells so the tables lint cleanly (Codacy CodeStyle / markdownlint table-column-style). Uses a ditto mark for continuation rows instead of ragged empty cells. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DQkMTSjZd2fWS8H45WLcsh --- specs/016-twin-interpolation/investigation.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/specs/016-twin-interpolation/investigation.md b/specs/016-twin-interpolation/investigation.md index f10a3b9..cba5afa 100644 --- a/specs/016-twin-interpolation/investigation.md +++ b/specs/016-twin-interpolation/investigation.md @@ -15,18 +15,18 @@ 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 | Speed | 000° | 045° | 090° | 135° | 180° | -|--------|------:|-----:|-----:|-----:|-----:|-----:| -| **Summer** (rowspan 6) | 30 | … | … | … | … | … | -| | 40 | … | | | | | -| | 50 | … | | | | | -| | 60 | … | | | | | -| | 70 | … | | | | | -| | 80 | … | | | | | -| **Winter** (rowspan 4) | 20 | … | … | … | … | … | -| | 30 | … | | | | | -| | 40 | … | | | | | -| | 60 | … | | | | | +| 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: @@ -134,7 +134,7 @@ same binding model if demand appears. ### 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/slider.ts` + `src/ui/` | Season selector control; re-range the row slider on group change; thread the active group into `refreshTable`. | medium | @@ -146,7 +146,7 @@ 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. | From 47adc572e62a263274697b861ffffeaa425674e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 10:06:44 +0000 Subject: [PATCH 3/6] Add lozenge-misplacement symptom to twin-interpolation investigation The merged group column breaks enrichment lozenge placement the same way it breaks slider binding: H/# mount on the Speed label column and on the merged Season cell, and the first speed of each block gets none. Documents this as a second manifestation of the shared single-row-header assumption and notes twin support is an addressing-layer concern, not slider-only. Verified against the live rendered DOM. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DQkMTSjZd2fWS8H45WLcsh --- specs/016-twin-interpolation/investigation.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/specs/016-twin-interpolation/investigation.md b/specs/016-twin-interpolation/investigation.md index cba5afa..c7c95d9 100644 --- a/specs/016-twin-interpolation/investigation.md +++ b/specs/016-twin-interpolation/investigation.md @@ -77,6 +77,40 @@ offered at all.** The table is silently un-enrichable — exactly the "we may no 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 @@ -138,6 +172,7 @@ same binding model if demand appears. | `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/slider.ts` + `src/ui/` | Season selector control; re-range the row slider on group change; thread the active group into `refreshTable`. | medium | +| `src/ui/header-utils.ts` | 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 | From 6f8cfb6f8da08000787db748ebd8bc86e69028e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 10:23:24 +0000 Subject: [PATCH 4/6] Implement twin (grouped) interpolation with synced blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for "twin" lookup tables — Speed (rows) x Direction (columns) grouped by a merged Season column (Summer/Winter) — which the single-grid addressing layer previously mis-read (no sliders offered; H/# lozenges mis-placed on the label columns and the merged group cell). New, isolated from the single-grid slider path so ordinary tables cannot regress: - src/core/twin-grid.ts — pure, rowspan-aware detection + model: partitions the body into one rowHeader x colHeader sub-grid per group. - src/enrichments/twin-interp.ts — pure per-block bilinear eval with the twin-specific out-of-range rule (clear, not clamp). - src/enrichments/twin-slider.ts — synced-blocks controller: one shared Direction slider + one Speed slider per block, synced by value; a block whose range excludes the shared speed is disabled and its readout + highlight cleared. Reuses bilinear + the slider highlight classes. - src/ui/header-utils.ts — on a twin table, offer only the twin-aware S toggle on the corner (the mis-placed H/# are no longer shown) and route slider applicable/active/toggle to the twin controller. Design (synced blocks, no selector) recorded in specs/016-twin-interpolation/investigation.md. The demo (#15) now drives the feature. Deferred: group-awareness for heatmap/statistics/sort/filter/outlier (not offered on twin tables rather than mis-applied). Tests: twin-grid (6), twin-interp (6), twin-slider controller (6), and e2e tests/e2e/twin-interpolation.spec.ts (4) covering sync + out-of-range + lozenge placement. Full node suite green (711); verified end-to-end in Chromium. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DQkMTSjZd2fWS8H45WLcsh --- public/demo/twin-table/index.html | 24 +- specs/016-twin-interpolation/investigation.md | 59 ++-- src/core/__tests__/twin-grid.test.ts | 113 ++++++++ src/core/twin-grid.ts | 149 ++++++++++ src/enrichments/__tests__/twin-interp.test.ts | 56 ++++ src/enrichments/__tests__/twin-slider.test.ts | 117 ++++++++ src/enrichments/twin-interp.ts | 56 ++++ src/enrichments/twin-slider.ts | 259 ++++++++++++++++++ src/ui/header-utils.ts | 31 +++ tests/e2e/twin-interpolation.spec.ts | 68 +++++ 10 files changed, 903 insertions(+), 29 deletions(-) create mode 100644 src/core/__tests__/twin-grid.test.ts create mode 100644 src/core/twin-grid.ts create mode 100644 src/enrichments/__tests__/twin-interp.test.ts create mode 100644 src/enrichments/__tests__/twin-slider.test.ts create mode 100644 src/enrichments/twin-interp.ts create mode 100644 src/enrichments/twin-slider.ts create mode 100644 tests/e2e/twin-interpolation.spec.ts diff --git a/public/demo/twin-table/index.html b/public/demo/twin-table/index.html index b572d9f..d0e878f 100644 --- a/public/demo/twin-table/index.html +++ b/public/demo/twin-table/index.html @@ -24,17 +24,19 @@

Twin (grouped) interpolation table — reproduction

- Investigation fixture (spec 016). This reproduces 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). - It is really two stacked grids sharing the Direction axis. - Grid-Sight's current single-grid slider binding cannot bind either axis on - this shape — see specs/016-twin-interpolation/investigation.md. - The heatmap enrichment is offered here so the two blocks are - visible; sliders are intentionally shown to demonstrate that no axis binding - is produced today. + 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.

diff --git a/specs/016-twin-interpolation/investigation.md b/specs/016-twin-interpolation/investigation.md index c7c95d9..0d60e24 100644 --- a/specs/016-twin-interpolation/investigation.md +++ b/specs/016-twin-interpolation/investigation.md @@ -2,11 +2,24 @@ **Branch**: `claude/twin-table-interpolation-rbbvxp` **Created**: 2026-07-03 -**Status**: Investigation / feasibility — not yet scheduled for implementation +**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) @@ -146,24 +159,34 @@ 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 (the real design question) +### 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). -Two UX shapes, both buildable on the existing slider: +- 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). -- **A. Season selector + shared sliders (recommended).** One extra control — a - small segmented `Summer | Winter` selector rendered in the corner cluster — - chooses the active block; the existing Speed (row) and Direction (col) sliders - then bind to that block's sub-grid. Speed slider min/max **re-ranges** when the - season changes (30–80 vs 20–60). One readout, one mental model, minimal new - surface. This matches how a human reads the sheet: "pick the season, then read - off speed and bearing." -- **B. Two independent slider sets.** Bind a full row+col slider pair to each - block simultaneously (two readouts). More literal to the "twin" name but - doubles the on-table UI and the axis-sync questions; better as a later option - than a first cut. +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. -Recommendation: **ship A first.** B is a superset that can be added behind the -same binding model if demand appears. +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 @@ -171,8 +194,8 @@ same binding model if demand appears. | ---- | ------ | ---- | | `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/slider.ts` + `src/ui/` | Season selector control; re-range the row slider on group change; thread the active group into `refreshTable`. | medium | -| `src/ui/header-utils.ts` | 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/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 | 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 = ` + + + + + + + + + + + + + + + + `; + 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 = ` + + + + + `; + 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 = ` + + + + + + + + `; + 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 = ` + + + + + + + + `; + 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 ` + + + + + + + + + + + + + + + `; + 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('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('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..fb1b277 --- /dev/null +++ b/src/enrichments/twin-slider.ts @@ -0,0 +1,259 @@ +/** + * 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; + 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] { + writing-mode: vertical-lr; direction: rtl; 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'); + } + } +} + +/* ── 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, 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); + 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 = `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); + for (const block of model.blocks) uis.push(injectBlockSlider(block, state.speed, onSpeed)); + render(); + + const controller: TwinController = { + destroy() { + dirRow.parentElement?.removeChild(dirRow); + for (const ui of uis) { + clearBlockHighlight(ui.block); + 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/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/tests/e2e/twin-interpolation.spec.ts b/tests/e2e/twin-interpolation.spec.ts new file mode 100644 index 0000000..b7b6bdd --- /dev/null +++ b/tests/e2e/twin-interpolation.spec.ts @@ -0,0 +1,68 @@ +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); + }); + + 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('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('—'); + }); +}); From 89773942c09fa984ab07c215eed88fc5518958b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 10:42:10 +0000 Subject: [PATCH 5/6] Fix twin slider orientation, readout, and GS-off teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from live testing of the twin sliders: - Vertical Speed sliders were inverted vs the table rows (dragging down raised the interpolation). Dropping `direction: rtl` puts the min at the top so the thumb tracks the rows: top = slowest (top row), bottom = fastest. Verified by click-mapping in Chromium (top→30, bottom→80). - The shared corner readout now shows the current Speed alongside the Direction ("Speed 45 · Direction 90"), not Direction alone. - Turning Grid-Sight off left the injected direction row, per-block sliders, and highlights behind. Both teardown paths (per-table deactivateToggle and the global disable()) now call disableTwinSliders, so GS-off restores the original markup on grouped tables too. Tests: added a corner-readout unit assertion and an e2e GS-off teardown case (no twin adornments remain). Full node suite green (712); all twin e2e green; verified end-to-end in Chromium. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DQkMTSjZd2fWS8H45WLcsh --- src/enrichments/__tests__/twin-slider.test.ts | 10 ++++++++++ src/enrichments/twin-slider.ts | 7 +++++-- src/index.ts | 2 ++ src/ui/toggle-injector.ts | 5 +++++ tests/e2e/twin-interpolation.spec.ts | 12 ++++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/enrichments/__tests__/twin-slider.test.ts b/src/enrichments/__tests__/twin-slider.test.ts index 5f3dbd8..f62e49e 100644 --- a/src/enrichments/__tests__/twin-slider.test.ts +++ b/src/enrichments/__tests__/twin-slider.test.ts @@ -76,6 +76,16 @@ describe('twin-slider controller', () => { 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); diff --git a/src/enrichments/twin-slider.ts b/src/enrichments/twin-slider.ts index fb1b277..fcda8e8 100644 --- a/src/enrichments/twin-slider.ts +++ b/src/enrichments/twin-slider.ts @@ -48,7 +48,10 @@ function ensureStyles(): void { 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] { - writing-mode: vertical-lr; direction: rtl; width:16px; height:84px; accent-color:#1976d2; + /* 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; } @@ -218,7 +221,7 @@ export function enableTwinSliders(table: HTMLTableElement): boolean { ui.input.title = `${ui.block.label} speed`; } } - dirReadout.textContent = `Direction ${formatNumber(state.dir)}`; + dirReadout.textContent = `Speed ${formatNumber(state.speed)} · Direction ${formatNumber(state.dir)}`; }; const onSpeed = (v: number): void => { 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/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 index b7b6bdd..edbb4ac 100644 --- a/tests/e2e/twin-interpolation.spec.ts +++ b/tests/e2e/twin-interpolation.spec.ts @@ -51,6 +51,18 @@ test.describe('spec 016: twin interpolation', () => { 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}`); From c5bf7d9e2e24fe1d672009caa2f39357d6ef6357 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 11:06:33 +0000 Subject: [PATCH 6/6] Add interpolated-position marker to twin sliders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each live block now shows the circle marker at the interpolated (speed, direction) point inside its highlight rectangle — matching the single-grid slider's [data-gs-marker]. The marker is placed by interpolating between the four bracketing cells' centres (same math as heatmap-marker.ts), reuses the existing marker CSS, repositions on window resize, hides when the block is out of range, and is removed on teardown. Tests: two unit cases (per-block marker shown/hidden by range; removed on teardown) and an e2e assertion (one visible marker per block). Node suite green (714); twin e2e green; verified in Chromium. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DQkMTSjZd2fWS8H45WLcsh --- src/enrichments/__tests__/twin-slider.test.ts | 24 ++++++ src/enrichments/twin-slider.ts | 86 ++++++++++++++++++- tests/e2e/twin-interpolation.spec.ts | 4 + 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/enrichments/__tests__/twin-slider.test.ts b/src/enrichments/__tests__/twin-slider.test.ts index f62e49e..bd5398f 100644 --- a/src/enrichments/__tests__/twin-slider.test.ts +++ b/src/enrichments/__tests__/twin-slider.test.ts @@ -102,6 +102,30 @@ describe('twin-slider controller', () => { 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); diff --git a/src/enrichments/twin-slider.ts b/src/enrichments/twin-slider.ts index fcda8e8..f814769 100644 --- a/src/enrichments/twin-slider.ts +++ b/src/enrichments/twin-slider.ts @@ -28,6 +28,8 @@ interface BlockUi { block: TwinBlock; input: HTMLInputElement; readout: HTMLElement; + /** Circle overlay showing the interpolated point inside the bracket. */ + marker: HTMLElement; min: number; max: number; } @@ -91,6 +93,68 @@ function highlightBlock(block: TwinBlock, e: BlockEval): void { } } +/* ── 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 { @@ -166,7 +230,7 @@ function injectBlockSlider( wrap.append(input, readout); block.groupCell.appendChild(wrap); - return { block, input, readout, min, max }; + return { block, input, readout, marker: makeMarker(), min, max }; } /* ── Controller ─────────────────────────────────────────────────────────── */ @@ -206,6 +270,7 @@ export function enableTwinSliders(table: HTMLTableElement): boolean { 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))); @@ -236,14 +301,31 @@ export function enableTwinSliders(table: HTMLTableElement): boolean { }; const { row: dirRow, readout: dirReadout } = injectDirectionRow(table, model, state.dir, onDir); - for (const block of model.blocks) uis.push(injectBlockSlider(block, state.speed, onSpeed)); + 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); diff --git a/tests/e2e/twin-interpolation.spec.ts b/tests/e2e/twin-interpolation.spec.ts index edbb4ac..37a72b2 100644 --- a/tests/e2e/twin-interpolation.spec.ts +++ b/tests/e2e/twin-interpolation.spec.ts @@ -37,6 +37,10 @@ test.describe('spec 016: twin interpolation', () => { 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 }) => {
SeasonSpeed000°045°090°135°180°
Summer3018.217.415.914.113.0
4016.816.014.612.911.8
5015.114.413.111.510.5
6013.312.711.510.09.1
7011.410.99.88.57.7
809.69.18.27.06.3
Winter2015.014.313.011.410.4
3013.612.911.710.29.2
4012.011.410.38.98.0
608.78.27.36.25.5
102030
1000123
2000456
3000789
SeasonSpeed000045
Summer3012
4034
5056
6078
SeasonGrade000045
Summerlo12
hi34
Winterlo56
hi78
` (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..5f3dbd8 --- /dev/null +++ b/src/enrichments/__tests__/twin-slider.test.ts @@ -0,0 +1,117 @@ +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 = ` +
SeasonSpeed000°045°090°135°180°
Summer3018.217.415.914.113.0
4016.816.014.612.911.8
5015.114.413.111.510.5
6013.312.711.510.09.1
7011.410.99.88.57.7
809.69.18.27.06.3
Winter2015.014.313.011.410.4
3013.612.911.710.29.2
4012.011.410.38.98.0
608.78.27.36.25.5