Example: Cars Bubble Chart

import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm';
import * as d3legend from 'https://cdn.jsdelivr.net/npm/d3-svg-legend/+esm';
import * as CP from 'https://cdn.jsdelivr.net/npm/counterpoint-vis@latest/dist/counterpoint-vis.es.js';

// Declare the chart dimensions and margins.
let width = 400;
let height = 400;
const marginTop = 60;
const marginRight = 60;
const marginBottom = 60;
const marginLeft = 60;

const StartYear = 1970;
  
// Create the SVG container and add axes/legends with D3
function createAxes(scales, sizeScale, colorScale, xEncoding, yEncoding, sizeEncoding, colorEncoding) {
  const svg = d3.select('#axes');
  if (svg.empty()) return;
  let rect = d3.select('#chart-container').node().getBoundingClientRect();
  svg.attr('width', rect.width).attr('height', rect.height);
  svg.selectAll('*').remove();

  // We portray the axes as log scales when needed and convert the extents
  let xScale, yScale;
  xScale = d3.scaleLinear(scales.xScale.domain(), scales.xScale.range());
  yScale = d3.scaleLinear(scales.yScale.domain(), scales.yScale.range());

  svg
    .append('g')
    .style('font-size', '10pt')
    .attr('transform', `translate(0,${height - marginBottom})`)
    .call(d3.axisBottom(xScale).tickArguments([5, ',.6~s']))
    .call((g) =>
      g
        .append('text')
        .attr('x', width - marginRight)
        .attr('y', 40)
        .attr('fill', 'currentColor')
        .attr('text-anchor', 'end')
        .text(`${xEncoding} →`)
    );
  svg
    .append('g')
    .selectAll('line')
    .data(xScale.ticks(5))
    .join('line')
    .attr('x1', (d) => xScale(d))
    .attr('x2', (d) => xScale(d))
    .attr('y1', marginTop)
    .attr('y2', height - marginBottom)
    .attr('stroke', '#f0f0f0')
    .attr('stroke-width', '1');

  // Add the y-axis.
  svg
    .append('g')
    .style('font-size', '10pt')
    .attr('transform', `translate(${marginLeft},0)`)
    .call(d3.axisLeft(yScale).tickArguments([5, ',.6~s']))
    .call((g) =>
      g
        .append('text')
        .attr('x', -marginLeft)
        .attr('y', marginTop - 20)
        .attr('fill', 'currentColor')
        .attr('text-anchor', 'start')
        .text(`↑ ${yEncoding}`)
    );
  svg
    .append('g')
    .attr('class', 'grid-lines')
    .selectAll('line')
    .data(yScale.ticks(5))
    .join('line')
    .attr('x1', marginLeft)
    .attr('x2', width - marginRight)
    .attr('y1', (d) => yScale(d))
    .attr('y2', (d) => yScale(d))
    .attr('stroke', '#f0f0f0')
    .attr('stroke-width', '1');

  // add legends

  svg
    .append('g')
    .attr('id', 'sizeLegend')
    .style('font-family', 'sans-serif')
    .style('font-size', '10pt')
    .attr('transform', `translate(${width},${marginTop})`);

  var sizeLegend = d3legend
    .legendSize()
    .cells(4)
    .shape('circle')
    .title(sizeEncoding)
    .labelFormat(d3.format(',.2r'))
    .shapePadding(10)
    .scale(sizeScale);

  svg.select('#sizeLegend').call(sizeLegend);
  svg
    .selectAll('#sizeLegend .swatch')
    .style('fill', colorScale(colorScale.domain()[0]));

  svg
    .append('g')
    .attr('id', 'colorLegend')
    .style('font-family', 'sans-serif')
    .style('font-size', '10pt')
    .attr('transform', `translate(${width},${marginTop + 200})`);

  var colorLegend = d3legend
    .legendColor()
    .shape('circle')
    .title(colorEncoding)
    .shapeRadius(5)
    .shapePadding(5)
    .scale(colorScale);

  svg.select('#colorLegend').call(colorLegend);
}

// Rendering function that accesses Counterpoint properties
function drawCanvas(canvas, bubbleSet) {
  const ctx = canvas.getContext('2d');

  // scaling for 2x devices
  ctx.resetTransform();
  ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
  ctx.clearRect(0, 0, width, height);

  // clip to chart bounds
  ctx.beginPath();
  ctx.rect(marginLeft, marginTop, width - marginLeft - marginRight, height - marginTop - marginBottom);
  ctx.clip();
  ctx.closePath();
  ctx.textAlign = 'center';
  ctx.textBaseline = 'bottom';
  ctx.lineWidth = 1;

  bubbleSet.stage.forEach((mark) => {
    ctx.save();
    ctx.beginPath();
    let { radius, x, y, alpha, color } = mark.get();
    ctx.ellipse(x, y, radius, radius, 0, 0, 2 * Math.PI);
    ctx.globalAlpha = alpha * 0.1;
    ctx.fillStyle = color;
    ctx.fill();
    ctx.globalAlpha = alpha * 0.8;
    ctx.strokeStyle = color;
    ctx.stroke();
    ctx.closePath();
    ctx.restore();
  });
}

