89
76

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

D3.jsを使ってかっこいい相関図を書きたい

Last updated at Posted at 2018-10-24

自分用にまとめてた記事を公開します。

D3.jsを勉強中です。
D3.jsを使ってかっこいい相関図を書きたいなぁと思っていろいろ試行錯誤しています。
試行錯誤の元は、D3js.orgのExamplesの中にあったこれです。
(いつのまにやらv4系にアップデートされてますね)

mizerable

#基本的なお話
相関図のようなグラフの場合、上の画像の●をnode、引かれている線をlinkと呼びます。
あと、基本的にブラウザはChrome推奨です。

D3.jsで書いたけど、実際svgはどうかかれてるんだろうって思ったときは、svg領域上右クリックから開くことができるChromeの検証画面で確認しましょう。

debug.PNG

データを表示しようとしたら[object Object]と出てこまったら、以下のコマンドを差し込んで、データの中身をコンソールに表示させましょう。(dは適宜変更して・・・)

console.log(JSON.stringify(d));

余談ですが、レ・ミゼラブルは、ヒュー・ジャックマン版の映画を見ただけなのでよく知りません。原作はやたらと長いので読んでないです。

#やりたいこと
とりあえずやりたいことはこんな感じです。

  • ZoomやPanを実装したい
  • nodeの名前を表示したい
  • nodeの形を変えたい
  • node、linkにカーソルを合わせたら色を変えたい
  • linkを曲線にしたい
  • linkを矢印にしたい
  • nodeに画像を表示したい
  • node、linkにカーソルを合わせたら情報を表示させたい
  • node、linkをクリックしたら情報を表示させたい
  • nodeを検索してFocusしたい
  • node、linkの表示条件等を設定したい
  • etc...

なんだかいっぱいありますね(汗)。
まだまだ初心者なので、一気に全部実現するのは難しいので、これを少しずつ実現したいと思います。

#準備
まずは上記の試行錯誤元にあるソースとJSONファイルをDLしてwebサーバに設定しましょう。404が出ちゃうときは、​IISでIMMAにJSONファイをル登録すればOK。IISじゃない場合はググってください。(ここ以外は環境依存しないと思います)

上記のJSONファイルでは全てのnodeが何らかのlinkにつながっているので、テストのために、linkにつながっていないものを"nodes"と"links"の間あたり(80行目あたり)に追加しておきます。

