"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(null); const importRef = useRef(null); const interactionRef = useRef(null); const suppressClickRef = useRef(false); const [graph, setGraph] = useState(() => 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("project-starsea"); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [connectMode, setConnectMode] = useState(false); const [connectFrom, setConnectFrom] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(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) => { setGraph((current) => ({ ...current, nodes: current.nodes.map((node) => node.id === id ? { ...node, ...patch } : node) })); }; const updateEdge = (id: string, patch: Partial) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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 (
StarGraph星空关系网络
{graph.nodes.length} 节点{graph.edges.length} 关联{hydrated ? "● 已自动保存" : "正在读取…"}
setHoveredNodeId(null)} onClick={handleCanvasClick} onDoubleClick={handleDoubleClick} onWheel={handleWheel} /> {hoveredNode && }
{Math.round(viewport.scale * 100)}%
{connectMode &&
{connectFrom ? "2" : "1"}

{fromNode ? `起点:${fromNode.label}` : "请选择第一个节点"}{fromNode ? "再点击另一个表单节点,即可建立直线关联" : "点击任意方形表单节点作为关联起点"}

{connectFrom && }
}
拖动画布移动滚轮缩放拖动节点调整位置双击空白处添加
LIVE MAP直线关系模式

节点包括:{graph.nodes.map((node) => node.label).join("、")}。关联包括:{graph.edges.map((edge) => `${nodeLabel(graph, edge.source)}与${nodeLabel(graph, edge.target)}:${edge.label}`).join(";")}。