关于 2d-or-3d-force-graph-with-lit
这是一项使用 Lit 构建力导向图 Web 组件的技能,可呈现 2D 或 3D 图形。适合需要从内嵌 JSON、文件或 URL 加载图数据的开发者。
主要功能
- 01呈现 2D 和 3D 图形
- 02支持内嵌或文件图数据
- 03按需从 URL 加载
- 04悬停回调与节点标签
- 05生成图节点和连接
适用场景
- 01在 Web 组件中可视化关联数据
- 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 浅色:
![]()
2D 深色:
![]()
3D 浅色:
![]()
3D 深色:
![]()
总结
现在,你可以轻松使用 Web 组件渲染复杂的数据结构!
如果你想进一步了解如何使用 Lit 构建应用,可以阅读此处的文档。
此示例的源码可在此处找到。