Power BI の Custom Visual の作成 (Visual Studio, Node.js)


2016/09 : This is an old post and please see "Build Your Custom Visuals in Power BI" as new one.
この投稿は古い情報です。最新の情報については「Build Your Custom Visuals in Power BI」を参照してください。

Power BI を使った開発

こんにちは。

今週は、まもなく (07/24 に) 新バージョンがリリースされる Power BI について記載します。

Power BI では、「グラフのカスタマイズができない」とよく言われますが、そのようなことはありません。
新 Power BI では、利用者からのフィードバックに基づき、Custom の Visualization (Chart, Report, etc) を作成して、それを Power BI 本体に Contribution できます。(つまり、一般の Power BI ユーザーが、Professional Developer や Designer が開発した Custom のVisualization を使用できます。)

補足 : Contribution 方法については、「PowerBI-visuals : Contribution Guidelines」を参照してください。

ここでは、この Custom Visualization の開発手法を紹介します。

 

誰でも使える BI への取り組み

Visualization API を使った開発手法を紹介する前に、まず、誤解を与えないように、ざっくりと Power BI についておさらいします。(Power BI の使い方などの解説は省略します。該当のドキュメントを参照してください。)
Power BI 自体は、本来、ここで紹介するようなプロフェッショナル開発のためのソリューションではありません。

Microsoft では、SQL Server の早い段階から、長年、「あらゆる人々が BI を簡単に扱えるようにする取り組み」(分析や OLAP のプロなどに限定せず、誰もが BI を簡単に扱える仕組みへの取り組み) がおこなわれてきました。
Power BI は、そうした過程で登場したサービスです。

これまでの Power BI では、例えば、Excel (PowerPivot) を使用してデータモデル作成や Visual 化をおこない、それを SharePoint (Power View) を使って Web 上に表示することなどができました。Excel のデータとしてすべてを取りこむのではなく、内部でインメモリやカラムストアなどの高度な技術を使うことで、利用者は普通に Excel を扱いながら大量データを扱うことなどができますが、その一方で、SharePoint をはじめとした Office や Office 365 の一定レベルの知識や利用環境が前提となっていました。
新 Power BI では、従来のメリットに加え、デスクトップ版の Power BI Designer や Web ベースの Power BI Dashboard (下記の PowerBI.com) により、単一の専用クライアントを使って Visual 化までの一貫した作業が可能になっており、しかも、これらの基本サービスは無料で使えます。(Power BI Designer で作成したファイルを、Power BI Dashboard にインポートすることも可能です。)

Power BI Dashboard (PowerBI.com)

https://powerbi.com/

利用者は、データソースからデータの取得をおこなって、ドラッグ・アンド・ドロップなどの操作をベースとしてデータモデル (ピポッド集計的なデータモデルなど) を構築し、利用可能な Visualization (棒グラフ、円グラフ、Map、Funnel 等々) を選択することで、そのモデルの可視化をおこないます。(下図は Power BI Dashboard での利用イメージです。) この可視化された内容は、Mobile 版の専用アプリを使って確認することもできます。

データソースとしては、SQL Database 等さまざまなデータソースを選択可能で、静的なものだけでなく、例えば、Azure ServiceBus の Event Hub にレポートされる telemetry データを Azure Stream Analytics で Query して結果を流し込み、Power BI Dashboard 上にリアルタイムに (刻一刻と変化するデータを) 表示することなども可能です。(また、REST API を使ってカスタムにデータを流し込むことも可能です。)

Github や Visual Studio Team Services における Project の情報を可視化したり、Google Analytics の分析データや Salesforce, Marketo のデータを可視化するなど、各種の既存サービスをデータソースにすることも可能です。

今回紹介するのは、この Visualization の Custom 開発であり、一般の利用者がおこなうことではありません。(開発者、デザイナー向けの拡張開発です。) 一般の利用者は、上述の通り、提供されている Visualization を選択すれば OK です。

 

開発環境の準備

では、Custom Visualization 開発のための開発環境を準備しましょう。

まず、開発環境に、下記のインストールをおこなってください。特に、Typescript 1.4 以上が必要です。(このため、Visual Studio 2015 を使用しても良いかもしれません。。。)

  • Git
  • Node.js
  • Visual Studio 2015 以上 (Visual Studio Community でも OK)
  • Unit Test をおこなう場合は、PhantomJS が必要

Github から、下記の Power BI Visual のソースを clone します。

[Github] Microsoft Power BI visuals
https://github.com/Microsoft/PowerBI-visuals

git clone https://github.com/Microsoft/PowerBI-visuals.git

ソースのダウンロードが完了したら、npm を使って必要なパッケージのインストール (ダウンロード) とビルドをおこなってください。

cd PowerBI-visuals
npm install

なお、本投稿では Visual Studio を使用しますが、Custom Visual 開発では、Visual Studio Code や Node.js のみ 使って Build / Test をおこなうこともできます。(「Github : Microsoft/PowerBI-visuals」を参照してください。)

まず、PowerBI-visuals\src に Visual Studio 用の Solution File (.sln) があります。
今回は、このソリューションを Visual Studio で開いて、src\Clients\PowerBIVisualsPlayground\index.html を Start Page に設定します。(下図)

つぎに、プロジェクトのプロパティ ページを表示し、[ビルド] の [スタートアップ ページの前に実行する動作] を [ビルドなし] に設定します。
なぜ、このような設定をおこなうかと言うと、このプロジェクトは Gulp の Task Runner により管理された Web サイトであるためです。(このソリューションは、正確には「プロジェクト」ではなく「Web サイト」です。)

F5 で Debug 実行してみてください。下図の通り、さまざまなサンプルの Visualization を参照できます。(下記は columnChart を選択しました。)

Visualization のソースは、すべて typescript で記述されています。つまり、JavaScript に関する基本的な知識があれば、開発可能です。(後述しますが、あと、この手の開発には、d3.js の知識も身に着けておくと良いでしょう。)

 

DataView の準備

Custom の Visualization を構築する前に、今回、Visualization に読み込ませるサンプル データとその構造を解説します。

上述の通り、Power BI では、取得したデータに Pivot などの操作をおこなって集計し (データモデルを作成し)、この集計結果を Visual 化しますが、この集計されたデータは DataView と呼ばれ、以下の要素を含んでいます。

  • Categorical
  • Table
  • Tree
  • Matrix
  • Single

この DataView は、上述の (Visual Studio の) ソリューションの場合、コードを使って作成されていて、上述の src\Clients\PowerBIVisualsPlayground\sampleDataViews.ts (typescript のソースコード) にその定義が記述されています。
今回は、この中の SalesByCountryData のみを使ってサンプルを構築してみます。

この DataView は、各国ごと (France, Germany など) の年ごと (2014 年, 2015 年) の売り上げや、逆に、年ごと (2014 年, 2015 年) の各国ごと (France, Germany など) の売り上げを表現しており、結果には、下記 Json テキストの通り Categorical, Table, Single を含んでいます。(上図の columnChart のサンプルも、この DataView を Visual 化しています。)
なお、後述しますが、実際の Power BI 上での利用の際には、このレイアウト (下記の metadata の情報、データの種類の数、objects 内にある format 情報等々) は後述する Visual Capability を使って決まるので、あくまでもサンプルの 1 つとして見ておいてください。(今回使用するソリューション上では、常にこのレイアウトでデータが渡されます。)

