HighMapsでmapbubbleな世界地図を

視覚化で国別の比較をする場合、世界地図に大小のバブルを描いて表現することがある。 これを HighCharts でやるとこんな感じだったというレポート。 完成版は一番下の方にあるので、急ぐ方はそちらを。

まず、 HighCharts には HighMaps なるパッケージがあって、これが地図関係の描画ツールになっている。 HighMaps demo も参照。

HighCharts は売り物なので、必要に応じて ライセンス を調達してください。 条件があえば無料で使えるようです。

白地図描画

これを使って マップバブル を描いていくわけだが、まずは世界地図を白地図状態で描画する。

MapBubble Chart 1

ソースはこれ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<html>
<head>
  <title>MapBubble Chart 1</title>
  <script src="//code.jquery.com/jquery-3.2.1.min.js"
          integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
          crossorigin="anonymous"></script>
  <script src="//code.highcharts.com/maps/highmaps.js"></script>
  <script src="//code.highcharts.com/maps/modules/data.js"></script>
  <script src="//code.highcharts.com/mapdata/custom/world.js"></script>
  <script src="//code.highcharts.com/modules/exporting.js"></script>
  <script src="//code.highcharts.com/modules/offline-exporting.js"></script>
</head>
<body>
  <script type="text/javascript">
  $(function() {
    var mapbopts1 = {
          chart: {renderTo: 'mapbchart1', map: 'custom/world'}
        , title: {text: 'Map Bubble Chart No.1'}
        , series: [
            {type: 'map', name: 'Countries', showInLegend: false, enableMouseTracking: false}
          ]
    };
    // console.log(mapbopts1);
    var mapbchart1 = new Highcharts.mapChart(mapbopts1);
  });
  </script>
  <div id="mapbchart1" style="width: 100%;"></div>
</body>
</html>
  • jQuery なしでもHighCharts は動くとのことだが、あとでCSVファイルを読み込んだりするので入れておく。

  • HighMaps に必要なのは、まず highmaps.js 本体と、地図のデータ(data.jsやworld.js)。exporting.jsとoffline-exporting.jsは印刷や画像としてのダウンロードのために必要。

  • まず設定集にあたる変数 mapbopts1 を作成する。
    • chart.renderTo でターゲットになる<div id=”mapbchart1”>を指定、地図はcustom/world
    • seriesには各系列のデータを入れていくのだが、今は白地図を描きたいのでtype=’map’な一行だけ。name:はとりあえずなんでも良い気がする。showInLegendは凡例に出したくないのでfalse、enableMouseTrackingをfalseにすることで、地図のオブジェクトに対するマウスの動きを監視しない(ということは表示が軽くなる)とのこと。
  • mapbopts1が出来たので、new Highcharts.mapChart(mapbobj1)で地図オブジェクトを作成。
    • High C hartsと書いてしばらくハマったのは秘密。
    • mapChart()を呼ぶのは結構大事で、普通のグラフ用の Chart() などとは初期値が変わるみたい。最大の利益は、mapChart()を呼ぶとズームできるようにした時に縦パンができることでこれはChart()を呼んだときにはどうしてもできなかった。他には mapbopts1 に [xy]Axis: {visible: false} が必要か否かとか。

一系列のmap bubble

白地図にデータを一系列入れる。ちょっとそれっぽくなってきたかも。

MapBubble Chart 2

ソースはこれ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<html>
<head>
  <title>MapBubble Chart 2</title>
  <script src="//code.jquery.com/jquery-3.2.1.min.js"
          integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
          crossorigin="anonymous"></script>
  <script src="//code.highcharts.com/maps/highmaps.js"></script>
  <script src="//code.highcharts.com/maps/modules/data.js"></script>
  <script src="//code.highcharts.com/mapdata/custom/world.js"></script>
  <script src="//code.highcharts.com/modules/exporting.js"></script>
  <script src="//code.highcharts.com/modules/offline-exporting.js"></script>
