@aiquants/spreadsheet 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,46 @@
44
44
  * - ポップオーバーアンカー: ポップオーバー層所有の 2 ノードを fill ハンドルと同じ凍結分割写像でセルへ
45
45
  * 配置 (凍結帯の下 / viewport 外は非表示)。
46
46
  * - ドラッグ矩形モード "point" (参照ドラッグ) は `data-aqsp-drag-mode="point"` を描く。
47
+ *
48
+ * v0.9 trailing freeze + freeze divider (plan §2.4 / §2.5 / §4.10, ADR-22 / ADR-24): each axis
49
+ * splits three ways — `[f?] + [s] + [t?]` (kind `m` still means an UNSPLIT axis, so `mm` keeps
50
+ * meaning "no freeze anywhere") — over the full 16-key {m,f,s,t}² pre-seeded registry with paint
51
+ * rank m 0 < s 1 < t 2 < f 3. Divider segments (`[data-aqsp-freeze-divider]`) are static JSX
52
+ * children of scroll-kind quads placed immediately before the active frame; the runtime stack is
53
+ * bands < fills < dividers < activeFrame < dragRect < marquee < fillPreview < resizeGuide, and
54
+ * BOTH dynamic insertion anchors go through the single `chromeFloorOf` helper (census #24/#25).
55
+ * Segment existence is count-driven (render), visibility is the equality-suppressed `display`
56
+ * heal in `layersFor` (band px), geometry comes from the quad clips — zero per-frame divider
57
+ * writes by construction. Fill-pool writes gain the same-value suppression (census #23): a
58
+ * steady scroll frame writes `transform` only — and `transform` itself is same-value cached on
59
+ * both band segments and fill slots (plan §5.9 step 3 first-stage retreat), so quadrant clones
60
+ * that do not move on a pure cross-axis scroll write nothing at all. Trailing (T = 0 /
61
+ * undeclared) reproduces the v0.8 DOM byte-identically as a capability branch. The clip marquee
62
+ * follows a seam edge-ownership rule (the pane-seam analogue of the cell-border "one of the two
63
+ * neighbours paints the shared line" fold): at a pane seam exactly one quadrant — the one whose
64
+ * half-open clip interval contains the range's adjacent cells — paints the shared edge; a
65
+ * non-owner whose seam-side edge sits in the contested outset strip ([clamp, clamp + 3) /
66
+ * (−3, 0]) hides its marquee clone so the progressive ants outset cannot pull a phantom dash
67
+ * line back across the seam — outside that strip the clip already blanks the clone and the
68
+ * write pattern stays byte-identical to v0.8.
69
+ *
70
+ * v0.9 末尾凍結 + 凍結分割線 (計画 §2.4 / §2.5 / §4.10、ADR-22 / ADR-24): 各軸は `[f?] + [s] +
71
+ * [t?]` の 3 分割 (kind `m` は「未分割軸」の意のままで `mm` は「どこにも凍結なし」を保つ)、
72
+ * レジストリは {m,f,s,t}² = 16 キーの事前シード、描画順位は m 0 < s 1 < t 2 < f 3。分割線
73
+ * セグメント (`[data-aqsp-freeze-divider]`) は scroll 種象限の静的 JSX 子として activeFrame の
74
+ * 直前に置き、実行時スタックは 帯 < fill < 分割線 < activeFrame < dragRect < marquee <
75
+ * fillPreview < resizeGuide。動的挿入アンカー 2 つは単一ヘルパ `chromeFloorOf` を通す (census
76
+ * #24 / #25)。存在 = 実効件数 (render)、可視 = `layersFor` の等値抑制 `display` heal (帯 px)、
77
+ * 幾何 = 象限クリップ任せ — 毎フレームの分割線書込は構造的に 0。fill プールは同値抑制を得て
78
+ * (census #23) 定常スクロールフレームの書込は `transform` のみ — さらに `transform` 自体も帯
79
+ * セグメント / fill スロットの両書込路で同値キャッシュ (計画 §5.9 手順 3 第 1 段リトリート) され、
80
+ * 純横 / 純縦スクロールで動かない象限クローンは何も書かない。末尾 (T = 0 / 未宣言) は能力
81
+ * 分岐として v0.8 の DOM を byte 恒等に再現する。クリップマーキーは継ぎ目の縁所有権規則に従う
82
+ * (セル罫線の「共有線は隣接の一方だけが塗る」畳込みのペイン継ぎ目版): ペイン継ぎ目の共有縁は、
83
+ * レンジの隣接セル列を半開クリップ区間に含む象限ちょうど 1 つが塗り、継ぎ目側の縁が争奪
84
+ * アウトセット帯 ([clamp, clamp + 3) / (−3, 0]) に載った非所有クローンは消灯する — 漸進アンツ
85
+ * アウトセットが幽霊破線を継ぎ目の向こうへ引き戻せないようにするため。帯の外はクリップが元より
86
+ * 不可視化しており、書込パターンは v0.8 と byte 恒等のまま。
47
87
  */
48
88
 
49
89
  import { useWheelBridge, type VirtualGridHandle } from "@aiquants/virtualscroll"
@@ -52,7 +92,7 @@ import type { AxisModel } from "../model/axisModel.ts"
52
92
  import { isWholeCols, isWholeRows } from "../model/cellAnnotations.ts"
53
93
  import { type CellCoord, type CellRange, makeRange } from "../model/cellCoord.ts"
54
94
  import { type EditPaint, IDLE_EDIT_PAINT } from "../model/cellPaint.ts"
55
- import { colToViewportX, frozenHeightOf, frozenWidthOf, rowToViewportY } from "../model/frozenPartition.ts"
95
+ import { type AxisForwardCtx, colToViewportX, frozenHeightOf, frozenWidthOf, rowToViewportY, trailingHeightOf, trailingWidthOf } from "../model/frozenPartition.ts"
56
96
  import { SCROLLBAR_WIDTH } from "../model/viewStore.ts"
57
97
  import type { PopoverAnchorState } from "../popover/usePopovers.ts"
58
98
  import type { SelectionState } from "../selection/selectionState.ts"
@@ -60,6 +100,17 @@ import type { SelectionState } from "../selection/selectionState.ts"
60
100
  /** Viewport-space rect (private — distinct from the logical-space LogicalRect). / ビューポート矩形 (非公開)。 */
61
101
  type ViewportRect = { top: number; left: number; width: number; height: number }
62
102
 
103
+ /** `getFrozenSize()` snapshot shape (engine 3.5.0 — 10 fields incl. the trailing family). / `getFrozenSize()` 快照の形 (エンジン 3.5.0 — 末尾族込みの 10 フィールド)。 */
104
+ type FrozenSnapshot = ReturnType<VirtualGridHandle["getFrozenSize"]>
105
+
106
+ /**
107
+ * Pre-attach frozen-size snapshot (all zeros — the capability branch for a not-yet-attached
108
+ * handle, byte-matching the engine's pre-measure state, not a value fallback).
109
+ * 装着前の凍結寸法快照 (全 0 — 未装着ハンドルの能力分岐。エンジンの未測定状態と byte 一致で、
110
+ * 値フォールバックではない)。
111
+ */
112
+ const EMPTY_FROZEN_SIZE: FrozenSnapshot = Object.freeze({ cols: 0, width: 0, rows: 0, height: 0, trailingCols: 0, trailingRows: 0, trailingWidth: 0, trailingHeight: 0, trailingVisibleWidth: 0, trailingVisibleHeight: 0 })
113
+
63
114
  /** Clamp margin: one screen each side. / クランプ余白 (± 1 画面)。 */
64
115
  const CLAMP_MARGIN_SCREENS = 1
65
116
 
@@ -87,6 +138,22 @@ export const DEFAULT_HIGHLIGHT_COLORS: HighlightColors = Object.freeze({ row: "#
87
138
  /** Range cap of the band pool: beyond it only the TAIL ranges carry bands (deviation row). / 帯プールのレンジ上限 (超過は末尾レンジだけ — 逸脱行)。 */
88
139
  export const BAND_RANGE_CAP = 16
89
140
 
141
+ /** Band-node ceiling above four quadrants (ADR-22 derate — fired at step-5c certification). / 4 象限超の帯ノード総数天井 (ADR-22 デレート — step 5c 認証で発火)。 */
142
+ const BAND_NODE_CEILING = 450
143
+
144
+ /**
145
+ * Effective band-range cap for one repaint, keyed on the active quadrant count (ADR-22
146
+ * derate, v0.9 plan §5.9 step 3): above four quadrants the cap is
147
+ * floor(450 / (5 × quadCount)) — 15 at six quadrants, 10 at nine — so total band nodes never
148
+ * exceed 450; at four quadrants or fewer the v0.8 BAND_RANGE_CAP applies unchanged (T = 0
149
+ * stays byte-identical to v0.8). Exported for the unit value pin (barrel-private).
150
+ * repaint 1 回の実効帯レンジ上限 (象限数キー — ADR-22 デレート、v0.9 計画 §5.9 手順 3)。
151
+ * 5 象限以上は floor(450 / (5 × 象限数)) (6 象限 15 / 9 象限 10) で帯ノード総数 ≤ 450、
152
+ * 4 象限以下は v0.8 の BAND_RANGE_CAP のまま (T = 0 は v0.8 と byte 恒等)。単体値ピン用に
153
+ * モジュール export (バレル非公開)。
154
+ */
155
+ export const bandRangeCapFor = (quadCount: number): number => (quadCount > 4 ? Math.floor(BAND_NODE_CEILING / (5 * quadCount)) : BAND_RANGE_CAP)
156
+
90
157
  /** Live drag-rect modes: §3.2 add / subtract plus the v0.8 "point" (formula-reference drag). / ドラッグ矩形のモード: §3.2 の add / subtract + v0.8 の "point" (参照ドラッグ)。 */
91
158
  export type DragFrameMode = "add" | "subtract" | "point"
92
159
 
@@ -98,14 +165,33 @@ type BandEdge = "left" | "right" | "above" | "below"
98
165
 
99
166
  /**
100
167
  * Last-written numbers of one band group — JS-side same-value suppression so a frame writes a
101
- * size, a display or the cross tint only when it actually changes (no DOM reads on the hot
102
- * path). `crossTint` is the colour family the cross node currently paints ("intersection" = the
103
- * CSS default, no inline background).
104
- * 帯 1 組の直近書込値 — JS 側の同値抑止で、サイズ / display / 交点の色は実際に変わるフレームだけ書く
105
- * (ホットパスで DOM を読まない)。`crossTint` は交点ノードが現在塗る色族 ("intersection" = CSS 既定、
106
- * インライン background なし)。
168
+ * size, a display, the cross tint or a segment `transform` only when it actually changes (no
169
+ * DOM reads on the hot path). The five `*Transform` strings extend census #23 to `transform`
170
+ * (v0.9 plan §5.9 step 3 first-stage retreat): a pure cross-axis scroll frame leaves the
171
+ * byte-identical transforms of the non-moving quadrant clones unwritten. `crossTint` is the
172
+ * colour family the cross node currently paints ("intersection" = the CSS default, no inline
173
+ * background).
174
+ * 帯 1 組の直近書込値 — JS 側の同値抑止で、サイズ / display / 交点の色 / セグメント `transform` は
175
+ * 実際に変わるフレームだけ書く (ホットパスで DOM を読まない)。`*Transform` 文字列 5 本は census #23
176
+ * の `transform` への拡張 (v0.9 計画 §5.9 手順 3 第 1 段リトリート) — 純横 / 純縦スクロールで動かない
177
+ * 象限クローンの byte 恒等 transform を再書込しない。`crossTint` は交点ノードが現在塗る色族
178
+ * ("intersection" = CSS 既定、インライン background なし)。
107
179
  */
108
- type BandWriteState = { rowShown: boolean; colShown: boolean; crossShown: boolean; crossTint: BandKind; rowHeight: number; colWidth: number; crossWidth: number; crossHeight: number }
180
+ type BandWriteState = {
181
+ rowShown: boolean
182
+ colShown: boolean
183
+ crossShown: boolean
184
+ crossTint: BandKind
185
+ rowHeight: number
186
+ colWidth: number
187
+ crossWidth: number
188
+ crossHeight: number
189
+ rowLeftTransform: string
190
+ rowRightTransform: string
191
+ colAboveTransform: string
192
+ colBelowTransform: string
193
+ crossTransform: string
194
+ }
109
195
 
110
196
  /** One range's band group: two row segments, two column segments and the intersection node. / レンジ 1 つの帯組: 行 2 セグメント + 列 2 セグメント + 交点ノード。 */
111
197
  export type BandNodes = { rowLeft: HTMLDivElement; rowRight: HTMLDivElement; colAbove: HTMLDivElement; colBelow: HTMLDivElement; cross: HTMLDivElement; last: BandWriteState }
@@ -140,7 +226,7 @@ export const createBandNodes = (): BandNodes => ({
140
226
  colAbove: makeBandNode("column", "above"),
141
227
  colBelow: makeBandNode("column", "below"),
142
228
  cross: makeBandNode("intersection", null),
143
- last: { rowShown: false, colShown: false, crossShown: false, crossTint: "intersection", rowHeight: -1, colWidth: -1, crossWidth: -1, crossHeight: -1 },
229
+ last: { rowShown: false, colShown: false, crossShown: false, crossTint: "intersection", rowHeight: -1, colWidth: -1, crossWidth: -1, crossHeight: -1, rowLeftTransform: "", rowRightTransform: "", colAboveTransform: "", colBelowTransform: "", crossTransform: "" },
144
230
  })
145
231
 
146
232
  /** The group's nodes in DOM (= paint) order: segments under the intersection. / 帯組のノードを DOM (= 描画) 順に: セグメントの上に交点。 */
@@ -183,12 +269,15 @@ const setShown = (nodes: readonly HTMLDivElement[], shown: boolean): void => {
183
269
  }
184
270
 
185
271
  /**
186
- * Per-frame band placement for one range rect in one layer (§2.4, partitioned BY INPUT):
272
+ * Per-frame band placement for one range rect in one layer (§2.4, partitioned BY INPUT).
273
+ * Every `transform` is same-value cached (census #23 extended — v0.9 plan §5.9 step 3): a
274
+ * byte-identical string is never rewritten, so quadrant clones that do not move on a pure
275
+ * cross-axis scroll write nothing.
187
276
  * - row segments: `transform` only — left segment's right edge at `clamp(rect.left, 0, W)`
188
277
  * (so `translate(clamp − W)` with its healed width W), right segment's left edge at
189
278
  * `clamp(rect.right, 0, W)`; their height (cross-axis) is written only when it changes;
190
279
  * - column segments: the transposed twin with H = clampHeight;
191
- * - intersection: range-sized (size when changed, `transform` per frame); when EXACTLY ONE
280
+ * - intersection: range-sized (size when changed, `transform` when changed); when EXACTLY ONE
192
281
  * family is shown and `highlight.intersection` is off, the node lights up tinted with that
193
282
  * family's colour (inline `background` through the family's `--aqsp-band-*` variable) — the
194
283
  * sheets-enhancer extension paints one full-width band THROUGH the selection when the other
@@ -200,11 +289,13 @@ const setShown = (nodes: readonly HTMLDivElement[], shown: boolean): void => {
200
289
  * range's extent would tint the whole viewport on the other side (the sheets-enhancer
201
290
  * extension draws nothing for whole-axis selections); `highlight` switches each family.
202
291
  * Display is written only on a change (JS-side cache).
203
- * レンジ矩形 1 つの層内配置 (毎フレーム、§2.4 の入力分割):
292
+ * レンジ矩形 1 つの層内配置 (毎フレーム、§2.4 の入力分割)。全 `transform` は同値キャッシュ
293
+ * (census #23 の拡張 — v0.9 計画 §5.9 手順 3): byte 恒等の文字列は再書込しないので、純横 / 純縦
294
+ * スクロールで動かない象限クローンは何も書かない。
204
295
  * - 行セグメント: `transform` のみ — 左セグメントの右辺を `clamp(rect.left, 0, W)` へ (heal 済み幅 W で
205
296
  * `translate(clamp − W)`)、右セグメントの左辺を `clamp(rect.right, 0, W)` へ。高さ (交差軸) は変化時だけ。
206
297
  * - 列セグメント: H = clampHeight の転置双子。
207
- * - 交点: レンジサイズ (サイズは変化時、`transform` は毎フレーム)。**単一族のみ**表示かつ intersection
298
+ * - 交点: レンジサイズ (サイズは変化時、`transform` も変化時)。**単一族のみ**表示かつ intersection
208
299
  * off のときはその族の色 (族の `--aqsp-band-*` 変数を参照するインライン `background`) で点灯する —
209
300
  * sheets-enhancer 拡張は片族 off なら選択を貫く全幅帯 1 本を描くため、両脇セグメントだけではレンジ
210
301
  * 位置で帯色が途切れる。両族表示時は拡張同様に穴のまま。
@@ -228,9 +319,19 @@ export const placeBandSegments = (nodes: BandNodes, rect: BandRect, extents: Ban
228
319
  nodes.rowLeft.style.height = height
229
320
  nodes.rowRight.style.height = height
230
321
  }
231
- // 主軸は transform のみ — 内側の辺をクランプ範囲へ置く (主軸サイズは heal パスの clampWidth)
232
- nodes.rowLeft.style.transform = `translate(${clampTo(rect.left, extents.clampWidth) - extents.clampWidth}px, ${rect.top}px)`
233
- nodes.rowRight.style.transform = `translate(${clampTo(rect.left + rect.width, extents.clampWidth)}px, ${rect.top}px)`
322
+ // 主軸は transform のみ — 内側の辺をクランプ範囲へ置く (主軸サイズは heal パスの clampWidth)。
323
+ // transform も同値キャッシュ (census #23 の拡張 — v0.9 計画 §5.9 手順 3 第 1 段リトリート):
324
+ // 純横 / 純縦スクロールで動かない象限クローンの byte 恒等文字列は再書込しない
325
+ const rowLeftTransform = `translate(${clampTo(rect.left, extents.clampWidth) - extents.clampWidth}px, ${rect.top}px)`
326
+ if (rowLeftTransform !== last.rowLeftTransform) {
327
+ last.rowLeftTransform = rowLeftTransform
328
+ nodes.rowLeft.style.transform = rowLeftTransform
329
+ }
330
+ const rowRightTransform = `translate(${clampTo(rect.left + rect.width, extents.clampWidth)}px, ${rect.top}px)`
331
+ if (rowRightTransform !== last.rowRightTransform) {
332
+ last.rowRightTransform = rowRightTransform
333
+ nodes.rowRight.style.transform = rowRightTransform
334
+ }
234
335
  }
235
336
  // 列帯: 行帯と同じ全軸スパン省略
236
337
  const colShown = highlight.column && !spansAxis
@@ -245,8 +346,17 @@ export const placeBandSegments = (nodes: BandNodes, rect: BandRect, extents: Ban
245
346
  nodes.colAbove.style.width = width
246
347
  nodes.colBelow.style.width = width
247
348
  }
248
- nodes.colAbove.style.transform = `translate(${rect.left}px, ${clampTo(rect.top, extents.clampHeight) - extents.clampHeight}px)`
249
- nodes.colBelow.style.transform = `translate(${rect.left}px, ${clampTo(rect.top + rect.height, extents.clampHeight)}px)`
349
+ // 列帯も transform は同値キャッシュ (census #23 拡張 — 行帯と同じ規律)
350
+ const colAboveTransform = `translate(${rect.left}px, ${clampTo(rect.top, extents.clampHeight) - extents.clampHeight}px)`
351
+ if (colAboveTransform !== last.colAboveTransform) {
352
+ last.colAboveTransform = colAboveTransform
353
+ nodes.colAbove.style.transform = colAboveTransform
354
+ }
355
+ const colBelowTransform = `translate(${rect.left}px, ${clampTo(rect.top + rect.height, extents.clampHeight)}px)`
356
+ if (colBelowTransform !== last.colBelowTransform) {
357
+ last.colBelowTransform = colBelowTransform
358
+ nodes.colBelow.style.transform = colBelowTransform
359
+ }
250
360
  }
251
361
  // 交点: レンジそのもの (サイズは変化時、transform は毎フレーム)。単一族のみ表示で intersection が
252
362
  // off のときもその族の色で点灯する — 拡張は片族 off なら選択を貫く全幅帯 1 本を描くため、両脇
@@ -269,7 +379,12 @@ export const placeBandSegments = (nodes: BandNodes, rect: BandRect, extents: Ban
269
379
  nodes.cross.style.width = `${rect.width}px`
270
380
  nodes.cross.style.height = `${rect.height}px`
271
381
  }
272
- nodes.cross.style.transform = `translate(${rect.left}px, ${rect.top}px)`
382
+ // 交点 transform も同値キャッシュ (census #23 拡張)
383
+ const crossTransform = `translate(${rect.left}px, ${rect.top}px)`
384
+ if (crossTransform !== last.crossTransform) {
385
+ last.crossTransform = crossTransform
386
+ nodes.cross.style.transform = crossTransform
387
+ }
273
388
  }
274
389
  }
275
390
 
@@ -365,6 +480,15 @@ type SelectionOverlayProps = {
365
480
  frozenCols: number
366
481
  /** Frozen leading rows (v0.6 — drives the vertical split into 2D quadrants). / 先頭凍結行数 (v0.6 — 縦分割で 2D 象限化)。 */
367
482
  frozenRows: number
483
+ /**
484
+ * v0.9 capability: trailing frozen columns (drives the horizontal split's t band; freshness
485
+ * reads still use the handle). Undeclared ⇒ the v0.8 x-split byte-identically (zero t quads).
486
+ * v0.9 能力: 末尾凍結列数 (横分割の t 帯。鮮度読みはハンドル)。未宣言 ⇒ v0.8 の x 分割と
487
+ * byte 恒等 (t 象限 0)。
488
+ */
489
+ trailingCols?: number
490
+ /** v0.9 capability: trailing frozen rows (the vertical twin of trailingCols). / v0.9 能力: 末尾凍結行数 (trailingCols の縦双子)。 */
491
+ trailingRows?: number
368
492
  sinkAriaLabel: string
369
493
  onSinkMouseDown: (event: MouseEvent) => void
370
494
  /**
@@ -390,8 +514,27 @@ type SelectionOverlayProps = {
390
514
  tipAnchorRef?: RefObject<HTMLDivElement | null>
391
515
  }
392
516
 
393
- /** Live resize guideline descriptor (logical axis px). / リサイズガイドラインの記述子 (論理軸 px)。 */
394
- export type ResizeGuide = { axis: "row" | "col"; position: number }
517
+ /**
518
+ * Live resize guideline descriptor (logical axis px). `anchor` is REQUIRED (v0.9 §2.6 — no
519
+ * optional / default per Strict No-Fallback): "leading" takes the byte-identical v0.8 path
520
+ * (all-quadrant paint, no clamp); "trailing" paints only in quads whose gesture-axis kind is
521
+ * `t` with the band-local position clamped `max(0, local)`.
522
+ * リサイズガイドラインの記述子 (論理軸 px)。`anchor` は必須 (v0.9 §2.6 — Strict No-Fallback に
523
+ * つき optional / default なし): "leading" は v0.8 と byte 恒等の経路 (全象限描画・無クランプ)、
524
+ * "trailing" はジェスチャ軸 kind = t の象限のみ + band-local を `max(0, local)` へクランプ。
525
+ */
526
+ export type ResizeGuide = { axis: "row" | "col"; position: number; anchor: "leading" | "trailing" }
527
+
528
+ /**
529
+ * One fill-pool entry: the node plus its last-written numbers (v0.9 §2.5 census #23 — the
530
+ * `placeBandSegments` last-value cache mirrored onto fills, `transform` included since the
531
+ * plan §5.9 step 3 first-stage retreat: a steady scroll frame writes only the transforms that
532
+ * actually changed; no DOM reads on the hot path).
533
+ * fill プールの 1 エントリ: ノード + 直近書込値 (v0.9 §2.5 census #23 — `placeBandSegments` の
534
+ * 同値キャッシュを fill へミラー。計画 §5.9 手順 3 第 1 段リトリートで `transform` も対象 —
535
+ * 定常スクロールフレームの書込は実際に変わった transform だけ、ホットパスで DOM を読まない)。
536
+ */
537
+ type FillSlot = { node: HTMLDivElement; last: { shown: boolean; width: number; height: number; transform: string } }
395
538
 
396
539
  /** Resident node set of one overlay layer (v0.5 layer split; every layer owns a dedicated container). / overlay 1 層ぶんの常設ノード束 (v0.5 の層分割。全層が専用コンテナを持つ)。 */
397
540
  type LayerNodes = {
@@ -401,7 +544,7 @@ type LayerNodes = {
401
544
  marquee: HTMLDivElement | null
402
545
  fillPreview: HTMLDivElement | null
403
546
  resizeGuide: HTMLDivElement | null
404
- fillPool: HTMLElement[]
547
+ fillPool: FillSlot[]
405
548
  /** Band groups per confirmed range (tail BAND_RANGE_CAP), inserted under the fills. / 確定レンジ毎の帯組 (末尾 BAND_RANGE_CAP 件)、fill の下。 */
406
549
  bandPool: BandNodes[]
407
550
  /** Band group of the live drag rect (null until a drag needs one). / ライブドラッグ矩形の帯組 (必要になるまで null)。 */
@@ -409,32 +552,107 @@ type LayerNodes = {
409
552
  /** Last healed clamp extents of this layer's bands (-1 = never healed). / この層の帯の直近 heal 済みクランプ範囲 (-1 = 未 heal)。 */
410
553
  bandClampWidth: number
411
554
  bandClampHeight: number
555
+ /** Mounted divider segments in fixed slot order [x-leading, x-trailing, y-leading, y-trailing] = JSX (DOM) order (v0.9 §2.4). / マウント済み分割線セグメント (固定スロット順 [x-leading, x-trailing, y-leading, y-trailing] = JSX (DOM) 順 — v0.9 §2.4)。 */
556
+ dividerSlots: (HTMLDivElement | null)[]
557
+ /** Last-written shown state per divider slot (equality-suppressed `display` heal — v0.9 §2.4). / 分割線スロット毎の直近書込 shown 状態 (等値抑制 `display` heal — v0.9 §2.4)。 */
558
+ dividerShown: boolean[]
559
+ /** Identity-stable compact array of the quad's mounted segments (≤ 4, DOM order — the `chromeFloorOf` source, v0.9 §2.5). / 象限のマウント済みセグメントの identity 安定な圧縮配列 (≤ 4、DOM 順 — `chromeFloorOf` の源、v0.9 §2.5)。 */
560
+ dividers: HTMLDivElement[]
412
561
  }
413
562
 
563
+ /**
564
+ * The canonical "first static chrome child" anchor of a quadrant (v0.9 §2.5 — ADR-24): the
565
+ * quad's first mounted divider segment, else the active frame. A structural ordering definition
566
+ * with a single source shared by BOTH dynamic insertion anchors (fill pool census #24 / band
567
+ * groups census #25) — not a value fallback. A fill or band created AFTER dividers exist must
568
+ * still land BELOW them, or the divider's seam absorption breaks.
569
+ * 象限の「最初の静的クローム子」正準アンカー (v0.9 §2.5 — ADR-24): マウント済み分割線セグメントの
570
+ * 先頭、無ければアクティブ枠。動的挿入アンカー 2 つ (fill プール census #24 / 帯組 census #25) が
571
+ * 共有する構造的順序定義の単一源 — 値フォールバックではない。分割線マウント後に生成された fill /
572
+ * 帯もその下へ着地しないと、分割線の継ぎ目吸収が破れる。
573
+ */
574
+ const chromeFloorOf = (nodes: LayerNodes): HTMLDivElement | null => nodes.dividers[0] ?? nodes.active
575
+
414
576
  /** Inline container geometry of one x-split ("" clears the property). / x 分割 1 本のコンテナインライン形状 ("" はプロパティ解除)。 */
415
577
  type XSplitStyle = { left: string; right: string; width: string }
416
578
 
417
579
  /** Inline container geometry of one y-split ("" clears the property). / y 分割 1 本のコンテナインライン形状 ("" はプロパティ解除)。 */
418
580
  type YSplitStyle = { top: string; bottom: string; height: string }
419
581
 
420
- /** One write-pass layer context: nodes + tree→viewport maps + clamp extents + container inline geometry on both axes (v0.6 2D quadrants). / 1 書込パスの層コンテキスト (ノード束 + 両軸の木→viewport 写像 + クランプ範囲 + コンテナのインライン形状。v0.6 の 2D 象限)。 */
421
- type LayerCtx = { nodes: LayerNodes; mapX: (treeX: number) => number; clampWidth: number; xStyle: XSplitStyle; mapY: (treeY: number) => number; clampHeight: number; yStyle: YSplitStyle }
582
+ /** Axis band kind (v0.9 §2.5): m = unsplit axis, f = leading frozen, s = scroll, t = trailing frozen. / 軸帯の種別 (v0.9 §2.5): m = 未分割軸、f = 先頭凍結、s = スクロール、t = 末尾凍結。 */
583
+ type AxisKind = "m" | "f" | "s" | "t"
584
+
585
+ /** One write-pass layer context: nodes + axis kinds + tree→viewport maps + clamp extents + container inline geometry on both axes + seam flags (v0.6 2D quadrants; v0.9 adds the kinds for the guide's t-only branch and the seam flags for the marquee's edge-ownership rule). / 1 書込パスの層コンテキスト (ノード束 + 軸種別 + 両軸の木→viewport 写像 + クランプ範囲 + コンテナのインライン形状 + 継ぎ目フラグ。v0.6 の 2D 象限、v0.9 でガイドの t 限定分岐用に種別を、マーキーの縁所有権規則用に継ぎ目フラグを追加)。 */
586
+ type LayerCtx = {
587
+ nodes: LayerNodes
588
+ xKind: AxisKind
589
+ yKind: AxisKind
590
+ mapX: (treeX: number) => number
591
+ clampWidth: number
592
+ xStyle: XSplitStyle
593
+ xSeamLead: boolean
594
+ xSeamTrail: boolean
595
+ mapY: (treeY: number) => number
596
+ clampHeight: number
597
+ yStyle: YSplitStyle
598
+ ySeamLead: boolean
599
+ ySeamTrail: boolean
600
+ }
422
601
 
423
- /** One-axis split segment (kind + tree→viewport map + clamp extent + container inline geometry). / 片軸の分割セグメント (種別 + 木→viewport 写像 + クランプ範囲 + コンテナのインライン形状)。 */
424
- type AxisSplit<S> = { kind: "m" | "f" | "s"; map: (tree: number) => number; clamp: number; style: S }
602
+ /**
603
+ * One-axis split segment: kind + tree→viewport map + clamp extent + container inline geometry +
604
+ * seam flags. `seamLead` / `seamTrail` mark the clip edges where ANOTHER band adjoins (a pane
605
+ * seam): f trails into s, s leads from f (F > 0) and trails into t (T > 0), t leads from s; an
606
+ * unsplit m axis and the viewport edges have no seams. Static per split-cache build — the
607
+ * marquee's edge-ownership rule reads them per frame without allocating.
608
+ * 片軸の分割セグメント (種別 + 木→viewport 写像 + クランプ範囲 + コンテナのインライン形状 +
609
+ * 継ぎ目フラグ)。`seamLead` / `seamTrail` は**別の帯が隣接する**クリップ辺 (ペイン継ぎ目) の印 —
610
+ * f の後端は s、s の先端は f (F > 0)・後端は t (T > 0)、t の先端は s。未分割 m 軸と viewport 端は
611
+ * 継ぎ目なし。分割キャッシュ構築時の静的値 — マーキーの縁所有権規則が毎フレーム無確保で読む。
612
+ */
613
+ type AxisSplit<S> = { kind: AxisKind; map: (tree: number) => number; clamp: number; style: S; seamLead: boolean; seamTrail: boolean }
425
614
 
426
- /** Split-descriptor cache: quadrant contexts keyed on the freeze/viewport geometry (scroll-free — the split maps read the live scroll snapshot). / 分割記述子キャッシュ: 凍結 / viewport 幾何をキーにした象限コンテキスト (スクロール非依存 — 写像は生スクロール快照を読む)。 */
427
- type SplitCache = { frozenCols: number; frozenWidth: number; frozenRows: number; frozenHeight: number; bodyWidth: number; bodyHeight: number; ctxs: LayerCtx[] }
615
+ /** Split-descriptor cache: quadrant contexts keyed on the freeze/viewport geometry incl. the v0.9 trailing family (scroll-free — the split maps read the live scroll snapshot; a trailing track resize moves trailingWidth and rebuilds). / 分割記述子キャッシュ: 末尾族 (v0.9) 込みの凍結 / viewport 幾何をキーにした象限コンテキスト (スクロール非依存 — 写像は生スクロール快照を読む。末尾トラックのリサイズは trailingWidth 経由で再構築)。 */
616
+ type SplitCache = {
617
+ frozenCols: number
618
+ frozenWidth: number
619
+ frozenRows: number
620
+ frozenHeight: number
621
+ trailingCols: number
622
+ trailingWidth: number
623
+ trailingVisibleWidth: number
624
+ trailingRows: number
625
+ trailingHeight: number
626
+ trailingVisibleHeight: number
627
+ bodyWidth: number
628
+ bodyHeight: number
629
+ ctxs: LayerCtx[]
630
+ }
428
631
 
429
632
  /** Main-band container geometry per axis (no freeze — module constants so a cache rebuild allocates none). / 各軸の主帯コンテナ形状 (非凍結 — キャッシュ再構築でも確保しないモジュール定数)。 */
430
633
  const X_MAIN_STYLE: XSplitStyle = { left: "0px", right: "0px", width: "" }
431
634
  const Y_MAIN_STYLE: YSplitStyle = { top: "0px", bottom: "0px", height: "" }
432
635
 
433
636
  /** Empty resident-node set. / 空の常設ノード束。 */
434
- const emptyLayerNodes = (): LayerNodes => ({ container: null, active: null, dragRect: null, marquee: null, fillPreview: null, resizeGuide: null, fillPool: [], bandPool: [], dragBand: null, bandClampWidth: -1, bandClampHeight: -1 })
637
+ const emptyLayerNodes = (): LayerNodes => ({
638
+ container: null,
639
+ active: null,
640
+ dragRect: null,
641
+ marquee: null,
642
+ fillPreview: null,
643
+ resizeGuide: null,
644
+ fillPool: [],
645
+ bandPool: [],
646
+ dragBand: null,
647
+ bandClampWidth: -1,
648
+ bandClampHeight: -1,
649
+ dividerSlots: [null, null, null, null],
650
+ dividerShown: [false, false, false, false],
651
+ dividers: [],
652
+ })
435
653
 
436
- /** All quadrant keys ever rendered (xKind + yKind; m = no freeze on that axis). / 描画され得る全象限キー (xKind + yKind、m = 当該軸非凍結)。 */
437
- const QUADRANT_KEYS = ["mm", "fm", "sm", "mf", "ms", "ff", "sf", "fs", "ss"] as const
654
+ /** All quadrant keys ever rendered — the full {m,f,s,t}² pre-seed (v0.9 §2.5: identity-stable ref-bundle life structure). / 描画され得る全象限キー — {m,f,s,t}² のフル事前シード (v0.9 §2.5: identity 安定な ref 束の生活構造)。 */
655
+ const QUADRANT_KEYS = ["mm", "mf", "ms", "mt", "fm", "ff", "fs", "ft", "sm", "sf", "ss", "st", "tm", "tf", "ts", "tt"] as const
438
656
 
439
657
  /** Fill-handle square size in px (§8.1). / fill ハンドルの一辺 (px)。 */
440
658
  const FILL_HANDLE_SIZE = 6
@@ -462,6 +680,8 @@ export const SelectionOverlay = ({
462
680
  enableFillHandle,
463
681
  frozenCols,
464
682
  frozenRows,
683
+ trailingCols,
684
+ trailingRows,
465
685
  sinkAriaLabel,
466
686
  onSinkMouseDown,
467
687
  editPaintRef,
@@ -541,6 +761,29 @@ export const SelectionOverlay = ({
541
761
  healBandGeometry(nodes.dragBand, clampWidth, clampHeight)
542
762
  }
543
763
  }, [])
764
+ /**
765
+ * Live axis models for the cached t maps (render-time refresh — a build-time closure capture
766
+ * would go stale across a sheet switch whose freeze/viewport cache key happens to match).
767
+ * キャッシュ済み t 写像が読む軸モデルの生 ref (レンダー毎更新 — 構築時の閉包捕捉は、凍結 /
768
+ * viewport キーが偶然一致するシート切替で stale になる)。
769
+ */
770
+ const axesRef = useRef({ rowAxis, colAxis })
771
+ axesRef.current = { rowAxis, colAxis }
772
+ /**
773
+ * Equality-suppressed `display` heal of one divider slot (v0.9 §2.4): visibility is
774
+ * band-px-driven per write pass, written ONLY on a flip — the "zero divider style writes
775
+ * during a steady wheel window" invariant holds by construction.
776
+ * 分割線スロット 1 つの等値抑制 `display` heal (v0.9 §2.4): 可視は書込パス毎の帯 px 駆動で、
777
+ * 書くのはフリップ時だけ — 「定常 wheel 窓中の分割線 style 書込 = 0」不変量が構造で立つ。
778
+ */
779
+ const healDivider = useCallback((nodes: LayerNodes, slot: number, shown: boolean) => {
780
+ const node = nodes.dividerSlots[slot]
781
+ if (node === null || nodes.dividerShown[slot] === shown) {
782
+ return
783
+ }
784
+ nodes.dividerShown[slot] = shown
785
+ node.style.display = shown ? "" : "none"
786
+ }, [])
544
787
  /** Live scroll snapshot the cached split maps read (`layersFor` refreshes it on every call). / キャッシュ済み分割写像が読む生スクロール快照 (`layersFor` が毎呼び更新)。 */
545
788
  const scrollSnapshotRef = useRef({ x: 0, y: 0 })
546
789
  /** Split-descriptor cache — invalidated by freeze / viewport geometry change, never by scroll. / 分割記述子キャッシュ — 凍結 / viewport 幾何の変化で無効化 (スクロールでは無効化しない)。 */
@@ -558,7 +801,7 @@ export const SelectionOverlay = ({
558
801
  const layersFor = useCallback(
559
802
  (hx: number, vy: number): LayerCtx[] => {
560
803
  const handle = handleRef.current
561
- const frozen = handle?.getFrozenSize() ?? { cols: 0, width: 0, rows: 0, height: 0 }
804
+ const frozen = handle?.getFrozenSize() ?? EMPTY_FROZEN_SIZE
562
805
  const viewport = handle?.getViewportSize()
563
806
  const bodyWidth = viewport?.width ?? -1
564
807
  const bodyHeight = viewport?.height ?? -1
@@ -567,42 +810,128 @@ export const SelectionOverlay = ({
567
810
  scroll.x = hx
568
811
  scroll.y = vy
569
812
  let cache = splitCacheRef.current
570
- if (cache === null || cache.frozenCols !== frozen.cols || cache.frozenWidth !== frozen.width || cache.frozenRows !== frozen.rows || cache.frozenHeight !== frozen.height || cache.bodyWidth !== bodyWidth || cache.bodyHeight !== bodyHeight) {
813
+ if (
814
+ cache === null ||
815
+ cache.frozenCols !== frozen.cols ||
816
+ cache.frozenWidth !== frozen.width ||
817
+ cache.frozenRows !== frozen.rows ||
818
+ cache.frozenHeight !== frozen.height ||
819
+ cache.trailingCols !== frozen.trailingCols ||
820
+ cache.trailingWidth !== frozen.trailingWidth ||
821
+ cache.trailingVisibleWidth !== frozen.trailingVisibleWidth ||
822
+ cache.trailingRows !== frozen.trailingRows ||
823
+ cache.trailingHeight !== frozen.trailingHeight ||
824
+ cache.trailingVisibleHeight !== frozen.trailingVisibleHeight ||
825
+ cache.bodyWidth !== bodyWidth ||
826
+ cache.bodyHeight !== bodyHeight
827
+ ) {
571
828
  // 幾何が変わったときだけ記述子を再構築 (最熱経路の毎 repaint 閉包 / spread / 配列確保を
572
- // 断つ GC 圧対策)。x 軸分割は非凍結が主帯 1 本、凍結は 凍結帯 [0, W_F) + スクロール帯
573
- // [W_F, →) — y 軸も同型
829
+ // 断つ GC 圧対策)。x 軸分割 (v0.9 §2.5): 両凍結なしは主帯 1 本、それ以外は
830
+ // [f?] + [s] + [t?] — 末尾のみの軸は s + t (kind m は「未分割軸」の意を保つ)。
831
+ // vis = handle の trailingVisible* (ADR-19 の単一源 — 書込パスはフィールド名で引用)
832
+ const visX = frozen.trailingVisibleWidth
833
+ const visY = frozen.trailingVisibleHeight
834
+ // 継ぎ目フラグの導出 (縁所有権規則の読み先): 帯クリップは軸を互いに素な半開区間
835
+ // f [0, W_F) / s [W_F, extent − vis) / t [extent − vis, extent) に分割する — 別の帯が
836
+ // 隣接する辺だけが継ぎ目 (m 軸と viewport 端は継ぎ目なし)。s の両端は F / T の実在で決まる
574
837
  const xSplits: AxisSplit<XSplitStyle>[] =
575
- frozen.cols <= 0
576
- ? [{ kind: "m", map: (treeX) => treeX - scroll.x, clamp: bodyWidth, style: X_MAIN_STYLE }]
838
+ frozen.cols <= 0 && frozen.trailingCols <= 0
839
+ ? [{ kind: "m", map: (treeX) => treeX - scroll.x, clamp: bodyWidth, style: X_MAIN_STYLE, seamLead: false, seamTrail: false }]
577
840
  : [
578
- { kind: "f", map: (treeX) => treeX, clamp: frozen.width, style: { left: "0px", width: `${frozen.width}px`, right: "" } },
579
- { kind: "s", map: (treeX) => treeX - scroll.x - frozen.width, clamp: Math.max(0, bodyWidth - frozen.width), style: { left: `${frozen.width}px`, right: "0px", width: "" } },
841
+ ...(frozen.cols > 0 ? [{ kind: "f" as const, map: (treeX: number) => treeX, clamp: frozen.width, style: { left: "0px", width: `${frozen.width}px`, right: "" }, seamLead: false, seamTrail: true }] : []),
842
+ // s 帯クリップ [W_F, extent − W_T_vis) — clamp = body − W_F − W_T_vis、
843
+ // right = W_T_vis (導出: v0.9 §2.5 の s 変形。T = 0 は vis 0 で v0.8 と byte 一致)
844
+ {
845
+ kind: "s" as const,
846
+ map: (treeX: number) => treeX - scroll.x - frozen.width,
847
+ clamp: Math.max(0, bodyWidth - frozen.width - visX),
848
+ style: { left: `${frozen.width}px`, right: `${visX}px`, width: "" },
849
+ seamLead: frozen.cols > 0,
850
+ seamTrail: frozen.trailingCols > 0,
851
+ },
852
+ // t 帯クリップ [extent − W_T_vis, extent)。band-local 写像の導出 (v0.9 §2.5):
853
+ // エンジン順写像 viewport x = extent − (totalSize − treeX)、象限原点 = extent − vis
854
+ // ⇒ band-local = treeX − totalSize + vis (tree 列 colCount − T の left は vis − W_T
855
+ // に着地 — クリップ左詰めの正しさの値ピン §5.4)。totalSize は axesRef 経由の生読み
856
+ // (スクロール帯トラックのリサイズはキャッシュキー外 — 入力 treeX が鮮度軸で来る
857
+ // ため total も鮮度で読む。W_T / vis の変化はキャッシュキーが再構築する)
858
+ ...(frozen.trailingCols > 0 ? [{ kind: "t" as const, map: (treeX: number) => treeX - axesRef.current.colAxis.totalSize() + visX, clamp: visX, style: { left: "", right: "0px", width: `${visX}px` }, seamLead: true, seamTrail: false }] : []),
580
859
  ]
581
860
  const ySplits: AxisSplit<YSplitStyle>[] =
582
- frozen.rows <= 0
583
- ? [{ kind: "m", map: (treeY) => treeY - scroll.y, clamp: bodyHeight, style: Y_MAIN_STYLE }]
861
+ frozen.rows <= 0 && frozen.trailingRows <= 0
862
+ ? [{ kind: "m", map: (treeY) => treeY - scroll.y, clamp: bodyHeight, style: Y_MAIN_STYLE, seamLead: false, seamTrail: false }]
584
863
  : [
585
- { kind: "f", map: (treeY) => treeY, clamp: frozen.height, style: { top: "0px", height: `${frozen.height}px`, bottom: "" } },
586
- { kind: "s", map: (treeY) => treeY - scroll.y - frozen.height, clamp: Math.max(0, bodyHeight - frozen.height), style: { top: `${frozen.height}px`, bottom: "0px", height: "" } },
864
+ ...(frozen.rows > 0 ? [{ kind: "f" as const, map: (treeY: number) => treeY, clamp: frozen.height, style: { top: "0px", height: `${frozen.height}px`, bottom: "" }, seamLead: false, seamTrail: true }] : []),
865
+ // s 帯クリップ [H_F, extent − H_T_vis) — x の転置双子 (v0.9 §2.5)
866
+ {
867
+ kind: "s" as const,
868
+ map: (treeY: number) => treeY - scroll.y - frozen.height,
869
+ clamp: Math.max(0, bodyHeight - frozen.height - visY),
870
+ style: { top: `${frozen.height}px`, bottom: `${visY}px`, height: "" },
871
+ seamLead: frozen.rows > 0,
872
+ seamTrail: frozen.trailingRows > 0,
873
+ },
874
+ // t 帯 (bottom-anchored の行双子): band-local = treeY − totalSize + vis (v0.9 §2.5)
875
+ ...(frozen.trailingRows > 0 ? [{ kind: "t" as const, map: (treeY: number) => treeY - axesRef.current.rowAxis.totalSize() + visY, clamp: visY, style: { top: "", bottom: "0px", height: `${visY}px` }, seamLead: true, seamTrail: false }] : []),
587
876
  ]
588
877
  const ctxs: LayerCtx[] = []
589
878
  for (const xs of xSplits) {
590
879
  for (const ys of ySplits) {
591
- ctxs.push({ nodes: nodeSetFor(`${xs.kind}${ys.kind}`), mapX: xs.map, clampWidth: xs.clamp, xStyle: xs.style, mapY: ys.map, clampHeight: ys.clamp, yStyle: ys.style })
880
+ ctxs.push({
881
+ nodes: nodeSetFor(`${xs.kind}${ys.kind}`),
882
+ xKind: xs.kind,
883
+ yKind: ys.kind,
884
+ mapX: xs.map,
885
+ clampWidth: xs.clamp,
886
+ xStyle: xs.style,
887
+ xSeamLead: xs.seamLead,
888
+ xSeamTrail: xs.seamTrail,
889
+ mapY: ys.map,
890
+ clampHeight: ys.clamp,
891
+ yStyle: ys.style,
892
+ ySeamLead: ys.seamLead,
893
+ ySeamTrail: ys.seamTrail,
894
+ })
592
895
  }
593
896
  }
594
- cache = { frozenCols: frozen.cols, frozenWidth: frozen.width, frozenRows: frozen.rows, frozenHeight: frozen.height, bodyWidth, bodyHeight, ctxs }
897
+ cache = {
898
+ frozenCols: frozen.cols,
899
+ frozenWidth: frozen.width,
900
+ frozenRows: frozen.rows,
901
+ frozenHeight: frozen.height,
902
+ trailingCols: frozen.trailingCols,
903
+ trailingWidth: frozen.trailingWidth,
904
+ trailingVisibleWidth: frozen.trailingVisibleWidth,
905
+ trailingRows: frozen.trailingRows,
906
+ trailingHeight: frozen.trailingHeight,
907
+ trailingVisibleHeight: frozen.trailingVisibleHeight,
908
+ bodyWidth,
909
+ bodyHeight,
910
+ ctxs,
911
+ }
595
912
  splitCacheRef.current = cache
596
913
  }
914
+ // 分割線の可視述語 (v0.9 §2.4 の表 — スロット固定順 [x-leading, x-trailing, y-leading,
915
+ // y-trailing] = [W_F, W_T, H_F, H_T] の TREE px > 0。全トラック非表示 (px = 0) で消灯、
916
+ // overlap 縮退の消滅は象限クリップが構造で担う)
917
+ const showXLead = frozen.width > 0
918
+ const showXTrail = frozen.trailingWidth > 0
919
+ const showYLead = frozen.height > 0
920
+ const showYTrail = frozen.trailingHeight > 0
597
921
  for (const ctx of cache.ctxs) {
598
922
  // ヒールは毎呼び (遅延 attach / 象限切替の新コンテナへも形状が届く — 同値抑止つき)
599
923
  healContainer(ctx.nodes.container, ctx.xStyle, ctx.yStyle)
600
924
  // 帯セグメントの主軸サイズはコンテナと同じ契機でヒール (§2.4 — 空プールなら無書込)
601
925
  healLayerBands(ctx.nodes, ctx.clampWidth, ctx.clampHeight)
926
+ // 分割線セグメント可視も同パスでヒール (v0.9 §2.4 / §4.10 #14 — 等値抑制つき)
927
+ healDivider(ctx.nodes, 0, showXLead)
928
+ healDivider(ctx.nodes, 1, showXTrail)
929
+ healDivider(ctx.nodes, 2, showYLead)
930
+ healDivider(ctx.nodes, 3, showYTrail)
602
931
  }
603
932
  return cache.ctxs
604
933
  },
605
- [handleRef, nodeSetFor, healContainer, healLayerBands],
934
+ [handleRef, nodeSetFor, healContainer, healLayerBands, healDivider],
606
935
  )
607
936
  /**
608
937
  * Stable ref-callback set for one layer (❗ identity-stable via useMemo — a per-render
@@ -612,6 +941,28 @@ export const SelectionOverlay = ({
612
941
  * React が毎回 detach / attach し、プールだけが空になって DOM ノードが残り、次の repaint で
613
942
  * 塗りが二重化する)。
614
943
  */
944
+ /**
945
+ * Registers one divider segment into its fixed slot and rebuilds the compact `dividers`
946
+ * array in slot (= JSX = DOM) order (v0.9 §2.4 / §2.5). Runs only on mount / unmount of a
947
+ * segment — never per frame. The last-shown cache resets to the JSX initial state
948
+ * (`display: none`) so the first heal after a mount writes exactly once.
949
+ * 分割線セグメント 1 つを固定スロットへ登録し、圧縮配列 `dividers` をスロット (= JSX = DOM)
950
+ * 順で再構成 (v0.9 §2.4 / §2.5)。実行はセグメントのマウント / アンマウント時のみ — 毎フレーム
951
+ * では走らない。直近 shown キャッシュは JSX 初期状態 (`display: none`) へ戻し、マウント後の
952
+ * 最初の heal がちょうど 1 回書けるようにする。
953
+ */
954
+ const setDividerSlot = useCallback((n: LayerNodes, slot: number, el: HTMLDivElement | null) => {
955
+ n.dividerSlots[slot] = el
956
+ // JSX 初期 display は "none" — 等値抑制キャッシュを初期値へ同期 (空砲抑制の防止)
957
+ n.dividerShown[slot] = false
958
+ // 圧縮配列は identity を保ったまま中身だけ再構成 (chromeFloorOf の読み先)
959
+ n.dividers.length = 0
960
+ for (const node of n.dividerSlots) {
961
+ if (node !== null) {
962
+ n.dividers.push(node)
963
+ }
964
+ }
965
+ }, [])
615
966
  const makeLayerRefs = useCallback(
616
967
  (key: string) => {
617
968
  const nodes = () => nodeSetFor(key)
@@ -628,6 +979,12 @@ export const SelectionOverlay = ({
628
979
  n.bandClampHeight = -1
629
980
  }
630
981
  },
982
+ // 分割線 4 スロット (v0.9 §2.4 — 固定スロット順 = JSX 順。存在は件数駆動の JSX 分岐、
983
+ // React が mount / unmount で装着・解除する)
984
+ dividerXLeading: (el: HTMLDivElement | null) => setDividerSlot(nodes(), 0, el),
985
+ dividerXTrailing: (el: HTMLDivElement | null) => setDividerSlot(nodes(), 1, el),
986
+ dividerYLeading: (el: HTMLDivElement | null) => setDividerSlot(nodes(), 2, el),
987
+ dividerYTrailing: (el: HTMLDivElement | null) => setDividerSlot(nodes(), 3, el),
631
988
  active: (el: HTMLDivElement | null) => {
632
989
  nodes().active = el
633
990
  },
@@ -645,7 +1002,7 @@ export const SelectionOverlay = ({
645
1002
  },
646
1003
  }
647
1004
  },
648
- [nodeSetFor],
1005
+ [nodeSetFor, setDividerSlot],
649
1006
  )
650
1007
  // 象限キーごとに identity 安定な ref 束を用意 (空 deps — 毎レンダー生成だと React が detach /
651
1008
  // attach してプールが空になり塗りが二重化する)。JSX は実効 F / R に応じて必要な象限だけ描く
@@ -655,6 +1012,12 @@ export const SelectionOverlay = ({
655
1012
  const overlayFrozenWidth = frozenWidthOf(colAxis, effectiveFrozenCols)
656
1013
  const effectiveFrozenRows = Math.min(Math.max(0, frozenRows), rowAxis.count)
657
1014
  const overlayFrozenHeight = frozenHeightOf(rowAxis, effectiveFrozenRows)
1015
+ // render 面の実効 T / W_T (v0.9 §2.2 の二値系双子 — エンジンクランプと byte 一致の leading 勝ち
1016
+ // min(max(0, T), count − F_eff))。未宣言は能力分岐で 0 (v0.8 の JSX 構造をそのまま再現)
1017
+ const effectiveTrailingCols = trailingCols === undefined ? 0 : Math.min(Math.max(0, trailingCols), colAxis.count - effectiveFrozenCols)
1018
+ const overlayTrailingWidth = trailingWidthOf(colAxis, effectiveTrailingCols)
1019
+ const effectiveTrailingRows = trailingRows === undefined ? 0 : Math.min(Math.max(0, trailingRows), rowAxis.count - effectiveFrozenRows)
1020
+ const overlayTrailingHeight = trailingHeightOf(rowAxis, effectiveTrailingRows)
658
1021
  // 生存中のドラッグ矩形レンジ (null = 非表示)。writeDragFrame が書き、repaintOverlay がスクロール
659
1022
  // 追随の再配置に読む — ドラッグ中ポインタ静止のままホイールすると、fill / アクティブ枠だけが
660
1023
  // onScroll repaint で動き矩形が取り残される desync の対策
@@ -748,9 +1111,9 @@ export const SelectionOverlay = ({
748
1111
  return highlight.row || highlight.column || highlight.intersection ? highlight : null
749
1112
  }, [highlightRef])
750
1113
 
751
- /** Inserts a band group under the layer's fills (before the first fill, else before the active frame). / 帯組を層の fill の下へ挿入 (先頭 fill の手前、無ければアクティブ枠の手前)。 */
1114
+ /** Inserts a band group under the layer's fills (before the first fill, else before the chrome floor — v0.9 §2.5 census #25: the old `nodes.active` anchor could not land a band group below a mounted divider). / 帯組を層の fill の下へ挿入 (先頭 fill の手前、無ければ静的クローム床の手前 — v0.9 §2.5 census #25: 旧 `nodes.active` 直指しではマウント済み分割線の下へ帯組を入れられない)。 */
752
1115
  const insertBandGroup = useCallback((container: HTMLDivElement, nodes: LayerNodes, group: BandNodes) => {
753
- const before = nodes.fillPool.length > 0 ? nodes.fillPool[0] : nodes.active
1116
+ const before = nodes.fillPool[0]?.node ?? chromeFloorOf(nodes)
754
1117
  for (const node of bandNodeList(group)) {
755
1118
  container.insertBefore(node, before)
756
1119
  }
@@ -873,7 +1236,7 @@ export const SelectionOverlay = ({
873
1236
  [rangeToLayerRect, layersFor, applyRect],
874
1237
  )
875
1238
 
876
- /** Positions the resize guideline nodes (all quadrants — the line is a tree boundary each quadrant maps/clips). / ガイドライン (全象限 — 線は各象限が写像 / クリップする木境界)。 */
1239
+ /** Positions the resize guideline nodes: a "leading" guide paints in ALL quadrants through each committed map (byte-identical v0.8 path, no clamp); a "trailing" guide paints ONLY in quads whose gesture-axis kind is `t`, with the band-local position clamped `max(0, local)` (v0.9 §2.6 / §4.10 #27). / ガイドライン配置: "leading" は全象限を各写像で描く (v0.8 と byte 恒等・無クランプ)、"trailing" はジェスチャ軸 kind = t の象限のみ + band-local を `max(0, local)` へクランプ (v0.9 §2.6 / §4.10 #27)。 */
877
1240
  const positionResizeGuide = useCallback(
878
1241
  (guide: ResizeGuide | null, vy: number, hx: number) => {
879
1242
  for (const ctx of layersFor(hx, vy)) {
@@ -885,14 +1248,32 @@ export const SelectionOverlay = ({
885
1248
  node.style.display = "none"
886
1249
  continue
887
1250
  }
888
- node.style.display = "block"
1251
+ const trailing = guide.anchor === "trailing"
889
1252
  if (guide.axis === "col") {
890
- node.style.transform = `translate(${ctx.mapX(guide.position)}px, 0px)`
1253
+ // 末尾アンカーはジェスチャ軸 kind = t の象限のみ描く (v0.9 §2.6 — 末尾の予定位置を
1254
+ // s / f 写像へ通すと別の木領域で、最大スクロール付近に第 2 の線が出る)
1255
+ if (trailing && ctx.xKind !== "t") {
1256
+ node.style.display = "none"
1257
+ continue
1258
+ }
1259
+ node.style.display = "block"
1260
+ const localX = ctx.mapX(guide.position)
1261
+ // t 分岐のみ max(0, local) クランプ (v0.9 §2.6 — 帯先頭拡大の負 band-local は
1262
+ // overflow: hidden で全ジェスチャ不可視になるため t クリップ近端へピン。
1263
+ // leading 分岐は無クランプのまま = byte 恒等対照)
1264
+ node.style.transform = `translate(${trailing ? Math.max(0, localX) : localX}px, 0px)`
891
1265
  node.style.width = "1px"
892
1266
  node.style.height = "100%"
893
1267
  } else {
894
- // 行線は各象限の mapY で写す (v0.6 — 凍結行帯では静的、スクロール帯では −vy−H_F)
895
- node.style.transform = `translate(0px, ${ctx.mapY(guide.position)}px)`
1268
+ if (trailing && ctx.yKind !== "t") {
1269
+ node.style.display = "none"
1270
+ continue
1271
+ }
1272
+ node.style.display = "block"
1273
+ // 行線は各象限の mapY で写す (v0.6 — 凍結行帯では静的、スクロール帯では −vy−H_F。
1274
+ // v0.9: t 帯では bottom-anchored band-local、末尾アンカーのみ max(0, local))
1275
+ const localY = ctx.mapY(guide.position)
1276
+ node.style.transform = `translate(0px, ${trailing ? Math.max(0, localY) : localY}px)`
896
1277
  node.style.width = "100%"
897
1278
  node.style.height = "1px"
898
1279
  }
@@ -928,7 +1309,7 @@ export const SelectionOverlay = ({
928
1309
  * 保ったまま戻る。閉じたアンカーは加えて `inert` (PopoverLayer) なので閉じた層の DOM は不変。
929
1310
  */
930
1311
  const placeAnchor = useCallback(
931
- (node: HTMLDivElement | null, cell: CellCoord | null, vy: number, hx: number, frozen: { cols: number; width: number; rows: number; height: number }, bodyWidth: number, viewportH: number) => {
1312
+ (node: HTMLDivElement | null, cell: CellCoord | null, ctxX: AxisForwardCtx, ctxY: AxisForwardCtx, frozen: FrozenSnapshot, bodyWidth: number, viewportH: number) => {
932
1313
  if (node === null) {
933
1314
  return
934
1315
  }
@@ -936,13 +1317,21 @@ export const SelectionOverlay = ({
936
1317
  node.style.clipPath = ANCHOR_HIDDEN_CLIP
937
1318
  return
938
1319
  }
939
- const rawTop = rowToViewportY(rowAxis, cell.row, vy, frozen.rows)
940
- const rawLeft = colToViewportX(colAxis, cell.col, hx, frozen.cols)
1320
+ const rawTop = rowToViewportY(rowAxis, cell.row, ctxY)
1321
+ const rawLeft = colToViewportX(colAxis, cell.col, ctxX)
1322
+ const width = colAxis.sizeOf(cell.col)
1323
+ const height = rowAxis.sizeOf(cell.row)
941
1324
  // 原点が viewport の右 / 下の外、またはスクロール軸のセルが凍結帯の下 (凍結なしは左 / 上の外) ⇒ 非表示
942
1325
  const beyondViewport = rawLeft >= bodyWidth || rawTop >= viewportH
943
1326
  const underFrozenCol = cell.col >= frozen.cols && rawLeft < frozen.width
944
1327
  const underFrozenRow = cell.row >= frozen.rows && rawTop < frozen.height
945
- if (beyondViewport || underFrozenCol || underFrozenRow) {
1328
+ // 末尾帯への侵入 (v0.9 §4.10 #15 — `rawLeft < W_F` の正確なミラー): スクロール軸セルの
1329
+ // 遠端 rawLeft + 幅が帯始端 body − vis を越えたら非表示。第 1 結合子 col < count − T は
1330
+ // load-bearing の除外 — 末尾セル自身は帯内が正位置で、落とすと末尾セルのアンカーが全滅
1331
+ // する (§5.4 陽性ゲート / TF-7)。T = 0 は trailingWidth 0 で恒偽 (v0.8 恒等)
1332
+ const underTrailingCol = frozen.trailingWidth > 0 && cell.col < colAxis.count - frozen.trailingCols && rawLeft + width > bodyWidth - frozen.trailingVisibleWidth
1333
+ const underTrailingRow = frozen.trailingHeight > 0 && cell.row < rowAxis.count - frozen.trailingRows && rawTop + height > viewportH - frozen.trailingVisibleHeight
1334
+ if (beyondViewport || underFrozenCol || underFrozenRow || underTrailingCol || underTrailingRow) {
946
1335
  node.style.clipPath = ANCHOR_HIDDEN_CLIP
947
1336
  return
948
1337
  }
@@ -950,8 +1339,6 @@ export const SelectionOverlay = ({
950
1339
  node.style.clipPath = ""
951
1340
  node.style.transform = `translate(${rawLeft}px, ${rawTop}px)`
952
1341
  // 寸法は変化時だけ (署名比較 — 毎フレームは transform のみ)
953
- const width = colAxis.sizeOf(cell.col)
954
- const height = rowAxis.sizeOf(cell.row)
955
1342
  const signature = `${width} ${height}`
956
1343
  if (node.dataset.aqspAnchorSig !== signature) {
957
1344
  node.dataset.aqspAnchorSig = signature
@@ -977,8 +1364,13 @@ export const SelectionOverlay = ({
977
1364
  const anchors = popoverAnchors.current
978
1365
  const { width: bodyWidth, height: viewportH } = handle.getViewportSize()
979
1366
  const frozen = handle.getFrozenSize()
980
- placeAnchor(interactiveAnchorRef === undefined ? null : interactiveAnchorRef.current, anchors.interactive, vy, hx, frozen, bodyWidth, viewportH)
981
- placeAnchor(tipAnchorRef === undefined ? null : tipAnchorRef.current, anchors.tip, vy, hx, frozen, bodyWidth, viewportH)
1367
+ // 判断時 forward ctx (v0.9 §2.1 — census #15): アンカー配置の呼出元で 1 ハンドル
1368
+ // スナップショットから両軸をその場インライン構築 (跨サイト共有ビルダ禁止 — §5.3。
1369
+ // extent は同スナップショット既読の getViewportSize())
1370
+ const anchorCtxX: AxisForwardCtx = { scroll: hx, frozenCount: frozen.cols, trailingCount: frozen.trailingCols, extent: bodyWidth }
1371
+ const anchorCtxY: AxisForwardCtx = { scroll: vy, frozenCount: frozen.rows, trailingCount: frozen.trailingRows, extent: viewportH }
1372
+ placeAnchor(interactiveAnchorRef === undefined ? null : interactiveAnchorRef.current, anchors.interactive, anchorCtxX, anchorCtxY, frozen, bodyWidth, viewportH)
1373
+ placeAnchor(tipAnchorRef === undefined ? null : tipAnchorRef.current, anchors.tip, anchorCtxX, anchorCtxY, frozen, bodyWidth, viewportH)
982
1374
  },
983
1375
  [popoverAnchors, interactiveAnchorRef, tipAnchorRef, placeAnchor],
984
1376
  )
@@ -988,6 +1380,7 @@ export const SelectionOverlay = ({
988
1380
  * viewport metrics come from cached numbers only.
989
1381
  * 唯一の再描画関数 (両経路共有)。内部で gBCR を呼ばない (キャッシュ数値のみ)。
990
1382
  */
1383
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 最熱の単一書込漏斗 (§6.7.2-3) — v0.9 の 16 象限一般化 + fill 同値抑制 (census #23) が意図的に本体を広げた。象限毎ヘルパへの分割は identity 安定な ref-bundle 生活構造と単一パス規律 (v0.9 §0.6-1) を壊すため、平坦な 1 関数を契約として保つ
991
1384
  const repaintOverlay = useCallback(() => {
992
1385
  const overlay = overlayRef.current
993
1386
  const handle = handleRef.current
@@ -1001,13 +1394,14 @@ export const SelectionOverlay = ({
1001
1394
  // 命令読み (props docstring 参照) — シート切替コミットでも常にリセット済みの最新確定選択
1002
1395
  const state = getSelection()
1003
1396
 
1004
- // 強調帯の対象レンジ (能力なし / 全 off は空 — 確保もしない)
1005
- const highlight = activeHighlight()
1006
- const bandRanges = highlight === null ? NO_RANGES : state.ranges.slice(-BAND_RANGE_CAP)
1007
-
1008
1397
  // 確定塗り (fills) — 層ごとの常設プール (ref 配列) を使い回す (毎 repaint の DOM 生成 /
1009
1398
  // クエリを避ける)。F = 0 は主プール単層で従来恒等
1010
1399
  const layers = layersFor(hx, vy)
1400
+
1401
+ // 強調帯の対象レンジ (能力なし / 全 off は空 — 確保もしない)。上限は象限数キーの実効 cap
1402
+ // (ADR-22 デレート — 4 象限以下は BAND_RANGE_CAP のまま、v0.9 §5.9 手順 3)
1403
+ const highlight = activeHighlight()
1404
+ const bandRanges = highlight === null ? NO_RANGES : state.ranges.slice(-bandRangeCapFor(layers.length))
1011
1405
  for (const ctx of layers) {
1012
1406
  const container = ctx.nodes.container
1013
1407
  if (container === null) {
@@ -1015,32 +1409,55 @@ export const SelectionOverlay = ({
1015
1409
  }
1016
1410
  const fills = ctx.nodes.fillPool
1017
1411
  for (let i = 0; i < Math.max(fills.length, state.ranges.length); i += 1) {
1018
- let node = fills[i]
1019
- if (node === undefined) {
1020
- node = document.createElement("div")
1412
+ let slot = fills[i]
1413
+ if (slot === undefined) {
1414
+ const node = document.createElement("div")
1021
1415
  node.className = "aqsp-selection-fill"
1022
1416
  node.setAttribute("data-aqsp-fill", "")
1023
- // 静的ノード群 (アクティブ枠 / ドラッグ矩形 / マーキー / シンク) の手前へ挿入 —
1024
- // append だと DOM 順でシンクより上に描かれ、Phase 3 のセル面積エディタが半透明
1025
- // fill の下に沈む
1026
- container.insertBefore(node, ctx.nodes.active)
1027
- fills.push(node)
1417
+ // 生成時のみ display を初期化 — 以後は同値キャッシュが管理 (v0.9 §2.5 census #23)
1418
+ node.style.display = "none"
1419
+ // 静的クローム床 (分割線先頭 or アクティブ枠 — chromeFloorOf) の手前へ挿入
1420
+ // (v0.9 §2.5 census #24: 分割線マウント後に生成された fill が分割線の上へ来る
1421
+ // 継ぎ目吸収の破れを塞ぐ)。append だと DOM 順でシンクより上に描かれ、Phase 3 の
1422
+ // セル面積エディタが半透明 fill の下に沈む (従来規律は不変)
1423
+ container.insertBefore(node, chromeFloorOf(ctx.nodes))
1424
+ slot = { node, last: { shown: false, width: -1, height: -1, transform: "" } }
1425
+ fills.push(slot)
1028
1426
  }
1029
1427
  const range = state.ranges[i]
1030
1428
  const rect = range === undefined ? null : rangeToLayerRect(range, ctx)
1429
+ // fill 書込規律 (v0.9 §2.5 census #23 — placeBandSegments の同値キャッシュのミラー):
1430
+ // display / width / height は変化時だけ書き、transform も同値キャッシュ (§5.9 手順 3
1431
+ // 第 1 段リトリート — 純横 / 純縦スクロールで動かない象限の byte 恒等文字列は再書込しない)
1031
1432
  if (rect === null) {
1032
- node.style.display = "none"
1433
+ if (slot.last.shown) {
1434
+ slot.last.shown = false
1435
+ slot.node.style.display = "none"
1436
+ }
1033
1437
  } else {
1034
- node.style.display = "block"
1035
- node.style.transform = `translate(${rect.left}px, ${rect.top}px)`
1036
- node.style.width = `${rect.width}px`
1037
- node.style.height = `${rect.height}px`
1438
+ if (!slot.last.shown) {
1439
+ slot.last.shown = true
1440
+ slot.node.style.display = "block"
1441
+ }
1442
+ const transform = `translate(${rect.left}px, ${rect.top}px)`
1443
+ if (transform !== slot.last.transform) {
1444
+ slot.last.transform = transform
1445
+ slot.node.style.transform = transform
1446
+ }
1447
+ if (rect.width !== slot.last.width) {
1448
+ slot.last.width = rect.width
1449
+ slot.node.style.width = `${rect.width}px`
1450
+ }
1451
+ if (rect.height !== slot.last.height) {
1452
+ slot.last.height = rect.height
1453
+ slot.node.style.height = `${rect.height}px`
1454
+ }
1038
1455
  }
1039
1456
  }
1040
1457
  // プールを生存レンジ数へ収縮 (多レンジ選択の残骸ノードを毎 repaint 走査し続けない —
1041
1458
  // 最熱ループは常に live 選択規模に比例させる)
1042
1459
  while (fills.length > state.ranges.length) {
1043
- fills.pop()?.remove()
1460
+ fills.pop()?.node.remove()
1044
1461
  }
1045
1462
 
1046
1463
  // アクティブセル枠 (常設ノードは ref 直読み — dragRect と同じ規律)
@@ -1061,7 +1478,27 @@ export const SelectionOverlay = ({
1061
1478
  if (marquee === null) {
1062
1479
  continue
1063
1480
  }
1064
- const rect = clip === null ? null : rangeToLayerRect(clip.range, ctx)
1481
+ const mapped = clip === null ? null : rangeToLayerRect(clip.range, ctx)
1482
+ // 縁所有権 (セル罫線の「共有線は隣接 2 セルの一方だけが塗る」畳込みのペイン継ぎ目版)。
1483
+ // 導出: 帯クリップは軸の互いに素な半開区間分割 f [0, W_F) / s [W_F, extent − vis) /
1484
+ // t [extent − vis, extent) — 継ぎ目上のレンジ縁は、レンジの隣接セル列を区間に含む象限
1485
+ // ちょうど 1 つが所有する (区間分割ゆえ所有者は一意で、描画順位 m < s < t < f の
1486
+ // タイブレークは不要 — 順位は重なる画素の上下、所有権は区間の帰属)。非所有側の矩形は
1487
+ // 継ぎ目側でクリップ区間と交差ゼロ — 本体はクリップが切るが、漸進アウトセットは全余白を
1488
+ // 得て ::after のアンツ帯を CLIP_MARQUEE_OUTSET px までクリップ内へ引き戻すため、縁が
1489
+ // 争奪帯 [clamp, clamp + 3) / (−3, 0] にある間だけ継ぎ目の両側に破線が二重化する — t
1490
+ // 写像はスクロール非依存なので (縁が恒常的に 0)、s ペインのスクロール中も幽霊縁が
1491
+ // 継ぎ目に貼り付く。抑止は争奪帯ちょうどに限定: 帯の外の非所有クローンは元よりクリップが
1492
+ // 不可視化しており、書込パターンは v0.8 / T = 0 と byte 恒等のまま (継ぎ目のない m 軸と
1493
+ // viewport 端はフラグ false で常に恒等)
1494
+ const rect =
1495
+ mapped !== null &&
1496
+ ((ctx.xSeamTrail && mapped.left >= ctx.clampWidth && mapped.left < ctx.clampWidth + CLIP_MARQUEE_OUTSET) ||
1497
+ (ctx.xSeamLead && mapped.left + mapped.width <= 0 && mapped.left + mapped.width > -CLIP_MARQUEE_OUTSET) ||
1498
+ (ctx.ySeamTrail && mapped.top >= ctx.clampHeight && mapped.top < ctx.clampHeight + CLIP_MARQUEE_OUTSET) ||
1499
+ (ctx.ySeamLead && mapped.top + mapped.height <= 0 && mapped.top + mapped.height > -CLIP_MARQUEE_OUTSET))
1500
+ ? null
1501
+ : mapped
1065
1502
  if (rect === null) {
1066
1503
  marquee.style.display = "none"
1067
1504
  } else {
@@ -1134,18 +1571,31 @@ export const SelectionOverlay = ({
1134
1571
  if (!eligible || viewportH <= 0 || bodyWidth <= 0) {
1135
1572
  fillHandle.style.display = "none"
1136
1573
  } else {
1137
- // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2)。スクロール軸の隅が
1138
- // 凍結帯の下へ潜った場合は不可視 (minLeft = W_F / minTop = H_F — 凍結軸自身の隅は
1139
- // 0 が下限)
1574
+ // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2、v0.9 で 3 分岐)。
1575
+ // スクロール軸の隅が凍結帯の下へ潜った場合は不可視 (minLeft = W_F / minTop = H_F —
1576
+ // 凍結軸自身の隅は 0 が下限)
1140
1577
  const frozen = handle.getFrozenSize()
1578
+ // 判断時 forward ctx (v0.9 §2.1 — census #16): 同ブロック既読の getViewportSize()
1579
+ // を extent に、1 スナップショットから両軸をその場インライン構築 (§5.3)
1580
+ const fillCtxX: AxisForwardCtx = { scroll: hx, frozenCount: frozen.cols, trailingCount: frozen.trailingCols, extent: bodyWidth }
1581
+ const fillCtxY: AxisForwardCtx = { scroll: vy, frozenCount: frozen.rows, trailingCount: frozen.trailingRows, extent: viewportH }
1141
1582
  const endColIsFrozen = tail.endCol < frozen.cols
1142
1583
  const endRowIsFrozen = tail.endRow < frozen.rows
1143
- const left = colToViewportX(colAxis, tail.endCol, hx, frozen.cols) + colAxis.sizeOf(tail.endCol)
1144
- const top = rowToViewportY(rowAxis, tail.endRow, vy, frozen.rows) + rowAxis.sizeOf(tail.endRow)
1145
- const minLeft = endColIsFrozen ? 0 : frozen.width
1146
- const minTop = endRowIsFrozen ? 0 : frozen.height
1147
- if (top <= minTop || top > viewportH || left <= minLeft || left > bodyWidth) {
1148
- fillHandle.style.display = "none" // 右下隅が viewport 外 / 凍結帯の下 (§8.1)
1584
+ // 厳密分類 (v0.9 §2.7 — FZ21 ミラー): endCol >= count − T が末尾 (境界列 count − T − 1
1585
+ // は SCROLL)。T = 0 は count − 0 = count で恒偽 (v0.8 恒等)
1586
+ const endColIsTrailing = tail.endCol >= colAxis.count - frozen.trailingCols
1587
+ const endRowIsTrailing = tail.endRow >= rowAxis.count - frozen.trailingRows
1588
+ const left = colToViewportX(colAxis, tail.endCol, fillCtxX) + colAxis.sizeOf(tail.endCol)
1589
+ const top = rowToViewportY(rowAxis, tail.endRow, fillCtxY) + rowAxis.sizeOf(tail.endRow)
1590
+ // 床: 凍結隅と末尾隅は 0 (帯内が正位置 — v0.9 §4.10 #16)、スクロール隅は W_F / H_F
1591
+ const minLeft = endColIsFrozen || endColIsTrailing ? 0 : frozen.width
1592
+ const minTop = endRowIsFrozen || endRowIsTrailing ? 0 : frozen.height
1593
+ // 天井 (v0.9 §4.10 #16 — 床の遠双子): スクロール隅が末尾帯 [body − vis, body) へ潜れば
1594
+ // 消灯。末尾隅の天井は body (帯内右端まで可視)。T = 0 は vis 0 で v0.8 の body 天井と一致
1595
+ const maxLeft = endColIsTrailing ? bodyWidth : bodyWidth - frozen.trailingVisibleWidth
1596
+ const maxTop = endRowIsTrailing ? viewportH : viewportH - frozen.trailingVisibleHeight
1597
+ if (top <= minTop || top > maxTop || left <= minLeft || left > maxLeft) {
1598
+ fillHandle.style.display = "none" // 右下隅が viewport 外 / 凍結帯 / 末尾帯の下 (§8.1)
1149
1599
  } else {
1150
1600
  fillHandle.style.display = "block"
1151
1601
  fillHandle.style.transform = `translate(${left - FILL_HANDLE_SIZE + 1}px, ${top - FILL_HANDLE_SIZE + 1}px)`
@@ -1159,10 +1609,15 @@ export const SelectionOverlay = ({
1159
1609
  if (sink !== null) {
1160
1610
  const a = state.active
1161
1611
  const { width: bodyWidth, height: viewportH } = handle.getViewportSize()
1162
- // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2 — 凍結軸の active は静的座標)
1612
+ // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2 — 凍結軸の active は静的座標、
1613
+ // v0.9 で末尾も extent アンカーの静的座標)
1163
1614
  const sinkFrozen = handle.getFrozenSize()
1164
- const rawTop = rowToViewportY(rowAxis, a.row, vy, sinkFrozen.rows)
1165
- const rawLeft = colToViewportX(colAxis, a.col, hx, sinkFrozen.cols)
1615
+ // 判断時 forward ctx (v0.9 §2.1 — census #17): 同ブロック既読の getViewportSize() を
1616
+ // extent に、1 スナップショットから両軸をその場インライン構築 (§5.3)
1617
+ const sinkCtxX: AxisForwardCtx = { scroll: hx, frozenCount: sinkFrozen.cols, trailingCount: sinkFrozen.trailingCols, extent: bodyWidth }
1618
+ const sinkCtxY: AxisForwardCtx = { scroll: vy, frozenCount: sinkFrozen.rows, trailingCount: sinkFrozen.trailingRows, extent: viewportH }
1619
+ const rawTop = rowToViewportY(rowAxis, a.row, sinkCtxY)
1620
+ const rawLeft = colToViewportX(colAxis, a.col, sinkCtxX)
1166
1621
  const top = viewportH > 0 ? Math.min(Math.max(rawTop, 0), Math.max(0, viewportH - 1)) : 0
1167
1622
  const left = bodyWidth > 0 ? Math.min(Math.max(rawLeft, 0), Math.max(0, bodyWidth - 1)) : 0
1168
1623
  sink.style.top = `${top}px`
@@ -1190,7 +1645,13 @@ export const SelectionOverlay = ({
1190
1645
  // スクロール軸の active が凍結帯の下へ潜った場合もエディタを降ろす (両軸)
1191
1646
  const underFrozenCol = a.col >= sinkFrozen.cols && rawLeft < sinkFrozen.width
1192
1647
  const underFrozenRow = a.row >= sinkFrozen.rows && rawTop < sinkFrozen.height
1193
- const editorVisible = editingRef.current && rawTop === top && rawLeft === left && !underFrozenCol && !underFrozenRow
1648
+ // 末尾帯への侵入でもエディタを降ろす遠双子 (v0.9 §4.10 #17 — `rawLeft < W_F` の正確な
1649
+ // ミラー: 遠端 rawLeft + 幅 > 帯始端 body − vis)。第 1 結合子 col < count − T は
1650
+ // load-bearing の除外 — 末尾セル自身は帯内が正位置で、落とすと末尾セル編集が全て
1651
+ // 不可視シンクへの IME 合成になる (§5.4 陽性ゲート / TF-7)。T = 0 は恒偽 (v0.8 恒等)
1652
+ const underTrailingCol = sinkFrozen.trailingWidth > 0 && a.col < colAxis.count - sinkFrozen.trailingCols && rawLeft + colAxis.sizeOf(a.col) > bodyWidth - sinkFrozen.trailingVisibleWidth
1653
+ const underTrailingRow = sinkFrozen.trailingHeight > 0 && a.row < rowAxis.count - sinkFrozen.trailingRows && rawTop + rowAxis.sizeOf(a.row) > viewportH - sinkFrozen.trailingVisibleHeight
1654
+ const editorVisible = editingRef.current && rawTop === top && rawLeft === left && !underFrozenCol && !underFrozenRow && !underTrailingCol && !underTrailingRow
1194
1655
  if (editorVisible !== lastEditingAttrRef.current) {
1195
1656
  lastEditingAttrRef.current = editorVisible
1196
1657
  if (editorVisible) {
@@ -1354,7 +1815,7 @@ export const SelectionOverlay = ({
1354
1815
  // (Spreadsheet の repaintOverlay 呼び) が描く (post-B F2 実測)
1355
1816
  useEffect(() => {
1356
1817
  repaintOverlay()
1357
- }, [repaintOverlay, editing, effectiveFrozenCols, effectiveFrozenRows])
1818
+ }, [repaintOverlay, editing, effectiveFrozenCols, effectiveFrozenRows, effectiveTrailingCols, effectiveTrailingRows])
1358
1819
 
1359
1820
  // シンクの mousedown ガード (idle の 1px 直クリックから armed 選択を守る — §4.2 ADR-4。
1360
1821
  // 所有は SelectionOverlay — §6.10 の変異検証対象)
@@ -1368,26 +1829,34 @@ export const SelectionOverlay = ({
1368
1829
  return () => sink.removeEventListener("mousedown", handler)
1369
1830
  }, [sinkRef, onSinkMouseDown])
1370
1831
 
1371
- // ---- 象限 JSX の構築 (v0.6 計画 §3-4): 各軸の凍結種 × をデカルト積し、必要な象限だけ描く。
1372
- // DOM 順は「後置 = 境界での描画上位」— 主 → 行凍結 → 列凍結 → コーナー の順で凍結ほど後置。
1373
- // v0.5 互換の data-attr フック (main / frozen / scroll) を R = 0 の 3 象限へ温存する ----
1374
- const xKinds =
1375
- effectiveFrozenCols > 0
1376
- ? ([
1377
- { k: "f", style: { left: 0, width: overlayFrozenWidth } },
1378
- { k: "s", style: { left: overlayFrozenWidth, right: 0 } },
1379
- ] as const)
1380
- : ([{ k: "m", style: { left: 0, right: 0 } }] as const)
1381
- const yKinds =
1382
- effectiveFrozenRows > 0
1383
- ? ([
1384
- { k: "f", style: { top: 0, height: overlayFrozenHeight } },
1385
- { k: "s", style: { top: overlayFrozenHeight, bottom: 0 } },
1386
- ] as const)
1387
- : ([{ k: "m", style: { top: 0, bottom: 0 } }] as const)
1388
- // 描画上位の並べ替え (凍結種 f を後置): 主(m) < スクロール(s) < 凍結(f)
1389
- const rank = (k: string): number => (k === "m" ? 0 : k === "s" ? 1 : 2)
1390
- const quadrants = xKinds.flatMap((x) => yKinds.map((y) => ({ key: `${x.k}${y.k}`, style: { ...x.style, ...y.style } }))).sort((a, b) => rank(a.key[0]) + rank(a.key[1]) - (rank(b.key[0]) + rank(b.key[1])))
1832
+ // ---- 象限 JSX の構築 (v0.6 計画 §3-4、v0.9 §2.5 で 3 分割へ一般化): 各軸の帯種 ×
1833
+ // デカルト積で必要な象限だけ描く。DOM 順は「後置 = 境界での描画上位」。
1834
+ // v0.5 互換の data-attr フック (main / frozen / scroll) は mm / fm / sm に据置き ----
1835
+ // x 帯の初期インライン形状 (レンダ面はハンドル読み不可 — 初回マウント ref null): f は
1836
+ // [0, W_F)、s は [W_F, extent − W_T)、t は right アンカーの幅 W_T (いずれも木 px —
1837
+ // 導出: frozenWidthOf / trailingWidthOf の二値系 §2.2)。viewport クリップ時 (overlap) の
1838
+ // vis への補正は書込パス heal (layersFor) が最初のフレームで行う
1839
+ const xKinds: { k: AxisKind; style: { left?: number; right?: number; width?: number } }[] =
1840
+ effectiveFrozenCols > 0 || effectiveTrailingCols > 0
1841
+ ? [
1842
+ ...(effectiveFrozenCols > 0 ? [{ k: "f" as const, style: { left: 0, width: overlayFrozenWidth } }] : []),
1843
+ { k: "s" as const, style: { left: overlayFrozenWidth, right: overlayTrailingWidth } },
1844
+ ...(effectiveTrailingCols > 0 ? [{ k: "t" as const, style: { right: 0, width: overlayTrailingWidth } }] : []),
1845
+ ]
1846
+ : [{ k: "m" as const, style: { left: 0, right: 0 } }]
1847
+ const yKinds: { k: AxisKind; style: { top?: number; bottom?: number; height?: number } }[] =
1848
+ effectiveFrozenRows > 0 || effectiveTrailingRows > 0
1849
+ ? [
1850
+ ...(effectiveFrozenRows > 0 ? [{ k: "f" as const, style: { top: 0, height: overlayFrozenHeight } }] : []),
1851
+ { k: "s" as const, style: { top: overlayFrozenHeight, bottom: overlayTrailingHeight } },
1852
+ ...(effectiveTrailingRows > 0 ? [{ k: "t" as const, style: { bottom: 0, height: overlayTrailingHeight } }] : []),
1853
+ ]
1854
+ : [{ k: "m" as const, style: { top: 0, bottom: 0 } }]
1855
+ // 描画順位 (v0.9 §2.5 — ADR-22): 主 m0 < スクロール s1 < 末尾 t2 < 凍結 f3 (末尾は scroll の
1856
+ // 上・凍結の下 — コンテナ overlap の leading 優先)。sort は安定 — 同順位は構築順 (f → s → t)
1857
+ // で、v0.8 の 4 象限順 (ss < fs < sf < ff 等) と byte 一致する
1858
+ const rank = (k: string): number => (k === "m" ? 0 : k === "s" ? 1 : k === "t" ? 2 : 3)
1859
+ const quadrants = xKinds.flatMap((x) => yKinds.map((y) => ({ xk: x.k, yk: y.k, key: `${x.k}${y.k}`, style: { ...x.style, ...y.style } }))).sort((a, b) => rank(a.key[0]) + rank(a.key[1]) - (rank(b.key[0]) + rank(b.key[1])))
1391
1860
  // R = 0 の 3 象限は v0.5 の data-attr を温存 (E2E / 単体フック互換)。行凍結が入る象限は
1392
1861
  // 汎用 data-aqsp-overlay-quad のみ
1393
1862
  const legacyAttr = (key: string): Record<string, string> => (key === "mm" ? { "data-aqsp-overlay-main": "" } : key === "fm" ? { "data-aqsp-overlay-frozen": "" } : key === "sm" ? { "data-aqsp-overlay-scroll": "" } : {})
@@ -1397,10 +1866,24 @@ export const SelectionOverlay = ({
1397
1866
  // 1 本 §6.7.2-1)。下は v0.4 で追加: 横バー帯がグリッド内在化し、オーバーレイはセル
1398
1867
  // ビューポート (バー帯を除く) だけを覆う
1399
1868
  <div ref={overlayRef} className="aqsp-overlay" data-aqsp-overlay="" style={{ right: SCROLLBAR_WIDTH, bottom: SCROLLBAR_WIDTH }}>
1400
- {quadrants.map(({ key, style }) => {
1869
+ {quadrants.map(({ xk, yk, key, style }) => {
1401
1870
  const refs = quadRefs[key]
1402
1871
  return (
1403
1872
  <div key={key} className="aqsp-overlay-quad" data-aqsp-overlay-quad={key} {...legacyAttr(key)} style={style} ref={refs.container}>
1873
+ {/* 分割線セグメント (v0.9 §2.4 — ADR-24 挿入のみ): scroll 種象限の子に限定
1874
+ (構造強制 — 中央帯外に分割線画素が存在できず、overlap 縮退では象限クリップ
1875
+ ごと消える)。位置は activeFrame の直前 = 実行時挿入の fill / 帯の後 ⇒
1876
+ 帯 < fill < 分割線 < activeFrame < … が構築だけで立つ。存在は実効件数
1877
+ (render)、可視は layersFor の等値抑制 display heal (帯 px)、静的幾何は
1878
+ 生成時のみ — 以後この 4 ノードへの書込は display フリップだけ。
1879
+ 静的幾何の導出 (§2.4 の表): x-leading は s 象限原点 = W_F に接する縦線
1880
+ (left: 0)、x-trailing は中央帯右端 = extent − W_T_vis (right: 0)、y は転置。
1881
+ 太さは CSS 変数 (--aqsp-divider-width) — 分割線は常に SCROLL 帯の縁画素を
1882
+ 覆い凍結帯セルを覆わない (Sheets 見え) */}
1883
+ {xk === "s" && effectiveFrozenCols > 0 ? <div className="aqsp-freeze-divider aqsp-freeze-divider-v" data-aqsp-freeze-divider="x-leading" style={{ left: 0, top: 0, bottom: 0, display: "none" }} ref={refs.dividerXLeading} /> : null}
1884
+ {xk === "s" && effectiveTrailingCols > 0 ? <div className="aqsp-freeze-divider aqsp-freeze-divider-v" data-aqsp-freeze-divider="x-trailing" style={{ right: 0, top: 0, bottom: 0, display: "none" }} ref={refs.dividerXTrailing} /> : null}
1885
+ {yk === "s" && effectiveFrozenRows > 0 ? <div className="aqsp-freeze-divider aqsp-freeze-divider-h" data-aqsp-freeze-divider="y-leading" style={{ top: 0, left: 0, right: 0, display: "none" }} ref={refs.dividerYLeading} /> : null}
1886
+ {yk === "s" && effectiveTrailingRows > 0 ? <div className="aqsp-freeze-divider aqsp-freeze-divider-h" data-aqsp-freeze-divider="y-trailing" style={{ bottom: 0, left: 0, right: 0, display: "none" }} ref={refs.dividerYTrailing} /> : null}
1404
1887
  <div className="aqsp-active-frame" data-aqsp-active="" style={{ display: "none" }} ref={refs.active} />
1405
1888
  <div className="aqsp-drag-rect" data-aqsp-drag-rect="" style={{ display: "none" }} ref={refs.dragRect} />
1406
1889
  <div className="aqsp-clip-marquee" data-aqsp-clip-marquee="" style={{ display: "none" }} ref={refs.marquee} />