@aiquants/virtualscroll 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Two-axis rAF driver hook for the grid-owned unified tap circle (v3.6.0). Transplants the
3
+ * ScrollBar tap-loop semantics per axis: residual accumulators with candidate-at-edge boundary
4
+ * reinforcement, per-axis direction-flip residual discard, per-frame immobility stop rule,
5
+ * extent-growth re-arm, and the fail-closed cancel trio.
6
+ * グリッド所有の統合タップサークル用 2 軸 rAF ドライバフック (v3.6.0)。ScrollBar タップループの
7
+ * 意味論を軸別に移植: 端候補補強つき残差アキュムレータ・軸別方向反転残差破棄・毎フレーム
8
+ * immobile 停止則・延長成長の再武装・fail-closed のキャンセル 3 系。
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from "react"
12
+ import { computeTapScrollVelocity, type TapScrollAxisSpeedParams } from "./computeTapScrollVelocity.ts"
13
+ import { TAP_SCROLL_CANCEL_EVENT, TAP_SCROLL_MAX_FRAME_DELTA_SECONDS } from "./ScrollBar.tsx"
14
+ import type { TapScrollCircleDragState, TapScrollCircleHandle } from "./TapScrollCircle.tsx"
15
+ import { minmax } from "./utils.ts"
16
+
17
+ /**
18
+ * Default corner-relative offset (px) for BOTH grid tap-circle axes — the reachability arm (R)
19
+ * of the three-arm placement law (plan §5). Derivation: full speed needs
20
+ * `maxVisualDistance = 240 px` of advancing pull toward the far screen edge, which is a hard
21
+ * stop on touch (`touch-action: none`). With `off = −200` the disc center sits
22
+ * `sbw − off − size/2 = 12 + 200 − 20 = 192 px` inboard of each far root edge, giving a
23
+ * worst-case (fullscreen, flush) reachable pull fraction `n = 192/240 = 0.80` (`0.66` with the
24
+ * ~34 px OS gesture-navigation zone) ≈ 78 % / 63 % of full-pull speed under the default curve
25
+ * (`n^1.1`). K = 120 (= −200 − (−80)) is half the pull range — the smallest K keeping the
26
+ * nav-zone envelope ≥ ~2/3. Deliberately a LITERAL: it must NOT track a consumer-supplied
27
+ * `maxVisualDistance` (coupling two knobs would make one silently move the other), and
28
+ * shrinking `maxVisualDistance` instead is forbidden (it changes the speed law's `maxDistance`
29
+ * input and breaks the T1 identity anchor).
30
+ * グリッドタップサークル両軸のコーナー相対既定オフセット (px) — 3 アーム配置則の到達性アーム
31
+ * (R)。導出: 全速には前進方向へ 240 px の引きが要り、タッチではスクリーン端がハードストップ。
32
+ * −200 で中心は各遠端から 192 px 内側 = 最悪ケース引き分率 0.80 (ナビ帯込み 0.66)。意図的な
33
+ * リテラル — 消費側 `maxVisualDistance` へ連動させない (ノブ連動の登記済み禁止)。
34
+ */
35
+ export const GRID_TAP_CIRCLE_DEFAULT_OFFSET = -200
36
+
37
+ /** Per-axis driver bookkeeping (residual + last travel direction). / 軸別のドライバ内部状態 (残差 + 直前進行方向)。 */
38
+ type AxisDriveState = {
39
+ /** Unconsumed scroll amount carried across frames. / フレームを跨いで持ち越す未消化スクロール量。 */
40
+ residual: number
41
+ /** Last non-zero travel direction (flip discards the residual). / 直前の非ゼロ進行方向 (反転で残差破棄)。 */
42
+ direction: -1 | 0 | 1
43
+ }
44
+
45
+ /** Idle drag state (the TapScrollCircle INITIAL_STATE shape — component-private, mirrored here like ScrollBar does). / 待機ドラッグ状態 (TapScrollCircle の INITIAL_STATE と同形 — ScrollBar と同じくコンポーネント私有形の写し)。 */
46
+ const GRID_TAP_INITIAL_STATE: TapScrollCircleDragState = { active: false, offsetX: 0, offsetY: 0, distance: 0, direction: 0, pointerId: null }
47
+
48
+ /** Parameters for `useGridTapScroll` (read live through a params ref — identity-stability is not required of callers). / `useGridTapScroll` のパラメータ (params ref 経由で毎フレーム最新を読む — 呼出し側に identity 安定を要求しない)。 */
49
+ export type UseGridTapScrollParams = {
50
+ /** Whether the unified circle is live (resolved `enabled` ∧ `gridScrollable`). / 統合サークルが生きているか (解決済み `enabled` ∧ `gridScrollable`)。 */
51
+ enabled: boolean
52
+ /** Shared pull range (= `max(maxVisualDistance, 1)`). / 共有引き範囲 (= `max(maxVisualDistance, 1)`)。 */
53
+ maxDistance: number
54
+ /** Horizontal speed parameters (§4.1 Pₓ). / 横軸速度パラメータ (§4.1 Pₓ)。 */
55
+ xSpeedParams: TapScrollAxisSpeedParams
56
+ /** Vertical speed parameters (§4.1 P_y). / 縦軸速度パラメータ (§4.1 P_y)。 */
57
+ ySpeedParams: TapScrollAxisSpeedParams
58
+ /** The grid's x apply seam (absolute position in, clamped applied position out). / グリッドの x 適用シーム (絶対位置 → クランプ後位置)。 */
59
+ applyHxRef: { readonly current: (next: number) => number }
60
+ /** Synchronous-fresh hx read. / hx の同期・最新読み。 */
61
+ getHx: () => number
62
+ /** The ONE extracted x clamp law (`getMaxHx` SSOT — §3.5-7). / 唯一抽出の x クランプ則 (`getMaxHx` SSOT — §3.5-7)。 */
63
+ getMaxHx: () => number
64
+ /** y apply seam: wraps `scrollBy(delta)` AND syncs `vyRef.current = applied` before returning (§3.5-8 — NOT a bare handle passthrough). / y 適用シーム: `scrollBy(delta)` をラップし `vyRef.current = applied` を同期してから返す (§3.5-8 — 素のハンドル素通しではない)。 */
65
+ applyVy: (delta: number) => number
66
+ /** Synchronous-fresh vy read. / vy の同期・最新読み。 */
67
+ getVy: () => number
68
+ /** y extent read through the embedded handle's freshness channel (never re-derived). / 埋め込みハンドルの鮮度チャネル経由の y 延長 (再導出禁止)。 */
69
+ getMaxVy: () => number
70
+ /** Pending column anchor — cleared ONLY on frames whose APPLIED hx delta ≠ 0 (§3.5-5). / 保留列アンカー — 適用済み hx デルタ ≠ 0 のフレームのみ解除 (§3.5-5)。 */
71
+ pendingColAnchorRef: { current: unknown }
72
+ /** x extent-growth freshness key (columnWindow / total width — §3.5-3). / x 延長成長の鮮度キー (columnWindow / 総幅 — §3.5-3)。 */
73
+ xExtentFreshness: unknown
74
+ /** y extent-growth freshness key (the vertical range state — §3.5-3). / y 延長成長の鮮度キー (縦レンジ state — §3.5-3)。 */
75
+ yExtentFreshness: unknown
76
+ }
77
+
78
+ /** Result of `useGridTapScroll`. / `useGridTapScroll` の返り値。 */
79
+ export type UseGridTapScrollResult = {
80
+ /** Whether a tap drag is engaged (drives the opacity law). / タップドラッグ係合中か (opacity 則を駆動)。 */
81
+ isTapActive: boolean
82
+ /** onDragChange handler for the grid's TapScrollCircle. / グリッドの TapScrollCircle へ渡す onDragChange。 */
83
+ handleTapCircleDragChange: (state: TapScrollCircleDragState) => void
84
+ /** Handle ref for the grid's TapScrollCircle (reset + containment reads). / グリッドの TapScrollCircle 用ハンドル ref (リセットと内包判定)。 */
85
+ tapCircleHandleRef: React.RefObject<TapScrollCircleHandle | null>
86
+ }
87
+
88
+ /**
89
+ * Owns the unified tap circle's drag state and the two-axis rAF integration loop.
90
+ * 統合タップサークルのドラッグ状態と 2 軸 rAF 積分ループを所有するフック。
91
+ *
92
+ * Per-frame semantics (plan §8.3 — implemented verbatim):
93
+ *
94
+ * ```text
95
+ * dt = min(max((t − last)/1000, 0), TAP_SCROLL_MAX_FRAME_DELTA_SECONDS); skip if dt ≤ 0
96
+ * (vx, vy) = computeTapScrollVelocity({ offsetX, offsetY, maxDistance, x: Pₓ, y: P_y })
97
+ * x: request = vx·dt → residual-apply via applyHx; applied hx delta ≠ 0 clears the col anchor
98
+ * y: request = vy·dt → residual-apply via applyVy (scrollBy + vyRef sync)
99
+ * immobile_a = (maxExtent_a ≤ 0) || atBoundary_a || (o_a === 0) // atBoundary_a from THIS
100
+ * // frame's apply — never latched
101
+ * stop iff !active || direction === 0 || (immobile_x && immobile_y)
102
+ * ```
103
+ *
104
+ * @param params Injected seams (read live through a ref). / 注入シーム (ref 経由で最新を読む)。
105
+ * @returns Drag-state wiring for the grid's circle. / グリッドサークル用のドラッグ結線。
106
+ */
107
+ export const useGridTapScroll = (params: UseGridTapScrollParams): UseGridTapScrollResult => {
108
+ // パラメータは ref 経由で毎フレーム最新を読む (既存フック配線 effect に deps を足さない規律)
109
+ const paramsRef = useRef(params)
110
+ paramsRef.current = params
111
+
112
+ const tapDragStateRef = useRef<TapScrollCircleDragState>(GRID_TAP_INITIAL_STATE)
113
+ const tapCircleHandleRef = useRef<TapScrollCircleHandle | null>(null)
114
+ const [isTapActive, setIsTapActive] = useState(false)
115
+ const frameRef = useRef<number | null>(null)
116
+ const lastTimestampRef = useRef<number | null>(null)
117
+ const xDriveRef = useRef<AxisDriveState>({ residual: 0, direction: 0 })
118
+ const yDriveRef = useRef<AxisDriveState>({ residual: 0, direction: 0 })
119
+
120
+ /**
121
+ * Stops the rAF loop and clears per-axis integration state.
122
+ * rAF ループを停止し、軸別積分状態をクリアする処理。
123
+ */
124
+ const stopLoop = useCallback(() => {
125
+ if (frameRef.current !== null) {
126
+ window.cancelAnimationFrame(frameRef.current)
127
+ frameRef.current = null
128
+ }
129
+ lastTimestampRef.current = null
130
+ xDriveRef.current = { residual: 0, direction: 0 }
131
+ yDriveRef.current = { residual: 0, direction: 0 }
132
+ }, [])
133
+
134
+ /**
135
+ * Applies one axis's per-frame request through its residual accumulator and reports the
136
+ * applied delta plus THIS frame's boundary flag (candidate-at-edge ∧ no movement, or exact
137
+ * edge arrival). The flag is never stored across frames (§3.5-2 freshness rule).
138
+ * 1 軸の毎フレーム要求を残差アキュムレータ経由で適用し、適用デルタと**このフレームの**
139
+ * 境界フラグ (端候補 ∧ 不動、または端値到達) を返す処理。フラグはフレームを跨いで保存
140
+ * しない (§3.5-2 の鮮度規律)。
141
+ *
142
+ * @param drive Axis bookkeeping (mutated in place). / 軸別内部状態 (この場で更新)。
143
+ * @param velocity Signed axis velocity (px/s). / 符号付き軸速度 (px/s)。
144
+ * @param dt Frame delta (s). / フレーム時間 (秒)。
145
+ * @param getPos Fresh position read. / 位置の最新読み。
146
+ * @param getMax Fresh extent read. / 延長の最新読み。
147
+ * @param applyDelta Applies a delta and returns the applied position. / 差分を適用し適用後位置を返す。
148
+ * @returns Applied delta and the per-frame boundary flag. / 適用デルタと毎フレーム境界フラグ。
149
+ */
150
+ const stepAxis = useCallback((drive: AxisDriveState, velocity: number, dt: number, getPos: () => number, getMax: () => number, applyDelta: (delta: number) => number): { actualDelta: number; atBoundary: boolean } => {
151
+ // 方向反転の残差破棄 (軸別 — ScrollBar の方向転換破棄の成分双子): 逆向き残差を
152
+ // 持ち越すと燃焼し切るまで反転が固着する
153
+ const travelDirection: -1 | 0 | 1 = velocity > 0 ? 1 : velocity < 0 ? -1 : 0
154
+ if (travelDirection !== 0 && travelDirection !== drive.direction) {
155
+ drive.direction = travelDirection
156
+ drive.residual = 0
157
+ }
158
+ drive.residual += velocity * dt
159
+ const maxExtent = Math.max(getMax(), 0)
160
+ // 残差の暴走防止: 全スクロール範囲を超える残差は意味を持たない (ScrollBar と同じ保険)
161
+ if (maxExtent > 0) {
162
+ drive.residual = minmax(drive.residual, -maxExtent, maxExtent)
163
+ }
164
+ const request = drive.residual
165
+ if (request === 0) {
166
+ return { actualDelta: 0, atBoundary: false }
167
+ }
168
+ const before = getPos()
169
+ const appliedPosition = applyDelta(request)
170
+ const actualDelta = appliedPosition - before
171
+ drive.residual -= actualDelta
172
+ // 端候補補強 (ScrollBar applyScrollDelta の双子): 量子化する親では実位置が端値ちょうどに
173
+ // 届かないため、「クランプ前の要求候補が端に達しているのに位置が変わらない」も境界とみなす
174
+ const candidateAtMin = request < 0 && before + request <= 0
175
+ const candidateAtMax = request > 0 && before + request >= maxExtent
176
+ const atBoundary = (request < 0 && (appliedPosition <= 0 || (candidateAtMin && actualDelta === 0))) || (request > 0 && (appliedPosition >= maxExtent || (candidateAtMax && actualDelta === 0)))
177
+ if (atBoundary) {
178
+ drive.residual = 0
179
+ }
180
+ return { actualDelta, atBoundary }
181
+ }, [])
182
+
183
+ /**
184
+ * One integration frame of the two-axis loop.
185
+ * 2 軸ループの 1 積分フレーム。
186
+ */
187
+ const step = useCallback(
188
+ (timestamp: number) => {
189
+ // この rAF ループ意味論は ScrollBar.tsx の tap ループ (stepAutoScroll) と双子 — 片方を直したらもう片方も直すこと (ADR-27)
190
+ const current = paramsRef.current
191
+ const state = tapDragStateRef.current
192
+ if (!state.active || state.direction === 0) {
193
+ stopLoop()
194
+ return
195
+ }
196
+ const lastTimestamp = lastTimestampRef.current ?? timestamp
197
+ const deltaSecondsRaw = Math.max((timestamp - lastTimestamp) / 1000, 0)
198
+ const deltaSeconds = Math.min(deltaSecondsRaw, TAP_SCROLL_MAX_FRAME_DELTA_SECONDS)
199
+ lastTimestampRef.current = timestamp
200
+ if (deltaSeconds <= 0) {
201
+ frameRef.current = window.requestAnimationFrame(step)
202
+ return
203
+ }
204
+ // live state ref から毎フレーム分解 (§4.1 — 方向余弦分解)
205
+ const velocity = computeTapScrollVelocity({ offsetX: state.offsetX, offsetY: state.offsetY, maxDistance: current.maxDistance, x: current.xSpeedParams, y: current.ySpeedParams })
206
+ const xResult = stepAxis(xDriveRef.current, velocity.x, deltaSeconds, current.getHx, current.getMaxHx, (delta) => current.applyHxRef.current(current.getHx() + delta))
207
+ if (xResult.actualDelta !== 0) {
208
+ // 適用済み hx デルタのみが保留列アンカーを解除する (§3.5-5 — バーの要求時規則より厳格:
209
+ // 余弦則下ではほぼ縦のドラッグも常に付随 vx ≠ 0 を運ぶため、要求ゲートでは
210
+ // 右端で休んでいるだけでアンカーが無言脱落する)
211
+ current.pendingColAnchorRef.current = null
212
+ }
213
+ const yResult = stepAxis(yDriveRef.current, velocity.y, deltaSeconds, current.getVy, current.getMaxVy, current.applyVy)
214
+ // 停止則 (§3.5-2): 軸別 immobile を毎フレーム評価。o_a === 0 腕は厳密 (v_a ≡ 0) で、
215
+ // 軸上保持のアイドル空転とコーナー保持空転の両穴を閉じる。片軸境界は他軸の積分を続行
216
+ const immobileX = current.getMaxHx() <= 0 || xResult.atBoundary || state.offsetX === 0
217
+ const immobileY = current.getMaxVy() <= 0 || yResult.atBoundary || state.offsetY === 0
218
+ if (immobileX && immobileY) {
219
+ // パーク: onDragChange は毎 pointermove 発火 + 冪等 start なので、角度が付けば再開する
220
+ stopLoop()
221
+ return
222
+ }
223
+ frameRef.current = window.requestAnimationFrame(step)
224
+ },
225
+ [stopLoop, stepAxis],
226
+ )
227
+
228
+ /**
229
+ * Starts the loop if it is not already scheduled (idempotent — the ScrollBar twin).
230
+ * 未予約ならループを開始する処理 (冪等 — ScrollBar の双子)。
231
+ */
232
+ const startLoop = useCallback(() => {
233
+ if (frameRef.current === null) {
234
+ lastTimestampRef.current = null
235
+ xDriveRef.current = { residual: 0, direction: 0 }
236
+ yDriveRef.current = { residual: 0, direction: 0 }
237
+ frameRef.current = window.requestAnimationFrame(step)
238
+ }
239
+ }, [step])
240
+
241
+ /**
242
+ * Resets the drag state, the circle, and the loop (the fail-closed reset target).
243
+ * ドラッグ状態・サークル・ループをリセットする処理 (fail-closed のリセット着地点)。
244
+ */
245
+ const resetTapScroll = useCallback(() => {
246
+ tapDragStateRef.current = { ...GRID_TAP_INITIAL_STATE }
247
+ setIsTapActive(false)
248
+ tapCircleHandleRef.current?.reset()
249
+ stopLoop()
250
+ }, [stopLoop])
251
+
252
+ /**
253
+ * Receives drag-state updates from the circle and starts/stops the loop.
254
+ * サークルからのドラッグ状態更新を受け、ループを開始 / 停止する処理。
255
+ */
256
+ const handleTapCircleDragChange = useCallback(
257
+ (state: TapScrollCircleDragState) => {
258
+ tapDragStateRef.current = state
259
+ setIsTapActive(state.active)
260
+ if (state.active && state.direction !== 0) {
261
+ startLoop()
262
+ } else {
263
+ stopLoop()
264
+ }
265
+ },
266
+ [startLoop, stopLoop],
267
+ )
268
+
269
+ const { enabled, xExtentFreshness, yExtentFreshness } = params
270
+
271
+ /**
272
+ * Re-arms a boundary-parked loop when either axis's extent grows under a held finger.
273
+ * 保持中にどちらかの軸の延長が伸びたとき、境界パーク中のループを張り直す副作用。
274
+ *
275
+ * ❗ ループは境界 / immobile で自分を止め、`startLoop()` はポインタイベントからしか呼ばれ
276
+ * ない。無限スクロール / ストリーミング取得では指を離すまで動かない状態になるため、
277
+ * グリッド自身の鮮度チャネル (x: columnWindow / 総幅、y: 縦レンジ state) をキーに再開する
278
+ * (ScrollBar の contentSize / viewportSize 再武装の 2 軸双子)。冪等 start のため多重起動なし。
279
+ *
280
+ * 目的: 延長成長時の再前進 (§3.5-3)。
281
+ * 依存関係: [xExtentFreshness, yExtentFreshness, startLoop]
282
+ * クリーンアップ: 不要 (停止はループ自身とアンマウント副作用が担う)。
283
+ */
284
+ useEffect(() => {
285
+ const state = tapDragStateRef.current
286
+ if (!state.active || state.direction === 0) {
287
+ return
288
+ }
289
+ startLoop()
290
+ }, [xExtentFreshness, yExtentFreshness, startLoop])
291
+
292
+ /**
293
+ * Cancel path (a): the package-wide cancel event — UNCONDITIONAL reset.
294
+ * キャンセル系 (a): パッケージ全域のキャンセルイベント — 無条件リセット。
295
+ *
296
+ * ❗ 出荷済み hbar 意味論の継承: `ariaControls` を持たないバーはページ全域のキャンセルに
297
+ * 反応する (paneId フィルタなし)。統合サークルも一様に無条件 = 厳密 fail-closed。
298
+ *
299
+ * 目的: コンテンツパン等の外部操作でタップスクロールを止める。
300
+ * 依存関係: [resetTapScroll]
301
+ * クリーンアップ: リスナ解除。
302
+ */
303
+ useEffect(() => {
304
+ const handleTapScrollCancel = () => {
305
+ resetTapScroll()
306
+ }
307
+ window.addEventListener(TAP_SCROLL_CANCEL_EVENT, handleTapScrollCancel)
308
+ return () => {
309
+ window.removeEventListener(TAP_SCROLL_CANCEL_EVENT, handleTapScrollCancel)
310
+ }
311
+ }, [resetTapScroll])
312
+
313
+ /**
314
+ * Cancel path (b): document-capture pointerdown outside the circle (another pointer).
315
+ * キャンセル系 (b): サークル外の別ポインタによる document 捕捉 pointerdown。
316
+ *
317
+ * 目的: ドラッグ中の別指タッチで fail-closed に停止する (ScrollBar の双子)。
318
+ * 依存関係: [enabled, resetTapScroll]
319
+ * クリーンアップ: リスナ解除。
320
+ */
321
+ useEffect(() => {
322
+ if (!enabled) {
323
+ return
324
+ }
325
+ const handlePointerDown = (event: PointerEvent) => {
326
+ if (!tapDragStateRef.current.active) {
327
+ return
328
+ }
329
+ // ドラッグ中のポインター自身は無視 (通常はキャプチャ済みでここへ来ないが念のため)
330
+ if (tapDragStateRef.current.pointerId === event.pointerId) {
331
+ return
332
+ }
333
+ const targetNode = event.target
334
+ if (!(targetNode instanceof Node)) {
335
+ resetTapScroll()
336
+ return
337
+ }
338
+ const element = tapCircleHandleRef.current?.getElement()
339
+ if (element?.contains(targetNode)) {
340
+ return
341
+ }
342
+ resetTapScroll()
343
+ }
344
+ document.addEventListener("pointerdown", handlePointerDown, true)
345
+ return () => {
346
+ document.removeEventListener("pointerdown", handlePointerDown, true)
347
+ }
348
+ }, [enabled, resetTapScroll])
349
+
350
+ /**
351
+ * Cancel path (c): `enabled` → false resets; unmount stops the loop.
352
+ * キャンセル系 (c): `enabled` → false でリセット、アンマウントでループ停止。
353
+ *
354
+ * 目的: 無効化 / 退場時に rAF を残さない。
355
+ * 依存関係: [enabled, resetTapScroll] / [stopLoop]
356
+ * クリーンアップ: アンマウント時のループ停止。
357
+ */
358
+ useEffect(() => {
359
+ if (!enabled) {
360
+ resetTapScroll()
361
+ }
362
+ }, [enabled, resetTapScroll])
363
+ useEffect(() => {
364
+ return () => {
365
+ stopLoop()
366
+ }
367
+ }, [stopLoop])
368
+
369
+ return { isTapActive, handleTapCircleDragChange, tapCircleHandleRef }
370
+ }