医学生わくばの欲張り技術ブログ

〜E資格を取りに行く!〜

扇形を描画したい初心者の格闘日記〜SVG vs Canvas〜

 

 どうも、わくばです!

 ReactNative  アドベントカレンダー10日目です!

 

 アドベントカレンダーは初投稿で、拙い内容かもしれないんですが箸休め程度に楽しんでいただければと思います。

 

 プログラミングはまだ始めて半年も経っていない初心者ですが、エンジニアの方々ともっと絡みたいと思って今回アドベントカレンダーに参加させていただきました。

 Twitterやってるのでぜひぜひフォローお願いします!!

 

概要

 こんな感じのUIを作るために、SVGCanvasを用いて扇形の描画を検討してみた記事です。

 

f:id:PYEcracker99:20201208110136p:plain

完成イメージ



目次

 

自己紹介

 現在医学部5年です。医療分野においてもITリテラシーが重要だというのはヒシヒシ感じていましたが、明確なインセンティブを持てずなんとなく過ごしておりました。将来的に僻地医療に携わりたいので、絶対に役立つと思ってるんですが。そんな中、半年前に医師からエンジニアへ転職された先生の登壇に感銘を受け一念発起し、本格的に勉強を始めました。当初右も左も分からず、なんとなく医学と親和性の高そうなAIの分野から始めようということでPythonからスタート。G検定合格したあたりから、自分のやりたいことはAIアルゴリズムの作成ではなく現場で使えるツール作りであることに気づき、8月ごろにアプリ開発の勉強を始めました。

 現状やっていることは医療とは一切関係ないので、その点ご了承ください。

 

 

動機

プログラミングを学ぶに当たり何か自分で作るのが得策だと思い、勉強の一環としてなにか作ろうと思ったのがきっかけです。  扇形の描画方法を模索しようと思ったのは、React Nativeで上の写真のような円形のスケジュールアプリが作りたかったからです。医学の勉強で時間に追われる身としてはスケジューリングアプリが真っ先に思い浮かびました。特に円形のアプリが一日を一目で定量的に把握できるので好みです。  

 

扇形の条件 

 最初は円グラフを作ればいいんだと思って突き進んでしまったのですが、円グラフだと割合(%)で円を作るので、一個一個の弧に対して開始時間と終了時間が指定できないことに途中で気づきました笑。

  泣きながら扇形を単体で描画できるツールを探すんですがこれがなかなか大変、、、、、。

 ツールに求める機能は以下の2つ!

  1. 扇の開始角度と終了角度を極座標的なパラメータを用いて変更できる
  2. 複数の扇をある中心点から放射状に配置できる  

  React Nativeではd3.jsVictory Nativeのような、グラフや表を描画するモジュールはすでに存在しますが、前述の問題点のように一個一個の扇の開度を自由に指定できず、割合(%)で円グラフを作る機能がほとんどなので残念ながら却下。したがってデータビジュアリゼーション系のものではなく、図形描画を自由に作成できるものを探し始めたんですが、初心者ではドキュメントを読むだけでどこまで実装できそうか検討をつけるのは難しく、片っ端から実装していって吟味する事になりました。トホホ。。。。

 

使用を検討したツール

 プロジェクト自体はTypeScriptのExpo(v3.27.6)で作成しています。初心者はTypeScriptのように静的型付けのほうがバグの検索がしやすいかなと思ったためです。

 扇形の作成において使用を検討したAPIは以下の2つです。

 単に扇形を作るだけならCSSでもできますが、調べる限りCSSなどでは角度を自由に変化させるのが難しそうでした。(できる方法をご存じの方がいましたらぜひ教えていただけるとありがたいです)

 

SVGを用いた扇

  最初にご紹介するのはSvgを用いるものです。一番素直な方法かなと思います。以下にコンポーネントのコードを添付いたします。時計の部分は含まれていないので注意してください。

 

import React from "react";
import { Dimensions, Platform, PixelRatio } from "react-native";
import { G, Path, Text } from "react-native-svg";

//中心座標を取得
const { width } = Dimensions.get("window");
const outerRadius = width / 2.24;
const centerWidth = width / 2;
const innerRadius = outerRadius / 2 + 12;
const cx = centerWidth;
const cy = centerWidth;

const fontScale = width / 880;
const adjustableScale = (size: number) => {
  const newSize = size * fontScale;
  if (Platform.OS === "ios") {
    return Math.round(PixelRatio.roundToNearestPixel(newSize));
  } else {
    return Math.round(PixelRatio.roundToNearestPixel(newSize)) - 2;
  }
};

type Props = {
  startDegree: number;
  finishDegree: number;
  color: string;
  children: string;
};

