LoginSignup
5
2

More than 5 years have passed since last update.

今日から4日間、svg による chart を作成します。React は svg と相性が良く、形状に沿って MouseEvent をバインドすることが出来ます。手書きの場合、デザイン・挙動の制約が無くなるので、未経験の方は是非挑戦してみてください。
code: github / $ yarn 1215

1215.jpg 1215-1.jpg

chart 作成にあたり必要な手順は決まっています。

  • Records を元に描画領域・座標を算出
  • Context Hooks に演算した値を凝集
  • 各要素が useContext で値を利用

Record

まず始めに、グラフの元になる Record の型定義から始めます。

components/records.ts
type Record = {
  color: string
  point: number
  name: string
}

サンプルではダミーを使います。

components/records.ts
const defaultRecords: Record[] = [
  { color: '#742d76', point: 434, name: 'A' },
  { color: '#00b5b5', point: 783, name: 'B' },
  { color: '#ffb100', point: 1083, name: 'C' },
  { color: '#ff0079', point: 1883, name: 'D' }
]

usePieChart

今回の Custom Hooks 内訳です。totalPoint は中央に表示する数値、progress はアニメーション進捗に利用します。

components/usePieChart.ts
const usePieChart = (props: Props) => {
  const [{ records, totalPoint }] = useState(...)
  const [progress, updateProgress] = useState(...)
  const rectSize = useMemo(...)
  const padding = useMemo(...)
  const size = useMemo(...)
  const centerCircleRadius = useMemo(...)
  const handleUpdate = useCallback(...)
  useEffect(...)
  return {
    rectSize,
    size,
    padding,
    centerCircleRadius,
    records,
    totalPoint,
    progress
  }
}

表示領域は正方形なので、ref 要素の短辺を取得します。 ref は Custom Hooks で宣言せず、usePieChart を利用するコンポーネントから注入します。

components/usePieChart.ts
const rectSize = useMemo(
  () => {
    if (props.ref.current === null) return 0
    const { width, height } = props.ref.current.getBoundingClientRect()
    return width > height ? height : width
  },
  [ props.ref.current ]
)

余白を除外した描画領域サイズを取得します。この余白が svg を書く時のコツです。svg サイズいっぱいに描画してしまうと、線が見切れたり、今回施している様なドロップシャドウが欠けてしまいます。

components/usePieChart.ts
const padding = useMemo(
  () => {
    return rectSize * props.padding
  },
  [ rectSize, props.padding ]
)
const size = useMemo(
  () => {
    return rectSize - padding * 2
  },
  [ rectSize, padding ]
)

マウント後所定時間アニメーションするため、その進捗(progress)を保持しており、useEffect で更新を行います。

components/usePieChart.ts
useEffect(
  () => {
    let interval: any
    if (progress !== 1) {
      interval = setInterval(handleUpdate, 8)
    }
    return () => clearInterval(interval)
  },
  [ progress ]
)

Context Hooks

型付き空 context を作成し、Provider コンポーネントで usePieChart の戻り値を注入します。

components/contexts.ts
export const PieChartContext = createContext(
  {} as ReturnType<typeof usePieChart>
)
components/provider.tsx
export default (props: Props) => {
  const ref = useRef(null! as HTMLDivElement)
  const value = usePieChart({
    ref,
    padding: 0.05, // 描画領域短辺の5%分余白を上下左右に設ける
    centerCircleRadius: 0.3, // 描画領域短辺の30%分半径の円を中央に設ける
  })
  return (
    <PieChartContext.Provider value={value}>
      <div ref={ref}>{props.children}</div>
    </PieChartContext.Provider>
  )
}

Chart

svg tag wrapper です。props.children で svg elements を配置します。filter 要素をここに起きますが、id名 は html内で一意である必要があるので注意してください。

components/chart/index.tsx
const View = (props: Props) => (
  <svg
    viewBox={`0 0 ${props.rectSize} ${props.rectSize}`}
    width={props.rectSize}
    height={props.rectSize}
  >
    <filter id="drop-shadow">
      <feGaussianBlur stdDeviation="8" />
    </filter>
    {props.children}
  </svg>
)

export default (props: { children?: React.ReactNode }) => {
  const { rectSize } = useContext(PieChartContext)
  return useMemo(
    () => <View rectSize={rectSize}>{props.children}</View>,
    [rectSize]
  )
}

扇コンポーネント配列です。ドロップシャドウのために一扇につき path を2つ書きます。svg filter は負荷が高いので、低スペック環境ではレンダリングがモタつきます。パフォーマンスと表現のトレードオフですね。

components/chart/pies.tsx
const View = (props: Props) => {
  let currentDeg = 0
  return (
    <>
      {props.records
        .map((record, index) => {
          const radius = props.size * 0.5
          const value =
            (record.point / props.totalPoint) *
            99.999 *
            props.progress // アニメーションの進捗を形状に反映
          const K = (2 * Math.PI) / (100 / value)
          const x = radius - Math.cos(K) * radius
          const y = radius + Math.sin(K) * radius
          const long = value <= 50 ? 0 : 1
          const T = radius + props.padding
          const M = `${T},${T}`
          const L = `${T},${props.padding}`
          const A = `${radius},${radius}`
          const XY = `${y + props.padding},${x +
            props.padding}`
          const d = `M${M} L${L} A${A} 0 ${long},1 ${XY} z` // 形状指定文字列
          const rotd = `${currentDeg * props.progress}`
          const rotx = `${T}`
          const roty = `${T}`
          const transform = `rotate(${rotd}, ${rotx}, ${roty})` // 扇の回転角
          const path = (
            <g key={index}>
              <path
                d={d}
                transform={transform}
                filter="url(#drop-shadow)"
              /> // ドロップシャドウの扇
              <path
                d={d}
                transform={transform}
                fill={record.color}
                onClick={() => console.log(record.name)}
              />
            </g>
          )
          currentDeg +=
            (360 * record.point) / props.totalPoint
          return path
        })
        .reverse()} // 影の重ね順を反転させる
    </>
  )
}
5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2