From 36b9b025c414ba827959ef1bc9aa2d061c3b9820 Mon Sep 17 00:00:00 2001 From: hustcc Date: Wed, 31 May 2023 18:52:00 +0800 Subject: [PATCH] feat: add venn chart demo, tests (#5129) * docs: add venn demo, tests * chore: build success * fix: sort data for occlusion --- .../snapshots/static/vennBasic.png | Bin 0 -> 25418 bytes .../snapshots/static/vennHollow.png | Bin 0 -> 37947 bytes __tests__/plots/static/index.ts | 2 + __tests__/plots/static/venn-basic.ts | 50 ++ __tests__/plots/static/venn-hollow.ts | 54 ++ __tests__/unit/data/venn.spec.ts | 24 + __tests__/unit/stdlib/index.spec.ts | 2 + package.json | 1 + site/examples/general/venn/demo/meta.json | 24 + .../examples/general/venn/demo/venn-hollow.ts | 42 + site/examples/general/venn/demo/venn.ts | 42 + site/examples/general/venn/index.en.md | 4 + site/examples/general/venn/index.zh.md | 4 + src/api/mark/index.ts | 3 + src/api/mark/mark.ts | 12 + src/data/index.ts | 2 + src/data/utils/venn/circleintersection.ts | 235 ++++++ src/data/utils/venn/diagram.ts | 47 ++ src/data/utils/venn/index.ts | 5 + src/data/utils/venn/layout.ts | 769 ++++++++++++++++++ src/data/venn.ts | 58 ++ src/shape/path/color.ts | 16 +- src/spec/dataTransform.ts | 25 + src/stdlib/index.ts | 2 + 24 files changed, 1417 insertions(+), 6 deletions(-) create mode 100644 __tests__/integration/snapshots/static/vennBasic.png create mode 100644 __tests__/integration/snapshots/static/vennHollow.png create mode 100644 __tests__/plots/static/venn-basic.ts create mode 100644 __tests__/plots/static/venn-hollow.ts create mode 100644 __tests__/unit/data/venn.spec.ts create mode 100644 site/examples/general/venn/demo/meta.json create mode 100644 site/examples/general/venn/demo/venn-hollow.ts create mode 100644 site/examples/general/venn/demo/venn.ts create mode 100644 site/examples/general/venn/index.en.md create mode 100644 site/examples/general/venn/index.zh.md create mode 100644 src/data/utils/venn/circleintersection.ts create mode 100644 src/data/utils/venn/diagram.ts create mode 100644 src/data/utils/venn/index.ts create mode 100644 src/data/utils/venn/layout.ts create mode 100644 src/data/venn.ts diff --git a/__tests__/integration/snapshots/static/vennBasic.png b/__tests__/integration/snapshots/static/vennBasic.png new file mode 100644 index 0000000000000000000000000000000000000000..0c3673e24f4dbb70cfa0b3bb9529c52bc61ad0af GIT binary patch literal 25418 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 N4RlPkt27_K_&*!MKJown literal 0 HcmV?d00001 diff --git a/__tests__/integration/snapshots/static/vennHollow.png b/__tests__/integration/snapshots/static/vennHollow.png new file mode 100644 index 0000000000000000000000000000000000000000..1022026177363049d5cce36691f653fa6abaa18c GIT binary patch literal 37947 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

o2NK!nHAq~3sHWA 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 `${datum.key}: ${datum.size}`, + }, + }, + ], + }, + ], + }; +} + +vennBasic.maxError = 100; diff --git a/__tests__/plots/static/venn-hollow.ts b/__tests__/plots/static/venn-hollow.ts new file mode 100644 index 0000000000..3f400666d4 --- /dev/null +++ b/__tests__/plots/static/venn-hollow.ts @@ -0,0 +1,54 @@ +import { G2Spec } from '../../../src'; + +export function vennHollow(): G2Spec { + return { + type: 'view', + width: 640, + height: 480, + children: [ + { + type: 'path', + data: { + type: 'inline', + value: [ + { sets: ['A'], value: 12, label: 'A' }, + { sets: ['B'], value: 12, label: 'B' }, + { sets: ['C'], value: 12, label: 'C' }, + { sets: ['A', 'B'], value: 2, label: 'A&B' }, + { sets: ['A', 'C'], value: 2, label: 'A&C' }, + { sets: ['B', 'C'], value: 2, label: 'B&C' }, + { sets: ['A', 'B', 'C'], value: 1 }, + ], + transform: [ + { + type: 'venn', + size: 'value', + padding: 10, + }, + ], + }, + encode: { + d: 'path', + color: 'key', + shape: 'hollow', + }, + style: { + fillOpacity: 0.8, + lineWidth: 6, + }, + labels: [ + { + text: 'key', + position: 'inside', + style: { + formatter: (_, datum) => `${datum.key}: ${datum.size}`, + fill: '#000', + }, + }, + ], + }, + ], + }; +} + +vennHollow.maxError = 100; diff --git a/__tests__/unit/data/venn.spec.ts b/__tests__/unit/data/venn.spec.ts new file mode 100644 index 0000000000..0c32988a4a --- /dev/null +++ b/__tests__/unit/data/venn.spec.ts @@ -0,0 +1,24 @@ +import { Venn } from '../../../src/data/venn'; + +describe('Venn', () => { + it('Venn({...}) returns function return layout venn data', async () => { + const data = [ + { sets: ['A'], size: 102, 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 v = Venn({ + sets: 'sets', + size: 'size', + as: ['_key', '_path'], + }); + const layout = await v(data); + expect(layout.length).toBe(7); + expect(layout[0]._path).toBeInstanceOf(Function); + expect(layout[6]._key).toBe('A&B&C'); + }); +}); diff --git a/__tests__/unit/stdlib/index.spec.ts b/__tests__/unit/stdlib/index.spec.ts index ca9e85d2e2..c419bd1e49 100644 --- a/__tests__/unit/stdlib/index.spec.ts +++ b/__tests__/unit/stdlib/index.spec.ts @@ -258,6 +258,7 @@ import { WordCloud, Join, KDE, + Venn, } from '../../../src/data'; import { OverflowHide, @@ -288,6 +289,7 @@ describe('stdlib', () => { 'data.wordCloud': WordCloud, 'data.join': Join, 'data.kde': KDE, + 'data.venn': Venn, 'transform.maybeZeroY1': MaybeZeroY1, 'transform.maybeZeroX': MaybeZeroX, 'transform.maybeStackY': MaybeStackY, diff --git a/package.json b/package.json index 449acd46f5..436ee21a9f 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "d3-shape": "^3.1.0", "d3-voronoi": "^1.1.4", "flru": "^1.0.2", + "fmin": "^0.0.2", "pdfast": "^0.2.0" }, "devDependencies": { diff --git a/site/examples/general/venn/demo/meta.json b/site/examples/general/venn/demo/meta.json new file mode 100644 index 0000000000..ec317e7e7f --- /dev/null +++ b/site/examples/general/venn/demo/meta.json @@ -0,0 +1,24 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "venn.ts", + "title": { + "zh": "韦恩图", + "en": "Venn Chart" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*IZT9TJ3eVpwAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "venn-hollow.ts", + "title": { + "zh": "空心韦恩图", + "en": "Hollow Venn" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*5XyrQppBM9YAAAAAAAAAAAAADmJ7AQ/original" + } + ] +} diff --git a/site/examples/general/venn/demo/venn-hollow.ts b/site/examples/general/venn/demo/venn-hollow.ts new file mode 100644 index 0000000000..7776e4baa0 --- /dev/null +++ b/site/examples/general/venn/demo/venn-hollow.ts @@ -0,0 +1,42 @@ +import { Chart } from '@antv/g2'; + +const chart = new Chart({ + container: 'container', + theme: 'classic', + autoFit: true, +}); + +chart + .path() + .data({ + type: 'inline', + value: [ + { sets: ['A'], size: 15, label: 'A' }, + { sets: ['B'], size: 12, label: 'B' }, + { sets: ['C'], size: 10, label: 'C' }, + { sets: ['A', 'B'], size: 2, label: 'A&B' }, + { sets: ['A', 'C'], size: 2, label: 'A&C' }, + { sets: ['B', 'C'], size: 1, label: 'B&C' }, + { sets: ['A', 'B', 'C'], size: 1 }, + ], + transform: [ + { + type: 'venn', + }, + ], + }) + .encode('d', 'path') + .encode('color', 'key') + .encode('shape', 'hollow') + .label({ + position: 'inside', + text: (d) => d.label || '', + style: { + fill: '#000', + }, + }) + .style('opacity', 0.6) + .style('lineWidth', 8) + .tooltip(false); + +chart.render(); diff --git a/site/examples/general/venn/demo/venn.ts b/site/examples/general/venn/demo/venn.ts new file mode 100644 index 0000000000..30637ccfed --- /dev/null +++ b/site/examples/general/venn/demo/venn.ts @@ -0,0 +1,42 @@ +/** + * A recreation of this demo: http://benfred.github.io/venn.js/examples/intersection_tooltip.html + */ +import { Chart } from '@antv/g2'; + +const chart = new Chart({ + container: 'container', + theme: 'classic', + autoFit: true, + paddingLeft: 50, + paddingRight: 50, +}); + +chart + .path() + .data({ + type: 'fetch', + value: 'https://assets.antv.antgroup.com/g2/lastfm.json', + transform: [ + { + type: 'venn', + padding: 8, + sets: 'sets', + size: 'size', + as: ['key', 'path'], + }, + ], + }) + .encode('d', 'path') + .encode('color', 'key') + .label({ + position: 'inside', + text: (d) => d.label || '', + transform: [{ type: 'contrastReverse' }], + }) + .style('opacity', (d) => (d.sets.length > 1 ? 0.001 : 0.5)) + .state('inactive', { opacity: 0.2 }) + .state('active', { opacity: 0.8 }) + .interaction('elementHighlight', true) + .legend(false); + +chart.render(); diff --git a/site/examples/general/venn/index.en.md b/site/examples/general/venn/index.en.md new file mode 100644 index 0000000000..ed6e141eb7 --- /dev/null +++ b/site/examples/general/venn/index.en.md @@ -0,0 +1,4 @@ +--- +title: Venn +order: 21 +--- \ No newline at end of file diff --git a/site/examples/general/venn/index.zh.md b/site/examples/general/venn/index.zh.md new file mode 100644 index 0000000000..3c768eee78 --- /dev/null +++ b/site/examples/general/venn/index.zh.md @@ -0,0 +1,4 @@ +--- +title: 韦恩图 +order: 21 +--- \ No newline at end of file diff --git a/src/api/mark/index.ts b/src/api/mark/index.ts index c826cb41c8..3792a9e281 100644 --- a/src/api/mark/index.ts +++ b/src/api/mark/index.ts @@ -23,6 +23,7 @@ import { Boxplot, Density, Heatmap, + Path, Shape, Pack, ForceGraph, @@ -57,6 +58,7 @@ export interface Mark { boxplot(): Boxplot; density(): Density; heatmap(): Heatmap; + path(): Path; shape(): Shape; pack(): Pack; forceGraph(): ForceGraph; @@ -92,6 +94,7 @@ export const mark = { boxplot: Boxplot, density: Density, heatmap: Heatmap, + path: Path, shape: Shape, pack: Pack, forceGraph: ForceGraph, diff --git a/src/api/mark/mark.ts b/src/api/mark/mark.ts index 5af176a643..90558d0a2f 100644 --- a/src/api/mark/mark.ts +++ b/src/api/mark/mark.ts @@ -21,6 +21,7 @@ import { BoxPlotMark, DensityMark, HeatmapMark, + PathMark, ShapeMark, TreemapMark, ForceGraphMark, @@ -123,6 +124,10 @@ export interface Heatmap extends API, Heatmap> { type: 'heatmap'; } +export interface Path extends API, Path> { + type: 'path'; +} + export interface Shape extends API, Shape> { type: 'shape'; } @@ -347,6 +352,13 @@ export class Heatmap extends MarkNode { } } +@defineProps(props) +export class Path extends MarkNode { + constructor() { + super({}, 'path'); + } +} + @defineProps([...props, { name: 'layout', type: 'value' }]) export class Pack extends MarkNode { constructor() { diff --git a/src/data/index.ts b/src/data/index.ts index 6d7661d19d..8ff9472234 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -16,6 +16,7 @@ export { WordCloud } from './wordCloud'; export { Join } from './join'; export { Slice } from './slice'; export { KDE } from './kde'; +export { Venn } from './venn'; export type { FetchOptions } from './fetch'; export type { FoldOptions } from './fold'; @@ -35,3 +36,4 @@ export type { WordCloudOptions } from './wordCloud'; export type { JoinOptions } from './join'; export type { SliceOptions } from './slice'; export type { KDEOptions } from './kde'; +export type { VennOptions } from './venn'; diff --git a/src/data/utils/venn/circleintersection.ts b/src/data/utils/venn/circleintersection.ts new file mode 100644 index 0000000000..1c37f2c837 --- /dev/null +++ b/src/data/utils/venn/circleintersection.ts @@ -0,0 +1,235 @@ +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/data/utils/venn/diagram.ts b/src/data/utils/venn/diagram.ts new file mode 100644 index 0000000000..44e010cb35 --- /dev/null +++ b/src/data/utils/venn/diagram.ts @@ -0,0 +1,47 @@ +import { intersectionArea } from './circleintersection'; + +/** + * 根据圆心(x, y) 半径 r 返回圆的绘制 path + * @param x 圆心点 x + * @param y 圆心点 y + * @param r 圆的半径 + * @returns 圆的 path + */ +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(' '); +} + +/** 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/data/utils/venn/index.ts b/src/data/utils/venn/index.ts new file mode 100644 index 0000000000..c37b2c63e8 --- /dev/null +++ b/src/data/utils/venn/index.ts @@ -0,0 +1,5 @@ +/** + * Code from https://github.com/benfred/venn.js/blob/master/src/. + */ +export { scaleSolution, venn } from './layout'; +export { intersectionAreaPath } from './diagram'; diff --git a/src/data/utils/venn/layout.ts b/src/data/utils/venn/layout.ts new file mode 100644 index 0000000000..2807dacea7 --- /dev/null +++ b/src/data/utils/venn/layout.ts @@ -0,0 +1,769 @@ +import { + bisect, + conjugateGradient, + nelderMead, + norm2, + scale, + zeros, + zerosM, +} from 'fmin'; +import { + circleCircleIntersection, + circleOverlap, + distance, + intersectionArea, +} 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, + }; + } + 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/data/venn.ts b/src/data/venn.ts new file mode 100644 index 0000000000..018f7fc566 --- /dev/null +++ b/src/data/venn.ts @@ -0,0 +1,58 @@ +import { DataComponent as DC } from '../runtime'; +import { VennDataTransform } from '../spec'; +import { intersectionAreaPath, scaleSolution, venn } from './utils/venn'; + +export type VennOptions = Omit; + +type VennData = { + key?: string; + sets: string[]; + size: number; +}; + +/** + * Layout venn data, get the path string for each set. + */ +export const Venn: DC = (options) => { + const { + sets = 'sets', + size = 'size', + as = ['key', 'path'], + padding = 0, + } = options; + const [key, path] = as; + return (data) => { + // Transform the data, venn layout use `sets` and `size` field. + const vennData: VennData[] = data.map((d) => ({ + ...d, + sets: d[sets], + size: d[size], + [key]: d.sets.join('&'), + })); + // Sort data, avoid data occlusion. + vennData.sort((a, b) => a.sets.length - b.sets.length); + + // Layout venn data. + const solution = venn(vennData); + + let circles; + + return vennData.map((datum) => { + const setsValue = datum[sets]; + const pathFunc = ({ width, height }) => { + circles = circles + ? circles + : scaleSolution(solution, width, height, padding); + const setCircles = setsValue.map((set) => circles[set]); + let p = intersectionAreaPath(setCircles); + // Close the path for event picker. + if (!/[zZ]$/.test(p)) p += ' Z'; + return p; + }; + + return { ...datum, [path]: pathFunc }; + }); + }; +}; + +Venn.props = {}; diff --git a/src/shape/path/color.ts b/src/shape/path/color.ts index 49f6f965b8..da579b98a6 100644 --- a/src/shape/path/color.ts +++ b/src/shape/path/color.ts @@ -22,12 +22,16 @@ export const Color: SC = (options) => { defaultShape, ); const { d, color } = value; - return select(new GPath()) - .call(applyStyle, shapeTheme) - .style('d', d) - .style(colorAttribute, color) - .call(applyStyle, style) - .node(); + const [width, height] = coordinate.getSize(); + return ( + select(new GPath()) + .call(applyStyle, shapeTheme) + // Path support string, function with parameter { width, height }. + .style('d', typeof d === 'function' ? d({ width, height }) : d) + .style(colorAttribute, color) + .call(applyStyle, style) + .node() + ); }; }; diff --git a/src/spec/dataTransform.ts b/src/spec/dataTransform.ts index 63c4612e6f..41dc4980f4 100644 --- a/src/spec/dataTransform.ts +++ b/src/spec/dataTransform.ts @@ -11,6 +11,7 @@ export type DataTransform = | MapTransform | SliceTransform | KDEDataTransform + | VennDataTransform | CustomTransform; export type DataTransformTypes = @@ -24,6 +25,7 @@ export type DataTransformTypes = | 'map' | 'slice' | 'kde' + | 'venn' | 'custom' | DataComponent; @@ -154,6 +156,29 @@ export type KDEDataTransform = { width?: number; }; +export type VennDataTransform = { + type?: 'venn'; + /** + * Canvas padding for 4 direction. + * Default is `0`. + */ + padding?: number; + /** + * Set the sets field. + * Default is `sets`. + */ + sets?: string; + /** + * Set the size field for each set. + * Default is `size`. + */ + size?: string; + /** + * Set the generated fields, includes: [key, x, y, path] + */ + as?: [string, string]; +}; + export type CustomTransform = { type?: DataComponent; [key: string]: any; diff --git a/src/stdlib/index.ts b/src/stdlib/index.ts index a25dd0acaf..a87365b36c 100644 --- a/src/stdlib/index.ts +++ b/src/stdlib/index.ts @@ -244,6 +244,7 @@ import { Join, Sort, KDE, + Venn, } from '../data'; import { OverlapDodgeY, @@ -273,6 +274,7 @@ export function createLibrary(): G2Library { 'data.wordCloud': WordCloud, 'data.join': Join, 'data.kde': KDE, + 'data.venn': Venn, // 'transform.voronoi': Voronoi, 'transform.maybeZeroY1': MaybeZeroY1, 'transform.maybeZeroX': MaybeZeroX,