Sequence diagrams from data

A reusable SequenceDiagram component renders any conversation - only the actors, messages, and activations arrays change

import {
	type Activation,
	type Frame,
	type Item,
	SequenceDiagram,
} from "./SequenceDiagram";

// Three different diagrams from the same component - only the data changes.

const httpFlow: {
	actors: string[];
	items: Item[];
	activations: Activation[];
} = {
	actors: ["Browser", "Server", "Database"],
	items: [
		{ from: "Browser", to: "Server", label: "GET /planets" },
		{ note: "over", actors: ["Server", "Database"], text: "query planning" },
		{ from: "Server", to: "Database", label: "SELECT * FROM planets" },
		{ from: "Database", to: "Server", label: "rows", line: "dotted" },
		{ from: "Server", to: "Server", label: "render JSON" },
		{ from: "Server", to: "Browser", label: "200 OK", line: "dotted" },
	],
	activations: [
		{ actor: "Server", from: 0, to: 5 },
		{ actor: "Database", from: 2, to: 3 },
	],
};

const oauthFlow: {
	actors: { id: string; label: string }[];
	items: Item[];
	activations: Activation[];
	frames: Frame[];
} = {
	actors: [
		{ id: "user", label: "User" },
		{ id: "app", label: "My App" },
		{ id: "auth", label: "Auth Server" },
	],
	items: [
		{ from: "user", to: "app", label: "Log in" },
		{ from: "app", to: "auth", label: "Authorization request", head: "open" },
		{ from: "auth", to: "user", label: "Consent prompt" },
		{ from: "user", to: "auth", label: "Approve" },
		{ from: "auth", to: "app", label: "Access token", line: "dotted" },
		{ note: "rightOf", actors: ["app"], text: "token cached" },
		{ from: "app", to: "user", label: "Logged in", line: "dotted" },
	],
	activations: [
		{ actor: "app", from: 0, to: 6 },
		{ actor: "auth", from: 1, to: 4 },
	],
	frames: [{ kind: "loop", label: "until granted", from: 2, to: 3 }],
};

const tcpHandshake: { actors: string[]; items: Item[]; frames: Frame[] } = {
	actors: ["Client", "Server"],
	items: [
		{ from: "Client", to: "Server", label: "SYN" },
		{ from: "Server", to: "Client", label: "SYN-ACK", line: "dotted" },
		{ from: "Client", to: "Server", label: "ACK" },
		{ from: "Client", to: "Server", label: "data", head: "both" },
		{ from: "Client", to: "Server", label: "FIN (lost)", head: "cross" },
	],
	frames: [{ kind: "rect", from: 0, to: 2, fill: "rgba(120,180,255,0.2)" }],
};

export function SequenceDiagramExample() {
	return (
		<div style={{ display: "flex", flexWrap: "wrap", gap: "1.5rem" }}>
			<div>
				<h3 style={{ margin: "0 0 0.5rem" }}>HTTP request (autonumbered)</h3>
				<SequenceDiagram {...httpFlow} autonumber />
			</div>
			<div>
				<h3 style={{ margin: "0 0 0.5rem" }}>OAuth login</h3>
				<SequenceDiagram {...oauthFlow} actorSpacing={200} />
			</div>
			<div>
				<h3 style={{ margin: "0 0 0.5rem" }}>TCP handshake</h3>
				<SequenceDiagram {...tcpHandshake} actorSpacing={140} />
			</div>
		</div>
	);
}

HTTP request (autonumbered)

OAuth login

TCP handshake

How the SequenceDiagram component works

The diagram class itself: actors in a stackH, dashed lifelines, messages as arrows between anchors positioned by align and distribute, and activation bars spanned to their message range

import { Graphic } from "@modular-svg/react";
import * as React from "react";

export type Actor = string | { id: string; label: string };

export type ArrowHead = "arrow" | "open" | "cross" | "none" | "both";

export type MessageItem = {
	from: string;
	to: string;
	label: string;
	/** shaft style; dotted is mermaid's reply/return style */
	line?: "solid" | "dotted";
	/** head style: filled arrow (default), open async head, lost-message
	 * cross, none, or heads on both ends */
	head?: ArrowHead;
};

export type NoteItem = {
	note: "leftOf" | "rightOf" | "over";
	/** one actor, or two for a spanning "over" note */
	actors: string[];
	text: string;
};

/** Items occupy vertical slots in order, like lines of mermaid syntax */
export type Item = MessageItem | NoteItem;

/** Which items activate an actor: from first index to last index */
export type Activation = { actor: string; from: number; to: number };

/** A labeled frame (loop/alt/opt/par) or plain highlight rect around a
 * range of items and a set of actors */
export type Frame = {
	kind: "loop" | "alt" | "opt" | "par" | "rect";
	label?: string;
	from: number;
	to: number;
	/** actors the frame spans horizontally; defaults to all */
	actors?: string[];
	/** fill for kind "rect" highlights */
	fill?: string;
	/** alt/par section boundaries: a dashed divider above item `at` */
	dividers?: { at: number; label?: string }[];
};

export type SequenceDiagramProps = {
	actors: Actor[];
	items: Item[];
	activations?: Activation[];
	frames?: Frame[];
	/** prefix messages with sequence numbers, like mermaid's autonumber */
	autonumber?: boolean;
	/** horizontal gap between actors; make room for the longest label */
	actorSpacing?: number;
	/** vertical gap between consecutive items */
	messageGap?: number;
	/** font size for actor names and message labels */
	fontSize?: number;
};

const isMessage = (item: Item): item is MessageItem => "from" in item;

const boxId = (actor: string) => `actor-${actor}`;
const bottomId = (actor: string) => `actor-${actor}-bottom`;
const rowId = (i: number) => `item${i}`;
const toAnchorId = (i: number) => `item${i}-to`;
const labelId = (i: number) => `item${i}-label`;

// The row anchor sits on the lifeline of a message's sender or a note's
// first actor; the message target gets a second anchor on its own lifeline.
function rowActor(item: Item): string {
	return isMessage(item) ? item.from : item.actors[0];
}

function anchorFor(items: Item[], actor: string, i: number): string {
	const item = items[i];
	if (isMessage(item) && item.to === actor && item.from !== actor) {
		return toAnchorId(i);
	}
	return rowId(i);
}

function ActorBox({
	id,
	name,
	fontSize,
}: {
	id: string;
	name: string;
	fontSize: number;
}) {
	return (
		<background
			key={id}
			padding={8}
			fill="#ECECFF"
			stroke="#555"
			stroke-width={1}
		>
			<text font-size={fontSize}>{name}</text>
		</background>
	);
}

// An invisible point on a lifeline where a message starts or ends
function Anchor({ id }: { id: string }) {
	return <rect key={id} width={0} height={0} stroke-width={0} />;
}

// The span relation via createElement (its tag collides with HTML <span>)
function Span(props: { axis: "x" | "y"; children?: React.ReactNode }) {
	return React.createElement("span", props);
}

// Dashed lifeline between an actor's top and bottom boxes (the line tag's
// fractional target prop collides with SVG's string target in TypeScript)
function Lifeline({ actor }: { actor: string }) {
	return React.createElement(
		"line",
		{
			key: `${actor}-lifeline`,
			stroke: "#999",
			"stroke-width": 1,
			"stroke-dasharray": "4 4",
			source: [0.5, 1],
			target: [0.5, 0],
		},
		React.createElement("ref", { target: boxId(actor) }),
		React.createElement("ref", { target: bottomId(actor) }),
	);
}

// Message shaft between the two anchors
function Shaft({
	id,
	from,
	to,
	dotted,
}: {
	id: string;
	from: string;
	to: string;
	dotted: boolean;
}) {
	return React.createElement(
		"line",
		{
			key: id,
			stroke: "#333",
			"stroke-width": 1.5,
			"stroke-dasharray": dotted ? "5 3" : undefined,
		},
		React.createElement("ref", { target: from }),
		React.createElement("ref", { target: to }),
	);
}

