Skip to content

Instantly share code, notes, and snippets.

@r-suen
Last active November 3, 2017 20:04
Show Gist options
  • Save r-suen/05cc014b7977adff8817b7b22c00f40b to your computer and use it in GitHub Desktop.
Save r-suen/05cc014b7977adff8817b7b22c00f40b to your computer and use it in GitHub Desktop.
Snap to Grid Scale

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 the scaled grid unit size. This is visualized by the grid translating and scaling 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 Scale</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);
renderGrid();
renderCircles();
document.addEventListener("keydown", keydown, false);
function drawGrid() {
ctxGrid.strokeStyle = style.grid["line-color"];
ctxGrid.lineWidth = style.grid["line-width"] / transform.k;
ctxGrid.beginPath();
for (let i = 0; i <= width / grid / transform.k; i++) {
ctxGrid.moveTo(i * grid + Math.ceil(transform.invertX(0) / grid) * grid, transform.invertY(0));
ctxGrid.lineTo(i * grid + Math.ceil(transform.invertX(0) / grid) * grid, transform.invertY(height));
}
for (let i = 0; i <= height / grid / transform.k; i++) {
ctxGrid.moveTo(transform.invertX(0), i * grid + Math.ceil(transform.invertY(0) / grid) * grid);
ctxGrid.lineTo(transform.invertX(width), i * grid + Math.ceil(transform.invertY(0) / grid) * grid);
}
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 / grid) * grid;
d.y = Math.round(d.y / grid) * grid;
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;
renderGrid();
renderCircles();
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment