mcpField
2
2d-or-3d-force-graph-with-lit

2d-or-3d-force-graph-with-lit

github.com/rodydavisデータ・データベース6064 回インストール2 週間前に更新
リポジトリ

2d-or-3d-force-graph-with-lit について

Litで力学ベースのグラフを2Dまたは3Dで表示するWebコンポーネントを作るスキルです。インラインJSON、ファイル、URLからグラフデータを扱いたい開発者向けです。

主な機能

  1. 012D・3Dグラフの描画
  2. 02インラインまたはファイルのデータ
  3. 03URLからの遅延読み込み
  4. 04ホバーコールバックとノード名
  5. 05ノードとリンクの生成

活用シーン

  1. 01Webコンポーネントで関連データを可視化
  2. 02リモートJSONファイルからグラフを読み込み

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rodydavis/skills --skill 2d-or-3d-force-graph-with-lit

インストール後、Claude Code で/2d-or-3d-force-graph-with-litを入力して読み込まれたか確認します。

詳細

Lit を使った 2D/3D 力学グラフ

この記事では、Lit を使って 2D/3D 力学グラフを作成する方法を説明します。

要約 最終的なソースコードはこちら、オンラインのデモもご覧ください。

前提条件

  • Vscode
  • Node >= 16
  • Typescript

はじめに

まず、ターミナルでプロジェクトの場所に移動し、次のコマンドを実行します。

npm init @vitejs/app --template lit-ts

次に、プロジェクト名として lit-force-graph を入力します。プロジェクトを vscode で開き、依存関係をインストールします。

cd lit-force-graph force-graph
npm i lit 3d-force-graph
npm i -D @types/node
code .

vite.config.ts を次のように更新します。

import { defineConfig } from "vite";
import { resolve } from "path";

export default defineConfig({
  base: "/lit-force-graph/",
  build: {
    lib: {
      entry: "src/lit-force-graph.ts",
      formats: ["es"],
    },
    rollupOptions: {
      input: {
        main: resolve(__dirname, "index.html"),
      },
    },
  },
});

テンプレート

index.html を開き、次のように更新します。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lit Force Graph</title>
    <script type="module" src="/src/lit-force-graph.ts"></script>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <lit-force-graph>
      <script type="application/json">
        {
          "name": "Lit Force Graph",
          "description": "A force graph built with Lit",
          "nodes": [
            {
              "id": "1",
              "name": "Node 1"
            },
            {
              "id": "2",
              "name": "Node 2"
            },
            {
              "id": "3",
              "name": "Node 3"
            },
            {
              "id": "4",
              "name": "Node 4"
            }
          ],
          "links": [
            {
              "source": "1",
              "target": "2"
            },
            {
              "source": "1",
              "target": "3"
            },
            {
              "source": "2",
              "target": "3"
            },
            {
              "source": "2",
              "target": "4"
            },
            {
              "source": "3",
              "target": "4"
            },
            {
              "source": "4",
              "target": "1"
            }
          ]
        }
      </script>
    </lit-force-graph>
  </body>
</html>

ここではグラフデータを JSON として渡していますが、リモートまたはローカルファイルを指定する src 属性を設定することもできます。コンポーネントにグラフデータを直接設定することも可能です。

スタイル

public/style.css ファイルを作成して開き、次のように更新します。

body {
  margin: 0;
  padding: 0;
  overflow: hidden;
  font-size: 12px;
  font-family: sans-serif;
  position: relative;
  width: 100%;
  height: 100%;
}

lit-force-graph {
  width: 100%;
  height: 100vh;
}

:root {
  --graph-background-color: #eee;
  --graph-foreground-color: #000;
  --graph-line-color: rgb(90, 90, 90);
  --graph-node-color: rgb(218, 14, 14);
}

@media (prefers-color-scheme: dark) {
  :root {
    --graph-background-color: #000;
    --graph-foreground-color: #fafafa;
    --graph-line-color: rgb(214, 214, 214);
    --graph-node-color: rgb(228, 8, 8);
  }
}

Web コンポーネント

コンポーネントを更新する前に、my-element.ts の名前を lit-force-graph.ts に変更します。

lit-force-graph.ts を開き、次のように更新します。

