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:
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:
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.
》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.