@aiquants/virtualscroll 3.4.0 → 3.6.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.
@@ -7,21 +7,29 @@
7
7
  * row-axis scale profile (<= 2^53 - 1). Grid semantics (selection / editing / clipboard)
8
8
  * are deliberately NOT owned here — consumers attach them via getCellProps / getRowProps /
9
9
  * contentProps. Design: docs/plans/2026.09.01 virtualgrid-horizontal-axis-design-plan (v6).
10
+ * Since v3.6.0 the grid owns ONE unified two-axis tap circle (axis="xy", corner-anchored via the
11
+ * three-arm placement law) driven by useGridTapScroll; both per-bar circles are suppressed
12
+ * grid-side (docs/plans/2026.09.16 unified-tap-circle plan).
10
13
  *
11
14
  * VirtualGrid — 実証済みの VirtualScroll 行軸に、対称の横列エンジン (同一の兆スケール機構:
12
15
  * 疎 Fenwick 幅木・共有 computeRenderingRanges (BigInt huge 分岐込み)・ANCHOR_REBASE_DISTANCE
13
16
  * の量子化アンカー再基準化・総サイズ px を DOM に着地させない合成スクロール) を合成した
14
17
  * 汎用 2D 仮想化プリミティブ。列数は行軸のスケールプロファイル (≤ 2^53 − 1) を完全継承する。
15
18
  * グリッド操作の意味論 (選択 / 編集 / クリップボード) は意図的に非所有 — 消費側が
16
- * getCellProps / getRowProps / contentProps で装着する。
19
+ * getCellProps / getRowProps / contentProps で装着する。v3.6.0 からグリッドは統合 2 軸
20
+ * タップサークル (axis="xy"・3 アーム則のコーナーアンカー・useGridTapScroll 駆動) を 1 つ
21
+ * 所有し、両バーのサークルはグリッド側で抑止する。
17
22
  */
18
23
 
