@aiquants/virtualscroll 1.18.5 → 1.19.1
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.
- package/README.md +20 -13
- package/dist/ScrollPane.d.cts +2 -0
- package/dist/ScrollPane.d.ts +2 -0
- package/dist/ScrollPane.d.ts.map +1 -1
- package/dist/VirtualScroll.d.cts +2 -0
- package/dist/VirtualScroll.d.ts +2 -0
- package/dist/VirtualScroll.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1115 -1112
- package/dist/styles/virtualscroll.css +1 -1
- package/dist/styles/virtualscroll.standalone.css +3 -0
- package/package.json +6 -4
- package/src/ScrollBar.spec.tsx +620 -0
- package/src/ScrollBar.tsx +1397 -0
- package/src/ScrollPane.spec.tsx +482 -0
- package/src/ScrollPane.tsx +913 -0
- package/src/TapScrollCircle.spec.tsx +275 -0
- package/src/TapScrollCircle.tsx +363 -0
- package/src/VirtualScroll.spec.ts +623 -0
- package/src/VirtualScroll.tsx +1891 -0
- package/src/cli.server.spec.ts +137 -0
- package/src/cli.server.ts +110 -0
- package/src/index.ts +23 -0
- package/src/logger.spec.ts +128 -0
- package/src/logger.ts +229 -0
- package/src/styles/components.entry.css +9 -0
- package/src/styles/standalone.entry.css +11 -0
- package/src/styles/virtualscroll.css +296 -0
- package/src/tapScrollCircleSampleVisual.tsx +74 -0
- package/src/useFenwickMapTree.huge.spec.ts +388 -0
- package/src/useFenwickMapTree.spec.ts +1518 -0
- package/src/useFenwickMapTree.ts +1368 -0
- package/src/useHeightCache.ts +32 -0
- package/src/useLruCache.spec.ts +382 -0
- package/src/useLruCache.ts +301 -0
- package/src/utils.spec.ts +39 -0
- package/src/utils.ts +16 -0
|
@@ -0,0 +1,1368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Fenwick Tree (Binary Indexed Tree) implementation and React hook.
|
|
3
|
+
* @file Fenwick Tree (Binary Indexed Tree) の実装と React フック。
|
|
4
|
+
*
|
|
5
|
+
* @module useFenwickMapTree
|
|
6
|
+
* @description This module provides a FenwickTree (Binary Indexed Tree) data structure
|
|
7
|
+
* and a React hook `useFenwickMapTree` to manage it.
|
|
8
|
+
* This is optimized for virtual scrolling scenarios with dynamically sized items.
|
|
9
|
+
*
|
|
10
|
+
* @description このモジュールは、FenwickTree (バイナリインデックスツリー) データ構造と、
|
|
11
|
+
* それを管理するための React フック `useFenwickMapTree` の提供。
|
|
12
|
+
* 動的なアイテムサイズを持つ仮想スクロールのシナリオに最適化されている。
|
|
13
|
+
*/
|
|
14
|
+
import { useRef } from "react"
|
|
15
|
+
import { Logger } from "./logger.ts"
|
|
16
|
+
import { minmax } from "./utils.ts"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Maximum number of points evaluated when estimating `baseValue`, and the small/large-list
|
|
20
|
+
* threshold. Lists with more than this many items switch from head/sample-range sampling to a
|
|
21
|
+
* whole-list stride sample so a non-representative head cannot skew the total-height estimate.
|
|
22
|
+
*
|
|
23
|
+
* `baseValue` 推定で評価する最大点数であり、小/大リストの分岐しきい値でもある。これを超える
|
|
24
|
+
* リストは先頭/サンプル範囲サンプリングからリスト全体のストライドサンプリングへ切り替わり、
|
|
25
|
+
* 非代表的な先頭が総高さ推定を狂わせないようにする。
|
|
26
|
+
*/
|
|
27
|
+
const SAMPLE_COUNT = 100
|
|
28
|
+
|
|
29
|
+
type MaterializeRange = { from: number; to: number }
|
|
30
|
+
type MaterializeOption = { materialize: boolean; ranges?: MaterializeRange[] }
|
|
31
|
+
type MaterializeConfig = { materializeOption?: MaterializeOption }
|
|
32
|
+
type DeltaUpdate = { index: number; change: number }
|
|
33
|
+
type ValueUpdate = { index: number; value: number }
|
|
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
|
+
/**
|
|
52
|
+
* Validates that `valueFn` returned a finite number, throwing otherwise. A single NaN/Infinity
|
|
53
|
+
* propagated into the tree poisons `tree`/`total` irrecoverably, so the materialization and
|
|
54
|
+
* sampling paths reject non-finite values up front, symmetric with the update-path validation.
|
|
55
|
+
*
|
|
56
|
+
* `valueFn` の返値が有限数であることの検証 (非有限なら throw)。NaN/Infinity は一度木へ伝播すると
|
|
57
|
+
* `tree`/`total` を復旧不能に汚染するため、update 系の検証と対称に具現化/サンプリング経路でも
|
|
58
|
+
* 前段で拒否する方針。
|
|
59
|
+
*/
|
|
60
|
+
const requireFiniteValue = (value: number, index: number): number => {
|
|
61
|
+
if (!Number.isFinite(value)) {
|
|
62
|
+
throw new Error(`valueFn returned a non-finite value at index ${index}: ${value}`)
|
|
63
|
+
}
|
|
64
|
+
return value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tolerance used when rejecting delta changes that would make an effective value negative.
|
|
69
|
+
* Guards against false positives caused by floating-point dust (e.g. a fractional `baseValue`
|
|
70
|
+
* combined with a change targeting an exact-zero effective value).
|
|
71
|
+
*
|
|
72
|
+
* 実効値が負になる delta 変更を拒否する際の許容誤差。小数 `baseValue` と実効値 0 を狙う変更の
|
|
73
|
+
* 組合せ等で生じる浮動小数点の丸め屑による誤検出の防止。
|
|
74
|
+
*/
|
|
75
|
+
const NEGATIVE_EFFECTIVE_VALUE_EPSILON = 1e-9
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Derives the lowest set bit without relying on 32-bit bitwise operators.
|
|
79
|
+
*
|
|
80
|
+
* 32 ビット演算に依存せずに最下位ビットを算出。
|
|
81
|
+
*/
|
|
82
|
+
const getLowestSetBit = (value: number): number => {
|
|
83
|
+
if (value <= 0 || !Number.isFinite(value)) {
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
86
|
+
const integer = Math.trunc(value)
|
|
87
|
+
// 32 ビット符号付き整数の範囲内なら通常のビット演算で最下位ビットを求める (BigInt 割り当てを回避)。
|
|
88
|
+
// ホットパス (prefixSum / _updateTree など) の GC 圧を大幅に削減する。
|
|
89
|
+
if (integer <= 0x7fffffff) {
|
|
90
|
+
return integer & -integer
|
|
91
|
+
}
|
|
92
|
+
const lowestBit = BigInt(integer) & -BigInt(integer)
|
|
93
|
+
return Number(lowestBit)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @class FenwickTree
|
|
98
|
+
* @classdesc Implements a Fenwick Tree (or Binary Indexed Tree).
|
|
99
|
+
* This data structure efficiently calculates prefix sums and performs updates in logarithmic time.
|
|
100
|
+
* It is particularly useful for virtual scrolling, where it can manage the offsets of variably sized items.
|
|
101
|
+
*
|
|
102
|
+
* @classdesc Fenwick Tree (バイナリインデックスツリー) の実装。
|
|
103
|
+
* このデータ構造は、接頭辞和の計算と更新を対数時間で効率的に実行。
|
|
104
|
+
* 可変サイズのアイテムのオフセットを管理できるため、特に仮想スクロールで有用。
|
|
105
|
+
*
|
|
106
|
+
* @remarks Memory: exact offsets are the design priority, so every materialized/updated row keeps
|
|
107
|
+
* an entry in `tree` and `deltas`. Memory therefore grows with the number of materialized elements,
|
|
108
|
+
* not with `size`. Rows equal to `baseValue` are pruned (zero-delta), but there is no eviction of
|
|
109
|
+
* measured deltas — evicting one would silently snap that row back to the estimated `baseValue` and
|
|
110
|
+
* corrupt `total`, which is incompatible with the exact-offset guarantee. If a hard memory ceiling
|
|
111
|
+
* is required, discard off-screen measured heights in the consumer layer (e.g. an LRU height cache)
|
|
112
|
+
* and let those rows fall back to the estimate there, rather than inside this tree.
|
|
113
|
+
* @remarks メモリ: 厳密オフセットを設計上の最優先とするため、具現化/更新された行はすべて `tree` と
|
|
114
|
+
* `deltas` にエントリを保持する。よってメモリは `size` ではなく具現化済み要素数に比例して増える。
|
|
115
|
+
* `baseValue` と一致する行は zero-delta 枝刈りされるが、計測済み delta の evict は行わない。evict すると
|
|
116
|
+
* その行が黙って推定値 `baseValue` へ戻り `total` が破損し、厳密オフセット保証と両立しないためである。
|
|
117
|
+
* ハードなメモリ上限が必要な場合は、この木の内部ではなく消費側 (LRU 高さキャッシュ等) で可視域外の
|
|
118
|
+
* 実測値を破棄し、そこで既定推定へ委ねる設計とすること。
|
|
119
|
+
*/
|
|
120
|
+
export class FenwickMapTree {
|
|
121
|
+
/**
|
|
122
|
+
* @private
|
|
123
|
+
* @property {Map<number, number>} tree - The Map storing the Fenwick tree structure, specifically the sums of deltas. It is 1-indexed.
|
|
124
|
+
* @property {Map<number, number>} tree - Fenwick Tree 構造を格納する Map。特に差分の合計を保持する。1-indexed。
|
|
125
|
+
*/
|
|
126
|
+
private tree!: Map<number, number>
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @private
|
|
130
|
+
* @property {Map<number, number>} deltas - The Map storing the differences (deltas) from the base value at each index.
|
|
131
|
+
* @property {Map<number, number>} deltas - 各インデックスにおける基準値との差分 (delta) を格納する Map。
|
|
132
|
+
*/
|
|
133
|
+
private deltas!: Map<number, number>
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @private
|
|
137
|
+
* @property {number} size - The number of elements the tree manages.
|
|
138
|
+
* @property {number} size - ツリーが管理する要素数。
|
|
139
|
+
*/
|
|
140
|
+
private size!: number
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @private
|
|
144
|
+
* @property {number} baseValue - The uniform base value for all elements, used to optimize memory.
|
|
145
|
+
* @property {number} baseValue - 全要素の均一な基準値。メモリ最適化のために使用。
|
|
146
|
+
*/
|
|
147
|
+
private baseValue!: number
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @private
|
|
151
|
+
* @property {((index: number) => number) | undefined} valueFn - A function to generate values, stored for lazy initialization.
|
|
152
|
+
* @property {((index: number) => number) | undefined} valueFn - 値を生成するための関数。遅延初期化のために保存される。
|
|
153
|
+
*/
|
|
154
|
+
private valueFn?: (index: number) => number
|
|
155
|
+
private total?: number
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @constructor
|
|
159
|
+
* @description Initializes the Fenwick Tree.
|
|
160
|
+
* @description Fenwick Tree の初期化。
|
|
161
|
+
* @param {number} size - The total number of items.
|
|
162
|
+
* @param {number | ((index: number) => number)} valueOrFn - The value for all elements, or a function to generate values.
|
|
163
|
+
* @param {{ sampleRange?: { from: number; to: number }, materialize?: boolean }} [options] - Optional settings for initialization.
|
|
164
|
+
*/
|
|
165
|
+
constructor(size: number, valueOrFn: number | ((index: number) => number), options?: { sampleRange?: { from: number; to: number }; materialize?: boolean }) {
|
|
166
|
+
this.reset(size, valueOrFn, options)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @method reset
|
|
171
|
+
* @description Resets the Fenwick Tree with a new size and initial values. The size is
|
|
172
|
+
* normalized on entry (non-finite/negative sizes collapse to 0, fractions are truncated).
|
|
173
|
+
* When `valueOrFn` is a function, all sampling/materialization values are computed and
|
|
174
|
+
* validated (finite-only) before the tree state is mutated, so a `valueFn` returning
|
|
175
|
+
* NaN/Infinity throws while leaving the previous tree state intact instead of a half-built one.
|
|
176
|
+
* @description Fenwick Tree を新しいサイズと初期値でリセット。size は入口で正規化する
|
|
177
|
+
* (非有限/負は 0 に縮退、小数は切り捨て)。`valueOrFn` が関数の場合、サンプリング/具現化の値は
|
|
178
|
+
* 木の状態を変異させる前に計算・検証 (有限数のみ) するため、NaN/Infinity を返す `valueFn` は
|
|
179
|
+
* throw しつつ木を半構築で残さず直前の状態を無傷に保つ方針。
|
|
180
|
+
* @param {number} size - The total number of items.
|
|
181
|
+
* @param {number | ((index: number) => number)} valueOrFn - The value for all elements, or a function to generate values.
|
|
182
|
+
* @param {{ sampleRange?: { from: number; to: number }, materialize?: boolean }} [options] - Optional settings for initialization.
|
|
183
|
+
*/
|
|
184
|
+
reset(size: number, valueOrFn: number | ((index: number) => number), options?: { sampleRange?: { from: number; to: number }; materialize?: boolean }) {
|
|
185
|
+
// size を正規化する: 非有限 (NaN/Infinity)・非正は 0、小数は切り捨て。
|
|
186
|
+
// NaN size は `this.size > 0` 系の判定をすり抜けて total を NaN 汚染するため入口で遮断する。
|
|
187
|
+
const safeSize = Number.isFinite(size) && size > 0 ? Math.trunc(size) : 0
|
|
188
|
+
|
|
189
|
+
if (typeof valueOrFn !== "function") {
|
|
190
|
+
this.size = safeSize
|
|
191
|
+
this.tree = new Map()
|
|
192
|
+
this.deltas = new Map()
|
|
193
|
+
this.valueFn = undefined
|
|
194
|
+
this.baseValue = valueOrFn
|
|
195
|
+
this.total = this.baseValue * this.size
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- 計算フェーズ (this を変異させない) ---
|
|
200
|
+
// valueFn が非有限値を返して throw しても、木は呼び出し前の状態のまま無傷に保つ
|
|
201
|
+
// (半構築状態を残さない)。検証済みの値だけを後段のコミットフェーズで木へ流し込む。
|
|
202
|
+
let baseValue = 0
|
|
203
|
+
let seed: { from: number; values: number[] } | undefined
|
|
204
|
+
if (safeSize > 0) {
|
|
205
|
+
// 初期可視ウィンドウ (= 具現化対象) の範囲を決定する。
|
|
206
|
+
// baseValue の推定範囲とは分離する: 具現化は利用者が最初に見る領域だけ正確化すればよいが、
|
|
207
|
+
// baseValue は未具現化の全アイテムの総高さ推定に効くため、リスト全体を代表する値が必要。
|
|
208
|
+
const range = options?.sampleRange ?? {
|
|
209
|
+
from: 0,
|
|
210
|
+
to: Math.min(SAMPLE_COUNT - 1, safeSize - 1),
|
|
211
|
+
}
|
|
212
|
+
// sampleRange は 0..size-1 にクランプ + 整数化してから使用する。負 index で valueFn を
|
|
213
|
+
// 呼ぶと undefined/NaN が返り baseValue が汚染され、小数はループ変数が非整数キーの
|
|
214
|
+
// ノードを生んで木を破損させるため。
|
|
215
|
+
let from = Math.max(0, Math.trunc(range.from))
|
|
216
|
+
let to = Math.min(Math.trunc(range.to), safeSize - 1)
|
|
217
|
+
// from > to の逆転指定はサンプルが空になり baseValue=0 / total=0 へ静かに縮退するため、
|
|
218
|
+
// 既定の先頭ウィンドウへフォールバックする。
|
|
219
|
+
if (from > to) {
|
|
220
|
+
from = 0
|
|
221
|
+
to = Math.min(SAMPLE_COUNT - 1, safeSize - 1)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (safeSize > SAMPLE_COUNT) {
|
|
225
|
+
// 大きいリストでは baseValue をリスト全体にわたる分散 (ストライド) サンプリングで決める。
|
|
226
|
+
// 先頭固定サンプリングだと、先頭が非代表的な高さ (ヒーロー行/見出しなど) のとき
|
|
227
|
+
// 多数派の実際の高さではなくその外れ値が baseValue に選ばれ、未具現化アイテムの
|
|
228
|
+
// 総高さ・スクロールバー比率・scrollToIndex クランプが大きく狂う。
|
|
229
|
+
// For large lists, derive baseValue from a stride sample spanning the whole list so
|
|
230
|
+
// a non-representative head (hero/heading rows) cannot skew the majority estimate.
|
|
231
|
+
baseValue = FenwickMapTree._sampleBaseValueStrided(valueOrFn, safeSize, SAMPLE_COUNT)
|
|
232
|
+
|
|
233
|
+
// 具現化は従来どおり初期可視ウィンドウ (sampleRange) のみを対象にする (正確性を維持)。
|
|
234
|
+
if (options?.materialize) {
|
|
235
|
+
seed = { from, values: FenwickMapTree._collectValidatedValues(valueOrFn, from, to) }
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
// 小さいリスト (size <= SAMPLE_COUNT) は分散サンプリングの利点が無いため、
|
|
239
|
+
// 従来どおり sampleRange の最頻値/中央値を baseValue とし、
|
|
240
|
+
// 生成済みの値を再利用して具現化する (valueFn の二重評価を回避)。
|
|
241
|
+
// For small lists, keep the original sample-range mode/median and value reuse.
|
|
242
|
+
const values = FenwickMapTree._collectValidatedValues(valueOrFn, from, to)
|
|
243
|
+
// _modeOrMedian はソートで配列を破壊するため、具現化用の並びはコピーで保護する
|
|
244
|
+
baseValue = FenwickMapTree._modeOrMedian([...values])
|
|
245
|
+
if (options?.materialize) {
|
|
246
|
+
seed = { from, values }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- コミットフェーズ (検証済みの値のみで木を構築する) ---
|
|
252
|
+
this.size = safeSize
|
|
253
|
+
this.tree = new Map()
|
|
254
|
+
this.deltas = new Map()
|
|
255
|
+
this.total = undefined
|
|
256
|
+
this.valueFn = valueOrFn
|
|
257
|
+
this.baseValue = baseValue
|
|
258
|
+
if (seed) {
|
|
259
|
+
for (let i = 0; i < seed.values.length; i++) {
|
|
260
|
+
const index = seed.from + i
|
|
261
|
+
if (index >= this.size) {
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
// _materialize を呼ぶ代わりに、計算フェーズの検証済みの値を使って更新する (二重評価を回避)
|
|
265
|
+
const change = seed.values[i] - this.baseValue
|
|
266
|
+
// baseValue と一致する行は delta を持たせない (Map の無駄な肥大を回避)
|
|
267
|
+
if (change !== 0) {
|
|
268
|
+
this.deltas.set(index, change)
|
|
269
|
+
this._updateTree(index, change)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// 具現化が完了した後に total を計算する
|
|
274
|
+
this.total = this.getTotal()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @method setValueFn
|
|
279
|
+
* @description Updates the value function and re-initializes the tree.
|
|
280
|
+
* @description 値関数を更新し、ツリーを再初期化する。
|
|
281
|
+
* @param {number | ((index: number) => number)} valueOrFn - The new value for all elements, or a function to generate values.
|
|
282
|
+
* @param {{ reset?: boolean }} [options] - Optional settings. If reset is true, the tree is reset with the new value function.
|
|
283
|
+
*/
|
|
284
|
+
setValueFn(valueOrFn: number | ((index: number) => number), options?: { reset?: boolean }) {
|
|
285
|
+
if (options?.reset) {
|
|
286
|
+
this.reset(this.size, valueOrFn)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (typeof valueOrFn === "function") {
|
|
291
|
+
this.valueFn = valueOrFn
|
|
292
|
+
} else {
|
|
293
|
+
// If a number is provided, it's treated as a new baseValue,
|
|
294
|
+
// and valueFn is cleared. This case effectively turns the tree
|
|
295
|
+
// into a uniform-value tree, but existing deltas are preserved.
|
|
296
|
+
this.valueFn = undefined
|
|
297
|
+
this.baseValue = valueOrFn
|
|
298
|
+
// baseValue を差し替えたら total を再計算しないと prefixSum と乖離する。
|
|
299
|
+
// (deltas は温存されるため sum(deltas) + baseValue * size に一致させる)
|
|
300
|
+
this.total = this._computeTreeTotal()
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @private
|
|
306
|
+
* @method _collectValidatedValues
|
|
307
|
+
* @description Evaluates `valueFn` over the inclusive `[from, to]` range and returns the values, throwing on any non-finite result. Validation happens here, before any tree mutation, so a poisoned `valueFn` can never leave the tree half-built.
|
|
308
|
+
* @description `valueFn` を閉区間 `[from, to]` で評価して値配列を返す。非有限値は throw で拒否。検証を木の変異前 (このメソッド内) で行うことで、汚染された `valueFn` が木を半構築のまま残す事態の防止。
|
|
309
|
+
* @param {(index: number) => number} valueFn - The value-generating function to evaluate.
|
|
310
|
+
* @param {number} from - The starting index of the range (inclusive).
|
|
311
|
+
* @param {number} to - The ending index of the range (inclusive).
|
|
312
|
+
* @returns {number[]} The validated (finite-only) values in index order.
|
|
313
|
+
*/
|
|
314
|
+
private static _collectValidatedValues(valueFn: (index: number) => number, from: number, to: number): number[] {
|
|
315
|
+
const values: number[] = []
|
|
316
|
+
for (let i = from; i <= to; i++) {
|
|
317
|
+
values.push(requireFiniteValue(valueFn(i), i))
|
|
318
|
+
}
|
|
319
|
+
return values
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @private
|
|
324
|
+
* @method _modeOrMedian
|
|
325
|
+
* @description Reduces a sample of generated values to a single representative height: the mode when it is frequent enough to be trustworthy, otherwise the median. A value is only accepted as the mode when its frequency exceeds 20% of the sample; this prevents periodic or near-uniform data (where sampling bias can make an arbitrary value momentarily frequent) from picking an unrepresentative base. Multiple co-modes are averaged.
|
|
326
|
+
* @description サンプル値を 1 つの代表的な高さへ縮約する。信頼できるほど頻出する場合は最頻値を、そうでなければ中央値を返す。最頻値はサンプルの 20% を超える頻度のときのみ採用し、周期的/ほぼ一様なデータ (サンプリング偏りで任意の値が一時的に頻出しうる) が非代表値を選ぶのを防ぐ。同率最頻値が複数ある場合は平均する。
|
|
327
|
+
* @param {number[]} values - The sampled values (mutated in place by sorting).
|
|
328
|
+
* @returns {number} The representative base value.
|
|
329
|
+
*/
|
|
330
|
+
private static _modeOrMedian(values: number[]): number {
|
|
331
|
+
if (values.length === 0) {
|
|
332
|
+
return 0
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 中央値を計算してデフォルトの最頻値として設定
|
|
336
|
+
values.sort((a, b) => a - b)
|
|
337
|
+
const mid = Math.floor(values.length / 2)
|
|
338
|
+
let mode: number
|
|
339
|
+
if (values.length % 2 === 0) {
|
|
340
|
+
// 偶数個の場合は中央2つの値の平均
|
|
341
|
+
mode = Math.floor((values[mid - 1] + values[mid]) / 2)
|
|
342
|
+
} else {
|
|
343
|
+
// 奇数個の場合は中央の値
|
|
344
|
+
mode = values[mid]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const frequencies = new Map<number, number>()
|
|
348
|
+
let maxFreq = 0
|
|
349
|
+
|
|
350
|
+
for (const value of values) {
|
|
351
|
+
const count = (frequencies.get(value) ?? 0) + 1
|
|
352
|
+
frequencies.set(value, count)
|
|
353
|
+
if (count > maxFreq) {
|
|
354
|
+
maxFreq = count
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 最頻値の出現頻度がサンプルサイズの 20% を超える場合のみ最頻値を採用する
|
|
359
|
+
// これにより、周期的なデータや一様分布に近いデータで、サンプリングの偏りによって
|
|
360
|
+
// 偶然頻度が高くなった値が採用されるのを防ぐ
|
|
361
|
+
if (maxFreq > values.length * 0.2) {
|
|
362
|
+
const modes: number[] = []
|
|
363
|
+
for (const [value, count] of frequencies.entries()) {
|
|
364
|
+
if (count === maxFreq) {
|
|
365
|
+
modes.push(value)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const sum = modes.reduce((a, b) => a + b, 0)
|
|
369
|
+
mode = Math.floor(sum / modes.length)
|
|
370
|
+
}
|
|
371
|
+
return mode
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* @private
|
|
376
|
+
* @method _sampleBaseValueStrided
|
|
377
|
+
* @description Estimates `baseValue` by evaluating `valueFn` at up to `sampleCount` points spread across the entire `[0, size-1]` range, then reducing them via `_modeOrMedian`. Spanning the whole list (rather than a fixed head window) makes the majority height win even when the first items are atypical (hero/heading rows). The stride uses `ceil(size / sampleCount)` so the samples always span the whole range — `floor` would collapse to stride 1 for `sampleCount < size < 2 * sampleCount` and only ever look at the head. The stride is also forced odd to avoid aliasing against common even-period layouts (e.g. alternating or 20-row cycles), which a stride that shares a factor with the period would otherwise collapse into a single residue. For a uniform list every sample is identical, so the estimate is unchanged. Non-finite sample values throw before any state is touched.
|
|
378
|
+
* @description `valueFn` を `[0, size-1]` 全域に散らした最大 `sampleCount` 点で評価し、`_modeOrMedian` で縮約して `baseValue` を推定する。先頭固定ウィンドウではなくリスト全体を跨ぐことで、先頭が非典型 (ヒーロー/見出し行) でも多数派の高さが選ばれる。ストライドは `ceil(size / sampleCount)` を用いて常に全域を跨がせる (`floor` だと `sampleCount < size < 2 * sampleCount` で stride=1 に潰れ先頭しか見ない盲点が生じる)。さらに奇数へ強制し、周期と公約数を持つストライドがサンプルを 1 剰余類へ潰してしまう周期レイアウト (交互・20行周期など) とのエイリアシングを避ける。一様リストでは全サンプルが同一なので推定値は不変。非有限のサンプル値は状態を変異させる前に throw で拒否。
|
|
379
|
+
* @param {(index: number) => number} valueFn - The value-generating function to sample.
|
|
380
|
+
* @param {number} size - The total number of items the tree will manage.
|
|
381
|
+
* @param {number} sampleCount - Maximum number of points to evaluate.
|
|
382
|
+
* @returns {number} The estimated base value.
|
|
383
|
+
*/
|
|
384
|
+
private static _sampleBaseValueStrided(valueFn: (index: number) => number, size: number, sampleCount: number): number {
|
|
385
|
+
if (size <= 0) {
|
|
386
|
+
return 0
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// stride = max(1, ceil(size / sampleCount)) を基準に、偶数なら +1 して奇数化する。
|
|
390
|
+
// ceil によりサンプル数を多少犠牲にしても必ずリスト全域を跨ぐ (_modeOrMedian の
|
|
391
|
+
// 20% しきい値は割合ベースのためサンプル数減の影響は受けない)。
|
|
392
|
+
// (bigint を使わず大サイズでも安全に奇数化するため | 1 ではなく剰余で判定する)
|
|
393
|
+
let stride = Math.max(1, Math.ceil(size / sampleCount))
|
|
394
|
+
if (stride % 2 === 0) {
|
|
395
|
+
stride += 1
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const values: number[] = []
|
|
399
|
+
for (let i = 0; i < size && values.length < sampleCount; i += stride) {
|
|
400
|
+
values.push(requireFiniteValue(valueFn(i), i))
|
|
401
|
+
}
|
|
402
|
+
return FenwickMapTree._modeOrMedian(values)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* @method update
|
|
407
|
+
* @description Updates the value at a given index.
|
|
408
|
+
* @description 指定されたインデックスの値を更新。
|
|
409
|
+
* @param {number} index - The 0-based index to update.
|
|
410
|
+
* @param {number} value - The new value.
|
|
411
|
+
*/
|
|
412
|
+
update(index: number, value: number): number | undefined {
|
|
413
|
+
return this.updates([{ index, value }])
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* @method updates
|
|
418
|
+
* @description Updates the values at given indices.
|
|
419
|
+
* @description 指定されたインデックスの値を更新。
|
|
420
|
+
* @param {ValueUpdate[]} updates - An array of updates, each with an index and the new value.
|
|
421
|
+
*/
|
|
422
|
+
updates(updates: ValueUpdate[]): number | undefined {
|
|
423
|
+
const deltaUpdates = this._buildDeltaUpdates(updates)
|
|
424
|
+
if (deltaUpdates.length > 0) {
|
|
425
|
+
return this.updateDeltas(deltaUpdates)
|
|
426
|
+
}
|
|
427
|
+
return this.total
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* @method updateDelta
|
|
432
|
+
* @description Updates the delta at a given index and propagates the change through the tree. A change that would make the effective value (`baseValue` + delta) negative is rejected with an error, mirroring the negative-value rejection of `update`/`updates`.
|
|
433
|
+
* @description 指定されたインデックスのデルタを更新し、変更をツリーに伝播させる。適用後の実効値 (`baseValue` + delta) が負になる変更は `update`/`updates` の負値拒否と対称に throw で拒否する方針。
|
|
434
|
+
* @param {number} index - The 0-based index to update.
|
|
435
|
+
* @param {number} change - The value to add to the delta at the given index.
|
|
436
|
+
*/
|
|
437
|
+
updateDelta(index: number, change: number): number | undefined {
|
|
438
|
+
return this.updateDeltas([{ index, change }])
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* @method updateDeltas
|
|
443
|
+
* @description Updates the deltas at given indices and propagates the changes through the tree. All entries are validated up front (atomicity): out-of-range or non-integer indices, non-finite changes, and changes whose resulting effective value (`baseValue` + delta) would turn negative are rejected with an error before anything is applied. Negative effective values would break the monotonic prefix sums that `_descend`/`_findIndexLarge` rely on.
|
|
444
|
+
* @description 指定されたインデックスのデルタを更新し、変更をツリーに伝播させる。全件を先に検証してから適用する (アトミック性): 範囲外/非整数の index、非有限の change、適用後の実効値 (`baseValue` + delta) が負になる変更はいずれも適用前に throw で拒否。負の実効値は `_descend`/`_findIndexLarge` が前提とする累積和の単調性を破壊するため。
|
|
445
|
+
* @param {DeltaUpdate[]} updates - An array of updates, each with an index and the change to apply.
|
|
446
|
+
*/
|
|
447
|
+
updateDeltas(updates: DeltaUpdate[]): number | undefined {
|
|
448
|
+
// 先に全件を検証してから適用する (アトミック性の確保)。
|
|
449
|
+
// 途中に範囲外 index があっても先行分だけ適用された不整合状態にしない。
|
|
450
|
+
// 同一 index がバッチ内に複数含まれても累積後の実効値で判定できるよう、
|
|
451
|
+
// バッチ内の確定済み delta を index ごとに追跡する。
|
|
452
|
+
const pendingDeltas = new Map<number, number>()
|
|
453
|
+
for (const { index, change } of updates) {
|
|
454
|
+
if (index < 0 || index >= this.size) {
|
|
455
|
+
throw new Error(`Index ${index} out of bounds`)
|
|
456
|
+
}
|
|
457
|
+
// 小数/NaN の index は整数走査が読まない不正キーのノードを作り
|
|
458
|
+
// cumulative と total を静かに乖離させるため拒否する (NaN は上の範囲比較を素通りする)。
|
|
459
|
+
if (!Number.isInteger(index)) {
|
|
460
|
+
throw new Error(`Index must be an integer: ${index}`)
|
|
461
|
+
}
|
|
462
|
+
// NaN/Infinity は一度伝播すると tree/total 全体が復旧不能に汚染されるため、
|
|
463
|
+
// 適用前の検証段で拒否する (負値 throw と同じ扱い)。
|
|
464
|
+
if (!Number.isFinite(change)) {
|
|
465
|
+
throw new Error(`Change must be a finite number: ${change}`)
|
|
466
|
+
}
|
|
467
|
+
// 適用後の実効値 (baseValue + delta) が負になる変更は累積和の単調性を破り、
|
|
468
|
+
// _descend / _findIndexLarge の探索契約を壊すため拒否する
|
|
469
|
+
// (updates 経路の value < 0 拒否と対称。丸め屑の誤検出は許容誤差で回避)。
|
|
470
|
+
const currentDelta = pendingDeltas.get(index) ?? this.deltas.get(index) ?? 0
|
|
471
|
+
const nextDelta = currentDelta + change
|
|
472
|
+
if (nextDelta + this.baseValue < -NEGATIVE_EFFECTIVE_VALUE_EPSILON) {
|
|
473
|
+
throw new Error(`Change would make the value at index ${index} negative: ${nextDelta + this.baseValue}`)
|
|
474
|
+
}
|
|
475
|
+
pendingDeltas.set(index, nextDelta)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const { index, change } of updates) {
|
|
479
|
+
// deltas Map を更新
|
|
480
|
+
const currentDelta = this.deltas.get(index) ?? 0
|
|
481
|
+
const nextDelta = currentDelta + change
|
|
482
|
+
// 差分が 0 に戻る場合はエントリを保持しない (Map の無制限成長を抑制)。
|
|
483
|
+
// value(index) は baseValue と一致し、has(index)=false でも意味は変わらない。
|
|
484
|
+
if (nextDelta === 0) {
|
|
485
|
+
this.deltas.delete(index)
|
|
486
|
+
} else {
|
|
487
|
+
this.deltas.set(index, nextDelta)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ツリーを更新
|
|
491
|
+
this._updateTree(index, change)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return this.total
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @private
|
|
499
|
+
* @method _updateTree
|
|
500
|
+
* @description Updates the Fenwick tree and the total sum with a given change.
|
|
501
|
+
* @description Fenwick Tree と合計値を指定された変更で更新する。
|
|
502
|
+
* @param {number} index - The 0-based index that changed.
|
|
503
|
+
* @param {number} change - The change in value.
|
|
504
|
+
*/
|
|
505
|
+
private _updateTree(index: number, change: number) {
|
|
506
|
+
if (change === 0) {
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
// tree Map を更新
|
|
510
|
+
let treeIndex = index + 1 // Fenwick Tree のアルゴリズムは 1 始まりで設計される
|
|
511
|
+
while (treeIndex <= this.size) {
|
|
512
|
+
this.tree.set(treeIndex, (this.tree.get(treeIndex) ?? 0) + change)
|
|
513
|
+
const step = getLowestSetBit(treeIndex)
|
|
514
|
+
if (step === 0) {
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
treeIndex += step
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 合計値を更新 (totalが計算済みの場合のみ)
|
|
521
|
+
if (this.total !== undefined) {
|
|
522
|
+
this.total += change
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* @private
|
|
528
|
+
* @method _buildDeltaUpdates
|
|
529
|
+
* @description Converts value updates into delta updates while preserving validation logic.
|
|
530
|
+
* @description 値更新入力を検証しつつデルタ更新へ変換する。
|
|
531
|
+
* @param {ValueUpdate[]} updates - Requested value updates.
|
|
532
|
+
* @returns {DeltaUpdate[]} Sanitized delta updates ready to apply.
|
|
533
|
+
*/
|
|
534
|
+
private _buildDeltaUpdates(updates: ValueUpdate[]): DeltaUpdate[] {
|
|
535
|
+
const deltaUpdates: DeltaUpdate[] = []
|
|
536
|
+
// 同一 index が同一バッチ内に複数含まれても差分を二重適用しないよう、
|
|
537
|
+
// バッチ内での確定済み delta を index ごとに追跡する。
|
|
538
|
+
const pendingDeltas = new Map<number, number>()
|
|
539
|
+
for (const { index, value } of updates) {
|
|
540
|
+
if (index < 0 || index >= this.size) {
|
|
541
|
+
throw new Error(`Index ${index} out of bounds`)
|
|
542
|
+
}
|
|
543
|
+
// 小数/NaN の index は整数走査が読まない不正キーのノードを作り
|
|
544
|
+
// cumulative と total を静かに乖離させるため拒否する (NaN は上の範囲比較を素通りする)。
|
|
545
|
+
if (!Number.isInteger(index)) {
|
|
546
|
+
throw new Error(`Index must be an integer: ${index}`)
|
|
547
|
+
}
|
|
548
|
+
// NaN は `< 0` をすり抜けて tree/total を復旧不能に全面汚染するため、
|
|
549
|
+
// 非有限値 (NaN/Infinity) は検証段で throw して拒否する。
|
|
550
|
+
if (!Number.isFinite(value)) {
|
|
551
|
+
throw new Error(`Value must be a finite number: ${value}`)
|
|
552
|
+
}
|
|
553
|
+
if (value < 0) {
|
|
554
|
+
throw new Error("Value cannot be negative.")
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 現在の delta はバッチ内で既に更新済みならその値を、なければ Map の値を使う
|
|
558
|
+
const currentDelta = pendingDeltas.has(index) ? (pendingDeltas.get(index) ?? 0) : (this.deltas.get(index) ?? 0)
|
|
559
|
+
const oldValue = currentDelta + this.baseValue
|
|
560
|
+
const change = value - oldValue
|
|
561
|
+
if (change !== 0) {
|
|
562
|
+
deltaUpdates.push({ index, change })
|
|
563
|
+
}
|
|
564
|
+
// 次の同一 index エントリのために確定 delta を進める (change=0 でも記録)
|
|
565
|
+
pendingDeltas.set(index, currentDelta + change)
|
|
566
|
+
}
|
|
567
|
+
return deltaUpdates
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* @private
|
|
572
|
+
* @method _computeTreeTotal
|
|
573
|
+
* @description Calculates the total height represented by the current Fenwick tree structure, reproducing the same traversal used by `prefixSum` at the last index to avoid floating-point drift.
|
|
574
|
+
* @description Fenwick 木が保持する合計値を算出する。末尾インデックスでの `prefixSum` と同一の走査手順を再現し、浮動小数点の誤差を防ぐ。
|
|
575
|
+
* @returns {number} The total height encoded in the Fenwick tree.
|
|
576
|
+
*/
|
|
577
|
+
private _computeTreeTotal(): number {
|
|
578
|
+
if (this.size <= 0) {
|
|
579
|
+
return 0
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let sum = 0
|
|
583
|
+
let treeIndex = this.size
|
|
584
|
+
while (treeIndex > 0) {
|
|
585
|
+
sum += this.tree.get(treeIndex) ?? 0
|
|
586
|
+
const step = getLowestSetBit(treeIndex)
|
|
587
|
+
if (step === 0) {
|
|
588
|
+
break
|
|
589
|
+
}
|
|
590
|
+
treeIndex -= step
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return sum + this.baseValue * this.size
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* @private
|
|
598
|
+
* @method _materialize
|
|
599
|
+
* @description Materializes the value at a specific index if it hasn't been already. A non-finite `valueFn` result throws before any mutation, symmetric with the update-path validation, so the tree is never poisoned.
|
|
600
|
+
* @description 特定のインデックスの値がまだ具現化されていない場合に具現化する。`valueFn` の非有限な返値は変異前に throw で拒否 (update 系の検証と対称) し、木を汚染させない方針。
|
|
601
|
+
* @param {number} index - The 0-based index to materialize.
|
|
602
|
+
* @param {boolean} [updateTree=true] - Whether to update the Fenwick tree after materialization.
|
|
603
|
+
*/
|
|
604
|
+
private _materialize(index: number, updateTree = true) {
|
|
605
|
+
if (this.valueFn) {
|
|
606
|
+
// 1. 現在の差分を取得する (存在しなければ 0)
|
|
607
|
+
const oldDelta = this.deltas.get(index) ?? 0
|
|
608
|
+
|
|
609
|
+
// 2. valueFn から本来の値を計算し、新しい差分を求める。
|
|
610
|
+
// 非有限値は一度伝播すると tree/total 全体が復旧不能に汚染されるため、
|
|
611
|
+
// deltas/tree を変異させる前に throw で拒否する (update 系と対称)。
|
|
612
|
+
const value = requireFiniteValue(this.valueFn(index), index)
|
|
613
|
+
const newDelta = value - this.baseValue
|
|
614
|
+
|
|
615
|
+
// 3. 差分が実際に変わった場合のみ、ツリーを更新する
|
|
616
|
+
if (newDelta !== oldDelta) {
|
|
617
|
+
// baseValue と一致する行は delta を保持しない (Map の無制限成長を抑制)
|
|
618
|
+
if (newDelta === 0) {
|
|
619
|
+
this.deltas.delete(index)
|
|
620
|
+
} else {
|
|
621
|
+
this.deltas.set(index, newDelta)
|
|
622
|
+
}
|
|
623
|
+
if (updateTree) {
|
|
624
|
+
// ツリーに反映すべき差分は「新しい差分」と「古い差分」の間の差
|
|
625
|
+
const changeForTree = newDelta - oldDelta
|
|
626
|
+
this._updateTree(index, changeForTree)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* @private
|
|
634
|
+
* @method _materializeRanges
|
|
635
|
+
* @description Materializes values for provided ranges and optionally a target index, keeping existing semantics for each caller.
|
|
636
|
+
* @description 指定された範囲とターゲットインデックスを既存仕様通りに具現化する。
|
|
637
|
+
* @param {MaterializeOption | undefined} option - Materialization option wrapper.
|
|
638
|
+
* @param {number | undefined} index - Target index for materialization.
|
|
639
|
+
* @param {boolean} [forceIndex=false] - When true, materializes the index even if it is outside the provided ranges.
|
|
640
|
+
*/
|
|
641
|
+
private _materializeRanges(option?: MaterializeOption, index?: number, forceIndex = false) {
|
|
642
|
+
if (!(option?.materialize && this.valueFn)) {
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const ranges = option.ranges
|
|
647
|
+
if (ranges && ranges.length > 0) {
|
|
648
|
+
for (const range of ranges) {
|
|
649
|
+
// from も 0 側にクランプする。負インデックスを具現化すると
|
|
650
|
+
// deltas に不正なキーが入り total が恒久的に破損するのを防ぐ。
|
|
651
|
+
// 小数はループ変数が非整数キーのノードを生むため切り捨てて整数化する。
|
|
652
|
+
const from = Math.max(0, Math.trunc(range.from))
|
|
653
|
+
const to = Math.min(Math.trunc(range.to), this.size - 1)
|
|
654
|
+
for (let i = from; i <= to; i++) {
|
|
655
|
+
this._materialize(i)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (index === undefined) {
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (forceIndex) {
|
|
664
|
+
this._materialize(index)
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const first = ranges[0].from
|
|
669
|
+
const last = ranges[ranges.length - 1].to
|
|
670
|
+
if (index >= first && index <= last) {
|
|
671
|
+
this._materialize(index)
|
|
672
|
+
}
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (index !== undefined) {
|
|
677
|
+
this._materialize(index)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* @private
|
|
683
|
+
* @method _findIndex
|
|
684
|
+
* @description Executes a binary search over prefix sums to find the first index satisfying a boundary condition.
|
|
685
|
+
* @description 累積和に対する二分探索を行い、境界条件を満たす最初のインデックスを求める。
|
|
686
|
+
* @param {number} target - Target cumulative value.
|
|
687
|
+
* @param {MaterializeConfig} options - Materialization setting wrapper.
|
|
688
|
+
* @param {boolean} chooseLowerBound - When true, finds the smallest index meeting or exceeding the target; otherwise finds the largest index not exceeding it.
|
|
689
|
+
* @returns {{ index: number; total: number | undefined; cumulative: number | undefined; currentValue: number | undefined; safeIndex: number | undefined }} Binary search result.
|
|
690
|
+
*/
|
|
691
|
+
private _findIndex(target: number, options: MaterializeConfig = {}, chooseLowerBound: boolean): { index: number; total: number | undefined; cumulative: number | undefined; currentValue: number | undefined; safeIndex: number | undefined } {
|
|
692
|
+
// JSのビット演算子は32ビット整数に制限されています。
|
|
693
|
+
// bit の倍加 (bit << 1) は 2^30 に達すると Int32 オーバーフローで負値へ化け、
|
|
694
|
+
// 条件が常に真になって無限ループする。そのため size が 2^30 以上のときは
|
|
695
|
+
// bigint ベースの二分探索 (_findIndexLarge) に委譲する。 (0x40000000 = 2^30)
|
|
696
|
+
if (this.size >= 0x40000000) {
|
|
697
|
+
return this._findIndexLarge(target, options, chooseLowerBound)
|
|
698
|
+
}
|
|
699
|
+
if (this.size === 0) {
|
|
700
|
+
return { index: -1, total: this.total ?? 0, cumulative: undefined, currentValue: undefined, safeIndex: undefined }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// 探索前に対象範囲を具現化しておく。具現化後のツリー状態で降下を行うことで、
|
|
704
|
+
// 返す index と後段 prefixSum の cumulative が同一状態を反映し、
|
|
705
|
+
// 同一引数の連続呼び出しでも結果がぶれないようにする (_findIndexLarge と挙動を揃える)。
|
|
706
|
+
this._materializeRanges(options.materializeOption)
|
|
707
|
+
|
|
708
|
+
// materialize:true の契約: 返す index/cumulative は「その index が具現化済みの木」を
|
|
709
|
+
// 反映しなければならない。降下後に結果 index を具現化すると木が変わり、返した
|
|
710
|
+
// cumulative が古い状態のまま (契約違反) になり、同一引数の再呼び出しで結果が
|
|
711
|
+
// ドリフトする。そこで「降下 -> 結果 index を具現化 -> 木が変わったら再降下」を
|
|
712
|
+
// 不動点に達するまで繰り返す。決定的な valueFn なら各行の具現化は 1 度しか木を
|
|
713
|
+
// 変えないため、ループは必ず停止する。
|
|
714
|
+
const materialize = options.materializeOption?.materialize === true
|
|
715
|
+
let resultIdx: number
|
|
716
|
+
for (;;) {
|
|
717
|
+
resultIdx = this._descend(target, chooseLowerBound)
|
|
718
|
+
|
|
719
|
+
if (resultIdx < 0 || resultIdx >= this.size) {
|
|
720
|
+
return { index: -1, total: this.total ?? this.getTotal(), cumulative: undefined, currentValue: undefined, safeIndex: undefined }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (!materialize) {
|
|
724
|
+
break
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// 結果 index を具現化し、delta が変化しなければ結果は安定している
|
|
728
|
+
const deltaBefore = this.deltas.get(resultIdx) ?? 0
|
|
729
|
+
this._materialize(resultIdx)
|
|
730
|
+
if ((this.deltas.get(resultIdx) ?? 0) === deltaBefore) {
|
|
731
|
+
break
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Retrieve full details for the found index.
|
|
736
|
+
// options を渡さない: 探索後の prefixSum 内部での強制具現化 (forceIndex) による
|
|
737
|
+
// 木の変異を防ぐ (必要な具現化は上のループで完了済み)。
|
|
738
|
+
const result = this.prefixSum(resultIdx)
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
index: resultIdx,
|
|
742
|
+
total: this.total ?? result.total,
|
|
743
|
+
cumulative: result.cumulative,
|
|
744
|
+
currentValue: result.currentValue,
|
|
745
|
+
safeIndex: result.safeIndex,
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* @private
|
|
751
|
+
* @method _descend
|
|
752
|
+
* @description Performs a single bitwise descent over the current tree state, returning the candidate index for the boundary condition without mutating the tree.
|
|
753
|
+
* @description 現在の木の状態に対してビット降下を 1 回実行し、木を変異させずに境界条件の候補インデックスを返す。
|
|
754
|
+
* @param {number} target - Target cumulative value.
|
|
755
|
+
* @param {boolean} chooseLowerBound - When true, finds the smallest index meeting or exceeding the target; otherwise finds the largest index not exceeding it.
|
|
756
|
+
* @returns {number} The candidate index (may be out of range when no index satisfies the condition).
|
|
757
|
+
*/
|
|
758
|
+
private _descend(target: number, chooseLowerBound: boolean): number {
|
|
759
|
+
// Optimization: Use bitwise descent (O(log N)) instead of binary search over prefixSum (O(log^2 N))
|
|
760
|
+
// This assumes non-negative values (monotonic prefix sums).
|
|
761
|
+
|
|
762
|
+
let currentIdx = 0
|
|
763
|
+
let currentSum = 0
|
|
764
|
+
|
|
765
|
+
// Find the highest power of 2 less than or equal to size
|
|
766
|
+
let bit = 1
|
|
767
|
+
while (bit << 1 <= this.size) {
|
|
768
|
+
bit <<= 1
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
for (; bit > 0; bit >>= 1) {
|
|
772
|
+
const nextIdx = currentIdx + bit
|
|
773
|
+
if (nextIdx <= this.size) {
|
|
774
|
+
const treeValue = this.tree.get(nextIdx) ?? 0
|
|
775
|
+
const val = treeValue + this.baseValue * bit
|
|
776
|
+
|
|
777
|
+
const check = chooseLowerBound ? currentSum + val < target : currentSum + val <= target
|
|
778
|
+
|
|
779
|
+
if (check) {
|
|
780
|
+
currentIdx = nextIdx
|
|
781
|
+
currentSum += val
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return chooseLowerBound ? currentIdx : currentIdx - 1
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Executes a binary search using bigint arithmetic for extremely large sizes.
|
|
791
|
+
*
|
|
792
|
+
* 非常に大きなサイズに対して bigint 演算を用いた二分探索を実施。
|
|
793
|
+
*/
|
|
794
|
+
private _findIndexLarge(target: number, options: MaterializeConfig, chooseLowerBound: boolean): { index: number; total: number | undefined; cumulative: number | undefined; currentValue: number | undefined; safeIndex: number | undefined } {
|
|
795
|
+
if (this.size === 0) {
|
|
796
|
+
return { index: -1, total: this.total ?? 0, cumulative: undefined, currentValue: undefined, safeIndex: undefined }
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const sizeBig = toSafeBigInt(this.size)
|
|
800
|
+
if (sizeBig === 0n) {
|
|
801
|
+
return { index: -1, total: this.total ?? 0, cumulative: undefined, currentValue: undefined, safeIndex: undefined }
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// 小サイズパス (_findIndex) と同一契約: 探索前に対象範囲を具現化し、探索中は木を
|
|
805
|
+
// 変異させない (prefixSum に options を渡さない)。materialize:true の場合は
|
|
806
|
+
// 「探索 -> 結果 index を具現化 -> 木が変わったら再探索」を不動点まで繰り返す。
|
|
807
|
+
this._materializeRanges(options.materializeOption)
|
|
808
|
+
const materialize = options.materializeOption?.materialize === true
|
|
809
|
+
|
|
810
|
+
for (;;) {
|
|
811
|
+
let low = 0n
|
|
812
|
+
let high = sizeBig - 1n
|
|
813
|
+
let resolvedIndex: bigint | undefined
|
|
814
|
+
let bestResult: { cumulative: number; total: number | undefined; currentValue: number; safeIndex: number } | undefined
|
|
815
|
+
let finalTotal = this.total
|
|
816
|
+
|
|
817
|
+
while (low <= high) {
|
|
818
|
+
const mid = (low + high) >> 1n
|
|
819
|
+
const midNumber = Number(mid)
|
|
820
|
+
const result = this.prefixSum(midNumber)
|
|
821
|
+
finalTotal = result.total
|
|
822
|
+
const meetsCondition = chooseLowerBound ? result.cumulative >= target : result.cumulative <= target
|
|
823
|
+
if (meetsCondition) {
|
|
824
|
+
resolvedIndex = mid
|
|
825
|
+
bestResult = result
|
|
826
|
+
if (chooseLowerBound) {
|
|
827
|
+
if (mid === 0n) {
|
|
828
|
+
break
|
|
829
|
+
}
|
|
830
|
+
high = mid - 1n
|
|
831
|
+
} else {
|
|
832
|
+
low = mid + 1n
|
|
833
|
+
}
|
|
834
|
+
} else if (chooseLowerBound) {
|
|
835
|
+
low = mid + 1n
|
|
836
|
+
} else {
|
|
837
|
+
if (mid === 0n) {
|
|
838
|
+
break
|
|
839
|
+
}
|
|
840
|
+
high = mid - 1n
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// 未発見時 (bestResult なし) は小サイズパス (_findIndex) と同一契約で
|
|
845
|
+
// cumulative/currentValue/safeIndex を undefined にする。lastResult へフォールバック
|
|
846
|
+
// すると無関係な最終プローブ位置の値が載り、AtOrBefore の未発見時に
|
|
847
|
+
// cumulative > target のような契約矛盾の値を返してしまうため。
|
|
848
|
+
const chosenResult = bestResult
|
|
849
|
+
const indexNumber = resolvedIndex !== undefined ? Number(resolvedIndex) : -1
|
|
850
|
+
|
|
851
|
+
if (materialize && indexNumber >= 0) {
|
|
852
|
+
// 結果 index を具現化し、木が変化した場合のみ再探索する
|
|
853
|
+
const deltaBefore = this.deltas.get(indexNumber) ?? 0
|
|
854
|
+
this._materialize(indexNumber)
|
|
855
|
+
if ((this.deltas.get(indexNumber) ?? 0) !== deltaBefore) {
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
index: indexNumber,
|
|
862
|
+
total: finalTotal,
|
|
863
|
+
cumulative: chosenResult?.cumulative,
|
|
864
|
+
currentValue: chosenResult?.currentValue,
|
|
865
|
+
safeIndex: chosenResult?.safeIndex,
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* @method prefixSum
|
|
872
|
+
* @description Calculates the cumulative sum up to a given index (inclusive) in O(log n) time.
|
|
873
|
+
* @description 指定されたインデックスまでの累積和を O(log n) で計算。
|
|
874
|
+
* @param {number} index - The 0-based index to prefixSum up to.
|
|
875
|
+
* @param {MaterializeConfig} [options] - Optional settings for materializing values.
|
|
876
|
+
* @returns {{ cumulative: number; total: number | undefined; currentValue: number; safeIndex: number }} The cumulative sum of values from index 0 to the given index, the total sum, and the value at the given index.
|
|
877
|
+
*/
|
|
878
|
+
prefixSum(index: number, options?: MaterializeConfig): { cumulative: number; total: number | undefined; currentValue: number; safeIndex: number } {
|
|
879
|
+
// NaN index は minmax を素通りして NaN キーの具現化 (木の汚染) を招くため、
|
|
880
|
+
// 負 index と同じ中立値へ縮退させる。
|
|
881
|
+
if (index < 0 || Number.isNaN(index)) {
|
|
882
|
+
return { cumulative: 0, total: this.total, currentValue: 0, safeIndex: 0 }
|
|
883
|
+
}
|
|
884
|
+
// 空の木 (size=0) は正当な状態として中立値を返す (throw しない)。
|
|
885
|
+
// minmax(index, 0, -1) が負値へ丸められ get(-1) が throw するのを回避する。
|
|
886
|
+
if (this.size === 0) {
|
|
887
|
+
return { cumulative: 0, total: this.total ?? 0, currentValue: 0, safeIndex: 0 }
|
|
888
|
+
}
|
|
889
|
+
// 小数 index は具現化時に非整数キーのノードを作り整数走査と乖離するため切り捨てる
|
|
890
|
+
// (Infinity は trunc を素通りした後 minmax で size-1 にクランプされる)。
|
|
891
|
+
const safeIndex = minmax(Math.trunc(index), 0, this.size - 1)
|
|
892
|
+
|
|
893
|
+
const materializeOption = options?.materializeOption
|
|
894
|
+
this._materializeRanges(materializeOption, safeIndex, true)
|
|
895
|
+
|
|
896
|
+
let sum = 0
|
|
897
|
+
let treeIndex = safeIndex + 1 // Fenwick Tree のアルゴリズムは 1 始まりで設計される
|
|
898
|
+
while (treeIndex > 0) {
|
|
899
|
+
const treeNodeValue = this.tree.get(treeIndex) ?? 0
|
|
900
|
+
sum += treeNodeValue
|
|
901
|
+
const step = getLowestSetBit(treeIndex)
|
|
902
|
+
if (step === 0) {
|
|
903
|
+
break
|
|
904
|
+
}
|
|
905
|
+
treeIndex -= step
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const currentValue = materializeOption?.materialize ? this.get(safeIndex) : (this.deltas.get(safeIndex) || 0) + this.baseValue
|
|
909
|
+
|
|
910
|
+
// ベース値に基づく合計を加算
|
|
911
|
+
return { cumulative: sum + this.baseValue * (safeIndex + 1), total: this.total, currentValue, safeIndex }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* @method get
|
|
916
|
+
* @description Gets the value at a specific index.
|
|
917
|
+
* @description 特定のインデックスの値を取得。
|
|
918
|
+
* @param {number} index - The 0-based index to get.
|
|
919
|
+
* @param {MaterializeConfig} [options] - Optional settings for materializing values.
|
|
920
|
+
* @returns {number} The value at the given index.
|
|
921
|
+
*/
|
|
922
|
+
get(index: number, options?: MaterializeConfig): number {
|
|
923
|
+
// 空の木 (size=0) は正当な状態として中立値 0 を返す (throw しない)。
|
|
924
|
+
if (this.size === 0) {
|
|
925
|
+
return 0
|
|
926
|
+
}
|
|
927
|
+
// 小数/NaN の index は具現化時に非整数キーのノードを作り total を静かに汚染するため、
|
|
928
|
+
// 範囲外と同様に throw で拒否する (NaN は範囲比較を素通りする)。
|
|
929
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.size) {
|
|
930
|
+
throw new Error("Index out of bounds")
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const materializeOption = options?.materializeOption
|
|
934
|
+
this._materializeRanges(materializeOption, index)
|
|
935
|
+
|
|
936
|
+
return (this.deltas.get(index) ?? 0) + this.baseValue
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* @method getTotal
|
|
941
|
+
* @description Gets the total sum of all values in the tree.
|
|
942
|
+
* @description ツリー内のすべての値の合計を取得。
|
|
943
|
+
* @param {MaterializeConfig} [options] - Optional settings for materializing values.
|
|
944
|
+
* @returns {number} The total sum of all values.
|
|
945
|
+
*/
|
|
946
|
+
getTotal(options?: MaterializeConfig): number {
|
|
947
|
+
const materializeOption = options?.materializeOption
|
|
948
|
+
this._materializeRanges(materializeOption)
|
|
949
|
+
|
|
950
|
+
if (this.total === undefined) {
|
|
951
|
+
if (this.size === 0) {
|
|
952
|
+
this.total = 0
|
|
953
|
+
} else {
|
|
954
|
+
this.total = this._computeTreeTotal()
|
|
955
|
+
const lastPrefix = this.prefixSum(this.getSize() - 1)
|
|
956
|
+
if (lastPrefix.cumulative !== lastPrefix.total) {
|
|
957
|
+
Logger.error("Inconsistent Fenwick Tree state")
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return this.total
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* @method rebuildTree
|
|
967
|
+
* @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`.
|
|
968
|
+
* @description 既存の `baseValue` と `deltas` から Fenwick Tree を再構築します。これにより、`deltas` からツリー構造と合計値を再計算することで、浮動小数点誤差などによって生じた内部状態の不一致を修正します。このメソッドは `valueFn` から値を再具現化しません。
|
|
969
|
+
* @param {object} [options] - Optional settings for rebuilding.
|
|
970
|
+
* @param {boolean} [options.materialize=false] - If true and `valueFn` is provided, re-materializes all values, recalculating `deltas` and `baseValue`.
|
|
971
|
+
*/
|
|
972
|
+
rebuildTree(options?: { materialize?: boolean }) {
|
|
973
|
+
if (options?.materialize && this.valueFn) {
|
|
974
|
+
// すべての値を具現化する
|
|
975
|
+
const valueFn = this.valueFn
|
|
976
|
+
this.reset(this.size, (i) => valueFn(i), { materialize: true })
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const newTree = new Map<number, number>()
|
|
981
|
+
// let newTotal = this.baseValue * this.size
|
|
982
|
+
|
|
983
|
+
// 既存の deltas を使って新しいツリーを構築し、合計値も同時に再計算する
|
|
984
|
+
for (const [index, delta] of this.deltas.entries()) {
|
|
985
|
+
if (delta === 0) {
|
|
986
|
+
continue
|
|
987
|
+
}
|
|
988
|
+
// Fenwick Tree のアルゴリズムは 1 始まりで設計されるため、インデックスを 1 加算する
|
|
989
|
+
let treeIndex = index + 1
|
|
990
|
+
while (treeIndex <= this.size) {
|
|
991
|
+
newTree.set(treeIndex, (newTree.get(treeIndex) ?? 0) + delta)
|
|
992
|
+
const step = getLowestSetBit(treeIndex)
|
|
993
|
+
if (step === 0) {
|
|
994
|
+
break
|
|
995
|
+
}
|
|
996
|
+
treeIndex += step
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// 最後に状態をアトミックに更新
|
|
1001
|
+
this.tree = newTree
|
|
1002
|
+
this.total = this._computeTreeTotal()
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* @method calculateAccumulatedError
|
|
1007
|
+
* @description Compares the cached total sum with a theoretical total calculated directly from the source values (`deltas` and `baseValue`, or `valueFn`). This helps detect any discrepancy in the tree's cached state, which might be caused by floating-point errors or other inconsistencies.
|
|
1008
|
+
* @description キャッシュされている合計値と、元の値 (`deltas` と `baseValue`、または `valueFn`) から直接計算した理論上の合計値とを比較します。これにより、浮動小数点数の累積誤差やその他の不整合によって生じる可能性のある、ツリーのキャッシュ状態の不一致を検出できます。
|
|
1009
|
+
* @returns {number} The difference between the cached total and the theoretical total.
|
|
1010
|
+
*/
|
|
1011
|
+
calculateAccumulatedError(): number {
|
|
1012
|
+
if (this.total === undefined) {
|
|
1013
|
+
// total がまだ計算されていない場合は、誤差は 0 とする
|
|
1014
|
+
return 0
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// 理論上の合計値を計算
|
|
1018
|
+
let theoreticalTotal = this.baseValue * this.size
|
|
1019
|
+
for (const delta of this.deltas.values()) {
|
|
1020
|
+
theoreticalTotal += delta
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// キャッシュされている合計値との差を返す
|
|
1024
|
+
return this.total - theoreticalTotal
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* @method changeSize
|
|
1029
|
+
* @description Changes the size of the Fenwick Tree. Only tail growth/shrink is modelled: this
|
|
1030
|
+
* method receives a size delta, not an index mapping, so it assumes items are appended to or
|
|
1031
|
+
* removed from the tail. Existing rows keep their index-to-height association. It therefore
|
|
1032
|
+
* cannot represent a middle insertion/deletion — after such a structural change the index↔height
|
|
1033
|
+
* mapping is no longer preserved and the tree's deltas would apply to the wrong rows. Consumers
|
|
1034
|
+
* that insert or remove items in the middle must instead reset the tree (see
|
|
1035
|
+
* `useFenwickMapTree`'s `resetOnValueFnChange`, surfaced as `resetOnGetItemHeightChange` in
|
|
1036
|
+
* VirtualScroll) or remount the component with a new `key`.
|
|
1037
|
+
* @description Fenwick Tree のサイズを変更する。モデル化するのは末尾の伸長/縮小のみ: 本メソッドは
|
|
1038
|
+
* サイズ差分のみを受け取りインデックスの対応表は受け取らないため、要素は末尾に追加/末尾から削除
|
|
1039
|
+
* されると仮定する。既存行は index↔高さ の対応を保つ。したがって中間挿入/削除は表現できず、その
|
|
1040
|
+
* ような構造変更の後は index↔高さ の対応が保存されず、木の delta が誤った行へ適用されてしまう。
|
|
1041
|
+
* 中間で要素を挿入/削除する消費側は、代わりに木をリセットするか (`useFenwickMapTree` の
|
|
1042
|
+
* `resetOnValueFnChange`。VirtualScroll では `resetOnGetItemHeightChange` として公開) 、新しい
|
|
1043
|
+
* `key` でコンポーネントを再マウントすること。
|
|
1044
|
+
* @remarks Validation: a non-finite `newSize` (NaN/Infinity) throws because it would poison
|
|
1045
|
+
* `size`/`total` irrecoverably. Fractional sizes are truncated and negative sizes collapse to 0,
|
|
1046
|
+
* matching the normalization rule of `reset`.
|
|
1047
|
+
* @remarks 検証: 非有限の `newSize` (NaN/Infinity) は `size`/`total` を復旧不能に汚染するため
|
|
1048
|
+
* throw。小数は切り捨て、負値は 0 に縮退 (`reset` の正規化規則と同一)。
|
|
1049
|
+
* @param {number} newSize - The new size of the tree.
|
|
1050
|
+
*/
|
|
1051
|
+
changeSize(newSize: number) {
|
|
1052
|
+
// 非有限 (NaN/Infinity) は size/total を復旧不能に汚染するため入口で拒否する。
|
|
1053
|
+
if (!Number.isFinite(newSize)) {
|
|
1054
|
+
throw new Error(`Size must be a finite number: ${newSize}`)
|
|
1055
|
+
}
|
|
1056
|
+
// 小数は切り捨て、負値は 0 として末尾伸縮を扱う (reset の正規化と同じ規則)。
|
|
1057
|
+
const targetSize = Math.max(0, Math.trunc(newSize))
|
|
1058
|
+
const oldSize = this.size
|
|
1059
|
+
if (targetSize === oldSize) {
|
|
1060
|
+
// サイズが変わらない場合は何もしない
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (targetSize > oldSize) {
|
|
1065
|
+
// 末尾への伸長は増分更新で済ませる (全再構築 O(D log n) を回避)。
|
|
1066
|
+
// 既存ノード (treeIndex <= oldSize) は不変であり、新規要素は delta 0 なので、
|
|
1067
|
+
// oldSize で伝播が止まっていた既存 delta を新たに有効化されたノード
|
|
1068
|
+
// (oldSize, targetSize] にのみ伝播すればよい。
|
|
1069
|
+
this._growSize(oldSize, targetSize)
|
|
1070
|
+
} else {
|
|
1071
|
+
// サイズが小さくなる場合、範囲外の delta を削除してからツリーを再構築する
|
|
1072
|
+
for (const index of this.deltas.keys()) {
|
|
1073
|
+
if (index >= targetSize) {
|
|
1074
|
+
this.deltas.delete(index)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
this.size = targetSize
|
|
1078
|
+
this.rebuildTree()
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const lastPrefix = this.prefixSum(this.getSize() - 1)
|
|
1082
|
+
if (lastPrefix.cumulative !== lastPrefix.total) {
|
|
1083
|
+
Logger.error("Inconsistent Fenwick Tree state")
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* @private
|
|
1089
|
+
* @method _minCoveredLowBound
|
|
1090
|
+
* @description Computes `min(t - lowestSetBit(t))` over the newly-activated node range `t in (oldSize, newSize]`. Any delta whose 1-based tree index is at or below this bound cannot be covered by any newly-activated node and can be skipped during an incremental grow.
|
|
1091
|
+
* @description 新規有効化ノード範囲 `t in (oldSize, newSize]` にわたる `min(t - lowestSetBit(t))` を算出する。1 始まりの木インデックスがこの下限以下の delta はどの新規ノードにも覆われないため、増分伸長時にスキップできる。
|
|
1092
|
+
* @param {number} oldSize - The previous size (exclusive lower bound of the new node range).
|
|
1093
|
+
* @param {number} newSize - The new size (inclusive upper bound of the new node range).
|
|
1094
|
+
* @returns {number} The minimum left boundary `t - lowestSetBit(t)` of the newly-activated nodes.
|
|
1095
|
+
*/
|
|
1096
|
+
private static _minCoveredLowBound(oldSize: number, newSize: number): number {
|
|
1097
|
+
// ノード t が覆う区間は (t - lsb(t), t]。min(t - lsb(t)) は
|
|
1098
|
+
// 「(oldSize, newSize] に倍数を持つ最大の 2 冪 p」を使うと閉形式で求まる:
|
|
1099
|
+
// 範囲内の p の倍数はいずれも lsb がちょうど p (2p の倍数が範囲に在れば p の最大性に反する)
|
|
1100
|
+
// なので、最小の倍数 m = p * (floor(oldSize / p) + 1) が min を与え、
|
|
1101
|
+
// m - p = p * floor(oldSize / p) となる。
|
|
1102
|
+
// 2 冪の乗除は仮数を変えないため 2^53 未満の整数では丸め誤差なし。
|
|
1103
|
+
let power = 1
|
|
1104
|
+
for (;;) {
|
|
1105
|
+
const next = power * 2
|
|
1106
|
+
// (oldSize, newSize] に next の倍数が存在するか (最大の倍数 > oldSize と同値)
|
|
1107
|
+
if (Math.floor(newSize / next) * next <= oldSize) {
|
|
1108
|
+
break
|
|
1109
|
+
}
|
|
1110
|
+
power = next
|
|
1111
|
+
}
|
|
1112
|
+
return power * Math.floor(oldSize / power)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* @private
|
|
1117
|
+
* @method _propagateDeltaToGrownNodes
|
|
1118
|
+
* @description Propagates a single delta into the tree nodes newly activated by a tail growth, i.e. parent-chain nodes in `(oldSize, newSize]`.
|
|
1119
|
+
* @description 末尾伸長で新規有効化されたノード ((oldSize, newSize] にある親チェーンノード) へ単一 delta を伝播する。
|
|
1120
|
+
* @param {number} index - The 0-based delta index.
|
|
1121
|
+
* @param {number} delta - The delta value to propagate.
|
|
1122
|
+
* @param {number} oldSize - The previous size.
|
|
1123
|
+
* @param {number} newSize - The new (larger) size.
|
|
1124
|
+
*/
|
|
1125
|
+
private _propagateDeltaToGrownNodes(index: number, delta: number, oldSize: number, newSize: number) {
|
|
1126
|
+
let treeIndex = index + 1
|
|
1127
|
+
// 既存範囲 (<= oldSize) のノードは更新済みなので、
|
|
1128
|
+
// 親チェーンを辿って oldSize を超える最初のノードまで進める。
|
|
1129
|
+
while (treeIndex <= oldSize) {
|
|
1130
|
+
const step = getLowestSetBit(treeIndex)
|
|
1131
|
+
if (step === 0) {
|
|
1132
|
+
break
|
|
1133
|
+
}
|
|
1134
|
+
treeIndex += step
|
|
1135
|
+
}
|
|
1136
|
+
// 新たに有効化されたノード (oldSize, newSize] にのみ差分を伝播する。
|
|
1137
|
+
while (treeIndex <= newSize) {
|
|
1138
|
+
this.tree.set(treeIndex, (this.tree.get(treeIndex) ?? 0) + delta)
|
|
1139
|
+
const step = getLowestSetBit(treeIndex)
|
|
1140
|
+
if (step === 0) {
|
|
1141
|
+
break
|
|
1142
|
+
}
|
|
1143
|
+
treeIndex += step
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* @private
|
|
1149
|
+
* @method _growSize
|
|
1150
|
+
* @description Extends the tree to a larger size incrementally, propagating existing deltas into newly-activated higher-order tree nodes only. Candidate deltas are narrowed via `_minCoveredLowBound`, so a typical `+1` tail append costs amortized O(log n) instead of O(D log n) (D = number of materialized deltas).
|
|
1151
|
+
* @description ツリーを末尾方向に増分的に拡張する。既存 delta を新たに有効化された上位ノードにのみ伝播する。候補 delta は `_minCoveredLowBound` で絞り込むため、通常の +1 末尾追記は O(D log n) (D = 具現化済み delta 数) ではなく償却 O(log n) で済む。
|
|
1152
|
+
* @param {number} oldSize - The previous size.
|
|
1153
|
+
* @param {number} newSize - The new (larger) size.
|
|
1154
|
+
*/
|
|
1155
|
+
private _growSize(oldSize: number, newSize: number) {
|
|
1156
|
+
this.size = newSize
|
|
1157
|
+
|
|
1158
|
+
if (this.deltas.size > 0) {
|
|
1159
|
+
// 影響し得る delta は木インデックス (index + 1) が lowBound を超えるものだけ。
|
|
1160
|
+
// 既存 delta の index は必ず oldSize 未満なので、候補は index in [lowBound, oldSize)。
|
|
1161
|
+
const lowBound = FenwickMapTree._minCoveredLowBound(oldSize, newSize)
|
|
1162
|
+
const candidateCount = oldSize - lowBound
|
|
1163
|
+
if (candidateCount < this.deltas.size) {
|
|
1164
|
+
// 候補範囲の方が狭い (+1 追記の通常ケース): index を直接ループして deltas から拾う。
|
|
1165
|
+
for (let index = lowBound; index < oldSize; index++) {
|
|
1166
|
+
const delta = this.deltas.get(index)
|
|
1167
|
+
if (delta === undefined || delta === 0) {
|
|
1168
|
+
continue
|
|
1169
|
+
}
|
|
1170
|
+
this._propagateDeltaToGrownNodes(index, delta, oldSize, newSize)
|
|
1171
|
+
}
|
|
1172
|
+
} else {
|
|
1173
|
+
// delta 件数の方が少ない (大幅伸長など): 従来どおり全 deltas を走査し、
|
|
1174
|
+
// lowBound 以下の木インデックスを持つものは除外する。
|
|
1175
|
+
for (const [index, delta] of this.deltas.entries()) {
|
|
1176
|
+
if (delta === 0 || index < lowBound) {
|
|
1177
|
+
continue
|
|
1178
|
+
}
|
|
1179
|
+
this._propagateDeltaToGrownNodes(index, delta, oldSize, newSize)
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// total は増分 (+= baseValue * Δ) ではなく prefixSum と同一走査を再現する
|
|
1185
|
+
// _computeTreeTotal で確定する。増分加算は浮動小数点の分配則不成立により
|
|
1186
|
+
// prefixSum の走査結果と厳密不一致になり、changeSize 末尾の整合検査が
|
|
1187
|
+
// 偽の "Inconsistent Fenwick Tree state" を量産するため。O(log n) なので
|
|
1188
|
+
// 増分更新の速度メリットは保たれる。
|
|
1189
|
+
this.total = this._computeTreeTotal()
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* @method getSize
|
|
1194
|
+
* @description Gets the size of the tree.
|
|
1195
|
+
* @description ツリーのサイズを取得。
|
|
1196
|
+
* @returns {number} The total number of items.
|
|
1197
|
+
*/
|
|
1198
|
+
getSize(): number {
|
|
1199
|
+
return this.size
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* @method findIndexAtOrAfter
|
|
1204
|
+
* @description Finds the first index where the cumulative sum is greater than or equal to a target value.
|
|
1205
|
+
* @description 累積和がターゲット値以上になる最初のインデックスを検索。
|
|
1206
|
+
* @param {number} target - The target cumulative sum.
|
|
1207
|
+
* @param {MaterializeConfig} [options] - Optional settings for materializing values.
|
|
1208
|
+
* @param {MaterializeOption} [options.materializeOption] - Options to control materialization.
|
|
1209
|
+
* @param {boolean} [options.materializeOption.materialize=false] - Whether to materialize values.
|
|
1210
|
+
* @param {MaterializeRange[]} [options.materializeOption.ranges] - Optional ranges for eager materialization.
|
|
1211
|
+
* @returns {{ index: number, total: number | undefined, cumulative: number | undefined, currentValue: number | undefined, safeIndex: number | undefined }} The 0-based index and the total sum, or -1 if not found.
|
|
1212
|
+
*/
|
|
1213
|
+
findIndexAtOrAfter(target: number, options?: MaterializeConfig): { index: number; total: number | undefined; cumulative: number | undefined; currentValue: number | undefined; safeIndex: number | undefined } {
|
|
1214
|
+
return this._findIndex(target, options ?? {}, true)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* @method findIndexAtOrBefore
|
|
1219
|
+
* @description Finds the last index where the cumulative sum is less than or equal to a target value.
|
|
1220
|
+
* @description 累積和がターゲット値以下になる最後のインデックスを検索。
|
|
1221
|
+
* @param {number} target - The target cumulative sum.
|
|
1222
|
+
* @param {MaterializeConfig} [options] - Optional settings for materializing values.
|
|
1223
|
+
* @param {MaterializeOption} [options.materializeOption] - Options to control materialization。
|
|
1224
|
+
* @param {boolean} [options.materializeOption.materialize=false] - Whether to materialize values。
|
|
1225
|
+
* @param {MaterializeRange[]} [options.materializeOption.ranges] - Optional ranges for eager materialization。
|
|
1226
|
+
* @returns {{ index: number, total: number | undefined, cumulative: number | undefined, currentValue: number | undefined, safeIndex: number | undefined }} The 0-based index and the total sum, or -1 if not found.
|
|
1227
|
+
*/
|
|
1228
|
+
findIndexAtOrBefore(target: number, options?: MaterializeConfig): { index: number; total: number | undefined; cumulative: number | undefined; currentValue: number | undefined; safeIndex: number | undefined } {
|
|
1229
|
+
return this._findIndex(target, options ?? {}, false)
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Determines whether the tree-relevant part of the options (only `sampleRange` affects
|
|
1235
|
+
* tree initialization/sampling) actually changed. Compares by value rather than by
|
|
1236
|
+
* reference so that non-memoized inline option objects with an equivalent `sampleRange`
|
|
1237
|
+
* do not trigger a destructive full reset every render.
|
|
1238
|
+
*
|
|
1239
|
+
* ツリー初期化に影響するオプション (`sampleRange` のみ) が実質的に変わったかを値比較で判定する。
|
|
1240
|
+
* 参照比較ではなくすることで、非メモ化のインライン options でも sampleRange が同一なら
|
|
1241
|
+
* 毎レンダーの破壊的な full reset を起こさない。
|
|
1242
|
+
*/
|
|
1243
|
+
const sampleRangeChanged = (a?: { sampleRange?: { from: number; to: number } }, b?: { sampleRange?: { from: number; to: number } }): boolean => {
|
|
1244
|
+
const ra = a?.sampleRange
|
|
1245
|
+
const rb = b?.sampleRange
|
|
1246
|
+
if (ra === rb) {
|
|
1247
|
+
return false
|
|
1248
|
+
}
|
|
1249
|
+
if (!(ra && rb)) {
|
|
1250
|
+
return true
|
|
1251
|
+
}
|
|
1252
|
+
return ra.from !== rb.from || ra.to !== rb.to
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* @hook useFenwickMapTree
|
|
1257
|
+
* @description A React hook that creates and manages a `FenwickMapTree` instance.
|
|
1258
|
+
* The tree instance is stable across re-renders. A new tree instance is created
|
|
1259
|
+
* if `size` or `valueOrFn` changes.
|
|
1260
|
+
* @description `FenwickMapTree` インスタンスを作成・管理する React フック。
|
|
1261
|
+
* ツリーインスタンスは再レンダリングされても同一性を維持する。`size` または `valueOrFn` が
|
|
1262
|
+
* 変更された場合に新しいインスタンスが作成される。
|
|
1263
|
+
*
|
|
1264
|
+
* @remarks Structural changes: `size` changes are treated as tail append/remove (see
|
|
1265
|
+
* `FenwickMapTree.changeSize`). If a `size`/`itemCount` change actually reflects a middle
|
|
1266
|
+
* insertion or deletion, the `valueOrFn` index↔height association is not preserved by an
|
|
1267
|
+
* incremental resize; set `resetOnValueFnChange` to `true` (so the tree fully re-samples on
|
|
1268
|
+
* every `valueOrFn` change) or remount with a new `key` when the list structure changes.
|
|
1269
|
+
* @remarks 構造変更: `size` 変更は末尾追加/削除として扱う (`FenwickMapTree.changeSize` 参照)。
|
|
1270
|
+
* `size`/`itemCount` の変更が実は中間挿入/削除を表す場合、増分リサイズでは `valueOrFn` の
|
|
1271
|
+
* index↔高さ 対応が保存されないため、`resetOnValueFnChange` を `true` にする (毎回の `valueOrFn`
|
|
1272
|
+
* 変更で木を全面再サンプリング) か、リスト構造変更時に新しい `key` で再マウントすること。
|
|
1273
|
+
*
|
|
1274
|
+
* @remarks Render-phase design constraint: this hook applies tree changes synchronously during
|
|
1275
|
+
* render (not in an effect) because VirtualScroll reads `getTotal()` synchronously right after a
|
|
1276
|
+
* size change. A consequence is that React concurrent's discarded renders are not undone — a
|
|
1277
|
+
* shrink (deltas deletion) is irreversible if the render is thrown away. To stay safe, always pass
|
|
1278
|
+
* a memoized `options`/`sampleRange` (e.g. `useMemo`) and a memoized `valueOrFn` (e.g. `useCallback`)
|
|
1279
|
+
* so an unstable reference does not trigger a destructive reset every render.
|
|
1280
|
+
* @remarks render フェーズの設計上の制約: 本フックはツリー変更を effect ではなく render 中に同期的に
|
|
1281
|
+
* 適用する。VirtualScroll がサイズ変更直後に `getTotal()` を同期的に読む契約に依存するためである。
|
|
1282
|
+
* 結果として React concurrent の破棄されたレンダーは巻き戻されず、縮小 (deltas 削除) はレンダーが
|
|
1283
|
+
* 破棄されると不可逆になる。安全のため `options`/`sampleRange` は必ずメモ化して渡し (`useMemo`)、
|
|
1284
|
+
* `valueOrFn` もメモ化する (`useCallback`) こと。不安定な参照は毎レンダーの破壊的 reset を招く。
|
|
1285
|
+
*
|
|
1286
|
+
* @param {number} size - The total number of items.
|
|
1287
|
+
* @param {number | ((index: number) => number)} valueOrFn - The value for all elements, or a function to generate values.
|
|
1288
|
+
* @param {number | ((index: number) => number)} valueOrFn - 全要素の均一な値、または値を生成する関数。不要なツリーの再作成を防ぐため、この関数は `useCallback` でメモ化すること。
|
|
1289
|
+
* @param {{ sampleRange?: { from: number; to: number }, resetOnValueFnChange?: boolean, debug?: boolean }} [options] - Optional settings.
|
|
1290
|
+
* @param {{ sampleRange?: { from: number; to: number } }} [options] - 初期化時のオプション設定。不要なツリーの再作成を防ぐため、このオブジェクトは `useMemo` でメモ化すること。
|
|
1291
|
+
* @returns {FenwickMapTree} The FenwickMapTree instance.
|
|
1292
|
+
* @returns {FenwickMapTree} FenwickMapTree インスタンス。
|
|
1293
|
+
*/
|
|
1294
|
+
export const useFenwickMapTree = (size: number, valueOrFn: number | ((index: number) => number), options?: { sampleRange?: { from: number; to: number }; debug?: boolean; resetOnValueFnChange?: boolean }): FenwickMapTree => {
|
|
1295
|
+
// size を正規化する: 非有限 (NaN は Math.max(0, NaN) を素通りする)・非正は 0、小数は切り捨て。
|
|
1296
|
+
// reset / changeSize へ渡る前にここで遮断し、木の内部を NaN 汚染させない (reset と同一規則)。
|
|
1297
|
+
const validSize = Number.isFinite(size) && size > 0 ? Math.trunc(size) : 0
|
|
1298
|
+
const treeRef = useRef<FenwickMapTree | null>(null)
|
|
1299
|
+
const prevPropsRef = useRef({ size: validSize, valueOrFn, options })
|
|
1300
|
+
|
|
1301
|
+
if (treeRef.current === null) {
|
|
1302
|
+
treeRef.current = new FenwickMapTree(validSize, valueOrFn, options)
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const tree = treeRef.current
|
|
1306
|
+
const prev = prevPropsRef.current
|
|
1307
|
+
|
|
1308
|
+
// Detect changes
|
|
1309
|
+
const sizeChanged = prev.size !== validSize
|
|
1310
|
+
const valueOrFnChanged = prev.valueOrFn !== valueOrFn
|
|
1311
|
+
// options は参照比較ではなく sampleRange の値で比較する。
|
|
1312
|
+
// 非メモ化のインライン options でも sampleRange が同一なら reset を起こさない
|
|
1313
|
+
// (計測済み delta を毎レンダーで破棄しないため)。
|
|
1314
|
+
const optionsChanged = sampleRangeChanged(prev.options, options)
|
|
1315
|
+
|
|
1316
|
+
// 旧 size が 0 の木は baseValue が未確立 (0) のまま。増分伸長 (changeSize) では
|
|
1317
|
+
// total = 0 * N = 0 で固着するため、0 -> N の伸長は resetOnValueFnChange の設定に
|
|
1318
|
+
// 関わらず常に reset (サンプリング込み) へ倒す。sizeChanged のみの分岐と前提を統一する。
|
|
1319
|
+
const growingFromZero = prev.size === 0 && validSize > 0
|
|
1320
|
+
|
|
1321
|
+
if (valueOrFnChanged || optionsChanged) {
|
|
1322
|
+
const resetRequested = options?.resetOnValueFnChange ?? true
|
|
1323
|
+
// If options changed, we essentially must reset because options control initialization behavior (sampling, etc).
|
|
1324
|
+
// If only valueOrFn changed, we respect the flag.
|
|
1325
|
+
// Growing from size 0 must always reset (baseValue was never established).
|
|
1326
|
+
const shouldReset = optionsChanged || (valueOrFnChanged && resetRequested) || growingFromZero
|
|
1327
|
+
|
|
1328
|
+
if (shouldReset) {
|
|
1329
|
+
// Full reset if fundamental properties change
|
|
1330
|
+
if (options?.debug) {
|
|
1331
|
+
Logger.debug("[useFenwickMapTree] reset", { valueOrFnChanged, optionsChanged })
|
|
1332
|
+
}
|
|
1333
|
+
tree.reset(validSize, valueOrFn, options)
|
|
1334
|
+
} else {
|
|
1335
|
+
// valueFn changed but reset is disabled
|
|
1336
|
+
// If size also changed, we need to apply resize before setting the new valueFn
|
|
1337
|
+
if (sizeChanged) {
|
|
1338
|
+
if (options?.debug) {
|
|
1339
|
+
Logger.debug("[useFenwickMapTree] resize (with valueFn change)", { from: prev.size, to: validSize })
|
|
1340
|
+
}
|
|
1341
|
+
tree.changeSize(validSize)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (options?.debug) {
|
|
1345
|
+
Logger.debug("[useFenwickMapTree] setValueFn (no reset)", { valueOrFnChanged })
|
|
1346
|
+
}
|
|
1347
|
+
tree.setValueFn(valueOrFn, { reset: false })
|
|
1348
|
+
}
|
|
1349
|
+
prevPropsRef.current = { size: validSize, valueOrFn, options }
|
|
1350
|
+
} else if (sizeChanged) {
|
|
1351
|
+
// Resize optimization if only size changes
|
|
1352
|
+
if (options?.debug) {
|
|
1353
|
+
Logger.debug("[useFenwickMapTree] resize", { from: prev.size, to: validSize })
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
if (growingFromZero) {
|
|
1357
|
+
// When growing from 0, baseValue is likely 0 (default).
|
|
1358
|
+
// We must reset to correctly initialize baseValue via sampling the new valueFn results.
|
|
1359
|
+
tree.reset(validSize, valueOrFn, options)
|
|
1360
|
+
} else {
|
|
1361
|
+
tree.changeSize(validSize)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
prevPropsRef.current = { size: validSize, valueOrFn, options }
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return tree
|
|
1368
|
+
}
|