export function loadCarsBubble() {
  // load dataset
  d3.json('https://cdn.jsdelivr.net/npm/vega-datasets@2/data/cars.json').then(
    (data) => {
      let canvas = document.getElementById('content');
      let slider = document.getElementById('year-slider');
      if (!canvas) return;

      data.forEach((d) => {
        d.Year = parseInt(d.Year.slice(0, d.Year.indexOf('-')));
        d.id = `${d.Year} ${d.Name}`;
      });

      // by declaring the year as an Attribute, we can listen for changes and react to them
      let currentYear = new CP.Attribute(StartYear);
      const xEncoding = 'Acceleration';
      const yEncoding = 'Miles_per_Gallon';
      const sizeEncoding = 'Weight_in_lbs';
      const colorEncoding = 'Origin';
      width = canvas.offsetWidth;
      height = canvas.offsetHeight;
      canvas.width = canvas.offsetWidth * window.devicePixelRatio;
      canvas.height = canvas.offsetHeight * window.devicePixelRatio;

      // for bubble size and color, use d3 scales
      let sizeScale = d3
        .scaleSqrt()
        .domain(d3.extent(data, (d) => d[sizeEncoding]))
        .range([1, 10])
        .nice();
      let colorScale = d3
        .scaleOrdinal(d3.schemeCategory10)
        .domain(Array.from(new Set(data.map((d) => d[colorEncoding]))).sort());

      let xExtent = d3.extent(data, (d) => d[xEncoding]);
      let yExtent = d3.extent(data, (d) => d[yEncoding]);

      // Counterpoint scales handle x and y transforms (and are easy to add zoom)
      let scales = (new CP.Scales()
        .xDomain([xExtent[0] - 0.05 * (xExtent[1] - xExtent[0]), xExtent[1] + 0.05 * (xExtent[1] - xExtent[0])])
        .yDomain([yExtent[0] - 0.05 * (yExtent[1] - yExtent[0]), yExtent[1] + 0.05 * (yExtent[1] - yExtent[0])])
        .xRange([marginLeft, width - marginRight])
        .yRange([height - marginBottom, marginTop]));
      createAxes(scales, sizeScale, colorScale, xEncoding, yEncoding, sizeEncoding, colorEncoding);

      // create a render group with all bubbles
      let bubbleSet = new CP.MarkRenderGroup()
        .configure({ animationDuration: 1000 })
        .configureStaging({
          initialize: (mark) => mark.setAttr('alpha', 0.0).setAttr('radius', 0),
          enter: (mark) =>
            mark
              .animateTo('alpha', 1.0)
              .animateTo('radius', (mark) => mark.represented[sizeEncoding])
              .wait(['alpha', 'radius']),
          exit: (mark) =>
            mark
              .animateTo('alpha', 0.0)
              .animateTo('radius', 0)
              .wait(['alpha', 'radius']),
        });

      // add any bubbles that are from cars less than or equal to this year
      function updateToYear(year) {
        data.forEach((d) => {
          if (d.Year > year && bubbleSet.has(d.id)) {
            bubbleSet.delete(d.id);
          } else if (d.Year <= year && !bubbleSet.has(d.id)) {
            bubbleSet.addMark(
              bubbleSet.stage.get(d.id) ??
                new CP.Mark(d.id, {
                  x: {
                    valueFn: (mark) => mark.represented[xEncoding],
                    transform: scales.xScale,
                  },
                  y: {
                    valueFn: (mark) => mark.represented[yEncoding],
                    transform: scales.yScale,
                  },
                  radius: {
                    value: 0,
                    transform: (v) => Math.max(0, sizeScale(v)),
                  },
                  color: {
                    valueFn: (mark) => mark.represented[colorEncoding],
                    transform: colorScale,
                  },
                  alpha: 0.0,
                }).representing(d)
            );
          }
        });
      }

      updateToYear(currentYear.get());

      // the ticker runs every frame and redraws only when needed
      let ticker = new CP.Ticker([currentYear, bubbleSet, scales]).onChange(
        () => drawCanvas(canvas, bubbleSet)
      );

      // respond to year slider selections
      slider.addEventListener('input', (e) => {
        let newValue = e.target.value;

        if (newValue != currentYear) {
          currentYear.set(parseInt(newValue));
          updateToYear(currentYear.get());

          slider.value = Math.round(currentYear.get());
          let yearLabel = document.getElementById('year-text');
          if (!!yearLabel) yearLabel.innerText = slider.value;
        }
      });
    }
  );
}

loadCarsBubble();

results matching ""

    No results matching ""