Exporting figures to static HTML

Hi!

I am trying to embed my makie plots as html files in my github pages website which uses jekyll.

I found this blog post which explains how it can be done: using Julia to do web based interactive plotting | Aaron Trowbridge

I imported the HTML files he generated and they work fine. However I am not able to generate such files myself yet for my own plots, his script throws some errors.

I have the following test code:

using WGLMakie
import WGLMakie as W
using Bonito

WGLMakie.activate!()  # Ensure WGLMakie is used

# this is optional and just changes the color theme
set_theme!(theme_dark())

# radial sinc function with scale parameter "a"
radial_sinc(x, y, a) = sinc(a * hypot(x, y)) 

# domain of surface
xs = LinRange(-5, 5, 150)
ys = LinRange(-5, 5, 150)

# creating the javascript app
app = App() do session::Session
    
    scale_slider = Slider(1:3)

    states = map(scale_slider) do a
        return [radial_sinc(x, y, a) for x in xs, y in ys]
    end

    fig, ax, = surface(xs, ys, states, figure=(size=(700, 400),))

    scale_value = DOM.div("\\(a = \\)", scale_slider.value)
    
    return Bonito.record_states(
        session,
        DOM.div(fig, scale_value, scale_slider)
    )
end

output_file = "_posts/examples/diffeqviz/sinc_surface.html"
Bonito.export_static(output_file, app)

I noticed that the file created by Aaron has the following structure:

<center>
<div id="347664a4-937a-4c8c-8d0a-0f724a4fca5a" data-jscall-id="40" style="visibility: hidden;">
  <div data-jscall-id="20">
    <div data-jscall-id="22">
      <canvas data-jscall-id="21" tabindex="0"></canvas>
    </div>
    <div data-jscall-id="19">\&#40;a &#61; \&#41;
      <span data-jscall-id="23">1</span>
    </div>
    <input step="1" max="3" min="1" data-jscall-id="24" value="1" oninput="JSServe.update_obs('16154021666513593650', parseFloat(value))" type="range" />
  </div>
  <script data-jscall-id="37" type="text/javascript">
    window.__define = window.define;
    window.__require = window.require;
    window.define = undefined;
    window.require = undefined;

</script>
  <script data-jscall-id="38" type="text/javascript">
    window.define = window.__define;
    window.require = window.__require;
    window.__define = undefined;
    window.__require = undefined;

</script>
  <script data-jscall-id="39" type="text/javascript">
(()=> {
    JSServe.register_sub_session('347664a4-937a-4c8c-8d0a-0f724a4fca5a')
    const init_data_b64 = 'H4sIAAAAAAAAA1ydd7j....'
    JSServe.init_from_b64(init_data_b64)
    if (!true){
        JSServe.update_obs('16814681312933192367', true)
    }
})()
</script>
</div>
</center>

Is there a way to replicate this in the new version of Bonito?

Hello,

I wasn’t able to reproduce any error using the test code that you provided.

The only (expected) issue was related to the path you used:

  • make sure you have the file/directory path properly created before running the script
  • change the path to a valid location

By the way, you can use mkpath to create all the intermediary directories needed to satisfy path by adding

mkpath("_posts/examples/diffeqviz")

before the Bonito.export_static... line.

Hi,

The code works, I should have been more specific about the issue.

It produces a html file of 36MB on my machine, compared to the blog which has a html file of a few MB. When I import the file I generated with the above script into my own blog nothing is displayed, when I import the file I downloaded from Aaron’s blog the interactive plot works fine.

So basically I want to replicate the HTML plot generation of this script: aarontrowbridge.github.io/static/plot_scripts/interactive_plotting/interactive_3d_visualization.jl at main ¡ aarontrowbridge/aarontrowbridge.github.io ¡ GitHub

Try using this script (adapted from WGLMakie docs to be compatible with your desired example):

using WGLMakie, Bonito, FileIO
WGLMakie.activate!()

mkpath("static/plot_html/interactive_plotting")
output_file = "static/plot_html/interactive_plotting/contour.html"