</head>
<body>
  <script type="text/javascript">
  $(function() {
    var mapbopts2 = {
          chart: {renderTo: 'mapbchart2', map: 'custom/world'}
        , title: {text: 'Map Bubble Chart No.2'}
        , plotOptions: {series: {events: {legendItemClick: function(event){return false}}}}
        , series: [
            {type: 'map', name: 'Countries', showInLegend: false, enableMouseTracking: false}
          , {type: 'mapbubble', id: 'Series-a', name: 'Series-A',
             data: [{name:'JP', z:80}, {name:'US', z:60}, {name:'CN', z:30}],
             joinBy: ['iso-a2', 'name']}
          ]
    };
    // console.log(mapbopts2);
    var mapbchart2 = new Highcharts.mapChart(mapbopts2);
  });
  </script>
  <div id="mapbchart2" style="width: 100%;"></div>
</body>
</html>
  • データは mapbopts2.series の地図データの続きに入れる。(22-24行目)
    • type:’mapbubble’なので、地図上の適切な位置にz値を円の大きさで表したもの(バブル)を描くことになる。
    • id はどうやらプログラム側からこの系列を区別するためのもので多分一意であることが必要。nameは名前ラベルなので一意でなくても良さそうだが、凡例に出るのでそのつもりで。(その識別のためにここではaとAにしてあるが普通は一緒でいい)
    • dataは配列で、一個一個の要素はnameにccTLD(JPとか)を指定してz値と組にしたもの。z値は要するにバブルの大きさなので、GDPとか人口とか表現したい数値を入れる。ここではダミーの点数を入れてあるが特に意味はない。ついでながら、これを通常z値と呼ぶのは、地図で二次元(xy)を使っているからってことかしらん。棒グラフを建てるイメージで言えば確かにzではある。
    • 地図情報(系列で言えばtype:’map’のあれ)の側と、このデータ系列の間の対応関係を指定しないといけない(でないとどこにバブルを置くべきかわからない)ので、joinBy: で指定する。ここでは、地図情報の側の iso-a2 カラムと、データ系列側の name で JOIN することになる。
  • 19行目のplotOptionsは、凡例の系列名をクリックしたときの動作を抑制している。
    • デフォルトではクリックによってその系列の表示・非表示をトグルするのだが、今は一系列しかないので消えてもらっては困る。そこで、legendItemClickに常にfalseを返す関数を渡してクリックしてもdefaultの動作をしないようにしている。
    • legendItemClickに渡す関数がtrueを返すとdefault動作(系列の表示・非表示トグル)を行い、falseならやらないということ。詳しくは legendItemClick を参照のこと。

複数系列のmap bubble

今度はデータ系列を複数にする。

MapBubble Chart 3

ソースはこれ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<html>
<head>
  <title>MapBubble Chart 3</title>
  <script src="//code.jquery.com/jquery-3.2.1.min.js"
          integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
          crossorigin="anonymous"></script>
  <script src="//code.highcharts.com/maps/highmaps.js"></script>
  <script src="//code.highcharts.com/maps/modules/data.js"></script>
  <script src="//code.highcharts.com/mapdata/custom/world.js"></script>
  <script src="//code.highcharts.com/modules/exporting.js"></script>
  <script src="//code.highcharts.com/modules/offline-exporting.js"></script>