19
24
  import { type CSSProperties, forwardRef, type HTMLAttributes, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
20
25
  import { twMerge } from "tailwind-merge"
26
+ import type { TapScrollAxisSpeedParams } from "./computeTapScrollVelocity.ts"
21
27
  import { Logger } from "./logger.ts"
22
- import { ScrollBar } from "./ScrollBar.tsx"
28
+ import { resolveTapScrollCircleOptions, ScrollBar, type ScrollBarTapCircleOptions } from "./ScrollBar.tsx"
23
29
  import type { ScrollPaneProps } from "./ScrollPane.tsx"
30
+ import { TapScrollCircle } from "./TapScrollCircle.tsx"
24
31
  import { useFenwickMapTree } from "./useFenwickMapTree.ts"
32
+ import { GRID_TAP_CIRCLE_DEFAULT_OFFSET, useGridTapScroll } from "./useGridTapScroll.ts"
25
33
  import { minmax } from "./utils.ts"
26
34
  import { ANCHOR_REBASE_DISTANCE, computeRenderingRanges, MAX_RENDERED_ITEMS, VirtualScroll, type VirtualScrollBehaviorOptions, type VirtualScrollHandle, type VirtualScrollRange, type VirtualScrollScrollBarOptions, ZERO_HEIGHT_RUN_LIMIT } from "./VirtualScroll.tsx"
27
35
 
@@ -89,6 +97,36 @@ export const MAX_FROZEN_LEADING_COLS = 128
89
97
  */
90
98
  export const MAX_FROZEN_LEADING_ROWS = 128
91
99
 
100
+ /**
101
+ * Hard cap for `frozenTrailingCols` (v3.5.0 trailing-freeze plan §3). The trailing band is NOT
102
+ * windowed — every trailing column materializes in every rendered row and is EXEMPT from the
103
+ * MAX_RENDERED_CELLS truncation (the scroll-band budget is reduced by the trailing count
104
+ * instead) — so the cap is what bounds the band: worst case 128 x MAX_RENDERED_ITEMS rows, and
105
+ * W_T <= 128 x MAX_TRACK_SIZE = 2^25 (the measured layout wall; practical W_T <= viewport <<
106
+ * 2^24). Violations fail fast with a RangeError.
107
+ *
108
+ * `frozenTrailingCols` の上限 (3.5.0 末尾凍結プラン §3)。末尾帯は窓化されず全描画行で具現化し、
109
+ * かつ MAX_RENDERED_CELLS の打切り対象外 (代わりにスクロール帯予算を末尾数ぶん控除) のため、
110
+ * 帯を有界にするのはこの上限そのもの: 最悪 128 × MAX_RENDERED_ITEMS 行、W_T ≤ 128 ×
111
+ * MAX_TRACK_SIZE = 2^25 (実測壁ちょうど — 実運用は W_T ≤ viewport ≪ 2^24)。違反は fail-fast の
112
+ * RangeError。
113
+ */
114
+ export const MAX_FROZEN_TRAILING_COLS = 128
115
+
116
+ /**
117
+ * Hard cap for `frozenTrailingRows` (v3.5.0 trailing-freeze plan §3). The trailing-row band is
118
+ * grid-owned and OUT of the embedded VirtualScroll (the SECOND out-of-pane band, below the
119
+ * pane): every band row materializes every rendered column and is exempt from
120
+ * MAX_RENDERED_CELLS (the budget denominator counts band rows instead), so the cap bounds the
121
+ * band itself. Violations fail fast with a RangeError.
122
+ *
123
+ * `frozenTrailingRows` の上限 (3.5.0 末尾凍結プラン §3)。末尾行帯はグリッド所有で埋め込み
124
+ * VirtualScroll の外 (ペインの**下**の第 2 帯外バンド)。帯行は描画列を全て具現化し
125
+ * MAX_RENDERED_CELLS の打切り対象外 (予算分母に帯行数を算入) のため、帯を有界にするのは
126
+ * この上限そのもの。違反は fail-fast の RangeError。
127
+ */
128
+ export const MAX_FROZEN_TRAILING_ROWS = 128
129
+
92
130
  /**
93
131
  * Maximum number of CONSECUTIVE NON-CONVERGENT self-heal flushes. A stable accessor converges in
94
132
  * ONE flush (tree == accessor afterwards), so the next collect pass is clean and RESETS this
@@ -303,6 +341,40 @@ export type VirtualGridProps<T> = {
303
341
  * 通知しゼロ描画)。
304
342
  */
305
343
  frozenLeadingRows?: number
344
+ /**
345
+ * Number of trailing columns frozen at the right edge (v3.5.0 trailing-freeze plan §3;
346
+ * default 0). Integer in [0, MAX_FROZEN_TRAILING_COLS] — RangeError otherwise (fail-fast).
347
+ * Dynamic clamp (documented contract): min(T, colCount − effectiveFrozenCols) — the LEADING
348
+ * band wins the count space. Trailing cells render in a right-anchored clip outside the
349
+ * anchor machinery (band-local lefts); `--aqvs-grid-trailing-width` is written iff T > 0.
350
+ * `scrollToCell` / `initialScrollAnchor` targeting a trailing column are horizontal no-ops
351
+ * (always visible). T = 0 is structurally identical to the pre-trailing DOM.
352
+ * 右端に凍結する末尾列数 (3.5.0 末尾凍結プラン §3。既定 0)。[0, MAX_FROZEN_TRAILING_COLS]
353
+ * の整数 — 違反は RangeError (fail-fast)。動的クランプ (文書化契約): min(T, colCount −
354
+ * effectiveFrozenCols) — カウント空間は**先頭が勝つ**。末尾セルはアンカー機構の外の
355
+ * 右アンカークリップに帯ローカル left で描画し、`--aqvs-grid-trailing-width` は T > 0 の
356
+ * ときのみ書かれる。`scrollToCell` / `initialScrollAnchor` の末尾列狙いは横 no-op
357
+ * (常時可視)。T = 0 は DOM 構造まで従来と恒等。
358
+ */
359
+ frozenTrailingCols?: number
360
+ /**
361
+ * Number of trailing rows pinned at the bottom (v3.5.0 trailing-freeze plan §3; default 0).
362
+ * Integer in [0, MAX_FROZEN_TRAILING_ROWS] — RangeError otherwise (fail-fast). Dynamic
363
+ * clamp (documented contract): min(T, rowCount − effectiveFrozenRows) — leading wins. The
364
+ * band is grid-owned BELOW the pane; the embedded scroll rows shrink at the TAIL only
365
+ * (itemCount = rowCount − R − T, getItem = i + R unchanged), so a runtime T change does NOT
366
+ * remount the embedded VirtualScroll (ADR-23). `scrollToCell` / `initialScrollAnchor`
367
+ * targeting a trailing row are vertical no-ops (always visible). T = 0 is structurally
368
+ * identical to the pre-trailing DOM.
369
+ * 下端に凍結する末尾行数 (3.5.0 末尾凍結プラン §3。既定 0)。[0, MAX_FROZEN_TRAILING_ROWS]
370
+ * の整数 — 違反は RangeError (fail-fast)。動的クランプ (文書化契約): min(T, rowCount −
371
+ * effectiveFrozenRows) — 先頭が勝つ。帯はペインの**下**のグリッド所有バンドで、埋め込み
372
+ * スクロール行は**末尾**だけが伸縮する (itemCount = rowCount − R − T、getItem = i + R
373
+ * 不変) — T の実行時変更は埋め込み VirtualScroll を再マウントしない (ADR-23)。
374
+ * `scrollToCell` / `initialScrollAnchor` の末尾行狙いは縦 no-op (常時可視)。T = 0 は
375
+ * DOM 構造まで従来と恒等。
376
+ */
377
+ frozenTrailingRows?: number
306
378
  /** Row-axis overscan (default 3 — a declared, intentional deviation from the bare VirtualScroll default 15; see the bounding contract). / 行軸 overscan (既定 3 — 素の既定 15 からの宣言済み意図的乖離)。 */
307
379
  overscanRows?: number
308
380
  /** Column-axis overscan (default 3). / 列軸 overscan (既定 3)。 */
@@ -338,7 +410,24 @@ export type VirtualGridHandle = {
338
410
  scrollTo(pos: { x?: number; y?: number }): { x: number; y: number }
339
411
  /** Relative float-accumulating path (wheel semantics; returns the applied clamped position — drag loops consume it synchronously). / 相対 float 累積経路 (ホイール同義。クランプ後位置を同期 return — ドラッグループが消費)。 */
340
412
  scrollBy(delta: { x?: number; y?: number }): { x: number; y: number }
341
- /** Cell-targeted jump. alignY uses the row vocabulary (top/bottom/center), alignX its horizontal twin (start/end/center). No "nearest". Targeting a FROZEN column is a horizontal no-op that leaves any previously armed column anchor in place (the visual status quo is preserved; manual horizontal input clears anchors as usual). / セル狙いジャンプ。"nearest" は無し。凍結列狙いの横成分は no-op で、既存の列アンカーはそのまま残る (視覚的現状維持 — 手動横入力が従来どおり解除する)。 */
413
+ /**
414
+ * Cell-targeted jump. alignY uses the row vocabulary (top/bottom/center), alignX its
415
+ * horizontal twin (start/end/center). No "nearest". Targeting a LEADING frozen or TRAILING
416
+ * frozen column is a horizontal no-op that leaves any previously armed column anchor in
417
+ * place (the visual status quo is preserved; manual horizontal input clears anchors as
418
+ * usual); targeting a leading/trailing frozen row is a vertical no-op. Band membership is
419
+ * judged at CALL time — a T change AFTER arming follows the ADR-23 clamp semantics: a
420
+ * pending row anchor whose target row lands inside the trailing band after a T increase
421
+ * clamps to the last scroll row. Overlap registration (ADR-19-1): when the leading +
422
+ * trailing band extents exceed the viewport, occluded trailing cells cannot be scrolled
423
+ * into view — the no-op is exact there too.
424
+ * セル狙いジャンプ。"nearest" は無し。先頭 / 末尾凍結列狙いの横成分は no-op で、既存の
425
+ * 列アンカーはそのまま残る (視覚的現状維持 — 手動横入力が従来どおり解除する)。先頭 /
426
+ * 末尾凍結行狙いの縦成分も no-op。帯所属の判定は**呼出し時** — 張った後の T 変更は
427
+ * ADR-23 のクランプ意味論 (T 増加で末尾帯入りする行を狙った保留行アンカーは最終
428
+ * スクロール行へクランプ) に従う。重複登記 (ADR-19-1): 先頭 + 末尾の帯寸がビューポートを
429
+ * 超えるとき、隠れた末尾セルはどのスクロール位置でも可視化できない — no-op はそこでも正確。
430
+ */
342
431
  scrollToCell(row: number, col: number, options?: { alignY?: "top" | "bottom" | "center"; alignX?: "start" | "end" | "center"; offsetX?: number; offsetY?: number }): void
343
432
  /** Synchronous-fresh position read (internal refs — safe mid-event; {-1,-1} pre-attach). / 同期・最新の位置読み (内部 ref — イベント中も安全。未接続 {-1,-1})。 */
344
433
  getScrollPosition(): { x: number; y: number }
@@ -347,7 +436,7 @@ export type VirtualGridHandle = {
347
436
  getViewportSize(): { width: number; height: number }
348
437
  getContentSize(): { width: number; height: number }
349
438
  getRange(): VirtualGridRange | null
350
- /** Pair-call seam: getRowHeight stays the truth; delegates to the row updateItemSize. / 対呼び出しシーム (getRowHeight が正) — 行 updateItemSize へ委譲。 */
439
+ /** Pair-call seam: getRowHeight stays the truth. 3-way split — leading band rows bump the frozen-row epoch, trailing band rows bump the trailing-row epoch (accessor re-read; the bands hold no tree), scroll rows delegate to the row updateItemSize at −R. / 対呼び出しシーム (getRowHeight が正)。3 分岐 — 先頭帯行は凍結行 epoch、末尾帯行は末尾行 epoch の繰上げ (アクセサ再読 — 帯に木は無い)、スクロール行は −R で行 updateItemSize へ委譲。 */
351
440
  updateRowSize(row: number, px: number): void
352
441
  /**
353
442
  * Column twin: validates (MAX_TRACK_SIZE fail-fast), updates the width tree, and bumps the
@@ -360,8 +449,25 @@ export type VirtualGridHandle = {
360
449
  * バッチングで 1 コミットに合流 (設計の rAF 合流意図のコミット粒度での実現)。
361
450
  */
362
451
  updateColSize(col: number, px: number): void
363
- /** Effective frozen-band sizes: {cols, width, rows, height} in the CURRENT trees (cols/rows = dynamic clamps, width = W_F px, height = H_F px). Consumers use it for hit-test partitioning on both axes. / 実効凍結帯寸法 {cols, width, rows, height} (cols / rows は動的クランプ後、width = W_F、height = H_F)。消費側の両軸ヒットテスト分割用。 */
364
- getFrozenSize(): { cols: number; width: number; rows: number; height: number }
452
+ /**
453
+ * Effective frozen-band sizes on both axes and both ends (v3.5.0 — a non-breaking field
454
+ * extension of the {cols, width} → {cols, width, rows, height} precedent). cols/rows/
455
+ * trailingCols/trailingRows = dynamic post-clamp counts; width/height/trailingWidth/
456
+ * trailingHeight = TREE px (the trailing sizes are tree SUFFIX sums); trailingVisibleWidth/
457
+ * trailingVisibleHeight = viewport-clipped `min(tree, max(0, viewport − leading))` — THE
458
+ * single source for W_T_vis / H_T_vis that every consumer judgment/write path cites (never
459
+ * re-derive the formula). Pre-attach the grid viewport reads {0, 0}, so the vis fields are
460
+ * 0 (an initial measured state, not a sentinel) while the tree fields stay tree px.
461
+ * 実効凍結帯寸法 — 両軸・両端 (v3.5.0。{cols, width} → {cols, width, rows, height} の
462
+ * 前例に続く非破壊のフィールド追加)。cols / rows / trailingCols / trailingRows は動的
463
+ * クランプ後の実効カウント、width / height / trailingWidth / trailingHeight は**木** px
464
+ * (末尾寸は木の接尾辞和)、trailingVisibleWidth / trailingVisibleHeight はビューポート
465
+ * クリップ `min(木, max(0, viewport − 先頭))` — W_T_vis / H_T_vis の**単一情報源**で、
466
+ * 消費側の判定 / 書込み経路は全てこのフィールドを名指しで読む (式の再導出禁止)。
467
+ * プレアタッチはビューポート {0, 0} のため vis フィールドは 0 (番兵ではなく初期実測状態)、
468
+ * 木フィールドは常に木 px。
469
+ */
470
+ getFrozenSize(): { cols: number; width: number; rows: number; height: number; trailingCols: number; trailingRows: number; trailingWidth: number; trailingHeight: number; trailingVisibleWidth: number; trailingVisibleHeight: number }
365
471
  /** 2-axis wheel bridge entry: vertical → embedded VirtualScroll, horizontal → hx. Returns whether the event was consumed (WheelBridgeTarget contract). / 2 軸ホイールブリッジ口。消費有無を返す (ブリッジ契約)。 */
366
472
  applyWheel(event: WheelEvent): boolean
367
473
  /** Focuses the row wrapper at `row` (scroll rows via the −R-shifted row-side focusItemAtIndex — enableKeyboardNavigation applies; frozen band rows are focused directly and need no option). / 行ラッパーへフォーカス (スクロール行は −R シフトの行側 focusItemAtIndex — enableKeyboardNavigation が前提。凍結帯行は直接フォーカスでオプション不要)。 */
@@ -525,6 +631,58 @@ const collectFrozenColumns = (frozenCols: number, getColWidth: (col: number) =>
525
631
  return { columns, widthUpdates, width: runningLeft }
526
632
  }
527
633
 
634
+ /**
635
+ * Enumerates the trailing frozen band (v3.5.0 trailing-freeze plan §6.1-5 — the suffix twin of
636
+ * collectFrozenColumns): columns [colCount − trailingCols, colCount) with BAND-LOCAL lefts (a
637
+ * running prefix that starts at 0 — the right-anchored inner owns the placement, so trailing
638
+ * cells never take the anchor subtraction; W_T is bounded by 128 x MAX_TRACK_SIZE = 2^25),
639
+ * zero-width columns skipped (no DOM — the shared hidden-track discipline), and width
640
+ * self-heal deltas collected under the same tree-is-position / accessor-is-width split as
641
+ * collectVisibleColumns. Returns width = W_T, the TREE suffix width of the band.
642
+ *
643
+ * 末尾凍結帯の列挙 (3.5.0 末尾凍結プラン §6.1-5 — collectFrozenColumns の接尾辞双子)。列
644
+ * [colCount − trailingCols, colCount) を**帯ローカル** left (0 始まりの running 接頭辞 —
645
+ * 配置は右アンカー inner が担い、末尾セルはアンカー減算を決して取らない。W_T は 128 × 2^18 =
646
+ * 2^25 で有界) で並べ、幅 0 列は DOM 非生成 (非表示トラックの共有規律)、幅 self-heal 差分は
647
+ * collectVisibleColumns と同じ「位置は木・幅はアクセサ」分担で収集する。返す width = W_T
648
+ * (帯の**木**接尾辞幅)。
649
+ */
650
+ const collectTrailingColumns = (colCount: number, trailingCols: number, getColWidth: (col: number) => number, colTree: ReturnType<typeof useFenwickMapTree>): { columns: PlacedColumn[]; widthUpdates: Array<{ index: number; value: number }>; width: number } => {
651
+ const columns: PlacedColumn[] = []
652
+ const widthUpdates: Array<{ index: number; value: number }> = []
653
+ let runningLeft = 0
654
+ // T ≤ MAX_FROZEN_TRAILING_COLS のため素朴な O(T) 走査で十分 (幅 0 ジャンプ機構は不要)
655
+ for (let col = colCount - trailingCols; col < colCount; col += 1) {
656
+ const width = getColWidth(col)
657
+ const cached = colTree.get(col)
658
+ if (width !== cached) {
659
+ widthUpdates.push({ index: col, value: width })
660
+ }
661
+ if (width > 0) {
662
+ columns.push({ col, left: runningLeft, width })
663
+ }
664
+ runningLeft += cached
665
+ }
666
+ return { columns, widthUpdates, width: runningLeft }
667
+ }
668
+
669
+ /**
670
+ * THE single JS source for W_T_vis / H_T_vis (v3.5.0 trailing-freeze plan §2.1-4): clips a
671
+ * trailing band's TREE extent to what the viewport can show after the leading band took its
672
+ * share — `min(trailing, max(0, viewport − leading))`. Consumers are exactly three: the
673
+ * trailing-rows band template height, the hbar paddingRight, and the getFrozenSize
674
+ * trailingVisibleWidth/Height export. Never re-derive the formula elsewhere (the CSS max()
675
+ * left edge of `.aqvs-grid-row-trailing` is the registered calc() equivalent — the ONLY
676
+ * exception, annotated in the stylesheet).
677
+ *
678
+ * W_T_vis / H_T_vis の**単一 JS 源** (3.5.0 末尾凍結プラン §2.1-4)。末尾帯の**木**寸を
679
+ * 「先頭帯が取った残り」へクリップする `min(末尾, max(0, viewport − 先頭))`。消費先は
680
+ * 末尾行帯テンプレート高 / hbar paddingRight / getFrozenSize の vis export の 3 点ちょうど —
681
+ * 式を他所へ複製しない (`.aqvs-grid-row-trailing` の CSS max() 左端だけが登記済みの calc()
682
+ * 等価形で、スタイルシート側にインライン登記済み)。
683
+ */
684
+ const trailingVisibleSize = (trailingSize: number, frozenSize: number, viewportSize: number): number => Math.min(trailingSize, Math.max(0, viewportSize - frozenSize))
685
+
528
686
  /** Internal committed column-window state (anchor rides with the window — the M10 coherence unit). / コミット済み列窓状態 (アンカーは窓と一体 — M10 整合の単位)。 */
529
687
  type ColumnWindowState = {
530
688
  renderingColStart: number
@@ -575,6 +733,8 @@ const VirtualGridInner = <T,>(
575
733
  onRowFocus,
576
734
  frozenLeadingCols = 0,
577
735
  frozenLeadingRows = 0,
736
+ frozenTrailingCols = 0,
737
+ frozenTrailingRows = 0,
578
738
  horizontalKeyInputs,
579
739
  horizontalKeyStep,
580
740
  onScroll,
@@ -650,17 +810,34 @@ const VirtualGridInner = <T,>(
650
810
  /** Fresh W_F for drag loops / clamps (mirrored from the frozen memo each render). / ドラッグループ / クランプ用の鮮度 W_F (凍結 memo から毎レンダー写像)。 */
651
811
  const frozenWidthRef = useRef(0)
652
812
 
813
+ // ---- 末尾凍結列帯 (3.5.0 プラン §6.1-3) — 先頭側と同文の fail-fast。動的クランプは
814
+ // min(T, colCount − 先頭実効) で**先頭がカウント空間で勝つ** (ADR-19-1 の文書化契約) ----
815
+ if (!(Number.isInteger(frozenTrailingCols) && frozenTrailingCols >= 0 && frozenTrailingCols <= MAX_FROZEN_TRAILING_COLS)) {
816
+ throw new RangeError(`[VirtualGrid] frozenTrailingCols must be an integer in [0, ${MAX_FROZEN_TRAILING_COLS}], received ${frozenTrailingCols}.`)
817
+ }
818
+ const effectiveTrailingCols = Math.min(frozenTrailingCols, colCount - effectiveFrozenCols)
819
+ /** Fresh W_T for drag loops / clamps (mirrored from the trailing memo each render). / ドラッグループ / クランプ用の鮮度 W_T (末尾 memo から毎レンダー写像)。 */
820
+ const trailingWidthRef = useRef(0)
821
+
653
822
  // ---- 凍結行帯 (行凍結設計 §4-1) — 列と同文の fail-fast + 動的クランプ ----
654
823
  if (!(Number.isInteger(frozenLeadingRows) && frozenLeadingRows >= 0 && frozenLeadingRows <= MAX_FROZEN_LEADING_ROWS)) {
655
824
  throw new RangeError(`[VirtualGrid] frozenLeadingRows must be an integer in [0, ${MAX_FROZEN_LEADING_ROWS}], received ${frozenLeadingRows}.`)
656
825
  }
657
826
  const effectiveFrozenRows = Math.min(frozenLeadingRows, rowCount)
658
- /** Scroll-row count in the SHIFTED space (design §2 — the embedded VirtualScroll's itemCount). / シフト空間のスクロール行数 (埋め込み VirtualScroll の itemCount)。 */
659
- const scrollRowCount = Math.max(0, rowCount - effectiveFrozenRows)
827
+
828
+ // ---- 末尾凍結行帯 (3.5.0 プラン §6.1-3) — 列側と同文の fail-fast + 先頭勝ちクランプ ----
829
+ if (!(Number.isInteger(frozenTrailingRows) && frozenTrailingRows >= 0 && frozenTrailingRows <= MAX_FROZEN_TRAILING_ROWS)) {
830
+ throw new RangeError(`[VirtualGrid] frozenTrailingRows must be an integer in [0, ${MAX_FROZEN_TRAILING_ROWS}], received ${frozenTrailingRows}.`)
831
+ }
832
+ const effectiveTrailingRows = Math.min(frozenTrailingRows, rowCount - effectiveFrozenRows)
833
+ /** Scroll-row count in the SHIFTED space (design §2; the tail loses T — the embedded VirtualScroll's itemCount). / シフト空間のスクロール行数 (末尾は −T — 埋め込み VirtualScroll の itemCount)。 */
834
+ const scrollRowCount = Math.max(0, rowCount - effectiveFrozenRows - effectiveTrailingRows)
660
835
  /** Degenerate-band flag ref (H_F >= measured viewport — §3.3 の縮退帯と同じ正準空窓へ). / 縮退帯フラグ ref (H_F ≥ 実測ビューポート — §3.3 縮退帯と同じ正準空窓)。 */
661
836
  const degenerateBandRef = useRef(false)
662
837
  /** Fresh H_F for handle reads (mirrored from the band memo each render). / ハンドル読み用の鮮度 H_F (帯 memo から毎レンダー写像)。 */
663
838
  const frozenHeightRef = useRef(0)
839
+ /** Fresh H_T for handle reads (mirrored from the trailing band memo each render). / ハンドル読み用の鮮度 H_T (末尾帯 memo から毎レンダー写像)。 */
840
+ const trailingHeightRef = useRef(0)
664
841
 
665
842
  // ---- 凍結行帯の列挙 (行凍結設計 §4-2) — グリッド所有の帯外バンド。行高の真実は
666
843
  // アクセサ直読 (列帯と違い第 2 の格納庫 = 木 が無いためヒール機構は不要 — 更新経路は
@@ -683,6 +860,28 @@ const VirtualGridInner = <T,>(
683
860
  return { frozenRows: rows, frozenHeight: running }
684
861
  }, [effectiveFrozenRows, frozenRowEpoch, validatedGetRowHeight])
685
862
  frozenHeightRef.current = frozenHeight
863
+
864
+ // ---- 末尾行帯の列挙 (3.5.0 プラン §6.1-11) — 先頭行帯と同形の帯外バンド。木もヒール機構も
865
+ // 持たない (アクセサが唯一の真実 — 先頭行帯の宣言済み非対称と同文)。top は帯ローカル
866
+ // 接頭辞和 (inner の bottom 定着が右アンカーの行版を担う)。高さ 0 行は DOM 非生成 ----
867
+ const [trailingRowEpoch, setTrailingRowEpoch] = useState(0)
868
+ const { trailingRows, trailingHeight } = useMemo(() => {
869
+ void trailingRowEpoch
870
+ if (effectiveTrailingRows === 0) {
871
+ return { trailingRows: [] as Array<{ row: number; top: number; height: number }>, trailingHeight: 0 }
872
+ }
873
+ const rows: Array<{ row: number; top: number; height: number }> = []
874
+ let running = 0
875
+ for (let row = rowCount - effectiveTrailingRows; row < rowCount; row += 1) {
876
+ const height = validatedGetRowHeight(row)
877
+ if (height > 0) {
878
+ rows.push({ row, top: running, height })
879
+ }
880
+ running += height
881
+ }
882
+ return { trailingRows: rows, trailingHeight: running }
883
+ }, [effectiveTrailingRows, rowCount, trailingRowEpoch, validatedGetRowHeight])
884
+ trailingHeightRef.current = trailingHeight
686
885
  /** Shifted row-height accessor for the embedded VirtualScroll (design §2). / 埋め込み VirtualScroll 用のシフト済み行高アクセサ (設計 §2)。 */
687
886
  const scrollGetRowHeight = useCallback((index: number) => validatedGetRowHeight(index + effectiveFrozenRows), [validatedGetRowHeight, effectiveFrozenRows])
688
887
  // 行フォーカス通知はフル行空間へ +R 翻訳する (シフト漏れの是正 — 素通しは R > 0 でフル行 r の
@@ -696,6 +895,8 @@ const VirtualGridInner = <T,>(
696
895
  const shiftedRowFocus = useMemo(() => (hasRowFocus ? (index: number) => onRowFocusRef.current?.(index + effectiveFrozenRows) : undefined), [effectiveFrozenRows, hasRowFocus])
697
896
  /** Reactive key for H_F consumers (memo edge — the ref alone would not invalidate). / H_F 消費 memo の反応辺キー (ref 直読は失効しない)。 */
698
897
  const frozenHeightEpochKey = frozenHeight
898
+ /** Reactive key for H_T consumers (the trailing twin). / H_T 消費 memo の反応辺キー (末尾双子)。 */
899
+ const trailingHeightEpochKey = trailingHeight
699
900
 
700
901
  // ---- R の実行時変更 (R1 是正): シフト空間の再ペアリングは埋め込み木にとって「中間削除」で、
701
902
  // 木の changeSize (末尾意味論) では歪む (実測 +228px の総高汚染)。key 再マウントで木を
@@ -756,8 +957,8 @@ const VirtualGridInner = <T,>(
756
957
  const viewport = explicitViewport ?? measuredViewport
757
958
  const viewportRef = useRef(viewport)
758
959
  viewportRef.current = viewport
759
- /** Degenerate band: H_F fills the measured viewport — scroll rows go canonical-EMPTY (§3.3 縮退帯の縦対称). / 縮退帯: H_F が実測ビューポートを食い尽くす — スクロール行は正準空窓 (§3.3 の縦対称)。 */
760
- const degenerateBand = viewport.height > 0 && viewport.height - frozenHeight <= 0
960
+ /** Degenerate band: H_F + H_T fills the measured viewport — scroll rows go canonical-EMPTY (§3.3 縮退帯の縦対称; 3.5.0 で −H_T を一般化). / 縮退帯: H_F + H_T が実測ビューポートを食い尽くす — スクロール行は正準空窓 (§3.3 の縦対称。3.5.0 で −H_T を一般化)。 */
961
+ const degenerateBand = viewport.height > 0 && viewport.height - frozenHeight - trailingHeight <= 0
761
962
  degenerateBandRef.current = degenerateBand
762
963
  const hasExplicitViewport = explicitViewport !== null
763
964
  useLayoutEffect(() => {
@@ -793,10 +994,31 @@ const VirtualGridInner = <T,>(
793
994
  root.style.setProperty("--aqvs-grid-hx-residual", `${committedAnchorRef.current - hxRef.current}px`)
794
995
  }, [])
795
996
 
997
+ /**
998
+ * Fresh scroll-band width from refs (v3.5.0 trailing-freeze plan §6.1-6 — the ONE ref-space
999
+ * source): viewport minus BOTH band extents. Replaces the three former inline
1000
+ * `max(0, viewport − W_F)` sites (applyHx / refreshColumns / resolveColumnAnchorHx) so a
1001
+ * W_T change can never go stale in just one of them. T = 0 reduces to the pre-trailing form.
1002
+ * ref 空間のスクロール帯幅 (3.5.0 末尾凍結プラン §6.1-6 — ref 面の単一源)。viewport から
1003
+ * 両端の帯寸を控除する。旧 3 箇所のインライン `max(0, viewport − W_F)` を置換し、W_T 変化が
1004
+ * 1 箇所だけ stale 化する余地を構造的に断つ。T = 0 は従来式へ恒等縮退。
1005
+ */
1006
+ const scrollBandWidthRef = useCallback(() => Math.max(0, viewportRef.current.width - frozenWidthRef.current - trailingWidthRef.current), [])
1007
+
1008
+ /**
1009
+ * The ONE extracted x clamp law (v3.6.0 §3.5-7 SSOT): `max(0, totalWidth − W_F − W_T −
1010
+ * bandWidth)`. Consumed by `applyHx` (pure refactor — behavior byte-identical) AND the tap
1011
+ * driver (boundary / pinned detection + `gridScrollable`). No second copy of the law exists.
1012
+ * 唯一抽出の x クランプ則 (v3.6.0 §3.5-7 SSOT)。`applyHx` (純リファクタ — 挙動バイト恒等)
1013
+ * とタップドライバ (境界 / ピン判定 + `gridScrollable`) の両方が消費する。第 2 の複製は
1014
+ * 存在しない。
1015
+ */
1016
+ const getMaxHx = useCallback(() => Math.max(0, totalWidthRef.current - frozenWidthRef.current - trailingWidthRef.current - scrollBandWidthRef()), [scrollBandWidthRef])
1017
+
796
1018
  // ---- 列窓の再計算 (setState は窓 / アンカー変化時のみ — key 比較で bail) ----
797
1019
  const refreshColumns = useCallback(() => {
798
- // 凍結帯 (§8-a): 窓探索は木座標 W_F + hx 始まり・帯幅 = viewport − W_F
799
- const bandWidth = Math.max(0, viewportRef.current.width - frozenWidthRef.current)
1020
+ // 凍結帯 (§8-a): 窓探索は木座標 W_F + hx 始まり・帯幅 = viewport − W_F − W_T (3.5.0 一般化)
1021
+ const bandWidth = scrollBandWidthRef()
800
1022
  const placement = computeColumnPlacement(frozenWidthRef.current + hxRef.current, bandWidth, overscanCols, colCount, validatedGetColWidth, colTree, totalWidthRef.current, columnWindowRef.current.colAnchor)
801
1023
  // 幅 0 の凍結列があると indexAt(W_F + hx) が F 未満へ届く — スクロール窓に凍結列を
802
1024
  // 再出現させない床クランプ (§8-c: 凍結帯の描画は凍結 subtree の専有)。床は F そのもの:
@@ -806,21 +1028,31 @@ const VirtualGridInner = <T,>(
806
1028
  if (effectiveFrozenCols > 0) {
807
1029
  placement.renderingColStart = Math.max(placement.renderingColStart, effectiveFrozenCols)
808
1030
  placement.visibleColStart = Math.max(placement.visibleColStart, effectiveFrozenCols)
809
- // 縮退帯 (帯幅 0 — W_F ≥ viewport) もスクロールセルを描かない状態: 範囲通知を
810
- // 全列凍結と同じ空窓正準形へ揃える (非空の可視窓を報告するとヘッダー同期 /
811
- // liveRegion 消費側がセル無き列を描く — R3 実証の非対称の解消)
812
- if (bandWidth <= 0) {
813
- placement.renderingColStart = colCount
814
- placement.renderingColEnd = colCount - 1
815
- placement.visibleColStart = colCount
816
- placement.visibleColEnd = colCount - 1
817
- }
1031
+ }
1032
+ // 末尾帯の ceiling clamp (3.5.0 プラン §6.1-6 — F 床クランプの鏡像・anti-#4454):
1033
+ // stale-bandWidth や幅 0 末尾列で窓末端が末尾帯へ届いても、スクロール窓に末尾列を
1034
+ // 再出現させない。天井は colCount − T_eff − 1 そのもの
1035
+ if (effectiveTrailingCols > 0) {
1036
+ const ceiling = colCount - effectiveTrailingCols - 1
1037
+ placement.renderingColEnd = Math.min(placement.renderingColEnd, ceiling)
1038
+ placement.visibleColEnd = Math.min(placement.visibleColEnd, ceiling)
1039
+ }
1040
+ // 空窓ガードの一般化 (3.5.0 プラン §6.1-6): F > 0 または T > 0 のとき、縮退帯
1041
+ // (帯幅 ≤ 0 — W_F + W_T ≥ viewport) と clamp の結果 start > end になった窓は
1042
+ // 全列凍結と同じ空窓正準形へ整形する (非空の可視窓を報告するとヘッダー同期 /
1043
+ // liveRegion 消費側がセル無き列を描く — R3 実証の非対称の解消。T のみ縮退の
1044
+ // phantom 窓もここで封じる)
1045
+ if ((effectiveFrozenCols > 0 || effectiveTrailingCols > 0) && (bandWidth <= 0 || placement.renderingColStart > placement.renderingColEnd)) {
1046
+ placement.renderingColStart = colCount
1047
+ placement.renderingColEnd = colCount - 1
1048
+ placement.visibleColStart = colCount
1049
+ placement.visibleColEnd = colCount - 1
818
1050
  }
819
1051
  const key = `${placement.renderingColStart}:${placement.renderingColEnd}:${placement.colAnchor}`
820
1052
  if (key !== columnWindowRef.current.key) {
821
1053
  setColumnWindow({ ...placement, key })
822
1054
  }
823
- }, [overscanCols, colCount, validatedGetColWidth, colTree, effectiveFrozenCols])
1055
+ }, [overscanCols, colCount, validatedGetColWidth, colTree, effectiveFrozenCols, effectiveTrailingCols, scrollBandWidthRef])
824
1056
 
825
1057
  // ---- 横スクロールバーの追従 (rAF 合流の軽量 state — React 毎フレーム再レンダー回避) ----
826
1058
  const [barHx, setBarHx] = useState(0)
@@ -866,9 +1098,10 @@ const VirtualGridInner = <T,>(
866
1098
  // 見積り基点は現在の行窓先頭 (到達済みなら) — マウント前は初期アンカー行。
867
1099
  // リサイズ時に初期アンカー基点のままだと現在位置と別領域の行高で見積もってしまう
868
1100
  const basisRow = rowRangeRef.current?.renderingStartIndex ?? initialAnchorRow
869
- // 凍結行帯はスクロール行の見積り高から控除する (帯外バンド — 設計 §4-3)
870
- return estimateRenderedRowCount(Math.max(0, viewport.height - frozenHeightRef.current), validatedGetRowHeight, rowCount, basisRow, overscanRows)
871
- }, [viewport.height, validatedGetRowHeight, rowCount, initialAnchorRow, overscanRows, frozenHeightEpochKey])
1101
+ // 凍結行帯 (先頭 + 末尾) はスクロール行の見積り高から控除する (帯外バンド — 設計 §4-3、
1102
+ // 3.5.0 プラン §6.1-12: −H_T 欠落は高い末尾帯のマウントコミットで偽打切りを発火させる)
1103
+ return estimateRenderedRowCount(Math.max(0, viewport.height - frozenHeightRef.current - trailingHeightRef.current), validatedGetRowHeight, rowCount, basisRow, overscanRows)
1104
+ }, [viewport.height, validatedGetRowHeight, rowCount, initialAnchorRow, overscanRows, frozenHeightEpochKey, trailingHeightEpochKey])
872
1105
  const effectiveRenderedRows = notifiedRowCount ?? estimatedRowCount
873
1106
 
874
1107
  const buildRange = useCallback((): VirtualGridRange | null => {
@@ -889,8 +1122,9 @@ const VirtualGridInner = <T,>(
889
1122
  scrollX: hxRef.current,
890
1123
  scrollY: vyRef.current,
891
1124
  totalWidth: totalWidthRef.current,
892
- // 帯高は鮮度加算 (格納はスクロール木のみ — 帯行リサイズ後も総高が正しい。R1 是正)
893
- totalHeight: rows.totalHeight + frozenHeightRef.current,
1125
+ // 帯高は鮮度加算 (格納はスクロール木のみ — 帯行リサイズ後も総高が正しい。R1 是正。
1126
+ // 3.5.0: 末尾帯高 H_T も同じ鮮度規律で加算)
1127
+ totalHeight: rows.totalHeight + frozenHeightRef.current + trailingHeightRef.current,
894
1128
  }
895
1129
  }, [])
896
1130
 
@@ -904,10 +1138,10 @@ const VirtualGridInner = <T,>(
904
1138
  // ---- applyHx — 横論理位置の唯一の書き口 (clamp → ref → 残差 var → 列窓 → 通知 → クランプ後値 return) ----
905
1139
  const applyHx = useCallback(
906
1140
  (next: number): number => {
907
- // 凍結帯 (§8): スクロール帯幅 = viewport − W_F。maxHx = (総幅 − W_F) − 帯幅で、
908
- // W_F < viewport のとき従来式 (総幅 − viewport) と一致する (F = 0 で恒等)
909
- const bandWidth = Math.max(0, viewportRef.current.width - frozenWidthRef.current)
910
- const maxHx = Math.max(0, totalWidthRef.current - frozenWidthRef.current - bandWidth)
1141
+ // 凍結帯 (§8): スクロール帯幅 = viewport − W_F − W_T (3.5.0 一般化)。maxHx =
1142
+ // (総幅 − W_F − W_T) − 帯幅で、W_F + W_T < viewport のとき従来式 (総幅 − viewport)
1143
+ // と一致する (F = T = 0 で恒等)。クランプ則の所有者は getMaxHx (v3.6.0 SSOT)
1144
+ const maxHx = getMaxHx()
911
1145
  const clamped = minmax(next, 0, maxHx)
912
1146
  if (clamped !== hxRef.current) {
913
1147
  hxRef.current = clamped
@@ -918,7 +1152,7 @@ const VirtualGridInner = <T,>(
918
1152
  }
919
1153
  return clamped
920
1154
  },
921
- [writeResidual, refreshColumns, scheduleBarSync],
1155
+ [writeResidual, refreshColumns, scheduleBarSync, getMaxHx],
922
1156
  )
923
1157
  const applyHxRef = useRef(applyHx)
924
1158
  applyHxRef.current = applyHx
@@ -936,7 +1170,7 @@ const VirtualGridInner = <T,>(
936
1170
  if (rowRangeRef.current !== null) {
937
1171
  emitRangeChange()
938
1172
  }
939
- }, [frozenHeight, emitRangeChange])
1173
+ }, [frozenHeight, trailingHeight, emitRangeChange])
940
1174
 
941
1175
  // ---- 保留列アンカー (行側 scrollToIndex のアンカー張りの列対): initialScrollAnchor 復元と
942
1176
  // scrollToCell が張り、self-heal / 総幅同期 / リサイズで列左が動くたび paint 前に再ピン留め
@@ -951,13 +1185,14 @@ const VirtualGridInner = <T,>(
951
1185
  const prefix = colTree.prefixSum(safeCol, { materializeOption: { materialize: false } })
952
1186
  const colLeft = prefix.cumulative - prefix.currentValue
953
1187
  const colWidth = prefix.currentValue
954
- // 凍結帯 (§8): 整列先はスクロール帯 [W_F, viewport) — 列座標から W_F を引き、
955
- // 幅もスクロール帯幅で測る (F = 0 では従来式と恒等)
956
- const bandWidth = Math.max(0, viewportRef.current.width - frozenWidthRef.current)
1188
+ // 凍結帯 (§8): 整列先はスクロール帯 [W_F, viewport − W_T) — 列座標から W_F を引き、
1189
+ // 幅もスクロール帯幅 (3.5.0 一般化: −W_T 済み) で測る (F = T = 0 では従来式と恒等。
1190
+ // "end" は目標右端を viewport − W_T へ着地させる — §8 の VALUE 行がピンする形)
1191
+ const bandWidth = scrollBandWidthRef()
957
1192
  const base = anchor.alignX === "end" ? colLeft + colWidth - frozenWidthRef.current - bandWidth : anchor.alignX === "center" ? colLeft + colWidth / 2 - frozenWidthRef.current - bandWidth / 2 : colLeft - frozenWidthRef.current
958
1193
  return base + anchor.offsetX
959
1194
  },
960
- [colTree, colCount],
1195
+ [colTree, colCount, scrollBandWidthRef],
961
1196
  )
962
1197
 
963
1198
  // ---- 幅 self-heal (窓内の getColWidth と木の乖離をバッチ適用 — 行 memo の toUpdateHeights 双子)。
@@ -1023,6 +1258,159 @@ const VirtualGridInner = <T,>(
1023
1258
  }
1024
1259
  }, [frozenWidth, effectiveFrozenCols])
