- Published on
Embedding Interactive Plotly Charts in MDX
- Authors
- Name
- Bojun Feng
Summary:
Intro
This post contains some quick snippets of code that embeds interactive html in MDX through iframes.
The tutorial section applies specifically for applying Plotly generated html visualizations to blogs powered by the tailwind-nextjs-starter-blog (Snapshot) template. If you are not working with the same template, you may want to look at the details section and modify the more generalized snippets there.
Tutorial
The process is quite similar to the "Creating MDX Tutorial" (Snapshot) provided by the template.
- Create a new
HTMLPlot.tsx
component in thecomponents
folder.
Expand / Collapse Full Code
'use client'
import React, { useState, useEffect } from 'react'
const HTMLPlot = ({
title,
pathname,
height_before_scale = '433.59',
width_before_scale = '768',
}) => {
// Convert string to number, state to store the current plot layout
const height_numeric = +height_before_scale
const width_numeric = +width_before_scale
const [layout, setLayout] = useState({
width: '762px',
height: '571.5px',
scale_change_str: 'scale(0.5291666666666667)',
shift: '0px',
})
let origin = ''
// Update layout on resize
const handleResize = () => {
if (typeof window !== 'undefined') {
origin = window.location.origin
console.log(origin.concat(pathname))
}
if (window.innerWidth >= 1280) {
setLayout({
...layout,
width: `${762 + 48 * 2}px`,
height: `${(762 + 48 * 2) * (height_numeric / width_numeric)}px`,
scale_change_str: `scale(${(762 + 48 * 2) / width_numeric})`,
shift: `${-48}px`,
})
} else if (window.innerWidth >= 768) {
setLayout({
...layout,
width: `${672 + 48 * 2}px`,
height: `${(672 + 48 * 2) * (height_numeric / width_numeric)}px`,
scale_change_str: `scale(${(672 + 48 * 2) / width_numeric})`,
shift: `${-48}px`,
})
} else {
setLayout({
...layout,
width: `${window.innerWidth}px`,
height: `${window.innerWidth * (height_numeric / width_numeric)}px`,
scale_change_str: `scale(${window.innerWidth / width_numeric})`,
shift: `${window.innerWidth >= 640 ? -48 : -32}px`,
})
}
}
// Effect to handle resizing
useEffect(() => {
window.addEventListener('resize', handleResize) // Add resize event listener
handleResize() // Run resize to update plot layout immediately
return () => {
window.removeEventListener('resize', handleResize) // Cleanup the event listener
}
}, []) // Empty dependency array ensures the effect runs only on mount and unmount
return (
<div
style={{
overflow: 'hidden',
position: 'relative',
width: layout.width,
height: layout.height,
marginLeft: layout.shift,
}}
>
<iframe
title={title}
src={origin.concat(pathname)}
style={{
height: `${height_numeric}px`,
width: `${width_numeric}px`,
transform: layout.scale_change_str,
transformOrigin: '0px 0px',
border: '0',
}}
></iframe>
</div>
)
}
export default HTMLPlot
- Add the component to
components/MDXComponents.tsx
.
Expand / Collapse Full Code
...
+ import HTMLPlot from './HTMLPlot'
export const components: MDXComponents = {
Image,
TOCInline,
a: CustomLink,
pre: Pre,
BlogNewsletterForm,
+ HTMLPlot,
}
- Create an interactive chart in Plotly and save as html.
We used a simple modified simple example from Plotly Official Documentation (Snapshot). If you do not have Python or relative packages installed, the code should be directly runnable in a new notebook in Google Colab, where you can download the output from the files tab on the left of the screen.
Expand / Collapse Full Code
import plotly.graph_objects as go
import numpy as np
# Create figure
fig = go.Figure()
# Add traces, one for each slider step
for step in np.arange(0, 3.1, 0.1):
fig.add_trace(
go.Scatter(
visible=False,
line=dict(color="#00CED1", width=6),
name="𝜈 = " + str(step),
x=np.arange(0, 10, 0.01),
y=np.sin(step * np.arange(0, 10, 0.01))))
# Make 10th trace visible
fig.data[10].visible = True
# Create and add slider
steps = []
for i in range(len(fig.data)):
step = dict(
method="update",
args=[{"visible": [False] * len(fig.data)},
{"title": "Slider switched to step: " + str(i)}], # layout attribute
)
step["args"][0]["visible"][i] = True # Toggle i'th trace to "visible"
steps.append(step)
sliders = [dict(
active=10,
currentvalue={"prefix": "Frequency: "},
pad={"t": 30},
steps=steps
)]
# Fix x and y axis
fig.update_layout(
sliders=sliders,
yaxis=dict(range=[-1.1, 1.1]),
xaxis=dict(range=[0, 10])
)
# Save output html to `sample_plot.html`, can change if needed
# Using [full_html=False, include_plotlyjs='cdn'] makes file size smaller
fig.write_html("sample_plot.html", full_html=False, include_plotlyjs='cdn')
Add the html to the public folder, track the relative path.
- If the html is directly in
public
, the path would simply besample_plot.html
- If the html is in a subdirectory like
public/blog_data/blog_name
, the path would beblog_data/blog_name/sample_plot.html
- If the html is directly in
The goal is to make the html file accessible from a public url.
- You can now embed the html as an iframe in the mdx file. The title can be any string, as long as it is unique.
<HTMLPlot
title="Sample Plot"
pathname="/blog_data/embedding-interactive-plotly-charts-in-mdx/sample_plot.html"
/>
Voilà! You now have an interactive visualization embedded in the website!
Details
The first step is to make the html file available from a url / web link. In the case of using the tailwind-nextjs-starter-blog (Snapshot) template, the file can be accessed from your.domain/filepath
after we put the file in the public folder. In our case, for instance, we have:
Filepath:
/blog_data/embedding-interactive-plotly-charts-in-mdx/sample_plot.html
Public url:
https://fengbojun.com/blog_data/embedding-interactive-plotly-charts-in-mdx/sample_plot.html
Sample 1: Simple Iframe
Now that we have this url ready, we can easily embed it in an iframe component. For instance:
<iframe
title="Sample 1"
src="https://fengbojun.com/blog_data/embedding-interactive-plotly-charts-in-mdx/sample_plot.html"
style={{
height: `300px`,
width: `300px`,
}}
></iframe>
It worked! However, the caveat is immediate: The interactive visualization's layout / format is different due to the smaller size of the iframe. Additionally, the iframe's size is hard-coded and always stays constant, so unless the viewer's screen just happened to be the perfect size, the visualization wouldn't fit.
Sample 2: Iframe with Transform
To address the layout change, we use the transform property. Essentially, we first display the visualization in a large iframe to get the layout we want (this is especailly relevant when there is a large legend), then scale the elements to make it fit the screen:
<iframe
title="Sample 2"
src="https://fengbojun.com/blog_data/embedding-interactive-plotly-charts-in-mdx/sample_plot.html"
style={{
width: '1000px',
height: '564.5px',
transform: 'scale(0.6)',
transformOrigin: '0px 0px',
}}
></iframe>
This works a lot better - we are seeing the original visualization, just scaled smaller. The issue is that the iframe is still 564.5px tall and 1000px wide, which results in additional blank space after the visualization is scaled smaller.
Sample 3: Iframe with Div Nesting
In order to just show the visualization without unnecessary blank space, we put the iframe in a div to "crop out" any unnecessary parts.
<div
style={{
overflow: 'hidden', // crop out the overflowing blank space
position: 'relative',
width: '600px', // 1000 * 0.6
height: '338.7px', // 564.5 * 0.6
}}
>
<iframe
title="Sample 3"
src="https://fengbojun.com/blog_data/embedding-interactive-plotly-charts-in-mdx/sample_plot.html"
style={{
width: '1000px',
height: '564.5px',
transform: 'scale(0.6)',
transformOrigin: '0px 0px',
}}
></iframe>
</div>
If you have a standard laptop screen size or perhaps a bit smaller, this visualization looks pretty great. However, there is still a central problem that is not resolved: The visualization does not dynamically adapt to the screen size.
Sample 4: Iframe with React - Dynamic Scaling & Domain Fetching
In order to scale things dynamically, we need access to the screen width. Since we have React at hand, we can also fetch the origin url (e.g. https://fengbojun.com
) so we only need to provide the relative path to the html file. This also allows the script to work in dev mode, as we can automatically detect local temporary orgins (e.g. http://localhost:3000
).
This code uses react hooks and states to keep track of the current screen size and update values whenever the screen size changes. Additionally, we use window.location.origin
to track the origin. Here is a helpful StackOverflow post by Sunil Shakya (Snapshot) on windows.location
:
Let's suppose you have this url path:
http://localhost:4200/landing?query=1#2
So, you can serve yourself by the location values, as follow:
window.location.hash: "#2" window.location.host: "localhost:4200" window.location.hostname: "localhost" window.location.href: "http://localhost:4200/landing?query=1#2" window.location.origin: "http://localhost:4200" window.location.pathname: "/landing" window.location.port: "4200" window.location.protocol: "http:" window.location.search: "?query=1"
With all of this combined, we get the final version of the MDX element, which we can then embed in the webpage as mentioned in the tutorial section:
'use client'
import React, { useState, useEffect } from 'react'
const HTMLPlot = ({
title,
pathname,
height_before_scale = '433.59',
width_before_scale = '768',
}) => {
// Convert string to number, state to store the current plot layout
const height_numeric = +height_before_scale
const width_numeric = +width_before_scale
const [layout, setLayout] = useState({
width: '762px',
height: '571.5px',
scale_change_str: 'scale(0.5291666666666667)',
shift: '0px',
})
let origin = ''
// Update layout on resize
const handleResize = () => {
if (typeof window !== 'undefined') {
origin = window.location.origin
}
if (window.innerWidth >= 1280) {
setLayout({
...layout,
width: `${762 + 48 * 2}px`,
height: `${(762 + 48 * 2) * (height_numeric / width_numeric)}px`,
scale_change_str: `scale(${(762 + 48 * 2) / width_numeric})`,
shift: `${-48}px`,
})
} else if (window.innerWidth >= 768) {
setLayout({
...layout,
width: `${672 + 48 * 2}px`,
height: `${(672 + 48 * 2) * (height_numeric / width_numeric)}px`,
scale_change_str: `scale(${(672 + 48 * 2) / width_numeric})`,
shift: `${-48}px`,
})
} else {
setLayout({
...layout,
width: `${window.innerWidth}px`,
height: `${window.innerWidth * (height_numeric / width_numeric)}px`,
scale_change_str: `scale(${window.innerWidth / width_numeric})`,
shift: `${window.innerWidth >= 640 ? -48 : -32}px`,
})
}
}
// Effect to handle resizing
useEffect(() => {
window.addEventListener('resize', handleResize) // Add resize event listener
handleResize() // Run resize to update plot layout immediately
return () => {
window.removeEventListener('resize', handleResize) // Cleanup the event listener
}
}, []) // Empty dependency array ensures the effect runs only on mount and unmount
return (
<div
style={{
overflow: 'hidden',
position: 'relative',
width: layout.width,
height: layout.height,
marginLeft: layout.shift,
}}
>
<iframe
title={title}
src={origin.concat(pathname)}
style={{
height: `${height_numeric}px`,
width: `${width_numeric}px`,
transform: layout.scale_change_str,
transformOrigin: '0px 0px',
border: '0',
}}
></iframe>
</div>
)
}
export default HTMLPlot