diff --git a/src/components/common/SmoothTrendCanvas.vue b/src/components/common/SmoothTrendCanvas.vue index 70c3ec0..b6e46ae 100644 --- a/src/components/common/SmoothTrendCanvas.vue +++ b/src/components/common/SmoothTrendCanvas.vue @@ -72,8 +72,15 @@ const drag = ref(null) const hoverX = ref(null) const velocity = ref(0) const rafId = ref(null) +const inertiaRafId = ref(null) +const lastInertiaTime = ref(0) const tooltip = ref(null) +const INERTIA_REFERENCE_FRAME_MS = 1000 / 120 +const INERTIA_MAX_FRAME_MS = 1000 / 30 +const INERTIA_DECAY_PER_120HZ_FRAME = 0.96 +const INERTIA_STOP_VELOCITY = 0.01 + const panelClass = computed(() => props.isDarkMode ? 'border-[#40545c] bg-[linear-gradient(135deg,rgba(34,49,56,0.96),rgba(24,35,42,0.92))] shadow-md shadow-black/25' @@ -130,8 +137,12 @@ const draw = () => { clampState() const dpr = window.devicePixelRatio || 1 - canvas.width = rect.width * dpr - canvas.height = rect.height * dpr + const targetWidth = Math.round(rect.width * dpr) + const targetHeight = Math.round(rect.height * dpr) + if (canvas.width !== targetWidth || canvas.height !== targetHeight) { + canvas.width = targetWidth + canvas.height = targetHeight + } ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, rect.width, rect.height) @@ -299,12 +310,12 @@ const draw = () => { } } -const pan = (deltaX: number) => { +const pan = (deltaX: number, shouldScheduleDraw = true) => { const rect = canvasRef.value?.getBoundingClientRect() if (!rect) return offset.value += (-deltaX / Math.max(1, rect.width)) * Math.max(1, windowSize.value - 1) * 1.15 clampState() - scheduleDraw() + if (shouldScheduleDraw) scheduleDraw() } const zoom = (factor: number, center = 0.5) => { @@ -318,11 +329,32 @@ const zoom = (factor: number, center = 0.5) => { scheduleDraw() } -const applyInertia = () => { - if (Math.abs(velocity.value) < 0.02) return - pan(velocity.value) - velocity.value *= 0.92 - window.requestAnimationFrame(applyInertia) +const cancelInertia = () => { + if (inertiaRafId.value !== null) { + window.cancelAnimationFrame(inertiaRafId.value) + inertiaRafId.value = null + } + lastInertiaTime.value = 0 +} + +const applyInertia = (timestamp: number) => { + if (Math.abs(velocity.value) < INERTIA_STOP_VELOCITY) { + cancelInertia() + return + } + + const elapsed = lastInertiaTime.value + ? Math.min(timestamp - lastInertiaTime.value, INERTIA_MAX_FRAME_MS) + : INERTIA_REFERENCE_FRAME_MS + lastInertiaTime.value = timestamp + + pan(velocity.value * elapsed, false) + draw() + velocity.value *= Math.pow( + INERTIA_DECAY_PER_120HZ_FRAME, + elapsed / INERTIA_REFERENCE_FRAME_MS + ) + inertiaRafId.value = window.requestAnimationFrame(applyInertia) } const handleWheel = (event: WheelEvent) => { @@ -332,28 +364,34 @@ const handleWheel = (event: WheelEvent) => { const handlePointerDown = (event: PointerEvent) => { canvasRef.value?.setPointerCapture(event.pointerId) + cancelInertia() velocity.value = 0 - drag.value = { lastX: event.clientX, time: performance.now() } + drag.value = { lastX: event.clientX, time: event.timeStamp || performance.now() } } const handlePointerMove = (event: PointerEvent) => { const rect = canvasRef.value?.getBoundingClientRect() - if (rect) hoverX.value = event.clientX - rect.left + const pointerEvents = event.getCoalescedEvents?.() || [event] + const latestEvent = pointerEvents[pointerEvents.length - 1] || event + if (rect) hoverX.value = latestEvent.clientX - rect.left if (!drag.value) { scheduleDraw() return } - const now = performance.now() - const deltaX = event.clientX - drag.value.lastX - const deltaTime = Math.max(16, now - drag.value.time) - velocity.value = deltaX * (16 / deltaTime) - pan(deltaX) - drag.value = { lastX: event.clientX, time: now } + pointerEvents.forEach((pointerEvent) => { + const now = pointerEvent.timeStamp || performance.now() + const deltaX = pointerEvent.clientX - drag.value!.lastX + const deltaTime = Math.max(1, now - drag.value!.time) + velocity.value = deltaX / deltaTime + pan(deltaX) + drag.value = { lastX: pointerEvent.clientX, time: now } + }) } const handlePointerUp = () => { drag.value = null - window.requestAnimationFrame(applyInertia) + cancelInertia() + inertiaRafId.value = window.requestAnimationFrame(applyInertia) } const handlePointerCancel = () => { @@ -392,6 +430,7 @@ onMounted(async () => { onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) if (rafId.value !== null) window.cancelAnimationFrame(rafId.value) + cancelInertia() }) defineExpose({ zoom, resetWindow })