diff --git a/.agignore b/.agignore new file mode 100644 index 00000000000..53c37a16608 --- /dev/null +++ b/.agignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index b25aded065e..37dd222627c 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -121,6 +121,24 @@ modeBarButtons.pan2d = { click: handleCartesian }; +modeBarButtons.select2d = { + name: 'select2d', + title: 'Box Select', + attr: 'dragmode', + val: 'select', + icon: Icons.selectbox, + click: handleCartesian +}; + +modeBarButtons.lasso2d = { + name: 'lasso2d', + title: 'Lasso Select', + attr: 'dragmode', + val: 'lasso', + icon: Icons.lasso, + click: handleCartesian +}; + modeBarButtons.zoomIn2d = { name: 'zoomIn2d', title: 'Zoom in', @@ -179,6 +197,13 @@ modeBarButtons.hoverCompareCartesian = { click: handleCartesian }; +var DRAGCURSORS = { + pan: 'move', + zoom: 'crosshair', + select: 'crosshair', + lasso: 'crosshair' +}; + function handleCartesian(gd, ev) { var button = ev.currentTarget, astr = button.getAttribute('data-attr'), @@ -230,7 +255,7 @@ function handleCartesian(gd, ev) { if(fullLayout._hasCartesian) { Plotly.Fx.setCursor( fullLayout._paper.select('.nsewdrag'), - {pan:'move', zoom:'crosshair'}[val] + DRAGCURSORS[val] ); } Plotly.Fx.supplyLayoutDefaults(gd.layout, fullLayout, gd._fullData); diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js index 970fb2ed034..2fa91f4a19f 100644 --- a/src/components/modebar/manage.js +++ b/src/components/modebar/manage.js @@ -10,6 +10,7 @@ 'use strict'; var Plotly = require('../../plotly'); +var scatterSubTypes = require('../../traces/scatter/subtypes'); var createModeBar = require('./'); var modeBarButtons = require('./buttons'); @@ -57,7 +58,7 @@ module.exports = function manageModeBar(gd) { } else { buttonGroups = getButtonGroups( - fullLayout, + gd, context.modeBarButtonsToRemove, context.modeBarButtonsToAdd ); @@ -68,8 +69,12 @@ module.exports = function manageModeBar(gd) { }; // logic behind which buttons are displayed by default -function getButtonGroups(fullLayout, buttonsToRemove, buttonsToAdd) { - var groups = []; +function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) { + var fullLayout = gd._fullLayout, + fullData = gd._fullData, + groups = [], + i, + trace; function addGroup(newGroup) { var out = []; @@ -99,10 +104,40 @@ function getButtonGroups(fullLayout, buttonsToRemove, buttonsToAdd) { var hasCartesian = fullLayout._hasCartesian, hasGL2D = fullLayout._hasGL2D, - allAxesFixed = areAllAxesFixed(fullLayout); + allAxesFixed = areAllAxesFixed(fullLayout), + dragModeGroup = []; + + if((hasCartesian || hasGL2D) && !allAxesFixed) { + dragModeGroup = ['zoom2d', 'pan2d']; + } + if(hasCartesian) { + // look for traces that support selection + // to be updated as we add more selectPoints handlers + var selectable = false; + for(i = 0; i < fullData.length; i++) { + if(selectable) break; + trace = fullData[i]; + if(!trace._module || !trace._module.selectPoints) continue; + + if(trace.type === 'scatter') { + if(scatterSubTypes.hasMarkers(trace) || scatterSubTypes.hasText(trace)) { + selectable = true; + } + } + // assume that in general if the trace module has selectPoints, + // then it's selectable. Scatter is an exception to this because it must + // have markers or text, not just be a scatter type. + else selectable = true; + } + + if(selectable) { + dragModeGroup.push('select2d'); + dragModeGroup.push('lasso2d'); + } + } + if(dragModeGroup.length) addGroup(dragModeGroup); if((hasCartesian || hasGL2D) && !allAxesFixed) { - addGroup(['zoom2d', 'pan2d']); addGroup(['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d']); } @@ -119,7 +154,7 @@ function getButtonGroups(fullLayout, buttonsToRemove, buttonsToAdd) { // append buttonsToAdd to the groups if(buttonsToAdd.length) { if(Array.isArray(buttonsToAdd[0])) { - for(var i = 0; i < buttonsToAdd.length; i++) { + for(i = 0; i < buttonsToAdd.length; i++) { groups.push(buttonsToAdd[i]); } } diff --git a/src/css/_drag.scss b/src/css/_drag.scss new file mode 100644 index 00000000000..0896a794f95 --- /dev/null +++ b/src/css/_drag.scss @@ -0,0 +1,12 @@ +.select-outline { + fill: none; + stroke-width: 1; + shape-rendering: crispEdges; +} +.select-outline-1 { + stroke: white; +} +.select-outline-2 { + stroke: black; + stroke-dasharray: 2px 2px; +} \ No newline at end of file diff --git a/src/css/style.scss b/src/css/style.scss index 8305dfe36b4..146a68e4afe 100644 --- a/src/css/style.scss +++ b/src/css/style.scss @@ -6,5 +6,6 @@ @import "cursor.scss"; @import "modebar.scss"; @import "tooltip.scss"; + @import "drag.scss"; } @import "notifier.scss"; diff --git a/src/fonts/ploticon/_ploticon.scss b/src/fonts/ploticon/_ploticon.scss index 9f515bc9247..2289649e152 100644 --- a/src/fonts/ploticon/_ploticon.scss +++ b/src/fonts/ploticon/_ploticon.scss @@ -41,3 +41,5 @@ .ploticon-movie:before { content: '\e80e'; } /* '' */ .ploticon-question:before { content: '\e80f'; } /* '' */ .ploticon-disk:before { content: '\e810'; } /* '' */ +.ploticon-lasso:before { content: '\e811'; } /* '' */ +.ploticon-selectbox:before { content: '\e812'; } /* '' */ diff --git a/src/fonts/ploticon/config.json b/src/fonts/ploticon/config.json index 231687afc3a..851669be315 100644 --- a/src/fonts/ploticon/config.json +++ b/src/fonts/ploticon/config.json @@ -1462,6 +1462,26 @@ "movie" ] }, + { + "uid": "11aaeff1fa846b5a34638dbb78619c59", + "css": "lasso", + "code": 59409, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M1018.2 311.9C981.8 105 727.8-23.6 450.2 25.7 172.6 74-22.5 281.9 13.9 488.7 23.6 545.6 50.4 597 90 639.9 77.2 706.3 100.8 777.1 157.6 823.2 191.9 851 232.6 863.9 272.2 865L216.5 934.6 216.5 934.6C215.4 935.7 214.4 936.8 213.3 937.8 202.6 951.8 204.7 972.1 217.6 982.9 231.5 993.6 251.9 991.4 262.6 978.6 263.7 977.5 264.7 976.4 264.7 974.3L264.7 974.3 378.3 833.9C394.4 823.2 409.4 810.3 423.4 794.2 426.6 791 428.7 786.7 430.9 783.5 479.1 785.6 530.5 783.5 582 773.8 859.6 725.6 1054.7 518.8 1018.2 311.9ZM394.4 691.3C314 677.4 245.4 643.1 197.2 594.9 239 553.1 305.5 547.7 352.6 586.3 385.9 612 399.8 651.7 394.4 691.3ZM206.9 765.3C187.6 749.2 173.6 727.8 168.3 705.3 217.6 737.4 276.5 759.9 341.9 772.8 300.1 798.5 246.5 797.4 206.9 765.3ZM567 690.2C532.7 696.7 498.4 698.8 465.2 697.7 472.7 635.6 449.1 570.2 396.6 528.4 323.7 469.5 221.9 473.7 153.3 532.7 143.6 513.4 137.2 493 132.9 471.6 105 313 254 155.4 466.2 117.9S872.5 177.9 900.3 335.5C928.2 494.1 779.2 652.7 567 690.2Z", + "width": 1031 + }, + "search": [ + "lasso" + ] + }, + { + "uid": "8ce732688587909ad0a9d8323eaca8ad", + "css": "selectbox", + "code": 59410, + "src": "fontelico" + }, { "uid": "9d3d9d6ce1ec63eaa26281e6162853c9", "css": "camera-retro", diff --git a/src/fonts/ploticon/ploticon.eot b/src/fonts/ploticon/ploticon.eot index 8153c046d27..5e42f0ed51f 100644 Binary files a/src/fonts/ploticon/ploticon.eot and b/src/fonts/ploticon/ploticon.eot differ diff --git a/src/fonts/ploticon/ploticon.svg b/src/fonts/ploticon/ploticon.svg index 825354874a3..a72ac5b551d 100644 --- a/src/fonts/ploticon/ploticon.svg +++ b/src/fonts/ploticon/ploticon.svg @@ -1,7 +1,7 @@ -Copyright (C) 2015 by original authors @ fontello.com +Copyright (C) 2016 by original authors @ fontello.com @@ -23,6 +23,8 @@ + + \ No newline at end of file diff --git a/src/fonts/ploticon/ploticon.ttf b/src/fonts/ploticon/ploticon.ttf index ac09dacb16f..02f92f31e8a 100644 Binary files a/src/fonts/ploticon/ploticon.ttf and b/src/fonts/ploticon/ploticon.ttf differ diff --git a/src/fonts/ploticon/ploticon.woff b/src/fonts/ploticon/ploticon.woff index 233eda43ce5..e2c4fff189c 100644 Binary files a/src/fonts/ploticon/ploticon.woff and b/src/fonts/ploticon/ploticon.woff differ diff --git a/src/lib/polygon.js b/src/lib/polygon.js new file mode 100644 index 00000000000..d83a05ef1dd --- /dev/null +++ b/src/lib/polygon.js @@ -0,0 +1,238 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var dot = require('./matrix').dot; + +var polygon = module.exports = {}; + +/** + * Turn an array of [x, y] pairs into a polygon object + * that can test if points are inside it + * + * @param ptsIn Array of [x, y] pairs + * + * @returns polygon Object {xmin, xmax, ymin, ymax, pts, contains} + * (x|y)(min|max) are the bounding rect of the polygon + * pts is the original array, with the first pair repeated at the end + * contains is a function: (pt, omitFirstEdge) + * pt is the [x, y] pair to test + * omitFirstEdge truthy means points exactly on the first edge don't + * count. This is for use adding one polygon to another so we + * don't double-count the edge where they meet. + * returns boolean: is pt inside the polygon (including on its edges) + */ +polygon.tester = function tester(ptsIn) { + var pts = ptsIn.slice(), + xmin = pts[0][0], + xmax = xmin, + ymin = pts[0][1], + ymax = ymin; + + pts.push(pts[0]); + for(var i = 1; i < pts.length; i++) { + xmin = Math.min(xmin, pts[i][0]); + xmax = Math.max(xmax, pts[i][0]); + ymin = Math.min(ymin, pts[i][1]); + ymax = Math.max(ymax, pts[i][1]); + } + + // do we have a rectangle? Handle this here, so we can use the same + // tester for the rectangular case without sacrificing speed + + var isRect = false, + rectFirstEdgeTest; + + if(pts.length === 5) { + if(pts[0][0] === pts[1][0]) { // vert, horz, vert, horz + if(pts[2][0] === pts[3][0] && + pts[0][1] === pts[3][1] && + pts[1][1] === pts[2][1]) { + isRect = true; + rectFirstEdgeTest = function(pt) { return pt[0] === pts[0][0]; }; + } + } + else if(pts[0][1] === pts[1][1]) { // horz, vert, horz, vert + if(pts[2][1] === pts[3][1] && + pts[0][0] === pts[3][0] && + pts[1][0] === pts[2][0]) { + isRect = true; + rectFirstEdgeTest = function(pt) { return pt[1] === pts[0][1]; }; + } + } + } + + function rectContains(pt, omitFirstEdge) { + var x = pt[0], + y = pt[1]; + + if(x < xmin || x > xmax || y < ymin || y > ymax) { + // pt is outside the bounding box of polygon + return false; + } + if(omitFirstEdge && rectFirstEdgeTest(pt)) return false; + + return true; + } + + function contains(pt, omitFirstEdge) { + var x = pt[0], + y = pt[1]; + + if(x < xmin || x > xmax || y < ymin || y > ymax) { + // pt is outside the bounding box of polygon + return false; + } + + var imax = pts.length, + x1 = pts[0][0], + y1 = pts[0][1], + crossings = 0, + i, + x0, + y0, + xmini, + ycross; + + for(i = 1; i < imax; i++) { + // find all crossings of a vertical line upward from pt with + // polygon segments + // crossings exactly at xmax don't count, unless the point is + // exactly on the segment, then it counts as inside. + x0 = x1; + y0 = y1; + x1 = pts[i][0]; + y1 = pts[i][1]; + xmini = Math.min(x0, x1); + + // outside the bounding box of this segment, it's only a crossing + // if it's below the box. + if(x < xmini || x > Math.max(x0, x1) || y > Math.max(y0, y1)) { + continue; + } + else if(y < Math.min(y0, y1)) { + // don't count the left-most point of the segment as a crossing + // because we don't want to double-count adjacent crossings + // UNLESS the polygon turns past vertical at exactly this x + // Note that this is repeated below, but we can't factor it out + // because + if(x !== xmini) crossings++; + } + // inside the bounding box, check the actual line intercept + else { + // vertical segment - we know already that the point is exactly + // on the segment, so mark the crossing as exactly at the point. + if(x1 === x0) ycross = y; + // any other angle + else ycross = y0 + (x - x0) * (y1 - y0) / (x1 - x0); + + // exactly on the edge: counts as inside the polygon, unless it's the + // first edge and we're omitting it. + if(y === ycross) { + if(i === 1 && omitFirstEdge) return false; + return true; + } + + if(y <= ycross && x !== xmini) crossings++; + } + } + + // if we've gotten this far, odd crossings means inside, even is outside + return crossings % 2 === 1; + } + + return { + xmin: xmin, + xmax: xmax, + ymin: ymin, + ymax: ymax, + pts: pts, + contains: isRect ? rectContains : contains, + isRect: isRect + }; +}; + +/** + * Test if a segment of a points array is bent or straight + * + * @param pts Array of [x, y] pairs + * @param start the index of the proposed start of the straight section + * @param end the index of the proposed end point + * @param tolerance the max distance off the line connecting start and end + * before the line counts as bent + * @returns boolean: true means this segment is bent, false means straight + */ +var isBent = polygon.isSegmentBent = function isBent(pts, start, end, tolerance) { + var startPt = pts[start], + segment = [pts[end][0] - startPt[0], pts[end][1] - startPt[1]], + segmentSquared = dot(segment, segment), + segmentLen = Math.sqrt(segmentSquared), + unitPerp = [-segment[1] / segmentLen, segment[0] / segmentLen], + i, + part, + partParallel; + + for(i = start + 1; i < end; i++) { + part = [pts[i][0] - startPt[0], pts[i][1] - startPt[1]]; + partParallel = dot(part, segment); + + if(partParallel < 0 || partParallel > segmentSquared || + Math.abs(dot(part, unitPerp)) > tolerance) return true; + } + return false; +}; + +/** + * Make a filtering polygon, to minimize the number of segments + * + * @param pts Array of [x, y] pairs (must start with at least 1 pair) + * @param tolerance the maximum deviation from straight allowed for + * removing points to simplify the polygon + * + * @returns Object {addPt, raw, filtered} + * addPt is a function(pt: [x, y] pair) to add a raw point and + * continue filtering + * raw is all the input points + * filtered is the resulting filtered Array of [x, y] pairs + */ +polygon.filter = function filter(pts, tolerance) { + var ptsFiltered = [pts[0]], + doneRawIndex = 0, + doneFilteredIndex = 0; + + function addPt(pt) { + pts.push(pt); + var prevFilterLen = ptsFiltered.length, + iLast = doneRawIndex; + ptsFiltered.splice(doneFilteredIndex + 1); + + for(var i = iLast + 1; i < pts.length; i++) { + if(i === pts.length - 1 || isBent(pts, iLast, i + 1, tolerance)) { + ptsFiltered.push(pts[i]); + if(ptsFiltered.length < prevFilterLen - 2) { + doneRawIndex = i; + doneFilteredIndex = ptsFiltered.length - 1; + } + iLast = i; + } + } + } + + if(pts.length > 1) { + var lastPt = pts.pop(); + addPt(lastPt); + } + + return { + addPt: addPt, + raw: pts, + filtered: ptsFiltered + }; +}; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 6e2f0877b5f..b09d43ea389 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -634,6 +634,7 @@ axes.setConvert = function(ax) { ax.c2l = (ax.type==='log') ? toLog : num; ax.l2c = (ax.type==='log') ? fromLog : num; ax.l2d = function(v) { return ax.c2d(ax.l2c(v)); }; + ax.p2d = function(v) { return ax.l2d(ax.p2l(v)); }; // set scaling to pixels ax.setScale = function(){ diff --git a/src/plots/cartesian/constants.js b/src/plots/cartesian/constants.js new file mode 100644 index 00000000000..6e2f1d9eb13 --- /dev/null +++ b/src/plots/cartesian/constants.js @@ -0,0 +1,44 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +module.exports = { + // ms between first mousedown and 2nd mouseup to constitute dblclick... + // we don't seem to have access to the system setting + DBLCLICKDELAY: 600, + + // pixels to move mouse before you stop clamping to starting point + MINDRAG: 8, + + // smallest dimension allowed for a select box + MINSELECT: 12, + + // smallest dimension allowed for a zoombox + MINZOOM: 20, + + // width of axis drag regions + DRAGGERSIZE: 20, + + // max pixels away from mouse to allow a point to highlight + MAXDIST: 20, + + // hover labels for multiple horizontal bars get tilted by this angle + YANGLE: 60, + + // size and display constants for hover text + HOVERARROWSIZE: 6, // pixel size of hover arrows + HOVERTEXTPAD: 3, // pixels padding around text + HOVERFONTSIZE: 13, + HOVERFONT: 'Arial, sans-serif', + + // minimum time (msec) between hover calls + HOVERMINTIME: 100, + + // max pixels off straight before a lasso select line counts as bent + BENDPX: 1.5 +}; diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js index 623e802fc88..fb09830b29a 100644 --- a/src/plots/cartesian/graph_interact.js +++ b/src/plots/cartesian/graph_interact.js @@ -9,20 +9,29 @@ 'use strict'; -var Plotly = require('../../plotly'); var d3 = require('d3'); var tinycolor = require('tinycolor2'); var isNumeric = require('fast-isnumeric'); + +var Plotly = require('../../plotly'); var Events = require('../../lib/events'); +var prepSelect = require('./select'); +var constants = require('./constants'); + var fx = module.exports = {}; fx.layoutAttributes = { dragmode: { valType: 'enumerated', role: 'info', - values: ['zoom', 'pan', 'orbit', 'turntable'], - description: 'Determines the mode of drag interactions.' + values: ['zoom', 'pan', 'select', 'lasso', 'orbit', 'turntable'], + description: [ + 'Determines the mode of drag interactions.', + '*select* and *lasso* apply only to scatter traces with', + 'markers or text. *orbit* and *turntable* apply only to', + '3D scenes.' + ].join(' ') }, hovermode: { valType: 'enumerated', @@ -67,19 +76,6 @@ fx.isHoriz = function(fullData) { return isHoriz; }; -// ms between first mousedown and 2nd mouseup to constitute dblclick... -// we don't seem to have access to the system setting -fx.DBLCLICKDELAY = 600; - -// pixels to move mouse before you stop clamping to starting point -fx.MINDRAG = 8; - -// smallest dimension allowed for a zoombox -fx.MINZOOM = 20; - -// width of axis drag regions -var DRAGGERSIZE = 20; - fx.init = function(gd) { var fullLayout = gd._fullLayout; @@ -112,6 +108,7 @@ fx.init = function(gd) { // the x position of the main y axis line x0 = (ya._linepositions[subplot]||[])[3]; + var DRAGGERSIZE = constants.DRAGGERSIZE; if(isNumeric(y0) && xa.side==='top') y0 -= DRAGGERSIZE; if(isNumeric(x0) && ya.side!=='right') x0 -= DRAGGERSIZE; @@ -203,7 +200,7 @@ fx.init = function(gd) { // hover labels for multiple horizontal bars get tilted by some angle, // then need to be offset differently if they overlap -var YANGLE = 60, +var YANGLE = constants.YANGLE, YA_RADIANS = Math.PI*YANGLE/180, // expansion of projected height @@ -236,13 +233,10 @@ function quadrature(dx, dy) { } // size and display constants for hover text -var HOVERARROWSIZE = 6, // pixel size of hover arrows - HOVERTEXTPAD = 3, // pixels padding around text - HOVERFONTSIZE = 13, - HOVERFONT = 'Arial, sans-serif'; - -// max pixels away from mouse to allow a point to highlight -fx.MAXDIST = 20; +var HOVERARROWSIZE = constants.HOVERARROWSIZE, + HOVERTEXTPAD = constants.HOVERTEXTPAD, + HOVERFONTSIZE = constants.HOVERFONTSIZE, + HOVERFONT = constants.HOVERFONT; // fx.hover: highlight data on hover // evt can be a mousemove event, or an object with data about what points @@ -270,8 +264,6 @@ fx.MAXDIST = 20; // The actual rendering is done by private functions // hover() and unhover(). -var HOVERMINTIME = 100; // minimum time between hover calls - fx.hover = function (gd, evt, subplot) { if(typeof gd === 'string') gd = document.getElementById(gd); if(gd._lastHoverTime === undefined) gd._lastHoverTime = 0; @@ -283,7 +275,7 @@ fx.hover = function (gd, evt, subplot) { } // Is it more than 100ms since the last update? If so, force // an update now (synchronously) and exit - if (Date.now() > gd._lastHoverTime + HOVERMINTIME) { + if (Date.now() > gd._lastHoverTime + constants.HOVERMINTIME) { hover(gd,evt,subplot); gd._lastHoverTime = Date.now(); return; @@ -293,7 +285,7 @@ fx.hover = function (gd, evt, subplot) { hover(gd,evt,subplot); gd._lastHoverTime = Date.now(); gd._hoverTimer = undefined; - }, HOVERMINTIME); + }, constants.HOVERMINTIME); }; fx.unhover = function (gd, evt, subplot) { @@ -450,7 +442,7 @@ function hover(gd, evt, subplot){ name: (gd.data.length>1 || trace.hoverinfo.indexOf('name')!==-1) ? trace.name : undefined, // point properties - override all of these index: false, // point index in trace - only used by plotly.js hoverdata consumers - distance: Math.min(distance, fx.MAXDIST), // pixel distance or pseudo-distance + distance: Math.min(distance, constants.MAXDIST), // pixel distance or pseudo-distance color: Plotly.Color.defaultLine, // trace color x0: undefined, x1: undefined, @@ -770,7 +762,7 @@ function createHoverText(hoverData, opts) { // show the common label, if any, on the axis // never show a common label in array mode, // even if sometimes there could be one - var showCommonLabel = c0.distance<=fx.MAXDIST && + var showCommonLabel = c0.distance<=constants.MAXDIST && (hovermode==='x' || hovermode==='y'); // all hover traces hoverinfo must contain the hovermode @@ -1320,6 +1312,8 @@ function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { ya = [plotinfo.y()], pw = xa[0]._length, ph = ya[0]._length, + MINDRAG = constants.MINDRAG, + MINZOOM = constants.MINZOOM, i, subplotXa, subplotYa; @@ -1370,15 +1364,40 @@ function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var dragOptions = { element: dragger, + gd: gd, + plotinfo: plotinfo, + xaxes: xa, + yaxes: ya, + doubleclick: doubleClick, prepFn: function(e, startX, startY) { - if(ns+ew==='nsew' && ((fullLayout.dragmode==='zoom') ? - !e.shiftKey : e.shiftKey)) { + fx.unhover(gd); // we want a clear plot for dragging + var dragModeNow = gd._fullLayout.dragmode; + if(ns + ew === 'nsew') { + // main dragger handles all drag modes, and changes + // to pan (or to zoom if it already is pan) on shift + if(e.shiftKey) { + if(dragModeNow === 'pan') dragModeNow = 'zoom'; + else dragModeNow = 'pan'; + } + } + // all other draggers just pan + else dragModeNow = 'pan'; + + if(dragModeNow === 'lasso') dragOptions.minDrag = 1; + else dragOptions.minDrag = undefined; + + if(dragModeNow === 'zoom') { dragOptions.moveFn = zoomMove; dragOptions.doneFn = zoomDone; zoomPrep(e, startX, startY); - } else { + } + else if(dragModeNow === 'pan') { dragOptions.moveFn = plotDrag; dragOptions.doneFn = dragDone; + clearSelect(); + } + else if(dragModeNow === 'select' || dragModeNow === 'lasso') { + prepSelect(e, startX, startY, dragOptions, dragModeNow); } } }; @@ -1425,15 +1444,23 @@ function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { }) .attr('d','M0,0Z'); + clearSelect(); for(i = 0; i < allaxes.length; i++) forceNumbers(allaxes[i].range); } + function clearSelect() { + // until we get around to persistent selections, remove the outline + // here. The selection itself will be removed when the plot redraws + // at the end. + plotinfo.plot.selectAll('.select-outline').remove(); + } + function zoomMove(dx0, dy0) { var x1 = Math.max(0, Math.min(pw, dx0 + x0)), y1 = Math.max(0, Math.min(ph, dy0 + y0)), dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0), - clen = Math.floor(Math.min(dy, dx, fx.MINZOOM) / 2); + clen = Math.floor(Math.min(dy, dx, MINZOOM) / 2); box.l = Math.min(x0, x1); box.r = Math.max(x0, x1); @@ -1442,8 +1469,8 @@ function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { // look for small drags in one direction or the other, // and only drag the other axis - if(!yActive || dy < Math.min(Math.max(dx * 0.6, fx.MINDRAG), fx.MINZOOM)) { - if(dx < fx.MINDRAG) { + if(!yActive || dy < Math.min(Math.max(dx * 0.6, MINDRAG), MINZOOM)) { + if(dx < MINDRAG) { zoomMode = ''; box.r = box.l; box.t = box.b; @@ -1454,21 +1481,21 @@ function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { box.b = ph; zoomMode = 'x'; corners.attr('d', - 'M'+(box.l-0.5)+','+(y0-fx.MINZOOM-0.5)+ - 'h-3v'+(2*fx.MINZOOM+1)+'h3ZM'+ - (box.r+0.5)+','+(y0-fx.MINZOOM-0.5)+ - 'h3v'+(2*fx.MINZOOM+1)+'h-3Z'); + 'M' + (box.l - 0.5) + ',' + (y0 - MINZOOM - 0.5) + + 'h-3v' + (2 * MINZOOM + 1) + 'h3ZM' + + (box.r + 0.5) + ',' + (y0 - MINZOOM - 0.5) + + 'h3v' + (2 * MINZOOM + 1) + 'h-3Z'); } } - else if(!xActive || dx < Math.min(dy * 0.6, fx.MINZOOM)) { + else if(!xActive || dx < Math.min(dy * 0.6, MINZOOM)) { box.l = 0; box.r = pw; zoomMode = 'y'; corners.attr('d', - 'M'+(x0-fx.MINZOOM-0.5)+','+(box.t-0.5)+ - 'v-3h'+(2*fx.MINZOOM+1)+'v3ZM'+ - (x0-fx.MINZOOM-0.5)+','+(box.b+0.5)+ - 'v3h'+(2*fx.MINZOOM+1)+'v-3Z'); + 'M' + (x0 - MINZOOM - 0.5) + ',' + (box.t - 0.5) + + 'v-3h' + (2 * MINZOOM + 1) + 'v3ZM' + + (x0 - MINZOOM - 0.5) + ',' + (box.b + 0.5) + + 'v3h' + (2 * MINZOOM + 1) + 'v-3Z'); } else { zoomMode = 'xy'; @@ -1520,7 +1547,7 @@ function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } function zoomDone(dragged, numClicks) { - if(Math.min(box.h, box.w) < fx.MINDRAG * 2) { + if(Math.min(box.h, box.w) < MINDRAG * 2) { if(numClicks === 2) doubleClick(); else pauseForDrag(gd); @@ -1879,7 +1906,7 @@ function pauseForDrag(gd) { gd._replotPending = deferredReplot; finishDrag(gd); }, - fx.DBLCLICKDELAY); + constants.DBLCLICKDELAY); } function finishDrag(gd) { @@ -1957,6 +1984,7 @@ fx.dragCursors = function(x,y,xanchor,yanchor){ fx.dragElement = function(options) { var gd = Plotly.Lib.getPlotDiv(options.element) || {}, numClicks = 1, + DBLCLICKDELAY = constants.DBLCLICKDELAY, startX, startY, newMouseDownTime, @@ -1980,7 +2008,7 @@ fx.dragElement = function(options) { initialTarget = e.target; newMouseDownTime = (new Date()).getTime(); - if(newMouseDownTime - gd._mouseDownTime < fx.DBLCLICKDELAY) { + if(newMouseDownTime - gd._mouseDownTime < DBLCLICKDELAY) { // in a click train numClicks += 1; } @@ -1998,14 +2026,18 @@ fx.dragElement = function(options) { dragCover.onmouseup = onDone; dragCover.onmouseout = onDone; + dragCover.style.cursor = window.getComputedStyle(options.element).cursor; + return Plotly.Lib.pauseEvent(e); } function onMove(e) { var dx = e.clientX - startX, - dy = e.clientY - startY; - if(Math.abs(dx) fx.DBLCLICKDELAY) { + if((new Date()).getTime() - gd._mouseDownTime > DBLCLICKDELAY) { numClicks = Math.max(numClicks - 1, 1); } @@ -2085,7 +2117,7 @@ fx.setCursor = function(el3,csr) { // count one edge as in, so that over continuous ranges you never get a gap fx.inbox = function(v0,v1){ if(v0*v1<0 || v0===0) { - return fx.MAXDIST*(0.6-0.3/Math.max(3,Math.abs(v0-v1))); + return constants.MAXDIST*(0.6-0.3/Math.max(3,Math.abs(v0-v1))); } return Infinity; }; diff --git a/src/plots/cartesian/select.js b/src/plots/cartesian/select.js new file mode 100644 index 00000000000..f86f336754d --- /dev/null +++ b/src/plots/cartesian/select.js @@ -0,0 +1,180 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var polygon = require('../../lib/polygon'); +var color = require('../../components/color'); + +var axes = require('./axes'); +var constants = require('./constants'); + +var filteredPolygon = polygon.filter; +var polygonTester = polygon.tester; +var MINSELECT = constants.MINSELECT; + +function getAxId(ax) { return ax._id; } + +module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { + var plot = dragOptions.plotinfo.plot, + dragBBox = dragOptions.element.getBoundingClientRect(), + x0 = startX - dragBBox.left, + y0 = startY - dragBBox.top, + x1 = x0, + y1 = y0, + path0 = 'M' + x0 + ',' + y0, + pw = dragOptions.xaxes[0]._length, + ph = dragOptions.yaxes[0]._length, + xAxisIds = dragOptions.xaxes.map(getAxId), + yAxisIds = dragOptions.yaxes.map(getAxId), + allAxes = dragOptions.xaxes.concat(dragOptions.yaxes); + + if(mode === 'lasso') { + var pts = filteredPolygon([[x0, y0]], constants.BENDPX); + } + + var outlines = plot.selectAll('path.select-outline').data([1,2]); + + outlines.enter() + .append('path') + .attr('class', function(d) { return 'select-outline select-outline-' + d; }) + .attr('d', path0 + 'Z'); + + var corners = plot.append('path') + .attr('class', 'zoombox-corners') + .style({ + fill: color.background, + stroke: color.defaultLine, + 'stroke-width': 1 + }) + .attr('d','M0,0Z'); + + + // find the traces to search for selection points + var searchTraces = [], + gd = dragOptions.gd, + i, + cd, + trace, + searchInfo, + selection = [], + eventData; + for(i = 0; i < gd.calcdata.length; i++) { + cd = gd.calcdata[i]; + trace = cd[0].trace; + if(!trace._module || !trace._module.selectPoints) continue; + + if(xAxisIds.indexOf(trace.xaxis) === -1) continue; + if(yAxisIds.indexOf(trace.yaxis) === -1) continue; + + searchTraces.push({ + selectPoints: trace._module.selectPoints, + cd: cd, + xaxis: axes.getFromId(gd, trace.xaxis), + yaxis: axes.getFromId(gd, trace.yaxis) + }); + } + + function axValue(ax) { + var index = (ax._id.charAt(0) === 'y') ? 1 : 0; + return function(v) { return ax.p2d(v[index]); }; + } + + function ascending(a, b){ return a - b; } + + dragOptions.moveFn = function(dx0, dy0) { + var poly, + ax; + x1 = Math.max(0, Math.min(pw, dx0 + x0)); + y1 = Math.max(0, Math.min(ph, dy0 + y0)); + + var dx = Math.abs(x1 - x0), + dy = Math.abs(y1 - y0); + + if(mode === 'select') { + if(dy < Math.min(dx * 0.6, MINSELECT)) { + // horizontal motion: make a vertical box + poly = polygonTester([[x0, 0], [x0, ph], [x1, ph], [x1, 0]]); + // extras to guide users in keeping a straight selection + corners.attr('d', 'M' + poly.xmin + ',' + (y0 - MINSELECT) + + 'h-4v' + (2 * MINSELECT) + 'h4Z' + + 'M' + (poly.xmax - 1) + ',' + (y0 - MINSELECT) + + 'h4v' + (2 * MINSELECT) + 'h-4Z'); + + } + else if(dx < Math.min(dy * 0.6, MINSELECT)) { + // vertical motion: make a horizontal box + poly = polygonTester([[0, y0], [0, y1], [pw, y1], [pw, y0]]); + corners.attr('d', 'M' + (x0 - MINSELECT) + ',' + poly.ymin + + 'v-4h' + (2 * MINSELECT) + 'v4Z' + + 'M' + (x0 - MINSELECT) + ',' + (poly.ymax - 1) + + 'v4h' + (2 * MINSELECT) + 'v-4Z'); + } + else { + // diagonal motion + poly = polygonTester([[x0, y0], [x0, y1], [x1, y1], [x1, y0]]); + corners.attr('d','M0,0Z'); + } + outlines.attr('d', 'M' + poly.xmin + ',' + poly.ymin + + 'H' + (poly.xmax - 1) + 'V' + (poly.ymax - 1) + + 'H' + poly.xmin + 'Z'); + } + else if(mode === 'lasso') { + pts.addPt([x1, y1]); + poly = polygonTester(pts.filtered); + outlines.attr('d', 'M' + pts.filtered.join('L') + 'Z'); + } + + selection = []; + for(i = 0; i < searchTraces.length; i++) { + searchInfo = searchTraces[i]; + [].push.apply(selection, searchInfo.selectPoints(searchInfo, poly)); + } + + eventData = {points: selection}; + + if(mode === 'select') { + var ranges = eventData.range = {}, + axLetter; + + for(i = 0; i < allAxes.length; i++) { + ax = allAxes[i]; + axLetter = ax._id.charAt(0); + ranges[ax._id] = [ + ax.p2d(poly[axLetter + 'min']), + ax.p2d(poly[axLetter + 'max'])].sort(ascending); + } + } + else { + var dataPts = eventData.lassoPoints = {}; + + for(i = 0; i < allAxes.length; i++) { + ax = allAxes[i]; + dataPts[ax._id] = pts.filtered.map(axValue(ax)); + } + } + dragOptions.gd.emit('plotly_selecting', eventData); + }; + + dragOptions.doneFn = function(dragged, numclicks) { + if(!dragged && numclicks === 2) { + // clear selection on doubleclick + outlines.remove(); + for(i = 0; i < searchTraces.length; i++) { + searchInfo = searchTraces[i]; + searchInfo.selectPoints(searchInfo, false); + } + } + else { + dragOptions.gd.emit('plotly_selected', eventData); + } + corners.remove(); + }; +}; + diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 6ff087d12dc..535d6aeced7 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -9,12 +9,22 @@ 'use strict'; -var Plotly = require('../../plotly'); var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); +var Plotly = require('../../plotly'); + +var subtypes = require('./subtypes'); + var scatter = module.exports = {}; +scatter.hasLines = subtypes.hasLines; +scatter.hasMarkers = subtypes.hasMarkers; +scatter.hasText = subtypes.hasText; +scatter.isBubble = subtypes.isBubble; + +scatter.selectPoints = require('./select'); + Plotly.Plots.register(scatter, 'scatter', ['cartesian', 'symbols', 'markerColorscale', 'errorBarsOK', 'showLegend'], { description: [ @@ -196,26 +206,6 @@ scatter.cleanData = function(fullData) { } }; -scatter.hasLines = function(trace) { - return trace.visible && trace.mode && - trace.mode.indexOf('lines') !== -1; -}; - -scatter.hasMarkers = function(trace) { - return trace.visible && trace.mode && - trace.mode.indexOf('markers') !== -1; -}; - -scatter.hasText = function(trace) { - return trace.visible && trace.mode && - trace.mode.indexOf('text') !== -1; -}; - -scatter.isBubble = function(trace) { - return (typeof trace.marker === 'object' && - Array.isArray(trace.marker.size)); -}; - scatter.colorbar = require('./colorbar'); // used in the drawing step for 'scatter' and 'scattegeo' and @@ -455,15 +445,17 @@ scatter.plot = function(gd, plotinfo, cdscatter) { tozero,tonext,nexttonext; scattertraces.each(function(d){ var trace = d[0].trace, - line = trace.line; + line = trace.line, + tr = d3.select(this); if(trace.visible !== true) return; + d[0].node3 = tr; // store node for tweaking by selectPoints + scatter.arraysToCalcdata(d); if(!scatter.hasLines(trace) && trace.fill==='none') return; - var tr = d3.select(this), - thispath, + var thispath, // fullpath is all paths for this curve, joined together straight // across gaps, for filling fullpath = '', diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js new file mode 100644 index 00000000000..25362f12186 --- /dev/null +++ b/src/traces/scatter/select.js @@ -0,0 +1,68 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var subtypes = require('./subtypes'); + +var DESELECTDIM = 0.2; + +module.exports = function selectPoints(searchInfo, polygon) { + var cd = searchInfo.cd, + xa = searchInfo.xaxis, + ya = searchInfo.yaxis, + selection = [], + trace = cd[0].trace, + curveNumber = trace.index, + marker = trace.marker, + i, + di, + x, + y; + + // TODO: include lines? that would require per-segment line properties + if(!subtypes.hasMarkers(trace) && ! subtypes.hasText(trace)) return; + + var opacity = Array.isArray(marker.opacity) ? 1 : marker.opacity; + + if(polygon === false) { // clear selection + for(i = 0; i < cd.length; i++) cd[i].dim = 0; + } + else { + for(i = 0; i < cd.length; i++) { + di = cd[i]; + x = xa.c2p(di.x); + y = ya.c2p(di.y); + if(polygon.contains([x, y])) { + selection.push({ + curveNumber: curveNumber, + pointNumber: i, + x: di.x, + y: di.y + }); + di.dim = 0; + } + else di.dim = 1; + } + } + + // do the dimming here, as well as returning the selection + // The logic here duplicates Drawing.pointStyle, but I don't want + // d.dim in pointStyle in case something goes wrong with selection. + cd[0].node3.selectAll('path.point') + .style('opacity', function(d) { + return ((d.mo+1 || opacity+1) - 1) * (d.dim ? DESELECTDIM : 1); + }); + cd[0].node3.selectAll('text') + .style('opacity', function(d) { + return d.dim ? DESELECTDIM : 1; + }); + + return selection; +}; diff --git a/src/traces/scatter/subtypes.js b/src/traces/scatter/subtypes.js new file mode 100644 index 00000000000..56814679824 --- /dev/null +++ b/src/traces/scatter/subtypes.js @@ -0,0 +1,32 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +module.exports = { + hasLines: function(trace) { + return trace.visible && trace.mode && + trace.mode.indexOf('lines') !== -1; + }, + + hasMarkers: function(trace) { + return trace.visible && trace.mode && + trace.mode.indexOf('markers') !== -1; + }, + + hasText: function(trace) { + return trace.visible && trace.mode && + trace.mode.indexOf('text') !== -1; + }, + + isBubble: function(trace) { + return (typeof trace.marker === 'object' && + Array.isArray(trace.marker.size)); + } +}; diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js index 942663fbf9e..06d64351469 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -25,6 +25,7 @@ describe('ModeBar', function() { dragmode: 'zoom', _paperdiv: d3.select(getMockContainerTree()) }, + _fullData: [], _context: { displaylogo: true, displayModeBar: true, @@ -158,7 +159,20 @@ describe('ModeBar', function() { return list; } - it('creates mode bar (cartesian version)', function() { + function checkButtons(modeBar, buttons, logos) { + var expectedGroupCount = buttons.length + logos; + var expectedButtonCount = logos; + buttons.forEach(function(group) { + expectedButtonCount += group.length; + }); + + expect(modeBar.hasButtons(buttons)).toBe(true); + expect(countGroups(modeBar)).toEqual(expectedGroupCount); + expect(countButtons(modeBar)).toEqual(expectedButtonCount); + expect(countLogo(modeBar)).toEqual(1); + } + + it('creates mode bar (unselectable cartesian version)', function() { var buttons = getButtons([ ['toImage', 'sendDataToCloud'], ['zoom2d', 'pan2d'], @@ -173,10 +187,31 @@ describe('ModeBar', function() { manageModeBar(gd); var modeBar = gd._fullLayout._modeBar; - expect(modeBar.hasButtons(buttons)).toBe(true); - expect(countGroups(modeBar)).toEqual(5); - expect(countButtons(modeBar)).toEqual(11); - expect(countLogo(modeBar)).toEqual(1); + checkButtons(modeBar, buttons, 1); + }); + + it('creates mode bar (selectable cartesian version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], + ['zoom2d', 'pan2d', 'select2d', 'lasso2d'], + ['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d'], + ['hoverClosestCartesian', 'hoverCompareCartesian'] + ]); + + var gd = getMockGraphInfo(); + gd._fullLayout._hasCartesian = true; + gd._fullLayout.xaxis = {fixedrange: false}; + gd._fullData = [{ + type:'scatter', + visible: true, + mode:'markers', + _module: {selectPoints: true} + }]; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + checkButtons(modeBar, buttons, 1); }); it('creates mode bar (cartesian fixed-axes version)', function() { @@ -191,10 +226,7 @@ describe('ModeBar', function() { manageModeBar(gd); var modeBar = gd._fullLayout._modeBar; - expect(modeBar.hasButtons(buttons)).toBe(true); - expect(countGroups(modeBar)).toEqual(3); - expect(countButtons(modeBar)).toEqual(5); - expect(countLogo(modeBar)).toEqual(1); + checkButtons(modeBar, buttons, 1); }); it('creates mode bar (gl3d version)', function() { @@ -211,10 +243,7 @@ describe('ModeBar', function() { manageModeBar(gd); var modeBar = gd._fullLayout._modeBar; - expect(modeBar.hasButtons(buttons)).toBe(true); - expect(countGroups(modeBar)).toEqual(5); - expect(countButtons(modeBar)).toEqual(10); - expect(countLogo(modeBar)).toEqual(1); + checkButtons(modeBar, buttons, 1); }); it('creates mode bar (geo version)', function() { @@ -230,10 +259,7 @@ describe('ModeBar', function() { manageModeBar(gd); var modeBar = gd._fullLayout._modeBar; - expect(modeBar.hasButtons(buttons)).toBe(true); - expect(countGroups(modeBar)).toEqual(4); - expect(countButtons(modeBar)).toEqual(7); - expect(countLogo(modeBar)).toEqual(1); + checkButtons(modeBar, buttons, 1); }); it('creates mode bar (gl2d version)', function() { @@ -251,10 +277,7 @@ describe('ModeBar', function() { manageModeBar(gd); var modeBar = gd._fullLayout._modeBar; - expect(modeBar.hasButtons(buttons)).toBe(true); - expect(countGroups(modeBar)).toEqual(5); - expect(countButtons(modeBar)).toEqual(10); - expect(countLogo(modeBar)).toEqual(1); + checkButtons(modeBar, buttons, 1); }); it('creates mode bar (pie version)', function() { @@ -269,10 +292,7 @@ describe('ModeBar', function() { manageModeBar(gd); var modeBar = gd._fullLayout._modeBar; - expect(modeBar.hasButtons(buttons)).toBe(true); - expect(countGroups(modeBar)).toEqual(3); - expect(countButtons(modeBar)).toEqual(4); - expect(countLogo(modeBar)).toEqual(1); + checkButtons(modeBar, buttons, 1); }); it('throws an error if modeBarButtonsToRemove isn\'t an array', function() { @@ -339,25 +359,32 @@ describe('ModeBar', function() { it('updates mode bar buttons if modeBarButtonsToRemove changes', function() { var gd = setupGraphInfo(); manageModeBar(gd); + var initialButtonCount = countButtons(gd._fullLayout._modeBar); gd._context.modeBarButtonsToRemove = ['toImage', 'sendDataToCloud']; manageModeBar(gd); - expect(countButtons(gd._fullLayout._modeBar)).toEqual(9); + expect(countButtons(gd._fullLayout._modeBar)) + .toEqual(initialButtonCount - 2); }); it('updates mode bar buttons if modeBarButtonsToAdd changes', function() { var gd = setupGraphInfo(); manageModeBar(gd); + var initialGroupCount = countGroups(gd._fullLayout._modeBar), + initialButtonCount = countButtons(gd._fullLayout._modeBar); + gd._context.modeBarButtonsToAdd = [{ name: 'some button', click: noop }]; manageModeBar(gd); - expect(countGroups(gd._fullLayout._modeBar)).toEqual(6); - expect(countButtons(gd._fullLayout._modeBar)).toEqual(12); + expect(countGroups(gd._fullLayout._modeBar)) + .toEqual(initialGroupCount + 1); + expect(countButtons(gd._fullLayout._modeBar)) + .toEqual(initialButtonCount + 1); }); it('sets up buttons with modeBarButtonsToAdd and modeBarButtonToRemove', function() { diff --git a/test/jasmine/tests/polygon_test.js b/test/jasmine/tests/polygon_test.js new file mode 100644 index 00000000000..4f17ad277aa --- /dev/null +++ b/test/jasmine/tests/polygon_test.js @@ -0,0 +1,214 @@ +var polygon = require('@src/lib/polygon'), + polygonTester = polygon.tester, + isBent = polygon.isSegmentBent, + filter = polygon.filter; + +describe('polygon.tester', function() { + 'use strict'; + + var squareCW = [[0, 0], [0, 1], [1, 1], [1, 0]], + squareCCW = [[0, 0], [1, 0], [1, 1], [0, 1]], + bowtie = [[0, 0], [0, 1], [1, 0], [1, 1]], + squareish = [ + [-0.123, -0.0456], + [0.12345, 1.2345], + [1.3456, 1.4567], + [1.5678, 0.21345]], + equilateralTriangle = [ + [0, Math.sqrt(3) / 3], + [-0.5, -Math.sqrt(3) / 6], + [0.5, -Math.sqrt(3) / 6]], + + zigzag = [ // 4 * + [0, 0], [2, 1], // \-. + [0, 1], [2, 2], // 3 * * + [1, 2], [3, 3], // ,-' | + [2, 4], [4, 3], // 2 *-* | + [4, 0]], // ,-' | + // 1 *---* | + // ,-' | + // 0 *-------* + // 0 1 2 3 4 + inZigzag = [ + [0.5, 0.01], [1, 0.49], [1.5, 0.5], [2, 0.5], [2.5, 0.5], [3, 0.5], + [3.5, 0.5], [0.5, 1.01], [1, 1.49], [1.5, 1.5], [2, 1.5], [2.5, 1.5], + [3, 1.5], [3.5, 1.5], [1.5, 2.01], [2, 2.49], [2.5, 2.5], [3, 2.5], + [3.5, 2.5], [2.5, 3.51], [3, 3.49]], + notInZigzag = [ + [0, -0.01], [0, 0.01], [0, 0.99], [0, 1.01], [0.5, -0.01], [0.5, 0.26], + [0.5, 0.99], [0.5, 1.26], [1, -0.01], [1, 0.51], [1, 0.99], [1, 1.51], + [1, 1.99], [1, 2.01], [2, -0.01], [2, 2.51], [2, 3.99], [2, 4.01], + [3, -0.01], [2.99, 3], [3, 3.51], [4, -0.01], [4, 3.01]], + + donut = [ // inner CCW, outer CW // 3 *-----* + [3, 0], [0, 0], [0, 1], [2, 1], [2, 2], // | | + [1, 2], [1, 1], [0, 1], [0, 3], [3, 3]], // 2 | *-* | + donut2 = [ // inner CCW, outer CCW // | | | | + [3, 3], [0, 3], [0, 1], [2, 1], [2, 2], // 1 *-*-* | + [1, 2], [1, 1], [0, 1], [0, 0], [3, 0]], // | | + // 0 *-----* + // 0 1 2 3 + inDonut = [[0.5, 0.5], [1, 0.5], [1.5, 0.5], [2, 0.5], [2.5, 0.5], + [2.5, 1], [2.5, 1.5], [2.5, 2], [2.5, 2.5], [2, 2.5], [1.5, 2.5], + [1, 2.5], [0.5, 2.5], [0.5, 2], [0.5, 1.5], [0.5, 1]], + notInDonut = [[1.5, -0.5], [1.5, 1.5], [1.5, 3.5], [-0.5, 1.5], [3.5, 1.5]]; + + it('should exclude points outside the bounding box', function() { + var poly = polygonTester([[1,2], [3,4]]); + var pts = [[0, 3], [4, 3], [2, 1], [2, 5]]; + pts.forEach(function(pt) { + expect(poly.contains(pt)).toBe(false); + expect(poly.contains(pt, true)).toBe(false); + expect(poly.contains(pt, false)).toBe(false); + }); + }); + + it('should prepare a polygon object correctly', function() { + var polyPts = [squareCW, squareCCW, bowtie, squareish, equilateralTriangle, + zigzag, donut, donut2]; + + polyPts.forEach(function(polyPt) { + var poly = polygonTester(polyPt), + xArray = polyPt.map(function(pt) { return pt[0]; }), + yArray = polyPt.map(function(pt) { return pt[1]; }); + + expect(poly.pts.length).toEqual(polyPt.length + 1); + polyPt.forEach(function(pt, i) { + expect(poly.pts[i]).toEqual(pt); + }); + expect(poly.pts[poly.pts.length - 1]).toEqual(polyPt[0]); + expect(poly.xmin).toEqual(Math.min.apply(null, xArray)); + expect(poly.xmax).toEqual(Math.max.apply(null, xArray)); + expect(poly.ymin).toEqual(Math.min.apply(null, yArray)); + expect(poly.ymax).toEqual(Math.max.apply(null, yArray)); + }); + }); + + it('should include the whole boundary, except as per omitFirstEdge', function() { + var polyPts = [squareCW, squareCCW, bowtie, squareish, equilateralTriangle, + zigzag, donut, donut2]; + var np = 6; // number of intermediate points on each edge to test + + polyPts.forEach(function(polyPt) { + var poly = polygonTester(polyPt); + + var isRect = polyPt === squareCW || polyPt === squareCCW; + expect(poly.isRect).toBe(isRect); + // to make sure we're only using the bounds and first pt, delete the rest + if(isRect) poly.pts.splice(1, poly.pts.length); + + poly.pts.forEach(function(pt1, i) { + if(!i) return; + var pt0 = poly.pts[i - 1], + j; + + var testPts = [pt0, pt1]; + for(j = 1; j < np; j++) { + if(pt0[0] === pt1[0]) { + testPts.push([pt0[0], pt0[1] + (pt1[1] - pt0[1]) * j / np]); + } + else { + var x = pt0[0] + (pt1[0] - pt0[0]) * j / np; + // calculated the same way as in the pt_in_polygon source, + // so we know rounding errors will apply the same and this pt + // *really* appears on the boundary + testPts.push([x, pt0[1] + (x - pt0[0]) * (pt1[1] - pt0[1]) / + (pt1[0] - pt0[0])]); + } + } + testPts.forEach(function(pt, j) { + expect(poly.contains(pt)) + .toBe(true, 'poly: ' + polyPt.join(';') + ', pt: ' + pt); + var isFirstEdge = (i === 1) || (i === 2 && j === 0) || + (i === poly.pts.length - 1 && j === 1); + expect(poly.contains(pt, true)) + .toBe(!isFirstEdge, 'omit: ' + !isFirstEdge + ', poly: ' + + polyPt.join(';') + ', pt: ' + pt); + }); + }); + }); + }); + + it('should find only the right interior points', function() { + var zzpoly = polygonTester(zigzag); + inZigzag.forEach(function(pt) { + expect(zzpoly.contains(pt)).toBe(true); + }); + notInZigzag.forEach(function(pt) { + expect(zzpoly.contains(pt)).toBe(false); + }); + + var donutpoly = polygonTester(donut), + donut2poly = polygonTester(donut2); + inDonut.forEach(function(pt) { + expect(donutpoly.contains(pt)).toBe(true); + expect(donut2poly.contains(pt)).toBe(true); + }); + notInDonut.forEach(function(pt) { + expect(donutpoly.contains(pt)).toBe(false); + expect(donut2poly.contains(pt)).toBe(false); + }); + }); +}); + +describe('polygon.isSegmentBent', function() { + 'use strict'; + + var pts = [[0, 0], [1, 1], [2, 0], [1, 0], [100, -37]]; + + it('should treat any two points as straight', function() { + for(var i = 0; i < pts.length - 1; i++) { + expect(isBent(pts, i, i + 1, 0)).toBe(false); + } + }); + + function rotatePt(theta) { + return function(pt) { + return [ + pt[0] * Math.cos(theta) - pt[1] * Math.sin(theta), + pt[0] * Math.sin(theta) + pt[1] * Math.cos(theta)]; + }; + } + + it('should find a bent line at the right tolerance', function() { + for(var theta = 0; theta < 6; theta += 0.3) { + var pts2 = pts.map(rotatePt(theta)); + expect(isBent(pts2, 0, 2, 0.99)).toBe(true); + expect(isBent(pts2, 0, 2, 1.01)).toBe(false); + } + }); + + it('should treat any backward motion as bent', function() { + expect(isBent([[0, 0], [2, 0], [1, 0]], 0, 2, 10)).toBe(true); + }); +}); + +describe('polygon.filter', function() { + 'use strict'; + + var pts = [ + [0, 0], [1, 0], [2, 0], [3, 0], + [3, 1], [3, 2], [3, 3], + [2, 3], [1, 3], [0, 3], + [0, 2], [0, 1], [0, 0]]; + + var ptsOut = [[0, 0], [3, 0], [3, 3], [0, 3], [0, 0]]; + + it('should give the right result if points are provided upfront', function() { + expect(filter(pts, 0.5).filtered).toEqual(ptsOut); + }); + + it('should give the right result if points are added one-by-one', function() { + var p = filter([pts[0]], 0.5), + i; + + // intermediate result (the last point isn't in the final) + for(i = 1; i < 6; i++) p.addPt(pts[i]); + expect(p.filtered).toEqual([[0, 0], [3, 0], [3, 2]]); + + // final result + for(i = 6; i < pts.length; i++) p.addPt(pts[i]); + expect(p.filtered).toEqual(ptsOut); + }); + +});