Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export type Node = {
id: string;
label: string;
value: number;
color?: string;
disabled?: boolean; // New field to test the disabledField functionality
}

export type Link = {
source: string;
target: string;
value: number;
}

export const collapseExampleData = {
nodes: [
// Layer 0 (sources)
{ id: 'A', label: 'Source A', value: 100 },
{ id: 'B', label: 'Source B', value: 80 },

// Layer 1 (intermediate level 1) - Process D is disabled/pre-collapsed
{ id: 'C', label: 'Process C', value: 90 },
{ id: 'D', label: 'Process D (Disabled)', value: 70, disabled: true },
{ id: 'E', label: 'Process E', value: 60 },

// Layer 2 (intermediate level 2) - Transform F is disabled/pre-collapsed
{ id: 'F', label: 'Transform F (Disabled)', value: 80, disabled: true },
{ id: 'G', label: 'Transform G', value: 60 },

// Layer 3 (destinations)
{ id: 'H', label: 'End H', value: 60 },
{ id: 'I', label: 'End I', value: 70 },
],
links: [
// Layer 0 to 1
{ source: 'A', target: 'C', value: 60 },
{ source: 'A', target: 'D', value: 40 },
{ source: 'B', target: 'D', value: 30 },
{ source: 'B', target: 'E', value: 50 },

// Layer 1 to 2
{ source: 'C', target: 'F', value: 50 },
{ source: 'C', target: 'G', value: 40 },
{ source: 'D', target: 'F', value: 30 },
{ source: 'E', target: 'G', value: 20 },

// Layer 2 to 3
{ source: 'F', target: 'H', value: 40 },
{ source: 'F', target: 'I', value: 40 },
{ source: 'G', target: 'H', value: 25 },
{ source: 'G', target: 'I', value: 35 },
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { useState } from 'react'
import { VisSingleContainer, VisSankey } from '@unovis/react'
import { Sizing } from '@unovis/ts'

import { collapseExampleData, Node, Link } from './data'

export const title = 'Sankey Node Collapse'
export const subTitle = 'Click on nodes to collapse/expand them'

export const component = (): React.ReactNode => {
const [data] = useState<{ nodes: Node[]; links: Link[] }>(collapseExampleData)

return (
<div>
<VisSingleContainer data={data} sizing={Sizing.Fit} height={400}>
<VisSankey<Node, Link>
// Enable the new collapse functionality
enableNodeCollapse={true}
collapseAnimationDuration={500}
disabledField="disabled" // Pre-collapse nodes with disabled: true
nodeWidth={30}
nodePadding={10}
nodeColor={(d: Node) => d.color}
label={(d: Node) => d.label}
labelPosition="auto"
labelMaxWidth={100}
linkValue={(d) => d.value}
/>
</VisSingleContainer>
</div>
)
}
13 changes: 13 additions & 0 deletions packages/ts/src/components/sankey/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export interface SankeyConfigInterface<N extends SankeyInputNode, L extends Sank
/** Sankey algorithm iterations. Default: `32` */
iterations?: number;

// Collapse/Expand
/** Enable node collapse functionality. When enabled, clicking on nodes will toggle their collapse state. Default: `false` */
enableNodeCollapse?: boolean;
/** Node collapse animation duration, ms. Default: `300` */
collapseAnimationDuration?: number;
/** Field name in the node data that indicates if a node should be pre-collapsed.
* For example, if set to "disabled", nodes with `disabled: true` will start collapsed.
* Default: `undefined` */
disabledField?: string;

// Sorting
/** Sankey node sorting function. Default: `undefined`.
* Node sorting is applied to nodes in one layer (column). Layer by layer.
Expand Down Expand Up @@ -179,6 +189,9 @@ export const SankeyDefaultConfig: SankeyConfigInterface<SankeyInputNode, SankeyI
highlightDuration: 300,
highlightDelay: 1000,
iterations: 32,
enableNodeCollapse: false,
collapseAnimationDuration: 300,
disabledField: undefined,
nodeSort: undefined,
nodeWidth: 25,
nodeAlign: SankeyNodeAlign.Justify,
Expand Down
65 changes: 65 additions & 0 deletions packages/ts/src/components/sankey/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export class Sankey<
[Sankey.selectors.node]: {
mouseenter: this._onNodeRectMouseOver.bind(this),
mouseleave: this._onNodeRectMouseOut.bind(this),
click: this._onNodeClick.bind(this),
},
[Sankey.selectors.link]: {
mouseenter: this._onLinkMouseOver.bind(this),
Expand Down Expand Up @@ -175,6 +176,9 @@ export class Sankey<
setData (data: { nodes: N[]; links?: L[] }): void {
super.setData(data)

// Pre-collapse nodes based on disabledField
this._applyInitialCollapseState()

// Pre-calculate component size for Sizing.EXTEND
if ((this.sizing !== Sizing.Fit) || !this._hasLinks()) this._preCalculateComponentSize()
this._bleedCached = null
Expand All @@ -197,6 +201,8 @@ export class Sankey<
} else if (this.prevConfig.zoomPan !== undefined) {
this._pan = [0, 0]
}
// Apply initial collapse state if disabledField is set
this._applyInitialCollapseState()

// Pre-calculate component size for Sizing.EXTEND
if ((this.sizing !== Sizing.Fit) || !this._hasLinks()) this._preCalculateComponentSize()
Expand Down Expand Up @@ -681,6 +687,34 @@ export class Sankey<
}
}

/**
* Sets the collapse state of a node and triggers re-rendering.
*/
private _setNodeCollapseState (node: SankeyNode<N, L>, collapsed: boolean): void {
const { config } = this

// Clear any active highlights before changing state
this.disableHighlight()

node._state = node._state || {}
node._state.collapsed = collapsed
this._render(config.collapseAnimationDuration)
}

/**
* Toggles the collapse state of a node.
* Collapses a node by hiding only the links directly connected to it.
* All other nodes (including children and descendants) remain visible in their original positions.
* Only the immediate incoming and outgoing links of the collapsed node are hidden.
*
* Expands a previously collapsed node by showing its directly connected links.
* @param node The node to toggle
*/
toggleNodeCollapse (node: SankeyNode<N, L>): void {
const isCurrentlyCollapsed = node._state?.collapsed ?? false
this._setNodeCollapseState(node, !isCurrentlyCollapsed)
}

private _hasLinks (): boolean {
const { datamodel } = this
return datamodel.links.length > 0
Expand All @@ -694,6 +728,30 @@ export class Sankey<
const nextLayerNode = nodes.find(d => d.layer === firstLayerNode.layer + 1)
return nextLayerNode ? nextLayerNode.x0 - (firstLayerNode.x0 + config.nodeWidth) : this._width - firstLayerNode.x1
}

/**
* Applies initial collapse state to nodes based on the disabledField configuration.
* If disabledField is set (e.g., "disabled"), nodes with that field set to true
* will be pre-collapsed when the component loads.
*/
private _applyInitialCollapseState (): void {
const { config, datamodel } = this

if (!config.disabledField) return

// Check each node for the disabled field and set initial collapse state
for (const node of datamodel.nodes) {
const inputData = node as unknown as N
const isDisabled = inputData && typeof inputData === 'object' &&
config.disabledField in inputData &&
(inputData as any)[config.disabledField] === true

if (isDisabled) {
node._state = node._state || {}
node._state.collapsed = true
}
}
}

private _onNodeMouseOver (d: SankeyNode<N, L>, event: MouseEvent): void {
const { datamodel } = this
Expand Down Expand Up @@ -729,4 +787,11 @@ export class Sankey<
private _onLinkMouseOut (): void {
this.disableHighlight()
}

private _onNodeClick (d: SankeyNode<N, L>, event: MouseEvent): void {
const { config } = this
if (config.enableNodeCollapse) {
this.toggleNodeCollapse(d)
}
}
}
12 changes: 11 additions & 1 deletion packages/ts/src/components/sankey/modules/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,19 @@ export function updateLinks<N extends SankeyInputNode, L extends SankeyInputLink
duration: number
): void {
smartTransition(sel, duration)
.style('opacity', (d: SankeyLink<N, L>) => d._state.greyout ? 0.2 : 1)
.style('opacity', (d: SankeyLink<N, L>) => {
// Hide links if either connected node is collapsed
if (d.source._state?.collapsed || d.target._state?.collapsed) return 0
// Apply greyout effect
return d._state?.greyout ? 0.2 : 1
})

const linkSelection = sel.select<SVGPathElement>(`.${s.linkPath}`)
.style('cursor', (d: SankeyLink<N, L>) => getString(d, config.linkCursor))
.style('pointer-events', (d: SankeyLink<N, L>) => {
// Disable pointer events for collapsed links to prevent hover interference
return (d.source._state?.collapsed || d.target._state?.collapsed) ? 'none' : null
})

const selectionTransition = smartTransition(linkSelection, duration)
.style('fill', (link: SankeyLink<N, L>) => getColor(link, config.linkColor))
Expand Down Expand Up @@ -133,6 +142,7 @@ export function updateLinks<N extends SankeyInputNode, L extends SankeyInputLink
width: Math.max(10, d.width),
}))
.style('cursor', d => getString(d, config.linkCursor))
.style('pointer-events', (d: SankeyLink<N, L>) => (d.source._state?.collapsed || d.target._state?.collapsed) ? 'none' : null)
}