{
  "metadata": {
    "columns": [
      {
        "displayName": "Country",
        "queryName": "Country",
        "type": {
          "underlyingType": 1,
          "category": null
        }
      },
      {
        "displayName": "Sales Amount (2014)",
        "isMeasure": true,
        "format": "$0,000.00",
        "queryName": "sales1",
        "type": {
          "underlyingType": 259,
          "category": null
        },
        "objects": {
          "dataPoint": {
            "fill": {
              "solid": {
                "color": "purple"
              }
            }
          }
        }
      },
      {
        "displayName": "Sales Amount (2015)",
        "isMeasure": true,
        "format": "$0,000.00",
        "queryName": "sales2",
        "type": {
          "underlyingType": 259,
          "category": null
        }
      }
    ]
  },
  "categorical": {
    "categories": [
      {
        "source": {
          "displayName": "Country",
          "queryName": "Country",
          "type": {
            "underlyingType": 1,
            "category": null
          }
        },
        "values": [
          "Australia",
          "Canada",
          "France",
          "Germany",
          "United Kingdom",
          "United States"
        ],
        "identity": [
          {
            "_expr": {
              "kind": 0,
              "left": {
                "source": {
                  "schema": "s",
                  "entity": "table1"
                },
                "ref": "country"
              },
              "right": {
                "type": {
                  "underlyingType": 1,
                  "category": null
                },
                "value": "Australia",
                "valueEncoded": "'Australia'"
              }
            },
            "_key": {
              
            }
          },
          {
            "_expr": {
              "kind": 0,
              "left": {
                "source": {
                  "schema": "s",
                  "entity": "table1"
                },
                "ref": "country"
              },
              "right": {
                "type": {
                  "underlyingType": 1,
                  "category": null
                },
                "value": "Canada",
                "valueEncoded": "'Canada'"
              }
            },
            "_key": {
              
            }
          },
          {
            "_expr": {
              "kind": 0,
              "left": {
                "source": {
                  "schema": "s",
                  "entity": "table1"
                },
                "ref": "country"
              },
              "right": {
                "type": {
                  "underlyingType": 1,
                  "category": null
                },
                "value": "France",
                "valueEncoded": "'France'"
              }
            },
            "_key": {
              
            }
          },
          {
            "_expr": {
              "kind": 0,
              "left": {
                "source": {
                  "schema": "s",
                  "entity": "table1"
                },
                "ref": "country"
              },
              "right": {
                "type": {
                  "underlyingType": 1,
                  "category": null
                },
                "value": "Germany",
                "valueEncoded": "'Germany'"
              }
            },
            "_key": {
              
            }
          },
          {
            "_expr": {
              "kind": 0,
              "left": {
                "source": {
                  "schema": "s",
                  "entity": "table1"
                },
                "ref": "country"
              },
              "right": {
                "type": {
                  "underlyingType": 1,
                  "category": null
                },
                "value": "United Kingdom",
                "valueEncoded": "'United Kingdom'"
              }
            },
            "_key": {
              
            }
          },
          {
            "_expr": {
              "kind": 0,
              "left": {
                "source": {
                  "schema": "s",
                  "entity": "table1"
                },
                "ref": "country"
              },
              "right": {
                "type": {
                  "underlyingType": 1,
                  "category": null
                },
                "value": "United States",
                "valueEncoded": "'United States'"
              }
            },
            "_key": {
              
            }
          }
        ]
      }
    ],
    "values": [
      {
        "source": {
          "displayName": "Sales Amount (2014)",
          "isMeasure": true,
          "format": "$0,000.00",
          "queryName": "sales1",
          "type": {
            "underlyingType": 259,
            "category": null
          },
          "objects": {
            "dataPoint": {
              "fill": {
                "solid": {
                  "color": "purple"
                }
              }
            }
          }
        },
        "values": [
          742731.43,
          162066.43,
          283085.78,
          300263.49,
          376074.57,
          814724.34
        ]
      },
      {
        "source": {
          "displayName": "Sales Amount (2015)",
          "isMeasure": true,
          "format": "$0,000.00",
          "queryName": "sales2",
          "type": {
            "underlyingType": 259,
            "category": null
          }
        },
        "values": [
          123455.43,
          40566.43,
          200457.78,
          5000.49,
          320000.57,
          450000.34
        ]
      }
    ]
  },
  "table": {
    "rows": [
      [
        "Australia",
        742731.43,
        123455.43
      ],
      [
        "Canada",
        162066.43,
        40566.43
      ],
      [
        "France",
        283085.78,
        200457.78
      ],
      [
        "Germany",
        300263.49,
        5000.49
      ],
      [
        "United Kingdom",
        376074.57,
        320000.57
      ],
      [
        "United States",
        814724.34,
        450000.34
      ]
    ],
    "columns": [
      {
        "displayName": "Country",
        "queryName": "Country",
        "type": {
          "underlyingType": 1,
          "category": null
        }
      },
      {
        "displayName": "Sales Amount (2014)",
        "isMeasure": true,
        "format": "$0,000.00",
        "queryName": "sales1",
        "type": {
          "underlyingType": 259,
          "category": null
        },
        "objects": {
          "dataPoint": {
            "fill": {
              "solid": {
                "color": "purple"
              }
            }
          }
        }
      },
      {
        "displayName": "Sales Amount (2015)",
        "isMeasure": true,
        "format": "$0,000.00",
        "queryName": "sales2",
        "type": {
          "underlyingType": 259,
          "category": null
        }
      }
    ]
  },
  "single": {
    "value": 559.43
  }
}

 