miserables.json
    {"id": "Brujon", "group": 4},
    {"id": "Mme.Hucheloup", "group": 8},
    {"id": "You", "group": 11},         ←追加
    {"id": "Reader1", "group": 12},     ←追加
    {"id": "Reader2", "group": 12}      ←追加
  ],/*
  "links": [
    {"source": "Reader1", "target": "Reader2", "value": 2},  ←追加
    {"source": "Reader2", "target": "Reader1", "value": 2},  ←追加
    {"source": "Napoleon", "target": "Myriel", "value": 1},

これで、node単体の"You"と、独立したlinkを持つ"Reader1""Reader2"が追加になります。

JSONファイル
https://github.com/hirodos/D3Test/blob/master/miserables.json

次に、表示領域が狭くて解りにくいので、htmlファイルのsvg領域を広くして色をつけます。

miserables_1.html
<svg width="1024" height="768" style="background-color:#ddd"></svg>    <!-- ちょっと広くして色つけた-->

次に、この例ではnodeとlinkの設定は個別に設定したいので、<style>タグごと消してしまいます。

miserables_1.html
<!-- 
<style>

.links line {
  stroke: #999;
  stroke-opacity: 0.6;
}

.nodes circle {
  stroke: #fff;  
  stroke-width: 1.5px;
}
</style>
-->

ZoomやPanを実装したい

まずはこれ。実際に表示してみると判るのですが、上で追加したlinkを持たないnode"You"が表示領域外にすっ飛んで行きます(笑)。とりあえず、こいつを探しにいけるようにします。

詳細な説明はGitHub上のREADMEを見たほうがいいと思います。
https://github.com/d3/d3-zoom

ZoomやPanを実装するには、svgを使用する方法とHTML5のCanvasを利用する方法がありますが、ここではsvgを利用する方法を中心に記載します。
canvasを利用する方法では、canvasのイベントとしてD3.zoomを呼び出すようですが、svgを利用する方法では、透明な矩形を作って、その矩形のイベントとしてD3.zoomを呼び出して実装するようです。
svgでのサンプル
canvasでのサンプル

では実装しましょう。
##Zoomイベント追加

miserables_1.html
//-(追加:ここから)-----------------------------------------------
//"svg"にZoomイベントを設定
var zoom = d3.zoom()
  .scaleExtent([1/4,4])
  .on('zoom', SVGzoomed);

svg.call(zoom);

//"svg"上に"g"をappendしてdragイベントを設定
var g = svg.append("g")
  .call(d3.drag()
  .on('drag',SVGdragged))

function SVGzoomed() {
  g.attr("transform", d3.event.transform);
}

function SVGdragged(d) {
  d3.select(this).attr('cx', d.x = d3.event.x).attr('cy', d.y = d3.event.y);
    };
//-(追加:ここまで)-----------------------------------------------

d3.json("miserables.json", function(error, graph) {
  if (error) throw error;


##nodeとlinkのグループ変更

そして、nodeとlinkを透明な矩形があるグループ"g"にいれちゃう。

miserables_1.html

  var link = g.append("g")       //svg⇒gに
      .attr("class", "links")
    .selectAll("line")

miserables_1.html

  var node = g.append("g")       //svg⇒gに
      .attr("class", "nodes")
    .selectAll("circle")

Linkをドラッグするとなぜかchromeの検証画面でエラーがでてるので、なんとかしたい。
あと、最初に<style>を削除しているので、輪郭線の色指定を追加しておきます。

error1.PNG

miserables_1.html

  var link = g.append("g")  //svg⇒gに
      .attr("class", "links")
    .selectAll("line")
    .data(graph.links)
    .enter().append("line")
      .attr("stroke","#999")  //輪郭線の色指定追加
      .attr("stroke-width", function(d) { return Math.sqrt(d.value); })
      .call(d3.drag()               //無いとエラーになる。。
          .on('start', dragstarted)
          .on('drag', dragged)
          .on('end', dragended));

"link"にd3.dragイベントを設定したらなんかうまくいきました。
!!ここは後でちゃんと調べよう!!

これで、ZoomとPanが実装できました。イメージはこちら。
relation2.PNG

左下にある点がnode"You"です。ずいぶん遠くに飛んでいってますねw

[scaleExtent]で、Zoomの最大、最小を指定できますので、いい感じで調整しましょう。

実行イメージ
https://hirodos.github.io/D3Test/miserables_1.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_1.html

#nodeの名前を表示したい
次に、せっかく"id"に名前が入っているので、表示させます。
その前に、まず見た目を調整します。(このへんはセンスなので、設定値は参考にしないほうがいいかも・・・)

##d3.forceSimulationの設定
froceCollideを変更、charge、positioningX、positioningYを追加します。
velocityDecayとpositioningX、YとChargeのバランスで相関図のイメージが決まってきます。

miserables_2.html

var simulation = d3.forceSimulation()
      .velocityDecay(0.4)                                                     //摩擦
    .force('charge', d3.forceManyBody())                                      //詳細設定は後で
    .force('link', d3.forceLink().id(function(d) { return d.id; }))      //詳細設定は後で
    .force('colllision',d3.forceCollide(40))                               //nodeの衝突半径:Nodeの最大値と同じ
    .force('positioningX',d3.forceX())                                        //詳細設定は後で
    .force('positioningY',d3.forceY())                                        //詳細設定は後で
    .force('center', d3.forceCenter(width / 2, height / 2));                  //重力の中心

そして後ろのほうでcharge、positioningX、positioningYの詳細設定をします。

miserables_2.html
        simulation.force('link')
            .distance(200) //Link長
            .links(graph.links);
↓↓↓↓↓追加
        simulation.force('charge')
            .strength(function(d) {return -300})  //node間の力

        simulation.force('positioningX')   //X方向の中心に向けた引力
            .strength(0.04)

        simulation.force('positioningY')  //Y方向の中心に向けた引力
            .strength(0.04)
↑↑↑↑↑追加

設定したプロパティはこんな感じ

forceSimularion

プロパティ 設定値 説明
velocityDecay 0.4 摩擦力、1にすると全く動かない

force

プロパティ メソッド 設定値 説明
colllision D3.forcelCollide 40 nodeはこの距離で衝突する
center d3.forceCenter width / 2, height / 2 引力の中心座標
link d3.forceLink.distance 200 Linkの長さ
charge D3.forcelMenybody.strength -300 node間の力、正値は引力負値は斥力
positioningX d3.forceX.strength 0.04 centerに向けたX方向の引力
positioningY d3.forceY.strength 0.04 centerに向けたX方向の引力

##nodeの設定
丸の半径を20にして、線の太さを2にしました。

  var node = g.append("g")  //svg⇒gに
      .attr("class", "nodes")
    .selectAll("circle")
    .data(graph.nodes)
    .enter().append("circle")
      .attr("r", 20)   //5⇒20
      .attr("fill", function(d) { return color(d.group); })
      .style('stroke-width', '2')   //線の太さを2に設定
      .call(d3.drag()
          .on("start", dragstarted)
          .on("drag", dragged)
          .on("end", dragended));

この状態で表示させるとこんな感じになります。
relation3.PNG

##名前を表示

では、いよいよ名前を表示させましょう!
名前を表示させるには、"node"にtextをappendすればいいのですが、後々のことも考えて"node"を定義しなおして、そこに"Circle"と"text"をappendすることにします。
この時、D3.dragのイベントは、"node"に定義しておきます。

miserables_2.html

// nodeの定義
  var node = g.append('g')
      .attr('class', 'nodes')
    .selectAll('g')
    .data(graph.nodes)
    .enter()
    .append('g')
    .call(d3.drag()
        .on('start', dragstarted)
        .on('drag', dragged)
        .on('end', dragended));
        
// node circleの定義
  node.append('circle')
    .attr('r', 20)   //5⇒20
    .attr('stroke', '#ccc')
    .attr('fill', function(d) { return color(d.group); })
    .style('stroke-width', '2');   //線の太さを2に設定

//node textの定義
  node.append('text')
    .attr('text-anchor', 'middle')
    .attr('fill', 'black')
    .style('pointer-events', 'none')
    .attr('font-size', function(d) {return '10px'; }  )
    .text(function(d) { return d.id; });

そして、nodesの要素が連動して動くように、transformを設定します

miserables_2.html
  function ticked() {
    link
        .attr("x1", function(d) { return d.source.x; })
        .attr("y1", function(d) { return d.source.y; })
        .attr("x2", function(d) { return d.target.x; })
        .attr("y2", function(d) { return d.target.y; });

    node
        .attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { return d.y; })
        .attr('transform', function(d) {return 'translate(' + d.x + ',' + d.y + ')'}) //nodesの要素が連動して動くように設定
  }

これで名前が丸の中に表示されました。(はみ出してますがw)
relation4.PNG

実行イメージ
https://hirodos.github.io/D3Test/miserables_2.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_2.html

#nodeの形を変えたい
これまでnodeの形は丸だけでしたが、svgの基本図形は、丸、四角、楕円、その他とあるので、条件によって表示をかえます。
nodeの"group"が1~12まであるので、ためしに次のように設定してみます。
(面倒くさ・・・)

group shape Line size
1 circle solid r=20
2 circle dash r=20
3 rect solid x=40,y=30
4 rect dash x=40,y=30
5 ellipse solid rx=30,ry=20
6-7 ellipse dash rx=30,ry=20
8 polygon(hexagon) solid r=20
0,9-12 polygon(hexagon) dash r=20

実装の仕方としては、丸、四角、楕円、六角形を最初から作成しておいて、Visibleで切り替えるという方式をとります。
!!後でもっとシンプルな方法が無いか調べよう!!

実装の仕方としては、defsにテンプレート図形を設定しておいて、nodeに対象となる図形をuseすることにします。

破線判定関数を作成(Line)

miserables_3.html
//破線判定
  function stroke_dasharrayCD(d){
      var arr = [2,4,6,7,9,10,11,12,0]
      if (arr.indexOf(d.group) >= 0) {
        return "3 2"  //3:2の破線
      }
      else {
        return "none"  //破線なし
      }
  }

図形判定関数を作成(shape)

miserables_3.html
//図形判定
  function nodeTypeID(d){
    var nodetype
    var arrRect = [3,4]
    var arrEllipse = [5,6,7]
    var arrHexagon = [9,10,11,12,0]

    if(arrRect.indexOf(d) >= 0){
      //Rect
      return "rect"
    }
    else if(arrEllipse.indexOf(d) >= 0){
      //Ellipse
      return "ellipse"
    }
    else if(arrHexagon.indexOf(d) >= 0){
      //Hexagon
      return "hexagon"
    }
    else{
      //Circle
      return "circle"
    }
  }

defs定義

miserables_3.html
//使用するnode図形形状定義(中心座標は(0,0))
var Defs = svg.append("defs");

//Circle
var figCircle = Defs.append('circle')
      .attr("id","circle")
      .attr('r', 20);   //5⇒20

//Rect
var figRect = Defs.append('rect')
      .attr("id","rect")
      .attr('width', 40)
      .attr('height', 30)
      .attr('rx', 7)  //角を丸める
      .attr('ry', 7)  //角を丸める
      .attr('x', -(40/2))  //circleと中心を合わせる
      .attr('y', -(30/2));  //circleと中心を合わせる

//Ellipse
var figEllipse = Defs.append('ellipse')
      .attr("id","ellipse")
      .attr('rx', 30)
      .attr('ry', 20);

// hexagon ※pointsは反時計回りで定義すると他の図形と記述の順番の整合が取れる
var figHexagon = Defs.append('polygon')
      .attr("id","hexagon")
      .attr('points', "0,20 -17.3,10 -17.3,-10 0,-20 17.3,-10 17.3,10");

nodeの配置設定
nodeに"circle"をappendしていた部分を変更します。

miserables_3.html
/*
// node circleの定義
  node.append('circle')
    .attr('r', 20)   //5⇒20
    .attr('stroke', '#ccc')
    .attr('fill', function(d) { return color(d.group); })
    .style('stroke-width', '2');  //線の太さ
*/

  node.append("use")
    .attr("xlink:href",function(d) {return "#"+ nodeTypeID(d.group)})        //図形判定
    .attr('stroke', '#ccc')
    .attr('fill', function(d) { return color(d.group); })
    .style('stroke-width', '2')  //線の太さ
    .style('stroke-dasharray',function(d){return stroke_dasharrayCD(d)})  //破線判定

