<repo_name>AngelMsger/Bangumi-Visualizer<file_sep>/Dockerfile
FROM node

LABEL maintainer="<EMAIL>"

COPY . /app

WORKDIR /app

RUN npm install --save

CMD ["node", "bin/www"]
<file_sep>/README.md
# Bangumi-Visualizer
[![Build Status](https://travis-ci.org/AngelMsger/Bangumi-Visualizer.svg?branch=master)](https://travis-ci.org/AngelMsger/Bangumi-Visualizer)
![](dist/img/ic_laoding_anim_01.png)

## Overview
Bangumi-Visualizer is a visual frontend demo for data provided by [Bangumi-Provider](https://github.com/AngelMsger/Bangumi-Provider). Bangumi-Visualizer only support data in mongodb while multi storage backend is supported by Bangumi-Provider.

[Demo](https://projects.angelmsger.com/bangumi)

![](dist/img/screenshot_0.png)
![](dist/img/screenshot_1.png)
![](dist/img/screenshot_2.png)

## Features

### Completely Split the Frontend and Backend Components.
* Lightweight API Backend with Node.js & Express.
* Implement [SPA](https://www.wikiwand.com/en/Single-page_application) Frontend Simply with Vue.js, Async Load Resource of Full Page.

### Data Visualization with Echarts.

## Usage
1. Make Sure Your Data Source(Data in MongoDB Provided by [Bangumi-Provider](https://github.com/AngelMsger/Bangumi-Provider)).
2. Update `conf.js` and Run `npm install && node bin/www` to Set up API Server.
3. Copy Files `dist/*` to Your Web Server(eg. Nginx) Root.
4. Update `apiPrefix` in `js/bangumi.js` to Your API Server URL.

## API Response Example

### Anime Information
```
curl https://api.angelmsger.com/bangumi/anime/season_id/21421
{
    "rating": {
        "count": 31896,
        "score": 9.8
    },
    "area": [
        {
            "id": 2,
            "name": "日本"
        }
    ],
    "tags": [
        {
            "id": 9,
            "name": "少女"
        },
        {
            "id": 122,
            "name": "魔法"
        },
        {
            "id": 135,
            "name": "漫改"
        }
    ],
    "top_matches": [
        {
            "media_id": 3756,
            "similarity": 0.28640856945518733
        },
        {
            "media_id": 8892,
            "similarity": 0.19707309807208745
        },
        {
            "media_id": 8772,
            "similarity": 0.1422207767154259
        },
        {
            "media_id": 8932,
            "similarity": 0.11738195985814143
        },
        {
            "media_id": 8792,
            "similarity": 0.10697599425914822
        },
        {
            "media_id": 11712,
            "similarity": 0.10262114618086689
        },
        {
            "media_id": 33512,
            "similarity": 0.10059228641931872
        },
        {
            "media_id": 8992,
            "similarity": 0.09941438054869527
        }
    ],
    "_id": "5aac7499635dc8f02da70665",
    "season_id": 21421,
    "alias": "カードキャプターさくら クリアカード編,百变小樱,魔法少女樱,魔卡少女樱：透明牌篇,魔卡少女樱,小樱",
    "cover_url": "http://i0.hdslb.com/bfs/bangumi/07245c4bf4acb03a5819762fea3210c656aba66c.jpg",
    "danmaku_count": 619362,
    "episodes": 11,
    "evaluate": "樱集齐了会给世间带来灾难的“库洛牌”，并用魔法的力量将它们变成了自己的牌。樱花盛开的4月，小樱升入了初中1年级。而后，小樱与曾返回香港的小狼重逢，并一起开心地去往学校。某个夜里，收集的牌出现了异常……而友枝町接连发生奇怪的事。小樱在梦中遇见的“钥匙”的指引下，再次开始收集卡片。这时，班里转来一个女孩子……",
    "favorites": 2364534,
    "is_finish": true,
    "media_id": 8752,
    "pub_time": "2018-01-07T09:00:00.000Z",
    "title": "魔卡少女樱 CLEAR CARD篇",
    "last_long_reviews_cursor": "4294976682",
    "last_short_reviews_cursor": "73285027959490"
}
```

### Author Information
```
curl https://api.angelmsger.com/bangumi/author/mid/3346211
{
    "follow": [],
    "recommendation": [
        8892,
        6463,
        6434,
        8772,
        6425,
        6301,
        836,
        8792
    ],
    "reviews": [
        {
            "review_id": 1721177,
            "content": "没看过原作，纯动画党表示剧情还可以。",
            "ctime": "2018-03-15T19:01:10.000Z",
            "mtime": "2018-03-15T19:01:10.000Z",
            "likes": 0,
            "score": 8,
            "media_id": 6339,
            "is_long": false,
            "last_ep_index": "12"
        },
        {
            "review_id": 1721182,
            "content": "一口气12集，真的有毒！",
            "ctime": "2018-03-15T19:01:37.000Z",
            "mtime": "2018-03-15T19:01:37.000Z",
            "likes": 0,
            "score": 8,
            "media_id": 6311,
            "is_long": false,
            "last_ep_index": "12"
        },
        {
            "review_id": 1721189,
            "content": "一开始设定很新鲜，但看久了就没有那么惊艳了。",
            "ctime": "2018-03-15T19:02:59.000Z",
            "mtime": "2018-03-15T19:02:59.000Z",
            "likes": 0,
            "score": 8,
            "media_id": 3461,
            "is_long": false,
            "last_ep_index": "25"
        },
        {
            "review_id": 1719102,
            "content": "很喜欢这种轻松的日常，有没有WiFi！",
            "ctime": "2018-03-15T11:25:25.000Z",
            "mtime": "2018-03-15T11:25:25.000Z",
            "likes": 0,
            "score": 10,
            "media_id": 2580,
            "is_long": false,
            "last_ep_index": "12"
        },
        {
            "review_id": 1725078,
            "content": "学姐党表示很不满┐(´-｀)┌。",
            "ctime": "2018-03-16T12:54:43.000Z",
            "mtime": "2018-03-16T12:54:43.000Z",
            "likes": 0,
            "score": 8,
            "media_id": 1512,
            "is_long": false,
            "last_ep_index": "0"
        },
        {
            "review_id": 1719106,
            "content": "友谊真的是很珍贵的东西，这部番很优秀很感人，面码赛高！",
            "ctime": "2018-03-15T11:26:51.000Z",
            "mtime": "2018-03-15T11:26:51.000Z",
            "likes": 0,
            "score": 10,
            "media_id": 835,
            "is_long": false
        },
        {
            "review_id": 1719092,
            "content": "一部被埋没的优秀作品。",
            "ctime": "2018-03-15T11:24:18.000Z",
            "mtime": "2018-03-15T11:24:18.000Z",
            "likes": 1,
            "score": 10,
            "media_id": 713,
            "is_long": false,
            "last_ep_index": "4"
        },
        {
            "review_id": 1721165,
            "content": "期待第三季(◍ ´꒳` ◍)！",
            "ctime": "2018-03-15T18:58:21.000Z",
            "mtime": "2018-03-15T18:58:21.000Z",
            "likes": 1,
            "score": 10,
            "media_id": 6446,
            "is_long": false,
            "last_ep_index": "12"
        },
        {
            "review_id": 1721901,
            "content": "OPED承载了回忆( ´▽` )。",
            "ctime": "2018-03-15T21:21:06.000Z",
            "mtime": "2018-03-15T21:21:06.000Z",
            "likes": 0,
            "score": 10,
            "media_id": 425,
            "is_long": false,
            "last_ep_index": "4"
        },
        {
            "review_id": 1721903,
            "content": "炮姐赛高ヾ(╹◡╹)ﾉ~",
            "ctime": "2018-03-15T21:21:36.000Z",
            "mtime": "2018-03-15T21:21:36.000Z",
            "likes": 0,
            "score": 10,
            "media_id": 427,
            "is_long": false
        },
        {
            "review_id": 1721883,
            "content": "不是很吸引我（ ’ - ’ * )。",
            "ctime": "2018-03-15T21:18:09.000Z",
            "mtime": "2018-03-15T21:18:09.000Z",
            "likes": 0,
            "score": 6,
            "media_id": 3450,
            "is_long": false
        },
        {
            "review_id": 1047291,
            "content": "信仰啊，快要二十年了，我的大学时光都要结束了，小樱知世还是那么年轻，Sakura赛高*٩(๑´∀`๑)ง*！",
            "ctime": "2018-01-10T22:27:29.000Z",
            "mtime": "2018-01-10T22:27:29.000Z",
            "likes": 1,
            "score": 10,
            "media_id": 8752,
            "is_long": false,
            "last_ep_index": "1"
        },
        {
            "review_id": 1703840,
            "content": "很不错的日常搞笑番剧～( ´▽` )",
            "ctime": "2018-03-12T00:49:05.000Z",
            "mtime": "2018-03-12T00:49:05.000Z",
            "likes": 0,
            "score": 10,
            "media_id": 5069,
            "is_long": false,
            "last_ep_index": "1"
        },
        {
            "review_id": 1721199,
            "content": "感觉和罪恶王冠相似的设定很多（ ’ - ’ * )。",
            "ctime": "2018-03-15T19:03:50.000Z",
            "mtime": "2018-03-15T19:03:50.000Z",
            "likes": 0,
            "score": 8,
            "media_id": 3494,
            "is_long": false,
            "last_ep_index": "12"
        },
        {
            "review_id": 790871,
            "content": "对我影响最深远的作品，小樱是我唯一的女神！(ˊ˘ˋ*)",
            "ctime": "2017-12-18T20:19:14.000Z",
            "mtime": "2017-12-18T20:19:14.000Z",
            "likes": 0,
            "score": 10,
            "media_id": 3756,
            "is_long": false,
            "last_ep_index": "1"
        },
        {
            "review_id": 1721207,
            "content": "室友推荐的，祈妹我的。",
            "ctime": "2018-03-15T19:05:26.000Z",
            "mtime": "2018-03-15T19:05:26.000Z",
            "likes": 0,
            "score": 10,
            "media_id": 1588,
            "is_long": false,
            "last_ep_index": "1"
        }
    ],
    "top_matches": [
        {
            "mid": 40355388,
            "similarity": 0.48550148160528617
        },
        {
            "mid": 33071733,
            "similarity": 0.4811947284652843
        },
        {
            "mid": 8005102,
            "similarity": 0.4588097570922974
        },
        {
            "mid": 18288767,
            "similarity": 0.4542083633154889
        },
        {
            "mid": 2527584,
            "similarity": 0.450496738116172
        },
        {
            "mid": 19352249,
            "similarity": 0.4497567393745113
        },
        {
            "mid": 21806447,
            "similarity": 0.443463559968293
        },
        {
            "mid": 34545175,
            "similarity": 0.44296005140240013
        }
    ],
    "_id": "5aac778a635dc8f02da91643",
    "mid": 3346211,
    "avatar_url": "http://i0.hdslb.com/bfs/face/6d848efd8e5cbab7369cb7005b6c3c9f94bb8e85.jpg",
    "uname": "共享协同过滤算法",
    "last_analyze": "2018-03-19T14:54:22.063Z"
}
```

## ~~Need Help~~(Solved)

### Question:
I use following code to query anime's archive in mongodb by its season_id:
```
db.archives.aggregate([
    {$replaceRoot: {
        newRoot: {
            $mergeObjects: [
                {date: '$date'},
                {$arrayElemAt: [
                    {$filter:{
                        input: '$archives',
                        cond: {
                            $eq: ['$$this.season_id',  21729]
                        }
                    }
                }, 0]
                }
            ]
        }
    }
    }
])
```
In Mongo Shell, it return result as following:
```
{
    "date": ISODate("2018-03-17T00:00:00Z"),
    "season_id": 21729,
    "favorites": 1169110,
    "danmaku_count": 88299,
    "reviews_count": 3731,
    "rating": {
        "count": 7831,
        "score": 9.7
    }
}
```
But with [Mongoose](http://mongoosejs.com/) in Node.js, the [same query](routes/app.js) return something different:
```
[{"date":"2018-03-17T00:00:00.000Z"}]
```
~~I have no solution for this right now.~~

## Solution:
While mongoose support auto convert type of parameter to Number in top level by [the schema](db.js) you defined, you should do this manually in embedded query by yourself such as aggregate. The reason why you should do this I guess is that Mongoose don't support advanced query type correctness detection.
```
# this is ok(season_id will auto convert to in)
db.collection.findOne({season_id: req.params.season_id})

# while this is error
db.archives.aggregate([
    {$replaceRoot: {
        ...
                    {$filter:{
                        ...
                            $eq: ['$$this.season_id', req.params.season_id]
                        ...
                    }
        ...
    }
    }
])

# fix error
db.archives.aggregate([
    {$replaceRoot: {
        ...
                    {$filter:{
                        ...
                            $eq: ['$$this.season_id', Number(req.params.season_id)]
                        ...
                    }
        ...
    }
    }
])
```

# Quote
1. Always dependent data from [AngelMsger/Bangumi-Provider](https://github.com/AngelMsger/Bangumi-Provider).
2. Use [Vue-Material](https://github.com/vuematerial/vue-material) to design web pages.
<file_sep>/routes/index.js
const express = require('express');
const conf = require('../conf');
const db = require('../db');

const router = express.Router();

/* GET api listing. */
router.get('/anime/season_id/:season_id', function(req, res) {
    const seasonId = parseInt(req.params.season_id);
    if (seasonId) {
        const key = 'anime-season-id:' + seasonId;
        db.redis.get(key, function (error, value) {
            if (!error && value) {
                res.send(JSON.parse(value));
            }
            else {
                db.animes.findOne({season_id: seasonId}, function (error, anime) {
                    if (error === null) {
                        res.send(anime);
                        db.redis.set(key, JSON.stringify(anime), 'EX', conf.redisKVTTL);
                    }
                });
            }
        });
    }
});

router.get('/anime/media_id/:media_id', function(req, res) {
    const mediaId = parseInt(req.params.media_id);
    if (mediaId) {
        const key = 'anime-media-id:' + mediaId;
        db.redis.get(key, function (error, value) {
            if (!error && value) {
                res.send(JSON.parse(value));
            }
            else {
                db.animes.findOne({media_id: mediaId}, function (error, anime) {
                    if (error === null) {
                        res.send(anime);
                        db.redis.set(key, JSON.stringify(anime), 'EX', conf.redisKVTTL)
                    }
                });
            }
        });
    }
});

router.get('/author/mid/:mid', function (req, res) {
    const mId = parseInt(req.params.mid);
    if (mId) {
        const key = 'author-mid:' + mId;
        db.redis.get(key, function (error, value) {
            if (!error && value) {
                res.send(JSON.parse(value));
            }
            else {
                db.authors.findOne({mid: mId}, function (error, author) {
                    if (error === null) {
                        res.send(author);
                        db.redis.set(key, JSON.stringify(author), 'EX', conf.redisKVTTL)
                    }
                });
            }
        });
    }
});

router.get('/archive/season_id/:season_id', function (req, res) {
    const seasonId = parseInt(req.params.season_id);
    if (seasonId) {
        const key = 'archive-season-id:' + seasonId;
        db.redis.get(key, function (error, value) {
            if (!error && value) {
                res.send(JSON.parse(value));
            }
            else {
                db.archives.aggregate([{
                    $replaceRoot: {
                        newRoot: {
                            $mergeObjects: [{
                                date: '$date'
                            }, {
                                $arrayElemAt: [{
                                    $filter:{
                                        input: '$archives', cond: {
                                            $eq: ['$$this.season_id', seasonId]
                                        }
                                    }
                                }, 0]
                            }]
                        }
                    }
                }], function (error, archives) {
                    if (error === null) {
                        res.send(archives);
                        db.redis.set(key, JSON.stringify(archives), 'EX', conf.redisKVTTL);
                    }
                });
            }
        });
    }
});

module.exports = router;
<file_sep>/conf.js
const dbHost = process.env.DB_HOST || 'localhost';
const dbPort = process.env.DB_PORT || 27017;
const dbDatabase = process.env.DB_DATABASE || 'bangumi';
const dbUsername = process.env.DB_USERNAME || 'bangumi';
const dbPassword = process.env.DB_PASSWORD || '<PASSWORD>';

const redisHost = process.env.REDIS_HOST || 'localhost';
const redisPort = process.env.REDIS_PORT || 6379;
const redisDatabase = process.env.REDIS_DATABASE || 0;
const redisMaxMemory = process.env.REDIS_MAXMEMORY || '1024mb';
const redisKVTTL = process.env.REDIS_KV_TTL || 43200;

const dev = {
    root: '/',
    mongoUrl: 'mongodb://localhost/bangumi',

    redisUrl: 'redis://localhost/0',
    redisMaxMemory: '8192mb',
    redisKVTTL: 43200
};

prod = {
    root: '/bangumi',
    mongoUrl: 'mongodb://' + dbUsername + ':' + dbPassword + '@' + dbHost + ':' + dbPort + '/' + dbDatabase,

    redisUrl: 'redis://' + redisHost + ':' + redisPort + '/' + redisDatabase,
    redisMaxMemory: redisMaxMemory,
    redisKVTTL: redisKVTTL
};

module.exports = prod;
<file_sep>/dist/js/bangumi.js
const conf = {
    apiPrefix: 'https://api.angelmsger.com/bangumi'
};

conf.animeBySeasonIdURLPrefix = conf.apiPrefix + '/anime/season_id/';
conf.animeByMediaIdURLPrefix = conf.apiPrefix + '/anime/media_id/';
conf.authorByMidURLPrefix = conf.apiPrefix + '/author/mid/';
conf.archiveBySeasonIdURLPrefix = conf.apiPrefix + '/archive/season_id/';

function LineChartOption(title, xAxisData, seriesData) {
    this.title = {
        text: title
    };
    this.xAxis = {
        data: xAxisData
    };
    this.yAxis = {
        type: 'value'
    };
    this.series = {
        type: 'line',
        data: seriesData
    };
}

function PieChartOption(seriesData, title='用户偏好', seriesName='评论番剧携带标签') {
    this.title = {
        text: title,
            left: 'center',
            top: 20,
            textStyle: {
            color: '#000'
        }
    };

    this.tooltip = {
        trigger: 'item',
        formatter: "{a} <br/>{b} : {c} ({d}%)"
    };

    this.visualMap = {
        show: false,
            min: 0,
            max: 16,
            inRange: {
            colorLightness: [0, 1]
        }
    };
    this.series = [
        {
            name: seriesName,
            type: 'pie',
            radius : '55%',
            center: ['50%', '50%'],
            data: seriesData.sort(function (a, b) { return a.value - b.value; }),
            roseType: 'radius',
            label: {
                normal: {
                    textStyle: {
                        color: 'rgba(0, 0, 0, 0.3)'
                    }
                }
            },
            labelLine: {
                normal: {
                    lineStyle: {
                        color: 'rgba(0, 0, 0, 0.3)'
                    },
                    smooth: 0.2,
                    length: 10,
                    length2: 20
                }
            },
            itemStyle: {
                normal: {
                    color: '#c23531',
                    shadowBlur: 200,
                    shadowColor: 'rgba(0, 0, 0, 0.5)'
                }
            },

            animationType: 'scale',
            animationEasing: 'elasticOut',
            animationDelay: function (idx) {
                return Math.random() * 200;
            }
        }
    ];
}

Vue.use(VueMaterial.default);

Vue.filter('filter_slice', function (value) {
    return value.length > 32 ? value.slice(0, 32) + '...' : value;
});

let vm = new Vue({
    el: '#app',
    data: {
        page: 0,
        menuVisible: false,
        season_id: '',
        anime: {
            success: false,
            alias: '',
            area: [],
            danmaku_count: 0,
            episodes: 0,
            evaluate: '',
            favorites: 0,
            is_finish: false,
            pub_time: null,
            rating: {
                count: 0,
                score: 0
            },
            tags: [],
            title: '',
            last_long_reviews_cursor: null,
            last_short_reviews_cursor: null,
            top_matches: [],
            archives: []
        },
        mid: '',
        author: {
            success: false,
            reviews: [],
            uname: '',
            follow: [],
            last_crawl: null,
            last_analyze: null,
            top_matches: [],
            recommendation: []
        },
        charts: {
            archivesToLoad: 0,
            archivesRenderFinished: false,
            reviewsToLoad: 0,
            tagsRenderFinished: false,
        }
    },
    watch: {
        season_id: function () {
            if (this.season_id) {
                this.$http.get(conf.animeBySeasonIdURLPrefix + this.season_id).then(response => {
                    let anime = response.body;
                    if (anime && typeof anime !== 'string') {
                        this.anime.success = true;
                        this.anime.alias = anime.alias;
                        this.anime.danmaku_count = anime.danmaku_count;
                        this.anime.episodes = anime.episodes;
                        this.anime.evaluate = anime.evaluate;
                        this.anime.favorites = anime.favorites;
                        this.anime.is_finish = anime.is_finish;
                        this.anime.pub_time = moment(anime.pub_time).format('LL');
                        this.anime.rating = anime.rating;
                        this.anime.title = anime.title;

                        const that = this;

                        while (this.anime.area.length > 0) this.anime.area.pop();
                        if (anime.area) {
                            anime.area.forEach(function (area) {
                                that.anime.area.push(area.name);
                            });
                        }

                        while (this.anime.tags.length > 0) this.anime.tags.pop();
                        if (anime.tags) {
                            anime.tags.forEach(function (tag) {
                                that.anime.tags.push(tag.name);
                            });
                        }

                        while (this.anime.top_matches.length > 0) this.anime.top_matches.pop();
                        if (anime.top_matches) {
                            anime.top_matches.forEach(function (pair) {
                                that.$http.get(conf.animeByMediaIdURLPrefix + pair.media_id).then(response => {
                                    let other = response.body;
                                    other.similarity = pair.similarity;
                                    that.anime.top_matches.push(other);
                                }, response => {
                                });
                            });
                        }

                        while (that.anime.archives.length > 0) that.anime.archives.pop();
                        this.$http.get(conf.archiveBySeasonIdURLPrefix + this.season_id).then(response => {
                            let archives = response.body;
                            if (archives && typeof archives !== 'string') {
                                this.charts.archivesToLoad = archives.length;
                                this.charts.archivesRenderFinished = false;
                                archives.forEach(function (archive) {
                                    that.anime.archives.push(archive);
                                });
                            }
                        }, response => {
                        });
                    }
                }, response => {
                    this.anime.success = false;
                });
            }
        },
        mid: function () {
            if (this.mid) {
                this.$http.get(conf.authorByMidURLPrefix + this.mid).then(response => {
                    let author = response.body;
                    if (author && typeof author !== 'string') {
                        this.author.success = true;
                        this.author.uname = author.uname;
                        this.author.follow = author.follow;
                        if (author.last_crawl) {
                            this.author.last_crawl = moment(author.last_crawl).format('LLL');
                        }
                        else {
                            this.author.last_crawl = null;
                        }
                        if (author.last_analyze) {
                            this.author.last_analyze = moment(author.last_analyze).format('LLL');
                        }
                        else {
                            this.author.last_analyze = null;
                        }

                        const that = this;

                        while (this.author.reviews.length > 0) this.author.reviews.pop();
                        if (author.reviews) {
                            that.charts.reviewsToLoad = author.reviews.length;
                            that.charts.tagsRenderFinished = false;
                            author.reviews.forEach(function (review) {
                                that.$http.get(conf.animeByMediaIdURLPrefix + review.media_id).then(response => {
                                    let anime = response.body;
                                    if (anime && typeof anime !== 'string') {
                                        review.anime_title = anime.title;
                                        review.anime_tags = anime.tags;
                                        review.ctime = moment(review.ctime).format('LLL');
                                        review.mtime = moment(review.mtime).format('LLL');
                                        that.author.reviews.push(review);
                                    }
                                }, response => {
                                });
                            });
                        }

                        while (this.author.top_matches.length > 0) this.author.top_matches.pop();
                        if (author.top_matches) {
                            author.top_matches.forEach(function (pair) {
                                that.$http.get(conf.authorByMidURLPrefix + pair.mid).then(response => {
                                    let other = response.body;
                                    other.similarity = pair.similarity;
                                    that.author.top_matches.push(other);
                                }, response => {
                                });
                            });
                        }

                        while (this.author.recommendation.length > 0) this.author.recommendation.pop();
                        if (author.recommendation) {
                            author.recommendation.forEach(function (media_id) {
                                that.$http.get(conf.animeByMediaIdURLPrefix + media_id).then(response => {
                                    that.author.recommendation.push(response.body);
                                }, response => {
                                });
                            });
                        }
                    }
                }, response => {
                    this.author.success = false;
                });
            }
        },
    },
    methods: {
        switchPage: function (index) {
            this.page = index;
            this.menuVisible = false;
        },
        applyAnime: function (season_id) {
            this.season_id = season_id;
            this.page = 0;
        },
        applyAuthor: function (mid) {
            this.mid = mid;
            this.page = 1;
        },
        updateAnimeArchiveCharts: function() {
            if (this.anime.archives) {
                let favoritesEl = document.getElementById('favorites-chart');
                let danmakuEl = document.getElementById('danmaku-chart');
                let reviewsEl = document.getElementById('reviews-chart');
                if (favoritesEl && danmakuEl && reviewsEl) {
                    let favoritesChart = echarts.init(favoritesEl);
                    let danmakuChart = echarts.init(danmakuEl);
                    let reviewsChart = echarts.init(reviewsEl);

                    let dates = [];
                    let favorites = [];
                    let danmaku = [];
                    let reviews = [];
                    this.anime.archives.forEach(function (archive) {
                        dates.push(moment(archive.date).format('LL'));
                        favorites.push(archive.favorites);
                        danmaku.push(archive.danmaku_count);
                        reviews.push(archive.reviews_count);
                    });

                    favoritesChart.setOption(new LineChartOption('追番人数', dates, favorites));
                    danmakuChart.setOption(new LineChartOption('弹幕数量', dates, danmaku));
                    reviewsChart.setOption(new LineChartOption('评论人数', dates, reviews));
                }
            }
        },
        updateAuthorReviewsTagsChart: function() {
            if (this.author.reviews) {
                let tagsEl = document.getElementById('tags-chart');
                if (tagsEl) {
                    let tagsChart = echarts.init(tagsEl);
                    let seriesData = [];
                    let tags_count = new Map();
                    this.author.reviews.forEach(function (review) {
                        if (review.anime_tags) {
                            review.anime_tags.forEach(function (pair) {
                                if (tags_count.has(pair.name)) {
                                    tags_count.set(pair.name, tags_count.get(pair.name) + 1);
                                }
                                else {
                                    tags_count.set(pair.name, 1);
                                }
                            });
                        }
                    });
                    tags_count.forEach(function (value, key, map) {
                        seriesData.push({
                            value: value,
                            name: key
                        });
                    });
                    tagsChart.setOption(new PieChartOption(seriesData));
                }
            }
        }
    },
    created: function () {
        document.getElementById('app').style.visibility = 'visible';
    },
    updated: function () {
        if (this.page === 0 && this.anime.archives && this.anime.archives.length === this.charts.archivesToLoad
            && !this.charts.archivesRenderFinished) {
            this.updateAnimeArchiveCharts();
            this.charts.archivesRenderFinished = true;
        }
        else if (this.page === 1 && this.author.reviews && this.author.reviews.length === this.charts.reviewsToLoad
            && !this.charts.tagsRenderFinished) {
            this.updateAuthorReviewsTagsChart();
            this.charts.tagsRenderFinished = true;
        }
    }
});
<file_sep>/deploy.sh
#!/bin/bash

# Login Registry(Docker Hub)
docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASSWORD"

# Generate Image Author/Name/Tag
image=angelmsger/bangumi-visualizer:${TRAVIS_TAG:-latest}

# Tag Image
docker tag bangumi-visualizer $image

# Push to Registry(Docker Hub)
docker push $image

# Request Webhook
# curl https://api.angelmsger.com/project/deploy/$image || /bin/true
