|
|
@@ -53,6 +53,8 @@
|
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
import * as d3 from 'd3'
|
|
|
import { useGraphStore } from '../stores/graph'
|
|
|
+import { dimColors, getNodeStyle } from '../config/nodeStyle'
|
|
|
+import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
|
|
|
|
|
|
const props = defineProps({
|
|
|
showExpand: {
|
|
|
@@ -88,24 +90,10 @@ function formatPostOption(post) {
|
|
|
return date ? `${date} ${shortTitle}` : shortTitle
|
|
|
}
|
|
|
|
|
|
-// 维度颜色映射(与人设树保持一致)
|
|
|
-const dimColors = {
|
|
|
- '帖子': '#e94560',
|
|
|
- '灵感点': '#f39c12',
|
|
|
- '目的点': '#3498db',
|
|
|
- '关键点': '#9b59b6'
|
|
|
-}
|
|
|
-
|
|
|
// 节点元素映射
|
|
|
let nodeElements = {}
|
|
|
let currentRoot = null
|
|
|
|
|
|
-// 获取节点颜色
|
|
|
-function getNodeColor(d) {
|
|
|
- if (d.data.type === '帖子') return dimColors['帖子']
|
|
|
- return dimColors[d.data.dimension] || '#888'
|
|
|
-}
|
|
|
-
|
|
|
// 处理节点点击
|
|
|
function handleNodeClick(event, d) {
|
|
|
event.stopPropagation()
|
|
|
@@ -219,16 +207,12 @@ function renderTree() {
|
|
|
.style('cursor', 'pointer')
|
|
|
.on('click', handleNodeClick)
|
|
|
|
|
|
- // 节点形状
|
|
|
+ // 节点形状(使用统一配置)
|
|
|
nodes.each(function(d) {
|
|
|
const el = d3.select(this)
|
|
|
- const nodeType = d.data.type
|
|
|
- const nodeColor = getNodeColor(d)
|
|
|
- const isRoot = d.depth === 0
|
|
|
- const isDimension = ['灵感点', '目的点', '关键点'].includes(nodeType)
|
|
|
+ const style = getNodeStyle(d)
|
|
|
|
|
|
- if (nodeType === '点') {
|
|
|
- // 点用方形
|
|
|
+ if (style.shape === 'rect') {
|
|
|
el.append('rect')
|
|
|
.attr('class', 'tree-shape')
|
|
|
.attr('x', -4)
|
|
|
@@ -236,15 +220,14 @@ function renderTree() {
|
|
|
.attr('width', 8)
|
|
|
.attr('height', 8)
|
|
|
.attr('rx', 1)
|
|
|
- .attr('fill', nodeColor)
|
|
|
+ .attr('fill', style.color)
|
|
|
.attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
.attr('stroke-width', 1)
|
|
|
} else {
|
|
|
- const radius = isRoot ? 6 : (isDimension ? 5 : 3)
|
|
|
el.append('circle')
|
|
|
.attr('class', 'tree-shape')
|
|
|
- .attr('r', radius)
|
|
|
- .attr('fill', nodeColor)
|
|
|
+ .attr('r', style.size / 2)
|
|
|
+ .attr('fill', style.color)
|
|
|
.attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
.attr('stroke-width', 1)
|
|
|
}
|
|
|
@@ -252,36 +235,200 @@ function renderTree() {
|
|
|
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 => {
|
|
|
- const isRoot = d.depth === 0
|
|
|
- const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
|
|
|
- return (isRoot || isDimension) ? getNodeColor(d) : '#bbb'
|
|
|
- })
|
|
|
- .attr('font-size', d => {
|
|
|
- const isRoot = d.depth === 0
|
|
|
- const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
|
|
|
- return isRoot ? '11px' : (isDimension ? '10px' : '9px')
|
|
|
- })
|
|
|
- .attr('font-weight', d => {
|
|
|
- const isRoot = d.depth === 0
|
|
|
- const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
|
|
|
- return (isRoot || isDimension) ? 'bold' : 'normal'
|
|
|
- })
|
|
|
+ .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
|
|
|
+ })
|
|
|
+
|
|
|
+ // 绘制匹配连线
|
|
|
+ const matchLinksG = contentG.append('g').attr('class', 'match-links')
|
|
|
+
|
|
|
+ for (const persona of matchedPersonas) {
|
|
|
+ for (const srcEdge of persona.sourceEdges) {
|
|
|
+ const sourceNode = nodeElements[srcEdge.sourceId]
|
|
|
+ if (!sourceNode) continue
|
|
|
+
|
|
|
+ // 获取源节点位置(需要减去 contentG 的偏移)
|
|
|
+ const srcX = sourceNode.x - 50
|
|
|
+ const srcY = sourceNode.y - 25
|
|
|
+
|
|
|
+ const midY = (srcY + persona.y) / 2
|
|
|
+ const midX = (srcX + persona.x) / 2
|
|
|
+ const style = getEdgeStyle({ type: '匹配', score: srcEdge.score })
|
|
|
+
|
|
|
+ matchLinksG.append('path')
|
|
|
+ .attr('class', 'match-link')
|
|
|
+ .attr('fill', 'none')
|
|
|
+ .attr('stroke', style.color)
|
|
|
+ .attr('stroke-opacity', style.opacity)
|
|
|
+ .attr('stroke-width', style.strokeWidth)
|
|
|
+ .attr('stroke-dasharray', style.strokeDasharray)
|
|
|
+ .attr('d', `M${srcX},${srcY} C${srcX},${midY} ${persona.x},${midY} ${persona.x},${persona.y}`)
|
|
|
+
|
|
|
+ // 显示分数(带背景)
|
|
|
+ if (style.scoreText) {
|
|
|
+ const scoreG = matchLinksG.append('g')
|
|
|
+ .attr('transform', `translate(${midX}, ${midY})`)
|
|
|
+
|
|
|
+ // 背景矩形
|
|
|
+ scoreG.append('rect')
|
|
|
+ .attr('x', -14)
|
|
|
+ .attr('y', -6)
|
|
|
+ .attr('width', 28)
|
|
|
+ .attr('height', 12)
|
|
|
+ .attr('rx', 2)
|
|
|
+ .attr('fill', '#1d232a')
|
|
|
+ .attr('opacity', 0.9)
|
|
|
+
|
|
|
+ // 分数文字
|
|
|
+ scoreG.append('text')
|
|
|
+ .attr('text-anchor', 'middle')
|
|
|
+ .attr('dy', '0.35em')
|
|
|
+ .attr('fill', style.color)
|
|
|
+ .attr('font-size', '8px')
|
|
|
+ .text(style.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)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 匹配节点标签(使用统一配置)
|
|
|
+ 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
|
|
|
+}
|
|
|
+
|
|
|
+// 匹配节点点击处理
|
|
|
+function handleMatchNodeClick(event, d) {
|
|
|
+ event.stopPropagation()
|
|
|
+ // 在人设树中选中对应节点
|
|
|
+ const personaNodeId = d.id.replace('人设:', '')
|
|
|
+ // 可以触发一个事件让左边的人设树高亮
|
|
|
+ console.log('点击匹配节点:', d.id, d.name)
|
|
|
+ // TODO: 联动人设树
|
|
|
+}
|
|
|
+
|
|
|
// 适应视图(自动缩放以显示全部内容)
|
|
|
function fitToView() {
|
|
|
if (!zoom || !mainG || !containerRef.value) return
|