mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-10-12 18:49: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>
|
@@ -24,9 +24,37 @@ const getStyles = (options: any) =>
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.relationship {
|
/* Ellipse shape styling for use cases */
|
||||||
|
.usecase-element ellipse {
|
||||||
|
fill: ${options.mainBkg ?? '#ffffff'};
|
||||||
stroke: ${options.primaryColor};
|
stroke: ${options.primaryColor};
|
||||||
fill: ${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.lineColor};
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .marker {
|
||||||
|
fill: ${options.lineColor};
|
||||||
|
stroke: ${options.lineColor};
|
||||||
}
|
}
|
||||||
|
|
||||||
.relationship-label {
|
.relationship-label {
|
||||||
@@ -35,6 +63,15 @@ const getStyles = (options: any) =>
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodeLabel, .edgeLabel {
|
||||||
|
color: ${options.classText};
|
||||||
|
}
|
||||||
|
.system-boundary {
|
||||||
|
fill: ${options.clusterBkg};
|
||||||
|
stroke: ${options.clusterBorder};
|
||||||
|
stroke-width: 1px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default getStyles;
|
export default getStyles;
|
||||||
|
@@ -15,10 +15,15 @@ import type {
|
|||||||
UseCase,
|
UseCase,
|
||||||
SystemBoundary,
|
SystemBoundary,
|
||||||
Relationship,
|
Relationship,
|
||||||
|
ActorMetadata,
|
||||||
|
Direction,
|
||||||
} from './usecaseTypes.js';
|
} from './usecaseTypes.js';
|
||||||
|
import { DEFAULT_DIRECTION } from './usecaseTypes.js';
|
||||||
import type { RequiredDeep } from 'type-fest';
|
import type { RequiredDeep } from 'type-fest';
|
||||||
import type { UsecaseDiagramConfig } from '../../config.type.js';
|
import type { UsecaseDiagramConfig } from '../../config.type.js';
|
||||||
import DEFAULT_CONFIG from '../../defaultConfig.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;
|
export const DEFAULT_USECASE_CONFIG: Required<UsecaseDiagramConfig> = DEFAULT_CONFIG.usecase;
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ export const DEFAULT_USECASE_DB: RequiredDeep<UsecaseFields> = {
|
|||||||
useCases: new Map(),
|
useCases: new Map(),
|
||||||
systemBoundaries: new Map(),
|
systemBoundaries: new Map(),
|
||||||
relationships: [],
|
relationships: [],
|
||||||
|
direction: DEFAULT_DIRECTION,
|
||||||
config: DEFAULT_USECASE_CONFIG,
|
config: DEFAULT_USECASE_CONFIG,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -34,6 +40,7 @@ let actors = new Map<string, Actor>();
|
|||||||
let useCases = new Map<string, UseCase>();
|
let useCases = new Map<string, UseCase>();
|
||||||
let systemBoundaries = new Map<string, SystemBoundary>();
|
let systemBoundaries = new Map<string, SystemBoundary>();
|
||||||
let relationships: Relationship[] = [];
|
let relationships: Relationship[] = [];
|
||||||
|
let direction: Direction = DEFAULT_DIRECTION;
|
||||||
const config: Required<UsecaseDiagramConfig> = structuredClone(DEFAULT_USECASE_CONFIG);
|
const config: Required<UsecaseDiagramConfig> = structuredClone(DEFAULT_USECASE_CONFIG);
|
||||||
|
|
||||||
const getConfig = (): Required<UsecaseDiagramConfig> => structuredClone(config);
|
const getConfig = (): Required<UsecaseDiagramConfig> => structuredClone(config);
|
||||||
@@ -43,6 +50,7 @@ const clear = (): void => {
|
|||||||
useCases = new Map();
|
useCases = new Map();
|
||||||
systemBoundaries = new Map();
|
systemBoundaries = new Map();
|
||||||
relationships = [];
|
relationships = [];
|
||||||
|
direction = DEFAULT_DIRECTION;
|
||||||
commonClear();
|
commonClear();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,6 +155,122 @@ const addRelationship = (relationship: Relationship): void => {
|
|||||||
|
|
||||||
const getRelationships = (): Relationship[] => relationships;
|
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 = {
|
export const db: UsecaseDB = {
|
||||||
getConfig,
|
getConfig,
|
||||||
|
|
||||||
@@ -172,4 +296,11 @@ export const db: UsecaseDB = {
|
|||||||
|
|
||||||
addRelationship,
|
addRelationship,
|
||||||
getRelationships,
|
getRelationships,
|
||||||
|
|
||||||
|
// Direction management
|
||||||
|
setDirection,
|
||||||
|
getDirection,
|
||||||
|
|
||||||
|
// Add getData method for unified rendering
|
||||||
|
getData,
|
||||||
};
|
};
|
||||||
|
@@ -26,6 +26,7 @@ interface UsecaseParseResult {
|
|||||||
arrowType: number;
|
arrowType: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
}[];
|
}[];
|
||||||
|
direction?: string;
|
||||||
accDescr?: string;
|
accDescr?: string;
|
||||||
accTitle?: string;
|
accTitle?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -94,10 +95,16 @@ const populateDb = (ast: UsecaseParseResult, db: UsecaseDB) => {
|
|||||||
db.addRelationship(relationship);
|
db.addRelationship(relationship);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set direction if provided
|
||||||
|
if (ast.direction) {
|
||||||
|
db.setDirection(ast.direction as any);
|
||||||
|
}
|
||||||
|
|
||||||
log.debug('Populated usecase database:', {
|
log.debug('Populated usecase database:', {
|
||||||
actors: ast.actors.length,
|
actors: ast.actors.length,
|
||||||
useCases: ast.useCases.length,
|
useCases: ast.useCases.length,
|
||||||
relationships: ast.relationships.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 { 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 { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js';
|
||||||
import { getConfig } from '../../diagram-api/diagramAPI.js';
|
import { getConfig } from '../../diagram-api/diagramAPI.js';
|
||||||
import {
|
import utils from '../../utils.js';
|
||||||
insertEdgeLabel,
|
import type { UsecaseDB } from './usecaseTypes.js';
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get actor styling based on metadata
|
* Main draw function using unified rendering system
|
||||||
*/
|
*/
|
||||||
const getActorStyling = (metadata?: Record<string, string>) => {
|
const draw: DrawDefinition = async (_text, id, _version, diag) => {
|
||||||
const defaults = {
|
log.info('Drawing usecase diagram (unified)', id);
|
||||||
fillColor: 'none',
|
const { securityLevel, usecase: conf, layout } = getConfig();
|
||||||
strokeColor: 'black',
|
|
||||||
strokeWidth: 2,
|
|
||||||
type: 'solid',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!metadata) {
|
// The getData method provided in all supported diagrams is used to extract the data from the parsed structure
|
||||||
return defaults;
|
// into the Layout data format
|
||||||
}
|
const usecaseDb = diag.db as UsecaseDB;
|
||||||
|
const data4Layout = usecaseDb.getData();
|
||||||
|
|
||||||
return {
|
// Create the root SVG - the element is the div containing the SVG element
|
||||||
fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor,
|
const svg = getDiagramElement(id, securityLevel);
|
||||||
strokeColor: metadata.strokeColor || defaults.strokeColor,
|
|
||||||
strokeWidth: parseInt(metadata.strokeWidth || '2', 10),
|
|
||||||
type: metadata.type || defaults.type,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
data4Layout.type = diag.type;
|
||||||
* Draw an actor (stick figure) with metadata support
|
data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout);
|
||||||
*/
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Add metadata as data attributes for CSS styling
|
data4Layout.nodeSpacing = 50; // Default node spacing
|
||||||
if (actor.metadata) {
|
data4Layout.rankSpacing = 50; // Default rank spacing
|
||||||
Object.entries(actor.metadata).forEach(([key, value]) => {
|
data4Layout.markers = ['point']; // Use point markers for usecase diagrams
|
||||||
actorGroup.attr(`data-${key}`, value);
|
data4Layout.diagramId = id;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should render an icon instead of stick figure
|
log.debug('Usecase layout data:', data4Layout);
|
||||||
if (actor.metadata?.icon) {
|
|
||||||
drawActorWithIcon(actorGroup, actor, x, y, styling);
|
|
||||||
} else {
|
|
||||||
drawStickFigure(actorGroup, actor, x, y, styling);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actor name (always rendered)
|
// Use the unified rendering system
|
||||||
const displayName = actor.metadata?.name ?? actor.name;
|
await render(data4Layout, svg);
|
||||||
actorGroup
|
|
||||||
.append('text')
|
|
||||||
.attr('x', x)
|
|
||||||
.attr('y', y + 50)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('class', 'actor-label')
|
|
||||||
.text(displayName);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const padding = 8;
|
||||||
* Draw traditional stick figure
|
utils.insertTitle(
|
||||||
*/
|
svg,
|
||||||
const drawStickFigure = (
|
'usecaseDiagramTitleText',
|
||||||
actorGroup: SVGGroup,
|
0, // Default title top margin
|
||||||
_actor: Actor,
|
usecaseDb.getDiagramTitle?.() ?? ''
|
||||||
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
|
setupViewPortForSVG(svg, padding, 'usecaseDiagram', conf?.useMaxWidth ?? false);
|
||||||
.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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const renderer = { draw };
|
export const renderer = { draw };
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import type { DiagramDB } from '../../diagram-api/types.js';
|
import type { DiagramDB } from '../../diagram-api/types.js';
|
||||||
import type { UsecaseDiagramConfig } from '../../config.type.js';
|
import type { UsecaseDiagramConfig } from '../../config.type.js';
|
||||||
|
import type { LayoutData } from '../../rendering-util/types.js';
|
||||||
|
|
||||||
export type ActorMetadata = Record<string, string>;
|
export type ActorMetadata = Record<string, string>;
|
||||||
|
|
||||||
@@ -45,11 +46,17 @@ export interface Relationship {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direction types for usecase diagrams
|
||||||
|
export type Direction = 'TB' | 'TD' | 'BT' | 'RL' | 'LR';
|
||||||
|
|
||||||
|
export const DEFAULT_DIRECTION: Direction = 'TB';
|
||||||
|
|
||||||
export interface UsecaseFields {
|
export interface UsecaseFields {
|
||||||
actors: Map<string, Actor>;
|
actors: Map<string, Actor>;
|
||||||
useCases: Map<string, UseCase>;
|
useCases: Map<string, UseCase>;
|
||||||
systemBoundaries: Map<string, SystemBoundary>;
|
systemBoundaries: Map<string, SystemBoundary>;
|
||||||
relationships: Relationship[];
|
relationships: Relationship[];
|
||||||
|
direction: Direction;
|
||||||
config: Required<UsecaseDiagramConfig>;
|
config: Required<UsecaseDiagramConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +82,13 @@ export interface UsecaseDB extends DiagramDB {
|
|||||||
addRelationship: (relationship: Relationship) => void;
|
addRelationship: (relationship: Relationship) => void;
|
||||||
getRelationships: () => Relationship[];
|
getRelationships: () => Relationship[];
|
||||||
|
|
||||||
|
// Direction management
|
||||||
|
setDirection: (direction: Direction) => void;
|
||||||
|
getDirection: () => Direction;
|
||||||
|
|
||||||
|
// Unified rendering support
|
||||||
|
getData: () => LayoutData;
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
@@ -459,6 +459,173 @@ const divider = (parent, node) => {
|
|||||||
return { cluster: shapeSvg, labelBBox: {} };
|
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 squareRect = rect;
|
||||||
const shapes = {
|
const shapes = {
|
||||||
rect,
|
rect,
|
||||||
@@ -467,6 +634,7 @@ const shapes = {
|
|||||||
noteGroup,
|
noteGroup,
|
||||||
divider,
|
divider,
|
||||||
kanbanSection,
|
kanbanSection,
|
||||||
|
usecaseSystemBoundary,
|
||||||
};
|
};
|
||||||
|
|
||||||
let clusterElems = new Map();
|
let clusterElems = new Map();
|
||||||
|
@@ -14,6 +14,7 @@ import { curvedTrapezoid } from './shapes/curvedTrapezoid.js';
|
|||||||
import { cylinder } from './shapes/cylinder.js';
|
import { cylinder } from './shapes/cylinder.js';
|
||||||
import { dividedRectangle } from './shapes/dividedRect.js';
|
import { dividedRectangle } from './shapes/dividedRect.js';
|
||||||
import { doublecircle } from './shapes/doubleCircle.js';
|
import { doublecircle } from './shapes/doubleCircle.js';
|
||||||
|
import { ellipse } from './shapes/ellipse.js';
|
||||||
import { filledCircle } from './shapes/filledCircle.js';
|
import { filledCircle } from './shapes/filledCircle.js';
|
||||||
import { flippedTriangle } from './shapes/flippedTriangle.js';
|
import { flippedTriangle } from './shapes/flippedTriangle.js';
|
||||||
import { forkJoin } from './shapes/forkJoin.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 { lightningBolt } from './shapes/lightningBolt.js';
|
||||||
import { linedCylinder } from './shapes/linedCylinder.js';
|
import { linedCylinder } from './shapes/linedCylinder.js';
|
||||||
import { linedWaveEdgedRect } from './shapes/linedWaveEdgedRect.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 { multiRect } from './shapes/multiRect.js';
|
||||||
import { multiWaveEdgedRectangle } from './shapes/multiWaveEdgedRectangle.js';
|
import { multiWaveEdgedRectangle } from './shapes/multiWaveEdgedRectangle.js';
|
||||||
import { note } from './shapes/note.js';
|
import { note } from './shapes/note.js';
|
||||||
@@ -115,6 +118,14 @@ export const shapesDefs = [
|
|||||||
aliases: ['terminal', 'pill'],
|
aliases: ['terminal', 'pill'],
|
||||||
handler: stadium,
|
handler: stadium,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
semanticName: 'Ellipse',
|
||||||
|
name: 'Ellipse',
|
||||||
|
shortName: 'ellipse',
|
||||||
|
description: 'Ellipse shape',
|
||||||
|
aliases: ['oval'],
|
||||||
|
handler: ellipse,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
semanticName: 'Subprocess',
|
semanticName: 'Subprocess',
|
||||||
name: 'Framed Rectangle',
|
name: 'Framed Rectangle',
|
||||||
@@ -507,6 +518,10 @@ const generateShapeMap = () => {
|
|||||||
|
|
||||||
// Requirement diagram
|
// Requirement diagram
|
||||||
requirementBox,
|
requirementBox,
|
||||||
|
|
||||||
|
// Usecase diagram
|
||||||
|
usecaseActor,
|
||||||
|
usecaseSystemBoundary,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const entries = [
|
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
|
| relationshipStatement
|
||||||
| systemBoundaryStatement
|
| systemBoundaryStatement
|
||||||
| systemBoundaryTypeStatement
|
| systemBoundaryTypeStatement
|
||||||
|
| directionStatement
|
||||||
| NEWLINE
|
| NEWLINE
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -119,6 +120,18 @@ edgeLabel
|
|||||||
| STRING
|
| STRING
|
||||||
;
|
;
|
||||||
|
|
||||||
|
directionStatement
|
||||||
|
: 'direction' direction NEWLINE*
|
||||||
|
;
|
||||||
|
|
||||||
|
direction
|
||||||
|
: 'TB'
|
||||||
|
| 'TD'
|
||||||
|
| 'BT'
|
||||||
|
| 'RL'
|
||||||
|
| 'LR'
|
||||||
|
;
|
||||||
|
|
||||||
// Lexer rules
|
// Lexer rules
|
||||||
SOLID_ARROW
|
SOLID_ARROW
|
||||||
: '-->'
|
: '-->'
|
||||||
|
@@ -49,6 +49,7 @@ export interface UsecaseParseResult {
|
|||||||
useCases: UseCase[];
|
useCases: UseCase[];
|
||||||
systemBoundaries: SystemBoundary[];
|
systemBoundaries: SystemBoundary[];
|
||||||
relationships: Relationship[];
|
relationships: Relationship[];
|
||||||
|
direction?: string;
|
||||||
accDescr?: string;
|
accDescr?: string;
|
||||||
accTitle?: string;
|
accTitle?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
@@ -30,6 +30,8 @@ import type {
|
|||||||
ArrowContext,
|
ArrowContext,
|
||||||
LabeledArrowContext,
|
LabeledArrowContext,
|
||||||
EdgeLabelContext,
|
EdgeLabelContext,
|
||||||
|
DirectionStatementContext,
|
||||||
|
DirectionContext,
|
||||||
} from './generated/UsecaseParser.js';
|
} from './generated/UsecaseParser.js';
|
||||||
import { ARROW_TYPE } from './types.js';
|
import { ARROW_TYPE } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -47,6 +49,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
|||||||
private systemBoundaries: SystemBoundary[] = [];
|
private systemBoundaries: SystemBoundary[] = [];
|
||||||
private relationships: Relationship[] = [];
|
private relationships: Relationship[] = [];
|
||||||
private relationshipCounter = 0;
|
private relationshipCounter = 0;
|
||||||
|
private direction = 'TB'; // Default direction
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -58,6 +61,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
|||||||
this.visitRelationshipStatement = this.visitRelationshipStatementImpl.bind(this);
|
this.visitRelationshipStatement = this.visitRelationshipStatementImpl.bind(this);
|
||||||
this.visitSystemBoundaryStatement = this.visitSystemBoundaryStatementImpl.bind(this);
|
this.visitSystemBoundaryStatement = this.visitSystemBoundaryStatementImpl.bind(this);
|
||||||
this.visitSystemBoundaryTypeStatement = this.visitSystemBoundaryTypeStatementImpl.bind(this);
|
this.visitSystemBoundaryTypeStatement = this.visitSystemBoundaryTypeStatementImpl.bind(this);
|
||||||
|
this.visitDirectionStatement = this.visitDirectionStatementImpl.bind(this);
|
||||||
this.visitActorName = this.visitActorNameImpl.bind(this);
|
this.visitActorName = this.visitActorNameImpl.bind(this);
|
||||||
this.visitArrow = this.visitArrowImpl.bind(this);
|
this.visitArrow = this.visitArrowImpl.bind(this);
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
|||||||
this.useCases = [];
|
this.useCases = [];
|
||||||
this.relationships = [];
|
this.relationships = [];
|
||||||
this.relationshipCounter = 0;
|
this.relationshipCounter = 0;
|
||||||
|
this.direction = 'TB'; // Reset direction to default
|
||||||
|
|
||||||
// Visit all statement children
|
// Visit all statement children
|
||||||
if (ctx.statement) {
|
if (ctx.statement) {
|
||||||
@@ -90,7 +95,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Visit statement rule
|
* Visit statement rule
|
||||||
* Grammar: statement : actorStatement | relationshipStatement | systemBoundaryStatement | systemBoundaryTypeStatement | NEWLINE ;
|
* Grammar: statement : actorStatement | relationshipStatement | systemBoundaryStatement | systemBoundaryTypeStatement | directionStatement | NEWLINE ;
|
||||||
*/
|
*/
|
||||||
private visitStatementImpl(ctx: StatementContext): void {
|
private visitStatementImpl(ctx: StatementContext): void {
|
||||||
if (ctx.actorStatement?.()) {
|
if (ctx.actorStatement?.()) {
|
||||||
@@ -101,6 +106,8 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
|||||||
this.visitSystemBoundaryStatementImpl(ctx.systemBoundaryStatement()!);
|
this.visitSystemBoundaryStatementImpl(ctx.systemBoundaryStatement()!);
|
||||||
} else if (ctx.systemBoundaryTypeStatement?.()) {
|
} else if (ctx.systemBoundaryTypeStatement?.()) {
|
||||||
this.visitSystemBoundaryTypeStatementImpl(ctx.systemBoundaryTypeStatement()!);
|
this.visitSystemBoundaryTypeStatementImpl(ctx.systemBoundaryTypeStatement()!);
|
||||||
|
} else if (ctx.directionStatement?.()) {
|
||||||
|
this.visitDirectionStatementImpl(ctx.directionStatement()!);
|
||||||
}
|
}
|
||||||
// NEWLINE is ignored
|
// NEWLINE is ignored
|
||||||
}
|
}
|
||||||
@@ -591,6 +598,30 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
|||||||
return text;
|
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
|
* Get the parse result after visiting the diagram
|
||||||
*/
|
*/
|
||||||
@@ -600,6 +631,7 @@ export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
|||||||
useCases: this.useCases,
|
useCases: this.useCases,
|
||||||
systemBoundaries: this.systemBoundaries,
|
systemBoundaries: this.systemBoundaries,
|
||||||
relationships: this.relationships,
|
relationships: this.relationships,
|
||||||
|
direction: this.direction,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user