jQuery: Cytoscape.js お試せた7.55 (qTip extensionを諦めた話と too many requests 問題への措置の新案兼プログレスバー)

qTip, too many requests, プログレスバー、と…、全然関係なさそうな列挙にみえて実は全部繋がってたりする。

jQuery: Cytoscape.js お試せた7.55 (qTip extensionを諦めた話と too many requests 問題への措置の新案兼プログレスバー)

qTip extension はダメかもしんない話

アタシの今作ってるヤツに限らず cytoscape.js や graphviz のようなものがターゲットにするようなものって、基本的にノードが大量にあるケースがほとんどなわけね。なので、ノードのテキストなり画像なりだけで「わかりやすい視覚化なのさこれは」というのはだいたいの場合は言い過ぎで、「なにこれ、読みにくい」になるのがフツーね。

なのでノード自身が何らかの方法で「(詳細の)目立つ自己主張」出来る手段がどうしても欲しいわけで、普通は「ツールチップ」が真っ先に候補になるわけでしょう。けど cytoscape.js のように主としてターゲットにしてるのが「タブレット端末のようなタッチデバイス」である場合(もしくはそれに色目を使っている場合)、どうしても「非タッチデバイス」向けのユーザインターフェイスが軽視されることになるわけだね、「ツールチップ」なんてさ、「マウス」前提の UI だもん、そら提供されなくてもおかしくない。

そういうわけで qtip extension が候補に挙がるわけなんだけどさ。デモをみてすぐに「タップせよってか」てことがすぐにわかるわけね。あー、やっぱタップがトリガーなのね、タップしかトリガーに出来ないんかね? と気付いたすぐ直後に、ダブルタップの件が頭をよぎる。う、いやな予感しかしない。

というよりね…。実際やってみたよ。はっきりいって2つ問題があった。調べつくせてはいないので断言はしないが:

  1. 予想通りダブルタップの措置とモロに衝突する。つまりダブルタップの処理入れてると qTip extension は動作しない。まぁ当然ではあるけれど。
  2. こっちが問題。qTip extension は単に jQuery qTip plugin に機能を丸投げしてる拡張と思うんだけど、なんだろうなぁ、「cytoscape から使うもの」として、当然以下のように書きたいわけだよ:
    ダメ、ほんとに「data(anime)」という文字列を出しちゃう
     1 cy.$('#mynode').qtip({
     2   content: 'data(anime)',
     3   position: {
     4     my: 'top center',
     5     at: 'bottom center'
     6   },
     7   style: {
     8     classes: 'qtip-bootstrap',
     9     tip: {
    10       width: 16,
    11       height: 8
    12     }
    13   }
    14 });
    

    こうやってノードのデータに無関係のリテラルしか出せないんなら「cytoscape の qTip 連携」の意味ないと思うんだけど、本当か? 少なくとも「まったく説明されてない」ので「本当だ」と思う。ならダメだ、だったら「本家 jQuery qTip plugin」を剥き身で使えばいいではないか。

というわけで、「cytoscape.js の qTip extension」にはもう興味なくしたんだけれど、本家 jQuery qTip plugin を無理して使おうか、というのもまぁいいかなという気がしてて。つまり…、「ダブルタップと衝突しないインターフェイス」として(PC なら)考えられるのが「マウスオーバー」なんだけれど、「うーんマウスオーバーに反応するつもりなら、ツールチップよりはステータスバーのようなもんの方が使いやすいような…」と。

そういうわけでマウスオーバーに反応してステータスバー

「ステータスバー」と言ってるが何か「お便利なナニモノか」の話ではなくて普通に html のエレメントを駆使して自力で、な話。ので「ステータスバー」そのものについての技術的にオイシイ話はないよ。

「マウスオーバーに反応して」は問題なく出来る。たんと説明されておる。アタシの例の場合はこんな感じ:

 1     var cy = window.cy = cytoscape({
 2       container: document.getElementById('cy'),
 3       /* ... */
 4       elements: cy_elements,  // cy_elements の nodes グループのノードデータに anime などがいる
 5       /* ... */
 6     });
 7     cy.on('mouseover', function(event) {
 8         if (event.target.data && event.target.id) {
 9             var anime = event.target.data("anime");
10             var character = event.target.data("character");
11             var actor = event.target.data("actor");
12             var s = "";
13             if (anime) {
14                 s += anime_nodes[anime["malid"]]["name"] + "   ";
15             }
16             if (character && character["japanese"]) {
17                 s += character["japanese"] + "   ";
18             }
19             if (actor && actor["japanese"]) {
20                 s += actor["japanese"];
21             }
22             $('#status-bar-text').html(s);
23         }
24     });

status-bar-text はただの div である。

プログレスバーと AJAX making too many requests との奇妙な関係について

奇妙というか、あぁ言われてみれば、てことだったりするんだけれど。

jquery: Tabulator 出来てみた5.555(Ajax making too many requests との格闘とか – AJAX Data Loadingの話の続き)とそれに続くjQuery: Cytoscape.js お試せた7 (multiline label とかダブルタップとか色んなレイアウトとかとにかく色々)で「リクエスト発行タイミングをバラけさせる」のに単純に乱数を使ってたわけね、いやだなぁと思いつつも。

そして。jQuery: Cytoscape.js お試せた7 (multiline label とかダブルタップとか色んなレイアウトとかとにかく色々)で書いた「やり残し宣言」のうちの一つ『「やってるぜ、動いてるぜ、まだ情報取得中だぜ」をどうにかわかるようにしないと』をさぁやろうか、と考え始めてハタと気付いた、って話。

進捗管理というのは当然「予定数と完了数の管理」なわけね。ワタシの例の場合では実に「AJAX リクエスト予定数と完了数」に対応するわけね。でさ、「「リクエスト発行タイミングをバラけさせる」のに単純に乱数を使う」のが何がイヤらしいかについて考えるわけ。当然「リクエスト発行予定タイミング」がてんでバラバラになるのがイヤなわけじゃん、「たとえ非同期だろうと、先にリクエストを予定したものほど先に発行してくれた方が嬉しい」でしょう? 「AJAX リクエスト予定数」…。あぁ、これを管理出来るんであれば、それを「リクエスト遅延」に使えるじゃないか。なるほどね:

 1 var _requested = {total: 0, rest: 0};
 2 function _ajax_get(url, async, fn, retries) {
 3     var conf = {
 4         async: async,
 5         url: url,
 6         type: "get",
 7         dataType: "html",
 8         success: function(data) {
 9             fn(data)
10             _requested["rest"] -= 1;
11             if (_requested["rest"] == 0) {
12                 _requested["total"] = 0;
13                 _requested["rest"] = 0;
14             }
15         },
16         error: function(xhr, textStatus, errorThrown) {
17                     if (retries === undefined) {
18                         retries = 5;
19                     }
20                     if (retries > 0) {
21                         setTimeout(function() {
22                                 _ajax_get(url, async, fn, retries - 1);
23                             }, 1000 * _requested["rest"]);
24                     }
25                 }
26     };
27     _requested["total"] += 1;
28     _requested["rest"] += 1;
29     setTimeout(function() {
30             jQuery.ajax(conf);
31         }, 500 * _requested["rest"] + Math.random() * 250);
32 }

あとはこの _requested データと「プログレスバー」なユーザインターフェイスコンポーネントを結びつければいい、てことになるわけね。(なお 500 だとか 1000 だとかのマジックナンバーの調整は相変わらず必要で、上の例は調整し切れてないよまだ。)

ひとまず「css だけで実現出来る方法がある」であろうなぁというのは調べる前から「確かトランジションかなにかでデータとバインド出来たような」という記憶があったのでわかっていたけど、検索にヒットしたどれをみてもやっぱし「クロスブラウザ問題がやらしいだろうな」感満載だったので…、jquery.ui の Progressbar Widget を使っちゃおう、と。幸いというか何というか、今作ってるヤツって、cytoscape.js だけでなく番組検索の検索結果のために使っている Tabulator が「幸い jquery.ui に依存している」ので、オレ的に使わない理由がないのよね。

先に答え:

 1 <div id="cy"></div>
 2 <div id="status-bar-text" style="height: 10pt; font-size: 9pt;"></div>
 3 <div id="progress-bar" style="height: 10pt;"></div>
 4 <script>
 5 /* ... */
 6 
 7 var _requested = {total: 0, rest: 0};
 8 function _ajax_get(url, async, fn, retries) {
 9     var conf = {
10         async: async,
11         url: url,
12         type: "get",
13         dataType: "html",
14         success: function(data) {
15             fn(data)
16             _requested["rest"] -= 1;
17             if (_requested["rest"] == 0) {
18                 _requested["total"] = 0;
19                 _requested["rest"] = 0;
20             }
21             $("#progress-bar").progressbar("option", "max", _requested["total"]);
22             $("#progress-bar").progressbar("option", "value", _requested["total"] - _requested["rest"]);
23         },
24         error: function(xhr, textStatus, errorThrown) {
25                     if (retries === undefined) {
26                         retries = 5;
27                     }
28                     if (retries > 0) {
29                         setTimeout(function() {
30                                 _ajax_get(url, async, fn, retries - 1);
31                             }, 1000 * _requested["rest"]);
32                     }
33                 }
34     };
35     _requested["total"] += 1;
36     _requested["rest"] += 1;
37     $("#progress-bar").progressbar("option", "max", _requested["total"]);
38     $("#progress-bar").progressbar("option", "value", _requested["total"] - _requested["rest"]);
39     setTimeout(function() {
40             jQuery.ajax(conf);
41         }, 500 * _requested["rest"] + Math.random() * 250);
42 }
43 
44 /* ... */
45 
46 $("#progress-bar").progressbar({
47      value: 0,
48      create: function(event, ui) {
49          $(this).find('.ui-widget-header').css(
50              {'background': '#9f9'}
51          )}
52    });
53 
54 </script>

_ajax_get 内に直接プログレスバーの更新処理を入れてるのは「雑」なので製品品質を考えたいならこういうことはするなよ。アタシは「今はまだこれでいい」。のちのち整理はしようとは思うけど。

悩んだのはバーの色を変えるやり方について。StackOverflow のこれを「雑に読む」と間違える。これはダメよ:

1 $("#progress-bar").progressbar({
2      value: 0,
3      create: function(event, ui) {
4          $(this).find('.ui-widget-header').css(
5              {'background-color': '#9f9'}
6          )}
7    });

なぜダメなのかの理由も StackOverflow の回答に書かれてるが要するにテーマが「background」を設定して「くれている」から。テーマによっては画像かな。

あとこれは公式ドキュメントにちゃんと書かれてるけど themes/smoothness/jquery-ui.css (等) のテーマの css に依存しているので、何かは読み込まないと何も表示されないことには「一応注意」。

20:30追記: 100%にならんやんけ

