2343 lines
76 KiB
TypeScript
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// 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)}">⚡ ${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">▶</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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
// 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()">✕</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();
|