fix HTML formatting in timeline diagrams

This commit is contained in:
darshanr0107
2025-06-19 12:05:22 +05:30
parent 797ba43d6e
commit a867842f32
3 changed files with 170 additions and 47 deletions

View File

@@ -225,4 +225,18 @@ timeline
{}
);
});
it('13: should render markdown htmlLabels', () => {
const timelineCode = `%%{init: { 'logLevel': 'debug', 'theme': 'forest' } }%%
timeline
title Timeline of Industrial Revolution
section 17th-20th century
Industry 1.0 : Machinery, Water power, Steam <br>power
Industry 2.0 : Electricity, <strong>Internal combustion engine </strong>, Mass production
Industry 3.0 : Electronics, Computers, Automation
section 21st century
Industry 4.0 : Internet, Robotics, Internet of Things
Industry 5.0 : Artificial intelligence, Big data, 3D printing
`;
imgSnapshotTest(timelineCode);
});
});

View File

@@ -1,6 +1,90 @@
import { arc as d3arc, select } from 'd3';
import { createText } from '../../rendering-util/createText.js';
const MAX_SECTIONS = 12;
/**
* Process HTML content in node descriptions
* @param {object} textElem - The SVG element to append text to
* @param {object} node - The node object containing description and dimensions
* @param {object} conf - Configuration object
* @param {boolean} isVirtual - Whether this is for virtual height calculation
*/
const processHtmlContent = async function (textElem, node, conf, isVirtual = false) {
// Create temporary text to get initial dimensions
const tempText = textElem
.append('text')
.text(node.descr.replace(/<[^>]*>/g, ''))
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
if (!isVirtual) {
tempText.attr('visibility', 'hidden');
}
const bbox = tempText.node().getBBox();
tempText.remove();
// Create the actual HTML content
const textObj = await createText(
textElem,
node.descr,
{
useHtmlLabels: true,
width: node.width,
classes: 'timeline-node-label',
isNode: true,
},
conf
);
if (!isVirtual) {
select(textObj).attr('transform', 'translate(0, 0)');
}
// Process the foreign object
const foreignObject = textElem.select('foreignObject');
if (foreignObject.node()) {
foreignObject.attr('width', `${10 * node.width}px`).attr('height', `${10 * node.width}px`);
const div = foreignObject.select('div');
if (div.node()) {
div
.style('display', 'table-cell')
.style('white-space', 'nowrap')
.style('line-height', '1.5')
.style('max-width', node.width + 'px')
.style('text-align', 'center');
let divBBox = div.node().getBoundingClientRect();
if (divBBox.width === node.width) {
div
.style('display', 'table')
.style('white-space', 'break-spaces')
.style('width', node.width + 'px');
divBBox = div.node().getBoundingClientRect();
}
foreignObject.attr('width', node.width).attr('height', divBBox.height);
if (!isVirtual) {
foreignObject.attr('x', -node.width / 2).attr('y', 3);
div.style('width', node.width + 'px');
} else {
bbox.height = divBBox.height;
}
}
}
return bbox;
};
export const drawRect = function (elem, rectData) {
const rectElem = elem.append('rect');
rectElem.attr('x', rectData.x);
@@ -409,6 +493,9 @@ const _drawTextCandidateFunc = (function () {
.style('display', 'table-cell')
.style('text-align', 'center')
.style('vertical-align', 'middle')
.style('word-wrap', 'break-word')
.style('overflow-wrap', 'break-word')
.style('white-space', 'normal')
.text(content);
byTspan(content, body, x, y, width, height, textAttrs, conf);
@@ -493,7 +580,7 @@ function wrap(text, width) {
});
}
export const drawNode = function (elem, node, fullSection, conf) {
export const drawNode = async function (elem, node, fullSection, conf) {
const section = (fullSection % MAX_SECTIONS) - 1;
const nodeElem = elem.append('g');
node.section = section;
@@ -506,19 +593,30 @@ export const drawNode = function (elem, node, fullSection, conf) {
// Create the wrapped text element
const textElem = nodeElem.append('g');
const txt = textElem
.append('text')
.text(node.descr)
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
const bbox = txt.node().getBBox();
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
node.height = Math.max(node.height, node.maxHeight);
node.width = node.width + 2 * node.padding;
const hasHtml = /<[a-z][\S\s]*>/i.test(node.descr);
if (hasHtml) {
const bbox = await processHtmlContent(textElem, node, conf, false);
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
node.height = Math.max(node.height, node.maxHeight);
node.width = node.width + 2 * node.padding;
} else {
const txt = textElem
.append('text')
.text(node.descr)
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
const bbox = txt.node().getBBox();
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
node.height = Math.max(node.height, node.maxHeight);
node.width = node.width + 2 * node.padding;
}
textElem.attr('transform', 'translate(' + node.width / 2 + ', ' + node.padding / 2 + ')');
@@ -528,17 +626,25 @@ export const drawNode = function (elem, node, fullSection, conf) {
return node;
};
export const getVirtualNodeHeight = function (elem, node, conf) {
export const getVirtualNodeHeight = async function (elem, node, conf) {
const textElem = elem.append('g');
const txt = textElem
.append('text')
.text(node.descr)
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
const bbox = txt.node().getBBox();
const hasHtml = /<[a-z][\S\s]*>/i.test(node.descr);
let bbox;
if (hasHtml) {
bbox = await processHtmlContent(textElem, node, conf, true);
} else {
const txt = textElem
.append('text')
.text(node.descr)
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
bbox = txt.node().getBBox();
}
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
textElem.remove();
return bbox.height + fontSize * 1.1 * 0.5 + node.padding;

View File

@@ -25,7 +25,7 @@ interface TimelineTask {
score: number;
events: string[];
}
export const draw = function (text: string, id: string, version: string, diagObj: Diagram) {
export const draw = async function (text: string, id: string, version: string, diagObj: Diagram) {
//1. Fetch the configuration
const conf = getConfig();
// @ts-expect-error - wrong config?
@@ -77,7 +77,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
let hasSections = true;
//Calculate the max height of the sections
sections.forEach(function (section: string) {
for (const section of sections) {
const sectionNode: Block<string, number> = {
number: sectionNumber,
descr: section,
@@ -86,10 +86,10 @@ export const draw = function (text: string, id: string, version: string, diagObj
padding: 20,
maxHeight: maxSectionHeight,
};
const sectionHeight = svgDraw.getVirtualNodeHeight(svg, sectionNode, conf);
const sectionHeight = await svgDraw.getVirtualNodeHeight(svg, sectionNode, conf);
log.debug('sectionHeight before draw', sectionHeight);
maxSectionHeight = Math.max(maxSectionHeight, sectionHeight + 20);
});
}
//tasks length and maxEventCount
let maxEventCount = 0;
@@ -107,7 +107,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
padding: 20,
maxHeight: maxTaskHeight,
};
const taskHeight = svgDraw.getVirtualNodeHeight(svg, taskNode, conf);
const taskHeight = await svgDraw.getVirtualNodeHeight(svg, taskNode, conf);
log.debug('taskHeight before draw', taskHeight);
maxTaskHeight = Math.max(maxTaskHeight, taskHeight + 20);
@@ -124,11 +124,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
padding: 20,
maxHeight: 50,
};
maxEventLineLengthTemp += svgDraw.getVirtualNodeHeight(svg, eventNode, conf);
}
// Add spacing between events (10px per event except the last one)
if (task.events.length > 0) {
maxEventLineLengthTemp += (task.events.length - 1) * 10;
maxEventLineLengthTemp += await svgDraw.getVirtualNodeHeight(svg, eventNode, conf);
}
maxEventLineLength = Math.max(maxEventLineLength, maxEventLineLengthTemp);
}
@@ -137,7 +133,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
log.debug('maxTaskHeight before draw', maxTaskHeight);
if (sections && sections.length > 0) {
sections.forEach((section) => {
for (const section of sections) {
//filter task where tasks.section == section
const tasksForSection = tasks.filter((task) => task.section === section);
@@ -151,7 +147,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
};
log.debug('sectionNode', sectionNode);
const sectionNodeWrapper = svg.append('g');
const node = svgDraw.drawNode(sectionNodeWrapper, sectionNode, sectionNumber, conf);
const node = await svgDraw.drawNode(sectionNodeWrapper, sectionNode, sectionNumber, conf);
log.debug('sectionNode output', node);
sectionNodeWrapper.attr('transform', `translate(${masterX}, ${sectionBeginY})`);
@@ -160,7 +156,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
//draw tasks for this section
if (tasksForSection.length > 0) {
drawTasks(
await drawTasks(
svg,
tasksForSection,
sectionNumber,
@@ -179,11 +175,11 @@ export const draw = function (text: string, id: string, version: string, diagObj
masterY = sectionBeginY;
sectionNumber++;
});
}
} else {
//draw tasks
hasSections = false;
drawTasks(
await drawTasks(
svg,
tasks,
sectionNumber,
@@ -237,7 +233,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
// addSVGAccessibilityFields(diagObj.db, diagram, id);
};
export const drawTasks = function (
export const drawTasks = async function (
diagram: Selection<SVGElement, unknown, null, undefined>,
tasks: TimelineTask[],
sectionColor: number,
@@ -266,7 +262,7 @@ export const drawTasks = function (
// create task wrapper
const taskWrapper = diagram.append('g').attr('class', 'taskWrapper');
const node = svgDraw.drawNode(taskWrapper, taskNode, sectionColor, conf);
const node = await svgDraw.drawNode(taskWrapper, taskNode, sectionColor, conf);
const taskHeight = node.height;
//log task height
log.debug('taskHeight after draw', taskHeight);
@@ -283,15 +279,22 @@ export const drawTasks = function (
//add margin to task
masterY += 100;
lineLength =
lineLength + drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf);
lineLength + (await drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf));
masterY -= 100;
lineWrapper
.append('line')
.attr('x1', masterX + 190 / 2)
.attr('y1', masterY + maxTaskHeight) // Start from bottom of task box
.attr('x2', masterX + 190 / 2) // Same x coordinate for vertical line
.attr('y2', masterY + maxTaskHeight + 100 + maxEventLineLength + 100) // End at consistent depth with ample padding for visible dashed lines and arrowheads
.attr('y1', masterY + maxTaskHeight) // One section head + one task + margins
.attr('x2', masterX + 190 / 2) // Subtract stroke width so arrow point is retained
.attr(
'y2',
masterY +
maxTaskHeight +
(isWithoutSections ? maxTaskHeight : maxSectionHeight) +
maxEventLineLength +
120
)
.attr('stroke-width', 2)
.attr('stroke', 'black')
.attr('marker-end', 'url(#arrowhead)')
@@ -308,7 +311,7 @@ export const drawTasks = function (
masterY = masterY - 10;
};
export const drawEvents = function (
export const drawEvents = async function (
diagram: Selection<SVGElement, unknown, null, undefined>,
events: string[],
sectionColor: number,
@@ -335,7 +338,7 @@ export const drawEvents = function (
log.debug('eventNode', eventNode);
// create event wrapper
const eventWrapper = diagram.append('g').attr('class', 'eventWrapper');
const node = svgDraw.drawNode(eventWrapper, eventNode, sectionColor, conf);
const node = await svgDraw.drawNode(eventWrapper, eventNode, sectionColor, conf);
const eventHeight = node.height;
maxEventHeight = maxEventHeight + eventHeight;
eventWrapper.attr('transform', `translate(${masterX}, ${masterY})`);