Power BI カスタムビジュアル開発 その２: プロジェクトの理解とグラフの追加

※ 2019/07/15 ツールが 3.0 になりいくつか内容が変わったため記事を編集します。

[前回の準備編](https://qiita.com/kenakamu/items/41ae97b59a47333fe40e) に引き続き、Power BI のカスタムビジュアルを開発していきます。今回は pbiviz で作成されたプロジェクトの中身を理解します。

# カスタムビジュアルプロジェクトの構造
Power BI カスタムビジュアルは以下の要素があります。

- TypeScript と LESS ベース
- UI: d3.js ベース
- データ: Power BI 側からフィードされる。データの構造や設定を行う必要がある

# 構成ファイル
## pbiviz.json
カスタムビジュアルプロジェクトの情報を保持。カスタムビジュアル自体の情報と、依存や機能ファイルの指定を含みます。
## capabilities.json
ビジュアル用のカテゴリや値の設定を保持。それぞれのラベルや、何個までフィールドを持てるかなど設定を行えます。
## tsconfig.json、tslint.json、package.json
TypeScript と　npm プロジェクトの設定

# ソースファイル
## src/visual.ts
カスタムビジュアルのソース
IVisual を継承したビジュアル用のクラスが存在し、コンストラクターと画面更新のたびに実行される Update メソッドを持つ。
## src/settings.ts
カスタムビジュアル用設定ソース
## style/visual.less
カスタムビジュアル用 LESS ファイル
## assets
画像保存用フォルダ

# データ構造の定義と読み込み
まずは今回作成するグラフが使うデータ構造を定義します。サンプルは [この GitHub](https://github.com/Microsoft/PowerBI-visuals-sampleBarChart) にあるものを再利用します。

1\. visual.ts に、以下インターフェースと import を追加

- BarChartViewModel: グラフに対して指定するモデル。ここでは実際のデータと最大値をそれぞれ保持するように指定。
- BarChatDataPoin: グラフに渡される実際のデータ。ここでは値をカテゴリをそれぞれ１つ。

```typescript
import * as d3 from 'd3';
import IVisualHost = powerbi.extensibility.visual.IVisualHost;

interface BarChartViewModel {
    dataPoints: BarChartDataPoint[];
    dataMax: number;
};

interface BarChartDataPoint {
    value: number;
    category: string;
};
```

2\. Power BI から受け取ったデータを上記の型に変換する関数として、visualTransform を追加。ポイントはデータが ``` options.dataViews ``` として渡される点。

```typescript
private static visualTransform(options: VisualUpdateOptions, host: IVisualHost): BarChartViewModel {
    // Power BI からのデータを受け取る
    // データは dataViews プロパティに存在
    let dataViews = options.dataViews;
    
    // 空の viewModel を作成。
    let viewModel: BarChartViewModel = {
        dataPoints: [],
        dataMax: 0
    }
    // 期待した値があるか確認。なければ空データを返す
    if (!dataViews
        || !dataViews[0]
        || !dataViews[0].categorical
        || !dataViews[0].categorical.categories
        || !dataViews[0].categorical.categories[0].source
        || !dataViews[0].categorical.values)
        return viewModel;
    // 値があった場合はそれぞれ変数に一旦抜き出し
    let categorical = dataViews[0].categorical;
    let category = categorical.categories[0];
    let dataValue = categorical.values[0]
    
    // dataPoint のインスタンス化
    let barChartDataPoints: BarChartDataPoint[] = [];
    let dataMax: number;
    // カテゴリと値のセットを dataPoint に入れていく
    for (let i = 0, len = Math.max(category.values.length, dataValue.values.length); i < len; i++){
        barChartDataPoints.push({
            category: <string>category.values[i], 
            value: <number>dataValue.values[i]
        });
    }
    // 値の最大を取得
    dataMax = <number>dataValue.maxLocal;
    // viewModel を返す
    return {
        dataPoints: barChartDataPoints,
        dataMax: dataMax
    };
}  
```
3\. Visual クラスの変数、コンストラクタ、Update の中身を一旦削除。

```typescript
export class Visual implements IVisual {
    constructor(options: VisualConstructorOptions) {
    }
    public update(options: VisualUpdateOptions) {
    }
    private static parseSettings(dataView: DataView): VisualSettings {
        return VisualSettings.parse(dataView) as VisualSettings;
    }
    /** 
     * This function gets called for each of the objects defined in the capabilities files and allows you to select which of the 
     * objects and properties you want to expose to the users in the property pane.
     * 
     */
    public enumerateObjectInstances(options: EnumerateVisualObjectInstancesOptions): VisualObjectInstance[] | VisualObjectInstanceEnumerationObject {
        return VisualSettings.enumerateObjectInstances(this.settings || VisualSettings.getDefault(), options);
    }
}
```

4\. クラスに以下 3 つのプロパティを追加。area は D3 を使って指定する HTML の表示領域。他 2 つは Power BI の要素。

```typescript
private area: d3.Selection<d3.BaseType, any, HTMLElement, any>
private host: IVisualHost;
private settings: VisualSettings;
```

5\. コンストラクタを以下に変更。ホストの情報とデータを表示したいエリアを取得。

```typescript
constructor(options: VisualConstructorOptions) {
    // カスタムビジュアルを配置しているホストの情報を取得
    this.host = options.host;
    // カスタムビジュアルのエリアを取得
    this.area = d3.select(options.element);
}
```

6\. update 関数を以下に変更。先ほど定義した関数を使ったデータの取得および、結果を p タグに列挙。D3 の知識が必要となるが、data でデータをバインドし、enter で各データをループするイメージ。各データごとに p と text を追加して、値を設定。

```typescript
public update(options: VisualUpdateOptions) {
    // Power BI より データを取得
    let viewModel: BarChartViewModel = Visual.visualTransform(options, this.host);

    // D3 を使って、データを p タグとして書き込み
    this.area.selectAll('p')
        .data(viewModel.dataPoints)
        .enter()
        .append('p')
        .append('text')
        .text(function (d) { return d.value; });
}
```

7\. ファイルを保存して、Power BI サービスでカスタムビジュアルを更新。任意のフィールドをいくつか設定して動作を確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/82aa9c7b-57ec-143f-1e22-928b088b607d.png)

# 棒グラフの実装
次に D3 を使って棒グラフを作ってみます。

1\. Visual クラスプロパティを以下のように変更。

```typescript
private svg: d3.Selection<d3.BaseType, any, HTMLElement, any>; // グラフ全体用
private barContainer: d3.Selection<d3.BaseType, any, any, any>; // 棒グラフ用
private xAxis: d3.Selection<d3.BaseType, any, any, any>; // X軸表示用
private host: IVisualHost;
private settings: VisualSettings;
```

2\. コンストラクターを以下コードに変更。

```typescript
constructor(options: VisualConstructorOptions) {
    // カスタムビジュアルを配置しているホストの情報を取得
    this.host = options.host;
    // カスタムビジュアルのエリアを取得し、svg を追加
    this.svg = d3.select<SVGElement, any>(options.element as any)
        .append('svg');
    // svg 内に棒グラフ用のエリアと xAxis 用のエリアをそれぞれグループ指定
    this.barContainer = this.svg
        .append('g');
    this.xAxis = this.svg
        .append('g');
}
```

3\. update を以下コードに変更。

```typescript
public update(options: VisualUpdateOptions) {
    // Power BI より データを取得
    let viewModel: BarChartViewModel = Visual.visualTransform(options, this.host);
    // 現在のビジュアルの幅と高さを取得
    let width = options.viewport.width;
    let height = options.viewport.height;
    //　棒グラフ用にマージンを指定。bottom を 25 上げる
    let margin = { top: 0, bottom: 25, left: 0, right: 0 }
    // メインの svg をカスタムビジュアルと同じ大きさに指定
    this.svg
        .attr("width",width)
        .attr("height", height);

    // 棒グラフ用の高さとして、マージンの bottom を引いたものを用意
    height -= margin.bottom;
    // Y軸スケール用の計算。0 から値の最大値を、高さから 0 にマップ。
    // 値が 0 の場合は高さが最大になり、値が最大の場合は、高さが 0 となる。
    let yScale = d3.scaleLinear()
        .domain([0, viewModel.dataMax])
        .range([height, 0]);

    // X軸スケール用の計算。カテゴリをドメインとし、全体の幅をグラフの数で分割
    // グラフとグラフの間は 0.1 の割合であける。
    let xScale = d3.scaleBand()
        .domain(viewModel.dataPoints.map(d => d.category))
        .rangeRound([0, width])
        .padding(0.1);
    
    // xAxis の場所をグラフ内各棒の下に設定
    let xAxis = d3.axisBottom(xScale);
    // xAxis の属性として　transform を追加。
    this.xAxis
        .attr('transform', 'translate(0, ' + height + ')')
        .call(xAxis);
    // 棒グラフ内のすべてのグラフを取得し、データをバインド
    let bars = this.barContainer
        .selectAll('.bar')
        .data(viewModel.dataPoints);
    // 各グラフ毎に rect (四角) を追加してクラスを定義
    // 高さや位置を指定
    bars.enter()
        .append('rect')
        .classed('bar', true)
        .attr("width",xScale.bandwidth())
        .attr("height", d => height - yScale(<number>d.value))
        .attr("y", d => yScale(<number>d.value))
        .attr("x", d => xScale(d.category));

    // 更新された際の再描写
    bars
        .attr("width",xScale.bandwidth())
        .attr("height", d => height - yScale(<number>d.value))
        .attr("y", d => yScale(<number>d.value))
        .attr("x", d => xScale(d.category));
     
    // 値がないグラフがあった場合は表示から削除
    bars.exit()
        .remove();
}
```

4\. 保存して Power BI サービスのカスタムビジュアルを更新。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/2c793442-571a-b605-6b43-aa2f1797def9.png)

