Mithril.js 試してみた(5) Excelの様な表計算アプリを3時間で作る m.component()

今度はふと思い立って Excel の様な表計算アプリを作ってみた。
もちろん使っているフレームワークは [Mithril.js](http://mithril-ja.js.org/) のみ。
jQuery はもちろん使ってはいない。というか jQuery で作ろうという気力は無い。
作ったアプリは HTML ファイルが1つだけ。CSS無し。埋め込んだ JavaScript のみだ。
3時間くらいでできた。

Mithril.js を始めてみて Excel っぽいアプリも作れそうな気がしたのだ。
できれば、みなさんも他のフレームワークでも似た様なものを作って晒して欲しい。
※他のフレームワークでできた Excel っぽいものを私が見つけられないだけかもしれない。
※できれば Angular.js か React.js のバージョンを見てみたい。

基本的な Excel の動作としては以下の様になると思う。
A1のセルに「10」と入力して、B1のセルに「=a1+2」、C1のセルに「=b1+2」と入れれば、
計算されて、A1は「10」、B1は「12」、C1は「14」となる。
そこで、A1に「=c1+2」とすると循環参照になり、計算が止まらなくなるので注意が必要だ。

Chrome, Firefox, Edge, ie11, ie9, ie8 で動く。
ギリギリというか、かろうじて IE8 でも動いた。

あまり良い例とは言えないかもしれないが、初心者の勉強にはなると思う。

サンプルは以下のURLで動きます。
https://lightspeedc.github.io/mithril/excel/excel-ex.html

[![qiita-excel.png](https://qiita-image-store.s3.amazonaws.com/0/16128/a9fed787-4097-38f8-59d5-de6e1cc68374.png)]
(https://lightspeedc.github.io/mithril/excel/excel-ex.html)

>前回までの記事
[Mithril.js 試してみた(1) todo アプリを作り始める所まで - Qiita]
(http://qiita.com/LightSpeedC/items/a2c967928f9cc13e0ebc)
[Mithril.js 試してみた(2) サーバーからデータを取得する m.request() - Qiita]
(http://qiita.com/LightSpeedC/items/f533f048e01ac19a06ec)
[Mithril.js 試してみた(3) console.logの様に画面に表示してみる - Qiita]
(http://qiita.com/LightSpeedC/items/23dcecfa22b89dc7c165)
[Mithril.js 試してみた(4) todo アプリのフロント側まで - Qiita]
(http://qiita.com/LightSpeedC/items/c3026847dc5809d2a7a9)
Mithril.js 試してみた(5) Excelの様な表計算アプリを3時間で作る m.component() - Qiita ⇒ この記事

コードは以下の様になった。

`m.component()` を使ってみた。
子コンポーネントは最初に生成した時のままで、前後に移動したり、増減しなければ、これで動く。

```html:excel-app.html
<!DOCTYPE html>
<meta charset="UTF-8">
<title>Excel example - Mithril.js</title>

<script src="/js/mithril.min.js"></script>
<!--[if IE]><script src="/js/es5-shim.min.js"></script><![endif]-->

<body>
<div id="$excelElement"></div>
</body>

<script>
(function () {
	'use strict';

	var ROWS = 10, COLS = 6; // 縦と横の最大
	var HEADER_STYLE = {style: {backgroundColor: 'lightblue'}};

	// Excel Sheet コンポーネント定義
	var excelComponent = {
		// コントローラ
		controller: function () {
			// 子コンポーネント「行」の配列
			this.children = range(ROWS + 1).map(function (i) {
				return m.component(rowComponent, {row: i}); });
		},
		// ビュー
		view: function (ctrl) {
			return m('table', {border: 0, cellpadding: 0, cellspacing: 0}, ctrl.children);
		}
	};

	// Excel Row コンポーネント定義
	var rowComponent = {
		// コントローラ
		controller: function (attrs) {
			// 子コンポーネント「セル」の配列
			this.children = range(COLS + 1).map(function (i) {
				return m.component(cellComponent, {col: i, row: attrs.row}); });
		},
		// ビュー
		view: function (ctrl) { return m('tr', ctrl.children); }
	};

	var dispData = {}; // 計算結果
	var prevData = ''; // 前回の計算結果(script)
	var currData = ''; // 今回の計算結果(script)
	var timer = null;  // timer稼働中かどうか

	// 計算結果を集約する。計算結果が前回と違うなら再描画(再計算)する。
	function collectData() {
		currData = '';
		for (var v in dispData)
			currData += 'var ' + v + '=' + JSON.stringify(dispData[v]) + ';';

		if (currData !== prevData && !timer)
			timer = setTimeout(function () { timer = null; m.redraw(true); }, 300);

		prevData = currData;
	}

	// Excel Cell コンポーネント定義
	var cellComponent = {
		// コントローラ
		controller: function (attrs) {
			this.attrs = attrs;
			this.colNm = attrs.col ? String.fromCharCode(0x40 + attrs.col) : '＼';
			if (attrs.col && attrs.row) {
				this.cellNm = this.colNm + attrs.row;
				this.expr = ''; // 入力中の数式
				this.disp = ''; // 表示用: 入れた文字列もしくは計算結果
				this.cell = m.prop(''); // セルの値
				dispData[this.cellNm] = this.disp;
				this.focus = 0; // focusを取得しているかどうか
				this.onchange = m.withAttr('value', this.cell);
			}
		},
		// セルを計算する
		calcCell: function (ctrl) {
			collectData();

			// 入力したものが数式の場合、計算してみる
			if (ctrl.expr.charAt(0) === '=') {
				try { ctrl.disp = Function(currData +
							'return ' + ctrl.expr.substr(1).toUpperCase())();
				} catch (e) { ctrl.disp = ctrl.expr + ' ' + e; }
			}
			// それ以外は、文字列か数値とする
			else {
				ctrl.disp = ctrl.expr;
				try { if (String(Number(ctrl.expr)) === ctrl.expr)
							ctrl.disp = Number(ctrl.expr);
				} catch (e) {}
			}
			dispData[ctrl.cellNm] = ctrl.disp;

			collectData();
		},
		// ビュー
		view: function view(ctrl) {
			// 一番上の行 A, B, C, ...
			if (ctrl.attrs.row === 0)
				return m('th', HEADER_STYLE, ctrl.colNm);

			// 一番左の列 1, 2, 3, ...
			if (ctrl.attrs.col === 0)
				return m('th', HEADER_STYLE, ctrl.attrs.row);

			// 入力中は再計算および表示を抑制
			if (ctrl.focus === 2) return {subtree: 'retain'};
			if (ctrl.focus === 1) ctrl.focus = 2; // focus取得直後の1回だけ表示する

			// セルを計算
			cellComponent.calcCell(ctrl);

			return m('td', {},
				m('input', {
					style: ctrl.focus ? {backgroundColor: 'lightgreen'} : {},
					onchange: function (e) {
						ctrl.onchange.call(this, e);
						ctrl.expr = ctrl.cell();
						cellComponent.calcCell(ctrl);
					},
					value: ctrl.focus ? ctrl.expr : ctrl.disp,
					onfocus: function () { ctrl.focus = 1; ctrl.cell(ctrl.expr); },
					onblur:  function () { ctrl.focus = 0; ctrl.cell(ctrl.disp); }
				})
			);
		}
	};

	// HTML要素のイベントと値にプロパティを接続するユーティリティ
	function m_on(eventName, propName, propFunc, attrs) {
		attrs = attrs || {};
		attrs['on' + eventName] = m.withAttr(propName, propFunc);
		attrs[propName] = propFunc();
		return attrs;
	}

	// componentのclosure化 (componentが静的で増減しない場合は不要)
	function m_comp(comp) {
		var ctrl = new comp.controller();
		return {view: function view() { return comp.view(ctrl); }};
	}

	// 0～n-1までの配列を返す
	function range(n) {
		var array = Array(n);
		for (var i = 0; i < n; ++i)
			array[i] = i;
		return array;
	}

	// マウント
	m.mount($excelElement, excelComponent);
})();
</script>
```

試行錯誤していた時間が長かったが、できてみると、コードは思ったより短いと思う。160行以下だ。

循環参照を検知できないので、試してはいけない。試すなって、ちょっ、試すなよ！（フリ）