LoginSignup
2

More than 3 years have passed since last update.

posted at

updated at

Organization

Pie Chart

今日から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()} // 影の重ね順を反転させる
    </>
  )
}

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
What you can do with signing up
2