'use strict'; var d3 = require('@plotly/d3'); var Lib = require('../../lib'); var Registry = require('../../registry'); var radians = Math.PI / 180; var degrees = 180 / Math.PI; var zoomstartStyle = {cursor: 'pointer'}; var zoomendStyle = {cursor: 'auto'}; function createGeoZoom(geo, geoLayout) { var projection = geo.projection; var zoomConstructor; if(geoLayout._isScoped) { zoomConstructor = zoomScoped; } else if(geoLayout._isClipped) { zoomConstructor = zoomClipped; } else { zoomConstructor = zoomNonClipped; } // TODO add a conic-specific zoom return zoomConstructor(geo, projection); } module.exports = createGeoZoom; // common to all zoom types function initZoom(geo, projection) { return d3.behavior.zoom() .translate(projection.translate()) .scale(projection.scale()); } // sync zoom updates with user & full layout function sync(geo, projection, cb) { var id = geo.id; var gd = geo.graphDiv; var layout = gd.layout; var userOpts = layout[id]; var fullLayout = gd._fullLayout; var fullOpts = fullLayout[id]; var preGUI = {}; var eventData = {}; function set(propStr, val) { preGUI[id + '.' + propStr] = Lib.nestedProperty(userOpts, propStr).get(); Registry.call('_storeDirectGUIEdit', layout, fullLayout._preGUI, preGUI); var fullNp = Lib.nestedProperty(fullOpts, propStr); if(fullNp.get() !== val) { fullNp.set(val); Lib.nestedProperty(userOpts, propStr).set(val); eventData[id + '.' + propStr] = val; } } cb(set); set('projection.scale', projection.scale() / geo.fitScale); set('fitbounds', false); gd.emit('plotly_relayout', eventData); } // zoom for scoped projections function zoomScoped(geo, projection) { var zoom = initZoom(geo, projection); function handleZoomstart() { d3.select(this).style(zoomstartStyle); } function handleZoom() { projection .scale(d3.event.scale) .translate(d3.event.translate); geo.render(true); var center = projection.invert(geo.midPt); geo.graphDiv.emit('plotly_relayouting', { 'geo.projection.scale': projection.scale() / geo.fitScale, 'geo.center.lon': center[0], 'geo.center.lat': center[1] }); } function syncCb(set) { var center = projection.invert(geo.midPt); set('center.lon', center[0]); set('center.lat', center[1]); } function handleZoomend() { d3.select(this).style(zoomendStyle); sync(geo, projection, syncCb); } zoom .on('zoomstart', handleZoomstart) .on('zoom', handleZoom) .on('zoomend', handleZoomend); return zoom; } // zoom for non-clipped projections function zoomNonClipped(geo, projection) { var zoom = initZoom(geo, projection); var INSIDETOLORANCEPXS = 2; var mouse0, rotate0, translate0, lastRotate, zoomPoint, mouse1, rotate1, point1, didZoom; function position(x) { return projection.invert(x); } function outside(x) { var pos = position(x); if(!pos) return true; var pt = projection(pos); return ( Math.abs(pt[0] - x[0]) > INSIDETOLORANCEPXS || Math.abs(pt[1] - x[1]) > INSIDETOLORANCEPXS ); } function handleZoomstart() { d3.select(this).style(zoomstartStyle); mouse0 = d3.mouse(this); rotate0 = projection.rotate(); translate0 = projection.translate(); lastRotate = rotate0; zoomPoint = position(mouse0); } function handleZoom() { mouse1 = d3.mouse(this); if(outside(mouse0)) { zoom.scale(projection.scale()); zoom.translate(projection.translate()); return; } projection.scale(d3.event.scale); projection.translate([translate0[0], d3.event.translate[1]]); if(!zoomPoint) { mouse0 = mouse1; zoomPoint = position(mouse0); } else if(position(mouse1)) { point1 = position(mouse1); rotate1 = [lastRotate[0] + (point1[0] - zoomPoint[0]), rotate0[1], rotate0[2]]; projection.rotate(rotate1); lastRotate = rotate1; } didZoom = true; geo.render(true); var rotate = projection.rotate(); var center = projection.invert(geo.midPt); geo.graphDiv.emit('plotly_relayouting', { 'geo.projection.scale': projection.scale() / geo.fitScale, 'geo.center.lon': center[0], 'geo.center.lat': center[1], 'geo.projection.rotation.lon': -rotate[0] }); } function handleZoomend() { d3.select(this).style(zoomendStyle); if(didZoom) sync(geo, projection, syncCb); } function syncCb(set) { var rotate = projection.rotate(); var center = projection.invert(geo.midPt); set('projection.rotation.lon', -rotate[0]); set('center.lon', center[0]); set('center.lat', center[1]); } zoom .on('zoomstart', handleZoomstart) .on('zoom', handleZoom) .on('zoomend', handleZoomend); return zoom; } // zoom for clipped projections // inspired by https://www.jasondavies.com/maps/d3.geo.zoom.js function zoomClipped(geo, projection) { var view = {r: projection.rotate(), k: projection.scale()}; var zoom = initZoom(geo, projection); var event = d3eventDispatch(zoom, 'zoomstart', 'zoom', 'zoomend'); var zooming = 0; var zoomOn = zoom.on; var zoomPoint; zoom.on('zoomstart', function() { d3.select(this).style(zoomstartStyle); var mouse0 = d3.mouse(this); var rotate0 = projection.rotate(); var lastRotate = rotate0; var translate0 = projection.translate(); var q = quaternionFromEuler(rotate0); zoomPoint = position(projection, mouse0); zoomOn.call(zoom, 'zoom', function() { var mouse1 = d3.mouse(this); projection.scale(view.k = d3.event.scale); if(!zoomPoint) { // if no zoomPoint, the mouse wasn't over the actual geography yet // maybe this point is the start... we'll find out next time! mouse0 = mouse1; zoomPoint = position(projection, mouse0); } else if(position(projection, mouse1)) { // check if the point is on the map // if not, don't do anything new but scale // if it is, then we can assume between will exist below // so we don't need the 'bank' function, whatever that is. // go back to original projection temporarily // except for scale... that's kind of independent? projection .rotate(rotate0) .translate(translate0); // calculate the new params var point1 = position(projection, mouse1); var between = rotateBetween(zoomPoint, point1); var newEuler = eulerFromQuaternion(multiply(q, between)); var rotateAngles = view.r = unRoll(newEuler, zoomPoint, lastRotate); if(!isFinite(rotateAngles[0]) || !isFinite(rotateAngles[1]) || !isFinite(rotateAngles[2])) { rotateAngles = lastRotate; } // update the projection projection.rotate(rotateAngles); lastRotate = rotateAngles; } zoomed(event.of(this, arguments)); }); zoomstarted(event.of(this, arguments)); }) .on('zoomend', function() { d3.select(this).style(zoomendStyle); zoomOn.call(zoom, 'zoom', null); zoomended(event.of(this, arguments)); sync(geo, projection, syncCb); }) .on('zoom.redraw', function() { geo.render(true); var _rotate = projection.rotate(); geo.graphDiv.emit('plotly_relayouting', { 'geo.projection.scale': projection.scale() / geo.fitScale, 'geo.projection.rotation.lon': -_rotate[0], 'geo.projection.rotation.lat': -_rotate[1] }); }); function zoomstarted(dispatch) { if(!zooming++) dispatch({type: 'zoomstart'}); } function zoomed(dispatch) { dispatch({type: 'zoom'}); } function zoomended(dispatch) { if(!--zooming) dispatch({type: 'zoomend'}); } function syncCb(set) { var _rotate = projection.rotate(); set('projection.rotation.lon', -_rotate[0]); set('projection.rotation.lat', -_rotate[1]); } return d3.rebind(zoom, event, 'on'); } // -- helper functions for zoomClipped function position(projection, point) { var spherical = projection.invert(point); return spherical && isFinite(spherical[0]) && isFinite(spherical[1]) && cartesian(spherical); } function quaternionFromEuler(euler) { var lambda = 0.5 * euler[0] * radians; var phi = 0.5 * euler[1] * radians; var gamma = 0.5 * euler[2] * radians; var sinLambda = Math.sin(lambda); var cosLambda = Math.cos(lambda); var sinPhi = Math.sin(phi); var cosPhi = Math.cos(phi); var sinGamma = Math.sin(gamma); var cosGamma = Math.cos(gamma); return [ cosLambda * cosPhi * cosGamma + sinLambda * sinPhi * sinGamma, sinLambda * cosPhi * cosGamma - cosLambda * sinPhi * sinGamma, cosLambda * sinPhi * cosGamma + sinLambda * cosPhi * sinGamma, cosLambda * cosPhi * sinGamma - sinLambda * sinPhi * cosGamma ]; } function multiply(a, b) { var a0 = a[0]; var a1 = a[1]; var a2 = a[2]; var a3 = a[3]; var b0 = b[0]; var b1 = b[1]; var b2 = b[2]; var b3 = b[3]; return [ a0 * b0 - a1 * b1 - a2 * b2 - a3 * b3, a0 * b1 + a1 * b0 + a2 * b3 - a3 * b2, a0 * b2 - a1 * b3 + a2 * b0 + a3 * b1, a0 * b3 + a1 * b2 - a2 * b1 + a3 * b0 ]; } function rotateBetween(a, b) { if(!a || !b) return; var axis = cross(a, b); var norm = Math.sqrt(dot(axis, axis)); var halfgamma = 0.5 * Math.acos(Math.max(-1, Math.min(1, dot(a, b)))); var k = Math.sin(halfgamma) / norm; return norm && [Math.cos(halfgamma), axis[2] * k, -axis[1] * k, axis[0] * k]; } // input: // rotateAngles: a calculated set of Euler angles // pt: a point (cartesian in 3-space) to keep fixed // roll0: an initial roll, to be preserved // output: // a set of Euler angles that preserve the projection of pt // but set roll (output[2]) equal to roll0 // note that this doesn't depend on the particular projection, // just on the rotation angles function unRoll(rotateAngles, pt, lastRotate) { // calculate the fixed point transformed by these Euler angles // but with the desired roll undone var ptRotated = rotateCartesian(pt, 2, rotateAngles[0]); ptRotated = rotateCartesian(ptRotated, 1, rotateAngles[1]); ptRotated = rotateCartesian(ptRotated, 0, rotateAngles[2] - lastRotate[2]); var x = pt[0]; var y = pt[1]; var z = pt[2]; var f = ptRotated[0]; var g = ptRotated[1]; var h = ptRotated[2]; // the following essentially solves: // ptRotated = rotateCartesian(rotateCartesian(pt, 2, newYaw), 1, newPitch) // for newYaw and newPitch, as best it can var theta = Math.atan2(y, x) * degrees; var a = Math.sqrt(x * x + y * y); var b; var newYaw1; if(Math.abs(g) > a) { newYaw1 = (g > 0 ? 90 : -90) - theta; b = 0; } else { newYaw1 = Math.asin(g / a) * degrees - theta; b = Math.sqrt(a * a - g * g); } var newYaw2 = 180 - newYaw1 - 2 * theta; var newPitch1 = (Math.atan2(h, f) - Math.atan2(z, b)) * degrees; var newPitch2 = (Math.atan2(h, f) - Math.atan2(z, -b)) * degrees; // which is closest to lastRotate[0,1]: newYaw/Pitch or newYaw2/Pitch2? var dist1 = angleDistance(lastRotate[0], lastRotate[1], newYaw1, newPitch1); var dist2 = angleDistance(lastRotate[0], lastRotate[1], newYaw2, newPitch2); if(dist1 <= dist2) return [newYaw1, newPitch1, lastRotate[2]]; else return [newYaw2, newPitch2, lastRotate[2]]; } function angleDistance(yaw0, pitch0, yaw1, pitch1) { var dYaw = angleMod(yaw1 - yaw0); var dPitch = angleMod(pitch1 - pitch0); return Math.sqrt(dYaw * dYaw + dPitch * dPitch); } // reduce an angle in degrees to [-180,180] function angleMod(angle) { return (angle % 360 + 540) % 360 - 180; } // rotate a cartesian vector // axis is 0 (x), 1 (y), or 2 (z) // angle is in degrees function rotateCartesian(vector, axis, angle) { var angleRads = angle * radians; var vectorOut = vector.slice(); var ax1 = (axis === 0) ? 1 : 0; var ax2 = (axis === 2) ? 1 : 2; var cosa = Math.cos(angleRads); var sina = Math.sin(angleRads); vectorOut[ax1] = vector[ax1] * cosa - vector[ax2] * sina; vectorOut[ax2] = vector[ax2] * cosa + vector[ax1] * sina; return vectorOut; } function eulerFromQuaternion(q) { return [ Math.atan2(2 * (q[0] * q[1] + q[2] * q[3]), 1 - 2 * (q[1] * q[1] + q[2] * q[2])) * degrees, Math.asin(Math.max(-1, Math.min(1, 2 * (q[0] * q[2] - q[3] * q[1])))) * degrees, Math.atan2(2 * (q[0] * q[3] + q[1] * q[2]), 1 - 2 * (q[2] * q[2] + q[3] * q[3])) * degrees ]; } function cartesian(spherical) { var lambda = spherical[0] * radians; var phi = spherical[1] * radians; var cosPhi = Math.cos(phi); return [ cosPhi * Math.cos(lambda), cosPhi * Math.sin(lambda), Math.sin(phi) ]; } function dot(a, b) { var s = 0; for(var i = 0, n = a.length; i < n; ++i) s += a[i] * b[i]; return s; } function cross(a, b) { return [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0] ]; } // Like d3.dispatch, but for custom events abstracting native UI events. These // events have a target component (such as a brush), a target element (such as // the svg:g element containing the brush) and the standard arguments `d` (the // target element's data) and `i` (the selection index of the target element). function d3eventDispatch(target) { var i = 0; var n = arguments.length; var argumentz = []; while(++i < n) argumentz.push(arguments[i]); var dispatch = d3.dispatch.apply(null, argumentz); // Creates a dispatch context for the specified `thiz` (typically, the target // DOM element that received the source event) and `argumentz` (typically, the // data `d` and index `i` of the target element). The returned function can be // used to dispatch an event to any registered listeners; the function takes a // single argument as input, being the event to dispatch. The event must have // a "type" attribute which corresponds to a type registered in the // constructor. This context will automatically populate the "sourceEvent" and // "target" attributes of the event, as well as setting the `d3.event` global // for the duration of the notification. dispatch.of = function(thiz, argumentz) { return function(e1) { var e0; try { e0 = e1.sourceEvent = d3.event; e1.target = target; d3.event = e1; dispatch[e1.type].apply(thiz, argumentz); } finally { d3.event = e0; } }; }; return dispatch; }