export function removeLinks<N extends SankeyInputNode, L extends SankeyInputLink> (
Expand Down
2 changes: 2 additions & 0 deletions packages/ts/src/components/sankey/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export type SankeyNode<N extends SankeyInputNode, L extends SankeyInputLink> = G
greyout?: boolean;
/* Pre-calculated node height value in pixels that will be used to manually generate the layout when data has no links */
precalculatedHeight?: number;
/** Whether this node is collapsed (hides all connected links) */
collapsed?: boolean;
};
}

Expand Down
34 changes: 34 additions & 0 deletions packages/website/docs/networks-and-flows/Sankey.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export const SankeyDoc = (props) => (
<DocWrapper {...sankeyProps()} {...props}/>
)

export const sankeyCollapseProps = {
data: sankeyData(100, [[1,2], [3,4], [5,6]], 4, ['D', 'F']),
enableNodeCollapse: true,
collapseAnimationDuration: 500,
disabledField: "disabled",
label: d => d.label || d.id,
nodePadding: 15,
}

## Basic Configuration
_Sankey_ is a popular kind of flow diagram that visualizes flows between multiple nodes. To define a Sankey diagram
you'll need to have data about its nodes and flows between them.
Expand Down Expand Up @@ -243,6 +252,31 @@ Use **selectedNodeIds: string[]** to programmatically set selected nodes. Select
selectedNodeIds={['A','C']}
showContext="minimal"
/>
### Pre-collapsed Nodes
You can specify nodes to have a collapsed state by using the `disabledField` property. Any nodes with this field set to `true` will be pre-collapsed when the diagram loads:

