/** * ZoomableChart — 支持 pinch-to-zoom 的图表容器(Task 8) * * 用法: * * * ... * * * * 特性: * - 支持 macOS 触控板双指缩放(wheel + ctrlKey) * - 支持移动端/触屏双指 pinch-to-zoom * - 缩放范围 0.5x – 4x,带 rubberband 效果 * - 动画由 @react-spring/web 处理,不触发 React re-render * - Must NOT: 不在 handler 内使用 useState */ import { useRef } from 'react' import { animated, useSpring } from '@react-spring/web' const AnimatedDiv = animated('div') import { usePinch } from '@use-gesture/react' import { cn } from '@/lib/utils' interface ZoomableChartProps { children: React.ReactNode className?: string 'aria-label': string minScale?: number maxScale?: number } export function ZoomableChart({ children, className, 'aria-label': ariaLabel, minScale = 0.5, maxScale = 4, }: ZoomableChartProps) { const containerRef = useRef(null) const [style, api] = useSpring(() => ({ scale: 1, config: { tension: 300, friction: 40 }, })) usePinch( ({ offset: [scale], first, last }) => { // Rubberband: 超出范围时有弹性阻力 const clamped = Math.min(Math.max(scale, minScale * 0.85), maxScale * 1.15) const rubberband = clamped < minScale ? minScale + (clamped - minScale) * 0.3 : clamped > maxScale ? maxScale + (clamped - maxScale) * 0.3 : clamped api.start({ scale: rubberband, immediate: first }) // 松手后弹回范围内 if (last && (scale < minScale || scale > maxScale)) { api.start({ scale: Math.min(Math.max(scale, minScale), maxScale), config: { tension: 200, friction: 30 }, }) } }, { target: containerRef, scaleBounds: { min: minScale * 0.85, max: maxScale * 1.15 }, rubberband: true, // 阻止浏览器默认的页面缩放 preventDefault: true, eventOptions: { passive: false }, } ) return (
{children}
) }