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();