From eb4cff81fd82b476b607746941f3d0d549137858 Mon Sep 17 00:00:00 2001 From: Lexachoc <20377719+Lexachoc@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:39:16 +0200 Subject: [PATCH 1/4] add spikelines to scatterternary --- src/components/fx/hover.js | 56 ++++++++- src/plots/ternary/layout_attributes.js | 7 ++ src/plots/ternary/layout_defaults.js | 8 ++ src/plots/ternary/ternary.js | 130 ++++++++++++++++++++ src/types/generated/schema.d.ts | 81 +++++++++++++ test/plot-schema.json | 162 +++++++++++++++++++++++++ 6 files changed, 443 insertions(+), 1 deletion(-) diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 92c7dd8b784..bb5dc6970c3 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -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'; @@ -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; @@ -658,6 +662,36 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { distance = hoverData[0].distance; } + // TODO need to support 'scatterternarygl' in future + if (trace.type === 'scatterternary' && 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) { + let spikePoint = pointData; + + // 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) { @@ -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; @@ -829,6 +878,9 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { // See dragelement/unhover.js. gd._hoverAnywhereActive = true; } + + if (hasTernary) updateTernarySpikelines(); + return result; } @@ -838,6 +890,8 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { } } + if (hasTernary) updateTernarySpikelines(); + if ( helpers.isXYhover(_mode) && hoverData[0].length !== 0 && diff --git a/src/plots/ternary/layout_attributes.js b/src/plots/ternary/layout_attributes.js index abc6fe29f6f..28946c4c13e 100644 --- a/src/plots/ternary/layout_attributes.js +++ b/src/plots/ternary/layout_attributes.js @@ -62,6 +62,13 @@ var ternaryAxesAttrs = { 'all the minima set to zero.' ].join(' ') }, + // spikelines + showspikes: axesAttrs.showspikes, + spikecolor: axesAttrs.spikecolor, + spikethickness: axesAttrs.spikethickness, + spikedash: axesAttrs.spikedash, + spikemode: axesAttrs.spikemode, + spikesnap: axesAttrs.spikesnap, }; var attrs = module.exports = overrideAll({ diff --git a/src/plots/ternary/layout_defaults.js b/src/plots/ternary/layout_defaults.js index 4afece35e7c..3a0d109b132 100644 --- a/src/plots/ternary/layout_defaults.js +++ b/src/plots/ternary/layout_defaults.js @@ -122,4 +122,12 @@ function handleAxisDefaults(containerIn, containerOut, options, ternaryLayoutOut coerce('hoverformat'); coerce('layer'); + + if(coerce('showspikes')) { + coerce('spikecolor'); + coerce('spikethickness'); + coerce('spikedash'); + coerce('spikemode'); + coerce('spikesnap'); + } } diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 836bee45a8f..38b0ef196ce 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -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'); @@ -170,6 +171,135 @@ 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; + + 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 color = axis.spikecolor || selectedPoint.color || axis.color; + 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; diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 4766d954d4c..b90274014df 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -14751,6 +14751,11 @@ export interface TernaryLayout { * @default true */ showline?: boolean; + /** + * Determines whether or not spikes (aka droplines) are drawn for this axis. Note that spikes will never be drawn when `hovermode` is *false*. + * @default false + */ + showspikes?: boolean; /** * Determines whether or not the tick labels are drawn. * @default true @@ -14766,6 +14771,28 @@ export interface TernaryLayout { * @default 'all' */ showticksuffix?: 'all' | 'first' | 'last' | 'none'; + /** Sets the spike color. If undefined, will use the series color */ + spikecolor?: Color; + /** + * Sets the dash style of lines. Set to a dash type string (*solid*, *dot*, *dash*, *longdash*, *dashdot*, or *longdashdot*) or a dash length list in px (eg *5px,10px,2px,2px*). + * @default 'dash' + */ + spikedash?: Dash; + /** + * Determines the drawing mode for the spike line If *toaxis*, the line is drawn from the data point to the axis the series is plotted on. If *across*, the line is drawn across the entire plot area, and supercedes *toaxis*. If *marker*, then a marker dot is drawn on the axis the series is plotted on + * @default 'toaxis' + */ + spikemode?: 'toaxis' | 'across' | 'marker' | (string & {}); + /** + * Determines whether spikelines are stuck to the cursor or to the closest datapoints. + * @default 'hovered data' + */ + spikesnap?: 'data' | 'cursor' | 'hovered data'; + /** + * Sets the width (in px) of the zero line. + * @default 3 + */ + spikethickness?: number; /** * Sets the placement of the first tick on this axis. Use with `dtick`. If the axis `type` is *log*, then you must take the log of your starting tick (e.g. to set the starting tick to 100, set the `tick0` to 2) except when `dtick`=*L* (see `dtick` for more info). If the axis `type` is *date*, it should be a date string, like date data. If the axis `type` is *category*, it should be a number, using the scale where each category is assigned a serial number from zero in the order it appears. * Setting this also sets: tickmode = "linear" @@ -14913,6 +14940,11 @@ export interface TernaryLayout { * @default true */ showline?: boolean; + /** + * Determines whether or not spikes (aka droplines) are drawn for this axis. Note that spikes will never be drawn when `hovermode` is *false*. + * @default false + */ + showspikes?: boolean; /** * Determines whether or not the tick labels are drawn. * @default true @@ -14928,6 +14960,28 @@ export interface TernaryLayout { * @default 'all' */ showticksuffix?: 'all' | 'first' | 'last' | 'none'; + /** Sets the spike color. If undefined, will use the series color */ + spikecolor?: Color; + /** + * Sets the dash style of lines. Set to a dash type string (*solid*, *dot*, *dash*, *longdash*, *dashdot*, or *longdashdot*) or a dash length list in px (eg *5px,10px,2px,2px*). + * @default 'dash' + */ + spikedash?: Dash; + /** + * Determines the drawing mode for the spike line If *toaxis*, the line is drawn from the data point to the axis the series is plotted on. If *across*, the line is drawn across the entire plot area, and supercedes *toaxis*. If *marker*, then a marker dot is drawn on the axis the series is plotted on + * @default 'toaxis' + */ + spikemode?: 'toaxis' | 'across' | 'marker' | (string & {}); + /** + * Determines whether spikelines are stuck to the cursor or to the closest datapoints. + * @default 'hovered data' + */ + spikesnap?: 'data' | 'cursor' | 'hovered data'; + /** + * Sets the width (in px) of the zero line. + * @default 3 + */ + spikethickness?: number; /** * Sets the placement of the first tick on this axis. Use with `dtick`. If the axis `type` is *log*, then you must take the log of your starting tick (e.g. to set the starting tick to 100, set the `tick0` to 2) except when `dtick`=*L* (see `dtick` for more info). If the axis `type` is *date*, it should be a date string, like date data. If the axis `type` is *category*, it should be a number, using the scale where each category is assigned a serial number from zero in the order it appears. * Setting this also sets: tickmode = "linear" @@ -15080,6 +15134,11 @@ export interface TernaryLayout { * @default true */ showline?: boolean; + /** + * Determines whether or not spikes (aka droplines) are drawn for this axis. Note that spikes will never be drawn when `hovermode` is *false*. + * @default false + */ + showspikes?: boolean; /** * Determines whether or not the tick labels are drawn. * @default true @@ -15095,6 +15154,28 @@ export interface TernaryLayout { * @default 'all' */ showticksuffix?: 'all' | 'first' | 'last' | 'none'; + /** Sets the spike color. If undefined, will use the series color */ + spikecolor?: Color; + /** + * Sets the dash style of lines. Set to a dash type string (*solid*, *dot*, *dash*, *longdash*, *dashdot*, or *longdashdot*) or a dash length list in px (eg *5px,10px,2px,2px*). + * @default 'dash' + */ + spikedash?: Dash; + /** + * Determines the drawing mode for the spike line If *toaxis*, the line is drawn from the data point to the axis the series is plotted on. If *across*, the line is drawn across the entire plot area, and supercedes *toaxis*. If *marker*, then a marker dot is drawn on the axis the series is plotted on + * @default 'toaxis' + */ + spikemode?: 'toaxis' | 'across' | 'marker' | (string & {}); + /** + * Determines whether spikelines are stuck to the cursor or to the closest datapoints. + * @default 'hovered data' + */ + spikesnap?: 'data' | 'cursor' | 'hovered data'; + /** + * Sets the width (in px) of the zero line. + * @default 3 + */ + spikethickness?: number; /** * Sets the placement of the first tick on this axis. Use with `dtick`. If the axis `type` is *log*, then you must take the log of your starting tick (e.g. to set the starting tick to 100, set the `tick0` to 2) except when `dtick`=*L* (see `dtick` for more info). If the axis `type` is *date*, it should be a date string, like date data. If the axis `type` is *category*, it should be a number, using the scale where each category is assigned a serial number from zero in the order it appears. * Setting this also sets: tickmode = "linear" diff --git a/test/plot-schema.json b/test/plot-schema.json index 39767518c96..4bb2f5d8ea9 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -10785,6 +10785,12 @@ "editType": "plot", "valType": "boolean" }, + "showspikes": { + "description": "Determines whether or not spikes (aka droplines) are drawn for this axis. Note that spikes will never be drawn when `hovermode` is *false*.", + "dflt": false, + "editType": "plot", + "valType": "boolean" + }, "showticklabels": { "description": "Determines whether or not the tick labels are drawn.", "dflt": true, @@ -10815,6 +10821,54 @@ "none" ] }, + "spikecolor": { + "description": "Sets the spike color. If undefined, will use the series color", + "dflt": null, + "editType": "plot", + "valType": "color" + }, + "spikedash": { + "description": "Sets the dash style of lines. Set to a dash type string (*solid*, *dot*, *dash*, *longdash*, *dashdot*, or *longdashdot*) or a dash length list in px (eg *5px,10px,2px,2px*).", + "dflt": "dash", + "editType": "plot", + "valType": "string", + "values": [ + "solid", + "dot", + "dash", + "longdash", + "dashdot", + "longdashdot" + ] + }, + "spikemode": { + "description": "Determines the drawing mode for the spike line If *toaxis*, the line is drawn from the data point to the axis the series is plotted on. If *across*, the line is drawn across the entire plot area, and supercedes *toaxis*. If *marker*, then a marker dot is drawn on the axis the series is plotted on", + "dflt": "toaxis", + "editType": "plot", + "flags": [ + "toaxis", + "across", + "marker" + ], + "valType": "flaglist" + }, + "spikesnap": { + "description": "Determines whether spikelines are stuck to the cursor or to the closest datapoints.", + "dflt": "hovered data", + "editType": "plot", + "valType": "enumerated", + "values": [ + "data", + "cursor", + "hovered data" + ] + }, + "spikethickness": { + "description": "Sets the width (in px) of the zero line.", + "dflt": 3, + "editType": "plot", + "valType": "number" + }, "tick0": { "description": "Sets the placement of the first tick on this axis. Use with `dtick`. If the axis `type` is *log*, then you must take the log of your starting tick (e.g. to set the starting tick to 100, set the `tick0` to 2) except when `dtick`=*L* (see `dtick` for more info). If the axis `type` is *date*, it should be a date string, like date data. If the axis `type` is *category*, it should be a number, using the scale where each category is assigned a serial number from zero in the order it appears.", "editType": "plot", @@ -11289,6 +11343,12 @@ "editType": "plot", "valType": "boolean" }, + "showspikes": { + "description": "Determines whether or not spikes (aka droplines) are drawn for this axis. Note that spikes will never be drawn when `hovermode` is *false*.", + "dflt": false, + "editType": "plot", + "valType": "boolean" + }, "showticklabels": { "description": "Determines whether or not the tick labels are drawn.", "dflt": true, @@ -11319,6 +11379,54 @@ "none" ] }, + "spikecolor": { + "description": "Sets the spike color. If undefined, will use the series color", + "dflt": null, + "editType": "plot", + "valType": "color" + }, + "spikedash": { + "description": "Sets the dash style of lines. Set to a dash type string (*solid*, *dot*, *dash*, *longdash*, *dashdot*, or *longdashdot*) or a dash length list in px (eg *5px,10px,2px,2px*).", + "dflt": "dash", + "editType": "plot", + "valType": "string", + "values": [ + "solid", + "dot", + "dash", + "longdash", + "dashdot", + "longdashdot" + ] + }, + "spikemode": { + "description": "Determines the drawing mode for the spike line If *toaxis*, the line is drawn from the data point to the axis the series is plotted on. If *across*, the line is drawn across the entire plot area, and supercedes *toaxis*. If *marker*, then a marker dot is drawn on the axis the series is plotted on", + "dflt": "toaxis", + "editType": "plot", + "flags": [ + "toaxis", + "across", + "marker" + ], + "valType": "flaglist" + }, + "spikesnap": { + "description": "Determines whether spikelines are stuck to the cursor or to the closest datapoints.", + "dflt": "hovered data", + "editType": "plot", + "valType": "enumerated", + "values": [ + "data", + "cursor", + "hovered data" + ] + }, + "spikethickness": { + "description": "Sets the width (in px) of the zero line.", + "dflt": 3, + "editType": "plot", + "valType": "number" + }, "tick0": { "description": "Sets the placement of the first tick on this axis. Use with `dtick`. If the axis `type` is *log*, then you must take the log of your starting tick (e.g. to set the starting tick to 100, set the `tick0` to 2) except when `dtick`=*L* (see `dtick` for more info). If the axis `type` is *date*, it should be a date string, like date data. If the axis `type` is *category*, it should be a number, using the scale where each category is assigned a serial number from zero in the order it appears.", "editType": "plot", @@ -11799,6 +11907,12 @@ "editType": "plot", "valType": "boolean" }, + "showspikes": { + "description": "Determines whether or not spikes (aka droplines) are drawn for this axis. Note that spikes will never be drawn when `hovermode` is *false*.", + "dflt": false, + "editType": "plot", + "valType": "boolean" + }, "showticklabels": { "description": "Determines whether or not the tick labels are drawn.", "dflt": true, @@ -11829,6 +11943,54 @@ "none" ] }, + "spikecolor": { + "description": "Sets the spike color. If undefined, will use the series color", + "dflt": null, + "editType": "plot", + "valType": "color" + }, + "spikedash": { + "description": "Sets the dash style of lines. Set to a dash type string (*solid*, *dot*, *dash*, *longdash*, *dashdot*, or *longdashdot*) or a dash length list in px (eg *5px,10px,2px,2px*).", + "dflt": "dash", + "editType": "plot", + "valType": "string", + "values": [ + "solid", + "dot", + "dash", + "longdash", + "dashdot", + "longdashdot" + ] + }, + "spikemode": { + "description": "Determines the drawing mode for the spike line If *toaxis*, the line is drawn from the data point to the axis the series is plotted on. If *across*, the line is drawn across the entire plot area, and supercedes *toaxis*. If *marker*, then a marker dot is drawn on the axis the series is plotted on", + "dflt": "toaxis", + "editType": "plot", + "flags": [ + "toaxis", + "across", + "marker" + ], + "valType": "flaglist" + }, + "spikesnap": { + "description": "Determines whether spikelines are stuck to the cursor or to the closest datapoints.", + "dflt": "hovered data", + "editType": "plot", + "valType": "enumerated", + "values": [ + "data", + "cursor", + "hovered data" + ] + }, + "spikethickness": { + "description": "Sets the width (in px) of the zero line.", + "dflt": 3, + "editType": "plot", + "valType": "number" + }, "tick0": { "description": "Sets the placement of the first tick on this axis. Use with `dtick`. If the axis `type` is *log*, then you must take the log of your starting tick (e.g. to set the starting tick to 100, set the `tick0` to 2) except when `dtick`=*L* (see `dtick` for more info). If the axis `type` is *date*, it should be a date string, like date data. If the axis `type` is *category*, it should be a number, using the scale where each category is assigned a serial number from zero in the order it appears.", "editType": "plot", From 51d7c94a66a0854e3009071856575c5c9439c3de Mon Sep 17 00:00:00 2001 From: Lexachoc <20377719+Lexachoc@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:53:23 +0200 Subject: [PATCH 2/4] update schema --- src/types/generated/schema.d.ts | 6 +++--- test/plot-schema.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index b90274014df..f732ce744e9 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -14789,7 +14789,7 @@ export interface TernaryLayout { */ spikesnap?: 'data' | 'cursor' | 'hovered data'; /** - * Sets the width (in px) of the zero line. + * Sets the spike line width in pixels. * @default 3 */ spikethickness?: number; @@ -14978,7 +14978,7 @@ export interface TernaryLayout { */ spikesnap?: 'data' | 'cursor' | 'hovered data'; /** - * Sets the width (in px) of the zero line. + * Sets the spike line width in pixels. * @default 3 */ spikethickness?: number; @@ -15172,7 +15172,7 @@ export interface TernaryLayout { */ spikesnap?: 'data' | 'cursor' | 'hovered data'; /** - * Sets the width (in px) of the zero line. + * Sets the spike line width in pixels. * @default 3 */ spikethickness?: number; diff --git a/test/plot-schema.json b/test/plot-schema.json index 4bb2f5d8ea9..329233b9cae 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -10864,7 +10864,7 @@ ] }, "spikethickness": { - "description": "Sets the width (in px) of the zero line.", + "description": "Sets the spike line width in pixels.", "dflt": 3, "editType": "plot", "valType": "number" @@ -11422,7 +11422,7 @@ ] }, "spikethickness": { - "description": "Sets the width (in px) of the zero line.", + "description": "Sets the spike line width in pixels.", "dflt": 3, "editType": "plot", "valType": "number" @@ -11986,7 +11986,7 @@ ] }, "spikethickness": { - "description": "Sets the width (in px) of the zero line.", + "description": "Sets the spike line width in pixels.", "dflt": 3, "editType": "plot", "valType": "number" From 4bc4aaa1ee75bad6d8e1a86aa391481a5593544f Mon Sep 17 00:00:00 2001 From: Lexachoc <20377719+Lexachoc@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:58:40 +0200 Subject: [PATCH 3/4] add ternary spikelines test --- test/jasmine/tests/ternary_test.js | 291 +++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) diff --git a/test/jasmine/tests/ternary_test.js b/test/jasmine/tests/ternary_test.js index 0a24c219506..9c1e0a341ba 100644 --- a/test/jasmine/tests/ternary_test.js +++ b/test/jasmine/tests/ternary_test.js @@ -706,6 +706,274 @@ describe('ternary plots when css transform is present', function() { }); }); +describe('ternary spikelines', function() { + 'use strict'; + + var gd; + + var data = [{ + type: 'scatterternary', + mode: 'markers', + a: [49], + b: [43], + c: [8] + }]; + + var layout = { + ternary: { + sum: 100, + aaxis: {showspikes: true, spikecolor: '#444', hoverformat: '.1f'}, + baxis: {showspikes: true, hoverformat: '.1f'}, + caxis: {showspikes: true, hoverformat: '.1f'}, + } + }; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function hoverAt(xval, yval, subplot) { + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: xval, yval: yval}, subplot || 'ternary'); + } + + it('draws toaxis spikes and labels for all three axes and removes them on unhover', function(done) { + Plotly.newPlot(gd, data, layout).then(function() { + // x = c - b = 8 - 43 = -35 + // y = a = 49 + hoverAt(-35, 49); + + var subplot = gd._fullLayout.ternary._subplot; + var lines = gd.querySelectorAll('.ternary-spikes line'); + expect(lines.length).toBe(3); + + // spike intersections with each axis, expressed as fractions of subplot width/height. + // fa = 49 / 100 = 0.49 + // fb = 43 / 100 = 0.43 + // fc = 8 / 100 = 0.08 + var axisEnds = [ + // [x fraction of subplot width, y fraction of subplot height] + [0.245, 0.51], // aaxis: x = fa/2 = 0.245, y = 0.51 + [0.57, 1], // baxis: x = 1 - fb = 0.57, y = 1 + [0.54, 0.08] // caxis: x = (1 + fc) / 2 = 0.54, y = fc = 0.08 + ]; + + // x1 = w * (fc + fa / 2) + // y1 = h * (1 - fa) + for(var i = 0; i < lines.length; i++) { + expect(+lines[i].getAttribute('x1')).toBeCloseTo(subplot.w * 0.325, 5); + expect(+lines[i].getAttribute('y1')).toBeCloseTo(subplot.h * 0.51, 5); + expect(+lines[i].getAttribute('x2')).toBeCloseTo(subplot.w * axisEnds[i][0], 5); + expect(+lines[i].getAttribute('y2')).toBeCloseTo(subplot.h * axisEnds[i][1], 5); + } + + expect(Array.from(gd.querySelectorAll('.ternary-spikelabel text'), function(el) { + return el.textContent; + })).toEqual(['49.0', '43.0', '8.0']); + + var labels = gd.querySelectorAll('.ternary-spikelabel'); + + for(var j = 0; j < labels.length; j++) { + var transform = labels[j].transform.baseVal.consolidate().matrix; + expect(transform.e).toBeCloseTo(+lines[j].getAttribute('x2'), 4); + expect(transform.f).toBeCloseTo(+lines[j].getAttribute('y2'), 4); + } + + Plotly.Fx.unhover(gd); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + }) + .then(done, done.fail); + }); + + it('keeps redrawn spikes behind the hover label', function(done) { + Plotly.newPlot(gd, data, layout).then(function() { + hoverAt(-35, 49); + expect(gd.querySelector('.hovertext')).not.toBeNull(); + + hoverAt(-35, 49); + + var spikes = gd.querySelector('.ternary-spikes'); + var hoverLabel = gd.querySelector('.hovertext'); + expect(spikes.parentNode).toBe(hoverLabel.parentNode); + expect(spikes.compareDocumentPosition(hoverLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }) + .then(done, done.fail); + }); + + it('supports spikesnap "cursor" with axis minima and spikemode "across+marker"', function(done) { + var options = Lib.extendDeep({}, layout, { + ternary: { + aaxis: { + min: 10, + spikesnap: 'cursor', + spikemode: 'across+marker', + }, + // only observe A spike + baxis: {min: 20, showspikes: false}, + caxis: {min: 5, showspikes: false}, + } + }); + + Plotly.newPlot(gd, data, options).then(function() { + hoverAt(-10, 36); + + var subplot = gd._fullLayout.ternary._subplot; + var line = gd.querySelector('.ternary-spikes line'); + expect(+line.getAttribute('x1')).toBeCloseTo(subplot.w * 0.8, 5); + expect(+line.getAttribute('x2')).toBeCloseTo(subplot.w * 0.2, 5); + expect(+line.getAttribute('y2')).toBeCloseTo(subplot.h * 0.6, 5); + expect(gd.querySelectorAll('.ternary-spikes circle').length).toBe(1); + expect(gd.querySelector('.ternary-spikelabel text').textContent).toBe('36.0'); + + // this synthetic x/y axes position converts to an invalid ternary point. + hoverAt(100, 90); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + }) + .then(done, done.fail); + }); + + it('respects hoverdistance and spikedistance across spikesnap modes', function(done) { + var options = Lib.extendDeep({}, layout, { + hoverdistance: 1, + spikedistance: 20, + ternary: { + aaxis: {spikesnap: 'hovered data'}, + baxis: {showspikes: false}, + caxis: {showspikes: false} + } + }); + + var nearXVal; + var farXVal; + + Plotly.newPlot(gd, data, options).then(function() { + var subplot = gd._fullLayout.ternary._subplot; + var pointPx = subplot.xaxis.c2p(-35); + + // keep the cursor outside hoverdistance but inside/outside spikedistance + nearXVal = subplot.xaxis.p2c(pointPx + 15); + farXVal = subplot.xaxis.p2c(pointPx + 30); + + hoverAt(nearXVal, 49); + expect(gd.querySelectorAll('.hovertext').length).toBe(0); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + + return Plotly.relayout(gd, 'ternary.aaxis.spikesnap', 'data'); + }) + .then(function() { + hoverAt(nearXVal, 49); + expect(gd.querySelectorAll('.hovertext').length).toBe(0); + expect(gd.querySelectorAll('.ternary-spikes line').length).toBe(1); + + return Plotly.relayout(gd, 'ternary.aaxis.spikesnap', 'cursor'); + }) + .then(function() { + hoverAt(nearXVal, 49); + expect(gd.querySelectorAll('.hovertext').length).toBe(0); + expect(gd.querySelectorAll('.ternary-spikes line').length).toBe(1); + + hoverAt(farXVal, 49); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + + return Plotly.relayout(gd, 'spikedistance', 0); + }) + .then(function() { + hoverAt(nearXVal, 49); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + }) + .then(done, done.fail); + }); + + it('clears spikes across ternary subplots and respects showspikes relayouts', function(done) { + var traces = [ + data[0], + Lib.extendDeep({}, data[0], {subplot: 'ternary2'}) + ]; + var options = Lib.extendDeep({}, layout, { + ternary: {domain: {x: [0, 0.45]}}, + ternary2: {sum: 100, domain: {x: [0.55, 1]}} + }); + + Plotly.newPlot(gd, traces, options).then(function() { + hoverAt(-35, 49, 'ternary'); + expect(gd.querySelectorAll('.ternary-spikes line').length).toBe(3); + + hoverAt(-35, 49, 'ternary2'); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + + return Plotly.relayout(gd, 'ternary2.aaxis.showspikes', true); + }) + .then(function() { + hoverAt(-35, 49, 'ternary2'); + expect(gd.querySelectorAll('.ternary-spikes line').length).toBe(1); + + return Plotly.relayout(gd, 'ternary2.aaxis.showspikes', false); + }) + .then(function() { + hoverAt(-35, 49, 'ternary2'); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + }) + .then(done, done.fail); + }); + + it('clears ternary spikes when hovering a Cartesian subplot in the same figure', function(done) { + var traces = [ + data[0], + {type: 'scatter', mode: 'markers', x: [0], y: [0]} + ]; + var options = Lib.extendDeep({}, layout, { + ternary: {domain: {x: [0, 0.45]}}, + xaxis: {domain: [0.55, 1]}, + yaxis: {domain: [0, 1]} + }); + + Plotly.newPlot(gd, traces, options).then(function() { + hoverAt(-35, 49, 'ternary'); + expect(gd.querySelectorAll('.ternary-spikes line').length).toBe(3); + + hoverAt(0, 0, 'xy'); + expect(gd.querySelectorAll('.ternary-spikes').length).toBe(0); + }) + .then(done, done.fail); + }); + + it('uses the closest spike candidate across traces in the same ternary subplot', function(done) { + var traces = [ + data[0], + { + type: 'scatterternary', + mode: 'markers', + a: [60], + b: [25], + c: [15] + } + ]; + var options = Lib.extendDeep({}, layout, { + hoverdistance: 0, // no looking for data + spikedistance: -1, // no cutoff (default) + ternary: { + aaxis: {spikesnap: 'data'}, + // only observe A spike + baxis: {showspikes: false}, + caxis: {showspikes: false} + } + }); + + Plotly.newPlot(gd, traces, options).then(function() { + // x = c - b = 15 - 25 = -10 + // y = a = 60 + hoverAt(-10, 60); + + expect(gd.querySelectorAll('.hovertext').length).toBe(0); + expect(gd.querySelectorAll('.ternary-spikes line').length).toBe(1); + expect(gd.querySelector('.ternary-spikelabel text').textContent).toBe('60.0'); + }) + .then(done, done.fail); + }); +}); + describe('ternary defaults', function() { 'use strict'; @@ -731,6 +999,29 @@ describe('ternary defaults', function() { expect(layoutOut.ternary.caxis.type).toEqual('linear'); }); + it('defaults spikes off and coerces each enabled axis independently', function() { + layoutIn = {ternary: { + aaxis: {showspikes: true}, + baxis: {showspikes: true, spikecolor: 'red', spikethickness: 2, + spikedash: 'dot', spikemode: 'across+marker', spikesnap: 'cursor'}, + caxis: {spikecolor: 'blue'} + }}; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + var ternary = layoutOut.ternary; + expect(ternary.aaxis.showspikes).toBe(true); + expect(ternary.aaxis.spikesnap).toBe('hovered data'); + expect(ternary.aaxis.spikemode).toBe('toaxis'); + expect(ternary.aaxis.spikethickness).toBe(3); + expect(ternary.aaxis.spikedash).toBe('dash'); + expect(ternary.baxis.spikecolor).toBe('red'); + expect(ternary.baxis.spikethickness).toBe(2); + expect(ternary.baxis.spikedash).toBe('dot'); + expect(ternary.baxis.spikemode).toBe('across+marker'); + expect(ternary.baxis.spikesnap).toBe('cursor'); + expect(ternary.caxis.showspikes).toBe(false); + expect(ternary.caxis.spikecolor).toBeUndefined(); + }); + it('should coerce \'min\' values to 0 and delete them for user data if they contradict', function() { layoutIn = { ternary: { From 10fa7a4d4463ecc90d5e3005364ab89a66da061c Mon Sep 17 00:00:00 2001 From: Lexachoc <20377719+Lexachoc@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:14:21 +0200 Subject: [PATCH 4/4] add draftlog --- draftlogs/8041_add.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/8041_add.md diff --git a/draftlogs/8041_add.md b/draftlogs/8041_add.md new file mode 100644 index 00000000000..6b3a4ed281e --- /dev/null +++ b/draftlogs/8041_add.md @@ -0,0 +1 @@ +- Add spikelines to ternary plots [[#8041](https://github.com/plotly/plotly.js/pull/8041)] \ No newline at end of file