open(output_file, "w") do io
    println(
        io,
        """
<center>
"""
    )
    Page(exportable=true, offline=true)

    app = App() do
        n = 10
        volume = rand(n, n, n)
        fig, _, _ = contour(volume, figure=(size=(700, 700),))
        fig
    end
    show(io, MIME"text/html"(), app)

    println(
        io,
        """
</center>
"""
    )
end

Wouldn’t a PNG or SVG image be more efficient?

Thanks, that works!

One problem left now, only the first plot is displayed. The file structure is:

$ tree
.
└── diffeqviz
    ├── 2025-02-13-interactive-julia-plotting.md
    ├── contour.html
    ├── diffeqviz.jl
    └── sinc_surface.html
---
layout: post
title: a post with interactive plots
date: 2025-02-13 19:35:00+0100
description: an example of a an interactive blog post with makie plots
tags: julia interactive
categories: sample-posts
related_posts: false
---

## a first example

A simple script to display a static 3d visualization---a contour plot of a random volume---looks like this:

{% include_relative contour.html %}

## a second example

Test plot 2 outputs:

{% include_relative sinc_surface.html %}

The post results in:

The buttons also don’t seem to be working yet:

using WGLMakie, Bonito, FileIO
WGLMakie.activate!()

# mkpath("static/plot_html/interactive_plotting")
output_file = "_posts/examples/diffeqviz/sinc_surface.html"

open(output_file, "w") do io
    println(
        io,
        """
<center>
"""
    )
    Page(exportable=true, offline=true)

    app = App() do session::Session
        # radial sinc function with scale parameter "a"
        radial_sinc(x, y, a) = sinc(a * hypot(x, y)) 

        # domain of surface
        xs = LinRange(-5, 5, 150)
        ys = LinRange(-5, 5, 150)

        scale_slider = Slider(1:3)

        states = map(scale_slider) do a
            return [radial_sinc(x, y, a) for x in xs, y in ys]
        end

        fig, ax, = surface(xs, ys, states, size=(resolution=(700, 400),))

        scale_value = DOM.div("\\(a = \\)", scale_slider.value)
        
        return Bonito.record_states(
            session,
            DOM.div(fig, scale_value, scale_slider)
        )
    end

    show(io, MIME"text/html"(), app)

    println(
        io,
        """
        </center>
        """
    )
end

Absolutely. But interactive plots in the browser are much cooler.

Please be aware of the significance of offline=true in Page(exportable=true, offline=true): this means that actions from the UI requiring Julia to compute or generate additional data points will not work (the client side will not even attempt to call the backend).

If you want a truly reactive application with on-demand data and computation, you’ll need a proper server running Julia.

There are also ways to precompute a large amount of data and load it all into JavaScript on the client side, but that may not be the most practical or desirable approach for most scenarios.

This is what the blog I referenced is doing.

In particular it uses this function: Bonito.record_states

I want to replicate what that blog is doing, using the same function. I don’t plan on plotting large datasets, the 3D examples I showed are reasonably responsive. It doesn’t have to be perfect :slight_smile:

Bonito.is_widget(Bonito.Slider) returns false - this means you need to implement the appropriate interface for Slider for record_states to actually do something.

From Bonito.record_states documentation:

 #Implementing interface for Bonito.Slider!
  is_widget(::Slider) = true
  value_range(slider::Slider) = 1:length(slider.values[])
  to_watch(slider::Slider) = slider.index # the observable that will trigger JS state change

Huh, okay, that was not required before. Bonito.is_widget(Slider(1:3)) returns True for me without your provided code. I tried it anyway, but it didnt help so it’s not needed I guess.

1 Like

This is almost working.

Somehow I get <!doctype html> around the page. Not sure how to fix that yet. Please let me know if you have any other suggestions :slight_smile:


using Bonito, WGLMakie, Makie, Colors, FileIO
using Bonito.DOM

function styled_slider(slider, value)
    rows(slider, DOM.span(value, class="p-1"), class="w-64 p-2 items-center")
end