盛りだくさん感が出てきましたね。
relation5.PNG

実行イメージ
https://hirodos.github.io/D3Test/miserables_3.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_3.html

#node、linkにカーソルを合わせたら色を変えたい
これは簡単。mouseover、mouseoutイベントを設定します。

link

miserables_4.html
  var link = g.append("g")  //svg⇒gに
      .attr("class", "links")
    .selectAll("line")
    .data(graph.links)
    .enter().append("line")
      .attr("stroke","#999")  //輪郭線の色指定追加
      .attr("stroke-width", function(d) { return d.value })  //valueの値をそのまま使用
    .on('mouseover', function(){d3.select(this).attr('stroke', 'red');}) //カーソルが合ったら赤に
    .on('mouseout', function(){d3.select(this).attr('stroke', "#999");}) //カーソルが外れたら元の色に
    .call(d3.drag()               //無いとエラーになる。。
        .on('start', dragstarted)
        .on('drag', dragged)
        .on('end', dragended));

node
nodeも同様です。

miserables_4.html
// node 図形の定義
  node.append("use")
    .attr("xlink:href",function(d) {return "#"+ nodeTypeID(d.group)})        //図形判定
    .attr('stroke', '#ccc')
    .attr('fill', function(d) { return color(d.group); })
    .style('stroke-width', '2')  //線の太さ
    .style('stroke-dasharray',function(d){return stroke_dasharrayCD(d)})  //破線判定
    .on('mouseover', function(){d3.select(this).attr('fill', 'red')})  //カーソルが合ったら赤に
    .on('mouseout', function(){d3.select(this).attr('fill', function(d) { return color(d.group); })}) //カーソルが外れたら元の色に

実行イメージ
https://hirodos.github.io/D3Test/miserables_4.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_4.html

#linkを曲線にしたい
これまで、linkは"line"で引いていましたが、"path"に変更することで、曲線等の設定を簡単にできます。

サンプルはここにあります。

先ずlinkの定義をpathに変更します。何もしないと、線に囲まれた領域を塗りつぶしちゃうので、プロパティ"fill"を"none"に設定します。

miserables_5.html

  //linkの定義
  var link = g.append("g")  //svg⇒gに
      .attr("class", "links")
    .selectAll("path")    //line⇒Path
    .data(graph.links)
    .enter().append("path")    //line⇒Path
      .attr("stroke","#999")  //輪郭線の色指定追加
      .attr("fill","none")    //塗りつぶしなしを追加
      .attr("stroke-width", function(d) { return Math.sqrt(d.value); })
    .on('mouseover', function(){d3.select(this).attr('stroke', 'red');}) //カーソルが合ったら赤に
    .on('mouseout', function(){d3.select(this).attr('stroke', "#999");}) //カーソルが外れたら元の色に
    .call(d3.drag()               //無いとエラーになる。。
        .on('start', dragstarted)
        .on('drag', dragged)
        .on('end', dragended));

ticked()にあるlinkのプロパティ設定を変更し、pathの形状定義のための関数linkArc(d)を追加します。

miserables_5.html

  function ticked() {
    link
        .attr("d", linkArc);
/*        
        .attr("x1", function(d) { return d.source.x; })
        .attr("y1", function(d) { return d.source.y; })
        .attr("x2", function(d) { return d.target.x; })
        .attr("y2", function(d) { return d.target.y; });
*/
    node
        .attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { return d.y; })
        .attr('transform', function(d) {return 'translate(' + d.x + ',' + d.y + ')'}) //nodesの要素が連動して動くように設定
  }
});

function linkArc(d) {
  var dx = d.target.x - d.source.x,
      dy = d.target.y - d.source.y,
      dr = Math.sqrt(dx * dx + dy * dy);
  return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
}

linkArc()では"path"のプロパティ"d"を設定しています。詳細はこことか、いろいろググると良いと思います。(適当。。。)

