I was going to say there are two possible approaches, but then @jakevdp post his answer, so I guess there are now three.
Use an ExprRef in title.subtitle. While that isn't explicitly supported in the documentation it doesn't make sense that only title.text would support an ExprRef. So I gave it a shot, and it works. Though the Vega editor will still raise schema validation warnings. I have a more involved example below using binned data.
-
- Filter and aggregate the selection. This will modify the current data stream and Vega-Lite doesn't support more than one data stream (unless using layers), so you'll have to patch the generated Vega.
- Access the text mark of the title using
title.encode.subtitle.update.text. Once again, necessary to patch the generated Vega. (This was on the assumption that a subtitle couldn't be an ExprRef).
The neat thing about (2) is that it allows adding a text mark to the selection mark, so the text could be made to follow the selection. That said that's possible with @jakevdp's answer as well, and his answer is much simpler.
JavaScript loader ensuring that nothing runs out-of-order:
%%javascript
function loadjs(ls, o) {
let i = 0;
let b = JSON.parse(document.querySelector('#jupyter-config-data').text)["baseUrl"];
ls = ls.reduce((a,l) => {
if (!l.path)
return a;
let p = l.path;
if (l.local)
p = "/" + [b,"/files",p].map(s => s.replace(/^\/+|\/+$/, "")).filter(i => i).join("/");
if (document.querySelector("script[src='" + p + "']"))
return a;
return [...a, p];
}, [])
function load() {
if (i >= ls.length)
return o();
let t = document.createElement("script");
[t.type, t.src, t.onload] = ["text/javascript", ls[i], (i+1>=ls.length) ? o : load];
i = i+1;
document.head.appendChild(t);
}
return load();
}
window.loadjs = loadjs
JavaScript loader for the JupyterLab cell as an IPython magic:
import IPython
import IPython.core.magic as ipymagic
@ipymagic.magics_class
class LoadJSMagics(ipymagic.Magics):
@ipymagic.cell_magic
def loadjs(self, line, cell):
js = f"loadjs({line}, () => {{\n{cell}\n}});"
return IPython.display.Javascript(js)
IPython.get_ipython().register_magics(LoadJSMagics)
Jinja templating for JupyterLab cells as an IPython magic:
import jinja2
import IPython
import IPython.core.magic as ipymagic
@ipymagic.magics_class
class JinjaMagics(ipymagic.Magics):
@ipymagic.cell_magic
def jinja(self, line, cell):
t = jinja2.Template(cell)
r = t.render({k:v for k,v in self.shell.user_ns.items() if k not in self.shell.user_ns_hidden})
IPython.get_ipython().run_cell(r)
#d = getattr(IPython.display, line.strip(), IPython.display.display)
#return d(r)
IPython.get_ipython().register_magics(JinjaMagics)
Generate some example temporal data in Pandas:
import pandas as pd
import numpy as np
c1 = np.random.randint(1,6, size=15)
c2 = pd.date_range(start="2021-01-01",end="2021-01-15")
df = pd.DataFrame({"day": c2, "value": c1})
df = df.drop([2, 5,6,7,13])
df
The necessary imports:
# Convert the Pandas dataframe to a format suitable for Vega-Lite.
import altair
# Tag Vega-Embed div's with UUIDs ensuring the correct div is targeted.
import uuid
import json
vega_libs =\
[ {"path": "https://cdn.jsdelivr.net/npm/vega@5"}
, {"path": "/libs/vega-lite@4-fix.js", "local": True}
, {"path": "https://cdn.jsdelivr.net/npm/vega-embed@6"}
]
The Vega-Lite:
s =\
{ "title":
{ "text": "Daily Counts"
, "subtitle": {"expr": "selectionSum(data('interval_store'), data('data_0'))"}
, "subtitleFont": "monospace"
}
, "mark": "bar"
, "encoding":
{ "x":
{ "type": "temporal"
, "bin": "binned"
, "field": "start"
, "axis": { "tickCount": "day" }
}
, "x2": {"field": "end"}
, "y": {"type": "quantitative", "field": "value"}
}
, "selection":
{ "interval":
{ "type": "interval"
, "encodings": ["x"]
}
}
, "transform":
[ # Convert 'day' from 'string' to timestamp ('number')
{"calculate": "toDate(datum.day)", "as": "day"}
# Provide "start" and "end" as Date objects to match the
# type of temporal domain objects
, {"calculate": "timeOffset('hours', datum.day, -12)", "as": "start"}
, {"calculate": "timeOffset('hours', datum.day, 12)", "as": "end"}
]
, "height": 250
, "width": "container"
, "$schema": "https://vega.github.io/schema/vega-lite/v4.json"
, "config": {"customFormatTypes": "True"}
, "data": altair.utils.data.to_values(df)
}
And finally running the Vega-Lite:
%%jinja
%%loadjs {{json.dumps(vega_libs)}}
{% set visid = uuid.uuid4() %}
element.innerHTML = `
<style>.vega-embed.has-actions {width:90%}</style>
<div id="vis-{{visid}}"></div>
`
var spec = {{json.dumps(s)}}
vega.expressionFunction("selectionSum", function(selection, data) {
var view = this.context.dataflow;
function intersects(i1, i2) {
return (i1[1] >= i2[0] && i1[0] <= i2[1]);
}
function cmp_interval_pt(i0, i1, p) {
if (i1 < p)
return -1;
if (i0 > p)
return 1;
return 0;
}
function cmp_primitive(a,b) {
if (a < b)
return -1
if (a > b)
return 1;
return 0;
}
function bisect_left(l, v, fc=cmp_primitive) {
return _bisect_left(l, 0, l.length, v, fc);
}
function _bisect_left(l, l0, l1, v, fc) {
if (l1 <= l0)
return l0;
var i = Math.floor((l0+l1)/2);
var c = fc(l[i], v);
if (c < 0)
l0 = i + 1;
else
l1 = i;
return _bisect_left(l, l0, l1, v, fc);
}
function bisect_right(l, v, fc=cmp_primitive) {
return _bisect_right(l, 0, l.length, v, fc);
}
function _bisect_right(l, l0, l1, v, fc) {
if (l1 <= l0)
return l0;
var i = Math.floor((l0+l1)/2);
var c = fc(l[i], v);
if (c <= 0)
l0 = i + 1;
else
l1 = i;
return _bisect_right(l, l0, l1, v, fc);
}
function cmp_data(lv, v) {
return cmp_interval_pt(lv.start, lv.end, v);
}
function constant_len_digits(s,l) {
return " ".repeat(Math.max(0, l-s.toString().length)) + s
}
if (selection.length) {
var r = selection[0]["values"][0];
var d0 = bisect_left(data, r[0], cmp_data);
var d1 = bisect_right(data, r[1], cmp_data);
var s = data.slice(d0,d1).reduce((a,v)=>a+v.value, 0);
}
else
var s = 0
return `selected: ${constant_len_digits(s,3)}`;
});
vegaEmbed('#vis-{{visid}}', spec).then(function(result) {
}).catch(console.error);
And the result:

Note that when it comes to coloring and selecting binned regions, you'll have to calculate the intersections yourself.