diff --git a/packages/dev/src/examples/networks-and-flows/sankey/sankey-node-collapse/data.ts b/packages/dev/src/examples/networks-and-flows/sankey/sankey-node-collapse/data.ts new file mode 100644 index 000000000..98d03aaa0 --- /dev/null +++ b/packages/dev/src/examples/networks-and-flows/sankey/sankey-node-collapse/data.ts @@ -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 }, + ], +} diff --git a/packages/dev/src/examples/networks-and-flows/sankey/sankey-node-collapse/index.tsx b/packages/dev/src/examples/networks-and-flows/sankey/sankey-node-collapse/index.tsx new file mode 100644 index 000000000..5a5efdaa5 --- /dev/null +++ b/packages/dev/src/examples/networks-and-flows/sankey/sankey-node-collapse/index.tsx @@ -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 ( +
+ + + // 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} + /> + +
+ ) +} diff --git a/packages/ts/src/components/sankey/config.ts b/packages/ts/src/components/sankey/config.ts index 3b4983284..6698f9639 100644 --- a/packages/ts/src/components/sankey/config.ts +++ b/packages/ts/src/components/sankey/config.ts @@ -51,6 +51,16 @@ export interface SankeyConfigInterface, 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): void { + const isCurrentlyCollapsed = node._state?.collapsed ?? false + this._setNodeCollapseState(node, !isCurrentlyCollapsed) + } + private _hasLinks (): boolean { const { datamodel } = this return datamodel.links.length > 0 @@ -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, event: MouseEvent): void { const { datamodel } = this @@ -729,4 +787,11 @@ export class Sankey< private _onLinkMouseOut (): void { this.disableHighlight() } + + private _onNodeClick (d: SankeyNode, event: MouseEvent): void { + const { config } = this + if (config.enableNodeCollapse) { + this.toggleNodeCollapse(d) + } + } } diff --git a/packages/ts/src/components/sankey/modules/link.ts b/packages/ts/src/components/sankey/modules/link.ts index 0836f8979..4d8d2b893 100644 --- a/packages/ts/src/components/sankey/modules/link.ts +++ b/packages/ts/src/components/sankey/modules/link.ts @@ -81,10 +81,19 @@ export function updateLinks) => d._state.greyout ? 0.2 : 1) + .style('opacity', (d: SankeyLink) => { + // 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(`.${s.linkPath}`) .style('cursor', (d: SankeyLink) => getString(d, config.linkCursor)) + .style('pointer-events', (d: SankeyLink) => { + // 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) => getColor(link, config.linkColor)) @@ -133,6 +142,7 @@ export function updateLinks getString(d, config.linkCursor)) + .style('pointer-events', (d: SankeyLink) => (d.source._state?.collapsed || d.target._state?.collapsed) ? 'none' : null) } export function removeLinks ( diff --git a/packages/ts/src/components/sankey/types.ts b/packages/ts/src/components/sankey/types.ts index 74ae1a84c..6702b9fe9 100644 --- a/packages/ts/src/components/sankey/types.ts +++ b/packages/ts/src/components/sankey/types.ts @@ -42,6 +42,8 @@ export type SankeyNode = 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; }; } diff --git a/packages/website/docs/networks-and-flows/Sankey.mdx b/packages/website/docs/networks-and-flows/Sankey.mdx index 903bfb80e..2d955cd19 100644 --- a/packages/website/docs/networks-and-flows/Sankey.mdx +++ b/packages/website/docs/networks-and-flows/Sankey.mdx @@ -32,6 +32,15 @@ export const SankeyDoc = (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. @@ -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. + + ## Sorting By default, _Sankey_ will sort the links based on their `value` in descending order from top to bottom. diff --git a/packages/website/docs/utils/data.ts b/packages/website/docs/utils/data.ts index 55ff6eeec..1beabae4d 100644 --- a/packages/website/docs/utils/data.ts +++ b/packages/website/docs/utils/data.ts @@ -65,6 +65,7 @@ type NodeDatum = { id: string; label?: string; value?: number; + disabled?: boolean; // For collapse functionality } type LinkDatum = { @@ -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] }) }