Search code examples
vega-lite

Synchronized Selections


Can someone show me how to use signals to synchronize two selections within two vconcat plots which share the same data?

  1. The selection of the first plot is bound to its scales (interactive) and tied to the x encoding.
  2. The selection of the second plot is tied to the x encoding.

At no time should the selections differ.

This is known as overview+detail display. Unlike the scale domain method it is bidirectional. See the performance plots of Chrome's developer tools for an example. You can use the scale domain example as a baseline:

{
  "$schema": "https://vega.github.io/schema/vega-lite/v4.json",
  "data": {"url": "data/sp500.csv"},
  "vconcat": [
    {
      "selection": {
        "s1": {
          "type": "interval",
          "encodings": ["x"],
          "bind": "scales"
        }
      },
      "width": 480,
      "mark": "area",
      "encoding": {
        "x": {
          "field": "date",
          "type": "temporal",
          "axis": {"title": ""}
        },
        "y": {"field": "price", "type": "quantitative"}
      }
    },
    {
      "selection": {
        "s2": {
          "type": "interval",
          "encodings": ["x"]
        }
      },
      "width": 480,
      "height": 60,
      "mark": "area",
      "encoding": {
        "x": {"field": "date", "type": "temporal"},
        "y": {
          "field": "price",
          "type": "quantitative",
          "axis": {"tickCount": 3, "grid": false}
        }
      }
    }
  ]
}

Edit: Doing this in JavaScript does not work:

vegaEmbed('#vis', vegalitespec).then(function(result) {
    result.view.addDataListener("s1_store", async function(name, value) {
        if (Object.is(value[0], result.view.data("s2_store")[0]))
            return;
        await result.view.data("s2_store", value).runAsync();
    });
    result.view.addDataListener("s2_store", async function(name, value) {
        if (Object.is(value[0], result.view.data("s1_store")[0]))
            return;
        await result.view.data("s1_store", value).runAsync();
    });
}).catch(console.error);

Edit: This may not be possible due to the nested scopes of vconcat. I can wire a one-way binding as with scale domains; the reverse remains elusive. Still, a few approaches remain untested. Such as creating a nested signal handler in either JavaScript or Vega (assuming outer scopes are accessible), or patching VegaLite to push the "s1_x" signal to the outer scope.

vegaEmbed('#vis', vegalitespec3).then(async function(result) {
  result.view.addSignalListener("s1", function(name, value){
    result.view.signal("s2_date", value.date);
    result.view.runAsync();
  });
                
  result.view.addSignalListener("s2", function(name, value){
    // todo: handle bounds / null
    if (!value.date)
      return;
    // This does not work with individual plots. Maybe it has
    // been rewired as writable in vconcat plots. Result: nope.
    //result.view.signal("s1", value);
    // Thanks to nested scopes, "s1_x" is not available.
    //result.view.signal("s1_x", value.date.map(result.view.scale("concat_1_x")));
    // How about editing the runtime dataflow object? Result: nope.
    //result.view._runtime.subcontext[1].signals.s1_x.value = value.date.map(result.view.scale("concat_1_x"));
    result.view.runAsync();
  });

}).catch(console.error);

Solution

  • Solved:

    1. Patch the generated Vega to push the "s1_x" signal to the outer scope.
    2. Add signal listeners to update the selections of each respective plot.
    3. Use the scales to convert from data-space to plot-space.
    4. Use a barrier boolean to prevent infinite looping.
    %%javascript
    window.vegalitespec3 = {
      "$schema": "https://vega.github.io/schema/vega-lite/v4.json",
      "data": {"url": vegaDatasets["sp500.csv"].url},
      "vconcat": [
        {
          "selection": {
            "s2": {
              "type": "interval",
              "encodings": ["x"],
              "bind": "scales"
            }
          },
          "width": 480,
          "mark": "area",
          "encoding": {
            "x": {
              "field": "date",
              "type": "temporal",
              "axis": {"title": ""}
            },
            "y": {"field": "price", "type": "quantitative"}
          }
        },
        {
          "selection": {
            "s1": {
              "type": "interval",
              "encodings": ["x"]
            }
          },
          "width": 480,
          "height": 60,
          "mark": "area",
          "encoding": {
            "x": {"field": "date", "type": "temporal"},
            "y": {
              "field": "price",
              "type": "quantitative",
              "axis": {"tickCount": 3, "grid": false}
            }
          }
        }
      ]
    }
    
    %%HTML
    <!DOCTYPE html>
    <html>
    <head>
        <style>.vega-embed.has-actions {width:90%}</style>
    
        <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
        <script src="https://cdn.jsdelivr.net/npm/vega-lite@4"></script>
        <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
        <script src="https://cdn.jsdelivr.net/npm/vega-datasets"></script>
    </head>
    <body>
    
    <div id="vis"></div>
    
    <script type="text/javascript">
        vegaEmbed( '#vis', vegalitespec3, {"patch": spec => {
            spec.signals.push({"name": "s1_x", "value": []});
            var s_s1_x = spec.marks.find(i => i.name == "concat_1_group")
                             .signals.find(i => i.name == "s1_x");
            delete s_s1_x.value;
            s_s1_x.push = "outer";
            return spec;
        }}).then(function(result) {
            result.locals = {"barrier": true};
            
            result.view.addSignalListener("s1", function(name, value){
                result.locals.barrier = !result.locals.barrier;
                if (!result.locals.barrier)
                    return;
                result.view.signal("s2_date", value.date);
                result.view.runAsync();
            });
                    
            result.view.addSignalListener("s2", function(name, value){
                result.locals.barrier = !result.locals.barrier;
                if (!result.locals.barrier)
                    return;
                value = value.date ? value.date.map(result.view.scale("concat_1_x")) : [];
                result.view.signal("s1_x", value);
                result.view.runAsync();
            });
        }).catch(console.error);
    </script>
    </body>
    </html>