業務用アプリでありがちな、一覧表とグラフ。
オープンソースで様々なライブラリが提供されている。
今回は今まで自分がよく使っていたライブラリがRiot.jsによってどのように分割、連携が行えるかを確認してみた。
#使用したライブラリ
-
一覧表 :
-
HANDSONTABLE
- MIT License
- Excelのように入力できる
- Excelからコピペもできる
- 最近有償版も出た
-
W2UI GRID
- MIT License
- 大量データの描画が高速
- ソートや表示列切り替え等の機能が豊富
- W2UIにはGRID以外にも様々なライブラリが提供されているがGRID以外は使ったことがない
-
HANDSONTABLE
-
グラフ :
-
NVD3
- Apache License, Version 2.0
- d3の拡張
- 簡潔な記述できれいなよく使うグラフが描ける
-
NVD3
#アプリの仕様
- 一覧表に入力したデータをリアルタイムに集計しグラフ表示する
- 一覧表は、HANDSONTABLE形式とW2UI GRID形式を切り替えられるようにする
受講者データを表入力 (名前,性別,点数) |
|
||
#通信仕様
前回と同様aggreさんのObseriotを利用させてもらった。
ちょっと込み入っているが、前回と基本はいっしょ。
下記のポイントを気をつけた。
- コンポーネント間を直接繋がない
- コンポーネント単体での試験が困難になり、拡張も気軽にできなくなる
- ストアを変更できるのはディスパッチャのみ
- データの整合性は集中管理する
- 表示コンポーネントにロジックは持たず、表示に必要なデータのみを格納したストアを用意する
- とりかえがしやすくなる(円グラフから棒グラフへの変更など)
- コンポーネントがストアを変更したい場合はアクションに通知する
#コード
index.html
<!DOCTYPE html>
<html>
<head>
<title>riot-handsontable-nvd3</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/css/bootstrap-material-design.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/css/ripples.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/w2ui/1.4.3/w2ui.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.4/nv.d3.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.27.0/handsontable.min.css">
</head>
<body>
<my-app></my-app>
<script src="https://code.jquery.com/jquery-1.10.2.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.6/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/js/material.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-material-design/0.5.10/js/ripples.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/w2ui/1.4.3/w2ui.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.27.0/handsontable.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.4/nv.d3.min.js"></script>
<script src="https://rawgit.com/riot/riot/master/riot%2Bcompiler.min.js"></script>
<script type="riot/tag" src="my-app.tag"></script>
<script type="riot/tag" src="my-navbar.tag"></script>
<script type="riot/tag" src="my-frame.tag"></script>
<script type="riot/tag" src="my-w2uigrid.tag"></script>
<script type="riot/tag" src="my-handsontable.tag"></script>
<script type="riot/tag" src="my-pie-chart.tag"></script>
<script type="riot/tag" src="my-bar-chart.tag"></script>
<script src="obseriot.js"></script>
<script>
//エイリアス
var action = obseriot.action,
store = obseriot.store;
riot.mount('my-app');
</script>
</body>
</html>
ルートコンポーネントをマウントするだけ
利用ライブラリがふくれあがってるのが気になるけど全ページに埋め込むよりいい
##ルートコンポーネント
<my-app>
<div class="container-fluid main">
<div class="row">
<div class="col-md-offset-1 col-md-10">
<my-navbar></my-navbar>
<div class="row">
<div class="col-sm-6">
<my-frame caption="データ入力 (上のボタンで入力方式切替)">
<div id="grid"></div>
</my-frame>
</div>
<div class="col-sm-6">
<div class="row">
<div class="col-sm-12">
<my-frame caption="点数分布">
<my-bar-chart id="tensubunpu"></my-bar-chart>
</my-frame>
</div>
<div class="col-sm-12">
<my-frame caption="男女比">
<my-pie-chart id="danjohi"></my-pie-chart>
</my-frame>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
省略
</script>
</my-app>
省略部ではアクション、ストア、ディスパッチャの定義を行っている。
抜粋して説明すると
アクション定義例
// データレコード変更アクション
// {index, column, value} e
// index: 配列インデックス
// column: 要素名
// value: 値
obseriot.defineAction(
"changeRecord", // action name
function(e) { // handler action
return e;
}
);
今回はストア内に配列で持たせているため、通知者は配列のインデックスと要素名、値を渡す仕様にした。
obseriot.notify( action.changeRecord, {
index: 0, column: 'seibetsu', value: '女',
})
ストア定義例
// 受講者データ
// {records: [{name, tensu, seibetsu},...]}
obseriot.defineStore(
"data", // sotre name
function () { // handler action
var trimData = [];
for(rec of store.data.state.records) {
var isEmpty = function(e){return !e || e == ""}
if(!isEmpty(rec.name) || !isEmpty(rec.tensu) || !isEmpty(rec.seibetsu))
trimData.push(rec)
}
return {records: trimData}
},
{// default state
records: []
}
);
行の内容が全て空の場合有効行とは見なさない仕様にしたため、監視者には有効行のみフィルタしたデータを渡す。
ディスパッチャ定義例
// データ変更アクション監視 → 受講者データストア変更
// → 受講者データストア変更通知
obseriot.listen( action.changeRecord, function (e) { // {index, column, value} e
var record = store.data.state.records[e.index];
record[e.column] = e.value;
obseriot.notify( store.data );
});
アクションを監視して、ストアを変更してストアの変更を通知
こんな感じでコンポーネントを繋ぐAPIを組み立てていく
###子コンポーネントの切り替え
obseriot.defineAction(
"changeInputView", // action name
function(e) { // handler action
return e;
}
);
var changeInputView = function() {
var mode = true; //true: HandsonTable, false: W2UI
var currentGrid = null;
return function() {
if(currentGrid) currentGrid.unmount(true)
if (mode)
currentGrid = riot.mount('#grid', 'my-handsontable')[0];
else
currentGrid = riot.mount('#grid', 'my-w2uigrid')[0];
mode = !mode;
}
}();
obseriot.listen( action.changeInputView, function () {
changeInputView();
});
riotではriot.mount('#タグのID', 'コンポーネント名')
とすることで動的にタグ配置を制御できる。
また、mount()
の戻りに対しunmount()を呼ぶとDOMから除去できる。
上記の実装では
obseriot.notify( action.load )
を実行する度に<div id='grid'></div>
の中身が<my-handsontable>
と<my-w2uigrid>
に切り替わる。
HANDSONTABLE
<my-handsontable>
<div class="row">
<div class="col-xs-12">
<div name="grid" style="height: 365px;"></div>
</div>
</div>
<style>
.handsontable th {
background-color: #F0FFF0;
}
.handsontable tbody th.ht__highlight, .handsontable thead th.ht__highlight {
background-color: #F0FFF0;
}
</style>
<script>
var
table,
self = this,
el = self.grid,
elContainer = el.parentNode,
tableSetting = {
colHeaders: [
'名前',
'性別',
'点数',
'偏差値',
],
columns: [
{
data: 'name',
type: 'text',
},
{
data: 'seibetsu',
editor: 'select',
selectOptions: ['男', '女'],
className: 'htCenter'
},
{
data: 'tensu',
type: 'numeric',
format: '0',
},
],
stretchH: 'all',
autoWrapRow: true,
colWidths: [150, 50, 50],
height: 360,
minSpareRows: 1,
rowHeaders: true,
data: $.extend([], store.data.state.records),
afterCreateRow: function(index, amount) {
obseriot.notify( action.createRecord, {
index: index,
record: this.getData()[index]
});
},
afterChange: function(changes, source) {
if(changes) {
for(e of changes) {
obseriot.notify( action.changeRecord, {
index: e[0],
column: e[1],
value: e[3],
})
}
}
}
};
this.on('mount', function() {
table = new Handsontable(el, tableSetting);
});
function f(data) {
table.loadData($.extend([], data.records))
}
obseriot.listen(store.load, f)
this.on('unmount', function() {
obseriot.unlisten(store.load, f)
})
</script>
</my-handsontable>
HANDSONTABLEに依存しているのはこのコンポーネントだけ
スタイル調整も閉じられる
HANDSONTABLEのフック可能なイベントからディスパッチャへアクション通知する
HANDSONTABLE.afterCreateRow
-> obseriot.action.createRecord
HANDSONTABLE.afterChange
-> obseriot.action.changeRecord
W2UI GRID
<my-w2uigrid>
<div class="row">
<div class="col-xs-12">
<div name="grid" style="height: 365px;"></div>
</div>
</div>
<style>
.w2ui-grid .w2ui-grid-body table .w2ui-head {
background: #F0FFF0;
}
.w2ui-grid .w2ui-grid-body .w2ui-grid-columns {
box-shadow: none;
}
</style>
<script>
var
self = this,
setting = {
name: opts.id,
show: {toolbar: true},
columns:[
{
field: 'recid',
size: '7%',
render:'int',
sortable: true,
},
{
field: 'name',
caption: '名前',
size: '36%',
editable: {type: 'text'},
},
{
field:'seibetsu',
caption:'性別',
size:'10%',
editable:{type: 'select', items: ['男', '女']},
},
{
field: 'tensu',
caption: '点数',
size: '14%',
render:'int',
editable: {type:'int', min:0, max:100},
sortable: true,
}
],
records: $.extend([], store.data.state.records).map(function(element, index, array) {
element['recid'] = index + 1;
return element;
}),
onChange: function(e) {
e.onComplete = w2ui[opts.id].mergeChanges
obseriot.notify( action.changeRecord, {
index: e.recid - 1,
column: w2ui[opts.id].columns[e.column].field,
value: e.value_new,
})
},
};
this.on('mount', function() {
$(self.grid).w2grid(setting);
});
function f(data) {
w2ui[opts.id].clear()
w2ui[opts.id].add($.extend([], data.records).map(function(element, index, array) {
element['recid'] = index + 1;
return element;
}))
}
obseriot.listen(store.load, f)
this.on('unmount', function() {
w2ui[opts.id].destroy()
obseriot.unlisten(store.load, f)
})
</script>
</my-w2uigrid>
W2UI GRIDに依存しているのはこのコンポーネントだけ
こちらは行追加できないように設定した
イベントの伝播は
w2grid.onChange
→ obseriot.action.changeRecord
SPA時は後処理に注意(this.on('unmount')
内で処理)
NVD3
<my-pie-chart>
<div><svg name="pie"></svg></div>
<style>
svg.nvd3-svg {
height: 180px;
}
</style>
<script type="javascript">
var self = this
var chart = nv.models.pieChart()
.x(function(d) { return d.label })
.y(function(d) { return d.value })
.valueFormat(d3.format(',.0d'))
.margin({top: 0, bottom: 0})
.showLabels(true);
var chartdata = null;
this.on('mount', function() {
nv.addGraph(function() {
chartdata = d3.select(self.pie)
.datum(store.danjohi.state.records)
chartdata.transition().duration(500).call(chart);
return chart;
});
obseriot.listen(store.danjohi, function (data) {
chartdata.datum(store.danjohi.state.records).transition().duration(500).call(chart);
});
var resizing = false;
$(window).resize( function() {
if (resizing) clearTimeout(resizing);
resizing = setTimeout(function() {
chart.update();
}, 200);
});
})
</script>
</my-pie-chart>
ライブラリに特化した記述のみでよいのですっきり
集計データストアを監視し、変更がある度に再描画している
問題なく動いた
#デモおよびソース
https://embed.plnkr.co/CVVw9m2D4QyXDL89Ddoi/
#所感
今回利用したライブラリは流行っているだけあって副作用が少ないし、イベントのフックもしやすいため、Riotとの相性は悪くなかった。
というより、HTMLをほぼそのまま分割しただけで実装できてしまったため、Riot導入のハードルは非常に低いと感じた。
コンポーネントの分割に伴う設計は行う必要があるが、イベント通知だけでAPIを組み立てることで、一見面倒に思える処理も単純化できるし、各コンポーネントの独立性を意識することで、もっとよいライブラリがでてきたときや、自作コンポーネントを作ったときに部分的にばっさり捨てて置き換えることもできる。
かといって、ガチガチに分割しなくてもそれなりに使えてしまうところがRiotのよいところだと思うので気軽に使って欲しいと思います。