diff --git a/cypress/integration/rendering/timeline.spec.ts b/cypress/integration/rendering/timeline.spec.ts
index 3785f5fcc..cb3ad1429 100644
--- a/cypress/integration/rendering/timeline.spec.ts
+++ b/cypress/integration/rendering/timeline.spec.ts
@@ -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
power
+ Industry 2.0 : Electricity, Internal combustion engine , 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);
+ });
});
diff --git a/packages/mermaid/src/diagrams/timeline/svgDraw.js b/packages/mermaid/src/diagrams/timeline/svgDraw.js
index 723dc46f1..ed1a7f400 100644
--- a/packages/mermaid/src/diagrams/timeline/svgDraw.js
+++ b/packages/mermaid/src/diagrams/timeline/svgDraw.js
@@ -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;
diff --git a/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts b/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts
index 7f406b589..fec244395 100644
--- a/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts
+++ b/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts
@@ -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 = {
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,
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,
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})`);