diff --git a/lib/index.js b/lib/index.js index cbb42c96bd7..9687411028d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -38,7 +38,7 @@ Plotly.register([ require('./pointcloud'), require('./heatmapgl'), require('./parcoords'), - + require('./parcats'), require('./scattermapbox'), require('./sankey'), diff --git a/lib/parcats.js b/lib/parcats.js new file mode 100644 index 00000000000..b087f4a06ba --- /dev/null +++ b/lib/parcats.js @@ -0,0 +1,11 @@ +/** +* Copyright 2012-2018, 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 = require('../src/traces/parcats'); diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 73ec3f3abca..a7e4eb6712d 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -153,6 +153,81 @@ exports.loneHover = function loneHover(hoverItem, opts) { return hoverLabel.node(); }; +exports.multiHovers = function multiHovers(hoverItems, opts) { + + if(!Array.isArray(hoverItems)) { + hoverItems = [hoverItems]; + } + + var pointsData = hoverItems.map(function(hoverItem) { + return { + color: hoverItem.color || Color.defaultLine, + x0: hoverItem.x0 || hoverItem.x || 0, + x1: hoverItem.x1 || hoverItem.x || 0, + y0: hoverItem.y0 || hoverItem.y || 0, + y1: hoverItem.y1 || hoverItem.y || 0, + xLabel: hoverItem.xLabel, + yLabel: hoverItem.yLabel, + zLabel: hoverItem.zLabel, + text: hoverItem.text, + name: hoverItem.name, + idealAlign: hoverItem.idealAlign, + + // optional extra bits of styling + borderColor: hoverItem.borderColor, + fontFamily: hoverItem.fontFamily, + fontSize: hoverItem.fontSize, + fontColor: hoverItem.fontColor, + + // filler to make createHoverText happy + trace: { + index: 0, + hoverinfo: '' + }, + xa: {_offset: 0}, + ya: {_offset: 0}, + index: 0 + }; + }); + + + var container3 = d3.select(opts.container), + outerContainer3 = opts.outerContainer ? + d3.select(opts.outerContainer) : container3; + + var fullOpts = { + hovermode: 'closest', + rotateLabels: false, + bgColor: opts.bgColor || Color.background, + container: container3, + outerContainer: outerContainer3 + }; + + var hoverLabel = createHoverText(pointsData, fullOpts, opts.gd); + + // Fix vertical overlap + var tooltipSpacing = 5; + var lastBottomY = 0; + hoverLabel + .sort(function(a, b) {return a.y0 - b.y0;}) + .each(function(d) { + var topY = d.y0 - d.by / 2; + + if((topY - tooltipSpacing) < lastBottomY) { + d.offset = (lastBottomY - topY) + tooltipSpacing; + } else { + d.offset = 0; + } + + lastBottomY = topY + d.by + d.offset; + }); + + + alignHoverText(hoverLabel, fullOpts.rotateLabels); + + return hoverLabel.node(); +}; + // The actual implementation is here: function _hover(gd, evt, subplot, noHoverEvent) { if(!subplot) subplot = 'xy'; diff --git a/src/components/fx/index.js b/src/components/fx/index.js index 77c618b8354..b494b0342dc 100644 --- a/src/components/fx/index.js +++ b/src/components/fx/index.js @@ -13,6 +13,7 @@ var Lib = require('../../lib'); var dragElement = require('../dragelement'); var helpers = require('./helpers'); var layoutAttributes = require('./layout_attributes'); +var hoverModule = require('./hover'); module.exports = { moduleType: 'component', @@ -41,10 +42,11 @@ module.exports = { castHoverOption: castHoverOption, castHoverinfo: castHoverinfo, - hover: require('./hover').hover, + hover: hoverModule.hover, unhover: dragElement.unhover, - loneHover: require('./hover').loneHover, + loneHover: hoverModule.loneHover, + multiHovers: hoverModule.multiHovers, loneUnhover: loneUnhover, click: require('./click') diff --git a/src/traces/parcats/attributes.js b/src/traces/parcats/attributes.js new file mode 100644 index 00000000000..7035c8ea45a --- /dev/null +++ b/src/traces/parcats/attributes.js @@ -0,0 +1,202 @@ +/** +* Copyright 2012-2018, 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 extendFlat = require('../../lib/extend').extendFlat; +var plotAttrs = require('../../plots/attributes'); +var fontAttrs = require('../../plots/font_attributes'); +var colorAttributes = require('../../components/colorscale/attributes'); +var domainAttrs = require('../../plots/domain').attributes; +var scatterAttrs = require('../scatter/attributes'); +var scatterLineAttrs = scatterAttrs.line; +var colorbarAttrs = require('../../components/colorbar/attributes'); + +var line = extendFlat({ + editType: 'calc' +}, colorAttributes('line', {editType: 'calc'}), + { + showscale: scatterLineAttrs.showscale, + colorbar: colorbarAttrs, + shape: { + valType: 'enumerated', + values: ['linear', 'hspline'], + dflt: 'linear', + role: 'info', + editType: 'plot', + description: [ + 'Sets the shape of the paths.', + 'If `linear`, paths are composed of straight lines.', + 'If `hspline`, paths are composed of horizontal curved splines' + ].join(' ') + } + }); + +module.exports = { + domain: domainAttrs({name: 'parcats', trace: true, editType: 'calc'}), + hoverinfo: extendFlat({}, plotAttrs.hoverinfo, { + flags: ['count', 'probability'], + editType: 'plot' + // plotAttrs.hoverinfo description is appropriate + }), + hoveron: { + valType: 'enumerated', + values: ['category', 'color', 'dimension'], + dflt: 'category', + role: 'info', + editType: 'plot', + description: [ + 'Sets the hover interaction mode for the parcats diagram.', + 'If `category`, hover interaction take place per category.', + 'If `color`, hover interactions take place per color per category.', + 'If `dimension`, hover interactions take place across all categories per dimension.' + ].join(' ') + }, + arrangement: { + valType: 'enumerated', + values: ['perpendicular', 'freeform', 'fixed'], + dflt: 'perpendicular', + role: 'style', + editType: 'plot', + description: [ + 'Sets the drag interaction mode for categories and dimensions.', + 'If `perpendicular`, the categories can only move along a line perpendicular to the paths.', + 'If `freeform`, the categories can freely move on the plane.', + 'If `fixed`, the categories and dimensions are stationary.' + ].join(' ') + }, + bundlecolors: { + valType: 'boolean', + dflt: true, + role: 'info', + editType: 'plot', + description: 'Sort paths so that like colors are bundled together within each category.' + }, + sortpaths: { + valType: 'enumerated', + values: ['forward', 'backward'], + dflt: 'forward', + role: 'info', + editType: 'plot', + description: [ + 'Sets the path sorting algorithm.', + 'If `forward`, sort paths based on dimension categories from left to right.', + 'If `backward`, sort paths based on dimensions categories from right to left.' + ].join(' ') + }, + labelfont: fontAttrs({ + editType: 'calc', + description: 'Sets the font for the `dimension` labels.' + }), + + tickfont: fontAttrs({ + editType: 'calc', + description: 'Sets the font for the `category` labels.' + }), + + dimensions: { + _isLinkedToArray: 'dimension', + label: { + valType: 'string', + role: 'info', + editType: 'calc', + description: 'The shown name of the dimension.' + }, + categoryorder: { + valType: 'enumerated', + values: [ + 'trace', 'category ascending', 'category descending', 'array' + ], + dflt: 'trace', + role: 'info', + editType: 'calc', + description: [ + 'Specifies the ordering logic for the categories in the dimension.', + 'By default, plotly uses *trace*, which specifies the order that is present in the data supplied.', + 'Set `categoryorder` to *category ascending* or *category descending* if order should be determined by', + 'the alphanumerical order of the category names.', + 'Set `categoryorder` to *array* to derive the ordering from the attribute `categoryarray`. If a category', + 'is not found in the `categoryarray` array, the sorting behavior for that attribute will be identical to', + 'the *trace* mode. The unspecified categories will follow the categories in `categoryarray`.' + ].join(' ') + }, + categoryarray: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets the order in which categories in this dimension appear.', + 'Only has an effect if `categoryorder` is set to *array*.', + 'Used with `categoryorder`.' + ].join(' ') + }, + ticktext: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets alternative tick labels for the categories in this dimension.', + 'Only has an effect if `categoryorder` is set to *array*.', + 'Should be an array the same length as `categoryarray`', + 'Used with `categoryorder`.' + ].join(' ') + }, + values: { + valType: 'data_array', + role: 'info', + dflt: [], + editType: 'calc', + description: [ + 'Dimension values. `values[n]` represents the category value of the `n`th point in the dataset,', + 'therefore the `values` vector for all dimensions must be the same (longer vectors', + 'will be truncated).' + ].join(' ') + }, + displayindex: { + valType: 'integer', + role: 'info', + editType: 'calc', + description: [ + 'The display index of dimension, from left to right, zero indexed, defaults to dimension', + 'index.' + ].join(' ') + }, + editType: 'calc', + description: 'The dimensions (variables) of the parallel categories diagram.', + visible: { + valType: 'boolean', + dflt: true, + role: 'info', + editType: 'calc', + description: 'Shows the dimension when set to `true` (the default). Hides the dimension for `false`.' + } + }, + + line: line, + counts: { + valType: 'number', + min: 0, + dflt: 1, + arrayOk: true, + role: 'info', + editType: 'calc', + description: [ + 'The number of observations represented by each state. Defaults to 1 so that each state represents', + 'one observation' + ].join(' ') + }, + + // Hide unsupported top-level properties from plot-schema + customdata: undefined, + hoverlabel: undefined, + ids: undefined, + legendgroup: undefined, + opacity: undefined, + selectedpoints: undefined, + showlegend: undefined +}; diff --git a/src/traces/parcats/base_plot.js b/src/traces/parcats/base_plot.js new file mode 100644 index 00000000000..85aa3986b24 --- /dev/null +++ b/src/traces/parcats/base_plot.js @@ -0,0 +1,34 @@ +/** +* Copyright 2012-2018, 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 getModuleCalcData = require('../../plots/get_data').getModuleCalcData; +var parcatsPlot = require('./plot'); + +var PARCATS = 'parcats'; +exports.name = PARCATS; + +exports.plot = function(gd, traces, transitionOpts, makeOnCompleteCallback) { + + var cdModuleAndOthers = getModuleCalcData(gd.calcdata, PARCATS); + + if(cdModuleAndOthers.length) { + var calcData = cdModuleAndOthers[0]; + parcatsPlot(gd, calcData, transitionOpts, makeOnCompleteCallback); + } +}; + +exports.clean = function(newFullData, newFullLayout, oldFullData, oldFullLayout) { + var hadTable = (oldFullLayout._has && oldFullLayout._has('parcats')); + var hasTable = (newFullLayout._has && newFullLayout._has('parcats')); + + if(hadTable && !hasTable) { + oldFullLayout._paperdiv.selectAll('.parcats').remove(); + } +}; diff --git a/src/traces/parcats/calc.js b/src/traces/parcats/calc.js new file mode 100644 index 00000000000..d52bbc4fca7 --- /dev/null +++ b/src/traces/parcats/calc.js @@ -0,0 +1,523 @@ +/** +* Copyright 2012-2018, 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'; + +// Requirements +// ============ +var wrap = require('../../lib/gup').wrap; +var hasColorscale = require('../../components/colorscale/has_colorscale'); +var colorscaleCalc = require('../../components/colorscale/calc'); +var filterUnique = require('../../lib/filter_unique.js'); +var Drawing = require('../../components/drawing'); +var Lib = require('../../lib'); + + +function visible(dimension) { return !('visible' in dimension) || dimension.visible; } + +// Exports +// ======= +/** + * Create a wrapped ParcatsModel object from trace + * + * Note: trace defaults have already been applied + * @param {Object} gd + * @param {Object} trace + * @return {Array.} + */ +module.exports = function calc(gd, trace) { + + // Process inputs + // -------------- + if(trace.dimensions.filter(visible).length === 0) { + // No visible dimensions specified. Nothing to compute + return []; + } + + // Compute unique information + // -------------------------- + // UniqueInfo per dimension + var uniqueInfoDims = trace.dimensions.filter(visible).map(function(dim) { + var categoryValues; + if(dim.categoryorder === 'trace') { + // Use order of first occurrence in trace + categoryValues = null; + } else if(dim.categoryorder === 'array') { + // Use categories specified in `categoryarray` first, then add extra to the end in trace order + categoryValues = dim.categoryarray; + } else { + // Get all categories up front so we can order them + // Should we check for numbers as sort numerically? + categoryValues = filterUnique(dim.values).sort(); + if(dim.categoryorder === 'category descending') { + categoryValues = categoryValues.reverse(); + } + } + return getUniqueInfo(dim.values, categoryValues); + }); + + // Process counts + // -------------- + var counts, + count, + totalCount; + if(Lib.isArrayOrTypedArray(trace.counts)) { + counts = trace.counts; + } else { + counts = [trace.counts]; + } + + // Validate dimension display order + // -------------------------------- + validateDimensionDisplayInds(trace); + + // Validate category display order + // ------------------------------- + trace.dimensions.filter(visible).forEach(function(dim, dimInd) { + validateCategoryProperties(dim, uniqueInfoDims[dimInd]); + }); + + // Handle path colors + // ------------------ + var line = trace.line; + var markerColorscale; + + // Process colorscale + if(line) { + if(hasColorscale(trace, 'line')) { + colorscaleCalc(trace, trace.line.color, 'line', 'c'); + } + markerColorscale = Drawing.tryColorscale(line); + } else { + markerColorscale = Lib.identity; + } + + // Build color generation function + function getMarkerColorInfo(index) { + var value; + if(Lib.isArrayOrTypedArray(line.color)) { + value = line.color[index % line.color.length]; + } else { + value = line.color; + } + + return {color: markerColorscale(value), rawColor: value}; + } + + // Number of values and counts + // --------------------------- + var numValues = trace.dimensions.filter(visible)[0].values.length; + + // Build path info + // --------------- + // Mapping from category inds to PathModel objects + var pathModels = {}; + + // Category inds array for each dimension + var categoryIndsDims = uniqueInfoDims.map(function(di) {return di.inds;}); + + // Initialize total count + totalCount = 0; + var valueInd; + var d; + + for(valueInd = 0; valueInd < numValues; valueInd++) { + + // Category inds for this input value across dimensions + var categoryIndsPath = []; + for(d = 0; d < categoryIndsDims.length; d++) { + categoryIndsPath.push(categoryIndsDims[d][valueInd]); + } + + // Count + count = counts[valueInd % counts.length]; + + // Update total count + totalCount += count; + + // Path color + var pathColorInfo = getMarkerColorInfo(valueInd); + + // path key + var pathKey = categoryIndsPath + '-' + pathColorInfo.rawColor; + + // Create / Update PathModel + if(pathModels[pathKey] === undefined) { + pathModels[pathKey] = createPathModel(categoryIndsPath, + pathColorInfo.color, + pathColorInfo.rawColor); + } + updatePathModel(pathModels[pathKey], valueInd, count); + } + + // Build categories info + // --------------------- + + // Array of DimensionModel objects + var dimensionModels = trace.dimensions.filter(visible).map(function(di, i) { + return createDimensionModel(i, di._index, di.displayindex, di.label, totalCount); + }); + + + for(valueInd = 0; valueInd < numValues; valueInd++) { + + count = counts[valueInd % counts.length]; + + for(d = 0; d < dimensionModels.length; d++) { + var containerInd = dimensionModels[d].containerInd; + var catInd = uniqueInfoDims[d].inds[valueInd]; + var cats = dimensionModels[d].categories; + + if(cats[catInd] === undefined) { + var catValue = trace.dimensions[containerInd].categoryarray[catInd]; + var catLabel = trace.dimensions[containerInd].ticktext[catInd]; + cats[catInd] = createCategoryModel(d, catInd, catValue, catLabel); + } + + updateCategoryModel(cats[catInd], valueInd, count); + } + } + + // Compute unique + return wrap(createParcatsModel(dimensionModels, pathModels, totalCount)); +}; + +// Models +// ====== + +// Parcats Model +// ------------- +/** + * @typedef {Object} ParcatsModel + * Object containing calculated information about a parcats trace + * + * @property {Array.} dimensions + * Array of dimension models + * @property {Object.} paths + * Dictionary from category inds string (e.g. "1,2,1,1") to path model + * @property {Number} maxCats + * The maximum number of categories of any dimension in the diagram + * @property {Number} count + * Total number of input values + * @property {Object} trace + */ + +/** + * Create and new ParcatsModel object + * @param {Array.} dimensions + * @param {Object.} paths + * @param {Number} count + * @return {ParcatsModel} + */ +function createParcatsModel(dimensions, paths, count) { + var maxCats = dimensions + .filter(visible) + .map(function(d) {return d.categories.length;}) + .reduce(function(v1, v2) {return Math.max(v1, v2);}); + return {dimensions: dimensions, paths: paths, trace: undefined, maxCats: maxCats, count: count}; +} + +// Dimension Model +// --------------- +/** + * @typedef {Object} DimensionModel + * Object containing calculated information about a single dimension + * + * @property {Number} dimensionInd + * The index of this dimension among the *visible* dimensions + * @property {Number} containerInd + * The index of this dimension in the original dimensions container, + * irrespective of dimension visibility + * @property {Number} displayInd + * The display index of this dimension (where 0 is the left most dimension) + * @property {String} dimensionLabel + * The label of this dimension + * @property {Number} count + * Total number of input values + * @property {Array.} categories + * @property {Number|null} dragX + * The x position of dimension that is currently being dragged. null if not being dragged + */ + +/** + * Create and new DimensionModel object with an empty categories array + * @param {Number} dimensionInd + * @param {Number} containerInd + * @param {Number} displayInd + * @param {String} dimensionLabel + * @param {Number} count + * Total number of input values + * @return {DimensionModel} + */ +function createDimensionModel(dimensionInd, containerInd, displayInd, dimensionLabel, count) { + return { + dimensionInd: dimensionInd, + containerInd: containerInd, + displayInd: displayInd, + dimensionLabel: dimensionLabel, + count: count, + categories: [], + dragX: null + }; +} + +// Category Model +// -------------- +/** + * @typedef {Object} CategoryModel + * Object containing calculated information about a single category. + * + * @property {Number} dimensionInd + * The index of this categories dimension + * @property {Number} categoryInd + * The index of this category + * @property {Number} displayInd + * The display index of this category (where 0 is the topmost category) + * @property {String} categoryLabel + * The name of this category + * @property categoryValue: Raw value of the category + * @property {Array} valueInds + * Array of indices (into the original value array) of all samples in this category + * @property {Number} count + * The number of elements from the original array in this path + * @property {Number|null} dragY + * The y position of category that is currently being dragged. null if not being dragged + */ + +/** + * Create and return a new CategoryModel object + * @param {Number} dimensionInd + * @param {Number} categoryInd + * The display index of this category (where 0 is the topmost category) + * @param {String} categoryValue + * @param {String} categoryLabel + * @return {CategoryModel} + */ +function createCategoryModel(dimensionInd, categoryInd, categoryValue, categoryLabel) { + return { + dimensionInd: dimensionInd, + categoryInd: categoryInd, + categoryValue: categoryValue, + displayInd: categoryInd, + categoryLabel: categoryLabel, + valueInds: [], + count: 0, + dragY: null + }; +} + +/** + * Update a CategoryModel object with a new value index + * Note: The calling parameter is modified in place. + * + * @param {CategoryModel} categoryModel + * @param {Number} valueInd + * @param {Number} count + */ +function updateCategoryModel(categoryModel, valueInd, count) { + categoryModel.valueInds.push(valueInd); + categoryModel.count += count; +} + + +// Path Model +// ---------- +/** + * @typedef {Object} PathModel + * Object containing calculated information about the samples in a path. + * + * @property {Array} categoryInds + * Array of category indices for each dimension (length `numDimensions`) + * @param {String} pathColor + * Color of this path. (Note: Any colorscaling has already taken place) + * @property {Array} valueInds + * Array of indices (into the original value array) of all samples in this path + * @property {Number} count + * The number of elements from the original array in this path + * @property {String} color + * The path's color (ass CSS color string) + * @property rawColor + * The raw color value specified by the user. May be a CSS color string or a Number + */ + +/** + * Create and return a new PathModel object + * @param {Array} categoryInds + * @param color + * @param rawColor + * @return {PathModel} + */ +function createPathModel(categoryInds, color, rawColor) { + return { + categoryInds: categoryInds, + color: color, + rawColor: rawColor, + valueInds: [], + count: 0 + }; +} + +/** + * Update a PathModel object with a new value index + * Note: The calling parameter is modified in place. + * + * @param {PathModel} pathModel + * @param {Number} valueInd + * @param {Number} count + */ +function updatePathModel(pathModel, valueInd, count) { + pathModel.valueInds.push(valueInd); + pathModel.count += count; +} + +// Unique calculations +// =================== +/** + * @typedef {Object} UniqueInfo + * Object containing information about the unique values of an input array + * + * @property {Array} uniqueValues + * The unique values in the input array + * @property {Array} uniqueCounts + * The number of times each entry in uniqueValues occurs in input array. + * This has the same length as `uniqueValues` + * @property {Array} inds + * Indices into uniqueValues that would reproduce original input array + */ + +/** + * Compute unique value information for an array + * + * IMPORTANT: Note that values are considered unique + * if their string representations are unique. + * + * @param {Array} values + * @param {Array|undefined} uniqueValues + * Array of expected unique values. The uniqueValues property of the resulting UniqueInfo object will begin with + * these entries. Entries are included even if there are zero occurrences in the values array. Entries found in + * the values array that are not present in uniqueValues will be included at the end of the array in the + * UniqueInfo object. + * @return {UniqueInfo} + */ +function getUniqueInfo(values, uniqueValues) { + + // Initialize uniqueValues if not specified + if(uniqueValues === undefined || uniqueValues === null) { + uniqueValues = []; + } else { + // Shallow copy so append below doesn't alter input array + uniqueValues = uniqueValues.map(function(e) {return e;}); + } + + // Initialize Variables + var uniqueValueCounts = {}, + uniqueValueInds = {}, + inds = []; + + // Initialize uniqueValueCounts and + uniqueValues.forEach(function(uniqueVal, valInd) { + uniqueValueCounts[uniqueVal] = 0; + uniqueValueInds[uniqueVal] = valInd; + }); + + // Compute the necessary unique info in a single pass + for(var i = 0; i < values.length; i++) { + var item = values[i]; + var itemInd; + + if(uniqueValueCounts[item] === undefined) { + // This item has a previously unseen value + uniqueValueCounts[item] = 1; + itemInd = uniqueValues.push(item) - 1; + uniqueValueInds[item] = itemInd; + } else { + // Increment count for this item + uniqueValueCounts[item]++; + itemInd = uniqueValueInds[item]; + } + inds.push(itemInd); + } + + // Build UniqueInfo + var uniqueCounts = uniqueValues.map(function(v) { return uniqueValueCounts[v]; }); + + return { + uniqueValues: uniqueValues, + uniqueCounts: uniqueCounts, + inds: inds + }; +} + + +/** + * Validate the requested display order for the dimensions. + * If the display order is a permutation of 0 through dimensions.length - 1 then leave it alone. Otherwise, repalce + * the display order with the dimension order + * @param {Object} trace + */ +function validateDimensionDisplayInds(trace) { + var displayInds = trace.dimensions.filter(visible).map(function(dim) {return dim.displayindex;}); + if(!isRangePermutation(displayInds)) { + trace.dimensions.filter(visible).forEach(function(dim, i) { + dim.displayindex = i; + }); + } +} + + +/** + * Validate the requested display order for the dimensions. + * If the display order is a permutation of 0 through dimensions.length - 1 then leave it alone. Otherwise, repalce + * the display order with the dimension order + * @param {Object} dim + * @param {UniqueInfo} uniqueInfoDim + */ +function validateCategoryProperties(dim, uniqueInfoDim) { + + // Update categoryarray + dim.categoryarray = uniqueInfoDim.uniqueValues; + + // Handle ticktext + if(dim.ticktext === null || dim.ticktext === undefined) { + dim.ticktext = []; + } else { + // Shallow copy to avoid modifying input array + dim.ticktext = dim.ticktext.slice(); + } + + // Extend ticktext with elements from uniqueInfoDim.uniqueValues + for(var i = dim.ticktext.length; i < uniqueInfoDim.uniqueValues.length; i++) { + dim.ticktext.push(uniqueInfoDim.uniqueValues[i]); + } +} + +/** + * Determine whether an array contains a permutation of the integers from 0 to the array's length - 1 + * @param {Array} inds + * @return {boolean} + */ +function isRangePermutation(inds) { + var indsSpecified = new Array(inds.length); + + for(var i = 0; i < inds.length; i++) { + // Check for out of bounds + if(inds[i] < 0 || inds[i] >= inds.length) { + return false; + } + + // Check for collisions with already specified index + if(indsSpecified[inds[i]] !== undefined) { + return false; + } + + indsSpecified[inds[i]] = true; + } + + // Nothing out of bounds and no collisions. We have a permutation + return true; +} diff --git a/src/traces/parcats/defaults.js b/src/traces/parcats/defaults.js new file mode 100644 index 00000000000..47537f1519e --- /dev/null +++ b/src/traces/parcats/defaults.js @@ -0,0 +1,119 @@ +/** +* Copyright 2012-2018, 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 Lib = require('../../lib'); +var hasColorscale = require('../../components/colorscale/has_colorscale'); +var colorscaleDefaults = require('../../components/colorscale/defaults'); +var handleDomainDefaults = require('../../plots/domain').defaults; +var handleArrayContainerDefaults = require('../../plots/array_container_defaults'); + +var attributes = require('./attributes'); +var mergeLength = require('../parcoords/merge_length'); + +function handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce) { + + coerce('line.shape'); + var lineColor = coerce('line.color', layout.colorway[0]); + if(hasColorscale(traceIn, 'line') && Lib.isArrayOrTypedArray(lineColor)) { + if(lineColor.length) { + coerce('line.colorscale'); + colorscaleDefaults(traceIn, traceOut, layout, coerce, {prefix: 'line.', cLetter: 'c'}); + return lineColor.length; + } + else { + traceOut.line.color = defaultColor; + } + } + return Infinity; +} + +function dimensionDefaults(dimensionIn, dimensionOut) { + function coerce(attr, dflt) { + return Lib.coerce(dimensionIn, dimensionOut, attributes.dimensions, attr, dflt); + } + + var values = coerce('values'); + var visible = coerce('visible'); + if(!(values && values.length)) { + visible = dimensionOut.visible = false; + } + + if(visible) { + // Dimension level + coerce('label'); + coerce('displayindex', dimensionOut._index); + + // Category level + var arrayIn = dimensionIn.categoryarray; + var isValidArray = (Array.isArray(arrayIn) && arrayIn.length > 0); + + var orderDefault; + if(isValidArray) orderDefault = 'array'; + var order = coerce('categoryorder', orderDefault); + + // coerce 'categoryarray' only in array order case + if(order === 'array') { + coerce('categoryarray'); + coerce('ticktext'); + } else { + delete dimensionIn.categoryarray; + delete dimensionIn.ticktext; + } + + // cannot set 'categoryorder' to 'array' with an invalid 'categoryarray' + if(!isValidArray && order === 'array') { + dimensionOut.categoryorder = 'trace'; + } + } +} + +module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { + + function coerce(attr, dflt) { + return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); + } + + var dimensions = handleArrayContainerDefaults(traceIn, traceOut, { + name: 'dimensions', + handleItemDefaults: dimensionDefaults + }); + + var len = handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce); + + handleDomainDefaults(traceOut, layout, coerce); + + if(!Array.isArray(dimensions) || !dimensions.length) { + traceOut.visible = false; + } + + mergeLength(traceOut, dimensions, 'values', len); + + coerce('hoveron'); + coerce('arrangement'); + coerce('bundlecolors'); + coerce('sortpaths'); + coerce('counts'); + + var labelfontDflt = { + family: layout.font.family, + size: Math.round(layout.font.size), + color: layout.font.color + }; + + Lib.coerceFont(coerce, 'labelfont', labelfontDflt); + + var categoryfontDefault = { + family: layout.font.family, + size: Math.round(layout.font.size / 1.2), + color: layout.font.color + }; + + Lib.coerceFont(coerce, 'tickfont', categoryfontDefault); +}; diff --git a/src/traces/parcats/index.js b/src/traces/parcats/index.js new file mode 100644 index 00000000000..5496d3b23ff --- /dev/null +++ b/src/traces/parcats/index.js @@ -0,0 +1,33 @@ +/** +* Copyright 2012-2018, 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 Parcats = {}; + +Parcats.attributes = require('./attributes'); +Parcats.supplyDefaults = require('./defaults'); +Parcats.calc = require('./calc'); +Parcats.plot = require('./plot'); +Parcats.colorbar = { + container: 'line', + min: 'cmin', + max: 'cmax' +}; + +Parcats.moduleType = 'trace'; +Parcats.name = 'parcats'; +Parcats.basePlotModule = require('./base_plot'); +Parcats.categories = ['noOpacity']; +Parcats.meta = { + description: [ + 'Parallel categories diagram for multidimensional categorical data.' + ].join(' ') +}; + +module.exports = Parcats; diff --git a/src/traces/parcats/parcats.js b/src/traces/parcats/parcats.js new file mode 100644 index 00000000000..41b49adba10 --- /dev/null +++ b/src/traces/parcats/parcats.js @@ -0,0 +1,2081 @@ +/** +* Copyright 2012-2018, 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 Plotly = require('../../plot_api/plot_api'); +var Fx = require('../../components/fx'); +var Lib = require('../../lib'); +var Drawing = require('../../components/drawing'); +var tinycolor = require('tinycolor2'); +var svgTextUtils = require('../../lib/svg_text_utils'); + +function performPlot(parcatsModels, graphDiv, layout, svg) { + + var viewModels = parcatsModels.map(createParcatsViewModel.bind(0, graphDiv, layout)); + + // Get (potentially empty) parcatslayer selection with bound data to single element array + var layerSelection = svg.selectAll('g.parcatslayer').data([null]); + + // Initialize single parcatslayer group if it doesn't exist + layerSelection.enter() + .append('g') + .attr('class', 'parcatslayer') + .style('pointer-events', 'all'); + + // Bind data to children of layerSelection and get reference to traceSelection + var traceSelection = layerSelection + .selectAll('g.trace.parcats') + .data(viewModels, key); + + // Initialize group for each trace/dimensions + var traceEnter = traceSelection.enter() + .append('g') + .attr('class', 'trace parcats'); + + // Update properties for each trace + traceSelection + .attr('transform', function(d) { + return 'translate(' + d.x + ', ' + d.y + ')'; + }); + + // Initialize paths group + traceEnter + .append('g') + .attr('class', 'paths'); + + // Update paths transform + var pathsSelection = traceSelection + .select('g.paths'); + + // Get paths selection + var pathSelection = pathsSelection + .selectAll('path.path') + .data(function(d) { + return d.paths; + }, key); + + // Update existing path colors + pathSelection + .attr('fill', function(d) { + return d.model.color; + }); + + // Create paths + var pathSelectionEnter = pathSelection + .enter() + .append('path') + .attr('class', 'path') + .attr('stroke-opacity', 0) + .attr('fill', function(d) { + return d.model.color; + }) + .attr('fill-opacity', 0); + + stylePathsNoHover(pathSelectionEnter); + + // Set path geometry + pathSelection + .attr('d', function(d) { + return d.svgD; + }); + + // sort paths + if(!pathSelectionEnter.empty()) { + // Only sort paths if there has been a change. + // Otherwise paths are already sorted or a hover operation may be in progress + pathSelection.sort(compareRawColor); + } + + // Remove any old paths + pathSelection.exit().remove(); + + // Path hover + pathSelection + .on('mouseover', mouseoverPath) + .on('mouseout', mouseoutPath) + .on('click', clickPath); + + // Initialize dimensions group + traceEnter.append('g').attr('class', 'dimensions'); + + // Update dimensions transform + var dimensionsSelection = traceSelection + .select('g.dimensions'); + + // Get dimension selection + var dimensionSelection = dimensionsSelection + .selectAll('g.dimension') + .data(function(d) { + return d.dimensions; + }, key); + + // Create dimension groups + dimensionSelection.enter() + .append('g') + .attr('class', 'dimension'); + + // Update dimension group transforms + dimensionSelection.attr('transform', function(d) { + return 'translate(' + d.x + ', 0)'; + }); + + // Remove any old dimensions + dimensionSelection.exit().remove(); + + // Get category selection + var categorySelection = dimensionSelection + .selectAll('g.category') + .data(function(d) { + return d.categories; + }, key); + + // Initialize category groups + var categoryGroupEnterSelection = categorySelection + .enter() + .append('g') + .attr('class', 'category'); + + // Update category transforms + categorySelection + .attr('transform', function(d) { + return 'translate(0, ' + d.y + ')'; + }); + + + // Initialize rectangle + categoryGroupEnterSelection + .append('rect') + .attr('class', 'catrect') + .attr('pointer-events', 'none'); + + + // Update rectangle + categorySelection.select('rect.catrect') + .attr('fill', 'none') + .attr('width', function(d) { + return d.width; + }) + .attr('height', function(d) { + return d.height; + }); + + styleCategoriesNoHover(categoryGroupEnterSelection); + + // Initialize color band rects + var bandSelection = categorySelection + .selectAll('rect.bandrect') + .data( + /** @param {CategoryViewModel} catViewModel*/ + function(catViewModel) { + return catViewModel.bands; + }, key); + + // Raise all update bands to the top so that fading enter/exit bands will be behind + bandSelection.each(function() {Lib.raiseToTop(this);}); + + // Update band color + bandSelection + .attr('fill', function(d) { + return d.color; + }); + + var bandsSelectionEnter = bandSelection.enter() + .append('rect') + .attr('class', 'bandrect') + .attr('stroke-opacity', 0) + .attr('fill', function(d) { + return d.color; + }) + .attr('fill-opacity', 0); + + bandSelection + .attr('fill', function(d) { + return d.color; + }) + .attr('width', function(d) { + return d.width; + }) + .attr('height', function(d) { + return d.height; + }) + .attr('y', function(d) { + return d.y; + }) + .attr('cursor', + /** @param {CategoryBandViewModel} bandModel*/ + function(bandModel) { + if(bandModel.parcatsViewModel.arrangement === 'fixed') { + return 'default'; + } else if(bandModel.parcatsViewModel.arrangement === 'perpendicular') { + return 'ns-resize'; + } else { + return 'move'; + } + }); + + styleBandsNoHover(bandsSelectionEnter); + + bandSelection.exit().remove(); + + // Initialize category label + categoryGroupEnterSelection + .append('text') + .attr('class', 'catlabel') + .attr('pointer-events', 'none'); + + // Update category label + categorySelection.select('text.catlabel') + .attr('text-anchor', + function(d) { + if(catInRightDim(d)) { + // Place label to the right of category + return 'start'; + } else { + // Place label to the left of category + return 'end'; + } + }) + .attr('alignment-baseline', 'middle') + + .style('text-shadow', + 'rgb(255, 255, 255) -1px 1px 2px, ' + + 'rgb(255, 255, 255) 1px 1px 2px, ' + + 'rgb(255, 255, 255) 1px -1px 2px, ' + + 'rgb(255, 255, 255) -1px -1px 2px') + .style('fill', 'rgb(0, 0, 0)') + .attr('x', + function(d) { + if(catInRightDim(d)) { + // Place label to the right of category + return d.width + 5; + } else { + // Place label to the left of category + return -5; + } + }) + .attr('y', function(d) { + return d.height / 2; + }) + .text(function(d) { + return d.model.categoryLabel; + }) + .each( + /** @param {CategoryViewModel} catModel*/ + function(catModel) { + Drawing.font(d3.select(this), catModel.parcatsViewModel.categorylabelfont); + svgTextUtils.convertToTspans(d3.select(this), graphDiv); + }); + + // Initialize dimension label + categoryGroupEnterSelection + .append('text') + .attr('class', 'dimlabel'); + + // Update dimension label + categorySelection.select('text.dimlabel') + .attr('text-anchor', 'middle') + .attr('alignment-baseline', 'baseline') + .attr('cursor', + /** @param {CategoryViewModel} catModel*/ + function(catModel) { + if(catModel.parcatsViewModel.arrangement === 'fixed') { + return 'default'; + } else { + return 'ew-resize'; + } + }) + .attr('x', function(d) { + return d.width / 2; + }) + .attr('y', -5) + .text(function(d, i) { + if(i === 0) { + // Add dimension label above topmost category + return d.parcatsViewModel.model.dimensions[d.model.dimensionInd].dimensionLabel; + } else { + return null; + } + }) + .each( + /** @param {CategoryViewModel} catModel*/ + function(catModel) { + Drawing.font(d3.select(this), catModel.parcatsViewModel.labelfont); + }); + + // Category hover + // categorySelection.select('rect.catrect') + categorySelection.selectAll('rect.bandrect') + .on('mouseover', mouseoverCategoryBand) + .on('mouseout', mouseoutCategory); + + // Remove unused categories + categorySelection.exit().remove(); + + // Setup drag + dimensionSelection.call(d3.behavior.drag() + .origin(function(d) { + return {x: d.x, y: 0}; + }) + .on('dragstart', dragDimensionStart) + .on('drag', dragDimension) + .on('dragend', dragDimensionEnd)); + + + // Save off selections to view models + traceSelection.each(function(d) { + d.traceSelection = d3.select(this); + d.pathSelection = d3.select(this).selectAll('g.paths').selectAll('path.path'); + d.dimensionSelection = d3.select(this).selectAll('g.dimensions').selectAll('g.dimension'); + }); + + // Remove any orphan traces + traceSelection.exit().remove(); +} + +/** + * Create / update parcat traces + * + * @param {Object} graphDiv + * @param {Object} svg + * @param {Array.} parcatsModels + * @param {Layout} layout + */ +module.exports = function(graphDiv, svg, parcatsModels, layout) { + performPlot(parcatsModels, graphDiv, layout, svg); +}; + +/** + * Function the returns the key property of an object for use with as D3 join function + * @param d + */ +function key(d) { + return d.key; +} + + /** True if a category view model is in the right-most display dimension + * @param {CategoryViewModel} d */ +function catInRightDim(d) { + var numDims = d.parcatsViewModel.dimensions.length, + leftDimInd = d.parcatsViewModel.dimensions[numDims - 1].model.dimensionInd; + return d.model.dimensionInd === leftDimInd; +} + +/** + * @param {PathViewModel} a + * @param {PathViewModel} b + */ +function compareRawColor(a, b) { + if(a.model.rawColor > b.model.rawColor) { + return 1; + } else if(a.model.rawColor < b.model.rawColor) { + return -1; + } else { + return 0; + } +} + +/** + * Handle path mouseover + * @param {PathViewModel} d + */ +function mouseoverPath(d) { + + if(!d.parcatsViewModel.dragDimension) { + // We're not currently dragging + + if(d.parcatsViewModel.hoverinfoItems.indexOf('skip') === -1) { + // hoverinfo is not skip, so we at least style the paths and emit interaction events + + // Raise path to top + Lib.raiseToTop(this); + + stylePathsHover(d3.select(this)); + + // Emit hover event + var points = buildPointsArrayForPath(d); + d.parcatsViewModel.graphDiv.emit('plotly_hover', {points: points, event: d3.event}); + + // Handle hover label + if(d.parcatsViewModel.hoverinfoItems.indexOf('none') === -1) { + // hoverinfo is a combination of 'count' and 'probability' + + // Mouse + var hoverX = d3.mouse(this)[0]; + + // Label + var gd = d.parcatsViewModel.graphDiv; + var fullLayout = gd._fullLayout; + var rootBBox = fullLayout._paperdiv.node().getBoundingClientRect(); + var graphDivBBox = d.parcatsViewModel.graphDiv.getBoundingClientRect(); + + // Find path center in path coordinates + var pathCenterX, + pathCenterY, + dimInd; + + for(dimInd = 0; dimInd < (d.leftXs.length - 1); dimInd++) { + if(d.leftXs[dimInd] + d.dimWidths[dimInd] - 2 <= hoverX && hoverX <= d.leftXs[dimInd + 1] + 2) { + var leftDim = d.parcatsViewModel.dimensions[dimInd]; + var rightDim = d.parcatsViewModel.dimensions[dimInd + 1]; + pathCenterX = (leftDim.x + leftDim.width + rightDim.x) / 2; + pathCenterY = (d.topYs[dimInd] + d.topYs[dimInd + 1] + d.height) / 2; + break; + } + } + + // Find path center in root coordinates + var hoverCenterX = d.parcatsViewModel.x + pathCenterX; + var hoverCenterY = d.parcatsViewModel.y + pathCenterY; + + var textColor = tinycolor.mostReadable(d.model.color, ['black', 'white']); + + // Build hover text + var hovertextParts = []; + if(d.parcatsViewModel.hoverinfoItems.indexOf('count') !== -1) { + hovertextParts.push(['Count:', d.model.count].join(' ')); + } + if(d.parcatsViewModel.hoverinfoItems.indexOf('probability') !== -1) { + hovertextParts.push(['P:', (d.model.count / d.parcatsViewModel.model.count).toFixed(3)].join(' ')); + } + + var hovertext = hovertextParts.join('
'); + var mouseX = d3.mouse(gd)[0]; + + Fx.loneHover({ + x: hoverCenterX - rootBBox.left + graphDivBBox.left, + y: hoverCenterY - rootBBox.top + graphDivBBox.top, + text: hovertext, + color: d.model.color, + borderColor: 'black', + fontFamily: 'Monaco, "Courier New", monospace', + fontSize: 10, + fontColor: textColor, + idealAlign: mouseX < hoverCenterX ? 'right' : 'left' + }, { + container: fullLayout._hoverlayer.node(), + outerContainer: fullLayout._paper.node(), + gd: gd + }); + } + } + } +} + +/** + * Handle path mouseout + * @param {PathViewModel} d + */ +function mouseoutPath(d) { + if(!d.parcatsViewModel.dragDimension) { + // We're not currently dragging + stylePathsNoHover(d3.select(this)); + + // Remove and hover label + Fx.loneUnhover(d.parcatsViewModel.graphDiv._fullLayout._hoverlayer.node()); + + // Restore path order + d.parcatsViewModel.pathSelection.sort(compareRawColor); + + // Emit unhover event + if(d.parcatsViewModel.hoverinfoItems.indexOf('skip') === -1) { + var points = buildPointsArrayForPath(d); + d.parcatsViewModel.graphDiv.emit('plotly_unhover', {points: points, event: d3.event}); + } + } +} + +/** + * Build array of point objects for a path + * + * For use in click/hover events + * @param {PathViewModel} d + */ +function buildPointsArrayForPath(d) { + var points = []; + var curveNumber = getTraceIndex(d.parcatsViewModel); + + for(var i = 0; i < d.model.valueInds.length; i++) { + var pointNumber = d.model.valueInds[i]; + points.push({ + curveNumber: curveNumber, + pointNumber: pointNumber + }); + } + return points; +} + +/** + * Handle path click + * @param {PathViewModel} d + */ +function clickPath(d) { + if(d.parcatsViewModel.hoverinfoItems.indexOf('skip') === -1) { + // hoverinfo it's skip, so interaction events aren't disabled + var points = buildPointsArrayForPath(d); + d.parcatsViewModel.graphDiv.emit('plotly_click', {points: points, event: d3.event}); + } +} + +function stylePathsNoHover(pathSelection) { + pathSelection + .attr('fill', function(d) { + return d.model.color; + }) + .attr('fill-opacity', 0.6) + .attr('stroke', 'lightgray') + .attr('stroke-width', 0.2) + .attr('stroke-opacity', 1.0); +} + +function stylePathsHover(pathSelection) { + pathSelection + .attr('fill-opacity', 0.8) + .attr('stroke', function(d) { + return tinycolor.mostReadable(d.model.color, ['black', 'white']); + }) + .attr('stroke-width', 0.3); +} + +function styleCategoryHover(categorySelection) { + categorySelection + .select('rect.catrect') + .attr('stroke', 'black') + .attr('stroke-width', 2.5); +} + +function styleCategoriesNoHover(categorySelection) { + categorySelection + .select('rect.catrect') + .attr('stroke', 'black') + .attr('stroke-width', 1) + .attr('stroke-opacity', 1); +} + +function styleBandsHover(bandsSelection) { + bandsSelection + .attr('stroke', 'black') + .attr('stroke-width', 1.5); +} + +function styleBandsNoHover(bandsSelection) { + bandsSelection + .attr('stroke', 'black') + .attr('stroke-width', 0.2) + .attr('stroke-opacity', 1.0) + .attr('fill-opacity', 1.0); +} + +/** + * Return selection of all paths that pass through the specified category + * @param {CategoryBandViewModel} catBandViewModel + */ +function selectPathsThroughCategoryBandColor(catBandViewModel) { + + var allPaths = catBandViewModel.parcatsViewModel.pathSelection; + var dimInd = catBandViewModel.categoryViewModel.model.dimensionInd; + var catInd = catBandViewModel.categoryViewModel.model.categoryInd; + + return allPaths + .filter( + /** @param {PathViewModel} pathViewModel */ + function(pathViewModel) { + return pathViewModel.model.categoryInds[dimInd] === catInd && + pathViewModel.model.color === catBandViewModel.color; + }); +} + + +/** + * Perform hover styling for all paths that pass though the specified band element's category + * + * @param {HTMLElement} bandElement + * HTML element for band + * + */ +function styleForCategoryHovermode(bandElement) { + + // Get all bands in the current category + var bandSel = d3.select(bandElement.parentNode).selectAll('rect.bandrect'); + + // Raise and style paths + bandSel.each(function(bvm) { + var paths = selectPathsThroughCategoryBandColor(bvm); + stylePathsHover(paths); + paths.each(function() { + // Raise path to top + Lib.raiseToTop(this); + }); + }); + + // Style category + styleCategoryHover(d3.select(bandElement.parentNode)); +} + +/** + * Perform hover styling for all paths that pass though the category of the specified band element and share the + * same color + * + * @param {HTMLElement} bandElement + * HTML element for band + * + */ +function styleForColorHovermode(bandElement) { + var bandViewModel = d3.select(bandElement).datum(); + var catPaths = selectPathsThroughCategoryBandColor(bandViewModel); + stylePathsHover(catPaths); + catPaths.each(function() { + // Raise path to top + Lib.raiseToTop(this); + }); + + // Style category for drag + d3.select(bandElement.parentNode) + .selectAll('rect.bandrect') + .filter(function(b) {return b.color === bandViewModel.color;}) + .each(function() { + Lib.raiseToTop(this); + styleBandsHover(d3.select(this)); + }); +} + + +/** + * @param {HTMLElement} bandElement + * HTML element for band + * @param eventName + * Event name (plotly_hover or plotly_click) + * @param event + * Mouse Event + */ +function emitPointsEventCategoryHovermode(bandElement, eventName, event) { + // Get all bands in the current category + var bandViewModel = d3.select(bandElement).datum(); + var gd = bandViewModel.parcatsViewModel.graphDiv; + var bandSel = d3.select(bandElement.parentNode).selectAll('rect.bandrect'); + + var points = []; + bandSel.each(function(bvm) { + var paths = selectPathsThroughCategoryBandColor(bvm); + paths.each(function(pathViewModel) { + // Extend points array + Array.prototype.push.apply(points, buildPointsArrayForPath(pathViewModel)); + }); + }); + + gd.emit(eventName, {points: points, event: event}); +} + +/** + * @param {HTMLElement} bandElement + * HTML element for band + * @param eventName + * Event name (plotly_hover or plotly_click) + * @param event + * Mouse Event + */ +function emitPointsEventColorHovermode(bandElement, eventName, event) { + var bandViewModel = d3.select(bandElement).datum(); + var gd = bandViewModel.parcatsViewModel.graphDiv; + var paths = selectPathsThroughCategoryBandColor(bandViewModel); + + var points = []; + paths.each(function(pathViewModel) { + // Extend points array + Array.prototype.push.apply(points, buildPointsArrayForPath(pathViewModel)); + }); + + gd.emit(eventName, {points: points, event: event}); +} + +/** + * Create hover label for a band element's category (for use when hoveron === 'category') + * + * @param {ClientRect} rootBBox + * Client bounding box for root of figure + * @param {HTMLElement} bandElement + * HTML element for band + * + */ +function createHoverLabelForCategoryHovermode(rootBBox, bandElement) { + // Selections + var rectSelection = d3.select(bandElement.parentNode).select('rect.catrect'); + var rectBoundingBox = rectSelection.node().getBoundingClientRect(); + + // Models + /** @type {CategoryViewModel} */ + var catViewModel = rectSelection.datum(); + var parcatsViewModel = catViewModel.parcatsViewModel; + var dimensionModel = parcatsViewModel.model.dimensions[catViewModel.model.dimensionInd]; + + // Positions + var hoverCenterY = rectBoundingBox.top + rectBoundingBox.height / 2; + var hoverCenterX, + hoverLabelIdealAlign; + + if(parcatsViewModel.dimensions.length > 1 && + dimensionModel.displayInd === parcatsViewModel.dimensions.length - 1) { + + // right most dimension + hoverCenterX = rectBoundingBox.left; + hoverLabelIdealAlign = 'left'; + } else { + hoverCenterX = rectBoundingBox.left + rectBoundingBox.width; + hoverLabelIdealAlign = 'right'; + } + + // Hover label text + var hoverinfoParts = []; + if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('count') !== -1) { + hoverinfoParts.push(['Count:', catViewModel.model.count].join(' ')); + } + if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('probability') !== -1) { + hoverinfoParts.push([ + 'P(' + catViewModel.model.categoryLabel + '):', + (catViewModel.model.count / catViewModel.parcatsViewModel.model.count).toFixed(3)].join(' ')); + } + + var hovertext = hoverinfoParts.join('
'); + return { + x: hoverCenterX - rootBBox.left, + y: hoverCenterY - rootBBox.top, + text: hovertext, + color: 'lightgray', + borderColor: 'black', + fontFamily: 'Monaco, "Courier New", monospace', + fontSize: 12, + fontColor: 'black', + idealAlign: hoverLabelIdealAlign + }; +} + +/** + * Create hover label for a band element's category (for use when hoveron === 'category') + * + * @param {ClientRect} rootBBox + * Client bounding box for root of figure + * @param {HTMLElement} bandElement + * HTML element for band + * + */ +function createHoverLabelForDimensionHovermode(rootBBox, bandElement) { + + var allHoverlabels = []; + + d3.select(bandElement.parentNode.parentNode) + .selectAll('g.category') + .select('rect.catrect') + .each(function() { + var bandNode = this; + allHoverlabels.push(createHoverLabelForCategoryHovermode(rootBBox, bandNode)); + }); + + return allHoverlabels; +} + +/** + * Create hover labels for a band element's category (for use when hoveron === 'dimension') + * + * @param {ClientRect} rootBBox + * Client bounding box for root of figure + * @param {HTMLElement} bandElement + * HTML element for band + * + */ +function createHoverLabelForColorHovermode(rootBBox, bandElement) { + + var bandBoundingBox = bandElement.getBoundingClientRect(); + + // Models + /** @type {CategoryBandViewModel} */ + var bandViewModel = d3.select(bandElement).datum(); + var catViewModel = bandViewModel.categoryViewModel; + var parcatsViewModel = catViewModel.parcatsViewModel; + var dimensionModel = parcatsViewModel.model.dimensions[catViewModel.model.dimensionInd]; + + // positions + var hoverCenterY = bandBoundingBox.y + bandBoundingBox.height / 2; + + var hoverCenterX, + hoverLabelIdealAlign; + if(parcatsViewModel.dimensions.length > 1 && + dimensionModel.displayInd === parcatsViewModel.dimensions.length - 1) { + // right most dimension + hoverCenterX = bandBoundingBox.left; + hoverLabelIdealAlign = 'left'; + } else { + hoverCenterX = bandBoundingBox.left + bandBoundingBox.width; + hoverLabelIdealAlign = 'right'; + } + + // Labels + var catLabel = catViewModel.model.categoryLabel; + + // Counts + var totalCount = bandViewModel.parcatsViewModel.model.count; + + var bandColorCount = 0; + bandViewModel.categoryViewModel.bands.forEach(function(b) { + if(b.color === bandViewModel.color) { + bandColorCount += b.count; + } + }); + + var catCount = catViewModel.model.count; + + var colorCount = 0; + parcatsViewModel.pathSelection.each( + /** @param {PathViewModel} pathViewModel */ + function(pathViewModel) { + if(pathViewModel.model.color === bandViewModel.color) { + colorCount += pathViewModel.model.count; + } + }); + + // Hover label text + var hoverinfoParts = []; + if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('count') !== -1) { + hoverinfoParts.push(['Count:', bandColorCount].join(' ')); + } + if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('probability') !== -1) { + var pColorAndCatLable = 'P(color ∩ ' + catLabel + '): '; + var pColorAndCatValue = (bandColorCount / totalCount).toFixed(3); + var pColorAndCatRow = pColorAndCatLable + pColorAndCatValue; + hoverinfoParts.push(pColorAndCatRow); + + var pCatGivenColorLabel = 'P(' + catLabel + ' | color): '; + var pCatGivenColorValue = (bandColorCount / colorCount).toFixed(3); + var pCatGivenColorRow = pCatGivenColorLabel + pCatGivenColorValue; + hoverinfoParts.push(pCatGivenColorRow); + + var pColorGivenCatLabel = 'P(color | ' + catLabel + '): '; + var pColorGivenCatValue = (bandColorCount / catCount).toFixed(3); + var pColorGivenCatRow = pColorGivenCatLabel + pColorGivenCatValue; + hoverinfoParts.push(pColorGivenCatRow); + } + + var hovertext = hoverinfoParts.join('
'); + + // Compute text color + var textColor = tinycolor.mostReadable(bandViewModel.color, ['black', 'white']); + + return { + x: hoverCenterX - rootBBox.left, + y: hoverCenterY - rootBBox.top, + // name: 'NAME', + text: hovertext, + color: bandViewModel.color, + borderColor: 'black', + fontFamily: 'Monaco, "Courier New", monospace', + fontColor: textColor, + fontSize: 10, + idealAlign: hoverLabelIdealAlign + }; +} + +/** + * Handle dimension mouseover + * @param {CategoryBandViewModel} bandViewModel + */ +function mouseoverCategoryBand(bandViewModel) { + if(!bandViewModel.parcatsViewModel.dragDimension) { + // We're not currently dragging + + if(bandViewModel.parcatsViewModel.hoverinfoItems.indexOf('skip') === -1) { + // hoverinfo is not skip, so we at least style the bands and emit interaction events + + // Mouse + var mouseY = d3.mouse(this)[1]; + if(mouseY < -1) { + // Hover is above above the category rectangle (probably the dimension title text) + return; + } + + var gd = bandViewModel.parcatsViewModel.graphDiv; + var fullLayout = gd._fullLayout; + var rootBBox = fullLayout._paperdiv.node().getBoundingClientRect(); + var hoveron = bandViewModel.parcatsViewModel.hoveron; + + /** @type {HTMLElement} */ + var bandElement = this; + + // Handle style and events + if(hoveron === 'color') { + styleForColorHovermode(bandElement); + emitPointsEventColorHovermode(bandElement, 'plotly_hover', d3.event); + } else { + styleForCategoryHovermode(bandElement); + emitPointsEventCategoryHovermode(bandElement, 'plotly_hover', d3.event); + } + + // Handle hover label + if(bandViewModel.parcatsViewModel.hoverinfoItems.indexOf('none') === -1) { + var hoverItems; + if(hoveron === 'category') { + hoverItems = createHoverLabelForCategoryHovermode(rootBBox, bandElement); + } else if(hoveron === 'color') { + hoverItems = createHoverLabelForColorHovermode(rootBBox, bandElement); + } else if(hoveron === 'dimension') { + hoverItems = createHoverLabelForDimensionHovermode(rootBBox, bandElement); + } + + if(hoverItems) { + Fx.multiHovers(hoverItems, { + container: fullLayout._hoverlayer.node(), + outerContainer: fullLayout._paper.node(), + gd: gd + }); + } + } + } + } +} + + +/** + * Handle dimension mouseover + * @param {CategoryBandViewModel} bandViewModel + */ +function mouseoutCategory(bandViewModel) { + + var parcatsViewModel = bandViewModel.parcatsViewModel; + + if(!parcatsViewModel.dragDimension) { + // We're not dragging anything + + // Reset unhovered styles + stylePathsNoHover(parcatsViewModel.pathSelection); + styleCategoriesNoHover(parcatsViewModel.dimensionSelection.selectAll('g.category')); + styleBandsNoHover(parcatsViewModel.dimensionSelection.selectAll('g.category').selectAll('rect.bandrect')); + + // Remove hover label + Fx.loneUnhover(parcatsViewModel.graphDiv._fullLayout._hoverlayer.node()); + + // Restore path order + parcatsViewModel.pathSelection.sort(compareRawColor); + + // Emit unhover event + if(parcatsViewModel.hoverinfoItems.indexOf('skip') === -1) { + + var hoveron = bandViewModel.parcatsViewModel.hoveron; + var bandElement = this; + + // Handle style and events + if(hoveron === 'color') { + emitPointsEventColorHovermode(bandElement, 'plotly_unhover', d3.event); + } else { + emitPointsEventCategoryHovermode(bandElement, 'plotly_unhover', d3.event); + } + } + } +} + + +/** + * Handle dimension drag start + * @param {DimensionViewModel} d + */ +function dragDimensionStart(d) { + + // Check if dragging is supported + if(d.parcatsViewModel.arrangement === 'fixed') { + return; + } + + // Save off initial drag indexes for dimension + d.dragDimensionDisplayInd = d.model.displayInd; + d.initialDragDimensionDisplayInds = d.parcatsViewModel.model.dimensions.map(function(d) {return d.displayInd;}); + d.dragHasMoved = false; + + // Check for category hit + d.dragCategoryDisplayInd = null; + d3.select(this) + .selectAll('g.category') + .select('rect.catrect') + .each( + /** @param {CategoryViewModel} catViewModel */ + function(catViewModel) { + var catMouseX = d3.mouse(this)[0]; + var catMouseY = d3.mouse(this)[1]; + + + if(-2 <= catMouseX && catMouseX <= catViewModel.width + 2 && + -2 <= catMouseY && catMouseY <= catViewModel.height + 2) { + + // Save off initial drag indexes for categories + d.dragCategoryDisplayInd = catViewModel.model.displayInd; + d.initialDragCategoryDisplayInds = d.model.categories.map(function(c) { + return c.displayInd; + }); + + // Initialize categories dragY to be the current y position + catViewModel.model.dragY = catViewModel.y; + + // Raise category + Lib.raiseToTop(this.parentNode); + + // Get band element + d3.select(this.parentNode) + .selectAll('rect.bandrect') + /** @param {CategoryBandViewModel} bandViewModel */ + .each(function(bandViewModel) { + if(bandViewModel.y < catMouseY && catMouseY <= bandViewModel.y + bandViewModel.height) { + d.potentialClickBand = this; + } + }); + } + }); + + // Update toplevel drag dimension + d.parcatsViewModel.dragDimension = d; + + // Remove hover label if any + Fx.loneUnhover(d.parcatsViewModel.graphDiv._fullLayout._hoverlayer.node()); +} + +/** + * Handle dimension drag + * @param {DimensionViewModel} d + */ +function dragDimension(d) { + + // Check if dragging is supported + if(d.parcatsViewModel.arrangement === 'fixed') { + return; + } + + d.dragHasMoved = true; + + if(d.dragDimensionDisplayInd === null) { + return; + } + + var dragDimInd = d.dragDimensionDisplayInd, + prevDimInd = dragDimInd - 1, + nextDimInd = dragDimInd + 1; + + var dragDimension = d.parcatsViewModel + .dimensions[dragDimInd]; + + // Update category + if(d.dragCategoryDisplayInd !== null) { + + var dragCategory = dragDimension.categories[d.dragCategoryDisplayInd]; + + // Update dragY by dy + dragCategory.model.dragY += d3.event.dy; + var categoryY = dragCategory.model.dragY; + + // Check for category drag swaps + var catDisplayInd = dragCategory.model.displayInd; + var dimCategoryViews = dragDimension.categories; + + var catAbove = dimCategoryViews[catDisplayInd - 1]; + var catBelow = dimCategoryViews[catDisplayInd + 1]; + + // Check for overlap above + if(catAbove !== undefined) { + + if(categoryY < (catAbove.y + catAbove.height / 2.0)) { + + // Swap display inds + dragCategory.model.displayInd = catAbove.model.displayInd; + catAbove.model.displayInd = catDisplayInd; + } + } + + if(catBelow !== undefined) { + + if((categoryY + dragCategory.height) > (catBelow.y + catBelow.height / 2.0)) { + + // Swap display inds + dragCategory.model.displayInd = catBelow.model.displayInd; + catBelow.model.displayInd = catDisplayInd; + } + } + + // Update category drag display index + d.dragCategoryDisplayInd = dragCategory.model.displayInd; + } + + // Update dimension position + if(d.dragCategoryDisplayInd === null || d.parcatsViewModel.arrangement === 'freeform') { + dragDimension.model.dragX = d3.event.x; + + // Check for dimension swaps + var prevDimension = d.parcatsViewModel.dimensions[prevDimInd]; + var nextDimension = d.parcatsViewModel.dimensions[nextDimInd]; + + if(prevDimension !== undefined) { + if(dragDimension.model.dragX < (prevDimension.x + prevDimension.width)) { + + // Swap display inds + dragDimension.model.displayInd = prevDimension.model.displayInd; + prevDimension.model.displayInd = dragDimInd; + } + } + + if(nextDimension !== undefined) { + if((dragDimension.model.dragX + dragDimension.width) > nextDimension.x) { + + // Swap display inds + dragDimension.model.displayInd = nextDimension.model.displayInd; + nextDimension.model.displayInd = d.dragDimensionDisplayInd; + } + } + + // Update drag display index + d.dragDimensionDisplayInd = dragDimension.model.displayInd; + } + + // Update view models + updateDimensionViewModels(d.parcatsViewModel); + updatePathViewModels(d.parcatsViewModel); + + // Update svg geometry + updateSvgCategories(d.parcatsViewModel); + updateSvgPaths(d.parcatsViewModel); +} + + +/** + * Handle dimension drag end + * @param {DimensionViewModel} d + */ +function dragDimensionEnd(d) { + + // Check if dragging is supported + if(d.parcatsViewModel.arrangement === 'fixed') { + return; + } + + if(d.dragDimensionDisplayInd === null) { + return; + } + + d3.select(this).selectAll('text').attr('font-weight', 'normal'); + + // Compute restyle command + // ----------------------- + var restyleData = {}; + var traceInd = getTraceIndex(d.parcatsViewModel); + + // ### Handle dimension reordering ### + var finalDragDimensionDisplayInds = d.parcatsViewModel.model.dimensions.map(function(d) {return d.displayInd;}); + var anyDimsReordered = d.initialDragDimensionDisplayInds.some(function(initDimDisplay, dimInd) { + return initDimDisplay !== finalDragDimensionDisplayInds[dimInd]; + }); + + if(anyDimsReordered) { + finalDragDimensionDisplayInds.forEach(function(finalDimDisplay, dimInd) { + var containerInd = d.parcatsViewModel.model.dimensions[dimInd].containerInd; + restyleData['dimensions[' + containerInd + '].displayindex'] = finalDimDisplay; + }); + } + + // ### Handle category reordering ### + var anyCatsReordered = false; + if(d.dragCategoryDisplayInd !== null) { + var finalDragCategoryDisplayInds = d.model.categories.map(function(c) { + return c.displayInd; + }); + + anyCatsReordered = d.initialDragCategoryDisplayInds.some(function(initCatDisplay, catInd) { + return initCatDisplay !== finalDragCategoryDisplayInds[catInd]; + }); + + if(anyCatsReordered) { + + // Sort a shallow copy of the category models by display index + var sortedCategoryModels = d.model.categories.slice().sort( + function(a, b) { return a.displayInd - b.displayInd; }); + + // Get new categoryarray and ticktext values + var newCategoryArray = sortedCategoryModels.map(function(v) { return v.categoryValue; }); + var newCategoryLabels = sortedCategoryModels.map(function(v) { return v.categoryLabel; }); + + restyleData['dimensions[' + d.model.containerInd + '].categoryarray'] = [newCategoryArray]; + restyleData['dimensions[' + d.model.containerInd + '].ticktext'] = [newCategoryLabels]; + restyleData['dimensions[' + d.model.containerInd + '].categoryorder'] = 'array'; + } + } + + // Handle potential click event + // ---------------------------- + if(d.parcatsViewModel.hoverinfoItems.indexOf('skip') === -1) { + if(!d.dragHasMoved && d.potentialClickBand) { + if(d.parcatsViewModel.hoveron === 'color') { + emitPointsEventColorHovermode(d.potentialClickBand, 'plotly_click', d3.event.sourceEvent); + } else { + emitPointsEventCategoryHovermode(d.potentialClickBand, 'plotly_click', d3.event.sourceEvent); + } + } + } + + // Nullify drag states + // ------------------- + d.model.dragX = null; + if(d.dragCategoryDisplayInd !== null) { + var dragCategory = d.parcatsViewModel + .dimensions[d.dragDimensionDisplayInd] + .categories[d.dragCategoryDisplayInd]; + + dragCategory.model.dragY = null; + d.dragCategoryDisplayInd = null; + } + + d.dragDimensionDisplayInd = null; + d.parcatsViewModel.dragDimension = null; + d.dragHasMoved = null; + d.potentialClickBand = null; + + // Update view models + // ------------------ + updateDimensionViewModels(d.parcatsViewModel); + updatePathViewModels(d.parcatsViewModel); + + // Perform transition + // ------------------ + var transition = d3.transition() + .duration(300) + .ease('cubic-in-out'); + + transition + .each(function() { + updateSvgCategories(d.parcatsViewModel, true); + updateSvgPaths(d.parcatsViewModel, true); + }) + .each('end', function() { + if(anyDimsReordered || anyCatsReordered) { + // Perform restyle if the order of categories or dimensions changed + Plotly.restyle(d.parcatsViewModel.graphDiv, restyleData, [traceInd]); + } + }); +} + +/** + * + * @param {ParcatsViewModel} parcatsViewModel + */ +function getTraceIndex(parcatsViewModel) { + var traceInd; + var allTraces = parcatsViewModel.graphDiv._fullData; + for(var i = 0; i < allTraces.length; i++) { + if(parcatsViewModel.key === allTraces[i].uid) { + traceInd = i; + break; + } + } + return traceInd; +} + +/** Update the svg paths for view model + * @param {ParcatsViewModel} parcatsViewModel + * @param {boolean} hasTransition Whether to update element with transition + */ +function updateSvgPaths(parcatsViewModel, hasTransition) { + + if(hasTransition === undefined) { + hasTransition = false; + } + + function transition(selection) { + return hasTransition ? selection.transition() : selection; + } + + // Update binding + parcatsViewModel.pathSelection.data(function(d) { + return d.paths; + }, key); + + // Update paths + transition(parcatsViewModel.pathSelection).attr('d', function(d) { + return d.svgD; + }); +} + +/** Update the svg paths for view model + * @param {ParcatsViewModel} parcatsViewModel + * @param {boolean} hasTransition Whether to update element with transition + */ +function updateSvgCategories(parcatsViewModel, hasTransition) { + + if(hasTransition === undefined) { + hasTransition = false; + } + + function transition(selection) { + return hasTransition ? selection.transition() : selection; + } + + // Update binding + parcatsViewModel.dimensionSelection + .data(function(d) { + return d.dimensions;}, key); + + var categorySelection = parcatsViewModel.dimensionSelection + .selectAll('g.category') + .data(function(d) {return d.categories;}, key); + + // Update dimension position + transition(parcatsViewModel.dimensionSelection) + .attr('transform', function(d) { + return 'translate(' + d.x + ', 0)'; + }); + + // Update category position + transition(categorySelection) + .attr('transform', function(d) { + return 'translate(0, ' + d.y + ')'; + }); + + var dimLabelSelection = categorySelection.select('.dimlabel'); + + // ### Update dimension label + // Only the top-most display category should have the dimension label + dimLabelSelection + .text(function(d, i) { + if(i === 0) { + // Add dimension label above topmost category + return d.parcatsViewModel.model.dimensions[d.model.dimensionInd].dimensionLabel; + } else { + return null; + } + }); + + // Update category label + // Categories in the right-most display dimension have their labels on + // the right, all others on the left + var catLabelSelection = categorySelection.select('.catlabel'); + catLabelSelection + .attr('text-anchor', + function(d) { + if(catInRightDim(d)) { + // Place label to the right of category + return 'start'; + } else { + // Place label to the left of category + return 'end'; + } + }) + .attr('x', + function(d) { + if(catInRightDim(d)) { + // Place label to the right of category + return d.width + 5; + } else { + // Place label to the left of category + return -5; + } + }) + .each(function(d) { + // Update attriubutes of elements + var newX; + var newAnchor; + if(catInRightDim(d)) { + // Place label to the right of category + newX = d.width + 5; + newAnchor = 'start'; + } else { + // Place label to the left of category + newX = -5; + newAnchor = 'end'; + } + d3.select(this) + .selectAll('tspan') + .attr('x', newX) + .attr('text-anchor', newAnchor); + }); + + // Update bands + // Initialize color band rects + var bandSelection = categorySelection + .selectAll('rect.bandrect') + .data( + /** @param {CategoryViewModel} catViewModel*/ + function(catViewModel) { + return catViewModel.bands; + }, key); + + var bandsSelectionEnter = bandSelection.enter() + .append('rect') + .attr('class', 'bandrect') + .attr('cursor', 'move') + .attr('stroke-opacity', 0) + .attr('fill', function(d) { + return d.color; + }) + .attr('fill-opacity', 0); + + bandSelection + .attr('fill', function(d) { + return d.color; + }) + .attr('width', function(d) { + return d.width; + }) + .attr('height', function(d) { + return d.height; + }) + .attr('y', function(d) { + return d.y; + }); + + styleBandsNoHover(bandsSelectionEnter); + + // Raise bands to the top + bandSelection.each(function() {Lib.raiseToTop(this);}); + + // Remove unused bands + bandSelection.exit().remove(); +} + +/** + * Create a ParcatsViewModel traces + * @param {Object} graphDiv + * Top-level graph div element + * @param {Layout} layout + * SVG layout object + * @param {Array.} wrappedParcatsModel + * Wrapped ParcatsModel for this trace + * @return {ParcatsViewModel} + */ +function createParcatsViewModel(graphDiv, layout, wrappedParcatsModel) { + // Unwrap model + var parcatsModel = wrappedParcatsModel[0]; + + // Compute margin + var margin = layout.margin || {l: 80, r: 80, t: 100, b: 80}; + + // Compute pixel position/extents + var trace = parcatsModel.trace, + domain = trace.domain, + figureWidth = layout.width, + figureHeight = layout.height, + traceWidth = Math.floor(figureWidth * (domain.x[1] - domain.x[0])), + traceHeight = Math.floor(figureHeight * (domain.y[1] - domain.y[0])), + traceX = domain.x[0] * figureWidth + margin.l, + traceY = layout.height - domain.y[1] * layout.height + margin.t; + + // Handle path shape + // ----------------- + var pathShape = trace.line.shape; + + // Handle hover info + // ----------------- + var hoverinfoItems; + if(trace.hoverinfo === 'all') { + hoverinfoItems = ['count', 'probability']; + } else { + hoverinfoItems = trace.hoverinfo.split('+'); + } + + // Construct parcatsViewModel + // -------------------------- + var parcatsViewModel = { + key: trace.uid, + model: parcatsModel, + x: traceX, + y: traceY, + width: traceWidth, + height: traceHeight, + hoveron: trace.hoveron, + hoverinfoItems: hoverinfoItems, + arrangement: trace.arrangement, + bundlecolors: trace.bundlecolors, + sortpaths: trace.sortpaths, + labelfont: trace.labelfont, + categorylabelfont: trace.tickfont, + pathShape: pathShape, + dragDimension: null, + margin: margin, + paths: [], + dimensions: [], + graphDiv: graphDiv, + traceSelection: null, + pathSelection: null, + dimensionSelection: null + }; + + // Update dimension view models if we have at least 1 dimension + if(parcatsModel.dimensions) { + updateDimensionViewModels(parcatsViewModel); + + // Update path view models if we have at least 2 dimensions + updatePathViewModels(parcatsViewModel); + } + // Inside a categories view model + return parcatsViewModel; +} + +/** + * Build the SVG string to represents a parallel categories path + * @param {Array.} leftXPositions + * Array of the x positions of the left edge of each dimension (in display order) + * @param {Array.} pathYs + * Array of the y positions of the top of the path at each dimension (in display order) + * @param {Array.} dimWidths + * Array of the widths of each dimension in display order + * @param {Number} pathHeight + * The height of the path in pixels + * @param {Number} curvature + * The curvature factor for the path. 0 results in a straight line and values greater than zero result in curved paths + * @return {string} + */ +function buildSvgPath(leftXPositions, pathYs, dimWidths, pathHeight, curvature) { + + // Compute the x midpoint of each path segment + var xRefPoints1 = [], + xRefPoints2 = [], + refInterpolator, + d; + + for(d = 0; d < dimWidths.length - 1; d++) { + refInterpolator = d3.interpolateNumber(dimWidths[d] + leftXPositions[d], leftXPositions[d + 1]); + xRefPoints1.push(refInterpolator(curvature)); + xRefPoints2.push(refInterpolator(1 - curvature)); + } + + // Move to top of path on left edge of left-most category + var svgD = 'M ' + leftXPositions[0] + ',' + pathYs[0]; + + // Horizontal line to right edge + svgD += 'l' + dimWidths[0] + ',0 '; + + // Horizontal line to right edge + for(d = 1; d < dimWidths.length; d++) { + + // Curve to left edge of category + svgD += 'C' + xRefPoints1[d - 1] + ',' + pathYs[d - 1] + + ' ' + xRefPoints2[d - 1] + ',' + pathYs[d] + + ' ' + leftXPositions[d] + ',' + pathYs[d]; + + // svgD += 'L' + leftXPositions[d] + ',' + pathYs[d]; + + // Horizontal line to right edge + svgD += 'l' + dimWidths[d] + ',0 '; + } + + // Line down + svgD += 'l' + '0,' + pathHeight + ' '; + + // Line to left edge of right-most category + svgD += 'l -' + dimWidths[dimWidths.length - 1] + ',0 '; + + for(d = dimWidths.length - 2; d >= 0; d--) { + + // Curve to right edge of category + svgD += 'C' + xRefPoints2[d] + ',' + (pathYs[d + 1] + pathHeight) + + ' ' + xRefPoints1[d] + ',' + (pathYs[d] + pathHeight) + + ' ' + (leftXPositions[d] + dimWidths[d]) + ',' + (pathYs[d] + pathHeight); + + // svgD += 'L' + (leftXPositions[d] + dimWidths[d]) + ',' + (pathYs[d] + pathHeight); + + // Horizontal line to right edge + svgD += 'l-' + dimWidths[d] + ',0 '; + } + + // Close path + svgD += 'Z'; + return svgD; +} + +/** + * Update the path view models based on the dimension view models in a ParcatsViewModel + * + * @param {ParcatsViewModel} parcatsViewModel + * View model for trace + */ +function updatePathViewModels(parcatsViewModel) { + + // Initialize an array of the y position of the top of the next path to be added to each category. + // + // nextYPositions[d][c] is the y position of the next path through category with index c of dimension with index d + var dimensionViewModels = parcatsViewModel.dimensions; + var parcatsModel = parcatsViewModel.model; + var nextYPositions = dimensionViewModels.map( + function(d) { + return d.categories.map( + function(c) { + return c.y; + }); + }); + + // Array from category index to category display index for each true dimension index + var catToDisplayIndPerDim = parcatsViewModel.model.dimensions.map( + function(d) { + return d.categories.map(function(c) {return c.displayInd;}); + }); + + // Array from true dimension index to dimension display index + var dimToDisplayInd = parcatsViewModel.model.dimensions.map(function(d) {return d.displayInd;}); + var displayToDimInd = parcatsViewModel.dimensions.map(function(d) {return d.model.dimensionInd;}); + + // Array of the x position of the left edge of the rectangles for each dimension + var leftXPositions = dimensionViewModels.map( + function(d) { + return d.x; + }); + + // Compute dimension widths + var dimWidths = dimensionViewModels.map(function(d) {return d.width;}); + + // Build sorted Array of PathModel objects + var pathModels = []; + for(var p in parcatsModel.paths) { + if(parcatsModel.paths.hasOwnProperty(p)) { + pathModels.push(parcatsModel.paths[p]); + } + } + + // Compute category display inds to use for sorting paths + function pathDisplayCategoryInds(pathModel) { + var dimensionInds = pathModel.categoryInds.map(function(catInd, dimInd) {return catToDisplayIndPerDim[dimInd][catInd];}); + var displayInds = displayToDimInd.map(function(dimInd) { + return dimensionInds[dimInd]; + }); + return displayInds; + } + + // Sort in ascending order by display index array + pathModels.sort(function(v1, v2) { + + // Build display inds for each path + var sortArray1 = pathDisplayCategoryInds(v1); + var sortArray2 = pathDisplayCategoryInds(v2); + + // Handle path sort order + if(parcatsViewModel.sortpaths === 'backward') { + sortArray1.reverse(); + sortArray2.reverse(); + } + + // Append the first value index of the path to break ties + sortArray1.push(v1.valueInds[0]); + sortArray2.push(v2.valueInds[0]); + + // Handle color bundling + if(parcatsViewModel.bundlecolors) { + // Prepend sort array with the raw color value + sortArray1.unshift(v1.rawColor); + sortArray2.unshift(v2.rawColor); + } + + // colors equal, sort by display categories + if(sortArray1 < sortArray2) { + return -1; + } + if(sortArray1 > sortArray2) { + return 1; + } + + return 0; + }); + + // Create path models + var pathViewModels = new Array(pathModels.length), + totalCount = dimensionViewModels[0].model.count, + totalHeight = dimensionViewModels[0].categories + .map(function(c) { + return c.height;}).reduce( + function(v1, v2) {return v1 + v2;}); + + + for(var pathNumber = 0; pathNumber < pathModels.length; pathNumber++) { + var pathModel = pathModels[pathNumber]; + + var pathHeight; + if(totalCount > 0) { + pathHeight = totalHeight * (pathModel.count / totalCount); + } else { + pathHeight = 0; + } + + // Build path y coords + var pathYs = new Array(nextYPositions.length); + for(var d = 0; d < pathModel.categoryInds.length; d++) { + var catInd = pathModel.categoryInds[d]; + var catDisplayInd = catToDisplayIndPerDim[d][catInd]; + var dimDisplayInd = dimToDisplayInd[d]; + + // Update next y position + pathYs[dimDisplayInd] = nextYPositions[dimDisplayInd][catDisplayInd]; + nextYPositions[dimDisplayInd][catDisplayInd] += pathHeight; + + // Update category color information + var catViewModle = parcatsViewModel.dimensions[dimDisplayInd].categories[catDisplayInd]; + var numBands = catViewModle.bands.length; + var lastCatBand = catViewModle.bands[numBands - 1]; + + if(lastCatBand === undefined || pathModel.rawColor !== lastCatBand.rawColor) { + // Create a new band + var bandY = lastCatBand === undefined ? 0 : lastCatBand.y + lastCatBand.height; + catViewModle.bands.push({ + key: bandY, + color: pathModel.color, + rawColor: pathModel.rawColor, + height: pathHeight, + width: catViewModle.width, + count: pathModel.count, + y: bandY, + categoryViewModel: catViewModle, + parcatsViewModel: parcatsViewModel + }); + } else { + // Extend current band + var currentBand = catViewModle.bands[numBands - 1]; + currentBand.height += pathHeight; + currentBand.count += pathModel.count; + } + } + + // build svg path + var svgD; + if(parcatsViewModel.pathShape === 'hspline') { + svgD = buildSvgPath(leftXPositions, pathYs, dimWidths, pathHeight, 0.5); + } else { + svgD = buildSvgPath(leftXPositions, pathYs, dimWidths, pathHeight, 0); + } + + pathViewModels[pathNumber] = { + key: pathModel.valueInds[0], + model: pathModel, + height: pathHeight, + leftXs: leftXPositions, + topYs: pathYs, + dimWidths: dimWidths, + svgD: svgD, + parcatsViewModel: parcatsViewModel + }; + } + + parcatsViewModel.paths = pathViewModels; + + // * @property key + // * Unique key for this model + // * @property {PathModel} model + // * Source path model + // * @property {Number} height + // * Height of this path (pixels) + // * @property {String} svgD + // * SVG path "d" attribute string +} + +/** + * Update the dimension view models based on the dimension models in a ParcatsViewModel + * + * @param {ParcatsViewModel} parcatsViewModel + * View model for trace + */ +function updateDimensionViewModels(parcatsViewModel) { + + // Compute dimension ordering + var dimensionsIndInfo = parcatsViewModel.model.dimensions.map(function(d) { + return {displayInd: d.displayInd, dimensionInd: d.dimensionInd}; + }); + + dimensionsIndInfo.sort(function(a, b) { + return a.displayInd - b.displayInd; + }); + + var dimensions = []; + for(var displayInd in dimensionsIndInfo) { + var dimensionInd = dimensionsIndInfo[displayInd].dimensionInd; + var dimModel = parcatsViewModel.model.dimensions[dimensionInd]; + dimensions.push(createDimensionViewModel(parcatsViewModel, dimModel)); + } + + parcatsViewModel.dimensions = dimensions; +} + +/** + * Create a parcats DimensionViewModel + * + * @param {ParcatsViewModel} parcatsViewModel + * View model for trace + * @param {DimensionModel} dimensionModel + * @return {DimensionViewModel} + */ +function createDimensionViewModel(parcatsViewModel, dimensionModel) { + + // Compute dimension x position + var categoryLabelPad = 40, + dimWidth = 16, + numDimensions = parcatsViewModel.model.dimensions.length, + displayInd = dimensionModel.displayInd; + + // Compute x coordinate values + var dimDx, + dimX0, + dimX; + + if(numDimensions > 1) { + dimDx = (parcatsViewModel.width - 2 * categoryLabelPad - dimWidth) / (numDimensions - 1); + } else { + dimDx = 0; + } + dimX0 = categoryLabelPad; + dimX = dimX0 + dimDx * displayInd; + + // Compute categories + var categories = [], + maxCats = parcatsViewModel.model.maxCats, + numCats = dimensionModel.categories.length, + catSpacing = 8, + totalCount = dimensionModel.count, + totalHeight = parcatsViewModel.height - catSpacing * (maxCats - 1), + nextCatHeight, + nextCatModel, + nextCat, + catInd, + catDisplayInd; + + // Compute starting Y offset + var nextCatY = (maxCats - numCats) * catSpacing / 2.0; + + // Compute category ordering + var categoryIndInfo = dimensionModel.categories.map(function(c) { + return {displayInd: c.displayInd, categoryInd: c.categoryInd}; + }); + + categoryIndInfo.sort(function(a, b) { + return a.displayInd - b.displayInd; + }); + + for(catDisplayInd = 0; catDisplayInd < numCats; catDisplayInd++) { + catInd = categoryIndInfo[catDisplayInd].categoryInd; + nextCatModel = dimensionModel.categories[catInd]; + + if(totalCount > 0) { + nextCatHeight = (nextCatModel.count / totalCount) * totalHeight; + } else { + nextCatHeight = 0; + } + + nextCat = { + key: nextCatModel.valueInds[0], + model: nextCatModel, + width: dimWidth, + height: nextCatHeight, + y: nextCatModel.dragY !== null ? nextCatModel.dragY : nextCatY, + bands: [], + parcatsViewModel: parcatsViewModel + }; + + nextCatY = nextCatY + nextCatHeight + catSpacing; + categories.push(nextCat); + } + + return { + key: dimensionModel.dimensionInd, + x: dimensionModel.dragX !== null ? dimensionModel.dragX : dimX, + y: 0, + width: dimWidth, + model: dimensionModel, + categories: categories, + parcatsViewModel: parcatsViewModel, + dragCategoryDisplayInd: null, + dragDimensionDisplayInd: null, + initialDragDimensionDisplayInds: null, + initialDragCategoryDisplayInds: null, + dragHasMoved: null, + potentialClickBand: null + }; +} + +// JSDoc typedefs +// ============== +/** + * @typedef {Object} Layout + * Object containing svg layout information + * + * @property {Number} width (pixels) + * Usable width for Figure (after margins are removed) + * @property {Number} height (pixels) + * Usable height for Figure (after margins are removed) + * @property {Margin} margin + * Margin around the Figure (pixels) + */ + +/** + * @typedef {Object} Margin + * Object containing padding information in pixels + * + * @property {Number} t + * Top margin + * @property {Number} r + * Right margin + * @property {Number} b + * Bottom margin + * @property {Number} l + * Left margin + */ + +/** + * @typedef {Object} Font + * Object containing font information + * + * @property {Number} size: Font size + * @property {String} color: Font color + * @property {String} family: Font family + */ + +/** + * @typedef {Object} ParcatsViewModel + * Object containing calculated parcats view information + * + * These are quantities that require Layout information to calculate + * @property key + * Unique key for this model + * @property {ParcatsModel} model + * Source parcats model + * @property {Array.} dimensions + * Array of dimension view models + * @property {Number} width + * Width for this trace (pixels) + * @property {Number} height + * Height for this trace (pixels) + * @property {Number} x + * X position of this trace with respect to the Figure (pixels) + * @property {Number} y + * Y position of this trace with respect to the Figure (pixels) + * @property {String} hoveron + * Hover interaction mode. One of: 'category', 'color', or 'dimension' + * @property {Array.} hoverinfoItems + * Info to display on hover. Array with a combination of 'counts' and/or 'probabilities', or 'none', or 'skip' + * @property {String} arrangement + * Category arrangement. One of: 'perpendicular', 'freeform', or 'fixed' + * @property {Boolean} bundlecolors + * Whether paths should be sorted so that like colors are bundled together as they pass through categories + * @property {String} sortpaths + * If 'forward' then sort paths based on dimensions from left to right. If 'backward' sort based on dimensions + * from right to left + * @property {Font} labelfont + * Font for the dimension labels + * @property {Font} categorylabelfont + * Font for the category labels + * @property {String} pathShape + * The shape of the paths. Either 'linear' or 'hspline'. + * @property {DimensionViewModel|null} dragDimension + * Dimension currently being dragged. Null if no drag in progress + * @property {Margin} margin + * Margin around the Figure + * @property {Object} graphDiv + * Top-level graph div element + * @property {Object} traceSelection + * D3 selection of this view models trace group element + * @property {Object} pathSelection + * D3 selection of this view models path elements + * @property {Object} dimensionSelection + * D3 selection of this view models dimension group element + */ + +/** + * @typedef {Object} DimensionViewModel + * Object containing calculated parcats dimension view information + * + * These are quantities that require Layout information to calculate + * @property key + * Unique key for this model + * @property {DimensionModel} model + * Source dimension model + * @property {Number} x + * X position of the center of this dimension with respect to the Figure (pixels) + * @property {Number} y + * Y position of the top of this dimension with respect to the Figure (pixels) + * @property {Number} width + * Width of categories in this dimension (pixels) + * @property {ParcatsViewModel} parcatsViewModel + * The parent trace's view model + * @property {Array.} categories + * Dimensions category view models + * @property {Number|null} dragCategoryDisplayInd + * Display index of category currently being dragged. null if no category is being dragged + * @property {Number|null} dragDimensionDisplayInd + * Display index of the dimension being dragged. null if no dimension is being dragged + * @property {Array.|null} initialDragDimensionDisplayInds + * Dimensions display indexes at the beginning of the current drag. null if no dimension is being dragged + * @property {Array.|null} initialDragCategoryDisplayInds + * Category display indexes for the at the beginning of the current drag. null if no category is being dragged + * @property {HTMLElement} potentialClickBand + * Band under mouse when current drag began. If no drag movement takes place then a click will be emitted for this + * band. Null if not drag in progress. + * @property {Boolean} dragHasMoved + * True if there is an active drag and the drag has moved. If drag doesn't move before being ended then + * this may be interpreted as a click. Null if no drag in progress + */ + +/** + * @typedef {Object} CategoryViewModel + * Object containing calculated parcats category view information + * + * These are quantities that require Layout information to calculate + * @property key + * Unique key for this model + * @property {CategoryModel} model + * Source category model + * @property {Number} width + * Width for this category (pixels) + * @property {Number} height + * Height for this category (pixels) + * @property {Number} y + * Y position of this cateogry with respect to the Figure (pixels) + * @property {Array.} bands + * Array of color bands inside the category + * @property {ParcatsViewModel} parcatsViewModel + * The parent trace's view model + */ + +/** + * @typedef {Object} CategoryBandViewModel + * Object containing calculated category band information. A category band is a region inside a category covering + * paths of a single color + * + * @property key + * Unique key for this model + * @property color + * Band color + * @property rawColor + * Raw color value for band + * @property {Number} width + * Band width + * @property {Number} height + * Band height + * @property {Number} y + * Y position of top of the band with respect to the category + * @property {Number} count + * The number of samples represented by the band + * @property {CategoryViewModel} categoryViewModel + * The parent categorie's view model + * @property {ParcatsViewModel} parcatsViewModel + * The parent trace's view model + */ + +/** + * @typedef {Object} PathViewModel + * Object containing calculated parcats path view information + * + * These are quantities that require Layout information to calculate + * @property key + * Unique key for this model + * @property {PathModel} model + * Source path model + * @property {Number} height + * Height of this path (pixels) + * @property {Array.} leftXs + * The x position of the left edge of each display dimension + * @property {Array.} topYs + * The y position of the top of the path for each display dimension + * @property {Array.} dimWidths + * The width of each display dimension + * @property {String} svgD + * SVG path "d" attribute string + * @property {ParcatsViewModel} parcatsViewModel + * The parent trace's view model + */ diff --git a/src/traces/parcats/plot.js b/src/traces/parcats/plot.js new file mode 100644 index 00000000000..2b958b920c4 --- /dev/null +++ b/src/traces/parcats/plot.js @@ -0,0 +1,42 @@ +/** +* Copyright 2012-2018, 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 parcats = require('./parcats'); + +/** + * Create / update parcat traces + * + * @param {Object} graphDiv + * @param {Array.} parcatsModels + */ +module.exports = function plot(graphDiv, parcatsModels, transitionOpts, makeOnCompleteCallback) { + var fullLayout = graphDiv._fullLayout, + svg = fullLayout._paper, + size = fullLayout._size; + + parcats( + graphDiv, + svg, + parcatsModels, + { + width: size.w, + height: size.h, + margin: { + t: size.t, + r: size.r, + b: size.b, + l: size.l + } + }, + transitionOpts, + makeOnCompleteCallback + ); +}; diff --git a/test/image/baselines/parcats_basic.png b/test/image/baselines/parcats_basic.png new file mode 100644 index 00000000000..20ffc0024f2 Binary files /dev/null and b/test/image/baselines/parcats_basic.png differ diff --git a/test/image/baselines/parcats_basic_freeform.png b/test/image/baselines/parcats_basic_freeform.png new file mode 100644 index 00000000000..20ffc0024f2 Binary files /dev/null and b/test/image/baselines/parcats_basic_freeform.png differ diff --git a/test/image/baselines/parcats_bundled.png b/test/image/baselines/parcats_bundled.png new file mode 100644 index 00000000000..813fd6ddd32 Binary files /dev/null and b/test/image/baselines/parcats_bundled.png differ diff --git a/test/image/baselines/parcats_bundled_reversed.png b/test/image/baselines/parcats_bundled_reversed.png new file mode 100644 index 00000000000..8b714e28fd9 Binary files /dev/null and b/test/image/baselines/parcats_bundled_reversed.png differ diff --git a/test/image/baselines/parcats_grid_subplots.png b/test/image/baselines/parcats_grid_subplots.png new file mode 100644 index 00000000000..6b3ce202c17 Binary files /dev/null and b/test/image/baselines/parcats_grid_subplots.png differ diff --git a/test/image/baselines/parcats_hoveron_color.png b/test/image/baselines/parcats_hoveron_color.png new file mode 100644 index 00000000000..9eac4f55d6e Binary files /dev/null and b/test/image/baselines/parcats_hoveron_color.png differ diff --git a/test/image/baselines/parcats_hoveron_dimension.png b/test/image/baselines/parcats_hoveron_dimension.png new file mode 100644 index 00000000000..fd4e7b27dbd Binary files /dev/null and b/test/image/baselines/parcats_hoveron_dimension.png differ diff --git a/test/image/baselines/parcats_invisible_dimension.png b/test/image/baselines/parcats_invisible_dimension.png new file mode 100644 index 00000000000..78923b01211 Binary files /dev/null and b/test/image/baselines/parcats_invisible_dimension.png differ diff --git a/test/image/baselines/parcats_reordered.png b/test/image/baselines/parcats_reordered.png new file mode 100644 index 00000000000..4acfe3a8b23 Binary files /dev/null and b/test/image/baselines/parcats_reordered.png differ diff --git a/test/image/baselines/parcats_unbundled.png b/test/image/baselines/parcats_unbundled.png new file mode 100644 index 00000000000..9eac4f55d6e Binary files /dev/null and b/test/image/baselines/parcats_unbundled.png differ diff --git a/test/image/mocks/parcats_basic.json b/test/image/mocks/parcats_basic.json new file mode 100644 index 00000000000..b52442186ed --- /dev/null +++ b/test/image/mocks/parcats_basic.json @@ -0,0 +1,16 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}]} + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_basic_freeform.json b/test/image/mocks/parcats_basic_freeform.json new file mode 100644 index 00000000000..4f0b54472f8 --- /dev/null +++ b/test/image/mocks/parcats_basic_freeform.json @@ -0,0 +1,17 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "arrangement": "freeform", + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}]} + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_bundled.json b/test/image/mocks/parcats_bundled.json new file mode 100644 index 00000000000..118c56234c3 --- /dev/null +++ b/test/image/mocks/parcats_bundled.json @@ -0,0 +1,21 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "bundlecolors": true, + "line": { + "color": [0, 0, 1, 1, 0, 1, 0, 0, 0] + } + } + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_bundled_reversed.json b/test/image/mocks/parcats_bundled_reversed.json new file mode 100644 index 00000000000..116945f9fb1 --- /dev/null +++ b/test/image/mocks/parcats_bundled_reversed.json @@ -0,0 +1,22 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "bundlecolors": true, + "sortpaths": "backward", + "line": { + "color": [0, 0, 1, 1, 0, 1, 0, 0, 0] + } + } + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_grid_subplots.json b/test/image/mocks/parcats_grid_subplots.json new file mode 100644 index 00000000000..e53e0345e1a --- /dev/null +++ b/test/image/mocks/parcats_grid_subplots.json @@ -0,0 +1,55 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"row": 0, "column": 0}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1, 2, 3, 1, 2, 3, 1, 2, 1, 2, 1, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C", "B", "C", "B", "C", "B", "C", "B", "C", "B", "A", "A"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "bundlecolors": true, + "line": { + "color": [0, 0, 1, 1, 0, 1, 0, 0, 0, 2, 2, 2, 2, 1, 2, 1, 0, 2, 1, 2], + "shape": "linear", + "showscale": true, + "colorscale": "Viridis" + } + }, + {"type": "parcats", + "domain": {"row": 0, "column": 1}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "labelfont": {"family": "Rockwell", "size": 20, "color": "gray"}, + "tickfont": {"family": "Arial", "size": 10, "color": "firebrick"} + }, + {"type": "parcats", + "domain": {"row": 1, "column": 0}, + "tickfont": {"color": "black"}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", + "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"], + "categoryarray": ["A", "B", "C"], + "ticktext": ["$A^2$", "Bold
and
Italic", "Link"] + }, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}] + }, + {"type": "parcats", + "domain": {"row": 1, "column": 1}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "bundlecolors": false, + "line": { + "color": [0, 0, 1, 1, 0, 1, 0, 0, 0], + "shape": "hspline" + } + } + ], + "layout": { + "height": 602, + "width": 592, + "grid": {"rows": 2, "columns": 2}} +} diff --git a/test/image/mocks/parcats_hoveron_color.json b/test/image/mocks/parcats_hoveron_color.json new file mode 100644 index 00000000000..be40fe56d8d --- /dev/null +++ b/test/image/mocks/parcats_hoveron_color.json @@ -0,0 +1,24 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "hoveron": "color", + "hoverinfo": "probability", + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "bundlecolors": false, + "line": { + "color": [0, 0, 1, 1, 0, 1, 0, 0, 0], + "shape": "hspline" + } + } + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_hoveron_dimension.json b/test/image/mocks/parcats_hoveron_dimension.json new file mode 100644 index 00000000000..e48432b03d5 --- /dev/null +++ b/test/image/mocks/parcats_hoveron_dimension.json @@ -0,0 +1,22 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "hoveron": "dimension", + "hoverinfo": "probability+count", + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", + "values": ["A", "A", "A", "B", "A", "A", "A", "A", "C"], + "categoryorder": "category descending"}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "counts": [11, 3, 5, 2, 1, 20, 5, 9, 1] + } + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_invisible_dimension.json b/test/image/mocks/parcats_invisible_dimension.json new file mode 100644 index 00000000000..159c1ff237e --- /dev/null +++ b/test/image/mocks/parcats_invisible_dimension.json @@ -0,0 +1,18 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", + "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"], + "visible": false}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}]} + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_reordered.json b/test/image/mocks/parcats_reordered.json new file mode 100644 index 00000000000..0a460c9f720 --- /dev/null +++ b/test/image/mocks/parcats_reordered.json @@ -0,0 +1,18 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "dimensions": [ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1], + "displayindex": 0, "categoryarray": [1, 2], "ticktext": ["One", "Two"]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"], + "displayindex": 2, "categoryarray": ["B", "A", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11], "displayindex": 1}]} + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/image/mocks/parcats_unbundled.json b/test/image/mocks/parcats_unbundled.json new file mode 100644 index 00000000000..d4a782a8ca5 --- /dev/null +++ b/test/image/mocks/parcats_unbundled.json @@ -0,0 +1,22 @@ +{ + "data": [ + {"type": "parcats", + "domain": {"x": [0.125, 0.625],"y": [0.25, 0.75]}, + "dimensions":[ + {"label": "One", "values": [1, 1, 2, 1, 2, 1, 1, 2, 1]}, + {"label": "Two", "values": ["A", "B", "A", "B", "C", "C", "A", "B", "C"]}, + {"label": "Three", "values": [11, 11, 11, 11, 11, 11, 11, 11, 11]}], + "bundlecolors": false, + "line": { + "color": [0, 0, 1, 1, 0, 1, 0, 0, 0], + "shape": "hspline" + } + } + ], + "layout": { + "height": 602, + "width": 592, + "margin": { + "l": 40, "r": 40, "t": 50, "b": 40 + }} +} diff --git a/test/jasmine/bundle_tests/plotschema_test.js b/test/jasmine/bundle_tests/plotschema_test.js index 92646325066..d9cc5d00f99 100644 --- a/test/jasmine/bundle_tests/plotschema_test.js +++ b/test/jasmine/bundle_tests/plotschema_test.js @@ -153,7 +153,7 @@ describe('plot schema', function() { // check that no other object has '_isSubplotObj' assertPlotSchema( function(attr, attrName) { - if(attr[IS_SUBPLOT_OBJ] === true) { + if(attr && attr[IS_SUBPLOT_OBJ] === true) { expect(astrs.indexOf(attrName)).not.toEqual(-1); cnt++; } @@ -228,7 +228,7 @@ describe('plot schema', function() { assertPlotSchema( function(attr, attrName, attrs, level, attrString) { - if(isPlainObject(attr[DEPRECATED])) { + if(attr && isPlainObject(attr[DEPRECATED])) { Object.keys(attr[DEPRECATED]).forEach(function(dAttrName) { var dAttr = attr[DEPRECATED][dAttrName]; @@ -244,7 +244,7 @@ describe('plot schema', function() { it('has valid or no `impliedEdits` in every attribute', function() { assertPlotSchema(function(attr, attrName, attrs, level, attrString) { - if(attr.impliedEdits !== undefined) { + if(attr && attr.impliedEdits !== undefined) { expect(isPlainObject(attr.impliedEdits)) .toBe(true, attrString + ': ' + JSON.stringify(attr.impliedEdits)); // make sure it wasn't emptied out diff --git a/test/jasmine/tests/parcats_test.js b/test/jasmine/tests/parcats_test.js new file mode 100644 index 00000000000..6fc5070ba53 --- /dev/null +++ b/test/jasmine/tests/parcats_test.js @@ -0,0 +1,1735 @@ +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); +var d3 = require('d3'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var failTest = require('../assets/fail_test'); +var mouseEvent = require('../assets/mouse_event'); +var click = require('../assets/click'); +var delay = require('../assets/delay'); + +var CALLBACK_DELAY = 500; + +// Testing constants +// ================= +var basic_mock = Lib.extendDeep({}, require('@mocks/parcats_basic.json')); +var margin = basic_mock.layout.margin; +var domain = basic_mock.data[0].domain; + +var categoryLabelPad = 40, + dimWidth = 16, + catSpacing = 8, + dimDx = (256 - 2 * categoryLabelPad - dimWidth) / 2; + +// Validation helpers +// ================== +function checkDimensionCalc(gd, dimInd, dimProps) { + /** @type {ParcatsModel} */ + var calcdata = gd.calcdata[0][0]; + var dimension = calcdata.dimensions[dimInd]; + for(var dimProp in dimProps) { + if(dimProps.hasOwnProperty(dimProp)) { + expect(dimension[dimProp]).toEqual(dimProps[dimProp]); + } + } +} + +function checkCategoryCalc(gd, dimInd, catInd, catProps) { + /** @type {ParcatsModel} */ + var calcdata = gd.calcdata[0][0]; + var dimension = calcdata.dimensions[dimInd]; + var category = dimension.categories[catInd]; + for(var catProp in catProps) { + if(catProps.hasOwnProperty(catProp)) { + expect(category[catProp]).toEqual(catProps[catProp]); + } + } +} + +function checkParcatsModelView(gd) { + var fullLayout = gd._fullLayout; + var size = fullLayout._size; + + // Make sure we have a 512x512 area for traces + expect(size.h).toEqual(512); + expect(size.w).toEqual(512); + + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + // Check location/size of this trace inside overall traces area + expect(parcatsViewModel.x).toEqual(64 + margin.r); + expect(parcatsViewModel.y).toEqual(128 + margin.t); + expect(parcatsViewModel.width).toEqual(256); + expect(parcatsViewModel.height).toEqual(256); + + // Check location of dimensions + expect(parcatsViewModel.dimensions[0].x).toEqual(categoryLabelPad); + expect(parcatsViewModel.dimensions[0].y).toEqual(0); + + expect(parcatsViewModel.dimensions[1].x).toEqual(categoryLabelPad + dimDx); + expect(parcatsViewModel.dimensions[1].y).toEqual(0); + + expect(parcatsViewModel.dimensions[2].x).toEqual(categoryLabelPad + 2 * dimDx); + expect(parcatsViewModel.dimensions[2].y).toEqual(0); + + // Check location of categories + /** @param {Array.} categories */ + function checkCategoryPositions(categories) { + var nextY = (3 - categories.length) * catSpacing / 2; + for(var c = 0; c < categories.length; c++) { + expect(categories[c].y).toEqual(nextY); + nextY += categories[c].height + catSpacing; + } + } + + checkCategoryPositions(parcatsViewModel.dimensions[0].categories); + checkCategoryPositions(parcatsViewModel.dimensions[1].categories); + checkCategoryPositions(parcatsViewModel.dimensions[2].categories); +} + +function checkParcatsSvg(gd) { + var fullLayout = gd._fullLayout; + var size = fullLayout._size; + + // Make sure we have a 512x512 area for traces + expect(size.h).toEqual(512); + expect(size.w).toEqual(512); + + // Check trace transform + var parcatsTraceSelection = d3.select('g.trace.parcats'); + + expect(parcatsTraceSelection.attr('transform')).toEqual( + makeTranslate( + size.w * domain.x[0] + margin.r, + size.h * domain.y[0] + margin.t)); + + // Check dimension transforms + var dimensionSelection = parcatsTraceSelection + .selectAll('g.dimensions') + .selectAll('g.dimension'); + + dimensionSelection.each(function(dimension, dimInd) { + var expectedX = categoryLabelPad + dimInd * dimDx, + expectedY = 0, + expectedTransform = makeTranslate(expectedX, expectedY); + expect(d3.select(this).attr('transform')).toEqual(expectedTransform); + }); + + // Check category transforms + dimensionSelection.each(function(dimension, dimDisplayInd) { + + var categorySelection = d3.select(this).selectAll('g.category'); + var nextY = (3 - categorySelection.size()) * catSpacing / 2; + + categorySelection.each(function(category) { + var catSel = d3.select(this), + catWidth = catSel.datum().width, + catHeight = catSel.datum().height; + + var expectedTransform = 'translate(0, ' + nextY + ')'; + expect(catSel.attr('transform')).toEqual(expectedTransform); + nextY += category.height + catSpacing; + + // Check category label position + var isRightDim = dimDisplayInd === 2, + catLabel = catSel.select('text.catlabel'); + + // Compute expected text properties based on + // whether this is the right-most dimension + var expectedTextAnchor = isRightDim ? 'start' : 'end', + expectedX = isRightDim ? catWidth + 5 : -5, + expectedY = catHeight / 2; + + expect(catLabel.attr('text-anchor')).toEqual(expectedTextAnchor); + expect(catLabel.attr('x')).toEqual(expectedX.toString()); + expect(catLabel.attr('y')).toEqual(expectedY.toString()); + }); + }); +} + +function makeTranslate(x, y) { + return 'translate(' + x + ', ' + y + ')'; +} + + +// Test cases +// ========== +describe('Basic parcats trace', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_basic.json')); + }); + + afterEach(destroyGraphDiv); + + // Tests + // ----- + it('should create trace properly', function(done) { + Plotly.newPlot(gd, basic_mock) + .then(function() { + // Check trace properties + var trace = gd.data[0]; + + expect(trace.type).toEqual('parcats'); + expect(trace.dimensions.length).toEqual(3); + }) + .catch(failTest) + .then(done); + }); + + it('should compute initial model properly', function(done) { + Plotly.newPlot(gd, basic_mock) + .then(function() { + + // Var check calc data + /** @type {ParcatsModel} */ + var calcdata = gd.calcdata[0][0]; + + // Check cross dimension values + // ---------------------------- + expect(calcdata.dimensions.length).toEqual(3); + expect(calcdata.maxCats).toEqual(3); + + // Check dimension 0 + // ----------------- + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dragX: null, dimensionLabel: 'One', count: 9}); + + checkCategoryCalc(gd, 0, 0, { + categoryLabel: 1, + dimensionInd: 0, + categoryInd: 0, + displayInd: 0, + count: 6, + valueInds: [0, 1, 3, 5, 6, 8]}); + + checkCategoryCalc(gd, 0, 1, { + categoryLabel: 2, + dimensionInd: 0, + categoryInd: 1, + displayInd: 1, + count: 3, + valueInds: [2, 4, 7]}); + + // Check dimension 1 + // ----------------- + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 1, dragX: null, dimensionLabel: 'Two', count: 9}); + + checkCategoryCalc(gd, 1, 0, { + categoryLabel: 'A', + dimensionInd: 1, + categoryInd: 0, + displayInd: 0, + count: 3, + valueInds: [0, 2, 6]}); + + checkCategoryCalc(gd, 1, 1, { + categoryLabel: 'B', + dimensionInd: 1, + categoryInd: 1, + displayInd: 1, + count: 3, + valueInds: [1, 3, 7]}); + + checkCategoryCalc(gd, 1, 2, { + categoryLabel: 'C', + dimensionInd: 1, + categoryInd: 2, + displayInd: 2, + count: 3, + valueInds: [4, 5, 8]}); + + // Check dimension 2 + // ----------------- + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 2, dragX: null, dimensionLabel: 'Three', count: 9}); + + checkCategoryCalc(gd, 2, 0, { + categoryLabel: 11, + dimensionInd: 2, + categoryInd: 0, + displayInd: 0, + count: 9, + valueInds: [0, 1, 2, 3, 4, 5, 6, 7, 8]}); + }) + .catch(failTest) + .then(done); + }); + + it('should compute initial data properly', function(done) { + Plotly.newPlot(gd, mock) + .then(function() { + + // Var check calc data + var gd_traceData = gd.data[0]; + + // Check that trace data matches input + expect(gd_traceData).toEqual(mock.data[0]); + }) + .catch(failTest) + .then(done); + }); + + it('should compute initial fullData properly', function(done) { + Plotly.newPlot(gd, basic_mock) + .then(function() { + // Check that some of the defaults are computed properly + var fullTrace = gd._fullData[0]; + expect(fullTrace.arrangement).toBe('perpendicular'); + expect(fullTrace.bundlecolors).toBe(true); + expect(fullTrace.dimensions[1].visible).toBe(true); + }) + .catch(failTest) + .then(done); + }); + + it('should compute initial model views properly', function(done) { + + Plotly.newPlot(gd, basic_mock) + .then(function() { + checkParcatsModelView(gd); + }) + + .catch(failTest) + .then(done); + }); + + it('should compute initial svg properly', function(done) { + Plotly.newPlot(gd, basic_mock) + .then(function() { + checkParcatsSvg(gd); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('Dimension reordered parcats trace', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_reordered.json')); + }); + + afterEach(destroyGraphDiv); + + // Tests + // ----- + it('should compute initial model properly', function(done) { + Plotly.newPlot(gd, mock) + .then(function() { + + // Var check calc data + /** @type {ParcatsModel} */ + var calcdata = gd.calcdata[0][0]; + + // Check cross dimension values + // ---------------------------- + expect(calcdata.dimensions.length).toEqual(3); + expect(calcdata.maxCats).toEqual(3); + + // Check dimension display order + // ----------------------------- + + // ### Dimension 0 ### + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dimensionLabel: 'One'}); + + checkCategoryCalc(gd, 0, 0, { + categoryLabel: 'One', + dimensionInd: 0, + categoryInd: 0, + displayInd: 0}); + + checkCategoryCalc(gd, 0, 1, { + categoryLabel: 'Two', + dimensionInd: 0, + categoryInd: 1, + displayInd: 1}); + + // ### Dimension 1 ### + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 2, dimensionLabel: 'Two'}); + + checkCategoryCalc(gd, 1, 0, { + categoryLabel: 'B', + dimensionInd: 1, + categoryInd: 0, + displayInd: 0}); + + checkCategoryCalc(gd, 1, 1, { + categoryLabel: 'A', + dimensionInd: 1, + categoryInd: 1, + displayInd: 1}); + + checkCategoryCalc(gd, 1, 2, { + categoryLabel: 'C', + dimensionInd: 1, + categoryInd: 2, + displayInd: 2}); + + // ### Dimension 2 ### + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 1, dimensionLabel: 'Three'}); + + checkCategoryCalc(gd, 2, 0, { + categoryLabel: 11, + dimensionInd: 2, + categoryInd: 0, + displayInd: 0}); + }) + .catch(failTest) + .then(done); + }); + + it('should recover from bad display order specification', function(done) { + + // Define bad display indexes [0, 2, 0] + mock.data[0].dimensions[2].displayindex = 0; + + Plotly.newPlot(gd, mock) + .then(function() { + + // Var check calc data + /** @type {ParcatsModel} */ + var calcdata = gd.calcdata[0][0]; + + // Check cross dimension values + // ---------------------------- + expect(calcdata.dimensions.length).toEqual(3); + expect(calcdata.maxCats).toEqual(3); + + // Check dimension display order + // ----------------------------- + // Display indexes should equal dimension indexes + + // ### Dimension 0 ### + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dimensionLabel: 'One'}); + + checkCategoryCalc(gd, 0, 0, { + categoryLabel: 'One', + categoryInd: 0, + displayInd: 0}); + + checkCategoryCalc(gd, 0, 1, { + categoryLabel: 'Two', + categoryInd: 1, + displayInd: 1}); + + // ### Dimension 1 ### + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 1, dimensionLabel: 'Two'}); + + checkCategoryCalc(gd, 1, 0, { + categoryLabel: 'B', + categoryInd: 0, + displayInd: 0}); + + checkCategoryCalc(gd, 1, 1, { + categoryLabel: 'A', + categoryInd: 1, + displayInd: 1}); + + checkCategoryCalc(gd, 1, 2, { + categoryLabel: 'C', + categoryInd: 2, + displayInd: 2}); + + // ### Dimension 2 ### + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 2, dimensionLabel: 'Three'}); + + checkCategoryCalc(gd, 2, 0, { + categoryLabel: 11, + categoryInd: 0, + displayInd: 0}); + }) + .catch(failTest) + .then(done); + }); + + it('should compute initial model views properly', function(done) { + + Plotly.newPlot(gd, mock) + .then(function() { + checkParcatsModelView(gd); + }) + + .catch(failTest) + .then(done); + }); + + it('should compute initial svg properly', function(done) { + Plotly.newPlot(gd, mock) + .then(function() { + checkParcatsSvg(gd); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('Drag to reordered dimensions', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + restyleCallback, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_basic_freeform.json')); + }); + + afterEach(destroyGraphDiv); + + function getMousePositions(parcatsViewModel) { + // Compute Mouse positions + // ----------------------- + // Start mouse in the middle of the dimension label on the + // second dimensions (dimension display index 1) + var dragDimStartX = parcatsViewModel.dimensions[1].x; + var mouseStartY = parcatsViewModel.y - 5, + mouseStartX = parcatsViewModel.x + dragDimStartX + dimWidth / 2; + + // Pause mouse half-way between the original location of + // the first and second dimensions. Also move mosue + // downward a bit to make sure drag 'sticks' + var mouseMidY = parcatsViewModel.y + 50, + mouseMidX = mouseStartX + dimDx / 2; + + // End mouse drag in the middle of the original + // position of the dimension label of the third dimension + // (dimension display index 2) + var mouseEndY = parcatsViewModel.y + 100, + mouseEndX = parcatsViewModel.x + parcatsViewModel.dimensions[2].x + dimWidth / 2; + return { + mouseStartY: mouseStartY, + mouseStartX: mouseStartX, + mouseMidY: mouseMidY, + mouseMidX: mouseMidX, + mouseEndY: mouseEndY, + mouseEndX: mouseEndX + }; + } + + function checkInitialDimensions() { + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dragX: null, dimensionLabel: 'One', count: 9}); + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 1, dragX: null, dimensionLabel: 'Two', count: 9}); + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 2, dragX: null, dimensionLabel: 'Three', count: 9}); + } + + function checkReorderedDimensions() { + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dragX: null, dimensionLabel: 'One', count: 9}); + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 2, dragX: null, dimensionLabel: 'Two', count: 9}); + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 1, dragX: null, dimensionLabel: 'Three', count: 9}); + } + + function checkMidDragDimensions(dragDimStartX) { + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dragX: null, dimensionLabel: 'One', count: 9}); + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 1, dragX: dragDimStartX + dimDx / 2, dimensionLabel: 'Two', count: 9}); + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 2, dragX: null, dimensionLabel: 'Three', count: 9}); + } + + it('should support dragging dimension label to reorder dimensions in freeform arrangement', function(done) { + + // Set arrangement + mock.data[0].arrangement = 'freeform'; + + Plotly.newPlot(gd, mock) + .then(function() { + restyleCallback = jasmine.createSpy('restyleCallback'); + gd.on('plotly_restyle', restyleCallback); + + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + var dragDimStartX = parcatsViewModel.dimensions[1].x; + var pos = getMousePositions(parcatsViewModel); + + // Check initial dimension order + // ----------------------------- + checkInitialDimensions(); + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', pos.mouseStartX, pos.mouseStartY); + + // Perform drag + // ------------ + mouseEvent('mousedown', pos.mouseStartX, pos.mouseStartY); + + // ### Pause at drag mid-point + mouseEvent('mousemove', pos.mouseMidX, pos.mouseMidY + // {buttons: 1} // Left click + ); + + // Make sure we're dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // Make sure dimensions haven't changed order yet, but that + // we do have a drag in progress on the middle dimension + checkMidDragDimensions(dragDimStartX); + + // ### Move to drag end-point + mouseEvent('mousemove', pos.mouseEndX, pos.mouseEndY); + + // Make sure we're still dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // End drag + // -------- + mouseEvent('mouseup', pos.mouseEndX, pos.mouseEndY); + + // Make sure we've cleared drag dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // Check final dimension order + // ----------------------------- + checkReorderedDimensions(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that proper restyle event was emitted + // ------------------------------------------- + expect(restyleCallback).toHaveBeenCalledTimes(1); + expect(restyleCallback).toHaveBeenCalledWith([ + { + 'dimensions[0].displayindex': 0, + 'dimensions[1].displayindex': 2, + 'dimensions[2].displayindex': 1 + }, + [0]]); + restyleCallback.calls.reset(); + }) + .catch(failTest) + .then(done); + }); + + it('should support dragging dimension label to reorder dimensions in perpendicular arrangement', function(done) { + + // Set arrangement + mock.data[0].arrangement = 'perpendicular'; + + Plotly.newPlot(gd, mock) + .then(function() { + restyleCallback = jasmine.createSpy('restyleCallback'); + gd.on('plotly_restyle', restyleCallback); + + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + var dragDimStartX = parcatsViewModel.dimensions[1].x; + var pos = getMousePositions(parcatsViewModel); + + // Check initial dimension order + // ----------------------------- + checkInitialDimensions(); + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', pos.mouseStartX, pos.mouseStartY); + + // Perform drag + // ------------ + mouseEvent('mousedown', pos.mouseStartX, pos.mouseStartY); + + // ### Pause at drag mid-point + mouseEvent('mousemove', pos.mouseMidX, pos.mouseMidY + // {buttons: 1} // Left click + ); + + // Make sure we're dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // Make sure dimensions haven't changed order yet, but that + // we do have a drag in progress on the middle dimension + checkMidDragDimensions(dragDimStartX); + + // ### Move to drag end-point + mouseEvent('mousemove', pos.mouseEndX, pos.mouseEndY); + + // Make sure we're still dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // End drag + // -------- + mouseEvent('mouseup', pos.mouseEndX, pos.mouseEndY); + + // Make sure we've cleared drag dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // Check final dimension order + // ----------------------------- + checkReorderedDimensions(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that proper restyle event was emitted + // ------------------------------------------- + expect(restyleCallback).toHaveBeenCalledTimes(1); + expect(restyleCallback).toHaveBeenCalledWith([ + { + 'dimensions[0].displayindex': 0, + 'dimensions[1].displayindex': 2, + 'dimensions[2].displayindex': 1 + }, + [0]]); + + restyleCallback.calls.reset(); + }) + .catch(failTest) + .then(done); + }); + + it('should NOT support dragging dimension label to reorder dimensions in fixed arrangement', function(done) { + + // Set arrangement + mock.data[0].arrangement = 'fixed'; + + Plotly.newPlot(gd, mock) + .then(function() { + console.log(gd.data); + restyleCallback = jasmine.createSpy('restyleCallback'); + gd.on('plotly_restyle', restyleCallback); + + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + var pos = getMousePositions(parcatsViewModel); + + // Check initial dimension order + // ----------------------------- + checkInitialDimensions(); + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', pos.mouseStartX, pos.mouseStartY); + + // Perform drag + // ------------ + mouseEvent('mousedown', pos.mouseStartX, pos.mouseStartY); + + // ### Pause at drag mid-point + mouseEvent('mousemove', pos.mouseMidX, pos.mouseMidY + // {buttons: 1} // Left click + ); + + // Make sure we're not dragging any dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // Make sure dimensions haven't changed order yet + checkInitialDimensions(); + + // ### Move to drag end-point + mouseEvent('mousemove', pos.mouseEndX, pos.mouseEndY); + + // Make sure we're still not dragging a dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // End drag + // -------- + mouseEvent('mouseup', pos.mouseEndX, pos.mouseEndY); + + // Make sure dimensions haven't changed + // ------------------------------------ + checkInitialDimensions(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that no restyle event was emitted + // --------------------------------------- + expect(restyleCallback).toHaveBeenCalledTimes(0); + restyleCallback.calls.reset(); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('Drag to reordered categories', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + restyleCallback, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_basic_freeform.json')); + }); + + afterEach(destroyGraphDiv); + + function getDragPositions(parcatsViewModel) { + var dragDimStartX = parcatsViewModel.dimensions[1].x; + + var mouseStartY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10, + mouseStartX = parcatsViewModel.x + dragDimStartX + dimWidth / 2; + + // Pause mouse half-way between the original location of + // the first and second dimensions. Also move mouse + // upward enough to swap position with middle category + var mouseMidY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[1].y, + mouseMidX = mouseStartX + dimDx / 2; + + // End mouse drag in the middle of the original + // position of the dimension label of the third dimension + // (dimension display index 2), and at the height of the original top category + var mouseEndY = parcatsViewModel.y, + mouseEndX = parcatsViewModel.x + parcatsViewModel.dimensions[2].x + dimWidth / 2; + return { + dragDimStartX: dragDimStartX, + mouseStartY: mouseStartY, + mouseStartX: mouseStartX, + mouseMidY: mouseMidY, + mouseMidX: mouseMidX, + mouseEndY: mouseEndY, + mouseEndX: mouseEndX + }; + } + + function checkInitialDimensions() { + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dragX: null, dimensionLabel: 'One', count: 9}); + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 1, dragX: null, dimensionLabel: 'Two', count: 9}); + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 2, dragX: null, dimensionLabel: 'Three', count: 9}); + } + + function checkMidDragDimensions(dragDimStartX) { + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dragX: null, dimensionLabel: 'One', count: 9}); + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 1, dragX: dragDimStartX + dimDx / 2, dimensionLabel: 'Two', count: 9}); + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 2, dragX: null, dimensionLabel: 'Three', count: 9}); + } + + function checkInitialCategories() { + checkCategoryCalc(gd, 1, 0, { + categoryLabel: 'A', + categoryInd: 0, + displayInd: 0 + }); + + checkCategoryCalc(gd, 1, 1, { + categoryLabel: 'B', + categoryInd: 1, + displayInd: 1 + }); + + checkCategoryCalc(gd, 1, 2, { + categoryLabel: 'C', + categoryInd: 2, + displayInd: 2 + }); + } + + function checkMidDragCategories() { + checkCategoryCalc(gd, 1, 0, { + categoryLabel: 'A', + categoryInd: 0, + displayInd: 0 + }); + + checkCategoryCalc(gd, 1, 1, { + categoryLabel: 'B', + categoryInd: 1, + displayInd: 2 + }); + + checkCategoryCalc(gd, 1, 2, { + categoryLabel: 'C', + categoryInd: 2, + displayInd: 1 + }); + } + + function checkFinalDimensions() { + checkDimensionCalc(gd, 0, + {dimensionInd: 0, displayInd: 0, dragX: null, dimensionLabel: 'One', count: 9}); + checkDimensionCalc(gd, 1, + {dimensionInd: 1, displayInd: 2, dragX: null, dimensionLabel: 'Two', count: 9}); + checkDimensionCalc(gd, 2, + {dimensionInd: 2, displayInd: 1, dragX: null, dimensionLabel: 'Three', count: 9}); + } + + function checkFinalCategories() { + checkCategoryCalc(gd, 1, 0, { + categoryLabel: 'A', + categoryInd: 0, + displayInd: 1 + }); + + checkCategoryCalc(gd, 1, 1, { + categoryLabel: 'B', + categoryInd: 1, + displayInd: 2 + }); + + checkCategoryCalc(gd, 1, 2, { + categoryLabel: 'C', + categoryInd: 2, + displayInd: 0 + }); + } + + it('should support dragging category to reorder categories and dimensions in freeform arrangement', function(done) { + + // Set arrangement + mock.data[0].arrangement = 'freeform'; + + Plotly.newPlot(gd, mock) + .then(function() { + + restyleCallback = jasmine.createSpy('restyleCallback'); + gd.on('plotly_restyle', restyleCallback); + + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + // Compute Mouse positions + // ----------------------- + // Start mouse in the middle of the lowest category + // second dimensions (dimension display index 1) + var pos = getDragPositions(parcatsViewModel); + + // Check initial dimension order + // ----------------------------- + checkInitialDimensions(); + + // Check initial categories + // ------------------------ + checkInitialCategories(); + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', pos.mouseStartX, pos.mouseStartY); + + // Perform drag + // ------------ + mouseEvent('mousedown', pos.mouseStartX, pos.mouseStartY); + + // ### Pause at drag mid-point + mouseEvent('mousemove', pos.mouseMidX, pos.mouseMidY); + + // Make sure we're dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // Make sure dimensions haven't changed order yet, but that + // we do have a drag in progress on the middle dimension + checkMidDragDimensions(pos.dragDimStartX); + + // Make sure categories in dimension 1 have changed already + checkMidDragCategories(); + + // ### Move to drag end-point + mouseEvent('mousemove', pos.mouseEndX, pos.mouseEndY); + + // Make sure we're still dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // End drag + // -------- + mouseEvent('mouseup', pos.mouseEndX, pos.mouseEndY); + + // Make sure we've cleared drag dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // Check final dimension order + // ----------------------------- + checkFinalDimensions(); + + // Make sure categories in dimension 1 have changed already + checkFinalCategories(); + + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that proper restyle event was emitted + // ------------------------------------------- + expect(restyleCallback).toHaveBeenCalledTimes(1); + expect(restyleCallback).toHaveBeenCalledWith([ + {'dimensions[0].displayindex': 0, + 'dimensions[1].displayindex': 2, + 'dimensions[2].displayindex': 1, + 'dimensions[1].categoryorder': 'array', + 'dimensions[1].categoryarray': [['C', 'A', 'B' ]], + 'dimensions[1].ticktext': [['C', 'A', 'B' ]]}, + [0]]); + + restyleCallback.calls.reset(); + }) + .catch(failTest) + .then(done); + }); + + it('should support dragging category to reorder categories only in perpendicular arrangement', function(done) { + + // Set arrangement + mock.data[0].arrangement = 'perpendicular'; + + Plotly.newPlot(gd, mock) + .then(function() { + + restyleCallback = jasmine.createSpy('restyleCallback'); + gd.on('plotly_restyle', restyleCallback); + + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + // Compute Mouse positions + // ----------------------- + var pos = getDragPositions(parcatsViewModel); + + // Check initial dimension order + // ----------------------------- + checkInitialDimensions(); + + // Check initial categories + // ------------------------ + checkInitialCategories(); + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', pos.mouseStartX, pos.mouseStartY); + + // Perform drag + // ------------ + mouseEvent('mousedown', pos.mouseStartX, pos.mouseStartY); + + // ### Pause at drag mid-point + mouseEvent('mousemove', pos.mouseMidX, pos.mouseMidY); + + // Make sure we're dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // Make sure dimensions haven't changed order or position + checkInitialDimensions(); + + // Make sure categories in dimension 1 have changed already + checkMidDragCategories(); + + // ### Move to drag end-point + mouseEvent('mousemove', pos.mouseEndX, pos.mouseEndY); + + // Make sure we're still dragging the middle dimension + expect(parcatsViewModel.dragDimension.model.dimensionLabel).toEqual('Two'); + + // End drag + // -------- + mouseEvent('mouseup', pos.mouseEndX, pos.mouseEndY); + + // Make sure we've cleared drag dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // Check final dimension order + // --------------------------- + // Dimension order should not have changed + checkInitialDimensions(); + + // Make sure categories in dimension 1 have changed already + checkFinalCategories(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that proper restyle event was emitted + // ------------------------------------------- + expect(restyleCallback).toHaveBeenCalledTimes(1); + expect(restyleCallback).toHaveBeenCalledWith([ + { + 'dimensions[1].categoryorder': 'array', + 'dimensions[1].categoryarray': [['C', 'A', 'B' ]], + 'dimensions[1].ticktext': [['C', 'A', 'B' ]]}, + [0]]); + + restyleCallback.calls.reset(); + }) + .catch(failTest) + .then(done); + }); + + it('should NOT support dragging category to reorder categories or dimensions in fixed arrangement', function(done) { + + // Set arrangement + mock.data[0].arrangement = 'fixed'; + + Plotly.newPlot(gd, mock) + .then(function() { + + restyleCallback = jasmine.createSpy('restyleCallback'); + gd.on('plotly_restyle', restyleCallback); + + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + // Compute Mouse positions + // ----------------------- + var pos = getDragPositions(parcatsViewModel); + + // Check initial dimension order + // ----------------------------- + checkInitialDimensions(); + + // Check initial categories + // ------------------------ + checkInitialCategories(); + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', pos.mouseStartX, pos.mouseStartY); + + // Perform drag + // ------------ + mouseEvent('mousedown', pos.mouseStartX, pos.mouseStartY); + + // ### Pause at drag mid-point + mouseEvent('mousemove', pos.mouseMidX, pos.mouseMidY); + + // Make sure we're not dragging a dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // Make sure dimensions and categories haven't changed order + checkInitialDimensions(); + checkInitialCategories(); + + // ### Move to drag end-point + mouseEvent('mousemove', pos.mouseEndX, pos.mouseEndY); + + // Make sure we're still not dragging a dimension + expect(parcatsViewModel.dragDimension).toEqual(null); + + // End drag + // -------- + mouseEvent('mouseup', pos.mouseEndX, pos.mouseEndY); + + // Check final dimension order + // --------------------------- + // Dimension and category order should not have changed + checkInitialDimensions(); + checkInitialCategories(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that no restyle event was emitted + // --------------------------------------- + expect(restyleCallback).toHaveBeenCalledTimes(0); + restyleCallback.calls.reset(); + }) + .catch(failTest) + .then(done); + }); +}); + + +describe('Click events', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_basic_freeform.json')); + }); + + afterEach(destroyGraphDiv); + + it('should fire on category click', function(done) { + + var clickData; + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_click', function(data) { + clickData = data; + }); + + // Click on the lowest category in the middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + var mouseY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10, + mouseX = parcatsViewModel.x + dimStartX + dimWidth / 2; + + // Position mouse for start of drag + // -------------------------------- + click(mouseX, mouseY); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that click callback was called + expect(clickData).toBeDefined(); + + // Check that the right points were reported + var pts = clickData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + + // Check points + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 4}, + {curveNumber: 0, pointNumber: 5}, + {curveNumber: 0, pointNumber: 8}]); + }) + .catch(failTest) + .then(done); + }); + + it('should NOT fire on category click if hoverinfo is skip', function(done) { + + var clickData; + mock.data[0].hoverinfo = 'skip'; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + console.log(gd.data[0]); + console.log(parcatsViewModel.hoverinfoItems); + + gd.on('plotly_click', function(data) { + clickData = data; + }); + + // Click on the lowest category in the middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + var mouseY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10, + mouseX = parcatsViewModel.x + dimStartX + dimWidth / 2; + + // Position mouse for start of drag + // -------------------------------- + click(mouseX, mouseY); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that click callback was called + expect(clickData).toBeUndefined(); + }) + .catch(failTest) + .then(done); + }); + + it('should fire on path click', function(done) { + + var clickData; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_click', function(data) { + clickData = data; + }); + + // Click on the top path to the right of the lowest category in the middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + var mouseY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10, + mouseX = parcatsViewModel.x + dimStartX + dimWidth + 10; + + // Position mouse for start of drag + // -------------------------------- + click(mouseX, mouseY); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that click callback was called + expect(clickData).toBeDefined(); + + // Check that the right points were reported + var pts = clickData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + + // Check points + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}, + {curveNumber: 0, pointNumber: 8}]); + }) + .catch(failTest) + .then(done); + }); + + it('should NOT fire on path click if hoverinfo is skip', function(done) { + + var clickData; + mock.data[0].hoverinfo = 'skip'; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_click', function(data) { + clickData = data; + }); + + // Click on the top path to the right of the lowest category in the middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + var mouseY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10, + mouseX = parcatsViewModel.x + dimStartX + dimWidth + 10; + + // Position mouse for start of drag + // -------------------------------- + click(mouseX, mouseY); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that click callback was called + expect(clickData).toBeUndefined(); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('Click events with hoveron color', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_hoveron_color.json')); + }); + + afterEach(destroyGraphDiv); + + it('should fire on category click', function(done) { + + var clickData; + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_click', function(data) { + clickData = data; + }); + + // Click on the top of the lowest category in the middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + var mouseY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10, + mouseX = parcatsViewModel.x + dimStartX + dimWidth / 2; + + // Position mouse for start of drag + // -------------------------------- + click(mouseX, mouseY); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that click callback was called + expect(clickData).toBeDefined(); + + // Check that the right points were reported + var pts = clickData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + + // Check points + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}]); + }) + .catch(failTest) + .then(done); + }); + + + it('should fire on path click', function(done) { + + var clickData; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_click', function(data) { + clickData = data; + }); + + // Click on the top path to the right of the lowest category in the middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + var mouseY = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10, + mouseX = parcatsViewModel.x + dimStartX + dimWidth + 10; + + // Position mouse for start of drag + // -------------------------------- + click(mouseX, mouseY); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that click callback was called + expect(clickData).toBeDefined(); + + // Check that the right points were reported + var pts = clickData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + + // Check points + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}]); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('Hover events', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_basic_freeform.json')); + }); + + afterEach(destroyGraphDiv); + + it('hover and unhover should fire on category', function(done) { + + var hoverData, + unhoverData, + mouseY0, + mouseX0; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_hover', function(data) { + hoverData = data; + }); + + gd.on('plotly_unhover', function(data) { + unhoverData = data; + }); + + // Hover over top of bottom category of middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + + mouseY0 = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10; + mouseX0 = parcatsViewModel.x + dimStartX + dimWidth / 2; + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', mouseX0, mouseY0); + mouseEvent('mouseover', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that hover callback was called + expect(hoverData).toBeDefined(); + + // Check that the right points were reported + var pts = hoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 4}, + {curveNumber: 0, pointNumber: 5}, + {curveNumber: 0, pointNumber: 8}]); + + // Check that unhover is still undefined + expect(unhoverData).toBeUndefined(); + }) + .then(function() { + // Unhover + mouseEvent('mouseout', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(function() { + // Check that unhover callback was called + expect(unhoverData).toBeDefined(); + + // Check that the right points were reported + var pts = unhoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 4}, + {curveNumber: 0, pointNumber: 5}, + {curveNumber: 0, pointNumber: 8}]); + }) + .catch(failTest) + .then(done); + }); + + it('hover and unhover should fire on path', function(done) { + + var hoverData, + unhoverData, + mouseY0, + mouseX0; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_hover', function(data) { + hoverData = data; + }); + + gd.on('plotly_unhover', function(data) { + unhoverData = data; + }); + + + var dimStartX = parcatsViewModel.dimensions[1].x; + mouseY0 = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10; + mouseX0 = parcatsViewModel.x + dimStartX + dimWidth + 10; + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', mouseX0, mouseY0); + mouseEvent('mouseover', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that hover callback was called + expect(hoverData).toBeDefined(); + + // Check that the right points were reported + var pts = hoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}, + {curveNumber: 0, pointNumber: 8}]); + + // Check that unhover is still undefined + expect(unhoverData).toBeUndefined(); + }) + .then(function() { + // Unhover + mouseEvent('mouseout', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(function() { + // Check that unhover callback was called + expect(unhoverData).toBeDefined(); + + // Check that the right points were reported + var pts = unhoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}, + {curveNumber: 0, pointNumber: 8}]); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('Hover events with hoveron color', function() { + + // Variable declarations + // --------------------- + // ### Trace level ### + var gd, + mock; + + // Fixtures + // -------- + beforeEach(function() { + gd = createGraphDiv(); + mock = Lib.extendDeep({}, require('@mocks/parcats_hoveron_color.json')); + }); + + afterEach(destroyGraphDiv); + + it('hover and unhover should fire on category hoveron color', function(done) { + + var hoverData, + unhoverData, + mouseY0, + mouseX0; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_hover', function(data) { + hoverData = data; + }); + + gd.on('plotly_unhover', function(data) { + unhoverData = data; + }); + + // Hover over top of bottom category of middle dimension (category "C") + var dimStartX = parcatsViewModel.dimensions[1].x; + + mouseY0 = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10; + mouseX0 = parcatsViewModel.x + dimStartX + dimWidth / 2; + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', mouseX0, mouseY0); + mouseEvent('mouseover', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that hover callback was called + expect(hoverData).toBeDefined(); + + // Check that the right points were reported + var pts = hoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}]); + + // Check that unhover is still undefined + expect(unhoverData).toBeUndefined(); + }) + .then(function() { + // Unhover + mouseEvent('mouseout', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(function() { + // Check that unhover callback was called + expect(unhoverData).toBeDefined(); + + // Check that the right points were reported + var pts = unhoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}]); + }) + .catch(failTest) + .then(done); + }); + + it('hover and unhover should fire on path hoveron color', function(done) { + + var hoverData, + unhoverData, + mouseY0, + mouseX0; + + Plotly.newPlot(gd, mock) + .then(function() { + /** @type {ParcatsViewModel} */ + var parcatsViewModel = d3.select('g.trace.parcats').datum(); + + gd.on('plotly_hover', function(data) { + hoverData = data; + }); + + gd.on('plotly_unhover', function(data) { + unhoverData = data; + }); + + + var dimStartX = parcatsViewModel.dimensions[1].x; + mouseY0 = parcatsViewModel.y + parcatsViewModel.dimensions[1].categories[2].y + 10; + mouseX0 = parcatsViewModel.x + dimStartX + dimWidth + 10; + + // Position mouse for start of drag + // -------------------------------- + mouseEvent('mousemove', mouseX0, mouseY0); + mouseEvent('mouseover', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(delay(CALLBACK_DELAY)) + .then(function() { + // Check that hover callback was called + expect(hoverData).toBeDefined(); + + // Check that the right points were reported + var pts = hoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}]); + + // Check that unhover is still undefined + expect(unhoverData).toBeUndefined(); + }) + .then(function() { + // Unhover + mouseEvent('mouseout', mouseX0, mouseY0); + Lib.clearThrottle(); + }) + .then(function() { + // Check that unhover callback was called + expect(unhoverData).toBeDefined(); + + // Check that the right points were reported + var pts = unhoverData.points.sort(function(a, b) { + return a.pointNumber - b.pointNumber; + }); + expect(pts).toEqual([ + {curveNumber: 0, pointNumber: 5}]); + }) + .catch(failTest) + .then(done); + }); +});