グラフの自動レイアウトに挑戦 #3 ノードをドラッグできるようにする
2022-08-13 08:57:22
作成したアプリケーションとソースコード
- アプリケーション: https://sqrjh6.csb.app/ (ブラウザで実行します)
- ソースコード: https://codesandbox.io/s/graph-layout3-sqrjh6
前回の課題
前回のPart2で作ったプログラムを実行するとノードが動き出した。エッジはばねでできており、ノード同士には反発力が働くというEadesのばねモデルの通りに動いているように見える。
しかし、ノードの動きが平衡状態に近づいても、結果として得られたレイアウトは美しいとは言いがたい。エッジが絡まっているからだ。シミュレーション実行中に、ノードの位置を自由に変更できないだろうか。
ノードをドラッグできるようにする
ノードをクリックして、マウスの左ボタンを押している間は、ノードの位置を自由に変更できるようにしよう。ドラッグしているノードはばねモデルに従わずに、マウスカーソルの位置に追随する。左ボタンを離せば、ノードは再びばねモデルにしたがって動き出す。
ついでに、スマホやタブレットにも対応しよう。マウスイベントだけでなく、タッチイベントにも反応するようにしたい。
これを実装するためにソースコードを変更する。
まずは、ノードオブジェクトに、ドラッグ中かどうかを判断するための locked
プロパティを追加することにした。
ロックされたノードは、ばねモデルによる移動をキャンセルする。
const moveAll = () => {
const dt = 0.1; // 時間の差分
for (let i = 0; i < nodes.length; i++) {
const n0 = nodes[i];
if (n0.locked) {
continue; // 移動をキャンセル
}
...
}
};
それから、ドラッグ中のノードがあれば取得する関数を追加した。
const findLockedNode = () => {
return nodes.find((n) => n.locked);
};
そして、イベントハンドラから呼び出され、ノードをロック/ドラッグ/アンロックする関数を実装した。
const onDown = (index) => {
const node = findLockedNode();
if (node) return;
nodes[index].locked = true;
};
const onMove = (r) => {
const node = findLockedNode();
if (!node) return;
node.r = r;
};
const onUp = () => {
const node = findLockedNode();
if (!node) return;
node.locked = false;
};
では、イベントハンドラを追加しよう。
iOS 13以降、マウスイベントとタッチイベントの両方に対応するには pointerdown
, pointerup
, pointermove
を対象にすればいいのだが、注意点がある。
pointerdown
は、ドラッグするノードを判定したいので、ノードを表す<g>
エレメントにハンドラをアタッチする。
(親エレメントのイベントで、クリック/タッチした場所に一番近いノードを計算する方法でもよいのだが、今回はそうしなかった)pointermove
は、ノードからはみ出すことがあるので、描画エリア全体をカバーしている、親の<g>
エレメントにハンドラをアタッチする。
pointerup
も同様に、親の<g>
エレメントにハンドラをアタッチする。- 描画エリアからポインターがはみ出した時はロック解除扱いにしたいので、
pointerleave
イベントも対象にする。 - SVGのラベルテキストを選択してしまわないように、
selectstart
イベントをキャンセルする。 - タッチ操作で画面がスクロールしてしまわないように、
touchmove
イベントをキャンセルする。
const attachEvents = () => {
for (const node of nodes) {
const el = node.element;
el.addEventListener("pointerdown", (e) => {
const index = Number(el.dataset.index);
onDown(index);
});
}
const g = document.getElementById("g");
g.addEventListener("pointermove", (e) => {
onMove({ x: e.pageX - center.x, y: e.pageY - center.y });
});
g.addEventListener("pointerup", (e) => onUp());
g.addEventListener("pointerleave", (e) => onUp());
g.addEventListener("selectstart", (e) => e.preventDefault());
g.addEventListener("touchmove", (e) => e.preventDefault());
};
ついでに、ロックされたノードは赤い円で描画するようにしよう。
const updateElements = () => {
...
for (const node of nodes) {
const g = node.element;
g.style.transform = `translate(${node.r.x}px, ${node.r.y}px)`;
const color = node.locked ? "red" : "white";
g.firstChild.style.fill = color;
}
};
適当に引っ張っていたら、エッジが絡まないように配置できた……いや、「b’」のノードが惜しい。