ここでは"path"が円の1/4の弧を描くように、指定しています。指定項目のイメージは以下の通りです。rx,ryあたりをいじるともっと緩やかな弧を描くはずです。

svg1.PNG

relation5.PNG

この状態は結構気持ち悪いですね。放置しますけど(笑)ただ、ようやく左上にReader1⇔Reader2のlinkが両方表示されました。
あとは直線に戻すも、もっと曲げるも自由に設定できます。

実行イメージ
https://hirodos.github.io/D3Test/miserables_5.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_5.html

#linkを矢印にしたい
意外と矢印を書くのは厄介です。
一番やっかいなのは、linkがnodeの中心まで惹かれている為、今回のようにnodeの図形が異なっていた場合、矢印のマーカーをnodeの図形と重ならないように上手に位置をずらさないといけない点です。

##作戦
重要なポイントとして「厳密な位置設定をあきらめる」ことにしました。
nodeの図形の「輪郭線の長さ」と、「始点から指定された長さの座標」を取得することができます。
そこで、linkが形成する角度をもとに「輪郭線の長さ」と「始点から指定された長さ」の比率として、座標を取得することにします。

言葉で説明しても全くわからないので、図で示します。
まずは丸の場合です。「要するに角度の比を輪郭線の比として座標を求めよう」ってことです。

Intersection1.PNG

四角の場合も同じです。長方形の場合はたぶんずれますが、気付くほどはずれないでしょう。正n角形でnが大きくなればなるほどいい感じになります。
なお、circle,ecllipse,rectは始点から反時計回りに描画されますが、polygonは、'points'で指定した順番で描画されるので、この順番を合わせないと、polygonで描画した図形だけ、変な位置にlinkがつくので注意が必要です。

Intersection2.PNG

##図形の輪郭線の長さと描画始点を取得しよう
図形の輪郭線の長さを取得する為に、'path'の以下の関数を使用します。

関数 説明
Element.getTotalLength() パスの全長を返す
Element.getPointAtLength() 与えた長さにおける座標を返す{x,y}

始点の座標は、"getPointAtLength(0)"で取得できます。

miserables_6.html
//Circle
var figCircle = Defs.append('circle')
      .attr("id","circle")
      .attr('r', 20),   //5⇒20
    lenCircle = figCircle.node().getTotalLength(),
    spCircle = figCircle.node().getPointAtLength(0);
//Rect
var figRect = Defs.append('rect')
      .attr("id","rect")
      .attr('width', 40)
      .attr('height', 30)
      .attr('rx', 7)  //角を丸める
      .attr('ry', 7)  //角を丸める
      .attr('x', -(40/2))  //circleと中心を合わせる
      .attr('y', -(30/2)),  //circleと中心を合わせる
    lenRect = figRect.node().getTotalLength(),
    spRect  = figRect.node().getPointAtLength(0);

//Ellipse
var figEllipse = Defs.append('ellipse')
      .attr("id","ellipse")
      .attr('rx', 30)
      .attr('ry', 20),
    lenEllipse = figEllipse.node().getTotalLength(),
    spEllipse  = figEllipse.node().getPointAtLength(0);

// hexagon ※pointsは反時計回りで定義すると他の図形と記述の順番の整合が取れる
var figHexagon = Defs.append('polygon')
      .attr("id","hexagon")
      .attr('points', "0,20 -17.3,10 -17.3,-10 0,-20 17.3,-10 17.3,10"),
    lenHexagon = figHexagon.node().getTotalLength(),
    spHexagon  = figHexagon.node().getPointAtLength(0);

ここで取得した始点(spXXXX)は、あくまでも当該図形の中心を原点とした座標平面上のものであることに注意が必要です。

##角度の比率を求める関数を用意する
ここでは、pos1,pos2,pos3の3点の角度を求めます。
Mathクラスにあるatan2は指定した座標のアークタンジェントを求める関数です。戻り値はradianですので、2πで割ってあげると比率が求まります。

miserables_6.html

//三点の座標から線の比率を返す
function calcLoengthRate(pos1,pos2,pos3){
  var v21 = {x:pos1.x - pos2.x , y:pos1.y - pos2.y},
      v23 = {x:pos3.x - pos2.x , y:pos3.y - pos2.y},
      x   =  v21.x * v23.x + v21.y * v23.y,    //v21・v23
      y   =  v21.x * v23.y - v21.y * v23.x,    //v21×v23
      theta = Math.atan2(y,x); //角度(radian)
  if ( theta > 0){
    return theta / (2 * Math.PI);
  }
  else {
    return  1 + theta / (2 * Math.PI);
  };
}

##図形とlinkの交点を求める関数を用意する
図形の形状にcaseを分けて、交点を求めます。基本的に交点の求め方は全ての図形で同じです。処理は以下のとおり。

  1. 図形の始点(pos1)、図形の中心(pos2)、linkの反対側の図形の中心(pos3)の各座標を算出
  2. 角度の比率を求める関数"calcLoengthRate"にpos1-3を入力して比率を取得
  3. 比率に輪郭線の長さを掛けて交点までの長さを取得
  4. "getPointAtLength"で交点の座標を取得する
miserables_6.html
//node図形とlinkの交点座標を取得(d1側の座標)
function getIntersectionPos(d1,d2){
  var nodeID = nodeTypeID(d1.group);
  switch (nodeID) {
    case "rect":
      var pos1 = {x:d1.x + spRect.x , y:d1.y + spRect.y},
          pos2 = {x:d1.x , y:d1.y},
          pos3 = {x:d2.x , y:d2.y},
          rate = calcLoengthRate(pos1,pos2,pos3),
          dpos =  figRect.node().getPointAtLength(lenRect * rate);
      return {x:d1.x + dpos.x, y:d1.y + dpos.y}
      break;
    case "ellipse":
      var pos1 = {x:d1.x + spEllipse.x , y:d1.y + spEllipse.y},
          pos2 = {x:d1.x , y:d1.y},
          pos3 = {x:d2.x , y:d2.y},
          rate = calcLoengthRate(pos1,pos2,pos3),
          dpos =  figEllipse.node().getPointAtLength(lenEllipse * rate);
      return {x:d1.x + dpos.x, y:d1.y + dpos.y}
      break;
    case "hexagon":
      var pos1 = {x:d1.x + spHexagon.x , y:d1.y + spHexagon.y},
          pos2 = {x:d1.x , y:d1.y},
          pos3 = {x:d2.x , y:d2.y},
          rate = calcLoengthRate(pos1,pos2,pos3),
          dpos =  figHexagon.node().getPointAtLength(lenHexagon * rate);
      return {x:d1.x + dpos.x, y:d1.y + dpos.y}
      break;
    default:
      var pos1 = {x:d1.x + spCircle.x , y:d1.y + spCircle.y},
          pos2 = {x:d1.x , y:d1.y},
          pos3 = {x:d2.x , y:d2.y},
          rate = calcLoengthRate(pos1,pos2,pos3),
          dpos =  figCircle.node().getPointAtLength(lenCircle * rate);
      return {x:d1.x + dpos.x, y:d1.y + dpos.y}
  }
}

