diff --git a/packages/sankey/src/hooks.ts b/packages/sankey/src/hooks.ts index 1b85c0f3ea..b0c77ff5c8 100644 --- a/packages/sankey/src/hooks.ts +++ b/packages/sankey/src/hooks.ts @@ -32,6 +32,7 @@ export const computeNodeAndLinks = ['data'] formatValue: (value: number) => string @@ -46,13 +47,12 @@ export const computeNodeAndLinks = , 'color' | 'label'>) => string getLabel: (node: Omit, 'color' | 'label'>) => string + nodePositions?: Record }) => { const sankey = d3Sankey() .nodeAlign(alignFunction) - // @ts-expect-error: this method signature is incorrect in current @types/d3-sankey - .nodeSort(sortFunction) - // @ts-expect-error: this method is not available in current @types/d3-sankey - .linkSort(linkSortMode) + .nodeSort(sortFunction as any) + .linkSort(linkSortMode as any) .nodeWidth(nodeThickness) .nodePadding(nodeSpacing) .size(layout === 'horizontal' ? [width, height] : [height, width]) @@ -67,10 +67,32 @@ export const computeNodeAndLinks = { + // Apply explicit positions provided via nodePositions map first + if (nodePositions) { + const override = nodePositions[node.id as unknown as string] + if (override) { + if (override.x !== undefined) node.manualX = override.x + if (override.y !== undefined) node.manualY = override.y + } + } + node.color = getColor(node) node.label = getLabel(node) node.formattedValue = formatValue(node.value) + // Apply manual positions if provided + if ('manualX' in node && node.manualX !== undefined) { + const thickness = node.x1 - node.x0; + node.x0 = node.manualX; + node.x1 = node.manualX + thickness; + } + + if ('manualY' in node && node.manualY !== undefined) { + const height = node.y1 - node.y0; + node.y0 = node.manualY; + node.y1 = node.manualY + height; + } + if (layout === 'horizontal') { node.x = node.x0 + nodeInnerPadding node.y = node.y0 @@ -96,17 +118,45 @@ export const computeNodeAndLinks = ) => { + if (layout === 'horizontal') { + // Outgoing links (left → right) + let sy = 0 + node.sourceLinks.forEach(link => { + link.pos0 = node.y0 + sy + link.thickness / 2 + sy += link.thickness + }) + + // Incoming links (left ← right) + let ty = 0 + node.targetLinks.forEach(link => { + link.pos1 = node.y0 + ty + link.thickness / 2 + ty += link.thickness + }) + } else { + // Vertical layout, we stack along the X axis instead of Y. + let sx = 0 + node.sourceLinks.forEach(link => { + link.pos0 = node.x0 + sx + link.thickness / 2 + sx += link.thickness + }) + + let tx = 0 + node.targetLinks.forEach(link => { + link.pos1 = node.x0 + tx + link.thickness / 2 + tx += link.thickness + }) + } }) return data @@ -127,6 +177,7 @@ export const useSankey = ({ nodeBorderColor, label, labelTextColor, + nodePositions, }: { data: SankeyDataProps['data'] valueFormat?: SankeyCommonProps['valueFormat'] @@ -142,6 +193,7 @@ export const useSankey = ({ nodeBorderColor: SankeyCommonProps['nodeBorderColor'] label: SankeyCommonProps['label'] labelTextColor: SankeyCommonProps['labelTextColor'] + nodePositions?: Record }) => { const [currentNode, setCurrentNode] = useState | null>(null) const [currentLink, setCurrentLink] = useState | null>(null) @@ -195,6 +247,7 @@ export const useSankey = ({ height, getColor, getLabel, + nodePositions, }), [ data, @@ -210,12 +263,13 @@ export const useSankey = ({ height, getColor, getLabel, + nodePositions, ] ) const legendData = useMemo( () => - nodes.map(node => ({ + nodes.map((node: SankeyNodeDatum) => ({ id: node.id, label: node.label, color: node.color, diff --git a/packages/sankey/src/types.ts b/packages/sankey/src/types.ts index 6a4c9c689e..73fb4069ad 100644 --- a/packages/sankey/src/types.ts +++ b/packages/sankey/src/types.ts @@ -68,6 +68,8 @@ export type SankeyNodeDatum = N & y1: number value: number // custom nivo properties + manualX?: number + manualY?: number color: string label: string formattedValue: string @@ -138,6 +140,7 @@ export interface SankeyCommonProps sort: SankeySortType | SankeySortFunction layers: readonly SankeyLayer[] + nodePositions?: Record margin: Box