|
| 1 | +// Decision tree rendering engine |
| 2 | +// Parses YAML decision trees and renders them as tree structure diagrams |
| 3 | + |
| 4 | +(function() { |
| 5 | + 'use strict'; |
| 6 | + |
| 7 | + // Parse YAML from the pre element |
| 8 | + function parseDecisionTreeYAML(yamlText) { |
| 9 | + const lines = yamlText.split('\n'); |
| 10 | + const root = {}; |
| 11 | + const stack = [{ node: root, indent: -1, key: null }]; |
| 12 | + |
| 13 | + for (let i = 0; i < lines.length; i++) { |
| 14 | + const line = lines[i]; |
| 15 | + if (!line.trim() || line.trim().startsWith('#')) continue; |
| 16 | + |
| 17 | + const indent = line.search(/\S/); |
| 18 | + const content = line.trim(); |
| 19 | + |
| 20 | + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { |
| 21 | + stack.pop(); |
| 22 | + } |
| 23 | + |
| 24 | + const parent = stack[stack.length - 1].node; |
| 25 | + |
| 26 | + if (content.includes(':')) { |
| 27 | + const colonIndex = content.indexOf(':'); |
| 28 | + const key = content.substring(0, colonIndex).trim(); |
| 29 | + let value = content.substring(colonIndex + 1).trim(); |
| 30 | + |
| 31 | + if ((value.startsWith('"') && value.endsWith('"')) || |
| 32 | + (value.startsWith("'") && value.endsWith("'"))) { |
| 33 | + value = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); |
| 34 | + } |
| 35 | + |
| 36 | + if (value === '|') { |
| 37 | + const multiLineValue = []; |
| 38 | + i++; |
| 39 | + while (i < lines.length) { |
| 40 | + const nextLine = lines[i]; |
| 41 | + if (nextLine.trim() === '') { |
| 42 | + i++; |
| 43 | + continue; |
| 44 | + } |
| 45 | + const nextIndent = nextLine.search(/\S/); |
| 46 | + if (nextIndent <= indent) break; |
| 47 | + multiLineValue.push(nextLine.trim()); |
| 48 | + i++; |
| 49 | + } |
| 50 | + i--; |
| 51 | + value = multiLineValue.join(' '); |
| 52 | + } |
| 53 | + |
| 54 | + if (value === '') { |
| 55 | + const newObj = {}; |
| 56 | + parent[key] = newObj; |
| 57 | + stack.push({ node: newObj, indent: indent, key: key }); |
| 58 | + } else { |
| 59 | + parent[key] = value; |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + return root; |
| 65 | + } |
| 66 | + |
| 67 | + // Flatten decision tree into a list for tree rendering |
| 68 | + function flattenDecisionTree(questions, rootId) { |
| 69 | + const items = []; |
| 70 | + const visited = new Set(); |
| 71 | + |
| 72 | + function traverse(nodeId, depth) { |
| 73 | + if (visited.has(nodeId)) return; |
| 74 | + visited.add(nodeId); |
| 75 | + |
| 76 | + const question = questions[nodeId]; |
| 77 | + if (!question) return; |
| 78 | + |
| 79 | + items.push({ |
| 80 | + id: nodeId, |
| 81 | + depth: depth, |
| 82 | + type: 'question', |
| 83 | + text: question.text || '', |
| 84 | + whyAsk: question.whyAsk || '' |
| 85 | + }); |
| 86 | + |
| 87 | + if (question.answers) { |
| 88 | + // Process yes answer |
| 89 | + if (question.answers.yes) { |
| 90 | + if (question.answers.yes.nextQuestion) { |
| 91 | + traverse(question.answers.yes.nextQuestion, depth + 1); |
| 92 | + } else if (question.answers.yes.outcome) { |
| 93 | + items.push({ |
| 94 | + id: question.answers.yes.outcome.id, |
| 95 | + depth: depth + 1, |
| 96 | + type: 'outcome', |
| 97 | + text: question.answers.yes.outcome.label || '', |
| 98 | + answer: 'Yes' |
| 99 | + }); |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + // Process no answer |
| 104 | + if (question.answers.no) { |
| 105 | + if (question.answers.no.nextQuestion) { |
| 106 | + traverse(question.answers.no.nextQuestion, depth + 1); |
| 107 | + } else if (question.answers.no.outcome) { |
| 108 | + items.push({ |
| 109 | + id: question.answers.no.outcome.id, |
| 110 | + depth: depth + 1, |
| 111 | + type: 'outcome', |
| 112 | + text: question.answers.no.outcome.label || '', |
| 113 | + answer: 'No' |
| 114 | + }); |
| 115 | + } |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + traverse(rootId, 0); |
| 121 | + return items; |
| 122 | + } |
| 123 | + |
| 124 | + // Wrap text to fit within a maximum width |
| 125 | + function wrapText(text, maxChars) { |
| 126 | + const words = text.split(' '); |
| 127 | + const lines = []; |
| 128 | + let currentLine = ''; |
| 129 | + |
| 130 | + words.forEach(word => { |
| 131 | + if ((currentLine + ' ' + word).trim().length <= maxChars) { |
| 132 | + currentLine = currentLine ? currentLine + ' ' + word : word; |
| 133 | + } else { |
| 134 | + if (currentLine) lines.push(currentLine); |
| 135 | + currentLine = word; |
| 136 | + } |
| 137 | + }); |
| 138 | + if (currentLine) lines.push(currentLine); |
| 139 | + return lines; |
| 140 | + } |
| 141 | + |
| 142 | + // Render decision tree as SVG with tree structure and boxes |
| 143 | + function renderDecisionTree(container, treeData) { |
| 144 | + const rootId = treeData.rootQuestion; |
| 145 | + const questions = treeData.questions; |
| 146 | + |
| 147 | + if (!rootId || !questions[rootId]) { |
| 148 | + container.textContent = 'Error: Invalid decision tree structure'; |
| 149 | + return; |
| 150 | + } |
| 151 | + |
| 152 | + const items = flattenDecisionTree(questions, rootId); |
| 153 | + |
| 154 | + const lineHeight = 24; |
| 155 | + const charWidth = 8; |
| 156 | + const leftMargin = 20; |
| 157 | + const topMargin = 10; |
| 158 | + const indentWidth = 24; |
| 159 | + const boxPadding = 8; |
| 160 | + const maxBoxWidth = 280; // Max width for text in box |
| 161 | + const maxCharsPerLine = Math.floor(maxBoxWidth / charWidth); |
| 162 | + |
| 163 | + // Calculate box dimensions for each item |
| 164 | + items.forEach(item => { |
| 165 | + const lines = wrapText(item.text, maxCharsPerLine); |
| 166 | + item.lines = lines; |
| 167 | + item.boxHeight = lines.length * lineHeight + boxPadding * 2; |
| 168 | + item.boxWidth = Math.min(maxBoxWidth, Math.max(...lines.map(l => l.length)) * charWidth + boxPadding * 2); |
| 169 | + }); |
| 170 | + |
| 171 | + // Calculate SVG dimensions |
| 172 | + let maxDepth = 0; |
| 173 | + items.forEach(item => { |
| 174 | + maxDepth = Math.max(maxDepth, item.depth); |
| 175 | + }); |
| 176 | + |
| 177 | + const svgWidth = leftMargin + (maxDepth + 1) * indentWidth + 320; |
| 178 | + const svgHeight = topMargin + items.reduce((sum, item) => sum + item.boxHeight + 20, 0) + 10; |
| 179 | + |
| 180 | + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); |
| 181 | + svg.setAttribute('width', svgWidth); |
| 182 | + svg.setAttribute('height', svgHeight); |
| 183 | + svg.setAttribute('class', 'decision-tree-diagram'); |
| 184 | + svg.style.marginTop = '1em'; |
| 185 | + svg.style.marginBottom = '1em'; |
| 186 | + svg.style.fontFamily = '"Space Mono", monospace'; |
| 187 | + svg.style.fontSize = '13px'; |
| 188 | + |
| 189 | + let currentY = topMargin; |
| 190 | + |
| 191 | + items.forEach((item, index) => { |
| 192 | + const x = leftMargin + item.depth * indentWidth; |
| 193 | + const y = currentY + item.boxHeight / 2; |
| 194 | + |
| 195 | + if (item.depth > 0) { |
| 196 | + drawTreeLines(svg, item, items, index, leftMargin, topMargin, lineHeight, indentWidth, currentY, item.boxHeight); |
| 197 | + } |
| 198 | + |
| 199 | + // Draw box with different styling for outcomes vs questions |
| 200 | + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); |
| 201 | + rect.setAttribute('x', x + 10); |
| 202 | + rect.setAttribute('y', currentY); |
| 203 | + rect.setAttribute('width', item.boxWidth); |
| 204 | + rect.setAttribute('height', item.boxHeight); |
| 205 | + |
| 206 | + if (item.type === 'outcome') { |
| 207 | + // Outcomes: lighter background, dashed border |
| 208 | + rect.setAttribute('fill', '#f9f9f9'); |
| 209 | + rect.setAttribute('stroke', '#ccc'); |
| 210 | + rect.setAttribute('stroke-width', '1'); |
| 211 | + rect.setAttribute('stroke-dasharray', '3,3'); |
| 212 | + } else { |
| 213 | + // Questions: standard styling |
| 214 | + rect.setAttribute('fill', '#f5f5f5'); |
| 215 | + rect.setAttribute('stroke', '#999'); |
| 216 | + rect.setAttribute('stroke-width', '1'); |
| 217 | + } |
| 218 | + rect.setAttribute('rx', '4'); |
| 219 | + svg.appendChild(rect); |
| 220 | + |
| 221 | + // Draw text lines |
| 222 | + item.lines.forEach((line, lineIndex) => { |
| 223 | + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); |
| 224 | + text.setAttribute('x', x + 10 + boxPadding); |
| 225 | + text.setAttribute('y', currentY + boxPadding + (lineIndex + 1) * lineHeight - 6); |
| 226 | + text.setAttribute('font-family', '"Space Mono", monospace'); |
| 227 | + text.setAttribute('font-size', '13'); |
| 228 | + text.setAttribute('fill', item.type === 'outcome' ? '#666' : '#333'); |
| 229 | + text.textContent = line; |
| 230 | + svg.appendChild(text); |
| 231 | + }); |
| 232 | + |
| 233 | + currentY += item.boxHeight + 20; |
| 234 | + }); |
| 235 | + |
| 236 | + const jsonScript = document.createElement('script'); |
| 237 | + jsonScript.type = 'application/json'; |
| 238 | + jsonScript.className = 'decision-tree-data'; |
| 239 | + jsonScript.textContent = JSON.stringify(treeData, null, 2); |
| 240 | + |
| 241 | + container.appendChild(svg); |
| 242 | + container.parentNode.insertBefore(jsonScript, container.nextSibling); |
| 243 | + } |
| 244 | + |
| 245 | + function drawTreeLines(svg, item, items, itemIndex, leftMargin, topMargin, lineHeight, indentWidth, currentY, boxHeight) { |
| 246 | + const y = currentY + boxHeight / 2; |
| 247 | + const x = leftMargin + item.depth * indentWidth; |
| 248 | + const parentX = x - indentWidth; |
| 249 | + const connectorX = parentX + 10; // Left edge of parent box (vertical line position) |
| 250 | + |
| 251 | + // Find parent item and its Y position, and determine the answer (Yes/No) |
| 252 | + let parentY = null; |
| 253 | + let parentBoxHeight = 0; |
| 254 | + let parentCurrentY = null; |
| 255 | + let answerLabel = ''; |
| 256 | + |
| 257 | + for (let i = itemIndex - 1; i >= 0; i--) { |
| 258 | + if (items[i].depth === item.depth - 1) { |
| 259 | + // Calculate parent's Y position |
| 260 | + let calcY = topMargin; |
| 261 | + for (let j = 0; j < i; j++) { |
| 262 | + calcY += items[j].boxHeight + 20; |
| 263 | + } |
| 264 | + parentCurrentY = calcY; |
| 265 | + parentY = calcY + items[i].boxHeight / 2; |
| 266 | + parentBoxHeight = items[i].boxHeight; |
| 267 | + |
| 268 | + // Determine if this is a Yes or No answer by checking the parent's answers |
| 269 | + // Count how many siblings come before this item |
| 270 | + let siblingCount = 0; |
| 271 | + for (let k = i + 1; k < itemIndex; k++) { |
| 272 | + if (items[k].depth === item.depth) { |
| 273 | + siblingCount++; |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + // If this is the first child of the parent, it's the "yes" path, otherwise "no" |
| 278 | + answerLabel = siblingCount === 0 ? 'Yes' : 'No'; |
| 279 | + break; |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + if (parentY !== null) { |
| 284 | + // Vertical line starts from bottom of parent box |
| 285 | + const verticalStartY = parentCurrentY + parentBoxHeight; |
| 286 | + |
| 287 | + // Vertical line from parent box bottom |
| 288 | + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); |
| 289 | + line.setAttribute('x1', connectorX); |
| 290 | + line.setAttribute('y1', verticalStartY); |
| 291 | + line.setAttribute('x2', connectorX); |
| 292 | + line.setAttribute('y2', y); |
| 293 | + line.setAttribute('stroke', '#999'); |
| 294 | + line.setAttribute('stroke-width', '1'); |
| 295 | + svg.appendChild(line); |
| 296 | + |
| 297 | + // Horizontal line to child |
| 298 | + const boxX = x + 10; |
| 299 | + const hline = document.createElementNS('http://www.w3.org/2000/svg', 'line'); |
| 300 | + hline.setAttribute('x1', connectorX); |
| 301 | + hline.setAttribute('y1', y); |
| 302 | + hline.setAttribute('x2', boxX); |
| 303 | + hline.setAttribute('y2', y); |
| 304 | + hline.setAttribute('stroke', '#999'); |
| 305 | + hline.setAttribute('stroke-width', '1'); |
| 306 | + svg.appendChild(hline); |
| 307 | + |
| 308 | + // Add answer label on the horizontal line, positioned below it to avoid boxes |
| 309 | + const labelX = connectorX + (boxX - connectorX) / 2; |
| 310 | + const labelY = y + 10; |
| 311 | + |
| 312 | + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); |
| 313 | + label.setAttribute('x', labelX); |
| 314 | + label.setAttribute('y', labelY); |
| 315 | + label.setAttribute('font-family', '"Space Mono", monospace'); |
| 316 | + label.setAttribute('font-size', '11'); |
| 317 | + label.setAttribute('fill', '#666'); |
| 318 | + label.setAttribute('text-anchor', 'middle'); |
| 319 | + label.textContent = answerLabel; |
| 320 | + svg.appendChild(label); |
| 321 | + } |
| 322 | + } |
| 323 | + |
| 324 | + document.addEventListener('DOMContentLoaded', function() { |
| 325 | + const sources = document.querySelectorAll('pre.decision-tree-source'); |
| 326 | + |
| 327 | + sources.forEach(pre => { |
| 328 | + const yamlText = pre.textContent; |
| 329 | + const treeData = parseDecisionTreeYAML(yamlText); |
| 330 | + |
| 331 | + const container = document.createElement('div'); |
| 332 | + container.className = 'decision-tree-container'; |
| 333 | + pre.parentNode.insertBefore(container, pre.nextSibling); |
| 334 | + |
| 335 | + renderDecisionTree(container, treeData); |
| 336 | + }); |
| 337 | + }); |
| 338 | +})(); |
0 commit comments