WSSCode Blog

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:

Tao Last Expression REPL command

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:

  1. No worries about clashing styles or scripts with any other thing
  2. Using loading="lazy" attribute, the browsers can delay the load until the user scrolls to it
  3. 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:

Open custom view

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:

  1. How to know when the page is loaded?
  2. 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!

Embed render with WebView

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:

  1. The components on JxBrowser require a custom initialization process that doesn’t use new
  2. There are two different components to make this work, the Browser and the BrowserView

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!

Rendered Graph

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:

Pathom Viz Action

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:

Albrecht Schmidt
Alister Lee
Austin Finlinson
Daemian Mack
Jochen Bedersdorfer
Kendall Buchanan
Mark Wardle
Michael Glaesemann
Oleg, Iar, Anton
West