0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MYJLabAdvent Calendar 2024

Day 25

ReactでCytoscapeに入門する

Last updated at Posted at 2024-12-24

こんにちは!最終日を担当する ea gitro です!

昨日は @iraka さんの「プログラミングで作った図形を刺しゅうしてみよう!」でした。
実際に手に触れるものを作るのってすごくいいですよね!!
かくいう私もプログラムばっか書いてはいますが、実は実体がわからないプログラミングより、手で触れるものを作る方が好きだったりします。(いいのかそれで??)

なので昨日の記事は 「"手で触れない" プログラミングで、"手で触れれる" 刺繍を作る」という意味でとても魅力的に感じました!!

はじめに

さて今日の内容は Cytoscape.js X React/Next です。
最近 Cytoscape でグラフを描く機会があったのですが、React を用いた入門記事はあまりなかったのでここに書こうと思った次第です。

(実はすでに react-cytoscapejs という wrapper ライブラリがあり、それの記事はちらほら見かけるのですが、
すでに更新が止まっており、使いにくい部分もあるためここでは使いません。またそれに代わる OSS を作成している途上であり公開予定です。)

環境構築

パッケージ管理には pnpm を使います。

Typescript を使います。 Tailwind はどちらでも構いません。

> pnpm -v
9.11.0

> pnpx create-next-app@latest

> pnpm add cytoscape @types/cytoscape   # cytoscape 

> pnpm list 
dependencies:
@types/cytoscape 3.21.8
cytoscape 3.30.4
next 15.0.3
react 19.0.0-rc-66855b96-20241106
react-dom 19.0.0-rc-66855b96-20241106

devDependencies:
@types/node 20.17.9
@types/react 18.3.12
@types/react-dom 18.3.1
postcss 8.4.49
tailwindcss 3.4.16
typescript 5.7.2

完成図

先に完成した姿をお見せします。

react-cytoscape-101-hover.gif

この記事を見ればこのようなものが React でつくれるようになります。(もちろんここから発展させることもできます。)

早速グラフを作成

面倒なので src/app 直下の page.tsx に簡単なグラフを作成します。

以下のような関係のグラフを作ります。

react-cytoscape-101-image.png

ソース全体像

まずはどのような命令でグラフを描画するかを説明します。

/components/Graph.tsx を作成し、以下のようなコンポーネントを作ります。
これがグラフ描画のためのコンポーネントの全体像となります。

import cytoscape from "cytoscape";
import { useRef } from "react";


function Graph() {
    const cyElemRef = useRef<HTMLDivElement>(null);     // グラフ(canvas)が描画される領域

    // 描画後 => useEfect 
    useEffect(()=>{

        // cytoscape に渡すオプション。複数のものがあるが代表的なものだけを述べる
        const cyInstance = cytoscape({                          
            container: cyElemRef.current,                   // 描画領域を渡す。js では `getElementById()` 等を使う
            elements: [/* グラフデータを渡す */],              // 2通り渡し方がある
            style: [/* 各要素や背景の style を指定できる */], // selector というものを使って適用範囲を指定します。 https://js.cytoscape.org/#selectors https://js.cytoscape.org/#style
            layout: {                                        // レイアウトアルゴリズム
                name: "各要素を配置するアルゴリズムの名前"      // https://js.cytoscape.org/#layouts
            }
        })

        // creanup 処理
        return ()=>{
            cyInstance.destroy()
        }
    },[])

    return(
        <div ref={cyElemRef} style={{       // 描画領域を確保
            width: 500,
            height: 500
        }} />
    )
}

