jQuery: Cytoscape.js お試せた21 (そうか、lock しちゃえばいいのか + cytoscape.js-grid-guide)

なんで気付かなかったのか。

いつものごとくで実例はこれからの直接の続き。

「選択ノードをマニュアルで移動」という行為で、これまではどうストレスだったのか、について、わざわざ動画を作って説明しようかと思ったが、よく考えたら automove 説明の後半が使えるな:

選択したものが、一個のノード移動にともなって「完全に同じように同期して動く」ことはもちろん必要、それも必要。けれども「選択状態を保ったままで」握ってるノードだけを移動したいことも非常に多いのはそう、「ノードを一箇所に集めたい」のようなことをしたいから、だよね。

ずーっといいアイディアがないなぁと思っていたんだけれど、「存在に気付いていながら気付いてなかった lock()」を急に思い出した。あぁ、そうだよ、こういうときに使いたいやつだったんだな、コレ。(眠りにつこうとする瞬間に思い出して飛び起きたの。)

「lock 中でっせ」のスタイルだけは不可欠ね。アタシのケースではノードはちょっと透明にしてあるので、ロック時は不透明、とするだけで結構視覚的にわかるのでこうしとく:

 1 // initialize cy
 2 var cy = window.cy = cytoscape({
 3     container: document.getElementById('cy'),
 4 
 5     // ... lock 出来ない設定…なんてのはないかな、なさそうだね、もしあったら外しとくこと。
 6     //     ワタシは見つけてないのでそういうのはないんだと思ってる。
 7     style: cytoscape.stylesheet()
 8         // ...    
 9         .selector("node:locked")
10         .css({
11             "background-opacity": 1.0,
12         })
13         // ...
14         }),
15 });

で、「lock しなはれ(云々)」を何でトリガーするかだが、ワタシの場合は cytoscape.js-context-menus 拡張を使っているのでそれで:

 1 var ctxm_menuItems = [
 2     // ...
 3     {
 4         "id": "lock_this",
 5         "content": "lock this node",
 6         "selector": "node:unlocked",
 7         "onClickFunction": function (event) {
 8             event.target.lock();
 9         },
10         "hasTrailingDivider": true,
11     },
12     {
13         "id": "unlock_this",
14         "content": "unlock this node",
15         "selector": "node:locked",
16         "onClickFunction": function (event) {
17             event.target.unlock();
18         },
19         "hasTrailingDivider": true,
20     },
21     {
22         "id": "lock_other_selnodes",
23         "content": "lock the other selected nodes",
24         "selector": "node:selected",
25         "onClickFunction": function (event) {
26             cy.elements("node:selected[id != '" + event.target.id() + "']").lock();
27         },
28         //"hasTrailingDivider": true,
29     },
30     {
31         "id": "unlock_other_selnodes",
32         "content": "unlock the other selected nodes",
33         "selector": "node:selected",
34         "onClickFunction": function (event) {
35             cy.elements("node:selected[id != '" + event.target.id() + "']").unlock();
36         },
37         "hasTrailingDivider": true,
38     },
39     {
40         "id": "unlock_all",
41         "content": "unlock all nodes",
42         "selector": "node, edge",
43         "onClickFunction": function (event) {
44             cy.elements("node:locked").unlock();
45         },
46         //"hasTrailingDivider": true,
47     },
48     {
49         "id": "lock_all",
50         "content": "lock all nodes",
51         "selector": "node, edge",
52         "onClickFunction": function (event) {
53             cy.elements("node:unlocked").lock();
54         },
55         "hasTrailingDivider": true,
56     },
57     // ...
58 ];
59 cy.contextMenus({
60     menuItems: ctxm_menuItems
61 });

ただねぇ、このコンテキストメニュー、ノードとエッジの上でしか使えないの? キャンバス上でトリガーする術が見つからず、なので滅茶苦茶不自然なインターフェイスになっちゃった。ほかの UI 考えたほうがいいなぁ。それはそれで設計は悩ましいけれど。(2018-02-01追記: 末尾の追記参照。)

ほんとは「結合ノードをロック」とかも考えたんだけれど、そして「たまに使いそう」とも思ったけど、結構わかりにくい振る舞いになるんで、今はやめておいた。今ワタシの場合は「結合ノードを選択」というメニューを付けてるので「選択ノードをロック」はすぐに出来ちゃうわけで、なのでなくてもいい。


で。いずれ試そうと思っていたのが cytoscape.js-grid-guide ね、誰でもすぐに何するものかはわかるさ、たとえば MS Visio だとか MS Excel (のお絵かきツール) を思い出してもらえれば、「グリッド強制モード」があるでしょ、あれね。

これさぁ、今回の lock すりゃいいのかの話と相性いいんだよね。というか、「選択状態だと同期的にしか動かせない問題」が解決しない限りは grid-guide は絶対「じぇんじぇんおいしくないわいな」と思っていた、ということ。

「CDN にはいない」という一点を除けば、デモが気合入りまくってるので滅茶苦茶簡単に導入出来るはず。デモの振る舞いから「ワタシには snapToGridDuringDrag が不可欠かなと思った。好みもあると思うけれど、snapToGridOnReleaseのみだとワタシはわかりにくく感じてしまうんだよね。

で、当たり前なんだけれど、一番の懸念は「性能」。ノード量が多くなったら使い物にならん、とかないか、てこと。

これはまぁ予想的中というか。「大量というほどではないがそこそこなノードを持ちうる」なワタシが扱ってる「声優関連図作り子ちゃん」ではすぐにハングアップ起こした。そもそもエッジの量が多いこともあって、それそのものもそのうちどうにかせねばと思っている(エッジをもっと隠すタイミング増やしたり、根本的に非表示選べたりとか)けれど、grid-guide そのものに関しては drawGrid: false しないとダメだね。ほかにもチューニング出来そうなら色々やったほうが良さそうだ。ともあれこんな:

 1 cy.gridGuide({
 2     // On/Off Modules
 3     /* From the following four snap options, at most one should be true at a given time */
 4     snapToGridOnRelease: true, // Snap to grid on release
 5     //snapToGridDuringDrag: false, // Snap to grid during drag
 6     snapToGridDuringDrag: true, // Snap to grid during drag
 7     //snapToAlignmentLocationOnRelease: false, // Snap to alignment location on release
 8     //snapToAlignmentLocationDuringDrag: false, // Snap to alignment location during drag
 9     //distributionGuidelines: false, // Distribution guidelines
10     //geometricGuideline: false, // Geometric guidelines
11     //initPosAlignment: false, // Guideline to initial mouse position
12     //centerToEdgeAlignment: false, // Center to edge alignment
13     //resize: false, // Adjust node sizes to cell sizes
14     //parentPadding: false, // Adjust parent sizes to cell sizes by padding
15     //drawGrid: true, // Draw grid background
16     drawGrid: false, // Draw grid background
17 
18     // General
19     //gridSpacing: 20, // Distance between the lines of the grid.
20 
21     // Draw Grid
22     //zoomDash: true, // Determines whether the size of the dashes should change when the drawing is zoomed in and out if grid is drawn.
23     //panGrid: false, // Determines whether the grid should move then the user moves the graph if grid is drawn.
24     //gridStackOrder: -1, // Namely z-index
25     //gridColor: '#dedede', // Color of grid lines
26     //lineWidth: 1.0, // Width of grid lines
27 
28     // Guidelines
29     //guidelinesStackOrder: 4, // z-index of guidelines
30     //guidelinesTolerance: 2.00, // Tolerance distance for rendered positions of nodes' interaction.
31     //guidelinesStyle: { // Set ctx properties of line. Properties are here:
32     //    strokeStyle: "#8b7d6b", // color of geometric guidelines
33     //    geometricGuidelineRange: 400, // range of geometric guidelines
34     //    range: 100, // max range of distribution guidelines
35     //    minDistRange: 10, // min range for distribution guidelines
36     //    distGuidelineOffset: 10, // shift amount of distribution guidelines
37     //    horizontalDistColor: "#ff0000", // color of horizontal distribution alignment
38     //    verticalDistColor: "#00ff00", // color of vertical distribution alignment
39     //    initPosAlignmentColor: "#0000ff", // color of alignment to initial mouse location
40     //    lineDash: [0, 0], // line style of geometric guidelines
41     //    horizontalDistLine: [0, 0], // line style of horizontal distribution guidelines
42     //    verticalDistLine: [0, 0], // line style of vertical distribution guidelines
43     //    initPosAlignmentLine: [0, 0], // line style of alignment to initial mouse position
44     //},
45 
46     // Parent Padding
47     //parentSpacing: -1 // -1 to set paddings of parents to gridSpacing
48 });

