Zion Boggan
repos/TreeTrace/src/render-mermaid.js
zionboggan.com ↗
404 lines · javascript
History for this file →
1
import { analyzeTree, isStrategicDirection, latestByTime } from './analyze.js';
2
 
3
const RELATIONSHIP_BY_KIND = {
4
  direction: 'refines',
5
  correction: 'corrects',
6
  'scope-change': 'expands',
7
  checkpoint: 'checkpoint',
8
  question: 'asks',
9
  root: 'refines',
10
};
11
 
12
const MAX_LABEL = 60;
13
 
14
const SUMMARY_THRESHOLD = 25;
15
 
16
const INIT =
17
  "%%{init: {'theme':'base','themeVariables':{" +
18
  "'background':'#0B1210'," +
19
  "'primaryColor':'#121A17'," +
20
  "'primaryTextColor':'#EDF7F2'," +
21
  "'primaryBorderColor':'#0CA08A'," +
22
  "'lineColor':'#5BF0B8'," +
23
  "'tertiaryColor':'#0B1210'," +
24
  "'edgeLabelBackground':'#0B1210'," +
25
  "'fontFamily':'JetBrains Mono, ui-monospace, monospace'," +
26
  "'fontSize':'13px'" +
27
  "}}}%%";
28
 
29
const CLASS_DEFS = [
30
  'classDef node fill:#121A17,stroke:#243430,color:#EDF7F2;',
31
  'classDef spine fill:#121A17,stroke:#0CA08A,color:#EDF7F2;',
32
  'classDef goal fill:#0E1714,stroke:#0CA08A,stroke-width:2px,color:#EDF7F2;',
33
  'classDef result fill:#0F221C,stroke:#5BF0B8,stroke-width:2.5px,color:#5BF0B8;',
34
  'classDef failure fill:#1A140C,stroke:#F0B86A,color:#F0B86A;',
35
  'classDef correction fill:#121A17,stroke:#0CA08A,color:#EDF7F2;',
36
  'classDef abandoned fill:#0E1411,stroke:#34493F,color:#8FA8A0,stroke-dasharray:3 3;',
37
];
38
 
39
const KIND_CLASS = {
40
  root: 'spine',
41
  direction: 'spine',
42
  correction: 'correction',
43
  'scope-change': 'spine',
44
  question: 'spine',
45
  checkpoint: 'spine',
46
};
47
 
48
export function renderMermaid(tree, opts = {}) {
49
  const { nodes } = tree;
50
  const analysis = analyzeTree(tree);
51
 
52
  const root = nodes.find((n) => n.kind === 'root') || nodes[0] || null;
53
  const result = pickResult(nodes);
54
 
55
  const liveCount = nodes.filter((n) => n.status !== 'abandoned').length;
56
  const summary = opts.summary === true
57
    ? true
58
    : opts.full === true
59
      ? false
60
      : liveCount > SUMMARY_THRESHOLD;
61
 
62
  return summary
63
    ? renderSummary(tree, analysis, root, result)
64
    : renderFull(tree, analysis, root, result);
65
}
66
 
67
export function isSummaryByDefault(tree) {
68
  const live = (tree.nodes || []).filter((n) => n.status !== 'abandoned').length;
69
  return live > SUMMARY_THRESHOLD;
70
}
71
 
72
export const SUMMARY_NODE_THRESHOLD = SUMMARY_THRESHOLD;
73
 
74
function renderFull(tree, analysis, root, result) {
75
  const { nodes } = tree;
76
  const lines = [];
77
  lines.push(INIT);
78
  lines.push('flowchart TD');
79
  for (const def of CLASS_DEFS) lines.push(`  ${def}`);
80
  lines.push('');
81
 
82
  for (const n of nodes) {
83
    const id = nodeId(n);
84
    const label = mermaidLabel(nodeText(n, root, result));
85
    lines.push(`  ${id}${shapeOpen(n, root, result)}"${label}"${shapeClose(n, root, result)}`);
86
  }
87
  lines.push('');
88
 
89
  for (const n of nodes) {
90
    if (!n.parent) continue;
91
    const rel = RELATIONSHIP_BY_KIND[n.kind] || 'refines';
92
    const abandoned = n.status === 'abandoned';
93
    const arrow = abandoned ? '-.->' : '-->';
94
    lines.push(`  ${nodeId(n.parent)} ${arrow}|${rel}| ${nodeId(n)}`);
95
  }
96
  lines.push('');
97
 
98
  const chains = analysis.correctionChains || [];
99
  const byId = new Map(nodes.map((n) => [n.id, n]));
100
  const chainEdges = [];
101
  const failureIds = new Set();
102
  for (const chain of chains) {
103
    const from = byId.get(chain.failureNodeId);
104
    const to = byId.get(chain.correctionNodeId);
105
    if (!from || !to || from === to) continue;
106
    failureIds.add(from.id);
107
    const conf = confLabel(from);
108
    const lbl = conf ? `${chain.failureType} ${conf}` : 'fixes';
109
    chainEdges.push(`  ${nodeId(from)} -.->|"${mermaidLabel(lbl)}"| ${nodeId(to)}`);
110
  }
111
  if (chainEdges.length) {
112
    lines.push('  %% correction chains (failure -> correction), amber flag');
113
    lines.push(...chainEdges);
114
    lines.push('');
115
  }
116
 
117
  const assigns = [];
118
  for (const n of nodes) {
119
    const classes = classesFor(n, root, result, failureIds);
120
    if (classes.length) assigns.push(`  class ${nodeId(n)} ${classes.join(',')};`);
121
  }
122
  lines.push(...assigns);
123
  lines.push('');
124
 
125
  const treeEdgeCount = nodes.filter((n) => n.parent).length;
126
  if (chainEdges.length) {
127
    const chainIdx = chainEdges.map((_, i) => treeEdgeCount + i);
128
    lines.push(`  linkStyle ${chainIdx.join(',')} stroke:#F0B86A,stroke-width:1.5px;`);
129
  }
130
  const spineLinks = spineLinkIndexes(nodes);
131
  if (spineLinks.length) {
132
    lines.push(`  linkStyle ${spineLinks.join(',')} stroke:#5BF0B8,stroke-width:2.5px;`);
133
  }
134
 
135
  return trimTrailing(lines).join('\n');
136
}
137
 
