| 
 | 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 | + | 
0 commit comments