UNPKG

plotly.js

Version:

The open source javascript graphing library that powers plotly

1,536 lines (1,320 loc) 71.2 kB
'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;