diff --git a/common/changes/@visactor/vchart/fix-sankey-related-emphasis-4707_2026-10-15.json b/common/changes/@visactor/vchart/fix-sankey-related-emphasis-4707_2026-10-15.json new file mode 100644 index 0000000000..8a8b09c269 --- /dev/null +++ b/common/changes/@visactor/vchart/fix-sankey-related-emphasis-4707_2026-10-15.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "fix: sankey related emphasis no longer throws on plain node-link data and highlights the full upstream and downstream path", + "type": "patch" + } + ], + "packageName": "@visactor/vchart" +} diff --git a/packages/vchart/__tests__/unit/series/sankey-emphasis-state.test.ts b/packages/vchart/__tests__/unit/series/sankey-emphasis-state.test.ts index 8fc645b467..01e238c7e1 100644 --- a/packages/vchart/__tests__/unit/series/sankey-emphasis-state.test.ts +++ b/packages/vchart/__tests__/unit/series/sankey-emphasis-state.test.ts @@ -197,4 +197,245 @@ describe('sankey emphasis state', () => { expect(thickness).toBeCloseTo((target.attribute.thickness * 30) / 130); } }); + + const createPlainRelatedSpec = () => ({ + type: 'sankey', + width: 500, + height: 300, + data: [ + { + values: [ + { + nodes: [{ name: 'A' }, { name: 'B' }, { name: 'C' }, { name: 'D' }], + links: [ + { source: 'A', target: 'B', value: 6 }, + { source: 'A', target: 'C', value: 4 }, + { source: 'B', target: 'D', value: 6 }, + { source: 'C', target: 'D', value: 4 } + ] + } + ] + } + ], + nodeKey: 'name', + categoryField: 'name', + sourceField: 'source', + targetField: 'target', + valueField: 'value', + nodeWidth: 20, + nodeGap: 20, + label: { visible: true }, + node: { + state: { + selected: { fillOpacity: 1 }, + blur: { fillOpacity: 0.08 } + } + }, + link: { + state: { + selected: { fillOpacity: 0.9 }, + blur: { fillOpacity: 0.05 } + } + }, + emphasis: { enable: true, trigger: 'hover', effect: 'related' } + }); + + const hoverRelated = (targetChart, series, graphic, mark) => { + const nested = graphic?.getSubGraphic?.()?.[0]; + const item = graphic + ? findMarkGraphic(targetChart.getCompiler().getRootGroup(), nested ?? graphic) ?? graphic + : undefined; + series.event.emit('pointerover', { item, mark: item ? mark : undefined }, Event_Bubble_Level.chart); + }; + + const expectPlainRelated = (series, selectedNodes, selectedLinks) => { + const nodes = series._nodeMark.getGraphics(); + const links = series._linkMark.getGraphics(); + expect(nodes.length).toBeGreaterThan(0); + expect(links.length).toBeGreaterThan(0); + nodes.forEach(node => { + const key = node.context.data[0].key; + const selected = selectedNodes.includes(key); + expect(node.currentStates).toContain(selected ? 'selected' : 'blur'); + expect(node.currentStates).not.toContain(selected ? 'blur' : 'selected'); + expect(node.attribute.fillOpacity).toBe(selected ? 1 : 0.08); + }); + links.forEach(link => { + const { source, target } = link.context.data[0]; + const selected = selectedLinks.some(([linkSource, linkTarget]) => linkSource === source && linkTarget === target); + expect(link.currentStates).toContain(selected ? 'selected' : 'blur'); + expect(link.currentStates).not.toContain(selected ? 'blur' : 'selected'); + expect(link.attribute.fillOpacity).toBe(selected ? 0.9 : 0.05); + }); + }; + + test('plain node-link related hover highlights the fork and merge path', () => { + chart = new VChart(createPlainRelatedSpec(), { dom, animation: false }); + chart.renderSync(); + const series = chart.getChart().getAllSeries()[0]; + const nodeMark = series._nodeMark; + const linkMark = series._linkMark; + const node = name => nodeMark.getGraphics().find(g => g.context.data[0].key === name); + const link = (source, target) => + linkMark.getGraphics().find(g => g.context.data[0].source === source && g.context.data[0].target === target); + const nodeA = node('A'); + const linkAB = link('A', 'B'); + expect(nodeMark.getGraphics()).toHaveLength(4); + expect(linkMark.getGraphics()).toHaveLength(4); + const initialNodeOpacity = nodeA.attribute.fillOpacity; + const initialLinkOpacity = linkAB.attribute.fillOpacity; + + hoverRelated(chart, series, nodeA, nodeMark); + expectPlainRelated( + series, + ['A', 'B', 'C', 'D'], + [ + ['A', 'B'], + ['A', 'C'], + ['B', 'D'], + ['C', 'D'] + ] + ); + + hoverRelated(chart, series, node('B'), nodeMark); + expectPlainRelated( + series, + ['A', 'B', 'D'], + [ + ['A', 'B'], + ['B', 'D'] + ] + ); + + hoverRelated(chart, series, link('A', 'C'), linkMark); + expectPlainRelated( + series, + ['A', 'C', 'D'], + [ + ['A', 'C'], + ['C', 'D'] + ] + ); + + hoverRelated(chart, series, link('B', 'D'), linkMark); + expectPlainRelated( + series, + ['A', 'B', 'D'], + [ + ['A', 'B'], + ['B', 'D'] + ] + ); + + hoverRelated(chart, series); + series._nodeMark.getGraphics().forEach(g => { + expect(g.currentStates).toEqual([]); + }); + series._linkMark.getGraphics().forEach(g => { + expect(g.currentStates).toEqual([]); + }); + expect(nodeA.attribute.fillOpacity).toBe(initialNodeOpacity); + expect(linkAB.attribute.fillOpacity).toBe(initialLinkOpacity); + + hoverRelated(chart, series, node('D'), nodeMark); + expectPlainRelated( + series, + ['A', 'B', 'C', 'D'], + [ + ['A', 'B'], + ['A', 'C'], + ['B', 'D'], + ['C', 'D'] + ] + ); + }); + + test('plain node-link related hover follows paths longer than two hops', () => { + chart = new VChart( + { + type: 'sankey', + width: 500, + height: 300, + data: [ + { + values: [ + { + nodes: [{ name: 'A' }, { name: 'B' }, { name: 'C' }, { name: 'D' }, { name: 'E' }], + links: [ + { source: 0, target: 1, value: 1 }, + { source: 1, target: 2, value: 1 }, + { source: 2, target: 3, value: 1 }, + { source: 4, target: 2, value: 1 } + ] + } + ] + } + ], + categoryField: 'name', + sourceField: 'source', + targetField: 'target', + valueField: 'value', + nodeWidth: 20, + nodeGap: 20, + node: { + state: { + selected: { fillOpacity: 1 }, + blur: { fillOpacity: 0.08 } + } + }, + link: { + state: { + selected: { fillOpacity: 0.9 }, + blur: { fillOpacity: 0.05 } + } + }, + emphasis: { enable: true, trigger: 'hover', effect: 'related' } + }, + { dom, animation: false } + ); + chart.renderSync(); + const series = chart.getChart().getAllSeries()[0]; + const nodeMark = series._nodeMark; + const linkMark = series._linkMark; + const node = key => nodeMark.getGraphics().find(g => g.context.data[0].key === key); + const link = (source, target) => + linkMark.getGraphics().find(g => g.context.data[0].source === source && g.context.data[0].target === target); + + expect(nodeMark.getGraphics()).toHaveLength(5); + expect(linkMark.getGraphics()).toHaveLength(4); + + hoverRelated(chart, series, node(0), nodeMark); + expectPlainRelated( + series, + [0, 1, 2, 3], + [ + [0, 1], + [1, 2], + [2, 3] + ] + ); + + hoverRelated(chart, series, link(1, 2), linkMark); + expectPlainRelated( + series, + [0, 1, 2, 3], + [ + [0, 1], + [1, 2], + [2, 3] + ] + ); + + hoverRelated(chart, series, node(2), nodeMark); + expectPlainRelated( + series, + [0, 1, 2, 3, 4], + [ + [0, 1], + [1, 2], + [2, 3], + [4, 2] + ] + ); + }); }); diff --git a/packages/vchart/src/series/sankey/sankey.ts b/packages/vchart/src/series/sankey/sankey.ts index d751e23f05..df8c93355d 100644 --- a/packages/vchart/src/series/sankey/sankey.ts +++ b/packages/vchart/src/series/sankey/sankey.ts @@ -622,6 +622,102 @@ export class SankeySeries exten this._needClear = true; }; + /** + * node-link 数据没有 parents。从给定节点沿出边走到下游、沿入边走到上游,高亮整条相关路径。 + * 预置 links 只包含当前边本身;它的两端要分别放进 downstreamFrom / upstreamFrom,避免把分叉上的旁路边算进来。 + */ + private _highlightPlainRelated( + allNodeElements: IMarkGraphic[], + allLinkElements: IMarkGraphic[], + related: { + nodes: StringOrNumber[]; + links?: Datum[]; + downstreamFrom: StringOrNumber[]; + upstreamFrom: StringOrNumber[]; + } + ) { + const outgoing = new Map(); + const incoming = new Map(); + const appendLink = (adjacency: Map, key: StringOrNumber, link: Datum) => { + if (isNil(key)) { + return; + } + const list = adjacency.get(key); + if (list) { + list.push(link); + } else { + adjacency.set(key, [link]); + } + }; + + allLinkElements.forEach(linkEl => { + const link = getDatumOfGraphic(linkEl) as Datum; + if (!link) { + return; + } + appendLink(outgoing, link.source, link); + appendLink(incoming, link.target, link); + }); + + const seenNodes = new Set(); + const seenLinks = new Set(); + const addNode = (key: StringOrNumber) => { + if (isNil(key) || seenNodes.has(key)) { + return false; + } + seenNodes.add(key); + return true; + }; + const addLink = (link: Datum) => { + const id = link?.key ?? link?.index; + if (isNil(id) || seenLinks.has(id)) { + return false; + } + seenLinks.add(id); + return true; + }; + + related.nodes.forEach(addNode); + related.links?.forEach(addLink); + + const walk = (seeds: StringOrNumber[], adjacency: Map, nextKey: 'source' | 'target') => { + const queue = seeds.filter(key => !isNil(key)); + let cursor = 0; + while (cursor < queue.length) { + const links = adjacency.get(queue[cursor]); + cursor += 1; + if (!links) { + continue; + } + links.forEach(link => { + if (!addLink(link)) { + return; + } + if (addNode(link[nextKey])) { + queue.push(link[nextKey]); + } + }); + } + }; + + walk(related.downstreamFrom, outgoing, 'target'); + walk(related.upstreamFrom, incoming, 'source'); + + allLinkElements.forEach(linkEl => { + const linkDatum = getDatumOfGraphic(linkEl) as Datum; + const id = linkDatum?.key ?? linkDatum?.index; + if (!isNil(id) && seenLinks.has(id)) { + removeGraphicState(linkEl, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE); + addGraphicState(linkEl, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS, true, this._hasLinkStateAnimation()); + } else { + removeGraphicState(linkEl, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS); + addGraphicState(linkEl, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE, true, this._hasLinkStateAnimation()); + } + }); + + this._highLightElements(allNodeElements, seenNodes); + } + protected _handleNodeRelatedClick = (graphic: IMarkGraphic) => { const nodeDatum = getDatumOfGraphic(graphic) as Datum; const allNodeElements = this._nodeMark.getGraphics(); @@ -639,120 +735,12 @@ export class SankeySeries exten const father = (getDatumOfGraphic(allLinkElements[0]) as Datum)?.parents ? 'parents' : 'source'; if (father === 'source') { - // node-link 型数据 - const highlightNodes: string[] = [nodeDatum.key]; - const highlightLinks: string[] = []; - - allLinkElements.forEach((linkEl: IMarkGraphic, i: number) => { - const linkDatum = getDatumOfGraphic(linkEl) as Datum; - const father = linkDatum?.parents ? 'parents' : 'source'; - - if (array(linkDatum[father]).includes(nodeDatum.key)) { - // 下游link - if (!highlightLinks.includes(linkDatum.key ?? linkDatum.index)) { - highlightLinks.push(linkDatum.key ?? linkDatum.index); - } - if (!highlightNodes.includes(linkDatum.source)) { - highlightNodes.push(linkDatum.source); - } - - if (!highlightNodes.includes(linkDatum.target)) { - highlightNodes.push(linkDatum.target); - // 下游link的目标节点 - const targetNode = allNodeElements.find(nodeElement => { - return nodeElement.data[0].key === linkDatum.target; - }); - - // 以下游link的目标节点为起点的links - let targetNodeSourceLinks: any[] = targetNode.data[0].sourceLinks as any[]; - while (targetNodeSourceLinks?.length > 0) { - const newTargetNodeSourceLinks: any[] = []; - targetNodeSourceLinks.forEach((targetNodeSourceLinkDatum: any) => { - if (!highlightLinks.includes(targetNodeSourceLinkDatum.key ?? targetNodeSourceLinkDatum.index)) { - highlightLinks.push(targetNodeSourceLinkDatum.key ?? targetNodeSourceLinkDatum.index); - // 该links的目标节点 - if (!highlightNodes.includes(targetNodeSourceLinkDatum.target)) { - highlightNodes.push(targetNodeSourceLinkDatum.target); - const sourceNodeTemp = allNodeElements.find(nodeElement => { - return nodeElement.data[0].key === targetNodeSourceLinkDatum.target; - }); - // 以该目标节点为起点的links - newTargetNodeSourceLinks.push(sourceNodeTemp.data[0].targetLinks as any[]); - } else { - return; - } - } else { - return; - } - }); - targetNodeSourceLinks = newTargetNodeSourceLinks; - return; - } - } - } else if (linkDatum.target === nodeDatum.key) { - // 上游link - if (!highlightLinks.includes(linkDatum.key ?? linkDatum.index)) { - highlightLinks.push(linkDatum.key ?? linkDatum.index); - } - if (!highlightNodes.includes(linkDatum.source)) { - highlightNodes.push(linkDatum.source); - const sourceNode = allNodeElements.find(nodeElement => { - return nodeElement.data[0].key === linkDatum.source; - }); - let sourceNodeTargetLinks: any[] = sourceNode.data[0].targetLinks as any; - while (sourceNodeTargetLinks?.length > 0) { - const newSourceNodeTargetLinks: any[] = []; - sourceNodeTargetLinks.forEach((sourceNodeTargetLinkDatum: any) => { - if (!highlightLinks.includes(sourceNodeTargetLinkDatum.key ?? sourceNodeTargetLinkDatum.index)) { - highlightLinks.push(sourceNodeTargetLinkDatum.key ?? sourceNodeTargetLinkDatum.index); - if (!highlightNodes.includes(sourceNodeTargetLinkDatum.source)) { - highlightNodes.push(sourceNodeTargetLinkDatum.source); - const sourceNodeTemp = allNodeElements.find(nodeElement => { - return nodeElement.data[0].key === sourceNodeTargetLinkDatum.source; - }); - newSourceNodeTargetLinks.push(sourceNodeTemp.data[0].targetLinks as any[]); - } else { - return; - } - } else { - return; - } - }); - sourceNodeTargetLinks = newSourceNodeTargetLinks; - return; - } - } - } + // node-link 型数据:沿 source/target 遍历上下游整条路径 + this._highlightPlainRelated(allNodeElements, allLinkElements, { + nodes: [nodeDatum.key], + downstreamFrom: [nodeDatum.key], + upstreamFrom: [nodeDatum.key] }); - - if (this._linkMark) { - const allLinkElements = this._linkMark.getGraphics(); - - if (!allLinkElements || !allLinkElements.length) { - return; - } - - allLinkElements.forEach((linkEl: IMarkGraphic, i: number) => { - const linkDatum = getDatumOfGraphic(linkEl) as Datum; - - if (highlightLinks.includes(linkDatum.key ?? linkDatum.index)) { - removeGraphicState(linkEl, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE); - addGraphicState(linkEl, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS, true, this._hasLinkStateAnimation()); - } else { - removeGraphicState(linkEl, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS); - addGraphicState( - linkEl, - STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE, - true, - this._hasLinkStateAnimation() - ); - } - }); - } - - if (this._nodeMark) { - this._highLightElements(this._nodeMark.getGraphics(), highlightNodes); - } } else { // 层级型数据 const highlightNodes: string[] = [nodeDatum.key]; @@ -874,28 +862,18 @@ export class SankeySeries exten return; } - const father = (getDatumOfGraphic(graphic) as Datum) ? 'parents' : 'source'; - if (father === 'source') { - // const states = [STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE]; - if (this._linkMark) { - allLinkElements.forEach(linkEl => { - removeGraphicState(linkEl, [ - STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS, - STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE - ]); - }); - } - - if (this._nodeMark) { - allNodeElements.forEach(el => { - removeGraphicState(el, [ - STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS, - STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE - ]); + const curLinkDatum = getDatumOfGraphic(graphic) as Datum; + // 普通连线同样有 datum;只有层级边才带 parents + if (!curLinkDatum?.parents) { + if (curLinkDatum) { + this._highlightPlainRelated(allNodeElements, allLinkElements, { + nodes: [curLinkDatum.source, curLinkDatum.target], + links: [curLinkDatum], + downstreamFrom: [curLinkDatum.target], + upstreamFrom: [curLinkDatum.source] }); } } else { - const curLinkDatum = getDatumOfGraphic(graphic) as Datum; const highlightNodes: string[] = [curLinkDatum.source, curLinkDatum.target]; const upstreamLinks: Array<{ source: string; target: string; value: number }> = []; @@ -1025,15 +1003,20 @@ export class SankeySeries exten this._needClear = true; }; - protected _highLightElements(graphics: IMarkGraphic[], highlightNodes: string[]) { + protected _highLightElements(graphics: IMarkGraphic[], highlightNodes: StringOrNumber[] | Set) { if (!graphics || !graphics.length) { return; } + const isHighlighted = + highlightNodes instanceof Set + ? (key: StringOrNumber) => highlightNodes.has(key) + : (key: StringOrNumber) => highlightNodes.includes(key); + graphics.forEach(g => { removeGraphicState(g, [STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS]); - if (highlightNodes.includes((getDatumOfGraphic(g) as Datum).key)) { + if (isHighlighted((getDatumOfGraphic(g) as Datum).key)) { addGraphicState(g, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS, true, this._hasNodeStateAnimation()); } else { addGraphicState(g, STATE_VALUE_ENUM.STATE_SANKEY_EMPHASIS_REVERSE, true, this._hasNodeStateAnimation());