Zoomable Binned Plot Example

An interactive scatter plot with binned aggregation supporting pan and zoom. Click and drag to pan, use the scroll wheel to zoom. This example demonstrates how binning works and, when you disable stable bins, how changes to the bin alignment can affect the perceived density.

Vega JSON Specification <>

{
  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "description": "An interactive scatter plot example with binned aggregation supporting pan and zoom.",
  "width": 500,
  "height": 300,
  "padding": {"top": 10, "left": 40, "bottom": 20, "right": 10},
  "autosize": "none",
  "config": {
    "axis": {
      "domain": false,
      "tickSize": 3,
      "tickColor": "#888",
      "labelFont": "Monaco, Courier New"
    }
  },
  "signals": [
    {"name": "margin", "value": 20},
    {"name": "stable", "value": true, "bind": {"input": "checkbox"}},
    {
      "name": "hover",
      "on": [
        {"events": "*:pointerover", "encode": "hover"},
        {"events": "*:pointerout", "encode": "leave"},
        {"events": "*:pointerdown", "encode": "select"},
        {"events": "*:pointerup", "encode": "release"}
      ]
    },
    {"name": "xoffset", "update": "-(height + padding.bottom)"},
    {"name": "yoffset", "update": "-(width + padding.left)"},
    {"name": "xrange", "update": "[0, width]"},
    {"name": "yrange", "update": "[height, 0]"},
    {
      "name": "down",
      "value": null,
      "on": [
        {"events": "touchend", "update": "null"},
        {"events": "pointerdown, touchstart", "update": "xy()"}
      ]
    },
    {
      "name": "xcur",
      "value": null,
      "on": [
        {"events": "pointerdown, touchstart, touchend", "update": "slice(xdom)"}
      ]
    },
    {
      "name": "ycur",
      "value": null,
      "on": [
        {"events": "pointerdown, touchstart, touchend", "update": "slice(ydom)"}
      ]
    },
    {
      "name": "delta",
      "value": [0, 0],
      "on": [
        {
          "events": [
            {
              "source": "window",
              "type": "pointermove",
              "consume": true,
              "between": [
                {"type": "pointerdown"},
                {"source": "window", "type": "pointerup"}
              ]
            },
            {
              "type": "touchmove",
              "consume": true,
              "filter": "event.touches.length === 1"
            }
          ],
          "update": "down ? [down[0]-x(), y()-down[1]] : [0,0]"
        }
      ]
    },
    {
      "name": "anchor",
      "value": [0, 0],
      "on": [
        {
          "events": "wheel",
          "update": "[invert('xscale', x()), invert('yscale', y())]"
        },
        {
          "events": {
            "type": "touchstart",
            "filter": "event.touches.length===2"
          },
          "update": "[(xdom[0] + xdom[1]) / 2, (ydom[0] + ydom[1]) / 2]"
        }
      ]
    },
    {
      "name": "zoom",
      "value": 1,
      "on": [
        {
          "events": "wheel!",
          "force": true,
          "update": "pow(1.001, event.deltaY * pow(16, event.deltaMode))"
        },
        {
          "events": {"signal": "dist2"},
          "force": true,
          "update": "dist1 / dist2"
        }
      ]
    },
    {
      "name": "dist1",
      "value": 0,
      "on": [
        {
          "events": {
            "type": "touchstart",
            "filter": "event.touches.length===2"
          },
          "update": "pinchDistance(event)"
        },
        {"events": {"signal": "dist2"}, "update": "dist2"}
      ]
    },
    {
      "name": "dist2",
      "value": 0,
      "on": [
        {
          "events": {
            "type": "touchmove",
            "consume": true,
            "filter": "event.touches.length===2"
          },
          "update": "pinchDistance(event)"
        }
      ]
    },
    {
      "name": "xdom",
      "update": "slice(xext)",
      "on": [
        {
          "events": {"signal": "delta"},
          "update": "[xcur[0] + span(xcur) * delta[0] / width, xcur[1] + span(xcur) * delta[0] / width]"
        },
        {
          "events": {"signal": "zoom"},
          "update": "[anchor[0] + (xdom[0] - anchor[0]) * zoom, anchor[0] + (xdom[1] - anchor[0]) * zoom]"
        }
      ]
    },
    {
      "name": "ydom",
      "update": "slice(yext)",
      "on": [
        {
          "events": {"signal": "delta"},
          "update": "[ycur[0] + span(ycur) * delta[1] / height, ycur[1] + span(ycur) * delta[1] / height]"
        },
        {
          "events": {"signal": "zoom"},
          "update": "[anchor[1] + (ydom[0] - anchor[1]) * zoom, anchor[1] + (ydom[1] - anchor[1]) * zoom]"
        }
      ]
    },
    {"name": "size", "update": "clamp(20 / span(xdom), 1, 1000)"}
  ],
  "data": [
    {
      "name": "points",
      "url": "data/normal-2d.json",
      "transform": [
        {"type": "extent", "field": "u", "signal": "xext"},
        {"type": "extent", "field": "v", "signal": "yext"}
      ]
    },
    {
      "name": "density",
      "source": "points",
      "transform": [
        {"type": "extent", "field": "u", "signal": "xextf"},
        {"type": "extent", "field": "v", "signal": "yextf"},
        {
          "type": "bin",
          "field": "u",
          "extent": {"signal": "stable ? xextf : xdom"},
          "as": ["ustart", "uend"],
          "maxbins": 12,
          "nice": {"signal": "stable"}
        },
        {
          "type": "bin",
          "field": "v",
          "extent": {"signal": "stable ? yextf : ydom"},
          "as": ["vstart", "vend"],
          "maxbins": 12,
          "nice": {"signal": "stable"}
        },
        {
          "type": "aggregate",
          "groupby": ["ustart", "uend", "vstart", "vend"],
          "as": ["count"]
        }
      ]
    }
  ],
  "scales": [
    {
      "name": "xscale",
      "zero": false,
      "domain": {"signal": "xdom"},
      "range": {"signal": "xrange"}
    },
    {
      "name": "yscale",
      "zero": false,
      "domain": {"signal": "ydom"},
      "range": {"signal": "yrange"}
    },
    {
      "name": "color",
      "domain": {"field": "count", "data": "density"},
      "type": "linear",
      "range": "heatmap",
      "interpolate": "hcl",
      "zero": false
    }
  ],
  "axes": [
    {"scale": "xscale", "orient": "top", "offset": {"signal": "xoffset"}},
    {"scale": "yscale", "orient": "right", "offset": {"signal": "yoffset"}}
  ],
  "marks": [
    {
      "type": "rect",
      "from": {"data": "density"},
      "clip": true,
      "encode": {
        "enter": {
          "fill": {"scale": "color", "field": "count"}
        },
        "update": {
          "x": {"scale": "xscale", "field": "ustart"},
          "x2": {"scale": "xscale", "field": "uend"},
          "y": {"scale": "yscale", "field": "vstart"},
          "y2": {"scale": "yscale", "field": "vend"},
          "size": {"signal": "size"}
        }
      }
    },
    {
      "type": "symbol",
      "from": {"data": "points"},
      "clip": true,
      "encode": {
        "enter": {
          "fillOpacity": {"value": 0.6},
          "fill": {"value": "black"}
        },
        "update": {
          "x": {"scale": "xscale", "field": "u"},
          "y": {"scale": "yscale", "field": "v"},
          "size": {"signal": "size"}
        }
      }
    }
  ]
}