こんにちは。エンジニアの辻です。
前回は、線形変換のポイントと行列積の特徴を見てきました。
今回は、行列積の応用っぽいことをやってみたいと思います。
どんな事をやるかというと「canvas 上で複数の線形変換を行った要素を任意の時点まで戻す」機能をつくっていきます。
いわゆる「元に戻す」機能の実装です。
「元に戻す」機能って、様々なアプリケーションに実装されていますよね。
言わずもがな、直前の動作を打ち消して、ワンステップ前の状態に戻す機能です。
windows なら Ctrl + Z キーで。 Mac なら Cmd + Z キーで実行できます。
私も大変お世話になっています。
※前回に扱った行列積の特徴「行列積は計算する順番によって結果が異なる = 計算順序が重要」が肝になります。
もし前回の記事を見ていない方は、JS奮闘記【JavaScriptとcanvasと線形変換 その4 ~線形変換のポイントと行列積の特徴編~】をご覧ください。
今回の成果物
まずはじめに今回の成果物です。
ソースコードは以下から確認できます。
画面の構成自体は前回とほぼ同様です。
1 つだけ異なるのは、画面左下に「戻る」ボタンを実装している点です。
この「戻る」ボタンを押下していくと、複数回の線形変換を行って座標を変えていった要素(点 P””)をワンステップずつ変換前の座標へ戻していく事ができます。
(変換前の座標へ戻した時に、その座標へ赤ボックスが描画されていきます。)
点 P には以下の順で、線形変換を行っています。
まずはじめに (75, 75)に点 P を描画します。これが初期位置です。
その後は…
- x 軸に対して反転する(点 P’)
- x 軸方向へ距離を 2 倍する(点 P”)
- y 軸方向へ距離を 3 倍にする(点 P”’)
- y 軸に対して反転する(点 P””)
…としています。
「戻る」ボタンを 1 回押せば、3. の時点に戻り、2 回押せば、2.の時点へ戻ります。
そして、4 回押せば、一番はじめの座標 点P(75,75)まで戻ります。
「元に戻す」機能の 2 パターンの実装方法
さて、さっそく「元に戻す」機能の中身を見ていきたいのですが…、
その前に、この機能の実装方法を 2 パターン紹介します。
「元に戻す」機能には、様々な実装方法があるかと思いますが、だいたいのアプリケーションは以下の 2 パターンのいずれかで、実装されているのではないでしょうか。
- その 1. 各時点の状態を履歴で管理する
- その 2. 作業対象と作業内容のみで履歴を管理する
その 1. 各時点の状態を履歴で管理する
「元に戻す」機能の最もシンプルな実装方法です。
ただ単純に、その時点における状態すべてを履歴に入れて管理します。
以下のように履歴を管理する配列(history)を用意します。
history の一番はじめには、初期状態を格納します。
ユーザーが何かしらの作業を行った場合、その作業結果の状態を history に追加していきます。
こうして各時点での状態が history に格納されていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
// 履歴を管理する配列 history = [ // 初期状態 [ { id: A, point: [0, 1] }, { id: B, point: [0, 2] }, … ], // 1回目の作業後の状態 [ { id: A, point: [3, 4] }, { id: B, point: [3, 8] }, … ], // 2回目の作業後の状態 [ { id: A, point: [3, 5] }, { id: B, point: [12, 6] }, … ], …, // n回目の作業後の状態 […] ] |
ある時点まで元に戻す場合は、history の中身を見ていき、ある時点の index 番目 + 1の状態を取り出して、アプリケーションの状態に反映します。
( + 1 するのは、配列の数え順が 0 から始まるためです。)
要は各時点の状態すべてを配列にぶち込んで管理するという力技です。
シンプルなアプリケーションであれば、上記の方法で事足りるかと思います。
しかし、履歴として管理したい要素の数が多くなった場合、大変なことになります。
例えば、要素が 100 コある配列の状態を管理したいとしましょう。
すると、history は以下のようになってしまいます。
1 2 3 4 5 6 7 |
history = [ […], // 100 件の Array。初期状態。 […], // 100 件の Array。1回目の作業後の状態。 […], // 100 件の Array。2回目の作業後の状態。 …, […], // 100 件の Array。n回目の作業後の状態。 ] |
これでは、履歴の管理だけでリソースを食いつぶしかねません。
要素数の多いオブジェクトの履歴を管理したい場合は、次のような作業対象と作業内容のみで履歴を管理する方法をオススメします。
その 2. 作業対象と作業内容のみで履歴を管理する
実は「作業対象」と「作業内容 = 何の処理をしたか」が分かれば、履歴を簡単に管理する事ができます。
まず履歴を管理する配列を用意します。
そして、ユーザーがなにかしらの作業を行った時に、履歴へ「作業対象」と「作業内容 = 何の処理をしたか」を格納していきます。
以下のようなイメージです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// 初期状態 status = { objectA = { … }, objectB = { … }, } // 履歴を管理する配列 history = [ // 1回目の作業 { target: status.objectA, // 作業対象 func: hoge // 作業内容 = 何の処理をしたか }, // 2回目の作業 { target: status.objectB, func: fuga }, …, // n回目の作業 { target: status.objectA, func: piyo }, ] |
ある時点まで元に戻す処理の流れは、以下の通りです。
まず history の中身を見ていきます。
そして、”ある時点の index 番目 + 1 “までの「作業対象」と「作業内容 = 何の処理をしたか」を取り出します。
この「作業対象」と「作業内容」を元に、作業対象へ、作業内容を打ち消す処理を順番に実行します。
このようにして、履歴の「作業対象」と「作業内容」に沿って、状態を 1 つずつ元に戻していく事ができます。
2.の方法は、線形変換の特徴「行列積は計算する順番によって結果が異なる = 計算順序が重要」と、うまくマッチしているため、描画処理の履歴管理に適しています。
また、1.の方法に比べて、「作業対象」と「作業内容」のみで履歴を管理できるため、リソースを抑えられるメリットもあります。
2.の方法で、要素数 100 コの配列の変更履歴を管理する場合を考えてみましょう。
…と言っても、特別に何かの手を加える必要はありません。
要素数が増えようが、「作業対象」と「作業内容」のみが分かれば良いので、ユーザーが作業を行うたびに、履歴に「作業対象」と「作業内容」を追加していけば OK です。
任意の時点まで状態を戻す場合は、「作業対象」と「作業内容」を 1 つ 1 つ取り出して、打ち消し処理を順番に実行していけば、完了です。
1.のように 100 コの状態すべて x n 回分を管理する必要がないので、非常に効率的です。
今回の成果物は、2.の方法で「元に戻す」機能を実装しています。
では、成果物のソースコードを見ていきましょう。
「元に戻す」機能の中身
ソースコードは以下から確認できます。
画面の初期描画は前回までと同様です。
今回は以下のコードを追記しています。
▼main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
// 変換の履歴 const history = [] // 中略 // 元に戻す処理 const undo = () => { // 変換の履歴がない場合 if (history.length === 0) return switch (history[history.length - 1].func) { case 'inversionX': // x軸に対して反転処理を戻す history[history.length - 1].target.x = inversionXTransformation(history[history.length - 1].target).x history[history.length - 1].target.y = inversionXTransformation(history[history.length - 1].target).y break case 'inversionY': // y軸に対して反転処理を戻す history[history.length - 1].target.x = inversionYTransformation(history[history.length - 1].target).x history[history.length - 1].target.y = inversionYTransformation(history[history.length - 1].target).y break case 'multipe': // n倍を戻す const nx = history[history.length - 1].args.nx const ny = history[history.length - 1].args.ny history[history.length - 1].target.x = multipeTransformation(history[history.length - 1].target, 1 / nx, 1 / ny).x history[history.length - 1].target.y = multipeTransformation(history[history.length - 1].target, 1 / nx, 1 / ny).y break default: break } // 履歴の末尾を消去 history.pop() } window.onload = function () { returnButton = document.getElementById('returnButton') // 初期化処理 init() // 原点を描画 drawCircle(0, 0, 4, '#000000') // y軸を描画 drawLine(0, -(height / 2), 0, height / 2) // x軸を描画 drawLine(-(width / 2), 0, width / 2, 0) // 線形変換前の点オブジェクト const rect01 = { x: 75, y: 75 } // ボックスの大きさ const boxSize = 20 // 初期配置のオブジェクトを描画 drawRect(rect01.x - boxSize / 2, rect01.y - boxSize / 2, boxSize, boxSize, 'orange') // 1. x 軸に対して反転する rect01.x = inversionXTransformation(rect01).x rect01.y = inversionXTransformation(rect01).y // 1. を履歴に追加 history.push({ target: rect01, func: 'inversionX' }) // 1. の結果を描画 drawRect(rect01.x - boxSize / 2, rect01.y - boxSize / 2, boxSize, boxSize, 'green') // 2. x 軸方向へ距離を 2 倍する rect01.x = multipeTransformation(rect01, 2, 1).x rect01.y = multipeTransformation(rect01, 2, 1).y // 2. を履歴に追加 history.push({ target: rect01, func: 'multipe', args: { nx: 2, ny: 1 } }) // 2. の結果を描画 drawRect(rect01.x - boxSize / 2, rect01.y - boxSize / 2, boxSize, boxSize, 'green') // 3. y 軸方向へ距離を 3 倍する rect01.x = multipeTransformation(rect01, 1, 3).x rect01.y = multipeTransformation(rect01, 1, 3).y // 3. を履歴に追加 history.push({ target: rect01, func: 'multipe', args: { nx: 1, ny: 3 } }) // 3. の結果を描画 drawRect(rect01.x - boxSize / 2, rect01.y - boxSize / 2, boxSize, boxSize, 'green') // 4. y 軸に対して反転する rect01.x = inversionYTransformation(rect01).x rect01.y = inversionYTransformation(rect01).y // 4. を履歴に追加 history.push({ target: rect01, func: 'inversionY' }) // 4. の結果を描画 drawRect(rect01.x - boxSize / 2, rect01.y - boxSize / 2, boxSize, boxSize, 'green') // 戻るボタンにイベントをセット returnButton.addEventListener( 'click', () => { undo() drawRect(rect01.x - boxSize / 2, rect01.y - boxSize / 2, boxSize, boxSize, 'red') }, false ) } |
全体の流れは以下の通りです。
まず、画面の初期描画が終わった後に、点 P (x: 75, y: 75)を描画します。これが初期位置です。
その次に、以下の処理を実行しています。
- 点 P を、x 軸に対して反転する。
- 点 P を canvas に描画する。
- 履歴を管理する history に「作業対象」と「作業内容」を格納する。
「作業対象」は rect01 (点P)。「作業内容」は文字列で関数名を入れます。
- 点 P を、x 軸方向へ距離を 2 倍して、描画。
以降、1.の 1. 2.と同様。 - 点 P を、y 軸方向へ距離を 3 倍して、描画。
以降、1.の 1. 2.と同様。 - 点 P を、y 軸に対して反転して、描画。
以降、1.の 1. 2.と同様。
こうすると history の中身は、以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
const history = [ { target: rect01(点P), // 作業対象 func: 'inversionX', // 作業内容 args: {} // 作業内容の予備情報 }, { target: rect01, func: 'multipe', args: { x: 2, y: 1 } }, { target: rect01, func: 'multipe', args: { x: 1, y: 3 } }, { target: rect01, func: 'inversionY', args: {} } ] |
最終的に点 P は、第三象限に描画されます。
では、「戻る」ボタンを押下してみましょう。
「戻る」ボタンを押下すると、undo 関数が実行され、点 P がワンステップ前の位置に描画されていきます。
この undo 関数こそが「戻る」機能の中身になります。
詳しく中身を見ていきましょう。
▼main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// 元に戻す処理 const undo = () => { // 変換の履歴がない場合 if (history.length === 0) return switch (history[history.length - 1].func) { case 'inversionX': // x軸に対して反転処理を戻す history[history.length - 1].target.x = inversionXTransformation(history[history.length - 1].target).x history[history.length - 1].target.y = inversionXTransformation(history[history.length - 1].target).y break case 'inversionY': // y軸に対して反転処理を戻す history[history.length - 1].target.x = inversionYTransformation(history[history.length - 1].target).x history[history.length - 1].target.y = inversionYTransformation(history[history.length - 1].target).y break case 'multipe': // n倍を戻す const nx = history[history.length - 1].args.nx const ny = history[history.length - 1].args.ny history[history.length - 1].target.x = multipeTransformation(history[history.length - 1].target, 1 / nx, 1 / ny).x history[history.length - 1].target.y = multipeTransformation(history[history.length - 1].target, 1 / nx, 1 / ny).y break default: break } // 履歴の末尾を消去 history.pop() } |
長々と書いていますが、undo 関数の中身はシンプルです。
まず undo 関数が発火した時に、history を末尾要素を見にいきます。
次に、末尾要素の func の文字列をみて、どんな処理が実行されたか(作業内容)を判断しています。
あとは作業対象(target)に対して、作業内容を打ち消す処理を実行すれば OK です。
例えば func が inversionX であれば、直前の作業内容は「x 軸に対して反転」であると判断できます。
そうと分かれば、作業対象に x 軸に対して反転すれば、元に戻りますね。
上記と同様に…
- func が inversionY であれば、直前の作業内容は「y 軸に対して反転」なので、target に対して y 軸に反転して、元に戻す。
- func が multipe であれば、直前の作業内容は「長さを n 倍にする」なので、target に対して 1 / n 倍して、元に戻す。(※n 倍を元に戻すには、n の値も必要なので、histroy に args として格納しています。)
…となります。
このようにして、target(作業対象)と func (作業内容)が分かれば、状態を元に戻せるわけです。
最後は、元に戻す処理を実行した後に pop()
を使って、最新の履歴を削除します。
undo 関数が完了すれば、作業対象(target)の情報はワンステップ前の状態に戻っているはずなので、後は画面上に描画するだけですね。
history 配列と undo 関数による「元に戻す」処理の様子は、行列積と同じです。
行列積は計算の順番が重要ですが、履歴の順番は history 配列によって保証されるので、ちゃんと元の値が復元できるわけですね。
まとめ
今回は「元に戻す」機能の実装方法を見てきました。
履歴を「作業対象」と「作業内容 = 何の作業をしたか」で管理する手法は、canvas の他にも、様々なアプリケーションや機能に応用できます。
「元に戻す」機能を実装する時は、ぜひこの方法を一考してみてください。
では、また!