Merge commit 'fa62a5fc4cdf127b919c90ca7451d24f7e25817c' into 1.0.0.0/IIAA-XYZ-agents365-lifecycle-002
# Conflicts: # scripts/src/authoring/ir-to-drawio.ts
This commit is contained in:
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { parseAllPages } from "../../services/drawio-parser/parser.js";
|
||||
import { run } from "./action.js";
|
||||
@@ -101,3 +102,45 @@ test("build serializes every v2 page and honors explicit geometry and waypoints"
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("build emits parseable XML when generic model attributes collide with fixed attributes", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "drawio-build-model-attributes-test-"));
|
||||
const input = join(dir, "model-attributes.yaml");
|
||||
const output = join(dir, "model-attributes.drawio");
|
||||
try {
|
||||
writeFileSync(input, `version: 2
|
||||
pages:
|
||||
- id: page
|
||||
title: Page
|
||||
layout: { type: manual, gridSize: 20 }
|
||||
extensions:
|
||||
$drawio:
|
||||
attributes:
|
||||
model:grid: "0"
|
||||
model:gridSize: "99"
|
||||
model:page: "0"
|
||||
model:pageWidth: "1"
|
||||
model:pageHeight: "2"
|
||||
model:background: "#ffffff"
|
||||
nodes:
|
||||
- { id: node, label: Node, geometry: { x: 0, y: 0, width: 100, height: 60 } }
|
||||
edges: []
|
||||
`, "utf8");
|
||||
run(input, 0, output);
|
||||
|
||||
const xml = readFileSync(output, "utf8");
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
const document = new dom.window.DOMParser().parseFromString(xml, "text/xml");
|
||||
assert.equal(document.querySelector("parsererror"), null);
|
||||
const model = document.querySelector("mxGraphModel");
|
||||
assert.ok(model);
|
||||
assert.equal(model.getAttribute("grid"), "1");
|
||||
assert.equal(model.getAttribute("gridSize"), "20");
|
||||
assert.equal(model.getAttribute("page"), "1");
|
||||
assert.equal(model.getAttribute("pageWidth"), "1169");
|
||||
assert.equal(model.getAttribute("pageHeight"), "827");
|
||||
assert.equal(model.getAttribute("background"), "#ffffff");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { layoutPage } from "../services/layout/layout-engine.js";
|
||||
const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;";
|
||||
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;";
|
||||
const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
|
||||
const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]);
|
||||
|
||||
type Preservation = {
|
||||
host?: string;
|
||||
@@ -50,14 +51,21 @@ function dataAttributes(values: Record<string, unknown>): string {
|
||||
return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join("");
|
||||
}
|
||||
|
||||
function rawAttributes(values: Record<string, string> | undefined, prefix?: string): string {
|
||||
return Object.entries(values ?? {}).filter(([key]) => !prefix || key.startsWith(prefix)).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(prefix ? key.slice(prefix.length) : key)}="${escapeXml(value)}"`).join("");
|
||||
function rawAttributes(values: Record<string, string> | undefined, prefix?: string, reserved = new Set<string>()): string {
|
||||
return Object.entries(values ?? {})
|
||||
.filter(([key]) => !prefix || key.startsWith(prefix))
|
||||
.map(([key, value]) => [prefix ? key.slice(prefix.length) : key, value] as const)
|
||||
.filter(([key]) => !reserved.has(key) && /^[A-Za-z_][\w:.-]*$/.test(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function unprefixedAttributes(values: Record<string, string> | undefined): string {
|
||||
return Object.entries(values ?? {}).filter(([key]) => !key.includes(":")).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`).join("");
|
||||
}
|
||||
|
||||
|
||||
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
|
||||
if (node.geometry) return node.geometry;
|
||||
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
|
||||
@@ -128,7 +136,7 @@ function serializePage(input: DiagramPage): string {
|
||||
const diagramChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
|
||||
const modelChildren = (p.modelChildXml ?? []).map((xml) => ` ${xml}`);
|
||||
const modelAttributes = p.modelAttributes ?? {};
|
||||
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}${unprefixedAttributes(p.attributes)}>\n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} <mxGraphModel grid="${escapeXml(modelAttributes.grid ?? "1")}" gridSize="${escapeXml(modelAttributes.gridSize ?? String(grid))}" page="${escapeXml(modelAttributes.page ?? "1")}" pageWidth="${escapeXml(modelAttributes.pageWidth ?? String(pageWidth))}" pageHeight="${escapeXml(modelAttributes.pageHeight ?? String(pageHeight))}"${rawAttributes(p.attributes, "model:")}>\n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
|
||||
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}${unprefixedAttributes(p.attributes)}>\n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} <mxGraphModel grid="${escapeXml(modelAttributes.grid ?? "1")}" gridSize="${escapeXml(modelAttributes.gridSize ?? String(grid))}" page="${escapeXml(modelAttributes.page ?? "1")}" pageWidth="${escapeXml(modelAttributes.pageWidth ?? String(pageWidth))}" pageHeight="${escapeXml(modelAttributes.pageHeight ?? String(pageHeight))}"${rawAttributes(p.attributes, "model:", FIXED_MODEL_ATTRIBUTES)}>\n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
|
||||
}
|
||||
|
||||
export function diagramIRToDrawio(ir: DiagramIR): string {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js";
|
||||
|
||||
test("offline catalog entries carry explicit public provenance and licensing", () => {
|
||||
assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6);
|
||||
for (const entry of OFFLINE_SHAPE_CATALOG) {
|
||||
assert.equal(entry.provenance.license, "Apache-2.0");
|
||||
assert.match(entry.provenance.sourceUrl, /^https:\/\//);
|
||||
assert.match(entry.style, /shape=/);
|
||||
}
|
||||
});
|
||||
|
||||
test("shape search ranks exact, alias, and fuzzy matches deterministically", () => {
|
||||
assert.deepEqual(searchShapes("database"), searchShapes("database"));
|
||||
assert.equal(searchShapes("database").matches[0].id, "cylinder");
|
||||
assert.equal(searchShapes("database").matches[0].matchType, "alias");
|
||||
assert.equal(searchShapes("rhombus").matches[0].matchType, "exact");
|
||||
const fuzzy = searchShapes("proces box").matches[0];
|
||||
assert.equal(fuzzy.id, "rectangle");
|
||||
assert.equal(fuzzy.matchType, "fuzzy");
|
||||
assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9);
|
||||
});
|
||||
|
||||
test("shape search returns an explicit generic fallback for unknown or blank queries", () => {
|
||||
for (const query of ["quantum-flibbertigibbet", " "]) {
|
||||
const result = searchShapes(query);
|
||||
assert.equal(result.matches.length, 0);
|
||||
assert.equal(result.fallback.id, "rectangle");
|
||||
assert.equal(result.fallback.matchType, "fallback");
|
||||
assert.equal(result.fallback.confidence, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
export interface ShapeProvenance {
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: "Apache-2.0";
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface ShapeCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases: readonly string[];
|
||||
style: string;
|
||||
provenance: ShapeProvenance;
|
||||
}
|
||||
|
||||
export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback";
|
||||
export interface ShapeSearchMatch extends ShapeCatalogEntry {
|
||||
confidence: number;
|
||||
matchType: ShapeMatchType;
|
||||
}
|
||||
export interface ShapeSearchResult {
|
||||
query: string;
|
||||
matches: ShapeSearchMatch[];
|
||||
fallback: ShapeSearchMatch;
|
||||
}
|
||||
|
||||
const PROVENANCE: ShapeProvenance = {
|
||||
sourceName: "diagrams.net generic built-in shape vocabulary",
|
||||
sourceUrl: "https://github.com/jgraph/drawio",
|
||||
license: "Apache-2.0",
|
||||
note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.",
|
||||
};
|
||||
|
||||
export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([
|
||||
entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"),
|
||||
entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"),
|
||||
entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"),
|
||||
entry("document", "Document", ["file", "report", "paper"], "shape=document;"),
|
||||
entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"),
|
||||
entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"),
|
||||
entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"),
|
||||
entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"),
|
||||
]);
|
||||
|
||||
function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry {
|
||||
return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) });
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function similarity(left: string, right: string): number {
|
||||
if (left === right) return 1;
|
||||
const a = [...left];
|
||||
const b = [...right];
|
||||
if (a.length === 0 || b.length === 0) return 0;
|
||||
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
||||
for (let row = 1; row <= a.length; row += 1) {
|
||||
const current = [row];
|
||||
for (let column = 1; column <= b.length; column += 1) {
|
||||
current[column] = Math.min(
|
||||
current[column - 1] + 1,
|
||||
previous[column] + 1,
|
||||
previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
return 1 - previous[b.length] / Math.max(a.length, b.length);
|
||||
}
|
||||
|
||||
function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined {
|
||||
const id = normalize(entryValue.id);
|
||||
const name = normalize(entryValue.name);
|
||||
const aliases = entryValue.aliases.map(normalize);
|
||||
if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" };
|
||||
if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" };
|
||||
const terms = [id, name, ...aliases];
|
||||
const score = Math.max(...terms.map((term) => similarity(query, term)));
|
||||
if (score < 0.45) return undefined;
|
||||
return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" };
|
||||
}
|
||||
|
||||
export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult {
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer");
|
||||
const query = normalize(rawQuery);
|
||||
const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!;
|
||||
const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" };
|
||||
const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG
|
||||
.map((candidate) => match(candidate, query))
|
||||
.filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined)
|
||||
.sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US"))
|
||||
.slice(0, limit);
|
||||
return { query: rawQuery, matches, fallback };
|
||||
}
|
||||
|
||||
export const searchShapeCatalog = searchShapes;
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
import { BUILT_IN_THEMES, applyTheme, contrastRatio, validateTheme } from "./theme-service.js";
|
||||
|
||||
const IR: DiagramIRV2 = {
|
||||
version: 2,
|
||||
pages: [{
|
||||
id: "p", title: "Page",
|
||||
nodes: [
|
||||
{ id: "service", label: "Service", kind: "service", style: "shape=hexagon;fillColor=#123456;rounded=1;" },
|
||||
{ id: "transparent", label: "Transparent", kind: "container", style: "swimlane;fillColor=none;horizontal=0;" },
|
||||
],
|
||||
edges: [{ id: "edge", source: "service", target: "transparent", style: "edgeStyle=orthogonalEdgeStyle;dashed=1;" }],
|
||||
}],
|
||||
};
|
||||
|
||||
test("built-in themes validate and meet role contrast requirements", () => {
|
||||
assert.deepEqual(Object.keys(BUILT_IN_THEMES), ["default", "corporate", "dark", "colorblind-safe", "handdrawn"]);
|
||||
for (const theme of Object.values(BUILT_IN_THEMES)) {
|
||||
assert.doesNotThrow(() => validateTheme(theme));
|
||||
assert.ok(contrastRatio(theme.node.fontColor, theme.node.fillColor) >= 4.5, theme.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("built-in theme registry and nested role styles reject mutation", () => {
|
||||
const dark = BUILT_IN_THEMES.dark;
|
||||
assert.ok(Object.isFrozen(dark));
|
||||
assert.ok(Object.isFrozen(dark.node));
|
||||
assert.ok(Object.isFrozen(dark.container));
|
||||
assert.ok(Object.isFrozen(dark.edge));
|
||||
assert.throws(() => { (dark.node as { fillColor: string }).fillColor = "#FFFFFF"; }, TypeError);
|
||||
assert.equal(BUILT_IN_THEMES.dark.node.fillColor, "#243447");
|
||||
});
|
||||
|
||||
test("theme application is deterministic, immutable, role based, and preserves structural style", () => {
|
||||
const snapshot = structuredClone(IR);
|
||||
const first = applyTheme(IR, "corporate");
|
||||
assert.deepEqual(first, applyTheme(IR, "corporate"));
|
||||
assert.deepEqual(IR, snapshot);
|
||||
assert.match(first.pages[0].nodes[0].style!, /shape=hexagon/);
|
||||
assert.match(first.pages[0].nodes[0].style!, /rounded=1/);
|
||||
assert.match(first.pages[0].nodes[1].style!, /fillColor=none/);
|
||||
assert.match(first.pages[0].nodes[1].style!, /swimlane/);
|
||||
assert.match(first.pages[0].edges[0].style!, /edgeStyle=orthogonalEdgeStyle/);
|
||||
assert.doesNotMatch(first.pages[0].nodes[0].style!, /rounded=0/);
|
||||
});
|
||||
|
||||
test("theme result is deeply isolated from source objects", () => {
|
||||
const source = structuredClone(IR);
|
||||
source.assets = { icons: [{ name: "api" }] };
|
||||
source.pages[0].nodes[0].properties = { config: { retries: 3 } };
|
||||
source.pages[0].edges[0].extensions = { route: { owner: "network" } };
|
||||
const snapshot = structuredClone(source);
|
||||
|
||||
const themed = applyTheme(source, "corporate");
|
||||
((themed.assets!.icons as Array<{ name: string }>)[0]).name = "changed";
|
||||
((themed.pages[0].nodes[0].properties!.config as { retries: number })).retries = 9;
|
||||
((themed.pages[0].edges[0].extensions!.route as { owner: string })).owner = "changed";
|
||||
|
||||
assert.deepEqual(source, snapshot);
|
||||
});
|
||||
|
||||
test("theme application emits each visual style key exactly once", () => {
|
||||
const source = structuredClone(IR);
|
||||
source.pages[0].nodes[0].style = "shape=hexagon;fillColor=#000000;fillColor=#FFFFFF;strokeColor=#000000;strokeColor=#FFFFFF;fontColor=#000000;fontColor=#FFFFFF;sketch=0;sketch=0;";
|
||||
|
||||
const style = applyTheme(source, "handdrawn").pages[0].nodes[0].style!;
|
||||
|
||||
for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
|
||||
assert.equal(style.split(";").filter((token) => token.startsWith(`${key}=`)).length, 1, style);
|
||||
}
|
||||
assert.match(style, /fillColor=#FFFDF5;/);
|
||||
assert.match(style, /strokeColor=#444444;/);
|
||||
assert.match(style, /fontColor=#222222;/);
|
||||
assert.match(style, /sketch=1;/);
|
||||
});
|
||||
|
||||
test("theme application preserves page extensions and serializes its canvas background", () => {
|
||||
const source = structuredClone(IR);
|
||||
source.pages[0].extensions = { owner: "platform", $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25" } } };
|
||||
|
||||
const themed = applyTheme(source, "dark");
|
||||
const xml = diagramIRToDrawio(themed);
|
||||
|
||||
assert.deepEqual(themed.pages[0].extensions, {
|
||||
owner: "platform",
|
||||
$drawio: { attributes: { custom: "kept", "model:pageScale": "1.25", "model:background": "#111827" } },
|
||||
});
|
||||
assert.match(xml, /<diagram[^>]* data-extensions="\{"owner":"platform"\}"[^>]* custom="kept"/);
|
||||
assert.match(xml, /<mxGraphModel[^>]* background="#111827"/);
|
||||
assert.match(xml, /<mxGraphModel[^>]* pageScale="1.25"/);
|
||||
assert.doesNotMatch(xml, /data-extensions="[^"]*\$drawio/);
|
||||
});
|
||||
|
||||
test("transparent nodes use a readable dark font on a light canvas", () => {
|
||||
const custom = structuredClone(BUILT_IN_THEMES.dark);
|
||||
custom.name = "custom-light-canvas";
|
||||
custom.backgroundColor = "#FFFFFF";
|
||||
custom.edge.fontColor = "#000000";
|
||||
|
||||
const style = applyTheme(IR, custom).pages[0].nodes[1].style!;
|
||||
|
||||
assert.match(style, /fillColor=none;/);
|
||||
assert.match(style, /fontColor=#000000;/);
|
||||
assert.ok(contrastRatio("#000000", custom.backgroundColor) >= 4.5);
|
||||
});
|
||||
|
||||
test("transparent nodes use a readable light font on a dark canvas", () => {
|
||||
const custom = structuredClone(BUILT_IN_THEMES.default);
|
||||
custom.name = "custom-dark-canvas";
|
||||
custom.backgroundColor = "#000000";
|
||||
custom.edge.fontColor = "#FFFFFF";
|
||||
|
||||
const source = structuredClone(IR);
|
||||
source.pages[0].nodes[0].style = "shape=hexagon;fillColor=none;rounded=1;";
|
||||
const style = applyTheme(source, custom).pages[0].nodes[0].style!;
|
||||
|
||||
assert.match(style, /fillColor=none;/);
|
||||
assert.match(style, /fontColor=#FFFFFF;/);
|
||||
assert.ok(contrastRatio("#FFFFFF", custom.backgroundColor) >= 4.5);
|
||||
});
|
||||
|
||||
test("theme validation rejects invalid colors and insufficient contrast", () => {
|
||||
const invalidColor = structuredClone(BUILT_IN_THEMES.default);
|
||||
invalidColor.node.fillColor = "red";
|
||||
assert.throws(() => validateTheme(invalidColor), /color/i);
|
||||
const lowContrast = structuredClone(BUILT_IN_THEMES.default);
|
||||
lowContrast.node.fontColor = "#777777";
|
||||
lowContrast.node.fillColor = "#777777";
|
||||
assert.throws(() => validateTheme(lowContrast), /contrast/i);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
|
||||
|
||||
export type BuiltInThemeName = "default" | "corporate" | "dark" | "colorblind-safe" | "handdrawn";
|
||||
|
||||
export interface ThemeRoleStyle {
|
||||
fillColor: string;
|
||||
strokeColor: string;
|
||||
fontColor: string;
|
||||
}
|
||||
|
||||
export interface ThemeDefinition {
|
||||
name: BuiltInThemeName | (string & {});
|
||||
backgroundColor: string;
|
||||
node: ThemeRoleStyle;
|
||||
container: ThemeRoleStyle;
|
||||
edge: Pick<ThemeRoleStyle, "strokeColor" | "fontColor">;
|
||||
sketch?: boolean;
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const BUILT_IN_THEMES: Readonly<Record<BuiltInThemeName, ThemeDefinition>> = deepFreeze({
|
||||
default: theme("default", "#FFFFFF", ["#FFFFFF", "#4A5568", "#1A202C"], ["#F7FAFC", "#718096", "#1A202C"], ["#4A5568", "#1A202C"]),
|
||||
corporate: theme("corporate", "#FFFFFF", ["#E8F0FE", "#1F5AA6", "#102A43"], ["#F5F8FC", "#526D82", "#102A43"], ["#1F5AA6", "#102A43"]),
|
||||
dark: theme("dark", "#111827", ["#243447", "#94A3B8", "#FFFFFF"], ["#1F2937", "#64748B", "#FFFFFF"], ["#CBD5E1", "#FFFFFF"]),
|
||||
"colorblind-safe": theme("colorblind-safe", "#FFFFFF", ["#FFF4CC", "#0072B2", "#1A1A1A"], ["#E8F3F8", "#009E73", "#1A1A1A"], ["#0072B2", "#1A1A1A"]),
|
||||
handdrawn: { ...theme("handdrawn", "#FFFDF5", ["#FFFDF5", "#444444", "#222222"], ["#F6F1E5", "#555555", "#222222"], ["#444444", "#222222"]), sketch: true },
|
||||
});
|
||||
|
||||
function theme(name: BuiltInThemeName, backgroundColor: string, node: [string, string, string], container: [string, string, string], edge: [string, string]): ThemeDefinition {
|
||||
return {
|
||||
name,
|
||||
backgroundColor,
|
||||
node: { fillColor: node[0], strokeColor: node[1], fontColor: node[2] },
|
||||
container: { fillColor: container[0], strokeColor: container[1], fontColor: container[2] },
|
||||
edge: { strokeColor: edge[0], fontColor: edge[1] },
|
||||
};
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
export function contrastRatio(left: string, right: string): number {
|
||||
const luminance = (color: string): number => {
|
||||
if (!HEX_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
|
||||
const values = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
|
||||
.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
|
||||
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
|
||||
};
|
||||
const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
|
||||
return (high + 0.05) / (low + 0.05);
|
||||
}
|
||||
|
||||
export function validateTheme(themeDefinition: ThemeDefinition): ThemeDefinition {
|
||||
if (!themeDefinition || typeof themeDefinition.name !== "string" || themeDefinition.name.trim() === "") throw new Error("Theme requires a name");
|
||||
const colors: Array<[string, string]> = [
|
||||
["backgroundColor", themeDefinition.backgroundColor],
|
||||
["node.fillColor", themeDefinition.node?.fillColor], ["node.strokeColor", themeDefinition.node?.strokeColor], ["node.fontColor", themeDefinition.node?.fontColor],
|
||||
["container.fillColor", themeDefinition.container?.fillColor], ["container.strokeColor", themeDefinition.container?.strokeColor], ["container.fontColor", themeDefinition.container?.fontColor],
|
||||
["edge.strokeColor", themeDefinition.edge?.strokeColor], ["edge.fontColor", themeDefinition.edge?.fontColor],
|
||||
];
|
||||
for (const [path, color] of colors) if (typeof color !== "string" || !HEX_COLOR.test(color)) throw new Error(`Theme ${path} must be a #RRGGBB color`);
|
||||
for (const role of ["node", "container"] as const) {
|
||||
if (contrastRatio(themeDefinition[role].fontColor, themeDefinition[role].fillColor) < 4.5) throw new Error(`Theme ${role} text has insufficient contrast`);
|
||||
}
|
||||
if (contrastRatio(themeDefinition.edge.fontColor, themeDefinition.backgroundColor) < 4.5) throw new Error("Theme edge text has insufficient contrast");
|
||||
return themeDefinition;
|
||||
}
|
||||
|
||||
interface StyleEntry { key: string; value?: string }
|
||||
|
||||
function parseStyle(style = ""): StyleEntry[] {
|
||||
return style.split(";").filter(Boolean).map((token) => {
|
||||
const separator = token.indexOf("=");
|
||||
return separator < 0 ? { key: token } : { key: token.slice(0, separator), value: token.slice(separator + 1) };
|
||||
});
|
||||
}
|
||||
|
||||
function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>, sketch: boolean, backgroundColor?: string): string {
|
||||
const entries = parseStyle(style);
|
||||
const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none");
|
||||
const visual = new Map<string, string>();
|
||||
if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor);
|
||||
if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor);
|
||||
if (colors.fontColor) {
|
||||
let fontColor = colors.fontColor;
|
||||
if (originalFillNone && backgroundColor && contrastRatio(fontColor, backgroundColor) < 4.5) {
|
||||
fontColor = contrastRatio("#000000", backgroundColor) >= 4.5 ? "#000000" : "#FFFFFF";
|
||||
}
|
||||
visual.set("fontColor", fontColor);
|
||||
}
|
||||
visual.set("sketch", sketch ? "1" : "0");
|
||||
const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "sketch"]);
|
||||
const handled = new Set<string>();
|
||||
const output = entries.flatMap((entry) => {
|
||||
if (!visualKeys.has(entry.key)) return [entry];
|
||||
if (handled.has(entry.key)) return [];
|
||||
handled.add(entry.key);
|
||||
const replacement = visual.get(entry.key);
|
||||
if (replacement === undefined) return [entry];
|
||||
visual.delete(entry.key);
|
||||
return [{ key: entry.key, value: replacement }];
|
||||
});
|
||||
for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
|
||||
const value = visual.get(key);
|
||||
if (value !== undefined) output.push({ key, value });
|
||||
}
|
||||
return output.map(({ key, value }) => value === undefined ? key : `${key}=${value}`).join(";") + ";";
|
||||
}
|
||||
|
||||
export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDefinition): DiagramIRV2 {
|
||||
const selectedTheme = typeof selected === "string" ? BUILT_IN_THEMES[selected] : selected;
|
||||
if (!selectedTheme) throw new Error(`Unknown built-in theme: ${selected}`);
|
||||
validateTheme(selectedTheme);
|
||||
const isolated = structuredClone(ir);
|
||||
const applyNode = (node: DiagramNode): DiagramNode => ({
|
||||
...node,
|
||||
style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor),
|
||||
});
|
||||
const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true) });
|
||||
return {
|
||||
...isolated,
|
||||
theme: selectedTheme.name,
|
||||
pages: isolated.pages.map((page) => {
|
||||
const extensions = page.extensions ?? {};
|
||||
const drawio = extensions.$drawio && typeof extensions.$drawio === "object" && !Array.isArray(extensions.$drawio)
|
||||
? extensions.$drawio as Record<string, unknown>
|
||||
: {};
|
||||
const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes)
|
||||
? drawio.attributes as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
...page,
|
||||
extensions: {
|
||||
...extensions,
|
||||
$drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } },
|
||||
},
|
||||
nodes: page.nodes.map(applyNode),
|
||||
edges: page.edges.map(applyEdge),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
import { applyMetricsHeatmap } from "./heatmap.js";
|
||||
import { contrastRatio } from "../themes/theme-service.js";
|
||||
|
||||
const IR: DiagramIRV2 = {
|
||||
version: 2,
|
||||
pages: [{ id: "p", title: "Page", nodes: [
|
||||
{ id: "low", label: "Low", style: "shape=hexagon;strokeColor=#000000;", geometry: { x: 0, y: 0, width: 100, height: 60 } },
|
||||
{ id: "high", label: "High", style: "shape=ellipse;", geometry: { x: 200, y: 0, width: 100, height: 60 } },
|
||||
], edges: [{ id: "flow", source: "low", target: "high", waypoints: [{ x: 150, y: 30 }] }] }],
|
||||
};
|
||||
|
||||
test("metrics heatmap is deterministic with legend metadata and preserves topology", () => {
|
||||
const request = { metric: "latency_ms", min: 0, max: 100, values: { "p/low": 0, "p/high": 100 } };
|
||||
const snapshot = structuredClone(IR);
|
||||
const first = applyMetricsHeatmap(IR, request);
|
||||
assert.deepEqual(first, applyMetricsHeatmap(IR, request));
|
||||
assert.deepEqual(IR, snapshot);
|
||||
assert.deepEqual(first.legend, {
|
||||
metric: "latency_ms", min: 0, max: 100,
|
||||
stops: [
|
||||
{ value: 0, color: "#440154", fontColor: "#FFFFFF" },
|
||||
{ value: 25, color: "#3B528B", fontColor: "#FFFFFF" },
|
||||
{ value: 50, color: "#21918C", fontColor: "#1A1A1A" },
|
||||
{ value: 75, color: "#5EC962", fontColor: "#1A1A1A" },
|
||||
{ value: 100, color: "#FDE725", fontColor: "#1A1A1A" },
|
||||
],
|
||||
});
|
||||
assert.match(first.diagram.pages[0].nodes[0].style!, /shape=hexagon/);
|
||||
assert.match(first.diagram.pages[0].nodes[0].style!, /fillColor=#440154/);
|
||||
assert.match(first.diagram.pages[0].nodes[1].style!, /fillColor=#FDE725/);
|
||||
assert.deepEqual(first.diagram.pages[0].edges, IR.pages[0].edges);
|
||||
assert.deepEqual(first.diagram.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })), IR.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })));
|
||||
});
|
||||
|
||||
test("metrics heatmap sets readable text for every palette stop", () => {
|
||||
const result = applyMetricsHeatmap(IR, {
|
||||
metric: "latency_ms", min: 0, max: 100,
|
||||
values: { "p/low": 0, "p/high": 100 },
|
||||
});
|
||||
|
||||
for (const stop of result.legend.stops) {
|
||||
assert.ok(contrastRatio(stop.fontColor, stop.color) >= 4.5, `${stop.fontColor} on ${stop.color}`);
|
||||
}
|
||||
assert.match(result.diagram.pages[0].nodes[0].style!, /fontColor=#FFFFFF/);
|
||||
assert.match(result.diagram.pages[0].nodes[1].style!, /fontColor=#1A1A1A/);
|
||||
});
|
||||
|
||||
test("metrics heatmap switches colors at the legend stop boundaries", () => {
|
||||
const colorsAt = (low: number, high: number): string[] => applyMetricsHeatmap(IR, {
|
||||
metric: "latency_ms", min: 0, max: 100, values: { "p/low": low, "p/high": high },
|
||||
}).diagram.pages[0].nodes.map((node) => /fillColor=(#[0-9A-F]{6})/i.exec(node.style!)?.[1] ?? "");
|
||||
|
||||
assert.deepEqual(colorsAt(24.999, 25), ["#440154", "#3B528B"]);
|
||||
assert.deepEqual(colorsAt(49.999, 50), ["#3B528B", "#21918C"]);
|
||||
assert.deepEqual(colorsAt(74.999, 75), ["#21918C", "#5EC962"]);
|
||||
assert.deepEqual(colorsAt(99.999, 100), ["#5EC962", "#FDE725"]);
|
||||
});
|
||||
|
||||
test("metrics heatmap rejects palettes without a readable text color", () => {
|
||||
assert.throws(() => applyMetricsHeatmap(IR, {
|
||||
metric: "x", min: 0, max: 1, values: {}, colors: ["#777777", "#777777"],
|
||||
}), /no readable font color/i);
|
||||
});
|
||||
|
||||
test("metrics heatmap rejects invalid bounds, colors, keys, and out-of-range values", () => {
|
||||
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 1, max: 1, values: {} }), /min.*max/i);
|
||||
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { "p/low": 2 } }), /bounded/i);
|
||||
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { missing: 0 } }), /unknown/i);
|
||||
assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: {}, colors: ["red"] }), /color/i);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface HeatmapRequest {
|
||||
metric: string;
|
||||
min: number;
|
||||
max: number;
|
||||
values: Readonly<Record<string, number>>;
|
||||
colors?: readonly string[];
|
||||
}
|
||||
export interface HeatmapLegendStop { value: number; color: string; fontColor: string }
|
||||
export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] }
|
||||
export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend }
|
||||
|
||||
export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]);
|
||||
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string {
|
||||
const entries = (style ?? "").split(";").filter(Boolean);
|
||||
const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]);
|
||||
const targetKeys = new Set(replacements.keys());
|
||||
const output = entries.map((entry) => {
|
||||
const key = entry.split("=", 1)[0];
|
||||
if (!targetKeys.has(key)) return entry;
|
||||
const replacement = replacements.get(key);
|
||||
if (replacement === undefined) return undefined;
|
||||
replacements.delete(key);
|
||||
return `${key}=${replacement}`;
|
||||
}).filter((entry): entry is string => entry !== undefined);
|
||||
for (const [key, value] of replacements) output.push(`${key}=${value}`);
|
||||
return `${output.join(";")};`;
|
||||
}
|
||||
|
||||
function contrastRatio(left: string, right: string): number {
|
||||
const luminance = (color: string): number => [1, 3, 5]
|
||||
.map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
|
||||
.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
|
||||
.reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0);
|
||||
const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
|
||||
return (high + 0.05) / (low + 0.05);
|
||||
}
|
||||
|
||||
const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const;
|
||||
|
||||
function readableFontColor(background: string): string {
|
||||
const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5);
|
||||
if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`);
|
||||
return color;
|
||||
}
|
||||
|
||||
function exactNumber(value: number): number {
|
||||
return Number(value.toFixed(12));
|
||||
}
|
||||
|
||||
export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult {
|
||||
if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string");
|
||||
if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max");
|
||||
if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object");
|
||||
const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)];
|
||||
if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors");
|
||||
const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`)));
|
||||
for (const [key, value] of Object.entries(request.values)) {
|
||||
if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`);
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) {
|
||||
throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`);
|
||||
}
|
||||
}
|
||||
const stops = colors.map((color, index) => ({
|
||||
value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)),
|
||||
color,
|
||||
fontColor: readableFontColor(color),
|
||||
}));
|
||||
const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops };
|
||||
const diagram = structuredClone(ir);
|
||||
for (const page of diagram.pages) for (const node of page.nodes) {
|
||||
const value = request.values[`${page.id}/${node.id}`];
|
||||
if (value === undefined) continue;
|
||||
const nextStopIndex = stops.findIndex((stop) => value < stop.value);
|
||||
const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1);
|
||||
node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor);
|
||||
}
|
||||
diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend };
|
||||
return { diagram, legend };
|
||||
}
|
||||
|
||||
export const transformMetricsHeatmap = applyMetricsHeatmap;
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
import { relabelDiagram } from "./relabel.js";
|
||||
|
||||
const IR: DiagramIRV2 = {
|
||||
version: 2,
|
||||
title: "Original",
|
||||
pages: [{ id: "p", title: "Page", layout: { type: "manual" }, nodes: [
|
||||
{ id: "a", label: "A", style: "shape=hexagon;", geometry: { x: 10, y: 20, width: 100, height: 60 } },
|
||||
{ id: "b", label: "B", style: "shape=ellipse;", geometry: { x: 200, y: 20, width: 100, height: 60 } },
|
||||
], edges: [{ id: "flow", source: "a", target: "b", label: "old", style: "dashed=1;", waypoints: [{ x: 150, y: 50 }] }] }],
|
||||
};
|
||||
|
||||
test("complete relabel changes only labels while preserving IDs, topology, layout, and style", () => {
|
||||
const snapshot = structuredClone(IR);
|
||||
const result = relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new" } });
|
||||
assert.deepEqual(result.pages[0].nodes.map(({ id, label }) => ({ id, label })), [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }]);
|
||||
assert.equal(result.pages[0].edges[0].label, "new");
|
||||
const restored = structuredClone(result);
|
||||
restored.pages[0].nodes[0].label = "A";
|
||||
restored.pages[0].nodes[1].label = "B";
|
||||
restored.pages[0].edges[0].label = "old";
|
||||
assert.deepEqual(restored, IR);
|
||||
assert.deepEqual(IR, snapshot);
|
||||
});
|
||||
|
||||
test("relabel requires a complete exact explicit map", () => {
|
||||
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha" } }), /missing.*p\/b.*p\/flow/i);
|
||||
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new", extra: "no" } }), /unknown.*extra/i);
|
||||
assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "", "p/b": "Beta", "p/flow": "new" } }), /non-empty/i);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface CompleteRelabelRequest {
|
||||
mode: "complete";
|
||||
labels: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export function relabelDiagram(ir: DiagramIRV2, request: CompleteRelabelRequest): DiagramIRV2 {
|
||||
if (!request || request.mode !== "complete" || !request.labels || typeof request.labels !== "object" || Array.isArray(request.labels)) {
|
||||
throw new Error("Relabel requires an explicit complete map mode");
|
||||
}
|
||||
const required: string[] = [];
|
||||
for (const page of ir.pages) {
|
||||
for (const node of page.nodes) required.push(`${page.id}/${node.id}`);
|
||||
for (const edge of page.edges) if (edge.label !== undefined) required.push(`${page.id}/${edge.id}`);
|
||||
}
|
||||
required.sort((left, right) => left.localeCompare(right, "en-US"));
|
||||
const provided = Object.keys(request.labels).sort((left, right) => left.localeCompare(right, "en-US"));
|
||||
const missing = required.filter((key) => !Object.hasOwn(request.labels, key));
|
||||
if (missing.length > 0) throw new Error(`Relabel map is missing: ${missing.join(", ")}`);
|
||||
const unknown = provided.filter((key) => !required.includes(key));
|
||||
if (unknown.length > 0) throw new Error(`Relabel map has unknown keys: ${unknown.join(", ")}`);
|
||||
for (const key of required) {
|
||||
const label = request.labels[key];
|
||||
if (typeof label !== "string" || label.trim() === "") throw new Error(`Relabel value for ${key} must be a non-empty string`);
|
||||
}
|
||||
const output = structuredClone(ir);
|
||||
for (const page of output.pages) {
|
||||
for (const node of page.nodes) node.label = request.labels[`${page.id}/${node.id}`];
|
||||
for (const edge of page.edges) if (edge.label !== undefined) edge.label = request.labels[`${page.id}/${edge.id}`];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export const relabelDiagramIR = relabelDiagram;
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
import { diagramIRToMermaid, diagramIRToStructuredMarkdown } from "./reverse.js";
|
||||
|
||||
const IR: DiagramIRV2 = {
|
||||
version: 2,
|
||||
title: "Platform",
|
||||
pages: [{
|
||||
id: "system", title: "System",
|
||||
nodes: [
|
||||
{ id: "a", label: "Client" },
|
||||
{ id: "b", label: "API \"danger\"]\n%%{init: {}}%% <script>alert(1)</script> [click](javascript:alert(1))" },
|
||||
],
|
||||
edges: [{ id: "call", source: "a", target: "b", label: "calls | inject" }],
|
||||
}],
|
||||
};
|
||||
|
||||
test("reverse Mermaid flowchart is deterministic and escapes untrusted labels", () => {
|
||||
const output = diagramIRToMermaid(IR);
|
||||
assert.equal(output, diagramIRToMermaid(IR));
|
||||
assert.match(output, /^flowchart LR/m);
|
||||
assert.match(output, /n0\["Client"\]/);
|
||||
assert.match(output, /n0 -->\|"calls | inject"\| n1/);
|
||||
assert.doesNotMatch(output, /<script>|%%\{init|danger"\]/);
|
||||
assert.match(output, /%%/);
|
||||
});
|
||||
|
||||
test("reverse Markdown explains pages, nodes, and flows without executable HTML or broken tables", () => {
|
||||
const output = diagramIRToStructuredMarkdown(IR);
|
||||
assert.equal(output, diagramIRToStructuredMarkdown(IR));
|
||||
assert.match(output, /## System/);
|
||||
assert.match(output, /### Nodes/);
|
||||
assert.match(output, /### Flows/);
|
||||
assert.match(output, /calls \\| inject/);
|
||||
assert.doesNotMatch(output, /<script>/);
|
||||
assert.doesNotMatch(output, /\]\(javascript:/);
|
||||
assert.match(output, /<script>/);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
|
||||
function mermaidEscape(value: string): string {
|
||||
const entities: Record<string, string> = {
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
|
||||
"[": "[", "]": "]", "{": "{", "}": "}", "(": "(", ")": ")",
|
||||
"|": "|", "`": "`", "%": "%", "\n": " ", "\r": "",
|
||||
};
|
||||
return [...value].map((character) => entities[character] ?? character).join("");
|
||||
}
|
||||
|
||||
function markdownEscape(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
.replaceAll("\\", "\\\\").replace(/([`*_{}\[\]()#+.!-])/g, "\\$1").replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
export function diagramIRToMermaid(ir: DiagramIRV2): string {
|
||||
const direction = ir.pages[0]?.layout?.direction === "vertical" ? "TD" : "LR";
|
||||
const lines = [`flowchart ${direction}`];
|
||||
let nextNode = 0;
|
||||
const nodeIds = new Map<string, string>();
|
||||
for (const [pageIndex, page] of ir.pages.entries()) {
|
||||
if (ir.pages.length > 1) lines.push(` subgraph page${pageIndex}["${mermaidEscape(page.title)}"]`);
|
||||
const indent = ir.pages.length > 1 ? " " : " ";
|
||||
for (const node of page.nodes) {
|
||||
const safeId = `n${nextNode}`;
|
||||
nextNode += 1;
|
||||
nodeIds.set(`${pageIndex}:${node.id}`, safeId);
|
||||
lines.push(`${indent}${safeId}["${mermaidEscape(node.label)}"]`);
|
||||
}
|
||||
if (ir.pages.length > 1) lines.push(" end");
|
||||
for (const edge of page.edges) {
|
||||
const source = nodeIds.get(`${pageIndex}:${edge.source}`);
|
||||
const target = nodeIds.get(`${pageIndex}:${edge.target}`);
|
||||
if (!source || !target) throw new Error(`Edge ${edge.id} references an unknown endpoint`);
|
||||
const label = edge.label === undefined ? "" : `|"${mermaidEscape(edge.label)}"|`;
|
||||
lines.push(` ${source} -->${label} ${target}`);
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function diagramIRToStructuredMarkdown(ir: DiagramIRV2): string {
|
||||
const lines = [`# ${markdownEscape(ir.title ?? "Diagram")}`, ""];
|
||||
for (const page of ir.pages) {
|
||||
lines.push(`## ${markdownEscape(page.title)}`, "", "### Nodes", "", "| ID | Label | Kind | Parent |", "| --- | --- | --- | --- |");
|
||||
for (const node of page.nodes) {
|
||||
lines.push(`| ${markdownEscape(node.id)} | ${markdownEscape(node.label)} | ${markdownEscape(node.kind ?? "node")} | ${markdownEscape(node.parentId ?? "—")} |`);
|
||||
}
|
||||
lines.push("", "### Flows", "", "| ID | From | To | Label |", "| --- | --- | --- | --- |");
|
||||
if (page.edges.length === 0) lines.push("| — | — | — | No flows |" );
|
||||
for (const edge of page.edges) {
|
||||
lines.push(`| ${markdownEscape(edge.id)} | ${markdownEscape(edge.source)} | ${markdownEscape(edge.target)} | ${markdownEscape(edge.label ?? "—")} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export const toMermaidFlowchart = diagramIRToMermaid;
|
||||
export const toMarkdownExplanation = diagramIRToStructuredMarkdown;
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
|
||||
import { semanticDiff } from "./semantic-diff.js";
|
||||
|
||||
const before: DiagramIRV2 = {
|
||||
version: 2,
|
||||
pages: [{ id: "p", title: "Page", nodes: [
|
||||
{ id: "removed", label: "Old" },
|
||||
{ id: "moved", label: "Move", geometry: { x: 0, y: 0, width: 100, height: 60 } },
|
||||
{ id: "changed", label: "Before", style: "shape=rectangle;" },
|
||||
{ id: "target", label: "Target" },
|
||||
], edges: [
|
||||
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 20, y: 10 }] },
|
||||
{ id: "gone", source: "removed", target: "target" },
|
||||
] }],
|
||||
};
|
||||
|
||||
const after: DiagramIRV2 = {
|
||||
version: 2,
|
||||
pages: [{ id: "p", title: "Page", nodes: [
|
||||
{ id: "moved", label: "Move", geometry: { x: 40, y: 0, width: 100, height: 60 } },
|
||||
{ id: "changed", label: "After", style: "shape=ellipse;" },
|
||||
{ id: "target", label: "Target" },
|
||||
{ id: "added", label: "New" },
|
||||
], edges: [
|
||||
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 30, y: 10 }] },
|
||||
{ id: "new-edge", source: "added", target: "target" },
|
||||
] }],
|
||||
};
|
||||
|
||||
test("semantic diff classifies added, removed, changed, moved, and rerouted elements", () => {
|
||||
const diff = semanticDiff(before, after);
|
||||
assert.deepEqual(diff.added.map((item) => item.id), ["p/added", "p/new-edge"]);
|
||||
assert.deepEqual(diff.removed.map((item) => item.id), ["p/gone", "p/removed"]);
|
||||
assert.deepEqual(diff.changed.map((item) => item.id), ["p/changed"]);
|
||||
assert.deepEqual(diff.moved.map((item) => item.id), ["p/moved"]);
|
||||
assert.deepEqual(diff.rerouted.map((item) => item.id), ["p/route"]);
|
||||
});
|
||||
|
||||
test("semantic diff values are deeply isolated from both inputs", () => {
|
||||
const oldDiagram = structuredClone(before);
|
||||
const newDiagram = structuredClone(after);
|
||||
newDiagram.pages[0].nodes.find(({ id }) => id === "added")!.extensions = { details: { owner: "platform" } };
|
||||
const oldSnapshot = structuredClone(oldDiagram);
|
||||
const newSnapshot = structuredClone(newDiagram);
|
||||
|
||||
const diff = semanticDiff(oldDiagram, newDiagram);
|
||||
(diff.moved[0].before as DiagramNode).geometry!.x = 999;
|
||||
((diff.added.find(({ id }) => id === "p/added")!.after!.extensions!.details as { owner: string })).owner = "changed";
|
||||
diff.removed.find(({ id }) => id === "p/removed")!.before!.label = "changed";
|
||||
|
||||
assert.deepEqual(oldDiagram, oldSnapshot);
|
||||
assert.deepEqual(newDiagram, newSnapshot);
|
||||
});
|
||||
|
||||
test("semantic diff is deterministic and does not mutate either input", () => {
|
||||
const beforeSnapshot = structuredClone(before);
|
||||
const afterSnapshot = structuredClone(after);
|
||||
assert.deepEqual(semanticDiff(before, after), semanticDiff(before, after));
|
||||
assert.deepEqual(before, beforeSnapshot);
|
||||
assert.deepEqual(after, afterSnapshot);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
|
||||
|
||||
export type SemanticEntity = "node" | "edge";
|
||||
export interface SemanticDiffItem {
|
||||
id: string;
|
||||
entity: SemanticEntity;
|
||||
before?: DiagramNode | DiagramEdge;
|
||||
after?: DiagramNode | DiagramEdge;
|
||||
}
|
||||
export interface SemanticDiffResult {
|
||||
added: SemanticDiffItem[];
|
||||
removed: SemanticDiffItem[];
|
||||
changed: SemanticDiffItem[];
|
||||
moved: SemanticDiffItem[];
|
||||
rerouted: SemanticDiffItem[];
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
const object = value as Record<string, unknown>;
|
||||
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function equal(left: unknown, right: unknown): boolean {
|
||||
return canonical(left) === canonical(right);
|
||||
}
|
||||
|
||||
function without<T extends object>(value: T, omitted: readonly string[]): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(value).filter(([key]) => !omitted.includes(key)));
|
||||
}
|
||||
|
||||
function position(node: DiagramNode): unknown {
|
||||
return { parentId: node.parentId, x: node.geometry?.x, y: node.geometry?.y };
|
||||
}
|
||||
|
||||
function nodeMeaning(node: DiagramNode): unknown {
|
||||
const semantic = without(node, ["geometry", "parentId"]);
|
||||
if (node.geometry) semantic.geometrySize = { width: node.geometry.width, height: node.geometry.height };
|
||||
return semantic;
|
||||
}
|
||||
|
||||
function edgeRoute(edge: DiagramEdge): unknown {
|
||||
return { source: edge.source, target: edge.target, waypoints: edge.waypoints ?? [] };
|
||||
}
|
||||
|
||||
function edgeMeaning(edge: DiagramEdge): unknown {
|
||||
return without(edge, ["source", "target", "waypoints"]);
|
||||
}
|
||||
|
||||
function index(ir: DiagramIRV2): Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }> {
|
||||
const result = new Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }>();
|
||||
for (const page of ir.pages) {
|
||||
for (const node of page.nodes) result.set(`${page.id}/${node.id}`, { entity: "node", value: node });
|
||||
for (const edge of page.edges) result.set(`${page.id}/${edge.id}`, { entity: "edge", value: edge });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ordered(items: SemanticDiffItem[]): SemanticDiffItem[] {
|
||||
return items.sort((left, right) => left.id.localeCompare(right.id, "en-US") || left.entity.localeCompare(right.entity, "en-US"));
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
export function semanticDiff(before: DiagramIRV2, after: DiagramIRV2): SemanticDiffResult {
|
||||
const oldItems = index(before);
|
||||
const newItems = index(after);
|
||||
const result: SemanticDiffResult = { added: [], removed: [], changed: [], moved: [], rerouted: [] };
|
||||
for (const [id, current] of newItems) {
|
||||
const previous = oldItems.get(id);
|
||||
if (!previous || previous.entity !== current.entity) {
|
||||
result.added.push({ id, entity: current.entity, after: clone(current.value) });
|
||||
continue;
|
||||
}
|
||||
if (current.entity === "node") {
|
||||
const oldNode = previous.value as DiagramNode;
|
||||
const newNode = current.value as DiagramNode;
|
||||
if (!equal(position(oldNode), position(newNode))) result.moved.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
|
||||
if (!equal(nodeMeaning(oldNode), nodeMeaning(newNode))) result.changed.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
|
||||
} else {
|
||||
const oldEdge = previous.value as DiagramEdge;
|
||||
const newEdge = current.value as DiagramEdge;
|
||||
if (!equal(edgeRoute(oldEdge), edgeRoute(newEdge))) result.rerouted.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
|
||||
if (!equal(edgeMeaning(oldEdge), edgeMeaning(newEdge))) result.changed.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
|
||||
}
|
||||
}
|
||||
for (const [id, previous] of oldItems) {
|
||||
const current = newItems.get(id);
|
||||
if (!current || current.entity !== previous.entity) result.removed.push({ id, entity: previous.entity, before: clone(previous.value) });
|
||||
}
|
||||
for (const values of Object.values(result)) ordered(values);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const diffDiagramIR = semanticDiff;
|
||||
Reference in New Issue
Block a user