plotly.js
Version:
The open source javascript graphing library that powers plotly
2,159 lines • 71.5 kB
JavaScript
/**
* Copyright 2012-2020, 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 strTranslate = Lib.strTranslate;
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 strTranslate(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 strTranslate(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 strTranslate(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');
var paperColor = graphDiv._fullLayout.paper_bgcolor;
// 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',
paperColor + ' -1px 1px 2px, ' +
paperColor + ' 1px 1px 2px, ' +
paperColor + ' 1px -1px 2px, ' +
paperColor + ' -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.<ParcatsModel>} 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;
var 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);
var constraints = buildConstraintsForPath(d);
d.parcatsViewModel.graphDiv.emit('plotly_hover', {
points: points, event: d3.event, constraints: constraints
});
// 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 trace = d.parcatsViewModel.trace;
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']);
var count = d.model.count;
var prob = count / d.parcatsViewModel.model.count;
var labels = {
countLabel: count,
probabilityLabel: prob.toFixed(3)
};
// Build hover text
var hovertextParts = [];
if(d.parcatsViewModel.hoverinfoItems.indexOf('count') !== -1) {
hovertextParts.push(['Count:', labels.countLabel].join(' '));
}
if(d.parcatsViewModel.hoverinfoItems.indexOf('probability') !== -1) {
hovertextParts.push(['P:', labels.probabilityLabel].join(' '));
}
var hovertext = hovertextParts.join('<br>');
var mouseX = d3.mouse(gd)[0];
Fx.loneHover({
trace: trace,
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',
hovertemplate: (trace.line || {}).hovertemplate,
hovertemplateLabels: labels,
eventData: [{
data: trace._input,
fullData: trace,
count: count,
probability: prob
}]
}, {
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);
var constraints = buildConstraintsForPath(d);
d.parcatsViewModel.graphDiv.emit('plotly_unhover', {
points: points, event: d3.event, constraints: constraints
});
}
}
}
/**
* 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;
}
/**
* Build constraints object for a path
*
* For use in click/hover events
* @param {PathViewModel} d
*/
function buildConstraintsForPath(d) {
var constraints = {};
var dimensions = d.parcatsViewModel.model.dimensions;
// dimensions
for(var i = 0; i < dimensions.length; i++) {
var dimension = dimensions[i];
var category = dimension.categories[d.model.categoryInds[i]];
constraints[dimension.containerInd] = category.categoryValue;
}
// color
if(d.model.rawColor !== undefined) {
constraints.color = d.model.rawColor;
}
return constraints;
}
/**
* 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);
var constraints = buildConstraintsForPath(d);
d.parcatsViewModel.graphDiv.emit('plotly_click', {
points: points, event: d3.event, constraints: constraints
});
}
}
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 categoryModel = bandViewModel.categoryViewModel.model;
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));
});
});
var constraints = {};
constraints[categoryModel.dimensionInd] = categoryModel.categoryValue;
gd.emit(eventName, {
points: points, event: event, constraints: constraints
});
}
/**
* @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 categoryModel = bandViewModel.categoryViewModel.model;
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));
});
var constraints = {};
constraints[categoryModel.dimensionInd] = categoryModel.categoryValue;
// color
if(bandViewModel.rawColor !== undefined) {
constraints.color = bandViewModel.rawColor;
}
gd.emit(eventName, {
points: points, event: event, constraints: constraints
});
}
/**
* 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(gd, rootBBox, bandElement) {
gd._fullLayout._calcInverseTransform(gd);
var scaleX = gd._fullLayout._invScaleX;
var scaleY = gd._fullLayout._invScaleY;
// 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];
var trace = parcatsViewModel.trace;
// 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';
}
var count = catViewModel.model.count;
var catLabel = catViewModel.model.categoryLabel;
var prob = count / catViewModel.parcatsViewModel.model.count;
var labels = {
countLabel: count,
categoryLabel: catLabel,
probabilityLabel: prob.toFixed(3)
};
// Hover label text
var hoverinfoParts = [];
if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('count') !== -1) {
hoverinfoParts.push(['Count:', labels.countLabel].join(' '));
}
if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('probability') !== -1) {
hoverinfoParts.push(['P(' + labels.categoryLabel + '):', labels.probabilityLabel].join(' '));
}
var hovertext = hoverinfoParts.join('<br>');
return {
trace: trace,
x: scaleX * (hoverCenterX - rootBBox.left),
y: scaleY * (hoverCenterY - rootBBox.top),
text: hovertext,
color: 'lightgray',
borderColor: 'black',
fontFamily: 'Monaco, "Courier New", monospace',
fontSize: 12,
fontColor: 'black',
idealAlign: hoverLabelIdealAlign,
hovertemplate: trace.hovertemplate,
hovertemplateLabels: labels,
eventData: [{
data: trace._input,
fullData: trace,
count: count,
category: catLabel,
probability: prob
}]
};
}
/**
* 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(gd, rootBBox, bandElement) {
var allHoverlabels = [];
d3.select(bandElement.parentNode.parentNode)
.selectAll('g.category')
.select('rect.catrect')
.each(function() {
var bandNode = this;
allHoverlabels.push(createHoverLabelForCategoryHovermode(gd, 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(gd, rootBBox, bandElement) {
gd._fullLayout._calcInverseTransform(gd);
var scaleX = gd._fullLayout._invScaleX;
var scaleY = gd._fullLayout._invScaleY;
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];
var trace = parcatsViewModel.trace;
// 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;
}
});
var pColorAndCat = bandColorCount / totalCount;
var pCatGivenColor = bandColorCount / colorCount;
var pColorGivenCat = bandColorCount / catCount;
var labels = {
countLabel: totalCount,
categoryLabel: catLabel,
probabilityLabel: pColorAndCat.toFixed(3)
};
// Hover label text
var hoverinfoParts = [];
if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('count') !== -1) {
hoverinfoParts.push(['Count:', labels.countLabel].join(' '));
}
if(catViewModel.parcatsViewModel.hoverinfoItems.indexOf('probability') !== -1) {
hoverinfoParts.push('P(color ∩ ' + catLabel + '): ' + labels.probabilityLabel);
hoverinfoParts.push('P(' + catLabel + ' | color): ' + pCatGivenColor.toFixed(3));
hoverinfoParts.push('P(color | ' + catLabel + '): ' + pColorGivenCat.toFixed(3));
}
var hovertext = hoverinfoParts.join('<br>');
// Compute text color
var textColor = tinycolor.mostReadable(bandViewModel.color, ['black', 'white']);
return {
trace: trace,
x: scaleX * (hoverCenterX - rootBBox.left),
y: scaleY * (hoverCenterY - rootBBox.top),
// name: 'NAME',
text: hovertext,
color: bandViewModel.color,
borderColor: 'black',
fontFamily: 'Monaco, "Courier New", monospace',
fontColor: textColor,
fontSize: 10,
idealAlign: hoverLabelIdealAlign,
hovertemplate: trace.hovertemplate,
hovertemplateLabels: labels,
eventData: [{
data: trace._input,
fullData: trace,
category: catLabel,
count: totalCount,
probability: pColorAndCat,
categorycount: catCount,
colorcount: colorCount,
bandcolorcount: bandColorCount
}]
};
}
/**
* 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(gd, rootBBox, bandElement);
} else if(hoveron === 'color') {
hoverItems = createHoverLabelForColorHovermode(gd, rootBBox, bandElement);
} else if(hoveron === 'dimension') {
hoverItems = createHoverLabelForDimensionHovermode(gd, rootBBox, bandElement);
}
if(hoverItems) {
Fx.loneHover(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;
var prevDimInd = dragDimInd - 1;
var 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 strTranslate(d.x, 0);
});
// Update category position
transition(categorySelection)
.attr('transform', function(d) {
return strTranslate(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 <tspan> 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.<ParcatsModel>} 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;
var domain = trace.domain;
var figureWidth = layout.width;
var figureHeight = layout.height;
var traceWidth = Math.floor(figureWidth * (domain.x[1] - domain.x[0]));
var traceHeight = Math.floor(figureHeight * (domain.y[1] - domain.y[0]));
var traceX = domain.x[0] * figureWidth + margin.l;
var 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 = {
trace: trace,
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.<Number>} leftXPositions
* Array of the x positions of the left edge of each dimension (in display order)
* @param {Array.<Number>} pathYs
* Array of the y positions of the top of the path at each dimension (in display order)
* @param {Array.<Number>} 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 = [];
var xRefPoints2 = [];
var refInterpolator;
var 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);
var totalCount = dimensionViewModels[0].model.count;
var 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;
var dimWidth = 16;
var numDimensions = parcatsViewModel.model.dimensions.length;
var displayInd = dimensionModel.displayInd;
// Compute x coordinate values
var dimDx;
var dimX0;
var 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 = [];
var maxCats = parcatsViewModel.model.maxCats;
var numCats = dimensionModel.categories.length;
var catSpacing = 8;
var totalCount = dimensionModel.count;
var totalHeight = parcatsViewModel.height - catSpacing * (maxCats - 1);
var nextCatHeight;
var nextCatModel;
var nextCat;
var catInd;
var 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.<DimensionViewModel>} 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.<String>} 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.<CategoryViewModel>} 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.<Number>|null} initialDragDimensionDisplayInds
* Dimensions display indexes at the beginning of the current drag. null if no dimension is being dragged
* @property {Array.<Number>|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.<CategoryBandViewModel>} 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.<Number>} leftXs
* The x position of the left edge of each display dimension
* @property {Array.<Number>} topYs
* The y position of the top of the path for each display dimension
* @property {Array.<Number>} dimWidths
* The width of each display dimension
* @property {String} svgD
* SVG path "d" attribute string
* @property {ParcatsViewModel} parcatsViewModel
* The parent trace's view model
*/