Skip to content

Commit 9354ce3

Browse files
committed
Component | Sankey: Advanced logic for left / right bleed and label width
1 parent 83d3fd4 commit 9354ce3

File tree

5 files changed

+131
-73
lines changed

5 files changed

+131
-73
lines changed

packages/ts/src/components/sankey/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export const SankeyDefaultConfig: SankeyConfigInterface<SankeyInputNode, SankeyI
169169
heightNormalizationCoeff: 1 / 16,
170170
zoomScale: undefined,
171171
zoomPan: undefined,
172-
enableZoom: true,
172+
enableZoom: false,
173173
zoomExtent: [1, 5] as [number, number],
174174
zoomMode: SankeyZoomMode.Y,
175175
exitTransitionType: SankeyExitTransitionType.Default,

packages/ts/src/components/sankey/index.ts

Lines changed: 77 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { VerticalAlign } from 'types/text'
1818
import { smartTransition } from 'utils/d3'
1919
import { clamp, getNumber, getString, groupBy, isNumber } from 'utils/data'
2020
import { getCSSVariableValueInPixels } from 'utils/misc'
21+
import { estimateStringPixelLength } from 'utils/text'
2122

2223
// Config
2324
import { SankeyDefaultConfig, SankeyConfigInterface } from './config'
@@ -26,12 +27,12 @@ import { SankeyDefaultConfig, SankeyConfigInterface } from './config'
2627
import * as s from './style'
2728

2829
// Local Types
29-
import { SankeyInputLink, SankeyInputNode, SankeyLayout, SankeyLink, SankeyNode, SankeyZoomMode } from './types'
30+
import { SankeyInputLink, SankeyInputNode, SankeyLayout, SankeyLink, SankeyNode, SankeySubLabelPlacement, SankeyZoomMode } from './types'
3031

3132
// Modules
3233
import { createLinks, removeLinks, updateLinks } from './modules/link'
3334
import { createNodes, NODE_SELECTION_RECT_DELTA, onNodeMouseOut, onNodeMouseOver, removeNodes, updateNodes } from './modules/node'
34-
import { getLabelOrientation, requiredLabelSpace } from './modules/label'
35+
import { getLabelOrientation, SANKEY_LABEL_BLOCK_PADDING, SANKEY_LABEL_SPACING } from './modules/label'
3536

3637
export class Sankey<
3738
N extends SankeyInputNode,
@@ -47,6 +48,7 @@ export class Sankey<
4748
public g: Selection<SVGGElement, unknown, null, undefined>
4849
// eslint-disable-next-line @typescript-eslint/naming-convention
4950
private _gNode: SVGGElement & { __zoom: ZoomTransform }
51+
private _prevWidth: number | undefined = undefined
5052
private _extendedWidth: number | undefined = undefined
5153
private _extendedHeight: number | undefined = undefined
5254
private _extendedHeightIncreased: number | undefined = undefined
@@ -104,45 +106,71 @@ export class Sankey<
104106

105107
get bleed (): Spacing {
106108
const { config, datamodel: { nodes, links } } = this
107-
if (!nodes.length) return { top: 0, bottom: 0, left: 0, right: 0 }
109+
let bleed: Spacing = { top: 0, bottom: 0, left: 0, right: 0 }
108110

109-
const labelFontSize = config.labelFontSize ?? getCSSVariableValueInPixels('var(--vis-sankey-label-font-size)', this.element)
111+
if (nodes.length) {
112+
const labelFontSize = config.labelFontSize || getCSSVariableValueInPixels('var(--vis-sankey-node-label-font-size)', this.element)
113+
const subLabelFontSize = config.subLabelFontSize || getCSSVariableValueInPixels('var(--vis-sankey-node-sublabel-font-size)', this.element)
110114

111-
// We pre-calculate sankey layout to get information about node labels placement and calculate bleed properly
112-
// Potentially it can be a performance bottleneck for large layouts, but generally rendering of such layouts is much more computationally heavy
113-
const sankeyProbeSize = 1000
114-
this._populateLinkAndNodeValues()
115-
this._sankey.size([sankeyProbeSize, sankeyProbeSize])
116-
this._sankey({ nodes, links })
117-
const layerSpacing = this._getLayerSpacing(nodes)
118-
const labelSize = requiredLabelSpace(clamp(layerSpacing, 0, config.labelMaxWidth ?? Infinity), labelFontSize)
119-
const maxDepth = max(nodes, d => d.depth)
120-
const zeroDepthNodes = nodes.filter(d => d.depth === 0)
121-
const maxDepthNodes = nodes.filter(d => d.depth === maxDepth)
122-
123-
124-
const left = zeroDepthNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Left) ? labelSize.width : 0
125-
const right = (maxDepthNodes.some(d => getString(d, config.label)) &&
126-
maxDepthNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Right))
127-
? labelSize.width
128-
: 0
129-
130-
const top = config.labelVerticalAlign === VerticalAlign.Top ? 0
131-
: config.labelVerticalAlign === VerticalAlign.Bottom ? labelSize.height
132-
: labelSize.height / 2
133-
134-
const bottom = config.labelVerticalAlign === VerticalAlign.Top ? labelSize.height
135-
: config.labelVerticalAlign === VerticalAlign.Bottom ? 0
136-
: labelSize.height / 2
137-
138-
const nodeSelectionBleed = config.selectedNodeIds ? 1 + NODE_SELECTION_RECT_DELTA : 0
139-
const bleed = {
140-
top: nodeSelectionBleed + top,
141-
bottom: nodeSelectionBleed + bottom,
142-
left: nodeSelectionBleed + left,
143-
right: nodeSelectionBleed + right,
115+
// We pre-calculate sankey layout to get information about node labels placement and calculate bleed properly
116+
// Potentially it can be a performance bottleneck for large layouts, but generally rendering of such layouts is much more computationally heavy
117+
const sankeyProbeSize = 1000
118+
this._populateLinkAndNodeValues()
119+
this._sankey.size([sankeyProbeSize, sankeyProbeSize])
120+
this._sankey({ nodes, links })
121+
const requiredLabelHeight = labelFontSize * 2.5 + 2 * SANKEY_LABEL_BLOCK_PADDING // Assuming 2.5 lines per label
122+
123+
const maxLayer = max(nodes, d => d.layer)
124+
const zeroLayerNodes = nodes.filter(d => d.layer === 0)
125+
const maxLayerNodes = nodes.filter(d => d.layer === maxLayer)
126+
const layerSpacing = this._getLayerSpacing(nodes)
127+
128+
let left = 0
129+
const fallbackLabelMaxWidth = Math.min(this._width / 6, layerSpacing)
130+
const labelMaxWidth = config.labelMaxWidth || fallbackLabelMaxWidth
131+
const labelHorizontalPadding = 2 * SANKEY_LABEL_SPACING + 2 * SANKEY_LABEL_BLOCK_PADDING
132+
const hasLabelsOnTheLeft = zeroLayerNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Left)
133+
134+
const estimateRequiredLabelWidth = (d: SankeyNode<N, L>, config: SankeyConfigInterface<N, L>): number => {
135+
const inlineLabelAddWidth = 5 // Without this, the label anf sub-label will look too close to each other
136+
const tolerance = 1.1
137+
const isSublabelInline = config.subLabelPlacement === SankeySubLabelPlacement.Inline
138+
const labelText = `${getString(d, config.label) ?? ''}` // Stringify because theoretically it can be a number
139+
const sublabelText = `${getString(d, config.subLabel) ?? ''}` // Stringify because theoretically it can be a number
140+
const labelTextWidth = tolerance * estimateStringPixelLength(labelText, labelFontSize)
141+
const sublabelTextWidth = tolerance * estimateStringPixelLength(sublabelText, subLabelFontSize)
142+
return isSublabelInline ? inlineLabelAddWidth + (labelTextWidth + sublabelTextWidth) : Math.max(labelTextWidth, sublabelTextWidth)
143+
}
144+
if (hasLabelsOnTheLeft) {
145+
const maxLeftLabelWidth = max(zeroLayerNodes, d => estimateRequiredLabelWidth(d, config))
146+
left = min([labelMaxWidth, maxLeftLabelWidth]) + labelHorizontalPadding
147+
}
148+
149+
let right = 0
150+
const hasLabelsOnTheRight = maxLayerNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Right)
151+
if (hasLabelsOnTheRight) {
152+
const maxRightLabelWidth = max(maxLayerNodes, d => estimateRequiredLabelWidth(d, config))
153+
right = min([labelMaxWidth, maxRightLabelWidth]) + labelHorizontalPadding
154+
}
155+
156+
const top = config.labelVerticalAlign === VerticalAlign.Top ? 0
157+
: config.labelVerticalAlign === VerticalAlign.Bottom ? requiredLabelHeight
158+
: requiredLabelHeight / 2
159+
160+
const bottom = config.labelVerticalAlign === VerticalAlign.Top ? requiredLabelHeight
161+
: config.labelVerticalAlign === VerticalAlign.Bottom ? 0
162+
: requiredLabelHeight / 2
163+
164+
const nodeSelectionRectBleed = config.selectedNodeIds ? 1 + NODE_SELECTION_RECT_DELTA : 0
165+
bleed = {
166+
top: nodeSelectionRectBleed + top,
167+
bottom: nodeSelectionRectBleed + bottom,
168+
left: left + (hasLabelsOnTheLeft ? 0 : nodeSelectionRectBleed),
169+
right: right + (hasLabelsOnTheRight ? 0 : nodeSelectionRectBleed),
170+
}
144171
}
145172

173+
146174
// Cache bleed for onZoom
147175
this._bleedCached = bleed
148176

@@ -200,7 +228,9 @@ export class Sankey<
200228

201229
_render (customDuration?: number): void {
202230
const { config, datamodel: { nodes, links } } = this
203-
const bleed = this._bleedCached ?? this.bleed
231+
const wasResized = this._prevWidth !== this._width
232+
this._prevWidth = this._width
233+
const bleed = wasResized || !this._bleedCached ? this.bleed : this._bleedCached
204234
const duration = isNumber(customDuration) ? customDuration : config.duration
205235

206236
if (
@@ -327,7 +357,7 @@ export class Sankey<
327357
const transform = event.transform
328358
const sourceEvent = event.sourceEvent as WheelEvent | MouseEvent | TouchEvent | undefined
329359
const zoomMode = config.zoomMode || SankeyZoomMode.XY
330-
const bleed = this._bleedCached
360+
const bleed = this._bleedCached ?? this.bleed
331361

332362
// Zoom pivots
333363
const minX = min(nodes, d => d.x0) ?? 0
@@ -441,7 +471,7 @@ export class Sankey<
441471

442472
private _prepareLayout (): void {
443473
const { config, datamodel } = this
444-
const bleed = this._bleedCached
474+
const bleed = this._bleedCached ?? this.bleed
445475
const isExtendedSize = this.sizing === Sizing.Extend
446476
const sankeyHeight = this.sizing === Sizing.Fit ? this._height : this._extendedHeight
447477
const sankeyWidth = this.sizing === Sizing.Fit ? this._width : this._extendedWidth
@@ -665,23 +695,25 @@ export class Sankey<
665695
const { config } = this
666696
if (!nodes?.length) return 0
667697

668-
const firstNode = nodes[0]
669-
const nextLayerNode = nodes.find(d => d.layer === firstNode.layer + 1)
670-
return nextLayerNode ? nextLayerNode.x0 - (firstNode.x0 + config.nodeWidth) : this._width - firstNode.x1
698+
const firstLayerNode = nodes.find(d => d.layer === 0)
699+
const nextLayerNode = nodes.find(d => d.layer === firstLayerNode.layer + 1)
700+
return nextLayerNode ? nextLayerNode.x0 - (firstLayerNode.x0 + config.nodeWidth) : this._width - firstLayerNode.x1
671701
}
672702

673703
private _onNodeMouseOver (d: SankeyNode<N, L>, event: MouseEvent): void {
674704
const { datamodel } = this
705+
const bleed = this._bleedCached ?? this.bleed
675706
const nodeSelection = select<SVGGElement, SankeyNode<N, L>>(event.currentTarget as SVGGElement)
676707
const sankeyWidth = this.sizing === Sizing.Fit ? this._width : this._extendedWidth
677-
onNodeMouseOver(d, datamodel.nodes, nodeSelection, this.config, sankeyWidth, this._getLayerSpacing(this.datamodel.nodes))
708+
onNodeMouseOver(d, datamodel.nodes, nodeSelection, this.config, sankeyWidth, this._getLayerSpacing(this.datamodel.nodes), bleed)
678709
}
679710

680711
private _onNodeMouseOut (d: SankeyNode<N, L>, event: MouseEvent): void {
681712
const { datamodel } = this
713+
const bleed = this._bleedCached ?? this.bleed
682714
const nodeSelection = select<SVGGElement, SankeyNode<N, L>>(event.currentTarget as SVGGElement)
683715
const sankeyWidth = this.sizing === Sizing.Fit ? this._width : this._extendedWidth
684-
onNodeMouseOut(d, datamodel.nodes, nodeSelection, this.config, sankeyWidth, this._getLayerSpacing(this.datamodel.nodes))
716+
onNodeMouseOut(d, datamodel.nodes, nodeSelection, this.config, sankeyWidth, this._getLayerSpacing(this.datamodel.nodes), bleed)
685717
}
686718

687719
private _onNodeRectMouseOver (d: SankeyNode<N, L>): void {

packages/ts/src/components/sankey/modules/label.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getCSSVariableValueInPixels } from 'utils/misc'
1010
import { GenericAccessor } from 'types/accessor'
1111
import { FitMode, VerticalAlign } from 'types/text'
1212
import { Position } from 'types/position'
13+
import { Spacing } from 'types/spacing'
1314

1415
// Local Types
1516
import { SankeyInputLink, SankeyInputNode, SankeyNode, SankeySubLabelPlacement } from '../types'
@@ -20,8 +21,8 @@ import { SankeyConfigInterface } from '../config'
2021
// Styles
2122
import * as s from '../style'
2223

23-
export const NODE_LABEL_SPACING = 10
24-
export const LABEL_BLOCK_PADDING = 6.5
24+
export const SANKEY_LABEL_SPACING = 10
25+
export const SANKEY_LABEL_BLOCK_PADDING = 6.5
2526

2627
function getLabelBackground (
2728
width: number,
@@ -61,32 +62,23 @@ export function getLabelOrientation<N extends SankeyInputNode, L extends SankeyI
6162
): (Position.Left | Position.Right) {
6263
let orientation = getValue(d, labelPosition)
6364
if (orientation === Position.Auto || !orientation) {
64-
orientation = d.x0 < sankeyWidth / 2 ? Position.Left : Position.Right
65+
orientation = d.x1 < sankeyWidth / 2 ? Position.Left : Position.Right
6566
}
6667

6768
return orientation as (Position.Left | Position.Right)
6869
}
6970

70-
export const requiredLabelSpace = (labelWidth: number, labelFontSize: number): { width: number; height: number } => {
71-
if (labelWidth === 0) return { width: 0, height: 0 }
72-
73-
return {
74-
height: labelFontSize * 2.5 + 2 * LABEL_BLOCK_PADDING, // Assuming 2.5 lines per label
75-
width: labelWidth + 2 * NODE_LABEL_SPACING + 2 * LABEL_BLOCK_PADDING,
76-
}
77-
}
78-
7971
export function getLabelGroupXTranslate<N extends SankeyInputNode, L extends SankeyInputLink> (
8072
d: SankeyNode<N, L>,
8173
config: SankeyConfigInterface<N, L>,
8274
width: number
8375
): number {
8476
const orientation = getLabelOrientation(d, width, config.labelPosition)
8577
switch (orientation) {
86-
case Position.Right: return config.nodeWidth + NODE_LABEL_SPACING
78+
case Position.Right: return config.nodeWidth + SANKEY_LABEL_SPACING
8779
case Position.Left:
8880
default:
89-
return -NODE_LABEL_SPACING
81+
return -SANKEY_LABEL_SPACING
9082
}
9183
}
9284

@@ -135,19 +127,45 @@ export function getSubLabelTextAnchor<N extends SankeyInputNode, L extends Sanke
135127
}
136128
}
137129

130+
export function getLabelMaxWidth<N extends SankeyInputNode, L extends SankeyInputLink> (
131+
d: SankeyNode<N, L>,
132+
config: SankeyConfigInterface<N, L>,
133+
labelOrientation: Position.Left | Position.Right,
134+
layerSpacing: number,
135+
sankeyMaxLayer: number,
136+
bleed: Spacing
137+
): number {
138+
const labelHorizontalPadding = 2 * SANKEY_LABEL_SPACING + 2 * SANKEY_LABEL_BLOCK_PADDING
139+
140+
// We want to fall through to the default case
141+
/* eslint-disable no-fallthrough */
142+
switch (d.layer) {
143+
case 0:
144+
if (labelOrientation === Position.Left) return bleed.left - labelHorizontalPadding
145+
case (sankeyMaxLayer):
146+
if (labelOrientation === Position.Right) {
147+
return bleed.right - labelHorizontalPadding
148+
}
149+
default:
150+
return clamp(layerSpacing - labelHorizontalPadding, 0, config.labelMaxWidth ?? Infinity)
151+
} /* eslint-enable no-fallthrough */
152+
}
153+
138154
export function renderLabel<N extends SankeyInputNode, L extends SankeyInputLink> (
139155
labelGroup: Selection<SVGGElement, SankeyNode<N, L>, SVGGElement, any>,
140156
d: SankeyNode<N, L>,
141157
config: SankeyConfigInterface<N, L>,
142158
width: number,
143159
duration: number,
144-
forceExpand = false,
145-
layerSpacing: number | undefined = undefined
160+
layerSpacing: number | undefined,
161+
sankeyMaxLayer: number,
162+
bleed: Spacing,
163+
forceExpand = false
146164
): { x: number; y: number; width: number; height: number; layer: number; selection: any; hidden?: boolean } {
147165
const labelTextSelection: Selection<SVGTextElement, SankeyNode<N, L>, SVGGElement, SankeyNode<N, L>> = labelGroup.select(`.${s.label}`)
148166
const labelShowBackground = config.labelBackground || forceExpand
149167
const sublabelTextSelection: Selection<SVGTextElement, SankeyNode<N, L>, SVGGElement, SankeyNode<N, L>> = labelGroup.select(`.${s.sublabel}`)
150-
const labelPadding = labelShowBackground ? LABEL_BLOCK_PADDING : 0
168+
const labelPadding = labelShowBackground ? SANKEY_LABEL_BLOCK_PADDING : 0
151169
const isSublabelInline = config.subLabelPlacement === SankeySubLabelPlacement.Inline
152170
const separator = config.labelForceWordBreak ? '' : config.labelTextSeparator
153171
const fastEstimatesMode = true // Fast but inaccurate
@@ -173,7 +191,7 @@ export function renderLabel<N extends SankeyInputNode, L extends SankeyInputLink
173191
.attr('transform', `translate(${labelOrientationMult * labelPadding},${labelTranslateY})`)
174192
.style('cursor', (d: SankeyNode<N, L>) => getString(d, config.labelCursor))
175193

176-
const labelMaxWidth = clamp(layerSpacing - 2 * NODE_LABEL_SPACING - 2 * LABEL_BLOCK_PADDING, 0, config.labelMaxWidth ?? Infinity)
194+
const labelMaxWidth = getLabelMaxWidth(d, config, labelOrientation, layerSpacing, sankeyMaxLayer, bleed)
177195
const labelWrapTrimWidth = isSublabelInline
178196
? labelMaxWidth * (1 - (sublabelText ? config.subLabelToLabelInlineWidthRatio : 0))
179197
: labelMaxWidth

0 commit comments

Comments
 (0)