plotly.js
Version:
The open source javascript graphing library that powers plotly
1,536 lines (1,320 loc) • 71.2 kB
JavaScript
'use strict';
var d3 = require('@plotly/d3');
var interpolateNumber = require('d3-interpolate').interpolateNumber;
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 isStatic = graphDiv._context.staticPlot;
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', isStatic ? 'none' : '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');
// 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('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: bandColorCount,
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;