ちょっと補足

  • 描画領域について

    • Cytoscape は canvas に描画をします。Javascript/HTML であればその際に container に getElementById() 等で描画領域を渡してあげればいいです。
    • しかしながらこれは React なため、 Ref を用います。
      • Ref を使うと React でDOMを参照できるようになります。詳しくは公式ドキュメントをどうぞ "ref で DOM を操作する"
    • また Container に渡す描画領域は、すでに描画されているものでなくてはなりません。 よって useEffect で先に1度だけレンダリングを行います。
    • useEffect にはメモリリークを防ぐためメモリを解放するためのクリーンアップ機構があります。 useEffect の返値として返す関数がそれにあたります。
    • 以上より、必然的に Next では "use client" になります。
  • 描画領域について(2)

    • 描画領域となるコンポーネントには、style/className 等で大きさを指定する必要があります。
  • Layout について

    • 今回はやらないので詳しく述べませんが、Cytoscape には様々なレイアウトアルゴリズムがあります。 (see: https://js.cytoscape.org/#layouts)
    • また拡張機能(プラグイン)もあり、様々なレイアウトを適用することが出来ます。

まずはグラフのデータの作成

elements:[] プロパティです。

@types/cytoscape では以下のような型として表されています。
今回は data のみ指定します。

長いので省略
interface ElementDefinition {
    group?: ElementGroup | undefined;
    data: NodeDataDefinition | EdgeDataDefinition;
    /**
     * Scratchpad data (usually temp or nonserialisable data)
     */
    scratch?: Scratchpad | undefined;
    /**
     * The model position of the node (optional on init, mandatory after)
     */
    position?: Position | undefined;
    /**
     * can alternatively specify position in rendered on-screen pixels
     */
    renderedPosition?: Position | undefined;
    /**
     * Whether the element is selected (default false)
     */
    selected?: boolean | undefined;
    /**
     * Whether the selection state is mutable (default true)
     */
    selectable?: boolean | undefined;
    /**
     * When locked a node's position is immutable (default false)
     */
    locked?: boolean | undefined;
    /**
     * Wether the node can be grabbed and moved by the user
     */
    grabbable?: boolean | undefined;
    /**
     * Whether the element has passthrough panning enabled.
     */
    pannable?: boolean | undefined;
    /**
     * a space separated list of class names that the element has
     */
    classes?: string[] | string | undefined;
    /**
     *  CssStyleDeclaration;
     */
    style?: CssStyleDeclaration | undefined;
    /**
     * you should only use `style`/`css` for very special cases; use classes instead
     */
    css?: Css.Node | Css.Edge | undefined;
}

interface NodeDataDefinition extends ElementDataDefinition {
    id?: string | undefined;
    parent?: string | undefined;
    [key: string]: any;
}

interface EdgeDataDefinition extends ElementDataDefinition {
    id?: string | undefined;

    /**
     * the source node id (edge comes from this node)
     */
    source: string;
    /**
     * the target node id (edge goes to this node)
     */
    target: string;

    [key: string]: any;
}

interface ElementDataDefinition {
    /**
     * elided id => autogenerated id
     */
    id?: string | undefined;
    position?: Position | undefined;
}

大体以下のようなものが指定されていれば OK です。

公式の説明も網羅したものがないのでなかなかわかりにくいですが、ここら辺を見れば大丈夫でしょう。
(実は指定の仕方には2種類あるのですが、ここでは下記のものを採用します。もう1種類は elements:{ nodes:[], edges:[] }) と分ける方法です。)

また data には自分でカスタムプロパティを設定することができ、それを style: などで参照することが出来ます。

// 必要最低限
type ElemType = (ElemNode | ElemEdge)[]         // `elements:` として渡す配列
type ElemNode = {
    data: {
        id: string,
        label?: string,     // ラベル
    },
    position?:{             // ノードの座標。あってもなくても
        x:number,
        y:number
    }
}

type ElemEdge = {
    data: {
        id: string,
        source: string,     // エッジの根本となる Node の id
        target: string,     // エッジの宛先の Node の id
        lebel?: string 
    }
}

今回は以下のようなデータ構造として表します。
今回色を指定するのに edgecolor というカスタムプロパティを使用しています。

// `@/consts.ts`

export const elems = [
    // Nodes
    {
        data: {
            id: "node-a",
            label: "Node A"
        },
    },
    {
        data: {
            id: "node-b",
            label: "Node B"
        }
    },
    {
        data: {
            id: "node-c",
            label: "Node C",
        }
    },
    {
        data: {
            id: "node-d",
            label: "Node D"
        }
    },
    {
        data: {
            id: "node-e",
            label: "Node E"
        }
    },
    // Edges
    // color というカスタムプロパティを追加
    {
        data: {
            id: "edge-a",
            source: "node-a",
            target: "node-b",
            label: "Edge A",
            color: "#ee827c"    // カスタムプロパティ
        }
    },
    {
        data: {
            id: "edge-b",
            source: "node-c",
            target: "node-b",
            label: "Edge B",
            color: "#38b48b"    // カスタムプロパティ
        }
    },
    {
        data:{
            id: "edge-c",
            source: "node-e",
            target: "node-d",
            label: "Edge C",
            color: "#89c3eb"    // カスタムプロパティ
        }
    }
] 

一度描画してみる

今はこのようになっているはずです。一度描画してみます。

/src/app/page.tsx の中身を全部消して、 <Graph/> だけを返すようにします。

// "@/components/Graph.tsx"
"use client"
import { elems } from "@/consts";
import cytoscape from "cytoscape";
import { useEffect, useRef } from "react";

export default function Graph() {
    const cyElemRef = useRef<HTMLDivElement>(null);
    useEffect(() => {
        const cyInstance = cytoscape({
            container: cyElemRef.current,
            elements: elems
        })

        return ()=>{
            cyInstance.destroy()
        }
    }, [])

    return (
        <div ref={cyElemRef} style={{
            width: 500,
            height: 500
        }} />
    )
}

react-cytoscape-101-temporary.png

何かが違うような気がします。

では style を修正します。

style の修正

style を修正します。 style は以下のように指定します。


cytoscape({
    style: [
        {
            selector: "スタイルを適用する範囲",  // https://js.cytoscape.org/#selectors
            style: {                            // https://js.cytoscape.org/#style
                /* 各指定 */
            }
        },
    ]
})

以下のようにしてみましょう

// "@/consts.ts"
export const styles = [
    {
        selector: "node",   // すべてのノードに適用
        style: {
            width: "80px",
            height: "80px",
            label: "data(label)",   // このようにして data にアクセスすることで label を指示する
            "border-width": 2,
            "text-valign": "center" // ノードの縦方向に中心に
        } as const      // ts の場合は as const を付けないとエラーになる場合がある
    },
    {
        selector: "edge",
        style: {
            width: 2,
            label: "data(label)",
            "line-color": "data(color)",    // カスタムプロパティにもアクセスできる => カスタムプロパティで色指定
            "text-background-color": "#e8ecef",
            "text-background-shape": "rectangle",
            "text-background-opacity": 1,       // 初期値で0に設定されているので 1 にしないとラベルのテキストボックスは見えない
        } as const 
    }
]


