avatar image - a green paisley swirl bits all the way down
December 30, 2023
By: Nundrum

Building A ClojureScript SVG Toolbox

》Laments and Goals

Why do I do this to myself? The idea, so easy. The implementation, such pain.

I wanted to build a custom startpage (aka "new tab page") in my usual cyberpunk style. There are dozens and dozens of startpages out there to choose from, many of which were very stylish. But none were the phosphor green-drenched (retro?) future-styled UI my heart desired.

Well, I’ve been on this ClojureScript journey, so why not continue it?

There were a few must-haves:

  • An "ambient calendar" showing a year progression

  • A per-Firefox-instance set of URLs

  • Current temperature and daily high/low forecast

  • Today’s allergen levels

  • Style that would make a movie studio jealous

  • No ongoing animation after the page loads

Then some stretch goals:

  • Weather radar

  • Disk usage of certain systems that I need to keep an eye on

  • Traffic conditions and alerts

  • Calendar entries

And two not-haves: a search bar and a clock. CTRL-k to search is a habit. And I friggin' hate clocks on my desktop. Long ago I learned they distract me.

Oh, this is going to be painful.

》The Base

To start the project, a quick npx create-cljs-project was all that was necessary.

Very quickly it became obvious that using Hiccup syntax would be great, and that pulling in Reagent would be necessary. Together they make building basic SVGs pretty easy: [:svg {:width w :height h} …​elements…​].

With a Reagent ratom here and there and some components for the minor interactivity required, I decided against including re-frame. I am curious to see how the size of app.js turns out compared to my previous project once this is done.

Here is an example component - a "gauge" that fills up, representing months as the year progresses:

(defn yearmonth-gauge [{:keys [x y w h]}]
  (let [month (inc (.getMonth (new js/Date)))
        border 4
        point (* 0.5 h)
        outer [[x (+ y point)]
               [(+ x point) y]
               [(+ x w (- 0)) y]
               [(+ x w) (+ y point)]
               [(+ x w (- point)) (+ y h)]
               [(+ x ) (+ y h)]]
        x2 (+ x point border)
        y2 (+ y border)
        w2 (- w point point border border)
        h2 (- h border border)
        inner [[x2 y2]
               [(+ x2 w2) y2]
               [(+ x2 w2) (+ y2 h2)]
               [x2 (+ y2 h2)]]
        x3 (+ x2 border)
        y3 (+ y2 border)
        wbox (- (/ (- w2 border border) 12) 4)
        hbox (- h2 border border)]
    [:g {:id "yearmonth"}
       [:polygon {:points (apply str (interpose " " (flatten outer)))
        :stroke "#0F0" :fill "#040"}]
       [:polygon {:points (apply str (interpose " " (flatten inner)))
        :stroke "#0A0" :fill "#000"}]
       (for [i (range 0 month)]
         [:rect {:x (+ x3 (* i wbox) (* 4 i)) :y y3
	         :width wbox :height hbox
                 :fill "#550" :stroke "#884"}])]))

This was the result:

svg toolbox yearmonth gauge

That gets me through the "simple" stuff, like SVG elements of a fixed size that can be hand-crafted. Next was building an outline frame for the display. I wanted it to have carve-outs both for style and for holding components. That quickly turned into a puzzle in terms of how to accomplish it with SVG. The clipPath SVG element only clips the view, it does not return a new polygon. Which means the border stroke gets clipped and does not follow the new path. That left the question of how to define the size/shape of the carve-outs and how to turn them into an SVG path or polygon. I needed more tools.

》Polygons, or Stumbling Through Interop part II

Based on prevoius work with other toolkits and libraries, I went looking for a way to perform boolean operations on polygons. There was no CLJS library to be found, but a few JS libraries that might work. Maybe using more JS interop wouldn’t be that bad? After finding the polyboojs library, I decided to give that a shot.

Polybooljs allows for defining a polygon as a "list of regions along with an inversion flag." A simple vector of points! Feels like the sort of thing Clojure should be good at. Once defined, the library can handle opeations like union and intersection on a pair of polygons. What I needed here is difference - define a rectangular frame and then subtract out polygonal areas for the carve-outs.

