【Shiny D3.js integration】Lesson01:Shinyからjavascriptにデータを渡す

Rの可視化プラットフォームであるShinyは非常に強力です。スライダや数値入力を使ってデータを再計算し、グラフを描画しなおしたりすることができ、インタラクティブな分析に有用です。一方で、Shinyで描画されるグラフ類はグラフそのものにはインタラクティブ性がありません。散布図の一部を拡大表示させたり、ツールチップを表示させたり、そういうことができるライブラリもありますが、どうしてもライブラリ依存になってしまいます。
Javascriptのデータ可視化機能も非常に強力です。様々な可視化ライブラリがありますが、やはりD3.jsが群を抜いて便利です。Javascriptはhtmlを直接操作することができ、描画したグラフそのものにインタラクティブ性をもたせる、というShinyが苦手としている部分が得意です。ところが、Javascriptは複雑な統計処理を行うのが苦手です。
ならば、お互いの苦手な部分を補い合って、複雑な統計処理部分をR,Shinyにやらせ、インタラクティブな可視化の部分をJavascript、主にD3.jsにやらせてみてはどうでしょうか?

というようなことを前々からやりたかったのですが、なかなかまとまった情報がなく、仕方なく自分で情報を集め、整理してみました。ひとつひとつ理解していかないとはまってしまうところが多々ありますので、少しずつ、進めていきたいと思います。

なお、この記事はShinyとJavascript、特にD3.js、jQueryあたりをある程度触ったことがある方を対象にしています。なるべく平易に書いていくつもりですが、はしょってしまう部分も出てくるかと思いますので、ご了承ください。

今回の最終目標

とはいえ、なんの目標もなく作り始めると続かないものです。というわけで、今回は次のような可視化インターフェイスを作ることを最終目標とします。

f:id:wanko_sato:20180527163600p:plain

おおまかな仕様としては、

  • 左側に箱ひげ図、群分けヒストグラム、箱ひげ図の外れ値を表示するグラフ
  • 右側に分ける群を選択するドロップダウンリスト、ヒストグラムのbinを設定するスライドバー
  • ドロップダウンリストで群を選択すると箱ひげ図とヒストグラムが該当する群で再計算され、再描画される
  • スライドバーでbinを変更するとヒストグラム区間幅が変わって再描画される
  • 箱ひげ図の箱部分をクリックすると、ヒストグラムがその群のみの表示となる
  • 箱ひげ図の箱部分をShift+クリックするとヒストグラムに描画される群が追加される

といったところです。右側のインターフェイスとグラフが連動しているのはShinyの基本仕様ですが、加えてグラフとグラフの間も何らかの操作に対して連動するようになっています。この動きはShinyだけでは実現が難しいところです。
また、グラフ-グラフ間の連携はJavascriptでも意外に面倒です。それを簡略化するため、グラフに対する操作をJavascriptで検知し、それをShinyで受け取り、別のグラフに投げる、という動きをさせています。ちょっと面倒そうに見えますが、全部をJavascriptで書くよりもずっと簡単です。

事前準備と前提条件

事前準備

以降の作業はAWS上に構築したRstudio serverとShiny serverで行います。環境構築にあたってはAMIを利用するのが簡単です。環境構築手順は下記の記事を参考にしてください。

wanko-sato.hatenablog.com

上記の記事に従うと、以下の環境が作られます。

  • Ubuntu 16.04 LTS
  • RStudio 1.1.383
  • R 3.4.2
  • Julia 0.6.0
  • CUDA 8

JuliaとかCUDAとかは今回は使いませんが、必要な方はお使いください。

前提条件

最初にも書きましたが、この記事はShinyとJavascriptの経験がある程度ある方を対象としています。といっても、そこまで深い知識は要求しません。ご自身でShinyアプリを作ってみた経験がある、なんとなくJavascriptを書いてみたことがある、程度で十分です。できればJavascriptの基本とD3.jsについてざっくり知っていると良いので、以下の本をお勧めします。

JavaScriptによるデータビジュアライゼーション入門

JavaScriptによるデータビジュアライゼーション入門

この3冊を読んでおけば、JavascriptおよびD3.jsによる可視化の概要はだいたいつかめると思います。特に三冊目のインタラクティブ・データビジュアライゼーション ―D3.jsによるデータの可視化はD3.jsのデータバインディングについて詳しく説明してあるので、最初はとっつきにくいデータバインディングについてきちんと理解できると思います。

