Browse Source

refactor: 统一节点和边的样式配置

- 新增 nodeStyle.js 统一管理节点样式(颜色、形状、大小、文字)
- 新增 edgeStyle.js 统一管理边样式(颜色、虚实线、分数显示)
- 添加匹配层显示人设匹配节点和连线
- 匹配边根据分数>=0.8显示实线,否则虚线
- 所有组件使用 getNodeStyle() 和 getEdgeStyle() 统一入口

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 1 ngày trước cách đây
mục cha
commit
2970c9d560

+ 2 - 17
script/visualization/src/App.vue

@@ -45,7 +45,7 @@
       <!-- 边类型图例 -->
       <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
         <span class="text-base-content font-medium">边:</span>
-        <div v-for="(color, type) in edgeTypesWithColors" :key="type" class="flex items-center gap-1">
+        <div v-for="(color, type) in edgeTypeColors" :key="type" class="flex items-center gap-1">
           <span class="w-4 h-0.5" :style="{ backgroundColor: color }"></span>
           <span>{{ type }}</span>
         </div>
@@ -119,6 +119,7 @@ import GraphView from './components/GraphView.vue'
 import PostTreeView from './components/PostTreeView.vue'
 import DetailPanel from './components/DetailPanel.vue'
 import { useGraphStore } from './stores/graph'
+import { edgeTypeColors } from './config/edgeStyle'
 
 const store = useGraphStore()
 
@@ -134,22 +135,6 @@ function switchTab(tab) {
   }
 }
 
-// 边类型颜色调色板
-const edgeColorPalette = ['#9b59b6', '#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#1abc9c']
-
-// 从数据中动态获取边类型及对应颜色
-const edgeTypesWithColors = computed(() => {
-  const types = new Set()
-  const edges = store.graphData.edges || {}
-  for (const edge of Object.values(edges)) {
-    if (edge.type) types.add(edge.type)
-  }
-  const result = {}
-  Array.from(types).forEach((t, i) => {
-    result[t] = edgeColorPalette[i % edgeColorPalette.length]
-  })
-  return result
-})
 
 // ==================== 布局类计算 ====================
 function getLeftPanelClass() {

+ 16 - 49
script/visualization/src/components/GraphView.vue

@@ -46,7 +46,7 @@
             <span class="text-base-content/60 w-14">边类型:</span>
             <label v-for="et in allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
               <input type="checkbox" v-model="stepConfigs[step-1].edgeTypes" :value="et" class="checkbox checkbox-xs" />
-              <span :style="{ color: edgeColors[et] }">{{ et }}</span>
+              <span :style="{ color: edgeTypeColors[et] }">{{ et }}</span>
             </label>
           </div>
           <div class="flex items-center gap-2">
@@ -73,6 +73,8 @@
 import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
+import { getNodeStyle } from '../config/nodeStyle'
+import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
 
 // Props
 const props = defineProps({
@@ -93,25 +95,7 @@ const svgRef = ref(null)
 
 let simulation = null
 
-// 维度颜色映射
-const dimColors = {
-  '人设': '#e94560',
-  '灵感点': '#f39c12',
-  '目的点': '#3498db',
-  '关键点': '#9b59b6'
-}
-
-// 边类型颜色(动态分配)
-const edgeColorPalette = ['#9b59b6', '#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#1abc9c']
-const edgeColors = computed(() => {
-  const colors = {}
-  allEdgeTypes.value.forEach((et, i) => {
-    colors[et] = edgeColorPalette[i % edgeColorPalette.length]
-  })
-  return colors
-})
-
-// 从数据中动态获取所有边类型
+// 从数据中动态获取所有边类型(用于游走配置)
 const allEdgeTypes = computed(() => {
   const types = new Set()
   const edges = store.graphData.edges || {}
@@ -230,12 +214,6 @@ const currentNodeName = computed(() => {
   return node ? node.name : store.selectedNodeId
 })
 
-// 获取节点颜色
-function getNodeColor(node) {
-  if (node.type === '人设') return dimColors['人设']
-  return dimColors[node.dimension] || '#888'
-}
-
 // 渲染相关图
 function renderGraph() {
   // 停止旧的 simulation
@@ -320,7 +298,7 @@ function renderGraph() {
     .data(links)
     .join('line')
     .attr('class', 'graph-link')
-    .attr('stroke', d => edgeColors.value[d.type] || '#666')
+    .attr('stroke', d => getEdgeStyle(d).color)
     .attr('stroke-width', 1.5)
 
   // 节点组
@@ -349,36 +327,25 @@ function renderGraph() {
       store.selectNode(d.id)
     })
 
-  // 节点形状
+  // 节点形状(使用统一配置)
   node.each(function(d) {
     const el = d3.select(this)
-    const size = d.isCenter ? 16 : 10
-    const color = getNodeColor(d)
-
-    // 高亮光晕
-    if (d.isHighlighted && !d.isCenter) {
-      el.append('circle')
-        .attr('r', size/2 + 4)
-        .attr('fill', 'none')
-        .attr('stroke', color)
-        .attr('stroke-width', 2)
-        .attr('stroke-opacity', 0.3)
-    }
+    const style = getNodeStyle(d, { isCenter: d.isCenter })
 
-    if (d.type === '分类') {
+    if (style.shape === 'rect') {
       el.append('rect')
-        .attr('x', -size/2)
-        .attr('y', -size/2)
-        .attr('width', size)
-        .attr('height', size)
+        .attr('x', -style.size/2)
+        .attr('y', -style.size/2)
+        .attr('width', style.size)
+        .attr('height', style.size)
         .attr('rx', 3)
-        .attr('fill', color)
+        .attr('fill', style.color)
         .attr('stroke', d.isCenter ? '#fff' : 'none')
         .attr('stroke-width', d.isCenter ? 2 : 0)
     } else {
       el.append('circle')
-        .attr('r', size/2)
-        .attr('fill', color)
+        .attr('r', style.size/2)
+        .attr('fill', style.color)
         .attr('stroke', d.isCenter ? '#fff' : 'none')
         .attr('stroke-width', d.isCenter ? 2 : 0)
     }
@@ -386,7 +353,7 @@ function renderGraph() {
 
   // 节点标签
   node.append('text')
-    .attr('dy', d => (d.isCenter ? 16 : 10) / 2 + 12)
+    .attr('dy', d => getNodeStyle(d, { isCenter: d.isCenter }).size / 2 + 12)
     .attr('text-anchor', 'middle')
     .text(d => d.name.length > 8 ? d.name.slice(0, 8) + '...' : d.name)
 

+ 188 - 41
script/visualization/src/components/PostTreeView.vue

@@ -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

+ 13 - 43
script/visualization/src/components/TreeView.vue

@@ -65,6 +65,7 @@
 import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
+import { dimColors, getNodeStyle } from '../config/nodeStyle'
 
 const props = defineProps({
   hideHeader: {
@@ -131,30 +132,15 @@ function selectNode(node) {
   scrollToNode(node.id)
 }
 
-// 根据节点数据获取颜色
+// 根据节点数据获取颜色(用于搜索列表)
 function getNodeColorById(node) {
-  if (node.type === '人设') return dimColors['人设']
-  return dimColors[node.dimension] || '#888'
-}
-
-// 维度颜色映射
-const dimColors = {
-  '人设': '#e94560',
-  '灵感点': '#f39c12',
-  '目的点': '#3498db',
-  '关键点': '#9b59b6'
+  return getNodeStyle(node).color
 }
 
 // 节点元素映射
 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()
@@ -233,15 +219,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)
@@ -249,15 +232,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)
     }
@@ -265,26 +247,14 @@ function renderTree() {
     nodeElements[d.data.id] = this
   })
 
-  // 节点标签
+  // 节点标签(使用统一配置)
   nodes.append('text')
     .attr('dy', '0.31em')
     .attr('x', d => d.children ? -8 : 8)
     .attr('text-anchor', d => d.children ? 'end' : '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 = d.children ? 6 : 8

+ 32 - 0
script/visualization/src/config/edgeStyle.js

@@ -0,0 +1,32 @@
+// 边样式统一配置
+
+// 所有边类型及颜色
+export const edgeTypeColors = {
+  '属于': '#9b59b6',
+  '包含': '#3498db',
+  '标签共现': '#2ecc71',
+  '分类共现': '#f39c12',
+  '匹配': '#e94560'
+}
+
+// 获取边样式(统一入口)
+// edge: { type, score, ... }
+export function getEdgeStyle(edge) {
+  const type = edge.type || ''
+  const score = edge.score
+  const color = edgeTypeColors[type] || '#666'
+
+  // 匹配边:>=0.8 实线,否则虚线
+  let strokeDasharray = 'none'
+  if (type === '匹配') {
+    strokeDasharray = (score >= 0.8) ? 'none' : '4,2'
+  }
+
+  return {
+    color,
+    strokeWidth: 1.5,
+    strokeDasharray,
+    opacity: type === '匹配' ? Math.max(0.3, score * 0.7) : 0.3,
+    scoreText: score !== undefined ? score.toFixed(2) : ''
+  }
+}

+ 52 - 0
script/visualization/src/config/nodeStyle.js

@@ -0,0 +1,52 @@
+// 节点样式统一配置
+
+// 维度颜色映射
+export const dimColors = {
+  '人设': '#e94560',
+  '帖子': '#e94560',
+  '灵感点': '#f39c12',
+  '目的点': '#3498db',
+  '关键点': '#9b59b6'
+}
+
+// 获取节点样式(统一入口)
+// node: d3 hierarchy 节点或普通对象
+// options: { isCenter, isMatch }
+export function getNodeStyle(node, options = {}) {
+  const data = node.data || node
+  const { isCenter = false, isMatch = false } = options
+
+  // 颜色
+  let color = '#888'
+  if (data.type === '人设' || data.type === '帖子') {
+    color = dimColors[data.type]
+  } else if (data.dimension && dimColors[data.dimension]) {
+    color = dimColors[data.dimension]
+  }
+
+  // 形状
+  const shape = data.type === '分类' ? 'rect' : 'circle'
+
+  // 大小
+  const isRoot = node.depth === 0
+  const isDimension = ['灵感点', '目的点', '关键点'].includes(data.type)
+  let size = 6
+  if (isCenter) size = 16
+  else if (isRoot) size = 12
+  else if (isDimension) size = 10
+
+  // 文字样式
+  const isHighlight = isRoot || isDimension
+  const text = {
+    fill: (isMatch || !isHighlight) ? '#bbb' : color,
+    fontSize: isRoot ? '11px' : (isDimension ? '10px' : '9px'),
+    fontWeight: isHighlight ? 'bold' : 'normal'
+  }
+
+  return {
+    color,
+    shape,
+    size,
+    text
+  }
+}