</head>
<body>
  <script type="text/javascript">
  $(function() {
    var mapbopts3 = {
          chart: {renderTo: 'mapbchart3', map: 'custom/world'}
        , title: {text: 'Map Bubble Chart No.3'}
        , plotOptions: {
            series: {events: {legendItemClick: function(event) {
              if (this.visible) {
                this.update(this.options);
                return false
              };
              for (i in mapbchart3.series) {
                var s = mapbchart3.series[i];
                if (s.type == 'mapbubble') {
                  if (s.visible) {
                    s.setVisible();
                  }
                }
                s.update(s.options);
              }
              return true;
            }}}}
        , series: [
            {type: 'map', name: 'Countries', showInLegend: false, enableMouseTracking: false}
          , {type: 'mapbubble', id: 'Series-a', name: 'Series-A',
             data: [{name:'JP', z:80}, {name:'US', z:60}, {name:'CN', z:30}],
             joinBy: ['iso-a2', 'name']}
          , {type: 'mapbubble', id: 'Series-b', name: 'Series-B',
             data: [{name:'JP', z:40}, {name:'US', z:95}, {name:'CN', z:65}],
             joinBy: ['iso-a2', 'name'], visible: false}
          , {type: 'mapbubble', id: 'Series-c', name: 'Series-C',
             data: [{name:'JP', z:50}, {name:'US', z:25}, {name:'CN', z:90}],
             joinBy: ['iso-a2', 'name'], visible: false}
          ]
    };
    // console.log(mapbopts3);
    var mapbchart3 = new Highcharts.mapChart(mapbopts3);
  });
  </script>
  <div id="mapbchart3" style="width: 100%;"></div>
</body>
</html>
  • 41-46行目にデータ系列を追加した。この2系列には visible: false が追記されているが、これは初期描画時には系列を表示しないという意味。Series-Aだけは visible: true (default) なので描画される。
  • 20行目から legendItemClick: に渡される関数が変更されている。
  • 現在表示中の系列について凡例中のシンボルをクリックした時には、表示状態が変わらない(現在表示中のものを表示し続ける)ことが期待されるが、defaultの動作は表示状態の反転なので表示中のものを非表示にする結果としてすべての系列が表示されない状態になる。(下の状態遷移表を参照)
  • 現在表示中でない系列をクリックすると、その系列が表示されるとともに、現在表示中の系列を非表示にすることが期待されるが、defaultの動作では現在表示中の系列とクリックされた系列の2つの系列が同時表示された状態になる。
  • そこで legendItemClick:にfunctionを渡して期待される動作を実現しているのが20-35行目である。
  • 21-24行目 if (this.visible)… は現在表示中の系列をクリックした時で、this.update()によって表示アニメーションを実行し、return falseによってdefaultの動作を抑止している。
  • 25行目以降は現在表示中でない系列をクリックした場合で、25行目のfor文ですべての系列を探索して「表示中」のもの(s.visible==true)についてだけ表示状態を反転(s.setVisible())し、最後にreturn trueによってクリックされた系列の表示状態を反転(非表示から表示へ)している。
  • update()はオブジェクト再作成を行うので、系列のサイズによっては動作が重いかもしれない。ここでは2箇所で使っているが、こうしないとアニメーション再生がされずにいきなり最終サイズのバブルが出現した。この辺はちょっとよくわかっていない。
表示状態の系列をクリックしたら
Series Current Status Click default behaivier expected behaivier
A show click hide show
B hide - hide hide
C hide - hide hide
非表示状態の系列をクリックしたら
Series Current Status Click default behaivier expected behaivier
A show - show hide
B hide click show show
C hide - hide hide

ズームとパンニング

ここで、欧州や東南アジア、あるいはカリブ海諸国のような面積の点であまり大きくない国々についてもバブルを表示することを想像してみると、どうやらズームとパンニングの機能が必要であろうことに思い至る。

MapBubble Chart 4

ソースはこれ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<html>
<head>
  <title>MapBubble Chart 4</title>
  <script src="//code.jquery.com/jquery-3.2.1.min.js"
          integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
          crossorigin="anonymous"></script>
  <script src="//code.highcharts.com/maps/highmaps.js"></script>
  <script src="//code.highcharts.com/maps/modules/data.js"></script>
  <script src="//code.highcharts.com/mapdata/custom/world.js"></script>
  <script src="//code.highcharts.com/modules/exporting.js"></script>
  <script src="//code.highcharts.com/modules/offline-exporting.js"></script>
