// Automatic placement: layered graph layout, label-based sizing, free-spot search, // and turning an agent's { nodes, edges, texts } spec into positioned elements. import { DEFAULT_SIZES, FONT_SIZES, LABEL_WEIGHT, ID_PATTERN, MAX_COORD, MAX_SIDE, isShape, isNum, snap, normalizeElement, normalizeSize, normalizeText, resolveShapeType, uniqueId, } from "./model.mjs"; import { LABEL_PAD, approxMeasure, lineHeight, wrapText, labelMaxWidth, neededHeight, elementBounds, unionBounds, textLayout, } from "./geometry.mjs"; const ceil20 = (v) => Math.ceil(v / 20) * 20; const lower = (v) => (typeof v === "string" ? v.trim().toLowerCase() : ""); export const DIRECTIONS = ["right", "down", "left", "up"]; // The most placeholders a layout may make (see layeredLayout). An arrow gets one on each layer it // passes, and each one takes part in every sweep, so a long chain with many arrows across it could // otherwise need millions and keep the extension busy for most of a minute. export const MAX_PLACEHOLDERS = 100000; // Thrown by layeredLayout when a drawing is too big to lay out. The message says why. export class LayoutError extends Error {} // Width/height that fit a label, in multiples of 20 so centers stay on the grid. export function autoSize(text, type, measure = approxMeasure, size = "m") { const [dw, dh] = DEFAULT_SIZES[type] || DEFAULT_SIZES.rect; const str = String(text || ""); if (!str.trim()) return { w: dw, h: dh }; const fs = FONT_SIZES[size] || FONT_SIZES.m; const widest = Math.max(...str.split("\n").map((l) => measure(l, fs, LABEL_WEIGHT))); let need = widest + 2 * LABEL_PAD + 4; if (type === "ellipse") need = (widest + 12) / 0.72; if (type === "diamond") need = (widest + 12) / 0.56; const w = Math.min(Math.max(ceil20(need), dw), type === "rect" ? 280 : 240); const h = Math.min(MAX_SIDE, Math.max(dh, ceil20(neededHeight({ type, x: 0, y: 0, w, h: dh, size, text: str }, measure)))); return { w, h }; } // 1D placement: keeps order, respects minimum gaps, and stays as close as possible to the // targets (weighted, so real nodes win over edge placeholders). function placeRow(desired, seps, weights) { const n = desired.length; const offset = [0]; for (let i = 1; i < n; i++) offset[i] = offset[i - 1] + seps[i - 1]; const blocks = []; for (let i = 0; i < n; i++) { const w = weights[i]; blocks.push({ sum: (desired[i] - offset[i]) * w, weight: w, start: i }); while (blocks.length > 1) { const b = blocks[blocks.length - 1]; const a = blocks[blocks.length - 2]; if (a.sum / a.weight <= b.sum / b.weight) break; a.sum += b.sum; a.weight += b.weight; blocks.pop(); } } const out = new Array(n); blocks.forEach((b, k) => { const end = k + 1 < blocks.length ? blocks[k + 1].start : n; for (let i = b.start; i < end; i++) out[i] = b.sum / b.weight + offset[i]; }); return out; } // Crossings between two neighboring layers. upper lists the upper layer's vertices in order, // down[u] the lower-layer vertices that u links to, pos[v] a lower vertex's position, and // lowerSize how many vertices the lower layer has. // Two edges cross when their ends are in opposite orders. Taking the edges in order of their upper // end, each one crosses the earlier edges whose lower end is further along. A Fenwick tree counts // those in O(log n), so this is O(E log V) rather than comparing every pair of edges. export function countCrossings(upper, down, pos, lowerSize) { const tree = new Uint32Array(lowerSize + 1); let crossings = 0; let seen = 0; const add = (b) => { let atOrBefore = 0; for (let i = b + 1; i > 0; i -= i & -i) atOrBefore += tree[i]; crossings += seen - atOrBefore; for (let i = b + 1; i <= lowerSize; i += i & -i) tree[i]++; seen++; }; for (const u of upper) { const targets = down[u]; // Edges from the same vertex never cross each other, so they go in increasing order. if (targets.length === 1) add(pos[targets[0]]); else if (targets.length > 1) for (const b of targets.map((v) => pos[v]).sort((x, y) => x - y)) add(b); } return crossings; } // Layered (Sugiyama-style) layout. nodes: [{id, w, h}], edges: [{from, to, label?}]. // Returns Map id -> {x, y} (top-left), with the layout's top-left corner at 0,0. Throws a // LayoutError when it would need more than maxPlaceholders placeholders. export function layeredLayout(nodes, edges, { direction = "right", measure = approxMeasure, maxPlaceholders = MAX_PLACEHOLDERS } = {}) { const horizontal = direction === "right" || direction === "left"; const N = nodes.length; const result = new Map(); if (!N) return result; const index = new Map(nodes.map((n, i) => [n.id, i])); const succ = Array.from({ length: N }, () => new Set()); let labelSpace = 0; for (const e of edges) { const a = index.get(e.from); const b = index.get(e.to); if (a === undefined || b === undefined || a === b) continue; succ[a].add(b); if (e.label) labelSpace = Math.max(labelSpace, horizontal ? measure(e.label, FONT_SIZES.s, 400) + 40 : 30); } const connected = new Array(N).fill(false); succ.forEach((set, a) => set.forEach((b) => { connected[a] = true; connected[b] = true; })); // Break cycles with a DFS, reversing back edges. Sources go first so cycles break naturally. const indeg0 = new Array(N).fill(0); succ.forEach((set) => set.forEach((b) => indeg0[b]++)); const roots = [...Array(N).keys()].sort((a, b) => (indeg0[a] === 0 ? 0 : 1) - (indeg0[b] === 0 ? 0 : 1) || a - b); const mark = new Uint8Array(N); const dag = Array.from({ length: N }, () => new Set()); for (const root of roots) { if (mark[root]) continue; mark[root] = 1; const stack = [[root, [...succ[root]], 0]]; while (stack.length) { const top = stack[stack.length - 1]; if (top[2] < top[1].length) { const v = top[1][top[2]++]; const u = top[0]; if (mark[v] === 1) dag[v].add(u); else { dag[u].add(v); if (mark[v] === 0) { mark[v] = 1; stack.push([v, [...succ[v]], 0]); } } } else { mark[top[0]] = 2; stack.pop(); } } } // Longest-path layering, then pull sources next to their first child. const preds = Array.from({ length: N }, () => []); dag.forEach((set, u) => set.forEach((v) => preds[v].push(u))); const indeg = preds.map((p) => p.length); const layer = new Array(N).fill(0); const queue = []; for (let i = 0; i < N; i++) if (connected[i] && !indeg[i]) queue.push(i); const topo = []; while (queue.length) { const u = queue.shift(); topo.push(u); for (const v of dag[u]) { layer[v] = Math.max(layer[v], layer[u] + 1); if (--indeg[v] === 0) queue.push(v); } } for (let i = topo.length - 1; i >= 0; i--) { const u = topo[i]; if (!preds[u].length && dag[u].size) layer[u] = Math.min(...[...dag[u]].map((v) => layer[v])) - 1; } // Placeholders are counted before any are made (see MAX_PLACEHOLDERS), so turning down a drawing // that needs too many costs only this loop. let placeholders = 0; for (let u = 0; u < N; u++) for (const v of dag[u]) placeholders += layer[v] - layer[u] - 1; if (placeholders > maxPlaceholders) { const count = (n) => n.toLocaleString("en-US"); throw new LayoutError( `The layout is too big to work out: an arrow needs a placeholder on each layer of shapes it passes, and this diagram needs ${count(placeholders)}, more than the ${count(maxPlaceholders)} automatic layout handles. Arrows that jump across many layers, like ones back to the start of a long chain, need the most. Split the diagram into smaller ones, or leave out some of those arrows.`, ); } // Layers with dummy vertices for edges that span several layers. const layers = []; const up = []; const down = []; const isDummy = []; const addVertex = (li, dummy) => { const v = up.length; up.push([]); down.push([]); isDummy.push(dummy); (layers[li] ||= []).push(v); return v; }; const vertexOf = new Array(N); for (let i = 0; i < N; i++) if (connected[i]) vertexOf[i] = addVertex(layer[i], false); const realOf = new Map(); vertexOf.forEach((v, i) => { if (v !== undefined) realOf.set(v, i); }); for (let u = 0; u < N; u++) { for (const v of dag[u]) { let prev = vertexOf[u]; for (let li = layer[u] + 1; li < layer[v]; li++) { const d = addVertex(li, true); down[prev].push(d); up[d].push(prev); prev = d; } down[prev].push(vertexOf[v]); up[vertexOf[v]].push(prev); } } for (let li = 0; li < layers.length; li++) layers[li] ||= []; // Crossing reduction: barycenter sweeps, keeping the best ordering seen. Per-vertex numbers live // in typed arrays, since long edges can add up to maxPlaceholders placeholder vertices. const pos = new Int32Array(up.length); const setPos = () => layers.forEach((L) => L.forEach((v, i) => { pos[v] = i; })); setPos(); const crossings = () => { let c = 0; for (let li = 0; li < layers.length - 1; li++) c += countCrossings(layers[li], down, pos, layers[li + 1].length); return c; }; let best = layers.map((L) => [...L]); let bestC = crossings(); const bc = new Float64Array(up.length); for (let iter = 0; iter < 12 && bestC > 0; iter++) { const downward = iter % 2 === 0; const order = [...layers.keys()]; if (!downward) order.reverse(); for (const li of order.slice(1)) { for (const v of layers[li]) { const nb = downward ? up[v] : down[v]; let sum = 0; for (const n of nb) sum += pos[n]; bc[v] = nb.length ? sum / nb.length : pos[v]; } layers[li].sort((a, b) => bc[a] - bc[b] || pos[a] - pos[b]); layers[li].forEach((v, i) => { pos[v] = i; }); } const c = crossings(); if (c < bestC) { bestC = c; best = layers.map((L) => [...L]); } } best.forEach((L, li) => { layers[li] = L; }); setPos(); // Coordinates. "main" runs along the flow, "cross" across it. const size = (v, main) => { if (isDummy[v]) return 0; const n = nodes[realOf.get(v)]; return horizontal === main ? n.w : n.h; }; const rankGap = Math.max(horizontal ? 100 : 80, labelSpace); const nodeGap = horizontal ? 50 : 60; const mainCenter = []; let cursor = 0; for (const L of layers) { const m = Math.max(0, ...L.map((v) => size(v, true))); mainCenter.push(cursor + m / 2); cursor += m + rankGap; } const gapBetween = (a, b) => (isDummy[a] && isDummy[b] ? 10 : isDummy[a] || isDummy[b] ? nodeGap / 2 : nodeGap); const seps = (L) => L.slice(1).map((v, i) => (size(L[i], false) + size(v, false)) / 2 + gapBetween(L[i], v)); const cross = new Float64Array(up.length); for (const L of layers) { const s = seps(L); const total = s.reduce((a, b) => a + b, 0); let c = -total / 2; L.forEach((v, i) => { if (i) c += s[i - 1]; cross[v] = c; }); } // Edge placeholders follow real nodes, not the other way around. const desiredOf = (v, nb) => { let sum = 0; let weight = 0; for (const n of nb) { const k = isDummy[n] ? 0.1 : 1; sum += cross[n] * k; weight += k; } return weight ? sum / weight : cross[v]; }; for (let iter = 0; iter < 10; iter++) { const mode = iter === 9 ? "both" : iter % 2 === 0 ? "down" : "up"; const order = [...layers.keys()]; if (mode === "up") order.reverse(); for (const li of order) { const L = layers[li]; if (!L.length) continue; const desired = L.map((v) => desiredOf(v, mode === "down" ? up[v] : mode === "up" ? down[v] : [...up[v], ...down[v]])); placeRow(desired, seps(L), L.map((v) => (isDummy[v] ? 0.05 : 1))).forEach((c, i) => { cross[L[i]] = c; }); } } // Straighten one-to-one links at the ends of a flow: a node with a single link, whose neighbor // has no other link on that side, moves into line with it if there is room. Nothing else moves. for (const L of layers) { const s = seps(L); L.forEach((v, i) => { if (isDummy[v] || up[v].length + down[v].length !== 1) return; const n = up[v].length ? up[v][0] : down[v][0]; if ((up[v].length ? down[n] : up[n]).length !== 1) return; let c = cross[n]; if (i > 0) c = Math.max(c, cross[L[i - 1]] + s[i - 1]); if (i < L.length - 1) c = Math.min(c, cross[L[i + 1]] - s[i]); cross[v] = c; }); } const flipMain = direction === "left" || direction === "up"; for (const [v, i] of realOf) { const n = nodes[i]; const li = layer[i]; const m = flipMain ? -mainCenter[li] : mainCenter[li]; const cx = snap(horizontal ? m : cross[v]); const cy = snap(horizontal ? cross[v] : m); result.set(n.id, { x: cx - n.w / 2, y: cy - n.h / 2 }); } // Unconnected nodes go in a grid after the connected part. const loose = nodes.filter((n, i) => !connected[i]); if (loose.length) { const placed = unionBounds([...result.entries()].map(([id, p]) => { const n = nodes[index.get(id)]; return { x: p.x, y: p.y, w: n.w, h: n.h }; })); const cols = Math.min(4, Math.ceil(Math.sqrt(loose.length))); const cellW = Math.max(...loose.map((n) => n.w)) + 60; const cellH = Math.max(...loose.map((n) => n.h)) + 60; const ox = placed ? placed.x : 0; const oy = placed ? placed.y + placed.h + 80 : 0; loose.forEach((n, k) => { const cx = snap(ox + (k % cols) * cellW + cellW / 2); const cy = snap(oy + Math.floor(k / cols) * cellH + cellH / 2); result.set(n.id, { x: cx - n.w / 2, y: cy - n.h / 2 }); }); } const all = unionBounds([...result.entries()].map(([id, p]) => ({ x: p.x, y: p.y, w: 0, h: 0 }))); const dx = snap(all.x); const dy = snap(all.y); for (const p of result.values()) { p.x -= dx; p.y -= dy; } return result; } // Finds a spot for a new w x h box next to an anchor box in a direction, avoiding obstacles. // When every spot near the anchor is taken, it goes just past everything, in line with the anchor. export function findFreeSpot(anchor, dir, w, h, obstacles, gap = 60) { const horizontal = dir === "left" || dir === "right"; const forward = dir === "right" || dir === "down"; const cx = anchor.x + anchor.w / 2; const cy = anchor.y + anchor.h / 2; // How far apart the new box and any obstacle stay. const pad = 20; const blocked = (x, y) => obstacles.some((o) => x < o.x + o.w + pad && x + w > o.x - pad && y < o.y + o.h + pad && y + h > o.y - pad); for (let step = 0; step < 6; step++) { for (const k of [0, 1, -1, 2, -2]) { let x; let y; if (horizontal) { x = forward ? anchor.x + anchor.w + gap + step * (w + gap) : anchor.x - gap - w - step * (w + gap); y = cy - h / 2 + k * (h + 30); } else { y = forward ? anchor.y + anchor.h + gap + step * (h + gap) : anchor.y - gap - h - step * (h + gap); x = cx - w / 2 + k * (w + 30); } if (!blocked(x, y)) return { x, y }; } } const all = unionBounds([anchor, ...obstacles]); const room = Math.max(gap, pad); if (horizontal) return { x: forward ? all.x + all.w + room : all.x - room - w, y: cy - h / 2 }; return { x: cx - w / 2, y: forward ? all.y + all.h + room : all.y - room - h }; } export const oppositeDir = (d) => ({ right: "left", left: "right", up: "down", down: "up" })[d] || "left"; export function slugId(label, taken, fallback = "node") { let base = lower(label).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24).replace(/-+$/, ""); if (!base) base = fallback; let id = base; for (let i = 2; taken.has(id); i++) id = `${base}-${i}`; taken.add(id); return id; } // x and y come together: with both, the item goes there, and with neither it is placed for you. // Anything else goes in errors, because placing the item anyway would drop the coordinate given. function pinnedAt(item, name, auto, errors) { const hasX = item.x != null; const hasY = item.y != null; if (hasX !== hasY) errors.push(`${name} has only ${hasX ? "x" : "y"}. Give x and y together, or leave both out ${auto}.`); else if (hasX && !(isNum(item.x) && isNum(item.y))) errors.push(`${name} needs numbers for x and y.`); else return hasX; return false; } // Runs layeredLayout. Returns { pos }, or { error } when the drawing is too big for it. function layOut(nodes, edges, options) { try { return { pos: layeredLayout(nodes, edges, options), error: null }; } catch (err) { if (err instanceof LayoutError) return { pos: null, error: err.message }; throw err; } } // Converts { nodes, edges, texts } into elements. With `existing`, new nodes are placed next to // the existing nodes they connect to, or beside the current content. // Returns { elements, errors }. Nothing should be applied when errors is non-empty. export function buildFromSpec(spec, { existing = [], direction = "right", measure = approxMeasure } = {}) { const errors = []; const taken = new Set(existing.map((e) => e.id)); const existingShapes = existing.filter(isShape); const nodes = []; const pinned = new Set(); for (const n of spec.nodes || []) { const label = normalizeText(n.label ?? n.text); const type = resolveShapeType(n.shape || n.type); let id; if (n.id != null && String(n.id) !== "") { id = String(n.id); if (!ID_PATTERN.test(id)) { errors.push(`Node id "${id}" is not valid. Use letters, numbers, dashes or underscores.`); continue; } if (taken.has(id)) { errors.push(`Node id "${id}" is already used. Pick another id, or use update_elements to change the existing one.`); continue; } taken.add(id); } else { id = slugId(label, taken); } const size = normalizeSize(n.size); const auto = autoSize(label, type, measure, size); const el = normalizeElement({ id, type, text: label, color: n.color, fill: n.fill, dash: n.dashed ?? n.dash, size, x: n.x, y: n.y, w: n.w ?? auto.w, h: n.h ?? auto.h, }); if (pinnedAt(n, `Node "${id}"`, "for automatic layout", errors)) pinned.add(id); nodes.push(el); } const allShapes = [...existingShapes, ...nodes]; const byId = new Map(allShapes.map((s) => [s.id, s])); // Label to id, or null when several shapes share the label. Built on the first label lookup. let byLabel = null; const resolveRef = (ref) => { if (typeof ref !== "string" || !ref) return null; if (byId.has(ref)) return ref; if (!byLabel) { byLabel = new Map(); for (const s of allShapes) { const key = lower(s.text); if (key) byLabel.set(key, byLabel.has(key) ? null : s.id); } } return byLabel.get(lower(ref)) || null; }; const edges = []; for (const e of spec.edges || []) { const from = resolveRef(e.from); const to = resolveRef(e.to); if (!from || !to) { errors.push(`Edge "${e.from}" -> "${e.to}": no node with id or label "${!from ? e.from : e.to}".`); continue; } if (from === to) { errors.push(`Edge "${e.from}" -> "${e.to}" connects a node to itself, which is not supported.`); continue; } let id = e.id != null && String(e.id) !== "" ? String(e.id) : null; if (id && (!ID_PATTERN.test(id) || taken.has(id))) { errors.push(`Edge id "${id}" is not valid or already used.`); continue; } if (id) taken.add(id); else id = uniqueId("a", taken); edges.push(normalizeElement({ id, type: "arrow", from, to, text: e.label ?? e.text, color: e.color, dash: e.dashed ?? e.dash, head: e.head, route: e.route, })); } // Place nodes that have no x/y. const existingById = new Map(existing.map((e) => [e.id, e])); const obstacles = existing.map((e) => elementBounds(e, existingById, measure)).filter(Boolean); for (const n of nodes) if (pinned.has(n.id)) obstacles.push({ x: n.x, y: n.y, w: n.w, h: n.h }); const anchored = new Map([...existingShapes, ...nodes.filter((n) => pinned.has(n.id))].map((s) => [s.id, s])); const unplaced = nodes.filter((n) => !pinned.has(n.id)); if (anchored.size) { for (let progress = true; progress;) { progress = false; for (const n of unplaced) { if (anchored.has(n.id)) continue; const link = edges.find((e) => (e.to === n.id && anchored.has(e.from)) || (e.from === n.id && anchored.has(e.to))); if (!link) continue; const outgoing = link.to === n.id; const anchor = anchored.get(outgoing ? link.from : link.to); const spot = findFreeSpot(anchor, outgoing ? direction : oppositeDir(direction), n.w, n.h, obstacles); n.x = spot.x; n.y = spot.y; anchored.set(n.id, n); obstacles.push({ x: n.x, y: n.y, w: n.w, h: n.h }); progress = true; } } } const rest = unplaced.filter((n) => !anchored.has(n.id)); if (rest.length) { const restIds = new Set(rest.map((n) => n.id)); const { pos, error } = layOut(rest, edges.filter((e) => restIds.has(e.from) && restIds.has(e.to)).map((e) => ({ from: e.from, to: e.to, label: e.text })), { direction, measure }); if (error) errors.push(error); const content = unionBounds(obstacles); let ox = 0; let oy = 0; if (content) { const vertical = direction === "down" || direction === "up"; ox = snap(vertical ? content.x : content.x + content.w + 120); oy = snap(vertical ? content.y + content.h + 100 : content.y); } // Without a layout the error above means nothing is applied, so they can stay where they are. if (pos) { for (const n of rest) { const p = pos.get(n.id); n.x = ox + p.x; n.y = oy + p.y; } } } const texts = []; const shapeBox = unionBounds([...existing, ...nodes].filter(isShape).map((s) => ({ x: s.x, y: s.y, w: s.w, h: s.h }))); let textCursor = shapeBox ? shapeBox.y - 30 : 0; for (const t of spec.texts || []) { const text = normalizeText(t.text ?? t.label); if (!text.trim()) { errors.push("A text item needs non-empty text."); continue; } let id = t.id != null && String(t.id) !== "" ? String(t.id) : null; if (id && (!ID_PATTERN.test(id) || taken.has(id))) { errors.push(`Text id "${id}" is not valid or already used.`); continue; } if (id) taken.add(id); const words = text.replace(/\s+/g, " ").trim(); const name = `Text "${id || (words.length > 40 ? `${words.slice(0, 37)}...` : words)}"`; const placed = pinnedAt(t, name, "to put it above the diagram", errors); if (!id) id = uniqueId("t", taken); const el = normalizeElement({ id, type: "text", text, color: t.color, size: t.size ?? "l", x: t.x, y: t.y }); if (!placed) { const l = textLayout(el, measure); textCursor -= l.h; el.x = shapeBox ? shapeBox.x : 0; el.y = Math.round(textCursor); textCursor -= 12; } texts.push(el); } const far = rangeError([...nodes, ...texts]); if (far) errors.push(far); return { elements: [...nodes, ...edges, ...texts], errors }; } // Saving moves a position past MAX_COORD back to it, on top of whatever else is there, so a // layout that puts a shape or text that far out (very long arrow labels spread the layers far // apart, for one) has to be turned down. Says which element it is, or returns null. function rangeError(elements) { const far = elements.find((e) => (isShape(e) || e.type === "text") && (Math.abs(e.x) > MAX_COORD || Math.abs(e.y) > MAX_COORD)); if (!far) return null; const limit = MAX_COORD.toLocaleString("en-US"); return `The layout does not fit: "${far.id}" would be at ${Math.round(far.x)},${Math.round(far.y)}, but positions only go from -${limit} to ${limit}. Shorten the longest labels, or split the diagram into smaller ones.`; } // Re-lays out all shapes in a drawing, keeping the top-left corner where it was. // Returns { elements, error }. Nothing should be applied when error is set. export function relayout(elements, { direction = "right", measure = approxMeasure } = {}) { const shapes = elements.filter(isShape); if (!shapes.length) return { elements, error: null }; const before = unionBounds(shapes.map((s) => ({ x: s.x, y: s.y, w: s.w, h: s.h }))); const edges = elements.filter((e) => e.type === "arrow" && e.from && e.to).map((e) => ({ from: e.from, to: e.to, label: e.text })); const { pos, error } = layOut(shapes, edges, { direction, measure }); if (error) return { elements, error }; const ox = snap(before.x); const oy = snap(before.y); const arranged = elements.map((e) => (isShape(e) && pos.has(e.id) ? { ...e, x: ox + pos.get(e.id).x, y: oy + pos.get(e.id).y } : e)); return { elements: arranged, error: rangeError(arranged) }; } // Labels longer than this are cut short in outlines. The element JSON has them in full. export const OUTLINE_LABEL_MAX = 300; const quote = (s) => { const flat = String(s).replace(/\n/g, " / "); if (flat.length <= OUTLINE_LABEL_MAX) return JSON.stringify(flat); return `${JSON.stringify(`${flat.slice(0, OUTLINE_LABEL_MAX)}...`)} (cut from ${String(s).length} characters)`; }; const OUTLINE_GROUPS = [ ["Shapes", isShape, (s) => { const style = [s.type, s.color !== "gray" ? s.color : null, s.fill !== "soft" ? `fill ${s.fill}` : null, s.dash ? "dashed" : null, s.size !== "m" ? `text size ${s.size}` : null].filter(Boolean).join(", "); return `- ${s.id} (${style}) ${s.text ? quote(s.text) : "(no label)"} at ${Math.round(s.x)},${Math.round(s.y)} size ${Math.round(s.w)}x${Math.round(s.h)}`; }], ["Arrows", (e) => e.type === "arrow", (a) => { const from = a.from || `(${Math.round(a.x1)},${Math.round(a.y1)})`; const to = a.to || `(${Math.round(a.x2)},${Math.round(a.y2)})`; const style = [a.head !== "end" ? `head ${a.head}` : null, a.route !== "straight" ? a.route : null, a.dash ? "dashed" : null, a.color !== "gray" ? a.color : null, a.size !== "s" ? `text size ${a.size}` : null].filter(Boolean).join(", "); return `- ${a.id}: ${from} -> ${to}${a.text ? ` ${quote(a.text)}` : ""}${style ? ` (${style})` : ""}`; }], ["Text", (e) => e.type === "text", (t) => { const style = [t.color !== "gray" ? t.color : null, t.size !== "l" ? `text size ${t.size}` : null].filter(Boolean).join(", "); return `- ${t.id}${style ? ` (${style})` : ""}: ${quote(t.text)} at ${Math.round(t.x)},${Math.round(t.y)}`; }], ["Pen strokes", (e) => e.type === "pen", (p) => { const b = elementBounds(p, null); const style = [p.color !== "gray" ? p.color : null, `width ${p.width}`].filter(Boolean).join(", "); return `- ${p.id} (${style}) ${p.points.length} points at ${Math.round(b.x)},${Math.round(b.y)} size ${Math.round(b.w)}x${Math.round(b.h)}`; }], ]; // A readable outline of a drawing for the agent: a count of each kind of element, then one line // per element with its id, label, position and style (shapes, then arrows, text and pen strokes). // A style is left out when it is the default (see the size, color and other inputs in actions.mjs). // A big drawing comes in parts. The text stops before it would pass `budget` characters, where // `cost(el)` adds what a caller sends along with an element, but a part always has at least one // element. `start` is where in the list to begin, and `next` is where the following part begins, // or null after the last part. `only` limits the list to a set of ids. export function outlinePage(doc, { start = 0, budget = Infinity, only = null, cost = null } = {}) { const els = doc.elements || []; const groups = OUTLINE_GROUPS.map(([title, test, line]) => ({ title, line, list: els.filter(test) })); const [shapes, arrows, texts, pens] = groups.map((g) => g.list.length); const header = `Drawing ${quote(doc.name)}: ${shapes} shapes, ${arrows} arrows, ${texts} text, ${pens} pen strokes.`; const order = groups.flatMap((g) => g.list.filter((el) => !only || only.has(el.id)).map((el) => ({ el, g }))); const lines = [header]; const shown = []; let used = header.length; let group = null; let next = null; for (let i = Math.max(0, start); i < order.length; i++) { const { el, g } = order[i]; const line = g.line(el); const heading = g === group ? null : `${g.title}:`; const size = line.length + 1 + (heading ? heading.length + 1 : 0) + (cost ? cost(el) : 0); if (shown.length && used + size > budget) { next = i; break; } if (heading) lines.push(heading); lines.push(line); group = g; used += size; shown.push(el); } return { text: lines.join("\n"), next, total: order.length, shown }; } // The whole outline as text, with long labels cut short. export function describe(doc) { return outlinePage(doc).text; } export { wrapText, labelMaxWidth, lineHeight };