Skip to content

Instantly share code, notes, and snippets.

@boeric
Last active October 22, 2023 12:23
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save boeric/aa80b0048b7e39dd71c8fbe958d1b1d4 to your computer and use it in GitHub Desktop.
Save boeric/aa80b0048b7e39dd71c8fbe958d1b1d4 to your computer and use it in GitHub Desktop.
Embedded Canvas in SVG

Embedded Canvas in SVG

The visualization demonstrates the use of an embedded Canvas in an SVG element, and how to generate a correlated array of numbers.

An embedded canvas may make sense when a large number of data points needs to be generated (for example in a scatter plot), that otherwise would overwhelm the DOM.

The Gist uses the array-correl npm module for generating correlated pairs of numbers (using its generate method), and for inspecting the generated array (using its inspect method) to obtain the actual pearson correlation coefficient of the array. At small sample sizes, the actual pearson correlation will differ from the desired correlation, but at large sample sizes it aligns very closely to the desired correlation.

The output of the generate method is an array of correlated pairs of numbers. Each of these array elements are rendered as a dot in the visualization, using the pair's first and second indexes as x and y coordinates.

In this demo, up to 50,000 elements can be generated and visualized in the embedded canvas with no impact to the DOM (other than creating the canvas). Mouse hit detection of data elements is performed with a 4x4 pixel zone under the current mouse position. While the example is admittedly a bit of a Rube Goldberg kind of project, it nevertheless attempts to demonstrate the following concepts:

  • Creation of an embedded canvas
  • Generation of normally distributed numbers (using array-correl)
  • Generation of correlated sets of numbers (using array-correl)
  • Computation of correlation coefficient (using array-correl)
  • Drawing of large number of data points in the canvas, while controlling opacity
  • Mouse data point hit detection in the canvas (even at 50K elements on the canvas)
  • D3 scales, axes, element creation, event handling and update pattern
  • Dynamic reconfiguration of a D3 axis
  • Usage of HTLM5 elements such as radio button, range and checkbox
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Canvas in SVG</title>
<!-- Author: Bo Ericsson, https://www.linkedin.com/in/boeric00/ -->
<link rel=stylesheet type=text/css href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/2.3.2/css/bootstrap.min.css" media="all">
<style>
body {
margin: 0px;
padding: 10px;
width: 920px;
height: 400px;
}
.well {
margin-bottom: 10px;
padding: 8px 12px;
}
h4 {
margin: 0px
}
svg {
border: 1px solid #E6AAAA; /*#e3e3e3 = bootstrap .well look*/;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0,0,0,0.05);
display: inline;
vertical-align: top;
}
svg .bg {
fill: #F5F5F5
}
button.btn {
width: 100%;
margin-bottom: 10px;
}
pre {
background-color: white;
border: none;
font-size: 10px;
overflow: scroll;
}
.control {
width: 295px;
overflow-x: scroll;
padding-left: 20px;
}
#container {
overflow: hidden;
}
label {
font-size: 12px;
margin-top: 15px;
}
input[type=range] {
display: block;
width: 100%;
}
input[type=checkbox],
input[type=radio] {
vertical-align: top;
margin-left: 5px;
}
text {
font-size: 12px;
font-family: "Arial"
}
.axis text {
font-family: "Arial";
/*font-size: 12px;*/
}
.axis path,
.axis line {
fill: none;
stroke: gray;
shape-rendering: crispEdges;
}
.sampleOption label {
display: inline-block;
}
.dataBar {
fill: lightgray;
}
</style>
</head>
<body>
<div class="well">
<h4>Canvas in SVG (with up to 50K data points)</h4>
</div>
<div id="container"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/array-correl@1.0.1/src/index.js"></script>
<script>
'use strict';
const { generate, inspect, version } = this.arrayCorrel;
console.log('version', version);
// Dimensions
var svgDim = { width: 600, height: 350 };
var margin = { x: 10, y: 10 };
var canvasDim = { width: svgDim.height - margin.x * 2, height: svgDim.height - margin.y * 2 };
var groupWidth = 200;
var barHeight = 18;
var barPadding = 3;
// Samples
var samplesOptions = [
{ value: 0, samples: 5, display: "5", pointScaleDomain: [0, 3], opacity: 1.0 },
{ value: 1, samples: 50, display: "50", pointScaleDomain: [0, 5], opacity: 0.8 },
{ value: 2, samples: 500, display: "500", pointScaleDomain: [0, 10], opacity: 0.5 },
{ value: 3, samples: 5000, display: "5K", pointScaleDomain: [0, 100], opacity: 0.2 },
{ value: 4, samples: 50000, display: "50K", pointScaleDomain: [0, 1000], opacity: 0.1 }
];
var samplesIdx = 2;
var samplesDefault = samplesOptions[samplesIdx].samples;
var samplesCurrent = samplesDefault;
// Other defaults
var opacityDefault = 0.7;
var opacityCurrent = opacityDefault;
var correlationDefault = 0.6;
var correlationCurrent = correlationDefault;
// SVG variables
var xScale;
var yScale;
var pearsonCorrelation;
var pointBarGroup;
var pointAxis;
var pointAxisGroup;
var pointScale;
// Other
var container = d3.select("#container");
var opacityRangeControl;
var opacityLabel;
var canvasColor = false;
var format = d3.format(",d");
var posY = 0;
var d3randomNormal = d3.random.normal(0, 1);
var useD3randomNormal = false;
var data = [];
// Create svg and group
var svg = container.append("svg")
.attr("width", svgDim.width + "px")
.attr("height", svgDim.height + "px")
.append("g");
// Background
svg.append("rect")
.attr("x", svgDim.x)
.attr("y", svgDim.y)
.attr("width", svgDim.width)
.attr("height", svgDim.height)
.attr("class", "bg");
// Add foreign object to svg
// https://gist.github.com/mbostock/1424037
var foreignObject = svg.append("foreignObject")
.attr("x", margin.x)
.attr("y", margin.y)
.attr("width", canvasDim.width)
.attr("height", canvasDim.height);
// Add embedded body to foreign object
var foBody = foreignObject.append("xhtml:body")
.style("margin", "0px")
.style("padding", "0px")
.style("background-color", "none")
.style("width", canvasDim.width + "px")
.style("height", canvasDim.height + "px")
.style("border", "1px solid lightgray");
// Add embedded canvas to embedded body
var canvas = foBody.append("canvas")
.attr("x", 0)
.attr("y", 0)
.attr("width", canvasDim.width)
.attr("height", canvasDim.height)
.style("cursor", "crosshair")
.on("mousemove", function() {
var pos = {
x: d3.mouse(this)[0],
y: d3.mouse(this)[1]
};
pos.minX = pos.x - 2;
pos.maxX = pos.x + 2;
pos.minY = pos.y - 2;
pos.maxY = pos.y + 2;
// hit detection
var matches = data.filter(function(d) {
if (d.x >= pos.minX && d.x <= pos.maxX &&
d.y >= pos.minY && d.y <= pos.maxY) {
return d;
}
});
var out = "Points under mouse: " + matches.length + "\n";
matches.forEach(function(d) {
out += "Id: " + d.id + ", x: " + d.x + ", y: " + d.y + ", color: " + d.color + "\n";
});
// output mouse hit details
d3.select("pre").text(out);
// cap point bar to current domain + 5% (which will slightly overflow pointBar scale)
var hits = matches.length;
var max = samplesOptions[samplesIdx].pointScaleDomain[1];
if (hits > max) {
hits = max * 1.05;
};
// update svg
pointBarGroup.selectAll("rect")
.data([hits])
.attr("width", function(d) { return pointScale(d) });
});
// Get drawing context of canvas
var ctx = canvas.node().getContext("2d");
// Add svg elements
// Add svg identifier
svg.append("text")
.attr({ "x": svgDim.width - 180, "y": 20 })
.text("SVG with embedded Canvas");
// Create group to hold viz elements
var group = svg.append("g")
.attr("transform", "translate(370, 50)");
// Add data extent bars
group.append("text")
.attr({ x: 0, y: posY })
.text("Data extent");
posY += 15;
var extentScale = d3.scale.linear()
.domain([-5, 5])
.range([0, groupWidth])
var extentAxis = d3.svg.axis()
.scale(extentScale)
.orient("bottom")
.outerTickSize([-(barHeight + barPadding) * 2])
var initialData = [
{ name: "x", min: -1, max: 3 },
{ name: "y", min: -4, max: 4 }
];
var extentBarGroup = group.append("g")
.attr("transform", "translate(0," + posY + ")");
var extentBarUpdateSel = extentBarGroup.selectAll("rect")
.data(initialData);
extentBarUpdateSel
.enter().append("rect")
.attr("class", "dataBar")
.attr("x", function(d) { return extentScale(d.min) })
.attr("y", function(d, i) { return i * (barHeight + 3) })
.attr("width", function(d) { return extentScale(d.max) - extentScale(d.min) })
.attr("height", barHeight);
extentBarUpdateSel
.enter().append("text")
.attr("x", -13)
.attr("y", function(d, i) { return i * (barHeight +3) + 12 })
.text(function(d) { return d.name });
extentBarGroup.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + 2 * (barHeight + barPadding) + ")")
.call(extentAxis);
posY += 2 * (barHeight + barPadding) + 50;
// Add computed correlation bar
group.append("text")
.attr({ x: 0, y: posY })
.text("Computed correlation");
posY += 15;
var correlScale = d3.scale.linear()
.domain([0, 1])
.range([0, groupWidth]);
var correlAxis = d3.svg.axis()
.scale(correlScale)
.orient("bottom")
.outerTickSize([-(barHeight + 3)])
.tickValues([0, 0.2, 0.4, 0.6, 0.8, 1]);
var correlBarGroup = group.append("g")
.attr("transform", "translate(0," + posY + ")");
var correlBar = correlBarGroup.selectAll("rect")
.data([1])
.enter().append("rect")
.attr("class", "dataBar")
.attr("x", 0)
.attr("y", 0)
.attr("width", function(d) { return correlScale(d) })
.attr("height", barHeight);
correlBarGroup.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + (barHeight + barPadding) + ")")
.call(correlAxis);
posY += (barHeight + barPadding) + 50;
// Add mouse hit bar
group.append("text")
.attr({ x: 0, y: posY })
.text("Current points under mouse");
posY += 15;
pointScale = d3.scale.linear()
.domain(samplesOptions[samplesIdx].pointScaleDomain)
.range([0, groupWidth]);
pointAxis = d3.svg.axis()
.scale(pointScale)
.orient("bottom")
.outerTickSize([-(barHeight + 3)])
.ticks([5]);
pointBarGroup = group.append("g")
.attr("transform", "translate(0," + posY + ")");
var pointBar = pointBarGroup.selectAll("rect")
.data([0])
.enter().append("rect")
.attr("class", "dataBar")
.attr("x", 0)
.attr("y", 0)
.attr("width", function(d) { return pointScale(d) })
.attr("height", barHeight);
pointAxisGroup = pointBarGroup.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + (barHeight + barPadding) + ")")
.call(pointAxis);
function updatePointScale() {
pointScale.domain(samplesOptions[samplesIdx].pointScaleDomain);
pointAxisGroup.call(pointAxis);
}
// Add ui controls
// Add update button
var controlPanel = container.append("div")
.style("display", "inline-block")
.attr("class", "control");
controlPanel.append("button")
.attr("class", "btn btn-primary btn-small")
.text("Generate New Data")
.on("click", function() {
this.blur();
generateData();
updateViz();
});
// Add samples radio buttons
var newSamplesLabel = controlPanel.append("label")
.text("Number of samples");
controlPanel.append("div")
.style("margin-top", "-20px")
.selectAll(".sampleOption")
.data(samplesOptions)
.enter().append("span")
.style("margin-right", "25px")
.append("label")
.attr("class", "sampleOption")
.style("display", "inline-block")
.text(function(d) { return d.display })
.append("input")
.attr({ type: "radio", class: "radio", name: "samples" })
.attr("value", function(d, i) { return d.value })
.property("checked", function(d) {
return d.samples === samplesCurrent ? true : false;
})
.on("change", function() {
// Get index to samplesOptions
samplesIdx = +d3.select('input[name="samples"]:checked').node().value;
// Update samples count
samplesCurrent = samplesOptions[samplesIdx].samples;
// Update opacity variable and opacity range control
opacityCurrent = samplesOptions[samplesIdx].opacity;
opacityRangeControl.property("value", Math.round(opacityCurrent * 100));
opacityLabel.text("Opacity (currently " + Math.round((opacityCurrent * 100)) + "% )");
// Update viz
updatePointScale();
generateData();
updateViz();
});
// Add opacity range control
opacityLabel = controlPanel.append("label")
.text("Opacity (currently " + (opacityCurrent * 100) + "% )");
opacityRangeControl = controlPanel.append("input")
.attr({ "type": "range", "min": 0, "max": 100, "step": 1 })
.attr("value", Math.round(opacityCurrent * 100))
.on("input", function() {
var value = d3.select(this).property("value");
opacityCurrent = value / 100;
opacityLabel.text("Opacity (currently " + Math.round((opacityCurrent * 100)) + "% )");
updateViz();
});
// Add correlation range control
var correlationLabel = controlPanel.append("label")
.text("Target correlation (currently " + correlationCurrent + ")");
controlPanel.append("input")
.attr({ "type": "range", "min": 0, "max": 100, "step": 1 })
.attr("value", Math.round(correlationDefault * 100))
.on("input", function() {
var value = d3.select(this).property("value");
correlationCurrent = value / 100;
correlationLabel.text("Target correlation (currently " + correlationCurrent + ")");
generateData();
updateViz();
});
// Add color checkbox
controlPanel.append("label")
.text("Use color")
.attr("for", "checkbox")
.append("input")
.attr("type", "checkbox")
.attr("name", "checkbox")
.property("checked", false)
.on("change", function() {
canvasColor = d3.select(this).property("checked");
generateData();
updateViz();
});
// Add container for mouseover hit detection list
container.append("pre");
// Data generator
function generateData() {
// clear data array
data = [];
xScale = d3.scale.linear()
.domain([-4, 4])
.rangeRound([0, canvasDim.width]);
yScale = d3.scale.linear()
.domain([-4, 4])
.rangeRound([canvasDim.height, 0]);
var color = d3.scale.category20b();
// Generate correlated data points
const array = generate(samplesCurrent, correlationCurrent);
array.forEach((d, id) => {
const { x: xValue, y: yValue } = d;
var colorValue = canvasColor ? color(d) : "black";
var point = {
x: xScale(xValue),
y: yScale(yValue),
id,
color: colorValue
};
data.push(point);
});
var xArr = data.map(function(d) { return d.x });
var yArr = data.map(function(d) { return d.y });
pearsonCorrelation = inspect(array).r;
}
function updateViz() {
// Update canvas
// Clear canvas
ctx.clearRect(0, 0, canvasDim.width, canvasDim.height);
// Draw identifier
ctx.globalAlpha = 1;
ctx.fillStyle = "black";
ctx.font = "12px Arial";
ctx.fillText("Canvas with " + format(samplesCurrent) + " elements",
canvasDim.width - 170, canvasDim.height - 20);
// Set opacity for data elements
ctx.globalAlpha = opacityCurrent;
// Draw the data
data.forEach(function(d, i) {
ctx.beginPath();
ctx.arc(d.x, d.y, 2, 0, 2 * Math.PI, true);
ctx.fillStyle = d.color;
ctx.closePath();
ctx.fill();
})
// Update svg
// Compute extents
var xExtent = d3.extent(data, function(d) { return xScale.invert(d.x); });
var yExtent = d3.extent(data, function(d) { return yScale.invert(d.y); });
var extents = [
{ name: "xDim", min: xExtent[0], max: xExtent[1] },
{ name: "yDim", min: yExtent[0], max: yExtent[1] }
];
// Compute mean
// var xMean = d3.mean(data, function(d) { return xScale.invert(d.x) });
// var yMean = d3.mean(data, function(d) { return yScale.invert(d.y) });
// Update extent bars
extentBarGroup.selectAll("rect")
.data(extents)
.attr("x", function(d) { return extentScale(d.min) })
.attr("width", function(d) { return extentScale(d.max) - extentScale(d.min) });
// Update correlation bar
correlBar = correlBarGroup.selectAll("rect")
.data([Math.abs(pearsonCorrelation)])
.attr("width", function(d) { return correlScale(d) });
}
// Initial update
generateData();
updateViz();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment