@marimo-team/islands 0.24.1-dev112 → 0.24.1-dev114
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/dist/{common-Ct9wmabC.js → common-CAzl5u7v.js} +1 -1
- package/dist/main.js +2 -2
- package/dist/{reveal-component-XFY3UczF.js → reveal-component-BA63jlJc.js} +1 -1
- package/package.json +1 -1
- package/src/components/dependency-graph/__tests__/elements.test.ts +151 -0
- package/src/components/dependency-graph/custom-edge.tsx +68 -0
- package/src/components/dependency-graph/custom-node.tsx +54 -26
- package/src/components/dependency-graph/dependency-graph-tree.tsx +110 -21
- package/src/components/dependency-graph/elements.ts +225 -31
- package/src/components/dependency-graph/panels.tsx +23 -2
- package/src/components/dependency-graph/utils/__tests__/layout.test.ts +78 -0
- package/src/components/dependency-graph/utils/layout.ts +108 -18
- package/src/components/static-html/share-modal.tsx +3 -2
|
@@ -31908,7 +31908,7 @@ function vK() {
|
|
|
31908
31908
|
return i.warn("Failed to get version from mount config"), null;
|
|
31909
31909
|
}
|
|
31910
31910
|
}
|
|
31911
|
-
var yK = vK() || "0.24.1-
|
|
31911
|
+
var yK = vK() || "0.24.1-dev114", bK = H(yK), xK = H(!0);
|
|
31912
31912
|
H(null);
|
|
31913
31913
|
//#endregion
|
|
31914
31914
|
//#region src/core/wasm/state.ts
|
package/dist/main.js
CHANGED
|
@@ -6,7 +6,7 @@ import { n as x, r as S, t as C } from "./copy-T56I_gVc.js";
|
|
|
6
6
|
import { At as w, B as T, Bn as E, Bt as D, C as O, Cn as k, Ct as A, Dn as j, Dt as M, En as N, Et as P, F, Ft as I, G as L, Gn as ee, Hn as te, I as R, It as ne, J as z, Jn as re, Jt as ie, K as ae, Kt as oe, L as se, Ln as ce, Mn as le, Mt as ue, Nn as de, Nt as fe, On as pe, Ot as me, P as he, Pn as ge, Q as _e, Qn as ve, R as ye, Rn as be, St as xe, Tn as Se, U as Ce, Un as we, Ut as Te, W as Ee, X as De, Xn as Oe, Xt as ke, Y as Ae, Yn as je, Z as Me, Zn as Ne, Zt as Pe, _ as Fe, a as Ie, an as Le, at as Re, b as ze, bn as Be, bt as Ve, cn as He, dn as Ue, dt as We, en as Ge, er as Ke, et as qe, g as Je, gn as Ye, i as Xe, in as Ze, ir as Qe, it as $e, j as et, jt as tt, k as nt, kn as rt, ln as it, lt as at, n as ot, o as st, on as ct, ot as lt, q as ut, qt as dt, rr as ft, sn as pt, st as mt, tr as ht, un as gt, ut as _t, v as vt, xn as yt, xt as bt, y as xt, yn as St, yt as Ct, z as wt, zn as Tt, zt as Et } from "./html-to-image-Cf3vfRXj.js";
|
|
7
7
|
import { o as Dt, s as Ot, u as kt } from "./dist-CSjGX93W.js";
|
|
8
8
|
import { D as At, E as jt, O as Mt, S as Nt, T as Pt, _ as Ft, a as It, c as Lt, d as Rt, f as zt, g as Bt, h as Vt, i as Ht, l as Ut, m as Wt, n as Gt, o as Kt, p as qt, r as Jt, s as Yt, t as Xt, u as Zt, v as Qt, w as $t, x as en, y as tn } from "./spec-JFqQpvZq.js";
|
|
9
|
-
import { $ as nn, $t as rn, A as an, At as on, B as sn, Bt as cn, C as ln, Cn as un, Ct as dn, D as fn, Dn as pn, Dt as mn, E as hn, En as gn, Et as _n, F as vn, Ft as yn, G as bn, Gt as xn, H as Sn, Ht as Cn, I as wn, It as Tn, J as En, Jt as Dn, K as On, Kt as kn, L as An, Lt as jn, M as Mn, N as Nn, Nt as Pn, O as Fn, Ot as In, P as Ln, Pt as Rn, Q as zn, Qt as Bn, R as Vn, Rt as Hn, S as Un, Sn as Wn, St as Gn, T as Kn, Tn as qn, Tt as Jn, U as Yn, Ut as Xn, V as Zn, Vt as Qn, W as $n, Wt as er, X as tr, Xt as nr, Y as rr, Yt as ir, Z as ar, Zt as or, _ as sr, _n as cr, _t as lr, a as ur, an as dr, at as fr, b as pr, bn as mr, bt as hr, cn as gr, dn as _r, dt as vr, en as yr, et as br, fn as xr, ft as Sr, g as Cr, gn as wr, gt as Tr, hn as Er, ht as Dr, i as Or, in as kr, it as Ar, j as jr, k as Mr, kt as Nr, l as Pr, ln as Fr, m as Ir, mn as Lr, mt as Rr, n as zr, nn as Br, nt as Vr, on as Hr, ot as Ur, pn as Wr, pt as Gr, q as Kr, qt as qr, rn as Jr, rt as Yr, s as Xr, sn as Zr, st as Qr, t as $r, tn as ei, tt as ti, un as ni, v as ri, vt as ii, w as ai, wn as oi, wt as si, x as ci, xn as li, xt as ui, y as di, yn as fi, yt as pi, z as mi, zt as hi } from "./common-
|
|
9
|
+
import { $ as nn, $t as rn, A as an, At as on, B as sn, Bt as cn, C as ln, Cn as un, Ct as dn, D as fn, Dn as pn, Dt as mn, E as hn, En as gn, Et as _n, F as vn, Ft as yn, G as bn, Gt as xn, H as Sn, Ht as Cn, I as wn, It as Tn, J as En, Jt as Dn, K as On, Kt as kn, L as An, Lt as jn, M as Mn, N as Nn, Nt as Pn, O as Fn, Ot as In, P as Ln, Pt as Rn, Q as zn, Qt as Bn, R as Vn, Rt as Hn, S as Un, Sn as Wn, St as Gn, T as Kn, Tn as qn, Tt as Jn, U as Yn, Ut as Xn, V as Zn, Vt as Qn, W as $n, Wt as er, X as tr, Xt as nr, Y as rr, Yt as ir, Z as ar, Zt as or, _ as sr, _n as cr, _t as lr, a as ur, an as dr, at as fr, b as pr, bn as mr, bt as hr, cn as gr, dn as _r, dt as vr, en as yr, et as br, fn as xr, ft as Sr, g as Cr, gn as wr, gt as Tr, hn as Er, ht as Dr, i as Or, in as kr, it as Ar, j as jr, k as Mr, kt as Nr, l as Pr, ln as Fr, m as Ir, mn as Lr, mt as Rr, n as zr, nn as Br, nt as Vr, on as Hr, ot as Ur, pn as Wr, pt as Gr, q as Kr, qt as qr, rn as Jr, rt as Yr, s as Xr, sn as Zr, st as Qr, t as $r, tn as ei, tt as ti, un as ni, v as ri, vt as ii, w as ai, wn as oi, wt as si, x as ci, xn as li, xt as ui, y as di, yn as fi, yt as pi, z as mi, zt as hi } from "./common-CAzl5u7v.js";
|
|
10
10
|
import { c as gi, i as _i, n as vi, o as yi, t as bi } from "./useLifecycle-BHpzFL9q.js";
|
|
11
11
|
import { t as xi } from "./check-DGKNz9MB.js";
|
|
12
12
|
import { A as Si, C as Ci, D as wi, E as Ti, F as Ei, I as Di, M as Oi, N as ki, P as Ai, R as ji, S as Mi, a as Ni, b as Pi, c as Fi, i as Ii, j as Li, l as Ri, n as zi, o as Bi, r as Vi, s as Hi, t as Ui, w as Wi, x as Gi } from "./select-foAi7oM_.js";
|
|
@@ -35316,7 +35316,7 @@ function lJ(e) {
|
|
|
35316
35316
|
}
|
|
35317
35317
|
//#endregion
|
|
35318
35318
|
//#region src/components/editor/renderers/slides-layout/slides-layout.tsx
|
|
35319
|
-
var uJ = q.lazy(() => import("./reveal-component-
|
|
35319
|
+
var uJ = q.lazy(() => import("./reveal-component-BA63jlJc.js")), dJ = (e) => {
|
|
35320
35320
|
let t = (0, Y.c)(33), { layout: n, setLayout: r, cells: i, mode: a } = e, o = Xm(me), s = a === "read" || o, c = Xm(Ae) > 1, [l, u] = (0, q.useState)(null), d;
|
|
35321
35321
|
t[0] !== i || t[1] !== n ? (d = sW(i, n), t[0] = i, t[1] = n, t[2] = d) : d = t[2];
|
|
35322
35322
|
let { slideCells: f, skippedIds: p, noOutputIds: m, slideTypes: h, startCellIndex: g } = d, _;
|
|
@@ -4,7 +4,7 @@ import { t as o } from "./react-DMWcHin-.js";
|
|
|
4
4
|
import { t as s } from "./compiler-runtime-CAH9RLw2.js";
|
|
5
5
|
import { Ot as c, ot as l } from "./html-to-image-Cf3vfRXj.js";
|
|
6
6
|
import { u } from "./dist-CSjGX93W.js";
|
|
7
|
-
import { _n as d, c as f, ct as p, g as m, h, l as g, lt as _, n as v, o as y, r as b, t as x, u as S, ut as C, vn as w, xn as T } from "./common-
|
|
7
|
+
import { _n as d, c as f, ct as p, g as m, h, l as g, lt as _, n as v, o as y, r as b, t as x, u as S, ut as C, vn as w, xn as T } from "./common-CAzl5u7v.js";
|
|
8
8
|
import { R as E } from "./select-foAi7oM_.js";
|
|
9
9
|
import { Y as D } from "./dropdown-menu-D-pePvPo.js";
|
|
10
10
|
import { t as O } from "./jsx-runtime-CpoKNRgu.js";
|
package/package.json
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { CellId } from "@/core/cells/ids";
|
|
5
|
+
import type { Variable, VariableName, Variables } from "@/core/variables/types";
|
|
6
|
+
import { createCellRuntimeState } from "@/core/cells/types";
|
|
7
|
+
import {
|
|
8
|
+
collapsedNodeWidth,
|
|
9
|
+
computeDefsByCell,
|
|
10
|
+
isStateFlowVariable,
|
|
11
|
+
reusableCellIds,
|
|
12
|
+
wrapDefs,
|
|
13
|
+
} from "../elements";
|
|
14
|
+
|
|
15
|
+
const names = (list: string[]) => list as VariableName[];
|
|
16
|
+
|
|
17
|
+
function variable(
|
|
18
|
+
partial: Omit<Partial<Variable>, "name"> & { name: string },
|
|
19
|
+
): Variable {
|
|
20
|
+
return {
|
|
21
|
+
declaredBy: [],
|
|
22
|
+
usedBy: [],
|
|
23
|
+
...partial,
|
|
24
|
+
name: partial.name as VariableName,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("collapsedNodeWidth", () => {
|
|
29
|
+
it("clamps short labels to the minimum width", () => {
|
|
30
|
+
expect(collapsedNodeWidth("")).toBe(90);
|
|
31
|
+
expect(collapsedNodeWidth("x")).toBe(90);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("clamps long labels to the maximum width", () => {
|
|
35
|
+
expect(collapsedNodeWidth("a".repeat(100))).toBe(240);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("scales with label length between the bounds", () => {
|
|
39
|
+
const width = collapsedNodeWidth("df, summary");
|
|
40
|
+
expect(width).toBeGreaterThan(90);
|
|
41
|
+
expect(width).toBeLessThan(240);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("isStateFlowVariable", () => {
|
|
46
|
+
it("flags State and SetFunctor (from mo.state)", () => {
|
|
47
|
+
expect(
|
|
48
|
+
isStateFlowVariable(variable({ name: "g", dataType: "State" })),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
expect(
|
|
51
|
+
isStateFlowVariable(variable({ name: "s", dataType: "SetFunctor" })),
|
|
52
|
+
).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not flag UI elements or plain values", () => {
|
|
56
|
+
// UIElement is frontend-driven / immutable during a run — not a state flow.
|
|
57
|
+
expect(
|
|
58
|
+
isStateFlowVariable(variable({ name: "sl", dataType: "slider" })),
|
|
59
|
+
).toBe(false);
|
|
60
|
+
expect(isStateFlowVariable(variable({ name: "n", dataType: "int" }))).toBe(
|
|
61
|
+
false,
|
|
62
|
+
);
|
|
63
|
+
expect(isStateFlowVariable(variable({ name: "u", dataType: null }))).toBe(
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
expect(isStateFlowVariable(variable({ name: "u2" }))).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("wrapDefs", () => {
|
|
71
|
+
it("packs short names to the 12-char minimum budget", () => {
|
|
72
|
+
expect(
|
|
73
|
+
wrapDefs(names(["np", "math", "time", "io", "numpy", "pandas"])),
|
|
74
|
+
).toEqual(["np, math", "time, io", "numpy", "pandas"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("gives a long name its own line, then packs the rest to its width", () => {
|
|
78
|
+
expect(
|
|
79
|
+
wrapDefs(
|
|
80
|
+
names([
|
|
81
|
+
"very_very_very_long_variable_name",
|
|
82
|
+
"short_var",
|
|
83
|
+
"shr_v",
|
|
84
|
+
"io",
|
|
85
|
+
"numpy",
|
|
86
|
+
"wev",
|
|
87
|
+
]),
|
|
88
|
+
),
|
|
89
|
+
).toEqual([
|
|
90
|
+
"very_very_very_long_variable_name",
|
|
91
|
+
"short_var, shr_v, io, numpy, wev",
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns an empty list for no defs", () => {
|
|
96
|
+
expect(wrapDefs(names([]))).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("caps the budget so packed lines fit the max node width", () => {
|
|
100
|
+
// One name far beyond the width cap must not widen every other line.
|
|
101
|
+
const huge = "x".repeat(60);
|
|
102
|
+
const lines = wrapDefs(
|
|
103
|
+
names([huge, "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"]),
|
|
104
|
+
);
|
|
105
|
+
expect(lines[0]).toBe(huge);
|
|
106
|
+
for (const line of lines.slice(1)) {
|
|
107
|
+
expect(line.length).toBeLessThanOrEqual(33);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("computeDefsByCell", () => {
|
|
113
|
+
it("maps each cell to the variables it declares", () => {
|
|
114
|
+
const c1 = "c1" as CellId;
|
|
115
|
+
const c2 = "c2" as CellId;
|
|
116
|
+
const a = "a" as VariableName;
|
|
117
|
+
const b = "b" as VariableName;
|
|
118
|
+
const c = "c" as VariableName;
|
|
119
|
+
const variables: Variables = {
|
|
120
|
+
[a]: variable({ name: a, declaredBy: [c1], usedBy: [c2] }),
|
|
121
|
+
[b]: variable({ name: b, declaredBy: [c1] }),
|
|
122
|
+
[c]: variable({ name: c, declaredBy: [c2] }),
|
|
123
|
+
};
|
|
124
|
+
const defs = computeDefsByCell(variables);
|
|
125
|
+
expect(defs.get(c1)).toEqual([a, b]);
|
|
126
|
+
expect(defs.get(c2)).toEqual([c]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns an empty map when there are no variables", () => {
|
|
130
|
+
expect(computeDefsByCell({}).size).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("reusableCellIds", () => {
|
|
135
|
+
const c1 = "c1" as CellId;
|
|
136
|
+
const c2 = "c2" as CellId;
|
|
137
|
+
const c3 = "c3" as CellId;
|
|
138
|
+
|
|
139
|
+
it("collects cells whose serialization hint is valid, case-insensitively", () => {
|
|
140
|
+
const runtime = {
|
|
141
|
+
[c1]: createCellRuntimeState({ serialization: "Valid" }),
|
|
142
|
+
[c2]: createCellRuntimeState({ serialization: "Unparsable" }),
|
|
143
|
+
[c3]: createCellRuntimeState(),
|
|
144
|
+
};
|
|
145
|
+
expect(reusableCellIds([c1, c2, c3], runtime)).toEqual(new Set([c1]));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("ignores cells with no runtime entry", () => {
|
|
149
|
+
expect(reusableCellIds([c1], {})).toEqual(new Set());
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { memo } from "react";
|
|
4
|
+
import { BaseEdge, type EdgeProps, getBezierPath } from "reactflow";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_EDGE_COLOR,
|
|
7
|
+
type DependencyEdgeData,
|
|
8
|
+
STATE_FLOW_EDGE_COLOR,
|
|
9
|
+
} from "./elements";
|
|
10
|
+
|
|
11
|
+
// Gentle curvature keeps edges reading as clean directional splines
|
|
12
|
+
// (TensorBoard-style) rather than hard right-angle elbows.
|
|
13
|
+
const EDGE_CURVATURE = 0.35;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A dependency edge, drawn as a smooth curved spline that flows out of the
|
|
17
|
+
* source handle and into the target — the TensorBoard graph aesthetic. Mutable
|
|
18
|
+
* `State`/setter flows get a distinct dashed accent style.
|
|
19
|
+
*/
|
|
20
|
+
export const DependencyEdge = memo((props: EdgeProps<DependencyEdgeData>) => {
|
|
21
|
+
const {
|
|
22
|
+
sourceX,
|
|
23
|
+
sourceY,
|
|
24
|
+
targetX,
|
|
25
|
+
targetY,
|
|
26
|
+
sourcePosition,
|
|
27
|
+
targetPosition,
|
|
28
|
+
markerEnd,
|
|
29
|
+
data,
|
|
30
|
+
selected,
|
|
31
|
+
} = props;
|
|
32
|
+
|
|
33
|
+
const [path] = getBezierPath({
|
|
34
|
+
sourceX,
|
|
35
|
+
sourceY,
|
|
36
|
+
targetX,
|
|
37
|
+
targetY,
|
|
38
|
+
sourcePosition,
|
|
39
|
+
targetPosition,
|
|
40
|
+
curvature: EDGE_CURVATURE,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const isStateFlow = data?.isStateFlow ?? false;
|
|
44
|
+
// The inline stroke replaces React Flow's stylesheet-driven edge colors, so
|
|
45
|
+
// the selected state must be restyled here too or it becomes invisible.
|
|
46
|
+
const stroke = selected
|
|
47
|
+
? "var(--primary)"
|
|
48
|
+
: isStateFlow
|
|
49
|
+
? STATE_FLOW_EDGE_COLOR
|
|
50
|
+
: DEFAULT_EDGE_COLOR;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<BaseEdge
|
|
54
|
+
path={path}
|
|
55
|
+
markerEnd={markerEnd}
|
|
56
|
+
style={{
|
|
57
|
+
strokeWidth: selected ? 2.5 : isStateFlow ? 2 : 1.5,
|
|
58
|
+
stroke,
|
|
59
|
+
strokeDasharray: isStateFlow ? "5 3" : undefined,
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
DependencyEdge.displayName = "DependencyEdge";
|
|
65
|
+
|
|
66
|
+
export const edgeTypes = {
|
|
67
|
+
dependency: DependencyEdge,
|
|
68
|
+
};
|
|
@@ -2,25 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import { useAtomValue } from "jotai";
|
|
4
4
|
import React, { memo, use } from "react";
|
|
5
|
-
import { Handle, Position
|
|
5
|
+
import { Handle, Position } from "reactflow";
|
|
6
6
|
import { TinyCode } from "@/components/editor/cell/TinyCode";
|
|
7
7
|
import { useCellIds } from "@/core/cells/cells";
|
|
8
|
-
import { displayCellName } from "@/core/cells/names";
|
|
8
|
+
import { displayCellName, isInternalCellName } from "@/core/cells/names";
|
|
9
9
|
import { cn } from "@/utils/cn";
|
|
10
10
|
import {
|
|
11
11
|
type CustomNodeProps,
|
|
12
|
-
getNodeHeight,
|
|
13
12
|
INPUTS_HANDLE_ID,
|
|
13
|
+
nodeBodyLines,
|
|
14
|
+
nodeDimensions,
|
|
14
15
|
OUTPUTS_HANDLE_ID,
|
|
15
16
|
} from "./elements";
|
|
16
17
|
import type { LayoutDirection } from "./types";
|
|
17
|
-
|
|
18
|
-
function getWidth(canvasWidth: number) {
|
|
19
|
-
const minWidth = 100;
|
|
20
|
-
const maxWidth = 400;
|
|
21
|
-
const padding = 50;
|
|
22
|
-
return Math.min(Math.max(canvasWidth - padding * 2, minWidth), maxWidth);
|
|
23
|
-
}
|
|
18
|
+
import { extractCellPreview } from "./utils/cell-preview";
|
|
24
19
|
|
|
25
20
|
export const EdgeMarkerContext = React.createContext<LayoutDirection>("LR");
|
|
26
21
|
|
|
@@ -36,13 +31,19 @@ export const CustomNode = memo((props: CustomNodeProps) => {
|
|
|
36
31
|
const { data, selected } = props; // must match the equality check
|
|
37
32
|
const cell = useAtomValue(data.atom);
|
|
38
33
|
const cellIndex = useCellIds().inOrderIds.indexOf(cell.id);
|
|
39
|
-
const
|
|
40
|
-
const selectedColor = "var(--gray-9)";
|
|
41
|
-
const color = selected ? selectedColor : nonSelectedColor;
|
|
42
|
-
const reactFlowWidth = useStore(({ width }) => width);
|
|
34
|
+
const handleColor = selected ? "var(--gray-9)" : "var(--gray-3)";
|
|
43
35
|
const edgeMarkers = use(EdgeMarkerContext);
|
|
44
36
|
|
|
45
|
-
const
|
|
37
|
+
const { width, height } = nodeDimensions(data);
|
|
38
|
+
const isNamed = !isInternalCellName(cell.name);
|
|
39
|
+
const name = displayCellName(cell.name, cellIndex);
|
|
40
|
+
// Only collapsed nodes without defs render the preview; skip the parse
|
|
41
|
+
// otherwise, since it scales with notebook size.
|
|
42
|
+
const preview =
|
|
43
|
+
!data.expanded && data.defs.length === 0
|
|
44
|
+
? extractCellPreview(cell.code).text
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
46
47
|
return (
|
|
47
48
|
<div>
|
|
48
49
|
<Handle
|
|
@@ -50,43 +51,70 @@ export const CustomNode = memo((props: CustomNodeProps) => {
|
|
|
50
51
|
id={INPUTS_HANDLE_ID}
|
|
51
52
|
data-testid="input-one"
|
|
52
53
|
position={edgeMarkers === "LR" ? Position.Left : Position.Top}
|
|
53
|
-
style={{ background:
|
|
54
|
+
style={{ background: handleColor }}
|
|
54
55
|
/>
|
|
55
56
|
<Handle
|
|
56
57
|
type="source"
|
|
57
58
|
id={INPUTS_HANDLE_ID}
|
|
58
59
|
data-testid="input-two"
|
|
59
60
|
position={edgeMarkers === "LR" ? Position.Left : Position.Top}
|
|
60
|
-
style={{ background:
|
|
61
|
+
style={{ background: handleColor }}
|
|
61
62
|
/>
|
|
62
63
|
<div
|
|
63
64
|
className={cn(
|
|
64
65
|
"flex flex-col bg-card border border-input/50 rounded-md mx-[2px] overflow-hidden",
|
|
65
66
|
selected && "border-primary",
|
|
66
67
|
)}
|
|
67
|
-
style={{
|
|
68
|
-
height: getNodeHeight(linesOfCode),
|
|
69
|
-
width: data.forceWidth || getWidth(reactFlowWidth),
|
|
70
|
-
}}
|
|
68
|
+
style={{ height, width }}
|
|
71
69
|
>
|
|
72
|
-
<div className="text-
|
|
73
|
-
|
|
70
|
+
<div className="text-xs py-0.5 px-2 bg-muted border-b flex items-center justify-between gap-2 shrink-0">
|
|
71
|
+
<span
|
|
72
|
+
className={cn(
|
|
73
|
+
"truncate",
|
|
74
|
+
isNamed
|
|
75
|
+
? "font-semibold text-foreground"
|
|
76
|
+
: "text-muted-foreground/70",
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{name}
|
|
80
|
+
</span>
|
|
74
81
|
</div>
|
|
75
|
-
|
|
82
|
+
{data.expanded ? (
|
|
83
|
+
<TinyCode code={cell.code} />
|
|
84
|
+
) : (
|
|
85
|
+
<div className="flex-1 min-h-0 flex flex-col justify-center px-2 py-1 font-mono text-xs leading-4">
|
|
86
|
+
{data.defs.length > 0 ? (
|
|
87
|
+
// Defs wrapped into comma-separated lines — taller, not wider.
|
|
88
|
+
nodeBodyLines(data).map((line) => (
|
|
89
|
+
<span
|
|
90
|
+
key={line}
|
|
91
|
+
className="text-foreground truncate"
|
|
92
|
+
title={line}
|
|
93
|
+
>
|
|
94
|
+
{line}
|
|
95
|
+
</span>
|
|
96
|
+
))
|
|
97
|
+
) : (
|
|
98
|
+
<span className="truncate italic text-muted-foreground/60">
|
|
99
|
+
{preview ?? "empty"}
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
76
104
|
</div>
|
|
77
105
|
<Handle
|
|
78
106
|
type="source"
|
|
79
107
|
id={OUTPUTS_HANDLE_ID}
|
|
80
108
|
data-testid="output-one"
|
|
81
109
|
position={edgeMarkers === "LR" ? Position.Right : Position.Bottom}
|
|
82
|
-
style={{ background:
|
|
110
|
+
style={{ background: handleColor }}
|
|
83
111
|
/>
|
|
84
112
|
<Handle
|
|
85
113
|
type="target"
|
|
86
114
|
id={OUTPUTS_HANDLE_ID}
|
|
87
115
|
data-testid="output-two"
|
|
88
116
|
position={edgeMarkers === "LR" ? Position.Right : Position.Bottom}
|
|
89
|
-
style={{ background:
|
|
117
|
+
style={{ background: handleColor }}
|
|
90
118
|
/>
|
|
91
119
|
</div>
|
|
92
120
|
);
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { type Atom, atom, useAtomValue } from "jotai";
|
|
4
4
|
import { MapPinIcon } from "lucide-react";
|
|
5
|
-
import React, {
|
|
5
|
+
import React, {
|
|
6
|
+
type PropsWithChildren,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
6
11
|
import useEvent from "react-use-event-hook";
|
|
7
12
|
import ReactFlow, {
|
|
8
13
|
Background,
|
|
@@ -15,6 +20,7 @@ import ReactFlow, {
|
|
|
15
20
|
useNodesState,
|
|
16
21
|
useReactFlow,
|
|
17
22
|
} from "reactflow";
|
|
23
|
+
import { edgeTypes } from "@/components/dependency-graph/custom-edge";
|
|
18
24
|
import {
|
|
19
25
|
EdgeMarkerContext,
|
|
20
26
|
nodeTypes,
|
|
@@ -25,11 +31,16 @@ import type { CellData } from "@/core/cells/types";
|
|
|
25
31
|
import { store } from "@/core/state/jotai";
|
|
26
32
|
import type { Variables } from "@/core/variables/types";
|
|
27
33
|
import { Events } from "@/utils/events";
|
|
28
|
-
import { scrollAndHighlightCell } from "../editor/links/cell-link";
|
|
29
34
|
import { Tooltip } from "../ui/tooltip";
|
|
30
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
computeDefsByCell,
|
|
37
|
+
type NodeData,
|
|
38
|
+
nodeDimensions,
|
|
39
|
+
TreeElementsBuilder,
|
|
40
|
+
} from "./elements";
|
|
31
41
|
import { GraphSelectionPanel } from "./panels";
|
|
32
42
|
import type { GraphSelection, GraphSettings, LayoutDirection } from "./types";
|
|
43
|
+
import { extractCellPreview } from "./utils/cell-preview";
|
|
33
44
|
import { layoutElements } from "./utils/layout";
|
|
34
45
|
import { useFitToViewOnDimensionChange } from "./utils/useFitToViewOnDimensionChange";
|
|
35
46
|
|
|
@@ -43,6 +54,24 @@ interface Props {
|
|
|
43
54
|
|
|
44
55
|
const elementsBuilder = new TreeElementsBuilder();
|
|
45
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Apply the current expand/collapse selection to freshly-built nodes, resizing
|
|
59
|
+
* each so the layout engine and the DOM agree on node dimensions.
|
|
60
|
+
*/
|
|
61
|
+
function withExpansion(
|
|
62
|
+
nodes: Node<NodeData>[],
|
|
63
|
+
expandedIds: Set<CellId>,
|
|
64
|
+
): Node<NodeData>[] {
|
|
65
|
+
return nodes.map((node) => {
|
|
66
|
+
const data: NodeData = {
|
|
67
|
+
...node.data,
|
|
68
|
+
expanded: expandedIds.has(node.id as CellId),
|
|
69
|
+
};
|
|
70
|
+
const { width, height } = nodeDimensions(data);
|
|
71
|
+
return { ...node, data, width, height };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
|
|
47
76
|
cellIds,
|
|
48
77
|
variables,
|
|
@@ -51,22 +80,23 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
|
|
|
51
80
|
layoutDirection,
|
|
52
81
|
settings,
|
|
53
82
|
}) => {
|
|
83
|
+
// Cells whose node is expanded to show its full code (toggled by double-click).
|
|
84
|
+
const [expandedIds, setExpandedIds] = useState<Set<CellId>>(() => new Set());
|
|
85
|
+
|
|
54
86
|
// oxlint-disable-next-line react/hook-use-state
|
|
55
87
|
const [initial] = useState(() => {
|
|
56
|
-
|
|
88
|
+
const elements = elementsBuilder.createElements(
|
|
57
89
|
cellIds,
|
|
58
90
|
cellAtoms,
|
|
59
91
|
variables,
|
|
60
92
|
settings.hidePureMarkdown,
|
|
61
93
|
settings.hideReusableFunctions,
|
|
62
94
|
);
|
|
63
|
-
|
|
64
|
-
nodes: elements.nodes,
|
|
95
|
+
return layoutElements({
|
|
96
|
+
nodes: withExpansion(elements.nodes, expandedIds),
|
|
65
97
|
edges: elements.edges,
|
|
66
98
|
direction: layoutDirection,
|
|
67
99
|
});
|
|
68
|
-
|
|
69
|
-
return elements;
|
|
70
100
|
// Only run once
|
|
71
101
|
});
|
|
72
102
|
|
|
@@ -82,22 +112,68 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
|
|
|
82
112
|
edges: elements.edges,
|
|
83
113
|
direction: layoutDirection,
|
|
84
114
|
});
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
// Rebuilt elements are fresh objects; carry selection over so a
|
|
116
|
+
// re-layout (e.g. toggling expansion) doesn't silently clear it while
|
|
117
|
+
// the selection panel still targets the element.
|
|
118
|
+
setNodes((prev) => {
|
|
119
|
+
const selected = new Set(
|
|
120
|
+
prev.filter((node) => node.selected).map((node) => node.id),
|
|
121
|
+
);
|
|
122
|
+
return result.nodes.map((node) =>
|
|
123
|
+
selected.has(node.id) ? { ...node, selected: true } : node,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
setEdges((prev) => {
|
|
127
|
+
const selected = new Set(
|
|
128
|
+
prev.filter((edge) => edge.selected).map((edge) => edge.id),
|
|
129
|
+
);
|
|
130
|
+
return result.edges.map((edge) =>
|
|
131
|
+
selected.has(edge.id) ? { ...edge, selected: true } : edge,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
87
134
|
},
|
|
88
135
|
);
|
|
89
136
|
|
|
90
|
-
//
|
|
137
|
+
// Node sizes depend on live cell code: the line count when expanded, or the
|
|
138
|
+
// preview when a cell defines nothing. The cell atoms keep their identity
|
|
139
|
+
// across edits, so the rebuild effect below can't see code changes through
|
|
140
|
+
// its deps — subscribe to the size-relevant inputs explicitly.
|
|
141
|
+
const defsByCell = useMemo(() => computeDefsByCell(variables), [variables]);
|
|
142
|
+
const sizeSignature = useAtomValue(
|
|
143
|
+
useMemo(
|
|
144
|
+
() =>
|
|
145
|
+
atom((get) =>
|
|
146
|
+
cellIds
|
|
147
|
+
.map((cellId, index) => {
|
|
148
|
+
const code = get(cellAtoms[index]).code;
|
|
149
|
+
if (expandedIds.has(cellId)) {
|
|
150
|
+
return `e${code.trim().split("\n").length}`;
|
|
151
|
+
}
|
|
152
|
+
if ((defsByCell.get(cellId) ?? []).length > 0) {
|
|
153
|
+
// Collapsed nodes with defs size off the defs, not the code.
|
|
154
|
+
return "d";
|
|
155
|
+
}
|
|
156
|
+
return `p${extractCellPreview(code).text?.length ?? 0}`;
|
|
157
|
+
})
|
|
158
|
+
.join(),
|
|
159
|
+
),
|
|
160
|
+
[cellIds, cellAtoms, expandedIds, defsByCell],
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Rebuild + re-layout when the graph inputs or the expand/collapse set change.
|
|
91
165
|
useEffect(() => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
settings.hideReusableFunctions,
|
|
99
|
-
),
|
|
166
|
+
const elements = elementsBuilder.createElements(
|
|
167
|
+
cellIds,
|
|
168
|
+
cellAtoms,
|
|
169
|
+
variables,
|
|
170
|
+
settings.hidePureMarkdown,
|
|
171
|
+
settings.hideReusableFunctions,
|
|
100
172
|
);
|
|
173
|
+
syncChanges({
|
|
174
|
+
nodes: withExpansion(elements.nodes, expandedIds),
|
|
175
|
+
edges: elements.edges,
|
|
176
|
+
});
|
|
101
177
|
}, [
|
|
102
178
|
cellIds,
|
|
103
179
|
variables,
|
|
@@ -105,6 +181,8 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
|
|
|
105
181
|
syncChanges,
|
|
106
182
|
settings.hidePureMarkdown,
|
|
107
183
|
settings.hideReusableFunctions,
|
|
184
|
+
expandedIds,
|
|
185
|
+
sizeSignature,
|
|
108
186
|
]);
|
|
109
187
|
|
|
110
188
|
const [selection, setSelection] = useState<GraphSelection>();
|
|
@@ -120,6 +198,7 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
|
|
|
120
198
|
nodes={nodes}
|
|
121
199
|
edges={edges}
|
|
122
200
|
nodeTypes={nodeTypes}
|
|
201
|
+
edgeTypes={edgeTypes}
|
|
123
202
|
minZoom={0.2}
|
|
124
203
|
fitViewOptions={{
|
|
125
204
|
minZoom: 0.5,
|
|
@@ -137,7 +216,17 @@ export const DependencyGraphTree: React.FC<PropsWithChildren<Props>> = ({
|
|
|
137
216
|
});
|
|
138
217
|
}}
|
|
139
218
|
onNodeDoubleClick={(_event, node) => {
|
|
140
|
-
|
|
219
|
+
// Expand/collapse the node to reveal its full code in place.
|
|
220
|
+
const id = node.id as CellId;
|
|
221
|
+
setExpandedIds((prev) => {
|
|
222
|
+
const next = new Set(prev);
|
|
223
|
+
if (next.has(id)) {
|
|
224
|
+
next.delete(id);
|
|
225
|
+
} else {
|
|
226
|
+
next.add(id);
|
|
227
|
+
}
|
|
228
|
+
return next;
|
|
229
|
+
});
|
|
141
230
|
}}
|
|
142
231
|
fitView={true}
|
|
143
232
|
onNodesChange={onNodesChange}
|
|
@@ -4,26 +4,177 @@ import type { Atom } from "jotai";
|
|
|
4
4
|
import { type Edge, MarkerType, type Node, type NodeProps } from "reactflow";
|
|
5
5
|
import { getNotebook } from "@/core/cells/cells";
|
|
6
6
|
import type { CellId } from "@/core/cells/ids";
|
|
7
|
-
import type { CellData } from "@/core/cells/types";
|
|
7
|
+
import type { CellData, CellRuntimeState } from "@/core/cells/types";
|
|
8
8
|
import { store } from "@/core/state/jotai";
|
|
9
|
-
import type { Variables } from "@/core/variables/types";
|
|
9
|
+
import type { Variable, VariableName, Variables } from "@/core/variables/types";
|
|
10
10
|
import { Arrays } from "@/utils/arrays";
|
|
11
|
+
import { extractCellPreview } from "./utils/cell-preview";
|
|
12
|
+
|
|
13
|
+
/** The edge type registered for dependency edges (see `custom-edge.tsx`). */
|
|
14
|
+
export const DEPENDENCY_EDGE_TYPE = "dependency";
|
|
15
|
+
|
|
16
|
+
// Stroke/marker colors shared by the dependency edge and its arrow marker.
|
|
17
|
+
export const STATE_FLOW_EDGE_COLOR = "var(--amber-10)";
|
|
18
|
+
export const DEFAULT_EDGE_COLOR = "var(--gray-8)";
|
|
19
|
+
|
|
20
|
+
export interface DependencyEdgeData {
|
|
21
|
+
/** Variables carried across this edge. */
|
|
22
|
+
variables: VariableName[];
|
|
23
|
+
/** True when any carried variable is a mutable `State`/`SetFunctor` flow. */
|
|
24
|
+
isStateFlow: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A `State`/`SetFunctor` (from `mo.state`) is mutable mid-run, so it is drawn
|
|
28
|
+
// distinctly. `UIElement` is intentionally excluded: it is frontend-driven and
|
|
29
|
+
// immutable during a run.
|
|
30
|
+
const STATE_DATATYPES = new Set(["State", "SetFunctor"]);
|
|
31
|
+
|
|
32
|
+
export function isStateFlowVariable(variable: Variable): boolean {
|
|
33
|
+
return variable.dataType != null && STATE_DATATYPES.has(variable.dataType);
|
|
34
|
+
}
|
|
11
35
|
|
|
12
36
|
export interface NodeData {
|
|
13
37
|
atom: Atom<CellData>;
|
|
14
|
-
|
|
38
|
+
/** Variables this cell defines — the node's primary identity when collapsed. */
|
|
39
|
+
defs: VariableName[];
|
|
40
|
+
/** When true the node renders its full code instead of the compact ref list. */
|
|
41
|
+
expanded?: boolean;
|
|
15
42
|
}
|
|
16
43
|
export type CustomNodeProps = NodeProps<NodeData>;
|
|
17
44
|
|
|
45
|
+
const LINE_HEIGHT = 11; // matches TinyCode.css
|
|
46
|
+
|
|
18
47
|
export function getNodeHeight(linesOfCode: number) {
|
|
19
|
-
const LINE_HEIGHT = 11; // matches TinyCode.css
|
|
20
48
|
return Math.min(linesOfCode * LINE_HEIGHT + 35, 200);
|
|
21
49
|
}
|
|
22
50
|
|
|
51
|
+
// Compact-node sizing. A collapsed node wraps its defined variables into
|
|
52
|
+
// comma-separated lines at a character budget, so it grows taller rather than
|
|
53
|
+
// wider; the same budget drives the estimated width so dagre and the DOM agree.
|
|
54
|
+
export const NODE_HEADER_HEIGHT = 22;
|
|
55
|
+
const DEF_LINE_HEIGHT = 16;
|
|
56
|
+
const BODY_V_PADDING = 10;
|
|
57
|
+
const EXPANDED_NODE_WIDTH = 300;
|
|
58
|
+
const CHAR_WIDTH = 6.5; // text-xs monospace-ish
|
|
59
|
+
const NODE_H_PADDING = 22;
|
|
60
|
+
const MIN_NODE_WIDTH = 90;
|
|
61
|
+
const MAX_NODE_WIDTH = 240;
|
|
62
|
+
// Wrap defs to the longest single name, but never narrower than this — short
|
|
63
|
+
// names pack several per line, a long one gets a line to itself.
|
|
64
|
+
const WRAP_MIN_CHARS = 12;
|
|
65
|
+
// ... and never wider than fits MAX_NODE_WIDTH, or packed lines would be
|
|
66
|
+
// clipped by the width clamp. A single name over this budget still gets its
|
|
67
|
+
// own line; the renderer truncates it with an ellipsis and a title.
|
|
68
|
+
const WRAP_MAX_CHARS = Math.floor(
|
|
69
|
+
(MAX_NODE_WIDTH - NODE_H_PADDING) / CHAR_WIDTH,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export function collapsedNodeWidth(label: string): number {
|
|
73
|
+
const raw = label.length * CHAR_WIDTH + NODE_H_PADDING;
|
|
74
|
+
return Math.min(Math.max(raw, MIN_NODE_WIDTH), MAX_NODE_WIDTH);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The per-line character budget used to wrap a node's defs. */
|
|
78
|
+
function defsWrapBudget(defs: VariableName[]): number {
|
|
79
|
+
const longest = defs.reduce(
|
|
80
|
+
(max, def) => Math.max(max, def.length),
|
|
81
|
+
WRAP_MIN_CHARS,
|
|
82
|
+
);
|
|
83
|
+
return Math.min(longest, WRAP_MAX_CHARS);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Greedily pack `defs` into comma-separated lines, each no wider than the
|
|
88
|
+
* budget (the longest name, or `WRAP_MIN_CHARS`). No trailing commas.
|
|
89
|
+
*/
|
|
90
|
+
export function wrapDefs(defs: VariableName[]): string[] {
|
|
91
|
+
const budget = defsWrapBudget(defs);
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
let current = "";
|
|
94
|
+
for (const def of defs) {
|
|
95
|
+
if (current === "") {
|
|
96
|
+
current = def;
|
|
97
|
+
} else if (current.length + 2 + def.length <= budget) {
|
|
98
|
+
current = `${current}, ${def}`;
|
|
99
|
+
} else {
|
|
100
|
+
lines.push(current);
|
|
101
|
+
current = def;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (current !== "") {
|
|
105
|
+
lines.push(current);
|
|
106
|
+
}
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The lines shown in a collapsed node's body: its defs wrapped to a budget, or
|
|
112
|
+
* a single code-preview line when the cell defines nothing.
|
|
113
|
+
*/
|
|
114
|
+
export function nodeBodyLines(data: NodeData): string[] {
|
|
115
|
+
if (data.defs.length > 0) {
|
|
116
|
+
return wrapDefs(data.defs);
|
|
117
|
+
}
|
|
118
|
+
const preview = extractCellPreview(store.get(data.atom).code).text;
|
|
119
|
+
return preview ? [preview] : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function nodeDimensions(data: NodeData): {
|
|
123
|
+
width: number;
|
|
124
|
+
height: number;
|
|
125
|
+
} {
|
|
126
|
+
if (data.expanded) {
|
|
127
|
+
const lines = store.get(data.atom).code.trim().split("\n").length;
|
|
128
|
+
return { width: EXPANDED_NODE_WIDTH, height: getNodeHeight(lines) };
|
|
129
|
+
}
|
|
130
|
+
const lines = nodeBodyLines(data);
|
|
131
|
+
const widthChars =
|
|
132
|
+
data.defs.length > 0
|
|
133
|
+
? defsWrapBudget(data.defs)
|
|
134
|
+
: lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
135
|
+
const bodyLines = Math.max(lines.length, 1);
|
|
136
|
+
return {
|
|
137
|
+
width: collapsedNodeWidth("x".repeat(widthChars)),
|
|
138
|
+
height: NODE_HEADER_HEIGHT + bodyLines * DEF_LINE_HEIGHT + BODY_V_PADDING,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
23
142
|
// The nodes must have the same handle IDs to ensure edges connect correctly
|
|
24
143
|
export const OUTPUTS_HANDLE_ID = "outputs";
|
|
25
144
|
export const INPUTS_HANDLE_ID = "inputs";
|
|
26
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Cells hidden by the reusable-functions filter: those the kernel marked as
|
|
148
|
+
* valid top-level definitions (serialization hint `Valid`).
|
|
149
|
+
*/
|
|
150
|
+
export function reusableCellIds(
|
|
151
|
+
cellIds: CellId[],
|
|
152
|
+
cellRuntime: Record<CellId, CellRuntimeState>,
|
|
153
|
+
): Set<CellId> {
|
|
154
|
+
const reusable = new Set<CellId>();
|
|
155
|
+
for (const cellId of cellIds) {
|
|
156
|
+
if (cellRuntime[cellId]?.serialization?.toLowerCase() === "valid") {
|
|
157
|
+
reusable.add(cellId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return reusable;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Map each cell to the names of the variables it declares. */
|
|
164
|
+
export function computeDefsByCell(
|
|
165
|
+
variables: Variables,
|
|
166
|
+
): Map<CellId, VariableName[]> {
|
|
167
|
+
const defsByCell = new Map<CellId, VariableName[]>();
|
|
168
|
+
for (const variable of Object.values(variables)) {
|
|
169
|
+
for (const cellId of variable.declaredBy) {
|
|
170
|
+
const defs = defsByCell.get(cellId) ?? [];
|
|
171
|
+
defs.push(variable.name);
|
|
172
|
+
defsByCell.set(cellId, defs);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return defsByCell;
|
|
176
|
+
}
|
|
177
|
+
|
|
27
178
|
interface ElementsBuilder {
|
|
28
179
|
createElements: (
|
|
29
180
|
cellIds: CellId[],
|
|
@@ -59,16 +210,17 @@ export class VerticalElementsBuilder implements ElementsBuilder {
|
|
|
59
210
|
private createNode(
|
|
60
211
|
id: string,
|
|
61
212
|
atom: Atom<CellData>,
|
|
213
|
+
defs: VariableName[],
|
|
62
214
|
prevY: number,
|
|
63
215
|
): Node<NodeData> {
|
|
64
|
-
const
|
|
65
|
-
const height =
|
|
216
|
+
const data: NodeData = { atom, defs };
|
|
217
|
+
const { width, height } = nodeDimensions(data);
|
|
66
218
|
return {
|
|
67
219
|
id: id,
|
|
68
|
-
data
|
|
69
|
-
width
|
|
220
|
+
data,
|
|
221
|
+
width,
|
|
70
222
|
type: "custom",
|
|
71
|
-
height
|
|
223
|
+
height,
|
|
72
224
|
position: { x: 0, y: prevY + 20 },
|
|
73
225
|
};
|
|
74
226
|
}
|
|
@@ -83,8 +235,14 @@ export class VerticalElementsBuilder implements ElementsBuilder {
|
|
|
83
235
|
let prevY = 0;
|
|
84
236
|
const nodes: Node<NodeData>[] = [];
|
|
85
237
|
const edges: Edge[] = [];
|
|
238
|
+
const defsByCell = computeDefsByCell(variables);
|
|
86
239
|
for (const [cellId, cellAtom] of Arrays.zip(cellIds, cellAtoms)) {
|
|
87
|
-
const node = this.createNode(
|
|
240
|
+
const node = this.createNode(
|
|
241
|
+
cellId,
|
|
242
|
+
cellAtom,
|
|
243
|
+
defsByCell.get(cellId) ?? [],
|
|
244
|
+
prevY,
|
|
245
|
+
);
|
|
88
246
|
nodes.push(node);
|
|
89
247
|
prevY = node.position.y + (node.height || 0);
|
|
90
248
|
}
|
|
@@ -110,16 +268,24 @@ export class VerticalElementsBuilder implements ElementsBuilder {
|
|
|
110
268
|
}
|
|
111
269
|
}
|
|
112
270
|
|
|
271
|
+
interface AggregatedEdge {
|
|
272
|
+
source: CellId;
|
|
273
|
+
target: CellId;
|
|
274
|
+
variables: VariableName[];
|
|
275
|
+
isStateFlow: boolean;
|
|
276
|
+
}
|
|
277
|
+
|
|
113
278
|
export class TreeElementsBuilder implements ElementsBuilder {
|
|
114
|
-
private createEdge(
|
|
279
|
+
private createEdge(edge: AggregatedEdge): Edge<DependencyEdgeData> {
|
|
280
|
+
const { source, target, variables, isStateFlow } = edge;
|
|
115
281
|
return {
|
|
116
|
-
|
|
282
|
+
type: DEPENDENCY_EDGE_TYPE,
|
|
117
283
|
markerEnd: {
|
|
118
284
|
type: MarkerType.ArrowClosed,
|
|
285
|
+
color: isStateFlow ? STATE_FLOW_EDGE_COLOR : DEFAULT_EDGE_COLOR,
|
|
119
286
|
},
|
|
120
287
|
id: `${source}-${target}`,
|
|
121
|
-
|
|
122
|
-
style: { strokeWidth: 2 },
|
|
288
|
+
data: { variables, isStateFlow },
|
|
123
289
|
source: source,
|
|
124
290
|
// Use the same handle ids as the custom node
|
|
125
291
|
sourceHandle: OUTPUTS_HANDLE_ID,
|
|
@@ -128,15 +294,19 @@ export class TreeElementsBuilder implements ElementsBuilder {
|
|
|
128
294
|
};
|
|
129
295
|
}
|
|
130
296
|
|
|
131
|
-
private createNode(
|
|
132
|
-
|
|
133
|
-
|
|
297
|
+
private createNode(
|
|
298
|
+
id: string,
|
|
299
|
+
atom: Atom<CellData>,
|
|
300
|
+
defs: VariableName[],
|
|
301
|
+
): Node<NodeData> {
|
|
302
|
+
const data: NodeData = { atom, defs };
|
|
303
|
+
const { width, height } = nodeDimensions(data);
|
|
134
304
|
return {
|
|
135
305
|
id: id,
|
|
136
|
-
data
|
|
137
|
-
width
|
|
306
|
+
data,
|
|
307
|
+
width,
|
|
138
308
|
type: "custom",
|
|
139
|
-
height
|
|
309
|
+
height,
|
|
140
310
|
position: { x: 0, y: 0 },
|
|
141
311
|
};
|
|
142
312
|
}
|
|
@@ -149,10 +319,19 @@ export class TreeElementsBuilder implements ElementsBuilder {
|
|
|
149
319
|
hideReusableFunctions: boolean,
|
|
150
320
|
) {
|
|
151
321
|
const nodes: Node<NodeData>[] = [];
|
|
152
|
-
|
|
322
|
+
|
|
323
|
+
const cellRuntime = getNotebook().cellRuntime;
|
|
324
|
+
// Hidden reusable cells are dropped along with their edges — a reusable
|
|
325
|
+
// function is nearly always referenced somewhere, so gating on "no edges"
|
|
326
|
+
// would make the filter a no-op.
|
|
327
|
+
const hiddenReusable = hideReusableFunctions
|
|
328
|
+
? reusableCellIds(cellIds, cellRuntime)
|
|
329
|
+
: new Set<CellId>();
|
|
153
330
|
|
|
154
331
|
const nodesWithEdges = new Set<CellId>();
|
|
155
|
-
|
|
332
|
+
// Dedupe cell→cell edges, aggregating every variable that crosses them so a
|
|
333
|
+
// single edge can carry multiple refs and flag whether any is a state flow.
|
|
334
|
+
const edgesByPair = new Map<string, AggregatedEdge>();
|
|
156
335
|
|
|
157
336
|
for (const variable of Object.values(variables)) {
|
|
158
337
|
// Skip marimo, since likely every cell uses it
|
|
@@ -160,40 +339,55 @@ export class TreeElementsBuilder implements ElementsBuilder {
|
|
|
160
339
|
continue;
|
|
161
340
|
}
|
|
162
341
|
|
|
342
|
+
const isState = isStateFlowVariable(variable);
|
|
163
343
|
const { declaredBy, usedBy } = variable;
|
|
164
344
|
for (const fromId of declaredBy) {
|
|
165
345
|
for (const toId of usedBy) {
|
|
166
|
-
|
|
167
|
-
if (visited.has(key)) {
|
|
346
|
+
if (hiddenReusable.has(fromId) || hiddenReusable.has(toId)) {
|
|
168
347
|
continue;
|
|
169
348
|
}
|
|
170
|
-
|
|
349
|
+
const key = `${fromId}-${toId}`;
|
|
350
|
+
const existing = edgesByPair.get(key);
|
|
351
|
+
if (existing) {
|
|
352
|
+
existing.variables.push(variable.name);
|
|
353
|
+
existing.isStateFlow ||= isState;
|
|
354
|
+
} else {
|
|
355
|
+
edgesByPair.set(key, {
|
|
356
|
+
source: fromId,
|
|
357
|
+
target: toId,
|
|
358
|
+
variables: [variable.name],
|
|
359
|
+
isStateFlow: isState,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
171
362
|
nodesWithEdges.add(fromId);
|
|
172
363
|
nodesWithEdges.add(toId);
|
|
173
|
-
edges.push(this.createEdge(fromId, toId));
|
|
174
364
|
}
|
|
175
365
|
}
|
|
176
366
|
}
|
|
177
367
|
|
|
178
|
-
const
|
|
368
|
+
const edges = [...edgesByPair.values()].map((edge) =>
|
|
369
|
+
this.createEdge(edge),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const defsByCell = computeDefsByCell(variables);
|
|
179
373
|
|
|
180
374
|
for (const [cellId, cellAtom] of Arrays.zip(cellIds, cellAtoms)) {
|
|
181
375
|
const code = store.get(cellAtom).code.trim();
|
|
182
376
|
const hasEdge = nodesWithEdges.has(cellId);
|
|
183
377
|
const isMarkdown = code.startsWith("mo.md");
|
|
184
|
-
const runtime = cellRuntime[cellId];
|
|
185
|
-
const isReusable = runtime?.serialization?.toLowerCase() === "valid";
|
|
186
378
|
|
|
187
379
|
// Apply filters
|
|
188
380
|
if (hidePureMarkdown && isMarkdown && !hasEdge) {
|
|
189
381
|
continue;
|
|
190
382
|
}
|
|
191
|
-
if (
|
|
383
|
+
if (hiddenReusable.has(cellId)) {
|
|
192
384
|
continue;
|
|
193
385
|
}
|
|
194
386
|
|
|
195
387
|
// Show every cell that wasn't filtered out
|
|
196
|
-
nodes.push(
|
|
388
|
+
nodes.push(
|
|
389
|
+
this.createNode(cellId, cellAtom, defsByCell.get(cellId) ?? []),
|
|
390
|
+
);
|
|
197
391
|
}
|
|
198
392
|
|
|
199
393
|
return { nodes, edges };
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ArrowRightFromLineIcon,
|
|
5
5
|
ArrowRightIcon,
|
|
6
6
|
ArrowRightToLineIcon,
|
|
7
|
+
ExternalLinkIcon,
|
|
7
8
|
MoreVerticalIcon,
|
|
8
9
|
NetworkIcon,
|
|
9
10
|
SettingsIcon,
|
|
@@ -13,17 +14,18 @@ import {
|
|
|
13
14
|
} from "lucide-react";
|
|
14
15
|
import React, { memo, useId } from "react";
|
|
15
16
|
import { type Edge, Panel } from "reactflow";
|
|
16
|
-
import { getCellEditorView } from "@/core/cells/cells";
|
|
17
|
+
import { getCellEditorView, useCellActions } from "@/core/cells/cells";
|
|
17
18
|
import type { CellId } from "@/core/cells/ids";
|
|
18
19
|
import { goToVariableDefinition } from "@/core/codemirror/go-to-definition/commands";
|
|
19
20
|
import type { Variable, Variables } from "@/core/variables/types";
|
|
20
21
|
import { ConnectionCellActionsDropdown } from "../editor/cell/cell-actions";
|
|
21
|
-
import { CellLink } from "../editor/links/cell-link";
|
|
22
|
+
import { CellLink, scrollAndHighlightCell } from "../editor/links/cell-link";
|
|
22
23
|
import { CellLinkList } from "../editor/links/cell-link-list";
|
|
23
24
|
import { Button } from "../ui/button";
|
|
24
25
|
import { Checkbox } from "../ui/checkbox";
|
|
25
26
|
import { Label } from "../ui/label";
|
|
26
27
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
|
28
|
+
import { Tooltip } from "../ui/tooltip";
|
|
27
29
|
import { VariableName } from "../variables/common";
|
|
28
30
|
import type { GraphSelection, GraphSettings, LayoutDirection } from "./types";
|
|
29
31
|
|
|
@@ -122,6 +124,8 @@ export const GraphSelectionPanel: React.FC<{
|
|
|
122
124
|
edges: Edge[];
|
|
123
125
|
variables: Variables;
|
|
124
126
|
}> = memo(({ selection, variables, onClearSelection }) => {
|
|
127
|
+
const { showCellIfHidden } = useCellActions();
|
|
128
|
+
|
|
125
129
|
if (!selection) {
|
|
126
130
|
return null;
|
|
127
131
|
}
|
|
@@ -194,6 +198,23 @@ export const GraphSelectionPanel: React.FC<{
|
|
|
194
198
|
<SquareFunction className="w-5 h-5" />
|
|
195
199
|
<CellLink cellId={selection.id} />
|
|
196
200
|
<div className="flex-1" />
|
|
201
|
+
<Tooltip content="Open cell in editor" delayDuration={200}>
|
|
202
|
+
<Button
|
|
203
|
+
aria-label="Open cell in editor"
|
|
204
|
+
variant="text"
|
|
205
|
+
size="icon"
|
|
206
|
+
onClick={() => {
|
|
207
|
+
// The cell may sit in a collapsed section; reveal it first
|
|
208
|
+
// or the scroll target won't be in the DOM.
|
|
209
|
+
showCellIfHidden({ cellId: selection.id });
|
|
210
|
+
requestAnimationFrame(() => {
|
|
211
|
+
scrollAndHighlightCell(selection.id, "focus");
|
|
212
|
+
});
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<ExternalLinkIcon className="w-4 h-4" />
|
|
216
|
+
</Button>
|
|
217
|
+
</Tooltip>
|
|
197
218
|
<ConnectionCellActionsDropdown cellId={selection.id}>
|
|
198
219
|
<Button variant="ghost" size="icon">
|
|
199
220
|
<MoreVerticalIcon className="w-4 h-4" />
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { Node } from "reactflow";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import type { NodeData } from "../../elements";
|
|
6
|
+
import { boundingBox, placeIslands } from "../layout";
|
|
7
|
+
|
|
8
|
+
function node(
|
|
9
|
+
id: string,
|
|
10
|
+
x: number,
|
|
11
|
+
y: number,
|
|
12
|
+
width: number,
|
|
13
|
+
height: number,
|
|
14
|
+
): Node<NodeData> {
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
position: { x, y },
|
|
18
|
+
width,
|
|
19
|
+
height,
|
|
20
|
+
data: { atom: {} as NodeData["atom"], defs: [] },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("boundingBox", () => {
|
|
25
|
+
it("is all-zero for an empty set", () => {
|
|
26
|
+
expect(boundingBox([])).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("spans position + size across nodes", () => {
|
|
30
|
+
const box = boundingBox([
|
|
31
|
+
node("a", 0, 0, 50, 50),
|
|
32
|
+
node("b", 10, 20, 100, 40),
|
|
33
|
+
]);
|
|
34
|
+
expect(box).toEqual({ minX: 0, minY: 0, maxX: 110, maxY: 60 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads size from style.width/height when width/height are absent", () => {
|
|
38
|
+
const groupNode: Node = {
|
|
39
|
+
id: "g",
|
|
40
|
+
position: { x: 5, y: 5 },
|
|
41
|
+
data: {},
|
|
42
|
+
style: { width: 200, height: 100 },
|
|
43
|
+
};
|
|
44
|
+
expect(boundingBox([groupNode])).toEqual({
|
|
45
|
+
minX: 5,
|
|
46
|
+
minY: 5,
|
|
47
|
+
maxX: 205,
|
|
48
|
+
maxY: 105,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("placeIslands", () => {
|
|
54
|
+
it("returns nothing when there are no islands", () => {
|
|
55
|
+
expect(placeIslands([], [node("a", 0, 0, 100, 100)])).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("stacks islands in a column to the right of the placed graph", () => {
|
|
59
|
+
const placed = [node("main", 0, 0, 200, 300)];
|
|
60
|
+
const islands = [node("i0", 0, 0, 90, 40), node("i1", 0, 0, 90, 40)];
|
|
61
|
+
const result = placeIslands(islands, placed);
|
|
62
|
+
// startX = maxX(200) + margin(40) = 240; startY = minY(0).
|
|
63
|
+
expect(result[0].position).toEqual({ x: 240, y: 0 });
|
|
64
|
+
// Second island stacked below the first (height 40 + gap 20).
|
|
65
|
+
expect(result[1].position).toEqual({ x: 240, y: 60 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("wraps into a new column when the column gets too tall", () => {
|
|
69
|
+
const placed = [node("main", 0, 0, 200, 300)];
|
|
70
|
+
const islands = Array.from({ length: 10 }, (_, i) =>
|
|
71
|
+
node(`i${i}`, 0, 0, 90, 40),
|
|
72
|
+
);
|
|
73
|
+
const result = placeIslands(islands, placed);
|
|
74
|
+
const firstColumnX = result[0].position.x;
|
|
75
|
+
const wrapped = result.find((n) => n.position.x > firstColumnX);
|
|
76
|
+
expect(wrapped).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -1,45 +1,135 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { graphlib, layout } from "@dagrejs/dagre";
|
|
3
3
|
import type { Edge, Node } from "reactflow";
|
|
4
|
+
import type { NodeData } from "../elements";
|
|
4
5
|
import type { LayoutDirection } from "../types";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// Disconnected cells are stacked in a compact column to the side of the main
|
|
8
|
+
// graph, like TensorBoard's extracted/auxiliary nodes (extractXOffset 15,
|
|
9
|
+
// extractYOffset 20).
|
|
10
|
+
const ISLAND_GAP = 20;
|
|
11
|
+
const ISLAND_MARGIN = 40;
|
|
12
|
+
const ISLAND_MIN_COLUMN_HEIGHT = 320;
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Lay out cell nodes with dagre. Disconnected cells are pulled out of the
|
|
16
|
+
* dagre flow and stacked in a compact column to the side of the main graph.
|
|
17
|
+
*/
|
|
8
18
|
export const layoutElements = ({
|
|
9
19
|
nodes,
|
|
10
20
|
edges,
|
|
11
21
|
direction,
|
|
12
22
|
}: {
|
|
13
|
-
nodes: Node[];
|
|
23
|
+
nodes: Node<NodeData>[];
|
|
14
24
|
edges: Edge[];
|
|
15
25
|
direction: LayoutDirection;
|
|
16
|
-
}) => {
|
|
26
|
+
}): { nodes: Node<NodeData>[]; edges: Edge[] } => {
|
|
27
|
+
const g = new graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
|
17
28
|
g.setGraph({
|
|
18
29
|
rankdir: direction,
|
|
19
|
-
nodesep
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
30
|
+
// Tight spacing mirroring TensorBoard's graph (nodesep 5, ranksep 25,
|
|
31
|
+
// edgesep 5), nudged up a little since our nodes are wider ref boxes than
|
|
32
|
+
// TensorBoard's op ellipses. The dagre default ranker (network-simplex)
|
|
33
|
+
// gives cleaner layered routing with fewer crossings than longest-path.
|
|
34
|
+
nodesep: 20,
|
|
35
|
+
ranksep: 30,
|
|
36
|
+
edgesep: 10,
|
|
24
37
|
});
|
|
25
38
|
|
|
39
|
+
// Disconnected cells are pulled out of the dagre flow and stacked to the
|
|
40
|
+
// side, so they don't stretch the main graph's layout.
|
|
41
|
+
const connectedIds = new Set<string>();
|
|
42
|
+
for (const edge of edges) {
|
|
43
|
+
connectedIds.add(edge.source);
|
|
44
|
+
connectedIds.add(edge.target);
|
|
45
|
+
}
|
|
46
|
+
const isIsland = (node: Node<NodeData>) => !connectedIds.has(node.id);
|
|
47
|
+
const flowNodes = nodes.filter((node) => !isIsland(node));
|
|
48
|
+
const islands = nodes.filter(isIsland);
|
|
49
|
+
|
|
26
50
|
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
|
|
27
|
-
|
|
51
|
+
flowNodes.forEach((node) => {
|
|
28
52
|
g.setNode(node.id, {
|
|
29
53
|
...node,
|
|
30
54
|
width: node.width ?? 0,
|
|
31
55
|
height: node.height ?? 0,
|
|
32
|
-
})
|
|
33
|
-
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
34
58
|
|
|
35
59
|
layout(g);
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return { ...node, position: { x, y } };
|
|
42
|
-
}),
|
|
43
|
-
edges,
|
|
61
|
+
// dagre reports node coords as centers; ReactFlow wants top-left.
|
|
62
|
+
const topLeft = (id: string) => {
|
|
63
|
+
const { x, y, width, height } = g.node(id);
|
|
64
|
+
return { x: x - width / 2, y: y - height / 2 };
|
|
44
65
|
};
|
|
66
|
+
|
|
67
|
+
const cellNodes = flowNodes.map((node) => ({
|
|
68
|
+
...node,
|
|
69
|
+
position: topLeft(node.id),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const islandNodes = placeIslands(islands, cellNodes);
|
|
73
|
+
return { nodes: [...cellNodes, ...islandNodes], edges };
|
|
45
74
|
};
|
|
75
|
+
|
|
76
|
+
/** Bounding box of already-positioned nodes (top-left position + size). */
|
|
77
|
+
export function boundingBox(nodes: Node[]): {
|
|
78
|
+
minX: number;
|
|
79
|
+
minY: number;
|
|
80
|
+
maxX: number;
|
|
81
|
+
maxY: number;
|
|
82
|
+
} {
|
|
83
|
+
if (nodes.length === 0) {
|
|
84
|
+
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
85
|
+
}
|
|
86
|
+
let minX = Infinity;
|
|
87
|
+
let minY = Infinity;
|
|
88
|
+
let maxX = -Infinity;
|
|
89
|
+
let maxY = -Infinity;
|
|
90
|
+
for (const node of nodes) {
|
|
91
|
+
const styleW = node.style?.width;
|
|
92
|
+
const styleH = node.style?.height;
|
|
93
|
+
const w = node.width ?? (typeof styleW === "number" ? styleW : 0);
|
|
94
|
+
const h = node.height ?? (typeof styleH === "number" ? styleH : 0);
|
|
95
|
+
minX = Math.min(minX, node.position.x);
|
|
96
|
+
minY = Math.min(minY, node.position.y);
|
|
97
|
+
maxX = Math.max(maxX, node.position.x + w);
|
|
98
|
+
maxY = Math.max(maxY, node.position.y + h);
|
|
99
|
+
}
|
|
100
|
+
return { minX, minY, maxX, maxY };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stack disconnected nodes in a compact column (wrapping into more columns when
|
|
105
|
+
* tall) just to the right of the main graph.
|
|
106
|
+
*/
|
|
107
|
+
export function placeIslands(
|
|
108
|
+
islands: Node<NodeData>[],
|
|
109
|
+
placed: Node[],
|
|
110
|
+
): Node<NodeData>[] {
|
|
111
|
+
if (islands.length === 0) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const box = boundingBox(placed);
|
|
115
|
+
const startX = (placed.length === 0 ? 0 : box.maxX) + ISLAND_MARGIN;
|
|
116
|
+
const startY = placed.length === 0 ? 0 : box.minY;
|
|
117
|
+
const columnHeight = Math.max(box.maxY - box.minY, ISLAND_MIN_COLUMN_HEIGHT);
|
|
118
|
+
|
|
119
|
+
let x = startX;
|
|
120
|
+
let y = startY;
|
|
121
|
+
let columnWidth = 0;
|
|
122
|
+
return islands.map((node) => {
|
|
123
|
+
const h = node.height ?? 0;
|
|
124
|
+
if (y > startY && y + h > startY + columnHeight) {
|
|
125
|
+
// Wrap to the next column.
|
|
126
|
+
x += columnWidth + ISLAND_MARGIN;
|
|
127
|
+
y = startY;
|
|
128
|
+
columnWidth = 0;
|
|
129
|
+
}
|
|
130
|
+
const position = { x, y };
|
|
131
|
+
y += h + ISLAND_GAP;
|
|
132
|
+
columnWidth = Math.max(columnWidth, node.width ?? 0);
|
|
133
|
+
return { ...node, position };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -21,13 +21,14 @@ import { Input } from "../ui/input";
|
|
|
21
21
|
import { Tooltip } from "../ui/tooltip";
|
|
22
22
|
|
|
23
23
|
const BASE_URL = "https://static.marimo.app";
|
|
24
|
-
|
|
25
24
|
export const ShareStaticNotebookModal: React.FC<{
|
|
26
25
|
onClose: () => void;
|
|
27
26
|
}> = ({ onClose }) => {
|
|
28
27
|
const [slug, setSlug] = useState("");
|
|
29
28
|
const { exportAsHTML } = useRequestClient();
|
|
30
|
-
|
|
29
|
+
|
|
30
|
+
// Keep one random suffix for the lifetime of the modal.
|
|
31
|
+
// oxlint-disable-next-line react/purity
|
|
31
32
|
const randomHash = useMemo(() => Math.random().toString(36).slice(2, 6), []);
|
|
32
33
|
|
|
33
34
|
// Globally unique path
|