ASP.NET Core Blazor と Chart.js で入門するWebアプリ作成 - Tips編:BlazorやChart.jsのあれこれ

609
NO IMAGE

「ASP.NET Core Blazor と Chart.js で入門するWebアプリ作成」の4回目。各回記事の内容は以下のようになります。

導入編:サンプルを動かす
探究編:Blazorの仕組みを理解する
実践編:Chart.jsでグラフを描く
Tips編:BlazorやChart.jsのあれこれ(当記事)
番外編:Ubuntuサーバで公開する

今回の内容は、第1回「導入編」の「作業の流れ」で提示した項目のうち 6と7を含む「8. Blazor のもう少し細かい話や Chart.jsのTips的なもの」となります。

「導入編」で紹介した個人用サイトで公開しているアプリを作る過程で悩んだ点なども含め、思いつくままに挙げていくことにします。「Chart.js Tips」で扱っているのは bar と line だけですが、これから Chart.js を始めようとしている方には(Blazor を使わない場合でも)お役に立てる内容になっているのではないかと思います。

Blazor のもう少し細かい話

サーバ側からJavaScript関数を呼び出す

Microsoftのドキュメントは「ASP.NET Core Blazor で .NET メソッドから JavaScript 関数を呼び出す」です。この機能は「JavaScript 相互運用」(JS 相互運用)と呼ばれています。

簡単に説明すると、システムが用意している IJSRuntime 型のオブジェクトが持つ InvokeAsync() メソッドを呼び出す、ということになります。以下は、前回にも登場したコードです。
image.png
サーバ側としては、ブラウザ側の JavaScript 関数を呼び出した後、そこから制御が戻ってくるまで待つわけにはいかないので、 async なメソッド InvokeAsync() を await 付きで呼び出すわけです(19行目)。await 処理を呼び出した時点でこのスレッドはRenderNewChart()メソッドを抜けて呼び出し側に制御が返ります。await 処理の続きについては、別のスレッドが生成され、そちらが担当します[^async_await]。

[^async_await]: 非同期処理を行うための仕掛けである Task/async/await の詳細については Qiita にもたくさんの解説があるので、そちらを参照ください。

RenderNewChart()メソッドで使われている変数 JSRuntime にバインドされるオブジェクトは、13行目の @inject IJSRuntime JSRuntime によって当コンポーネントに注入されてきます。IJSRuntime を実装したシングルトンインスタンスがあらかじめシステム側で用意されており、@inject IJSRuntime VAR_NAME という記述があると、VAR_NAME という変数にそのインスタンスがバインドされるわけです。

InvokeAsync<T>()T は、呼び出した JavaScript関数からの戻値の型を指定します。後述の LocalStorage を扱う節でその例を示します。

InvokeAsync<T>() の第1引数は呼び出すJavaScript関数名です。第2引数以降は配列化されて params object[] として扱われます。型チェックができないので、呼び出されるJavaScript関数が期待する引数の型に合わせるように注意する必要があります。

LocalStorage/SessionStorageの取得・設定

サーバ側からJavaScript関数を呼び出す例として、ブラウザが管理している LocalStorage や SessionStorage[^browser_storage] へのアクセスを実装してみます。どちらも同じようなやり方になるので、ここでは LocalStorage のほうを扱うことにします。LocalStorage を使うと、サイトにアクセスしたユーザのPCやスマホに、その時のアプリの状態などを保存することができるようになります。

[^browser_storage]: LocalStorage/SessionStorage はブラウザ側で管理される辞書オブジェクトです。LocalStorage は永続化されており、ブラウザを閉じても情報が残ります。SessionStorage は現在実行中のセッションでのみ有効な情報となります。これらの情報は、 JavaScript から localStorage / sessionStorage オブジェクトによりアクセスできます。

まず JavaScript 側の関数です。_Host.cshtml に実装します。

// _Host.cshtml
    <script>
        // LocalStorage からキーに対する値を取得
        function getLocalStorage(key) {
            return localStorage.getItem(key);
        }

        // LocalStorage にキーと値を保存
        function setLocalStorage(key, value) {
            localStorage.setItem(key, value);
        }
    </script>

下記はサーバ側のメソッドです。Chart.razor@code ブロックに実装します。もちろん、Chart.razor の分離コードファイル Cahrt.razor.cs を作成してそこに実装しても構いません。

//Chart.razor
    /// <summary>
    /// ブラウザの LocalStorage から値を取得
    /// </summary>
    /// <param name="key"></param>
    /// <returns></returns>
    private async ValueTask<string> getLocalStorage(string key)
    {
        return await JSRuntime.InvokeAsync<string>("getLocalStorage", key);
    }

    /// <summary>
    /// ブラウザの LocalStorage に値を保存
    /// </summary>
    /// <param name="key"></param>
    private async Task setLocalStorage(string key, string value)
    {
        await JSRuntime.InvokeAsync<string>("setLocalStorage", key, value);
    }

JavaScript 関数 getLocalStorage() の呼び出しでは文字列が返ってくるので、サーバ側の getLocalStorage()メソッドも ValueTask<string>[^valuetask] を返すようになっています。

[^valuetask]: 最近の C# では値を返す Task はこのように ValueTask を用いるようです。

value には個々の string や bool の値を入れてもよいのですが、複数の値を使用している場合は、それらを JSON の辞書形式でまとめて一つの値として扱うと、サーバ側からの呼び出しが1回で済むので効率的です。以下は bool isAnimation, string chartType を1つのクラスにまとめて扱うコードです^settings_class。こちらも Chart.razor に実装します。

//Chart.razor
    public class Settings
    {
        public bool isAnimation { get; set; }
        public string chartType { get; set; }
    }

    private const string SettingsKey = "some-unique-string-like-fully.qualified.domain.name-of-your-site";

    private async ValueTask<Settings> getSettings()
    {
        try {
            // SettingsKey にはサイトのFQDNなど、ユニークな文字列を設定しておく
            return (await getLocalStorage(SettingsKey))._jsonDeserialize<Settings>();
        } catch {
            // まだ SettingsKey に対する値が保存されていないと、getLocalStorage() は null を返してくるので、初期値を生成して返す
            return new Settings { isAnimation = true, chartType = "bar" };
        }
    }

    private async Task saveSettings(Settings settings)
    {
        await setLocalStorage(SettingsKey, settings._jsonSerialize());
    }

Settings インスタンスと JSON 文字列との変換に、ChartJson.cs の Extension で定義した拡張メソッド、_jsonDeserialize()_jsonSerialize() を利用しています。また、SettingsKey には適当なユニーク文字列を設定しておきます。

getSettings()saveSettings() を使う例については、この後「disabled や checked などの論理属性のレンダリング」のところで取り上げます。

JavaScriptからサーバ側のメソッドを呼び出す

Microsoft のドキュメントは「ASP.NET Core Blazor で JavaScript 関数から .NET メソッドを呼び出す」です。基本的にはこのドキュメントを読めば事足りると思うので、以下では、サーバ側のインスタンスメソッドを呼び出す場合について、前回記事で作成したアプリの修正を通じて簡単に説明しますが、キモとなるのは次の3点です。

  • ヘルパーオブジェクトを作成する
  • 同オブジェクトを使って JavaScript関数から invokeMethodAsync() を呼び出す
  • Dispose() を実装してリソースリークを防ぐ

JavaScript から呼び出すメソッドの追加

今回呼び出されるサーバ側インスタンスは WeatherForecastService クラスのシングルトンインスタンスとします[^invokeMethodAsync-instance]。これは DI によって Chart コンポーネントに注入されてきます。このクラスに JavaScript側から呼び出すメソッドを追加します。

[^invokeMethodAsync-instance]: もちろん新しいクラスを定義してそのインスタンスメソッドを呼び出すようにしてもよいのですが、説明を簡単にするために、ここでは WeatherForecastService を使い回すことにします。

まず、次の using を追加し、

//WeatherForecastService.cs/using
using Microsoft.JSInterop;

そして、次のメソッドを追加します。

//WeatherForecastService.cs/GetForecast2
        [JSInvokable]
        public string GetForecast2()
        {
            return GetForecastAsync(DateTime.Now).Result._toString();
        }

修正後のイメージは次のようになります。
image.png
11行目 [JSInvokable] 属性を付加すると JavaScript から呼び出せるようになります[^jsinvokable]。この属性を利用するには 5行目の using Microsoft.JSInterop; が必要です。14行目、既に作ってあった GetForecastAsync() を呼び出してChart用データを作成し、それを JSON 文字列に変換して返しています。

[^jsinvokable]: .razor ファイルに実装したメソッドは何もしなくてもブラウザ側のボタンクリックなどで呼び出せるので、たぶん、裏で秘かに [JSInvokable] が付加されているのではないかと想像しています。

呼び出し側の JavaScript 関数

_Host.cshtml に次の JavaScript関数を追加します。

// _Host.cshtml
        function renderChart2(dotnetHelper) {
            if (window.chartObj) {
                console.log('destroy chartObj');
                window.chartObj.destroy();
            }
            dotnetHelper.invokeMethodAsync('GetForecast2').then((json) => {
                console.log("json="+json);
                var ctx = document.getElementById('myChart');
                window.chartObj = new Chart(ctx, JSON.parse(json));
            });
            dotnetHelper.dispose();
        };

次のようなイメージです。
image.png
53行目、後述する dotnetHelperInvokeMethodAsync() を使って、先ほど定義した GetForecast2 を呼び出しています。InvokeMethodAsync()の戻り値は Promise となるので、 then により、結果が得られたときに呼び出すラムダ関数^lambdaを与えています。そのラムダ関数の中で Chart を呼び出してグラフを描画しています。

ヘルパーオブジェクトとJavaScript関数の呼び出し

JavaScript関数からサーバ側のインスタンスメソッドを呼び出すには、そのインスタンスに関連付けられたヘルパーオブジェクトが必要になります。以下では、Chart.razor を修正して、ヘルパーオブジェクトの用意と上で定義したJavaScript関数 renderChart2() の呼び出しを行います。まず @implements IDisposable を追加し、さらに @codeブロックに以下のコードを追加してください。

//Chart.razor
    private DotNetObjectReference<WeatherForecastService> objRef;

    public async Task RenderNewChart2()
    {
        objRef = DotNetObjectReference.Create(ForecastService);
        await JSRuntime.InvokeAsync<string>("renderChart2", objRef);
    }

    public void Dispose()
    {
        objRef?.Dispose();
    }

そして、@onclick時の呼び出しメソッドを、上で追加した RenderNewChart2 に変更します。以下のようなイメージです。
image.png
16行目 private DotNetObjectReference<WeatherForecastService> objRef; で、呼び出し対象クラスに関連付けられたヘルパーオブジェクトのラッパーを定義しています。そして、20行目 DotNetObjectReference.Create(ForecastService) によってヘルパーオブジェクトが作成され、そのラッパーが返されます。リソースリークを避けるために、Chart クラスに IDisposable インターフェースを追加(14行目 @implements IDisposable)して、Dispose() メソッドで objRef を破棄しています(26行目)。

ここで F5 を押して実行してみてください。前回と同様の動きをすればOKです。

render-mode

_Host.cshtml<body> のところに以下のような記述があります。
image.png
この render-mode は、Microsoftのドキュメント「表示モード」で説明されています。が、説明が足りなくてはっきり言って分かりにくいです。もし英語でよければ「Building a Software Survey using Blazor - Part 4 - Render Mode」がかなりよく説明してくれています。ServerPrerendered の説明をちょっと引用します。

What you are effectively getting it a two stage render to the browser:

  1. Cosmetic render of the component - all the HTML - but none of the functionality
  2. Functional render of the components - replaces the cosmetic only HTML with all the intended functionality

