diff --git a/hyperdb/templates/hypergraph_viewer.html b/hyperdb/templates/hypergraph_viewer.html index 11aafb2..988e36e 100644 --- a/hyperdb/templates/hypergraph_viewer.html +++ b/hyperdb/templates/hypergraph_viewer.html @@ -32,8 +32,9 @@ // 嵌入的数据 const embeddedData = {{DATA_JSON}}; // const embeddedData = datas; - // 颜色配置 - const colors = [ + + // 常量配置 + const COLORS = [ "#F6BD16", "#00C9C9", "#F08F56", @@ -46,13 +47,68 @@ "#c8ff00", ]; - const entityTypeColors = { - PERSON: "#00C9C9", - CONCEPT: "#a68fff", - ORGANIZATION: "#F08F56", - LOCATION: "#16f69c", - EVENT: "#004ac9", - PRODUCT: "#f056d1", + // 6个预定义颜色,用于循环分配给不同的实体类型 + const ENTITY_TYPE_COLORS_PALETTE = [ + "#00C9C9", + "#a68fff", + "#F08F56", + "#0d7c4f", + "#004ac9", + "#f056d1" + ]; + + const DEFAULT_NODE_COLOR = "#8b5cf6"; + + // 动态生成实体类型颜色映射 + const generateEntityTypeColors = (vertices) => { + const entityTypes = [...new Set(vertices.map(v => v.entity_type).filter(Boolean))]; + const colorMap = {}; + + entityTypes.forEach((entityType, index) => { + colorMap[entityType] = ENTITY_TYPE_COLORS_PALETTE[index % ENTITY_TYPE_COLORS_PALETTE.length]; + }); + + return colorMap; + }; + const LAYOUT_THRESHOLD = 100; + const EDGE_SEPARATOR = "|#|"; + + // 工具函数 + const calculateMatchScore = (vertex, searchLower) => { + let score = 0; + if (vertex.id.toString().toLowerCase().includes(searchLower)) + score += 3; + if (vertex.entity_type?.toLowerCase().includes(searchLower)) score += 2; + if (vertex.description?.toLowerCase().includes(searchLower)) score += 1; + return score; + }; + + const createBubbleStyle = (baseColor) => ({ + fill: baseColor, + stroke: baseColor, + maxRoutingIterations: 100, + maxMarchingIterations: 20, + pixelGroup: 4, + edgeR0: 10, + edgeR1: 60, + nodeR0: 15, + nodeR1: 50, + morphBuffer: 10, + threshold: 4, + memberInfluenceFactor: 1, + edgeInfluenceFactor: 4, + nonMemberInfluenceFactor: -0.8, + virtualEdges: true, + }); + + const getNodeColor = (node, selectedVertex, entityTypeColors) => { + if (node.id === selectedVertex) return "#6d28d9"; + return entityTypeColors[node.entity_type] || DEFAULT_NODE_COLOR; + }; + + const formatDescription = (description) => { + if (!description) return ""; + return description.split("").join(" "); }; function HyperGraphViewer() { @@ -75,6 +131,11 @@ const [graphVersion, setGraphVersion] = useState(0); const [hoverHyperedge, setHoverHyperedge] = useState(null); const [hoverNode, setHoverNode] = useState(null); + + // 生成实体类型颜色映射 + const entityTypeColors = useMemo(() => { + return generateEntityTypeColors(embeddedData.vertices); + }, [embeddedData.vertices]); // 搜索功能 useEffect(() => { if (!searchTerm.trim()) { @@ -83,44 +144,18 @@ return; } - const results = []; const searchLower = searchTerm.toLowerCase(); - - const filtered = embeddedData.vertices.filter((vertex) => { - const matches = - vertex.id.toString().toLowerCase().includes(searchLower) || - (vertex.entity_type && - vertex.entity_type.toLowerCase().includes(searchLower)) || - (vertex.description && - vertex.description.toLowerCase().includes(searchLower)); - - if (matches) { - // 计算匹配度分数,用于排序 - let score = 0; - if (vertex.id.toString().toLowerCase().includes(searchLower)) - score += 3; - if ( - vertex.entity_type && - vertex.entity_type.toLowerCase().includes(searchLower) - ) - score += 2; - if ( - vertex.description && - vertex.description.toLowerCase().includes(searchLower) - ) - score += 1; - - results.push({ ...vertex, score }); - } - return matches; - }); - - // 按匹配度分数排序 - results.sort((a, b) => b.score - a.score); - const sortedResults = results.map(({ score, ...vertex }) => vertex); - - setFilteredVertices(sortedResults); - setSearchResults(sortedResults); + const results = embeddedData.vertices + .map((vertex) => ({ + ...vertex, + score: calculateMatchScore(vertex, searchLower), + })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) + .map(({ score, ...vertex }) => vertex); + + setFilteredVertices(results); + setSearchResults(results); }, [searchTerm]); // 加载图数据(从嵌入数据获取) @@ -144,33 +179,29 @@ const graphDataFormatted = useMemo(() => { if (!graphData) return null; - const hyperData = { nodes: [], edges: [], hyperEdges: [] }; - const plugins = []; - - // 添加顶点 - for (const [key, value] of Object.entries(graphData.vertices)) { - hyperData.nodes.push({ + const hyperData = { + nodes: Object.entries(graphData.vertices).map(([key, value]) => ({ id: key, label: key, ...value, - }); - } + })), + edges: [], + hyperEdges: [], + }; + const plugins = []; + const edgeEntries = Object.entries(graphData.edges); if (visualizationMode === "graph") { - // Graph模式:只显示维度为2的球棍图(仅保留包含2个节点的超边) - const edgeKeys = Object.keys(graphData.edges); - const edgeSet = new Set(); // 用于去重 - - for (let i = 0; i < edgeKeys.length; i++) { - const key = edgeKeys[i]; - const edge = graphData.edges[key]; - const nodes = key.split("|#|"); - // 仅保留正好包含2个节点的边,其余丢弃 - if (nodes.length !== 2) continue; - - // 创建边的唯一标识符,确保顺序一致 + // Graph模式:只显示维度为2的球棍图 + const edgeSet = new Set(); + + edgeEntries.forEach(([key, edge]) => { + const nodes = key.split(EDGE_SEPARATOR); + if (nodes.length !== 2) return; + const [a, b] = nodes; const edgeId = a < b ? `${a}-${b}` : `${b}-${a}`; + if (!edgeSet.has(edgeId)) { edgeSet.add(edgeId); hyperData.edges.push({ @@ -180,42 +211,21 @@ ...edge, }); } - } - // 过滤nodes不在hyperData.edges中的节点 - hyperData.nodes = hyperData.nodes.filter((node) => { - return hyperData.edges.some((edge) => { - return edge.source === node.id || edge.target === node.id; - }); }); + + // 过滤未连接的节点 + const connectedNodes = new Set(); + hyperData.edges.forEach((edge) => { + connectedNodes.add(edge.source); + connectedNodes.add(edge.target); + }); + hyperData.nodes = hyperData.nodes.filter((node) => + connectedNodes.has(node.id) + ); } else { // Hyper模式:使用bubble-sets插件 - // 创建样式函数 - const createStyle = (baseColor) => ({ - fill: baseColor, - /* stroke: baseColor, */ - stroke: "#000000", - lineWidth: 1, - maxRoutingIterations: 100, - maxMarchingIterations: 20, - pixelGroup: 4, - edgeR0: 10, - edgeR1: 60, - nodeR0: 15, - nodeR1: 50, - morphBuffer: 10, - threshold: 4, - memberInfluenceFactor: 1, - edgeInfluenceFactor: 4, - nonMemberInfluenceFactor: -0.8, - virtualEdges: true, - }); - - // 添加超边 - const edgeKeys = Object.keys(graphData.edges); - for (let i = 0; i < edgeKeys.length; i++) { - const key = edgeKeys[i]; - const edge = graphData.edges[key]; - const nodes = key.split("|#|"); + edgeEntries.forEach(([key, edge], i) => { + const nodes = key.split(EDGE_SEPARATOR); plugins.push({ key: `bubble-sets-${key}`, @@ -224,7 +234,7 @@ keywords: edge.keywords || "", summary: edge.summary || "", weight: edge.weight || nodes.length, - ...createStyle(colors[i % colors.length]), + ...createBubbleStyle(COLORS[i % COLORS.length]), }); hyperData.hyperEdges.push({ @@ -232,88 +242,80 @@ ...edge, members: nodes, }); - } + }); } // 添加tooltip插件 + const excludedKeys = new Set([ + "id", + "entity_name", + "entity_type", + "style", + "data", + "description", + ]); + plugins.push({ type: "tooltip", getContent: (e, items) => { - let result = ""; - items.forEach((item) => { - result += `

