Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

728
app/stargraph/page.tsx Normal file
View File

@@ -0,0 +1,728 @@
"use client";
import Link from "next/link";
import {
ChangeEvent,
PointerEvent as ReactPointerEvent,
WheelEvent as ReactWheelEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import demoData from "../data/star-graph.json";
import styles from "./StarGraph.module.css";
import hoverStyles from "./StarGraphHover.module.css";
type GraphNode = {
id: string;
label: string;
type: string;
x: number;
y: number;
color: string;
size: number;
description: string;
tags: string[];
};
type GraphEdge = {
id: string;
source: string;
target: string;
label: string;
type: string;
};
type GraphData = {
title: string;
subtitle: string;
nodeTypes: { id: string; name: string; color: string }[];
nodes: GraphNode[];
edges: GraphEdge[];
};
type Interaction = {
mode: "node" | "pan";
nodeId?: string;
startX: number;
startY: number;
baseX: number;
baseY: number;
moved: boolean;
};
const STORAGE_KEY = "orbit-star-graph-v1";
const INITIAL_GRAPH = demoData as GraphData;
const RELATION_TYPES = ["角色关系", "组织关系", "地点关系", "剧情关系", "事件关系", "协作关系", "自定义"];
export default function StarGraphPage() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const importRef = useRef<HTMLInputElement>(null);
const interactionRef = useRef<Interaction | null>(null);
const suppressClickRef = useRef(false);
const [graph, setGraph] = useState<GraphData>(() => cloneGraph(INITIAL_GRAPH));
const [hydrated, setHydrated] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 650, dpr: 1 });
const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 });
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>("project-starsea");
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [connectMode, setConnectMode] = useState(false);
const [connectFrom, setConnectFrom] = useState<string | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [hoverPoint, setHoverPoint] = useState({ x: 0, y: 0 });
const [showAddNode, setShowAddNode] = useState(false);
const [showAddEdge, setShowAddEdge] = useState(false);
const [addPoint, setAddPoint] = useState({ x: 0, y: 0 });
const [pendingEdge, setPendingEdge] = useState({ source: "", target: "" });
const [newNode, setNewNode] = useState({ label: "", type: "character", description: "", tags: "" });
const [newEdge, setNewEdge] = useState({ label: "关联", type: "自定义" });
const [toast, setToast] = useState("");
const stars = useMemo(() => makeStars(180), []);
const selectedNode = graph.nodes.find((node) => node.id === selectedNodeId) ?? null;
const selectedEdge = graph.edges.find((edge) => edge.id === selectedEdgeId) ?? null;
const fromNode = graph.nodes.find((node) => node.id === connectFrom) ?? null;
const hoveredNode = graph.nodes.find((node) => node.id === hoveredNodeId) ?? null;
const visibleNodes = graph.nodes.filter((node) => {
const text = `${node.label} ${node.type} ${node.tags.join(" ")}`.toLowerCase();
return (typeFilter === "all" || node.type === typeFilter) && text.includes(query.trim().toLowerCase());
});
useEffect(() => {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (isGraphData(parsed)) setGraph(parsed);
}
} catch {
// Keep the bundled demo if local data cannot be read.
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(graph));
}, [graph, hydrated]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
setCanvasSize({ width: rect.width, height: rect.height, dpr });
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
return () => observer.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
let frame = 0;
const draw = (time: number) => {
drawGraph({
context,
width: canvasSize.width,
height: canvasSize.height,
dpr: canvasSize.dpr,
time,
stars,
graph,
viewport,
selectedNodeId,
selectedEdgeId,
connectFrom,
query,
typeFilter,
});
frame = window.requestAnimationFrame(draw);
};
frame = window.requestAnimationFrame(draw);
return () => window.cancelAnimationFrame(frame);
}, [canvasSize, stars, graph, viewport, selectedNodeId, selectedEdgeId, connectFrom, query, typeFilter]);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 1800);
};
const updateNode = (id: string, patch: Partial<GraphNode>) => {
setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === id ? { ...node, ...patch } : node) }));
};
const updateEdge = (id: string, patch: Partial<GraphEdge>) => {
setGraph((current) => ({ ...current, edges: current.edges.map((edge) => edge.id === id ? { ...edge, ...patch } : edge) }));
};
const pointInCanvas = (event: { clientX: number; clientY: number }) => {
const rect = canvasRef.current?.getBoundingClientRect();
return { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) };
};
const screenPoint = (node: GraphNode) => ({
x: canvasSize.width / 2 + viewport.x + node.x * viewport.scale,
y: canvasSize.height / 2 + viewport.y + node.y * viewport.scale,
});
const hitNode = (point: { x: number; y: number }) => {
for (let index = graph.nodes.length - 1; index >= 0; index -= 1) {
const node = graph.nodes[index];
const screen = screenPoint(node);
const card = nodeCardSize(node, viewport.scale);
if (Math.abs(point.x - screen.x) <= card.width / 2 + 5 && Math.abs(point.y - screen.y) <= card.height / 2 + 5) return node;
}
return null;
};
const hitEdge = (point: { x: number; y: number }) => graph.edges.find((edge) => {
const source = graph.nodes.find((node) => node.id === edge.source);
const target = graph.nodes.find((node) => node.id === edge.target);
if (!source || !target) return false;
return distanceToSegment(point, screenPoint(source), screenPoint(target)) < 9;
}) ?? null;
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = pointInCanvas(event);
const node = hitNode(point);
if (connectMode && node) return;
event.currentTarget.setPointerCapture(event.pointerId);
interactionRef.current = node
? { mode: "node", nodeId: node.id, startX: point.x, startY: point.y, baseX: node.x, baseY: node.y, moved: false }
: { mode: "pan", startX: point.x, startY: point.y, baseX: viewport.x, baseY: viewport.y, moved: false };
};
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const interaction = interactionRef.current;
const point = pointInCanvas(event);
if (!interaction) {
const node = hitNode(point);
setHoveredNodeId(node?.id ?? null);
setHoverPoint({
x: clamp(point.x + 15, 12, Math.max(12, canvasSize.width - 238)),
y: clamp(point.y + 15, 58, Math.max(58, canvasSize.height - 150)),
});
return;
}
setHoveredNodeId(null);
const dx = point.x - interaction.startX;
const dy = point.y - interaction.startY;
if (Math.hypot(dx, dy) > 3) interaction.moved = true;
if (interaction.mode === "node" && interaction.nodeId) {
updateNode(interaction.nodeId, { x: interaction.baseX + dx / viewport.scale, y: interaction.baseY + dy / viewport.scale });
} else {
setViewport((current) => ({ ...current, x: interaction.baseX + dx, y: interaction.baseY + dy }));
}
};
const handlePointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (interactionRef.current?.moved) suppressClickRef.current = true;
interactionRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const handleCanvasClick = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
const point = pointInCanvas(event);
const node = hitNode(point);
if (node) {
if (connectMode) {
if (!connectFrom) {
setConnectFrom(node.id);
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
notify(`已选择起点:${node.label}`);
} else if (connectFrom === node.id) {
notify("请选择另一个节点");
} else {
setPendingEdge({ source: connectFrom, target: node.id });
setNewEdge({ label: "关联", type: "自定义" });
setShowAddEdge(true);
}
return;
}
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
return;
}
if (connectMode) return;
const edge = hitEdge(point);
setSelectedEdgeId(edge?.id ?? null);
setSelectedNodeId(null);
};
const handleDoubleClick = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = pointInCanvas(event);
if (hitNode(point)) return;
setAddPoint({
x: (point.x - canvasSize.width / 2 - viewport.x) / viewport.scale,
y: (point.y - canvasSize.height / 2 - viewport.y) / viewport.scale,
});
setShowAddNode(true);
};
const handleWheel = (event: ReactWheelEvent<HTMLCanvasElement>) => {
event.preventDefault();
const point = pointInCanvas(event);
const nextScale = clamp(viewport.scale * (event.deltaY > 0 ? 0.9 : 1.1), 0.45, 2.2);
const worldX = (point.x - canvasSize.width / 2 - viewport.x) / viewport.scale;
const worldY = (point.y - canvasSize.height / 2 - viewport.y) / viewport.scale;
setViewport({
scale: nextScale,
x: point.x - canvasSize.width / 2 - worldX * nextScale,
y: point.y - canvasSize.height / 2 - worldY * nextScale,
});
};
const openAddNode = () => {
setAddPoint({ x: -viewport.x / viewport.scale, y: -viewport.y / viewport.scale });
setNewNode({ label: "", type: "character", description: "", tags: "" });
setShowAddNode(true);
};
const createNode = () => {
const label = newNode.label.trim();
if (!label) return notify("请输入节点名称");
const nodeType = graph.nodeTypes.find((type) => type.id === newNode.type) ?? graph.nodeTypes[0];
const node: GraphNode = {
id: `node-${Date.now()}`,
label,
type: nodeType.id,
x: addPoint.x,
y: addPoint.y,
color: nodeType.color,
size: 21,
description: newNode.description.trim() || "暂无说明",
tags: newNode.tags.split(/[,]/).map((tag) => tag.trim()).filter(Boolean),
};
setGraph((current) => ({ ...current, nodes: [...current.nodes, node] }));
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
setShowAddNode(false);
notify("节点已添加");
};
const createEdge = () => {
if (!pendingEdge.source || !pendingEdge.target) return;
const duplicate = graph.edges.some((edge) =>
(edge.source === pendingEdge.source && edge.target === pendingEdge.target)
|| (edge.source === pendingEdge.target && edge.target === pendingEdge.source));
if (duplicate) {
setShowAddEdge(false);
setConnectFrom(null);
return notify("这两个节点已经存在关联");
}
const edge: GraphEdge = {
id: `edge-${Date.now()}`,
source: pendingEdge.source,
target: pendingEdge.target,
label: newEdge.label.trim() || "关联",
type: newEdge.type,
};
setGraph((current) => ({ ...current, edges: [...current.edges, edge] }));
setSelectedEdgeId(edge.id);
setSelectedNodeId(null);
setShowAddEdge(false);
setConnectFrom(null);
notify("直线关联已建立");
};
const deleteNode = (id: string) => {
setGraph((current) => ({
...current,
nodes: current.nodes.filter((node) => node.id !== id),
edges: current.edges.filter((edge) => edge.source !== id && edge.target !== id),
}));
setSelectedNodeId(null);
if (connectFrom === id) setConnectFrom(null);
notify("节点及其关联已删除");
};
const deleteEdge = (id: string) => {
setGraph((current) => ({ ...current, edges: current.edges.filter((edge) => edge.id !== id) }));
setSelectedEdgeId(null);
notify("关联已删除");
};
const resetGraph = () => {
setGraph(cloneGraph(INITIAL_GRAPH));
setViewport({ x: 0, y: 0, scale: 1 });
setSelectedNodeId("project-starsea");
setSelectedEdgeId(null);
setConnectFrom(null);
notify("已恢复演示数据");
};
const exportGraph = () => {
const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "star-graph.json";
anchor.click();
URL.revokeObjectURL(url);
notify("JSON 已导出");
};
const importGraph = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const parsed = JSON.parse(await file.text());
if (!isGraphData(parsed)) throw new Error("invalid");
setGraph(parsed);
setSelectedNodeId(parsed.nodes[0]?.id ?? null);
setSelectedEdgeId(null);
notify("JSON 已导入");
} catch {
notify("JSON 格式不正确");
}
event.target.value = "";
};
const focusNode = (node: GraphNode) => {
setViewport((current) => ({ ...current, x: -node.x * current.scale, y: -node.y * current.scale }));
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
};
return (
<div className={styles.page}>
<header className={styles.header}>
<Link className={styles.brand} href="/"><span></span><div><strong>StarGraph</strong><small></small></div></Link>
<nav><Link href="/worklist"></Link><Link href="/approval"></Link><Link className={styles.activeNav} href="/stargraph"></Link></nav>
<div className={styles.headerStats}><span><b>{graph.nodes.length}</b> </span><span><b>{graph.edges.length}</b> </span><em>{hydrated ? "● 已自动保存" : "正在读取…"}</em></div>
</header>
<div className={styles.workspace}>
<aside className={styles.sidebar}>
<div className={styles.sideHeading}><span>GRAPH EXPLORER</span><h1>{graph.title}</h1><p>{graph.subtitle}</p></div>
<label className={styles.search}><span></span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点或标签" />{query && <button onClick={() => setQuery("")}>×</button>}</label>
<div className={styles.filters}>
<button className={typeFilter === "all" ? styles.filterActive : ""} onClick={() => setTypeFilter("all")}><i style={{ background: "#a9b1d6" }} /><b>{graph.nodes.length}</b></button>
{graph.nodeTypes.map((type) => <button key={type.id} className={typeFilter === type.id ? styles.filterActive : ""} onClick={() => setTypeFilter(type.id)}><i style={{ background: type.color }} />{type.name}<b>{graph.nodes.filter((node) => node.type === type.id).length}</b></button>)}
</div>
<div className={styles.nodeListHeader}><span></span><b>{visibleNodes.length}</b></div>
<div className={styles.nodeList}>
{visibleNodes.map((node) => <button key={node.id} className={selectedNodeId === node.id ? styles.nodeActive : ""} onClick={() => focusNode(node)}><i style={{ background: node.color, boxShadow: `0 0 12px ${node.color}` }} /><span><strong>{node.label}</strong><small>{typeName(graph, node.type)} · {graph.edges.filter((edge) => edge.source === node.id || edge.target === node.id).length} </small></span><em></em></button>)}
{visibleNodes.length === 0 && <p className={styles.emptyList}></p>}
</div>
<div className={styles.dataActions}><button onClick={exportGraph}> JSON</button><button onClick={() => importRef.current?.click()}> </button><button onClick={resetGraph}> </button><input ref={importRef} type="file" accept="application/json,.json" onChange={importGraph} /></div>
</aside>
<main className={`${styles.stage} ${connectMode ? styles.connecting : ""}`}>
<canvas
ref={canvasRef}
className={styles.canvas}
role="img"
aria-label={`星空关系图,共 ${graph.nodes.length} 个节点和 ${graph.edges.length} 条直线关联。可拖动节点或画布,滚轮缩放。`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerLeave={() => setHoveredNodeId(null)}
onClick={handleCanvasClick}
onDoubleClick={handleDoubleClick}
onWheel={handleWheel}
/>
{hoveredNode && <aside className={hoverStyles.hoverCard} style={{ left: hoverPoint.x, top: hoverPoint.y }} aria-live="polite">
<div className={hoverStyles.hoverHeader}><i style={{ background: hoveredNode.color, boxShadow: `0 0 12px ${hoveredNode.color}` }} /><span><small>{typeName(graph, hoveredNode.type)}</small><strong>{hoveredNode.label}</strong></span><em>{graph.edges.filter((edge) => edge.source === hoveredNode.id || edge.target === hoveredNode.id).length} </em></div>
<p>{hoveredNode.description}</p>
{hoveredNode.tags.length > 0 && <div className={hoverStyles.hoverTags}>{hoveredNode.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>}
</aside>}
<div className={styles.canvasToolbar}>
<button className={styles.primaryButton} onClick={openAddNode}><span></span></button>
<button className={connectMode ? styles.connectButtonActive : ""} onClick={() => { setConnectMode((value) => !value); setConnectFrom(null); }}><span></span>{connectMode ? "退出关联" : "关联节点"}</button>
<i />
<button title="放大" onClick={() => setViewport((current) => ({ ...current, scale: clamp(current.scale + 0.15, 0.45, 2.2) }))}></button>
<button title="缩小" onClick={() => setViewport((current) => ({ ...current, scale: clamp(current.scale - 0.15, 0.45, 2.2) }))}></button>
<button title="居中" onClick={() => setViewport({ x: 0, y: 0, scale: 1 })}></button>
<span className={styles.zoomValue}>{Math.round(viewport.scale * 100)}%</span>
</div>
{connectMode && <div className={styles.connectHint}><span>{connectFrom ? "2" : "1"}</span><p><strong>{fromNode ? `起点:${fromNode.label}` : "请选择第一个节点"}</strong><small>{fromNode ? "再点击另一个表单节点,即可建立直线关联" : "点击任意方形表单节点作为关联起点"}</small></p>{connectFrom && <button onClick={() => setConnectFrom(null)}></button>}</div>}
<div className={styles.canvasHelp}><span></span><span></span><span></span><span></span></div>
<div className={styles.minimapLabel}><span>LIVE MAP</span><b>线</b></div>
<p className={styles.srOnly}>{graph.nodes.map((node) => node.label).join("、")}{graph.edges.map((edge) => `${nodeLabel(graph, edge.source)}${nodeLabel(graph, edge.target)}${edge.label}`).join("")}</p>
</main>
<aside className={styles.inspector}>
<div className={styles.inspectorHeading}><div><span>INSPECTOR</span><strong>{selectedNode ? "节点属性" : selectedEdge ? "关联属性" : "属性面板"}</strong></div><small>{selectedNode ? "NODE" : selectedEdge ? "EDGE" : "EMPTY"}</small></div>
{selectedNode ? <>
<div className={styles.nodePreview}><i style={{ background: selectedNode.color, boxShadow: `0 0 28px ${selectedNode.color}`, borderRadius: "7px" }} /><div><strong>{selectedNode.label}</strong><span>{typeName(graph, selectedNode.type)}</span></div><em>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).length}</em></div>
<div className={styles.fieldGroup}>
<label><span></span><input value={selectedNode.label} onChange={(event) => updateNode(selectedNode.id, { label: event.target.value })} /></label>
<label><span></span><select value={selectedNode.type} onChange={(event) => { const type = graph.nodeTypes.find((item) => item.id === event.target.value); updateNode(selectedNode.id, { type: event.target.value, color: type?.color ?? selectedNode.color }); }}>{graph.nodeTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
<label><span></span><textarea rows={4} value={selectedNode.description} onChange={(event) => updateNode(selectedNode.id, { description: event.target.value })} /></label>
<label><span></span><input value={selectedNode.tags.join("")} onChange={(event) => updateNode(selectedNode.id, { tags: event.target.value.split(/[,]/).map((tag) => tag.trim()).filter(Boolean) })} /></label>
<label><span></span><div className={styles.colorInput}><input type="color" value={selectedNode.color} onChange={(event) => updateNode(selectedNode.id, { color: event.target.value })} /><code>{selectedNode.color.toUpperCase()}</code></div></label>
<label><span></span><div className={styles.rangeInput}><input type="range" min="14" max="36" value={selectedNode.size} onChange={(event) => updateNode(selectedNode.id, { size: Number(event.target.value) })} /><b>{selectedNode.size}</b></div></label>
</div>
<div className={styles.relationBlock}><div><strong></strong><span>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).length}</span></div>{graph.edges.filter((edge) => edge.source === selectedNode.id || edge.target === selectedNode.id).map((edge) => { const otherId = edge.source === selectedNode.id ? edge.target : edge.source; const other = graph.nodes.find((node) => node.id === otherId); return <button key={edge.id} onClick={() => { setSelectedEdgeId(edge.id); setSelectedNodeId(null); }}><i style={{ background: other?.color }} /><span><strong>{other?.label}</strong><small>{edge.label}</small></span><em></em></button>; })}</div>
<button className={styles.deleteButton} onClick={() => deleteNode(selectedNode.id)}></button>
</> : selectedEdge ? <>
<div className={styles.edgePreview}><span style={{ background: graph.nodes.find((node) => node.id === selectedEdge.source)?.color, borderRadius: "3px" }} /><i /><span style={{ background: graph.nodes.find((node) => node.id === selectedEdge.target)?.color, borderRadius: "3px" }} /></div>
<div className={styles.edgeEndpoints}><strong>{nodeLabel(graph, selectedEdge.source)}</strong><small>线</small><strong>{nodeLabel(graph, selectedEdge.target)}</strong></div>
<div className={styles.fieldGroup}>
<label><span></span><input value={selectedEdge.label} onChange={(event) => updateEdge(selectedEdge.id, { label: event.target.value })} /></label>
<label><span></span><select value={selectedEdge.type} onChange={(event) => updateEdge(selectedEdge.id, { type: event.target.value })}>{RELATION_TYPES.map((type) => <option key={type}>{type}</option>)}</select></label>
</div>
<div className={styles.edgeNote}><span></span><p><strong>线</strong><small>线</small></p></div>
<button className={styles.deleteButton} onClick={() => deleteEdge(selectedEdge.id)}></button>
</> : <div className={styles.emptyInspector}><span></span><h2></h2><p>线</p><button onClick={openAddNode}> </button></div>}
</aside>
</div>
{showAddNode && <div className={styles.modalBackdrop} onMouseDown={(event) => { if (event.target === event.currentTarget) setShowAddNode(false); }}><section className={styles.modal} role="dialog" aria-modal="true" aria-labelledby="add-node-title"><div className={styles.modalHeader}><div><span>NEW STAR</span><h2 id="add-node-title"></h2></div><button onClick={() => setShowAddNode(false)}>×</button></div><div className={styles.modalBody}><label><span> *</span><input autoFocus value={newNode.label} onChange={(event) => setNewNode({ ...newNode, label: event.target.value })} placeholder="例如:新角色、线索或地点" onKeyDown={(event) => { if (event.key === "Enter") createNode(); }} /></label><label><span></span><div className={styles.typeCards}>{graph.nodeTypes.map((type) => <button key={type.id} className={newNode.type === type.id ? styles.typeCardActive : ""} onClick={() => setNewNode({ ...newNode, type: type.id })}><i style={{ background: type.color, boxShadow: `0 0 12px ${type.color}` }} />{type.name}</button>)}</div></label><label><span></span><textarea rows={3} value={newNode.description} onChange={(event) => setNewNode({ ...newNode, description: event.target.value })} placeholder="简要描述这个节点" /></label><label><span></span><input value={newNode.tags} onChange={(event) => setNewNode({ ...newNode, tags: event.target.value })} placeholder="使用逗号分隔多个标签" /></label></div><div className={styles.modalFooter}><button onClick={() => setShowAddNode(false)}></button><button className={styles.modalPrimary} onClick={createNode}></button></div></section></div>}
{showAddEdge && <div className={styles.modalBackdrop} onMouseDown={(event) => { if (event.target === event.currentTarget) setShowAddEdge(false); }}><section className={`${styles.modal} ${styles.edgeModal}`} role="dialog" aria-modal="true" aria-labelledby="add-edge-title"><div className={styles.modalHeader}><div><span>NEW CONNECTION</span><h2 id="add-edge-title">线</h2></div><button onClick={() => setShowAddEdge(false)}>×</button></div><div className={styles.connectionPreview}><div><i style={{ background: graph.nodes.find((node) => node.id === pendingEdge.source)?.color, borderRadius: "5px" }} /><strong>{nodeLabel(graph, pendingEdge.source)}</strong></div><span><b></b><small>线</small></span><div><i style={{ background: graph.nodes.find((node) => node.id === pendingEdge.target)?.color, borderRadius: "5px" }} /><strong>{nodeLabel(graph, pendingEdge.target)}</strong></div></div><div className={styles.modalBody}><label><span></span><input autoFocus value={newEdge.label} onChange={(event) => setNewEdge({ ...newEdge, label: event.target.value })} placeholder="例如:好友、所属、依赖" onKeyDown={(event) => { if (event.key === "Enter") createEdge(); }} /></label><label><span></span><select value={newEdge.type} onChange={(event) => setNewEdge({ ...newEdge, type: event.target.value })}>{RELATION_TYPES.map((type) => <option key={type}>{type}</option>)}</select></label></div><div className={styles.modalFooter}><button onClick={() => setShowAddEdge(false)}></button><button className={styles.modalPrimary} onClick={createEdge}></button></div></section></div>}
{toast && <div className={styles.toast}> {toast}</div>}
</div>
);
}
function drawGraph(options: {
context: CanvasRenderingContext2D;
width: number;
height: number;
dpr: number;
time: number;
stars: { x: number; y: number; size: number; alpha: number; phase: number }[];
graph: GraphData;
viewport: { x: number; y: number; scale: number };
selectedNodeId: string | null;
selectedEdgeId: string | null;
connectFrom: string | null;
query: string;
typeFilter: string;
}) {
const { context: ctx, width, height, dpr, time, stars, graph, viewport, selectedNodeId, selectedEdgeId, connectFrom, query, typeFilter } = options;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const background = ctx.createLinearGradient(0, 0, width, height);
background.addColorStop(0, "#070a19");
background.addColorStop(0.48, "#10112b");
background.addColorStop(1, "#070b1b");
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
drawNebula(ctx, width * 0.2, height * 0.28, Math.max(width, height) * 0.42, "111,89,255");
drawNebula(ctx, width * 0.82, height * 0.68, Math.max(width, height) * 0.38, "39,199,217");
stars.forEach((star) => {
const pulse = 0.68 + Math.sin(time * 0.0014 + star.phase) * 0.32;
ctx.beginPath();
ctx.fillStyle = `rgba(218,231,255,${star.alpha * pulse})`;
ctx.arc(star.x * width, star.y * height, star.size, 0, Math.PI * 2);
ctx.fill();
});
const toScreen = (node: GraphNode) => ({ x: width / 2 + viewport.x + node.x * viewport.scale, y: height / 2 + viewport.y + node.y * viewport.scale });
const matches = (node: GraphNode) => {
const search = query.trim().toLowerCase();
return (typeFilter === "all" || node.type === typeFilter) && (!search || `${node.label} ${node.tags.join(" ")}`.toLowerCase().includes(search));
};
graph.edges.forEach((edge) => {
const source = graph.nodes.find((node) => node.id === edge.source);
const target = graph.nodes.find((node) => node.id === edge.target);
if (!source || !target) return;
const sourceCenter = toScreen(source);
const targetCenter = toScreen(target);
const a = cardEdgePoint(sourceCenter, targetCenter, nodeCardSize(source, viewport.scale));
const b = cardEdgePoint(targetCenter, sourceCenter, nodeCardSize(target, viewport.scale));
const active = edge.id === selectedEdgeId;
const faded = !matches(source) || !matches(target);
ctx.save();
ctx.globalAlpha = faded ? 0.09 : active ? 1 : 0.48;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.lineWidth = active ? 2.4 : 1.15;
ctx.strokeStyle = active ? "#e8e5ff" : "#7e86bc";
ctx.shadowBlur = active ? 12 : 4;
ctx.shadowColor = active ? "#9b8aff" : "#4d60a3";
ctx.stroke();
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
ctx.font = '500 10px "Microsoft YaHei", "PingFang SC", sans-serif';
const labelWidth = ctx.measureText(edge.label).width + 14;
ctx.shadowBlur = 0;
ctx.fillStyle = active ? "rgba(111,92,214,.92)" : "rgba(13,17,43,.76)";
ctx.fillRect(mx - labelWidth / 2, my - 9, labelWidth, 18);
ctx.fillStyle = active ? "#ffffff" : "#aeb6dd";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(edge.label, mx, my);
ctx.restore();
});
graph.nodes.forEach((node) => {
const point = toScreen(node);
const card = nodeCardSize(node, viewport.scale);
const left = point.x - card.width / 2;
const top = point.y - card.height / 2;
const active = node.id === selectedNodeId;
const linking = node.id === connectFrom;
const faded = !matches(node);
const relationCount = graph.edges.filter((edge) => edge.source === node.id || edge.target === node.id).length;
const pulse = 0.78 + Math.sin(time * 0.002 + node.x * 0.01) * 0.18;
ctx.save();
ctx.globalAlpha = faded ? 0.16 : 1;
roundedRectPath(ctx, left, top, card.width, card.height, 5);
ctx.fillStyle = "rgba(11,15,34,.96)";
ctx.shadowBlur = active || linking ? 22 : 9 * pulse;
ctx.shadowColor = node.color;
ctx.fill();
ctx.shadowBlur = 0;
ctx.lineWidth = active || linking ? 1.8 : 1;
ctx.setLineDash(linking ? [5, 3] : []);
ctx.strokeStyle = linking ? "#ffffff" : active ? node.color : "rgba(135,145,190,.35)";
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = node.color;
ctx.fillRect(left, top, 3, card.height);
ctx.fillStyle = hexToRgba(node.color, active ? 0.18 : 0.1);
ctx.fillRect(left + 3, top, card.width - 3, card.height);
const padding = 7;
const markerSize = clamp(card.height - 13, 9, 13);
ctx.fillStyle = node.color;
roundedRectPath(ctx, left + padding, point.y - markerSize / 2, markerSize, markerSize, 2);
ctx.fill();
ctx.font = `700 ${clamp(markerSize - 5, 7, 9)}px "Microsoft YaHei", "PingFang SC", sans-serif`;
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(typeGlyph(node.type), left + padding + markerSize / 2, point.y + .5);
ctx.font = `${active ? 700 : 600} ${clamp(card.height * .34, 9, 11)}px "Microsoft YaHei", "PingFang SC", sans-serif`;
ctx.fillStyle = active ? "#ffffff" : "#e2e7fb";
ctx.textAlign = "left";
ctx.fillText(fitCanvasText(ctx, node.label, card.width - markerSize - 34), left + padding + markerSize + 6, point.y + .5);
ctx.font = '600 8px "Microsoft YaHei", "PingFang SC", sans-serif';
ctx.textAlign = "right";
ctx.fillStyle = "#7f89aa";
ctx.fillText(String(relationCount), left + card.width - 7, point.y + .5);
if (active || linking) {
const portSize = 4;
ctx.fillStyle = linking ? "#ffffff" : node.color;
[[point.x, top], [left + card.width, point.y], [point.x, top + card.height], [left, point.y]].forEach(([x, y]) => ctx.fillRect(x - portSize / 2, y - portSize / 2, portSize, portSize));
}
ctx.restore();
});
}
function nodeCardSize(node: GraphNode, scale: number) {
const emphasis = clamp((node.size - 14) / 22, 0, 1);
return {
width: clamp((92 + emphasis * 14) * scale, 70, 118),
height: clamp((25 + emphasis * 5) * scale, 21, 32),
};
}
function cardEdgePoint(center: { x: number; y: number }, toward: { x: number; y: number }, card: { width: number; height: number }) {
const dx = toward.x - center.x;
const dy = toward.y - center.y;
if (dx === 0 && dy === 0) return center;
const xScale = dx === 0 ? Number.POSITIVE_INFINITY : card.width / 2 / Math.abs(dx);
const yScale = dy === 0 ? Number.POSITIVE_INFINITY : card.height / 2 / Math.abs(dy);
const scale = Math.min(xScale, yScale);
return { x: center.x + dx * scale, y: center.y + dy * scale };
}
function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
const r = Math.min(radius, width / 2, height / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
ctx.lineTo(x + width, y + height - r);
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
ctx.lineTo(x + r, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
if (ctx.measureText(text).width <= maxWidth) return text;
let value = text;
while (value.length > 1 && ctx.measureText(`${value}`).width > maxWidth) value = value.slice(0, -1);
return `${value}`;
}
function hexToRgba(hex: string, alpha: number) {
const clean = hex.replace("#", "");
const value = Number.parseInt(clean.length === 3 ? clean.split("").map((char) => `${char}${char}`).join("") : clean, 16);
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
return `rgba(${red},${green},${blue},${alpha})`;
}
function drawNebula(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, rgb: string) {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(${rgb},.12)`);
gradient.addColorStop(0.35, `rgba(${rgb},.055)`);
gradient.addColorStop(1, `rgba(${rgb},0)`);
ctx.fillStyle = gradient;
ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
}
function makeStars(count: number) {
let seed = 12873;
const random = () => {
seed = (seed * 16807) % 2147483647;
return (seed - 1) / 2147483646;
};
return Array.from({ length: count }, () => ({ x: random(), y: random(), size: 0.3 + random() * 1.25, alpha: 0.25 + random() * 0.65, phase: random() * Math.PI * 2 }));
}
function distanceToSegment(point: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }) {
const dx = b.x - a.x;
const dy = b.y - a.y;
if (dx === 0 && dy === 0) return Math.hypot(point.x - a.x, point.y - a.y);
const t = clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy), 0, 1);
return Math.hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy));
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function cloneGraph(graph: GraphData): GraphData {
return JSON.parse(JSON.stringify(graph));
}
function isGraphData(value: unknown): value is GraphData {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<GraphData>;
return Array.isArray(candidate.nodes) && Array.isArray(candidate.edges) && Array.isArray(candidate.nodeTypes);
}
function nodeLabel(graph: GraphData, id: string) {
return graph.nodes.find((node) => node.id === id)?.label ?? "未知节点";
}
function typeName(graph: GraphData, id: string) {
return graph.nodeTypes.find((type) => type.id === id)?.name ?? id;
}
function typeGlyph(type: string) {
return ({ project: "星", character: "人", world: "界", organization: "组", event: "事" } as Record<string, string>)[type] ?? "点";
}