jQuery: Cytoscape.js お試せた? 22.5 (ラベル(等)をダイナミックに取り替える…のはツラいのよ – Part II)

何が作りにくさの原因かだいたいほぼ把握できたの巻、てことかも。

jQuery: Cytoscape.js お試せた? 22.5 (ラベル(等)をダイナミックに取り替える…のはツラいのよ – Part II)

前置き

前回書いた通り、そろそろ Cytoscape.js 「そのもの」のネタはなくなっていて、まぁ「レイアウトの部分適用」時の位置指定が心残りであったりするけれど、それは「どうも多分無理」で決着しそうなので、多分「Cytoscape そのものネタ」は今回のが最後になるんじゃないかと思う。そこで作ってきた「声優関連図作り子ちゃん」は裏でこっそり進化させてくだけのこと。(そのうちちゃんとした形で公開したいと思ってるけど、まぁ気長にやってる。)

前回 Cytoscape.js の基礎についておさらいしてて、今回の話はその知識が大前提なので、理解出来てないなら読んでおいて。

もうひとつ、アニメーションについて書いたこれこれ、さらにはパフォーマンスについて書いたこの話、全てが繋がってくる話。今回のを読む前にこれらを読んでおいてもらえると助かる。

「やりたいこと」そのものの若干の複雑さについて

「この一連のシリーズで作ってる「声優関連図作り子ちゃん」」に固有の事情ばかりというわけでもない。

やりたいのは、

  1. ラベルをユーザ設定で「見えないようにする」こと
  2. エッジをユーザ設定で「見えないようにする」こと
  3. ただしエッジ非表示設定時でも、ノードタップ時などでは結合エッジが一瞬表示されること

言葉にすると非常にシンプルなニーズに思えるであろう。何も「声優関連図作り子ちゃん」でだけ発生する要件てもんではない。ゆえ、きっと「あなたも悩むときがくる」つーことな。

1. の動機が不明なことはなかろう。「絵だけでわかるから文字はいらん」という人向けか、もしくはラベルのレンダリングはコストがかかるために、大量ノードを扱う際に、一時的にでも隠したい、ということである。

2. と 3. が、これは「UI 設計として悩ましい」ところなのだがわかる? つまり…、エッジというのは「気持ちは繋がってます」ということを表現するもんであって、だから「繋がってるのにそう見えない」のがまずは非常に困る、ということ。だから基本的には「繋がっているんであれば隠したくはない」という性質のものなのね。けれど、やはり同じく「エッジのレンダリングは「とても」コストがかかる」ので、大量のノードが大量のエッジで結合しまくっていると、これは体感ではっきりと「重い」。特にパン操作とズーム操作時はこれの有無でほんとうに信じられないほどの性能差が出る。(ついでに言うとエッジはどうしても大量にあると見栄えとして「みっともない」というのもあって、その意味でも隠したくはなる。)

ちなみに前回まで見せてきてた「実際に動くやつ」では些細な間違いをしてて、「同一声優が演じている別キャラ」を青い線でつなぐとこ、なぜか「全キャラ」つながずに、一つだけつないでたのよね。これは意図ではなくて単純に間違えてただけ。して、これを直したら当然エラいことに。とにかくエッジが凄まじく多い。ので余計に「エッジ、隠せるようにしないと」となったのよね。

先にエッジ表示/非表示切り替えのほう

ラベルの方よりは一応「腑に落ちないことはない」ので先に。けれど「ただしエッジ非表示設定時でも、ノードタップ時などでは結合エッジが一瞬表示されること」と絡むだけで実現が鬱陶しい、ておはなし。

パフォーマンスの話の中で作ったヤツを再掲しておく:

 1 var __orig_style = null;
 2 function _toggole_visible_edges(target_edges, display) {
 3     // なんで json 経由せないかんのよ、は腑に落ちないけれど、とにかく
 4     // 「elements のほうではなくスタイルの方を」変更出来る。
 5     if (!__orig_style) {
 6         __orig_style = cy.style().json();
 7     }
 8     if (display == "none") {
 9         var style = jQuery.extend([], __orig_style);
10         style.push({selector: "edge", style: {display: display}});
11         cy.style().fromJson(style).update();
12         if (target_edges.length > 0) {
13             target_edges.style("display", "element");
14         }
15     } else {
16         if (target_edges.length > 0) {
17             target_edges.removeStyle("display"); // clear
18         }
19         var style = jQuery.extend([], __orig_style);
20         style.push({selector: "edge", style: {display: display}});
21         cy.style().fromJson(style).update();
22     }
23 }
24 var __nodes_on_tapstart = null;
25 cy.on('tapstart', function(event) {
26     if (cy === event.target) {  // neither node nor edge.
27         __nodes_on_tapstart = cy.elements("node:selected");
28         _toggole_visible_edges(
29             __nodes_on_tapstart.connectedEdges(), "none");
30     } else if (event.target.group() == "nodes") {  // node
31         __nodes_on_tapstart = cy.elements("node:selected, #" + event.target.id() + "");
32         _toggole_visible_edges(
33             __nodes_on_tapstart.connectedEdges(), "none");
34     } else {  // edge
35         _toggole_visible_edges(event.target, "none");
36     }
37 });
38 cy.on('tapend', function(event) {
39     if (cy === event.target) {  // neither node nor edge.
40         _toggole_visible_edges(__nodes_on_tapstart.connectedEdges(), "element");
41     } else if (event.target.group() == "nodes") {  // node
42         _toggole_visible_edges(__nodes_on_tapstart.connectedEdges(), "element");
43     } else {  // edge
44         _toggole_visible_edges(event.target, "element");
45     }
46 });
47 var __hideedges_on_zoom_timeout = 0;
48 cy.on('zoom', function(event) {
49     if (__hideedges_on_zoom_timeout) {
50         clearTimeout(__hideedges_on_zoom_timeout);
51         __hideedges_on_zoom_timeout = 0;
52     }
53     var edges = cy.elements("node:selected");
54     if (!__touring) { // この if は今無視してね
55         _toggole_visible_edges(edges.connectedEdges(), "none");
56     }
57     __hideedges_on_zoom_timeout = setTimeout(function() {
58         if (!__touring) { // この if は今無視してね
59             _toggole_visible_edges(edges.connectedEdges(), "element");
60         }
61     }, 300);
62 });

これで何をしたかといえば、「絶賛ズーム操作中」「絶賛タップ操作中」に、「興味深いエッジ(選択されているノードに結合するエッジとつかんでるノードに繋がるエッジ)」以外のエッジを隠している。これで「ドラッグ動作がもっさりする」ことなどを回避しているわけだね。そしてそこでも書いたように、「興味深いエッジを特別扱い」しないなら、これは hideEdgesOnViewport をセットするだけでいい。

最初に書いた際も言ってるが、「いいやり方とは思えない」ということではあるものの、ともあれ「エレメント全スキャンしてエレメント単位にスタイル適用」ということをしないで済んでいるために、「猛烈に遅い」ことはない、というやり方ね、少なくとも「ワタシはそう思っている」というやり方。

さて。今回やりたいのは、「これとは別に」エッジを非表示にしたりしたい、ということである。「隠したい」「見たくない」には実は2つの相反するニーズがあって、

  • つながりはそのままで、単に「見たくない」(赤い糸の存在は信じるが見えたら元も子もない)
  • つながりそのものを切りたい(お前とは縁を切る、赤い糸、切っちゃえ切っちゃえ)

この後者は単純にエッジのエレメントを「削除」してしまうだけでいい。人によってはこういうニーズは少ないと思うかもしれないけれど、実際アタシのヤツでも「そうしたいこともあるかもなぁ」というのはあるんだね。「同一声優関連線」はさ、これが繋がってると「結合ノードを選択」というインターフェイスをつけてるんで「特定の声優を一撃で選択」出来る、とも言えるけれど、そうしたくない場合も考えられるんだよね。「アニメとのつながりだけが知りたいんぢゃ」という人にはこれは迷惑なのだ。

ただ今回はこの後者はやらずに(ワタシのヤツの場合はすぐに実現出来る)、やりたいのはあくまでも前者。

ゆえ…、そう、もろ上で再掲した処理と衝突すんだわ。「表示/非表示のやり方は既にわかってるのさ」と言えるとしても、である。結局のところさっきのコードが「見たいの? 見たくないの?」に直接関与するしかないんだよね。といわけで、ワタシのこの「エッジ見たいのや見たくないのや」関連のコードは全体でこういうムゴいものになった:

 1 // 「エッジ表示/非表示」設定に反応
 2 $(".label-settings, .edge-settings").on("change", function() {
 3     _update_elements_style();
 4 });
 5 // ...
 6 function _update_elements_style() { // 設定変更時だけでなくグラフを最初に作る際にも呼ぶ
 7     // ...
 8     var hide_ac = ($("#settings-edge-show-ac").val() == "hide");  // プルダウン
 9     var hide_sa = ($("#settings-edge-show-sa").val() == "hide");
10     // ...
11     cy.elements("edge.ac").forEach(function(n, _) { // 「アニメ⇒キャラ」のエッジ全部回しやがれ
12         if (hide_ac) {
13             // "display": "none" / "display": "element" の対でやろうとすると
14             // _toggole_visible_edges の処理と衝突して御せないので、
15             // 幸い機能が被ってる visibility の方でコントロールする。
16             n.css("visibility", "hidden");
17         } else {
18             n.css("visibility", "visible");
19         }
20         // ...
21     });
22     // ...
23 }
24 // ...
25 var __orig_style = null;
26 function _toggole_visible_edges(target_edges, display) {
27     // 上で再掲したのとかなり「同じ」だが、_update_elements_style() がすることを
28     // 知っていて、それを壊さないように腐心する。やぁねぇ。
29     var hide_ac = ($("#settings-edge-show-ac").val() == "hide");
30     var hide_sa = ($("#settings-edge-show-sa").val() == "hide");
31     if (!__orig_style) {
32         __orig_style = cy.style().json();
33     }
34     if (display == "none") {
35         var style = jQuery.extend([], __orig_style);
36         style.push({selector: "edge", style: {display: display}});
37         cy.style().fromJson(style).update();
38         if (target_edges.length > 0) {
39             target_edges.style("display", "element");
40         }
41         if (hide_ac || hide_sa) {
42             // せっかく隠したしせっかく一部みせたが「普段は隠したい」設定時に
43             // 「あえてこの際に限り」見たいのである。「つながりを知るための
44             // エッジ」を、「未来永劫見たくない」なんてことはなくて、
45             // タップしたら見たいのは「いつでも」ということ。
46 
47             // to be visible temporarily
48             cy.elements("edge.ac").css("visibility", "visible");
49             cy.elements("edge.sa").css("visibility", "visible");
50         }
51     } else {
52         // ドラッグ移動開始時に「普段見ないようにしてたヤツを隠した」ので
53         // 改めて隠さねばならぬ、と。やぁねぇ。
54         if (hide_ac) {
55             // hide again
56             cy.elements("edge.ac").css("visibility", "hidden");
57         }
58         if (hide_sa) {
59             // hide again
60             cy.elements("edge.sa").css("visibility", "hidden");
61         }
62         if (target_edges.length > 0) {
63             target_edges.removeStyle("display"); // clear
64         }
65         var style = jQuery.extend([], __orig_style);
66         style.push({selector: "edge", style: {display: display}});
67         cy.style().fromJson(style).update();
68     }
69 }
70 // 以降は同じなので省略

結局何がこの複雑さの原因かなんてのはハッキリしてて、「ヲィ、hideEdgesOnViewport、これは違うぞ…。ドラッグ開始したら全部のエッジを隠す…のは普通は望んだことではないぞ」に尽きる。かなり頻出のニーズだと思うんだよなぁ、「関連エッジ以外は隠す」なんてのは。

ラベルのほう

先に答え言っちゃう。「隠す」ことが出来ない。と思う。少なくともワタシはドキュメントから見つけられない。

