renderer.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. const STATUS_LABELS = {
  2. pending: "待开始",
  3. "in-progress": "执行中",
  4. completed: "已完成",
  5. bug: "Bug",
  6. };
  7. function svgEl(tagName, attrs = {}) {
  8. const node = document.createElementNS("http://www.w3.org/2000/svg", tagName);
  9. Object.entries(attrs).forEach(([key, value]) => {
  10. if (value !== undefined && value !== null) node.setAttribute(key, String(value));
  11. });
  12. return node;
  13. }
  14. function truncate(text, max = 26) {
  15. if (!text) return "";
  16. if (text.length <= max) return text;
  17. return `${text.slice(0, max - 1)}…`;
  18. }
  19. function oneLine(text) {
  20. return String(text || "").replace(/\s+/g, " ").trim();
  21. }
  22. function toPrettyTime(isoValue) {
  23. if (!isoValue) return "-";
  24. const date = new Date(isoValue);
  25. if (Number.isNaN(date.getTime())) return "-";
  26. const y = date.getFullYear();
  27. const m = String(date.getMonth() + 1).padStart(2, "0");
  28. const d = String(date.getDate()).padStart(2, "0");
  29. const h = String(date.getHours()).padStart(2, "0");
  30. const min = String(date.getMinutes()).padStart(2, "0");
  31. return `${y}-${m}-${d} ${h}:${min}`;
  32. }
  33. function edgePath(from, to) {
  34. const c1x = from.x + 92;
  35. const c2x = to.x - 92;
  36. return `M ${from.x} ${from.y} C ${c1x} ${from.y}, ${c2x} ${to.y}, ${to.x} ${to.y}`;
  37. }
  38. export class ThoughtChainRenderer {
  39. constructor(params) {
  40. this.svg = params.svg;
  41. this.linksLayer = params.linksLayer;
  42. this.nodesLayer = params.nodesLayer;
  43. }
  44. renderEmpty() {
  45. this.linksLayer.innerHTML = "";
  46. this.nodesLayer.innerHTML = "";
  47. const text = svgEl("text", {
  48. x: 180,
  49. y: 200,
  50. class: "empty-state",
  51. });
  52. text.textContent = "当前会话暂无节点";
  53. this.nodesLayer.appendChild(text);
  54. }
  55. render(graph, layout, options = {}) {
  56. const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
  57. if (!nodes.length) {
  58. this.renderEmpty();
  59. return;
  60. }
  61. const selectedNodeId = options.selectedNodeId || null;
  62. const onNodeClick = typeof options.onNodeClick === "function" ? options.onNodeClick : null;
  63. this.linksLayer.innerHTML = "";
  64. this.nodesLayer.innerHTML = "";
  65. for (const link of layout.links) {
  66. const path = svgEl("path", {
  67. class: `link-path status-${link.status} type-${link.type || "reference"}`,
  68. d: edgePath(link.from, link.to),
  69. });
  70. this.linksLayer.appendChild(path);
  71. }
  72. for (const node of nodes) {
  73. const pos = layout.positions.get(node.id);
  74. if (!pos) continue;
  75. const selectedClass = node.id === selectedNodeId ? " selected" : "";
  76. const group = svgEl("g", {
  77. class: `node-group status-${node.status}${selectedClass}`,
  78. transform: `translate(${pos.x} ${pos.y})`,
  79. "data-id": node.id,
  80. });
  81. if (node.status === "in-progress") {
  82. const ring = svgEl("rect", {
  83. class: "progress-ring",
  84. x: -146,
  85. y: -58,
  86. width: 292,
  87. height: 116,
  88. rx: 24,
  89. });
  90. group.appendChild(ring);
  91. }
  92. const card = svgEl("rect", {
  93. class: "node-card",
  94. x: -135,
  95. y: -50,
  96. width: 270,
  97. height: 100,
  98. rx: 18,
  99. });
  100. group.appendChild(card);
  101. const statusDot = svgEl("circle", {
  102. class: "node-status-dot",
  103. cx: -114,
  104. cy: -31,
  105. r: 7,
  106. });
  107. group.appendChild(statusDot);
  108. const title = svgEl("text", {
  109. class: "node-title",
  110. x: -98,
  111. y: -27,
  112. });
  113. title.textContent = truncate(oneLine(node.name), 22);
  114. group.appendChild(title);
  115. const line1 = svgEl("text", {
  116. class: "node-subtitle",
  117. x: -98,
  118. y: -5,
  119. });
  120. line1.textContent = `${STATUS_LABELS[node.status] || node.status} · ${node.nodeType}`;
  121. group.appendChild(line1);
  122. const line2 = svgEl("text", {
  123. class: "node-subtitle",
  124. x: -98,
  125. y: 16,
  126. });
  127. line2.textContent = toPrettyTime(node.timestamps?.created || node.createdAt);
  128. group.appendChild(line2);
  129. const line3 = svgEl("text", {
  130. class: "node-subtitle muted",
  131. x: -98,
  132. y: 36,
  133. });
  134. line3.textContent = truncate(oneLine(node.content || node.notes || ""), 28) || "无详细内容";
  135. group.appendChild(line3);
  136. if (onNodeClick) {
  137. group.addEventListener("click", (event) => {
  138. event.stopPropagation();
  139. onNodeClick(node.id);
  140. });
  141. }
  142. this.nodesLayer.appendChild(group);
  143. }
  144. }
  145. exportSvg(filename) {
  146. const cloned = this.svg.cloneNode(true);
  147. cloned.setAttribute("xmlns", "http://www.w3.org/2000/svg");
  148. cloned.setAttribute("width", String(this.svg.clientWidth || 1600));
  149. cloned.setAttribute("height", String(this.svg.clientHeight || 960));
  150. const source = new XMLSerializer().serializeToString(cloned);
  151. const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
  152. const url = URL.createObjectURL(blob);
  153. const link = document.createElement("a");
  154. link.href = url;
  155. link.download = filename;
  156. link.click();
  157. URL.revokeObjectURL(url);
  158. }
  159. }