Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e980e44
Linting/formatting
camdecoster Sep 29, 2025
212f892
Return empty string for undefined value in templateFormatString
camdecoster Sep 29, 2025
75b157f
Refactoring
camdecoster Oct 2, 2025
5f3670f
Add fallback value for template strings
camdecoster Oct 3, 2025
336e9ae
Add fallback to calls to hovertemplateString
camdecoster Oct 3, 2025
02a4cad
Add fallback to calls to texttemplateString
camdecoster Oct 3, 2025
77bc2b9
Add fallback to calls to texttemplateStringForShapes
camdecoster Oct 7, 2025
85b67e9
Update defaults calculations
camdecoster Oct 6, 2025
680c793
Add helper function for template fallback attributes
camdecoster Oct 6, 2025
52c29cc
Add fallback value to attributes files
camdecoster Oct 7, 2025
f623b22
Update esbuild strip meta plugin to handle more joined arrays
camdecoster Oct 8, 2025
074f8a0
Return array from ternary
camdecoster Oct 8, 2025
6f3daa8
Update tests per default fallback value
camdecoster Oct 8, 2025
6719bd3
Update schema
camdecoster Oct 8, 2025
84fc044
Update test baselines
camdecoster Oct 8, 2025
e719f74
Rename object keys
camdecoster Oct 8, 2025
fb7a40c
Add/update tests to check fallback value
camdecoster Oct 9, 2025
faaae28
Add draftlog
camdecoster Oct 9, 2025
5c032c6
Fix typos
camdecoster Oct 21, 2025
a0ce641
Update fallback `editType` to match template
camdecoster Oct 21, 2025
14ab31c
Handle undefined values and missing values differently
camdecoster Oct 23, 2025
4f79efe
Update default fallback value and template attribute descriptions
camdecoster Oct 23, 2025
257475c
Add tests for missing and undefined values
camdecoster Oct 23, 2025
a148edd
Update schema
camdecoster Oct 23, 2025
c40fe9f
Merge remote-tracking branch 'origin/master' into cam/7564/return-emp…
camdecoster Oct 23, 2025
6a66148
Revert "Update test baselines"
camdecoster Oct 23, 2025
5215f13
Always use fallback for missing values, except when fallback is false
camdecoster Oct 24, 2025
30f87f3
Update schema
camdecoster Oct 24, 2025
b61d70d
Update mocks to show fallback values
camdecoster Oct 24, 2025
d900ca3
Update tests per final behavior
camdecoster Oct 24, 2025
a061787
Update baseline images
camdecoster Oct 24, 2025
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/7577_add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add `hovertemplatefallback` and `texttemplatefallback` attributes [[#7577](https://github.com/plotly/plotly.js/pull/7577)]
9 changes: 7 additions & 2 deletions src/components/drawing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1299,8 +1299,13 @@ drawing.textPointStyle = function (s, trace, gd) {
var labels = fn ? fn(d, trace, fullLayout) : {};
var pointValues = {};
appendArrayPointValue(pointValues, trace, d.i);
var meta = trace._meta || {};
text = Lib.texttemplateString(text, labels, fullLayout._d3locale, pointValues, d, meta);
text = Lib.texttemplateString({
data: [pointValues, d, trace._meta],
fallback: trace.texttemplatefallback,
labels,
locale: fullLayout._d3locale,
template: text
});
}

var pos = d.tp || trace.textposition;
Expand Down
54 changes: 22 additions & 32 deletions src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -796,9 +796,7 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
var winningPoint = hoverData[0];
// discard other points
if (multipleHoverPoints[winningPoint.trace.type]) {
hoverData = hoverData.filter(function (d) {
return d.trace.index === winningPoint.trace.index;
});
hoverData = hoverData.filter((d) => d.trace.index === winningPoint.trace.index);
} else {
hoverData = [winningPoint];
}
Expand Down Expand Up @@ -851,8 +849,7 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {

// pull out just the data that's useful to
// other people and send it to the event
for (itemnum = 0; itemnum < hoverData.length; itemnum++) {
var pt = hoverData[itemnum];
for (const pt of hoverData) {
var eventData = helpers.makeEventData(pt, pt.trace, pt.cd);

if (pt.hovertemplate !== false) {
Expand Down Expand Up @@ -1237,9 +1234,7 @@ function createHoverText(hoverData, opts) {
if (helpers.isUnifiedHover(hovermode)) {
// Delete leftover hover labels from other hovermodes
container.selectAll('g.hovertext').remove();
var groupedHoverData = hoverData.filter(function (data) {
return data.hoverinfo !== 'none';
});
const groupedHoverData = hoverData.filter((data) => data.hoverinfo !== 'none');
// Return early if nothing is hovered on
if (groupedHoverData.length === 0) return [];

Expand All @@ -1253,12 +1248,13 @@ function createHoverText(hoverData, opts) {

var mainText = !unifiedhovertitleText
? t0
: Lib.hovertemplateString(
unifiedhovertitleText,
{},
fullLayout._d3locale,
hovermode === 'x unified' ? { xa: item0.xa, x: item0.xVal } : { ya: item0.ya, y: item0.yVal }
);
: Lib.hovertemplateString({
data:
hovermode === 'x unified' ? [{ xa: item0.xa, x: item0.xVal }] : [{ ya: item0.ya, y: item0.yVal }],
fallback: item0.trace.hovertemplatefallback,
locale: fullLayout._d3locale,
template: unifiedhovertitleText
});

var mockLayoutIn = {
showlegend: true,
Expand Down Expand Up @@ -1624,9 +1620,7 @@ function getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g) {
if (d.nameOverride !== undefined) d.name = d.nameOverride;

if (d.name) {
if (d.trace._meta) {
d.name = Lib.templateString(d.name, d.trace._meta);
}
if (d.trace._meta) d.name = Lib.templateString(d.name, d.trace._meta);
name = plainText(d.name, d.nameLength);
}

Expand Down Expand Up @@ -1669,24 +1663,24 @@ function getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g) {
}

// hovertemplate
var hovertemplate = d.hovertemplate || false;
const { hovertemplate = false } = d;
if (hovertemplate) {
var labels = d.hovertemplateLabels || d;
const labels = d.hovertemplateLabels || d;

if (d[h0 + 'Label'] !== t0) {
labels[h0 + 'other'] = labels[h0 + 'Val'];
labels[h0 + 'otherLabel'] = labels[h0 + 'Label'];
}

text = Lib.hovertemplateString(
hovertemplate,
text = Lib.hovertemplateString({
data: [d.eventData[0] || {}, d.trace._meta],
fallback: d.trace.hovertemplatefallback,
labels,
fullLayout._d3locale,
d.eventData[0] || {},
d.trace._meta
);
locale: fullLayout._d3locale,
template: hovertemplate
});

text = text.replace(EXTRA_STRING_REGEX, function (match, extra) {
text = text.replace(EXTRA_STRING_REGEX, (_, extra) => {
// assign name for secondary text label
name = plainText(extra, d.nameLength);
// remove from main text label
Expand Down Expand Up @@ -2487,12 +2481,8 @@ function getCoord(axLetter, winningPoint, fullLayout) {
// Top/left hover offsets relative to graph div. As long as hover content is
// a sibling of the graph div, it will be positioned correctly relative to
// the offset parent, whatever that may be.
function getTopOffset(gd) {
return gd.offsetTop + gd.clientTop;
}
function getLeftOffset(gd) {
return gd.offsetLeft + gd.clientLeft;
}
const getTopOffset = (gd) => gd.offsetTop + gd.clientTop;
const getLeftOffset = (gd) => gd.offsetLeft + gd.clientLeft;

function getBoundingClientRect(gd, node) {
var fullLayout = gd._fullLayout;
Expand Down
3 changes: 2 additions & 1 deletion src/components/shapes/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ var extendFlat = require('../../lib/extend').extendFlat;
var templatedArray = require('../../plot_api/plot_template').templatedArray;
var axisPlaceableObjs = require('../../constants/axis_placeable_objects');
var basePlotAttributes = require('../../plots/attributes');
var shapeTexttemplateAttrs = require('../../plots/template_attributes').shapeTexttemplateAttrs;
const { shapeTexttemplateAttrs, templatefallbackAttrs } = require('../../plots/template_attributes');
var shapeLabelTexttemplateVars = require('./label_texttemplate');

module.exports = templatedArray('shape', {
Expand Down Expand Up @@ -331,6 +331,7 @@ module.exports = templatedArray('shape', {
].join(' ')
},
texttemplate: shapeTexttemplateAttrs({}, { keys: Object.keys(shapeLabelTexttemplateVars) }),
texttemplatefallback: templatefallbackAttrs(),
font: fontAttrs({
editType: 'calc+arraydraw',
colorEditType: 'arraydraw',
Expand Down
1 change: 1 addition & 0 deletions src/components/shapes/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ function handleShapeDefaults(shapeIn, shapeOut, fullLayout) {
var labelTextTemplate, labelText;
if (noPath) {
labelTextTemplate = coerce('label.texttemplate');
coerce('label.texttemplatefallback');
}
if (!labelTextTemplate) {
labelText = coerce('label.text');
Expand Down
12 changes: 6 additions & 6 deletions src/components/shapes/display_labels.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ module.exports = function drawLabel(gd, index, options, shapeGroup) {
if (val !== undefined) templateValues[key] = val;
}
}
text = Lib.texttemplateStringForShapes(
options.label.texttemplate,
{},
gd._fullLayout._d3locale,
templateValues
);
text = Lib.texttemplateStringForShapes({
data: [templateValues],
fallback: options.label.texttemplatefallback,
locale: gd._fullLayout._d3locale,
template: options.label.texttemplate
});
} else {
text = options.label.text;
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/shapes/draw_newshape/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ var basePlotAttributes = require('../../../plots/attributes');
var fontAttrs = require('../../../plots/font_attributes');
var dash = require('../../drawing/attributes').dash;
var extendFlat = require('../../../lib/extend').extendFlat;
var shapeTexttemplateAttrs = require('../../../plots/template_attributes').shapeTexttemplateAttrs;
const { shapeTexttemplateAttrs, templatefallbackAttrs } = require('../../../plots/template_attributes');
var shapeLabelTexttemplateVars = require('../label_texttemplate');

module.exports = overrideAll(
Expand Down Expand Up @@ -150,6 +150,7 @@ module.exports = overrideAll(
{ newshape: true },
{ keys: Object.keys(shapeLabelTexttemplateVars) }
),
texttemplatefallback: templatefallbackAttrs(),
font: fontAttrs({
description: 'Sets the new shape label text font.'
}),
Expand Down
1 change: 1 addition & 0 deletions src/components/shapes/draw_newshape/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = function supplyDrawNewShapeDefaults(layoutIn, layoutOut, coerce
var isLine = layoutIn.dragmode === 'drawline';
var labelText = coerce('newshape.label.text');
var labelTextTemplate = coerce('newshape.label.texttemplate');
coerce('newshape.label.texttemplatefallback');
if (labelText || labelTextTemplate) {
coerce('newshape.label.textangle');
var labelTextPosition = coerce('newshape.label.textposition', isLine ? 'middle' : 'middle center');
Expand Down
99 changes: 39 additions & 60 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1074,23 +1074,19 @@ lib.templateString = function (string, obj) {
});
};

var hovertemplateWarnings = {
const hovertemplateWarnings = {
max: 10,
count: 0,
name: 'hovertemplate'
};
lib.hovertemplateString = function () {
return templateFormatString.apply(hovertemplateWarnings, arguments);
};
lib.hovertemplateString = (params) => templateFormatString({ ...params, opts: hovertemplateWarnings });

var texttemplateWarnings = {
const texttemplateWarnings = {
max: 10,
count: 0,
name: 'texttemplate'
};
lib.texttemplateString = function () {
return templateFormatString.apply(texttemplateWarnings, arguments);
};
lib.texttemplateString = (params) => templateFormatString({ ...params, opts: texttemplateWarnings });

// Regex for parsing multiplication and division operations applied to a template key
// Used for shape.label.texttemplate
Expand All @@ -1108,66 +1104,57 @@ var texttemplateWarningsForShapes = {
name: 'texttemplate',
parseMultDiv: true
};
lib.texttemplateStringForShapes = function () {
return templateFormatString.apply(texttemplateWarningsForShapes, arguments);
};
lib.texttemplateStringForShapes = (params) => templateFormatString({ ...params, opts: texttemplateWarningsForShapes });

var TEMPLATE_STRING_FORMAT_SEPARATOR = /^[:|\|]/;
/**
* Substitute values from an object into a string and optionally formats them using d3-format,
* or fallback to associated labels.
*
* Examples:
* Lib.hovertemplateString('name: %{trace}', {trace: 'asdf'}) --> 'name: asdf'
* Lib.hovertemplateString('name: %{trace[0].name}', {trace: [{name: 'asdf'}]}) --> 'name: asdf'
* Lib.hovertemplateString('price: %{y:$.2f}', {y: 1}) --> 'price: $1.00'
* Lib.templateFormatString({ template 'name: %{trace}', labels: {trace: 'asdf'} }) --> 'name: asdf'
* Lib.templateFormatString({ template: 'name: %{trace[0].name}', labels: { trace: [{ name: 'asdf' }] } }) --> 'name: asdf'
* Lib.templateFormatString({ template: 'price: %{y:$.2f}', labels: { y: 1 } }) --> 'price: $1.00'
*
* @param {string} input string containing %{...:...} template strings
* @param {obj} data object containing fallback text when no formatting is specified, ex.: {yLabel: 'formattedYValue'}
* @param {obj} d3 locale
* @param {obj} data objects containing substitution values
* @param {object} options - Configuration object
* @param {array} options.data - Data objects containing substitution values
* @param {string} options.fallback - Fallback value when substitution fails
* @param {object} options.labels - Data object containing fallback text when no formatting is specified, ex.: {yLabel: 'formattedYValue'}
* @param {object} options.locale - D3 locale for formatting
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: IMO this argument should continue to be called d3locale for clarity

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave it as is since the JSDoc description explains what it is.

* @param {object} options.opts - Additional options
* @param {string} options.template - Input string containing %{...:...} template strings
*
* @return {string} templated string
*/
function templateFormatString(string, labels, d3locale) {
var opts = this;
var args = arguments;
if (!labels) labels = {};

return string.replace(lib.TEMPLATE_STRING_REGEX, function (match, rawKey, format) {
var isOther = rawKey === 'xother' || rawKey === 'yother';

var isSpaceOther = rawKey === '_xother' || rawKey === '_yother';

var isSpaceOtherSpace = rawKey === '_xother_' || rawKey === '_yother_';

var isOtherSpace = rawKey === 'xother_' || rawKey === 'yother_';

var hasOther = isOther || isSpaceOther || isOtherSpace || isSpaceOtherSpace;

var key = rawKey;
function templateFormatString({ data = [], locale, fallback, labels = {}, opts, template }) {
return template.replace(lib.TEMPLATE_STRING_REGEX, (_, rawKey, format) => {
const isOther = ['xother', 'yother'].includes(rawKey);
const isSpaceOther = ['_xother', '_yother'].includes(rawKey);
const isSpaceOtherSpace = ['_xother_', '_yother_'].includes(rawKey);
const isOtherSpace = ['xother_', 'yother_'].includes(rawKey);
const hasOther = isOther || isSpaceOther || isOtherSpace || isSpaceOtherSpace;

let key = rawKey;
if (isSpaceOther || isSpaceOtherSpace) key = key.substring(1);
if (isOtherSpace || isSpaceOtherSpace) key = key.substring(0, key.length - 1);

// Shape labels support * and / operators in template string
// Parse these if the parseMultDiv param is set to true
var parsedOp = null;
var parsedNumber = null;
let parsedOp = null;
let parsedNumber = null;
if (opts.parseMultDiv) {
var _match = multDivParser(key);
key = _match.key;
parsedOp = _match.op;
parsedNumber = _match.number;
}

var value;
let value;
if (hasOther) {
if (labels[key] === undefined) return '';
value = labels[key];
if (value === undefined) return '';
} else {
var obj, i;
for (i = 3; i < args.length; i++) {
obj = args[i];
for (const obj of data) {
if (!obj) continue;
if (obj.hasOwnProperty(key)) {
value = obj[key];
Expand All @@ -1182,38 +1169,30 @@ function templateFormatString(string, labels, d3locale) {
}
}

// Apply mult/div operation (if applicable)
if (value !== undefined) {
if (parsedOp === '*') value *= parsedNumber;
if (parsedOp === '/') value /= parsedNumber;
}

if (value === undefined && opts) {
if (opts.count < opts.max) {
lib.warn("Variable '" + key + "' in " + opts.name + ' could not be found!');
value = match;
}

if (opts.count === opts.max) {
lib.warn('Too many ' + opts.name + ' warnings - additional warnings will be suppressed');
}
if (value === undefined) {
const { count, max, name } = opts;
if (count < max) lib.warn(`Variable '${key}' in ${name} could not be found! Using fallback value.`);
if (count === max) lib.warn(`Too many '${name}' warnings - additional warnings will be suppressed`);
opts.count++;

return match;
return fallback;
}

if (parsedOp === '*') value *= parsedNumber;
if (parsedOp === '/') value /= parsedNumber;

if (format) {
var fmt;
if (format[0] === ':') {
fmt = d3locale ? d3locale.numberFormat : lib.numberFormat;
fmt = locale ? locale.numberFormat : lib.numberFormat;
if (value !== '') {
// e.g. skip missing data on heatmap
value = fmt(format.replace(TEMPLATE_STRING_FORMAT_SEPARATOR, ''))(value);
}
}

if (format[0] === '|') {
fmt = d3locale ? d3locale.timeFormat : utcFormat;
fmt = locale ? locale.timeFormat : utcFormat;
var ms = lib.dateTime2ms(value);
value = lib.formatDate(ms, format.replace(TEMPLATE_STRING_FORMAT_SEPARATOR, ''), false, fmt);
}
Expand Down
Loading