This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/stargraph/page.tsx
2026-08-27 20:15:24 +08:00

729 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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] ?? "点";
}