Adding treemap

This commit is contained in:
Knut Sveidqvist
2025-05-07 18:16:00 +02:00
parent 40eb0cc240
commit e0a075ecca
14 changed files with 705 additions and 5 deletions

View File

@@ -258,6 +258,16 @@ const config: RequiredDeep<MermaidConfig> = {
radar: {
...defaultConfigJson.radar,
},
treemap: {
useMaxWidth: true,
padding: 10,
showValues: true,
nodeWidth: 100,
nodeHeight: 40,
borderWidth: 1,
valueFontSize: 12,
labelFontSize: 14,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -27,6 +27,7 @@ import block from '../diagrams/block/blockDetector.js';
import architecture from '../diagrams/architecture/architectureDetector.js';
import { registerLazyLoadedDiagrams } from './detectType.js';
import { registerDiagram } from './diagramAPI.js';
import { treemap } from '../diagrams/treemap/detector.js';
let hasLoadedDiagrams = false;
export const addDiagrams = () => {
@@ -96,6 +97,7 @@ export const addDiagrams = () => {
xychart,
block,
architecture,
radar
radar,
treemap
);
};

View File

@@ -0,0 +1,60 @@
import { getConfig as commonGetConfig } from '../../config.js';
import DEFAULT_CONFIG from '../../defaultConfig.js';
import { cleanAndMerge } from '../../utils.js';
import {
clear as commonClear,
getAccDescription,
getAccTitle,
getDiagramTitle,
setAccDescription,
setAccTitle,
setDiagramTitle,
} from '../common/commonDb.js';
import type { TreemapDB, TreemapData, TreemapNode } from './types.js';
const defaultTreemapData: TreemapData = {
nodes: [],
levels: new Map(),
};
let data: TreemapData = structuredClone(defaultTreemapData);
const getConfig = () => {
return cleanAndMerge({
...DEFAULT_CONFIG.treemap,
...commonGetConfig().treemap,
});
};
const getNodes = (): TreemapNode[] => data.nodes;
const addNode = (node: TreemapNode, level: number) => {
data.nodes.push(node);
data.levels.set(node, level);
// Set the root node if this is a level 0 node and we don't have a root yet
if (level === 0 && !data.root) {
data.root = node;
}
};
const getRoot = (): TreemapNode | undefined => data.root;
const clear = () => {
commonClear();
data = structuredClone(defaultTreemapData);
};
export const db: TreemapDB = {
getNodes,
addNode,
getRoot,
getConfig,
clear,
setAccTitle,
getAccTitle,
setDiagramTitle,
getDiagramTitle,
getAccDescription,
setAccDescription,
};

View File

@@ -0,0 +1,23 @@
import type {
DiagramDetector,
DiagramLoader,
ExternalDiagramDefinition,
} from '../../diagram-api/types.js';
const id = 'treemap';
const detector: DiagramDetector = (txt) => {
console.log('treemap detector', txt);
return /^\s*treemap/.test(txt);
};
const loader: DiagramLoader = async () => {
const { diagram } = await import('./diagram.js');
return { id, diagram };
};
export const treemap: ExternalDiagramDefinition = {
id,
detector,
loader,
};

View File

@@ -0,0 +1,12 @@
import type { DiagramDefinition } from '../../diagram-api/types.js';
import { db } from './db.js';
import { parser } from './parser.js';
import { renderer } from './renderer.js';
import styles from './styles.js';
export const diagram: DiagramDefinition = {
parser,
db,
renderer,
styles,
};

View File

@@ -0,0 +1,103 @@
import { parse } from '@mermaid-js/parser';
import type { ParserDefinition } from '../../diagram-api/types.js';
import { log } from '../../logger.js';
import { populateCommonDb } from '../common/populateCommonDb.js';
import { db } from './db.js';
import type { TreemapNode } from './types.js';
/**
* Populates the database with data from the Treemap AST
* @param ast - The Treemap AST
*/
const populate = (ast: any) => {
populateCommonDb(ast, db);
// Process rows
let lastLevel = 0;
let lastNode: TreemapNode | undefined;
// Process each row in the treemap, building the node hierarchy
for (const row of ast.TreemapRows || []) {
const item = row.item;
if (!item) {
continue;
}
const level = row.indent ? parseInt(row.indent) : 0;
const name = getItemName(item);
// Create the node
const node: TreemapNode = {
name,
children: [],
};
// If it's a leaf node, add the value
if (item.$type === 'Leaf') {
node.value = item.value;
}
// Add to the right place in hierarchy
if (level === 0) {
// Root node
db.addNode(node, level);
} else if (level > lastLevel) {
// Child of the last node
if (lastNode) {
lastNode.children = lastNode.children || [];
lastNode.children.push(node);
node.parent = lastNode;
}
db.addNode(node, level);
} else if (level === lastLevel) {
// Sibling of the last node
if (lastNode?.parent) {
lastNode.parent.children = lastNode.parent.children || [];
lastNode.parent.children.push(node);
node.parent = lastNode.parent;
}
db.addNode(node, level);
} else if (level < lastLevel) {
// Go up in the hierarchy
let parent = lastNode ? lastNode.parent : undefined;
for (let i = lastLevel; i > level; i--) {
if (parent) {
parent = parent.parent;
}
}
if (parent) {
parent.children = parent.children || [];
parent.children.push(node);
node.parent = parent;
}
db.addNode(node, level);
}
lastLevel = level;
lastNode = node;
}
};
/**
* Gets the name of a treemap item
* @param item - The treemap item
* @returns The name of the item
*/
const getItemName = (item: any): string => {
return item.name ? String(item.name) : '';
};
export const parser: ParserDefinition = {
parse: async (text: string): Promise<void> => {
try {
// Use a generic parse that accepts any diagram type
const parseFunc = parse as (diagramType: string, text: string) => Promise<any>;
const ast = await parseFunc('treemap', text);
log.debug('Treemap AST:', ast);
populate(ast);
} catch (error) {
log.error('Error parsing treemap:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,159 @@
import type { Diagram } from '../../Diagram.js';
import type { DiagramRenderer, DrawDefinition } from '../../diagram-api/types.js';
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
import { configureSvgSize } from '../../setupGraphViewbox.js';
import type { TreemapDB, TreemapNode } from './types.js';
const DEFAULT_PADDING = 10;
const DEFAULT_NODE_WIDTH = 100;
const DEFAULT_NODE_HEIGHT = 40;
/**
* Draws the treemap diagram
*/
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 title = treemapDb.getDiagramTitle();
const root = treemapDb.getRoot();
if (!root) {
return;
}
const svg = selectSvgElement(id);
// Calculate the size of the treemap
const { width, height } = calculateTreemapSize(root, config);
const titleHeight = title ? 30 : 0;
const svgWidth = width + padding * 2;
const svgHeight = height + padding * 2 + titleHeight;
// Set the SVG size
svg.attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
configureSvgSize(svg, svgHeight, svgWidth, config.useMaxWidth);
// Create a container group to hold all elements
const g = svg.append('g').attr('transform', `translate(${padding}, ${padding + titleHeight})`);
// Draw the title if it exists
if (title) {
svg
.append('text')
.attr('x', svgWidth / 2)
.attr('y', padding + titleHeight / 2)
.attr('class', 'treemapTitle')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.text(title);
}
// Draw the treemap recursively
drawNode(g, root, 0, 0, width, height, config);
};
/**
* Calculates the size of the treemap
*/
const calculateTreemapSize = (
root: TreemapNode,
config: any
): { width: number; height: number } => {
// If we have a value, use it as the size
if (root.value) {
return {
width: config.nodeWidth || DEFAULT_NODE_WIDTH,
height: config.nodeHeight || DEFAULT_NODE_HEIGHT,
};
}
// Otherwise, layout the children
if (!root.children || root.children.length === 0) {
return {
width: config.nodeWidth || DEFAULT_NODE_WIDTH,
height: config.nodeHeight || DEFAULT_NODE_HEIGHT,
};
}
// Calculate based on children
let totalWidth = 0;
let maxHeight = 0;
// Arrange in a simple tiled layout
for (const child of root.children) {
const { width, height } = calculateTreemapSize(child, config);
totalWidth += width + (config.padding || DEFAULT_PADDING);
maxHeight = Math.max(maxHeight, height);
}
// Remove the last padding
totalWidth -= config.padding || DEFAULT_PADDING;
return {
width: Math.max(totalWidth, config.nodeWidth || DEFAULT_NODE_WIDTH),
height: Math.max(
maxHeight + (config.padding || DEFAULT_PADDING) * 2,
config.nodeHeight || DEFAULT_NODE_HEIGHT
),
};
};
/**
* Recursively draws a node and its children in the treemap
*/
const drawNode = (
parent: any,
node: TreemapNode,
x: number,
y: number,
width: number,
height: number,
config: any
) => {
// Add rectangle
parent
.append('rect')
.attr('x', x)
.attr('y', y)
.attr('width', width)
.attr('height', height)
.attr('class', `treemapNode ${node.value ? 'treemapLeaf' : 'treemapSection'}`);
// Add the label
parent
.append('text')
.attr('x', x + width / 2)
.attr('y', y + 20) // Position the label at the top
.attr('class', 'treemapLabel')
.attr('text-anchor', 'middle')
.text(node.name);
// Add the value if it exists and should be shown
if (node.value !== undefined && config.showValues !== false) {
parent
.append('text')
.attr('x', x + width / 2)
.attr('y', y + height - 10) // Position the value at the bottom
.attr('class', 'treemapValue')
.attr('text-anchor', 'middle')
.text(node.value);
}
// If this is a section with children, layout and draw the children
if (!node.value && node.children && node.children.length > 0) {
// Simple tiled layout for children
const padding = config.padding || DEFAULT_PADDING;
let currentX = x + padding;
const innerY = y + 30; // Allow space for the label
const innerHeight = height - 40; // Allow space for label
for (const child of node.children) {
const childWidth = width / node.children.length - padding;
drawNode(parent, child, currentX, innerY, childWidth, innerHeight, config);
currentX += childWidth + padding;
}
}
};
export const renderer: DiagramRenderer = { draw };

View File

@@ -0,0 +1,49 @@
import type { DiagramStylesProvider } from '../../diagram-api/types.js';
import { cleanAndMerge } from '../../utils.js';
import type { PacketStyleOptions } from './types.js';
const defaultPacketStyleOptions: PacketStyleOptions = {
byteFontSize: '10px',
startByteColor: 'black',
endByteColor: 'black',
labelColor: 'black',
labelFontSize: '12px',
titleColor: 'black',
titleFontSize: '14px',
blockStrokeColor: 'black',
blockStrokeWidth: '1',
blockFillColor: '#efefef',
};
export const getStyles: DiagramStylesProvider = ({
packet,
}: { packet?: PacketStyleOptions } = {}) => {
const options = cleanAndMerge(defaultPacketStyleOptions, packet);
return `
.packetByte {
font-size: ${options.byteFontSize};
}
.packetByte.start {
fill: ${options.startByteColor};
}
.packetByte.end {
fill: ${options.endByteColor};
}
.packetLabel {
fill: ${options.labelColor};
font-size: ${options.labelFontSize};
}
.packetTitle {
fill: ${options.titleColor};
font-size: ${options.titleFontSize};
}
.packetBlock {
stroke: ${options.blockStrokeColor};
stroke-width: ${options.blockStrokeWidth};
fill: ${options.blockFillColor};
}
`;
};
export default getStyles;

View File

@@ -0,0 +1,47 @@
import type { DiagramDBBase } from '../../diagram-api/types.js';
import type { BaseDiagramConfig } from '../../config.type.js';
export interface TreemapNode {
name: string;
children?: TreemapNode[];
value?: number;
parent?: TreemapNode;
}
export interface TreemapDB extends DiagramDBBase<TreemapDiagramConfig> {
getNodes: () => TreemapNode[];
addNode: (node: TreemapNode, level: number) => void;
getRoot: () => TreemapNode | undefined;
}
export interface TreemapStyleOptions {
sectionStrokeColor?: string;
sectionStrokeWidth?: string;
sectionFillColor?: string;
leafStrokeColor?: string;
leafStrokeWidth?: string;
leafFillColor?: string;
labelColor?: string;
labelFontSize?: string;
valueFontSize?: string;
valueColor?: string;
titleColor?: string;
titleFontSize?: string;
}
export interface TreemapData {
nodes: TreemapNode[];
levels: Map<TreemapNode, number>;
root?: TreemapNode;
}
// Define the TreemapDiagramConfig interface
export interface TreemapDiagramConfig extends BaseDiagramConfig {
padding?: number;
showValues?: boolean;
nodeWidth?: number;
nodeHeight?: number;
borderWidth?: number;
valueFontSize?: number;
labelFontSize?: number;
}

View File

@@ -8,6 +8,7 @@ export {
Architecture,
GitGraph,
Radar,
TreemapDoc,
Branch,
Commit,
Merge,
@@ -19,6 +20,7 @@ export {
isPieSection,
isArchitecture,
isGitGraph,
isTreemapDoc,
isBranch,
isCommit,
isMerge,
@@ -32,6 +34,7 @@ export {
ArchitectureGeneratedModule,
GitGraphGeneratedModule,
RadarGeneratedModule,
TreemapGeneratedModule,
} from './generated/module.js';
export * from './gitGraph/index.js';
@@ -41,3 +44,4 @@ export * from './packet/index.js';
export * from './pie/index.js';
export * from './architecture/index.js';
export * from './radar/index.js';
export * from './treemap/index.js';

View File

@@ -1,6 +1,6 @@
import type { LangiumParser, ParseResult } from 'langium';
import type { Info, Packet, Pie, Architecture, GitGraph, Radar } from './index.js';
import type { Info, Packet, Pie, Architecture, GitGraph, Radar, Treemap } from './index.js';
export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar;
@@ -36,6 +36,11 @@ const initializers = {
const parser = createRadarServices().Radar.parser.LangiumParser;
parsers.radar = parser;
},
treemap: async () => {
const { createTreemapServices } = await import('./language/treemap/index.js');
const parser = createTreemapServices().Treemap.parser.LangiumParser;
parsers.treemap = parser;
},
} as const;
export async function parse(diagramType: 'info', text: string): Promise<Info>;
@@ -44,6 +49,7 @@ export async function parse(diagramType: 'pie', text: string): Promise<Pie>;
export async function parse(diagramType: 'architecture', text: string): Promise<Architecture>;
export async function parse(diagramType: 'gitGraph', text: string): Promise<GitGraph>;
export async function parse(diagramType: 'radar', text: string): Promise<Radar>;
export async function parse(diagramType: 'treemap', text: string): Promise<Treemap>;
export async function parse<T extends DiagramAST>(
diagramType: keyof typeof initializers,