@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.
@@ -7,21 +7,29 @@
7
7
  * row-axis scale profile (<= 2^53 - 1). Grid semantics (selection / editing / clipboard)
8
8
  * are deliberately NOT owned here — consumers attach them via getCellProps / getRowProps /
9
9
  * contentProps. Design: docs/plans/2026.09.01 virtualgrid-horizontal-axis-design-plan (v6).
10
+ * Since v3.6.0 the grid owns ONE unified two-axis tap circle (axis="xy", corner-anchored via the
11
+ * three-arm placement law) driven by useGridTapScroll; both per-bar circles are suppressed
12
+ * grid-side (docs/plans/2026.09.16 unified-tap-circle plan).
10
13
  *
11
14
  * VirtualGrid — 実証済みの VirtualScroll 行軸に、対称の横列エンジン (同一の兆スケール機構:
12
15
  * 疎 Fenwick 幅木・共有 computeRenderingRanges (BigInt huge 分岐込み)・ANCHOR_REBASE_DISTANCE
13
16
  * の量子化アンカー再基準化・総サイズ px を DOM に着地させない合成スクロール) を合成した
14
17
  * 汎用 2D 仮想化プリミティブ。列数は行軸のスケールプロファイル (≤ 2^53 − 1) を完全継承する。
15
18
  * グリッド操作の意味論 (選択 / 編集 / クリップボード) は意図的に非所有 — 消費側が
16
- * getCellProps / getRowProps / contentProps で装着する。
19
+ * getCellProps / getRowProps / contentProps で装着する。v3.6.0 からグリッドは統合 2 軸
20
+ * タップサークル (axis="xy"・3 アーム則のコーナーアンカー・useGridTapScroll 駆動) を 1 つ
21
+ * 所有し、両バーのサークルはグリッド側で抑止する。
17
22
  */
18
23
 
