layout-engine.js 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. const DEFAULT_LAYOUT = {
  2. nodeWidth: 270,
  3. nodeHeight: 108,
  4. horizontalGap: 340,
  5. verticalGap: 138,
  6. marginX: 180,
  7. marginY: 120,
  8. };
  9. function safeTime(node) {
  10. const created = node?.timestamps?.created || node?.createdAt || "";
  11. return Date.parse(created) || 0;
  12. }
  13. function cmpByTime(a, b) {
  14. const dt = safeTime(a) - safeTime(b);
  15. if (dt !== 0) return dt;
  16. return String(a.id).localeCompare(String(b.id));
  17. }
  18. function createAdjacency(nodes, edges) {
  19. const byId = new Map(nodes.map((node) => [node.id, node]));
  20. const outgoing = new Map();
  21. const incoming = new Map();
  22. const indegree = new Map();
  23. for (const node of nodes) {
  24. outgoing.set(node.id, []);
  25. incoming.set(node.id, []);
  26. indegree.set(node.id, 0);
  27. }
  28. for (const edge of edges) {
  29. if (!byId.has(edge.from) || !byId.has(edge.to)) continue;
  30. outgoing.get(edge.from).push(edge);
  31. incoming.get(edge.to).push(edge);
  32. indegree.set(edge.to, (indegree.get(edge.to) || 0) + 1);
  33. }
  34. return { byId, outgoing, incoming, indegree };
  35. }
  36. function topologicalOrder(nodes, graph) {
  37. const queue = nodes
  38. .filter((node) => (graph.indegree.get(node.id) || 0) === 0)
  39. .slice()
  40. .sort(cmpByTime);
  41. const indegree = new Map(graph.indegree);
  42. const order = [];
  43. while (queue.length) {
  44. const node = queue.shift();
  45. order.push(node);
  46. const outgoing = graph.outgoing.get(node.id) || [];
  47. for (const edge of outgoing) {
  48. const nextCount = (indegree.get(edge.to) || 0) - 1;
  49. indegree.set(edge.to, nextCount);
  50. if (nextCount === 0) {
  51. queue.push(graph.byId.get(edge.to));
  52. }
  53. }
  54. queue.sort(cmpByTime);
  55. }
  56. if (order.length < nodes.length) {
  57. const seen = new Set(order.map((node) => node.id));
  58. const fallback = nodes.filter((node) => !seen.has(node.id)).sort(cmpByTime);
  59. order.push(...fallback);
  60. }
  61. return order;
  62. }
  63. function computeLayers(order, graph) {
  64. const layerById = new Map();
  65. for (const node of order) {
  66. const incoming = graph.incoming.get(node.id) || [];
  67. let layer = 0;
  68. for (const edge of incoming) {
  69. const parentLayer = layerById.get(edge.from) || 0;
  70. layer = Math.max(layer, parentLayer + 1);
  71. }
  72. layerById.set(node.id, layer);
  73. }
  74. return layerById;
  75. }
  76. function layerBarycenter(node, graph, positions) {
  77. const incoming = graph.incoming.get(node.id) || [];
  78. if (!incoming.length) return Number.POSITIVE_INFINITY;
  79. let sum = 0;
  80. let count = 0;
  81. for (const edge of incoming) {
  82. const pos = positions.get(edge.from);
  83. if (!pos) continue;
  84. sum += pos.y;
  85. count += 1;
  86. }
  87. return count ? sum / count : Number.POSITIVE_INFINITY;
  88. }
  89. function computeBounds(positions, layout) {
  90. if (!positions.size) {
  91. return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 };
  92. }
  93. const xs = [];
  94. const ys = [];
  95. positions.forEach((pos) => {
  96. xs.push(pos.x);
  97. ys.push(pos.y);
  98. });
  99. const minX = Math.min(...xs) - layout.nodeWidth / 2 - 60;
  100. const maxX = Math.max(...xs) + layout.nodeWidth / 2 + 60;
  101. const minY = Math.min(...ys) - layout.nodeHeight / 2 - 60;
  102. const maxY = Math.max(...ys) + layout.nodeHeight / 2 + 60;
  103. return {
  104. minX,
  105. minY,
  106. maxX,
  107. maxY,
  108. width: maxX - minX,
  109. height: maxY - minY,
  110. };
  111. }
  112. export function computeThoughtChainLayout(graphData, options = {}) {
  113. const layout = { ...DEFAULT_LAYOUT, ...options };
  114. const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes.slice() : [];
  115. const edges = Array.isArray(graphData?.edges) ? graphData.edges.slice() : [];
  116. if (!nodes.length) {
  117. return {
  118. positions: new Map(),
  119. links: [],
  120. bounds: { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 },
  121. layout,
  122. };
  123. }
  124. const graph = createAdjacency(nodes, edges);
  125. const order = topologicalOrder(nodes, graph);
  126. const layerById = computeLayers(order, graph);
  127. const layers = new Map();
  128. for (const node of order) {
  129. const layer = layerById.get(node.id) || 0;
  130. if (!layers.has(layer)) layers.set(layer, []);
  131. layers.get(layer).push(node);
  132. }
  133. const positions = new Map();
  134. const layerKeys = Array.from(layers.keys()).sort((a, b) => a - b);
  135. for (const layer of layerKeys) {
  136. const layerNodes = layers.get(layer).slice().sort((a, b) => {
  137. const ba = layerBarycenter(a, graph, positions);
  138. const bb = layerBarycenter(b, graph, positions);
  139. if (Number.isFinite(ba) && Number.isFinite(bb) && ba !== bb) return ba - bb;
  140. if (Number.isFinite(ba) && !Number.isFinite(bb)) return -1;
  141. if (!Number.isFinite(ba) && Number.isFinite(bb)) return 1;
  142. return cmpByTime(a, b);
  143. });
  144. for (let i = 0; i < layerNodes.length; i += 1) {
  145. const node = layerNodes[i];
  146. positions.set(node.id, {
  147. x: layout.marginX + layer * layout.horizontalGap,
  148. y: layout.marginY + i * layout.verticalGap,
  149. });
  150. }
  151. }
  152. const links = edges
  153. .map((edge) => {
  154. const from = positions.get(edge.from);
  155. const to = positions.get(edge.to);
  156. if (!from || !to) return null;
  157. const targetNode = graph.byId.get(edge.to);
  158. return {
  159. ...edge,
  160. from,
  161. to,
  162. status: targetNode ? targetNode.status : "pending",
  163. };
  164. })
  165. .filter(Boolean);
  166. return {
  167. positions,
  168. links,
  169. bounds: computeBounds(positions, layout),
  170. layout,
  171. };
  172. }