Skip to content

Commit 4862f62

Browse files
authored
Merge pull request #8041 from Lexachoc/scatterternary-spikelines
Add spikelines to ternary plots
2 parents 236e43f + 35c4be4 commit 4862f62

8 files changed

Lines changed: 783 additions & 1 deletion

File tree

‎draftlogs/8041_add.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add spikelines to ternary plots [[#8041](https://github.com/plotly/plotly.js/pull/8041)], with thanks to @Lexachoc for the contribution!

‎src/components/fx/hover.js‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
282282
var plots = fullLayout._plots || [];
283283
var plotinfo = plots[subplot];
284284
var hasCartesian = fullLayout._has('cartesian');
285+
var hasTernary = fullLayout._has('ternary');
285286

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

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

379-
// spikePoints: the set of candidate points we've found to draw spikes to
380+
// cartesian candidate points for drawing spikes
380381
var spikePoints = {
381382
hLinePoint: null,
382383
vLinePoint: null
383384
};
384385

386+
// candidate spike points for ternary subplots
387+
const ternarySpikePoints = {};
388+
385389
// does subplot have one (or more) horizontal traces?
386390
// This is used to determine whether we rotate the labels or not
387391
var hasOneHorizontalTrace = false;
@@ -658,6 +662,36 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
658662
distance = hoverData[0].distance;
659663
}
660664

665+
if (Registry.traceIs(trace, 'ternary') && spikedistance !== 0) {
666+
const ternary = pointData.subplot;
667+
668+
// as in Cartesian, 'hovered data' relies only on the normal hover result, while
669+
// 'data' and 'cursor' need a closest data point
670+
const needsClosestPoint = ['aaxis', 'baxis', 'caxis'].some((name) =>
671+
ternary[name].showspikes && ternary[name].spikesnap !== 'hovered data');
672+
673+
if (needsClosestPoint) {
674+
// may be undefined when no point is found within hoverdistance
675+
let spikePoint = newPoints?.[0];
676+
677+
// reuse the normal hover candidate when available
678+
// otherwise search again using spikedistance
679+
if (!Number.isFinite(spikePoint?.spikeDistance)) {
680+
const spikeData = Lib.extendFlat({}, pointData, {distance: spikedistance, index: false});
681+
const closestPoints = trace._module.hoverPoints(spikeData, xval, yval, 'closest');
682+
spikePoint = closestPoints && closestPoints[0];
683+
}
684+
685+
const previousSpikePoint = ternarySpikePoints[subplotId];
686+
687+
if (spikePoint && spikePoint.index !== undefined && spikePoint.index !== false &&
688+
spikePoint.spikeDistance <= spikedistance &&
689+
(!previousSpikePoint || spikePoint.spikeDistance < previousSpikePoint.spikeDistance)) {
690+
ternarySpikePoints[subplotId] = spikePoint;
691+
}
692+
}
693+
}
694+
661695
// Now if there is range to look in, find the points to draw the spikelines
662696
// Do it only if there is no hoverData
663697
if (hasCartesian && spikedistance !== 0) {
@@ -715,6 +749,21 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
715749

716750
findHoverPoints();
717751

752+
function updateTernarySpikelines() {
753+
fullLayout._hoverlayer.selectAll('.ternary-spikes').remove();
754+
755+
if (spikedistance === 0) return;
756+
for (let i = 0; i < subplots.length; i++) {
757+
const id = subplots[i];
758+
const ternary = fullLayout[id] && fullLayout[id]._subplot;
759+
if (ternary && ternary.drawSpikelines) {
760+
const point = hoverData.find((d) => d.trace.subplot === id &&
761+
d.index !== undefined && d.index !== false && d.spikeDistance <= spikedistance);
762+
ternary.drawSpikelines(point, ternarySpikePoints[id], xvalArray && xvalArray[i], yvalArray && yvalArray[i]);
763+
}
764+
}
765+
}
766+
718767
function selectClosestPoint(pointsData, spikedistance, spikeOnWinning) {
719768
var resultPoint = null;
720769
var minDistance = Infinity;
@@ -829,6 +878,9 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
829878
// See dragelement/unhover.js.
830879
gd._hoverAnywhereActive = true;
831880
}
881+
882+
if (hasTernary) updateTernarySpikelines();
883+
832884
return result;
833885
}
834886

@@ -838,6 +890,8 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
838890
}
839891
}
840892

