Extending Reveal with Embedded Browser Visualizations
December 10, 2020
Hello, I’ve been using Reveal to visualize my data, and it’s fantastic!
After just using it for a while, I wanted to extend it to show some Pathom visualizations. In this article, I’ll talk about the experience and things that I’ve learned in the process.
Using Reveal with Cursive
The first thing I like to mention is how I’m using Reveal. The documentation points to the solution to use clojure.main. The problem with this solution is that the REPL doesn’t work correctly in Clojure utilizing this mode, load files don’t work, the form is not sent in the current namespace…
So instead, I have an alias for Reveal that just adds the package:
{:aliases
{:reveal
{:extra-deps {vlaaad/reveal {:mvn/version "1.1.171"}}}}}
Then I manually start it from the REPL using a reveal
snippet:
(do
(require 'vlaaad.reveal)
(add-tap (vlaaad.reveal/ui)))
Now I can use tap>
to send things to Reveal.
Another trick I added recently is a REPL command to send the last expression to Reveal:
Then assign a keyboard shortcut. This way, I can quickly send the last REPL value to Reveal.
Idea Context
I recently launched the planner documentation page for Pathom 3, in which I add some custom visualizations for the graph planner on the page.
I had a bit of a hard time to get this done. This was due to the bundling process of
the Pathom 3 documentation page. The approach I’m using there (same as in this blog)
was to have a Shadow CLJS build using :npm-module
target, and then the Docusauros
the compiler uses that as part of its build for the final result.
This process worked fine during dev. I got the React components to work in the MDX code for the documentation without issues. The headache started when I trigger the release build, which does the SSR (Server Side Rendering) and get:
ReferenceError: window is not defined
It happens that the Cytoscape JS library in some part of its code uses the window
instance, and that I learned this breaks the SSR compilation (and looking it up
I found this seems to
be
a
common
issue)
I did fight that for a while, I tried the suggestions from the articles, but none worked, and the SSR won’t work.
So I gave up on trying to inject my component in the main build of the site. I knew an
old trick, that’s guaranteed to have its isolated world where the SSR would never
reach: the iframe
.
To make this, I created a new static HTML page and a new Shadow CLJS build for it. The idea is to work as an embed component (think like Youtube Embed) that uses a different build. This way, SSR won’t have to touch it. I still used my CLJS React components to integrate it, though.
The exciting part here is how to communicate between the iframe
and the
parent document. I wanted to have a single embed that can display a variety of components.
My idea is to start the embed page blank and listening for messages (using the window message api).
I’m using Helix to add custom React components in the site, so it looks like this:
(ns com.wsscode.pathom3.docs.components
(:require [cljs.reader :refer [read-string]]
[helix.core :as h]
[helix.dom :as dom]
[helix.hooks :as hooks]))
(defn post-message [^js window message]
(.postMessage window (pr-str message)))
(h/defnc EmbedComponent [{:keys [message height]}]
(let [iframe-ref (hooks/use-ref nil)]
(hooks/use-effect [iframe-ref]
(when @iframe-ref
(-> @iframe-ref
(.addEventListener "load"
(fn []
(-> @iframe-ref
(.-contentWindow)
(post-message message)))))))
(dom/iframe {:src "/embed.html"
:ref iframe-ref
:loading "lazy"
:style {:width "100%" :height height :border "0"}})))
It adds the iframe
, waits for it to load, and then triggers a message to it.
On the embed side, I listen for the command and render a component depending on the data:
(defn listen-window-messages [f]
(js/window.addEventListener "message"
(fn [^js msg]
(try
(f (read-string (.-data msg)))
(catch :default _)))))
(def component-map
{"plan-history" PlanHistory
"plan-view" PlanView
"planner-explorer" PlannerExplorer})
(defn start-component [{:keys [component] :as msg}]
(if-let [Comp (component-map component)]
(react-dom/render (h/$ Comp {:& msg}) (js/document.getElementById "component"))
(js/console.warn "Component not found:" component)))
(defn start []
(listen-window-messages start-component))
(start)
This way, I expose these three components on this embed.
You can find the embed full source here.
This solution worked great, SSR compiling, and the components integrated into the page.
I was a bit resistant about going with the iframe
, but after doing it, I’m
happy with the results:
- No worries about clashing styles or scripts with any other thing
- Using
loading="lazy"
attribute, the browsers can delay the load until the user scrolls to it - The solution is easy to integrate anywhere that supports a web view
From point 3, we finally get back on this article topic: can I integrate this as a web view inside Reveal and have custom web visualizations there?
Show a browser
The next thing to learn is how to render custom views in Reveal. The trick here is to
create a map with the key :fx/type
. This way, Reveal recognizes it as a potential
custom view. To illustrate, you can use the following to render a browser in a view:
(tap>
{:fx/type :web-view
:url "https://www.clojure.org"})
Then request the view
option from Reveal context:
Bringing Pathom Viz
So now it’s time to combine the WebView
with my embedded implementation of these visualizations.
At first, I tried to use the same messaging system. This requires me to figure out:
- How to know when the page is loaded?
- How to send a message to the web view?
My first source to find this was looking at the web view example in cljfx.
After some digging in the Web View docs and with some help from Vlad, I got to this:
(ns com.wsscode.demos.reveal
(:require [vlaaad.reveal.ext :as rx]
[cljfx.api :as fx]
[cljfx.prop :as prop]
[cljfx.mutator :as mutator]
[cljfx.lifecycle :as lifecycle])
(:import (javafx.scene.web WebView)))
(def web-view-with-ext-props
(fx/make-ext-with-props
{:on-load-state-changed
(prop/make
(mutator/property-change-listener #(-> (.getEngine ^WebView %)
(.getLoadWorker)
(.stateProperty)))
lifecycle/change-listener)}))
(tap>
{:fx/type web-view-with-ext-props
:desc {:fx/type :web-view
:url "http://localhost:3000/embed.html"}
:props {:on-load-state-changed
(fn [e]
(tap> ["state changed" e]))}})
This solves part one, but I was having trouble on how to get the WebView
instance from
that event response, to trigger the JS execution.
So in the middle of the process I had an idea for a simpler approach, I would just change
the embed to also support a message coming from the query params, this way I can send the
messages directly in the URL instead of doing all this mess. This is the updated start
on embed:
(defn start []
(listen-window-messages start-component)
(if-let [msg (some-> js/window.location.search (js/URLSearchParams.) (.get "msg"))]
(try
(start-component (read-string msg))
(catch :default e
(js/console.error "Error parsing query msg:" msg e)))))
(start)
Now with the easy integration set, time to try the visualization
(defn viz-snapshots [req]
(let [content (merge {:message "pathom-start-embed" :component "plan-history"} req)
encoded-req (-> content pr-str URLEncoder/encode)]
{:fx/type :web-view
:url (str "http://localhost:3000/embed.html?msg=" encoded-req)}))
(tap>
(viz-snapshots '{:oir {:a {#{} #{a}}
:b {#{:g} #{b}}
:c {#{} #{c}}
:e {#{} #{e e1}}
:f {#{:e} #{f}}
:g {#{:c :f} #{g}}
:h {#{:a :b} #{h}}}
:query [:h]}))
Then it’s time to try it!
So, no graph… After some debug I figure the problem is related to the WebKit version in JavaFX, apparently, it’s not so up to date, and it lacks features used by Cytoscape to render the graph.
That’s a bummer…
JxBrowser
After looking a bit online, I stumbled on this library called JxBrowser.
They claim to fix the issues by using Chromium and keeping up to date with it, so I decided to give it a try.
This was the most challenging part, trying to understand how cljfx views work. I like to thank Vlad for the assistance with this. It was really helpful!
The goal was to port this demo
from JxBrowser
to cljfx
. I was looking in the cljfx built-in components to figure out
how to do it, but there were challenges:
- The components on JxBrowser require a custom initialization process that doesn’t use
new
- There are two different components to make this work, the
Browser
and theBrowserView
That’s where Vlad came to my aid and taught me the way to cljfx
. What I understood is
that the idea is to mimic the object hierarchy in cases like this, and sent me the snippets
to make it work, it ended up like this:
(def browser
(composite/lifecycle
{:props (composite/props Browser
:url [(fx.mutator/setter
(fn [^Browser browser ^String url]
(when url
(.loadUrl (.navigation browser) url))))
fx.lifecycle/scalar])
:args []
:ctor (fn []
(-> (EngineOptions/newBuilder RenderingMode/HARDWARE_ACCELERATED)
.build
Engine/newInstance
.newBrowser))}))
(def browser-view
(composite/lifecycle
{:props (merge
fx.node/props
(composite/props BrowserView
:browser [fx.mutator/forbidden fx.lifecycle/dynamic]))
:args [:browser]
:ctor (fn [^Browser browser]
(BrowserView/newInstance browser))}))
Then reflect it on the map to describe the view:
(defn pathom-embed [content]
(let [encoded-req (-> content pr-str URLEncoder/encode)]
{:fx/type browser-view
:browser {:fx/type browser
:url (str "http://localhost:3000/embed.html?msg=" encoded-req)}}))
(defn viz-snapshots [req]
(pathom-embed (merge {:message "pathom-start-embed" :component "plan-history"} req)))
; example usage:
(tap>
(viz-snapshots
'{:oir {:a {#{} #{a}}
:b {#{:g} #{b}}
:c {#{} #{c}}
:e {#{} #{e e1}}
:f {#{:e} #{f}}
:g {#{:c :f} #{g}}
:h {#{:a :b} #{h}}}
:query [:h]}))
With that, it renders as expected!
The bad news is that JxBrowser is a paid solution and costs $1,799
per developer, quite
unfeasible for open-source. So I hope to find some other solution.
I wonder if there is something else out there to use Chromium. I found this page on Java CEF, but it seems complicated to setup. I’ll keep using the 30 days trial on JxBrowser for now.
Register custom action
Now, I like to integrate a new Reveal action to render a graph from some plan data.
This was the easiest part. All you need is to call defaction
macro, make a condition
to check if the data is eligible for your view, and return a function with it:
; this renders a single graph, instead of a snapshot series like the other one
(defn viz-graph [plan]
(pathom-embed (merge {:message "pathom-start-embed" :component "plan-view" :plan plan})))
(rx/defaction ::pathom-plan-viz [x ann]
(when (::pcp/nodes x)
#(rx/stream-as-is
(viz-graph x))))
For the eligibility check, I check if X contains ::pcp/nodes
, this is a good indication
this might be a map with a Pathom plan. This is one of those cases where having fully
qualified keywords makes life easy.
Here is a demo showing the usage of it:
Conclusions
It was quite a ride. I got a liking for the embed components idea. I think I’ll have something like that as an official option to use Pathom Viz in the future. The setup for including is so easy. It would also be great to have a full Pathom Viz experience inside Reveal, it only needs a good chromium render that is free.
I’m a believer that visualizations are crucial to the understanding of complex systems. If you haven’t watched it, I recommend the presentation Media for Thinking the Unthinkable from Bret Victor, it’s really inspiring ideas about interactive media and visualizations.
I hope we are entering a new REPL area with much more sophisticated visualizations at the tips of our REPL’s, and I find Reveal an excellent gateway to start it out!
Follow closer
If you like to know in more details about my projects check my open Roam database where you can see development details almost daily.
Support my work
I'm currently an independent developer and I spent quite a lot of my personal time doing open-source work. If my work is valuable for you or your company, please consider supporting my work though Patreon, this way you can help me have more available time to keep doing this work. Thanks!
Current supporters
And here I like to give a thanks to my current supporters: