@parallelworks/ui 0.82.1 → 0.83.0

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.
@@ -26,7 +26,7 @@ import { WarningMarks, WarningMarksContext, locateWarnings, rowKey } from "./run
26
26
  import { Joblist } from "./JobSummary.js";
27
27
  import { Store, useStore } from "./store.js";
28
28
  import { Lineage, LineageContext } from "./lineage.js";
29
- import { DimRules, JobNode, setDimmed } from "./GraphNode.js";
29
+ import { DimRules, GraphDirectionContext, JobNode, setDimmed } from "./GraphNode.js";
30
30
  import { ConnectorLayers } from "./Connectors.js";
31
31
  import { routeConnectors, sameBoxes } from "./connectorGeometry.js";
32
32
  import { isJobRecord } from "./types.js";
@@ -35,7 +35,7 @@ import { MatrixGroupNode, MatrixGroupSummaryItem } from "./MatrixGroup.js";
35
35
  import { StepSummaries } from "./StepSummaries.js";
36
36
  import { StepSummary } from "./StepSummary.js";
37
37
  import TreeView from "./TreeView.js";
38
- import { FIT_MAX_SCALE, MIN_SCALE, ZOOM_STEP, enclosing, fitMargin, fitView, intoView, viewShowing, wheelFactor, zoomAround, zoomLimit } from "./viewport.js";
38
+ import { FIT_MAX_SCALE, MIN_SCALE, ZOOM_STEP, enclosing, fitMargin, fitView, intoReach, intoView, reachKept, viewShowing, wheelFactor, zoomAround, zoomLimit } from "./viewport.js";
39
39
  import { Fragment, Suspense, createContext, lazy, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
40
40
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
41
41
  import { flushSync } from "react-dom";
@@ -294,14 +294,20 @@ function sizingTransitions(el) {
294
294
  }
295
295
  var HOLD_MS = 2e3;
296
296
  var skippedBefore = (slots, i) => (slots?.[i] ?? i) - (i === 0 ? 0 : (slots?.[i - 1] ?? i - 1) + 1);
297
- function measureBoxes(cols, pathPrefix, wrapper) {
297
+ function measureBoxes(cols, pathPrefix, wrapper, down) {
298
298
  const boxes = /* @__PURE__ */ new Map();
299
299
  for (const col of cols) for (const [head] of col) {
300
300
  const el = head === void 0 ? null : document.getElementById(`node_${pathPrefix}${head}`);
301
- if (head !== void 0 && el) boxes.set(head, {
302
- ...offsetWithin(el, wrapper),
303
- width: el.offsetWidth
304
- });
301
+ if (head !== void 0 && el) {
302
+ const box = {
303
+ ...offsetWithin(el, wrapper),
304
+ width: el.offsetWidth
305
+ };
306
+ boxes.set(head, down ? {
307
+ ...box,
308
+ height: el.offsetHeight
309
+ } : box);
310
+ }
305
311
  }
306
312
  return boxes;
307
313
  }
@@ -313,6 +319,7 @@ var Subgraph = memo(function Subgraph({ jobs, pathPrefix, depth, preview, animT,
313
319
  const editorApi = useGraphEditor();
314
320
  const selectedEdges = useSelectedEdges(editable ? editorApi : null);
315
321
  const zBase = depth * SUBGRAPH_Z_STEP;
322
+ const down = useContext(GraphDirectionContext) === "down";
316
323
  const placed = useMemo(() => layout ?? (depth > 0 ? positionsOf(jobs) : void 0), [
317
324
  layout,
318
325
  depth,
@@ -459,9 +466,10 @@ var Subgraph = memo(function Subgraph({ jobs, pathPrefix, depth, preview, animT,
459
466
  toggleMatrix
460
467
  ]);
461
468
  const nodes = useMemo(() => /* @__PURE__ */ jsx("div", {
462
- className: "flex relative",
469
+ className: down ? "flex flex-col items-center relative" : "flex relative",
463
470
  children: dependencyCols.map((col, c) => /* @__PURE__ */ jsx("div", {
464
- style: emptyColumnsStyle(skippedBefore(colSlots, c)),
471
+ className: down ? "flex items-start justify-center" : void 0,
472
+ style: down ? void 0 : emptyColumnsStyle(skippedBefore(colSlots, c)),
465
473
  children: col.map((jobNames, i) => {
466
474
  const first = jobNames[0];
467
475
  if (first === void 0) return null;
@@ -504,16 +512,17 @@ var Subgraph = memo(function Subgraph({ jobs, pathPrefix, depth, preview, animT,
504
512
  pathPrefix,
505
513
  toggleMatrix,
506
514
  handlers,
507
- editable
515
+ editable,
516
+ down
508
517
  ]);
509
518
  if (!dependencyCols[0]?.length) return null;
510
519
  const wrapper = wrapperRef.current;
511
- const measured = wrapper ? measureBoxes(dependencyCols, pathPrefix, wrapper) : /* @__PURE__ */ new Map();
520
+ const measured = wrapper ? measureBoxes(dependencyCols, pathPrefix, wrapper, down) : /* @__PURE__ */ new Map();
512
521
  const last = routedRef.current;
513
522
  const routed = last && last.directDeps === directDeps && sameBoxes(last.boxes, measured) ? last : {
514
523
  boxes: measured,
515
524
  directDeps,
516
- edges: routeConnectors(dependencyCols, colSlots.some((column, c) => column !== c), directDeps, measured, 37)
525
+ edges: routeConnectors(dependencyCols, colSlots.some((column, c) => column !== c), directDeps, measured, 37, down ? "down" : "right")
517
526
  };
518
527
  routedRef.current = routed;
519
528
  const { boxes, edges } = routed;
@@ -704,6 +713,8 @@ function graphBounds(content) {
704
713
  }
705
714
  return bounds;
706
715
  }
716
+ var NARROW_PANEL_PX = 640;
717
+ var BLEED_CLASSES = "max-sm:-mx-4 max-sm:w-auto max-sm:rounded-none max-sm:border-x-0";
707
718
  var READABLE_SCALE = .5;
708
719
  /** Where a job or step a warning took the graph to shows: the panel below its view bar, which sits
709
720
  * over the graph. */
@@ -741,7 +752,7 @@ function flash(row) {
741
752
  easing: "ease-in"
742
753
  });
743
754
  }
744
- function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag", setViewMode, layout, editor, yamlJobs, inputs, workflow, fixedHeight, openStep, onCloseLog, stickySidebar }) {
755
+ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag", setViewMode, layout, editor, yamlJobs, inputs, workflow, fixedHeight, openStep, onCloseLog, stickySidebar, bleed = false }) {
745
756
  const engine = useWorkflowEngine();
746
757
  const animT = .125;
747
758
  const rootJobs = isJobRecord(run.executedJobs) ? run.executedJobs : {};
@@ -800,6 +811,7 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
800
811
  }
801
812
  }, [preview]);
802
813
  const containerHeight = topOffset !== null ? Math.max(windowSize.h - topOffset - 16, 160) : null;
814
+ const runsDown = !preview && !editor && windowSize.w < NARROW_PANEL_PX;
803
815
  const [fitHeight, setFitHeight] = useState(null);
804
816
  const fitViewRef = useRef(null);
805
817
  useEffect(() => {
@@ -1012,7 +1024,12 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
1012
1024
  if (dx !== 0 || dy !== 0) requestAnimationFrame(() => transform.setTransform(x + dx, y + dy, scale, FIT_ANIMATION_MS, "easeOut"));
1013
1025
  return;
1014
1026
  }
1015
- const view = fitView(bounds, panel);
1027
+ const narrow = !preview && !editor && panel.width < NARROW_PANEL_PX;
1028
+ const view = fitView(bounds, panel, narrow || runsDown ? {
1029
+ minScale: READABLE_SCALE,
1030
+ top: viewBarRef.current?.offsetHeight ?? 0,
1031
+ width: runsDown
1032
+ } : void 0);
1016
1033
  if (!view) return;
1017
1034
  if (animationMs > 0) {
1018
1035
  transform.setTransform(view.x, view.y, view.scale, animationMs, "easeOut");
@@ -1024,7 +1041,9 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
1024
1041
  }, [
1025
1042
  nodeOf,
1026
1043
  editor,
1027
- runRowOf
1044
+ runRowOf,
1045
+ preview,
1046
+ runsDown
1028
1047
  ]);
1029
1048
  const fitWhenSettled = useCallback(async () => {
1030
1049
  const container = containerRef.current;
@@ -1135,6 +1154,27 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
1135
1154
  const moved = useCallback(() => {
1136
1155
  viewed.current = true;
1137
1156
  }, []);
1157
+ const keepInReach = useCallback(() => {
1158
+ const container = containerRef.current;
1159
+ const content = graphContentRef.current;
1160
+ const transform = transformRef.current;
1161
+ const bounds = content ? graphBounds(content) : null;
1162
+ if (!container || !transform || !bounds) return;
1163
+ const { positionX, positionY, scale } = transform.instance.state;
1164
+ const panel = {
1165
+ left: 0,
1166
+ top: 0,
1167
+ width: container.clientWidth,
1168
+ height: container.clientHeight
1169
+ };
1170
+ const { dx, dy } = intoReach({
1171
+ left: positionX + bounds.minX * scale,
1172
+ top: positionY + bounds.minY * scale,
1173
+ width: (bounds.maxX - bounds.minX) * scale,
1174
+ height: (bounds.maxY - bounds.minY) * scale
1175
+ }, panel, reachKept(panel));
1176
+ if (dx !== 0 || dy !== 0) transform.setTransform(positionX + dx, positionY + dy, scale, FIT_ANIMATION_MS, "easeOut");
1177
+ }, []);
1138
1178
  const zoomTo = useCallback((view, animationMs) => {
1139
1179
  viewed.current = true;
1140
1180
  transformRef.current?.setTransform(view.x, view.y, view.scale, animationMs, "easeOut");
@@ -1484,7 +1524,7 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
1484
1524
  }),
1485
1525
  !preview && /* @__PURE__ */ jsx(AnnotationBanner, { executedJobs: jobs }),
1486
1526
  viewMode === "tree" && !preview ? /* @__PURE__ */ jsx("div", {
1487
- className: "panel w-full border",
1527
+ className: cx("panel w-full border", bleed && BLEED_CLASSES),
1488
1528
  children: /* @__PURE__ */ jsx(TreeView, {
1489
1529
  jobs,
1490
1530
  slug: runSlug,
@@ -1494,9 +1534,9 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
1494
1534
  }) : /* @__PURE__ */ jsxs("div", {
1495
1535
  ref: setContainerElement,
1496
1536
  tabIndex: editor ? -1 : void 0,
1497
- className: cx("panel w-full overflow-hidden relative", removeBorder ? "border-none" : "border", editor && "outline-none"),
1537
+ className: cx("panel w-full overflow-hidden relative", removeBorder ? "border-none" : "border", bleed && BLEED_CLASSES, editor && "outline-none"),
1498
1538
  style: {
1499
- height: fixedHeight ?? (fitHeight !== null ? `${Math.min(fitHeight, containerHeight ?? windowSize.h * .62)}px` : containerHeight !== null ? `${containerHeight}px` : "62vh"),
1539
+ height: fixedHeight ?? (fitHeight !== null ? `${Math.min(fitHeight, runsDown ? Math.max(containerHeight ?? 0, windowSize.h * .75) : containerHeight ?? windowSize.h * .62)}px` : containerHeight !== null ? `${containerHeight}px` : "62vh"),
1500
1540
  minHeight: preview && fitHeight !== null && !fixedHeight ? 0 : 160
1501
1541
  },
1502
1542
  children: [/* @__PURE__ */ jsx(TransformWrapper, {
@@ -1511,8 +1551,11 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
1511
1551
  onInit: () => {
1512
1552
  setTimeout(invalidateConnectors, 0);
1513
1553
  },
1554
+ velocityAnimation: { sensitivityTouch: 0 },
1514
1555
  onPanning: moved,
1556
+ onPanningStop: keepInReach,
1515
1557
  onPinch: moved,
1558
+ onPinchStop: keepInReach,
1516
1559
  onTransform,
1517
1560
  children: () => /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
1518
1561
  ref: viewBarRef,
@@ -1542,27 +1585,30 @@ function DependencyGraph({ run, preview = false, removeBorder, viewMode = "dag",
1542
1585
  ref: graphContentRef,
1543
1586
  className: "flex relative mr-[8000px]",
1544
1587
  style: { minHeight: 4e3 },
1545
- children: /* @__PURE__ */ jsx(Subgraph, {
1546
- jobs,
1547
- pathPrefix: pathAppend,
1548
- depth: 0,
1549
- preview,
1550
- animT,
1551
- expandedSubworkflows,
1552
- toggleSubworkflow,
1553
- openInNewGraph,
1554
- setSublogOpen,
1555
- invalidateConnectors,
1556
- requestFit: fitWhenSettled,
1557
- stepsOpen,
1558
- toggleSteps,
1559
- matrixOpen,
1560
- toggleMatrix,
1561
- onReady: fitGraph,
1562
- layout: placed,
1563
- graph: rootGraph,
1564
- editable: !!editorApi && currentPath.length === 0
1565
- }, `sg-${executedJobsPathIdx}-${pathAppend}`)
1588
+ children: /* @__PURE__ */ jsx(GraphDirectionContext, {
1589
+ value: runsDown ? "down" : "right",
1590
+ children: /* @__PURE__ */ jsx(Subgraph, {
1591
+ jobs,
1592
+ pathPrefix: pathAppend,
1593
+ depth: 0,
1594
+ preview,
1595
+ animT,
1596
+ expandedSubworkflows,
1597
+ toggleSubworkflow,
1598
+ openInNewGraph,
1599
+ setSublogOpen,
1600
+ invalidateConnectors,
1601
+ requestFit: fitWhenSettled,
1602
+ stepsOpen,
1603
+ toggleSteps,
1604
+ matrixOpen,
1605
+ toggleMatrix,
1606
+ onReady: fitGraph,
1607
+ layout: placed,
1608
+ graph: rootGraph,
1609
+ editable: !!editorApi && currentPath.length === 0
1610
+ }, `sg-${executedJobsPathIdx}-${pathAppend}`)
1611
+ })
1566
1612
  })
1567
1613
  }) })] })
1568
1614
  }), editorOverlays]
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
+ import type { GraphDirection } from './connectorGeometry.js';
2
3
  import { type JobHandlers } from './JobSummary.js';
3
4
  import type { WorkflowJob } from './types.js';
4
5
  export declare const dimClasses: (depth: number) => {
@@ -10,6 +11,8 @@ export declare const dimClasses: (depth: number) => {
10
11
  export declare function DimRules({ depth }: {
11
12
  depth: number;
12
13
  }): import("react").JSX.Element;
14
+ /** Which way the graph around a node runs; a run's graph on a phone runs down. */
15
+ export declare const GraphDirectionContext: import("react").Context<GraphDirection>;
13
16
  /** Dims what of a graph level isn't in the hovered job's lineage, while one is hovered. */
14
17
  export declare function setDimmed(wrapper: HTMLElement, depth: number, hovering: boolean): void;
15
18
  interface NodeShell {
@@ -2,7 +2,7 @@ import { BoxGrip } from "./editorRows.js";
2
2
  import { emptyRowsStyle } from "./gridSpacing.js";
3
3
  import { Joblist } from "./JobSummary.js";
4
4
  import { useLineage, useLineageStore } from "./lineage.js";
5
- import { memo } from "react";
5
+ import { createContext, memo, useContext } from "react";
6
6
  import { jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/graph/GraphNode.tsx
8
8
  var dimClasses = (depth) => ({
@@ -19,6 +19,8 @@ function DimRules({ depth }) {
19
19
  children: [`.${level} .${node}{opacity:.5}.${level} .${edge}{opacity:.3}`, "@media (prefers-reduced-motion:reduce){.dag-fade{transition:none!important}}"]
20
20
  });
21
21
  }
22
+ /** Which way the graph around a node runs; a run's graph on a phone runs down. */
23
+ var GraphDirectionContext = createContext("right");
22
24
  /** Dims what of a graph level isn't in the hovered job's lineage, while one is hovered. */
23
25
  function setDimmed(wrapper, depth, hovering) {
24
26
  wrapper.classList.toggle(dimClasses(depth).level, hovering);
@@ -26,6 +28,7 @@ function setDimmed(wrapper, depth, hovering) {
26
28
  /** One node of the graph: its jobs' box, raised above connectors while its lineage is shown. */
27
29
  function GraphNode({ id, job, rowsAbove, zBase, children }) {
28
30
  const lineage = useLineageStore();
31
+ const down = useContext(GraphDirectionContext) === "down";
29
32
  const highlight = useLineage((l) => l.node(job), 0);
30
33
  const enter = () => lineage?.hover(job);
31
34
  const leave = () => lineage?.hover(null);
@@ -35,10 +38,10 @@ function GraphNode({ id, job, rowsAbove, zBase, children }) {
35
38
  return /* @__PURE__ */ jsxs("div", {
36
39
  id,
37
40
  role: "none",
38
- className: "relative m-24",
41
+ className: down ? "relative mx-8 my-16" : "relative m-24",
39
42
  style: {
40
43
  zIndex: (highlight > 0 ? 25 : 1) + zBase,
41
- ...emptyRowsStyle(rowsAbove)
44
+ ...down ? {} : emptyRowsStyle(rowsAbove)
42
45
  },
43
46
  onMouseEnter: enter,
44
47
  onMouseLeave: leave,
@@ -70,4 +73,4 @@ var JobNode = memo(function JobNode({ jobNames, jobs, handlers, preview, editabl
70
73
  });
71
74
  });
72
75
  //#endregion
73
- export { DimRules, GraphNode, JobNode, dimClasses, setDimmed };
76
+ export { DimRules, GraphDirectionContext, GraphNode, JobNode, dimClasses, setDimmed };
@@ -77,20 +77,20 @@ function JobRow({ jobName, job, isExpanded, onToggle, prefix, state }) {
77
77
  className: "flex min-w-0 items-center gap-1.5",
78
78
  children: [/* @__PURE__ */ jsx("span", {
79
79
  className: "font-mono text-sm text-[var(--theme-app)] truncate",
80
+ title: jobLabel(jobName, job),
80
81
  children: jobLabel(jobName, job)
81
82
  }), /* @__PURE__ */ jsx(WarningMark, { warnings: marks.under(rowKey(prefix, jobName)) })]
82
83
  }), dependencies.length > 0 && /* @__PURE__ */ jsxs("div", {
83
- className: "flex items-center gap-1.5",
84
+ className: "flex flex-wrap items-center gap-x-1.5 gap-y-1",
84
85
  children: [/* @__PURE__ */ jsx("span", {
85
- className: "text-[10px] text-[var(--theme-muted-text-color)]",
86
+ className: "whitespace-nowrap text-xs text-[var(--theme-muted-text-color)]",
86
87
  children: t.dependsOn
87
88
  }), dependencies.map((dep) => /* @__PURE__ */ jsx("span", {
88
- className: "text-[10px] px-1.5 py-0.5 rounded bg-[var(--theme-muted-panel-bg)] text-[var(--theme-muted-text-color)]",
89
+ className: "whitespace-nowrap text-xs px-1.5 py-0.5 rounded bg-[var(--theme-muted-panel-bg)] text-[var(--theme-muted-text-color)]",
89
90
  children: jobLabel(dep)
90
91
  }, dep))]
91
92
  })]
92
93
  }),
93
- /* @__PURE__ */ jsx("div", { className: "flex-1" }),
94
94
  /* @__PURE__ */ jsx(Elapsed, {
95
95
  start: job.startedAt,
96
96
  end: job.completedAt,
@@ -18,11 +18,18 @@ export declare function arcRadius(minRise: number, colGap: number): number;
18
18
  * a connector spanning several columns runs level until it nears its target.
19
19
  */
20
20
  export declare function connectorPath(start: Point, end: Point, r: number, colGap: number): ConnectorPath;
21
+ /** Which way a graph runs: ranks as columns, left to right, or as rows, top to bottom. */
22
+ export type GraphDirection = 'right' | 'down';
23
+ /** A connector from a node's bottom to a later row's node's top: `connectorPath` turned a quarter,
24
+ * with the bends in the gap above `end`'s row. */
25
+ export declare function connectorPathDown(start: Point, end: Point, r: number, rowGap: number): ConnectorPath;
21
26
  /** A node's place in its graph level, as laid out. */
22
27
  export interface Box {
23
28
  x: number;
24
29
  y: number;
25
30
  width: number;
31
+ /** Where a graph runs down, its height, which places the connectors leaving its bottom. */
32
+ height?: number | undefined;
26
33
  }
27
34
  /** Whether two layouts put every node in the same place. */
28
35
  export declare function sameBoxes(a: Map<string, Box>, b: Map<string, Box>): boolean;
@@ -41,4 +48,6 @@ export interface DrawnEdge {
41
48
  */
42
49
  export declare function routeConnectors(cols: string[][][],
43
50
  /** Whether columns are left empty between those drawn, so gaps between them differ. */
44
- spaced: boolean, deps: Record<string, string[]>, boxes: Map<string, Box>, connectorY: number): DrawnEdge[];
51
+ spaced: boolean, deps: Record<string, string[]>, boxes: Map<string, Box>, connectorY: number,
52
+ /** `down` joins a node's bottom to the top of one in a later row, at their middles. */
53
+ direction?: GraphDirection): DrawnEdge[];
@@ -44,12 +44,44 @@ function connectorPath(start, end, r, colGap) {
44
44
  ].join(" ")
45
45
  };
46
46
  }
47
+ /** A connector from a node's bottom to a later row's node's top: `connectorPath` turned a quarter,
48
+ * with the bends in the gap above `end`'s row. */
49
+ function connectorPathDown(start, end, r, rowGap) {
50
+ const across = connectorPath({
51
+ x: start.y,
52
+ y: start.x
53
+ }, {
54
+ x: end.y,
55
+ y: end.x
56
+ }, r, rowGap);
57
+ return {
58
+ d: transposed(across.d),
59
+ reversed: transposed(across.reversed)
60
+ };
61
+ }
62
+ function transposed(d) {
63
+ const parts = d.split(" ");
64
+ const out = [];
65
+ for (let i = 0; i < parts.length;) {
66
+ const op = parts[i++] ?? "";
67
+ if (op === "A") {
68
+ const [rx, ry, rot, large, sweep, x, y] = parts.slice(i, i + 7);
69
+ out.push("A", ry ?? "", rx ?? "", rot ?? "", large ?? "", sweep === "1" ? "0" : "1", y ?? "", x ?? "");
70
+ i += 7;
71
+ } else {
72
+ const [x, y] = parts.slice(i, i + 2);
73
+ out.push(op, y ?? "", x ?? "");
74
+ i += 2;
75
+ }
76
+ }
77
+ return out.join(" ");
78
+ }
47
79
  /** Whether two layouts put every node in the same place. */
48
80
  function sameBoxes(a, b) {
49
81
  if (a.size !== b.size) return false;
50
82
  for (const [job, box] of a) {
51
83
  const other = b.get(job);
52
- if (!other || other.x !== box.x || other.y !== box.y || other.width !== box.width) return false;
84
+ if (!other || other.x !== box.x || other.y !== box.y || other.width !== box.width || other.height !== box.height) return false;
53
85
  }
54
86
  return true;
55
87
  }
@@ -58,7 +90,8 @@ function sameBoxes(a, b) {
58
90
  * jobs share their needs, so its first job's needs are the node's; a need naming a node not laid out
59
91
  * draws nothing. `connectorY` is how far below a node's top its connectors meet it.
60
92
  */
61
- function routeConnectors(cols, spaced, deps, boxes, connectorY) {
93
+ function routeConnectors(cols, spaced, deps, boxes, connectorY, direction = "right") {
94
+ if (direction === "down") return routeDown(cols, deps, boxes);
62
95
  const heads = (c) => cols[c]?.[0]?.[0];
63
96
  const gapAfter = (c) => {
64
97
  const left = boxes.get(heads(c) ?? "");
@@ -99,5 +132,50 @@ function routeConnectors(cols, spaced, deps, boxes, connectorY) {
99
132
  path: connectorPath(edge.start, edge.end, r, colGap)
100
133
  }));
101
134
  }
135
+ function routeDown(rows, deps, boxes) {
136
+ const top = (row) => Math.min(...row.map(([head]) => boxes.get(head ?? "")?.y ?? Number.POSITIVE_INFINITY));
137
+ const bottom = (row) => Math.max(...row.map(([head]) => {
138
+ const box = boxes.get(head ?? "");
139
+ return box ? box.y + (box.height ?? 0) : Number.NEGATIVE_INFINITY;
140
+ }));
141
+ let rowGap = Number.POSITIVE_INFINITY;
142
+ for (let r = 0; r + 1 < rows.length; r++) {
143
+ const gap = top(rows[r + 1] ?? []) - bottom(rows[r] ?? []);
144
+ if (Number.isFinite(gap)) rowGap = Math.min(rowGap, gap);
145
+ }
146
+ if (!Number.isFinite(rowGap)) rowGap = 32;
147
+ const ends = [];
148
+ let minShift = Number.POSITIVE_INFINITY;
149
+ for (const row of rows) for (const [to] of row) {
150
+ const target = to === void 0 ? void 0 : boxes.get(to);
151
+ if (to === void 0 || !target) continue;
152
+ for (const from of deps[to] ?? []) {
153
+ const source = boxes.get(from);
154
+ if (!source) continue;
155
+ const start = {
156
+ x: Math.round(source.x + source.width / 2),
157
+ y: source.y + (source.height ?? 0) - 2
158
+ };
159
+ const middle = Math.round(target.x + target.width / 2);
160
+ const end = {
161
+ x: Math.abs(middle - start.x) <= 8 ? start.x : middle,
162
+ y: target.y + 2
163
+ };
164
+ const shift = Math.abs(end.x - start.x);
165
+ if (shift > 0) minShift = Math.min(minShift, shift);
166
+ ends.push({
167
+ from,
168
+ to,
169
+ start,
170
+ end
171
+ });
172
+ }
173
+ }
174
+ const r = arcRadius(minShift, rowGap);
175
+ return ends.map((edge) => ({
176
+ ...edge,
177
+ path: connectorPathDown(edge.start, edge.end, r, rowGap)
178
+ }));
179
+ }
102
180
  //#endregion
103
- export { arcRadius, connectorPath, routeConnectors, sameBoxes };
181
+ export { arcRadius, connectorPath, connectorPathDown, routeConnectors, sameBoxes };
@@ -31,13 +31,37 @@ export declare function fitMargin(panel: {
31
31
  width: number;
32
32
  height: number;
33
33
  }): number;
34
+ /** How a fit may differ from showing everything. */
35
+ export interface FitOptions {
36
+ /** The farthest out it goes: a graph too big to read at this scale shows from its start,
37
+ * its top left, for the rest to be panned to. */
38
+ minScale?: number | undefined;
39
+ /** Room kept clear at the panel's top, such as under a bar over it, when the fit stops at
40
+ * `minScale`. */
41
+ top?: number | undefined;
42
+ /** Fits the graph's width alone, for one that runs down the panel to be panned along: centered
43
+ * across the panel, from its top below `top`. */
44
+ width?: boolean | undefined;
45
+ }
34
46
  /** The view that shows all of `bounds` in a panel from its top left, no closer than a fit comes. */
35
47
  export declare function fitView(bounds: Bounds, panel: {
36
48
  width: number;
37
49
  height: number;
38
- }): View | null;
50
+ }, { minScale, top, width: byWidth }?: FitOptions): View | null;
39
51
  /** The least shift that brings [start, end] inside [low, high]; none when it can't all fit, so the view stays. */
40
52
  export declare function intoView(start: number, end: number, low: number, high: number): number;
53
+ /** How much of a graph a pan or pinch leaves in its panel, at the least: half the panel's shorter
54
+ * side, or all of a graph smaller than that. */
55
+ export declare function reachKept(panel: {
56
+ width: number;
57
+ height: number;
58
+ }): number;
59
+ /** The least shift that brings at least `keep` of `graph` back inside `panel` on each axis, so a
60
+ * pan or a fling never leaves the graph out of reach; none while that much already shows. */
61
+ export declare function intoReach(graph: Rect, panel: Rect, keep: number): {
62
+ dx: number;
63
+ dy: number;
64
+ };
41
65
  /** A box on screen, as `getBoundingClientRect` gives it. */
42
66
  export interface Rect {
43
67
  left: number;
@@ -34,15 +34,18 @@ function fitMargin(panel) {
34
34
  return Math.min(panel.width, panel.height) * .05;
35
35
  }
36
36
  /** The view that shows all of `bounds` in a panel from its top left, no closer than a fit comes. */
37
- function fitView(bounds, panel) {
37
+ function fitView(bounds, panel, { minScale = MIN_SCALE, top = 0, width: byWidth = false } = {}) {
38
38
  const width = bounds.maxX - bounds.minX;
39
39
  const height = bounds.maxY - bounds.minY;
40
40
  if (width <= 0 || height <= 0) return null;
41
41
  const margin = fitMargin(panel);
42
- const scale = Math.max(MIN_SCALE, Math.min((panel.width - margin * 2) / width, (panel.height - margin * 2) / height, FIT_MAX_SCALE));
42
+ const fit = Math.min((panel.width - margin * 2) / width, byWidth ? FIT_MAX_SCALE : (panel.height - margin * 2) / height, FIT_MAX_SCALE);
43
+ const floor = Math.min(Math.max(MIN_SCALE, minScale), FIT_MAX_SCALE);
44
+ const scale = Math.max(floor, fit);
45
+ const y = fit < floor || byWidth ? Math.max(margin, top + margin) : margin;
43
46
  return {
44
- x: margin - bounds.minX * scale,
45
- y: margin - bounds.minY * scale,
47
+ x: (byWidth ? (panel.width - width * scale) / 2 : margin) - bounds.minX * scale,
48
+ y: y - bounds.minY * scale,
46
49
  scale
47
50
  };
48
51
  }
@@ -52,6 +55,25 @@ function intoView(start, end, low, high) {
52
55
  if (start < low) return low - start;
53
56
  return end > high ? high - end : 0;
54
57
  }
58
+ /** How much of a graph a pan or pinch leaves in its panel, at the least: half the panel's shorter
59
+ * side, or all of a graph smaller than that. */
60
+ function reachKept(panel) {
61
+ return Math.min(panel.width, panel.height) / 2;
62
+ }
63
+ /** The least shift that brings at least `keep` of `graph` back inside `panel` on each axis, so a
64
+ * pan or a fling never leaves the graph out of reach; none while that much already shows. */
65
+ function intoReach(graph, panel, keep) {
66
+ const axis = (start, size, low, span) => {
67
+ if (size <= 0) return 0;
68
+ const least = Math.min(keep, size, span);
69
+ if (start + size < low + least) return low + least - (start + size);
70
+ return start > low + span - least ? low + span - least - start : 0;
71
+ };
72
+ return {
73
+ dx: axis(graph.left, graph.width, panel.left, panel.width),
74
+ dy: axis(graph.top, graph.height, panel.top, panel.height)
75
+ };
76
+ }
55
77
  /** The smallest box around all of `rects`, which must not be empty. */
56
78
  function enclosing(rects) {
57
79
  const left = Math.min(...rects.map((r) => r.left));
@@ -114,4 +136,4 @@ function viewShowing(region, target, visible, origin, view, readable) {
114
136
  };
115
137
  }
116
138
  //#endregion
117
- export { FIT_MAX_SCALE, MIN_SCALE, ZOOM_STEP, enclosing, fitMargin, fitView, intoView, viewShowing, wheelFactor, zoomAround, zoomLimit };
139
+ export { FIT_MAX_SCALE, MIN_SCALE, ZOOM_STEP, enclosing, fitMargin, fitView, intoReach, intoView, reachKept, viewShowing, wheelFactor, zoomAround, zoomLimit };
@@ -36,7 +36,10 @@ export declare const STRETCHED_LINK_ROW = "relative [&_:is(a,button,input,label,
36
36
  export declare function NameCell({ icon, iconTile, name, subtitle, href, badge, title, children, }: {
37
37
  icon: ReactNode;
38
38
  /** Sets the icon in a small bordered tile, so rows whose icons differ in
39
- * weight, such as kinds of token or runner, still line up. */
39
+ * weight, such as kinds of token or runner, still line up. The 28px tile
40
+ * sits beside a name and its subtitle; a row with no subtitle keeps its
41
+ * icon bare, centered where the tile would be, so a one-line row stays
42
+ * as short as its text and its name still lines up with tiled rows. */
40
43
  iconTile?: boolean | undefined;
41
44
  name: string;
42
45
  subtitle?: string | undefined;
@@ -45,9 +45,12 @@ function NameCell({ icon, iconTile = false, name, subtitle, href, badge, title,
45
45
  const stretch = useContext(StretchRowLinkContext);
46
46
  const hasIcon = icon !== null && icon !== void 0 && icon !== false;
47
47
  const columns = hasIcon ? "col-span-2 grid grid-cols-subgrid items-center" : "flex items-center";
48
- const inner = /* @__PURE__ */ jsxs(Fragment$1, { children: [hasIcon && (iconTile ? /* @__PURE__ */ jsx("span", {
48
+ const inner = /* @__PURE__ */ jsxs(Fragment$1, { children: [hasIcon && (iconTile && subtitle ? /* @__PURE__ */ jsx("span", {
49
49
  className: "flex size-7 shrink-0 items-center justify-center rounded-md border border-(--theme-border) text-(--theme-muted-text-color) [&>svg]:size-3.5",
50
50
  children: icon
51
+ }) : iconTile ? /* @__PURE__ */ jsx("span", {
52
+ className: "flex w-7 shrink-0 items-center justify-center text-(--theme-muted-text-color) [&>svg]:size-3.5",
53
+ children: icon
51
54
  }) : icon), /* @__PURE__ */ jsxs("div", {
52
55
  className: "min-w-0",
53
56
  children: [/* @__PURE__ */ jsxs("div", {