# Create a little interactive app
Page(exportable=true, offline=true)

app = App() do session
    markersize = Bonito.Slider(range(10, stop=100, length=100))

    # Create a scatter plot
    fig, ax = meshscatter(rand(3, 100), markersize=markersize)

    # Create a styled slider
    styled_slider(markersize, "Marker Size")

    # Return the plot and the slider
    return Bonito.record_states(session, DOM.div(fig, markersize))
end;

# mkdir("simple")
output_file = "_posts/examples/diffeqviz/sinc_surface.html"
Bonito.export_static(output_file, app)

Perhaps this link is helpful:

1 Like

The reason for getting it wrapped in a full HTML document (with <!doctype html> around the page) is that you are sticking with the export_static function.

So you are embedding a HTML document in another HTML document (which will not work right away without using some <iframe> approach).

Please take a look at my example above where I am only writing the relevant HTML snippet to the file: Exporting figures to static HTML - #4 by algunion

1 Like

Like I said before, when I use that code only one of the plots displays and the other dissapears. So something is being overwritten.

There are some good ideas here in the thread, but I think the best way of doing this goes like this:

using WGLMakie, Bonito
# Maybe should be in bonito?
function as_html(io, session, app)
    dom = Bonito.session_dom(session, app)
    show(io, MIME"text/html"(), Bonito.Pretty(dom))
end

# For a single plot, we can just do this:
open("test.html", "w") do io
    # Do you own header etc
    println(io, "<!doctype html>")
    app = App(contour(rand(10, 10, 10))) 
    # Page(exportable=true, offline=true) creates pretty much just the below Session 
    session = Session(NoConnection(); asset_server=NoServer())
    # This should only create  a `div` with all imports and the plot:
    as_html(io, session, app)
end

For multiple plots, we need to use a Subsession, which skips uploading similar assets/data and setup.
This is pretty much what Page does, but for direct export, Page is a bit cumbersome to work with, so I recommend just creating the subsession manually:

open("test.html", "w") do io
    println(io, "<!doctype html>")
    app = App(contour(rand(10, 10, 10)))
    # Page(exportable=true, offline=true) creates pretty much just the below Session 
    session = Session(NoConnection(); asset_server=NoServer())
    println(io, "<h2>First plot</h2>")
    as_html(io, session, app)
    app2 = App(volume(rand(10, 10, 10)))
    # Create a new session with the same connection, but skip uploading assets/data
    println(io, "<h2>Second plot</h2>")
    sub = Session(session)
    as_html(io, sub, app2)
end

If you use a static site, which supports serving files (like github.io), you can also use Bonito.AssetFolder, instead of NoServer().
NoServer uses Base64 encoded urls for all assets, which is slower and leads to worse stack traces, while AssetFolder writes out all dependencies and uses relative urls to include them:


root = "root-of-server"
mkdir(root)
# First argument is the root of the server
# Second argument is the location of the current html file that gets exported
# Which is required for generating urls for assets correctly 
# e.g. you could want to generate `/root/plots/plot.html`, then the second argument would need to be 
# "root-of-server/plots"
asset_server = Bonito.AssetFolder(root, root)
open(joinpath(root, "index.html"), "w") do io
    println(io, "<!doctype html>")
    app = App(contour(rand(10, 10, 10)))
    # Page(exportable=true, offline=true) creates pretty much just the below Session 
    session = Session(NoConnection(); asset_server=asset_server)
    println(io, "<h2>First plot</h2>")
    as_html(io, session, app)
    app2 = App(volume(rand(10, 10, 10)))
    # Create a new session with the same connection, but skip uploading assets/data
    println(io, "<h2>Second plot</h2>")
    sub = Session(session)
    as_html(io, sub, app2)
end

You will see that a few files and folders are created in the root folder, which contain the assets used by index.html. The index.html then uses something like ./bonito/someasset.js to include those.

You will need e.g. LiveServer, to look at index.html now, since it needs a full file server to display things:

using LiveServer
LiveServer.serve(dir=root)

