feat(usecase): add direction support and custom shapes for usecase diagrams

on-behalf-of: @Mermaid-Chart hello@mermaidchart.com
This commit is contained in:
omkarht
2025-10-08 18:25:58 +05:30
parent dc9bfa712d
commit 6c8cfa6b55
13 changed files with 992 additions and 532 deletions

234
demos/usecase.html Normal file
View File

@@ -0,0 +1,234 @@
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Montserrat&display=swap" rel="stylesheet" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<link
href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
rel="stylesheet"
/>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Noto+Sans+SC&display=swap"
rel="stylesheet"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400..700&family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Recursive:wght@300..1000&display=swap"
rel="stylesheet"
/>
<style>
.recursive-500 {
font-family: 'Recursive', serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
font-variation-settings:
'slnt' 0,
'CASL' 0,
'CRSV' 0.5,
'MONO' 0;
}
body {
/* background: rgb(221, 208, 208); */
/* background: #333; */
/* font-family: 'Arial'; */
font-family: 'Recursive', serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
font-variation-settings:
'slnt' 0,
'CASL' 0,
'CRSV' 0.5,
'MONO' 0;
/* color: white; */
/* font-size: 18px !important; */
}
.gridify.tiny {
background-image:
linear-gradient(transparent 11px, rgba(220, 220, 200, 0.8) 12px, transparent 12px),
linear-gradient(90deg, transparent 11px, rgba(220, 220, 200, 0.8) 12px, transparent 12px);
background-size:
100% 12px,
12px 100%;
}
.gridify.dots {
background-image: radial-gradient(
circle at center,
rgba(220, 220, 200, 0.8) 1px,
transparent 1px
);
background-size: 24px 24px;
}
h1 {
color: grey;
}
.mermaid2 {
display: none;
}
.mermaid svg {
font-size: 16px !important;
font-family: 'Recursive', serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
font-variation-settings:
'slnt' 0,
'CASL' 0,
'CRSV' 0.5,
'MONO' 0;
}
pre {
width: 100%;
/*box-shadow: 4px 4px 0px 0px #0000000F;*/
}
</style>
</head>
<body class="gridify dots">
<p class="mb-20">Test Diagram</p>
<div class="w-full h-64">
<pre id="diagram4" class="mermaid">
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
</pre
>
</div>
<script type="module">
import mermaid from './mermaid.esm.mjs';
import layouts from './mermaid-layout-elk.esm.mjs';
const staticBellIconPack = {
prefix: 'fa6-regular',
icons: {
bell: {
body: '<path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32v19.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416h400c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6c-28.3-35.5-43.8-79.6-43.8-125V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32m0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3c25.8-40 39.7-86.7 39.7-134.6V208c0-61.9 50.1-112 112-112m64 352H160c0 17 6.7 33.3 18.7 45.3S207 512 224 512s33.3-6.7 45.3-18.7S288 465 288 448"/>',
width: 448,
},
},
width: 512,
height: 512,
};
mermaid.registerIconPacks([
{
name: 'logos',
loader: () =>
fetch('https://unpkg.com/@iconify-json/logos@1/icons.json').then((res) => res.json()),
},
{
name: 'fa',
loader: () =>
fetch('https://unpkg.com/@iconify-json/fa6-solid/icons.json').then((res) => res.json()),
},
]);
mermaid.registerLayoutLoaders(layouts);
mermaid.parseError = function (err, hash) {
console.error('Mermaid error: ', err);
};
window.callback = function () {
alert('A callback was triggered');
};
function callback() {
alert('It worked');
}
await mermaid.initialize({
startOnLoad: false,
theme: 'default',
// theme: 'forest',
// handDrawnSeed: 12,
// 'elk.nodePlacement.strategy': 'NETWORK_SIMPLEX',
layout: 'elk',
// layout: 'elk',
// layout: 'fixed',
// htmlLabels: false,
flowchart: { titleTopMargin: 10 },
// fontFamily: 'Caveat',
// fontFamily: 'Kalam',
// fontFamily: 'courier',
fontFamily: 'Recursive',
sequence: {
actorFontFamily: 'courier',
noteFontFamily: 'courier',
messageFontFamily: 'courier',
},
kanban: {
htmlLabels: false,
},
fontSize: 16,
logLevel: 0,
securityLevel: 'loose',
callback,
});
// setTimeout(() => {
mermaid.init(undefined, document.querySelectorAll('.mermaid'));
// }, 1000);
mermaid.parseError = function (err, hash) {
console.error('In parse error:');
console.error(err);
};
</script>
</body>
</html>

View File

@@ -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;

View File

@@ -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<UsecaseDiagramConfig> = DEFAULT_CONFIG.usecase;
@@ -27,6 +32,7 @@ export const DEFAULT_USECASE_DB: RequiredDeep<UsecaseFields> = {
useCases: new Map(),
systemBoundaries: new Map(),
relationships: [],
direction: DEFAULT_DIRECTION,
config: DEFAULT_USECASE_CONFIG,
} as const;
@@ -34,6 +40,7 @@ let actors = new Map<string, Actor>();
let useCases = new Map<string, UseCase>();
let systemBoundaries = new Map<string, SystemBoundary>();
let relationships: Relationship[] = [];
let direction: Direction = DEFAULT_DIRECTION;
const config: Required<UsecaseDiagramConfig> = structuredClone(DEFAULT_USECASE_CONFIG);
const getConfig = (): Required<UsecaseDiagramConfig> => 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,
};

View File

@@ -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,
});
};

View File

@@ -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<string, string>) => {
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<typeof getActorStyling>
): 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<typeof getActorStyling>
): 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<string, string> = {
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<string, { x: number; y: number }>
): 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<void> => {
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<string, { x: number; y: number }>();
const useCasePositions = new Map<string, { x: number; y: number }>();
// 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 };

View File

@@ -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<string, string>;
@@ -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<string, Actor>;
useCases: Map<string, UseCase>;
systemBoundaries: Map<string, SystemBoundary>;
relationships: Relationship[];
direction: Direction;
config: Required<UsecaseDiagramConfig>;
}
@@ -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;
}

View File

@@ -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();

View File

@@ -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 = [

View File

@@ -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<T extends SVGGraphicsElement>(parent: D3Selection<T>, 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;
}

View File

@@ -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<string, string>) => {
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<SVGGElement>,
styling: ReturnType<typeof getActorStyling>,
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<SVGGElement>,
iconName: string,
styling: ReturnType<typeof getActorStyling>,
node: Node
): Promise<void> => {
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(
`<g>${await getIconSVG(iconName, {
height: iconSize,
width: iconSize,
fallbackPrefix: 'fa',
})}</g>`
);
// 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<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
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<string, string> }).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;
}

View File

@@ -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
: '-->'

View File

@@ -49,6 +49,7 @@ export interface UsecaseParseResult {
useCases: UseCase[];
systemBoundaries: SystemBoundary[];
relationships: Relationship[];
direction?: string;
accDescr?: string;
accTitle?: string;
title?: string;

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
/**
* 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<void> {
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<void> {
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<void> {
useCases: this.useCases,
systemBoundaries: this.systemBoundaries,
relationships: this.relationships,
direction: this.direction,
};
}
}