mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-10-09 00:59:37 +02:00
feat(usecase): add direction support and custom shapes for usecase diagrams
on-behalf-of: @Mermaid-Chart hello@mermaidchart.com
This commit is contained in:
234
demos/usecase.html
Normal file
234
demos/usecase.html
Normal 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>
|
@@ -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;
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
@@ -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 };
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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 = [
|
||||
|
@@ -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;
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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
|
||||
: '-->'
|
||||
|
@@ -49,6 +49,7 @@ export interface UsecaseParseResult {
|
||||
useCases: UseCase[];
|
||||
systemBoundaries: SystemBoundary[];
|
||||
relationships: Relationship[];
|
||||
direction?: string;
|
||||
accDescr?: string;
|
||||
accTitle?: string;
|
||||
title?: string;
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user