というわけで、そろそろ始めましょう。

ディレクトリ構成

はまる可能性があるところは最初につぶしておきます。

f:id:wanko_sato:20180527170623p:plain

上の画像はAWSグローバルIPでRstudioにログインした画面のうち、右下に表示されるものです。Shiny serverのアプリにブラウザからアクセスする場合、HomeディレクトリのShinyApps内にアプリ名のディレクトリを作る必要があります。赤枠で囲んだように、今回はShinyApps>D3integration>lesson01の中にファイルを配置していきます。こうすると、

http://(グローバルIP):3838/(ユーザ名)/D3integration/lesson01/

でブラウザからアプリにアクセスできるようになります。ちなみにポート番号3838はAMIのデフォルト設定です。

もう一つ、上記の画像にwwwというディレクトリがありますが、Javascriptおよびcssファイルはこのwwwディレクトリの中に入っていなければなりません。でないとShinyがただしくファイルを読みにいってくれないようです。

※自分はここで少しはまりました。。。

今回のお題

ものすごく単純なところから始めましょう。
今回は「ボタンを押すと10個の正規分布に従う乱数が3組生成され、その要約統計量がli要素として書き込まれる」「スライダで正規分布の平均値を変えられる」の機能を持つものになります。

f:id:wanko_sato:20180527171521p:plain

今回はShinyからJavascriptにデータを渡す、という動きを確認していきます。

  • ShinyからJavascriptにどのようにデータを渡したら良いか
  • Shinyから渡されたデータはJavascriptではどのようなデータになるのか

この2点を重点的に確認していきます。

コーディングタイム

ファイルを作る

すでに上記のディレクトリ構成で示しましたが、まずファイルを作成します。今回は複数のファイルをいじっていくので、順番が大事です。
lesson01ディレクトリの直下に

  • app.R
  • customFunctions.r

の二つのファイルを作成し、lesson01/wwwディレクトの直下に

  • message.js

ファイルを作成してください。まだ中身は空で良いです。

app.Rはメインのファイルです。ブラウザからアクセスした場合、まずこのファイルが参照されます。app.Rの中でUI関数とserver関数を設定し、画面の動きを定義します。もちろん、動きをすべてapp.Rに書き込んでも良いのですが、それだと保守性が悪くなるため、一部の関数はcustomFunctions.rに出した方がやりやすいです。最後にmessage.jsはShinyからデータを受け取り、それをJavascriptで処理するコードが書かれています。

アプリの大枠を書く

どこから書いていくべきなのか、正直悩むところです。画面の構成は基本的にapp.Rで定義しますが、一部、htmlに書き込むcssへのリンク等は外だししているため、ある程度構成を考えていないと混乱してしまいます。
今回はある程度Shinyに慣れた方を対象にしていますので、まずはapp.Rの大枠を作るところから始めましょう。

app.R

source("customFunctions.r")

##########################################################################
# define UI

ui <- function(){

}

##########################################################################
# define server logic

server <- function(input, output, session){
  
}

# Run the application 
shinyApp(ui = ui, server = server)

これがapp.Rの大枠です。最初にcustomFunctions.rを読み込むsource関数を書き込んであります。そのあと、uiとserverを定義し、最後にshinyAppでuiとserverを指定します。この枠組みの中に、uiとserverを書き込んでいく感じになります。

UIを定義する

app.R

ui <- function(){
  fluidPage(
    fluidRow(
      column(width=9,
             libraryList(),
             sliderInput(inputId="sliderMean",
                         label="select mean for normal distribution",
                         min=-10,
                         max=10,
                         value=0),
             actionButton(inputId="btn1",
                          label="press button to generate rundom numbers"),
             testscript()
             )
    )
  )
}

UI部分はこんな感じです。Shinyに慣れた方なら、sliderInputとactionButtonは説明不要でしょう。問題はlibraryList()とtestscript()の部分です。ここはまだ定義しておらず、customFunctions.rで定義する部分です。では、customFunctions.rの該当部分を見てみましょう。

customFuctions.r

##########################################################################
# load library

library(shiny)
library(dplyr)
library(RJSONIO)
library(jsonlite)

##########################################################################
# write html tags
# called by ui

libraryList <- function(){
  tagList(
    singleton(tags$head(
      #load javascript libraries
      tags$script(src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.2/d3.min.js"), # D3.js
      tags$script(src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"), # underscore
      tags$link(href="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.6/nv.d3.css",rel="stylesheet",type="text/css"), #css for nvd3
      tags$script(src="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.6/nv.d3.js") # nvd3
    )
    )
  )
}

testscript <- function(){
  tagList(
    singleton(tags$head(
      #load custom javascript
      tags$script(src="message.js")
    )
    ),
    div(id="testtable",class="testclass")
  )}

まず最初にRの各種ライブラリを読み込んでいます。customFunctions.rが最初に読み込まれますので、ここでライブラリを読み込むのが適切です。
libraryList()は必要なJavascriptの外部スクリプトCSSを読み込む部分をhtmlに書き込む関数です。D3.js、underscore、nvd3をcloudfrareから読み込むよう、tags$script、tags$linkを使ってhtmlに書き込んでいます。
testscript()は加えて、今回カスタムで作成するmessage.jsを読み込ませるのに加え、divタグを生成します。message.jsはこのdivタグに対して要素を追加していく動きを定義します。また、testscript()はapp.RではactionButtonの下に書き込まれていますので、ここで定義したdivタグはactionButtonの下に書かれることになります。

serverを定義する

UI部分ができたので次にserverを定義します。

app.R

server <- function(input, output, session){

  # when push the button "btn1", generate 3 vectors gathered as data.frame
  # by calling function "generateRandom"
  # and throw to javascript connected by "hander1"
  observeEvent(input$btn1,{
    message1 <- data.frame(rand1 = generateRandom(input$sliderMean),
                           rand2 = generateRandom(input$sliderMean),
                           rand3 = generateRandom(input$sliderMean))
    session$sendCustomMessage("handler1",message1)
  })
  
}

コメントに書いたように、"btn1"というidをもったactionButtonが押される、つまりobserveEventがtrueになると、message1にデータフレームを格納し、そのデータフレームをsendCustomMessage関数でJavascriptに送ります。
このとき、乱数を生成するgenerateRandom()もcustomFunctions.rで定義してあります。この関数には引数としてsliderInputの値を渡すようにしてあります。sliderInputの値は正規分布の平均値を指定するものです。

customFunctions.r

##########################################################################
# generate random numbers followed by normal distribution as vector
# called by server-logic

generateRandom <- function(meanValue){
  rnorm(n=10,
        mean = meanValue,
        sd = 1)
}

generateRandom()関数は、10個の正規分布に従う乱数を生成する関数です。引数として受け取るmeanValueはrnorm関数のmeanオプションに渡されますので、sliderInputで指定した値はapp.Rでの動作を通じてこの関数に渡されることになります。

なお、ここで生成されるデータフレームは下記のようなものになります。

         rand1      rand2      rand3
1   0.01633233 -0.4931712 -1.2125886
2   0.39106158  0.8625300  0.1181408
3   1.71774868  2.0125740  1.1739632
4   0.50665550  0.4328938 -1.8877041
5   0.04509580 -0.4841872  0.6345670
6   0.52584753 -0.4782004  0.4963967
7  -0.99934707  0.1717911 -0.6538395
8   0.83418888  0.2028068  0.2931271
9  -3.13736640 -0.4739292 -0.9980760
10  0.79428250 -0.9417283 -0.4269410

rand1、rand2、rand3という列名を持つ10行3列のデータフレームです。これがsendCustomMessageでJavascriptに渡されていくわけですが、このデータがJavascriptでどのように扱われるのか、がこの記事のポイントの一つになります。

Javascriptの大枠を書く

では、Javascriptを書き込んでいきましょう。まずは大枠を書きます。

Shiny - How to send messages from the browser to the server and back using Shiny

上記URLのExample1に倣っています。

message.js

///get message from shiny via "handler1" and execute the function "doAwesomeThing"
Shiny.addCustomMessageHandler("handler1", doAwesomeThing);

var out;

///definition "doAwesomeThing"
///argument is "message1" from shiny
function doAwesomeThing(message1){

}

大枠としては、Shiny.addCustomMessageHandlerを最初に置き、"handler1"を受け取ったときに実行される関数doAwesomeThingを定義する、という流れです。doAwesomeThing関数はmessage1を引数として取りますが、これはShinyから受け取ったmessage1つまり10行3列のデータフレームです。
また、関数定義の前に

var out;

グローバル変数を定義していますが、これはブラウザのconsoleでデータを確認したりいじったりできるようにするためのものです。本来はグローバル変数はあまり多用すべきではないのですが、今回はテスト用途でもあるため、このような書き方をしています。

Javascriptで受け取ったデータを確認する

message.js

function doAwesomeThing(message1){

  ///to check the data from shiny, argument substitutes to global variable "out".
  ///argument "message1" is data-frame.
  out = message1;

  ///to check the data, show the argument by console.log
  ///see console, data-frame is converted to associate array.
  console.log(message1);
}

このようにdoAwesomeThingの関数の中身を書きます。まず

out = message1;

でShinyから受け取ったデータをグローバル変数outに代入します。こうすることで、ブラウザのコンソールからoutをいじることができるようになります。
※これは変数のスコープの話です。関数の中で定義された数はその関数の中でしか使われませんが、関数の外で定義された変数は関数の中からも外からも参照できるようになります。Javascriptにおける関数のスコープについては別途、書籍等で確認してください。

次に

console.log(message1);

でmessage1をコンソールに表示します。

さて、この状態でアプリを動作させてみましょう。

http://(グローバルIP):3838/(ユーザ名)/D3integration/lesson01/

にアクセスしてみてください。

データ形式を確認する

アクセスできたら、Chromeの検証ツールを立ち上げてください。Ctrl+Shift+Iで次のような画面になるはずです。

f:id:wanko_sato:20180527181210p:plain

右側下にconsoleというタブがあります。Javascriptの実行検証はこのconsoleを確認しながら行うのが主になります。
では、アプリのボタン"press button to generate rundom nubmers"を押してください。console.が次の画面のようになるはずです。

f:id:wanko_sato:20180527181336p:plain

このなかに{rand1: Array(10), rand2: Array(10), rand3: Array(10)}というところがあるのがわかるでしょうか。ここがconsole.logコマンドで表示させたmessage1になります。では、この中身を確認するため、グレーの三角形をクリックし、展開してみましょう。次の画像のようになるはずです。

f:id:wanko_sato:20180527181500p:plain

もともとShinyから渡したデータはデータフレームだったのでした。Javascriptでは{ }で囲んだものを連想配列、[ ]で囲んだものを配列としています。それに従ってデータを見ると、message1は連想配列として渡されていることがわかります。連想配列のkey名はrand1、rand2、rand3とデータフレームの列名になっています。さらに、各keyには配列が格納されており、その中身は10個の乱数です。
ということは、Shinyから渡されたデータフレームは、列名をkeyとし、その列全体を配列とした連想配列としてJavascriptに渡される、ということがわかります。これ、結構重要なのでしっかり押さえておいてください。
Javascriptが受け取るデータ形式がどのようなものかわかっていないと、D3.jsにデータを渡すときにおかしくなってしまうからです。

データを処理して出力する

後は要約統計量(平均値、標準偏差、N数)を計算してli要素に書き込むだけです。先ほどの関数に付け加えます。

message.js

function doAwesomeThing(message1){

  ///to check the data from shiny, argument substitutes to global variable "out".
  ///argument "message1" is data-frame.
  out = message1;

  ///to check the data, show the argument by console.log
  ///see console, data-frame is converted to associate array.
  console.log(message1);

  ///set blank array to store statistics.
  var mean = [];
  var sd = [];
  var n = [];

  ///for-loop by key of data-frame
  for (key in message1){
    ///to debug, each statistics are shown in console.
    ///each statistics are calculated by the function in d3.js.
    console.log(d3.mean(message1[key]), d3.deviation(message1[key]), message1[key].length);

    ///store each statistics to arrays by push.
    mean.push(d3.mean(message1[key]));
    sd.push(d3.deviation(message1[key]));
    n.push(message1[key].length);
  }

  ///each array storing statistics are shown in console.
  console.log(mean,sd,n);

  ///first of all, remove all "li" elements
  $("li").remove();
  ///append li elements to "div" whose id is "testtable" by jQuery
  ///when this function works, it looks like to change the values in the talbe.
  $('#testtable').append('<li>n : ' + n + '</li>')
                 .append('<li>mean : ' + mean + '</li>')
                 .append('<li>sd : ' + sd + '</li>');

}

まず要約統計量を格納する空の配列を用意します。この配列のスコープはこの関数の中だけですので、関数内でvarを使って宣言します。
次に、連想配列のkeyを使ってループさせます。JavascriptPythonではおなじみの書き方ですね。平均値と標準偏差はD3.jsに組み込まれている関数を使い、N数はlengthで算出します。算出された値はすでに宣言された配列に.pushで追加していきます。それらは確認用に適宜console.logでconsoleに出力します。

最後にjQueryを使ってli要素をid指定したdivタグの中に加えていきます。
ここまで書いてアプリを実行すると、

f:id:wanko_sato:20180527183118p:plain

こうなりました。

なんとなく雰囲気がつかめたのではないでしょうか。
では、最後にコード全体を再掲します。

コード全体

app.R

source("customFunctions.r")

##########################################################################
# define UI

ui <- function(){
  fluidPage(
    fluidRow(
      column(width=9,
             libraryList(),
             sliderInput(inputId="sliderMean",
                         label="select mean for normal distribution",
                         min=-10,
                         max=10,
                         value=0),
             actionButton(inputId="btn1",
                          label="press button to generate rundom numbers"),
             testscript()
             )
    )
  )

}


##########################################################################
# define server logic

server <- function(input, output, session){

  # when push the button "btn1", generate 3 vectors gathered as data.frame
  # by calling function "generateRandom"
  # and throw to javascript connected by "hander1"
  observeEvent(input$btn1,{
    message1 <- data.frame(rand1 = generateRandom(input$sliderMean),
                           rand2 = generateRandom(input$sliderMean),
                           rand3 = generateRandom(input$sliderMean))
    session$sendCustomMessage("handler1",message1)
  })
  
}

# Run the application 
shinyApp(ui = ui, server = server)


customFunctions.r

##########################################################################
# load library

library(shiny)
library(dplyr)
library(RJSONIO)
library(jsonlite)

##########################################################################
# write html tags
# called by ui

libraryList <- function(){
  tagList(
    singleton(tags$head(
      #load custom javascript
      tags$script(src="message.js"),
      #load javascript libraries
      tags$script(src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.2/d3.min.js"), # D3.js
      tags$script(src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"), # underscore
      tags$link(href="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.6/nv.d3.css",rel="stylesheet",type="text/css"), #css for nvd3
      tags$script(src="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.6/nv.d3.js") # nvd3
    )
    )
  )
}

testscript <- function(){
  tagList(
    singleton(tags$head(
      #load custom javascript
      tags$script(src="message.js")
    )
    ),
    div(id="testtable",class="testclass")
  )}

##########################################################################
# generate random numbers followed by normal distribution as vector
# called by server-logic

generateRandom <- function(meanValue){
  rnorm(n=10,
        mean = meanValue,
        sd = 1)
}


message.js

///get message from shiny via "handler1" and execute the function "doAwesomeThing"
Shiny.addCustomMessageHandler("handler1", doAwesomeThing);

var out;

///definition "doAwesomeThing"
///argument is "message1" from shiny
function doAwesomeThing(message1){

  ///to check the data from shiny, argument substitutes to global variable "out".
  ///argument "message1" is data-frame.
  out = message1;

  ///to check the data, show the argument by console.log
  ///see console, data-frame is converted to associate array.
  console.log(message1);

  ///set blank array to store statistics.
  var mean = [];
  var sd = [];
  var n = [];

  ///for-loop by key of data-frame
  for (key in message1){
    ///to debug, each statistics are shown in console.
    ///each statistics are calculated by the function in d3.js.
    console.log(d3.mean(message1[key]), d3.deviation(message1[key]), message1[key].length);

    ///store each statistics to arrays by push.
    mean.push(d3.mean(message1[key]));
    sd.push(d3.deviation(message1[key]));
    n.push(message1[key].length);
  }

  ///each array storing statistics are shown in console.
  console.log(mean,sd,n);

  ///first of all, remove all "li" elements
  $("li").remove();
  ///append li elements to "div" whose id is "testtable" by jQuery
  ///when this function works, it looks like to change the values in the talbe.
  $('#testtable').append('<li>n : ' + n + '</li>')
                 .append('<li>mean : ' + mean + '</li>')
                 .append('<li>sd : ' + sd + '</li>');

}


※.jsファイルはwwwディレクトリ内に配置するのを忘れずに!!!