BPMN processes from data

Nodes carry only a column and a lane; the layout follows. Flows pick their own route from that placement - straight across a lane, curving between lanes, or looping back under the pool to an earlier column

import {
	BpmnDiagram,
	type BpmnNode,
	type Flow,
	type Lane,
} from "./BpmnDiagram";

const lanes: Lane[] = [
	{ id: "employee", label: "Employee" },
	{ id: "finance", label: "Finance" },
];

// column places a node left to right, lane places it top to bottom
const nodes: BpmnNode[] = [
	{
		id: "incurred",
		type: "event",
		kind: "start",
		column: 0,
		lane: "employee",
		label: "Expense\nincurred",
	},
	{
		id: "submit",
		type: "task",
		column: 1,
		lane: "employee",
		label: "Submit\nclaim",
		marker: "user",
	},
	{
		id: "logged",
		type: "event",
		kind: "intermediate",
		column: 2,
		lane: "finance",
		label: "Claim\nlogged",
	},
	{
		id: "review",
		type: "task",
		column: 3,
		lane: "finance",
		label: "Review\nreceipts",
		marker: "user",
	},
	{
		id: "approved",
		type: "gateway",
		kind: "exclusive",
		column: 4,
		lane: "finance",
		label: "Approved?",
	},
	{
		id: "revise",
		type: "task",
		column: 5,
		lane: "employee",
		label: "Revise\nclaim",
		marker: "script",
	},
	{
		id: "pay",
		type: "task",
		column: 6,
		lane: "finance",
		label: "Reimburse",
		marker: "service",
	},
	{
		id: "settled",
		type: "event",
		kind: "end",
		column: 7,
		lane: "finance",
		label: "Claim\nsettled",
	},
];

const flows: Flow[] = [
	{ from: "incurred", to: "submit" },
	{ from: "submit", to: "logged" },
	{ from: "logged", to: "review" },
	{ from: "review", to: "approved" },
	{ from: "approved", to: "pay", label: "yes" },
	{ from: "approved", to: "revise", kind: "default" },
	// a flow to an earlier column is a rework loop, routed under the pool
	{ from: "revise", to: "submit", label: "resubmit" },
	{ from: "pay", to: "settled" },
];

// The same component without lanes: a bare process with a parallel split
// and join, where both branches must finish before the flow continues
const buildNodes: BpmnNode[] = [
	{ id: "push", type: "event", kind: "start", column: 0, label: "Push" },
	{ id: "fork", type: "gateway", kind: "parallel", column: 1 },
	{
		id: "unit",
		type: "task",
		column: 2,
		label: "Run unit\ntests",
		marker: "script",
	},
	{
		id: "lint",
		type: "task",
		column: 2,
		lane: "second",
		label: "Lint &\ntypecheck",
		marker: "script",
	},
	{ id: "join", type: "gateway", kind: "parallel", column: 3 },
	{
		id: "publish",
		type: "task",
		column: 4,
		label: "Publish\nartifact",
		marker: "service",
	},
	{ id: "green", type: "event", kind: "end", column: 5, label: "Build green" },
];

const buildFlows: Flow[] = [
	{ from: "push", to: "fork" },
	{ from: "fork", to: "unit" },
	{ from: "fork", to: "lint" },
	{ from: "unit", to: "join" },
	{ from: "lint", to: "join" },
	{ from: "join", to: "publish" },
	{ from: "publish", to: "green" },
];

export function BpmnDiagramExample() {
	return (
		<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
			<div>
				<h3 style={{ margin: "0 0 0.5rem" }}>Pool with lanes</h3>
				<BpmnDiagram
					lanes={lanes}
					poolLabel="Expense claim"
					nodes={nodes}
					flows={flows}
				/>
			</div>
			<div>
				<h3 style={{ margin: "0 0 0.5rem" }}>Bare process, no pool</h3>
				<BpmnDiagram
					lanes={[
						{ id: "first", label: "" },
						{ id: "second", label: "" },
					]}
					showPool={false}
					nodes={buildNodes}
					flows={buildFlows}
				/>
			</div>
		</div>
	);
}

Pool with lanes

Bare process, no pool

How the BpmnDiagram component works

Fixed-height lane rails give every node its y and invisible column boxes give it its x, so the grid is two stacks. The pool border and lane bands are drawn first and then span-copied off the rails, and arrowheads align onto invisible edge points so they sit just outside the shape they point at

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

/** A horizontal band of the pool: who performs the work inside it */
export type Lane = { id: string; label: string };

export type EventKind = "start" | "intermediate" | "end";
export type GatewayKind = "exclusive" | "parallel" | "inclusive";
/** Glyph in a task's top-left corner, saying how the work gets done */
export type TaskMarker = "user" | "service" | "script" | "message";

type Placement = {
	id: string;
	/** grid column; every node in a column shares an x position */
	column: number;
	/** lane id, defaulting to the first lane */
	lane?: string;
};

export type BpmnNode =
	| (Placement & { type: "event"; kind?: EventKind; label?: string })
	| (Placement & { type: "gateway"; kind?: GatewayKind; label?: string })
	| (Placement & { type: "task"; label: string; marker?: TaskMarker });

export type Flow = {
	from: string;
	to: string;
	label?: string;
	/** "default" adds the tick mark for a gateway's fallback branch */
	kind?: "sequence" | "default";
};

export type BpmnDiagramProps = {
	nodes: BpmnNode[];
	flows: Flow[];
	/** the rows nodes are placed in; omit for a single-row process */
	lanes?: Lane[];
	/** draw the pool border and lane headers; on by default with lanes */
	showPool?: boolean;
	poolLabel?: string;
	columnGap?: number;
	laneHeight?: number;
	taskWidth?: number;
	taskHeight?: number;
	fontSize?: number;
};

const EVENT_R = 17;
const GATEWAY_SIZE = 42;
const HEAD = 10;
const MARKER_CELL = 20;
const HEADER_WIDTH = 84;
const TITLE_HEIGHT = 26;
const LOOP_DROP = 34;

const FLOW_STROKE = "#4A4A4A";
const POOL_STROKE = "#7A8189";
const ICON = "#3D5A73";

const EVENT_STYLE: Record<
	EventKind,
	{ fill: string; stroke: string; strokeWidth: number }
> = {
	start: { fill: "#E7F5E9", stroke: "#4C9A54", strokeWidth: 2 },
	intermediate: { fill: "#FFF4E2", stroke: "#B87A1E", strokeWidth: 2 },
	end: { fill: "#FCEBE9", stroke: "#C0392B", strokeWidth: 4 },
};

const slotId = (id: string) => `slot-${id}`;
const shapeId = (id: string) => `n-${id}`;
const colId = (i: number) => `col${i}`;
const railId = (lane: string) => `rail-${lane}`;
const bandId = (lane: string) => `band-${lane}`;
const headId = (i: number) => `flow${i}-head`;
const viaId = (i: number) => `flow${i}-via`;
const midId = (i: number) => `flow${i}-mid`;

// 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);
}

type Side = "left" | "right" | "top" | "bottom";

const FRACTION: Record<Side, number[]> = {
	left: [0, 0.5],
	right: [1, 0.5],
	top: [0.5, 0],
	bottom: [0.5, 1],
};
const OPPOSITE: Record<Side, Side> = {
	left: "right",
	right: "left",
	top: "bottom",
	bottom: "top",
};
const AXIS: Record<Side, "x" | "y"> = {
	left: "x",
	right: "x",
	top: "y",
	bottom: "y",
};
const CROSS: Record<Side, "centerX" | "centerY"> = {
	left: "centerY",
	right: "centerY",
	top: "centerX",
	bottom: "centerX",
};

// Where a flow leaves its source and enters its target. A forward flow that
// stays in its lane runs straight across; one that changes lane leaves
// through the edge facing the target lane, which is also what keeps a
// gateway's branches from stacking on the same exit point. Same-column flows
// run straight up or down, and a flow to an earlier column loops back
// underneath the pool.
type Route = { exit: Side; enter: Side; loop: boolean };

function routeFor(
	from: { column: number; lane: number },
	to: { column: number; lane: number },
): Route {
	if (to.column > from.column) {
		if (to.lane === from.lane)
			return { exit: "right", enter: "left", loop: false };
		return {
			exit: to.lane > from.lane ? "bottom" : "top",
			enter: "left",
			loop: false,
		};
	}
	if (to.column < from.column)
		return { exit: "bottom", enter: "bottom", loop: true };
	return to.lane > from.lane
		? { exit: "bottom", enter: "top", loop: false }
		: { exit: "top", enter: "bottom", loop: false };
}

const headPath = (enter: Side) => {
	const h = HEAD;
	if (enter === "left") return `M 0,0 L ${h},${h / 2} L 0,${h} Z`;
	if (enter === "right") return `M ${h},0 L 0,${h / 2} L ${h},${h} Z`;
	if (enter === "top") return `M 0,0 L ${h / 2},${h} L ${h},0 Z`;
	return `M 0,${h} L ${h / 2},0 L ${h},${h} Z`;
};

// A square-toothed gear outline, drawn as one polygon so its bounds come
// out right (the parser measures paths from their line segments)
const gearPath = (r: number, teeth = 6, depth = 0.26) => {
	const steps = teeth * 4;
	const points: string[] = [];
	for (let i = 0; i < steps; i++) {
		const angle = (i * 2 * Math.PI) / steps;
		const radius = i % 4 < 2 ? r : r * (1 - depth);
		points.push(
			`${(r + radius * Math.cos(angle)).toFixed(2)},${(r + radius * Math.sin(angle)).toFixed(2)}`,
		);
	}
	return `M ${points.join(" L ")} Z`;
};

// An invisible point pinned to one edge of a node: the anchor arrowheads and
// default-flow ticks align against, so they can sit outside the shape
function EdgePoint({
	id,
	node,
	side,
}: {
	id: string;
	node: string;
	side: Side;
}) {
	return (
		<React.Fragment>
			<rect key={id} width={0} height={0} stroke-width={0} />
			<align axis={AXIS[side]} alignment={side}>
				<ref target={node} />
				<ref target={id} />
			</align>
			<align alignment={CROSS[side]}>
				<ref target={node} />
				<ref target={id} />
			</align>
		</React.Fragment>
	);
}

// Aligned against an edge point on the opposite side, so the tip lands on
// the point and the body sits outside the shape
function OutsideEdge({
	id,
	point,
	side,
	children,
}: {
	id: string;
	point: string;
	side: Side;
	children: React.ReactNode;
}) {
	return (
		<React.Fragment>
			{children}
			<align axis={AXIS[side]} alignment={OPPOSITE[side]}>
				<ref target={point} />
				<ref target={id} />
			</align>
			<align alignment={CROSS[side]}>
				<ref target={point} />
				<ref target={id} />
			</align>
		</React.Fragment>
	);
}

function TaskMarkerGlyph({ marker }: { marker: TaskMarker }) {
	if (marker === "user") {
		// head over shoulders: a stack keeps the two parts in relation
		return (
			<stackV spacing={1} alignment="center">
				<circle r={3.2} fill="none" stroke={ICON} stroke-width={1.3} />
				<path
					d="M 0,4 L 1.4,0 L 7.6,0 L 9,4 Z"
					fill="none"
					stroke={ICON}
					stroke-width={1.3}
				/>
			</stackV>
		);
	}
	if (marker === "service") {
		return (
			<path d={gearPath(6.5)} fill="none" stroke={ICON} stroke-width={1.3} />
		);
	}
	if (marker === "script") {
		return (
			<path
				d="M 1,0 L 11,0 L 11,13 L 1,13 Z M 3.5,3.5 L 8.5,3.5 M 3.5,6.5 L 8.5,6.5 M 3.5,9.5 L 7,9.5"
				fill="none"
				stroke={ICON}
				stroke-width={1.2}
			/>
		);
	}
	return (
		<path
			d="M 0,0 L 13,0 L 13,9.5 L 0,9.5 Z M 0,0 L 6.5,5 L 13,0"
			fill="none"
			stroke={ICON}
			stroke-width={1.2}
		/>
	);
}

export function BpmnDiagram({
	nodes,
	flows,
	lanes,
	showPool,
	poolLabel,
	columnGap = 34,
	laneHeight = 92,
	taskWidth = 116,
	taskHeight = 58,
	fontSize = 12,
}: BpmnDiagramProps) {
	const laneList: Lane[] =
		lanes !== undefined && lanes.length > 0 ? lanes : [{ id: "_", label: "" }];
	const pooled = showPool ?? (lanes !== undefined && lanes.length > 0);
	const laneOrder = new Map(laneList.map((l, i) => [l.id, i]));
	const titleRail = "_title";

	const nodeWidth = (n: BpmnNode) =>
		n.type === "task"
			? taskWidth
			: n.type === "gateway"
				? GATEWAY_SIZE
				: EVENT_R * 2;
	const nodeHeight = (n: BpmnNode) =>
		n.type === "task"
			? taskHeight
			: n.type === "gateway"
				? GATEWAY_SIZE
				: EVENT_R * 2;

	const columnCount = Math.max(...nodes.map((n) => n.column)) + 1;
	const columnWidths = Array.from({ length: columnCount }, (_, i) =>
		Math.max(0, ...nodes.filter((n) => n.column === i).map(nodeWidth)),
	);

	const placement = new Map(
		nodes.map((n) => [
			n.id,
			{ column: n.column, lane: laneOrder.get(n.lane ?? laneList[0].id) ?? 0 },
		]),
	);
	const laneIdOf = (n: BpmnNode) => n.lane ?? laneList[0].id;

	return (
		<Graphic
			style={{
				border: "1px solid #ddd",
				borderRadius: "8px",
				display: "inline-block",
				maxWidth: "100%",
				overflow: "auto",
			}}
			margin={15}
		>
			<group>
				{/* Pool chrome, declared first so everything else paints over it.
				    Its geometry is copied on from the rails further down. */}
				{pooled && (
					<>
						<rect
							key="pool"
							fill="#FFFFFF"
							stroke={POOL_STROKE}
							stroke-width={2}
						/>
						{laneList.map((l) => (
							<rect
								key={bandId(l.id)}
								fill="none"
								stroke="#B4BBC2"
								stroke-width={1}
							/>
						))}
						{poolLabel && (
							<>
								<rect
									key="title"
									fill="#E2E7EC"
									stroke={POOL_STROKE}
									stroke-width={1}
								/>
								<text key="title-label" font-size={fontSize + 1} fill="#3A4148">
									{poolLabel}
								</text>
							</>
						)}
					</>
				)}

				{/* Lane rails: fixed-height rows that give every node its y. With a
				    pool they double as the visible lane headers. */}
				<stackV key="rails" spacing={0} alignment="left">
					{poolLabel && pooled && (
						<rect
							key={railId(titleRail)}
							width={HEADER_WIDTH}
							height={TITLE_HEIGHT}
							fill="transparent"
							stroke-width={0}
						/>
					)}
					{laneList.map((l) => (
						<rect
							key={railId(l.id)}
							width={pooled ? HEADER_WIDTH : 0}
							height={laneHeight}
							fill={pooled ? "#F1F4F7" : "transparent"}
							stroke={pooled ? "#B4BBC2" : undefined}
							stroke-width={pooled ? 1 : 0}
						/>
					))}
				</stackV>

				{pooled &&
					laneList.map((l) => (
						<React.Fragment key={`lane-label-${l.id}`}>
							<text key={`label-${l.id}`} font-size={fontSize} fill="#3A4148">
								{l.label}
							</text>
							<align alignment="center">
								<ref target={railId(l.id)} />
								<ref target={`label-${l.id}`} />
							</align>
						</React.Fragment>
					))}

				{/* Column rails: invisible boxes as wide as their widest node, so
				    the gap between columns stays constant whatever they hold */}
				<stackH key="cols" spacing={columnGap}>
					{columnWidths.map((w, i) => (
						<rect
							key={colId(i)}
							width={w}
							height={0}
							fill="transparent"
							stroke-width={0}
						/>
					))}
					{/* trailing breathing room inside the pool */}
					<rect key="col-end" width={columnGap} height={0} stroke-width={0} />
				</stackH>
				<distribute axis="x" spacing={columnGap}>
					<ref target="rails" />
					<ref target="cols" />
				</distribute>

				{/* One invisible box per node, sized like the shape that will fill
				    it and pinned to its column (x) and its lane rail (y). Flows
				    reference these, so the shapes declared afterwards paint over
				    the flow ends without any z-order juggling. */}
				{nodes.map((n) => (
					<React.Fragment key={slotId(n.id)}>
						<rect
							key={slotId(n.id)}
							width={nodeWidth(n)}
							height={nodeHeight(n)}
							fill="transparent"
							stroke-width={0}
						/>
						<align alignment="centerX">
							<ref target={colId(n.column)} />
							<ref target={slotId(n.id)} />
						</align>
						<align alignment="centerY">
							<ref target={railId(laneIdOf(n))} />
							<ref target={slotId(n.id)} />
						</align>
					</React.Fragment>
				))}

				{flows.map((f, i) => {
					const from = placement.get(f.from);
					const to = placement.get(f.to);
					if (!from || !to) return null;
					const route = routeFor(from, to);
					if (route.loop) {
						// A rework loop dives under the pool through a waypoint
						// centred between the two nodes
						return (
							<React.Fragment key={`flow${i}`}>
								<rect key={viaId(i)} width={0} height={0} stroke-width={0} />
								<align alignment="centerX">
									<group>
										<ref target={slotId(f.from)} />
										<ref target={slotId(f.to)} />
									</group>
									<ref target={viaId(i)} />
								</align>
								<distribute axis="y" spacing={LOOP_DROP}>
									<ref target="rails" />
									<ref target={viaId(i)} />
								</distribute>
								<curve
									key={`flow${i}-down`}
									stroke={FLOW_STROKE}
									stroke-width={1.6}
									source={FRACTION.bottom}
									target={[0.5, 0.5]}
									direction="vertical"
								>
									<ref target={slotId(f.from)} />
									<ref target={viaId(i)} />
								</curve>
								<curve
									key={`flow${i}-up`}
									stroke={FLOW_STROKE}
									stroke-width={1.6}
									source={[0.5, 0.5]}
									target={FRACTION.bottom}
									direction="vertical"
								>
									<ref target={viaId(i)} />
									<ref target={slotId(f.to)} />
								</curve>
							</React.Fragment>
						);
					}
					return (
						<curve
							key={`flow${i}`}
							stroke={FLOW_STROKE}
							stroke-width={1.6}
							source={FRACTION[route.exit]}
							target={FRACTION[route.enter]}
							direction={AXIS[route.enter] === "x" ? "horizontal" : "vertical"}
						>
							<ref target={slotId(f.from)} />
							<ref target={slotId(f.to)} />
						</curve>
					);
				})}

				{/* Shapes, each centred on its slot */}
				{nodes.map((n) => (
					<React.Fragment key={n.id}>
						{n.type === "event" && (
							<EventShape
								id={shapeId(n.id)}
								slot={slotId(n.id)}
								kind={n.kind ?? "start"}
							/>
						)}
						{n.type === "gateway" && (
							<GatewayShape
								id={shapeId(n.id)}
								slot={slotId(n.id)}
								kind={n.kind ?? "exclusive"}
							/>
						)}
						{n.type === "task" && (
							<TaskShape
								id={shapeId(n.id)}
								slot={slotId(n.id)}
								label={n.label}
								width={taskWidth}
								height={taskHeight}
								fontSize={fontSize}
							/>
						)}

						{n.type === "task" && n.marker && (
							<align alignment="topLeft">
								<ref target={slotId(n.id)} />
								<align alignment="center">
									<rect
										width={MARKER_CELL}
										height={MARKER_CELL}
										fill="transparent"
										stroke-width={0}
									/>
									<TaskMarkerGlyph marker={n.marker} />
								</align>
							</align>
						)}

						{/* Events and gateways caption themselves underneath */}
						{n.type !== "task" && n.label && (
							<>
								<stackV
									key={`${shapeId(n.id)}-cap`}
									spacing={2}
									alignment="center"
								>
									{n.label.split("\n").map((line, li) => (
										<text
											key={`${shapeId(n.id)}-cap${li}`}
											font-size={fontSize - 1}
											fill="#3A4148"
										>
											{line}
										</text>
									))}
								</stackV>
								<align alignment="centerX">
									<ref target={slotId(n.id)} />
									<ref target={`${shapeId(n.id)}-cap`} />
								</align>
								<distribute axis="y" spacing={6}>
									<ref target={slotId(n.id)} />
									<ref target={`${shapeId(n.id)}-cap`} />
								</distribute>
							</>
						)}
					</React.Fragment>
				))}

				{/* Arrowheads, default-branch ticks and flow labels go on top */}
				{flows.map((f, i) => {
					const from = placement.get(f.from);
					const to = placement.get(f.to);
					if (!from || !to) return null;
					const route = routeFor(from, to);
					const enter = route.enter;
					const exit = route.exit;
					// A curve leaves along the axis it arrives on, so that - not
					// the exit edge - is where the default marker has to sit for
					// it to land on the line
					const tickSide: Side = AXIS[enter] === "x" ? "right" : exit;
					return (
						<React.Fragment key={`deco${i}`}>
							<EdgePoint
								id={`${headId(i)}-pt`}
								node={slotId(f.to)}
								side={enter}
							/>
							<OutsideEdge
								id={headId(i)}
								point={`${headId(i)}-pt`}
								side={enter}
							>
								<path
									key={headId(i)}
									d={headPath(enter)}
									fill={FLOW_STROKE}
									stroke-width={0}
								/>
							</OutsideEdge>

							{f.kind === "default" && (
								<>
									<EdgePoint
										id={`flow${i}-tick-pt`}
										node={slotId(f.from)}
										side={exit}
									/>
									<OutsideEdge
										id={`flow${i}-tick`}
										point={`flow${i}-tick-pt`}
										side={tickSide}
									>
										<path
											key={`flow${i}-tick`}
											d="M 0,9 L 9,0"
											fill="none"
											stroke={FLOW_STROKE}
											stroke-width={1.6}
										/>
									</OutsideEdge>
								</>
							)}

							{f.label && (
								<>
									{!route.loop && (
										<>
											<rect
												key={midId(i)}
												width={0}
												height={0}
												stroke-width={0}
											/>
											<align alignment="center">
												<group>
													<ref target={slotId(f.from)} />
													<ref target={slotId(f.to)} />
												</group>
												<ref target={midId(i)} />
											</align>
										</>
									)}
									{/* transparent padding lifts the text clear of the line */}
									<background
										key={`flow${i}-label`}
										padding={4}
										fill="transparent"
										stroke-width={0}
									>
										<text font-size={fontSize - 1} fill="#4A4A4A">
											{f.label}
										</text>
									</background>
									<align axis="y" alignment={route.loop ? "top" : "bottom"}>
										<ref target={route.loop ? viaId(i) : midId(i)} />
										<ref target={`flow${i}-label`} />
									</align>
									<align alignment="centerX">
										<ref target={route.loop ? viaId(i) : midId(i)} />
										<ref target={`flow${i}-label`} />
									</align>
								</>
							)}
						</React.Fragment>
					);
				})}

				{/* Pool geometry, copied from the rails and columns onto the chrome
				    declared at the top */}
				{pooled && (
					<>
						<Span axis="x">
							<group key="pool-extent">
								<ref target="rails" />
								<ref target="cols" />
							</group>
							<ref target="pool" />
						</Span>
						<Span axis="y">
							<ref target="rails" />
							<ref target="pool" />
						</Span>
						{laneList.map((l) => (
							<React.Fragment key={`band-pos-${l.id}`}>
								<Span axis="x">
									<ref target="pool" />
									<ref target={bandId(l.id)} />
								</Span>
								<Span axis="y">
									<ref target={railId(l.id)} />
									<ref target={bandId(l.id)} />
								</Span>
							</React.Fragment>
						))}
						{poolLabel && (
							<>
								<Span axis="x">
									<ref target="pool" />
									<ref target="title" />
								</Span>
								<Span axis="y">
									<ref target={railId(titleRail)} />
									<ref target="title" />
								</Span>
								<align alignment="center">
									<ref target="title" />
									<ref target="title-label" />
								</align>
							</>
						)}
					</>
				)}
			</group>
		</Graphic>
	);
}

// Every shape declares its outline, then claims its slot, and only then
// positions whatever sits on top of it: a relation anchors on its first
// already-positioned child, so the outline has to be pinned first.
function OnSlot({ id, slot }: { id: string; slot: string }) {
	return (
		<align alignment="center">
			<ref target={slot} />
			<ref target={id} />
		</align>
	);
}

function EventShape({
	id,
	slot,
	kind,
}: {
	id: string;
	slot: string;
	kind: EventKind;
}) {
	const style = EVENT_STYLE[kind];
	return (
		<React.Fragment>
			<circle
				key={id}
				r={EVENT_R}
				fill={style.fill}
				stroke={style.stroke}
				stroke-width={style.strokeWidth}
			/>
			<OnSlot id={id} slot={slot} />
			{/* the double ring that marks an intermediate event */}
			{kind === "intermediate" && (
				<>
					<circle
						key={`${id}-ring`}
						r={EVENT_R - 4}
						fill="none"
						stroke={style.stroke}
						stroke-width={2}
					/>
					<align alignment="center">
						<ref target={id} />
						<ref target={`${id}-ring`} />
					</align>
				</>
			)}
		</React.Fragment>
	);
}

const diamond = (s: number) =>
	`M ${s / 2},0 L ${s},${s / 2} L ${s / 2},${s} L 0,${s / 2} Z`;

