Selection Transform as Text/Title

Viewed 345

How can I display a transform over selected points as text within a title or subtitle?

{
  "$schema": "https://vega.github.io/schema/vega-lite/v4.json",
  "data": {"url": "data/stocks.csv"},
  "transform": [{"filter": "datum.symbol==='GOOG'"}],
  "width": 800,
  "title": {
    "text": "Google's stock price over time.",
    "subtitle": "selected average: ???"
  },
  "selection": {
    "interval": {
      "type": "interval",
      "encodings": ["x"]
    }
  },
  "mark": "point",
  "encoding": {
    "x": {"field": "date", "type": "temporal"},
    "y": {"field": "price", "type": "quantitative"}
  }
}

A sum transformation does not lend itself to any graphical/visual representations. An idea is to have it as a selection tooltip, but that is not possible. So I settle for a subtitle.

2 Answers

I don't think there is an easy way to insert a computed value within a subtitle, but you can achieve the same effect using a layer chart with a text mark that contains a computed value.

For example (open in editor):

{
  "data": {"url": "data/stocks.csv"},
  "transform": [{"filter": "datum.symbol==='GOOG'"}],
  "width": 800,
  "title": {"text": "Google's stock price over time."},
  "layer": [
    {
      "transform": [
        {"filter": {"selection": "interval"}},
        {"aggregate": [{"op": "sum", "field": "price", "as": "total_price"}]}
      ],
      "mark": "text",
      "encoding": {
        "x": {"value": 400},
        "y": {"value": -10},
        "text": {"field": "total_price", "type": "quantitative"}
      }
    },
    {
      "mark": "point",
      "encoding": {
        "x": {"field": "date", "type": "temporal"},
        "y": {"field": "price", "type": "quantitative"}
      },
      "selection": {"interval": {"type": "interval", "encodings": ["x"]}}
    }
  ]
}

enter image description here

Note that the text layer has x and y encoding values specified in pixels from the upper left of the chart.

I was going to say there are two possible approaches, but then @jakevdp post his answer, so I guess there are now three.

  1. 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.

    1. 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.
    2. 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:

final result: binned temporal bar graph with selection sum as subtitle

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

Related