@aiquants/virtualscroll 3.9.2 → 3.11.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +135 -40
  3. package/dist/ScrollBar.d.cts +45 -38
  4. package/dist/ScrollBar.d.ts +45 -38
  5. package/dist/ScrollBar.d.ts.map +1 -1
  6. package/dist/ScrollPane.d.cts +12 -14
  7. package/dist/ScrollPane.d.ts +12 -14
  8. package/dist/ScrollPane.d.ts.map +1 -1
  9. package/dist/VirtualGrid.d.cts +6 -6
  10. package/dist/VirtualGrid.d.ts +6 -6
  11. package/dist/VirtualGrid.d.ts.map +1 -1
  12. package/dist/VirtualScroll.d.cts +184 -30
  13. package/dist/VirtualScroll.d.ts +184 -30
  14. package/dist/VirtualScroll.d.ts.map +1 -1
  15. package/dist/cli.js +26 -47
  16. package/dist/index.cjs +1 -1
  17. package/dist/index.js +2385 -2413
  18. package/dist/labels.d.cts +38 -7
  19. package/dist/labels.d.ts +38 -7
  20. package/dist/labels.d.ts.map +1 -1
  21. package/dist/logger.d.cts +1 -16
  22. package/dist/logger.d.ts +1 -16
  23. package/dist/logger.d.ts.map +1 -1
  24. package/dist/styles/virtualscroll.css +1 -1
  25. package/dist/styles/virtualscroll.standalone.css +1 -1
  26. package/dist/useFenwickMapTree.d.cts +21 -1
  27. package/dist/useFenwickMapTree.d.ts +21 -1
  28. package/dist/useFenwickMapTree.d.ts.map +1 -1
  29. package/dist/useLruCache.d.ts.map +1 -1
  30. package/dist/utils.d.cts +14 -0
  31. package/dist/utils.d.ts +14 -0
  32. package/dist/utils.d.ts.map +1 -1
  33. package/package.json +5 -2
  34. package/src/ScrollBar.tsx +115 -55
  35. package/src/ScrollPane.tsx +12 -14
  36. package/src/VirtualGrid.tsx +27 -25
  37. package/src/VirtualScroll.tsx +486 -234
  38. package/src/labels.ts +58 -7
  39. package/src/logger.ts +1 -25
  40. package/src/styles/virtualscroll.css +51 -3
  41. package/src/useFenwickMapTree.ts +60 -98
  42. package/src/useLruCache.ts +14 -8
  43. package/src/utils.ts +15 -0
package/src/labels.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * @module labels
3
- * @description Built-in UI chrome label catalog of the package. Covers exactly the seven strings
4
- * the components render on their own: the ScrollBar arrow aria-labels, the VirtualScroll
3
+ * @description Built-in UI chrome label catalog of the package. Covers exactly the eleven strings
4
+ * the components render on their own: the ScrollBar accessible names (per orientation, the two
5
+ * arrows, the `role="scrollbar"` bar and its `role="slider"` thumb), the VirtualScroll
5
6
  * scroll-to-edge pill texts and the empty-state text. Live-region wording stays consumer-owned
6
7
  * (`liveRegion.format` / `liveRegion.buildMessage`) and is not part of this catalog. The default
7
8
  * locale is `"en"`; `"ja"` is the second locale. No language negotiation happens here: hosts map
8
9
  * `navigator.language` (or anything else) to a supported locale themselves.
9
10
  *
10
- * @description パッケージ内蔵の UI クローム文言カタログ。コンポーネント自身が描画する 7 文言
11
- * (ScrollBar 矢印の aria-label、VirtualScroll の端スクロールピル文言、空状態文言) だけを対象とし、
11
+ * @description パッケージ内蔵の UI クローム文言カタログ。コンポーネント自身が描画する 11 文言
12
+ * (向きごとの ScrollBar のアクセシブルネーム — 矢印 2 個・`role="scrollbar"` のバー・その `role="slider"` の
13
+ * つまみ —、VirtualScroll の端スクロールピル文言、空状態文言) だけを対象とし、
12
14
  * ライブリージョンの文言は利用側の所有 (`liveRegion.format` / `liveRegion.buildMessage`) で
13
15
  * 本カタログの対象外。既定ロケールは `"en"`、第 2 ロケールは `"ja"`。言語ネゴシエーションは行わず、
14
16
  * `navigator.language` 等から対応ロケールへの対応付けはホスト側の責務。
@@ -53,6 +55,30 @@ export type VirtualScrollLabels = {
53
55
  * 横 ScrollBar 終端 (右) 矢印ボタンの aria-label。
54
56
  */
55
57
  readonly scrollRight: string
58
+ /**
59
+ * aria-label of the vertical ScrollBar root (`role="scrollbar"`). Assistive technology appends
60
+ * the role ("scroll bar") itself, so the name carries only what tells the two bars apart.
61
+ * 縦 ScrollBar のルート (`role="scrollbar"`) の aria-label。ロール名 (スクロールバー) は支援技術が
62
+ * 付け足すため、名前は 2 本のバーを区別する情報のみ。
63
+ */
64
+ readonly verticalScrollBar: string
65
+ /**
66
+ * aria-label of the horizontal ScrollBar root (`role="scrollbar"`).
67
+ * 横 ScrollBar のルート (`role="scrollbar"`) の aria-label。
68
+ */
69
+ readonly horizontalScrollBar: string
70
+ /**
71
+ * aria-label of the vertical ScrollBar thumb (`role="slider"`), whose value is the scroll
72
+ * position.
73
+ * 縦 ScrollBar のつまみ (`role="slider"`) の aria-label。値はスクロール位置。
74
+ */
75
+ readonly verticalScrollThumb: string
76
+ /**
77
+ * aria-label of the horizontal ScrollBar thumb (`role="slider"`), whose value is the scroll
78
+ * position.
79
+ * 横 ScrollBar のつまみ (`role="slider"`) の aria-label。値はスクロール位置。
80
+ */
81
+ readonly horizontalScrollThumb: string
56
82
  /**
57
83
  * Text of the VirtualScroll scroll-to-top pill, shown when
58
84
  * `scrollBarOptions.enableScrollToTopBottomButtons` is on.
@@ -87,15 +113,32 @@ export type VirtualScrollLabelOverrides = { readonly [K in keyof VirtualScrollLa
87
113
  * overrides are validated against.
88
114
  * {@link VirtualScrollLabels} の全キー (カタログ順)。上書き検証に用いる閉じたキー集合。
89
115
  */
90
- export const VIRTUAL_SCROLL_LABEL_KEYS = ["scrollUp", "scrollDown", "scrollLeft", "scrollRight", "scrollToTop", "scrollToBottom", "noItems"] as const satisfies readonly (keyof VirtualScrollLabels)[]
116
+ export const VIRTUAL_SCROLL_LABEL_KEYS = [
117
+ "scrollUp",
118
+ "scrollDown",
119
+ "scrollLeft",
120
+ "scrollRight",
121
+ "verticalScrollBar",
122
+ "horizontalScrollBar",
123
+ "verticalScrollThumb",
124
+ "horizontalScrollThumb",
125
+ "scrollToTop",
126
+ "scrollToBottom",
127
+ "noItems",
128
+ ] as const satisfies readonly (keyof VirtualScrollLabels)[]
91
129
 
92
130
  // satisfies は部分集合しか保証しないため、キー追加時の登録漏れを型で検出する
93
131
  const keysCoverEveryLabel: [Exclude<keyof VirtualScrollLabels, (typeof VIRTUAL_SCROLL_LABEL_KEYS)[number]>] extends [never] ? true : never = true
94
132
  void keysCoverEveryLabel
95
133
 
96
134
  /**
97
- * Frozen built-in catalogs per locale. `en` is the package's historical English wording.
98
- * ロケールごとの凍結済み内蔵カタログ。`en` はパッケージ従来の英語文言。
135
+ * Frozen built-in catalogs per locale. The arrow, pill and empty-state values of `en` are the
136
+ * package's historical English wording. The bar names state only the orientation, because
137
+ * assistive technology already reads the role as "scroll bar"; the thumb names state what the
138
+ * value measures.
139
+ * ロケールごとの凍結済み内蔵カタログ。`en` の矢印・ピル・空状態の値はパッケージ従来の英語文言。
140
+ * バーの名前は向きのみ (支援技術がロールを既に「スクロールバー」と読むため)、つまみの名前は値が
141
+ * 表すもの。
99
142
  */
100
143
  export const VIRTUAL_SCROLL_LABEL_CATALOGS: Readonly<Record<VirtualScrollLocale, VirtualScrollLabels>> = Object.freeze({
101
144
  en: Object.freeze({
@@ -103,6 +146,10 @@ export const VIRTUAL_SCROLL_LABEL_CATALOGS: Readonly<Record<VirtualScrollLocale,
103
146
  scrollDown: "Scroll down",
104
147
  scrollLeft: "Scroll left",
105
148
  scrollRight: "Scroll right",
149
+ verticalScrollBar: "Vertical",
150
+ horizontalScrollBar: "Horizontal",
151
+ verticalScrollThumb: "Vertical scroll position",
152
+ horizontalScrollThumb: "Horizontal scroll position",
106
153
  scrollToTop: "Top",
107
154
  scrollToBottom: "Bottom",
108
155
  noItems: "No items",
@@ -112,6 +159,10 @@ export const VIRTUAL_SCROLL_LABEL_CATALOGS: Readonly<Record<VirtualScrollLocale,
112
159
  scrollDown: "下へスクロール",
113
160
  scrollLeft: "左へスクロール",
114
161
  scrollRight: "右へスクロール",
162
+ verticalScrollBar: "縦方向",
163
+ horizontalScrollBar: "横方向",
164
+ verticalScrollThumb: "縦スクロール位置",
165
+ horizontalScrollThumb: "横スクロール位置",
115
166
  scrollToTop: "先頭へ",
116
167
  scrollToBottom: "末尾へ",
117
168
  noItems: "項目がありません",
package/src/logger.ts CHANGED
@@ -47,7 +47,7 @@ export interface ILogger {
47
47
  */
48
48
  export class Logger implements ILogger {
49
49
  private level: LogLevel
50
- private prefix: string
50
+ private readonly prefix: string
51
51
  private impl: ILogger
52
52
 
53
53
  /**
@@ -104,26 +104,6 @@ export class Logger implements ILogger {
104
104
  this.impl = impl
105
105
  }
106
106
 
107
- /**
108
- * @method setPrefix
109
- * @description Updates the log prefix for the static instance.
110
- * @description 静的インスタンスのログのプレフィックスを更新します。
111
- * @param {string} prefix - The new prefix.
112
- */
113
- static setPrefix(prefix: string): void {
114
- Logger.instance.setPrefix(prefix)
115
- }
116
-
117
- /**
118
- * @method setPrefix
119
- * @description Updates the log prefix.
120
- * @description ログのプレフィックスを更新します。
121
- * @param {string} prefix - The new prefix.
122
- */
123
- setPrefix(prefix: string): void {
124
- this.prefix = prefix
125
- }
126
-
127
107
  /**
128
108
  * @method isEnabled
129
109
  * @description Returns whether the given level would be emitted at the current level (static instance).
@@ -197,10 +177,6 @@ export class Logger implements ILogger {
197
177
  }
198
178
  }
199
179
 
200
- static info(message?: unknown, ...optionalParams: unknown[]): void {
201
- Logger.instance.info(message, ...optionalParams)
202
- }
203
-
204
180
  info(message?: unknown, ...optionalParams: unknown[]): void {
205
181
  if (this.level <= LogLevel.INFO) {
206
182
  this.impl.info(...this.formatMessage(Logger.resolveLazy(message)), ...optionalParams.map(Logger.resolveLazy))
@@ -329,6 +329,22 @@
329
329
  width: 100%;
330
330
  }
331
331
 
332
+ /* 行ラッパーの包含ブロックで、配置の境界 (Chromium の relayout boundary)。窓をずらす 1 段が行を足し引きしても、配置はこの箱から
333
+ * 始まって箱の中で終わり、文書の根まで遡らない。境界になるには flex の子でも grid の子でもないことが要る (ペインの中身は flex の子
334
+ * なので、ラッパーの包含ブロックにしても境界にならない)。この箱は位置指定と、大きさと配置の封じ込めで境界になる。
335
+ * 箱はペインの中身のパディングの辺に付いて幅を合わせ、高さは 0 に固定する: 行は箱の外へはみ出して描かれ (描画は封じ込めないので、
336
+ * 切り取りはペインの中身の overflow: hidden のまま)、箱は当たり判定で背景 (background) の前に立たない。行のはみ出しは封じ込めで
337
+ * インクのはみ出しになるので、ペインの中身のスクロールできる範囲にも数えない。そのため、ブラウザが自分で行を見せるスクロール
338
+ * (オーバースキャンの行の中への Tab・ページ内検索) はペインの中身を動かせず、行の箱が外側のスクローラーの外にあればそちらを動かす */
339
+ .aqvs-items-boundary {
340
+ position: absolute;
341
+ top: 0;
342
+ right: 0;
343
+ left: 0;
344
+ height: 0;
345
+ contain: size layout style;
346
+ }
347
+
332
348
  .aqvs-items-wrapper {
333
349
  position: absolute;
334
350
  width: 100%;
@@ -547,15 +563,47 @@
547
563
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
548
564
  }
549
565
 
550
- /* パッケージ全域・単一所有者 — 本 CSS 初の forced-colors 規則: グラデーション視覚は
551
- * forced-colors で平坦化され、輪郭なしではサークルが不可視になる (描画モード修正 —
552
- * 単体サークルにも輪郭が付くのは登記済みの挙動非変更デルタ)。 */
566
+ /* 強制配色 (forced-colors: active) では、ブラウザがシステム色でない地色をどれも Canvas に置き換える (透明度は残す)。地色だけで
567
+ * 描く部品 (文字色も枠線も輪郭も持たない部品) は周りと区別できなくなるので、どれもここでシステム色の描き方を持つ
568
+ * (forcedColors.spec.tsx がこのファイルから地色だけで描く部品を導いて照合する)。
569
+ * - タップスクロールサークル: グラデーションの視覚は平坦になるので、輪郭で形を残す。
570
+ * - バーとトラック: 縁を GrayText の輪郭で描く (バーは矢印とトラックの下地、トラックはつまみが動く範囲)。
571
+ * - つまみ: ネイティブのスクロールバーと同じくシステム色で塗る。状態の規則 (ホバー・ドラッグ・無効) は同じ選択子でここに
572
+ * 言い直す: 詳細度が同じなので、後に置いたこちらが勝つ。forced-color-adjust: none はホストが塗ったシステム色でない地色を
573
+ * Canvas へ消させない (つまみが溝と同じ色になって消えない)。
574
+ * - サンプルのビジュアルの光と棒: 引いた向きと量を示すので CanvasText で塗る。 */
553
575
  @media (forced-colors: active) {
554
576
  .aqvs-tap-scroll-circle {
555
577
  outline: 1px solid CanvasText;
556
578
  outline-offset: -1px;
557
579
  border-radius: 50%;
558
580
  }
581
+
582
+ .aqvs-scrollbar,
583
+ .aqvs-scrollbar-track {
584
+ outline: 1px solid GrayText;
585
+ outline-offset: -1px;
586
+ }
587
+
588
+ .aqvs-scrollbar-thumb {
589
+ forced-color-adjust: none;
590
+ background-color: CanvasText;
591
+ }
592
+
593
+ .aqvs-scrollbar-thumb[data-thumb-state="hover"],
594
+ .aqvs-scrollbar-thumb[data-thumb-state="dragging"] {
595
+ background-color: Highlight;
596
+ }
597
+
598
+ .aqvs-scrollbar-thumb[data-thumb-state="disabled"] {
599
+ background-color: GrayText;
600
+ }
601
+
602
+ .aqvs-sample-visual-highlight,
603
+ .aqvs-sample-visual-rod {
604
+ forced-color-adjust: none;
605
+ background-color: CanvasText;
606
+ }
559
607
  }
560
608
 
561
609
  /* 動きを減らす設定 (prefers-reduced-motion: reduce) では、パッケージが動かす部品はどれも遷移もアニメーションも持たず、
@@ -32,22 +32,6 @@ type MaterializeConfig = { materializeOption?: MaterializeOption }
32
32
  type DeltaUpdate = { index: number; change: number }
33
33
  type ValueUpdate = { index: number; value: number }
34
34
 
35
- /**
36
- * Converts a numeric size into a non-negative bigint, safeguarding against fractional input.
37
- *
38
- * 分数入力を安全に丸めてから非負の bigint へ変換。
39
- */
40
- const toSafeBigInt = (value: number): bigint => {
41
- if (!Number.isFinite(value)) {
42
- return 0n
43
- }
44
- const truncated = Math.trunc(value)
45
- if (truncated <= 0) {
46
- return 0n
47
- }
48
- return BigInt(truncated)
49
- }
50
-
51
35
  /**
52
36
  * Validates that `valueFn` returned a finite number, throwing otherwise. A single NaN/Infinity
53
37
  * propagated into the tree poisons `tree`/`total` irrecoverably, so the materialization and
@@ -75,14 +59,17 @@ const requireFiniteValue = (value: number, index: number): number => {
75
59
  const NEGATIVE_EFFECTIVE_VALUE_EPSILON = 1e-9
76
60
 
77
61
  /**
78
- * Derives the lowest set bit without relying on 32-bit bitwise operators.
62
+ * Derives the lowest set bit of a 1-based tree index (a positive integer) without relying on 32-bit bitwise operators. Every
63
+ * caller walks the tree from such an index, so the result is at least 1 and each walk ends: a walk up adds it until the index
64
+ * passes the size, a walk down subtracts it until the index reaches 0.
65
+ *
66
+ * 1 始まりの木の添字 (正の整数) の最下位ビットを、32 ビット演算に依存せずに求める処理。呼び出し元はどれもそのような添字から木を
67
+ * 辿るので、結果は 1 以上で、どの辿り方も終わる (上へ辿ると足して添字が要素数を越え、下へ辿ると引いて添字が 0 に着く)。
79
68
  *
80
- * 32 ビット演算に依存せずに最下位ビットを算出。
69
+ * @param value - A 1-based tree index (a positive integer) / 1 始まりの木の添字 (正の整数)
70
+ * @returns The lowest set bit (1 or more) / 最下位ビット (1 以上)
81
71
  */
82
72
  const getLowestSetBit = (value: number): number => {
83
- if (value <= 0 || !Number.isFinite(value)) {
84
- return 0
85
- }
86
73
  const integer = Math.trunc(value)
87
74
  // 32 ビット符号付き整数の範囲内なら通常のビット演算で最下位ビットを求める (BigInt 割り当てを回避)。
88
75
  // ホットパス (prefixSum / _updateTree など) の GC 圧を大幅に削減する。
@@ -154,6 +141,13 @@ export class FenwickMapTree {
154
141
  private valueFn?: (index: number) => number
155
142
  private total?: number
156
143
 
144
+ /**
145
+ * @private
146
+ * @property {number} revisionCount - The counter behind `revision`: raised by every change of a stored value or of the size.
147
+ * @property {number} revisionCount - `revision` の元の数。保持する値か要素数が変わるたびに上がる。
148
+ */
149
+ private revisionCount = 0
150
+
157
151
  /**
158
152
  * @constructor
159
153
  * @description Initializes the Fenwick Tree.
@@ -193,6 +187,7 @@ export class FenwickMapTree {
193
187
  this.valueFn = undefined
194
188
  this.baseValue = valueOrFn
195
189
  this.total = this.baseValue * this.size
190
+ this.revisionCount += 1
196
191
  return
197
192
  }
198
193
 
@@ -276,11 +271,9 @@ export class FenwickMapTree {
276
271
  this.valueFn = valueOrFn
277
272
  this.baseValue = baseValue
278
273
  if (seed) {
274
+ // 種は [from, to] (to < size) の値なので、添字は要素数を越えない
279
275
  for (let i = 0; i < seed.values.length; i++) {
280
276
  const index = seed.from + i
281
- if (index >= this.size) {
282
- break
283
- }
284
277
  // _materialize を呼ぶ代わりに、計算フェーズの検証済みの値を使って更新する (二重評価を回避)
285
278
  const change = seed.values[i] - this.baseValue
286
279
  // baseValue と一致する行は delta を持たせない (Map の無駄な肥大を回避)
@@ -292,6 +285,7 @@ export class FenwickMapTree {
292
285
  }
293
286
  // 具現化が完了した後に total を計算する
294
287
  this.total = this.getTotal()
288
+ this.revisionCount += 1
295
289
  }
296
290
 
297
291
  /**
@@ -318,6 +312,7 @@ export class FenwickMapTree {
318
312
  // baseValue を差し替えたら total を再計算しないと prefixSum と乖離する。
319
313
  // (deltas は温存されるため sum(deltas) + baseValue * size に一致させる)
320
314
  this.total = this._computeTreeTotal()
315
+ this.revisionCount += 1
321
316
  }
322
317
  }
323
318
 
@@ -348,10 +343,6 @@ export class FenwickMapTree {
348
343
  * @returns {number} The representative base value.
349
344
  */
350
345
  private static _modeOrMedian(values: number[]): number {
351
- if (values.length === 0) {
352
- return 0
353
- }
354
-
355
346
  // 中央値を計算してデフォルトの最頻値として設定
356
347
  values.sort((a, b) => a - b)
357
348
  const mid = Math.floor(values.length / 2)
@@ -402,10 +393,6 @@ export class FenwickMapTree {
402
393
  * @returns {number} The estimated base value.
403
394
  */
404
395
  private static _sampleBaseValueStrided(valueFn: (index: number) => number, size: number, sampleCount: number): number {
405
- if (size <= 0) {
406
- return 0
407
- }
408
-
409
396
  // stride = max(1, ceil(size / sampleCount)) を基準に、偶数なら +1 して奇数化する。
410
397
  // ceil によりサンプル数を多少犠牲にしても必ずリスト全域を跨ぐ (_modeOrMedian の
411
398
  // 20% しきい値は割合ベースのためサンプル数減の影響は受けない)。
@@ -526,15 +513,12 @@ export class FenwickMapTree {
526
513
  if (change === 0) {
527
514
  return
528
515
  }
516
+ this.revisionCount += 1
529
517
  // tree Map を更新
530
518
  let treeIndex = index + 1 // Fenwick Tree のアルゴリズムは 1 始まりで設計される
531
519
  while (treeIndex <= this.size) {
532
520
  this.tree.set(treeIndex, (this.tree.get(treeIndex) ?? 0) + change)
533
- const step = getLowestSetBit(treeIndex)
534
- if (step === 0) {
535
- break
536
- }
537
- treeIndex += step
521
+ treeIndex += getLowestSetBit(treeIndex)
538
522
  }
539
523
 
540
524
  // 合計値を更新 (totalが計算済みの場合のみ)
@@ -575,7 +559,8 @@ export class FenwickMapTree {
575
559
  }
576
560
 
577
561
  // 現在の delta はバッチ内で既に更新済みならその値を、なければ Map の値を使う
578
- const currentDelta = pendingDeltas.has(index) ? (pendingDeltas.get(index) ?? 0) : (this.deltas.get(index) ?? 0)
562
+ const pendingDelta = pendingDeltas.get(index)
563
+ const currentDelta = pendingDelta === undefined ? (this.deltas.get(index) ?? 0) : pendingDelta
579
564
  const oldValue = currentDelta + this.baseValue
580
565
  const change = value - oldValue
581
566
  if (change !== 0) {
@@ -603,11 +588,7 @@ export class FenwickMapTree {
603
588
  let treeIndex = this.size
604
589
  while (treeIndex > 0) {
605
590
  sum += this.tree.get(treeIndex) ?? 0
606
- const step = getLowestSetBit(treeIndex)
607
- if (step === 0) {
608
- break
609
- }
610
- treeIndex -= step
591
+ treeIndex -= getLowestSetBit(treeIndex)
611
592
  }
612
593
 
613
594
  return sum + this.baseValue * this.size
@@ -717,7 +698,7 @@ export class FenwickMapTree {
717
698
  return this._findIndexLarge(target, options, chooseLowerBound)
718
699
  }
719
700
  if (this.size === 0) {
720
- return { index: -1, total: this.total ?? 0, cumulative: undefined, currentValue: undefined, safeIndex: undefined }
701
+ return { index: -1, total: this.getTotal(), cumulative: undefined, currentValue: undefined, safeIndex: undefined }
721
702
  }
722
703
 
723
704
  // 探索前に対象範囲を具現化しておく。具現化後のツリー状態で降下を行うことで、
@@ -737,7 +718,7 @@ export class FenwickMapTree {
737
718
  resultIdx = this._descend(target, chooseLowerBound)
738
719
 
739
720
  if (resultIdx < 0 || resultIdx >= this.size) {
740
- return { index: -1, total: this.total ?? this.getTotal(), cumulative: undefined, currentValue: undefined, safeIndex: undefined }
721
+ return { index: -1, total: this.getTotal(), cumulative: undefined, currentValue: undefined, safeIndex: undefined }
741
722
  }
742
723
 
743
724
  if (!materialize) {
@@ -759,7 +740,7 @@ export class FenwickMapTree {
759
740
 
760
741
  return {
761
742
  index: resultIdx,
762
- total: this.total ?? result.total,
743
+ total: this.getTotal(),
763
744
  cumulative: result.cumulative,
764
745
  currentValue: result.currentValue,
765
746
  safeIndex: result.safeIndex,
@@ -812,14 +793,8 @@ export class FenwickMapTree {
812
793
  * 非常に大きなサイズに対して bigint 演算を用いた二分探索を実施。
813
794
  */
814
795
  private _findIndexLarge(target: number, options: MaterializeConfig, chooseLowerBound: boolean): { index: number; total: number | undefined; cumulative: number | undefined; currentValue: number | undefined; safeIndex: number | undefined } {
815
- if (this.size === 0) {
816
- return { index: -1, total: this.total ?? 0, cumulative: undefined, currentValue: undefined, safeIndex: undefined }
817
- }
818
-
819
- const sizeBig = toSafeBigInt(this.size)
820
- if (sizeBig === 0n) {
821
- return { index: -1, total: this.total ?? 0, cumulative: undefined, currentValue: undefined, safeIndex: undefined }
822
- }
796
+ // 呼び出し元 (_findIndex) は要素数 2^30 以上のときだけここへ来るので、要素数は正の整数
797
+ const sizeBig = BigInt(this.size)
823
798
 
824
799
  // 小サイズパス (_findIndex) と同一契約: 探索前に対象範囲を具現化し、探索中は木を
825
800
  // 変異させない (prefixSum に options を渡さない)。materialize:true の場合は
@@ -904,7 +879,7 @@ export class FenwickMapTree {
904
879
  // 空の木 (size=0) は正当な状態として中立値を返す (throw しない)。
905
880
  // minmax(index, 0, -1) が負値へ丸められ get(-1) が throw するのを回避する。
906
881
  if (this.size === 0) {
907
- return { cumulative: 0, total: this.total ?? 0, currentValue: 0, safeIndex: 0 }
882
+ return { cumulative: 0, total: this.getTotal(), currentValue: 0, safeIndex: 0 }
908
883
  }
909
884
  // 小数 index は具現化時に非整数キーのノードを作り整数走査と乖離するため切り捨てる
910
885
  // (Infinity は trunc を素通りした後 minmax で size-1 にクランプされる)。
@@ -918,11 +893,7 @@ export class FenwickMapTree {
918
893
  while (treeIndex > 0) {
919
894
  const treeNodeValue = this.tree.get(treeIndex) ?? 0
920
895
  sum += treeNodeValue
921
- const step = getLowestSetBit(treeIndex)
922
- if (step === 0) {
923
- break
924
- }
925
- treeIndex -= step
896
+ treeIndex -= getLowestSetBit(treeIndex)
926
897
  }
927
898
 
928
899
  const currentValue = materializeOption?.materialize ? this.get(safeIndex) : (this.deltas.get(safeIndex) || 0) + this.baseValue
@@ -972,10 +943,6 @@ export class FenwickMapTree {
972
943
  this.total = 0
973
944
  } else {
974
945
  this.total = this._computeTreeTotal()
975
- const lastPrefix = this.prefixSum(this.getSize() - 1)
976
- if (lastPrefix.cumulative !== lastPrefix.total) {
977
- Logger.error("Inconsistent Fenwick Tree state")
978
- }
979
946
  }
980
947
  }
981
948
 
@@ -987,11 +954,11 @@ export class FenwickMapTree {
987
954
  * @description Rebuilds the Fenwick Tree from the existing `baseValue` and `deltas`. This corrects any discrepancies in the tree's internal state, such as those caused by floating-point errors, by recalculating the tree structure and the total sum from the source `deltas`. This method does not re-materialize values from `valueFn`.
988
955
  * @description 既存の `baseValue` と `deltas` から Fenwick Tree を再構築します。これにより、`deltas` からツリー構造と合計値を再計算することで、浮動小数点誤差などによって生じた内部状態の不一致を修正します。このメソッドは `valueFn` から値を再具現化しません。
989
956
  * @param {object} [options] - Optional settings for rebuilding.
990
- * @param {boolean} [options.materialize=false] - If true and `valueFn` is provided, re-materializes all values, recalculating `deltas` and `baseValue`.
957
+ * @param {boolean} [options.materialize=false] - If true and `valueFn` is set, rebuilds from `valueFn` instead: a reset with materialization (`reset(size, valueFn, { materialize: true })`) that re-samples `baseValue`, materializes the initial window (rows 0 to `SAMPLE_COUNT - 1`) and discards every other measured delta. A tree with a uniform value (no `valueFn`) rebuilds from its deltas either way. / true かつ `valueFn` があれば、代わりに `valueFn` から作り直す。具現化付きのリセット (`reset(size, valueFn, { materialize: true })`) で、`baseValue` を標本で決め直し、初期の窓 (行 0〜`SAMPLE_COUNT - 1`) を具現化し、ほかの測った差分はすべて捨てる。一様な値の木 (`valueFn` 無し) はどちらでも差分から作り直す。
991
958
  */
992
959
  rebuildTree(options?: { materialize?: boolean }) {
993
960
  if (options?.materialize && this.valueFn) {
994
- // すべての値を具現化する
961
+ // 全行の具現化は巨大な木で O(n) の valueFn 呼び出しになるので、リセットと同じく初期の窓だけを具現化する
995
962
  const valueFn = this.valueFn
996
963
  this.reset(this.size, (i) => valueFn(i), { materialize: true })
997
964
  return
@@ -1001,25 +968,21 @@ export class FenwickMapTree {
1001
968
  // let newTotal = this.baseValue * this.size
1002
969
 
1003
970
  // 既存の deltas を使って新しいツリーを構築し、合計値も同時に再計算する
971
+ // 0 の delta は保持しない (zero-delta 枝刈り) ので、どの delta も木へ伝える
1004
972
  for (const [index, delta] of this.deltas.entries()) {
1005
- if (delta === 0) {
1006
- continue
1007
- }
1008
973
  // Fenwick Tree のアルゴリズムは 1 始まりで設計されるため、インデックスを 1 加算する
1009
974
  let treeIndex = index + 1
1010
975
  while (treeIndex <= this.size) {
1011
976
  newTree.set(treeIndex, (newTree.get(treeIndex) ?? 0) + delta)
1012
- const step = getLowestSetBit(treeIndex)
1013
- if (step === 0) {
1014
- break
1015
- }
1016
- treeIndex += step
977
+ treeIndex += getLowestSetBit(treeIndex)
1017
978
  }
1018
979
  }
1019
980
 
1020
981
  // 最後に状態をアトミックに更新
1021
982
  this.tree = newTree
1022
983
  this.total = this._computeTreeTotal()
984
+ // 値は変えないが、浮動小数点の誤差を直した接頭辞和は変わり得る
985
+ this.revisionCount += 1
1023
986
  }
1024
987
 
1025
988
  /**
@@ -1034,11 +997,6 @@ export class FenwickMapTree {
1034
997
  * @returns {number} The difference between the cached total and the theoretical total.
1035
998
  */
1036
999
  calculateAccumulatedError(): number {
1037
- if (this.total === undefined) {
1038
- // total がまだ計算されていない場合は、誤差は 0 とする
1039
- return 0
1040
- }
1041
-
1042
1000
  // 理論上の合計値を計算する。範囲外へ退避している delta は木に載っていないので数えない
1043
1001
  let theoreticalTotal = this.baseValue * this.size
1044
1002
  for (const [index, delta] of this.deltas) {
@@ -1048,7 +1006,7 @@ export class FenwickMapTree {
1048
1006
  }
1049
1007
 
1050
1008
  // キャッシュされている合計値との差を返す
1051
- return this.total - theoreticalTotal
1009
+ return this.getTotal() - theoreticalTotal
1052
1010
  }
1053
1011
 
1054
1012
  /**
@@ -1140,11 +1098,7 @@ export class FenwickMapTree {
1140
1098
  this.size = targetSize
1141
1099
  this.rebuildTree()
1142
1100
  }
1143
-
1144
- const lastPrefix = this.prefixSum(this.getSize() - 1)
1145
- if (lastPrefix.cumulative !== lastPrefix.total) {
1146
- Logger.error("Inconsistent Fenwick Tree state")
1147
- }
1101
+ this.revisionCount += 1
1148
1102
  }
1149
1103
 
1150
1104
  /**
@@ -1190,20 +1144,12 @@ export class FenwickMapTree {
1190
1144
  // 既存範囲 (<= oldSize) のノードは更新済みなので、
1191
1145
  // 親チェーンを辿って oldSize を超える最初のノードまで進める。
1192
1146
  while (treeIndex <= oldSize) {
1193
- const step = getLowestSetBit(treeIndex)
1194
- if (step === 0) {
1195
- break
1196
- }
1197
- treeIndex += step
1147
+ treeIndex += getLowestSetBit(treeIndex)
1198
1148
  }
1199
1149
  // 新たに有効化されたノード (oldSize, newSize] にのみ差分を伝播する。
1200
1150
  while (treeIndex <= newSize) {
1201
1151
  this.tree.set(treeIndex, (this.tree.get(treeIndex) ?? 0) + delta)
1202
- const step = getLowestSetBit(treeIndex)
1203
- if (step === 0) {
1204
- break
1205
- }
1206
- treeIndex += step
1152
+ treeIndex += getLowestSetBit(treeIndex)
1207
1153
  }
1208
1154
  }
1209
1155
 
@@ -1246,9 +1192,8 @@ export class FenwickMapTree {
1246
1192
 
1247
1193
  // total は増分 (+= baseValue * Δ) ではなく prefixSum と同一走査を再現する
1248
1194
  // _computeTreeTotal で確定する。増分加算は浮動小数点の分配則不成立により
1249
- // prefixSum の走査結果と厳密不一致になり、changeSize 末尾の整合検査が
1250
- // 偽の "Inconsistent Fenwick Tree state" を量産するため。O(log n) なので
1251
- // 増分更新の速度メリットは保たれる。
1195
+ // prefixSum(size - 1) の走査結果と厳密には一致せず、末尾の位置で total と
1196
+ // 最後の行の下端が食い違うため。O(log n) なので増分更新の速度メリットは保たれる。
1252
1197
  this.total = this._computeTreeTotal()
1253
1198
  }
1254
1199
 
@@ -1291,6 +1236,23 @@ export class FenwickMapTree {
1291
1236
  return this.baseValue
1292
1237
  }
1293
1238
 
1239
+ /**
1240
+ * @description Gets the tree's revision: a counter that rises whenever a value the tree stores or its size changes — an
1241
+ * update, a materialization that changes a row, a reset, a resize, a new uniform value or a rebuild — and stays the same
1242
+ * otherwise (reads, a materialization that finds the stored value, an update to the same value, a resize to the same size).
1243
+ * The tree keeps its identity while its contents change, so a caller that derives anything from the prefix sums (row tops,
1244
+ * a rendering range) compares two reads: equal reads mean nothing it derived became stale in between. The counter only
1245
+ * rises; how far it rises per change is unspecified.
1246
+ * @description 木の版を取得。木が保持する値か要素数が変わるたび (更新・行を変える具現化・リセット・要素数の変更・一様な値の
1247
+ * 差し替え・再構築) に上がり、それ以外 (読み取り・保持する値と同じ値を見つけた具現化・同じ値への更新・同じ要素数への変更) では
1248
+ * 変わらない数。木は中身が変わっても同一性を保つため、接頭辞和から何か (行の上端・描画範囲) を導く呼び出し側は 2 回の読み取りを
1249
+ * 比べ、等しければその間に導いたものは古くなっていない。数は上がるだけで、1 回の変化でいくつ上がるかは決めない。
1250
+ * @returns {number} The revision. 版。
1251
+ */
1252
+ get revision(): number {
1253
+ return this.revisionCount
1254
+ }
1255
+
1294
1256
  /**
1295
1257
  * @method findIndexAtOrAfter
1296
1258
  * @description Finds the first index where the cumulative sum is greater than or equal to a target value.
@@ -90,6 +90,16 @@ class DoublyLinkedList<K, V> {
90
90
  node.next = null
91
91
  }
92
92
 
93
+ /**
94
+ * @method peekHead
95
+ * @description Returns the head of the list (the least recently used item) without removing it.
96
+ * @description リストの先頭 (最も最近使用されていないアイテム) を外さずに返します。
97
+ * @returns {DoublyLinkedListNode<K, V> | null} The head node, or null if the list is empty.
98
+ */
99
+ peekHead(): DoublyLinkedListNode<K, V> | null {
100
+ return this.head
101
+ }
102
+
93
103
  /**
94
104
  * @method removeHead
95
105
  * @description Removes and returns the head of the list, which is the least recently used item.
@@ -176,14 +186,10 @@ export function useLruCache<K, V>(capacity: number) {
176
186
  if (!Number.isFinite(normalizedCapacity)) {
177
187
  return
178
188
  }
179
- while (cache.current.size > normalizedCapacity) {
180
- const lruNode = list.current.removeHead()
181
- if (lruNode) {
182
- cache.current.delete(lruNode.key)
183
- } else {
184
- // This should not happen if cache.current.size > 0
185
- break
186
- }
189
+ // 使用順の一覧と Map は同じ項目を持つので、Map が容量を超えている間は一覧の先頭が必ずある
190
+ for (let lruNode = list.current.peekHead(); lruNode !== null && cache.current.size > normalizedCapacity; lruNode = list.current.peekHead()) {
191
+ list.current.remove(lruNode)
192
+ cache.current.delete(lruNode.key)
187
193
  }
188
194
  }, [capacity, normalizedCapacity])
189
195
 
package/src/utils.ts CHANGED
@@ -56,3 +56,18 @@ export const getAxisScale = (element: HTMLElement, axis: "x" | "y"): number => {
56
56
  }
57
57
  return visualSize / layoutSize
58
58
  }
59
+
60
+ /**
61
+ * Pointer-down handler of pointer-only scroll chrome (the scrollbar and the scroll-to-edge pills of a host that scrolls by
62
+ * keyboard itself): cancels the press's default action, so the press never moves focus — neither onto a part of the chrome
63
+ * nor away from where the host put it — while the press itself, its pointer events and its click still reach the part.
64
+ *
65
+ * ポインタ専用のスクロールの部品 (キーボードのスクロールを自分で持つホストのスクロールバーと端へ戻るピル) の pointerdown の
66
+ * 処理。押下の既定動作を取り消すので、押下はフォーカスを動かさない — 部品の上へも、ホストが置いた場所の外へも — が、押下
67
+ * そのもの (ポインタのイベントと click) は部品へ届く。
68
+ *
69
+ * @param event - The pointer-down event (only its default action is read) / pointerdown のイベント (既定動作だけを扱う)
70
+ */
71
+ export const keepFocusOnPress = (event: { readonly preventDefault: () => void }): void => {
72
+ event.preventDefault()
73
+ }