The example looked easy enough. Install was easy with npm install polybooljs. And then I looked at this JS block direct from the GitHub page:

var PolyBool = require('polybooljs');
PolyBool.intersect({
    regions: [
      [[50,50], [150,150], [190,50]],
      [[130,50], [290,150], [290,50]]
    ], inverted: false
  }, {
    regions: [
      [[110,20], [110,110], [20,20]],
      [[130,170], [130,20], [260,20], [260,170]]
    ], inverted: false
  });
===> {
  regions: [
    [[50,50], [110,50], [110,110]],
    [[178,80], [130,50], [130,130], [150,150]],
    [[178,80], [190,50], [260,50], [260,131.25]]
  ], inverted: false
}

So let’s try it;

;my broken naive attempt
(let [poly1 {:regions [[0,0] [10,0] [10,10] [0,10]] :inverted false}
      poly2 {:regions [[5,0] [15,0] [15,15] [0,15]] :inverted false}]
      (poly/difference poly1 poly2))
;Execution error (TypeError) at (<cljs repl>:1)
;poly.regions is undefined

Uh oh. What had I missed? A little research showed me that CLJS data structures have to be translated into JS data structures to be used with JS functions. The magic is in clj→js and js→cljs. That is just messy enough to warrant wrapping it up into a easier-to-use function.

(defn difference [poly1 poly2]
  (let [jspoly1 (clj->js {:regions [poly1] :inverted false})
        jspoly2 (clj->js {:regions [poly2] :inverted false})]
  (-> (poly/difference jspoly1 jspoly2) js->clj (get "regions") first)))

Ah ha! This is great! With that code, I was able to define a rectangle and carve out a trapezoid. Polybooljs has a notion of polygon that has multiple regions. That is more complex that what this project needs, but is a feature I might revisit later.

Translating the set of points from difference into the points needed for a polygon element was wonderfully simple thanks to flatten and interpose. Then I created duplicate polygons from the same set of points and applied a Gaussian blur filter to the bottom one. This is just what I was looking for:

(defn carveout-box []
  (let [w js/document.documentElement.clientWidth
        h js/document.documentElement.clientHeight
        l 10
        r (- w 10)
        t 10
        b (- h 10)
        nw [l t]
        ne [r t]
        se [r b]
        sw [l b]

        frame [nw ne se sw]
        carveout [[500 0] [800 0] [780 50] [520 50]]
        diff (difference frame carveout)
        pstr (apply str (interpose " " (flatten diff)))]
    [:<>
     [:defs
      [:filter {:id "mainblur"}
      [:feGaussianBlur {:in "SourceGraphic" :stdDeviation 5 }]]]
     [:polygon {:points pstr :fill "#0209"
                :filter "url(#mainblur)" :stroke "#0F0"
		:stroke-width 2}]
     [:polygon {:points pstr :fill "#0000"
                :stroke "#0F0" :stroke-width 2}]]))

This is the resultling SVG:

carveout box

Polybooljs is so handy I might decide to make a wrapper library for it in CLJS and share it with the author. But for now, there is a blog post to finish!

》More State, Less Stumbling

Now I needed data. My previous post helped me figure out how to handle fetching data. And luckily I have data! A while back I needed to get weather data for multiple projects, and didn’t want each one polling weather.gov repeatedly. So I wrote a Babashka script that fetches, parses, and stores the data as edn files. They are updated hourly and placed in a folder on a local Apache httpd server. The script also handles scraping allergen data from my local allergy clinic.

That covers the data for two of my three goals. The third being a list of URLs. I figure that putting the URLs into another edn file alongside the page itself will be easy enough. Firefox should be able to fetch them from the filesystem directly.

On the ClojureScript side, I opted for putting the data into top level ratoms instead of in the components themselves. That simplifies performing the fetch of data since I’m not using re-frame here. The ratoms can be used in reagent components just like if they had been declared locally. All together, it looks like this:

(defonce weather (r/atom nil))

