mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user