1025
1260
 
1261
+ // ---- 末尾列帯の列挙 (3.5.0 プラン §6.1-7 — 先頭帯 memo の接尾辞双子)。self-heal 差分は
1262
+ // 共有バッチへ append 合流し、暴走ガードは帯専用の独立カウンタ (T = 0 でエピソードを
1263
+ // 閉じる — 先頭帯の「越境リーク遮断」と同文) ----
1264
+ const trailingHealBurstRef = useRef({ count: 0, warned: false })
1265
+ const { trailingColumns, trailingWidth } = useMemo(() => {
1266
+ // widthEpoch は木内容バージョンの反応辺 (先頭帯 memo と同じ規律)
1267
+ void widthEpoch
1268
+ if (effectiveTrailingCols === 0) {
1269
+ // 末尾凍結解除は帯ガードのエピソードも閉じる (先頭帯の R3 実証と同文の遮断)
1270
+ trailingHealBurstRef.current.count = 0
1271
+ trailingHealBurstRef.current.warned = false
1272
+ return { trailingColumns: EMPTY_PLACED_COLUMNS, trailingWidth: 0 }
1273
+ }
1274
+ const { columns, widthUpdates, width } = collectTrailingColumns(colCount, effectiveTrailingCols, validatedGetColWidth, colTree)
1275
+ if (widthUpdates.length > 0) {
1276
+ const burst = trailingHealBurstRef.current
1277
+ if (burst.count >= WIDTH_HEAL_BURST_LIMIT) {
1278
+ if (!burst.warned) {
1279
+ burst.warned = true
1280
+ Logger.warn("[VirtualGrid] getColWidth keeps disagreeing with its own previous values inside the trailing band; width self-heal is suspended until a converged pass re-arms it (unstable accessor).", {
1281
+ trailingCols: effectiveTrailingCols,
1282
+ pendingUpdates: widthUpdates.length,
1283
+ })
1284
+ }
1285
+ } else {
1286
+ burst.count += 1
1287
+ // 共有バッチへ append 合流 (同 tick の先頭帯 / スクロール窓の治癒を上書きで落とさない)
1288
+ pendingWidthUpdatesRef.current = [...(pendingWidthUpdatesRef.current ?? []), ...widthUpdates]
1289
+ Promise.resolve().then(flushWidthUpdates)
1290
+ }
1291
+ } else {
1292
+ trailingHealBurstRef.current.count = 0
1293
+ trailingHealBurstRef.current.warned = false
1294
+ }
1295
+ return { trailingColumns: columns, trailingWidth: width }
1296
+ }, [effectiveTrailingCols, colCount, widthEpoch, validatedGetColWidth, colTree, flushWidthUpdates])
1297
+ trailingWidthRef.current = trailingWidth
1298
+ // 末尾幅 var の書き手 (3.5.0 プラン §6.1-8 — **単一書き手規律**: この effect だけが書く)。
1299
+ // 消費者はクリップ右端 (.aqvs-grid-row-scroll の right)・末尾クリップの max() 左端と inner 幅。
1300
+ // T = 0 は除去 (T = 0 恒等 — CSS 側 0px フォールバックで安全)
1301
+ useLayoutEffect(() => {
1302
+ if (effectiveTrailingCols > 0) {
1303
+ rootRef.current?.style.setProperty("--aqvs-grid-trailing-width", `${trailingWidth}px`)
1304
+ } else {
1305
+ rootRef.current?.style.removeProperty("--aqvs-grid-trailing-width")
1306
+ }
1307
+ }, [trailingWidth, effectiveTrailingCols])
1308
+
1309
+ // ---- 統合 2 軸タップサークル (v3.6.0 — ADR-27..32) ----
1310
+ // 埋め込み VirtualScroll へ転送するオプションのメモ化フォーク (§3.1): tapScrollCircleOptions
1311
+ // だけ enabled: false を強制し、兄弟フィールド (width 等) は必ず生存させる。両バーの
1312
+ // サークルはグリッド側だけで抑止し、単体 VirtualScroll / ScrollPane / ScrollBar は触らない
1313
+ const embeddedScrollBarOptions = useMemo<VirtualScrollScrollBarOptions>(() => ({ ...scrollBarOptions, tapScrollCircleOptions: { ...scrollBarOptions?.tapScrollCircleOptions, enabled: false } }), [scrollBarOptions])
1314
+ const rawTapScrollCircleOptions = scrollBarOptions?.tapScrollCircleOptions
1315
+ // hasCustomMaxSpeedMultiplier は**生**オプションからのピン (§8.3 — ScrollBar stepAutoScroll 内の
1316
+ // 導出の双子)。リゾルバ出力からの導出 (typeof resolved.maxSpeedMultiplier === "number") は
1317
+ // 常に true で、1,200 px/s 自動床を minSpeed へ無言反転させる T1 破壊 (MV-XY28)
1318
+ const hasCustomTapMaxSpeedMultiplier = typeof rawTapScrollCircleOptions?.maxSpeedMultiplier === "number"
1319
+ // 生オプションへ両オフセットのグリッド既定 (−200) を注入してから解決する (§8.4-5)。バー既定
1320
+ // −80/0 はバーアンカー則の符号でありコーナーアンカーでは無意味
1321
+ const gridTapOptions = useMemo<ScrollBarTapCircleOptions>(
1322
+ () => ({
1323
+ ...rawTapScrollCircleOptions,
1324
+ offsetX: rawTapScrollCircleOptions?.offsetX === undefined ? GRID_TAP_CIRCLE_DEFAULT_OFFSET : rawTapScrollCircleOptions.offsetX,
1325
+ offsetY: rawTapScrollCircleOptions?.offsetY === undefined ? GRID_TAP_CIRCLE_DEFAULT_OFFSET : rawTapScrollCircleOptions.offsetY,
1326
+ }),
1327
+ [rawTapScrollCircleOptions],
1328
+ )
1329
+ // 軸別解決 (§8.4-5): resolveTapScrollCircleOptions を軸ごとに 1 回 (itemCount = 軸カウント —
1330
+ // 不変量 8 保存)。視覚フィールドはどちらの結果から読んでも同一 (構造上) — x 側から読む
1331
+ const resolvedTapX = useMemo(() => resolveTapScrollCircleOptions(gridTapOptions, Math.max(0, colCount - effectiveFrozenCols - effectiveTrailingCols)), [gridTapOptions, colCount, effectiveFrozenCols, effectiveTrailingCols])
1332
+ const resolvedTapY = useMemo(() => resolveTapScrollCircleOptions(gridTapOptions, scrollRowCount), [gridTapOptions, scrollRowCount])
1333
+ const tapCircleEnabled = resolvedTapX.enabled
1334
+ const tapMaxDistance = Math.max(resolvedTapX.maxVisualDistance, 1)
1335
+ // 軸別速度パラメータ (§4.1): 今日の 2 バー構成そのもの — x はスクロール帯幅 × 列カウント、
1336
+ // y は埋め込みペイン高 × スクロール行カウント。min/max/curve は共有 (1 オプションオブジェクト)
1337
+ const tapXSpeedParams: TapScrollAxisSpeedParams = {
1338
+ viewportSize: Math.max(0, viewport.width - frozenWidth - trailingWidth),
1339
+ minSpeedMultiplier: resolvedTapX.minSpeedMultiplier,
1340
+ maxSpeedMultiplier: resolvedTapX.maxSpeedMultiplier,
1341
+ hasCustomMaxSpeedMultiplier: hasCustomTapMaxSpeedMultiplier,
1342
+ curve: resolvedTapX.maxSpeedCurve,
1343
+ }
1344
+ const tapYSpeedParams: TapScrollAxisSpeedParams = {
1345
+ viewportSize: Math.max(0, viewport.height - frozenHeight - trailingHeight),
1346
+ minSpeedMultiplier: resolvedTapY.minSpeedMultiplier,
1347
+ maxSpeedMultiplier: resolvedTapY.maxSpeedMultiplier,
1348
+ hasCustomMaxSpeedMultiplier: hasCustomTapMaxSpeedMultiplier,
1349
+ curve: resolvedTapY.maxSpeedCurve,
1350
+ }
1351
+ const getHx = useCallback(() => hxRef.current, [])
1352
+ const getVy = useCallback(() => vyRef.current, [])
1353
+ /**
1354
+ * Fresh y extent through the embedded handle — the same freshness channel the vertical bar
1355
+ * uses (`contentSize − viewportSize` floored at 0). Never re-derived from grid-side trees.
1356
+ * 埋め込みハンドル経由の鮮度 y 延長 — vbar と同じ鮮度チャネル (`contentSize − viewportSize`
1357
+ * の 0 下支え)。グリッド側の木から再導出しない (§3.5-7)。
1358
+ */
1359
+ const getMaxVy = useCallback(() => {
1360
+ const inner = scrollHandleRef.current
1361
+ if (inner === null) {
1362
+ return 0
1363
+ }
1364
+ return Math.max(0, Math.max(0, inner.getContentSize()) - Math.max(0, inner.getViewportSize()))
1365
+ }, [])
1366
+ /**
1367
+ * The y apply seam (§3.5-8): wraps the embedded `scrollBy` AND syncs `vyRef.current =
1368
+ * applied` before returning — the grid handle `scrollBy` y-half twin, symmetric with
1369
+ * `applyHxRef`. Without the sync, x-frame `onScroll({ x, y: vyRef.current })` notifications
1370
+ * during a diagonal drag carry y stale by up to the `callbackThrottleMs` window (MV-XY29).
1371
+ * y 適用シーム (§3.5-8): 埋め込み `scrollBy` をラップし、**返す前に** `vyRef.current =
1372
+ * applied` を同期する — グリッドハンドル `scrollBy` の y 半分の双子で `applyHxRef` と対称。
1373
+ * 同期が無いと対角ドラッグ中の x フレーム通知が stale y を運ぶ (MV-XY29)。
1374
+ */
1375
+ const applyVy = useCallback((delta: number): number => {
1376
+ const inner = scrollHandleRef.current
1377
+ if (inner === null) {
1378
+ // ハンドル未接続時は現在値を返して動かない (グリッドハンドル scrollBy と同じ姿勢)
1379
+ return vyRef.current
1380
+ }
1381
+ const applied = inner.scrollBy(delta)
1382
+ vyRef.current = applied
1383
+ return applied
1384
+ }, [])
1385
+ // gridScrollable (§8.4-6): 動く軸が 1 つあれば十分 — y-only グリッドは x 成分が境界ピンの
1386
+ // まま動くサークルを保持する (MV-XY20/21)。バーを駆動するのと同じ鮮度チャネル (x:
1387
+ // columnWindow / 総幅、y: 縦レンジ state = notifiedRowCount の通知駆動再レンダー) で再計算される
1388
+ const gridScrollable = getMaxHx() > 0 || getMaxVy() > 0
1389
+ // 延長成長の再武装キー (§3.5-3): 保持中の境界パークを鮮度チャネルの成長で再開する
1390
+ const tapXExtentFreshness = `${totalWidth}:${frozenWidth}:${trailingWidth}:${viewport.width}:${columnWindow.key}`
1391
+ const tapYExtentFreshness = `${rowCount}:${notifiedRowCount ?? -1}:${frozenHeight}:${trailingHeight}:${viewport.height}`
1392
+ const { isTapActive, handleTapCircleDragChange, tapCircleHandleRef } = useGridTapScroll({
1393
+ enabled: tapCircleEnabled && gridScrollable,
1394
+ maxDistance: tapMaxDistance,
1395
+ xSpeedParams: tapXSpeedParams,
1396
+ ySpeedParams: tapYSpeedParams,
1397
+ applyHxRef,
1398
+ getHx,
1399
+ getMaxHx,
1400
+ applyVy,
1401
+ getVy,
1402
+ getMaxVy,
1403
+ pendingColAnchorRef,
1404
+ xExtentFreshness: tapXExtentFreshness,
1405
+ yExtentFreshness: tapYExtentFreshness,
1406
+ })
1407
+ // バーの 2 行 opacity memo の複製 (§8.4-5): インライン opacity の着地先はサークルルート
1408
+ // (props 経由) — 値と要素同一性はテストでピン (MV-XY23/27)
1409
+ const tapCircleOpacityValue = useMemo(() => {
1410
+ const baseOpacity = isTapActive ? 1 : 0.8
1411
+ return minmax(baseOpacity * resolvedTapX.opacity, 0, 1)
1412
+ }, [isTapActive, resolvedTapX.opacity])
1413
+
1026
1414
  // ---- ビューポート / 総幅 / 幅エポック変化での再クランプ + 列窓追随。保留列アンカーが
1027
1415
  // あれば位置はアンカーから再導出 (単なる再クランプでは推定空間の hx が残留する) ----
1028
1416
  useLayoutEffect(() => {
@@ -1033,7 +1421,7 @@ const VirtualGridInner = <T,>(
1033
1421
  applyHxRef.current(resolveColumnAnchorHx(pending))
1034
1422
  }
1035
1423
  refreshColumns()
1036
- }, [viewport.width, totalWidth, widthEpoch, frozenWidth, refreshColumns, resolveColumnAnchorHx])
1424
+ }, [viewport.width, totalWidth, widthEpoch, frozenWidth, trailingWidth, refreshColumns, resolveColumnAnchorHx])
1037
1425
 
1038
1426
  // ---- 初期アンカー復元 (横) — 生 px でなく {col, offsetX} (行と同一契約)。マウント時 1 回。
1039
1427
  // 保留列アンカーとして張り、以後の self-heal による列左の実測化へ追随させる ----
@@ -1048,10 +1436,15 @@ const VirtualGridInner = <T,>(
1048
1436
  if (targetCol < effectiveFrozenCols) {
1049
1437
  return
1050
1438
  }
1439
+ // 末尾凍結列狙いも同文の no-op (3.5.0 プラン §6.1-16 — appliedRef を先に立てる現行順序は
1440
+ // 仕様 §7 の登記済み未検証防御と同じ姿勢で維持)
1441
+ if (targetCol >= colCount - effectiveTrailingCols) {
1442
+ return
1443
+ }
1051
1444
  const anchor = { col: targetCol, alignX: "start" as const, offsetX: Math.max(0, initialScrollAnchor.offsetX) }
1052
1445
  pendingColAnchorRef.current = anchor
1053
1446
  applyHxRef.current(resolveColumnAnchorHx(anchor))
1054
- }, [initialScrollAnchor, colCount, resolveColumnAnchorHx, effectiveFrozenCols])
1447
+ }, [initialScrollAnchor, colCount, resolveColumnAnchorHx, effectiveFrozenCols, effectiveTrailingCols])
1055
1448
 