</head>
<body>
  <script type="text/javascript">
  $(function() {
    var mapbopts4 = {
          chart: {renderTo: 'mapbchart4', map: 'custom/world'}
        , title: {text: 'Map Bubble Chart No.4'}
        , mapNavigation: {enableDoubleClickZoom: true,
                          enableButtons: true,
                          buttonOptions: {verticalAlign: 'bottom'}}
        , plotOptions: {
            series: {events: {legendItemClick: function(event) {
              if (this.visible) {
                this.update(this.options);
                return false
              };
              for (i in mapbchart4.series) {
                var s = mapbchart4.series[i];
                if (s.type == 'mapbubble') {
                  if (s.visible) {
                    s.setVisible();
                  }
                }
                s.update(s.options);
              }
              return true;
            }}}}
        , series: [
            {type: 'map', name: 'Countries', showInLegend: false, enableMouseTracking: false}
          , {type: 'mapbubble', id: 'Series-a', name: 'Series-A',
             data: [{name:'JP', z:80}, {name:'US', z:60}, {name:'CN', z:30}],
             joinBy: ['iso-a2', 'name']}
          , {type: 'mapbubble', id: 'Series-b', name: 'Series-B',
             data: [{name:'JP', z:40}, {name:'US', z:95}, {name:'CN', z:65}],
             joinBy: ['iso-a2', 'name'], visible: false}
          , {type: 'mapbubble', id: 'Series-c', name: 'Series-C',
             data: [{name:'JP', z:50}, {name:'US', z:25}, {name:'CN', z:90}],
             joinBy: ['iso-a2', 'name'], visible: false}
          ]
    };
    // console.log(mapbopts4);
    var mapbchart4 = new Highcharts.mapChart(mapbopts4);
  });
  </script>
  <div id="mapbchart4" style="width: 100%;"></div>
</body>
</html>
  • 画面左下の +/- のボタンによってズームイン・アウトができるだけでなく、地図のどこかをダブルクリックすることでズームインできる。
  • 地図のどこかを掴んでドラッグすると上下左右にパンニングができる。ただし初期表示状態では画面サイズいっぱいに地図が描画されているのでパンニングしてまで表示する余り部分がないのでパンニング出来ない。
  • 19-21行目の mapNavigation: でこれらの設定をしている。

CSVからデータを読み込む

ここまでは options.series に直接にデータを埋め込んできたが、これを外部の CSV ファイルから読み込むことを考える。 データファイルとして次のようなCSVファイル mapbubble5.csv を使ってみよう。

cc2,Series-A,Series-B,Series-C
JP,80,40,50
US,60,95,25
CN,30,65,90
MapBubble Chart 5

ソースはこれ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<html>
<head>
  <title>MapBubble Chart 5</title>
  <script src="//code.jquery.com/jquery-3.2.1.min.js"
          integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
          crossorigin="anonymous"></script>
  <script src="//code.highcharts.com/maps/highmaps.js"></script>
  <script src="//code.highcharts.com/maps/modules/data.js"></script>
  <script src="//code.highcharts.com/mapdata/custom/world.js"></script>
  <script src="//code.highcharts.com/modules/exporting.js"></script>
  <script src="//code.highcharts.com/modules/offline-exporting.js"></script>
