Skip to content

Commit 4190609

Browse files
authored
Plotly support (#1226)
1 parent 2bff51e commit 4190609

File tree

9 files changed

+2400
-81
lines changed

9 files changed

+2400
-81
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## Unreleased
8+
9+
### Added
10+
11+
- `plotly` support (#1226)
12+
713
## [0.32.0] - 2025-10-13
814

915
### Added

cypress/e2e/spec-wc-pyodide.cy.js

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe("Running the code with pyodide", () => {
5656

5757
it("interrupts the code when the stop button is clicked", () => {
5858
runCode(
59-
"from time import sleep\nfor i in range(100):\n\tprint(i)\n\tsleep(1)"
59+
"from time import sleep\nfor i in range(100):\n\tprint(i)\n\tsleep(1)",
6060
);
6161
cy.get("editor-wc")
6262
.shadow()
@@ -115,7 +115,7 @@ describe("Running the code with pyodide", () => {
115115
.find(".error-message__content")
116116
.should(
117117
"contain",
118-
"FileExistsError: File 'output.txt' already exists on line 1 of main.py"
118+
"FileExistsError: File 'output.txt' already exists on line 1 of main.py",
119119
);
120120
});
121121

@@ -126,7 +126,7 @@ describe("Running the code with pyodide", () => {
126126
.find("div[class=cm-content]")
127127
.invoke(
128128
"text",
129-
'with open("output.txt", "a") as f:\n\tf.write("Hello again world")'
129+
'with open("output.txt", "a") as f:\n\tf.write("Hello again world")',
130130
);
131131
cy.get("editor-wc")
132132
.shadow()
@@ -153,7 +153,7 @@ describe("Running the code with pyodide", () => {
153153

154154
it("runs a simple program with a built-in pyodide module", () => {
155155
runCode(
156-
"import simplejson as json\nprint(json.dumps(['foo', {'bar': ('baz', None, 1.0, 2)}]))"
156+
"import simplejson as json\nprint(json.dumps(['foo', {'bar': ('baz', None, 1.0, 2)}]))",
157157
);
158158
cy.get("editor-wc")
159159
.shadow()
@@ -163,7 +163,7 @@ describe("Running the code with pyodide", () => {
163163

164164
it("runs a simple pygal program", () => {
165165
runCode(
166-
"import pygal\nbar_chart = pygal.Bar()\nbar_chart.add('Fibonacci', [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55])\nbar_chart.render()"
166+
"import pygal\nbar_chart = pygal.Bar()\nbar_chart.add('Fibonacci', [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55])\nbar_chart.render()",
167167
);
168168
cy.get("editor-wc")
169169
.shadow()
@@ -173,7 +173,7 @@ describe("Running the code with pyodide", () => {
173173

174174
it("runs a simple matplotlib program", () => {
175175
runCode(
176-
"import matplotlib.pyplot as plt\nx = [1,2,3]\ny = [2,4,1]\nplt.plot(x, y)\nplt.title('My first graph!')\nplt.show()"
176+
"import matplotlib.pyplot as plt\nx = [1,2,3]\ny = [2,4,1]\nplt.plot(x, y)\nplt.title('My first graph!')\nplt.show()",
177177
);
178178
cy.wait(5000);
179179
cy.get("editor-wc")
@@ -193,12 +193,24 @@ describe("Running the code with pyodide", () => {
193193
.should("be.visible");
194194
});
195195

196+
it("runs a simple plotly program", () => {
197+
runCode(
198+
'import plotly.express as px\ndf = px.data.gapminder().query("country==\'Canada\'")\nfig = px.line(df, x="year", y="lifeExp", title=\'Life expectancy in Canada\')\nfig.show()',
199+
);
200+
cy.wait(3000);
201+
cy.get("editor-wc")
202+
.shadow()
203+
.find(".pyodiderunner")
204+
.find("div.js-plotly-plot")
205+
.should("be.visible");
206+
});
207+
196208
it("runs a simple urllib program", () => {
197209
cy.intercept("GET", "https://www.my-amazing-website.com", {
198210
statusCode: 200,
199211
});
200212
runCode(
201-
"import urllib.request\nresponse = urllib.request.urlopen('https://www.my-amazing-website.com')\nprint(response.getcode())"
213+
"import urllib.request\nresponse = urllib.request.urlopen('https://www.my-amazing-website.com')\nprint(response.getcode())",
202214
);
203215
cy.get("editor-wc")
204216
.shadow()
@@ -208,7 +220,7 @@ describe("Running the code with pyodide", () => {
208220

209221
it("runs a simple program with a module from PyPI", () => {
210222
runCode(
211-
"from strsimpy.levenshtein import Levenshtein\nlevenshtein = Levenshtein()\nprint(levenshtein.distance('hello', 'world'))"
223+
"from strsimpy.levenshtein import Levenshtein\nlevenshtein = Levenshtein()\nprint(levenshtein.distance('hello', 'world'))",
212224
);
213225
cy.get("editor-wc")
214226
.shadow()
@@ -236,7 +248,7 @@ text_in = "This is a test message"
236248
rotor_start = "FNZ"
237249
text_out = use_enigma_machine(text_in, rotor_start)
238250
print(text_out)
239-
`
251+
`,
240252
);
241253
cy.get("editor-wc")
242254
.shadow()
@@ -251,7 +263,7 @@ print(text_out)
251263
.find(".error-message__content")
252264
.should(
253265
"contain",
254-
"ModuleNotFoundError: No module named 'i_do_not_exist' on line 1 of main.py"
266+
"ModuleNotFoundError: No module named 'i_do_not_exist' on line 1 of main.py",
255267
);
256268
});
257269

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@sentry/tracing": "7.16.0",
2727
"@szhsin/react-menu": "^3.2.0",
2828
"apollo-link-sentry": "^3.2.3",
29+
"assert": "^2.1.0",
2930
"axios": "^0.24.0",
3031
"classnames": "^2.3.2",
3132
"codemirror": "^6.0.1",
@@ -51,6 +52,7 @@
5152
"node-html-parser": "^6.1.5",
5253
"oidc-client": "^1.11.5",
5354
"parse-link-header": "^2.0.0",
55+
"plotly.js": "^3.0.2",
5456
"prismjs": "^1.29.0",
5557
"prompts": "2.4.0",
5658
"prop-types": "^15.8.1",
@@ -76,6 +78,7 @@
7678
"react-toggle": "^4.1.3",
7779
"redux-oidc": "^4.0.0-beta1",
7880
"skulpt": "^1.2.0",
81+
"stream-browserify": "^3.0.0",
7982
"three": "0.169.0",
8083
"ts-pnp": "1.2.0",
8184
"url": "^0.11.4",

src/PyodideWorker.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,32 @@ const PyodideWorker = () => {
367367
`);
368368
},
369369
},
370+
plotly: {
371+
before: async () => {
372+
if (!pyodide.micropip) {
373+
await pyodide.loadPackage("micropip");
374+
pyodide.micropip = pyodide.pyimport("micropip");
375+
}
376+
377+
// If the import is for a PyPi package then load it.
378+
// Otherwise, don't error now so that we get an error later from Python.
379+
await pyodide.micropip.install("plotly").catch(() => {});
380+
await pyodide.micropip.install("pandas").catch(() => {});
381+
pyodide.registerJsModule("basthon", fakeBasthonPackage);
382+
pyodide.runPython(`
383+
import plotly.graph_objs as go
384+
385+
def _hacked_show(self, *args, **kwargs):
386+
basthon.kernel.display_event({
387+
"display_type": "plotly",
388+
"content": self.to_json()
389+
})
390+
391+
go.Figure.show = _hacked_show
392+
`);
393+
},
394+
after: () => {},
395+
},
370396
};
371397

372398
const fakeBasthonPackage = {

src/assets/stylesheets/ExternalStyles.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
@use "../../../node_modules/prismjs/plugins/line-highlight/prism-line-highlight.css";
55
@use "../../../node_modules/@raspberrypifoundation/design-system-core/scss/components/alert.scss";
66
@use "../../../node_modules/material-symbols/sharp.scss";
7+
@use "../../../node_modules/plotly.js/src/css/style.scss" as plotlyStyle;

src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef } from "react";
22
import { useSelector } from "react-redux";
33
import AstroPiModel from "../../../../AstroPiModel/AstroPiModel";
44
import Highcharts from "highcharts";
5+
import Plotly from "plotly.js";
56

67
const VisualOutputPane = ({ visuals, setVisuals }) => {
78
const senseHatEnabled = useSelector((s) => s.editor.senseHatEnabled);
@@ -49,6 +50,13 @@ const VisualOutputPane = ({ visuals, setVisuals }) => {
4950
)}`;
5051
output.current.innerHTML = img.outerHTML;
5152
break;
53+
case "plotly":
54+
const plotlyJson = visual.content;
55+
// Parse the JSON
56+
const figure = JSON.parse(plotlyJson);
57+
// Render using Plotly.js
58+
Plotly.newPlot(output.current, figure.data, figure.layout);
59+
break;
5260
default:
5361
throw new Error(`Unsupported origin: ${visual.origin}`);
5462
}

src/components/Editor/Runners/PythonRunner/PyodideRunner/VisualOutputPane.test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,31 @@ describe("When there is a matplotlib output", () => {
119119
});
120120
});
121121

122+
describe("when there is plotly output", () => {
123+
beforeEach(() => {
124+
const visuals = [
125+
{
126+
origin: "plotly",
127+
content: JSON.stringify({
128+
data: [{ x: [1, 2, 3], y: [4, 5, 6], type: "scatter" }],
129+
layout: { title: { text: "Test Plot" } },
130+
}),
131+
},
132+
];
133+
renderPaneWithVisuals(visuals);
134+
});
135+
136+
test("it renders without crashing", () => {
137+
expect(document.getElementsByClassName("pythonrunner-graphic").length).toBe(
138+
1,
139+
);
140+
});
141+
142+
test("it renders the plotly chart as an svg", () => {
143+
expect(screen.getByText("Test Plot")).toBeInTheDocument();
144+
});
145+
});
146+
122147
describe("When there is an unsupported origin", () => {
123148
test("it throws an error", () => {
124149
const visuals = [

webpack.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ module.exports = {
8484
resolve: {
8585
extensions: [".*", ".js", ".jsx", ".css"],
8686
fallback: {
87+
stream: require.resolve("stream-browserify"),
88+
assert: require.resolve("assert"),
8789
path: require.resolve("path-browserify"),
8890
url: require.resolve("url/"),
8991
},

0 commit comments

Comments
 (0)