Skip to content
Open
4 changes: 4 additions & 0 deletions draftlogs/7888_add.md
Original file line number Diff line number Diff line change
@@ -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
44 changes: 43 additions & 1 deletion src/traces/sankey/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/traces/sankey/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions src/traces/sankey/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
169 changes: 153 additions & 16 deletions src/traces/sankey/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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('<br>');
};
}

// 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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
})

Expand All @@ -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);
}
})

Expand Down Expand Up @@ -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++) {
Expand All @@ -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);
Expand Down Expand Up @@ -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')
Expand All @@ -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);

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/types/generated/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Binary file added test/image/baselines/sankey_link_labels.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading