k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

画像アップロード(1)Canvasでプレビューして普通に画像をアップロード

AJAXとかドラッグ&ドロップといったややこしい処理はおいといて、HTML5Canvasを使ってクライアント側でプレビューしてみるテストです。

動作確認した環境は以下の通りです。

Volcanus_CanvasResizer.jsでCanvasプレビューする

いきなり俺々ライブラリ登場で恐縮ですが…。

Volcanus_CanvasResizer.js は今回のような処理のために書いた createObjectURL or FileReader + Canvas で画像をリサイズして表示するだけのものです。

/**
 * Volcanus_CanvasResizer
 * 
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
var Volcanus_CanvasResizer = {
    create: function(args) {
        var instance = {
            file: null
            ,maxWidth: 0
            ,maxHeight: 0
            ,objectUrl: null
            ,onLoad: null
            ,init: function(args) {
                if (typeof args.file !== 'undefined') {
                    this.file = args.file;
                }
                if (typeof args.maxWidth !== 'undefined') {
                    this.maxWidth = args.maxWidth;
                    if (typeof args.maxHeight !== 'undefined') {
                        this.maxHeight = args.maxWidth;
                    }
                }
                if (typeof args.maxHeight !== 'undefined') {
                    this.maxHeight = args.maxHeight;
                    if (typeof args.maxWidth !== 'undefined') {
                        this.maxWidth = args.maxHeight;
                    }
                }
                if (typeof args.onLoad !== 'undefined') {
                    this.onLoad = args.onLoad;
                }
                var url = window.webkitURL || window.URL;
                var createObjectURL = (url && url.createObjectURL);
                var image = new Image();
                var _self = this;
                image.onload = function() {
                    var dist = _self.getSize(image.width, image.height);
                    var canvas = document.createElement('canvas');
                    var context = canvas.getContext('2d');
                    canvas.width = dist.width;
                    canvas.height = dist.height;
                    context.drawImage(image, 0, 0, image.width, image.height, 0, 0, dist.width, dist.height);
                    if (typeof _self.onLoad !== 'undefined') {
                        _self.onLoad(image, canvas);
                    }
                    if (url && _self.objectUrl) {
                        url.revokeObjectURL(_self.objectUrl);
                    }
                };
                if (typeof createObjectURL !== 'undefined') {
                    image.src = this.objectUrl = url.createObjectURL(this.file);
                } else {
                    var reader = new FileReader();
                    reader.onload = function(event) {
                        image.src = event.target.result;
                    }
                    reader.readAsDataURL(this.file);
                }
                return this;
            }
            ,getSize: function(srcWidth, srcHeight) {
                var dist = {
                    width: srcWidth
                    ,height: srcHeight
                }
                if ((this.maxWidth > 0 && srcWidth > this.maxWidth) || (this.maxHeight > 0 && srcHeight > this.maxHeight)) {
                    var w_percent = (100 * this.maxWidth) / srcWidth;
                    var h_percent = (100 * this.maxHeight) / srcHeight;
                    if (w_percent < h_percent) {
                        dist.width = this.maxWidth;
                        dist.height = Math.floor((srcHeight * w_percent) / 100);
                    } else {
                        dist.width = Math.floor((srcWidth * h_percent) / 100);
                        dist.height = this.maxHeight;
                    }
                    if (dist.width < 1) {
                        dist.width = 1;
                    }
                    if (dist.height < 1) {
                        dist.height = 1;
                    }
                }
                return dist;
            }
        }
        instance.init(args);
        return instance;
    }
};

以下はこのライブラリを使ったHTML + JavaScriptだけの最小サンプルです。

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>CanvasResizer 最小サンプル</title>
</head>

<body>
<form method="post" enctype="multipart/form-data">
    <div class="uploader">
        <p>
            <input name="sample_image" type="file" accept="image/*" />
        </p>
        <div class="thumbnail"></div>
    </div>
    <p>
        <input type="submit" value="送信する" />
    </p>
</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript">/*<![CDATA[*/
$(function() {
    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var file = event.target.files[0]
        if (file.type.substring(0, 6) === 'image/') {
            var uploader = $(this).parent().parent()[0];
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html(
                        $('<img>').attr('src', canvas.toDataURL('image/png'))
                    ).append(
                        $('<h3>').text(file.name)
                    ).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + file.size))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                }
            });
        }
    });
});
/*]]>*/</script>

</body>
</html>

Volcanus_CanvasResizer.create({...}) で対象ファイル、横幅の最大値、高さの最大値、画像ファイルを読み終えた際のコールバックを指定しています。

コールバックの引数に元画像のImageオブジェクトとリサイズされたCanvasが渡されますので、 Canvas.toDataURL() でDataURLに変換してプレビュー表示したり、元の元の横幅、高さなどを取得できます。

フォーム上でファイルを選択すると、こんな感じで指定した横幅と高さの最大値に合わせてリサイズされたプレビュー画像が表示されます。

クライアントのメモリ量に左右されますので、巨大なファイルだとこういうことになります…。(私の拙いJavaScript力だとこんなもんです…)

Volcanus_FileUploaderでPHPのファイルアップロード処理を簡潔に書く

またまた俺々ライブラリ登場で恐縮ですが…。

Volcanus_FileUploader は今回のような処理のために書いた…(略)

Uploaderクラス + FileValidatorクラス + Fileクラス (FileInterface実装クラス) という構成になっていて、これらを組み合わせて使います。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;
use Volcanus\FileUploader\Exception\FilenameException;
use Volcanus\FileUploader\Exception\FilesizeException;
use Volcanus\FileUploader\Exception\ExtensionException;
use Volcanus\FileUploader\Exception\ImageWidthException;
use Volcanus\FileUploader\Exception\ImageHeightException;

$form = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => 'jpg,png',
        'maxWidth' => 400,
        'maxHeight' => 400,
        'maxFilesize' => '1M',
    ]);

    if (isset($_FILES['sample_image'])) {

        $uploadedFile = new NativeFile($_FILES['sample_image']);

        try {

            $uploader->validate($uploadedFile, $validator);

            $form['name'] = $uploadedFile->getClientFilename();
            $form['size'] = $uploadedFile->getSize();
            $form['type'] = $uploadedFile->getMimeType();
            $form['dataUri'] = $uploadedFile->getContentAsDataUri();

            if ($uploadedFile->isImage()) {
                if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                    $form['width'] = $width;
                    $form['height'] = $height;
                }
            }

            $form['movedPath'] = $uploader->move($uploadedFile);

        } catch (FilenameException $e) {
            $error = 'ファイル名が不正です。';
        } catch (FilesizeException $e) {
            $error = sprintf('ファイルサイズが%sバイトを超えています。', $validator->config('maxFilesize'));
        } catch (ExtensionException $e) {
            $error = sprintf('アップロード可能なファイルは%sです。', $validator->config('allowableType'));
        } catch (ImageWidthException $e) {
            $error = sprintf('横幅が%spxを超えています。', $validator->config('maxWidth'));
        } catch (ImageHeightException $e) {
            $error = sprintf('高さが%spxを超えています。', $validator->config('maxHeight'));
        } catch (\Exception $e) {
            $error = 'アップロードに失敗しました。';
        }
    }

}
include __DIR__ . '/template.php';

バリデーションはFileValidatorクラスで受け付けるファイルタイプやファイルサイズ、画像の横幅や高さを指定した上で、Uploader::validate() の第1引数にFile、第2引数にFileValidatorを渡して実行します。

バリデーションエラーは例外でスローされますので、適宜これをキャッチすることでエラーメッセージを出力できます。(複数の項目にバリデーションエラーがある場合は最初に検知したもののみとなります…ここはいずれ変えるかも…)

※2015-03-19追記:最新版ではバリデーションエラー発生時に例外をスローするかどうかをオプションで指定できるようにしました。

上記サンプルでは、バリデーション結果がOKの場合のみ、DataURI形式で画像を取得するとともに、ファイルを移動して移動先のパスを取得しています。この辺りは画像ファイルのアップロードではお決まりの処理になるんじゃないでしょうか。(ファイルが巨大だとDataURI生成でメモリがアウアウしてしまうかもしれませんが…)

前述の Volcanus_CanvasResizer.js に加えて、上記サーバ側で取得した変数の表示を組み込むとこんな感じです。(素のPHP

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>CanvasResizer + Volcanus_FileUploaderサンプル</title>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data">

    <div class="uploader">
        <p>
            <input name="sample_image" type="file" accept="image/*" />
        </p>
        <div class="thumbnail">
<?php if (isset($form['dataUri'])) : ?>
            <img src="<?=htmlspecialchars($form['dataUri'])?>" />
<?php else : ?>
            <img data-src="/js/holder.js/400x400" />
<?php endif ?>
<?php if (isset($form['name'])) : ?>
            <h3><?=htmlspecialchars($form['name'])?></h3>
<?php endif ?>
            <ul>
<?php if (isset($form['size'])) : ?>
                <li>ファイルサイズ:<?=htmlspecialchars($form['size'])?></li>
<?php endif ?>
<?php if (isset($form['type'])) : ?>
                <li>ファイル種別:<?=htmlspecialchars($form['type'])?></li>
<?php endif ?>
<?php if (isset($form['width'])) : ?>
                <li>横幅:<?=htmlspecialchars($form['width'])?></li>
<?php endif ?>
<?php if (isset($form['height'])) : ?>
                <li>高さ:<?=htmlspecialchars($form['height'])?></li>
<?php endif ?>
<?php if (isset($form['movedPath'])) : ?>
                <li>配置先:<?=htmlspecialchars($form['movedPath'])?></li>
<?php endif ?>
            </ul>
        </div>
    </div>

    <p>
        <input type="submit" value="送信する" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript">/*<![CDATA[*/
$(function() {
    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var file = event.target.files[0]
        if (file.type.substring(0, 6) === 'image/') {
            var uploader = $(this).parent().parent()[0];
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html(
                        $('<img>').attr('src', canvas.toDataURL('image/png'))
                    ).append(
                        $('<h3>').text(file.name)
                    ).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + file.size))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                }
            });
        }
    });
});
/*]]>*/</script>

</body>
</html>

バリデーションエラーの時はこんな感じでメッセージ表示しています。

バリデーションエラーがなければアップロードファイルの内容をDataURIで取得した後でファイルを移動し、DataURIの画像と移動先のパスを表示しています。

なお移動後のファイル名は sha1(uniqid(mt_rand(), true)) とアップロード時の拡張子で決定されます。

確認画面を入れたい、ファイル選択がださいのでスタイル適用したい、ドラッグ&ドロップ対応、かつIEでも動くように、など様々な要求に対応しようとすると大変ですが、ひとまずクライアント側プレビューとサーバ側バリデーションとアップロードのシンプルな流れは実装できました。

参考にした情報

Windowsでgitコミット差分抽出+ZIPアーカイブを生成するバッチファイル

git diff コマンドと git archive コマンドを利用した差分抽出については知ってはいたんですが、しばらくFTPでのアップロードが必要なプロジェクトから逃れていたこともあり、あまり追求していませんでした。

今回再びそのような環境に携わることになりましたので、Windowsで実現する方法を調べて試してみたところ、以下のようなバッチファイルでうまくいきました。

git-archive-diff.bat

@echo off
setlocal ENABLEDELAYEDEXPANSION
set DIFF_LIST=
set NEW_SHA=%1
set OLD_SHA=%2
if "%OLD_SHA%" == "" (
  set OLD_SHA=%NEW_SHA%
  set ARCHIVE="%NEW_SHA:~0,7%.zip"
) else (
  set ARCHIVE="%OLD_SHA:~0,7%-%NEW_SHA:~0,7%.zip"
)
for /f "usebackq" %%A in (`git diff --name-only --diff-filter=AM %OLD_SHA%..%NEW_SHA%`) do set DIFF_LIST=!DIFF_LIST! %%A
git archive --format=zip HEAD -o %ARCHIVE% %DIFF_LIST%
endlocal

SourceTreeから実行

自分はこのバッチファイルをSourceTreeから利用していますので、参考までに設定の手順を記しておきます。

適当な場所に配置した上で、 [Tools] → [Options] → [Custom Actions] → [Add] でカスタムアクション名とバッチファイルのパスを指定し、パラメータに $SHA と入力します。

例ではカスタムアクション名を「archive-diff」としています。オプションのチェックボックスはチェックしなくていいです。

差分を抽出したいコミットを2つ選択し、右クリックから [Custom Actions] → [archive-diff] と選択すると、登録したバッチファイルが実行され、リポジトリのルートに差分を抽出したZIPファイルが作成されます。

コマンドラインから実行

$ git-archive-diff {新しい方のコミットID} {古い方のコミットID}

引数で指定するコミットIDの順序が直感的ではありませんが、SourceTreeのカスタムアクションの仕様に合わせてこうなっています。

コマンドラインで利用する場合はバッチファイルの set NEW_SHA=%1set OLD_SHA=%2 のところを %1 ←→ %2 として、引数を入れ替えた方が分かりやすいかもしれません。

参考

スクリプトの内容は、こちらの質問および回答を参考にさせていただきました。(参考というかほとんどそのままです…)

git diffコマンドにdiff-filterオプションを追加したのと、出力ファイル名をコミットIDから生成する処理を追加しただけです。

回答にもありますが、コマンドプロンプトや変数で扱える文字数に制限がある(8191文字?)とのことで、対象ファイルが多い、またファイル名が長いとエラーが発生するかもしれません。

なお、以下のMicrosoft技術情報はWindows XPのものですが、長すぎるパラメータはファイルから読み込むようにすればこの制限を回避することができるようです。

以下の記事はバッチファイルの仕様を把握するのにお世話になりました。

PHPは言語仕様がクソ」とかよく言われますが、PHPの文法は分かりやすいよなぁ、というのが真性PHPerな自分の正直な感想です…。

(記号ばっかり…同じ記号の意味が文脈で変わる…エスケープどうすればいいの…なんでコマンドラインとバッチファイルで違うの…みたいな)

PHPUnit 4.1系で \Symfony\Component\HttpFoundation\File\UploadedFile のモックオブジェクトを作成しようとすると "Erroneous data format for unserializing" のエラーが発生した件

PHPUnit 4.1系で \Symfony\Component\HttpFoundation\File\UploadedFile のモックオブジェクトを作成しようとすると "Erroneous data format for unserializing" のエラーが発生します。

PHP 5.6.1 + PHPUnit 4.1.6 で確認しました。

エラーメッセージで検索してみると、phpunit/phpunit-mock-objects にこんなissueが。

whatthejeff さん曰く

Unfortunately, since Symfony\Component\HttpFoundation\File\UploadedFile extends SplFileInfo, you will not be able to instantiate it without calling its constructor in 5.5.16. This was previously possible with a serialization hack, but it has been deemed unsafe by the PHP core team.

(英語分からないので機械翻訳を元にしたフィーリング訳ですが)

Symfony\Component\HttpFoundation\File\UploadedFile は SplFileInfo を継承していますが、組み込みオブジェクトの多くがシリアライズ不可なため、以前は "serialization hack" と呼ばれる手段で回避していたとのこと。

しかし、PHP 5.5.16 から(?)この方法が安全ではないと判断されて禁止され、コンストラクタを呼ばずにインスタンスを生成できなくなったため、エラーが発生するようになったみたいです。

issueのコメントからリンクされていたコミットを見たところ phpunit/phpunit-mock-objects の 2.3で対処されたみたい?

差分を見たところ ReflectionClass::isInternal() での分岐処理が追加されてます。

ユーザークラスの場合は ReflectionClass::newInstanceWithoutConstructor() で生成されて、内部クラスの場合は従来通りのコード、つまり "serialization hack" で生成しているようです。

クラス名を元に空のオブジェクトをシリアライズした文字列を組み立てて unserialize() すると、インスタンスが生成できるというトリックのようですね。

<?php
// We have to use this dirty trick instead of ReflectionClass::newInstanceWithoutConstructor()
// because of https://github.com/sebastianbergmann/phpunit-mock-objects/issues/154
$object = unserialize(
    sprintf('O:%d:"%s":0:{}', strlen($className), $className)
);

コメントを見たところ、そもそもこのトリックを使い出したきっかけもSymfony2のテストで不具合が発生したためのようです。

ともあれ、現在の phpunit/phpunit-mock-objects のバージョンを調べてみると…。

$ composer global show -i phpunit/phpunit-mock-objects
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
name     : phpunit/phpunit-mock-objects
descrip. : Mock Object library for PHPUnit
keywords : mock, xunit
versions : * 2.1.5
type     : library
license  : BSD-3-Clause
source   : [git] https://github.com/sebastianbergmann/phpunit-mock-objects.git 7878b9c41edb3afab92b85edf5f0981014a2713a
dist     : [zip] https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/7878b9c41edb3afab92b85edf5f0981014a2713a 7878b9c41edb3afab92b85edf5f0981014a2713a
names    : phpunit/phpunit-mock-objects

(以下略)

2.1.5らしい。こりゃあかんわ。

とりあえずPHPUnitのバージョンを4.1系から4.2系に上げてみます。

$ composer global require "phpunit/phpunit=4.2.*"
$ composer global update
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing doctrine/instantiator (1.0.4)
    Downloading: 100%

  - Removing phpunit/phpunit-mock-objects (2.1.5)
  - Installing phpunit/phpunit-mock-objects (2.3.0)
    Downloading: 100%

  - Removing phpunit/phpunit (4.1.6)
  - Installing phpunit/phpunit (4.2.6)
    Downloading: 100%

Writing lock file
Generating autoload files

試してみたところ、これで通るようになりました。同じ問題に引っ掛かった方へのヒントになれば幸いです。

グラフ描画ライブラリ Chart.js で凡例を表示する

JavaScriptのグラフ描画ライブラリ Chart.js

サンプルが綺麗でシンプルなためかデザイナーの方に人気のようで、いろんなブログで紹介されてます。

しかしサンプルではラベルを設定しているにも関わらず凡例が表示されておらず、「凡例を表示する機能がない」とまで書かれている記事もあって、そんなバカなと思いつつ公式ドキュメント(Chart.js | Documentation)を確認したら、ちゃんと対応されていました。

凡例にラベルを出力するには legendTemplate オプションと generateLegend() メソッドを使え

凡例にラベルを出力するには、グラフの設定オプション legendTemplate でラベルを出力するよう値を指定した上で、ChartオブジェクトのgenerateLegend()メソッドを実行し、その戻り値で得られるHTMLをどこかに差し込む必要があります。

Chart.js の設定値には全グラフ共通の Global chart configuration と 各グラフ別の設定があります。

例えば Bar Chart (Chart.Bar.js)のデフォルト設定ではこのような値が設定されています。

legendTemplate : "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; i++){%><li><span style=\"background-color:<%=datasets[i].fillColor%>\"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>"

Chart.jsは内部で簡易のマイクロテンプレート機能を持っていて、どういう仕組みかは分かりませんが、上記のようなfor文とif文の中身を置換してくれます。

Chart.Core.js のコメントによると Javascript micro templating by John Resig が元らしい)

以下は Bar Chartで凡例を表示する例です。

<canvas id="chart_canvas" width="800" height="400"></canvas>
<ul id="chart_legend"></ul>
<script type="text/javascript" src="/static/js/Chart.js/Chart.js"></script>
<script type="text/javascript">/*<![CDATA[*/
var chart_data = {
    labels: ['9月', '10月', '11月'],
    datasets: [
        {
            label: 'りんご',
            fillColor: 'rgba(255, 0, 0, 0.5)',
            strokeColor: 'rgba(255, 0, 0, 0.75)',
            highlightFill: 'rgba(255, 0, 0, 0.75)',
            highlightStroke: 'rgba(255, 0, 0, 1)',
            data: [10, 20, 30]
        },
        {
            label: 'バナナ',
            fillColor: 'rgba(255, 255, 0, 0.5)',
            strokeColor: 'rgba(255, 255, 0, 0.75)',
            highlightFill: 'rgba(255, 255, 0, 0.75)',
            highlightStroke: 'rgba(255, 255, 0, 1)',
            data: [30, 10, 20]
        },
        {
            label: 'みかん',
            fillColor: 'rgba(255, 255, 128, 0.5)',
            strokeColor: 'rgba(255, 255, 128, 0.75)',
            highlightFill: 'rgba(255, 255, 128, 0.75)',
            highlightStroke: 'rgba(255, 255, 128, 1)',
            data: [20, 30, 10]
        }
    ]
};

var chart_context = document.getElementById('chart_canvas').getContext('2d');

var chart_option = {
    legendTemplate : "<% for (var i=0; i<datasets.length; i++){%><li><span style=\"color:<%=datasets[i].strokeColor%>\">■</span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%>"
};

