mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine box rendered identically (white, 11px), so anything whose meaning lives in visual hierarchy came out flat. This makes presentation a first-class, generalised part of the declaration - not a poster feature. Structure/presentation separation, the same split HTML and CSS settled on: - ROLE says what a node IS: banner, heading, body, callout, good, bad, metric, muted. Maps to a type scale and an emphasis (filled / tinted / outlined / ghost), never to a colour. - GROUP says which semantic zone a node belongs to. Each distinct group name gets one hue ramp (tint / base / dark), assigned in document order. Promoted from a draw_graph-only field to BoxNode and GroupNode, round-tripped via dai_group. - themedStyle(role, hue, kind) composes the two by rule - there is no per-combination table to extend, so a new diagram kind gets full theming by tagging nodes. The model never sees a hex value. A heading container plus a group yields the tinted section panel with a dark title; a grouped body box takes its zone's tint; verdict roles stay green/red regardless of zone; the banner is the page's one dark field. Also fixed, found while building the acceptance poster: - autoBoxSize only counted explicit newlines, so a long single-line label wrapped to six lines in draw.io but got a one-line-tall box, and the text overflowed the cell. - Marker stamping appended without replacing, so every render of a recovered style grew it by one duplicate dai_* token per key - unnoticed because draw.io resolves duplicates last-wins. dai_* keys are now replaced in place; mxGraph keys still append, because last-wins is load-bearing for container=1 normalisation. - Banner/heading/metric roles stretch across their container's cross axis, the way a masthead spans its page. - Prompt: a poster's banner IS its title (no set_title alongside), and sections get their colour by naming groups. 537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e tests in a real browser. Verified visually: the Transformer-paper poster renders with a navy masthead, three hue-coded section panels, metric, verdict and callout boxes - all engine-computed geometry.
This commit is contained in:
@@ -702,7 +702,9 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
|
||||
restructure_diagram: {
|
||||
description: `Build or edit a diagram by declaring STRUCTURE. The engine computes every coordinate.
|
||||
|
||||
PREFER THIS over display_diagram/edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
|
||||
PREFER THIS over display_diagram/edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts — AND poster-style layouts: paper summaries, cheat sheets, infographics, comparison sheets. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
|
||||
|
||||
For a POSTER (paper summary, cheat sheet): one col container as the page; a banner box as the masthead (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container holding 2-4 col containers as columns; each section is a container with role "heading" holding its items. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print.
|
||||
|
||||
Never write coordinates, mxCell XML, or style strings. Look AWS icon names up with search_stencils first — an invented name is rejected with suggestions.
|
||||
|
||||
@@ -817,6 +819,21 @@ Grouping: when the nodes fall into natural zones (remote vs local, frontend vs b
|
||||
.describe(
|
||||
"Semantic group name, e.g. 'remote' or 'local'. Nodes sharing a group get the same colour from the engine's palette — never pick colours yourself",
|
||||
),
|
||||
role: z
|
||||
.enum([
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
])
|
||||
.optional()
|
||||
.describe(
|
||||
"What this node IS: heading, callout (must-not-miss), good/bad (verdict), metric (key number), muted (fine print). The theme styles it",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe("Every box in the diagram"),
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
*/
|
||||
|
||||
import type { Operation } from "./operations"
|
||||
import { groupColour } from "./render"
|
||||
import type { BoxShape } from "./types"
|
||||
import type { BoxShape, Role } from "./types"
|
||||
|
||||
/** A node in the graph the caller wants drawn. */
|
||||
export interface GraphNode {
|
||||
@@ -40,6 +39,8 @@ export interface GraphNode {
|
||||
* caller names the grouping and never touches a colour.
|
||||
*/
|
||||
group?: string
|
||||
/** Information role (heading, callout, metric…); the theme decides how it looks. */
|
||||
role?: Role
|
||||
}
|
||||
|
||||
/** An arrow. Direction matters: it is what determines the layering. */
|
||||
@@ -309,19 +310,8 @@ export function graphToOperations(
|
||||
},
|
||||
]
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
// Groups become colours here, in order of first appearance, so "the second group named
|
||||
// is green" holds for every diagram the engine draws. The caller only names groups.
|
||||
const groupIndex = new Map<string, number>()
|
||||
for (const n of nodes)
|
||||
if (n.group && !groupIndex.has(n.group))
|
||||
groupIndex.set(n.group, groupIndex.size)
|
||||
|
||||
const add = (id: string, parent: string): Operation => {
|
||||
const n = byId.get(id) as GraphNode
|
||||
const colour =
|
||||
n.group !== undefined
|
||||
? groupColour(groupIndex.get(n.group) ?? 0)
|
||||
: null
|
||||
return n.icon
|
||||
? {
|
||||
op: "add_icon",
|
||||
@@ -336,9 +326,8 @@ export function graphToOperations(
|
||||
parent,
|
||||
label: n.label,
|
||||
...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}),
|
||||
...(colour
|
||||
? { fill: colour.fill, stroke: colour.stroke }
|
||||
: {}),
|
||||
...(n.role && n.role !== "body" ? { role: n.role } : {}),
|
||||
...(n.group ? { group: n.group } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
* `pool()` primitive; sequence and radial are original to this repository.
|
||||
*/
|
||||
|
||||
import { type Role, roleMetrics } from "./theme"
|
||||
import type {
|
||||
ContainerNode,
|
||||
DiagramNode,
|
||||
@@ -171,14 +172,37 @@ export interface Placed {
|
||||
*/
|
||||
export type LayoutLinks = { source: string; target: string; step?: number }[]
|
||||
|
||||
/** Intrinsic size of a text box: widest wrapped line by line count. */
|
||||
export function autoBoxSize(label: string): { w: number; h: number } {
|
||||
const lines = String(label ?? "").split("\n")
|
||||
const longest = Math.max(1, ...lines.map((l) => l.length))
|
||||
return {
|
||||
w: Math.min(260, Math.max(120, Math.round(longest * CHAR_W + 28))),
|
||||
h: Math.max(44, lines.length * 18 + 26),
|
||||
}
|
||||
/**
|
||||
* Intrinsic size of a text box: widest wrapped line by line count.
|
||||
*
|
||||
* The role scales the estimate: a banner sets 20px type and a footnote 9px, and layout has
|
||||
* to reserve what render will draw or the text overflows its cell.
|
||||
*/
|
||||
export function autoBoxSize(
|
||||
label: string,
|
||||
role?: Role,
|
||||
): { w: number; h: number } {
|
||||
const r = roleMetrics(role)
|
||||
const maxW = Math.round(260 * Math.max(1, r.charScale))
|
||||
const explicit = String(label ?? "").split("\n")
|
||||
const longest = Math.max(1, ...explicit.map((l) => l.length))
|
||||
const w = Math.min(
|
||||
maxW,
|
||||
Math.max(120, Math.round(longest * CHAR_W * r.charScale + 28)),
|
||||
)
|
||||
// Count the lines the text ACTUALLY occupies: draw.io wraps at the box width, so a
|
||||
// long line becomes several. Estimating by explicit newlines alone left the box one
|
||||
// line tall while the text wrapped to six — and overflowed straight out of it.
|
||||
const charsPerLine = Math.max(
|
||||
8,
|
||||
Math.floor((w - 28) / (CHAR_W * r.charScale)),
|
||||
)
|
||||
const lines = explicit.reduce(
|
||||
(sum, l) => sum + Math.max(1, Math.ceil(l.length / charsPerLine)),
|
||||
0,
|
||||
)
|
||||
const lineH = Math.round(r.fontSize * 1.6)
|
||||
return { w, h: Math.max(r.minH, lines * lineH + 26) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,7 +401,7 @@ function measure(
|
||||
return { node: n, rect: { x: 0, y: 0, ...s }, children: [] }
|
||||
}
|
||||
if (n.kind === "box") {
|
||||
const auto = autoBoxSize(n.label)
|
||||
const auto = autoBoxSize(n.label, n.role)
|
||||
return {
|
||||
node: n,
|
||||
rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h },
|
||||
@@ -636,10 +660,18 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
let cur = (alongRow ? innerX : innerTop) + Math.max(0, (extent - span) / 2)
|
||||
|
||||
for (const kid of kids) {
|
||||
// A stretching role fills the cross axis: a masthead spans its page, a section
|
||||
// heading spans its column. Measured at its text width, then widened here — the
|
||||
// container's size still comes from the widest ordinary child.
|
||||
const kn = kid.node
|
||||
const stretches =
|
||||
kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch
|
||||
if (alongRow) {
|
||||
if (stretches) kid.rect.h = innerH
|
||||
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, links)
|
||||
cur += kid.rect.w + gap
|
||||
} else {
|
||||
if (stretches) kid.rect.w = innerW
|
||||
place(kid, innerX + (innerW - kid.rect.w) / 2, cur, links)
|
||||
cur += kid.rect.h + gap
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ export const MARKER = {
|
||||
step: "dai_step",
|
||||
/** How a radial container fans its branches out: "radial" or "down". */
|
||||
spread: "dai_spread",
|
||||
/** The node's information role (banner, heading, callout…), for the round trip. */
|
||||
role: "dai_role",
|
||||
/** The node's semantic zone, whose hue ramp colours it. */
|
||||
group: "dai_group",
|
||||
/**
|
||||
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
|
||||
* columns, its milestone strip. The parser must not read these back as nodes — they are
|
||||
@@ -203,9 +207,24 @@ export function isPinned(style: string): boolean {
|
||||
return s !== "" && s !== "0" && s !== "false"
|
||||
}
|
||||
|
||||
/** Append `key=value;`, ensuring the style ends with a separator first. */
|
||||
/**
|
||||
* Append `key=value;`, replacing any existing occurrence of the key first.
|
||||
*
|
||||
* Styles are re-stamped on every render, and a style recovered from the canvas already
|
||||
* carries last render's markers — blindly appending grew the string by one duplicate per
|
||||
* round-trip, unboundedly. Duplicates resolve last-wins in draw.io so nothing ever LOOKED
|
||||
* wrong, which is why it went unnoticed until a byte-identity test caught it.
|
||||
*
|
||||
* Only `dai_*` keys are cleaned. mxGraph keys are appended verbatim because last-wins is
|
||||
* load-bearing there: the container tokens rely on appending `container=1` after a catalog
|
||||
* style that may say `container=0`.
|
||||
*/
|
||||
function append(style: string, key: string, value: string | number): string {
|
||||
const base = style.endsWith(";") || style === "" ? style : `${style};`
|
||||
const cleaned = key.startsWith("dai_")
|
||||
? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
||||
: style
|
||||
const base =
|
||||
cleaned.endsWith(";") || cleaned === "" ? cleaned : `${cleaned};`
|
||||
return `${base}${key}=${value};`
|
||||
}
|
||||
|
||||
@@ -353,6 +372,16 @@ export function stampLeaf(
|
||||
return opts.name ? append(s, MARKER.name, opts.name) : s
|
||||
}
|
||||
|
||||
/** Stamp the node's information role, replacing any previous one. */
|
||||
export function stampRole(style: string, role: string): string {
|
||||
return append(style, MARKER.role, role)
|
||||
}
|
||||
|
||||
/** Stamp the node's semantic zone, replacing any previous one. */
|
||||
export function stampGroup(style: string, group: string): string {
|
||||
return append(style, MARKER.group, encodeURIComponent(group))
|
||||
}
|
||||
|
||||
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
||||
export function stripMarkers(style: string): string {
|
||||
return style
|
||||
|
||||
@@ -54,6 +54,27 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
id: z.string(),
|
||||
parent: z.string().optional(),
|
||||
label: z.string(),
|
||||
role: z
|
||||
.enum([
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
])
|
||||
.optional()
|
||||
.describe(
|
||||
"What this IS: banner=masthead, heading=section title, callout=must-not-miss, good/bad=verdict, metric=key number, muted=fine print. The theme decides how each looks",
|
||||
),
|
||||
group: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Semantic zone name; nodes and panels sharing a group get the same hue from the engine's palette. Never pick colours",
|
||||
),
|
||||
fill: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -98,6 +119,27 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
label: z
|
||||
.string()
|
||||
.describe("Frame title; empty string means invisible wrapper"),
|
||||
role: z
|
||||
.enum([
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
])
|
||||
.optional()
|
||||
.describe(
|
||||
"Section role: heading=titled tinted panel, banner=masthead strip, good/bad=verdict panel",
|
||||
),
|
||||
group: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Semantic zone name; the panel and everything sharing this group take one hue",
|
||||
),
|
||||
dir: z.enum(["row", "col"]).describe("How children stack"),
|
||||
gname: z
|
||||
.string()
|
||||
@@ -370,6 +412,8 @@ export function applyOperations(
|
||||
: {}),
|
||||
...(op.fill ? { fill: op.fill } : {}),
|
||||
...(op.stroke ? { stroke: op.stroke } : {}),
|
||||
...(op.role ? { role: op.role } : {}),
|
||||
...(op.group ? { group: op.group } : {}),
|
||||
...cellOf(op),
|
||||
}
|
||||
else if (op.op === "add_container")
|
||||
@@ -381,6 +425,8 @@ export function applyOperations(
|
||||
dir: op.dir,
|
||||
gap: op.gap ?? 20,
|
||||
children: [],
|
||||
...(op.role ? { role: op.role } : {}),
|
||||
...(op.group ? { group: op.group } : {}),
|
||||
}
|
||||
else if (op.op === "add_grid")
|
||||
node = {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
readList,
|
||||
readMarker,
|
||||
} from "./markers"
|
||||
import { isRole, type Role } from "./theme"
|
||||
import type {
|
||||
BoxNode,
|
||||
BoxShape,
|
||||
@@ -347,6 +348,18 @@ function looksLikeText(style: string): boolean {
|
||||
}
|
||||
|
||||
/** The flowchart outline a box is drawn with, read back from its style. */
|
||||
/** The information role stamped on a cell, if any. */
|
||||
function roleOf(style: string): Role | undefined {
|
||||
const v = readMarker(style, MARKER.role)
|
||||
return isRole(v) ? v : undefined
|
||||
}
|
||||
|
||||
/** The semantic zone stamped on a cell, if any. */
|
||||
function zoneOf(style: string): string | undefined {
|
||||
const v = readMarker(style, MARKER.group)
|
||||
return v ? decodeURIComponent(v) : undefined
|
||||
}
|
||||
|
||||
function boxShape(style: string): BoxShape | undefined {
|
||||
const shape = styleValue(style, "shape")
|
||||
if (shape === "parallelogram") return "data"
|
||||
@@ -1055,6 +1068,8 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
|
||||
fill: styleValue(c.style, "fillColor"),
|
||||
stroke: styleValue(c.style, "strokeColor"),
|
||||
shape: boxShape(c.style),
|
||||
role: roleOf(c.style),
|
||||
group: zoneOf(c.style),
|
||||
// A lifeline's style is chrome the renderer rebuilds, so keeping it verbatim
|
||||
// would re-emit a lifeline that no longer matches the new message count.
|
||||
style: lifeline ? undefined : c.style,
|
||||
@@ -1204,6 +1219,8 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
|
||||
kind: "group",
|
||||
...common,
|
||||
dir: dir === "col" ? "col" : "row",
|
||||
role: roleOf(c.style),
|
||||
group: zoneOf(c.style),
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -25,14 +25,17 @@ import {
|
||||
isInvisible,
|
||||
stampCell,
|
||||
stampContainer,
|
||||
stampGroup,
|
||||
stampLane,
|
||||
stampLeaf,
|
||||
stampPool,
|
||||
stampPoolDecoration,
|
||||
stampRadial,
|
||||
stampRole,
|
||||
stampSequence,
|
||||
} from "./markers"
|
||||
import { type RoutedEdge, routeEdges } from "./route"
|
||||
import { hueOf, NEUTRAL, type Role, themedStyle } from "./theme"
|
||||
import {
|
||||
type BoxShape,
|
||||
type DiagramNode,
|
||||
@@ -64,31 +67,10 @@ export type StyleResolver = (
|
||||
const FALLBACK_BOX =
|
||||
"rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;verticalAlign=middle;"
|
||||
|
||||
/**
|
||||
* The palette for semantic groups: paired fill/stroke, assigned to group names in order of
|
||||
* first appearance.
|
||||
*
|
||||
* The engine owns these hex values and the model never sees them — it only names groups
|
||||
* ("remote", "local", "temp"), which is the judgement it is actually good at. Letting the
|
||||
* model pick colours per diagram produced mismatched saturations and a different look every
|
||||
* time; a fixed palette is what makes two diagrams from the same engine look related.
|
||||
*
|
||||
* All six fills are low-saturation tints that keep #1A1A1A text readable, each with a
|
||||
* darker stroke of the same hue, deliberately quieter than the near-black edge lines so
|
||||
* colour reads as grouping rather than emphasis.
|
||||
*/
|
||||
const GROUP_PALETTE: { fill: string; stroke: string }[] = [
|
||||
{ fill: "#DAE8FC", stroke: "#6C8EBF" }, // blue
|
||||
{ fill: "#D5E8D4", stroke: "#82B366" }, // green
|
||||
{ fill: "#FFE6CC", stroke: "#D79B00" }, // orange
|
||||
{ fill: "#E1D5E7", stroke: "#9673A6" }, // purple
|
||||
{ fill: "#F8CECC", stroke: "#B85450" }, // red
|
||||
{ fill: "#FFF2CC", stroke: "#D6B656" }, // yellow
|
||||
]
|
||||
|
||||
/** fill/stroke for the n-th distinct group. Wraps: a 7th group reuses the 1st colour. */
|
||||
/** fill/stroke for the n-th distinct group — the theme's hue ramp, tint and base steps. */
|
||||
export function groupColour(index: number): { fill: string; stroke: string } {
|
||||
return GROUP_PALETTE[index % GROUP_PALETTE.length]
|
||||
const h = hueOf(index)
|
||||
return { fill: h.tint, stroke: h.base }
|
||||
}
|
||||
const FALLBACK_FRAME =
|
||||
"rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#999999;fontColor=#1A1A1A;fontSize=12;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;"
|
||||
@@ -160,7 +142,16 @@ export interface RenderOptions {
|
||||
* what is already on the canvas, including any colour the user changed by hand. We only
|
||||
* re-stamp the markers on top, so layout parameters stay current.
|
||||
*/
|
||||
function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
|
||||
/** The hue ramp for a node's group, or the neutral ramp. Assigned in document order. */
|
||||
export type HueResolver = (
|
||||
group: string | undefined,
|
||||
) => ReturnType<typeof hueOf>
|
||||
|
||||
function styleFor(
|
||||
n: DiagramNode,
|
||||
resolve: StyleResolver | undefined,
|
||||
hue: HueResolver = () => NEUTRAL,
|
||||
): string {
|
||||
if (n.kind === "title") return TITLE_STYLE
|
||||
|
||||
if (n.kind === "icon") {
|
||||
@@ -174,14 +165,18 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
|
||||
if (n.kind === "box") {
|
||||
let base = n.style ?? FALLBACK_BOX
|
||||
if (!n.style) {
|
||||
// The outline comes first: BOX_SHAPES carries `rounded=`, which the fallback
|
||||
// also sets, and appending lets the shape's value win.
|
||||
// Order matters, later keys win in draw.io: outline, then the theme's
|
||||
// composition of role x hue, then explicit colours on top of everything.
|
||||
if (n.shape) base += BOX_SHAPES[n.shape]
|
||||
if (n.role || n.group)
|
||||
base += themedStyle(n.role ?? "body", hue(n.group), "leaf")
|
||||
if (n.fill) base += `fillColor=${n.fill};`
|
||||
if (n.stroke) base += `strokeColor=${n.stroke};`
|
||||
if (n.bold) base += "fontStyle=1;"
|
||||
}
|
||||
const stamped = stampLeaf(base, "box")
|
||||
let stamped = stampLeaf(base, "box")
|
||||
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
|
||||
if (n.group) stamped = stampGroup(stamped, n.group)
|
||||
return n.cell ? stampCell(stamped, n.cell) : stamped
|
||||
}
|
||||
|
||||
@@ -210,12 +205,19 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
|
||||
// the structure survives a round-trip, but draw nothing. This replaces the
|
||||
// reference project's "phantom", which emitted no cell and therefore lost the
|
||||
// wrapper's direction and grouping on the way back.
|
||||
const invisible = !n.gname && !n.label && !n.fill && !n.stroke
|
||||
const groupRole = n.kind === "group" ? n.role : undefined
|
||||
const zone = n.kind === "group" ? n.group : undefined
|
||||
const invisible =
|
||||
!n.gname && !n.label && !n.fill && !n.stroke && !groupRole && !zone
|
||||
let base = n.style ?? fromCatalog ?? FALLBACK_FRAME
|
||||
if (!n.style && !fromCatalog) {
|
||||
if (groupRole || zone)
|
||||
base += themedStyle(groupRole ?? "heading", hue(zone), "container")
|
||||
if (n.fill) base += `fillColor=${n.fill};`
|
||||
if (n.stroke) base += `strokeColor=${n.stroke};`
|
||||
}
|
||||
if (groupRole && groupRole !== "body") base = stampRole(base, groupRole)
|
||||
if (zone) base = stampGroup(base, zone)
|
||||
return stampContainer(base, {
|
||||
kind: n.kind,
|
||||
dir: n.kind === "grid" ? "grid" : n.dir,
|
||||
@@ -269,13 +271,14 @@ function vertexXml(
|
||||
parentRect: Rect | null,
|
||||
resolve: StyleResolver | undefined,
|
||||
defaultGlyph: number,
|
||||
hue: HueResolver = () => NEUTRAL,
|
||||
): string {
|
||||
const ox = parentRect?.x ?? 0
|
||||
const oy = parentRect?.y ?? 0
|
||||
const box = cellRect(n, rect, defaultGlyph)
|
||||
return (
|
||||
`<mxCell id="${esc(n.id)}" value="${esc("label" in n ? n.label : "")}"` +
|
||||
` style="${styleFor(n, resolve)}" vertex="1" parent="${esc(parent)}">` +
|
||||
` style="${styleFor(n, resolve, hue)}" vertex="1" parent="${esc(parent)}">` +
|
||||
`<mxGeometry x="${box.x - ox}" y="${box.y - oy}" width="${box.w}" height="${box.h}" as="geometry"/>` +
|
||||
`</mxCell>`
|
||||
)
|
||||
@@ -748,6 +751,21 @@ export function renderDiagram(
|
||||
}
|
||||
}
|
||||
|
||||
// Groups become hues here, in document order, so "the second zone named is green"
|
||||
// holds for every diagram the engine draws. The caller only ever names zones.
|
||||
const groupIndex = new Map<string, number>()
|
||||
for (const f of flat) {
|
||||
const g =
|
||||
f.node.kind === "box" || f.node.kind === "group"
|
||||
? f.node.group
|
||||
: undefined
|
||||
if (g && !groupIndex.has(g)) groupIndex.set(g, groupIndex.size)
|
||||
}
|
||||
const hue: HueResolver = (g) =>
|
||||
g !== undefined && groupIndex.has(g)
|
||||
? hueOf(groupIndex.get(g) as number)
|
||||
: NEUTRAL
|
||||
|
||||
// Parents come before children (flatten guarantees it), which draw.io requires.
|
||||
for (const f of flat) {
|
||||
// A lifeline cell already carries its participant's label and geometry.
|
||||
@@ -764,6 +782,7 @@ export function renderDiagram(
|
||||
parentRect,
|
||||
opts.resolveStyle,
|
||||
glyph,
|
||||
hue,
|
||||
),
|
||||
)
|
||||
const own = chrome.get(f.node.id)
|
||||
|
||||
245
lib/diagram-engine/theme.ts
Normal file
245
lib/diagram-engine/theme.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* The theme: design tokens plus one composition rule, in place of style tables.
|
||||
*
|
||||
* The engine's original deal was: the model declares structure, the engine computes
|
||||
* geometry. But every box rendered identically — white, 11px, black border — so anything
|
||||
* whose meaning lives in visual hierarchy (a paper-summary poster, a cheat sheet, a
|
||||
* comparison panel) came out flat, and the only escape was hand-written XML with no layout
|
||||
* guarantees at all.
|
||||
*
|
||||
* Two ideas fix that generally, not per diagram type:
|
||||
*
|
||||
* ROLE — what a node IS in the information hierarchy: a masthead, a section heading, a
|
||||
* key number, fine print. The model judges this well. Each role maps to a type size and
|
||||
* an emphasis (filled / outlined / ghost), not to any colour.
|
||||
*
|
||||
* GROUP — which semantic zone a node belongs to: remote vs local, one poster section vs
|
||||
* another. Each distinct group name gets one HUE RAMP — a light tint, a mid stroke, a
|
||||
* dark text colour — assigned in order of first appearance.
|
||||
*
|
||||
* `themedStyle(role, hue, kind)` composes the two by rule. A heading container in group 2
|
||||
* gets that hue's tint as its panel and the dark step for its title; a metric in the same
|
||||
* group gets the mid step as a heavy border. Nothing is enumerated per combination, so a
|
||||
* new diagram kind gets full theming by tagging its nodes — there is no table to extend.
|
||||
* The model never sees a hex value; the same declaration always renders the same way.
|
||||
*/
|
||||
|
||||
/** What a node is, in the information hierarchy of the diagram. */
|
||||
export type Role =
|
||||
| "banner" // the masthead: large type on the theme's one dark field
|
||||
| "heading" // a section title / titled panel
|
||||
| "body" // ordinary content (the default look)
|
||||
| "callout" // something the reader must not miss
|
||||
| "good" // a positive verdict (always green, group or not)
|
||||
| "bad" // a negative verdict or warning (always red)
|
||||
| "metric" // the key number
|
||||
| "muted" // fine print
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
]
|
||||
|
||||
export function isRole(v: string | null | undefined): v is Role {
|
||||
return ROLES.includes(v as Role)
|
||||
}
|
||||
|
||||
// ---- tokens ----
|
||||
|
||||
/** One hue, three steps: a field to sit on, a line to draw with, a colour to write in. */
|
||||
export interface HueRamp {
|
||||
tint: string
|
||||
base: string
|
||||
dark: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The hue ramps groups draw from, in assignment order.
|
||||
*
|
||||
* Tint/base pairs are draw.io's classic palette, so themed output looks native to the
|
||||
* editor; the dark step is the same hue pulled down far enough for 4.5:1 text on white.
|
||||
*/
|
||||
export const HUES: readonly HueRamp[] = [
|
||||
{ tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }, // blue
|
||||
{ tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }, // green
|
||||
{ tint: "#FFE6CC", base: "#D79B00", dark: "#8A5A00" }, // orange
|
||||
{ tint: "#E1D5E7", base: "#9673A6", dark: "#4A2E5E" }, // purple
|
||||
{ tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }, // red
|
||||
{ tint: "#FFF2CC", base: "#D6B656", dark: "#7A5C00" }, // yellow
|
||||
]
|
||||
|
||||
/** The neutral ramp, for ungrouped nodes: today's grey-on-white look. */
|
||||
export const NEUTRAL: HueRamp = {
|
||||
tint: "#F5F8FB",
|
||||
base: "#5A6B7B",
|
||||
dark: "#1A1A1A",
|
||||
}
|
||||
|
||||
/** Semantic verdict hues: good is green and bad is red no matter what group says. */
|
||||
const GOOD: HueRamp = { tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }
|
||||
const BAD: HueRamp = { tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }
|
||||
/** The callout field: a warm highlight distinct from every group tint. */
|
||||
const CALLOUT: HueRamp = { tint: "#FFF9C4", base: "#B8860B", dark: "#6D4C00" }
|
||||
|
||||
/** Type scale, px. One scale for every diagram kind. */
|
||||
export const TYPE = { xs: 9, sm: 11, md: 13, lg: 15, xl: 20 } as const
|
||||
|
||||
/** The hue ramp for the n-th distinct group. Wraps: a 7th group reuses the 1st hue. */
|
||||
export function hueOf(index: number): HueRamp {
|
||||
return HUES[index % HUES.length]
|
||||
}
|
||||
|
||||
// ---- the composition rule ----
|
||||
|
||||
/** How a role renders, independent of colour. */
|
||||
interface RoleSpec {
|
||||
size: number
|
||||
bold: boolean
|
||||
/** filled: dark field, light text. tinted: hue field. outlined: white field, hue border.
|
||||
* ghost: no field, no border — pure text. */
|
||||
emphasis: "filled" | "tinted" | "outlined" | "ghost"
|
||||
/** Overrides the group hue; verdicts stay green/red whatever zone they sit in. */
|
||||
hue?: HueRamp
|
||||
/** Fill the container's cross axis, the way a masthead spans its page. */
|
||||
stretch?: boolean
|
||||
/** Minimum cell height. */
|
||||
minH: number
|
||||
/** Character width relative to 11px type, for the measure pass. */
|
||||
charScale: number
|
||||
}
|
||||
|
||||
/** The masthead field when no group says otherwise: the deep navy of the first hue. */
|
||||
const BANNER: HueRamp = { tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }
|
||||
|
||||
const ROLE_SPECS: Record<Role, RoleSpec> = {
|
||||
banner: {
|
||||
size: TYPE.xl,
|
||||
bold: true,
|
||||
emphasis: "filled",
|
||||
hue: BANNER,
|
||||
stretch: true,
|
||||
minH: 64,
|
||||
charScale: 1.8,
|
||||
},
|
||||
heading: {
|
||||
size: TYPE.lg,
|
||||
bold: true,
|
||||
emphasis: "ghost",
|
||||
stretch: true,
|
||||
minH: 32,
|
||||
charScale: 1.35,
|
||||
},
|
||||
body: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "outlined",
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
callout: {
|
||||
size: TYPE.sm,
|
||||
bold: true,
|
||||
emphasis: "tinted",
|
||||
hue: CALLOUT,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
good: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "tinted",
|
||||
hue: GOOD,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
bad: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "tinted",
|
||||
hue: BAD,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
metric: {
|
||||
size: TYPE.xl,
|
||||
bold: true,
|
||||
emphasis: "outlined",
|
||||
minH: 56,
|
||||
charScale: 1.8,
|
||||
},
|
||||
muted: {
|
||||
size: TYPE.xs,
|
||||
bold: false,
|
||||
emphasis: "ghost",
|
||||
minH: 24,
|
||||
charScale: 0.82,
|
||||
},
|
||||
}
|
||||
|
||||
/** Metrics the measure pass needs, so layout reserves what render will draw. */
|
||||
export function roleMetrics(role: Role | undefined): {
|
||||
fontSize: number
|
||||
minH: number
|
||||
charScale: number
|
||||
stretch: boolean
|
||||
} {
|
||||
const s = ROLE_SPECS[role ?? "body"]
|
||||
return {
|
||||
fontSize: s.size,
|
||||
minH: s.minH,
|
||||
charScale: s.charScale,
|
||||
stretch: s.stretch === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The style tokens for one node: the whole theme in a single rule.
|
||||
*
|
||||
* `hue` is the node's group ramp (or the neutral ramp); a role with a semantic hue
|
||||
* (good/bad/callout) overrides it. `kind` softens the treatment for containers — a
|
||||
* section panel is a field its children sit on, so it takes the tint at panel weight
|
||||
* rather than a leaf's full treatment.
|
||||
*/
|
||||
export function themedStyle(
|
||||
role: Role,
|
||||
hue: HueRamp,
|
||||
kind: "leaf" | "container",
|
||||
): string {
|
||||
const spec = ROLE_SPECS[role]
|
||||
const ramp = spec.hue ?? hue
|
||||
const size = kind === "container" && role === "banner" ? TYPE.lg : spec.size
|
||||
const font = `fontSize=${size};${spec.bold || kind === "container" ? "fontStyle=1;" : ""}`
|
||||
|
||||
if (spec.emphasis === "filled")
|
||||
return `fillColor=${ramp.dark};strokeColor=none;fontColor=#FFFFFF;${font}rounded=1;arcSize=6;`
|
||||
|
||||
if (kind === "container") {
|
||||
// A panel: the tint as a quiet field, the dark step for its title, the base for
|
||||
// its border. This is where "every section gets its own colour" comes from —
|
||||
// a heading container plus a group, no extra mechanism.
|
||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${font}verticalAlign=top;align=left;spacingLeft=10;spacingTop=6;`
|
||||
}
|
||||
|
||||
if (spec.emphasis === "ghost")
|
||||
return `fillColor=none;strokeColor=none;fontColor=${ramp.dark};${font}align=left;`
|
||||
|
||||
if (spec.emphasis === "tinted") {
|
||||
// A callout keeps a heavy left bar, the editor's convention for "note well".
|
||||
const bar = role === "callout" ? `strokeWidth=2;` : ""
|
||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${bar}${font}`
|
||||
}
|
||||
|
||||
// outlined: the hue carried by the border and text. A grouped ordinary node takes its
|
||||
// zone's tint as the field — colour-as-grouping is the whole point of naming a zone —
|
||||
// while an ungrouped one stays white. A metric stays white either way, so its number
|
||||
// sits on the page's calmest field with the hue in a heavy border.
|
||||
const weight = role === "metric" ? "strokeWidth=2;" : ""
|
||||
const field = role === "body" && ramp !== NEUTRAL ? ramp.tint : "#FFFFFF"
|
||||
return `fillColor=${field};strokeColor=${ramp.base};fontColor=${ramp.dark};${weight}${font}`
|
||||
}
|
||||
@@ -9,8 +9,10 @@
|
||||
*/
|
||||
|
||||
import type { Direction } from "./markers"
|
||||
import type { Role } from "./theme"
|
||||
|
||||
export type { Direction } from "./markers"
|
||||
export type { Role } from "./theme"
|
||||
|
||||
/**
|
||||
* Which cell of a swimlane pool a node sits in.
|
||||
@@ -67,6 +69,10 @@ export interface BoxNode {
|
||||
fill?: string
|
||||
stroke?: string
|
||||
bold?: boolean
|
||||
/** What this node IS in the information hierarchy; the theme decides how that looks. */
|
||||
role?: Role
|
||||
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
|
||||
group?: string
|
||||
/** Flowchart outline. Absent means a plain rectangle. */
|
||||
shape?: BoxShape
|
||||
style?: string
|
||||
@@ -99,6 +105,10 @@ export interface GroupNode {
|
||||
children: DiagramNode[]
|
||||
fill?: string
|
||||
stroke?: string
|
||||
/** Section role; a themed panel for its children. */
|
||||
role?: Role
|
||||
/** Semantic zone name; the panel takes this hue's tint. */
|
||||
group?: string
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
|
||||
@@ -57,7 +57,7 @@ parameters: {
|
||||
}
|
||||
---Tool5---
|
||||
tool name: restructure_diagram
|
||||
description: Build or edit a diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings.
|
||||
description: Build or edit a diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Boxes and containers accept a role (banner/heading/callout/good/bad/metric/muted) for visual hierarchy — the engine's theme styles each role consistently. Never pass coordinates, XML or style strings.
|
||||
parameters: {
|
||||
operations: Array<Operation> // add_icon | add_box | add_container | add_grid | add_pool | add_sequence | add_radial | remove | move | set_label | set_dir | set_gap | link | unlink | set_title
|
||||
}
|
||||
@@ -103,9 +103,16 @@ Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed f
|
||||
- Mind maps and org charts: add_radial, one add_box per node, then link parent to child.
|
||||
This applies to BOTH creating and editing.
|
||||
|
||||
Use restructure_diagram ALSO for poster-style layouts — paper summaries, cheat sheets,
|
||||
infographics, comparison sheets: a col container as the page, a banner box as the masthead
|
||||
(no set_title — the banner IS the title), a row of col containers as columns, each section
|
||||
a "heading"-role container with its own group name (sections sharing a group share a hue).
|
||||
Give boxes roles (callout, good/bad, metric, muted) — roles are the visual hierarchy,
|
||||
groups are the colour, and the engine guarantees nothing overlaps.
|
||||
|
||||
Use display_diagram only for diagrams that need ABSOLUTE positioning, where the engine's layout
|
||||
would be wrong rather than merely different:
|
||||
UI mockups and wireframes, floor plans, circuit and P&ID diagrams, seating charts, illustrations,
|
||||
UI mockups and wireframes, floor plans, circuit and P&ID diagrams, seating charts,
|
||||
Gantt charts, anything where the exact position of each element is the content.
|
||||
|
||||
- Use edit_diagram for: small changes to a diagram that was made with display_diagram.
|
||||
|
||||
256
tests/unit/diagram-engine-theme.test.ts
Normal file
256
tests/unit/diagram-engine-theme.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { drawGraph, restructureDiagram } from "@/lib/diagram-engine"
|
||||
import {
|
||||
absoluteRects,
|
||||
escapesParent,
|
||||
outsidePage,
|
||||
overlaps,
|
||||
rectOf,
|
||||
} from "./fixtures/geometry"
|
||||
|
||||
/**
|
||||
* Roles: a node says WHAT IT IS and the engine's theme decides how that looks.
|
||||
*
|
||||
* This is what lets one engine cover poster-style content — paper summaries, cheat
|
||||
* sheets — that previously had to be hand-written XML with no layout guarantees. The
|
||||
* model judges "this is a heading, this is a warning"; the theme guarantees the same
|
||||
* role always renders the same way.
|
||||
*/
|
||||
|
||||
const styleOf = (xml: string, id: string): string =>
|
||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))?.[1] ?? ""
|
||||
const last = (style: string, key: string): string | undefined =>
|
||||
[...style.matchAll(new RegExp(`${key}=([^;]*)`, "g"))].pop()?.[1]
|
||||
|
||||
describe("roles", () => {
|
||||
it("styles each role from the theme, and measures type at its real size", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "Title",
|
||||
role: "banner",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "note",
|
||||
parent: "page",
|
||||
label: "fine print",
|
||||
role: "muted",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "num",
|
||||
parent: "page",
|
||||
label: "18% -> 57%",
|
||||
role: "metric",
|
||||
},
|
||||
{ op: "add_box", id: "plain", parent: "page", label: "ordinary" },
|
||||
])
|
||||
expect(r.errors).toEqual([])
|
||||
const xml = r.xml as string
|
||||
expect(last(styleOf(xml, "mast"), "fontSize")).toBe("20")
|
||||
expect(last(styleOf(xml, "mast"), "fillColor")).toBe("#1A237E")
|
||||
expect(last(styleOf(xml, "note"), "fontSize")).toBe("9")
|
||||
expect(last(styleOf(xml, "num"), "fontSize")).toBe("20")
|
||||
// The default look is unchanged: the fallback's own 11px, no theme tokens.
|
||||
expect(last(styleOf(xml, "plain"), "fontSize")).toBe("11")
|
||||
expect(styleOf(xml, "plain")).not.toContain("dai_role")
|
||||
|
||||
// Layout reserved room for the larger type: the banner's cell is taller than a
|
||||
// plain box, or its 20px line would overflow.
|
||||
const rects = absoluteRects(xml)
|
||||
expect(rectOf(rects, "mast").h).toBeGreaterThan(
|
||||
rectOf(rects, "plain").h,
|
||||
)
|
||||
})
|
||||
|
||||
it("a banner spans its column; a heading spans its section", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "T",
|
||||
role: "banner",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "wide",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
gap: 12,
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "a",
|
||||
parent: "wide",
|
||||
label: "left column content here",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "b",
|
||||
parent: "wide",
|
||||
label: "right column content here",
|
||||
},
|
||||
])
|
||||
const rects = absoluteRects(r.xml as string)
|
||||
const mast = rectOf(rects, "mast")
|
||||
const wide = rectOf(rects, "wide")
|
||||
// The masthead fills the page column's width, not just its own text width.
|
||||
expect(mast.w).toBeGreaterThanOrEqual(wide.w - 1)
|
||||
})
|
||||
|
||||
it("roles survive the round trip", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_box", id: "m", label: "Title", role: "banner" },
|
||||
])
|
||||
const again = restructureDiagram(r.xml as string, [])
|
||||
expect(styleOf(again.xml as string, "m")).toContain("dai_role=banner")
|
||||
expect(last(styleOf(again.xml as string, "m"), "fontSize")).toBe("20")
|
||||
// Fixed point: a third pass matches the second.
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
|
||||
it("draw_graph nodes accept roles too", () => {
|
||||
const r = drawGraph(
|
||||
[
|
||||
{ id: "t", label: "Pipeline", role: "heading" },
|
||||
{ id: "a", label: "Build" },
|
||||
{ id: "warn", label: "Flaky stage", role: "bad" },
|
||||
],
|
||||
[
|
||||
{ source: "t", target: "a" },
|
||||
{ source: "a", target: "warn" },
|
||||
],
|
||||
)
|
||||
expect(r.errors).toEqual([])
|
||||
expect(last(styleOf(r.xml as string, "warn"), "fillColor")).toBe(
|
||||
"#F8CECC",
|
||||
)
|
||||
})
|
||||
|
||||
it("a full poster lays out with no sibling overlaps and nothing outside the page", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 16 },
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "Chain-of-Thought Prompting",
|
||||
role: "banner",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "byline",
|
||||
parent: "page",
|
||||
label: "Wei et al. | NeurIPS 2022",
|
||||
role: "muted",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "cols",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
gap: 20,
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "c1",
|
||||
parent: "cols",
|
||||
label: "",
|
||||
dir: "col",
|
||||
gap: 14,
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "c2",
|
||||
parent: "cols",
|
||||
label: "",
|
||||
dir: "col",
|
||||
gap: 14,
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "core",
|
||||
parent: "c1",
|
||||
label: "Core Idea",
|
||||
role: "heading",
|
||||
dir: "col",
|
||||
gap: 8,
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "def",
|
||||
parent: "core",
|
||||
label: "CoT = intermediate reasoning steps",
|
||||
role: "callout",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "vs",
|
||||
parent: "c1",
|
||||
label: "Standard vs CoT",
|
||||
role: "heading",
|
||||
dir: "row",
|
||||
gap: 8,
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "std",
|
||||
parent: "vs",
|
||||
label: "Often wrong",
|
||||
role: "bad",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "cot",
|
||||
parent: "vs",
|
||||
label: "Correct",
|
||||
role: "good",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "res",
|
||||
parent: "c2",
|
||||
label: "Key Results",
|
||||
role: "heading",
|
||||
dir: "col",
|
||||
gap: 8,
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "m1",
|
||||
parent: "res",
|
||||
label: "GSM8K: 18% -> 57%",
|
||||
role: "metric",
|
||||
},
|
||||
])
|
||||
expect(r.errors).toEqual([])
|
||||
const xml = r.xml as string
|
||||
const rects = absoluteRects(xml)
|
||||
// Overlaps are judged among siblings — containment of children is by design.
|
||||
const parentOfId = new Map<string, string>()
|
||||
for (const m of xml.matchAll(
|
||||
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
|
||||
))
|
||||
parentOfId.set(m[1], m[2])
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const [id, p] of parentOfId) {
|
||||
if (id.startsWith("__")) continue
|
||||
const l = byParent.get(p) ?? []
|
||||
l.push(id)
|
||||
byParent.set(p, l)
|
||||
}
|
||||
for (const sibs of byParent.values())
|
||||
expect(overlaps(rects, sibs)).toEqual([])
|
||||
expect(outsidePage(xml)).toEqual([])
|
||||
expect(escapesParent(xml)).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user