feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge

The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:

- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
  matching perimeter — required or edges connect to the bounding box; a
  text-scale factor verified in the real editor — the same sentence overflows
  a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
  figures). Any other token passes through verbatim: draw.io degrades unknown
  shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
  rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
  distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
  rectangular node forever. set_shape/set_role/set_group operations make the
  fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
  displace each other) instead of string concatenation. This is what makes
  shape and theme composable by rule — shape owns geometry keys, theme owns
  colour/type keys, and an overlap resolves by order instead of emitting
  contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
  appearance-based reverse mapping cannot distinguish aliases (diamond vs
  decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
  boxes no longer freeze the first layout's numbers, so changing a label
  re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
  schema-drift problem the review predicted).

14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
This commit is contained in:
dayuan.jiang
2026-08-09 21:56:09 +09:00
parent db9db1ff4e
commit 50826c0ac8
11 changed files with 644 additions and 75 deletions

View File

@@ -23,6 +23,7 @@ import {
} from "./layout"
import {
isInvisible,
stampAuto,
stampCell,
stampContainer,
stampFlex,
@@ -34,11 +35,12 @@ import {
stampRadial,
stampRole,
stampSequence,
stampShape,
} from "./markers"
import { type RoutedEdge, routeEdges } from "./route"
import { mergeStyle, resolveShape } from "./shapes"
import { hueOf, NEUTRAL, themedStyle } from "./theme"
import {
type BoxShape,
type DiagramNode,
type DiagramTree,
isContainer,
@@ -88,27 +90,6 @@ const TITLE_STYLE =
const EDGE_STYLE =
"edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;"
/**
* Flowchart outlines, as mxGraph draws them.
*
* All six are core mxGraph shapes, not stencils from a shape library, so they render
* without the catalog and without any extra dependency. The notation is conventional: a
* reader takes a diamond to mean a branch and a stadium to mean a start or end point, so
* drawing every step as the same rectangle loses information the shape was carrying.
*/
const BOX_SHAPES: Record<BoxShape, string> = {
box: "rounded=0;",
round: "rounded=1;arcSize=12;",
/** Decision — a diamond. */
decision: "rhombus;",
/** Start or end — a stadium. draw.io draws `rounded=1` at arcSize 50 as a full stadium. */
terminator: "rounded=1;arcSize=50;",
/** Input or output — a parallelogram. */
data: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
/** A document or report — a rectangle with a wavy bottom edge. */
document: "shape=document;boundedLbl=1;",
}
// ---- swimlane pool chrome ----
/** Hairline between lane bands: present, but quieter than the shapes sitting on it. */
@@ -172,21 +153,34 @@ function styleFor(
}
if (n.kind === "box") {
let base = n.style ?? FALLBACK_BOX
if (!n.style) {
// 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;"
let base: string
if (n.style) {
base = n.style
} else {
// Structured merge, ownership by fragment order: the fallback's neutral
// look, the shape's geometry keys, the theme's colour/type keys, explicit
// colours last. Each key ends up in the style exactly once — a theme that
// says rounded=1 cannot leave a contradictory duplicate on a rhombus.
const shape = n.shape ? resolveShape(n.shape) : null
base = mergeStyle(
FALLBACK_BOX,
shape?.spec.style,
n.role || n.group
? themedStyle(n.role ?? "body", hue(n.group), "leaf")
: undefined,
n.fill ? `fillColor=${n.fill};` : undefined,
n.stroke ? `strokeColor=${n.stroke};` : undefined,
n.bold ? "fontStyle=1;" : undefined,
)
}
let stamped = stampLeaf(base, "box")
if (n.shape && n.shape !== "box") stamped = stampShape(stamped, n.shape)
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
if (n.group) stamped = stampGroup(stamped, n.group)
stamped = stampFlex(stamped, { grow: n.grow, align: n.align })
// Engine-measured (no explicit w/h): mark it, so the parser re-measures next
// time instead of freezing this layout's numbers as a fixed size.
if (n.w == null && n.h == null) stamped = stampAuto(stamped)
return n.cell ? stampCell(stamped, n.cell) : stamped
}