@marimo-team/islands 0.22.6-dev1 → 0.22.6-dev12
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/{chat-ui-BXYRQ5MH.js → chat-ui-B9oZ19ii.js} +1 -0
- package/dist/main.js +55 -37
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/app-config/user-config-form.tsx +25 -0
- package/src/components/editor/actions/pair-with-agent-modal.tsx +4 -1
- package/src/components/editor/cell/code/cell-editor.tsx +4 -0
- package/src/components/editor/package-alert.tsx +17 -0
- package/src/core/alerts/state.ts +1 -0
- package/src/core/codemirror/ai/resources.ts +1 -0
- package/src/core/codemirror/cm.ts +3 -1
- package/src/core/codemirror/completion/__tests__/keymap.test.ts +40 -1
- package/src/core/codemirror/completion/accept-on-enter-atom.ts +10 -0
- package/src/core/codemirror/completion/keymap.ts +16 -9
- package/src/plugins/impl/plotly/PlotlyPlugin.tsx +4 -2
- package/src/plugins/impl/plotly/__tests__/PlotlyPlugin.test.tsx +50 -0
- package/src/plugins/impl/plotly/__tests__/selection.test.ts +73 -0
- package/src/plugins/impl/plotly/__tests__/usePlotlyLayout.test.ts +104 -6
- package/src/plugins/impl/plotly/selection.ts +35 -3
- package/src/plugins/impl/plotly/usePlotlyLayout.ts +38 -4
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import { useForm } from "react-hook-form";
|
|
|
19
19
|
import type z from "zod";
|
|
20
20
|
import { Button } from "@/components/ui/button";
|
|
21
21
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
22
|
+
import { acceptCompletionOnEnterAtom } from "@/core/codemirror/completion/accept-on-enter-atom";
|
|
22
23
|
import {
|
|
23
24
|
Form,
|
|
24
25
|
FormControl,
|
|
@@ -118,6 +119,9 @@ const LOCALE_SYSTEM_VALUE = "__system__";
|
|
|
118
119
|
|
|
119
120
|
export const UserConfigForm: React.FC = () => {
|
|
120
121
|
const [config, setConfig] = useUserConfig();
|
|
122
|
+
const [acceptOnEnter, setAcceptOnEnter] = useAtom(
|
|
123
|
+
acceptCompletionOnEnterAtom,
|
|
124
|
+
);
|
|
121
125
|
const formElement = useRef<HTMLFormElement>(null);
|
|
122
126
|
const setKeyboardShortcutsOpen = useSetAtom(keyboardShortcutsAtom);
|
|
123
127
|
const [activeCategory, setActiveCategory] = useAtom(
|
|
@@ -446,6 +450,27 @@ export const UserConfigForm: React.FC = () => {
|
|
|
446
450
|
</div>
|
|
447
451
|
)}
|
|
448
452
|
/>
|
|
453
|
+
<div className="flex flex-col space-y-1">
|
|
454
|
+
<FormItem className={formItemClasses}>
|
|
455
|
+
<FormLabel className="font-normal">
|
|
456
|
+
Accept suggestion on Enter
|
|
457
|
+
</FormLabel>
|
|
458
|
+
<FormControl>
|
|
459
|
+
<Checkbox
|
|
460
|
+
data-testid="accept-completion-on-enter-checkbox"
|
|
461
|
+
checked={acceptOnEnter}
|
|
462
|
+
onCheckedChange={(checked) =>
|
|
463
|
+
setAcceptOnEnter(Boolean(checked))
|
|
464
|
+
}
|
|
465
|
+
/>
|
|
466
|
+
</FormControl>
|
|
467
|
+
</FormItem>
|
|
468
|
+
<FormDescription>
|
|
469
|
+
When unchecked, pressing Enter inserts a new line instead of
|
|
470
|
+
accepting an autocomplete suggestion. Use Tab to accept
|
|
471
|
+
suggestions.
|
|
472
|
+
</FormDescription>
|
|
473
|
+
</div>
|
|
449
474
|
<FormField
|
|
450
475
|
control={form.control}
|
|
451
476
|
name="completion.signature_hint_on_typing"
|
|
@@ -16,6 +16,7 @@ import { Events } from "@/utils/events";
|
|
|
16
16
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
17
17
|
import { assertNever } from "@/utils/assertNever";
|
|
18
18
|
import { asRemoteURL, useRuntimeManager } from "@/core/runtime/config";
|
|
19
|
+
import { API } from "@/core/network/api";
|
|
19
20
|
|
|
20
21
|
type AgentTab = "claude" | "codex" | "opencode";
|
|
21
22
|
|
|
@@ -54,7 +55,9 @@ const SKILL_INSTALL = "npx skills add marimo-team/marimo-pair";
|
|
|
54
55
|
function useAuthToken(): string | null {
|
|
55
56
|
const [token, setToken] = useState<string | null>(null);
|
|
56
57
|
useEffect(() => {
|
|
57
|
-
fetch(asRemoteURL("/auth/token").href, {
|
|
58
|
+
fetch(asRemoteURL("/auth/token").href, {
|
|
59
|
+
headers: API.headers(),
|
|
60
|
+
})
|
|
58
61
|
.then((res) =>
|
|
59
62
|
res.ok ? (res.json() as Promise<{ token: string | null }>) : null,
|
|
60
63
|
)
|
|
@@ -13,6 +13,7 @@ import { useCellActions } from "@/core/cells/cells";
|
|
|
13
13
|
import { usePendingDeleteService } from "@/core/cells/pending-delete-service";
|
|
14
14
|
import type { CellData, CellRuntimeState } from "@/core/cells/types";
|
|
15
15
|
import { setupCodeMirror } from "@/core/codemirror/cm";
|
|
16
|
+
import { acceptCompletionOnEnterAtom } from "@/core/codemirror/completion/accept-on-enter-atom";
|
|
16
17
|
import {
|
|
17
18
|
getInitialLanguageAdapter,
|
|
18
19
|
languageAdapterState,
|
|
@@ -146,6 +147,7 @@ const CellEditorInternal = ({
|
|
|
146
147
|
});
|
|
147
148
|
|
|
148
149
|
const autoInstantiate = useAtomValue(autoInstantiateAtom);
|
|
150
|
+
const acceptCompletionOnEnter = useAtomValue(acceptCompletionOnEnterAtom);
|
|
149
151
|
const afterToggleMarkdown = useEvent(() => {
|
|
150
152
|
maybeAddMarimoImport({
|
|
151
153
|
autoInstantiate,
|
|
@@ -212,6 +214,7 @@ const CellEditorInternal = ({
|
|
|
212
214
|
},
|
|
213
215
|
},
|
|
214
216
|
completionConfig: userConfig.completion,
|
|
217
|
+
acceptCompletionOnEnter,
|
|
215
218
|
keymapConfig: userConfig.keymap,
|
|
216
219
|
lspConfig: userConfig.language_servers,
|
|
217
220
|
theme,
|
|
@@ -261,6 +264,7 @@ const CellEditorInternal = ({
|
|
|
261
264
|
return extensions;
|
|
262
265
|
}, [
|
|
263
266
|
cellId,
|
|
267
|
+
acceptCompletionOnEnter,
|
|
264
268
|
userConfig.keymap,
|
|
265
269
|
userConfig.completion,
|
|
266
270
|
userConfig.language_servers,
|
|
@@ -76,6 +76,21 @@ function buildPackageSpecifier(name: string, extras: string[]): string {
|
|
|
76
76
|
return `${name}[${extras.join(",")}]`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const SourceBadge: React.FC<{ source?: "kernel" | "server" }> = ({
|
|
80
|
+
source,
|
|
81
|
+
}) => {
|
|
82
|
+
if (source !== "server") {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<Tooltip content="Installing into the server environment, not the notebook kernel">
|
|
87
|
+
<span className="ml-2 text-xs font-normal border border-current rounded px-1 py-0.5 opacity-60">
|
|
88
|
+
server
|
|
89
|
+
</span>
|
|
90
|
+
</Tooltip>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
79
94
|
export const PackageAlert: React.FC = () => {
|
|
80
95
|
const { packageAlert, packageLogs } = useAlerts();
|
|
81
96
|
const { clearPackageAlert } = useAlertActions();
|
|
@@ -105,6 +120,7 @@ export const PackageAlert: React.FC = () => {
|
|
|
105
120
|
<span className="font-bold text-lg flex items-center mb-2">
|
|
106
121
|
<PackageXIcon className="w-5 h-5 inline-block mr-2" />
|
|
107
122
|
Missing packages
|
|
123
|
+
<SourceBadge source={packageAlert.source} />
|
|
108
124
|
</span>
|
|
109
125
|
<Button
|
|
110
126
|
variant="text"
|
|
@@ -217,6 +233,7 @@ export const PackageAlert: React.FC = () => {
|
|
|
217
233
|
<span className="font-bold text-lg flex items-center mb-2">
|
|
218
234
|
{titleIcon}
|
|
219
235
|
{title}
|
|
236
|
+
<SourceBadge source={packageAlert.source} />
|
|
220
237
|
</span>
|
|
221
238
|
<Button
|
|
222
239
|
variant="text"
|
package/src/core/alerts/state.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface CodeMirrorSetupOpts {
|
|
|
73
73
|
cellId: CellId;
|
|
74
74
|
showPlaceholder: boolean;
|
|
75
75
|
enableAI: boolean;
|
|
76
|
+
acceptCompletionOnEnter?: boolean;
|
|
76
77
|
cellActions: CodemirrorCellActions;
|
|
77
78
|
completionConfig: CompletionConfig;
|
|
78
79
|
keymapConfig: KeymapConfig;
|
|
@@ -175,6 +176,7 @@ export const basicBundle = (opts: CodeMirrorSetupOpts): Extension[] => {
|
|
|
175
176
|
theme,
|
|
176
177
|
hotkeys,
|
|
177
178
|
completionConfig,
|
|
179
|
+
acceptCompletionOnEnter,
|
|
178
180
|
cellId,
|
|
179
181
|
lspConfig,
|
|
180
182
|
diagnosticsConfig,
|
|
@@ -207,7 +209,7 @@ export const basicBundle = (opts: CodeMirrorSetupOpts): Extension[] => {
|
|
|
207
209
|
foldGutter(),
|
|
208
210
|
stringsAutoCloseBraces(),
|
|
209
211
|
closeBrackets(),
|
|
210
|
-
completionKeymap(),
|
|
212
|
+
completionKeymap(acceptCompletionOnEnter),
|
|
211
213
|
// to avoid clash with charDeleteBackward keymap
|
|
212
214
|
Prec.high(keymap.of(closeBracketsKeymap)),
|
|
213
215
|
bracketMatching(),
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { completionKeymap as defaultCompletionKeymap } from "@codemirror/autocomplete";
|
|
4
|
+
import { EditorState } from "@codemirror/state";
|
|
5
|
+
import { keymap } from "@codemirror/view";
|
|
4
6
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { filterCompletionBindings } from "../keymap";
|
|
7
|
+
import { completionKeymap, filterCompletionBindings } from "../keymap";
|
|
8
|
+
|
|
9
|
+
function hasEnterBinding(acceptOnEnter: boolean): boolean {
|
|
10
|
+
const state = EditorState.create({
|
|
11
|
+
extensions: [completionKeymap(acceptOnEnter)],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return state
|
|
15
|
+
.facet(keymap)
|
|
16
|
+
.flat()
|
|
17
|
+
.some((binding) => binding.key === "Enter");
|
|
18
|
+
}
|
|
6
19
|
|
|
7
20
|
describe("completionKeymap", () => {
|
|
8
21
|
it("upstream includes the macOS-only completion bindings we care about", () => {
|
|
@@ -21,4 +34,30 @@ describe("completionKeymap", () => {
|
|
|
21
34
|
expect(filtered.some((binding) => binding.key === "Escape")).toBe(false);
|
|
22
35
|
expect(filtered.some((binding) => binding.mac === "Alt-i")).toBe(true);
|
|
23
36
|
});
|
|
37
|
+
|
|
38
|
+
it("includes Enter by default", () => {
|
|
39
|
+
const filtered = filterCompletionBindings(defaultCompletionKeymap);
|
|
40
|
+
expect(filtered.some((binding) => binding.key === "Enter")).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("removes Enter when passed a keysToRemove set containing Enter", () => {
|
|
44
|
+
const keysToRemove = new Set<string | undefined>([
|
|
45
|
+
"Escape",
|
|
46
|
+
"Alt-`",
|
|
47
|
+
"Enter",
|
|
48
|
+
]);
|
|
49
|
+
const filtered = filterCompletionBindings(
|
|
50
|
+
defaultCompletionKeymap,
|
|
51
|
+
keysToRemove,
|
|
52
|
+
);
|
|
53
|
+
expect(filtered.some((binding) => binding.key === "Enter")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("completionKeymap includes Enter when acceptOnEnter is true", () => {
|
|
57
|
+
expect(hasEnterBinding(true)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("completionKeymap removes Enter when acceptOnEnter is false", () => {
|
|
61
|
+
expect(hasEnterBinding(false)).toBe(false);
|
|
62
|
+
});
|
|
24
63
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { atomWithStorage } from "jotai/utils";
|
|
3
|
+
import { jotaiJsonStorage } from "@/utils/storage/jotai";
|
|
4
|
+
// Default: true (Enter accepts suggestion, matching VS Code default)
|
|
5
|
+
export const acceptCompletionOnEnterAtom = atomWithStorage<boolean>(
|
|
6
|
+
"marimo:accept-completion-on-enter",
|
|
7
|
+
true,
|
|
8
|
+
jotaiJsonStorage,
|
|
9
|
+
{ getOnInit: true },
|
|
10
|
+
);
|
|
@@ -22,20 +22,27 @@ const KEYS_TO_REMOVE = new Set<string | undefined>([
|
|
|
22
22
|
"Alt-`",
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
-
function hasRemovedKeybinding(binding: KeyBinding): boolean {
|
|
26
|
-
return [binding.key, binding.mac, binding.linux, binding.win].some((key) =>
|
|
27
|
-
KEYS_TO_REMOVE.has(key),
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
25
|
export function filterCompletionBindings(
|
|
32
26
|
bindings: readonly KeyBinding[],
|
|
27
|
+
keysToRemove: Set<string | undefined> = KEYS_TO_REMOVE,
|
|
33
28
|
): readonly KeyBinding[] {
|
|
34
|
-
return bindings.filter(
|
|
29
|
+
return bindings.filter(
|
|
30
|
+
(binding) =>
|
|
31
|
+
![binding.key, binding.mac, binding.linux, binding.win].some((key) =>
|
|
32
|
+
keysToRemove.has(key),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function completionKeymap(): Extension {
|
|
38
|
-
const
|
|
37
|
+
export function completionKeymap(acceptOnEnter = true): Extension {
|
|
38
|
+
const keysToRemove = new Set(KEYS_TO_REMOVE);
|
|
39
|
+
if (!acceptOnEnter) {
|
|
40
|
+
keysToRemove.add("Enter");
|
|
41
|
+
}
|
|
42
|
+
const withoutKeysToRemove = filterCompletionBindings(
|
|
43
|
+
defaultCompletionKeymap,
|
|
44
|
+
keysToRemove,
|
|
45
|
+
);
|
|
39
46
|
|
|
40
47
|
return Prec.highest(
|
|
41
48
|
keymap.of([
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
extractPoints,
|
|
20
20
|
extractSunburstPoints,
|
|
21
21
|
extractTreemapPoints,
|
|
22
|
+
hasAreaTrace,
|
|
22
23
|
hasPureLineTrace,
|
|
23
24
|
lineSelectionButtons,
|
|
24
25
|
type ModeBarButton,
|
|
@@ -113,7 +114,8 @@ export const PlotlyComponent = memo(
|
|
|
113
114
|
|
|
114
115
|
const configMemo = useDeepCompareMemoize(config);
|
|
115
116
|
const plotlyConfig = useMemo((): Partial<Plotly.Config> => {
|
|
116
|
-
const
|
|
117
|
+
const hasLineOrAreaTrace =
|
|
118
|
+
hasPureLineTrace(figure.data) || hasAreaTrace(figure.data);
|
|
117
119
|
const defaultButtons: ModeBarButton[] = [
|
|
118
120
|
// Custom button to reset the state
|
|
119
121
|
{
|
|
@@ -130,7 +132,7 @@ export const PlotlyComponent = memo(
|
|
|
130
132
|
click: handleResetWithClear,
|
|
131
133
|
},
|
|
132
134
|
];
|
|
133
|
-
if (
|
|
135
|
+
if (hasLineOrAreaTrace) {
|
|
134
136
|
defaultButtons.push(...lineSelectionButtons(handleSetDragmode));
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -111,4 +111,54 @@ describe("PlotlyPlugin", () => {
|
|
|
111
111
|
range: undefined,
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
it("clicking a violin element triggers onClick", async () => {
|
|
116
|
+
const setValue = vi.fn<Setter<unknown>>();
|
|
117
|
+
|
|
118
|
+
render(
|
|
119
|
+
<Suspense fallback={null}>
|
|
120
|
+
<PlotlyComponent
|
|
121
|
+
figure={{
|
|
122
|
+
data: [{ type: "violin" }],
|
|
123
|
+
layout: {},
|
|
124
|
+
frames: null,
|
|
125
|
+
}}
|
|
126
|
+
value={undefined}
|
|
127
|
+
setValue={setValue}
|
|
128
|
+
host={document.createElement("div")}
|
|
129
|
+
config={{}}
|
|
130
|
+
/>
|
|
131
|
+
</Suspense>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(capturedPlotProps).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
capturedPlotProps?.onClick?.({
|
|
140
|
+
points: [
|
|
141
|
+
{
|
|
142
|
+
data: { type: "violin" },
|
|
143
|
+
x: "Group A",
|
|
144
|
+
y: 3,
|
|
145
|
+
pointIndex: 0,
|
|
146
|
+
pointNumber: 0,
|
|
147
|
+
curveNumber: 0,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(setValue).toHaveBeenCalledTimes(1);
|
|
154
|
+
const updater = setValue.mock.calls[0][0] as (value: unknown) => unknown;
|
|
155
|
+
expect(updater({})).toEqual({
|
|
156
|
+
selections: [],
|
|
157
|
+
points: [
|
|
158
|
+
{ x: "Group A", y: 3, curveNumber: 0, pointNumber: 0, pointIndex: 0 },
|
|
159
|
+
],
|
|
160
|
+
indices: [0],
|
|
161
|
+
range: undefined,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
114
164
|
});
|
|
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import {
|
|
6
6
|
extractIndices,
|
|
7
7
|
extractPoints,
|
|
8
|
+
hasAreaTrace,
|
|
8
9
|
hasPureLineTrace,
|
|
9
10
|
lineSelectionButtons,
|
|
10
11
|
type ModeBarButton,
|
|
@@ -101,6 +102,14 @@ describe("shouldHandleClickSelection", () => {
|
|
|
101
102
|
expect(shouldHandleClickSelection([heatmapPoint])).toBe(true);
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
it("accepts violin clicks", () => {
|
|
106
|
+
const violinPoint = createPlotDatum({
|
|
107
|
+
data: { type: "violin" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(shouldHandleClickSelection([violinPoint])).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
104
113
|
it("accepts histogram clicks", () => {
|
|
105
114
|
const histogramPoint = createPlotDatum({
|
|
106
115
|
data: { type: "histogram" },
|
|
@@ -219,3 +228,67 @@ describe("extractPoints", () => {
|
|
|
219
228
|
]);
|
|
220
229
|
});
|
|
221
230
|
});
|
|
231
|
+
|
|
232
|
+
describe("hasAreaTrace", () => {
|
|
233
|
+
it("detects scatter trace with tozeroy fill", () => {
|
|
234
|
+
expect(
|
|
235
|
+
hasAreaTrace([createTrace({ type: "scatter", fill: "tozeroy" })]),
|
|
236
|
+
).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("detects scatter trace with tonexty fill", () => {
|
|
240
|
+
expect(
|
|
241
|
+
hasAreaTrace([createTrace({ type: "scatter", fill: "tonexty" })]),
|
|
242
|
+
).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("detects scatter trace with stackgroup (px.area pattern)", () => {
|
|
246
|
+
expect(
|
|
247
|
+
hasAreaTrace([
|
|
248
|
+
createTrace({ type: "scatter", mode: "lines", stackgroup: "one" }),
|
|
249
|
+
]),
|
|
250
|
+
).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("detects area traces with mode=none (fill-only, no visible line)", () => {
|
|
254
|
+
expect(
|
|
255
|
+
hasAreaTrace([
|
|
256
|
+
createTrace({ type: "scatter", fill: "tozeroy", mode: "none" }),
|
|
257
|
+
]),
|
|
258
|
+
).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("ignores scatter traces with no fill and no stackgroup", () => {
|
|
262
|
+
expect(
|
|
263
|
+
hasAreaTrace([
|
|
264
|
+
createTrace({ type: "scatter", mode: "lines" }),
|
|
265
|
+
createTrace({ type: "scatter", mode: "markers" }),
|
|
266
|
+
]),
|
|
267
|
+
).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("ignores scatter traces with fill=none", () => {
|
|
271
|
+
expect(hasAreaTrace([createTrace({ type: "scatter", fill: "none" })])).toBe(
|
|
272
|
+
false,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("ignores scatter traces with fill=empty string", () => {
|
|
277
|
+
expect(
|
|
278
|
+
hasAreaTrace([createTrace({ type: "scatter", fill: "" as "none" })]),
|
|
279
|
+
).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("ignores non-scatter traces", () => {
|
|
283
|
+
expect(
|
|
284
|
+
hasAreaTrace([
|
|
285
|
+
createTrace({ type: "bar" }),
|
|
286
|
+
createTrace({ type: "heatmap" }),
|
|
287
|
+
]),
|
|
288
|
+
).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("returns false for undefined data", () => {
|
|
292
|
+
expect(hasAreaTrace(undefined)).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -8,11 +8,15 @@ import {
|
|
|
8
8
|
computeLayoutUpdate,
|
|
9
9
|
computeOmitKeys,
|
|
10
10
|
createInitialLayout,
|
|
11
|
+
hasCompatibleTraces,
|
|
11
12
|
} from "../usePlotlyLayout";
|
|
12
13
|
|
|
13
|
-
function createFigure(
|
|
14
|
+
function createFigure(
|
|
15
|
+
layoutOverrides: Partial<Plotly.Layout> = {},
|
|
16
|
+
data: Plotly.Data[] = [],
|
|
17
|
+
): Figure {
|
|
14
18
|
return {
|
|
15
|
-
data
|
|
19
|
+
data,
|
|
16
20
|
layout: { ...layoutOverrides } as Plotly.Layout,
|
|
17
21
|
frames: null,
|
|
18
22
|
};
|
|
@@ -35,9 +39,46 @@ describe("createInitialLayout", () => {
|
|
|
35
39
|
});
|
|
36
40
|
});
|
|
37
41
|
|
|
42
|
+
describe("hasCompatibleTraces", () => {
|
|
43
|
+
it("returns true for same trace types", () => {
|
|
44
|
+
const a = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
|
|
45
|
+
const b = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
|
|
46
|
+
expect(hasCompatibleTraces(a, b)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns true for default scatter types (undefined type)", () => {
|
|
50
|
+
const a = createFigure({}, [{} as Plotly.Data]);
|
|
51
|
+
const b = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
|
|
52
|
+
expect(hasCompatibleTraces(a, b)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns false for different trace types", () => {
|
|
56
|
+
const a = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
|
|
57
|
+
const b = createFigure({}, [{ type: "histogram" } as Plotly.Data]);
|
|
58
|
+
expect(hasCompatibleTraces(a, b)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false for different number of traces", () => {
|
|
62
|
+
const a = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
|
|
63
|
+
const b = createFigure({}, [
|
|
64
|
+
{ type: "scatter" } as Plotly.Data,
|
|
65
|
+
{ type: "scatter" } as Plotly.Data,
|
|
66
|
+
]);
|
|
67
|
+
expect(hasCompatibleTraces(a, b)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns true for empty data arrays", () => {
|
|
71
|
+
const a = createFigure({}, []);
|
|
72
|
+
const b = createFigure({}, []);
|
|
73
|
+
expect(hasCompatibleTraces(a, b)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
38
77
|
describe("computeLayoutOnFigureChange", () => {
|
|
39
|
-
it("preserves only dragmode/xaxis/yaxis from previous layout (#7964)", () => {
|
|
40
|
-
const
|
|
78
|
+
it("preserves only dragmode/xaxis/yaxis from previous layout for compatible traces (#7964)", () => {
|
|
79
|
+
const scatterData = [{ type: "scatter" } as Plotly.Data];
|
|
80
|
+
const prevFigure = createFigure({}, scatterData);
|
|
81
|
+
const nextFigure = createFigure({ title: { text: "New" } }, scatterData);
|
|
41
82
|
const prevLayout: Partial<Plotly.Layout> = {
|
|
42
83
|
dragmode: "zoom",
|
|
43
84
|
xaxis: { range: [0, 10] },
|
|
@@ -46,7 +87,11 @@ describe("computeLayoutOnFigureChange", () => {
|
|
|
46
87
|
annotations: [{ text: "Old", x: 0, y: 0 }],
|
|
47
88
|
};
|
|
48
89
|
|
|
49
|
-
const result = computeLayoutOnFigureChange(
|
|
90
|
+
const result = computeLayoutOnFigureChange(
|
|
91
|
+
nextFigure,
|
|
92
|
+
prevFigure,
|
|
93
|
+
prevLayout,
|
|
94
|
+
);
|
|
50
95
|
|
|
51
96
|
// Preserved from prev
|
|
52
97
|
expect(result.dragmode).toBe("zoom");
|
|
@@ -59,15 +104,68 @@ describe("computeLayoutOnFigureChange", () => {
|
|
|
59
104
|
expect(result.annotations).toBeUndefined();
|
|
60
105
|
});
|
|
61
106
|
|
|
107
|
+
it("resets axis settings when trace types change (#5898)", () => {
|
|
108
|
+
const prevFigure = createFigure({}, [{ type: "histogram" } as Plotly.Data]);
|
|
109
|
+
const nextFigure = createFigure({ title: { text: "Bar" } }, [
|
|
110
|
+
{ type: "bar" } as Plotly.Data,
|
|
111
|
+
]);
|
|
112
|
+
const prevLayout: Partial<Plotly.Layout> = {
|
|
113
|
+
dragmode: "zoom",
|
|
114
|
+
xaxis: { range: [-3, 3] },
|
|
115
|
+
yaxis: { range: [0, 200] },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const result = computeLayoutOnFigureChange(
|
|
119
|
+
nextFigure,
|
|
120
|
+
prevFigure,
|
|
121
|
+
prevLayout,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Dragmode is still preserved
|
|
125
|
+
expect(result.dragmode).toBe("zoom");
|
|
126
|
+
// Axis settings are NOT preserved — they come from the new figure's layout
|
|
127
|
+
expect(result.xaxis).toBeUndefined();
|
|
128
|
+
expect(result.yaxis).toBeUndefined();
|
|
129
|
+
// New figure's layout is applied
|
|
130
|
+
expect(result.title).toEqual({ text: "Bar" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("preserves nextFigure dragmode when prevLayout has no dragmode", () => {
|
|
134
|
+
const prevFigure = createFigure({}, [{ type: "histogram" } as Plotly.Data]);
|
|
135
|
+
const nextFigure = createFigure({ dragmode: "lasso" }, [
|
|
136
|
+
{ type: "bar" } as Plotly.Data,
|
|
137
|
+
]);
|
|
138
|
+
const prevLayout: Partial<Plotly.Layout> = {
|
|
139
|
+
xaxis: { range: [0, 10] },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const result = computeLayoutOnFigureChange(
|
|
143
|
+
nextFigure,
|
|
144
|
+
prevFigure,
|
|
145
|
+
prevLayout,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// nextFigure.layout.dragmode should be preserved via base, not overwritten
|
|
149
|
+
expect(result.dragmode).toBe("lasso");
|
|
150
|
+
// Axis settings are NOT preserved for incompatible traces
|
|
151
|
+
expect(result.xaxis).toBeUndefined();
|
|
152
|
+
expect(result.yaxis).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
62
155
|
it("uses shapes from new figure, not previous layout", () => {
|
|
63
156
|
const nextFigure = createFigure({
|
|
64
157
|
shapes: [{ type: "circle", x0: 0, x1: 1, y0: 0, y1: 1 }],
|
|
65
158
|
});
|
|
159
|
+
const prevFigure = createFigure({});
|
|
66
160
|
const prevLayout: Partial<Plotly.Layout> = {
|
|
67
161
|
shapes: [{ type: "rect", x0: 0, x1: 1, y0: 0, y1: 1 }],
|
|
68
162
|
};
|
|
69
163
|
|
|
70
|
-
const result = computeLayoutOnFigureChange(
|
|
164
|
+
const result = computeLayoutOnFigureChange(
|
|
165
|
+
nextFigure,
|
|
166
|
+
prevFigure,
|
|
167
|
+
prevLayout,
|
|
168
|
+
);
|
|
71
169
|
|
|
72
170
|
expect(result.shapes).toHaveLength(1);
|
|
73
171
|
expect(result.shapes?.[0].type).toBe("circle");
|
|
@@ -141,13 +141,44 @@ export function hasPureLineTrace(
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
return data.some((trace) => {
|
|
144
|
-
const
|
|
144
|
+
const t = trace as Record<string, unknown>;
|
|
145
145
|
const isScatterLike =
|
|
146
|
-
|
|
146
|
+
t.type === undefined || LINE_CLICK_TRACE_TYPES.has(String(t.type));
|
|
147
147
|
if (!isScatterLike) {
|
|
148
148
|
return false;
|
|
149
149
|
}
|
|
150
|
-
return isPureLineMode(
|
|
150
|
+
return isPureLineMode(t.mode);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Return true when any scatter/scattergl trace has a non-empty fill or a
|
|
156
|
+
* stackgroup, i.e. it is an area chart.
|
|
157
|
+
*
|
|
158
|
+
* Area traces built with `mode="none"` have no visible line or markers, so
|
|
159
|
+
* `hasPureLineTrace` returns false for them even though they need select/lasso
|
|
160
|
+
* buttons just as much as `mode="lines"` area charts. This function covers
|
|
161
|
+
* that gap and is OR-ed with `hasPureLineTrace` in the config builder.
|
|
162
|
+
*/
|
|
163
|
+
export function hasAreaTrace(
|
|
164
|
+
data: readonly Plotly.Data[] | undefined,
|
|
165
|
+
): boolean {
|
|
166
|
+
if (!data) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return data.some((trace) => {
|
|
171
|
+
const t = trace as Record<string, unknown>;
|
|
172
|
+
// Only scatter/scattergl can be area traces.
|
|
173
|
+
if (t.type !== undefined && !LINE_CLICK_TRACE_TYPES.has(String(t.type))) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
// A trace is an area trace when fill is a non-empty string other than
|
|
177
|
+
// "none", OR it belongs to a stackgroup (px.area always sets stackgroup).
|
|
178
|
+
return (
|
|
179
|
+
(typeof t.fill === "string" && t.fill !== "" && t.fill !== "none") ||
|
|
180
|
+
t.stackgroup != null
|
|
181
|
+
);
|
|
151
182
|
});
|
|
152
183
|
}
|
|
153
184
|
|
|
@@ -228,6 +259,7 @@ export function shouldHandleClickSelection(
|
|
|
228
259
|
type === "heatmap" ||
|
|
229
260
|
type === "histogram" ||
|
|
230
261
|
type === "waterfall" ||
|
|
262
|
+
type === "violin" ||
|
|
231
263
|
isLinePoint(point)
|
|
232
264
|
);
|
|
233
265
|
});
|