Skip to content

Instantly share code, notes, and snippets.

@r-suen
Last active November 4, 2017 11:22
Show Gist options
  • Save r-suen/458e219937050ccc266a65fb5b020f3b to your computer and use it in GitHub Desktop.
Save r-suen/458e219937050ccc266a65fb5b020f3b to your computer and use it in GitHub Desktop.
Snap to Grid Ticks

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 dynamic grid unit sizes. The grid size is based on D3 ticks. This is visualized by the grid unit size continuously increasing or decreasing and then suddenly resetting with 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 Ticks</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)
.call(zoom.transform, d3.zoomIdentity);
renderCircles();
document.addEventListener("keydown", keydown, false);
var xticks = [],
yticks = [],
xticks_step,
yticks_step;
function drawGrid() {
ctxGrid.strokeStyle = style.grid["line-color"];
ctxGrid.lineWidth = style.grid["line-width"] / transform.k;
ctxGrid.beginPath();
for (let i = 0; i <= xticks.length; i++) {
ctxGrid.moveTo(xticks[i], transform.invertY(0));
ctxGrid.lineTo(xticks[i], transform.invertY(height));
}
for (let i = 0; i <= yticks.length; i++) {
ctxGrid.moveTo(transform.invertX(0), yticks[i]);
ctxGrid.lineTo(transform.invertX(width), yticks[i]);
}
ctxGrid.stroke();
}
function renderGrid() {
ctxGrid.save();
ctxGrid.clearRect(0, 0, width, height);
ctxGrid.translate(transform.x, transform.y);
ctxGrid.scale(transform.k, transform.k);
drawGrid();
ctxGrid.restore();
}
function renderCircles() {
ctxCircles.save();
ctxCircles.clearRect(0, 0, width, height);
ctxCircles.translate(transform.x, transform.y);
ctxCircles.scale(transform.k, transform.k);
drawCircle();
ctxCircles.restore();
}
function drawCircle() {
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) {
d.x = Math.round(d.x / xticks_step) * xticks_step;
d.y = Math.round(d.y / yticks_step) * yticks_step;
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) {
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;
xticks = d3.ticks(transform.invertX(0), transform.invertX(width), width / grid);
yticks = d3.ticks(transform.invertY(0), transform.invertY(height), height / grid);
xticks_step = xticks[1] - xticks[0];
yticks_step = yticks[1] - yticks[0];
renderGrid();
renderCircles();
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment