LoginSignup
4
8

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-01-29

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

image.png

技術的なポイント:

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

1.サーバ (Flask + PostgreSQL) の設定

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

Flaskについての基本的なところは以下のサイトを参照してください。
「Flask - 公式サイト」
「View Decorators - @wrapsについての説明」
「Pythonのデコレータについて」
「PythonのFlaskで学ぶWebアプリケーション制作講座 第6章 〜セッション〜」

1-1.ライブラリのインストール

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

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

1-2.SQLAlchemyの設定

pythonのORMモジュールであるSQLAlchemyを使います。基本的な説明は以下のサイトを参照してください。
「Flask-SQLAlchemy - 公式サイト」
「Flask-SQLAlchemyの使い方」
「Flask by Example – Setting up Postgres, SQLAlchemy, and Alembic」

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

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

1-3.PostgreSQLの設定

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

「PostgreSQL 10 を CentOS 7 に yum インストールする手順」
「CentOS7にErlangとElixir、Phoenixをインストールしてみる」
「centOS7 でpostgreSQLがアンインストールできない」

まず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コマンドチートシート

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

1-4.サーバプログラム server.pyの説明

server.pyは基本的には「OpenWeatherMap」のAPIを使って気温データを取得してDBに保存します。

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

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

以下が全ソースです。

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)

2.クライアント(D3.js)の設定

2-1.ソースコードツリー

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

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

2-2.modal画面のHTML

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

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>

2-3.index.html(D3.js)

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

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()  // ** 2個の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
D3.jsで日本地図を描くときの基本(geojson)
D3.jsで埼玉県地図を描くときの基本(topojson)
D3.jsで埼玉県の地図上に市町村ラベルを描く
React+D3.jsアプリ作成の基本
埼玉県の市町村別人口をD3.jsのツリーマップで表現してみる
D3.jsの enter-updata-exit パタンでLive Data表示

4
8
0

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
  3. You can use dark theme
What you can do with signing up
4
8