diff --git a/common/changes/@visactor/vlayouts/feat-venn_2025-05-16-06-12.json b/common/changes/@visactor/vlayouts/feat-venn_2025-05-16-06-12.json new file mode 100644 index 0000000..c0b2bca --- /dev/null +++ b/common/changes/@visactor/vlayouts/feat-venn_2025-05-16-06-12.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: add emptySet option to VennTransform", + "type": "none", + "packageName": "@visactor/vlayouts" + } + ], + "packageName": "@visactor/vlayouts", + "email": "shuzhuxvchuang@163.com" +} \ No newline at end of file diff --git a/packages/vlayouts/__tests__/venn/venn.test.ts b/packages/vlayouts/__tests__/venn/venn.test.ts index 355dd7b..1528a78 100644 --- a/packages/vlayouts/__tests__/venn/venn.test.ts +++ b/packages/vlayouts/__tests__/venn/venn.test.ts @@ -60,3 +60,70 @@ test('Path transform of 3 element venn', async () => { expect(circles[2].x).toBeCloseTo((result[2] as IVennCircleDatum).x, 0); expect(circles[2].y).toBeCloseTo((result[2] as IVennCircleDatum).y, 0); }); + +const data2 = [ + { sets: [], size: 0, label: 'none' }, + { sets: ['A'], size: 12, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['A', 'B'], size: 4, label: 'A,B', stroke: 'red' } +]; + +test('Data transform of 2 element venn with empty set', async () => { + const result = await transform( + { + x0: 0, + y0: 0, + x1: 500, + y1: 500 + }, + data2 + ); + + expect(result.length).toEqual(4); + + expect(result[0].type).toEqual('circle'); + expect(result[3].type).toEqual('overlap'); + + expect((result[0] as IVennCircleDatum).radius).toBeCloseTo(250, 0); + expect((result[0] as IVennCircleDatum).x).toBeCloseTo(250, 0); + expect((result[0] as IVennCircleDatum).y).toBeCloseTo(250, 0); + + const circles = getCirclesFromArcs(getArcsFromPath((result[3] as IVennOverlapDatum).path)); + const circleA = result[2] as IVennCircleDatum; + const circleB = result[1] as IVennCircleDatum; + + expect(circles.length).toEqual(2); + + expect(circles[0].radius).toBeCloseTo(circleA.radius, 0); + expect(circles[0].x).toBeCloseTo(circleA.x, 0); + expect(circles[0].y).toBeCloseTo(circleA.y, 0); + + expect(circles[1].radius).toBeCloseTo(circleB.radius, 0); + expect(circles[1].x).toBeCloseTo(circleB.x, 0); + expect(circles[1].y).toBeCloseTo(circleB.y, 0); +}); + +test('Data transform of 1 element venn with empty set and no overlaps', async () => { + const result = await transform( + { + x0: 0, + y0: 0, + x1: 500, + y1: 500 + }, + data2.slice(0, 2) + ); + + expect(result.length).toEqual(2); + + expect(result[0].type).toEqual('circle'); + expect(result[1].type).toEqual('circle'); + + expect((result[0] as IVennCircleDatum).radius).toBeCloseTo(250, 0); + expect((result[0] as IVennCircleDatum).x).toBeCloseTo(250, 0); + expect((result[0] as IVennCircleDatum).y).toBeCloseTo(250, 0); + + expect((result[1] as IVennCircleDatum).radius).toBeCloseTo((result[0] as IVennCircleDatum).radius, 0); + expect((result[1] as IVennCircleDatum).x).toBeCloseTo((result[0] as IVennCircleDatum).x, 0); + expect((result[1] as IVennCircleDatum).y).toBeCloseTo((result[0] as IVennCircleDatum).y, 0); +}); diff --git a/packages/vlayouts/src/venn/interface.ts b/packages/vlayouts/src/venn/interface.ts index 9211b75..a9cc60b 100644 --- a/packages/vlayouts/src/venn/interface.ts +++ b/packages/vlayouts/src/venn/interface.ts @@ -9,6 +9,7 @@ export interface IVennTransformOptions extends IVennParams { valueField?: string; orientation?: number; orientationOrder?: any; + emptySetKey?: string; } export interface IVennTransformMarkOptions { diff --git a/packages/vlayouts/src/venn/utils/solution/scale-solution.ts b/packages/vlayouts/src/venn/utils/solution/scale-solution.ts index c7450b2..379bec9 100644 --- a/packages/vlayouts/src/venn/utils/solution/scale-solution.ts +++ b/packages/vlayouts/src/venn/utils/solution/scale-solution.ts @@ -18,7 +18,8 @@ export function scaleSolution( width: number, height: number, x0: number, - y0: number + y0: number, + hasEmptySet: boolean = false ): Record { width = Math.max(width, 1); height = Math.max(height, 1); @@ -44,12 +45,28 @@ export function scaleSolution( const xScaling = width / (xRange.max - xRange.min); const yScaling = height / (yRange.max - yRange.min); - const scaling = Math.min(yScaling, xScaling); + let scaling: number; + + if (hasEmptySet) { + const containerRadius = Math.min(width, height) / 2; + + const centerX = (xRange.min + xRange.max) / 2; + const centerY = (yRange.min + yRange.max) / 2; + + let diagramRadius = 0; + for (const circle of circles) { + const distanceToCenter = Math.sqrt(Math.pow(circle.x - centerX, 2) + Math.pow(circle.y - centerY, 2)); + const maxDistanceForThisCircle = distanceToCenter + circle.radius; + diagramRadius = Math.max(diagramRadius, maxDistanceForThisCircle); + } + scaling = containerRadius / diagramRadius; + } else { + scaling = Math.min(yScaling, xScaling); + } // while we're at it, center the diagram too const xOffset = (width - (xRange.max - xRange.min) * scaling) / 2; const yOffset = (height - (yRange.max - yRange.min) * scaling) / 2; - const scaled: Record = {}; for (let i = 0; i < circles.length; ++i) { const circle = circles[i]; diff --git a/packages/vlayouts/src/venn/venn.ts b/packages/vlayouts/src/venn/venn.ts index 7b4d8b7..a11f3c2 100644 --- a/packages/vlayouts/src/venn/venn.ts +++ b/packages/vlayouts/src/venn/venn.ts @@ -1,5 +1,5 @@ import type { IPointLike } from '@visactor/vutils'; -import { array } from '@visactor/vutils'; +import { array, isEmpty } from '@visactor/vutils'; import type { IVennCircleDatum, IVennCommonDatum, @@ -23,14 +23,21 @@ export const transform = ( setField = 'sets', valueField = 'size', orientation = Math.PI / 2, - orientationOrder = null + orientationOrder = null, + emptySetKey } = options; - let circles: Record = {}; let textCenters: Record = {}; - if (upstreamData.length > 0) { - const vennData = upstreamData.map( + const hasEmptySet = upstreamData.some(area => { + const sets = array(area[setField]); + return !sets || sets.length === 0; + }); + + const nonEmptyData = hasEmptySet ? upstreamData.filter(area => !isEmpty(array(area[setField]))) : upstreamData; + + if (nonEmptyData.length > 0) { + const vennData = nonEmptyData.map( area => ({ sets: array(area[setField]), @@ -39,12 +46,27 @@ export const transform = ( ); let solution = venn(vennData, options); solution = normalizeSolution(solution, orientation, orientationOrder); - circles = scaleSolution(solution, x1 - x0, y1 - y0, x0, y0); + circles = scaleSolution(solution, x1 - x0, y1 - y0, x0, y0, hasEmptySet); textCenters = computeTextCenters(circles, vennData); } const data = upstreamData.map(area => { const sets = array(area[setField]); + if (!sets || sets.length === 0) { + return { + ...area, + datum: area, + sets, + key: emptySetKey || 'others', + size: area[valueField], + labelX: undefined, + labelY: undefined, + type: 'circle', + x: x0 + (x1 - x0) / 2, + y: y0 + (y1 - y0) / 2, + radius: Math.min(x1 - x0, y1 - y0) / 2 + } as IVennCircleDatum; + } const key = sets.toString(); const textCenter = textCenters[key]; const basicDatum = {