つまり、最初に機能が何もないただのHTMLとしてレンダリングを行い、その後に必要な機能を追加するために再度レンダリングする、ということのようです。全機能を最初から提供しようとすると時間がかかってしまうので、まずは画面の見てくれだけをレンダリングしておく、ということですね。Mircrosoftのドキュメントでは、この最初のレンダリングを「プリレンダリング」と呼んでいます。なお本稿では、2回目のレンダリングを「本レンダリング」と呼ぶことがあります。

OnInitialized が2回呼ばれる件

ServerPrerendered の場合、上記の「レンダリングが2回実行される」という動作により、コンポーネントの初期化メソッドである OnInitialized{,Async}[^oninitialized_or_async] が2回呼ばれることになります。実行順序は次のような感じになります[^lifecycle]。

[^oninitialized_or_async]: OnInitialized または OnInitializedAsync という意味です。 Shellの変数展開風に書いてます。Git Bash をお使いであれば echo OnInitialized{,Async} と実行してみてください。

[^lifecycle]: このあたりの詳細は、「ASP.NET Core Blazor ライフサイクル」を参照してください。

1回目の OnInitialized{,Async}() ⇒ プリレンダリング
 ⇒ 2回目の OnInitialized{,Async}() ⇒ 本レンダリング
 ⇒ OnAfterRender{,Async}()

「2回呼ばれる」と書きましたが、正確には「コンポーネントが2回生成されて、都度、OnInitialized{,Async}が呼ばれる」です。つまり、1回目と2回目では、インスタンスが異なります。なので、「1回目だけ、この初期化処理をやろう」と思って次のようなコードを書いてもワークしません。2回目は別インスタンスなので、isFirst にはやっぱり true が設定されています。

//Chart.razor
    private bool isFirst = true;
    protected override async Task OnInitializedAsync()
    {
        if (isFirst) {
            callSomeInitializingMethod();
            isFirst = false;
        }
    }

1回目と2回目で処理を分けるようなコードについては、うまい解決法が分かったら追記したいと思います[^supplement_1]。

[^supplement_1]: 一応「プリレンダリング後のステートフル再接続」に解決法らしきものが記述されています。が、MemoryCache を使うのはよいとして、Identifier となるべき引数の startDate は呼び出しのたびに DateTime.Now で生成しているので、1回目と2回目では別の値になっているはず。なので、毎回「1回目」として認識されてしまう気がするのは筆者だけでしょうか。。。

なお、render-modeServer にすると、レンダリングは1回だけになります(したがって、OnInitialized{,Async} も1回だけ呼ばれる)が、そのかわり、ブラウザとの間で SignalR の接続が完了し、Blazor が準備完了状態になるまではページに何も表示されません[^server_mode_1]。この空白時間が長くなるようだとユーザ体験あるいはSEO的にあまりよろしくないということになるかもしれませんね。

[^server_mode_1]: 本文でも触れた英語サイトから引用します。>Server will operate the same as ServerPrerendered but without the static HTML version on the first page request. Rather it leaves the app content empty until the SignalR connection is established and Blazor is running fully.

JavaScript 相互運用との関係

ASP.NET Core Blazor で .NET メソッドから JavaScript 関数を呼び出す」には次のような記述があります。

Blazor Server アプリでプリレンダリングが有効になっている場合、最初のプリレンダリング中に JavaScript を呼び出すことはできません。 JavaScript 相互運用呼び出しは、ブラウザーとの接続が確立されるまで遅延させる必要があります。 詳細については、「Blazor Server アプリがプリレンダリングされていることを検出する」セクションを参照してください。

render-mode="ServerPrerendered" の場合、「最初のプリレンダリング中」、つまり1回目の OnInitialized{,Async} の時は JavaScript 関数を呼び出すことができないということです。これは次節のテーマにかかわってきます

ページを開いたときにグラフを描画する

TL;DR: OnAfterRenderAsync() を実装し、そこで描画する。

上述のように render-mode="ServerPrerendered" に設定している場合、1回目の OnInitialized{,Async} が呼ばれた時点ではまだ JavaScript関数を呼び出すことができません。本稿で作成したアプリは、「Click me」ボタンをクリックされたらグラフを表示するというものなので問題はなかったのですが、これをページアクセスされたら最初からグラフを表示するように変更するにはどうしたらよいでしょうか。

まずは試しに OnInitializedAsync() が呼ばれたところでグラフ描画メソッドを呼んでみます。Chart.razor に以下のようなコードを追加してみてください。

// Chart.razor
    protected override async Task OnInitializedAsync()
    {
        await RenderNewChart();
    }

image.png
実行してみると、ブラウザに以下のようなメッセージが表示されました。

InvalidOperationException: JavaScript interop calls cannot be issued at this time. This is because the component is being statically rendererd. When prerendering is enabled, JavaScript interop calls can only be performed during the OnAfterRenderAsync lifecycle method.

prerenderingが有効になっていると、JavaScript相互運用は OnAfterRenderAsync の時しか実行できないよ」と言っているようです。ということで、言われた通りにコードを変更してみます^after_rendering

// Chart.razor/OnAfterRenderAsync版
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender) {
            await RenderNewChart();
        }
    }

image.png
実行します。
image.png
今度はうまく行きました。しかも最初からグラフが表示されています。

一応、これで目的は達せられたのですが、実は2回目の OnInitializedAsync() であれば JavaScript 関数を呼び出せます[^js-interop-confirmed]。なので、「OnInitialized が2回呼ばれる件」でも書きましたが、やはり1回目と2回目の OnInitializedAsync() 呼び出しを区別したいところです。この件は「ページを開いたときにラジオボタンの状態を更新する」にもかかわってきます。

[^js-interop-confirmed]: ちょっとインチキなのですが、シングルトンインスタンスである ForecastService に public bool IsFirst {get;set;} = true; というフラグを設けて、1回目と2回目の呼び出しを区別できるようにして確認しました。

なお、render-mode="Server" の場合は、OnInitializedAsync() で JavaScript相互運用コードを実行しても問題ないようです。

シングルトンインスタンスと DI

第2回の記事で、Startup.csservice.AddSingleton<CLASSNAME>() を呼ぶと CLASSNAME のインスタンスがシングルトンとして生成される、と書きました。このシングルトンインスタンスは、「サービス全体にわたるシングルトン」になります^excuse_2

MARKDOWN_HASHd955e2212a9bf644923eca824e399709MARKDOWNHASH で生成したシングルトンインスタンスは、.razor ファイルまたはその分離コードである .razor.cs_ で次のような記述をすると、コンポーネントに「注入」されてきます。いわゆる「依存性の注入」(Dependency Injection)です。

// Chart.razor
@inject CLASSNAME _obj
// Chart.razor.cs
using Microsoft.AspNetCore.Components;
using MyBlazorApp.Data;
namespace MyBlazorApp.Shared
{
    public partial class Chart
    {
        [Inject]
        private CLASSNAME _obj { get; set; }
    }
}

分離コードにおいては using Microsoft.AspNetCore.Components; が必須です。そしてプロパティの前に[Inject] という属性を付加します。第2回記事の「FetchData.razor」や第3回記事の「Chart.razor の修正」も参照ください。

コンポーネント使用時に引数を渡す

タグの形でコンポーネントを利用するとき、[Parameter]属性の付加されたプロパティには、タグにおけるプロパティ名="値"の形で値を渡すことができます。下記は第2回記事の SurveyPrompt コンポーネントの例です。

//SurveyPrompt.razor
@code {
    // Demonstrates how a parent component can supply parameters
    [Parameter]
    public string Title { get; set; }
}

この Titleプロパティに対して Indexページから次のような形で文字列を渡しています。

<!-- Index.razor -->

<SurveyPrompt Title="How is Blazor working for you?" />

@onchange 時にメソッドに渡される引数

<input>要素に @onchange="METHOD_NAME" を設定しておくと、値が変わったときに METHOD_NAME を呼び出すことができます。呼び出されるメソッドには、ChangeEventArgs 型のオブジェクトが引数として渡されます。そこから値を取り出すのは args.Value.ToString() となります。

Shared/Chart.razor を修正して確認してみます。次のコードを追加してください。

<!-- Chart.razor/html -->
<div>
    <label for="radio-bar" ><input type="radio" name="chart-type" id="radio-bar"  value="bar"  @onchange="ChangeChartType" checked />棒グラフ</label>
    <label for="radio-line"><input type="radio" name="chart-type" id="radio-line" value="line" @onchange="ChangeChartType" />曲線グラフ</label>
</div>
// Chart.razor/@code
    private string chartType = "bar";

    private async Task ChangeChartType(ChangeEventArgs args)
    {
        chartType = args.Value.ToString();
        await RenderNewChart();
    }

ラジオボタン要素の value="bar"value="line" の値を private フィールドの chartType にセットしてからグラフ描画を実行するというコードになっています。修正部分のイメージは下図のようになります。
image.png
そして、 Data/WeatherForecastService.csGetForecastAsync() メソッド全体を次のコードで上書きします。

// Data/WeatherForecastService.cs
        public Task<ChartJson> GetForecastAsync(DateTime startDate, string chartType = "bar")
        {
            var rng = new Random();
            return Task.Run(() => {
                var forecast = getForecast(startDate);
                return new ChartJson {
                    type = chartType,
                    data = new Data {
                        labels = forecast.Select(x => x.Date.ToString("MM月dd日")).ToArray(),
                        datasets = chartType == "bar"
                            ? new Dataset[] {
                                Dataset.CreateBar("摂氏", forecast.Select(x => (double?)x.TemperatureC).ToArray() , "royalblue"),
                                Dataset.CreateBar("華氏", forecast.Select(x => (double?)x.TemperatureF).ToArray(), "darkblue"),}
                            : new Dataset[] {
                                Dataset.CreateLine("摂氏", forecast.Select(x => (double?)x.TemperatureC).ToArray() , "darkorange", "yellow"),
                                Dataset.CreateLine("華氏", forecast.Select(x => (double?)x.TemperatureF).ToArray(), "darkblue", "lightblue"),},
                    },
                    options = Options.Plain(),
                };
            });
        }

GetForecastAsync() の引数に string chartType を追加し、その値によって描画するグラフ種別を変更しています。修正部分のイメージは下図のようになります。
image.png
Chart.razor の呼び出し部分も修正します。再び RenderNewChart() を使います。
image.png
実行してみます。下図のようにラジオボタンが表示されます。
image.png
「曲線グラフ」をクリックします。
image.png
このように曲線グラフで表示されればOKです。

disabled や checked などの論理属性のレンダリング

HTMLの要素は、disabled 属性があると無効状態になります。またラジオボタンやチェックボックスでは checked 属性があるとその要素が選択状態になります。Blazor でこのような論理属性の付加や除去を制御するには次のような書き方をします。

    @code {
        private bool _isDisabled { get; set; } = false;
        private bool _isChecked { get; set; } = true;
    }
    <input ・・・ disabled="@_isDisabled" />
    <input ・・・ checked="@_isChecked" />

bool値が true の場合は、論理属性名のみが残ります。false の場合は論理属性自体が削除されます[^bool_attr]。つまり、上記のケースでは、以下のようにレンダリングされます。

[^bool_attr]: 実際には、論理属性に限らず、値を必要とする属性であっても同様の振る舞いになります。また値が null だった場合も属性は削除されます。この書き方は、NavMenu.razorでも登場しました。

    <input ・・・ />
    <input ・・・ checked />

ここでは disabled="@_isDisabled" のように bool型の変数を直接参照しましたが、 disabled="@(result != "YES")" のような形で bool値を返す式を書くこともできます。

ページを開いたときにラジオボタンの状態を更新する

TL;DR: OnAfterRenderAsync() でやる場合は StateHasChanged() を呼び出す。

余力のある方は、LocalStorage に選択されたグラフ種別を保存するようにして、ページが開かれたときにその値に従ってラジオボタンの選択を変えるような修正をしてみてください^materials_ready

たとえばラジオボタンについては下記のような記述に変更して、chartType フィールドの値によって選択されるボタンが変わるようにします。

<!-- Chart.razor -->
<div>
    <label for="radio-bar" ><input type="radio" name="chart-type" id="radio-bar"  value="bar"  @onchange="ChangeChartType" checked="@(chartType != "line")" />棒グラフ</label>
    <label for="radio-line"><input type="radio" name="chart-type" id="radio-line" value="line" @onchange="ChangeChartType" checked="@(chartType == "line")" />曲線グラフ</label>
</div>

ChangeChartType() が呼ばれたら chartType フィールドの値を変更するとともに LocalStorage にも保存するようにします。そして、ページが最初に開かれたときには LocalStorage から値を取得して chartType にセットするようにします。

// Chart.razor
@code{
    private async Task ChangeChartType(ChangeEventArgs args)
    {
        chartType = args.Value.ToString();
        await RenderNewChart();
        await saveSettings(new Settings {chartType = chartType});
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender) {
            var settings = await getSettings();
            chartType = settings.chartType;
            StateHasChanged();
            await RenderNewChart();
        }
    }

ここで注意すべき点は、OnAfterRenderAsync() が呼ばれるのはページの本レンダリングが終わってからなので、そこでラジオボタンの選択フラグを変更してもすぐにはブラウザの表示に反映されません。すぐに反映させたい場合は、そのことをシステム側に通知する必要があります。下記イメージでは、35行目 StateHasChange() を呼び出すことで、再レンダリングが必要であることをシステム側に通知しています[^StateHasChange]。
image.png
試しに35行目 StateHasChanged(); をコメントアウトして実行してみてください。ページをリロードすると、直前のボタンの選択状態にかかわらず、ラジオボタンの状態が必ず「棒グラフ」になってしまうことが分かると思います[^bar_reason]。

[^StateHasChange]: ページの再レンダリングと36行目の RenderNewChart() 呼び出しは無関係です。念のため。
[^bar_reason]: ページをリロードすると Chart コンポーネントのインスタンスが新しく作成されて、 chartType が "bar" で初期化されるためです。

上述の Chart.razor では、ラジオボタンの状態(checked)を Blazor に管理させています。でも、本節のようにレンダリング中でないタイミングでサーバ側で状態を変えた場合は、それが直ちにブラウザ側に反映されるわけではない、ということです。仮にラジオボタンの状態表示を変更するような JavaScript関数を作ってそれをJS相互運用で呼び出せば画面の表示状態は変更できます。しかし、Blazor の管理から外れた操作となるので、やらないほうがよいでしょう。

逆に、グラフの描画は Blazor とは無関係なのでJS相互運用によって自前で制御する必要があるわけです。本レンダリングが終了したときに呼ばれる OnAfterRenderAsync() のところで描画する、というのはタイミングとしても適切であると思います。

なお、_Host.cshtmrender-mode="Server" に変更すると、OnInitializedAsync() でも JavaScript 相互運用コードを実行できます(あるいは render-mode="ServerPrerenderd" でも2回目の OnInitializedAsync() ならJS相互運用可能)。この時点ではまだレンダリングが終わってないので、ここで getSettings() を呼んでラジオボタン選択フラグを変更しておけば、その後の本レンダリングでラジオボタンの選択状態が反映されます。
image.png

Chart.js Tips

ここからは Chart.js を利用する上での手順や Tips などを挙げていきます。

慣れてきたら公式サイトのドキュメントを読むのが結局は一番早いでしょう。

当記事の末尾に Chart.js をローカルPCでテストするためのHTML^chart_exsampleを置いておきますので、それを使いながら見てもらうと理解が早まるかと思います。

Chart() 関数の呼び出し

以下はページを開いたときにグラフを描く例です。記事末尾の HTML も参照ください。

  • Chart.js を参照
  • canvas を用意
  • グラフデータを格納するJSONを用意
  • canvas とグラフデータを引数にして Chart() を呼び出す
<!-- Chart()呼び出し -->
<body>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.js"></script>

  <div class="chart-container">
      <canvas id="myChart"></canvas>
  </div>

  <script>
      var chartData = { /*JSONデータ*/ }
      var chart = null;

      function render() {
        var ctx = document.getElementById("myChart").getContext("2d");
        chart = new Chart(ctx, chartData);
      };

      window.onload = render;
  </script>
</body>

canvas サイズの変更については「Responsive Charts」を参照ください。

new Chart() で生成された Chart オブジェクトを使って、次のようにデータを変更して再描画することができます。

    chart.data.datasets[0].data[0] += 1;
    chart.update();

Chart オブジェクトが不要になった場合は、 chart.destroy() で明示的にオブジェクトを破棄することができます。

グラフの再描画

同じ canvas を使って何度も new Chart() を実行すると、その度に Chart オブジェクトが生成されてグラフが重ね描きされます。そのような場合、グラフ上でマウスホバーさせたときにツールチップの表示がちらちらと変化したりします。記事末尾のテスト用HTMLを使って「New」を何度かクリックしてグラフを重ね描きしてみてください。

新しいChartを作る際にグラフが重ね描きされるのを防止するには、以前のものを destroy() する必要があります。これについては、前回記事の「Chart() を呼び出す関数を用意する」を参照ください。

毎回新しく Chart() を呼ばずに、最初に作成した Chart オブジェクトを update() して使い回すことでも重ね描きを防止することができます。テスト用HTML で「Update」ボタンをクリックしたときに呼ばれる renderUpdate() がそのような処理を行っています。update の詳細については「Updating Charts」を参照ください。

JSON全体構造

とりあえず、筆者が使ったことのある値[^excuse_4]を、JSON構造も反映させて一覧にしてみました。これを見れば、何かをやりたいときにおおよそどの値を設定すればよいかが分かるかと思います[^js_type]。

[^excuse_4]: bar と line しか使用経験がありません。

[^js_type]: 型については正確ではないかもしれません。DOUBLE は JavaScript では number とされている型です。正確なところは公式サイトのドキュメントで確認してください。

{
  type: STRING,              // 既定のグラフ種別 (必須; 'bar', 'line', etc.)
  data:{
    labels: STRING[]         // X軸のラベル列
    datasets: [{             // 1つ目のグラフのデータセット
        label: STRING,       // 当データセットのラベル(凡例に表示される)
        type: STRING,        // 当データセットのグラフ種別 ('bar', 'line', etc.)(省略時は既定となる)
        data: DOUBLE[],      // 当データセットのグラフデータ
        backgroundColor: COLOR or COLOR[],  // bar や line の塗りつぶしの色
        borderColor: COLOR or COLOR[],      // bar や line の外枠の色
        borderWidth: DOUBLE,                // bar や line の外枠の線幅 (default: bar=0, line=3)
        borderDash: DOUBLE[],               // 破線を描く場合の線と間隙の長さ([線長, 間隙長])
        order: DOUBLE,       // 重なり順 (値の小さいほうが前面; default: 0)
        fill: BOOLEAN,       // 背景色による塗りつぶしを行うか (default: true)
        lineTension: DOUBLE, // line の場合の各値を結ぶ線の Bezier curve tension を指定する(default: 0.4; 0 だと直線)
        pointRadius: DOUBLE, // line の場合のポイント円の半径 (default: 3)
        xAxisID,             // 複数のX軸を使用する場合、スケールを合わせる軸のIDを指定(省略すると options.scales.xAxes[0] が使われる)
        yAxisID,             // 複数のY軸を使用する場合、スケールを合わせる軸のIDを指定(省略すると options.scales.yAxes[0] が使われる)
        ...
      },{                    // 2つ目以降のグラフのデータセット
        ...
      }
    ]
  },
  options: {               // 描画オプション
    animation: {
      duration: DOUBLE     // アニメーションの時間(ミリ秒単位; default=1000; 0 にするとアニメーションをやらない)
    },
    scales: {
      xAxes: [{            // 1つ目のX軸の設定
        id: STRING,              // 当軸設定のID(data.datasets[].xAxisID で参照する)
        position: STRING,        // 'top'(上), 'bottom'(下; default)
        ticks: { }               // Y軸と同様
      },{                  // 2つ目以降のX軸の設定(3つ以上のX軸も設定可)
      }],
      yAxes: [{            // 1つ目のY軸の設定
        id: STRING,              // 当軸設定のID(data.datasets[].yAxisID で参照する)
        position: STRING,        // 'left'(左; default), 'right'(右)
        ticks: {
          beginAtZero: BOOLEAN,  // Y軸最小値を0にするか(true)、自動的に決定するか(false; default)
          min: DOUBLE,           // Y軸の最小値(指定しなければ、実際のデータから自動推定)
          max: DOUBLE,           // Y軸の最大値(指定しなければ、実際のデータから自動推定)
          stepSize: DOUBLE       // Y軸の補助線のステップ値(指定しなければ、実際のデータから自動推定)
        }
      },{                  // 2つ目以降のY軸の設定(3つ以上のY軸も設定可)
        ...
      }]
    }
  }
}

ルート要素は、type, data, options の3つです。前回記事の「ChartJson クラスの用意」のところに、この構造を反映した C# のクラス構成を載せてあります。足りない属性があれば必要に応じてクラスのプロパティとして追加してみてください。

描画するグラフの既定種別を設定する

ルート要素の type に種別名('bar' や 'line' など^chart-type)を設定します。これは必須要素のようです。ちなみに、これを 'line'(曲線、折れ線)に設定し、後述の data.datasets[].type に 'bar'(棒グラフ)を指定することもできます。が、この場合、縦棒の描画位置がずれたりします。

X軸のラベルと表示範囲の設定

下図で黄色の枠線で囲んであるやつですね。
image.png
data.datasets[].labes に文字列配列として設定します。設定例は「Creating a Chart」を参照してください。

options.scales.xAxes により、X軸ラベルの表示範囲を設定することができます。たとえば、Blue ~ Purple の範囲だけを表示する場合は次のようにします。

// options.scales.xAxes
    options: {
        scales: {
            xAxes: [{
                position: 'right',
                ticks: {
                    min: 'Blue',
                    max: 'Purple'
                }
            }]
        }
    }

image.png
これと Chart APIupdate() を組み合わせれば、サーバに頼らずとも JavaScript の中だけでX軸の表示範囲を動的に変更するような仕掛けも作れるかと思います。

色の設定

data.datasets[].backgroundColor で bar や line の背景色(塗りつぶし色)を設定し、data.datasets[].borderColor で枠線の色を設定します。色の記述方法は、「色名」「16進」「RGB」「HSL」となります^color_spec

記述方法 設定例 設定される色
色名 'darkblue' ダークブルー
16進 '#006400' ダークグリーン
RGB 'rgba(255, 20, 147, 0.5)' ダークピンクの半透明
HSL 'hsl(282, 100%, 41%)' ダークバイオレット

一つのグラフデータに単一の色を設定する場合は backgroundColor: 'red' のように単一のCOLOR値を設定しますが、 backgroundColor や borderColor を配列にするとX軸の各ラベルごとに色の設定を行えます[^color-array]。

[^color-array]: backgroundColor: ['red'] のような記述をすると、X軸の2つ目以降のデータに対してはデフォルト色が適用されるので注意。

            backgroundColor: [
              'darkblue',
              '#006400',
              'rgba(255, 20, 147, 0.5)',
              'hsla(282, 100%, 41%, 0.5)'
            ],
            borderColor: [
              'darkblue',
              '#006400',
              'rgb(255, 20, 147)',
              'hsl(282, 100%, 41%)'
            ],

