From 3629e8e480cb94ab787660042762ee2d43d42a78 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 8 May 2025 14:20:05 +0200 Subject: [PATCH] Update treemap renderer for improved padding and layout adjustments --- packages/mermaid/package.json | 2 +- .../mermaid/src/diagrams/treemap/renderer.ts | 223 +++++------------- 2 files changed, 63 insertions(+), 162 deletions(-) diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index 7f8230229..36018cc17 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -105,7 +105,7 @@ "@types/stylis": "^4.2.7", "@types/uuid": "^10.0.0", "ajv": "^8.17.1", - "chokidar": "^4.0.3", + "chokidar": "3.6.0", "concurrently": "^9.1.2", "csstree-validator": "^4.0.1", "globby": "^14.0.2", diff --git a/packages/mermaid/src/diagrams/treemap/renderer.ts b/packages/mermaid/src/diagrams/treemap/renderer.ts index 30f540bf6..c13b1c40b 100644 --- a/packages/mermaid/src/diagrams/treemap/renderer.ts +++ b/packages/mermaid/src/diagrams/treemap/renderer.ts @@ -5,7 +5,8 @@ import { configureSvgSize } from '../../setupGraphViewbox.js'; import type { TreemapDB, TreemapNode } from './types.js'; 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 @@ -13,7 +14,7 @@ const DEFAULT_PADDING = 1; const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { const treemapDb = diagram.db as TreemapDB; 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 root = treemapDb.getRoot(); @@ -22,25 +23,22 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { } // Define dimensions - const rootHeaderHeight = 50; const titleHeight = title ? 30 : 0; - const rootBorderWidth = 3; - const sectionHeaderHeight = 25; - const rootSectionGap = 15; const svg = selectSvgElement(id); // Use config dimensions or defaults const width = config.nodeWidth ? config.nodeWidth * 10 : 960; 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 svg.attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`); configureSvgSize(svg, svgHeight, svgWidth, config.useMaxWidth); // Format for displaying values - const valueFormat = format(',d'); + const valueFormat = format(','); // Create color scale const colorScale = scaleOrdinal().range([ @@ -68,163 +66,42 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .text(title); } - // Create a root container that wraps everything - const rootContainer = svg.append('g').attr('transform', `translate(0, ${titleHeight})`); - - // Create a container group for the inner treemap with additional gap for separation - const g = rootContainer + // Create a main container for the treemap, translated below the title + const g = svg .append('g') - .attr('transform', `translate(${rootBorderWidth}, ${rootHeaderHeight + rootSectionGap})`) + .attr('transform', `translate(0, ${titleHeight})`) .attr('class', 'treemapContainer'); - // MULTI-PASS LAYOUT APPROACH - // Step 1: Create the hierarchical structure + // Create the hierarchical structure const hierarchyRoot = hierarchy(root) .sum((d) => d.value || 0) .sort((a, b) => (b.value || 0) - (a.value || 0)); - // Step 2: Pre-process to count sections that need headers - const branchNodes: d3.HierarchyRectangularNode[] = []; - 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); - } - }); - - // 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 + // Create treemap layout const treemapLayout = treemap() - .size([width, height - headerSpaceNeeded - rootSectionGap]) - .paddingTop(0) - .paddingInner(padding + 8) + .size([width, height]) + .paddingTop((d) => (d.children && d.children.length > 0 ? SECTION_HEADER_HEIGHT : 0)) + .paddingInner(treemapInnerPadding) .round(true); // Apply the treemap layout to the hierarchy const treemapData = treemapLayout(hierarchyRoot); - // Step 4: Post-process nodes to adjust positions based on section headers - // Map to track y-offset for each parent - 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 + // Draw section nodes (branches - nodes with children) + const branchNodes = treemapData.descendants().filter((d) => d.children && d.children.length > 0); const sections = g .selectAll('.treemapSection') .data(branchNodes) .enter() .append('g') .attr('class', 'treemapSection') - .attr('transform', (d) => `translate(${d.x0},${d.y0 - sectionHeaderHeight})`); - - // 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); + .attr('transform', (d) => `translate(${d.x0},${d.y0})`); // Add section header background sections .append('rect') .attr('width', (d) => d.x1 - d.x0) - .attr('height', sectionHeaderHeight) + .attr('height', SECTION_HEADER_HEIGHT) .attr('class', 'treemapSectionHeader') .attr('fill', (d) => colorScale(d.data.name)) .attr('fill-opacity', 0.6) @@ -236,22 +113,50 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .append('text') .attr('class', 'treemapSectionLabel') .attr('x', 6) - .attr('y', sectionHeaderHeight / 2) + .attr('y', SECTION_HEADER_HEIGHT / 2) .attr('dominant-baseline', 'middle') .text((d) => d.data.name) .attr('font-weight', 'bold') .style('font-size', '12px') .style('fill', '#000000') .each(function (d) { - // Truncate text if needed - const textWidth = this.getComputedTextLength(); - const availableWidth = d.x1 - d.x0 - 20; - if (textWidth > availableWidth) { - const text = d.data.name; - let truncatedText = text; - while (truncatedText.length > 3 && this.getComputedTextLength() > availableWidth) { - truncatedText = truncatedText.slice(0, -1); - select(this).text(truncatedText + '...'); + const self = select(this); + const originalText = d.data.name; + self.text(originalText); + const totalHeaderWidth = d.x1 - d.x0; + const labelXPosition = 6; + let spaceForTextContent; + if (config.showValues !== false && d.value) { + const valueEndsAtXRelative = totalHeaderWidth - 10; + 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') .attr('class', 'treemapSectionValue') .attr('x', (d) => d.x1 - d.x0 - 10) - .attr('y', sectionHeaderHeight / 2) + .attr('y', SECTION_HEADER_HEIGHT / 2) .attr('text-anchor', 'end') .attr('dominant-baseline', 'middle') .text((d) => (d.value ? valueFormat(d.value) : '')) @@ -272,12 +177,13 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { } // Draw the leaf nodes + const leafNodes = treemapData.leaves(); const cell = g - .selectAll('.treemapLeaf') - .data(treemapData.leaves()) + .selectAll('.treemapLeafGroup') + .data(leafNodes) .enter() .append('g') - .attr('class', 'treemapNode') + .attr('class', 'treemapNode treemapLeafGroup') .attr('transform', (d) => `translate(${d.x0},${d.y0})`); // 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('class', 'treemapLeaf') .attr('fill', (d) => { - // Go up to parent for color let current = d; while (current.depth > 1 && 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})`) .text((d) => d.data.name); - // Only render label if box is big enough leafLabels.each(function (d) { const nodeWidth = d.x1 - d.x0; const nodeHeight = d.y1 - d.y0; - if (nodeWidth < 30 || nodeHeight < 20) { 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})`) .text((d) => (d.value ? valueFormat(d.value) : '')); - // Only render value if box is big enough leafValues.each(function (d) { const nodeWidth = d.x1 - d.x0; const nodeHeight = d.y1 - d.y0; - if (nodeWidth < 30 || nodeHeight < 30) { select(this).style('display', 'none'); }