```ts
// In your data
const nodes = [
{ id: 'A', label: 'Active Node' },
{ id: 'B', label: 'Pre-collapsed Node', disabled: true }, // This node starts collapsed
]

// In your configuration
{
enableNodeCollapse: true,
disabledField: 'disabled', // Field name that indicates pre-collapsed nodes
}
```

When a node is collapsed:
- Only the links **directly connected** to that node are hidden
- All other nodes (including children and descendants) remain visible in their original positions
- The collapsed node itself remains visible and clickable

This approach differs from traditional tree-like collapse where entire subtrees disappear.

<SankeyDoc {...sankeyCollapseProps} showContext='full'/>

## Sorting
By default, _Sankey_ will sort the links based on their `value` in descending order from top to bottom.
Expand Down
14 changes: 10 additions & 4 deletions packages/website/docs/utils/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type NodeDatum = {
id: string;
label?: string;
value?: number;
disabled?: boolean; // For collapse functionality
}

type LinkDatum = {
Expand Down Expand Up @@ -110,18 +111,23 @@ function generateLinks (n: number, count: number): number[] {
return [val, ...generateLinks(n - val, count - 1)]
}

export const sankeyData = (src: number, edges: [[number, number]], subDataCount = 4): NodeLinkData => {
export const sankeyData = (src: number, edges: [[number, number]], subDataCount = 4, disabledNodes?: string[]): NodeLinkData => {
const nodes = [{ id: 'A', val: src, x: 0 }]
const links = []
for (let i = 0; i < edges.length; i++) {
const vals = generateLinks(nodes[i].val, edges[i].length)
for (let j = 0; j < edges[i].length; j++) {
if (edges[i][j] >= nodes.length) {
nodes.push({
id: String.fromCharCode(65 + nodes.length),
const nodeId = String.fromCharCode(65 + nodes.length)
const node: any = {
id: nodeId,
val: vals[j],
x: Math.floor(Math.random() * subDataCount),
})
}
if (disabledNodes?.includes(nodeId)) {
node.disabled = true
}
nodes.push(node)
}
links.push({ source: nodes[i].id, target: nodes[edges[i][j]].id, value: vals[j] })
}
Expand Down
Loading