Skip to content

Commit c5361e3

Browse files
committed
feat: support images in vega/altair tooltips
Added vega-tooltip and adds the `tooltip` prop for all the vega charts can have custom tooltips
1 parent 89cd027 commit c5361e3

File tree

8 files changed

+238
-4
lines changed

8 files changed

+238
-4
lines changed

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"use-resize-observer": "^9.1.0",
171171
"vega-lite": "^5.23.0",
172172
"vega-loader": "^4.5.3",
173+
"vega-tooltip": "^1.1.0",
173174
"vscode-jsonrpc": "^8.2.1",
174175
"vscode-languageserver-protocol": "^3.17.5",
175176
"web-vitals": "^4.2.4",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Handler } from "vega-tooltip";
2+
3+
// Create a tooltip handler that supports HTML content (including images)
4+
export const tooltipHandler = new Handler();

frontend/src/components/data-table/charts/lazy-chart.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import React from "react";
44
import type { TopLevelSpec } from "vega-lite";
5+
import { tooltipHandler } from "@/components/charts/tooltip";
56
import { useTheme } from "@/theme/useTheme";
67
import type { ErrorMessage } from "./chart-spec/spec";
78
import { augmentSpecWithData } from "./chart-spec/spec";
@@ -33,6 +34,7 @@ export const LazyChart: React.FC<{
3334
spec={spec}
3435
theme={theme === "dark" ? "dark" : undefined}
3536
height={height}
37+
tooltip={tooltipHandler.call}
3638
actions={{
3739
export: true,
3840
source: false,

frontend/src/components/editor/Output.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
ChevronsUpDownIcon,
2626
ExpandIcon,
2727
} from "lucide-react";
28+
import { tooltipHandler } from "@/components/charts/tooltip";
2829
import { useExpandedOutput } from "@/core/cells/outputs";
2930
import { useIframeCapabilities } from "@/hooks/useIframeCapabilities";
3031
import { renderHTML } from "@/plugins/core/RenderHTML";
@@ -179,6 +180,7 @@ export const OutputRenderer: React.FC<{
179180
<Suspense fallback={<ChartLoadingState />}>
180181
<LazyVegaLite
181182
spec={parsedJsonData as TopLevelFacetedUnitSpec}
183+
tooltip={tooltipHandler.call}
182184
theme={theme === "dark" ? "dark" : undefined}
183185
/>
184186
</Suspense>

frontend/src/plugins/impl/data-explorer/ConnectedDataExplorerComponent.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ListFilterIcon } from "lucide-react";
66
import React, { type JSX, useMemo } from "react";
77
import { VegaLite } from "react-vega";
88
import type { VegaLiteProps } from "react-vega/lib/VegaLite";
9+
import { tooltipHandler } from "@/components/charts/tooltip";
910
import { Badge } from "@/components/ui/badge";
1011
import { Button } from "@/components/ui/button";
1112
import { Tooltip } from "@/components/ui/tooltip";
@@ -135,6 +136,7 @@ export const DataExplorerComponent = ({
135136
padding={PADDING}
136137
actions={ACTIONS}
137138
spec={makeResponsive(mainPlot.spec)}
139+
tooltip={tooltipHandler.call}
138140
theme={theme === "dark" ? "dark" : undefined}
139141
/>
140142
</div>
@@ -210,6 +212,7 @@ export const DataExplorerComponent = ({
210212
key={idx}
211213
actions={false}
212214
spec={plot.spec}
215+
tooltip={tooltipHandler.call}
213216
theme={theme === "dark" ? "dark" : undefined}
214217
/>
215218
</HorizontalCarouselItem>

frontend/src/plugins/impl/vega/vega-component.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import useEvent from "react-use-event-hook";
88
import { type SignalListeners, VegaLite, type View } from "react-vega";
99
// @ts-expect-error vega-typings does not include formats
1010
import { formats } from "vega-loader";
11+
import { tooltipHandler } from "@/components/charts/tooltip";
1112
import { Alert, AlertTitle } from "@/components/ui/alert";
1213
import { Tooltip } from "@/components/ui/tooltip";
1314
import { useAsyncData } from "@/hooks/useAsyncData";
@@ -26,7 +27,6 @@ import type { VegaLiteSpec } from "./types";
2627

2728
// register arrow reader under type 'arrow'
2829
formats("arrow", arrow);
29-
3030
export interface Data {
3131
spec: VegaLiteSpec;
3232
chartSelection: boolean | "point" | "interval";
@@ -234,6 +234,7 @@ const LoadedVegaComponent = ({
234234
signalListeners={signalListeners}
235235
onError={handleError}
236236
onNewView={handleNewView}
237+
tooltip={tooltipHandler.call}
237238
/>
238239
{renderHelpContent()}
239240
</div>
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# /// script
2+
# requires-python = ">=3.11"
3+
# dependencies = [
4+
# "pandas",
5+
# "altair",
6+
# "marimo",
7+
# ]
8+
# ///
9+
# Copyright 2024 Marimo. All rights reserved.
10+
11+
import marimo
12+
13+
__generated_with = "0.17.2"
14+
app = marimo.App(width="medium")
15+
16+
17+
@app.cell
18+
def _():
19+
import marimo as mo
20+
return (mo,)
21+
22+
23+
@app.cell(hide_code=True)
24+
def _(mo):
25+
mo.md(
26+
"""
27+
# Altair Tooltip Images
28+
29+
This smoke test verifies that images in Altair tooltips render correctly
30+
and that the tooltip handler properly sanitizes potentially malicious content.
31+
32+
**Tests:**
33+
1. External image URLs in tooltips
34+
2. Base64-encoded images in tooltips
35+
3. XSS vulnerability prevention
36+
"""
37+
)
38+
return
39+
40+
41+
@app.cell
42+
def _():
43+
import altair as alt
44+
import pandas as pd
45+
46+
# Create sample data with image URLs
47+
source = pd.DataFrame(
48+
{
49+
"a": [1, 2],
50+
"b": [1, 2],
51+
"image": [
52+
"https://marimo.io/logo.png",
53+
"https://marimo.io/favicon.ico",
54+
],
55+
}
56+
)
57+
58+
# Create chart with image tooltip
59+
chart = (
60+
alt.Chart(source)
61+
.mark_circle(size=200)
62+
.encode(
63+
x=alt.X("a", scale=alt.Scale(domain=[0, 3])),
64+
y=alt.Y("b", scale=alt.Scale(domain=[0, 3])),
65+
tooltip=["image"],
66+
)
67+
.properties(
68+
title="Scatter Plot with Image Tooltips - Hover to see images",
69+
width=400,
70+
height=400,
71+
)
72+
)
73+
74+
chart
75+
return alt, pd
76+
77+
78+
@app.cell(hide_code=True)
79+
def _(mo):
80+
mo.md(
81+
r"""
82+
## Instructions
83+
84+
Hover over the circles to see the image tooltips.
85+
The images should render in the tooltip, not just show URLs as text.
86+
"""
87+
)
88+
return
89+
90+
91+
@app.cell
92+
def _(alt, pd):
93+
# Example with base64 encoded image
94+
base64_image = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMjUiIGN5PSIyNSIgcj0iMjAiIGZpbGw9InJlZCIvPjwvc3ZnPg=="
95+
96+
source2 = pd.DataFrame(
97+
{
98+
"x": [1, 2, 3],
99+
"y": [1, 2, 1],
100+
"name": ["Red Circle", "Blue Square", "Green Triangle"],
101+
"image": [
102+
base64_image,
103+
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgeD0iMTAiIHk9IjEwIiB3aWR0aD0iMzAiIGhlaWdodD0iMzAiIGZpbGw9ImJsdWUiLz48L3N2Zz4=",
104+
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBvbHlnb24gcG9pbnRzPSIyNSw1IDQwLDQwIDEwLDQwIiBmaWxsPSJncmVlbiIvPjwvc3ZnPg==",
105+
],
106+
}
107+
)
108+
109+
chart2 = (
110+
alt.Chart(source2)
111+
.mark_point(size=300, filled=True)
112+
.encode(
113+
x=alt.X("x", scale=alt.Scale(domain=[0, 4])),
114+
y=alt.Y("y", scale=alt.Scale(domain=[0, 3])),
115+
tooltip=["name", "image"],
116+
)
117+
.properties(
118+
title="Chart with Base64 Encoded Image Tooltips",
119+
width=400,
120+
height=300,
121+
)
122+
)
123+
124+
chart2
125+
return
126+
127+
128+
@app.cell(hide_code=True)
129+
def _(mo):
130+
mo.md(
131+
r"""
132+
## XSS Security Tests
133+
134+
These tests verify that the tooltip handler properly sanitizes
135+
potentially malicious content and prevents XSS attacks.
136+
137+
**Expected behavior:** All XSS attempts should be neutralized.
138+
No alert boxes or script execution should occur when hovering.
139+
"""
140+
)
141+
return
142+
143+
144+
@app.cell
145+
def _(alt, pd):
146+
# Test various XSS attack vectors
147+
xss_test_data = pd.DataFrame(
148+
{
149+
"x": [1, 2, 3, 4, 1, 2, 3, 4],
150+
"y": [1, 1, 1, 1, 2, 2, 2, 2],
151+
"label": [
152+
"Script Tag",
153+
"Event Handler",
154+
"JS URL",
155+
"IMG onerror",
156+
"SVG Script",
157+
"HTML Injection",
158+
"OnMouseOver",
159+
"Data URL JS",
160+
],
161+
"image": [
162+
# Script tag injection
163+
'<script>alert("XSS")</script>',
164+
# Event handler
165+
"<img src=x onerror=\"alert('XSS')\">",
166+
# JavaScript URL
167+
"<a href=\"javascript:alert('XSS')\">click</a>",
168+
# IMG with onerror
169+
'<img src="invalid" onerror="alert(\'XSS\')">',
170+
# SVG with embedded script
171+
'<svg><script>alert("XSS")</script></svg>',
172+
# HTML injection
173+
"<div onclick=\"alert('XSS')\">Click me</div>",
174+
# OnMouseOver
175+
"<span onmouseover=\"alert('XSS')\">hover</span>",
176+
# Data URL with JavaScript
177+
"<img src=\"data:text/html,<script>alert('XSS')</script>\">",
178+
],
179+
}
180+
)
181+
182+
xss_chart = (
183+
alt.Chart(xss_test_data)
184+
.mark_circle(size=150)
185+
.encode(
186+
x=alt.X("x:O", title="Test Vector"),
187+
y=alt.Y("y:O", title="Category"),
188+
color=alt.Color(
189+
"label:N",
190+
legend=alt.Legend(title="Attack Type"),
191+
),
192+
tooltip=["label", "image"],
193+
)
194+
.properties(
195+
title="XSS Security Test - Hover to verify sanitization",
196+
width=600,
197+
height=300,
198+
)
199+
)
200+
201+
xss_chart
202+
return
203+
204+
205+
if __name__ == "__main__":
206+
app.run()

pnpm-lock.yaml

Lines changed: 18 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)