This is actually how makie.org is created - if you don’t already use e.g. Jekyll, Bonito has some tools to create the whole site, avoiding the whole manual export above:

And the make.jl.

Would be nice to update the Bonito docs with all of this.

Btw, record_states is not completely deprecated, but it’s quite buggy, and I’m not sure if I’d recommend it anymore.
Maybe for very simple stuff, but the size also balloons pretty quickly, and the last refactor of it, made it even worse sadly (I tried to improve correctness, but mainly just increased size) :frowning:
But I guess if it works for you and doesn’t become too big, there’s no reason against using it. Not sure, if it’s worth putting more time into it, but maybe I’ll find some low hanging fruits to decrease size and make it work better.

Currently, I’m thinking it’s better to just improve the javascript interface, making it easier to update plots via sliders in javascript, and then maybe make it easier to hook those up with precalculated data from Julia.

4 Likes

Thank you very much for your eleborate reply!

Since I still want control about where the plots appear, I have to split them to separate HTML files (unless there is a better way?).

I adapted your code into this. I am not sure whether this ‘works by acccident’ or is an intended usecase? I do notice that the HTML file associated with the session has to be imported first. So if I import {% include_relative sinc_surface.html %} before {% include_relative contour.html %} none of the plots display.

using Bonito, WGLMakie


function as_html(io, session, app)
    dom = Bonito.session_dom(session, app)
    show(io, MIME"text/html"(), Bonito.Pretty(dom))
end

session = Session(NoConnection(); asset_server=NoServer())
sub = Session(session)

# plot 1 - interactive plot
open("_posts/examples/diffeqviz/contour.html", "w") do io
    app = App() do session
        markersize = Bonito.Slider(range(10, stop=100, length=100))

        # Create a scatter plot
        fig, ax = meshscatter(rand(3, 100), markersize=markersize)

        # Return the plot and the slider
        return Bonito.record_states(session, DOM.div(fig, markersize))
    end;

    as_html(io, session, app)
end

# plot 2 - volume plot
open("_posts/examples/diffeqviz/sinc_surface.html", "w") do io
    app = App(volume(rand(10, 10, 10)))
    as_html(io, sub, app)
end

Which along with the blog post now produces this, which works great:

## a first example

A simple script to display a static 3d visualization---a contour plot of a random volume---looks like this:

{% include_relative contour.html %}

## a second example

Test plot 2 outputs:

{% include_relative sinc_surface.html %}

Got it. It seems to work fine for my usecase, I haven’t been able to find any issues. I know it’s not optimal, but it renders pretty fast on the real thing: a post with interactive plots | Hi!

I guess there isn’t anything speaking against splitting them into separate files, if including them works well with your framework.

I do notice that the HTML file associated with the session has to be imported first.

Yes, the first session will include all the setup for any session that’s get included afterwards.
You’ll also need to honor the order of rendering, since new dependencies get included in the session that first “sees” that dependency.

To lessen the order dependency, you could export one setup html, which you include first into every page:

page_session = Session()

open("setup.html", "w") do io
    as_html(io, page_session , App(nothing))
end

open("sinc_surface.html", "w") do io
    app = App(volume(rand(10, 10, 10)))
    as_html(io, Session(page_session), app)
end

To make it truly order independent, you’ll need to include the WGLMakie dependencies though, which I’m not sure how to do best right now… A hacky way would be something like this:

open("setup.html", "w") do io
    setup_app = App() do  
           s = Scene(size=(1,1))
           # Make sure texture atlas gets included:
           text!(s, Point2f(0), text="0") 
           # include WGLMakie plot but hide it
           return DOM.div(s; style="display: none;") 
    end
    as_html(io, page_session, setup_app)
end

Maybe, we could introduce a mode to always include all dependencies and rely on the browser caching any double inclusion, working around the downsides - which I think would also help e.g. Pluto.

1 Like

Thanks a lot for thinking a long! Makie is awesome.

I am not able to make it work yet with this approach, the plots don’t render in this setup. Having it depend on the order is absolutely not a big deal for me personally, I’m already very happy this is working as it is.