D3.jsで気温グラフを描く - Flask + PostgreSQL

D3.jsで、以下のようなグラフを描くプログラムを作ってみました。定期的に、東京、ロンドン、ニューヨークの3大都市の気温を取得しグラフを更新していきます。[「OpenWeatherMap」](https://openweathermap.org/)のAPIを利用して気温データを取得します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/ee729521-80dc-f99d-f6d9-1ac20d05a3c2.png)


技術的なポイント：

* D3.jsでリアルタイムに更新するグラフを描く
* サーバ側にWebサーバとしてお手軽なFlask(Python)を採用する
* OpenWeatherMapのAPIをrequestsで叩く
* 取得した気温データはPostgreSQL(SQLAlchemy)に保存する




#１．サーバ (Flask + PostgreSQL) の設定

サーバ側は、AWS(Chalice)を使えば良いのですが、しょぼい話ですが、私のAWSアカウントが無料枠を超えてしまっているようなので、今回は手元のCentos7サーバで、Flaskを立てます。

Flaskについての基本的なところは以下のサイトを参照してください。
[「Flask - 公式サイト」](http://flask.pocoo.org/docs/1.0/)
[「View Decorators - @wrapsについての説明」](http://flask.pocoo.org/docs/0.12/patterns/viewdecorators/)
[「Pythonのデコレータについて」](https://qiita.com/mtb_beta/items/d257519b018b8cd0cc2e)
[「PythonのFlaskで学ぶWebアプリケーション制作講座 第6章 〜セッション〜」](https://qiita.com/ikaro1192/items/d890eefbdbbfe1460252)


##１－１．ライブラリのインストール

まず必要なライブラリをインストールしておきます。

```
pip install flask SQLAlchemy flask-sqlalchemy
pip install psycopg2
pip install psycopg2-binary    # 不要かも。
```

##１－２．SQLAlchemyの設定

pythonのORMモジュールである**SQLAlchemy**を使います。基本的な説明は以下のサイトを参照してください。
[「Flask-SQLAlchemy - 公式サイト」](http://flask-sqlalchemy.pocoo.org/2.3/)
[「Flask-SQLAlchemyの使い方」](https://qiita.com/msrks/items/673c083ca91f000d3ed1)
[「Flask by Example – Setting up Postgres, SQLAlchemy, and Alembic」](
https://realpython.com/flask-by-example-part-2-postgres-sqlalchemy-and-alembic/)




以下のように設定ファイルを作成します。**flask_weather**というDBを利用する予定です。


```python:config.py
SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:postgres@localhost:5432/flask_weather'
SQLALCHEMY_TRACK_MODIFICATIONS = True
```

##１－３．PostgreSQLの設定

PostgreSQL自体はインストール済みであることを前提とします。まだ、インストールしていない場合は、以下のサイトをご参照してください。

[「PostgreSQL 10 を CentOS 7 に yum インストールする手順」](https://weblabo.oscasierra.net/postgresql10-centos7-install/)
[「CentOS7にErlangとElixir、Phoenixをインストールしてみる」](https://qiita.com/sand/items/e98c9e98351b816c7e69)
[「centOS7 でpostgreSQLがアンインストールできない」](https://teratail.com/questions/85421)


まず**flask_weather**というDBを作成します。

```
sudo -u postgres psql -U postgres
postgres=# create database flask_weather;
```

Pythonインタープリタから、server.py（後で説明）を使ってテーブルを作成します。（Pythonってこういう使い方するんだ～。）

```
python
>>> from server import db
>>> db.create_all()
```

PostgreSQLの設定はこれで終わりです。最後に、忘れやすいので、PostgreSQLの設定を確認するために便利なコマンドを挙げておきます。[PostgreSQLコマンドチートシート](https://qiita.com/Shitimi_613/items/bcd6a7f4134e6a8f0621)

```
sudo -u postgres psql
postgres=# \l                  # DB一覧
postgres=# \c flask_weather    # DB接続
flask_weather=# \z             # テーブル一覧
flask_weather=# \d entries     # テーブル定義
flask_weather=# select * from temp;  # テーブル表示
```


##１－４．サーバプログラム server.pyの説明

server.pyは基本的には[「OpenWeatherMap」](https://openweathermap.org/)のAPIを使って気温データを取得してDBに保存します。

Flaskアプリとして以下の3つのパスを定義します。

* "/" - トップページ。D3.jsアプリが動作する。
* "/weather_list/＜length＞" - D3.jsアプリがデータを取得するパス。length個のデータを要求
* "/weather" - cronから15分おきにアクセス。気温データを取得しDBに保存。前に取得した時間データが同じなら保存しない。


以下が全ソースです。

```python:server.py
# server.py
from flask import Flask, render_template, jsonify
from flask_sqlalchemy import SQLAlchemy, get_debug_queries
import requests
import json, pprint

# APIキーの指定
apikey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# 都市の一覧
cities = ["Tokyo,JP", "London,UK", "New York,US"]
# cnames = map(lambda str: str[:-3], cities) # render()の中に入れないとダメ
cnames = ["Tokyo", "London", "New York"]
# APIのテンプレート
api = "https://api.openweathermap.org/data/2.5/weather?q={city}&APPID={key}"

# 温度変換(ケルビン -> 摂氏)
k2c = lambda k: k - 273.15

# 前回の時間 - データ更新判定に利用
lastdt = [0,0,0]

app = Flask(__name__, static_folder="./build/static", template_folder="./build")

# SQLAlchemyのお決りの定義。テーブルとオブジェクトの関連付け
#------------------------------------------------------
app.config.from_object('config')
db = SQLAlchemy(app)

class Temp(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    city = db.Column(db.String(10))
    utime = db.Column(db.Integer)
    temp = db.Column(db.Float)

    def __init__(self, city, utime, temp):
        self.city = city
        self.utime = utime
        self.temp = temp

    def __repr__(self):
        return '<Temp %r>' % self.temp

# app.config.update(dict( #debug
#   SQLALCHEMY_RECORD_QUERIES = True,
# ))
#------------------------------------------------------


# トップページ
@app.route("/")
def index():
    return render_template("index.html")

# ブラウザからデータを要求(Rest API)
@app.route("/weather_list/<length>")
def get_weather_list(length=1):
    global cnames
    print(cnames)
    resall = []
    for cname in cnames:
        print(cname)
        res = Temp.query.filter_by(city=cname).order_by(Temp.utime.desc()).limit(length).all()
        # info = get_debug_queries()[0] #debug
        # pprint.pprint(info) #debug

        res2 = []
        for r in res:
            res2.append( { "city":r.city, "dt":r.utime, "temp":r.temp} )
        pprint.pprint(res2)
        resall.append(res2)

    return jsonify(resall)

# cronからアクセス。気温データを取得してDBに保存(Rest API)
@app.route("/weather")
def get_weather():
    i = -1
    res = []

    for name in cities:
        i = i+1
        # APIのURL
        url = api.format(city=name, key=apikey)
        # APIにリクエストを送信
        r = requests.get(url)
        # JSON形式をデコード
        jdata = json.loads(r.text)
        if( lastdt[i] == int(jdata["dt"]) ):
            break
        else:
            res.append(jdata)

    if( len(res) == 3 ) :
        i = -1
        for kdata in res :
            i = i + 1
            lastdt[i] = int(kdata["dt"])
            temp = Temp( kdata["name"], lastdt[i], k2c(kdata["main"]["temp"]))
            db.session.add(temp)
            db.session.commit()

    return jsonify(res)

if __name__ == "__main__":
    app.debug = True
    app.run(host='0.0.0.0', port=8080)
```






#２．クライアント(D3.js)の設定


##２－１．ソースコードツリー
今回のサーバとクライアントのソースコードツリーです。

```:ソースコードツリー
config.py
server.py
build  -  index.html
       |- static -  modal.html
                 |- js  - d3.js
                 |- css - styles.css
```


##２－２．modal画面のHTML

都市名一覧を表示するmodal画面用のhtmlです。2列のテーブルですが、今のところ1列しか使っていません


```html:build/static/modal.html
<table>
    <tr>
        <th>世界の気温</th>
    </tr>
    <tr class="city"><td class="city">東京</td><td class="data"></td></tr>
    <tr class="city"><td class="city">ロンドン</td><td class="data"></td></tr>
    <tr class="city"><td class="city">ニューヨーク</td><td class="data"></td></tr>
</table>
```


##２－３．index.html(D3.js)

以下がメインのD3.jsのプログラムです。ちょっと長いです。


```html:build/index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Line Chart</title>
    <link rel="stylesheet" type="text/css" href="/static/css/styles.css"/>
    <script type="text/javascript" src="/static/js/d3.js"></script>
    <style>
      #modal {
        position:fixed;
        left:150px;
        top:20px;
        z-index:1;
        background: white;
        border: 1px black solid;
        box-shadow: 10px 10px 5px #888888;
      }
    </style>
</head>
<body>
<script type="text/javascript">
// ********************************************************************
// **** D3.jsが扱うDOMツリー
// **
// ** svg
// **   g.axes
// **     g.x .axes       -- translate(xStart,yStart)
// **     g.y .axes       -- translate(xStart,yEnd)
// **   defs
// **   g.body            -- translate(xStart,yEnd)
// **     path.line
// **     path.line
// **     circle.dot(11個)
// **     circle.dot(11個)
// ** 
// ****   g.bodyのハミダシタlineやdotはclipPath(defs)で切り取られる
// ********************************************************************
    function lineChart() {
        var _chart = {};
        var _width = 700, _height = 500, 
                _margins = {top: 30, left: 30, right: 30, bottom: 30},
                 _x, _y,
                _data = [],
                _colors = d3.scaleOrdinal(d3.schemeCategory10),
                _svg,
                _modal,
                _bodyG,
                _line;

        _chart.render = function () {
            if(_data[0].length < numberOfDataPoint) return;

            var startEnd = d3.extent(_data[0], d => d.x);
            // ** X軸のscale、時間軸
            _x = d3.scaleTime().domain(startEnd).range([0, quadrantWidth()])
            // ** y軸のscale,rangeがinvertedであることに注意
            _y = d3.scaleLinear().domain([min_y, max_y]).range([quadrantHeight(), 0])

            if (!_svg) {
                _svg = d3.select("body").append("svg")
                        .attr("height", _height)
                        .attr("width", _width);

                renderModal(); // ** 最初の1回だけ描画
                defineBodyClip(_svg);
            }
            renderAxes(_svg); // ** render()が呼ばれるごとに描画
            renderBody(_svg);
        };

        function renderModal() {
            d3.text("/static/modal.html", data => {
                if(!_modal) {
                    _modal = d3.select("body").append("div").attr("id", "modal");
                }
                _modal.html(data);

                d3.selectAll("tr.city")
                    .each(function (d,i) {
                        d3.select(this)
                            .style("color", function (d) {
                                return _colors(i);
                            })
                    })
            });
        };

        function renderAxes(svg) {
            svg.select("g.axes").remove()  // ** 前回のDOMをクリアー
            var axesG = svg.append("g")
                    .attr("class", "axes");

            renderXAxis(axesG);
            renderYAxis(axesG);
        }

        // ** X座標
        function renderXAxis(axesG){
            var xAxis = d3.axisBottom()
                    .scale(_x);

            axesG.append("g")
                    .attr("class", "x axis")
                    .attr("transform", function () {
                        return "translate(" + xStart() + "," + yStart() + ")";  // ** ＠左下
                    })
                    .call(xAxis);

            // ** y座標軸に平行なgrid line
            d3.selectAll("g.x g.tick")
                .append("line")
                    .classed("grid-line", true)
                    .attr("x1", 0)
                    .attr("y1", 0)
                    .attr("x2", 0)
                    .attr("y2", - quadrantHeight());
        }

        // ** Y座標
        function renderYAxis(axesG){
            var yAxis = d3.axisLeft()
                    .scale(_y);

            axesG.append("g")
                    .attr("class", "y axis")
                    .attr("transform", function () {
                        return "translate(" + xStart() + "," + yEnd() + ")";  // ** ＠左上  Invertedだから。
                    })
                    .call(yAxis);

             // ** x座標軸に平行なgrid line
             d3.selectAll("g.y g.tick")
                .append("line")
                    .classed("grid-line", true)
                    .attr("x1", 0)
                    .attr("y1", 0)
                    .attr("x2", quadrantWidth())
                    .attr("y2", 0);
        }

        // ** Bodyを切り取るRect(body-clip)を定義
        function defineBodyClip(svg) {
            var padding = 5;

            svg.append("defs")
                    .append("clipPath")
                    .attr("id", "body-clip")
                    .append("rect")
                    .attr("x", 0 - padding)
                    .attr("y", 0)
                    .attr("width", quadrantWidth() + 2 * padding)
                    .attr("height", quadrantHeight());
        }

        // ** Body描画
        function renderBody(svg) {
            if (!_bodyG)
                _bodyG = svg.append("g")
                        .attr("class", "body")
                        .attr("transform", "translate("
                            + xStart() + ","
                            + yEnd() + ")") // <-2E
                        .attr("clip-path", "url(#body-clip)");
// ** Rect(body-clip)でBodyを切り取る。点や線がはみ出したらキロ取る。
            renderLines();
            renderDots();
        }


// ** data = [d1, d2]
// ** d1 = [a1,b1,c1,..],  d2 = [a2,b2,c2,...]
// ** lineはdataレベルのbindであり、dotはd1,d2レベルのbindである。

         // ** line描画
        function renderLines() {
            _line = d3.line()
                            .x(function (d) { return _x(d.x); })
                            .y(function (d) { return _y(d.y); });

            var pathLines = _bodyG.selectAll("path.line")
                    .data(_data);
            pathLines
                    .enter()  // ** ２個のdata setで未バインドなもの
                        .append("path")
                    .merge(pathLines)  // ** enter + update
                        .style("stroke", function (d, i) {  // ** i はdata set番号
                            return _colors(i);
                        })
                        .attr("class", "line")
// ** transition()行は無くとも最終的なグラフ描画は変わらないが、
// ** transition()行でupdate時のdotの動作（前回->今回）がスムーズになる。
                    .transition()
                        .attr("d", function (d) { return _line(d); });
        }

        // ** Dot描画
        function renderDots() {
            _data.forEach(function (list, i) { // ** iはdata set番号
                var circle = _bodyG.selectAll("circle._" + i)
                        .data(list);

                circle.enter()
                        .append("circle")
                    .merge(circle)
                        .attr("class", "dot _" + i)
                        .style("stroke", function (d) {
                            return _colors(i);
                        })
// ** transition()行は無くとも最終的なグラフ描画は変わらないが、
// ** transition()行でupdate時のdotの動作（前回->今回）がスムーズになる。
                    .transition()
                        .attr("cx", function (d) { return _x(d.x); })
                        .attr("cy", function (d) { return _y(d.y); })
                        .attr("r", 4.5);
            });
        }

        function xStart() {
            return _margins.left;
        }
        function yStart() {
            return _height - _margins.bottom;
        }
        function xEnd() {
            return _width - _margins.right;
        }
        function yEnd() {
            return _margins.top;
        }
        function quadrantWidth() {
            return _width - _margins.left - _margins.right;
        }
        function quadrantHeight() {
            return _height - _margins.top - _margins.bottom;
        }
        _chart.width = function (w) {
            if (!arguments.length) return _width;
            _width = w;
            return _chart;
        };
        _chart.height = function (h) { // <-1C
            if (!arguments.length) return _height;
            _height = h;
            return _chart;
        };
        _chart.margins = function (m) {
            if (!arguments.length) return _margins;
            _margins = m;
            return _chart;
        };
        _chart.colors = function (c) {
            if (!arguments.length) return _colors;
            _colors = c;
            return _chart;
        };
        _chart.x = function (x) {
            if (!arguments.length) return _x;
            _x = x;
            return _chart;
        };
        _chart.y = function (y) {
            if (!arguments.length) return _y;
            _y = y;
            return _chart;
        };

        _chart.setData = function (data) {
            _data = []
            data.forEach(function(series,i) {
                var temp = []
                series.forEach( function(ds) {
                    temp.push({x: new Date(ds["dt"]*1000), y:ds["temp"]});
                } )
                _data.push(temp)
            } )
        }

        return _chart; 
    }

    function update() {
        d3.json("http://www.mypress.jp:8080/weather_list/"+numberOfDataPoint, function(error, data) {
            chart.setData(data);
            chart.render();
        });
    }

    var numberOfSeries = 3,
        numberOfDataPoint = 11,
        min_y = -5,
        max_y = 15;

    var chart = lineChart()

    update();

    setInterval(function () {
        update();
    }, 1000*60*15);
</script>

<div class="control-group">
    <button onclick="update()">Update</button>
</div>
</body>
</html>
```

**clippath**については以下のサイトを参考にしました。

「Clipped Paths in d3.js (AKA clipPath)」
http://www.d3noob.org/2015/07/clipped-paths-in-d3js-aka-clippath.html


今回は以上です。


#付録：D3.js関連の過去記事
[D3.jsで気温グラフを描く - Flask + PostgreSQL](https://qiita.com/sand/items/9cdb36a5d14a9f087305)
[D3.jsで日本地図を描くときの基本(geojson)](https://qiita.com/sand/items/422d4fab77ea8f69dfdf "D3.jsで日本地図を描くときの基本(geojson)")
[D3.jsで埼玉県地図を描くときの基本（topojson)](https://qiita.com/sand/items/e8c82f1d61ca4e7bdfb7 "D3.jsで埼玉県地図を描くときの基本（topojson)")
[D3.jsで埼玉県の地図上に市町村ラベルを描く](https://qiita.com/sand/items/e11e597d88ee6745c22f "D3.jsで埼玉県の地図上に市町村ラベルを描く")
[React+D3.jsアプリ作成の基本](https://qiita.com/sand/items/4090fc9e93ff81180fdf)
[埼玉県の市町村別人口をD3.jsのツリーマップで表現してみる](https://qiita.com/sand/items/0f2a828c893f8315a15b)
[D3.jsの enter-updata-exit パタンでLive Data表示](https://qiita.com/sand/items/91fd2a40a7c651c9dd9c)