const GATEWAY_SYMBOL: Record<GatewayKind, string> = {
	exclusive: "M 0,0 L 13,13 M 13,0 L 0,13",
	parallel: "M 8,0 L 8,16 M 0,8 L 16,8",
	inclusive: "",
};

function GatewayShape({
	id,
	slot,
	kind,
}: {
	id: string;
	slot: string;
	kind: GatewayKind;
}) {
	return (
		<React.Fragment>
			<path
				key={id}
				d={diamond(GATEWAY_SIZE)}
				fill="#FDF6DD"
				stroke="#B8912F"
				stroke-width={2}
			/>
			<OnSlot id={id} slot={slot} />
			{kind === "inclusive" ? (
				<circle
					key={`${id}-sym`}
					r={8}
					fill="none"
					stroke="#8A6D1F"
					stroke-width={2.4}
				/>
			) : (
				<path
					key={`${id}-sym`}
					d={GATEWAY_SYMBOL[kind]}
					fill="none"
					stroke="#8A6D1F"
					stroke-width={2.4}
				/>
			)}
			<align alignment="center">
				<ref target={id} />
				<ref target={`${id}-sym`} />
			</align>
		</React.Fragment>
	);
}

function TaskShape({
	id,
	slot,
	label,
	width,
	height,
	fontSize,
}: {
	id: string;
	slot: string;
	label: string;
	width: number;
	height: number;
	fontSize: number;
}) {
	// no text wrapping in the layout engine, so newlines are the line breaks
	const lines = label.split("\n");
	return (
		<React.Fragment>
			<rect
				key={id}
				width={width}
				height={height}
				rx={10}
				fill="#FFFFFF"
				stroke="#4E7CA8"
				stroke-width={2}
			/>
			<OnSlot id={id} slot={slot} />
			<stackV key={`${id}-text`} spacing={3} alignment="center">
				{lines.map((line, i) => (
					<text key={`${id}-line${i}`} font-size={fontSize} fill="#20303D">
						{line}
					</text>
				))}
			</stackV>
			<align alignment="center">
				<ref target={id} />
				<ref target={`${id}-text`} />
			</align>
		</React.Fragment>
	);
}