893+
if (hasTernary) updateTernarySpikelines();
894+
841895
if (
842896
helpers.isXYhover(_mode) &&
843897
hoverData[0].length !== 0 &&

‎src/plots/ternary/layout_attributes.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ var ternaryAxesAttrs = {
6262
'all the minima set to zero.'
6363
].join(' ')
6464
},
65+
// spikelines
66+
showspikes: extendFlat({}, axesAttrs.showspikes, {
67+
description: [
68+
'Determines whether or not spikes (aka droplines) are drawn for this axis.',
69+
'Ternary spikes are accompanied by axis value labels formatted according to `hoverformat`.',
70+
].join(' ')
71+
}),
72+
spikecolor: axesAttrs.spikecolor,
73+
spikethickness: axesAttrs.spikethickness,
74+
spikedash: axesAttrs.spikedash,
75+
spikemode: axesAttrs.spikemode,
76+
spikesnap: axesAttrs.spikesnap,
6577
};
6678

6779
var attrs = module.exports = overrideAll({

‎src/plots/ternary/layout_defaults.js‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ function handleAxisDefaults(containerIn, containerOut, options, ternaryLayoutOut
6565
return Lib.coerce(containerIn, containerOut, axAttrs, attr, dflt);
6666
}
6767

68+
function coerce2(attr, dflt) {
69+
return Lib.coerce2(containerIn, containerOut, axAttrs, attr, dflt);
70+
}
71+
6872
coerce('uirevision', ternaryLayoutOut.uirevision);
6973

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

123127
coerce('hoverformat');
124128
coerce('layer');
129+
130+
var spikecolor = coerce2('spikecolor');
131+
var spikethickness = coerce2('spikethickness');
132+
var spikedash = coerce2('spikedash');
133+
var spikemode = coerce2('spikemode');
134+
var spikesnap = coerce2('spikesnap');
135+
var showSpikes = coerce('showspikes', !!spikecolor || !!spikethickness || !!spikedash || !!spikemode || !!spikesnap);
136+
137+
if(!showSpikes) {
138+
delete containerOut.spikecolor;
139+
delete containerOut.spikethickness;
140+
delete containerOut.spikedash;
141+
delete containerOut.spikemode;
142+
delete containerOut.spikesnap;
143+
}
125144
}

‎src/plots/ternary/ternary.js‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ var strTranslate = Lib.strTranslate;
88
var _ = Lib._;
99
var Color = require('../../components/color');
1010
var Drawing = require('../../components/drawing');
11+
var svgTextUtils = require('../../lib/svg_text_utils');
1112
var setConvert = require('../cartesian/set_convert');
1213
var extendFlat = require('../../lib/extend').extendFlat;
1314
var Plots = require('../plots');
@@ -170,6 +171,141 @@ proto.updateLayers = function(ternaryLayout) {
170171

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

174+
proto.drawSpikelines = function (hoverPoint, spikePoint, cursorXVal, cursorYVal) {
175+
const fullLayout = this.graphDiv._fullLayout;
176+
const axes = [this.aaxis, this.baxis, this.caxis];
177+
178+
if(!axes.some((axis) => axis.showspikes)) return;
179+
180+
const span = this.sum - axes[0].min - axes[1].min - axes[2].min;
181+
182+
const contrastColor = Color.combine(fullLayout[this.id].bgcolor, fullLayout.paper_bgcolor);
183+
184+
let layer;
185+
186+
for(let i = 0; i < axes.length; i++) {
187+
const axis = axes[i];
188+
if(!axis.showspikes) continue;
189+
190+
const snap = axis.spikesnap;
191+
192+
// 'hovered data' uses the current hover point
193+
// 'data' and 'cursor' use the closest point within spikedistance
194+
// 'data' draws at that point, while 'cursor' draws at the cursor position
195+
const selectedPoint = snap === 'hovered data' ? hoverPoint : spikePoint || hoverPoint;
196+
197+
if(!selectedPoint) continue;
198+
199+
const snapToCursor = snap === 'cursor';
200+
201+
if(snapToCursor && (!Number.isFinite(cursorXVal) || !Number.isFinite(cursorYVal))) {
202+
continue;
203+
}
204+
205+
const calcPoint = selectedPoint.cd[selectedPoint.index];
206+
207+
// ternary plots use synthetic x/y axes (x = c - b, y = a), see src/traces/scatterternary/calc.js
208+
// cursor positions are expressed in these coordinates,
209+
// so we need to convert them to a/b/c for the ternary geometry below
210+
const a = snapToCursor ? cursorYVal : calcPoint.a;
211+
const b = snapToCursor ? (this.sum - cursorYVal - cursorXVal) / 2 : calcPoint.b;
212+
const c = snapToCursor ? (this.sum - cursorYVal + cursorXVal) / 2 : calcPoint.c;
213+
214+
if(!this.xaxis.isPtWithinRange({a, b, c})) continue;
215+
216+
if(!layer) {
217+
layer = fullLayout._hoverlayer
218+
.insert('g', ':first-child')
219+
.attr('class', 'ternary-spikes')
220+
.attr('transform', strTranslate(this.x0, this.y0))
221+
.style('pointer-events', 'none');
222+
}
223+
224+
// normalize to the visible ternary range so the spike
225+
// geometry also works when plot is zoomed, and constrain fraction at [0, 1] boundaries
226+
const fa = Lib.constrain((a - axes[0].min) / span, 0, 1);
227+
const fb = Lib.constrain((b - axes[1].min) / span, 0, 1);
228+
const fc = Lib.constrain((c - axes[2].min) / span, 0, 1);
229+
230+
const x = this.w * (fc + fa / 2);
231+
const y = this.h * (1 - fa);
232+
233+
// intersections of the line with the target axis and the opposite triangle edge
234+
const [axisX, axisY, acrossX, acrossY] = [
235+
[this.w * fa / 2, y, this.w * (1 - fa / 2), y], // aaxis
236+
[this.w * (1 - fb), this.h, this.w * (1 - fb) / 2, this.h * fb], // baxis
237+
[this.w * (1 + fc) / 2, this.h * fc, this.w * fc, this.h] // caxis
238+
][i];
239+
240+
const pointColor = selectedPoint.color || axis.color;
241+
const dfltColor = Color.wcagContrast(pointColor, contrastColor) < 1.5
242+
? Color.contrast(contrastColor)
243+
: pointColor;
244+
const color = axis.spikecolor || dfltColor;
245+
const thickness = axis.spikethickness;
246+
const mode = axis.spikemode;
247+
248+
if(mode.indexOf('toaxis') !== -1 || mode.indexOf('across') !== -1) {
249+
const across = mode.indexOf('across') !== -1;
250+
layer.append('line').attr({
251+
class: 'spikeline',
252+
x1: across ? acrossX : x,
253+
y1: across ? acrossY : y,
254+
x2: axisX,
255+
y2: axisY,
256+
'stroke-width': thickness,
257+
'stroke-dasharray': Drawing.dashStyle(axis.spikedash, thickness)
258+
}).call(Color.stroke, color);
259+
}
260+
261+
if(mode.indexOf('marker') !== -1) {
262+
layer.append('circle').attr({
263+
class: 'spikeline',
264+
cx: axisX,
265+
cy: axisY,
266+
r: thickness
267+
}).call(Color.fill, color);
268+
}
269+
270+
// draw the axis value label (similar to commonlabel for Cartesian) at the spike intersection
271+
// note that strictly speaking, spikelines feature shouldn't include this label,
272+
// in Carteisan, the label behavior is controlled by hovermode
273+
// but in ternary plot, the current hovermode options are not fit and it is very intuitive
274+
// that we want the labels to show together with spikelines
275+
const label = layer.append('g')
276+
.attr('class', 'ternary-spikelabel')
277+
.attr('transform', strTranslate(axisX, axisY));
278+
279+
const text = label.append('text')
280+
.attr('text-anchor', 'middle')
281+
.call(Drawing.font, axis.tickfont || fullLayout.font)
282+
.text(Axes.tickText(axis, [a, b, c][i], true).text)
283+
.call(svgTextUtils.convertToTspans, this.graphDiv);
284+
285+
const box = Drawing.bBox(text.node());
286+
const width = box.width + 6;
287+
const height = box.height + 4;
288+
const arrow = Math.min(6, height / 2, width / 2);
289+
const left = i === 0 ? -arrow - width : i === 1 ? -width / 2 : 0;
290+
const top = i === 0 ? -height / 2 : i === 1 ? arrow : -height;
291+
292+
let outline;
293+
if(i === 0) {
294+
outline = `M0,0L${-arrow},${-arrow}V${top}H${left}v${height}H${-arrow}V${arrow}Z`;
295+
} else if(i === 1) {
296+
outline = `M0,0L${arrow},${arrow}H${width / 2}v${height}H${left}V${arrow}H${-arrow}Z`;
297+
} else {
298+
outline = `M0,0V${top}h${width}V0Z`;
299+
}
300+
301+
label.insert('path', 'text').attr('d', outline).call(Color.fill, color);
302+
text.call(svgTextUtils.positionText,
303+
left + width / 2 - box.left - box.width / 2,
304+
top + height / 2 - box.top - box.height / 2);
305+
text.call(Color.fill, Color.contrast(color));
306+
}
307+
};
308+
173309
proto.adjustLayout = function(ternaryLayout, graphSize) {
174310
var _this = this;
175311
var domain = ternaryLayout.domain;

0 commit comments

Comments
 (0)