</head>
<body>
  <script type="text/javascript">
  $(function() {
    $.get('/_static/mapbubble5.csv',
      function(data) {
        var mapbopts5 = {
              chart: {renderTo: 'mapbchart5', map: 'custom/world'}
            , title: {text: 'Map Bubble Chart No.5'}
            , mapNavigation: {enableDoubleClickZoom: true,
                              enableButtons: true,
                              buttonOptions: {verticalAlign: 'bottom'}}
            , plotOptions: {
                series: {events: {legendItemClick: function(event) {
                  if (this.visible) {
                    this.update(this.options);
                    return false
                  };
                  for (i in mapbchart5.series) {
                    var s = mapbchart5.series[i];
                    if (s.type == 'mapbubble') {
                      if (s.visible) {
                        s.setVisible();
                      }
                    }
                    s.update(s.options);
                  }
                  return true;
                }}}}
            , series: [
                {type: 'map', name: 'Countries', showInLegend: false, enableMouseTracking: false}
              ]
        };
        var lines = data.split('\n');
        $.each(lines,
               function(lineNo, line) {
                 if (line == '') {
                   return true;    // continue to the next line.
                 }
                 var items = line.split(',');
                 $.each(items,
                        function(itemNo, item) {
                          if (lineNo == 0 && itemNo > 0) {
                            mapbopts5.series.push({type: 'mapbubble', id: item, name: item, data: [], joinBy: ['iso-a2', 'name'], visible: (itemNo == 1 ? true : false)});
                          } else if (lineNo > 0) {
                            for (i = 1; i <= itemNo; i++) {
                              mapbopts5.series[i].data.push({name: items[0], z: Math.round(items[i]*100)/100});
                            }
                          }
                        });
               });
      // console.log(mapbopts5);
      var mapbchart5 = new Highcharts.mapChart(mapbopts5);
    });
  });
  </script>
  <div id="mapbchart5" style="width: 100%;"></div>
</body>
</html>
  • まず、43行目にこれまであったはずのデータ系列3個がなくなっている。その代わりにCSVファイルからデータを読み込もうというのがお題。
  • そこで、これまでの mapbopts[1-4] 設定とその後の mapChart() 呼び出しを、CSV ファイル読み込み関数で包摂する。(16-17行目の$.get()とこれを閉じる66行目の }); を参照)
  • 45行目、mapbopts5の設定が終わったところで、mapbubble5.csv のデコードに入る。まずは読み込んだデータ(ファイル全体)を split(‘\n’) して行毎に分けて配列 lines に保持する。
  • 46行目、lines について1行づつ処理していく。
  • その処理は、まず例外処理であるが、1行に何も入っていない場合は次の行へ進む。(48-50行目)
  • 51行目、1行を ‘,’ で split() して要素毎の配列 items に保持する。
  • 52行目から、items について1要素づつ処理していく。
  • 54-55行目、1行目で、かつ、2番め以降の要素について、ということは “Series-A, Series-B, Series-C” の部分について、mapbopts5.series に系列を新設する形で push する。その内容は、type: ‘mapbubble’ 等の固定部分は直接書いてあるし、id:やname: にはその時扱っているitemの値を入れる。visible: については最初のアイテム(itemNo==1)のときだけ true で以降は falseを入れている。また、data: に空の配列 [] を入れている点に注意。ここにあとでデータを入れていくことになる。
  • 56-60行目、2行目以降の場合、ということは “JP,80,40,50” のような行の場合、一番目の要素は ccTLD で二番目以降の要素はそれぞれの系列のz値であるから、それをmapbopts5.seriesの適切なdataに追加していく。series[i]のiは2番め以降のitemNoなので、Series-A, Series-B,… といった各系列に該当する。items[0]はこの行の最初の要素なので ccTLD、また item[i] は各データの数値(z値)である。
  • z値を100倍して四捨五入し100で割っているのは、小数点下に多くの桁があるような数字を渡された時に tooltip が見にくくなるので小数点下2桁にしているのである。

まとめ

これで、データをCSVファイルで与えれば map bubble chart を描画する仕組みが一応完成した。 CSVファイルのカラムを増やせば(Series-D, Series-E,…)系列が増えるはずだし、各国のデータ行を増やせばバブルの数が増えるだろう。

この他、HighMaps にあるAPIの説明を参照すれば、更に細部の調整が可能であろう。例えば plotOptions.sizeBy を area から width に変更することで、バブルの相対的な大きさのスケールを変更できる。

(2017/Jul/02ごろ書いた)