| 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, ''') |
| 366 | .replace(/[<]/g, '<') |
| 367 | .replace(/[>]/g, '>') |
| 368 | .replace(/\|/g, '|') |
| 369 | .replace(/`/g, '`') |
| 370 | .replace(/[{}]/g, (m) => (m === '{' ? '{' : '}')) |
| 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 | } |