Genkanテックブログ

S3 + CloudFront + CircleCI on GitFlow で構成する快適な開発&デプロイ環境

初めまして!
ソフトウェアエンジニアでインターン生の都築です。
サーバサイドやDevOpsを中心にフロント、インフラ、たまにはハードウェアまで広く薄く(?)やっています。
今回はGitFlowに沿ってReactアプリケーションを開発して、CircleCIでS3に自動デプロイ、CloudFrontでCDNにのせるという一連の流れについて書いてみたいと思います。

課題と方針

創業初期は人数が少なく「それぞれがローカルで開発して動かしてみる -> ある程度まとまったら手動でビルドしてデプロイ」という開発フローで回っていましたが、より効率的な開発をしていくために開発環境、デプロイ環境を見直すことになりました。
まず、Git周りを整理するため「A successful Git branching model」を導入することにしました。
GitFlowはその導入を支援するツールですが、ここでは便宜的に「A successful Git branching model」をGitFlowと呼ぶことにします。
そしてCIも導入することになりました。
いくつか選択肢はありましたが、手軽に導入できそうなCircleCIを選びました。

前提と注意

  • AWSアカウントを持っている前提です。
  • 今回は独自ドメインの設定は行いません。
  • CircleCI用のIAMユーザー(以下ではcircle_ci)を作ってください。権限はS3のフルアクセスとCloudFrontのフルアクセスを与えてください。
  • GitHubと連携したCircleCIアカウントを作ってください。

大まかな流れ

  1. S3バケットを作りポリシーを編集する
  2. CloudFront Distributionを作る
  3. GitHubリポジトリとCircleCIプロジェクトを作る
  4. CircleCIの設定を行う
  5. GitFlowに沿って開発してみる

S3バケットを作りポリシーを編集する

S3バケットを作る

まずはS3バケットを作りましょう。
f:id:Genkan:20190417205947p:plain
- 本番用: genkan.example
- ステージング用: genkan-stg.example
- 開発用: genkan-dev.example

の3つを作りました。
パブリックアクセス設定についてはとりあえず全てFalseでいいと思います。
それぞれバケットホスティングも有効にしておきましょう。

バケットポリシーを編集する

例としてgenkan-dev.exampleバケットポリシーを挙げておきます。

{
    "Version": "2012-10-17",
    "Id": "Policy1000000000000",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::{AWSアカウントID}:user/circle_ci"
            },
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::genkan-dev.example",
                "arn:aws:s3:::genkan-dev.example/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::genkan-dev.example/*"
        }
    ]
}

{AWSアカウントID}には適切なIDをおいてください。
1つ目のStatementはCircleCIがデプロイできるように設定しています。
2つ目のStatementはアプリケーションを公開するために設定しています。
この設定を作成したS3バケット全てにしてください。
バケットポリシーについてはこちらが分かりやすいです。

CloudFront Distributionを作る

本番用のgenkan.exampleに対応するもののみ作ります。
今回は以下のように設定しました。
f:id:Genkan:20190417210040p:plain

Distributionの作成についてはこちらが詳しいです。
Default Root Objectにはindex.htmlを指定するのをお忘れなく。
ついでにCustom Error Responseについても設定しておきましょう。
cer.png これは以前「CloudfrontReactアプリケーション上でハードリロードをすると403が返ってくる」という現象に遭遇し、それに対処するため設定しました。 参考

GitHubリポジトリとCircleCIプロジェクトを作る

GitHubリポジトリを作ります。
とりあえずクローンしてcreate-react-appしてプッシュしておきます。

git clone git@github.com:genkan-team/genkan-example.git
cd genkan-example
npx create-react-app .
git add .
git commit -m "Initial commit"
git push

次にCircleCIのセットアップを行います。
Add Projectsからリポジトリを選択(Set Up Project) ci-setup そしてStart buildingしてみましょう。
ci-sb.png

.circleci/config.ymlを設定していないのでFAILEDになっていると思います。

次で設定していきます。

CircleCIの設定を行う

CircleCIがデプロイできるようにする

まずCircleCIのEnvironment VariablesにCircleCI用のIAMのアクセスキーとシークレットアクセスキーを設定します。
それぞれNameは
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY

としてください。
こうすることでCircleCI上でAWS CLIを用いてデプロイすることが可能になります。

設定ファイルを編集する

.circleci/config.ymlを設定していきます。

version: 2.1

executors:
  default:
    docker:
      - image: circleci/node:10.11.0-browsers
    working_directory: ~/genkan-example
  deploy:
    docker:
      - image: circleci/python:3.6.5


commands:
  yarn_install:
    steps:
      - restore_cache:
          name: Restore yarn cache
          keys:
            - v1-npm-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}
      - run:
          name: yarn install
          command: yarn install
      - save_cache:
          name: Save yarn cache
          key: v1-npm-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}
          paths:
            - node_modules
  yarn_build:
    steps:
      - run: yarn build
  install_awscli:
    steps:
      - run: sudo pip install awscli


jobs:
  build:
    executor: default
    steps:
      - checkout

  # ビルド
  build-dev:
    executor: default
    steps:
      - checkout
      - yarn_install
      - yarn_build
      - persist_to_workspace: &default-persist
          root: .
          paths: .
  build-stage:
    executor: default
    steps:
      - checkout
      - yarn_install
      - yarn_build
      - persist_to_workspace:
          <<: *default-persist
  build-master:
    executor: default
    steps:
      - checkout
      - yarn_install
      - yarn_build
      - persist_to_workspace:
          <<: *default-persist

  # デプロイ
  # デプロイ先のS3バケットについては適宜変更してください 
  deploy-dev:
    executor: deploy
    steps:
      - attach_workspace:
          at: .
      - install_awscli
      - run:
          name: Deploy to genkan-dev.example
          command: aws s3 sync ./build/ s3://genkan-dev.example/ --delete --exact-timestamps
  deploy-stage:
    executor: deploy
    steps:
      - attach_workspace:
          at: .
      - install_awscli
      - run:
          name: Deploy to genkan-stg.example
          command: aws s3 sync ./build/ s3://genkan-stg.example/ --delete --exact-timestamps
  # deploy-masterはCloudFlontのDistribution IDについても適切に変更してください
  deploy-master:
    executor: deploy
    steps:
      - attach_workspace:
          at: .
      - install_awscli
      - run:
          name: Deploy to genkan.example
          command: aws s3 sync ./build/ s3://genkan.example/ --delete --exact-timestamps
      - run:
          name: Update cloudFront
          command: aws cloudfront create-invalidation --distribution-id E3MA337W8TIFUE --paths "/*"


workflows:
  deploy:
    jobs:
      # developブランチ
      - build-dev:
          filters: &dev-filters
            branches:
              only: develop
      - deploy-dev:
          requires:
            - build-dev
          filters:
            <<: *dev-filters

      # releaseブランチ
      - build-stage:
          filters: &stage-filters
            branches:
              only: /release\/.*/
      - deploy-stage:
          requires:
            - build-stage
          filters:
            <<: *stage-filters

      # masterブランチ
      - build-master:
          filters: &master-filters
            branches:
              only: master
      - deploy-master:
          requires:
            - build-master
          filters:
            <<: *master-filters

# もっと美しく書けそうな気がするのでぜひフィードバックください...!!

コメントにもありますが、デプロイ先のS3バケットやCloudFrontのDistribution IDについては適切に変更してください。
developブランチ、releaseブランチ、masterブランチのそれぞれでビルドをしてその後デプロイ。masterブランチの場合だけデプロイ後にCloudFrontのアップデートをしています。
手元でconfigファイルを編集した際、

circleci config validate

を走らせると文法チェックなどができます。 参考

GitFlowに沿って開発してみる

ここまでできたら、とりあえず.circleci/config.ymlの変更をコミットしてプッシュしてみましょう。

git add .
git commit -m "Add CircleCI config file"
git push

そしてCircleCIのダッシュボードに飛んでみると...
masterブランチのビルドとデプロイが成功しているのが分かります。
初回だと少し時間がかかるかもしれません。
それではCloudFrontのDomain NameでURLを確認してそこに飛んでみましょう。

無事デプロイされているのが確認できました。

GitFlowの確認

これについてはgit-flow cheatsheetを参照するのが一番いいと思います。 これに沿って少しコードいじりながらやっていきます。

開発してみる

ここでは簡単に
App.jsを少し変更する -> developブランチがデプロイされているのを確認 -> リリース作業をスタートしてreleaseブランチがデプロイされているのを確認 -> 本番環境にデプロイ
という感じでやってみます。

まず

git flow init -d
git flow feature start try-git-flow

これでカレントブランチがtry-git-flowになったと思います。
App.jsEdit <code>src/App.js</code> and save to reload.の部分を原価管理SaaSのGenKanです!に変更します。
次は変更をコミットしてdevelopブランチにマージ、プッシュします。

git commit -am "Implement cool feature!"
git flow feature finish try-git-flow
git push -u origin develop

それではCircleCIのダッシュボードへ。
Jobが回っているのが確認できると思います。(画像省略)
開発用のS3のエンドポイントに飛んでみると...
きちんと変更が反映されていますね。
ちなみにエンドポイントは、S3のStatic website hostingを確認すれば分かります。
次はリリース作業です。

git flow release start v1.0
# ここでCHANGELOG.mdなどを編集することが多いですが、今回はそのままプッシュします。
git push origin release/v1.0

先程と同様にCircleCIのダッシュボードを見て、デプロイの完了を確認してステージング用のS3エンドポイントに飛んでみると、変更が反映されているのが分かると思います。
最後に本番環境へデプロイします。

git flow release finish v1.0
git push

CircleCIのJobの完了を待ってGitFlowに沿って開発してみるで確認したCloudFrontのエンドポイントに飛ぶと、同じように変更が反映されているのが確認できると思います。
以上が一連の流れになります。

最後に

念の為補足しておくと、S3のバケットについては必要に応じてステージング用や開発用にはIP制限をかけた方がいいかもしれません。
この記事が快適な開発&デプロイ環境を実現するための参考になれば嬉しいです。

Arduino UNO と TB6612FNGでDVDドライブのステッピングモータを制御

初めまして。KOSKAのハードウェアエンジニアでインターン生の上原です。
第二回技術ブログでは,Arduino UNOとデュアルモータドライバTB6612FNGを用いたステッピングモータの制御について書きます。 PCからの入力により,ステッピングモータのステップ数と向き,速さの制御が可能です。

Genkanはこのようにハードウェア開発をしたい方、ジャンク品の制御に挑戦してみたい方を募集中です! 興味があれば是非ご連絡ください!

GenKan実験機問題

GenKanは工作機械にセンサーやカメラをつけることで自動でサイクルタイムを取得し、原価計算を自動化するサービスです。

弊社では様々な手法で工作機械のサイクルタイムを簡単に計測するアルゴリズムを開発しています。

そこで問題になるのが、工作機械の実験をどのようにするかということです。 巨大な工作機を買って実験するのは非常に難しいですし、似たような動きをする機械を自作する必要がありました。

実験機の要件は以下の3つです。

  • 直動の動きを制御できる機械
  • 手軽に入手できる
  • 小さくてどこでも実験しやすい

その実験機として採用されたものの一つが「DVDドライブ」です。

DVDドライブを利用したGenKanカメラ実験

DVDドライブを使ったGenKanカメラ実験の様子

DVDドライブが内蔵しているモーターを制御することで、様々なタイミングで動く機械を再現し、実験機とすることができました。

DVDドライブのステッピングモータを制御

・使用部品

1. ステッピングモータ
2. Arduino UNO
3. デュアルモータドライバ (TB6612FNG)


・ステッピングモータ

ステッピングモータは,DCモータと異なり,図1のように複数のコイルを組み合わせることで,細かいステップ角で回転します。 磁極数が増えるとより細かく回転します。

f:id:Genkan:20190406132322p:plain:w480
図1) ステッピングモータの原理
https://www.edaboard.com/showthread.php?217270-cd-rom-drive-stepper-motor

図1のように,Step1から,A,Bのコイルの電流の向きが切り替わることで,1ステップにつき90°回転し,これを4回切り替えることで1周します。
今回は,DVDドライブの中のステッピングモータを使用しました。
f:id:Genkan:20190406132513j:plain:w480
図2) 今回使用したステッピングモータ


・TB6612FNG

TB6612FNGは,モータを2個接続できるモータドライバモジュールです。 A,Bの各チャンネルに入力が3本あり,1チャンネルで1つのモータを制御することができます。VMにはモータ用の電源を接続します。STBYピンをhighにすることで,スタンバイモードから解除できます。
f:id:Genkan:20190406132632j:plain


通常,ステッピングモータを制御するには,ステッピングモータドライバを使って制御しますが,今回用いたステッピングモータは動作するのに高い電圧を必要としないため,低電圧で制御が可能なデュアルモータドライバを用いて制御を行いました。


・TB6612FNGのピン配置

VM:ステッピングモータ供給電源(2.5–13.5V)
Vcc : ボード電源(5V)
A1, B1, A2, B2 : ステッピングモータへ
AIN2 : Digital 4ピンへ
AIN1 : Digital 5ピンへ
BIN1 : Digital 6ピンへ
BIN2 : Digital 7ピンへ
PWMA, PWMB : Vccピンへ
STBY : Vccピンへ


f:id:Genkan:20190406132705p:plain

図4) Fritzingによる実態配線図


電池からTB6612FNG のVMに繋ぐことで電源を供給します。
今回,Arduinoでステッピングモータを制御するプログラム作成するにあたり,ArduinoのStepper Libraryを用いました。
https://www.arduino.cc/en/Reference/Stepper

ソースリスト
//ステッピングモータ1つを制御するようのプログラム
//ステッピング1は(STEPS, 4, 5, 6, 7)、aで正,bで負回転
//serial入力例 100aで100回回転,200bで-200回回転
//20cで回転速度が20になる

#include <Stepper.h>
#define STEPS 200  // 用意したモータのステップ数に変える
Stepper stepper(STEPS, 4, 5, 6, 7);  // モータのステップ数とピンを指定して、ステッパークラスのインスタンスを作成。
// グローバル変数の宣言
char input[5];  // inputに5つの文字を格納できるようにする
int i = 0;      // inputに格納されている文字数のカウンタ
int val = 0;    // 受信した数値
int steps = 60;    // ステッピングモータのステップ数
int motorSelect = 0;//0はなし、1は正回転,2は負回転
void setup()
{
  Serial.begin(9600); //シリアル通信のレートを9600に設定
  Serial.println("Stepper test!");
  stepper.setSpeed(40);  // モータの速度を40に設定
}

// シリアル通信で受信したデータを数値に変換
int serialNumVal(){
  // データを受信したときの処理
  // inputのi=0からa, b, cのどれかの文字を見つける。いずれかがある場合inputFlagが1になり次の処理へ
  // 無い場合はinputFlagが0になり処理が終了
  if (Serial.available()) {
    input[i] = Serial.read(); //シリアル通信で送信された値を読み取る
     // 末尾がa,b,cそれぞれの処理
    char inputFlag = 0;
    char select_char = input[i];
    inputFlag = 1;
    switch(select_char){
      case 'a':                  //末尾にaを入力した場合
        motorSelect = 1;
        break;
      case 'b':                  //末尾にbを入力した場合
        motorSelect = 2;
        break;
       case 'c':                 //末尾にcを入力した場合
        motorSelect = 3;
        break;
    default:
     inputFlag = 0;              // 無い場合は,処理が終了
    }
    // a, b, cのどれかがある場合は以下の処理。inputの文字列を送信
    if(inputFlag == 1){
      input[i] = '\0';      // 末尾に終端文字の挿入
      val = atoi(input);    // 文字列を数値に変換
      Serial.write(input); // 文字列を送信
      Serial.write("\n");  //改行
      i = 0;      // カウンタの初期化
     inputFlag = 0;
    }
    else { i++; }
  }
  return val;
}

void loop()
{
  steps = serialNumVal();
  if(motorSelect == 1)       //末尾がaのとき
  {  
  stepper.step(steps);    //入力した数値分のステップ数だけ正の方向にモータが動く
  Serial.println(steps);  //入力した数値を表示
  Serial.println("st1+");  //st1+を表示
   motorSelect=0;
  }
  else if(motorSelect == 2)  //末尾がbのとき
  {  
  stepper.step(-steps);   //入力した数値分のステップ数だけ負の方向にモータが動く
  Serial.println(steps);  //入力した数値を表示
  Serial.println("st1-");  //st1-を表示
   motorSelect=0;
  }
  else if(motorSelect == 3)  //末尾がcのとき
  {  
  stepper.setSpeed(steps);  //入力した数値の速さで動く
  Serial.println(steps);    //入力した数値を表示
  Serial.println("setSpeed");//setSpeedを表示
   motorSelect=0;
  }
}

Arduinoのシリアルモニタから「100a」や「200b」などを入力することでステップ数や向きを変えることができ,「40c」などを入力することで速度を変えることができますので,数値をいろいろ入力し試してみてください。


実行例


f:id:Genkan:20190406141104g:plain

図5) 実行例


まとめ

今回は,ステッピングモータの制御をステッピングモータドライバを用いずにデュアルモータドライバを用いて行いました。
制御できる項目としては,

・ステップ数
・回転方向
・速度

です。

参考URL

ステッピングモータの制御
https://learn.adafruit.com/adafruit-tb6612-h-bridge-dc-stepper-motor-driver-breakout/using-stepper-motors

ステッピングモータの説明
https://monoist.atmarkit.co.jp/mn/articles/1606/10/news011.html
https://www.edaboard.com/showthread.php?217270-CD-ROM-Drive-Stepper-motor

ステッピングモータドライバ(TB6612FNG)の説明
https://www.switch-science.com/catalog/3586/
http://doc.switch-science.com/datasheets/TB6612FNG_datasheet_ja_20141001.pdf

データビジュアライゼーションライブラリD3.js講座①

初めまして。KOSKA代表兼エンジニア(メインはフロントエンドとインフラ)の曽根です。 今日は第一回技術ブログということで、弊社のフロントを支える技術D3.jsの解説記事を書こうと思います。 今日は基礎的な話ですが、今後はclippathなどを使ったかなり複雑なグラフの作り方も連載していこうと思います。

Genkanはこのようにデータビジュアライゼーションに強いフロントエンド開発をしたい方、またそういったデザインに挑戦してみたいデザイナーの方を募集中です! 興味があれば是非ご連絡ください!

D3.js講座①

GenkanとD3.js

弊社システムの「Genkan」は簡単なセンサーを使って現場のデータをリアルタイムに金額化していくシステムで、そのためにはもちろんセンサーアルゴリズムやデータ整合性を担保するバックエンドシステム開発は重要であるが、弊社のシステムに最も重要な要素の一つが「いかにわかりやすいグラフを作るか」である。

弊社は2018年10月創業なのでまだ半年しか経っていないが、その間にすでに3回のデザイン刷新が行われており、その度にグラフの形も変わっていった。

例えば初期は下のようなグラフを作っていた

Genkanグラフ画像1

現在では例えば生産量を表示するだけでも

現在の生産量と、それが目標に対して何パーセントなのかということと、前日に比べどの程度なのかを一度に乗せたグラフや Genkan生産量グラフ画像

前日と現在の生産量を1時間、1日単位で比較できるグラフ Genkan単位時間あたり生産量グラフ

このように、様々な形式のグラフが登場している。 これらは全て毎回弊社スーパーデザイナーの三田地(@HiroshiMitachi)が僕らの 「こういう意味をグラフに込めたいんだけどどう表現すればいいんだろう?」 という無茶振りに対して完璧なグラフをデザインしてくれているおかげである。(いつもありがとうございます)

デザイナーがここまでいいものを作ってくるとフロントエンドエンジニアの僕としては「どんな難しいグラフでも作ってやろう」という気分になる。 ただ、もう一番最初の段階でchartjsではカスタム性が全く足りない(Y軸がある数値以下のエリアは赤、それ以上は紫にするエリアグラフなんて要件絶対不可能・・・)

達成するならばグラフを1から作るしかない!ということで、弊社が採用したのがd3.jsであった。

そのため、今日はD3を使ったグラフ作成術を1からまとめた記事を書いていく。

d3.js(ver4)でグラフを一から作っていく

d3.js

d3.jsはビジュアライゼーションライブラリで、基本的にはsvgを操作するためのツールである。 d3.jsを利用したグラフライブラリが多いためグラフライブラリと勘違いされやすいが、絵をかいたりアニメーションしたりといったことに強い。

d3でグラフを作る前に

たいていのことはchartjsで可能である。

今回グラフに注釈をつけたり、複数グラフ組み合わせたり、自由にお絵描きできるようにする必要があったのでd3を学んだが、その辺も2.0になって複数グラフ可になったし、chart-js-annotationというライブラリで注釈つけたり(結構しょぼいが)できるようになった。 d3も意外と簡単だが、そこまで学ばなくてもchartjsは使えるので、本当に今の要件がchartjsで達成できないのか考える必要がある。

ちなみに弊社の場合は現在システムに存在するほぼ全てのグラフにおいてchartjsだとデザイナーさんの考えてくれたデザインを再現するのが不可能に近いのでこの採用は正解であった。

d3を覚えるにはまずsvgの仕組みを覚えなければならない

そんなに重くないのだが、svgを理解しないとd3は扱いづらい。基本的にはsvgを操作して描画することが多いからである。 SVGとはScalable Vector Graphics(スケーラブル・ベクター・グラフィックス)の略で、pngやjpgのような画像の形式の一つのようなものだが、特徴的なのはxmlで描画内容が書いてあるところである。 svgは以下のようにhtmlの中にsvgタグを用いて宣言する

<svg width="500" height="50">
</svg>

このsvgタグにより、この中ではsvgで使われるオブジェクトを宣言できる

<svg width=500 height=200>
    <circle cx="50" cy="50" r="10"/>
    <line x1=10 y1=80 x2=100 y2=100 stroke="black"/>
    <rect x=10 y=110 width= 90 height=50 fill= "red"/>
    <text x=10 y= 190 fill="darkgrey" font-size=25>test</text>
</svg>

このようなコードはこのような図になる

test

基本的にはsvgはhtmlと変わらない。注意してほしいのはスタイルで、上のように直接書くこともできるし、クラスやidを付けて、cssファイルにすることもできる。 直接書く場合はstyle="fill:red;"と書いても問題はないが、styleを入れないでhtmlプロパティとしても使える。 cssにするときの注意点としては、svgの時だけいくつかプロパティ名が変わることである。例えば background-colorfillというプロパティだし、border-colorstrokeになる。cssが効かないときはこれを疑う。

また注意点として座標が直感に反するというものもある。 svgの中では(0,0)が左上、x座標は右に行くほど大きいので普段と変わらないが、y座標は下に行くほど大きくなる。

(0,0) (300,0) (0,100)

d3を使ってsvgをいじる

上ではsvgを自ら書いたが、d3を使う場合svgからすべてコードで生み出す。 svgJQueryの書き方にかなり近く、メソッドチェーンでDOMを操作することができる(後述するがこれがreactと相性が悪い原因でもある)

<script>
    const svg = d3.select("body")
                   .append("svg")
                   .attr("width",500)
                   .attr("height",200)
       
</script>

これをbodyタグに埋め込めば先ほどのsvgをタグを埋め込んだことになる。 一応解説すると、 d3.select("body")によってJQueryのようにDOMをいじることができる。 .append("svg")によってsvgタグのDOMを追加、attr.("width",500)によってsvgタグのスタイルをいじっている。 さらにこれらはd3オブジェクトを返すので、これを変数svgに格納することで同じように様々なsvg操作ができる。

先ほどの画像を再現してみる

svg.append("circle").attr("cx",50).attr("cy",50).attr("r",10)
svg.append("line").attr("x1",10).attr("y1",80).attr("x2",100).attr("y2",100).attr("stroke","black")
svg.append("rect").attr("x",10).attr("y",110).attr("width",90).attr("height",50).attr("fill","red")
svg.append("text").attr("x",10).attr("y",190).attr("fill","darkgray").attr("font-size",25).text("test")

見ればわかると思うが、基本的にはappendしたsvgに対してさらにcirclerect,line,textを追加し、その後スタイルを設定している。

これでd3の基本的な動作は理解できたはず。ただこれだとただのSVGJQueryである。 d3の本領はグラフ描画やアニメーションなどで発揮される。

d3でグラフを作成する。

例えばd3を使わず散布図グラフを自作すると必要な要件として様々なものがある。

  1. データに合わせて散布図のx座標とy座標を決め、それを基にcircleを描画する
  2. データに合わせてx軸、y軸を調整する
  3. 散布図とx軸、y軸の位置がpx単位で合うように設定する
  4. データが更新された場合、アニメーションして変更する

このような要件を自動化してくれるのがd3の最も強い点である。 そのために必要な概念がいくつかある。

  • データバインディングをするdataメソッド
  • スケールという概念
  • axisの設定
  • d3の持つデータ更新メソッド(enter(),exit(),transition()等)

dataメソッド

まずはこのようなグラフを作る

散布図1

条件としては

  • x軸は0~9

  • y軸は0~900

  • y=100*xの直線に沿うように散布図を配置する

このようにグラフを作る場合、まず元となるデータの配列をビジュアル化する仕組みを作る

まずは元となるデータを生み出す

d3にはjsoncsvのパース機能まであるのでデータを扱うのは非常に容易だが、今回は動的に配列を作る。d3.range()メソッドを使うと、0から引数にとった数字までの配列を生み出す

const dataSet = d3.range(10).map(elem=>({x: elem, y:elem*100}))

これで

[
    {x:0,y:0},
    {x:1,y:10},
...
    ,{x:9,y:900}
]

の配列が生み出された。このデータをもとに配列散布図を作る。

配列から散布図の点を生み出す

この時使うのがデータバインディングメソッドであるdata() 以下のように書く

const svg = d3.select("body")
                .append("svg")
                .attr("width",500)
                .attr("height",200)
const dataSet = d3.range(10).map(elem=>({x: elem, y:elem*100}))

svg
.selectAll("circle")//<-バインドするためにこれから追加するDOMのセレクタを取得
.data(dataSet)//<-バインド。これ以降のメソッドはバインドされたオブジェクトを受け取れる
.enter()//<-selectAllで取得したDOMに対してデータが追加されている場合、このメソッドで検知する。詳しくは後述
.append("circle")//<-circleオブジェクトをappend
.attr("cx",(d)=>(d.x))//<- 配列のxをそのままx座標へ
.attr("cy",(d)=>(d.y))//<- 配列のyをそのままy座標へ
.attr("r",2)

ポイントとなるのは data(),enter(),attr(property,function)という書き方である

  • data() このメソッドによって引数にもらった変数を基に既存のDOMに対してd3オブジェクトリファレンスが作られる。つまりselectAllメソッドで取得したDOMとオブジェクトを結びつける役割を担う。 これが効果あるのはデータが変更された時だ。一度appendしたDOMはselectAll()で再び取得できるので、もう一度selectAll("circle").data(newDataSet)のようにやるとDOMを新しいデータに対応するように勝手に表示変更してくれる。

  • enter() data()はあくまで既存のDOM(selectAllで取得したDOM)とデータを結ぶものであり、データが追加されたときはそもそもそのようなDOMがない。 そのため、データに応じてDOMを追加する場合enter()メソッドを使う。 データ配列に対してこれは既存のDOMが少ないとき、まだ存在しない空のDOMのレファレンスを追加してリターンする。つまりこのenter()以降でappendすると、追加されたデータに対しての差分分だけオブジェクトを作り出すことができる。今はよくわからないかもしれないが、アニメーションを行う部分で再び解説する。

  • attr(property,function) attrメソッドは第一引数にプロパティ名を表すstring、第二引数には数字や文字なども置けるが、functionも置くことができる。 このコールバック関数は引数にバインドされたデータのオブジェクトを渡され実行される。そのため例えばこの例における.attr("cx",(d)=>d.x)は、dに{x: (number),y:(number)}という形で渡されるので、xを基にcircleの座標を設定することができる。

ここまで書いて、htmlを表示すると以下のようになる 散布図(miss)

何も移っていないように見えるだろうが左の方に微妙に点が見えていると思う。 なぜならx座標は1px~10px,y座標は0px~900pxという設定になっているからである。 これではx座標はほとんど変化ないし、y座標はheightが500しかないのではみ出でいる。 なのでcsとcyを調整する必要がある。まずはこれをいい感じになるように手動で調整してみる。

const svg = d3.select("body")
                .append("svg")
                .attr("width",500)
                .attr("height",200)
const dataSet = d3.range(10).map(elem=>({x: elem, y:elem*100}))

svg
.selectAll("circle")//<-バインドするためにこれから追加するDOMのセレクタを取得
.data(dataSet)//<-バインド。これ以降のメソッドはバインドされたオブジェクトを受け取れる
.enter()//<-selectAllで取得したDOMに対してデータが追加されている場合、このメソッドで検知する。詳しくは後述
.append("circle")//<-circleオブジェクトをappend
.attr("cx",(d)=>(d.x/10*500))//<- xが最大値と比べて何パーセントなのかを計算し(x/10)それに高さをかける(*500)
.attr("cy",(d)=>(d.y/900*200))//<- yが最大値と比べて何パーセントなのかを計算し(x/900)それに高さをかける(*200)
.attr("r",2)

このようにxとy座標を計算して表示してあげるといい感じになる。 散布図(改良1)

しかし、この計算を手動でやるのはめんどくさい。しかもかなりずれる。小数点の丸めなどを考慮しないといけないからである。 もちろんD3はそこをやってくれるメソッドを持っている。それがscale系のメソッドである。

scale

scaleというのはd3の概念で、言ってみれば入力されたデータと座標のマッピングを助けるものである。 d3の最も強力な機能といっても過言ではない。 xが1~10の間にあり、widthが0~500pxの場合、例えばxが2の場合、2pxにおいたら先ほどのように左端に表示されてしまう。 本来この場合は2*50の100pxの場所にあるのが適切だろう。これをいい感じに計算してくれるのがscale関数。

ver3とver4の違い

d3は少し前にver4へのメジャーアップデートがあったため、ver3で使っていた関数が使えなくなっている。 スケールの書き方も変わっているので注意。

//ver3
d3.scale.linear()
//ver4
d3.scaleLinear()

なのでバージョンに注意しながら使う必要がある。ここではver4の書き方で書く。

domain()range()

d3.scaleLinear().domain()は入力する配列の範囲を指定する。

d3.scaleLinear().domain([0,10])

これによって0~10までの入力があるということになる。 ただ、これだと配列の最大値を必ず確定させなけらばならないのだが、配列が動的の場合d3.max()という関数が使える。 この関数は配列を入れると最大値を返す簡単な関数である。また、d3.max(array,function(element))のようにし、コールバック関数でどのようなものから最大値を選ぶかも選択できる。

d3.scaleLinear().domain([0,d3.max(array)])

また、d3.scaleLinear().range()はpxにしたとき何ピクセルから何ピクセルにするかを決める関数。 これにより先ほどのdomain関数で決めたインプットの範囲をpxのどの範囲にアウトプットするかが決まる。

d3.range()d3.scaleLinear().range()は大きく意味が違うので注意。 d3.range()は引数の数字分配列を作る関数であるが、d3.scaleLinear().range()は出力するピクセルの範囲を指定する関数になる。

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()

このコードによって0,10の範囲にある点を0pxから500pxの間にマッピングする。domainとrange関数の戻り値はscale関数である。 このscale関数に数字を入れるとマッピングされた座標を得ることができる。 nice()丸め誤差をいい感じにしてくる関数。 基本的につけておいた方がいいくらいいい感じにしてくれるナイスな関数。

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()
xScale(5)//<-250

これを使って先ほどのコードを書き替えると

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const dataSet = d3.range(10).map(
      (elem) => {
        return {x: elem, y: elem * 100}
      }
      )

svg
    .selectAll("circle")//<-バインドするためにこれから追加するDOMのセレクタを取得
    .data(dataSet)//<-バインド。これ以降のメソッドはバインドされたオブジェクトを受け取れる
    .enter()//<-selectAllで取得したDOMに対してデータが追加されている場合、このメソッドで検知する。詳しくは後述
    .append("circle")//<-circleオブジェクトをappend
    .attr("cx",(d)=>(d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([0,500]).nice()(d.x)))//<- 0~10を0px~500pxにマッピング
    .attr("cy",(d)=>d3.scaleLinear().domain([0,d3.max(dataSet, (elem)=>elem.y)]).range([0,200]).nice()(d.y))//<- 0~900を0px~200pxにマッピング
    .attr("r",2)

これで前と全くおなじ図が出る。ただし、今度は例え最大値が変わっても全く問題ない。データがランダムになってもスケールをいい感じにしてpxにマッピングしてくれる。 ただこれだと高ければ高いほど下に配置される図ができる。svgにおいてy座標は低ければ低いほど上に行く図になるからである。

これを逆転させるには0~500pxの高さと設定したものを500px~0pxという風に設定するとy軸のマッピングを簡単に逆転させることができる。

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const dataSet = d3.range(10).map(
      (elem) => {
        return {x: elem, y: elem * 100}
      }
      )

svg
    .selectAll("circle")
    .data(dataSet)
    .enter()
    .append("circle")
    .attr("cx",(d)=>(d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([0,500]).nice()(d.x)))
    .attr("cy",(d)=>d3.scaleLinear().domain([0,d3.max(dataSet, (elem)=>elem.y)]).range([200,0]).nice()(d.y))//<- range([0,200])をrange([200,0])に変更
    .attr("r",2)

これで以下のようにいい感じになる。 散布図(改良2)

axis

x軸y軸がないとグラフとして成立しないだろう。ただし、これもスケールを使うことでかなり簡単に書ける。

ver3とver4の違い

これもまたバージョンが違うと大きく書き方が変わる。

//ver3
const xAxis = d3.svg.axis().scale(xScale).orient("bottom")

//ver4
const xAxis = d3.axisBottom(xscale)

ver4はかなり見やすくなった。

軸オブジェクト

軸オブジェクトを作るには、上に書いてあるようにスケールを引数に入れるだけでいい感じに軸を作ることができる。 これが非常に強力で、同じスケールを使うことで軸とグラフの座標を完璧に一致させることができる。 例えばx軸を上の例で作ると、

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()
const xAxis = d3.axisBottom(xScale)

これで軸オブジェクトを作る。ただし、これをグラフに追加するときはひと工夫必要である。svgには軸オブジェクトに相当するタグがない。 そのためgroupという他のタグをひとまとめにするグラフを用意し、それを使って軸を描画する。

group

まず最初に、axisを描く場所を作る。この時groupエレメントを用いて、空の箱のようなものを作成する。

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const group = svg.append("g")

このグループというものは表示するものは何も持っていない。ただし、他のエレメントを囲いグループ化できる。 また、そのグループ全体に座標変換を行うことができるという特徴もある。

さて、軸追加の場合、追加したグループを軸に変換する形になる。

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()
const xAxis = d3.axisBottom(xScale)

const group = svg.append("g")
group.call(xAxis)

これで以下のようになる

散布図と軸

ただ、これの問題点はaxisBottom()としてるのにかかわらず、軸が上にあることである。 これはaxisというものはgroupの座標に依存してるからである。それではBottomで何が変わっているかというと、軸の数字が下になっている。 これは下にある軸の時に適切な配置になるように軸の数字をいい感じに配置してくれているのだ。 それではどのように軸を下に置くかというと、groupの座標変換機能を使う。