5\. 一番下がつながっているので、CSS で表示を少し調整。まず CSS で要素を指定できるよう xAxis にクラスを付与。コンストラクターで xAxis を作成している場所を以下のように変更。

```typescript
this.xAxis = this.svg
    .append('g')
    .classed('xAxis', true);
```

6\. style/visual.less の中身を以下に変更。

```less
.xAxis {
    path {
        display: none;
    }
} 
```

7\. 保存後、再度カスタムビジュアルを更新。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/4776ad40-2702-4132-0265-bef19e00491d.png)

8\. 値がないものは削除したはずだが、2 つしか棒が出ていない。理由は値がないのではなく、負の値となっているため。最大値を同じように対応するため、まずは BarChartViewModel で最小値を持つように変更。

```typescript
interface BarChartViewModel {
    dataPoints: BarChartDataPoint[];
    dataMax: number;
    dataMin: number;
};
```

9\. visualTransform 関数を以下のように変更。

```typescript
private static visualTransform(options: VisualUpdateOptions, host: IVisualHost): BarChartViewModel {
    // Power BI からのデータを受け取る
    // データは dataViews プロパティに存在
    let dataViews = options.dataViews;
    // 空の viewModel を作成。
    let viewModel: BarChartViewModel = {
        dataPoints: [],
        dataMax: 0,
        dataMin: 0
    }
    // 期待した値があるか確認。なければ空データを返す
    if (!dataViews
        || !dataViews[0]
        || !dataViews[0].categorical
        || !dataViews[0].categorical.categories
        || !dataViews[0].categorical.categories[0].source
        || !dataViews[0].categorical.values)
        return viewModel;
    // 値があった場合はそれぞれ変数に一旦抜き出し
    let categorical = dataViews[0].categorical;
    let category = categorical.categories[0];
    let dataValue = categorical.values[0]
    // dataPoint のインスタンス化
    let barChartDataPoints: BarChartDataPoint[] = [];
    let dataMax: number;
    let dataMin: number;
    // カテゴリと値のセットを dataPoint に入れていく
    for (let i = 0, len = Math.max(category.values.length, dataValue.values.length); i < len; i++) {
        barChartDataPoints.push({
            category: <string>category.values[i],
            value: <number>dataValue.values[i]
        });
    }
    // 値の最大を取得
    dataMax = <number>dataValue.maxLocal;
    // 値の最小値を取得
    dataMin = <number>dataValue.minLocal;
    // viewModel を返す
    return {
        dataPoints: barChartDataPoints,
        dataMax: dataMax,
        dataMin: dataMin
    };
}
```

10\. 最後に update 関数を変更。

```typescript
public update(options: VisualUpdateOptions) {
    // Power BI より データを取得
    let viewModel: BarChartViewModel = Visual.visualTransform(options, this.host);
    // 現在のビジュアルの幅と高さを取得
    let width = options.viewport.width;
    let height = options.viewport.height;
    //　棒グラフ用にマージンを指定。bottom を 25 上げる
    let margin = { top: 0, bottom: 25, left: 0, right: 0 }
    // メインの svg をカスタムビジュアルと同じ大きさに指定
    this.svg
        .attr("width",width)
        .attr("height", height);

    // 棒グラフ用の高さとして、マージンの bottom を引いたものを用意
    height -= margin.bottom;
    // 値が負になることを考慮して高さマップ。
    let max = Math.max(viewModel.dataMax, -viewModel.dataMin);
    let yScale = d3.scaleLinear()
        .domain([0, max])
        .range([0, height * (max / (viewModel.dataMax - viewModel.dataMin))])
        .nice();

    // X軸スケール用の計算。カテゴリをドメインとし、全体の幅をグラフの数で分割
    // グラフとグラフの間は 0.1 の割合であける。
    let xScale = d3.scaleBand()
        .domain(viewModel.dataPoints.map(d => d.category))
        .rangeRound([0, width])
        .padding(0.1);
    
    // xAxis の場所をグラフ内各棒の下に設定
    let xAxis = d3.axisBottom(xScale);
    // xAxis の属性として　transform を追加。
    this.xAxis
        .attr('transform', 'translate(0, ' + height + ')')
        .call(xAxis);
    // 棒グラフ内のすべてのグラフを取得し、データをバインド
    let bars = this.barContainer
        .selectAll('.bar')
        .data(viewModel.dataPoints);
    // 各グラフ毎に rect (四角) を追加してクラスを定義
    // 高さや位置を指定
    bars.enter()
        .append('rect')
        .classed('bar', true)
        .attr("width",xScale.bandwidth())
        .attr("height", d => yScale(Math.abs(d.value)))
        .attr("y", d => {
            if (d.value > 0) {
                return yScale(max) - yScale(Math.abs(d.value));
            }
            else {
                return yScale(max);
            }
        })
        .attr("x", d => xScale(d.category));

    // 更新された際の再描写
    bars
        .attr("width",xScale.bandwidth())
        .attr("height", d => yScale(Math.abs(d.value)))
        .attr("y", d => {
            if (d.value > 0) {
                return yScale(max) - yScale(Math.abs(d.value));
            }
            else {
                return yScale(max);
            }
        })
        .attr("x", d => xScale(d.category));

    // 値がないグラフがあった場合は表示から削除
    bars.exit()
        .remove();
}
```