代替案として例えば「一時的に読めないフォントサイズにする」とか「読めないように完全透明にしちゃう」とかも考えられないこともないし、ブラウザが持ってるデベロッパツールで構造を解読すれば、「剥き身の DOM ノードを直接操作する」ことで強引にやる手段もきっとあるんじゃないかと思ってはいるけれど、少なくとも「公式に説明される方法で」、ラベルテキストを維持したまま非表示にすることはどうやら出来なそうなのね。

すなわちこれは何を意味するかと言えば。「設定変更のたびに label をセットし直す」しかない、ということ。前回の話も根本的には同じことなのだけれど、今回の場合特に「隠すことは出来ないので仕方なく label を削除する」しかやりようがないことが問題なのであって。して、いったん削除したものを戻すには、当然「もう一度 label をセットし直す」必要がある、と。

ワタシのヤツみたく「ノード・エッジには情報のキーだけ維持していて、label は cytoscape.js 管轄外のデータから引っ張ってくる」ノリで書いてると、そうして label を書くことを「またやらねばならん」てこと。うげぇ。

そしてワタシのヤツの場合、もう一つあって、これでやった「ツアー」、これは Google Earth とかのツアーと同じノリの、選択ノードを自動で巡回するアニメーションで、そこで「一瞬フォントを大きくする」てことをやってるわけで、これを「非表示設定でも」維持したければ、やはり「label 書き直し」しなければならない。うげげげげぇ。

ひとまずツアーはおいといて、こういうこと:

 1 function _update_elements_style() {  // 気のせいではないよ、さっき挙げた関数ね
 2     var lk = $("#label-lang").val();
 3     var hide_ac = ($("#settings-edge-show-ac").val() == "hide");
 4     var hide_sa = ($("#settings-edge-show-sa").val() == "hide");
 5     var show_label_ac = $("#settings-edge-labelshow-ac").is(':checked');
 6     var show_label_sa = $("#settings-edge-labelshow-sa").is(':checked');
 7     var show_label_a = $("#settings-node-labelshow-a").is(':checked');
 8     var show_label_c = $("#settings-node-labelshow-c").is(':checked');
 9     var show_label_v = $("#settings-node-labelshow-v").is(':checked');
10     cy.elements("node.a").forEach(function(n, _) {
11         if (show_label_a) {
12             // malrawdata が cytoscape.js 範疇外のデータ。ここに「全ての情報」が入ってる。
13             var anime = malrawdata["a"][n.data("a")["#"]];
14             n.css("label", anime[lk]);
15         } else {
16             n.removeStyle("label"); // 「hide」とかしたいのにねぇ。
17         }
18     });
19     cy.elements("node.v").forEach(function(n, _) {
20         if (show_label_c && show_label_v) {
21             var ch = malrawdata["c"][n.data("c")["#"]];
22             var cv = malrawdata["v"][n.data("v")["#"]];
23             n.css("label", ch[lk] + "\n(" + cv[lk] + ")");
24         } else if (show_label_c) {
25             var ch = malrawdata["c"][n.data("c")["#"]];
26             n.css("label", ch[lk]);
27         } else if (show_label_v) {
28             var cv = malrawdata["v"][n.data("v")["#"]];
29             n.css("label", cv[lk]);
30         } else {
31             n.removeStyle("label");
32         }
33     });
34     cy.elements("edge.ac").forEach(function(n, _) {
35         if (hide_ac) {
36             n.css("visibility", "hidden");
37         } else {
38             n.css("visibility", "visible");
39         }
40         if (show_label_ac && !hide_ac) {
41             var sn = n.source();
42             var tn = n.target();
43             var anime = malrawdata["a"][sn.data("a")["#"]];
44             var ch = malrawdata["c"][tn.data("c")["#"]];
45             n.css("label", anime[lk]);
46             n.css("source-label", ch[lk]);
47         } else {
48             n.removeStyle("label source-label");
49         }
50     });
51     cy.elements("edge.sa").forEach(function(n, _) {
52         if (hide_sa) {
53             n.css("visibility", "hidden");
54         } else {
55             n.css("visibility", "visible");
56         }
57         if (show_label_sa && !hide_sa) {
58             var sn = n.source();
59             var cv = malrawdata["v"][sn.data("v")["#"]];
60             n.css("label", cv[lk]);
61         } else {
62             n.removeStyle("label");
63         }
64     });
65 }

