@r0kshan/cairn 1.0.0-RC12 → 1.0.0-RC13

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/bin/cairn.mjs CHANGED
@@ -92256,10 +92256,13 @@ function lex(src, diagnostics) {
92256
92256
  col++;
92257
92257
  continue;
92258
92258
  }
92259
- if (char === "-" && src[position + 1] === ">") {
92260
- push("arrow", "->", line, col);
92261
- position += 2;
92262
- col += 2;
92259
+ const arrowGlyph = ["-->", "..>", "->"].find(
92260
+ (glyph) => src.startsWith(glyph, position) && glyph[0] === char
92261
+ );
92262
+ if (arrowGlyph) {
92263
+ push("arrow", arrowGlyph, line, col);
92264
+ position += arrowGlyph.length;
92265
+ col += arrowGlyph.length;
92263
92266
  continue;
92264
92267
  }
92265
92268
  const singleCharKind = SINGLE_CHAR_TOKENS[char];
@@ -92317,10 +92320,11 @@ var init_lexer = __esm({
92317
92320
  });
92318
92321
 
92319
92322
  // src/models/ast.ts
92320
- var defaultDiagramStyle, explanations;
92323
+ var ATTACH_SIDES, defaultDiagramStyle, explanations;
92321
92324
  var init_ast = __esm({
92322
92325
  "src/models/ast.ts"() {
92323
92326
  "use strict";
92327
+ ATTACH_SIDES = ["left", "right", "top", "bottom"];
92324
92328
  defaultDiagramStyle = () => ({
92325
92329
  crossingHops: true,
92326
92330
  compact: false,
@@ -92339,6 +92343,7 @@ var init_ast = __esm({
92339
92343
  });
92340
92344
  explanations = {
92341
92345
  E0101: "Syntax error: the file does not follow the DSL grammar.",
92346
+ E0106: "`order:` takes a whole number \u2265 0. On a top-level element it sets the reading order the disposition defines \u2014 left to right for `wide`/`slide`, top to bottom for `tall`/`page` \u2014 by banding the element inside its own layout partition, so it never crosses into another partition. Inside a container the axis flips: a child's layer belongs to the flows and elk honors no constraint that would change it, so there `order:` sorts the siblings that share a layer. Values need not be contiguous.",
92342
92347
  E0201: "Unknown element kind for the active view. Each view defines its own kinds (e.g. logical: actor-group, actor, system, layer, block, external).",
92343
92348
  E0202: "Duplicate identifier. IDs are flat and unique per file (decision D1): every element needs a distinct ID so flows can reference it unambiguously.",
92344
92349
  E0203: "The logical view forbids unlabelled arrows: every flow must name the data, command or event it carries. A logical diagram tells what circulates, not just who talks to whom.",
@@ -92355,6 +92360,7 @@ var init_ast = __esm({
92355
92360
  E0215: "A deployed application (app-instance) must sit on a server or in a zone. It shows WHERE an application runs, without its internal detail (C4 deployment convention).",
92356
92361
  E0216: "Network zones belong to a site (or nest inside a larger zone): DMZ and LAN only mean something relative to a perimeter.",
92357
92362
  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.',
92363
+ E0223: "Unknown attachment side. 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`. The side is a hint, not a guarantee: a side the layout cannot reach is dropped with a W0570 warning rather than forced.",
92358
92364
  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.",
92359
92365
  E0221: 'Unknown business-object reference: a flow carries `[AN_ID]` that no `business-object` declaration defines. Declare it once (`business-object ID "Name" "description"`) so the registry and the chips stay consistent.',
92360
92366
  E0222: "Business objects are a logical-view concept (what data circulates between functional blocks). They are not part of the application, infrastructure, or security views \u2014 model the exchange with the flow label and technical tail instead. Remove the `business-object` declaration and its `[refs]`, or switch the diagram to `logical`.",
@@ -92362,6 +92368,8 @@ var init_ast = __esm({
92362
92368
  E0217: "A sensitive asset must sit inside a trust zone: its protection level is defined by the zone that contains it.",
92363
92369
  E0218: "A security node (firewall, WAF, bastion, reverse proxy) lives inside a trust zone \u2014 typically the exposed zone whose traffic it filters.",
92364
92370
  E0250: 'The security view requires each trust zone to declare a sensitivity level in parentheses after the label: `trust-zone DMZ "DMZ" (public)`. Levels, least \u2192 most trusted: public, internal, restricted, secret. The level drives the zone color and the trust-boundary crossing checks.',
92371
+ W0570: "A declared attachment side (`APP.right -> DB.left`) did not survive layout: the flow ended up on another side of the element. Pins are honored where the layout can reach them and dropped where it cannot, rather than forced into an unreadable route. Move the element instead (`order:`), pin the other endpoint, or drop the pin.",
92372
+ 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.",
92365
92373
  W0560: "Security check: this flow enters a more-trusted zone from a less-trusted one without passing through a security-node (firewall/WAF/bastion). Route it through a filtering point, or confirm the direct path is deliberate. This is the diagram equivalent of a missing firewall rule review.",
92366
92374
  W0561: "Security check: an inter-zone flow does not state its encryption/protocol. Cross-zone traffic should declare how it is protected: add `(TLS1.3)` or the relevant protocol after the label."
92367
92375
  };
@@ -92998,9 +93006,9 @@ var init_element_tree = __esm({
92998
93006
  // src/parser.ts
92999
93007
  function parseFlow(p, sourceToken) {
93000
93008
  const { matchToken, advance, reportError, lookAhead, syncToNextLine, model } = p;
93001
- advance();
93009
+ const arrowToken = advance();
93002
93010
  if (!matchToken("id")) {
93003
- reportError("target identifier expected after `->`", lookAhead().span);
93011
+ reportError(`target identifier expected after \`${arrowToken.text}\``, lookAhead().span);
93004
93012
  syncToNextLine();
93005
93013
  return;
93006
93014
  }
@@ -93017,6 +93025,8 @@ function parseFlow(p, sourceToken) {
93017
93025
  len: targetToken.span.col + targetToken.span.len - sourceToken.span.col
93018
93026
  }
93019
93027
  };
93028
+ if (arrowToken.text === "-->") flow.lineStyle = "dashed";
93029
+ else if (arrowToken.text === "..>") flow.lineStyle = "dotted";
93020
93030
  if (matchToken("colon")) {
93021
93031
  advance();
93022
93032
  if (matchToken("str")) flow.label = advance().text;
@@ -93069,6 +93079,11 @@ function parseFlow(p, sourceToken) {
93069
93079
  flow.style = {};
93070
93080
  p.parseStyleEntries(null, flow.style);
93071
93081
  }
93082
+ for (const split of [
93083
+ splitEndpoint(sourceToken, flow, "from"),
93084
+ splitEndpoint(targetToken, flow, "to")
93085
+ ])
93086
+ if (split) p.endpointSplits.push(split);
93072
93087
  model.flows.push(flow);
93073
93088
  }
93074
93089
  function parseElement(p, sourceToken, parent) {
@@ -93120,6 +93135,84 @@ function parseElement(p, sourceToken, parent) {
93120
93135
  }
93121
93136
  (parent ? parent.children : model.elements).push(element);
93122
93137
  }
93138
+ function splitEndpoint(token, flow, role) {
93139
+ const dot = token.text.lastIndexOf(".");
93140
+ if (dot <= 0 || dot === token.text.length - 1) return null;
93141
+ const base = token.text.slice(0, dot);
93142
+ const side = token.text.slice(dot + 1);
93143
+ return {
93144
+ flow,
93145
+ role,
93146
+ raw: token.text,
93147
+ rawSpan: token.span,
93148
+ base,
93149
+ baseSpan: { line: token.span.line, col: token.span.col, len: base.length },
93150
+ side,
93151
+ sideSpan: { line: token.span.line, col: token.span.col + dot + 1, len: side.length }
93152
+ };
93153
+ }
93154
+ function resolveAttachSides(splits, model, diagnostics) {
93155
+ for (const split of splits) {
93156
+ if (model.index.has(split.raw)) {
93157
+ if (ATTACH_SIDES.includes(split.side))
93158
+ diagnostics.push({
93159
+ code: "W0571",
93160
+ severity: "warning",
93161
+ message: `\`${split.raw}\` is a declared element, so \`.${split.side}\` is not read as an attachment side`,
93162
+ span: split.rawSpan,
93163
+ note: "a declared id always wins over the `ID.side` reading",
93164
+ help: `rename the element if you meant to attach the flow to the ${split.side} side of \`${split.base}\``
93165
+ });
93166
+ continue;
93167
+ }
93168
+ if (!model.index.has(split.base)) continue;
93169
+ const known = ATTACH_SIDES.includes(split.side);
93170
+ if (!known)
93171
+ diagnostics.push({
93172
+ code: "E0223",
93173
+ severity: "error",
93174
+ message: `unknown attachment side \`${split.side}\``,
93175
+ span: split.sideSpan,
93176
+ note: "sides are named as the diagram is read",
93177
+ help: "use `left`, `right`, `top` or `bottom`, e.g. `APP.right -> DB.left`"
93178
+ });
93179
+ const side = known ? { value: split.side, span: split.sideSpan } : void 0;
93180
+ if (split.role === "from") {
93181
+ split.flow.from = split.base;
93182
+ split.flow.fromSpan = split.baseSpan;
93183
+ split.flow.fromSide = side;
93184
+ } else {
93185
+ split.flow.to = split.base;
93186
+ split.flow.toSpan = split.baseSpan;
93187
+ split.flow.toSide = side;
93188
+ }
93189
+ }
93190
+ }
93191
+ function tryOrderEntry(p, parent) {
93192
+ const { matchToken, advance, reportCoded, save, restore, syncToNextLine } = p;
93193
+ if (!parent || !matchToken("id", "order")) return false;
93194
+ const mark = save();
93195
+ const keyToken = advance();
93196
+ if (!matchToken("colon")) {
93197
+ restore(mark);
93198
+ return false;
93199
+ }
93200
+ advance();
93201
+ const valueToken = matchToken("num") ? advance() : null;
93202
+ const value = valueToken ? Number(valueToken.text) : Number.NaN;
93203
+ if (!Number.isInteger(value) || value < 0) {
93204
+ reportCoded(
93205
+ "E0106",
93206
+ "`order` expects a whole number \u2265 0",
93207
+ (valueToken ?? keyToken).span,
93208
+ "e.g. `order: 1` \u2014 lower comes first in reading order"
93209
+ );
93210
+ syncToNextLine();
93211
+ return true;
93212
+ }
93213
+ parent.order = { value, span: valueToken.span };
93214
+ return true;
93215
+ }
93123
93216
  function tryLegendBlock(p) {
93124
93217
  const { matchToken, advance, reportError, lookAhead, skipNewlines, syncToNextLine, model } = p;
93125
93218
  if (!matchToken("id", "legend")) return false;
@@ -93197,13 +93290,14 @@ function parse(src) {
93197
93290
  const skipNewlines = () => {
93198
93291
  while (matchToken("nl")) advance();
93199
93292
  };
93200
- const reportError = (message, span, help) => diagnostics.push({
93201
- code: "E0101",
93293
+ const reportCoded = (code, message, span, help) => diagnostics.push({
93294
+ code,
93202
93295
  severity: "error",
93203
93296
  message,
93204
93297
  span,
93205
93298
  help
93206
93299
  });
93300
+ const reportError = (message, span, help) => reportCoded("E0101", message, span, help);
93207
93301
  const syncToNextLine = () => {
93208
93302
  while (!matchToken("nl") && !matchToken("eof")) advance();
93209
93303
  };
@@ -93216,6 +93310,7 @@ function parse(src) {
93216
93310
  index: /* @__PURE__ */ new Map()
93217
93311
  };
93218
93312
  const flowSequence = { next: 0 };
93313
+ const endpointSplits = [];
93219
93314
  skipNewlines();
93220
93315
  if (matchToken("id", "diagram")) {
93221
93316
  advance();
@@ -93307,12 +93402,14 @@ function parse(src) {
93307
93402
  advance,
93308
93403
  skipNewlines,
93309
93404
  reportError,
93405
+ reportCoded,
93310
93406
  syncToNextLine,
93311
93407
  save: () => position,
93312
93408
  restore: (mark) => {
93313
93409
  position = mark;
93314
93410
  },
93315
93411
  flowSequence,
93412
+ endpointSplits,
93316
93413
  parseStyleEntries,
93317
93414
  parseElementBody
93318
93415
  };
@@ -93322,6 +93419,7 @@ function parse(src) {
93322
93419
  return;
93323
93420
  }
93324
93421
  if (tryStyleBlock(parent)) return;
93422
+ if (tryOrderEntry(parser, parent)) return;
93325
93423
  if (!parent && tryLegendBlock(parser)) return;
93326
93424
  if (!parent && tryBusinessObject(parser)) return;
93327
93425
  parseFlowOrElement(parser, parent);
@@ -93334,6 +93432,7 @@ function parse(src) {
93334
93432
  for (const [id, element] of indexElementsById(model.elements)) {
93335
93433
  if (!model.index.has(id)) model.index.set(id, element);
93336
93434
  }
93435
+ resolveAttachSides(endpointSplits, model, diagnostics);
93337
93436
  return { model, diags: diagnostics };
93338
93437
  }
93339
93438
  function applyStyleEntry(entry) {
@@ -93693,12 +93792,27 @@ var init_views = __esm({
93693
93792
  };
93694
93793
  applicationView = {
93695
93794
  name: "application",
93696
- kinds: ["actor-group", "actor", "application", "module", "queue", "datastore", "external"],
93697
- containerKinds: ["actor-group", "application", "external"],
93795
+ kinds: [
93796
+ "actor-group",
93797
+ "actor",
93798
+ "system",
93799
+ "application",
93800
+ "module",
93801
+ "queue",
93802
+ "datastore",
93803
+ "external"
93804
+ ],
93805
+ containerKinds: ["actor-group", "system", "application", "external"],
93698
93806
  // C4-style `(API_REST, JSON)`: the protocol half is worth tabulating, the port is not.
93699
- matrix: { zoneKinds: ["application"], columns: ["num", "source", "dest", "proto", "nature"] },
93807
+ // `zoneOf` walks ancestors nearest-first, so an endpoint inside an application
93808
+ // still reads `Name (App)`; the system only annotates what sits directly in it.
93809
+ matrix: {
93810
+ zoneKinds: ["application", "system"],
93811
+ columns: ["num", "source", "dest", "proto", "nature"]
93812
+ },
93700
93813
  partitions: {
93701
93814
  "actor-group": 0,
93815
+ system: 1,
93702
93816
  application: 1,
93703
93817
  queue: 1,
93704
93818
  datastore: 1,
@@ -93707,6 +93821,7 @@ var init_views = __esm({
93707
93821
  legendNames: {
93708
93822
  "actor-group": "Actor group",
93709
93823
  actor: "Actor",
93824
+ system: "System",
93710
93825
  application: "Application",
93711
93826
  module: "Application module",
93712
93827
  queue: "Message queue / broker",
@@ -93716,6 +93831,7 @@ var init_views = __esm({
93716
93831
  legendNamesFr: {
93717
93832
  "actor-group": "Groupe d'acteurs",
93718
93833
  actor: "Acteur",
93834
+ system: "Syst\xE8me",
93719
93835
  application: "Application",
93720
93836
  module: "Module applicatif",
93721
93837
  queue: "File de messages / broker",
@@ -93763,6 +93879,12 @@ var init_views = __esm({
93763
93879
  fill: "#eef4fb",
93764
93880
  stroke: { color: "#7a9cc4", style: "dashed", width: 1.2 }
93765
93881
  },
93882
+ // Same palette as the logical view's `system`, so a system boundary reads
93883
+ // the same in both views.
93884
+ system: {
93885
+ fill: "#f6f2ea",
93886
+ stroke: { color: "#b09a6d", style: "solid", width: 1.2 }
93887
+ },
93766
93888
  application: {
93767
93889
  fill: "#e8f1f8",
93768
93890
  stroke: { color: "#5b8db8", style: "solid", width: 1.2 }
@@ -93790,6 +93912,10 @@ var init_views = __esm({
93790
93912
  fill: "#232a33",
93791
93913
  stroke: { color: "#5c7fa8", style: "dashed", width: 1.2 }
93792
93914
  },
93915
+ system: {
93916
+ fill: "#2b2822",
93917
+ stroke: { color: "#9c8558", style: "solid", width: 1.2 }
93918
+ },
93793
93919
  application: {
93794
93920
  fill: "#1f2a33",
93795
93921
  stroke: { color: "#4a7ba6", style: "solid", width: 1.2 }
@@ -95266,7 +95392,7 @@ var init_slide_fold = __esm({
95266
95392
  });
95267
95393
 
95268
95394
  // src/geometry.ts
95269
- var MIN_ATTACH_GAP, boxesOverlap, boxToSegmentSq, boxGapSq, boundsOf, pathLength, boxToPolylineSq;
95395
+ var MIN_ATTACH_GAP, boxesOverlap, boxToSegmentSq, boxGapSq, boundsOf, pathLength, DETOUR_RATIO, DETOUR_WASTE, isLongDetour, boxToPolylineSq;
95270
95396
  var init_geometry = __esm({
95271
95397
  "src/geometry.ts"() {
95272
95398
  "use strict";
@@ -95301,6 +95427,14 @@ var init_geometry = __esm({
95301
95427
  length += Math.abs(points[index + 1].x - points[index].x) + Math.abs(points[index + 1].y - points[index].y);
95302
95428
  return length;
95303
95429
  };
95430
+ DETOUR_RATIO = 2.2;
95431
+ DETOUR_WASTE = 400;
95432
+ isLongDetour = (points, boxA, boxB) => {
95433
+ const direct = Math.abs(boxA.x + boxA.width / 2 - (boxB.x + boxB.width / 2)) + Math.abs(boxA.y + boxA.height / 2 - (boxB.y + boxB.height / 2));
95434
+ if (direct <= 0) return false;
95435
+ const length = pathLength(points);
95436
+ return length > DETOUR_RATIO * direct && length - direct > DETOUR_WASTE;
95437
+ };
95304
95438
  boxToPolylineSq = (box, points) => {
95305
95439
  let best = Number.POSITIVE_INFINITY;
95306
95440
  for (let index = 0; index + 1 < points.length; index++) {
@@ -95330,6 +95464,7 @@ function detourCandidates(scene, model) {
95330
95464
  for (const edge of scene.edges) {
95331
95465
  const flow = flowById.get(edge.id);
95332
95466
  if (!flow || edge.pts.length < 2) continue;
95467
+ if (edge.pinned) continue;
95333
95468
  const source = nodeById.get(flow.from);
95334
95469
  const target = nodeById.get(flow.to);
95335
95470
  if (!source || !target) continue;
@@ -95750,9 +95885,7 @@ function laneOffsets(lc, subset, direction, anchor) {
95750
95885
  const ownPosition = spanAnchor(lc.scene, { left, right, exempt }, direction, anchor) + direction * CHANNEL_GAP;
95751
95886
  const start = lane === 0 ? ownPosition : direction > 0 ? Math.max(ownPosition, positions[lane - 1] + labelHeights[lane - 1] + 14) : Math.min(ownPosition, positions[lane - 1] - (labelHeights[lane - 1] + 14));
95752
95887
  labelHeights.push(labelHeight);
95753
- positions.push(
95754
- resolveLanePosition(lc, { left, right, exempt, labelHeight, start }, direction)
95755
- );
95888
+ positions.push(resolveLanePosition(lc, { left, right, exempt, labelHeight, start }, direction));
95756
95889
  }
95757
95890
  return positions;
95758
95891
  }
@@ -95943,7 +96076,9 @@ var init_route_detour = __esm({
95943
96076
  blockedBelowBy = (leafBoxes, x, top) => leafBoxes.some(
95944
96077
  (node) => x >= node.x - 2 && x <= node.x + node.width + 2 && node.y + node.height > top + 1
95945
96078
  );
95946
- blockedAboveBy = (leafBoxes, titleBoxes, x, bottom) => leafBoxes.some((node) => x >= node.x - 2 && x <= node.x + node.width + 2 && node.y < bottom - 1) || titleBoxes.some(
96079
+ blockedAboveBy = (leafBoxes, titleBoxes, x, bottom) => leafBoxes.some(
96080
+ (node) => x >= node.x - 2 && x <= node.x + node.width + 2 && node.y < bottom - 1
96081
+ ) || titleBoxes.some(
95947
96082
  (box) => x >= box.x - TITLE_CLEARANCE && x <= box.x + box.width + TITLE_CLEARANCE && box.y < bottom - 1
95948
96083
  );
95949
96084
  horizontalBlocked = (leafBoxes, y, probe) => leafBoxes.some(
@@ -97224,6 +97359,7 @@ function clearRunHug(ctx, edge, run, own) {
97224
97359
  if (attemptHugFix(ctx, edge, { run, axis, target }, own)) return true;
97225
97360
  }
97226
97361
  if (run.i !== 0 && run.i + 1 !== edge.pts.length - 1) return false;
97362
+ if (edge.pinned?.[run.i === 0 ? "start" : "end"]) return false;
97227
97363
  if (!sideOf(edge.pts[run.i === 0 ? 0 : edge.pts.length - 1], leaves)) return false;
97228
97364
  return resideAtSide(ctx, edge, run, own);
97229
97365
  }
@@ -97640,8 +97776,10 @@ function generateReaimCandidates(AWAY_TOL2, from, to) {
97640
97776
  };
97641
97777
  const normalOf = (side) => side === "north" ? { x: 0, y: -1 } : side === "south" ? { x: 0, y: 1 } : side === "west" ? { x: -1, y: 0 } : { x: 1, y: 0 };
97642
97778
  const candidates = [];
97643
- for (const srcSide of facingSides(src, dstC))
97644
- for (const dstSide of facingSides(dst, srcC)) {
97779
+ const srcSides = from.keepSide ? [from.keepSide] : facingSides(src, dstC);
97780
+ const dstSides = to.keepSide ? [to.keepSide] : facingSides(dst, srcC);
97781
+ for (const srcSide of srcSides)
97782
+ for (const dstSide of dstSides) {
97645
97783
  const na = normalOf(srcSide);
97646
97784
  const nb = normalOf(dstSide);
97647
97785
  const horizontalA = na.x !== 0;
@@ -97785,8 +97923,8 @@ function reaimEdge(rctx, edge) {
97785
97923
  const dstC = { x: dst.x + dst.width / 2, y: dst.y + dst.height / 2 };
97786
97924
  const candidates = generateReaimCandidates(
97787
97925
  AWAY_TOL2,
97788
- { node: src, centre: srcC },
97789
- { node: dst, centre: dstC }
97926
+ { node: src, centre: srcC, keepSide: edge.pinned?.start ? srcSeat.side : null },
97927
+ { node: dst, centre: dstC, keepSide: edge.pinned?.end ? dstSeat.side : null }
97790
97928
  );
97791
97929
  const outside = runsExcept(edge.id);
97792
97930
  const labelBoxes = seatedLabelBoxes(edge);
@@ -97818,7 +97956,8 @@ function reaimEdge(rctx, edge) {
97818
97956
  function reaimWrapAroundTerminals(ctx) {
97819
97957
  const { scene } = ctx;
97820
97958
  const rctx = createReaimContext(ctx);
97821
- for (const edge of scene.edges) reaimEdge(rctx, edge);
97959
+ for (const edge of scene.edges)
97960
+ if (!(edge.pinned?.start && edge.pinned?.end)) reaimEdge(rctx, edge);
97822
97961
  }
97823
97962
  function shiftCoincidentRun(leaves, pts, b, newAt) {
97824
97963
  if (b.vert) {
@@ -98264,8 +98403,10 @@ function bestUnweaveRoute(uctx, job) {
98264
98403
  const { edge, srcSeat, dstSeat } = job;
98265
98404
  let best = null;
98266
98405
  let bestLength = Number.POSITIVE_INFINITY;
98267
- for (const srcSide of ALL_SIDES)
98268
- for (const dstSide of ALL_SIDES)
98406
+ const srcSides = edge.pinned?.start ? [srcSeat.side] : ALL_SIDES;
98407
+ const dstSides = edge.pinned?.end ? [dstSeat.side] : ALL_SIDES;
98408
+ for (const srcSide of srcSides)
98409
+ for (const dstSide of dstSides)
98269
98410
  for (const srcOffset of SEAT_OFFSETS)
98270
98411
  for (const dstOffset of SEAT_OFFSETS) {
98271
98412
  const a = unweaveSeat(srcSeat.node, srcSide, srcOffset);
@@ -98318,6 +98459,7 @@ function unweaveAndClearContainers(ctx) {
98318
98459
  for (const edge of [...scene.edges].sort(
98319
98460
  (a, b) => (Number.parseInt(a.id.slice(1), 10) || 0) - (Number.parseInt(b.id.slice(1), 10) || 0)
98320
98461
  )) {
98462
+ if (edge.pinned?.start && edge.pinned?.end) continue;
98321
98463
  unweaveEdge(uctx, edge);
98322
98464
  }
98323
98465
  }
@@ -98618,7 +98760,7 @@ function createLaneModel(deps) {
98618
98760
  const fromB = lane - (firstVertical ? pts[3].y : pts[3].x);
98619
98761
  return fromA * fromB > 0;
98620
98762
  };
98621
- return { channelU, isChannelU };
98763
+ return { channelU, isChannelU, laneBeyond };
98622
98764
  }
98623
98765
  function optimiseRoutes(scene, titleBoxes = [], folded = false) {
98624
98766
  const leaves = scene.nodes.filter((node) => !node.container);
@@ -98628,7 +98770,7 @@ function optimiseRoutes(scene, titleBoxes = [], folded = false) {
98628
98770
  const preRouted = new Set(scene.edges.filter((edge) => edge.detour).map((edge) => edge.id));
98629
98771
  const { SIDES, seatOffsetsFor, seatOn, outward, horizontalSide } = createSeatModel(titleBoxes);
98630
98772
  const clock = { generation: 0 };
98631
- const { channelU, isChannelU } = createLaneModel({
98773
+ const { channelU, isChannelU, laneBeyond } = createLaneModel({
98632
98774
  scene,
98633
98775
  inspector,
98634
98776
  titleBoxes,
@@ -98638,7 +98780,10 @@ function optimiseRoutes(scene, titleBoxes = [], folded = false) {
98638
98780
  const shapesFor = (a, b, sides, subject) => {
98639
98781
  const aH = horizontalSide(sides.a);
98640
98782
  const bH = horizontalSide(sides.b);
98641
- if (aH !== bH) return [[a, aH ? { x: b.x, y: a.y } : { x: a.x, y: b.y }, b]];
98783
+ if (aH !== bH) {
98784
+ const plainL = [a, aH ? { x: b.x, y: a.y } : { x: a.x, y: b.y }, b];
98785
+ return [plainL, ...approachLanes(a, b, sides, subject)];
98786
+ }
98642
98787
  if (outward(sides.a) === outward(sides.b)) return channelU(a, b, sides.a, subject);
98643
98788
  if (aH)
98644
98789
  return [
@@ -98652,6 +98797,40 @@ function optimiseRoutes(scene, titleBoxes = [], folded = false) {
98652
98797
  Math.max(a.y, b.y) - CHANNEL_INSET
98653
98798
  ].map((mid) => [a, { x: a.x, y: mid }, { x: b.x, y: mid }, b]);
98654
98799
  };
98800
+ const approachLanes = (a, b, sides, subject) => {
98801
+ if (!subject.edge.pinned) return [];
98802
+ const aH = horizontalSide(sides.a);
98803
+ const bH = horizontalSide(sides.b);
98804
+ const beforeA = sides.a === "west" || sides.a === "north";
98805
+ const crossLane = laneBeyond(
98806
+ {
98807
+ spanLo: Math.min(a.x, b.x),
98808
+ spanHi: Math.max(a.x, b.x),
98809
+ start: (aH ? a.x : a.y) + (beforeA ? -CHANNEL_STEP : CHANNEL_STEP),
98810
+ vertical: !aH,
98811
+ before: beforeA
98812
+ },
98813
+ subject,
98814
+ true
98815
+ );
98816
+ if (crossLane === null) return [];
98817
+ const beforeB = sides.b === "west" || sides.b === "north";
98818
+ const search = {
98819
+ spanLo: bH ? Math.min(crossLane, b.y) : Math.min(crossLane, b.x),
98820
+ spanHi: bH ? Math.max(crossLane, b.y) : Math.max(crossLane, b.x),
98821
+ start: (bH ? b.x : b.y) + (beforeB ? -CHANNEL_STEP : CHANNEL_STEP),
98822
+ // `laneBeyond` names the axis after the terminal legs: a lane at a
98823
+ // constant x is the `vertical: false` search.
98824
+ vertical: !bH,
98825
+ before: beforeB
98826
+ };
98827
+ const lanes = [laneBeyond(search, subject, false), laneBeyond(search, subject, true)].filter(
98828
+ (lane, index, all) => lane !== null && all.indexOf(lane) === index
98829
+ );
98830
+ return lanes.map(
98831
+ (lane) => bH ? [a, { x: a.x, y: crossLane }, { x: lane, y: crossLane }, { x: lane, y: b.y }, b] : [a, { x: crossLane, y: a.y }, { x: crossLane, y: lane }, { x: b.x, y: lane }, b]
98832
+ );
98833
+ };
98655
98834
  const routeCache = /* @__PURE__ */ new Map();
98656
98835
  const routesFor = (edge) => {
98657
98836
  const cacheKey = `${clock.generation}|${edge.id}|${edge.pts[0].x},${edge.pts[0].y}|${edge.pts[edge.pts.length - 1].x},${edge.pts[edge.pts.length - 1].y}`;
@@ -98662,8 +98841,10 @@ function optimiseRoutes(scene, titleBoxes = [], folded = false) {
98662
98841
  const subject = { ends, edge };
98663
98842
  const out = [];
98664
98843
  const seen = /* @__PURE__ */ new Set();
98665
- for (const aSide of SIDES)
98666
- for (const bSide of SIDES) {
98844
+ const aSides = edge.pinned?.start ? [ends[0].side] : SIDES;
98845
+ const bSides = edge.pinned?.end ? [ends[1].side] : SIDES;
98846
+ for (const aSide of aSides)
98847
+ for (const bSide of bSides) {
98667
98848
  const sides = { a: aSide, b: bSide };
98668
98849
  for (const aOff of seatOffsetsFor(ends[0].node, aSide))
98669
98850
  for (const bOff of seatOffsetsFor(ends[1].node, bSide))
@@ -99052,13 +99233,16 @@ function computeIngressExternalElements(model) {
99052
99233
  }
99053
99234
  return ingressExternalElements;
99054
99235
  }
99055
- function toElkNode2(element, compact, containerFontSize, nodeFontSize) {
99236
+ function toElkNode2(element, compact, fonts, root = false) {
99237
+ const { cont: containerFontSize, node: nodeFontSize } = fonts;
99056
99238
  if (element.children.length) {
99057
99239
  const lineCount = (element.label ?? element.id).split("\n").length;
99058
99240
  return {
99059
99241
  id: element.id,
99060
99242
  layoutOptions: {
99061
- "elk.padding": `[top=${(compact ? 11 : 13) + lineCount * 14},left=${compact ? 7 : 9},bottom=${compact ? 7 : 9},right=${compact ? 7 : 9}]`
99243
+ "elk.padding": `[top=${(compact ? 11 : 13) + lineCount * 14},left=${compact ? 7 : 9},bottom=${compact ? 7 : 9},right=${compact ? 7 : 9}]`,
99244
+ ...root ? {} : orderOption(element),
99245
+ ...semiInteractiveOption(element.children)
99062
99246
  },
99063
99247
  labels: [
99064
99248
  {
@@ -99067,7 +99251,7 @@ function toElkNode2(element, compact, containerFontSize, nodeFontSize) {
99067
99251
  }
99068
99252
  ],
99069
99253
  children: element.children.map(
99070
- (child) => toElkNode2(child, compact, containerFontSize, nodeFontSize)
99254
+ (child) => toElkNode2(child, compact, fonts)
99071
99255
  )
99072
99256
  };
99073
99257
  }
@@ -99075,6 +99259,7 @@ function toElkNode2(element, compact, containerFontSize, nodeFontSize) {
99075
99259
  const isActor = element.kind === "actor";
99076
99260
  return {
99077
99261
  id: element.id,
99262
+ ...element.order && !root ? { layoutOptions: orderOption(element) } : {},
99078
99263
  width: isActor ? Math.max(64, measure(element.label ?? element.id, nodeFontSize - 1.5).width + 8) : Math.max(compact ? 98 : 108, measured.width + (compact ? 10 : 12)),
99079
99264
  height: isActor ? 54 + ((element.label ?? element.id).split("\n").length - 1) * 11 : Math.max(compact ? 36 : 38, measured.height + (compact ? 10 : 12))
99080
99265
  };
@@ -99161,6 +99346,11 @@ function transpose(scene, titleBoxes) {
99161
99346
  }
99162
99347
  [scene.width, scene.height] = [scene.height, scene.width];
99163
99348
  }
99349
+ function beatsRelayout(round, best) {
99350
+ if (!best) return true;
99351
+ if (round.tier !== best.tier) return round.tier < best.tier;
99352
+ return round.detours < best.detours;
99353
+ }
99164
99354
  function attachAwayOf(scene, model) {
99165
99355
  const ATTACH_AWAY_TOL = 24;
99166
99356
  const byId = new Map(scene.nodes.map((node) => [node.id, node]));
@@ -99171,21 +99361,35 @@ function attachAwayOf(scene, model) {
99171
99361
  const from = byId.get(flow?.from ?? "");
99172
99362
  const to = byId.get(flow?.to ?? "");
99173
99363
  if (!from || !to) continue;
99174
- const centerOf = (n) => ({ x: n.x + n.width / 2, y: n.y + n.height / 2 });
99364
+ const centerOf2 = (n) => ({ x: n.x + n.width / 2, y: n.y + n.height / 2 });
99175
99365
  const away = (seg, target) => Math.abs(seg.x) >= 0.5 && Math.abs(target.x) > ATTACH_AWAY_TOL && seg.x * target.x < 0 || Math.abs(seg.y) >= 0.5 && Math.abs(target.y) > ATTACH_AWAY_TOL && seg.y * target.y < 0;
99176
99366
  const p0 = e.pts[0];
99177
99367
  const p1 = e.pts[1];
99178
99368
  const pn = e.pts[e.pts.length - 1];
99179
99369
  const pm = e.pts[e.pts.length - 2];
99180
- const toCenter = centerOf(to);
99181
- const fromCenter = centerOf(from);
99182
- if (away({ x: p1.x - p0.x, y: p1.y - p0.y }, { x: toCenter.x - p0.x, y: toCenter.y - p0.y }))
99370
+ const toCenter = centerOf2(to);
99371
+ const fromCenter = centerOf2(from);
99372
+ if (!e.pinned?.start && away({ x: p1.x - p0.x, y: p1.y - p0.y }, { x: toCenter.x - p0.x, y: toCenter.y - p0.y }))
99183
99373
  flagged.add(e.id);
99184
- if (away({ x: pn.x - pm.x, y: pn.y - pm.y }, { x: pn.x - fromCenter.x, y: pn.y - fromCenter.y }))
99374
+ if (!e.pinned?.end && away({ x: pn.x - pm.x, y: pn.y - pm.y }, { x: pn.x - fromCenter.x, y: pn.y - fromCenter.y }))
99185
99375
  flagged.add(e.id);
99376
+ if (!e.pinned && isLongDetour(e.pts, from, to)) flagged.add(e.id);
99186
99377
  }
99187
99378
  return flagged;
99188
99379
  }
99380
+ function longDetourCount(scene, model) {
99381
+ const byId = new Map(scene.nodes.map((node) => [node.id, node]));
99382
+ let count = 0;
99383
+ for (const edge of scene.edges) {
99384
+ if (edge.pts.length < 2) continue;
99385
+ const flow = model.flows.find((f) => f.id === edge.id);
99386
+ const from = byId.get(flow?.from ?? "");
99387
+ const to = byId.get(flow?.to ?? "");
99388
+ if (!from || !to) continue;
99389
+ if (isLongDetour(edge.pts, from, to)) count++;
99390
+ }
99391
+ return count;
99392
+ }
99189
99393
  function selectionExtras(scene, model) {
99190
99394
  const extra = /* @__PURE__ */ new Map();
99191
99395
  const edgeEnds = new Map(model.flows.map((flow) => [flow.id, /* @__PURE__ */ new Set([flow.from, flow.to])]));
@@ -99243,6 +99447,27 @@ function relayoutVerdict(before, after) {
99243
99447
  }
99244
99448
  return -1;
99245
99449
  }
99450
+ function sideToward(from, to) {
99451
+ const dx = centerOf(to).x - centerOf(from).x;
99452
+ const dy = centerOf(to).y - centerOf(from).y;
99453
+ return Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? "WEST" : "EAST" : dy < 0 ? "NORTH" : "SOUTH";
99454
+ }
99455
+ function sideOn(p, n) {
99456
+ if (p.x > n.x - 2 && p.x < n.x + n.width + 2) {
99457
+ if (Math.abs(p.y - n.y) < 2) return "NORTH";
99458
+ if (Math.abs(p.y - (n.y + n.height)) < 2) return "SOUTH";
99459
+ }
99460
+ if (p.y > n.y - 2 && p.y < n.y + n.height + 2) {
99461
+ if (Math.abs(p.x - n.x) < 2) return "WEST";
99462
+ if (Math.abs(p.x - (n.x + n.width)) < 2) return "EAST";
99463
+ }
99464
+ return null;
99465
+ }
99466
+ function firstPassSide(scene, flowId, role, sceneNode) {
99467
+ const edge = scene.edges.find((candidate) => candidate.id === flowId);
99468
+ if (!edge || edge.pts.length < 2) return null;
99469
+ return sideOn(role === "src" ? edge.pts[0] : edge.pts[edge.pts.length - 1], sceneNode);
99470
+ }
99246
99471
  function constrainPorts(graph, scene, flagged, model) {
99247
99472
  const nodeById = new Map(scene.nodes.map((node) => [node.id, node]));
99248
99473
  const elkById = /* @__PURE__ */ new Map();
@@ -99251,23 +99476,6 @@ function constrainPorts(graph, scene, flagged, model) {
99251
99476
  for (const child of node.children ?? []) register(child);
99252
99477
  };
99253
99478
  register(graph);
99254
- const centerOf = (n) => ({ x: n.x + n.width / 2, y: n.y + n.height / 2 });
99255
- const sideToward = (from, to) => {
99256
- const dx = centerOf(to).x - centerOf(from).x;
99257
- const dy = centerOf(to).y - centerOf(from).y;
99258
- return Math.abs(dx) >= Math.abs(dy) ? dx < 0 ? "WEST" : "EAST" : dy < 0 ? "NORTH" : "SOUTH";
99259
- };
99260
- const sideOn = (p, n) => {
99261
- if (p.x > n.x - 2 && p.x < n.x + n.width + 2) {
99262
- if (Math.abs(p.y - n.y) < 2) return "NORTH";
99263
- if (Math.abs(p.y - (n.y + n.height)) < 2) return "SOUTH";
99264
- }
99265
- if (p.y > n.y - 2 && p.y < n.y + n.height + 2) {
99266
- if (Math.abs(p.x - n.x) < 2) return "WEST";
99267
- if (Math.abs(p.x - (n.x + n.width)) < 2) return "EAST";
99268
- }
99269
- return null;
99270
- };
99271
99479
  const flaggedNodes = /* @__PURE__ */ new Set();
99272
99480
  for (const flow of model.flows)
99273
99481
  if (flagged.has(flow.id)) {
@@ -99284,14 +99492,9 @@ function constrainPorts(graph, scene, flagged, model) {
99284
99492
  const role = flow.from === nodeId ? "src" : "dst";
99285
99493
  const other = nodeById.get(role === "src" ? flow.to : flow.from);
99286
99494
  if (!other) continue;
99287
- let side = sideToward(sceneNode, other);
99288
- if (!flagged.has(flow.id)) {
99289
- const edge = scene.edges.find((e) => e.id === flow.id);
99290
- const terminal = edge && edge.pts.length >= 2 ? role === "src" ? edge.pts[0] : edge.pts[edge.pts.length - 1] : null;
99291
- const actual = terminal ? sideOn(terminal, sceneNode) : null;
99292
- if (actual) side = actual;
99293
- }
99495
+ const side = flagged.has(flow.id) ? sideToward(sceneNode, other) : firstPassSide(scene, flow.id, role, sceneNode) ?? sideToward(sceneNode, other);
99294
99496
  const portId = `${flow.id}${role === "src" ? "#out" : "#in"}`;
99497
+ if ((elkNode.ports ?? []).some((port) => port.id === portId)) continue;
99295
99498
  ports.push({ id: portId, width: 1, height: 1, layoutOptions: { "elk.port.side": side } });
99296
99499
  const elkEdge = (graph.edges ?? []).find((edge) => edge.id === flow.id);
99297
99500
  if (elkEdge) {
@@ -99312,6 +99515,52 @@ function elkPartitionOf(element, index, view, ingressExternal) {
99312
99515
  if (view.partitions[element.kind] !== void 0) return 90 + view.partitions[element.kind];
99313
99516
  return index;
99314
99517
  }
99518
+ function rootAncestorOf(model, id) {
99519
+ let element = model.index.get(id);
99520
+ if (!element) return void 0;
99521
+ while (element.parent) element = element.parent;
99522
+ return element.id;
99523
+ }
99524
+ function readingSlots(model, view, ingressExternal) {
99525
+ const slotOf = /* @__PURE__ */ new Map();
99526
+ if (!model.elements.some((element) => element.order)) return slotOf;
99527
+ const bandOf = new Map(
99528
+ model.elements.map((element, index) => [
99529
+ element.id,
99530
+ elkPartitionOf(element, index, view, ingressExternal)
99531
+ ])
99532
+ );
99533
+ const lowestOf = /* @__PURE__ */ new Map();
99534
+ for (const band of new Set(bandOf.values())) {
99535
+ const members = model.elements.filter((element) => bandOf.get(element.id) === band);
99536
+ const declared = [
99537
+ ...new Set(members.filter((m) => m.order).map((m) => m.order.value))
99538
+ ].sort((a, b) => a - b);
99539
+ const lowest = declared.length ? 1 : 0;
99540
+ lowestOf.set(band, lowest);
99541
+ for (const member of members)
99542
+ slotOf.set(
99543
+ member.id,
99544
+ member.order ? declared.indexOf(member.order.value) + 1 : lowest
99545
+ );
99546
+ }
99547
+ const ordered = new Set(model.elements.filter((element) => element.order).map((e) => e.id));
99548
+ for (let pass = 0; pass < model.elements.length; pass++) {
99549
+ let moved = false;
99550
+ for (const flow of model.flows) {
99551
+ const from = rootAncestorOf(model, flow.from);
99552
+ const to = rootAncestorOf(model, flow.to);
99553
+ if (!from || !to || from === to || ordered.has(to)) continue;
99554
+ if (bandOf.get(from) !== bandOf.get(to)) continue;
99555
+ const candidate = slotOf.get(from);
99556
+ if (candidate <= slotOf.get(to)) continue;
99557
+ slotOf.set(to, candidate);
99558
+ moved = true;
99559
+ }
99560
+ if (!moved) break;
99561
+ }
99562
+ return slotOf;
99563
+ }
99315
99564
  function elkFlowEdge(flow, ctx, labelWrap) {
99316
99565
  const { compact, numbered, fonts, businessObjectName } = ctx;
99317
99566
  if (numbered)
@@ -99348,9 +99597,82 @@ function elkFlowEdge(flow, ctx, labelWrap) {
99348
99597
  labels: text || chips.length ? [{ text, ...labelBox }] : []
99349
99598
  };
99350
99599
  }
99600
+ function applyDeclaredPorts(graph, model) {
99601
+ const pinned = model.flows.filter((flow) => flow.fromSide || flow.toSide);
99602
+ if (!pinned.length) return;
99603
+ const elkById = /* @__PURE__ */ new Map();
99604
+ const register = (node) => {
99605
+ elkById.set(node.id, node);
99606
+ for (const child of node.children ?? []) register(child);
99607
+ };
99608
+ register(graph);
99609
+ for (const flow of pinned) {
99610
+ const elkEdge = (graph.edges ?? []).find((edge) => edge.id === flow.id);
99611
+ if (!elkEdge) continue;
99612
+ for (const [role, declared, nodeId] of [
99613
+ ["out", flow.fromSide, flow.from],
99614
+ ["in", flow.toSide, flow.to]
99615
+ ]) {
99616
+ if (!declared) continue;
99617
+ const elkNode = elkById.get(nodeId);
99618
+ if (!elkNode) continue;
99619
+ const portId = `${flow.id}#${role}`;
99620
+ elkNode.ports = [
99621
+ ...elkNode.ports ?? [],
99622
+ {
99623
+ id: portId,
99624
+ width: 1,
99625
+ height: 1,
99626
+ layoutOptions: { "elk.port.side": SIDE_TO_ELK[declared.value] }
99627
+ }
99628
+ ];
99629
+ elkNode.layoutOptions = { ...elkNode.layoutOptions, "elk.portConstraints": "FIXED_SIDE" };
99630
+ if (role === "out") elkEdge.sources = [portId];
99631
+ else elkEdge.targets = [portId];
99632
+ }
99633
+ }
99634
+ }
99635
+ function terminalSide(p, node) {
99636
+ if (p.x > node.x - 2 && p.x < node.x + node.width + 2) {
99637
+ if (Math.abs(p.y - node.y) < 2) return "top";
99638
+ if (Math.abs(p.y - (node.y + node.height)) < 2) return "bottom";
99639
+ }
99640
+ if (p.y > node.y - 2 && p.y < node.y + node.height + 2) {
99641
+ if (Math.abs(p.x - node.x) < 2) return "left";
99642
+ if (Math.abs(p.x - (node.x + node.width)) < 2) return "right";
99643
+ }
99644
+ return null;
99645
+ }
99646
+ function attachSideDiagnostics(scene, model) {
99647
+ const diagnostics = [];
99648
+ const nodeById = new Map(scene.nodes.map((node) => [node.id, node]));
99649
+ for (const flow of model.flows) {
99650
+ if (!flow.fromSide && !flow.toSide) continue;
99651
+ const edge = scene.edges.find((candidate) => candidate.id === flow.id);
99652
+ if (!edge || edge.pts.length < 2) continue;
99653
+ for (const [declared, nodeId, point, role] of [
99654
+ [flow.fromSide, flow.from, edge.pts[0], "leaves"],
99655
+ [flow.toSide, flow.to, edge.pts[edge.pts.length - 1], "arrives on"]
99656
+ ]) {
99657
+ const node = declared ? nodeById.get(nodeId) : void 0;
99658
+ if (!declared || !node) continue;
99659
+ const actual = terminalSide(point, node);
99660
+ if (actual === declared.value) continue;
99661
+ diagnostics.push({
99662
+ code: "W0570",
99663
+ severity: "warning",
99664
+ message: `attachment side \`${declared.value}\` could not be honored`,
99665
+ span: declared.span,
99666
+ note: actual ? `the flow ${role} the ${actual} side of \`${nodeId}\`` : `the flow does not ${role} a side of \`${nodeId}\` cleanly`,
99667
+ help: "a side the layout cannot reach is dropped rather than forced \u2014 try the opposite endpoint, or `order:` to move the element instead"
99668
+ });
99669
+ }
99670
+ }
99671
+ return diagnostics;
99672
+ }
99351
99673
  function buildElkGraph(ctx, direction, options) {
99352
- const { model, view, ingressExternal, compact, numbered, fonts } = ctx;
99353
- return {
99674
+ const { model, view, ingressExternal, slotOf, compact, numbered, fonts } = ctx;
99675
+ const graph = {
99354
99676
  id: "root",
99355
99677
  layoutOptions: {
99356
99678
  "elk.algorithm": "layered",
@@ -99388,17 +99710,23 @@ function buildElkGraph(ctx, direction, options) {
99388
99710
  } : {}
99389
99711
  },
99390
99712
  children: model.elements.map((element, index) => {
99391
- const elkNode = toElkNode2(element, compact, fonts.cont, fonts.node);
99713
+ const elkNode = toElkNode2(element, compact, fonts, true);
99714
+ const band = elkPartitionOf(element, index, view, ingressExternal);
99715
+ const slot = slotOf.get(element.id);
99392
99716
  elkNode.layoutOptions = {
99393
99717
  ...elkNode.layoutOptions,
99718
+ // Without an `order:` anywhere the band is emitted as it always was, so
99719
+ // the drawing is byte-identical to one from before the hint existed.
99394
99720
  "elk.partitioning.partition": String(
99395
- elkPartitionOf(element, index, view, ingressExternal)
99721
+ slot === void 0 ? band : band * SLOT_SCALE + slot
99396
99722
  )
99397
99723
  };
99398
99724
  return elkNode;
99399
99725
  }),
99400
99726
  edges: model.flows.map((flow) => elkFlowEdge(flow, ctx, options?.labelWrap))
99401
99727
  };
99728
+ applyDeclaredPorts(graph, model);
99729
+ return graph;
99402
99730
  }
99403
99731
  function recordRepairs(scene, routesBefore) {
99404
99732
  for (const edge of scene.edges) {
@@ -99453,6 +99781,7 @@ async function layout(model, view) {
99453
99781
  model,
99454
99782
  view,
99455
99783
  ingressExternal: ingressExternalElements,
99784
+ slotOf: readingSlots(model, view, ingressExternalElements),
99456
99785
  compact,
99457
99786
  numbered,
99458
99787
  fonts: {
@@ -99473,18 +99802,25 @@ async function layout(model, view) {
99473
99802
  const nodes = walkedNodes.map((walked) => walked.node);
99474
99803
  for (const walked of walkedNodes) origins[walked.id] = { x: walked.x, y: walked.y };
99475
99804
  const edges = collectSceneEdges(result2, origins, numbered, edgeFontSize);
99476
- const scene2 = {
99805
+ const pinnedFlows = new Map(
99806
+ model.flows.filter((flow) => flow.fromSide || flow.toSide).map((flow) => [flow.id, { start: !!flow.fromSide, end: !!flow.toSide }])
99807
+ );
99808
+ for (const edge of edges) {
99809
+ const pinned = pinnedFlows.get(edge.id);
99810
+ if (pinned) edge.pinned = { ...pinned };
99811
+ }
99812
+ const scene = {
99477
99813
  width: Math.ceil(result2.width),
99478
99814
  height: Math.ceil(result2.height),
99479
99815
  nodes,
99480
99816
  edges,
99481
99817
  layoutMs: layoutMs2
99482
99818
  };
99483
- runGeometryPasses(scene2, model, {
99819
+ runGeometryPasses(scene, model, {
99484
99820
  numbered,
99485
99821
  sideways: disposition === "page" || disposition === "tall"
99486
99822
  });
99487
- return scene2;
99823
+ return scene;
99488
99824
  };
99489
99825
  const startTime = Date.now();
99490
99826
  let result;
@@ -99530,30 +99866,39 @@ async function layout(model, view) {
99530
99866
  result = await elk.layout(makeGraph(winnerDirection));
99531
99867
  }
99532
99868
  const layoutMs = Date.now() - startTime;
99533
- const scene = sceneFromResult(result, layoutMs);
99534
- const away = attachAwayOf(scene, model);
99869
+ const base = sceneFromResult(result, layoutMs);
99535
99870
  const skipPortPass = !!globalThis.process?.env?.CAIRN_NO_PORT_PASS;
99536
- if (!away.size || skipPortPass) return scene;
99537
- try {
99538
- const constrained = makeGraph(winnerDirection, winnerOptions);
99539
- constrainPorts(constrained, scene, away, model);
99540
- const reresult = await elk.layout(constrained);
99541
- const rescene = sceneFromResult(reresult, Date.now() - startTime);
99542
- const allEdges = (s) => new Set(s.edges.map((edge) => edge.id));
99543
- const before = inspect(scene, titleBoxesOf(scene, model)).local(allEdges(scene), /* @__PURE__ */ new Map());
99544
- const after = inspect(rescene, titleBoxesOf(rescene, model)).local(
99545
- allEdges(rescene),
99546
- /* @__PURE__ */ new Map()
99547
- );
99548
- for (const [key, tier] of selectionExtras(scene, model)) before.set(key, tier);
99549
- for (const [key, tier] of selectionExtras(rescene, model)) after.set(key, tier);
99550
- const verdict = relayoutVerdict(before, after);
99551
- return verdict >= 0 ? rescene : scene;
99552
- } catch {
99553
- return scene;
99871
+ if (skipPortPass) return base;
99872
+ const layoutProfile = (candidate) => {
99873
+ const everyEdge = new Set(candidate.edges.map((edge) => edge.id));
99874
+ const profile = inspect(candidate, titleBoxesOf(candidate, model)).local(everyEdge, /* @__PURE__ */ new Map());
99875
+ for (const [key, tier] of selectionExtras(candidate, model)) profile.set(key, tier);
99876
+ return profile;
99877
+ };
99878
+ const baseProfile = layoutProfile(base);
99879
+ let current = base;
99880
+ let best = null;
99881
+ for (let round = 0; round < PORT_PASS_ROUNDS; round++) {
99882
+ const flagged = attachAwayOf(current, model);
99883
+ if (!flagged.size) break;
99884
+ let candidate;
99885
+ try {
99886
+ const constrained = makeGraph(winnerDirection, winnerOptions);
99887
+ constrainPorts(constrained, current, flagged, model);
99888
+ const reresult = await elk.layout(constrained);
99889
+ candidate = sceneFromResult(reresult, Date.now() - startTime);
99890
+ } catch {
99891
+ break;
99892
+ }
99893
+ const tier = relayoutVerdict(baseProfile, layoutProfile(candidate));
99894
+ if (tier < 0) break;
99895
+ const scored = { scene: candidate, tier, detours: longDetourCount(candidate, model) };
99896
+ if (beatsRelayout(scored, best)) best = scored;
99897
+ current = candidate;
99554
99898
  }
99899
+ return best?.scene ?? base;
99555
99900
  }
99556
- var INGRESS_PARTITION, EGRESS_PARTITION, COMPACT_WRAP;
99901
+ var orderOption, semiInteractiveOption, PORT_PASS_ROUNDS, centerOf, INGRESS_PARTITION, EGRESS_PARTITION, COMPACT_WRAP, SLOT_SCALE, SIDE_TO_ELK;
99557
99902
  var init_scene_layout = __esm({
99558
99903
  "src/scene-layout.ts"() {
99559
99904
  "use strict";
@@ -99561,14 +99906,26 @@ var init_scene_layout = __esm({
99561
99906
  init_slide_fold();
99562
99907
  init_elk_engine();
99563
99908
  init_route_detour();
99909
+ init_geometry();
99564
99910
  init_compact();
99565
99911
  init_edge_tidy();
99566
99912
  init_readability();
99567
99913
  init_label_anchor();
99568
99914
  init_element_tree();
99915
+ orderOption = (element) => element.order ? { "elk.position": `(0,${element.order.value})` } : {};
99916
+ semiInteractiveOption = (children) => children.some((child) => child.order) ? { "elk.layered.crossingMinimization.semiInteractive": "true" } : {};
99917
+ PORT_PASS_ROUNDS = 2;
99918
+ centerOf = (n) => ({ x: n.x + n.width / 2, y: n.y + n.height / 2 });
99569
99919
  INGRESS_PARTITION = -1;
99570
99920
  EGRESS_PARTITION = 900;
99571
99921
  COMPACT_WRAP = 10;
99922
+ SLOT_SCALE = 1e3;
99923
+ SIDE_TO_ELK = {
99924
+ left: "WEST",
99925
+ right: "EAST",
99926
+ top: "NORTH",
99927
+ bottom: "SOUTH"
99928
+ };
99572
99929
  }
99573
99930
  });
99574
99931
 
@@ -100224,7 +100581,7 @@ function createEdgePainter(paint) {
100224
100581
  const flowStyle = flow?.style;
100225
100582
  const color = flowColorOf(flow);
100226
100583
  const headColor = style.flowColor === "by-source" ? color : defaultEdgeColor;
100227
- const dash = dashArray(flowStyle?.stroke?.style ?? style.flowStroke.style);
100584
+ const dash = dashArray(flowStyle?.stroke?.style ?? flow?.lineStyle ?? style.flowStroke.style);
100228
100585
  const width = flowStyle?.stroke?.width ?? style.flowStroke.width;
100229
100586
  return `<path d="${edgePath(edge.pts)}" fill="none" stroke="${escAttr(color)}" stroke-width="${width}"${dash ? ` stroke-dasharray="${dash}"` : ""} marker-end="url(#${markerName(headColor)})"/>
100230
100587
  `;
@@ -100642,6 +100999,7 @@ function watchCommand(file, outFile) {
100642
100999
  try {
100643
101000
  const view = views[model.type];
100644
101001
  const scene = await layout(model, view);
101002
+ diags.push(...attachSideDiagnostics(scene, model));
100645
101003
  const { svg, overlapsAfter } = render(model, view, scene);
100646
101004
  writeFileSync(outFile, svg);
100647
101005
  if (diags.length)
@@ -100938,7 +101296,7 @@ APP -> DB : "Read/write" (TLS1.3)
100938
101296
  };
100939
101297
  args = process.argv.slice(2);
100940
101298
  command = args[0];
100941
- version = true ? "1.0.0-RC12" : pkg.version;
101299
+ version = true ? "1.0.0-RC13" : pkg.version;
100942
101300
  VALUE_FLAGS = /* @__PURE__ */ new Set(["-o", "--format"]);
100943
101301
  positionalFile = () => {
100944
101302
  for (let index = 0; index < args.length; index++) {
@@ -100987,6 +101345,7 @@ APP -> DB : "Read/write" (TLS1.3)
100987
101345
  model.typeSpan ?? { line: 1, col: 1, len: 7 }
100988
101346
  );
100989
101347
  if (diagnostic) diagnostics.push(diagnostic);
101348
+ diagnostics.push(...attachSideDiagnostics(scene, model));
100990
101349
  const warnings = diagnostics.filter((d) => d.severity === "warning");
100991
101350
  if (warnings.length)
100992
101351
  console.error(renderHuman(file, src, warnings, process.stderr.isTTY ?? false));