IVisual の実装 - init, update

Custom の Visualization を開発 (プログラミング) する最も簡単な方法は、IVisual の実装 (implementation) です。
IVisual では、描画の開始時に一度だけ呼ばれる init(), データ更新のたびに呼ばれる update() を実装します。

では、実際にサンプルを書いてみましょう。

上記の Solution の Visuals プロジェクトに、.ts ファイルを新規作成し、下記のプログラム コードを記述してみてください。
やっていることは、すごく簡単ですよね。初期化 (init) の際に div 要素を追加して、update の際、この div 要素に、取得した DataView の内容を文字列で書き込んでいます。

module powerbi.visuals {

  export class TestChart implements IVisual {
    private drawArea: HTMLElement;

    public static capabilities: VisualCapabilities = {
      /* not implement, this time */
    };

    public init(options: VisualInitOptions): void {
      // "this" scope will change
      var _this = this;

      var captionArea = document.createElement("div");
      captionArea.innerHTML = "This is test chart";
      options.element.get(0).appendChild(captionArea);
      _this.drawArea = document.createElement("div");
      options.element.get(0).appendChild(_this.drawArea);
    }

    public update(options: VisualUpdateOptions) {
      // Debug:
      //_this.drawArea.innerHTML =
      //  JSON.stringify(options.dataViews[0]);

      // "this" scope will change
      var _this = this;

      _this.drawArea.innerHTML = "";
      for (var i = 0;
        i < options.dataViews[0].table.rows.length;
        i++) {
        var row = options.dataViews[0].table.rows[i];
        _this.drawArea.innerHTML += row[0] + "=" + row[1] + "<br />";
      }
    }

  }
}

src\Clients\Visuals\plugins.ts に下記の通り追記して、この新しい IVisual の実装を Plug-in に組み込みます。

module powerbi.visuals.plugins {
  . . .

  export var testChart: IVisualPlugin = {
    name: 'testChart',
    create: () => new TestChart()
  };

}

さらに、src\Clients\Visuals\services\visualPluginService.ts の createMinervaPlugins 関数に、下記の通り追記して、上記 Plugin を明示的に作成します。

function createMinervaPlugins(plugins: jsCommon.IStringDictionary<IVisualPlugin>, seriesLabelFormattingEnabled: boolean) {
  . . .

  // Test Chart
  createPlugin(plugins, powerbi.visuals.plugins.testChart, () => new TestChart());

}

 

なお、前述の通り、この Web サイト (プロジェクト) では Gulp の Task Runner が使用されています。(ソースコードを変更すると、動的にコンパイルなどが実行されます。)
このため、記述したコードにエラーがある場合は、コードの作成や変更をおこなって保存した際に、下図の Task Runner 上にエラーが表示されますので、適宜、修正してください。(MS Build は使用しません。)

プログラミングが完了したら、F5 を押して実行します。(反映されない場合は、ブラウザーのリロードなどをおこなってみてください。)
「testChart」が表示されるので、これを選択すると、下図の通り、各国 (Australia, Germany など) と 2014 年の Sales の実績値が文字列で表示されます。

なお、今回は IVisual を使いましたが、ICartesianVisual などを使用すると、さらに応用的な動作が実装可能です。これらの実装については、プロジェクトに含まれるサンプル コードを参照してください。

 

Visual Capability

前述で DataView の Json フォーマットを示しましたが、実は、入力データを最終的に決めるのは、上記の IVisual の実装 (implementation) で省略した VisualCapabilities オブジェクトです。