1056
1449
  // ---- 縦軸の結線 ----
1057
1450
  const handleVerticalScroll = useCallback((position: number) => {
@@ -1097,8 +1490,9 @@ const VirtualGridInner = <T,>(
1097
1490
  }, [])
1098
1491
 
1099
1492
  // ---- 描画列の列挙 (配置ごとに 1 回 — 行間で共有) + セル数上限 ----
1100
- // 凍結行帯は全帯行 × 全描画列を具現化する — 予算分母に帯行数を算入 (行凍結設計 §4-7)
1101
- const maxColsPerRow = resolveMaxColsPerRow(__maxRenderedCells, effectiveRenderedRows + frozenRows.length)
1493
+ // 凍結行帯 (先頭 + 末尾) は全帯行 × 全描画列を具現化する — 予算分母に帯行数を算入
1494
+ // (行凍結設計 §4-7、3.5.0 プラン §6.1-13)
1495
+ const maxColsPerRow = resolveMaxColsPerRow(__maxRenderedCells, effectiveRenderedRows + frozenRows.length + trailingRows.length)
1102
1496
  // self-heal の暴走ガード: 乖離→治癒→再乖離の非収束が連続する = getColWidth が自身の
1103
1497
  // 過去値と不一致を繰り返す契約違反 (振動アクセサ)。放置すると治癒→エポック→再走査の
1104
1498
  // マイクロタスクループが無限旋回するため、上限で治癒を停止し 1 回だけ警告する。
@@ -1108,9 +1502,9 @@ const VirtualGridInner = <T,>(
1108
1502
  const { placedColumns, truncatedCols } = useMemo(() => {
1109
1503
  // widthEpoch は木の内容バージョンの反応辺 (木は identity 安定のため値経由では失効しない)
1110
1504
  void widthEpoch
1111
- // 凍結帯 (§8): スクロール帯幅 ≤ 0 (W_F ≥ viewport の縮退) はスクロール列を描かない —
1112
- // 凍結帯自体は独立 memo が描き続ける定義済み縮退
1113
- const bandWidth = Math.max(0, viewport.width - frozenWidth)
1505
+ // 凍結帯 (§8): スクロール帯幅 ≤ 0 (W_F + W_T ≥ viewport の縮退 — 3.5.0 一般化) は
1506
+ // スクロール列を描かない — 凍結帯 / 末尾帯自体は独立 memo が描き続ける定義済み縮退
1507
+ const bandWidth = Math.max(0, viewport.width - frozenWidth - trailingWidth)
1114
1508
  if (colCount === 0 || bandWidth <= 0) {
1115
1509
  return { placedColumns: [] as PlacedColumn[], truncatedCols: 0 }
1116
1510
  }
@@ -1118,8 +1512,8 @@ const VirtualGridInner = <T,>(
1118
1512
  if (columnWindow.renderingColStart > columnWindow.renderingColEnd) {
1119
1513
  return { placedColumns: [] as PlacedColumn[], truncatedCols: 0 }
1120
1514
  }
1121
- // セル予算は凍結列ぶんを控除 (凍結セルも行ごとに具現化するため — 上限は行の総セル数)
1122
- const scrollColsBudget = Math.max(1, maxColsPerRow - frozenColumns.length)
1515
+ // セル予算は凍結列 + 末尾列ぶんを控除 (両帯セルも行ごとに具現化するため — 上限は行の総セル数)
1516
+ const scrollColsBudget = Math.max(1, maxColsPerRow - frozenColumns.length - trailingColumns.length)
1123
1517
  const { columns, widthUpdates, truncated } = collectVisibleColumns(minmax(columnWindow.renderingColStart, 0, colCount - 1), minmax(columnWindow.renderingColEnd, 0, colCount - 1), validatedGetColWidth, colTree, scrollColsBudget)
1124
1518
  if (widthUpdates.length > 0) {
1125
1519
  const burst = healBurstRef.current
@@ -1147,7 +1541,10 @@ const VirtualGridInner = <T,>(
1147
1541
  healBurstRef.current.warned = false
1148
1542
  }
1149
1543
  return { placedColumns: columns, truncatedCols: truncated }
1150
- }, [columnWindow, colCount, viewport.width, frozenWidth, frozenColumns.length, widthEpoch, validatedGetColWidth, colTree, maxColsPerRow, flushWidthUpdates])
1544
+ // deps へ trailingWidth / trailingColumns.length を明示追加 (3.5.0 プラン §6.1-6 / F4):
1545
+ // bandWidth 式とスクロール列予算が同 memo 内で両値を読む — 追加を怠ると列窓キー不変の
1546
+ // T トグルで memo が stale 化し、予算が trailing 分だけ過大に残る
1547
+ }, [columnWindow, colCount, viewport.width, frozenWidth, trailingWidth, frozenColumns.length, trailingColumns.length, widthEpoch, validatedGetColWidth, colTree, maxColsPerRow, flushWidthUpdates])
1151
1548
 
1152
1549
  // 打切りは無言にしない (設計 §3.2 — 行側の病的ガードと違い正当構成で到達し得る)
1153
1550
  const lastTruncationKeyRef = useRef("")
@@ -1156,10 +1553,11 @@ const VirtualGridInner = <T,>(
1156
1553
  const key = `${columnWindow.key}:${truncatedCols}`
1157
1554
  if (key !== lastTruncationKeyRef.current) {
1158
1555
  lastTruncationKeyRef.current = key
1159
- onRenderTruncatedRef.current?.({ renderedCells: (placedColumns.length + frozenColumns.length) * (effectiveRenderedRows + frozenRows.length), droppedCols: truncatedCols })
1556
+ // 打切り分子は両帯セル / 両帯行込みの exactness を維持する (3.5.0 プラン §6.1-13)
1557
+ onRenderTruncatedRef.current?.({ renderedCells: (placedColumns.length + frozenColumns.length + trailingColumns.length) * (effectiveRenderedRows + frozenRows.length + trailingRows.length), droppedCols: truncatedCols })
1160
1558
  }
1161
1559
  }
1162
- }, [truncatedCols, columnWindow.key, placedColumns.length, frozenColumns.length, effectiveRenderedRows, frozenRows.length])
1560
+ }, [truncatedCols, columnWindow.key, placedColumns.length, frozenColumns.length, trailingColumns.length, effectiveRenderedRows, frozenRows.length, trailingRows.length])
1163
1561
 