##Linkに反映
linkの'path'プロパティ"d"を設定するために作った関数linkArc()に交点の座標を反映させます。
下のソースでは、"srcPos"が始点の座標、"tgtPos"が終点の座標となります。

miserables_6.html
function linkArc(d) {
  var dx = d.target.x - d.source.x,
      dy = d.target.y - d.source.y,
      dr = Math.sqrt(dx * dx + dy * dy),
      srcPos = getIntersectionPos(d.source , d.target),
      tgtPos = getIntersectionPos(d.target , d.source);
      return "M" + srcPos.x + "," + srcPos.y + "A" + dr + "," + dr + " 0 0,1 " + tgtPos.x + "," + tgtPos.y;
}

##矢印を設定
矢印を設定するには"marker"指定を使用します。
基本的な考え方は、pathやらcircleやらで作っておいたmarker図形をlinkにくっつけるというやり方になります。

矢印の場所は、pathの設定で、marker-start、marker-mid、marker-endの設定画可能です。
marker1.PNG

"marker"は独自のViewBoxをもち、はみ出ると表示が切れてしまいます。
線の太さ(strokewidth)も考慮して設定しましょう。
イメージは以下のとおりです。詳細はこことか、いろいろググると良いと思います。(いつもどおり。。。)
marker2.PNG

"marker"は、上図のとおりのものをpathで設定しています。色はとりあえず背景色と同じにしているので白抜き矢印のように見えるはずです。

修正のポイントのもう1点は、"marker"の色を"path"と一緒に変更したい場合、同じグループ配下に配置するという点です。
そのため、linksというグループを作成し、これまで設定していたイベント類をlinksに設定するようにしています。
それにともない、linkの輪郭線の色指定を削除しています。

miserables_6.html
//Linksの定義
  var links = g.append("g")  //svg⇒gに
      .attr("class", "links")
    .selectAll("g")
    .data(graph.links)
    .enter()
    .append("g")
      .attr("class", "linkArrow")
      .attr("fill", "#999")
      .attr("stroke","#999")  //輪郭線の色指定追加
      .on('mouseover', function(){
            d3.select(this).attr('stroke', 'red'); //カーソルが合ったら赤に
          })
      .on('mouseout', function(){d3.select(this).attr('stroke', "#999"); //カーソルが外れたら元の色に
          }) 
      .call(d3.drag()               //無いとエラーになる。。
          .on('start', dragstarted)
          .on('drag', dragged)
          .on('end', dragended));

//Markerの定義
  var marker = links.append("marker")
      .attr("id", function(d) { return "mkr" + d.source+d.target })
      .attr("viewBox", "0 0 20 20")
      .attr("markerWidth", 10)
      .attr("markerHeight", 10)
      .attr("refX", 19)
      .attr("refY", 10)
      .attr("orient", "auto-start-reverse")

  marker.append("path")
      .attr("d", "M0.5,0.75L18.88,10L0.5,19.25z")
      .attr("fill","#ddd")          //背景色と同じ
      .attr("stroke-width", 1)

//linkの定義
  var link = links.append("path")    //line⇒Path
//      .attr("stroke","#999")  //輪郭線の色指定追加
      .attr("marker-start",function(d) { return "url(#mkr" + d.source+d.target  + ")"})
      .attr("marker-end",function(d) { return "url(#mkr" + d.source+d.target  + ")"})
      .attr("fill","none")    //塗りつぶしなしを追加
      .attr("stroke-width", function(d) { return Math.sqrt(d.value); })
      .attr("stroke-dashoffset", 0)

いっぱい修正しましたが、実装のイメージは以下のとおりです。
relation7.PNG

見えにくいので拡大すると、以下のような感じ。
relation8.PNG

ごちゃごちゃしてきたので、きれいに見えるようにする為には少し調整したほうがいいとは思いますが、とりあえず目的は達成したので良しとしましょう(笑)

あと、やっぱりちょっと重いです。実際に使う為には軽量化は考えないといけないでしょうね。

実行イメージ
https://hirodos.github.io/D3Test/miserables_6.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_6.html

#nodeに画像を表示したい
正直、画像を表示したい気持ちは全く無いのですが、node情報にいくつか情報を追加して、表示させることにしてみます。

とりあえず、適当に画像を用意します。(主要人物のみ・・・)

image
実際は写真をDLしてますが、とりあえずは「いらすとや」で。(unknownだけは書きました)

##JSONファイルの修正
主要人物のJSONファイルに項目を追加します。以降のこともあるので、"image"として画像のパスを、"memo"として説明の文章を追加しました。
説明の文章はとりあえずwikipediaの説明をコピーしています。
これまでのデータと区別する為に修正したJSONファイルを"miserables2.json"という名前で保存します。