// Arrow heads are small linear paths aligned onto an anchor
const HEAD = 9;
function headPath(style: "arrow" | "open" | "cross", pointsRight: boolean) {
	const h = HEAD;
	if (style === "cross") {
		return `M 0,0 L ${h},${h} M ${h},0 L 0,${h}`;
	}
	const tipX = pointsRight ? h : 0;
	const backX = pointsRight ? 0 : h;
	const shape = `M ${backX},0 L ${tipX},${h / 2} L ${backX},${h}`;
	return style === "arrow" ? `${shape} Z` : shape;
}

function Head({
	id,
	anchor,
	style,
	pointsRight,
}: {
	id: string;
	anchor: string;
	style: "arrow" | "open" | "cross";
	pointsRight: boolean;
}) {
	const filled = style === "arrow";
	return (
		<React.Fragment>
			<path
				key={id}
				d={headPath(style, pointsRight)}
				fill={filled ? "#333" : "none"}
				stroke="#333"
				stroke-width={filled ? 0 : 1.5}
			/>
			<align alignment="centerY">
				<ref target={anchor} />
				<ref target={id} />
			</align>
			{/* the tip touches the anchor point */}
			<align axis="x" alignment={pointsRight ? "right" : "left"}>
				<ref target={anchor} />
				<ref target={id} />
			</align>
		</React.Fragment>
	);
}

// Invisible padded box used as a span source, so frames extend slightly
// past what they enclose
function Pad({
	id,
	padding,
	targets,
}: {
	id: string;
	padding: number;
	targets: string[];
}) {
	return (
		<background key={id} padding={padding} fill="transparent" stroke-width={0}>
			{targets.map((t) => (
				<ref key={t} target={t} />
			))}
		</background>
	);
}

export function SequenceDiagram({
	actors,
	items,
	activations = [],
	frames = [],
	autonumber = false,
	actorSpacing = 170,
	messageGap = 40,
	fontSize = 13,
}: SequenceDiagramProps) {
	const normalized = actors.map((a) =>
		typeof a === "string" ? { id: a, label: a } : a,
	);
	const actorIds = normalized.map((a) => a.id);
	const order = new Map(actorIds.map((id, i) => [id, i]));

	let msgNo = 0;
	const numbered = items.map((item) => {
		if (!isMessage(item)) return { item, label: undefined };
		msgNo++;
		const label = autonumber ? `${msgNo}. ${item.label}` : item.label;
		return { item, label };
	});

	return (
		<Graphic
			style={{
				border: "1px solid #ddd",
				borderRadius: "8px",
				display: "inline-block",
			}}
			margin={15}
		>
			<group>
				{/* Actor boxes, top and bottom (painted first: declared first) */}
				<stackH key="actors" spacing={actorSpacing} alignment="top">
					{normalized.map((a) => (
						<ActorBox
							key={a.id}
							id={boxId(a.id)}
							name={a.label}
							fontSize={fontSize}
						/>
					))}
				</stackH>
				{normalized.map((a) => (
					<ActorBox
						key={`${a.id}-b`}
						id={bottomId(a.id)}
						name={a.label}
						fontSize={fontSize}
					/>
				))}

				{/* Lifelines */}
				{actorIds.map((a) => (
					<Lifeline key={`${a}-lifeline`} actor={a} />
				))}

				{/* Frame rects and tabs (painted under bars and messages) */}
				{frames.map((f, fi) => {
					const id = `frame${fi}`;
					const highlight = f.kind === "rect";
					return (
						<React.Fragment key={id}>
							<rect
								key={id}
								fill={highlight ? (f.fill ?? "rgba(255,220,120,0.25)") : "none"}
								stroke={highlight ? "#0000" : "#666"}
								stroke-width={highlight ? 0 : 1}
							/>
							{!highlight && (
								<>
									<background
										key={`${id}-tab`}
										padding={3}
										fill="#EEE"
										stroke="#666"
										stroke-width={1}
									>
										<text font-size={fontSize - 2}>{f.kind}</text>
									</background>
									{f.label && (
										<text
											key={`${id}-cond`}
											font-size={fontSize - 2}
											fill="#555"
										>
											{`[${f.label}]`}
										</text>
									)}
								</>
							)}
						</React.Fragment>
					);
				})}

				{/* Activation bars */}
				{activations.map((act) => (
					<rect
						key={`act-${act.actor}`}
						width={10}
						fill="#E8E8E8"
						stroke="#999"
						stroke-width={1}
					/>
				))}

				{/* Items: a row anchor each, then message or note content */}
				{numbered.map(({ item, label }, i) => {
					const prev = i === 0 ? "actors" : rowId(i - 1);
					const spacing = i === 0 ? 50 : messageGap;
					const common = (
						<>
							<Anchor id={rowId(i)} />
							<align alignment="centerX">
								<ref target={boxId(rowActor(item))} />
								<ref target={rowId(i)} />
							</align>
							<distribute axis="y" spacing={spacing}>
								<ref target={prev} />
								<ref target={rowId(i)} />
							</distribute>
						</>
					);

					if (!isMessage(item)) {
						const over = item.note === "over";
						return (
							<React.Fragment key={rowId(i)}>
								{common}
								{over ? (
									<>
										<rect
											key={`${rowId(i)}-note`}
											height={fontSize + 12}
											fill="#FFF5AD"
											stroke="#AAAA33"
											stroke-width={1}
										/>
										<Span axis="x">
											<group>
												{item.actors.map((a) => (
													<ref key={a} target={boxId(a)} />
												))}
											</group>
											<ref target={`${rowId(i)}-note`} />
										</Span>
										<align alignment="centerY">
											<ref target={rowId(i)} />
											<ref target={`${rowId(i)}-note`} />
										</align>
										<text key={labelId(i)} font-size={fontSize}>
											{item.text}
										</text>
										<align alignment="center">
											<ref target={`${rowId(i)}-note`} />
											<ref target={labelId(i)} />
										</align>
									</>
								) : (
									<>
										<background
											key={`${rowId(i)}-note`}
											padding={6}
											fill="#FFF5AD"
											stroke="#AAAA33"
											stroke-width={1}
										>
											<text font-size={fontSize}>{item.text}</text>
										</background>
										<align alignment="centerY">
											<ref target={rowId(i)} />
											<ref target={`${rowId(i)}-note`} />
										</align>
										{item.note === "rightOf" ? (
											<distribute axis="x" spacing={12}>
												<ref target={rowId(i)} />
												<ref target={`${rowId(i)}-note`} />
											</distribute>
										) : (
											<distribute axis="x" spacing={12}>
												<ref target={`${rowId(i)}-note`} />
												<ref target={rowId(i)} />
											</distribute>
										)}
									</>
								)}
							</React.Fragment>
						);
					}

					const selfMessage = item.from === item.to;
					const head = item.head ?? "arrow";
					const pointsRight =
						(order.get(item.to) ?? 0) > (order.get(item.from) ?? 0);
					return (
						<React.Fragment key={rowId(i)}>
							{common}
							{!selfMessage && (
								<>
									<Anchor id={toAnchorId(i)} />
									<align alignment="centerY">
										<ref target={rowId(i)} />
										<ref target={toAnchorId(i)} />
									</align>
									<align alignment="centerX">
										<ref target={boxId(item.to)} />
										<ref target={toAnchorId(i)} />
									</align>
								</>
							)}

							<text key={labelId(i)} font-size={fontSize}>
								{label ?? item.label}
							</text>
							{selfMessage ? (
								<distribute axis="x" spacing={12}>
									<ref target={rowId(i)} />
									<ref target={labelId(i)} />
								</distribute>
							) : (
								<align alignment="centerX">
									<group>
										<ref target={rowId(i)} />
										<ref target={toAnchorId(i)} />
									</group>
									<ref target={labelId(i)} />
								</align>
							)}
							<distribute axis="y" spacing={4}>
								<ref target={labelId(i)} />
								<ref target={rowId(i)} />
							</distribute>

							{!selfMessage && (
								<>
									<Shaft
										id={`${rowId(i)}-shaft`}
										from={rowId(i)}
										to={toAnchorId(i)}
										dotted={item.line === "dotted"}
									/>
									{head !== "none" && (
										<Head
											id={`${rowId(i)}-head`}
											anchor={toAnchorId(i)}
											style={head === "both" ? "arrow" : head}
											pointsRight={pointsRight}
										/>
									)}
									{head === "both" && (
										<Head
											id={`${rowId(i)}-headback`}
											anchor={rowId(i)}
											style="arrow"
											pointsRight={!pointsRight}
										/>
									)}
								</>
							)}
						</React.Fragment>
					);
				})}

				{/* Bottom boxes hang below the last item */}
				{actorIds.map((a) => (
					<React.Fragment key={`${a}-bottom-pos`}>
						<align alignment="centerX">
							<ref target={boxId(a)} />
							<ref target={bottomId(a)} />
						</align>
						<distribute axis="y" spacing={40}>
							<ref target={rowId(items.length - 1)} />
							<ref target={bottomId(a)} />
						</distribute>
					</React.Fragment>
				))}

				{/* Activation bars: on the lifeline, spanning their item range */}
				{activations.map((act) => (
					<React.Fragment key={`act-${act.actor}-pos`}>
						<align alignment="centerX">
							<ref target={boxId(act.actor)} />
							<ref target={`act-${act.actor}`} />
						</align>
						<Span axis="y">
							<group>
								<ref target={anchorFor(items, act.actor, act.from)} />
								<ref target={anchorFor(items, act.actor, act.to)} />
							</group>
							<ref target={`act-${act.actor}`} />
						</Span>
					</React.Fragment>
				))}

				{/* Frames: spanned to padded boxes around their actors and rows */}
				{frames.map((f, fi) => {
					const id = `frame${fi}`;
					const frameActors = f.actors ?? actorIds;
					return (
						<React.Fragment key={`${id}-pos`}>
							<Pad
								id={`${id}-xpad`}
								padding={16}
								targets={frameActors.map(boxId)}
							/>
							<Pad
								id={`${id}-ypad`}
								padding={14}
								targets={[
									anchorFor(items, rowActor(items[f.from]), f.from),
									anchorFor(items, rowActor(items[f.to]), f.to),
								]}
							/>
							<Span axis="x">
								<ref target={`${id}-xpad`} />
								<ref target={id} />
							</Span>
							<Span axis="y">
								<ref target={`${id}-ypad`} />
								<ref target={id} />
							</Span>
							{f.kind !== "rect" && (
								<>
									<align alignment="topLeft">
										<ref target={id} />
										<ref target={`${id}-tab`} />
									</align>
									{f.label && (
										<>
											<distribute axis="x" spacing={8}>
												<ref target={`${id}-tab`} />
												<ref target={`${id}-cond`} />
											</distribute>
											<align alignment="centerY">
												<ref target={`${id}-tab`} />
												<ref target={`${id}-cond`} />
											</align>
										</>
									)}
								</>
							)}
							{(f.dividers ?? []).map((d, di) => (
								<React.Fragment key={`${id}-div${di}`}>
									<rect
										key={`${id}-div${di}`}
										height={0}
										fill="none"
										stroke="#666"
										stroke-width={1}
										stroke-dasharray="4 3"
									/>
									<Span axis="x">
										<ref target={id} />
										<ref target={`${id}-div${di}`} />
									</Span>
									<distribute axis="y" spacing={messageGap / 2}>
										<ref target={`${id}-div${di}`} />
										<ref target={rowId(d.at)} />
									</distribute>
									{d.label && (
										<>
											<text
												key={`${id}-div${di}-label`}
												font-size={fontSize - 2}
												fill="#555"
											>
												{`[${d.label}]`}
											</text>
											<align alignment="centerX">
												<ref target={`${id}-div${di}`} />
												<ref target={`${id}-div${di}-label`} />
											</align>
											<distribute axis="y" spacing={2}>
												<ref target={`${id}-div${di}-label`} />
												<ref target={`${id}-div${di}`} />
											</distribute>
										</>
									)}
								</React.Fragment>
							))}
						</React.Fragment>
					);
				})}
			</group>
		</Graphic>
	);
}