Skip to content

Instantly share code, notes, and snippets.

@pbeshai
Last active August 4, 2021 11:37
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save pbeshai/8008075f9ce771ee8be39e8c38907570 to your computer and use it in GitHub Desktop.
Save pbeshai/8008075f9ce771ee8be39e8c38907570 to your computer and use it in GitHub Desktop.
Lasso with d3 v4 and Canvas
license: mit
height: 500
border: no

Lasso with d3 v4 and canvas

Example of a lasso technique using data drawn on canvas (or really anywhere) and an SVG interaction layer for drawing the lasso.

Click and drag to select nodes.

Excuse my laziness in the dist files. I coded lasso.js and script.js and then ran buble to compile them for the block.

<!DOCTYPE html>
<title>Lasso in d3 v4 with Canvas</title>
<body>
<script src='https://d3js.org/d3.v4.min.js'></script>
<script src='lasso.dist.js'></script>
<script src='script.dist.js'></script>
</body>
// import * as d3 from 'd3';
function polygonToPath(polygon) {
return ("M" + (polygon.map(function (d) { return d.join(','); }).join('L')));
}
function distance(pt1, pt2) {
return Math.sqrt(Math.pow( (pt2[0] - pt1[0]), 2 ) + Math.pow( (pt2[1] - pt1[1]), 2 ));
}
// export default function lasso() {
function lasso() {
var dispatch = d3.dispatch('start', 'end');
// distance last point has to be to first point before it auto closes when mouse is released
var closeDistance = 75;
function lasso(root) {
// append a <g> with a rect
var g = root.append('g').attr('class', 'lasso-group');
var bbox = root.node().getBoundingClientRect();
var area = g
.append('rect')
.attr('width', bbox.width)
.attr('height', bbox.height)
.attr('fill', 'tomato')
.attr('opacity', 0);
var drag = d3
.drag()
.on('start', handleDragStart)
.on('drag', handleDrag)
.on('end', handleDragEnd);
area.call(drag);
var lassoPolygon;
var lassoPath;
var closePath;
function handleDragStart() {
lassoPolygon = [d3.mouse(this)];
if (lassoPath) {
lassoPath.remove();
}
lassoPath = g
.append('path')
.attr('fill', '#0bb')
.attr('fill-opacity', 0.1)
.attr('stroke', '#0bb')
.attr('stroke-dasharray', '3, 3');
closePath = g
.append('line')
.attr('x2', lassoPolygon[0][0])
.attr('y2', lassoPolygon[0][1])
.attr('stroke', '#0bb')
.attr('stroke-dasharray', '3, 3')
.attr('opacity', 0);
dispatch.call('start', lasso, lassoPolygon);
}
function handleDrag() {
var point = d3.mouse(this);
lassoPolygon.push(point);
lassoPath.attr('d', polygonToPath(lassoPolygon));
// indicate if we are within closing distance
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
closePath
.attr('x1', point[0])
.attr('y1', point[1])
.attr('opacity', 1);
} else {
closePath.attr('opacity', 0);
}
}
function handleDragEnd() {
// remove the close path
closePath.remove();
closePath = null;
// succesfully closed
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
lassoPath.attr('d', polygonToPath(lassoPolygon) + 'Z');
dispatch.call('end', lasso, lassoPolygon);
// otherwise cancel
} else {
lassoPath.remove();
lassoPath = null;
lassoPolygon = null;
}
}
lasso.reset = function () {
if (lassoPath) {
lassoPath.remove();
lassoPath = null;
}
lassoPolygon = null;
if (closePath) {
closePath.remove();
closePath = null;
}
};
}
lasso.on = function (type, callback) {
dispatch.on(type, callback);
return lasso;
};
return lasso;
}
// import * as d3 from 'd3';
function polygonToPath(polygon) {
return `M${polygon.map(d => d.join(',')).join('L')}`;
}
function distance(pt1, pt2) {
return Math.sqrt((pt2[0] - pt1[0]) ** 2 + (pt2[1] - pt1[1]) ** 2);
}
// export default function lasso() {
function lasso() {
const dispatch = d3.dispatch('start', 'end');
// distance last point has to be to first point before it auto closes when mouse is released
let closeDistance = 75;
function lasso(root) {
// append a <g> with a rect
const g = root.append('g').attr('class', 'lasso-group');
const bbox = root.node().getBoundingClientRect();
const area = g
.append('rect')
.attr('width', bbox.width)
.attr('height', bbox.height)
.attr('fill', 'tomato')
.attr('opacity', 0);
const drag = d3
.drag()
.on('start', handleDragStart)
.on('drag', handleDrag)
.on('end', handleDragEnd);
area.call(drag);
let lassoPolygon;
let lassoPath;
let closePath;
function handleDragStart() {
lassoPolygon = [d3.mouse(this)];
if (lassoPath) {
lassoPath.remove();
}
lassoPath = g
.append('path')
.attr('fill', '#0bb')
.attr('fill-opacity', 0.1)
.attr('stroke', '#0bb')
.attr('stroke-dasharray', '3, 3');
closePath = g
.append('line')
.attr('x2', lassoPolygon[0][0])
.attr('y2', lassoPolygon[0][1])
.attr('stroke', '#0bb')
.attr('stroke-dasharray', '3, 3')
.attr('opacity', 0);
dispatch.call('start', lasso, lassoPolygon);
}
function handleDrag() {
const point = d3.mouse(this);
lassoPolygon.push(point);
lassoPath.attr('d', polygonToPath(lassoPolygon));
// indicate if we are within closing distance
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
closePath
.attr('x1', point[0])
.attr('y1', point[1])
.attr('opacity', 1);
} else {
closePath.attr('opacity', 0);
}
}
function handleDragEnd() {
// remove the close path
closePath.remove();
closePath = null;
// succesfully closed
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
lassoPath.attr('d', polygonToPath(lassoPolygon) + 'Z');
dispatch.call('end', lasso, lassoPolygon);
// otherwise cancel
} else {
lassoPath.remove();
lassoPath = null;
lassoPolygon = null;
}
}
lasso.reset = () => {
if (lassoPath) {
lassoPath.remove();
lassoPath = null;
}
lassoPolygon = null;
if (closePath) {
closePath.remove();
closePath = null;
}
};
}
lasso.on = (type, callback) => {
dispatch.on(type, callback);
return lasso;
};
return lasso;
}
// general size parameters for the vis
var width = window.innerWidth;
var height = window.innerHeight;
var padding = { top: 40, right: 40, bottom: 40, left: 40 };
var plotAreaWidth = width - padding.left - padding.right;
var plotAreaHeight = height - padding.top - padding.bottom;
// when a lasso is completed, filter to the points within the lasso polygon
function handleLassoEnd(lassoPolygon) {
var selectedPoints = points.filter(function (d) {
// note we have to undo any transforms done to the x and y to match with the
// coordinate system in the svg.
var x = d.x + padding.left;
var y = d.y + padding.top;
return d3.polygonContains(lassoPolygon, [x, y]);
});
updateSelectedPoints(selectedPoints);
}
// reset selected points when starting a new polygon
function handleLassoStart(lassoPolygon) {
updateSelectedPoints([]);
}
// when we have selected points, update the colors and redraw
function updateSelectedPoints(selectedPoints) {
// if no selected points, reset to all tomato
if (!selectedPoints.length) {
// reset all
points.forEach(function (d) {
d.color = 'tomato';
});
// otherwise gray out selected and color selected black
} else {
points.forEach(function (d) {
d.color = '#eee';
});
selectedPoints.forEach(function (d) {
d.color = '#000';
});
}
// redraw with new colors
drawPoints();
}
// helper to actually draw points on the canvas
function drawPoints() {
var context = canvas.node().getContext('2d');
context.save();
context.clearRect(0, 0, width, height);
context.translate(padding.left, padding.top);
// draw each point as a rectangle
for (var i = 0; i < points.length; ++i) {
var point = points[i];
// draw circles
context.fillStyle = point.color;
context.beginPath();
context.arc(point.x, point.y, point.r, 0, 2 * Math.PI);
context.fill();
}
context.restore();
}
// create a container with position relative to handle our canvas layer
// and our SVG interaction layer
var visRoot = d3
.select(document.body)
.append('div')
.attr('class', 'vis-root')
.style('position', 'relative');
// main canvas to draw on
var screenScale = window.devicePixelRatio || 1;
var canvas = visRoot
.append('canvas')
.attr('width', width * screenScale)
.attr('height', height * screenScale)
.style('width', (width + "px"))
.style('height', (height + "px"));
canvas
.node()
.getContext('2d')
.scale(screenScale, screenScale);
// add in an interaction layer as an SVG
var interactionSvg = visRoot
.append('svg')
.attr('width', width)
.attr('height', height)
.style('position', 'absolute')
.style('top', 0)
.style('left', 0);
// attach lasso to interaction SVG
var lassoInstance = lasso()
.on('end', handleLassoEnd)
.on('start', handleLassoStart);
interactionSvg.call(lassoInstance);
// make up fake data
var points = d3.range(500).map(function () { return ({
x: Math.random() * plotAreaWidth,
y: Math.random() * plotAreaHeight,
r: Math.random() * 5 + 2,
color: 'tomato',
}); });
// initial draw of points
drawPoints();
// general size parameters for the vis
const width = window.innerWidth;
const height = window.innerHeight;
const padding = { top: 40, right: 40, bottom: 40, left: 40 };
const plotAreaWidth = width - padding.left - padding.right;
const plotAreaHeight = height - padding.top - padding.bottom;
// when a lasso is completed, filter to the points within the lasso polygon
function handleLassoEnd(lassoPolygon) {
const selectedPoints = points.filter(d => {
// note we have to undo any transforms done to the x and y to match with the
// coordinate system in the svg.
const x = d.x + padding.left;
const y = d.y + padding.top;
return d3.polygonContains(lassoPolygon, [x, y]);
});
updateSelectedPoints(selectedPoints);
}
// reset selected points when starting a new polygon
function handleLassoStart(lassoPolygon) {
updateSelectedPoints([]);
}
// when we have selected points, update the colors and redraw
function updateSelectedPoints(selectedPoints) {
// if no selected points, reset to all tomato
if (!selectedPoints.length) {
// reset all
points.forEach(d => {
d.color = 'tomato';
});
// otherwise gray out selected and color selected black
} else {
points.forEach(d => {
d.color = '#eee';
});
selectedPoints.forEach(d => {
d.color = '#000';
});
}
// redraw with new colors
drawPoints();
}
// helper to actually draw points on the canvas
function drawPoints() {
const context = canvas.node().getContext('2d');
context.save();
context.clearRect(0, 0, width, height);
context.translate(padding.left, padding.top);
// draw each point as a rectangle
for (let i = 0; i < points.length; ++i) {
const point = points[i];
// draw circles
context.fillStyle = point.color;
context.beginPath();
context.arc(point.x, point.y, point.r, 0, 2 * Math.PI);
context.fill();
}
context.restore();
}
// create a container with position relative to handle our canvas layer
// and our SVG interaction layer
const visRoot = d3
.select(document.body)
.append('div')
.attr('class', 'vis-root')
.style('position', 'relative');
// main canvas to draw on
const screenScale = window.devicePixelRatio || 1;
const canvas = visRoot
.append('canvas')
.attr('width', width * screenScale)
.attr('height', height * screenScale)
.style('width', `${width}px`)
.style('height', `${height}px`);
canvas
.node()
.getContext('2d')
.scale(screenScale, screenScale);
// add in an interaction layer as an SVG
const interactionSvg = visRoot
.append('svg')
.attr('width', width)
.attr('height', height)
.style('position', 'absolute')
.style('top', 0)
.style('left', 0);
// attach lasso to interaction SVG
const lassoInstance = lasso()
.on('end', handleLassoEnd)
.on('start', handleLassoStart);
interactionSvg.call(lassoInstance);
// make up fake data
const points = d3.range(500).map(() => ({
x: Math.random() * plotAreaWidth,
y: Math.random() * plotAreaHeight,
r: Math.random() * 5 + 2,
color: 'tomato',
}));
// initial draw of points
drawPoints();
@AndriiShtoiko
Copy link

AndriiShtoiko commented Jun 2, 2019

What is the diff between .dist. and non-.dist. files? guess dist is 'compiled' and 'prod-ready'.

@pbeshai
Copy link
Author

pbeshai commented Jun 2, 2019

Yea the dist files are just transpiled to work in older browsers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment