@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.
Files changed (40) hide show
  1. package/README.md +20 -13
  2. package/dist/ScrollPane.d.cts +2 -0
  3. package/dist/ScrollPane.d.ts +2 -0
  4. package/dist/ScrollPane.d.ts.map +1 -1
  5. package/dist/VirtualScroll.d.cts +2 -0
  6. package/dist/VirtualScroll.d.ts +2 -0
  7. package/dist/VirtualScroll.d.ts.map +1 -1
  8. package/dist/index.cjs +1 -1
  9. package/dist/index.d.cts +6 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1115 -1112
  13. package/dist/styles/virtualscroll.css +1 -1
  14. package/dist/styles/virtualscroll.standalone.css +3 -0
  15. package/package.json +6 -4
  16. package/src/ScrollBar.spec.tsx +620 -0
  17. package/src/ScrollBar.tsx +1397 -0
  18. package/src/ScrollPane.spec.tsx +482 -0
  19. package/src/ScrollPane.tsx +913 -0
  20. package/src/TapScrollCircle.spec.tsx +275 -0
  21. package/src/TapScrollCircle.tsx +363 -0
  22. package/src/VirtualScroll.spec.ts +623 -0
  23. package/src/VirtualScroll.tsx +1891 -0
  24. package/src/cli.server.spec.ts +137 -0
  25. package/src/cli.server.ts +110 -0
  26. package/src/index.ts +23 -0
  27. package/src/logger.spec.ts +128 -0
  28. package/src/logger.ts +229 -0
  29. package/src/styles/components.entry.css +9 -0
  30. package/src/styles/standalone.entry.css +11 -0
  31. package/src/styles/virtualscroll.css +296 -0
  32. package/src/tapScrollCircleSampleVisual.tsx +74 -0
  33. package/src/useFenwickMapTree.huge.spec.ts +388 -0
  34. package/src/useFenwickMapTree.spec.ts +1518 -0
  35. package/src/useFenwickMapTree.ts +1368 -0
  36. package/src/useHeightCache.ts +32 -0
  37. package/src/useLruCache.spec.ts +382 -0
  38. package/src/useLruCache.ts +301 -0
  39. package/src/utils.spec.ts +39 -0
  40. package/src/utils.ts +16 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * @module useLruCache