var chart = new Chart(chart_context).Bar({
    labels: chart_data.labels,
    datasets: chart_data.datasets
}, chart_option);

document.getElementById('chart_legend').innerHTML = chart.generateLegend();

/*]]>*/</script>

Chart.jsでは現在のところ自動での色分け機能は持っていないので、上記コードのように個別に設定しないといけません。

また、有効な色の設定オプションはグラフによって異なりますので、サーバ側で動的に生成したデータを表示する場合、この辺も少し面倒になると思います。

(サンプルの Bar Chart の場合は、fillColorが棒グラフの塗りつぶし色、strokeColorが棒グラフの線の色、highlightFillがマウスオーバー時の塗りつぶし色、highlightStrokeがマウスオーバー時の線の色になっています。)

サーバ側でグラフの値と設定値を生成する例

自分の場合は、色の指定も含めてサーバ側で生成したJSONデータを隠しフォームに出力して、これをJavaScriptから読み込むという単純な方法にしました。

以下は簡単な例ですが、どういう構造の配列を生成すればいいかの参考にしていただければ。(Ajaxは使っていません)

<?php
$createData = function() {
    return [
        '4月' => mt_rand(0, 100),
        '5月' => mt_rand(0, 100),
        '6月' => mt_rand(0, 100),
        '7月' => mt_rand(0, 100),
        '8月' => mt_rand(0, 100),
        '9月' => mt_rand(0, 100),
        '10月' => mt_rand(0, 100),
        '11月' => mt_rand(0, 100),
        '12月' => mt_rand(0, 100),
    ];
};

$hexToRGB = function($hex) {
    return [
        hexdec(substr($hex, 0, 2)),
        hexdec(substr($hex, 2, 2)),
        hexdec(substr($hex, 4, 2)),
    ];
};

$list = [
    [
        'label' => 'りんご',
        'data' => $createData(),
        'rgb' =>  $hexToRGB('ff0000'),
    ],
    [
        'label' => 'バナナ',
        'data' => $createData(),
        'rgb' =>  $hexToRGB('ffff00'),
    ],
    [
        'label' => 'みかん',
        'data' => $createData(),
        'rgb' =>  $hexToRGB('ff8000'),
    ],
];

$chart_data = [
    'labels' => array_keys($list[0]['data']),
    'datasets' => array_reduce($list, function($dataset, $item) {
        $dataset[] = [
            'label' => $item['label'],
            'fillColor' => sprintf('rgba(%s,0.5)', implode(',', $item['rgb'])),
            'strokeColor' => sprintf('rgba(%s,0.75)', implode(',', $item['rgb'])),
            'highlightFill' => sprintf('rgba(%s,0.75)', implode(',', $item['rgb'])),
            'highlightStroke' => sprintf('rgba(%s,1)', implode(',', $item['rgb'])),
            'data' => array_values($item['data']),
        ];
        return $dataset;
    }, [])
];

$chart_json = json_encode($chart_data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>Chart.js</title>
</head>
<body>
<form>
<input type="hidden" id="chart_json" value="<?=htmlspecialchars($chart_json, ENT_QUOTES, 'UTF-8')?>" />
</form>
<canvas id="chart_canvas" width="800" height="400"></canvas>
<ul id="chart_legend"></ul>
<script type="text/javascript" src="/static/js/Chart.js/Chart.js"></script>
<script type="text/javascript">/*<![CDATA[*/

var chart_json = document.getElementById('chart_json').getAttribute('value');

var chart_data = JSON.parse(chart_json);

var chart_context = document.getElementById('chart_canvas').getContext('2d');

var chart_option = {
    legendTemplate : "<% for (var i=0; i<datasets.length; i++){%><li><span style=\"color:<%=datasets[i].strokeColor%>\">■</span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%>"
};

var chart = new Chart(chart_context).Bar({
    labels: chart_data.labels,
    datasets: chart_data.datasets
}, chart_option);

document.getElementById('chart_legend').innerHTML = chart.generateLegend();

/*]]>*/</script>
</body>
</html>

出力結果はこんな感じです。

PHPのWebアプリケーションで動的にグラフ生成といえば昔は GD + JpGraph が定番で、自分もこの業界に入って最初の仕事がそれだったこともあって感慨深いのですが、HTML5やらJavaScriptの隆盛で今ではグラフはクライアント側で描画するのが当たり前になりましたね。それもこんなに簡単にかっこいいグラフが表示できるなんて。

Bowerを使ってみたメモ

まず node.js が必要らしい。

最新バージョンのインストーラ http://nodejs.org/dist/v0.10.33/node-v0.10.33-x86.msi を取得して、全部デフォルトのままインストール。パス環境変数への追加なども勝手にやってくれるみたい。

以下、シェルは NYAOS にて。

$ node --version
v0.10.33

きたきた。

Bower は node.js のパッケージ管理ツール npm でインストールするそうな。

グローバルにインストール。グローバルって何処?よく分からんけどとりあえずユーザーのホームディレクトリから実行してみた。

$ npm install -g bower
C:\Users\k_horii\AppData\Roaming\npm\bower -> C:\Users\k_horii\AppData\Roaming\npm\node_modules\bower\bin\bower
bower@1.3.12 C:\Users\k_horii\AppData\Roaming\npm\node_modules\bower
├── is-root@1.0.0
├── junk@1.0.0
├── stringify-object@1.0.0
├── chmodr@0.1.0
├── abbrev@1.0.5
├── which@1.0.5
├── osenv@0.1.0
├── opn@1.0.0
├── archy@0.0.2
├── bower-endpoint-parser@0.2.2
├── bower-logger@0.2.2
├── graceful-fs@3.0.4
├── lru-cache@2.5.0
├── rimraf@2.2.8
├── lockfile@1.0.0
├── retry@0.6.0
├── nopt@3.0.1
├── tmp@0.0.23
├── q@1.0.1
├── semver@2.3.2
├── p-throttler@0.1.0 (q@0.9.7)
├── request-progress@0.3.0 (throttleit@0.0.2)
├── shell-quote@1.4.2 (array-filter@0.0.1, array-reduce@0.0.0, array-map@0.0.0, jsonify@0.0.0)
├── bower-json@0.4.0 (intersect@0.0.3, graceful-fs@2.0.3, deep-extend@0.2.11)
├── promptly@0.2.0 (read@1.0.5)
├── mkdirp@0.5.0 (minimist@0.0.8)
├── chalk@0.5.0 (ansi-styles@1.1.0, escape-string-regexp@1.0.2, supports-color@0.2.0, has-ansi@0.1.0, strip-ansi@0.3.0)
├── bower-config@0.5.2 (osenv@0.0.3, graceful-fs@2.0.3, optimist@0.6.1)
├── cardinal@0.4.0 (redeyed@0.4.4)
├── bower-registry-client@0.2.1 (lru-cache@2.3.1, async@0.2.10, graceful-fs@2.0.3, request-replay@0.2.0, mkdirp@0.3.5,request@2.27.0)
├── handlebars@2.0.0 (optimist@0.3.7, uglify-js@2.3.6)
├── mout@0.9.1
├── decompress-zip@0.0.8 (mkpath@0.1.0, nopt@2.2.1, touch@0.0.2, binary@0.3.0, readable-stream@1.1.13)
├── request@2.42.0 (json-stringify-safe@5.0.0, caseless@0.6.0, forever-agent@0.5.2, aws-sign2@0.5.0, stringstream@0.0.4, oauth-sign@0.4.0, tunnel-agent@0.4.0, mime-types@1.0.2, node-uuid@1.4.1, qs@1.2.2, tough-cookie@0.12.1, form-data@0.1.4, http-signature@0.10.0, hawk@1.1.1, bl@0.9.3)
├── tar-fs@0.5.2 (pump@0.3.5, tar-stream@0.4.7)
├── glob@4.0.6 (inherits@2.0.1, once@1.3.1, minimatch@1.0.0)
├── fstream-ignore@1.0.1 (minimatch@1.0.0, inherits@2.0.1)
├── fstream@1.0.2 (inherits@2.0.1)
├── insight@0.4.3 (object-assign@1.0.0, async@0.9.0, tough-cookie@0.12.1, os-name@1.0.1, chalk@0.5.1, lodash.debounce@2.4.1, configstore@0.3.1, inquirer@0.6.0)
├── update-notifier@0.2.0 (semver-diff@0.1.0, string-length@0.1.2, configstore@0.3.1, latest-version@0.2.0)
└── inquirer@0.7.1 (figures@1.3.3, mute-stream@0.0.4, through@2.3.6, lodash@2.4.1, readline2@0.1.0, rx@2.3.14, cli-color@0.3.2)

なんだこれ…やたら一杯入ってびびるわ…。

$ bower -v
1.3.12

なんか反応が遅いけど、とりあえずプロジェクトのホームに移動して使ってみる。

$ bower init
[?] May bower anonymously report usage statistics to improve the tool over time?

そのまま

? name: (chrony)

現在のディレクトリ名が勝手に入るっぽい。そのまま

? version: (0.0.0)

プロジェクトのバージョン?そのまま

? description:

そのまま

? main file:

そのまま

? what types of modules does this package expose? (Press <space> to select)
( ) amd
( ) es6
( ) globals
( ) node
( ) yui

何言ってるか分からんがそのまま

? keywords:

そのまま

? authors: (k-holy <k.holy74@gmail.com>)

gitの設定から勝手に取ってくるっぽい?そのまま

license: (MIT)

そのまま

? homepage:

そのまま

? set currently installed components as dependencies?: (Y/n)

はい

? add commonly ignored files to ignore list?: (Y/n)

はい

? would you like to mark this package as private which prevents it from being accidentally published to the registry? (y/N)

はい

{
  name: 'chrony',
  version: '0.0.0',
  authors: [
    'k-holy <k.holy74@gmail.com>'
  ],
  license: 'MIT',
  private: true,
  ignore: [
    '**/.*',
    'node_modules',
    'bower_components',
    'test',
    'tests'
  ]
}
? Looks good? (Y/n)

はい

カレントディレクトリに bower.json が作成された。

jQueryを入れてみる。

$ bower install jquery --save

bower_components 以下に jquery が入った。

bower.jsonは以下の通り。

{
  "name": "chrony",
  "version": "0.0.0",
  "authors": [
    "k-holy <k.holy74@gmail.com>"
  ],
  "license": "MIT",
  "private": true,
  "ignore": [
    "**/.*",
    "node_modules",
    "bower_components",
    "test",
    "tests"
  ],
  "dependencies": {
    "jquery": "~2.1.1"
  }
}

で、これどうやってドキュメントルート以下から参照するの…?

.bowerrc 設定ファイルに定義すればインストール先を変更できるらしいので、変えてみる。

一旦 bower_components は削除。

以下の内容で .bowerrc ファイルを作成して…

{
    "directory": "public/bower_components"
}

再インストール。すでに bower.json に依存は定義されているのでこれでOKかな?

$ bower install
bower jquery#~2.1.1             cached git://github.com/jquery/jquery.git#2.1.1
bower jquery#~2.1.1           validate 2.1.1 against git://github.com/jquery/jquery.git#~2.1.1
bower jquery#~2.1.1            install jquery#2.1.1

jquery#2.1.1 public\bower_components\jquery

OK。次は Bootstrap を入れる。

$ bower install bootstrap --save
bower bootstrap#*           not-cached git://github.com/twbs/bootstrap.git#*
bower bootstrap#*              resolve git://github.com/twbs/bootstrap.git#*
bower bootstrap#*             download https://github.com/twbs/bootstrap/archive/v3.3.0.tar.gz
bower bootstrap#*              extract archive.tar.gz
bower bootstrap#*             resolved git://github.com/twbs/bootstrap.git#3.3.0
bower bootstrap#~3.3.0         install bootstrap#3.3.0

bootstrap#3.3.0 public\bower_components\bootstrap
└── jquery#2.1.1

テンプレートからの呼び出しはこんな感じに修正する。

<script src="/bower_components/jquery/dist/jquery.min.js"></script>
<script src="/bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css" />

JavaScriptフレームワークとか全然分からん、クソみたいな自作jQueryプラグインで何とかやってる、そんなレベルの自分でも大丈夫でした。

後は、サーバ側にも同じように node.js を入れて bower コマンドを入れて、デプロイ時には bower install すると楽できるというわけですか。

node.js なんてまるで興味ない&特に必要もされていないLAMPおじさんな自分が、そのためだけに node.js を入れるというのは微妙な気がするけども…。

参考にした記事