とりあえずデフォルトじゃないのは2箇所だけ、から始めてみる。

基本的に結構いい感じではあるんだけれど、ただもちろん「layout が自動でいいあんばいで配置」時点での強制は、「何もしなければ」何もしないので、グリッドのアランメントに沿わない状態から始まっちゃうんだよね。だから「タップすると勝手にアラインメントされる(つまり勝手に動く)」のが気持ち悪いといえば気持ち悪い。本来自動位置決めとともに使うユースケース向けのものではないからね、致し方ないといえば致し方ない。一応 cytoscape 本体では (null などの特殊なものを除いて) layout が transform を持ってるので、たぶんそこで位置合わせ強制するチャンスはあると思うし、grid-guide でも何らか手段があるのかもしらんし。

あと細かいところだと、タップ時点で「選択行為」に関与してしまっているようにみえる。これを使う前は「つかんでドラッグ開始してリリース」という行為が選択をトリガーすることはなかったんだけど、grid-guide を入れるだけで「つかんでドラッグ開始してリリース」すると選択状態が切り替わってしまう。これを是とするか否とするか。個人的には滅茶苦茶気持ち悪い、この振る舞い。

もう一つが、どうもコンテナの外に何か固有の領域を作ってる? これ入れただけでワタシのヤツではブラウザの縦スクロールバーが消えなくなってしまった。

てわけで、ほんの少しやってみただけでも「いいところと悪いところがすぐにわかった」ので、ちょっと使い続けるかどうかはわからないけれど、「入れてみた実際のヤツ」がこれ(lock/unlock の件も込みで):


drawGrid 以外にも性能への懸念はあるので、ワタシのヤツで「現実に考えられる「最も穏健な最大ノード数見積もり」」で試してみて、それで何らか問題ありそうなら使うのはやめる。それに「縦スクロールバー」はこれが解決出来ないようなら UI としてかなり最悪なので、性能問題がなくてもこれが理由でやめる可能性もある。


17:30追記:
縦スクロールバーの件はやはり cy コンテナの外の領域に canvas を作ってる(のか既存のを広げてるのかどっちか)。無論 cy な div のサイズを少し小さくすれば当座縦スクロールバーを消すだけなら出来るが、ワタシのケースでは cy な div を height: 78% にしないと消えず、これだとかなりみっともない UI になっちゃう。許容範囲外だなぁ。

あと… grid-guide のせいかと思ったら違ったことに気付いたりはした。なぜか起動直後にエッジ選択関係の操作が出来なくなっちゃってる。いつからだ? 付け外してみて grid-guide のせいではないことは確認。疑って悪かった。

一応「こういうものもあって使える時は使えると思うぜっ」という紹介の役目は済んだので、うーん、自分のでは grid-guide 使うのやめちゃう。縦スクロールバーの件がワタシにはイタ過ぎるし、自動レイアウトとはあんまし馴染まんからね。


2018-02-01追記:
「このコンテキストメニュー、ノードとエッジの上でしか使えないの? キャンバス上でトリガーする術が見つからず、なので滅茶苦茶不自然なインターフェイスになっちゃった」について、解決。

完全に節穴で、デモでやってるんだから、どこ探してたんだよ、て感じ。coreAsWell を真にすればいいだけだった:

 1 var ctxm_menuItems = [
 2     // ...
 3     {
 4         "id": "unlock_all",
 5         "content": "unlock all nodes",
 6         //"selector": "node:locked", // セレクタを指定しないことも出来た。
 7         "onClickFunction": function (event) {
 8             cy.elements("node:locked").unlock();
 9         },
10         //"hasTrailingDivider": true,
11         "coreAsWell": true,
12     },
13     {
14         "id": "lock_all",
15         "content": "lock all nodes",
16         //"selector": "node:unlocked",
17         "onClickFunction": function (event) {
18             cy.elements("node:unlocked").lock();
19         },
20         "hasTrailingDivider": true,
21         "coreAsWell": true,
22     },
23     // ...
24 ];
25 cy.contextMenus({
26     menuItems: ctxm_menuItems
27 });