11\. グラフを更新して確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/6833a04f-bb8e-14c1-085a-9488e1e65c10.png)

# 棒グラフに色を付ける
今回は値の正負のみで色分けしてみます。

1\. update 関数最後にあるグラフの属性指定を変更して、fill を追加。

```typescript
bars.enter()
    .append('rect')
    .classed('bar', true)
    .attr("width",xScale.bandwidth())
    .attr("height", d => yScale(Math.abs(d.value)))
    .attr("y", d => {
        if (d.value > 0) {
            return yScale(max) - yScale(Math.abs(d.value));
        }
        else {
            return yScale(max);
        }
    })
    .attr("x", d => xScale(d.category))
    .attr("fill", d => {
        if (d.value > 0) {
            return 'green';
        }
        else {
            return 'red';
        }
    });
```

2\. グラフを更新して確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/ff128a80-8c27-0753-6db9-d89ca855e592.png)

今回はプロジェクトの構成と棒グラフの追加をしました。次回は既定のグラフのように、各棒グラフをクリックした際に他の要素が薄くなるなどインタラクションを追加していきます。

[次の記事へ](https://qiita.com/kenakamu/items/cf4acb91d8ea366bc45c)
[目次へ戻る](https://qiita.com/kenakamu/items/f57ab7f73d6ea10bc9d9)

# 参考
[GitHub: Visual Project](https://github.com/Microsoft/PowerBI-visuals/blob/master/VisualProject.md)