Files
gremiumhub/scripts/generate-form-flow-diagram.ts

2343 lines
76 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Form Flow Diagram Generator - Interactive Tree View
*
* Generates a comprehensive, interactive HTML visualization showing
* ALL branches and decision paths through the form with expandable/collapsible nodes.
*
* Usage:
* cd scripts && npm run generate:form-flow
* OR
* npx tsx scripts/generate-form-flow-diagram.ts
*/
import * as fs from "fs";
import * as path from "path";
import * as yaml from "js-yaml";
import * as crypto from "crypto";
// Paths
const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, "..");
const TEMPLATE_DIR = path.join(
PROJECT_ROOT,
"legalconsenthub-backend/src/main/resources/seed/template"
);
const OUTPUT_FILE = path.join(PROJECT_ROOT, "docs/form-flow-diagram.html");
// Types
interface FormOption {
value: string;
label: string;
processingPurpose?: string;
employeeDataCategory?: string;
}
interface VisibilityConditionLeaf {
nodeType?: "LEAF";
formElementConditionType?: string;
sourceFormElementReference: string;
formElementExpectedValue?: string;
formElementOperator: string;
}
interface VisibilityConditionGroup {
nodeType: "GROUP";
groupOperator: "AND" | "OR";
conditions: (VisibilityConditionLeaf | VisibilityConditionGroup)[];
}
interface VisibilityConditions {
operator: "AND" | "OR";
conditions: (VisibilityConditionLeaf | VisibilityConditionGroup)[];
}
interface SectionSpawnTrigger {
templateReference: string;
sectionSpawnConditionType: string;
sectionSpawnExpectedValue?: string;
sectionSpawnOperator: string;
}
interface FormElement {
reference: string;
title?: string;
description?: string;
type: string;
options?: FormOption[];
isClonable?: boolean;
visibilityConditions?: VisibilityConditions;
sectionSpawnTriggers?: SectionSpawnTrigger[];
}
interface FormElementSubSection {
title: string;
subtitle?: string;
formElements: FormElement[];
}
interface FormElementSection {
title: string;
shortTitle?: string;
description?: string;
isTemplate?: boolean;
templateReference?: string;
titleTemplate?: string;
formElementSubSections: FormElementSubSection[];
}
// Tree node for the interactive diagram
interface TreeNode {
id: string;
reference: string;
title: string;
type: string;
sectionTitle: string;
isClonable: boolean;
options: {
value: string;
label: string;
children: TreeNode[];
spawnsTemplates: { templateRef: string; title: string }[];
}[];
// For elements without options (TEXT, TEXTAREA, etc.)
children: TreeNode[];
spawnsTemplates: { templateRef: string; title: string }[];
employeeDataCategory?: string;
}
interface TemplateInfo {
templateReference: string;
title: string;
titleTemplate?: string;
rootElements: string[]; // References of elements without visibility conditions in this template
}
interface D3Node {
id: string;
name: string;
reference: string;
type: string;
nodeKind: "element" | "option" | "template";
sensitivity?: string;
isClonable?: boolean;
sectionTitle?: string;
optionValue?: string;
spawns?: string[];
children?: D3Node[];
}
// Global data structures
const allElements = new Map<string, FormElement>();
const elementSections = new Map<string, string>(); // reference -> section title
const templateInfos = new Map<string, TemplateInfo>();
const sourceHashes = new Map<string, string>();
// Track which elements are controlled by which element+value combinations
const visibilityTriggers = new Map<string, Map<string, string[]>>(); // sourceRef -> Map<value, targetRefs[]>
// Compute file hash for change tracking
function computeFileHash(filePath: string): string {
const content = fs.readFileSync(filePath);
return crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
}
// Parse all YAML files
function loadAllTemplates(): void {
const yamlFiles = fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith(".yaml"));
for (const file of yamlFiles) {
const filePath = path.join(TEMPLATE_DIR, file);
sourceHashes.set(file, computeFileHash(filePath));
if (file === "_main.yaml") continue;
const section = yaml.load(fs.readFileSync(filePath, "utf-8")) as FormElementSection;
const sectionTitle = section.title;
// Track template sections
if (section.isTemplate && section.templateReference) {
const rootElements: string[] = [];
if (section.formElementSubSections) {
for (const subSection of section.formElementSubSections) {
if (subSection.formElements) {
for (const element of subSection.formElements) {
allElements.set(element.reference, element);
elementSections.set(element.reference, sectionTitle);
// Elements without visibility conditions are "root" elements of this template
if (!element.visibilityConditions) {
rootElements.push(element.reference);
}
// Build visibility triggers map
if (element.visibilityConditions) {
extractVisibilityTriggers(element.visibilityConditions, element.reference);
}
}
}
}
}
templateInfos.set(section.templateReference, {
templateReference: section.templateReference,
title: section.title,
titleTemplate: section.titleTemplate,
rootElements,
});
} else {
// Non-template section
if (section.formElementSubSections) {
for (const subSection of section.formElementSubSections) {
if (subSection.formElements) {
for (const element of subSection.formElements) {
allElements.set(element.reference, element);
elementSections.set(element.reference, sectionTitle);
if (element.visibilityConditions) {
extractVisibilityTriggers(element.visibilityConditions, element.reference);
}
}
}
}
}
}
}
}
function extractVisibilityTriggers(
conditions: VisibilityConditions,
targetRef: string
): void {
function processCondition(cond: VisibilityConditionLeaf | VisibilityConditionGroup): void {
if ("sourceFormElementReference" in cond && cond.sourceFormElementReference) {
const sourceRef = cond.sourceFormElementReference;
const expectedValue = cond.formElementExpectedValue || "__ANY__";
const operator = cond.formElementOperator;
if (!visibilityTriggers.has(sourceRef)) {
visibilityTriggers.set(sourceRef, new Map());
}
const valueMap = visibilityTriggers.get(sourceRef)!;
// For EQUALS, use specific value; for others, use special keys
let key = expectedValue;
if (operator === "IS_NOT_EMPTY") {
key = "__NOT_EMPTY__";
} else if (operator === "IS_EMPTY") {
key = "__EMPTY__";
} else if (operator === "NOT_EQUALS") {
key = `__NOT__${expectedValue}`;
} else if (operator === "CONTAINS") {
key = `__CONTAINS__${expectedValue}`;
} else if (operator === "NOT_CONTAINS") {
key = `__NOT_CONTAINS__${expectedValue}`;
}
if (!valueMap.has(key)) {
valueMap.set(key, []);
}
const targets = valueMap.get(key)!;
if (!targets.includes(targetRef)) {
targets.push(targetRef);
}
}
if ("conditions" in cond && cond.conditions) {
for (const subCond of cond.conditions) {
processCondition(subCond);
}
}
}
for (const cond of conditions.conditions) {
processCondition(cond);
}
}
// Get elements that become visible when a specific element has a specific value
function getTriggeredElements(sourceRef: string, value: string): string[] {
const valueMap = visibilityTriggers.get(sourceRef);
if (!valueMap) return [];
const result: string[] = [];
// Exact match
if (valueMap.has(value)) {
result.push(...valueMap.get(value)!);
}
// NOT_EMPTY triggers (for any non-empty value)
if (value && value !== "" && valueMap.has("__NOT_EMPTY__")) {
result.push(...valueMap.get("__NOT_EMPTY__")!);
}
// CONTAINS triggers
for (const [key, targets] of valueMap) {
if (key.startsWith("__CONTAINS__")) {
const searchValue = key.replace("__CONTAINS__", "");
if (value.includes(searchValue)) {
result.push(...targets);
}
}
}
return [...new Set(result)];
}
// Get templates spawned by an element's value
function getSpawnedTemplates(
element: FormElement,
value: string
): { templateRef: string; title: string }[] {
if (!element.sectionSpawnTriggers) return [];
const result: { templateRef: string; title: string }[] = [];
for (const trigger of element.sectionSpawnTriggers) {
let matches = false;
if (trigger.sectionSpawnOperator === "EQUALS") {
matches = value === trigger.sectionSpawnExpectedValue;
} else if (trigger.sectionSpawnOperator === "IS_NOT_EMPTY") {
matches = value !== "" && value !== undefined;
} else if (trigger.sectionSpawnOperator === "CONTAINS") {
matches = value.includes(trigger.sectionSpawnExpectedValue || "");
}
if (matches) {
const templateInfo = templateInfos.get(trigger.templateReference);
const title = templateInfo?.title || trigger.templateReference;
result.push({ templateRef: trigger.templateReference, title });
}
}
return result;
}
// Build tree starting from root elements (elements with no visibility conditions in main sections)
function buildTree(): TreeNode[] {
// Find root elements (no visibility conditions and not in template sections)
const rootElements: string[] = [];
for (const [ref, element] of allElements) {
const sectionTitle = elementSections.get(ref);
// Check if this element is in a template section
let isInTemplate = false;
for (const template of templateInfos.values()) {
if (template.title === sectionTitle) {
isInTemplate = true;
break;
}
}
if (!isInTemplate && !element.visibilityConditions) {
rootElements.push(ref);
}
}
// Build tree for each root element
const visited = new Set<string>();
return rootElements.map((ref) => buildTreeNode(ref, visited, 0));
}
function buildTreeNode(ref: string, visited: Set<string>, depth: number): TreeNode {
const element = allElements.get(ref);
if (!element) {
return {
id: ref,
reference: ref,
title: `Unknown: ${ref}`,
type: "UNKNOWN",
sectionTitle: "",
isClonable: false,
options: [],
children: [],
spawnsTemplates: [],
};
}
// Prevent infinite recursion
const nodeId = `${ref}_${depth}`;
if (visited.has(nodeId) || depth > 10) {
return {
id: nodeId,
reference: ref,
title: element.title || ref,
type: element.type,
sectionTitle: elementSections.get(ref) || "",
isClonable: element.isClonable || false,
options: [],
children: [],
spawnsTemplates: [],
};
}
visited.add(nodeId);
const node: TreeNode = {
id: nodeId,
reference: ref,
title: element.title || ref,
type: element.type,
sectionTitle: elementSections.get(ref) || "",
isClonable: element.isClonable || false,
options: [],
children: [],
spawnsTemplates: [],
};
// Get employee data category from first option if available
if (element.options && element.options.length > 0) {
const firstSensitive = element.options.find(
(o) => o.employeeDataCategory === "SENSITIVE"
);
const firstReview = element.options.find(
(o) => o.employeeDataCategory === "REVIEW_REQUIRED"
);
if (firstSensitive) {
node.employeeDataCategory = "SENSITIVE";
} else if (firstReview) {
node.employeeDataCategory = "REVIEW_REQUIRED";
}
}
// For elements with options (SELECT, RADIOBUTTON, CHECKBOX)
if (
element.options &&
element.options.length > 0 &&
["SELECT", "RADIOBUTTON", "CHECKBOX"].includes(element.type)
) {
for (const option of element.options) {
const value = option.value || option.label;
const label = option.label || option.value;
// Get elements triggered by this option
const triggeredRefs = getTriggeredElements(ref, value);
const childNodes = triggeredRefs.map((childRef) =>
buildTreeNode(childRef, new Set(visited), depth + 1)
);
// Get templates spawned by this option
const spawned = getSpawnedTemplates(element, value);
node.options.push({
value,
label,
children: childNodes,
spawnsTemplates: spawned,
});
}
} else {
// For text inputs, check IS_NOT_EMPTY triggers
const triggeredRefs = getTriggeredElements(ref, "__NOT_EMPTY_PLACEHOLDER__");
const notEmptyTriggers = visibilityTriggers.get(ref)?.get("__NOT_EMPTY__") || [];
for (const childRef of notEmptyTriggers) {
node.children.push(buildTreeNode(childRef, new Set(visited), depth + 1));
}
// Check for spawn triggers
node.spawnsTemplates = getSpawnedTemplates(element, "non-empty-value");
}
return node;
}
// Build template tree (for spawned sections)
function buildTemplateTree(templateRef: string): TreeNode[] {
const template = templateInfos.get(templateRef);
if (!template) return [];
const visited = new Set<string>();
return template.rootElements.map((ref) => buildTreeNode(ref, visited, 0));
}
// Convert TreeNode[] to a single D3Node hierarchy
function convertToD3Hierarchy(roots: TreeNode[]): D3Node {
const visited = new Set<string>();
let idSeq = 0;
function nextId(): string {
return `d3_${++idSeq}`;
}
function convertTreeNode(node: TreeNode, depth: number): D3Node {
const nodeKey = `${node.reference}_${depth}`;
if (visited.has(nodeKey) || depth > 12) {
return {
id: nextId(),
name: node.title || node.reference,
reference: node.reference,
type: node.type,
nodeKind: "element",
sectionTitle: node.sectionTitle,
sensitivity: node.employeeDataCategory,
isClonable: node.isClonable,
};
}
visited.add(nodeKey);
const children: D3Node[] = [];
// Options become intermediate option nodes
if (node.options && node.options.length > 0) {
for (const opt of node.options) {
const optChildren: D3Node[] = [];
for (const child of opt.children) {
optChildren.push(convertTreeNode(child, depth + 1));
}
// Spawned templates become template nodes
for (const spawn of opt.spawnsTemplates) {
const templateRoots = buildTemplateTree(spawn.templateRef);
const templateChildren: D3Node[] = [];
for (const tr of templateRoots) {
templateChildren.push(convertTreeNode(tr, depth + 2));
}
optChildren.push({
id: nextId(),
name: spawn.title,
reference: spawn.templateRef,
type: "TEMPLATE",
nodeKind: "template",
children: templateChildren.length > 0 ? templateChildren : undefined,
});
}
children.push({
id: nextId(),
name: opt.label,
reference: node.reference,
type: node.type,
nodeKind: "option",
optionValue: opt.value,
spawns: opt.spawnsTemplates.map((s) => s.title),
children: optChildren.length > 0 ? optChildren : undefined,
});
}
}
// Direct children (non-option elements like TEXT with IS_NOT_EMPTY triggers)
for (const child of node.children) {
children.push(convertTreeNode(child, depth + 1));
}
// Direct spawns on the element itself
for (const spawn of node.spawnsTemplates) {
const templateRoots = buildTemplateTree(spawn.templateRef);
const templateChildren: D3Node[] = [];
for (const tr of templateRoots) {
templateChildren.push(convertTreeNode(tr, depth + 2));
}
children.push({
id: nextId(),
name: spawn.title,
reference: spawn.templateRef,
type: "TEMPLATE",
nodeKind: "template",
children: templateChildren.length > 0 ? templateChildren : undefined,
});
}
visited.delete(nodeKey);
return {
id: nextId(),
name: node.title || node.reference,
reference: node.reference,
type: node.type,
nodeKind: "element",
sectionTitle: node.sectionTitle,
sensitivity: node.employeeDataCategory,
isClonable: node.isClonable,
children: children.length > 0 ? children : undefined,
};
}
const rootChildren = roots.map((r) => convertTreeNode(r, 0));
return {
id: "d3_root",
name: "Form Flow",
reference: "root",
type: "ROOT",
nodeKind: "element",
children: rootChildren,
};
}
// Generate embedded JavaScript for D3 mind-map visualization
function generateD3Script(): string {
return `
var mmInitialized = false;
function initMindMap() {
if (mmInitialized) return;
mmInitialized = true;
var data = mmData;
var container = document.getElementById('mindMapContainer');
var width = container.clientWidth;
var height = container.clientHeight;
// Color map for node types
var typeColors = {
ROOT: '#64748b',
RADIOBUTTON: '#4f7df5',
CHECKBOX: '#8b6cf5',
SELECT: '#1ba8b8',
TEXTAREA: '#3dba7a',
TEXTFIELD: '#3dba7a',
DATE: '#e68a3a',
TABLE: '#1ba8b8',
RICH_TEXT: '#10a37f',
SWITCH: '#8b6cf5',
TEMPLATE: '#d4930d',
UNKNOWN: '#94a3b8'
};
function getColor(d) {
return typeColors[d.data.type] || '#94a3b8';
}
var svg = d3.select('#mindMapSvg')
.attr('width', width)
.attr('height', height);
var g = svg.append('g');
// Zoom behavior
var zoom = d3.zoom()
.scaleExtent([0.15, 3])
.on('zoom', function(event) {
g.attr('transform', event.transform);
});
svg.call(zoom)
.on('dblclick.zoom', null);
svg.style('cursor', 'grab')
.on('mousedown.cursor', function() { svg.style('cursor', 'grabbing'); })
.on('mouseup.cursor', function() { svg.style('cursor', 'grab'); })
.on('mouseleave.cursor', function() { svg.style('cursor', 'grab'); });
// Build hierarchy
var root = d3.hierarchy(data);
// Collapse all initially except first two levels
root.each(function(d) {
if (d.depth >= 2 && d.children) {
d._children = d.children;
d.children = null;
}
});
var nodeHeight = 42;
var nodeWidth = 280;
var treeLayout = d3.tree().nodeSize([nodeHeight, nodeWidth]);
// Tooltip
var tooltip = d3.select('#mmTooltip');
// Links group and nodes group
var linkGroup = g.append('g').attr('class', 'mm-links');
var nodeGroup = g.append('g').attr('class', 'mm-nodes');
function update(source) {
treeLayout(root);
var nodes = root.descendants();
var links = root.links();
var duration = 300;
// — LINKS —
var link = linkGroup.selectAll('.mm-link')
.data(links, function(d) { return d.target.data.id; });
var linkEnter = link.enter().append('path')
.attr('class', 'mm-link')
.attr('d', function() {
var o = {x: source.x0 || source.x, y: source.y0 || source.y};
return diagonal({source: o, target: o});
});
var linkUpdate = linkEnter.merge(link);
linkUpdate.transition().duration(duration)
.attr('d', diagonal)
.attr('stroke', function(d) { return getColor(d.target); })
.attr('stroke-opacity', 0.25)
.attr('fill', 'none')
.attr('stroke-width', 1.5);
link.exit().transition().duration(duration)
.attr('d', function() {
var o = {x: source.x, y: source.y};
return diagonal({source: o, target: o});
})
.remove();
// — NODES —
var node = nodeGroup.selectAll('.mm-node')
.data(nodes, function(d) { return d.data.id; });
var nodeEnter = node.enter().append('g')
.attr('class', 'mm-node')
.attr('transform', function() {
var x0 = source.x0 !== undefined ? source.x0 : source.x;
var y0 = source.y0 !== undefined ? source.y0 : source.y;
return 'translate(' + y0 + ',' + x0 + ')';
})
.style('cursor', 'pointer')
.on('click', function(event, d) {
event.stopPropagation();
if (d.children) {
// Collapse: hide children
d._children = d.children;
d.children = null;
} else if (d._children) {
// Expand: show direct children only
d.children = d._children;
d._children = null;
// Collapse grandchildren so only the next level is revealed
d.children.forEach(function(child) {
if (child.children) {
child._children = child.children;
child.children = null;
}
});
}
update(d);
})
.on('mouseover', function(event, d) {
var lines = ['<strong>' + escHtml(d.data.name) + '</strong>'];
if (d.data.reference && d.data.reference !== 'root') lines.push('<code>' + escHtml(d.data.reference) + '</code>');
lines.push('Type: ' + d.data.type);
if (d.data.nodeKind === 'option' && d.data.optionValue) lines.push('Value: ' + escHtml(d.data.optionValue));
if (d.data.sectionTitle) lines.push('Section: ' + escHtml(d.data.sectionTitle));
if (d.data.sensitivity) lines.push('Sensitivity: ' + d.data.sensitivity);
if (d.data.spawns && d.data.spawns.length) lines.push('Spawns: ' + d.data.spawns.map(escHtml).join(', '));
tooltip.html(lines.join('<br>'))
.style('display', 'block')
.style('left', (event.pageX + 12) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mousemove', function(event) {
tooltip.style('left', (event.pageX + 12) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mouseout', function() {
tooltip.style('display', 'none');
});
// Node rect
nodeEnter.append('rect')
.attr('class', 'mm-node-rect')
.attr('rx', 6).attr('ry', 6)
.attr('x', -6)
.attr('y', -14)
.attr('width', function(d) { return d.data.nodeKind === 'option' ? 180 : 200; })
.attr('height', 28)
.attr('fill', function(d) {
if (d.data.nodeKind === 'template') return '#fef8e7';
if (d.data.nodeKind === 'option') return '#f1f5f9';
return '#ffffff';
})
.attr('stroke', function(d) { return getColor(d); })
.attr('stroke-width', function(d) { return d.data.nodeKind === 'template' ? 1.5 : 1; });
// Sensitivity stripe
nodeEnter.filter(function(d) { return d.data.sensitivity === 'SENSITIVE' || d.data.sensitivity === 'REVIEW_REQUIRED'; })
.append('rect')
.attr('x', -6).attr('y', -14)
.attr('width', 3).attr('height', 28)
.attr('rx', 1)
.attr('fill', function(d) { return d.data.sensitivity === 'SENSITIVE' ? '#e05252' : '#d4930d'; });
// Template badge
nodeEnter.filter(function(d) { return d.data.nodeKind === 'template'; })
.append('rect')
.attr('x', 0).attr('y', -12)
.attr('width', 52).attr('height', 12)
.attr('rx', 6)
.attr('fill', '#d4930d');
nodeEnter.filter(function(d) { return d.data.nodeKind === 'template'; })
.append('text')
.attr('x', 4).attr('y', -3)
.attr('font-size', '7px')
.attr('fill', '#fff')
.attr('font-weight', '700')
.text('SPAWNED');
// Node title
nodeEnter.append('text')
.attr('class', 'mm-node-title')
.attr('x', function(d) { return d.data.nodeKind === 'template' ? 56 : 2; })
.attr('y', function(d) { return d.data.nodeKind === 'template' ? -3 : 0; })
.attr('font-size', function(d) { return d.data.nodeKind === 'option' ? '10px' : '11px'; })
.attr('font-weight', function(d) { return d.data.nodeKind === 'option' ? '600' : '500'; })
.attr('fill', function(d) {
if (d.data.nodeKind === 'template') return '#6b4f10';
if (d.data.nodeKind === 'option') return '#334155';
return '#1a2332';
})
.text(function(d) {
var name = d.data.name || '';
var maxLen = d.data.nodeKind === 'option' ? 22 : 24;
return name.length > maxLen ? name.slice(0, maxLen) + '...' : name;
});
// Type badge for element nodes (rect + text, positioned after render via getBBox)
nodeEnter.filter(function(d) { return d.data.nodeKind === 'element' && d.data.type !== 'ROOT'; })
.append('rect')
.attr('class', 'mm-badge-rect')
.attr('y', -10)
.attr('height', 14)
.attr('rx', 7)
.attr('fill', function(d) {
var c = getColor(d);
return c + '18';
})
.attr('stroke', function(d) { return getColor(d); })
.attr('stroke-width', 0.5);
nodeEnter.filter(function(d) { return d.data.nodeKind === 'element' && d.data.type !== 'ROOT'; })
.append('text')
.attr('class', 'mm-badge-text')
.attr('y', -1)
.attr('font-size', '7px')
.attr('font-weight', '600')
.attr('fill', function(d) { return getColor(d); })
.text(function(d) { return d.data.type; });
// Position badges and resize node rects based on actual rendered text widths
nodeEnter.each(function(d) {
var group = d3.select(this);
var titleEl = group.select('.mm-node-title').node();
var badgeRect = group.select('.mm-badge-rect');
var badgeText = group.select('.mm-badge-text');
var nodeRect = group.select('.mm-node-rect');
if (titleEl && !badgeRect.empty()) {
var titleBox = titleEl.getBBox();
var badgeTextNode = badgeText.node();
var badgeTextWidth = badgeTextNode ? badgeTextNode.getBBox().width : 30;
var gap = 8;
var badgePadX = 5;
var badgeX = titleBox.x + titleBox.width + gap;
var badgeW = badgeTextWidth + badgePadX * 2;
badgeRect.attr('x', badgeX).attr('width', badgeW);
badgeText.attr('x', badgeX + badgePadX);
// Resize node rect to fit title + badge + padding
var totalW = badgeX + badgeW + 6 - (-6); // from rect x=-6
var minW = d.data.nodeKind === 'option' ? 180 : 200;
nodeRect.attr('width', Math.max(totalW, minW));
} else if (titleEl) {
// No badge (ROOT node) — size to title
var tb = titleEl.getBBox();
var w = tb.x + tb.width + 12 - (-6);
var mw = d.data.nodeKind === 'option' ? 180 : 200;
nodeRect.attr('width', Math.max(w, mw));
}
});
// Option value text
nodeEnter.filter(function(d) { return d.data.nodeKind === 'option' && d.data.optionValue; })
.append('text')
.attr('x', 2).attr('y', 10)
.attr('font-size', '8px')
.attr('fill', '#94a3b8')
.text(function(d) {
var v = d.data.optionValue || '';
return v.length > 28 ? v.slice(0, 28) + '...' : v;
});
// Collapsed indicator (small circle on right edge, positioned from node rect width)
nodeEnter.append('circle')
.attr('class', 'mm-collapse-indicator')
.attr('cy', 0)
.attr('r', 4)
.attr('fill', function(d) { return getColor(d); })
.attr('opacity', function(d) { return d._children ? 0.6 : 0; });
// Position collapse indicators at right edge of their node rect
nodeEnter.each(function() {
var group = d3.select(this);
var nodeRect = group.select('.mm-node-rect').node();
var indicator = group.select('.mm-collapse-indicator');
if (nodeRect && !indicator.empty()) {
var rectX = parseFloat(nodeRect.getAttribute('x')) || -6;
var rectW = parseFloat(nodeRect.getAttribute('width')) || 200;
indicator.attr('cx', rectX + rectW + 2);
}
});
// Update
var nodeUpdate = nodeEnter.merge(node);
nodeUpdate.transition().duration(duration)
.attr('transform', function(d) { return 'translate(' + d.y + ',' + d.x + ')'; });
// Update collapsed indicator
nodeUpdate.select('.mm-collapse-indicator')
.attr('opacity', function(d) { return d._children ? 0.6 : 0; });
// Exit
node.exit().transition().duration(duration)
.attr('transform', function() {
return 'translate(' + source.y + ',' + source.x + ')';
})
.remove();
// Save positions for transitions
nodes.forEach(function(d) {
d.x0 = d.x;
d.y0 = d.y;
});
}
function diagonal(d) {
return d3.linkHorizontal()
.x(function(p) { return p.y; })
.y(function(p) { return p.x; })
(d);
}
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Initial render
root.x0 = 0;
root.y0 = 0;
update(root);
// Fit to screen
function fitToScreen() {
var nodes = root.descendants();
if (nodes.length === 0) return;
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
nodes.forEach(function(d) {
if (d.x < minX) minX = d.x;
if (d.x > maxX) maxX = d.x;
if (d.y < minY) minY = d.y;
if (d.y > maxY) maxY = d.y;
});
var padding = 60;
var treeWidth = (maxY - minY) + 250;
var treeHeight = (maxX - minX) + 60;
var scaleX = (width - padding * 2) / treeWidth;
var scaleY = (height - padding * 2) / treeHeight;
var scale = Math.min(scaleX, scaleY, 1.2);
scale = Math.max(0.15, Math.min(scale, 3));
var centerX = (minY + maxY) / 2;
var centerY = (minX + maxX) / 2;
var tx = width / 2 - centerX * scale;
var ty = height / 2 - centerY * scale;
svg.transition().duration(500)
.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
fitToScreen();
// Expose controls
window.mmFit = fitToScreen;
window.mmExpandAll = function() {
root.each(function(d) {
if (d._children) {
d.children = d._children;
d._children = null;
}
});
update(root);
setTimeout(fitToScreen, 350);
};
window.mmCollapseAll = function() {
root.each(function(d) {
if (d.depth >= 1 && d.children) {
d._children = d.children;
d.children = null;
}
});
update(root);
setTimeout(fitToScreen, 350);
};
window.mmExpandToDepth = function(maxDepth) {
root.each(function(d) {
if (d.depth < maxDepth) {
if (d._children) {
d.children = d._children;
d._children = null;
}
} else {
if (d.children) {
d._children = d.children;
d.children = null;
}
}
});
update(root);
setTimeout(fitToScreen, 350);
// Update active button
document.querySelectorAll('.mm-depth-btn').forEach(function(btn) {
btn.classList.toggle('active', parseInt(btn.getAttribute('data-depth')) === maxDepth);
});
};
window.mmSearch = function(query) {
var q = query.toLowerCase().trim();
// Reset highlights
nodeGroup.selectAll('.mm-node-rect')
.attr('stroke-width', function(d) { return d.data.nodeKind === 'template' ? 1.5 : 1; })
.attr('filter', null);
if (!q) return;
// Find matching nodes in full tree
var matches = [];
root.each(function(d) {
var name = (d.data.name || '').toLowerCase();
var ref = (d.data.reference || '').toLowerCase();
if (name.indexOf(q) !== -1 || ref.indexOf(q) !== -1) {
matches.push(d);
}
});
// Also check collapsed nodes
function searchCollapsed(d) {
if (d._children) {
d._children.forEach(function(child) {
var name = (child.data.name || '').toLowerCase();
var ref = (child.data.reference || '').toLowerCase();
if (name.indexOf(q) !== -1 || ref.indexOf(q) !== -1) {
matches.push(child);
// Expand ancestors
var ancestor = child;
while (ancestor.parent) {
ancestor = ancestor.parent;
if (ancestor._children) {
ancestor.children = ancestor._children;
ancestor._children = null;
}
}
// Expand the child's parent chain
var p = d;
if (p._children) {
p.children = p._children;
p._children = null;
}
}
searchCollapsed(child);
});
}
}
root.each(searchCollapsed);
// Expand ancestors of matches
matches.forEach(function(d) {
var p = d.parent;
while (p) {
if (p._children) {
p.children = p._children;
p._children = null;
}
p = p.parent;
}
});
update(root);
// Highlight matches
setTimeout(function() {
var matchIds = new Set(matches.map(function(d) { return d.data.id; }));
nodeGroup.selectAll('.mm-node').each(function(d) {
if (matchIds.has(d.data.id)) {
d3.select(this).select('.mm-node-rect')
.attr('stroke', '#d4930d')
.attr('stroke-width', 2.5)
.attr('filter', 'drop-shadow(0 0 4px rgba(212,147,13,0.5))');
}
});
// Pan to first match
if (matches.length > 0) {
var first = matches[0];
var scale = d3.zoomTransform(svg.node()).k || 1;
svg.transition().duration(500)
.call(zoom.transform, d3.zoomIdentity
.translate(width / 2 - first.y * scale, height / 2 - first.x * scale)
.scale(scale));
}
}, 350);
};
// Resize handler
window.addEventListener('resize', function() {
width = container.clientWidth;
height = container.clientHeight;
svg.attr('width', width).attr('height', height);
});
}
`;
}
// Generate unique ID for HTML elements
let idCounter = 0;
function generateId(): string {
return `node_${++idCounter}`;
}
// Generate HTML for a tree node
function generateNodeHtml(node: TreeNode, indent: number = 0, depth: number = 0): string {
const nodeId = generateId();
const pad = " ".repeat(indent);
const hasChildren =
node.options.some((o) => o.children.length > 0 || o.spawnsTemplates.length > 0) ||
node.children.length > 0 ||
node.spawnsTemplates.length > 0;
const typeClass = getTypeClass(node.type);
const sensitivityClass = getSensitivityClass(node.employeeDataCategory);
const clonableTag = node.isClonable ? '<span class="clonable-tag">Clonable</span>' : "";
let html = `${pad}<div class="tree-node ${typeClass} ${sensitivityClass}" data-node-id="${nodeId}" data-level="${depth}">
${pad} <div class="node-header" onclick="toggleNode('${nodeId}')">
${pad} <span class="expand-icon">${hasChildren ? "▶" : "•"}</span>
${pad} <span class="node-title">${escapeHtml(node.title || node.reference)}</span>
${pad} <span class="node-type">${node.type}</span>
${pad} <code class="node-ref">${node.reference}</code>
${pad} ${clonableTag}
${pad} </div>
`;
if (hasChildren) {
html += `${pad} <div class="node-children" id="${nodeId}_children">
`;
// Options with children
if (node.options.length > 0) {
for (const option of node.options) {
const optionId = generateId();
const optionHasContent =
option.children.length > 0 || option.spawnsTemplates.length > 0;
const optionClass = optionHasContent ? "has-content" : "no-content";
html += `${pad} <div class="option-branch ${optionClass}" data-option-id="${optionId}" data-level="${depth + 1}">
${pad} <div class="option-header" onclick="toggleOption('${optionId}')">
${pad} <span class="option-icon">${optionHasContent ? "▶" : "○"}</span>
${pad} <span class="option-label">${escapeHtml(option.label)}</span>
${pad} <span class="option-value">(${escapeHtml(option.value)})</span>
`;
// Show spawn badges
if (option.spawnsTemplates.length > 0) {
html += `${pad} <span class="spawn-badges">`;
for (const spawn of option.spawnsTemplates) {
html += `<span class="spawn-badge" title="Spawns: ${escapeHtml(spawn.title)}">&#9889; ${escapeHtml(spawn.title)}</span>`;
}
html += `</span>\n`;
}
html += `${pad} </div>
`;
if (optionHasContent) {
html += `${pad} <div class="option-children" id="${optionId}_children">
`;
for (const child of option.children) {
html += generateNodeHtml(child, indent + 4, depth + 2);
}
// Show spawned template contents
for (const spawn of option.spawnsTemplates) {
html += generateTemplateHtml(spawn.templateRef, spawn.title, indent + 4, depth + 2);
}
html += `${pad} </div>
`;
}
html += `${pad} </div>
`;
}
}
// Direct children (for non-option elements)
for (const child of node.children) {
html += generateNodeHtml(child, indent + 2, depth + 1);
}
// Direct spawns
for (const spawn of node.spawnsTemplates) {
html += generateTemplateHtml(spawn.templateRef, spawn.title, indent + 2, depth + 1);
}
html += `${pad} </div>
`;
}
html += `${pad}</div>
`;
return html;
}
// Generate HTML for a spawned template section
function generateTemplateHtml(
templateRef: string,
title: string,
indentLevel: number,
depth: number = 0
): string {
const templateId = generateId();
const pad = " ".repeat(indentLevel);
const templateNodes = buildTemplateTree(templateRef);
let html = `${pad}<div class="template-section" data-template-id="${templateId}" data-level="${depth}">
${pad} <div class="template-header" onclick="toggleTemplate('${templateId}')">
${pad} <span class="template-icon">&#9654;</span>
${pad} <span class="template-badge">SPAWNED SECTION</span>
${pad} <span class="template-title">${escapeHtml(title)}</span>
${pad} <code class="template-ref">${escapeHtml(templateRef)}</code>
${pad} </div>
${pad} <div class="template-children" id="${templateId}_children">
`;
for (const node of templateNodes) {
html += generateNodeHtml(node, indentLevel + 2, depth + 1);
}
html += `${pad} </div>
${pad}</div>
`;
return html;
}
function getTypeClass(type: string): string {
const typeClasses: Record<string, string> = {
RADIOBUTTON: "type-radio",
CHECKBOX: "type-checkbox",
SELECT: "type-select",
TEXTAREA: "type-text",
TEXTFIELD: "type-text",
DATE: "type-date",
TABLE: "type-table",
RICH_TEXT: "type-richtext",
SWITCH: "type-switch",
};
return typeClasses[type] || "type-default";
}
function getSensitivityClass(category?: string): string {
if (category === "SENSITIVE") return "sensitivity-high";
if (category === "REVIEW_REQUIRED") return "sensitivity-medium";
return "";
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Generate the full HTML document
function generateHtml(treeRoots: TreeNode[]): string {
const timestamp = new Date().toISOString().slice(0, 10);
// Reset ID counter for fresh generation
idCounter = 0;
// Generate tree HTML (indent=2 for HTML formatting, depth=0 for logical tree depth)
let treeHtml = "";
for (const root of treeRoots) {
treeHtml += generateNodeHtml(root, 2, 0);
}
// Generate D3 hierarchy data
const d3Hierarchy = convertToD3Hierarchy(treeRoots);
const d3DataJson = JSON.stringify(d3Hierarchy);
const d3Script = generateD3Script();
// Count stats
const totalElements = allElements.size;
const totalTemplates = templateInfos.size;
const totalTriggers = visibilityTriggers.size;
let totalSpawns = 0;
for (const elem of allElements.values()) {
totalSpawns += elem.sectionSpawnTriggers?.length || 0;
}
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Form Flow Tree</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* ═══ Reset & Base ═══ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--border: #e2e6ed;
--border-light: #eef1f5;
--text: #1a2332;
--text-muted: #7a8599;
--text-dim: #a3aebf;
--blue: #4f7df5;
--blue-soft: #eef3ff;
--purple: #8b6cf5;
--purple-soft: #f3efff;
--green: #3dba7a;
--green-soft: #eafaf2;
--orange: #e68a3a;
--orange-soft: #fef4e8;
--red: #e05252;
--red-soft: #fdeaea;
--amber: #d4930d;
--amber-soft: #fef8e7;
--cyan: #1ba8b8;
--cyan-soft: #e8f8fa;
--teal: #10a37f;
--radius: 8px;
--radius-sm: 5px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.07), 0 1px 4px rgba(0,0,0,0.04);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.09);
--transition: 150ms ease;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
/* ═══ Sticky Header ═══ */
.header {
background: linear-gradient(135deg, #1a2332 0%, #2a3a52 100%);
color: #fff;
padding: 1rem 2rem;
position: sticky;
top: 0;
z-index: 200;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header h1 {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.header-badge {
font-size: 0.7rem;
padding: 0.2rem 0.6rem;
background: rgba(255,255,255,0.15);
border-radius: 20px;
color: rgba(255,255,255,0.8);
backdrop-filter: blur(4px);
white-space: nowrap;
}
.header-stats {
display: flex;
gap: 1.5rem;
font-size: 0.8rem;
color: rgba(255,255,255,0.7);
}
.header-stats strong {
color: #fff;
font-weight: 600;
margin-right: 0.2rem;
}
/* ═══ Toolbar ═══ */
.toolbar {
position: sticky;
top: 90px;
z-index: 190;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 0.6rem 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 0.35rem;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: var(--border);
margin: 0 0.4rem;
}
.toolbar-label {
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-right: 0.2rem;
}
.btn {
padding: 0.35rem 0.75rem;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 20px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
color: var(--text);
transition: all var(--transition);
white-space: nowrap;
font-family: inherit;
}
.btn:hover {
background: var(--blue-soft);
border-color: var(--blue);
color: var(--blue);
box-shadow: var(--shadow-sm);
}
.btn.active {
background: var(--blue);
color: #fff;
border-color: var(--blue);
box-shadow: 0 2px 8px rgba(79,125,245,0.3);
}
.btn-depth {
width: 32px;
padding: 0.35rem 0;
text-align: center;
}
.search-box {
flex: 1;
min-width: 180px;
max-width: 360px;
position: relative;
}
.search-box svg {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--text-dim);
pointer-events: none;
}
.search-box input {
width: 100%;
padding: 0.4rem 2rem 0.4rem 2rem;
border: 1px solid var(--border);
border-radius: 20px;
font-size: 0.8rem;
font-family: inherit;
color: var(--text);
background: var(--surface);
transition: all var(--transition);
}
.search-box input:focus {
outline: none;
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(79,125,245,0.12);
}
.search-box input::placeholder { color: var(--text-dim); }
.search-clear {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
color: var(--text-dim);
padding: 2px 5px;
border-radius: 50%;
display: none;
}
.search-clear.visible { display: block; }
.search-clear:hover { background: var(--border-light); color: var(--text); }
/* ═══ Main Content ═══ */
.main-content {
max-width: 1200px;
margin: 1.5rem auto;
padding: 0 1.5rem 4rem;
}
.tree-card {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
padding: 1.25rem;
}
/* ═══ Tree Nodes ═══ */
.tree-node {
position: relative;
margin: 3px 0;
padding-left: 24px;
}
/* Vertical connector line */
.tree-node::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: var(--border);
opacity: 0.5;
}
.node-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.65rem;
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
flex-wrap: wrap;
}
.node-header:hover {
background: #f8fafd;
border-color: var(--border);
box-shadow: var(--shadow-sm);
transform: translateY(-0.5px);
}
.expand-icon {
font-size: 0.6rem;
color: var(--text-dim);
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition);
flex-shrink: 0;
}
.tree-node.expanded > .node-header .expand-icon {
transform: rotate(90deg);
}
.node-title {
font-weight: 500;
font-size: 0.85rem;
color: var(--text);
flex: 1;
min-width: 150px;
}
.node-type {
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
border-radius: 20px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
flex-shrink: 0;
}
.type-radio .node-type { background: var(--blue-soft); color: var(--blue); }
.type-checkbox .node-type { background: var(--purple-soft); color: var(--purple); }
.type-select .node-type { background: var(--cyan-soft); color: var(--cyan); }
.type-text .node-type { background: var(--green-soft); color: var(--green); }
.type-date .node-type { background: var(--orange-soft); color: var(--orange); }
.type-table .node-type { background: var(--cyan-soft); color: var(--cyan); }
.type-richtext .node-type { background: var(--green-soft); color: var(--teal); }
.type-switch .node-type { background: var(--purple-soft); color: var(--purple); }
.type-default .node-type { background: var(--border-light); color: var(--text-muted); }
.node-ref {
font-size: 0.68rem;
color: var(--text-dim);
background: var(--border-light);
padding: 0.12rem 0.4rem;
border-radius: var(--radius-sm);
font-family: 'SFMono-Regular', 'Cascadia Code', 'Consolas', monospace;
flex-shrink: 0;
}
.clonable-tag {
font-size: 0.6rem;
padding: 0.12rem 0.4rem;
background: var(--purple-soft);
color: var(--purple);
border-radius: 20px;
font-weight: 600;
}
/* ═══ Children containers ═══ */
.node-children, .option-children, .template-children {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 200ms ease-out, opacity 150ms ease-out;
}
.tree-node.expanded > .node-children,
.option-branch.expanded > .option-children,
.template-section.expanded > .template-children {
max-height: 100000px;
opacity: 1;
transition: max-height 400ms ease-in, opacity 200ms ease-in;
}
.node-children { padding-top: 2px; }
/* ═══ Option Branches ═══ */
.option-branch {
position: relative;
margin: 2px 0;
padding-left: 24px;
}
.option-branch::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: var(--border);
opacity: 0.5;
}
.option-header {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.55rem;
background: var(--border-light);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
flex-wrap: wrap;
}
.option-header:hover {
background: #eaecf0;
}
.option-branch.expanded > .option-header {
background: var(--green-soft);
border: 1px solid rgba(61,186,122,0.2);
}
.option-icon {
font-size: 0.55rem;
color: var(--text-dim);
width: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition);
flex-shrink: 0;
}
.option-branch.expanded > .option-header .option-icon {
transform: rotate(90deg);
}
.option-label {
font-weight: 500;
font-size: 0.82rem;
color: var(--text);
}
.option-value {
font-size: 0.72rem;
color: var(--text-dim);
}
.option-children { padding-top: 2px; }
/* ═══ Spawn Badges ═══ */
.spawn-badges { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.spawn-badge {
font-size: 0.62rem;
padding: 0.12rem 0.45rem;
background: linear-gradient(135deg, var(--amber-soft) 0%, #fef0d0 100%);
color: #8b6914;
border-radius: 20px;
font-weight: 600;
border: 1px solid rgba(212,147,13,0.25);
}
/* ═══ Template Sections ═══ */
.template-section {
margin: 4px 0 4px 24px;
background: var(--amber-soft);
border: 1px solid rgba(212,147,13,0.2);
border-left: 3px solid var(--amber);
border-radius: var(--radius);
overflow: hidden;
}
.template-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
cursor: pointer;
transition: background var(--transition);
flex-wrap: wrap;
}
.template-header:hover {
background: rgba(212,147,13,0.08);
}
.template-section.expanded > .template-header {
border-bottom: 1px solid rgba(212,147,13,0.15);
}
.template-icon {
font-size: 0.6rem;
color: #8b6914;
transition: transform var(--transition);
width: 14px;
display: flex;
align-items: center;
}
.template-section.expanded > .template-header .template-icon {
transform: rotate(90deg);
}
.template-badge {
font-size: 0.58rem;
padding: 0.12rem 0.45rem;
background: var(--amber);
color: #fff;
border-radius: 20px;
font-weight: 700;
letter-spacing: 0.04em;
}
.template-title {
font-weight: 600;
font-size: 0.85rem;
color: #6b4f10;
}
.template-ref {
font-size: 0.68rem;
color: #9a7730;
background: rgba(255,255,255,0.6);
padding: 0.12rem 0.4rem;
border-radius: var(--radius-sm);
font-family: 'SFMono-Regular', 'Cascadia Code', 'Consolas', monospace;
}
.template-children {
padding: 0.5rem;
background: rgba(255,255,255,0.5);
}
/* ═══ Search ═══ */
.highlight {
background: #fef08a;
padding: 0 2px;
border-radius: 2px;
}
.search-match > .node-header {
outline: 2px solid var(--amber);
outline-offset: 1px;
background: #fffdf0;
}
.search-hidden { display: none !important; }
/* ═══ Legend ═══ */
.legend {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.5rem 2rem;
background: var(--surface);
border-bottom: 1px solid var(--border-light);
font-size: 0.7rem;
align-items: center;
}
.legend-label {
font-weight: 600;
color: var(--text-muted);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-right: 0.25rem;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.45rem;
border-radius: 20px;
font-weight: 500;
font-size: 0.68rem;
}
.legend-item.l-radio { background: var(--blue-soft); color: var(--blue); }
.legend-item.l-checkbox { background: var(--purple-soft); color: var(--purple); }
.legend-item.l-select { background: var(--cyan-soft); color: var(--cyan); }
.legend-item.l-text { background: var(--green-soft); color: var(--green); }
.legend-item.l-date { background: var(--orange-soft); color: var(--orange); }
.legend-item.l-table { background: var(--cyan-soft); color: var(--cyan); }
.legend-item.l-sens { background: var(--red-soft); color: var(--red); border: 1px solid rgba(224,82,82,0.2); }
.legend-item.l-review { background: var(--amber-soft); color: var(--amber); border: 1px solid rgba(212,147,13,0.2); }
/* ═══ Tab Bar ═══ */
.tab-bar {
position: sticky;
top: 52px;
z-index: 195;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
gap: 0;
padding: 0 2rem;
}
.tab-btn {
padding: 0.6rem 1.25rem;
border: none;
background: none;
cursor: pointer;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: all var(--transition);
font-family: inherit;
}
.tab-btn:hover {
color: var(--text);
background: var(--border-light);
}
.tab-btn.active {
color: var(--blue);
border-bottom-color: var(--blue);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* ═══ Mind Map ═══ */
#mindMapPanel {
position: relative;
}
#mindMapContainer {
width: 100%;
height: calc(100vh - 120px);
overflow: hidden;
background: var(--bg);
}
#mindMapSvg {
display: block;
}
#mmTooltip {
display: none;
position: absolute;
z-index: 300;
background: rgba(26,35,50,0.95);
color: #fff;
padding: 0.55rem 0.75rem;
border-radius: var(--radius);
font-size: 0.75rem;
line-height: 1.5;
max-width: 320px;
pointer-events: none;
box-shadow: var(--shadow-lg);
}
#mmTooltip code {
background: rgba(255,255,255,0.15);
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.7rem;
}
.mm-controls {
position: absolute;
top: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 250;
}
.mm-controls-row {
display: flex;
gap: 4px;
}
.mm-ctrl-btn {
padding: 0.35rem 0.65rem;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 20px;
cursor: pointer;
font-size: 0.72rem;
font-weight: 500;
color: var(--text);
transition: all var(--transition);
font-family: inherit;
box-shadow: var(--shadow-sm);
}
.mm-ctrl-btn:hover {
background: var(--blue-soft);
border-color: var(--blue);
color: var(--blue);
}
.mm-ctrl-btn.active {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.mm-depth-btn {
width: 28px;
text-align: center;
padding: 0.35rem 0;
}
.mm-search-box {
position: absolute;
top: 12px;
left: 12px;
z-index: 250;
width: 260px;
}
.mm-search-box input {
width: 100%;
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 20px;
font-size: 0.8rem;
font-family: inherit;
color: var(--text);
background: var(--surface);
box-shadow: var(--shadow-sm);
transition: all var(--transition);
}
.mm-search-box input:focus {
outline: none;
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(79,125,245,0.12);
}
/* ═══ Print ═══ */
@media print {
.header, .toolbar, .legend, .tab-bar { position: relative; }
.node-children, .option-children, .template-children { max-height: none !important; opacity: 1 !important; }
.expand-icon, .option-icon, .template-icon { display: none; }
}
/* ═══ Responsive ═══ */
@media (max-width: 768px) {
.header { padding: 0.75rem 1rem; }
.toolbar { padding: 0.5rem 1rem; top: 82px; }
.tab-bar { top: 44px; padding: 0 1rem; }
.main-content { padding: 0 0.75rem 3rem; }
.legend { padding: 0.5rem 1rem; }
.node-title { min-width: auto; }
.header-stats { display: none; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>Form Flow Tree</h1>
<span class="header-badge">${timestamp}</span>
</div>
<div class="header-stats">
<span><strong>${totalElements}</strong> Elements</span>
<span><strong>${totalTemplates}</strong> Templates</span>
<span><strong>${totalTriggers}</strong> Controllers</span>
<span><strong>${totalSpawns}</strong> Spawns</span>
</div>
</div>
<div class="tab-bar">
<button class="tab-btn active" data-tab="treeViewPanel" onclick="switchTab('treeViewPanel')">Tree View</button>
<button class="tab-btn" data-tab="mindMapPanel" onclick="switchTab('mindMapPanel')">Mind Map</button>
</div>
<!-- Tree View Panel -->
<div id="treeViewPanel" class="tab-panel active">
<div class="toolbar">
<div class="toolbar-group">
<button class="btn" onclick="expandAll()">Expand All</button>
<button class="btn" onclick="collapseAll()">Collapse All</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<span class="toolbar-label">Depth</span>
<button class="btn btn-depth active" data-depth="1" onclick="expandLevel(1)">1</button>
<button class="btn btn-depth" data-depth="2" onclick="expandLevel(2)">2</button>
<button class="btn btn-depth" data-depth="3" onclick="expandLevel(3)">3</button>
<button class="btn btn-depth" data-depth="4" onclick="expandLevel(4)">4</button>
</div>
<div class="toolbar-divider"></div>
<div class="search-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd"/></svg>
<input type="text" id="searchInput" placeholder="Search by title or reference..." oninput="searchTree(this.value)">
<button class="search-clear" id="searchClear" onclick="clearSearch()">&#10005;</button>
</div>
</div>
<div class="legend">
<span class="legend-label">Types</span>
<span class="legend-item l-radio">Radio</span>
<span class="legend-item l-checkbox">Checkbox</span>
<span class="legend-item l-select">Select</span>
<span class="legend-item l-text">Text</span>
<span class="legend-item l-date">Date</span>
<span class="legend-item l-table">Table</span>
<span class="legend-label" style="margin-left: 0.75rem;">Sensitivity</span>
<span class="legend-item l-sens">Sensitive</span>
<span class="legend-item l-review">Review Required</span>
</div>
<div class="main-content">
<div class="tree-card" id="treeContainer">
${treeHtml}
</div>
</div>
</div>
<!-- Mind Map Panel -->
<div id="mindMapPanel" class="tab-panel">
<div id="mindMapContainer">
<svg id="mindMapSvg"></svg>
</div>
<div id="mmTooltip"></div>
<div class="mm-search-box">
<input type="text" id="mmSearchInput" placeholder="Search mind map..." oninput="if(window.mmSearch) window.mmSearch(this.value)">
</div>
<div class="mm-controls">
<div class="mm-controls-row">
<button class="mm-ctrl-btn" onclick="if(window.mmFit) window.mmFit()">Fit</button>
<button class="mm-ctrl-btn" onclick="if(window.mmExpandAll) window.mmExpandAll()">Expand All</button>
<button class="mm-ctrl-btn" onclick="if(window.mmCollapseAll) window.mmCollapseAll()">Collapse All</button>
</div>
<div class="mm-controls-row">
<span style="font-size:0.7rem;color:var(--text-muted);padding:0.35rem 0.3rem;font-weight:500;">Depth</span>
<button class="mm-ctrl-btn mm-depth-btn" data-depth="1" onclick="if(window.mmExpandToDepth) window.mmExpandToDepth(1)">1</button>
<button class="mm-ctrl-btn mm-depth-btn" data-depth="2" onclick="if(window.mmExpandToDepth) window.mmExpandToDepth(2)">2</button>
<button class="mm-ctrl-btn mm-depth-btn" data-depth="3" onclick="if(window.mmExpandToDepth) window.mmExpandToDepth(3)">3</button>
<button class="mm-ctrl-btn mm-depth-btn" data-depth="4" onclick="if(window.mmExpandToDepth) window.mmExpandToDepth(4)">4</button>
</div>
</div>
</div>
<script>
// Mind map data
var mmData = ${d3DataJson};
// Tab switching
function switchTab(tabId) {
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.getElementById(tabId).classList.add('active');
document.querySelector('.tab-btn[data-tab="' + tabId + '"]').classList.add('active');
if (tabId === 'mindMapPanel') {
initMindMap();
}
}
${d3Script}
// Toggle node expansion
function toggleNode(nodeId) {
const node = document.querySelector('[data-node-id="' + nodeId + '"]');
if (!node) return;
const children = document.getElementById(nodeId + '_children');
if (!children) return;
node.classList.toggle('expanded');
updateDepthButtons();
}
// Toggle option expansion
function toggleOption(optionId) {
const option = document.querySelector('[data-option-id="' + optionId + '"]');
if (!option) return;
const children = document.getElementById(optionId + '_children');
if (!children) return;
option.classList.toggle('expanded');
updateDepthButtons();
}
// Toggle template expansion
function toggleTemplate(templateId) {
const template = document.querySelector('[data-template-id="' + templateId + '"]');
if (!template) return;
const children = document.getElementById(templateId + '_children');
if (!children) return;
template.classList.toggle('expanded');
updateDepthButtons();
}
// Expand all nodes
function expandAll() {
document.querySelectorAll('.tree-node').forEach(function(n) { n.classList.add('expanded'); });
document.querySelectorAll('.option-branch').forEach(function(n) { n.classList.add('expanded'); });
document.querySelectorAll('.template-section').forEach(function(n) { n.classList.add('expanded'); });
clearDepthButtons();
}
// Collapse all nodes
function collapseAll() {
document.querySelectorAll('.tree-node').forEach(function(n) { n.classList.remove('expanded'); });
document.querySelectorAll('.option-branch').forEach(function(n) { n.classList.remove('expanded'); });
document.querySelectorAll('.template-section').forEach(function(n) { n.classList.remove('expanded'); });
clearDepthButtons();
}
// Expand to specific level using data-level attribute
function expandLevel(maxLevel) {
collapseAll();
// Expand tree nodes at levels below maxLevel
document.querySelectorAll('.tree-node[data-level]').forEach(function(node) {
var lvl = parseInt(node.getAttribute('data-level'), 10);
if (lvl < maxLevel) {
node.classList.add('expanded');
}
});
// Expand option branches at levels below maxLevel
document.querySelectorAll('.option-branch[data-level]').forEach(function(opt) {
var lvl = parseInt(opt.getAttribute('data-level'), 10);
if (lvl < maxLevel && opt.classList.contains('has-content')) {
opt.classList.add('expanded');
}
});
// Expand templates at levels below maxLevel
document.querySelectorAll('.template-section[data-level]').forEach(function(tpl) {
var lvl = parseInt(tpl.getAttribute('data-level'), 10);
if (lvl < maxLevel) {
tpl.classList.add('expanded');
}
});
// Update depth button active states
document.querySelectorAll('.btn-depth').forEach(function(btn) {
btn.classList.toggle('active', parseInt(btn.getAttribute('data-depth'), 10) === maxLevel);
});
}
function clearDepthButtons() {
document.querySelectorAll('.btn-depth').forEach(function(btn) { btn.classList.remove('active'); });
}
function updateDepthButtons() {
clearDepthButtons();
}
// Search functionality
function searchTree(query) {
var searchLower = query.toLowerCase().trim();
var clearBtn = document.getElementById('searchClear');
clearBtn.classList.toggle('visible', searchLower.length > 0);
// Remove previous highlights and matches
document.querySelectorAll('.highlight').forEach(function(el) {
el.outerHTML = el.textContent;
});
document.querySelectorAll('.search-match').forEach(function(el) {
el.classList.remove('search-match');
});
if (!searchLower) return;
// Find and highlight matching nodes
document.querySelectorAll('.tree-node').forEach(function(node) {
var title = (node.querySelector('.node-title') || {}).textContent || '';
var ref = (node.querySelector('.node-ref') || {}).textContent || '';
if (title.toLowerCase().indexOf(searchLower) !== -1 || ref.toLowerCase().indexOf(searchLower) !== -1) {
node.classList.add('search-match');
var titleEl = node.querySelector('.node-title');
var refEl = node.querySelector('.node-ref');
if (titleEl && title.toLowerCase().indexOf(searchLower) !== -1) {
titleEl.innerHTML = highlightText(titleEl.textContent, query);
}
if (refEl && ref.toLowerCase().indexOf(searchLower) !== -1) {
refEl.innerHTML = highlightText(refEl.textContent, query);
}
expandParents(node);
}
});
}
function highlightText(text, query) {
var regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
return text.replace(regex, '<span class="highlight">$1</span>');
}
function escapeRegex(string) {
return string.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
}
function expandParents(element) {
var parent = element.parentElement;
while (parent && parent.id !== 'treeContainer') {
if (parent.classList.contains('node-children')) {
var nodeId = parent.id.replace('_children', '');
var node = document.querySelector('[data-node-id="' + nodeId + '"]');
if (node) node.classList.add('expanded');
}
if (parent.classList.contains('option-children')) {
var optionId = parent.id.replace('_children', '');
var option = document.querySelector('[data-option-id="' + optionId + '"]');
if (option) option.classList.add('expanded');
}
if (parent.classList.contains('template-children')) {
var templateId = parent.id.replace('_children', '');
var template = document.querySelector('[data-template-id="' + templateId + '"]');
if (template) template.classList.add('expanded');
}
parent = parent.parentElement;
}
}
function clearSearch() {
document.getElementById('searchInput').value = '';
searchTree('');
}
// Initialize with Level 1 expanded
document.addEventListener('DOMContentLoaded', function() {
expandLevel(1);
});
</script>
</body>
</html>`;
}
// Main execution
function main(): void {
console.log("Analyzing YAML template files...");
if (!fs.existsSync(TEMPLATE_DIR)) {
console.error(`Template directory not found: ${TEMPLATE_DIR}`);
process.exit(1);
}
loadAllTemplates();
console.log(` Found ${allElements.size} form elements`);
console.log(` Found ${templateInfos.size} template sections`);
console.log(` Found ${visibilityTriggers.size} visibility controllers`);
console.log("\nBuilding interactive tree...");
const treeRoots = buildTree();
console.log(` Built tree with ${treeRoots.length} root elements`);
console.log("\nGenerating HTML...");
const html = generateHtml(treeRoots);
const outputDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(OUTPUT_FILE, html);
console.log(`Generated: ${OUTPUT_FILE}`);
console.log("\nSummary:");
console.log(` Elements: ${allElements.size}`);
console.log(` Templates: ${templateInfos.size}`);
console.log(` Root nodes: ${treeRoots.length}`);
}
main();