//扇形のコンポーネント
const FanShape = (props: Props) => {
  const { startDegree, finishDegree, color, children } = props;
//扇形の開始座標 const outerStartX = cx + outerRadius * Math.sin((startDegree / 180) * Math.PI); const outerStartY = cy - outerRadius * Math.cos((startDegree / 180) * Math.PI); //扇形の終了座標 const outerFinishX = cx + outerRadius * Math.sin((finishDegree / 180) * Math.PI); const outerFinishY = cy - outerRadius * Math.cos((finishDegree / 180) * Math.PI); //扇形の角度が180度を超えているか; const largeArcFlag = finishDegree - startDegree <= 180 ? 0 : 1; //内円 const innerStartX = cx + innerRadius * Math.sin((startDegree / 180) * Math.PI); const innerStartY = cy - innerRadius * Math.cos((startDegree / 180) * Math.PI); //円弧の終わり座標; const innerFinishX = cx + innerRadius * Math.sin((finishDegree / 180) * Math.PI); const innerFinishY = cy - innerRadius * Math.cos((finishDegree / 180) * Math.PI); //弧の中点 const outerMediumX = cx + outerRadius * Math.sin((((startDegree + finishDegree) / 2 + 90) / 180) * Math.PI); const innerMediumX = cx + innerRadius * Math.sin((((startDegree + finishDegree) / 2 + 90) / 180) * Math.PI); const outerMediumY = cy - outerRadius * Math.cos((((startDegree + finishDegree) / 2 + 90) / 180) * Math.PI); const innerMediumY = cy - innerRadius * Math.cos((((startDegree + finishDegree) / 2 + 90) / 180) * Math.PI); //ラベルのポジション const xPositionOfLabel = outerMediumX / 2 + innerMediumX / 2; const yPositionOfLabel = outerMediumY / 2 + innerMediumY / 2; //円弧を描くコマンド return ( <G> <Text fill="black" stroke={color} fontSize={adjustableScale(45)} fontWeight="bold" x={xPositionOfLabel} y={yPositionOfLabel} textAnchor="middle" > {children} </Text> <Path d={`M${innerStartX},${innerStartY} A${innerRadius},${innerRadius} 0 ${largeArcFlag} 1 ${innerFinishX},${innerFinishY} L${outerFinishX},${outerFinishY} A${outerRadius},${outerRadius} 0 ${largeArcFlag} 0 ${outerStartX},${outerStartY} Z`} fill={color} fillOpacity="0.5" stroke="grey" /> </G> ); }; export default FanShape;

 

f:id:PYEcracker99:20201208110136p:plain

SVGの描画結果

f:id:PYEcracker99:20201208142108j:plain




 propsを変更しリアルタイムで変化するかチェック↓

テンプレートリテラルを用いてPathのコマンドに変数を埋め込んでいます。color, startDegree, finishDegree, childrenをpropsとして親コンポーネントで指定すれば自由に扇形の開度と色調を変更できます。

 SVGのPathは小文字のコマンドでは直前の位置からの相対位置を指定でき、大文字のコマンドでは左角を0とした絶対位置を指定できます。上のコードは絶対位置で描画しています。コマンドについてSVGで円グラフを描く | Web活 が非常に参考になりますのでご興味ある方はどうぞ。

メリット

 非常に簡潔に書けると感じました。定数の宣言をし、テンプレートリテラルでPathに投げ込むだけです。

デメリット

  これは僕自身のコーディング力の問題でもありますが、Canvasと比べて扇の作成部分の位置取りが細かいので若干冗長に感じます。また初心者にとってPathのコマンドが非常に学習コストが高いのではないかと思います。特にarcを描くコマンドは独特です。

 

Canvasを用いた扇

 続いてCanvasを用いた扇形です。まずは扇のコンポーネントのコードです。 同じく時計の部分のコードは含まれません。

 

import React, { useRef, useEffect } from "react";
import { StyleSheet } from "react-native";
import Canvas from "react-native-canvas";
import { Dimensions, PixelRatio, Platform } from "react-native";

//window情報を取得し半径や中心座標を決める
const { width } = Dimensions.get("window");
const outerRadius = width / 2.24;
const centerWidth = width / 2;
const innerRadius = outerRadius / 2 + 12;
const cx = centerWidth;
const cy = centerWidth;

//テキストサイズを画面に応じて変化させる関数
const fontScale = width / 880;
const adjustableScale = (size: number) => {
  const newSize = size * fontScale;
  if (Platform.OS === "ios") {
    return Math.round(PixelRatio.roundToNearestPixel(newSize));
  } else {
    return Math.round(PixelRatio.roundToNearestPixel(newSize)) - 2;
  }
};

type Props = {
  startDegree: number;
  finishDegree: number;
  color: string;
  label: string;
};

const FanShapeByCanvas = (props: Props) => {
  const { startDegree, finishDegree, color, label } = props;
  const canvasRef = useRef(null);
  const getContext = (): CanvasRenderingContext2D => {
    const canvas: any = canvasRef.current; //キャンバスを画面サイズに合わせて正方形にとる
    canvas.width = width;
    canvas.height = width;
    return canvas.getContext("2d");
  };

  useEffect(
    () => {
      //propsの変化時にラベルの位置を取り直す
      let xPositionOfLabel =
        cx +
        (Math.cos(((startDegree + finishDegree) / 2 / 180) * Math.PI) *
          (innerRadius + outerRadius)) /
          2;
      let yPositionOfLabel =
        cy +
        (Math.sin(((startDegree + finishDegree) / 2 / 180) * Math.PI) *
          (innerRadius + outerRadius)) /
          2;

      //context作成
      const ctx: CanvasRenderingContext2D = getContext();
      ctx.beginPath();

      //内円
      ctx.arc(
        cx,
        cy,
        innerRadius,
        (startDegree / 180) * Math.PI,
        (finishDegree / 180) * Math.PI,
        false
      );

      //外円
      ctx.arc(
        cx,
        cy,
        outerRadius,
        (finishDegree / 180) * Math.PI,
        (startDegree / 180) * Math.PI,
        true
      );
      ctx.closePath();
      ctx.strokeStyle = "#0000ff";
      ctx.fillStyle = color; //color propsを受け取る
      ctx.stroke();
      ctx.fill();
      ctx.font = `bold ${adjustableScale(45)}px Arial`; //画面のサイズ変化に応じてテキストサイズを変化させるadjustableScale関数を上で定義しています
      ctx.fillStyle = "black";
      ctx.fillText(label, xPositionOfLabel - 25, yPositionOfLabel, 300);
    },
    [startDegree, finishDegree, color, label] //canvasは副作用で図を描くので角度や色の変更があった際は再描画が必要。そのためuseEffectのトリガーをpropsにする
  );

  return <Canvas style={styles.canvas} ref={canvasRef} />;
};

const styles = StyleSheet.create({
  canvas: {
    position: "absolute",
    opacity: 0.5,
  },
});

export default FanShapeByCanvas;

f:id:PYEcracker99:20201208131031p:plain

Canvasの描画結果

f:id:PYEcracker99:20201208142148j:plain

メリット

 円弧の作成部分のコードだけ見るとCanvasのほうがSVGより簡略に記述できるので使いやすいとは思います。arcの設計がSVGほどクセがなくて、シンプルに極座標なので比較的直感的です。

 

デメリット

 致命的ですがExpo非推奨になっています笑

 またCanvas属性をTypeScriptで扱おうとするとエラーが吐かれるので、そこのやりくりがめんどくさいです。多分普通のJavaScriptだといけると思います。TypeScriptはまだ学習中なのでだいぶ苦戦しました(汗) 上のコードでは

const getContext = (): CanvasRenderingContext2D => {
        const canvas: any = canvasRef.current;
        canvas.width = 500;
        canvas.height = 500;
        return canvas.getContext('2d');
    };

においてCanvasRenderingContext2Dとconst canvas: any = canvasRef.current;の部分で明示的に型を宣言しています。

 CanvasをReactで用いる場合useRefでDOMの参照を維持しておかないといけないのでその点が初心者には難解だと感じました。

  またちゃんと速度を図らないと正確なことは言えませんが、Canvasのほうが若干遅いかなと感じます。一般的にCanvasのほうが早いらしいんですが、僕の書いたコードですとpropsに変更が加わるたびにuseEffectが走って再レンダリングされるのでその分遅くなるんだと思います。ここはもう少し勉強が必要だと感じました。

 

 

まとめ

 SVGでもCanvasでもほぼ同じ図形が描画でき、両者とも角度の変更など柔軟な対応ができそうでしたが、今回はSVGに軍配が上がりそうです。というのも、react-native-canvasは公式で非推奨ですし、Githubのissuesなどもだいぶ放置されていて対応が追いついていない感じがありました。

 またCanvasは軽量で、アニメーションなどを描画する際に効果を発揮するそうなので、今回は非推奨を無視してまで使うほどメリットはないと思われます。

 SVGならPathさえ習得すればおそらく好きなように図形が作れますし、今後のことを考えればSVGに慣れておくのが賢明と判断しました。

 

 

 誤っている点のご指摘やご意見などございましたら遠慮なくコメント下さい。メッチャクチャ嬉しいです!

 エンジニアの方々ともっと絡みたいので遠慮は無用です!ぜひTwitterの方もフォローお願いします!

 つくりたいアプリがたくさんあるので引き続きReact Nativeの学習頑張っていこうと思います。

 では。