2つミスってた。一つがリトライカウントが尽きた場合のプログレスバー更新してなかったこと。もう一つがすぐにわからなかったが、「cytoscape.js が迷子エッジで例外を起こす」ことを失念してたこと。なので(マジックナンバーの微調整も込みで)例えばワタシの例ではこうなった:

 1 var _requested = {total: 0, rest: 0};
 2 function _ajax_get(url, async, fn, retries) {
 3     var max_retry = 10;
 4     var conf = {
 5         async: async,
 6         url: url,
 7         type: "get",
 8         dataType: "html",
 9         success: function(data) {
10             _requested["rest"] -= 1;
11             if (_requested["rest"] == 0) {
12                 _requested["total"] = 0;
13                 _requested["rest"] = 0;
14             }
15             $("#progress-bar").progressbar("option", "max", _requested["total"]);
16             $("#progress-bar").progressbar("option", "value", _requested["total"] - _requested["rest"]);
17             fn(data); // 例外を起こしうるので最後に
18         },
19         error: function(xhr, textStatus, errorThrown) {
20                     if (retries === undefined) {
21                         retries = max_retry;
22                     }
23                     if (retries > 0) {
24                         var delay = 1250 * (_requested["rest"] + 1 + max_retry - retries);
25                         setTimeout(function() {
26                                 _ajax_get(url, async, fn, retries - 1);
27                             }, delay);
28                     } else { // give up...
29                         _requested["rest"] -= 1;
30                         if (_requested["rest"] == 0) {
31                             _requested["total"] = 0;
32                             _requested["rest"] = 0;
33                         }
34                         $("#progress-bar").progressbar("option", "max", _requested["total"]);
35                         $("#progress-bar").progressbar("option", "value", _requested["total"] - _requested["rest"]);
36                     }
37                 }
38     };
39     _requested["total"] += 1;
40     _requested["rest"] += 1;
41     $("#progress-bar").progressbar("option", "max", _requested["total"]);
42     $("#progress-bar").progressbar("option", "value", _requested["total"] - _requested["rest"]);
43     var delay = 1250 * (_requested["rest"] - 1);
44     setTimeout(function() {
45             jQuery.ajax(conf);
46         }, delay);
47 }

250 とか 750 だとせっかち過ぎなことがわかり、なのでもっと穏健に 1250 にしてある。だいぶ too many… 頻度は減った。下の「進化版」は差し替えておいた。

23:30追記: まだ100%にならんやんけ、と整理

まだしょーもないミスがあったが、その前にええ加減整理せぃ、と:

  1 // ------------------------------------------
  2 function AjaxHtmlGetter(progress_callback) {
  3     this._requested = {total: 0, rest: 0};
  4     this._MAX_RETRY = 10;
  5     this._progress_callback = progress_callback;
  6 }
  7 AjaxHtmlGetter.prototype._incl = function(url) {
  8     var _this = this;
  9     _this._requested["total"] += 1;
 10     _this._requested["rest"] += 1;
 11     _this._progress_callback(
 12         _this._requested["total"] - _this._requested["rest"],
 13         _this._requested["total"], "inc", url);
 14 }
 15 AjaxHtmlGetter.prototype._decl = function(url) {
 16     var _this = this;
 17     _this._requested["rest"] -= 1;
 18     if (_this._requested["rest"] == 0) {
 19         _this._requested["total"] = 0;
 20     }
 21     _this._progress_callback(
 22         _this._requested["total"] - _this._requested["rest"],
 23         _this._requested["total"], "dec", url);
 24 }
 25 AjaxHtmlGetter.prototype.get = function(url, fn, retries) {
 26     var _this = this;
 27     var conf = {
 28         async: true,
 29         url: url,
 30         type: "get",
 31         dataType: "html",
 32         success: function(data) {
 33             _this._decl(url);
 34             fn(data);
 35         },
 36         error: function(xhr, textStatus, errorThrown) {
 37             // TODO: (1) check error type (especially 404)
 38             // TODO: (2) call back to caller on "give-up"
 39             if (retries === undefined) {
 40                 retries = _this._MAX_RETRY;
 41             }
 42             if (retries > 0) {
 43                 _this._decl("");
 44                 _this.get(url, fn, retries - 1);
 45             } else { // give up...
 46                 _this._decl(url);
 47             }
 48         }
 49     };
 50     _this._incl(url);
 51     var delay = 1250 * (_this._requested["rest"] - 1);
 52     setTimeout(function() {
 53         jQuery.ajax(conf);
 54     }, delay);
 55 }
 56 // ------------------------------------------
 57 var ajaxgetter = new AjaxHtmlGetter(function(curval, maxval, incdec, trigger_url) {
 58     var arrow = (incdec == "inc" ? "→ " : "← ");
 59     //$('#status-bar-text').html(arrow + trigger_url);
 60     //console.log(arrow + trigger_url);
 61     $("#progress-bar").progressbar("option", "max", maxval);
 62     $("#progress-bar").progressbar("option", "value", curval);
 63 });
 64 
 65 var _cache = {};
 66 var _requested = [];
 67 function get_characters(anime, fn) {
 68     var url = urlbuilder.get_characters(anime["malid"], anime["canonical_name"]);
 69     if (url in _cache) {
 70         fn(_cache[url]);
 71         return;
 72     }
 73     if (_requested.includes(url)) {
 74         setTimeout(function() {
 75             get_characters(anime, fn);
 76         }, 1250);
 77         return;
 78     }
 79     _requested.push(url);
 80     ajaxgetter.get(url, function (data) {
 81                 //console.log(url);
 82                 var result = parser.parse_characters_and_staff(data);
 83                 _cache[url] = result;
 84                 fn(result);
 85             });
 86 }
 87 function get_person(person, fn) {
 88     var url = urlbuilder.get_person(person["malid"], person["canonical_name"]);
 89     if (url in _cache) {
 90         fn(_cache[url]);
 91         return;
 92     }
 93     if (_requested.includes(url)) {
 94         setTimeout(function() {
 95             get_person(person, fn);
 96         }, 1250);
 97         return;
 98     }
 99     _requested.push(url);
100     ajaxgetter.get(url, function (data) {
101                 //console.log(url);
102                 var result = parser.parse_person(data);
103                 _cache[url] = result;
104                 fn(result);
105             });
106 }
107 function get_character(character, fn) {
108     var url = urlbuilder.get_character(character["malid"], character["canonical_name"]);
109     if (url in _cache) {
110         fn(_cache[url]);
111         return;
112     }
113     if (_requested.includes(url)) {
114         setTimeout(function() {
115             get_character(character, fn);
116         }, 1250);
117         return;
118     }
119     _requested.push(url);
120     ajaxgetter.get(url, function (data) {
121                 //console.log(url);
122                 var result = parser.parse_character(data);
123                 _cache[url] = result;
124                 fn(result);
125             });
126 }
127 
128 // ----------------------------
129 $("#progress-bar").progressbar({
130      value: 0,
131      create: function(event, ui) {
132          $(this).find('.ui-widget-header').css(
133              {'background': '#9f9'}
134          )}
135    });

class 的にしたわけな。AjaxHtmlGetter という名前はいかにも汎用に見せかけているが別にそうでもないので、真似するつもりがあるならちゃんと読めよ。何が硬直してるかは読めばすぐわかるから。(例えば名前で主張してる通りの「dataType: “html”」固定、は序の口。)

あと「キャッシュ」してるつもりが、半分しか使えてなかった。setTimeout 「前」にはまだキャッシュにいないのにそれがタイムアウトした暁には既にキャッシュがいる「かもしれない」のにも関わらずリクエスト発行しちゃってたわけで。こういうの、ちゃんと(少なくとも脳内では)絵にしなきゃダメね、案外気付きにくい。

主として以上が改造の要点だが、もう一つ、情報取得が済むたんびに cy を毎度ビルドが忙しすぎるので、「setTimeout で落ち着かせる」というアタシの好きなタイムアウトの使い方、を施しといた。少しだけ CPU に優しくなってる。

以上3点の改善版で下の「進化版」は更に差し替えた。

というわけで進化版


あんまし cytoscape.js に関係ない話ばかりですまんね

「結構付き物の話」なのではないかとワタシは思っているので、あえて cytoscape.js ネタ内に書いてる。「cytoscape.js でやりたいこと」に関係するもろもろの問題って、結構似かよるんじゃないかと思うんだよね。どうしても「大量のノードを扱う」以上は、なおかつ「WEB ベースでやるんだから何かダイナミックにデータを持ってきたいんでしょうよ」である以上は、ハマったり考えたりすることって、どんなの作る場合も似たり寄ったりになるんであろうと。

ほんとは画像エキスポート、コンテキストメニュー、ツールバーなどの拡張を試すのも一緒にやろうかとも思ったんだけど、ちょっとそれだとこのページだけに書き切るのはうるさい気がしたんで、これらは後で。