miserables2.json

    {"id": "Valjean", "group": 2, "image":"image/Valjean.jpg","memo":"1769年にブリーのファヴロール (Faverolles) の貧しい農家の子供として生まれた。・・・(略)"},
    {"id": "Fantine", "group": 3, "image":"image/Fantine.jpg" ,"memo":"1796年生まれの、美しい髪と前歯を持つ可憐で純粋な美女。・・・(略)"},
    {"id": "Mme.Thenardier", "group": 4, "image":"image/Mme.Thenardier.jpg" ,"memo":"テナルディエの妻(ファーストネームは不明)。・・・(略)"},
    {"id": "Thenardier", "group": 4, "image":"image/Thenardier.jpg" ,"memo":"「テナルディエ」とは苗字であり、ファーストネームは不明。・・・(略)"},
    {"id": "Cosette", "group": 5, "image":"image/Cosette.jpg" ,"memo":"コゼットというのはファンティーヌがつけた愛称で、本名はユーフラジー (Euphrasie)。・・・(略)"},
    {"id": "Javert", "group": 4, "image":"image/Javert.jpg" ,"memo":"1780年に、服役囚の父と、同じく服役囚のトランプ占いのジプシー女の子供としてトゥーロンの徒刑場で生まれた、ブルドッグのような顔つきの男。・・・(略)"},

JSONファイルのファイル名を変更したのでJSONファイル呼び出し部を修正します。

miserables_7.html

d3.json("miserables2.json", function(error, graph) {  //miserables2.jsonに変更
  if (error) throw error;

そして、text定義の下に、image定義を追加します。
JSONの"image"にパスが指定されていたら、そのパスを表示します。
"image"の指定が無かったら、"unknown.jpg"を表示します。
ここで指定する座標はnodeの中心を原点とする座標系です。

miserables_7.html
//node textの定義
  node.append('text')
    .attr('text-anchor', 'middle')
    .attr('fill', 'black')
    .style('pointer-events', 'none')
    .attr('font-size', function(d) {return '10px'; }  )
    .attr('font-weight', function(d) { return 'bold'; }  )
    .text(function(d) { return d.id; });

  //node imageの定義
    node.append('image')
      .attr('xlink:href', function(d) {if (typeof d.image === "undefined") {return  "image/unknown.png" } else { return d.image }})
      .attr('width',30)
      .attr('x',-15)
      .attr('y',-40)

作成したイメージは以下のとおり。
image

とりあえずはこんなもんでしょうw

実行イメージ
https://hirodos.github.io/D3Test/miserables_7.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_7.html

#node、linkにカーソルを合わせたら情報を表示させたい
ちょっとした情報を表示するだけなら、titleを指定するだけなので、簡単です。
現在も、nodeには"id"を表示するように設定されています。

miserables_7.html
  node.append("title")
      .text(function(d) { return d.id; });

ここでは、もう少し詳しい情報を表示できる様にしようと思います。
元ねたはこちらです。

##情報表示画面の設定

まず、styleタグに以下の設定を行います。実際はCSSに書いたほうがいいです。

miserable_8.html
#datatip{
	padding: 10px 20px;
	font-family: Verdana, arial;
	width: 240px;
	height: auto;
	position: absolute;
	border: 1px solid black;
	background-color: white;
	border-radius: 5px;
	opacity: 0.0;
	left: 0;
	top: 0;
	background-position: 10px 7px, 220px 7px;
	background-size: 50px auto, 50px auto;
	background-repeat: no-repeat , no-repeat;
}

#datatip h2 {
	font-size: 18px;
	padding-bottom: 5px;
  padding-left: 5px;
	margin-left: 50px;
}

#datatip p {
	font-size: 14px;
}
</style>

次に以下のdivタグを設定します。デザインは上記CSSにお任せです。

miserable_8.html
<div id="datatip">
	<h2></h2>
	<p></p>
</div>

最後に、scriptタグ内に上記divタグで設定した情報を定義します。

miserable_8.html
//カーソルを合わせたときに表示する情報領域
var datatip = d3.select("#datatip");

これで、枠ができました。

##イベントの設定
今回は、nodeとlineにカーソルを合わせたときの設定なので、nodeとlineの'mouseover','mousemove','mousuout'イベントに処理を仕込みます。
'mouseover','mousuout'イベントは、カーソルを合わせたときに色を変える際に追加しているので、基本的にはその場所に処理を追加するイメージです。

###nodeにイベントを追加
以下のとおり、イベントを設定します。
追加したのは、'mousemove'イベントと、datatip.で始まる文です。
表示内容は適当に決めています。
うっとうしいので、nodeの"title"設定は削除しておくといいです。

miserables_8.html
  node.append("use")
    .attr("xlink:href",function(d) {return "#"+ nodeTypeID(d.group)})        //図形判定
    .attr('stroke', '#ccc')
    .attr('fill', function(d) { return color(d.group); })
    .style('stroke-width', '2')  //線の太さ
    .style('stroke-dasharray',function(d) {return stroke_dasharrayCD(d)})  //破線判定
    .on('mouseover', function(d){
          d3.select(this).attr('fill', 'red'); //カーソルが合ったら赤に
          datatip.style("left", d3.event.pageX + 20 + "px")
                  .style("top", d3.event.pageY + 20 + "px")
                  .style("z-index", 0)
                  .style("opacity", 1)
                  .style("z-index", 0)
                  .style('background-image', function() {if (typeof d.image === "undefined" ) {return  'url("image/unknown.png")' } else { return 'url("'+ d.image + '")'}})


          datatip.select("h2")
                  .style("border-bottom", "2px solid " +color(d.group))
                  .style("margin-right", "0px")
                  .text(d.id);

          datatip.select("p")
                  .text("グループID:" + d.group );
      })
    .on('mousemove', function(){
          datatip.style("left", d3.event.pageX + 20 + "px")
                  .style("top", d3.event.pageY + 20 + "px")
      })
    .on('mouseout', function(){
          d3.select(this).attr('fill', function(d) { return color(d.group); })  //カーソルが外れたら元の色に
          datatip.style("z-index", -1)
                 .style("opacity", 0)
      })

###linkにイベントを追加
nodeと同様に以下のとおり、イベントを設定します。
追加したのは、'mousemove'イベントと、datatip.で始まる文です。
表示内容は適当に決めています。
イベント内でJSONファイルの"source"や"target"の情報を取得する際、d.source,d.targetではなく、d.source.id、d.target.idで取得することになるのは、便利ですが初見殺しの感じですw

