diff --git a/demos/usecase.html b/demos/usecase.html
new file mode 100644
index 000000000..2fcc4f091
--- /dev/null
+++ b/demos/usecase.html
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test Diagram
+
+
+usecase
+direction LR
+ actor User1, User2, User3, User4
+ actor Admin1, Admin2
+ actor System1@{ "icon": "bell" }
+ actor System2@{ "icon": "database" }
+
+ systemBoundary "Module A"
+ "Feature A1"
+ "Feature A2"
+ "Admin A1"
+
+ end
+ "Module A"@{ type: package }
+
+ systemBoundary "Module B"
+ "Feature B1"
+ "Feature B2"
+ "Admin B1"
+ end
+
+ User1 --important--> "Feature A1"
+ User2 --> "Feature A2"
+ Admin1 --> "Admin A1"
+ User3 --> "Feature B1"
+ User4 --> "Feature B2"
+ Admin2 --> "Admin B1"
+
+ System1 <-- "Feature A1"
+ System1 <-- "Feature B1"
+ System2 <-- "Admin A1"
+ System2 <-- "Admin B1"
+
+ User1 --"collaborates"--> User2
+ Admin1 --"supervises"--> Admin2
+
+
+
+
+
+
+
diff --git a/packages/mermaid/src/diagrams/usecase/styles.ts b/packages/mermaid/src/diagrams/usecase/styles.ts
index 526e80661..7657a2c35 100644
--- a/packages/mermaid/src/diagrams/usecase/styles.ts
+++ b/packages/mermaid/src/diagrams/usecase/styles.ts
@@ -23,10 +23,38 @@ const getStyles = (options: any) =>
font-size: 12px;
font-weight: normal;
}
+
+ /* Ellipse shape styling for use cases */
+ .usecase-element ellipse {
+ fill: ${options.mainBkg ?? '#ffffff'};
+ stroke: ${options.primaryColor};
+ stroke-width: 2px;
+ }
+
+ .usecase-element .label {
+ fill: ${options.primaryTextColor};
+ font-family: ${options.fontFamily};
+ font-size: 12px;
+ font-weight: normal;
+ text-anchor: middle;
+ dominant-baseline: central;
+ }
+
+ /* General ellipse styling */
+ .node ellipse {
+ fill: ${options.mainBkg ?? '#ffffff'};
+ stroke: ${options.nodeBorder ?? options.primaryColor};
+ stroke-width: 1px;
+ }
.relationship {
- stroke: ${options.primaryColor};
- fill: ${options.primaryColor};
+ stroke: ${options.lineColor};
+ fill: none;
+ }
+
+ & .marker {
+ fill: ${options.lineColor};
+ stroke: ${options.lineColor};
}
.relationship-label {
@@ -35,6 +63,15 @@ const getStyles = (options: any) =>
font-size: 10px;
font-weight: normal;
}
+
+ .nodeLabel, .edgeLabel {
+ color: ${options.classText};
+ }
+ .system-boundary {
+ fill: ${options.clusterBkg};
+ stroke: ${options.clusterBorder};
+ stroke-width: 1px;
+ }
`;
export default getStyles;
diff --git a/packages/mermaid/src/diagrams/usecase/usecaseDb.ts b/packages/mermaid/src/diagrams/usecase/usecaseDb.ts
index 513699c6d..e813be6be 100644
--- a/packages/mermaid/src/diagrams/usecase/usecaseDb.ts
+++ b/packages/mermaid/src/diagrams/usecase/usecaseDb.ts
@@ -15,10 +15,15 @@ import type {
UseCase,
SystemBoundary,
Relationship,
+ ActorMetadata,
+ Direction,
} from './usecaseTypes.js';
+import { DEFAULT_DIRECTION } from './usecaseTypes.js';
import type { RequiredDeep } from 'type-fest';
import type { UsecaseDiagramConfig } from '../../config.type.js';
import DEFAULT_CONFIG from '../../defaultConfig.js';
+import { getConfig as getGlobalConfig } from '../../diagram-api/diagramAPI.js';
+import type { LayoutData, Node, ClusterNode, Edge } from '../../rendering-util/types.js';
export const DEFAULT_USECASE_CONFIG: Required = DEFAULT_CONFIG.usecase;
@@ -27,6 +32,7 @@ export const DEFAULT_USECASE_DB: RequiredDeep = {
useCases: new Map(),
systemBoundaries: new Map(),
relationships: [],
+ direction: DEFAULT_DIRECTION,
config: DEFAULT_USECASE_CONFIG,
} as const;
@@ -34,6 +40,7 @@ let actors = new Map();
let useCases = new Map();
let systemBoundaries = new Map();
let relationships: Relationship[] = [];
+let direction: Direction = DEFAULT_DIRECTION;
const config: Required = structuredClone(DEFAULT_USECASE_CONFIG);
const getConfig = (): Required => structuredClone(config);
@@ -43,6 +50,7 @@ const clear = (): void => {
useCases = new Map();
systemBoundaries = new Map();
relationships = [];
+ direction = DEFAULT_DIRECTION;
commonClear();
};
@@ -147,6 +155,122 @@ const addRelationship = (relationship: Relationship): void => {
const getRelationships = (): Relationship[] => relationships;
+// Direction management
+const setDirection = (dir: Direction): void => {
+ // Normalize TD to TB (same as flowchart)
+ if (dir === 'TD') {
+ direction = 'TB';
+ } else {
+ direction = dir;
+ }
+ log.debug('Direction set to:', direction);
+};
+
+const getDirection = (): Direction => direction;
+
+// Convert usecase diagram data to LayoutData format for unified rendering
+const getData = (): LayoutData => {
+ const globalConfig = getGlobalConfig();
+ const nodes: Node[] = [];
+ const edges: Edge[] = [];
+
+ // Convert actors to nodes
+ for (const actor of actors.values()) {
+ const node: Node = {
+ id: actor.id,
+ label: actor.name,
+ description: actor.description ? [actor.description] : undefined,
+ shape: 'usecaseActor', // Use custom actor shape
+ isGroup: false,
+ padding: 10,
+ look: globalConfig.look,
+ // Add metadata as data attributes for styling
+ cssClasses: `usecase-actor ${
+ actor.metadata && Object.keys(actor.metadata).length > 0
+ ? Object.entries(actor.metadata)
+ .map(([key, value]) => `actor-${key}-${value}`)
+ .join(' ')
+ : ''
+ }`.trim(),
+ // Pass actor metadata to the shape handler
+ metadata: actor.metadata,
+ } as Node & { metadata?: ActorMetadata };
+ nodes.push(node);
+ }
+
+ // Convert use cases to nodes
+ for (const useCase of useCases.values()) {
+ const node: Node = {
+ id: useCase.id,
+ label: useCase.name,
+ description: useCase.description ? [useCase.description] : undefined,
+ shape: 'ellipse', // Use ellipse shape for use cases
+ isGroup: false,
+ padding: 10,
+ look: globalConfig.look,
+ cssClasses: 'usecase-element',
+ // If use case belongs to a system boundary, set parentId
+ ...(useCase.systemBoundary && { parentId: useCase.systemBoundary }),
+ };
+ nodes.push(node);
+ }
+
+ // Convert system boundaries to group nodes
+ for (const boundary of systemBoundaries.values()) {
+ const node: ClusterNode & { boundaryType?: string } = {
+ id: boundary.id,
+ label: boundary.name,
+ shape: 'usecaseSystemBoundary', // Use custom usecase system boundary cluster shape
+ isGroup: true, // System boundaries are clusters (containers for other nodes)
+ padding: 20,
+ look: globalConfig.look,
+ cssClasses: `system-boundary system-boundary-${boundary.type ?? 'rect'}`,
+ // Pass boundary type to the shape handler
+ boundaryType: boundary.type,
+ };
+ nodes.push(node);
+ }
+
+ // Convert relationships to edges
+ relationships.forEach((relationship, index) => {
+ const edge: Edge = {
+ id: relationship.id || `edge-${index}`,
+ start: relationship.from,
+ end: relationship.to,
+ source: relationship.from,
+ target: relationship.to,
+ label: relationship.label,
+ type: relationship.type,
+ arrowTypeEnd:
+ relationship.arrowType === 0
+ ? 'arrow_point' // Forward arrow (-->)
+ : 'none', // No end arrow for back arrow or line
+ arrowTypeStart:
+ relationship.arrowType === 1
+ ? 'arrow_point' // Back arrow (<--)
+ : 'none', // No start arrow for forward arrow or line
+ classes: `relationship relationship-${relationship.type}`,
+ look: globalConfig.look,
+ thickness: 'normal',
+ pattern: 'solid',
+ };
+ edges.push(edge);
+ });
+
+ return {
+ nodes,
+ edges,
+ config: globalConfig,
+ // Additional properties that layout algorithms might expect
+ type: 'usecase',
+ layoutAlgorithm: 'dagre', // Default layout algorithm
+ direction: getDirection(), // Use the current direction setting
+ nodeSpacing: 50, // Default node spacing
+ rankSpacing: 50, // Default rank spacing
+ markers: ['arrow_point'], // Arrow point markers used in usecase diagrams
+ };
+};
+
export const db: UsecaseDB = {
getConfig,
@@ -172,4 +296,11 @@ export const db: UsecaseDB = {
addRelationship,
getRelationships,
+
+ // Direction management
+ setDirection,
+ getDirection,
+
+ // Add getData method for unified rendering
+ getData,
};
diff --git a/packages/mermaid/src/diagrams/usecase/usecaseParser.ts b/packages/mermaid/src/diagrams/usecase/usecaseParser.ts
index 96305ca16..ab8a59893 100644
--- a/packages/mermaid/src/diagrams/usecase/usecaseParser.ts
+++ b/packages/mermaid/src/diagrams/usecase/usecaseParser.ts
@@ -26,6 +26,7 @@ interface UsecaseParseResult {
arrowType: number;
label?: string;
}[];
+ direction?: string;
accDescr?: string;
accTitle?: string;
title?: string;
@@ -94,10 +95,16 @@ const populateDb = (ast: UsecaseParseResult, db: UsecaseDB) => {
db.addRelationship(relationship);
});
+ // Set direction if provided
+ if (ast.direction) {
+ db.setDirection(ast.direction as any);
+ }
+
log.debug('Populated usecase database:', {
actors: ast.actors.length,
useCases: ast.useCases.length,
relationships: ast.relationships.length,
+ direction: ast.direction,
});
};
diff --git a/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts b/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts
index cdae53883..e23ba6bd5 100644
--- a/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts
+++ b/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts
@@ -1,545 +1,48 @@
-import type { DrawDefinition, SVG, SVGGroup } from '../../diagram-api/types.js';
+import type { DrawDefinition } from '../../diagram-api/types.js';
import { log } from '../../logger.js';
-import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
+import { getDiagramElement } from '../../rendering-util/insertElementsForSize.js';
+import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js';
import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
-import {
- insertEdgeLabel,
- positionEdgeLabel,
-} from '../../rendering-util/rendering-elements/edges.js';
-import { db } from './usecaseDb.js';
-import { ARROW_TYPE } from './usecaseTypes.js';
-import type { Actor, UseCase, SystemBoundary, Relationship } from './usecaseTypes.js';
-
-// Layout constants
-const ACTOR_WIDTH = 80;
-const ACTOR_HEIGHT = 100;
-const USECASE_WIDTH = 120;
-const USECASE_HEIGHT = 60;
-const SYSTEM_BOUNDARY_PADDING = 30;
-const MARGIN = 50;
-const SPACING_X = 200;
-const SPACING_Y = 150;
+import utils from '../../utils.js';
+import type { UsecaseDB } from './usecaseTypes.js';
/**
- * Get actor styling based on metadata
+ * Main draw function using unified rendering system
*/
-const getActorStyling = (metadata?: Record) => {
- const defaults = {
- fillColor: 'none',
- strokeColor: 'black',
- strokeWidth: 2,
- type: 'solid',
- };
+const draw: DrawDefinition = async (_text, id, _version, diag) => {
+ log.info('Drawing usecase diagram (unified)', id);
+ const { securityLevel, usecase: conf, layout } = getConfig();
- if (!metadata) {
- return defaults;
- }
+ // The getData method provided in all supported diagrams is used to extract the data from the parsed structure
+ // into the Layout data format
+ const usecaseDb = diag.db as UsecaseDB;
+ const data4Layout = usecaseDb.getData();
- return {
- fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor,
- strokeColor: metadata.strokeColor || defaults.strokeColor,
- strokeWidth: parseInt(metadata.strokeWidth || '2', 10),
- type: metadata.type || defaults.type,
- };
-};
+ // Create the root SVG - the element is the div containing the SVG element
+ const svg = getDiagramElement(id, securityLevel);
-/**
- * Draw an actor (stick figure) with metadata support
- */
-const drawActor = (group: SVGGroup, actor: Actor, x: number, y: number): void => {
- const actorGroup = group.append('g').attr('class', 'actor').attr('id', actor.id);
- const styling = getActorStyling(actor.metadata);
+ data4Layout.type = diag.type;
+ data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout);
- // Add metadata as data attributes for CSS styling
- if (actor.metadata) {
- Object.entries(actor.metadata).forEach(([key, value]) => {
- actorGroup.attr(`data-${key}`, value);
- });
- }
+ data4Layout.nodeSpacing = 50; // Default node spacing
+ data4Layout.rankSpacing = 50; // Default rank spacing
+ data4Layout.markers = ['point']; // Use point markers for usecase diagrams
+ data4Layout.diagramId = id;
- // Check if we should render an icon instead of stick figure
- if (actor.metadata?.icon) {
- drawActorWithIcon(actorGroup, actor, x, y, styling);
- } else {
- drawStickFigure(actorGroup, actor, x, y, styling);
- }
+ log.debug('Usecase layout data:', data4Layout);
- // Actor name (always rendered)
- const displayName = actor.metadata?.name ?? actor.name;
- actorGroup
- .append('text')
- .attr('x', x)
- .attr('y', y + 50)
- .attr('text-anchor', 'middle')
- .attr('class', 'actor-label')
- .text(displayName);
-};
+ // Use the unified rendering system
+ await render(data4Layout, svg);
-/**
- * Draw traditional stick figure
- */
-const drawStickFigure = (
- actorGroup: SVGGroup,
- _actor: Actor,
- x: number,
- y: number,
- styling: ReturnType
-): void => {
- // Head (circle)
- actorGroup
- .append('circle')
- .attr('cx', x)
- .attr('cy', y - 30)
- .attr('r', 8)
- .attr('fill', styling.fillColor)
- .attr('stroke', styling.strokeColor)
- .attr('stroke-width', styling.strokeWidth);
-
- // Body (line)
- actorGroup
- .append('line')
- .attr('x1', x)
- .attr('y1', y - 22)
- .attr('x2', x)
- .attr('y2', y + 10)
- .attr('stroke', styling.strokeColor)
- .attr('stroke-width', styling.strokeWidth);
-
- // Arms (line)
- actorGroup
- .append('line')
- .attr('x1', x - 15)
- .attr('y1', y - 10)
- .attr('x2', x + 15)
- .attr('y2', y - 10)
- .attr('stroke', styling.strokeColor)
- .attr('stroke-width', styling.strokeWidth);
-
- // Left leg
- actorGroup
- .append('line')
- .attr('x1', x)
- .attr('y1', y + 10)
- .attr('x2', x - 15)
- .attr('y2', y + 30)
- .attr('stroke', styling.strokeColor)
- .attr('stroke-width', styling.strokeWidth);
-
- // Right leg
- actorGroup
- .append('line')
- .attr('x1', x)
- .attr('y1', y + 10)
- .attr('x2', x + 15)
- .attr('y2', y + 30)
- .attr('stroke', styling.strokeColor)
- .attr('stroke-width', styling.strokeWidth);
-};
-
-/**
- * Draw actor with icon representation
- */
-const drawActorWithIcon = (
- actorGroup: SVGGroup,
- actor: Actor,
- x: number,
- y: number,
- styling: ReturnType
-): void => {
- const iconName = actor.metadata?.icon ?? 'user';
-
- // Create a rectangle background for the icon
- actorGroup
- .append('rect')
- .attr('x', x - 20)
- .attr('y', y - 35)
- .attr('width', 40)
- .attr('height', 40)
- .attr('rx', 5)
- .attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor)
- .attr('stroke', styling.strokeColor)
- .attr('stroke-width', styling.strokeWidth);
-
- // Add icon text (could be enhanced to use actual icons/SVG symbols)
- actorGroup
- .append('text')
- .attr('x', x)
- .attr('y', y - 10)
- .attr('text-anchor', 'middle')
- .attr('class', 'actor-icon')
- .attr('font-size', '16px')
- .attr('font-weight', 'bold')
- .text(getIconSymbol(iconName));
-};
-
-/**
- * Get symbol representation for common icons
- */
-const getIconSymbol = (iconName: string): string => {
- const iconMap: Record = {
- user: '👤',
- admin: '👑',
- system: '⚙️',
- database: '🗄️',
- api: '🔌',
- service: '🔧',
- client: '💻',
- server: '🖥️',
- mobile: '📱',
- web: '🌐',
- default: '👤',
- };
-
- return iconMap[iconName.toLowerCase()] || iconMap.default;
-};
-
-/**
- * Draw a use case (oval)
- */
-const drawUseCase = (group: SVGGroup, useCase: UseCase, x: number, y: number): void => {
- const useCaseGroup = group.append('g').attr('class', 'usecase').attr('id', useCase.id);
-
- // Oval
- useCaseGroup
- .append('ellipse')
- .attr('cx', x)
- .attr('cy', y)
- .attr('rx', USECASE_WIDTH / 2)
- .attr('ry', USECASE_HEIGHT / 2)
- .attr('fill', 'white')
- .attr('stroke', 'black')
- .attr('stroke-width', 2);
-
- // Use case name
- useCaseGroup
- .append('text')
- .attr('x', x)
- .attr('y', y + 5)
- .attr('text-anchor', 'middle')
- .attr('class', 'usecase-label')
- .text(useCase.name);
-};
-
-/**
- * Draw a system boundary (supports both 'rect' and 'package' types)
- */
-const drawSystemBoundary = (
- group: SVGGroup,
- boundary: SystemBoundary,
- useCasePositions: Map
-): void => {
- // Calculate boundary dimensions based on contained use cases
- const containedUseCases = boundary.useCases
- .map((ucId) => useCasePositions.get(ucId))
- .filter((pos) => pos !== undefined) as { x: number; y: number }[];
-
- if (containedUseCases.length === 0) {
- return; // No use cases to bound
- }
-
- // Find min/max coordinates of contained use cases
- const minX =
- Math.min(...containedUseCases.map((pos) => pos.x)) -
- USECASE_WIDTH / 2 -
- SYSTEM_BOUNDARY_PADDING;
- const maxX =
- Math.max(...containedUseCases.map((pos) => pos.x)) +
- USECASE_WIDTH / 2 +
- SYSTEM_BOUNDARY_PADDING;
- const minY =
- Math.min(...containedUseCases.map((pos) => pos.y)) -
- USECASE_HEIGHT / 2 -
- SYSTEM_BOUNDARY_PADDING;
- const maxY =
- Math.max(...containedUseCases.map((pos) => pos.y)) +
- USECASE_HEIGHT / 2 +
- SYSTEM_BOUNDARY_PADDING;
-
- const boundaryType = boundary.type || 'rect'; // default to 'rect'
- const boundaryGroup = group.append('g').attr('class', 'system-boundary').attr('id', boundary.id);
-
- if (boundaryType === 'package') {
- drawPackageBoundary(boundaryGroup, boundary, minX, minY, maxX, maxY);
- } else {
- drawRectBoundary(boundaryGroup, boundary, minX, minY, maxX, maxY);
- }
-};
-
-/**
- * Draw rect-type system boundary (simple dashed rectangle)
- */
-const drawRectBoundary = (
- boundaryGroup: SVGGroup,
- boundary: SystemBoundary,
- minX: number,
- minY: number,
- maxX: number,
- maxY: number
-): void => {
- // Draw dashed rectangle
- boundaryGroup
- .append('rect')
- .attr('x', minX)
- .attr('y', minY)
- .attr('width', maxX - minX)
- .attr('height', maxY - minY)
- .attr('fill', 'none')
- .attr('stroke', 'black')
- .attr('stroke-width', 2)
- .attr('stroke-dasharray', '5,5');
- // .attr('rx', 10)
- // .attr('ry', 10);
-
- // Draw boundary label at top-left
- boundaryGroup
- .append('text')
- .attr('x', minX + 10)
- .attr('y', minY + 20)
- .attr('class', 'system-boundary-label')
- .attr('font-weight', 'bold')
- .attr('font-size', '14px')
- .text(boundary.name);
-};
-
-/**
- * Draw package-type system boundary (rectangle with separate name box)
- */
-const drawPackageBoundary = (
- boundaryGroup: SVGGroup,
- boundary: SystemBoundary,
- minX: number,
- minY: number,
- maxX: number,
- maxY: number
-): void => {
- // Calculate name box dimensions
- const nameBoxWidth = Math.max(80, boundary.name.length * 8 + 20);
- const nameBoxHeight = 25;
-
- // Draw main boundary rectangle
- boundaryGroup
- .append('rect')
- .attr('x', minX)
- .attr('y', minY)
- .attr('width', maxX - minX)
- .attr('height', maxY - minY)
- .attr('fill', 'none')
- .attr('stroke', 'black')
- .attr('stroke-width', 2);
-
- // Draw name box (package tab)
- boundaryGroup
- .append('rect')
- .attr('x', minX)
- .attr('y', minY - nameBoxHeight)
- .attr('width', nameBoxWidth)
- .attr('height', nameBoxHeight)
- .attr('fill', 'white')
- .attr('stroke', 'black')
- .attr('stroke-width', 2);
- // .attr('rx', 5)
- // .attr('ry', 5);
-
- // Draw boundary label in the name box
- boundaryGroup
- .append('text')
- .attr('x', minX + nameBoxWidth / 2)
- .attr('y', minY - nameBoxHeight / 2 + 5)
- .attr('text-anchor', 'middle')
- .attr('class', 'system-boundary-label')
- .attr('font-weight', 'bold')
- .attr('font-size', '14px')
- .text(boundary.name);
-};
-
-/**
- * Draw a relationship (line with arrow)
- */
-const drawRelationship = async (
- group: SVGGroup,
- relationship: Relationship,
- fromPos: { x: number; y: number },
- toPos: { x: number; y: number }
-): Promise => {
- const relationshipGroup = group
- .append('g')
- .attr('class', 'relationship')
- .attr('id', relationship.id);
-
- // Calculate arrow direction
- const dx = toPos.x - fromPos.x;
- const dy = toPos.y - fromPos.y;
- const length = Math.sqrt(dx * dx + dy * dy);
- const unitX = dx / length;
- const unitY = dy / length;
-
- // Adjust start and end points to avoid overlapping with shapes
- const startX = fromPos.x + unitX * 40;
- const startY = fromPos.y + unitY * 40;
- const endX = toPos.x - unitX * 60;
- const endY = toPos.y - unitY * 30;
-
- // Main line
- relationshipGroup
- .append('line')
- .attr('x1', startX)
- .attr('y1', startY)
- .attr('x2', endX)
- .attr('y2', endY)
- .attr('stroke', 'black')
- .attr('stroke-width', 2);
-
- // Draw arrow based on arrow type
- const arrowSize = 10;
-
- if (relationship.arrowType === ARROW_TYPE.SOLID_ARROW) {
- // Forward arrow (-->)
- const arrowX1 = endX - arrowSize * unitX - arrowSize * unitY * 0.5;
- const arrowY1 = endY - arrowSize * unitY + arrowSize * unitX * 0.5;
- const arrowX2 = endX - arrowSize * unitX + arrowSize * unitY * 0.5;
- const arrowY2 = endY - arrowSize * unitY - arrowSize * unitX * 0.5;
-
- relationshipGroup
- .append('polygon')
- .attr('points', `${endX},${endY} ${arrowX1},${arrowY1} ${arrowX2},${arrowY2}`)
- .attr('fill', 'black');
- } else if (relationship.arrowType === ARROW_TYPE.BACK_ARROW) {
- // Backward arrow (<--)
- const arrowX1 = startX + arrowSize * unitX - arrowSize * unitY * 0.5;
- const arrowY1 = startY + arrowSize * unitY + arrowSize * unitX * 0.5;
- const arrowX2 = startX + arrowSize * unitX + arrowSize * unitY * 0.5;
- const arrowY2 = startY + arrowSize * unitY - arrowSize * unitX * 0.5;
-
- relationshipGroup
- .append('polygon')
- .attr('points', `${startX},${startY} ${arrowX1},${arrowY1} ${arrowX2},${arrowY2}`)
- .attr('fill', 'black');
- }
- // For LINE_SOLID (--), no arrow head is drawn
-
- // Enhanced edge label rendering (if present)
- if (relationship.label) {
- const midX = (startX + endX) / 2;
- const midY = (startY + endY) / 2;
-
- // Create edge data structure compatible with the edge label system
- const edgeData = {
- id: relationship.id,
- label: relationship.label,
- labelStyle: 'stroke: #333; stroke-width: 1.5px; fill: none;',
- x: midX,
- y: midY,
- width: 0,
- height: 0,
- };
-
- try {
- // Use the proper edge label rendering system
- await insertEdgeLabel(relationshipGroup, edgeData);
-
- // Position the edge label at the midpoint
- const points = [
- { x: startX, y: startY },
- { x: midX, y: midY },
- { x: endX, y: endY },
- ];
-
- positionEdgeLabel(edgeData, {
- originalPath: points,
- });
- } catch (error) {
- // Fallback to simple text if edge label system fails
- log.warn(
- 'Failed to render edge label with advanced system, falling back to simple text:',
- error
- );
- relationshipGroup
- .append('text')
- .attr('x', midX)
- .attr('y', midY - 5)
- .attr('text-anchor', 'middle')
- .attr('class', 'relationship-label')
- .text(relationship.label);
- }
- }
-};
-
-/**
- * Main draw function
- */
-const draw: DrawDefinition = async (text, id, _version) => {
- log.debug('Rendering usecase diagram\n' + text);
-
- const svg: SVG = selectSvgElement(id);
- const group: SVGGroup = svg.append('g');
-
- // Get data from database
- const actors = [...db.getActors().values()];
- const useCases = [...db.getUseCases().values()];
- const systemBoundaries = [...db.getSystemBoundaries().values()];
- const relationships = db.getRelationships();
-
- log.debug('Rendering data:', {
- actors: actors.length,
- useCases: useCases.length,
- systemBoundaries: systemBoundaries.length,
- relationships: relationships.length,
- });
-
- // Calculate layout
- const actorPositions = new Map();
- const useCasePositions = new Map();
-
- // Position actors on the left
- actors.forEach((actor, index) => {
- const x = MARGIN + ACTOR_WIDTH / 2;
- const y = MARGIN + ACTOR_HEIGHT / 2 + index * SPACING_Y;
- actorPositions.set(actor.id, { x, y });
- });
-
- // Position use cases on the right
- useCases.forEach((useCase, index) => {
- const x = MARGIN + SPACING_X + USECASE_WIDTH / 2;
- const y = MARGIN + USECASE_HEIGHT / 2 + index * SPACING_Y;
- useCasePositions.set(useCase.id, { x, y });
- });
-
- // Draw actors
- actors.forEach((actor) => {
- const pos = actorPositions.get(actor.id)!;
- drawActor(group, actor, pos.x, pos.y);
- });
-
- // Draw use cases
- useCases.forEach((useCase) => {
- const pos = useCasePositions.get(useCase.id)!;
- drawUseCase(group, useCase, pos.x, pos.y);
- });
-
- // Draw system boundaries (after use cases, before relationships)
- systemBoundaries.forEach((boundary) => {
- drawSystemBoundary(group, boundary, useCasePositions);
- });
-
- // Draw relationships (async to handle edge labels)
- const relationshipPromises = relationships.map(async (relationship) => {
- const fromPos =
- actorPositions.get(relationship.from) ?? useCasePositions.get(relationship.from);
- const toPos = actorPositions.get(relationship.to) ?? useCasePositions.get(relationship.to);
-
- if (fromPos && toPos) {
- await drawRelationship(group, relationship, fromPos, toPos);
- }
- });
-
- // Wait for all relationships to be drawn
- await Promise.all(relationshipPromises);
-
- // Setup viewBox and SVG dimensions properly
- const { usecase: conf } = getConfig();
- const padding = 8; // Standard padding used by other diagrams
- setupViewPortForSVG(svg, padding, 'usecaseDiagram', conf?.useMaxWidth ?? true);
+ const padding = 8;
+ utils.insertTitle(
+ svg,
+ 'usecaseDiagramTitleText',
+ 0, // Default title top margin
+ usecaseDb.getDiagramTitle?.() ?? ''
+ );
+ setupViewPortForSVG(svg, padding, 'usecaseDiagram', conf?.useMaxWidth ?? false);
};
export const renderer = { draw };
diff --git a/packages/mermaid/src/diagrams/usecase/usecaseTypes.ts b/packages/mermaid/src/diagrams/usecase/usecaseTypes.ts
index 303c64fb4..2c4bb09ff 100644
--- a/packages/mermaid/src/diagrams/usecase/usecaseTypes.ts
+++ b/packages/mermaid/src/diagrams/usecase/usecaseTypes.ts
@@ -1,5 +1,6 @@
import type { DiagramDB } from '../../diagram-api/types.js';
import type { UsecaseDiagramConfig } from '../../config.type.js';
+import type { LayoutData } from '../../rendering-util/types.js';
export type ActorMetadata = Record;
@@ -45,11 +46,17 @@ export interface Relationship {
label?: string;
}
+// Direction types for usecase diagrams
+export type Direction = 'TB' | 'TD' | 'BT' | 'RL' | 'LR';
+
+export const DEFAULT_DIRECTION: Direction = 'TB';
+
export interface UsecaseFields {
actors: Map;
useCases: Map;
systemBoundaries: Map;
relationships: Relationship[];
+ direction: Direction;
config: Required;
}
@@ -75,6 +82,13 @@ export interface UsecaseDB extends DiagramDB {
addRelationship: (relationship: Relationship) => void;
getRelationships: () => Relationship[];
+ // Direction management
+ setDirection: (direction: Direction) => void;
+ getDirection: () => Direction;
+
+ // Unified rendering support
+ getData: () => LayoutData;
+
// Utility methods
clear: () => void;
}
diff --git a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js
index 1dd87d438..096d6c7cc 100644
--- a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js
+++ b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js
@@ -459,6 +459,173 @@ const divider = (parent, node) => {
return { cluster: shapeSvg, labelBBox: {} };
};
+/**
+ * Custom cluster shape for usecase system boundaries
+ * Supports two types: 'rect' (dashed rectangle) and 'package' (UML package notation)
+ * @param {any} parent
+ * @param {any} node
+ * @returns {any} ShapeSvg
+ */
+const usecaseSystemBoundary = async (parent, node) => {
+ log.info('Creating usecase system boundary for ', node.id, node);
+ const siteConfig = getConfig();
+ const { handDrawnSeed } = siteConfig;
+
+ // Add outer g element
+ const shapeSvg = parent
+ .insert('g')
+ .attr('class', 'cluster usecase-system-boundary ' + node.cssClasses)
+ .attr('id', node.id)
+ .attr('data-look', node.look);
+
+ // Get boundary type from node metadata (default to 'rect')
+ const boundaryType = node.boundaryType || 'rect';
+ shapeSvg.attr('data-boundary-type', boundaryType);
+
+ const useHtmlLabels = evaluate(siteConfig.flowchart?.htmlLabels);
+
+ // Create the label
+ const labelEl = shapeSvg.insert('g').attr('class', 'cluster-label');
+ const text = await createText(labelEl, node.label, {
+ style: node.labelStyle,
+ useHtmlLabels,
+ isNode: true,
+ });
+
+ // Get the size of the label
+ let bbox = text.getBBox();
+ if (evaluate(siteConfig.flowchart?.htmlLabels)) {
+ const div = text.children[0];
+ const dv = select(text);
+ bbox = div.getBoundingClientRect();
+ dv.attr('width', bbox.width);
+ dv.attr('height', bbox.height);
+ }
+
+ // Calculate width with padding (similar to rect cluster)
+ const width = node.width <= bbox.width + node.padding ? bbox.width + node.padding : node.width;
+ if (node.width <= bbox.width + node.padding) {
+ node.diff = (width - node.width) / 2 - node.padding;
+ } else {
+ node.diff = -node.padding;
+ }
+
+ const height = node.height;
+ // Use absolute coordinates from layout engine (like rect cluster does)
+ const x = node.x - width / 2;
+ const y = node.y - height / 2;
+
+ let boundaryRect;
+ const { subGraphTitleTopMargin } = getSubGraphTitleMargins(siteConfig);
+
+ if (boundaryType === 'package') {
+ // Draw package-type boundary (rectangle with separate name box at top)
+ const nameBoxWidth = Math.max(80, bbox.width + 20);
+ const nameBoxHeight = 25;
+
+ if (node.look === 'handDrawn') {
+ const rc = rough.svg(shapeSvg);
+ const options = userNodeOverrides(node, {
+ stroke: 'black',
+ strokeWidth: 2,
+ fill: 'none',
+ seed: handDrawnSeed,
+ });
+
+ // Draw main boundary rectangle
+ const roughRect = rc.rectangle(x, y, width, height, options);
+ boundaryRect = shapeSvg.insert(() => roughRect, ':first-child');
+
+ // Draw name box at top-left
+ const roughNameBox = rc.rectangle(x, y - nameBoxHeight, nameBoxWidth, nameBoxHeight, options);
+ shapeSvg.insert(() => roughNameBox, ':first-child');
+ } else {
+ // Draw main boundary rectangle
+ boundaryRect = shapeSvg
+ .insert('rect', ':first-child')
+ .attr('x', x)
+ .attr('y', y)
+ .attr('width', width)
+ .attr('height', height)
+ .attr('fill', 'none')
+ .attr('stroke', 'black')
+ .attr('stroke-width', 2);
+
+ // Draw name box at top-left
+ shapeSvg
+ .insert('rect', ':first-child')
+ .attr('x', x)
+ .attr('y', y - nameBoxHeight)
+ .attr('width', nameBoxWidth)
+ .attr('height', nameBoxHeight)
+ .attr('fill', 'white')
+ .attr('stroke', 'black')
+ .attr('stroke-width', 2);
+ }
+
+ // Position label in the center of the name box (using absolute coordinates)
+ // The name box is at (x, y - nameBoxHeight), so center the label there
+ labelEl.attr(
+ 'transform',
+ `translate(${x + nameBoxWidth / 2 - bbox.width / 2}, ${y - nameBoxHeight})`
+ );
+ } else {
+ // Draw rect-type boundary (simple dashed rectangle)
+ if (node.look === 'handDrawn') {
+ const rc = rough.svg(shapeSvg);
+ const options = userNodeOverrides(node, {
+ stroke: 'black',
+ strokeWidth: 2,
+ fill: 'none',
+ strokeLineDash: [5, 5],
+ seed: handDrawnSeed,
+ });
+
+ const roughRect = rc.rectangle(x, y, width, height, options);
+ boundaryRect = shapeSvg.insert(() => roughRect, ':first-child');
+ } else {
+ // Draw dashed rectangle
+ boundaryRect = shapeSvg
+ .insert('rect', ':first-child')
+ .attr('x', x)
+ .attr('y', y)
+ .attr('width', width)
+ .attr('height', height)
+ .attr('fill', 'none')
+ .attr('stroke', 'black')
+ .attr('stroke-width', 2)
+ .attr('stroke-dasharray', '5,5');
+ }
+
+ // Position label at top-left (using absolute coordinates, same as rect cluster)
+ labelEl.attr(
+ 'transform',
+ `translate(${node.x - bbox.width / 2}, ${node.y - node.height / 2 + subGraphTitleTopMargin})`
+ );
+ }
+
+ // Get the bounding box of the boundary rectangle
+ const rectBox = boundaryRect.node().getBBox();
+
+ // Set node properties required by layout engine (similar to rect cluster)
+ node.offsetX = 0;
+ node.width = rectBox.width;
+ node.height = rectBox.height;
+ // Used by layout engine to position subgraph in parent
+ node.offsetY = bbox.height - node.padding / 2;
+
+ // Set intersection function for edge routing
+ node.intersect = function (point) {
+ return intersectRect(node, point);
+ };
+
+ // Return cluster object
+ return {
+ cluster: shapeSvg,
+ labelBBox: bbox,
+ };
+};
+
const squareRect = rect;
const shapes = {
rect,
@@ -467,6 +634,7 @@ const shapes = {
noteGroup,
divider,
kanbanSection,
+ usecaseSystemBoundary,
};
let clusterElems = new Map();
diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts
index 2509dead4..5dc41f619 100644
--- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts
+++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts
@@ -14,6 +14,7 @@ import { curvedTrapezoid } from './shapes/curvedTrapezoid.js';
import { cylinder } from './shapes/cylinder.js';
import { dividedRectangle } from './shapes/dividedRect.js';
import { doublecircle } from './shapes/doubleCircle.js';
+import { ellipse } from './shapes/ellipse.js';
import { filledCircle } from './shapes/filledCircle.js';
import { flippedTriangle } from './shapes/flippedTriangle.js';
import { forkJoin } from './shapes/forkJoin.js';
@@ -32,6 +33,8 @@ import { lean_right } from './shapes/leanRight.js';
import { lightningBolt } from './shapes/lightningBolt.js';
import { linedCylinder } from './shapes/linedCylinder.js';
import { linedWaveEdgedRect } from './shapes/linedWaveEdgedRect.js';
+import { usecaseActor } from './shapes/usecaseActor.js';
+import { usecaseSystemBoundary } from './shapes/usecaseSystemBoundary.js';
import { multiRect } from './shapes/multiRect.js';
import { multiWaveEdgedRectangle } from './shapes/multiWaveEdgedRectangle.js';
import { note } from './shapes/note.js';
@@ -115,6 +118,14 @@ export const shapesDefs = [
aliases: ['terminal', 'pill'],
handler: stadium,
},
+ {
+ semanticName: 'Ellipse',
+ name: 'Ellipse',
+ shortName: 'ellipse',
+ description: 'Ellipse shape',
+ aliases: ['oval'],
+ handler: ellipse,
+ },
{
semanticName: 'Subprocess',
name: 'Framed Rectangle',
@@ -507,6 +518,10 @@ const generateShapeMap = () => {
// Requirement diagram
requirementBox,
+
+ // Usecase diagram
+ usecaseActor,
+ usecaseSystemBoundary,
} as const;
const entries = [
diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/ellipse.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/ellipse.ts
new file mode 100644
index 000000000..6d0028ad7
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/ellipse.ts
@@ -0,0 +1,60 @@
+import rough from 'roughjs';
+import type { Bounds, D3Selection, Point } from '../../../types.js';
+import type { Node } from '../../types.js';
+import intersect from '../intersect/index.js';
+import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
+import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js';
+
+export async function ellipse(parent: D3Selection, node: Node) {
+ const { labelStyles, nodeStyles } = styles2String(node);
+ node.labelStyle = labelStyles;
+ const { shapeSvg, bbox, halfPadding } = await labelHelper(parent, node, getNodeClasses(node));
+
+ // Calculate ellipse dimensions with padding
+ const padding = halfPadding ?? 10;
+ const radiusX = bbox.width / 2 + padding * 2;
+ const radiusY = bbox.height / 2 + padding;
+
+ let ellipseElem;
+ const { cssStyles } = node;
+
+ if (node.look === 'handDrawn') {
+ // @ts-expect-error -- Passing a D3.Selection seems to work for some reason
+ const rc = rough.svg(shapeSvg);
+ const options = userNodeOverrides(node, {});
+ const roughNode = rc.ellipse(0, 0, radiusX * 2, radiusY * 2, options);
+
+ ellipseElem = shapeSvg.insert(() => roughNode, ':first-child');
+ ellipseElem.attr('class', 'basic label-container');
+
+ if (cssStyles) {
+ ellipseElem.attr('style', cssStyles);
+ }
+ } else {
+ ellipseElem = shapeSvg
+ .insert('ellipse', ':first-child')
+ .attr('class', 'basic label-container')
+ .attr('style', nodeStyles)
+ .attr('rx', radiusX)
+ .attr('ry', radiusY)
+ .attr('cx', 0)
+ .attr('cy', 0);
+ }
+
+ node.width = radiusX * 2;
+ node.height = radiusY * 2;
+
+ updateNodeBounds(node, ellipseElem);
+
+ node.calcIntersect = function (bounds: Bounds, point: Point) {
+ const rx = bounds.width / 2;
+ const ry = bounds.height / 2;
+ return intersect.ellipse(bounds, rx, ry, point);
+ };
+
+ node.intersect = function (point) {
+ return intersect.ellipse(node, radiusX, radiusY, point);
+ };
+
+ return shapeSvg;
+}
diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts
new file mode 100644
index 000000000..0a37ac457
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts
@@ -0,0 +1,245 @@
+import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js';
+import type { Node } from '../../types.js';
+import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
+import { getIconSVG } from '../../icons.js';
+import rough from 'roughjs';
+import type { D3Selection } from '../../../types.js';
+
+/**
+ * Get actor styling based on metadata
+ */
+const getActorStyling = (metadata?: Record) => {
+ const defaults = {
+ fillColor: 'none',
+ strokeColor: 'black',
+ strokeWidth: 2,
+ type: 'solid',
+ };
+
+ if (!metadata) {
+ return defaults;
+ }
+
+ return {
+ fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor,
+ strokeColor: metadata.strokeColor || defaults.strokeColor,
+ strokeWidth: parseInt(metadata.strokeWidth || '2', 10),
+ type: metadata.type || defaults.type,
+ };
+};
+
+/**
+ * Draw traditional stick figure
+ */
+const drawStickFigure = (
+ actorGroup: D3Selection,
+ styling: ReturnType,
+ node: Node
+): void => {
+ const x = 0; // Center at origin
+ const y = -10; // Adjust vertical position
+
+ if (node.look === 'handDrawn') {
+ // @ts-expect-error -- Passing a D3.Selection seems to work for some reason
+ const rc = rough.svg(actorGroup);
+ const options = userNodeOverrides(node, {
+ stroke: styling.strokeColor,
+ strokeWidth: styling.strokeWidth,
+ fill: styling.fillColor,
+ });
+
+ // Head (circle)
+ const head = rc.circle(x, y - 30, 16, options);
+ actorGroup.insert(() => head, ':first-child');
+
+ // Body (line)
+ const body = rc.line(x, y - 22, x, y + 10, options);
+ actorGroup.insert(() => body, ':first-child');
+
+ // Arms (line)
+ const arms = rc.line(x - 15, y - 10, x + 15, y - 10, options);
+ actorGroup.insert(() => arms, ':first-child');
+
+ // Left leg
+ const leftLeg = rc.line(x, y + 10, x - 15, y + 30, options);
+ actorGroup.insert(() => leftLeg, ':first-child');
+
+ // Right leg
+ const rightLeg = rc.line(x, y + 10, x + 15, y + 30, options);
+ actorGroup.insert(() => rightLeg, ':first-child');
+ } else {
+ // Head (circle)
+ actorGroup
+ .append('circle')
+ .attr('cx', x)
+ .attr('cy', y - 30)
+ .attr('r', 8)
+ .attr('fill', styling.fillColor)
+ .attr('stroke', styling.strokeColor)
+ .attr('stroke-width', styling.strokeWidth);
+
+ // Body (line)
+ actorGroup
+ .append('line')
+ .attr('x1', x)
+ .attr('y1', y - 22)
+ .attr('x2', x)
+ .attr('y2', y + 10)
+ .attr('stroke', styling.strokeColor)
+ .attr('stroke-width', styling.strokeWidth);
+
+ // Arms (line)
+ actorGroup
+ .append('line')
+ .attr('x1', x - 15)
+ .attr('y1', y - 10)
+ .attr('x2', x + 15)
+ .attr('y2', y - 10)
+ .attr('stroke', styling.strokeColor)
+ .attr('stroke-width', styling.strokeWidth);
+
+ // Left leg
+ actorGroup
+ .append('line')
+ .attr('x1', x)
+ .attr('y1', y + 10)
+ .attr('x2', x - 15)
+ .attr('y2', y + 30)
+ .attr('stroke', styling.strokeColor)
+ .attr('stroke-width', styling.strokeWidth);
+
+ // Right leg
+ actorGroup
+ .append('line')
+ .attr('x1', x)
+ .attr('y1', y + 10)
+ .attr('x2', x + 15)
+ .attr('y2', y + 30)
+ .attr('stroke', styling.strokeColor)
+ .attr('stroke-width', styling.strokeWidth);
+ }
+};
+
+/**
+ * Draw actor with icon representation
+ */
+const drawActorWithIcon = async (
+ actorGroup: D3Selection,
+ iconName: string,
+ styling: ReturnType,
+ node: Node
+): Promise => {
+ const x = 0; // Center at origin
+ const y = -10; // Adjust vertical position
+ const iconSize = 50; // Icon size
+
+ if (node.look === 'handDrawn') {
+ // @ts-expect-error -- Passing a D3.Selection seems to work for some reason
+ const rc = rough.svg(actorGroup);
+ const options = userNodeOverrides(node, {
+ stroke: styling.strokeColor,
+ strokeWidth: styling.strokeWidth,
+ fill: styling.fillColor === 'none' ? 'white' : styling.fillColor,
+ });
+
+ // Create a rectangle background for the icon
+ const iconBg = rc.rectangle(x - 35, y - 40, 50, 50, options);
+ actorGroup.insert(() => iconBg, ':first-child');
+ } else {
+ // Create a rectangle background for the icon
+ actorGroup
+ .append('rect')
+ .attr('x', x - 27.5)
+ .attr('y', y - 42)
+ .attr('width', 55)
+ .attr('height', 55)
+ .attr('rx', 5)
+ .attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor)
+ .attr('stroke', styling.strokeColor)
+ .attr('stroke-width', styling.strokeWidth);
+ }
+
+ // Add icon using getIconSVG (like iconCircle.ts does)
+ const iconElem = actorGroup.append('g').attr('class', 'actor-icon');
+ iconElem.html(
+ `${await getIconSVG(iconName, {
+ height: iconSize,
+ width: iconSize,
+ fallbackPrefix: 'fa',
+ })}`
+ );
+
+ // Get icon bounding box for positioning
+ const iconBBox = iconElem.node()?.getBBox();
+ if (iconBBox) {
+ const iconWidth = iconBBox.width;
+ const iconHeight = iconBBox.height;
+ const iconX = iconBBox.x;
+ const iconY = iconBBox.y;
+
+ // Center the icon in the rectangle
+ iconElem.attr(
+ 'transform',
+ `translate(${-iconWidth / 2 - iconX}, ${y - 15 - iconHeight / 2 - iconY})`
+ );
+ }
+};
+
+/**
+ * Custom shape handler for usecase actors
+ */
+export async function usecaseActor(
+ parent: D3Selection,
+ node: Node
+) {
+ const { labelStyles } = styles2String(node);
+ node.labelStyle = labelStyles;
+ const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node));
+
+ // Actor dimensions
+ const actorWidth = 80;
+ const actorHeight = 70; // Height for the stick figure part
+
+ // Get actor metadata from node
+ const metadata = (node as Node & { metadata?: Record }).metadata;
+ const styling = getActorStyling(metadata);
+
+ // Create actor group
+ const actorGroup = shapeSvg.append('g').attr('class', 'usecase-actor-shape');
+
+ // Add metadata as data attributes for CSS styling
+ if (metadata) {
+ Object.entries(metadata).forEach(([key, value]) => {
+ actorGroup.attr(`data-${key}`, value);
+ });
+ }
+
+ // Check if we should render an icon instead of stick figure
+ if (metadata?.icon) {
+ await drawActorWithIcon(actorGroup, metadata.icon, styling, node);
+ } else {
+ drawStickFigure(actorGroup, styling, node);
+ }
+
+ // Actor name (always rendered below the figure)
+ const labelY = actorHeight / 2 + 15; // Position label below the figure
+
+ // Calculate label height from the actual text element
+
+ const labelBBox = label.node()?.getBBox() ?? { height: 20 };
+ const labelHeight = labelBBox.height + 10; // Space for label below
+ const totalHeight = actorHeight + labelHeight;
+ label.attr(
+ 'transform',
+ `translate(${-bbox.width / 2 - (bbox.x - (bbox.left ?? 0))},${labelY / 2})`
+ );
+
+ // Update node bounds for layout
+ updateNodeBounds(node, actorGroup);
+
+ // Set explicit dimensions for layout algorithm
+ node.width = actorWidth;
+ node.height = totalHeight;
+
+ return shapeSvg;
+}
diff --git a/packages/parser/src/language/usecase/Usecase.g4 b/packages/parser/src/language/usecase/Usecase.g4
index 3a477191d..3b54fc0c5 100644
--- a/packages/parser/src/language/usecase/Usecase.g4
+++ b/packages/parser/src/language/usecase/Usecase.g4
@@ -10,6 +10,7 @@ statement
| relationshipStatement
| systemBoundaryStatement
| systemBoundaryTypeStatement
+ | directionStatement
| NEWLINE
;
@@ -119,6 +120,18 @@ edgeLabel
| STRING
;
+directionStatement
+ : 'direction' direction NEWLINE*
+ ;
+
+direction
+ : 'TB'
+ | 'TD'
+ | 'BT'
+ | 'RL'
+ | 'LR'
+ ;
+
// Lexer rules
SOLID_ARROW
: '-->'
diff --git a/packages/parser/src/language/usecase/types.ts b/packages/parser/src/language/usecase/types.ts
index bef197ff0..31b8b4119 100644
--- a/packages/parser/src/language/usecase/types.ts
+++ b/packages/parser/src/language/usecase/types.ts
@@ -49,6 +49,7 @@ export interface UsecaseParseResult {
useCases: UseCase[];
systemBoundaries: SystemBoundary[];
relationships: Relationship[];
+ direction?: string;
accDescr?: string;
accTitle?: string;
title?: string;
diff --git a/packages/parser/src/language/usecase/visitor.ts b/packages/parser/src/language/usecase/visitor.ts
index dc1c21d4e..7d92769a7 100644
--- a/packages/parser/src/language/usecase/visitor.ts
+++ b/packages/parser/src/language/usecase/visitor.ts
@@ -30,6 +30,8 @@ import type {
ArrowContext,
LabeledArrowContext,
EdgeLabelContext,
+ DirectionStatementContext,
+ DirectionContext,
} from './generated/UsecaseParser.js';
import { ARROW_TYPE } from './types.js';
import type {
@@ -47,6 +49,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor {
private systemBoundaries: SystemBoundary[] = [];
private relationships: Relationship[] = [];
private relationshipCounter = 0;
+ private direction = 'TB'; // Default direction
constructor() {
super();
@@ -58,6 +61,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor {
this.visitRelationshipStatement = this.visitRelationshipStatementImpl.bind(this);
this.visitSystemBoundaryStatement = this.visitSystemBoundaryStatementImpl.bind(this);
this.visitSystemBoundaryTypeStatement = this.visitSystemBoundaryTypeStatementImpl.bind(this);
+ this.visitDirectionStatement = this.visitDirectionStatementImpl.bind(this);
this.visitActorName = this.visitActorNameImpl.bind(this);
this.visitArrow = this.visitArrowImpl.bind(this);
}
@@ -72,6 +76,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor {
this.useCases = [];
this.relationships = [];
this.relationshipCounter = 0;
+ this.direction = 'TB'; // Reset direction to default
// Visit all statement children
if (ctx.statement) {
@@ -90,7 +95,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor {
/**
* Visit statement rule
- * Grammar: statement : actorStatement | relationshipStatement | systemBoundaryStatement | systemBoundaryTypeStatement | NEWLINE ;
+ * Grammar: statement : actorStatement | relationshipStatement | systemBoundaryStatement | systemBoundaryTypeStatement | directionStatement | NEWLINE ;
*/
private visitStatementImpl(ctx: StatementContext): void {
if (ctx.actorStatement?.()) {
@@ -101,6 +106,8 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor {
this.visitSystemBoundaryStatementImpl(ctx.systemBoundaryStatement()!);
} else if (ctx.systemBoundaryTypeStatement?.()) {
this.visitSystemBoundaryTypeStatementImpl(ctx.systemBoundaryTypeStatement()!);
+ } else if (ctx.directionStatement?.()) {
+ this.visitDirectionStatementImpl(ctx.directionStatement()!);
}
// NEWLINE is ignored
}
@@ -591,6 +598,30 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor {
return text;
}
+ /**
+ * Visit directionStatement rule
+ * Grammar: directionStatement : 'direction' direction NEWLINE* ;
+ */
+ visitDirectionStatementImpl(ctx: DirectionStatementContext): void {
+ const directionCtx = ctx.direction?.();
+ if (directionCtx) {
+ this.direction = this.visitDirectionImpl(directionCtx);
+ }
+ }
+
+ /**
+ * Visit direction rule
+ * Grammar: direction : 'TB' | 'TD' | 'BT' | 'RL' | 'LR' ;
+ */
+ private visitDirectionImpl(ctx: DirectionContext): string {
+ const text = ctx.getText();
+ // Normalize TD to TB (same as flowchart)
+ if (text === 'TD') {
+ return 'TB';
+ }
+ return text;
+ }
+
/**
* Get the parse result after visiting the diagram
*/
@@ -600,6 +631,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor {
useCases: this.useCases,
systemBoundaries: this.systemBoundaries,
relationships: this.relationships,
+ direction: this.direction,
};
}
}