[React筆記]使用 Visx 製作可互動的折線圖表

[React筆記]使用 Visx 製作可互動的折線圖表

https://github.com/airbnb/visx

圖表功能

  1. 拖曳數據點可以修改y值
  2. 雙擊左鍵新增數據點
  3. 右鍵移除數據點

左鍵長按拖曳

import React, { useEffect, useRef, useState } from 'react'
import { Box } from '@chakra-ui/react'
import { Group } from '@visx/group'
import { scaleLinear } from '@visx/scale'
import { Axis } from '@visx/axis'
import { Grid } from '@visx/grid'
import { LinePath } from '@visx/shape'


const Chart = ({ onChange }) => {
  const chartRef = useRef<HTMLDivElement>(null)
  const [dragIndex, setDragIndex] = useState<number | null>(null)

  const margin = { top: 20, right: 20, bottom: 45, left: 50 }
  const width = 1024
  const height = 768
  const domain = [0, 100]
  
  const xScale = scaleLinear({
    domain,
    range: [margin.left, width - margin.right],
  })
  
  const yScale = scaleLinear({
    domain,
    range: [height - margin.bottom, margin.top],
  })

  const handleDragStart = (index: number, event: React.MouseEvent) => {
    if (event.button !== 0) return // 只允許左鍵拖曳, 0: 通常是左鍵 1: 通常是滾輪或中鍵 2: 通常是右鍵
    setDragIndex(index)
  }
  
  const handleDragMove = (event: React.MouseEvent) => {
    if (dragIndex === null) return
    if (event.buttons !== 1) return // 只允許左鍵拖曳
    const chart = chartRef.current
    if (!chart) return
    const svg = chart.querySelector('svg')
    if (!svg) return
    const rect = svg.getBoundingClientRect()
    const y = event.clientY - rect.top // 計算滑鼠在 SVG 內的 y 座標
    const newY = Math.max(0, Math.min(100, Math.round(yScale.invert(y)))) // 把滑鼠在 SVG 上的 y 像素座標,轉換成對應的數據值
    const newData = data.map((pt, i) =>
      i === dragIndex ? { ...pt, y: newY } : pt,
    )
    onChange?.(newData)
  }
  
  const handleDragEnd = () => {
    setDragIndex(null)
  }
  
  return (
    <Box
      w="100%"
      h={height}
      ref={chartRef}
      userSelect="none"
      bg="transparent"
      overflow="hidden"
      onMouseMove={handleDragMove}
      onMouseUp={handleDragEnd}
      onMouseLeave={handleDragEnd}
    >
      <svg width={width} height={height}>
        <Group>
          <Grid
            xScale={xScale}
            yScale={yScale}
            width={width}
            height={height}
            stroke="#444"
            strokeDasharray="3 3"
            numTicksRows={10}
            numTicksColumns={10}
          />
          <Axis
            orientation="bottom"
            scale={xScale}
            top={height - margin.bottom}
            tickFormat={(value) => `${value}`}
            stroke="#fff"
            tickStroke="#fff"
            tickLabelProps={() => ({
              fill: "#fff",
              fontSize: 14,
              textAnchor: 'middle',
            })}
          />
          <Axis
            orientation="left"
            scale={yScale}
            left={margin.left}
            tickFormat={(value) => `${Number(value)}%`}
            stroke="#fff"
            tickStroke="#fff"
            tickLabelProps={() => ({
              fill: "#fff",
              fontSize: 14,
              textAnchor: 'end',
              dy: '0.33em',
            })}
          />
          <LinePath
            data={data}
            x={(d) => xScale(d.x)}
            y={(d) => yScale(d.y)}
            stroke="#FFDFA3"
            strokeWidth={2}
          />
          {data.map((point, i) => (
            <circle
              key={i}
              cx={xScale(point.x)}
              cy={yScale(point.y)}
              r={4}
              fill="#fff"
              stroke="#FFDFA3"
              strokeWidth={2}
              style={{ cursor: 'ns-resize' }}
              onMouseDown={(e) => handleDragStart(i, e)}
            />
          ))}
        </Group>
      </svg>
    </Box>
  )
}

Element.getBoundingClientRect() 能夠取得 SVG 在畫面上的位置與大小,用於計算滑鼠相對於 SVG 的座標。若要計算滑鼠在 SVG 內的 y 座標,即滑鼠的螢幕 y 座標(event.clientY)減去 SVG 上邊界的螢幕 y 座標(rect.top)。

element box diagram

雙擊新增數據點

x, y 是計算滑鼠在 SVG 內的 x、y 座標,即滑鼠的螢幕座標減去 SVG 左上角的螢幕座標。其他都跟 handleDragMove 差不多邏輯。

const handleDoubleClick = (
  event: React.MouseEvent<SVGSVGElement, MouseEvent>,
) => {
  const chart = chartRef.current
  if (!chart) return
  const svg = chart.querySelector('svg')
  if (!svg) return
  const rect = svg.getBoundingClientRect()
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top
  let xVal = Math.round(xScale.invert(x))
  let yVal = Math.round(yScale.invert(y))
  xVal = Math.max(0, Math.min(100, xVal))
  yVal = Math.max(0, Math.min(100, yVal))
  // 避免 x 重複
  if (data.some((pt) => pt.x === xVal)) return
  const newData = [...data, { x: xVal, y: yVal }].sort((a, b) => a.x - b.x)
  onChange?.(newData)
}

在元素上雙擊會觸發 onDoubleClick

<svg width={width} height={height} onDoubleClick={handleDoubleClick}>
  <Group>
  //...

右鍵刪除數據點

在元素上單擊右鍵會觸發 onContextMenu

const handlePointRightClick = (event: React.MouseEvent, index: number) => {
  event.preventDefault()
  if (data.length <= 1) return // 至少保留1個點
  const newData = data.filter((_, i) => i !== index)
  onChange?.(newData)
}
{data.map((point, i) => (
  <circle
    key={i}
    cx={xScale(point.x)}
    cy={yScale(point.y)}
    r={4}
    fill="#fff"
    stroke={colors.secondary}
    strokeWidth={2}
    style={{ cursor: 'ns-resize' }}
    onMouseDown={(e) => handleDragStart(i, e)}
    onContextMenu={(e) => handlePointRightClick(e, i)}
  />
))}
guest

0 評論
最舊
最新 最多投票
內聯回饋
查看全部評論