├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── src ├── venny.ts ├── prop-change-event.ts ├── fmin │ ├── bisect.ts │ ├── blas1.ts │ ├── line-search.ts │ ├── conjugate-gradient.ts │ └── nelder-mead.ts ├── venn-intersection.ts ├── venn-set.ts ├── interfaces.ts ├── base-element.ts ├── venn │ ├── diagram.ts │ ├── circle-intersection.ts │ └── layout.ts └── venn-diagram.ts ├── tsconfig.json ├── rollup.config.js ├── .eslintrc.json ├── package.json ├── LICENSE └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pshihn 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | lib 4 | demo 5 | dist 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | lib 4 | demo 5 | dist 6 | tsconfig.json 7 | rollup.config.json 8 | .eslintrsc.json -------------------------------------------------------------------------------- /src/venny.ts: -------------------------------------------------------------------------------- 1 | import './venn-diagram.js'; 2 | import './venn-set'; 3 | import './venn-intersection'; 4 | 5 | export * from './venn-diagram.js'; 6 | export * from './venn-set'; 7 | export * from './venn-intersection'; -------------------------------------------------------------------------------- /src/prop-change-event.ts: -------------------------------------------------------------------------------- 1 | export class PropChangeEvent extends Event { 2 | propName = ''; 3 | 4 | constructor(propName: string) { 5 | super('prop-change', { 6 | cancelable: true, 7 | bubbles: true, 8 | composed: true, 9 | }); 10 | this.propName = propName; 11 | } 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2017", 8 | "dom" 9 | ], 10 | "declaration": true, 11 | "outDir": "./bin", 12 | "baseUrl": ".", 13 | "strict": true, 14 | "strictNullChecks": true, 15 | "noImplicitAny": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": [ 22 | "src/**/*.ts" 23 | ] 24 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { terser } from 'rollup-plugin-terser'; 4 | 5 | const outFolder = 'bin'; 6 | 7 | function onwarn(warning) { 8 | if (warning.code === 'THIS_IS_UNDEFINED') 9 | return; 10 | console.error(warning.message); 11 | } 12 | 13 | export default [ 14 | { 15 | input: 'bin/venny.js', 16 | output: { 17 | file: `${outFolder}/venny.esm.js`, 18 | format: 'esm' 19 | }, 20 | onwarn, 21 | plugins: [ 22 | terser({ 23 | output: { 24 | comments: false 25 | } 26 | }) 27 | ] 28 | }, 29 | { 30 | input: 'bin/venny.js', 31 | output: { 32 | file: `${outFolder}/venny.iife.js`, 33 | format: 'iife', 34 | name: 'Venny' 35 | }, 36 | onwarn, 37 | plugins: [ 38 | terser({ 39 | output: { 40 | comments: false 41 | } 42 | }) 43 | ] 44 | } 45 | ]; -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended" 6 | ], 7 | "plugins": [ 8 | "@typescript-eslint" 9 | ], 10 | "rules": { 11 | "arrow-parens": [ 12 | "error", 13 | "always" 14 | ], 15 | "prefer-const": "error", 16 | "no-eval": "error", 17 | "no-trailing-spaces": "error", 18 | "no-var": "error", 19 | "quotes": [ 20 | "error", 21 | "single", 22 | { 23 | "allowTemplateLiterals": true 24 | } 25 | ], 26 | "semi": "error", 27 | "comma-dangle": [ 28 | "error", 29 | "always-multiline" 30 | ], 31 | "eqeqeq": "error", 32 | "no-useless-escape": "off", 33 | "@typescript-eslint/indent": [ 34 | "error", 35 | 2 36 | ], 37 | "@typescript-eslint/no-inferrable-types": "off", 38 | "@typescript-eslint/no-unused-vars": "error" 39 | } 40 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "venny", 3 | "version": "0.1.0", 4 | "description": "Declarative Venn diagrams", 5 | "main": "bin/venny.esm.js", 6 | "module": "bin/venny.esm.js", 7 | "type": "module", 8 | "types": "bin/venny.d.ts", 9 | "scripts": { 10 | "build": "rm -rf bin && tsc && rollup -c", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/pshihn/venn.git" 16 | }, 17 | "keywords": [ 18 | "venn", 19 | "venn", 20 | "diagrams" 21 | ], 22 | "author": "Preet Shihn", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/pshihn/venn/issues" 26 | }, 27 | "homepage": "https://github.com/pshihn/venn#readme", 28 | "devDependencies": { 29 | "@typescript-eslint/eslint-plugin": "^5.4.0", 30 | "@typescript-eslint/parser": "^5.4.0", 31 | "eslint": "^8.2.0", 32 | "rollup": "^2.60.0", 33 | "rollup-plugin-terser": "^7.0.2", 34 | "typescript": "^4.5.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/fmin/bisect.ts: -------------------------------------------------------------------------------- 1 | // Code adapted from https://github.com/benfred/fmin 2 | 3 | import { LayoutParameter } from '../interfaces'; 4 | 5 | /** finds the zeros of a function, given two starting points (which must 6 | * have opposite signs */ 7 | export function bisect(f: (x: number) => number, a: number, b: number, parameters?: LayoutParameter) { 8 | parameters = parameters || {}; 9 | const maxIterations = parameters.maxIterations || 100, 10 | tolerance = parameters.tolerance || 1e-10, 11 | fA = f(a), 12 | fB = f(b); 13 | 14 | let delta = b - a; 15 | 16 | if (fA * fB > 0) { 17 | throw new Error('Initial bisect points must have opposite signs'); 18 | } 19 | 20 | if (fA === 0) return a; 21 | if (fB === 0) return b; 22 | 23 | for (let i = 0; i < maxIterations; ++i) { 24 | delta /= 2; 25 | const mid = a + delta, 26 | fMid = f(mid); 27 | 28 | if (fMid * fA >= 0) { 29 | a = mid; 30 | } 31 | 32 | if ((Math.abs(delta) < tolerance) || (fMid === 0)) { 33 | return mid; 34 | } 35 | } 36 | return a + delta; 37 | } -------------------------------------------------------------------------------- /src/fmin/blas1.ts: -------------------------------------------------------------------------------- 1 | // Code adapted from https://github.com/benfred/fmin 2 | 3 | // need some basic operations on vectors, rather than adding a dependency, 4 | // just define here 5 | export function zeros(x: number): number[] { 6 | const r = new Array(x); 7 | for (let i = 0; i < x; ++i) { r[i] = 0; } 8 | return r; 9 | } 10 | 11 | export function zerosM(x: number, y: number): number[][] { 12 | return zeros(x).map(function () { return zeros(y); }); 13 | } 14 | 15 | export function dot(a: number[], b: number[]): number { 16 | let ret = 0; 17 | for (let i = 0; i < a.length; ++i) { 18 | ret += a[i] * b[i]; 19 | } 20 | return ret; 21 | } 22 | 23 | export function norm2(a: number[]) { 24 | return Math.sqrt(dot(a, a)); 25 | } 26 | 27 | export function scale(ret: number[], value: number[], c: number) { 28 | for (let i = 0; i < value.length; ++i) { 29 | ret[i] = value[i] * c; 30 | } 31 | } 32 | 33 | export function weightedSum(ret: number[], w1: number, v1: number[], w2: number, v2: number[]) { 34 | for (let j = 0; j < ret.length; ++j) { 35 | ret[j] = w1 * v1[j] + w2 * v2[j]; 36 | } 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Preet Shihn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/venn-intersection.ts: -------------------------------------------------------------------------------- 1 | import { VennElement } from './base-element'; 2 | import { AreaDetails } from './interfaces.js'; 3 | 4 | export class VennIntersection extends VennElement { 5 | private _sets: string[] = []; 6 | protected _size = 2; 7 | 8 | static get observedAttributes() { 9 | return ['sets', 'size', 'label']; 10 | } 11 | 12 | get sets(): string[] { 13 | return [...this._sets]; 14 | } 15 | 16 | set sets(value: string[] | string) { 17 | if (typeof value === 'string') { 18 | this._sets = value.trim().split(' ').filter((d) => !!d); 19 | } else { 20 | this._sets = [...value]; 21 | } 22 | this._firePropChange('sets'); 23 | } 24 | 25 | attributeChangedCallback(name: string, oldValue: string, newValue: string) { 26 | if (name === 'sets') { 27 | this.sets = newValue; 28 | } else { 29 | super.attributeChangedCallback(name, oldValue, newValue); 30 | } 31 | } 32 | 33 | computeAreas(): AreaDetails[] { 34 | const sets = this.sets; 35 | if (sets.length > 1) { 36 | return [ 37 | { 38 | sets: [...this.sets].sort(), 39 | size: this.size, 40 | label: this.label, 41 | component: this, 42 | }, 43 | ]; 44 | } 45 | return []; 46 | } 47 | } 48 | customElements.define('venn-n', VennIntersection); 49 | 50 | declare global { 51 | interface HTMLElementTagNameMap { 52 | 'venn-n': VennIntersection; 53 | } 54 | } -------------------------------------------------------------------------------- /src/venn-set.ts: -------------------------------------------------------------------------------- 1 | import { VennElement } from './base-element'; 2 | import { AreaDetails } from './interfaces.js'; 3 | 4 | export class VennSet extends VennElement { 5 | private _name = ''; 6 | protected _size = 10; 7 | 8 | static get observedAttributes() { 9 | return ['name', 'size', 'label']; 10 | } 11 | 12 | get name(): string { 13 | return this._name; 14 | } 15 | 16 | set name(value: string) { 17 | if (value !== this._name) { 18 | this._name = value; 19 | this._firePropChange('name'); 20 | } 21 | } 22 | 23 | computeAreas(): AreaDetails[] { 24 | const areas: AreaDetails[] = [ 25 | { 26 | sets: [this.name], 27 | size: this.size, 28 | label: this.label, 29 | component: this, 30 | }, 31 | ]; 32 | const children = this.children; 33 | let setChildren = 0; 34 | for (let i = 0; i < children.length; i++) { 35 | if (children[i] instanceof VennSet) { 36 | setChildren++; 37 | } 38 | } 39 | for (let i = 0; i < children.length; i++) { 40 | if (children[i] instanceof VennElement) { 41 | const childSet = (children[i] as VennElement); 42 | if (childSet.size >= this.size) { 43 | switch (setChildren) { 44 | case 0: 45 | case 1: 46 | childSet.size = this.size / 2; 47 | break; 48 | default: 49 | childSet.size = this.size / ((setChildren + 1) * 2); 50 | break; 51 | } 52 | } 53 | const childAreas = childSet.computeAreas(); 54 | if (childAreas && childAreas.length) { 55 | for (const ca of childAreas) { 56 | areas.push(ca); 57 | areas.push({ 58 | sets: [this.name, ...ca.sets], 59 | size: Math.min(this.size, ca.size), 60 | }); 61 | } 62 | } 63 | } 64 | } 65 | return areas; 66 | } 67 | } 68 | customElements.define('venn-set', VennSet); 69 | 70 | declare global { 71 | interface HTMLElementTagNameMap { 72 | 'venn-set': VennSet; 73 | } 74 | } -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface GradientLoss { 2 | x: number[], 3 | fxprime: number[] 4 | fx: number; 5 | alpha?: number; 6 | id?: number 7 | } 8 | 9 | export interface Point { 10 | x: number; 11 | y: number; 12 | disjoint?: boolean; 13 | } 14 | 15 | /** Typing for Circle object. */ 16 | export interface Circle { 17 | x: number; 18 | y: number; 19 | radius: number; 20 | setid?: string; 21 | parent?: Circle; 22 | rowid?: number 23 | size?: number; 24 | } 25 | 26 | export interface Intersection { 27 | path: string; 28 | } 29 | 30 | export interface SetIntersection extends Intersection { 31 | sets: string[]; 32 | size: number; 33 | } 34 | 35 | export interface Arc { 36 | circle: Circle; 37 | width: number; 38 | p1: Point; 39 | p2: Point; 40 | } 41 | 42 | export interface IntersectionStats { 43 | area?: number; 44 | arcArea?: number; 45 | polygonArea?: number; 46 | arcs: Arc[]; 47 | innerPoints?: Point[]; 48 | intersectionPoints?: Point[] 49 | } 50 | 51 | export interface Area { 52 | sets: string[]; 53 | size: number; 54 | weight?: number | undefined; 55 | } 56 | 57 | export interface VennBaseElement extends HTMLElement { 58 | setSvgNode(node: SVGElement): void; 59 | } 60 | 61 | export interface AreaDetails { 62 | sets: string[]; 63 | size: number; 64 | label?: string; 65 | component?: VennBaseElement; 66 | } 67 | 68 | export interface OverlapItem { 69 | set: string; 70 | size: number; 71 | weight?: number; 72 | } 73 | 74 | export type CircleMap = { 75 | [setid: string]: Circle 76 | }; 77 | 78 | export interface Range { 79 | min: number; 80 | max: number; 81 | } 82 | 83 | export interface Bounds { 84 | xRange: Range; 85 | yRange: Range; 86 | } 87 | 88 | export interface CircleCluster extends Array { 89 | size?: number; 90 | bounds?: Bounds; 91 | } 92 | 93 | export interface LayoutParameter { 94 | lossFunction?: (sets: CircleMap, overlaps: Area[]) => number; 95 | restarts?: number; 96 | maxIterations?: number; 97 | tolerance?: number; 98 | history?: GradientLoss[]; 99 | initialLayout?: (areas: Area[], params: LayoutParameter) => CircleMap; 100 | } -------------------------------------------------------------------------------- /src/fmin/line-search.ts: -------------------------------------------------------------------------------- 1 | import { dot, weightedSum } from './blas1'; 2 | import { GradientLoss } from '../interfaces'; 3 | 4 | /// searches along line 'pk' for a point that satifies the wolfe conditions 5 | /// See 'Numerical Optimization' by Nocedal and Wright p59-60 6 | /// f : objective function 7 | /// pk : search direction 8 | /// current: object containing current gradient/loss 9 | /// next: output: contains next gradient/loss 10 | /// returns a: step size taken 11 | export function wolfeLineSearch(f: (a: number[], b: number[]) => number, pk: number[], current: GradientLoss, next: GradientLoss, ain?: number, c1in?: number, c2in?: number) { 12 | const phi0 = current.fx, phiPrime0 = dot(current.fxprime, pk); 13 | 14 | let phi = phi0, phi_old = phi0, 15 | phiPrime = phiPrime0, 16 | a0 = 0; 17 | 18 | let a = ain || 1; 19 | const c1 = c1in || 1e-6; 20 | const c2 = c2in || 0.1; 21 | 22 | const zoom = (a_lo: number, a_high: number, phi_lo: number): number => { 23 | for (let iteration = 0; iteration < 16; ++iteration) { 24 | a = (a_lo + a_high) / 2; 25 | weightedSum(next.x, 1.0, current.x, a, pk); 26 | phi = next.fx = f(next.x, next.fxprime); 27 | phiPrime = dot(next.fxprime, pk); 28 | 29 | if ((phi > (phi0 + c1 * a * phiPrime0)) || 30 | (phi >= phi_lo)) { 31 | a_high = a; 32 | } else { 33 | if (Math.abs(phiPrime) <= -c2 * phiPrime0) { 34 | return a; 35 | } 36 | if (phiPrime * (a_high - a_lo) >= 0) { 37 | a_high = a_lo; 38 | } 39 | a_lo = a; 40 | phi_lo = phi; 41 | } 42 | } 43 | return 0; 44 | }; 45 | 46 | for (let iteration = 0; iteration < 10; ++iteration) { 47 | weightedSum(next.x, 1.0, current.x, a, pk); 48 | phi = next.fx = f(next.x, next.fxprime); 49 | phiPrime = dot(next.fxprime, pk); 50 | if ((phi > (phi0 + c1 * a * phiPrime0)) || 51 | (iteration && (phi >= phi_old))) { 52 | return zoom(a0, a, phi_old); 53 | } 54 | 55 | if (Math.abs(phiPrime) <= -c2 * phiPrime0) { 56 | return a; 57 | } 58 | 59 | if (phiPrime >= 0) { 60 | return zoom(a, a0, phi); 61 | } 62 | 63 | phi_old = phi; 64 | a0 = a; 65 | a *= 2; 66 | } 67 | 68 | return a; 69 | } -------------------------------------------------------------------------------- /src/fmin/conjugate-gradient.ts: -------------------------------------------------------------------------------- 1 | // Code adapted from https://github.com/benfred/fmin 2 | 3 | import { dot, norm2, scale, weightedSum } from './blas1'; 4 | import { wolfeLineSearch } from './line-search'; 5 | import { LayoutParameter, GradientLoss } from '../interfaces'; 6 | 7 | export function conjugateGradient(f: (a: number[], b: number[]) => number, initial: number[], params: LayoutParameter) { 8 | // allocate all memory up front here, keep out of the loop for perfomance 9 | // reasons 10 | let current: GradientLoss = { x: initial.slice(), fx: 0, fxprime: initial.slice() }; 11 | let next: GradientLoss = { x: initial.slice(), fx: 0, fxprime: initial.slice() }; 12 | let temp: GradientLoss | undefined = undefined; 13 | const yk = initial.slice(); 14 | 15 | let a = 1; 16 | 17 | params = params || {}; 18 | const maxIterations = params.maxIterations || initial.length * 20; 19 | 20 | current.fx = f(current.x, current.fxprime); 21 | const pk = current.fxprime.slice(); 22 | scale(pk, current.fxprime, -1); 23 | 24 | for (let i = 0; i < maxIterations; ++i) { 25 | a = wolfeLineSearch(f, pk, current, next, a); 26 | 27 | // todo: history in wrong spot? 28 | if (params.history) { 29 | params.history.push({ 30 | x: current.x.slice(), 31 | fx: current.fx, 32 | fxprime: current.fxprime.slice(), 33 | alpha: a, 34 | }); 35 | } 36 | 37 | if (!a) { 38 | // faiiled to find point that satifies wolfe conditions. 39 | // reset direction for next iteration 40 | scale(pk, current.fxprime, -1); 41 | 42 | } else { 43 | // update direction using Polak–Ribiere CG method 44 | weightedSum(yk, 1, next.fxprime, -1, current.fxprime); 45 | 46 | const delta_k = dot(current.fxprime, current.fxprime); 47 | const beta_k = Math.max(0, dot(yk, next.fxprime) / delta_k); 48 | 49 | weightedSum(pk, beta_k, pk, -1, next.fxprime); 50 | 51 | temp = current; 52 | current = next; 53 | next = temp; 54 | } 55 | 56 | if (norm2(current.fxprime) <= 1e-5) { 57 | break; 58 | } 59 | } 60 | 61 | if (params.history) { 62 | params.history.push({ 63 | x: current.x.slice(), 64 | fx: current.fx, 65 | fxprime: current.fxprime.slice(), 66 | alpha: a, 67 | }); 68 | } 69 | 70 | return current; 71 | } -------------------------------------------------------------------------------- /src/base-element.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { PropChangeEvent } from './prop-change-event'; 4 | import { AreaDetails, VennBaseElement } from './interfaces.js'; 5 | 6 | interface PendingEventListener { 7 | type: string; 8 | listener: EventListenerOrEventListenerObject; 9 | options?: boolean | AddEventListenerOptions 10 | } 11 | 12 | export abstract class VennElement extends HTMLElement implements VennBaseElement { 13 | private _connected = false; 14 | private _svgNode?: SVGElement; 15 | private _pendingEventListeners: PendingEventListener[] = []; 16 | protected _size = 0; 17 | private _label = ''; 18 | 19 | get size(): number { 20 | return this._size; 21 | } 22 | 23 | set size(value: number) { 24 | if (value !== this._size) { 25 | this._size = value; 26 | this._firePropChange('size'); 27 | } 28 | } 29 | 30 | get label(): string { 31 | return this._label; 32 | } 33 | 34 | set label(value: string) { 35 | if (value !== this._label) { 36 | this._label = value; 37 | this._firePropChange('label'); 38 | } 39 | } 40 | 41 | attributeChangedCallback(name: string, _: string, newValue: string) { 42 | (this as any)[name] = newValue; 43 | } 44 | 45 | protected _firePropChange = (prop: string) => { 46 | if (this._connected) { 47 | this.dispatchEvent(new PropChangeEvent(prop)); 48 | } 49 | }; 50 | 51 | connectedCallback(): void { 52 | this._connected = true; 53 | this.dispatchEvent(new Event('area-add', { bubbles: true, composed: true })); 54 | } 55 | 56 | disconnectedCallback(): void { 57 | this._connected = false; 58 | } 59 | 60 | private _addPendingSvgListeners() { 61 | if (this._svgNode) { 62 | for (const pl of this._pendingEventListeners) { 63 | this._svgNode.addEventListener(pl.type, pl.listener, pl.options); 64 | } 65 | this._pendingEventListeners = []; 66 | } 67 | } 68 | 69 | setSvgNode(node: SVGElement): void { 70 | this._svgNode = node; 71 | this._addPendingSvgListeners(); 72 | } 73 | 74 | addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void { 75 | this._pendingEventListeners.push({ type, listener, options }); 76 | this._addPendingSvgListeners(); 77 | } 78 | 79 | removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void { 80 | if (this._svgNode) { 81 | this._svgNode.removeEventListener(type, listener, options); 82 | } 83 | } 84 | 85 | abstract computeAreas(): AreaDetails[]; 86 | } -------------------------------------------------------------------------------- /src/fmin/nelder-mead.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | // Code adapted from https://github.com/benfred/fmin 4 | 5 | import { weightedSum } from './blas1'; 6 | 7 | export interface NelderData extends Array { 8 | fx: number; 9 | id: number 10 | } 11 | 12 | /** minimizes a function using the downhill simplex method */ 13 | export function nelderMead(f: (a: number[]) => number, x0: NelderData, parameters: any) { 14 | parameters = parameters || {}; 15 | 16 | const maxIterations = parameters.maxIterations || x0.length * 200, 17 | nonZeroDelta = parameters.nonZeroDelta || 1.05, 18 | zeroDelta = parameters.zeroDelta || 0.001, 19 | minErrorDelta = parameters.minErrorDelta || 1e-6, 20 | minTolerance = parameters.minErrorDelta || 1e-5, 21 | rho = (parameters.rho !== undefined) ? parameters.rho : 1, 22 | chi = (parameters.chi !== undefined) ? parameters.chi : 2, 23 | psi = (parameters.psi !== undefined) ? parameters.psi : -0.5, 24 | sigma = (parameters.sigma !== undefined) ? parameters.sigma : 0.5; 25 | 26 | // initialize simplex. 27 | const N = x0.length; 28 | const simplex = new Array(N + 1); 29 | 30 | simplex[0] = x0; 31 | simplex[0].fx = f(x0); 32 | simplex[0].id = 0; 33 | for (let i = 0; i < N; ++i) { 34 | const point = x0.slice(); 35 | point[i] = point[i] ? point[i] * nonZeroDelta : zeroDelta; 36 | simplex[i + 1] = point as NelderData; 37 | simplex[i + 1].fx = f(point); 38 | simplex[i + 1].id = i + 1; 39 | } 40 | 41 | function updateSimplex(value: NelderData) { 42 | for (let i = 0; i < value.length; i++) { 43 | simplex[N][i] = value[i]; 44 | } 45 | simplex[N].fx = value.fx; 46 | } 47 | 48 | const sortOrder = function (a: NelderData, b: NelderData) { return a.fx - b.fx; }; 49 | 50 | const centroid = x0.slice(), 51 | reflected = x0.slice() as NelderData, 52 | contracted = x0.slice() as NelderData, 53 | expanded = x0.slice() as NelderData; 54 | 55 | for (let iteration = 0; iteration < maxIterations; ++iteration) { 56 | simplex.sort(sortOrder); 57 | 58 | if (parameters.history) { 59 | // copy the simplex (since later iterations will mutate) and 60 | // sort it to have a consistent order between iterations 61 | const sortedSimplex = simplex.map((x) => { 62 | const state = x.slice() as NelderData; 63 | state.fx = x.fx; 64 | state.id = x.id; 65 | return state; 66 | }); 67 | sortedSimplex.sort(function (a, b) { return a.id - b.id; }); 68 | 69 | parameters.history.push({ 70 | x: simplex[0].slice(), 71 | fx: simplex[0].fx, 72 | simplex: sortedSimplex, 73 | }); 74 | } 75 | 76 | let maxDiff = 0; 77 | for (let i = 0; i < N; ++i) { 78 | maxDiff = Math.max(maxDiff, Math.abs(simplex[0][i] - simplex[1][i])); 79 | } 80 | 81 | if ((Math.abs(simplex[0].fx - simplex[N].fx) < minErrorDelta) && 82 | (maxDiff < minTolerance)) { 83 | break; 84 | } 85 | 86 | // compute the centroid of all but the worst point in the simplex 87 | for (let i = 0; i < N; ++i) { 88 | centroid[i] = 0; 89 | for (let j = 0; j < N; ++j) { 90 | centroid[i] += simplex[j][i]; 91 | } 92 | centroid[i] /= N; 93 | } 94 | 95 | // reflect the worst point past the centroid and compute loss at reflected 96 | // point 97 | const worst = simplex[N]; 98 | weightedSum(reflected, 1 + rho, centroid, -rho, worst); 99 | reflected.fx = f(reflected); 100 | 101 | // if the reflected point is the best seen, then possibly expand 102 | if (reflected.fx < simplex[0].fx) { 103 | weightedSum(expanded, 1 + chi, centroid, -chi, worst); 104 | expanded.fx = f(expanded); 105 | if (expanded.fx < reflected.fx) { 106 | updateSimplex(expanded); 107 | } else { 108 | updateSimplex(reflected); 109 | } 110 | } 111 | 112 | // if the reflected point is worse than the second worst, we need to 113 | // contract 114 | else if (reflected.fx >= simplex[N - 1].fx) { 115 | let shouldReduce = false; 116 | 117 | if (reflected.fx > worst.fx) { 118 | // do an inside contraction 119 | weightedSum(contracted, 1 + psi, centroid, -psi, worst); 120 | contracted.fx = f(contracted); 121 | if (contracted.fx < worst.fx) { 122 | updateSimplex(contracted); 123 | } else { 124 | shouldReduce = true; 125 | } 126 | } else { 127 | // do an outside contraction 128 | weightedSum(contracted, 1 - psi * rho, centroid, psi * rho, worst); 129 | contracted.fx = f(contracted); 130 | if (contracted.fx < reflected.fx) { 131 | updateSimplex(contracted); 132 | } else { 133 | shouldReduce = true; 134 | } 135 | } 136 | 137 | if (shouldReduce) { 138 | // if we don't contract here, we're done 139 | if (sigma >= 1) break; 140 | 141 | // do a reduction 142 | for (let i = 1; i < simplex.length; ++i) { 143 | weightedSum(simplex[i], 1 - sigma, simplex[0], sigma, simplex[i]); 144 | simplex[i].fx = f(simplex[i]); 145 | } 146 | } 147 | } else { 148 | updateSimplex(reflected); 149 | } 150 | } 151 | 152 | simplex.sort(sortOrder); 153 | return { 154 | fx: simplex[0].fx, 155 | x: simplex[0], 156 | }; 157 | } -------------------------------------------------------------------------------- /src/venn/diagram.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { Area, Circle, CircleMap, Point, IntersectionStats, Intersection } from '../interfaces'; 4 | import { venn, lossFunction, normalizeSolution, scaleSolution } from './layout'; 5 | import { intersectionArea, distance, getCenter } from './circle-intersection'; 6 | import { nelderMead, NelderData } from '../fmin/nelder-mead'; 7 | 8 | export interface DiagramConfig { 9 | width?: number; 10 | height?: number; 11 | padding?: number; 12 | normalize?: boolean; 13 | orientation?: number; 14 | orientationOrder?: (a: Circle, b: Circle) => number; 15 | } 16 | 17 | export interface ResolvedDiagramConfig extends DiagramConfig { 18 | width: number; 19 | height: number; 20 | padding: number; 21 | normalize: boolean; 22 | orientation: number; 23 | } 24 | 25 | const DFEAULT_CONFIG: ResolvedDiagramConfig = { 26 | width: 600, 27 | height: 350, 28 | padding: 15, 29 | normalize: true, 30 | orientation: Math.PI / 2, 31 | }; 32 | 33 | export function diagram(areas: Area[], config?: DiagramConfig) { 34 | const _config = Object.assign({}, DFEAULT_CONFIG, config || {}) as ResolvedDiagramConfig; 35 | let data = areas; 36 | 37 | // handle 0-sized sets by removing from input 38 | const toRemove = new Set(); 39 | data.forEach(function (datum) { 40 | if ((datum.size === 0) && datum.sets.length === 1) { 41 | toRemove.add(datum.sets[0]); 42 | } 43 | }); 44 | data = data.filter(function (datum) { 45 | return !datum.sets.some((set) => toRemove.has(set)); 46 | }); 47 | 48 | let circles: CircleMap = {}; 49 | let textCenters = new Map(); 50 | if (data.length) { 51 | let solution = venn(data, { lossFunction }); 52 | if (_config.normalize) { 53 | solution = normalizeSolution(solution, _config.orientation, _config.orientationOrder); 54 | circles = scaleSolution(solution, _config.width, _config.height, _config.padding); 55 | textCenters = computeTextCenters(circles, data); 56 | } 57 | } 58 | 59 | return { circles, textCenters }; 60 | } 61 | 62 | function getOverlappingCircles(circles: CircleMap) { 63 | const ret = new Map(); 64 | const circleids: string[] = []; 65 | for (const circleid in circles) { 66 | circleids.push(circleid); 67 | ret.set(circleid, []); 68 | } 69 | for (let i = 0; i < circleids.length; i++) { 70 | const a = circles[circleids[i]]; 71 | for (let j = i + 1; j < circleids.length; ++j) { 72 | const b = circles[circleids[j]]; 73 | const d = distance(a, b); 74 | 75 | if (d + b.radius <= a.radius + 1e-10) { 76 | ret.get(circleids[j])?.push(circleids[i]); 77 | } else if (d + a.radius <= b.radius + 1e-10) { 78 | ret.get(circleids[i])?.push(circleids[j]); 79 | } 80 | } 81 | } 82 | return ret; 83 | } 84 | 85 | function areaKey(ids: string[]): string { 86 | return [...ids].sort().join(','); 87 | } 88 | 89 | function computeTextCenters(circles: CircleMap, areas: Area[]) { 90 | const ret = new Map(); 91 | const overlapped = getOverlappingCircles(circles); 92 | for (let i = 0; i < areas.length; ++i) { 93 | const area = areas[i].sets; 94 | const areaids = new Set(); 95 | const exclude = new Set(); 96 | for (let j = 0; j < area.length; ++j) { 97 | areaids.add(area[j]); 98 | const overlaps = overlapped.get(area[j]) || []; 99 | // keep track of any circles that overlap this area, 100 | // and don't consider for purposes of computing the text 101 | // centre 102 | for (let k = 0; k < overlaps.length; ++k) { 103 | exclude.add(overlaps[k]); 104 | } 105 | } 106 | 107 | const interior: Circle[] = []; 108 | const exterior: Circle[] = []; 109 | for (const setid in circles) { 110 | if (areaids.has(setid)) { 111 | interior.push(circles[setid]); 112 | } else if (!(exclude.has(setid))) { 113 | exterior.push(circles[setid]); 114 | } 115 | } 116 | const centre = computeTextCenter(interior, exterior); 117 | ret.set(areaKey(area), centre); 118 | if (centre.disjoint && (areas[i].size > 0)) { 119 | // TODO: 120 | // console.log("WARNING: area " + area + " not represented on screen"); 121 | } 122 | } 123 | return ret; 124 | } 125 | 126 | function circleMargin(current: Point, interior: Circle[], exterior: Circle[]): number { 127 | let margin = interior[0].radius - distance(interior[0], current); 128 | for (let i = 1; i < interior.length; ++i) { 129 | const m = interior[i].radius - distance(interior[i], current); 130 | if (m <= margin) { 131 | margin = m; 132 | } 133 | } 134 | 135 | for (let i = 0; i < exterior.length; ++i) { 136 | const m = distance(exterior[i], current) - exterior[i].radius; 137 | if (m <= margin) { 138 | margin = m; 139 | } 140 | } 141 | return margin; 142 | } 143 | 144 | function computeTextCenter(interior: Circle[], exterior: Circle[]): Point { 145 | // get an initial estimate by sampling around the interior circles 146 | // and taking the point with the biggest margin 147 | const points: Point[] = []; 148 | for (let i = 0; i < interior.length; ++i) { 149 | const c = interior[i]; 150 | points.push({ x: c.x, y: c.y }); 151 | points.push({ x: c.x + c.radius / 2, y: c.y }); 152 | points.push({ x: c.x - c.radius / 2, y: c.y }); 153 | points.push({ x: c.x, y: c.y + c.radius / 2 }); 154 | points.push({ x: c.x, y: c.y - c.radius / 2 }); 155 | } 156 | let initial = points[0]; 157 | let margin = circleMargin(points[0], interior, exterior); 158 | for (let i = 1; i < points.length; ++i) { 159 | const m = circleMargin(points[i], interior, exterior); 160 | if (m >= margin) { 161 | initial = points[i]; 162 | margin = m; 163 | } 164 | } 165 | 166 | // maximize the margin numerically 167 | const solution = nelderMead( 168 | (p) => { return -1 * circleMargin({ x: p[0], y: p[1] }, interior, exterior); }, 169 | [initial.x, initial.y] as NelderData, 170 | { maxIterations: 500, minErrorDelta: 1e-10 } 171 | ).x; 172 | 173 | let ret: Point = { x: solution[0], y: solution[1] }; 174 | 175 | // check solution, fallback as needed (happens if fully overlapped 176 | // etc) 177 | let valid = true; 178 | for (let i = 0; i < interior.length; ++i) { 179 | if (distance(ret, interior[i]) > interior[i].radius) { 180 | valid = false; 181 | break; 182 | } 183 | } 184 | 185 | for (let i = 0; i < exterior.length; ++i) { 186 | if (distance(ret, exterior[i]) < exterior[i].radius) { 187 | valid = false; 188 | break; 189 | } 190 | } 191 | 192 | if (!valid) { 193 | if (interior.length === 1) { 194 | ret = { x: interior[0].x, y: interior[0].y }; 195 | } else { 196 | const areaStats: IntersectionStats = { arcs: [] }; 197 | intersectionArea(interior, areaStats); 198 | 199 | if (areaStats.arcs.length === 0) { 200 | ret = { 'x': 0, 'y': -1000, disjoint: true }; 201 | 202 | } else if (areaStats.arcs.length === 1) { 203 | ret = { 204 | 'x': areaStats.arcs[0].circle.x, 205 | 'y': areaStats.arcs[0].circle.y, 206 | }; 207 | 208 | } else if (exterior.length) { 209 | // try again without other circles 210 | ret = computeTextCenter(interior, []); 211 | 212 | } else { 213 | // take average of all the points in the intersection 214 | // polygon. this should basically never happen 215 | // and has some issues: 216 | // https://github.com/benfred/venn.js/issues/48#issuecomment-146069777 217 | ret = getCenter(areaStats.arcs.map(function (a: any) { return a.p1; })); 218 | } 219 | } 220 | } 221 | 222 | return ret; 223 | } 224 | 225 | export function intersectionAreaPath(circles: Circle[]): Intersection | null { 226 | const stats: IntersectionStats = { arcs: [] }; 227 | intersectionArea(circles, stats); 228 | const arcs = stats.arcs; 229 | 230 | if (arcs.length <= 1) { 231 | return null; 232 | } else { 233 | // draw path around arcs 234 | const pathSegments: (string | number)[] = [ 235 | 'M', arcs[0].p2.x, arcs[0].p2.y, 236 | ]; 237 | for (let i = 0; i < arcs.length; ++i) { 238 | const arc = arcs[i], r = arc.circle.radius, wide = arc.width > r; 239 | pathSegments.push('A', r, r, 0, wide ? 1 : 0, 1, arc.p1.x, arc.p1.y); 240 | } 241 | return { 242 | path: pathSegments.join(' '), 243 | }; 244 | } 245 | } -------------------------------------------------------------------------------- /src/venn/circle-intersection.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | // Code adapted from https://github.com/benfred/venn.js 4 | 5 | 6 | import { Circle, Arc, Point, IntersectionStats } from '../interfaces'; 7 | const SMALL = 1e-10; 8 | 9 | export interface PointWithIndex extends Point { 10 | parentIndex: [number, number]; 11 | angle: number 12 | } 13 | 14 | /** Returns the intersection area of a bunch of circles (where each circle 15 | is an object having an x,y and radius property) */ 16 | export function intersectionArea(circles: Circle[], stats?: IntersectionStats) { 17 | // get all the intersection points of the circles 18 | const intersectionPoints = getIntersectionPoints(circles); 19 | 20 | // filter out points that aren't included in all the circles 21 | const innerPoints = intersectionPoints.filter(function (p) { 22 | return containedInCircles(p, circles); 23 | }); 24 | 25 | let arcArea = 0, polygonArea = 0; 26 | const arcs: Arc[] = []; 27 | 28 | // if we have intersection points that are within all the circles, 29 | // then figure out the area contained by them 30 | if (innerPoints.length > 1) { 31 | // sort the points by angle from the center of the polygon, which lets 32 | // us just iterate over points to get the edges 33 | const center = getCenter(innerPoints); 34 | for (let i = 0; i < innerPoints.length; ++i) { 35 | const p = innerPoints[i]; 36 | p.angle = Math.atan2(p.x - center.x, p.y - center.y); 37 | } 38 | innerPoints.sort(function (a, b) { return b.angle - a.angle; }); 39 | 40 | // iterate over all points, get arc between the points 41 | // and update the areas 42 | let p2 = innerPoints[innerPoints.length - 1]; 43 | for (let i = 0; i < innerPoints.length; ++i) { 44 | const p1 = innerPoints[i]; 45 | 46 | // polygon area updates easily ... 47 | polygonArea += (p2.x + p1.x) * (p1.y - p2.y); 48 | 49 | // updating the arc area is a little more involved 50 | const midPoint = { 51 | x: (p1.x + p2.x) / 2, 52 | y: (p1.y + p2.y) / 2, 53 | }; 54 | let arc = null; 55 | 56 | for (let j = 0; j < p1.parentIndex.length; ++j) { 57 | if (p2.parentIndex.indexOf(p1.parentIndex[j]) > -1) { 58 | // figure out the angle halfway between the two points 59 | // on the current circle 60 | const circle = circles[p1.parentIndex[j]], 61 | a1 = Math.atan2(p1.x - circle.x, p1.y - circle.y), 62 | a2 = Math.atan2(p2.x - circle.x, p2.y - circle.y); 63 | 64 | let angleDiff = (a2 - a1); 65 | if (angleDiff < 0) { 66 | angleDiff += 2 * Math.PI; 67 | } 68 | 69 | // and use that angle to figure out the width of the 70 | // arc 71 | const a = a2 - angleDiff / 2; 72 | let width = distance(midPoint, { 73 | x: circle.x + circle.radius * Math.sin(a), 74 | y: circle.y + circle.radius * Math.cos(a), 75 | }); 76 | 77 | // clamp the width to the largest is can actually be 78 | // (sometimes slightly overflows because of FP errors) 79 | if (width > circle.radius * 2) { 80 | width = circle.radius * 2; 81 | } 82 | 83 | // pick the circle whose arc has the smallest width 84 | if ((arc === null) || (arc.width > width)) { 85 | arc = { 86 | circle: circle, 87 | width: width, 88 | p1: p1, 89 | p2: p2, 90 | }; 91 | } 92 | } 93 | } 94 | 95 | if (arc !== null) { 96 | arcs.push(arc); 97 | arcArea += circleArea(arc.circle.radius, arc.width); 98 | p2 = p1; 99 | } 100 | } 101 | } else { 102 | // no intersection points, is either disjoint - or is completely 103 | // overlapped. figure out which by examining the smallest circle 104 | let smallest = circles[0]; 105 | for (let i = 1; i < circles.length; ++i) { 106 | if (circles[i].radius < smallest.radius) { 107 | smallest = circles[i]; 108 | } 109 | } 110 | 111 | // make sure the smallest circle is completely contained in all 112 | // the other circles 113 | let disjoint = false; 114 | for (let i = 0; i < circles.length; ++i) { 115 | if (distance(circles[i], smallest) > Math.abs(smallest.radius - circles[i].radius)) { 116 | disjoint = true; 117 | break; 118 | } 119 | } 120 | 121 | if (disjoint) { 122 | arcArea = polygonArea = 0; 123 | 124 | } else { 125 | arcArea = smallest.radius * smallest.radius * Math.PI; 126 | arcs.push({ 127 | circle: smallest, 128 | p1: { x: smallest.x, y: smallest.y + smallest.radius }, 129 | p2: { x: smallest.x - SMALL, y: smallest.y + smallest.radius }, 130 | width: smallest.radius * 2, 131 | }); 132 | } 133 | } 134 | 135 | polygonArea /= 2; 136 | if (stats) { 137 | stats.area = arcArea + polygonArea; 138 | stats.arcArea = arcArea; 139 | stats.polygonArea = polygonArea; 140 | stats.arcs = arcs; 141 | stats.innerPoints = innerPoints; 142 | stats.intersectionPoints = intersectionPoints; 143 | } 144 | 145 | return arcArea + polygonArea; 146 | } 147 | 148 | /** returns whether a point is contained by all of a list of circles */ 149 | export function containedInCircles(point: Point, circles: Circle[]) { 150 | for (let i = 0; i < circles.length; ++i) { 151 | if (distance(point, circles[i]) > circles[i].radius + SMALL) { 152 | return false; 153 | } 154 | } 155 | return true; 156 | } 157 | 158 | /** Gets all intersection points between a bunch of circles */ 159 | function getIntersectionPoints(circles: Circle[]): PointWithIndex[] { 160 | const ret: PointWithIndex[] = []; 161 | for (let i = 0; i < circles.length; ++i) { 162 | for (let j = i + 1; j < circles.length; ++j) { 163 | const intersect = circleCircleIntersection(circles[i], 164 | circles[j]); 165 | for (let k = 0; k < intersect.length; ++k) { 166 | const p = intersect[k]; 167 | ret.push({ 168 | x: p.x, 169 | y: p.y, 170 | parentIndex: [i, j], 171 | angle: 0, 172 | }); 173 | } 174 | } 175 | } 176 | return ret; 177 | } 178 | 179 | /** Circular segment area calculation. See http://mathworld.wolfram.com/CircularSegment.html */ 180 | export function circleArea(r: number, width: number) { 181 | return r * r * Math.acos(1 - width / r) - (r - width) * Math.sqrt(width * (2 * r - width)); 182 | } 183 | 184 | /** euclidean distance between two points */ 185 | export function distance(p1: Point, p2: Point) { 186 | return Math.sqrt((p1.x - p2.x) * (p1.x - p2.x) + 187 | (p1.y - p2.y) * (p1.y - p2.y)); 188 | } 189 | 190 | 191 | /** Returns the overlap area of two circles of radius r1 and r2 - that 192 | have their centers separated by distance d. Simpler faster 193 | circle intersection for only two circles */ 194 | export function circleOverlap(r1: number, r2: number, d: number) { 195 | // no overlap 196 | if (d >= r1 + r2) { 197 | return 0; 198 | } 199 | 200 | // completely overlapped 201 | if (d <= Math.abs(r1 - r2)) { 202 | return Math.PI * Math.min(r1, r2) * Math.min(r1, r2); 203 | } 204 | 205 | const w1 = r1 - (d * d - r2 * r2 + r1 * r1) / (2 * d), 206 | w2 = r2 - (d * d - r1 * r1 + r2 * r2) / (2 * d); 207 | return circleArea(r1, w1) + circleArea(r2, w2); 208 | } 209 | 210 | /** Given two circles (containing a x/y/radius attributes), 211 | returns the intersecting points if possible. 212 | note: doesn't handle cases where there are infinitely many 213 | intersection points (circles are equivalent):, or only one intersection point*/ 214 | export function circleCircleIntersection(p1: Circle, p2: Circle): Point[] { 215 | const d = distance(p1, p2), 216 | r1 = p1.radius, 217 | r2 = p2.radius; 218 | 219 | // if to far away, or self contained - can't be done 220 | if ((d >= (r1 + r2)) || (d <= Math.abs(r1 - r2))) { 221 | return []; 222 | } 223 | 224 | const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d), 225 | h = Math.sqrt(r1 * r1 - a * a), 226 | x0 = p1.x + a * (p2.x - p1.x) / d, 227 | y0 = p1.y + a * (p2.y - p1.y) / d, 228 | rx = -(p2.y - p1.y) * (h / d), 229 | ry = -(p2.x - p1.x) * (h / d); 230 | 231 | return [ 232 | { x: x0 + rx, y: y0 - ry }, 233 | { x: x0 - rx, y: y0 + ry }, 234 | ]; 235 | } 236 | 237 | /** Returns the center of a bunch of points */ 238 | export function getCenter(points: Point[]): Point { 239 | const center = { x: 0, y: 0 }; 240 | for (let i = 0; i < points.length; ++i) { 241 | center.x += points[i].x; 242 | center.y += points[i].y; 243 | } 244 | center.x /= points.length; 245 | center.y /= points.length; 246 | return center; 247 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Venny 2 | 3 | Venny is a set of custom elements used to show Venn/Euler Diagrams on a web page. These Web Components are framework indepenedent and can easily be used with any framework or in markdown. 4 | 5 | Venny is based on [venn.js](https://github.com/benfred/venn.js/) which provides the algorithms to layout area proportional Venn and Euler diagrams. 6 | 7 | _Venny is good for area proportional diagrams with as many sets but not very good when the intersections are of more than three sets._ 8 | 9 | 10 | ## Usage 11 | 12 | In your HTML you can import the library and just use the `venn-` elements in your HTML 13 | 14 | For example: 15 | ```html 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ``` 24 | 25 | will create: 26 | 27 | ![Venn diagram example](https://user-images.githubusercontent.com/833927/142365349-bd5ca46d-7c3b-41cd-98ee-94848c4f6094.png) 28 | 29 | 30 | You can also import Venny in your JavaScript project from NPM 31 | 32 | ``` 33 | npm install venny -s 34 | ``` 35 | 36 | ### Styling & Interactivity 37 | 38 | Venny components expose CSS properties to let you control the styling of the section. See more in the [Styling section](#styling). 39 | 40 | Since these components are just DOM Nodes, you can add click and other handlers to them as you would add to any other node. 41 | 42 | ### Usage Examples 43 | 44 | More usage and examples on this website: [Venny Website](https://pshihn.github.io/venn/) 45 | 46 | ## Documentation 47 | 48 | Venny is a set of **three components**: `venn-diagram` is the container. `venn-set` represents a set or, visually a circle. `venn-n` describes intersection of sets. 49 | 50 | ### venn-diagram 51 | 52 | This is the outer-most element for any diagram. It sets the size of the diagram. The default size is `600px x 350px`. These values can be set as properties or via atttributes to the node. These properties/attributes are reactive. When set, the diagram will recalculate the sizes of the shapes. 53 | 54 | ```html 55 | 56 | 57 | 58 | ``` 59 | 60 | ```javascript 61 | const vd = document.querySelector('venn-diagram'); 62 | vd.width = 400; 63 | vd.height = 200; 64 | ``` 65 | 66 | ### venn-set 67 | 68 | This element represents a single Set. It must have a `name` property. You can also set a `label` property which gets displayed in the diagram. 69 | 70 | Circles corresponding to each set are sized based on the `size` property which has a numeric value. If no `size` is set, it is assumed that the set's size is `10`. 71 | 72 | ```html 73 | 74 | 75 | 76 | 77 | ``` 78 | 79 | ![Venn Diagram with differrnt sized sets](https://user-images.githubusercontent.com/833927/142366532-ed00c3c0-16f9-4f18-a10e-1c7b5bafe818.png) 80 | 81 | 82 | ### venn-n 83 | 84 | This element represents the intersection of two or more sets. The intersecting sets are specified in the `sets` property, which is a list of *space separated* set namnes. 85 | Like a set, the intersection can alsoe have a `label` and a `size`. The `size` property indicates how much the sets are intersecting. e.g. two sets each of size 10, can have 5 elements in the intersection or just 1. 86 | 87 | ```html 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | ``` 100 | 101 | ![Screen Shot 2021-11-17 at 10 58 04 PM](https://user-images.githubusercontent.com/833927/142367366-494f134c-6a59-4e3f-a117-76577b375562.png) 102 | ![Screen Shot 2021-11-17 at 10 57 52 PM](https://user-images.githubusercontent.com/833927/142367386-fc28c2de-6ac0-4dcc-b91f-13f899ca81a2.png) 103 | 104 | 105 | Normally when more than two sets are intersecting, you should declare all possible intersections but it is not necessary. e.g. If three sets are intersecting, you can just provide one intersection for sets `A, B, C`. Venny will automatically assume values for intersections of `A, B` `B, C` and `A, C`. 106 | 107 | ```html 108 | 109 | 110 | 111 | 112 | 113 | 114 | ``` 115 | ![Screen Shot 2021-11-17 at 11 01 17 PM](https://user-images.githubusercontent.com/833927/142367951-bed31784-289f-42ad-9a8e-ff9e05c2d017.png) 116 | 117 | Or you can be specific 118 | 119 | ```html 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | ``` 129 | ![Screen Shot 2021-11-17 at 11 02 07 PM](https://user-images.githubusercontent.com/833927/142368046-b808e127-c4b1-436a-b9e1-fa0ddad015d8.png) 130 | 131 | ### Nested Sets 132 | 133 | When you need to show that a Set is a subset of another one, you can create an intersection expressing that, or you can define the Subset as a child of the parent Set. Venny will automatically generate the intersection of the two. 134 | 135 | ```html 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | ``` 146 | ![Screen Shot 2021-11-17 at 11 11 15 PM](https://user-images.githubusercontent.com/833927/142369233-21eb4005-fcec-4a4a-a9e1-605c8a6f565e.png) 147 | 148 | 149 | ## Styling 150 | 151 | Venny exposes custom CSS properties to style the diagrams. Color, opacity of the set fill, stroke colors can be specified for the normal and the `hover` states. 152 | 153 | ### Styling Circles 154 | 155 | **Fill Color:** A dynamic color is assigned to each circle. But this can be overriden by setting the css property `--venn-circle-[name]-fill` where name is the name of the set in lower-case. e.g. `--venn-circle-apples-fill: red;` 156 | 157 | A corersponding property `--venn-hover-[name]-fill` can be set to change the color of the set when the user hovers over the set. 158 | 159 | **Fill Opacity:** By default all circles are filled with an opacity of `0.25`. Having a translucent fill easily shows the intersections between the sets. However the default value of this can be set by setting the `--venn-circle-fill-opacity` property. To change the fill opacity only of a specific set you can set the `--venn-circle-[name]-fill-opacity` property by substituting `[name]` with the name of the set in lower-case. 160 | 161 | Corresponding hover properties are `--venn-hover-circle-fill-opacity` and `--venn-hover-circle-[name]-fill-opacity` to change the opacity on hover. 162 | 163 | **Stroke:** Circles are not drawn without any stroke (outline). But circle stroke color, size can be set using following properties: 164 | 165 | `--venn-circle-stroke` to set the color of the stroke of all circles. `--venn-circle-[name]-stroke` to set the stroke color of a specific set. 166 | 167 | `--venn-circle-stroke-width` to set the width of the stroke of all circles. `--venn-circle-[name]-stroke-width` to set the stroke width of a specific set. 168 | 169 | Replace `--venn-` with `--venn-hover-` in these styles to set these when hovered. 170 | 171 | ### Styling Intersections 172 | 173 | Intersection strokes can be set using `--venn-intersection-stroke` and `--venn-intersection-stroke-width` prroperties. 174 | 175 | To set stroke on a specific intersection specify the intersecting set names in lower case, separated by a `-` 176 | e.g. `--venn-intersection-a-b-stroke` sets the stroke color only of the intersection of Sets A and B. 177 | 178 | Replace `--venn-` with `--venn-hover-` in these styles to set these when hovered. 179 | 180 | ### Styling Labels 181 | 182 | By default labels use the same color as their corresponding sets but with full opacity. Intersection labels are black by default. 183 | 184 | `--venn-label-color` can be set to set the color of all labels. 185 | 186 | `--venn-label-[name]-color` to set the label color of a specific set or intersection. e.g `--venn-label-a-b-color` sets the label color of the intersection of sets A, B 187 | 188 | Following properties cannot be set on a set specifc levl at the moment: 189 | 190 | `--venn-label-size` sets the font size of the label. 191 | 192 | `--venn-label-font-family` sets which Font you'd like to use for the labels. 193 | 194 | `--venn-label-font-weight` sets the font weight which defaults to normal / 400. 195 | 196 | ## Sponsor 197 | 198 | If you like my whimsical open source projects like this one, you can show some love by sponsoring me https://github.com/sponsors/pshihn 199 | Or just Tweet at me to show your love, that's equally appreciated :) 200 | 201 | -------------------------------------------------------------------------------- /src/venn-diagram.ts: -------------------------------------------------------------------------------- 1 | import { Circle, AreaDetails, SetIntersection, Point } from './interfaces.js'; 2 | import { DiagramConfig, diagram, intersectionAreaPath } from './venn/diagram.js'; 3 | import { VennElement } from './base-element'; 4 | 5 | interface CircleElement { 6 | id: string; 7 | circle: Circle; 8 | circleNode: SVGCircleElement; 9 | groupNode: SVGGElement; 10 | styleNode: SVGStyleElement; 11 | labelNode?: HTMLLabelElement; 12 | } 13 | 14 | interface IntersectionElement extends SetIntersection { 15 | id: string; 16 | pathNode: SVGPathElement; 17 | groupNode: SVGGElement; 18 | styleNode: SVGStyleElement; 19 | labelNode?: HTMLLabelElement; 20 | } 21 | 22 | const NS = 'http://www.w3.org/2000/svg'; 23 | const COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']; 24 | const DEFAULT_WIDTH = 600; 25 | const DEFAULT_HEIGHT = 350; 26 | 27 | export class VennDiagram extends HTMLElement { 28 | private _connected = false; 29 | private _root: ShadowRoot; 30 | private __svg?: SVGSVGElement; 31 | private __labels?: HTMLDivElement; 32 | 33 | private _config: DiagramConfig = { 34 | height: DEFAULT_HEIGHT, 35 | width: DEFAULT_WIDTH, 36 | }; 37 | 38 | private _areas: AreaDetails[] = []; 39 | private _areaMap = new Map(); 40 | 41 | private _circleList = new Set(); 42 | private _circleMap = new Map(); 43 | 44 | private _nList = new Set(); 45 | private _nMap = new Map(); 46 | 47 | private _renderRequestPending = false; 48 | 49 | static get observedAttributes() { 50 | return ['width', 'height']; 51 | } 52 | 53 | constructor() { 54 | super(); 55 | this._root = this.attachShadow({ mode: 'open' }); 56 | this._root.innerHTML = ` 57 | 86 | 87 |
88 | `; 89 | } 90 | 91 | attributeChangedCallback(name: string, _: string, newValue: string) { 92 | switch (name) { 93 | case 'width': 94 | this.width = +newValue; 95 | break; 96 | case 'height': 97 | this.height = +newValue; 98 | break; 99 | } 100 | } 101 | 102 | get width(): number { 103 | return this._config.width || DEFAULT_WIDTH; 104 | } 105 | 106 | set width(value: number) { 107 | if (this._config.width !== value) { 108 | this._config.width = value; 109 | this._requestRender(); 110 | } 111 | } 112 | 113 | get height(): number { 114 | return this._config.height || DEFAULT_HEIGHT; 115 | } 116 | 117 | set height(value: number) { 118 | if (this._config.height !== value) { 119 | this._config.height = value; 120 | this._requestRender(); 121 | } 122 | } 123 | 124 | 125 | 126 | connectedCallback() { 127 | this._connected = true; 128 | this.addEventListener('area-add', this._areaChangeHandler); 129 | this.addEventListener('prop-change', this._areaChangeHandler); 130 | this._requestRender(); 131 | } 132 | 133 | disconnectedCallback() { 134 | this._connected = false; 135 | } 136 | 137 | private _areaKey(d: AreaDetails | SetIntersection) { 138 | return [...d.sets].sort().join(','); 139 | } 140 | 141 | private _areaChangeHandler = (event: Event) => { 142 | event.stopPropagation(); 143 | this._requestRender(); 144 | }; 145 | 146 | private _requestRender() { 147 | if (this._connected && (!this._renderRequestPending)) { 148 | this._renderRequestPending = true; 149 | setTimeout(() => { 150 | try { 151 | if (this._connected) { 152 | const areas: AreaDetails[] = []; 153 | const children = this.children; 154 | for (let i = 0; i < children.length; i++) { 155 | if (children[i] instanceof VennElement) { 156 | const childAreas = (children[i] as VennElement).computeAreas(); 157 | if (childAreas && childAreas.length) { 158 | areas.push(...childAreas); 159 | } 160 | } 161 | } 162 | this._renderData(areas); 163 | } 164 | } finally { 165 | this._renderRequestPending = false; 166 | } 167 | }, 0); 168 | } 169 | } 170 | 171 | private _findSubsets(arr: string[], data: string[], start: number, end: number, index: number, r: number, out: string[][]) { 172 | if (index === r) { 173 | const temp: string[] = []; 174 | for (let j = 0; j < r; j++) { 175 | temp.push(data[j]); 176 | } 177 | out.push(temp); 178 | } 179 | for (let i = start; i <= end && end - i + 1 >= r - index; i++) { 180 | data[index] = arr[i]; 181 | this._findSubsets(arr, data, i + 1, end, index + 1, r, out); 182 | } 183 | } 184 | 185 | private _renderData(value: AreaDetails[]) { 186 | if (!this._connected) { 187 | return; 188 | } 189 | value = value || []; 190 | 191 | this._areas = value; 192 | this._areaMap.clear(); 193 | value.forEach((d) => { 194 | this._areaMap.set(this._areaKey(d), d); 195 | if (!d.size) { 196 | if (d.sets.length === 1) { 197 | d.size = 10; 198 | } else if (d.sets.length > 1) { 199 | d.size = 2; 200 | } 201 | } 202 | }); 203 | 204 | // Add missing intersections 205 | for (const area of this._areas) { 206 | const length = area.sets.length; 207 | if (length > 2) { 208 | const out: string[][] = []; 209 | for (let r = 2; r < length; r++) { 210 | const data = new Array(r); 211 | this._findSubsets(area.sets, data, 0, length - 1, 0, r, out); 212 | } 213 | for (const subset of out) { 214 | const key = subset.join(','); 215 | if (!this._areaMap.has(key)) { 216 | const missingArea: AreaDetails = { 217 | sets: [...subset], 218 | size: area.size, 219 | }; 220 | this._areas.push(missingArea); 221 | this._areaMap.set(key, missingArea); 222 | } 223 | } 224 | } 225 | } 226 | 227 | this._render(); 228 | } 229 | 230 | 231 | private get _svg(): SVGSVGElement { 232 | if (!this.__svg) { 233 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 234 | this.__svg = this._root.querySelector('svg')!; 235 | } 236 | return this.__svg; 237 | } 238 | 239 | private get _labels(): HTMLDivElement { 240 | if (!this.__labels) { 241 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 242 | this.__labels = this._root.querySelector('#labels')!; 243 | } 244 | return this.__labels; 245 | } 246 | 247 | private _renderLabel(area: AreaDetails, textCenters: Map, se: CircleElement | IntersectionElement, color: string, maxWidth?: number) { 248 | const labels = this._labels; 249 | let labelNode = se.labelNode; 250 | if (area.label) { 251 | if (!labelNode) { 252 | labelNode = document.createElement('label'); 253 | se.labelNode = labelNode; 254 | labels.appendChild(labelNode); 255 | } 256 | labelNode.style.color = `var(--venn-label-${this._areaKey(area).toLowerCase().replace(/,/g, '-')}-color, var(--venn-label-color, ${color}))`; 257 | labelNode.textContent = area.label; 258 | const centerPoint = textCenters.get(this._areaKey(area)) || { x: 0, y: 0 }; 259 | labelNode.style.transform = `translate3d(-50%, -50%, 0) translate3d(${centerPoint.x}px, ${centerPoint.y}px, 0px)`; 260 | labelNode.style.maxWidth = maxWidth ? `${maxWidth}px` : '12em'; 261 | } else { 262 | if (labelNode) { 263 | labels.removeChild(labelNode); 264 | se.labelNode = undefined; 265 | } 266 | } 267 | } 268 | 269 | private _render() { 270 | const svg = this._svg; 271 | svg.setAttribute('width', `${this._config.width || DEFAULT_WIDTH}`); 272 | svg.setAttribute('height', `${this._config.height || DEFAULT_HEIGHT}`); 273 | 274 | const { circles, textCenters } = diagram(this._areas, this._config); 275 | const usedCircles = new Set(); 276 | const usedIntersections = new Set(); 277 | 278 | let colorIndex = 0; 279 | const nextColor = () => { 280 | const color = COLORS[colorIndex++]; 281 | if (colorIndex >= COLORS.length) { 282 | colorIndex = 0; 283 | } 284 | return color; 285 | }; 286 | 287 | // ********************** 288 | // RENDER CIRCLES 289 | // ********************** 290 | 291 | for (const id in circles) { 292 | const circle = circles[id]; 293 | // check if an element already exists for this id 294 | // if not, render a node 295 | let se = this._circleMap.get(id); 296 | const circleId = `circle-${id.toLowerCase().replace(/\s/g, '')}`; 297 | if (!se) { 298 | const g = svg.ownerDocument.createElementNS(NS, 'g'); 299 | const c = svg.ownerDocument.createElementNS(NS, 'circle'); 300 | const svgStyle = svg.ownerDocument.createElementNS(NS, 'style'); 301 | g.appendChild(svgStyle); 302 | g.appendChild(c); 303 | svg.appendChild(g); 304 | se = { 305 | id, 306 | circle, 307 | circleNode: c, 308 | groupNode: g, 309 | styleNode: svgStyle, 310 | }; 311 | this._circleMap.set(id, se); 312 | } else { 313 | this._circleList.delete(se); 314 | } 315 | usedCircles.add(se); 316 | 317 | // Update nodes with data 318 | const { groupNode, circleNode, styleNode } = se; 319 | const color = nextColor(); 320 | circleNode.setAttribute('id', circleId); 321 | groupNode.setAttribute('transform', `translate(${circle.x} ${circle.y})`); 322 | circleNode.setAttribute('r', `${circle.radius}`); 323 | styleNode.textContent = ` 324 | #${circleId} { 325 | fill: var(--venn-${circleId}-fill, ${color}); 326 | fill-opacity: var(--venn-${circleId}-fill-opacity, var(--venn-circle-fill-opacity, 0.25)); 327 | stroke: var(--venn-${circleId}-stroke, var(--venn-circle-stroke)); 328 | stroke-width: var(--venn-${circleId}-stroke-width, var(--venn-circle-stroke-width)); 329 | } 330 | #${circleId}:hover { 331 | fill: var(--venn-hover-${circleId}-fill, var(--venn-${circleId}-fill, ${color})); 332 | fill-opacity: var(--venn-hover-${circleId}-fill-opacity, var(--venn-hover-circle-fill-opacity, var(--venn-${circleId}-fill-opacity, var(--venn-circle-fill-opacity, 0.25)))); 333 | stroke: var(--venn-hover-${circleId}-stroke, var(--venn-hover-circle-stroke, var(--venn-${circleId}-stroke, var(--venn-circle-stroke)))); 334 | stroke-width: var(--venn-hover-${circleId}-stroke-width, var(--venn-hover-circle-stroke-width, var(--venn-${circleId}-stroke-width, var(--venn-circle-stroke-width)))); 335 | } 336 | `; 337 | const area = this._areaMap.get(id); 338 | if (area) { 339 | if (area.component) { 340 | area.component.setSvgNode(se.groupNode); 341 | } 342 | const maxWidth = Math.max(100, se.circle.radius * 2 * 0.6); 343 | this._renderLabel(area, textCenters, se, color, maxWidth); 344 | } 345 | } 346 | // Cleanup the list - remove unused shapes 347 | for (const se of this._circleList) { 348 | this._circleMap.delete(se.id); 349 | const gp = se.groupNode.parentElement; 350 | if (gp) { 351 | gp.removeChild(se.groupNode); 352 | } 353 | } 354 | this._circleList = usedCircles; 355 | 356 | // ********************** 357 | // RENDER INTERSECTIONS 358 | // ********************** 359 | 360 | const setIntersections: SetIntersection[] = []; 361 | for (const area of this._areas) { 362 | if (area.sets.length > 1) { 363 | const setCircles = (area.sets.map((d) => this._circleMap.get(d)?.circle).filter((d) => !!d)) as Circle[]; 364 | const intersection = intersectionAreaPath(setCircles); 365 | if (intersection) { 366 | setIntersections.push({ 367 | sets: [...area.sets], 368 | path: intersection.path, 369 | size: area.size, 370 | }); 371 | } 372 | } 373 | } 374 | setIntersections.sort((a, b) => { 375 | if (a.sets.length === b.sets.length) { 376 | return b.size - a.size; 377 | } 378 | return a.sets.length - b.sets.length; 379 | }); 380 | for (const intersection of setIntersections) { 381 | const key = this._areaKey(intersection); 382 | const pathId = `intersection-${key.toLowerCase().replace(/,/g, '-')}`; 383 | // check if shape element already exists 384 | let intersectionElement = this._nMap.get(key); 385 | if (intersectionElement) { 386 | this._nList.delete(intersectionElement); 387 | } else { 388 | const g = svg.ownerDocument.createElementNS(NS, 'g'); 389 | const path = svg.ownerDocument.createElementNS(NS, 'path'); 390 | const svgStyle = svg.ownerDocument.createElementNS(NS, 'style'); 391 | g.appendChild(svgStyle); 392 | g.appendChild(path); 393 | svg.appendChild(g); 394 | intersectionElement = { 395 | id: key, 396 | sets: [...intersection.sets], 397 | path: intersection.path, 398 | size: intersection.size, 399 | groupNode: g, 400 | pathNode: path, 401 | styleNode: svgStyle, 402 | }; 403 | this._nMap.set(key, intersectionElement); 404 | } 405 | 406 | usedIntersections.add(intersectionElement); 407 | const { groupNode, styleNode, pathNode } = intersectionElement; 408 | groupNode.style.fillOpacity = '0'; 409 | pathNode.setAttribute('id', pathId); 410 | pathNode.setAttribute('d', `${intersectionElement.path}`); 411 | styleNode.textContent = ` 412 | #${pathId} { 413 | stroke: var(--venn-${pathId}-stroke, var(--venn-intersection-stroke)); 414 | stroke-width: var(--venn-${pathId}-stroke-width, var(--venn-intersection-stroke-width)); 415 | } 416 | #${pathId}:hover { 417 | stroke: var(--venn-hover-${pathId}-stroke, var(--venn-hover-intersection-stroke, var(--venn-${pathId}-stroke, var(--venn-intersection-stroke)))); 418 | stroke-width: var(--venn-hover-${pathId}-stroke-width, var(--venn-hover-intersection-stroke-width, var(--venn-${pathId}-stroke-width, var(--venn-intersection-stroke-width)))); 419 | } 420 | `; 421 | 422 | const area = this._areaMap.get(key); 423 | if (area) { 424 | if (area.component) { 425 | area.component.setSvgNode(intersectionElement.groupNode); 426 | } 427 | this._renderLabel(area, textCenters, intersectionElement, ''); 428 | } 429 | } 430 | // Cleanup the list - remove unused shapes 431 | for (const intersectionElement of this._nList) { 432 | this._nMap.delete(intersectionElement.id); 433 | const gp = intersectionElement.groupNode.parentElement; 434 | if (gp) { 435 | gp.removeChild(intersectionElement.groupNode); 436 | } 437 | } 438 | this._nList = usedIntersections; 439 | } 440 | } 441 | customElements.define('venn-diagram', VennDiagram); 442 | 443 | declare global { 444 | interface HTMLElementTagNameMap { 445 | 'venn-diagram': VennDiagram; 446 | } 447 | } -------------------------------------------------------------------------------- /src/venn/layout.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | // Code adapted from https://github.com/benfred/venn.js 5 | 6 | import { zeros, zerosM, norm2, scale } from '../fmin/blas1'; 7 | import { bisect } from '../fmin/bisect'; 8 | import { conjugateGradient } from '../fmin/conjugate-gradient'; 9 | import { nelderMead, NelderData } from '../fmin/nelder-mead'; 10 | import { intersectionArea, circleOverlap, circleCircleIntersection, distance } from './circle-intersection'; 11 | import { Area, Point, Circle, CircleMap, Bounds, CircleCluster, OverlapItem, LayoutParameter, GradientLoss } from '../interfaces'; 12 | 13 | /** given a list of set objects, and their corresponding overlaps. 14 | updates the (x, y, radius) attribute on each set such that their positions 15 | roughly correspond to the desired overlaps */ 16 | 17 | export function venn(areas: Area[], parameters?: LayoutParameter): CircleMap { 18 | parameters = parameters || {}; 19 | parameters.maxIterations = parameters.maxIterations || 500; 20 | const initialLayout = parameters.initialLayout || bestInitialLayout; 21 | const loss = parameters.lossFunction || lossFunction; 22 | 23 | // add in missing pairwise areas as having 0 size 24 | areas = addMissingAreas(areas); 25 | 26 | // initial layout is done greedily 27 | const circles = initialLayout(areas, parameters); 28 | 29 | // transform x/y coordinates to a vector to optimize 30 | const initial: number[] = []; 31 | const setids: string[] = []; 32 | for (const setid in circles) { 33 | if (circles[setid]) { 34 | initial.push(circles[setid].x); 35 | initial.push(circles[setid].y); 36 | setids.push(setid); 37 | } 38 | } 39 | 40 | // optimize initial layout from our loss function 41 | const solution = nelderMead( 42 | (values) => { 43 | const current: CircleMap = {}; 44 | for (let i = 0; i < setids.length; ++i) { 45 | const setid = setids[i]; 46 | current[setid] = { 47 | x: values[2 * i], 48 | y: values[2 * i + 1], 49 | radius: circles[setid].radius, 50 | // size : circles[setid].size 51 | }; 52 | } 53 | return loss(current, areas); 54 | }, 55 | initial as NelderData, 56 | parameters); 57 | 58 | // transform solution vector back to x/y points 59 | const positions = solution.x; 60 | for (let i = 0; i < setids.length; ++i) { 61 | const setid = setids[i]; 62 | circles[setid].x = positions[2 * i]; 63 | circles[setid].y = positions[2 * i + 1]; 64 | } 65 | 66 | return circles; 67 | } 68 | 69 | const SMALL = 1e-10; 70 | 71 | /** Returns the distance necessary for two circles of radius r1 + r2 to 72 | have the overlap area 'overlap' */ 73 | function distanceFromIntersectArea(r1: number, r2: number, overlap: number) { 74 | // handle complete overlapped circles 75 | if (Math.min(r1, r2) * Math.min(r1, r2) * Math.PI <= overlap + SMALL) { 76 | return Math.abs(r1 - r2); 77 | } 78 | return bisect((distance) => circleOverlap(r1, r2, distance) - overlap, 0, r1 + r2); 79 | } 80 | 81 | /** Missing pair-wise intersection area data can cause problems: 82 | treating as an unknown means that sets will be laid out overlapping, 83 | which isn't what people expect. To reflect that we want disjoint sets 84 | here, set the overlap to 0 for all missing pairwise set intersections */ 85 | function addMissingAreas(areas: Area[]) { 86 | areas = areas.slice(); 87 | 88 | // two circle intersections that aren't defined 89 | const ids: string[] = []; 90 | const pairs = new Map(); 91 | for (let i = 0; i < areas.length; ++i) { 92 | const area = areas[i]; 93 | if (area.sets.length === 1) { 94 | ids.push(area.sets[0]); 95 | } else if (area.sets.length === 2) { 96 | const a = area.sets[0]; 97 | const b = area.sets[1]; 98 | pairs.set([a, b].join(','), true); 99 | pairs.set([b, a].join(','), true); 100 | } 101 | } 102 | ids.sort((a: string, b: string) => { return a > b ? 1 : 0; }); 103 | 104 | for (let i = 0; i < ids.length; ++i) { 105 | const a = ids[i]; 106 | for (let j = i + 1; j < ids.length; ++j) { 107 | const b = ids[j]; 108 | if (!pairs.has([a, b].join(','))) { 109 | areas.push({ 110 | 'sets': [a, b], 111 | 'size': 0, 112 | }); 113 | } 114 | } 115 | } 116 | return areas; 117 | } 118 | 119 | /// Returns two matrices, one of the euclidean distances between the sets 120 | /// and the other indicating if there are subset or disjoint set relationships 121 | function getDistanceMatrices(areas: Area[], sets: Area[], setids: { [key: string]: number }) { 122 | // initialize an empty distance matrix between all the points 123 | const distances = zerosM(sets.length, sets.length); 124 | const constraints = zerosM(sets.length, sets.length); 125 | 126 | // compute required distances between all the sets such that 127 | // the areas match 128 | areas.filter(function (x) { 129 | if (x.sets.length === 2) { 130 | if (setids[x.sets[0]] && setids[x.sets[1]]) { 131 | return true; 132 | } 133 | } 134 | return false; 135 | }).map(function (current) { 136 | const left = setids[current.sets[0]]; 137 | const right = setids[current.sets[1]]; 138 | const r1 = Math.sqrt(sets[left].size / Math.PI), 139 | r2 = Math.sqrt(sets[right].size / Math.PI), 140 | distance = distanceFromIntersectArea(r1, r2, current.size); 141 | 142 | distances[left][right] = distances[right][left] = distance; 143 | 144 | // also update constraints to indicate if its a subset or disjoint 145 | // relationship 146 | let c = 0; 147 | if (current.size + 1e-10 >= Math.min(sets[left].size, 148 | sets[right].size)) { 149 | c = 1; 150 | } else if (current.size <= 1e-10) { 151 | c = -1; 152 | } 153 | constraints[left][right] = constraints[right][left] = c; 154 | }); 155 | 156 | return { distances: distances, constraints: constraints }; 157 | } 158 | 159 | /// computes the gradient and loss simulatenously for our constrained MDS optimizer 160 | function constrainedMDSGradient(x: number[], fxprime: number[], distances: number[][], constraints: number[][]): number { 161 | let loss = 0; 162 | for (let i = 0; i < fxprime.length; ++i) { 163 | fxprime[i] = 0; 164 | } 165 | 166 | for (let i = 0; i < distances.length; ++i) { 167 | const xi = x[2 * i]; 168 | const yi = x[2 * i + 1]; 169 | for (let j = i + 1; j < distances.length; ++j) { 170 | const xj = x[2 * j]; 171 | const yj = x[2 * j + 1]; 172 | const dij = distances[i][j]; 173 | const constraint = constraints[i][j]; 174 | 175 | const squaredDistance = (xj - xi) * (xj - xi) + (yj - yi) * (yj - yi), 176 | distance = Math.sqrt(squaredDistance), 177 | delta = squaredDistance - dij * dij; 178 | 179 | if (((constraint > 0) && (distance <= dij)) || 180 | ((constraint < 0) && (distance >= dij))) { 181 | continue; 182 | } 183 | 184 | loss += 2 * delta * delta; 185 | 186 | fxprime[2 * i] += 4 * delta * (xi - xj); 187 | fxprime[2 * i + 1] += 4 * delta * (yi - yj); 188 | 189 | fxprime[2 * j] += 4 * delta * (xj - xi); 190 | fxprime[2 * j + 1] += 4 * delta * (yj - yi); 191 | } 192 | } 193 | return loss; 194 | } 195 | 196 | /// takes the best working variant of either constrained MDS or greedy 197 | function bestInitialLayout(areas: Area[], params: LayoutParameter) { 198 | let initial = greedyLayout(areas, params); 199 | const loss = params.lossFunction || lossFunction; 200 | 201 | // greedylayout is sufficient for all 2/3 circle cases. try out 202 | // constrained MDS for higher order problems, take its output 203 | // if it outperforms. (greedy is aesthetically better on 2/3 circles 204 | // since it axis aligns) 205 | if (areas.length >= 8) { 206 | const constrained = constrainedMDSLayout(areas, params); 207 | const constrainedLoss = loss(constrained, areas); 208 | const greedyLoss = loss(initial, areas); 209 | 210 | if (constrainedLoss + 1e-8 < greedyLoss) { 211 | initial = constrained; 212 | } 213 | } 214 | return initial; 215 | } 216 | 217 | /// use the constrained MDS variant to generate an initial layout 218 | function constrainedMDSLayout(areas: Area[], params?: LayoutParameter) { 219 | params = params || {}; 220 | const restarts = params.restarts || 10; 221 | 222 | // bidirectionally map sets to a rowid (so we can create a matrix) 223 | const sets: Area[] = []; 224 | const setids: { [key: string]: number } = {}; 225 | for (let i = 0; i < areas.length; ++i) { 226 | const area = areas[i]; 227 | if (area.sets.length === 1) { 228 | setids[area.sets[0]] = sets.length; 229 | sets.push(area); 230 | } 231 | } 232 | 233 | const matrices = getDistanceMatrices(areas, sets, setids); 234 | let distances = matrices.distances; 235 | const constraints = matrices.constraints; 236 | 237 | // keep distances bounded, things get messed up otherwise. 238 | // TODO: proper preconditioner? 239 | const norm = norm2(distances.map(norm2)) / (distances.length); 240 | distances = distances.map(function (row) { 241 | return row.map(function (value) { return value / norm; }); 242 | }); 243 | 244 | const obj = (x: number[], fxprime: number[]) => { 245 | return constrainedMDSGradient(x, fxprime, distances, constraints); 246 | }; 247 | 248 | let best: GradientLoss | null = null; 249 | let current: GradientLoss | null = null; 250 | for (let i = 0; i < restarts; ++i) { 251 | const initial = zeros(distances.length * 2).map(Math.random); 252 | 253 | current = conjugateGradient(obj, initial, params); 254 | if (!best || (current.fx < best.fx)) { 255 | best = current; 256 | } 257 | } 258 | const positions = best?.x || []; 259 | 260 | // translate rows back to (x,y,radius) coordinates 261 | const circles: CircleMap = {}; 262 | for (let i = 0; i < sets.length; ++i) { 263 | const set = sets[i]; 264 | circles[set.sets[0]] = { 265 | x: positions[2 * i] * norm, 266 | y: positions[2 * i + 1] * norm, 267 | radius: Math.sqrt(set.size / Math.PI), 268 | }; 269 | } 270 | 271 | if (params.history) { 272 | for (let i = 0; i < params.history.length; ++i) { 273 | scale(params.history[i].x, params.history[i].x, norm); 274 | } 275 | } 276 | return circles; 277 | } 278 | 279 | /** Lays out a Venn diagram greedily, going from most overlapped sets to 280 | least overlapped, attempting to position each new set such that the 281 | overlapping areas to already positioned sets are basically right */ 282 | function greedyLayout(areas: Area[], params: LayoutParameter) { 283 | const loss = params && params.lossFunction ? params.lossFunction : lossFunction; 284 | // define a circle for each set 285 | const circles: CircleMap = {}; 286 | const setOverlaps: { [key: string]: OverlapItem[] } = {}; 287 | for (let i = 0; i < areas.length; ++i) { 288 | const area = areas[i]; 289 | if (area.sets.length === 1) { 290 | const set = area.sets[0]; 291 | circles[set] = { 292 | x: 1e10, y: 1e10, 293 | rowid: (circles as any).length, // TODO: needed 294 | size: area.size, 295 | radius: Math.sqrt(area.size / Math.PI), 296 | }; 297 | setOverlaps[set] = []; 298 | } 299 | } 300 | areas = areas.filter(function (a) { return a.sets.length === 2; }); 301 | 302 | // map each set to a list of all the other sets that overlap it 303 | for (let i = 0; i < areas.length; ++i) { 304 | const current = areas[i]; 305 | let weight = (typeof current.weight === 'number') ? current.weight : 1.0; 306 | const left = current.sets[0], right = current.sets[1]; 307 | 308 | if (circles[left] && circles[right]) { 309 | // completely overlapped circles shouldn't be positioned early here 310 | if (current.size + SMALL >= Math.min(circles[left].size || 0, circles[right].size || 0)) { 311 | weight = 0; 312 | } 313 | 314 | setOverlaps[left].push({ set: right, size: current.size, weight: weight }); 315 | setOverlaps[right].push({ set: left, size: current.size, weight: weight }); 316 | } 317 | } 318 | 319 | // get list of most overlapped sets 320 | const mostOverlapped: OverlapItem[] = []; 321 | for (const set in setOverlaps) { 322 | if (setOverlaps[set] !== undefined) { 323 | let size = 0; 324 | for (let i = 0; i < setOverlaps[set].length; ++i) { 325 | size += setOverlaps[set][i].size * setOverlaps[set][i].weight!; 326 | } 327 | mostOverlapped.push({ set: set, size: size }); 328 | } 329 | } 330 | 331 | // sort by size desc 332 | const sortOrder = (a: OverlapItem, b: OverlapItem) => b.size - a.size; 333 | mostOverlapped.sort(sortOrder); 334 | 335 | // keep track of what sets have been laid out 336 | const positioned: { [key: string]: boolean } = {}; 337 | const isPositioned = (element: OverlapItem) => { 338 | return element.set in positioned; 339 | }; 340 | 341 | // adds a point to the output 342 | const positionSet = (point: Point, index: string) => { 343 | circles[index].x = point.x; 344 | circles[index].y = point.y; 345 | positioned[index] = true; 346 | }; 347 | 348 | // add most overlapped set at (0,0) 349 | positionSet({ x: 0, y: 0 }, mostOverlapped[0].set); 350 | 351 | // get distances between all points. TODO, necessary? 352 | // answer: probably not 353 | // var distances = venn.getDistanceMatrices(circles, areas).distances; 354 | for (let i = 1; i < mostOverlapped.length; ++i) { 355 | const setIndex = mostOverlapped[i].set, 356 | overlap = setOverlaps[setIndex].filter(isPositioned); 357 | const set = circles[setIndex]; 358 | overlap.sort(sortOrder); 359 | 360 | if (overlap.length === 0) { 361 | // this shouldn't happen anymore with addMissingAreas 362 | throw new Error('ERROR: missing pairwise overlap information'); 363 | } 364 | 365 | const points: Point[] = []; 366 | for (let j = 0; j < overlap.length; ++j) { 367 | // get appropriate distance from most overlapped already added set 368 | const p1 = circles[overlap[j].set]; 369 | const d1 = distanceFromIntersectArea(set.radius, p1.radius, overlap[j].size); 370 | 371 | // sample positions at 90 degrees for maximum aesthetics 372 | points.push({ x: p1.x + d1, y: p1.y }); 373 | points.push({ x: p1.x - d1, y: p1.y }); 374 | points.push({ y: p1.y + d1, x: p1.x }); 375 | points.push({ y: p1.y - d1, x: p1.x }); 376 | 377 | // if we have at least 2 overlaps, then figure out where the 378 | // set should be positioned analytically and try those too 379 | for (let k = j + 1; k < overlap.length; ++k) { 380 | const p2 = circles[overlap[k].set]; 381 | const d2 = distanceFromIntersectArea(set.radius, p2.radius, overlap[k].size); 382 | 383 | const extraPoints = circleCircleIntersection( 384 | { x: p1.x, y: p1.y, radius: d1 }, 385 | { x: p2.x, y: p2.y, radius: d2 } 386 | ); 387 | 388 | for (let l = 0; l < extraPoints.length; ++l) { 389 | points.push(extraPoints[l]); 390 | } 391 | } 392 | } 393 | 394 | // we have some candidate positions for the set, examine loss 395 | // at each position to figure out where to put it at 396 | let bestLoss = 1e50, bestPoint = points[0]; 397 | for (let j = 0; j < points.length; ++j) { 398 | circles[setIndex].x = points[j].x; 399 | circles[setIndex].y = points[j].y; 400 | const localLoss = loss(circles, areas); 401 | if (localLoss < bestLoss) { 402 | bestLoss = localLoss; 403 | bestPoint = points[j]; 404 | } 405 | } 406 | 407 | positionSet(bestPoint, setIndex); 408 | } 409 | 410 | return circles; 411 | } 412 | 413 | /** Given a bunch of sets, and the desired overlaps between these sets - computes 414 | the distance from the actual overlaps to the desired overlaps. Note that 415 | this method ignores overlaps of more than 2 circles */ 416 | export function lossFunction(sets: CircleMap, overlaps: Area[]): number { 417 | let output = 0; 418 | 419 | const getCircles = (indices: string[]) => { 420 | return indices.map((i) => { return sets[i]; }).filter((d) => !!d); 421 | }; 422 | 423 | for (let i = 0; i < overlaps.length; ++i) { 424 | const area = overlaps[i]; 425 | let overlap = 0; 426 | if (area.sets.length === 1) { 427 | continue; 428 | } else if (area.sets.length === 2) { 429 | const left = sets[area.sets[0]]; 430 | const right = sets[area.sets[1]]; 431 | if (left && right) { 432 | overlap = circleOverlap(left.radius, right.radius, distance(left, right)); 433 | } else { 434 | continue; 435 | } 436 | } else { 437 | overlap = intersectionArea(getCircles(area.sets)); 438 | } 439 | const weight = (typeof area.weight === 'number') ? area.weight : 1.0; 440 | output += weight * (overlap - area.size) * (overlap - area.size); 441 | } 442 | 443 | return output; 444 | } 445 | 446 | // orientates a bunch of circles to point in orientation 447 | function orientateCircles(circles: Circle[], orientation: number, orientationOrder?: (a: Circle, b: Circle) => number) { 448 | if (orientationOrder === undefined) { 449 | circles.sort(function (a, b) { return b.radius - a.radius; }); 450 | } else { 451 | circles.sort(orientationOrder); 452 | } 453 | // shift circles so largest circle is at (0, 0) 454 | if (circles.length > 0) { 455 | const largestX = circles[0].x; 456 | const largestY = circles[0].y; 457 | for (let i = 0; i < circles.length; ++i) { 458 | circles[i].x -= largestX; 459 | circles[i].y -= largestY; 460 | } 461 | } 462 | 463 | if (circles.length === 2) { 464 | // if the second circle is a subset of the first, arrange so that 465 | // it is off to one side. hack for https://github.com/benfred/venn.js/issues/120 466 | const dist = distance(circles[0], circles[1]); 467 | if (dist < Math.abs(circles[1].radius - circles[0].radius)) { 468 | circles[1].x = circles[0].x + circles[0].radius - circles[1].radius - 1e-10; 469 | circles[1].y = circles[0].y; 470 | } 471 | } 472 | 473 | // rotate circles so that second largest is at an angle of 'orientation' 474 | // from largest 475 | if (circles.length > 1) { 476 | const rotation = Math.atan2(circles[1].x, circles[1].y) - orientation; 477 | const c = Math.cos(rotation); 478 | const s = Math.sin(rotation); 479 | 480 | for (let i = 0; i < circles.length; ++i) { 481 | const x = circles[i].x; 482 | const y = circles[i].y; 483 | circles[i].x = c * x - s * y; 484 | circles[i].y = s * x + c * y; 485 | } 486 | } 487 | 488 | // mirror solution if third solution is above plane specified by 489 | // first two circles 490 | if (circles.length > 2) { 491 | let angle = Math.atan2(circles[2].x, circles[2].y) - orientation; 492 | while (angle < 0) { angle += 2 * Math.PI; } 493 | while (angle > 2 * Math.PI) { angle -= 2 * Math.PI; } 494 | if (angle > Math.PI) { 495 | const slope = circles[1].y / (1e-10 + circles[1].x); 496 | for (let i = 0; i < circles.length; ++i) { 497 | const d = (circles[i].x + slope * circles[i].y) / (1 + slope * slope); 498 | circles[i].x = 2 * d - circles[i].x; 499 | circles[i].y = 2 * d * slope - circles[i].y; 500 | } 501 | } 502 | } 503 | } 504 | 505 | function disjointCluster(circles: Circle[]): CircleCluster[] { 506 | // union-find clustering to get disjoint sets 507 | circles.map(function (circle) { circle.parent = circle; }); 508 | 509 | // path compression step in union find 510 | const find = (circle: Circle): Circle => { 511 | if (circle.parent !== circle) { 512 | circle.parent = find(circle.parent!); 513 | } 514 | return circle.parent; 515 | }; 516 | 517 | const union = (x: Circle, y: Circle) => { 518 | const xRoot = find(x); 519 | const yRoot = find(y); 520 | xRoot.parent = yRoot; 521 | }; 522 | 523 | // get the union of all overlapping sets 524 | for (let i = 0; i < circles.length; ++i) { 525 | for (let j = i + 1; j < circles.length; ++j) { 526 | const maxDistance = circles[i].radius + circles[j].radius; 527 | if (distance(circles[i], circles[j]) + 1e-10 < maxDistance) { 528 | union(circles[j], circles[i]); 529 | } 530 | } 531 | } 532 | 533 | // find all the disjoint clusters and group them together 534 | const disjointClusters = new Map(); 535 | for (let i = 0; i < circles.length; ++i) { 536 | const setid = find(circles[i]).parent!.setid; 537 | if (setid) { 538 | if (!disjointClusters.has(setid)) { 539 | disjointClusters.set(setid, []); 540 | } 541 | disjointClusters.get(setid)!.push(circles[i]); 542 | } 543 | } 544 | 545 | // cleanup bookkeeping 546 | circles.map(function (circle) { delete circle.parent; }); 547 | 548 | // return in more usable form 549 | const ret: Circle[][] = []; 550 | for (const setid of disjointClusters.keys()) { 551 | ret.push(disjointClusters.get(setid)!); 552 | } 553 | return ret; 554 | } 555 | 556 | function getBoundingBox(circles: Circle[]): Bounds { 557 | const minMax = function (d: string) { 558 | const hi = Math.max.apply(null, circles.map( 559 | (c) => { return (c as any)[d] + c.radius; })); 560 | const lo = Math.min.apply(null, circles.map( 561 | (c) => { return (c as any)[d] - c.radius; })); 562 | return { max: hi, min: lo }; 563 | }; 564 | 565 | return { xRange: minMax('x'), yRange: minMax('y') }; 566 | } 567 | 568 | export function normalizeSolution(solution: CircleMap, orientation: number | null, orientationOrder?: (a: Circle, b: Circle) => number) { 569 | if (orientation === null) { 570 | orientation = Math.PI / 2; 571 | } 572 | 573 | // work with a list instead of a dictionary, and take a copy so we 574 | // don't mutate input 575 | let circles: CircleCluster = []; 576 | for (const setid in solution) { 577 | const previous = solution[setid]; 578 | circles.push({ 579 | x: previous.x, 580 | y: previous.y, 581 | radius: previous.radius, 582 | setid: setid, 583 | }); 584 | } 585 | 586 | // get all the disjoint clusters 587 | const clusters = disjointCluster(circles); 588 | 589 | // orientate all disjoint sets, get sizes 590 | for (let i = 0; i < clusters.length; ++i) { 591 | orientateCircles(clusters[i], orientation, orientationOrder); 592 | const bounds = getBoundingBox(clusters[i]); 593 | clusters[i].size = (bounds.xRange.max - bounds.xRange.min) * (bounds.yRange.max - bounds.yRange.min); 594 | clusters[i].bounds = bounds; 595 | } 596 | clusters.sort(function (a, b) { return b.size! - a.size!; }); 597 | 598 | // orientate the largest at 0,0, and get the bounds 599 | circles = clusters[0]; 600 | let returnBounds = circles.bounds!; 601 | 602 | const spacing = (returnBounds.xRange.max - returnBounds.xRange.min) / 50; 603 | 604 | const addCluster = (cluster: CircleCluster | null, right: boolean, bottom: boolean) => { 605 | if (!cluster) return; 606 | if (!cluster.bounds) return; 607 | 608 | const bounds = cluster.bounds; 609 | let xOffset = 0, yOffset = 0, centreing = 0; 610 | 611 | if (right) { 612 | xOffset = returnBounds.xRange.max - bounds.xRange.min + spacing; 613 | } else { 614 | xOffset = returnBounds.xRange.max - bounds.xRange.max; 615 | centreing = (bounds.xRange.max - bounds.xRange.min) / 2 - 616 | (returnBounds.xRange.max - returnBounds.xRange.min) / 2; 617 | if (centreing < 0) xOffset += centreing; 618 | } 619 | 620 | if (bottom) { 621 | yOffset = returnBounds.yRange.max - bounds.yRange.min + spacing; 622 | } else { 623 | yOffset = returnBounds.yRange.max - bounds.yRange.max; 624 | centreing = (bounds.yRange.max - bounds.yRange.min) / 2 - 625 | (returnBounds.yRange.max - returnBounds.yRange.min) / 2; 626 | if (centreing < 0) yOffset += centreing; 627 | } 628 | 629 | for (let j = 0; j < cluster.length; ++j) { 630 | cluster[j].x += xOffset; 631 | cluster[j].y += yOffset; 632 | circles.push(cluster[j]); 633 | } 634 | }; 635 | 636 | let index = 1; 637 | while (index < clusters.length) { 638 | addCluster(clusters[index], true, false); 639 | addCluster(clusters[index + 1], false, true); 640 | addCluster(clusters[index + 2], true, true); 641 | index += 3; 642 | 643 | // have one cluster (in top left). lay out next three relative 644 | // to it in a grid 645 | returnBounds = getBoundingBox(circles); 646 | } 647 | 648 | // convert back to solution form 649 | const ret: CircleMap = {}; 650 | for (let i = 0; i < circles.length; ++i) { 651 | const setid = circles[i].setid; 652 | if (setid) { 653 | ret[setid] = circles[i]; 654 | } 655 | } 656 | return ret; 657 | } 658 | 659 | /** Scales a solution from venn.venn or venn.greedyLayout such that it fits in 660 | a rectangle of width/height - with padding around the borders. also 661 | centers the diagram in the available space at the same time */ 662 | export function scaleSolution(solution: CircleMap, width: number, height: number, padding: number): CircleMap { 663 | const circles: Circle[] = []; 664 | const setids: string[] = []; 665 | for (const setid in solution) { 666 | setids.push(setid); 667 | circles.push(solution[setid]); 668 | } 669 | 670 | width -= 2 * padding; 671 | height -= 2 * padding; 672 | 673 | const bounds = getBoundingBox(circles), 674 | xRange = bounds.xRange, 675 | yRange = bounds.yRange; 676 | 677 | if ((xRange.max === xRange.min) || (yRange.max === yRange.min)) { 678 | return solution; 679 | } 680 | 681 | const xScaling = width / (xRange.max - xRange.min), 682 | yScaling = height / (yRange.max - yRange.min), 683 | scaling = Math.min(yScaling, xScaling), 684 | 685 | // while we're at it, center the diagram too 686 | xOffset = (width - (xRange.max - xRange.min) * scaling) / 2, 687 | yOffset = (height - (yRange.max - yRange.min) * scaling) / 2; 688 | 689 | const scaled: { [setid: string]: Circle } = {}; 690 | for (let i = 0; i < circles.length; ++i) { 691 | const circle = circles[i]; 692 | scaled[setids[i]] = { 693 | radius: scaling * circle.radius, 694 | x: padding + xOffset + (circle.x - xRange.min) * scaling, 695 | y: padding + yOffset + (circle.y - yRange.min) * scaling, 696 | }; 697 | } 698 | 699 | return scaled; 700 | } --------------------------------------------------------------------------------