Skip to content

Commit 917ef8b

Browse files
committed
Dev | Examples: Sankey Zoom
1 parent 1080d65 commit 917ef8b

File tree

4 files changed

+264
-26
lines changed

4 files changed

+264
-26
lines changed

packages/dev/src/examples/networks-and-flows/sankey/sankey-api-endpoints/data.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { sum } from 'd3-array'
22
import { groupBy } from '@src/utils/array'
3+
import { SankeyLink, SankeyNode } from '@unovis/ts'
34

45
export type ApiEndpointRecord = {
56
collapsedUrl: string;
@@ -121,3 +122,29 @@ export function getSankeyData (apiData: ApiEndpointRecord[], collapsedItems: { [
121122
})),
122123
}
123124
}
125+
126+
export const compareStrings = (a = '', b = ''): number => {
127+
const strA = a.toUpperCase()
128+
const strB = b.toUpperCase()
129+
130+
if (strA < strB) return -1
131+
if (strA > strB) return 1
132+
return 0
133+
}
134+
135+
export const nodeSort = (a: SankeyNode<ApiEndpointNode, ApiEndpointLink>, b: SankeyNode<ApiEndpointNode, ApiEndpointLink>): number => {
136+
const aParent = a.targetLinks[0]?.source
137+
const bParent = b.targetLinks[0]?.source
138+
const aGrandparent = a.targetLinks[0]?.source?.targetLinks[0]?.source
139+
const bGrandparent = b.targetLinks[0]?.source?.targetLinks[0]?.source
140+
141+
if ((aParent === bParent)) { // Same parent nodes are sorted by: value + alphabetically
142+
return (b.value - a.value) || compareStrings(a?.path, b?.path)
143+
} else { // Different parent nodes are sorted by: 1st grandparent value + 1st parent value + alphabetically
144+
return (bGrandparent?.value - aGrandparent?.value) || (bParent?.value - aParent?.value) || -compareStrings(aParent?.path, bParent?.path)
145+
}
146+
}
147+
148+
export const linkSort = (a: SankeyLink<ApiEndpointNode, ApiEndpointLink>, b: SankeyLink<ApiEndpointNode, ApiEndpointLink>): number => {
149+
return b.value - a.value || compareStrings(a.target?.path, b.target?.path) // Links sorted by: value + alphabetically
150+
}

