diff --git a/__tests__/unit/plots/tiny-area/pattern-spec.ts b/__tests__/unit/plots/tiny-area/pattern-spec.ts index ea27a11335..299857543c 100644 --- a/__tests__/unit/plots/tiny-area/pattern-spec.ts +++ b/__tests__/unit/plots/tiny-area/pattern-spec.ts @@ -1,6 +1,4 @@ -import { TooltipCfg } from '@antv/g2/lib/interface'; import { TinyArea } from '../../../../src'; -import { DEFAULT_OPTIONS } from '../../../../src/plots/tiny-area/constants'; import { partySupport } from '../../../data/party-support'; import { createDiv } from '../../../utils/dom'; diff --git a/__tests__/unit/plots/venn/blend-spec.ts b/__tests__/unit/plots/venn/blend-spec.ts new file mode 100644 index 0000000000..cf2e63e854 --- /dev/null +++ b/__tests__/unit/plots/venn/blend-spec.ts @@ -0,0 +1,114 @@ +import { Venn } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; + +describe('venn: blendMode', () => { + const plot = new Venn(createDiv(), { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + ], + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + color: ['red', 'lime', 'blue'], + legend: false, + }); + plot.render(); + + it('blendMode: default multiply', () => { + plot.update({ blendMode: 'multiply' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(0, 0, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(0, 0, 0, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 0, 0, 1)'); + }); + + it('blendMode: normal', () => { + plot.update({ blendMode: 'normal' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(255, 0, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(255, 0, 0, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 255, 0, 1)'); + }); + + it('blendMode: darken', () => { + plot.update({ blendMode: 'darken' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(0, 0, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(0, 0, 0, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 0, 0, 1)'); + }); + + it('blendMode: lighten', () => { + plot.update({ blendMode: 'lighten' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(255, 255, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(255, 0, 255, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 255, 255, 1)'); + }); + + it('blendMode: screen', () => { + plot.update({ blendMode: 'screen' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(255, 255, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(255, 0, 255, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 255, 255, 1)'); + }); + + it('blendMode: overlay', () => { + plot.update({ blendMode: 'overlay' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(0, 255, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(0, 0, 255, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 0, 255, 1)'); + }); + + it('blendMode: burn', () => { + plot.update({ blendMode: 'burn' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(0, 255, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(0, 0, 255, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 0, 255, 1)'); + }); + + it('blendMode: dodge', () => { + plot.update({ blendMode: 'dodge' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('lime'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(255, 255, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(255, 0, 255, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 255, 255, 1)'); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/color-spec.ts b/__tests__/unit/plots/venn/color-spec.ts new file mode 100644 index 0000000000..64e6e6d477 --- /dev/null +++ b/__tests__/unit/plots/venn/color-spec.ts @@ -0,0 +1,63 @@ +import { Venn } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; + +describe('venn: color', () => { + const plot = new Venn(createDiv(), { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + ], + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + // default blendMode: 'multiply', + legend: false, + }); + plot.render(); + + it('color: string', () => { + plot.update({ color: 'red' }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(255, 0, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(255, 0, 0, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(255, 0, 0, 1)'); + }); + + it('color: array', () => { + plot.update({ color: ['red', 'blue', 'yellow'] }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('yellow'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('rgba(0, 0, 0, 1)'); // 交集元素 + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('rgba(255, 0, 0, 1)'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('rgba(0, 0, 0, 1)'); + }); + + it('color: callback', () => { + plot.update({ + color: ({ size }) => { + return size > 2 ? 'red' : 'blue'; + }, + }); + + expect(plot.chart.geometries[0].elements[0].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[1].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[2].getModel().color).toBe('red'); + expect(plot.chart.geometries[0].elements[3].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[4].getModel().color).toBe('blue'); + expect(plot.chart.geometries[0].elements[5].getModel().color).toBe('blue'); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/data-spec.ts b/__tests__/unit/plots/venn/data-spec.ts new file mode 100644 index 0000000000..4d8d06abbe --- /dev/null +++ b/__tests__/unit/plots/venn/data-spec.ts @@ -0,0 +1,32 @@ +import { Venn } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; + +describe('venn 异常分支处理', () => { + const plot = new Venn(createDiv(), { + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + data: [], + }); + + it('并集中,出现不存在的集合', () => { + function render() { + plot.changeData([ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'D'], size: 2, label: 'B&C' }, + { sets: ['A', 'B', 'C'], size: 300 }, + ]); + } + // 下个 pr 再处理 + expect(render).toThrowError(); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/index-spec.ts b/__tests__/unit/plots/venn/index-spec.ts new file mode 100644 index 0000000000..0024705f35 --- /dev/null +++ b/__tests__/unit/plots/venn/index-spec.ts @@ -0,0 +1,144 @@ +import { IGroup } from '@antv/g-base'; +import { Venn } from '../../../../src'; +import { DEFAULT_OPTIONS } from '../../../../src/plots/venn/constant'; +import { VennData } from '../../../../src/plots/venn/types'; +import { createDiv } from '../../../utils/dom'; + +describe('venn', () => { + const data1 = [{ sets: ['A'], size: 10, label: 'A' }]; + + const plot = new Venn(createDiv(), { + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + data: data1, + appendPadding: 0, + legend: false, + }); + + it('sets: 1个集合', () => { + plot.render(); + expect(plot.type).toBe('venn'); + // @ts-ignore + expect(plot.getDefaultOptions()).toEqual(Venn.getDefaultOptions()); + + // VennShape 已注册 + const elements = plot.chart.geometries[0].elements; + expect(elements[0].shapeFactory['venn']).toBeDefined(); + + expect(elements.length).toBe(1); + expect((elements[0].getData() as any).size).toBe(10); + // path: [['M', ...], ['A', rx ry 0 1 0 x y], ...] + const path = (elements[0].shape as IGroup).getChildren()[0].attr('path'); + const label = (elements[0].shape as IGroup).getChildren()[1]; + expect(label.get('name')).toBe('venn-label'); + expect(path[0][0]).toBe('M'); + expect(path[1][0]).toBe('A'); + expect(path[2][0]).toBe('A'); + expect((elements[0].getData() as any).radius).toBe(400 / 2); + expect(path[1][1]).toBe(400 / 2); + }); + + it('sets: 2个集合 & changedata', () => { + plot.changeData([ + { sets: ['A'], size: 10, label: 'A' }, + { sets: ['B'], size: 10, label: 'B' }, + ]); + + const elements = plot.chart.geometries[0].elements; + + expect(elements.length).toBe(2); + expect(elements[0].getModel().size).toBe(1); + expect(elements[1].getModel().size).toBe(1); + // path: [['M', ...], ['A', rx ry 0 1 0 x y], ...] + const path = (elements[0].shape as IGroup).getChildren()[0].attr('path'); + const label = (elements[0].shape as IGroup).getChildren()[1]; + expect(label.get('name')).toBe('venn-label'); + expect(path[0][0]).toBe('M'); + expect(path[1][0]).toBe('A'); + expect(path[2][0]).toBe('A'); + expect(path[1][1]).toBe(400 / 2 / 2); + expect(path[1][6]).toBe(200); + // 居中 + expect(path[1][7]).toBe(500 / 2); + + const path1 = (elements[1].shape as IGroup).getChildren()[0].attr('path'); + expect(path1[1][1]).toBe(400 / 2 / 2); + expect(path1[1][6]).toBe(400); + expect(path1[1][7]).toBe(500 / 2); + }); + + it('sets: 2个集合 & exist intersection', () => { + plot.changeData([ + { sets: ['A'], size: 10, label: 'A' }, + { sets: ['B'], size: 10, label: 'B' }, + { sets: ['A', 'B'], size: 4, label: 'A&B' }, + ]); + + const elements = plot.chart.geometries[0].elements; + + expect(elements.length).toBe(3); + // 📒 size 的具体计算逻辑,可以再看下 + expect((elements[0].getData() as any).size).toBe(10); + expect((elements[1].getData() as any).size).toBe(10); + expect((elements[2].getData() as any).size).toBe(4); + // path: [['M', ...], ['A', rx ry 0 1 0 x y], ...] + const path = (elements[0].shape as IGroup).getChildren()[0].attr('path'); + const label = (elements[0].shape as IGroup).getChildren()[1]; + expect(label.get('name')).toBe('venn-label'); + expect(path[0][0]).toBe('M'); + expect(path[1][0]).toBe('A'); + expect(path[2][0]).toBe('A'); + expect(path[1][1]).toBeGreaterThan(400 / 2 / 2); + // 有交集,所以往前进一步 + expect(path[1][6]).toBeGreaterThan(200); + // 居中 + expect(path[1][7]).toBeCloseTo(500 / 2); + + const path1 = (elements[1].shape as IGroup).getChildren()[0].attr('path'); + expect(path1[1][1]).toBeGreaterThan(400 / 2 / 2); + expect(path1[1][6]).toBeLessThan(400); + expect(path1[1][7]).toBeCloseTo(500 / 2); + }); + + it('sets: 3个集合 & exist intersection', () => { + plot.changeData([ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + { sets: ['A', 'B', 'C'], size: 1 }, + ]); + + const elements = plot.chart.geometries[0].elements; + + expect(elements.length).toBe(7); + const path = (elements[0].shape as IGroup).getChildren()[0].attr('path'); + + expect(path[0][0]).toBe('M'); + expect(path[1][0]).toBe('A'); + expect(path[2][0]).toBe('A'); + + // 中心点 + expect((elements[6].getData() as any).x).toBeCloseTo(200); + // 元素对齐 + expect(elements[2].getData().x).toBeCloseTo((elements[6].getData() as any).x); + expect(elements[0].getData().y).toBeCloseTo((elements[1].getData() as any).y); + }); + + it('defaultOptions 保持从 constants 中获取', () => { + expect(Venn.getDefaultOptions()).toEqual(DEFAULT_OPTIONS); + }); + + it('韦恩图数据结构类型定义 types & constants', () => { + const vennData: VennData = [{ sets: [], size: 0, path: '', id: '' }]; + expect(vennData).toBeDefined(); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/label-spec.ts b/__tests__/unit/plots/venn/label-spec.ts new file mode 100644 index 0000000000..b80a3b38bb --- /dev/null +++ b/__tests__/unit/plots/venn/label-spec.ts @@ -0,0 +1,78 @@ +import { Venn } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; + +describe('venn: label', () => { + const data = [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + ]; + const plot = new Venn(createDiv(), { + data, + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + }); + plot.render(); + + it('label: offset', () => { + plot.update({ + label: { + offsetY: 6, + }, + }); + // @ts-ignore + expect(plot.chart.geometries[0].customOption.label.offsetY).toBe(6); + }); + + it('label: style', () => { + plot.update({ + label: { + style: { + textAlign: 'center', + fill: 'red', + lineHeight: 18, + }, + }, + }); + // @ts-ignore + expect(plot.chart.geometries[0].customOption.label.style.textAlign).toBe('center'); + // @ts-ignore + expect(plot.chart.geometries[0].customOption.label.style.fill).toBe('red'); + // @ts-ignore + expect(plot.chart.geometries[0].customOption.label.style.lineHeight).toBe(18); + }); + + it('label: formatter', () => { + const toPercent = (p) => `${(p * 100).toFixed(2)}%`; + const sum = data.reduce((a, b) => a + b.size, 0); + const formatter = (datum) => { + return datum.sets.length > 1 + ? `${datum.size} (${toPercent(datum.size / sum)})` + : `${datum.id}\n${datum.size} (${toPercent(datum.size / sum)})`; + }; + plot.update({ + label: { + formatter, + }, + }); + // @ts-ignore + expect(plot.chart.geometries[0].customOption.label.formatter).toEqual(formatter); + }); + + it('label: false', () => { + plot.update({ + label: false, + }); + // @ts-ignore + expect(plot.chart.geometries[0].customOption.label).toEqual(false); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/legend-spec.ts b/__tests__/unit/plots/venn/legend-spec.ts new file mode 100644 index 0000000000..7d39ec8089 --- /dev/null +++ b/__tests__/unit/plots/venn/legend-spec.ts @@ -0,0 +1,63 @@ +import { Venn } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; + +describe('venn: legend', () => { + const plot = new Venn(createDiv(), { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + ], + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + }); + plot.render(); + + it('legend: default', () => { + const legendController = plot.chart.getController('legend'); + + expect(legendController.getComponents().length).toBe(1); + expect(legendController.getComponents()[0].component.get('items').length).toBe(6); + }); + + it('legend: false', () => { + plot.update({ + legend: false, + }); + + const legendController = plot.chart.getController('legend'); + expect(legendController.getComponents().length).toBe(0); + }); + + it('legend: true', () => { + plot.update({ + legend: {}, + }); + + const legendController = plot.chart.getController('legend'); + expect(legendController.getComponents().length).toBe(1); + expect(legendController.getComponents()[0].component.get('items').length).toBe(6); + }); + + it('legend: position', () => { + plot.update({ + legend: { + position: 'top', + }, + }); + + const legendController = plot.chart.getController('legend'); + expect(legendController.getComponents().length).toBe(1); + expect(legendController.getComponents()[0].id).toBe('legend-id'); + expect(legendController.getComponents()[0].direction).toBe('top'); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/padding-spec.ts b/__tests__/unit/plots/venn/padding-spec.ts new file mode 100644 index 0000000000..a039efac93 --- /dev/null +++ b/__tests__/unit/plots/venn/padding-spec.ts @@ -0,0 +1,131 @@ +import { Venn } from '../../../../src'; +import { LEGEND_SPACE } from '../../../../src/plots/venn/adaptor'; +import { createDiv } from '../../../utils/dom'; + +describe('venn padding', () => { + const plot = new Venn(createDiv(), { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + ], + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + }); + plot.render(); + + it('appendPadding', () => { + plot.update({ + legend: false, + padding: 0, + appendPadding: [40, 0, 0, 0], + }); + expect(plot.chart.appendPadding).toEqual([40, 0, 0, 0]); + plot.chart.geometries[0].elements.forEach((element) => { + expect(element.shape.attr('matrix')[7]).toBe(40); + }); + + plot.update({ + legend: { position: 'left' }, + padding: 0, + appendPadding: [40, 0, 0, 0], + }); + expect(plot.chart.appendPadding).toEqual([40, 0, 0, LEGEND_SPACE]); + plot.chart.geometries[0].elements.forEach((element) => { + // 所有元素的都进行了偏移 [1, 0, 0, 0, 1, 0, 40, 0, 1] + expect(element.shape.attr('matrix')[6]).toBe(LEGEND_SPACE); + expect(element.shape.attr('matrix')[7]).toBe(40); + }); + + plot.update({ + legend: false, + padding: 0, + appendPadding: [10, 10, 10, 10], + }); + expect(plot.chart.appendPadding).toEqual([10, 10, 10, 10]); + plot.chart.geometries[0].elements.forEach((element) => { + expect(element.shape.attr('matrix')[6]).toBe(10); + }); + }); + + it('padding', () => { + plot.update({ + legend: false, + appendPadding: 0, + padding: [50, 0, 0, 0], + }); + expect(plot.chart.appendPadding).toEqual([50, 0, 0, 0]); + plot.chart.geometries[0].elements.forEach((element) => { + expect(element.shape.attr('matrix')[7]).toBe(50); + }); + + plot.update({ + legend: false, + appendPadding: 0, + padding: 100, + }); + expect(plot.chart.appendPadding).toEqual([100, 100, 100, 100]); + plot.chart.geometries[0].elements.forEach((element) => { + expect(element.shape.attr('matrix')[6]).toBe(100); + expect(element.shape.attr('matrix')[7]).toBe(100); + }); + + plot.update({ + legend: { position: 'left' }, + appendPadding: 0, + padding: [50, 0, 0, 0], + }); + expect(plot.chart.appendPadding).toEqual([50, 0, 0, LEGEND_SPACE]); + plot.chart.geometries[0].elements.forEach((element) => { + // 所有元素的都进行了偏移 [1, 0, 0, 0, 1, 0, 40, 50, 1] + expect(element.shape.attr('matrix')[6]).toBe(LEGEND_SPACE); + expect(element.shape.attr('matrix')[7]).toBe(50); + }); + }); + + it('padding & appendPadding', () => { + plot.update({ + legend: false, + appendPadding: 10, + padding: [50, 0, 0, 0], + }); + expect(plot.chart.appendPadding).toEqual([60, 10, 10, 10]); + expect(plot.chart.padding).toEqual([50, 0, 0, 0]); + plot.chart.geometries[0].elements.forEach((element) => { + // 所有元素的都进行了偏移 [1, 0, 0, 0, 1, 0, 0, 60, 1] + expect(element.shape.attr('matrix')[7]).toBe(60); + }); + + plot.update({ + legend: { position: 'top' }, + appendPadding: 10, + padding: 50, + }); + expect(plot.chart.appendPadding).toEqual([60 + LEGEND_SPACE, 60, 60, 60]); + expect(plot.chart.padding).toEqual(50); + plot.chart.geometries[0].elements.forEach((element) => { + expect(element.shape.attr('matrix')[6]).toBe(60); + expect(element.shape.attr('matrix')[7]).toBe(60 + LEGEND_SPACE); + }); + }); + + it('changesize', () => { + plot.changeSize(800, 500); + // @ts-ignore + plot.triggerResize(); + // 确认resize后,元素依然偏移 + plot.chart.geometries[0].elements.forEach((element) => { + expect(element.shape.attr('matrix')[6]).toBe(60); + expect(element.shape.attr('matrix')[7]).toBe(60 + LEGEND_SPACE); + }); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/style-spec.ts b/__tests__/unit/plots/venn/style-spec.ts new file mode 100644 index 0000000000..ff59912d0a --- /dev/null +++ b/__tests__/unit/plots/venn/style-spec.ts @@ -0,0 +1,74 @@ +import { Venn } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; + +describe('venn: pointStyle', () => { + const plot = new Venn(createDiv(), { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + ], + width: 400, + height: 500, + legend: false, + setsField: 'sets', + sizeField: 'size', + }); + plot.render(); + + it('style: object', () => { + plot.update({ + pointStyle: { + fill: 'red', + stroke: 'yellow', + opacity: 0.8, + lineWidth: 4, + lineDash: [2, 2], + fillOpacity: 0.5, + strokeOpacity: 0.5, + }, + }); + + const elements = plot.chart.geometries[0].elements; + expect(elements[0].getModel().style.fill).toBe('red'); + expect(elements[0].getModel().style.stroke).toBe('yellow'); + expect(elements[0].getModel().style.opacity).toBe(0.8); + expect(elements[0].getModel().style.lineDash).toEqual([2, 2]); + expect(elements[0].getModel().style.fillOpacity).toBe(0.5); + expect(elements[0].getModel().style.strokeOpacity).toBe(0.5); + }); + + it('style: callback', () => { + plot.update({ + pointStyle: ({ size }) => { + if (size > 2) { + return { + fill: 'blue', + stroke: 'yellow', + opacity: 0.8, + }; + } + return { + fill: 'red', + stroke: 'green', + opacity: 0.5, + }; + }, + }); + + const elements = plot.chart.geometries[0].elements; + expect(elements[0].getModel().style.fill).toBe('blue'); + expect(elements[0].getModel().style.stroke).toBe('yellow'); + expect(elements[0].getModel().style.opacity).toBe(0.8); + expect(elements[3].getModel().style.fill).toBe('red'); + expect(elements[3].getModel().style.stroke).toBe('green'); + expect(elements[3].getModel().style.opacity).toBe(0.5); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/plots/venn/tooltip-spec.ts b/__tests__/unit/plots/venn/tooltip-spec.ts new file mode 100644 index 0000000000..50dd856ec9 --- /dev/null +++ b/__tests__/unit/plots/venn/tooltip-spec.ts @@ -0,0 +1,102 @@ +import { Venn } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; + +describe('venn: tooltip', () => { + const div = createDiv(); + const data = [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + ]; + const plot = new Venn(div, { + data, + width: 400, + height: 500, + setsField: 'sets', + sizeField: 'size', + }); + plot.render(); + + it('tooltip: defaultOption', () => { + const tooltipOptions = plot.chart.getOptions().tooltip; + // 默认有 tooltip options + expect(tooltipOptions).not.toBe(false); + // @ts-ignore + expect(tooltipOptions.showMarkers).toBe(false); + // @ts-ignore + expect(tooltipOptions.showTitle).toBe(false); + // @ts-ignore + expect(tooltipOptions.fields).toEqual(['id', 'size']); + }); + + it('tooltip: showTooltip', () => { + const tooltipController = plot.chart.getController('tooltip'); + const box = plot.chart.geometries[0].elements[2].shape.getBBox(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + + plot.chart.showTooltip(point); + expect(div.querySelectorAll('.g2-tooltip-list-item').length).toBe(1); + + const items = tooltipController.getTooltipItems(point); + expect(items.length).toBe(1); + expect((div.querySelector('.g2-tooltip-name') as HTMLElement).innerText).toBe('C'); + expect((div.querySelectorAll('.g2-tooltip-value')[0] as HTMLElement).innerText).toBe( + `${plot.chart.getData()[2].size}` + ); + plot.chart.hideTooltip(); + }); + + it('tooltip: fields and formatter', () => { + plot.update({ + tooltip: { + fields: ['sets', 'size'], + formatter: (datum) => ({ name: `${datum.sets}`, value: `🌞${datum.size}` }), + }, + }); + const box = plot.chart.geometries[0].elements[2].shape.getBBox(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + plot.chart.showTooltip(point); + expect((div.querySelector('.g2-tooltip-name') as HTMLElement).innerText).toBe(`${plot.chart.getData()[2].sets}`); + expect((div.querySelectorAll('.g2-tooltip-value')[0] as HTMLElement).innerText).toBe( + `🌞${plot.chart.getData()[2].size}` + ); + plot.chart.hideTooltip(); + }); + + it('tooltip: customContent', () => { + plot.update({ + tooltip: { + fields: ['id', 'size'], + formatter: undefined, + customContent: (title, items) => + `
${items.map((item) => `${item.value}`)}
`, + }, + }); + const box = plot.chart.geometries[0].elements[2].shape.getBBox(); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + plot.chart.showTooltip(point); + + expect((div.querySelectorAll('.custom-tooltip-value')[0] as HTMLElement).innerText).toBe( + `${plot.chart.getData()[2].id}` + ); + expect((div.querySelectorAll('.custom-tooltip-value')[1] as HTMLElement).innerText).toBe( + `${plot.chart.getData()[2].size}` + ); + plot.chart.hideTooltip(); + }); + + it('tooltip: hide', () => { + plot.update({ tooltip: false }); + // @ts-ignore + expect(plot.chart.options.tooltip).toBe(false); + // @ts-ignore + expect(plot.chart.getController('tooltip').isVisible()).toBe(false); + }); + + afterAll(() => { + plot.destroy(); + }); +}); diff --git a/__tests__/unit/utils/color/blend-spec.ts b/__tests__/unit/utils/color/blend-spec.ts new file mode 100644 index 0000000000..9cb541daca --- /dev/null +++ b/__tests__/unit/utils/color/blend-spec.ts @@ -0,0 +1,39 @@ +import { blend, colorToArr, innerBlend } from '../../../../src/utils/color/blend'; + +describe('blend utils', () => { + it('colorToArr', () => { + expect(colorToArr('red')).toEqual([255, 0, 0, 1]); + expect(colorToArr('rgba(255, 34, 0, 0)')).toEqual([255, 34, 0, 0]); + expect(colorToArr('rgba(255, 34, 0, 0.4)')).toEqual([255, 34, 0, 0.4]); + expect(colorToArr('rgba(255, 34, 0, 1)')).toEqual([255, 34, 0, 1]); + expect(colorToArr('#ff0000')).toEqual([255, 0, 0, 1]); + }); + + it('innerBlend', () => { + expect(innerBlend('normal')(255)).toBe(255); + expect(innerBlend('multiply')(255, 200)).toBe(200); + expect(innerBlend('screen')(255, 255)).toBe(255); + expect(innerBlend('overlay')(255, 0)).toBe(0); + expect(innerBlend('overlay')(255, 255)).toBe(255); + expect(innerBlend('darken')(255, 0)).toBe(0); + expect(innerBlend('darken')(0, 255)).toBe(0); + expect(innerBlend('lighten')(255, 0)).toBe(255); + expect(innerBlend('lighten')(0, 255)).toBe(255); + expect(innerBlend('dodge')(255, 0)).toBe(255); + expect(innerBlend('dodge')(0, 255)).toBe(255); + expect(innerBlend('burn')(0, 255)).toBe(255); + expect(innerBlend('burn')(255, 0)).toBe(0); + expect(innerBlend('burn')(200, 100) | 0).toBe(57); + }); + + it('blend', () => { + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'normal')).toBe('rgba(97, 0, 158, 0.79)'); + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'multiply')).toBe('rgba(29, 0, 158, 0.79)'); + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'screen')).toBe('rgba(97, 0, 226, 0.79)'); + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'overlay')).toBe('rgba(29, 0, 226, 0.79)'); + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'darken')).toBe('rgba(29, 0, 158, 0.79)'); + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'lighten')).toBe('rgba(97, 0, 226, 0.79)'); + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'burn')).toBe('rgba(29, 0, 226, 0.79)'); + expect(blend('rgba(255, 0, 0, 0.3)', 'rgba(0, 0, 255, 0.7)', 'dodge')).toBe('rgba(97, 0, 226, 0.79)'); + }); +}); diff --git a/docs/api/plots/venn.en.md b/docs/api/plots/venn.en.md new file mode 100644 index 0000000000..1f8fde70f6 --- /dev/null +++ b/docs/api/plots/venn.en.md @@ -0,0 +1,125 @@ +--- +title: Venn +order: 12 +--- + +### Plot Container + +`markdown:docs/common/chart-options.en.md` + +### Data Mapping + +#### data + +**required** _object_ + +Configure the chart data source. For example: + +```ts + const data = [ + { sets: ['A'], size: 5 }, + { sets: ['B'], size: 10 }, + { sets: ['A', 'B'], size: 2 }, + ... + ]; +``` + +#### setsField + +**optional** _string_ + +The field of the collection(sets). + +#### sizeField + +**optional** _string_ + +The name of the data field corresponding to the point size map. + +### Geometry Style + +`markdown:docs/common/color.en.md` + +#### blendMode + +**optional** _string_ + +Color blend mode of the intersection area, default: `multiply`. Other: `darken`, `lighten`, `screen`, `overlay`, `burn`, and `dodge`. +reference:https://gka.github.io/chroma.js/#chroma-blend + +#### pointStyle + +**optional** _object_ + +Set the point style. The `fill` in pointStyle overrides the configuration of `color`. PointStyle can be specified either directly or via a callback to specify individual styles based on the data. + +Default configuration: + +| Properties | Type | Description | +| ------------- | ------ | --------------------- | +| fill | string | Fill color | +| stroke | string | Stroke color | +| lineWidth | number | Line width | +| lineDash | number | The dotted lines show | +| opacity | number | Transparency | +| fillOpacity | number | Fill transparency | +| strokeOpacity | number | Stroke transparency | + +```ts +// Specified directly +{ + pointStyle: { + fill: 'red', + stroke: 'yellow', + opacity: 0.8 + }, +} +// Function +{ + pointStyle: ({ size }) => { + if (size > 1) { + return { + fill: 'green', + stroke: 'yellow', + opacity: 0.8, + } + } + return { + fill: 'red', + stroke: 'yellow', + opacity: 0.8, + } + }, +} +``` + +### Plot Components + +#### legend + +`markdown:docs/common/legend.en.md` + +#### label + +`markdown:docs/common/label.en.md` + +#### tooltip + +`markdown:docs/common/tooltip.en.md` + + +### Plot Interactions + +`markdown:docs/common/interactions.en.md` + +### Plot Event + +`markdown:docs/common/events.en.md` + +### Plot Method + +`markdown:docs/common/chart-methods.en.md` + +### Plot Theme + +`markdown:docs/common/theme.en.md` diff --git a/docs/api/plots/venn.zh.md b/docs/api/plots/venn.zh.md new file mode 100644 index 0000000000..14ccb22acc --- /dev/null +++ b/docs/api/plots/venn.zh.md @@ -0,0 +1,125 @@ +--- +title: Venn +order: 12 +--- + +### 图表容器 + +`markdown:docs/common/chart-options.zh.md` + +### 数据映射 + +#### data + +**required** _object_ + +设置图表数据源。数据源为对象集合,例如: + +```ts + const data = [ + { sets: ['A'], size: 5 }, + { sets: ['B'], size: 10 }, + { sets: ['A', 'B'], size: 2 }, + ... + ]; +``` + +#### setsField + +**optional** _string_ + +设置集合的字段。 + +#### sizeField + +**optional** _string_ + +圆形大小映射对应的字段。 + +### 图形样式 + + +`markdown:docs/common/color.zh.md` + +#### blendMode + +**optional** _string_ + +交集区域的颜色混合方式, 默认: `multiply`(正片叠底)。可选项: `multiply`, `darken`, `lighten`, `screen`, `overlay`, `burn`, and `dodge`. +参考:https://gka.github.io/chroma.js/#chroma-blend + +#### pointStyle + +**optional** _object_ + +设置点样式。pointStyle 中的`fill`会覆盖 `color` 的配置。pointStyle 可以直接指定,也可以通过 callback 的方式,根据数据指定单独的样式。 + +默认配置: + +| 细分配置 | 类型 | 功能描述 | +| ------------- | ------ | ---------- | +| fill | string | 填充颜色 | +| stroke | string | 描边颜色 | +| lineWidth | number | 线宽 | +| lineDash | number | 虚线显示 | +| opacity | number | 透明度 | +| fillOpacity | number | 填充透明度 | +| strokeOpacity | number | 描边透明度 | + +```ts +// 直接指定 +{ + pointStyle: { + fill: 'red', + stroke: 'yellow', + opacity: 0.8 + }, +} +// Function +{ + pointStyle: ({ size }) => { + if (size > 1) { + return { + fill: 'green', + stroke: 'yellow', + opacity: 0.8, + } + } + return { + fill: 'red', + stroke: 'yellow', + opacity: 0.8, + } + }, +} +``` + +### 图表组件 + +#### legend + +`markdown:docs/common/legend.zh.md` + +#### label + +`markdown:docs/common/label.zh.md` + +#### tooltip + +`markdown:docs/common/tooltip.zh.md` + +### 图表交互 + +`markdown:docs/common/interactions.zh.md` + +### 图表事件 + +`markdown:docs/common/events.zh.md` + +### 图表方法 + +`markdown:docs/common/chart-methods.zh.md` + +### 图表主题 + +`markdown:docs/common/theme.zh.md` diff --git a/examples/more-plots/venn/API.en.md b/examples/more-plots/venn/API.en.md new file mode 100644 index 0000000000..707c21e55e --- /dev/null +++ b/examples/more-plots/venn/API.en.md @@ -0,0 +1 @@ +`markdown:docs/api/plots/venn.en.md` \ No newline at end of file diff --git a/examples/more-plots/venn/API.zh.md b/examples/more-plots/venn/API.zh.md new file mode 100644 index 0000000000..46374368a5 --- /dev/null +++ b/examples/more-plots/venn/API.zh.md @@ -0,0 +1 @@ +`markdown:docs/api/plots/venn.zh.md` \ No newline at end of file diff --git a/examples/more-plots/venn/demo/basic.ts b/examples/more-plots/venn/demo/basic.ts new file mode 100644 index 0000000000..2b8ee65304 --- /dev/null +++ b/examples/more-plots/venn/demo/basic.ts @@ -0,0 +1,17 @@ +import { Venn } from '@antv/g2plot'; + +const plot = new Venn('container', { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + { sets: ['A', 'B', 'C'], size: 1 }, + ], + setsField: 'sets', + sizeField: 'size', + pointStyle: { fillOpacity: 0.85 }, +}); +plot.render(); diff --git a/examples/more-plots/venn/demo/blend-mode.ts b/examples/more-plots/venn/demo/blend-mode.ts new file mode 100644 index 0000000000..0698f3c127 --- /dev/null +++ b/examples/more-plots/venn/demo/blend-mode.ts @@ -0,0 +1,19 @@ +import { Venn } from '@antv/g2plot'; + +const plot = new Venn('container', { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + { sets: ['A', 'B', 'C'], size: 1 }, + ], + setsField: 'sets', + sizeField: 'size', + // more blendMode to see: https://gka.github.io/chroma.js/#chroma-blend + blendMode: 'overlay', + pointStyle: { fillOpacity: 0.85 }, +}); +plot.render(); diff --git a/examples/more-plots/venn/demo/customize.ts b/examples/more-plots/venn/demo/customize.ts new file mode 100644 index 0000000000..44d5c55e8c --- /dev/null +++ b/examples/more-plots/venn/demo/customize.ts @@ -0,0 +1,57 @@ +import { Venn } from '@antv/g2plot'; + +fetch('https://gw.alipayobjects.com/os/antfincdn/yzC3ZiBbhM/venn-data.json') + .then((data) => data.json()) + .then((data) => { + const sum = data.reduce((a, b) => a + b.size, 0); + const toPercent = (p) => `${(p * 100).toFixed(2)}%`; + const plot = new Venn('container', { + setsField: 'sets', + sizeField: 'size', + data, + pointStyle: { fillOpacity: 0.85 }, + color: ['#9DF5CA', '#61DDAA', '#42C090'], + label: { + style: { + lineHeight: 18, + }, + formatter: (datum) => { + return datum.sets.length > 1 + ? `${datum.size} (${toPercent(datum.size / sum)})` + : `${datum.id}\n${datum.size} (${toPercent(datum.size / sum)})`; + }, + }, + tooltip: { + fields: ['sets', 'size'], + customContent: (title, items) => { + const datum = items[0]?.data || {}; + const color = items[0]?.color; + + let listStr = ''; + if (datum['伙伴名称']?.length > 0) { + datum['伙伴名称'].forEach((item, idx) => { + listStr += `
+ ${idx}. ${item} +
`; + }); + } + + return `
+
+ + ${datum.sets?.join('&')} + ${datum.size} +
+ ${ + listStr + ? `
+ 伙伴名称 +
${listStr}` + : '' + } +
`; + }, + }, + }); + plot.render(); + }); diff --git a/examples/more-plots/venn/demo/label.ts b/examples/more-plots/venn/demo/label.ts new file mode 100644 index 0000000000..5ebbbf70c2 --- /dev/null +++ b/examples/more-plots/venn/demo/label.ts @@ -0,0 +1,24 @@ +import { Venn } from '@antv/g2plot'; + +const plot = new Venn('container', { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + { sets: ['A', 'B', 'C'], size: 1, label: 'A&B&C' }, + ], + setsField: 'sets', + sizeField: 'size', + pointStyle: { fillOpacity: 0.85 }, + label: { + offsetY: 7, + style: { + fontSize: 14, + }, + formatter: (datum) => `${datum.sets.join('&')}: ${datum.size}`, + }, +}); +plot.render(); diff --git a/examples/more-plots/venn/demo/meta.json b/examples/more-plots/venn/demo/meta.json new file mode 100644 index 0000000000..de83076285 --- /dev/null +++ b/examples/more-plots/venn/demo/meta.json @@ -0,0 +1,53 @@ +{ + "title": { + "zh": "韦恩图", + "en": "Venn" + }, + "demos": [ + { + "filename": "basic.ts", + "title": { + "zh": "基础韦恩图", + "en": "Basic venn plot" + }, + "new": true, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/BJw8fy6uxU/009dd50e-c2a4-48dc-a79a-dc417123889f.png" + }, + { + "filename": "blend-mode.ts", + "title": { + "zh": "设置颜色叠加模式", + "en": "Color blend mode" + }, + "new": true, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/XZE3uS3Ezm/e18cdf57-f528-4a43-a0a5-b92dba819477.png" + }, + { + "filename": "tooltip.ts", + "title": { + "zh": "格式化 tooltip", + "en": "Formatter tooltip" + }, + "new": true, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/BJw8fy6uxU/009dd50e-c2a4-48dc-a79a-dc417123889f.png" + }, + { + "filename": "label.ts", + "title": { + "zh": "设置 label", + "en": "Label setting" + }, + "new": true, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/nyPJ6bGZ68/d5e85b5d-70c5-45b5-94aa-9a229e65474c.png" + }, + { + "filename": "customize.ts", + "title": { + "zh": "自定义韦恩图", + "en": "Customize venn plot" + }, + "new": true, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/T6cgHx5BHB/f2137e3b-5784-4626-a986-109fc8cb5feb.png" + } + ] +} \ No newline at end of file diff --git a/examples/more-plots/venn/demo/tooltip.ts b/examples/more-plots/venn/demo/tooltip.ts new file mode 100644 index 0000000000..dcdcf07e77 --- /dev/null +++ b/examples/more-plots/venn/demo/tooltip.ts @@ -0,0 +1,23 @@ +import { Venn } from '@antv/g2plot'; + +const plot = new Venn('container', { + data: [ + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 12, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 2, label: 'B&C' }, + { sets: ['A', 'B', 'C'], size: 1, label: 'A&B&C' }, + ], + setsField: 'sets', + sizeField: 'size', + pointStyle: { fillOpacity: 0.85 }, + tooltip: { + fields: ['label', 'size'], + formatter: (datum) => { + return { name: datum.label, value: datum.size }; + }, + }, +}); +plot.render(); diff --git a/examples/more-plots/venn/index.en.md b/examples/more-plots/venn/index.en.md new file mode 100644 index 0000000000..509dd78892 --- /dev/null +++ b/examples/more-plots/venn/index.en.md @@ -0,0 +1,4 @@ +--- +title: Venn +order: 12 +--- diff --git a/examples/more-plots/venn/index.zh.md b/examples/more-plots/venn/index.zh.md new file mode 100644 index 0000000000..f7b0d5e9ef --- /dev/null +++ b/examples/more-plots/venn/index.zh.md @@ -0,0 +1,4 @@ +--- +title: 韦恩图 +order: 12 +--- diff --git a/package.json b/package.json index ae10b92729..4fbc5b248c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@antv/g2": "^4.1.23", "d3-hierarchy": "^2.0.0", "d3-regression": "^1.3.5", + "fmin": "^0.0.2", "pdfast": "^0.2.0", "size-sensor": "^1.0.1", "tslib": "^2.0.3" @@ -126,7 +127,8 @@ "!**/node_modules/**", "!**/vendor/**", "!**/_template/**", - "!**/interactions/**" + "!**/interactions/**", + "!**/venn/layout/**" ], "testRegex": "/__tests__/.*-spec\\.ts?$" }, @@ -142,11 +144,11 @@ "limit-size": [ { "path": "dist/g2plot.min.js", - "limit": "950 Kb" + "limit": "985 Kb" }, { "path": "dist/g2plot.min.js", - "limit": "260 Kb", + "limit": "270 Kb", "gzip": true } ], @@ -157,4 +159,4 @@ "@babel/standalone": "7.12.6", "d3-array": "2.12.1" } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 689339b8e8..c6321d303f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,10 @@ export type { BoxOptions } from './plots/box'; export { Violin } from './plots/violin'; export type { ViolinOptions } from './plots/violin'; +// 韦恩图及类型定义 | author by [visiky](https://github.com/visiky) +export { Venn } from './plots/venn'; +export type { VennOptions } from './plots/venn'; + // K线图及类型定义 | author by [jhwong](https://github.com/jinhuiWong), [visiky](https://github.com/visiky) export { Stock } from './plots/stock'; export type { StockOptions } from './plots/stock'; diff --git a/src/plots/circle-packing/adaptor.ts b/src/plots/circle-packing/adaptor.ts index 25a8cc74b0..411579c727 100644 --- a/src/plots/circle-packing/adaptor.ts +++ b/src/plots/circle-packing/adaptor.ts @@ -12,8 +12,8 @@ import { pattern, } from '../../adaptor/common'; import { flow, deepAssign } from '../../utils'; -import { getAdjustAppendPadding } from '../../utils/padding'; -import { transformData, resolvePaddingForCircle, resolveAllPadding } from './utils'; +import { getAdjustAppendPadding, resolveAllPadding } from '../../utils/padding'; +import { transformData, resolvePaddingForCircle } from './utils'; import { CirclePackingOptions } from './types'; import { RAW_FIELDS } from './constant'; diff --git a/src/plots/circle-packing/utils.ts b/src/plots/circle-packing/utils.ts index 72d2726d3b..7bfe09a505 100644 --- a/src/plots/circle-packing/utils.ts +++ b/src/plots/circle-packing/utils.ts @@ -2,7 +2,7 @@ import { Types } from '@antv/g2'; import { pack } from '../../utils/hierarchy/pack'; import { deepAssign, pick } from '../../utils'; import { HIERARCHY_DATA_TRANSFORM_PARAMS } from '../../interactions/actions/drill-down'; -import { normalPadding } from '../../utils/padding'; +import { resolveAllPadding } from '../../utils/padding'; import { CirclePackingOptions } from './types'; interface TransformDataOptions { @@ -55,26 +55,6 @@ export function transformData(options: TransformDataOptions) { return result; } -/** - * 根据图表的 padding 和 appendPadding 计算出图表的最终 padding - * @param array - */ -export function resolveAllPadding(paddings: Types.ViewPadding[]) { - // 先把数组里的 padding 全部转换成 normal - const normalPaddings = paddings.map((item) => normalPadding(item)); - let finalPadding = [0, 0, 0, 0]; - if (normalPaddings.length > 0) { - finalPadding = finalPadding.map((item, index) => { - // 有几个 padding 数组就遍历几次,累加 - normalPaddings.forEach((d, i) => { - item += normalPaddings[i][index]; - }); - return item; - }); - } - return finalPadding; -} - /** * 根据传入的 padding 和 现有的 画布大小, 输出针对圆形视图布局需要的 finalPadding 以及 finalSize * @param params diff --git a/src/plots/funnel/geometries/basic.ts b/src/plots/funnel/geometries/basic.ts index 89c35b1936..aa445f908a 100644 --- a/src/plots/funnel/geometries/basic.ts +++ b/src/plots/funnel/geometries/basic.ts @@ -1,6 +1,6 @@ import { Types } from '@antv/g2'; import { isArray } from '@antv/util'; -import { flow, findGeometry, deepAssign } from '../../../utils'; +import { flow, findGeometry } from '../../../utils'; import { getTooltipMapping } from '../../../utils/tooltip'; import { Params } from '../../../core/adaptor'; import { Datum, Data } from '../../../types/common'; diff --git a/src/plots/venn/adaptor.ts b/src/plots/venn/adaptor.ts new file mode 100644 index 0000000000..6d2db72e31 --- /dev/null +++ b/src/plots/venn/adaptor.ts @@ -0,0 +1,141 @@ +import { Geometry } from '@antv/g2'; +import { isArray, get } from '@antv/util'; +import { interaction, animation, theme, tooltip, scale } from '../../adaptor/common'; +import { Params } from '../../core/adaptor'; +import { schema as schemaGeometry } from '../../adaptor/geometries'; +import { deepAssign, flow, getAdjustAppendPadding, normalPadding, resolveAllPadding } from '../../utils'; +import { Datum } from '../../types'; +import { getColorMap, layoutVennData } from './utils'; +import { CustomInfo, VennData, VennOptions } from './types'; +import { ID_FIELD } from './constant'; +import './shape'; + +/** 图例默认预留空间 */ +export const LEGEND_SPACE = 40; + +/** + * color options 转换 + */ +function transformColor(params: Params, data: VennData): VennOptions['color'] { + const { chart, options } = params; + const { color, setsField } = options; + + if (typeof color !== 'function') { + let colorPalette = typeof color === 'string' ? [color] : color; + if (!isArray(colorPalette)) { + const { colors10, colors20 } = chart.getTheme(); + colorPalette = data.filter((d) => d[setsField].length === 1).length <= 10 ? colors10 : colors20; + } + const colorMap = getColorMap(colorPalette, data, options); + return (datum: Datum) => colorMap.get(datum[ID_FIELD]) || colorPalette[0]; + } + return color; +} + +/** + * 处理 padding + */ +function padding(params: Params): Params { + const { chart, options } = params; + const { legend, appendPadding, padding } = options; + + // 处理 legend 的位置. 默认预留 40px, 业务上可以通过 appendPadding 增加 + let tempPadding: number[] = normalPadding(appendPadding); + if (legend !== false) { + tempPadding = getAdjustAppendPadding(appendPadding, get(legend, 'position'), LEGEND_SPACE); + } + + chart.appendPadding = resolveAllPadding([tempPadding, padding]); + + return params; +} + +/** + * geometry 处理 + * @param params + */ +function geometry(params: Params): Params { + const { chart, options } = params; + const { pointStyle, label, setsField, sizeField } = options; + + // 获取容器大小 + const [t, r, b, l] = normalPadding(chart.appendPadding); + // 处理 legend 的位置. 默认预留 40px, 业务上可以通过 appendPadding 增加 + const customInfo: CustomInfo = { offsetX: l, offsetY: t, label }; + // coordinateBBox + appendPadding = viewBBox, 不需要再计算 appendPadding 部分,因此直接使用 viewBBox + const { width, height } = chart.viewBBox; + + const vennData: VennData = layoutVennData(options, width - (r + l), height - (t + b), 0); + chart.data(vennData); + + const { ext } = schemaGeometry( + deepAssign({}, params, { + options: { + xField: 'x', + yField: 'y', + sizeField: sizeField, + seriesField: ID_FIELD, + rawFields: [setsField, sizeField], + // 不使用 G2 的label,直接在自定义 shape 中实现 + label: false, + schema: { + shape: 'venn', + style: pointStyle, + color: transformColor(params, vennData), + }, + }, + }) + ); + + const geometry = ext.geometry as Geometry; + geometry.customInfo(customInfo); + + return params; +} + +/** + * legend 配置 + * @param params + */ +export function legend(params: Params): Params { + const { chart, options } = params; + const { legend, sizeField } = options; + + chart.legend(ID_FIELD, legend); + // 强制不开启 连续图例 + chart.legend(sizeField, false); + + return params; +} + +/** + * 默认关闭坐标轴 + * @param params + */ +export function axis(params: Params): Params { + const { chart } = params; + chart.axis(false); + + return params; +} + +/** + * 图适配器 + * @param chart + * @param options + */ +export function adaptor(params: Params) { + // flow 的方式处理所有的配置到 G2 API + return flow( + padding, + theme, + geometry, + scale({}), + legend, + axis, + tooltip, + interaction, + animation + // ... 其他的 adaptor flow + )(params); +} diff --git a/src/plots/venn/constant.ts b/src/plots/venn/constant.ts new file mode 100644 index 0000000000..0d7f9e80b6 --- /dev/null +++ b/src/plots/venn/constant.ts @@ -0,0 +1,36 @@ +import { VennOptions } from './types'; + +// 一些字段常量定义,需要在文档初告知用户 +export const ID_FIELD = 'id'; +export const PATH_FIELD = 'path'; + +/** + * 韦恩图 默认配置项 + */ +export const DEFAULT_OPTIONS: Partial = { + appendPadding: [10, 0, 20, 0], + blendMode: 'multiply', + tooltip: { + showTitle: false, + showMarkers: false, + fields: ['id', 'size'], + formatter: (datum) => { + return { name: datum.id, value: datum.size }; + }, + }, + legend: { position: 'top-left' }, + label: { + offsetY: 6, + style: { + textAlign: 'center', + fill: '#fff', + }, + }, + // 默认不开启 图例筛选交互 + interactions: [ + { type: 'legend-filter', enable: false }, + // hover 激活的时候,元素的层级展示不太好 先移除该交互 + { type: 'legend-highlight', enable: false }, + { type: 'legend-active', enable: false }, + ], +}; diff --git a/src/plots/venn/index.ts b/src/plots/venn/index.ts new file mode 100644 index 0000000000..0743c2a70a --- /dev/null +++ b/src/plots/venn/index.ts @@ -0,0 +1,47 @@ +import { Plot } from '../../core/plot'; +import { Adaptor } from '../../core/adaptor'; +import { VennOptions } from './types'; +import { adaptor } from './adaptor'; +import { DEFAULT_OPTIONS } from './constant'; + +export type { VennOptions }; + +/** + * 这个是一个图表开发的 模板代码! + */ +export class Venn extends Plot { + /** 图表类型 */ + public type: string = 'venn'; + + static getDefaultOptions() { + return DEFAULT_OPTIONS; + } + + /** + * 获取 韦恩图 默认配置 + */ + protected getDefaultOptions() { + return Venn.getDefaultOptions(); + } + + /** + * 获取适配器 + */ + protected getSchemaAdaptor(): Adaptor { + return adaptor; + } + + /** + * 覆写父类的方法 + */ + protected triggerResize() { + if (!this.chart.destroyed) { + // 首先自适应容器的宽高 + this.chart.forceFit(); // g2 内部执行 changeSize,changeSize 中执行 render(true) + this.chart.clear(); + this.execAdaptor(); // 核心:宽高更新之后计算布局 + // 渲染 + this.chart.render(true); + } + } +} diff --git a/src/plots/venn/layout/circleintersection.ts b/src/plots/venn/layout/circleintersection.ts new file mode 100644 index 0000000000..b93496b8df --- /dev/null +++ b/src/plots/venn/layout/circleintersection.ts @@ -0,0 +1,223 @@ +const SMALL = 1e-10; + +/** Returns the intersection area of a bunch of circles (where each circle + is an object having an x,y and radius property) */ +export function intersectionArea(circles, stats?: any) { + // get all the intersection points of the circles + const intersectionPoints = getIntersectionPoints(circles); + + // filter out points that aren't included in all the circles + const innerPoints = intersectionPoints.filter(function (p) { + return containedInCircles(p, circles); + }); + + let arcArea = 0, + polygonArea = 0, + i; + const arcs = []; + // if we have intersection points that are within all the circles, + // then figure out the area contained by them + if (innerPoints.length > 1) { + // sort the points by angle from the center of the polygon, which lets + // us just iterate over points to get the edges + const center = getCenter(innerPoints); + for (i = 0; i < innerPoints.length; ++i) { + const p = innerPoints[i]; + p.angle = Math.atan2(p.x - center.x, p.y - center.y); + } + innerPoints.sort(function (a, b) { + return b.angle - a.angle; + }); + + // iterate over all points, get arc between the points + // and update the areas + let p2 = innerPoints[innerPoints.length - 1]; + for (i = 0; i < innerPoints.length; ++i) { + const p1 = innerPoints[i]; + + // polygon area updates easily ... + polygonArea += (p2.x + p1.x) * (p1.y - p2.y); + + // updating the arc area is a little more involved + const midPoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; + let arc = null; + + for (let j = 0; j < p1.parentIndex.length; ++j) { + if (p2.parentIndex.indexOf(p1.parentIndex[j]) > -1) { + // figure out the angle halfway between the two points + // on the current circle + const circle = circles[p1.parentIndex[j]], + a1 = Math.atan2(p1.x - circle.x, p1.y - circle.y), + a2 = Math.atan2(p2.x - circle.x, p2.y - circle.y); + + let angleDiff = a2 - a1; + if (angleDiff < 0) { + angleDiff += 2 * Math.PI; + } + + // and use that angle to figure out the width of the + // arc + const a = a2 - angleDiff / 2; + let width = distance(midPoint, { + x: circle.x + circle.radius * Math.sin(a), + y: circle.y + circle.radius * Math.cos(a), + }); + + // clamp the width to the largest is can actually be + // (sometimes slightly overflows because of FP errors) + if (width > circle.radius * 2) { + width = circle.radius * 2; + } + + // pick the circle whose arc has the smallest width + if (arc === null || arc.width > width) { + arc = { circle: circle, width: width, p1: p1, p2: p2 }; + } + } + } + + if (arc !== null) { + arcs.push(arc); + arcArea += circleArea(arc.circle.radius, arc.width); + p2 = p1; + } + } + } else { + // no intersection points, is either disjoint - or is completely + // overlapped. figure out which by examining the smallest circle + let smallest = circles[0]; + for (i = 1; i < circles.length; ++i) { + if (circles[i].radius < smallest.radius) { + smallest = circles[i]; + } + } + + // make sure the smallest circle is completely contained in all + // the other circles + let disjoint = false; + for (i = 0; i < circles.length; ++i) { + if (distance(circles[i], smallest) > Math.abs(smallest.radius - circles[i].radius)) { + disjoint = true; + break; + } + } + + if (disjoint) { + arcArea = polygonArea = 0; + } else { + arcArea = smallest.radius * smallest.radius * Math.PI; + arcs.push({ + circle: smallest, + p1: { x: smallest.x, y: smallest.y + smallest.radius }, + p2: { x: smallest.x - SMALL, y: smallest.y + smallest.radius }, + width: smallest.radius * 2, + }); + } + } + + polygonArea /= 2; + if (stats) { + stats.area = arcArea + polygonArea; + stats.arcArea = arcArea; + stats.polygonArea = polygonArea; + stats.arcs = arcs; + stats.innerPoints = innerPoints; + stats.intersectionPoints = intersectionPoints; + } + + return arcArea + polygonArea; +} + +/** returns whether a point is contained by all of a list of circles */ +export function containedInCircles(point, circles) { + for (let i = 0; i < circles.length; ++i) { + if (distance(point, circles[i]) > circles[i].radius + SMALL) { + return false; + } + } + return true; +} + +/** Gets all intersection points between a bunch of circles */ +function getIntersectionPoints(circles) { + const ret = []; + for (let i = 0; i < circles.length; ++i) { + for (let j = i + 1; j < circles.length; ++j) { + const intersect = circleCircleIntersection(circles[i], circles[j]); + for (let k = 0; k < intersect.length; ++k) { + const p: any = intersect[k]; + p.parentIndex = [i, j]; + ret.push(p); + } + } + } + return ret; +} + +/** Circular segment area calculation. See http://mathworld.wolfram.com/CircularSegment.html */ +export function circleArea(r, width) { + return r * r * Math.acos(1 - width / r) - (r - width) * Math.sqrt(width * (2 * r - width)); +} + +/** euclidean distance between two points */ +export function distance(p1, p2) { + return Math.sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y)); +} + +/** Returns the overlap area of two circles of radius r1 and r2 - that +have their centers separated by distance d. Simpler faster +circle intersection for only two circles */ +export function circleOverlap(r1, r2, d) { + // no overlap + if (d >= r1 + r2) { + return 0; + } + + // completely overlapped + if (d <= Math.abs(r1 - r2)) { + return Math.PI * Math.min(r1, r2) * Math.min(r1, r2); + } + + const w1 = r1 - (d * d - r2 * r2 + r1 * r1) / (2 * d), + w2 = r2 - (d * d - r1 * r1 + r2 * r2) / (2 * d); + return circleArea(r1, w1) + circleArea(r2, w2); +} + +/** Given two circles (containing a x/y/radius attributes), +returns the intersecting points if possible. +note: doesn't handle cases where there are infinitely many +intersection points (circles are equivalent):, or only one intersection point*/ +export function circleCircleIntersection(p1, p2) { + const d = distance(p1, p2), + r1 = p1.radius, + r2 = p2.radius; + + // if to far away, or self contained - can't be done + if (d >= r1 + r2 || d <= Math.abs(r1 - r2)) { + return []; + } + + const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d), + h = Math.sqrt(r1 * r1 - a * a), + x0 = p1.x + (a * (p2.x - p1.x)) / d, + y0 = p1.y + (a * (p2.y - p1.y)) / d, + rx = -(p2.y - p1.y) * (h / d), + ry = -(p2.x - p1.x) * (h / d); + + return [ + { x: x0 + rx, y: y0 - ry }, + { x: x0 - rx, y: y0 + ry }, + ]; +} + +/** Returns the center of a bunch of points */ +export function getCenter(points) { + const center = { x: 0, y: 0 }; + for (let i = 0; i < points.length; ++i) { + center.x += points[i].x; + center.y += points[i].y; + } + center.x /= points.length; + center.y /= points.length; + return center; +} diff --git a/src/plots/venn/layout/diagram.ts b/src/plots/venn/layout/diagram.ts new file mode 100644 index 0000000000..ef01d8d949 --- /dev/null +++ b/src/plots/venn/layout/diagram.ts @@ -0,0 +1,219 @@ +import { nelderMead } from 'fmin'; +import { intersectionArea, distance, getCenter } from './circleintersection'; + +function circleMargin(current, interior, exterior) { + let margin = interior[0].radius - distance(interior[0], current), + i, + m; + for (i = 1; i < interior.length; ++i) { + m = interior[i].radius - distance(interior[i], current); + if (m <= margin) { + margin = m; + } + } + + for (i = 0; i < exterior.length; ++i) { + m = distance(exterior[i], current) - exterior[i].radius; + if (m <= margin) { + margin = m; + } + } + return margin; +} + +// compute the center of some circles by maximizing the margin of +// the center point relative to the circles (interior) after subtracting +// nearby circles (exterior) +export function computeTextCentre(interior, exterior) { + // get an initial estimate by sampling around the interior circles + // and taking the point with the biggest margin + const points = []; + let i; + for (i = 0; i < interior.length; ++i) { + const c = interior[i]; + points.push({ x: c.x, y: c.y }); + points.push({ x: c.x + c.radius / 2, y: c.y }); + points.push({ x: c.x - c.radius / 2, y: c.y }); + points.push({ x: c.x, y: c.y + c.radius / 2 }); + points.push({ x: c.x, y: c.y - c.radius / 2 }); + } + let initial = points[0], + margin = circleMargin(points[0], interior, exterior); + for (i = 1; i < points.length; ++i) { + const m = circleMargin(points[i], interior, exterior); + if (m >= margin) { + initial = points[i]; + margin = m; + } + } + + // maximize the margin numerically + const solution = nelderMead( + function (p) { + return -1 * circleMargin({ x: p[0], y: p[1] }, interior, exterior); + }, + [initial.x, initial.y], + { maxIterations: 500, minErrorDelta: 1e-10 } + ).x; + let ret: any = { x: solution[0], y: solution[1] }; + + // check solution, fallback as needed (happens if fully overlapped + // etc) + let valid = true; + for (i = 0; i < interior.length; ++i) { + if (distance(ret, interior[i]) > interior[i].radius) { + valid = false; + break; + } + } + + for (i = 0; i < exterior.length; ++i) { + if (distance(ret, exterior[i]) < exterior[i].radius) { + valid = false; + break; + } + } + + if (!valid) { + if (interior.length == 1) { + ret = { x: interior[0].x, y: interior[0].y }; + } else { + const areaStats: any = {}; + intersectionArea(interior, areaStats); + + if (areaStats.arcs.length === 0) { + ret = { x: 0, y: -1000, disjoint: true }; + } else if (areaStats.arcs.length == 1) { + ret = { x: areaStats.arcs[0].circle.x, y: areaStats.arcs[0].circle.y }; + } else if (exterior.length) { + // try again without other circles + ret = computeTextCentre(interior, []); + } else { + // take average of all the points in the intersection + // polygon. this should basically never happen + // and has some issues: + // https://github.com/benfred/venn.js/issues/48#issuecomment-146069777 + ret = getCenter( + areaStats.arcs.map(function (a) { + return a.p1; + }) + ); + } + } + } + + return ret; +} + +// given a dictionary of {setid : circle}, returns +// a dictionary of setid to list of circles that completely overlap it +function getOverlappingCircles(circles) { + const ret = {}, + circleids = []; + for (const circleid in circles) { + circleids.push(circleid); + ret[circleid] = []; + } + for (let i = 0; i < circleids.length; i++) { + const a = circles[circleids[i]]; + for (let j = i + 1; j < circleids.length; ++j) { + const b = circles[circleids[j]], + d = distance(a, b); + + if (d + b.radius <= a.radius + 1e-10) { + ret[circleids[j]].push(circleids[i]); + } else if (d + a.radius <= b.radius + 1e-10) { + ret[circleids[i]].push(circleids[j]); + } + } + } + return ret; +} + +export function computeTextCentres(circles, areas) { + const ret = {}, + overlapped = getOverlappingCircles(circles); + for (let i = 0; i < areas.length; ++i) { + const area = areas[i].sets, + areaids = {}, + exclude = {}; + for (let j = 0; j < area.length; ++j) { + areaids[area[j]] = true; + const overlaps = overlapped[area[j]]; + // keep track of any circles that overlap this area, + // and don't consider for purposes of computing the text + // centre + for (let k = 0; k < overlaps.length; ++k) { + exclude[overlaps[k]] = true; + } + } + + const interior = [], + exterior = []; + for (const setid in circles) { + if (setid in areaids) { + interior.push(circles[setid]); + } else if (!(setid in exclude)) { + exterior.push(circles[setid]); + } + } + const centre = computeTextCentre(interior, exterior); + ret[area] = centre; + if (centre.disjoint && areas[i].size > 0) { + console.log('WARNING: area ' + area + ' not represented on screen'); + } + } + return ret; +} + +/** + * 根据圆心(x, y) 半径 r 返回圆的绘制 path + * @param x 圆心点 x + * @param y 圆心点 y + * @param r 圆的半径 + * @returns 圆的 path + */ +export function circlePath(x, y, r) { + const ret = []; + // ret.push('\nM', x, y); + // ret.push('\nm', -r, 0); + // ret.push('\na', r, r, 0, 1, 0, r * 2, 0); + // ret.push('\na', r, r, 0, 1, 0, -r * 2, 0); + const x0 = x - r; + const y0 = y; + ret.push('M', x0, y0); + ret.push('A', r, r, 0, 1, 0, x0 + 2 * r, y0); + ret.push('A', r, r, 0, 1, 0, x0, y0); + + return ret.join(' '); +} + +// inverse of the circlePath function, returns a circle object from an svg path +export function circleFromPath(path) { + const tokens = path.split(' '); + return { x: parseFloat(tokens[1]), y: parseFloat(tokens[2]), radius: -parseFloat(tokens[4]) }; +} + +/** returns a svg path of the intersection area of a bunch of circles */ +export function intersectionAreaPath(circles) { + const stats: any = {}; + intersectionArea(circles, stats); + const arcs = stats.arcs; + + if (arcs.length === 0) { + return 'M 0 0'; + } else if (arcs.length == 1) { + const circle = arcs[0].circle; + return circlePath(circle.x, circle.y, circle.radius); + } else { + // draw path around arcs + const ret = ['\nM', arcs[0].p2.x, arcs[0].p2.y]; + for (let i = 0; i < arcs.length; ++i) { + const arc = arcs[i], + r = arc.circle.radius, + wide = arc.width > r; + ret.push('\nA', r, r, 0, wide ? 1 : 0, 1, arc.p1.x, arc.p1.y); + } + return ret.join(' '); + } +} diff --git a/src/plots/venn/layout/layout.ts b/src/plots/venn/layout/layout.ts new file mode 100644 index 0000000000..aa9324a3cf --- /dev/null +++ b/src/plots/venn/layout/layout.ts @@ -0,0 +1,734 @@ +import { nelderMead, bisect, conjugateGradient, zeros, zerosM, norm2, scale } from 'fmin'; +import { intersectionArea, circleOverlap, circleCircleIntersection, distance } from './circleintersection'; + +/** given a list of set objects, and their corresponding overlaps. +updates the (x, y, radius) attribute on each set such that their positions +roughly correspond to the desired overlaps */ +export function venn(areas, parameters?: any) { + parameters = parameters || {}; + parameters.maxIterations = parameters.maxIterations || 500; + const initialLayout = parameters.initialLayout || bestInitialLayout; + const loss = parameters.lossFunction || lossFunction; + + // add in missing pairwise areas as having 0 size + areas = addMissingAreas(areas); + + // initial layout is done greedily + const circles = initialLayout(areas, parameters); + + // transform x/y coordinates to a vector to optimize + const initial = [], + setids = []; + let setid; + for (setid in circles) { + // eslint-disable-next-line + if (circles.hasOwnProperty(setid)) { + initial.push(circles[setid].x); + initial.push(circles[setid].y); + setids.push(setid); + } + } + + // optimize initial layout from our loss function + const solution = nelderMead( + function (values) { + const current = {}; + for (let i = 0; i < setids.length; ++i) { + const setid = setids[i]; + current[setid] = { + x: values[2 * i], + y: values[2 * i + 1], + radius: circles[setid].radius, + // size : circles[setid].size + }; + } + return loss(current, areas); + }, + initial, + parameters + ); + + // transform solution vector back to x/y points + const positions = solution.x; + for (let i = 0; i < setids.length; ++i) { + setid = setids[i]; + circles[setid].x = positions[2 * i]; + circles[setid].y = positions[2 * i + 1]; + } + + return circles; +} + +const SMALL = 1e-10; + +/** Returns the distance necessary for two circles of radius r1 + r2 to +have the overlap area 'overlap' */ +export function distanceFromIntersectArea(r1, r2, overlap) { + // handle complete overlapped circles + if (Math.min(r1, r2) * Math.min(r1, r2) * Math.PI <= overlap + SMALL) { + return Math.abs(r1 - r2); + } + + return bisect( + function (distance) { + return circleOverlap(r1, r2, distance) - overlap; + }, + 0, + r1 + r2 + ); +} + +/** Missing pair-wise intersection area data can cause problems: + treating as an unknown means that sets will be laid out overlapping, + which isn't what people expect. To reflect that we want disjoint sets + here, set the overlap to 0 for all missing pairwise set intersections */ +function addMissingAreas(areas) { + areas = areas.slice(); + + // two circle intersections that aren't defined + const ids: number[] = [], + pairs: any = {}; + let i, j, a, b; + for (i = 0; i < areas.length; ++i) { + const area = areas[i]; + if (area.sets.length == 1) { + ids.push(area.sets[0]); + } else if (area.sets.length == 2) { + a = area.sets[0]; + b = area.sets[1]; + // @ts-ignore + pairs[[a, b]] = true; + // @ts-ignore + pairs[[b, a]] = true; + } + } + ids.sort((a, b) => { + return a > b ? 1 : -1; + }); + + for (i = 0; i < ids.length; ++i) { + a = ids[i]; + for (j = i + 1; j < ids.length; ++j) { + b = ids[j]; + // @ts-ignore + if (!([a, b] in pairs)) { + areas.push({ sets: [a, b], size: 0 }); + } + } + } + return areas; +} + +/// Returns two matrices, one of the euclidean distances between the sets +/// and the other indicating if there are subset or disjoint set relationships +export function getDistanceMatrices(areas, sets, setids) { + // initialize an empty distance matrix between all the points + const distances = zerosM(sets.length, sets.length), + constraints = zerosM(sets.length, sets.length); + + // compute required distances between all the sets such that + // the areas match + areas + .filter(function (x) { + return x.sets.length == 2; + }) + .map(function (current) { + const left = setids[current.sets[0]], + right = setids[current.sets[1]], + r1 = Math.sqrt(sets[left].size / Math.PI), + r2 = Math.sqrt(sets[right].size / Math.PI), + distance = distanceFromIntersectArea(r1, r2, current.size); + + distances[left][right] = distances[right][left] = distance; + + // also update constraints to indicate if its a subset or disjoint + // relationship + let c = 0; + if (current.size + 1e-10 >= Math.min(sets[left].size, sets[right].size)) { + c = 1; + } else if (current.size <= 1e-10) { + c = -1; + } + constraints[left][right] = constraints[right][left] = c; + }); + + return { distances: distances, constraints: constraints }; +} + +/// computes the gradient and loss simulatenously for our constrained MDS optimizer +function constrainedMDSGradient(x, fxprime, distances, constraints) { + let loss = 0, + i; + for (i = 0; i < fxprime.length; ++i) { + fxprime[i] = 0; + } + + for (i = 0; i < distances.length; ++i) { + const xi = x[2 * i], + yi = x[2 * i + 1]; + for (let j = i + 1; j < distances.length; ++j) { + const xj = x[2 * j], + yj = x[2 * j + 1], + dij = distances[i][j], + constraint = constraints[i][j]; + + const squaredDistance = (xj - xi) * (xj - xi) + (yj - yi) * (yj - yi), + distance = Math.sqrt(squaredDistance), + delta = squaredDistance - dij * dij; + + if ((constraint > 0 && distance <= dij) || (constraint < 0 && distance >= dij)) { + continue; + } + + loss += 2 * delta * delta; + + fxprime[2 * i] += 4 * delta * (xi - xj); + fxprime[2 * i + 1] += 4 * delta * (yi - yj); + + fxprime[2 * j] += 4 * delta * (xj - xi); + fxprime[2 * j + 1] += 4 * delta * (yj - yi); + } + } + return loss; +} + +/// takes the best working variant of either constrained MDS or greedy +export function bestInitialLayout(areas, params) { + let initial = greedyLayout(areas, params); + const loss = params.lossFunction || lossFunction; + + // greedylayout is sufficient for all 2/3 circle cases. try out + // constrained MDS for higher order problems, take its output + // if it outperforms. (greedy is aesthetically better on 2/3 circles + // since it axis aligns) + if (areas.length >= 8) { + const constrained = constrainedMDSLayout(areas, params), + constrainedLoss = loss(constrained, areas), + greedyLoss = loss(initial, areas); + + if (constrainedLoss + 1e-8 < greedyLoss) { + initial = constrained; + } + } + return initial; +} + +/// use the constrained MDS variant to generate an initial layout +export function constrainedMDSLayout(areas, params) { + params = params || {}; + const restarts = params.restarts || 10; + + // bidirectionally map sets to a rowid (so we can create a matrix) + const sets = [], + setids = {}; + let i; + for (i = 0; i < areas.length; ++i) { + const area = areas[i]; + if (area.sets.length == 1) { + setids[area.sets[0]] = sets.length; + sets.push(area); + } + } + + const matrices = getDistanceMatrices(areas, sets, setids); + let distances = matrices.distances; + const constraints = matrices.constraints; + + // keep distances bounded, things get messed up otherwise. + // TODO: proper preconditioner? + const norm = norm2(distances.map(norm2)) / distances.length; + distances = distances.map(function (row) { + return row.map(function (value) { + return value / norm; + }); + }); + + const obj = function (x, fxprime) { + return constrainedMDSGradient(x, fxprime, distances, constraints); + }; + + let best, current; + for (i = 0; i < restarts; ++i) { + const initial = zeros(distances.length * 2).map(Math.random); + + current = conjugateGradient(obj, initial, params); + if (!best || current.fx < best.fx) { + best = current; + } + } + const positions = best.x; + + // translate rows back to (x,y,radius) coordinates + const circles = {}; + for (i = 0; i < sets.length; ++i) { + const set = sets[i]; + circles[set.sets[0]] = { + x: positions[2 * i] * norm, + y: positions[2 * i + 1] * norm, + radius: Math.sqrt(set.size / Math.PI), + }; + } + + if (params.history) { + for (i = 0; i < params.history.length; ++i) { + scale(params.history[i].x, norm); + } + } + return circles; +} + +/** Lays out a Venn diagram greedily, going from most overlapped sets to +least overlapped, attempting to position each new set such that the +overlapping areas to already positioned sets are basically right */ +export function greedyLayout(areas, params) { + const loss = params && params.lossFunction ? params.lossFunction : lossFunction; + // define a circle for each set + const circles = {}, + setOverlaps = {}; + let set; + for (let i = 0; i < areas.length; ++i) { + const area = areas[i]; + if (area.sets.length == 1) { + set = area.sets[0]; + circles[set] = { + x: 1e10, + y: 1e10, + // rowid: circles.length, // fix to -> + rowid: Object.keys(circles).length, + size: area.size, + radius: Math.sqrt(area.size / Math.PI), + }; + setOverlaps[set] = []; + } + } + areas = areas.filter(function (a) { + return a.sets.length == 2; + }); + + // map each set to a list of all the other sets that overlap it + for (let i = 0; i < areas.length; ++i) { + const current = areas[i]; + // eslint-disable-next-line + let weight = current.hasOwnProperty('weight') ? current.weight : 1.0; + const left = current.sets[0], + right = current.sets[1]; + + // completely overlapped circles shouldn't be positioned early here + if (current.size + SMALL >= Math.min(circles[left].size, circles[right].size)) { + weight = 0; + } + + setOverlaps[left].push({ set: right, size: current.size, weight: weight }); + setOverlaps[right].push({ set: left, size: current.size, weight: weight }); + } + + // get list of most overlapped sets + const mostOverlapped = []; + for (set in setOverlaps) { + // eslint-disable-next-line + if (setOverlaps.hasOwnProperty(set)) { + let size = 0; + for (let i = 0; i < setOverlaps[set].length; ++i) { + size += setOverlaps[set][i].size * setOverlaps[set][i].weight; + } + + mostOverlapped.push({ set: set, size: size }); + } + } + + // sort by size desc + function sortOrder(a, b) { + return b.size - a.size; + } + mostOverlapped.sort(sortOrder); + + // keep track of what sets have been laid out + const positioned = {}; + function isPositioned(element) { + return element.set in positioned; + } + + // adds a point to the output + function positionSet(point, index) { + circles[index].x = point.x; + circles[index].y = point.y; + positioned[index] = true; + } + + // add most overlapped set at (0,0) + positionSet({ x: 0, y: 0 }, mostOverlapped[0].set); + + // get distances between all points. TODO, necessary? + // answer: probably not + // var distances = venn.getDistanceMatrices(circles, areas).distances; + for (let i = 1; i < mostOverlapped.length; ++i) { + const setIndex = mostOverlapped[i].set, + overlap = setOverlaps[setIndex].filter(isPositioned); + set = circles[setIndex]; + overlap.sort(sortOrder); + + if (overlap.length === 0) { + // this shouldn't happen anymore with addMissingAreas + throw 'ERROR: missing pairwise overlap information'; + } + + const points = []; + for (let j = 0; j < overlap.length; ++j) { + // get appropriate distance from most overlapped already added set + const p1 = circles[overlap[j].set], + d1 = distanceFromIntersectArea(set.radius, p1.radius, overlap[j].size); + + // sample positions at 90 degrees for maximum aesthetics + points.push({ x: p1.x + d1, y: p1.y }); + points.push({ x: p1.x - d1, y: p1.y }); + points.push({ y: p1.y + d1, x: p1.x }); + points.push({ y: p1.y - d1, x: p1.x }); + + // if we have at least 2 overlaps, then figure out where the + // set should be positioned analytically and try those too + for (let k = j + 1; k < overlap.length; ++k) { + const p2 = circles[overlap[k].set], + d2 = distanceFromIntersectArea(set.radius, p2.radius, overlap[k].size); + + const extraPoints = circleCircleIntersection( + { x: p1.x, y: p1.y, radius: d1 }, + { x: p2.x, y: p2.y, radius: d2 } + ); + + for (let l = 0; l < extraPoints.length; ++l) { + points.push(extraPoints[l]); + } + } + } + + // we have some candidate positions for the set, examine loss + // at each position to figure out where to put it at + let bestLoss = 1e50, + bestPoint = points[0]; + for (let j = 0; j < points.length; ++j) { + circles[setIndex].x = points[j].x; + circles[setIndex].y = points[j].y; + const localLoss = loss(circles, areas); + if (localLoss < bestLoss) { + bestLoss = localLoss; + bestPoint = points[j]; + } + } + + positionSet(bestPoint, setIndex); + } + + return circles; +} + +/** Given a bunch of sets, and the desired overlaps between these sets - computes +the distance from the actual overlaps to the desired overlaps. Note that +this method ignores overlaps of more than 2 circles */ +export function lossFunction(sets, overlaps) { + let output = 0; + + function getCircles(indices) { + return indices.map(function (i) { + return sets[i]; + }); + } + + for (let i = 0; i < overlaps.length; ++i) { + const area = overlaps[i]; + let overlap; + if (area.sets.length == 1) { + continue; + } else if (area.sets.length == 2) { + const left = sets[area.sets[0]], + right = sets[area.sets[1]]; + overlap = circleOverlap(left.radius, right.radius, distance(left, right)); + } else { + overlap = intersectionArea(getCircles(area.sets)); + } + + // eslint-disable-next-line + const weight = area.hasOwnProperty('weight') ? area.weight : 1.0; + output += weight * (overlap - area.size) * (overlap - area.size); + } + + return output; +} + +// orientates a bunch of circles to point in orientation +function orientateCircles(circles, orientation, orientationOrder) { + if (orientationOrder === null) { + circles.sort(function (a, b) { + return b.radius - a.radius; + }); + } else { + circles.sort(orientationOrder); + } + + let i; + // shift circles so largest circle is at (0, 0) + if (circles.length > 0) { + const largestX = circles[0].x, + largestY = circles[0].y; + + for (i = 0; i < circles.length; ++i) { + circles[i].x -= largestX; + circles[i].y -= largestY; + } + } + + if (circles.length == 2) { + // if the second circle is a subset of the first, arrange so that + // it is off to one side. hack for https://github.com/benfred/venn.js/issues/120 + const dist = distance(circles[0], circles[1]); + if (dist < Math.abs(circles[1].radius - circles[0].radius)) { + circles[1].x = circles[0].x + circles[0].radius - circles[1].radius - 1e-10; + circles[1].y = circles[0].y; + } + } + + // rotate circles so that second largest is at an angle of 'orientation' + // from largest + if (circles.length > 1) { + const rotation = Math.atan2(circles[1].x, circles[1].y) - orientation; + let x, y; + const c = Math.cos(rotation), + s = Math.sin(rotation); + for (i = 0; i < circles.length; ++i) { + x = circles[i].x; + y = circles[i].y; + circles[i].x = c * x - s * y; + circles[i].y = s * x + c * y; + } + } + + // mirror solution if third solution is above plane specified by + // first two circles + if (circles.length > 2) { + let angle = Math.atan2(circles[2].x, circles[2].y) - orientation; + while (angle < 0) { + angle += 2 * Math.PI; + } + while (angle > 2 * Math.PI) { + angle -= 2 * Math.PI; + } + if (angle > Math.PI) { + const slope = circles[1].y / (1e-10 + circles[1].x); + for (i = 0; i < circles.length; ++i) { + const d = (circles[i].x + slope * circles[i].y) / (1 + slope * slope); + circles[i].x = 2 * d - circles[i].x; + circles[i].y = 2 * d * slope - circles[i].y; + } + } + } +} + +export function disjointCluster(circles) { + // union-find clustering to get disjoint sets + circles.map(function (circle) { + circle.parent = circle; + }); + + // path compression step in union find + function find(circle) { + if (circle.parent !== circle) { + circle.parent = find(circle.parent); + } + return circle.parent; + } + + function union(x, y) { + const xRoot = find(x), + yRoot = find(y); + xRoot.parent = yRoot; + } + + // get the union of all overlapping sets + for (let i = 0; i < circles.length; ++i) { + for (let j = i + 1; j < circles.length; ++j) { + const maxDistance = circles[i].radius + circles[j].radius; + if (distance(circles[i], circles[j]) + 1e-10 < maxDistance) { + union(circles[j], circles[i]); + } + } + } + + // find all the disjoint clusters and group them together + const disjointClusters = {}; + let setid; + for (let i = 0; i < circles.length; ++i) { + setid = find(circles[i]).parent.setid; + if (!(setid in disjointClusters)) { + disjointClusters[setid] = []; + } + disjointClusters[setid].push(circles[i]); + } + + // cleanup bookkeeping + circles.map(function (circle) { + delete circle.parent; + }); + + // return in more usable form + const ret = []; + for (setid in disjointClusters) { + // eslint-disable-next-line + if (disjointClusters.hasOwnProperty(setid)) { + ret.push(disjointClusters[setid]); + } + } + return ret; +} + +function getBoundingBox(circles) { + const minMax = function (d) { + const hi = Math.max.apply( + null, + circles.map(function (c) { + return c[d] + c.radius; + }) + ), + lo = Math.min.apply( + null, + circles.map(function (c) { + return c[d] - c.radius; + }) + ); + return { max: hi, min: lo }; + }; + + return { xRange: minMax('x'), yRange: minMax('y') }; +} + +export function normalizeSolution(solution, orientation, orientationOrder) { + if (orientation === null) { + orientation = Math.PI / 2; + } + + // work with a list instead of a dictionary, and take a copy so we + // don't mutate input + let circles = [], + i, + setid; + for (setid in solution) { + // eslint-disable-next-line + if (solution.hasOwnProperty(setid)) { + const previous = solution[setid]; + circles.push({ x: previous.x, y: previous.y, radius: previous.radius, setid: setid }); + } + } + + // get all the disjoint clusters + const clusters = disjointCluster(circles); + + // orientate all disjoint sets, get sizes + for (i = 0; i < clusters.length; ++i) { + orientateCircles(clusters[i], orientation, orientationOrder); + const bounds = getBoundingBox(clusters[i]); + clusters[i].size = (bounds.xRange.max - bounds.xRange.min) * (bounds.yRange.max - bounds.yRange.min); + clusters[i].bounds = bounds; + } + clusters.sort(function (a, b) { + return b.size - a.size; + }); + + // orientate the largest at 0,0, and get the bounds + circles = clusters[0]; + // @ts-ignore fixme 从逻辑上看似乎是不对的,后续看看 + let returnBounds = circles.bounds; + + const spacing = (returnBounds.xRange.max - returnBounds.xRange.min) / 50; + + function addCluster(cluster, right, bottom) { + if (!cluster) return; + + const bounds = cluster.bounds; + let xOffset, yOffset, centreing; + + if (right) { + xOffset = returnBounds.xRange.max - bounds.xRange.min + spacing; + } else { + xOffset = returnBounds.xRange.max - bounds.xRange.max; + centreing = (bounds.xRange.max - bounds.xRange.min) / 2 - (returnBounds.xRange.max - returnBounds.xRange.min) / 2; + if (centreing < 0) xOffset += centreing; + } + + if (bottom) { + yOffset = returnBounds.yRange.max - bounds.yRange.min + spacing; + } else { + yOffset = returnBounds.yRange.max - bounds.yRange.max; + centreing = (bounds.yRange.max - bounds.yRange.min) / 2 - (returnBounds.yRange.max - returnBounds.yRange.min) / 2; + if (centreing < 0) yOffset += centreing; + } + + for (let j = 0; j < cluster.length; ++j) { + cluster[j].x += xOffset; + cluster[j].y += yOffset; + circles.push(cluster[j]); + } + } + + let index = 1; + while (index < clusters.length) { + addCluster(clusters[index], true, false); + addCluster(clusters[index + 1], false, true); + addCluster(clusters[index + 2], true, true); + index += 3; + + // have one cluster (in top left). lay out next three relative + // to it in a grid + returnBounds = getBoundingBox(circles); + } + + // convert back to solution form + const ret = {}; + for (i = 0; i < circles.length; ++i) { + ret[circles[i].setid] = circles[i]; + } + return ret; +} + +/** Scales a solution from venn.venn or venn.greedyLayout such that it fits in +a rectangle of width/height - with padding around the borders. also +centers the diagram in the available space at the same time */ +export function scaleSolution(solution, width, height, padding) { + const circles = [], + setids = []; + for (const setid in solution) { + // eslint-disable-next-line + if (solution.hasOwnProperty(setid)) { + setids.push(setid); + circles.push(solution[setid]); + } + } + + width -= 2 * padding; + height -= 2 * padding; + + const bounds = getBoundingBox(circles), + xRange = bounds.xRange, + yRange = bounds.yRange; + + if (xRange.max == xRange.min || yRange.max == yRange.min) { + console.log('not scaling solution: zero size detected'); + return solution; + } + + const xScaling = width / (xRange.max - xRange.min), + yScaling = height / (yRange.max - yRange.min), + scaling = Math.min(yScaling, xScaling), + // while we're at it, center the diagram too + xOffset = (width - (xRange.max - xRange.min) * scaling) / 2, + yOffset = (height - (yRange.max - yRange.min) * scaling) / 2; + + const scaled = {}; + for (let i = 0; i < circles.length; ++i) { + const circle = circles[i]; + scaled[setids[i]] = { + radius: scaling * circle.radius, + x: padding + xOffset + (circle.x - xRange.min) * scaling, + y: padding + yOffset + (circle.y - yRange.min) * scaling, + }; + } + + return scaled; +} diff --git a/src/plots/venn/shape.ts b/src/plots/venn/shape.ts new file mode 100644 index 0000000000..bdaabba850 --- /dev/null +++ b/src/plots/venn/shape.ts @@ -0,0 +1,70 @@ +import { IGroup } from '@antv/g-base'; +import { registerShape, Types, Util } from '@antv/g2'; +import { parsePathString } from '@antv/path-util'; +import { get } from '@antv/util'; +import { deepAssign } from '../../utils'; +import { Datum, Point } from '../../types'; +import { CustomInfo } from './types'; +import { PATH_FIELD } from './constant'; + +/** + * 获取填充属性 + * @param cfg 图形绘制数据 + */ +function getFillAttrs(cfg: Types.ShapeInfo) { + // style.fill 优先级更高 + return deepAssign({}, cfg.defaultStyle, { fill: cfg.color }, cfg.style); +} + +registerShape('schema', 'venn', { + draw(cfg: Types.ShapeInfo & { points: Point[]; nextPoints: Point[] }, container: IGroup) { + const data = cfg.data as Datum; + const segments = parsePathString(data[PATH_FIELD]); + const fillAttrs = getFillAttrs(cfg); + + const group = container.addGroup({ name: 'venn-shape' }); + + group.addShape('path', { + attrs: { + ...fillAttrs, + path: segments, + }, + name: 'venn-path', + }); + + const { offsetX, offsetY, label } = cfg.customInfo as CustomInfo; + + if (label !== false) { + const formatter = get(label, 'formatter'); + const offsetX = get(label, 'offsetX', 0); + const offsetY = get(label, 'offsetY', 0); + group.addShape('text', { + attrs: { + ...label, + ...get(label, 'style', { textAlign: 'center', fill: '#fff' }), + x: data.x + offsetX, + y: data.y + offsetY, + text: formatter ? formatter(data) : `${data.id}: ${data.size}`, + }, + name: 'venn-label', + }); + } + + const matrix = Util.transform(null, [['t', offsetX, offsetY]]); + group.setMatrix(matrix); + + return group; + }, + getMarker(markerCfg: Types.ShapeMarkerCfg) { + const { color } = markerCfg; + return { + symbol: 'circle', + style: { + lineWidth: 0, + stroke: color, + fill: color, + r: 4, + }, + }; + }, +}); diff --git a/src/plots/venn/types.ts b/src/plots/venn/types.ts new file mode 100644 index 0000000000..35dfba8ab4 --- /dev/null +++ b/src/plots/venn/types.ts @@ -0,0 +1,25 @@ +import { Types } from '@antv/g2'; +import { Options, StyleAttr } from '../../types'; +import { ID_FIELD, PATH_FIELD } from './constant'; + +export type VennData = (Types.Datum & { sets: string[]; [PATH_FIELD]: string; [ID_FIELD]: string })[]; + +/** 配置类型定义 */ +export interface VennOptions extends Options { + /** 韦恩图 数据 */ + readonly data: Types.Datum[]; + /** 集合字段 */ + readonly setsField: string; + /** 大小字段 */ + readonly sizeField: string; + + // 韦恩图 样式 + /** color */ + readonly color?: Options['color']; + /** 并集合的颜色混合方式, 可选项: 参考 https://gka.github.io/chroma.js/#chroma-blend, 默认: multiply */ + readonly blendMode?: string; + /** point 样式 */ + readonly pointStyle?: StyleAttr; +} + +export type CustomInfo = { offsetY: number; offsetX: number } & Pick; diff --git a/src/plots/venn/utils.ts b/src/plots/venn/utils.ts new file mode 100644 index 0000000000..977c6029e7 --- /dev/null +++ b/src/plots/venn/utils.ts @@ -0,0 +1,83 @@ +import { assign, memoize } from '@antv/util'; +import { blend } from '../../utils/color/blend'; +import { venn, scaleSolution } from './layout/layout'; +import { circlePath, intersectionAreaPath, computeTextCentres } from './layout/diagram'; +import { ID_FIELD, PATH_FIELD } from './constant'; +import { VennData, VennOptions } from './types'; + +/** + * 获取 颜色映射 + * @usage colorMap.get(id) => color + * + * @returns Map + */ +export const getColorMap = memoize( + (colorPalette: string[], data: VennData, options: VennOptions) => { + const { blendMode, setsField } = options; + const colorMap = new Map(); + const colorPaletteLen = colorPalette.length; + data.forEach((d, idx) => { + if (d[setsField].length === 1) { + colorMap.set(d[ID_FIELD], colorPalette[(idx + colorPaletteLen) % colorPaletteLen]); + } else { + /** 一般都是可以获取到颜色的,如果不正确 就是输入了非法数据 */ + const colorArr = d[setsField].map((id) => colorMap.get(id)); + colorMap.set( + d[ID_FIELD], + colorArr.slice(1).reduce((a, b) => blend(a, b, blendMode), colorArr[0]) + ); + } + }); + + return colorMap; + }, + (...params) => JSON.stringify(params) +); + +/** + * 给韦恩图数据进行布局 + * + * @param data + * @param width + * @param height + * @param padding + * @returns 韦恩图数据 + */ +export function layoutVennData(options: VennOptions, width: number, height: number, padding: number = 0): VennData { + const { data, setsField, sizeField } = options; + + const vennData: VennData = data.map((d) => ({ + ...d, + sets: d[setsField] || [], + size: d[sizeField], + [PATH_FIELD]: '', + [ID_FIELD]: '', + })); + // 1. 进行排序,避免图形元素遮挡 + vennData.sort((a, b) => a.sets.length - b.sets.length); + // todo 2. 可以在这里处理下非法数据输入,避免直接 crash + + const solution = venn(vennData); + const circles = scaleSolution(solution, width, height, padding); + const textCenters = computeTextCentres(circles, vennData); + vennData.forEach((row) => { + const sets = row.sets; + const id = sets.join(','); + row[ID_FIELD] = id; + if (sets.length === 1) { + const circle = circles[id]; + row[PATH_FIELD] = circlePath(circle.x, circle.y, circle.radius); + assign(row, circle); + } else { + const setCircles = sets.map((set) => circles[set]); + let path = intersectionAreaPath(setCircles); + if (!/[zZ]$/.test(path)) { + path += ' Z'; + } + row[PATH_FIELD] = path; + const center = textCenters[id] || { x: 0, y: 0 }; + assign(row, center); + } + }); + return vennData; +} diff --git a/src/utils/color/blend.ts b/src/utils/color/blend.ts new file mode 100644 index 0000000000..7f0b4136ad --- /dev/null +++ b/src/utils/color/blend.ts @@ -0,0 +1,103 @@ +import colorUtil from '@antv/color-util'; +/* + * interpolates between a set of colors uzing a bezier spline + * blend mode formulas taken from http://www.venture-ware.com/kevin/coding/lets-learn-math-photoshop-blend-modes/ + */ + +const each = + (f) => + (c0: number[], c1: number[]): number[] => { + const out = []; + out[0] = f(c0[0], c1[0]); + out[1] = f(c0[1], c1[1]); + out[2] = f(c0[2], c1[2]); + return out; + }; + +/** + * 混合方法集合 + */ +const blendObject = { + normal: (a: number) => a, + multiply: (a: number, b: number) => (a * b) / 255, + screen: (a: number, b: number) => 255 * (1 - (1 - a / 255) * (1 - b / 255)), + overlay: (a: number, b: number) => (b < 128 ? (2 * a * b) / 255 : 255 * (1 - 2 * (1 - a / 255) * (1 - b / 255))), + darken: (a: number, b: number) => (a > b ? b : a), + lighten: (a: number, b: number) => (a > b ? a : b), + dodge: (a: number, b: number) => { + if (a === 255) return 255; + a = (255 * (b / 255)) / (1 - a / 255); + return a > 255 ? 255 : a; + }, + burn: (a: number, b: number) => { + // 参考 w3c 的写法,考虑除数为 0 的情况 + if (b === 255) return 255; + else if (a === 0) return 0; + else return 255 * (1 - Math.min(1, (1 - b / 255) / (a / 255))); + }, +}; + +/** + * 获取混合方法 + */ +export const innerBlend = (mode: string) => { + if (!blendObject[mode]) { + throw new Error('unknown blend mode ' + mode); + } + return blendObject[mode]; +}; + +/** + * 混合颜色,并处理透明度情况 + * 参考:https://www.w3.org/TR/compositing/#blending + * @param c0 + * @param c1 + * @param mode 混合模式 + * @return rbga + */ +export function blend(c0: string, c1: string, mode = 'normal') { + // blendRgbArr: 生成不考虑透明度的 blend color: [r, g, b] + const blendRgbArr = each(innerBlend(mode))(colorToArr(c0), colorToArr(c1)); + + const [r0, g0, b0, a0] = colorToArr(c0); + const [r1, g1, b1, a1] = colorToArr(c1); + + const a = Number((a0 + a1 * (1 - a0)).toFixed(2)); + + const r = Math.round( + ((a0 * (1 - a1) * (r0 / 255) + a0 * a1 * (blendRgbArr[0] / 255) + (1 - a0) * a1 * (r1 / 255)) / a) * 255 + ); + const g = Math.round( + ((a0 * (1 - a1) * (g0 / 255) + a0 * a1 * (blendRgbArr[1] / 255) + (1 - a0) * a1 * (g1 / 255)) / a) * 255 + ); + const b = Math.round( + ((a0 * (1 - a1) * (b0 / 255) + a0 * a1 * (blendRgbArr[2] / 255) + (1 - a0) * a1 * (b1 / 255)) / a) * 255 + ); + + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +/** + * 统一颜色输入的格式 [r, g, b, a] + * 参考:https://www.w3.org/TR/compositing/#blending + * @param c color + * @return [r, g, b, a] + */ +export function colorToArr(c: string): number[] { + const color = c.replace('/s+/g', ''); // 去除所有空格 + let rgbaArr: any[]; + + // 'red' -> [r, g, b, 1] + if (typeof color === 'string' && !color.startsWith('rgba') && !color.startsWith('#')) { + return (rgbaArr = colorUtil.rgb2arr(colorUtil.toRGB(color)).concat([1])); + } + + // rgba(255, 200, 125, 0.5) -> [r, g, b, a] + if (color.startsWith('rgba')) rgbaArr = color.replace('rgba(', '').replace(')', '').split(','); + + // '#fff000' -> [r, g, b, 1] + if (color.startsWith('#')) rgbaArr = colorUtil.rgb2arr(color).concat([1]); // 如果是 16 进制(6 位数),默认透明度 1 + + // [r, g, b, a] 前三位取整 + return rgbaArr.map((item, index) => (index === 3 ? Number(item) : item | 0)); +} diff --git a/src/utils/padding.ts b/src/utils/padding.ts index 98a051cc93..6144ccfd32 100644 --- a/src/utils/padding.ts +++ b/src/utils/padding.ts @@ -48,3 +48,23 @@ export function getAdjustAppendPadding(padding: Types.ViewAppendPadding, positio currentAppendPadding[3] + PADDING[3], ]; } + +/** + * 根据图表的 padding 和 appendPadding 计算出图表的最终 padding + * @param array + */ +export function resolveAllPadding(paddings: Types.ViewPadding[]) { + // 先把数组里的 padding 全部转换成 normal + const normalPaddings = paddings.map((item) => normalPadding(item)); + let finalPadding = [0, 0, 0, 0]; + if (normalPaddings.length > 0) { + finalPadding = finalPadding.map((item, index) => { + // 有几个 padding 数组就遍历几次,累加 + normalPaddings.forEach((d, i) => { + item += normalPaddings[i][index]; + }); + return item; + }); + } + return finalPadding; +}