matarillo.com

The best days are ahead of us.

グラフの自動レイアウトに挑戦 #3 ノードをドラッグできるようにする

2022-08-13 08:57:22

作成したアプリケーションとソースコード

前回の課題

前回の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’」のノードが惜しい。

完成ソースコードの実行

グラフの自動レイアウトに挑戦 インデックスへ戻る