@aiquants/virtualscroll 3.1.1 → 3.5.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.
@@ -0,0 +1,1822 @@
1
+ /**
2
+ * @fileoverview VirtualGrid — a generic 2D virtualization primitive composing the proven
3
+ * VirtualScroll row axis with a symmetric horizontal column engine (the SAME trillion-scale
4
+ * machinery: the sparse Fenwick width tree, the shared computeRenderingRanges (incl. its
5
+ * BigInt huge branch), quantized anchor rebasing at ANCHOR_REBASE_DISTANCE, and synthetic
6
+ * scrolling that never lands total-size pixels in the DOM). Column count inherits the full
7
+ * row-axis scale profile (<= 2^53 - 1). Grid semantics (selection / editing / clipboard)
8
+ * are deliberately NOT owned here — consumers attach them via getCellProps / getRowProps /
9
+ * contentProps. Design: docs/plans/2026.09.01 virtualgrid-horizontal-axis-design-plan (v6).
10
+ *
11
+ * VirtualGrid — 実証済みの VirtualScroll 行軸に、対称の横列エンジン (同一の兆スケール機構:
12
+ * 疎 Fenwick 幅木・共有 computeRenderingRanges (BigInt huge 分岐込み)・ANCHOR_REBASE_DISTANCE
13
+ * の量子化アンカー再基準化・総サイズ px を DOM に着地させない合成スクロール) を合成した
14
+ * 汎用 2D 仮想化プリミティブ。列数は行軸のスケールプロファイル (≤ 2^53 − 1) を完全継承する。
15
+ * グリッド操作の意味論 (選択 / 編集 / クリップボード) は意図的に非所有 — 消費側が
16
+ * getCellProps / getRowProps / contentProps で装着する。
17
+ */
18
+
19
+ import { type CSSProperties, forwardRef, type HTMLAttributes, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
20
+ import { twMerge } from "tailwind-merge"
21
+ import { Logger } from "./logger.ts"
22
+ import { ScrollBar } from "./ScrollBar.tsx"
23
+ import type { ScrollPaneProps } from "./ScrollPane.tsx"
24
+ import { useFenwickMapTree } from "./useFenwickMapTree.ts"
25
+ import { minmax } from "./utils.ts"
26
+ 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
+
28
+ /**
29
+ * Hard per-track size cap (px) for both axes. Values above this are rejected with a RangeError
30
+ * (fail-fast, no silent clamp): the §6 bounding contract `z × (ANCHOR_REBASE_DISTANCE +
31
+ * windowSpan) <= 2^24` relies on every track being bounded, and a single oversized track would
32
+ * let cell coordinates / the residual transform pierce the measured LayoutUnit wall (2^25).
33
+ *
34
+ * 両軸共通のトラックサイズ上限 (px)。超過は RangeError で fail-fast (暗黙 clamp なし):
35
+ * §6 の有界化契約はトラック有界性に立脚し、1 本の過大トラックでセル座標 / 残差 transform が
36
+ * 実測の LayoutUnit 壁 (2^25) を踏み抜くため。
37
+ */
38
+ export const MAX_TRACK_SIZE = 262_144 // 2^18 px
39
+
40
+ /**
41
+ * Per-axis overscan default. Deliberately 3 (NOT the bare VirtualScroll default of 15): the
42
+ * bounding contract's window-span term is overscan-proportional, and 2D multiplies overscan
43
+ * cost by the other axis. VirtualGrid injects this into the embedded VirtualScroll explicitly.
44
+ *
45
+ * 軸別 overscan 既定。意図的に 3 (素の VirtualScroll 既定 15 ではない): 有界化契約の窓スパン
46
+ * 項が overscan 比例で、2D では他軸との積で費用が増幅されるため。内包 VirtualScroll へも
47
+ * 明示注入する。
48
+ */
49
+ const DEFAULT_GRID_OVERSCAN = 3
50
+
51
+ /**
52
+ * Safety cap for cell nodes materialized in one render pass (rendered rows × rendered cols).
53
+ * Unlike the row-side MAX_RENDERED_ITEMS (a pathological-input guard), the 2D product can be
54
+ * reached by LEGITIMATE configurations (min zoom on large displays), so truncation is never
55
+ * silent — onRenderTruncated fires. Initial value assumes WQHD; §9-3 of the design plan owns
56
+ * the final calibration (4K floor ≈ 55,955).
57
+ *
58
+ * 1 レンダーで具現化するセルノード数 (描画行 × 描画列) の安全上限。行側 MAX_RENDERED_ITEMS
59
+ * (病的入力ガード) と違い、2D 積は正当な構成 (大型ディスプレイの最小ズーム) で到達し得るため
60
+ * 打切りは無言にしない — onRenderTruncated が発火する。初期値は WQHD 前提で、最終確定は
61
+ * 設計計画 §9-3 の所有 (4K の床 ≈ 55,955)。
62
+ */
63
+ export const MAX_RENDERED_CELLS = 32_768 // 2^15
64
+
65
+ /**
66
+ * Hard cap for `frozenLeadingCols` (design §8). The frozen band is NOT windowed — every frozen
67
+ * column materializes in every rendered row and is EXEMPT from the MAX_RENDERED_CELLS
68
+ * truncation (the scroll-band budget is reduced by the frozen count instead) — so the cap is
69
+ * what bounds the band: worst case 128 x MAX_RENDERED_ITEMS rows, and W_F <= 128 x
70
+ * MAX_TRACK_SIZE = 2^25 (the measured layout wall; practical W_F <= viewport << 2^24). Real
71
+ * frozen panes are single-digit; violations fail fast with a RangeError.
72
+ *
73
+ * `frozenLeadingCols` の上限 (設計 §8)。凍結帯は窓化されず全描画行で具現化し、かつ
74
+ * MAX_RENDERED_CELLS の打切り対象外 (代わりにスクロール帯予算を凍結数ぶん控除) のため、帯を
75
+ * 有界にするのはこの上限そのもの: 最悪 128 × MAX_RENDERED_ITEMS 行、W_F ≤ 128 × MAX_TRACK_SIZE
76
+ * = 2^25 (実測壁ちょうど — 実運用は W_F ≤ viewport ≪ 2^24)。違反は fail-fast の RangeError。
77
+ */
78
+ export const MAX_FROZEN_LEADING_COLS = 128
79
+
80
+ /**
81
+ * Hard cap for `frozenLeadingRows` (frozen-rows design plan §4-1). The frozen-row band is
82
+ * grid-owned and OUT of the embedded VirtualScroll (index-shift architecture — plan §2):
83
+ * every band row materializes every rendered column and is exempt from MAX_RENDERED_CELLS
84
+ * (the budget denominator counts band rows instead), so the cap bounds the band itself.
85
+ * `frozenLeadingRows` の上限 (行凍結設計 §4-1)。凍結行帯はグリッド所有で埋め込み
86
+ * VirtualScroll の外 (インデックスシフト方式 — 設計 §2)。帯行は描画列を全て具現化し
87
+ * MAX_RENDERED_CELLS の打切り対象外 (予算分母に帯行数を算入) のため、帯を有界にするのは
88
+ * この上限そのもの。違反は fail-fast の RangeError。
89
+ */
90
+ export const MAX_FROZEN_LEADING_ROWS = 128
91
+
92
+ /**
93
+ * Hard cap for `frozenTrailingCols` (v3.5.0 trailing-freeze plan §3). The trailing band is NOT
94
+ * windowed — every trailing column materializes in every rendered row and is EXEMPT from the
95
+ * MAX_RENDERED_CELLS truncation (the scroll-band budget is reduced by the trailing count
96
+ * instead) — so the cap is what bounds the band: worst case 128 x MAX_RENDERED_ITEMS rows, and
97
+ * W_T <= 128 x MAX_TRACK_SIZE = 2^25 (the measured layout wall; practical W_T <= viewport <<
98
+ * 2^24). Violations fail fast with a RangeError.
99
+ *
100
+ * `frozenTrailingCols` の上限 (3.5.0 末尾凍結プラン §3)。末尾帯は窓化されず全描画行で具現化し、
101
+ * かつ MAX_RENDERED_CELLS の打切り対象外 (代わりにスクロール帯予算を末尾数ぶん控除) のため、
102
+ * 帯を有界にするのはこの上限そのもの: 最悪 128 × MAX_RENDERED_ITEMS 行、W_T ≤ 128 ×
103
+ * MAX_TRACK_SIZE = 2^25 (実測壁ちょうど — 実運用は W_T ≤ viewport ≪ 2^24)。違反は fail-fast の
104
+ * RangeError。
105
+ */
106
+ export const MAX_FROZEN_TRAILING_COLS = 128
107
+
108
+ /**
109
+ * Hard cap for `frozenTrailingRows` (v3.5.0 trailing-freeze plan §3). The trailing-row band is
110
+ * grid-owned and OUT of the embedded VirtualScroll (the SECOND out-of-pane band, below the
111
+ * pane): every band row materializes every rendered column and is exempt from
112
+ * MAX_RENDERED_CELLS (the budget denominator counts band rows instead), so the cap bounds the
113
+ * band itself. Violations fail fast with a RangeError.
114
+ *
115
+ * `frozenTrailingRows` の上限 (3.5.0 末尾凍結プラン §3)。末尾行帯はグリッド所有で埋め込み
116
+ * VirtualScroll の外 (ペインの**下**の第 2 帯外バンド)。帯行は描画列を全て具現化し
117
+ * MAX_RENDERED_CELLS の打切り対象外 (予算分母に帯行数を算入) のため、帯を有界にするのは
118
+ * この上限そのもの。違反は fail-fast の RangeError。
119
+ */
120
+ export const MAX_FROZEN_TRAILING_ROWS = 128
121
+
122
+ /**
123
+ * Maximum number of CONSECUTIVE NON-CONVERGENT self-heal flushes. A stable accessor converges in
124
+ * ONE flush (tree == accessor afterwards), so the next collect pass is clean and RESETS this
125
+ * counter — sequential legitimate width changes never accumulate. A truly oscillating getColWidth
126
+ * (a contract violation) never produces a clean pass, so the heal→epoch→re-collect microtask
127
+ * cycle would spin forever — past this limit healing is suspended with a single warning (bounded
128
+ * degradation instead of a livelock). ❗ The state is deliberately window-agnostic ({count,
129
+ * warned} — no window key): a key-scoped reset would be evaded forever by an oscillation whose
130
+ * amplitude moves the window edge (alternating keys every heal — empirically refuted). Recovery
131
+ * after suspension rides the clean-pass reset: any converged pass in ANY window (e.g. after
132
+ * scrolling to a healthy region) re-arms healing.
133
+ *
134
+ * 「**連続する非収束**」self-heal フラッシュ数の上限。安定したアクセサは 1 回で収束する
135
+ * (治癒後は 木 == アクセサ) ため次の走査は乖離ゼロとなり本カウンタは**解消**される — 逐次の
136
+ * 正当な幅変更は蓄積しない。真に振動する getColWidth (契約違反) は収束パスを一度も作れず、
137
+ * 治癒→エポック→再走査のマイクロタスク循環が無限旋回するため、超過時は治癒を停止し 1 回だけ
138
+ * 警告する (livelock でなく有界な縮退)。❗ 状態は意図的に窓非依存 ({count, warned} — 窓キーを
139
+ * 持たない): キー基準の解消は、振幅が窓端を動かす振動が治癒のたびにキーを交互させて永久回避する
140
+ * (実測反証済み)。停止後の回復は収束パス解消が担う (**どの窓でも**収束 1 回で治癒が再武装する)。
141
+ */
142
+ const WIDTH_HEAL_BURST_LIMIT = 10
143
+
144
+ /**
145
+ * Resolves the per-row column cap from the cell budget and the effective rendered-row count.
146
+ * Pure and exported as the mutation-gate surface for the production MAX_RENDERED_CELLS fallback
147
+ * (the component itself is only ever gated through the __maxRenderedCells test seam).
148
+ *
149
+ * セル予算と実効描画行数から行あたり列上限を解決する。純関数 — 本番既定 MAX_RENDERED_CELLS への
150
+ * フォールバックを変異ゲート化するための export (コンポーネント側はテストシーム経由でしか
151
+ * ゲートできないため)。
152
+ */
153
+ export const resolveMaxColsPerRow = (maxRenderedCells: number | undefined, renderedRowCount: number): number => Math.max(1, Math.floor((maxRenderedCells ?? MAX_RENDERED_CELLS) / Math.max(1, renderedRowCount)))
154
+
155
+ /**
156
+ * Estimates the MATERIALIZED-row count of a commit that precedes the embedded VirtualScroll's
157
+ * range notification (the notification is a state update and lags one commit — mount and
158
+ * viewport resizes): walks row indices from `startRow` accumulating real heights until the
159
+ * viewport is filled, counting EVERY index — zero-height (hidden) rows materialize as DOM rows
160
+ * too, so counting only visible rows would under-divide the cell budget by the whole hidden run.
161
+ * The walk is capped by the shared MAX_RENDERED_ITEMS (the row axis truncates its own render at
162
+ * the same constant), then the rendering-window slack (2 x overscan + 2 boundary rows) is added.
163
+ *
164
+ * 内包 VirtualScroll の範囲通知 (state 更新のため 1 コミット遅れる — マウントとビューポート
165
+ * リサイズ) より前のコミットに対する**具現化行数**の見積り: `startRow` から実行高を積んで
166
+ * ビューポートを満たすまで**全 index** を数える — 高さ 0 (非表示) 行も DOM 行として具現化される
167
+ * ため、可視行だけを数えると隠しラン全量ぶんセル予算を過小分割する。ウォークは共有
168
+ * MAX_RENDERED_ITEMS (行軸自身の描画打切り定数) で上限化し、描画窓スラック (2 x overscan + 2)
169
+ * を足す。
170
+ */
171
+ export const estimateRenderedRowCount = (viewportHeight: number, getRowHeight: (row: number) => number, rowCount: number, startRow: number, overscanRows: number): number => {
172
+ let accumulated = 0
173
+ let rows = 0
174
+ let row = Math.max(0, Math.min(startRow, rowCount - 1))
175
+ // 具現化行の母集団 = 描画窓の index 幅。行軸は窓を MAX_RENDERED_ITEMS で打ち切るため、
176
+ // ウォークも同じ上限で有界 (高さ 0 の巨大ランでも最大 2,000 歩)
177
+ while (row < rowCount && accumulated < viewportHeight && rows < MAX_RENDERED_ITEMS) {
178
+ accumulated += getRowHeight(row)
179
+ rows += 1
180
+ row += 1
181
+ }
182
+ return Math.max(1, rows + 2 * overscanRows + 2)
183
+ }
184
+
185
+ /**
186
+ * 2D range notification payload (rendering = overscan-inclusive, visible = strict; row-parity
187
+ * with VirtualScrollRange). The all-frozen scroll band reports the CANONICAL EMPTY window as
188
+ * start > end (renderingColStart = colCount, renderingColEnd = colCount - 1) — inclusive
189
+ * [start..end] loops naturally run zero iterations; formatters must handle start > end.
190
+ * 2D 範囲通知 (rendering = オーバースキャン込み、visible = 厳密 — 行側 VirtualScrollRange と
191
+ * 対称)。全列凍結のスクロール帯は**空窓の正準表現 start > end** (renderingColStart = colCount /
192
+ * renderingColEnd = colCount − 1) で通知する — 閉区間 [start..end] の列挙は自然に 0 周、
193
+ * 文言整形側は start > end を扱うこと。
194
+ */
195
+ export type VirtualGridRange = {
196
+ renderingRowStart: number
197
+ renderingRowEnd: number
198
+ visibleRowStart: number
199
+ visibleRowEnd: number
200
+ renderingColStart: number
201
+ renderingColEnd: number
202
+ visibleColStart: number
203
+ visibleColEnd: number
204
+ /** Logical scroll position (px). / 論理スクロール位置 (px)。 */
205
+ scrollX: number
206
+ scrollY: number
207
+ totalWidth: number
208
+ totalHeight: number
209
+ }
210
+
211
+ /** Live-region options: the consumer owns wording/locale (no built-in strings — row-parity). / liveRegion オプション: 文言とロケールは消費側所有 (内蔵文字列なし — 行側と同一方針)。 */
212
+ export type VirtualGridLiveRegionOptions = {
213
+ /** Builds the announcement (same string = no re-announce, "" clears). / 読み上げ文言の組み立て (同一文字列 = 再読み上げなし、"" でクリア)。 */
214
+ buildMessage: (range: VirtualGridRange) => string
215
+ /** Settle debounce before announcing (ms, default 400 — the row-side default). Must be finite and >= 0; anything else logs a warning and disables announcements (no silent substitution — row-parity contract). / 読み上げ前の静定待ち (ms、既定 400 — 行側と同一)。有限かつ 0 以上のみ — それ以外は警告して読み上げを無効化 (黙って既定へ読み替えない — 行側と同契約)。 */
216
+ debounceMs?: number
217
+ }
218
+
219
+ /** Behavior options (embeds the vertical VirtualScrollBehaviorOptions + column-axis twins). / 挙動オプション (縦の VirtualScrollBehaviorOptions を包含 + 列軸双子)。 */
220
+ export type VirtualGridBehaviorOptions = VirtualScrollBehaviorOptions & {
221
+ /**
222
+ * Rebuilds the column width tree when getColWidth identity changes (the column twin of the
223
+ * row-side resetOnGetItemHeightChange — for mid-list column insert/delete where index↔width
224
+ * pairing shifts). Default false.
225
+ * getColWidth の identity 変化で列幅木を再構築する (行側 resetOnGetItemHeightChange の列
226
+ * 双子 — 中間挿入 / 削除で index↔幅 対応がずれる場合)。既定 false。
227
+ */
228
+ resetOnGetColWidthChange?: boolean
229
+ /**
230
+ * Explicit Fenwick baseValue for the column width tree (design §3.4): skips the stride
231
+ * sampling of getColWidth at construction, so unmaterialized columns are estimated at exactly
232
+ * this width. Integer in [0, MAX_TRACK_SIZE] — RangeError otherwise (fail-fast, no clamp).
233
+ * 列幅木の Fenwick baseValue の明示指定 (設計 §3.4): 構築時の getColWidth ストライド
234
+ * サンプリングを省略し、未具現化列をこの幅で見積もる。[0, MAX_TRACK_SIZE] の整数 —
235
+ * 違反は RangeError (fail-fast、clamp なし)。
236
+ */
237
+ defaultColWidth?: number
238
+ }
239
+
240
+ /** Props for VirtualGrid. / VirtualGrid の props。 */
241
+ export type VirtualGridProps<T> = {
242
+ /** Row count (<= 2^53 - 1). / 行数 (≤ 2^53 − 1)。 */
243
+ rowCount: number
244
+ /** Column count (<= 2^53 - 1 — full row-axis parity). / 列数 (≤ 2^53 − 1 — 行軸と完全対称)。 */
245
+ colCount: number
246
+ /**
247
+ * Row height accessor — integer px, 0 = hidden, <= MAX_TRACK_SIZE (RangeError otherwise).
248
+ * Must stay index-stable while the ordering is unchanged (row-side getItemHeight contract).
249
+ * 行高アクセサ — 整数 px、0 = 非表示、≤ MAX_TRACK_SIZE (超過は RangeError)。並び順が
250
+ * 不変の間は index 安定であること (行側 getItemHeight と同文の契約)。
251
+ */
252
+ getRowHeight: (row: number) => number
253
+ /** Column width accessor — the width SSOT (same contract as getRowHeight; no eager bulk init is ever performed). / 列幅アクセサ — 幅の SSOT (getRowHeight と同契約。eager な一括初期化は行わない)。 */
254
+ getColWidth: (col: number) => number
255
+ /** Cell value accessor (pull型 — the grid holds no data). / セル値アクセサ (pull 型 — グリッドはデータを持たない)。 */
256
+ getCell: (row: number, col: number) => T
257
+ /** Stable cell key (default: the column index within the row). / 安定セルキー (既定: 行内の列 index)。 */
258
+ getCellKey?: (row: number, col: number) => string | number
259
+ /** Cell renderer. / セルレンダラー。 */
260
+ children: (cell: T, row: number, col: number) => ReactNode
261
+ /** Attributes merged onto the positioned cell element (roles / aria-* / data-* — the consumer hook surface). / 配置済みセル要素へマージする属性 (role / aria / data — 消費側フック面)。 */
262
+ getCellProps?: (row: number, col: number) => HTMLAttributes<HTMLDivElement>
263
+ /** Attributes merged onto the row wrapper (role="row" / aria-rowindex). / 行ラッパーへマージする属性。 */
264
+ getRowProps?: (row: number) => HTMLAttributes<HTMLDivElement>
265
+ /**
266
+ * ARIA / identity attributes for the scrollable content element (role="grid" etc.) — the
267
+ * EXACT row-side type (ScrollPaneProps["contentProps"]): deliberately NOT HTMLAttributes, so a
268
+ * consumer cannot smuggle className / style past the pane's structural class and inline
269
+ * geometry (the pane spreads contentProps after its own className — a wide type here would be
270
+ * a type-system bypass that clobbers overflow / position / flex in real browsers).
271
+ * スクロール面要素への ARIA / 識別属性 (role="grid" 等) — 行側と完全同型
272
+ * (ScrollPaneProps["contentProps"])。意図的に HTMLAttributes ではない: 広い型にすると
273
+ * className / style がペインの構造クラスとインライン形状を後勝ちで破壊できてしまう
274
+ * (型システムバイパス — 実ブラウザで overflow / position / flex が消える)。
275
+ */
276
+ contentProps?: ScrollPaneProps["contentProps"]
277
+ /** Background layer rendered inside the content element behind the rows (row-side pass-through). / 行の背後・コンテンツ要素内に描画する背景レイヤー (行側の透過)。 */
278
+ background?: ReactNode
279
+ /** Vertical content insets (px) around the rows (row-side pass-through — the horizontal axis has no inset concept in v1). / 行の上下コンテンツインセット (px、行側の透過 — 横軸のインセットは v1 非対応)。 */
280
+ contentInsets?: ScrollPaneProps["contentInsets"]
281
+ /** Fires when a row wrapper receives focus, in FULL row space (scroll rows via the +R-wrapped row-side onItemFocus — enableKeyboardNavigation applies; frozen band rows fire on direct DOM focus regardless). / 行ラッパーのフォーカス通知 (フル行空間)。スクロール行は +R ラップ済み行側 onItemFocus (enableKeyboardNavigation が前提)、凍結帯行は直接 DOM フォーカスで無条件発火。 */
282
+ onRowFocus?: (row: number) => void
283
+ /**
284
+ * Keyboard gestures that horizontally scroll the grid-owned axis (default: none — the exact
285
+ * row-side prop; the emitted delta is consumed internally into hx). Requires
286
+ * behaviorOptions.enableKeyboardNavigation and focus on a row wrapper (row-side contract).
287
+ * グリッド所有の横軸をキーボードで動かす操作種別 (既定: 無効 — 行側と同一 prop。放出量は
288
+ * 内部で hx に消費)。behaviorOptions.enableKeyboardNavigation と行ラッパーへの
289
+ * フォーカスが前提 (行側と同契約)。
290
+ */
291
+ horizontalKeyInputs?: readonly "arrow"[]
292
+ /** Pixels per horizontal arrow key press (default 40 — row-side pass-through incl. the reject-invalid contract). / 横矢印キー 1 回の移動量 (既定 40 — 不正値拒否契約ごと行側の透過)。 */
293
+ horizontalKeyStep?: number
294
+ /** Fires on logical scroll (either axis). / 論理スクロール通知 (どちらの軸でも)。 */
295
+ onScroll?: (pos: { x: number; y: number }) => void
296
+ /** Fires when the 2D rendering/visible ranges change. / 2D 範囲変化の通知。 */
297
+ onRangeChange?: (range: VirtualGridRange) => void
298
+ /** Fires when the MAX_RENDERED_CELLS cap truncates columns (never silent). / MAX_RENDERED_CELLS 打切りの通知 (無言切り捨て禁止)。 */
299
+ onRenderTruncated?: (info: { renderedCells: number; droppedCols: number }) => void
300
+ /** Vertical callback throttle passthrough (horizontal fires synchronously). / 縦コールバックのスロットル透過 (横は同期発火)。 */
301
+ callbackThrottleMs?: number
302
+ /** Restores both axes from a {row, col, offsetY, offsetX} anchor (px-restore is refuted row-side — anchors survive estimation-space changes). / 両軸を {row, col, offsetY, offsetX} アンカーから復元 (生 px 復元は行側で反証済み)。 */
303
+ initialScrollAnchor?: { row: number; col: number; offsetY: number; offsetX: number }
304
+ /**
305
+ * Number of leading columns frozen at the left edge (design §8; default 0). The frozen band
306
+ * lives OUTSIDE the anchor machinery: tree coordinates [0, W_F) render as direct static
307
+ * lefts (safe — W_F <= viewport << 2^24), the scroll band's window search starts at the
308
+ * W_F offset, and its residual transform carries the -W_F origin-shift term. Non-negative
309
+ * integer <= MAX_FROZEN_LEADING_COLS — RangeError otherwise (fail-fast); values beyond the
310
+ * CURRENT colCount freeze every column (documented dynamic clamp, not a fallback — the
311
+ * scroll band then reports the EMPTY window start > end and renders no scroll cells).
312
+ * 左端に凍結する先頭列数 (設計 §8。既定 0)。凍結帯はアンカー機構の**外**: 木座標 [0, W_F)
313
+ * を静的 left で直接描画 (W_F は上限 128 × 2^18 = 2^25 で有界 — 実運用は viewport ≪ 2^24)、
314
+ * スクロール帯の窓探索は W_F
315
+ * オフセット始まり、残差 transform は −W_F の原点シフト項を持つ。0 以上の整数かつ
316
+ * MAX_FROZEN_LEADING_COLS 以下 — 違反は RangeError (fail-fast)。現在の colCount を超える
317
+ * 値は全列凍結 (文書化された動的クランプ — フォールバックではない)。
318
+ */
319
+ frozenLeadingCols?: number
320
+ /**
321
+ * Number of leading rows pinned at the top (frozen-rows design plan — default 0). The band
322
+ * is grid-owned and OUT of the embedded VirtualScroll: the scroll rows live in a SHIFTED
323
+ * index space (itemCount = rowCount − R, getItem = i + R), so vy is the scroll-band
324
+ * logical position and the vertical bar maps the band naturally. Non-negative integer <=
325
+ * MAX_FROZEN_LEADING_ROWS — RangeError otherwise (fail-fast); values beyond the CURRENT
326
+ * rowCount freeze every row (documented dynamic clamp — the scroll rows then report the
327
+ * canonical EMPTY window start > end and render nothing).
328
+ * 上端に凍結する先頭行数 (行凍結設計 — 既定 0)。帯はグリッド所有で埋め込み VirtualScroll
329
+ * の外: スクロール行は**シフト済みインデックス空間** (itemCount = rowCount − R、getItem =
330
+ * i + R) に住み、vy はスクロール帯の論理位置・縦バーは帯を自然に写像する。0 以上の整数
331
+ * かつ MAX_FROZEN_LEADING_ROWS 以下 — 違反は RangeError (fail-fast)。現在の rowCount を
332
+ * 超える値は全行凍結 (文書化された動的クランプ — スクロール行は正準空窓 start > end を
333
+ * 通知しゼロ描画)。
334
+ */
335
+ frozenLeadingRows?: number
336
+ /**
337
+ * Number of trailing columns frozen at the right edge (v3.5.0 trailing-freeze plan §3;
338
+ * default 0). Integer in [0, MAX_FROZEN_TRAILING_COLS] — RangeError otherwise (fail-fast).
339
+ * Dynamic clamp (documented contract): min(T, colCount − effectiveFrozenCols) — the LEADING
340
+ * band wins the count space. Trailing cells render in a right-anchored clip outside the
341
+ * anchor machinery (band-local lefts); `--aqvs-grid-trailing-width` is written iff T > 0.
342
+ * `scrollToCell` / `initialScrollAnchor` targeting a trailing column are horizontal no-ops
343
+ * (always visible). T = 0 is structurally identical to the pre-trailing DOM.
344
+ * 右端に凍結する末尾列数 (3.5.0 末尾凍結プラン §3。既定 0)。[0, MAX_FROZEN_TRAILING_COLS]
345
+ * の整数 — 違反は RangeError (fail-fast)。動的クランプ (文書化契約): min(T, colCount −
346
+ * effectiveFrozenCols) — カウント空間は**先頭が勝つ**。末尾セルはアンカー機構の外の
347
+ * 右アンカークリップに帯ローカル left で描画し、`--aqvs-grid-trailing-width` は T > 0 の
348
+ * ときのみ書かれる。`scrollToCell` / `initialScrollAnchor` の末尾列狙いは横 no-op
349
+ * (常時可視)。T = 0 は DOM 構造まで従来と恒等。
350
+ */
351
+ frozenTrailingCols?: number
352
+ /**
353
+ * Number of trailing rows pinned at the bottom (v3.5.0 trailing-freeze plan §3; default 0).
354
+ * Integer in [0, MAX_FROZEN_TRAILING_ROWS] — RangeError otherwise (fail-fast). Dynamic
355
+ * clamp (documented contract): min(T, rowCount − effectiveFrozenRows) — leading wins. The
356
+ * band is grid-owned BELOW the pane; the embedded scroll rows shrink at the TAIL only
357
+ * (itemCount = rowCount − R − T, getItem = i + R unchanged), so a runtime T change does NOT
358
+ * remount the embedded VirtualScroll (ADR-23). `scrollToCell` / `initialScrollAnchor`
359
+ * targeting a trailing row are vertical no-ops (always visible). T = 0 is structurally
360
+ * identical to the pre-trailing DOM.
361
+ * 下端に凍結する末尾行数 (3.5.0 末尾凍結プラン §3。既定 0)。[0, MAX_FROZEN_TRAILING_ROWS]
362
+ * の整数 — 違反は RangeError (fail-fast)。動的クランプ (文書化契約): min(T, rowCount −
363
+ * effectiveFrozenRows) — 先頭が勝つ。帯はペインの**下**のグリッド所有バンドで、埋め込み
364
+ * スクロール行は**末尾**だけが伸縮する (itemCount = rowCount − R − T、getItem = i + R
365
+ * 不変) — T の実行時変更は埋め込み VirtualScroll を再マウントしない (ADR-23)。
366
+ * `scrollToCell` / `initialScrollAnchor` の末尾行狙いは縦 no-op (常時可視)。T = 0 は
367
+ * DOM 構造まで従来と恒等。
368
+ */
369
+ frozenTrailingRows?: number
370
+ /** Row-axis overscan (default 3 — a declared, intentional deviation from the bare VirtualScroll default 15; see the bounding contract). / 行軸 overscan (既定 3 — 素の既定 15 からの宣言済み意図的乖離)。 */
371
+ overscanRows?: number
372
+ /** Column-axis overscan (default 3). / 列軸 overscan (既定 3)。 */
373
+ overscanCols?: number
374
+ /**
375
+ * Explicit root box size (px) — skips ResizeObserver self-measurement (the row-side
376
+ * viewportSize explicit-mode twin; deterministic for tests / SSR). The cell viewport is
377
+ * derived by subtracting the scrollbar thickness on both axes.
378
+ * ルート箱寸法の明示指定 (px) — RO 自己計測を省略する (行側 viewportSize 明示モードの双子。
379
+ * テスト / SSR の決定性)。セルビューポートは両軸ともバー厚を差し引いて導出する。
380
+ */
381
+ viewportSize?: { width: number; height: number }
382
+ /** TEST-ONLY seam: overrides MAX_RENDERED_CELLS so the truncation path is gateable without materializing 32k DOM nodes in jsdom. / テスト専用シーム: 打切り経路を jsdom で 32k ノード無しにゲート化するための上限上書き。 */
383
+ __maxRenderedCells?: number
384
+ behaviorOptions?: VirtualGridBehaviorOptions
385
+ scrollBarOptions?: VirtualScrollScrollBarOptions
386
+ liveRegion?: VirtualGridLiveRegionOptions
387
+ className?: string
388
+ testId?: string
389
+ }
390
+
391
+ /** Imperative handle (logical coordinates everywhere; -1 sentinels pre-attach — row-parity incl. return values). / 命令ハンドル (全て論理座標・未接続 -1 番兵 — 返り値契約まで行側と対称)。 */
392
+ export type VirtualGridHandle = {
393
+ /**
394
+ * Jump API — do NOT use for continuous input (use scrollBy). Vertical delegates to the row
395
+ * scrollTo (which floors and pins a row anchor); horizontal clamps into [0, max] with no
396
+ * floor and no anchor pinning (the synthetic axis has no pane anchor — depth safety comes
397
+ * from quantized anchor rebasing instead). Returns the applied clamped position.
398
+ * ジャンプ API — 連続入力には使わない (scrollBy を使う)。縦は行 scrollTo へ委譲 (floor +
399
+ * 行アンカー張り)、横は [0, max] クランプのみで floor もアンカー張りも行わない (合成軸に
400
+ * ペインアンカーは無く、深度安全は量子化アンカー再基準化が担う)。クランプ後位置を返す。
401
+ */
402
+ scrollTo(pos: { x?: number; y?: number }): { x: number; y: number }
403
+ /** Relative float-accumulating path (wheel semantics; returns the applied clamped position — drag loops consume it synchronously). / 相対 float 累積経路 (ホイール同義。クランプ後位置を同期 return — ドラッグループが消費)。 */
404
+ scrollBy(delta: { x?: number; y?: number }): { x: number; y: number }
405
+ /**
406
+ * Cell-targeted jump. alignY uses the row vocabulary (top/bottom/center), alignX its
407
+ * horizontal twin (start/end/center). No "nearest". Targeting a LEADING frozen or TRAILING
408
+ * frozen column is a horizontal no-op that leaves any previously armed column anchor in
409
+ * place (the visual status quo is preserved; manual horizontal input clears anchors as
410
+ * usual); targeting a leading/trailing frozen row is a vertical no-op. Band membership is
411
+ * judged at CALL time — a T change AFTER arming follows the ADR-23 clamp semantics: a
412
+ * pending row anchor whose target row lands inside the trailing band after a T increase
413
+ * clamps to the last scroll row. Overlap registration (ADR-19-1): when the leading +
414
+ * trailing band extents exceed the viewport, occluded trailing cells cannot be scrolled
415
+ * into view — the no-op is exact there too.
416
+ * セル狙いジャンプ。"nearest" は無し。先頭 / 末尾凍結列狙いの横成分は no-op で、既存の
417
+ * 列アンカーはそのまま残る (視覚的現状維持 — 手動横入力が従来どおり解除する)。先頭 /
418
+ * 末尾凍結行狙いの縦成分も no-op。帯所属の判定は**呼出し時** — 張った後の T 変更は
419
+ * ADR-23 のクランプ意味論 (T 増加で末尾帯入りする行を狙った保留行アンカーは最終
420
+ * スクロール行へクランプ) に従う。重複登記 (ADR-19-1): 先頭 + 末尾の帯寸がビューポートを
421
+ * 超えるとき、隠れた末尾セルはどのスクロール位置でも可視化できない — no-op はそこでも正確。
422
+ */
423
+ scrollToCell(row: number, col: number, options?: { alignY?: "top" | "bottom" | "center"; alignX?: "start" | "end" | "center"; offsetX?: number; offsetY?: number }): void
424
+ /** Synchronous-fresh position read (internal refs — safe mid-event; {-1,-1} pre-attach). / 同期・最新の位置読み (内部 ref — イベント中も安全。未接続 {-1,-1})。 */
425
+ getScrollPosition(): { x: number; y: number }
426
+ /** {index, offset} anchor capture for exact restore; null when either count is 0, or the embedded scroll rows are absent (all-frozen OR a degenerate band whose H_F fills the viewport — both force itemCount 0, so there is no scroll row to anchor). / 厳密復元用アンカー捕獲。どちらかの count が 0、またはスクロール行が不在 (全行凍結 / H_F がビューポートを埋める縮退帯 — いずれも itemCount 0) なら null。 */
427
+ getScrollAnchor(): { row: number; col: number; offsetX: number; offsetY: number } | null
428
+ getViewportSize(): { width: number; height: number }
429
+ getContentSize(): { width: number; height: number }
430
+ getRange(): VirtualGridRange | null
431
+ /** 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 へ委譲。 */
432
+ updateRowSize(row: number, px: number): void
433
+ /**
434
+ * Column twin: validates (MAX_TRACK_SIZE fail-fast), updates the width tree, and bumps the
435
+ * width epoch so committed cells REPAINT even when the window key is unchanged (small grids,
436
+ * offsetting batch resizes). Multi-call batch loops coalesce into one recompute via React's
437
+ * automatic state batching (one commit per synchronous batch — the design's rAF-coalescing
438
+ * intent realized at commit granularity).
439
+ * 列双子: 検証 (fail-fast) → 幅木更新 → 幅エポックの繰上げで、窓キー不変でもコミット済み
440
+ * セルを再描画する (小さなグリッド・相殺バッチリサイズ)。連続呼び出しは React の自動
441
+ * バッチングで 1 コミットに合流 (設計の rAF 合流意図のコミット粒度での実現)。
442
+ */
443
+ updateColSize(col: number, px: number): void
444
+ /**
445
+ * Effective frozen-band sizes on both axes and both ends (v3.5.0 — a non-breaking field
446
+ * extension of the {cols, width} → {cols, width, rows, height} precedent). cols/rows/
447
+ * trailingCols/trailingRows = dynamic post-clamp counts; width/height/trailingWidth/
448
+ * trailingHeight = TREE px (the trailing sizes are tree SUFFIX sums); trailingVisibleWidth/
449
+ * trailingVisibleHeight = viewport-clipped `min(tree, max(0, viewport − leading))` — THE
450
+ * single source for W_T_vis / H_T_vis that every consumer judgment/write path cites (never
451
+ * re-derive the formula). Pre-attach the grid viewport reads {0, 0}, so the vis fields are
452
+ * 0 (an initial measured state, not a sentinel) while the tree fields stay tree px.
453
+ * 実効凍結帯寸法 — 両軸・両端 (v3.5.0。{cols, width} → {cols, width, rows, height} の
454
+ * 前例に続く非破壊のフィールド追加)。cols / rows / trailingCols / trailingRows は動的
455
+ * クランプ後の実効カウント、width / height / trailingWidth / trailingHeight は**木** px
456
+ * (末尾寸は木の接尾辞和)、trailingVisibleWidth / trailingVisibleHeight はビューポート
457
+ * クリップ `min(木, max(0, viewport − 先頭))` — W_T_vis / H_T_vis の**単一情報源**で、
458
+ * 消費側の判定 / 書込み経路は全てこのフィールドを名指しで読む (式の再導出禁止)。
459
+ * プレアタッチはビューポート {0, 0} のため vis フィールドは 0 (番兵ではなく初期実測状態)、
460
+ * 木フィールドは常に木 px。
461
+ */
462
+ getFrozenSize(): { cols: number; width: number; rows: number; height: number; trailingCols: number; trailingRows: number; trailingWidth: number; trailingHeight: number; trailingVisibleWidth: number; trailingVisibleHeight: number }
463
+ /** 2-axis wheel bridge entry: vertical → embedded VirtualScroll, horizontal → hx. Returns whether the event was consumed (WheelBridgeTarget contract). / 2 軸ホイールブリッジ口。消費有無を返す (ブリッジ契約)。 */
464
+ applyWheel(event: WheelEvent): boolean
465
+ /** 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 が前提。凍結帯行は直接フォーカスでオプション不要)。 */
466
+ focusRowAtIndex(row: number, options?: { ensureVisible?: boolean }): void
467
+ }
468
+
469
+ /**
470
+ * Validates a track size returned by a consumer accessor: finite integer in [0, MAX_TRACK_SIZE].
471
+ * Throws RangeError otherwise (fail-fast — a contract violation surfaces to the error boundary,
472
+ * possibly during render since the width tree materializes lazily).
473
+ *
474
+ * 消費側アクセサのトラックサイズ検証: [0, MAX_TRACK_SIZE] の有限整数。違反は RangeError
475
+ * (fail-fast — 幅木は遅延具現化のため render 中の throw もあり得る契約違反として error
476
+ * boundary へ)。
477
+ */
478
+ const validateTrackSize = (value: number, axis: "row" | "col", index: number): number => {
479
+ if (!(Number.isInteger(value) && value >= 0 && value <= MAX_TRACK_SIZE)) {
480
+ throw new RangeError(
481
+ `[VirtualGrid] ${axis === "row" ? "getRowHeight" : "getColWidth"}(${index}) returned ${value} — must be an integer in [0, ${MAX_TRACK_SIZE}] (0 = hidden). Oversized tracks would pierce the browser layout-coordinate wall (see the design plan §3.1/§6).`,
482
+ )
483
+ }
484
+ return value
485
+ }
486
+
487
+ /**
488
+ * Computes the column placement for one horizontal position: the rendering/visible ranges via
489
+ * the SHARED computeRenderingRanges (identical function to the row axis, huge branch included)
490
+ * plus the quantized column anchor. The anchor rebases only when the RENDERING-window start
491
+ * (overscan inclusive — the exact row-side basis) drifts beyond ANCHOR_REBASE_DISTANCE, so cell
492
+ * `left` (= absolute − anchor) and the wrapper residual stay small at any depth. Pure — exported
493
+ * as the primary mutation-gate surface.
494
+ *
495
+ * 1 つの横位置に対する列配置の算出: 共有 computeRenderingRanges (行軸と同一関数、huge 分岐
496
+ * 込み) による範囲 + 量子化列アンカー。アンカーは**描画窓 (オーバースキャン込み) 先頭**が
497
+ * ANCHOR_REBASE_DISTANCE を超えて離れたときだけ付替える (行側と同一基準) — セル left
498
+ * (= 絶対 − アンカー) と残差が任意深度で小さく保たれる。純関数 — 変異ゲートの主面として export。
499
+ */
500
+ export const computeColumnPlacement = (
501
+ hx: number,
502
+ viewportWidth: number,
503
+ overscanCols: number,
504
+ colCount: number,
505
+ getColWidth: (col: number) => number,
506
+ colTree: ReturnType<typeof useFenwickMapTree>,
507
+ totalWidth: number,
508
+ prevAnchor: number,
509
+ ): { renderingColStart: number; renderingColEnd: number; visibleColStart: number; visibleColEnd: number; colAnchor: number } => {
510
+ const ranges = computeRenderingRanges(hx, viewportWidth, overscanCols, colCount, getColWidth, colTree, totalWidth)
511
+ if (colCount === 0) {
512
+ return { renderingColStart: 0, renderingColEnd: 0, visibleColStart: 0, visibleColEnd: 0, colAnchor: 0 }
513
+ }
514
+ // アンカー再基準化 (行 memo と同一ロジック): 描画窓先頭の絶対 X が現アンカーから距離超過の
515
+ // ときのみ付替え。付替え先は任意座標 (グリッド量子化ではなくトリガー距離 — 付録 A.2)
516
+ const safeStart = minmax(ranges.renderingStartIndex, 0, colCount - 1)
517
+ const { cumulative, currentValue } = colTree.prefixSum(safeStart, { materializeOption: { materialize: false } })
518
+ const startPosition = cumulative - currentValue
519
+ const colAnchor = Number.isFinite(startPosition) && Math.abs(startPosition - prevAnchor) > ANCHOR_REBASE_DISTANCE ? startPosition : prevAnchor
520
+ return {
521
+ renderingColStart: ranges.renderingStartIndex,
522
+ renderingColEnd: ranges.renderingEndIndex,
523
+ visibleColStart: ranges.visibleStartIndex,
524
+ visibleColEnd: ranges.visibleEndIndex,
525
+ colAnchor,
526
+ }
527
+ }
528
+
529
+ /** One placed column: index + content-absolute left + width. / 配置済み列 1 本: index + コンテンツ絶対 left + 幅。 */
530
+ type PlacedColumn = { col: number; left: number; width: number }
531
+
532
+ /** Stable empty placement (identity-stable for the F = 0 default path). / 安定空配置 (F = 0 既定経路の identity 安定用)。 */
533
+ const EMPTY_PLACED_COLUMNS: PlacedColumn[] = []
534
+
535
+ /**
536
+ * Enumerates the visible (width > 0) columns of a rendering window ONCE per placement, with
537
+ * content-absolute lefts, self-healing width deltas (getColWidth is the truth — tree deviations
538
+ * inside the window are collected for a batched update, the row-side reconciliation twin), and
539
+ * zero-width runs jumped in O(log n) past the SHARED ZERO_HEIGHT_RUN_LIMIT (imported from the
540
+ * row axis — no duplicated constant, same `>` trigger).
541
+ *
542
+ * 描画窓の可視 (幅 > 0) 列を配置ごとに 1 回列挙する: コンテンツ絶対 left、幅の自己修復差分
543
+ * (getColWidth が正 — 窓内の木との乖離をバッチ更新用に収集、行側照合の双子)、幅 0 ランは
544
+ * 行軸から import した共有 ZERO_HEIGHT_RUN_LIMIT 超 (`>` — 比較まで同一) で O(log n) ジャンプ。
545
+ */
546
+ const collectVisibleColumns = (startCol: number, endCol: number, getColWidth: (col: number) => number, colTree: ReturnType<typeof useFenwickMapTree>, maxColumns: number): { columns: PlacedColumn[]; widthUpdates: Array<{ index: number; value: number }>; truncated: number } => {
547
+ const columns: PlacedColumn[] = []
548
+ const widthUpdates: Array<{ index: number; value: number }> = []
549
+ if (endCol < startCol) {
550
+ return { columns, widthUpdates, truncated: 0 }
551
+ }
552
+ const startPrefix = colTree.prefixSum(startCol, { materializeOption: { materialize: false } })
553
+ // 窓先頭の絶対 left から幅を積む O(k) 走査 (列ごとの prefixSum O(k·log n) を回避 — 行 memo と同型)
554
+ let runningLeft = startPrefix.cumulative - startPrefix.currentValue
555
+ let zeroRun = 0
556
+ let i = startCol
557
+ while (i <= endCol) {
558
+ if (columns.length >= maxColumns) {
559
+ // 打切り列数 = 残り index 数 (可視/非可視を問わず未走査分)
560
+ return { columns, widthUpdates, truncated: endCol - i + 1 }
561
+ }
562
+ const width = getColWidth(i)
563
+ const cached = colTree.get(i)
564
+ if (width !== cached) {
565
+ widthUpdates.push({ index: i, value: width })
566
+ }
567
+ if (width > 0) {
568
+ columns.push({ col: i, left: runningLeft, width })
569
+ runningLeft += cached
570
+ zeroRun = 0
571
+ i += 1
572
+ continue
573
+ }
574
+ runningLeft += cached
575
+ zeroRun += 1
576
+ i += 1
577
+ if (zeroRun > ZERO_HEIGHT_RUN_LIMIT) {
578
+ // 幅 0 ランの Fenwick ジャンプ (行側と共有定数・同一比較 `>` — 複製禁止): 次の非 0 列へ。
579
+ // +0.5 は右開区間バンプ — 木側の値で跳ぶため self-heal 前の乖離があっても有界性は保たれる
580
+ const jump = colTree.findIndexAtOrAfter(runningLeft + 0.5, { materializeOption: { materialize: false } })
581
+ if (jump.index === -1 || jump.index <= i || !Number.isSafeInteger(jump.index)) {
582
+ break
583
+ }
584
+ const jumpPrefix = colTree.prefixSum(jump.index, { materializeOption: { materialize: false } })
585
+ runningLeft = jumpPrefix.cumulative - jumpPrefix.currentValue
586
+ i = jump.index
587
+ zeroRun = 0
588
+ }
589
+ }
590
+ return { columns, widthUpdates, truncated: 0 }
591
+ }
592
+
593
+ /**
594
+ * Enumerates the frozen band (design §8): columns [0, frozenCols) with DIRECT tree-absolute
595
+ * lefts (no anchor subtraction — the band sits outside the anchor machinery; W_F is bounded
596
+ * by 128 x MAX_TRACK_SIZE = 2^25, practically <= viewport << 2^24), zero-width columns skipped (no DOM — the shared hidden-track
597
+ * discipline), and width self-heal deltas collected under the same tree-is-position /
598
+ * accessor-is-width split as collectVisibleColumns. Returns W_F = the TREE prefix width of the
599
+ * band (the scroll band's coordinate origin — heals converge the two).
600
+ *
601
+ * 凍結帯の列挙 (設計 §8): 列 [0, frozenCols) を木絶対 left の直接値で並べ (アンカー減算なし —
602
+ * 帯はアンカー機構の外。W_F は 128 × 2^18 = 2^25 で有界、実運用 ≤ viewport ≪ 2^24)、幅 0 列は DOM 非生成
603
+ * (非表示トラックの共有規律)、幅 self-heal 差分は collectVisibleColumns と同じ
604
+ * 「位置は木・幅はアクセサ」分担で収集する。返す W_F は帯の**木**接頭辞幅 (スクロール帯の
605
+ * 座標原点 — 治癒が両者を収束させる)。
606
+ */
607
+ const collectFrozenColumns = (frozenCols: number, getColWidth: (col: number) => number, colTree: ReturnType<typeof useFenwickMapTree>): { columns: PlacedColumn[]; widthUpdates: Array<{ index: number; value: number }>; width: number } => {
608
+ const columns: PlacedColumn[] = []
609
+ const widthUpdates: Array<{ index: number; value: number }> = []
610
+ let runningLeft = 0
611
+ // F ≤ MAX_FROZEN_LEADING_COLS のため素朴な O(F) 走査で十分 (幅 0 ジャンプ機構は不要)
612
+ for (let col = 0; col < frozenCols; col += 1) {
613
+ const width = getColWidth(col)
614
+ const cached = colTree.get(col)
615
+ if (width !== cached) {
616
+ widthUpdates.push({ index: col, value: width })
617
+ }
618
+ if (width > 0) {
619
+ columns.push({ col, left: runningLeft, width })
620
+ }
621
+ runningLeft += cached
622
+ }
623
+ return { columns, widthUpdates, width: runningLeft }
624
+ }
625
+
626
+ /**
627
+ * Enumerates the trailing frozen band (v3.5.0 trailing-freeze plan §6.1-5 — the suffix twin of
628
+ * collectFrozenColumns): columns [colCount − trailingCols, colCount) with BAND-LOCAL lefts (a
629
+ * running prefix that starts at 0 — the right-anchored inner owns the placement, so trailing
630
+ * cells never take the anchor subtraction; W_T is bounded by 128 x MAX_TRACK_SIZE = 2^25),
631
+ * zero-width columns skipped (no DOM — the shared hidden-track discipline), and width
632
+ * self-heal deltas collected under the same tree-is-position / accessor-is-width split as
633
+ * collectVisibleColumns. Returns width = W_T, the TREE suffix width of the band.
634
+ *
635
+ * 末尾凍結帯の列挙 (3.5.0 末尾凍結プラン §6.1-5 — collectFrozenColumns の接尾辞双子)。列
636
+ * [colCount − trailingCols, colCount) を**帯ローカル** left (0 始まりの running 接頭辞 —
637
+ * 配置は右アンカー inner が担い、末尾セルはアンカー減算を決して取らない。W_T は 128 × 2^18 =
638
+ * 2^25 で有界) で並べ、幅 0 列は DOM 非生成 (非表示トラックの共有規律)、幅 self-heal 差分は
639
+ * collectVisibleColumns と同じ「位置は木・幅はアクセサ」分担で収集する。返す width = W_T
640
+ * (帯の**木**接尾辞幅)。
641
+ */
642
+ const collectTrailingColumns = (colCount: number, trailingCols: number, getColWidth: (col: number) => number, colTree: ReturnType<typeof useFenwickMapTree>): { columns: PlacedColumn[]; widthUpdates: Array<{ index: number; value: number }>; width: number } => {
643
+ const columns: PlacedColumn[] = []
644
+ const widthUpdates: Array<{ index: number; value: number }> = []
645
+ let runningLeft = 0
646
+ // T ≤ MAX_FROZEN_TRAILING_COLS のため素朴な O(T) 走査で十分 (幅 0 ジャンプ機構は不要)
647
+ for (let col = colCount - trailingCols; col < colCount; col += 1) {
648
+ const width = getColWidth(col)
649
+ const cached = colTree.get(col)
650
+ if (width !== cached) {
651
+ widthUpdates.push({ index: col, value: width })
652
+ }
653
+ if (width > 0) {
654
+ columns.push({ col, left: runningLeft, width })
655
+ }
656
+ runningLeft += cached
657
+ }
658
+ return { columns, widthUpdates, width: runningLeft }
659
+ }
660
+
661
+ /**
662
+ * THE single JS source for W_T_vis / H_T_vis (v3.5.0 trailing-freeze plan §2.1-4): clips a
663
+ * trailing band's TREE extent to what the viewport can show after the leading band took its
664
+ * share — `min(trailing, max(0, viewport − leading))`. Consumers are exactly three: the
665
+ * trailing-rows band template height, the hbar paddingRight, and the getFrozenSize
666
+ * trailingVisibleWidth/Height export. Never re-derive the formula elsewhere (the CSS max()
667
+ * left edge of `.aqvs-grid-row-trailing` is the registered calc() equivalent — the ONLY
668
+ * exception, annotated in the stylesheet).
669
+ *
670
+ * W_T_vis / H_T_vis の**単一 JS 源** (3.5.0 末尾凍結プラン §2.1-4)。末尾帯の**木**寸を
671
+ * 「先頭帯が取った残り」へクリップする `min(末尾, max(0, viewport − 先頭))`。消費先は
672
+ * 末尾行帯テンプレート高 / hbar paddingRight / getFrozenSize の vis export の 3 点ちょうど —
673
+ * 式を他所へ複製しない (`.aqvs-grid-row-trailing` の CSS max() 左端だけが登記済みの calc()
674
+ * 等価形で、スタイルシート側にインライン登記済み)。
675
+ */
676
+ const trailingVisibleSize = (trailingSize: number, frozenSize: number, viewportSize: number): number => Math.min(trailingSize, Math.max(0, viewportSize - frozenSize))
677
+
678
+ /** Internal committed column-window state (anchor rides with the window — the M10 coherence unit). / コミット済み列窓状態 (アンカーは窓と一体 — M10 整合の単位)。 */
679
+ type ColumnWindowState = {
680
+ renderingColStart: number
681
+ renderingColEnd: number
682
+ visibleColStart: number
683
+ visibleColEnd: number
684
+ colAnchor: number
685
+ /** Monotonic key for cheap bail comparisons. / 安価な bail 比較用の合成キー。 */
686
+ key: string
687
+ }
688
+
689
+ const INITIAL_COLUMN_WINDOW: ColumnWindowState = { renderingColStart: 0, renderingColEnd: 0, visibleColStart: 0, visibleColEnd: 0, colAnchor: 0, key: "0:0:0" }
690
+
691
+ /**
692
+ * VirtualGrid implementation. The vertical axis is the embedded VirtualScroll (unchanged
693
+ * machinery); the horizontal axis mirrors it: logical hx in a float64 ref, the residual as a
694
+ * root-scoped CSS variable (`--aqvs-grid-hx-residual` — written at ONE site) consumed by the
695
+ * grid-owned `.aqvs-grid-row` translateX — or, in frozen mode (v3.3.0), by
696
+ * `.aqvs-grid-row-scroll-inner`'s calc(residual − W_F) while the host row stays static —
697
+ * (package CSS — deliberately NOT via contentProps className, which would clobber the pane's
698
+ * structural class), and the committed column window
699
+ * + anchor as React state so cells and the residual always switch in the same commit (the
700
+ * layout effect below writes the residual for the committed anchor pre-paint — the M10
701
+ * no-tearing invariant).
702
+ *
703
+ * VirtualGrid 実装。縦軸は内包 VirtualScroll (機構無変更)、横軸はその鏡像: 論理 hx は
704
+ * float64 ref、残差はルートの CSS 変数 `--aqvs-grid-hx-residual` (書き手 1 点) をグリッド
705
+ * 所有の `.aqvs-grid-row` translateX — 凍結モード (v3.3.0) では `.aqvs-grid-row-scroll-inner`
706
+ * の calc(残差 − W_F) — が消費 (パッケージ CSS — contentProps className 経由は
707
+ * ペイン構造クラスを破壊するため意図的に不採用)、コミット済み列窓 + アンカーは React state —
708
+ * セルと残差が常に同一コミットで切替わる (下の layout effect がコミット済みアンカーの残差を
709
+ * paint 前に書く = M10 の非 tearing 不変条件)。
710
+ */
711
+ const VirtualGridInner = <T,>(
712
+ {
713
+ rowCount,
714
+ colCount,
715
+ getRowHeight,
716
+ getColWidth,
717
+ getCell,
718
+ getCellKey,
719
+ children,
720
+ getCellProps,
721
+ getRowProps,
722
+ contentProps,
723
+ background,
724
+ contentInsets,
725
+ onRowFocus,
726
+ frozenLeadingCols = 0,
727
+ frozenLeadingRows = 0,
728
+ frozenTrailingCols = 0,
729
+ frozenTrailingRows = 0,
730
+ horizontalKeyInputs,
731
+ horizontalKeyStep,
732
+ onScroll,
733
+ onRangeChange,
734
+ onRenderTruncated,
735
+ callbackThrottleMs,
736
+ initialScrollAnchor,
737
+ overscanRows = DEFAULT_GRID_OVERSCAN,
738
+ overscanCols = DEFAULT_GRID_OVERSCAN,
739
+ viewportSize,
740
+ __maxRenderedCells,
741
+ behaviorOptions,
742
+ scrollBarOptions,
743
+ liveRegion,
744
+ className,
745
+ testId,
746
+ }: VirtualGridProps<T>,
747
+ ref: React.Ref<VirtualGridHandle>,
748
+ ): ReactNode => {
749
+ const scrollHandleRef = useRef<VirtualScrollHandle | null>(null)
750
+ const rootRef = useRef<HTMLDivElement | null>(null)
751
+
752
+ // ---- 検証つきアクセサ (identity は既定で安定 — ref 経由で最新を読む)。resetOn* 指定時のみ
753
+ // 素のアクセサ identity を意図的に踏襲する: 内包 VirtualScroll / 幅木の再構築キーは
754
+ // アクセサ identity のため、恒久固定ラッパーでは reset が構造的に発火しない ----
755
+ const getRowHeightRef = useRef(getRowHeight)
756
+ getRowHeightRef.current = getRowHeight
757
+ const getColWidthRef = useRef(getColWidth)
758
+ getColWidthRef.current = getColWidth
759
+ const resetOnGetColWidthChange = behaviorOptions?.resetOnGetColWidthChange === true
760
+ const resetOnGetRowHeightChange = behaviorOptions?.resetOnGetItemHeightChange === true
761
+ const rowHeightIdentity = resetOnGetRowHeightChange ? getRowHeight : null
762
+ const colWidthIdentity = resetOnGetColWidthChange ? getColWidth : null
763
+ const validatedGetRowHeight = useCallback(
764
+ (row: number) => {
765
+ void rowHeightIdentity
766
+ return validateTrackSize(getRowHeightRef.current(row), "row", row)
767
+ },
768
+ [rowHeightIdentity],
769
+ )
770
+ const validatedGetColWidth = useCallback(
771
+ (col: number) => {
772
+ void colWidthIdentity
773
+ return validateTrackSize(getColWidthRef.current(col), "col", col)
774
+ },
775
+ [colWidthIdentity],
776
+ )
777
+
778
+ // ---- 列幅木 (M1/M2 の第 2 インスタンス — 疎 Map + baseValue サンプリング O(100) + 遅延具現化)。
779
+ // §10.4 の render-phase 変異契約を継承: valueOrFn / options の identity 安定が必須
780
+ // (resetOn* 指定時の identity 変化は意図的な再構築トリガー) ----
781
+ const defaultColWidth = behaviorOptions?.defaultColWidth
782
+ const colTreeOptions = useMemo(() => {
783
+ // defaultColWidth はトラックサイズと同じ検査 (fail-fast — 不正な見積り値で総幅を汚染しない)
784
+ if (defaultColWidth !== undefined && !(Number.isInteger(defaultColWidth) && defaultColWidth >= 0 && defaultColWidth <= MAX_TRACK_SIZE)) {
785
+ throw new RangeError(`[VirtualGrid] behaviorOptions.defaultColWidth must be an integer in [0, ${MAX_TRACK_SIZE}], received ${defaultColWidth}.`)
786
+ }
787
+ return { resetOnValueFnChange: resetOnGetColWidthChange, baseValue: defaultColWidth }
788
+ }, [resetOnGetColWidthChange, defaultColWidth])
789
+ const colTree = useFenwickMapTree(colCount, validatedGetColWidth, colTreeOptions)
790
+
791
+ // ---- 幅エポック (木の内容バージョン): updateColSize / self-heal 適用で繰上げ、窓キー不変でも
792
+ // 配置 memo と列窓再計算を失効させる。行側は総高 (contentSize) を反応辺にするが、相殺
793
+ // バッチリサイズ (総和不変) も拾えるようエポックはその厳密上位互換 ----
794
+ const [widthEpoch, setWidthEpoch] = useState(0)
795
+
796
+ // ---- 凍結帯 (設計 §8) — 形状検証は fail-fast、colCount 超過だけは動的クランプ (シート
797
+ // 切替の過渡で colCount が一時 0 になる正当構成を throw で殺さないための文書化契約) ----
798
+ if (!(Number.isInteger(frozenLeadingCols) && frozenLeadingCols >= 0 && frozenLeadingCols <= MAX_FROZEN_LEADING_COLS)) {
799
+ throw new RangeError(`[VirtualGrid] frozenLeadingCols must be an integer in [0, ${MAX_FROZEN_LEADING_COLS}], received ${frozenLeadingCols}.`)
800
+ }
801
+ const effectiveFrozenCols = Math.min(frozenLeadingCols, colCount)
802
+ /** Fresh W_F for drag loops / clamps (mirrored from the frozen memo each render). / ドラッグループ / クランプ用の鮮度 W_F (凍結 memo から毎レンダー写像)。 */
803
+ const frozenWidthRef = useRef(0)
804
+
805
+ // ---- 末尾凍結列帯 (3.5.0 プラン §6.1-3) — 先頭側と同文の fail-fast。動的クランプは
806
+ // min(T, colCount − 先頭実効) で**先頭がカウント空間で勝つ** (ADR-19-1 の文書化契約) ----
807
+ if (!(Number.isInteger(frozenTrailingCols) && frozenTrailingCols >= 0 && frozenTrailingCols <= MAX_FROZEN_TRAILING_COLS)) {
808
+ throw new RangeError(`[VirtualGrid] frozenTrailingCols must be an integer in [0, ${MAX_FROZEN_TRAILING_COLS}], received ${frozenTrailingCols}.`)
809
+ }
810
+ const effectiveTrailingCols = Math.min(frozenTrailingCols, colCount - effectiveFrozenCols)
811
+ /** Fresh W_T for drag loops / clamps (mirrored from the trailing memo each render). / ドラッグループ / クランプ用の鮮度 W_T (末尾 memo から毎レンダー写像)。 */
812
+ const trailingWidthRef = useRef(0)
813
+
814
+ // ---- 凍結行帯 (行凍結設計 §4-1) — 列と同文の fail-fast + 動的クランプ ----
815
+ if (!(Number.isInteger(frozenLeadingRows) && frozenLeadingRows >= 0 && frozenLeadingRows <= MAX_FROZEN_LEADING_ROWS)) {
816
+ throw new RangeError(`[VirtualGrid] frozenLeadingRows must be an integer in [0, ${MAX_FROZEN_LEADING_ROWS}], received ${frozenLeadingRows}.`)
817
+ }
818
+ const effectiveFrozenRows = Math.min(frozenLeadingRows, rowCount)
819
+
820
+ // ---- 末尾凍結行帯 (3.5.0 プラン §6.1-3) — 列側と同文の fail-fast + 先頭勝ちクランプ ----
821
+ if (!(Number.isInteger(frozenTrailingRows) && frozenTrailingRows >= 0 && frozenTrailingRows <= MAX_FROZEN_TRAILING_ROWS)) {
822
+ throw new RangeError(`[VirtualGrid] frozenTrailingRows must be an integer in [0, ${MAX_FROZEN_TRAILING_ROWS}], received ${frozenTrailingRows}.`)
823
+ }
824
+ const effectiveTrailingRows = Math.min(frozenTrailingRows, rowCount - effectiveFrozenRows)
825
+ /** Scroll-row count in the SHIFTED space (design §2; the tail loses T — the embedded VirtualScroll's itemCount). / シフト空間のスクロール行数 (末尾は −T — 埋め込み VirtualScroll の itemCount)。 */
826
+ const scrollRowCount = Math.max(0, rowCount - effectiveFrozenRows - effectiveTrailingRows)
827
+ /** Degenerate-band flag ref (H_F >= measured viewport — §3.3 の縮退帯と同じ正準空窓へ). / 縮退帯フラグ ref (H_F ≥ 実測ビューポート — §3.3 縮退帯と同じ正準空窓)。 */
828
+ const degenerateBandRef = useRef(false)
829
+ /** Fresh H_F for handle reads (mirrored from the band memo each render). / ハンドル読み用の鮮度 H_F (帯 memo から毎レンダー写像)。 */
830
+ const frozenHeightRef = useRef(0)
831
+ /** Fresh H_T for handle reads (mirrored from the trailing band memo each render). / ハンドル読み用の鮮度 H_T (末尾帯 memo から毎レンダー写像)。 */
832
+ const trailingHeightRef = useRef(0)
833
+
834
+ // ---- 凍結行帯の列挙 (行凍結設計 §4-2) — グリッド所有の帯外バンド。行高の真実は
835
+ // アクセサ直読 (列帯と違い第 2 の格納庫 = 木 が無いためヒール機構は不要 — 更新経路は
836
+ // updateRowSize の epoch 繰上げとアクセサ identity 変化のみ)。高さ 0 行は DOM 非生成 ----
837
+ const [frozenRowEpoch, setFrozenRowEpoch] = useState(0)
838
+ const { frozenRows, frozenHeight } = useMemo(() => {
839
+ void frozenRowEpoch
840
+ if (effectiveFrozenRows === 0) {
841
+ return { frozenRows: [] as Array<{ row: number; top: number; height: number }>, frozenHeight: 0 }
842
+ }
843
+ const rows: Array<{ row: number; top: number; height: number }> = []
844
+ let running = 0
845
+ for (let row = 0; row < effectiveFrozenRows; row += 1) {
846
+ const height = validatedGetRowHeight(row)
847
+ if (height > 0) {
848
+ rows.push({ row, top: running, height })
849
+ }
850
+ running += height
851
+ }
852
+ return { frozenRows: rows, frozenHeight: running }
853
+ }, [effectiveFrozenRows, frozenRowEpoch, validatedGetRowHeight])
854
+ frozenHeightRef.current = frozenHeight
855
+
856
+ // ---- 末尾行帯の列挙 (3.5.0 プラン §6.1-11) — 先頭行帯と同形の帯外バンド。木もヒール機構も
857
+ // 持たない (アクセサが唯一の真実 — 先頭行帯の宣言済み非対称と同文)。top は帯ローカル
858
+ // 接頭辞和 (inner の bottom 定着が右アンカーの行版を担う)。高さ 0 行は DOM 非生成 ----
859
+ const [trailingRowEpoch, setTrailingRowEpoch] = useState(0)
860
+ const { trailingRows, trailingHeight } = useMemo(() => {
861
+ void trailingRowEpoch
862
+ if (effectiveTrailingRows === 0) {
863
+ return { trailingRows: [] as Array<{ row: number; top: number; height: number }>, trailingHeight: 0 }
864
+ }
865
+ const rows: Array<{ row: number; top: number; height: number }> = []
866
+ let running = 0
867
+ for (let row = rowCount - effectiveTrailingRows; row < rowCount; row += 1) {
868
+ const height = validatedGetRowHeight(row)
869
+ if (height > 0) {
870
+ rows.push({ row, top: running, height })
871
+ }
872
+ running += height
873
+ }
874
+ return { trailingRows: rows, trailingHeight: running }
875
+ }, [effectiveTrailingRows, rowCount, trailingRowEpoch, validatedGetRowHeight])
876
+ trailingHeightRef.current = trailingHeight
877
+ /** Shifted row-height accessor for the embedded VirtualScroll (design §2). / 埋め込み VirtualScroll 用のシフト済み行高アクセサ (設計 §2)。 */
878
+ const scrollGetRowHeight = useCallback((index: number) => validatedGetRowHeight(index + effectiveFrozenRows), [validatedGetRowHeight, effectiveFrozenRows])
879
+ // 行フォーカス通知はフル行空間へ +R 翻訳する (シフト漏れの是正 — 素通しは R > 0 でフル行 r の
880
+ // フォーカスを r − R として報告してしまう)。identity は [effectiveFrozenRows] のみで安定化する
881
+ // — インラインアロー直渡しは毎レンダーで prop identity を変え、内包側 handleItemFocus 経由で
882
+ // React.memo VirtualScrollItem の全行を再レンダーさせる (横スクロールの setBarHx 毎 rAF で発火。
883
+ // R2 是正)。onRowFocus は ref 経由で読み、その identity 変化を deps に含めない
884
+ const onRowFocusRef = useRef(onRowFocus)
885
+ onRowFocusRef.current = onRowFocus
886
+ const hasRowFocus = onRowFocus !== undefined
887
+ const shiftedRowFocus = useMemo(() => (hasRowFocus ? (index: number) => onRowFocusRef.current?.(index + effectiveFrozenRows) : undefined), [effectiveFrozenRows, hasRowFocus])
888
+ /** Reactive key for H_F consumers (memo edge — the ref alone would not invalidate). / H_F 消費 memo の反応辺キー (ref 直読は失効しない)。 */
889
+ const frozenHeightEpochKey = frozenHeight
890
+ /** Reactive key for H_T consumers (the trailing twin). / H_T 消費 memo の反応辺キー (末尾双子)。 */
891
+ const trailingHeightEpochKey = trailingHeight
892
+
893
+ // ---- R の実行時変更 (R1 是正): シフト空間の再ペアリングは埋め込み木にとって「中間削除」で、
894
+ // 木の changeSize (末尾意味論) では歪む (実測 +228px の総高汚染)。key 再マウントで木を
895
+ // 作り直し、可視先頭アンカーをフル行空間で持ち越して復元する (視点保存) ----
896
+ const [prevFrozenRows, setPrevFrozenRows] = useState(effectiveFrozenRows)
897
+ const toggleAnchorRef = useRef<{ row: number; offsetPx: number } | null>(null)
898
+ // トグル発生フラグ (捕捉値とは独立の三状態化 — R2 是正): 一度でもトグルすれば、以後は
899
+ // マウント時 initialScrollAnchor props を絶対に再適用しない。捕捉が null (退場側 itemCount 0 =
900
+ // 全行凍結 / 縮退帯 / rowCount 過渡 0) でも props へ落ちず undefined (先頭) にする — さもなくば
901
+ // 消費済みのマウントアンカーが key 再マウントで蘇り視点がテレポートする (実測 y=1200)
902
+ const toggledRef = useRef(false)
903
+ if (prevFrozenRows !== effectiveFrozenRows) {
904
+ setPrevFrozenRows(effectiveFrozenRows)
905
+ toggledRef.current = true
906
+ // 旧 R 空間の内包側アンカーを捕捉し、フル行空間へ +旧R で翻訳して保存 (render 中の
907
+ // ref 書きは derived-state パターンの捕捉 — 同コミットの key 再マウントだけが消費する)
908
+ const innerAnchor = scrollHandleRef.current?.getScrollAnchor() ?? null
909
+ toggleAnchorRef.current = innerAnchor === null ? null : { row: innerAnchor.index + prevFrozenRows, offsetPx: innerAnchor.offsetPx }
910
+ }
911
+
912
+ // ---- 論理位置 (float64 ref — DOM へは書かない) と鮮度序列 ----
913
+ const hxRef = useRef(0)
914
+ const vyRef = useRef(0)
915
+ const attachedRef = useRef(false)
916
+
917
+ // ---- コミット済み列窓 + アンカー (M10 整合の単位) ----
918
+ const [columnWindow, setColumnWindow] = useState<ColumnWindowState>(INITIAL_COLUMN_WINDOW)
919
+ const columnWindowRef = useRef(columnWindow)
920
+ columnWindowRef.current = columnWindow
921
+ /** Anchor the DOM currently shows (updated post-commit in the layout effect). / DOM が現在表示中のアンカー (commit 後の layout effect で更新)。 */
922
+ const committedAnchorRef = useRef(0)
923
+
924
+ const [totalWidth, setTotalWidth] = useState(() => colTree.getTotal() ?? 0)
925
+ const totalWidthRef = useRef(totalWidth)
926
+ totalWidthRef.current = totalWidth
927
+ /** Mirrors the tree total into state+ref immediately (same tick — drag loops clamp fresh). / 木総幅を state + ref へ即時反映 (同 tick — ドラッグループが新鮮にクランプ)。 */
928
+ const commitTotalWidth = useCallback((total: number | undefined) => {
929
+ // undefined 分岐は木 API の宣言型 (`number | undefined`) 由来。isFinite は正常経路で
930
+ // 反証不能な防御分岐 (木は入力を事前検証する) — §7 に未検証防御として記録
931
+ if (typeof total === "number" && Number.isFinite(total)) {
932
+ totalWidthRef.current = total
933
+ setTotalWidth(total)
934
+ }
935
+ }, [])
936
+ // ---- 総幅と木の恒常同期: colCount 変化・0 → N 伸長・resetOn* 再構築は木を作り替えるが、
937
+ // state はそれを自動では知らない。毎コミットの bail つき照合で木総幅 (真実) へ追随する ----
938
+ useLayoutEffect(() => {
939
+ const total = colTree.getTotal()
940
+ if (typeof total === "number" && Number.isFinite(total) && total !== totalWidthRef.current) {
941
+ commitTotalWidth(total)
942
+ }
943
+ })
944
+
945
+ // ---- ビューポート実測 (RO — 縦バー幅ぶんを列窓から差し引く) ----
946
+ const scrollBarWidth = scrollBarOptions?.width ?? 12
947
+ const explicitViewport = viewportSize === undefined ? null : { width: Math.max(0, Math.round(viewportSize.width) - scrollBarWidth), height: Math.max(0, Math.round(viewportSize.height) - scrollBarWidth) }
948
+ const [measuredViewport, setMeasuredViewport] = useState({ width: 0, height: 0 })
949
+ const viewport = explicitViewport ?? measuredViewport
950
+ const viewportRef = useRef(viewport)
951
+ viewportRef.current = viewport
952
+ /** 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 を一般化)。 */
953
+ const degenerateBand = viewport.height > 0 && viewport.height - frozenHeight - trailingHeight <= 0
954
+ degenerateBandRef.current = degenerateBand
955
+ const hasExplicitViewport = explicitViewport !== null
956
+ useLayoutEffect(() => {
957
+ if (hasExplicitViewport) {
958
+ return
959
+ }
960
+ const root = rootRef.current
961
+ if (root === null) {
962
+ return
963
+ }
964
+ const measure = () => {
965
+ // ❗ 論理 (レイアウト) px で測る — getBoundingClientRect は祖先 transform scale 下で
966
+ // 視覚 px (論理 × z) を返し、ズームホストで窓寸・クランプ・列窓の全幾何が z 倍へ歪む。
967
+ // clientWidth / clientHeight は transform 非影響のレイアウト px (行側 ScrollPane の
968
+ // 自己計測と同じ座標系)
969
+ const width = Math.max(0, Math.round(root.clientWidth) - scrollBarWidth)
970
+ const height = Math.max(0, Math.round(root.clientHeight) - scrollBarWidth)
971
+ setMeasuredViewport((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
972
+ }
973
+ measure()
974
+ const observer = new ResizeObserver(measure)
975
+ observer.observe(root)
976
+ return () => observer.disconnect()
977
+ }, [scrollBarWidth, hasExplicitViewport])
978
+
979
+ // ---- 残差 var の書き手 (1 点)。コミット済みアンカー基準 — スクロール中の中間フレームでも
980
+ // セル left (コミット済みアンカー) と常に同一基準で整合する ----
981
+ const writeResidual = useCallback(() => {
982
+ const root = rootRef.current
983
+ if (root === null) {
984
+ return
985
+ }
986
+ root.style.setProperty("--aqvs-grid-hx-residual", `${committedAnchorRef.current - hxRef.current}px`)
987
+ }, [])
988
+
989
+ /**
990
+ * Fresh scroll-band width from refs (v3.5.0 trailing-freeze plan §6.1-6 — the ONE ref-space
991
+ * source): viewport minus BOTH band extents. Replaces the three former inline
992
+ * `max(0, viewport − W_F)` sites (applyHx / refreshColumns / resolveColumnAnchorHx) so a
993
+ * W_T change can never go stale in just one of them. T = 0 reduces to the pre-trailing form.
994
+ * ref 空間のスクロール帯幅 (3.5.0 末尾凍結プラン §6.1-6 — ref 面の単一源)。viewport から
995
+ * 両端の帯寸を控除する。旧 3 箇所のインライン `max(0, viewport − W_F)` を置換し、W_T 変化が
996
+ * 1 箇所だけ stale 化する余地を構造的に断つ。T = 0 は従来式へ恒等縮退。
997
+ */
998
+ const scrollBandWidthRef = useCallback(() => Math.max(0, viewportRef.current.width - frozenWidthRef.current - trailingWidthRef.current), [])
999
+
1000
+ // ---- 列窓の再計算 (setState は窓 / アンカー変化時のみ — key 比較で bail) ----
1001
+ const refreshColumns = useCallback(() => {
1002
+ // 凍結帯 (§8-a): 窓探索は木座標 W_F + hx 始まり・帯幅 = viewport − W_F − W_T (3.5.0 一般化)
1003
+ const bandWidth = scrollBandWidthRef()
1004
+ const placement = computeColumnPlacement(frozenWidthRef.current + hxRef.current, bandWidth, overscanCols, colCount, validatedGetColWidth, colTree, totalWidthRef.current, columnWindowRef.current.colAnchor)
1005
+ // 幅 0 の凍結列があると indexAt(W_F + hx) が F 未満へ届く — スクロール窓に凍結列を
1006
+ // 再出現させない床クランプ (§8-c: 凍結帯の描画は凍結 subtree の専有)。床は F そのもの:
1007
+ // 全列凍結 (F ≥ colCount) では start = colCount > end となり、start > end = 空窓が
1008
+ // スクロール帯の正準表現 (修理して非空へ戻すと最終凍結列が両帯へ重複する — R1 実証)。
1009
+ // effectiveFrozenCols > 0 は colCount > 0 を含意する (min クランプ) ため条件は 1 項
1010
+ if (effectiveFrozenCols > 0) {
1011
+ placement.renderingColStart = Math.max(placement.renderingColStart, effectiveFrozenCols)
1012
+ placement.visibleColStart = Math.max(placement.visibleColStart, effectiveFrozenCols)
1013
+ }
1014
+ // 末尾帯の ceiling clamp (3.5.0 プラン §6.1-6 — F 床クランプの鏡像・anti-#4454):
1015
+ // stale-bandWidth や幅 0 末尾列で窓末端が末尾帯へ届いても、スクロール窓に末尾列を
1016
+ // 再出現させない。天井は colCount − T_eff − 1 そのもの
1017
+ if (effectiveTrailingCols > 0) {
1018
+ const ceiling = colCount - effectiveTrailingCols - 1
1019
+ placement.renderingColEnd = Math.min(placement.renderingColEnd, ceiling)
1020
+ placement.visibleColEnd = Math.min(placement.visibleColEnd, ceiling)
1021
+ }
1022
+ // 空窓ガードの一般化 (3.5.0 プラン §6.1-6): F > 0 または T > 0 のとき、縮退帯
1023
+ // (帯幅 ≤ 0 — W_F + W_T ≥ viewport) と clamp の結果 start > end になった窓は
1024
+ // 全列凍結と同じ空窓正準形へ整形する (非空の可視窓を報告するとヘッダー同期 /
1025
+ // liveRegion 消費側がセル無き列を描く — R3 実証の非対称の解消。T のみ縮退の
1026
+ // phantom 窓もここで封じる)
1027
+ if ((effectiveFrozenCols > 0 || effectiveTrailingCols > 0) && (bandWidth <= 0 || placement.renderingColStart > placement.renderingColEnd)) {
1028
+ placement.renderingColStart = colCount
1029
+ placement.renderingColEnd = colCount - 1
1030
+ placement.visibleColStart = colCount
1031
+ placement.visibleColEnd = colCount - 1
1032
+ }
1033
+ const key = `${placement.renderingColStart}:${placement.renderingColEnd}:${placement.colAnchor}`
1034
+ if (key !== columnWindowRef.current.key) {
1035
+ setColumnWindow({ ...placement, key })
1036
+ }
1037
+ }, [overscanCols, colCount, validatedGetColWidth, colTree, effectiveFrozenCols, effectiveTrailingCols, scrollBandWidthRef])
1038
+
1039
+ // ---- 横スクロールバーの追従 (rAF 合流の軽量 state — React 毎フレーム再レンダー回避) ----
1040
+ const [barHx, setBarHx] = useState(0)
1041
+ const barFrameRef = useRef<number | null>(null)
1042
+ const scheduleBarSync = useCallback(() => {
1043
+ if (barFrameRef.current !== null) {
1044
+ return
1045
+ }
1046
+ barFrameRef.current = requestAnimationFrame(() => {
1047
+ barFrameRef.current = null
1048
+ setBarHx(hxRef.current)
1049
+ })
1050
+ }, [])
1051
+ useEffect(
1052
+ () => () => {
1053
+ if (barFrameRef.current !== null) {
1054
+ cancelAnimationFrame(barFrameRef.current)
1055
+ }
1056
+ },
1057
+ [],
1058
+ )
1059
+
1060
+ // ---- onScroll / onRangeChange の合成通知 ----
1061
+ const onScrollRef = useRef(onScroll)
1062
+ onScrollRef.current = onScroll
1063
+ const onRangeChangeRef = useRef(onRangeChange)
1064
+ onRangeChangeRef.current = onRangeChange
1065
+ const onRenderTruncatedRef = useRef(onRenderTruncated)
1066
+ onRenderTruncatedRef.current = onRenderTruncated
1067
+ const rowRangeRef = useRef<VirtualScrollRange | null>(null)
1068
+ // 描画行数はセル上限の分母。実測通知 (state) は 1 コミット遅れるため、通知到着までは
1069
+ // 同期見積り (具現化行ウォーク) を分母に使う — マウントコミットの上限突破 (分母 1) の根治
1070
+ const [notifiedRowCount, setNotifiedRowCount] = useState<number | null>(null)
1071
+ // ビューポート高が変わるコミットも通知が 1 コミット遅れる — 古い実測を render 中に即時無効化し、
1072
+ // 同一コミットから新鮮な見積りへ切替える (derived-state パターン。リサイズ拡大時の上限突破の根治)
1073
+ const [prevViewportHeight, setPrevViewportHeight] = useState(viewport.height)
1074
+ if (prevViewportHeight !== viewport.height) {
1075
+ setPrevViewportHeight(viewport.height)
1076
+ setNotifiedRowCount(null)
1077
+ }
1078
+ const initialAnchorRow = initialScrollAnchor === undefined || rowCount === 0 ? 0 : minmax(Math.trunc(initialScrollAnchor.row), 0, rowCount - 1)
1079
+ const estimatedRowCount = useMemo(() => {
1080
+ // 見積り基点は現在の行窓先頭 (到達済みなら) — マウント前は初期アンカー行。
1081
+ // リサイズ時に初期アンカー基点のままだと現在位置と別領域の行高で見積もってしまう
1082
+ const basisRow = rowRangeRef.current?.renderingStartIndex ?? initialAnchorRow
1083
+ // 凍結行帯 (先頭 + 末尾) はスクロール行の見積り高から控除する (帯外バンド — 設計 §4-3、
1084
+ // 3.5.0 プラン §6.1-12: −H_T 欠落は高い末尾帯のマウントコミットで偽打切りを発火させる)
1085
+ return estimateRenderedRowCount(Math.max(0, viewport.height - frozenHeightRef.current - trailingHeightRef.current), validatedGetRowHeight, rowCount, basisRow, overscanRows)
1086
+ }, [viewport.height, validatedGetRowHeight, rowCount, initialAnchorRow, overscanRows, frozenHeightEpochKey, trailingHeightEpochKey])
1087
+ const effectiveRenderedRows = notifiedRowCount ?? estimatedRowCount
1088
+
1089
+ const buildRange = useCallback((): VirtualGridRange | null => {
1090
+ const rows = rowRangeRef.current
1091
+ if (rows === null) {
1092
+ return null
1093
+ }
1094
+ const cols = columnWindowRef.current
1095
+ return {
1096
+ renderingRowStart: rows.renderingStartIndex,
1097
+ renderingRowEnd: rows.renderingEndIndex,
1098
+ visibleRowStart: rows.visibleStartIndex,
1099
+ visibleRowEnd: rows.visibleEndIndex,
1100
+ renderingColStart: cols.renderingColStart,
1101
+ renderingColEnd: cols.renderingColEnd,
1102
+ visibleColStart: cols.visibleColStart,
1103
+ visibleColEnd: cols.visibleColEnd,
1104
+ scrollX: hxRef.current,
1105
+ scrollY: vyRef.current,
1106
+ totalWidth: totalWidthRef.current,
1107
+ // 帯高は鮮度加算 (格納はスクロール木のみ — 帯行リサイズ後も総高が正しい。R1 是正。
1108
+ // 3.5.0: 末尾帯高 H_T も同じ鮮度規律で加算)
1109
+ totalHeight: rows.totalHeight + frozenHeightRef.current + trailingHeightRef.current,
1110
+ }
1111
+ }, [])
1112
+
1113
+ const emitRangeChange = useCallback(() => {
1114
+ const range = buildRange()
1115
+ if (range !== null) {
1116
+ onRangeChangeRef.current?.(range)
1117
+ }
1118
+ }, [buildRange])
1119
+
1120
+ // ---- applyHx — 横論理位置の唯一の書き口 (clamp → ref → 残差 var → 列窓 → 通知 → クランプ後値 return) ----
1121
+ const applyHx = useCallback(
1122
+ (next: number): number => {
1123
+ // 凍結帯 (§8): スクロール帯幅 = viewport − W_F − W_T (3.5.0 一般化)。maxHx =
1124
+ // (総幅 − W_F − W_T) − 帯幅で、W_F + W_T < viewport のとき従来式 (総幅 − viewport)
1125
+ // と一致する (F = T = 0 で恒等)
1126
+ const bandWidth = scrollBandWidthRef()
1127
+ const maxHx = Math.max(0, totalWidthRef.current - frozenWidthRef.current - trailingWidthRef.current - bandWidth)
1128
+ const clamped = minmax(next, 0, maxHx)
1129
+ if (clamped !== hxRef.current) {
1130
+ hxRef.current = clamped
1131
+ writeResidual()
1132
+ refreshColumns()
1133
+ scheduleBarSync()
1134
+ onScrollRef.current?.({ x: clamped, y: vyRef.current })
1135
+ }
1136
+ return clamped
1137
+ },
1138
+ [writeResidual, refreshColumns, scheduleBarSync, scrollBandWidthRef],
1139
+ )
1140
+ const applyHxRef = useRef(applyHx)
1141
+ applyHxRef.current = applyHx
1142
+
1143
+ // ---- コミット後のアンカー公開 + 残差再書き (M10: セル left とペアで paint 前に切替) ----
1144
+ useLayoutEffect(() => {
1145
+ committedAnchorRef.current = columnWindow.colAnchor
1146
+ writeResidual()
1147
+ emitRangeChange()
1148
+ }, [columnWindow, writeResidual, emitRangeChange])
1149
+
1150
+ // ---- 帯高変化の再通知 (R1 是正): 帯行リサイズは埋め込み側の onRangeChange を発火させない
1151
+ // (帯は木の外) — buildRange の鮮度加算と対で、消費側とライブリージョンへ明示再配送する ----
1152
+ useLayoutEffect(() => {
1153
+ if (rowRangeRef.current !== null) {
1154
+ emitRangeChange()
1155
+ }
1156
+ }, [frozenHeight, trailingHeight, emitRangeChange])
1157
+
1158
+ // ---- 保留列アンカー (行側 scrollToIndex のアンカー張りの列対): initialScrollAnchor 復元と
1159
+ // scrollToCell が張り、self-heal / 総幅同期 / リサイズで列左が動くたび paint 前に再ピン留め
1160
+ // する (推定空間 → 実測空間の遷移をアンカーが生き延びる = M5)。手動横入力で解除 ----
1161
+ const pendingColAnchorRef = useRef<{ col: number; alignX: "start" | "end" | "center"; offsetX: number } | null>(null)
1162
+ /** Resolves the pending column anchor to an hx in the CURRENT tree space. / 保留列アンカーを現在の木空間の hx へ解決。 */
1163
+ const resolveColumnAnchorHx = useCallback(
1164
+ (anchor: { col: number; alignX: "start" | "end" | "center"; offsetX: number }): number => {
1165
+ // colCount 縮小で範囲外化したアンカーは最終列へ明示クランプ (fail-safe — 木側の暗黙
1166
+ // クランプへ依存しない。挙動は §7 の既知の制約として記録)
1167
+ const safeCol = minmax(anchor.col, 0, Math.max(0, colCount - 1))
1168
+ const prefix = colTree.prefixSum(safeCol, { materializeOption: { materialize: false } })
1169
+ const colLeft = prefix.cumulative - prefix.currentValue
1170
+ const colWidth = prefix.currentValue
1171
+ // 凍結帯 (§8): 整列先はスクロール帯 [W_F, viewport − W_T) — 列座標から W_F を引き、
1172
+ // 幅もスクロール帯幅 (3.5.0 一般化: −W_T 済み) で測る (F = T = 0 では従来式と恒等。
1173
+ // "end" は目標右端を viewport − W_T へ着地させる — §8 の VALUE 行がピンする形)
1174
+ const bandWidth = scrollBandWidthRef()
1175
+ const base = anchor.alignX === "end" ? colLeft + colWidth - frozenWidthRef.current - bandWidth : anchor.alignX === "center" ? colLeft + colWidth / 2 - frozenWidthRef.current - bandWidth / 2 : colLeft - frozenWidthRef.current
1176
+ return base + anchor.offsetX
1177
+ },
1178
+ [colTree, colCount, scrollBandWidthRef],
1179
+ )
1180
+
1181
+ // ---- 幅 self-heal (窓内の getColWidth と木の乖離をバッチ適用 — 行 memo の toUpdateHeights 双子)。
1182
+ // 適用後はエポックを繰上げ、治癒前の stale な左座標で確定したセル配置を再計算させる ----
1183
+ const pendingWidthUpdatesRef = useRef<Array<{ index: number; value: number }> | null>(null)
1184
+ const flushWidthUpdates = useCallback(() => {
1185
+ const updates = pendingWidthUpdatesRef.current
1186
+ pendingWidthUpdatesRef.current = null
1187
+ if (updates === null || updates.length === 0) {
1188
+ return
1189
+ }
1190
+ commitTotalWidth(colTree.updates(updates))
1191
+ setWidthEpoch((epoch) => epoch + 1)
1192
+ }, [colTree, commitTotalWidth])
1193
+
1194
+ // ---- 凍結帯の列挙 (設計 §8 — 窓化されない静的帯)。幅 self-heal は共有バッチへ合流し、
1195
+ // 暴走ガードは帯専用カウンタ (スクロール窓側と独立 — 凍結専用振動アクセサが窓側の
1196
+ // 収束リセットで永久にガードを回避する穴を作らない) ----
1197
+ const frozenHealBurstRef = useRef({ count: 0, warned: false })
1198
+ const { frozenColumns, frozenWidth } = useMemo(() => {
1199
+ // widthEpoch は木内容バージョンの反応辺 (スクロール窓 memo と同じ規律)
1200
+ void widthEpoch
1201
+ if (effectiveFrozenCols === 0) {
1202
+ // 凍結解除は帯ガードのエピソードも閉じる — 次の凍結エピソードが停止状態を継承して
1203
+ // 無言で self-heal 不能になる越境リークの遮断 (R3 実証)
1204
+ frozenHealBurstRef.current.count = 0
1205
+ frozenHealBurstRef.current.warned = false
1206
+ return { frozenColumns: EMPTY_PLACED_COLUMNS, frozenWidth: 0 }
1207
+ }
1208
+ const { columns, widthUpdates, width } = collectFrozenColumns(effectiveFrozenCols, validatedGetColWidth, colTree)
1209
+ if (widthUpdates.length > 0) {
1210
+ const burst = frozenHealBurstRef.current
1211
+ if (burst.count >= WIDTH_HEAL_BURST_LIMIT) {
1212
+ if (!burst.warned) {
1213
+ burst.warned = true
1214
+ Logger.warn("[VirtualGrid] getColWidth keeps disagreeing with its own previous values inside the frozen band; width self-heal is suspended until a converged pass re-arms it (unstable accessor).", {
1215
+ frozenCols: effectiveFrozenCols,
1216
+ pendingUpdates: widthUpdates.length,
1217
+ })
1218
+ }
1219
+ } else {
1220
+ burst.count += 1
1221
+ // スクロール窓側と同じ共有バッチへ合流 (append — 同 tick の両者の治癒を 1 フラッシュに)
1222
+ pendingWidthUpdatesRef.current = [...(pendingWidthUpdatesRef.current ?? []), ...widthUpdates]
1223
+ Promise.resolve().then(flushWidthUpdates)
1224
+ }
1225
+ } else {
1226
+ frozenHealBurstRef.current.count = 0
1227
+ frozenHealBurstRef.current.warned = false
1228
+ }
1229
+ return { frozenColumns: columns, frozenWidth: width }
1230
+ }, [effectiveFrozenCols, widthEpoch, validatedGetColWidth, colTree, flushWidthUpdates])
1231
+ frozenWidthRef.current = frozenWidth
1232
+ // 凍結幅 var の書き手 (1 点): クリップ左端 (.aqvs-grid-row-scroll の left) と残差の −W_F 項
1233
+ // (.aqvs-grid-row-scroll-inner の calc) の両消費者が読む
1234
+ useLayoutEffect(() => {
1235
+ // F = 0 は DOM 構造 (インライン属性まで) 従来と恒等に保つ — var は凍結モードのみ書き、
1236
+ // 解除時は除去する (CSS 側は 0px フォールバックを持つため除去で安全)
1237
+ if (effectiveFrozenCols > 0) {
1238
+ rootRef.current?.style.setProperty("--aqvs-grid-frozen-width", `${frozenWidth}px`)
1239
+ } else {
1240
+ rootRef.current?.style.removeProperty("--aqvs-grid-frozen-width")
1241
+ }
1242
+ }, [frozenWidth, effectiveFrozenCols])
1243
+
1244
+ // ---- 末尾列帯の列挙 (3.5.0 プラン §6.1-7 — 先頭帯 memo の接尾辞双子)。self-heal 差分は
1245
+ // 共有バッチへ append 合流し、暴走ガードは帯専用の独立カウンタ (T = 0 でエピソードを
1246
+ // 閉じる — 先頭帯の「越境リーク遮断」と同文) ----
1247
+ const trailingHealBurstRef = useRef({ count: 0, warned: false })
1248
+ const { trailingColumns, trailingWidth } = useMemo(() => {
1249
+ // widthEpoch は木内容バージョンの反応辺 (先頭帯 memo と同じ規律)
1250
+ void widthEpoch
1251
+ if (effectiveTrailingCols === 0) {
1252
+ // 末尾凍結解除は帯ガードのエピソードも閉じる (先頭帯の R3 実証と同文の遮断)
1253
+ trailingHealBurstRef.current.count = 0
1254
+ trailingHealBurstRef.current.warned = false
1255
+ return { trailingColumns: EMPTY_PLACED_COLUMNS, trailingWidth: 0 }
1256
+ }
1257
+ const { columns, widthUpdates, width } = collectTrailingColumns(colCount, effectiveTrailingCols, validatedGetColWidth, colTree)
1258
+ if (widthUpdates.length > 0) {
1259
+ const burst = trailingHealBurstRef.current
1260
+ if (burst.count >= WIDTH_HEAL_BURST_LIMIT) {
1261
+ if (!burst.warned) {
1262
+ burst.warned = true
1263
+ 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).", {
1264
+ trailingCols: effectiveTrailingCols,
1265
+ pendingUpdates: widthUpdates.length,
1266
+ })
1267
+ }
1268
+ } else {
1269
+ burst.count += 1
1270
+ // 共有バッチへ append 合流 (同 tick の先頭帯 / スクロール窓の治癒を上書きで落とさない)
1271
+ pendingWidthUpdatesRef.current = [...(pendingWidthUpdatesRef.current ?? []), ...widthUpdates]
1272
+ Promise.resolve().then(flushWidthUpdates)
1273
+ }
1274
+ } else {
1275
+ trailingHealBurstRef.current.count = 0
1276
+ trailingHealBurstRef.current.warned = false
1277
+ }
1278
+ return { trailingColumns: columns, trailingWidth: width }
1279
+ }, [effectiveTrailingCols, colCount, widthEpoch, validatedGetColWidth, colTree, flushWidthUpdates])
1280
+ trailingWidthRef.current = trailingWidth
1281
+ // 末尾幅 var の書き手 (3.5.0 プラン §6.1-8 — **単一書き手規律**: この effect だけが書く)。
1282
+ // 消費者はクリップ右端 (.aqvs-grid-row-scroll の right)・末尾クリップの max() 左端と inner 幅。
1283
+ // T = 0 は除去 (T = 0 恒等 — CSS 側 0px フォールバックで安全)
1284
+ useLayoutEffect(() => {
1285
+ if (effectiveTrailingCols > 0) {
1286
+ rootRef.current?.style.setProperty("--aqvs-grid-trailing-width", `${trailingWidth}px`)
1287
+ } else {
1288
+ rootRef.current?.style.removeProperty("--aqvs-grid-trailing-width")
1289
+ }
1290
+ }, [trailingWidth, effectiveTrailingCols])
1291
+
1292
+ // ---- ビューポート / 総幅 / 幅エポック変化での再クランプ + 列窓追随。保留列アンカーが
1293
+ // あれば位置はアンカーから再導出 (単なる再クランプでは推定空間の hx が残留する) ----
1294
+ useLayoutEffect(() => {
1295
+ const pending = pendingColAnchorRef.current
1296
+ if (pending === null) {
1297
+ applyHxRef.current(hxRef.current)
1298
+ } else {
1299
+ applyHxRef.current(resolveColumnAnchorHx(pending))
1300
+ }
1301
+ refreshColumns()
1302
+ }, [viewport.width, totalWidth, widthEpoch, frozenWidth, trailingWidth, refreshColumns, resolveColumnAnchorHx])
1303
+
1304
+ // ---- 初期アンカー復元 (横) — 生 px でなく {col, offsetX} (行と同一契約)。マウント時 1 回。
1305
+ // 保留列アンカーとして張り、以後の self-heal による列左の実測化へ追随させる ----
1306
+ const initialAnchorAppliedRef = useRef(false)
1307
+ useLayoutEffect(() => {
1308
+ if (initialAnchorAppliedRef.current || initialScrollAnchor === undefined || colCount === 0) {
1309
+ return
1310
+ }
1311
+ initialAnchorAppliedRef.current = true
1312
+ const targetCol = minmax(Math.trunc(initialScrollAnchor.col), 0, colCount - 1)
1313
+ // 凍結列狙いの x 復元は no-op (§8 — 常時可視のため hx 0 のままが復元そのもの)
1314
+ if (targetCol < effectiveFrozenCols) {
1315
+ return
1316
+ }
1317
+ // 末尾凍結列狙いも同文の no-op (3.5.0 プラン §6.1-16 — appliedRef を先に立てる現行順序は
1318
+ // 仕様 §7 の登記済み未検証防御と同じ姿勢で維持)
1319
+ if (targetCol >= colCount - effectiveTrailingCols) {
1320
+ return
1321
+ }
1322
+ const anchor = { col: targetCol, alignX: "start" as const, offsetX: Math.max(0, initialScrollAnchor.offsetX) }
1323
+ pendingColAnchorRef.current = anchor
1324
+ applyHxRef.current(resolveColumnAnchorHx(anchor))
1325
+ }, [initialScrollAnchor, colCount, resolveColumnAnchorHx, effectiveFrozenCols, effectiveTrailingCols])
1326
+
1327
+ // ---- 縦軸の結線 ----
1328
+ const handleVerticalScroll = useCallback((position: number) => {
1329
+ vyRef.current = position
1330
+ attachedRef.current = true
1331
+ onScrollRef.current?.({ x: hxRef.current, y: position })
1332
+ }, [])
1333
+ const handleVerticalRangeChange = useCallback(
1334
+ (range: VirtualScrollRange) => {
1335
+ // 行凍結 (設計 §3): 埋め込み VirtualScroll はシフト空間 (行 − R) で通知する — フル
1336
+ // 行空間へ +R 翻訳して保持する (翻訳シームはここ 1 点)。スクロール行ゼロ (全行凍結 /
1337
+ // rowCount ≤ R) と縮退帯 (H_F ≥ ビューポート — R1 是正で列 §3.3 と対称化) は正準空窓
1338
+ // start > end。totalHeight は**スクロール木のみ**を格納し、帯高は buildRange が毎回
1339
+ // 鮮度加算する (帯行リサイズが通知済みレンジを陳腐化させない — R1 是正)
1340
+ if (scrollRowCount === 0 || degenerateBandRef.current) {
1341
+ rowRangeRef.current = { renderingStartIndex: rowCount, renderingEndIndex: rowCount - 1, visibleStartIndex: rowCount, visibleEndIndex: rowCount - 1, scrollPosition: 0, totalHeight: scrollRowCount === 0 ? 0 : range.totalHeight }
1342
+ vyRef.current = 0
1343
+ } else {
1344
+ const shift = effectiveFrozenRows
1345
+ rowRangeRef.current = {
1346
+ renderingStartIndex: range.renderingStartIndex + shift,
1347
+ renderingEndIndex: range.renderingEndIndex + shift,
1348
+ visibleStartIndex: range.visibleStartIndex + shift,
1349
+ visibleEndIndex: range.visibleEndIndex + shift,
1350
+ scrollPosition: range.scrollPosition,
1351
+ totalHeight: range.totalHeight,
1352
+ }
1353
+ vyRef.current = range.scrollPosition
1354
+ }
1355
+ setNotifiedRowCount((prev) => {
1356
+ const next = Math.max(1, range.renderingEndIndex - range.renderingStartIndex + 1)
1357
+ return next === prev ? prev : next
1358
+ })
1359
+ emitRangeChange()
1360
+ },
1361
+ [emitRangeChange, effectiveFrozenRows, scrollRowCount, rowCount],
1362
+ )
1363
+ const consumeHorizontalDelta = useCallback((deltaX: number) => {
1364
+ // 手動横入力はアンカー張りを解除する (行側の「手動スクロールでアンカー解除」と同契約)
1365
+ pendingColAnchorRef.current = null
1366
+ // 相対 float 加算のみ (絶対 floor 経路は 1px 未満消失の行側反証に従い禁止)
1367
+ applyHxRef.current(hxRef.current + deltaX)
1368
+ }, [])
1369
+
1370
+ // ---- 描画列の列挙 (配置ごとに 1 回 — 行間で共有) + セル数上限 ----
1371
+ // 凍結行帯 (先頭 + 末尾) は全帯行 × 全描画列を具現化する — 予算分母に帯行数を算入
1372
+ // (行凍結設計 §4-7、3.5.0 プラン §6.1-13)
1373
+ const maxColsPerRow = resolveMaxColsPerRow(__maxRenderedCells, effectiveRenderedRows + frozenRows.length + trailingRows.length)
1374
+ // self-heal の暴走ガード: 乖離→治癒→再乖離の非収束が連続する = getColWidth が自身の
1375
+ // 過去値と不一致を繰り返す契約違反 (振動アクセサ)。放置すると治癒→エポック→再走査の
1376
+ // マイクロタスクループが無限旋回するため、上限で治癒を停止し 1 回だけ警告する。
1377
+ // 状態は {count, warned} のみ — 窓キーは保持しない (キー基準の解消は窓端を動かす振動に
1378
+ // 永久回避されるため構造的に持たない。解消は収束パスのみ)
1379
+ const healBurstRef = useRef({ count: 0, warned: false })
1380
+ const { placedColumns, truncatedCols } = useMemo(() => {
1381
+ // widthEpoch は木の内容バージョンの反応辺 (木は identity 安定のため値経由では失効しない)
1382
+ void widthEpoch
1383
+ // 凍結帯 (§8): スクロール帯幅 ≤ 0 (W_F + W_T ≥ viewport の縮退 — 3.5.0 一般化) は
1384
+ // スクロール列を描かない — 凍結帯 / 末尾帯自体は独立 memo が描き続ける定義済み縮退
1385
+ const bandWidth = Math.max(0, viewport.width - frozenWidth - trailingWidth)
1386
+ if (colCount === 0 || bandWidth <= 0) {
1387
+ return { placedColumns: [] as PlacedColumn[], truncatedCols: 0 }
1388
+ }
1389
+ // 空窓 (start > end — 全列凍結の正準表現) は minmax クランプが潰す前に早期脱出する
1390
+ if (columnWindow.renderingColStart > columnWindow.renderingColEnd) {
1391
+ return { placedColumns: [] as PlacedColumn[], truncatedCols: 0 }
1392
+ }
1393
+ // セル予算は凍結列 + 末尾列ぶんを控除 (両帯セルも行ごとに具現化するため — 上限は行の総セル数)
1394
+ const scrollColsBudget = Math.max(1, maxColsPerRow - frozenColumns.length - trailingColumns.length)
1395
+ const { columns, widthUpdates, truncated } = collectVisibleColumns(minmax(columnWindow.renderingColStart, 0, colCount - 1), minmax(columnWindow.renderingColEnd, 0, colCount - 1), validatedGetColWidth, colTree, scrollColsBudget)
1396
+ if (widthUpdates.length > 0) {
1397
+ const burst = healBurstRef.current
1398
+ if (burst.count >= WIDTH_HEAL_BURST_LIMIT) {
1399
+ if (!burst.warned) {
1400
+ burst.warned = true
1401
+ Logger.warn("[VirtualGrid] getColWidth keeps disagreeing with its own previous values; width self-heal is suspended until a converged (zero-deviation) pass re-arms it (unstable accessor — the accessor must be index-stable while the ordering is unchanged).", {
1402
+ window: columnWindow.key,
1403
+ pendingUpdates: widthUpdates.length,
1404
+ })
1405
+ }
1406
+ } else {
1407
+ burst.count += 1
1408
+ // render 中の木変異を避け、マイクロタスクでバッチ適用 (行 memo と同じ遅延照合)。
1409
+ // append 合流 — 同一レンダーパスで先行する凍結 memo の治癒を上書きで落とさない
1410
+ pendingWidthUpdatesRef.current = [...(pendingWidthUpdatesRef.current ?? []), ...widthUpdates]
1411
+ Promise.resolve().then(flushWidthUpdates)
1412
+ }
1413
+ } else {
1414
+ // 収束パス (乖離ゼロ) はどの窓でもバーストを解消する — カウンタが測るのは
1415
+ // 「連続する非収束」であり、逐次の正当な治癒 (毎回 1 フラッシュで収束) を振動と誤認
1416
+ // してはならない。真の振動アクセサは収束パスを一度も作れないため停止を免れず、
1417
+ // 停止後も健全な窓へ移れば収束パスで解消され、stale な窓へ戻ったときに治癒が再開する
1418
+ healBurstRef.current.count = 0
1419
+ healBurstRef.current.warned = false
1420
+ }
1421
+ return { placedColumns: columns, truncatedCols: truncated }
1422
+ // deps へ trailingWidth / trailingColumns.length を明示追加 (3.5.0 プラン §6.1-6 / F4):
1423
+ // bandWidth 式とスクロール列予算が同 memo 内で両値を読む — 追加を怠ると列窓キー不変の
1424
+ // T トグルで memo が stale 化し、予算が trailing 分だけ過大に残る
1425
+ }, [columnWindow, colCount, viewport.width, frozenWidth, trailingWidth, frozenColumns.length, trailingColumns.length, widthEpoch, validatedGetColWidth, colTree, maxColsPerRow, flushWidthUpdates])
1426
+
1427
+ // 打切りは無言にしない (設計 §3.2 — 行側の病的ガードと違い正当構成で到達し得る)
1428
+ const lastTruncationKeyRef = useRef("")
1429
+ useEffect(() => {
1430
+ if (truncatedCols > 0) {
1431
+ const key = `${columnWindow.key}:${truncatedCols}`
1432
+ if (key !== lastTruncationKeyRef.current) {
1433
+ lastTruncationKeyRef.current = key
1434
+ // 打切り分子は両帯セル / 両帯行込みの exactness を維持する (3.5.0 プラン §6.1-13)
1435
+ onRenderTruncatedRef.current?.({ renderedCells: (placedColumns.length + frozenColumns.length + trailingColumns.length) * (effectiveRenderedRows + frozenRows.length + trailingRows.length), droppedCols: truncatedCols })
1436
+ }
1437
+ }
1438
+ }, [truncatedCols, columnWindow.key, placedColumns.length, frozenColumns.length, trailingColumns.length, effectiveRenderedRows, frozenRows.length, trailingRows.length])
1439
+
1440
+ // ---- 行レンダラー (VirtualScroll children) — セル left は「絶対 − コミット対象アンカー」 ----
1441
+ const colAnchor = columnWindow.colAnchor
1442
+ const renderRow = useCallback(
1443
+ (rowIndex: number): ReactNode => {
1444
+ const rowProps = getRowProps?.(rowIndex)
1445
+ // 高さ 0 (非表示) 行はセル DOM を生成しない — 幅 0 列スキップの行双子。0 高でも
1446
+ // セルを置くと消費側のセル装飾 (罫線等) が 1px 級のアーティファクトとして残り、
1447
+ // 巨大な非表示ランでセル予算も浪費する (getRowHeight は検証済みアクセサで整数契約)
1448
+ const hidden = validatedGetRowHeight(rowIndex) === 0
1449
+ const scrollCells = hidden
1450
+ ? null
1451
+ : placedColumns.map(({ col, left, width }) => {
1452
+ const cellProps = getCellProps?.(rowIndex, col)
1453
+ const style: CSSProperties = { ...cellProps?.style, left: left - colAnchor, width }
1454
+ return (
1455
+ <div {...cellProps} key={getCellKey ? getCellKey(rowIndex, col) : col} className={twMerge("aqvs-grid-cell", cellProps?.className)} style={style}>
1456
+ {children(getCell(rowIndex, col), rowIndex, col)}
1457
+ </div>
1458
+ )
1459
+ })
1460
+ // 横帯なし (既定) は従来 DOM と恒等 — 行が残差 translate を運ぶ (中身はバイト恒等)
1461
+ if (effectiveFrozenCols === 0 && effectiveTrailingCols === 0) {
1462
+ return (
1463
+ <div {...rowProps} className={twMerge("aqvs-grid-row", rowProps?.className)} style={rowProps?.style}>
1464
+ {scrollCells}
1465
+ </div>
1466
+ )
1467
+ }
1468
+ // 横帯モード (§8-c + 3.5.0 プラン §6.1-15): 行は静的化し、スクロールセルは左端 W_F ・
1469
+ // 右端 W_T の静的クリップ + −W_F 原点シフト付き残差 translate の内側へ。凍結セルは
1470
+ // 行直下の独立 subtree で木絶対 left の直接描画 — DOM は**前置** (論理列順 = 読み上げ /
1471
+ // フォーカス順)。末尾セルは右アンカー inner の帯ローカル left で**後置** (同じく論理列順)。
1472
+ // class はモードマーカー (描画トレイト) のまま、data-aqvs-frozen-row は F_eff > 0 のみ
1473
+ // (ADR-25 — 末尾のみのグリッドが凍結列ゼロで属性を出すフック面の嘘の封じ)。
1474
+ // 描画順は自由: クリップが帯への侵入を構造的に遮断するため重なり自体が無い
1475
+ return (
1476
+ <div {...rowProps} data-aqvs-frozen-row={effectiveFrozenCols > 0 ? "" : undefined} className={twMerge("aqvs-grid-row aqvs-grid-row-frozen-host", rowProps?.className)} style={rowProps?.style}>
1477
+ {hidden
1478
+ ? null
1479
+ : frozenColumns.map(({ col, left, width }) => {
1480
+ const cellProps = getCellProps?.(rowIndex, col)
1481
+ const style: CSSProperties = { ...cellProps?.style, left, width }
1482
+ return (
1483
+ <div {...cellProps} data-aqvs-frozen-cell="" key={getCellKey ? getCellKey(rowIndex, col) : col} className={twMerge("aqvs-grid-cell", cellProps?.className)} style={style}>
1484
+ {children(getCell(rowIndex, col), rowIndex, col)}
1485
+ </div>
1486
+ )
1487
+ })}
1488
+ {hidden ? null : (
1489
+ <div className="aqvs-grid-row-scroll">
1490
+ <div className="aqvs-grid-row-scroll-inner">{scrollCells}</div>
1491
+ </div>
1492
+ )}
1493
+ {hidden || effectiveTrailingCols === 0 ? null : (
1494
+ <div className="aqvs-grid-row-trailing">
1495
+ <div className="aqvs-grid-row-trailing-inner">
1496
+ {trailingColumns.map(({ col, left, width }) => {
1497
+ const cellProps = getCellProps?.(rowIndex, col)
1498
+ const style: CSSProperties = { ...cellProps?.style, left, width }
1499
+ return (
1500
+ <div {...cellProps} data-aqvs-trailing-cell="" key={getCellKey ? getCellKey(rowIndex, col) : col} className={twMerge("aqvs-grid-cell", cellProps?.className)} style={style}>
1501
+ {children(getCell(rowIndex, col), rowIndex, col)}
1502
+ </div>
1503
+ )
1504
+ })}
1505
+ </div>
1506
+ </div>
1507
+ )}
1508
+ </div>
1509
+ )
1510
+ },
1511
+ [placedColumns, frozenColumns, trailingColumns, effectiveFrozenCols, effectiveTrailingCols, colAnchor, getRowProps, getCellProps, getCellKey, getCell, children, validatedGetRowHeight],
1512
+ )
1513
+
1514
+ // ---- liveRegion (2D — 文言は消費側所有、静定デバウンス) ----
1515
+ const [liveMessage, setLiveMessage] = useState("")
1516
+ const liveTimerRef = useRef<number | null>(null)
1517
+ const liveRegionRef = useRef(liveRegion)
1518
+ liveRegionRef.current = liveRegion
1519
+ const scheduleLiveMessage = useCallback(() => {
1520
+ const options = liveRegionRef.current
1521
+ if (options === undefined) {
1522
+ return
1523
+ }
1524
+ // 不正な debounceMs は黙って既定へ読み替えず読み上げを無効化する (警告は下の effect が 1 回だけ出す)
1525
+ const debounce = options.debounceMs ?? 400
1526
+ if (!(Number.isFinite(debounce) && debounce >= 0)) {
1527
+ return
1528
+ }
1529
+ if (liveTimerRef.current !== null) {
1530
+ window.clearTimeout(liveTimerRef.current)
1531
+ }
1532
+ liveTimerRef.current = window.setTimeout(() => {
1533
+ liveTimerRef.current = null
1534
+ const range = buildRange()
1535
+ if (range !== null) {
1536
+ setLiveMessage((prev) => {
1537
+ const next = options.buildMessage(range)
1538
+ return next === prev ? prev : next
1539
+ })
1540
+ }
1541
+ }, debounce)
1542
+ }, [buildRange])
1543
+ const liveDebounceMs = liveRegion?.debounceMs ?? 400
1544
+ const liveDebounceValid = Number.isFinite(liveDebounceMs) && liveDebounceMs >= 0
1545
+ const hasLiveRegion = liveRegion !== undefined
1546
+ useEffect(() => {
1547
+ // 目的: 不正な debounceMs の無効化を黙らせない (行側 liveRegion と同一契約の 1 回警告)
1548
+ if (hasLiveRegion && !liveDebounceValid) {
1549
+ Logger.warn(`[VirtualGrid] liveRegion.debounceMs must be a finite number >= 0, received ${liveDebounceMs}. Announcements are disabled.`)
1550
+ }
1551
+ }, [hasLiveRegion, liveDebounceValid, liveDebounceMs])
1552
+ useEffect(
1553
+ () => () => {
1554
+ if (liveTimerRef.current !== null) {
1555
+ window.clearTimeout(liveTimerRef.current)
1556
+ }
1557
+ },
1558
+ [],
1559
+ )
1560
+ useEffect(() => {
1561
+ scheduleLiveMessage()
1562
+ }, [columnWindow, scheduleLiveMessage])
1563
+ const handleRangeChangeWithLive = useCallback(
1564
+ (range: VirtualScrollRange) => {
1565
+ handleVerticalRangeChange(range)
1566
+ scheduleLiveMessage()
1567
+ },
1568
+ [handleVerticalRangeChange, scheduleLiveMessage],
1569
+ )
1570
+
1571
+ // ---- ハンドル ----
1572
+ useImperativeHandle(
1573
+ ref,
1574
+ (): VirtualGridHandle => ({
1575
+ scrollTo: (pos) => {
1576
+ const inner = scrollHandleRef.current
1577
+ let y = vyRef.current
1578
+ if (pos.y !== undefined && inner !== null) {
1579
+ inner.scrollTo(pos.y)
1580
+ y = inner.getScrollPosition()
1581
+ vyRef.current = y
1582
+ }
1583
+ let x = hxRef.current
1584
+ if (pos.x !== undefined) {
1585
+ // 明示ジャンプは列アンカーを張らない (設計 §3.4) — 既存の張りも解除する
1586
+ pendingColAnchorRef.current = null
1587
+ x = applyHxRef.current(pos.x)
1588
+ }
1589
+ return { x, y }
1590
+ },
1591
+ scrollBy: (delta) => {
1592
+ const inner = scrollHandleRef.current
1593
+ let y = vyRef.current
1594
+ if (delta.y !== undefined && delta.y !== 0 && inner !== null) {
1595
+ y = inner.scrollBy(delta.y)
1596
+ vyRef.current = y
1597
+ }
1598
+ let x = hxRef.current
1599
+ if (delta.x !== undefined && delta.x !== 0) {
1600
+ pendingColAnchorRef.current = null
1601
+ x = applyHxRef.current(hxRef.current + delta.x)
1602
+ }
1603
+ return { x, y }
1604
+ },
1605
+ scrollToCell: (row, col, options) => {
1606
+ const inner = scrollHandleRef.current
1607
+ if (inner !== null && rowCount > 0) {
1608
+ const targetRow = minmax(Math.trunc(row), 0, rowCount - 1)
1609
+ // 凍結行狙いの y は no-op (行凍結設計 §3 — 常時可視。列の §8 no-op と同文)。
1610
+ // 末尾帯行狙いも同文の縦 no-op (帯所属は**呼出し時**判定 — 3.5.0 プラン §3)。
1611
+ // スクロール行はシフト空間へ −R 翻訳して委譲する
1612
+ if (targetRow >= effectiveFrozenRows && targetRow < rowCount - effectiveTrailingRows) {
1613
+ inner.scrollToIndex(targetRow - effectiveFrozenRows, { align: options?.alignY ?? "top", offset: options?.offsetY })
1614
+ }
1615
+ }
1616
+ if (colCount > 0) {
1617
+ const targetCol = minmax(Math.trunc(col), 0, colCount - 1)
1618
+ // 凍結列狙いの x は no-op (§8 — 常時可視)。末尾列狙いも横 no-op かつ既存の
1619
+ // 列アンカー残置 (分岐スキップのみ — pendingColAnchorRef は触らない。3.5.0 §3)
1620
+ if (targetCol >= effectiveFrozenCols && targetCol < colCount - effectiveTrailingCols) {
1621
+ // 列アンカーを張る (行 scrollToIndex のアンカー張りと同義): 以後の self-heal で
1622
+ // 列左が実測化されても狙った列に留まり続ける。手動横入力で解除
1623
+ const anchor = { col: targetCol, alignX: options?.alignX ?? "start", offsetX: options?.offsetX ?? 0 }
1624
+ pendingColAnchorRef.current = anchor
1625
+ applyHxRef.current(resolveColumnAnchorHx(anchor))
1626
+ }
1627
+ }
1628
+ },
1629
+ getScrollPosition: () => (attachedRef.current || scrollHandleRef.current !== null ? { x: hxRef.current, y: scrollHandleRef.current?.getScrollPosition() ?? vyRef.current } : { x: -1, y: -1 }),
1630
+ getScrollAnchor: () => {
1631
+ if (rowCount === 0 || colCount === 0) {
1632
+ return null
1633
+ }
1634
+ const rowAnchor = scrollHandleRef.current?.getScrollAnchor() ?? null
1635
+ if (rowAnchor === null) {
1636
+ return null
1637
+ }
1638
+ // 境界規約は行側 getScrollAnchor と同一: 末尾越えは最終列 offsetX 0、列の右端が
1639
+ // ちょうど hx (cumulative === hx) なら可視先頭は次列 offsetX 0 — {index, offset}
1640
+ // 分解が列幅変更を跨いでも行側と同じ列に解決される (M5 のアンカー不変性)
1641
+ // 凍結帯 (§8): アンカーはスクロール帯の可視先頭 = 木座標 W_F + hx で捕獲する
1642
+ // (復元側 resolveColumnAnchorHx が W_F を引き戻すため往復が恒等)
1643
+ const bandStart = frozenWidthRef.current + hxRef.current
1644
+ const found = colTree.findIndexAtOrAfter(bandStart, { materializeOption: { materialize: false } })
1645
+ if (found.index === -1) {
1646
+ return { row: rowAnchor.index + effectiveFrozenRows, col: colCount - 1, offsetX: 0, offsetY: rowAnchor.offsetPx }
1647
+ }
1648
+ if (found.cumulative === bandStart) {
1649
+ return { row: rowAnchor.index + effectiveFrozenRows, col: minmax(found.index + 1, 0, colCount - 1), offsetX: 0, offsetY: rowAnchor.offsetPx }
1650
+ }
1651
+ // 行はシフト空間 → フル行空間へ +R 翻訳 (行凍結設計 §3 — 復元側は −R で往復恒等)
1652
+ const colLeft = (found.cumulative ?? 0) - (found.currentValue ?? 0)
1653
+ return { row: rowAnchor.index + effectiveFrozenRows, col: found.index, offsetX: Math.max(0, bandStart - colLeft), offsetY: rowAnchor.offsetPx }
1654
+ },
1655
+ getViewportSize: () => ({ width: viewportRef.current.width, height: viewportRef.current.height }),
1656
+ // 縦はインセット込みの行側 getContentSize を単一情報源にする (range.totalHeight は木総高のみ)。
1657
+ // 3.5.0: height = H_F + 埋め込み + H_T
1658
+ getContentSize: () => ({ width: totalWidthRef.current, height: scrollHandleRef.current === null ? 0 : frozenHeightRef.current + trailingHeightRef.current + Math.max(0, scrollHandleRef.current.getContentSize()) }),
1659
+ getRange: buildRange,
1660
+ updateRowSize: (row, px) => {
1661
+ validateTrackSize(px, "row", row)
1662
+ // 凍結行はグリッド所有バンド — epoch 繰上げでアクセサ再読 (帯に第 2 の格納庫は
1663
+ // 無いため木更新は不要)。末尾帯行も同文の epoch 繰上げ (3.5.0 — 3 分岐。T = 0 は
1664
+ // 分岐自体が不成立で従来挙動と恒等)。スクロール行はシフト空間へ −R 翻訳して委譲する
1665
+ if (Math.trunc(row) < effectiveFrozenRows) {
1666
+ setFrozenRowEpoch((epoch) => epoch + 1)
1667
+ } else if (effectiveTrailingRows > 0 && Math.trunc(row) >= rowCount - effectiveTrailingRows) {
1668
+ setTrailingRowEpoch((epoch) => epoch + 1)
1669
+ } else {
1670
+ scrollHandleRef.current?.updateItemSize(Math.trunc(row) - effectiveFrozenRows, px)
1671
+ }
1672
+ },
1673
+ updateColSize: (col, px) => {
1674
+ validateTrackSize(px, "col", col)
1675
+ // 総幅は ref へも即時反映 (同 tick の scrollBy/scrollTo が旧上限でクランプしないため)
1676
+ commitTotalWidth(colTree.update(minmax(Math.trunc(col), 0, Math.max(0, colCount - 1)), px))
1677
+ // 窓キー不変の幅変更でもセル配置を失効させる (バッチループは React 自動バッチングで 1 コミット)
1678
+ setWidthEpoch((epoch) => epoch + 1)
1679
+ },
1680
+ getFrozenSize: () => ({
1681
+ cols: effectiveFrozenCols,
1682
+ width: frozenWidthRef.current,
1683
+ rows: effectiveFrozenRows,
1684
+ height: frozenHeightRef.current,
1685
+ trailingCols: effectiveTrailingCols,
1686
+ trailingRows: effectiveTrailingRows,
1687
+ trailingWidth: trailingWidthRef.current,
1688
+ trailingHeight: trailingHeightRef.current,
1689
+ // vis は単一 JS 源ヘルパーの呼び出しのみ (式の再導出禁止 — 3.5.0 プラン §2.1-4)
1690
+ trailingVisibleWidth: trailingVisibleSize(trailingWidthRef.current, frozenWidthRef.current, viewportRef.current.width),
1691
+ trailingVisibleHeight: trailingVisibleSize(trailingHeightRef.current, frozenHeightRef.current, viewportRef.current.height),
1692
+ }),
1693
+ applyWheel: (event) => scrollHandleRef.current?.applyWheel(event) ?? false,
1694
+ focusRowAtIndex: (row, options) => {
1695
+ // 凍結行はバンド行 DOM へ直接フォーカス、末尾帯行も帯行 DOM へ直接フォーカス
1696
+ // (3.5.0 — 3 分岐。T = 0 は分岐不成立で従来挙動と恒等)、スクロール行は −R 翻訳して委譲
1697
+ const targetRow = Math.trunc(row)
1698
+ if (targetRow < effectiveFrozenRows) {
1699
+ rootRef.current?.querySelector<HTMLElement>(`[data-aqvs-frozen-band-row="${targetRow}"]`)?.focus()
1700
+ } else if (effectiveTrailingRows > 0 && targetRow >= rowCount - effectiveTrailingRows) {
1701
+ rootRef.current?.querySelector<HTMLElement>(`[data-aqvs-trailing-band-row="${targetRow}"]`)?.focus()
1702
+ } else {
1703
+ scrollHandleRef.current?.focusItemAtIndex(targetRow - effectiveFrozenRows, options)
1704
+ }
1705
+ },
1706
+ }),
1707
+ [rowCount, colCount, colTree, buildRange, commitTotalWidth, resolveColumnAnchorHx, effectiveFrozenCols, effectiveFrozenRows, effectiveTrailingCols, effectiveTrailingRows],
1708
+ )
1709
+
1710
+ return (
1711
+ <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}>
1712
+ {effectiveFrozenRows > 0 ? (
1713
+ // 凍結行帯 (行凍結設計 §4-2): グリッド所有の帯外バンド — 行本体は renderRow を
1714
+ // そのまま再利用する (F > 0 の凍結セル / クリップ / hx 残差 var は継承で無償追随
1715
+ // = 4 象限コーナーの成立点)。top は木絶対プレフィックス、高さ 0 行は DOM 非生成
1716
+ <div className="aqvs-grid-frozen-rows" style={{ height: frozenHeight }} data-testid={testId ? `${testId}-frozen-rows` : undefined}>
1717
+ {frozenRows.map(({ row, top, height }) => (
1718
+ // 行ラッパーの role は消費側の複合ウィジェット契約 (listbox / tree / grid — README
1719
+ // 「Composite widget roles」) の所有物で、パッケージが既定 role を課すと衝突する。
1720
+ // フォーカス結線は focusRowAtIndex ↔ onRowFocus 往復のバンド版そのもの。右インセット
1721
+ // (right: scrollBarWidth + overflow クリップ) は縦バーコーナーの予約 — ペインの
1722
+ // クリップ箱 (root − バー幅) と帯のクリップ箱を一致させる (R1 是正)。
1723
+ // biome-ignore lint/a11y/noStaticElementInteractions: role は消費側の複合ウィジェット契約の所有物 (上記)
1724
+ <div key={row} data-aqvs-frozen-band-row={row} className="aqvs-grid-frozen-band-row" style={{ top, height, right: scrollBarWidth }} tabIndex={-1} onFocus={() => onRowFocus?.(row)}>
1725
+ {renderRow(row)}
1726
+ </div>
1727
+ ))}
1728
+ </div>
1729
+ ) : null}
1730
+ <div className="aqvs-grid-main" data-testid={testId ? `${testId}-main` : undefined}>
1731
+ <VirtualScroll<number>
1732
+ key={effectiveFrozenRows}
1733
+ ref={scrollHandleRef}
1734
+ itemCount={degenerateBand ? 0 : scrollRowCount}
1735
+ getItem={(i) => i + effectiveFrozenRows}
1736
+ getItemKey={(i) => i + effectiveFrozenRows}
1737
+ getItemHeight={scrollGetRowHeight}
1738
+ overscanCount={overscanRows}
1739
+ viewportSize={viewport.height > 0 ? Math.max(0, viewport.height - frozenHeight - trailingHeight) : undefined}
1740
+ callbackThrottleMs={callbackThrottleMs}
1741
+ behaviorOptions={behaviorOptions}
1742
+ scrollBarOptions={scrollBarOptions}
1743
+ contentProps={contentProps}
1744
+ background={background}
1745
+ contentInsets={contentInsets}
1746
+ onItemFocus={shiftedRowFocus}
1747
+ horizontalKeyInputs={horizontalKeyInputs}
1748
+ horizontalKeyStep={horizontalKeyStep}
1749
+ initialScrollAnchor={
1750
+ // トグル済みなら視点保存 (捕捉アンカー) のみ — 捕捉 null は undefined (先頭)
1751
+ // へ落とし、消費済みマウント props の蘇りを断つ (R2 是正)。未トグルのみ
1752
+ // マウント props の凍結行 no-op 付き −R 変換 (列の §8 no-op と同文)
1753
+ toggledRef.current
1754
+ ? toggleAnchorRef.current !== null && toggleAnchorRef.current.row >= effectiveFrozenRows && scrollRowCount > 0
1755
+ ? { index: toggleAnchorRef.current.row - effectiveFrozenRows, offsetPx: toggleAnchorRef.current.offsetPx }
1756
+ : undefined
1757
+ : initialScrollAnchor !== undefined && scrollRowCount > 0 && Math.trunc(initialScrollAnchor.row) >= effectiveFrozenRows && Math.trunc(initialScrollAnchor.row) < rowCount - effectiveTrailingRows
1758
+ ? { index: Math.trunc(initialScrollAnchor.row) - effectiveFrozenRows, offsetPx: initialScrollAnchor.offsetY }
1759
+ : undefined
1760
+ }
1761
+ onScroll={handleVerticalScroll}
1762
+ onRangeChange={handleRangeChangeWithLive}
1763
+ onWheelHorizontal={consumeHorizontalDelta}
1764
+ onPanHorizontal={consumeHorizontalDelta}
1765
+ testId={testId ? `${testId}-rows` : undefined}>
1766
+ {renderRow}
1767
+ </VirtualScroll>
1768
+ </div>
1769
+ {effectiveTrailingRows > 0 ? (
1770
+ // 末尾行帯 (3.5.0 プラン §6.1-17): .aqvs-grid-main の**後**・hbar の**前**の兄弟
1771
+ // (AT には「最終行が最後」の DOM 順正しさ。縦バーは帯の上で自然に終端)。帯クリップ高は
1772
+ // インライン H_T_vis (単一 JS 源 — 帯寸の CSS 変数は幅 2 つのみで高さ変数は導入しない)、
1773
+ // inner はインライン木高 H_T の bottom 定着 — 帯上端 = extent − H_T_vis が全構成で
1774
+ // 成立し、重複縮退では帯の**末尾**行が可視に残る。行本体は renderRow 逐語再利用
1775
+ // (先頭列 / 末尾列の帯構造を継承し 4 象限コーナー全種が無償で成立)
1776
+ <div className="aqvs-grid-trailing-rows" style={{ height: trailingVisibleSize(trailingHeight, frozenHeight, viewport.height) }} data-testid={testId ? `${testId}-trailing-rows` : undefined}>
1777
+ <div className="aqvs-grid-trailing-rows-inner" style={{ height: trailingHeight }}>
1778
+ {trailingRows.map(({ row, top, height }) => (
1779
+ // 帯行は帯ローカル top + right: scrollBarWidth (縦バーコーナーの予約 — 先頭帯行と
1780
+ // 同文) + tabIndex −1 + onFocus フル行 index (focusRowAtIndex ↔ onRowFocus 往復)。
1781
+ // biome-ignore lint/a11y/noStaticElementInteractions: role は消費側の複合ウィジェット契約の所有物 (先頭帯行と同文)
1782
+ <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)}>
1783
+ {renderRow(row)}
1784
+ </div>
1785
+ ))}
1786
+ </div>
1787
+ </div>
1788
+ ) : null}
1789
+ <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}>
1790
+ <ScrollBar
1791
+ horizontal
1792
+ enableHorizontalTapCircle
1793
+ contentSize={Math.max(0, totalWidth - frozenWidth - trailingWidth)}
1794
+ viewportSize={Math.max(0, viewport.width - frozenWidth - trailingWidth)}
1795
+ scrollPosition={barHx}
1796
+ // タップ速度の log10(colCount) 則 (設計 §3.3 — B7 の対策そのもの)。未配線だと
1797
+ // 兆列でも基礎倍率 2.2x に縮退し、サム粒度の到達性が死ぬ。−F / −T 写像は
1798
+ // aria 面に露出せずピン不能 (仕様 §7 非ピン面 (3) の登記済み面)
1799
+ itemCount={Math.max(0, colCount - effectiveFrozenCols - effectiveTrailingCols)}
1800
+ tapScrollCircleOptions={scrollBarOptions?.tapScrollCircleOptions}
1801
+ onScroll={(request, previous) => {
1802
+ // バー操作は手動横入力 — 列アンカーの張りを解除する
1803
+ pendingColAnchorRef.current = null
1804
+ return applyHxRef.current(typeof request === "function" ? request(previous ?? hxRef.current) : request)
1805
+ }}
1806
+ scrollBarWidth={scrollBarWidth}
1807
+ />
1808
+ </div>
1809
+ {liveRegion !== undefined ? (
1810
+ <div className="aqvs-grid-live-region" aria-live="polite" data-testid={testId ? `${testId}-live` : undefined}>
1811
+ {liveMessage}
1812
+ </div>
1813
+ ) : null}
1814
+ </div>
1815
+ )
1816
+ }
1817
+
1818
+ /**
1819
+ * Generic 2D virtualization grid — see the fileoverview. Exposes VirtualGridHandle via ref.
1820
+ * 汎用 2D 仮想化グリッド — fileoverview 参照。ref で VirtualGridHandle を公開する。
1821
+ */
1822
+ export const VirtualGrid = forwardRef(VirtualGridInner) as <T>(props: VirtualGridProps<T> & { ref?: React.Ref<VirtualGridHandle> }) => ReactNode