❮ Back to Gallery
Expandable Sankey
Spending patterns of UK households, 2019 (source: Office for National Statistics)
- React
- Angular
- Svelte
- Vue
- Solid
- TypeScript
- Data
import React, { useCallback, useState } from 'react'
import { FitMode, Sankey, SankeyLink, SankeyNode, SankeySubLabelPlacement, VerticalAlign } from '@unovis/ts'
import { VisSingleContainer, VisSankey } from '@unovis/react'
import { sankeyData, root, Node, Link } from './data'
export default function ExpandableSankey (): JSX.Element {
const subLabelPlacement = window.innerHeight > window.innerWidth
? SankeySubLabelPlacement.Below
: SankeySubLabelPlacement.Inline
const [data, setData] = useState<{ nodes: Node[]; links: Link[] }>(sankeyData)
const toggleGroup = useCallback((n: SankeyNode<Node, Link>): void => {
if (n.expandable) {
if (n.expanded) {
sankeyData.collapse(n)
} else {
sankeyData.expand(n)
}
setData({ nodes: sankeyData.nodes, links: sankeyData.links })
}
}, [data])
return (
<VisSingleContainer data={data} height={'min(60vh,75vw)'}>
<VisSankey
labelFit={FitMode.Wrap}
labelForceWordBreak={false}
labelMaxWidth={window.innerWidth * 0.12}
labelVerticalAlign={VerticalAlign.Middle}
nodeIcon={useCallback((d: Node) => d.expandable ? (d.expanded ? '-' : '+') : '', [])}
nodeCursor={useCallback((d: Node) => d.expandable ? 'pointer' : null, [])}
nodePadding={20}
linkColor={useCallback((d: SankeyLink<Node, Link>) => d.source.color ?? null, [])}
subLabelPlacement={subLabelPlacement}
subLabel={useCallback((d: SankeyNode<Node, Link>) => ((d.depth === 0) || d.expanded)
? ''
: `${((d.value / root.value) * 100).toFixed(1)}%`
, [])}
events={{
[Sankey.selectors.node]: {
click: toggleGroup,
},
}}
/>
</VisSingleContainer>
)
}
expandable-sankey.html
<vis-single-container #vis [data]="data" [height]="'60vh'">
<vis-sankey
labelFit="wrap"
labelVerticalAlign="middle"
subLabelPlacement="inline"
[events]="events"
[labelForceWordBreak]="false"
[labelMaxWidth]="150"
[linkColor]="linkColor"
[nodeCursor]="nodeCursor"
[nodeIcon]="nodeIcon"
[nodePadding]="20"
[subLabel]="subLabel"
></vis-sankey>
</vis-single-container>
expandable-sankey.component.ts
import { Component, ViewChild } from '@angular/core'
import { VisSingleContainerComponent } from '@unovis/angular'
import { Sankey, SankeyLink, SankeyNode } from '@unovis/ts'
import { sankeyData, root, Node, Link } from './data'
@Component({
selector: 'expandable-sankey',
templateUrl: './expandable-sankey.component.html',
})
export class ExpandableSankeyComponent {
@ViewChild('vis') vis: VisSingleContainerComponent<{ nodes: Node[]; links: Link[] }>
data = { nodes: sankeyData.nodes, links: sankeyData.links }
events = {
[Sankey.selectors.node]: {
click: this.toggleGroup.bind(this),
},
}
linkColor = (d: SankeyLink<Node, Link>): string => d.source.color ?? null
nodeCursor = (d: Node): string => d.expandable ? 'pointer' : null
nodeIcon = (d: Node): string => !d.expandable ? '' : (d.expanded ? '-' : '+')
subLabel = (d: SankeyNode<Node, Link>): string => {
if (d.expanded || d.depth === 0) return ''
return `${((d.value / root.value) * 100).toFixed(1)}%`
}
toggleGroup (n: Node): void {
if (n.expandable) {
if (n.expanded) {
sankeyData.collapse(n)
} else {
sankeyData.expand(n)
}
this.vis.chart.setData(sankeyData)
}
}
}
expandable-sankey.module.ts
import { NgModule } from '@angular/core'
import { VisSingleContainerModule, VisSankeyModule } from '@unovis/angular'
import { ExpandableSankeyComponent } from './expandable-sankey.component'
@NgModule({
imports: [VisSingleContainerModule, VisSankeyModule],
declarations: [ExpandableSankeyComponent],
exports: [ExpandableSankeyComponent],
})
export class ExpandableSankeyModule { }
<script lang='ts'>
import { FitMode, Sankey, SankeyLink, SankeyNode, SankeySubLabelPlacement, VerticalAlign } from '@unovis/ts'
import { VisSingleContainer, VisSankey } from '@unovis/svelte'
import { sankeyData, root, Node, Link } from './data'
let data = { nodes: sankeyData.nodes, links: sankeyData.links }
function toggleGroup (n: Node): void {
if (n.expandable) {
if (n.expanded) {
sankeyData.collapse(n)
} else {
sankeyData.expand(n)
}
data = sankeyData
}
}
const callbacks = {
linkColor: (d: SankeyLink<Node, Link>): string => d.source.color ?? null,
nodeCursor: (d: Node) => d.expandable ? 'pointer' : null,
nodeIcon: (d: Node): string => d.expandable ? (d.expanded ? '-' : '+') : '',
subLabel: (d: SankeyNode<Node, Link>): string => (d.depth === 0 || d.expanded)
? ''
: `${((d.value / root.value) * 100).toFixed(1)}%`,
events: {
[Sankey.selectors.node]: {
click: toggleGroup,
},
},
}
</script>
<VisSingleContainer {data} height={'min(60vh,75vw)'}>
<VisSankey
{...callbacks}
labelFit={FitMode.Wrap}
labelForceWordBreak={false}
labelMaxWidth={150}
labelVerticalAlign={VerticalAlign.Middle}
nodePadding={20}
subLabelPlacement={SankeySubLabelPlacement.Inline}
/>
</VisSingleContainer>
<script setup lang="ts">
import { FitMode, Sankey, SankeyLink, SankeyNode, SankeySubLabelPlacement, VerticalAlign } from '@unovis/ts'
import { VisSingleContainer, VisSankey } from '@unovis/vue'
import { sankeyData, root, Node, Link } from './data'
import { ref } from "vue"
const data = ref({ nodes: sankeyData.nodes, links: sankeyData.links })
function toggleGroup(n: SankeyNode<Node, Link>): void {
if (n.expandable) {
if (n.expanded) {
sankeyData.collapse(n)
} else {
sankeyData.expand(n)
}
data.value = ({ nodes: sankeyData.nodes, links: sankeyData.links })
}
}
const callbacks = {
linkColor: (d: SankeyLink<Node, Link>): string => d.source.color ?? null,
nodeCursor: (d: Node) => d.expandable ? 'pointer' : null,
nodeIcon: (d: Node): string => d.expandable ? (d.expanded ? '-' : '+') : '',
subLabel: (d: SankeyNode<Node, Link>): string => (d.depth === 0 || d.expanded)
? ''
: `${((d.value / root.value) * 100).toFixed(1)}%`,
events: {
[Sankey.selectors.node]: {
click: toggleGroup,
},
},
}
</script>
<template>
<VisSingleContainer :data="data" height="min(60vh,75vw)">
<VisSankey v-bind="callbacks" :labelFit="FitMode.Wrap" :labelForceWordBreak="false" :labelMaxWidth="150"
:labelVerticalAlign="VerticalAlign.Middle" :nodePadding="20" :subLabelPlacement="SankeySubLabelPlacement.Inline" />
</VisSingleContainer>
</template>
import { FitMode, Sankey, SankeySubLabelPlacement, VerticalAlign } from '@unovis/ts'
import { VisSankey, VisSingleContainer } from '@unovis/solid'
import { createSignal, JSX } from 'solid-js'
import type { SankeyLink, SankeyNode } from '@unovis/ts'
import { sankeyData, root, Node, Link } from './data'
const ExpandableSankey = (): JSX.Element => {
const [data, setData] = createSignal(sankeyData)
function toggleGroup (n: SankeyNode<Node, Link>): void {
if (n.expandable) {
if (n.expanded) {
sankeyData.collapse(n)
} else {
sankeyData.expand(n)
}
setData({ nodes: sankeyData.nodes, links: sankeyData.links })
}
}
const callbacks = {
linkColor: (d: SankeyLink<Node, Link>): string => d.source.color ?? null,
nodeCursor: (d: Node) => (d.expandable ? 'pointer' : null),
nodeIcon: (d: Node): string =>
d.expandable ? (d.expanded ? '-' : '+') : '',
subLabel: (d: SankeyNode<Node, Link>): string =>
d.depth === 0 || d.expanded
? ''
: `${((d.value / root.value) * 100).toFixed(1)}%`,
events: {
[Sankey.selectors.node]: {
click: toggleGroup,
},
},
}
return (
<VisSingleContainer data={data()} height='50dvh'>
<VisSankey
{...callbacks}
labelFit={FitMode.Wrap}
labelForceWordBreak={false}
labelMaxWidth={150}
labelVerticalAlign={VerticalAlign.Middle}
nodePadding={20}
subLabelPlacement={SankeySubLabelPlacement.Inline}
/>
</VisSingleContainer>
)
}
export default ExpandableSankey
import { SingleContainer, Sankey, FitMode, SankeyNode, SankeySubLabelPlacement, VerticalAlign, SankeyLink } from '@unovis/ts'
import { sankeyData, root, Node, Link } from './data'
// initialize chart
const container = document.getElementById('vis-container')
const chart = new SingleContainer(container)
// node click event listener
function toggleGroup (n: SankeyNode<Node, Link>): void {
if (n.expandable) {
if (n.expanded) {
sankeyData.collapse(n)
} else {
sankeyData.expand(n)
}
chart.setData({ nodes: sankeyData.nodes, links: sankeyData.links })
}
}
// main component
const sankey = new Sankey<Node, Link>({
events: {
[Sankey.selectors.node]: {
click: toggleGroup,
},
},
labelFit: FitMode.Wrap,
labelForceWordBreak: false,
labelMaxWidth: 150,
labelVerticalAlign: VerticalAlign.Middle,
linkColor: (d: SankeyLink<Node, Link>) => d.source.color ?? null,
nodeCursor: (d: Node) => d.expandable ? 'pointer' : null,
nodeIcon: (d: Node) => d.expandable ? (d.expanded ? '-' : '+') : '',
nodePadding: 20,
subLabel: (d: SankeyNode<Node, Link>): string => ((d.depth === 0) || d.expanded)
? ''
: `${((d.value / root.value) * 100).toFixed(1)}%`,
subLabelPlacement: window.innerHeight > window.innerWidth
? SankeySubLabelPlacement.Below
: SankeySubLabelPlacement.Inline,
})
chart.update({
component: sankey,
height: 'min(60vh,75vw)',
}, sankey.config, sankeyData)
import type { SankeyNode } from '@unovis/ts'
export type Node = {
id: string;
label: string;
value: number;
subgroups: Node[];
color?: string;
expandable?: boolean;
expanded?: boolean;
}
export type Link = { source: string; target: string; value: number }
export type Sankey<N extends Node, L extends Link> = {
nodes: N[];
links: L[];
expand: (n: SankeyNode<N, L>) => void;
collapse: (n: SankeyNode<N, L>) => void;
}
const categories = [
{
label: 'Consumables',
color: '#1acb9a',
value: 83.3,
subgroups: [
{ label: 'Food', value: 63.3 },
{
label: 'Drinks',
value: 17.2,
subgroups: [
{ label: 'Non-alcoholic', value: 5.9 },
{ label: 'Alcoholic', value: 11.3 },
],
},
{ label: 'Tobacco/narcotics', value: 2.8 },
],
},
{ label: 'Apparel', value: 14.5 },
{
label: 'Household expenses',
color: '#6A9DFF',
value: 131.4,
subgroups: [
{ label: 'Rent', value: 51.2 },
{
label: 'Utilities',
value: 34.3,
subgroups: [
{ label: 'Water', value: 9.9 },
{ label: 'Power', value: 23.2 },
{ label: 'Other', value: 1.2 },
],
},
{ label: 'Goods & Furninshings', value: 15.0 },
{ label: 'Repair', value: 10 },
{ label: 'Phone/Internet', value: 20.9 },
],
},
{
label: 'Transport',
color: '#8ee422',
value: 60.8,
subgroups: [
{ label: 'Personal transport', value: 51.2 },
{ label: 'Public transport', value: 9.6 },
],
},
{
label: 'Individual expenses',
color: '#a611a5',
value: 37.0,
subgroups: [
{ label: 'Insurance', value: 20.4 },
{ label: 'Healthcare', value: 6.7 },
{ label: 'Personal care', value: 9.9 },
],
},
{
label: 'Recreation & Culture',
color: '#f88080',
value: 45.5,
subgroups: [
{ label: 'Electronics', value: 5.0 },
{ label: 'Events & Activities', value: 11.3 },
{ label: 'Pets', value: 6.7 },
{ label: 'Hobbies', value: 16.3 },
{ label: 'Other', value: 6.2 },
],
},
{ label: 'Education', value: 8.3 },
{ label: 'Restaurant & Hotels', value: 18.8 },
{ label: 'Miscellaneous', value: 15.0 },
]
const getNodes = (n: Node): Node[] => n.subgroups?.map((child, i) => ({
...child,
id: [n.id, i].join(''),
color: child.color ?? n.color,
expanded: false,
expandable: child.subgroups?.length > 0,
}))
const getLinks = (n: Node): Link[] => n.subgroups.map(target => ({
source: n.id,
target: target.id,
value: target.value,
}))
const generate = (n: Node): Node => ({ ...n, subgroups: getNodes(n) })
export const root: Node = generate({
id: 'root',
label: 'Average Weekly Expenditure',
value: 414.7,
expanded: true,
expandable: true,
subgroups: categories as Node[],
})
export const sankeyData: Sankey<Node, Link> = {
nodes: [root, ...root.subgroups],
links: getLinks(root),
expand: function (n: SankeyNode<Node, Link>): void {
n.subgroups = getNodes(n)
this.nodes[n.index].expanded = true
this.nodes = this.nodes.concat(n.subgroups)
this.links = this.links.concat(getLinks(n))
},
collapse: function (n: SankeyNode<Node, Link>): void {
this.nodes[n.index].expanded = false
this.nodes = this.nodes.filter(d => d.id === n.id || !d.id.startsWith(n.id))
this.links = this.links.filter(d => !d.source.startsWith(n.id))
},
}