Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x | /**
* useZoomWash — zoom velocity tracking + background color wash effect.
*
* Extracted from NumberLine.tsx. Owns the zoom velocity refs, the exponential
* moving average display filter, the decay RAF loop, and the direct DOM
* background color update.
*/
import { useRef, useCallback, useEffect } from 'react'
/** Time constant for the EMA low-pass filter (ms). */
const WASH_TAU = 150
export interface UseZoomWashOptions {
resolvedTheme: string
/** Ref to the latest draw() — called from the decay loop. */
drawRef: React.MutableRefObject<() => void>
}
export interface UseZoomWashReturn {
/** Current display-filtered velocity (read by renderNumberLine). */
displayVelocityRef: React.MutableRefObject<number>
/** Current display-filtered hue (read by renderNumberLine). */
displayHueRef: React.MutableRefObject<number>
/** Focal point as fraction of canvas width 0-1 (read by renderNumberLine). */
zoomFocalXRef: React.MutableRefObject<number>
/** Ref to the decay loop RAF handle (cancel on unmount). */
decayRafRef: React.MutableRefObject<number>
/** Ref to the wrapper div — attach to the outer wrapper element. */
wrapperRef: React.MutableRefObject<HTMLDivElement | null>
/** Ref to the page container — used for background wash + unmount cleanup. */
pageRef: React.MutableRefObject<HTMLElement | null>
/** Feed a zoom velocity sample (called from touch/wheel handler). */
handleZoomVelocity: (velocity: number, focalX: number) => void
/** Tick the display filter (call once per draw frame). */
updateDisplayValues: () => void
}
export function useZoomWash({ resolvedTheme, drawRef }: UseZoomWashOptions): UseZoomWashReturn {
// Raw values track instantaneous state; display values are slew-rate-limited
const zoomVelocityRef = useRef(0)
const zoomHueRef = useRef(0)
const displayVelocityRef = useRef(0)
const displayHueRef = useRef(0)
const lastDisplayTimeRef = useRef(0)
const zoomFocalXRef = useRef(0.5)
const decayRafRef = useRef<number>(0)
const wrapperRef = useRef<HTMLDivElement>(null)
const pageRef = useRef<HTMLElement | null>(null)
// Find the page container on mount
useEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
pageRef.current = wrapper.closest('[data-component="number-line-page"]') as HTMLElement | null
}, [])
// EMA low-pass filter for display values
const updateDisplayValues = useCallback(() => {
const now = performance.now()
const dt = Math.min(now - (lastDisplayTimeRef.current || now), 50)
lastDisplayTimeRef.current = now
if (dt <= 0) return
const alpha = 1 - Math.exp(-dt / WASH_TAU)
displayVelocityRef.current += (zoomVelocityRef.current - displayVelocityRef.current) * alpha
displayHueRef.current += (zoomHueRef.current - displayHueRef.current) * alpha
}, [])
// Direct DOM style update (bypasses React for 60fps)
const updateWrapperBg = useCallback(
(velocity: number, hue: number) => {
const page = pageRef.current
if (!page) return
if (Math.abs(velocity) < 0.001) {
page.style.backgroundColor = ''
return
}
const isDark = resolvedTheme === 'dark'
const baseR = isDark ? 17 : 249
const baseG = isDark ? 24 : 250
const baseB = isDark ? 39 : 251
const edgeIntensity = Math.min(Math.abs(velocity) * 1.5, 0.18)
const lum = isDark ? 30 : 70
const sat = 0.8
const l = lum / 100
const a = sat * Math.min(l, 1 - l)
const f = (n: number) => {
const k = (n + hue / 30) % 12
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
}
const wR = f(0) * 255
const wG = f(8) * 255
const wB = f(4) * 255
const r = Math.round(baseR * (1 - edgeIntensity) + wR * edgeIntensity)
const g = Math.round(baseG * (1 - edgeIntensity) + wG * edgeIntensity)
const b = Math.round(baseB * (1 - edgeIntensity) + wB * edgeIntensity)
page.style.backgroundColor = `rgb(${r}, ${g}, ${b})`
},
[resolvedTheme]
)
// Decay loop — keeps redrawing until the wash fades out
const startDecay = useCallback(() => {
if (decayRafRef.current) return
const tick = () => {
zoomVelocityRef.current *= 0.88
if (Math.abs(zoomVelocityRef.current) > 0.01) {
const targetHue = zoomVelocityRef.current > 0 ? 220 : 25
zoomHueRef.current += (targetHue - zoomHueRef.current) * 0.08
}
updateDisplayValues()
updateWrapperBg(displayVelocityRef.current, displayHueRef.current)
if (
Math.abs(zoomVelocityRef.current) < 0.001 &&
Math.abs(displayVelocityRef.current) < 0.001
) {
zoomVelocityRef.current = 0
displayVelocityRef.current = 0
decayRafRef.current = 0
drawRef.current()
return
}
drawRef.current()
decayRafRef.current = requestAnimationFrame(tick)
}
decayRafRef.current = requestAnimationFrame(tick)
}, [drawRef, updateWrapperBg, updateDisplayValues])
const handleZoomVelocity = useCallback(
(velocity: number, focalX: number) => {
zoomVelocityRef.current = zoomVelocityRef.current * 0.6 + velocity * 8
zoomFocalXRef.current += (focalX - zoomFocalXRef.current) * 0.3
const targetHue = zoomVelocityRef.current > 0 ? 220 : 25
zoomHueRef.current += (targetHue - zoomHueRef.current) * 0.15
startDecay()
},
[startDecay]
)
return {
displayVelocityRef,
displayHueRef,
zoomFocalXRef,
decayRafRef,
wrapperRef,
pageRef,
handleZoomVelocity,
updateDisplayValues,
}
}
|