diff --git a/demos/usecase.html b/demos/usecase.html new file mode 100644 index 000000000..2fcc4f091 --- /dev/null +++ b/demos/usecase.html @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + +

Test Diagram

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