19
24
  import { type CSSProperties, forwardRef, type HTMLAttributes, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"
20
25
  import { twMerge } from "tailwind-merge"
26
+ import type { TapScrollAxisSpeedParams } from "./computeTapScrollVelocity.ts"
21
27
  import { Logger } from "./logger.ts"
22
- import { ScrollBar } from "./ScrollBar.tsx"
28
+ import { resolveTapScrollCircleOptions, ScrollBar, type ScrollBarTapCircleOptions } from "./ScrollBar.tsx"
23
29
  import type { ScrollPaneProps } from "./ScrollPane.tsx"
30
+ import { TapScrollCircle } from "./TapScrollCircle.tsx"
24
31
  import { useFenwickMapTree } from "./useFenwickMapTree.ts"
32
+ import { GRID_TAP_CIRCLE_DEFAULT_OFFSET, useGridTapScroll } from "./useGridTapScroll.ts"
25
33
  import { minmax } from "./utils.ts"
26
34
  import { ANCHOR_REBASE_DISTANCE, computeRenderingRanges, MAX_RENDERED_ITEMS, VirtualScroll, type VirtualScrollBehaviorOptions, type VirtualScrollHandle, type VirtualScrollRange, type VirtualScrollScrollBarOptions, ZERO_HEIGHT_RUN_LIMIT } from "./VirtualScroll.tsx"
27
35
 
@@ -997,6 +1005,16 @@ const VirtualGridInner = <T,>(
997
1005
  */
998
1006
  const scrollBandWidthRef = useCallback(() => Math.max(0, viewportRef.current.width - frozenWidthRef.current - trailingWidthRef.current), [])
999
1007
 
1008
+ /**
1009
+ * The ONE extracted x clamp law (v3.6.0 §3.5-7 SSOT): `max(0, totalWidth − W_F − W_T −
1010
+ * bandWidth)`. Consumed by `applyHx` (pure refactor — behavior byte-identical) AND the tap
1011
+ * driver (boundary / pinned detection + `gridScrollable`). No second copy of the law exists.
1012
+ * 唯一抽出の x クランプ則 (v3.6.0 §3.5-7 SSOT)。`applyHx` (純リファクタ — 挙動バイト恒等)
1013
+ * とタップドライバ (境界 / ピン判定 + `gridScrollable`) の両方が消費する。第 2 の複製は
1014
+ * 存在しない。
1015
+ */
1016
+ const getMaxHx = useCallback(() => Math.max(0, totalWidthRef.current - frozenWidthRef.current - trailingWidthRef.current - scrollBandWidthRef()), [scrollBandWidthRef])
1017
+
1000
1018
  // ---- 列窓の再計算 (setState は窓 / アンカー変化時のみ — key 比較で bail) ----
1001
1019
  const refreshColumns = useCallback(() => {
1002
1020
  // 凍結帯 (§8-a): 窓探索は木座標 W_F + hx 始まり・帯幅 = viewport − W_F − W_T (3.5.0 一般化)
@@ -1122,9 +1140,8 @@ const VirtualGridInner = <T,>(
1122
1140
  (next: number): number => {
1123
1141
  // 凍結帯 (§8): スクロール帯幅 = viewport − W_F − W_T (3.5.0 一般化)。maxHx =
1124
1142
  // (総幅 − W_F − W_T) − 帯幅で、W_F + W_T < viewport のとき従来式 (総幅 − viewport)
1125
- // と一致する (F = T = 0 で恒等)
1126
- const bandWidth = scrollBandWidthRef()
1127
- const maxHx = Math.max(0, totalWidthRef.current - frozenWidthRef.current - trailingWidthRef.current - bandWidth)
1143
+ // と一致する (F = T = 0 で恒等)。クランプ則の所有者は getMaxHx (v3.6.0 SSOT)
1144
+ const maxHx = getMaxHx()
1128
1145
  const clamped = minmax(next, 0, maxHx)
1129
1146
  if (clamped !== hxRef.current) {
1130
1147
  hxRef.current = clamped
@@ -1135,7 +1152,7 @@ const VirtualGridInner = <T,>(
1135
1152
  }
1136
1153
  return clamped
1137
1154
  },
1138
- [writeResidual, refreshColumns, scheduleBarSync, scrollBandWidthRef],
1155
+ [writeResidual, refreshColumns, scheduleBarSync, getMaxHx],
1139
1156
  )
1140
1157
  const applyHxRef = useRef(applyHx)
1141
1158
  applyHxRef.current = applyHx
@@ -1289,6 +1306,111 @@ const VirtualGridInner = <T,>(
1289
1306
  }
1290
1307
  }, [trailingWidth, effectiveTrailingCols])
1291
1308
 
1309
+ // ---- 統合 2 軸タップサークル (v3.6.0 — ADR-27..32) ----
1310
+ // 埋め込み VirtualScroll へ転送するオプションのメモ化フォーク (§3.1): tapScrollCircleOptions
1311
+ // だけ enabled: false を強制し、兄弟フィールド (width 等) は必ず生存させる。両バーの
1312
+ // サークルはグリッド側だけで抑止し、単体 VirtualScroll / ScrollPane / ScrollBar は触らない
1313
+ const embeddedScrollBarOptions = useMemo<VirtualScrollScrollBarOptions>(() => ({ ...scrollBarOptions, tapScrollCircleOptions: { ...scrollBarOptions?.tapScrollCircleOptions, enabled: false } }), [scrollBarOptions])
1314
+ const rawTapScrollCircleOptions = scrollBarOptions?.tapScrollCircleOptions
1315
+ // hasCustomMaxSpeedMultiplier は**生**オプションからのピン (§8.3 — ScrollBar stepAutoScroll 内の
1316
+ // 導出の双子)。リゾルバ出力からの導出 (typeof resolved.maxSpeedMultiplier === "number") は
1317
+ // 常に true で、1,200 px/s 自動床を minSpeed へ無言反転させる T1 破壊 (MV-XY28)
1318
+ const hasCustomTapMaxSpeedMultiplier = typeof rawTapScrollCircleOptions?.maxSpeedMultiplier === "number"
1319
+ // 生オプションへ両オフセットのグリッド既定 (−200) を注入してから解決する (§8.4-5)。バー既定
1320
+ // −80/0 はバーアンカー則の符号でありコーナーアンカーでは無意味
1321
+ const gridTapOptions = useMemo<ScrollBarTapCircleOptions>(
1322
+ () => ({
1323
+ ...rawTapScrollCircleOptions,
1324
+ offsetX: rawTapScrollCircleOptions?.offsetX === undefined ? GRID_TAP_CIRCLE_DEFAULT_OFFSET : rawTapScrollCircleOptions.offsetX,
1325
+ offsetY: rawTapScrollCircleOptions?.offsetY === undefined ? GRID_TAP_CIRCLE_DEFAULT_OFFSET : rawTapScrollCircleOptions.offsetY,
1326
+ }),
1327
+ [rawTapScrollCircleOptions],
1328
+ )
1329
+ // 軸別解決 (§8.4-5): resolveTapScrollCircleOptions を軸ごとに 1 回 (itemCount = 軸カウント —
1330
+ // 不変量 8 保存)。視覚フィールドはどちらの結果から読んでも同一 (構造上) — x 側から読む
1331
+ const resolvedTapX = useMemo(() => resolveTapScrollCircleOptions(gridTapOptions, Math.max(0, colCount - effectiveFrozenCols - effectiveTrailingCols)), [gridTapOptions, colCount, effectiveFrozenCols, effectiveTrailingCols])
1332
+ const resolvedTapY = useMemo(() => resolveTapScrollCircleOptions(gridTapOptions, scrollRowCount), [gridTapOptions, scrollRowCount])
1333
+ const tapCircleEnabled = resolvedTapX.enabled
1334
+ const tapMaxDistance = Math.max(resolvedTapX.maxVisualDistance, 1)
1335
+ // 軸別速度パラメータ (§4.1): 今日の 2 バー構成そのもの — x はスクロール帯幅 × 列カウント、
1336
+ // y は埋め込みペイン高 × スクロール行カウント。min/max/curve は共有 (1 オプションオブジェクト)
1337
+ const tapXSpeedParams: TapScrollAxisSpeedParams = {
1338
+ viewportSize: Math.max(0, viewport.width - frozenWidth - trailingWidth),
1339
+ minSpeedMultiplier: resolvedTapX.minSpeedMultiplier,
1340
+ maxSpeedMultiplier: resolvedTapX.maxSpeedMultiplier,
1341
+ hasCustomMaxSpeedMultiplier: hasCustomTapMaxSpeedMultiplier,
1342
+ curve: resolvedTapX.maxSpeedCurve,
1343
+ }
1344
+ const tapYSpeedParams: TapScrollAxisSpeedParams = {
1345
+ viewportSize: Math.max(0, viewport.height - frozenHeight - trailingHeight),
1346
+ minSpeedMultiplier: resolvedTapY.minSpeedMultiplier,
1347
+ maxSpeedMultiplier: resolvedTapY.maxSpeedMultiplier,
1348
+ hasCustomMaxSpeedMultiplier: hasCustomTapMaxSpeedMultiplier,
1349
+ curve: resolvedTapY.maxSpeedCurve,
1350
+ }
1351
+ const getHx = useCallback(() => hxRef.current, [])
1352
+ const getVy = useCallback(() => vyRef.current, [])
1353
+ /**
1354
+ * Fresh y extent through the embedded handle — the same freshness channel the vertical bar
1355
+ * uses (`contentSize − viewportSize` floored at 0). Never re-derived from grid-side trees.
1356
+ * 埋め込みハンドル経由の鮮度 y 延長 — vbar と同じ鮮度チャネル (`contentSize − viewportSize`
1357
+ * の 0 下支え)。グリッド側の木から再導出しない (§3.5-7)。
1358
+ */
1359
+ const getMaxVy = useCallback(() => {
1360
+ const inner = scrollHandleRef.current
1361
+ if (inner === null) {
1362
+ return 0
1363
+ }
1364
+ return Math.max(0, Math.max(0, inner.getContentSize()) - Math.max(0, inner.getViewportSize()))
1365
+ }, [])
1366
+ /**
1367
+ * The y apply seam (§3.5-8): wraps the embedded `scrollBy` AND syncs `vyRef.current =
1368
+ * applied` before returning — the grid handle `scrollBy` y-half twin, symmetric with
1369
+ * `applyHxRef`. Without the sync, x-frame `onScroll({ x, y: vyRef.current })` notifications
1370
+ * during a diagonal drag carry y stale by up to the `callbackThrottleMs` window (MV-XY29).
1371
+ * y 適用シーム (§3.5-8): 埋め込み `scrollBy` をラップし、**返す前に** `vyRef.current =
1372
+ * applied` を同期する — グリッドハンドル `scrollBy` の y 半分の双子で `applyHxRef` と対称。
1373
+ * 同期が無いと対角ドラッグ中の x フレーム通知が stale y を運ぶ (MV-XY29)。
1374
+ */
1375
+ const applyVy = useCallback((delta: number): number => {
1376
+ const inner = scrollHandleRef.current
1377
+ if (inner === null) {
1378
+ // ハンドル未接続時は現在値を返して動かない (グリッドハンドル scrollBy と同じ姿勢)
1379
+ return vyRef.current
1380
+ }
1381
+ const applied = inner.scrollBy(delta)
1382
+ vyRef.current = applied
1383
+ return applied
1384
+ }, [])
1385
+ // gridScrollable (§8.4-6): 動く軸が 1 つあれば十分 — y-only グリッドは x 成分が境界ピンの
1386
+ // まま動くサークルを保持する (MV-XY20/21)。バーを駆動するのと同じ鮮度チャネル (x:
1387
+ // columnWindow / 総幅、y: 縦レンジ state = notifiedRowCount の通知駆動再レンダー) で再計算される
1388
+ const gridScrollable = getMaxHx() > 0 || getMaxVy() > 0
1389
+ // 延長成長の再武装キー (§3.5-3): 保持中の境界パークを鮮度チャネルの成長で再開する
1390
+ const tapXExtentFreshness = `${totalWidth}:${frozenWidth}:${trailingWidth}:${viewport.width}:${columnWindow.key}`
1391
+ const tapYExtentFreshness = `${rowCount}:${notifiedRowCount ?? -1}:${frozenHeight}:${trailingHeight}:${viewport.height}`
1392
+ const { isTapActive, handleTapCircleDragChange, tapCircleHandleRef } = useGridTapScroll({
1393
+ enabled: tapCircleEnabled && gridScrollable,
1394
+ maxDistance: tapMaxDistance,
1395
+ xSpeedParams: tapXSpeedParams,
1396
+ ySpeedParams: tapYSpeedParams,
1397
+ applyHxRef,
1398
+ getHx,
1399
+ getMaxHx,
1400
+ applyVy,
1401
+ getVy,
1402
+ getMaxVy,
1403
+ pendingColAnchorRef,
1404
+ xExtentFreshness: tapXExtentFreshness,
1405
+ yExtentFreshness: tapYExtentFreshness,
1406
+ })
1407
+ // バーの 2 行 opacity memo の複製 (§8.4-5): インライン opacity の着地先はサークルルート
1408
+ // (props 経由) — 値と要素同一性はテストでピン (MV-XY23/27)
1409
+ const tapCircleOpacityValue = useMemo(() => {
1410
+ const baseOpacity = isTapActive ? 1 : 0.8
1411
+ return minmax(baseOpacity * resolvedTapX.opacity, 0, 1)
1412
+ }, [isTapActive, resolvedTapX.opacity])
1413
+
1292
1414
  // ---- ビューポート / 総幅 / 幅エポック変化での再クランプ + 列窓追随。保留列アンカーが
1293
1415
  // あれば位置はアンカーから再導出 (単なる再クランプでは推定空間の hx が残留する) ----
1294
1416
  useLayoutEffect(() => {
@@ -1739,7 +1861,7 @@ const VirtualGridInner = <T,>(
1739
1861
  viewportSize={viewport.height > 0 ? Math.max(0, viewport.height - frozenHeight - trailingHeight) : undefined}
1740
1862
  callbackThrottleMs={callbackThrottleMs}
1741
1863
  behaviorOptions={behaviorOptions}
1742
- scrollBarOptions={scrollBarOptions}
1864
+ scrollBarOptions={embeddedScrollBarOptions}
1743
1865
  contentProps={contentProps}
1744
1866
  background={background}
1745
1867
  contentInsets={contentInsets}
@@ -1789,7 +1911,6 @@ const VirtualGridInner = <T,>(
1789
1911
  <div className="aqvs-grid-hbar-strip" style={{ height: scrollBarWidth, paddingRight: effectiveTrailingCols > 0 ? scrollBarWidth + trailingVisibleSize(trailingWidth, frozenWidth, viewport.width) : scrollBarWidth }} data-testid={testId ? `${testId}-hbar` : undefined}>
1790
1912
  <ScrollBar
1791
1913
  horizontal
1792
- enableHorizontalTapCircle
1793
1914
  contentSize={Math.max(0, totalWidth - frozenWidth - trailingWidth)}
1794
1915
  viewportSize={Math.max(0, viewport.width - frozenWidth - trailingWidth)}
1795
1916
  scrollPosition={barHx}
@@ -1806,6 +1927,32 @@ const VirtualGridInner = <T,>(
1806
1927
  scrollBarWidth={scrollBarWidth}
1807
1928
  />
1808
1929
  </div>
1930
+ {tapCircleEnabled && gridScrollable && (
1931
+ // 統合 2 軸タップサークル (v3.6.0 — ADR-30 の 3 アーム則): (C) 可視上限
1932
+ // `100% − (sbw + size)` / (R) 到達性 `100% − sbw + off` / (Q) 遠象限床
1933
+ // `75% − size/2`。全アームが root 寸・sbw・size・オフセットのみに依存 —
1934
+ // 帯幅非依存 (凍結トグル不変)。wrapper は純配置で transition を持たない
1935
+ // (フェードはサークルルートのインライン opacity — §8.7)。hbar strip の後・
1936
+ // live region の前 = 共有 z-50 層の DOM 後勝ちで最前
1937
+ <div
1938
+ className="aqvs-grid-tap-circle-wrapper"
1939
+ data-aqvs-grid-tap-circle=""
1940
+ style={{
1941
+ left: `min(calc(100% - ${scrollBarWidth + resolvedTapX.size}px), max(calc(100% - ${scrollBarWidth}px + ${resolvedTapX.offsetX}px), calc(75% - ${resolvedTapX.size / 2}px)))`,
1942
+ top: `min(calc(100% - ${scrollBarWidth + resolvedTapX.size}px), max(calc(100% - ${scrollBarWidth}px + ${resolvedTapX.offsetY}px), calc(75% - ${resolvedTapX.size / 2}px)))`,
1943
+ }}>
1944
+ <TapScrollCircle
1945
+ axis="xy"
1946
+ ref={tapCircleHandleRef}
1947
+ className={twMerge("aqvs-grid-tap-circle", resolvedTapX.className)}
1948
+ size={resolvedTapX.size}
1949
+ maxVisualDistance={tapMaxDistance}
1950
+ opacity={tapCircleOpacityValue}
1951
+ renderVisual={resolvedTapX.renderVisual}
1952
+ onDragChange={handleTapCircleDragChange}
1953
+ />
1954
+ </div>
1955
+ )}
1809
1956
  {liveRegion !== undefined ? (
1810
1957
  <div className="aqvs-grid-live-region" aria-live="polite" data-testid={testId ? `${testId}-live` : undefined}>
1811
1958
  {liveMessage}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Pure two-axis velocity decomposition for the unified grid tap circle (v3.6.0).
3
+ * 統合 2 軸タップサークル (v3.6.0) 用の純関数 2 軸速度分解モジュール。
4
+ */
5
+
6
+ import { computeTapScrollSpeed } from "./ScrollBar.tsx"
7
+
8
+ /**
9
+ * Per-axis speed parameters for `computeTapScrollVelocity` — the exact tuple one bar resolves
10
+ * today. Deliberately an explicit structural type (NOT `Omit<TapScrollSpeedInput, …>`):
11
+ * api-extractor cannot roll up `Omit<>` on the public surface (house rule).
12
+ * `computeTapScrollVelocity` の軸別速度パラメータ — 今日 1 本のバーが解決する組そのもの。
13
+ * 意図的な明示構造型 (`Omit<>` 非使用 — api-extractor がロールアップできないための既定方針)。
14
+ */
15
+ export type TapScrollAxisSpeedParams = {
16
+ /** Viewport length on this axis (px). / この軸のビューポート長 (px)。 */
17
+ viewportSize: number
18
+ /** Minimum speed multiplier (viewport-relative). / 最小速度倍率 (ビューポート比)。 */
19
+ minSpeedMultiplier: number
20
+ /** Maximum speed multiplier (viewport-relative). / 最大速度倍率 (ビューポート比)。 */
21
+ maxSpeedMultiplier: number
22
+ /** Whether `maxSpeedMultiplier` was explicitly supplied (controls the 1,200 px/s auto floor). / `maxSpeedMultiplier` が明示指定か (1,200 px/s 自動床の制御)。 */
23
+ hasCustomMaxSpeedMultiplier: boolean
24
+ /** Optional speed-curve shape (same contract as `TapScrollSpeedInput.curve`). / 任意の速度曲線形状 (`TapScrollSpeedInput.curve` と同契約)。 */
25
+ curve?: { exponentialSteepness: number; exponentialScale?: number; easedOffset?: number }
26
+ }
27
+
28
+ /**
29
+ * Input for `computeTapScrollVelocity`.
30
+ * `computeTapScrollVelocity` の入力。
31
+ */
32
+ export type TapScrollVelocityInput = {
33
+ /** Signed px from the circle center (state canonical field). / サークル中心からの符号付き px (state 正準フィールド)。 */
34
+ offsetX: number
35
+ /** Signed px from the circle center (state canonical field). / サークル中心からの符号付き px (state 正準フィールド)。 */
36
+ offsetY: number
37
+ /** Shared pull range (= `max(maxVisualDistance, 1)`). / 共有引き範囲 (= `max(maxVisualDistance, 1)`)。 */
38
+ maxDistance: number
39
+ /** Horizontal-axis speed parameters. / 横軸の速度パラメータ。 */
40
+ x: TapScrollAxisSpeedParams
41
+ /** Vertical-axis speed parameters. / 縦軸の速度パラメータ。 */
42
+ y: TapScrollAxisSpeedParams
43
+ }
44
+
45
+ /**
46
+ * Decomposes a 2-D tap-circle pull into per-axis scroll velocities (px/s, signed):
47
+ *
48
+ * ```text
49
+ * r = 0 => v = (0, 0)
50
+ * r > 0 => v = ( s_x(r) * ox / r , s_y(r) * oy / r )
51
+ * ```
52
+ *
53
+ * where `r = ||(ox, oy)||_2` and `s_a` is the UNCHANGED `computeTapScrollSpeed` under axis `a`'s
54
+ * parameters (`distance: r`, shared `maxDistance`).
55
+ *
56
+ * Derivation summary (the axioms this law is the unique solution of):
57
+ *
58
+ * - D1 (legacy reproduction): pure-axis inputs reproduce each 1-D bar law to within 1 ulp
59
+ * under the pinned order — deviations hit a config-dependent ~9% of probed pure-axis points,
60
+ * so never assert bit-equality (`toBe` / `Object.is`) of a pure-axis component against
61
+ * `computeTapScrollSpeed` outside the committed exact fixtures. The rejected order
62
+ * `s * (o / r)` would be bit-exact on pure axes (`o / r = ±1` exactly); the pin below
63
+ * trades that for fixture-order independence (plan §4.2 T1 / §4.3).
64
+ * - D2 (radial law under isotropy): when `s_x = s_y = s`, `|v| = s(r)` at every angle.
65
+ * - D3 (separability): `v_a = s_a(r) * u_a(theta)` for a direction-only unit field `u`.
66
+ * - D4 (isotropic direction fidelity — a CHOSEN axiom, not derived): when `s_x = s_y`,
67
+ * `v` is parallel to the offset with a positive scalar (rotational equivariance).
68
+ *
69
+ * D1-D3 alone do NOT force this law: they admit the whole family
70
+ * `u = (cos phi(theta), sin phi(theta))` for any continuous strictly increasing angle remap
71
+ * `phi` fixing the four cardinal angles (e.g. `phi(theta) = theta - epsilon * sin(4 theta)`).
72
+ * D4 pins `phi = id`. That family is the registered invariant-preserving tuning point for
73
+ * near-axis leakage (plan §4.4); v3.6.0 ships `phi = id`.
74
+ *
75
+ * Evaluation-order pin: each component is computed literally as `s_a(r) * o_a / r`
76
+ * (left-to-right, i.e. `(s * o) / r`). The two orders differ by 1 ulp for SOME inputs —
77
+ * verified witness: `40 * (7 / 25) = 11.200000000000001` while `(40 * 7) / 25 = 11.2` exactly.
78
+ * The committed spec fixtures happen to agree under BOTH orders (`200 * (30 / 50) === 120`
79
+ * etc. — the products land inside half-ulp and round back); the pin exists so the fixtures'
80
+ * `toBe` exactness never depends on that luck of the operand set. Do NOT "test" the old,
81
+ * numerically false claim that `200 * (30 / 50)` misrounds — it does not; deleting this pin
82
+ * because that claim fails would discard a sound pin.
83
+ *
84
+ * Totality: non-finite `ox` / `oy` are sanitized to 0 and `r = 0` returns `(0, 0)` (the origin
85
+ * has no continuous extension — the limit depends on the approach angle). Components are always
86
+ * finite: `s_a` is the existing sole NaN barrier and `|o_a| / r <= 1`.
87
+ *
88
+ * External reproduction of what a bar/grid actually resolves (same pattern as the
89
+ * `computeTapScrollSpeed` docstring):
90
+ *
91
+ * ```ts
92
+ * const velocity = computeTapScrollVelocity({
93
+ * offsetX,
94
+ * offsetY,
95
+ * maxDistance: options.maxVisualDistance ?? TAP_SCROLL_SPEED_DEFAULTS.maxDistance,
96
+ * x: {
97
+ * viewportSize: bandWidth,
98
+ * minSpeedMultiplier: options.minSpeedMultiplier ?? TAP_SCROLL_SPEED_DEFAULTS.minSpeedMultiplier,
99
+ * maxSpeedMultiplier: options.maxSpeedMultiplier ?? computeAutoTapScrollMaxSpeedMultiplier(colCount),
100
+ * hasCustomMaxSpeedMultiplier: options.maxSpeedMultiplier !== undefined,
101
+ * curve: options.maxSpeedCurve,
102
+ * },
103
+ * y: { …same with the pane height and rowCount… },
104
+ * })
105
+ * ```
106
+ *
107
+ * 2 次元のタップサークル引きを軸別スクロール速度 (px/s、符号付き) へ分解する処理。方向余弦
108
+ * 分解 `v = (s_x(r)·ox/r, s_y(r)·oy/r)` — `s_a` は無改造の `computeTapScrollSpeed`。D4
109
+ * (等方時の方向忠実) は導出ではなく選択公理で、許容リマップ族 `u = (cos φ(θ), sin φ(θ))` を
110
+ * `φ = id` に固定する。評価順序は `s_a(r) * o_a / r` (左から右) にピン — 1 ulp 証人は
111
+ * `40*(7/25) ≠ (40*7)/25`。D1 の純軸再現はピン順序下で ≤ 1 ulp (構成依存の約 9% の点で乖離 —
112
+ * ビット恒等は棄却順序 `s·(o/r)` の側で、ピンはそれをフィクスチャ順序非依存と引き換えた)。
113
+ * 非有限オフセットは 0 へ消毒し、`r = 0` は `(0, 0)`。
114
+ *
115
+ * @param input Offsets, shared pull range, and per-axis speed parameters. / オフセット・共有引き範囲・軸別速度パラメータ。
116
+ * @returns Signed per-axis velocities in px/s (always finite). / 軸別の符号付き速度 (px/s、常に有限)。
117
+ */
118
+ export const computeTapScrollVelocity = ({ offsetX, offsetY, maxDistance, x, y }: TapScrollVelocityInput): { x: number; y: number } => {
119
+ // 非有限オフセットの消毒 (finiteOr 規律): 素通しすると r と両成分が NaN 化し、
120
+ // スクロール位置の NaN 汚染 (文書化済みの回復不能故障) へ直結する
121
+ const ox = Number.isFinite(offsetX) ? offsetX : 0
122
+ const oy = Number.isFinite(offsetY) ? offsetY : 0
123
+ const r = Math.hypot(ox, oy)
124
+ // 原点は連続拡張が存在しない (極限が接近角に依存) ため全域関数規約で (0, 0) を返す。
125
+ // ドライバのゲートが積分時 r >= 6 を保証するので、この分岐は純関数側の防御でもある
126
+ if (r === 0) {
127
+ return { x: 0, y: 0 }
128
+ }
129
+ const speedX = computeTapScrollSpeed({
130
+ distance: r,
131
+ maxDistance,
132
+ viewportSize: x.viewportSize,
133
+ minSpeedMultiplier: x.minSpeedMultiplier,
134
+ maxSpeedMultiplier: x.maxSpeedMultiplier,
135
+ hasCustomMaxSpeedMultiplier: x.hasCustomMaxSpeedMultiplier,
136
+ curve: x.curve,
137
+ })
138
+ const speedY = computeTapScrollSpeed({
139
+ distance: r,
140
+ maxDistance,
141
+ viewportSize: y.viewportSize,
142
+ minSpeedMultiplier: y.minSpeedMultiplier,
143
+ maxSpeedMultiplier: y.maxSpeedMultiplier,
144
+ hasCustomMaxSpeedMultiplier: y.hasCustomMaxSpeedMultiplier,
145
+ curve: y.curve,
146
+ })
147
+ // 評価順序ピン (§4.3): s_a(r) * o_a / r — 左から右。括弧で順序を固定し、
148
+ // 「同値だから」と (s·(o/r)) へ並べ替える 1 ulp ドリフトを封じる
149
+ return { x: (speedX * ox) / r, y: (speedY * oy) / r }
150
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * 可変なアイテム高さに対応したReact用の高性能仮想スクロールコンポーネント。
6
6
  */
7
7
 
8
+ export { computeTapScrollVelocity, type TapScrollAxisSpeedParams, type TapScrollVelocityInput } from "./computeTapScrollVelocity.ts"
8
9
  export { createResidualQuantizer, type ResidualQuantizer, type ResidualQuantizerOptions } from "./residualQuantizer.ts"
9
10
  export { computeAutoTapScrollMaxSpeedMultiplier, computeTapScrollSpeed, ScrollBar, type ScrollBarProps, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps, TAP_SCROLL_SPEED_DEFAULTS, type TapScrollSpeedInput } from "./ScrollBar.tsx"
10
11
  export { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneInertiaOptions, type ScrollPaneProps } from "./ScrollPane.tsx"
@@ -526,3 +526,34 @@
526
526
  white-space: nowrap;
527
527
  border: 0;
528
528
  }
529
+
530
+ /* 統合 2 軸タップサークル (v3.6.0)。
531
+ * 純配置 — 意図的に transition を持たない: フェードする opacity は子 (サークルルート) に
532
+ * 住む。バー配置の「効果における双子」(DOM 形状の双子ではない)。 */
533
+ .aqvs-grid-tap-circle-wrapper {
534
+ position: absolute;
535
+ z-index: 50; /* バーと同層 (.aqvs-scrollbar) — DOM 後勝ちで最前 */
536
+ pointer-events: auto;
537
+ }
538
+
539
+ /* ❗ このファイル内で .aqvs-tap-scroll-circle より「後」に置くこと: 両規則が同一要素へ
540
+ * 同特異度で transition longhand を設定し、後の規則が丸ごと勝つ — 出荷済み
541
+ * .aqvs-scrollbar-tap-circle-wrapper が今日それを行っているのと同一機構。バーサークルの
542
+ * 実効 transition (opacity のみ 150ms) をバイト再現し、transform 平滑化は意図的に無し
543
+ * (出荷挙動と一致)。順序はテストでピンする (プラン §10.2)。 */
544
+ .aqvs-grid-tap-circle {
545
+ transition-property: opacity;
546
+ transition-duration: 150ms;
547
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
548
+ }
549
+
550
+ /* パッケージ全域・単一所有者 — 本 CSS 初の forced-colors 規則: グラデーション視覚は
551
+ * forced-colors で平坦化され、輪郭なしではサークルが不可視になる (描画モード修正 —
552
+ * 単体サークルにも輪郭が付くのは登記済みの挙動非変更デルタ)。 */
553
+ @media (forced-colors: active) {
554
+ .aqvs-tap-scroll-circle {
555
+ outline: 1px solid CanvasText;
556
+ outline-offset: -1px;
557
+ border-radius: 50%;
558
+ }
559
+ }