${item.id}

`; - if (item.entity_name) { - result += `

Name: ${item.entity_name}

`; - } - if (item.entity_type) { - result += `

Type: ${item.entity_type}

`; - } - if (item.description) { - const desc = item.description - .split("") - .slice(0, 2) - .join("; "); - result += `

Description: ${desc}

`; - } - //展示所有剩余属性 - Object.entries(item).forEach(([key, value]) => { - if ( - key !== "id" && - key !== "entity_name" && - key !== "entity_type" && - key !== "style" && - key !== "data" && - key !== "description" - ) { - result += `

${key}: ${value}

`; + return items + .map((item) => { + let result = `

${item.id}

`; + if (item.entity_name) + result += `

Name: ${item.entity_name}

`; + if (item.entity_type) + result += `

Type: ${item.entity_type}

`; + if (item.description) { + result += `

Description: ${formatDescription( + item.description + )}

`; } - }); - }); - return result; + // 展示所有剩余属性 + Object.entries(item).forEach(([key, value]) => { + if (!excludedKeys.has(key)) { + result += `

${key}: ${value}

`; + } + }); + return result; + }) + .join(""); }, }); + const isGraph = visualizationMode === "graph"; + return { data: hyperData, - plugins: - visualizationMode === "graph" - ? [plugins[plugins.length - 1]] - : plugins, // graph模式只保留tooltip + plugins: isGraph ? [plugins[plugins.length - 1]] : plugins, node: { palette: { field: "cluster" }, style: { - size: visualizationMode === "graph" ? 20 : 25, + size: isGraph ? 20 : 25, labelText: (d) => d.id, - fill: (d) => { - if (d.id === selectedVertex) { - return "black"; - } - if (d.entity_type) { - return entityTypeColors[d.entity_type] || "#8566CC"; - } - return "#8566CC"; - }, + fill: (d) => getNodeColor(d, selectedVertex, entityTypeColors), }, }, edge: { style: { - size: visualizationMode === "graph" ? 3 : 2, - stroke: visualizationMode === "graph" ? "#a68fff" : undefined, - lineWidth: visualizationMode === "graph" ? 2 : undefined, + size: isGraph ? 3 : 2, + stroke: isGraph ? "#a68fff" : undefined, + lineWidth: isGraph ? 2 : undefined, }, }, layout: { - type: hyperData.nodes.length > 100 ? "force-atlas2" : "force", - clustering: visualizationMode === "graph" ? false : true, + type: + hyperData.nodes.length > LAYOUT_THRESHOLD + ? "force-atlas2" + : "force", + clustering: !isGraph, preventOverlap: true, - nodeClusterBy: - visualizationMode === "graph" ? undefined : "entity_type", + nodeClusterBy: isGraph ? undefined : "entity_type", gravity: 20, - linkDistance: visualizationMode === "graph" ? 100 : 150, + linkDistance: isGraph ? 100 : 150, }, autoFit: "center", }; - }, [graphData, selectedVertex, visualizationMode]); + }, [graphData, selectedVertex, visualizationMode, entityTypeColors]); // 初始化图形 useEffect(() => { @@ -344,22 +346,42 @@ graphRef.current = graph; graphRef.current.render(); - graph.on("pointerover", (e) => { - // 如果e.target是hyperEdge,则显示自定义tooltip + graph.on("pointermove", (e) => { if (e.targetType === "bubble-sets") { + console.log("bubble-sets", e.target); const target = e.target.options; - setHoverHyperedge({ + const newHyperedge = { keywords: target.keywords || "", summary: target.summary || "", members: Array.isArray(target.members) ? target.members : [], weight: target.weight, + }; + // 只在数据不同时才更新 + setHoverHyperedge((prev) => { + if ( + !prev || + prev.keywords !== newHyperedge.keywords || + prev.summary !== newHyperedge.summary || + prev.weight !== newHyperedge.weight || + JSON.stringify(prev.members) !== + JSON.stringify(newHyperedge.members) + ) { + return newHyperedge; + } + return prev; }); } if (e.targetType === "node") { const target = graphDataFormatted.data.nodes.find( (node) => node.id === e.target.id ); - setHoverNode(target); + // 只在节点不同时才更新 + setHoverNode((prev) => { + if (!prev || prev.id !== target?.id) { + return target; + } + return prev; + }); } }); @@ -388,51 +410,32 @@ }; }, [graphDataFormatted, visualizationMode]); - // 默认选中“最大的”节点与超边(首次或每次数据/模式变化时) + // 默认选中"最大的"节点与超边(首次或每次数据/模式变化时) useEffect(() => { if (!graphDataFormatted) return; - const nodes = graphDataFormatted?.data?.nodes || []; + const nodes = graphDataFormatted.data.nodes; if (!hoverNode && nodes.length > 0) { - const nodeWithMax = nodes.reduce((best, cur) => { - const bestDeg = typeof best.degree === "number" ? best.degree : 0; - const curDeg = typeof cur.degree === "number" ? cur.degree : 0; - return curDeg > bestDeg ? cur : best; - }, nodes[0]); + const nodeWithMax = nodes.reduce((best, cur) => + (cur.degree || 0) > (best.degree || 0) ? cur : best + ); setHoverNode(nodeWithMax); } if (visualizationMode === "hyper") { - const hyperEdges = graphDataFormatted?.data?.hyperEdges || []; + const hyperEdges = graphDataFormatted.data.hyperEdges; if (!hoverHyperedge && hyperEdges.length > 0) { - const hyperWithMax = hyperEdges.reduce((best, cur) => { - const bestVal = - typeof best.weight === "number" - ? best.weight - : Array.isArray(best.members) - ? best.members.length - : 0; - const curVal = - typeof cur.weight === "number" - ? cur.weight - : Array.isArray(cur.members) - ? cur.members.length - : 0; - return curVal > bestVal ? cur : best; - }, hyperEdges[0]); + const getWeight = (edge) => + edge.weight || edge.members?.length || 0; + const hyperWithMax = hyperEdges.reduce((best, cur) => + getWeight(cur) > getWeight(best) ? cur : best + ); setHoverHyperedge({ keywords: hyperWithMax.keywords || "", summary: hyperWithMax.summary || "", - members: Array.isArray(hyperWithMax.members) - ? hyperWithMax.members - : [], - weight: - typeof hyperWithMax.weight === "number" - ? hyperWithMax.weight - : Array.isArray(hyperWithMax.members) - ? hyperWithMax.members.length - : undefined, + members: hyperWithMax.members || [], + weight: getWeight(hyperWithMax), }); } } @@ -550,21 +553,20 @@

) : ( filteredVertices.map((vertex) => { + const isSelected = selectedVertex === vertex.id; const isSearchMatch = searchResults.some( - (result) => result.id === vertex.id + (r) => r.id === vertex.id ); - const isSelected = selectedVertex === vertex.id; + const highlightClass = isSelected + ? "bg-primary-50 border-l-4 border-l-primary-500 shadow-md" + : isSearchMatch + ? "bg-yellow-50 border-l-4 border-l-yellow-400 shadow-sm" + : ""; return (
setSelectedVertex(vertex.id)} >
@@ -576,21 +578,14 @@

)}

- {vertex.entity_type ? ( -
- Type: - - {vertex.entity_type} - -
- ) : ( -
- ID: - - {vertex.id} - -
- )} +
+ + {vertex.entity_type ? "Type" : "ID"}: + + + {vertex.entity_type || vertex.id} + +
Degree: @@ -600,7 +595,7 @@

{vertex.description && (
- {vertex.description} + {formatDescription(vertex.description)}
)}
@@ -622,26 +617,19 @@

Mode:
- - + {["hyper", "graph"].map((mode) => ( + + ))}

@@ -695,7 +683,7 @@

HyperGraph Detail
- {hoverHyperedge ? ( + {hoverHyperedge && (
HyperEdge @@ -705,9 +693,12 @@

Keywords:
{hoverHyperedge.keywords - .split(",") - .map((keyword) => ( - + .split(/,|,|、|。|/) + .map((keyword, i) => ( + {keyword} ))} @@ -725,11 +716,14 @@

{hoverHyperedge.members?.length > 0 && (
- Members ({hoverHyperedge.members.length}): + Nodes ({hoverHyperedge.members.length}):
- {hoverHyperedge.members.map((member) => ( - + {hoverHyperedge.members.map((member, i) => ( + {member} ))} @@ -737,10 +731,8 @@

)}
- ) : ( -
)} - {hoverNode ? ( + {hoverNode && (
Node @@ -766,8 +758,8 @@

Description: - - {hoverNode.description} + + {formatDescription(hoverNode.description)}

)} @@ -776,14 +768,14 @@

Additional Properties: - - {hoverNode.additional_properties} + + {formatDescription( + hoverNode.additional_properties + )}

)}

- ) : ( -
)}

)}