packages/dev/src/examples/networks-and-flows/sankey/sankey-api-endpoints/index.tsx

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
Sankey,
66
SankeyEnterTransitionType,
77
SankeyExitTransitionType,
8-
SankeyLink,
98
SankeyNode,
109
SankeyNodeAlign,
1110
SankeySubLabelPlacement,
@@ -15,7 +14,7 @@ import {
1514
import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index'
1615

1716
import apiRawData from './apieplist.json'
18-
import { getSankeyData, ApiEndpointNode, ApiEndpointLink } from './data'
17+
import { getSankeyData, ApiEndpointNode, ApiEndpointLink, nodeSort, linkSort } from './data'
1918

2019
export const title = 'API Endpoints Tree'
2120
export const subTitle = 'Collapsible nodes'
@@ -28,15 +27,6 @@ export const component = (props: ExampleViewerDurationProps): React.ReactNode =>
2827
const nodeWidth = 30
2928
const nodeHorizontalSpacing = 260
3029

31-
const compareStrings = (a = '', b = ''): number => {
32-
const strA = a.toUpperCase()
33-
const strB = b.toUpperCase()
34-
35-
if (strA < strB) return -1
36-
if (strA > strB) return 1
37-
return 0
38-
}
39-
4030
return (
4131
<>
4232
<VisSingleContainer data={data} sizing={Sizing.Extend}>
@@ -66,21 +56,8 @@ export const component = (props: ExampleViewerDurationProps): React.ReactNode =>
6656
enterTransitionType={SankeyEnterTransitionType.FromAncestor}
6757
highlightSubtreeOnHover={false}
6858
duration={props.duration}
69-
nodeSort={(a: SankeyNode<ApiEndpointNode, ApiEndpointLink>, b: SankeyNode<ApiEndpointNode, ApiEndpointLink>) => {
70-
const aParent = a.targetLinks[0]?.source
71-
const bParent = b.targetLinks[0]?.source
72-
const aGrandparent = a.targetLinks[0]?.source?.targetLinks[0]?.source
73-
const bGrandparent = b.targetLinks[0]?.source?.targetLinks[0]?.source
74-
75-
if ((aParent === bParent)) { // Same parent nodes are sorted by: value + alphabetically
76-
return (b.value - a.value) || compareStrings(a?.path, b?.path)
77-
} else { // Different parent nodes are sorted by: 1st grandparent value + 1st parent value + alphabetically
78-
return (bGrandparent?.value - aGrandparent?.value) || (bParent?.value - aParent?.value) || -compareStrings(aParent?.path, bParent?.path)
79-
}
80-
}}
81-
linkSort={(a: SankeyLink<ApiEndpointNode, ApiEndpointLink>, b: SankeyLink<ApiEndpointNode, ApiEndpointLink>) => {
82-
return b.value - a.value || compareStrings(a.target?.path, b.target?.path) // Links sorted by: value + alphabetically
83-
}}
59+
nodeSort={nodeSort}
60+
linkSort={linkSort}
8461
events={{
8562
[Sankey.selectors.background]: {
8663
// eslint-disable-next-line no-console
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import React, { useRef, useState } from 'react'
2+
import { VisSingleContainer, VisSankey } from '@unovis/react'
3+
import {
4+
Position,
5+
Sankey,
6+
SankeyEnterTransitionType,
7+
SankeyNode,
8+
SankeyNodeAlign,
9+
SankeySubLabelPlacement,
10+
SankeyZoomMode,
11+
Sizing,
12+
VerticalAlign,
13+
} from '@unovis/ts'
14+
import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index'
15+
16+
import apiRawData from '../sankey-api-endpoints/apieplist.json'
17+
import { getSankeyData, ApiEndpointNode, ApiEndpointLink, linkSort, nodeSort } from '../sankey-api-endpoints/data'
18+
19+
import s from './style.module.css'
20+
21+
export const title = 'Sankey with Zoom'
22+
export const subTitle = 'Collapsible nodes'
23+
24+
export const component = (props: ExampleViewerDurationProps): React.ReactNode => {
25+
const collapsedStateRef = useRef<{ [key: string]: boolean }>({})
26+
const rawData = apiRawData
27+
const [data, setData] = useState(getSankeyData(rawData))
28+
const sankeyRef = useRef<{ component?: Sankey<ApiEndpointNode, ApiEndpointLink> }>(null)
29+
30+
const nodeWidth = 30
31+
const nodeHorizontalSpacing = 260
32+
33+
const zoomStep = 1.2
34+
35+
// State for config properties
36+
const [zoomMode, setZoomMode] = useState<SankeyZoomMode>(SankeyZoomMode.Y)
37+
const [zoomExtent] = useState<[number, number]>([1, 5])
38+
const [configZoomScale, setConfigZoomScale] = useState<[number, number] | undefined>(undefined)
39+
const [configZoomPan, setConfigZoomPan] = useState<[number, number] | undefined>(undefined)
40+
41+
const getCurrentScales = (): [number, number] => {
42+
const c = sankeyRef.current?.component
43+
const [h, v] = c?.getZoomScale() ?? [1, 1]
44+
45+
return [h, v]
46+
}
47+
48+
const onZoom = (factor: number): void => {
49+
const c = sankeyRef.current?.component
50+
if (!c) return
51+
52+
const [h, v] = getCurrentScales()
53+
const nextH = h
54+
const nextV = v * factor
55+
c.setZoomScale(nextH, nextV)
56+
}
57+
58+
const onFit = (): void => {
59+
const c = sankeyRef.current?.component
60+
c?.fitView?.(600)
61+
}
62+
63+
const toggleZoomMode = (): void => {
64+
const modes = [SankeyZoomMode.XY, SankeyZoomMode.X, SankeyZoomMode.Y]
65+
const currentIndex = modes.indexOf(zoomMode)
66+
const nextMode = modes[(currentIndex + 1) % modes.length]
67+
setZoomMode(nextMode)
68+
}
69+
70+
const setConfigScale = (h: number, v: number): void => {
71+
setConfigZoomScale([h, v])
72+
}
73+
74+
const setConfigPanOffset = (x: number, y: number): void => {
75+
setConfigZoomPan([x, y])
76+
}
77+
78+
const resetConfigScaleAndPan = (): void => {
79+
setConfigZoomScale(undefined)
80+
setConfigZoomPan(undefined)
81+
}
82+
83+
const setPanOffset = (x: number, y: number): void => {
84+
const c = sankeyRef.current?.component
85+
c?.setPan(x, y)
86+
}
87+
88+
const shouldDisableApiControls = configZoomScale !== undefined || configZoomPan !== undefined
89+
return (
90+
<>
91+
<VisSingleContainer data={data} height="95vh" sizing={Sizing.Fit}>
92+
<VisSankey<ApiEndpointNode, ApiEndpointLink>
93+
ref={sankeyRef}
94+
labelPosition={Position.Right}
95+
labelVerticalAlign={VerticalAlign.Middle}
96+
labelMaxWidthTakeAvailableSpace={true}
97+
labelMaxWidth={240}
98+
nodeHorizontalSpacing={nodeHorizontalSpacing}
99+
nodeWidth={nodeWidth}
100+
nodeAlign={SankeyNodeAlign.Left}
101+
nodeIconColor={'#e9edfe'}
102+
nodePadding={10}
103+
nodeMinHeight={5}
104+
labelBackground={false}
105+
labelColor={'#0D1C5B'}
106+
labelCursor={'pointer'}
107+
label={d => d.isLeafNode ? d.method : `${d.leafs} ${d.leafs === 1 ? 'Leaf' : 'Leaves'}`}
108+
subLabel={d => d.label}
109+
nodeColor={d => d.isLeafNode ? '#0D1C5B' : null}
110+
subLabelFontSize={14}
111+
labelFontSize={14}
112+
subLabelPlacement={SankeySubLabelPlacement.Below}
113+
nodeCursor={'pointer'}
114+
linkCursor={'pointer'}
115+
nodeIcon={d => (d.sourceLinks?.[0] || (!d.sourceLinks?.[0] && d.collapsed)) ? (d.collapsed ? '+' : '') : null}
116+
enterTransitionType={SankeyEnterTransitionType.FromAncestor}
117+
highlightSubtreeOnHover={false}
118+
duration={props.duration}
119+
zoomMode={zoomMode}
120+
zoomExtent={zoomExtent}
121+
zoomScale={configZoomScale}
122+
zoomPan={configZoomPan}
123+
enableZoom={true}
124+
nodeSort={nodeSort}
125+
linkSort={linkSort}
126+
events={{
127+
[Sankey.selectors.background]: {
128+
// eslint-disable-next-line no-console
129+
click: () => { console.log('Background click!') },
130+
},
131+
[Sankey.selectors.nodeGroup]: {
132+
click: (d: SankeyNode<ApiEndpointNode, ApiEndpointLink>) => {
133+
if (!d.targetLinks?.[0] || (!collapsedStateRef.current[d.id] && !d.sourceLinks?.[0])) return
134+
collapsedStateRef.current[d.id] = !collapsedStateRef.current[d.id]
135+
setData(getSankeyData(rawData, collapsedStateRef.current))
136+
},
137+
},
138+
}}
139+
/>
140+
</VisSingleContainer>
141+
142+
<div className={s.controlPanel}>
143+
{/* Basic Zoom Controls */}
144+
<div className={s.buttonRow}>
145+
<button onClick={() => onZoom(zoomStep)} disabled={shouldDisableApiControls}>
146+
Zoom In
147+
</button>
148+
<button onClick={() => onZoom(1 / zoomStep)} disabled={shouldDisableApiControls}>
149+
Zoom Out
150+
</button>
151+
<button onClick={onFit} disabled={shouldDisableApiControls}>
152+
Fit View
153+
</button>
154+
</div>
155+
156+
157+
{/* API Methods */}
158+
<div className={s.controlRow}>
159+
<span className={s.label}>Set Pan:</span>
160+
<button
161+
onClick={() => setPanOffset(100, 50)}
162+
className={s.smallButton}
163+
disabled={shouldDisableApiControls}
164+
>
165+
[100, 50]
166+
</button>
167+
<button
168+
onClick={() => setPanOffset(0, 0)}
169+
className={s.smallButton}
170+
disabled={shouldDisableApiControls}
171+
>
172+
Reset
173+
</button>
174+
</div>
175+
176+
{/* Zoom Mode */}
177+
<div className={s.buttonRow}>
178+
<span className={s.label}>Mode: {zoomMode}</span>
179+
<button onClick={toggleZoomMode}>Toggle Mode</button>
180+
</div>
181+
182+
{/* Config Scale */}
183+
<div className={s.controlRow}>
184+
<span className={s.label}>Config Scale:</span>
185+
<button onClick={() => setConfigScale(1.5, 2.0)} className={s.smallButton}>[1.5, 2.0]</button>
186+
<button onClick={() => setConfigScale(2, 1)} className={s.smallButton}>[2, 1]</button>
187+
</div>
188+
189+
{/* Config Pan */}
190+
<div className={s.controlRow}>
191+
<span className={s.label}>Config Pan:</span>
192+
<button onClick={() => setConfigPanOffset(50, 100)} className={s.smallButton}>[50, 100]</button>
193+
<button onClick={() => setConfigPanOffset(-50, -50)} className={s.smallButton}>[-50, -50]</button>
194+
</div>
195+
196+
{/* Reset */}
197+
<button onClick={resetConfigScaleAndPan}>Reset Config Scale & Pan</button>
198+
</div>
199+
</>
200+
)
201+
}
202+
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.controlPanel {
2+
position: absolute;
3+
right: 10px;
4+
bottom: 10px;
5+
display: flex;
6+
flex-direction: column;
7+
gap: 8px;
8+
background: white;
9+
padding: 12px;
10+
border-radius: 8px;
11+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
12+
}
13+
14+
.buttonRow {
15+
display: flex;
16+
gap: 8px;
17+
}
18+
19+
.controlRow {
20+
display: flex;
21+
gap: 4px;
22+
align-items: center;
23+
}
24+
25+
.label {
26+
font-size: 12px;
27+
min-width: 80px;
28+
}
29+
30+
.smallButton {
31+
font-size: 11px;
32+
}

0 commit comments

Comments
 (0)