@r0kshan/cairn 1.0.0-RC17 → 1.0.0-RC18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Furthermore, complexe software architecture with many flows and component genera
34
34
  | **Typed diagrams with validation.** | Each view defines its element kinds and rules; `cairn validate` reports syntax, schema, and completeness issues as source-located, coded diagnostics, with a JSON mode for CI. |
35
35
  | **Flow matrix.** | `cairn matrix` exports the flow matrix as CSV, Markdown, or SVG — from any view, with the columns that view can fill. In infrastructure it splits protocol from port and annotates endpoints that sit in a network zone; headers localise via `style { lang: fr }`. The same table is available to embedders through `compile(source, { matrix: true })`. |
36
36
  | **Author-controlled positioning.** | Layout is automatic, with three opt-in overrides for when it gets a diagram wrong: `order: 2` sequences a top-level element along the disposition's own reading direction (left to right in `wide`, top to bottom in `tall`) and sorts siblings inside a container, `A.right -> B.left` pins which side of an element a flow leaves and arrives on, and the arrow glyph carries the line style (`->` solid, `-->` dashed, `..>` dotted). |
37
- | **Playground drag-to-adjust.** | When the automatic layout needs a correction, the [playground](https://cairn-psi-five.vercel.app/) lets you drag elements, flow labels, a single route segment, or a flow's endpoint onto another side. Every drag is written back into the source as the matching hint — `offset:`, `label-offset:`, `segment-offset:`, `ID.side` — so the adjustment stays versionable text and re-renders identically from the CLI. |
37
+ | **Playground drag-to-adjust.** | When the automatic layout needs a correction, the [playground](https://cairn-psi-five.vercel.app/) lets you drag elements, flow labels, a single route segment, or a flow's endpoint onto another side. Every drag is written back into the source as the matching hint — `offset:`, `label-offset:`, `segment-offset:`, `ID.side` — so the adjustment stays versionable text and re-renders identically from the CLI. A hovered flow label also offers a wrap button, which writes that flow's own `flow-label-wrap:`. |
38
38
  | **Enterprise-view extras.** | Business objects on flows, an auto-generated legend, and a numbered-flow table via `flow-text: numbered`. |
39
39
  | **French or English output.** | `style { lang: fr }` localizes band titles, legend, and matrix headers while keeping keywords English for portable sources (open to adding other languages if you find this usefull) |
40
40
  | **In-built themes and customizable colours** | Whether using the default or a chosen in-built theme, element colours can be overriden for all elements of a given kind in the `style` block |
package/bin/cairn.mjs CHANGED
@@ -92386,7 +92386,8 @@ var init_ast = __esm({
92386
92386
  E0216: "Network zones belong to a site (or nest inside a larger zone): DMZ and LAN only mean something relative to a perimeter.",
92387
92387
  E0217: "A cluster groups the nodes that stand in for one another, so it needs a network location like any of them: put it in a `network-zone` or a `site`. Its members may be servers, deployed applications or databases \u2014 a Kubernetes cluster of worker nodes, or a database cluster of primary and standby.",
92388
92388
  W0540: 'C4 container-diagram practice: inter-process relationships should be labelled with their technology/protocol ("the how, not just the what"). Human/actor interactions are exempt. Add `(API_REST, JSON)` after the label, or ignore if the diagram is intentionally functional-only.',
92389
- E0223: "Unknown endpoint suffix. A flow endpoint may name the side it attaches to \u2014 `APP.right -> DB.left` \u2014 using the diagram as it is read: `left`, `right`, `top`, `bottom`. It may instead name its role towards a queue at the other end: `producer` or `consumer`. A declared side is a hint, not a guarantee: one the layout cannot reach is dropped with a W0570 warning rather than forced.",
92389
+ E0223: "Unknown endpoint suffix. A flow endpoint may name the side it attaches to \u2014 `APP.right -> DB.left` \u2014 using the diagram as it is read: `left`, `right`, `top`, `bottom`. It may instead name its role towards a queue at the other end: `producer` or `consumer`. It may name one of each, in either order (`CAPTURE.producer.top`). A declared side is a hint, not a guarantee: one the layout cannot reach is dropped with a W0570 warning rather than forced.",
92390
+ E0227: "One endpoint names two attachment sides, or two roles, and only one of each can hold: `A.top.bottom` asks for two sides of the same element, `A.producer.consumer` for two caps of the same queue. The first one written stands. A side and a *role* are a different matter \u2014 they answer different questions, so `CAPTURE.producer.top -> EVENTS` is read as both: the flow leaves CAPTURE's top and meets the queue's producer cap.",
92390
92391
  E0224: "An endpoint role says what this element does with the *queue* at the other end of the flow (`CAPTURE.producer -> EVENTS`), so the other end has to be one. Point the flow at a queue, or drop the role \u2014 between two ordinary elements the arrow already says everything a role would.",
92391
92392
  E0225: "A flow's queue endpoint carries both a declared side and a role at the other end, and they are two answers to one question: `producer` already means the queue's left cap and `consumer` its right. Keep the role and drop the side, or keep the side and drop the role.",
92392
92393
  E0240: "The infrastructure view requires every flow to carry its protocol (and port if relevant): the flow matrix is the primary output of this view. Add `(HTTPS/443)` after the label.",
@@ -92397,6 +92398,9 @@ var init_ast = __esm({
92397
92398
  E0109: "`offset:` takes two whole numbers \u2014 `offset: <dx>, <dy>` \u2014 the pixels to move this element by, right and down, from where the layout engine placed it. Either may be negative. It is a nudge, not a seat: elk still decides where the element belongs, so the diagram keeps re-flowing around it. Pair it with `order:` when the element is in the wrong place entirely \u2014 an offset large enough to change reading order is a sign the band is wrong, not the pixels.",
92398
92399
  W0572: "An `offset:` (or `label-offset:`) moved something onto something else. The hint is still honored \u2014 an author's positioning request is not negotiated \u2014 but the drawing now has an overlap the layout would never have produced on its own, so it is reported rather than left to be discovered in a review. Reduce the offset, or move the element with `order:` instead.",
92399
92400
  W0573: "An `offset:` was cut short so the element stays inside the container that holds it. Nesting is what a diagram *means* \u2014 a block drawn outside its system reads as a broken drawing, not a nudged one \u2014 so containment wins over the nudge here, and this is the one place a positioning hint is negotiated rather than honored outright. Nudge the container instead if the whole group belongs elsewhere, or use a smaller offset.",
92401
+ E0110: "`size:` takes two whole numbers \u2014 `size: <dw>, <dh>` \u2014 the pixels of room to add to this container, rightward and downward, on top of what the layout gave it. Either may be negative. A delta rather than a box, for the same reason `offset:` is one: elk still sizes a container from what it holds, so a hint naming an absolute width would go stale the moment a child is added.",
92402
+ E0226: "`size:` is a container's knob, and a container is something *drawn* as one. A leaf box is sized by the label in it \u2014 the uniform node width, or the tighter fit `label-padding:` asks for \u2014 so a delta there would argue with the one thing that decides it. A container kind holding nothing is the same case: with no children there is no frame, the layout gives it a plain box sized by its own label, and there is no room around anything to give. Put the elements it contains inside it, use `label-padding:` in `style` to change how tightly every box hugs its text, or `offset:` to move this one.",
92403
+ W0575: "A `size:` ran out of room. A container is sized to hug what it holds, so its border has no slack of its own \u2014 shrinking closes the empty bands *between* its children instead, and stops once those are down to the minimum gap. Growing has no limit at all: a container enlarged past the one that holds it makes that one grow too. Everything else is honored as written, so a container grown onto a neighbour is drawn as asked and reported as W0572. To make this box smaller still, move or resize what is inside it.",
92400
92404
  W0571: "An endpoint reads `ID.side`, but `ID.side` is itself a declared element, and a declared id always wins \u2014 so the flow attaches to that element and no side is pinned. Rename the element if you meant the side."
92401
92405
  };
92402
92406
  }
@@ -93222,57 +93226,93 @@ function parseElement(p, sourceToken, parent) {
93222
93226
  (parent ? parent.children : model.elements).push(element);
93223
93227
  }
93224
93228
  function splitEndpoint(token, flow, end) {
93225
- const dot = token.text.lastIndexOf(".");
93226
- if (dot <= 0 || dot === token.text.length - 1) return null;
93227
- const base = token.text.slice(0, dot);
93228
- const suffix = token.text.slice(dot + 1);
93229
- return {
93230
- flow,
93231
- end,
93232
- raw: token.text,
93233
- rawSpan: token.span,
93234
- base,
93235
- baseSpan: { line: token.span.line, col: token.span.col, len: base.length },
93236
- suffix,
93237
- suffixSpan: { line: token.span.line, col: token.span.col + dot + 1, len: suffix.length }
93238
- };
93229
+ const texts = token.text.split(".");
93230
+ if (texts.length < 2 || texts.some((text) => !text)) return null;
93231
+ let col = token.span.col;
93232
+ const parts = texts.map((text) => {
93233
+ const span = { line: token.span.line, col, len: text.length };
93234
+ col += text.length + 1;
93235
+ return { text, span };
93236
+ });
93237
+ return { flow, end, raw: token.text, rawSpan: token.span, parts };
93239
93238
  }
93240
93239
  function resolveEndpointSuffixes(splits, model, diagnostics) {
93240
+ const sideOf2 = (text) => ATTACH_SIDES.includes(text);
93241
+ const roleOf = (text) => ATTACH_ROLES.includes(text);
93241
93242
  for (const split of splits) {
93242
- const isSide = ATTACH_SIDES.includes(split.suffix);
93243
- const isRole = ATTACH_ROLES.includes(split.suffix);
93244
93243
  if (model.index.has(split.raw)) {
93245
- if (isSide || isRole)
93244
+ let head = split.parts.length;
93245
+ while (head > 1 && (sideOf2(split.parts[head - 1].text) || roleOf(split.parts[head - 1].text)))
93246
+ head--;
93247
+ const shadowed = split.parts.slice(head).map((part) => part.text);
93248
+ if (shadowed.length) {
93249
+ const base = split.parts.slice(0, head).map((part) => part.text).join(".");
93250
+ const reading = [
93251
+ shadowed.some((text) => roleOf(text)) ? "a role" : "",
93252
+ shadowed.some((text) => sideOf2(text)) ? "an attachment side" : ""
93253
+ ].filter(Boolean);
93246
93254
  diagnostics.push({
93247
93255
  code: "W0571",
93248
93256
  severity: "warning",
93249
- message: `\`${split.raw}\` is a declared element, so \`.${split.suffix}\` is not read as ${isSide ? "an attachment side" : "a role"}`,
93257
+ message: `\`${split.raw}\` is a declared element, so \`.${shadowed.join(".")}\` is not read as ${reading.join(" and ")}`,
93250
93258
  span: split.rawSpan,
93251
93259
  note: "a declared id always wins over the `ID.side` reading",
93252
- help: isSide ? `rename the element if you meant to attach the flow to the ${split.suffix} side of \`${split.base}\`` : `rename the element if you meant \`${split.base}\` to be the ${split.suffix} of the queue at the other end`
93260
+ help: `rename the element if you meant \`.${shadowed.join(".")}\` on \`${base}\``
93253
93261
  });
93262
+ }
93254
93263
  continue;
93255
93264
  }
93256
- if (!model.index.has(split.base)) continue;
93257
- if (!isSide && !isRole)
93258
- diagnostics.push({
93259
- code: "E0223",
93260
- severity: "error",
93261
- message: `unknown endpoint suffix \`${split.suffix}\``,
93262
- span: split.suffixSpan,
93263
- note: "an endpoint names either the side it attaches to, as the diagram is read, or its role towards a queue",
93264
- help: "use `left`, `right`, `top` or `bottom` \u2014 `APP.right -> DB.left` \u2014 or `producer` / `consumer`, e.g. `CAPTURE.producer -> EVENTS`"
93265
- });
93266
- const side = isSide ? { value: split.suffix, span: split.suffixSpan } : void 0;
93267
- const role = isRole ? { value: split.suffix, span: split.suffixSpan } : void 0;
93265
+ let cut = 0;
93266
+ for (let take = split.parts.length - 1; take >= 1; take--) {
93267
+ const base = split.parts.slice(0, take).map((part) => part.text).join(".");
93268
+ if (model.index.has(base)) {
93269
+ cut = take;
93270
+ break;
93271
+ }
93272
+ }
93273
+ if (!cut) continue;
93274
+ let side;
93275
+ let role;
93276
+ for (const part of split.parts.slice(cut)) {
93277
+ const partIsSide = sideOf2(part.text);
93278
+ const partIsRole = roleOf(part.text);
93279
+ if (!partIsSide && !partIsRole) {
93280
+ diagnostics.push({
93281
+ code: "E0223",
93282
+ severity: "error",
93283
+ message: `unknown endpoint suffix \`${part.text}\``,
93284
+ span: part.span,
93285
+ note: "an endpoint names either the side it attaches to, as the diagram is read, or its role towards a queue",
93286
+ help: "use `left`, `right`, `top` or `bottom` \u2014 `APP.right -> DB.left` \u2014 or `producer` / `consumer`, e.g. `CAPTURE.producer -> EVENTS`, or one of each: `CAPTURE.producer.top -> EVENTS`"
93287
+ });
93288
+ continue;
93289
+ }
93290
+ const held = partIsSide ? side : role;
93291
+ if (held) {
93292
+ diagnostics.push({
93293
+ code: "E0227",
93294
+ severity: "error",
93295
+ message: `this endpoint already names ${partIsSide ? `the \`${held.value}\` side` : `the \`${held.value}\` role`}`,
93296
+ span: part.span,
93297
+ note: `\`${split.raw}\` names two ${partIsSide ? "attachment sides" : "roles"}, and only one can hold`,
93298
+ help: partIsSide ? "keep one side \u2014 a side and a *role* do accumulate, e.g. `CAPTURE.producer.top`" : "keep one role \u2014 a role and a *side* do accumulate, e.g. `CAPTURE.producer.top`"
93299
+ });
93300
+ continue;
93301
+ }
93302
+ if (partIsSide) side = { value: part.text, span: part.span };
93303
+ else role = { value: part.text, span: part.span };
93304
+ }
93305
+ const baseParts = split.parts.slice(0, cut);
93306
+ const baseText = baseParts.map((part) => part.text).join(".");
93307
+ const baseSpan = { line: split.rawSpan.line, col: split.rawSpan.col, len: baseText.length };
93268
93308
  if (split.end === "from") {
93269
- split.flow.from = split.base;
93270
- split.flow.fromSpan = split.baseSpan;
93309
+ split.flow.from = baseText;
93310
+ split.flow.fromSpan = baseSpan;
93271
93311
  split.flow.fromSide = side;
93272
93312
  split.flow.fromRole = role;
93273
93313
  } else {
93274
- split.flow.to = split.base;
93275
- split.flow.toSpan = split.baseSpan;
93314
+ split.flow.to = baseText;
93315
+ split.flow.toSpan = baseSpan;
93276
93316
  split.flow.toSide = side;
93277
93317
  split.flow.toRole = role;
93278
93318
  }
@@ -93322,9 +93362,9 @@ function readOffsetPair(p) {
93322
93362
  span: { line: dx.span.line, col: dx.span.col, len: dy.span.col + dy.span.len - dx.span.col }
93323
93363
  };
93324
93364
  }
93325
- function tryOffsetEntry(p, parent) {
93365
+ function tryPairEntry(p, parent, entry, put) {
93326
93366
  const { matchToken, advance, reportCoded, save, restore, syncToNextLine } = p;
93327
- if (!parent || !matchToken("id", "offset")) return false;
93367
+ if (!parent || !matchToken("id", entry.key)) return false;
93328
93368
  const mark = save();
93329
93369
  const keyToken = advance();
93330
93370
  if (!matchToken("colon")) {
@@ -93334,16 +93374,11 @@ function tryOffsetEntry(p, parent) {
93334
93374
  advance();
93335
93375
  const pair2 = readOffsetPair(p);
93336
93376
  if (!pair2) {
93337
- reportCoded(
93338
- "E0109",
93339
- "`offset` expects two whole numbers \u2014 `offset: <dx>, <dy>`",
93340
- keyToken.span,
93341
- "e.g. `offset: 40, -20` \u2014 40px right and 20px up from where the layout put it"
93342
- );
93377
+ reportCoded(entry.code, entry.message, keyToken.span, entry.help);
93343
93378
  syncToNextLine();
93344
93379
  return true;
93345
93380
  }
93346
- parent.offset = pair2;
93381
+ put(parent, pair2);
93347
93382
  return true;
93348
93383
  }
93349
93384
  function tryLogoEntry(p, parent) {
@@ -93589,7 +93624,7 @@ function parse(src) {
93589
93624
  }
93590
93625
  if (tryStyleBlock(parent)) return;
93591
93626
  if (tryOrderEntry(parser, parent)) return;
93592
- if (tryOffsetEntry(parser, parent)) return;
93627
+ if (tryOffsetEntry(parser, parent) || trySizeEntry(parser, parent)) return;
93593
93628
  if (tryLogoEntry(parser, parent)) return;
93594
93629
  if (!parent && tryLegendBlock(parser)) return;
93595
93630
  if (!parent && tryBusinessObject(parser)) return;
@@ -93784,7 +93819,7 @@ function applyStyleEntry(entry) {
93784
93819
  });
93785
93820
  }
93786
93821
  }
93787
- var LINE_STYLES, LABEL_POSITIONS, DISPOSITIONS, NUMERIC_STYLE_ENTRIES, UNIFORM_STYLE_ENTRIES;
93822
+ var tryOffsetEntry, trySizeEntry, LINE_STYLES, LABEL_POSITIONS, DISPOSITIONS, NUMERIC_STYLE_ENTRIES, UNIFORM_STYLE_ENTRIES;
93788
93823
  var init_parser = __esm({
93789
93824
  "src/parser.ts"() {
93790
93825
  "use strict";
@@ -93793,6 +93828,32 @@ var init_parser = __esm({
93793
93828
  init_themes();
93794
93829
  init_element_tree();
93795
93830
  init_text_metrics();
93831
+ tryOffsetEntry = (p, parent) => tryPairEntry(
93832
+ p,
93833
+ parent,
93834
+ {
93835
+ key: "offset",
93836
+ code: "E0109",
93837
+ message: "`offset` expects two whole numbers \u2014 `offset: <dx>, <dy>`",
93838
+ help: "e.g. `offset: 40, -20` \u2014 40px right and 20px up from where the layout put it"
93839
+ },
93840
+ (element, pair2) => {
93841
+ element.offset = pair2;
93842
+ }
93843
+ );
93844
+ trySizeEntry = (p, parent) => tryPairEntry(
93845
+ p,
93846
+ parent,
93847
+ {
93848
+ key: "size",
93849
+ code: "E0110",
93850
+ message: "`size` expects two whole numbers \u2014 `size: <dw>, <dh>`",
93851
+ help: "e.g. `size: 120, 40` \u2014 120px wider and 40px taller than the layout made it"
93852
+ },
93853
+ (element, pair2) => {
93854
+ element.size = { dw: pair2.dx, dh: pair2.dy, span: pair2.span };
93855
+ }
93856
+ );
93796
93857
  LINE_STYLES = /* @__PURE__ */ new Set(["solid", "dashed", "dotted"]);
93797
93858
  LABEL_POSITIONS = /* @__PURE__ */ new Set(["on-line", "above", "below"]);
93798
93859
  DISPOSITIONS = /* @__PURE__ */ new Set(["wide", "tall", "slide", "page"]);
@@ -94764,6 +94825,7 @@ function validate(model) {
94764
94825
  ...checkNesting(elements, view),
94765
94826
  ...checkFlows(model, view),
94766
94827
  ...checkEndpointRoles(model, view),
94828
+ ...checkSizes(elements, view),
94767
94829
  ...checkLogos(elements, view),
94768
94830
  ...checkBusinessObjects(model, view),
94769
94831
  ...checkMinimumCounts(elements, model, view),
@@ -94806,6 +94868,31 @@ function checkUnknownKinds(elements, view) {
94806
94868
  }
94807
94869
  return diagnostics;
94808
94870
  }
94871
+ function checkSizes(elements, view) {
94872
+ const diagnostics = [];
94873
+ for (const element of elements) {
94874
+ if (!element.size) continue;
94875
+ if (!view.containerKinds.includes(element.kind))
94876
+ diagnostics.push({
94877
+ code: "E0226",
94878
+ severity: "error",
94879
+ message: `\`${element.kind}\` is not a container, so it has no \`size\` to give`,
94880
+ span: element.size.span,
94881
+ note: `the \`${view.name}\` view accepts \`size\` on: ${view.containerKinds.join(", ")}`,
94882
+ help: "a leaf box is sized by its own label \u2014 set `label-padding:` in `style` to change how tightly, or nudge this one with `offset:`"
94883
+ });
94884
+ else if (!element.children.length)
94885
+ diagnostics.push({
94886
+ code: "E0226",
94887
+ severity: "error",
94888
+ message: `\`${element.id}\` holds nothing, so it is drawn as a plain box rather than a container`,
94889
+ span: element.size.span,
94890
+ note: "a container's size is the room around what it holds \u2014 with no children there is no room to give",
94891
+ help: "put the elements it contains inside it, or drop the `size:` and nudge this one with `offset:`"
94892
+ });
94893
+ }
94894
+ return diagnostics;
94895
+ }
94809
94896
  function checkLogos(elements, view) {
94810
94897
  const diagnostics = [];
94811
94898
  const logoKinds = view.logoKinds ?? [];
@@ -98288,6 +98375,58 @@ function applySwap(scene, pair2, swap, leaves) {
98288
98375
  return true;
98289
98376
  }
98290
98377
  }
98378
+ function straightenCarriedRuns(scene, titleBoxes, only) {
98379
+ if (!only.size) return;
98380
+ const leaves = scene.nodes.filter((node) => !node.container);
98381
+ if (!leaves.length) return;
98382
+ const ctx = createTidyContext(scene, leaves, titleBoxes, false);
98383
+ const strikesTitle = (at, from, to, horizontal) => {
98384
+ const lo = Math.min(from, to);
98385
+ const hi = Math.max(from, to);
98386
+ return titleBoxes.some(
98387
+ (band) => horizontal ? at > band.y && at < band.y + band.height && lo < band.x + band.width && hi > band.x : at > band.x && at < band.x + band.width && lo < band.y + band.height && hi > band.y
98388
+ );
98389
+ };
98390
+ const taken = (seat, mine) => scene.edges.some(
98391
+ (other) => other !== mine && other.pts.length >= 2 && [other.pts[0], other.pts[other.pts.length - 1]].some(
98392
+ (end) => Math.abs(end.x - seat.x) < MIN_ATTACH_GAP && Math.abs(end.y - seat.y) < MIN_ATTACH_GAP
98393
+ )
98394
+ );
98395
+ for (const edge of scene.edges) {
98396
+ if (!only.has(edge.id) || edge.pts.length < 3) continue;
98397
+ if (edge.pinned?.start || edge.pinned?.end) continue;
98398
+ const src = sideOf(edge.pts[0], leaves);
98399
+ const dst = sideOf(edge.pts[edge.pts.length - 1], leaves);
98400
+ if (!src || !dst || src.node === dst.node) continue;
98401
+ const alongY = src.side === "east" || src.side === "west";
98402
+ const rightwards = dst.node.x > src.node.x;
98403
+ const downwards = dst.node.y > src.node.y;
98404
+ const srcSide = alongY ? rightwards ? "east" : "west" : downwards ? "south" : "north";
98405
+ const dstSide = alongY ? rightwards ? "west" : "east" : downwards ? "north" : "south";
98406
+ const span = (node) => alongY ? [node.y + MIN_SIDE_INSET, node.y + node.height - MIN_SIDE_INSET] : [node.x + MIN_SIDE_INSET, node.x + node.width - MIN_SIDE_INSET];
98407
+ const [srcLo, srcHi] = span(src.node);
98408
+ const [dstLo, dstHi] = span(dst.node);
98409
+ const low = Math.max(srcLo, dstLo);
98410
+ const high = Math.min(srcHi, dstHi);
98411
+ if (low > high) continue;
98412
+ const at = Math.round((low + high) / 2);
98413
+ const seatOnFace = (node, side) => side === "north" ? { x: at, y: node.y } : side === "south" ? { x: at, y: node.y + node.height } : side === "west" ? { x: node.x, y: at } : { x: node.x + node.width, y: at };
98414
+ const from = seatOnFace(src.node, srcSide);
98415
+ const to = seatOnFace(dst.node, dstSide);
98416
+ const travel = alongY ? to.x - from.x : to.y - from.y;
98417
+ const outward = (side) => side === "east" || side === "south" ? 1 : -1;
98418
+ if (travel === 0 || Math.sign(travel) !== outward(srcSide)) continue;
98419
+ const lane = alongY ? [from.x, to.x] : [from.y, to.y];
98420
+ const runFrom = Math.min(lane[0], lane[1]);
98421
+ const runTo = Math.max(lane[0], lane[1]);
98422
+ if (ctx.runHitsNode(!alongY, at, runFrom, runTo)) continue;
98423
+ if (strikesTitle(at, runFrom, runTo, alongY)) continue;
98424
+ const others = ctx.runsExcept(edge.id);
98425
+ if (!ctx.runIsClear(alongY ? others.horizontal : others.vertical, at, runFrom, runTo)) continue;
98426
+ if (taken(from, edge) || taken(to, edge)) continue;
98427
+ edge.pts = [from, to];
98428
+ }
98429
+ }
98291
98430
  function swapCrossingSiblingSeats(scene) {
98292
98431
  const leaves = scene.nodes.filter((node) => !node.container);
98293
98432
  const endsOf = (edge) => [
@@ -100954,6 +101093,155 @@ function stampLabelOffsets(edges, model) {
100954
101093
  for (const label of edge.labels) label.offset = { dx: declared.dx, dy: declared.dy };
100955
101094
  }
100956
101095
  }
101096
+ function freeBands(box, axis) {
101097
+ const { node, pad } = box;
101098
+ const { min, size } = AXIS[axis];
101099
+ const spans = kidsOf(box).map((kid) => ({ start: kid[min], end: kid[min] + kid[size] })).sort((a, b) => a.start - b.start || a.end - b.end);
101100
+ const merged = [];
101101
+ for (const span of spans) {
101102
+ const last = merged[merged.length - 1];
101103
+ if (last && span.start <= last.end) last.end = Math.max(last.end, span.end);
101104
+ else merged.push({ ...span });
101105
+ }
101106
+ if (!merged.length) return [];
101107
+ const bands = [];
101108
+ const add = (start, end, keep) => {
101109
+ if (end - start > keep) bands.push({ end, spare: end - start - keep });
101110
+ };
101111
+ add(node[min], merged[0].start, pad);
101112
+ for (let i = 0; i + 1 < merged.length; i++)
101113
+ add(merged[i].end, merged[i + 1].start, MIN_SIBLING_GAP);
101114
+ add(merged[merged.length - 1].end, node[min] + node[size], pad);
101115
+ return bands;
101116
+ }
101117
+ function holdsChildren(box) {
101118
+ const { node, pad } = box;
101119
+ const kids = kidsOf(box);
101120
+ if (!kids.length) return { minWidth: pad * 2, minHeight: pad * 2 };
101121
+ return {
101122
+ minWidth: Math.max(...kids.map((kid) => kid.x + kid.width)) + pad - node.x,
101123
+ minHeight: Math.max(...kids.map((kid) => kid.y + kid.height)) + pad - node.y
101124
+ };
101125
+ }
101126
+ function contentFloor(box) {
101127
+ const spare = (axis) => freeBands(box, axis).reduce((total, band) => total + band.spare, 0);
101128
+ return { minWidth: box.node.width - spare("x"), minHeight: box.node.height - spare("y") };
101129
+ }
101130
+ function squeezeInside(box, axis, target) {
101131
+ const { min, size } = AXIS[axis];
101132
+ const need = box.node[size] - target;
101133
+ if (need <= 0) return;
101134
+ const bands = freeBands(box, axis);
101135
+ const spare = bands.reduce((total, band) => total + band.spare, 0);
101136
+ if (spare <= 0) return;
101137
+ const share = Math.min(1, need / spare);
101138
+ const shiftAt = (at) => bands.reduce((total, band) => at >= band.end ? total + band.spare * share : total, 0);
101139
+ for (const child of box.element.children) {
101140
+ const kid = box.nodeById.get(child.id);
101141
+ if (!kid) continue;
101142
+ const shift = shiftAt(kid[min]);
101143
+ if (!shift) continue;
101144
+ for (const id of subtreeIds(child)) {
101145
+ const inside = box.nodeById.get(id);
101146
+ if (inside) inside[min] -= shift;
101147
+ }
101148
+ }
101149
+ }
101150
+ function applyContainerSizes(scene, model) {
101151
+ const carried = /* @__PURE__ */ new Set();
101152
+ const wanted = /* @__PURE__ */ new Map();
101153
+ const walk = (elements) => {
101154
+ for (const element of elements) {
101155
+ walk(element.children);
101156
+ if (element.size) wanted.set(element.id, element.size);
101157
+ }
101158
+ };
101159
+ walk(model.elements);
101160
+ if (!wanted.size) return carried;
101161
+ const nodeById = new Map(scene.nodes.map((node) => [node.id, node]));
101162
+ const pad = containerPad(model);
101163
+ const clampedSpans = /* @__PURE__ */ new Set();
101164
+ const applied = /* @__PURE__ */ new Map();
101165
+ const before = new Map(
101166
+ scene.nodes.map((node) => [
101167
+ node.id,
101168
+ { x: node.x, y: node.y, width: node.width, height: node.height }
101169
+ ])
101170
+ );
101171
+ for (const [id, spec] of wanted) {
101172
+ const node = nodeById.get(id);
101173
+ const element = model.index.get(id);
101174
+ if (!node || !element) continue;
101175
+ const box = { node, element, nodeById, pad };
101176
+ const floor = contentFloor(box);
101177
+ const width = Math.max(node.width + spec.dw, floor.minWidth);
101178
+ const height = Math.max(node.height + spec.dh, floor.minHeight);
101179
+ if (width !== node.width + spec.dw || height !== node.height + spec.dh)
101180
+ clampedSpans.add(spec.span);
101181
+ squeezeInside(box, "x", width);
101182
+ squeezeInside(box, "y", height);
101183
+ node.width = width;
101184
+ node.height = height;
101185
+ }
101186
+ const containWithin = (elements) => {
101187
+ for (const element of elements) {
101188
+ containWithin(element.children);
101189
+ const node = nodeById.get(element.id);
101190
+ if (!node?.container) continue;
101191
+ const holds = holdsChildren({ node, element, nodeById, pad });
101192
+ node.width = Math.max(node.width, holds.minWidth);
101193
+ node.height = Math.max(node.height, holds.minHeight);
101194
+ }
101195
+ };
101196
+ containWithin(model.elements);
101197
+ for (const id of wanted.keys()) {
101198
+ const node = nodeById.get(id);
101199
+ const was = before.get(id);
101200
+ if (node && was)
101201
+ applied.set(id, { dw: node.width - was.width, dh: node.height - was.height });
101202
+ }
101203
+ if (clampedSpans.size) scene.clampedSizes = clampedSpans;
101204
+ scene.appliedSizes = applied;
101205
+ const seats = [];
101206
+ for (const node of scene.nodes) {
101207
+ const was = before.get(node.id);
101208
+ if (!was) continue;
101209
+ const moved = {
101210
+ dx: node.x - was.x,
101211
+ dy: node.y - was.y,
101212
+ dw: node.width - was.width,
101213
+ dh: node.height - was.height
101214
+ };
101215
+ if (!moved.dx && !moved.dy && !moved.dw && !moved.dh) continue;
101216
+ seats.push({ box: was, ...moved });
101217
+ }
101218
+ seats.sort((a, b) => a.box.width * a.box.height - b.box.width * b.box.height);
101219
+ if (!seats.length) return carried;
101220
+ for (const edge of scene.edges) {
101221
+ if (edge.pts.length < 2) continue;
101222
+ for (const which of ["first", "last"]) {
101223
+ const terminal = edge.pts[which === "first" ? 0 : edge.pts.length - 1];
101224
+ const seat = seats.find((candidate) => pointOn(terminal, candidate.box));
101225
+ if (!seat) continue;
101226
+ const onRight = terminal.x >= seat.box.x + seat.box.width - ON_BORDER;
101227
+ const onBottom = terminal.y >= seat.box.y + seat.box.height - ON_BORDER;
101228
+ const delta = {
101229
+ dx: seat.dx + (onRight ? seat.dw : 0),
101230
+ dy: seat.dy + (onBottom ? seat.dh : 0)
101231
+ };
101232
+ if (!delta.dx && !delta.dy) continue;
101233
+ carried.add(edge.id);
101234
+ carryTerminal(edge, which, delta, {
101235
+ x: seat.box.x + seat.dx,
101236
+ y: seat.box.y + seat.dy,
101237
+ width: seat.box.width + seat.dw,
101238
+ height: seat.box.height + seat.dh
101239
+ });
101240
+ }
101241
+ }
101242
+ for (const edge of scene.edges) if (carried.has(edge.id)) dropRedundantPoints(edge.pts);
101243
+ return carried;
101244
+ }
100957
101245
  function applyNodeOffsets(scene, offsetOf) {
100958
101246
  const carried = /* @__PURE__ */ new Set();
100959
101247
  if (!offsetOf.size) return carried;
@@ -101018,19 +101306,36 @@ function applyNodeOffsets(scene, offsetOf) {
101018
101306
  const seat = seats.find((candidate) => pointOn(terminal, candidate.box));
101019
101307
  if (!seat || !seat.delta.dx && !seat.delta.dy) continue;
101020
101308
  carried.add(edge.id);
101021
- const neighbourIndex = end === 0 ? 1 : edge.pts.length - 2;
101022
- const neighbour = edge.pts[neighbourIndex];
101023
- const wasHorizontal = Math.abs(terminal.y - neighbour.y) < 0.5;
101024
- terminal.x += seat.delta.dx;
101025
- terminal.y += seat.delta.dy;
101026
- const elbow = wasHorizontal ? { x: neighbour.x, y: terminal.y } : { x: terminal.x, y: neighbour.y };
101027
- const degenerate = Math.abs(elbow.x - terminal.x) < 0.5 && Math.abs(elbow.y - terminal.y) < 0.5 || Math.abs(elbow.x - neighbour.x) < 0.5 && Math.abs(elbow.y - neighbour.y) < 0.5;
101028
- if (!degenerate) edge.pts.splice(end === 0 ? 1 : edge.pts.length - 1, 0, elbow);
101309
+ carryTerminal(edge, end === 0 ? "first" : "last", seat.delta, {
101310
+ x: seat.box.x + seat.delta.dx,
101311
+ y: seat.box.y + seat.delta.dy,
101312
+ width: seat.box.width,
101313
+ height: seat.box.height
101314
+ });
101029
101315
  }
101030
101316
  }
101031
101317
  for (const edge of scene.edges) if (carried.has(edge.id)) dropRedundantPoints(edge.pts);
101032
101318
  return carried;
101033
101319
  }
101320
+ function carryTerminal(edge, which, delta, face) {
101321
+ const end = which === "first" ? 0 : edge.pts.length - 1;
101322
+ const terminal = edge.pts[end];
101323
+ const neighbour = edge.pts[which === "first" ? 1 : edge.pts.length - 2];
101324
+ const wasHorizontal = Math.abs(terminal.y - neighbour.y) < SEGMENT_EPSILON;
101325
+ terminal.x += delta.dx;
101326
+ terminal.y += delta.dy;
101327
+ const slide = wasHorizontal ? { along: "y", low: face.y, high: face.y + face.height } : { along: "x", low: face.x, high: face.x + face.width };
101328
+ const residual = neighbour[slide.along] - terminal[slide.along];
101329
+ const seat = Math.min(
101330
+ Math.max(neighbour[slide.along], slide.low + SEAT_INSET),
101331
+ Math.max(slide.high - SEAT_INSET, slide.low + SEAT_INSET)
101332
+ );
101333
+ if (Math.abs(residual) < ARROW_ROOM2 && Math.abs(seat - neighbour[slide.along]) < SEGMENT_EPSILON)
101334
+ terminal[slide.along] = seat;
101335
+ const elbow = wasHorizontal ? { x: neighbour.x, y: terminal.y } : { x: terminal.x, y: neighbour.y };
101336
+ const degenerate = Math.abs(elbow.x - terminal.x) < SEGMENT_EPSILON && Math.abs(elbow.y - terminal.y) < SEGMENT_EPSILON || Math.abs(elbow.x - neighbour.x) < SEGMENT_EPSILON && Math.abs(elbow.y - neighbour.y) < SEGMENT_EPSILON;
101337
+ if (!degenerate) edge.pts.splice(which === "first" ? 1 : edge.pts.length - 1, 0, elbow);
101338
+ }
101034
101339
  function dropRedundantPoints(pts) {
101035
101340
  const inLine = (a, b, c) => Math.abs(a.x - b.x) < SEGMENT_EPSILON && Math.abs(b.x - c.x) < SEGMENT_EPSILON || Math.abs(a.y - b.y) < SEGMENT_EPSILON && Math.abs(b.y - c.y) < SEGMENT_EPSILON;
101036
101341
  for (let i = pts.length - 2; i >= 1; i--)
@@ -101156,6 +101461,37 @@ function shiftIntoCanvas(scene) {
101156
101461
  scene.width += shiftX;
101157
101462
  scene.height += shiftY;
101158
101463
  }
101464
+ function sizeOverlaps(scene, model, overlap) {
101465
+ const sized = /* @__PURE__ */ new Map();
101466
+ for (const element of model.index.values())
101467
+ if (element.size) sized.set(element.id, element.size.span);
101468
+ if (!sized.size) return [];
101469
+ const family = /* @__PURE__ */ new Map();
101470
+ const walk = (elements, ancestors) => {
101471
+ for (const element of elements) {
101472
+ if (sized.has(element.id))
101473
+ family.set(element.id, /* @__PURE__ */ new Set([...ancestors, ...subtreeIds(element)]));
101474
+ walk(element.children, [...ancestors, element.id]);
101475
+ }
101476
+ };
101477
+ walk(model.elements, []);
101478
+ const diagnostics = [];
101479
+ for (const node of scene.nodes) {
101480
+ const span = sized.get(node.id);
101481
+ if (!span) continue;
101482
+ const kin = family.get(node.id);
101483
+ const struck = scene.nodes.find((other) => !kin.has(other.id) && overlap(node, other));
101484
+ if (!struck) continue;
101485
+ diagnostics.push({
101486
+ code: "W0572",
101487
+ severity: "warning",
101488
+ message: `\`size\` on \`${node.id}\` overlaps \`${struck.id}\``,
101489
+ span,
101490
+ help: "the resize is applied as written \u2014 make it smaller, or give the drawing room with `order:`"
101491
+ });
101492
+ }
101493
+ return diagnostics;
101494
+ }
101159
101495
  function offsetDiagnostics(scene, model) {
101160
101496
  const hints = [];
101161
101497
  for (const span of scene.segmentHints?.stale ?? [])
@@ -101174,6 +101510,14 @@ function offsetDiagnostics(scene, model) {
101174
101510
  span,
101175
101511
  help: "the run carries a terminal, so it can only slide as far as the side that terminal sits on \u2014 pin the side with `ID.side`, or move the element instead"
101176
101512
  });
101513
+ for (const span of scene.clampedSizes ?? [])
101514
+ hints.push({
101515
+ code: "W0575",
101516
+ severity: "warning",
101517
+ message: "`size` was limited to keep this container around its own children",
101518
+ span,
101519
+ help: "a container is drawn around what it holds, which is not negotiable \u2014 resize or move the children first if the box has to be smaller"
101520
+ });
101177
101521
  const spans = /* @__PURE__ */ new Map();
101178
101522
  const walk = (elements, inherited) => {
101179
101523
  for (const element of elements) {
@@ -101184,8 +101528,9 @@ function offsetDiagnostics(scene, model) {
101184
101528
  };
101185
101529
  walk(model.elements);
101186
101530
  for (const flow of model.flows) if (flow.labelOffset) spans.set(flow.id, flow.labelOffset.span);
101187
- if (!spans.size) return hints;
101188
101531
  const overlap = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
101532
+ hints.push(...sizeOverlaps(scene, model, overlap));
101533
+ if (!spans.size) return hints;
101189
101534
  const leaves = scene.nodes.filter((node) => !node.container);
101190
101535
  const labels = scene.edges.flatMap((edge) => edge.labels);
101191
101536
  const diagnostics = [...hints];
@@ -101644,9 +101989,11 @@ function refuseScenicRepairs(scene, before) {
101644
101989
  }
101645
101990
  }
101646
101991
  function applyAuthorPositioning(scene, model) {
101992
+ const runsBefore = new Map(scene.edges.map((edge) => [edge.id, straightRuns(edge.pts).length]));
101993
+ const resized = applyContainerSizes(scene, model);
101647
101994
  const offsets = offsetAssignment(model);
101648
- if (offsets.size) {
101649
- const carried = applyNodeOffsets(scene, offsets);
101995
+ if (offsets.size || resized.size) {
101996
+ const carried = /* @__PURE__ */ new Set([...resized, ...applyNodeOffsets(scene, offsets)]);
101650
101997
  for (const edge of scene.edges) if (carried.has(edge.id)) edge.repairedFrom = void 0;
101651
101998
  const titles = titleBoxesOf(scene, model);
101652
101999
  reaimAfterOffsets(scene, titles, carried);
@@ -101662,6 +102009,12 @@ function applyAuthorPositioning(scene, model) {
101662
102009
  refuseScenicRepairs(scene, before);
101663
102010
  decoincideAfterOffsets(scene, titles, repairable);
101664
102011
  }
102012
+ const bent = new Set(
102013
+ scene.edges.filter(
102014
+ (edge) => carried.has(edge.id) && straightRuns(edge.pts).length > (runsBefore.get(edge.id) ?? 0)
102015
+ ).map((edge) => edge.id)
102016
+ );
102017
+ straightenCarriedRuns(scene, titles, bent);
101665
102018
  }
101666
102019
  const nudgedRuns = applySegmentOffsets(scene, model);
101667
102020
  if (nudgedRuns) {
@@ -101670,7 +102023,7 @@ function applyAuthorPositioning(scene, model) {
101670
102023
  edge.repairedFrom = void 0;
101671
102024
  }
101672
102025
  const nudgedLabels = scene.edges.some((edge) => edge.labels.some((label) => label.offset));
101673
- if (!offsets.size && !nudgedRuns && !nudgedLabels) return;
102026
+ if (!offsets.size && !resized.size && !nudgedRuns && !nudgedLabels) return;
101674
102027
  anchorFlowLabels(scene, titleBoxesOf(scene, model));
101675
102028
  fitCanvas(scene);
101676
102029
  }
@@ -101821,7 +102174,7 @@ async function chooseLayout(model, view) {
101821
102174
  }
101822
102175
  return best?.scene ?? base;
101823
102176
  }
101824
- var orderOption, semiInteractiveOption, ACTOR_MIN_WIDTH, PORT_PASS_ROUNDS, centerOf, isElkSource, elkPortId, DENSE_ENOUGH, DENSITY_GAIN, INGRESS_PARTITION, DEFAULT_INGRESS_KINDS, EGRESS_PARTITION, SLOT_SCALE, SIDE_TO_ELK, sideRequest, pointOn, SEGMENT_EPSILON, SLIDE_UNBOUNDED, SEAT_INSET, LEFT_MARGIN, RECLAIM_GAP, MIN_RECLAIM, MIN_RECLAIM_KEPT, RECLAIM_SHARE, RECLAIM_UNBUYABLE_TIER, RECLAIM_TRADE_SHARE, RECLAIM_TRADE_MIN, RECLAIM_LIFT_SHARE, BORDER_AIR, ARROW_ROOM2, RUN_CLEAR, ASPECT_TARGETS, pathLength2;
102177
+ var orderOption, semiInteractiveOption, ACTOR_MIN_WIDTH, PORT_PASS_ROUNDS, centerOf, isElkSource, elkPortId, DENSE_ENOUGH, DENSITY_GAIN, INGRESS_PARTITION, DEFAULT_INGRESS_KINDS, EGRESS_PARTITION, SLOT_SCALE, SIDE_TO_ELK, sideRequest, ON_BORDER, pointOn, containerPad, MIN_SIBLING_GAP, kidsOf, AXIS, SEGMENT_EPSILON, SLIDE_UNBOUNDED, SEAT_INSET, LEFT_MARGIN, RECLAIM_GAP, MIN_RECLAIM, MIN_RECLAIM_KEPT, RECLAIM_SHARE, RECLAIM_UNBUYABLE_TIER, RECLAIM_TRADE_SHARE, RECLAIM_TRADE_MIN, RECLAIM_LIFT_SHARE, BORDER_AIR, ARROW_ROOM2, RUN_CLEAR, ASPECT_TARGETS, pathLength2;
101825
102178
  var init_scene_layout = __esm({
101826
102179
  "src/scene-layout.ts"() {
101827
102180
  "use strict";
@@ -101855,7 +102208,15 @@ var init_scene_layout = __esm({
101855
102208
  bottom: "SOUTH"
101856
102209
  };
101857
102210
  sideRequest = (side) => side ? { value: side } : void 0;
102211
+ ON_BORDER = 1;
101858
102212
  pointOn = (point, box) => point.x >= box.x - 1 && point.x <= box.x + box.width + 1 && point.y >= box.y - 1 && point.y <= box.y + box.height + 1;
102213
+ containerPad = (model) => model.style.containerPadding ?? (model.style.compact ? 7 : 9);
102214
+ MIN_SIBLING_GAP = 20;
102215
+ kidsOf = (box) => box.element.children.map((child) => box.nodeById.get(child.id)).filter((child) => !!child);
102216
+ AXIS = {
102217
+ x: { min: "x", size: "width" },
102218
+ y: { min: "y", size: "height" }
102219
+ };
101859
102220
  SEGMENT_EPSILON = 0.5;
101860
102221
  SLIDE_UNBOUNDED = 1e4;
101861
102222
  SEAT_INSET = 4;
@@ -103908,7 +104269,7 @@ CORE_I -> PARTNER "Nightly export" (SFTP/22)
103908
104269
  };
103909
104270
  args = process.argv.slice(2);
103910
104271
  command = args[0];
103911
- version = true ? "1.0.0-RC17" : pkg.version;
104272
+ version = true ? "1.0.0-RC18" : pkg.version;
103912
104273
  VALUE_FLAGS = /* @__PURE__ */ new Set(["-o", "--format", "--theme"]);
103913
104274
  positionalFile = () => {
103914
104275
  for (let index = 0; index < args.length; index++) {
package/dist/cairn.mjs CHANGED
@@ -93129,57 +93129,93 @@ function parseElement(p, sourceToken, parent) {
93129
93129
  (parent ? parent.children : model.elements).push(element);
93130
93130
  }
93131
93131
  function splitEndpoint(token, flow, end) {
93132
- const dot = token.text.lastIndexOf(".");
93133
- if (dot <= 0 || dot === token.text.length - 1) return null;
93134
- const base = token.text.slice(0, dot);
93135
- const suffix = token.text.slice(dot + 1);
93136
- return {
93137
- flow,
93138
- end,
93139
- raw: token.text,
93140
- rawSpan: token.span,
93141
- base,
93142
- baseSpan: { line: token.span.line, col: token.span.col, len: base.length },
93143
- suffix,
93144
- suffixSpan: { line: token.span.line, col: token.span.col + dot + 1, len: suffix.length }
93145
- };
93132
+ const texts = token.text.split(".");
93133
+ if (texts.length < 2 || texts.some((text) => !text)) return null;
93134
+ let col = token.span.col;
93135
+ const parts = texts.map((text) => {
93136
+ const span = { line: token.span.line, col, len: text.length };
93137
+ col += text.length + 1;
93138
+ return { text, span };
93139
+ });
93140
+ return { flow, end, raw: token.text, rawSpan: token.span, parts };
93146
93141
  }
93147
93142
  function resolveEndpointSuffixes(splits, model, diagnostics) {
93143
+ const sideOf2 = (text) => ATTACH_SIDES.includes(text);
93144
+ const roleOf = (text) => ATTACH_ROLES.includes(text);
93148
93145
  for (const split of splits) {
93149
- const isSide = ATTACH_SIDES.includes(split.suffix);
93150
- const isRole = ATTACH_ROLES.includes(split.suffix);
93151
93146
  if (model.index.has(split.raw)) {
93152
- if (isSide || isRole)
93147
+ let head = split.parts.length;
93148
+ while (head > 1 && (sideOf2(split.parts[head - 1].text) || roleOf(split.parts[head - 1].text)))
93149
+ head--;
93150
+ const shadowed = split.parts.slice(head).map((part) => part.text);
93151
+ if (shadowed.length) {
93152
+ const base = split.parts.slice(0, head).map((part) => part.text).join(".");
93153
+ const reading = [
93154
+ shadowed.some((text) => roleOf(text)) ? "a role" : "",
93155
+ shadowed.some((text) => sideOf2(text)) ? "an attachment side" : ""
93156
+ ].filter(Boolean);
93153
93157
  diagnostics.push({
93154
93158
  code: "W0571",
93155
93159
  severity: "warning",
93156
- message: `\`${split.raw}\` is a declared element, so \`.${split.suffix}\` is not read as ${isSide ? "an attachment side" : "a role"}`,
93160
+ message: `\`${split.raw}\` is a declared element, so \`.${shadowed.join(".")}\` is not read as ${reading.join(" and ")}`,
93157
93161
  span: split.rawSpan,
93158
93162
  note: "a declared id always wins over the `ID.side` reading",
93159
- help: isSide ? `rename the element if you meant to attach the flow to the ${split.suffix} side of \`${split.base}\`` : `rename the element if you meant \`${split.base}\` to be the ${split.suffix} of the queue at the other end`
93163
+ help: `rename the element if you meant \`.${shadowed.join(".")}\` on \`${base}\``
93160
93164
  });
93165
+ }
93161
93166
  continue;
93162
93167
  }
93163
- if (!model.index.has(split.base)) continue;
93164
- if (!isSide && !isRole)
93165
- diagnostics.push({
93166
- code: "E0223",
93167
- severity: "error",
93168
- message: `unknown endpoint suffix \`${split.suffix}\``,
93169
- span: split.suffixSpan,
93170
- note: "an endpoint names either the side it attaches to, as the diagram is read, or its role towards a queue",
93171
- help: "use `left`, `right`, `top` or `bottom` \u2014 `APP.right -> DB.left` \u2014 or `producer` / `consumer`, e.g. `CAPTURE.producer -> EVENTS`"
93172
- });
93173
- const side = isSide ? { value: split.suffix, span: split.suffixSpan } : void 0;
93174
- const role = isRole ? { value: split.suffix, span: split.suffixSpan } : void 0;
93168
+ let cut = 0;
93169
+ for (let take = split.parts.length - 1; take >= 1; take--) {
93170
+ const base = split.parts.slice(0, take).map((part) => part.text).join(".");
93171
+ if (model.index.has(base)) {
93172
+ cut = take;
93173
+ break;
93174
+ }
93175
+ }
93176
+ if (!cut) continue;
93177
+ let side;
93178
+ let role;
93179
+ for (const part of split.parts.slice(cut)) {
93180
+ const partIsSide = sideOf2(part.text);
93181
+ const partIsRole = roleOf(part.text);
93182
+ if (!partIsSide && !partIsRole) {
93183
+ diagnostics.push({
93184
+ code: "E0223",
93185
+ severity: "error",
93186
+ message: `unknown endpoint suffix \`${part.text}\``,
93187
+ span: part.span,
93188
+ note: "an endpoint names either the side it attaches to, as the diagram is read, or its role towards a queue",
93189
+ help: "use `left`, `right`, `top` or `bottom` \u2014 `APP.right -> DB.left` \u2014 or `producer` / `consumer`, e.g. `CAPTURE.producer -> EVENTS`, or one of each: `CAPTURE.producer.top -> EVENTS`"
93190
+ });
93191
+ continue;
93192
+ }
93193
+ const held = partIsSide ? side : role;
93194
+ if (held) {
93195
+ diagnostics.push({
93196
+ code: "E0227",
93197
+ severity: "error",
93198
+ message: `this endpoint already names ${partIsSide ? `the \`${held.value}\` side` : `the \`${held.value}\` role`}`,
93199
+ span: part.span,
93200
+ note: `\`${split.raw}\` names two ${partIsSide ? "attachment sides" : "roles"}, and only one can hold`,
93201
+ help: partIsSide ? "keep one side \u2014 a side and a *role* do accumulate, e.g. `CAPTURE.producer.top`" : "keep one role \u2014 a role and a *side* do accumulate, e.g. `CAPTURE.producer.top`"
93202
+ });
93203
+ continue;
93204
+ }
93205
+ if (partIsSide) side = { value: part.text, span: part.span };
93206
+ else role = { value: part.text, span: part.span };
93207
+ }
93208
+ const baseParts = split.parts.slice(0, cut);
93209
+ const baseText = baseParts.map((part) => part.text).join(".");
93210
+ const baseSpan = { line: split.rawSpan.line, col: split.rawSpan.col, len: baseText.length };
93175
93211
  if (split.end === "from") {
93176
- split.flow.from = split.base;
93177
- split.flow.fromSpan = split.baseSpan;
93212
+ split.flow.from = baseText;
93213
+ split.flow.fromSpan = baseSpan;
93178
93214
  split.flow.fromSide = side;
93179
93215
  split.flow.fromRole = role;
93180
93216
  } else {
93181
- split.flow.to = split.base;
93182
- split.flow.toSpan = split.baseSpan;
93217
+ split.flow.to = baseText;
93218
+ split.flow.toSpan = baseSpan;
93183
93219
  split.flow.toSide = side;
93184
93220
  split.flow.toRole = role;
93185
93221
  }
@@ -93229,9 +93265,9 @@ function readOffsetPair(p) {
93229
93265
  span: { line: dx.span.line, col: dx.span.col, len: dy.span.col + dy.span.len - dx.span.col }
93230
93266
  };
93231
93267
  }
93232
- function tryOffsetEntry(p, parent) {
93268
+ function tryPairEntry(p, parent, entry, put) {
93233
93269
  const { matchToken, advance, reportCoded, save, restore, syncToNextLine } = p;
93234
- if (!parent || !matchToken("id", "offset")) return false;
93270
+ if (!parent || !matchToken("id", entry.key)) return false;
93235
93271
  const mark = save();
93236
93272
  const keyToken = advance();
93237
93273
  if (!matchToken("colon")) {
@@ -93241,18 +93277,39 @@ function tryOffsetEntry(p, parent) {
93241
93277
  advance();
93242
93278
  const pair2 = readOffsetPair(p);
93243
93279
  if (!pair2) {
93244
- reportCoded(
93245
- "E0109",
93246
- "`offset` expects two whole numbers \u2014 `offset: <dx>, <dy>`",
93247
- keyToken.span,
93248
- "e.g. `offset: 40, -20` \u2014 40px right and 20px up from where the layout put it"
93249
- );
93280
+ reportCoded(entry.code, entry.message, keyToken.span, entry.help);
93250
93281
  syncToNextLine();
93251
93282
  return true;
93252
93283
  }
93253
- parent.offset = pair2;
93284
+ put(parent, pair2);
93254
93285
  return true;
93255
93286
  }
93287
+ var tryOffsetEntry = (p, parent) => tryPairEntry(
93288
+ p,
93289
+ parent,
93290
+ {
93291
+ key: "offset",
93292
+ code: "E0109",
93293
+ message: "`offset` expects two whole numbers \u2014 `offset: <dx>, <dy>`",
93294
+ help: "e.g. `offset: 40, -20` \u2014 40px right and 20px up from where the layout put it"
93295
+ },
93296
+ (element, pair2) => {
93297
+ element.offset = pair2;
93298
+ }
93299
+ );
93300
+ var trySizeEntry = (p, parent) => tryPairEntry(
93301
+ p,
93302
+ parent,
93303
+ {
93304
+ key: "size",
93305
+ code: "E0110",
93306
+ message: "`size` expects two whole numbers \u2014 `size: <dw>, <dh>`",
93307
+ help: "e.g. `size: 120, 40` \u2014 120px wider and 40px taller than the layout made it"
93308
+ },
93309
+ (element, pair2) => {
93310
+ element.size = { dw: pair2.dx, dh: pair2.dy, span: pair2.span };
93311
+ }
93312
+ );
93256
93313
  function tryLogoEntry(p, parent) {
93257
93314
  const { matchToken, advance, reportCoded, save, restore, syncToNextLine } = p;
93258
93315
  if (!parent || !matchToken("id", "logo")) return false;
@@ -93496,7 +93553,7 @@ function parse(src) {
93496
93553
  }
93497
93554
  if (tryStyleBlock(parent)) return;
93498
93555
  if (tryOrderEntry(parser, parent)) return;
93499
- if (tryOffsetEntry(parser, parent)) return;
93556
+ if (tryOffsetEntry(parser, parent) || trySizeEntry(parser, parent)) return;
93500
93557
  if (tryLogoEntry(parser, parent)) return;
93501
93558
  if (!parent && tryLegendBlock(parser)) return;
93502
93559
  if (!parent && tryBusinessObject(parser)) return;
@@ -94648,6 +94705,7 @@ function validate(model) {
94648
94705
  ...checkNesting(elements, view),
94649
94706
  ...checkFlows(model, view),
94650
94707
  ...checkEndpointRoles(model, view),
94708
+ ...checkSizes(elements, view),
94651
94709
  ...checkLogos(elements, view),
94652
94710
  ...checkBusinessObjects(model, view),
94653
94711
  ...checkMinimumCounts(elements, model, view),
@@ -94690,6 +94748,31 @@ function checkUnknownKinds(elements, view) {
94690
94748
  }
94691
94749
  return diagnostics;
94692
94750
  }
94751
+ function checkSizes(elements, view) {
94752
+ const diagnostics = [];
94753
+ for (const element of elements) {
94754
+ if (!element.size) continue;
94755
+ if (!view.containerKinds.includes(element.kind))
94756
+ diagnostics.push({
94757
+ code: "E0226",
94758
+ severity: "error",
94759
+ message: `\`${element.kind}\` is not a container, so it has no \`size\` to give`,
94760
+ span: element.size.span,
94761
+ note: `the \`${view.name}\` view accepts \`size\` on: ${view.containerKinds.join(", ")}`,
94762
+ help: "a leaf box is sized by its own label \u2014 set `label-padding:` in `style` to change how tightly, or nudge this one with `offset:`"
94763
+ });
94764
+ else if (!element.children.length)
94765
+ diagnostics.push({
94766
+ code: "E0226",
94767
+ severity: "error",
94768
+ message: `\`${element.id}\` holds nothing, so it is drawn as a plain box rather than a container`,
94769
+ span: element.size.span,
94770
+ note: "a container's size is the room around what it holds \u2014 with no children there is no room to give",
94771
+ help: "put the elements it contains inside it, or drop the `size:` and nudge this one with `offset:`"
94772
+ });
94773
+ }
94774
+ return diagnostics;
94775
+ }
94693
94776
  function checkLogos(elements, view) {
94694
94777
  const diagnostics = [];
94695
94778
  const logoKinds = view.logoKinds ?? [];
@@ -98089,6 +98172,58 @@ function applySwap(scene, pair2, swap, leaves) {
98089
98172
  return true;
98090
98173
  }
98091
98174
  }
98175
+ function straightenCarriedRuns(scene, titleBoxes, only) {
98176
+ if (!only.size) return;
98177
+ const leaves = scene.nodes.filter((node) => !node.container);
98178
+ if (!leaves.length) return;
98179
+ const ctx = createTidyContext(scene, leaves, titleBoxes, false);
98180
+ const strikesTitle = (at, from, to, horizontal) => {
98181
+ const lo = Math.min(from, to);
98182
+ const hi = Math.max(from, to);
98183
+ return titleBoxes.some(
98184
+ (band) => horizontal ? at > band.y && at < band.y + band.height && lo < band.x + band.width && hi > band.x : at > band.x && at < band.x + band.width && lo < band.y + band.height && hi > band.y
98185
+ );
98186
+ };
98187
+ const taken = (seat, mine) => scene.edges.some(
98188
+ (other) => other !== mine && other.pts.length >= 2 && [other.pts[0], other.pts[other.pts.length - 1]].some(
98189
+ (end) => Math.abs(end.x - seat.x) < MIN_ATTACH_GAP && Math.abs(end.y - seat.y) < MIN_ATTACH_GAP
98190
+ )
98191
+ );
98192
+ for (const edge of scene.edges) {
98193
+ if (!only.has(edge.id) || edge.pts.length < 3) continue;
98194
+ if (edge.pinned?.start || edge.pinned?.end) continue;
98195
+ const src = sideOf(edge.pts[0], leaves);
98196
+ const dst = sideOf(edge.pts[edge.pts.length - 1], leaves);
98197
+ if (!src || !dst || src.node === dst.node) continue;
98198
+ const alongY = src.side === "east" || src.side === "west";
98199
+ const rightwards = dst.node.x > src.node.x;
98200
+ const downwards = dst.node.y > src.node.y;
98201
+ const srcSide = alongY ? rightwards ? "east" : "west" : downwards ? "south" : "north";
98202
+ const dstSide = alongY ? rightwards ? "west" : "east" : downwards ? "north" : "south";
98203
+ const span = (node) => alongY ? [node.y + MIN_SIDE_INSET, node.y + node.height - MIN_SIDE_INSET] : [node.x + MIN_SIDE_INSET, node.x + node.width - MIN_SIDE_INSET];
98204
+ const [srcLo, srcHi] = span(src.node);
98205
+ const [dstLo, dstHi] = span(dst.node);
98206
+ const low = Math.max(srcLo, dstLo);
98207
+ const high = Math.min(srcHi, dstHi);
98208
+ if (low > high) continue;
98209
+ const at = Math.round((low + high) / 2);
98210
+ const seatOnFace = (node, side) => side === "north" ? { x: at, y: node.y } : side === "south" ? { x: at, y: node.y + node.height } : side === "west" ? { x: node.x, y: at } : { x: node.x + node.width, y: at };
98211
+ const from = seatOnFace(src.node, srcSide);
98212
+ const to = seatOnFace(dst.node, dstSide);
98213
+ const travel = alongY ? to.x - from.x : to.y - from.y;
98214
+ const outward = (side) => side === "east" || side === "south" ? 1 : -1;
98215
+ if (travel === 0 || Math.sign(travel) !== outward(srcSide)) continue;
98216
+ const lane = alongY ? [from.x, to.x] : [from.y, to.y];
98217
+ const runFrom = Math.min(lane[0], lane[1]);
98218
+ const runTo = Math.max(lane[0], lane[1]);
98219
+ if (ctx.runHitsNode(!alongY, at, runFrom, runTo)) continue;
98220
+ if (strikesTitle(at, runFrom, runTo, alongY)) continue;
98221
+ const others = ctx.runsExcept(edge.id);
98222
+ if (!ctx.runIsClear(alongY ? others.horizontal : others.vertical, at, runFrom, runTo)) continue;
98223
+ if (taken(from, edge) || taken(to, edge)) continue;
98224
+ edge.pts = [from, to];
98225
+ }
98226
+ }
98092
98227
  function swapCrossingSiblingSeats(scene) {
98093
98228
  const leaves = scene.nodes.filter((node) => !node.container);
98094
98229
  const endsOf = (edge) => [
@@ -100712,7 +100847,178 @@ function stampLabelOffsets(edges, model) {
100712
100847
  for (const label of edge.labels) label.offset = { dx: declared.dx, dy: declared.dy };
100713
100848
  }
100714
100849
  }
100850
+ var ON_BORDER = 1;
100715
100851
  var pointOn = (point, box) => point.x >= box.x - 1 && point.x <= box.x + box.width + 1 && point.y >= box.y - 1 && point.y <= box.y + box.height + 1;
100852
+ function containerSizeBounds(scene, model) {
100853
+ const nodeById = new Map(scene.nodes.map((node) => [node.id, node]));
100854
+ const pad = containerPad(model);
100855
+ const bounds = /* @__PURE__ */ new Map();
100856
+ const walk = (elements) => {
100857
+ for (const element of elements) {
100858
+ walk(element.children);
100859
+ const node = nodeById.get(element.id);
100860
+ if (node?.container) bounds.set(element.id, contentFloor({ node, element, nodeById, pad }));
100861
+ }
100862
+ };
100863
+ walk(model.elements);
100864
+ return bounds;
100865
+ }
100866
+ var containerPad = (model) => model.style.containerPadding ?? (model.style.compact ? 7 : 9);
100867
+ var MIN_SIBLING_GAP = 20;
100868
+ var kidsOf = (box) => box.element.children.map((child) => box.nodeById.get(child.id)).filter((child) => !!child);
100869
+ function freeBands(box, axis) {
100870
+ const { node, pad } = box;
100871
+ const { min, size } = AXIS[axis];
100872
+ const spans = kidsOf(box).map((kid) => ({ start: kid[min], end: kid[min] + kid[size] })).sort((a, b) => a.start - b.start || a.end - b.end);
100873
+ const merged = [];
100874
+ for (const span of spans) {
100875
+ const last = merged[merged.length - 1];
100876
+ if (last && span.start <= last.end) last.end = Math.max(last.end, span.end);
100877
+ else merged.push({ ...span });
100878
+ }
100879
+ if (!merged.length) return [];
100880
+ const bands = [];
100881
+ const add = (start, end, keep) => {
100882
+ if (end - start > keep) bands.push({ end, spare: end - start - keep });
100883
+ };
100884
+ add(node[min], merged[0].start, pad);
100885
+ for (let i = 0; i + 1 < merged.length; i++)
100886
+ add(merged[i].end, merged[i + 1].start, MIN_SIBLING_GAP);
100887
+ add(merged[merged.length - 1].end, node[min] + node[size], pad);
100888
+ return bands;
100889
+ }
100890
+ var AXIS = {
100891
+ x: { min: "x", size: "width" },
100892
+ y: { min: "y", size: "height" }
100893
+ };
100894
+ function holdsChildren(box) {
100895
+ const { node, pad } = box;
100896
+ const kids = kidsOf(box);
100897
+ if (!kids.length) return { minWidth: pad * 2, minHeight: pad * 2 };
100898
+ return {
100899
+ minWidth: Math.max(...kids.map((kid) => kid.x + kid.width)) + pad - node.x,
100900
+ minHeight: Math.max(...kids.map((kid) => kid.y + kid.height)) + pad - node.y
100901
+ };
100902
+ }
100903
+ function contentFloor(box) {
100904
+ const spare = (axis) => freeBands(box, axis).reduce((total, band) => total + band.spare, 0);
100905
+ return { minWidth: box.node.width - spare("x"), minHeight: box.node.height - spare("y") };
100906
+ }
100907
+ function squeezeInside(box, axis, target) {
100908
+ const { min, size } = AXIS[axis];
100909
+ const need = box.node[size] - target;
100910
+ if (need <= 0) return;
100911
+ const bands = freeBands(box, axis);
100912
+ const spare = bands.reduce((total, band) => total + band.spare, 0);
100913
+ if (spare <= 0) return;
100914
+ const share = Math.min(1, need / spare);
100915
+ const shiftAt = (at) => bands.reduce((total, band) => at >= band.end ? total + band.spare * share : total, 0);
100916
+ for (const child of box.element.children) {
100917
+ const kid = box.nodeById.get(child.id);
100918
+ if (!kid) continue;
100919
+ const shift = shiftAt(kid[min]);
100920
+ if (!shift) continue;
100921
+ for (const id of subtreeIds(child)) {
100922
+ const inside = box.nodeById.get(id);
100923
+ if (inside) inside[min] -= shift;
100924
+ }
100925
+ }
100926
+ }
100927
+ function applyContainerSizes(scene, model) {
100928
+ const carried = /* @__PURE__ */ new Set();
100929
+ const wanted = /* @__PURE__ */ new Map();
100930
+ const walk = (elements) => {
100931
+ for (const element of elements) {
100932
+ walk(element.children);
100933
+ if (element.size) wanted.set(element.id, element.size);
100934
+ }
100935
+ };
100936
+ walk(model.elements);
100937
+ if (!wanted.size) return carried;
100938
+ const nodeById = new Map(scene.nodes.map((node) => [node.id, node]));
100939
+ const pad = containerPad(model);
100940
+ const clampedSpans = /* @__PURE__ */ new Set();
100941
+ const applied = /* @__PURE__ */ new Map();
100942
+ const before = new Map(
100943
+ scene.nodes.map((node) => [
100944
+ node.id,
100945
+ { x: node.x, y: node.y, width: node.width, height: node.height }
100946
+ ])
100947
+ );
100948
+ for (const [id, spec] of wanted) {
100949
+ const node = nodeById.get(id);
100950
+ const element = model.index.get(id);
100951
+ if (!node || !element) continue;
100952
+ const box = { node, element, nodeById, pad };
100953
+ const floor = contentFloor(box);
100954
+ const width = Math.max(node.width + spec.dw, floor.minWidth);
100955
+ const height = Math.max(node.height + spec.dh, floor.minHeight);
100956
+ if (width !== node.width + spec.dw || height !== node.height + spec.dh)
100957
+ clampedSpans.add(spec.span);
100958
+ squeezeInside(box, "x", width);
100959
+ squeezeInside(box, "y", height);
100960
+ node.width = width;
100961
+ node.height = height;
100962
+ }
100963
+ const containWithin = (elements) => {
100964
+ for (const element of elements) {
100965
+ containWithin(element.children);
100966
+ const node = nodeById.get(element.id);
100967
+ if (!node?.container) continue;
100968
+ const holds = holdsChildren({ node, element, nodeById, pad });
100969
+ node.width = Math.max(node.width, holds.minWidth);
100970
+ node.height = Math.max(node.height, holds.minHeight);
100971
+ }
100972
+ };
100973
+ containWithin(model.elements);
100974
+ for (const id of wanted.keys()) {
100975
+ const node = nodeById.get(id);
100976
+ const was = before.get(id);
100977
+ if (node && was)
100978
+ applied.set(id, { dw: node.width - was.width, dh: node.height - was.height });
100979
+ }
100980
+ if (clampedSpans.size) scene.clampedSizes = clampedSpans;
100981
+ scene.appliedSizes = applied;
100982
+ const seats = [];
100983
+ for (const node of scene.nodes) {
100984
+ const was = before.get(node.id);
100985
+ if (!was) continue;
100986
+ const moved = {
100987
+ dx: node.x - was.x,
100988
+ dy: node.y - was.y,
100989
+ dw: node.width - was.width,
100990
+ dh: node.height - was.height
100991
+ };
100992
+ if (!moved.dx && !moved.dy && !moved.dw && !moved.dh) continue;
100993
+ seats.push({ box: was, ...moved });
100994
+ }
100995
+ seats.sort((a, b) => a.box.width * a.box.height - b.box.width * b.box.height);
100996
+ if (!seats.length) return carried;
100997
+ for (const edge of scene.edges) {
100998
+ if (edge.pts.length < 2) continue;
100999
+ for (const which of ["first", "last"]) {
101000
+ const terminal = edge.pts[which === "first" ? 0 : edge.pts.length - 1];
101001
+ const seat = seats.find((candidate) => pointOn(terminal, candidate.box));
101002
+ if (!seat) continue;
101003
+ const onRight = terminal.x >= seat.box.x + seat.box.width - ON_BORDER;
101004
+ const onBottom = terminal.y >= seat.box.y + seat.box.height - ON_BORDER;
101005
+ const delta = {
101006
+ dx: seat.dx + (onRight ? seat.dw : 0),
101007
+ dy: seat.dy + (onBottom ? seat.dh : 0)
101008
+ };
101009
+ if (!delta.dx && !delta.dy) continue;
101010
+ carried.add(edge.id);
101011
+ carryTerminal(edge, which, delta, {
101012
+ x: seat.box.x + seat.dx,
101013
+ y: seat.box.y + seat.dy,
101014
+ width: seat.box.width + seat.dw,
101015
+ height: seat.box.height + seat.dh
101016
+ });
101017
+ }
101018
+ }
101019
+ for (const edge of scene.edges) if (carried.has(edge.id)) dropRedundantPoints(edge.pts);
101020
+ return carried;
101021
+ }
100716
101022
  function applyNodeOffsets(scene, offsetOf) {
100717
101023
  const carried = /* @__PURE__ */ new Set();
100718
101024
  if (!offsetOf.size) return carried;
@@ -100777,19 +101083,36 @@ function applyNodeOffsets(scene, offsetOf) {
100777
101083
  const seat = seats.find((candidate) => pointOn(terminal, candidate.box));
100778
101084
  if (!seat || !seat.delta.dx && !seat.delta.dy) continue;
100779
101085
  carried.add(edge.id);
100780
- const neighbourIndex = end === 0 ? 1 : edge.pts.length - 2;
100781
- const neighbour = edge.pts[neighbourIndex];
100782
- const wasHorizontal = Math.abs(terminal.y - neighbour.y) < 0.5;
100783
- terminal.x += seat.delta.dx;
100784
- terminal.y += seat.delta.dy;
100785
- const elbow = wasHorizontal ? { x: neighbour.x, y: terminal.y } : { x: terminal.x, y: neighbour.y };
100786
- const degenerate = Math.abs(elbow.x - terminal.x) < 0.5 && Math.abs(elbow.y - terminal.y) < 0.5 || Math.abs(elbow.x - neighbour.x) < 0.5 && Math.abs(elbow.y - neighbour.y) < 0.5;
100787
- if (!degenerate) edge.pts.splice(end === 0 ? 1 : edge.pts.length - 1, 0, elbow);
101086
+ carryTerminal(edge, end === 0 ? "first" : "last", seat.delta, {
101087
+ x: seat.box.x + seat.delta.dx,
101088
+ y: seat.box.y + seat.delta.dy,
101089
+ width: seat.box.width,
101090
+ height: seat.box.height
101091
+ });
100788
101092
  }
100789
101093
  }
100790
101094
  for (const edge of scene.edges) if (carried.has(edge.id)) dropRedundantPoints(edge.pts);
100791
101095
  return carried;
100792
101096
  }
101097
+ function carryTerminal(edge, which, delta, face) {
101098
+ const end = which === "first" ? 0 : edge.pts.length - 1;
101099
+ const terminal = edge.pts[end];
101100
+ const neighbour = edge.pts[which === "first" ? 1 : edge.pts.length - 2];
101101
+ const wasHorizontal = Math.abs(terminal.y - neighbour.y) < SEGMENT_EPSILON;
101102
+ terminal.x += delta.dx;
101103
+ terminal.y += delta.dy;
101104
+ const slide = wasHorizontal ? { along: "y", low: face.y, high: face.y + face.height } : { along: "x", low: face.x, high: face.x + face.width };
101105
+ const residual = neighbour[slide.along] - terminal[slide.along];
101106
+ const seat = Math.min(
101107
+ Math.max(neighbour[slide.along], slide.low + SEAT_INSET),
101108
+ Math.max(slide.high - SEAT_INSET, slide.low + SEAT_INSET)
101109
+ );
101110
+ if (Math.abs(residual) < ARROW_ROOM2 && Math.abs(seat - neighbour[slide.along]) < SEGMENT_EPSILON)
101111
+ terminal[slide.along] = seat;
101112
+ const elbow = wasHorizontal ? { x: neighbour.x, y: terminal.y } : { x: terminal.x, y: neighbour.y };
101113
+ const degenerate = Math.abs(elbow.x - terminal.x) < SEGMENT_EPSILON && Math.abs(elbow.y - terminal.y) < SEGMENT_EPSILON || Math.abs(elbow.x - neighbour.x) < SEGMENT_EPSILON && Math.abs(elbow.y - neighbour.y) < SEGMENT_EPSILON;
101114
+ if (!degenerate) edge.pts.splice(which === "first" ? 1 : edge.pts.length - 1, 0, elbow);
101115
+ }
100793
101116
  function dropRedundantPoints(pts) {
100794
101117
  const inLine = (a, b, c) => Math.abs(a.x - b.x) < SEGMENT_EPSILON && Math.abs(b.x - c.x) < SEGMENT_EPSILON || Math.abs(a.y - b.y) < SEGMENT_EPSILON && Math.abs(b.y - c.y) < SEGMENT_EPSILON;
100795
101118
  for (let i = pts.length - 2; i >= 1; i--)
@@ -100919,6 +101242,37 @@ function shiftIntoCanvas(scene) {
100919
101242
  scene.width += shiftX;
100920
101243
  scene.height += shiftY;
100921
101244
  }
101245
+ function sizeOverlaps(scene, model, overlap) {
101246
+ const sized = /* @__PURE__ */ new Map();
101247
+ for (const element of model.index.values())
101248
+ if (element.size) sized.set(element.id, element.size.span);
101249
+ if (!sized.size) return [];
101250
+ const family = /* @__PURE__ */ new Map();
101251
+ const walk = (elements, ancestors) => {
101252
+ for (const element of elements) {
101253
+ if (sized.has(element.id))
101254
+ family.set(element.id, /* @__PURE__ */ new Set([...ancestors, ...subtreeIds(element)]));
101255
+ walk(element.children, [...ancestors, element.id]);
101256
+ }
101257
+ };
101258
+ walk(model.elements, []);
101259
+ const diagnostics = [];
101260
+ for (const node of scene.nodes) {
101261
+ const span = sized.get(node.id);
101262
+ if (!span) continue;
101263
+ const kin = family.get(node.id);
101264
+ const struck = scene.nodes.find((other) => !kin.has(other.id) && overlap(node, other));
101265
+ if (!struck) continue;
101266
+ diagnostics.push({
101267
+ code: "W0572",
101268
+ severity: "warning",
101269
+ message: `\`size\` on \`${node.id}\` overlaps \`${struck.id}\``,
101270
+ span,
101271
+ help: "the resize is applied as written \u2014 make it smaller, or give the drawing room with `order:`"
101272
+ });
101273
+ }
101274
+ return diagnostics;
101275
+ }
100922
101276
  function offsetDiagnostics(scene, model) {
100923
101277
  const hints = [];
100924
101278
  for (const span of scene.segmentHints?.stale ?? [])
@@ -100937,6 +101291,14 @@ function offsetDiagnostics(scene, model) {
100937
101291
  span,
100938
101292
  help: "the run carries a terminal, so it can only slide as far as the side that terminal sits on \u2014 pin the side with `ID.side`, or move the element instead"
100939
101293
  });
101294
+ for (const span of scene.clampedSizes ?? [])
101295
+ hints.push({
101296
+ code: "W0575",
101297
+ severity: "warning",
101298
+ message: "`size` was limited to keep this container around its own children",
101299
+ span,
101300
+ help: "a container is drawn around what it holds, which is not negotiable \u2014 resize or move the children first if the box has to be smaller"
101301
+ });
100940
101302
  const spans = /* @__PURE__ */ new Map();
100941
101303
  const walk = (elements, inherited) => {
100942
101304
  for (const element of elements) {
@@ -100947,8 +101309,9 @@ function offsetDiagnostics(scene, model) {
100947
101309
  };
100948
101310
  walk(model.elements);
100949
101311
  for (const flow of model.flows) if (flow.labelOffset) spans.set(flow.id, flow.labelOffset.span);
100950
- if (!spans.size) return hints;
100951
101312
  const overlap = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
101313
+ hints.push(...sizeOverlaps(scene, model, overlap));
101314
+ if (!spans.size) return hints;
100952
101315
  const leaves = scene.nodes.filter((node) => !node.container);
100953
101316
  const labels = scene.edges.flatMap((edge) => edge.labels);
100954
101317
  const diagnostics = [...hints];
@@ -101426,9 +101789,11 @@ function refuseScenicRepairs(scene, before) {
101426
101789
  }
101427
101790
  }
101428
101791
  function applyAuthorPositioning(scene, model) {
101792
+ const runsBefore = new Map(scene.edges.map((edge) => [edge.id, straightRuns(edge.pts).length]));
101793
+ const resized = applyContainerSizes(scene, model);
101429
101794
  const offsets = offsetAssignment(model);
101430
- if (offsets.size) {
101431
- const carried = applyNodeOffsets(scene, offsets);
101795
+ if (offsets.size || resized.size) {
101796
+ const carried = /* @__PURE__ */ new Set([...resized, ...applyNodeOffsets(scene, offsets)]);
101432
101797
  for (const edge of scene.edges) if (carried.has(edge.id)) edge.repairedFrom = void 0;
101433
101798
  const titles = titleBoxesOf(scene, model);
101434
101799
  reaimAfterOffsets(scene, titles, carried);
@@ -101444,6 +101809,12 @@ function applyAuthorPositioning(scene, model) {
101444
101809
  refuseScenicRepairs(scene, before);
101445
101810
  decoincideAfterOffsets(scene, titles, repairable);
101446
101811
  }
101812
+ const bent = new Set(
101813
+ scene.edges.filter(
101814
+ (edge) => carried.has(edge.id) && straightRuns(edge.pts).length > (runsBefore.get(edge.id) ?? 0)
101815
+ ).map((edge) => edge.id)
101816
+ );
101817
+ straightenCarriedRuns(scene, titles, bent);
101447
101818
  }
101448
101819
  const nudgedRuns = applySegmentOffsets(scene, model);
101449
101820
  if (nudgedRuns) {
@@ -101452,7 +101823,7 @@ function applyAuthorPositioning(scene, model) {
101452
101823
  edge.repairedFrom = void 0;
101453
101824
  }
101454
101825
  const nudgedLabels = scene.edges.some((edge) => edge.labels.some((label) => label.offset));
101455
- if (!offsets.size && !nudgedRuns && !nudgedLabels) return;
101826
+ if (!offsets.size && !resized.size && !nudgedRuns && !nudgedLabels) return;
101456
101827
  anchorFlowLabels(scene, titleBoxesOf(scene, model));
101457
101828
  fitCanvas(scene);
101458
101829
  }
@@ -103076,6 +103447,7 @@ function layoutBoxes(model, scene) {
103076
103447
  declarations.set(element.id, {
103077
103448
  line: element.kindSpan.line,
103078
103449
  offsetSpan: element.offset?.span,
103450
+ sizeSpan: element.size?.span,
103079
103451
  parent
103080
103452
  });
103081
103453
  walk(element.children, element.id);
@@ -103083,6 +103455,7 @@ function layoutBoxes(model, scene) {
103083
103455
  };
103084
103456
  walk(model.elements);
103085
103457
  const flowById = new Map(model.flows.map((flow) => [flow.id, flow]));
103458
+ const sizeBounds = containerSizeBounds(scene, model);
103086
103459
  const boxes = [];
103087
103460
  const leaves = scene.nodes.filter((node) => !node.container);
103088
103461
  for (const node of scene.nodes) {
@@ -103096,6 +103469,8 @@ function layoutBoxes(model, scene) {
103096
103469
  width: node.width,
103097
103470
  height: node.height,
103098
103471
  container: node.container,
103472
+ resize: sizeBounds.get(node.id),
103473
+ sizeApplied: scene.appliedSizes?.get(node.id),
103099
103474
  ...declaration
103100
103475
  });
103101
103476
  }
@@ -103161,7 +103536,7 @@ function layoutBoxes(model, scene) {
103161
103536
  }
103162
103537
 
103163
103538
  // src/api.ts
103164
- var version = true ? "1.0.0-RC17" : pkg.version;
103539
+ var version = true ? "1.0.0-RC18" : pkg.version;
103165
103540
 
103166
103541
  // src/playground.ts
103167
103542
  var ElkClass = import_elk_bundled.default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r0kshan/cairn",
3
- "version": "1.0.0-RC17",
3
+ "version": "1.0.0-RC18",
4
4
  "description": "Architecture diagrams as code — typed views, semantic layout, no label overlap",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/R0kshan/cairn#readme",