@aiquants/spreadsheet 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,17 @@
15
15
  * bucketed by font key so `ctx.font` is assigned once per distinct font) / row default-height
16
16
  * restore / unhide marker restore. While the editor is pointing at a formula reference
17
17
  * (v0.8 §4.2.10) a strip press is consumed (focus back to the sink, no gesture, no commit).
18
+ * v0.9 (ADR-26 / ADR-21): the gesture record and the guide carry a REQUIRED
19
+ * `anchor: "leading" | "trailing"` discriminator (classified by attribute presence —
20
+ * `data-aqsp-resize-anchor`, emitted by the trailing header subtrees only); trailing gestures
21
+ * take the INVERTED delta `pendingSize = max(0, round(initialSize − delta))` (pinned-right
22
+ * idiom — dragging left/up grows, the pointer and the moving edge travel together) and BOTH
23
+ * guide writers (the rAF move writer and the press-time write) place the guide at the
24
+ * prospective MOVING edge `offsetOf(index) + initialSize − pendingSize`; the leading path is
25
+ * byte-identical (sign +1, guide `offsetOf + pendingSize`, both writers). Column autofit
26
+ * measures the union `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)` (each
27
+ * filtered by sizeOf > 0 — ADR-21, band counts from a judgment-time `getFrozenSize()` read)
28
+ * and the measured-cell budget gate counts band rows.
18
29
  *
19
30
  * ヘッダーリサイズ / unhide 機械 (§7 PT-1)。単一フックが両帯を結線 (§7.4 の機械勘定 = 1)。
20
31
  * 帯へ wire-once の native capture pointerdown を 1 本 (ストリップはリスナーを持たない —
@@ -30,6 +41,15 @@
30
41
  * 代入を異なりフォント毎 1 回にする) / 行は既定行高復元 / unhide マーカーは既定サイズ復元。
31
42
  * 参照ポインティング中 (v0.8 §4.2.10) のストリップ押下は consume (シンクへ焦点、ジェスチャも
32
43
  * 確定もなし)。
44
+ * v0.9 (ADR-26 / ADR-21): ジェスチャレコードとガイドは必須の
45
+ * `anchor: "leading" | "trailing"` 判別子を持つ (分類は属性保持 — `data-aqsp-resize-anchor`
46
+ * は末尾ヘッダーサブツリーのみ発行)。末尾ジェスチャは反転デルタ
47
+ * `pendingSize = max(0, round(initialSize − delta))` (pinned-right イディオム — 左 / 上
48
+ * ドラッグで拡大、ポインタと動く辺が同行)、ガイドは**両方の書き手** (rAF 移動書き手と押下
49
+ * 即時書込) が予定動く辺 `offsetOf(index) + initialSize − pendingSize` に置く。leading パスは
50
+ * byte 恒等 (符号 +1、ガイド `offsetOf + pendingSize`、両書き手)。列 autofit の測定行集合は
51
+ * 和集合 `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)` (各 sizeOf > 0 —
52
+ * ADR-21。帯件数は判断時 `getFrozenSize()` 読み)、計測セル予算ゲートは帯行を算入する。
33
53
  */
34
54
 
35
55
  import type { VirtualGridHandle } from "@aiquants/virtualscroll"
@@ -126,11 +146,28 @@ type ResizeGesture = {
126
146
  axis: "row" | "col"
127
147
  kind: "resize" | "unhide"
128
148
  index: number
149
+ /** REQUIRED anchor discriminator (ADR-26 — no optional/default): "trailing" inverts the delta sign and the guide formula. / 必須アンカー判別子 (ADR-26 — optional / default なし): "trailing" はデルタ符号とガイド式を反転。 */
150
+ anchor: "leading" | "trailing"
129
151
  initialSize: number
130
152
  startClientX: number
131
153
  startClientY: number
132
154
  }
133
155
 
156
+ /**
157
+ * Prospective guide TREE position for one gesture (ADR-26 — shared by BOTH writer sites: the
158
+ * rAF move writer and the press-time immediate write). Leading: the prospective FAR boundary
159
+ * `offsetOf(index) + pendingSize` (v0.8 byte-identical). Trailing: the prospective MOVING
160
+ * (near) edge `offsetOf(index) + initialSize − pendingSize` — at press pendingSize =
161
+ * initialSize, so the trailing form lands on `offsetOf(index)` (the pressed strip's own near
162
+ * boundary, under the pointer), where the leading form would flash the track's far boundary.
163
+ * ジェスチャ 1 件の予定ガイド木位置 (ADR-26 — rAF 移動書き手と押下即時書込の**両書き手**で共有)。
164
+ * leading は予定遠端境界 `offsetOf(index) + pendingSize` (v0.8 byte 恒等)。trailing は予定
165
+ * **動く辺** (近端) `offsetOf(index) + initialSize − pendingSize` — 押下時 pendingSize =
166
+ * initialSize のため trailing 式は `offsetOf(index)` (押下ストリップ自身の近端 = ポインタ直下)
167
+ * に着地し、leading 式のままだと遠端に点滅する (§14-6 の押下のみピン)。
168
+ */
169
+ const guidePositionOf = (gesture: ResizeGesture, axisModel: AxisModel, pendingSize: number): number => (gesture.anchor === "trailing" ? axisModel.offsetOf(gesture.index) + gesture.initialSize - pendingSize : axisModel.offsetOf(gesture.index) + pendingSize)
170
+
134
171
  /**
135
172
  * Wires the resize/unhide machine onto both header bands; returns the §7.4 composite surface.
136
173
  * 両ヘッダー帯へリサイズ / unhide 機械を結線し、§7.4 の合成面を返す。
@@ -267,7 +304,7 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
267
304
  [collectTargets, applyEntries],
268
305
  )
269
306
 
270
- /** Column autofit (§7.3 / v0.8 §4.2.10): per-column canvas measurement over the visible window in each cell's painted font, bucketed by font key. / 列 autofit (可視ウィンドウをセルの塗りフォントで計測、フォントキーでバケット化)。 */
307
+ /** Column autofit (§7.3 / v0.8 §4.2.10 / ADR-21): per-column canvas measurement over the union of both frozen bands and the visible window — `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)`, each filtered by sizeOf > 0 — in each cell's painted font, bucketed by font key. / 列 autofit (§7.3 / v0.8 §4.2.10 / ADR-21): 両凍結帯 + 可視ウィンドウの和集合 `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)` (各 sizeOf > 0) をセルの塗りフォントで計測、フォントキーでバケット化。 */
271
308
  const autofitColumns = useCallback(
272
309
  (band: HTMLElement, pressedIndex: number): void => {
273
310
  const p = paramsRef.current
@@ -275,23 +312,45 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
275
312
  if (targets === null) {
276
313
  return
277
314
  }
278
- // 可視ウィンドウ内の可視行 (幅 0 行は計測しない)
315
+ // 帯件数は判断時ハンドル読み (ADR-21 / ADR-19 の判断時規律 — getFrozenSize().rows /
316
+ // .trailingRows はエンジンクランプ済み実効値)。装着前 (null) は帯集合なし — 測定集合が
317
+ // 窓のみに縮む v0.8 恒等の能力分岐 (値代替フォールバックではない)
318
+ const handle = p.handleRef.current
319
+ const frozenSize = handle === null ? null : handle.getFrozenSize()
320
+ const leadingRows = frozenSize === null ? 0 : frozenSize.rows
321
+ const trailingRows = frozenSize === null ? 0 : frozenSize.trailingRows
322
+ // 測定行集合 = [0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount) の順序付き
323
+ // 和集合 (ADR-21 — v0.9 §4.14)。幅 0 行は計測しない (sizeOf > 0 フィルタ)、重複は Set で
324
+ // 去重 (R = T = 0 は窓のみ = v0.8 恒等)
279
325
  const { rowStart, rowEnd } = p.viewStore.get()
280
326
  const last = Math.min(rowEnd, p.rowAxis.count - 1)
281
- const visibleRows: number[] = []
282
- for (let row = rowStart; row <= last; row += 1) {
283
- if (p.rowAxis.sizeOf(row) > 0) {
284
- visibleRows.push(row)
327
+ const measuredRows: number[] = []
328
+ const seen = new Set<number>()
329
+ const pushRow = (row: number): void => {
330
+ if (!seen.has(row) && p.rowAxis.sizeOf(row) > 0) {
331
+ seen.add(row)
332
+ measuredRows.push(row)
285
333
  }
286
334
  }
287
- // 計測対象の可視行がゼロ (命令 API 経由の全窓内非表示) は no-op — Excel も計測対象
335
+ for (let row = 0; row < leadingRows; row += 1) {
336
+ pushRow(row)
337
+ }
338
+ for (let row = rowStart; row <= last; row += 1) {
339
+ pushRow(row)
340
+ }
341
+ // 末尾帯の開始 = rowCount − T_eff (エンジンクランプ済みのため常に ≥ R_eff ≥ 0 — §1.2)
342
+ for (let row = p.rowAxis.count - trailingRows; row < p.rowAxis.count; row += 1) {
343
+ pushRow(row)
344
+ }
345
+ // 計測対象の可視行がゼロ (全帯 + 窓が非表示) は no-op — Excel も計測対象
288
346
  // なしでは幅を変えない (0 計測 → 9px 潰しの破壊を作らない。R1(B) 所見)
289
- if (visibleRows.length === 0) {
347
+ if (measuredRows.length === 0) {
290
348
  return
291
349
  }
292
350
  // 上限ゲートは計測セル数単位 (§7.3 — トラック数単位では 16k 列 × 可視 40 行が素通り
293
- // する)。上限は autofit 専用の低い係数 (§13 (e) の設計返却 — 実測根拠は定数 docstring)
294
- const measuredCells = targets.length * visibleRows.length
351
+ // する)。上限は autofit 専用の低い係数 (§13 (e) の設計返却 — 実測根拠は定数 docstring)。
352
+ // 帯行も算入する (ADR-21 — measuredRows が帯行を含む)
353
+ const measuredCells = targets.length * measuredRows.length
295
354
  const measureLimit = Math.floor(p.maxOperationCells / AUTOFIT_MEASURE_DIVISOR)
296
355
  if (measuredCells > measureLimit) {
297
356
  p.onOperationRejected?.("resize", measuredCells, "limit")
@@ -317,7 +376,7 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
317
376
  const buckets = new Map<string, { col: number; text: string }[]>()
318
377
  for (const col of targets) {
319
378
  const colIndex = cfRules === null ? null : buildCfIndex(cfRules.filter((rule) => rule.ranges.some((range) => col >= range.startCol && col <= range.endCol)))
320
- for (const row of visibleRows) {
379
+ for (const row of measuredRows) {
321
380
  // 計測対象は**画面に描かれる表示文字列** (v0.7 — 値書式適用後)。素の getCellText を測ると
322
381
  // 通貨 "¥1,234,567" が素値 "1234567" の幅に潰されて最上位桁ごと左クリップされる (誤読事故)。
323
382
  // 書式なしセルは formatCellValue が素通しするため従来と同一。interned フォーマッタにより
@@ -374,8 +433,8 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
374
433
  /** Per-axis closest() selector (structural exclusivity of the two hooks). / 軸別 closest() セレクタ。 */
375
434
  const selectorFor = (axis: "row" | "col"): string => (axis === "col" ? "[data-aqsp-col-resize], [data-aqsp-unhide]" : "[data-aqsp-row-resize], [data-aqsp-unhide]")
376
435
 
377
- /** Resolves a strip hit to a gesture seed. / ストリップ命中のジェスチャ種解決。 */
378
- const resolveHit = (axis: "row" | "col", target: EventTarget | null): { kind: "resize" | "unhide"; index: number } | null => {
436
+ /** Resolves a strip hit to a gesture seed (incl. the ADR-26 anchor). / ストリップ命中のジェスチャ種解決 (ADR-26 の anchor 込み)。 */
437
+ const resolveHit = (axis: "row" | "col", target: EventTarget | null): { kind: "resize" | "unhide"; index: number; anchor: "leading" | "trailing" } | null => {
379
438
  if (!(target instanceof Element)) {
380
439
  return null
381
440
  }
@@ -385,7 +444,10 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
385
444
  }
386
445
  const isUnhide = hit.hasAttribute("data-aqsp-unhide")
387
446
  const index = Number(hit.getAttribute(isUnhide ? "data-aqsp-unhide" : axis === "col" ? "data-aqsp-col-resize" : "data-aqsp-row-resize"))
388
- return { kind: isUnhide ? "unhide" : "resize", index }
447
+ // 末尾判別は属性保持の分類分岐 (§2.6 — isUnhide と同型。属性は末尾サブツリーのみ発行、
448
+ // leading DOM は不変なのでフォールバックではない)
449
+ const anchor: "leading" | "trailing" = hit.hasAttribute("data-aqsp-resize-anchor") ? "trailing" : "leading"
450
+ return { kind: isUnhide ? "unhide" : "resize", index, anchor }
389
451
  }
390
452
 
391
453
  const applyWindowPointerMove = (event: PointerEvent) => {
@@ -398,7 +460,9 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
398
460
  }
399
461
  // ズーム境界 (v0.3): ドラッグ差分は視覚 px — 論理 px へ 1 回だけ除算して適用する
400
462
  const delta = (gesture.axis === "col" ? event.clientX - gesture.startClientX : event.clientY - gesture.startClientY) / paramsRef.current.zoomRef.current
401
- pendingSizeRef.current = Math.max(0, Math.round(gesture.initialSize + delta))
463
+ // 末尾アンカーは反転デルタ (ADR-26 — pinned-right: 左 / 上ドラッグで拡大、ポインタと
464
+ // 動く辺が同行)。leading は v0.8 byte 恒等の +delta
465
+ pendingSizeRef.current = Math.max(0, Math.round(gesture.anchor === "trailing" ? gesture.initialSize - delta : gesture.initialSize + delta))
402
466
  // ガイドラインは rAF 合流の直書き 1 ノードのみ (§7.2 — セル実サイズは確定まで不変)
403
467
  if (guideRafRef.current === null) {
404
468
  guideRafRef.current = requestAnimationFrame(() => {
@@ -409,7 +473,8 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
409
473
  }
410
474
  const p = paramsRef.current
411
475
  const axisModel = live.axis === "col" ? p.colAxis : p.rowAxis
412
- p.writeResizeGuide({ axis: live.axis, position: axisModel.offsetOf(live.index) + pendingSizeRef.current })
476
+ // rAF 移動書き手もアンカー分岐 (ADR-26 — guidePositionOf が両書き手の単一式)
477
+ p.writeResizeGuide({ axis: live.axis, position: guidePositionOf(live, axisModel, pendingSizeRef.current), anchor: live.anchor })
413
478
  })
414
479
  }
415
480
  }
@@ -473,8 +538,9 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
473
538
  }
474
539
  p.focusSink()
475
540
  const axisModel = axis === "col" ? p.colAxis : p.rowAxis
476
- gestureRef.current = { axis, kind: hit.kind, index: hit.index, initialSize: axisModel.sizeOf(hit.index), startClientX: event.clientX, startClientY: event.clientY }
477
- pendingSizeRef.current = gestureRef.current.initialSize
541
+ const gesture: ResizeGesture = { axis, kind: hit.kind, index: hit.index, anchor: hit.anchor, initialSize: axisModel.sizeOf(hit.index), startClientX: event.clientX, startClientY: event.clientY }
542
+ gestureRef.current = gesture
543
+ pendingSizeRef.current = gesture.initialSize
478
544
  pressButtonsRef.current = event.buttons
479
545
  draggingRef.current = true
480
546
  // 自機フラグセット後の義務 press repaint (§8.2 の repaint エッジ — fill ハンドル消灯等)
@@ -492,8 +558,10 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
492
558
  windowListenersRef.current = { move, up }
493
559
  window.addEventListener("pointermove", move)
494
560
  window.addEventListener("pointerup", up)
495
- // 押下位置の境界にガイドラインを即時表示
496
- p.writeResizeGuide({ axis, position: axisModel.offsetOf(hit.index) + pendingSizeRef.current })
561
+ // 押下位置の境界にガイドラインを即時表示 — 押下書き手もアンカー分岐 (ADR-26 §14-6:
562
+ // 押下時 pendingSize = initialSize のため trailing 式は押下ストリップ自身の近端
563
+ // offsetOf(index) に着地する。leading 式のままだと最初の pointermove まで遠端に点滅)
564
+ p.writeResizeGuide({ axis, position: guidePositionOf(gesture, axisModel, pendingSizeRef.current), anchor: gesture.anchor })
497
565
  }
498
566
 
499
567
  /** Band dblclick: autofit / unhide-restore (mouse via lastPointerTypeRef — dblclick has no pointerType). / 帯 dblclick (autofit / 復元)。 */
@@ -101,7 +101,18 @@ export const useHeaderSelection = (params: UseHeaderSelectionParams): SelectionE
101
101
  /** Reserved press index ((a) subtract target / (b) conversion anchor seed). / 予約押下 index ((a) の減算対象 / (b) 変換時の anchor 種)。 */
102
102
  const reservedIndexRef = useRef(0)
103
103
 
104
- /** Band coordinate to main-axis index (columns add hx, rows add vy). / 帯座標 → 主軸 index (列は hx、行は vy を加算)。 */
104
+ /**
105
+ * Band coordinate to main-axis index via the three-way inverse map (columns add hx, rows add
106
+ * vy; leading > trailing > middle — v0.9 plan §4.3, census #2). Each axis branch takes its
107
+ * OWN handle snapshot and builds the AxisInverseCtx inline (`trailingSize` = the TREE field,
108
+ * `extent` = a judgment-time `getViewportSize()` read — plan §2.1 rules 1/2; extent 0 keeps
109
+ * the trailing branch inert, byte-matching v0.8). The [0, count − 1] clamp is unchanged.
110
+ * 帯座標 → 主軸 index の 3 分岐逆写像 (列は hx、行は vy を加算。先頭 > 末尾 > 中央 — v0.9
111
+ * 計画 §4.3、census #2)。列 / 行の各分岐が自前ハンドルスナップショットを取り AxisInverseCtx を
112
+ * インライン構築 (`trailingSize` は TREE フィールド、`extent` は判断時 `getViewportSize()`
113
+ * 読み — 計画 §2.1 規則 1/2。extent 0 は末尾分岐を不活性に保ち v0.8 と byte 一致)。クランプ
114
+ * [0, count − 1] は不変。
115
+ */
105
116
  const hitIndex = useCallback((clientX: number, clientY: number): number | null => {
106
117
  const { axis, mainAxis, handleRef, zoomRef } = paramsRef.current
107
118
  const rect = headerRectRef.current
@@ -112,22 +123,30 @@ export const useHeaderSelection = (params: UseHeaderSelectionParams): SelectionE
112
123
  // 位置は両軸ともハンドルの同期読み (§7-4 — ドラッグ中オートスクロールでも最新の hx で
113
124
  // 着地列が決まる。旧 rAF 写像読みは列ヘッダー帯ドラッグの鮮度非対称の現存証拠だった)
114
125
  const z = zoomRef.current
126
+ const handle = handleRef.current
127
+ if (handle === null) {
128
+ return null
129
+ }
115
130
  if (axis === "col") {
116
- const hx = handleRef.current?.getScrollPosition().x ?? -1
131
+ const hx = handle.getScrollPosition().x
117
132
  if (hx < 0) {
118
133
  return null
119
134
  }
120
- // 凍結パーティション逆写像 (v0.5 計画 §2) — 凍結ヘッダー帯上の押下は静的座標で解決
121
- const frozenWidth = handleRef.current?.getFrozenSize().width ?? 0
122
- return Math.min(Math.max(viewportXToCol(mainAxis, (clientX - rect.left) / z, hx, frozenWidth), 0), mainAxis.count - 1)
135
+ // 凍結パーティション 3 分岐逆写像 (v0.5 計画 §2、末尾は v0.9 §2.1) — ctx は列分岐の
136
+ // 自前スナップショットからこの場でインライン構築 (規則 1)。trailingSize は TREE
137
+ // フィールド (規則 2)、extent は判断時の getViewportSize() 読み
138
+ const frozen = handle.getFrozenSize()
139
+ const viewport = handle.getViewportSize()
140
+ return Math.min(Math.max(viewportXToCol(mainAxis, (clientX - rect.left) / z, { scroll: hx, frozenSize: frozen.width, trailingSize: frozen.trailingWidth, extent: viewport.width }), 0), mainAxis.count - 1)
123
141
  }
124
- const vy = handleRef.current?.getScrollPosition().y ?? -1
142
+ const vy = handle.getScrollPosition().y
125
143
  if (vy < 0) {
126
144
  return null
127
145
  }
128
- // 行ヘッダーも凍結パーティション逆写像 (v0.6 計画 §2 — 凍結行ヘッダー帯上の押下は静的解決)
129
- const frozenHeight = handleRef.current?.getFrozenSize().height ?? 0
130
- return Math.min(Math.max(viewportYToRow(mainAxis, (clientY - rect.top) / z, vy, frozenHeight), 0), mainAxis.count - 1)
146
+ // 行ヘッダーも 3 分岐逆写像 (v0.6 計画 §2、末尾は v0.9 §2.1) — 行分岐の自前スナップショット
147
+ const frozen = handle.getFrozenSize()
148
+ const viewport = handle.getViewportSize()
149
+ return Math.min(Math.max(viewportYToRow(mainAxis, (clientY - rect.top) / z, { scroll: vy, frozenSize: frozen.height, trailingSize: frozen.trailingHeight, extent: viewport.height }), 0), mainAxis.count - 1)
131
150
  }, [])
132
151
 
133
152
  /** Index to axis-full span corner cells. / index → 片軸全域スパンの両端セル。 */
@@ -3,11 +3,22 @@
3
3
  * "no-op when visible" align (start/end/center only, and it pins anchors), so arrow-key
4
4
  * navigation must use edge-delta scrollBy on BOTH axes to avoid per-keystroke snapping
5
5
  * (§7-7 of the VirtualGrid design plan — scrollTo is the floor-and-pin jump API and is
6
- * deliberately not used here).
6
+ * deliberately not used here). v0.9 (plan §4.7, census #9): trailing frozen tracks are
7
+ * always visible and become per-axis no-ops (`row >= rowCount − trailingRows` blocks the
8
+ * vertical drive, `col >= colCount − trailingCols` the horizontal one), and the far
9
+ * visibility limit of SCROLL tracks shrinks by the trailing band's visible size
10
+ * (`viewport − trailingVisible*` — the Handsontable #5947 under-band landing class); all
11
+ * trailing fields are judgment-time `getFrozenSize()` reads, and T = 0 leaves every
12
+ * expression byte-identical to the v0.8 arithmetic.
7
13
  *
8
14
  * 自前 nearest 可視化スクロール (§6.6.3)。scrollToCell に「可視なら no-op」は存在しない
9
15
  * (start/end/center のみ、かつアンカーを張る) ため、矢印ナビは両軸ともはみ出し分だけの
10
16
  * scrollBy で自作する (設計計画 §7-7 — scrollTo は floor + アンカー張りのジャンプ API で不使用)。
17
+ * v0.9 (計画 §4.7、census #9): 末尾凍結トラックは常時可視のため軸別 no-op (縦は
18
+ * `row >= rowCount − trailingRows`、横は `col >= colCount − trailingCols` で遮断)、SCROLL
19
+ * トラックの遠可視限界は末尾帯の可視寸ぶん縮小 (`viewport − trailingVisible*` — Handsontable
20
+ * #5947 類の帯下敷き着地の回避)。trailing 系は全て判断時 `getFrozenSize()` 読みで、T = 0 は
21
+ * 全式が v0.8 算術と byte 一致。
11
22
  */
12
23
 
13
24
  import type { VirtualGridHandle } from "@aiquants/virtualscroll"
@@ -25,9 +36,14 @@ type EnsureCellVisibleDeps = {
25
36
  /**
26
37
  * Builds the ensureCellVisible helper (constructed once in Spreadsheet, injected into
27
38
  * keyboard / editor / Shift+F10 — §6.9-7 ownership). Both axes read the grid handle
28
- * synchronously (the truth — §7-4) and land via edge-delta scrollBy.
39
+ * synchronously (the truth — §7-4) and land via edge-delta scrollBy. v0.9 (plan §4.7):
40
+ * trailing frozen tracks are always-visible per-axis no-ops and the far limit of scroll
41
+ * tracks shrinks by the trailing band's viewport-clipped visible size (the vis fields —
42
+ * judgment-time handle reads, never render-surface rederivations).
29
43
  * ensureCellVisible の構築 (Spreadsheet で 1 回構築し keyboard / editor / Shift+F10 へ配る)。
30
- * 両軸ともグリッドハンドルの同期読み (§7-4 の真実) + 端差分 scrollBy で着地する。
44
+ * 両軸ともグリッドハンドルの同期読み (§7-4 の真実) + 端差分 scrollBy で着地する。v0.9 (計画
45
+ * §4.7): 末尾凍結トラックは常時可視の軸別 no-op、スクロールトラックの遠限界は末尾帯の
46
+ * viewport クリップ済み可視寸 (vis フィールド — 判断時ハンドル読み、レンダ面再導出禁止) ぶん縮小。
31
47
  */
32
48
  export const createEnsureCellVisible = ({ handleRef, rowAxis, colAxis }: EnsureCellVisibleDeps) => {
33
49
  return (cell: CellCoord): void => {
@@ -43,34 +59,41 @@ export const createEnsureCellVisible = ({ handleRef, rowAxis, colAxis }: EnsureC
43
59
  if (vy < 0 || hx < 0) {
44
60
  return
45
61
  }
62
+ // 帯幾何は判断毎の同期スナップショット 1 回 (leading / trailing とも同じ鮮度で読む)
63
+ const frozen = handle.getFrozenSize()
46
64
  // 縦: 論理座標で可視域と比較し、はみ出しぶんだけ scrollBy (端差分 — §7-7)。
47
- // 凍結行 (v0.6 計画 §3 — 横の凍結列 no-op と同文) は常時可視のため縦成分のみ no-op
48
- // (横調整は下ブロックで続行 — 凍結行 × スクロール列のセルは横スクロールが要る)。
49
- // スクロール行の上可視限界は木座標 H_F + vy (viewport 上端 y = H_F がスクロール帯の始まり)
50
- if (viewportH > 0 && cell.row >= handle.getFrozenSize().rows) {
51
- const frozenHeight = handle.getFrozenSize().height
65
+ // 凍結行 (v0.6 計画 §3 — 横の凍結列 no-op と同文) と末尾行 (v0.9 §4.7 — row >=
66
+ // rowCount − trailingRows は常時可視) は縦成分のみ no-op (横調整は下ブロックで続行 —
67
+ // 帯行 × スクロール列のセルは横スクロールが要る)。スクロール行の上可視限界は木座標
68
+ // H_F + vy、下可視限界は末尾帯の可視高ぶん縮小した vy + viewportH − H_T_vis (v0.9 §4.7
69
+ // の縮小遠限界 — 素の viewportH のままだと着地行が末尾帯の下敷きになる)
70
+ if (viewportH > 0 && cell.row >= frozen.rows && cell.row < rowAxis.count - frozen.trailingRows) {
52
71
  const top = rowAxis.offsetOf(cell.row)
53
72
  const bottom = top + rowAxis.sizeOf(cell.row)
54
- if (top < frozenHeight + vy) {
55
- handle.scrollBy({ y: top - (frozenHeight + vy) })
56
- } else if (bottom > vy + viewportH) {
57
- handle.scrollBy({ y: bottom - (vy + viewportH) })
73
+ if (top < frozen.height + vy) {
74
+ handle.scrollBy({ y: top - (frozen.height + vy) })
75
+ } else if (bottom > vy + viewportH - frozen.trailingVisibleHeight) {
76
+ handle.scrollBy({ y: bottom - (vy + viewportH - frozen.trailingVisibleHeight) })
58
77
  }
59
78
  }
60
79
  // 横: 同型の nearest 計算 → 端差分 scrollBy (クランプはハンドルが担う)。
61
- // 凍結列 (v0.5 計画 §3-4) は常時可視のため no-op。スクロール列の左可視限界は木座標
62
- // W_F + hx (viewport 左端 x = W_F がスクロール帯の始まり — エンジン §8-a)
80
+ // 凍結列 (v0.5 計画 §3-4) と末尾列 (v0.9 §4.7 — col >= colCount − trailingCols) は
81
+ // 常時可視のため no-op (横ブロックは末尾処理なので早期 return で足りる)。スクロール列の
82
+ // 左可視限界は木座標 W_F + hx (viewport 左端 x = W_F がスクロール帯の始まり — エンジン
83
+ // §8-a)、右可視限界は末尾帯の可視幅ぶん縮小した hx + viewportW − W_T_vis (v0.9 §4.7)
63
84
  if (viewportW > 0) {
64
- const frozen = handle.getFrozenSize()
65
85
  if (cell.col < frozen.cols) {
66
86
  return
67
87
  }
88
+ if (cell.col >= colAxis.count - frozen.trailingCols) {
89
+ return
90
+ }
68
91
  const left = colAxis.offsetOf(cell.col)
69
92
  const right = left + colAxis.sizeOf(cell.col)
70
93
  if (left < frozen.width + hx) {
71
94
  handle.scrollBy({ x: left - (frozen.width + hx) })
72
- } else if (right > hx + viewportW) {
73
- handle.scrollBy({ x: right - (hx + viewportW) })
95
+ } else if (right > hx + viewportW - frozen.trailingVisibleWidth) {
96
+ handle.scrollBy({ x: right - (hx + viewportW - frozen.trailingVisibleWidth) })
74
97
  }
75
98
  }
76
99
  }
@@ -773,19 +773,25 @@ export const useGridKeyboard = (params: UseGridKeyboardParams): void => {
773
773
  // 両軸ともハンドルの同期読み (§7-4): ensureCellVisible 直後の cellRect は
774
774
  // 最新位置で計算しないと横画面外からのメニュー表示が旧位置へ誤配置される
775
775
  const handle = handleRef.current
776
- const position = handle?.getScrollPosition() ?? { x: 0, y: 0 }
776
+ const position = handle === null ? { x: 0, y: 0 } : handle.getScrollPosition()
777
777
  // 両軸とも読点で番兵クランプ (対称 — detached の {-1,-1} を座標へ流さない)
778
778
  const vy = Math.max(0, position.y)
779
779
  const hx = Math.max(0, position.x)
780
+ // 帯件数と extent も同じ判断で読む (v0.9 計画 §4.8、census #10 — AxisForwardCtx は
781
+ // この場でインライン構築、§2.1 規則 1)。detached ハンドルは件数 0 / extent 0 の縮退
782
+ // (末尾分岐不活性 = v0.8 恒等)
783
+ const frozenMenu = handle === null ? { rows: 0, cols: 0, trailingRows: 0, trailingCols: 0 } : handle.getFrozenSize()
784
+ const viewportMenu = handle === null ? { width: 0, height: 0 } : handle.getViewportSize()
780
785
  // ズーム境界 (v0.3): cellRect は視覚 px (論理 × z) — consumer は胴体ペインの
781
786
  // gBCR (視覚 px) と直接加算して fixed 配置できる (デモ /render が参照実装)
782
787
  const zMenu = p.zoomRef.current
783
788
  p.onCellContextMenu?.(active, {
784
789
  kind: "keyboard",
785
790
  cellRect: {
786
- // 行 / 列とも凍結パーティション順写像 (v0.6 / v0.5 計画 §2 — 凍結軸は静的座標)
787
- top: rowToViewportY(p.rowAxis, active.row, vy, handle?.getFrozenSize().rows ?? 0) * zMenu,
788
- left: colToViewportX(p.colAxis, active.col, hx, handle?.getFrozenSize().cols ?? 0) * zMenu,
791
+ // 行 / 列とも凍結パーティション 3 分岐順写像 (v0.6 / v0.5 計画 §2、末尾は
792
+ // v0.9 §2.1 — 凍結軸は静的座標、末尾軸は extent アンカー)。×zoom は写像後 (契約不変)
793
+ top: rowToViewportY(p.rowAxis, active.row, { scroll: vy, frozenCount: frozenMenu.rows, trailingCount: frozenMenu.trailingRows, extent: viewportMenu.height }) * zMenu,
794
+ left: colToViewportX(p.colAxis, active.col, { scroll: hx, frozenCount: frozenMenu.cols, trailingCount: frozenMenu.trailingCols, extent: viewportMenu.width }) * zMenu,
789
795
  width: p.colAxis.sizeOf(active.col) * zMenu,
790
796
  height: p.rowAxis.sizeOf(active.row) * zMenu,
791
797
  },