diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 84f7aeb6d..e683fa99d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,6 +16,7 @@
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"pinia": "^2.2.2",
+ "plotly.js-dist-min": "^2.35.2",
"simplebar-vue": "^2.3.5",
"vega-embed": "^6.26.0",
"vue": "^3.4.38",
@@ -30,6 +31,8 @@
"@types/markdown-it-emoji": "^3.0.1",
"@types/markdown-it-highlightjs": "^3.3.4",
"@types/node": "^22.5.2",
+ "@types/plotly.js": "^2.33.4",
+ "@types/plotly.js-dist-min": "^2.3.4",
"@vitejs/plugin-vue": "^5.1.3",
"@vitest/coverage-v8": "^2.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
@@ -1403,6 +1406,19 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
+ "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -1879,6 +1895,23 @@
"undici-types": "~6.19.2"
}
},
+ "node_modules/@types/plotly.js": {
+ "version": "2.33.4",
+ "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.33.4.tgz",
+ "integrity": "sha512-BzAbsJTiUQyALkkYx1D31YZ9YvcU2ag3LlE/iePMo19eDPvM30cbM2EFNIcu31n39EhXj/9G7800XLA8/rfApA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/plotly.js-dist-min": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@types/plotly.js-dist-min/-/plotly.js-dist-min-2.3.4.tgz",
+ "integrity": "sha512-ISwLFV6Zs/v3DkaRFLyk2rvYAfVdnYP2VVVy7h+fBDWw52sn7sMUzytkWiN4M75uxr1uz1uiBioePTDpAfoFIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/plotly.js": "*"
+ }
+ },
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@@ -2802,6 +2835,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -5922,6 +5964,12 @@
}
}
},
+ "node_modules/plotly.js-dist-min": {
+ "version": "2.35.2",
+ "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.35.2.tgz",
+ "integrity": "sha512-oWDTf2kYOmTtEw3epeeSBdfH/H3OSktF0suST9oI6fIgKfbyd4MT7TPh8+CVzdHYllYon24Q0HI1hZjOnLqk6g==",
+ "license": "MIT"
+ },
"node_modules/postcss": {
"version": "8.4.44",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.44.tgz",
@@ -6515,6 +6563,18 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
@@ -6523,6 +6583,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
@@ -7087,6 +7160,36 @@
"node": ">=8"
}
},
+ "node_modules/terser": {
+ "version": "5.33.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.33.0.tgz",
+ "integrity": "sha512-JuPVaB7s1gdFKPKTelwUyRq5Sid2A3Gko2S0PncwdBq7kN9Ti9HPWDQ06MPsEDGsZeVESjKEnyGy68quBk1w6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.8.2",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 46819ea4a..aefe63efd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -25,6 +25,7 @@
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"pinia": "^2.2.2",
+ "plotly.js-dist-min": "^2.35.2",
"simplebar-vue": "^2.3.5",
"vega-embed": "^6.26.0",
"vue": "^3.4.38",
@@ -39,6 +40,8 @@
"@types/markdown-it-emoji": "^3.0.1",
"@types/markdown-it-highlightjs": "^3.3.4",
"@types/node": "^22.5.2",
+ "@types/plotly.js": "^2.33.4",
+ "@types/plotly.js-dist-min": "^2.3.4",
"@vitejs/plugin-vue": "^5.1.3",
"@vitest/coverage-v8": "^2.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
diff --git a/frontend/src/assets/styles/_reset.css b/frontend/src/assets/styles/_reset.css
index 16d7c4fb4..cf357c1aa 100644
--- a/frontend/src/assets/styles/_reset.css
+++ b/frontend/src/assets/styles/_reset.css
@@ -20,8 +20,7 @@ body {
img,
picture,
video,
-canvas,
-svg {
+canvas {
display: block;
max-width: 100%;
}
diff --git a/frontend/src/components/PlotlyWidget.vue b/frontend/src/components/PlotlyWidget.vue
new file mode 100644
index 000000000..9a17cebb0
--- /dev/null
+++ b/frontend/src/components/PlotlyWidget.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ReportCanvas.vue b/frontend/src/components/ReportCanvas.vue
index 3b720d2c3..a106bd823 100644
--- a/frontend/src/components/ReportCanvas.vue
+++ b/frontend/src/components/ReportCanvas.vue
@@ -6,6 +6,7 @@ import DataFrameWidget from "@/components/DataFrameWidget.vue";
import HtmlSnippetWidget from "@/components/HtmlSnippetWidget.vue";
import ImageWidget from "@/components/ImageWidget.vue";
import MarkdownWidget from "@/components/MarkdownWidget.vue";
+import PlotlyWidget from "@/components/PlotlyWidget.vue";
import ReportCard from "@/components/ReportCard.vue";
import VegaWidget from "@/components/VegaWidget.vue";
import type { KeyLayoutSize, KeyMoveDirection } from "@/models";
@@ -36,7 +37,7 @@ const visibleItems = computed(() => {
data = item.value;
} else {
data = atob(item.value);
- if (mediaType === "application/vnd.vega.v5+json") {
+ if (mediaType.includes("json")) {
data = JSON.parse(data);
}
}
@@ -112,6 +113,7 @@ function getItemSubtitle(created_at: Date, updated_at: Date) {
/>
+
boolean) {
if (reportStore.items === null) {
return [];
diff --git a/frontend/tests/views/ReportBuilderView.spec.ts b/frontend/tests/views/ReportBuilderView.spec.ts
index ff4eda9da..2205fa1d2 100644
--- a/frontend/tests/views/ReportBuilderView.spec.ts
+++ b/frontend/tests/views/ReportBuilderView.spec.ts
@@ -15,6 +15,13 @@ vi.mock("@/services/api", () => {
return { fetchReport };
});
+vi.hoisted(() => {
+ // required because plotly depends on URL.createObjectURL
+ const mockObjectURL = vi.fn();
+ window.URL.createObjectURL = mockObjectURL;
+ window.URL.revokeObjectURL = mockObjectURL;
+});
+
describe("ReportBuilderView", () => {
beforeEach(() => {
vi.mock("vue-router");
diff --git a/pyproject.toml b/pyproject.toml
index b047853f5..fa5f7d176 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ test = [
"httpx",
"matplotlib",
"pandas",
+ "plotly",
"pre-commit",
"pytest",
"pytest-cov",
diff --git a/requirements-test.txt b/requirements-test.txt
index 35b6fb9d2..f3dfd5be9 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.11
+# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --extra=test --output-file=requirements-test.txt pyproject.toml
@@ -100,6 +100,7 @@ packaging==24.1
# altair
# huggingface-hub
# matplotlib
+ # plotly
# pytest
# skops
pandas==2.2.2
@@ -108,6 +109,8 @@ pillow==10.4.0
# via matplotlib
platformdirs==4.3.6
# via virtualenv
+plotly==5.24.1
+ # via skore (pyproject.toml)
pluggy==1.5.0
# via pytest
pre-commit==3.8.0
@@ -174,6 +177,8 @@ starlette==0.38.5
# via fastapi
tabulate==0.9.0
# via skops
+tenacity==9.0.0
+ # via plotly
threadpoolctl==3.5.0
# via scikit-learn
tqdm==4.66.5
diff --git a/requirements-tools.txt b/requirements-tools.txt
index c09a5cad3..a02f720ad 100644
--- a/requirements-tools.txt
+++ b/requirements-tools.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.11
+# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --extra=tools --output-file=requirements-tools.txt pyproject.toml
diff --git a/requirements.txt b/requirements.txt
index 23991ca8b..39ffc0f96 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.11
+# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --output-file=requirements.txt pyproject.toml
diff --git a/src/skore/item/media_item.py b/src/skore/item/media_item.py
index 90edefd7a..5c35fda66 100644
--- a/src/skore/item/media_item.py
+++ b/src/skore/item/media_item.py
@@ -12,6 +12,7 @@
from altair.vegalite.v5.schema.core import TopLevelSpec as Altair
from matplotlib.figure import Figure as Matplotlib
from PIL.Image import Image as Pillow
+ from plotly.basedatatypes import BaseFigure as Plotly
from skore.item.item import Item
@@ -93,6 +94,8 @@ def factory(cls, media, *args, **kwargs):
return cls.factory_matplotlib(media, *args, **kwargs)
if lazy_is_instance(media, "PIL.Image.Image"):
return cls.factory_pillow(media, *args, **kwargs)
+ if lazy_is_instance(media, "plotly.basedatatypes.BaseFigure"):
+ return cls.factory_plotly(media, *args, **kwargs)
raise TypeError(f"Type '{media.__class__}' is not supported.")
@@ -223,3 +226,28 @@ def factory_pillow(cls, media: Pillow) -> MediaItem:
media_encoding="utf-8",
media_type="image/png",
)
+
+ @classmethod
+ def factory_plotly(cls, media: Plotly) -> MediaItem:
+ """
+ Create a new MediaItem instance from a Plotly figure.
+
+ Parameters
+ ----------
+ media : Plotly
+ The Plotly figure to store.
+
+ Returns
+ -------
+ MediaItem
+ A new MediaItem instance.
+ """
+ import plotly.io
+
+ media_bytes = plotly.io.to_json(media, engine="json").encode("utf-8")
+
+ return cls(
+ media_bytes=media_bytes,
+ media_encoding="utf-8",
+ media_type="application/vnd.plotly.v1+json",
+ )
diff --git a/tests/unit/item/test_media_item.py b/tests/unit/item/test_media_item.py
index 25a876578..c8934e6a6 100644
--- a/tests/unit/item/test_media_item.py
+++ b/tests/unit/item/test_media_item.py
@@ -3,6 +3,7 @@
import altair
import matplotlib.pyplot
import PIL as pillow
+import plotly.graph_objects as go
import pytest
from skore.item import MediaItem
@@ -75,3 +76,15 @@ def test_factory_pillow(self, mock_nowstr):
assert item.media_type == "image/png"
assert item.created_at == mock_nowstr
assert item.updated_at == mock_nowstr
+
+ def test_factory_plotly(self, mock_nowstr):
+ figure = go.Figure(data=[go.Bar(x=[1, 2, 3], y=[1, 3, 2])])
+ figure_bytes = figure.to_json().encode("utf-8")
+
+ item = MediaItem.factory(figure)
+
+ assert item.media_bytes == figure_bytes
+ assert item.media_encoding == "utf-8"
+ assert item.media_type == "application/vnd.plotly.v1+json"
+ assert item.created_at == mock_nowstr
+ assert item.updated_at == mock_nowstr