で、「ツアー」の方だけど、これを最初に作ったときは「flashClass ネタ」も兼ねてたので「一瞬だけフォントサイズをデカくする」のも無理やり flashClass 範疇だけでやろうとして「こういうことには向かないよ」と言ったわけだけど、今回の件で改造必要なついでに「だったら普通に css(“font-size”, …) すればええやん」に直しつつ、今回の本題の「label が削除されちゃっていないのです」対応も突っ込む:

  1 // initialize cy
  2 var cy = window.cy = cytoscape({
  3     // ...
  4     style: cytoscape.stylesheet()
  5        // ...
  6         .selector("node.tour_anime_start")
  7         .css({
  8             // もともとここに「font-size: 500」なんてのを入れてたが、「ズームレベル依存」に
  9             // そもそも馴染まないのでそれはやめたということ。
 10             "color": "black",
 11             "text-outline-width": 5,
 12             "text-outline-color": "white",
 13             //"text-opacity": 1,
 14             //"background-opacity": 0.5,
 15         }),
 16 });
 17 // ...
 18 var __touring = 0;
 19 var __tour_nodes = [];
 20 var __tour_idx = 0;
 21 function _zoom_and_center(node) {
 22     // cytoscape.js 組み込みの cy.center(eles) が期待のものと違うという
 23     // 理由だけで必要な、「普通あんだろこんくらい」な当たり前のことを
 24     // するヤツ。コードは省略。以前のやつ参照。
 25 }
 26 function _scale_fontsize(fontsize_string, scale) {
 27     // 「16px」みたいに返ってくる font-size を、ズームレベルに基づいて
 28     // 拡縮したいので、のためだけに必要な処理。
 29     var m = (new RegExp("([0-9]+)([a-z]+)")).exec(fontsize_string);
 30     return parseInt(parseInt(m[1]) * scale) + m[2];
 31 }
 32 $("#tour-selected").on("click", function(event) {
 33     function _render_anode_label(n, ctx) {
 34         // _update_elements_style() でやってることと限りなく同じなのだが、
 35         // 「ツアー」ではユーザが「普段はラベルは読みたくない」と思って
 36         // いることに「依存しない見せ方」をしたいのである。
 37         var lk = $("#label-lang").val();
 38         var show_label_a = $("#settings-node-labelshow-a").is(':checked');
 39         if (ctx == "highlight") {
 40             var anime = malrawdata["a"][n.data("a")["#"]];
 41             n.css("label", anime[lk]);
 42         } else {  // revert
 43             if (!show_label_a) {
 44                 n.removeStyle("label");
 45             }
 46         }
 47     }
 48     function _render_cnode_label(n, ctx) {
 49         // 同上。
 50         var lk = $("#label-lang").val();
 51         var show_label_c = $("#settings-node-labelshow-c").is(':checked');
 52         var show_label_v = $("#settings-node-labelshow-v").is(':checked');
 53         if (ctx == "highlight") {
 54             var ch = malrawdata["c"][n.data("c")["#"]];
 55             var cv = malrawdata["v"][n.data("v")["#"]];
 56             n.css("label", ch[lk] + "\n(" + cv[lk] + ")");
 57         } else {  // revert
 58             if (show_label_c && show_label_v) {
 59                 // to do nothing.
 60             } else if (show_label_c) {
 61                 var ch = malrawdata["c"][n.data("c")["#"]];
 62                 n.css("label", ch[lk]);
 63             } else if (show_label_v) {
 64                 var cv = malrawdata["v"][n.data("v")["#"]];
 65                 n.css("label", cv[lk]);
 66             } else {
 67                 n.removeStyle("label");
 68             }
 69         }
 70     }
 71     // 以降「基本構造」は前回書いたのとほぼ同じではある。ただし。
 72     if (!__touring) {
 73         __tour_idx = 0;
 74         var cnodes = cy.elements("node.v:selected");
 75         cnodes.forEach(function(c, _) {
 76             __tour_nodes.push(c);
 77         });
 78         if (__tour_nodes.length) {
 79             var fn = function() {
 80                 _toggole_visible_edges([], "element");
 81                 cy.fit();
 82                 // 前回はこれを font-size 変更も flashClass 範疇でやってたのを、
 83                 // 「ターゲットノード狙い撃ちで css」してる、というだけの話。
 84                 // そしてここでも「普段は見たくないのよ設定」を踏まえねばならん、
 85                 // というわけである。
 86                 var charanode = __tour_nodes[__tour_idx];
 87                 var amalid = charanode.data("a")["#"];
 88                 var animenode = cy.$id("a" + amalid);
 89                 var animenode_orig_fontsize = animenode.css("font-size");
 90                 var charanode_orig_fontsize = charanode.css("font-size");
 91                 _render_anode_label(animenode, "highlight");
 92                 animenode.css(
 93                     "font-size",
 94                     _scale_fontsize(animenode_orig_fontsize, 2.0 / cy.zoom()));
 95                 animenode.flashClass("tour_anime_start", 1500);
 96                 setTimeout(function() {
 97                     animenode.css(
 98                         "font-size",
 99                         animenode_orig_fontsize);
100                     _render_anode_label(animenode, "end");
101                     _render_cnode_label(charanode, "highlight");
102                     charanode.css(
103                         "font-size",
104                         _scale_fontsize(charanode_orig_fontsize, 2.0 / cy.zoom()));
105                     charanode.flashClass("tour_anime_start", 1500);
106                 }, 1500);
107                 setTimeout(function() {
108                     charanode.css(
109                         "font-size",
110                         charanode_orig_fontsize);
111                     _render_cnode_label(charanode, "end");
112                     _zoom_and_center(charanode);
113                     __tour_idx = (__tour_idx + 1) % __tour_nodes.length;
114                     _toggole_visible_edges([], "none");
115                 }, 3000);
116             };
117             fn();
118             __touring = setInterval(function() {
119                 fn();
120             }, 10000);
121             $("#tour-selected-icon").attr(
122                 "class", "fa fa-stop fa-1x");
123             $("#tour-selected-icon").attr(
124                 "title", "Stop tour");
125         }
126     } else {
127         _toggole_visible_edges([], "element");
128         clearInterval(__touring);
129         __touring = 0;
130         __tour_nodes = [];
131         __tour_idx = 0;
132         $("#tour-selected-icon").attr(
133             "class", "fa fa-play fa-1x");
134         $("#tour-selected-icon").attr(
135             "title", "Start tour to selected nodes");
136     }
137 });

