26 Interactivity
Plotje produces SVG hiccup. Two layers of interaction are available:
Built-in: pass
:tooltip trueor:brush truein pose options. Plotje injectsdata-tooltip/data-row-idxattributes on the points and interval bars it draws, and includes the matching browser-side script automatically.Custom wrappers: wrap the SVG output with
kind/hiccupplus a small[:script ...]form for behaviours not built in (e.g. save-as-PNG).
The static GFM render of this notebook shows the SVGs as flat images. Open the HTML rendering to see the interactions live.
Both layers are drawn by a browser reading the figure, so SVG is the only format that answers them β see Formats that draw interaction at the end of the chapter.
(ns plotje-book.interactivity
(:require
;; Tablecloth -- dataset manipulation
[tablecloth.api :as tc]
;; rdatasets -- bundled R datasets
[scicloj.metamorph.ml.rdatasets :as rdatasets]
;; Kindly -- notebook rendering protocol
[scicloj.kindly.v4.kind :as kind]
;; Plotje -- composable plotting
[scicloj.plotje.api :as pj]))Tooltips
Pass :tooltip true to pj/options and every point gets a data-tooltip attribute holding its column values. A small embedded script renders the tooltip on hover β no extra setup. pj/lay-point and pj/lay-interval-h are the two marks that carry a tooltip, whether the text is the built-in one or a mapped column; a tooltip written on any other layer type is accepted and draws none, which Known Limitations records.
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
(pj/options {:title "Hover over a point for column values"
:tooltip true
:height 320}))Writing the tooltip yourself
The built-in text names the columns the layer drew and their values, which is right for exploring and wrong as soon as the numbers need presenting β a currency sign, a thousands separator, a percentage, 1,653,346 read as 1.7M, or a column the plot does not draw at all.
Rather than a formatting option per case, :tooltip is an aesthetic like any other, mapped to a column whose values are what each point says. Building that column is ordinary data work, with the whole language available.
(def sales
(tc/dataset {:month ["Jan" "Feb" "Mar" "Apr"]
:revenue [1653346 2410880 987654 3120500]
:margin [0.184 0.223 0.161 0.207]}))sales_unnamed [4 3]:
| :month | :revenue | :margin |
|---|---|---|
| Jan | 1653346 | 0.184 |
| Feb | 2410880 | 0.223 |
| Mar | 987654 | 0.161 |
| Apr | 3120500 | 0.207 |
The tooltip column is built with the same tools any other column is. A newline in the string breaks the line on the page.
(def sales-labelled
(tc/add-column sales :hover
#(map (fn [month revenue margin]
(str month "\n"
(format "%.1fM" (/ (double revenue) 1e6))
" at " (format "%.1f%%" (* 100.0 margin))))
(:month %) (:revenue %) (:margin %))))sales-labelled_unnamed [4 4]:
| :month | :revenue | :margin | :hover |
|---|---|---|---|
| Jan | 1653346 | 0.184 | Jan |
| 1.7M at 18.4% | |||
| Feb | 2410880 | 0.223 | Feb |
| 2.4M at 22.3% | |||
| Mar | 987654 | 0.161 | Mar |
| 1.0M at 16.1% | |||
| Apr | 3120500 | 0.207 | Apr |
| 3.1M at 20.7% |
Mapping that column is the whole of it:
(-> sales-labelled
(pj/lay-point :margin :revenue {:tooltip :hover})
(pj/options {:title "Hover for the month, revenue and margin"
:height 320}))Three things follow from :tooltip being an aesthetic rather than a plot option.
The month is in the tooltip and on neither axis. A tooltip column is built from whatever the dataset holds.
Writing it turns tooltips on.
{:tooltip true}inpj/optionsis the switch for the built-in text, and is not needed beside a mapping.It scopes like every mapping. On the pose it covers each layer; on one layer it covers that layer, and the nearer one wins.
A newline in the string breaks the line. A written string rather than a column says the same thing for every mark of the layer: {:tooltip "one reading per bar"}.
Tooltips with markup
A tooltip may hold hiccup instead of a string, for a label that carries a heading, an emphasis or a table rather than a line of text. A column of hiccup gives each mark its own markup, and a written hiccup vector gives every mark of the layer the same markup, exactly as a column of strings and a written string do.
(def sales-rich
(tc/add-column sales :hover
#(map (fn [month revenue margin]
[:div
[:b month]
[:br]
"revenue " [:code (format "%.1fM" (/ (double revenue) 1e6))]
[:br]
"margin " [:code (format "%.1f%%" (* 100.0 margin))]])
(:month %) (:revenue %) (:margin %))))sales-rich_unnamed [4 4]:
| :month | :revenue | :margin | :hover |
|---|---|---|---|
| Jan | 1653346 | 0.184 | [:div [:b Jan] [:br] revenue [:code 1.7M] [:br] margin [:code 18.4%]] |
| Feb | 2410880 | 0.223 | [:div [:b Feb] [:br] revenue [:code 2.4M] [:br] margin [:code 22.3%]] |
| Mar | 987654 | 0.161 | [:div [:b Mar] [:br] revenue [:code 1.0M] [:br] margin [:code 16.1%]] |
| Apr | 3120500 | 0.207 | [:div [:b Apr] [:br] revenue [:code 3.1M] [:br] margin [:code 20.7%]] |
The column holds hiccup vectors, one per row.
(-> sales-rich
(pj/lay-point :margin :revenue {:tooltip :hover})
(pj/options {:title "Hover for a formatted label"
:height 320}))A written hiccup vector covers the layer, the way a written string does:
(-> sales
(pj/lay-point :margin :revenue {:tooltip [:b "one reading per point"]})
(pj/options {:height 240}))A string tooltip stays text, so a string that happens to spell out a tag is shown as that text rather than rendered:
(-> sales
(pj/lay-point :margin :revenue {:tooltip "<b>not bold</b>"})
(pj/options {:height 240}))Brush selection
:brush true enables drag-to-select. While dragging, a shaded rectangle follows the cursor; on release, points inside keep full opacity and points outside dim to 0.15. A short drag (less than 3 CSS pixels each side β the unit a mouse event reports, not a drawing unit) clears the selection. Selection is keyed by row index, so it tracks the same rows across every panel in the pose.
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
(pj/options {:title "Drag a rectangle to highlight a region"
:brush true
:height 320}))Cross-panel linked highlighting
Because brush selection is keyed by data-row-idx (a stable integer attached to each rendered shape at extract time), the same selection lights up matching rows in every panel of a faceted pose. Drag in one species panel; the corresponding rows in the other two species panels respond immediately.
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width)
(pj/facet :species)
(pj/options {:title "Brush on one panel, see linked points in the others"
:brush true
:tooltip true
:height 320}))Interval (Gantt) tooltips
lay-interval-h participates in the same tooltip/brush system. Each rectangleβs tooltip names the lane, the start, the end, and the color label; on temporal axes the start and end are formatted as date strings rather than raw epoch-milliseconds.
(-> {:start [#inst "2024-01-01" #inst "2024-02-15" #inst "2024-04-01"
#inst "2024-05-10" #inst "2024-06-20"]
:end [#inst "2024-03-15" #inst "2024-04-20" #inst "2024-06-30"
#inst "2024-07-10" #inst "2024-08-30"]
:task ["Design" "Build" "Test" "Deploy" "Document"]
:team ["UX" "Eng" "QA" "Eng" "UX"]}
(pj/lay-interval-h :start :task {:x-end :end :color :team})
(pj/options {:title "Hover for task: start -> end, team"
:tooltip true
:height 320}))Custom wrapper: save as PNG
Browsers can serialize an SVG element to a PNG via a <canvas> round-trip. The wrapper below adds a βSave PNGβ button that renders the current SVG into a canvas and triggers a download.
(let [plot-svg (pj/plot
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
(pj/options {:title "Click 'Save PNG' to download the rendering"
:height 320})))
attrs (second plot-svg)
body (drop 2 plot-svg)
plot-id (str "pj-png-" (System/nanoTime))
btn-id (str plot-id "-save")
script (str "document.getElementById('" btn-id "').addEventListener('click',function(){"
"var svg=document.getElementById('" plot-id "');"
"var w=svg.clientWidth||" (or (:width attrs) 600) ","
"h=svg.clientHeight||" (or (:height attrs) 400) ";"
"var data=new XMLSerializer().serializeToString(svg);"
"var img=new Image();"
"img.onload=function(){"
"var c=document.createElement('canvas');c.width=w;c.height=h;"
"c.getContext('2d').drawImage(img,0,0,w,h);"
"var a=document.createElement('a');"
"a.href=c.toDataURL('image/png');a.download='plotje.png';"
"document.body.appendChild(a);a.click();a.remove();};"
"img.src='data:image/svg+xml;base64,'+btoa(unescape(encodeURIComponent(data)));"
"});")]
(kind/hiccup
[:div
[:button {:id btn-id
:style "margin-bottom:6px; padding:4px 12px;"}
"Save PNG"]
(into [:svg (assoc attrs :id plot-id)] body)
[:script script]]))Formats that draw interaction
A tooltip and a brush are behaviours a browser runs over the figure, so they reach a reader only where the figure is SVG. A plot rendered to :bufimg, or saved as a PNG, carries neither: a raster image is finished before anyone hovers over it, and has no elements for a script to attach to.
Asking for one on such a format is reported. One message covers the plot, naming both requests where both were written, the format they were asked of, and the formats that answer them. The figure still renders β what is dropped is the request, not the plot:
(with-out-str
(-> (rdatasets/datasets-iris)
(pj/lay-point :sepal-length :sepal-width {:color :species})
(pj/options {:tooltip true :brush true})
(pj/plot {:format :bufimg})))"Warning: :tooltip and :brush asked for, and the :bufimg format draws no interaction. The formats that do: :svg. The request is accepted and draws nothing.\n"pj/save to a PNG reports the same thing, since a PNG file is that format written to disk. Saving the SVG keeps the attributes on the marks, and a browser opening the file runs the script that reads them.
See Also
- Options and Scopes β where
:tooltip,:brush, and other plot options live in the pose
Whatβs Next
- Timelines β where most of the example data came from
- Customization β titles, palettes, scales
- Architecture β how the pipeline produces the SVG