miserables_8.html
  var links = g.append("g")  //svg⇒gに
      .attr("class", "links")
    .selectAll("g")
    .data(graph.links)
    .enter()
    .append("g")
      .attr("class", "linkArrow")
      .attr("fill", "#999")
      .attr("stroke","#999")  //輪郭線の色指定追加
      .on('mouseover', function(d){
	          d3.select(this).attr('stroke', 'red'); //カーソルが合ったら赤に
	          datatip.style("left", d3.event.pageX + 20 + "px")
	                  .style("top", d3.event.pageY + 20 + "px")
	                  .style("z-index", 0)
	                  .style("opacity", 1)
	                  .style('background-image',function(){
	                      if (typeof d.source.image === "undefined" ){
	                          if (typeof d.target.image === "undefined" ){
	                              return  'url("image/unknown.png"), url("image/unknown.png")'
	                          }
	                          else {
	                              return  'url("image/unknown.png"), ' + 'url("'+ d.target.image + '")'
	                          }
	                      }
	                      else {
	                          if (typeof d.target.image === "undefined" ){
	                              return 'url("'+ d.source.image + '"), ' + 'url("image/unknown.png")'
	                          }
	                          else {
	                              return 'url("'+ d.source.image + '"), url("'+ d.target.image + '")'
	                          }
	                      }
	                  });

	          datatip.select("h2")
	                  .style("border-bottom", "2px solid " +color(d.source.group))
	                  .style("margin-right", "50px")
	                  .text("value:" + d.value);

	          datatip.select("p")
	                  .text(d.source.id + " to " + d.target.id);
        })
      .on('mousemove', function(){
	          datatip.style("left", d3.event.pageX + 20 + "px")
	                  .style("top", d3.event.pageY + 20 + "px")
				 })
      .on('mouseout', function(){
	          d3.select(this).attr('stroke', "#999"); //カーソルが外れたら元の色に
	          datatip.style("z-index", -1)
	                 .style("opacity", 0)
        })

ここまでして何なのですが、個人的にはd3.forceを使用した図には「node、linkにカーソルを合わせたら情報を表示させる」のは向いていない気がしますw
情報の閲覧性が低下するので、素直に次項の「クリックして情報表示」だけにしておく方がいいですね。
どうしてもという場合は素直に"title"を設定しておく程度にとどめたほうがいいと思います。

出来上がりのイメージはこんな感じです。

popup1.png

image

実行イメージ
https://hirodos.github.io/D3Test/miserables_8.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_8.html

#node、linkをクリックしたら情報を表示させたい
設定自体は簡単で、「node、linkにカーソルを合わせたら情報を表示させたい」と同様にイベントを設定するだけです。
問題は情報をどこに表示させるかですが、次の「やりたいこと」の枠を作ることもかねて、サイドに表示欄を作ることにします。
画面サイズにあわせた調整等は、いつの日か考えることにして一旦忘れておきます。
ここでは手元のディスプレイサイズではみ出さないように、svgの領域を再調整しています。

miserable_9.html
<svg width="980" height="810" style="background-color:#ddd"></svg>    <!-- ちょっと広くして色つけた-->

<!-- カーソルを合わせたときに表示する情報領域-->
<div id="datatip">
  <h2></h2>
  <p></p>
</div>

<!-- サイドバー設定-->
<div id="sidebar">

  <section id= "side_setting">
    <h2>Setting</h2>
    <input id="rng_zoom" type="range" min="-2" max="2" value="0" step="0.1">
    Zoom: <span id="r_zoom_text">100</span>
    <input id="rng_link" type="range" min="0" max="31" value="0" step="1">
    Link: <span id="r_link_text">0</span>
    <br>
    Group  : <input id="num_group" type="number" min="0" max="12" value="" step="1">
    <input type="submit" value="set" id="btn_group">
  </section>

  <section id= "side_search">
    <h2>Search</h2>
    <input type="text" name="group_id" id="txt_search">
    <input type="submit" value="検索" id="btn_search">
  </section>

  <section id= "side_data">
    <h2>Data</h2>
    <h3></h3>
    <iframe id="data_memo" seamless height=300 width=220 sandbox="allow-same-origin"></iframe>
    <iframe id="data_relation" seamless height=140 width=220 sandbox="allow-same-origin"></iframe>
  </section>
</div>

合わせてCSSも適当に設定しておきます。

miserable_9.html
#sidebar {
  width: 250px;
  float: left;
}

#side_setting{
  padding: 5px 10px;
  font-family: Verdana, arial;
  width: auto;
  height: 140px;
  border: 1px solid black;
  background-color: white;
  border-radius: 5px;
  left: 0;
  top: 0;
}

#side_setting h2{
  font-size: 18px;
  padding-bottom: 5px;
  border-bottom: 2px solid gray
}

#side_search{
  padding: 5px 10px;
  font-family: Verdana, arial;
  width: auto;
  height: 100px;
  border: 1px solid black;
  background-color: white;
  border-radius: 5px;
  left: 0;
  top: 0;
}

#side_search h2{
  font-size: 18px;
  padding-bottom: 5px;
  border-bottom: 2px solid gray
}

#side_data{
  padding: 5px 10px;
  font-family: Verdana, arial;
  width: auto;
  height: 540px;
  border: 1px solid black;
  background-color: white;
  border-radius: 5px;
  background-position: 160px 7px;
  background-size: 70px auto;
  background-repeat: no-repeat;
  left: 0;
  top: 0;
  opacity: 0.0;
}

#side_data h2{
  font-size: 18px;
  padding-bottom: 5px;
  border-bottom: 2px solid gray;
  margin-right: 100px;
}

#side_data h3 {
  font-size: 14px;
}

こんな感じです。今回使用するのは一番下の"Data"欄だけで、そのほかはあとで使うので、今はただのモックです。
sidebar.PNG

実装部分は、最初に記載したとおりシンプルです。
今回はnodeの情報とそのnodeにつながるlink先の情報を表示させています。

まず、読込んだJSONファイルの情報を他の場所でも利用できるように、glovalな変数 jsondataを定義し、値を格納します。
これは、もっとスマートな方法があるかもしれません。

miserables_9.html
//Jsonfileのデータを保持
var jsondata;

d3.json("miserables2.json", function(error, graph) {  //miserables2.jsonに変更
  if (error) throw error;

//jsondataに値を格納
 jsondata = graph;

次に、nodeの定義にmousedownイベントを追加します。

miserables_9.html
// nodeの定義
  var node = g.append('g')
      .attr('class', 'nodes')
    .selectAll('g')
    .data(graph.nodes)
    .enter()
    .append('g')
    .on('mousedown', set_sidedata)  //情報をsidebarに表示するためのmousedownイベント
    .call(d3.drag()
        .on('start', dragstarted)
        .on('drag', dragged)
        .on('end', dragended));

そしてmousedownイベントが発生したときの処理set_sidedataを追加します。

miserables_9.html
//Sidedataに情報をセット
function set_sidedata(d){
//画像表示
  sidedata.style("z-index", 0)
          .style("opacity", 1)
          .style('background-image', function() {if (typeof d.image === "undefined" ) {return 'url("image/unknown.png")' } else { return 'url("'+ d.image + '")'}})

//node名表示
  sidedata.select("h3")
          .text(d.id)

//nodeのメモ表示
  sidedata.select("#data_memo")
          .attr("srcdoc",function(){
            if (typeof d.memo === "undefined" ){
              return "<p style='font-size: 11px'></p>"
            }
            else {
              return "<p style='font-size: 11px'>" +d.memo.replace(/\n/g,"<br>") +"</p>"
            }
          })

//nodeにつながるLink表示
  sidedata.select("#data_relation")
          .attr("srcdoc",function(){
            var r_value ="",
                r_target =jsondata.links.filter(function(item,index){if(item.source.id == d.id ) return true}),
                r_source =jsondata.links.filter(function(item,index){if(item.target.id == d.id ) return true});
            for (var key in r_target) {
              r_value = r_value + "to: " + r_target[key].target.id + "<br>"
            }
            for (var key in r_source) {
              r_value = r_value + "from: " + r_source[key].source.id + "<br>"
            }
            return "<p style='font-size: 11px'>" + r_value +"</p>"
          })
  }


めんどくさいので、<iframe>にしなけりゃ良かったと思いましたw
出来上がりのイメージはこちら。

data01.PNG

実行イメージ
https://hirodos.github.io/D3Test/miserables_9.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_9.html

#nodeを検索してFocusしたい
正直、検索なんて「ctrl+f」でやればいいのですが、もうちょっとすてきなかんじにしたいので、検索機能を追加します。
枠だけは、「node、linkをクリックしたら情報を表示させたい」のところでつくってあるので、処理を実装させるだけです。
<section id= "side_search">に配置した、"textBox"と"btn_search"をつかいます。
最初に"btn_search"にonclickイベントを追加します。

miserables_10.html
  <section id= "side_search">
    <h2>Search</h2>
    <input type="text" name="group_id" id="txt_search">
    <input type="submit" value="検索" id="btn_search" onclick="OnClickSearch();">
  </section>

そして、検索処理を追加します。
Textに入力された文字がidと一致(大文字小文字を区別)するnodeがある場合、そのノードにズームインして、sidebarに情報を表示します。
検索の方法については、検討の余地がありそうです。

miserables_10.html
function OnClickSearch(){
  var search_val = document.getElementById("txt_search").value
  if (search_val == ""){
    window.alert("検索文字列を入力して下さい")
  }
  else{
    var search_data = jsondata.nodes.filter(function(d){if (d.id == search_val) return true})[0]
    if  (typeof search_data === "undefined") {
      window.alert("無効な検索文字列です");
      document.getElementById("txt_search").value = "";
    }
    else {
   //対象先にズームイン
      svg.transition()
        .duration(750)   //Zoomにかかる時間
        .call(zoom.transform, transform(search_data));
    //sidedataにデータを表示
      set_sidedata(search_data);

    }
  }
}

zoom.transformで呼び出す関数transform()を指定します。
ここで滑らかにZoomするときの設定を行っています。

miserables_10.html
function transform(d) {
  return d3.zoomIdentity
      .translate(width / 2, height / 2)     //一旦中央に
      .scale(4)                             //とりあえず4倍でZoomIn
      .translate(-d.x, -d.y);               //指定nodeの座標
}

結果のイメージは以下のとおりです。もう図だとわかりませんね(汗)

search.png

実行イメージ
https://hirodos.github.io/D3Test/miserables_10.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_10.html

#node、linkの表示条件等を設定したい
ここでは「node、linkをクリックしたら情報を表示させたい」で追加した"Setting"欄に次の3つを実装します。
・link.valueによるLink表示制限
・node.groupによるNode表示制限
・Zoomの画面コントロール連動

##Link.valueによるLink表示制限
画面にあるスライダーと連動してLink表示をコントロールさせます。
まずはスライダーにイベントを設定します。
試しましたが、スライダーの場合onmousemoveイベントを設定すればいいみたいですね。

miserables_11.html
    <input id="rng_link" type="range" min="0" max="31" value="0" step="1" onmousemove="OnChangeValue();">
    Link: <span id="r_link_text">0</span>

このとき、linkの線を呼び出したいので、"linkArrow"をclassでは無くidとして定義するように修正します。

miserables_11.html
//Linksの定義
  var links = g.append("g")  //svg⇒gに
      .attr("class", "links")
    .selectAll("g")
    .data(graph.links)
    .enter()
    .append("g")
      .attr("id", "linkArrow")  //外から使用するため、classでは無くidに
      .attr("fill", "#999")
      .attr("stroke","#999")  //輪郭線の色指定追加

そしてイベント発生時の処理を追加します。
"linkArrow"のvisibilityを設定することでlinkの表示を切り替えています。

miserables_11.html
function OnChangeValue(){
  var linkvalue = document.getElementById("rng_link").value;
  d3.select("#r_link_text").text(linkvalue);  //スライダーの横にある表示を更新

  d3.selectAll("#linkArrow").attr("visibility",function(d){
    if(d.value >= linkvalue){return "visible"} else {return "hidden"}})  //各矢印の表示設定を更新
}

Groupのsetとlinkの絞込みの更新タイミングがちょっと変ですが、めんどくさいので一旦このままで。
(画面のコントロール設計の問題なので、ちゃんと設計すればいい話です・・・)

結果のイメージは以下のとおりです。
selectLink.PNG

実行イメージ
https://hirodos.github.io/D3Test/miserables_11.html
ここまでのソース
https://github.com/hirodos/D3Test/blob/master/miserables_11.html

とりあえずはここまで・・・

89
76
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
89
76

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?