Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/8041_add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add spikelines to ternary plots [[#8041](https://github.com/plotly/plotly.js/pull/8041)]
56 changes: 55 additions & 1 deletion src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
var plots = fullLayout._plots || [];
var plotinfo = plots[subplot];
var hasCartesian = fullLayout._has('cartesian');
var hasTernary = fullLayout._has('ternary');

var hovermode = evt.hovermode || fullLayout.hovermode;
var hovermodeHasX = (hovermode || '').charAt(0) === 'x';
Expand Down Expand Up @@ -376,12 +377,15 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {

var itemnum, curvenum, cd, trace, subplotId, subploti, _mode, xval, yval, pointData, closedataPreviousLength;

// spikePoints: the set of candidate points we've found to draw spikes to
// cartesian candidate points for drawing spikes
var spikePoints = {
hLinePoint: null,
vLinePoint: null
};

// candidate spike points for ternary subplots
const ternarySpikePoints = {};

// does subplot have one (or more) horizontal traces?
// This is used to determine whether we rotate the labels or not
var hasOneHorizontalTrace = false;
Expand Down Expand Up @@ -658,6 +662,36 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
distance = hoverData[0].distance;
}

if (Registry.traceIs(trace, 'ternary') && spikedistance !== 0) {
const ternary = pointData.subplot;

// as in Cartesian, 'hovered data' relies only on the normal hover result, while
// 'data' and 'cursor' need a closest data point
const needsClosestPoint = ['aaxis', 'baxis', 'caxis'].some((name) =>
ternary[name].showspikes && ternary[name].spikesnap !== 'hovered data');

if (needsClosestPoint) {
// may be undefined when no point is found within hoverdistance
let spikePoint = newPoints?.[0];

// reuse the normal hover candidate when available
// otherwise search again using spikedistance
if (!Number.isFinite(spikePoint?.spikeDistance)) {
const spikeData = Lib.extendFlat({}, pointData, {distance: spikedistance, index: false});
const closestPoints = trace._module.hoverPoints(spikeData, xval, yval, 'closest');
spikePoint = closestPoints && closestPoints[0];
}

const previousSpikePoint = ternarySpikePoints[subplotId];

if (spikePoint && spikePoint.index !== undefined && spikePoint.index !== false &&
spikePoint.spikeDistance <= spikedistance &&
(!previousSpikePoint || spikePoint.spikeDistance < previousSpikePoint.spikeDistance)) {
ternarySpikePoints[subplotId] = spikePoint;
}
}
}

// Now if there is range to look in, find the points to draw the spikelines
// Do it only if there is no hoverData
if (hasCartesian && spikedistance !== 0) {
Expand Down Expand Up @@ -715,6 +749,21 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {

findHoverPoints();

function updateTernarySpikelines() {
fullLayout._hoverlayer.selectAll('.ternary-spikes').remove();

if (spikedistance === 0) return;
for (let i = 0; i < subplots.length; i++) {
const id = subplots[i];
const ternary = fullLayout[id] && fullLayout[id]._subplot;
if (ternary && ternary.drawSpikelines) {
const point = hoverData.find((d) => d.trace.subplot === id &&
d.index !== undefined && d.index !== false && d.spikeDistance <= spikedistance);
ternary.drawSpikelines(point, ternarySpikePoints[id], xvalArray && xvalArray[i], yvalArray && yvalArray[i]);
}
}
}

function selectClosestPoint(pointsData, spikedistance, spikeOnWinning) {
var resultPoint = null;
var minDistance = Infinity;
Expand Down Expand Up @@ -829,6 +878,9 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
// See dragelement/unhover.js.
gd._hoverAnywhereActive = true;
}

if (hasTernary) updateTernarySpikelines();

return result;
}

Expand All @@ -838,6 +890,8 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
}
}

if (hasTernary) updateTernarySpikelines();

if (
helpers.isXYhover(_mode) &&
hoverData[0].length !== 0 &&
Expand Down
12 changes: 12 additions & 0 deletions src/plots/ternary/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ var ternaryAxesAttrs = {
'all the minima set to zero.'
].join(' ')
},
// spikelines
showspikes: extendFlat({}, axesAttrs.showspikes, {
description: [
'Determines whether or not spikes (aka droplines) are drawn for this axis.',
'Ternary spikes are accompanied by axis value labels formatted according to `hoverformat`.',
].join(' ')
}),
spikecolor: axesAttrs.spikecolor,
spikethickness: axesAttrs.spikethickness,
spikedash: axesAttrs.spikedash,
spikemode: axesAttrs.spikemode,
spikesnap: axesAttrs.spikesnap,
};

