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