flotの選択部分を拡大

flot って何?

flot (http://code.google.com/p/flot/) というのはjQueryで動作するJavaScriptのグラフプロットライブラリです。
動的にさまざまなグラフを生成することができます。
いいからさっさとサンプル見せろよって人は、公式のサンプルが http://people.iola.dk/olau/flot/examples/ にあります。

プロットされたグラフの一部を選択する

flotでは、x軸/y軸ともにドラッグアンドドロップによる選択が標準で用意されています。
たとえば x軸のみ選択可能にしたい場合はプロットするときのオプションを

var options = {
	selection: {
		mode: "x"
	}
};
$.plot(target, data, options)

のようにするだけです。x軸/y軸ともに選択したい場合は mode: "xy" となります。
範囲選択をすると "plotselected" というイベントが通知されます。

選択範囲の拡大

公式サンプルの "Selection support and zooming" にあるので、まずはそちらを見てみましょう。

var placeholder = $("#placeholder");

placeholder.bind("plotselected", function (event, ranges) {
    $("#selection").text(ranges.xaxis.from.toFixed(1) + " to " + ranges.xaxis.to.toFixed(1));

    var zoom = $("#zoom").attr("checked");
    if (zoom)
        plot = $.plot(placeholder, data,
                      $.extend(true, {}, options, {
                          xaxis: { min: ranges.xaxis.from, max: ranges.xaxis.to }
                      }));
});

となってますね。ズームが有効な時は $.plot() をしなおしてると…。毎回グラフを作り直してます。
ちなみに $.plot() は

    $.plot = function(target, data, options) {
        var plot = new Plot($(target), data, options, $.plot.plugins);
        /*var t0 = new Date();
        var t1 = new Date();
        var tstr = "time used (msecs): " + (t1.getTime() - t0.getTime())
        if (window.console)
            console.log(tstr);
        else
            alert(tstr);*/
        return plot;
    };

となっています。Plotクラスのインスタンスをつくってそれを返しています。
今回やりたいことはグラフのズームだけなのに毎回グラフ全体を作り直す必要があるんでしょうか?
とっても無駄な気がします。

無駄な処理があるだけじゃなくてメモリも徐々に消費していく…?

少し話がそれてしまうんですが、今回この話を書こうと思ったのは無駄な処理が気になったわけではないのです。
先ほどの公式サンプルで "Zoom to selection." をチェックして何度も何度も拡大してみてください。
何度も繰り返していると徐々にブラウザのメモリ使用量が増えていませんか?
Firefox3.5, Google chrome 2.0 で確認したところどちらも顕著に増えています。
(IE8でもためそうとしたところそもそもマトモにグラフが表示されませんでした。なんでだろう?)

原因は明らかに毎回インスタンスを作り直すところなので、一度生成した canvas を使い回せば改善するんじゃないかと考えたわけです。

選択範囲の拡大

というわけで、がんばってグラフの再描画をするときに Plot インスタンスを使い回すようにしてみましょう。
まずは公式のAPIリファレンス*1を見てみましょう。
"Plot Methods" というところで何ができるか把握する必要があります。
下記に "APIリファレンスに載っている" メソッドを列挙します。

  • clearSelection()
  • setSelection(ranges, preventEvent)
  • highlight(series, datapoint)
  • unhighlight(series, datapoint)
  • setData(data)
  • setupGrid()
  • draw()
  • getData()
  • getAxes()
  • getCanvas()
  • getPlotOffset()

setData(), setupGrid(), draw() あたりが再描画に使えそうな感じですね。
説明を見てもそれっぽいことが書いてあります。
あ、今回は表示の拡大なので setData() は使わないですね。

残ったのは setupGrid() と draw() です。どちらも引数を取らないメソッドですね。
options はどこで指定するんでしょうか…。
拡大は options を設定しなおして再描画、というだけでできそうなんですがオプションが取れないですね。
こういう場合はとりあえずソースを見てみましょう。
オプションを返すメソッドがないなら最悪自作すればいいです。

        // public functions
        plot.setData = setData;
        plot.setupGrid = setupGrid;
        plot.draw = draw;
        plot.clearSelection = clearSelection;
        plot.setSelection = setSelection;
        plot.getSelection = getSelection;
        plot.getCanvas = function() { return canvas; };
        plot.getPlotOffset = function() { return plotOffset; };
        plot.width = function () { return plotWidth; };
        plot.height = function () { return plotHeight; };
        plot.offset = function () {
            var o = eventHolder.offset();
            o.left += plotOffset.left;
            o.top += plotOffset.top;
            return o;
        };
        plot.getData = function() { return series; };
        plot.getAxes = function() { return axes; };
        plot.getOptions = function() { return options; };
        plot.highlight = highlight;
        plot.unhighlight = unhighlight;
        plot.triggerRedrawOverlay = triggerRedrawOverlay;
        plot.pointOffset = function(point) {
            return { left: parseInt(axisSpecToRealAxis(point, "xaxis").p2c(+point.x) + plotOffset.left),
                     top: parseInt(axisSpecToRealAxis(point, "yaxis").p2c(+point.y) + plotOffset.top) };
        };

ソースを見てみるとAPIリファレンスに載っていないメソッドもいっぱい載ってるぞ! やった!

        plot.getOptions = function() { return options; };

ん…? options をそのまま返しているメソッドが…!
これを使えば拡大ができそうです!
注意しないといけないのは、ここで戻してもらった options に別の配列とかをぶちこむともともとのoptionsと表示がずれる可能性があります。
必要なところだけ上書きしてあげましょう。

var plot = $.plot(target, data, options);
var placeholder = $("#placeholder");

placeholder.bind("plotselected", function(event, ranges){
	var options = plot.getOptions();

	options.xaxis.min = ranges.xaxis.from;
	options.xaxis.max = ranges.xaxis.to;
	options.yaxis.min = ranges.yaxis.from;
	options.yaxis.max = ranges.yaxis.to;
			
	plot.clearSelection();
	plot.setupGrid();
	plot.draw();
});

という感じですね。
ちなみに、「拡大したらページをリロードするまで元に戻さないぜフゥハハァー!」という人以外は元の大きさまで戻す手段も用意しておくといいですね。
min, maxをそれぞれ null にするとデフォルトの設定にもどります。あ、optionsで設定変えてるひとはその値を使うようにしてください。

placeholder.dblclick(function(event){
	var options = plot.getOptions();

	options.xaxis.min = null;
	options.xaxis.max = null;
	options.yaxis.min = null;
	options.yaxis.max = null;
			
	plot.clearSelection();
	plot.setupGrid();
	plot.draw();
});

まとめ

Plotインスタンスを使い回すと速いしすっきりする。

JavaFX 始めました

ブラウザから飛び出る

なんとなく空いてる時間を利用して JavaFX を始めてみました。
なんで JavaFX かというと、理由はいろいろあるのですがブラウザからドラッグアンドドロップしてインストールするというデモを見て「こりゃすげえ!」って思ったからです。
見たことのない人は一度おためしあれ!

http://javafx.com/samples/EffectsPlayground/
(インストールはできないけどブラウザから飛び出す)

何を作ろう?

JavaFXで何をつくるか?というのは非常に重要な問題です。
これまでの経験から、作るものが決まってないと半日もせずに飽きてしまうことが容易に想像できるからです。

特に作りたいアプリケーションもないので、JavaFXを知るきっかけになった「RPGツクール2000/2003エミュレータ」を作ることにします。
JavaFXを知るきっかけとなったのは、15さん( http://www.geocities.jp/kaibazakku/Site/ ) が Flash で同様のことをやろうとしていて BGM の再生を「Flash→JavaScript→JavaApplet」というようにしようとしていたので、いろんなのがごちゃまぜになっててなんか気持ち悪いなぁとおもって代替手段がないか探していたのが発端です。

なぜ BGM 再生が面倒なのか

まず、RPGツクールでは BGM として .wav .mp3 .mid が使用可能です。
これらのファイルをただそのまま再生するだけならば、さほど難しいことではないのです。
しかし、RPGツクールには以下のような機能があります。

  • MIDIの部分ループ
  • 再生速度の変更
  • フェードイン/フェードアウト

これらの変則的な効果を実装するには、現行の Flash などでは難しいです。
しかし、Java には Java Sound API というナイスなライブラリ*1があるので上記の機能もなんとか実装できそうなのです。

がんばって実装を始めます

というわけで JavaFX でまずはツクール仕様の音楽再生機能を実装しようとおもいます。
ところで Java Sound API というナイスなライブラリを使うと言いましたが、あまりにもローレベルなAPIのためなるべく使いたくありません。
MIDIとWAVでそれぞれ実装変えなきゃいけなかったりするし…。
そこで今回使うのは JavaFX の MediaPlayer です!
再生速度の変更、音量の変更など基本的なことはできます。つまりあとはMIDIの部分ループに対応すれば完璧ですね!

MIDIの部分ループ

MIDIの部分ループというのは、ツクールの音楽素材特有の機能で MIDI の Control Change #111(以下CC#111) をループ開始地点として、曲の最後まで演奏したらCC#111までもどってループするという機能です。もちろんCC#111なんていうのは標準の仕様では定められていません。
この機能を実装するためには、MIDIのシーケンスをパースしてやらなければなりません。
でもそんなの自力で実装するのはめんどくさいので Java Sound API にお任せしよう!

つまり、作戦はこういうことです。

  1. MediaPlayer でサクサク再生したい
  2. でも MediaPlayer は CC#111 なんてローカル仕様しらないから部分ループができない
  3. そうだ! 部分ループ再生位置だけ自力でゲットして MediaPlayer に教えてあげればいいんだ!

また、部分ループの際に気をつけないといけないのは「曲の終了時間」も気をつけないといけません。
なぜかというと、ふつうにリピートさせると音がフェードアウトしていって無音になるまでまってから部分ループするからです。
曲のつなぎ目がわからないように部分ループさせたいのに一度無音になるまで待ってしまってはがっかりです。
曲の終わりは MIDI の Meta Event のタイプ 0x2F (End-of-Track) を使いましょう。

ループの開始位置と曲の終わりの取得は以下のようにやります。

    var file  = "sample.mid";
    var sequence = MidiSystem.getSequence(new File(file));
    var end_of_track: Integer[] = [];
    var tick_rate = sequence.getMicrosecondLength()  / sequence.getTickLength();

    /* get loop start position */
    for (track in sequence.getTracks()) {
        for (index in [0..(track.size()-1)]) {
            var event  = track.get(index);
            var tick   = event.getTick();
            var msg    = event.getMessage();
            var status = msg.getStatus();
            var data   = msg.getMessage();

            // control change (0xBn, n=channel)
            if ( Bits.bitAnd(status, 0xF0) == 0xB0) {
                var channel  = Bits.bitAnd(status, 0xB0);
                var databyte = data[1];

                // loop (CC#111)
                if (databyte == 111) {
                    println("*** loop-start: {%02x status} {%02x databyte} at tick:{tick} ***");
                    loop_start = javafx.lang.Duration.valueOf(tick * tick_rate / 1000);
                }
            }

            // meta message (0xFF)
            if (status == 0xFF) {
                var type = data[1];
                var length = data[2];

                // save end-of-track
                if (type == 0x2F) {
                    println("*** end-of-track: {%02x status} {%02x type} {%02x length} at tick:{tick} ***");
                    insert tick into end_of_track;
                }
            }
        }
    }
    /* calculate end-of-music */
    if (end_of_track.size() > 0) {
        end_of_music = javafx.lang.Duration.valueOf(
            (javafx.util.Sequences.max(end_of_track) as Integer) * tick_rate / 1000
        );
    }

MediaPlayer でふつうに再生しよう!

MediaPlayerというのはなんともナイスに作られていて、以下のような感じでさくっと再生できてしまいます。

var file  = "sample.mid";
var media = Media { source: "file:///{file}" };
var player = MediaPlayer {
    media       : media
    repeatCount : MediaPlayer.REPEAT_FOREVER
    rate        : 1 // tempo
};
player.play();

ふつうに再生できましたね。パーフェクトです。こっそり再生速度も指定しちゃってます。

曲の終了時に巻き戻して再生しよう!(失敗編)

MediaPlayer には timers というプロパティがあって、ここに「時間」と「やりたいこと」をいれておけばその時間になったらやってくれるみたいです。
つまり、「曲の終了時」に「ループ開始位置にもどす」ということを設定しておけば今回やりたいことができるというわけです。すごい!
先ほどの player.play(); のところを以下のように変えてみます。

player.timers = [];
insert
    MediaTimer {
        time: end_of_music
        action: function (mediatimer) {
            player.currentTime = loop_start;
        }
    } into player.timers;
player.play();

しかしいろいろ試しても期待した動作になりません。そこでグーグル先生にお伺いをたてたところ、「このAPIはまだ使えないぜ! まあお前ら素人は currentTime へのアクセスをトリガにしててなさいってこった。(意訳)」*2 というのを見つけました。
なるほど、しばらくは currentTime を使うのが良いみたいですね。

曲の終了時に巻き戻して再生しよう!(リベンジ編)

JavaFX 1.2 では "on replace" というキーワードで変数の変更時の挙動をかえることができるようです。イベント駆動万歳ですね。
ではでは、さっそく…

player.currentTime on replace {
    if (player.currentTime >= end_of_music)
        player.startTime = loop_start;
};

なんかはげしく syntax error になります。いいですね。リファレンスを読んでないのがバレバレです。
人様のプロパティに勝手にイベント登録すんなカスってことでしょうか、と勝手に想像して以下のようにお上品にやってみました。

var update_playertime = bind player.currentTime on replace {
    if (player.currentTime >= end_of_music)
        player.startTime = loop_start;
};

怒られなかった! こんな感じでやればいいみたいですね。
再生してみたところ、なんとなくうまくいってる気がしますね。少しずれてるかも…。
その辺は適当に補正いれてやればいいのかな。

まとめ

MIDIの部分ループ再生できた。
今回のソースを以下に貼っておきます。

import javafx.stage.Stage;
import javafx.scene.Scene;
import javafx.scene.text.Text;
import javafx.scene.text.Font;
import javafx.scene.media.Media;
import javafx.scene.media.MediaPlayer;
import javafx.util.Bits;

/* MediaPlayer.timers is not available */
// import javafx.scene.media.MediaTimer;

import java.io.File;
import javax.sound.midi.MidiSystem;

/**
 * @author rgssws4m <rgssws4m@gmail.com>
 */

var file  = "sample.mid";
var media = Media { source: "file:///{file}" };
var loop_start = 0ms;
var end_of_music: Duration = media.duration;

if (file.substring(file.length() - 4) == ".mid") {
    var sequence = MidiSystem.getSequence(new File(file));
    var end_of_track: Integer[] = [];
    var tick_rate = sequence.getMicrosecondLength()  / sequence.getTickLength();

    /* get loop start position */
    for (track in sequence.getTracks()) {
        for (index in [0..(track.size()-1)]) {
            var event  = track.get(index);
            var tick   = event.getTick();
            var msg    = event.getMessage();
            var status = msg.getStatus();
            var data   = msg.getMessage();

            // control change (0xBn, n=channel)
            if ( Bits.bitAnd(status, 0xF0) == 0xB0) {
                var channel  = Bits.bitAnd(status, 0xB0);
                var databyte = data[1];

                // loop (CC#111)
                if (databyte == 111) {
                    println("*** loop-start: {%02x status} {%02x databyte} at tick:{tick} ***");
                    loop_start = javafx.lang.Duration.valueOf(tick * tick_rate / 1000);
                }
            }

            // meta message (0xFF)
            if (status == 0xFF) {
                var type = data[1];
                var length = data[2];

                // save end-of-track
                if (type == 0x2F) {
                    println("*** end-of-track: {%02x status} {%02x type} {%02x length} at tick:{tick} ***");
                    insert tick into end_of_track;
                }
            }
        }
    }
    /* calculate end-of-music */
    if (end_of_track.size() > 0) {
        end_of_music = javafx.lang.Duration.valueOf(
            (javafx.util.Sequences.max(end_of_track) as Integer) * tick_rate / 1000
        );
    }
}

/* make player */
var player = MediaPlayer {
    media       : media
    repeatCount : MediaPlayer.REPEAT_FOREVER
    rate        : 1 // tempo
};

/**
 * XXX: MediaPlayer.timers is not available
 * see: http://forums.sun.com/thread.jspa?threadID=5379055
 *
player.timers = [];
insert
    MediaTimer {
        time: end_of_music
        action: function (mediatimer) {
            player.currentTime = loop_start;
        }
    } into player.timers;
*/

/**
 * instead of MediaPlayer.timers
 */
var update_playertime = bind player.currentTime on replace {
    println("current: {player.currentTime} / {end_of_music}");
    if (player.currentTime >= end_of_music)
        player.startTime = loop_start;
};

// XXX: MediaPlayer.play is BLOCKING METHOD. use thread.
player.play();


/* user interface */
Stage {
    title:  "Midi Loop"
    width:  320
    height: 240
    scene: Scene {
        content: [
            Text {
                font : Font {
                    size : 16
                }
                x: 10
                y: 30
                content: "TotalTime: {player.media.duration}"
            }
        ]
    }
}

JavaFX のリファレンスとかあまり読まずに感性でだらだらとやっただけなので、もっとカッコイイやりかた知ってる人いたら教えてもらえるとうれしいです。
あ、あとはてなダイアリーはじめました。よろしくおねがいします。

2009/08/20: 見出しにすると記事が小分けになってしまうみたいなので修正しました。

*1:あまりナイスじゃないかも…

*2:http://forums.sun.com/thread.jspa?threadID=5379055