image.png
パターンやグラデーションなども使えるようです。詳細は「Colors」を参照してください。

アニメーションをオフにする

デフォルトだと、グラフ描画時に 1000ミリ秒のアニメーションが実行されます。options.animation.duration: DOUBLE でこの時間を変えられます(ミリ秒単位)。アニメーションをオフにするには options.animation.duration: 0 と設定します[^animation-off]。

[^animation-off]: ググると options.animation: false にするとよい、という記事がたくさんヒットします。今回使用している Ver. 2.9.3 でもその設定でアニメーションを無効にすることはできるのですが、逆に、options.animation: true と設定するとエラーになります。やはり options.animation.duration: 0 に設定するのが正当なやり方かと思われます。

アニメーションに関しては、他にマウスホバーやリサイズ時のアニメーション時間も設定可能です[^other-animation]。詳細は「Disable Animations」を参照ください。

[^other-animation]: 末尾のテスト用HTMLでは options.hover.animationDurationoptions.responsiveAnimationDuration が 0 と設定されています。これを変化させて動きを観察してみてください。

左右のY軸を使う

異なるスケールを持つ複数のグラフを描画するような場合、Y軸の左と右で異なるスケールを設定することができます[^multi_scales]。スケールは options.scales.yAxes[].ticksmin, max, stepSizeで設定します。

[^multi_scales]: 実は3つ以上のY軸を定義することもできます。yAxes の配列に3つ以上の設定を定義してみてください。

{
  options: {
    scales: {
      yAxes: [{
        id: "Y-1",
        position: "left",
        ticks: {
          min: 0,
          max: 30,
          stepSize: 5
        }
      },{
        id: "Y-2",
        position: "right",
        ticks: {
          min: 0,
          max: 1.2,
          stepSize: 0.2
        }
      }]
    }
  }
}

下図のようにY軸の両側にスケールが表示されます。
image.png
どちらの軸に合わせるかは、yAxes[].id の値を、グラフごとに data.datasets[].yAxisID で指定します。

ticksmin, max, stepSize を設定しなかった場合は、 Y軸の範囲は次のように決定されます。

  • データが全て正(または負)の場合: ticks.beginAtZerotrue なら最小値(または最大値)が 0 になる。 false ならY軸の範囲がデータから自動的に決定される。
  • データに正値も負値も含む場合: ticks.beginAtZero にかかわらず、Y軸の範囲がデータから自動的に決定される。

bar と line の混在、重なり順

以下のように、data.datasets に複数のデータを並べるだけです。ここでは一つを type: 'line' にし、もう一つを 'bar' にしています。どちらのグラフを前面に表示するかは order で設定します(値の小さいほうが前面)。

{
    data: {
      labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
      datasets: [{
        type: 'line',
        label: '# of Rate',
        data: [12, 19, 3, 5, 2, 3],
        ・・・,
        order: 1
      },{
        type: 'bar',
        label: '# of Votes',
        data: [12, 19, 3, 5, 2, 3],
        ・・・,
        order: 2
      }]
}

下図は、前図とほぼ同じですが line の方を前面に配置してみたものです。
image.png

line を折れ線にする(点を直線で結ぶ)

line のデフォルトでは、各点は Bezier 曲線で結ばれます。その tension 具合を指定するのが data.datasets[].lineTension: DOUBLE です。この値が大きいほどユルユルの曲線になるようです。デフォルトでは 0.3 に設定されていますが、これを 0 にすると下図のように点を直線で結ぶようになります。
image.png

line の線幅、ポイント円、色

line の線幅は data.datasets[].borderWidth で設定し、線色は data.datasets[].borderColoer で設定します。

デフォルトで値の点(ポイント)のところに円(circle)が描画されます。円に関する主な項目は以下のとおり[^line-point]。

[^line-point]: 公式ドキュメントの「Line」には「省略時」の記述が無いのですが、多分これで合っていると思います。が、他に Global point option というのもあるので、実際にはもう少し複雑かもしれません。

項目 設定属性名 省略時 左省略時 備考
円の半径 pointRadius 3 0 にすると円を描画しない
円の枠線幅 pointBorderWidth borderWidth 1 半径位置が枠線の中心
円の枠線色 pointBorderColor borderColor
円の内部色 pointBackgroundColor backgroundColor 半径の内側が対象
スタイル pointStyle 'circle' 点のスタイルを指定

属性は data.datasets[] 配下となります。※省略時の色は 'rgba(0, 0, 0, 0.1)' です。

下図は pointRadius: 7, borderWidth: 3 の例[^pointBorderWidth]。
image.png
[^pointBorderWidth]: 本文中の表のような関係があるので、borderWidth を設定してしまうと pointBorderWidth に影響を与えることになります。知らないとビックリします。というかビックリしました。

他にもマウスホバー時の円の半径や色、線幅なども設定できます。詳細は「Line」を参照ください。

line で fill(塗りつぶし)しない

デフォルトだと line の下部が背景色(backgroundColor)で塗りつぶされます。
image.png
これを止めるには、data.datasets[].fill: false を設定します。
image.png

line を破線にする

line を破線で描画する場合は、data.datasets[].borderDash[実線長, 間隙長] を設定します。

    borderDash: [10, 4],

下図は同時に pointRadius: 0 に設定しています。
image.png

欠損値の line 処理

値が存在しない欠損値に対して line を描画しない
たとえば株価などで5日移動平均線を表示する場合、初めの4日間については移動平均データが無いので線を描画したくありません。このような場合は、配列 datasets[].data の該当要素を空にするか、null を設定します[^use-nullable-type]。下記はデータ列の途中が欠けている例です。

[^excel-na]: Excel の折れ線グラフでいうと #N/A (=NA()) ですね。

[^use-nullable-type]: 第3回記事で紹介した ChartJson クラスでは、datasets[].datadouble?[] 型にしています。このような null許容型を使うことで、値が欠けている場合は null をセットすることができるようになります。

  data {
    datasets: [{
      data: [0.3, 0.2, 0.5, , 0.9, 1.0],      // 空にした例
      ...
    },{
      data: [0.3, 0.2, 0.5, null, 0.9, 1.0],  // null をセットした例
      ...
    }],
    ...
  }

image.png

値が存在しない欠損値に対して line を補間して描画する
値が欠けている部分を補間して line 描画したい場合は、datasets[].spanGaps: true と設定します。
image.png

ツールチップに全グラフの値を表示

デフォルトの設定では、マウスポインタを描画されているグラフのバーやポイント円の上に持っていくと、そのグラフの値がツールチップで表示されます。X軸上の同じ位置にあるすべてのグラフの値を表示させたい場合は、以下のような設定をします。ツールチップの詳細については、「Tooltips」を参照ください。

マウスポインタがバーやポイント円に乗っていなくてもツールチップを表示する場合

options: {
  tooltips: {
    mode: 'index',
    intersect: false
  }
}

下図でマウスポインタは「Yellow」の上部に位置しています。このようにマウスポインタがどこにあろうともツールチップにすべてのグラフの値が表示されることになります。
image.png
この場合、グラフエリア内にマウスポインタがあると常にツールチップが表示されるので少々煩わしいかもしれません。

マウスポインタがバーやポイント円に乗ったときだけツールチップを表示する場合

options: {
  tooltips: {
    mode: 'index',
    intersect: true
  }
}

マウスポインタが「Yellow」のバーの上またはポイント円に乗ったときだけ、下図のようにツールチップが表示されます。
image.png
こちらのほうが使い勝手が良さそうですね。

そもそもツールチップを表示させたくない場合
options.tooltips.enabled: false と設定します。

options: {
  tooltips: {
    enabled: false
  }
}

テスト用HTML

Chart.js の動きを確認するために筆者が使用した HTML ファイルを下記に置いておきます。ローカルPCに適当な HTMLファイルを作成し、下記内容をコピペします。その後、ブラウザで当HTMLファイルを参照[^file-ref]してください。

[^file-ref]: Windows の場合は、たとえば file:///E:/Dev/Work/chartjs/index.html などとなります。

テスト用HTMLの内容(クリックして開いてください)
   

<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.js"></script>

<div>
    <button onclick="renderNew()">New</button>
     <button onclick="renderUpdate()">Update</button>
 </div>

  <div class="chart-container" style="width:600px">
    <!-- <div class="chart-container" style="width:80vw"> -->
       <canvas id="myChart"></canvas>
   </div>

    <script>
      var chartData = {
        type: 'bar',
        data: {
          labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
          datasets: [{
            type: 'bar',
            label: '# of Votes',
            data: [12, 19, 3, 5, 2, 3],
            yAxisID: 'left',
            backgroundColor: [
              'rgba(255, 99, 132, 1)',
              'rgba(54, 162, 235, 1)',
              'rgba(255, 206, 86, 1)',
              'rgba(75, 192, 192, 1)',
              'rgba(153, 102, 255, 1)',
              'rgba(255, 159, 64, 1)'
            ],
            borderColor: [
              'rgba(255, 99, 132, 1)',
              'rgba(54, 162, 235, 1)',
              'rgba(255, 206, 86, 1)',
              'rgba(75, 192, 192, 1)',
              'rgba(153, 102, 255, 1)',
              'rgba(255, 159, 64, 1)'
            ],
            borderWidth: 3,
            pointRadius: 5,
            fill: true,
            order: 2
          },{
            type: 'line',
            label: 'Rate',
            data: [1.1, 0.9, 0.1, 0.5, 0.2, 0.3],
            //spanGaps: true,
            yAxisID: 'right',
            backgroundColor: [
              'rgba(255, 99, 132, 0.2)',
              'rgba(54, 162, 235, 0.2)',
              'rgba(255, 206, 86, 0.2)',
              'rgba(75, 192, 192, 0.2)',
              'rgba(153, 102, 255, 0.2)',
              'rgba(255, 159, 64, 0.2)'
            ],
            borderColor: [
              'rgba(255, 99, 132, 1)',
              'rgba(54, 162, 235, 1)',
              'rgba(255, 206, 86, 1)',
              'rgba(75, 192, 192, 1)',
              'rgba(153, 102, 255, 1)',
              'rgba(255, 159, 64, 1)'
            ],
            borderWidth: 3,
            //borderDash: [10, 4],
            pointRadius: 4,
            //pointBorderWidth: 1,
            fill: false,
            lineTension: 0.3,
            order: 1
          }]
        },
        options: {
          animation: {
            duration: 777.777 // general animation time
          },
          hover: {
            animationDuration: 0 // duration of animations when hovering an item
          },
          responsiveAnimationDuration: 0, // animation duration after a resize
          scales: {
            yAxes: [{
              id: "left",
              position: "left",
              ticks: {
                beginAtZero: false,
                min: 0,
                max: 30,
                stepSize: 5
              }
            },{
              id: "right",
              position: "right",
              ticks: {
                beginAtZero: true
              }
            }]
          },
          tooltips: {
            //mode: 'index',
            //intersect: true,
            //position: 'average'
          }
        }
      };

      var ctx = document.getElementById("myChart").getContext("2d");
      var myChart = new Chart(ctx, chartData);

      function updateData(chartdata) {
        chartdata.data.datasets[1].data[0] += 1;
        chartdata.options.animation.duration -= 200;
      }

      function renderNew() {
        updateData(chartData);
        new Chart(ctx, chartData);
      };

      function renderUpdate() {
        updateData(myChart);
        myChart.update();
      };
    </script>
</body>

今回の記事は以上です。次回(番外編[^bangai];最終回)では外部の Ubuntuサーバでアプリを公開する手順について説明します。nginx, リバースプロキシ, SSL, Let's Encrypt (certbot) といったあたりの話が中心になるかと思います。

[^bangai]: もはや Blazor や Chart.js はほとんど関係なくなっているので「番外編」と銘打つことにしました。