(-> (js/fetch weather-url )
    (.then #(.text %))
    (.then #(reset! weather (cljs.reader/read-string %)))
    (.catch #(println (str "error: " %))))

(defn weather-component [x y]
  (if (nil? @weather) [:<> ...svg elements before load...]
    [:<> ...svg elements after load...]))

》Rounding Out The Toolbox

Basic SVG knowledge? ✓
SVG components in Reagent? ✓
Boolean ops on polygons? ✓
Data to work with? ✓

Now can I put that together to show the high/low/current temperatures in a "widget?" ✗

Sadly, the weather.gov API is not returning the high/low. There is plenty of other data, including current temperature and relative humidity. Those two will do for now.

For a quick start, my first thought is to put two triangles side-by-side but rotated with one pointing up-ish and one down-ish. The values would be set inside of each. Time for trigonometry! And a refresher on equilateral triangles. Then I will need a way to construct triangle polygons, rotate them, translate them into place, and render them as SVG polygons.

These functions would not suffice for a gaming engine, but for rendering a single and mostly static page they’re good enough:

(defn degrees-to-radians [degrees]
  (-> degrees (- 90) (* js/Math.PI) (/ 180.0)))

(defn polar-to-cartesian [cx cy radius degrees]
  (let [radians (degrees-to-radians degrees)]
    [(+ cx (* radius (js/Math.cos radians)))
     (+ cy (* radius (js/Math.sin radians)))]))

(defn translate-point [[x y] xd yd]
  [(+ x xd) (+ y yd)])

(defn translate-poly [poly xd yd ]
  (mapv #(translate-point % xd yd) poly))

(defn rotate-point [[x y] degrees]
  (let [radians (degrees-to-radians degrees)
        cos     (js/Math.cos radians)
        sin     (js/Math.sin radians)
        xr      (+ (* cos x) (* sin y))
        yr      (- (* cos y) (* sin x))]
    [xr yr]))

(defn rotate-poly [poly degrees]
  (mapv #(rotate-point % degrees) poly))

After defining a triangle-poly function to give me the points of a triangle, it was easy to put those together into a component. The specific numbers used here were just fiddled into place until they looked OK. Shadow-cljs makes that very easy to do with live reloading. In a future iteration the component will be parameterized.

(defn conditions []
  (let [w           @weather
        temperature (-> w :temperature :value c-to-f)
        humidity    (-> w :relativeHumidity :value int)
        ]
    [:<>
     [:polygon {:stroke "#0F0" :points
                (-> (c/triangle-poly 50)
                    (c/rotate-poly 45)
                    (c/translate-poly 300 150)
                    (c/poly2path))}]
     [:text {:x 290 :y 155 :fill "#9F0"} temperature]
     [:polygon {:stroke "#0F0" :points
                (-> (c/triangle-poly 50)
                    (c/rotate-poly 345)
                    (c/translate-poly 275 180)
                    (c/poly2path))}]
     [:text {:x 265 :y 185 :fill "#0FF"} humidity]]))

The result is reasonable, but not awesome (yet). At least this makes me feel good that the tools are in place to finish the job.

svg toolbox conditions

》In Closing

Though it took a bit of time and a bit of trig, the tools developed thus far should work well to build the rest of the project. Styling, effects, and font selection will probably consume me for many days. Expect a follow-up post sometime soon.

If you know of better ways, better libraries, or just have suggestions let me know on Mastodon or @Nundrum on the Clojurians slack.

Tags: svg interop clojurescript
« More for the ClojureScript SVG Toolbox Stumbling Through Interop »

A blog by Nundrum

Links

  • @Nundrum on Mastodon
  • Archives
  • RSS
  • Clojurians Slack
  • Contacting Nundrum
  • P.E.P.R. Jacket Project

Recent Posts

  • Fountainvoid
  • Introducing XScreenBane
  • A Jacket, Microcontrollers, and Clojure

Tags

  • re-frame
  • clojure
  • svg
  • async
  • interop
  • jna
  • screensaver
  • platformio
  • font
  • pepr jacket project
  • cyberpunk
  • clojurescript

Copyright © 2025 Nundrum

Powered by Cryogen