Skip to content

Commit ef9cf2f

Browse files
DOC-5991 intial decision tree diagram
1 parent cada51a commit ef9cf2f

File tree

3 files changed

+347
-0
lines changed

3 files changed

+347
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{{- /* Render hook for decision tree code blocks */ -}}
2+
<pre class="decision-tree-source">{{ .Inner | htmlEscape | safeHTML }}</pre>
3+
{{ .Page.Store.Set "hasDecisionTree" true }}
4+

layouts/_default/baseof.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,10 @@
108108
{{ if .Page.Store.Get "hasHierarchy" }}
109109
<script src="{{ "js/hierarchy.js" | relURL }}"></script>
110110
{{ end }}
111+
112+
<!-- Decision tree functionality -->
113+
{{ if .Page.Store.Get "hasDecisionTree" }}
114+
<script src="{{ "js/decision-tree.js" | relURL }}"></script>
115+
{{ end }}
111116
</body>
112117
</html>

static/js/decision-tree.js

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
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

Comments
 (0)