ツアーの性質上「目一杯拡大」するので、設定によらずエッジを隠したい、てのもあって、結局今回貼り付けたコード全てが「同じことを意識して、お互いを思いやりながら処理する」という、非常に相互にカップリングの強い、「ソフトウェア工学的にめちゃくちゃアウト」なもんに…、なっちゃいましたがナニか?

ほんと些細なことなんだよね。「たったこれがないばかりに」。今の場合は「label 非表示出来ないこと」と、「エッジを隠すのがオールオアナッシングしか用意されてないこと」のたった2つだけ。それだけのことなのにこれだけのことになっちゃう。

そして多分最後となる「実際に動くヤツ」貼り付け

「声優関連図作り子ちゃん」はそれはそれとして裏でこっそり進化させ続けるので、いずれちゃんとした形で、「もっと本格的に使いやすいものになって」お披露目する機会はあるとは思ってる。けど、「Cytoscape.js ネタの実例として」の役割で貼り付けるのは(たぶん)今回が最後。こうなった:


いつものごとく:

遊び方はこれの末尾。ソースコードをどうしても読みたい人は、chrome なら「フレームのソースを表示」で読める。同じく chrome なら、Open Frame を導入すれば、ワタシのブログにはり付いたせせこましい画面でなく「スクリーン目一杯」使って遊べる。