2013年4月23日火曜日

Highcharts 3.0で追加された Axis.toPixels() が色々捗る件


Highchartsという、jQueryベースのグラフ描画ライブラリが有ります。
グラフ描画に特化したライブラリの中では、一番、かっこよくてダサくなく実用的なライブラリだと個人的には思っています。
商用利用時は有料ですが、個人利用であれば無料で使えます。
http://www.highcharts.com/
公式サイトはこちら。

最近?だと、PHPMyAdminのグラフ描画なんかでも使われてますね。
※データベースの「状態」や、クエリのプロファイリングで見れます。

ライセンスがどうしても気に食わない、とか
もっとスゲー事やりたい!って人は
D3.jsをどうぞ。
グラフ化よりもう少し上、データビジュアライゼーションのためのライブラリです。
http://ja.d3js.node.ws/
※jQueryに依存してません。

さて。
Highcharts、2013/03/22に3.0にバージョンがあがりました。
パッと見一番解りやすいのは、デフォルトのグラフの色が変わったことなんですが
その他にも、バブルチャートが追加されたり、色々追加されています。
http://www.highcharts.com/component/content/article/2-news/54-highcharts-3-0-released
大きな変更点はこちらの公式ニュースを読んでください。

細かいところでも、いくつかAPIに追加があるのですが、
今回は、タイトルに有るとおり、3.0で追加されたAxis.toPixels()のお話です。

http://api.highcharts.com/highcharts#Axis.toPixels()
公式APIドキュメントが上記です。
APIドキュメント上にサンプルが無いので、
公式デモの一番最初のグラフのソースをベースに紹介しますね。

グラフの下に、謎の数字が出ているかと思います。
これが、「X軸が3の値をプロットするときのX座標」を表しています。
このグラフだと、X軸にcategoryで名前を与えているので、ちょうどAprの所になります。
2にするとMarの所のX座標になります。
小数点でもOKなので、例えば2.5なんて入れると、MarとAprの中間点の座標が取れます。

コレが、もー本当に捗るんですよ!
こいつが後1ヵ月遅ければ、今の私はデスマっていたかもしれないレベルで。

…先ほどのサンプルだけだと何が捗るかよーわからんと思うので、説明しますね。

例えば、先ほどのグラフで、東京の5月の気温をうまくとることが出来ず、データ上0になってしまったとします。
グラフ上すごいV字になってしまいます。
大体の人は「なんか障害があって取れなかったのかな?」と思ってくれるでしょうが、気持ち悪いので、
ちゃんと理由を吹き出しで明示したくなりました。

グラフの下に、HTMLで普通に記述しても良いのですが、
せっかくなので、Excel(笑)のオートシェイプみたいに吹き出しを上に乗せたい!

という時に、こんな感じで書けるんです。

重要なポイントは、chart.events.loadとchart.events.redrawで描画ファンクションを呼ぶ事。
特に、redrawが重要で、ここで再描画するようにしておく事で、
画面のサイズ変更時にも適切な位置に配置してくれます。
Edit in JSFiddleをクリックして、新しいウィンドウででデモ表示して横幅を変えると解りやすいです。
jQueryのwindow resizeのイベントにバインドして再描画しようとしても、上手くできないので注意。

後、y軸の値、今回のケースでは0だと解り切っていましたが、
場合によっては変動する可能性もあるので、4行目のようにして取得すると良いです。

今回、吹き出しのCSSが面倒だったんでAAにしちゃいましたが、
#textのCSSを弄れば、イケてる吹き出しにももちろん出来ます。
widthとかmargin-leftも、動的に取るようにすれば本当に自由に吹き出しが表示出来ますね。
また、#textはただのDIV要素なので、onclick等のイベントも設定出来ます。
「吹き出しをクリックしたら、具体的な障害内容が出る」とかも、jQueryの知識だけで簡単に出来ますね!

というわけで、Highcharts 3.0で追加されたAPIがすごく捗るお話でした。

2013年4月10日水曜日

CakePHP 2系で、独自カラムに対してPaginatorを使ってソートする方法

 
 このやり方が正しいかは解りませんが、
 そこそこ綺麗に解決したような気がするので、覚書として載せておきます。
 
 今回、割と特例的な対応だったので、全部MVCの中で完結させていますが、
 頻繁に使う用であれば、ComponentにするとかPaginator拡張するとかしても良いと思います。
 
 
 さて、まずは簡単な仕様の場合。
 
 CakePHPにはバーチャルフィールドという便利な仕組みがあります。
 「カラムaとカラムbをくっつけてカラムcにしたい!」なんて時にはこう書きます。
public $virtualFields = array(
    'column_c' => 'CONCAT(Hoge.column_a, " ", Hoge.column_b)'
);
参照:http://book.cakephp.org/2.0/en/models/virtual-fields.html
 
 見ての通り、SQL文をそのまま書く事になります。
 具体的には、SELECT句のフィールドに、そのまま
 SELECT CONCAT(Hoge.column_a, " ", Hoge.column_b) AS Hoge__column_c ...
 と記述されるようなイメージです。
 
 SQL文そのままなので、RDBMSが変わると動かなくなってしまうという欠点もあるのですが、
 バーチャルフィールドで定義したカラムは、通常のカラム同様に扱う事が出来るというメリットが有ります。
 なので、find時のorder句に、'column_c DESC'とか書いても、ちゃんとソートしてくれるのです。
 
 便利!
 
 
 さて、ちょっと複雑な仕様のカラムを追加する必要が出てきたとします。
 
 SQL文をそのまま書く、という事は、SQL文で解決できないような値はバーチャルフィールドで扱えません。
 例えば、MySQLの場合、順位を取得するクエリって簡単にはかけないんですよね。
 「複数のカラムが有り、それぞれのカラムの順位を合計した値を「ランク」として表示したい」
 なんて仕様は、さすがにバーチャルフィールドでは荷が重いわけです。
 
 そういう場合、どうするか?というと、
 Modelのコールバックメソッドの、afterFindの中で計算して設定します。
 
 諸々省略して雰囲気だけ伝えるコードを書くと
public function afterFind($results, $primary = false) {
 foreach($results as $key => $result) {
  // なんか計算したりして追加するカラムの値を作る
  $hoge = $aaa + $bbb;
  $results[$key][$this->alias]['hoge'] = $hoge;
 }
 return $results;
}
こんな感じで、hogeってカラムを増やします。
 参照:http://book.cakephp.org/2.0/en/models/callback-methods.html
 
 さて、ここで増やしたカラムに対して、Paginatorでソートしたい場合はどうすれば良いでしょうか?
 
 Helperはさすがにここまでやってくれないので、コントローラ辺りで自前でソートすることになります。
 幸い、CakePHPにはSet(2.2以降だとHashですね)という優秀なコアライブラリがあり、
 上記のafterFindが'Fuga'というモデル内の物だったとすると
$data = $this->Fuga->findAll(/* 省略 */);
$data = Set::sort($data, '/Fuga/hoge');
って記述で簡単にソートしてくれます。
 参照:http://book.cakephp.org/2.0/en/core-utility-libraries/set.html
 
 Paginatorでソートする場合、ソートのパラメータはデフォルトだとnamedに入るので、
 $this->request->named['sort']の値が特定の物だった場合に、
 Set::sortでソートしてやればよい事になります。
 
 先ほどから例に出している、Fugaモデルのhogeカラムで独自ソートしたい場合は、
$data = $this->paginate();
if (isset($this->request->named['sort']) && $this->request->named['sort'] == 'Fuga.hoge') {
 Set::sort($data, '/Fuga/hoge', $this->request->named['dir']);
}
$this->set('data', $data);

 とすればソートされて表示されます。
 
 
 ヤッター!これで独自カラムでソートができたぞー!
 
 と喜び勇んでいると、「デフォルト時にhogeでソートしておいてほしい」
 なんて要求が来たりするわけです。
 
 Paginatorでソートする場合の、クエリの条件は、Controllerの$paginateプロパティに記述します。
 
 これが、もし、fugasテーブル上のpiyoという、存在するカラムに対するソートであれば、
$paginate = array(
 'order' => array('piyo' => ASC)
);
と記述してしまえば良いのですが、こちらのパラメータ、そのまま、SQLのORDER BYに入るので、
 hogeカラムのように、テーブル上にもバーチャルフィールド上にも存在しないカラム名を指定すると
 「そんなカラムねーよばーか」ってMySQLに怒られてしまいます。
 
 仕方がないので、独自ソートするときのif文条件を変更して、
 sortパラメータが無い場合も、hogeでソートする
 というように変更するのが、手っ取り早い対応になるのですが、
 これを行うと、HTML上のasc/descの表示が狂ったり、クリック時の挙動がおかしくなります。
 
 何故か、というと、CakeRequestのparamsに格納された、pagingという値(連想配列です)を元に、
 「今ソートされているキーはどれか?」を判定して、表示上に取り入れているからなんですね。
 具体的には、PaginatorHelperのsortKeyというメソッドで判定しているので、その辺を読んでみてください。
 参照:http://book.cakephp.org/2.0/en/core-libraries/helpers/paginator.html#PaginatorHelper::sortKey
 
 (書いていて気づいたのですが、デフォルト時のソート云々抜きにして、
 Set::sortでデータだけソートしている状態だと、挙動がおかしくなっていた可能性は高いですねー。
 検証してないので、一見うまく動いてくれてる可能性もありますけども。)
 
 ここを解決するためには、Set::sortで独自ソートした後で、$this->request->params['paging']['order']の値を書き換えます。
 paramsの値は直接アクセスして書き換えることが出来ないので、CakeRequestのoffsetSetを使います。
 纏めると、先ほどのif文の中身は、こうなります。
if(/* 省略 */) {
 Set::sort($data, '/Fuga/hoge', $this->request->named['dir']);
 // 現在セットされているpagingパラメータを取得、値を上書きしてから再セット
 $pagingParams = $this->request->offsetGet('paging');
 $pagingParams['Fuga']['order'] = array('Fuga.hoge' => $dir);
 $this->request->offsetSet('pagin', $pagingParams);
}
これにて、無事、独自追加したフィールドに対するページングがうまく働くようになりました。
 
おわり!