Visual Capabilities では、入力データのどの種類のデータを扱い、どのように抽出し (query なども可能です)、どのような formatting をおこなうかといった定義 (つまり、mapping 定義) をおこないます。また、sort の可否、ハイライト表示 (ユーザーがデータを選択した場合に、その箇所を強調表示する機能) の可否など、その Visualization が持っている機能も Visual Capabilities として定義できます。(Power BI は、この VisualCapabilities を見て、sort や highlight の際に必要な関数を呼び出します。)

使用可能な VisualCapabilities の要素については、下記のドキュメント (Wiki) を参照してください。

[Github] PowerBI visuals - VisualCapabilities

https://github.com/Microsoft/PowerBI-visuals/wiki/Capabilities-Introduction

記述方法は以下の通りです。(細かな記載は省略します。プロジェクトに含まれているサンプル コードを参照してください。)

module powerbi.visuals {

  export class TestChart implements IVisual {
    private drawArea: HTMLElement;

    public static capabilities: VisualCapabilities = {
      dataRoles: [
        {
          name: 'Category',
          kind: VisualDataRoleKind.Grouping,
          displayName: data.createDisplayNameGetter('Role_DisplayName_Axis'),
        },
        {
          name: 'Y',
          kind: VisualDataRoleKind.Measure,
          displayName: data.createDisplayNameGetter('Role_DisplayName_Value'),
        },
        . . .

      ],
      dataViewMappings: [{
        conditions: [
          . . .

        ],
        categorical: {
          categories: {
            for: { in: 'Category' },
            dataReductionAlgorithm: { top: {} }
          },
          values: {
            . . .

          }
        },
      }],
      supportsHighlight: true,
      . . .

    }
    . . .

  }
}

下記の通り plugins.ts を記述して、Plug-in に VisualCapabilities を組み込みます。

module powerbi.visuals.plugins {
  . . .

  export var testChart: IVisualPlugin = {
    name: 'testChart',
    capabilities: TestChart.capabilities,
    create: () => new TestChart()
  };

}

なお、現状、ダウンロードした Project (上記) を使って VisualCapabilities の動作確認 (デバッグ) ができないようですが、この辺りは今後に期待したいと思います。。。(Contribution の前に、VisualCapabilities の動作も確認しておく必要がありますね。)

 

d3 の使用

実は、プロジェクトに含まれているサンプル コードを見ていただくとわかりますが、ほとんどが d3.js を使って記述されています。
この手の Visualization に適したさまざまな記述 (関数) も提供されていますし、更新データの再描画、アニメーション効果など、Visualization で必要な細やかな制御も数行で書けるので、現実の開発では、d3 を使用したほうがはるかに開発効率が高くなりますね。

例えば、下記は、上記の DataView を読み込み、d3.js を使って単純な棒グラフを表示するサンプル コードです。
現実の開発 (プログラミング) では、例えば、country や sales data 以外のデータが指定された場合でも扱えるように、DataView の metadata から動的に情報を取得して処理をおこなうなどしてください。(ソースコードが煩雑にならないように、今回は、こうした処理を省略していますので注意してください。)

module powerbi.visuals {

  export interface TestItem {
    country: string;
    amount: number;
  }

  export class TestChart implements IVisual {
    private svg: D3.Selection;
    private margin = { top: 20, right: 20, bottom: 30, left: 70 };
    private height: number;
    private width: number;

    public static capabilities: VisualCapabilities = {
      ...
    }

    public init(options: VisualInitOptions): void {
      // "this" scope will change
      var _this = this;

      // get height and width from viewport
      _this.height = options.viewport.height
        - _this.margin.top
        - _this.margin.bottom;
      _this.width = options.viewport.width
        - _this.margin.right
        - _this.margin.left;

      // append svg graphics
      _this.svg = d3.select(options.element.get(0)).append('svg')
        .attr('width', options.viewport.width)
        .attr('height', options.viewport.height)
        .append('g')
        .attr('transform',
          'translate(' + _this.margin.left + ',' + _this.margin.top + ')');
    }

