Files
mai-bot/dashboard/src/components/ui/zoomable-chart.tsx
DrSmoothl 8f41e25696 fix(dashboard): resolve TS build errors in a11y changes
- Fix duplicate className attr in EmojiDialogs.tsx
- Replace animated.div with animated('div') in expression-reviewer and zoomable-chart to fix React 19 children type error
- Fix malformed i18n JSON (a11y namespace was outside root object)
2026-03-05 22:10:32 +08:00

93 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ZoomableChart — 支持 pinch-to-zoom 的图表容器Task 8
*
* 用法:
* <ZoomableChart aria-label="每小时请求量趋势">
* <ChartContainer ...>
* <LineChart ...>...</LineChart>
* </ChartContainer>
* </ZoomableChart>
*
* 特性:
* - 支持 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<HTMLDivElement>(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 (
<div
ref={containerRef}
role="img"
aria-label={ariaLabel}
className={cn('overflow-hidden touch-none select-none', className)}
style={{ touchAction: 'none' }}
>
<AnimatedDiv style={style} className="w-full h-full origin-center">
{children}
</AnimatedDiv>
</div>
)
}