tscircuit/ti
ti tscircuit library
- Version
- 1.0.195
- License
- unset
- Stars
- 0
system-block-ui/src/model/system-diagram-svg.ts
import { SUBCIRCUIT_CATALOG } from "./catalog";
import { resolveDesignConnections } from "./resolve-connection";
import type {
BlockInstance,
GenerateTsxRequest,
ResolvedConnection,
SubcircuitDefinition,
} from "./types";
const NODE_WIDTH = 284;
const NODE_HEIGHT = 136;
const NODE_COLUMN_GAP = 126;
const NODE_ROW_GAP = 92;
const CONTENT_PADDING = 48;
const HEADER_HEIGHT = 76;
const MINIMUM_WIDTH = 900;
const MINIMUM_HEIGHT = 520;
const POWER_COLOR = "#d97706";
const DATA_COLOR = "#377bd4";
const compareStrings = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
const normalizeDisplayText = (value: string): string =>
value
.replace(
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\uD800-\uDFFF\uFFFE\uFFFF]/gu,
"",
)
.replace(/\s+/gu, " ")
.trim();
const escapeXml = (value: string): string =>
normalizeDisplayText(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const truncateText = (value: string, maximumLength: number): string => {
const normalized = normalizeDisplayText(value);
if (normalized.length <= maximumLength) return normalized;
return `${normalized.slice(0, Math.max(1, maximumLength - 1)).trimEnd()}…`;
};
const wrapTitle = (value: string): readonly string[] => {
const normalized = normalizeDisplayText(value) || "Untitled block";
const maximumLineLength = 36;
const words = normalized.split(" ");
const lines: string[] = [];
for (const word of words) {
const current = lines.at(-1);
if (!current) {
lines.push(word);
continue;
}
if (`${current} ${word}`.length <= maximumLineLength) {
lines[lines.length - 1] = `${current} ${word}`;
continue;
}
lines.push(word);
}
if (lines.length <= 2) {
return lines.map((line) => truncateText(line, maximumLineLength));
}
return [
truncateText(lines[0] ?? "Untitled block", maximumLineLength),
truncateText(lines.slice(1).join(" "), maximumLineLength),
];
};
const formatProtocolLabel = (protocol: string): string => {
const known: Readonly<Record<string, string>> = {
"can-bus": "CAN Bus",
"can-controller": "CAN",
"hci-uart": "HCI UART",
"motor-control": "Motor Control",
gpio: "GPIO",
i2c: "I²C",
i2s: "I²S",
uart: "UART",
};
return (
known[protocol] ??
protocol
.split(/[-_]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ")
);
};
const isFinitePosition = (
position: BlockInstance["position"],
): position is NonNullable<BlockInstance["position"]> =>
position !== undefined &&
Number.isFinite(position.x) &&
Number.isFinite(position.y);
interface DiagramBlock {
block: BlockInstance;
definition: SubcircuitDefinition;
instanceName: string;
x: number;
y: number;
}
interface DiagramConnection {
id: string;
fromBlockId: string;
toBlockId: string;
kind: "power" | "data";
label: string;
}
const getVisibleConnections = (
resolvedConnections: readonly ResolvedConnection[],
): readonly DiagramConnection[] => {
return resolvedConnections
.map(
(connection): DiagramConnection => ({
id: connection.id,
fromBlockId: connection.fromBlockId,
toBlockId: connection.toBlockId,
kind: connection.kind,
label:
connection.kind === "power"
? "Power"
: connection.protocol
? `Data · ${formatProtocolLabel(connection.protocol)}`
: "Data",
}),
)
.sort(
(a, b) =>
compareStrings(a.kind, b.kind) ||
compareStrings(a.fromBlockId, b.fromBlockId) ||
compareStrings(a.toBlockId, b.toBlockId) ||
compareStrings(a.id, b.id),
);
};
const prepareDiagramBlocks = (
blocks: readonly BlockInstance[],
catalog: readonly SubcircuitDefinition[],
): readonly DiagramBlock[] => {
const definitionById = new Map(catalog.map((item) => [item.id, item]));
const sorted = [...blocks].sort(
(a, b) =>
compareStrings(a.name ?? a.id, b.name ?? b.id) ||
compareStrings(a.id, b.id),
);
const preservePositions = sorted.every((block) =>
isFinitePosition(block.position),
);
const columnCount = Math.max(
1,
Math.ceil(Math.sqrt(Math.max(1, sorted.length) * 1.6)),
);
const unnormalized = sorted.map((block, index) => {
const definition = definitionById.get(block.definitionId);
if (!definition) {
throw new Error(
`Block ${block.id} references unknown subcircuit ${block.definitionId}.`,
);
}
const fallbackPosition = {
x: (index % columnCount) * (NODE_WIDTH + NODE_COLUMN_GAP),
y: Math.floor(index / columnCount) * (NODE_HEIGHT + NODE_ROW_GAP),
};
const position =
preservePositions && isFinitePosition(block.position)
? block.position
: fallbackPosition;
return {
block,
definition,
instanceName: block.name ?? block.id,
x: position.x,
y: position.y,
};
});
const minimumX =
unnormalized.length > 0 ? Math.min(...unnormalized.map(({ x }) => x)) : 0;
const minimumY =
unnormalized.length > 0 ? Math.min(...unnormalized.map(({ y }) => y)) : 0;
return unnormalized.map((item) => ({
...item,
x: item.x - minimumX + CONTENT_PADDING,
y: item.y - minimumY + HEADER_HEIGHT + CONTENT_PADDING,
}));
};
interface EdgeGeometry {
path: string;
labelX: number;
labelY: number;
}
const getEdgeGeometry = (
source: DiagramBlock,
target: DiagramBlock,
parallelOffset: number,
): EdgeGeometry => {
const forward = target.x + NODE_WIDTH / 2 >= source.x + NODE_WIDTH / 2;
const direction = forward ? 1 : -1;
const startX = source.x + (forward ? NODE_WIDTH : 0);
const endX = target.x + (forward ? 0 : NODE_WIDTH);
const startY = source.y + NODE_HEIGHT / 2;
const endY = target.y + NODE_HEIGHT / 2;
const controlDistance = Math.max(72, Math.abs(endX - startX) * 0.42);
const controlOne = {
x: startX + direction * controlDistance,
y: startY + parallelOffset,
};
const controlTwo = {
x: endX - direction * controlDistance,
y: endY + parallelOffset,
};
const labelX = (startX + 3 * controlOne.x + 3 * controlTwo.x + endX) / 8;
const labelY = (startY + 3 * controlOne.y + 3 * controlTwo.y + endY) / 8;
return {
path: `M ${startX} ${startY} C ${controlOne.x} ${controlOne.y}, ${controlTwo.x} ${controlTwo.y}, ${endX} ${endY}`,
labelX,
labelY,
};
};
const renderBlock = (item: DiagramBlock): readonly string[] => {
const titleLines = wrapTitle(item.definition.title);
const titleStartY = item.y + 35;
const hasPower = item.definition.ports.some((port) => port.kind === "power");
const hasData = item.definition.ports.some((port) => port.kind === "data");
const reviewed = item.definition.ports.length > 0;
const chips: Array<{ label: string; color: string }> = [];
if (hasPower) chips.push({ label: "Power", color: POWER_COLOR });
if (hasData) chips.push({ label: "Data", color: DATA_COLOR });
const lines = [
` <g data-block-id="${escapeXml(item.block.id)}">`,
` <title>${escapeXml(item.definition.title)}</title>`,
` <rect x="${item.x}" y="${item.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="#ffffff" stroke="#cbd5e1" stroke-width="2"/>`,
` <rect x="${item.x}" y="${item.y}" width="${NODE_WIDTH}" height="24" rx="12" fill="#f1f5f9"/>`,
` <path d="M ${item.x} ${item.y + 24} H ${item.x + NODE_WIDTH}" stroke="#e2e8f0"/>`,
];
titleLines.forEach((line, index) => {
lines.push(
` <text x="${item.x + 16}" y="${titleStartY + index * 18}" fill="#1f2937" font-family="Arial, Helvetica, sans-serif" font-size="14" font-weight="700">${escapeXml(line)}</text>`,
);
});
lines.push(
` <text x="${item.x + 16}" y="${item.y + 76}" fill="#64748b" font-family="Arial, Helvetica, sans-serif" font-size="10" font-weight="700" letter-spacing="0.8">${escapeXml(truncateText(item.definition.category.toUpperCase(), 38))}</text>`,
` <text x="${item.x + 16}" y="${item.y + 104}" fill="#475569" font-family="ui-monospace, SFMono-Regular, Consolas, monospace" font-size="11">${escapeXml(truncateText(item.instanceName, 34))}</text>`,
` <text x="${item.x + NODE_WIDTH - 14}" y="${item.y + 19}" text-anchor="end" fill="${reviewed ? "#287252" : "#9a6700"}" font-family="Arial, Helvetica, sans-serif" font-size="9" font-weight="700">${reviewed ? "REVIEWED" : "PLACEMENT ONLY"}</text>`,
);
let chipX = item.x + 16;
for (const chip of chips) {
const width = chip.label.length * 7 + 23;
lines.push(
` <rect x="${chipX}" y="${item.y + 114}" width="${width}" height="17" rx="8.5" fill="#ffffff" stroke="${chip.color}"/>`,
` <circle cx="${chipX + 9}" cy="${item.y + 122.5}" r="3" fill="${chip.color}"/>`,
` <text x="${chipX + 16}" y="${item.y + 126}" fill="${chip.color}" font-family="Arial, Helvetica, sans-serif" font-size="9" font-weight="700">${chip.label}</text>`,
);
chipX += width + 8;
}
if (chips.length === 0) {
lines.push(
` <text x="${item.x + 16}" y="${item.y + 126}" fill="#8a7658" font-family="Arial, Helvetica, sans-serif" font-size="9">No reviewed interfaces</text>`,
);
}
lines.push(" </g>");
return lines;
};
export interface RenderSystemDiagramSvgRequest {
blocks: readonly BlockInstance[];
resolvedConnections: readonly ResolvedConnection[];
catalog?: readonly SubcircuitDefinition[];
title?: string;
}
/** Render a standalone, deterministic SVG overview without requiring a DOM. */
export function renderSystemDiagramSvg({
blocks,
resolvedConnections,
catalog = SUBCIRCUIT_CATALOG,
title = "System Diagram",
}: RenderSystemDiagramSvgRequest): string {
const diagramBlocks = prepareDiagramBlocks(blocks, catalog);
const blockById = new Map(
diagramBlocks.map((block) => [block.block.id, block]),
);
const connections = getVisibleConnections(resolvedConnections);
const maximumX = Math.max(
...diagramBlocks.map(({ x }) => x + NODE_WIDTH),
MINIMUM_WIDTH - CONTENT_PADDING,
);
const maximumY = Math.max(
...diagramBlocks.map(({ y }) => y + NODE_HEIGHT),
MINIMUM_HEIGHT - CONTENT_PADDING,
);
const width = Math.ceil(Math.max(MINIMUM_WIDTH, maximumX + CONTENT_PADDING));
const height = Math.ceil(
Math.max(MINIMUM_HEIGHT, maximumY + CONTENT_PADDING),
);
const normalizedTitle = normalizeDisplayText(title) || "System Diagram";
const lines = [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="system-diagram-title system-diagram-description">`,
` <title id="system-diagram-title">${escapeXml(normalizedTitle)}</title>`,
` <desc id="system-diagram-description">${diagramBlocks.length} system block${diagramBlocks.length === 1 ? "" : "s"} with ${connections.length} resolved semantic connection${connections.length === 1 ? "" : "s"}.</desc>`,
" <defs>",
` <marker id="system-arrow-power" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${POWER_COLOR}"/></marker>`,
` <marker id="system-arrow-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${DATA_COLOR}"/></marker>`,
" </defs>",
` <text x="${CONTENT_PADDING}" y="42" fill="#1f2937" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="700">${escapeXml(normalizedTitle)}</text>`,
` <text x="${CONTENT_PADDING}" y="62" fill="#64748b" font-family="Arial, Helvetica, sans-serif" font-size="11">${diagramBlocks.length} block${diagramBlocks.length === 1 ? "" : "s"} · ${connections.length} resolved link${connections.length === 1 ? "" : "s"}</text>`,
` <circle cx="${width - 210}" cy="42" r="5" fill="${POWER_COLOR}"/><text x="${width - 198}" y="46" fill="#475569" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700">Power</text>`,
` <circle cx="${width - 112}" cy="42" r="5" fill="${DATA_COLOR}"/><text x="${width - 100}" y="46" fill="#475569" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700">Data</text>`,
` <path d="M ${CONTENT_PADDING} ${HEADER_HEIGHT} H ${width - CONTENT_PADDING}" stroke="#dbe2ea"/>`,
];
const pairCounts = new Map<string, number>();
for (const connection of connections) {
const pairKey = `${connection.fromBlockId}\u0000${connection.toBlockId}`;
pairCounts.set(pairKey, (pairCounts.get(pairKey) ?? 0) + 1);
}
const pairIndexes = new Map<string, number>();
const labels: string[][] = [];
for (const connection of connections) {
const source = blockById.get(connection.fromBlockId);
const target = blockById.get(connection.toBlockId);
if (!source || !target) {
throw new Error(
`System diagram connection ${connection.id} references a missing block.`,
);
}
const pairKey = `${connection.fromBlockId}\u0000${connection.toBlockId}`;
const pairIndex = pairIndexes.get(pairKey) ?? 0;
pairIndexes.set(pairKey, pairIndex + 1);
const pairCount = pairCounts.get(pairKey) ?? 1;
const parallelOffset = (pairIndex - (pairCount - 1) / 2) * 34;
const geometry = getEdgeGeometry(source, target, parallelOffset);
const color = connection.kind === "power" ? POWER_COLOR : DATA_COLOR;
lines.push(
` <path data-connection-id="${escapeXml(connection.id)}" data-kind="${connection.kind}" d="${geometry.path}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" marker-end="url(#system-arrow-${connection.kind})"/>`,
);
const labelWidth = Math.max(84, connection.label.length * 7 + 30);
labels.push([
` <g data-connection-label-for="${escapeXml(connection.id)}">`,
` <rect x="${geometry.labelX - labelWidth / 2}" y="${geometry.labelY - 14}" width="${labelWidth}" height="28" rx="14" fill="#ffffff" stroke="${color}"/>`,
` <circle cx="${geometry.labelX - labelWidth / 2 + 12}" cy="${geometry.labelY}" r="4" fill="${color}"/>`,
` <text x="${geometry.labelX - labelWidth / 2 + 22}" y="${geometry.labelY + 4}" fill="${color}" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700">${escapeXml(connection.label)}</text>`,
" </g>",
]);
}
if (diagramBlocks.length === 0) {
lines.push(
` <rect x="${width / 2 - 190}" y="${height / 2 - 55}" width="380" height="110" rx="14" fill="#ffffff" stroke="#cbd5e1" stroke-width="2"/>`,
` <text x="${width / 2}" y="${height / 2 - 3}" text-anchor="middle" fill="#334155" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700">No system blocks yet</text>`,
` <text x="${width / 2}" y="${height / 2 + 23}" text-anchor="middle" fill="#64748b" font-family="Arial, Helvetica, sans-serif" font-size="12">Add subcircuits to build the system overview.</text>`,
);
} else {
for (const block of diagramBlocks) lines.push(...renderBlock(block));
for (const label of labels) lines.push(...label);
}
lines.push("</svg>");
return lines.join("\n");
}
export interface GenerateSystemDiagramSvgRequest
extends Pick<GenerateTsxRequest, "blocks" | "connections" | "catalog"> {
title?: string;
}
/** Resolve a logical graph and render its standalone system diagram SVG. */
export function generateSystemDiagramSvg(
request: GenerateSystemDiagramSvgRequest,
): string {
const catalog = request.catalog ?? SUBCIRCUIT_CATALOG;
const connections = [...request.connections].sort((a, b) =>
compareStrings(a.id, b.id),
);
return renderSystemDiagramSvg({
blocks: request.blocks,
resolvedConnections: resolveDesignConnections(
request.blocks,
connections,
catalog,
),
catalog,
title: request.title,
});
}