import { html, css, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";

export const tagName = "lit-force-graph";

@customElement(tagName)
export class LitForceGraph extends LitElement {
  static styles = css`
    :host {
      background-color: var(--graph-background-color, #000011);
      color: var(--graph-foreground-color, #ffffff);
      width: var(--graph-width, 100%);
      height: var(--graph-height, 100vh);
    }

    #graph {
      width: 100%;
      height: 100%;
      width: var(--graph-width, 100%);
      height: var(--graph-height, 100vh);
    }

    #controls {
      position: absolute;
      top: 20px;
      right: 20px;
      z-index: 100 !important;
      display: flex;
      flex-direction: column;
      align-items: flex-end;
    }
    #controls div {
      padding: 5px;
    }

    #info {
      position: absolute;
      top: 10px;
      left: 10px;
      z-index: 100 !important;
      display: flex;
      flex-direction: column;
      align-items: flex-start;
    }

    #tooltips {
      position: absolute;
      bottom: 10px;
      left: 10px;
      right: 10px;
      display: flex;
      flex-direction: row;
      align-items: center;
      text-align: center;
      justify-content: center;
    }

    .node-tooltip {
      background-color: var(--graph-foreground-color, #ffffff);
      color: var(--graph-background-color, #000011);
      border-radius: 5px;
      font-size: 12px;
      padding: 5px;
      opacity: 0.67;
    }

    #graph-description {
      opacity: 0.67;
    }

    .scene-tooltip {
      color: var(--graph-foreground-color, #ffffff);
      background-color: transparent;
      display: none;
    }
  `;

  @query("#graph") graph!: HTMLElement;
  @property() src = "";
  @property() mode = "2D";

  render() {
    return html` <main
      accept="application/json"
      @drop="${this.onDrop}"
      @dragover="${(e: Event) => e.preventDefault()}"
    >
      <div id="graph"></div>
      <div id="controls">
        <div>
          <label for="render-mode">Render mode</label>
          <select id="render-mode" @change=${this.onChangeMode}>
            
          </select>
        </div>
      </div>
      <div id="info">
        
      </div>
      <div id="tooltips">
        
      </div>
    </main>`;
  }

  override async firstUpdated() {
    await this.refresh();
    const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
    prefersDark.addEventListener("change", () => {
      this.refresh();
    });
  }

  override attributeChangedCallback(
    name: string,
    _old: string | null,
    value: string | null
  ): void {
    if (name === "src" && value) {
      this.refresh();
    }
    if (name === "data" && value) {
      this.setData(JSON.parse(value));
    }
    if (name === "mode" && value) {
      this.mode = value;
      if (this.data) {
        this.setData({ ...this.data! });
      }
    }
    super.attributeChangedCallback(name, _old, value);
  }

  /**
   * Set the graph data and update the renderer
   *
   * @param data Graph JSON
   */
  setData(data: GraphData) {
    this.data = data;
    // TODO: Render the graph!
  }

  private async refresh() {
    // Get json from script tag
    const children = Array.from(this.children);
    const elem = children.find((child) => child.tagName === "SCRIPT");
    if (elem) {
      // Render from script tag contents
      if (elem.textContent) {
        const data = JSON.parse(elem.textContent);
        if (data) this.setData(data);
        // Render from script tag src
      } else if (elem.hasAttribute("src")) {
        const url = elem.getAttribute("src")!;
        const data = await fetch(url).then((res) => res.json());
        if (data) this.setData(data);
      }
    } else if (this.src.length > 0) {
      // Render from src attribute
      const data = await fetch(this.src).then((res) => res.json());
      if (data) this.setData(data);
    }
  }

  private onDrop(e: DragEvent) {
    e.preventDefault();
    const files = e.dataTransfer?.files;
    if (files && files.length > 0) {
      const file = files[0];
      const reader = new FileReader();
      reader.onload = () => {
        const json = JSON.parse(reader.result as string);
        this.data = json;
        this.setData(json);
      };
      reader.readAsText(file);
    }
    return false;
  }

  private onChangeMode(e: Event) {
    const mode = (e.target as HTMLSelectElement).value;
    this.mode = mode;
    if (!this.data) return;
    this.setData({ ...this.data! });
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "lit-force-graph": LitForceGraph;
  }
}

ここでは、基本となるコンポーネントを作成し、JSON のドロップイベントを受け付けるほか、src 属性やテキストコンテンツに JSON を含む script タグを受け取れるようにしています。

CSS は、ツールチップを画面下部に、タイトルを左側に、レンダリング選択コントロールを右上に配置します。

Lit を使えば、コンポーネントのデータを複数の方法で設定しやすくなります。

インライン
<lit-source-graph>
  <script type="application/json">
    {
      "nodes": [],
      "links": []
    }
  </script>
</lit-source-graph>
遅延読み込み
<lit-source-graph></lit-source-graph>
<script>
  const elem = document.createElement("lit-source-graph");
  elem.src = "./graph-data.json";
  // Or remote url
  elem.src = "https://example.com/graph-data.json";
  // Or data from an object
  elem.data = { node: [], links: [] };
</script>

グラフデータ

src/classes/graph.ts ファイルを作成して開き、次のコードを追加します。

export class Graph {
  private ids = new Set();
  private graph: GraphData = {
    nodes: [],
    links: [],
  };

  addNode<T = any>(node: GraphNode<T>) {
    if (this.ids.has(node.id)) {
      return this.graph.nodes.find((n) => n.id === node.id)!;
    }
    this.ids.add(node.id);
    this.graph.nodes.push(node);
    return node;
  }

  addLink<T = any>(link: GraphLink<T>) {
    this.graph.links.push(link);
    return link;
  }

  toJSON() {
    return this.graph;
  }
}

export interface GraphNode<T = any> {
  id: string;
  name?: string;
  group?: string;
  value?: T;
}

export interface GraphLink<T = any> {
  source: string;
  target: string;
  name?: string;
  value?: T;
}

export interface GraphData<A = any, B = any> {
  name?: string;
  description?: string;
  nodes: GraphNode<A>[];
  links: GraphLink<B>[];
}

ここでは、重複を除外しながらノードとリンクを生成し、グラフデータを返すユーティリティクラスを作成しています。

src/classes/context.ts ファイルを作成して開き、次のコードを追加します。

import { GraphData, GraphNode } from "./graph";

export interface RenderContext {
  data: GraphData;
  element: HTMLElement;
  onHover: (node?: GraphNode) => void;
}

export type Renderer = (context: RenderContext) => void;

これが、レンダラーの作成とデータとの受け渡しに使用するコンテキスト型です。

2D レンダラー

src/renderers/mode-2d.ts ファイルを作成して開き、次のコードを追加します。

import ForceGraph from "force-graph";
import { RenderContext } from "../classes/context";

export function render(context: RenderContext) {
  const graph = ForceGraph();
  const style = getComputedStyle(context.element);
  const lineColor = style.getPropertyValue("--graph-line-color").trim();
  const bgColor = style.getPropertyValue("--graph-background-color").trim();
  const fgColor = style.getPropertyValue("--graph-foreground-color").trim();
  const nodeColor = style.getPropertyValue("--graph-node-color").trim();
  graph(context.element)
    .graphData(context.data)
    .width(Number(style.width.slice(0, -2)))
    .height(Number(style.height.slice(0, -2)))
    .cooldownTicks(100)
    .backgroundColor(bgColor)
    .linkColor(() => lineColor)
    .linkWidth(0.2)
    .nodeCanvasObject((node: any, ctx, globalScale) => {
      // Draw a circle
      ctx.beginPath();
      const size = 5 / globalScale;
      ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
      //   ctx.fillStyle = nodeColor(node, groupColors);
      ctx.fillStyle = nodeColor;
      ctx.fill();
      ctx.lineWidth = 1 / globalScale;
      ctx.strokeStyle = lineColor;
      ctx.stroke();

      if (globalScale >= 4) {
        const label = node.name ?? node.id;
        const fontSize = 12 / globalScale;
        ctx.font = `${fontSize}px Sans-Serif`;
        const textWidth = ctx.measureText(label).width;
        const bckgDimensions = [textWidth, fontSize].map(
          (n) => n + fontSize * 0.2
        ); // some padding

        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillStyle = fgColor;
        // Measure text
        ctx.fillText(label, node.x + size * 2 + textWidth / 2, node.y);

        node.__bckgDimensions = bckgDimensions;
      }
    })
    .onNodeHover((node: any, prev: any) => {
      if (node) {
        const graphNode = context.data.nodes.find((n) => n.id === node.id);
        context.onHover(graphNode);
      }
      if (prev) {
        context.onHover(undefined);
      }
    });
}

ここでは、コンテキストをインポートし、2D レンダラーのひな形を作成しています。スケールが 4 を超えると、より詳細に表示するためノード名を描画します。

ノードにポインターを合わせたとき、ホバーされたノードを引数に onHover コールバックを呼び出し、色の描画にはカスタムプロパティを使っている点に注目してください。

3D レンダラー

src/renderers/mode-3d.ts を作成して開き、次のコードを追加します。

import ForceGraph from "3d-force-graph";
import { RenderContext } from "../classes/context.js";

export function render(context: RenderContext) {
  const graph = ForceGraph({
    controlType: "trackball",
    rendererConfig: { antialias: true, alpha: true },
  });
  const style = getComputedStyle(context.element);
  const lineColor = style.getPropertyValue("--graph-line-color").trim();
  const bgColor = style.getPropertyValue("--graph-background-color").trim();
  const nodeColor = style.getPropertyValue("--graph-node-color").trim();
  graph(context.element)
    .graphData(context.data)
    .width(Number(style.width.slice(0, -2)))
    .height(Number(style.height.slice(0, -2)))
    .showNavInfo(false)
    .linkColor(() => lineColor)
    .backgroundColor(bgColor)
    .nodeThreeObject((node: any) => {
      const color = node.color ?? nodeColor;
      node.color = color;
      return false as any;
    })
    .nodeThreeObjectExtend(true)
    .onNodeHover((node: any, prev: any) => {
      if (node) {
        const graphNode = context.data.nodes.find((n) => n.id === node.id);
        context.onHover(graphNode);
      }
      if (prev) {
        context.onHover(undefined);
      }
    })
    .cooldownTicks(100);
}

ほぼ 2D レンダラーと同じ処理を行いますが、代わりに Three.js を使って作成します。

レンダリング

次に、src/lit-force-graph.ts を開き、作成したレンダラー、グラフ、コンテキストのクラスをインポートします。

// ...
import { Renderer } from "./classes/context";
import { GraphData, GraphNode } from "./classes/graph";
import { render as render2D } from "./modes/mode-2d";
import { render as render3D } from "./modes/mode-3d";
// ...

次に、クラスにグラフデータとレンダラーのプロパティを追加します。

  @property({ type: Object }) data?: GraphData;
  @state() hovered?: GraphNode;

  renderers = new Map<string, Renderer>([
    ["2D", render2D],
    ["3D", render3D],
  ]);

setData を更新して、現在のレンダラーで描画するようにします。

setData(data: GraphData) {
    this.data = data;
    const renderer = this.renderers.get(this.mode);
    renderer?.({
        element: this.graph,
        data,
        onHover: (node) => (this.hovered = node),
    });
}

最後に、グラフのタイトルと現在ホバーされているノードを表示するよう、render メソッドを更新します。

render() {
    return html` <main
      accept="application/json"
      @drop="${this.onDrop}"
      @dragover="${(e: Event) => e.preventDefault()}"
    >
      <div id="graph"></div>
      <div id="controls">
        <div>
          <label for="render-mode">Render mode</label>
          <select id="render-mode" @change=${this.onChangeMode}>
            ${Array.from(this.renderers.keys()).map((mode) => {
              return html` <option value="${mode}">${mode}</option> `;
            })}
          </select>
        </div>
      </div>
      <div id="info">
        <h2 id="graph-name">${this.data?.name}</h2>
        <div id="graph-description">${this?.data?.description}</div>
      </div>
      <div id="tooltips">
        ${this.hovered
          ? html` <div class="node-tooltip">
              ${this.hovered?.name ?? this.hovered?.id}
            </div>`
          : html``}
      </div>
    </main>`;
}

完成したコード

すべて正しく追加できていれば、次のようになります。

import { html, css, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { Renderer } from "./classes/context";
import { GraphData, GraphNode } from "./classes/graph";
import { render as render2D } from "./modes/mode-2d";
import { render as render3D } from "./modes/mode-3d";

export const tagName = "lit-force-graph";

@customElement(tagName)
export class LitForceGraph extends LitElement {
  static styles = css`
    :host {
      background-color: var(--graph-background-color, #000011);
      color: var(--graph-foreground-color, #ffffff);
      width: var(--graph-width, 100%);
      height: var(--graph-height, 100vh);
    }

    #graph {
      width: 100%;
      height: 100%;
      width: var(--graph-width, 100%);
      height: var(--graph-height, 100vh);
    }

    #controls {
      position: absolute;
      top: 20px;
      right: 20px;
      z-index: 100 !important;
      display: flex;
      flex-direction: column;
      align-items: flex-end;
    }
    #controls div {
      padding: 5px;
    }

    #info {
      position: absolute;
      top: 10px;
      left: 10px;
      z-index: 100 !important;
      display: flex;
      flex-direction: column;
      align-items: flex-start;
    }

    #tooltips {
      position: absolute;
      bottom: 10px;
      left: 10px;
      right: 10px;
      display: flex;
      flex-direction: row;
      align-items: center;
      text-align: center;
      justify-content: center;
    }

    .node-tooltip {
      background-color: var(--graph-foreground-color, #ffffff);
      color: var(--graph-background-color, #000011);
      border-radius: 5px;
      font-size: 12px;
      padding: 5px;
      opacity: 0.67;
    }

    #graph-description {
      opacity: 0.67;
    }

    .scene-tooltip {
      color: var(--graph-foreground-color, #ffffff);
      background-color: transparent;
      display: none;
    }
  `;

  @query("#graph") graph!: HTMLElement;
  @property() src = "";
  @property() mode = "2D";
  @property({ type: Object }) data?: GraphData;
  @state() hovered?: GraphNode;

  renderers = new Map<string, Renderer>([
    ["2D", render2D],
    ["3D", render3D],
  ]);

  render() {
    return html` <main
      accept="application/json"
      @drop="${this.onDrop}"
      @dragover="${(e: Event) => e.preventDefault()}"
    >
      <div id="graph"></div>
      <div id="controls">
        <div>
          <label for="render-mode">Render mode</label>
          <select id="render-mode" @change=${this.onChangeMode}>
            ${Array.from(this.renderers.keys()).map((mode) => {
              return html` <option value="${mode}">${mode}</option> `;
            })}
          </select>
        </div>
      </div>
      <div id="info">
        <h2 id="graph-name">${this.data?.name}</h2>
        <div id="graph-description">${this?.data?.description}</div>
      </div>
      <div id="tooltips">
        ${this.hovered
          ? html` <div class="node-tooltip">
              ${this.hovered?.name ?? this.hovered?.id}
            </div>`
          : html``}
      </div>
    </main>`;
  }

  async firstUpdated() {
    await this.refresh();
    const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
    prefersDark.addEventListener("change", () => {
      this.refresh();
    });
  }

  /**
   * Set the graph data and update the renderer
   *
   * @param data Graph JSON
   */
  setData(data: GraphData) {
    this.data = data;
    const renderer = this.renderers.get(this.mode);
    renderer?.({
      element: this.graph,
      data,
      onHover: (node) => (this.hovered = node),
    });
  }

  private async refresh() {
    // Get json from script tag
    const children = Array.from(this.children);
    const elem = children.find((child) => child.tagName === "SCRIPT");
    if (elem) {
      // Render from script tag contents
      if (elem.textContent) {
        const data = JSON.parse(elem.textContent);
        if (data) this.setData(data);
        // Render from script tag src
      } else if (elem.hasAttribute("src")) {
        const url = elem.getAttribute("src")!;
        const data = await fetch(url).then((res) => res.json());
        if (data) this.setData(data);
      }
    } else if (this.src.length > 0) {
      // Render from src attribute
      const data = await fetch(this.src).then((res) => res.json());
      if (data) this.setData(data);
    }
  }

  private onChangeMode(e: Event) {
    const mode = (e.target as HTMLSelectElement).value;
    this.mode = mode;
    if (!this.data) return;
    this.setData({ ...this.data! });
  }

  private onDrop(e: DragEvent) {
    e.preventDefault();
    const files = e.dataTransfer?.files;
    if (files && files.length > 0) {
      const file = files[0];
      const reader = new FileReader();
      reader.onload = () => {
        const json = JSON.parse(reader.result as string);
        this.data = json;
        this.setData(json);
      };
      reader.readAsText(file);
    }
    return false;
  }

  attributeChangedCallback(
    name: string,
    _old: string | null,
    value: string | null
  ): void {
    if (name === "src" && value) {
      this.refresh();
    }
    if (name === "data" && value) {
      this.setData(JSON.parse(value));
    }
    if (name === "mode" && value) {
      this.mode = value;
      if (this.data) {
        this.setData({ ...this.data! });
      }
    }
    super.attributeChangedCallback(name, _old, value);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "lit-force-graph": LitForceGraph;
  }
}

2D ライト:

README の画像

2D ダーク:

README の画像

3D ライト:

README の画像

3D ダーク:

README の画像

まとめ

これで、Web コンポーネントを使って複雑なデータ構造を簡単に描画できます。

Lit を使った開発について詳しく知りたい場合は、こちらのドキュメントをご覧ください。

この例のソースコードはこちらで確認できます。

コメント