group.attr('transform', `translate(0,${(180)})`).call(xAxis)

高さが200なので200移動してしまうとsvgからはみ出でしまうので、180下に移動させる。

散布図と軸2

これである程度作れるようになった。同じようにy軸も作って、さらにいい加減はみ出ている部分も調節する。 scaleのrangeをある程度余裕をもって作ればよい。

//データセットを作成
const dataSet = d3.range(10).map(
  (elem) => {
    return {x: elem, y: elem * 100}
  }
)


//幅と高さ設定
const width = 500;
const height = 200;

//それぞれのpaddingを設定
const xPadding= 50;
const yPadding = 20;

//設定した幅と高さのsvgを追加
const svg = d3.select("body")
  .append("svg")
  .attr("width",width)
  .attr("height",height);


// scaleを作成。rangeにpaddingを設定
const xScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([xPadding, width - xPadding]).nice()

const yScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.y)]).range([ height - yPadding,yPadding]).nice()


svg
    .selectAll("circle")
    .data(dataSet)
    .enter()
    .append("circle")
    .attr("cx",(d)=>xScale(d.x))
    .attr("cy",(d)=>yScale(d.y))
    .attr("r",2)

// padding分を考慮して軸を移動させる
svg.append("g").attr('transform', `translate(0,${(height - yPadding)})`).call(d3.axisBottom(xScale))

// axisLeftを使う。そのうえで軸の位置を移動させる
svg.append("g").attr('transform', `translate(${(xPadding)},0)`).call(d3.axisLeft(yScale))

ようやくまともなグラフが完成した。

散布図完成

長い闘いだったが、これでようやくまともなグラフが作れるようになった。

さて、次はデータを更新していく。

データ更新(アニメーション)

例えば、ランダムにデータを更新していく。グラフがアニメーションして更新されたいと考えることがあるだろう。

その場合d3は非常に強力なメソッドを持っている。transition()である。これを利用するとデータが更新されたときいい感じにアニメーションされる。

まずデータを更新できるように、軸の


// クラスに x axisを追加する
svg.append("g").attr("class","x axis").attr('transform', `translate(0,${(height - yPadding)})`).call(d3.axisBottom(xScale))


// クラスに y axisを追加する
svg.append("g").attr("class","y axis").attr('transform', `translate(${(xPadding)},0)`).call(d3.axisLeft(yScale))

データ更新用関数を作る。transitionはデータバインディングをした後に行うとデータからアニメーションが適応される。

function changeData(){

    //データセットを作成
    const dataSet = d3.range(10).map(
      (elem) => {
        return {x: elem, y: elem * 100 * Math.random()}
      }
    )
    
    const svg = d3.select("svg")

    // scaleを作成。rangeにpaddingを設定
    const xScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([xPadding, width - xPadding]).nice()

    const yScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.y)]).range([ height - yPadding,yPadding]).nice()
    

    svg
        .selectAll("circle")
        .data(dataSet)
        .transition()//アニメーション追加
        .duration(1000)
        .attr("cx",(d)=>xScale(d.x))
        .attr("cy",(d)=>yScale(d.y))
        .attr("r",2)

    svg.select(".x.axis").transition().duration(1000).call(d3.axisBottom(xScale))//アニメーション追加
    svg.select(".y.axis").transition().duration(1000).call(d3.axisLeft(yScale))//アニメーション追加
}

どうせならボタンを押したら更新されるようにする。 適当にbuttonを置いて関数実行させる。

<button onclick="changeData()">change</button>

散布図アニメーション

enter(),exit()

上の場合、データの長さが変わったときにその変更は無視される。つまり最初が10個しかない場合、更新されたデータが11個だった場合、グラフの点は増えずに位置だけ変わる。 そもそもtransitionというのは既存DOMとデータバインディングの差にアニメーションを適用していくものなので、DOMがないものには適用できない。 よってそれをするためにはほかに必要なことがある。

まずうまくやるためには先に差分のDOMを追加し、その後transitionを行う。この時に使えるのがenter()だ。 先ほどから何回か説明しているがこのenter()はデータが追加されたことを検知するもので、差分分のd3オブジェクトリファレンスが返される

svg
    .selectAll("circle")
    .data(dataSet)
    .enter()
    .append("circle")//<-先に差分を追加(座標は(0,0))


svg
    .selectAll("circle")
    .data(dataSet)
    .transition()//アニメーション追加
    .duration(1000)
    .attr("cx",(d)=>xScale(d.x))
    .attr("cy",(d)=>yScale(d.y))
    .attr("r",2)

svg.select(".x.axis").call(d3.axisBottom(newXScale))//アニメーション追加
svg.select(".y.axis").call(d3.axisLeft(newYScale))//アニメーション追加

これで座標0,0から点が追加されていくようなアニメーションになる。

また、exit()も同じような使い方だが、これは逆にデータが減ったときのものとなる。 このようにしてデータが増減しても、変更されてもうまくアニメーションすることができる。

終わり

今回は基本である

  • SVGの基本
  • dataメソッド
  • scaleメソッド
  • axis
  • データ更新(enter(), exit())

等についての記事を書いた。 次回はもっと複雑な描画をするためにはどうするのかということと、ReactでのD3.jsの使い方について書く予定。