@quolu/lattice 0.12.30 → 0.12.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.12.30",
3
+ "version": "0.12.31",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -436,6 +436,8 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
436
436
  });
437
437
  stageRowCount.push(Math.max(0, ...layer.map((key) => stageRow.get(key))) + 1);
438
438
  }
439
+ const maximumLayerWidth = Math.max(1,
440
+ ...[...transversePosition.values()].map((position) => position + 1));
439
441
  const visibleKeys = new Set(nodes.map(({ key }) => key));
440
442
  const displayBranches = edges.flatMap((edge, semanticIndex) => {
441
443
  const identities = [...edge.joinIdentities.entries()]
@@ -455,14 +457,26 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
455
457
  if (!portGroups.has(key)) portGroups.set(key, []);
456
458
  portGroups.get(key).push(entry);
457
459
  };
460
+ // 隣の段の真下へ繋ぐ辺は、出発と到着が同じ取り付け位置の集合を奪い合う。別々の
461
+ // 枠を取ると2本の縦線が12pxずれて短い横棒で継がれ、まっすぐ引ける線が段差付きに
462
+ // 見える。両端の縦線は帯の中で1点で接するだけなので、1枠を共有させて直線にする。
463
+ const sharedPort = new Set();
458
464
  displayBranches.forEach((branch) => {
459
465
  const { edge } = branch;
460
466
  for (const gap of new Set([wave.get(edge.from), wave.get(edge.to) - 1])) {
461
467
  if (!gapGroups.has(gap)) gapGroups.set(gap, []);
462
468
  gapGroups.get(gap).push(branch.key);
463
469
  }
464
- addPort(wave.get(edge.from), transversePosition.get(edge.from), portEntryKey(branch.key, 'departure'));
465
- addPort(wave.get(edge.to) - 1, transversePosition.get(edge.to), portEntryKey(branch.key, 'arrival'));
470
+ const departureGap = wave.get(edge.from);
471
+ const fromColumn = transversePosition.get(edge.from);
472
+ const toColumn = transversePosition.get(edge.to);
473
+ if (departureGap === wave.get(edge.to) - 1 && fromColumn === toColumn) {
474
+ sharedPort.add(branch.key);
475
+ addPort(departureGap, fromColumn, portEntryKey(branch.key, 'through'));
476
+ return;
477
+ }
478
+ addPort(departureGap, fromColumn, portEntryKey(branch.key, 'departure'));
479
+ addPort(wave.get(edge.to) - 1, toColumn, portEntryKey(branch.key, 'arrival'));
466
480
  });
467
481
  for (const keys of gapGroups.values()) keys.sort(compareText);
468
482
  for (const entries of portGroups.values()) entries.sort(compareText);
@@ -477,9 +491,51 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
477
491
  const allGapIndexes = new Set([...gapGroups.keys(), ...connectorGroupKeysByGap.keys()]);
478
492
  const maximumGapOccupancy = Math.max(0, ...[...allGapIndexes].map((gap) =>
479
493
  (gapGroups.get(gap)?.length ?? 0) + (connectorGroupKeysByGap.get(gap)?.size ?? 0)));
494
+ // An edge that skips a stage has to travel vertically past the stages in
495
+ // between. It used to do that in a corridor beyond the right edge of the
496
+ // whole diagram, so a local hop between two neighbouring cards was drawn as a
497
+ // trip to the far right and back. It now descends through the vertical
498
+ // channel between two columns of cards, chosen from the boundaries that lie
499
+ // between its endpoints. Every card sits on the same column grid, so those
500
+ // gaps are free of cards at every stage: routing inside one is both shorter
501
+ // to follow and structurally unable to cross a card.
502
+ const channelLoad = new Map();
503
+ const channelAssignment = new Map();
504
+ const channelTraffic = (boundary) => channelLoad.get(boundary) ?? 0;
505
+ for (const branch of [...displayBranches].sort((left, right) => compareText(left.key, right.key))) {
506
+ const { edge } = branch;
507
+ if (wave.get(edge.from) === wave.get(edge.to) - 1) continue;
508
+ const fromColumn = transversePosition.get(edge.from);
509
+ const toColumn = transversePosition.get(edge.to);
510
+ const [first, last] = fromColumn === toColumn
511
+ ? [toColumn, toColumn + 1]
512
+ : [Math.min(fromColumn, toColumn) + 1, Math.max(fromColumn, toColumn)];
513
+ let chosen = first;
514
+ for (let boundary = first + 1; boundary <= last; boundary += 1) {
515
+ const closer = Math.abs(boundary - toColumn) < Math.abs(chosen - toColumn);
516
+ if (channelTraffic(boundary) < channelTraffic(chosen)
517
+ || (channelTraffic(boundary) === channelTraffic(chosen) && closer)) chosen = boundary;
518
+ }
519
+ channelAssignment.set(branch.key, { boundary: chosen, lane: channelTraffic(chosen) });
520
+ channelLoad.set(chosen, channelTraffic(chosen) + 1);
521
+ }
522
+
480
523
  const nodeWidth = Math.max(GEOMETRY.node_width,
481
524
  GEOMETRY.route_inset * 2 + (maximumPortTraffic + 1) * GEOMETRY.route_spacing);
482
- const laneGap = nodeWidth + 24;
525
+ // Only the boundaries that actually carry channels pay for them. Boundary 0
526
+ // is the margin left of the first column and only exists when something
527
+ // routes there, so an untravelled diagram keeps its original left edge.
528
+ const channelWidth = (boundary) => Math.max(24,
529
+ (channelTraffic(boundary) + 1) * GEOMETRY.route_spacing);
530
+ const columnLeft = [GEOMETRY.left + (channelTraffic(0) === 0 ? 0 : channelWidth(0))];
531
+ for (let column = 1; column < maximumLayerWidth; column += 1) {
532
+ columnLeft.push(columnLeft[column - 1] + nodeWidth + channelWidth(column));
533
+ }
534
+ const channelPosition = (edgeKey) => {
535
+ const { boundary, lane } = channelAssignment.get(edgeKey);
536
+ const left = boundary === 0 ? GEOMETRY.left : columnLeft[boundary - 1] + nodeWidth;
537
+ return left + GEOMETRY.route_spacing * (lane + 1);
538
+ };
483
539
  // Each routing band is sized for the edges that actually cross it. One
484
540
  // crowded band used to set the spacing for every stage in the diagram, so a
485
541
  // long plan paid the width of its busiest junction on all of its stages.
@@ -496,7 +552,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
496
552
  const projectedNodes = nodes.map((node) => {
497
553
  const visible = visibleKeys.has(node.key);
498
554
  const geometry = visible ? {
499
- x: GEOMETRY.left + transversePosition.get(node.key) * laneGap,
555
+ x: columnLeft[transversePosition.get(node.key)],
500
556
  y: stageTop[wave.get(node.key)]
501
557
  + stageRow.get(node.key) * (GEOMETRY.node_height + GEOMETRY.stage_row_gap),
502
558
  width: nodeWidth,
@@ -521,20 +577,18 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
521
577
  return stageTop[waveIndex] + stageHeight(waveIndex)
522
578
  + GEOMETRY.route_spacing * (index + 1);
523
579
  };
524
- const maximumLayerWidth = Math.max(1,
525
- ...[...transversePosition.values()].map((position) => position + 1));
526
- const routeRight = GEOMETRY.left + (maximumLayerWidth - 1) * laneGap + nodeWidth + 12;
527
580
  const branchCounts = new Map();
528
581
  for (const branch of displayBranches) {
529
582
  branchCounts.set(branch.semanticIndex, (branchCounts.get(branch.semanticIndex) ?? 0) + 1);
530
583
  }
531
584
  const portPosition = (edgeKey, direction, gap, row, nodeX) => {
532
585
  const entries = portGroups.get(portGroupKey(gap, row));
586
+ const entry = portEntryKey(edgeKey, sharedPort.has(edgeKey) ? 'through' : direction);
533
587
  return nodeX + GEOMETRY.route_inset
534
- + GEOMETRY.route_spacing * (entries.indexOf(portEntryKey(edgeKey, direction)) + 1);
588
+ + GEOMETRY.route_spacing * (entries.indexOf(entry) + 1);
535
589
  };
536
590
 
537
- const projectedEdges = displayBranches.map((branch, index) => {
591
+ const projectedEdges = displayBranches.map((branch) => {
538
592
  const { edge } = branch;
539
593
  const onLongestChain = longestEdgeKeys.has(edge.key);
540
594
  const from = coordinates.get(edge.from);
@@ -549,11 +603,17 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
549
603
  const endY = to.y;
550
604
  const departureY = gapPosition(branch.key, departureGap);
551
605
  const arrivalY = gapPosition(branch.key, arrivalGap);
552
- const corridorX = routeRight + GEOMETRY.route_spacing * (index + 1);
553
- const route = [[startX, startY], [startX, departureY]];
554
- if (arrivalY === departureY) route.push([endX, arrivalY]);
555
- else route.push([corridorX, departureY], [corridorX, arrivalY], [endX, arrivalY]);
556
- route.push([endX, endY]);
606
+ const corners = [[startX, startY], [startX, departureY]];
607
+ if (departureGap === arrivalGap) corners.push([endX, arrivalY]);
608
+ else {
609
+ const channelX = channelPosition(branch.key);
610
+ corners.push([channelX, departureY], [channelX, arrivalY], [endX, arrivalY]);
611
+ }
612
+ corners.push([endX, endY]);
613
+ // 1枠を共有する直線では折れ点が重なる。長さゼロの区間を残すと交差判定にも
614
+ // 描画にも意味のない点が混ざるので畳む。
615
+ const route = corners.filter(([x, y], index) =>
616
+ index === 0 || x !== corners[index - 1][0] || y !== corners[index - 1][1]);
557
617
  return {
558
618
  id: branchCounts.get(branch.semanticIndex) === 1
559
619
  ? `edge-${branch.semanticIndex}` : `edge-${branch.semanticIndex}-join-${branch.branchIndex}`,
@@ -610,7 +670,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
610
670
  }
611
671
  addCrossingBridges([...projectedEdges, ...junctionConnectors], logicalContacts);
612
672
  let routeMaximumX = nodes.length === 0 ? 0
613
- : GEOMETRY.left + (maximumLayerWidth - 1) * laneGap + nodeWidth;
673
+ : columnLeft[maximumLayerWidth - 1] + nodeWidth;
614
674
  for (const edge of projectedEdges) {
615
675
  for (const [x] of edge.route) routeMaximumX = Math.max(routeMaximumX, x);
616
676
  }