|
@@ -93,6 +93,7 @@ function formatPostOption(post) {
|
|
|
|
|
|
|
|
// 节点元素映射(统一存储所有节点位置)
|
|
// 节点元素映射(统一存储所有节点位置)
|
|
|
let nodeElements = {}
|
|
let nodeElements = {}
|
|
|
|
|
+let baseNodeElements = {} // 基础节点(帖子树+匹配层),不含游走节点
|
|
|
let currentRoot = null
|
|
let currentRoot = null
|
|
|
|
|
|
|
|
// 处理节点点击
|
|
// 处理节点点击
|
|
@@ -427,6 +428,271 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
|
|
|
|
|
// 更新总高度(用于 fitToView)
|
|
// 更新总高度(用于 fitToView)
|
|
|
treeHeight = matchLayerY + 50
|
|
treeHeight = matchLayerY + 50
|
|
|
|
|
+
|
|
|
|
|
+ // 保存匹配层 Y 位置,供游走层使用
|
|
|
|
|
+ lastMatchLayerY = matchLayerY
|
|
|
|
|
+
|
|
|
|
|
+ // 保存基础节点快照(帖子树+匹配层),供游走层判断已有节点
|
|
|
|
|
+ baseNodeElements = { ...nodeElements }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 保存匹配层 Y 位置
|
|
|
|
|
+let lastMatchLayerY = 0
|
|
|
|
|
+
|
|
|
|
|
+// 绘制游走层(按层级渲染路径)
|
|
|
|
|
+function renderWalkedLayer() {
|
|
|
|
|
+ if (!mainG) return
|
|
|
|
|
+
|
|
|
|
|
+ // 移除旧的游走层
|
|
|
|
|
+ mainG.selectAll('.walked-layer').remove()
|
|
|
|
|
+
|
|
|
|
|
+ // 重置 nodeElements 为基础节点(清除之前的游走节点)
|
|
|
|
|
+ nodeElements = { ...baseNodeElements }
|
|
|
|
|
+
|
|
|
|
|
+ const paths = store.postWalkedPaths
|
|
|
|
|
+ if (!paths.length) return
|
|
|
|
|
+
|
|
|
|
|
+ const contentG = mainG.select('g')
|
|
|
|
|
+ if (contentG.empty()) return
|
|
|
|
|
+
|
|
|
|
|
+ // 创建游走层组
|
|
|
|
|
+ const walkedG = contentG.append('g').attr('class', 'walked-layer')
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 按路径位置分层遍历,确定每个新节点的层数 ==========
|
|
|
|
|
+ // nodeLayer: nodeId -> layer (已有节点从 nodeElements 推断,新节点计算得出)
|
|
|
|
|
+ const nodeLayer = new Map()
|
|
|
|
|
+ const newNodes = new Set() // 新增的节点
|
|
|
|
|
+
|
|
|
|
|
+ console.log('=== renderWalkedLayer 层数计算 ===')
|
|
|
|
|
+ console.log('路径数:', paths.length)
|
|
|
|
|
+ console.log('lastMatchLayerY:', lastMatchLayerY)
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化:已有节点的层数(帖子树和匹配层)
|
|
|
|
|
+ // 使用 baseNodeElements(不含之前的游走节点)
|
|
|
|
|
+ // 匹配层的 Y 坐标是 lastMatchLayerY,作为基准层 0
|
|
|
|
|
+ for (const [nodeId, info] of Object.entries(baseNodeElements)) {
|
|
|
|
|
+ // 根据 Y 坐标推断层数(匹配层为0,帖子树在上面为负数)
|
|
|
|
|
+ const layer = Math.round((info.y - 25 - lastMatchLayerY) / 80)
|
|
|
|
|
+ nodeLayer.set(nodeId, Math.min(layer, 0)) // 已有节点层数 <= 0
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log('基础节点数:', nodeLayer.size)
|
|
|
|
|
+
|
|
|
|
|
+ // 找出所有路径的最大长度
|
|
|
|
|
+ const maxPathLen = Math.max(...paths.map(p => p.nodes.length))
|
|
|
|
|
+ console.log('最大路径长度:', maxPathLen)
|
|
|
|
|
+
|
|
|
|
|
+ // 按位置分层遍历(从第2个节点开始,index=1)
|
|
|
|
|
+ for (let pos = 1; pos < maxPathLen; pos++) {
|
|
|
|
|
+ console.log(`--- 处理位置 ${pos} ---`)
|
|
|
|
|
+ for (const path of paths) {
|
|
|
|
|
+ if (pos >= path.nodes.length) continue
|
|
|
|
|
+
|
|
|
|
|
+ const nodeId = path.nodes[pos]
|
|
|
|
|
+ const prevNodeId = path.nodes[pos - 1]
|
|
|
|
|
+
|
|
|
|
|
+ // 如果已经在树上,跳过
|
|
|
|
|
+ if (nodeLayer.has(nodeId)) {
|
|
|
|
|
+ console.log(` 节点 ${nodeId}: 已存在,层数=${nodeLayer.get(nodeId)},跳过`)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 找前驱节点的层数
|
|
|
|
|
+ const prevLayer = nodeLayer.get(prevNodeId) ?? 0
|
|
|
|
|
+ const newLayer = prevLayer + 1
|
|
|
|
|
+
|
|
|
|
|
+ console.log(` 节点 ${nodeId}: 前驱=${prevNodeId}(层${prevLayer}) → 新层数=${newLayer}`)
|
|
|
|
|
+
|
|
|
|
|
+ // 新节点层数 = 前驱层数 + 1
|
|
|
|
|
+ nodeLayer.set(nodeId, newLayer)
|
|
|
|
|
+ newNodes.add(nodeId)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log('新增节点数:', newNodes.size)
|
|
|
|
|
+ console.log('新增节点:', Array.from(newNodes))
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 按层分组新节点 ==========
|
|
|
|
|
+ const layerGroups = new Map() // layer -> Set of nodeIds
|
|
|
|
|
+ for (const nodeId of newNodes) {
|
|
|
|
|
+ const layer = nodeLayer.get(nodeId)
|
|
|
|
|
+ if (!layerGroups.has(layer)) {
|
|
|
|
|
+ layerGroups.set(layer, new Set())
|
|
|
|
|
+ }
|
|
|
|
|
+ layerGroups.get(layer).add(nodeId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 计算新节点位置 ==========
|
|
|
|
|
+ const treeNodes = Object.values(nodeElements)
|
|
|
|
|
+ const minX = d3.min(treeNodes, d => d.x) || 50
|
|
|
|
|
+ const maxX = d3.max(treeNodes, d => d.x) || 300
|
|
|
|
|
+ const layerSpacing = 80 // 层间距
|
|
|
|
|
+
|
|
|
|
|
+ // nodePositions 只存储新节点的位置
|
|
|
|
|
+ const nodePositions = {}
|
|
|
|
|
+
|
|
|
|
|
+ for (const [layer, nodeIds] of layerGroups) {
|
|
|
|
|
+ const nodesAtLayer = Array.from(nodeIds)
|
|
|
|
|
+ const layerY = lastMatchLayerY + layer * layerSpacing
|
|
|
|
|
+
|
|
|
|
|
+ // 新节点均匀分布
|
|
|
|
|
+ const spacing = (maxX - minX - 100) / Math.max(nodesAtLayer.length - 1, 1)
|
|
|
|
|
+ nodesAtLayer.forEach((nodeId, i) => {
|
|
|
|
|
+ nodePositions[nodeId] = {
|
|
|
|
|
+ x: nodesAtLayer.length === 1 ? (minX + maxX) / 2 - 50 : (minX - 50) + i * spacing,
|
|
|
|
|
+ y: layerY
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 收集所有边 ==========
|
|
|
|
|
+ const allEdges = new Map()
|
|
|
|
|
+ for (const path of paths) {
|
|
|
|
|
+ for (const edge of path.edges) {
|
|
|
|
|
+ const edgeKey = `${edge.source}->${edge.target}`
|
|
|
|
|
+ if (!allEdges.has(edgeKey)) {
|
|
|
|
|
+ allEdges.set(edgeKey, edge)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取节点位置的辅助函数(优先新节点位置,然后已有节点位置)
|
|
|
|
|
+ function getNodePos(nodeId) {
|
|
|
|
|
+ if (nodePositions[nodeId]) {
|
|
|
|
|
+ return nodePositions[nodeId]
|
|
|
|
|
+ }
|
|
|
|
|
+ if (nodeElements[nodeId]) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ x: nodeElements[nodeId].x - 50,
|
|
|
|
|
+ y: nodeElements[nodeId].y - 25
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 绘制所有路径上的边 ==========
|
|
|
|
|
+ const edgesData = []
|
|
|
|
|
+ console.log('=== 绘制边 ===')
|
|
|
|
|
+ console.log('总边数:', allEdges.size)
|
|
|
|
|
+
|
|
|
|
|
+ for (const edge of allEdges.values()) {
|
|
|
|
|
+ const srcPos = getNodePos(edge.source)
|
|
|
|
|
+ const tgtPos = getNodePos(edge.target)
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`边 ${edge.source} -> ${edge.target}:`,
|
|
|
|
|
+ 'srcPos=', srcPos ? `(${srcPos.x.toFixed(0)},${srcPos.y.toFixed(0)})` : 'null',
|
|
|
|
|
+ 'tgtPos=', tgtPos ? `(${tgtPos.x.toFixed(0)},${tgtPos.y.toFixed(0)})` : 'null'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if (srcPos && tgtPos) {
|
|
|
|
|
+ edgesData.push({
|
|
|
|
|
+ ...edge,
|
|
|
|
|
+ srcX: srcPos.x,
|
|
|
|
|
+ srcY: srcPos.y,
|
|
|
|
|
+ tgtX: tgtPos.x,
|
|
|
|
|
+ tgtY: tgtPos.y
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log(' ⚠️ 跳过:位置缺失')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log('实际绘制边数:', edgesData.length)
|
|
|
|
|
+
|
|
|
|
|
+ walkedG.selectAll('.walked-link')
|
|
|
|
|
+ .data(edgesData)
|
|
|
|
|
+ .join('path')
|
|
|
|
|
+ .attr('class', 'walked-link')
|
|
|
|
|
+ .attr('fill', 'none')
|
|
|
|
|
+ .attr('stroke', d => getEdgeStyle({ type: d.type, score: d.score }).color)
|
|
|
|
|
+ .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }).opacity)
|
|
|
|
|
+ .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
|
|
|
|
|
+ .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
|
|
|
|
|
+ .attr('d', d => {
|
|
|
|
|
+ const midY = (d.srcY + d.tgtY) / 2
|
|
|
|
|
+ return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 绘制分数标签
|
|
|
|
|
+ const scoreData = edgesData.filter(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
|
|
|
|
|
+ const scoreGroups = walkedG.selectAll('.walked-score')
|
|
|
|
|
+ .data(scoreData)
|
|
|
|
|
+ .join('g')
|
|
|
|
|
+ .attr('class', 'walked-score')
|
|
|
|
|
+ .attr('transform', d => {
|
|
|
|
|
+ const midX = (d.srcX + d.tgtX) / 2
|
|
|
|
|
+ const midY = (d.srcY + d.tgtY) / 2
|
|
|
|
|
+ return `translate(${midX}, ${midY})`
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ scoreGroups.append('rect')
|
|
|
|
|
+ .attr('x', -14).attr('y', -6).attr('width', 28).attr('height', 12)
|
|
|
|
|
+ .attr('rx', 2).attr('fill', '#1d232a').attr('opacity', 0.9)
|
|
|
|
|
+
|
|
|
|
|
+ scoreGroups.append('text')
|
|
|
|
|
+ .attr('text-anchor', 'middle').attr('dy', '0.35em')
|
|
|
|
|
+ .attr('fill', d => getEdgeStyle({ type: d.type, score: d.score }).color)
|
|
|
|
|
+ .attr('font-size', '8px')
|
|
|
|
|
+ .text(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
|
|
|
|
|
+
|
|
|
|
|
+ // ========== 绘制新节点(nodePositions 中的都是新节点) ==========
|
|
|
|
|
+ const newNodesData = []
|
|
|
|
|
+ for (const [nodeId, pos] of Object.entries(nodePositions)) {
|
|
|
|
|
+ // 获取节点数据
|
|
|
|
|
+ const nodeData = store.postWalkedNodes.find(n => n.id === nodeId)
|
|
|
|
|
+ if (nodeData) {
|
|
|
|
|
+ newNodesData.push({ ...nodeData, x: pos.x, y: pos.y })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const walkedNodeGroups = walkedG.selectAll('.walked-node')
|
|
|
|
|
+ .data(newNodesData)
|
|
|
|
|
+ .join('g')
|
|
|
|
|
+ .attr('class', 'walked-node')
|
|
|
|
|
+ .attr('transform', d => `translate(${d.x},${d.y})`)
|
|
|
|
|
+ .style('cursor', 'pointer')
|
|
|
|
|
+ .on('click', (event, d) => {
|
|
|
|
|
+ event.stopPropagation()
|
|
|
|
|
+ store.selectNode(d)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 节点形状
|
|
|
|
|
+ walkedNodeGroups.each(function(d) {
|
|
|
|
|
+ const el = d3.select(this)
|
|
|
|
|
+ const style = getNodeStyle(d)
|
|
|
|
|
+
|
|
|
|
|
+ if (style.shape === 'rect') {
|
|
|
|
|
+ el.append('rect')
|
|
|
|
|
+ .attr('class', 'walked-shape')
|
|
|
|
|
+ .attr('x', -4).attr('y', -4).attr('width', 8).attr('height', 8)
|
|
|
|
|
+ .attr('rx', 1).attr('fill', style.color)
|
|
|
|
|
+ .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ el.append('circle')
|
|
|
|
|
+ .attr('class', 'walked-shape')
|
|
|
|
|
+ .attr('r', 3).attr('fill', style.color)
|
|
|
|
|
+ .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 保存节点位置
|
|
|
|
|
+ nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 节点标签
|
|
|
|
|
+ walkedNodeGroups.append('text')
|
|
|
|
|
+ .attr('dy', 4).attr('dx', 10).attr('text-anchor', 'start')
|
|
|
|
|
+ .attr('fill', d => getNodeStyle(d).text.fill)
|
|
|
|
|
+ .attr('font-size', d => getNodeStyle(d).text.fontSize)
|
|
|
|
|
+ .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
|
|
|
|
|
+ .text(d => {
|
|
|
|
|
+ const name = d.name
|
|
|
|
|
+ const maxLen = 10
|
|
|
|
|
+ return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 更新高度(如果有新节点)
|
|
|
|
|
+ if (Object.keys(nodePositions).length > 0) {
|
|
|
|
|
+ const maxY = Math.max(...Object.values(nodePositions).map(p => p.y))
|
|
|
|
|
+ treeHeight = Math.max(treeHeight, maxY + 50)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 匹配节点点击处理
|
|
// 匹配节点点击处理
|
|
@@ -479,13 +745,15 @@ function zoomToNode(nodeId) {
|
|
|
|
|
|
|
|
// 更新高亮/置灰状态
|
|
// 更新高亮/置灰状态
|
|
|
function updateHighlight() {
|
|
function updateHighlight() {
|
|
|
- applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
|
|
|
|
|
|
|
+ // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
|
|
|
|
|
+ const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
|
|
|
|
|
+ applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 点击空白取消激活
|
|
// 点击空白取消激活
|
|
|
function handleSvgClick(event) {
|
|
function handleSvgClick(event) {
|
|
|
const target = event.target
|
|
const target = event.target
|
|
|
- if (!target.closest('.tree-node') && !target.closest('.match-node')) {
|
|
|
|
|
|
|
+ if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
|
|
|
store.clearSelection()
|
|
store.clearSelection()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -500,6 +768,11 @@ watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
|
|
|
|
|
|
|
|
watch(() => store.highlightedNodeIds.size, updateHighlight)
|
|
watch(() => store.highlightedNodeIds.size, updateHighlight)
|
|
|
|
|
|
|
|
|
|
+// 监听帖子游走结果变化,渲染游走层
|
|
|
|
|
+watch(() => store.postWalkedNodes.length, () => {
|
|
|
|
|
+ nextTick(renderWalkedLayer)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
// 监听当前帖子变化,重新渲染树
|
|
// 监听当前帖子变化,重新渲染树
|
|
|
watch(() => store.currentPostGraph, () => {
|
|
watch(() => store.currentPostGraph, () => {
|
|
|
nextTick(() => {
|
|
nextTick(() => {
|