diff --git a/draftlogs/7888_add.md b/draftlogs/7888_add.md new file mode 100644 index 00000000000..21cb984eb05 --- /dev/null +++ b/draftlogs/7888_add.md @@ -0,0 +1,4 @@ +- Add permanent on-diagram link labels to the Sankey trace via new `link.textinfo`, `link.texttemplate`, `link.texttemplatefallback`, `link.textfont`, `link.valueformat`, and `link.valuesuffix` attributes [[#7888](https://github.com/plotly/plotly.js/pull/7888)]. + - `link.textinfo` shows `label` and/or `value` directly on each link, opt-in and off by default + - `link.texttemplate` allows full custom formatting, with `%{label}`, `%{value}`, `%{valueLabel}`, `%{source}`, `%{target}`, `%{customdata}` and `%{meta}` as available variables, resolved using the layout locale + - `link.texttemplatefallback`, `link.textfont`, `link.valueformat` and `link.valuesuffix` control the fallback text for missing template variables, the label font, and value formatting/suffix respectively, each falling back to the corresponding trace-level attribute when unset \ No newline at end of file diff --git a/src/traces/sankey/attributes.js b/src/traces/sankey/attributes.js index 11003ada21d..3f506d70455 100644 --- a/src/traces/sankey/attributes.js +++ b/src/traces/sankey/attributes.js @@ -5,7 +5,7 @@ var baseAttrs = require('../../plots/attributes'); var colorAttrs = require('../../components/color/attributes'); var fxAttrs = require('../../components/fx/attributes'); var domainAttrs = require('../../plots/domain').attributes; -const { hovertemplateAttrs, templatefallbackAttrs } = require('../../plots/template_attributes'); +const { hovertemplateAttrs, texttemplateAttrs, templatefallbackAttrs } = require('../../plots/template_attributes'); var colorAttributes = require('../../components/colorscale/attributes'); var templatedArray = require('../../plot_api/plot_template').templatedArray; var descriptionOnlyNumbers = require('../../plots/cartesian/axis_format_attributes').descriptionOnlyNumbers; @@ -210,6 +210,48 @@ var attrs = (module.exports = overrideAll( dflt: [], description: 'The shown name of the link.' }, + textinfo: { + valType: 'flaglist', + flags: ['label', 'value'], + extras: ['none'], + dflt: 'none', + description: [ + 'Determines which trace information appears permanently on the links.', + 'Any combination of *label* and *value* joined with a *+* OR *none*.' + ].join(' ') + }, + texttemplate: texttemplateAttrs({editType: 'calc'}, { + description: [ + '*%{label}: %{valueLabel}* renders the link label and its formatted', + 'value; `valueLabel` is the value formatted with `valueformat`/`valuesuffix`.', + 'When set per link, an empty entry makes that link fall back to `textinfo`', + 'rather than leaving it unlabeled; to label only some of the links,', + 'set `textinfo` to *none* and give a template to those links alone.' + ].join(' '), + keys: ['label', 'value', 'valueLabel', 'source', 'target', 'customdata', 'meta'] + }), + texttemplatefallback: templatefallbackAttrs(), + textfont: fontAttrs({ + autoShadowDflt: true, + description: 'Sets the font for the permanent link labels.' + }), + valueformat: { + valType: 'string', + dflt: '', + description: [ + 'Sets the value formatting rule for the permanent link labels,', + 'using d3 formatting mini-languages. Falls back to the trace-level', + '`valueformat` when empty.' + ].join(' ') + }, + valuesuffix: { + valType: 'string', + dflt: '', + description: [ + 'Adds a unit to follow the value in the permanent link labels.', + 'Falls back to the trace-level `valuesuffix` when empty.' + ].join(' ') + }, color: { valType: 'color', arrayOk: true, diff --git a/src/traces/sankey/constants.js b/src/traces/sankey/constants.js index c7eda8ca65b..5845009a9d9 100644 --- a/src/traces/sankey/constants.js +++ b/src/traces/sankey/constants.js @@ -13,6 +13,8 @@ module.exports = { sankey: 'sankey', sankeyLinks: 'sankey-links', sankeyLink: 'sankey-link', + sankeyLinkLabelSet: 'sankey-link-label-set', + sankeyLinkLabel: 'sankey-link-label', sankeyNodeSet: 'sankey-node-set', sankeyNode: 'sankey-node', nodeRect: 'node-rect', diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index 98a8e1a018e..01c238d3ca9 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -110,6 +110,16 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout Lib.coerceFont(coerce, 'textfont', layout.font, { autoShadowDflt: true }); + // permanent link text labels (opt-in via link.textinfo / link.texttemplate) + var linkTextInfo = coerceLink('textinfo'); + var linkTextTemplate = coerceLink('texttemplate'); + if(linkTextInfo !== 'none' || linkTextTemplate) { + Lib.coerceFont(coerceLink, 'textfont', traceOut.textfont, { autoShadowDflt: true }); + coerceLink('valueformat', traceOut.valueformat); + coerceLink('valuesuffix', traceOut.valuesuffix); + if(linkTextTemplate) coerceLink('texttemplatefallback'); + } + // Ensure _length is defined traceOut._length = null; }; diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 9a5ecfbb494..ac757cb26e1 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -346,6 +346,17 @@ function linkModel(d, l, i) { }; } +// The link paths and their permanent labels are two joins over the same links, +// so the models are built once per draw and shared between both selections. +function linkModels(d) { + if(!d._linkModels) { + d._linkModels = d.graph.links + .filter(function(l) {return l.value;}) + .map(linkModel.bind(null, d)); + } + return d._linkModels; +} + function createCircularClosedPathString(link, arrowLen) { // Using coordinates computed by d3-sankey-circular var pathString = ''; @@ -550,6 +561,90 @@ function linkPath() { return path; } +// Builds the permanent-label string for a link from textinfo / texttemplate. +// Returns null when the feature is opted out, so that traces which do not use it +// do no per-link work at all. +function linkTextGetter(gd, trace) { + var linkAttr = trace.link; + var textinfo = linkAttr.textinfo; + var texttemplate = linkAttr.texttemplate; + var flags = (textinfo && textinfo !== 'none') ? textinfo.split('+') : []; + var hasTemplate = Array.isArray(texttemplate) ? texttemplate.length > 0 : !!texttemplate; + if(!flags.length && !hasTemplate) return null; + + // the formatter depends on the trace only, so build it once per draw + var formatValue = Lib.numberFormat(linkAttr.valueformat); + var vSuf = linkAttr.valuesuffix; + var locale = gd._fullLayout._d3locale; + + return function(l, i) { + var valueLabel = formatValue(l.value) + vSuf; + + var tt = Array.isArray(texttemplate) ? texttemplate[i] : texttemplate; + if(tt) { + return Lib.texttemplateString({ + template: tt, + labels: {valueLabel: valueLabel}, + data: [{ + label: l.label, + value: l.value, + valueLabel: valueLabel, + source: l.source.label, + target: l.target.label, + customdata: l.customdata + }, trace._meta], + locale: locale, + fallback: linkAttr.texttemplatefallback + }); + } + + if(!flags.length) return ''; + var parts = []; + if(flags.indexOf('label') !== -1 && l.label) parts.push(l.label); + if(flags.indexOf('value') !== -1) parts.push(valueLabel); + return parts.join('
'); + }; +} + +// Counter-transform that cancels the group matrix applied in sankeyTransform, so +// the label glyphs always read left-to-right and upright. For every combination +// the product (group matrix x this) is the identity: +// h + forward : matrix( 1 0 0 1) -> '' +// h + reversed: matrix(-1 0 0 1) -> scale(-1,1) +// v + forward : matrix( 0 1 1 0) -> scale(-1,1) rotate(90) +// v + reversed: matrix( 0 -1 1 0) -> rotate(90) +// Shared by the node labels and the permanent link labels. +function uprightTransform(d) { + if(d.horizontal) return d.reversed ? 'scale(-1,1)' : ''; + return d.reversed ? strRotate(90) : ('scale(-1,1)' + strRotate(90)); +} + +// Positions a permanent link label at the link midpoint (layout frame) and keeps +// the glyphs upright. The midpoint stays in the layout frame: the enclosing +// `.sankey` group already carries the orientation/direction transform, so the +// coordinates themselves must not be mirrored here. The trailing translate runs +// in the already uprighted frame and is therefore a plain screen-space shift: it +// centres the whole text block on the anchor instead of its first baseline, +// using the same block metric as the node labels. +function linkLabelTransform(d) { + var l = d.link; + var midX, midY; + if(l.circular) { + // same anchor as the hover label (see hoverCenterPosition in plot.js) + midX = (l.circularPathData.leftInnerExtent + l.circularPathData.rightInnerExtent) / 2; + midY = l.circularPathData.verticalFullExtent; + } else { + midX = (l.source.x1 + l.target.x0) / 2; + midY = (l.y0 + l.y1) / 2; + } + var blockShift = l.trace.link.textfont.size * + (CAP_SHIFT - ((d.linkLabelLines || 1) - 1) * LINE_SPACING) / 2; + + return strTranslate(midX, midY) + + uprightTransform(d.parent) + + strTranslate(0, blockShift); +} + function nodeModel(d, n) { var zoneThicknessPad = c.nodePadAcross; var zoneLengthPad = d.nodePad / 2; @@ -620,9 +715,11 @@ function updateNodeShapes(sankeyNode) { sankeyNode.call(updateNodePositions); } -function updateShapes(sankeyNode, sankeyLink) { +function updateShapes(sankeyNode, sankeyLink, sankeyLinkLabel) { sankeyNode.call(updateNodeShapes); sankeyLink.attr('d', linkPath()); + // empty selection when the permanent labels are not opted in + sankeyLinkLabel.attr('transform', linkLabelTransform); } function sizeNode(rect) { @@ -685,7 +782,7 @@ function attachPointerEvents(selection, sankey, eventSet) { }); } -function attachDragHandler(sankeyNode, sankeyLink, callbacks, gd) { +function attachDragHandler(sankeyNode, sankeyLink, sankeyLinkLabel, callbacks, gd) { var dragBehavior = d3.behavior.drag() .origin(function(d) { return { @@ -714,7 +811,7 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks, gd) { } else { // make a forceLayout if needed attachForce(sankeyNode, forceKey, d, gd); } - startForce(sankeyNode, sankeyLink, d, forceKey, gd); + startForce(sankeyNode, sankeyLink, sankeyLinkLabel, d, forceKey, gd); } }) @@ -740,7 +837,7 @@ function attachDragHandler(sankeyNode, sankeyLink, callbacks, gd) { saveCurrentDragPosition(d.node); if(d.arrangement !== 'snap') { d.sankey.update(d.graph); - updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink); + updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink, sankeyLinkLabel); } }) @@ -776,7 +873,7 @@ function attachForce(sankeyNode, forceKey, d, gd) { .stop(); } -function startForce(sankeyNode, sankeyLink, d, forceKey, gd) { +function startForce(sankeyNode, sankeyLink, sankeyLinkLabel, d, forceKey, gd) { window.requestAnimationFrame(function faster() { var i; for(i = 0; i < c.forceTicksPerFrame; i++) { @@ -787,7 +884,7 @@ function startForce(sankeyNode, sankeyLink, d, forceKey, gd) { switchToSankeyFormat(nodes); d.sankey.update(d.graph); - updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink); + updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink, sankeyLinkLabel); if(d.forceLayouts[forceKey].alpha() > 0) { window.requestAnimationFrame(faster); @@ -953,12 +1050,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .style('fill', 'none'); var sankeyLink = sankeyLinks.selectAll('.' + c.cn.sankeyLink) - .data(function(d) { - var links = d.graph.links; - return links - .filter(function(l) {return l.value;}) - .map(linkModel.bind(null, d)); - }, keyFun); + .data(linkModels, keyFun); sankeyLink .enter().append('path') @@ -985,6 +1077,52 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { .style('opacity', 0) .remove(); + // Own group, appended between the links and the nodes: entering link paths + // of a later draw must not end up on top of already rendered labels. + var sankeyLinkLabelSet = sankey.selectAll('.' + c.cn.sankeyLinkLabelSet) + .data(repeat, keyFun); + + sankeyLinkLabelSet.enter() + .append('g') + .classed(c.cn.sankeyLinkLabelSet, true) + .style('pointer-events', 'none'); + + var sankeyLinkLabel = sankeyLinkLabelSet.selectAll('.' + c.cn.sankeyLinkLabel) + .data(function(d) { + var getText = linkTextGetter(gd, d.trace); + if(!getText) return []; + var out = []; + linkModels(d).forEach(function(m) { + // pointNumber indexes the input arrays, so it is the one an + // arrayOk texttemplate has to be looked up with + var txt = getText(m.link, m.pointNumber); + if(!txt) return; + m.linkLabelText = txt; + out.push(m); + }); + return out; + }, keyFun); + + sankeyLinkLabel.enter() + .append('text') + .classed(c.cn.sankeyLinkLabel, true) + .attr('text-anchor', 'middle'); + + sankeyLinkLabel + .attr('data-notex', 1) + .text(function(d) { return d.linkLabelText; }) + .each(function(d) { + var e = d3.select(this); + Drawing.font(e, d.link.trace.link.textfont); + svgTextUtils.convertToTspans(e, gd); + // cached for linkLabelTransform, which is re-applied on every drag + // frame and must not query the DOM there + d.linkLabelLines = svgTextUtils.lineCount(e); + }) + .attr('transform', linkLabelTransform); + + sankeyLinkLabel.exit().remove(); + var sankeyNodeSet = sankey.selectAll('.' + c.cn.sankeyNodeSet) .data(repeat, keyFun); @@ -1017,7 +1155,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { sankeyNode .call(attachPointerEvents, sankey, callbacks.nodeEvents) - .call(attachDragHandler, sankeyLink, callbacks, gd); // has to be here as it binds sankeyLink + .call(attachDragHandler, sankeyLink, sankeyLinkLabel, callbacks, gd); // has to be here as it binds sankeyLink sankeyNode .transition() @@ -1093,8 +1231,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { var posY = d.reversed ? (d.visibleWidth + blockHeight) / 2 : (d.visibleWidth - blockHeight) / 2; - var flipV = d.reversed ? strRotate(90) : ('scale(-1,1)' + strRotate(90)); - return strTranslate(posY, pad) + flipV; + return strTranslate(posY, pad) + uprightTransform(d); } // horizontal: center along the node length, place just past the thickness edge. @@ -1105,7 +1242,7 @@ module.exports = function(gd, svg, calcData, layout, callbacks) { } else { posX += d.visibleWidth; } - return strTranslate(posX, posY) + (d.reversed ? 'scale(-1,1)' : ''); + return strTranslate(posX, posY) + uprightTransform(d); }); nodeLabel diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index a0fe3258807..4c9a15dfad5 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -7003,11 +7003,29 @@ export interface SankeyData { * @default [] */ target?: Datum[] | Datum[][] | TypedArray; + /** Sets the font for the permanent link labels. */ + textfont?: Font; + /** + * Determines which trace information appears permanently on the links. Any combination of *label* and *value* joined with a *+* OR *none*. + * @default 'none' + */ + textinfo?: 'label' | 'value' | 'none' | (string & {}); + /** Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example "y: %{y}". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of "data: %{x}, %{y}" will result in a value of "data: 1, %{y}" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `label`, `value`, `valueLabel`, `source`, `target`, `customdata` and `meta`. */ + texttemplate?: string | string[]; + /** + * Fallback string that's displayed when a variable referenced in a template is missing. If the boolean value 'false' is passed in, the specifier with the missing variable will be displayed. + * @default '-' + */ + texttemplatefallback?: any; /** * A numeric value representing the flow volume value. * @default [] */ value?: Datum[] | Datum[][] | TypedArray; + /** Sets the value formatting rule for the permanent link labels, using d3 formatting mini-languages. Falls back to the trace-level `valueformat` when empty. */ + valueformat?: string; + /** Adds a unit to follow the value in the permanent link labels. Falls back to the trace-level `valuesuffix` when empty. */ + valuesuffix?: string; }; /** Assigns extra meta information associated with this trace that can be used in various text attributes. Attributes such as trace `name`, graph, axis and colorbar `title.text`, annotation `text` `rangeselector`, `updatemenues` and `sliders` `label` text all support `meta`. To access the trace `meta` values in an attribute in the same trace, simply use `%{meta[i]}` where `i` is the index or key of the `meta` item in question. To access trace `meta` in layout attributes, use `%{data[n[.meta[i]}` where `i` is the index or key of the `meta` and `n` is the trace index. */ meta?: any; diff --git a/test/image/baselines/sankey_link_labels_circular_orientations.png b/test/image/baselines/sankey_link_labels_circular_orientations.png new file mode 100644 index 00000000000..e212f94a3b8 Binary files /dev/null and b/test/image/baselines/sankey_link_labels_circular_orientations.png differ diff --git a/test/image/baselines/sankey_link_labels_inherit.png b/test/image/baselines/sankey_link_labels_inherit.png new file mode 100644 index 00000000000..313ed7e1f34 Binary files /dev/null and b/test/image/baselines/sankey_link_labels_inherit.png differ diff --git a/test/image/baselines/sankey_link_labels_orientations.png b/test/image/baselines/sankey_link_labels_orientations.png new file mode 100644 index 00000000000..64f49a3c189 Binary files /dev/null and b/test/image/baselines/sankey_link_labels_orientations.png differ diff --git a/test/image/baselines/sankey_link_labels_textinfo.png b/test/image/baselines/sankey_link_labels_textinfo.png new file mode 100644 index 00000000000..7ecce5930c0 Binary files /dev/null and b/test/image/baselines/sankey_link_labels_textinfo.png differ diff --git a/test/image/baselines/sankey_link_labels_texttemplate.png b/test/image/baselines/sankey_link_labels_texttemplate.png new file mode 100644 index 00000000000..cd85036cc50 Binary files /dev/null and b/test/image/baselines/sankey_link_labels_texttemplate.png differ diff --git a/test/image/baselines/sankey_link_labels_with_arrows.png b/test/image/baselines/sankey_link_labels_with_arrows.png new file mode 100644 index 00000000000..21d6e316bbf Binary files /dev/null and b/test/image/baselines/sankey_link_labels_with_arrows.png differ diff --git a/test/image/mocks/sankey_link_labels_circular_orientations.json b/test/image/mocks/sankey_link_labels_circular_orientations.json new file mode 100644 index 00000000000..65e2d8680c6 --- /dev/null +++ b/test/image/mocks/sankey_link_labels_circular_orientations.json @@ -0,0 +1,116 @@ +{ + "data": [ + { + "domain": { + "x": [ + 0, + 0.45 + ] + }, + "type": "sankey", + "node": { + "pad": 5, + "label": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6" + ] + }, + "link": { + "source": [ + 0, + 0, + 1, + 2, + 5, + 4, + 3 + ], + "target": [ + 5, + 3, + 4, + 3, + 0, + 2, + 2 + ], + "value": [ + 1, + 2, + 1, + 1, + 1, + 1, + 1 + ], + "textinfo": "value" + } + }, + { + "domain": { + "x": [ + 0.55, + 1 + ] + }, + "type": "sankey", + "orientation": "v", + "direction": "reversed", + "node": { + "pad": 5, + "label": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6" + ] + }, + "link": { + "source": [ + 0, + 0, + 1, + 2, + 5, + 4, + 3 + ], + "target": [ + 5, + 3, + 4, + 3, + 0, + 2, + 2 + ], + "value": [ + 1, + 2, + 1, + 1, + 1, + 1, + 1 + ], + "textinfo": "value" + } + } + ], + "layout": { + "title": { + "text": "Permanent link labels on circular links (left: h/forward, right: v/reversed)" + }, + "width": 1000, + "height": 600, + "showlegend": false + } +} diff --git a/test/image/mocks/sankey_link_labels_inherit.json b/test/image/mocks/sankey_link_labels_inherit.json new file mode 100644 index 00000000000..9ceed95da55 --- /dev/null +++ b/test/image/mocks/sankey_link_labels_inherit.json @@ -0,0 +1,47 @@ +{ + "data": [ + { + "domain": { "x": [0, 0.45] }, + "type": "sankey", + "valueformat": ".0f", + "valuesuffix": " TWh", + "textfont": { "family": "Times New Roman", "size": 16, "color": "darkblue" }, + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "color": "gray" + }, + "link": { + "source": [0, 1, 4, 2, 1], + "target": [1, 4, 5, 4, 3], + "value": [4, 2, 3, 1, 2], + "textinfo": "value" + } + }, + { + "domain": { "x": [0.55, 1] }, + "type": "sankey", + "valueformat": ".0f", + "valuesuffix": " TWh", + "textfont": { "family": "Times New Roman", "size": 16, "color": "darkblue" }, + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "color": "gray" + }, + "link": { + "source": [0, 1, 4, 2, 1], + "target": [1, 4, 5, 4, 3], + "value": [4, 2, 3, 1, 2], + "textinfo": "value", + "valueformat": ".2f", + "valuesuffix": " GW", + "textfont": { "family": "Arial", "size": 10, "color": "black" } + } + } + ], + "layout": { + "title": { "text": "Permanent link labels: trace level inherited (left) vs link level override (right)" }, + "width": 1000, + "height": 600, + "showlegend": false + } +} diff --git a/test/image/mocks/sankey_link_labels_orientations.json b/test/image/mocks/sankey_link_labels_orientations.json new file mode 100644 index 00000000000..c2c75bcee3d --- /dev/null +++ b/test/image/mocks/sankey_link_labels_orientations.json @@ -0,0 +1,264 @@ +{ + "data": [ + { + "domain": { + "x": [ + 0, + 0.45 + ], + "y": [ + 0.55, + 1 + ] + }, + "type": "sankey", + "node": { + "label": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "color": "gray" + }, + "link": { + "source": [ + 0, + 1, + 4, + 2, + 1 + ], + "target": [ + 1, + 4, + 5, + 4, + 3 + ], + "value": [ + 4, + 2, + 3, + 1, + 2 + ], + "label": [ + "a", + "b", + "c", + "d", + "e" + ], + "color": [ + "rgba(230, 25, 75, 0.6)", + "rgba(255, 165, 0, 0.6)", + "rgba(60, 180, 75, 0.6)", + "rgba(0, 130, 200, 0.6)", + "rgba(145, 30, 180, 0.6)" + ], + "textinfo": "label+value" + } + }, + { + "domain": { + "x": [ + 0.55, + 1 + ], + "y": [ + 0.55, + 1 + ] + }, + "type": "sankey", + "orientation": "v", + "node": { + "label": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "color": "gray" + }, + "link": { + "source": [ + 0, + 1, + 4, + 2, + 1 + ], + "target": [ + 1, + 4, + 5, + 4, + 3 + ], + "value": [ + 4, + 2, + 3, + 1, + 2 + ], + "label": [ + "a", + "b", + "c", + "d", + "e" + ], + "color": [ + "rgba(230, 25, 75, 0.6)", + "rgba(255, 165, 0, 0.6)", + "rgba(60, 180, 75, 0.6)", + "rgba(0, 130, 200, 0.6)", + "rgba(145, 30, 180, 0.6)" + ], + "textinfo": "label+value" + } + }, + { + "domain": { + "x": [ + 0, + 0.45 + ], + "y": [ + 0, + 0.45 + ] + }, + "type": "sankey", + "direction": "reversed", + "node": { + "label": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "color": "gray" + }, + "link": { + "source": [ + 0, + 1, + 4, + 2, + 1 + ], + "target": [ + 1, + 4, + 5, + 4, + 3 + ], + "value": [ + 4, + 2, + 3, + 1, + 2 + ], + "label": [ + "a", + "b", + "c", + "d", + "e" + ], + "color": [ + "rgba(230, 25, 75, 0.6)", + "rgba(255, 165, 0, 0.6)", + "rgba(60, 180, 75, 0.6)", + "rgba(0, 130, 200, 0.6)", + "rgba(145, 30, 180, 0.6)" + ], + "textinfo": "label+value" + } + }, + { + "domain": { + "x": [ + 0.55, + 1 + ], + "y": [ + 0, + 0.45 + ] + }, + "type": "sankey", + "orientation": "v", + "direction": "reversed", + "node": { + "label": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "color": "gray" + }, + "link": { + "source": [ + 0, + 1, + 4, + 2, + 1 + ], + "target": [ + 1, + 4, + 5, + 4, + 3 + ], + "value": [ + 4, + 2, + 3, + 1, + 2 + ], + "label": [ + "a", + "b", + "c", + "d", + "e" + ], + "color": [ + "rgba(230, 25, 75, 0.6)", + "rgba(255, 165, 0, 0.6)", + "rgba(60, 180, 75, 0.6)", + "rgba(0, 130, 200, 0.6)", + "rgba(145, 30, 180, 0.6)" + ], + "textinfo": "label+value" + } + } + ], + "layout": { + "title": { + "text": "Permanent link labels across orientation x direction" + }, + "width": 1000, + "height": 1000, + "showlegend": false + } +} diff --git a/test/image/mocks/sankey_link_labels_textinfo.json b/test/image/mocks/sankey_link_labels_textinfo.json new file mode 100644 index 00000000000..70cecdf13ef --- /dev/null +++ b/test/image/mocks/sankey_link_labels_textinfo.json @@ -0,0 +1,43 @@ +{ + "data": [ + { + "domain": { "x": [0, 0.45] }, + "type": "sankey", + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "color": "gray" + }, + "link": { + "source": [0, 1, 4, 2, 1, 0], + "target": [1, 4, 5, 4, 3, 5], + "value": [4, 2, 3, 1, 2, 0], + "label": ["a", "b", "", "d", "e", "zero"], + "textinfo": "label" + } + }, + { + "domain": { "x": [0.55, 1] }, + "type": "sankey", + "valueformat": ".0f", + "valuesuffix": " TWh", + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "color": "gray" + }, + "link": { + "source": [0, 1, 4, 2, 1], + "target": [1, 4, 5, 4, 3], + "value": [4, 2, 3, 1, 2], + "label": ["a", "b", "c", "d", "e"], + "textinfo": "label+value", + "texttemplate": "%{label} = %{valueLabel}" + } + } + ], + "layout": { + "title": { "text": "Permanent link labels: textinfo label only (left) vs texttemplate overriding textinfo (right)" }, + "width": 1000, + "height": 600, + "showlegend": false + } +} diff --git a/test/image/mocks/sankey_link_labels_texttemplate.json b/test/image/mocks/sankey_link_labels_texttemplate.json new file mode 100644 index 00000000000..fc8449699c5 --- /dev/null +++ b/test/image/mocks/sankey_link_labels_texttemplate.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "type": "sankey", + "valueformat": ".0f", + "valuesuffix": " TWh", + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "color": "gray" + }, + "link": { + "source": [0, 1, 4, 2, 1], + "target": [1, 4, 5, 4, 3], + "value": [4, 2, 3, 1, 2], + "label": ["a", "b", "c", "d", "e"], + "customdata": ["cd0", "cd1", "cd2", "cd3", "cd4"], + "color": [ + "rgba(230, 25, 75, 0.6)", + "rgba(255, 165, 0, 0.6)", + "rgba(60, 180, 75, 0.6)", + "rgba(0, 130, 200, 0.6)", + "rgba(145, 30, 180, 0.6)" + ], + "texttemplate": [ + "%{label}", + "%{value}", + "%{valueLabel}", + "%{source}/%{target}", + "%{customdata}" + ], + "textfont": { "family": "Times New Roman", "size": 14, "color": "black" } + } + } + ], + "layout": { + "title": { "text": "Sankey with permanent link labels from texttemplate" }, + "width": 800, + "height": 800 + } +} diff --git a/test/image/mocks/sankey_link_labels_with_arrows.json b/test/image/mocks/sankey_link_labels_with_arrows.json new file mode 100644 index 00000000000..cce4dbcf405 --- /dev/null +++ b/test/image/mocks/sankey_link_labels_with_arrows.json @@ -0,0 +1,30 @@ +{ + "data": [ + { + "type": "sankey", + "node": { + "label": ["0", "1", "2", "3", "4", "5"], + "color": "gray" + }, + "link": { + "arrowlen": 40, + "source": [0, 1, 4, 2, 1], + "target": [1, 4, 5, 4, 3], + "value": [4, 2, 3, 1, 2], + "color": [ + "rgba(230, 25, 75, 0.6)", + "rgba(255, 165, 0, 0.6)", + "rgba(60, 180, 75, 0.6)", + "rgba(0, 130, 200, 0.6)", + "rgba(145, 30, 180, 0.6)" + ], + "textinfo": "value" + } + } + ], + "layout": { + "title": { "text": "Sankey with permanent link labels and arrows" }, + "width": 800, + "height": 800 + } +} diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index d82ecdf9296..1fed1d2f8b0 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -158,6 +158,72 @@ describe('sankey tests', function () { .toBe(attributes.direction.dflt, 'invalid direction falls back to default'); }); + it('coerces the permanent link label values', function() { + var off = _supply({}); + + expect(off.link.textinfo) + .toBe(attributes.link.textinfo.dflt, 'link labels are off by default'); + expect(off.link.texttemplate) + .toBe(attributes.link.texttemplate.dflt, 'empty texttemplate by default'); + + // The feature is opt-in: while it is off nothing downstream of it is + // coerced, so an untouched trace keeps exactly the fullData it had before. + expect(off.link.textfont).toBe(undefined, 'textfont not coerced while off'); + expect(off.link.valueformat).toBe(undefined, 'valueformat not coerced while off'); + expect(off.link.valuesuffix).toBe(undefined, 'valuesuffix not coerced while off'); + expect(off.link.texttemplatefallback) + .toBe(undefined, 'texttemplatefallback not coerced while off'); + + ['label', 'value', 'label+value'].forEach(function(ti) { + expect(_supply({link: {textinfo: ti}}).link.textinfo) + .toBe(ti, ti + ' is a valid textinfo'); + }); + expect(_supply({link: {textinfo: 'source+target'}}).link.textinfo) + .toBe(attributes.link.textinfo.dflt, 'invalid textinfo falls back to default'); + + expect(_supply({link: {texttemplate: '%{valueLabel}'}}).link.valueformat) + .not.toBe(undefined, 'texttemplate alone also opts in'); + + // texttemplatefallback only matters once a template can actually + // reference a missing variable, so textinfo alone must not pull it + // in - only texttemplate does, and an explicit value wins over the + // attribute default. + expect(_supply({link: {textinfo: 'value'}}).link.texttemplatefallback) + .toBe(undefined, 'textinfo alone does not coerce texttemplatefallback'); + expect(_supply({link: {texttemplate: '%{valueLabel}'}}).link.texttemplatefallback) + .toBe(attributes.link.texttemplatefallback.dflt, 'texttemplate opts texttemplatefallback in'); + expect(_supply({link: {texttemplate: '%{nope}', texttemplatefallback: 'n/a'}}).link.texttemplatefallback) + .toBe('n/a', 'an explicit texttemplatefallback wins'); + }); + + it('falls back to the trace level formatting for permanent link labels', function() { + var layout = {font: {family: 'Arial', size: 11}}; + var traceIn = { + valueformat: '.0f', + valuesuffix: 'TWh', + link: {textinfo: 'value'} + }; + + var inherited = _supplyWithLayout(traceIn, layout); + expect(inherited.link.valueformat).toBe('.0f', 'inherits trace valueformat'); + expect(inherited.link.valuesuffix).toBe('TWh', 'inherits trace valuesuffix'); + expect(inherited.link.textfont.family).toBe('Arial', 'inherits trace textfont'); + + var explicit = _supplyWithLayout( + Lib.extendDeep({}, traceIn, { + link: { + valueformat: '.2f', + valuesuffix: ' GW', + textfont: {family: 'Georgia'} + } + }), + layout + ); + expect(explicit.link.valueformat).toBe('.2f', 'link valueformat wins'); + expect(explicit.link.valuesuffix).toBe(' GW', 'link valuesuffix wins'); + expect(explicit.link.textfont.family).toBe('Georgia', 'link textfont wins'); + }); + it("'Sankey' layout dependent specification should have proper types", function () { var fullTrace = _supplyWithLayout( {}, @@ -1698,6 +1764,256 @@ describe('sankey tests', function () { }); }); + describe('permanent link labels', function() { + var gd; + + // A link that exists in every variant of the energy mock and is neither + // circular nor the first one in the list. + var SOURCE = 'Thermal generation'; + var TARGET = 'Losses'; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + // Screen-space bounding rect for a link, identified by its source and + // target labels (same idea as the helper in the hover label suite). + function rectForLink(sourceLabel, targetLabel) { + var rect; + d3SelectAll('.sankey-link').each(function(d) { + if(d.link.source.label === sourceLabel && d.link.target.label === targetLabel) { + rect = this.getBoundingClientRect(); + } + }); + if(!rect) throw new Error('link not found: ' + sourceLabel + ' -> ' + targetLabel); + + return rect; + } + + function rectForLinkLabel(sourceLabel, targetLabel) { + var rect; + d3SelectAll('.sankey-link-label').each(function(d) { + if(d.link.source.label === sourceLabel && d.link.target.label === targetLabel) { + rect = this.getBoundingClientRect(); + } + }); + if(!rect) throw new Error('link label not found: ' + sourceLabel + ' -> ' + targetLabel); + + return rect; + } + + function plotWith(patch) { + var fig = Lib.extendDeep({}, mock); + Lib.extendDeep(fig.data[0], patch); + return Plotly.newPlot(gd, fig); + } + + it('only renders link labels when they are opted in', function(done) { + plotWith({}) + .then(function() { + expect(d3SelectAll('.sankey-link-label').size()) + .toBe(0, 'no labels by default'); + + return Plotly.restyle(gd, 'link.textinfo', 'value'); + }) + .then(function() { + // Labels and link paths are built from the same filtered + // `graph.links` list, so the counts have to agree. + expect(d3SelectAll('.sankey-link-label').size()) + .toBe(d3SelectAll('.sankey-link').size(), 'one label per drawn link'); + + return Plotly.restyle(gd, 'link.textinfo', 'none'); + }) + .then(function() { + // No leftover empty nodes - they would still show up + // in the SVG/PNG export. + expect(d3SelectAll('.sankey-link-label').size()) + .toBe(0, 'labels removed again'); + }) + .then(done, done.fail); + }); + + it('formats the label text from textinfo and texttemplate', function(done) { + function textFor(sourceLabel, targetLabel) { + var txt; + d3SelectAll('.sankey-link-label').each(function(d) { + if(d.link.source.label === sourceLabel && d.link.target.label === targetLabel) { + txt = d3Select(this).text(); + } + }); + return txt; + } + + plotWith({valueformat: '.0f', valuesuffix: 'TWh', link: {textinfo: 'value'}}) + .then(function() { + expect(textFor(SOURCE, TARGET)) + .toBe('787TWh', 'value formatted with the trace level format'); + + return Plotly.restyle(gd, 'link.texttemplate', '%{source}/%{target}: %{valueLabel}'); + }) + .then(function() { + expect(textFor(SOURCE, TARGET)) + .toBe(SOURCE + '/' + TARGET + ': 787TWh', 'texttemplate overrides textinfo'); + + // An empty entry in an arrayOk texttemplate falls back to + // textinfo for that link alone - it does not hide the label. + // Leaving a single link unlabeled is only possible with + // textinfo 'none'. Pure string logic, so it is asserted here + // rather than in an image baseline. + var fd = gd._fullData[0]; + var srcIdx = fd.node.label.indexOf(SOURCE); + var tgtIdx = fd.node.label.indexOf(TARGET); + var perLink = fd.link.value.map(function(_, i) { + return (fd.link.source[i] === srcIdx && fd.link.target[i] === tgtIdx) ? + '' : 'templated'; + }); + + return Plotly.restyle(gd, {'link.texttemplate': [perLink]}); + }) + .then(function() { + expect(textFor(SOURCE, TARGET)) + .toBe('787TWh', 'empty template entry falls back to textinfo'); + expect(textFor('Solid', 'Industry')) + .toBe('templated', 'other links still use the template'); + }) + .then(done, done.fail); + }); + + it('formats with the layout locale and resolves meta and the fallback', function(done) { + var fig = Lib.extendDeep({}, mock); + fig.layout.separators = ',.'; + Lib.extendDeep(fig.data[0], { + meta: ['GWh'], + link: { + texttemplate: '%{value:,.2f} %{meta[0]} %{notAVariable}', + texttemplatefallback: 'n/a' + } + }); + + Plotly.newPlot(gd, fig) + .then(function() { + var texts = []; + d3SelectAll('.sankey-link-label').each(function() { + texts.push(d3Select(this).text()); + }); + + expect(texts.length).toBeGreaterThan(0, 'labels were drawn'); + texts.forEach(function(t) { + // ',.' swaps the decimal and thousands separators, which + // only reaches d3-format if _d3locale is handed to + // Lib.texttemplateString. %{meta[0]} likewise only + // resolves if trace._meta is part of its data array, and + // an unknown variable has to hit the fallback rather than + // rendering the string 'undefined'. + expect(t).toMatch(/^[\d.]+,\d{2} GWh n\/a$/); + }); + }) + .then(done, done.fail); + }); + + it('keeps link labels upright and centred per orientation and direction', function(done) { + function assertUprightAndCentred(msg) { + // The labels live inside the .sankey group and therefore inherit the + // matrix from sankeyTransform. uprightTransform has to cancel it exactly, + // so the glyphs' own screen matrix is the identity - without that the + // text renders mirrored (reversed) or rotated (vertical). + var ctm = d3Select('.sankey-link-label').node().getScreenCTM(); + expect(ctm.a).toBeCloseTo(1, 3, msg + ': no x mirroring'); + expect(ctm.b).toBeCloseTo(0, 3, msg + ': no rotation/skew'); + expect(ctm.c).toBeCloseTo(0, 3, msg + ': no rotation/skew'); + expect(ctm.d).toBeCloseTo(1, 3, msg + ': no y mirroring'); + + // ... while the anchor still sits on the midpoint of its own link. + // linkLabelTransform deliberately does *not* mirror the coordinates + // (the group transform already does); a label that stayed glued to + // the un-mirrored anchor would break this invariant. + var link = rectForLink(SOURCE, TARGET); + var label = rectForLinkLabel(SOURCE, TARGET); + expect(label.left + label.width / 2) + .toBeCloseTo(link.left + link.width / 2, -1, msg + ': centred in x'); + expect(label.top + label.height / 2) + .toBeCloseTo(link.top + link.height / 2, -1, msg + ': centred in y'); + } + + function plotOriented(orientation, direction) { + return plotWith({ + orientation: orientation, + direction: direction, + link: {textinfo: 'value'} + }).then(function() { + // Guard against a vacuously passing test: the assertions above are + // only meaningful if the group transform really was applied. + expect(d3Select('.sankey').attr('transform')) + .toContain(expectedMatrix[orientation + '-' + direction]); + assertUprightAndCentred(orientation + ' + ' + direction); + }); + } + + var expectedMatrix = { + 'h-forward': 'matrix(1 0 0 1 0 0)', + 'h-reversed': 'matrix(-1 0 0 1 0 0)', + 'v-forward': 'matrix(0 1 1 0 0 0)', + 'v-reversed': 'matrix(0 -1 1 0 0 0)' + }; + + plotOriented('h', 'forward') + .then(function() { return plotOriented('h', 'reversed'); }) + .then(function() { return plotOriented('v', 'forward'); }) + .then(function() { return plotOriented('v', 'reversed'); }) + .then(done, done.fail); + }); + + it('moves the link labels along when a node is dragged', function(done) { + var nodeId = 4; // node with label 'Solid' + var before; + + plotWith({arrangement: 'freeform', link: {textinfo: 'value'}}) + .then(function() { + before = rectForLinkLabel('Solid', 'Industry'); + + var node = document.getElementsByClassName('sankey-node').item(nodeId); + return drag({node: node, dpos: [0, 100], nsteps: 10, timeDelay: 0}); + }) + .then(function() { + // updateShapes re-applies linkLabelTransform on every drag frame, + // so the label has to travel with the link it belongs to. + var after = rectForLinkLabel('Solid', 'Industry'); + expect(after.top).toBeGreaterThan(before.top + 20, 'label followed the drag'); + + var link = rectForLink('Solid', 'Industry'); + expect(after.top + after.height / 2) + .toBeCloseTo(link.top + link.height / 2, -1, 'still centred on its link'); + }) + .then(done, done.fail); + }); + + it('keeps the link labels on their links while a snap layout settles', function(done) { + var nodeId = 4; // node with label 'Solid' + + // Unlike freeform, snap keeps ticking a force simulation after the + // drop; startForce calls updateShapes on every tick, so the labels + // have to stay glued to their links through that as well, not just + // through the drag itself. + plotWith({arrangement: 'snap', link: {textinfo: 'value'}}) + .then(function() { + var node = document.getElementsByClassName('sankey-node').item(nodeId); + return drag({node: node, dpos: [0, 100], nsteps: 10, timeDelay: 2000}); + }) + .then(function() { + var link = rectForLink('Solid', 'Industry'); + var label = rectForLinkLabel('Solid', 'Industry'); + expect(label.top + label.height / 2) + .toBeCloseTo(link.top + link.height / 2, -1, 'still centred on its link once settled'); + }) + .then(done, done.fail); + }); + }); + describe('Test drag interactions', function () { ['freeform', 'perpendicular', 'snap'].forEach(function (arrangement) { describe('for arrangement ' + arrangement + ':', function () { diff --git a/test/plot-schema.json b/test/plot-schema.json index f5623e929be..895875377eb 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -51907,11 +51907,138 @@ "editType": "calc", "valType": "data_array" }, + "textfont": { + "color": { + "editType": "calc", + "valType": "color" + }, + "description": "Sets the font for the permanent link labels.", + "editType": "calc", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser can only apply a font if it is available on the system where it runs. Provide multiple font families, separated by commas, to indicate the order in which to apply fonts if they aren't available.", + "editType": "calc", + "noBlank": true, + "strict": true, + "valType": "string" + }, + "lineposition": { + "description": "Sets the kind of decoration line(s) with text, such as an *under*, *over* or *through* as well as combinations e.g. *under+over*, etc.", + "dflt": "none", + "editType": "calc", + "extras": [ + "none" + ], + "flags": [ + "under", + "over", + "through" + ], + "valType": "flaglist" + }, + "role": "object", + "shadow": { + "description": "Sets the shape and color of the shadow behind text. *auto* places minimal shadow and applies contrast text font color. See https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow for additional options.", + "dflt": "auto", + "editType": "calc", + "valType": "string" + }, + "size": { + "editType": "calc", + "min": 1, + "valType": "number" + }, + "style": { + "description": "Sets whether a font should be styled with a normal or italic face from its family.", + "dflt": "normal", + "editType": "calc", + "valType": "enumerated", + "values": [ + "normal", + "italic" + ] + }, + "textcase": { + "description": "Sets capitalization of text. It can be used to make text appear in all-uppercase or all-lowercase, or with each word capitalized.", + "dflt": "normal", + "editType": "calc", + "valType": "enumerated", + "values": [ + "normal", + "word caps", + "upper", + "lower" + ] + }, + "variant": { + "description": "Sets the variant of the font.", + "dflt": "normal", + "editType": "calc", + "valType": "enumerated", + "values": [ + "normal", + "small-caps", + "all-small-caps", + "all-petite-caps", + "petite-caps", + "unicase" + ] + }, + "weight": { + "description": "Sets the weight (or boldness) of the font.", + "dflt": "normal", + "editType": "calc", + "extras": [ + "normal", + "bold" + ], + "max": 1000, + "min": 1, + "valType": "integer" + } + }, + "textinfo": { + "description": "Determines which trace information appears permanently on the links. Any combination of *label* and *value* joined with a *+* OR *none*.", + "dflt": "none", + "editType": "calc", + "extras": [ + "none" + ], + "flags": [ + "label", + "value" + ], + "valType": "flaglist" + }, + "texttemplate": { + "arrayOk": true, + "description": "Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example \"y: %{y}\". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example \"Price: %{y:$.2f}\". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example \"Day: %{2019-01-01|%A}\". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of \"data: %{x}, %{y}\" will result in a value of \"data: 1, %{y}\" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `label`, `value`, `valueLabel`, `source`, `target`, `customdata` and `meta`.", + "dflt": "", + "editType": "calc", + "valType": "string" + }, + "texttemplatefallback": { + "description": "Fallback string that's displayed when a variable referenced in a template is missing. If the boolean value 'false' is passed in, the specifier with the missing variable will be displayed.", + "dflt": "-", + "editType": "calc", + "valType": "any" + }, "value": { "description": "A numeric value representing the flow volume value.", "dflt": [], "editType": "calc", "valType": "data_array" + }, + "valueformat": { + "description": "Sets the value formatting rule for the permanent link labels, using d3 formatting mini-languages. Falls back to the trace-level `valueformat` when empty.", + "dflt": "", + "editType": "calc", + "valType": "string" + }, + "valuesuffix": { + "description": "Adds a unit to follow the value in the permanent link labels. Falls back to the trace-level `valuesuffix` when empty.", + "dflt": "", + "editType": "calc", + "valType": "string" } }, "meta": {