    public update(options: VisualUpdateOptions) {
      // "this" scope will change
      var _this = this;

      // convert data format
      var dat =
        TestChart.converter(options.dataViews[0]);

      // setup d3 scale
      var xScale = d3.scale.ordinal()
        .domain(dat.map(function (d) { return d.country; }))
        .rangeRoundBands([0, _this.width], 0.1);
      var yMax =
        d3.max(dat, function (d) { return d.amount + 1000 });
      var yScale = d3.scale.linear()
        .domain([0, yMax])
        .range([_this.height, 0]);

      // remove exsisting axis
      _this.svg.selectAll('.axis').remove();

      // draw x axis
      var xAxis = d3.svg.axis()
        .scale(xScale)
        .orient('bottom');
      _this.svg
        .append('g')
        .attr('class', 'x axis')
        .style('fill', 'black') // you can get from metadata
        .attr('transform', 'translate(0,' + (_this.height - 1) + ')')
        .call(xAxis)
        .selectAll('text') // rotate text
          .style('text-anchor', 'end')
          .attr('dx', '-.8em')
          .attr('dy', '-.6em')
          .attr('transform', 'rotate(-90)');

      // draw y axis
      var yAxis = d3.svg.axis()
        .scale(yScale)
        .orient('left');
      _this.svg
        .append('g')
        .attr('class', 'y axis')
        .style('fill', 'black') // you can get from metadata
        .call(yAxis);

      // draw bar
      var shapes = _this.svg
        .selectAll('.bar')
        .data(dat);

      shapes.enter()
        .append('rect')
        .attr('class', 'bar')
        .attr('fill', 'green')
        .attr('stroke', 'black')
        .attr('x', function (d) {
          return xScale(d.country);
        })
        .attr('width', 10)
        .attr('y', function (d) {
          return yScale(d.amount);
        })
        .attr('height', function (d) {
          return _this.height - yScale(d.amount);
        });

      shapes
        .exit()
        .remove();
    }

    // convert data as following
    //
    //[
    //  "Australia",
    //  742731.43,
    //  123455.43
    //],
    //[
    //  "Canada",
    //  162066.43,
    //  40566.43
    //],
    //...
    //
    // -->
    //
    // [{"country" : "Australia", "amount" : 742731.43},
    //  {"country" : "Canada", "amount" : 162066.43},
    // ...]
    //
    public static converter(dataView: DataView): TestItem[]{
      var resultData: TestItem[] = [];

      for (var i = 0;
        i < dataView.table.rows.length;
        i++) {
        var row = dataView.table.rows[i];
        resultData.push({
          country: row[0],
          amount: row[1]
        });
      }

      return resultData;
    }

    public onResizing(viewport: IViewport, duration: number) {
      /* This API will be depricated */
    }

    public onDataChanged(options: VisualDataChangedOptions) {
      /* This API will be depricated */
    }

    public destroy(): void {
      /* This time, nothing to do */
    }
  }
}

この実行結果は、ご想像通り、下図のようになります。

 

ということで、New Power BI、リリースが楽しみですね。
作成した Visualization を iframe で Web に組み込める仕組みなども、現在、ユーザー フィードバックを受けて検討段階に入っています。皆さんも、どんどんフィードバックしましょう !

 

以前、本ブログでも紹介した E2D3 という国産の Open Source Project (Excel Add-in) をご存じでしょうか ?
Power BI では Business 系の Visualization (どちらかというと Report) が対象ですが、E2D3 は Data Journalism などのプロ メディアに代表される Visualization (むしろ、Infographics 的な表現) を目的としたアプリで、やはり「これまでプロの仕事であったデータ表現の世界をすべてのユーザーに解放する」という目的で提供されています。
Power BI のようなインメモリ技術等は使用できませんが、データ形式に制限はなく、Excel 上での加工と作成した表現の Web ページへの移植 (単なる iframe の挿入ではなく、d3 のソースごと移植する機能) が可能で、同様に、d3.js を使用した Developer (または Designer) による Contribution が可能です。

Data Visualization は、今後ますますホットになってきそうです。

 

※ 変更履歴 :

2015/10 Preview Update にあわせて記事を変更 (特に、以前は Visual Studio 2013 が使用可能でしたが、不可になりました)

 

Comments (0)

Skip to main content