今回はフィクションとノンフィクションの豪華二本立て。
Contents
Query: Cytoscape.js お試せた11 (ノードのデータ検索に基いて選択、とオマケの cytoscape-toolbar.js (がダメだった話))
ノードのデータ検索に基いて選択なんて簡単に出来たぜっ
動機
前回までのものを実際に使ってもらえればわかるのだが、このようにノードが大量に追加されうるものは、最低でもノードの検索機能くらいはないと、とてもじゃないが快適に使えない。
ワタシは今やってる例のアプリケーションで、たとえばこういうことをしたいわけなのだ:
- 「宝石の国」キャストから始める
- 「黒沢ともよ」が演じたほかのキャラを列挙し、これによって「宝石の国」キャストどうしの他のアニメでの共演関係がわかる
- これをほかのキャストでも繰り返す
- あぁ、ここでも共演してたのか、を知って喜ぶ
- 繰り返す
- うーん、多過ぎて読めないがとにかく「スナップショット」として読みやすい配置に並べ替えて PNG にしちゃおう
特に最後の作業だ、問題なのは。見やすく配置し直すのに、一つ一つのノードを目視で確認しながら一つ一つ配置し直すのはとてもじゃないが現実的ではないし、そもそも前回までのものは「複数選択そのものが出来ない」、ので、関連ノード複数をまとめて移動することも出来ないのであるからして、めっさストレスたまるのであ。
うん、そもそも cytoscape.js ネタとしても「プログラマティックにノードトラバースと選択操作をするネタ」になりつつ、ワタシのアプリケーションのユーザでもあるワタシが使いやすくなって嬉しい、一石二鳥だ。
まずは複数選択可能にしないとね、簡単だぜ
最初に、当然ワタシは気付いていたが、「選択されていたとしても選択状態がわかるようにしてなかった」のである。もちろんこれは知っていた。
cytoscape.js は「ノードが選択されている」状態のスタイルを当然のことながら勝手には設定しないので、以下のように明示的に設定しなければならないがこれは非常に簡単なことだ:
1 //
2 var cy = window.cy = cytoscape({
3 container: document.getElementById('cy'),
4
5 // ...
6 style: cytoscape.stylesheet()
7 .selector("node:selected")
8 .css({
9 "border-style": "solid",
10 "border-color": "#47119e",
11 "border-width": 6,
12 "border-opacity": 0.7,
13 })
14 // ...
これでまずは最低限の準備だ。これを忘れていると本題の設定を「したのに効かない!」と大騒ぎすることになるので注意。無論ワタシはそんなバカなことはしない。
この最低限の準備をしたら、以下の設定が本題の「複数選択可能にする」である:
1 //
2 var cy = window.cy = cytoscape({
3 container: document.getElementById('cy'),
4
5 boxSelectionEnabled: true,
6 autounselectify: false,
7 selectionType: "additive",
8
9 // ...
10 style: cytoscape.stylesheet()
11 .selector("node:selected")
12 .css({
13 "border-style": "solid",
14 "border-color": "#47119e",
15 "border-width": 6,
16 "border-opacity": 0.7,
17 })
18 // ...
悩むところはないだろう。boxSelectionEnabled
はお好みだが「コントロールキーを押しながら」という操作になるのは注意。
ダブルタップと「選択」という行為は頗る相性悪いのでダブルタップはやめちゃおう
ここで頑張ってダブルタップを仕込んだけれど、考えてみて欲しい、そもそも「タップして選択」という行為と「ダブルタップ」という行為が相性がいいわけがないではないか、だからワタシは最初から「いつかダブルタップはやめることになるはずだ」とわかっていた。
なので、元々はこうしていた:
1 var tappedBefore;
2 var tappedTimeout;
3 //...
4 //
5 var cy = window.cy = cytoscape({
6 container: document.getElementById('cy'),
7
8 //...
9 });
10 cy.on('tap', function(event) {
11 // NOTE: older cytoscape's event has cyTarget rather than target.
12 var tappedNow = event.target;
13 if (tappedTimeout && tappedBefore) {
14 clearTimeout(tappedTimeout);
15 }
16 if(tappedBefore === tappedNow) {
17 tappedNow.trigger('doubleTap');
18 tappedBefore = null;
19 } else {
20 tappedTimeout = setTimeout(function(){ tappedBefore = null; }, 300);
21 tappedBefore = tappedNow;
22 }
23 });
24 cy.on('doubleTap', 'node', function(event) {
25 if (this.data("id").startsWith("anime")) {
26 construct_cy_tree(this.data());
27 } else {
28 var actor = this.data("actor");
29 actor["voice acting roles"].forEach(function(ch, i) {
30 construct_cy_tree(ch, actor);
31 });
32 }
33 });
34 // ↓これはサードパーティの cytoscape.js-context-menus で使える拡張
35 cy.contextMenus({
36 menuItems: [
37 "id": "jump_to_actor_page",
38 "content": "jump to actor page",
39 "selector": "node.actor",
40 "onClickFunction": function (event) {
41 var actor = event.target.data("actor");
42 var url = (new MalUrlBuilder(false)).get_person(actor["malid"], actor["canonical_name"]);
43 try { // your browser may block popups
44 window.open(url);
45 } catch(e) { // fall back on url change
46 window.location.href = url;
47 }
48 },
49 },
50 // ...
51 ]});
この tap に反応して自前 doubleTap をトリガーする処理と、doubleTap に反応する部分をやめつつ、doubleTap に反応してやっていた「実」の部分を contextMenus 側に移動した、というわけだ。これは無論造作ない:
1 //
2 var cy = window.cy = cytoscape({
3 container: document.getElementById('cy'),
4
5 //...
6 });
7 // ↓これはサードパーティの cytoscape.js-context-menus で使える拡張
8 cy.contextMenus({
9 menuItems: [
10 {
11 "id": "query_characters",
12 "content": "request query characters",
13 "selector": "node",
14 "onClickFunction": function (event) {
15 if (event.target.data("id").startsWith("anime")) {
16 construct_cy_tree(event.target.data());
17 } else {
18 var actor = event.target.data("actor");
19 actor["voice acting roles"].forEach(function(ch, i) {
20 construct_cy_tree(ch, actor);
21 });
22 }
23 },
24 "hasTrailingDivider": true,
25 },
26 {
27 "id": "jump_to_actor_page",
28 "content": "jump to actor page",
29 "selector": "node.actor",
30 "onClickFunction": function (event) {
31 var actor = event.target.data("actor");
32 var url = (new MalUrlBuilder(false)).get_person(actor["malid"], actor["canonical_name"]);
33 try { // your browser may block popups
34 window.open(url);
35 } catch(e) { // fall back on url change
36 window.location.href = url;
37 }
38 },
39 },
40 // ...
41 ]});
ところでだいぶ UI がごちゃごちゃしてきたので jQuery-ui の Dialog 使ってまとめちゃおう
ワタシが今作ってるヤツは、最初にアニメ一覧を検索で取りに行って、それを選ばせるための「検索結果リスト」のために Tabulator を使っているわけね。これが jquery-ui に依存しているわけね。
なのでここにあるものを使わない理由は「ワタシの場合は」ないので、フラットに span を並べていたのをこんな風に jquery-ui の Dialog で包んだ:
1 <head jang="ja">
2 <meta charset="UTF-8">
3 <!-- ... -->
4 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/3.3.2/css/tabulator_site.min.css">
5 <link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
6 <!-- ... -->
7 <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
8 <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
9 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/3.3.2/js/tabulator.min.js"></script>
10 <!-- ... -->
11 <style>
12 /* ... */
13 .ui-dialog {
14 background-color: rgba(240, 240, 240, 0.8);
15 }
16 </style>
17 </head>
18 <body>
19 <!-- ... -->
20 <span id="open-node-filters" title="Open node filters"> [+]</span>
21
22 <div id="node-filters" style="display: none" title="Node filters">
23
24 Type: <input
25 class="cynodes-filters"
26 id="animetypes"
27 type="text"
28 placeholder="comma separated types"
29 title="comma separated types like 'TV, OVA'"
30 value="TV"
31 size=14/><br/>
32 Genres: <input
33 class="cynodes-filters"
34 id="genres"
35 type="text"
36 placeholder="comma separated genres"
37 title="comma separated genres like 'Action, Fantasy'"
38 value="Action, Fantasy, Seinen"
39 size=24/><br/>
40 <!-- ... 以下延々入力フィールド -->
41
42 </div>
43 <div id="cy"></div>
44 <!-- ... -->
45 <script>
46 /* ... */
47 function _nodefilterdialog_open(evt) {
48 $("#node-filters").dialog({
49 width: 300,
50 });
51 }
52 //_nodefilterdialog_open(); // イキナリ開いた状態で始めたいならコメントアウト外す
53 $("#open-node-filters").on("click", _nodefilterdialog_open);
54 /* ... */
55 <!-- ... -->
特に悩むことはないだろう。
あとは本題の「ノードのデータ検索に基いて選択」を実現するだけ、簡単さ
わかればいいのは cytoscape の木構造をトラバースする術と、プログラムから「選択」行為をするための API を知ることだ。どちらもすぐに見つかる。cy.collection と eles.select だ。使いやすいドキュメントなので、こんな情報提供が必要ないほど誰でも簡単に見つけられるし、使い方もすぐにわかるはず:
1 //
2 function _select_nodes_by_name(event) {
3 var find_what = $("#select-nodes-by-name").val().trim();
4 if (!find_what) {
5 return;
6 }
7 //
8 var sel = ($("#select-or-unselect-nodes-by-name").val() == "select");
9 //
10 var cy = window.cy;
11 var eles = cy.collection("node");
12 for (var i in eles) {
13 var targets = [];
14 if (!eles[i].data) {
15 continue;
16 }
17 var anime = eles[i].data("anime");
18 var character = eles[i].data("character");
19 var actor = eles[i].data("actor");
20 if (anime) {
21 targets.push(anime["name"]);
22 targets.push(anime_nodes[anime["malid"]]["name"]);
23 }
24 if (character) {
25 targets.push(character["japanese"]);
26 targets.push(character["name"]);
27 }
28 if (actor) {
29 targets.push(actor["japanese"]);
30 targets.push(actor["name"]);
31 }
32 for (var j in targets) {
33 if (targets[j] && targets[j].indexOf(find_what) >= 0) {
34 if (sel) {
35 eles[i].select();
36 } else {
37 eles[i].unselect();
38 }
39 break;
40 }
41 }
42 }
43 }
44 $("#select-or-unselect-nodes-by-name").on("change", function(event) {
45 _select_nodes_by_name(event);
46 });
47 $("#select-nodes-by-name").on("change", function(event) {
48 _select_nodes_by_name(event);
49 });
select-nodes-by-name
と select-nodes-by-name
がナニモノなのかは下で実際の全コードを「動く状態で」貼り付けるのでそれ見てもらえればわかるはず。
出来たヤツ
いつもの通り、実際に動くもの:
「フレームのソースを表示」してもらえればそれが「全部」なので見てもらえればいい。
ノンフィクション (動機からして違ってたのだ)
動機、本当の
読んでくれてる人には大差ない話だしワタシ的にも「どうせ勉強するなら趣味に関係することを絡めて一石二鳥」という結果を導くという動機は「結局は同じ」、ではあるのだけれど、「さぁ次のネタ」と思って始めた「やろうとしたこと」は、今回ここまで書いてきた流れとは随分違っていた。
最初に考えたのが、上のフィクションであげた「そろそろごちゃごちゃしてきたので、お便利ライブラリでまとめたいのな、cytoscape.js-toolbar の出番なのでわ、やってみねば」だった。というか一応 cytoscape.js-navigator も候補に考えたが、ビデオを見つけて「これだと今のワタシのヤツにはあんまし嬉しくないな」と思ったので、cytoscape.js-toolbar の出番だろう、と。
入り口からして全く印象が「沸かない」のよ、cytoscape.js-toolbar。ライブデモがないというのが一番大きいんだけれど、「読めば読むほど、ちゃんと読もうとすればするほど「不案内無限ループ」」なのね。ライブデモがないからには「そもそもどんなだ、どう嬉しい、どういう見栄えになる」をなんとかしてわかりたいんだけれど、「ドキュメントは全て index.html に書かれてる」と説明されてる index.html は「単純のために省略」し過ぎていて、実際に「動いてたと思われるコード」は、「ほーらこんなに簡単だぜっ」として「省略された」コードにはない「必要コード」をバリバリ書いていて、だとすれば「あんまし嬉しいようにもみえんなぁ」と思うわけなんだけれど、「デフォルトがあるのでこれでいいのだ」のかどうかが、どこ読んでも「あっちに書いてある」とループる。
で、仕方がないので「全部ダウンロードしてきてみて、自分ちで動かしてみるしかない」という結論にしかならなくて、demos をどうにかして(リンクを書き換えるなどして)動かそうと試みてはみたんだけれど、ダメだ、果てた。なんだっけか、「cy.$container が undefined
」みたいな例外が取れなかった。
もうだから「動かしてみて感触を確かめる」のは諦めたんだけれど、ただね、「全然意味不明のドキュメント」をなんとか空気読んで「解釈」出来たと仮定して、なおかつ「実装」をなんとなく眺めたりしてたんだけど、少なくとも「オイシイようにはまったく思えない」感じなのね。実際アタシが「cytoscape の組み込みやプラグインがなければないで自分で普通に html の button なりで UI を作って、外から cy を呼び出せばええんや」してる通り、「ないと絶対に困る」てことはもとからないわけで、こういうものを作るなら「cytoscape.js に対するプラグインならでは」の何かしらオイシイところがないといけないと思うんだけれど、そのオイシサてのは単に「「cytoscape.js でパンを実現するには」サンプル」でしかないのね、だったらこのコードをコピペすりゃぁええし、もしくは「公式ドキュメント読めばわかるわい」。
だからさ、ほんとは「cytoscape-toolbar.js に多分自作ツールを置けるんだべ、そこから「お好きな位置にパンツール」なんてネタになればいいなぁ、cytoscape-toolbar.js 組み込みのツールもあってオイシイんだろうし」と思って始めたのが、見事なまでに打ち砕かれた、て話。そんなわけで、「仕方なく jquery-ui.js にトライしたが、やってみたら思わず良かったので結果オーライ」てだけのことだったのさ。
「選択する」が簡単なことはわかってたから避けようとしたんだけどねほんとは
ローリスクハイリターンなネタなのよ、アレは。実装すれば必ず使い勝手が劇的に向上するはずの鉄板ネタで、なおかつこれは本当にドキュメントから「簡単なことは知っていた」。
同じく「使いやすくなる可能性もある」上に「実現が大抵何かしらは必ず悩むズームとパン」の方こそやりたかったの。これを「検索してズーム」とか「検索してパン」て、「使いやすくなるかもしれないがやってみないとどの程度かわからん」という絶妙なとこでしょ? けどさぁ…、なんか全然言うこときいてくんないの、cy.zoom、cy.pan。地図系を結構いろいろやってるんで、「少し厄介」なのは知ってるけれど、ここまで言うこときいてくんないのはさすがに初めて。何がどうなってるんだか。
これはまだ時間かかりそうだわ。
選択状態をあらわす style の話、本題の「複数選択可能にする」設定、と、ダブルタップの話
一番ウソついたのがこの 3つの話。
「わかった人」が書けば、間違いなく上のフィクションの通りの流れの説明になると思う。けどワタシは全然違った。
まずね…、「複数選択にする設定」。これだけでいいようにみえんけ?:
まずこれを「設定するだけで」イケそうな気がするんだね、間違いなく。そしてワタシはご想像通り「:selected」に対するスタイル付けをしていないことに気付いていない。ので、「なんでだぁ」となるわけだ。
で「試行錯誤」が始まったわけなんだけれど、autounselectify に気付いたのとスタイルを設定してないことに気付いたのはほぼ同時だったかな、確か。
まずね、フィクション部分では
1 //
2 var cy = window.cy = cytoscape({
3 container: document.getElementById('cy'),
4
5 // ...
6 style: cytoscape.stylesheet()
7 .selector("node:selected")
8 .css({
9 "border-style": "solid",
10 "border-color": "#47119e",
11 "border-width": 6,
12 "border-opacity": 0.7,
13 })
14 // ...
けどさ、これな、「ボーダしか効かない」みたいだぞ。ノードの背景全部変えたい、とか思ったらどうも出来ないらしい。確実に人によっては致命的に頭掻き毟ると思うぞ。
そして autounselectify の件。わかるか?:
これね、false にしないと「複数選択どころか単一選択すら出来ない」。なんかもっとわかりやすい言い回しはないんだろうか。だってこれ、「選択不可能」てことじゃん、なんでそう言わないの? そして「selectionType でやれやそいうの」てことだろ。
そして以上のことがわかる前に、「あぁ、そうか、ダブルタップの処理と衝突しちゃってんだ、なーんだ」と思ったよ、見事に。現実に「ユーザ向けインターフェイス」としてそもそも「衝突」するのね、けど「目的の処理がトリガーされるや否や」に関して「独自ダブルタップ実現コード」が邪魔をしてる、なんてことは「どうでもいい話」。そもそも両方やろうとすること「そのものが間違い」なんだから。(想像してみればいいよ、「タップ」が選択をトリガーしつつ「ダブルタップ」出来たとして、「ダブルタップ」時って、「選択して非選択に戻る」のかい?)
jquery-ui の Dialog で悩まなかったって?
それほど大きなウソではないが、なんてのかねぇ、「アチラのひとたちのフォントの使い方」ってさ、ほとんどの場合「小さ過ぎる」か「大き過ぎる」かどっちかなんだよね、マルチバイト言語圏の国で使う標準とだいたいズレてんだよね。今回のは後者。文字サイズがデカ過ぎてビビった。
あとさ、「少し透かした」かったのよ、けどさ、テーマと class の関係の説明がなんかこ難しいんだよね、実際は上でやってみせた通りで「普通に class と css の関係」でしかないんだけど。
まだあって、これは「ワタシのこのページに貼り付ける」場合限定の話なんだけど、まぁ当たり前だけど「ダイアログ」なので、ページのロード完了時に「アクティブになろうと」して、ダイアログ内の入力フィールドに強制的に飛んじゃうのよ、「最初から開く」コードにしとくと。なので貼り付けたコードでは最初から開くための部分はコメントアウトの上で貼り付けてる。まぁこれで困る人はあんまいないだろうけどね。
その他気付いてること
コンテキストメニューなんだけどさ、「何もせずに閉じる」ことが出来ないんだな、マズいよなぁこれ。しかも閉じずに右クリックすると延々立ち上がりやがる。ヒドいよ。一応デストロイする API があるみたいなんで、自力でなんとか出来るのかもなぁ。
同じくコンテキストメニューの話。ワタシのヤツの場合は、「複数選択時に選んだ全ノード分に反応したら困る」ほうなんだけど、「残念ながらそっち」。つまり、マウスカーソルを置いた位置のノードの方だけが動く。ワタシの期待通りの振る舞いではあるけれど、一般的には違う気がする。どっちがいいかは半々くらいかもしれんけど、どっちかといえば「選択全部に反応して欲しい」と思うんじゃないのかな。
最後に cytoscape ネタでもないしょーもない話なんだけれど、前回のヤツ、ajax 呼び出しコードを「整理」したつもりになったのだが、直し忘れ箇所があって壊れてた。まぁ実際に動かしてデベロッパツールなんぞでコンソール見てれば何が起こるか、どう間違えたかはすぐにわかると思うけどね。
2018-02-01追記: バカ言ってんじゃないわよ
そそっかしい性格で、結構な頻度で「阿呆な誤解」を瞬発的にやっちゃうことが多くて。上の「気付いてること」、2つ間違っている。
一つ目。「「何もせずに閉じる」ことが出来ない」ことはない。ノード・エッジをタップ、以外をタップ、どちらでもいいがそれで閉じれる。まぁ「延々立ち上がりやがる」方はその通り、何かおかしいけれど。
二つ目が致命的に阿呆で、なんでこんなこと思い込んだんだか。実は結構前にやらかしてたことに気付いてたけどどこに書いたか忘れちゃってさ、追記遅れちゃった。当然「複数選択時」の振る舞いを決めるのは「オレ」。ハンドラで「選択全部に反応する実装」を書けばそうなるし、書かなければそうならない。なんだかなぁ。
21:20 追記: Font Awesome 使ってみた
toolbar や Dialog に対するモチベーションと同じで、「コンパクトにしてグラフ領域を目一杯広げたい」わけね。
ちなみに Font Awesome について、ずっと(おそらく3年以上?)気付いてて、個人的に需要がなかったので調べてみることさえしてこなかった。けどまず「今回のは確実にその需要」だったのだわ。(というか「存在に気付いてただけで「何がどう嬉しい」かさえわかってなかったんだけど、ここんとこの cytoscape.js とか色々調べまくってるとそこかしこで使ってるし「説明」されるんで、さすがにどういう意味かわかった。それ以前にまさにこれこそ「気付かずに使ってた」の典型だし。)
一覧は The Icons に、基本的な使い方は Get Started と Examples で、ということ以上に説明することはないが、「サイズ変更するにはどうしたらええんや」が最初わからんかった。書いてあるよ Examples の Larger Icons に。サイズを揃えたいなら Fixed Width Icons ね。
というわけで、「Save as PNG」と「Open node filters」に使ってみたのよ:
使おうとして思ったこと:
- 量が多過ぎて目的のものを探すの大変
- のわりにはほんの少し欲しいものが足りない
- コンセンサスが出来上がってるものを見つけることの方が苦労しそうだ
最後のはつまり「わかりやすくしようとしたつもりが誤解を生じさせる」ことが起こりうるんだよね、こういうのって。皆が同じ意味に使ってるものを違った使い方をすると「とてもマズイことが起こる」ことが「ありえる」てこと。思ったより疲れるわ。
2018-01-22追記: cy.collection() と cy.elements() について
cy.collection() を誤解していた。確かに見かけ上はこれは cy.elements() と同じように使えるが、後者が参照を扱い、前者は(多分)そうじゃない。少なくともユースケースは違い、後者は「eles と同じインターフェイスを持つ「高級 Array」」のつもりで使うものみたい:
1 var collection = cy.collection();
2 cy.nodes().on("click", function(){
3 collection = collection.add(this);
4 });