@upsetjs/venn.js
Version:
Area Proportional Venn and Euler Diagrams
786 lines (699 loc) • 22 kB
JavaScript
import { venn, lossFunction, logRatioLossFunction, normalizeSolution, scaleSolution } from './layout';
import { intersectionArea, distance, getCenter } from './circleintersection';
import { nelderMead } from 'fmin';
/*global console:true*/
/**
* VennDiagram includes an optional `options` parameter containing the following option(s):
*
* `colourScheme: Array<String>`
* A list of color values to be applied when coloring diagram circles.
*
* `symmetricalTextCentre: Boolean`
* Whether to symmetrically center each circle's text horizontally and vertically.
* Defaults to `false`.
*
* `textFill: String`
* The color to be applied to the text within each circle.
*
* @param {object} options
*/
export function VennDiagram(options = {}) {
let useViewBox = false,
width = 600,
height = 350,
padding = 15,
duration = 1000,
orientation = Math.PI / 2,
normalize = true,
scaleToFit = null,
wrap = true,
styled = true,
fontSize = null,
orientationOrder = null,
distinct = false,
round = null,
symmetricalTextCentre = options && options.symmetricalTextCentre ? options.symmetricalTextCentre : false,
// mimic the behaviour of d3.scale.category10 from the previous
// version of d3
colourMap = {},
// so this is the same as d3.schemeCategory10, which is only defined in d3 4.0
// since we can support older versions of d3 as long as we don't force this,
// I'm hackily redefining below. TODO: remove this and change to d3.schemeCategory10
colourScheme =
options && options.colourScheme
? options.colourScheme
: options && options.colorScheme
? options.colorScheme
: [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf',
],
colourIndex = 0,
colours = function (key) {
if (key in colourMap) {
return colourMap[key];
}
var ret = (colourMap[key] = colourScheme[colourIndex]);
colourIndex += 1;
if (colourIndex >= colourScheme.length) {
colourIndex = 0;
}
return ret;
},
layoutFunction = venn,
loss = lossFunction;
function chart(selection) {
let data = selection.datum();
// handle 0-sized sets by removing from input
const toRemove = new Set();
data.forEach((datum) => {
if (datum.size == 0 && datum.sets.length == 1) {
toRemove.add(datum.sets[0]);
}
});
data = data.filter((datum) => !datum.sets.some((set) => toRemove.has(set)));
let circles = {};
let textCentres = {};
if (data.length > 0) {
let solution = layoutFunction(data, { lossFunction: loss, distinct });
if (normalize) {
solution = normalizeSolution(solution, orientation, orientationOrder);
}
circles = scaleSolution(solution, width, height, padding, scaleToFit);
textCentres = computeTextCentres(circles, data, symmetricalTextCentre);
}
// Figure out the current label for each set. These can change
// and D3 won't necessarily update (fixes https://github.com/benfred/venn.js/issues/103)
const labels = {};
data.forEach((datum) => {
if (datum.label) {
labels[datum.sets] = datum.label;
}
});
function label(d) {
if (d.sets in labels) {
return labels[d.sets];
}
if (d.sets.length == 1) {
return '' + d.sets[0];
}
}
// create svg if not already existing
selection.selectAll('svg').data([circles]).enter().append('svg');
const svg = selection.select('svg');
if (useViewBox) {
svg.attr('viewBox', `0 0 ${width} ${height}`);
} else {
svg.attr('width', width).attr('height', height);
}
// to properly transition intersection areas, we need the
// previous circles locations. load from elements
const previous = {};
let hasPrevious = false;
svg.selectAll('.venn-area path').each(function (d) {
const path = this.getAttribute('d');
if (d.sets.length == 1 && path && !distinct) {
hasPrevious = true;
previous[d.sets[0]] = circleFromPath(path);
}
});
// interpolate intersection area paths between previous and
// current paths
function pathTween(d) {
return (t) => {
const c = d.sets.map((set) => {
let start = previous[set];
let end = circles[set];
if (!start) {
start = { x: width / 2, y: height / 2, radius: 1 };
}
if (!end) {
end = { x: width / 2, y: height / 2, radius: 1 };
}
return {
x: start.x * (1 - t) + end.x * t,
y: start.y * (1 - t) + end.y * t,
radius: start.radius * (1 - t) + end.radius * t,
};
});
return intersectionAreaPath(c, round);
};
}
// update data, joining on the set ids
const nodes = svg.selectAll('.venn-area').data(data, (d) => d.sets);
// create new nodes
const enter = nodes
.enter()
.append('g')
.attr(
'class',
(d) =>
`venn-area venn-${d.sets.length == 1 ? 'circle' : 'intersection'}${
d.colour || d.color ? ' venn-coloured' : ''
}`
)
.attr('data-venn-sets', (d) => d.sets.join('_'));
const enterPath = enter.append('path');
const enterText = enter
.append('text')
.attr('class', 'label')
.text((d) => label(d))
.attr('text-anchor', 'middle')
.attr('dy', '.35em')
.attr('x', width / 2)
.attr('y', height / 2);
// apply minimal style if wanted
if (styled) {
enterPath
.style('fill-opacity', '0')
.filter((d) => d.sets.length == 1)
.style('fill', (d) => (d.colour ? d.colour : d.color ? d.color : colours(d.sets)))
.style('fill-opacity', '.25');
enterText.style('fill', (d) => {
if (d.colour || d.color) {
return '#FFF';
}
if (options.textFill) {
return options.textFill;
}
return d.sets.length == 1 ? colours(d.sets) : '#444';
});
}
function asTransition(s) {
if (typeof s.transition === 'function') {
return s.transition('venn').duration(duration);
}
return s;
}
// update existing, using pathTween if necessary
let update = selection;
if (hasPrevious && typeof update.transition === 'function') {
update = asTransition(selection);
update.selectAll('path').attrTween('d', pathTween);
} else {
update.selectAll('path').attr('d', (d) => intersectionAreaPath(d.sets.map((set) => circles[set])), round);
}
const updateText = update
.selectAll('text')
.filter((d) => d.sets in textCentres)
.text((d) => label(d))
.attr('x', (d) => Math.floor(textCentres[d.sets].x))
.attr('y', (d) => Math.floor(textCentres[d.sets].y));
if (wrap) {
if (hasPrevious) {
// d3 4.0 uses 'on' for events on transitions,
// but d3 3.0 used 'each' instead. switch appropriately
if ('on' in updateText) {
updateText.on('end', wrapText(circles, label));
} else {
updateText.each('end', wrapText(circles, label));
}
} else {
updateText.each(wrapText(circles, label));
}
}
// remove old
const exit = asTransition(nodes.exit()).remove();
if (typeof nodes.transition === 'function') {
exit.selectAll('path').attrTween('d', pathTween);
}
const exitText = exit
.selectAll('text')
.attr('x', width / 2)
.attr('y', height / 2);
// if we've been passed a fontSize explicitly, use it to
// transition
if (fontSize !== null) {
enterText.style('font-size', '0px');
updateText.style('font-size', fontSize);
exitText.style('font-size', '0px');
}
return { circles, textCentres, nodes, enter, update, exit };
}
chart.wrap = function (_) {
if (!arguments.length) return wrap;
wrap = _;
return chart;
};
chart.useViewBox = function () {
useViewBox = true;
return chart;
};
chart.width = function (_) {
if (!arguments.length) return width;
width = _;
return chart;
};
chart.height = function (_) {
if (!arguments.length) return height;
height = _;
return chart;
};
chart.padding = function (_) {
if (!arguments.length) return padding;
padding = _;
return chart;
};
chart.distinct = function (_) {
if (!arguments.length) return distinct;
distinct = _;
return chart;
};
chart.colours = function (_) {
if (!arguments.length) return colours;
colours = _;
return chart;
};
chart.colors = function (_) {
if (!arguments.length) return colors;
colours = _;
return chart;
};
chart.fontSize = function (_) {
if (!arguments.length) return fontSize;
fontSize = _;
return chart;
};
chart.round = function (_) {
if (!arguments.length) return round;
round = _;
return chart;
};
chart.duration = function (_) {
if (!arguments.length) return duration;
duration = _;
return chart;
};
chart.layoutFunction = function (_) {
if (!arguments.length) return layoutFunction;
layoutFunction = _;
return chart;
};
chart.normalize = function (_) {
if (!arguments.length) return normalize;
normalize = _;
return chart;
};
chart.scaleToFit = function (_) {
if (!arguments.length) return scaleToFit;
scaleToFit = _;
return chart;
};
chart.styled = function (_) {
if (!arguments.length) return styled;
styled = _;
return chart;
};
chart.orientation = function (_) {
if (!arguments.length) return orientation;
orientation = _;
return chart;
};
chart.orientationOrder = function (_) {
if (!arguments.length) return orientationOrder;
orientationOrder = _;
return chart;
};
chart.lossFunction = function (_) {
if (!arguments.length) return loss;
loss = _ === 'default' ? lossFunction : _ === 'logRatio' ? logRatioLossFunction : _;
return chart;
};
return chart;
}
// sometimes text doesn't fit inside the circle, if thats the case lets wrap
// the text here such that it fits
// todo: looks like this might be merged into d3 (
// https://github.com/mbostock/d3/issues/1642),
// also worth checking out is
// http://engineering.findthebest.com/wrapping-axis-labels-in-d3-js/
// this seems to be one of those things that should be easy but isn't
export function wrapText(circles, labeller) {
return function (data) {
const text = this;
const width = circles[data.sets[0]].radius || 50;
const label = labeller(data) || '';
const words = label.split(/\s+/).reverse();
const maxLines = 3;
const minChars = (label.length + words.length) / maxLines;
let word = words.pop();
let line = [word];
let lineNumber = 0;
const lineHeight = 1.1; // ems
text.textContent = null; // clear
const tspans = [];
function append(word) {
const tspan = text.ownerDocument.createElementNS(text.namespaceURI, 'tspan');
tspan.textContent = word;
tspans.push(tspan);
text.append(tspan);
return tspan;
}
let tspan = append(word);
while (true) {
word = words.pop();
if (!word) {
break;
}
line.push(word);
const joined = line.join(' ');
tspan.textContent = joined;
if (joined.length > minChars && tspan.getComputedTextLength() > width) {
line.pop();
tspan.textContent = line.join(' ');
line = [word];
tspan = append(word);
lineNumber++;
}
}
const initial = 0.35 - (lineNumber * lineHeight) / 2;
const x = text.getAttribute('x');
const y = text.getAttribute('y');
tspans.forEach((t, i) => {
t.setAttribute('x', x);
t.setAttribute('y', y);
t.setAttribute('dy', `${initial + i * lineHeight}em`);
});
};
}
/**
*
* @param {{x: number, y: number}} current
* @param {ReadonlyArray<{x: number, y: number}>} interior
* @param {ReadonlyArray<{x: number, y: number}>} exterior
* @returns {number}
*/
function circleMargin(current, interior, exterior) {
let margin = interior[0].radius - distance(interior[0], current);
for (let i = 1; i < interior.length; ++i) {
const m = interior[i].radius - distance(interior[i], current);
if (m <= margin) {
margin = m;
}
}
for (let i = 0; i < exterior.length; ++i) {
const m = distance(exterior[i], current) - exterior[i].radius;
if (m <= margin) {
margin = m;
}
}
return margin;
}
/**
* compute the center of some circles by maximizing the margin of
* the center point relative to the circles (interior) after subtracting
* nearby circles (exterior)
* @param {readonly {x: number, y: number, radius: number}[]} interior
* @param {readonly {x: number, y: number, radius: number}[]} exterior
* @param {boolean} symmetricalTextCentre
* @returns {{x:number, y: number}}
*/
export function computeTextCentre(interior, exterior, symmetricalTextCentre) {
// get an initial estimate by sampling around the interior circles
// and taking the point with the biggest margin
/** @type {{x: number, y: number}[]} */
const points = [];
for (const c of interior) {
points.push({ x: c.x, y: c.y });
points.push({ x: c.x + c.radius / 2, y: c.y });
points.push({ x: c.x - c.radius / 2, y: c.y });
points.push({ x: c.x, y: c.y + c.radius / 2 });
points.push({ x: c.x, y: c.y - c.radius / 2 });
}
let initial = points[0];
let margin = circleMargin(points[0], interior, exterior);
for (let i = 1; i < points.length; ++i) {
const m = circleMargin(points[i], interior, exterior);
if (m >= margin) {
initial = points[i];
margin = m;
}
}
// maximize the margin numerically
const solution = nelderMead(
(p) => -1 * circleMargin({ x: p[0], y: p[1] }, interior, exterior),
[initial.x, initial.y],
{ maxIterations: 500, minErrorDelta: 1e-10 }
).x;
const ret = { x: symmetricalTextCentre ? 0 : solution[0], y: solution[1] };
// check solution, fallback as needed (happens if fully overlapped
// etc)
let valid = true;
for (const i of interior) {
if (distance(ret, i) > i.radius) {
valid = false;
break;
}
}
for (const e of exterior) {
if (distance(ret, e) < e.radius) {
valid = false;
break;
}
}
if (valid) {
return ret;
}
if (interior.length == 1) {
return { x: interior[0].x, y: interior[0].y };
}
const areaStats = {};
intersectionArea(interior, areaStats);
if (areaStats.arcs.length === 0) {
return { x: 0, y: -1000, disjoint: true };
}
if (areaStats.arcs.length == 1) {
return { x: areaStats.arcs[0].circle.x, y: areaStats.arcs[0].circle.y };
}
if (exterior.length) {
// try again without other circles
return computeTextCentre(interior, []);
}
// take average of all the points in the intersection
// polygon. this should basically never happen
// and has some issues:
// https://github.com/benfred/venn.js/issues/48#issuecomment-146069777
return getCenter(areaStats.arcs.map((a) => a.p1));
}
// given a dictionary of {setid : circle}, returns
// a dictionary of setid to list of circles that completely overlap it
function getOverlappingCircles(circles) {
const ret = {};
const circleids = Object.keys(circles);
for (const circleid of circleids) {
ret[circleid] = [];
}
for (let i = 0; i < circleids.length; i++) {
const ci = circleids[i];
const a = circles[ci];
for (let j = i + 1; j < circleids.length; ++j) {
const cj = circleids[j];
const b = circles[cj];
const d = distance(a, b);
if (d + b.radius <= a.radius + 1e-10) {
ret[cj].push(ci);
} else if (d + a.radius <= b.radius + 1e-10) {
ret[ci].push(cj);
}
}
}
return ret;
}
export function computeTextCentres(circles, areas, symmetricalTextCentre) {
const ret = {};
const overlapped = getOverlappingCircles(circles);
for (let i = 0; i < areas.length; ++i) {
const area = areas[i].sets;
const areaids = {};
const exclude = {};
for (let j = 0; j < area.length; ++j) {
areaids[area[j]] = true;
const overlaps = overlapped[area[j]];
// keep track of any circles that overlap this area,
// and don't consider for purposes of computing the text
// centre
for (let k = 0; k < overlaps.length; ++k) {
exclude[overlaps[k]] = true;
}
}
const interior = [];
const exterior = [];
for (let setid in circles) {
if (setid in areaids) {
interior.push(circles[setid]);
} else if (!(setid in exclude)) {
exterior.push(circles[setid]);
}
}
const centre = computeTextCentre(interior, exterior, symmetricalTextCentre);
ret[area] = centre;
if (centre.disjoint && areas[i].size > 0) {
console.log('WARNING: area ' + area + ' not represented on screen');
}
}
return ret;
}
// sorts all areas in the venn diagram, so that
// a particular area is on top (relativeTo) - and
// all other areas are so that the smallest areas are on top
export function sortAreas(div, relativeTo) {
// figure out sets that are completely overlapped by relativeTo
const overlaps = getOverlappingCircles(div.selectAll('svg').datum());
const exclude = new Set();
for (const check of relativeTo.sets) {
for (let setid in overlaps) {
const overlap = overlaps[setid];
for (let j = 0; j < overlap.length; ++j) {
if (overlap[j] == check) {
exclude.add(setid);
break;
}
}
}
}
// checks that all sets are in exclude;
function shouldExclude(sets) {
return sets.every((set) => !exclude.has(set));
}
// need to sort div's so that Z order is correct
div.selectAll('g').sort((a, b) => {
// highest order set intersections first
if (a.sets.length != b.sets.length) {
return a.sets.length - b.sets.length;
}
if (a == relativeTo) {
return shouldExclude(b.sets) ? -1 : 1;
}
if (b == relativeTo) {
return shouldExclude(a.sets) ? 1 : -1;
}
// finally by size
return b.size - a.size;
});
}
/**
* @param {number} x
* @param {number} y
* @param {number} r
* @returns {string}
*/
export function circlePath(x, y, r) {
const ret = [];
ret.push('\nM', x, y);
ret.push('\nm', -r, 0);
ret.push('\na', r, r, 0, 1, 0, r * 2, 0);
ret.push('\na', r, r, 0, 1, 0, -r * 2, 0);
return ret.join(' ');
}
/**
* inverse of the circlePath function, returns a circle object from an svg path
* @param {string} path
* @returns {{x: number, y: number, radius: number}}
*/
export function circleFromPath(path) {
const tokens = path.split(' ');
return { x: Number.parseFloat(tokens[1]), y: Number.parseFloat(tokens[2]), radius: -Number.parseFloat(tokens[4]) };
}
function intersectionAreaArcs(circles) {
if (circles.length === 0) {
return [];
}
const stats = {};
intersectionArea(circles, stats);
return stats.arcs;
}
function arcsToPath(arcs, round) {
if (arcs.length === 0) {
return 'M 0 0';
}
const rFactor = Math.pow(10, round || 0);
const r = round != null ? (v) => Math.round(v * rFactor) / rFactor : (v) => v;
if (arcs.length == 1) {
const circle = arcs[0].circle;
return circlePath(r(circle.x), r(circle.y), r(circle.radius));
}
// draw path around arcs
const ret = ['\nM', r(arcs[0].p2.x), r(arcs[0].p2.y)];
for (const arc of arcs) {
const radius = r(arc.circle.radius);
ret.push('\nA', radius, radius, 0, arc.large ? 1 : 0, arc.sweep ? 1 : 0, r(arc.p1.x), r(arc.p1.y));
}
return ret.join(' ');
}
/**
* returns a svg path of the intersection area of a bunch of circles
* @param {ReadonlyArray<{x: number, y: number, radius: number}>} circles
* @returns {string}
*/
export function intersectionAreaPath(circles, round) {
return arcsToPath(intersectionAreaArcs(circles), round);
}
export function layout(data, options = {}) {
const {
lossFunction: loss,
layoutFunction: layout = venn,
normalize = true,
orientation = Math.PI / 2,
orientationOrder,
width = 600,
height = 350,
padding = 15,
scaleToFit = false,
symmetricalTextCentre = false,
distinct,
round = 2,
} = options;
let solution = layout(data, {
lossFunction: loss === 'default' || !loss ? lossFunction : loss === 'logRatio' ? logRatioLossFunction : loss,
distinct,
});
if (normalize) {
solution = normalizeSolution(solution, orientation, orientationOrder);
}
const circles = scaleSolution(solution, width, height, padding, scaleToFit);
const textCentres = computeTextCentres(circles, data, symmetricalTextCentre);
const circleLookup = new Map(
Object.keys(circles).map((set) => [
set,
{
set,
x: circles[set].x,
y: circles[set].y,
radius: circles[set].radius,
},
])
);
const helpers = data.map((area) => {
const circles = area.sets.map((s) => circleLookup.get(s));
const arcs = intersectionAreaArcs(circles);
const path = arcsToPath(arcs, round);
return { circles, arcs, path, area, has: new Set(area.sets) };
});
function genDistinctPath(sets) {
let r = '';
for (const e of helpers) {
if (e.has.size > sets.length && sets.every((s) => e.has.has(s))) {
r += ' ' + e.path;
}
}
return r;
}
return helpers.map(({ circles, arcs, path, area }) => {
return {
data: area,
text: textCentres[area.sets],
circles,
arcs,
path,
distinctPath: path + genDistinctPath(area.sets),
};
});
}