Published on

Embedding Interactive Plotly Charts in MDX

Authors
  • avatar
    Name
    Bojun Feng
    Twitter

Summary:

  1. Intro
  2. Tutorial
  3. Details

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.

  1. Create a new HTMLPlot.tsx component in the components folder.
Expand / Collapse Full Code
HTMLPlot.tsx
'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
  1. Add the component to components/MDXComponents.tsx.
Expand / Collapse Full Code
UpdateMDXComponents.tsx
...
+ import HTMLPlot from './HTMLPlot'

export const components: MDXComponents = {
  Image,
  TOCInline,
  a: CustomLink,
  pre: Pre,
  BlogNewsletterForm,
+  HTMLPlot,
}
  1. 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
InteractivePlot.py
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')
  1. Add the html to the public folder, track the relative path.

    • If the html is directly in public, the path would simply be sample_plot.html
    • If the html is in a subdirectory like public/blog_data/blog_name, the path would be blog_data/blog_name/sample_plot.html

The goal is to make the html file accessible from a public url.

  1. 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.
Blog.mdx
<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:

HTMLPlot.tsx
'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