diff --git a/__tests__/unit/plots/sankey/circle-spec.ts b/__tests__/unit/plots/sankey/circle-spec.ts new file mode 100644 index 0000000000..00078ce799 --- /dev/null +++ b/__tests__/unit/plots/sankey/circle-spec.ts @@ -0,0 +1,94 @@ +import { cutoffCircle } from '../../../../src/plots/sankey/circle'; +import { ENERGY_RELATIONS } from '../../../data/sankey-energy'; + +describe('sankey ', () => { + it('cutoffCircle', () => { + let data = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'a', target: 'c' }, + { source: 'c', target: 'd' }, + ]; + + // 不成环 + expect(cutoffCircle(data, 'source', 'target')).toEqual([ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'a', target: 'c' }, + { source: 'c', target: 'd' }, + ]); + + // 两节点环 + data = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'a' }, + ]; + + expect(cutoffCircle(data, 'source', 'target')).toEqual([{ source: 'a', target: 'b' }]); + + // 三节点环 + data = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, + ]; + + expect(cutoffCircle(data, 'source', 'target')).toEqual([ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + ]); + + // 多个环 + data = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, + { source: 'a', target: 'd' }, + { source: 'd', target: 'e' }, + { source: 'e', target: 'a' }, + ]; + + expect(cutoffCircle(data, 'source', 'target')).toEqual([ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'a', target: 'd' }, + { source: 'd', target: 'e' }, + ]); + + // 一条边产生两个环 + data = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, // 它带来两个环 + { source: 'a', target: 'd' }, + { source: 'd', target: 'c' }, + ]; + + expect(cutoffCircle(data, 'source', 'target')).toEqual([ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'a', target: 'd' }, + { source: 'd', target: 'c' }, + ]); + + // 节点多个父 + data = [ + { source: 'a', target: 'c' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, + ]; + + expect(cutoffCircle(data, 'source', 'target')).toEqual([ + { source: 'a', target: 'c' }, + { source: 'b', target: 'c' }, + ]); + + // 稍微正式一点的数据 + expect(cutoffCircle(ENERGY_RELATIONS, 'source', 'target')).toEqual(ENERGY_RELATIONS); + expect(cutoffCircle(ENERGY_RELATIONS, 'source', 'target')).not.toBe(ENERGY_RELATIONS); + + // 空数据 + expect(cutoffCircle(null, 'source', 'target')).toEqual([]); + expect(cutoffCircle(undefined, 'source', 'target')).toEqual([]); + }); +}); diff --git a/__tests__/unit/plots/sankey/index-spec.ts b/__tests__/unit/plots/sankey/index-spec.ts index e605ee5f0d..a68304ac5a 100644 --- a/__tests__/unit/plots/sankey/index-spec.ts +++ b/__tests__/unit/plots/sankey/index-spec.ts @@ -123,4 +123,27 @@ describe('sankey', () => { sankey.destroy(); }); + + it('sankey circle', () => { + const DATA = [ + { source: 'a', target: 'b', value: 160 }, + { source: 'b', target: 'c', value: 40 }, + { source: 'c', target: 'd', value: 10 }, + { source: 'd', target: 'a', value: 10 }, + ]; + + const sankey = new Sankey(createDiv(), { + data: DATA, + sourceField: 'source', + targetField: 'target', + weightField: 'value', + }); + + sankey.render(); + + // 被去掉环 + expect(sankey.chart.views[1].getOptions().data.length).toBe(3); + + sankey.destroy(); + }); }); diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts index d8eda00dd2..d7a946fe69 100644 --- a/src/plots/sankey/adaptor.ts +++ b/src/plots/sankey/adaptor.ts @@ -6,6 +6,7 @@ import { polygon, edge } from '../../adaptor/geometries'; import { transformDataToNodeLinkData } from '../../utils/data'; import { SankeyOptions } from './types'; import { X_FIELD, Y_FIELD, COLOR_FIELD } from './constant'; +import { cutoffCircle } from './circle'; /** * geometry 处理 @@ -35,7 +36,12 @@ function geometry(params: Params): Params { chart.axis(false); // 2. 转换出 layout 前数据 - const sankeyLayoutInputData = transformDataToNodeLinkData(data, sourceField, targetField, weightField); + const sankeyLayoutInputData = transformDataToNodeLinkData( + cutoffCircle(data, sourceField, targetField), + sourceField, + targetField, + weightField + ); // 3. layout 之后的数据 const { nodes, links } = sankeyLayout( diff --git a/src/plots/sankey/circle.ts b/src/plots/sankey/circle.ts new file mode 100644 index 0000000000..062a7799a1 --- /dev/null +++ b/src/plots/sankey/circle.ts @@ -0,0 +1,57 @@ +import { each, size } from '@antv/util'; +import { Data, Datum } from '../../types'; + +/** + * 是否有环的判断依据是,当前 source 对应的 target 是 source 的父节点 + * @param circleCache + * @param source + * @param target + */ +function hasCircle(circleCache: Map, source: string[], target: string): boolean { + // 父元素为空,则表示已经到头了! + if (size(source) === 0) return false; + // target 在父元素路径上,所以形成环 + if (source.includes(target)) return true; + + // 递归 + return source.some((s: string) => hasCircle(circleCache, circleCache.get(s), target)); +} + +/** + * 切断桑基图数据中的环(会丢失数据),保证顺序 + * @param data + * @param sourceField + * @param targetField + */ +export function cutoffCircle(data: Data, sourceField: string, targetField: string): Data { + const dataWithoutCircle = []; + const removedData = []; + + /** 存储父子关系的链表关系,具体是 子 -> 父 数组 */ + const circleCache = new Map(); + + each(data, (d: Datum) => { + const source = d[sourceField] as string; + const target = d[targetField] as string; + + // 当前数据,不成环 + if (!hasCircle(circleCache, [source], target)) { + // 保留数据 + dataWithoutCircle.push(d); + // 存储关系链表 + if (!circleCache.has(target)) { + circleCache.set(target, []); + } + circleCache.get(target).push(source); + } else { + // 保存起来用于打印 log + removedData.push(d); + } + }); + + if (removedData.length !== 0) { + console.warn(`sankey data contains circle, ${removedData.length} records removed.`, removedData); + } + + return dataWithoutCircle; +}