D3.jsでセンサ情報をブラウザで見える化する

sensor_viwer_cut

前回までのセミオート・スマートホームを作っていく中で、センサが取得したログを1日ごとにCSVファイルの形で書き出すようにしていました。

date,weekday,time,temperature_t,humidity_t,illuminance_t,motion_t,temperature_l,humidity_l,illuminance_l,motion_l,door_l,door_l_jp,window_l
2015-07-31,Fri,00:00,28.32,40.99,114,5,29.09,67.78,291,1,1,1,0
2015-07-31,Fri,00:01,28.28,40.43,136,4,29.1,68.79,290,1,1,1,0
2015-07-31,Fri,00:02,28.32,40.28,104,3,29.09,69.09,117,2,1,0,0
...

こんな感じです。

このログを見える化してみると結構面白くて、自分や奥さんが、いつ、家の中のどこで行動していたか、というのがよくわかったりします。

はじめはこれを都度Raspberry Piから開発用PCにscpで落としてきて、Excelを使ってグラフ化していたのですが、いちいちそれをやるのが面倒になってきました。せっかくラピロに搭載したRaspberry Piをブラウザから制御できるように色々やってきたので、ログもブラウザから見えるようにした方が絶対楽なハズ。

ということで、風の噂(?)で聞いていた、D3.jsというものを使ってみることにしました。

D3.jsについてはこれまで全く使ったことがなかったので、まずはいつものごとく、本を買ってお勉強です。

D3.jsの使用経験ありの人から見たらどうかはわかりませんが、「まずそもそも、どうやって使うの?」というレベルの自分には、とても役立つ本でした。第1章〜第6章までをじっくり読んで、残りの章から自分がやりたことに近い事例を拾っていけば、基本的なグラフは十分作れるようになると思います。

 

今回作りたいものの要件は以下のようになります。

  • アクセスしたら、その日の取得済みのログ情報を表示する。
  • 日付を変更して、表示するログを変更可能にする。できればカレンダーを使って変更できるようにする。
  • 表示するグラフは、横軸を24時間にした折れ線グラフ。
  • 現在2つの部屋のセンサ情報を取得しているので、これを部屋ごとに表示するようにする。
  • 一つの部屋につき、二つのセンサの情報を重ねて表示する。表示するセンサ情報は切り替えられるようにする。

で、色々寄せ集めて作ってみたのがこちら。まずはhtmlファイルから。CSSもそんなに多くないので、ここに記述してしまっています。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="css/bootstrap.min.css" rel="stylesheet">
    <link href="css/bootstrap-datepicker.min.css" rel="stylesheet">
    <title>Sensor Data</title>

    <style>
      svg {width:630px; height:340px; border:1px solid black;}
      .temperature {fill: none; stroke: red;}
      .humidity    {fill: none; stroke: blue;}
      .illuminance {fill: none; stroke: green;}
      .motion      {fill: none; stroke: purple;}
      .door        {fill: none; stroke: orange;}
      .door_jp     {fill: none; stroke: gray;}
      .window      {fill: none; stroke: brown;}
      .axis text{
        font-family: sans-serif;
        font-size: 8px;
      }
      .axis path,
      .axis line{
        fill: none;
        stroke: black;
      }
      .grid{
        stroke: gray;
        stroke-dasharray: 4, 2;
        shape-rendering: crispEdges;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <div class="panel panel-default">

        <div class="panel-heading text-center">
          <h1>Sensor Data</h1>
        </div>

        <div class="panel-body">
          <div class="row">
            <div class="text-center">
              <!--<form name="date_form" action="">-->
                <input id="date" name="date_name" maxlength="10" type="text" readonly="readonly">
                <button id="show">表示</button>
              <!--</form>-->        
            </div>
          </div>

          <div class="row">
            <div class="text-center">
              <h2>ROOM T</h2>
            </div>
          </div>
          <div class="row">
            <div class="col-xs-offset-4 col-xs-1">
              <form name="room_t_device1">
                <select id="room_t_device1">
                  <option value="temperature">室温</option>
                  <option value="humidity">湿度</option>
                  <option value="illuminance" selected>明るさ</option>
                  <option value="motion">人感</option>
                </select>
              </form>
            </div>
            <div class="col-xs-offset-2 col-xs-1">
              <form name="room_t_device2">
                <select id="room_t_device2">
                  <option value="temperature">室温</option>
                  <option value="humidity">湿度</option>
                  <option value="illuminance">明るさ</option>
                  <option value="motion" selected>人感</option>
                </select>
              </form>
            </div>
            <div class="col-sx-4"></div>
          </div>
          <div class="row">
            <div class="text-center">
              <svg id="graphT"></svg>
            </div>
          </div>

          <div class="row">
            <div class="text-center">
              <h2>ROOM L</h2>
            </div>
          </div>
          <div class="row">
            <div class="col-xs-offset-4 col-xs-1">
              <form name="room_l_device1">
                <select id="room_l_device1">
                  <option value="temperature">室温</option>
                  <option value="humidity">湿度</option>
                  <option value="illuminance" selected>明るさ</option>
                  <option value="motion">人感</option>
                  <option value="door">ドア</option>
                  <option value="door_jp">障子</option>
                  <option value="window">窓</option>
                </select>
              </form>
            </div>
            <div class="col-xs-offset-2 col-xs-1">
              <form name="room_l_device2">
                <select id="room_l_device2">
                  <option value="temperature">室温</option>
                  <option value="humidity">湿度</option>
                  <option value="illuminance">明るさ</option>
                  <option value="motion" selected>人感</option>
                  <option value="door">ドア</option>
                  <option value="door_jp">障子</option>
                  <option value="window">窓</option>
                </select>
              </form>
            </div>
            <div class="col-sx-4"></div>
          </div>
          <div class="row">
            <div class="text-center">
              <svg id="graphL"></svg>
            </div>
          </div>
        </div>

        <div class="panel-footer text-center">
          <p>&copy;make-muda.weblike.jp</p>
        </div>

      </div>
    </div>

    <script src="js/jquery.min.js"></script>
    <script src="js/bootstrap.min.js"></script>
    <script src="js/d3.min.js" charset="utf-8"></script>
    <script src="js/bootstrap-datepicker.min.js"></script>
    <script src="js/bootstrap-datepicker.ja.min.js"></script>
    <script src="js/sensors.js"></script>
  </body>
</html>

基本的なレイアウトにはBootstrapを使っています。実際にグラフを描画することになる<svg>については、レスポンシブ対応にしようとしてimg-responsiveクラスを適用してみたりしましたが、うまくいかなかったので、一旦WidthとHeightは固定にしています。iPhone 6を横向きにすると、ちょうど幅いっぱいに広がるサイズです。

sensor_viewer_2

こんな感じ。

続いて、jsファイルです。

(function(){
  $('#date').datepicker({
    format: "yyyy-mm-dd",
    language: "ja",
    autoclose: true,
    orientation: "top auto"
  });

  var SVG_WIDTH  = $("#graphT").width()-4; // グリッド線のずれの補正
  var SVG_HEIGHT = $("#graphT").height(); 
  var OFFSET_X = 30;
  var OFFSET_Y = 20;
  var X_AXIS_WIDTH = SVG_WIDTH - OFFSET_X * 2;
  var Y_AXIS_HEIGHT = 300;

  var TIME_RANGE_MAX = 1440;
  var TEMPERATURE_MAX = 40;
  var TEMPERATURE_TICKS = 9;
  var HUMIDITY_MAX = 100;
  var HUMIDITY_TICKS = 6;
  var ILLUMINANCE_MAX = 500;
  var ILLUMINANCE_TICKS = 6;
  var MOTION_MAX = 10;
  var MOTION_TICKS = 6;
  var DOOR_WINDOW_MAX = 1;
  var DOOR_WINDOW_TICKS = 2;
  var temperature_t_elements;

  var csvFile;
  var roomT_device1_index = document.room_t_device1.room_t_device1.selectedIndex;
  var roomT_device1;
  switch (roomT_device1_index) {
  case 0: roomT_device1 = "temprature";  break;
  case 1: roomT_device1 = "humidity";    break;
  case 2: roomT_device1 = "illuminance"; break;
  case 3: roomT_device1 = "motion";      break;
  }
  var roomT_device2_index = document.room_t_device2.room_t_device2.selectedIndex;
  var roomT_device2;
  switch (roomT_device2_index) {
  case 0: roomT_device2 = "temprature";  break;
  case 1: roomT_device2 = "humidity";    break;
  case 2: roomT_device2 = "illuminance"; break;
  case 3: roomT_device2 = "motion";      break;
  }
  var roomL_device1_index = document.room_l_device1.room_l_device1.selectedIndex;
  var roomL_device1;
  switch (roomL_device1_index) {
  case 0: roomL_device1 = "temprature";  break;
  case 1: roomL_device1 = "humidity";    break;
  case 2: roomL_device1 = "illuminance"; break;
  case 3: roomL_device1 = "motion";      break;
  case 4: roomL_device1 = "door";        break;
  case 5: roomL_device1 = "door_jp";     break;
  case 6: roomL_device1 = "window";      break;
  }
  var roomL_device2_index = document.room_l_device2.room_l_device2.selectedIndex;
  var roomL_device2;
  switch (roomL_device2_index) {
  case 0: roomL_device2 = "temprature";  break;
  case 1: roomL_device2 = "humidity";    break;
  case 2: roomL_device2 = "illuminance"; break;
  case 3: roomL_device2 = "motion";      break;
  case 4: roomL_device2 = "door";        break;
  case 5: roomL_device2 = "door_jp";     break;
  case 6: roomL_device2 = "window";      break;
  }

  var dataSetTime = new Array(TIME_RANGE_MAX);
  var dataSetTemperatureT = new Array(TIME_RANGE_MAX);
  var dataSetHumidityT    = new Array(TIME_RANGE_MAX);
  var dataSetIlluminanceT = new Array(TIME_RANGE_MAX);
  var dataSetMotionT      = new Array(TIME_RANGE_MAX);
  var dataSetTemperatureL = new Array(TIME_RANGE_MAX);
  var dataSetHumidityL    = new Array(TIME_RANGE_MAX);
  var dataSetIlluminanceL = new Array(TIME_RANGE_MAX);
  var dataSetMotionL      = new Array(TIME_RANGE_MAX);
  var dataSetDoorL        = new Array(TIME_RANGE_MAX);
  var dataSetDoorLjp      = new Array(TIME_RANGE_MAX);
  var dataSetWindowL      = new Array(TIME_RANGE_MAX);

  function initDataSet(){
    for(var i=0;i<TIME_RANGE_MAX;i++){
      dataSetTime[i]         = 0;
      dataSetTemperatureT[i] = 0;
      dataSetHumidityT[i]    = 0;
      dataSetIlluminanceT[i] = 0;
      dataSetMotionT[i]      = 0;
      dataSetTemperatureL[i] = 0;
      dataSetHumidityL[i]    = 0;
      dataSetIlluminanceL[i] = 0;
      dataSetMotionL[i]      = 0;
      dataSetDoorL[i]        = 0;
      dataSetDoorLjp[i]      = 0;
      dataSetWindowL[i]      = 0;
    }
  }

  d3.select("#show").on("click", function(){
    csvFile = "log/" + $("#date").val() + ".csv";
    //console.log("fileName:" + csvFile);
    drawGraph();
  });

  d3.select("#room_t_device1").on("change", function(){
    d3.select("#graphT").selectAll("*").remove();
    roomT_device1 = this.value;
    drawLineAndAxis("room_t")
  });

  d3.select("#room_t_device2").on("change", function(){
    d3.select("#graphT").selectAll("*").remove();
    roomT_device2 = this.value;
    drawLineAndAxis("room_t")
  });

  d3.select("#room_l_device1").on("change", function(){
    d3.select("#graphL").selectAll("*").remove();
    roomL_device1 = this.value;
    drawLineAndAxis("room_l")
  });

  d3.select("#room_l_device2").on("change", function(){
    d3.select("#graphL").selectAll("*").remove();
    roomL_device2 = this.value;
    drawLineAndAxis("room_l")
  });

  function drawXaxis(room){
    var svgId;
    if(room == "room_t"){svgId = "#graphT"}
    else if(room == "room_l"){svgId = "#graphL"}

    // 横軸目盛り表示のためのスケール設定
    var xScale = d3.time.scale()
      .domain([new Date("2015/8/1 00:00"),new Date("2015/8/1 23:59")])  // 値の範囲
      .range([0, X_AXIS_WIDTH]) // 値の描画範囲
    // 横軸目盛りを設定して表示
    d3.select(svgId).append("g")
      .attr("class", "axis")
      .attr("transform", "translate("+OFFSET_X+","+(SVG_HEIGHT-OFFSET_Y)+")") // 軸の描画開始位置
      .call(
        d3.svg.axis()
          .scale(xScale)
          .orient("bottom") // 値を軸の下側に表示
          .ticks(24) // 目盛りの個数を提案
          .tickFormat(function(d,i){
            var fmtFunc = d3.time.format("%H")
            return fmtFunc(d)
          })
      )

    // グリッドの表示
    var grid = d3.select(svgId).append("g");
    var gridRangeX = d3.range(0, X_AXIS_WIDTH, Math.floor(X_AXIS_WIDTH/4))
    grid.selectAll("line.x")
      .data(gridRangeX)
      .enter()
      .append("line")
      .attr("class","grid")
      .attr("x1", function(d,i){
        return d + OFFSET_X;
      })
      .attr("y1", SVG_HEIGHT - OFFSET_Y)
      .attr("x2", function(d,i){
        return d + OFFSET_X;
      })
      .attr("y2", SVG_HEIGHT - OFFSET_Y - Y_AXIS_HEIGHT)
  }

  function drawLine(room, device, yMax, dataSet){
    var svgId;
    if(room == "room_t"){svgId = "#graphT"}
    else if(room == "room_l"){svgId = "#graphL"}

    // 折れ線グラフの座標値計算
    var line = d3.svg.line()
      .x(function(d,i){
        return i * X_AXIS_WIDTH/TIME_RANGE_MAX + OFFSET_X;
      })
      .y(function(d,i){
        return SVG_HEIGHT - d * Y_AXIS_HEIGHT/yMax - OFFSET_Y;
      })
      .interpolate("linear")

    // 折れ線グラフ描画
    d3.select(svgId)
      .append("path")
      .attr("class", device)
      .attr("d", line(dataSet))
  }

  function drawYaxis(room, yMax, yTicks, position){
    var svgId;
    if(room == "room_t"){svgId = "#graphT"}
    else if(room == "room_l"){svgId = "#graphL"}

    var xPosition;  // 軸のx座標
    if(position == "left"){
      xPosition = OFFSET_X;
    }else if(position == "right"){
      xPosition = OFFSET_X + X_AXIS_WIDTH;
    }

    // 縦軸目盛り表示のためのスケール設定
    var yScale = d3.scale.linear()
      .domain([0,yMax])        // 値の範囲
      .range([Y_AXIS_HEIGHT,0]) // 軸の描画範囲
    // 縦軸目盛りを設定して表示
    d3.select(svgId).append("g")
      .attr("class", "axis")
      .attr("transform", "translate("+xPosition+","+(SVG_HEIGHT-Y_AXIS_HEIGHT-OFFSET_Y)+")") // 軸の描画開始位置
      .call(
        d3.svg.axis()
          .scale(yScale)
          .orient(position)
          .ticks(yTicks) // 目盛りの個数の提案
      )
  }

  function drawLineAndAxis(room){
    var dataSet1;
    var dataSet2;
    var yMax1;
    var yMax2;
    var yTicks1;
    var yTicks2;

    if(room == "room_t"){
      if(roomT_device1 == "temperature"){dataSet1 = dataSetTemperatureT; yMax1 = TEMPERATURE_MAX; yTicks1 = TEMPERATURE_TICKS;}
      else if(roomT_device1 == "humidity"){dataSet1 = dataSetHumidityT;  yMax1 = HUMIDITY_MAX; yTicks1 = HUMIDITY_TICKS;}
      else if(roomT_device1 == "illuminance"){dataSet1 = dataSetIlluminanceT; yMax1 = ILLUMINANCE_MAX; yTicks1 = ILLUMINANCE_TICKS;}
      else if(roomT_device1 == "motion"){dataSet1 = dataSetMotionT; yMax1 = MOTION_MAX; yTicks1 = MOTION_TICKS;}

      if(roomT_device2 == "temperature"){dataSet2 = dataSetTemperatureT; yMax2 = TEMPERATURE_MAX; yTicks2 = TEMPERATURE_TICKS;}
      else if(roomT_device2 == "humidity"){dataSet2 = dataSetHumidityT;  yMax2 = HUMIDITY_MAX; yTicks2 = HUMIDITY_TICKS;}
      else if(roomT_device2 == "illuminance"){dataSet2 = dataSetIlluminanceT; yMax2 = ILLUMINANCE_MAX; yTicks2 = ILLUMINANCE_TICKS;}
      else if(roomT_device2 == "motion"){dataSet2 = dataSetMotionT; yMax2 = MOTION_MAX; yTicks2 = MOTION_TICKS;}

      drawLine(room,roomT_device1, yMax1, dataSet1);
      drawYaxis(room, yMax1, yTicks1, "left");

      drawLine(room,roomT_device2, yMax2, dataSet2);
      drawYaxis(room, yMax2, yTicks2, "right");

      drawXaxis(room);
    }else if(room == "room_l"){
      if(roomL_device1 == "temperature"){dataSet1 = dataSetTemperatureL; yMax1 = TEMPERATURE_MAX; yTicks1 = TEMPERATURE_TICKS;}
      else if(roomL_device1 == "humidity"){dataSet1 = dataSetHumidityL;  yMax1 = HUMIDITY_MAX; yTicks1 = HUMIDITY_TICKS;}
      else if(roomL_device1 == "illuminance"){dataSet1 = dataSetIlluminanceL; yMax1 = ILLUMINANCE_MAX; yTicks1 = ILLUMINANCE_TICKS;}
      else if(roomL_device1 == "motion"){dataSet1 = dataSetMotionL; yMax1 = MOTION_MAX; yTicks1 = MOTION_TICKS;}
      else if(roomL_device1 == "door"){dataSet1 = dataSetDoorL; yMax1 = DOOR_WINDOW_MAX; yTicks1 = DOOR_WINDOW_TICKS;}
      else if(roomL_device1 == "door_jp"){dataSet1 = dataSetDoorLjp; yMax1 = DOOR_WINDOW_MAX; yTicks1 = DOOR_WINDOW_TICKS;}
      else if(roomL_device1 == "window"){dataSet1 = dataSetWindowL;  yMax1 = DOOR_WINDOW_MAX; yTicks1 = DOOR_WINDOW_TICKS;}

      if(roomL_device2 == "temperature"){dataSet2 = dataSetTemperatureL; yMax2 = TEMPERATURE_MAX; yTicks2 = TEMPERATURE_TICKS;}
      else if(roomL_device2 == "humidity"){dataSet2 = dataSetHumidityL; yMax2 = HUMIDITY_MAX; yTicks2 = HUMIDITY_TICKS;}
      else if(roomL_device2 == "illuminance"){dataSet2 = dataSetIlluminanceL; yMax2 = ILLUMINANCE_MAX; yTicks2 = ILLUMINANCE_TICKS;}
      else if(roomL_device2 == "motion"){dataSet2 = dataSetMotionL; yMax2 = MOTION_MAX; yTicks2 = MOTION_TICKS;}
      else if(roomL_device2 == "door"){dataSet2 = dataSetDoorL; yMax2 = DOOR_WINDOW_MAX; yTicks2 = DOOR_WINDOW_TICKS;}
      else if(roomL_device2 == "door_jp"){dataSet2 = dataSetDoorLjp; yMax2 = DOOR_WINDOW_MAX; yTicks2 = DOOR_WINDOW_TICKS;}
      else if(roomL_device2 == "window"){dataSet2 = dataSetWindowL; yMax2 = DOOR_WINDOW_MAX; yTicks2 = DOOR_WINDOW_TICKS;}

      drawLine(room,roomL_device1, yMax1, dataSet1);
      drawYaxis(room, yMax1, yTicks1, "left");

      drawLine(room,roomL_device2, yMax2, dataSet2);
      drawYaxis(room, yMax2, yTicks2, "right");

      drawXaxis(room);
    }
  }

  function drawGraph(){
    d3.csv(csvFile, function(error,data){
      if(error){
        alert("Data does NOT exist.")
        console.log(error);
      }else{
        d3.select("#graphT").selectAll("*").remove();
        d3.select("#graphL").selectAll("*").remove();
        initDataSet();
        for(var i=0; i<data.length; i++){
          var timeStr = data[i].time.split(":");
          var hour    = parseInt(timeStr[0]);
          var minute  = parseInt(timeStr[1]);
          var index = hour * 60 + minute; 
          //console.log("index:" + index);
          dataSetTemperatureT[index] = data[i].temperature_t;
          dataSetHumidityT[index]    = data[i].humidity_t;
          dataSetIlluminanceT[index] = data[i].illuminance_t;
          dataSetMotionT[index]      = data[i].motion_t;
          dataSetTemperatureL[index] = data[i].temperature_l;
          dataSetHumidityL[index]    = data[i].humidity_l;
          dataSetIlluminanceL[index] = data[i].illuminance_l;
          dataSetMotionL[index]      = data[i].motion_l;
          dataSetDoorL[index]        = data[i].door_l;
          dataSetDoorLjp[index]      = data[i].door_l_jp;
          dataSetWindowL[index]      = data[i].window_l;
        }

        drawLineAndAxis("room_t");
        drawLineAndAxis("room_l");
      }
    })
  }

  var today = new Date();
  $("#date").val(today.getFullYear() + "-" + ("0"+(today.getMonth()+1)).slice(-2) + "-" + ("0"+today.getDate()).slice(-2));
  csvFile = "log/" + $("#date").val() + ".csv";
  drawGraph();
})();

要素の指定にはJQueryを使っています。カレンダーUIは、bootstrap-datepickerを使って実現しています。使い方は、こちらが参考になると思います。

ちなみに日付変更時にcsvファイルが読み込めないバグが発生して、3時間ほど悩まされたのですが、datepickerを適用するinput要素をform要素で囲んでしまっていたのが原因でした。。。

 

ということで、実際に作成したものがこちら。

sensor_viwer_cut

CSVファイルをダウンロードしてグラフ化するより、遥かにラクになりました(作るのに二日かかってしまいましたが)。D3.jsの強みであるはずのアニメーションは使っていませんし、まだまだ無駄のあるプログラムかもしれませんが、実用上は特に問題ないので個人的には満足です。

ログ情報は、当事者が見れば「ああ、これはこれをしてるんだな」というのがよくわかるのですが、機械が当事者の助言なしでこのログから行動を推定できるようにならないと、人が見て楽しむ止まりになってしまいます。ということで、今後はこのログ情報を足がかりに、機械学習での人の行動推定とかもやってみようかなーと思ったり。もう少し先の話になりそうですが。