| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830 |
- <template>
- <div class="flex flex-col h-full">
- <!-- 头部 -->
- <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
- <span>帖子树</span>
- <div class="flex items-center gap-2">
- <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
- 已高亮 {{ store.highlightedNodeIds.size }} 个节点
- </span>
- <template v-if="showExpand">
- <button
- v-if="store.expandedPanel !== 'post-tree'"
- @click="store.expandPanel('post-tree')"
- class="btn btn-ghost btn-xs"
- title="放大"
- >⤢</button>
- <button
- v-if="store.expandedPanel !== 'default'"
- @click="store.resetLayout()"
- class="btn btn-ghost btn-xs"
- title="恢复"
- >⊡</button>
- </template>
- </div>
- </div>
- <!-- 帖子选择下拉框 -->
- <div class="px-4 py-2 bg-base-200 border-b border-base-300">
- <select
- v-model="selectedPostIdx"
- @change="onPostChange"
- class="select select-xs select-bordered w-full"
- >
- <option v-if="store.postList.length === 0" :value="-1">暂无帖子数据</option>
- <option
- v-for="post in store.postList"
- :key="post.index"
- :value="post.index"
- >
- {{ formatPostOption(post) }}
- </option>
- </select>
- </div>
- <!-- SVG 容器 -->
- <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
- <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
- import * as d3 from 'd3'
- import { useGraphStore } from '../stores/graph'
- import { getNodeStyle } from '../config/nodeStyle'
- import { getEdgeStyle } from '../config/edgeStyle'
- import { applyHighlight } from '../utils/highlight'
- const props = defineProps({
- showExpand: {
- type: Boolean,
- default: false
- }
- })
- const store = useGraphStore()
- const containerRef = ref(null)
- const svgRef = ref(null)
- // 当前选中的帖子索引
- const selectedPostIdx = ref(store.selectedPostIndex)
- // zoom 实例和主 g 元素
- let zoom = null
- let mainG = null
- let treeWidth = 0
- let treeHeight = 0
- // 帖子选择变化
- function onPostChange() {
- store.selectPost(selectedPostIdx.value)
- }
- // 格式化帖子选项显示
- function formatPostOption(post) {
- const date = post.createTime ? new Date(post.createTime * 1000).toLocaleDateString() : ''
- const title = post.postTitle || post.postId
- const shortTitle = title.length > 20 ? title.slice(0, 20) + '...' : title
- return date ? `${date} ${shortTitle}` : shortTitle
- }
- // 节点元素映射(统一存储所有节点位置)
- let nodeElements = {}
- let baseNodeElements = {} // 基础节点(帖子树+匹配层),不含游走节点
- let currentRoot = null
- // 处理节点点击
- function handleNodeClick(event, d) {
- event.stopPropagation()
- store.selectNode(d)
- }
- // 渲染树
- function renderTree() {
- const svg = d3.select(svgRef.value)
- svg.selectAll('*').remove()
- nodeElements = {}
- const treeData = store.postTreeData
- if (!treeData || !treeData.id) return
- const container = containerRef.value
- if (!container) return
- const width = container.clientWidth
- const height = container.clientHeight
- svg.attr('viewBox', `0 0 ${width} ${height}`)
- // 创建层级数据
- const root = d3.hierarchy(treeData)
- currentRoot = root
- // 智能计算树的尺寸(垂直布局)
- const allNodes = root.descendants()
- const maxDepth = d3.max(allNodes, d => d.depth)
- const leafCount = allNodes.filter(d => !d.children).length
- // 计算最长文字长度(用于动态调整间距)
- const maxTextLen = d3.max(allNodes, d => {
- const name = d.data.name || ''
- return Math.min(name.length, 10) // 最多显示10个字
- }) || 6
- // 动态计算节点间距(根据文字长度)
- const nodeSpacing = Math.max(60, maxTextLen * 12)
- // 宽度:基于叶子节点数量和文字间距
- treeWidth = Math.max(400, leafCount * nodeSpacing + 100)
- // 高度:根据深度,增大层间距避免垂直重叠
- treeHeight = Math.max(400, (maxDepth + 1) * 120 + 50)
- // 创建缩放行为
- zoom = d3.zoom()
- .scaleExtent([0.1, 3])
- .on('zoom', (e) => {
- mainG.attr('transform', e.transform)
- })
- svg.call(zoom)
- // 创建主组
- mainG = svg.append('g')
- // 创建树布局(垂直方向:从上到下)
- const treeLayout = d3.tree()
- .size([treeWidth - 100, treeHeight - 50])
- .separation((a, b) => {
- // 同级节点间距根据是否有子节点调整
- if (a.parent === b.parent) {
- // 叶子节点需要更大间距放文字
- const aIsLeaf = !a.children
- const bIsLeaf = !b.children
- if (aIsLeaf || bIsLeaf) return 1.5
- return 1
- }
- return 2
- })
- treeLayout(root)
- // 内容组(带偏移)
- const contentG = mainG.append('g')
- .attr('transform', 'translate(50, 25)')
- // 绘制边(垂直方向)
- contentG.append('g')
- .attr('class', 'tree-edges')
- .selectAll('.tree-link')
- .data(root.links())
- .join('path')
- .attr('class', 'tree-link')
- .attr('fill', 'none')
- .attr('stroke', '#3498db')
- .attr('stroke-opacity', 0.3)
- .attr('stroke-width', 1)
- .attr('d', d => {
- const midY = (d.source.y + d.target.y) / 2
- return `M${d.source.x},${d.source.y} C${d.source.x},${midY} ${d.target.x},${midY} ${d.target.x},${d.target.y}`
- })
- // 绘制节点(垂直布局:x 是水平位置,y 是垂直位置)
- const nodes = contentG.append('g')
- .attr('class', 'tree-nodes')
- .selectAll('.tree-node')
- .data(root.descendants())
- .join('g')
- .attr('class', 'tree-node')
- .attr('transform', d => `translate(${d.x},${d.y})`)
- .style('cursor', 'pointer')
- .on('click', handleNodeClick)
- // 节点形状(使用统一配置)
- nodes.each(function(d) {
- const el = d3.select(this)
- const style = getNodeStyle(d)
- if (style.shape === 'rect') {
- el.append('rect')
- .attr('class', 'tree-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', 'tree-shape')
- .attr('r', style.size / 2)
- .attr('fill', style.color)
- .attr('stroke', 'rgba(255,255,255,0.5)')
- .attr('stroke-width', 1)
- }
- nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
- })
- // 节点标签(使用统一配置)
- nodes.append('text')
- .attr('dy', d => d.children ? -10 : 4)
- .attr('dx', d => d.children ? 0 : 10)
- .attr('text-anchor', d => d.children ? 'middle' : '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.data.name
- const maxLen = 10
- return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
- })
- // ========== 绘制匹配层 ==========
- renderMatchLayer(contentG, root, treeHeight)
- // 初始适应视图
- fitToView()
- }
- // 绘制匹配层(人设节点 + 连线)
- function renderMatchLayer(contentG, root, baseTreeHeight) {
- const postGraph = store.currentPostGraph
- if (!postGraph || !postGraph.edges) return
- // 提取匹配边(只取帖子->人设方向的)
- const matchEdges = []
- for (const edge of Object.values(postGraph.edges)) {
- if (edge.type === '匹配' && edge.source.startsWith('帖子:') && edge.target.startsWith('人设:')) {
- matchEdges.push(edge)
- }
- }
- if (matchEdges.length === 0) return
- // 收集匹配的人设节点(去重)
- const matchedPersonaMap = new Map()
- for (const edge of matchEdges) {
- if (!matchedPersonaMap.has(edge.target)) {
- // 从人设节点ID提取信息: "人设:目的点:标签:进行产品种草"
- const parts = edge.target.split(':')
- const name = parts[parts.length - 1]
- const dimension = parts[1] // 灵感点/目的点/关键点
- const type = parts[2] // 标签/分类/点
- matchedPersonaMap.set(edge.target, {
- id: edge.target,
- name: name,
- dimension: dimension,
- type: type,
- sourceEdges: [] // 连接的帖子节点
- })
- }
- matchedPersonaMap.get(edge.target).sourceEdges.push({
- sourceId: edge.source,
- score: edge.score
- })
- }
- const matchedPersonas = Array.from(matchedPersonaMap.values())
- if (matchedPersonas.length === 0) return
- // 计算匹配层的 Y 位置(树的最大深度 + 间距)
- const maxY = d3.max(root.descendants(), d => d.y) || 0
- const matchLayerY = maxY + 100
- // 计算匹配节点的 X 位置(均匀分布)
- const minX = d3.min(root.descendants(), d => d.x) || 0
- const maxX = d3.max(root.descendants(), d => d.x) || 0
- const matchSpacing = (maxX - minX) / Math.max(matchedPersonas.length - 1, 1)
- matchedPersonas.forEach((persona, i) => {
- persona.x = matchedPersonas.length === 1
- ? (minX + maxX) / 2
- : minX + i * matchSpacing
- persona.y = matchLayerY
- })
- // 收集匹配边数据(统一数据结构:source, target)
- const matchLinksData = []
- for (const persona of matchedPersonas) {
- for (const srcEdge of persona.sourceEdges) {
- const sourceNode = nodeElements[srcEdge.sourceId]
- if (!sourceNode) continue
- matchLinksData.push({
- source: srcEdge.sourceId,
- target: persona.id,
- score: srcEdge.score,
- srcX: sourceNode.x - 50,
- srcY: sourceNode.y - 25,
- tgtX: persona.x,
- tgtY: persona.y
- })
- }
- }
- // 绘制匹配连线(使用 data binding)
- const matchLinksG = contentG.append('g').attr('class', 'match-links')
- matchLinksG.selectAll('.match-link')
- .data(matchLinksData)
- .join('path')
- .attr('class', 'match-link')
- .attr('fill', 'none')
- .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
- .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
- .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
- .attr('stroke-dasharray', d => getEdgeStyle({ 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}`
- })
- // 绘制分数标签(使用 data binding)
- const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
- const scoreGroups = matchLinksG.selectAll('.match-score')
- .data(scoreData)
- .join('g')
- .attr('class', 'match-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: '匹配', score: d.score }).color)
- .attr('font-size', '8px')
- .text(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
- // 绘制匹配节点
- const matchNodesG = contentG.append('g').attr('class', 'match-nodes')
- const matchNodes = matchNodesG.selectAll('.match-node')
- .data(matchedPersonas)
- .join('g')
- .attr('class', 'match-node')
- .attr('transform', d => `translate(${d.x},${d.y})`)
- .style('cursor', 'pointer')
- .on('click', handleMatchNodeClick)
- // 匹配节点形状(使用统一配置)
- matchNodes.each(function(d) {
- const el = d3.select(this)
- const style = getNodeStyle(d, { isMatch: true })
- if (style.shape === 'rect') {
- el.append('rect')
- .attr('class', 'tree-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', 'tree-shape')
- .attr('r', 3)
- .attr('fill', style.color)
- .attr('stroke', 'rgba(255,255,255,0.5)')
- .attr('stroke-width', 1)
- }
- // 保存匹配节点位置(统一存入 nodeElements)
- nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
- })
- // 匹配节点标签(使用统一配置)
- matchNodes.append('text')
- .attr('dy', 4)
- .attr('dx', 10)
- .attr('text-anchor', 'start')
- .attr('fill', d => getNodeStyle(d, { isMatch: true }).text.fill)
- .attr('font-size', d => getNodeStyle(d, { isMatch: true }).text.fontSize)
- .attr('font-weight', d => getNodeStyle(d, { isMatch: true }).text.fontWeight)
- .text(d => {
- const name = d.name
- const maxLen = 10
- return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
- })
- // 更新总高度(用于 fitToView)
- 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 => {
- // 同一层的边(Y坐标相近)用向下弯曲的曲线
- if (Math.abs(d.srcY - d.tgtY) < 10) {
- const controlY = d.srcY + 50 // 向下弯曲
- const midX = (d.srcX + d.tgtX) / 2
- return `M${d.srcX},${d.srcY} Q${midX},${controlY} ${d.tgtX},${d.tgtY}`
- }
- // 不同层的边用 S 形曲线
- 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
- // 同一层的边用向下弯曲曲线,分数标签放在曲线中点(t=0.5 时 y = srcY + 25)
- const midY = Math.abs(d.srcY - d.tgtY) < 10 ? d.srcY + 25 : (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)
- }
- }
- // 匹配节点点击处理
- function handleMatchNodeClick(event, d) {
- event.stopPropagation()
- store.selectNode(d)
- }
- // 适应视图(自动缩放以显示全部内容)
- function fitToView() {
- if (!zoom || !mainG || !containerRef.value) return
- const container = containerRef.value
- const width = container.clientWidth
- const height = container.clientHeight
- // 计算缩放比例以适应容器
- const scaleX = width / treeWidth
- const scaleY = height / treeHeight
- const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
- // 计算居中偏移
- const translateX = (width - treeWidth * scale) / 2
- const translateY = (height - treeHeight * scale) / 2
- const svg = d3.select(svgRef.value)
- svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
- }
- // 定位到指定节点
- function zoomToNode(nodeId) {
- const nodeInfo = nodeElements[nodeId]
- if (!nodeInfo || !zoom || !containerRef.value) return
- const container = containerRef.value
- const width = container.clientWidth
- const height = container.clientHeight
- // 计算平移使节点居中
- const scale = 1
- const translateX = width / 2 - nodeInfo.x * scale
- const translateY = height / 2 - nodeInfo.y * scale
- const svg = d3.select(svgRef.value)
- svg.transition().duration(300).call(
- zoom.transform,
- d3.zoomIdentity.translate(translateX, translateY).scale(scale)
- )
- }
- // 更新高亮/置灰状态
- function updateHighlight() {
- // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
- const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
- applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
- }
- // 点击空白取消激活
- function handleSvgClick(event) {
- const target = event.target
- if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
- store.clearSelection()
- }
- }
- // 监听选中/高亮变化,统一更新
- watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
- updateHighlight()
- if (nodeId && nodeId !== oldNodeId) {
- zoomToNode(nodeId)
- }
- })
- watch(() => store.highlightedNodeIds.size, updateHighlight)
- // 监听帖子游走结果变化,渲染游走层
- watch(() => store.postWalkedNodes.length, () => {
- nextTick(renderWalkedLayer)
- })
- // 监听当前帖子变化,重新渲染树
- watch(() => store.currentPostGraph, () => {
- nextTick(() => {
- renderTree()
- })
- }, { immediate: false })
- // 监听 selectedPostIndex 变化,同步下拉框
- watch(() => store.selectedPostIndex, (newIdx) => {
- selectedPostIdx.value = newIdx
- })
- // 监听布局变化,过渡结束后重新适应视图
- function handleTransitionEnd(e) {
- if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
- nextTick(() => {
- renderTree()
- })
- }
- }
- let transitionParent = null
- onMounted(() => {
- nextTick(() => {
- renderTree()
- })
- // 监听父容器的过渡结束事件
- if (containerRef.value) {
- let parent = containerRef.value.parentElement
- while (parent && !parent.classList.contains('transition-all')) {
- parent = parent.parentElement
- }
- if (parent) {
- transitionParent = parent
- parent.addEventListener('transitionend', handleTransitionEnd)
- }
- }
- })
- onUnmounted(() => {
- if (transitionParent) {
- transitionParent.removeEventListener('transitionend', handleTransitionEnd)
- }
- })
- </script>
|