UNPKG

venny

Version:

Declarative Venn diagrams

386 lines (385 loc) 14.7 kB
import { diagram, intersectionAreaPath } from './venn/diagram.js'; import { VennElement } from './base-element'; const NS = 'http://www.w3.org/2000/svg'; const COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']; const DEFAULT_WIDTH = 600; const DEFAULT_HEIGHT = 350; export class VennDiagram extends HTMLElement { constructor() { super(); this._connected = false; this._config = { height: DEFAULT_HEIGHT, width: DEFAULT_WIDTH, }; this._areas = []; this._areaMap = new Map(); this._circleList = new Set(); this._circleMap = new Map(); this._nList = new Set(); this._nMap = new Map(); this._renderRequestPending = false; this._areaChangeHandler = (event) => { event.stopPropagation(); this._requestRender(); }; this._root = this.attachShadow({ mode: 'open' }); this._root.innerHTML = ` <style> :host { display: inline-block; position: relative; } * { box-sizing: border-box; } svg { display: block; } #labels { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } #labels label { position: absolute; top: 0; left: 0; user-select: none; font-size: var(--venn-label-size, 15px); font-family: var(--venn-label-font-family, system-ui, sans-serif); font-weight: var(--venn-label-font-weight, 400); } </style> <svg></svg> <div id="labels"></div> `; } static get observedAttributes() { return ['width', 'height']; } attributeChangedCallback(name, _, newValue) { switch (name) { case 'width': this.width = +newValue; break; case 'height': this.height = +newValue; break; } } get width() { return this._config.width || DEFAULT_WIDTH; } set width(value) { if (this._config.width !== value) { this._config.width = value; this._requestRender(); } } get height() { return this._config.height || DEFAULT_HEIGHT; } set height(value) { if (this._config.height !== value) { this._config.height = value; this._requestRender(); } } connectedCallback() { this._connected = true; this.addEventListener('area-add', this._areaChangeHandler); this.addEventListener('prop-change', this._areaChangeHandler); this._requestRender(); } disconnectedCallback() { this._connected = false; } _areaKey(d) { return [...d.sets].sort().join(','); } _requestRender() { if (this._connected && (!this._renderRequestPending)) { this._renderRequestPending = true; setTimeout(() => { try { if (this._connected) { const areas = []; const children = this.children; for (let i = 0; i < children.length; i++) { if (children[i] instanceof VennElement) { const childAreas = children[i].computeAreas(); if (childAreas && childAreas.length) { areas.push(...childAreas); } } } this._renderData(areas); } } finally { this._renderRequestPending = false; } }, 0); } } _findSubsets(arr, data, start, end, index, r, out) { if (index === r) { const temp = []; for (let j = 0; j < r; j++) { temp.push(data[j]); } out.push(temp); } for (let i = start; i <= end && end - i + 1 >= r - index; i++) { data[index] = arr[i]; this._findSubsets(arr, data, i + 1, end, index + 1, r, out); } } _renderData(value) { if (!this._connected) { return; } value = value || []; this._areas = value; this._areaMap.clear(); value.forEach((d) => { this._areaMap.set(this._areaKey(d), d); if (!d.size) { if (d.sets.length === 1) { d.size = 10; } else if (d.sets.length > 1) { d.size = 2; } } }); // Add missing intersections for (const area of this._areas) { const length = area.sets.length; if (length > 2) { const out = []; for (let r = 2; r < length; r++) { const data = new Array(r); this._findSubsets(area.sets, data, 0, length - 1, 0, r, out); } for (const subset of out) { const key = subset.join(','); if (!this._areaMap.has(key)) { const missingArea = { sets: [...subset], size: area.size, }; this._areas.push(missingArea); this._areaMap.set(key, missingArea); } } } } this._render(); } get _svg() { if (!this.__svg) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.__svg = this._root.querySelector('svg'); } return this.__svg; } get _labels() { if (!this.__labels) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.__labels = this._root.querySelector('#labels'); } return this.__labels; } _renderLabel(area, textCenters, se, color, maxWidth) { const labels = this._labels; let labelNode = se.labelNode; if (area.label) { if (!labelNode) { labelNode = document.createElement('label'); se.labelNode = labelNode; labels.appendChild(labelNode); } labelNode.style.color = `var(--venn-label-${this._areaKey(area).toLowerCase().replace(/,/g, '-')}-color, var(--venn-label-color, ${color}))`; labelNode.textContent = area.label; const centerPoint = textCenters.get(this._areaKey(area)) || { x: 0, y: 0 }; labelNode.style.transform = `translate3d(-50%, -50%, 0) translate3d(${centerPoint.x}px, ${centerPoint.y}px, 0px)`; labelNode.style.maxWidth = maxWidth ? `${maxWidth}px` : '12em'; } else { if (labelNode) { labels.removeChild(labelNode); se.labelNode = undefined; } } } _render() { const svg = this._svg; svg.setAttribute('width', `${this._config.width || DEFAULT_WIDTH}`); svg.setAttribute('height', `${this._config.height || DEFAULT_HEIGHT}`); const { circles, textCenters } = diagram(this._areas, this._config); const usedCircles = new Set(); const usedIntersections = new Set(); let colorIndex = 0; const nextColor = () => { const color = COLORS[colorIndex++]; if (colorIndex >= COLORS.length) { colorIndex = 0; } return color; }; // ********************** // RENDER CIRCLES // ********************** for (const id in circles) { const circle = circles[id]; // check if an element already exists for this id // if not, render a node let se = this._circleMap.get(id); const circleId = `circle-${id.toLowerCase().replace(/\s/g, '')}`; if (!se) { const g = svg.ownerDocument.createElementNS(NS, 'g'); const c = svg.ownerDocument.createElementNS(NS, 'circle'); const svgStyle = svg.ownerDocument.createElementNS(NS, 'style'); g.appendChild(svgStyle); g.appendChild(c); svg.appendChild(g); se = { id, circle, circleNode: c, groupNode: g, styleNode: svgStyle, }; this._circleMap.set(id, se); } else { this._circleList.delete(se); } usedCircles.add(se); // Update nodes with data const { groupNode, circleNode, styleNode } = se; const color = nextColor(); circleNode.setAttribute('id', circleId); groupNode.setAttribute('transform', `translate(${circle.x} ${circle.y})`); circleNode.setAttribute('r', `${circle.radius}`); styleNode.textContent = ` #${circleId} { fill: var(--venn-${circleId}-fill, ${color}); fill-opacity: var(--venn-${circleId}-fill-opacity, var(--venn-circle-fill-opacity, 0.25)); stroke: var(--venn-${circleId}-stroke, var(--venn-circle-stroke)); stroke-width: var(--venn-${circleId}-stroke-width, var(--venn-circle-stroke-width)); } #${circleId}:hover { fill: var(--venn-hover-${circleId}-fill, var(--venn-${circleId}-fill, ${color})); 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)))); stroke: var(--venn-hover-${circleId}-stroke, var(--venn-hover-circle-stroke, var(--venn-${circleId}-stroke, var(--venn-circle-stroke)))); stroke-width: var(--venn-hover-${circleId}-stroke-width, var(--venn-hover-circle-stroke-width, var(--venn-${circleId}-stroke-width, var(--venn-circle-stroke-width)))); } `; const area = this._areaMap.get(id); if (area) { if (area.component) { area.component.setSvgNode(se.groupNode); } const maxWidth = Math.max(100, se.circle.radius * 2 * 0.6); this._renderLabel(area, textCenters, se, color, maxWidth); } } // Cleanup the list - remove unused shapes for (const se of this._circleList) { this._circleMap.delete(se.id); const gp = se.groupNode.parentElement; if (gp) { gp.removeChild(se.groupNode); } } this._circleList = usedCircles; // ********************** // RENDER INTERSECTIONS // ********************** const setIntersections = []; for (const area of this._areas) { if (area.sets.length > 1) { const setCircles = (area.sets.map((d) => { var _a; return (_a = this._circleMap.get(d)) === null || _a === void 0 ? void 0 : _a.circle; }).filter((d) => !!d)); const intersection = intersectionAreaPath(setCircles); if (intersection) { setIntersections.push({ sets: [...area.sets], path: intersection.path, size: area.size, }); } } } setIntersections.sort((a, b) => { if (a.sets.length === b.sets.length) { return b.size - a.size; } return a.sets.length - b.sets.length; }); for (const intersection of setIntersections) { const key = this._areaKey(intersection); const pathId = `intersection-${key.toLowerCase().replace(/,/g, '-')}`; // check if shape element already exists let intersectionElement = this._nMap.get(key); if (intersectionElement) { this._nList.delete(intersectionElement); } else { const g = svg.ownerDocument.createElementNS(NS, 'g'); const path = svg.ownerDocument.createElementNS(NS, 'path'); const svgStyle = svg.ownerDocument.createElementNS(NS, 'style'); g.appendChild(svgStyle); g.appendChild(path); svg.appendChild(g); intersectionElement = { id: key, sets: [...intersection.sets], path: intersection.path, size: intersection.size, groupNode: g, pathNode: path, styleNode: svgStyle, }; this._nMap.set(key, intersectionElement); } usedIntersections.add(intersectionElement); const { groupNode, styleNode, pathNode } = intersectionElement; groupNode.style.fillOpacity = '0'; pathNode.setAttribute('id', pathId); pathNode.setAttribute('d', `${intersectionElement.path}`); styleNode.textContent = ` #${pathId} { stroke: var(--venn-${pathId}-stroke, var(--venn-intersection-stroke)); stroke-width: var(--venn-${pathId}-stroke-width, var(--venn-intersection-stroke-width)); } #${pathId}:hover { stroke: var(--venn-hover-${pathId}-stroke, var(--venn-hover-intersection-stroke, var(--venn-${pathId}-stroke, var(--venn-intersection-stroke)))); stroke-width: var(--venn-hover-${pathId}-stroke-width, var(--venn-hover-intersection-stroke-width, var(--venn-${pathId}-stroke-width, var(--venn-intersection-stroke-width)))); } `; const area = this._areaMap.get(key); if (area) { if (area.component) { area.component.setSvgNode(intersectionElement.groupNode); } this._renderLabel(area, textCenters, intersectionElement, ''); } } // Cleanup the list - remove unused shapes for (const intersectionElement of this._nList) { this._nMap.delete(intersectionElement.id); const gp = intersectionElement.groupNode.parentElement; if (gp) { gp.removeChild(intersectionElement.groupNode); } } this._nList = usedIntersections; } } customElements.define('venn-diagram', VennDiagram);