mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-30 04:39:40 +02:00
Update treemap renderer for improved padding and layout adjustments
This commit is contained in:
@@ -105,7 +105,7 @@
|
|||||||
"@types/stylis": "^4.2.7",
|
"@types/stylis": "^4.2.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "3.6.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"csstree-validator": "^4.0.1",
|
"csstree-validator": "^4.0.1",
|
||||||
"globby": "^14.0.2",
|
"globby": "^14.0.2",
|
||||||
|
@@ -5,7 +5,8 @@ import { configureSvgSize } from '../../setupGraphViewbox.js';
|
|||||||
import type { TreemapDB, TreemapNode } from './types.js';
|
import type { TreemapDB, TreemapNode } from './types.js';
|
||||||
import { scaleOrdinal, treemap, hierarchy, format, select } from 'd3';
|
import { scaleOrdinal, treemap, hierarchy, format, select } from 'd3';
|
||||||
|
|
||||||
const DEFAULT_PADDING = 1;
|
const DEFAULT_INNER_PADDING = 5; // Default for inner padding between cells/sections
|
||||||
|
const SECTION_HEADER_HEIGHT = 25;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws the treemap diagram
|
* Draws the treemap diagram
|
||||||
@@ -13,7 +14,7 @@ const DEFAULT_PADDING = 1;
|
|||||||
const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
||||||
const treemapDb = diagram.db as TreemapDB;
|
const treemapDb = diagram.db as TreemapDB;
|
||||||
const config = treemapDb.getConfig();
|
const config = treemapDb.getConfig();
|
||||||
const padding = config.padding || DEFAULT_PADDING;
|
const treemapInnerPadding = config.padding !== undefined ? config.padding : DEFAULT_INNER_PADDING;
|
||||||
const title = treemapDb.getDiagramTitle();
|
const title = treemapDb.getDiagramTitle();
|
||||||
const root = treemapDb.getRoot();
|
const root = treemapDb.getRoot();
|
||||||
|
|
||||||
@@ -22,25 +23,22 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Define dimensions
|
// Define dimensions
|
||||||
const rootHeaderHeight = 50;
|
|
||||||
const titleHeight = title ? 30 : 0;
|
const titleHeight = title ? 30 : 0;
|
||||||
const rootBorderWidth = 3;
|
|
||||||
const sectionHeaderHeight = 25;
|
|
||||||
const rootSectionGap = 15;
|
|
||||||
|
|
||||||
const svg = selectSvgElement(id);
|
const svg = selectSvgElement(id);
|
||||||
// Use config dimensions or defaults
|
// Use config dimensions or defaults
|
||||||
const width = config.nodeWidth ? config.nodeWidth * 10 : 960;
|
const width = config.nodeWidth ? config.nodeWidth * 10 : 960;
|
||||||
const height = config.nodeHeight ? config.nodeHeight * 10 : 500;
|
const height = config.nodeHeight ? config.nodeHeight * 10 : 500;
|
||||||
const svgWidth = width + 2 * rootBorderWidth;
|
|
||||||
const svgHeight = height + titleHeight + rootHeaderHeight + rootBorderWidth + rootSectionGap;
|
const svgWidth = width;
|
||||||
|
const svgHeight = height + titleHeight;
|
||||||
|
|
||||||
// Set the SVG size
|
// Set the SVG size
|
||||||
svg.attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
|
svg.attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
|
||||||
configureSvgSize(svg, svgHeight, svgWidth, config.useMaxWidth);
|
configureSvgSize(svg, svgHeight, svgWidth, config.useMaxWidth);
|
||||||
|
|
||||||
// Format for displaying values
|
// Format for displaying values
|
||||||
const valueFormat = format(',d');
|
const valueFormat = format(',');
|
||||||
|
|
||||||
// Create color scale
|
// Create color scale
|
||||||
const colorScale = scaleOrdinal<string>().range([
|
const colorScale = scaleOrdinal<string>().range([
|
||||||
@@ -68,163 +66,42 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
.text(title);
|
.text(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a root container that wraps everything
|
// Create a main container for the treemap, translated below the title
|
||||||
const rootContainer = svg.append('g').attr('transform', `translate(0, ${titleHeight})`);
|
const g = svg
|
||||||
|
|
||||||
// Create a container group for the inner treemap with additional gap for separation
|
|
||||||
const g = rootContainer
|
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('transform', `translate(${rootBorderWidth}, ${rootHeaderHeight + rootSectionGap})`)
|
.attr('transform', `translate(0, ${titleHeight})`)
|
||||||
.attr('class', 'treemapContainer');
|
.attr('class', 'treemapContainer');
|
||||||
|
|
||||||
// MULTI-PASS LAYOUT APPROACH
|
// Create the hierarchical structure
|
||||||
// Step 1: Create the hierarchical structure
|
|
||||||
const hierarchyRoot = hierarchy<TreemapNode>(root)
|
const hierarchyRoot = hierarchy<TreemapNode>(root)
|
||||||
.sum((d) => d.value || 0)
|
.sum((d) => d.value || 0)
|
||||||
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
||||||
|
|
||||||
// Step 2: Pre-process to count sections that need headers
|
// Create treemap layout
|
||||||
const branchNodes: d3.HierarchyRectangularNode<TreemapNode>[] = [];
|
|
||||||
let maxDepth = 0;
|
|
||||||
|
|
||||||
hierarchyRoot.each((node) => {
|
|
||||||
if (node.depth > maxDepth) {
|
|
||||||
maxDepth = node.depth;
|
|
||||||
}
|
|
||||||
if (node.depth > 0 && node.children && node.children.length > 0) {
|
|
||||||
branchNodes.push(node as d3.HierarchyRectangularNode<TreemapNode>);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Create the treemap layout with reduced height to account for headers
|
|
||||||
// Each first-level section gets its own header
|
|
||||||
const sectionsAtLevel1 = branchNodes.filter((n) => n.depth === 1).length;
|
|
||||||
const headerSpaceNeeded = sectionsAtLevel1 * sectionHeaderHeight;
|
|
||||||
|
|
||||||
// Create treemap layout with reduced height
|
|
||||||
const treemapLayout = treemap<TreemapNode>()
|
const treemapLayout = treemap<TreemapNode>()
|
||||||
.size([width, height - headerSpaceNeeded - rootSectionGap])
|
.size([width, height])
|
||||||
.paddingTop(0)
|
.paddingTop((d) => (d.children && d.children.length > 0 ? SECTION_HEADER_HEIGHT : 0))
|
||||||
.paddingInner(padding + 8)
|
.paddingInner(treemapInnerPadding)
|
||||||
.round(true);
|
.round(true);
|
||||||
|
|
||||||
// Apply the treemap layout to the hierarchy
|
// Apply the treemap layout to the hierarchy
|
||||||
const treemapData = treemapLayout(hierarchyRoot);
|
const treemapData = treemapLayout(hierarchyRoot);
|
||||||
|
|
||||||
// Step 4: Post-process nodes to adjust positions based on section headers
|
// Draw section nodes (branches - nodes with children)
|
||||||
// Map to track y-offset for each parent
|
const branchNodes = treemapData.descendants().filter((d) => d.children && d.children.length > 0);
|
||||||
const sectionOffsets = new Map();
|
|
||||||
|
|
||||||
// Start by adjusting top-level branches
|
|
||||||
const topLevelBranches =
|
|
||||||
treemapData.children?.filter((c) => c.children && c.children.length > 0) || [];
|
|
||||||
|
|
||||||
let currentY = 0;
|
|
||||||
topLevelBranches.forEach((branch) => {
|
|
||||||
// Record section offset
|
|
||||||
sectionOffsets.set(branch.id || branch.data.name, currentY);
|
|
||||||
|
|
||||||
// Shift the branch down to make room for header
|
|
||||||
branch.y0 += currentY;
|
|
||||||
branch.y1 += currentY;
|
|
||||||
|
|
||||||
// Update offset for next branch
|
|
||||||
currentY += sectionHeaderHeight;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then adjust all descendant nodes
|
|
||||||
treemapData.each((node) => {
|
|
||||||
if (node.depth <= 1) {
|
|
||||||
return;
|
|
||||||
} // Already handled top level
|
|
||||||
|
|
||||||
// Find all section ancestors and sum their offsets
|
|
||||||
let totalOffset = 0;
|
|
||||||
let current = node.parent;
|
|
||||||
|
|
||||||
while (current && current.depth > 0) {
|
|
||||||
const offset = sectionOffsets.get(current.id || current.data.name) || 0;
|
|
||||||
totalOffset += offset;
|
|
||||||
current = current.parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply cumulative offset
|
|
||||||
node.y0 += totalOffset;
|
|
||||||
node.y1 += totalOffset;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the root border container after all layout calculations
|
|
||||||
rootContainer
|
|
||||||
.append('rect')
|
|
||||||
.attr('x', 0)
|
|
||||||
.attr('y', 0)
|
|
||||||
.attr('width', svgWidth)
|
|
||||||
.attr('height', height + rootHeaderHeight + rootBorderWidth + rootSectionGap)
|
|
||||||
.attr('fill', 'none')
|
|
||||||
.attr('stroke', colorScale(root.name))
|
|
||||||
.attr('stroke-width', rootBorderWidth)
|
|
||||||
.attr('rx', 4)
|
|
||||||
.attr('ry', 4);
|
|
||||||
|
|
||||||
// Add root header background - with clear separation from sections
|
|
||||||
rootContainer
|
|
||||||
.append('rect')
|
|
||||||
.attr('x', 0)
|
|
||||||
.attr('y', 0)
|
|
||||||
.attr('width', svgWidth)
|
|
||||||
.attr('height', rootHeaderHeight)
|
|
||||||
.attr('fill', colorScale(root.name))
|
|
||||||
.attr('fill-opacity', 0.2)
|
|
||||||
.attr('stroke', 'none')
|
|
||||||
.attr('rx', 4)
|
|
||||||
.attr('ry', 4);
|
|
||||||
|
|
||||||
// Add root label
|
|
||||||
rootContainer
|
|
||||||
.append('text')
|
|
||||||
.attr('x', rootBorderWidth * 2)
|
|
||||||
.attr('y', rootHeaderHeight / 2)
|
|
||||||
.attr('dominant-baseline', 'middle')
|
|
||||||
.attr('font-weight', 'bold')
|
|
||||||
.attr('font-size', '18px')
|
|
||||||
.text(root.name);
|
|
||||||
|
|
||||||
// Add a visual separator line between root and sections
|
|
||||||
rootContainer
|
|
||||||
.append('line')
|
|
||||||
.attr('x1', rootBorderWidth)
|
|
||||||
.attr('y1', rootHeaderHeight + rootSectionGap / 2)
|
|
||||||
.attr('x2', svgWidth - rootBorderWidth)
|
|
||||||
.attr('y2', rootHeaderHeight + rootSectionGap / 2)
|
|
||||||
.attr('stroke', colorScale(root.name))
|
|
||||||
.attr('stroke-width', 1)
|
|
||||||
.attr('stroke-dasharray', '4,2');
|
|
||||||
|
|
||||||
// Draw section nodes (non-leaf nodes), skip the root
|
|
||||||
const sections = g
|
const sections = g
|
||||||
.selectAll('.treemapSection')
|
.selectAll('.treemapSection')
|
||||||
.data(branchNodes)
|
.data(branchNodes)
|
||||||
.enter()
|
.enter()
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('class', 'treemapSection')
|
.attr('class', 'treemapSection')
|
||||||
.attr('transform', (d) => `translate(${d.x0},${d.y0 - sectionHeaderHeight})`);
|
.attr('transform', (d) => `translate(${d.x0},${d.y0})`);
|
||||||
|
|
||||||
// Add section rectangles (full container including header)
|
|
||||||
sections
|
|
||||||
.append('rect')
|
|
||||||
.attr('width', (d) => d.x1 - d.x0)
|
|
||||||
.attr('height', (d) => d.y1 - d.y0 + sectionHeaderHeight)
|
|
||||||
.attr('class', 'treemapSectionRect')
|
|
||||||
.attr('fill', (d) => colorScale(d.data.name))
|
|
||||||
.attr('fill-opacity', 0.1)
|
|
||||||
.attr('stroke', (d) => colorScale(d.data.name))
|
|
||||||
.attr('stroke-width', 2);
|
|
||||||
|
|
||||||
// Add section header background
|
// Add section header background
|
||||||
sections
|
sections
|
||||||
.append('rect')
|
.append('rect')
|
||||||
.attr('width', (d) => d.x1 - d.x0)
|
.attr('width', (d) => d.x1 - d.x0)
|
||||||
.attr('height', sectionHeaderHeight)
|
.attr('height', SECTION_HEADER_HEIGHT)
|
||||||
.attr('class', 'treemapSectionHeader')
|
.attr('class', 'treemapSectionHeader')
|
||||||
.attr('fill', (d) => colorScale(d.data.name))
|
.attr('fill', (d) => colorScale(d.data.name))
|
||||||
.attr('fill-opacity', 0.6)
|
.attr('fill-opacity', 0.6)
|
||||||
@@ -236,22 +113,50 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
.append('text')
|
.append('text')
|
||||||
.attr('class', 'treemapSectionLabel')
|
.attr('class', 'treemapSectionLabel')
|
||||||
.attr('x', 6)
|
.attr('x', 6)
|
||||||
.attr('y', sectionHeaderHeight / 2)
|
.attr('y', SECTION_HEADER_HEIGHT / 2)
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.text((d) => d.data.name)
|
.text((d) => d.data.name)
|
||||||
.attr('font-weight', 'bold')
|
.attr('font-weight', 'bold')
|
||||||
.style('font-size', '12px')
|
.style('font-size', '12px')
|
||||||
.style('fill', '#000000')
|
.style('fill', '#000000')
|
||||||
.each(function (d) {
|
.each(function (d) {
|
||||||
// Truncate text if needed
|
const self = select(this);
|
||||||
const textWidth = this.getComputedTextLength();
|
const originalText = d.data.name;
|
||||||
const availableWidth = d.x1 - d.x0 - 20;
|
self.text(originalText);
|
||||||
if (textWidth > availableWidth) {
|
const totalHeaderWidth = d.x1 - d.x0;
|
||||||
const text = d.data.name;
|
const labelXPosition = 6;
|
||||||
let truncatedText = text;
|
let spaceForTextContent;
|
||||||
while (truncatedText.length > 3 && this.getComputedTextLength() > availableWidth) {
|
if (config.showValues !== false && d.value) {
|
||||||
truncatedText = truncatedText.slice(0, -1);
|
const valueEndsAtXRelative = totalHeaderWidth - 10;
|
||||||
select(this).text(truncatedText + '...');
|
const estimatedValueTextActualWidth = 30;
|
||||||
|
const gapBetweenLabelAndValue = 10;
|
||||||
|
const labelMustEndBeforeX =
|
||||||
|
valueEndsAtXRelative - estimatedValueTextActualWidth - gapBetweenLabelAndValue;
|
||||||
|
spaceForTextContent = labelMustEndBeforeX - labelXPosition;
|
||||||
|
} else {
|
||||||
|
const labelOwnRightPadding = 6;
|
||||||
|
spaceForTextContent = totalHeaderWidth - labelXPosition - labelOwnRightPadding;
|
||||||
|
}
|
||||||
|
const minimumWidthToDisplay = 15;
|
||||||
|
const actualAvailableWidth = Math.max(minimumWidthToDisplay, spaceForTextContent);
|
||||||
|
const textNode = self.node()!;
|
||||||
|
const currentTextContentLength = textNode.getComputedTextLength();
|
||||||
|
if (currentTextContentLength > actualAvailableWidth) {
|
||||||
|
const ellipsis = '...';
|
||||||
|
let currentTruncatedText = originalText;
|
||||||
|
while (currentTruncatedText.length > 0) {
|
||||||
|
currentTruncatedText = originalText.substring(0, currentTruncatedText.length - 1);
|
||||||
|
if (currentTruncatedText.length === 0) {
|
||||||
|
self.text(ellipsis);
|
||||||
|
if (textNode.getComputedTextLength() > actualAvailableWidth) {
|
||||||
|
self.text('');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.text(currentTruncatedText + ellipsis);
|
||||||
|
if (textNode.getComputedTextLength() <= actualAvailableWidth) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -262,7 +167,7 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
.append('text')
|
.append('text')
|
||||||
.attr('class', 'treemapSectionValue')
|
.attr('class', 'treemapSectionValue')
|
||||||
.attr('x', (d) => d.x1 - d.x0 - 10)
|
.attr('x', (d) => d.x1 - d.x0 - 10)
|
||||||
.attr('y', sectionHeaderHeight / 2)
|
.attr('y', SECTION_HEADER_HEIGHT / 2)
|
||||||
.attr('text-anchor', 'end')
|
.attr('text-anchor', 'end')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.text((d) => (d.value ? valueFormat(d.value) : ''))
|
.text((d) => (d.value ? valueFormat(d.value) : ''))
|
||||||
@@ -272,12 +177,13 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw the leaf nodes
|
// Draw the leaf nodes
|
||||||
|
const leafNodes = treemapData.leaves();
|
||||||
const cell = g
|
const cell = g
|
||||||
.selectAll('.treemapLeaf')
|
.selectAll('.treemapLeafGroup')
|
||||||
.data(treemapData.leaves())
|
.data(leafNodes)
|
||||||
.enter()
|
.enter()
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('class', 'treemapNode')
|
.attr('class', 'treemapNode treemapLeafGroup')
|
||||||
.attr('transform', (d) => `translate(${d.x0},${d.y0})`);
|
.attr('transform', (d) => `translate(${d.x0},${d.y0})`);
|
||||||
|
|
||||||
// Add rectangle for each leaf node
|
// Add rectangle for each leaf node
|
||||||
@@ -287,7 +193,6 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
.attr('height', (d) => d.y1 - d.y0)
|
.attr('height', (d) => d.y1 - d.y0)
|
||||||
.attr('class', 'treemapLeaf')
|
.attr('class', 'treemapLeaf')
|
||||||
.attr('fill', (d) => {
|
.attr('fill', (d) => {
|
||||||
// Go up to parent for color
|
|
||||||
let current = d;
|
let current = d;
|
||||||
while (current.depth > 1 && current.parent) {
|
while (current.depth > 1 && current.parent) {
|
||||||
current = current.parent;
|
current = current.parent;
|
||||||
@@ -314,11 +219,9 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
.attr('clip-path', (d, i) => `url(#clip-${id}-${i})`)
|
.attr('clip-path', (d, i) => `url(#clip-${id}-${i})`)
|
||||||
.text((d) => d.data.name);
|
.text((d) => d.data.name);
|
||||||
|
|
||||||
// Only render label if box is big enough
|
|
||||||
leafLabels.each(function (d) {
|
leafLabels.each(function (d) {
|
||||||
const nodeWidth = d.x1 - d.x0;
|
const nodeWidth = d.x1 - d.x0;
|
||||||
const nodeHeight = d.y1 - d.y0;
|
const nodeHeight = d.y1 - d.y0;
|
||||||
|
|
||||||
if (nodeWidth < 30 || nodeHeight < 20) {
|
if (nodeWidth < 30 || nodeHeight < 20) {
|
||||||
select(this).style('display', 'none');
|
select(this).style('display', 'none');
|
||||||
}
|
}
|
||||||
@@ -335,11 +238,9 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
|
|||||||
.attr('clip-path', (d, i) => `url(#clip-${id}-${i})`)
|
.attr('clip-path', (d, i) => `url(#clip-${id}-${i})`)
|
||||||
.text((d) => (d.value ? valueFormat(d.value) : ''));
|
.text((d) => (d.value ? valueFormat(d.value) : ''));
|
||||||
|
|
||||||
// Only render value if box is big enough
|
|
||||||
leafValues.each(function (d) {
|
leafValues.each(function (d) {
|
||||||
const nodeWidth = d.x1 - d.x0;
|
const nodeWidth = d.x1 - d.x0;
|
||||||
const nodeHeight = d.y1 - d.y0;
|
const nodeHeight = d.y1 - d.y0;
|
||||||
|
|
||||||
if (nodeWidth < 30 || nodeHeight < 30) {
|
if (nodeWidth < 30 || nodeHeight < 30) {
|
||||||
select(this).style('display', 'none');
|
select(this).style('display', 'none');
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user