3
+ * @description This module provides a `useLruCache` hook, which implements a Least Recently Used (LRU) cache.
4
+ * It uses a Map for O(1) key-based lookups and a doubly linked list to maintain the order of usage, ensuring that get, set, and eviction operations are all efficient.
5
+ *
6
+ * @description このモジュールは、Least Recently Used (LRU) キャッシュを実装した `useLruCache` フックを提供します。
7
+ * Map を使用した O(1) のキー検索と、使用順序を維持するための双方向連結リストを組み合わせることで、get, set, および削除操作のすべてを効率的に行います。
8
+ */
9
+ import { useCallback, useEffect, useMemo, useRef } from "react"
10
+ import { Logger } from "./logger"
11
+
12
+ /**
13
+ * @class DoublyLinkedListNode
14
+ * @description Represents a node in the doubly linked list. It holds a key-value pair and references to the previous and next nodes.
15
+ * @description 双方向連結リスト内のノードを表します。キーと値のペア、および前後のノードへの参照を保持します。
16
+ */
17
+ class DoublyLinkedListNode<K, V> {
18
+ key: K
19
+ value: V
20
+ prev: DoublyLinkedListNode<K, V> | null = null
21
+ next: DoublyLinkedListNode<K, V> | null = null
22
+
23
+ constructor(key: K, value: V) {
24
+ // キーを初期化
25
+ this.key = key
26
+ // 値を初期化
27
+ this.value = value
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @class DoublyLinkedList
33
+ * @description Implements a doubly linked list to maintain the usage order of cache items.
34
+ * The tail of the list represents the most recently used item, and the head represents the least recently used item.
35
+ * @description キャッシュアイテムの使用順序を維持するための双方向連結リストを実装します。
36
+ * リストの末尾が最も最近使用されたアイテムを、先頭が最も最近使用されていないアイテムを示します。
37
+ */
38
+ class DoublyLinkedList<K, V> {
39
+ private head: DoublyLinkedListNode<K, V> | null = null
40
+ private tail: DoublyLinkedListNode<K, V> | null = null
41
+
42
+ /**
43
+ * @method addToTail
44
+ * @description Adds a node to the tail of the list, marking it as the most recently used.
45
+ * @description ノードをリストの末尾に追加し、最も最近使用されたものとしてマークします。
46
+ * @param {DoublyLinkedListNode<K, V>} node - The node to add.
47
+ */
48
+ addToTail(node: DoublyLinkedListNode<K, V>) {
49
+ // リストが既に末尾ノードを持っているかチェック
50
+ if (this.tail) {
51
+ // 既存の末尾ノードの次に新しいノードをリンク
52
+ this.tail.next = node
53
+ // 新しいノードの前に既存の末尾ノードをリンク
54
+ node.prev = this.tail
55
+ // リストの末尾を新しいノードに更新
56
+ this.tail = node
57
+ } else {
58
+ // リストが空の場合、新しいノードが先頭かつ末尾となる
59
+ this.head = this.tail = node
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @method remove
65
+ * @description Removes a given node from the list.
66
+ * @description 指定されたノードをリストから削除します。
67
+ * @param {DoublyLinkedListNode<K, V>} node - The node to remove.
68
+ */
69
+ remove(node: DoublyLinkedListNode<K, V>) {
70
+ // ノードに前のノードが存在する場合
71
+ if (node.prev) {
72
+ // 前のノードの `next` を、現在のノードの `next` につなぎ直す
73
+ node.prev.next = node.next
74
+ } else {
75
+ // ノードが先頭の場合、リストの先頭を次のノードに更新
76
+ this.head = node.next
77
+ }
78
+
79
+ // ノードに次のノードが存在する場合
80
+ if (node.next) {
81
+ // 次のノードの `prev` を、現在のノードの `prev` につなぎ直す
82
+ node.next.prev = node.prev
83
+ } else {
84
+ // ノードが末尾の場合、リストの末尾を前のノードに更新
85
+ this.tail = node.prev
86
+ }
87
+
88
+ // 削除されたノードの参照をクリア
89
+ node.prev = null
90
+ node.next = null
91
+ }
92
+
93
+ /**
94
+ * @method removeHead
95
+ * @description Removes and returns the head of the list, which is the least recently used item.
96
+ * @description リストの先頭(最も最近使用されていないアイテム)を削除して返します。
97
+ * @returns {DoublyLinkedListNode<K, V> | null} The removed head node, or null if the list is empty.
98
+ */
99
+ removeHead(): DoublyLinkedListNode<K, V> | null {
100
+ // 現在の先頭ノードを保持
101
+ const head = this.head
102
+ // 先頭ノードが存在する場合のみ処理
103
+ if (head) {
104
+ // 先頭ノードをリストから削除
105
+ this.remove(head)
106
+ }
107
+ // 削除したノード(または null)を返す
108
+ return head
109
+ }
110
+
111
+ /**
112
+ * @method moveToTail
113
+ * @description Moves an existing node to the tail of the list to mark it as most recently used.
114
+ * @description 既存のノードをリストの末尾に移動し、最も最近使用されたものとしてマークします。
115
+ * @param {DoublyLinkedListNode<K, V>} node - The node to move.
116
+ */
117
+ moveToTail(node: DoublyLinkedListNode<K, V>) {
118
+ // ノードを現在の位置から一旦削除
119
+ this.remove(node)
120
+ // ノードをリストの末尾に再追加
121
+ this.addToTail(node)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @hook useLruCache
127
+ * @description A custom hook that provides a Least Recently Used (LRU) cache of a specified capacity.
128
+ * It returns an object with methods to interact with the cache (`get`, `set`, `has`, `remove`, `clear`).
129
+ * `Infinity` is treated as an unbounded cache (entries are stored and never evicted),
130
+ * while `NaN` or a negative capacity is invalid (nothing is stored, existing entries are cleared, and a warning is logged).
131
+ * A fractional capacity is normalized with `Math.floor` so that both the set path and the pruning
132
+ * effect share the same effective capacity.
133
+ * @description 指定された容量の LRU (Least Recently Used) キャッシュを提供するカスタムフック。
134
+ * キャッシュを操作するためのメソッド (`get`, `set`, `has`, `remove`, `clear`) を持つオブジェクトを返します。
135
+ * `Infinity` は無制限キャッシュ (格納し、evict しない)、`NaN` / 負値は無効容量
136
+ * (格納せず、既存エントリも全消去し、警告をログ) として扱う仕様。
137
+ * 小数の capacity は `Math.floor` で正規化し、set 経路と刈り込み effect が同じ実効容量を共有する。
138
+ * @param {number} capacity - The maximum capacity of the cache. `Infinity` means unbounded; `NaN` / negative values are invalid; fractional values are floored.
139
+ * @returns {{ get: (key: K) => V | undefined, set: (key: K, value: V) => void, has: (key: K) => boolean, remove: (key: K) => void, clear: () => void }} An object with methods to interact with the cache.
140
+ */
141
+ export function useLruCache<K, V>(capacity: number) {
142
+ // キャッシュストレージ。キーと双方向連結リストのノードをマッピングする (O(1) アクセス用)
143
+ const cache = useRef(new Map<K, DoublyLinkedListNode<K, V>>())
144
+ // 使用順序を管理する双方向連結リスト (先頭が最も古く、末尾が最も新しい)
145
+ const list = useRef(new DoublyLinkedList<K, V>())
146
+
147
+ // 小数の capacity は floor で正規化し、set の eviction 判定と刈り込み effect が同じ実効容量を使う。
148
+ // Math.floor は NaN → NaN、Infinity → Infinity、負値 → 負値のまま保つため、無効容量/無制限の判定は影響を受けない。
149
+ // Fractional capacities are floored so the set path and the pruning effect share one effective capacity.
150
+ const normalizedCapacity = Math.floor(capacity)
151
+
152
+ // 最新の capacity を保持する ref。
153
+ // set などのコールバックがこの ref を参照することで、識別子を安定 (空 deps) に保ちつつ
154
+ // 常に現在の capacity で eviction 判定でき、capacity 変更時に stale なクロージャが公開される問題を防ぐ。
155
+ // レンダーフェーズでは書き込まず、下の useEffect でコミット後に同期する (concurrent rendering で
156
+ // 破棄されたレンダーの値が ref に残留する tearing を避けるため)。
157
+ // The ref keeps callbacks stable while always reading the latest capacity, avoiding stale closures on
158
+ // capacity change. It is synchronized in the effect below (not during render) so a discarded render
159
+ // can never leave an uncommitted capacity behind.
160
+ const capacityRef = useRef(normalizedCapacity)
161
+
162
+ useEffect(() => {
163
+ // コミット済みの capacity を ref に同期する (set はこの値で eviction 判定する)
164
+ capacityRef.current = normalizedCapacity
165
+ // NaN / 負の capacity は無効容量。既存エントリも全消去し、capacity 変更ごとに 1 回だけ警告する
166
+ // Invalid capacity (NaN or negative): clear existing entries and warn once per capacity change.
167
+ if (Number.isNaN(normalizedCapacity) || normalizedCapacity < 0) {
168
+ Logger.warn(`useLruCache: invalid capacity ${capacity}; the cache will not store any entries.`)
169
+ // 警告文言「何も保持しない」と挙動を一致させるため、既存エントリを消去する
170
+ cache.current.clear()
171
+ list.current = new DoublyLinkedList<K, V>()
172
+ return
173
+ }
174
+ // capacity が Infinity の場合は「無制限キャッシュ」として刈り込みを行わない
175
+ // Infinity means an unbounded cache: never prune.
176
+ if (!Number.isFinite(normalizedCapacity)) {
177
+ return
178
+ }
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
+ }
187
+ }
188
+ }, [capacity, normalizedCapacity])
189
+
190
+ /**
191
+ * @function get
192
+ * @description Retrieves a value from the cache for a given key. If found, the item is marked as most recently used.
193
+ * @description 指定されたキーの値を取得します。アイテムが見つかった場合、それは最も最近使用されたものとしてマークされます。
194
+ * @param {K} key - The key of the item to retrieve.
195
+ * @returns {V | undefined} The cached value, or undefined if the key does not exist.
196
+ */
197
+ const get = useCallback((key: K): V | undefined => {
198
+ // Map からノードを効率的に検索
199
+ const node = cache.current.get(key)
200
+ // ノードが存在する場合
201
+ if (node) {
202
+ // アクセスされたノードを使用順序リストの末尾に移動
203
+ list.current.moveToTail(node)
204
+ // ノードの値を返す
205
+ return node.value
206
+ }
207
+ // ノードが存在しない場合は undefined を返す
208
+ return undefined
209
+ }, [])
210
+
211
+ /**
212
+ * @function set
213
+ * @description Adds or updates a key-value pair in the cache. If the cache is full, it evicts the least recently used item.
214
+ * @description キーと値のペアをキャッシュに追加または更新します。キャッシュが満杯の場合、最も最近使用されていないアイテムが削除されます。
215
+ * @param {K} key - The key of the item to set.
216
+ * @param {V} value - The value of the item to set.
217
+ */
218
+ const set = useCallback((key: K, value: V) => {
219
+ // 現在の実効 capacity (floor 正規化済み) を ref から読み取る (識別子安定化のため deps に含めない)
220
+ const capacity = capacityRef.current
221
+ // NaN / 0 以下は無効容量として格納しない (警告は capacity 変更時の effect が発行する)。
222
+ // capacity = Infinity は「無制限 (evict しない)」として格納を許可する。
223
+ // NaN / <=0 are invalid capacities: skip caching (the effect above logs the warning).
224
+ // capacity = Infinity means an unbounded cache: storing is allowed and eviction never triggers.
225
+ if (Number.isNaN(capacity) || capacity <= 0) {
226
+ return
227
+ }
228
+ // キャッシュ内にキーが既に存在するかチェック
229
+ let node = cache.current.get(key)
230
+
231
+ if (node) {
232
+ // キーが存在する場合、値を更新
233
+ node.value = value
234
+ // アクセスされたので、使用順序リストの末尾に移動
235
+ list.current.moveToTail(node)
236
+ } else {
237
+ // 新しいノードを追加する場合
238
+ // キャッシュが容量上限に達しているかチェック
239
+ if (cache.current.size >= capacity) {
240
+ // 容量オーバーの場合、最も最近使用されていないノード (リストの先頭) を取得して削除
241
+ const lruNode = list.current.removeHead()
242
+ if (lruNode) {
243
+ // Map からも対応するエントリを削除
244
+ cache.current.delete(lruNode.key)
245
+ }
246
+ }
247
+ // 新しいノードを作成
248
+ node = new DoublyLinkedListNode(key, value)
249
+ // 新しいノードを Map に登録
250
+ cache.current.set(key, node)
251
+ // 新しいノードを使用順序リストの末尾に追加
252
+ list.current.addToTail(node)
253
+ }
254
+ }, [])
255
+
256
+ /**
257
+ * @function has
258
+ * @description Checks if a key exists in the cache.
259
+ * @description 指定されたキーがキャッシュ内に存在するかどうかを確認します。
260
+ * @param {K} key - The key to check.
261
+ * @returns {boolean} True if the key exists, false otherwise.
262
+ */
263
+ const has = useCallback((key: K): boolean => {
264
+ // Map にキーが存在するかどうかを O(1) でチェック
265
+ return cache.current.has(key)
266
+ }, [])
267
+
268
+ /**
269
+ * @function remove
270
+ * @description Deletes a specific entry from the cache and updates the usage order list.
271
+ * @description キャッシュから特定のエントリを削除し、使用順リストを更新します。
272
+ * @param {K} key - The key to delete.
273
+ */
274
+ const remove = useCallback((key: K) => {
275
+ const node = cache.current.get(key)
276
+ if (!node) {
277
+ return
278
+ }
279
+ list.current.remove(node)
280
+ cache.current.delete(key)
281
+ }, [])
282
+
283
+ /**
284
+ * @function clear
285
+ * @description Clears the entire cache, removing all entries.
286
+ * @description キャッシュを完全にクリアし、すべてのエントリを削除します。
287
+ */
288
+ const clear = useCallback(() => {
289
+ // Map をクリア
290
+ cache.current.clear()
291
+ // 双方向連結リストを新しく作成してリセット
292
+ list.current = new DoublyLinkedList<K, V>()
293
+ }, [])
294
+
295
+ // キャッシュ操作用の API を安定参照で返す。
296
+ // get/set/has/remove/clear はすべて安定 (空 deps) なので、この handler もマウント後に再生成されず、
297
+ // useState + useEffect による余計な再レンダーを回避する。
298
+ // Returns a stable API object; since all callbacks are stable, this handler is created once and
299
+ // never triggers the extra re-render the previous useState/useEffect pattern caused.
300
+ return useMemo(() => ({ get, set, has, remove, clear }), [get, set, has, remove, clear])
301
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @fileoverview Tests for utils.
3
+ * utils のテスト。
4
+ */
5
+
6
+ import { describe, expect, it } from "vitest"
7
+ import { minmax } from "./utils"
8
+
9
+ describe("minmax", () => {
10
+ it("値を [min, max] にクランプする", () => {
11
+ expect(minmax(5, 0, 10)).toBe(5)
12
+ expect(minmax(-3, 0, 10)).toBe(0)
13
+ expect(minmax(42, 0, 10)).toBe(10)
14
+ })
15
+
16
+ it("通常の min <= max では従来と同一結果を返す(境界含む)", () => {
17
+ // 従来実装 Math.min(max, Math.max(min, value)) と一致することを保証。
18
+ for (const [value, min, max] of [
19
+ [5, 0, 10],
20
+ [0, 0, 10],
21
+ [10, 0, 10],
22
+ [-1, 0, 10],
23
+ [11, 0, 10],
24
+ [-100, -50, 50],
25
+ [7, 7, 7],
26
+ ] as const) {
27
+ expect(minmax(value, min, max)).toBe(Math.min(max, Math.max(min, value)))
28
+ }
29
+ })
30
+
31
+ it("退化入力 min > max でも下限 (min) を下回らない", () => {
32
+ // 回帰: 旧実装 Math.min(max, Math.max(min, value)) は max を返し
33
+ // min を割り込んでいた (例: 空リストで safeIndex=-1 が漏れる)。
34
+ expect(minmax(5, 0, -1)).toBe(0)
35
+ expect(minmax(0, 0, -1)).toBe(0)
36
+ expect(minmax(-5, 0, -1)).toBe(0)
37
+ expect(minmax(100, 3, 1)).toBe(3)
38
+ })
39
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Clamps a value between a minimum and maximum value.
3
+ *
4
+ * 指定された値が最小値と最大値の範囲内に収まるように調整。
5
+ *
6
+ * min > max のような退化した入力でも、下限 (min) を下回る値を返さない。
7
+ * (Math.min を内側に置くことで、min > max のときは常に min を返す。)
8
+ *
9
+ * @param value The value to clamp. / クランプする値。
10
+ * @param min The minimum value. / 最小値。
11
+ * @param max The maximum value. / 最大値。
12
+ * @returns The clamped value. / クランプされた値。
13
+ */
14
+ export const minmax = (value: number, min: number, max: number): number => {
15
+ return Math.max(min, Math.min(max, value))
16
+ }