1164
1562
  // ---- 行レンダラー (VirtualScroll children) — セル left は「絶対 − コミット対象アンカー」 ----
1165
1563
  const colAnchor = columnWindow.colAnchor
@@ -1181,20 +1579,23 @@ const VirtualGridInner = <T,>(
1181
1579
  </div>
1182
1580
  )
1183
1581
  })
1184
- // 凍結帯なし (既定) は従来 DOM と恒等 — 行が残差 translate を運ぶ
1185
- if (effectiveFrozenCols === 0) {
1582
+ // 横帯なし (既定) は従来 DOM と恒等 — 行が残差 translate を運ぶ (中身はバイト恒等)
1583
+ if (effectiveFrozenCols === 0 && effectiveTrailingCols === 0) {
1186
1584
  return (
1187
1585
  <div {...rowProps} className={twMerge("aqvs-grid-row", rowProps?.className)} style={rowProps?.style}>
1188
1586
  {scrollCells}
1189
1587
  </div>
1190
1588
  )
1191
1589
  }
1192
- // 凍結モード (§8-c): 行は静的化し、スクロールセルは左端 W_F の静的クリップ +
1193
- // −W_F 原点シフト付き残差 translate の内側へ。凍結セルは行直下の独立 subtree で
1194
- // 木絶対 left の直接描画 — DOM は**前置** (論理列順 = 読み上げ / フォーカス順)。
1195
- // 描画順は自由: クリップが [0, W_F) への侵入を構造的に遮断するため重なり自体が無い
1590
+ // 横帯モード (§8-c + 3.5.0 プラン §6.1-15): 行は静的化し、スクロールセルは左端 W_F ・
1591
+ // 右端 W_T の静的クリップ + −W_F 原点シフト付き残差 translate の内側へ。凍結セルは
1592
+ // 行直下の独立 subtree で木絶対 left の直接描画 — DOM は**前置** (論理列順 = 読み上げ /
1593
+ // フォーカス順)。末尾セルは右アンカー inner の帯ローカル left で**後置** (同じく論理列順)。
1594
+ // class はモードマーカー (描画トレイト) のまま、data-aqvs-frozen-row は F_eff > 0 のみ
1595
+ // (ADR-25 — 末尾のみのグリッドが凍結列ゼロで属性を出すフック面の嘘の封じ)。
1596
+ // 描画順は自由: クリップが帯への侵入を構造的に遮断するため重なり自体が無い
1196
1597
  return (
1197
- <div {...rowProps} data-aqvs-frozen-row="" className={twMerge("aqvs-grid-row aqvs-grid-row-frozen-host", rowProps?.className)} style={rowProps?.style}>
1598
+ <div {...rowProps} data-aqvs-frozen-row={effectiveFrozenCols > 0 ? "" : undefined} className={twMerge("aqvs-grid-row aqvs-grid-row-frozen-host", rowProps?.className)} style={rowProps?.style}>
1198
1599
  {hidden
1199
1600
  ? null
1200
1601
  : frozenColumns.map(({ col, left, width }) => {
@@ -1211,10 +1612,25 @@ const VirtualGridInner = <T,>(
1211
1612
  <div className="aqvs-grid-row-scroll-inner">{scrollCells}</div>
1212
1613
  </div>
1213
1614
  )}
1615
+ {hidden || effectiveTrailingCols === 0 ? null : (
1616
+ <div className="aqvs-grid-row-trailing">
1617
+ <div className="aqvs-grid-row-trailing-inner">
1618
+ {trailingColumns.map(({ col, left, width }) => {
1619
+ const cellProps = getCellProps?.(rowIndex, col)
1620
+ const style: CSSProperties = { ...cellProps?.style, left, width }
1621
+ return (
1622
+ <div {...cellProps} data-aqvs-trailing-cell="" key={getCellKey ? getCellKey(rowIndex, col) : col} className={twMerge("aqvs-grid-cell", cellProps?.className)} style={style}>
1623
+ {children(getCell(rowIndex, col), rowIndex, col)}
1624
+ </div>
1625
+ )
1626
+ })}
1627
+ </div>
1628
+ </div>
1629
+ )}
1214
1630
  </div>
1215
1631
  )
1216
1632
  },
1217
- [placedColumns, frozenColumns, effectiveFrozenCols, colAnchor, getRowProps, getCellProps, getCellKey, getCell, children, validatedGetRowHeight],
1633
+ [placedColumns, frozenColumns, trailingColumns, effectiveFrozenCols, effectiveTrailingCols, colAnchor, getRowProps, getCellProps, getCellKey, getCell, children, validatedGetRowHeight],
1218
1634
  )
1219
1635
 
1220
1636
  // ---- liveRegion (2D — 文言は消費側所有、静定デバウンス) ----
@@ -1313,15 +1729,17 @@ const VirtualGridInner = <T,>(
1313
1729
  if (inner !== null && rowCount > 0) {
1314
1730
  const targetRow = minmax(Math.trunc(row), 0, rowCount - 1)
1315
1731
  // 凍結行狙いの y は no-op (行凍結設計 §3 — 常時可視。列の §8 no-op と同文)。
1732
+ // 末尾帯行狙いも同文の縦 no-op (帯所属は**呼出し時**判定 — 3.5.0 プラン §3)。
1316
1733
  // スクロール行はシフト空間へ −R 翻訳して委譲する
1317
- if (targetRow >= effectiveFrozenRows) {
1734
+ if (targetRow >= effectiveFrozenRows && targetRow < rowCount - effectiveTrailingRows) {
1318
1735
  inner.scrollToIndex(targetRow - effectiveFrozenRows, { align: options?.alignY ?? "top", offset: options?.offsetY })
1319
1736
  }
1320
1737
  }
1321
1738
  if (colCount > 0) {
1322
1739
  const targetCol = minmax(Math.trunc(col), 0, colCount - 1)
1323
- // 凍結列狙いの x は no-op (§8 — 常時可視。スクロールで到達性が変わらない)
1324
- if (targetCol >= effectiveFrozenCols) {
1740
+ // 凍結列狙いの x は no-op (§8 — 常時可視)。末尾列狙いも横 no-op かつ既存の
1741
+ // 列アンカー残置 (分岐スキップのみ — pendingColAnchorRef は触らない。3.5.0 §3)
1742
+ if (targetCol >= effectiveFrozenCols && targetCol < colCount - effectiveTrailingCols) {
1325
1743
  // 列アンカーを張る (行 scrollToIndex のアンカー張りと同義): 以後の self-heal で
1326
1744
  // 列左が実測化されても狙った列に留まり続ける。手動横入力で解除
1327
1745
  const anchor = { col: targetCol, alignX: options?.alignX ?? "start", offsetX: options?.offsetX ?? 0 }
@@ -1357,15 +1775,19 @@ const VirtualGridInner = <T,>(
1357
1775
  return { row: rowAnchor.index + effectiveFrozenRows, col: found.index, offsetX: Math.max(0, bandStart - colLeft), offsetY: rowAnchor.offsetPx }
1358
1776
  },
1359
1777
  getViewportSize: () => ({ width: viewportRef.current.width, height: viewportRef.current.height }),
1360
- // 縦はインセット込みの行側 getContentSize を単一情報源にする (range.totalHeight は木総高のみ)
1361
- getContentSize: () => ({ width: totalWidthRef.current, height: scrollHandleRef.current === null ? 0 : frozenHeightRef.current + Math.max(0, scrollHandleRef.current.getContentSize()) }),
1778
+ // 縦はインセット込みの行側 getContentSize を単一情報源にする (range.totalHeight は木総高のみ)。
1779
+ // 3.5.0: height = H_F + 埋め込み + H_T
1780
+ getContentSize: () => ({ width: totalWidthRef.current, height: scrollHandleRef.current === null ? 0 : frozenHeightRef.current + trailingHeightRef.current + Math.max(0, scrollHandleRef.current.getContentSize()) }),
1362
1781
  getRange: buildRange,
1363
1782
  updateRowSize: (row, px) => {
1364
1783
  validateTrackSize(px, "row", row)
1365
1784
  // 凍結行はグリッド所有バンド — epoch 繰上げでアクセサ再読 (帯に第 2 の格納庫は
1366
- // 無いため木更新は不要)。スクロール行はシフト空間へ −R 翻訳して委譲する
1785
+ // 無いため木更新は不要)。末尾帯行も同文の epoch 繰上げ (3.5.0 — 3 分岐。T = 0 は
1786
+ // 分岐自体が不成立で従来挙動と恒等)。スクロール行はシフト空間へ −R 翻訳して委譲する
1367
1787
  if (Math.trunc(row) < effectiveFrozenRows) {
1368
1788
  setFrozenRowEpoch((epoch) => epoch + 1)
1789
+ } else if (effectiveTrailingRows > 0 && Math.trunc(row) >= rowCount - effectiveTrailingRows) {
1790
+ setTrailingRowEpoch((epoch) => epoch + 1)
1369
1791
  } else {
1370
1792
  scrollHandleRef.current?.updateItemSize(Math.trunc(row) - effectiveFrozenRows, px)
1371
1793
  }
@@ -1377,23 +1799,38 @@ const VirtualGridInner = <T,>(
1377
1799
  // 窓キー不変の幅変更でもセル配置を失効させる (バッチループは React 自動バッチングで 1 コミット)
1378
1800
  setWidthEpoch((epoch) => epoch + 1)
1379
1801
  },
1380
- getFrozenSize: () => ({ cols: effectiveFrozenCols, width: frozenWidthRef.current, rows: effectiveFrozenRows, height: frozenHeightRef.current }),
1802
+ getFrozenSize: () => ({
1803
+ cols: effectiveFrozenCols,
1804
+ width: frozenWidthRef.current,
1805
+ rows: effectiveFrozenRows,
1806
+ height: frozenHeightRef.current,
1807
+ trailingCols: effectiveTrailingCols,
1808
+ trailingRows: effectiveTrailingRows,
1809
+ trailingWidth: trailingWidthRef.current,
1810
+ trailingHeight: trailingHeightRef.current,
1811
+ // vis は単一 JS 源ヘルパーの呼び出しのみ (式の再導出禁止 — 3.5.0 プラン §2.1-4)
1812
+ trailingVisibleWidth: trailingVisibleSize(trailingWidthRef.current, frozenWidthRef.current, viewportRef.current.width),
1813
+ trailingVisibleHeight: trailingVisibleSize(trailingHeightRef.current, frozenHeightRef.current, viewportRef.current.height),
1814
+ }),
1381
1815
  applyWheel: (event) => scrollHandleRef.current?.applyWheel(event) ?? false,
1382
1816
  focusRowAtIndex: (row, options) => {
1383
- // 凍結行はバンド行 DOM へ直接フォーカス、スクロール行は −R 翻訳して委譲
1817
+ // 凍結行はバンド行 DOM へ直接フォーカス、末尾帯行も帯行 DOM へ直接フォーカス
1818
+ // (3.5.0 — 3 分岐。T = 0 は分岐不成立で従来挙動と恒等)、スクロール行は −R 翻訳して委譲
1384
1819
  const targetRow = Math.trunc(row)
1385
1820
  if (targetRow < effectiveFrozenRows) {
1386
1821
  rootRef.current?.querySelector<HTMLElement>(`[data-aqvs-frozen-band-row="${targetRow}"]`)?.focus()
1822
+ } else if (effectiveTrailingRows > 0 && targetRow >= rowCount - effectiveTrailingRows) {
1823
+ rootRef.current?.querySelector<HTMLElement>(`[data-aqvs-trailing-band-row="${targetRow}"]`)?.focus()
1387
1824
  } else {
1388
1825
  scrollHandleRef.current?.focusItemAtIndex(targetRow - effectiveFrozenRows, options)
1389
1826
  }
1390
1827
  },
1391
1828
  }),
1392
- [rowCount, colCount, colTree, buildRange, commitTotalWidth, resolveColumnAnchorHx, effectiveFrozenCols, effectiveFrozenRows],
1829
+ [rowCount, colCount, colTree, buildRange, commitTotalWidth, resolveColumnAnchorHx, effectiveFrozenCols, effectiveFrozenRows, effectiveTrailingCols, effectiveTrailingRows],
1393
1830
  )
1394
1831
 
1395
1832
  return (
1396
- <div ref={rootRef} className={twMerge("aqvs-grid", effectiveFrozenRows > 0 ? "aqvs-grid-has-frozen-rows" : "", className)} data-testid={testId}>
1833
+ <div ref={rootRef} className={twMerge("aqvs-grid", effectiveFrozenRows > 0 ? "aqvs-grid-has-frozen-rows" : "", effectiveTrailingRows > 0 ? "aqvs-grid-has-trailing-rows" : "", className)} data-testid={testId}>
1397
1834
  {effectiveFrozenRows > 0 ? (
1398
1835
  // 凍結行帯 (行凍結設計 §4-2): グリッド所有の帯外バンド — 行本体は renderRow を
1399
1836
  // そのまま再利用する (F > 0 の凍結セル / クリップ / hx 残差 var は継承で無償追随
@@ -1421,10 +1858,10 @@ const VirtualGridInner = <T,>(
1421
1858
  getItemKey={(i) => i + effectiveFrozenRows}
1422
1859
  getItemHeight={scrollGetRowHeight}
1423
1860
  overscanCount={overscanRows}
1424
- viewportSize={viewport.height > 0 ? Math.max(0, viewport.height - frozenHeight) : undefined}
1861
+ viewportSize={viewport.height > 0 ? Math.max(0, viewport.height - frozenHeight - trailingHeight) : undefined}
1425
1862
  callbackThrottleMs={callbackThrottleMs}
1426
1863
  behaviorOptions={behaviorOptions}
1427
- scrollBarOptions={scrollBarOptions}
1864
+ scrollBarOptions={embeddedScrollBarOptions}
1428
1865
  contentProps={contentProps}
1429
1866
  background={background}
1430
1867
  contentInsets={contentInsets}
@@ -1439,7 +1876,7 @@ const VirtualGridInner = <T,>(
1439
1876
  ? toggleAnchorRef.current !== null && toggleAnchorRef.current.row >= effectiveFrozenRows && scrollRowCount > 0
1440
1877
  ? { index: toggleAnchorRef.current.row - effectiveFrozenRows, offsetPx: toggleAnchorRef.current.offsetPx }
1441
1878
  : undefined
1442
- : initialScrollAnchor !== undefined && scrollRowCount > 0 && Math.trunc(initialScrollAnchor.row) >= effectiveFrozenRows
1879
+ : initialScrollAnchor !== undefined && scrollRowCount > 0 && Math.trunc(initialScrollAnchor.row) >= effectiveFrozenRows && Math.trunc(initialScrollAnchor.row) < rowCount - effectiveTrailingRows
1443
1880
  ? { index: Math.trunc(initialScrollAnchor.row) - effectiveFrozenRows, offsetPx: initialScrollAnchor.offsetY }
1444
1881
  : undefined
1445
1882
  }
@@ -1451,16 +1888,36 @@ const VirtualGridInner = <T,>(
1451
1888
  {renderRow}
1452
1889
  </VirtualScroll>
1453
1890
  </div>
1454
- <div className="aqvs-grid-hbar-strip" style={{ height: scrollBarWidth, paddingRight: scrollBarWidth }} data-testid={testId ? `${testId}-hbar` : undefined}>
1891
+ {effectiveTrailingRows > 0 ? (
1892
+ // 末尾行帯 (3.5.0 プラン §6.1-17): .aqvs-grid-main の**後**・hbar の**前**の兄弟
1893
+ // (AT には「最終行が最後」の DOM 順正しさ。縦バーは帯の上で自然に終端)。帯クリップ高は
1894
+ // インライン H_T_vis (単一 JS 源 — 帯寸の CSS 変数は幅 2 つのみで高さ変数は導入しない)、
1895
+ // inner はインライン木高 H_T の bottom 定着 — 帯上端 = extent − H_T_vis が全構成で
1896
+ // 成立し、重複縮退では帯の**末尾**行が可視に残る。行本体は renderRow 逐語再利用
1897
+ // (先頭列 / 末尾列の帯構造を継承し 4 象限コーナー全種が無償で成立)
1898
+ <div className="aqvs-grid-trailing-rows" style={{ height: trailingVisibleSize(trailingHeight, frozenHeight, viewport.height) }} data-testid={testId ? `${testId}-trailing-rows` : undefined}>
1899
+ <div className="aqvs-grid-trailing-rows-inner" style={{ height: trailingHeight }}>
1900
+ {trailingRows.map(({ row, top, height }) => (
1901
+ // 帯行は帯ローカル top + right: scrollBarWidth (縦バーコーナーの予約 — 先頭帯行と
1902
+ // 同文) + tabIndex −1 + onFocus フル行 index (focusRowAtIndex ↔ onRowFocus 往復)。
1903
+ // biome-ignore lint/a11y/noStaticElementInteractions: role は消費側の複合ウィジェット契約の所有物 (先頭帯行と同文)
1904
+ <div key={row} data-aqvs-trailing-band-row={row} className="aqvs-grid-trailing-band-row" style={{ top, height, right: scrollBarWidth }} tabIndex={-1} onFocus={() => onRowFocus?.(row)}>
1905
+ {renderRow(row)}
1906
+ </div>
1907
+ ))}
1908
+ </div>
1909
+ </div>
1910
+ ) : null}
1911
+ <div className="aqvs-grid-hbar-strip" style={{ height: scrollBarWidth, paddingRight: effectiveTrailingCols > 0 ? scrollBarWidth + trailingVisibleSize(trailingWidth, frozenWidth, viewport.width) : scrollBarWidth }} data-testid={testId ? `${testId}-hbar` : undefined}>
1455
1912
  <ScrollBar
1456
1913
  horizontal
1457
- enableHorizontalTapCircle
1458
- contentSize={Math.max(0, totalWidth - frozenWidth)}
1459
- viewportSize={Math.max(0, viewport.width - frozenWidth)}
1914
+ contentSize={Math.max(0, totalWidth - frozenWidth - trailingWidth)}
1915
+ viewportSize={Math.max(0, viewport.width - frozenWidth - trailingWidth)}
1460
1916
  scrollPosition={barHx}
1461
1917
  // タップ速度の log10(colCount) 則 (設計 §3.3 — B7 の対策そのもの)。未配線だと
1462
- // 兆列でも基礎倍率 2.2x に縮退し、サム粒度の到達性が死ぬ
1463
- itemCount={Math.max(0, colCount - effectiveFrozenCols)}
1918
+ // 兆列でも基礎倍率 2.2x に縮退し、サム粒度の到達性が死ぬ。−F / −T 写像は
1919
+ // aria 面に露出せずピン不能 (仕様 §7 非ピン面 (3) の登記済み面)
1920
+ itemCount={Math.max(0, colCount - effectiveFrozenCols - effectiveTrailingCols)}
1464
1921
  tapScrollCircleOptions={scrollBarOptions?.tapScrollCircleOptions}
1465
1922
  onScroll={(request, previous) => {
1466
1923
  // バー操作は手動横入力 — 列アンカーの張りを解除する
@@ -1470,6 +1927,32 @@ const VirtualGridInner = <T,>(
1470
1927
  scrollBarWidth={scrollBarWidth}
1471
1928
  />
1472
1929
  </div>
1930
+ {tapCircleEnabled && gridScrollable && (
1931
+ // 統合 2 軸タップサークル (v3.6.0 — ADR-30 の 3 アーム則): (C) 可視上限
1932
+ // `100% − (sbw + size)` / (R) 到達性 `100% − sbw + off` / (Q) 遠象限床
1933
+ // `75% − size/2`。全アームが root 寸・sbw・size・オフセットのみに依存 —
1934
+ // 帯幅非依存 (凍結トグル不変)。wrapper は純配置で transition を持たない
1935
+ // (フェードはサークルルートのインライン opacity — §8.7)。hbar strip の後・
1936
+ // live region の前 = 共有 z-50 層の DOM 後勝ちで最前
1937
+ <div
1938
+ className="aqvs-grid-tap-circle-wrapper"
1939
+ data-aqvs-grid-tap-circle=""
1940
+ style={{
1941
+ left: `min(calc(100% - ${scrollBarWidth + resolvedTapX.size}px), max(calc(100% - ${scrollBarWidth}px + ${resolvedTapX.offsetX}px), calc(75% - ${resolvedTapX.size / 2}px)))`,
1942
+ top: `min(calc(100% - ${scrollBarWidth + resolvedTapX.size}px), max(calc(100% - ${scrollBarWidth}px + ${resolvedTapX.offsetY}px), calc(75% - ${resolvedTapX.size / 2}px)))`,
1943
+ }}>
1944
+ <TapScrollCircle
1945
+ axis="xy"
1946
+ ref={tapCircleHandleRef}
1947
+ className={twMerge("aqvs-grid-tap-circle", resolvedTapX.className)}
1948
+ size={resolvedTapX.size}
1949
+ maxVisualDistance={tapMaxDistance}
1950
+ opacity={tapCircleOpacityValue}
1951
+ renderVisual={resolvedTapX.renderVisual}
1952
+ onDragChange={handleTapCircleDragChange}
1953
+ />
1954
+ </div>
1955
+ )}
1473
1956
  {liveRegion !== undefined ? (
1474
1957
  <div className="aqvs-grid-live-region" aria-live="polite" data-testid={testId ? `${testId}-live` : undefined}>
1475
1958
  {liveMessage}