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 ``;
+ },
+ },
+ });
+ 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;
+}