Skip to content

Instantly share code, notes, and snippets.

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

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 relative intervals of 20; a grid unit size. This is visualized by the grid translating and not 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 Relative</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 renderGrid() {
ctxGrid.save();
ctxGrid.clearRect(0, 0, width, height);
ctxGrid.translate(transform.x, transform.y);
drawGrid();
ctxGrid.restore();
}
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 - Math.floor(transform.x / grid) * grid, 0 - Math.ceil(transform.y / grid) * grid);
ctxGrid.lineTo(i * grid - Math.floor(transform.x / grid) * grid, height - Math.floor(transform.y / grid) * grid);
}
for (let i = 0; i <= height / grid; i++) {
ctxGrid.moveTo(0 - Math.ceil(transform.x / grid) * grid, i * grid - Math.floor(transform.y / grid) * grid);
ctxGrid.lineTo(width - Math.floor(transform.x / grid) * grid, i * grid - Math.floor(transform.y / grid) * 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() {
let unit = grid / transform.k;
selected.forEach(function(d) {
d.x = Math.round(d.x / unit) * unit;
d.y = Math.round(d.y / unit) * unit;
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