138
function renderSummary(tree, analysis, root, result) {
139
  const { nodes } = tree;
140
  const byId = new Map(nodes.map((n) => [n.id, n]));
141
  const childrenOf = new Map();
142
  for (const n of nodes) {
143
    if (!n.parent) continue;
144
    const arr = childrenOf.get(n.parent.id) || [];
145
    arr.push(n);
146
    childrenOf.set(n.parent.id, arr);
147
  }
148
 
149
  const chains = analysis.correctionChains || [];
150
  const failureIds = new Set();
151
  const chainTarget = new Map();
152
  for (const chain of chains) {
153
    const from = byId.get(chain.failureNodeId);
154
    const to = byId.get(chain.correctionNodeId);
155
    if (!from || !to || from === to) continue;
156
    failureIds.add(from.id);
157
    chainTarget.set(from.id, { to, type: chain.failureType });
158
  }
159
 
160
  const isKept = (n) =>
161
    n === root ||
162
    (result && n === result) ||
163
    failureIds.has(n.id) ||
164
    n.kind === 'direction' ||
165
    n.kind === 'scope-change' ||
166
    n.kind === 'correction';
167
 
168
  const lines = [];
169
  lines.push(INIT);
170
  lines.push('flowchart TD');
171
  for (const def of CLASS_DEFS) lines.push(`  ${def}`);
172
  lines.push('');
173
 
174
  const nodeDecls = [];
175
  const edges = [];
176
  const assigns = [];
177
  const stubAssigns = [];
178
  let stubSeq = 0;
179
 
180
  const liveChildren = (n) =>
181
    (childrenOf.get(n.id) || []).filter((c) => c.status !== 'abandoned');
182
  const abandonedChildren = (n) =>
183
    (childrenOf.get(n.id) || []).filter((c) => c.status === 'abandoned');
184
 
185
  const emittedNode = new Set();
186
  const emitNode = (n) => {
187
    if (emittedNode.has(n.id)) return;
188
    emittedNode.add(n.id);
189
    const label = mermaidLabel(nodeText(n, root, result));
190
    nodeDecls.push(`  ${nodeId(n)}${shapeOpen(n, root, result)}"${label}"${shapeClose(n, root, result)}`);
191
    const classes = classesFor(n, root, result, failureIds);
192
    if (classes.length) assigns.push(`  class ${nodeId(n)} ${classes.join(',')};`);
193
  };
194
 
195
  const sizeOfSubtree = (n) => {
196
    let count = 1;
197
    for (const c of childrenOf.get(n.id) || []) count += sizeOfSubtree(c);
198
    return count;
199
  };
200
  const emitAbandonedStub = (liveParent) => {
201
    const roots = abandonedChildren(liveParent);
202
    if (!roots.length) return;
203
    let total = 0;
204
    for (const r of roots) total += sizeOfSubtree(r);
205
    const stubId = `A${stubSeq++}`;
206
    const label = `${total} abandoned ${total === 1 ? 'step' : 'steps'}`;
207
    nodeDecls.push(`  ${stubId}["${label}"]`);
208
    stubAssigns.push(`  class ${stubId} abandoned;`);
209
    edges.push({ fromId: nodeId(liveParent), toId: stubId, rel: 'dropped', dotted: true, spine: false });
210
  };
211
 
212
  emitNode(root);
213
  emitAbandonedStub(root);
214
 
215
  const visitFrom = (anchor) => {
216
    const kids = liveChildren(anchor);
217
    if (!kids.length) return;
218
 
219
    const keptKids = kids.filter((c) => isKept(c));
220
    const routineKids = kids.filter((c) => !isKept(c));
221
 
222
    for (const start of routineKids) {
223
      const routine = [];
224
      let cur = start;
225
      let keptNext = null;
226
      while (cur && !isKept(cur)) {
227
        routine.push(cur);
228
        emitAbandonedStub(cur);
229
        const ck = liveChildren(cur);
230
        const nextKept = ck.find((c) => isKept(c));
231
        if (nextKept) { keptNext = nextKept; break; }
232
        cur = ck.length === 1 ? ck[0] : null;
233
        if (!cur && ck.length > 1) {
234
          break;
235
        }
236
      }
237
      const count = routine.length;
238
      const stubId = `S${stubSeq++}`;
239
      const label = `${count} ${count === 1 ? 'step' : 'steps'}`;
240
      nodeDecls.push(`  ${stubId}["${label}"]`);
241
      stubAssigns.push(`  class ${stubId} node;`);
242
      edges.push({ fromId: nodeId(anchor), toId: stubId, rel: 'then', dotted: false, spine: true });
243
      if (keptNext) {
244
        emitNode(keptNext);
245
        emitAbandonedStub(keptNext);
246
        edges.push({
247
          fromId: stubId,
248
          toId: nodeId(keptNext),
249
          rel: RELATIONSHIP_BY_KIND[keptNext.kind] || 'refines',
250
          dotted: false,
251
          spine: true,
252
        });
253
        visitFrom(keptNext);
254
      }
255
    }
256
 
257
    for (const child of keptKids) {
258
      emitNode(child);
259
      emitAbandonedStub(child);
260
      edges.push({
261
        fromId: nodeId(anchor),
262
        toId: nodeId(child),
263
        rel: RELATIONSHIP_BY_KIND[child.kind] || 'refines',
264
        dotted: false,
265
        spine: true,
266
      });
267
      visitFrom(child);
268
    }
269
  };
270
  visitFrom(root);
271
 
272
  const chainEdges = [];
273
  for (const fid of failureIds) {
274
    const from = byId.get(fid);
275
    const t = chainTarget.get(fid);
276
    if (!from || !t || !emittedNode.has(from.id) || !emittedNode.has(t.to.id)) continue;
277
    const conf = confLabel(from);
278
    const lbl = conf ? `${t.type} ${conf}` : 'fixes';
279
    chainEdges.push({ fromId: nodeId(from), toId: nodeId(t.to), label: mermaidLabel(lbl) });
280
  }
281
 
282
  lines.push(...nodeDecls);
283
  lines.push('');
284
 
285
  for (const e of edges) {
286
    const arrow = e.dotted ? '-.->' : '-->';
287
    lines.push(`  ${e.fromId} ${arrow}|${e.rel}| ${e.toId}`);
288
  }
289
  for (const e of chainEdges) {
290
    lines.push(`  ${e.fromId} -.->|"${e.label}"| ${e.toId}`);
291
  }
292
  lines.push('');
293
 
294
  lines.push(...assigns);
295
  lines.push(...stubAssigns);
296
  lines.push('');
297
 
298
  if (chainEdges.length) {
299
    const chainIdx = chainEdges.map((_, i) => edges.length + i);
300
    lines.push(`  linkStyle ${chainIdx.join(',')} stroke:#F0B86A,stroke-width:1.5px;`);
301
  }
302
  const spineIdx = edges.map((e, i) => (e.spine ? i : -1)).filter((i) => i >= 0);
303
  if (spineIdx.length) {
304
    lines.push(`  linkStyle ${spineIdx.join(',')} stroke:#5BF0B8,stroke-width:2.5px;`);
305
  }
306
 
307
  return trimTrailing(lines).join('\n');
308
}
309
 
310
function classesFor(node, root, result, failureIds) {
311
  if (node.status === 'abandoned') return ['abandoned'];
312
  const out = [];
313
  out.push(KIND_CLASS[node.kind] || 'spine');
314
  if (failureIds && failureIds.has(node.id)) out.push('failure');
315
  if (node === root) out.push('goal');
316
  if (result && node === result) out.push('result');
317
  return out;
318
}
319
 
320
function pickResult(nodes) {
321
  const live = nodes.filter((n) => n.status !== 'abandoned');
322
  if (!live.length) return null;
323
  const strategic = live.filter(
324
    (n) =>
325
      (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change') &&
326
      isStrategicDirection(n)
327
  );
328
  const latest = latestByTime(strategic);
329
  if (latest) return latest;
330
  return live[live.length - 1];
331
}
332
 
333
function confLabel(node) {
334
  const sig = (node.failureSignals || [])[0];
335
  if (!sig || typeof sig.confidence !== 'number') return '';
336
  return sig.confidence.toFixed(2);
337
}
338
 
339
function nodeText(node, root, result) {
340
  let prefix = '';
341
  if (node === root) prefix = 'GOAL: ';
342
  else if (result && node === result) prefix = 'RESULT: ';
343
  return prefix + truncateWord(node.title || node.text || node.id, MAX_LABEL);
344
}
345
 
346
function truncateWord(s, n = MAX_LABEL) {
347
  if (!s) return '';
348
  const one = String(s).replace(/\s+/g, ' ').trim();
349
  if (one.length <= n) return one;
350
  const budget = n - 1;
351
  const slice = one.slice(0, budget);
352
  const lastSpace = slice.lastIndexOf(' ');
353
  const head = lastSpace > Math.floor(budget * 0.5) ? slice.slice(0, lastSpace) : slice;
354
  return `${head.trimEnd()}…`;
355
}
356
 
357
function nodeId(node) {
358
  const m = /(\d+)\s*$/.exec(String(node.id || ''));
359
  return m ? `N${m[1]}` : `N_${String(node.id || 'x').replace(/[^A-Za-z0-9_]/g, '')}`;
360
}
361
 
362
function mermaidLabel(text) {
363
  return String(text == null ? '' : text)
364
    .replace(/\r?\n/g, ' ')
365
    .replace(/"/g, '&#39;')
366
    .replace(/[<]/g, '&lt;')
367
    .replace(/[>]/g, '&gt;')
368
    .replace(/\|/g, '&#124;')
369
    .replace(/`/g, '&#96;')
370
    .replace(/[{}]/g, (m) => (m === '{' ? '&#123;' : '&#125;'))
371
    .replace(/\s+/g, ' ')
372
    .trim();
373
}
374
 
375
function shapeOpen(node, root, result) {
376
  if (node === root || (result && node === result)) return '([';
377
  if (node.kind === 'question') return '{{';
378
  if (node.kind === 'checkpoint') return '[/';
379
  return '[';
380
}
381
function shapeClose(node, root, result) {
382
  if (node === root || (result && node === result)) return '])';
383
  if (node.kind === 'question') return '}}';
384
  if (node.kind === 'checkpoint') return '/]';
385
  return ']';
386
}
387
 
388
function spineLinkIndexes(nodes) {
389
  const idxs = [];
390
  let edgeIndex = 0;
391
  for (const n of nodes) {
392
    if (!n.parent) continue;
393
    const onSpine = n.status !== 'abandoned' && n.parent.status !== 'abandoned';
394
    if (onSpine) idxs.push(edgeIndex);
395
    edgeIndex++;
396
  }
397
  return idxs;
398
}
399
 
400
function trimTrailing(lines) {
401
  const out = lines.slice();
402
  while (out.length && out[out.length - 1] === '') out.pop();
403
  return out;
404
}