Skip to content

Instantly share code, notes, and snippets.

@r-suen
Last active October 30, 2017 20:16
Show Gist options
  • Save r-suen/d127fbdf2ea55155e5d892093f3ee61f to your computer and use it in GitHub Desktop.
Save r-suen/d127fbdf2ea55155e5d892093f3ee61f to your computer and use it in GitHub Desktop.
Snap to Grid Absolute

Drag and snap circles to a grid within a zoomable canvas. The zoomable canvas adds complexity in converting coordinate positions between the circles and mouse pointer. It also introduces new behavior to address with the zoom. Should the grid scale and position also transform with zoom?

In this implementation, the snap behavior repositions the circles to screen xy coordinates at absolute intervals of 20; a grid unit size. This is visualized by the grid being unchanged by the canvas zoom behavior.

Drag and drop selected circles with the mouse.
Pan and zoom the canvas with the mouse.
Press spacebar to toggle the brush.

<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Snap to Grid Absolute</title>
<script src='https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js'></script>
</head>
<style>
body {
margin: 0;
overflow: hidden;
}
#container > * {
position: absolute;
}
.hidden {
display: none;
pointer-events: none;
}
</style>
<body>
<div id="container">
</div>
<script>
var width = window.innerWidth,
height = window.innerHeight;
var dpr = window.devicePixelRatio;
var canvasGrid = d3.select('#container').append('canvas')
.attr('id', 'grid')
.attr('width', width * dpr)
.attr('height', height * dpr)
.style('width', width + 'px')
.style('height', height + 'px');
var ctxGrid = canvasGrid.node().getContext('2d');
ctxGrid.scale(dpr, dpr);
var canvasCircles = d3.select('#container').append('canvas')
.attr('id', 'circles')
.attr('width', width * dpr)
.attr('height', height * dpr)
.style('width', width + 'px')
.style('height', height + 'px');
var ctxCircles = canvasCircles.node().getContext('2d');
ctxCircles.scale(dpr, dpr);
var svg = d3.select('#container').append('svg')
.attr('width', width)
.attr('height', height)
.classed('hidden', true);
var g = svg.append('g')
.attr('class', 'brush');
var style = {
"grid": {
"line-color": "#ccc",
"line-width": 1
},
"circle": {
"fill-color": "red",
},
"selected": {
"fill-color": "blue"
}
};
var random = Math.random;
var circles = d3.range(100).map(function(i) {
return {
index: i,
x: random() * width,
y: random() * height
}
});
var grid = 20,
radius = 8,
spaceKey = false,
selected = [],
offsetx = 0,
offsety = 0,
transform = d3.zoomIdentity;
var drag = d3.drag()
.subject(dragsubject)
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended)
.on('start.render drag.render end.render', renderCircles);
var brush = d3.brush()
.on('brush', brushed)
.on('end', brushended);
var zoom = d3.zoom()
.scaleExtent([1, 1 << 10])
.on('zoom', zoomed);
canvasCircles
.call(drag)
.call(zoom);
drawGrid();
renderCircles();
document.addEventListener("keydown", keydown, false);
function drawGrid() {
ctxGrid.strokeStyle = style.grid["line-color"];
ctxGrid.lineWidth = style.grid["line-width"];
ctxGrid.beginPath();
for (let i = 0; i <= width / grid; i++) {
ctxGrid.moveTo(i * grid, 0);
ctxGrid.lineTo(i * grid, height);
}
for (let i = 0; i <= height / grid; i++) {
ctxGrid.moveTo(0, i * grid);
ctxGrid.lineTo(width, i * grid);
}
ctxGrid.stroke();
}
function renderCircles() {
ctxCircles.save();
ctxCircles.clearRect(0, 0, width, height);
ctxCircles.translate(transform.x, transform.y);
ctxCircles.scale(transform.k, transform.k);
drawCircles();
ctxCircles.restore();
}
function drawCircles() {
ctxCircles.beginPath();
for (let i = 0, n = circles.length, circle; i < n; ++i) {
circle = circles[i];
ctxCircles.beginPath();
ctxCircles.arc(circle.x, circle.y, radius / transform.k, 0, 2 * Math.PI)
ctxCircles.fillStyle = (circle.active) ? style.selected["fill-color"] : style.circle["fill-color"]
ctxCircles.fill();
}
}
function dragsubject() {
let x = transform.invertX(d3.event.x),
y = transform.invertY(d3.event.y),
dx,
dy;
for (let i = circles.length - 1, circle; i >= 0; --i) {
circle = circles[i];
dx = x - circle.x;
dy = y - circle.y;
if (dx * dx + dy * dy < radius * radius / transform.k / transform.k) {
circle.x = transform.applyX(circle.x);
circle.y = transform.applyY(circle.y);
return circle;
}
}
}
function dragstarted() {
d3.event.subject.x = transform.invertX(d3.event.subject.x);
d3.event.subject.y = transform.invertY(d3.event.subject.y);
circles.splice(circles.indexOf(d3.event.subject), 1);
circles.push(d3.event.subject);
d3.event.subject.active = true;
if (selected.map(d => d.index).indexOf(d3.event.subject.index) == -1) {
selected.push(d3.event.subject);
}
}
function dragged() {
offsetx = transform.invertX(d3.event.x) - (d3.event.subject.x);
offsety = transform.invertY(d3.event.y) - (d3.event.subject.y);
selected.forEach(function(d) {
d.x += offsetx;
d.y += offsety;
});
}
function dragended() {
selected.forEach(function(d) {
let x = Math.round(transform.applyX(d.x) / grid) * grid,
y = Math.round(transform.applyY(d.y) / grid) * grid;
d.x = transform.invertX(x);
d.y = transform.invertY(y);
d.active = false;
});
d3.event.subject.active = false;
selected = [];
offsetx = 0;
offsety = 0;
}
function brushed() {
if (d3.event.selection == null) return;
let extent = d3.event.selection;
extent[0][0] = transform.invertX(extent[0][0]);
extent[0][1] = transform.invertY(extent[0][1]);
extent[1][0] = transform.invertX(extent[1][0]);
extent[1][1] = transform.invertY(extent[1][1]);
selected = [];
for (let i = circles.length - 1, circle, x, y; i >= 0; --i) {
circle = circles[i];
circle.active = false;
if (circle.x >= extent[0][0] && circle.x <= extent[1][0]) {
if (circle.y >= extent[0][1] && circle.y <= extent[1][1]) {
circle.active = true;
selected.push(circle);
}
}
}
renderCircles();
}
function brushended() {
if (d3.event.selection == null) return;
d3.event.selection[0] = transform.apply(d3.event.selection[0]);
d3.event.selection[1] = transform.apply(d3.event.selection[1]);
}
function keydown(e) {
if (e.keyCode === 32) { //spacebar key
d3.select('svg').classed('hidden', spaceKey);
spaceKey = !spaceKey;
if (spaceKey) {
g.call(brush)
} else {
g.call(brush.move, null);
}
}
}
function zoomed() {
transform = d3.event.transform;
renderCircles();
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment