-
+
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/FragmentOpt.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/FragmentOpt.tsx
index 159a43b7..4fc2f0ca 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/FragmentOpt.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/FragmentOpt.tsx
@@ -6,6 +6,7 @@ import { CollapseButton } from "./CollapseButton";
import { Block } from "../../Block";
import { cn } from "@/utils";
import Icon from "@/components/Icon/Icons";
+import { FragmentLayout } from "@/domain/models/DiagramLayout";
export const FragmentOpt = (props: {
context: any;
@@ -14,6 +15,7 @@ export const FragmentOpt = (props: {
commentObj?: CommentClass;
number?: string;
className?: string;
+ layoutData?: FragmentLayout;
}) => {
const opt = props.context.opt();
const {
@@ -24,17 +26,42 @@ export const FragmentOpt = (props: {
border,
leftParticipant,
} = useFragmentData(props.context, props.origin);
+
+ // Determine if using new or old architecture
+ const isNewArchitecture = !!props.layoutData;
+
+ // Extract data based on architecture
+ const data = isNewArchitecture
+ ? {
+ collapsed: props.layoutData!.collapsed,
+ toggleCollapse: () => {}, // TODO: Implement collapse functionality in new architecture
+ paddingLeft: props.layoutData!.paddingLeft,
+ fragmentStyle: props.layoutData!.fragmentStyle,
+ border: props.layoutData!.border,
+ leftParticipant: props.layoutData!.leftParticipant,
+ block: props.layoutData!.block,
+ }
+ : {
+ collapsed,
+ toggleCollapse,
+ paddingLeft,
+ fragmentStyle,
+ border,
+ leftParticipant,
+ block: opt?.braceBlock()?.block(),
+ };
+
return (
{props.commentObj?.text && (
@@ -46,8 +73,8 @@ export const FragmentOpt = (props: {
@@ -55,10 +82,10 @@ export const FragmentOpt = (props: {
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/useFragmentData.ts b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/useFragmentData.ts
index 8a5138fd..d62798dd 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/useFragmentData.ts
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Fragment/useFragmentData.ts
@@ -4,95 +4,146 @@ import FrameBorder from "@/positioning/FrameBorder";
import { getLocalParticipantNames } from "@/positioning/LocalParticipants";
import store, { coordinatesAtom } from "@/store/Store";
import { FRAGMENT_MIN_WIDTH } from "@/positioning/Constants";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useMemo } from "react";
+import { depthOnParticipant } from "../utils";
+import {
+ generateFragmentTransform,
+ calculateFragmentPaddingLeft
+} from "@/positioning/GeometryUtils";
-export const getLeftParticipant = (context: any) => {
- const allParticipants = store.get(coordinatesAtom).orderedParticipantNames();
- const localParticipants = getLocalParticipantNames(context);
- return allParticipants.find((p) => localParticipants.includes(p));
-};
+/**
+ * Pure mathematical fragment geometry extracted from context
+ * Contains only the essential geometric parameters needed for positioning calculations
+ */
+interface FragmentGeometry {
+ readonly leftParticipant: string;
+ readonly localParticipants: readonly string[];
+ readonly originLayers: number;
+ readonly borderPadding: { left: number; right: number };
+}
-export const getBorder = (context: any) => {
- const allParticipants = store.get(coordinatesAtom).orderedParticipantNames();
- const frameBuilder = new FrameBuilder(allParticipants);
- const frame = frameBuilder.getFrame(context);
- return FrameBorder(frame);
-};
+/**
+ * Extracts pure geometric parameters from the God object (context)
+ * Isolates the mathematical essence from the complex context object
+ */
+class FragmentGeometryExtractor {
+ static extract(context: any, origin: string): FragmentGeometry {
+ const coordinates = store.get(coordinatesAtom);
+ const allParticipants = coordinates.orderedParticipantNames();
+ const localParticipants = getLocalParticipantNames(context);
+ const leftParticipant = allParticipants.find((p: string) => localParticipants.includes(p)) || "";
-export const getOffsetX = (context: any, origin: string) => {
- const coordinates = store.get(coordinatesAtom);
- const leftParticipant = getLeftParticipant(context) || "";
- // TODO: consider using this.getParticipantGap(this.participantModels[0])
- const halfLeftParticipant = coordinates.half(leftParticipant);
- console.debug(`left participant: ${leftParticipant} ${halfLeftParticipant}`);
- return (
- (origin ? coordinates.distance(leftParticipant, origin) : 0) +
- getBorder(context).left +
- halfLeftParticipant
- );
-};
+ const frameBuilder = new FrameBuilder(allParticipants);
+ const frame = frameBuilder.getFrame(context);
+ const border = FrameBorder(frame);
-export const getPaddingLeft = (context: any) => {
- const halfLeftParticipant = store
- .get(coordinatesAtom)
- .half(getLeftParticipant(context) || "");
- return getBorder(context).left + halfLeftParticipant;
-};
+ return {
+ leftParticipant,
+ localParticipants,
+ originLayers: depthOnParticipant(context, origin),
+ borderPadding: { left: border.left, right: border.right },
+ };
+ }
+}
-export const getFragmentStyle = (context: any, origin: string) => {
- return {
- // +1px for the border of the fragment
- transform: "translateX(" + (getOffsetX(context, origin) + 1) * -1 + "px)",
- width: TotalWidth(context, store.get(coordinatesAtom)) + "px",
- minWidth: FRAGMENT_MIN_WIDTH + "px",
- };
-};
+/**
+ * Simplified fragment coordinate transformer using unified mathematical model
+ * Uses LayoutMath for all complex calculations, dramatically reducing code complexity
+ */
+class PureFragmentCoordinateTransform {
+ private readonly geometry: FragmentGeometry;
+ private readonly origin: string;
+ private readonly coordinates: any; // Coordinates object - mathematically pure
+
+ // Cached calculation results
+ private _fragmentStyle?: any;
+
+ constructor(geometry: FragmentGeometry, origin: string, coordinates: any) {
+ this.geometry = geometry;
+ this.origin = origin;
+ this.coordinates = coordinates;
+ }
+ generateFragmentStyle(totalWidth: number, minWidth: number): any {
+ if (this._fragmentStyle !== undefined) {
+ return this._fragmentStyle;
+ }
+
+ const { leftParticipant, borderPadding, originLayers } = this.geometry;
+ const borderDepth = borderPadding.left / 10; // Convert border to depth
+
+ // Use unified mathematical model for transform generation with correct origin activation layers
+ const transform = generateFragmentTransform(leftParticipant, this.origin, borderDepth, this.coordinates, originLayers);
+
+ this._fragmentStyle = {
+ transform: transform,
+ width: `${totalWidth}px`,
+ minWidth: `${minWidth}px`,
+ };
+
+ return this._fragmentStyle;
+ }
+
+ getPaddingLeft(): number {
+ const { leftParticipant, borderPadding } = this.geometry;
+ const borderDepth = borderPadding.left / 10; // Convert border to depth
+
+ // Use unified mathematical model - replaces manual calculation
+ return calculateFragmentPaddingLeft(leftParticipant, borderDepth, this.coordinates);
+ }
+
+ getLeftParticipant(): string {
+ return this.geometry.leftParticipant;
+ }
+
+ getBorderPadding(): { left: number; right: number } {
+ return this.geometry.borderPadding;
+ }
+
+ // Clear cached calculations
+ invalidateCache(): void {
+ this._fragmentStyle = undefined;
+ }
+}
export const useFragmentData = (context: any, origin: string) => {
const [collapsed, setCollapsed] = useState(false);
+ const coordinates = store.get(coordinatesAtom);
+
+ // Extract pure geometric parameters from the God object
+ const geometry = useMemo(() => {
+ return FragmentGeometryExtractor.extract(context, origin);
+ }, [context, origin]);
+
+ // Create pure mathematical coordinate transformer
+ const coordinateTransform = useMemo(() => {
+ return new PureFragmentCoordinateTransform(geometry, origin, coordinates);
+ }, [geometry, origin, coordinates]);
+
const toggleCollapse = () => {
setCollapsed((prev) => !prev);
};
useEffect(() => {
setCollapsed(false);
- }, [context]);
+ // Invalidate cache when context changes to ensure fresh calculations
+ coordinateTransform.invalidateCache();
+ }, [context, coordinateTransform]);
- const coordinates = store.get(coordinatesAtom);
-
- const allParticipants = coordinates.orderedParticipantNames();
- const localParticipants = getLocalParticipantNames(context);
- const leftParticipant =
- allParticipants.find((p) => localParticipants.includes(p)) || "";
-
- const frameBuilder = new FrameBuilder(allParticipants);
- const frame = frameBuilder.getFrame(context);
- const border = FrameBorder(frame);
-
- // TODO: consider using this.getParticipantGap(this.participantModels[0])
- const halfLeftParticipant = coordinates.half(leftParticipant);
- console.debug(`left participant: ${leftParticipant} ${halfLeftParticipant}`);
- const offsetX =
- (origin ? coordinates.distance(leftParticipant, origin) : 0) +
- getBorder(context).left +
- halfLeftParticipant;
- const paddingLeft = getBorder(context).left + halfLeftParticipant;
-
- const fragmentStyle = {
- // +1px for the border of the fragment
- transform: "translateX(" + (offsetX + 1) * -1 + "px)",
- width: TotalWidth(context, coordinates) + "px",
- minWidth: FRAGMENT_MIN_WIDTH + "px",
- };
+ // Use pure mathematical calculations
+ const paddingLeft = coordinateTransform.getPaddingLeft();
+ const fragmentStyle = coordinateTransform.generateFragmentStyle(
+ TotalWidth(context, coordinates),
+ FRAGMENT_MIN_WIDTH
+ );
+ const border = coordinateTransform.getBorderPadding();
+ const leftParticipant = coordinateTransform.getLeftParticipant();
return {
collapsed,
toggleCollapse,
- offsetX,
paddingLeft,
fragmentStyle,
border,
- halfLeftParticipant,
leftParticipant,
};
};
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx
index b2750d57..7b614e03 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx
@@ -8,6 +8,7 @@ import { cursorAtom } from "@/store/Store";
import { _STARTER_ } from "@/parser/OrderedParticipants";
import { Comment } from "../Comment/Comment";
import { useArrow } from "../useArrow";
+import { InteractionLayout } from "@/domain/models/DiagramLayout";
export const Interaction = (props: {
context: any;
@@ -15,8 +16,15 @@ export const Interaction = (props: {
commentObj?: CommentClass;
number?: string;
className?: string;
+ layoutData?: InteractionLayout;
}) => {
+ // Always call hooks to maintain order
const cursor = useAtomValue(cursorAtom);
+
+ // Determine if using new or old architecture
+ const isNewArchitecture = !!props.layoutData;
+
+ // Pre-calculate values for useArrow hook (always called to maintain hook order)
const messageTextStyle = props.commentObj?.messageStyle;
const messageClassNames = props.commentObj?.messageClassNames;
const message = props.context?.message();
@@ -28,83 +36,141 @@ export const Interaction = (props: {
const target = props.context?.message()?.Owner() || _STARTER_;
const isSelf = source === target;
- const {
- translateX,
- interactionWidth,
- originLayers,
- sourceLayers,
- targetLayers,
- rightToLeft,
- } = useArrow({
+ // Always call useArrow hook to maintain hook order
+ const arrowData = useArrow({
context: props.context,
origin: props.origin,
source,
target,
});
+
+ // For old architecture, collect all data
+ let oldArchData = null;
+ if (!isNewArchitecture) {
+ oldArchData = {
+ messageTextStyle,
+ messageClassNames,
+ message,
+ statements,
+ assignee,
+ signature,
+ isCurrent,
+ source,
+ target,
+ isSelf,
+ ...arrowData
+ };
+ }
+
+ // Extract data based on architecture
+ const data = isNewArchitecture
+ ? {
+ messageTextStyle: props.commentObj?.messageStyle,
+ messageClassNames: props.commentObj?.messageClassNames,
+ message: null, // New architecture doesn't need raw context
+ statements: props.layoutData!.statements || [],
+ assignee: props.layoutData!.assignee || "",
+ signature: props.layoutData!.signature,
+ isCurrent: false, // TODO: Handle current state in new architecture
+ source: props.layoutData!.source,
+ target: props.layoutData!.target,
+ isSelf: props.layoutData!.isSelf,
+ translateX: props.layoutData!.translateX,
+ interactionWidth: props.layoutData!.interactionWidth,
+ originLayers: props.layoutData!.originLayers,
+ sourceLayers: props.layoutData!.sourceLayers,
+ targetLayers: props.layoutData!.targetLayers,
+ rightToLeft: props.layoutData!.rightToLeft,
+ }
+ : oldArchData!;
return (
e.stopPropagation()}
- data-to={target}
- data-origin={origin}
- data-source={source}
- data-target={target}
- data-origin-layers={originLayers}
- data-source-layers={sourceLayers}
- data-target-layers={targetLayers}
+ data-to={data.target}
+ data-origin={props.origin}
+ data-source={data.source}
+ data-target={data.target}
+ data-origin-layers={data.originLayers}
+ data-source-layers={data.sourceLayers}
+ data-target-layers={data.targetLayers}
data-type="interaction"
- data-signature={signature}
+ data-signature={data.signature}
style={{
- width: isSelf ? undefined : interactionWidth + "px",
- transform: "translateX(" + translateX + "px)",
+ width: data.isSelf ? undefined : data.interactionWidth + "px",
+ transform: "translateX(" + data.translateX + "px)",
}}
>
{props.commentObj?.text && }
- {isSelf ? (
+ {data.isSelf ? (
) : (
)}
- {assignee && !isSelf && (
+ {data.assignee && !data.isSelf && (
)}
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/InteractionWithLayout.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/InteractionWithLayout.tsx
new file mode 100644
index 00000000..3633add8
--- /dev/null
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/InteractionWithLayout.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { cn } from '@/utils';
+import { InteractionLayout } from '@/domain/models/DiagramLayout';
+
+interface InteractionWithLayoutProps {
+ layout: InteractionLayout;
+ className?: string;
+}
+
+/**
+ * Pure interaction renderer that only depends on layout data
+ */
+export const InteractionWithLayout: React.FC
= ({
+ layout,
+ className
+}) => {
+ const { rightToLeft, isSelfMessage, translateX, width } = layout;
+
+ if (isSelfMessage) {
+ // Self message rendering
+ return (
+
+
{layout.message}
+
+
+
+
+ );
+ }
+
+ // Regular message rendering
+ return (
+
+
+
{layout.message}
+
+
+
+
+
+
+
+
+
+
+ {/* Nested interactions */}
+ {layout.children && layout.children.length > 0 && (
+
+ {layout.children.map((child, index) => (
+
+ ))}
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx
index 78f47b94..6d7dba39 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx
@@ -3,7 +3,7 @@ import { EventBus } from "@/EventBus";
import { useEffect, useState } from "react";
import { cn } from "@/utils";
import { Block } from "../../../Block";
-import { centerOf } from "../../utils";
+import { getParticipantCenter } from "@/positioning/GeometryUtils";
export const Occurrence = (props: {
context: any;
@@ -18,7 +18,7 @@ export const Occurrence = (props: {
const computedCenter = () => {
try {
- return centerOf(props.participant);
+ return getParticipantCenter(props.participant);
} catch (e) {
console.error(e);
return 0;
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx
index 18eb4898..1dacc960 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx
@@ -4,7 +4,20 @@ import { CSSProperties, useMemo, useRef } from "react";
import { Numbering } from "../../../../Numbering";
import { MessageLabel } from "../../../../MessageLabel";
+/**
+ * SelfInvocation component with both old and new architecture support
+ */
export const SelfInvocation = (props: {
+ // New architecture props
+ layoutData?: {
+ assignee: string;
+ signatureText: string;
+ labelPosition: [number, number];
+ number?: string;
+ textStyle?: CSSProperties;
+ classNames?: any;
+ };
+ // Old architecture props (kept for compatibility)
context?: any;
number?: string;
textStyle?: CSSProperties;
@@ -13,15 +26,40 @@ export const SelfInvocation = (props: {
const messageRef = useRef(null);
const onMessageClick = useAtomValue(onMessageClickAtom);
- const assignee = props.context?.Assignment()?.getText() || "";
- const labelPosition: [number, number] = useMemo(() => {
+ // Determine if using new or old architecture
+ const isNewArchitecture = !!props.layoutData;
+
+ // Always call useMemo to maintain hook order
+ const labelPosition = useMemo(() => {
const func = props.context?.messageBody().func();
- if (!func) return [-1, -1];
- return [func.start.start, func.stop.stop];
+ if (!func) return [-1, -1] as [number, number];
+ return [func.start.start, func.stop.stop] as [number, number];
}, [props.context]);
+
+ // Extract data based on architecture
+ const data = isNewArchitecture
+ ? {
+ assignee: props.layoutData!.assignee,
+ signatureText: props.layoutData!.signatureText,
+ labelPosition: props.layoutData!.labelPosition,
+ number: props.layoutData!.number,
+ textStyle: props.layoutData!.textStyle,
+ classNames: props.layoutData!.classNames,
+ }
+ : {
+ assignee: props.context?.Assignment()?.getText() || "",
+ signatureText: props.context?.SignatureText(),
+ labelPosition: labelPosition,
+ number: props.number,
+ textStyle: props.textStyle,
+ classNames: props.classNames,
+ };
const onClick = () => {
- onMessageClick(props.context, messageRef.current!);
+ // Only call onMessageClick if we have a context (old architecture)
+ if (props.context && messageRef.current) {
+ onMessageClick(props.context, messageRef.current);
+ }
};
return (
@@ -31,19 +69,19 @@ export const SelfInvocation = (props: {
onClick={onClick}
>
-
+
- {assignee && (
+ {data.assignee && (
- {assignee}
+ {data.assignee}
=
)}
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/InteractionAsync/Interaction-async.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/InteractionAsync/Interaction-async.tsx
index 854d29cc..54de8252 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/InteractionAsync/Interaction-async.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/InteractionAsync/Interaction-async.tsx
@@ -75,6 +75,7 @@ import { useAtomValue } from "jotai";
import { cursorAtom, onElementClickAtom } from "@/store/Store";
import { CodeRange } from "@/parser/CodeRange";
import { useArrow } from "../useArrow";
+import { InteractionLayout } from "@/domain/models/DiagramLayout";
function isNullOrUndefined(value: any) {
return value === null || value === undefined;
@@ -87,9 +88,16 @@ export const InteractionAsync = (props: {
commentObj?: CommentClass;
number?: string;
className?: string;
+ layoutData?: InteractionLayout;
}) => {
+ // Always call hooks to maintain order
const cursor = useAtomValue(cursorAtom);
const onElementClick = useAtomValue(onElementClickAtom);
+
+ // Determine if using new or old architecture
+ const isNewArchitecture = !!props.layoutData;
+
+ // Pre-calculate values for useArrow hook (always called to maintain hook order)
const asyncMessage = props.context?.asyncMessage();
const signature = asyncMessage?.content()?.getFormattedText();
const providedSource = asyncMessage?.ProvidedFrom();
@@ -97,64 +105,106 @@ export const InteractionAsync = (props: {
const target = asyncMessage?.to()?.getFormattedText();
const isSelf = source === target;
- const { translateX, interactionWidth, rightToLeft } = useArrow({
+ // Always call useArrow hook to maintain hook order
+ const arrowData = useArrow({
context: props.context,
origin: props.origin,
source,
target,
});
- const messageClassNames = props.commentObj?.messageClassNames;
- const messageTextStyle = props.commentObj?.messageStyle;
- const getIsCurrent = () => {
- const start = asyncMessage.start.start;
- const stop = asyncMessage.stop.stop + 1;
- if (
- isNullOrUndefined(cursor) ||
- isNullOrUndefined(start) ||
- isNullOrUndefined(stop)
- )
- return false;
- return cursor! >= start && cursor! <= stop;
- };
+ // For old architecture, calculate all values as before
+ let oldArchData = null;
+ if (!isNewArchitecture) {
+ const messageClassNames = props.commentObj?.messageClassNames;
+ const messageTextStyle = props.commentObj?.messageStyle;
+ const getIsCurrent = () => {
+ const start = asyncMessage.start.start;
+ const stop = asyncMessage.stop.stop + 1;
+ if (
+ isNullOrUndefined(cursor) ||
+ isNullOrUndefined(start) ||
+ isNullOrUndefined(stop)
+ )
+ return false;
+ return cursor! >= start && cursor! <= stop;
+ };
+
+ oldArchData = {
+ asyncMessage,
+ signature,
+ source,
+ target,
+ isSelf,
+ messageClassNames,
+ messageTextStyle,
+ isCurrent: getIsCurrent(),
+ ...arrowData
+ };
+ }
+
+ // Extract data based on architecture
+ const data = isNewArchitecture
+ ? {
+ asyncMessage: null, // New architecture doesn't need raw context
+ signature: props.layoutData!.signature,
+ source: props.layoutData!.source,
+ target: props.layoutData!.target,
+ isSelf: props.layoutData!.isSelf,
+ messageClassNames: props.commentObj?.messageClassNames,
+ messageTextStyle: props.commentObj?.messageStyle,
+ isCurrent: false, // TODO: Handle current state in new architecture
+ translateX: props.layoutData!.translateX,
+ interactionWidth: props.layoutData!.interactionWidth,
+ rightToLeft: props.layoutData!.rightToLeft,
+ }
+ : oldArchData!;
return (
onElementClick(CodeRange.from(props.context))}
- data-signature={signature}
+ data-signature={data.signature}
style={{
- width: interactionWidth + "px",
- transform: "translateX(" + translateX + "px)",
+ width: data.interactionWidth + "px",
+ transform: "translateX(" + data.translateX + "px)",
}}
>
{props.comment &&
}
- {isSelf ? (
+ {data.isSelf ? (
) : (
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Message/index.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Message/index.tsx
index 6d64751f..33b5a660 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Message/index.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Message/index.tsx
@@ -68,9 +68,25 @@ const getLabelPosition = (context: Context, type: string): [number, number] => {
return [start, stop];
};
+/**
+ * Message component that supports both old and new architecture
+ */
export const Message = (props: {
+ // New architecture props
+ layoutData?: {
+ content: string;
+ rtl?: boolean;
+ type?: string;
+ textStyle?: CSSProperties;
+ className?: string;
+ style?: CSSProperties;
+ number?: string;
+ borderStyle?: "solid" | "dashed";
+ editable?: boolean;
+ };
+ // Old architecture props (kept for compatibility)
context?: Context;
- content: string;
+ content?: string;
rtl?: string | boolean;
type?: string;
textStyle?: CSSProperties;
@@ -78,37 +94,61 @@ export const Message = (props: {
style?: CSSProperties;
number?: string;
}) => {
- const {
- context,
- content,
- rtl,
- type = "",
- textStyle,
- className,
- style,
- number,
- } = props;
const mode = useAtomValue(modeAtom);
const onMessageClick = useAtomValue(onMessageClickAtom);
const messageRef = useRef
(null);
- const isAsync = type === "async";
- const editable = getEditable(context, mode, type || "");
+
+ // Determine if using new or old architecture
+ const isNewArchitecture = !!props.layoutData;
+
+ // Extract data based on architecture
+ const data = isNewArchitecture
+ ? {
+ content: props.layoutData!.content,
+ rtl: props.layoutData!.rtl,
+ type: props.layoutData!.type || "",
+ textStyle: props.layoutData!.textStyle,
+ className: props.layoutData!.className,
+ style: props.layoutData!.style,
+ number: props.layoutData!.number,
+ borderStyle: props.layoutData!.borderStyle,
+ editable: props.layoutData!.editable,
+ }
+ : {
+ content: props.content || "",
+ rtl: props.rtl,
+ type: props.type || "",
+ textStyle: props.textStyle,
+ className: props.className,
+ style: props.style,
+ number: props.number,
+ borderStyle: ({
+ sync: "solid",
+ async: "solid",
+ creation: "dashed",
+ return: "dashed",
+ }[props.type || ""] as "solid" | "dashed"),
+ editable: getEditable(props.context, mode, props.type || ""),
+ };
+
+ const isAsync = data.type === "async";
const stylable =
mode !== RenderMode.Static &&
- ["sync", "async", "return", "creation"].includes(type);
+ ["sync", "async", "return", "creation"].includes(data.type);
const labelText =
- type === "creation" ? content.match(/«([^»]+)»/)?.[1] || "" : content || "";
- const labelPosition = getLabelPosition(context, type || "");
- const borderStyle: "solid" | "dashed" | undefined = {
- sync: "solid",
- async: "solid",
- creation: "dashed",
- return: "dashed",
- }[type] as "solid";
+ data.type === "creation"
+ ? data.content.match(/«([^»]+)»/)?.[1] || ""
+ : data.content || "";
+ const labelPosition = isNewArchitecture
+ ? [-1, -1] as [number, number] // New architecture doesn't need label positions for now
+ : getLabelPosition(props.context, data.type || "");
const onClick = () => {
if (!stylable || !messageRef.current) return;
- onMessageClick(context, messageRef.current);
+ // For new architecture, we might not have context, so only call if it exists
+ if (props.context) {
+ onMessageClick(props.context, messageRef.current);
+ }
};
return (
@@ -116,41 +156,41 @@ export const Message = (props: {
className={cn(
"message leading-none border-skin-message-arrow border-b-2 flex items-end",
{
- "flex-row-reverse": rtl,
- return: type === "return",
- "right-to-left": rtl,
+ "flex-row-reverse": data.rtl,
+ return: data.type === "return",
+ "right-to-left": data.rtl,
},
- className,
+ data.className,
)}
- style={{ ...style, borderBottomStyle: borderStyle }}
+ style={{ ...data.style, borderBottomStyle: data.borderStyle }}
onClick={onClick}
ref={messageRef}
>
-
- {editable ? (
+
+ {data.editable ? (
<>
- {type === "creation" && « }
+ {data.type === "creation" && « }
- {type === "creation" && » }
+ {data.type === "creation" && » }
>
) : (
- <>{content}>
+ <>{data.content}>
)}
-
+
);
};
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Return/Return.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Return/Return.tsx
index 86e3d486..ac6108c3 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Return/Return.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Return/Return.tsx
@@ -9,9 +9,27 @@ import { CodeRange } from "@/parser/CodeRange";
import { SyntheticEvent } from "react";
import { useArrow } from "../useArrow";
+/**
+ * Return component with both old and new architecture support
+ */
export const Return = (props: {
- context: any;
- origin: string;
+ // New architecture props
+ layoutData?: {
+ signature: string;
+ source: string;
+ target: string;
+ rightToLeft: boolean;
+ isSelf: boolean;
+ interactionWidth: number;
+ translateX: number;
+ comment?: string;
+ commentObj?: CommentClass;
+ number?: string;
+ className?: string;
+ };
+ // Old architecture props (kept for compatibility)
+ context?: any;
+ origin?: string;
comment?: string;
commentObj?: CommentClass;
number?: string;
@@ -19,60 +37,107 @@ export const Return = (props: {
}) => {
const onElementClick = useAtomValue(onElementClickAtom);
+ // Determine if using new or old architecture
+ const isNewArchitecture = !!props.layoutData;
+
+ // For old architecture, pre-calculate values to pass to useArrow
const ret = props.context?.ret();
-
const asyncMessage = ret?.asyncMessage();
-
- const signature =
- asyncMessage?.content()?.getFormattedText() ||
- props.context?.ret()?.expr()?.getFormattedText();
const source = asyncMessage?.From() || ret?.From() || _STARTER_;
-
const target =
- // TODO: move this logic to the parser (ReturnTo)
asyncMessage?.to()?.getFormattedText() ||
props.context?.ret()?.ReturnTo() ||
_STARTER_;
-
- const messageContext =
- asyncMessage?.content() || props.context?.ret()?.expr();
-
- const { translateX, interactionWidth, rightToLeft, isSelf } = useArrow({
+
+ // Always call useArrow hook to maintain hook order
+ const arrowData = useArrow({
context: props.context,
- origin: props.origin,
+ origin: props.origin || '',
source,
target,
});
+
+ // For old architecture, calculate values as before
+ let oldArchData = null;
+ if (!isNewArchitecture && props.context && props.origin) {
+ const signature =
+ asyncMessage?.content()?.getFormattedText() ||
+ props.context.ret()?.expr()?.getFormattedText();
+ const messageContext = asyncMessage?.content() || props.context.ret()?.expr();
+
+ oldArchData = {
+ signature,
+ source,
+ target,
+ messageContext,
+ ...arrowData
+ };
+ }
+
+ // Extract data based on architecture
+ const data = isNewArchitecture
+ ? {
+ signature: props.layoutData!.signature,
+ source: props.layoutData!.source,
+ target: props.layoutData!.target,
+ rightToLeft: props.layoutData!.rightToLeft,
+ isSelf: props.layoutData!.isSelf,
+ interactionWidth: props.layoutData!.interactionWidth,
+ translateX: props.layoutData!.translateX,
+ comment: props.layoutData!.comment,
+ commentObj: props.layoutData!.commentObj,
+ number: props.layoutData!.number,
+ className: props.layoutData!.className,
+ messageContext: null, // New architecture doesn't need this
+ }
+ : {
+ signature: oldArchData!.signature,
+ source: oldArchData!.source,
+ target: oldArchData!.target,
+ rightToLeft: oldArchData!.rightToLeft,
+ isSelf: oldArchData!.isSelf,
+ interactionWidth: oldArchData!.interactionWidth,
+ translateX: oldArchData!.translateX,
+ comment: props.comment,
+ commentObj: props.commentObj,
+ number: props.number,
+ className: props.className,
+ messageContext: oldArchData!.messageContext,
+ };
const onClick = (e: SyntheticEvent) => {
e.stopPropagation();
- onElementClick(CodeRange.from(props.context));
+ // Only call onElementClick if we have a context (old architecture)
+ if (props.context) {
+ onElementClick(CodeRange.from(props.context));
+ }
};
+
return (
// .relative to allow left style
- {props.comment &&
}
- {isSelf && (
+ {data.comment &&
}
+ {data.isSelf && (
- {signature}
+ {data.signature}
)}
- {!isSelf && (
+ {!data.isSelf && (
)}
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Statement.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Statement.tsx
index 7faffa48..f1afefd6 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Statement.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Statement.tsx
@@ -13,6 +13,8 @@ import { Divider } from "./Divider/Divider";
import { Return } from "./Return/Return";
import Comment from "../../../../../Comment/Comment";
import { cn } from "@/utils";
+import { useAtomValue } from "jotai";
+import { diagramLayoutAtom, domainModelAtom, contextMappingAtom } from "@/domain/DomainModelStore";
export const Statement = (props: {
context: any;
@@ -22,6 +24,92 @@ export const Statement = (props: {
}) => {
const comment = props.context.getComment() || "";
const commentObj = new Comment(comment);
+
+ // Try to get layout data from new architecture
+ // TEMPORARILY DISABLED until hook order issues are resolved
+ const diagramLayout = null;
+ const domainModel = null;
+ const contextMapping = null;
+
+ // Helper to find divider layout - in a real implementation,
+ // we'd need a better way to map context to domain model
+ const findDividerLayout = () => {
+ if (!diagramLayout || !domainModel || !props.context.divider()) {
+ return undefined;
+ }
+
+ // This is a simplified approach - in production, we'd need
+ // a proper mapping between context and domain model statements
+ const dividerText = props.context.divider().Note()?.trim();
+
+ if (dividerText) {
+ return diagramLayout.dividers.find(d =>
+ d.text === dividerText ||
+ d.text === dividerText.replace(/\[.*?\]\s*/, '') // Handle styled text
+ );
+ }
+ return undefined;
+ };
+
+ // Helper to find fragment layout using context mapping
+ const findFragmentLayout = (fragmentContext: any) => {
+ if (!diagramLayout || !domainModel || !contextMapping) {
+ console.log('[Statement] Missing dependencies:', {
+ diagramLayout: !!diagramLayout,
+ domainModel: !!domainModel,
+ contextMapping: !!contextMapping
+ });
+ return undefined;
+ }
+
+ // Use the context mapping to find the fragment ID
+ const fragmentId = contextMapping.get(fragmentContext);
+ console.log('[Statement] Context mapping lookup:', {
+ fragmentContext,
+ fragmentId,
+ mappingSize: contextMapping.size
+ });
+
+ if (!fragmentId) {
+ console.log('[Statement] No fragment ID found in context mapping');
+ return undefined;
+ }
+
+ // Find the fragment layout by ID
+ const layout = diagramLayout.fragments.find(f => f.fragmentId === fragmentId);
+ console.log('[Statement] Found layout:', layout);
+ return layout;
+ };
+
+ // Helper function to find interaction layout by context
+ const findInteractionLayout = (messageContext: any) => {
+ if (!diagramLayout || !domainModel || !contextMapping) {
+ console.log('[Statement] Missing dependencies for interaction:', {
+ diagramLayout: !!diagramLayout,
+ domainModel: !!domainModel,
+ contextMapping: !!contextMapping
+ });
+ return undefined;
+ }
+
+ // Use the context mapping to find the interaction ID
+ const interactionId = contextMapping.get(messageContext);
+ console.log('[Statement] Interaction context mapping lookup:', {
+ messageContext,
+ interactionId,
+ mappingSize: contextMapping.size
+ });
+
+ if (!interactionId) {
+ console.log('[Statement] No interaction ID found in context mapping');
+ return undefined;
+ }
+
+ // Find the interaction layout by ID
+ const layout = diagramLayout.interactions.find(i => i.interactionId === interactionId);
+ console.log('[Statement] Found interaction layout:', layout);
+ return layout;
+ };
const subProps = {
className: cn("text-left text-sm text-skin-message", {
@@ -36,13 +124,22 @@ export const Statement = (props: {
switch (true) {
case Boolean(props.context.loop()):
- return ;
+ const loopContext = props.context.loop();
+ const loopLayoutData = findFragmentLayout(loopContext);
+ console.log('[Statement] Loop fragment - context:', loopContext, 'layoutData:', loopLayoutData);
+ return ;
case Boolean(props.context.alt()):
- return ;
+ const altContext = props.context.alt();
+ const layoutData = findFragmentLayout(altContext);
+ console.log('[Statement] Alt fragment - context:', altContext, 'layoutData:', layoutData);
+ return ;
case Boolean(props.context.par()):
return ;
case Boolean(props.context.opt()):
- return ;
+ const optContext = props.context.opt();
+ const optLayoutData = findFragmentLayout(optContext);
+ console.log('[Statement] Opt fragment - context:', optContext, 'layoutData:', optLayoutData);
+ return ;
case Boolean(props.context.section()):
return ;
case Boolean(props.context.critical()):
@@ -54,11 +151,17 @@ export const Statement = (props: {
case Boolean(props.context.creation()):
return ;
case Boolean(props.context.message()):
- return ;
+ const messageContext = props.context.message();
+ const interactionLayoutData = findInteractionLayout(messageContext);
+ console.log('[Statement] Interaction - context:', messageContext, 'layoutData:', interactionLayoutData);
+ return ;
case Boolean(props.context.asyncMessage()):
- return ;
+ const asyncMessageContext = props.context.asyncMessage();
+ const asyncInteractionLayoutData = findInteractionLayout(asyncMessageContext);
+ console.log('[Statement] AsyncInteraction - context:', asyncMessageContext, 'layoutData:', asyncInteractionLayoutData);
+ return ;
case Boolean(props.context.divider()):
- return ;
+ return ;
case Boolean(props.context.ret()):
return (
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/useArrow.ts b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/useArrow.ts
index 81d3d765..23b2ef1a 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/useArrow.ts
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/useArrow.ts
@@ -1,32 +1,6 @@
-import sequenceParser from "@/generated-parser/sequenceParser";
-import { centerOf, distance2 } from "./utils";
-import Anchor2 from "@/positioning/Anchor2";
-
-const depthOnParticipant = (context: any, participant: any): number => {
- return context?.getAncestors((ctx: any) => {
- const isSync = (ctx: any) => {
- const isMessageContext = ctx instanceof sequenceParser.MessageContext;
- const isCreationContext = ctx instanceof sequenceParser.CreationContext;
- return isMessageContext || isCreationContext;
- };
- if (isSync(ctx)) {
- return ctx.Owner() === participant;
- }
- return false;
- }).length;
-};
-
-const depthOnParticipant4Stat = (context: any, participant: any): number => {
- if (!(context instanceof sequenceParser.StatContext)) {
- return 0;
- }
-
- const child = context?.children?.[0];
- if (!child) {
- return 0;
- }
- return depthOnParticipant(child, participant);
-};
+import { ArrowCalculator } from "@/layout/calculator/ArrowCalculator";
+import { ArrowGeometryExtractor } from "@/layout/extractor/ArrowGeometryExtractor";
+import { useMemo } from "react";
export const useArrow = ({
context,
@@ -39,38 +13,20 @@ export const useArrow = ({
source: string;
target: string;
}) => {
- const isSelf = source === target;
-
- const originLayers = depthOnParticipant(context, origin);
-
- const sourceLayers = depthOnParticipant(context, source);
-
- const targetLayers = depthOnParticipant4Stat(context, target);
-
- const anchor2Origin = new Anchor2(centerOf(origin), originLayers);
-
- const anchor2Source = new Anchor2(centerOf(source), sourceLayers);
-
- const anchor2Target = new Anchor2(centerOf(target), targetLayers);
-
- const interactionWidth = Math.abs(anchor2Source.edgeOffset(anchor2Target));
-
- const rightToLeft = distance2(source, target) < 0;
-
- const translateX = anchor2Origin.centerToEdge(
- !rightToLeft ? anchor2Source : anchor2Target,
- );
-
- return {
- isSelf,
- originLayers,
- sourceLayers,
- targetLayers,
- anchor2Origin,
- anchor2Source,
- anchor2Target,
- interactionWidth,
- rightToLeft,
- translateX,
- };
+ // Extract geometric data from context (happens once per context change)
+ const arrowGeometry = useMemo(() =>
+ ArrowGeometryExtractor.extractArrowGeometry({
+ context,
+ origin,
+ source,
+ target,
+ }), [context, origin, source, target]);
+
+ // Pure mathematical calculation (can be cached and optimized)
+ const arrowLayout = useMemo(() => {
+ const calculator = new ArrowCalculator();
+ return calculator.calculateArrowLayout(arrowGeometry);
+ }, [arrowGeometry]);
+
+ return arrowLayout;
};
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/utils.ts b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/utils.ts
index 4a5b2811..72aa6d9d 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/utils.ts
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/utils.ts
@@ -1,10 +1,4 @@
-import { Coordinates } from "@/positioning/Coordinates";
-import store, { coordinatesAtom } from "@/store/Store";
-
-let coordinates: Coordinates = store.get(coordinatesAtom);
-store.sub(coordinatesAtom, () => {
- coordinates = store.get(coordinatesAtom);
-});
+import sequenceParser from "@/generated-parser/sequenceParser";
export const getContextType = (context: any) => {
const dict: Record = {
@@ -26,24 +20,16 @@ export const getContextType = (context: any) => {
return dict[key];
};
-export const centerOf = (entity: string) => {
- if (!entity) {
- console.error("[@zenuml/core] centerOf: entity is undefined");
- return 0;
- }
- try {
- return coordinates.getPosition(entity) || 0;
- } catch (e) {
- console.error(e);
- return 0;
- }
-};
-
-export const distance = (from: string, to: string) => {
- return centerOf(from) - centerOf(to);
-};
-
-export const distance2 = (from: string, to: string) => {
- if (!from || !to) return 0;
- return centerOf(to) - centerOf(from);
+export const depthOnParticipant = (context: any, participant: any): number => {
+ return context?.getAncestors((ctx: any) => {
+ const isSync = (ctx: any) => {
+ const isMessageContext = ctx instanceof sequenceParser.MessageContext;
+ const isCreationContext = ctx instanceof sequenceParser.CreationContext;
+ return isMessageContext || isCreationContext;
+ };
+ if (isSync(ctx)) {
+ return ctx.Owner() === participant;
+ }
+ return false;
+ }).length;
};
diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLayer.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLayer.tsx
index 7e11e905..ecd01e2a 100644
--- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLayer.tsx
+++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLayer.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Block } from "./Block/Block";
-import { centerOf } from "./Block/Statement/utils";
+import { getParticipantCenter } from "@/positioning/GeometryUtils";
import { StylePanel } from "./StylePanel";
import { useAtomValue } from "jotai";
import { rootContextAtom } from "@/store/Store";
@@ -23,7 +23,7 @@ export const MessageLayer = (props: {
return ownableMessages[0].from || _STARTER_;
}, [rootContext]);
- const paddingLeft = centerOf(origin) + 1;
+ const paddingLeft = getParticipantCenter(origin) + 1;
const [mounted, setMounted] = useState(false);
if (mounted) {
diff --git a/src/components/DiagramFrame/SeqDiagram/WidthOfContext.ts b/src/components/DiagramFrame/SeqDiagram/WidthOfContext.ts
index 982ce8be..4c2beb64 100644
--- a/src/components/DiagramFrame/SeqDiagram/WidthOfContext.ts
+++ b/src/components/DiagramFrame/SeqDiagram/WidthOfContext.ts
@@ -2,9 +2,8 @@ import { AllMessages } from "@/parser/MessageCollector";
import FrameBuilder from "@/parser/FrameBuilder";
import FrameBorder, { Frame } from "@/positioning/FrameBorder";
import { Coordinates } from "@/positioning/Coordinates";
-import { FRAGMENT_MIN_WIDTH } from "@/positioning/Constants";
import { getLocalParticipantNames } from "@/positioning/LocalParticipants";
-import { _STARTER_ } from "@/parser/OrderedParticipants";
+import { calculateFragmentContextWidth, calculateSelfMessageExtraWidth } from "@/positioning/GeometryUtils";
export function TotalWidth(ctx: any, coordinates: Coordinates) {
const allParticipants = coordinates.orderedParticipantNames();
@@ -16,43 +15,24 @@ export function TotalWidth(ctx: any, coordinates: Coordinates) {
.slice()
.reverse()
.find((p) => localParticipants.includes(p)) || "";
+
+ if (leftParticipant === "" || rightParticipant === "") {
+ return 0;
+ }
+
const frameBuilder = new FrameBuilder(allParticipants as string[]);
const frame = frameBuilder.getFrame(ctx);
const border = FrameBorder(frame as Frame);
- const extraWidth = extraWidthDueToSelfMessage(
- ctx,
- rightParticipant,
- coordinates,
- );
- // if (leftParticipant === "" || rightParticipant === "") {
- // return 0;
- // }
- const participantWidth =
- coordinates.distance(leftParticipant, rightParticipant) +
- coordinates.half(leftParticipant) +
- coordinates.half(rightParticipant);
- return (
- Math.max(participantWidth, FRAGMENT_MIN_WIDTH) +
- border.left +
- border.right +
- extraWidth
- );
-}
-
-function extraWidthDueToSelfMessage(
- ctx: any,
- rightParticipant: string,
- coordinates: Coordinates,
-) {
+
+ // Calculate extra width due to self messages using new mathematical model
const allMessages = AllMessages(ctx);
- const widths = allMessages
- .filter((m) => m.from === m.to)
- // 37 is arrow width (30) + half occurrence width(7)
- .map(
- (m) =>
- coordinates.getMessageWidth(m) -
- coordinates.distance(m.from || _STARTER_, rightParticipant) -
- coordinates.half(rightParticipant),
- );
- return Math.max.apply(null, [0, ...widths]);
+ const selfMessages = allMessages.filter((m) => m.from === m.to);
+ const extraWidth = calculateSelfMessageExtraWidth(selfMessages, rightParticipant, coordinates);
+
+ // Calculate border depth from border object (border.left should equal border.right)
+ const borderDepth = border.left / 10; // FRAGMENT_PADDING_X = 10
+
+ // Use new mathematical model for fragment context width calculation
+ return calculateFragmentContextWidth(leftParticipant, rightParticipant, borderDepth, extraWidth, coordinates);
}
+
diff --git a/src/components/DiagramRenderer/DiagramRenderer.tsx b/src/components/DiagramRenderer/DiagramRenderer.tsx
new file mode 100644
index 00000000..cf91f486
--- /dev/null
+++ b/src/components/DiagramRenderer/DiagramRenderer.tsx
@@ -0,0 +1,292 @@
+import React from 'react';
+import { DiagramLayout, ParticipantLayout, InteractionLayout, FragmentLayout, ActivationLayout, DividerLayout } from '@/domain/models/DiagramLayout';
+import { cn } from '@/utils';
+
+/**
+ * Pure rendering components that only depend on layout data.
+ * No knowledge of parse trees or contexts.
+ */
+
+interface DiagramRendererProps {
+ layout: DiagramLayout;
+}
+
+export const DiagramRenderer: React.FC = ({ layout }) => {
+ return (
+
+ {/* Render participants */}
+ {layout.participants.map(participant => (
+
+ ))}
+
+ {/* Render lifelines */}
+ {layout.lifelines.map(lifeline => (
+
+ ))}
+
+ {/* Render fragments (behind interactions) */}
+ {layout.fragments.map(fragment => (
+
+ ))}
+
+ {/* Render activations */}
+ {layout.activations.map((activation, index) => (
+
+ ))}
+
+ {/* Render dividers */}
+ {layout.dividers.map((divider, index) => (
+
+ ))}
+
+ {/* Render interactions */}
+ {layout.interactions.map(interaction => (
+
+ ))}
+
+ );
+};
+
+interface ParticipantRendererProps {
+ layout: ParticipantLayout;
+}
+
+const ParticipantRenderer: React.FC = ({ layout }) => {
+ return (
+
+
+
+ {/* Label will be provided by domain model lookup */}
+
+
+ );
+};
+
+interface LifelineRendererProps {
+ layout: {
+ x: number;
+ startY: number;
+ endY: number;
+ };
+}
+
+const LifelineRenderer: React.FC = ({ layout }) => {
+ return (
+
+ );
+};
+
+interface InteractionRendererProps {
+ layout: InteractionLayout;
+}
+
+const InteractionRenderer: React.FC = ({ layout }) => {
+ const { startPoint, endPoint, arrowStyle } = layout;
+
+ if (arrowStyle.selfMessage) {
+ // Render self message
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+};
+
+interface FragmentRendererProps {
+ layout: FragmentLayout;
+}
+
+const FragmentRenderer: React.FC = ({ layout }) => {
+ return (
+
+
+
+
+ {layout.type}
+
+
+ {/* Render section dividers */}
+ {layout.sections.slice(1).map((section, index) => (
+
+ ))}
+
+ );
+};
+
+interface ActivationRendererProps {
+ layout: ActivationLayout;
+}
+
+const ActivationRenderer: React.FC = ({ layout }) => {
+ return (
+
+ );
+};
+
+interface ArrowHeadProps {
+ x: number;
+ y: number;
+ direction: 'left' | 'right';
+ type: 'filled' | 'open';
+}
+
+const ArrowHead: React.FC = ({ x, y, direction, type }) => {
+ const size = 10;
+ const dx = direction === 'right' ? -size : size;
+
+ if (type === 'filled') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+interface DividerRendererProps {
+ layout: DividerLayout;
+}
+
+const DividerRenderer: React.FC = ({ layout }) => {
+ return (
+
+ {/* Left line */}
+
+
+ {/* Label */}
+
+ {layout.text}
+
+
+ {/* Right line */}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/domain/DomainModelStore.ts b/src/domain/DomainModelStore.ts
new file mode 100644
index 00000000..d6a19f08
--- /dev/null
+++ b/src/domain/DomainModelStore.ts
@@ -0,0 +1,172 @@
+import { atom } from 'jotai';
+import { rootContextAtom } from '@/store/Store';
+import { buildDomainModel } from '@/domain/builders/DomainModelBuilder';
+import { LayoutCalculator } from '@/domain/layout/LayoutCalculator';
+import { SequenceDiagram } from '@/domain/models/SequenceDiagram';
+import { DiagramLayout } from '@/domain/models/DiagramLayout';
+
+/**
+ * Bridge between old and new architecture.
+ * These atoms provide the new domain model and layout while keeping the old system working.
+ */
+
+// Store both domain model and context mapping together
+interface DomainModelWithMapping {
+ diagram: SequenceDiagram | null;
+ contextMapping: Map;
+}
+
+// Domain model and context mapping derived from parse tree
+const domainModelWithMappingAtom = atom((get) => {
+ const rootContext = get(rootContextAtom);
+ if (!rootContext) {
+ console.log('[DomainModelStore] No root context available');
+ return { diagram: null, contextMapping: new Map() };
+ }
+
+ try {
+ // Convert parse tree to domain model (single traversal)
+ console.log('[DomainModelStore] Building domain model from root context');
+ const result = buildDomainModel(rootContext);
+ return {
+ diagram: result.diagram,
+ contextMapping: result.contextMapping
+ };
+ } catch (error) {
+ console.error('Failed to build domain model:', error);
+ return { diagram: null, contextMapping: new Map() };
+ }
+});
+
+// Expose just the diagram for existing code
+export const domainModelAtom = atom((get) => {
+ return get(domainModelWithMappingAtom).diagram;
+});
+
+// Expose the context mapping
+export const contextMappingAtom = atom>((get) => {
+ return get(domainModelWithMappingAtom).contextMapping;
+});
+
+// Layout calculated from domain model
+export const diagramLayoutAtom = atom((get) => {
+ const domainModel = get(domainModelAtom);
+ if (!domainModel) {
+ console.log('[DomainModelStore] No domain model available for layout');
+ return null;
+ }
+
+ try {
+ // Calculate layout from domain model (pure calculation)
+ console.log('[DomainModelStore] Calculating layout from domain model');
+ const calculator = new LayoutCalculator();
+ const layout = calculator.calculate(domainModel);
+ console.log('[DomainModelStore] Layout calculated:', {
+ participants: layout.participants.length,
+ dividers: layout.dividers.length,
+ interactions: layout.interactions.length
+ });
+ return layout;
+ } catch (error) {
+ console.error('Failed to calculate layout:', error);
+ return null;
+ }
+});
+
+// Convenience selectors for specific data
+export const participantsFromDomainAtom = atom((get) => {
+ const model = get(domainModelAtom);
+ return model?.participants || new Map();
+});
+
+export const interactionsFromDomainAtom = atom((get) => {
+ const model = get(domainModelAtom);
+ return model?.interactions || [];
+});
+
+export const fragmentsFromDomainAtom = atom((get) => {
+ const model = get(domainModelAtom);
+ return model?.fragments || [];
+});
+
+/**
+ * Adapter functions to help migrate components gradually
+ */
+
+// Get participant info without parse tree navigation
+export function getParticipantFromDomain(
+ domainModel: SequenceDiagram,
+ participantId: string
+) {
+ return domainModel.participants.get(participantId);
+}
+
+// Get interactions for a specific block without visitor pattern
+export function getInteractionsInBlock(
+ domainModel: SequenceDiagram,
+ blockId: string
+) {
+ // Find all interaction statements in the block
+ const block = findBlockById(domainModel, blockId);
+ if (!block) return [];
+
+ return block.statements
+ .filter(s => s.type === 'interaction')
+ .map(s => domainModel.interactions.find(i => i.id === s.interactionId))
+ .filter(Boolean);
+}
+
+// Get local participants for a fragment without parse tree walking
+export function getLocalParticipantsForFragment(
+ domainModel: SequenceDiagram,
+ fragmentId: string
+): string[] {
+ const fragment = domainModel.fragments.find(f => f.id === fragmentId);
+ if (!fragment) return [];
+
+ const participants = new Set();
+
+ // Collect from all sections
+ for (const section of fragment.sections) {
+ collectParticipantsFromBlock(domainModel, section.block, participants);
+ }
+
+ return Array.from(participants);
+}
+
+function findBlockById(model: SequenceDiagram, blockId: string): any {
+ // Search in root block
+ if (model.rootBlock.id === blockId) return model.rootBlock;
+
+ // Search in fragments
+ for (const fragment of model.fragments) {
+ for (const section of fragment.sections) {
+ if (section.block.id === blockId) return section.block;
+ }
+ }
+
+ return null;
+}
+
+function collectParticipantsFromBlock(
+ model: SequenceDiagram,
+ block: any,
+ participants: Set
+) {
+ for (const statement of block.statements) {
+ if (statement.type === 'interaction') {
+ const interaction = model.interactions.find(i => i.id === statement.interactionId);
+ if (interaction) {
+ participants.add(interaction.from);
+ participants.add(interaction.to);
+ }
+ } else if (statement.type === 'fragment') {
+ const fragment = model.fragments.find(f => f.id === statement.fragmentId);
+ if (fragment) {
+ for (const section of fragment.sections) {
+ collectParticipantsFromBlock(model, section.block, participants);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/domain/builders/DomainModelBuilder.participant.spec.ts b/src/domain/builders/DomainModelBuilder.participant.spec.ts
new file mode 100644
index 00000000..3479db8d
--- /dev/null
+++ b/src/domain/builders/DomainModelBuilder.participant.spec.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect } from 'vitest';
+import { buildDomainModel } from '@/domain/builders/DomainModelBuilder';
+import { RootContext } from '@/parser';
+import { ParticipantType } from '@/domain/models/SequenceDiagram';
+
+describe('DomainModelBuilder - Participant Features', () => {
+ it('should extract participant with color', () => {
+ const code = `
+ A #ff0000
+ A->B: test
+ `;
+
+ const rootContext = RootContext(code);
+ const result = buildDomainModel(rootContext);
+ const domainModel = result.diagram;
+
+ const participantA = domainModel.participants.get('A');
+ expect(participantA).toBeDefined();
+ expect(participantA?.color).toBe('#ff0000');
+ expect(participantA?.style?.backgroundColor).toBe('#ff0000');
+ });
+
+ it('should extract participant with label', () => {
+ const code = `
+ A as "Alice System"
+ A->B: test
+ `;
+
+ const rootContext = RootContext(code);
+ const result = buildDomainModel(rootContext);
+ const domainModel = result.diagram;
+
+ const participantA = domainModel.participants.get('A');
+ expect(participantA).toBeDefined();
+ expect(participantA?.label).toBe('Alice System');
+ });
+
+ it('should extract participant with type', () => {
+ const code = `
+ @actor A
+ A->B: test
+ `;
+
+ const rootContext = RootContext(code);
+ const result = buildDomainModel(rootContext);
+ const domainModel = result.diagram;
+
+ const participantA = domainModel.participants.get('A');
+ expect(participantA).toBeDefined();
+ expect(participantA?.type).toBe(ParticipantType.ACTOR);
+ });
+
+ it('should extract participant with all features', () => {
+ const code = `
+ @actor <> A 200 as "Alice" #ff0000
+ A->B: test
+ `;
+
+ const rootContext = RootContext(code);
+ const result = buildDomainModel(rootContext);
+ const domainModel = result.diagram;
+
+ const participantA = domainModel.participants.get('A');
+ expect(participantA).toBeDefined();
+ expect(participantA?.type).toBe(ParticipantType.ACTOR);
+ expect(participantA?.stereotype).toBe('Repo');
+ expect(participantA?.width).toBe(200);
+ expect(participantA?.label).toBe('Alice');
+ expect(participantA?.color).toBe('#ff0000');
+ });
+});
\ No newline at end of file
diff --git a/src/domain/builders/DomainModelBuilder.spec.ts b/src/domain/builders/DomainModelBuilder.spec.ts
new file mode 100644
index 00000000..44058209
--- /dev/null
+++ b/src/domain/builders/DomainModelBuilder.spec.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from 'vitest';
+import { buildDomainModel } from '@/domain/builders/DomainModelBuilder';
+import { RootContext } from '@/parser';
+
+describe('DomainModelBuilder - Divider Handling', () => {
+ it('should correctly parse divider statements', () => {
+ const code = `
+ A->B: test
+ == Basic Divider ==
+ B->A: response
+ `;
+
+ const rootContext = RootContext(code);
+ const result = buildDomainModel(rootContext);
+ const domainModel = result.diagram;
+
+ // Check that we have the divider in the root block
+ const dividerStatements = domainModel.rootBlock.statements.filter(s => s.type === 'divider');
+ expect(dividerStatements).toHaveLength(1);
+
+ const divider = dividerStatements[0];
+ expect(divider.type).toBe('divider');
+ expect(divider.text).toBe('Basic Divider');
+ });
+
+ it('should correctly parse divider with style', () => {
+ const code = `
+ A->B: test
+ == [red, bold] Styled Divider ==
+ B->A: response
+ `;
+
+ const rootContext = RootContext(code);
+ const result = buildDomainModel(rootContext);
+ const domainModel = result.diagram;
+
+ const dividerStatements = domainModel.rootBlock.statements.filter(s => s.type === 'divider');
+ expect(dividerStatements).toHaveLength(1);
+
+ const divider = dividerStatements[0];
+ expect(divider.text).toBe('Styled Divider');
+ expect(divider.style).toBeDefined();
+ expect(divider.style?.classNames).toContain('red');
+ expect(divider.style?.textStyle?.fontWeight).toBe('bold');
+ });
+
+ it('should correctly parse divider with color style', () => {
+ const code = `
+ A->B: test
+ == [#FF0000] Red Divider ==
+ B->A: response
+ `;
+
+ const rootContext = RootContext(code);
+ const result = buildDomainModel(rootContext);
+ const domainModel = result.diagram;
+
+ const dividerStatements = domainModel.rootBlock.statements.filter(s => s.type === 'divider');
+ const divider = dividerStatements[0];
+
+ expect(divider.text).toBe('Red Divider');
+ expect(divider.style?.textStyle?.color).toBe('#FF0000');
+ });
+});
\ No newline at end of file
diff --git a/src/domain/builders/DomainModelBuilder.ts b/src/domain/builders/DomainModelBuilder.ts
new file mode 100644
index 00000000..b1b354cd
--- /dev/null
+++ b/src/domain/builders/DomainModelBuilder.ts
@@ -0,0 +1,439 @@
+import { SequenceDiagram, Participant, Interaction, Fragment, Block, Statement, InteractionType, FragmentType, ParticipantType, DividerStatement, MessageStyle } from '../models/SequenceDiagram';
+import sequenceParser from '@/generated-parser/sequenceParser';
+import sequenceParserListener from '@/generated-parser/sequenceParserListener';
+import antlr4 from 'antlr4';
+
+/**
+ * Builds a domain model from the ANTLR parse tree.
+ * This is the only place that knows about the parse tree structure.
+ * All other parts of the system work with the domain model.
+ */
+export class DomainModelBuilder extends sequenceParserListener {
+ private diagram: SequenceDiagram = {
+ participants: new Map(),
+ interactions: [],
+ fragments: [],
+ rootBlock: { id: 'root', statements: [] }
+ };
+
+ private participantOrder = 0;
+ private idCounter = 0;
+
+ // Stacks for tracking nesting
+ private blockStack: Block[] = [];
+ private fragmentStack: Fragment[] = [];
+ private interactionStack: Interaction[] = [];
+
+ // Context mapping for connecting ANTLR contexts to domain model elements
+ private contextToElementMap = new Map();
+
+ constructor() {
+ super();
+ this.blockStack.push(this.diagram.rootBlock);
+ }
+
+ private generateId(type: string): string {
+ return `${type}_${++this.idCounter}`;
+ }
+
+ private get currentBlock(): Block {
+ return this.blockStack[this.blockStack.length - 1];
+ }
+
+ private get currentFragment(): Fragment | undefined {
+ return this.fragmentStack[this.fragmentStack.length - 1];
+ }
+
+ private get currentInteraction(): Interaction | undefined {
+ return this.interactionStack[this.interactionStack.length - 1];
+ }
+
+ // Title handling
+ enterTitle(ctx: any) {
+ this.diagram.title = ctx.TITLE_CONTENT()?.getText();
+ }
+
+ // Participant handling
+ enterParticipant(ctx: any) {
+ const name = ctx.name()?.getText() || '';
+ const color = ctx.COLOR()?.getText();
+ const participant: Participant = {
+ id: name,
+ name: name,
+ label: ctx.label()?.name()?.getText()?.replace(/['"]/g, ''),
+ type: this.mapParticipantType(ctx.participantType()?.getText()),
+ stereotype: ctx.stereotype()?.getText()?.replace(/^<<|>>$/g, ''),
+ width: ctx.width() ? parseInt(ctx.width().getText()) : undefined,
+ color: color,
+ style: color ? {
+ backgroundColor: color,
+ color: undefined // Will be calculated based on background brightness
+ } : undefined,
+ order: this.participantOrder++
+ };
+
+ this.diagram.participants.set(name, participant);
+ }
+
+ private mapParticipantType(type?: string): ParticipantType {
+ const typeMap: Record = {
+ 'actor': ParticipantType.ACTOR,
+ '@actor': ParticipantType.ACTOR,
+ 'boundary': ParticipantType.BOUNDARY,
+ '@boundary': ParticipantType.BOUNDARY,
+ 'control': ParticipantType.CONTROL,
+ '@control': ParticipantType.CONTROL,
+ 'entity': ParticipantType.ENTITY,
+ '@entity': ParticipantType.ENTITY,
+ 'database': ParticipantType.DATABASE,
+ '@database': ParticipantType.DATABASE,
+ 'collections': ParticipantType.COLLECTIONS,
+ '@collections': ParticipantType.COLLECTIONS,
+ 'queue': ParticipantType.QUEUE,
+ '@queue': ParticipantType.QUEUE
+ };
+
+ // Check for known types first
+ if (typeMap[type || '']) {
+ return typeMap[type || ''];
+ }
+
+ // For AWS services and other custom types starting with @, preserve the type name
+ // This allows @EC2, @Lambda, @S3, etc. to be passed through for icon resolution
+ if (type && type.startsWith('@')) {
+ return type.substring(1) as ParticipantType; // Remove @ prefix and use as type
+ }
+
+ return ParticipantType.PARTICIPANT;
+ }
+
+ // Message/Interaction handling
+ enterMessage(ctx: any) {
+ const from = ctx.From() || '_STARTER_';
+ const to = ctx.Owner() || '';
+ const signature = ctx.SignatureText();
+
+ // Ensure participants exist
+ this.ensureParticipant(from);
+ this.ensureParticipant(to);
+
+ const interaction: Interaction = {
+ id: this.generateId('interaction'),
+ type: InteractionType.SYNC,
+ from,
+ to,
+ message: signature || '',
+ parent: this.currentInteraction?.id,
+ children: []
+ };
+
+ this.diagram.interactions.push(interaction);
+ this.addStatementToCurrentBlock({
+ type: 'interaction',
+ interactionId: interaction.id
+ });
+
+ // If this is a nested call, add to parent's children
+ if (this.currentInteraction) {
+ this.currentInteraction.children?.push(interaction);
+ }
+
+ this.interactionStack.push(interaction);
+ }
+
+ exitMessage(ctx: any) {
+ this.interactionStack.pop();
+ }
+
+ enterAsyncMessage(ctx: any) {
+ const from = ctx.From() || '_STARTER_';
+ const to = ctx.Owner() || '';
+
+ this.ensureParticipant(from);
+ this.ensureParticipant(to);
+
+ const interaction: Interaction = {
+ id: this.generateId('interaction'),
+ type: InteractionType.ASYNC,
+ from,
+ to,
+ message: ctx.SignatureText() || ''
+ };
+
+ this.diagram.interactions.push(interaction);
+ this.addStatementToCurrentBlock({
+ type: 'interaction',
+ interactionId: interaction.id
+ });
+ }
+
+ enterCreation(ctx: any) {
+ const to = ctx.Owner() || '';
+ const from = this.currentInteraction?.to || '_STARTER_';
+
+ this.ensureParticipant(from);
+ this.ensureParticipant(to);
+
+ const interaction: Interaction = {
+ id: this.generateId('interaction'),
+ type: InteractionType.CREATE,
+ from,
+ to,
+ message: 'new'
+ };
+
+ this.diagram.interactions.push(interaction);
+ this.addStatementToCurrentBlock({
+ type: 'interaction',
+ interactionId: interaction.id
+ });
+ }
+
+ // Fragment handling
+ enterAlt(ctx: any) {
+ const fragment: Fragment = {
+ id: this.generateId('fragment'),
+ type: FragmentType.ALT,
+ sections: [],
+ parent: this.currentFragment?.id,
+ comment: ctx.getComment ? ctx.getComment() : undefined
+ };
+
+ this.diagram.fragments.push(fragment);
+ this.addStatementToCurrentBlock({
+ type: 'fragment',
+ fragmentId: fragment.id
+ });
+
+ // Store the mapping from context to fragment ID
+ this.contextToElementMap.set(ctx, fragment.id);
+
+ this.fragmentStack.push(fragment);
+ }
+
+ enterIfBlock(ctx: any) {
+ const fragment = this.currentFragment;
+ if (fragment && fragment.type === FragmentType.ALT) {
+ const block: Block = {
+ id: this.generateId('block'),
+ statements: []
+ };
+
+ fragment.sections.push({
+ condition: ctx.parExpr()?.condition()?.getText(),
+ block
+ });
+
+ this.blockStack.push(block);
+ }
+ }
+
+ exitIfBlock(ctx: any) {
+ this.blockStack.pop();
+ }
+
+ enterElseIfBlock(ctx: any) {
+ const fragment = this.currentFragment;
+ if (fragment && fragment.type === FragmentType.ALT) {
+ const block: Block = {
+ id: this.generateId('block'),
+ statements: []
+ };
+
+ fragment.sections.push({
+ label: 'else if',
+ condition: ctx.parExpr()?.condition()?.getText(),
+ block
+ });
+
+ this.blockStack.push(block);
+ }
+ }
+
+ exitElseIfBlock(ctx: any) {
+ this.blockStack.pop();
+ }
+
+ enterElseBlock(ctx: any) {
+ const fragment = this.currentFragment;
+ if (fragment && fragment.type === FragmentType.ALT) {
+ const block: Block = {
+ id: this.generateId('block'),
+ statements: []
+ };
+
+ fragment.sections.push({
+ label: 'else',
+ block
+ });
+
+ this.blockStack.push(block);
+ }
+ }
+
+ exitElseBlock(ctx: any) {
+ this.blockStack.pop();
+ }
+
+ exitAlt(ctx: any) {
+ this.fragmentStack.pop();
+ }
+
+ // Similar implementations for opt, loop, par, etc...
+ enterOpt(ctx: any) {
+ const fragment: Fragment = {
+ id: this.generateId('fragment'),
+ type: FragmentType.OPT,
+ condition: ctx.parExpr()?.condition()?.getText(),
+ sections: [{
+ block: {
+ id: this.generateId('block'),
+ statements: []
+ }
+ }],
+ parent: this.currentFragment?.id,
+ comment: ctx.getComment ? ctx.getComment() : undefined
+ };
+
+ this.diagram.fragments.push(fragment);
+ this.addStatementToCurrentBlock({
+ type: 'fragment',
+ fragmentId: fragment.id
+ });
+
+ // Store the mapping from context to fragment ID
+ this.contextToElementMap.set(ctx, fragment.id);
+
+ this.fragmentStack.push(fragment);
+ this.blockStack.push(fragment.sections[0].block);
+ }
+
+ exitOpt(ctx: any) {
+ this.blockStack.pop();
+ this.fragmentStack.pop();
+ }
+
+ // Loop fragment
+ enterLoop(ctx: any) {
+ const fragment: Fragment = {
+ id: this.generateId('fragment'),
+ type: FragmentType.LOOP,
+ condition: ctx.parExpr()?.condition()?.getText(),
+ sections: [{
+ block: {
+ id: this.generateId('block'),
+ statements: []
+ }
+ }],
+ parent: this.currentFragment?.id,
+ comment: ctx.getComment ? ctx.getComment() : undefined
+ };
+
+ this.diagram.fragments.push(fragment);
+ this.addStatementToCurrentBlock({
+ type: 'fragment',
+ fragmentId: fragment.id
+ });
+
+ // Store the mapping from context to fragment ID
+ this.contextToElementMap.set(ctx, fragment.id);
+
+ this.fragmentStack.push(fragment);
+ this.blockStack.push(fragment.sections[0].block);
+ }
+
+ exitLoop(ctx: any) {
+ this.blockStack.pop();
+ this.fragmentStack.pop();
+ }
+
+ // Divider handling
+ enterDivider(ctx: any) {
+ const note = ctx.Note();
+ console.log('[DomainModelBuilder] Building divider with note:', note);
+
+ let text = note;
+ let style: MessageStyle | undefined;
+
+ // Parse style from note text
+ if (note.trim().indexOf('[') === 0 && note.indexOf(']') !== -1) {
+ const startIndex = note.indexOf('[');
+ const endIndex = note.indexOf(']');
+ const styleStr = note.slice(startIndex + 1, endIndex);
+ text = note.slice(endIndex + 1);
+
+ // Convert style string to MessageStyle
+ const styles = styleStr.split(',').map((s: string) => s.trim());
+ style = this.parseMessageStyle(styles);
+ }
+
+ const divider: DividerStatement = {
+ type: 'divider',
+ text: text.trim(),
+ style
+ };
+
+ this.addStatementToCurrentBlock(divider);
+ }
+
+ private parseMessageStyle(styles: string[]): MessageStyle {
+ const classNames: string[] = [];
+ const textStyle: any = {};
+
+ styles.forEach(style => {
+ if (style.startsWith('#')) {
+ // Color
+ textStyle.color = style;
+ } else if (style === 'bold') {
+ textStyle.fontWeight = 'bold';
+ } else if (style === 'italic') {
+ textStyle.fontStyle = 'italic';
+ } else {
+ // Add as class name
+ classNames.push(style);
+ }
+ });
+
+ return {
+ textStyle,
+ classNames
+ };
+ }
+
+ // Helper methods
+ private ensureParticipant(name: string) {
+ if (!this.diagram.participants.has(name)) {
+ this.diagram.participants.set(name, {
+ id: name,
+ name: name,
+ type: ParticipantType.PARTICIPANT,
+ order: this.participantOrder++
+ });
+ }
+ }
+
+ private addStatementToCurrentBlock(statement: Statement) {
+ this.currentBlock.statements.push(statement);
+ }
+
+ getDiagram(): SequenceDiagram {
+ return this.diagram;
+ }
+
+ getContextMapping(): Map {
+ return this.contextToElementMap;
+ }
+}
+
+/**
+ * Main entry point for converting parse tree to domain model
+ */
+export function buildDomainModel(parseTree: any): { diagram: SequenceDiagram; contextMapping: Map } {
+ const builder = new DomainModelBuilder();
+ const walker = antlr4.tree.ParseTreeWalker.DEFAULT;
+ walker.walk(builder, parseTree);
+ return {
+ diagram: builder.getDiagram(),
+ contextMapping: builder.getContextMapping()
+ };
+}
\ No newline at end of file
diff --git a/src/domain/layout/LayoutCalculator.spec.ts b/src/domain/layout/LayoutCalculator.spec.ts
new file mode 100644
index 00000000..d44cccd8
--- /dev/null
+++ b/src/domain/layout/LayoutCalculator.spec.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { LayoutCalculator } from '@/domain/layout/LayoutCalculator';
+import { SequenceDiagram, DividerStatement } from '@/domain/models/SequenceDiagram';
+
+describe('LayoutCalculator - Divider Layout', () => {
+ it('should calculate layout for dividers', () => {
+ // Create a simple domain model with a divider
+ const diagram: SequenceDiagram = {
+ participants: new Map([
+ ['A', { id: 'A', name: 'A', type: 'participant' as any, order: 0 }],
+ ['B', { id: 'B', name: 'B', type: 'participant' as any, order: 1 }]
+ ]),
+ interactions: [
+ {
+ id: 'i1',
+ type: 'sync' as any,
+ from: 'A',
+ to: 'B',
+ message: 'test'
+ }
+ ],
+ fragments: [],
+ rootBlock: {
+ id: 'root',
+ statements: [
+ { type: 'interaction', interactionId: 'i1' },
+ {
+ type: 'divider',
+ text: 'Test Divider',
+ style: {
+ textStyle: { color: 'red' },
+ classNames: ['custom-class']
+ }
+ } as DividerStatement
+ ]
+ }
+ };
+
+ const calculator = new LayoutCalculator();
+ const layout = calculator.calculate(diagram);
+
+ // Verify we have divider layout
+ expect(layout.dividers).toHaveLength(1);
+
+ const dividerLayout = layout.dividers[0];
+ expect(dividerLayout.text).toBe('Test Divider');
+ expect(dividerLayout.style?.textStyle?.color).toBe('red');
+ expect(dividerLayout.style?.classNames).toContain('custom-class');
+
+ // Verify positioning
+ expect(dividerLayout.bounds.x).toBe(10); // Left margin
+ expect(dividerLayout.bounds.y).toBeGreaterThan(0); // After the interaction
+ expect(dividerLayout.bounds.width).toBeGreaterThan(0);
+ expect(dividerLayout.bounds.height).toBe(20);
+
+ // Verify label positioning
+ expect(dividerLayout.labelBounds.width).toBe(100);
+ expect(dividerLayout.labelBounds.height).toBe(16);
+ });
+});
\ No newline at end of file
diff --git a/src/domain/layout/LayoutCalculator.ts b/src/domain/layout/LayoutCalculator.ts
new file mode 100644
index 00000000..f2940398
--- /dev/null
+++ b/src/domain/layout/LayoutCalculator.ts
@@ -0,0 +1,443 @@
+import { SequenceDiagram, Participant, Interaction, Fragment, Block, Statement, DividerStatement } from '../models/SequenceDiagram';
+import { DiagramLayout, ParticipantLayout, InteractionLayout, FragmentLayout, ActivationLayout, BoundingBox, Point, LayoutConstraints, DividerLayout } from '../models/DiagramLayout';
+
+/**
+ * Calculates layout from domain model.
+ * This is a pure function that takes a domain model and returns layout information.
+ * No side effects, no external dependencies.
+ */
+export class LayoutCalculator {
+ private constraints: LayoutConstraints = {
+ minParticipantWidth: 100,
+ participantPadding: 50,
+ interactionHeight: 30,
+ fragmentPadding: 10,
+ activationWidth: 15,
+ selfMessageWidth: 40
+ };
+
+ private participantPositions = new Map();
+ private currentY = 50; // Start position
+
+ calculate(diagram: SequenceDiagram): DiagramLayout {
+ console.log('[LayoutCalculator] Calculating layout for diagram');
+ console.log(' - Participants:', diagram.participants.size);
+ console.log(' - Interactions:', diagram.interactions.length);
+ console.log(' - Fragments:', diagram.fragments.length);
+
+ // Step 1: Calculate participant positions
+ const participants = this.calculateParticipantLayouts(diagram);
+
+ // Step 2: Process root block to calculate vertical positions
+ const { interactions, fragments, activations, dividers } = this.processBlock(
+ diagram.rootBlock,
+ diagram,
+ 0 // nesting level
+ );
+
+ // Step 3: Calculate lifeline heights
+ const lifelines = this.calculateLifelineLayouts(participants, this.currentY);
+
+ // Step 4: Calculate total dimensions
+ const width = this.calculateTotalWidth(participants);
+ const height = this.currentY + 50; // Add bottom padding
+
+ return {
+ width,
+ height,
+ participants,
+ lifelines,
+ interactions,
+ fragments,
+ activations,
+ dividers
+ };
+ }
+
+ private calculateParticipantLayouts(diagram: SequenceDiagram): ParticipantLayout[] {
+ const layouts: ParticipantLayout[] = [];
+ let currentX = 50; // Left margin
+
+ // Sort participants by order
+ const sortedParticipants = Array.from(diagram.participants.values())
+ .sort((a, b) => a.order - b.order);
+
+ for (const participant of sortedParticipants) {
+ const width = this.calculateParticipantWidth(participant);
+ const centerX = currentX + width / 2;
+
+ this.participantPositions.set(participant.id, centerX);
+
+ layouts.push({
+ participantId: participant.id,
+ bounds: {
+ x: currentX,
+ y: 10,
+ width,
+ height: 40
+ },
+ labelBounds: {
+ x: currentX + 10,
+ y: 20,
+ width: width - 20,
+ height: 20
+ },
+ lifelineX: centerX,
+ // Additional properties for rendering
+ label: participant.label || participant.name,
+ type: participant.type,
+ stereotype: participant.stereotype,
+ isAssignee: Boolean(participant.assignee),
+ style: participant.style ? {
+ backgroundColor: participant.style.backgroundColor,
+ color: participant.style.color
+ } : undefined,
+ // These would be calculated from interactions in a real implementation
+ labelPositions: [],
+ assigneePositions: []
+ });
+
+ currentX += width + this.constraints.participantPadding;
+ }
+
+ return layouts;
+ }
+
+ private calculateParticipantWidth(participant: Participant): number {
+ // In real implementation, would measure text
+ const labelWidth = (participant.label || participant.name).length * 8;
+ return Math.max(
+ this.constraints.minParticipantWidth,
+ participant.width || 0,
+ labelWidth + 20
+ );
+ }
+
+ private processBlock(
+ block: Block,
+ diagram: SequenceDiagram,
+ nestingLevel: number
+ ): {
+ interactions: InteractionLayout[];
+ fragments: FragmentLayout[];
+ activations: ActivationLayout[];
+ dividers: DividerLayout[];
+ } {
+ const interactions: InteractionLayout[] = [];
+ const fragments: FragmentLayout[] = [];
+ const activations: ActivationLayout[] = [];
+ const dividers: DividerLayout[] = [];
+
+ for (const statement of block.statements) {
+ switch (statement.type) {
+ case 'interaction':
+ const interaction = diagram.interactions.find(
+ i => i.id === statement.interactionId
+ );
+ if (interaction) {
+ const layout = this.calculateInteractionLayout(interaction, nestingLevel);
+ interactions.push(layout);
+
+ // Add activation if sync
+ if (interaction.type === 'sync' || interaction.type === 'create') {
+ activations.push(this.createActivation(interaction, layout));
+ }
+
+ this.currentY += this.constraints.interactionHeight;
+ }
+ break;
+
+ case 'fragment':
+ const fragment = diagram.fragments.find(
+ f => f.id === statement.fragmentId
+ );
+ if (fragment) {
+ const fragmentLayout = this.calculateFragmentLayout(
+ fragment,
+ diagram,
+ nestingLevel
+ );
+ fragments.push(fragmentLayout);
+
+ // Process fragment sections
+ for (const section of fragment.sections) {
+ const sectionResult = this.processBlock(
+ section.block,
+ diagram,
+ nestingLevel + 1
+ );
+ interactions.push(...sectionResult.interactions);
+ fragments.push(...sectionResult.fragments);
+ activations.push(...sectionResult.activations);
+ dividers.push(...sectionResult.dividers);
+ }
+ }
+ break;
+
+ case 'divider':
+ console.log('[LayoutCalculator] Processing divider statement:', statement);
+ const dividerLayout = this.calculateDividerLayout(
+ statement as DividerStatement,
+ diagram
+ );
+ dividers.push(dividerLayout);
+ this.currentY += 20;
+ break;
+
+ case 'comment':
+ this.currentY += 15;
+ break;
+ }
+ }
+
+ return { interactions, fragments, activations, dividers };
+ }
+
+ private calculateInteractionLayout(
+ interaction: Interaction,
+ nestingLevel: number
+ ): InteractionLayout {
+ const fromX = this.participantPositions.get(interaction.from) || 0;
+ const toX = this.participantPositions.get(interaction.to) || 0;
+
+ const isSelfMessage = interaction.from === interaction.to;
+
+ if (isSelfMessage) {
+ return {
+ interactionId: interaction.id,
+ type: interaction.type,
+ from: interaction.from,
+ to: interaction.to,
+ message: interaction.message,
+ isSelfMessage: true,
+ startPoint: { x: fromX, y: this.currentY },
+ endPoint: { x: fromX + this.constraints.selfMessageWidth, y: this.currentY + 20 },
+ labelBounds: {
+ x: fromX + 10,
+ y: this.currentY - 10,
+ width: this.constraints.selfMessageWidth,
+ height: 15
+ },
+ arrowStyle: {
+ lineType: interaction.type === 'return' ? 'dashed' : 'solid',
+ arrowHead: interaction.type === 'async' ? 'open' : 'filled',
+ selfMessage: {
+ width: this.constraints.selfMessageWidth,
+ height: 20
+ }
+ },
+ translateX: fromX,
+ width: this.constraints.selfMessageWidth
+ };
+ }
+
+ return {
+ interactionId: interaction.id,
+ type: interaction.type,
+ from: interaction.from,
+ to: interaction.to,
+ message: interaction.message,
+ rightToLeft: fromX > toX,
+ isSelfMessage: false,
+ startPoint: {
+ x: fromX + (nestingLevel * this.constraints.activationWidth),
+ y: this.currentY
+ },
+ endPoint: {
+ x: toX - (nestingLevel * this.constraints.activationWidth),
+ y: this.currentY
+ },
+ labelBounds: {
+ x: Math.min(fromX, toX) + 10,
+ y: this.currentY - 15,
+ width: Math.abs(toX - fromX) - 20,
+ height: 15
+ },
+ arrowStyle: {
+ lineType: interaction.type === 'return' ? 'dashed' : 'solid',
+ arrowHead: interaction.type === 'async' ? 'open' : 'filled'
+ },
+ translateX: Math.min(fromX, toX),
+ width: Math.abs(toX - fromX)
+ };
+ }
+
+ private calculateFragmentLayout(
+ fragment: Fragment,
+ diagram: SequenceDiagram,
+ nestingLevel: number
+ ): FragmentLayout {
+ // Find participants involved in this fragment
+ const involvedParticipants = this.findInvolvedParticipants(fragment, diagram);
+ if (involvedParticipants.length === 0) {
+ // If no participants found, use all participants
+ involvedParticipants.push(...Array.from(diagram.participants.keys()));
+ }
+
+ const leftmostX = Math.min(...involvedParticipants.map(p =>
+ this.participantPositions.get(p) || 0
+ )) - 50;
+ const rightmostX = Math.max(...involvedParticipants.map(p =>
+ this.participantPositions.get(p) || 0
+ )) + 50;
+
+ const startY = this.currentY;
+ const padding = this.constraints.fragmentPadding + (nestingLevel * 10);
+ const paddingLeft = padding + 20; // Internal padding for content
+
+ // Calculate transform based on leftmost participant
+ const leftmostParticipant = involvedParticipants.reduce((left, p) => {
+ const pos = this.participantPositions.get(p) || 0;
+ const leftPos = this.participantPositions.get(left) || 0;
+ return pos < leftPos ? p : left;
+ });
+
+ const leftParticipantX = this.participantPositions.get(leftmostParticipant) || 0;
+ const transform = `translateX(${leftParticipantX - padding}px)`;
+
+ const fragmentLayout: FragmentLayout = {
+ fragmentId: fragment.id,
+ type: fragment.type,
+ bounds: {
+ x: leftmostX - padding,
+ y: startY,
+ width: rightmostX - leftmostX + 2 * padding,
+ height: 100 // Will be adjusted after processing content
+ },
+ headerBounds: {
+ x: leftmostX - padding,
+ y: startY,
+ width: 80,
+ height: 25
+ },
+ sections: fragment.sections.map((section, index) => ({
+ bounds: {
+ x: leftmostX - padding,
+ y: startY + 25 + (index * 50), // Will be adjusted
+ width: rightmostX - leftmostX + 2 * padding,
+ height: 50 // Will be adjusted
+ },
+ contentOffset: {
+ x: paddingLeft,
+ y: 5
+ },
+ label: section.label,
+ condition: section.condition
+ })),
+ nestingLevel,
+ comment: fragment.comment,
+ style: fragment.style,
+ paddingLeft,
+ transform
+ };
+
+ return fragmentLayout;
+ }
+
+ private createActivation(
+ interaction: Interaction,
+ layout: InteractionLayout
+ ): ActivationLayout {
+ return {
+ participantId: interaction.to,
+ bounds: {
+ x: layout.endPoint.x - this.constraints.activationWidth / 2,
+ y: layout.endPoint.y,
+ width: this.constraints.activationWidth,
+ height: 40 // Will be adjusted based on nested calls
+ },
+ level: 0 // Will be calculated based on active activations
+ };
+ }
+
+ private calculateLifelineLayouts(
+ participants: ParticipantLayout[],
+ maxY: number
+ ) {
+ return participants.map(p => ({
+ participantId: p.participantId,
+ x: p.lifelineX,
+ startY: p.bounds.y + p.bounds.height,
+ endY: maxY
+ }));
+ }
+
+ private calculateTotalWidth(participants: ParticipantLayout[]): number {
+ if (participants.length === 0) return 0;
+ const lastParticipant = participants[participants.length - 1];
+ return lastParticipant.bounds.x + lastParticipant.bounds.width + 50;
+ }
+
+ private findInvolvedParticipants(
+ fragment: Fragment,
+ diagram: SequenceDiagram
+ ): string[] {
+ const participants = new Set();
+
+ // Find all interactions in this fragment's blocks
+ for (const section of fragment.sections) {
+ this.collectParticipantsFromBlock(section.block, diagram, participants);
+ }
+
+ return Array.from(participants);
+ }
+
+ private collectParticipantsFromBlock(
+ block: Block,
+ diagram: SequenceDiagram,
+ participants: Set
+ ) {
+ for (const statement of block.statements) {
+ if (statement.type === 'interaction') {
+ const interaction = diagram.interactions.find(
+ i => i.id === statement.interactionId
+ );
+ if (interaction) {
+ participants.add(interaction.from);
+ participants.add(interaction.to);
+ }
+ } else if (statement.type === 'fragment') {
+ const fragment = diagram.fragments.find(
+ f => f.id === statement.fragmentId
+ );
+ if (fragment) {
+ for (const section of fragment.sections) {
+ this.collectParticipantsFromBlock(section.block, diagram, participants);
+ }
+ }
+ }
+ }
+ }
+
+ private calculateDividerLayout(
+ divider: DividerStatement,
+ diagram: SequenceDiagram
+ ): DividerLayout {
+ // Get the rightmost participant to determine diagram width
+ const sortedParticipants = Array.from(diagram.participants.values())
+ .sort((a, b) => a.order - b.order);
+
+ const rightmostParticipant = sortedParticipants[sortedParticipants.length - 1];
+ const rightmostPosition = this.participantPositions.get(rightmostParticipant?.id || '') || 0;
+
+ // Divider spans from left edge to beyond rightmost participant
+ const dividerWidth = rightmostPosition + 50; // Add some padding
+
+ return {
+ bounds: {
+ x: 10, // Start from left margin
+ y: this.currentY,
+ width: dividerWidth,
+ height: 20
+ },
+ labelBounds: {
+ x: dividerWidth / 2 - 50, // Center the label
+ y: this.currentY + 2,
+ width: 100,
+ height: 16
+ },
+ text: divider.text,
+ style: divider.style
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/domain/models/DiagramLayout.ts b/src/domain/models/DiagramLayout.ts
new file mode 100644
index 00000000..d0463be0
--- /dev/null
+++ b/src/domain/models/DiagramLayout.ts
@@ -0,0 +1,153 @@
+/**
+ * Layout Models - Geometric representation for rendering
+ * These models contain all positioning and sizing information needed for rendering,
+ * computed from the domain models.
+ */
+
+export interface DiagramLayout {
+ width: number;
+ height: number;
+ participants: ParticipantLayout[];
+ lifelines: LifelineLayout[];
+ interactions: InteractionLayout[];
+ fragments: FragmentLayout[];
+ activations: ActivationLayout[];
+ dividers: DividerLayout[];
+}
+
+export interface BoundingBox {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface ParticipantLayout {
+ participantId: string;
+ bounds: BoundingBox;
+ labelBounds: BoundingBox;
+ lifelineX: number; // Center X position for lifeline
+ // Additional properties for rendering
+ label: string;
+ type?: string;
+ stereotype?: string;
+ isAssignee?: boolean;
+ style?: {
+ backgroundColor?: string;
+ color?: string;
+ };
+ labelPositions?: [number, number][];
+ assigneePositions?: [number, number][];
+}
+
+export interface LifelineLayout {
+ participantId: string;
+ x: number;
+ startY: number;
+ endY: number;
+}
+
+export interface InteractionLayout {
+ interactionId: string;
+ type: 'sync' | 'async' | 'create' | 'return';
+ from: string;
+ to: string;
+ message: string;
+ startPoint: Point;
+ endPoint: Point;
+ labelBounds: BoundingBox;
+ arrowStyle: ArrowStyle;
+ rightToLeft?: boolean;
+ isSelfMessage?: boolean;
+ assignee?: string;
+ translateX?: number;
+ width?: number;
+ children?: InteractionLayout[]; // For nested calls
+}
+
+export interface Point {
+ x: number;
+ y: number;
+}
+
+export interface ArrowStyle {
+ lineType: 'solid' | 'dashed';
+ arrowHead: 'filled' | 'open';
+ selfMessage?: {
+ width: number;
+ height: number;
+ };
+}
+
+export interface FragmentLayout {
+ fragmentId: string;
+ type: string;
+ bounds: BoundingBox;
+ headerBounds: BoundingBox;
+ sections: FragmentSectionLayout[];
+ nestingLevel: number; // For border calculation
+ comment?: string;
+ style?: {
+ textStyle?: any;
+ classNames?: string[];
+ };
+ paddingLeft?: number; // Internal padding for content
+ transform?: string; // CSS transform for positioning
+}
+
+export interface FragmentSectionLayout {
+ bounds: BoundingBox;
+ labelBounds?: BoundingBox;
+ contentOffset: Point; // Offset for nested content
+ label?: string; // e.g., "else", "else if", "catch"
+ condition?: string; // Condition text for this section
+}
+
+export interface ActivationLayout {
+ participantId: string;
+ bounds: BoundingBox;
+ level: number; // Nesting level for stacked activations
+}
+
+export interface DividerLayout {
+ bounds: BoundingBox;
+ labelBounds: BoundingBox;
+ text: string;
+ style?: {
+ textStyle?: any;
+ classNames?: string[];
+ };
+}
+
+/**
+ * Layout constraints used during calculation
+ */
+export interface LayoutConstraints {
+ minParticipantWidth: number;
+ participantPadding: number;
+ interactionHeight: number;
+ fragmentPadding: number;
+ activationWidth: number;
+ selfMessageWidth: number;
+}
+
+/**
+ * Layout calculation result with debugging info
+ */
+export interface LayoutResult {
+ layout: DiagramLayout;
+ constraints: ConstraintViolation[];
+ warnings: LayoutWarning[];
+}
+
+export interface ConstraintViolation {
+ type: 'overlap' | 'overflow' | 'underflow';
+ message: string;
+ elements: string[]; // IDs of affected elements
+}
+
+export interface LayoutWarning {
+ type: 'truncated' | 'adjusted';
+ message: string;
+ elementId: string;
+}
\ No newline at end of file
diff --git a/src/domain/models/SequenceDiagram.ts b/src/domain/models/SequenceDiagram.ts
new file mode 100644
index 00000000..841d09e8
--- /dev/null
+++ b/src/domain/models/SequenceDiagram.ts
@@ -0,0 +1,137 @@
+/**
+ * Domain Models for Sequence Diagrams
+ * These models represent the conceptual structure of a sequence diagram,
+ * independent of parsing or rendering concerns.
+ */
+
+export interface SequenceDiagram {
+ title?: string;
+ participants: Map;
+ interactions: Interaction[];
+ fragments: Fragment[];
+ rootBlock: Block;
+}
+
+export interface Participant {
+ id: string;
+ name: string;
+ label?: string;
+ type: ParticipantType;
+ stereotype?: string;
+ width?: number;
+ color?: string;
+ group?: string;
+ order: number; // Explicit ordering
+ style?: {
+ backgroundColor?: string;
+ color?: string;
+ };
+}
+
+export enum ParticipantType {
+ PARTICIPANT = 'participant',
+ ACTOR = 'actor',
+ BOUNDARY = 'boundary',
+ CONTROL = 'control',
+ ENTITY = 'entity',
+ DATABASE = 'database',
+ COLLECTIONS = 'collections',
+ QUEUE = 'queue'
+}
+
+export interface Interaction {
+ id: string;
+ type: InteractionType;
+ from: string; // Participant ID
+ to: string; // Participant ID
+ message: string;
+ signature?: MethodSignature;
+ returnValue?: string;
+ children?: Interaction[]; // Nested interactions
+ parent?: string; // Parent interaction ID
+}
+
+export enum InteractionType {
+ SYNC = 'sync',
+ ASYNC = 'async',
+ CREATE = 'create',
+ RETURN = 'return'
+}
+
+export interface MethodSignature {
+ name: string;
+ parameters: Parameter[];
+ isStatic?: boolean;
+ isAsync?: boolean;
+}
+
+export interface Parameter {
+ name?: string;
+ type?: string;
+ value?: string;
+}
+
+export interface Fragment {
+ id: string;
+ type: FragmentType;
+ condition?: string; // For alt, opt, loop
+ sections: FragmentSection[];
+ parent?: string; // Parent fragment ID for nesting
+ comment?: string; // Comment text for the fragment
+ style?: MessageStyle; // Style for the fragment
+}
+
+export enum FragmentType {
+ ALT = 'alt',
+ OPT = 'opt',
+ LOOP = 'loop',
+ PAR = 'par',
+ CRITICAL = 'critical',
+ SECTION = 'section',
+ REF = 'ref',
+ TRY_CATCH = 'try-catch'
+}
+
+export interface FragmentSection {
+ label?: string; // e.g., "else", "catch", "finally"
+ condition?: string;
+ block: Block;
+}
+
+export interface Block {
+ id: string;
+ statements: Statement[];
+}
+
+export type Statement =
+ | InteractionStatement
+ | FragmentStatement
+ | DividerStatement
+ | CommentStatement;
+
+export interface InteractionStatement {
+ type: 'interaction';
+ interactionId: string;
+}
+
+export interface FragmentStatement {
+ type: 'fragment';
+ fragmentId: string;
+}
+
+export interface DividerStatement {
+ type: 'divider';
+ text: string;
+ style?: MessageStyle;
+}
+
+export interface MessageStyle {
+ textStyle?: any;
+ classNames?: string[];
+}
+
+export interface CommentStatement {
+ type: 'comment';
+ text: string;
+ style?: any;
+}
\ No newline at end of file
diff --git a/src/examples/NewArchitectureExample.tsx b/src/examples/NewArchitectureExample.tsx
new file mode 100644
index 00000000..bab59113
--- /dev/null
+++ b/src/examples/NewArchitectureExample.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { useAtomValue } from 'jotai';
+import { diagramLayoutAtom, domainModelAtom } from '@/domain/DomainModelStore';
+
+/**
+ * Example component showing how to use the new architecture
+ */
+export const NewArchitectureExample = () => {
+ const domainModel = useAtomValue(domainModelAtom);
+ const diagramLayout = useAtomValue(diagramLayoutAtom);
+
+ if (!domainModel || !diagramLayout) {
+ return Loading...
;
+ }
+
+ return (
+
+
Domain Model Participants
+
+ {Array.from(domainModel.participants.values()).map(participant => (
+
+ {participant.name} - {participant.type}
+ {participant.color && ` (${participant.color})`}
+
+ ))}
+
+
+
Layout Information
+
+ {diagramLayout.participants.map(layout => (
+
+ {layout.participantId}: x={layout.bounds.x}, width={layout.bounds.width}
+
+ ))}
+
+
+
Dividers
+
+ {diagramLayout.dividers.map((divider, index) => (
+
+ {divider.text} at y={divider.bounds.y}
+
+ ))}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/layout/calculator/ArrowCalculator.spec.ts b/src/layout/calculator/ArrowCalculator.spec.ts
new file mode 100644
index 00000000..09cba1ff
--- /dev/null
+++ b/src/layout/calculator/ArrowCalculator.spec.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+import { ArrowCalculator } from "./ArrowCalculator";
+import { ArrowGeometry } from "../geometry/ArrowGeometry";
+
+describe("ArrowCalculator", () => {
+ const calculator = new ArrowCalculator();
+
+ it("should calculate arrow layout for horizontal arrow", () => {
+ const geometry: ArrowGeometry = {
+ origin: { name: "A", centerPosition: 100, activationLayers: 1 },
+ source: { name: "A", centerPosition: 100, activationLayers: 1 },
+ target: { name: "B", centerPosition: 200, activationLayers: 0 },
+ isSelfCall: false,
+ };
+
+ const result = calculator.calculateArrowLayout(geometry);
+
+ expect(result.isSelf).toBe(false);
+ expect(result.rightToLeft).toBe(false);
+ expect(result.originLayers).toBe(1);
+ expect(result.sourceLayers).toBe(1);
+ expect(result.targetLayers).toBe(0);
+ expect(result.interactionWidth).toBeGreaterThan(0);
+ expect(result.translateX).toBeDefined();
+ });
+
+ it("should calculate arrow layout for self call", () => {
+ const geometry: ArrowGeometry = {
+ origin: { name: "A", centerPosition: 100, activationLayers: 0 },
+ source: { name: "A", centerPosition: 100, activationLayers: 0 },
+ target: { name: "A", centerPosition: 100, activationLayers: 1 },
+ isSelfCall: true,
+ };
+
+ const result = calculator.calculateArrowLayout(geometry);
+
+ expect(result.isSelf).toBe(true);
+ expect(result.originLayers).toBe(0);
+ expect(result.sourceLayers).toBe(0);
+ expect(result.targetLayers).toBe(1);
+ });
+
+ it("should detect right-to-left arrow direction", () => {
+ const geometry: ArrowGeometry = {
+ origin: { name: "B", centerPosition: 200, activationLayers: 0 },
+ source: { name: "B", centerPosition: 200, activationLayers: 0 },
+ target: { name: "A", centerPosition: 100, activationLayers: 0 },
+ isSelfCall: false,
+ };
+
+ const result = calculator.calculateArrowLayout(geometry);
+
+ expect(result.rightToLeft).toBe(true);
+ });
+
+ it("should calculate distance between participants", () => {
+ const from = { name: "A", centerPosition: 100, activationLayers: 0 };
+ const to = { name: "B", centerPosition: 200, activationLayers: 0 };
+
+ const distance = calculator.calculateDistance(from, to);
+
+ expect(distance).toBe(100);
+ });
+
+ it("should detect self call correctly", () => {
+ expect(calculator.isSelfCall("A", "A")).toBe(true);
+ expect(calculator.isSelfCall("A", "B")).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/src/layout/calculator/ArrowCalculator.ts b/src/layout/calculator/ArrowCalculator.ts
new file mode 100644
index 00000000..ed717f25
--- /dev/null
+++ b/src/layout/calculator/ArrowCalculator.ts
@@ -0,0 +1,75 @@
+import Anchor2 from "@/positioning/Anchor2";
+import { ArrowGeometry, ArrowLayoutResult, ParticipantArrowData } from "../geometry/ArrowGeometry";
+
+/**
+ * Pure mathematical arrow layout calculator
+ * Contains no dependencies on Context or parsing logic
+ * All calculations are based on pre-extracted geometric data
+ */
+export class ArrowCalculator {
+
+ /**
+ * Calculate complete arrow layout from pure geometric data
+ * This replaces the context-dependent useArrow hook logic
+ */
+ calculateArrowLayout(geometry: ArrowGeometry): ArrowLayoutResult {
+ const { origin, source, target } = geometry;
+
+ // Create anchor objects for mathematical calculations (used internally only)
+ const anchor2Origin = this.createAnchor(origin);
+ const anchor2Source = this.createAnchor(source);
+ const anchor2Target = this.createAnchor(target);
+
+ // Pure mathematical calculations
+ const interactionWidth = Math.abs(anchor2Source.edgeOffset(anchor2Target));
+ const rightToLeft = this.isRightToLeft(source, target);
+ const translateX = anchor2Origin.centerToEdge(
+ !rightToLeft ? anchor2Source : anchor2Target
+ );
+
+ return {
+ // Core layout fields (used by all components)
+ interactionWidth,
+ rightToLeft,
+ translateX,
+
+ // Metadata fields (used by specific components)
+ isSelf: geometry.isSelfCall,
+ originLayers: origin.activationLayers,
+ sourceLayers: source.activationLayers,
+ targetLayers: target.activationLayers,
+ };
+ }
+
+ /**
+ * Create Anchor2 instance from participant data
+ * Pure mathematical transformation of geometric parameters
+ */
+ private createAnchor(participant: ParticipantArrowData): Anchor2 {
+ return new Anchor2(participant.centerPosition, participant.activationLayers);
+ }
+
+ /**
+ * Determine arrow direction based on participant positions
+ * Pure mathematical comparison, no context traversal needed
+ */
+ private isRightToLeft(source: ParticipantArrowData, target: ParticipantArrowData): boolean {
+ return target.centerPosition - source.centerPosition < 0;
+ }
+
+ /**
+ * Calculate distance between two participants
+ * Pure mathematical function based on positions
+ */
+ calculateDistance(from: ParticipantArrowData, to: ParticipantArrowData): number {
+ return to.centerPosition - from.centerPosition;
+ }
+
+ /**
+ * Check if this is a self-call based on participant names
+ * Simple string comparison, no context needed
+ */
+ isSelfCall(source: string, target: string): boolean {
+ return source === target;
+ }
+}
\ No newline at end of file
diff --git a/src/layout/extractor/ArrowGeometryExtractor.ts b/src/layout/extractor/ArrowGeometryExtractor.ts
new file mode 100644
index 00000000..ea97be0f
--- /dev/null
+++ b/src/layout/extractor/ArrowGeometryExtractor.ts
@@ -0,0 +1,93 @@
+import { ArrowGeometry, ParticipantArrowData } from "../geometry/ArrowGeometry";
+import { getParticipantCenter } from "@/positioning/GeometryUtils";
+import { depthOnParticipant } from "@/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/utils";
+import sequenceParser from "@/generated-parser/sequenceParser";
+
+/**
+ * Extracts pure geometric data from context for arrow calculations
+ * This is the only place that should touch the context object
+ * All mathematical calculations should use the extracted data
+ */
+export class ArrowGeometryExtractor {
+
+ /**
+ * Extract all geometric data needed for arrow calculations
+ * Replaces the context-dependent logic in useArrow
+ */
+ static extractArrowGeometry({
+ context,
+ origin,
+ source,
+ target,
+ }: {
+ context: any;
+ origin: string;
+ source: string;
+ target: string;
+ }): ArrowGeometry {
+
+ // Extract geometric data for each participant
+ const originData = this.extractParticipantData(context, origin);
+ const sourceData = this.extractParticipantData(context, source);
+ const targetData = this.extractParticipantData(context, target, true); // Use special logic for target
+
+ return {
+ origin: originData,
+ source: sourceData,
+ target: targetData,
+ isSelfCall: source === target,
+ };
+ }
+
+ /**
+ * Extract geometric data for a single participant
+ * Centralizes all context-dependent logic for participant data
+ */
+ private static extractParticipantData(
+ context: any,
+ participantName: string,
+ isTarget: boolean = false
+ ): ParticipantArrowData {
+
+ const centerPosition = getParticipantCenter(participantName);
+ const activationLayers = isTarget
+ ? this.depthOnParticipantForTarget(context, participantName)
+ : depthOnParticipant(context, participantName);
+
+ return {
+ name: participantName,
+ centerPosition,
+ activationLayers,
+ };
+ }
+
+ /**
+ * Special depth calculation for target participants
+ * Migrated from depthOnParticipant4Stat in useArrow.ts
+ */
+ private static depthOnParticipantForTarget(context: any, participant: any): number {
+ if (!(context instanceof sequenceParser.StatContext)) {
+ return 0;
+ }
+
+ // Get the first child by checking each possible child type
+ const child = context.alt() ||
+ context.par() ||
+ context.opt() ||
+ context.critical() ||
+ context.section() ||
+ context.ref() ||
+ context.loop() ||
+ context.creation() ||
+ context.message() ||
+ context.asyncMessage() ||
+ context.ret() ||
+ context.divider() ||
+ context.tcf();
+
+ if (!child) {
+ return 0;
+ }
+ return depthOnParticipant(child, participant);
+ }
+}
\ No newline at end of file
diff --git a/src/layout/geometry/ArrowGeometry.ts b/src/layout/geometry/ArrowGeometry.ts
new file mode 100644
index 00000000..b3fc1471
--- /dev/null
+++ b/src/layout/geometry/ArrowGeometry.ts
@@ -0,0 +1,44 @@
+/**
+ * Pure data objects for arrow layout calculations
+ * Contains only geometric parameters, no behavior or context dependencies
+ */
+
+/**
+ * Geometric data for a single participant in arrow calculations
+ */
+export interface ParticipantArrowData {
+ readonly name: string;
+ readonly centerPosition: number;
+ readonly activationLayers: number;
+}
+
+/**
+ * Complete geometric data needed for arrow layout calculations
+ * This replaces the need to pass context and perform runtime traversals
+ */
+export interface ArrowGeometry {
+ readonly origin: ParticipantArrowData;
+ readonly source: ParticipantArrowData;
+ readonly target: ParticipantArrowData;
+ readonly isSelfCall: boolean;
+}
+
+/**
+ * Result of pure mathematical arrow layout calculations
+ * Contains only the fields actually used by components
+ */
+export interface ArrowLayoutResult {
+ // Core layout fields - used by all components (4/4)
+ readonly interactionWidth: number;
+ readonly rightToLeft: boolean;
+ readonly translateX: number;
+
+ // Metadata fields - used by specific components
+ readonly isSelf: boolean; // Used by Return component (1/4)
+ readonly originLayers: number; // Used by Interaction component for data attributes (1/4)
+ readonly sourceLayers: number; // Used by Interaction component for data attributes (1/4)
+ readonly targetLayers: number; // Used by Interaction component for data attributes (1/4)
+
+ // Note: Removed unused anchor objects (anchor2Origin, anchor2Source, anchor2Target)
+ // They were never used by any component (0/4 usage)
+}
\ No newline at end of file
diff --git a/src/parser/ContextsFixture.ts b/src/parser/ContextsFixture.ts
index 07f19740..6dffc6e1 100644
--- a/src/parser/ContextsFixture.ts
+++ b/src/parser/ContextsFixture.ts
@@ -13,7 +13,7 @@ function createParser(code: any) {
return parser;
}
-// eslint-disable-next-line no-unused-vars
+
function createParseFunction(parseMethod: (parser: sequenceParser) => any) {
return (code: string) => {
const parser = createParser(code);
diff --git a/src/parser/FrameBuilder.ts b/src/parser/FrameBuilder.ts
index c03862b5..0f2efc0f 100644
--- a/src/parser/FrameBuilder.ts
+++ b/src/parser/FrameBuilder.ts
@@ -1,9 +1,6 @@
import antlr4 from "antlr4";
import sequenceParserListener from "../generated-parser/sequenceParserListener";
import { Frame } from "@/positioning/FrameBorder";
-import { Participants } from "./index";
-import { Participant } from "@/parser/Participants";
-import { _STARTER_ } from "@/parser/OrderedParticipants";
import { getLocalParticipantNames } from "@/positioning/LocalParticipants";
const walker = antlr4.tree.ParseTreeWalker.DEFAULT;
@@ -18,16 +15,6 @@ class FrameBuilder extends sequenceParserListener {
this._orderedParticipants = orderedParticipants;
}
- // TODO: extract a module to get local participants
- private getLocalParticipants(ctx: any): string[] {
- return [
- ctx.Origin() || _STARTER_,
- ...Participants(ctx)
- .ImplicitArray()
- .map((p: Participant) => p.name),
- ];
- }
-
private getLeft(ctx: any): string {
const localParticipants = getLocalParticipantNames(ctx);
return (
diff --git a/src/positioning/Anchor2.ts b/src/positioning/Anchor2.ts
index 6b860683..f008abd4 100644
--- a/src/positioning/Anchor2.ts
+++ b/src/positioning/Anchor2.ts
@@ -16,6 +16,9 @@ export default class Anchor2 {
return other.rightEdgeOfRightWall() - this.centerOfRightWall();
}
+ centerToCenter(other: Anchor2): number {
+ return other.centerOfRightWall() - this.centerOfRightWall();
+ }
/**
* edgeOffset is used for interactionWidth calculations.
*/
diff --git a/src/positioning/FragmentLayoutCalculator.spec.ts b/src/positioning/FragmentLayoutCalculator.spec.ts
new file mode 100644
index 00000000..6913bcf4
--- /dev/null
+++ b/src/positioning/FragmentLayoutCalculator.spec.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { FragmentLayoutCalculator } from "./FragmentLayoutCalculator";
+import { Coordinates } from "./Coordinates";
+import { stubWidthProvider } from "../../test/unit/parser/fixture/Fixture";
+import { RootContext } from "@/parser";
+
+describe("FragmentLayoutCalculator", () => {
+ let coordinates: Coordinates;
+ let calculator: FragmentLayoutCalculator;
+
+ beforeEach(() => {
+ // 创建包含Fragment的测试场景
+ const context = RootContext(`
+ A->B: message1
+ opt condition
+ B->C: message2
+ end
+ `);
+ coordinates = new Coordinates(context, stubWidthProvider);
+ calculator = new FragmentLayoutCalculator(coordinates);
+ });
+
+ describe("extractFragmentGeometry", () => {
+ it("应该正确提取Fragment几何数据", () => {
+ const context = RootContext("opt condition\nA->B: message\nend");
+ const optFragment = context.block().stat()[0].opt();
+
+ const geometry = calculator.extractFragmentGeometry(optFragment, "A");
+
+ expect(geometry.leftParticipant.name).toBeDefined();
+ expect(geometry.rightParticipant.name).toBeDefined();
+ expect(geometry.localParticipants.length).toBeGreaterThan(0);
+ expect(geometry.borderDepth).toBeGreaterThanOrEqual(1);
+ expect(geometry.origin.name).toBe("A");
+ });
+ });
+
+ describe("calculateFragmentLayout", () => {
+ it("应该计算Fragment的完整布局", () => {
+ const context = RootContext("opt condition\nA->B: message\nend");
+ const optFragment = context.block().stat()[0].opt();
+
+ const layout = calculator.calculateFragmentLayout(optFragment, "A");
+
+ expect(layout.offsetX).toBeDefined();
+ expect(layout.paddingLeft).toBeDefined();
+ expect(layout.borderPadding.left).toBeGreaterThanOrEqual(10); // 至少10px
+ expect(layout.borderPadding.right).toBeGreaterThanOrEqual(10);
+ expect(layout.dimensions.width).toBeGreaterThan(0);
+ expect(layout.transform).toContain("translateX");
+ });
+ });
+
+ describe("generateFragmentStyle", () => {
+ it("应该生成正确的CSS样式对象", () => {
+ const context = RootContext("opt condition\nA->B: message\nend");
+ const optFragment = context.block().stat()[0].opt();
+
+ const style = calculator.generateFragmentStyle(optFragment, "A");
+
+ expect(style).toHaveProperty("transform");
+ expect(style).toHaveProperty("width");
+ expect(style).toHaveProperty("minWidth");
+ expect(style.minWidth).toBe("100px"); // FRAGMENT_MIN_WIDTH
+ });
+ });
+
+ describe("辅助方法", () => {
+ let optFragment: any;
+
+ beforeEach(() => {
+ const context = RootContext("opt condition\nA->B: message\nend");
+ optFragment = context.block().stat()[0].opt();
+ });
+
+ it("getFragmentPaddingLeft 应该返回正确的左边距", () => {
+ const paddingLeft = calculator.getFragmentPaddingLeft(optFragment);
+ expect(typeof paddingLeft).toBe("number");
+ expect(paddingLeft).toBeGreaterThan(0);
+ });
+
+ it("getFragmentOffset 应该返回正确的偏移量", () => {
+ const offset = calculator.getFragmentOffset(optFragment, "A");
+ expect(typeof offset).toBe("number");
+ });
+
+ it("getFragmentBorder 应该返回正确的边界信息", () => {
+ const border = calculator.getFragmentBorder(optFragment);
+ expect(border.left).toBeGreaterThanOrEqual(10); // 至少10px
+ expect(border.right).toBeGreaterThanOrEqual(10);
+ expect(border.left).toBe(border.right); // 对称边界
+ });
+
+ it("getLeftParticipantName 应该返回最左参与者名称", () => {
+ const leftParticipant = calculator.getLeftParticipantName(optFragment);
+ expect(typeof leftParticipant).toBe("string");
+ expect(leftParticipant.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("错误处理", () => {
+ it("应该处理没有本地参与者的Fragment", () => {
+ // 创建一个只有外部参与者的Fragment(虽然这种情况在实际中很少见)
+ const context = RootContext("opt condition\nend"); // 空Fragment
+ const optFragment = context.block().stat()[0].opt();
+
+ // 由于我们的实现会提供默认处理,这里检查是否能够正常处理
+ const geometry = calculator.extractFragmentGeometry(optFragment, "_STARTER_");
+ expect(geometry).toBeDefined();
+ expect(geometry.borderDepth).toBeGreaterThanOrEqual(1);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/positioning/FragmentLayoutCalculator.ts b/src/positioning/FragmentLayoutCalculator.ts
new file mode 100644
index 00000000..ca201b53
--- /dev/null
+++ b/src/positioning/FragmentLayoutCalculator.ts
@@ -0,0 +1,173 @@
+/**
+ * Fragment布局计算器
+ * 基于统一数学模型简化Fragment偏移和布局计算
+ * 替代原有的复杂Fragment几何提取逻辑
+ */
+
+import { FragmentGeometry, FragmentLayoutResult, ParticipantGeometry } from "./GeometryTypes";
+import { ParticipantGeometryExtractor } from "./ParticipantGeometryExtractor";
+import { LayoutMath } from "./LayoutMath";
+import { Coordinates } from "./Coordinates";
+import { getLocalParticipantNames } from "./LocalParticipants";
+import FrameBuilder from "@/parser/FrameBuilder";
+import FrameBorder from "./FrameBorder";
+import { TotalWidth } from "@/components/DiagramFrame/SeqDiagram/WidthOfContext";
+import { FRAGMENT_MIN_WIDTH } from "./Constants";
+
+/**
+ * Fragment布局计算器
+ * 提供清晰、统一的Fragment布局计算接口
+ */
+export class FragmentLayoutCalculator {
+ private readonly extractor: ParticipantGeometryExtractor;
+ private readonly coordinates: Coordinates;
+
+ constructor(coordinates: Coordinates) {
+ this.coordinates = coordinates;
+ this.extractor = new ParticipantGeometryExtractor(coordinates);
+ }
+
+ /**
+ * 从context提取Fragment几何数据
+ * @param context 上下文对象
+ * @param origin 起源参与者名称
+ * @returns Fragment几何数据
+ */
+ extractFragmentGeometry(context: any, origin: string): FragmentGeometry {
+ // 获取所有参与者
+ const allParticipants = this.coordinates.orderedParticipantNames();
+ const localParticipantNames = getLocalParticipantNames(context);
+
+ // 提取本地参与者的几何数据
+ const localParticipants = this.extractor.extractParticipants(localParticipantNames, context);
+
+ // 如果没有本地参与者,使用所有参与者
+ const participantsToUse = localParticipants.length > 0
+ ? localParticipants
+ : this.extractor.extractAllParticipants(context);
+
+ // 找到最左和最右的参与者
+ const leftParticipant = ParticipantGeometryExtractor.getLeftmostParticipant(participantsToUse);
+ const rightParticipant = ParticipantGeometryExtractor.getRightmostParticipant(participantsToUse);
+
+ // 提取起源参与者数据
+ const originParticipant = this.extractor.extractParticipant(origin, context);
+
+ // 计算边界深度
+ const frameBuilder = new FrameBuilder(allParticipants);
+ const frame = frameBuilder.getFrame(context);
+ const border = FrameBorder(frame);
+ const borderDepth = Math.max(1, border.left / 10); // FRAGMENT_PADDING_X = 10, 至少为1
+
+ if (!leftParticipant || !rightParticipant) {
+ throw new Error("Could not determine left or right participant for fragment");
+ }
+
+ return {
+ leftParticipant,
+ rightParticipant,
+ localParticipants: participantsToUse,
+ borderDepth,
+ origin: originParticipant,
+ };
+ }
+
+ /**
+ * 计算Fragment的完整布局
+ * @param context 上下文对象
+ * @param origin 起源参与者名称
+ * @returns Fragment布局结果
+ */
+ calculateFragmentLayout(context: any, origin: string): FragmentLayoutResult {
+ const geometry = this.extractFragmentGeometry(context, origin);
+
+ // 使用统一数学模型计算各项值
+ const offsetX = LayoutMath.fragmentTotalOffset(
+ geometry.leftParticipant,
+ geometry.origin,
+ geometry.borderDepth
+ );
+
+ const paddingLeft = LayoutMath.fragmentBaseOffset(
+ geometry.leftParticipant,
+ geometry.borderDepth
+ );
+
+ const borderPadding = LayoutMath.fragmentBorderPadding(geometry.borderDepth);
+
+ const totalWidth = TotalWidth(context, this.coordinates);
+
+ return {
+ position: { x: 0, y: 0 }, // Fragment通常没有Y偏移
+ dimensions: {
+ width: totalWidth,
+ height: 0 // 高度由内容决定
+ },
+ transform: `translateX(${-(offsetX + 1)}px)`, // 添加1px的调整
+ offsetX,
+ paddingLeft,
+ borderPadding,
+ };
+ }
+
+ /**
+ * 生成Fragment的CSS样式对象
+ * @param context 上下文对象
+ * @param origin 起源参与者名称
+ * @returns CSS样式对象
+ */
+ generateFragmentStyle(context: any, origin: string): object {
+ const layout = this.calculateFragmentLayout(context, origin);
+
+ return {
+ transform: layout.transform,
+ width: `${layout.dimensions.width}px`,
+ minWidth: `${FRAGMENT_MIN_WIDTH}px`,
+ };
+ }
+
+ /**
+ * 获取Fragment的左边距
+ * @param context 上下文对象
+ * @returns 左边距值
+ */
+ getFragmentPaddingLeft(context: any): number {
+ const geometry = this.extractFragmentGeometry(context, ""); // 无需origin来计算padding
+ return LayoutMath.fragmentBaseOffset(geometry.leftParticipant, geometry.borderDepth);
+ }
+
+ /**
+ * 获取Fragment的偏移量(用于特定计算)
+ * @param context 上下文对象
+ * @param origin 起源参与者名称
+ * @returns 偏移量
+ */
+ getFragmentOffset(context: any, origin: string): number {
+ const geometry = this.extractFragmentGeometry(context, origin);
+ return LayoutMath.fragmentTotalOffset(
+ geometry.leftParticipant,
+ geometry.origin,
+ geometry.borderDepth
+ );
+ }
+
+ /**
+ * 获取Fragment的边界信息
+ * @param context 上下文对象
+ * @returns 边界信息
+ */
+ getFragmentBorder(context: any): { left: number; right: number } {
+ const geometry = this.extractFragmentGeometry(context, "");
+ return LayoutMath.fragmentBorderPadding(geometry.borderDepth);
+ }
+
+ /**
+ * 获取Fragment的最左参与者名称
+ * @param context 上下文对象
+ * @returns 最左参与者名称
+ */
+ getLeftParticipantName(context: any): string {
+ const geometry = this.extractFragmentGeometry(context, "");
+ return geometry.leftParticipant.name;
+ }
+}
\ No newline at end of file
diff --git a/src/positioning/GeometryTypes.ts b/src/positioning/GeometryTypes.ts
new file mode 100644
index 00000000..a1dc0d61
--- /dev/null
+++ b/src/positioning/GeometryTypes.ts
@@ -0,0 +1,84 @@
+/**
+ * 统一的几何数据类型定义
+ * 这些类型包含布局计算所需的纯几何信息,不依赖于复杂的context对象
+ */
+
+/**
+ * 参与者几何数据
+ * 包含参与者在布局中的所有几何属性
+ */
+export interface ParticipantGeometry {
+ readonly name: string;
+ readonly centerPosition: number; // 在全局坐标系中的中心位置
+ readonly halfWidth: number; // 参与者宽度的一半
+ readonly activationLayers: number; // 当前激活层数(嵌套深度)
+}
+
+/**
+ * 锚点几何数据
+ * 表示参与者在特定激活层的精确位置信息
+ */
+export interface AnchorGeometry {
+ readonly position: number; // 全局坐标系位置
+ readonly layers: number; // 激活层数
+}
+
+/**
+ * 消息几何数据
+ * 包含消息布局所需的几何信息
+ */
+export interface MessageGeometry {
+ readonly from: ParticipantGeometry;
+ readonly to: ParticipantGeometry;
+ readonly textWidth: number; // 消息文本宽度
+ readonly messageType: string; // 消息类型(用于特殊处理)
+}
+
+/**
+ * Fragment几何数据
+ * 包含Fragment布局所需的几何信息
+ */
+export interface FragmentGeometry {
+ readonly leftParticipant: ParticipantGeometry;
+ readonly rightParticipant: ParticipantGeometry;
+ readonly localParticipants: readonly ParticipantGeometry[];
+ readonly borderDepth: number; // 嵌套深度(影响边界填充)
+ readonly origin: ParticipantGeometry;
+}
+
+/**
+ * 位置信息
+ */
+export interface Position {
+ readonly x: number;
+ readonly y: number;
+}
+
+/**
+ * 尺寸信息
+ */
+export interface Dimensions {
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * 布局计算结果
+ */
+export interface LayoutResult {
+ readonly position: Position;
+ readonly dimensions: Dimensions;
+ readonly transform?: string; // CSS transform 字符串
+}
+
+/**
+ * Fragment布局计算结果
+ */
+export interface FragmentLayoutResult extends LayoutResult {
+ readonly offsetX: number; // X轴偏移量
+ readonly paddingLeft: number; // 左边距
+ readonly borderPadding: { // 边界填充
+ left: number;
+ right: number;
+ };
+}
\ No newline at end of file
diff --git a/src/positioning/GeometryUtils.spec.ts b/src/positioning/GeometryUtils.spec.ts
new file mode 100644
index 00000000..17dfb7cf
--- /dev/null
+++ b/src/positioning/GeometryUtils.spec.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { getParticipantCenter, calculateParticipantDistance, calculateParticipantDistance2 } from "./GeometryUtils";
+import { Coordinates } from "./Coordinates";
+import { stubWidthProvider } from "../../test/unit/parser/fixture/Fixture";
+import { RootContext } from "@/parser";
+
+describe("GeometryUtils", () => {
+ let coordinates: Coordinates;
+
+ beforeEach(() => {
+ // 创建测试场景:A -> B
+ const context = RootContext("A->B:message");
+ coordinates = new Coordinates(context, stubWidthProvider);
+ });
+
+ describe("getParticipantCenter", () => {
+ it("应该返回正确的参与者中心位置", () => {
+ const participantA_center = getParticipantCenter("A", coordinates);
+ const participantB_center = getParticipantCenter("B", coordinates);
+
+ // 验证返回值是数字且大于0
+ expect(typeof participantA_center).toBe("number");
+ expect(typeof participantB_center).toBe("number");
+ expect(participantA_center).toBeGreaterThan(0);
+ expect(participantB_center).toBeGreaterThan(0);
+
+ // B应该在A的右边
+ expect(participantB_center).toBeGreaterThan(participantA_center);
+ });
+
+ it("应该处理不存在的参与者", () => {
+ const nonExistent = getParticipantCenter("NonExistent", coordinates);
+ expect(nonExistent).toBe(0);
+ });
+
+ it("应该处理undefined输入", () => {
+ const undefined_result = getParticipantCenter("", coordinates);
+ expect(undefined_result).toBe(0);
+ });
+ });
+
+ describe("calculateParticipantDistance", () => {
+ it("应该计算正确的距离", () => {
+ // A到B的距离应该是负数(因为计算的是 A_center - B_center)
+ const distanceAB = calculateParticipantDistance("A", "B", coordinates);
+ expect(distanceAB).toBeLessThan(0);
+
+ // B到A的距离应该是正数
+ const distanceBA = calculateParticipantDistance("B", "A", coordinates);
+ expect(distanceBA).toBeGreaterThan(0);
+
+ // 两个距离应该是相反数
+ expect(distanceAB).toBe(-distanceBA);
+ });
+ });
+
+ describe("calculateParticipantDistance2", () => {
+ it("应该计算正确的距离(相反方向)", () => {
+ // A到B的distance2应该是正数(因为计算的是 B_center - A_center)
+ const distance2AB = calculateParticipantDistance2("A", "B", coordinates);
+ expect(distance2AB).toBeGreaterThan(0);
+
+ // B到A的distance2应该是负数
+ const distance2BA = calculateParticipantDistance2("B", "A", coordinates);
+ expect(distance2BA).toBeLessThan(0);
+
+ // 两个距离应该是相反数
+ expect(distance2AB).toBe(-distance2BA);
+ });
+
+ it("应该处理空参数", () => {
+ const empty = calculateParticipantDistance2("", "", coordinates);
+ expect(empty).toBe(0);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/positioning/GeometryUtils.ts b/src/positioning/GeometryUtils.ts
new file mode 100644
index 00000000..d1006f7a
--- /dev/null
+++ b/src/positioning/GeometryUtils.ts
@@ -0,0 +1,226 @@
+/**
+ * Geometric calculation utility functions
+ * Simplified utility functions based on new mathematical model, gradually replacing complex implementations in utils.ts
+ */
+
+import { ParticipantGeometryExtractor } from "./ParticipantGeometryExtractor";
+import { Coordinates } from "./Coordinates";
+import store, { coordinatesAtom } from "@/store/Store";
+import { LayoutMath } from "./LayoutMath";
+
+/**
+ * Get participant center position (based on new mathematical model)
+ * This is a simplified replacement for the centerOf function
+ * @param participantName Participant name
+ * @param coordinates Optional coordinates object, if not provided, get from store
+ * @returns Center position
+ */
+export function getParticipantCenter(participantName: string, coordinates?: Coordinates): number {
+ if (!participantName) {
+ console.error("[@zenuml/core] getParticipantCenter: participantName is undefined");
+ return 0;
+ }
+
+ try {
+ const coords = coordinates || store.get(coordinatesAtom);
+ const extractor = new ParticipantGeometryExtractor(coords);
+ const geometry = extractor.extractParticipant(participantName);
+ return geometry.centerPosition;
+ } catch (e) {
+ console.error(e);
+ return 0;
+ }
+}
+
+/**
+ * Calculate distance between two participants (based on new mathematical model)
+ * This is a simplified replacement for the distance function
+ * @param from Starting participant
+ * @param to Target participant
+ * @param coordinates Optional coordinates object, if not provided, get from store
+ * @returns Distance (from to to direction)
+ */
+export function calculateParticipantDistance(from: string, to: string, coordinates?: Coordinates): number {
+ return getParticipantCenter(from, coordinates) - getParticipantCenter(to, coordinates);
+}
+
+/**
+ * Calculate distance between two participants (based on new mathematical model)
+ * This is a simplified replacement for the distance2 function
+ * @param from Starting participant
+ * @param to Target participant
+ * @param coordinates Optional coordinates object, if not provided, get from store
+ * @returns Distance (to to from direction)
+ */
+export function calculateParticipantDistance2(from: string, to: string, coordinates?: Coordinates): number {
+ if (!from || !to) return 0;
+ return getParticipantCenter(to, coordinates) - getParticipantCenter(from, coordinates);
+}
+
+/**
+ * Calculate fragment context total width using new mathematical model
+ * @param leftParticipant Leftmost participant name
+ * @param rightParticipant Rightmost participant name
+ * @param borderDepth Border depth from frame border
+ * @param extraWidth Extra width (e.g. from self messages)
+ * @param coordinates Optional coordinates object
+ * @returns Fragment context total width
+ */
+export function calculateFragmentContextWidth(
+ leftParticipant: string,
+ rightParticipant: string,
+ borderDepth: number,
+ extraWidth: number = 0,
+ coordinates?: Coordinates
+): number {
+ try {
+ const coords = coordinates || store.get(coordinatesAtom);
+ const extractor = new ParticipantGeometryExtractor(coords);
+
+ const leftGeometry = extractor.extractParticipant(leftParticipant);
+ const rightGeometry = extractor.extractParticipant(rightParticipant);
+
+ return LayoutMath.fragmentContextTotalWidth(leftGeometry, rightGeometry, borderDepth, extraWidth);
+ } catch (e) {
+ console.error(e);
+ return 0;
+ }
+}
+
+/**
+ * Calculate extra width due to self messages using new mathematical model
+ * @param selfMessages Array of self messages (OwnableMessage type)
+ * @param rightParticipant Rightmost participant name
+ * @param coordinates Optional coordinates object
+ * @returns Maximum extra width needed for self messages
+ */
+export function calculateSelfMessageExtraWidth(
+ selfMessages: any[],
+ rightParticipant: string,
+ coordinates?: Coordinates
+): number {
+ try {
+ const coords = coordinates || store.get(coordinatesAtom);
+ const extractor = new ParticipantGeometryExtractor(coords);
+ const rightGeometry = extractor.extractParticipant(rightParticipant);
+
+ // Convert OwnableMessage objects to MessageGeometry objects
+ const messageGeometries = selfMessages.map(message => {
+ const fromName = message.from || "_STARTER_";
+ const toName = message.to || "_STARTER_";
+
+ const fromGeometry = extractor.extractParticipant(fromName);
+ const toGeometry = extractor.extractParticipant(toName);
+ const textWidth = coords.getMessageWidth(message);
+
+ return {
+ from: fromGeometry,
+ to: toGeometry,
+ textWidth: textWidth,
+ messageType: message.type === 2 ? "creation" : "normal"
+ };
+ });
+
+ return LayoutMath.selfMessageExtraWidth(messageGeometries, rightGeometry);
+ } catch (e) {
+ console.error(e);
+ return 0;
+ }
+}
+
+/**
+ * Calculate fragment offset using new mathematical model
+ * @param leftParticipant Leftmost participant name
+ * @param originParticipant Origin participant name (can be null)
+ * @param borderDepth Border depth
+ * @param coordinates Optional coordinates object
+ * @returns Fragment offset for CSS transform
+ */
+export function calculateFragmentOffset(
+ leftParticipant: string,
+ originParticipant: string | null,
+ borderDepth: number,
+ coordinates?: Coordinates
+): number {
+ try {
+ const coords = coordinates || store.get(coordinatesAtom);
+ const extractor = new ParticipantGeometryExtractor(coords);
+
+ const leftGeometry = extractor.extractParticipant(leftParticipant);
+ const originGeometry = originParticipant ? extractor.extractParticipant(originParticipant) : null;
+
+ return LayoutMath.fragmentOffset(leftGeometry, originGeometry, borderDepth);
+ } catch (e) {
+ console.error(e);
+ return 0;
+ }
+}
+
+/**
+ * Generate fragment CSS transform using new mathematical model
+ * @param leftParticipant Leftmost participant name
+ * @param originParticipant Origin participant name (can be null)
+ * @param borderDepth Border depth
+ * @param coordinates Optional coordinates object
+ * @param originLayers Optional origin activation layers count
+ * @returns CSS transform string
+ */
+export function generateFragmentTransform(
+ leftParticipant: string,
+ originParticipant: string | null,
+ borderDepth: number,
+ coordinates?: Coordinates,
+ originLayers?: number
+): string {
+ try {
+ const coords = coordinates || store.get(coordinatesAtom);
+ const extractor = new ParticipantGeometryExtractor(coords);
+
+ const leftGeometry = extractor.extractParticipant(leftParticipant);
+ let originGeometry = null;
+
+ if (originParticipant) {
+ // Use provided originLayers if available, otherwise extract without context (defaulting to 0)
+ if (originLayers !== undefined) {
+ originGeometry = {
+ name: originParticipant,
+ centerPosition: coords.getPosition(originParticipant),
+ halfWidth: coords.half(originParticipant),
+ activationLayers: originLayers,
+ };
+ } else {
+ originGeometry = extractor.extractParticipant(originParticipant);
+ }
+ }
+
+ return LayoutMath.fragmentTransform(leftGeometry, originGeometry, borderDepth);
+ } catch (e) {
+ console.error(e);
+ return "translateX(0px)";
+ }
+}
+
+/**
+ * Calculate fragment padding left using new mathematical model
+ * @param leftParticipant Leftmost participant name
+ * @param borderDepth Border depth
+ * @param coordinates Optional coordinates object
+ * @returns Padding left value
+ */
+export function calculateFragmentPaddingLeft(
+ leftParticipant: string,
+ borderDepth: number,
+ coordinates?: Coordinates
+): number {
+ try {
+ const coords = coordinates || store.get(coordinatesAtom);
+ const extractor = new ParticipantGeometryExtractor(coords);
+
+ const leftGeometry = extractor.extractParticipant(leftParticipant);
+
+ return LayoutMath.fragmentPaddingLeft(leftGeometry, borderDepth);
+ } catch (e) {
+ console.error(e);
+ return 0;
+ }
+}
\ No newline at end of file
diff --git a/src/positioning/LayoutMath.spec.ts b/src/positioning/LayoutMath.spec.ts
new file mode 100644
index 00000000..38654ece
--- /dev/null
+++ b/src/positioning/LayoutMath.spec.ts
@@ -0,0 +1,188 @@
+import { describe, it, expect } from "vitest";
+import { LayoutMath } from "./LayoutMath";
+import { ParticipantGeometry, MessageGeometry } from "./GeometryTypes";
+
+describe("LayoutMath", () => {
+ // Test participant geometry data
+ const participantA: ParticipantGeometry = {
+ name: "A",
+ centerPosition: 100,
+ halfWidth: 50,
+ activationLayers: 0,
+ };
+
+ const participantB: ParticipantGeometry = {
+ name: "B",
+ centerPosition: 300,
+ halfWidth: 50,
+ activationLayers: 1,
+ };
+
+ const participantC: ParticipantGeometry = {
+ name: "C",
+ centerPosition: 500,
+ halfWidth: 60,
+ activationLayers: 2,
+ };
+
+ describe("Basic Distance Calculations", () => {
+ it("participantDistance should calculate correct distance", () => {
+ expect(LayoutMath.participantDistance(participantA, participantB)).toBe(200); // 300 - 100
+ expect(LayoutMath.participantDistance(participantB, participantA)).toBe(-200); // 100 - 300
+ expect(LayoutMath.participantDistance(participantA, participantA)).toBe(0); // Same participant
+ });
+
+ it("interactionWidth should calculate interaction width", () => {
+ const width = LayoutMath.interactionWidth(participantA, participantB);
+ expect(typeof width).toBe("number");
+ expect(width).toBeGreaterThan(0);
+ });
+ });
+
+ describe("Participant Layout Calculations", () => {
+ it("participantLeft should calculate left boundary", () => {
+ expect(LayoutMath.participantLeft(participantA)).toBe(50); // 100 - 50
+ expect(LayoutMath.participantLeft(participantB)).toBe(250); // 300 - 50
+ });
+
+ it("participantRight should calculate right boundary", () => {
+ expect(LayoutMath.participantRight(participantA)).toBe(150); // 100 + 50
+ expect(LayoutMath.participantRight(participantC)).toBe(560); // 500 + 60
+ });
+
+ it("participantWidth should calculate full width", () => {
+ expect(LayoutMath.participantWidth(participantA)).toBe(100); // 50 * 2
+ expect(LayoutMath.participantWidth(participantC)).toBe(120); // 60 * 2
+ });
+ });
+
+ describe("Fragment Layout Calculations", () => {
+ it("fragmentBorderPadding should calculate border padding", () => {
+ const padding1 = LayoutMath.fragmentBorderPadding(1);
+ const padding2 = LayoutMath.fragmentBorderPadding(2);
+
+ expect(padding1.left).toBe(10); // FRAGMENT_PADDING_X * 1
+ expect(padding1.right).toBe(10);
+ expect(padding2.left).toBe(20); // FRAGMENT_PADDING_X * 2
+ expect(padding2.right).toBe(20);
+ });
+
+ it("fragmentBaseOffset should calculate base offset", () => {
+ const offset = LayoutMath.fragmentBaseOffset(participantA, 1);
+ expect(offset).toBe(60); // 10 (border) + 50 (half width)
+ });
+
+ it("fragmentTotalOffset should calculate total offset", () => {
+ // Same participant case
+ const sameParticipantOffset = LayoutMath.fragmentTotalOffset(participantA, participantA, 1);
+ expect(sameParticipantOffset).toBe(60); // Should equal base offset
+
+ // Different participant case
+ const crossParticipantOffset = LayoutMath.fragmentTotalOffset(participantA, participantB, 1);
+ expect(typeof crossParticipantOffset).toBe("number");
+ });
+ });
+
+ describe("Width Calculations", () => {
+ it("participantSpanWidth should calculate participant span width", () => {
+ const spanWidth = LayoutMath.participantSpanWidth(participantA, participantB);
+ expect(spanWidth).toBe(300); // 200 (distance) + 50 (A half) + 50 (B half)
+ });
+
+ it("fragmentTotalWidth should calculate Fragment total width", () => {
+ const totalWidth = LayoutMath.fragmentTotalWidth(participantA, participantB, 1, 0);
+ expect(totalWidth).toBeGreaterThan(100); // Should be greater than minimum width
+ expect(totalWidth).toBe(320); // 300 (span) + 20 (border padding)
+ });
+
+ it("fragmentTotalWidth should use minimum width", () => {
+ // Create a very small participant span
+ const smallA: ParticipantGeometry = {
+ name: "SmallA",
+ centerPosition: 10,
+ halfWidth: 5,
+ activationLayers: 0,
+ };
+ const smallB: ParticipantGeometry = {
+ name: "SmallB",
+ centerPosition: 20,
+ halfWidth: 5,
+ activationLayers: 0,
+ };
+
+ const totalWidth = LayoutMath.fragmentTotalWidth(smallA, smallB, 1, 0);
+ expect(totalWidth).toBe(100); // Should use FRAGMENT_MIN_WIDTH
+ });
+ });
+
+ describe("Message Width Calculations", () => {
+ it("messageWidth should calculate normal message width", () => {
+ const messageGeometry: MessageGeometry = {
+ from: participantA,
+ to: participantB,
+ textWidth: 50,
+ messageType: "normal",
+ };
+
+ const width = LayoutMath.messageWidth(messageGeometry);
+ expect(width).toBe(50); // 50 (text only, arrows/occurrence handled separately)
+ });
+
+ it("messageWidth should add extra width for creation messages", () => {
+ const creationMessageGeometry: MessageGeometry = {
+ from: participantA,
+ to: participantB,
+ textWidth: 50,
+ messageType: "creation",
+ };
+
+ const width = LayoutMath.messageWidth(creationMessageGeometry);
+ expect(width).toBe(100); // 50 (text) + 50 (target half width), arrows/occurrence handled separately
+ });
+
+ it("messageWidthWithVisualElements should include arrows and occurrence", () => {
+ const messageGeometry: MessageGeometry = {
+ from: participantA,
+ to: participantB,
+ textWidth: 50,
+ messageType: "normal",
+ };
+
+ const width = LayoutMath.messageWidthWithVisualElements(messageGeometry);
+ expect(width).toBe(75); // 50 (text) + 10 (arrow) + 15 (occurrence)
+ });
+
+ it("messageWidthWithVisualElements should work with creation messages", () => {
+ const creationMessageGeometry: MessageGeometry = {
+ from: participantA,
+ to: participantB,
+ textWidth: 50,
+ messageType: "creation",
+ };
+
+ const width = LayoutMath.messageWidthWithVisualElements(creationMessageGeometry);
+ expect(width).toBe(125); // 50 (text) + 50 (target half width) + 10 (arrow) + 15 (occurrence)
+ });
+ });
+
+ describe("Direction Utilities", () => {
+ it("isRightToLeft should correctly determine direction", () => {
+ expect(LayoutMath.isRightToLeft(participantA, participantB)).toBe(false); // A->B left to right
+ expect(LayoutMath.isRightToLeft(participantB, participantA)).toBe(true); // B->A right to left
+ expect(LayoutMath.isRightToLeft(participantA, participantA)).toBe(false); // Same participant
+ });
+
+ it("isSelfMessage should correctly determine self message", () => {
+ expect(LayoutMath.isSelfMessage(participantA, participantA)).toBe(true);
+ expect(LayoutMath.isSelfMessage(participantA, participantB)).toBe(false);
+ });
+ });
+
+ describe("Helper Calculation Methods", () => {
+ it("generateTransformCSS should generate correct CSS string", () => {
+ expect(LayoutMath.generateTransformCSS(100)).toBe("translateX(100px)");
+ expect(LayoutMath.generateTransformCSS(-50)).toBe("translateX(-50px)");
+ expect(LayoutMath.generateTransformCSS(0)).toBe("translateX(0px)");
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/positioning/LayoutMath.ts b/src/positioning/LayoutMath.ts
new file mode 100644
index 00000000..1bed81f3
--- /dev/null
+++ b/src/positioning/LayoutMath.ts
@@ -0,0 +1,339 @@
+/**
+ * Unified layout mathematical calculation utility class
+ * Contains all core distance, width and position calculation functions
+ * Based on new geometric data interfaces, providing clear mathematical models
+ */
+
+import { ParticipantGeometry, FragmentGeometry, MessageGeometry } from "./GeometryTypes";
+import { UnifiedAnchor } from "./UnifiedAnchor";
+import { FRAGMENT_PADDING_X, FRAGMENT_MIN_WIDTH, LIFELINE_WIDTH, ARROW_HEAD_WIDTH, OCCURRENCE_WIDTH } from "./Constants";
+
+/**
+ * Core layout mathematical calculation class
+ * All calculations are based on pure geometric data, independent of complex context objects
+ */
+export class LayoutMath {
+
+ // ==================== Basic Distance Calculations ====================
+
+ /**
+ * Calculate the center distance between two participants
+ * @param from Starting participant
+ * @param to Target participant
+ * @returns Distance (positive means right, negative means left)
+ */
+ static participantDistance(from: ParticipantGeometry, to: ParticipantGeometry): number {
+ return to.centerPosition - from.centerPosition;
+ }
+
+ /**
+ * Calculate interaction width (for message rendering)
+ * @param source Source participant
+ * @param target Target participant
+ * @returns Interaction width
+ */
+ static interactionWidth(source: ParticipantGeometry, target: ParticipantGeometry): number {
+ const sourceAnchor = UnifiedAnchor.fromParticipantGeometry(source);
+ const targetAnchor = UnifiedAnchor.fromParticipantGeometry(target);
+ return Math.abs(sourceAnchor.edgeOffset(targetAnchor));
+ }
+
+
+ // ==================== Participant Layout Calculations ====================
+
+ /**
+ * Calculate the left boundary of participant
+ * @param participant Participant geometry data
+ * @returns Left boundary position
+ */
+ static participantLeft(participant: ParticipantGeometry): number {
+ return participant.centerPosition - participant.halfWidth;
+ }
+
+ /**
+ * Calculate the right boundary of participant
+ * @param participant Participant geometry data
+ * @returns Right boundary position
+ */
+ static participantRight(participant: ParticipantGeometry): number {
+ return participant.centerPosition + participant.halfWidth;
+ }
+
+ /**
+ * Calculate the full width of participant
+ * @param participant Participant geometry data
+ * @returns Full width
+ */
+ static participantWidth(participant: ParticipantGeometry): number {
+ return participant.halfWidth * 2;
+ }
+
+ // ==================== Fragment Layout Calculations ====================
+
+ /**
+ * Calculate Fragment border padding
+ * @param borderDepth Border depth (nesting level)
+ * @returns Border padding object
+ */
+ static fragmentBorderPadding(borderDepth: number): { left: number; right: number } {
+ const padding = FRAGMENT_PADDING_X * borderDepth;
+ return { left: padding, right: padding };
+ }
+
+ /**
+ * Calculate Fragment base offset
+ * @param leftParticipant Leftmost participant
+ * @param borderDepth Border depth
+ * @returns Base offset
+ */
+ static fragmentBaseOffset(leftParticipant: ParticipantGeometry, borderDepth: number): number {
+ const borderPadding = this.fragmentBorderPadding(borderDepth);
+ return borderPadding.left + leftParticipant.halfWidth;
+ }
+
+ /**
+ * Calculate Fragment activation layer correction offset
+ * @param leftParticipant Leftmost participant
+ * @param originParticipant Origin participant (with activation layers)
+ * @returns Activation layer correction offset
+ */
+ static fragmentActivationLayerCorrection(
+ leftParticipant: ParticipantGeometry,
+ originParticipant: ParticipantGeometry
+ ): number {
+ const leftAnchor = UnifiedAnchor.fromParticipantGeometry({
+ ...leftParticipant,
+ activationLayers: 0, // Left participant does not consider activation layers
+ });
+ const originAnchor = UnifiedAnchor.fromParticipantGeometry(originParticipant);
+ return leftAnchor.centerToCenter(originAnchor);
+ }
+
+ /**
+ * Calculate Fragment total offset
+ * @param leftParticipant Leftmost participant
+ * @param originParticipant Origin participant
+ * @param borderDepth Border depth
+ * @returns Total offset
+ */
+ static fragmentTotalOffset(
+ leftParticipant: ParticipantGeometry,
+ originParticipant: ParticipantGeometry,
+ borderDepth: number
+ ): number {
+ // If same participant, return base offset directly
+ if (leftParticipant.name === originParticipant.name) {
+ return this.fragmentBaseOffset(leftParticipant, borderDepth);
+ }
+
+ // Cross-participant composite transformation
+ const baseOffset = this.fragmentBaseOffset(leftParticipant, borderDepth);
+ const layerCorrection = this.fragmentActivationLayerCorrection(leftParticipant, originParticipant);
+ return baseOffset + layerCorrection;
+ }
+
+ // ==================== Width Calculations ====================
+
+ /**
+ * Calculate participant span width
+ * @param leftParticipant Leftmost participant
+ * @param rightParticipant Rightmost participant
+ * @returns Participant span width
+ */
+ static participantSpanWidth(
+ leftParticipant: ParticipantGeometry,
+ rightParticipant: ParticipantGeometry
+ ): number {
+ const distance = this.participantDistance(leftParticipant, rightParticipant);
+ return distance + leftParticipant.halfWidth + rightParticipant.halfWidth;
+ }
+
+ /**
+ * Calculate Fragment total width
+ * @param leftParticipant Leftmost participant
+ * @param rightParticipant Rightmost participant
+ * @param borderDepth Border depth
+ * @param extraWidth Extra width (e.g. extra width from self messages)
+ * @returns Fragment total width
+ */
+ static fragmentTotalWidth(
+ leftParticipant: ParticipantGeometry,
+ rightParticipant: ParticipantGeometry,
+ borderDepth: number,
+ extraWidth: number = 0
+ ): number {
+ const participantSpan = this.participantSpanWidth(leftParticipant, rightParticipant);
+ const borderPadding = this.fragmentBorderPadding(borderDepth);
+ const totalBorderWidth = borderPadding.left + borderPadding.right;
+
+ return Math.max(
+ participantSpan + totalBorderWidth + extraWidth,
+ FRAGMENT_MIN_WIDTH
+ );
+ }
+
+ // ==================== Message Width Calculations ====================
+
+ /**
+ * Calculate message display width (text + creation adjustments, excluding arrows/occurrence)
+ * This matches the behavior of coordinates.getMessageWidth()
+ * @param messageGeometry Message geometry data
+ * @returns Message display width
+ */
+ static messageWidth(messageGeometry: MessageGeometry): number {
+ let totalWidth = messageGeometry.textWidth;
+
+ // Add extra width for creation messages
+ if (messageGeometry.messageType === "creation") {
+ totalWidth += messageGeometry.to.halfWidth;
+ }
+
+ // NOTE: ARROW_HEAD_WIDTH + OCCURRENCE_WIDTH are handled separately in the calling logic
+ return totalWidth;
+ }
+
+ /**
+ * Calculate message total width including arrows and activation bars
+ * @param messageGeometry Message geometry data
+ * @returns Message total width including all visual elements
+ */
+ static messageWidthWithVisualElements(messageGeometry: MessageGeometry): number {
+ return this.messageWidth(messageGeometry) + ARROW_HEAD_WIDTH + OCCURRENCE_WIDTH;
+ }
+
+ // ==================== Direction Utilities ====================
+
+ /**
+ * Determine the relative direction between two participants
+ * @param from Starting participant
+ * @param to Target participant
+ * @returns true means right to left, false means left to right
+ */
+ static isRightToLeft(from: ParticipantGeometry, to: ParticipantGeometry): boolean {
+ return to.centerPosition < from.centerPosition;
+ }
+
+ /**
+ * Determine if it is a self message
+ * @param from Starting participant
+ * @param to Target participant
+ * @returns Whether it is a self message
+ */
+ static isSelfMessage(from: ParticipantGeometry, to: ParticipantGeometry): boolean {
+ return from.name === to.name;
+ }
+
+ // ==================== Fragment Offset and Transform Calculations ====================
+
+ /**
+ * Calculate fragment offset in global coordinate system
+ * @param leftParticipant Leftmost participant
+ * @param originParticipant Origin participant (where fragment starts)
+ * @param borderDepth Border depth
+ * @returns Fragment offset for CSS transform
+ */
+ static fragmentOffset(
+ leftParticipant: ParticipantGeometry,
+ originParticipant: ParticipantGeometry | null,
+ borderDepth: number
+ ): number {
+ const baseOffset = this.fragmentBaseOffset(leftParticipant, borderDepth);
+
+ // If same participant or no origin, return base offset directly
+ if (!originParticipant || leftParticipant.name === originParticipant.name) {
+ return baseOffset;
+ }
+
+ // Cross-participant spatial transformation
+ const spatialCorrection = this.fragmentActivationLayerCorrection(leftParticipant, originParticipant);
+ return baseOffset + spatialCorrection;
+ }
+
+ /**
+ * Generate CSS transform string for fragment positioning
+ * @param leftParticipant Leftmost participant
+ * @param originParticipant Origin participant
+ * @param borderDepth Border depth
+ * @returns CSS transform string
+ */
+ static fragmentTransform(
+ leftParticipant: ParticipantGeometry,
+ originParticipant: ParticipantGeometry | null,
+ borderDepth: number
+ ): string {
+ const offsetX = this.fragmentOffset(leftParticipant, originParticipant, borderDepth);
+ return `translateX(${-(offsetX + 1)}px)`;
+ }
+
+ /**
+ * Calculate fragment padding left (border + participant half width)
+ * @param leftParticipant Leftmost participant
+ * @param borderDepth Border depth
+ * @returns Padding left value
+ */
+ static fragmentPaddingLeft(
+ leftParticipant: ParticipantGeometry,
+ borderDepth: number
+ ): number {
+ return this.fragmentBaseOffset(leftParticipant, borderDepth);
+ }
+
+ // ==================== Fragment Context Width Calculations ====================
+
+ /**
+ * Calculate fragment context total width
+ * @param leftParticipant Leftmost participant
+ * @param rightParticipant Rightmost participant
+ * @param borderDepth Border depth
+ * @param extraWidth Extra width (e.g. from self messages)
+ * @returns Fragment context total width
+ */
+ static fragmentContextTotalWidth(
+ leftParticipant: ParticipantGeometry,
+ rightParticipant: ParticipantGeometry,
+ borderDepth: number,
+ extraWidth: number = 0
+ ): number {
+ const participantSpan = this.participantSpanWidth(leftParticipant, rightParticipant);
+ const borderPadding = this.fragmentBorderPadding(borderDepth);
+ const totalBorderWidth = borderPadding.left + borderPadding.right;
+
+ return Math.max(
+ participantSpan + totalBorderWidth + extraWidth,
+ FRAGMENT_MIN_WIDTH
+ );
+ }
+
+ /**
+ * Calculate extra width due to self messages
+ * @param selfMessages Array of self messages
+ * @param rightParticipant Rightmost participant
+ * @returns Maximum extra width needed
+ */
+ static selfMessageExtraWidth(
+ selfMessages: MessageGeometry[],
+ rightParticipant: ParticipantGeometry
+ ): number {
+ const widths = selfMessages.map(message => {
+ const messageWidth = this.messageWidth(message);
+ const distanceToRight = this.participantDistance(message.from, rightParticipant);
+ const rightHalfWidth = rightParticipant.halfWidth;
+
+ return messageWidth - distanceToRight - rightHalfWidth;
+ });
+
+ return Math.max(0, ...widths);
+ }
+
+ // ==================== Helper Calculation Methods ====================
+
+
+ /**
+ * Calculate CSS transform string
+ * @param translateX X-axis offset
+ * @returns CSS transform string
+ */
+ static generateTransformCSS(translateX: number): string {
+ return `translateX(${translateX}px)`;
+ }
+}
\ No newline at end of file
diff --git a/src/positioning/ParticipantGeometryExtractor.spec.ts b/src/positioning/ParticipantGeometryExtractor.spec.ts
new file mode 100644
index 00000000..8bf79a5d
--- /dev/null
+++ b/src/positioning/ParticipantGeometryExtractor.spec.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { ParticipantGeometryExtractor } from "./ParticipantGeometryExtractor";
+import { Coordinates } from "./Coordinates";
+import { stubWidthProvider } from "../../test/unit/parser/fixture/Fixture";
+import { RootContext } from "@/parser";
+
+describe("ParticipantGeometryExtractor", () => {
+ let coordinates: Coordinates;
+ let extractor: ParticipantGeometryExtractor;
+
+ beforeEach(() => {
+ // 创建简单的测试场景:A -> B
+ const context = RootContext("A->B:message");
+ coordinates = new Coordinates(context, stubWidthProvider);
+ extractor = new ParticipantGeometryExtractor(coordinates);
+ });
+
+ describe("extractParticipant", () => {
+ it("应该提取单个参与者的几何数据", () => {
+ const geometry = extractor.extractParticipant("A");
+
+ expect(geometry.name).toBe("A");
+ expect(geometry.centerPosition).toBeGreaterThan(0);
+ expect(geometry.halfWidth).toBeGreaterThan(0);
+ expect(geometry.activationLayers).toBe(0); // 没有context时应该为0
+ });
+
+ it("应该处理不存在的参与者", () => {
+ const geometry = extractor.extractParticipant("NonExistent");
+
+ expect(geometry.name).toBe("NonExistent");
+ expect(geometry.centerPosition).toBe(0); // Coordinates.getPosition 返回0
+ expect(geometry.halfWidth).toBe(0); // Coordinates.half 返回0
+ });
+ });
+
+ describe("extractParticipants", () => {
+ it("应该提取多个参与者的几何数据", () => {
+ const geometries = extractor.extractParticipants(["A", "B"]);
+
+ expect(geometries).toHaveLength(2);
+ expect(geometries[0].name).toBe("A");
+ expect(geometries[1].name).toBe("B");
+ expect(geometries[1].centerPosition).toBeGreaterThan(geometries[0].centerPosition);
+ });
+ });
+
+ describe("extractAllParticipants", () => {
+ it("应该提取所有参与者的几何数据", () => {
+ const geometries = extractor.extractAllParticipants();
+
+ expect(geometries.length).toBeGreaterThanOrEqual(2); // 至少包含A和B
+ expect(geometries.some(g => g.name === "A")).toBe(true);
+ expect(geometries.some(g => g.name === "B")).toBe(true);
+ });
+ });
+
+ describe("静态工具方法", () => {
+ it("findParticipant 应该根据名称查找参与者", () => {
+ const geometries = extractor.extractParticipants(["A", "B"]);
+
+ const foundA = ParticipantGeometryExtractor.findParticipant("A", geometries);
+ const foundNonExistent = ParticipantGeometryExtractor.findParticipant("C", geometries);
+
+ expect(foundA?.name).toBe("A");
+ expect(foundNonExistent).toBeUndefined();
+ });
+
+ it("getLeftmostParticipant 应该返回最左边的参与者", () => {
+ const geometries = extractor.extractParticipants(["A", "B"]);
+
+ const leftmost = ParticipantGeometryExtractor.getLeftmostParticipant(geometries);
+
+ expect(leftmost?.name).toBe("A"); // A应该在B的左边
+ });
+
+ it("getRightmostParticipant 应该返回最右边的参与者", () => {
+ const geometries = extractor.extractParticipants(["A", "B"]);
+
+ const rightmost = ParticipantGeometryExtractor.getRightmostParticipant(geometries);
+
+ expect(rightmost?.name).toBe("B"); // B应该在A的右边
+ });
+ });
+
+ describe("hasParticipant", () => {
+ it("应该正确检查参与者是否存在", () => {
+ expect(extractor.hasParticipant("A")).toBe(true);
+ expect(extractor.hasParticipant("B")).toBe(true);
+ expect(extractor.hasParticipant("NonExistent")).toBe(false);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/positioning/ParticipantGeometryExtractor.ts b/src/positioning/ParticipantGeometryExtractor.ts
new file mode 100644
index 00000000..ec6c12ff
--- /dev/null
+++ b/src/positioning/ParticipantGeometryExtractor.ts
@@ -0,0 +1,118 @@
+/**
+ * ParticipantGeometry提取器
+ * 从现有的Coordinates对象提取纯几何数据,简化接口访问
+ */
+
+import { ParticipantGeometry } from "./GeometryTypes";
+import { Coordinates } from "./Coordinates";
+import { depthOnParticipant } from "@/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/utils";
+
+/**
+ * 从Coordinates对象提取参与者几何数据的工具类
+ * 这是连接现有复杂系统和新数学模型的桥梁
+ */
+export class ParticipantGeometryExtractor {
+ private readonly coordinates: Coordinates;
+
+ constructor(coordinates: Coordinates) {
+ this.coordinates = coordinates;
+ }
+
+ /**
+ * 提取单个参与者的几何数据
+ * @param participantName 参与者名称
+ * @param context 上下文对象(用于计算激活层)
+ * @returns 参与者几何数据
+ */
+ extractParticipant(
+ participantName: string,
+ context?: any
+ ): ParticipantGeometry {
+ const centerPosition = this.coordinates.getPosition(participantName);
+ const halfWidth = this.coordinates.half(participantName);
+ const activationLayers = context
+ ? depthOnParticipant(context, participantName)
+ : 0;
+
+ return {
+ name: participantName,
+ centerPosition,
+ halfWidth,
+ activationLayers,
+ };
+ }
+
+ /**
+ * 提取多个参与者的几何数据
+ * @param participantNames 参与者名称列表
+ * @param context 上下文对象(用于计算激活层)
+ * @returns 参与者几何数据数组
+ */
+ extractParticipants(
+ participantNames: string[],
+ context?: any
+ ): ParticipantGeometry[] {
+ return participantNames.map(name =>
+ this.extractParticipant(name, context)
+ );
+ }
+
+ /**
+ * 提取所有有序参与者的几何数据
+ * @param context 上下文对象(用于计算激活层)
+ * @returns 所有参与者几何数据数组
+ */
+ extractAllParticipants(context?: any): ParticipantGeometry[] {
+ const participantNames = this.coordinates.orderedParticipantNames();
+ return this.extractParticipants(participantNames, context);
+ }
+
+ /**
+ * 根据参与者名称查找几何数据
+ * @param participantName 参与者名称
+ * @param participants 几何数据数组
+ * @returns 找到的几何数据或undefined
+ */
+ static findParticipant(
+ participantName: string,
+ participants: ParticipantGeometry[]
+ ): ParticipantGeometry | undefined {
+ return participants.find(p => p.name === participantName);
+ }
+
+ /**
+ * 获取最左边的参与者
+ * @param participants 几何数据数组
+ * @returns 最左边的参与者几何数据
+ */
+ static getLeftmostParticipant(
+ participants: ParticipantGeometry[]
+ ): ParticipantGeometry | undefined {
+ return participants.reduce((leftmost, current) =>
+ current.centerPosition < leftmost.centerPosition ? current : leftmost
+ );
+ }
+
+ /**
+ * 获取最右边的参与者
+ * @param participants 几何数据数组
+ * @returns 最右边的参与者几何数据
+ */
+ static getRightmostParticipant(
+ participants: ParticipantGeometry[]
+ ): ParticipantGeometry | undefined {
+ return participants.reduce((rightmost, current) =>
+ current.centerPosition > rightmost.centerPosition ? current : rightmost
+ );
+ }
+
+ /**
+ * 检查参与者是否存在
+ * @param participantName 参与者名称
+ * @returns 是否存在
+ */
+ hasParticipant(participantName: string): boolean {
+ const participantNames = this.coordinates.orderedParticipantNames();
+ return participantNames.includes(participantName);
+ }
+}
\ No newline at end of file
diff --git a/src/positioning/UnifiedAnchor.spec.ts b/src/positioning/UnifiedAnchor.spec.ts
new file mode 100644
index 00000000..d244ef02
--- /dev/null
+++ b/src/positioning/UnifiedAnchor.spec.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect } from "vitest";
+import { UnifiedAnchor } from "./UnifiedAnchor";
+import { ParticipantGeometry } from "./GeometryTypes";
+
+describe("UnifiedAnchor", () => {
+ // 创建测试用的参与者几何数据
+ const geometryA: ParticipantGeometry = {
+ name: "A",
+ centerPosition: 100,
+ halfWidth: 50,
+ activationLayers: 0,
+ };
+
+ const geometryB: ParticipantGeometry = {
+ name: "B",
+ centerPosition: 300,
+ halfWidth: 50,
+ activationLayers: 2,
+ };
+
+ describe("基础功能", () => {
+ it("应该正确创建锚点", () => {
+ const anchor = new UnifiedAnchor(geometryA);
+
+ expect(anchor.getParticipantName()).toBe("A");
+ expect(anchor.getCenterPosition()).toBe(100);
+ expect(anchor.getActivationLayers()).toBe(0);
+ });
+
+ it("应该返回正确的锚点几何数据", () => {
+ const anchor = new UnifiedAnchor(geometryA);
+ const anchorGeometry = anchor.getAnchorGeometry();
+
+ expect(anchorGeometry.position).toBe(100);
+ expect(anchorGeometry.layers).toBe(0);
+ });
+ });
+
+ describe("距离计算", () => {
+ it("应该计算中心到中心的距离", () => {
+ const anchorA = new UnifiedAnchor(geometryA);
+ const anchorB = new UnifiedAnchor(geometryB);
+
+ const distance = anchorA.centerToCenter(anchorB);
+
+ // B的中心位置(300)+ B的激活层偏移 - A的中心位置(100)
+ expect(distance).toBeGreaterThan(0);
+ expect(distance).toBe(anchorB.centerOfRightWall() - anchorA.centerOfRightWall());
+ });
+
+ it("应该计算边缘偏移量", () => {
+ const anchorA = new UnifiedAnchor(geometryA);
+ const anchorB = new UnifiedAnchor(geometryB);
+
+ const edgeOffset = anchorA.edgeOffset(anchorB);
+
+ expect(typeof edgeOffset).toBe("number");
+ // 应该考虑激活层的影响
+ });
+ });
+
+ describe("静态工厂方法", () => {
+ it("fromParticipantGeometry 应该创建锚点", () => {
+ const anchor = UnifiedAnchor.fromParticipantGeometry(geometryA);
+
+ expect(anchor.getParticipantName()).toBe("A");
+ expect(anchor.getCenterPosition()).toBe(100);
+ });
+
+ it("fromParticipantGeometries 应该创建锚点数组", () => {
+ const anchors = UnifiedAnchor.fromParticipantGeometries([geometryA, geometryB]);
+
+ expect(anchors).toHaveLength(2);
+ expect(anchors[0].getParticipantName()).toBe("A");
+ expect(anchors[1].getParticipantName()).toBe("B");
+ });
+ });
+
+ describe("静态计算方法", () => {
+ it("isRightToLeft 应该正确判断方向", () => {
+ const anchorA = new UnifiedAnchor(geometryA); // 位置100
+ const anchorB = new UnifiedAnchor(geometryB); // 位置300
+
+ expect(UnifiedAnchor.isRightToLeft(anchorA, anchorB)).toBe(false); // A->B 从左到右
+ expect(UnifiedAnchor.isRightToLeft(anchorB, anchorA)).toBe(true); // B->A 从右到左
+ });
+
+ it("distance 应该计算两个锚点之间的距离", () => {
+ const anchorA = new UnifiedAnchor(geometryA);
+ const anchorB = new UnifiedAnchor(geometryB);
+
+ expect(UnifiedAnchor.distance(anchorA, anchorB)).toBe(200); // 300 - 100
+ expect(UnifiedAnchor.distance(anchorB, anchorA)).toBe(-200); // 100 - 300
+ });
+ });
+
+ describe("墙位置计算", () => {
+ it("应该返回正确的墙位置", () => {
+ const anchor = new UnifiedAnchor(geometryB); // 有激活层的参与者
+
+ const centerOfWall = anchor.centerOfRightWall();
+ const rightEdge = anchor.rightEdgeOfRightWall();
+ const leftEdge = anchor.leftEdgeOfRightWall();
+
+ expect(rightEdge).toBeGreaterThan(centerOfWall);
+ expect(centerOfWall).toBeGreaterThan(leftEdge);
+ expect(centerOfWall).toBeGreaterThan(geometryB.centerPosition); // 有激活层时应该偏移
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/positioning/UnifiedAnchor.ts b/src/positioning/UnifiedAnchor.ts
new file mode 100644
index 00000000..e34c6576
--- /dev/null
+++ b/src/positioning/UnifiedAnchor.ts
@@ -0,0 +1,138 @@
+/**
+ * 统一的Anchor计算类
+ * 基于新的几何数据接口,提供更清晰的锚点计算功能
+ */
+
+import { ParticipantGeometry, AnchorGeometry } from "./GeometryTypes";
+import Anchor2 from "./Anchor2";
+
+/**
+ * 统一的锚点计算器
+ * 将ParticipantGeometry转换为精确的锚点计算,封装Anchor2的复杂性
+ */
+export class UnifiedAnchor {
+ private readonly geometry: ParticipantGeometry;
+ private readonly anchor2: Anchor2;
+
+ constructor(geometry: ParticipantGeometry) {
+ this.geometry = geometry;
+ this.anchor2 = new Anchor2(geometry.centerPosition, geometry.activationLayers);
+ }
+
+ /**
+ * 获取锚点几何数据
+ */
+ getAnchorGeometry(): AnchorGeometry {
+ return {
+ position: this.geometry.centerPosition,
+ layers: this.geometry.activationLayers,
+ };
+ }
+
+ /**
+ * 计算到另一个锚点的中心距离
+ * @param other 另一个锚点
+ * @returns 中心到中心的距离
+ */
+ centerToCenter(other: UnifiedAnchor): number {
+ return this.anchor2.centerToCenter(other.anchor2);
+ }
+
+ /**
+ * 计算到另一个锚点边缘的距离(用于translateX)
+ * @param other 另一个锚点
+ * @returns 中心到边缘的距离
+ */
+ centerToEdge(other: UnifiedAnchor): number {
+ return this.anchor2.centerToEdge(other.anchor2);
+ }
+
+ /**
+ * 计算边缘偏移量(用于交互宽度计算)
+ * @param other 另一个锚点
+ * @returns 边缘偏移量
+ */
+ edgeOffset(other: UnifiedAnchor): number {
+ return this.anchor2.edgeOffset(other.anchor2);
+ }
+
+ /**
+ * 获取右墙中心位置
+ */
+ centerOfRightWall(): number {
+ return this.anchor2.centerOfRightWall();
+ }
+
+ /**
+ * 获取右墙右边缘位置
+ */
+ rightEdgeOfRightWall(): number {
+ return this.anchor2.rightEdgeOfRightWall();
+ }
+
+ /**
+ * 获取右墙左边缘位置
+ */
+ leftEdgeOfRightWall(): number {
+ return this.anchor2.leftEdgeOfRightWall();
+ }
+
+ /**
+ * 获取参与者名称
+ */
+ getParticipantName(): string {
+ return this.geometry.name;
+ }
+
+ /**
+ * 获取激活层数
+ */
+ getActivationLayers(): number {
+ return this.geometry.activationLayers;
+ }
+
+ /**
+ * 获取中心位置
+ */
+ getCenterPosition(): number {
+ return this.geometry.centerPosition;
+ }
+
+ /**
+ * 静态工厂方法:从参与者几何数据创建锚点
+ * @param geometry 参与者几何数据
+ * @returns 统一锚点实例
+ */
+ static fromParticipantGeometry(geometry: ParticipantGeometry): UnifiedAnchor {
+ return new UnifiedAnchor(geometry);
+ }
+
+ /**
+ * 静态工厂方法:从多个参与者几何数据创建锚点数组
+ * @param geometries 参与者几何数据数组
+ * @returns 统一锚点实例数组
+ */
+ static fromParticipantGeometries(geometries: ParticipantGeometry[]): UnifiedAnchor[] {
+ return geometries.map(geometry => new UnifiedAnchor(geometry));
+ }
+
+ /**
+ * 判断方向:是否从右到左
+ * @param source 源锚点
+ * @param target 目标锚点
+ * @returns 是否从右到左
+ */
+ static isRightToLeft(source: UnifiedAnchor, target: UnifiedAnchor): boolean {
+ return target.getCenterPosition() < source.getCenterPosition();
+ }
+
+ /**
+ * 计算两个锚点之间的距离
+ * @param from 起始锚点
+ * @param to 目标锚点
+ * @returns 距离
+ */
+ static distance(from: UnifiedAnchor, to: UnifiedAnchor): number {
+ return to.getCenterPosition() - from.getCenterPosition();
+ }
+}
\ No newline at end of file
diff --git a/src/positioning/david/DavidEisenstat.ts b/src/positioning/david/DavidEisenstat.ts
index 7004216f..c0505dfa 100644
--- a/src/positioning/david/DavidEisenstat.ts
+++ b/src/positioning/david/DavidEisenstat.ts
@@ -118,7 +118,7 @@ function find_optimal(matrix: Array>) {
for (let j = 1; j < n; j++) {
gaps.push(Dual(0, 1));
}
- // eslint-disable-next-line no-constant-condition
+
while (true) {
const [delta, table] = longestPathTable(graph, gaps);
if (delta == Infinity) {
diff --git a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-darwin.png b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-darwin.png
index b458e4b6..66777cb9 100644
Binary files a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-darwin.png and b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-darwin.png differ
diff --git a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-linux.png b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-linux.png
index 98c9fd1c..3946bf09 100644
Binary files a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-linux.png and b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--14630--with-outbound-message-and-fragment-correctly-1-chromium-linux.png differ
diff --git a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-darwin.png b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-darwin.png
index 607d1e73..cc14a6d1 100644
Binary files a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-darwin.png and b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-darwin.png differ
diff --git a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-linux.png b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-linux.png
index dbeaa0a7..6e2eb7a2 100644
Binary files a/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-linux.png and b/tests/nested-interactions.spec.ts-snapshots/Nested-Interactions-Test-should-render-nested--8ead2-s-with-fragment-and-self-invocation-correctly-1-chromium-linux.png differ
diff --git a/tests/smoke.spec.ts-snapshots/should-load-the-home-page-chromium-linux.png b/tests/smoke.spec.ts-snapshots/should-load-the-home-page-chromium-linux.png
index 81e01e21..48b0f0d0 100644
Binary files a/tests/smoke.spec.ts-snapshots/should-load-the-home-page-chromium-linux.png and b/tests/smoke.spec.ts-snapshots/should-load-the-home-page-chromium-linux.png differ