Skip to content

Instantly share code, notes, and snippets.

@eweitnauer
Last active December 26, 2015 10:29
Show Gist options
  • Save eweitnauer/7136812 to your computer and use it in GitHub Desktop.
Save eweitnauer/7136812 to your computer and use it in GitHub Desktop.
Transform Behavior

Translate the view by dragging with the mouse or one finger. Rotate, scale and translate the view using two fingers at once.

This example is based on the transform behavior, which is in turn based on the mtouch events.

Mtouch events allows treating mouse events and touch events the same way. It supports the events "touch", "release", "tap", "dbltap", "hold", "drag" and "mdrag". The "mdrag" event is dispatched per mousemove/touchmove DOM event and can contain multiple changed touches at once, while all the other events are dispatched per touch.

<!doctype html>
<meta charset="utf-8">
<title>Transform Behavior</title>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script src="mtouch-events.js"></script>
<script src="transform-behavior.js"></script>
<style> .overlay { fill: none; pointer-events: all; } </style>
<body>
<script>
var width = 960
,height = 500;
var randomX = d3.random.normal(width / 2, 80),
randomY = d3.random.normal(height / 2, 80);
var data = d3.range(250).map(function() {
return [randomX(), randomY()];
});
var tg = transform_behavior()
.on("transform", transform)
var mtouch = mtouch_events()
.call(tg);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.call(mtouch)
.append("g");
svg.selectAll("circle")
.data(data)
.enter().append("circle")
.attr("r", 5)
.attr("transform", function(d) { return "translate(" + d + ")"; });
svg.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height);
function transform(d) {
// it is very important for performance on mobile devices to use a transition with duration 0,
// instead of directly setting the attributes. This is because the transition will be called
// inside an animationFrame and only as often as the browser can actually update the view!
svg.transition().duration(0).attr("transform", transform_string(d3.event.transform));
}
var transform_string = function(t) {
var res = [];
if (t.translate && (t.translate[0] || t.translate[1])) {
res.push('translate(' + t.translate + ')');
}
if (t.scale && (t.scale[0] !== 1 || t.scale[1] !== 1)) {
res.push('scale(' + t.scale + ')');
}
if (t.rotate) {
res.push('rotate(' + t.rotate*180/Math.PI + ')');
}
return res.join('');
}
</script>
/// Based on the d3.behavior.drag and d3.behavior.zoom.
mtouch_events = function() {
var event = d3_eventDispatch(mtouch, "tap", "dbltap", "hold", "drag", "mdrag", 'touch', 'release')
,fingers = [] // array of augmented touches = fingers
,id2finger = {} // maps ids to fingers
,last_taps = [] // [{timeStamp: xxx, pos: [x,y]}, ...], used to detect dbltaps
,mouse_id = 'mouse'
,tap_max_time = 250
,tap_max_dist2 = 10*10
,hold_time = 500
,hold_max_dist2 = 10*10
,dbltap_max_delay = 400
,dbltap_max_dist = 20;
function mtouch() {
this.on("touchstart.mtouch", touchstarted)
.on("mousedown.mtouch", mousedown)
.on("touchmove.mtouch", touchmoved)
.on("touchend.mtouch", touchended)
.on("touchcancel.mtouch", touchended);
}
mtouch.call = function(f) {
f.apply(mtouch, arguments); return this;
}
/// On mousedown, start listening for mousemove and mouseup events on the
/// whole window. Also call the touchstarted function. If it was not the left
/// mousebutton that was pressed, do nothing.
function mousedown() {
if (!detectLeftButton(d3.event)) return;
var w = d3.select(window);
var thiz = this, argumentz = arguments;
w.on("mousemove.mtouch", function() { touchmoved.apply(thiz, argumentz) });
w.on("mouseup.mtouch", function() {
w.on("mousemove.mtouch", null);
w.on("mouseup.mtouch", null);
touchended.apply(thiz, argumentz);
});
touchstarted.apply(this, arguments);
}
function touchstarted() {
d3.event.preventDefault();
var target = this
,event_ = event.of(target, arguments)
,touches = get_changed_touches();
for (var i=0,N=touches.length; i<N; i++) {
var finger = new Finger(touches[i].identifier, event_, target);
fingers.push(finger);
id2finger[touches[i].identifier] = finger;
event_({type: 'touch', finger: finger, fingers: fingers});
}
}
function touchmoved() {
d3.event.preventDefault();
var target = this
,event_ = event.of(target, arguments)
,touches = get_changed_touches();
for (var i=0,N=fingers.length; i<N; i++) fingers[i].changed = false;
var df = [];
for (var i=0,N=touches.length; i<N; i++) {
var finger = id2finger[touches[i].identifier];
if (!finger) continue;
finger.move(event_);
df.push(finger);
}
event_({type: 'mdrag', dragged_fingers: df, fingers: fingers});
}
function touchended() {
d3.event.preventDefault();
var target = this
,event_ = event.of(target, arguments)
,touches = get_changed_touches();
for (var i=0,N=touches.length; i<N; i++) {
var finger = id2finger[touches[i].identifier];
if (!finger) continue;
finger.end(event_);
delete id2finger[touches[i].identifier];
fingers = d3.values(id2finger);
event_({type: 'release', finger: finger, fingers: fingers});
}
}
function Finger(id, event, target) {
this.id = id;
this.target = target;
this.event = event;
this.parent = target.parentNode;
this.timeStamp0 = d3.event.timeStamp;
this.timeStamp = this.timeStamp0;
this.hold_timer = setTimeout(this.held.bind(this), hold_time);
this.pos = get_position(this.parent, this.id);
this.pos0 = [this.pos[0], this.pos[1]];
this.dist_x = 0; // dx between current and starting point
this.dist_y = 0;
this.dx = 0; // dx in the last dragging step
this.dy = 0;
this.dt = 0; // dt in the last dragging step
this.changed = true; // used by gesture to check whether it needs to update
this.gesture = null; // is set when finger gets bound to a gesture
}
Finger.prototype.cancel_hold = function() {
if (this.hold_timer) clearTimeout(this.hold_timer);
this.hold_timer = null;
}
Finger.prototype.held = function() {
this.event({type: 'hold', id: this.id, fingers: fingers});
this.hold_timer = null;
}
Finger.prototype.move = function(event) {
this.changed = true;
this.event = event;
var p = get_position(this.parent, this.id)
,t = d3.event.timeStamp;
this.dx = p[0] - this.pos[0];
this.dy = p[1] - this.pos[1];
this.dist_x = p[0] - this.pos0[0];
this.dist_y = p[1] - this.pos0[1];
this.pos = p;
this.dt = t-this.timeStamp;
this.timeStamp = t;
if (this.dist_x*this.dist_x+this.dist_y*this.dist_y > hold_max_dist2) {
this.cancel_hold();
}
if (this.gesture) return;
event({type: 'drag', finger: this, x: this.pos[0], y: this.pos[1], dx: this.dx, dy: this.dy, fingers: fingers});
}
Finger.prototype.end = function(event) {
var dt = d3.event.timeStamp - this.timeStamp0;
if (dt <= tap_max_time && (this.dist_x*this.dist_x+this.dist_y*this.dist_y) <= tap_max_dist2) {
if (match_tap(d3.event.timeStamp, this.pos[0], this.pos[1])) {
event({type: 'dbltap', finger: this, fingers: fingers});
} else {
event({type: 'tap', finger: this, fingers: fingers});
}
}
this.cancel_hold();
}
function get_changed_touches() {
return d3.event.changedTouches || [{identifier: mouse_id}];
}
function detectLeftButton(event) {
if ('buttons' in event) return event.buttons === 1;
else if ('which' in event) return event.which === 1;
else return event.button === 1;
}
/// Returns true if any tap in the last_taps list is spatially and temporally
/// close enough to the passed time and postion to count as a dbltap. If not,
/// the passed data is added as new tap. All taps that are too old are removed.
function match_tap(timeStamp, x, y) {
var idx = -1, pos = [x,y];
last_taps = last_taps.filter(function (tap, i) {
if (timeStamp - tap.timeStamp <= dbltap_max_delay
&& get_distance(tap.pos, pos) <= dbltap_max_dist) idx = i;
return tap.timeStamp-timeStamp <= dbltap_max_delay && idx !== i;
});
if (idx === -1) last_taps.push({timeStamp: timeStamp, pos: pos});
return idx !== -1;
}
function get_position(container, id) {
if (id === mouse_id) return d3.mouse(container);
else return d3.touches(container).filter(function(p) { return p.identifier === id; })[0];
}
function get_distance(p1, p2) {
return Math.sqrt((p1[0]-p2[0])*(p1[0]-p2[0]) + (p1[1]-p2[1])*(p1[1]-p2[1]));
}
return d3.rebind(mtouch, event, "on");
};
/// Replication of the internal d3_eventDispatch method.
function d3_eventDispatch(target) {
var dispatch = d3.dispatch.apply(this, Array.apply(null, arguments).slice(1));
dispatch.of = function(thiz, argumentz) {
return function(e1) {
try {
var e0 =
e1.sourceEvent = d3.event;
e1.target = target;
d3.event = e1;
dispatch[e1.type].apply(thiz, argumentz);
} finally {
d3.event = e0;
}
};
};
return dispatch;
}
/// Will automatically listen to the first two fingers on the screen.
/// Emits 'transformstart', 'transformend' and 'transform' events,
/// passing the current transform in the event as {translate: [0,0],
/// rotate: 0, zoom: 1}. The transformation steps are applied in this order:
/// rotate around (0,0), zoom around (0,0), then translate.
/// In order to use it in svg, you need to pass the steps in the opposite
/// order. The behavior has the following getters & setters:
/// transform, allow_translate, allow_scale, allow_rotate
transform_behavior = function() {
var event = d3_eventDispatch(tg, 'transform', 'transformend', 'transformstart')
,fingers = []
,x = 0, y = 0, rotate = 0, scale = 1 // current transform
,allow = {scale: true, translate: true, rotate: true}
,pos, pos0 // current and initial mid-point b/w touches
,loc0 // image location of initial mid-point b/w touches
,dist, dist0 // current and initial distance b/w touches
,angle, angle0 // current and initial angle of line through touches
,scale0 // initial scale in transform
,rot0; // initial rotation in transform
var tg = function() {
this.on('touch.transform', touched)
.on('mdrag.transform', dragged)
.on('release.transform', released);
}
tg.transform = function(val) {
if (!arguments.length) return {scale: scale, rotate: rotate, translate: [x, y]};
scale = val.scale;
rotate = val.rotate;
x = val.translate[0]; y = val.translate[1];
return tg;
}
tg.allow_translate = function(val) {
if (!arguments.length) return allow.translate;
allow.translate = val;
return tg;
}
tg.allow_scale = function(val) {
if (!arguments.length) return allow.scale;
allow.scale = val;
return tg;
}
tg.allow_rotate = function(val) {
if (!arguments.length) return allow.rotate;
allow.rotate = val;
return tg;
}
var touched = function() {
if (fingers.length == 2) return;
fingers.push(d3.event.finger);
if (fingers.length == 1) {
pos = pos0 = [fingers[0].pos[0], fingers[0].pos[1]];
loc0 = to_location(pos0);
event.of(this, arguments)({type: 'transformstart'});
}
if (fingers.length == 2) {
pos = pos0 = get_mean(fingers[0].pos, fingers[1].pos);
dist = dist0 = get_distance(fingers[0].pos, fingers[1].pos);
angle = angle0 = get_angle(fingers[0].pos, fingers[1].pos);
loc0 = to_location(pos0);
scale0 = scale;
rot0 = rotate;
}
}
var dragged = function() {
if (fingers.length == 0) return;
if (!d3.event.dragged_fingers.some(function (f) { return f.changed })) return;
if (fingers.length == 1) {
if (allow.translate) pos = [fingers[0].pos[0], fingers[0].pos[1]];
translateTo(pos, loc0);
}
if (fingers.length == 2) {
if (allow.scale) dist = get_distance(fingers[0].pos, fingers[1].pos);
if (allow.translate) pos = get_mean(fingers[0].pos, fingers[1].pos);
if (allow.rotate) angle = get_angle(fingers[0].pos, fingers[1].pos);
update_transform();
}
event.of(this, arguments)({type: 'transform', fingers: fingers, transform:
{scale: scale, rotate: rotate, translate: [x, y]}});
}
var released = function() {
if (fingers.length == 0) return;
var f = d3.event.finger;
var idx = fingers.indexOf(f);
if (idx == -1) return;
fingers.splice(idx,1);
if (fingers.length == 0) {
event.of(this, arguments)({type: 'transformend'});
}
if (fingers.length == 1) {
pos = pos0 = [fingers[0].pos[0], fingers[0].pos[1]];
loc0 = to_location(pos0);
}
}
var update_transform = function() {
if (allow.rotate) rotate = rot0+(angle-angle0);
if (allow.scale) scale = scale0*dist/dist0;
if (allow.translate || allow.rotate || allow.scale) translateTo(pos, loc0);
}
/// turn an image position into a screen position
var to_screen = function(l) {
// rotate then zoom then translate
var cos = Math.cos(rotate), sin = Math.sin(rotate);
var rx = l[0]*cos - l[1]*sin
,ry = l[1]*cos + l[0]*sin;
return [rx * scale + x
,ry * scale + y];
}
/// turn a screen position into an image position
var to_location = function(p) {
// untranslate, then unzoom, then unrotate
var sx = (p[0] - x) / scale
,sy = (p[1] - y) / scale
var cos = Math.cos(-rotate), sin = Math.sin(-rotate);
return [sx*cos-sy*sin, sy*cos+sx*sin];
}
var translateTo = function(p, l) {
l = to_screen(l);
x += p[0] - l[0];
y += p[1] - l[1];
}
function get_distance(p1, p2) {
return Math.sqrt((p1[0]-p2[0])*(p1[0]-p2[0]) + (p1[1]-p2[1])*(p1[1]-p2[1]));
}
function get_mean(p1, p2) {
return [(p1[0]+p2[0])/2, (p1[1]+p2[1])/2];
}
function get_angle(p1, p2) {
return Math.atan2(p1[1]-p2[1], p1[0]-p2[0]);
}
return d3.rebind(tg, event, "on");
}
@armamut
Copy link

armamut commented Sep 3, 2015

Thanks for the replication of the internal d3_eventDispatch method. I'm trying to implement a custom behavior. I'll surely get inspiration from your code.

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