@marimo-team/islands 0.19.7-dev6 → 0.19.7-dev8
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
|
@@ -7,15 +7,14 @@ import { Logger } from "@/utils/Logger";
|
|
|
7
7
|
|
|
8
8
|
import "./plotly.css";
|
|
9
9
|
import "./mapbox.css";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { type JSX, lazy, memo, useEffect, useMemo, useState } from "react";
|
|
10
|
+
import { pick, set } from "lodash-es";
|
|
11
|
+
import { type JSX, lazy, memo, useMemo } from "react";
|
|
13
12
|
import useEvent from "react-use-event-hook";
|
|
14
13
|
import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
|
|
15
14
|
import { useScript } from "@/hooks/useScript";
|
|
16
15
|
import { Arrays } from "@/utils/arrays";
|
|
17
|
-
import { Objects } from "@/utils/objects";
|
|
18
16
|
import { createParser, type PlotlyTemplateParser } from "./parse-from-template";
|
|
17
|
+
import { usePlotlyLayout } from "./usePlotlyLayout";
|
|
19
18
|
|
|
20
19
|
interface Data {
|
|
21
20
|
figure: Figure;
|
|
@@ -80,18 +79,6 @@ export const LazyPlot = lazy(() =>
|
|
|
80
79
|
}),
|
|
81
80
|
);
|
|
82
81
|
|
|
83
|
-
function initialLayout(figure: Figure): Partial<Plotly.Layout> {
|
|
84
|
-
// Enable autosize if width is not specified
|
|
85
|
-
const shouldAutoSize = figure.layout.width === undefined;
|
|
86
|
-
return {
|
|
87
|
-
autosize: shouldAutoSize,
|
|
88
|
-
dragmode: "select",
|
|
89
|
-
height: 540,
|
|
90
|
-
// Prioritize user's config
|
|
91
|
-
...figure.layout,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
82
|
const SUNBURST_DATA_KEYS: (keyof Plotly.SunburstPlotDatum)[] = [
|
|
96
83
|
"color",
|
|
97
84
|
"curveNumber",
|
|
@@ -111,38 +98,20 @@ const TREE_MAP_DATA_KEYS = SUNBURST_DATA_KEYS;
|
|
|
111
98
|
|
|
112
99
|
export const PlotlyComponent = memo(
|
|
113
100
|
({ figure: originalFigure, value, setValue, config }: PlotlyPluginProps) => {
|
|
114
|
-
const [figure, setFigure] = useState(() => {
|
|
115
|
-
// We clone the figure since Plotly mutates the figure in place
|
|
116
|
-
return structuredClone(originalFigure);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
101
|
// Used for rendering LaTeX. TODO: Serve this library from Marimo
|
|
120
102
|
const scriptStatus = useScript(
|
|
121
103
|
"https://cdn.jsdelivr.net/npm/mathjax-full@3.2.2/es5/tex-mml-svg.min.js",
|
|
122
104
|
);
|
|
123
105
|
const isScriptLoaded = scriptStatus === "ready";
|
|
124
106
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
...initialLayout(nextFigure),
|
|
130
|
-
...prev,
|
|
131
|
-
}));
|
|
132
|
-
}, [originalFigure, isScriptLoaded]);
|
|
133
|
-
|
|
134
|
-
const [layout, setLayout] = useState<Partial<Plotly.Layout>>(() => {
|
|
135
|
-
return {
|
|
136
|
-
...initialLayout(figure),
|
|
137
|
-
// Override with persisted values (dragmode, xaxis, yaxis)
|
|
138
|
-
...value,
|
|
139
|
-
};
|
|
107
|
+
const { figure, layout, handleReset } = usePlotlyLayout({
|
|
108
|
+
originalFigure,
|
|
109
|
+
initialValue: value,
|
|
110
|
+
isScriptLoaded,
|
|
140
111
|
});
|
|
141
112
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
setFigure(nextFigure);
|
|
145
|
-
setLayout(initialLayout(nextFigure));
|
|
113
|
+
const handleResetWithClear = useEvent(() => {
|
|
114
|
+
handleReset();
|
|
146
115
|
setValue({});
|
|
147
116
|
});
|
|
148
117
|
|
|
@@ -163,37 +132,13 @@ export const PlotlyComponent = memo(
|
|
|
163
132
|
<path d="M3 3v5h5" />
|
|
164
133
|
</svg>`,
|
|
165
134
|
},
|
|
166
|
-
click:
|
|
135
|
+
click: handleResetWithClear,
|
|
167
136
|
},
|
|
168
137
|
],
|
|
169
138
|
// Prioritize user's config
|
|
170
139
|
...configMemo,
|
|
171
140
|
};
|
|
172
|
-
}, [
|
|
173
|
-
|
|
174
|
-
const prevFigure = usePrevious(figure) ?? figure;
|
|
175
|
-
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
const omitKeys = new Set<keyof Plotly.Layout>([
|
|
178
|
-
"autosize",
|
|
179
|
-
"dragmode",
|
|
180
|
-
"xaxis",
|
|
181
|
-
"yaxis",
|
|
182
|
-
]);
|
|
183
|
-
|
|
184
|
-
// If the key was updated externally (e.g. can be specifically passed in the config)
|
|
185
|
-
// then we need to update the layout
|
|
186
|
-
for (const key of omitKeys) {
|
|
187
|
-
if (!isEqual(figure.layout[key], prevFigure.layout[key])) {
|
|
188
|
-
omitKeys.delete(key);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Update layout when figure.layout changes
|
|
193
|
-
// Omit keys that we don't want to override
|
|
194
|
-
const layout = Objects.omit(figure.layout, omitKeys);
|
|
195
|
-
setLayout((prev) => ({ ...prev, ...layout }));
|
|
196
|
-
}, [figure.layout, prevFigure.layout]);
|
|
141
|
+
}, [handleResetWithClear, configMemo]);
|
|
197
142
|
|
|
198
143
|
return (
|
|
199
144
|
<LazyPlot
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { Figure } from "react-plotly.js";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
computeLayoutOnFigureChange,
|
|
7
|
+
computeLayoutUpdate,
|
|
8
|
+
computeOmitKeys,
|
|
9
|
+
createInitialLayout,
|
|
10
|
+
} from "../usePlotlyLayout";
|
|
11
|
+
|
|
12
|
+
function createFigure(layoutOverrides: Partial<Plotly.Layout> = {}): Figure {
|
|
13
|
+
return {
|
|
14
|
+
data: [],
|
|
15
|
+
layout: { ...layoutOverrides } as Plotly.Layout,
|
|
16
|
+
frames: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("createInitialLayout", () => {
|
|
21
|
+
it("sets defaults and merges figure layout", () => {
|
|
22
|
+
const figure = createFigure({ title: { text: "Test" }, dragmode: "zoom" });
|
|
23
|
+
const result = createInitialLayout(figure);
|
|
24
|
+
|
|
25
|
+
expect(result.autosize).toBe(true);
|
|
26
|
+
expect(result.height).toBe(540);
|
|
27
|
+
expect(result.dragmode).toBe("zoom"); // figure overrides default
|
|
28
|
+
expect(result.title).toEqual({ text: "Test" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("disables autosize when width is specified", () => {
|
|
32
|
+
const result = createInitialLayout(createFigure({ width: 800 }));
|
|
33
|
+
expect(result.autosize).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("computeLayoutOnFigureChange", () => {
|
|
38
|
+
it("preserves only dragmode/xaxis/yaxis from previous layout (#7964)", () => {
|
|
39
|
+
const nextFigure = createFigure({ title: { text: "New" } });
|
|
40
|
+
const prevLayout: Partial<Plotly.Layout> = {
|
|
41
|
+
dragmode: "zoom",
|
|
42
|
+
xaxis: { range: [0, 10] },
|
|
43
|
+
yaxis: { range: [0, 100] },
|
|
44
|
+
shapes: [{ type: "rect", x0: 0, x1: 1, y0: 0, y1: 1 }],
|
|
45
|
+
annotations: [{ text: "Old", x: 0, y: 0 }],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const result = computeLayoutOnFigureChange(nextFigure, prevLayout);
|
|
49
|
+
|
|
50
|
+
// Preserved from prev
|
|
51
|
+
expect(result.dragmode).toBe("zoom");
|
|
52
|
+
expect(result.xaxis).toEqual({ range: [0, 10] });
|
|
53
|
+
expect(result.yaxis).toEqual({ range: [0, 100] });
|
|
54
|
+
// From new figure
|
|
55
|
+
expect(result.title).toEqual({ text: "New" });
|
|
56
|
+
// NOT preserved (the bug fix)
|
|
57
|
+
expect(result.shapes).toBeUndefined();
|
|
58
|
+
expect(result.annotations).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("uses shapes from new figure, not previous layout", () => {
|
|
62
|
+
const nextFigure = createFigure({
|
|
63
|
+
shapes: [{ type: "circle", x0: 0, x1: 1, y0: 0, y1: 1 }],
|
|
64
|
+
});
|
|
65
|
+
const prevLayout: Partial<Plotly.Layout> = {
|
|
66
|
+
shapes: [{ type: "rect", x0: 0, x1: 1, y0: 0, y1: 1 }],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = computeLayoutOnFigureChange(nextFigure, prevLayout);
|
|
70
|
+
|
|
71
|
+
expect(result.shapes).toHaveLength(1);
|
|
72
|
+
expect(result.shapes?.[0].type).toBe("circle");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("computeOmitKeys", () => {
|
|
77
|
+
it("omits user-interaction keys unless they changed in figure", () => {
|
|
78
|
+
const unchanged = computeOmitKeys({}, {});
|
|
79
|
+
expect([...unchanged]).toEqual(
|
|
80
|
+
expect.arrayContaining(["autosize", "dragmode", "xaxis", "yaxis"]),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const changed = computeOmitKeys(
|
|
84
|
+
{ dragmode: "zoom", xaxis: { range: [0, 10] } },
|
|
85
|
+
{ dragmode: "select", xaxis: { range: [0, 5] } },
|
|
86
|
+
);
|
|
87
|
+
expect(changed.has("dragmode")).toBe(false);
|
|
88
|
+
expect(changed.has("xaxis")).toBe(false);
|
|
89
|
+
expect(changed.has("autosize")).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("computeLayoutUpdate", () => {
|
|
94
|
+
it("merges figure layout while respecting omit keys", () => {
|
|
95
|
+
// dragmode unchanged in figure -> preserve prev layout's dragmode
|
|
96
|
+
const result1 = computeLayoutUpdate(
|
|
97
|
+
{ dragmode: "pan", title: { text: "New" } },
|
|
98
|
+
{ dragmode: "pan" },
|
|
99
|
+
{ dragmode: "zoom", height: 400 },
|
|
100
|
+
);
|
|
101
|
+
expect(result1.dragmode).toBe("zoom");
|
|
102
|
+
expect(result1.title).toEqual({ text: "New" });
|
|
103
|
+
expect(result1.height).toBe(400);
|
|
104
|
+
|
|
105
|
+
// dragmode changed in figure -> use figure's dragmode
|
|
106
|
+
const result2 = computeLayoutUpdate(
|
|
107
|
+
{ dragmode: "pan" },
|
|
108
|
+
{ dragmode: "select" },
|
|
109
|
+
{ dragmode: "zoom" },
|
|
110
|
+
);
|
|
111
|
+
expect(result2.dragmode).toBe("pan");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { usePrevious } from "@uidotdev/usehooks";
|
|
4
|
+
import { isEqual, pick } from "lodash-es";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import type { Figure } from "react-plotly.js";
|
|
7
|
+
import { Objects } from "@/utils/objects";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keys that are preserved across figure updates when set by user interaction.
|
|
11
|
+
* These include dragmode and axis settings that users may adjust.
|
|
12
|
+
*/
|
|
13
|
+
export const PERSISTED_LAYOUT_KEYS = ["dragmode", "xaxis", "yaxis"] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keys that are omitted from layout updates unless they changed in the figure.
|
|
17
|
+
* This prevents overwriting user interactions like zoom/pan.
|
|
18
|
+
*/
|
|
19
|
+
export const LAYOUT_OMIT_KEYS: (keyof Plotly.Layout)[] = [
|
|
20
|
+
"autosize",
|
|
21
|
+
"dragmode",
|
|
22
|
+
"xaxis",
|
|
23
|
+
"yaxis",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates the initial layout for a Plotly figure with sensible defaults.
|
|
28
|
+
*/
|
|
29
|
+
export function createInitialLayout(figure: Figure): Partial<Plotly.Layout> {
|
|
30
|
+
// Enable autosize if width is not specified
|
|
31
|
+
const shouldAutoSize = figure.layout.width === undefined;
|
|
32
|
+
return {
|
|
33
|
+
autosize: shouldAutoSize,
|
|
34
|
+
dragmode: "select",
|
|
35
|
+
height: 540,
|
|
36
|
+
// Prioritize user's config
|
|
37
|
+
...figure.layout,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Computes the updated layout when the figure changes.
|
|
43
|
+
* Preserves user-interaction values (dragmode, xaxis, yaxis) while
|
|
44
|
+
* taking everything else from the new figure's layout.
|
|
45
|
+
*/
|
|
46
|
+
export function computeLayoutOnFigureChange(
|
|
47
|
+
nextFigure: Figure,
|
|
48
|
+
prevLayout: Partial<Plotly.Layout>,
|
|
49
|
+
): Partial<Plotly.Layout> {
|
|
50
|
+
return {
|
|
51
|
+
...createInitialLayout(nextFigure),
|
|
52
|
+
...pick(prevLayout, PERSISTED_LAYOUT_KEYS),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Computes which keys to omit from layout updates based on what changed.
|
|
58
|
+
* If a key changed in the figure, we should update it even if it's normally omitted.
|
|
59
|
+
*/
|
|
60
|
+
export function computeOmitKeys(
|
|
61
|
+
currentLayout: Partial<Plotly.Layout>,
|
|
62
|
+
previousLayout: Partial<Plotly.Layout>,
|
|
63
|
+
): Set<keyof Plotly.Layout> {
|
|
64
|
+
const omitKeys = new Set<keyof Plotly.Layout>(LAYOUT_OMIT_KEYS);
|
|
65
|
+
|
|
66
|
+
// If the key was updated externally (e.g. can be specifically passed in the config)
|
|
67
|
+
// then we need to update the layout
|
|
68
|
+
for (const key of omitKeys) {
|
|
69
|
+
if (!isEqual(currentLayout[key], previousLayout[key])) {
|
|
70
|
+
omitKeys.delete(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return omitKeys;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Computes the layout update when figure.layout changes.
|
|
79
|
+
* Omits keys that shouldn't override user interactions unless they changed.
|
|
80
|
+
*/
|
|
81
|
+
export function computeLayoutUpdate(
|
|
82
|
+
figureLayout: Partial<Plotly.Layout>,
|
|
83
|
+
previousFigureLayout: Partial<Plotly.Layout>,
|
|
84
|
+
prevLayout: Partial<Plotly.Layout>,
|
|
85
|
+
): Partial<Plotly.Layout> {
|
|
86
|
+
const omitKeys = computeOmitKeys(figureLayout, previousFigureLayout);
|
|
87
|
+
const layoutUpdate = Objects.omit(figureLayout, omitKeys);
|
|
88
|
+
return { ...prevLayout, ...layoutUpdate };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface UsePlotlyLayoutOptions {
|
|
92
|
+
originalFigure: Figure;
|
|
93
|
+
initialValue?: Partial<Plotly.Layout>;
|
|
94
|
+
isScriptLoaded?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface UsePlotlyLayoutResult {
|
|
98
|
+
figure: Figure;
|
|
99
|
+
layout: Partial<Plotly.Layout>;
|
|
100
|
+
setLayout: React.Dispatch<React.SetStateAction<Partial<Plotly.Layout>>>;
|
|
101
|
+
handleReset: () => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Hook that manages the Plotly figure and layout state.
|
|
106
|
+
*
|
|
107
|
+
* This hook handles:
|
|
108
|
+
* - Cloning the figure to prevent Plotly mutations
|
|
109
|
+
* - Managing layout state with proper preservation of user interactions
|
|
110
|
+
* - Syncing layout when the figure changes
|
|
111
|
+
* - Providing a reset function to restore original state
|
|
112
|
+
*/
|
|
113
|
+
export function usePlotlyLayout({
|
|
114
|
+
originalFigure,
|
|
115
|
+
initialValue,
|
|
116
|
+
isScriptLoaded = true,
|
|
117
|
+
}: UsePlotlyLayoutOptions): UsePlotlyLayoutResult {
|
|
118
|
+
const [figure, setFigure] = useState(() => {
|
|
119
|
+
// We clone the figure since Plotly mutates the figure in place
|
|
120
|
+
return structuredClone(originalFigure);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const [layout, setLayout] = useState<Partial<Plotly.Layout>>(() => {
|
|
124
|
+
return {
|
|
125
|
+
...createInitialLayout(figure),
|
|
126
|
+
// Override with persisted values (dragmode, xaxis, yaxis)
|
|
127
|
+
...initialValue,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Update figure and layout when originalFigure changes
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const nextFigure = structuredClone(originalFigure);
|
|
134
|
+
setFigure(nextFigure);
|
|
135
|
+
// Start with the new figure's layout, then only preserve user-interaction
|
|
136
|
+
// values (dragmode, xaxis, yaxis) from the previous layout.
|
|
137
|
+
// We don't want to preserve other properties like `shapes` from the previous
|
|
138
|
+
// layout, as they should be fully controlled by the figure prop.
|
|
139
|
+
setLayout((prev) => computeLayoutOnFigureChange(nextFigure, prev));
|
|
140
|
+
}, [originalFigure, isScriptLoaded]);
|
|
141
|
+
|
|
142
|
+
const prevFigure = usePrevious(figure) ?? figure;
|
|
143
|
+
|
|
144
|
+
// Sync layout when figure.layout changes
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
setLayout((prev) =>
|
|
147
|
+
computeLayoutUpdate(figure.layout, prevFigure.layout, prev),
|
|
148
|
+
);
|
|
149
|
+
}, [figure.layout, prevFigure.layout]);
|
|
150
|
+
|
|
151
|
+
const handleReset = () => {
|
|
152
|
+
const nextFigure = structuredClone(originalFigure);
|
|
153
|
+
setFigure(nextFigure);
|
|
154
|
+
setLayout(createInitialLayout(nextFigure));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
figure,
|
|
159
|
+
layout,
|
|
160
|
+
setLayout,
|
|
161
|
+
handleReset,
|
|
162
|
+
};
|
|
163
|
+
}
|