diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 92066a95f71..61a29b9fda8 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -487,7 +487,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { // to get the parity of the number of intersections. if(edges.reduce(function(a, x) { return a ^ - !!lineIntersect(headX, headY, headX + 1e6, headY + 1e6, + !!Lib.segmentsIntersect(headX, headY, headX + 1e6, headY + 1e6, x[0], x[1], x[2], x[3]); }, false)) { // no line or arrow - so quit drawArrow now @@ -495,7 +495,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { } edges.forEach(function(x) { - var p = lineIntersect(tailX, tailY, headX, headY, + var p = Lib.segmentsIntersect(tailX, tailY, headX, headY, x[0], x[1], x[2], x[3]); if(p) { tailX = p.x; @@ -701,24 +701,3 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { } else annText.call(textLayout); } - -// look for intersection of two line segments -// (1->2 and 3->4) - returns array [x,y] if they do, null if not -function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { - var a = x2 - x1, - b = x3 - x1, - c = x4 - x3, - d = y2 - y1, - e = y3 - y1, - f = y4 - y3, - det = a * f - c * d; - // parallel lines? intersection is undefined - // ignore the case where they are colinear - if(det === 0) return null; - var t = (b * f - c * e) / det, - u = (b * d - a * e) / det; - // segments do not intersect? - if(u < 0 || u > 1 || t < 0 || t > 1) return null; - - return {x: x1 + a * t, y: y1 + d * t}; -} diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index c2e2540ef97..e234c5d2a68 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -34,7 +34,7 @@ var drawing = module.exports = {}; drawing.font = function(s, family, size, color) { // also allow the form font(s, {family, size, color}) - if(family && family.family) { + if(Lib.isPlainObject(family)) { color = family.color; size = family.size; family = family.family; @@ -569,9 +569,6 @@ drawing.steps = function(shape) { // off-screen svg render testing element, shared by the whole page // uses the id 'js-plotly-tester' and stores it in drawing.tester -// makes a hash of cached text items in tester.node()._cache -// so we can add references to rendered text (including all info -// needed to fully determine its bounding rect) drawing.makeTester = function() { var tester = d3.select('body') .selectAll('#js-plotly-tester') @@ -601,25 +598,37 @@ drawing.makeTester = function() { fill: 'black' }); - if(!tester.node()._cache) { - tester.node()._cache = {}; - } - drawing.tester = tester; drawing.testref = testref; }; /* * use our offscreen tester to get a clientRect for an element, - * in a reference frame where it isn't translated and its anchor - * point is at (0,0) + * in a reference frame where it isn't translated (or transformed) and + * its anchor point is at (0,0) * always returns a copy of the bbox, so the caller can modify it safely + * + * @param {SVGElement} node: the element to measure. If possible this should be + * a or MathJax element that's already passed through + * `convertToTspans` because in that case we can cache the results, but it's + * possible to pass in any svg element. + * + * @param {boolean} inTester: is this element already in `drawing.tester`? + * If you are measuring a dummy element, rather than one you really intend + * to use on the plot, making it in `drawing.tester` in the first place + * allows us to test faster because it cuts out cloning and appending it. + * + * @param {string} hash: for internal use only, if we already know the cache key + * for this element beforehand. + * + * @return {object}: a plain object containing the width, height, left, right, + * top, and bottom of `node` */ drawing.savedBBoxes = {}; var savedBBoxesCount = 0; var maxSavedBBoxes = 10000; -drawing.bBox = function(node, hash) { +drawing.bBox = function(node, inTester, hash) { /* * Cache elements we've already measured so we don't have to * remeasure the same thing many times @@ -652,7 +661,7 @@ drawing.bBox = function(node, hash) { if(!transform) { // in this case, just varying x and y, don't bother caching // the final bBox because the alteration is quick. - var innerBB = drawing.bBox(innerNode, hash); + var innerBB = drawing.bBox(innerNode, false, hash); if(x) { innerBB.left += x; innerBB.right += x; @@ -679,12 +688,17 @@ drawing.bBox = function(node, hash) { if(out) return Lib.extendFlat({}, out); } } + var testNode, tester; + if(inTester) { + testNode = node; + } + else { + tester = drawing.tester.node(); - var tester = drawing.tester.node(); - - // copy the node to test into the tester - var testNode = node.cloneNode(true); - tester.appendChild(testNode); + // copy the node to test into the tester + testNode = node.cloneNode(true); + tester.appendChild(testNode); + } // standardize its position (and newline tspans if any) d3.select(testNode) @@ -696,7 +710,7 @@ drawing.bBox = function(node, hash) { .node() .getBoundingClientRect(); - tester.removeChild(testNode); + if(!inTester) tester.removeChild(testNode); var bb = { height: testRect.height, diff --git a/src/lib/geometry2d.js b/src/lib/geometry2d.js new file mode 100644 index 00000000000..a946ccf5e23 --- /dev/null +++ b/src/lib/geometry2d.js @@ -0,0 +1,195 @@ +/** +* Copyright 2012-2017, 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 mod = require('./mod'); + +/* + * look for intersection of two line segments + * (1->2 and 3->4) - returns array [x,y] if they do, null if not + */ +exports.segmentsIntersect = segmentsIntersect; +function segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { + var a = x2 - x1, + b = x3 - x1, + c = x4 - x3, + d = y2 - y1, + e = y3 - y1, + f = y4 - y3, + det = a * f - c * d; + // parallel lines? intersection is undefined + // ignore the case where they are colinear + if(det === 0) return null; + var t = (b * f - c * e) / det, + u = (b * d - a * e) / det; + // segments do not intersect? + if(u < 0 || u > 1 || t < 0 || t > 1) return null; + + return {x: x1 + a * t, y: y1 + d * t}; +} + +/* + * find the minimum distance between two line segments (1->2 and 3->4) + */ +exports.segmentDistance = function segmentDistance(x1, y1, x2, y2, x3, y3, x4, y4) { + if(segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)) return 0; + + // the two segments and their lengths squared + var x12 = x2 - x1; + var y12 = y2 - y1; + var x34 = x4 - x3; + var y34 = y4 - y3; + var l2_12 = x12 * x12 + y12 * y12; + var l2_34 = x34 * x34 + y34 * y34; + + // calculate distance squared, then take the sqrt at the very end + var dist2 = Math.min( + perpDistance2(x12, y12, l2_12, x3 - x1, y3 - y1), + perpDistance2(x12, y12, l2_12, x4 - x1, y4 - y1), + perpDistance2(x34, y34, l2_34, x1 - x3, y1 - y3), + perpDistance2(x34, y34, l2_34, x2 - x3, y2 - y3) + ); + + return Math.sqrt(dist2); +}; + +/* + * distance squared from segment ab to point c + * [xab, yab] is the vector b-a + * [xac, yac] is the vector c-a + * l2_ab is the length squared of (b-a), just to simplify calculation + */ +function perpDistance2(xab, yab, l2_ab, xac, yac) { + var fc_ab = (xac * xab + yac * yab); + if(fc_ab < 0) { + // point c is closer to point a + return xac * xac + yac * yac; + } + else if(fc_ab > l2_ab) { + // point c is closer to point b + var xbc = xac - xab; + var ybc = yac - yab; + return xbc * xbc + ybc * ybc; + } + else { + // perpendicular distance is the shortest + var crossProduct = xac * yab - yac * xab; + return crossProduct * crossProduct / l2_ab; + } +} + +// a very short-term cache for getTextLocation, just because +// we're often looping over the same locations multiple times +// invalidated as soon as we look at a different path +var locationCache, workingPath, workingTextWidth; + +// turn a path and position along it into x, y, and angle for the given text +exports.getTextLocation = function getTextLocation(path, totalPathLen, positionOnPath, textWidth) { + if(path !== workingPath || textWidth !== workingTextWidth) { + locationCache = {}; + workingPath = path; + workingTextWidth = textWidth; + } + if(locationCache[positionOnPath]) { + return locationCache[positionOnPath]; + } + + // for the angle, use points on the path separated by the text width + // even though due to curvature, the text will cover a bit more than that + var p0 = path.getPointAtLength(mod(positionOnPath - textWidth / 2, totalPathLen)); + var p1 = path.getPointAtLength(mod(positionOnPath + textWidth / 2, totalPathLen)); + // note: atan handles 1/0 nicely + var theta = Math.atan((p1.y - p0.y) / (p1.x - p0.x)); + // center the text at 2/3 of the center position plus 1/3 the p0/p1 midpoint + // that's the average position of this segment, assuming it's roughly quadratic + var pCenter = path.getPointAtLength(mod(positionOnPath, totalPathLen)); + var x = (pCenter.x * 4 + p0.x + p1.x) / 6; + var y = (pCenter.y * 4 + p0.y + p1.y) / 6; + + var out = {x: x, y: y, theta: theta}; + locationCache[positionOnPath] = out; + return out; +}; + +exports.clearLocationCache = function() { + workingPath = null; +}; + +/* + * Find the segment of `path` that's within the visible area + * given by `bounds` {left, right, top, bottom}, to within a + * precision of `buffer` px + * + * returns: undefined if nothing is visible, else object: + * { + * min: position where the path first enters bounds, or 0 if it + * starts within bounds + * max: position where the path last exits bounds, or the path length + * if it finishes within bounds + * len: max - min, ie the length of visible path + * total: the total path length - just included so the caller doesn't + * need to call path.getTotalLength() again + * isClosed: true iff the start and end points of the path are both visible + * and are at the same point + * } + * + * Works by starting from either end and repeatedly finding the distance from + * that point to the plot area, and if it's outside the plot, moving along the + * path by that distance (because the plot must be at least that far away on + * the path). Note that if a path enters, exits, and re-enters the plot, we + * will not capture this behavior. + */ +exports.getVisibleSegment = function getVisibleSegment(path, bounds, buffer) { + var left = bounds.left; + var right = bounds.right; + var top = bounds.top; + var bottom = bounds.bottom; + + var pMin = 0; + var pTotal = path.getTotalLength(); + var pMax = pTotal; + + var pt0, ptTotal; + + function getDistToPlot(len) { + var pt = path.getPointAtLength(len); + + // hold on to the start and end points for `closed` + if(len === 0) pt0 = pt; + else if(len === pTotal) ptTotal = pt; + + var dx = (pt.x < left) ? left - pt.x : (pt.x > right ? pt.x - right : 0); + var dy = (pt.y < top) ? top - pt.y : (pt.y > bottom ? pt.y - bottom : 0); + return Math.sqrt(dx * dx + dy * dy); + } + + var distToPlot = getDistToPlot(pMin); + while(distToPlot) { + pMin += distToPlot + buffer; + if(pMin > pMax) return; + distToPlot = getDistToPlot(pMin); + } + + distToPlot = getDistToPlot(pMax); + while(distToPlot) { + pMax -= distToPlot + buffer; + if(pMin > pMax) return; + distToPlot = getDistToPlot(pMax); + } + + return { + min: pMin, + max: pMax, + len: pMax - pMin, + total: pTotal, + isClosed: pMin === 0 && pMax === pTotal && + Math.abs(pt0.x - ptTotal.x) < 0.1 && + Math.abs(pt0.y - ptTotal.y) < 0.1 + }; +}; diff --git a/src/lib/index.js b/src/lib/index.js index 25eff611e5c..c935d8fafac 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -74,6 +74,13 @@ lib.rotationXYMatrix = matrixModule.rotationXYMatrix; lib.apply2DTransform = matrixModule.apply2DTransform; lib.apply2DTransform2 = matrixModule.apply2DTransform2; +var geom2dModule = require('./geometry2d'); +lib.segmentsIntersect = geom2dModule.segmentsIntersect; +lib.segmentDistance = geom2dModule.segmentDistance; +lib.getTextLocation = geom2dModule.getTextLocation; +lib.clearLocationCache = geom2dModule.clearLocationCache; +lib.getVisibleSegment = geom2dModule.getVisibleSegment; + var extendModule = require('./extend'); lib.extendFlat = extendModule.extendFlat; lib.extendDeep = extendModule.extendDeep; diff --git a/src/plot_api/edit_types.js b/src/plot_api/edit_types.js new file mode 100644 index 00000000000..37a69c1bdd0 --- /dev/null +++ b/src/plot_api/edit_types.js @@ -0,0 +1,58 @@ +/** +* Copyright 2012-2017, 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 = { + /* + * default (all false) edit flags for restyle (traces) + * creates a new object each call, so the caller can mutate freely + */ + traces: function() { + return { + docalc: false, + docalcAutorange: false, + doplot: false, + dostyle: false, + docolorbars: false, + autorangeOn: false, + clearCalc: false, + fullReplot: false + }; + }, + + /* + * default (all false) edit flags for relayout + * creates a new object each call, so the caller can mutate freely + */ + layout: function() { + return { + dolegend: false, + doticks: false, + dolayoutstyle: false, + doplot: false, + docalc: false, + domodebar: false, + docamera: false, + layoutReplot: false + }; + }, + + /* + * update `flags` with the `editType` values found in `attr` + */ + update: function(flags, attr) { + var editType = attr.editType; + if(editType) { + var editTypeParts = editType.split('+'); + for(var i = 0; i < editTypeParts.length; i++) { + flags[editTypeParts[i]] = true; + } + } + } +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index c067d798f58..7a122a5890c 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -31,6 +31,8 @@ var svgTextUtils = require('../lib/svg_text_utils'); var manageArrays = require('./manage_arrays'); var helpers = require('./helpers'); var subroutines = require('./subroutines'); +var editTypes = require('./edit_types'); + var cartesianConstants = require('../plots/cartesian/constants'); var axisConstraints = require('../plots/cartesian/constraints'); var enforceAxisConstraints = axisConstraints.enforce; @@ -1264,16 +1266,7 @@ function _restyle(gd, aobj, _traces) { var traces = helpers.coerceTraceIndices(gd, _traces); // initialize flags - var flags = { - docalc: false, - docalcAutorange: false, - doplot: false, - dostyle: false, - docolorbars: false, - autorangeOn: false, - clearCalc: false, - fullReplot: false - }; + var flags = editTypes.traces(); // copies of the change (and previous values of anything affected) // for the undo / redo queue @@ -1304,8 +1297,6 @@ function _restyle(gd, aobj, _traces) { 'reversescale', 'marker.reversescale', 'autobinx', 'nbinsx', 'xbins', 'xbins.start', 'xbins.end', 'xbins.size', 'autobiny', 'nbinsy', 'ybins', 'ybins.start', 'ybins.end', 'ybins.size', - 'autocontour', 'ncontours', 'contours', 'contours.coloring', - 'contours.operation', 'contours.value', 'contours.type', 'contours.value[0]', 'contours.value[1]', 'error_y', 'error_y.visible', 'error_y.value', 'error_y.type', 'error_y.traceref', 'error_y.array', 'error_y.symmetric', 'error_y.arrayminus', 'error_y.valueminus', 'error_y.tracerefminus', @@ -1373,8 +1364,6 @@ function _restyle(gd, aobj, _traces) { 'marker.cmin', 'marker.cmax', 'marker.cauto', 'line.cmin', 'line.cmax', 'marker.line.cmin', 'marker.line.cmax', - 'contours.start', 'contours.end', 'contours.size', - 'contours.showlines', 'line', 'line.smoothing', 'line.shape', 'error_y.width', 'error_x.width', 'error_x.copy_ystyle', 'marker.maxdisplayed' @@ -1619,23 +1608,41 @@ function _restyle(gd, aobj, _traces) { flags.docalc = true; } else { - var moduleAttrs = (contFull._module || {}).attributes || {}; - var valObject = Lib.nestedProperty(moduleAttrs, ai).get() || - Lib.nestedProperty(Plots.attributes, ai).get() || - {}; + var aiHead = param.parts[0]; + var moduleAttrs = (contFull._module || {}).attributes; + var valObject = moduleAttrs && moduleAttrs[aiHead]; + if(!valObject) valObject = Plots.attributes[aiHead]; + if(valObject) { + /* + * In occasional cases we can't the innermost valObject + * doesn't exist, for example `valType: 'any'` items like + * contourcarpet `contours.value` where we might set + * `contours.value[0]`. In that case, stop at the deepest + * valObject we *do* find. + */ + for(var parti = 1; parti < param.parts.length; parti++) { + var newValObject = valObject[param.parts[parti]]; + if(newValObject) valObject = newValObject; + else break; + } - // if restyling entire attribute container, assume worse case - if(!valObject.valType) { - flags.docalc = true; - } + /* + * must redo calcdata when restyling: + * 1) array values of arrayOk attributes + * 2) a container object (it would be hard to tell what + * pieces changed, whether any are arrays, so to be + * safe we need to recalc) + */ + if(!valObject.valType || (valObject.arrayOk && (Array.isArray(newVal) || Array.isArray(oldVal)))) { + flags.docalc = true; + } - // must redo calcdata when restyling array values of arrayOk attributes - if(valObject.arrayOk && (Array.isArray(newVal) || Array.isArray(oldVal))) { - flags.docalc = true; + // some attributes declare an 'editType' flaglist + editTypes.update(flags, valObject); } - - // some attributes declare an 'editType' flaglist - if(valObject.editType === 'docalc') { + else { + // if we couldn't find valObject even at the root, + // assume a full recalc. flags.docalc = true; } @@ -1863,16 +1870,7 @@ function _relayout(gd, aobj) { } // initialize flags - var flags = { - dolegend: false, - doticks: false, - dolayoutstyle: false, - doplot: false, - docalc: false, - domodebar: false, - docamera: false, - layoutReplot: false - }; + var flags = editTypes.layout(); // copies of the change (and previous values of anything affected) // for the undo / redo queue diff --git a/src/plot_api/plot_schema.js b/src/plot_api/plot_schema.js index 03bc35ad760..9bb7efa8d5d 100644 --- a/src/plot_api/plot_schema.js +++ b/src/plot_api/plot_schema.js @@ -21,6 +21,8 @@ var animationAttributes = require('../plots/animation_attributes'); var polarAreaAttrs = require('../plots/polar/area_attributes'); var polarAxisAttrs = require('../plots/polar/axis_attributes'); +var editTypes = require('./edit_types'); + var extendFlat = Lib.extendFlat; var extendDeep = Lib.extendDeep; @@ -62,7 +64,11 @@ exports.get = function() { return { defs: { valObjects: Lib.valObjects, - metaKeys: UNDERSCORE_ATTRS.concat(['description', 'role']) + metaKeys: UNDERSCORE_ATTRS.concat(['description', 'role']), + editTypes: { + traces: editTypes.traces(), + layout: editTypes.layout() + } }, traces: traces, diff --git a/src/traces/carpet/defaults.js b/src/traces/carpet/defaults.js index 332117da1c4..0333644b4b9 100644 --- a/src/traces/carpet/defaults.js +++ b/src/traces/carpet/defaults.js @@ -21,6 +21,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, dfltColor, fullLayou return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } + traceOut._clipPathId = 'clip' + traceOut.uid + 'carpet'; + var defaultColor = coerce('color', colorAttrs.defaultLine); Lib.coerceFont(coerce, 'font'); @@ -55,6 +57,5 @@ module.exports = function supplyDefaults(traceIn, traceOut, dfltColor, fullLayou if(!len) { traceOut.visible = false; - return; } }; diff --git a/src/traces/carpet/plot.js b/src/traces/carpet/plot.js index 914982943c3..8c469851e90 100644 --- a/src/traces/carpet/plot.js +++ b/src/traces/carpet/plot.js @@ -36,8 +36,6 @@ function plotOne(gd, plotinfo, cd) { aax = trace.aaxis, bax = trace.baxis, fullLayout = gd._fullLayout; - // uid = trace.uid, - // id = 'carpet' + uid; var gridLayer = plotinfo.plot.selectAll('.carpetlayer'); var clipLayer = makeg(fullLayout._defs, 'g', 'clips'); @@ -65,17 +63,13 @@ function plotOne(gd, plotinfo, cd) { drawAxisTitles(gd, labelLayer, trace, t, xa, ya, maxAExtent, maxBExtent); - // Swap for debugging in order to draw directly: - // drawClipPath(trace, axisLayer, xa, ya); drawClipPath(trace, t, clipLayer, xa, ya); } function drawClipPath(trace, t, layer, xaxis, yaxis) { var seg, xp, yp, i; - // var clip = makeg(layer, 'g', 'carpetclip'); - trace.clipPathId = 'clip' + trace.uid + 'carpet'; - var clip = layer.select('#' + trace.clipPathId); + var clip = layer.select('#' + trace._clipPathId); if(!clip.size()) { clip = layer.append('clipPath') @@ -96,13 +90,9 @@ function drawClipPath(trace, t, layer, xaxis, yaxis) { // This could be optimized ever so slightly to avoid no-op L segments // at the corners, but it's so negligible that I don't think it's worth // the extra complexity - trace.clipPathData = 'M' + segs.join('L') + 'Z'; - clip.attr('id', trace.clipPathId); - path.attr('d', trace.clipPathData); - // .style('stroke-width', 20) - // .style('vector-effect', 'non-scaling-stroke') - // .style('stroke', 'black') - // .style('fill', 'rgba(0, 0, 0, 0.1)'); + var clipPathData = 'M' + segs.join('L') + 'Z'; + clip.attr('id', trace._clipPathId); + path.attr('d', clipPathData); } function drawGridLines(xaxis, yaxis, layer, axis, axisLetter, gridlines) { diff --git a/src/traces/contour/attributes.js b/src/traces/contour/attributes.js index 042bc7355f8..92e6d2d82e6 100644 --- a/src/traces/contour/attributes.js +++ b/src/traces/contour/attributes.js @@ -13,6 +13,7 @@ var scatterAttrs = require('../scatter/attributes'); var colorscaleAttrs = require('../../components/colorscale/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); var dash = require('../../components/drawing/attributes').dash; +var fontAttrs = require('../../plots/font_attributes'); var extendFlat = require('../../lib/extend').extendFlat; var scatterLineAttrs = scatterAttrs.line; @@ -36,6 +37,7 @@ module.exports = extendFlat({}, { valType: 'boolean', dflt: true, role: 'style', + editType: 'docalc', description: [ 'Determines whether or not the contour level attributes are', 'picked by an algorithm.', @@ -48,6 +50,7 @@ module.exports = extendFlat({}, { dflt: 15, min: 1, role: 'style', + editType: 'docalc', description: [ 'Sets the maximum number of contour levels. The actual number', 'of contours will be chosen automatically to be less than or', @@ -62,6 +65,7 @@ module.exports = extendFlat({}, { valType: 'number', dflt: null, role: 'style', + editType: 'doplot', description: [ 'Sets the starting contour level value.', 'Must be less than `contours.end`' @@ -71,6 +75,7 @@ module.exports = extendFlat({}, { valType: 'number', dflt: null, role: 'style', + editType: 'doplot', description: [ 'Sets the end contour level value.', 'Must be more than `contours.start`' @@ -81,6 +86,7 @@ module.exports = extendFlat({}, { dflt: null, min: 0, role: 'style', + editType: 'doplot', description: [ 'Sets the step between each contour level.', 'Must be positive.' @@ -91,6 +97,7 @@ module.exports = extendFlat({}, { values: ['fill', 'heatmap', 'lines', 'none'], dflt: 'fill', role: 'style', + editType: 'docalc', description: [ 'Determines the coloring method showing the contour values.', 'If *fill*, coloring is done evenly between each contour level', @@ -104,9 +111,40 @@ module.exports = extendFlat({}, { valType: 'boolean', dflt: true, role: 'style', + editType: 'doplot', description: [ 'Determines whether or not the contour lines are drawn.', - 'Has only an effect if `contours.coloring` is set to *fill*.' + 'Has an effect only if `contours.coloring` is set to *fill*.' + ].join(' ') + }, + showlabels: { + valType: 'boolean', + dflt: false, + role: 'style', + editType: 'doplot', + description: [ + 'Determines whether to label the contour lines with their values.' + ].join(' ') + }, + labelfont: extendFlat({}, fontAttrs, { + description: [ + 'Sets the font used for labeling the contour levels.', + 'The default color comes from the lines, if shown.', + 'The default family and size come from `layout.font`.' + ].join(' '), + family: extendFlat({}, fontAttrs.family, {editType: 'doplot'}), + size: extendFlat({}, fontAttrs.size, {editType: 'doplot'}), + color: extendFlat({}, fontAttrs.color, {editType: 'dostyle'}) + }), + labelformat: { + valType: 'string', + dflt: '', + role: 'style', + editType: 'doplot', + description: [ + 'Sets the contour label formatting rule using d3 formatting', + 'mini-language which is very similar to Python, see:', + 'https://github.com/d3/d3-format/blob/master/README.md#locale_format.' ].join(' ') } }, @@ -115,7 +153,7 @@ module.exports = extendFlat({}, { color: extendFlat({}, scatterLineAttrs.color, { description: [ 'Sets the color of the contour level.', - 'Has no if `contours.coloring` is set to *lines*.' + 'Has no effect if `contours.coloring` is set to *lines*.' ].join(' ') }), width: scatterLineAttrs.width, diff --git a/src/traces/contour/constants.js b/src/traces/contour/constants.js index 406c4057804..ec70bb3c738 100644 --- a/src/traces/contour/constants.js +++ b/src/traces/contour/constants.js @@ -7,32 +7,70 @@ */ 'use strict'; +module.exports = { + // some constants to help with marching squares algorithm + // where does the path start for each index? + BOTTOMSTART: [1, 9, 13, 104, 713], + TOPSTART: [4, 6, 7, 104, 713], + LEFTSTART: [8, 12, 14, 208, 1114], + RIGHTSTART: [2, 3, 11, 208, 1114], -// some constants to help with marching squares algorithm -// where does the path start for each index? -module.exports.BOTTOMSTART = [1, 9, 13, 104, 713]; -module.exports.TOPSTART = [4, 6, 7, 104, 713]; -module.exports.LEFTSTART = [8, 12, 14, 208, 1114]; -module.exports.RIGHTSTART = [2, 3, 11, 208, 1114]; - -// which way [dx,dy] do we leave a given index? -// saddles are already disambiguated -module.exports.NEWDELTA = [ - null, [-1, 0], [0, -1], [-1, 0], - [1, 0], null, [0, -1], [-1, 0], - [0, 1], [0, 1], null, [0, 1], - [1, 0], [1, 0], [0, -1] -]; - -// for each saddle, the first index here is used -// for dx||dy<0, the second for dx||dy>0 -module.exports.CHOOSESADDLE = { - 104: [4, 1], - 208: [2, 8], - 713: [7, 13], - 1114: [11, 14] -}; + // which way [dx,dy] do we leave a given index? + // saddles are already disambiguated + NEWDELTA: [ + null, [-1, 0], [0, -1], [-1, 0], + [1, 0], null, [0, -1], [-1, 0], + [0, 1], [0, 1], null, [0, 1], + [1, 0], [1, 0], [0, -1] + ], + + // for each saddle, the first index here is used + // for dx||dy<0, the second for dx||dy>0 + CHOOSESADDLE: { + 104: [4, 1], + 208: [2, 8], + 713: [7, 13], + 1114: [11, 14] + }, + + // after one index has been used for a saddle, which do we + // substitute to be used up later? + SADDLEREMAINDER: {1: 4, 2: 8, 4: 1, 7: 13, 8: 2, 11: 14, 13: 7, 14: 11}, + + // length of a contour, as a multiple of the plot area diagonal, per label + LABELDISTANCE: 2, -// after one index has been used for a saddle, which do we -// substitute to be used up later? -module.exports.SADDLEREMAINDER = {1: 4, 2: 8, 4: 1, 7: 13, 8: 2, 11: 14, 13: 7, 14: 11}; + // number of contour levels after which we start increasing the number of + // labels we draw. Many contours means they will generally be close + // together, so it will be harder to follow a long way to find a label + LABELINCREASE: 10, + + // minimum length of a contour line, as a multiple of the label length, + // at which we draw *any* labels + LABELMIN: 3, + + // max number of labels to draw on a single contour path, no matter how long + LABELMAX: 10, + + // constants for the label position cost function + LABELOPTIMIZER: { + // weight given to edge proximity + EDGECOST: 1, + // weight given to the angle off horizontal + ANGLECOST: 1, + // weight given to distance from already-placed labels + NEIGHBORCOST: 5, + // cost multiplier for labels on the same level + SAMELEVELFACTOR: 10, + // minimum distance (as a multiple of the label length) + // for labels on the same level + SAMELEVELDISTANCE: 5, + // maximum cost before we won't even place the label + MAXCOST: 100, + // number of evenly spaced points to look at in the first + // iteration of the search + INITIALSEARCHPOINTS: 10, + // number of binary search iterations after the initial wide search + ITERATIONS: 5 + } +}; diff --git a/src/traces/contour/index.js b/src/traces/contour/index.js index ee18de12422..cd85c0f8490 100644 --- a/src/traces/contour/index.js +++ b/src/traces/contour/index.js @@ -14,7 +14,7 @@ var Contour = {}; Contour.attributes = require('./attributes'); Contour.supplyDefaults = require('./defaults'); Contour.calc = require('./calc'); -Contour.plot = require('./plot'); +Contour.plot = require('./plot').plot; Contour.style = require('./style'); Contour.colorbar = require('./colorbar'); Contour.hoverPoints = require('./hover'); diff --git a/src/traces/contour/plot.js b/src/traces/contour/plot.js index da09ada387b..72d9e892229 100644 --- a/src/traces/contour/plot.js +++ b/src/traces/contour/plot.js @@ -13,14 +13,19 @@ var d3 = require('d3'); var Lib = require('../../lib'); var Drawing = require('../../components/drawing'); +var svgTextUtils = require('../../lib/svg_text_utils'); +var Axes = require('../../plots/cartesian/axes'); +var setConvert = require('../../plots/cartesian/set_convert'); var heatmapPlot = require('../heatmap/plot'); var makeCrossings = require('./make_crossings'); var findAllPaths = require('./find_all_paths'); var endPlus = require('./end_plus'); +var constants = require('./constants'); +var costConstants = constants.LABELOPTIMIZER; -module.exports = function plot(gd, plotinfo, cdcontours) { +exports.plot = function plot(gd, plotinfo, cdcontours) { for(var i = 0; i < cdcontours.length; i++) { plotOne(gd, plotinfo, cdcontours[i]); } @@ -77,11 +82,11 @@ function plotOne(gd, plotinfo, cd) { ]; // draw everything - var plotGroup = makeContourGroup(plotinfo, cd, id); + var plotGroup = exports.makeContourGroup(plotinfo, cd, id); makeBackground(plotGroup, perimeter, contours); makeFills(plotGroup, pathinfo, perimeter, contours); - makeLines(plotGroup, pathinfo, contours); - clipGaps(plotGroup, plotinfo, cd[0], perimeter); + makeLinesAndLabels(plotGroup, pathinfo, gd, cd[0], contours, perimeter); + clipGaps(plotGroup, plotinfo, fullLayout._defs, cd[0], perimeter); } function emptyPathinfo(contours, plotinfo, cd0) { @@ -118,7 +123,7 @@ function emptyPathinfo(contours, plotinfo, cd0) { } return pathinfo; } -function makeContourGroup(plotinfo, cd, id) { +exports.makeContourGroup = function(plotinfo, cd, id) { var plotgroup = plotinfo.plot.select('.maplayer') .selectAll('g.contour.' + id) .data(cd); @@ -130,7 +135,7 @@ function makeContourGroup(plotinfo, cd, id) { plotgroup.exit().remove(); return plotgroup; -} +}; function makeBackground(plotgroup, perimeter, contours) { var bggroup = plotgroup.selectAll('g.contourbg').data([0]); @@ -259,50 +264,380 @@ function joinAllPaths(pi, perimeter) { return fullpath; } -function makeLines(plotgroup, pathinfo, contours) { +function makeLinesAndLabels(plotgroup, pathinfo, gd, cd0, contours, perimeter) { + var lineContainer = plotgroup.selectAll('g.contourlines').data([0]); + + lineContainer.enter().append('g') + .classed('contourlines', true); + + var showLines = contours.showlines !== false; + var showLabels = contours.showlabels; + var clipLinesForLabels = showLines && showLabels; + + // Even if we're not going to show lines, we need to create them + // if we're showing labels, because the fill paths include the perimeter + // so can't be used to position the labels correctly. + // In this case we'll remove the lines after making the labels. + var linegroup = exports.createLines(lineContainer, showLines || showLabels, pathinfo); + + var lineClip = exports.createLineClip(lineContainer, clipLinesForLabels, + gd._fullLayout._defs, cd0.trace.uid); + + var labelGroup = plotgroup.selectAll('g.contourlabels') + .data(showLabels ? [0] : []); + + labelGroup.exit().remove(); + + labelGroup.enter().append('g') + .classed('contourlabels', true); + + if(showLabels) { + var labelClipPathData = [perimeter]; + + var labelData = []; + + // invalidate the getTextLocation cache in case paths changed + Lib.clearLocationCache(); + + var contourFormat = exports.labelFormatter(contours, cd0.t.cb, gd._fullLayout); + + var dummyText = Drawing.tester.append('text') + .attr('data-notex', 1) + .call(Drawing.font, contours.labelfont); + + var xLen = pathinfo[0].xaxis._length; + var yLen = pathinfo[0].yaxis._length; + + // visible bounds of the contour trace (and the midpoints, to + // help with cost calculations) + var bounds = { + left: Math.max(perimeter[0][0], 0), + right: Math.min(perimeter[2][0], xLen), + top: Math.max(perimeter[0][1], 0), + bottom: Math.min(perimeter[2][1], yLen) + }; + bounds.middle = (bounds.top + bounds.bottom) / 2; + bounds.center = (bounds.left + bounds.right) / 2; + + var plotDiagonal = Math.sqrt(xLen * xLen + yLen * yLen); + + // the path length to use to scale the number of labels to draw: + var normLength = constants.LABELDISTANCE * plotDiagonal / + Math.max(1, pathinfo.length / constants.LABELINCREASE); + + linegroup.each(function(d) { + var textOpts = exports.calcTextOpts(d.level, contourFormat, dummyText, gd); + + d3.select(this).selectAll('path').each(function() { + var path = this; + var pathBounds = Lib.getVisibleSegment(path, bounds, textOpts.height / 2); + if(!pathBounds) return; + + if(pathBounds.len < (textOpts.width + textOpts.height) * constants.LABELMIN) return; + + var maxLabels = Math.min(Math.ceil(pathBounds.len / normLength), + constants.LABELMAX); + + for(var i = 0; i < maxLabels; i++) { + var loc = exports.findBestTextLocation(path, pathBounds, textOpts, + labelData, bounds); + + if(!loc) break; + + exports.addLabelData(loc, textOpts, labelData, labelClipPathData); + } + }); + }); + + dummyText.remove(); + + exports.drawLabels(labelGroup, labelData, gd, lineClip, + clipLinesForLabels ? labelClipPathData : null); + } + + if(showLabels && !showLines) linegroup.remove(); +} + +exports.createLines = function(lineContainer, makeLines, pathinfo) { var smoothing = pathinfo[0].smoothing; - var linegroup = plotgroup.selectAll('g.contourlevel') - .data(contours.showlines === false ? [] : pathinfo); + var linegroup = lineContainer.selectAll('g.contourlevel') + .data(makeLines ? pathinfo : []); + + linegroup.exit().remove(); linegroup.enter().append('g') .classed('contourlevel', true); - linegroup.exit().remove(); - var opencontourlines = linegroup.selectAll('path.openline') - .data(function(d) { return d.edgepaths; }); - opencontourlines.enter().append('path') - .classed('openline', true); - opencontourlines.exit().remove(); - opencontourlines - .attr('d', function(d) { - return Drawing.smoothopen(d, smoothing); - }) - .style('stroke-miterlimit', 1) - .style('vector-effect', 'non-scaling-stroke'); - - var closedcontourlines = linegroup.selectAll('path.closedline') - .data(function(d) { return d.paths; }); - closedcontourlines.enter().append('path') - .classed('closedline', true); - closedcontourlines.exit().remove(); - closedcontourlines - .attr('d', function(d) { - return Drawing.smoothclosed(d, smoothing); - }) - .style('stroke-miterlimit', 1) - .style('vector-effect', 'non-scaling-stroke'); + if(makeLines) { + // pedgepaths / ppaths are used by contourcarpet, for the paths transformed from a/b to x/y + // edgepaths / paths are used by contour since it's in x/y from the start + var opencontourlines = linegroup.selectAll('path.openline') + .data(function(d) { return d.pedgepaths || d.edgepaths; }); + + opencontourlines.exit().remove(); + opencontourlines.enter().append('path') + .classed('openline', true); + + opencontourlines + .attr('d', function(d) { + return Drawing.smoothopen(d, smoothing); + }) + .style('stroke-miterlimit', 1) + .style('vector-effect', 'non-scaling-stroke'); + + var closedcontourlines = linegroup.selectAll('path.closedline') + .data(function(d) { return d.ppaths || d.paths; }); + + closedcontourlines.exit().remove(); + closedcontourlines.enter().append('path') + .classed('closedline', true); + + closedcontourlines + .attr('d', function(d) { + return Drawing.smoothclosed(d, smoothing); + }) + .style('stroke-miterlimit', 1) + .style('vector-effect', 'non-scaling-stroke'); + } + + return linegroup; +}; + +exports.createLineClip = function(lineContainer, clipLinesForLabels, defs, uid) { + var clipId = clipLinesForLabels ? ('clipline' + uid) : null; + + var lineClip = defs.select('.clips').selectAll('#' + clipId) + .data(clipLinesForLabels ? [0] : []); + lineClip.exit().remove(); + + lineClip.enter().append('clipPath') + .classed('contourlineclip', true) + .attr('id', clipId); + + Drawing.setClipUrl(lineContainer, clipId); + + return lineClip; +}; + +exports.labelFormatter = function(contours, colorbar, fullLayout) { + if(contours.labelformat) { + return d3.format(contours.labelformat); + } + else { + var formatAxis; + if(colorbar) { + formatAxis = colorbar.axis; + } + else { + formatAxis = { + type: 'linear', + _separators: '.,', + _id: 'ycontour', + nticks: (contours.end - contours.start) / contours.size, + showexponent: 'all', + range: [contours.start, contours.end] + }; + setConvert(formatAxis, fullLayout); + Axes.calcTicks(formatAxis); + formatAxis._tmin = null; + formatAxis._tmax = null; + } + return function(v) { + return Axes.tickText(formatAxis, v).text; + }; + } +}; + +exports.calcTextOpts = function(level, contourFormat, dummyText, gd) { + var text = contourFormat(level); + dummyText.text(text) + .call(svgTextUtils.convertToTspans, gd); + var bBox = Drawing.bBox(dummyText.node(), true); + + return { + text: text, + width: bBox.width, + height: bBox.height, + level: level, + dy: (bBox.top + bBox.bottom) / 2 + }; +}; + +exports.findBestTextLocation = function(path, pathBounds, textOpts, labelData, plotBounds) { + var textWidth = textOpts.width; + + var p0, dp, pMax, pMin, loc; + if(pathBounds.isClosed) { + dp = pathBounds.len / costConstants.INITIALSEARCHPOINTS; + p0 = pathBounds.min + dp / 2; + pMax = pathBounds.max; + } + else { + dp = (pathBounds.len - textWidth) / (costConstants.INITIALSEARCHPOINTS + 1); + p0 = pathBounds.min + dp + textWidth / 2; + pMax = pathBounds.max - (dp + textWidth) / 2; + } + + var cost = Infinity; + for(var j = 0; j < costConstants.ITERATIONS; j++) { + for(var p = p0; p < pMax; p += dp) { + var newLocation = Lib.getTextLocation(path, pathBounds.total, p, textWidth); + var newCost = locationCost(newLocation, textOpts, labelData, plotBounds); + if(newCost < cost) { + cost = newCost; + loc = newLocation; + pMin = p; + } + } + if(cost > costConstants.MAXCOST * 2) break; + + // subsequent iterations just look half steps away from the + // best we found in the previous iteration + if(j) dp /= 2; + p0 = pMin - dp / 2; + pMax = p0 + dp * 1.5; + } + if(cost <= costConstants.MAXCOST) return loc; +}; + +/* + * locationCost: a cost function for label locations + * composed of three kinds of penalty: + * - for open paths, being close to the end of the path + * - the angle away from horizontal + * - being too close to already placed neighbors + */ +function locationCost(loc, textOpts, labelData, bounds) { + var halfWidth = textOpts.width / 2; + var halfHeight = textOpts.height / 2; + var x = loc.x; + var y = loc.y; + var theta = loc.theta; + var dx = Math.cos(theta) * halfWidth; + var dy = Math.sin(theta) * halfWidth; + + // cost for being near an edge + var normX = ((x > bounds.center) ? (bounds.right - x) : (x - bounds.left)) / + (dx + Math.abs(Math.sin(theta) * halfHeight)); + var normY = ((y > bounds.middle) ? (bounds.bottom - y) : (y - bounds.top)) / + (Math.abs(dy) + Math.cos(theta) * halfHeight); + if(normX < 1 || normY < 1) return Infinity; + var cost = costConstants.EDGECOST * (1 / (normX - 1) + 1 / (normY - 1)); + + // cost for not being horizontal + cost += costConstants.ANGLECOST * theta * theta; + + // cost for being close to other labels + var x1 = x - dx; + var y1 = y - dy; + var x2 = x + dx; + var y2 = y + dy; + for(var i = 0; i < labelData.length; i++) { + var labeli = labelData[i]; + var dxd = Math.cos(labeli.theta) * labeli.width / 2; + var dyd = Math.sin(labeli.theta) * labeli.width / 2; + var dist = Lib.segmentDistance( + x1, y1, + x2, y2, + labeli.x - dxd, labeli.y - dyd, + labeli.x + dxd, labeli.y + dyd + ) * 2 / (textOpts.height + labeli.height); + + var sameLevel = labeli.level === textOpts.level; + var distOffset = sameLevel ? costConstants.SAMELEVELDISTANCE : 1; + + if(dist <= distOffset) return Infinity; + + var distFactor = costConstants.NEIGHBORCOST * + (sameLevel ? costConstants.SAMELEVELFACTOR : 1); + + cost += distFactor / (dist - distOffset); + } + + return cost; } -function clipGaps(plotGroup, plotinfo, cd0, perimeter) { - var clipId = 'clip' + cd0.trace.uid; +exports.addLabelData = function(loc, textOpts, labelData, labelClipPathData) { + var halfWidth = textOpts.width / 2; + var halfHeight = textOpts.height / 2; + + var x = loc.x; + var y = loc.y; + var theta = loc.theta; + + var sin = Math.sin(theta); + var cos = Math.cos(theta); + var dxw = halfWidth * cos; + var dxh = halfHeight * sin; + var dyw = halfWidth * sin; + var dyh = -halfHeight * cos; + var bBoxPts = [ + [x - dxw - dxh, y - dyw - dyh], + [x + dxw - dxh, y + dyw - dyh], + [x + dxw + dxh, y + dyw + dyh], + [x - dxw + dxh, y - dyw + dyh], + ]; + + labelData.push({ + text: textOpts.text, + x: x, + y: y, + dy: textOpts.dy, + theta: theta, + level: textOpts.level, + width: textOpts.width, + height: textOpts.height + }); - var defs = plotinfo.plot.selectAll('defs') - .data([0]); - defs.enter().append('defs'); + labelClipPathData.push(bBoxPts); +}; + +exports.drawLabels = function(labelGroup, labelData, gd, lineClip, labelClipPathData) { + var labels = labelGroup.selectAll('text') + .data(labelData, function(d) { + return d.text + ',' + d.x + ',' + d.y + ',' + d.theta; + }); + + labels.exit().remove(); + + labels.enter().append('text') + .attr({ + 'data-notex': 1, + 'text-anchor': 'middle' + }) + .each(function(d) { + var x = d.x + Math.sin(d.theta) * d.dy; + var y = d.y - Math.cos(d.theta) * d.dy; + d3.select(this) + .text(d.text) + .attr({ + x: x, + y: y, + transform: 'rotate(' + (180 * d.theta / Math.PI) + ' ' + x + ' ' + y + ')' + }) + .call(svgTextUtils.convertToTspans, gd); + }); + + if(labelClipPathData) { + var clipPath = ''; + for(var i = 0; i < labelClipPathData.length; i++) { + clipPath += 'M' + labelClipPathData[i].join('L') + 'Z'; + } + + var lineClipPath = lineClip.selectAll('path').data([0]); + lineClipPath.enter().append('path'); + lineClipPath.attr('d', clipPath); + } +}; + +function clipGaps(plotGroup, plotinfo, defs, cd0, perimeter) { + var clipId = 'clip' + cd0.trace.uid; - var clipPath = defs.selectAll('#' + clipId) + var clipPath = defs.select('.clips').selectAll('#' + clipId) .data(cd0.trace.connectgaps ? [] : [0]); - clipPath.enter().append('clipPath').attr('id', clipId); + clipPath.enter().append('clipPath') + .classed('contourclip', true) + .attr('id', clipId); clipPath.exit().remove(); if(cd0.trace.connectgaps === false) { diff --git a/src/traces/contour/style.js b/src/traces/contour/style.js index 3ce4e56c64c..eff61fb3beb 100644 --- a/src/traces/contour/style.js +++ b/src/traces/contour/style.js @@ -25,35 +25,55 @@ module.exports = function style(gd) { }); contours.each(function(d) { - var c = d3.select(this), - trace = d.trace, - contours = trace.contours, - line = trace.line, - cs = contours.size || 1, - start = contours.start; + var c = d3.select(this); + var trace = d.trace; + var contours = trace.contours; + var line = trace.line; + var cs = contours.size || 1; + var start = contours.start; - var colorMap = makeColorMap(trace); + // for contourcarpet only - is this a constraint-type contour trace? + var isConstraintType = contours.type === 'constraint'; + var colorLines = !isConstraintType && contours.coloring === 'lines'; + var colorFills = !isConstraintType && contours.coloring === 'fill'; + + var colorMap = (colorLines || colorFills) ? makeColorMap(trace) : null; c.selectAll('g.contourlevel').each(function(d) { d3.select(this).selectAll('path') .call(Drawing.lineGroupStyle, line.width, - contours.coloring === 'lines' ? colorMap(d.level) : line.color, + colorLines ? colorMap(d.level) : line.color, line.dash); }); - var firstFill; - - c.selectAll('g.contourfill path') - .style('fill', function(d) { - if(firstFill === undefined) firstFill = d.level; - return colorMap(d.level + 0.5 * cs); + var labelFont = contours.labelfont; + c.selectAll('g.contourlabels text').each(function(d) { + Drawing.font(d3.select(this), { + family: labelFont.family, + size: labelFont.size, + color: labelFont.color || (colorLines ? colorMap(d.level) : line.color) }); + }); + + if(isConstraintType) { + c.selectAll('g.contourfill path') + .style('fill', trace.fillcolor); + } + else if(colorFills) { + var firstFill; + + c.selectAll('g.contourfill path') + .style('fill', function(d) { + if(firstFill === undefined) firstFill = d.level; + return colorMap(d.level + 0.5 * cs); + }); - if(firstFill === undefined) firstFill = start; + if(firstFill === undefined) firstFill = start; - c.selectAll('g.contourbg path') - .style('fill', colorMap(firstFill - 0.5 * cs)); + c.selectAll('g.contourbg path') + .style('fill', colorMap(firstFill - 0.5 * cs)); + } }); heatmapStyle(gd); diff --git a/src/traces/contour/style_defaults.js b/src/traces/contour/style_defaults.js index cf87d2b4c2c..02c279cc6fc 100644 --- a/src/traces/contour/style_defaults.js +++ b/src/traces/contour/style_defaults.js @@ -10,25 +10,38 @@ 'use strict'; var colorscaleDefaults = require('../../components/colorscale/defaults'); +var Lib = require('../../lib'); module.exports = function handleStyleDefaults(traceIn, traceOut, coerce, layout, defaultColor, defaultWidth) { var coloring = coerce('contours.coloring'); var showLines; + var lineColor = ''; if(coloring === 'fill') showLines = coerce('contours.showlines'); if(showLines !== false) { - if(coloring !== 'lines') coerce('line.color', defaultColor || '#000'); + if(coloring !== 'lines') lineColor = coerce('line.color', defaultColor || '#000'); coerce('line.width', defaultWidth === undefined ? 0.5 : defaultWidth); coerce('line.dash'); } coerce('line.smoothing'); - if((traceOut.contours || {}).coloring !== 'none') { + if(coloring !== 'none') { colorscaleDefaults( traceIn, traceOut, layout, coerce, {prefix: '', cLetter: 'z'} ); } + + var showLabels = coerce('contours.showlabels'); + if(showLabels) { + var globalFont = layout.font; + Lib.coerceFont(coerce, 'contours.labelfont', { + family: globalFont.family, + size: globalFont.size, + color: lineColor + }); + coerce('contours.labelformat'); + } }; diff --git a/src/traces/contourcarpet/attributes.js b/src/traces/contourcarpet/attributes.js index 678ab370232..78fd52e099b 100644 --- a/src/traces/contourcarpet/attributes.js +++ b/src/traces/contourcarpet/attributes.js @@ -9,6 +9,8 @@ 'use strict'; var heatmapAttrs = require('../heatmap/attributes'); +var contourAttrs = require('../contour/attributes'); +var contourContourAttrs = contourAttrs.contours; var scatterAttrs = require('../scatter/attributes'); var colorscaleAttrs = require('../../components/colorscale/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); @@ -58,30 +60,8 @@ module.exports = extendFlat({}, { ].join(' ') }, - autocontour: { - valType: 'boolean', - dflt: true, - role: 'style', - description: [ - 'Determines whether or not the contour level attributes are', - 'picked by an algorithm.', - 'If *true*, the number of contour levels can be set in `ncontours`.', - 'If *false*, set the contour level attributes in `contours`.' - ].join(' ') - }, - ncontours: { - valType: 'integer', - dflt: 15, - min: 1, - role: 'style', - description: [ - 'Sets the maximum number of contour levels. The actual number', - 'of contours will be chosen automatically to be less than or', - 'equal to the value of `ncontours`.', - 'Has an effect only if `autocontour` is *true* or if', - '`contours.size` is missing.' - ].join(' ') - }, + autocontour: contourAttrs.autocontour, + ncontours: contourAttrs.ncontours, contours: { type: { @@ -89,6 +69,7 @@ module.exports = extendFlat({}, { values: ['levels', 'constraint'], dflt: 'levels', role: 'info', + editType: 'docalc', description: [ 'If `levels`, the data is represented as a contour plot with multiple', 'levels displayed. If `constraint`, the data is represented as constraints', @@ -96,39 +77,16 @@ module.exports = extendFlat({}, { '`value` parameters.' ].join(' ') }, - start: { - valType: 'number', - dflt: null, - role: 'style', - description: [ - 'Sets the starting contour level value.', - 'Must be less than `contours.end`' - ].join(' ') - }, - end: { - valType: 'number', - dflt: null, - role: 'style', - description: [ - 'Sets the end contour level value.', - 'Must be more than `contours.start`' - ].join(' ') - }, - size: { - valType: 'number', - dflt: null, - min: 0, - role: 'style', - description: [ - 'Sets the step between each contour level.', - 'Must be positive.' - ].join(' ') - }, + start: contourContourAttrs.start, + end: contourContourAttrs.end, + size: contourContourAttrs.size, coloring: { + // from contourAttrs.contours.coloring but no 'heatmap' option valType: 'enumerated', values: ['fill', 'lines', 'none'], dflt: 'fill', role: 'style', + editType: 'docalc', description: [ 'Determines the coloring method showing the contour values.', 'If *fill*, coloring is done evenly between each contour level', @@ -136,20 +94,16 @@ module.exports = extendFlat({}, { 'If *none*, no coloring is applied on this trace.' ].join(' ') }, - showlines: { - valType: 'boolean', - dflt: true, - role: 'style', - description: [ - 'Determines whether or not the contour lines are drawn.', - 'Has only an effect if `contours.coloring` is set to *fill*.' - ].join(' ') - }, + showlines: contourContourAttrs.showlines, + showlabels: contourContourAttrs.showlabels, + labelfont: contourContourAttrs.labelfont, + labelformat: contourContourAttrs.labelformat, operation: { valType: 'enumerated', values: [].concat(constants.INEQUALITY_OPS).concat(constants.INTERVAL_OPS).concat(constants.SET_OPS), role: 'info', dflt: '=', + editType: 'docalc', description: [ 'Sets the filter operation.', @@ -176,6 +130,7 @@ module.exports = extendFlat({}, { valType: 'any', dflt: 0, role: 'info', + editType: 'docalc', description: [ 'Sets the value or values by which to filter by.', diff --git a/src/traces/contourcarpet/index.js b/src/traces/contourcarpet/index.js index 9c894f4ae64..63187c53ca6 100644 --- a/src/traces/contourcarpet/index.js +++ b/src/traces/contourcarpet/index.js @@ -15,7 +15,7 @@ ContourCarpet.supplyDefaults = require('./defaults'); ContourCarpet.colorbar = require('../contour/colorbar'); ContourCarpet.calc = require('./calc'); ContourCarpet.plot = require('./plot'); -ContourCarpet.style = require('./style'); +ContourCarpet.style = require('../contour/style'); ContourCarpet.moduleType = 'trace'; ContourCarpet.name = 'contourcarpet'; diff --git a/src/traces/contourcarpet/plot.js b/src/traces/contourcarpet/plot.js index e3e9bc04478..29e2b395f24 100644 --- a/src/traces/contourcarpet/plot.js +++ b/src/traces/contourcarpet/plot.js @@ -12,9 +12,12 @@ var d3 = require('d3'); var map1dArray = require('../carpet/map_1d_array'); var makepath = require('../carpet/makepath'); var Drawing = require('../../components/drawing'); +var Lib = require('../../lib'); var makeCrossings = require('../contour/make_crossings'); var findAllPaths = require('../contour/find_all_paths'); +var contourPlot = require('../contour/plot'); +var constants = require('../contour/constants'); var convertToConstraints = require('./convert_to_constraints'); var joinAllPaths = require('./join_all_paths'); var emptyPathinfo = require('./empty_pathinfo'); @@ -22,11 +25,6 @@ var mapPathinfo = require('./map_pathinfo'); var lookupCarpet = require('../carpet/lookup_carpetid'); var closeBoundaries = require('./close_boundaries'); -function makeg(el, type, klass) { - var join = el.selectAll(type + '.' + klass).data([0]); - join.enter().append(type).classed(klass, true); - return join; -} module.exports = function plot(gd, plotinfo, cdcontours) { for(var i = 0; i < cdcontours.length; i++) { @@ -95,7 +93,7 @@ function plotOne(gd, plotinfo, cd) { mapPathinfo(pathinfo, ab2p); // draw everything - var plotGroup = makeContourGroup(plotinfo, cd, id); + var plotGroup = contourPlot.makeContourGroup(plotinfo, cd, id); // Compute the boundary path var seg, xp, yp, i; @@ -121,67 +119,184 @@ function plotOne(gd, plotinfo, cd) { makeFills(trace, plotGroup, xa, ya, pathinfo, perimeter, ab2p, carpet, carpetcd, contours.coloring, boundaryPath); // Draw contour lines: - makeLines(plotGroup, pathinfo, contours); + makeLinesAndLabels(plotGroup, pathinfo, gd, cd[0], contours, plotinfo, carpet); - // Clip the boundary of the plot: - clipBoundary(plotGroup, carpet); + // Clip the boundary of the plot + Drawing.setClipUrl(plotGroup, carpet._clipPathId); } -function clipBoundary(plotGroup, carpet) { - plotGroup.attr('clip-path', 'url(#' + carpet.clipPathId + ')'); +function makeLinesAndLabels(plotgroup, pathinfo, gd, cd0, contours, plotinfo, carpet) { + var lineContainer = plotgroup.selectAll('g.contourlines').data([0]); + + lineContainer.enter().append('g') + .classed('contourlines', true); + + var showLines = contours.showlines !== false; + var showLabels = contours.showlabels; + var clipLinesForLabels = showLines && showLabels; + + // Even if we're not going to show lines, we need to create them + // if we're showing labels, because the fill paths include the perimeter + // so can't be used to position the labels correctly. + // In this case we'll remove the lines after making the labels. + var linegroup = contourPlot.createLines(lineContainer, showLines || showLabels, pathinfo); + + var lineClip = contourPlot.createLineClip(lineContainer, clipLinesForLabels, + gd._fullLayout._defs, cd0.trace.uid); + + var labelGroup = plotgroup.selectAll('g.contourlabels') + .data(showLabels ? [0] : []); + + labelGroup.exit().remove(); + + labelGroup.enter().append('g') + .classed('contourlabels', true); + + if(showLabels) { + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + var xLen = xa._length; + var yLen = ya._length; + // for simplicity use the xy box for label clipping outline. + var labelClipPathData = [[ + [0, 0], + [xLen, 0], + [xLen, yLen], + [0, yLen] + ]]; + + + var labelData = []; + + // invalidate the getTextLocation cache in case paths changed + Lib.clearLocationCache(); + + var contourFormat = contourPlot.labelFormatter(contours, cd0.t.cb, gd._fullLayout); + + var dummyText = Drawing.tester.append('text') + .attr('data-notex', 1) + .call(Drawing.font, contours.labelfont); + + // use `bounds` only to keep labels away from the x/y boundaries + // `constrainToCarpet` below ensures labels don't go off the + // carpet edges + var bounds = { + left: 0, + right: xLen, + center: xLen / 2, + top: 0, + bottom: yLen, + middle: yLen / 2 + }; + + var plotDiagonal = Math.sqrt(xLen * xLen + yLen * yLen); + + // the path length to use to scale the number of labels to draw: + var normLength = constants.LABELDISTANCE * plotDiagonal / + Math.max(1, pathinfo.length / constants.LABELINCREASE); + + linegroup.each(function(d) { + var textOpts = contourPlot.calcTextOpts(d.level, contourFormat, dummyText, gd); + + d3.select(this).selectAll('path').each(function(pathData) { + var path = this; + var pathBounds = Lib.getVisibleSegment(path, bounds, textOpts.height / 2); + if(!pathBounds) return; + + constrainToCarpet(path, pathData, d, pathBounds, carpet, textOpts.height); + + if(pathBounds.len < (textOpts.width + textOpts.height) * constants.LABELMIN) return; + + var maxLabels = Math.min(Math.ceil(pathBounds.len / normLength), + constants.LABELMAX); + + for(var i = 0; i < maxLabels; i++) { + var loc = contourPlot.findBestTextLocation(path, pathBounds, textOpts, + labelData, bounds); + + if(!loc) break; + + contourPlot.addLabelData(loc, textOpts, labelData, labelClipPathData); + } + }); + }); + + dummyText.remove(); + + contourPlot.drawLabels(labelGroup, labelData, gd, lineClip, + clipLinesForLabels ? labelClipPathData : null); + } + + if(showLabels && !showLines) linegroup.remove(); } -function makeContourGroup(plotinfo, cd, id) { - var plotgroup = plotinfo.plot.select('.maplayer') - .selectAll('g.contour.' + id) - .classed('trace', true) - .data(cd); +// figure out if this path goes off the edge of the carpet +// and shorten the part we call visible to keep labels away from the edge +function constrainToCarpet(path, pathData, levelData, pathBounds, carpet, textHeight) { + var pathABData; + for(var i = 0; i < levelData.pedgepaths.length; i++) { + if(pathData === levelData.pedgepaths[i]) { + pathABData = levelData.edgepaths[i]; + } + } + if(!pathABData) return; + + var aMin = carpet.a[0]; + var aMax = carpet.a[carpet.a.length - 1]; + var bMin = carpet.b[0]; + var bMax = carpet.b[carpet.b.length - 1]; + + function getOffset(abPt, pathVector) { + var offset = 0; + var edgeVector; + var dAB = 0.1; + if(Math.abs(abPt[0] - aMin) < dAB || Math.abs(abPt[0] - aMax) < dAB) { + edgeVector = normalizeVector(carpet.dxydb_rough(abPt[0], abPt[1], dAB)); + offset = Math.max(offset, textHeight * vectorTan(pathVector, edgeVector) / 2); + } + + if(Math.abs(abPt[1] - bMin) < dAB || Math.abs(abPt[1] - bMax) < dAB) { + edgeVector = normalizeVector(carpet.dxyda_rough(abPt[0], abPt[1], dAB)); + offset = Math.max(offset, textHeight * vectorTan(pathVector, edgeVector) / 2); + } + return offset; + } - plotgroup.enter().append('g') - .classed('contour', true) - .classed(id, true); + var startVector = getUnitVector(path, 0, 1); + var endVector = getUnitVector(path, pathBounds.total, pathBounds.total - 1); + var minStart = getOffset(pathABData[0], startVector); + var maxEnd = pathBounds.total - getOffset(pathABData[pathABData.length - 1], endVector); - plotgroup.exit().remove(); + if(pathBounds.min < minStart) pathBounds.min = minStart; + if(pathBounds.max > maxEnd) pathBounds.max = maxEnd; + + pathBounds.len = pathBounds.max - pathBounds.min; +} + +function getUnitVector(path, p0, p1) { + var pt0 = path.getPointAtLength(p0); + var pt1 = path.getPointAtLength(p1); + var dx = pt1.x - pt0.x; + var dy = pt1.y - pt0.y; + var len = Math.sqrt(dx * dx + dy * dy); + return [dx / len, dy / len]; +} - return plotgroup; +function normalizeVector(v) { + var len = Math.sqrt(v[0] * v[0] + v[1] * v[1]); + return [v[0] / len, v[1] / len]; } -function makeLines(plotgroup, pathinfo, contours) { - var smoothing = pathinfo[0].smoothing; - - var linegroup = plotgroup.selectAll('g.contourlevel') - .data(contours.showlines === false ? [] : pathinfo); - linegroup.enter().append('g') - .classed('contourlevel', true); - linegroup.exit().remove(); - - var opencontourlines = linegroup.selectAll('path.openline') - .data(function(d) { return d.pedgepaths; }); - opencontourlines.enter().append('path') - .classed('openline', true); - opencontourlines.exit().remove(); - opencontourlines - .attr('d', function(d) { - return Drawing.smoothopen(d, smoothing); - }) - .style('vector-effect', 'non-scaling-stroke'); - - var closedcontourlines = linegroup.selectAll('path.closedline') - .data(function(d) { return d.ppaths; }); - closedcontourlines.enter().append('path') - .classed('closedline', true); - closedcontourlines.exit().remove(); - closedcontourlines - .attr('d', function(d) { - return Drawing.smoothclosed(d, smoothing); - }) - .style('vector-effect', 'non-scaling-stroke') - .style('stroke-miterlimit', 1); +function vectorTan(v0, v1) { + var cos = Math.abs(v0[0] * v1[0] + v0[1] * v1[1]); + var sin = Math.sqrt(1 - cos * cos); + return sin / cos; } function makeBackground(plotgroup, clipsegments, xaxis, yaxis, isConstraint, coloring) { var seg, xp, yp, i; - var bggroup = makeg(plotgroup, 'g', 'contourbg'); + var bggroup = plotgroup.selectAll('g.contourbg').data([0]); + bggroup.enter().append('g').classed('contourbg', true); var bgfill = bggroup.selectAll('path') .data((coloring === 'fill' && !isConstraint) ? [0] : []); diff --git a/src/traces/contourcarpet/style.js b/src/traces/contourcarpet/style.js deleted file mode 100644 index eae3c131e9b..00000000000 --- a/src/traces/contourcarpet/style.js +++ /dev/null @@ -1,63 +0,0 @@ -/** -* Copyright 2012-2017, 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 d3 = require('d3'); - -var Drawing = require('../../components/drawing'); -var heatmapStyle = require('../heatmap/style'); - -var makeColorMap = require('../contour/make_color_map'); - -module.exports = function style(gd) { - var contours = d3.select(gd).selectAll('g.contour'); - - contours.style('opacity', function(d) { - return d.trace.opacity; - }); - - contours.each(function(d) { - var c = d3.select(this); - var trace = d.trace; - var contours = trace.contours; - var line = trace.line; - var cs = contours.size || 1; - var start = contours.start; - - if(!isFinite(cs)) { - cs = 0; - } - - c.selectAll('g.contourlevel').each(function() { - d3.select(this).selectAll('path') - .call(Drawing.lineGroupStyle, - line.width, - line.color, - line.dash); - }); - - if(trace.contours.type === 'levels' && trace.contours.coloring !== 'none') { - var colorMap = makeColorMap(trace); - - c.selectAll('g.contourbg path') - .style('fill', colorMap(start - cs / 2)); - - c.selectAll('g.contourfill path') - .style('fill', function(d, i) { - return colorMap(start + (i + 0.5) * cs); - }); - } else { - c.selectAll('g.contourfill path') - .style('fill', trace.fillcolor); - } - }); - - heatmapStyle(gd); -}; diff --git a/src/traces/histogram2dcontour/index.js b/src/traces/histogram2dcontour/index.js index 1f4e8ef5d84..a4e50c4a450 100644 --- a/src/traces/histogram2dcontour/index.js +++ b/src/traces/histogram2dcontour/index.js @@ -14,7 +14,7 @@ var Histogram2dContour = {}; Histogram2dContour.attributes = require('./attributes'); Histogram2dContour.supplyDefaults = require('./defaults'); Histogram2dContour.calc = require('../contour/calc'); -Histogram2dContour.plot = require('../contour/plot'); +Histogram2dContour.plot = require('../contour/plot').plot; Histogram2dContour.style = require('../contour/style'); Histogram2dContour.colorbar = require('../contour/colorbar'); Histogram2dContour.hoverPoints = require('../contour/hover'); diff --git a/src/traces/scattercarpet/plot.js b/src/traces/scattercarpet/plot.js index 21b0421ab0b..2f39ef7068b 100644 --- a/src/traces/scattercarpet/plot.js +++ b/src/traces/scattercarpet/plot.js @@ -11,6 +11,7 @@ var scatterPlot = require('../scatter/plot'); var Axes = require('../../plots/cartesian/axes'); +var Drawing = require('../../components/drawing'); module.exports = function plot(gd, plotinfoproxy, data) { var i, trace, node; @@ -37,6 +38,6 @@ module.exports = function plot(gd, plotinfoproxy, data) { // separately to all scattercarpet traces, but that would require // lots of reorganization of scatter traces that is otherwise not // necessary. That makes this a potential optimization. - node.attr('clip-path', 'url(#clip' + carpet.uid + 'carpet)'); + Drawing.setClipUrl(node, carpet._clipPathId); } }; diff --git a/test/image/baselines/cheater.png b/test/image/baselines/cheater.png index c22359e7d98..488316c98fd 100644 Binary files a/test/image/baselines/cheater.png and b/test/image/baselines/cheater.png differ diff --git a/test/image/baselines/cheater_constraint_greater_than.png b/test/image/baselines/cheater_constraint_greater_than.png index 540728e570d..724d8c5da5c 100644 Binary files a/test/image/baselines/cheater_constraint_greater_than.png and b/test/image/baselines/cheater_constraint_greater_than.png differ diff --git a/test/image/baselines/cheater_constraint_greater_than_with_hill.png b/test/image/baselines/cheater_constraint_greater_than_with_hill.png index bc1c3bf3f1d..e903cbb3e85 100644 Binary files a/test/image/baselines/cheater_constraint_greater_than_with_hill.png and b/test/image/baselines/cheater_constraint_greater_than_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_greater_than_with_valley.png b/test/image/baselines/cheater_constraint_greater_than_with_valley.png index 04756020783..a0c94afedc3 100644 Binary files a/test/image/baselines/cheater_constraint_greater_than_with_valley.png and b/test/image/baselines/cheater_constraint_greater_than_with_valley.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range.png b/test/image/baselines/cheater_constraint_inner_range.png index faad63793bb..826dd1a08eb 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range.png and b/test/image/baselines/cheater_constraint_inner_range.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_hi_top.png b/test/image/baselines/cheater_constraint_inner_range_hi_top.png index 8d6b9c7cf39..65431e4f8f0 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_hi_top.png and b/test/image/baselines/cheater_constraint_inner_range_hi_top.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_hi_top_with_hill.png b/test/image/baselines/cheater_constraint_inner_range_hi_top_with_hill.png index ebfdfa4e581..d7eb0bcfd47 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_hi_top_with_hill.png and b/test/image/baselines/cheater_constraint_inner_range_hi_top_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_hi_top_with_valley.png b/test/image/baselines/cheater_constraint_inner_range_hi_top_with_valley.png index 6d9cecccd30..1ca6fd6014d 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_hi_top_with_valley.png and b/test/image/baselines/cheater_constraint_inner_range_hi_top_with_valley.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_lo_top.png b/test/image/baselines/cheater_constraint_inner_range_lo_top.png index 3eb1f52edc3..318dd4c6d14 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_lo_top.png and b/test/image/baselines/cheater_constraint_inner_range_lo_top.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_lo_top_with_hill.png b/test/image/baselines/cheater_constraint_inner_range_lo_top_with_hill.png index 240f47d82a2..c00e06c3708 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_lo_top_with_hill.png and b/test/image/baselines/cheater_constraint_inner_range_lo_top_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_lo_top_with_valley.png b/test/image/baselines/cheater_constraint_inner_range_lo_top_with_valley.png index c083e840042..8dd6c2c61d8 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_lo_top_with_valley.png and b/test/image/baselines/cheater_constraint_inner_range_lo_top_with_valley.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_with_hill.png b/test/image/baselines/cheater_constraint_inner_range_with_hill.png index a3a226dc778..c4f6fffe24d 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_with_hill.png and b/test/image/baselines/cheater_constraint_inner_range_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_inner_range_with_valley.png b/test/image/baselines/cheater_constraint_inner_range_with_valley.png index 6bec9e84ee8..e5d16d9958b 100644 Binary files a/test/image/baselines/cheater_constraint_inner_range_with_valley.png and b/test/image/baselines/cheater_constraint_inner_range_with_valley.png differ diff --git a/test/image/baselines/cheater_constraint_less_than.png b/test/image/baselines/cheater_constraint_less_than.png index 686239be521..c30c6e79164 100644 Binary files a/test/image/baselines/cheater_constraint_less_than.png and b/test/image/baselines/cheater_constraint_less_than.png differ diff --git a/test/image/baselines/cheater_constraint_less_than_with_hill.png b/test/image/baselines/cheater_constraint_less_than_with_hill.png index e348b2d1f63..4fd274bf205 100644 Binary files a/test/image/baselines/cheater_constraint_less_than_with_hill.png and b/test/image/baselines/cheater_constraint_less_than_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_less_than_with_valley.png b/test/image/baselines/cheater_constraint_less_than_with_valley.png index 7ce196aa586..6c768a76dc0 100644 Binary files a/test/image/baselines/cheater_constraint_less_than_with_valley.png and b/test/image/baselines/cheater_constraint_less_than_with_valley.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range.png b/test/image/baselines/cheater_constraint_outer_range.png index 93a22387005..f1432a4f6e9 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range.png and b/test/image/baselines/cheater_constraint_outer_range.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_hi_top.png b/test/image/baselines/cheater_constraint_outer_range_hi_top.png index d033c854152..8c388507d0c 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_hi_top.png and b/test/image/baselines/cheater_constraint_outer_range_hi_top.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_hi_top_with_hill.png b/test/image/baselines/cheater_constraint_outer_range_hi_top_with_hill.png index 041ebc7cae8..1cb4f3f3a3d 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_hi_top_with_hill.png and b/test/image/baselines/cheater_constraint_outer_range_hi_top_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_hi_top_with_valley.png b/test/image/baselines/cheater_constraint_outer_range_hi_top_with_valley.png index d5e83425a07..b78f7beb7fc 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_hi_top_with_valley.png and b/test/image/baselines/cheater_constraint_outer_range_hi_top_with_valley.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_lo_top.png b/test/image/baselines/cheater_constraint_outer_range_lo_top.png index 04edd3ec5e7..34dd7977426 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_lo_top.png and b/test/image/baselines/cheater_constraint_outer_range_lo_top.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_lo_top_with_hill.png b/test/image/baselines/cheater_constraint_outer_range_lo_top_with_hill.png index 67a8416e5f2..5e24bfab675 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_lo_top_with_hill.png and b/test/image/baselines/cheater_constraint_outer_range_lo_top_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_lo_top_with_valley.png b/test/image/baselines/cheater_constraint_outer_range_lo_top_with_valley.png index ccca7a93811..6bd52cdda65 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_lo_top_with_valley.png and b/test/image/baselines/cheater_constraint_outer_range_lo_top_with_valley.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_with_hill.png b/test/image/baselines/cheater_constraint_outer_range_with_hill.png index 7e86ea89787..a8241c98fec 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_with_hill.png and b/test/image/baselines/cheater_constraint_outer_range_with_hill.png differ diff --git a/test/image/baselines/cheater_constraint_outer_range_with_valley.png b/test/image/baselines/cheater_constraint_outer_range_with_valley.png index 923ac649da8..3473948647d 100644 Binary files a/test/image/baselines/cheater_constraint_outer_range_with_valley.png and b/test/image/baselines/cheater_constraint_outer_range_with_valley.png differ diff --git a/test/image/baselines/cheater_constraints.png b/test/image/baselines/cheater_constraints.png index 07a2b4554e4..e8edc4d40ff 100644 Binary files a/test/image/baselines/cheater_constraints.png and b/test/image/baselines/cheater_constraints.png differ diff --git a/test/image/baselines/cheater_contour.png b/test/image/baselines/cheater_contour.png index 3ab46900ff7..a47a4ededa3 100644 Binary files a/test/image/baselines/cheater_contour.png and b/test/image/baselines/cheater_contour.png differ diff --git a/test/image/baselines/cheater_smooth.png b/test/image/baselines/cheater_smooth.png index ba8e9f4a12d..3087e107ade 100644 Binary files a/test/image/baselines/cheater_smooth.png and b/test/image/baselines/cheater_smooth.png differ diff --git a/test/image/baselines/contour_edge_cases.png b/test/image/baselines/contour_edge_cases.png index eb13470cd1c..db17de50e16 100644 Binary files a/test/image/baselines/contour_edge_cases.png and b/test/image/baselines/contour_edge_cases.png differ diff --git a/test/image/baselines/contour_lines_coloring.png b/test/image/baselines/contour_lines_coloring.png index 1740bee2545..2c8cbf70d95 100644 Binary files a/test/image/baselines/contour_lines_coloring.png and b/test/image/baselines/contour_lines_coloring.png differ diff --git a/test/image/baselines/contour_nolines.png b/test/image/baselines/contour_nolines.png index da9ded0b9dc..89b0b318949 100644 Binary files a/test/image/baselines/contour_nolines.png and b/test/image/baselines/contour_nolines.png differ diff --git a/test/image/baselines/contour_scatter.png b/test/image/baselines/contour_scatter.png index 593077b6c0b..c9285faea72 100644 Binary files a/test/image/baselines/contour_scatter.png and b/test/image/baselines/contour_scatter.png differ diff --git a/test/image/mocks/cheater_contour.json b/test/image/mocks/cheater_contour.json index cd78034730a..8dd58972a7c 100644 --- a/test/image/mocks/cheater_contour.json +++ b/test/image/mocks/cheater_contour.json @@ -7,7 +7,9 @@ "contours":{ "start":1, "end":14, - "size":1 + "size":1, + "showlabels": true, + "labelfont": {"size": 9, "color": "#ff0"} }, "line":{ "width":2, @@ -15,7 +17,8 @@ }, "colorbar": { "len": 0.4, - "y": 0.25 + "y": 0.25, + "x": 0.45 }, "z":[1, 1.96, 2.56, 3.0625, 4, 5.0625, 1, 7.5625, 9, 12.25, 15.21, 14.0625], "a":[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3], @@ -52,14 +55,17 @@ "contours":{ "start":1, "end":14, - "size":1 + "size":1, + "coloring": "lines", + "showlabels": true }, "line":{ "width":2 }, "colorbar": { "len": 0.4, - "y": 0.75 + "y": 0.75, + "x": 0.45 }, "z":[1, 1.96, 2.56, 3.0625, 4, 5.0625, 1, 7.5625, 9, 12.25, 15.21, 14.0625], "a":[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3], @@ -91,10 +97,105 @@ }, "xaxis":"x", "yaxis":"y2" + }, + { + "carpet":"c3", + "type":"contourcarpet", + "autocontour":false, + "contours":{ + "start":-5, + "end":20, + "size":1, + "coloring": "lines" + }, + "line":{ + "width":2, + "smoothing":0 + }, + "colorbar": { + "len": 0.4, + "y": 0.25 + }, + "z":[1, 1.96, 2.56, 3.0625, 4, 5.0625, 1, 7.5625, 9, 12.25, 15.21, 14.0625], + "a":[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3], + "b":[4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6], + "xaxis":"x2", + "yaxis":"y" + }, + { + "carpet":"c3", + "a":[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3], + "b":[4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6], + "y":[1, 1.4, 1.6, 1.75, 2, 2.5, 2.7, 2.75, 3, 3.5, 3.7, 3.75], + "x":[2, 3, 4, 5, 2.2, 3.1, 4.1, 5.1, 1.5, 2.5, 3.5, 4.5], + "type":"carpet", + "aaxis":{ + "tickprefix":"a = ", + "smoothing":0, + "minorgridcount":9, + "type":"linear" + }, + "baxis":{ + "tickprefix":"b = ", + "smoothing":0, + "minorgridcount":9, + "type":"linear" + }, + "xaxis":"x2", + "yaxis":"y" + }, + { + "carpet":"c4", + "type":"contourcarpet", + "autocontour":false, + "contours":{ + "start":-5, + "end":20, + "size":1, + "showlabels": true + }, + "line":{ + "width":2, + "color": "#fff" + }, + "colorbar": { + "len": 0.4, + "y": 0.75 + }, + "z":[1, 1.96, 2.56, 3.0625, 4, 5.0625, 1, 7.5625, 9, 12.25, 15.21, 14.0625], + "a":[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3], + "b":[4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6], + "xaxis":"x2", + "yaxis":"y2", + "zmin":1, + "zmax":15.21, + "zauto":true + }, + { + "carpet":"c4", + "a":[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3], + "b":[4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6], + "y":[1, 1.4, 1.6, 1.75, 2, 2.5, 2.7, 2.75, 3, 3.5, 3.7, 3.75], + "x":[2, 3, 4, 5, 2.2, 3.1, 4.1, 5.1, 1.5, 2.5, 3.5, 4.5], + "type":"carpet", + "aaxis":{ + "tickprefix":"a = ", + "smoothing":1, + "minorgridcount":9, + "type":"linear" + }, + "baxis":{ + "tickprefix":"b = ", + "smoothing":1, + "minorgridcount":9, + "type":"linear" + }, + "xaxis":"x2", + "yaxis":"y2" } ], "layout": { - "width": 600, + "width": 1000, "height": 600, "title": "Cheater plot with 1D input", "margin":{ @@ -125,6 +226,20 @@ ] }, "xaxis":{ + "domain": [ + 0, + 0.48 + ], + "range":[ + 0.6676731793960924, + 5.932326820603907 + ] + }, + "xaxis2":{ + "domain": [ + 0.52, + 1 + ], "range":[ 0.6676731793960924, 5.932326820603907 diff --git a/test/image/mocks/contour_edge_cases.json b/test/image/mocks/contour_edge_cases.json index 9b56c3ba3f8..c33a6061caf 100644 --- a/test/image/mocks/contour_edge_cases.json +++ b/test/image/mocks/contour_edge_cases.json @@ -8,7 +8,13 @@ "contours": { "start": 1.1, "end": 4.09, - "size": 1 + "size": 1, + "showlabels": true, + "labelformat": ".3f", + "labelfont": { + "size": "8", + "color": "#0f0" + } }, "colorbar": {"x": 0.4, "y": 0.9, "len": 0.2} }, @@ -35,7 +41,9 @@ "contours": { "start": -0.000001, "end": 0.000001, - "size": 0.000001 + "size": 0.000001, + "showlabels": true, + "labelformat": ".1e" }, "colorbar": {"x": 0.4, "y": 0.65, "len": 0.2}, "yaxis": "y2" @@ -50,7 +58,11 @@ "contours": { "start": -0.000001, "end": 0.000001, - "size": 0.000001 + "size": 0.000001, + "showlabels": true + }, + "line": { + "color": "#fff" }, "colorbar": {"x": 1, "y": 0.65, "len": 0.2}, "xaxis": "x2", @@ -66,9 +78,10 @@ "contours": { "start": -0.000001, "end": 0.000001, - "size": 0.000001 + "size": 0.000001, + "showlabels": true }, - "colorbar": {"x": 0.4, "y": 0.4, "len": 0.2}, + "showscale": false, "yaxis": "y3" }, { diff --git a/test/image/mocks/contour_lines_coloring.json b/test/image/mocks/contour_lines_coloring.json index ef0b136b728..a34c8354d8f 100644 --- a/test/image/mocks/contour_lines_coloring.json +++ b/test/image/mocks/contour_lines_coloring.json @@ -1,6 +1,6 @@ { "data":[{ - "contours":{"coloring":"lines"}, + "contours":{"coloring":"lines", "showlabels": true}, "z":[["1",""],["2",""],["3",""],["3",""],["4",""],["5",""],["6",""],["5",""],["2",""],["3",""],["3",""],["5",""],["6",""],["5",""],["4","1"],["4","2"],["2","3"],["1","4"],["3","5"],["2","4"],["1","3"],["3","2"],["5","3"],["4","4"],["3","3"],["2","2"],["1","1"],["2","2"],["3","3"],["4","4"],["5","6"],["4","5"],["3","4"],["2","3"],["3","2"],["2","3"],["3","4"],["3","3"],["3","2"]], "type":"contour" }], diff --git a/test/image/mocks/contour_nolines.json b/test/image/mocks/contour_nolines.json index 1cfee5473a0..939a901793e 100644 --- a/test/image/mocks/contour_nolines.json +++ b/test/image/mocks/contour_nolines.json @@ -1,13 +1,19 @@ { "data":[{ - "contours":{"coloring":"fill","showlines":false}, - "z":[["1",""],["2",""],["3",""],["3",""],["4",""],["5",""],["6",""],["5",""],["2",""],["3",""],["3",""],["5",""],["6",""],["5",""],["4","1"],["4","2"],["2","3"],["1","4"],["3","5"],["2","4"],["1","3"],["3","2"],["5","3"],["4","4"],["3","3"],["2","2"],["1","1"],["2","2"],["3","3"],["4","4"],["5","6"],["4","5"],["3","4"],["2","3"],["3","2"],["2","3"],["3","4"],["3","3"],["3","2"]], + "contours":{ + "coloring": "fill", + "showlines": false, + "showlabels": true + }, + "z":[[0, 10, 0], [10, 80, 20], [0, 40, 0]], "type":"contour" }], "layout":{ "showlegend":false, "autosize":false, "height":400, - "width":400 + "width":400, + "xaxis": {"range": [1, 2.5]}, + "yaxis": {"range": [0.2, 1]} } } diff --git a/test/image/mocks/contour_scatter.json b/test/image/mocks/contour_scatter.json index 0ba4c1fd150..2650e54414e 100644 --- a/test/image/mocks/contour_scatter.json +++ b/test/image/mocks/contour_scatter.json @@ -294,7 +294,10 @@ ], "ncontours": 30, "showscale": false, - "type": "contour" + "type": "contour", + "contours": { + "showlabels": true + } }, { "x": [ diff --git a/test/jasmine/tests/drawing_test.js b/test/jasmine/tests/drawing_test.js index fb5a283ea78..fe6821f6246 100644 --- a/test/jasmine/tests/drawing_test.js +++ b/test/jasmine/tests/drawing_test.js @@ -1,6 +1,7 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); var Drawing = require('@src/components/drawing'); +var svgTextUtils = require('@src/lib/svg_text_utils'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); @@ -355,14 +356,14 @@ describe('Drawing', function() { afterEach(destroyGraphDiv); function assertBBox(actual, expected) { - var TOL = 3; - expect(actual.height).toBeWithin(expected.height, TOL, 'height'); - expect(actual.top).toBeWithin(expected.top, TOL, 'top'); - expect(actual.bottom).toBeWithin(expected.bottom, TOL, 'bottom'); - - expect(actual.width).toBeWithin(expected.width, TOL, 'width'); - expect(actual.left).toBeWithin(expected.left, TOL, 'left'); - expect(actual.right).toBeWithin(expected.right, TOL, 'right'); + [ + 'height', 'top', 'bottom', + 'width', 'left', 'right' + ].forEach(function(dim) { + // give larger dimensions some extra tolerance + var tol = Math.max(expected[dim] / 10, 3); + expect(actual[dim]).toBeWithin(expected[dim], tol, dim); + }); } it('should update bounding box dimension on window scroll', function(done) { @@ -422,6 +423,28 @@ describe('Drawing', function() { .catch(fail) .then(done); }); + + it('works with dummy nodes created in Drawing.tester', function() { + var node = Drawing.tester.append('text') + .text('bananas') + .call(Drawing.font, '"Open Sans", verdana, arial, sans-serif', 19) + .call(svgTextUtils.convertToTspans).node(); + + expect(node.parentNode).toBe(Drawing.tester.node()); + + assertBBox(Drawing.bBox(node), { + height: 21, + width: 76, + left: 0, + top: -17, + right: 76, + bottom: 4 + }); + + expect(node.parentNode).toBe(Drawing.tester.node()); + + node.parentNode.removeChild(node); + }); }); }); diff --git a/test/jasmine/tests/lib_geometry2d_test.js b/test/jasmine/tests/lib_geometry2d_test.js new file mode 100644 index 00000000000..475bdad75b3 --- /dev/null +++ b/test/jasmine/tests/lib_geometry2d_test.js @@ -0,0 +1,201 @@ +var geom2d = require('@src/lib/geometry2d'); +var customMatchers = require('../assets/custom_matchers'); +var Drawing = require('@src/components/drawing'); + +// various reversals of segments and endpoints that should all give identical results +function permute(_inner, x1, y1, x2, y2, x3, y3, x4, y4, expected) { + _inner(x1, y1, x2, y2, x3, y3, x4, y4, expected); + _inner(x2, y2, x1, y1, x3, y3, x4, y4, expected); + _inner(x1, y1, x2, y2, x4, y4, x3, y3, expected); + _inner(x2, y2, x1, y1, x4, y4, x3, y3, expected); + _inner(x3, y3, x4, y4, x1, y1, x2, y2, expected); + _inner(x4, y4, x3, y3, x1, y1, x2, y2, expected); + _inner(x3, y3, x4, y4, x2, y2, x1, y1, expected); + _inner(x4, y4, x3, y3, x2, y2, x1, y1, expected); +} + +describe('segmentsIntersect', function() { + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + function check(x1, y1, x2, y2, x3, y3, x4, y4, expected) { + // test swapping x/y + var result1 = geom2d.segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4); + var result2 = geom2d.segmentsIntersect(y1, x1, y2, x2, y3, x3, y4, x4); + if(Array.isArray(expected)) { + expect(result1.x).toBeWithin(expected[0], 1e-6); + expect(result2.y).toBeWithin(expected[0], 1e-6); + expect(result1.y).toBeWithin(expected[1], 1e-6); + expect(result2.x).toBeWithin(expected[1], 1e-6); + } + else { + expect(result1).toBe(expected); + expect(result2).toBe(expected); + } + } + + it('catches normal intersections', function() { + permute(check, -1, -1, 1, 1, -1, 1, 1, -1, [0, 0]); + permute(check, -1, 0, 1, 0, 0, -1, 0, 1, [0, 0]); + permute(check, 0, 0, 100, 100, 0, 1, 100, 99, [50, 50]); + }); + + it('catches non-intersections', function() { + permute(check, -1, 0, 1, 0, 0, 0.1, 0, 2, null); + permute(check, -1, -1, 1, 1, -1, 1, 1, 2, null); + permute(check, -1, 0, 1, 0, -1, 0.0001, 1, 0.0001, null); + permute(check, -1, 0, 1, 0.0001, -1, 0.0001, 1, 0.00011, null); + permute(check, -1, -1, 1, 1, -1, 0, 1, 2, null); + }); + + it('does not consider colinear lines intersecting', function() { + permute(check, -1, 0, 1, 0, -1, 0, 1, 0, null); + permute(check, -1, 0, 1, 0, -2, 0, 2, 0, null); + permute(check, -2, -1, 2, 1, -2, -1, 2, 1, null); + permute(check, -4, -2, 0, 0, -2, -1, 2, 1, null); + }); +}); + +describe('segmentDistance', function() { + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + function check(x1, y1, x2, y2, x3, y3, x4, y4, expected) { + var result1 = geom2d.segmentDistance(x1, y1, x2, y2, x3, y3, x4, y4); + var result2 = geom2d.segmentDistance(y1, x1, y2, x2, y3, x3, y4, x4); + expect(result1).toBeWithin(expected, 1e-6); + expect(result2).toBeWithin(expected, 1e-6); + } + + it('returns 0 if segments intersect or share endpoints', function() { + permute(check, -1, -1, 1, 1, -1, 1, 1, -1, 0); + permute(check, -1, 0, 1, 0, 0, -1, 0, 1, 0); + permute(check, 0, 0, 100, 100, 0, 1, 100, 99, 0); + permute(check, 0, 0, 1.23, 2.34, 12.99, 14.55, 1.23, 2.34, 0); + }); + + it('works in the endpoint-to-endpoint case', function() { + permute(check, 0, 0, 1, 0, 5, 0, 6, 0, 4); + permute(check, 0, 0, 1, 0, 5, 0, 6, 50, 4); + permute(check, 0, -50, 1, 0, 5, 0, 6, 0, 4); + permute(check, 0, -50, 1, 0, 5, 0, 6, 50, 4); + permute(check, 1, -50, 1, 0, 5, 0, 5, 50, 4); + + permute(check, 0, 0, 1, 0, 2, 2, 3, 2, Math.sqrt(5)); + permute(check, 0, 0, 1, 0, 2, 2, 2, 3, Math.sqrt(5)); + }); + + it('works in the endpoint-to-perpendicular case', function() { + permute(check, -5, 0, 5, 0, 0, 1, 0, 2, 1); + permute(check, -5, 0, 5, 0, 3.23, 1.55, -7.13, 1.65, 1.55); + permute(check, 100, 0, 0, 100, 0, 5, 15, 0, 85 / Math.sqrt(2)); + }); +}); + +describe('getVisibleSegment', function() { + beforeAll(function() { + Drawing.makeTester(); + jasmine.addMatchers(customMatchers); + }); + + var path; + + beforeEach(function() { + path = Drawing.tester.append('path').node(); + }); + + afterEach(function() { + path.parentNode.removeChild(path); + }); + + // always check against the same bounds + var bounds = { + left: 50, + top: 100, + right: 250, + bottom: 200 + }; + + function checkD(d, expected, msg) { + path.setAttribute('d', d); + [0.1, 0.3, 1, 3, 10, 30].forEach(function(buffer) { + var msg2 = msg ? (msg + ' - ' + buffer) : buffer; + var vis = geom2d.getVisibleSegment(path, bounds, buffer); + + if(!expected) { + expect(vis).toBeUndefined(msg2); + } + else { + expect(vis.min).toBeWithin(expected.min, buffer * 1.1, msg2); + expect(vis.max).toBeWithin(expected.max, buffer * 1.1, msg2); + expect(vis.len).toBeWithin(expected.len, buffer * 2.1, msg2); + expect(vis.total).toBeWithin(expected.total, 0.1, msg2); + expect(vis.isClosed).toBe(expected.isClosed, msg2); + } + }); + } + + it('returns undefined if the path is out of bounds', function() { + checkD('M0,0V500'); + checkD('M0,0H500'); + checkD('M500,0H0'); + checkD('M0,200L99,0H201L300,200L150,201Z'); + }); + + it('returns the whole path if it is not clipped', function() { + var diag = 100 * Math.sqrt(5); + checkD('M50,100L250,200', { + min: 0, max: diag, total: diag, len: diag, isClosed: false + }); + + checkD('M100,110H200V185Z', { + min: 0, max: 300, total: 300, len: 300, isClosed: true + }); + }); + + it('works with initial clipping', function() { + checkD('M0,0H150V150H100', { + min: 250, max: 350, total: 350, len: 100, isClosed: false + }); + }); + + it('works with both ends clipped', function() { + checkD('M0,125H100V175H0', { + min: 50, max: 200, total: 250, len: 150, isClosed: false + }); + }); + + it('works with final clipping', function() { + checkD('M100,150H500', { + min: 0, max: 150, total: 400, len: 150, isClosed: false + }); + }); + + it('is open if entry/exit points match but are not the start/end points', function() { + checkD('M0,150H100Z', { + min: 50, max: 150, total: 200, len: 100, isClosed: false + }); + + checkD('M0,150H100H50', { + min: 50, max: 150, total: 150, len: 100, isClosed: false + }); + + checkD('M50,150H100H0', { + min: 0, max: 100, total: 150, len: 100, isClosed: false + }); + }); + + it('can be closed even without Z', function() { + checkD('M100,150H200H100', { + min: 0, max: 200, total: 200, len: 200, isClosed: true + }); + + // notice that this one goes outside the bounds but then + // comes back in. We don't catch that part. + checkD('M100,150V650V150', { + min: 0, max: 1000, total: 1000, len: 1000, isClosed: true + }); + }); +}); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index 94e5fe31a19..c9035febbbc 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -8,6 +8,7 @@ var Legend = require('@src/components/legend'); var pkg = require('../../../package.json'); var subroutines = require('@src/plot_api/subroutines'); var helpers = require('@src/plot_api/helpers'); +var editTypes = require('@src/plot_api/edit_types'); var d3 = require('d3'); var customMatchers = require('../assets/custom_matchers'); @@ -1741,3 +1742,47 @@ describe('plot_api helpers', function() { }); }); }); + +describe('plot_api edit_types', function() { + it('initializes flags with all false', function() { + ['traces', 'layout'].forEach(function(container) { + var initFlags = editTypes[container](); + Object.keys(initFlags).forEach(function(key) { + expect(initFlags[key]).toBe(false, container + '.' + key); + }); + }); + }); + + it('makes no changes if editType is not included', function() { + var flags = {docalc: false, dostyle: true}; + + editTypes.update(flags, { + valType: 'boolean', + dflt: true, + role: 'style' + }); + + expect(flags).toEqual({docalc: false, dostyle: true}); + + editTypes.update(flags, { + family: {valType: 'string', dflt: 'Comic sans'}, + size: {valType: 'number', dflt: 96}, + color: {valType: 'color', dflt: 'red'} + }); + + expect(flags).toEqual({docalc: false, dostyle: true}); + }); + + it('gets updates from the outer object and ignores nested items', function() { + var flags = {docalc: false, dolegend: true}; + + editTypes.update(flags, { + editType: 'docalc+dostyle', + valType: 'number', + dflt: 1, + role: 'style' + }); + + expect(flags).toEqual({docalc: true, dolegend: true, dostyle: true}); + }); +}); diff --git a/test/jasmine/tests/plotschema_test.js b/test/jasmine/tests/plotschema_test.js index eba5226cf31..e13210bc8fc 100644 --- a/test/jasmine/tests/plotschema_test.js +++ b/test/jasmine/tests/plotschema_test.js @@ -11,8 +11,9 @@ describe('plot schema', function() { var isValObject = Plotly.PlotSchema.isValObject, isPlainObject = Lib.isPlainObject; - var VALTYPES = Object.keys(valObjects), - ROLES = ['info', 'style', 'data']; + var VALTYPES = Object.keys(valObjects); + var ROLES = ['info', 'style', 'data']; + var editTypes = plotSchema.defs.editTypes; function assertPlotSchema(callback) { var traces = plotSchema.traces; @@ -179,6 +180,27 @@ describe('plot schema', function() { ); }); + it('has valid or no `editType` in every attribute', function() { + var validEditTypes = editTypes.traces; + assertPlotSchema( + function(attr, attrName, attrs) { + if(attrs === plotSchema.layout.layoutAttributes) { + // detect when we switch from trace attributes to layout + // attributes - depends on doing all the trace attributes + // first, then switching to layout attributes + validEditTypes = editTypes.layout; + } + if(attr.editType !== undefined) { + var editTypeParts = attr.editType.split('+'); + editTypeParts.forEach(function(editTypePart) { + expect(validEditTypes[editTypePart]) + .toBe(false, editTypePart); + }); + } + } + ); + }); + it('should work with registered transforms', function() { var valObjects = plotSchema.transforms.filter.attributes, attrNames = Object.keys(valObjects);