var attrs = module.exports = overrideAll({
Expand Down
19 changes: 19 additions & 0 deletions src/plots/ternary/layout_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ function handleAxisDefaults(containerIn, containerOut, options, ternaryLayoutOut
return Lib.coerce(containerIn, containerOut, axAttrs, attr, dflt);
}

function coerce2(attr, dflt) {
return Lib.coerce2(containerIn, containerOut, axAttrs, attr, dflt);
}

coerce('uirevision', ternaryLayoutOut.uirevision);

containerOut.type = 'linear'; // no other types allowed for ternary
Expand Down Expand Up @@ -122,4 +126,19 @@ function handleAxisDefaults(containerIn, containerOut, options, ternaryLayoutOut

coerce('hoverformat');
coerce('layer');

var spikecolor = coerce2('spikecolor');
var spikethickness = coerce2('spikethickness');
var spikedash = coerce2('spikedash');
var spikemode = coerce2('spikemode');
var spikesnap = coerce2('spikesnap');
var showSpikes = coerce('showspikes', !!spikecolor || !!spikethickness || !!spikedash || !!spikemode || !!spikesnap);

if(!showSpikes) {
delete containerOut.spikecolor;
delete containerOut.spikethickness;
delete containerOut.spikedash;
delete containerOut.spikemode;
delete containerOut.spikesnap;
}
}
136 changes: 136 additions & 0 deletions src/plots/ternary/ternary.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ var strTranslate = Lib.strTranslate;
var _ = Lib._;
var Color = require('../../components/color');
var Drawing = require('../../components/drawing');
var svgTextUtils = require('../../lib/svg_text_utils');
var setConvert = require('../cartesian/set_convert');
var extendFlat = require('../../lib/extend').extendFlat;
var Plots = require('../plots');
Expand Down Expand Up @@ -170,6 +171,141 @@ proto.updateLayers = function(ternaryLayout) {

var whRatio = Math.sqrt(4 / 3);

proto.drawSpikelines = function (hoverPoint, spikePoint, cursorXVal, cursorYVal) {
const fullLayout = this.graphDiv._fullLayout;
const axes = [this.aaxis, this.baxis, this.caxis];

if(!axes.some((axis) => axis.showspikes)) return;

const span = this.sum - axes[0].min - axes[1].min - axes[2].min;

const contrastColor = Color.combine(fullLayout[this.id].bgcolor, fullLayout.paper_bgcolor);

let layer;

for(let i = 0; i < axes.length; i++) {
const axis = axes[i];
if(!axis.showspikes) continue;

const snap = axis.spikesnap;

// 'hovered data' uses the current hover point
// 'data' and 'cursor' use the closest point within spikedistance
// 'data' draws at that point, while 'cursor' draws at the cursor position
const selectedPoint = snap === 'hovered data' ? hoverPoint : spikePoint || hoverPoint;

if(!selectedPoint) continue;

const snapToCursor = snap === 'cursor';

if(snapToCursor && (!Number.isFinite(cursorXVal) || !Number.isFinite(cursorYVal))) {
continue;
}

const calcPoint = selectedPoint.cd[selectedPoint.index];

// ternary plots use synthetic x/y axes (x = c - b, y = a), see src/traces/scatterternary/calc.js
// cursor positions are expressed in these coordinates,
// so we need to convert them to a/b/c for the ternary geometry below
const a = snapToCursor ? cursorYVal : calcPoint.a;
const b = snapToCursor ? (this.sum - cursorYVal - cursorXVal) / 2 : calcPoint.b;
const c = snapToCursor ? (this.sum - cursorYVal + cursorXVal) / 2 : calcPoint.c;

if(!this.xaxis.isPtWithinRange({a, b, c})) continue;

if(!layer) {
layer = fullLayout._hoverlayer
.insert('g', ':first-child')
.attr('class', 'ternary-spikes')
.attr('transform', strTranslate(this.x0, this.y0))
.style('pointer-events', 'none');
}

// normalize to the visible ternary range so the spike
// geometry also works when plot is zoomed, and constrain fraction at [0, 1] boundaries
const fa = Lib.constrain((a - axes[0].min) / span, 0, 1);
const fb = Lib.constrain((b - axes[1].min) / span, 0, 1);
const fc = Lib.constrain((c - axes[2].min) / span, 0, 1);

const x = this.w * (fc + fa / 2);
const y = this.h * (1 - fa);

// intersections of the line with the target axis and the opposite triangle edge
const [axisX, axisY, acrossX, acrossY] = [
[this.w * fa / 2, y, this.w * (1 - fa / 2), y], // aaxis
[this.w * (1 - fb), this.h, this.w * (1 - fb) / 2, this.h * fb], // baxis
[this.w * (1 + fc) / 2, this.h * fc, this.w * fc, this.h] // caxis
][i];

const pointColor = selectedPoint.color || axis.color;
const dfltColor = Color.wcagContrast(pointColor, contrastColor) < 1.5
? Color.contrast(contrastColor)
: pointColor;
const color = axis.spikecolor || dfltColor;
const thickness = axis.spikethickness;
const mode = axis.spikemode;

if(mode.indexOf('toaxis') !== -1 || mode.indexOf('across') !== -1) {
const across = mode.indexOf('across') !== -1;
layer.append('line').attr({
class: 'spikeline',
x1: across ? acrossX : x,
y1: across ? acrossY : y,
x2: axisX,
y2: axisY,
'stroke-width': thickness,
'stroke-dasharray': Drawing.dashStyle(axis.spikedash, thickness)
}).call(Color.stroke, color);
}

if(mode.indexOf('marker') !== -1) {
layer.append('circle').attr({
class: 'spikeline',
cx: axisX,
cy: axisY,
r: thickness
}).call(Color.fill, color);
}

// draw the axis value label (similar to commonlabel for Cartesian) at the spike intersection
// note that strictly speaking, spikelines feature shouldn't include this label,
// in Carteisan, the label behavior is controlled by hovermode
// but in ternary plot, the current hovermode options are not fit and it is very intuitive
// that we want the labels to show together with spikelines
const label = layer.append('g')
.attr('class', 'ternary-spikelabel')
.attr('transform', strTranslate(axisX, axisY));

const text = label.append('text')
.attr('text-anchor', 'middle')
.call(Drawing.font, axis.tickfont || fullLayout.font)
.text(Axes.tickText(axis, [a, b, c][i], true).text)
.call(svgTextUtils.convertToTspans, this.graphDiv);

const box = Drawing.bBox(text.node());
const width = box.width + 6;
const height = box.height + 4;
const arrow = Math.min(6, height / 2, width / 2);
const left = i === 0 ? -arrow - width : i === 1 ? -width / 2 : 0;
const top = i === 0 ? -height / 2 : i === 1 ? arrow : -height;

let outline;
if(i === 0) {
outline = `M0,0L${-arrow},${-arrow}V${top}H${left}v${height}H${-arrow}V${arrow}Z`;
} else if(i === 1) {
outline = `M0,0L${arrow},${arrow}H${width / 2}v${height}H${left}V${arrow}H${-arrow}Z`;
} else {
outline = `M0,0V${top}h${width}V0Z`;
}

label.insert('path', 'text').attr('d', outline).call(Color.fill, color);
text.call(svgTextUtils.positionText,
left + width / 2 - box.left - box.width / 2,
top + height / 2 - box.top - box.height / 2);
text.call(Color.fill, Color.contrast(color));
}
};

proto.adjustLayout = function(ternaryLayout, graphSize) {
var _this = this;
var domain = ternaryLayout.domain;
Expand Down
Loading
Loading