// @/components/Graph.tsx
export default function Graph(){
    useEffect(()=>{
        const cyInstance = cytoscape({
            elements: elems,
            style: styles,      // <= ここに入る
            ...
    })
}

すると以下のようになりました!

react-cytoscape-101-temporary-2.png

だいぶいい感じです!

event を付けてみる

ではスタイルはよさそうなのでイベントを付けてみましょう!

Cytoscape.js では特定のノードやエッジなどにイベントをつけることが出来ます。
今回はホバーしたノードと隣接するノードとエッジに色を付けてみることにしましょう!

  • イベントを付けるには cy.on( events [, selector], function(event) ) を用います。

    • selector は先ほどのものと同じです。イベントの種類は公式ドキュメントに載っています。

    • ここでは event として mouseover(ターゲットの上にカーソルが来たとき), mouseout(カーソルがターゲットから外れたとき)を使います。

  • さらに「色を付ける」ためにcssクラスを用います。

    • .active というクラス作成し、ホバーしているものに付与します

先ほど cyInstance という変数を作りましたが、そこから .on() を持ってきます

// Graph.tsx
useEffect(()=>{
    const cyInstance = cytoscape({...})
    cyInstance.on(      
        "mouseover",    // ターゲットに入ったとき
        "node",         // すべてのノードに対して
        (e)=>{          // ハンドラ
            const target = e.target;    // ホバーされたノード
            const connEdges = target.connectedEdges();      // 隣接エッジたち
            const connNodes = connEdges.connectedNodes();   // 隣接エッジに隣接したノードたち
            
            // e のなかにも cy がある
            // その中から .batch() という再描画させずに要素を操作する関数を使う。
            e.cy.batch(()=>{
                // addClass で対象にcssクラスを付与する
                target.addClass("active")   // .active というクラスを付与
                connEdges.addClass("active")
                connNodes.addClass("active")
            })
        }
    );

    cyInstance.on(
        "mouseout",     // ターゲットが外れた時
        "node",
        (e)=>{
            // 面倒なのですべてのノードから `.active` クラスをはぎ取ります
            e.cy.batch(() => {
			    e.cy.elements().removeClass("active");
			});
        }
    )
},[])

style に .active クラスを追加します。 edge/node それぞれに追加します。


export const styles = [
    ...,
    {
        selector: `node.active`,            // node の中で active クラスのもの
        style: {
            "border-color": "#ffff00",      // 黄色にしてみる
            "border-width": 2,
        },
    },
    {
        selector: `edge.active`,
        style: {
            "line-color": "#ffff00",
        },
    },
    ...,
]

するとこんな感じになったと思います!

react-cytoscape-101-hover.gif

無事ホバーイベントを付けることが出来ました!

全体

ここまで一緒に書いたコードです。

// src/app/page.tsx

import Graph from "@/components/Graph";
import Image from "next/image";

export default function Home() {
  return (
    <Graph/>
  );
}
// src/components/Graph.tsx

"use client"
import { elems, styles } from "@/consts";
import cytoscape from "cytoscape";
import { useEffect, useRef } from "react";

export default function Graph() {
    const cyElemRef = useRef<HTMLDivElement>(null); // グラフ(canvas)が描画される領域

    // 描画後 => useEfect 
    useEffect(() => {

        // cytoscape に渡すオプション。
        const cyInstance = cytoscape({
            container: cyElemRef.current,   // 描画領域を渡す。js では `getElementById()` 等を使う
            elements: elems,
            style: styles
        })


        // イベントを付ける(ホバーイベント)
        cyInstance.on(      
            "mouseover",    // ターゲットに入ったとき
            "node",         // すべてのノードに対して
            (e)=>{          // ハンドラ
                const target = e.target;    // ホバーされたノード
                const connEdges = target.connectedEdges();      // 隣接エッジたち
                const connNodes = connEdges.connectedNodes();   // 隣接エッジに隣接したノードたち
                
                // e のなかにも cy がある
                // その中から .batch() という再描画させずに要素を操作する関数を使う。
                e.cy.batch(()=>{
                    // addClass で対象にcssクラスを付与する
                    target.addClass("active")   // .active というクラスを付与
                    connEdges.addClass("active")
                    connNodes.addClass("active")
                })
            }
        );
        cyInstance.on(
            "mouseout",     // ターゲットが外れた時
            "node",
            (e)=>{
                // 面倒なのですべてのノードから `.active` クラスをはぎ取ります
                e.cy.batch(() => {
                    e.cy.elements().removeClass("active");
                });
            }
        )


        // creanup 処理
        return ()=>{
            cyInstance.destroy()
        }
    }, [])

    return (
        <div ref={cyElemRef} style={{ // 描画領域を確保
            width: 500,
            height: 500
        }} />
    )
}
// src/consts.ts

export const elems = [
    // Nodes
    {
        data: {
            id: "node-a",
            label: "Node A"
        },
    },
    {
        data: {
            id: "node-b",
            label: "Node B"
        }
    },
    {
        data: {
            id: "node-c",
            label: "Node C",
        }
    },
    {
        data: {
            id: "node-d",
            label: "Node D"
        }
    },
    {
        data: {
            id: "node-e",
            label: "Node E"
        }
    },
    // Edges
    // color というカスタムプロパティを追加
    {
        data: {
            id: "edge-a",
            source: "node-a",
            target: "node-b",
            label: "Edge A",
            color: "#ee827c"
        }
    },
    {
        data: {
            id: "edge-b",
            source: "node-c",
            target: "node-b",
            label: "Edge B",
            color: "#38b48b"
        }
    },
    {
        data:{
            id: "edge-c",
            source: "node-e",
            target: "node-d",
            label: "Edge C",
            color: "#89c3eb"
        }
    }
] 

export const styles = [
    {
        selector: "node",   // すべてのノードに適用
        style: {
            width: "80px",
            height: "80px",
            label: "data(label)",   // このようにして data にアクセスすることで label を指示する
            "border-width": 2,
            "text-valign": "center" // ノードの縦方向に中心に
        } as const      // ts の場合は as const を付けないとエラーになる場合がある
    },
    {
        selector: "edge",
        style: {
            width: 2,
            label: "data(label)",
            "line-color": "data(color)",    // カスタムプロパティにもアクセスできる
            "text-background-color": "#e8ecef",
            "text-background-shape": "rectangle",
            "text-background-opacity": 1,
        } as const 
    },
    {
        selector: `node.active`,            // node の中で active クラスのもの
        style: {
            "border-color": "#ffff00",      // 黄色にしてみる
            "border-width": 2,
        },
    },
    {
        selector: `edge.active`,
        style: {
            "line-color": "#ffff00",
        },
    },
]

終わりに

今回はこれで終わりにします。

入門すると言っても少しだけ長くなってしまいました。

GitHub にリポジトリをあげたのでもし必要なら参照ください。

今日学んだことがわかれば Cytoscape も怖くないはずです!
皆さんもネットワークグラフ図を書きましょう!!!!!!

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?