edeap
Version:
Euler Diagrams Drawn with Ellipses Area-Proportionally (Edeap)
348 lines (347 loc) • 15.8 kB
JavaScript
import { distanceBetween, ellipseBoundaryPosition, isInEllipse, toDegrees, toRadians, } from "./geometry.js";
import { check, transform, calculateInitial } from "./parse.js";
import { TextDimensionsNaive } from "./TextDimensionsNaive.js";
import { defaultColor } from "./defaults.js";
export function initialState({ overlaps, initialLayout }) {
const parsed = transform(check(overlaps));
const state = {
...parsed,
...calculateInitial(parsed),
ellipseParams: [],
ellipseDuplication: [],
// duplicatedEllipseIndexes: [],
};
if (initialLayout === "random") {
generateRandomLayout(state, 2, 2);
}
else {
generateDefaultLayout(state);
}
return state;
}
function generateDefaultLayout(state) {
for (let i = 0; i < state.contourAreas.length; i++) {
const radius = Math.sqrt(state.contourAreas[i] / Math.PI); // start as a circle
state.ellipseParams[i] = {
X: 1,
Y: 1,
A: radius,
B: radius,
R: 0,
};
}
// Check for ellipses that must be the same:
// state.duplicatedEllipseIndexes = [];
const ellipseEquivilenceSet = {};
let ellipseEquivilenceSetCount = 0;
for (let indexA = 0; indexA < state.contours.length; ++indexA) {
if (state.ellipseDuplication[indexA] !== undefined) {
// Already processed.
continue;
}
let count = 1;
let zonesWithA = state.zones
.filter((element) => element.includes(state.contours[indexA]))
.join("#");
for (let indexB = indexA + 1; indexB < state.contours.length; ++indexB) {
let zonesWithB = state.zones
.filter((element) => element.includes(state.contours[indexB]))
.join("#");
if (zonesWithA === zonesWithB) {
if (ellipseEquivilenceSet[zonesWithA] === undefined) {
ellipseEquivilenceSetCount++;
console.log("Eqivalence set " + ellipseEquivilenceSetCount);
ellipseEquivilenceSet[zonesWithA] = ellipseEquivilenceSetCount;
console.log(" -- " + state.contours[indexA]);
}
ellipseEquivilenceSet[zonesWithB] = ellipseEquivilenceSetCount;
console.log(" -- " + state.contours[indexB]);
// Set ellipse B as a duplicate of ellipse A
state.ellipseParams[indexB] = state.ellipseParams[indexA];
// state.duplicatedEllipseIndexes.push(indexB);
count++;
state.ellipseDuplication[indexB] = count;
}
}
}
}
function generateRandomLayout(state, maxX, maxY) {
for (let i = 0; i < state.contourAreas.length; i++) {
const radius = Math.sqrt(state.contourAreas[i] / Math.PI); // start as a circle
state.ellipseParams[i] = {
X: Math.random() * maxX,
Y: Math.random() * maxY,
A: radius,
B: radius,
R: 0,
};
}
}
// generate svg from ellipses
export function generateSVG({ state, areas, width, height, showLabels, showValues, standalone, labelSize, labelFont, valueSize, color: colorGenerator, dimensions, }) {
labelFont = labelFont || "Helvetica";
labelSize = labelSize || 16;
valueSize = valueSize || 16;
colorGenerator = colorGenerator || defaultColor;
showLabels = showLabels === undefined ? true : showLabels;
showValues = showValues === undefined ? true : showValues;
width = width || 1000;
height = height || 500;
dimensions = dimensions || new TextDimensionsNaive();
dimensions.init(labelSize, labelFont);
const labelDimensions = textDimensions(state.contours, dimensions);
dimensions.destroy();
const { translateX, translateY, scaling } = findTransformationToFit(width, height, areas, labelDimensions.maxWidth, labelDimensions.maxHeight);
let svgString = "";
if (standalone) {
// Prolog is only needed for when in a standalone file.
svgString += '<?xml version="1.0" standalone="no"?>';
svgString +=
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">';
}
svgString += `<svg viewBox="0 0 ${width} ${height}" ${standalone ? `width="${width}" height="${height} "` : ""}xmlns="http://www.w3.org/2000/svg">\n`;
let nextSVG = "";
const N = areas.contours.length;
for (let i = 0; i < N; i++) {
const color = colorGenerator(i, areas.contours[i]);
const eX = (areas.ellipseParams[i].X + translateX) * scaling;
const eY = (areas.ellipseParams[i].Y + translateY) * scaling;
const eA = areas.ellipseParams[i].A * scaling;
const eB = areas.ellipseParams[i].B * scaling;
const eR = toDegrees(areas.ellipseParams[i].R);
nextSVG = `<ellipse cx="${eX}" cy="${eY}" rx="${eA}" ry="${eB}" fill="${color}" fill-opacity="0.075" stroke="${color}" stroke-width="${2}" transform="rotate(${eR} ${eX} ${eY})" />\n`;
svgString += nextSVG;
}
if (showLabels) {
const LABEL_DEBUGGING = false;
// Find positions for ellipses, one at a time.
// let angleRange = Math.PI * 2;
let ranges = [];
for (let i = 0; i < N; i++) {
// const color = findColor(i, colourPalettes[colourPaletteName]);
const eX = (areas.ellipseParams[i].X + translateX) * scaling;
const eY = (areas.ellipseParams[i].Y + translateY) * scaling;
const eA = areas.ellipseParams[i].A * scaling;
const eB = areas.ellipseParams[i].B * scaling;
const eR = areas.ellipseParams[i].R;
let minDepth = Number.MAX_VALUE;
let maxDepth = 0;
// Compute the depth of each boundary point (i.e., how many
// other ellipses it is within.)
const ellipseRanges = [];
let currentRange = null;
ranges[i] = ellipseRanges;
for (let angle = 0; angle < 360; angle += 10) {
const angleRad = toRadians(angle);
let { x, y } = ellipseBoundaryPosition(eA, eB, eR, angleRad);
let isIn = 0;
let nearestPoint = Number.MAX_VALUE;
for (let j = 0; j < N; j++) {
if (i === j)
continue;
const jX = (areas.ellipseParams[j].X + translateX) * scaling;
const jY = (areas.ellipseParams[j].Y + translateY) * scaling;
const jA = areas.ellipseParams[j].A * scaling;
const jB = areas.ellipseParams[j].B * scaling;
const jR = areas.ellipseParams[j].R;
if (isInEllipse(x + eX, y + eY, jX, jY, jA, jB, jR)) {
isIn++;
}
for (let jAngle = 0; jAngle < 360; jAngle += 10) {
const jAngleRad = toRadians(jAngle);
const { x: jBX, y: jBY } = ellipseBoundaryPosition(jA, jB, jR, jAngleRad);
const distance = distanceBetween(jX + jBX, jY + jBY, eX + x, eY + y);
nearestPoint = Math.min(distance, nearestPoint);
}
}
minDepth = Math.min(minDepth, isIn);
maxDepth = Math.max(maxDepth, isIn);
const tooClose = nearestPoint < 11;
if (!tooClose) {
if (currentRange == null || currentRange[0].depth != isIn) {
// Start a new range.
currentRange = [];
ellipseRanges.push(currentRange);
}
// Add point to the existing range.
currentRange.push({
angle: angle,
depth: isIn,
x: x + eX,
y: y + eY,
distanceToNearest: nearestPoint,
});
}
else {
// End the current range.
if (currentRange != null) {
currentRange = null;
}
}
if (LABEL_DEBUGGING) {
const intensity = 255 - (255 / (maxDepth - minDepth)) * isIn;
const dotColour = tooClose
? "orange"
: "rgb(" + intensity + ", " + intensity + ", " + intensity + ")";
nextSVG = `<circle cx="${x + eX}" cy="${y + eY}" r="4" stroke-width="1" stroke="black" fill="${dotColour}" />\n`;
svgString += nextSVG;
}
}
}
for (let i = 0; i < N; i++) {
const ellipseRanges = ranges[i];
const eX = (areas.ellipseParams[i].X + translateX) * scaling;
const eY = (areas.ellipseParams[i].Y + translateY) * scaling;
let eA = areas.ellipseParams[i].A * scaling;
let eB = areas.ellipseParams[i].B * scaling;
const eR = areas.ellipseParams[i].R;
const ellipseRangesN = ellipseRanges.length;
if (ellipseRangesN >= 2) {
// Check for wrap around. Two ranges around zero.
if (ellipseRanges[0][0].angle == 0 &&
ellipseRanges[ellipseRangesN - 1][ellipseRanges[ellipseRangesN - 1].length - 1].angle == 350) {
// Join them together.
for (let j = 0; j < ellipseRanges[0].length; j++) {
ellipseRanges[0][j].angle += 360;
ellipseRanges[ellipseRangesN - 1].push(ellipseRanges[0][j]);
}
ellipseRanges.shift();
}
}
// Sort the ranges by depth (lowest first) and secondarily
// by length (highest first).
ellipseRanges.sort(function (a, b) {
if (a[0].depth != b[0].depth) {
return a[0].depth - b[0].depth;
}
return b.length - a.length;
});
const spacingFromEdge = 8;
// Take the first range, it will be the best.
const range = ellipseRanges[0];
let angle;
if (ellipseRanges.length == 0) {
// At top for if no valid regions.
angle = 270;
}
else if (range[0].angle == 0 &&
range[range.length - 1].angle == 350 &&
range[26].distanceToNearest >= 50) {
// At top for full circle, or if no valid range.
angle = 270;
}
else {
// Take point furthest away from others.
range.sort(function (a, b) {
return b.distanceToNearest - a.distanceToNearest;
});
angle = range[0].angle;
}
if (state.ellipseDuplication[i] !== undefined) {
angle -= 15 * (state.ellipseDuplication[i] - 1);
}
eA += spacingFromEdge;
eB += spacingFromEdge;
let angleRad = toRadians(angle);
let { x, y } = ellipseBoundaryPosition(eA, eB, eR, angleRad);
const textWidth = labelDimensions.widths[i];
const textHeight = labelDimensions.heights[i];
if (LABEL_DEBUGGING) {
nextSVG = `<circle cx="${x + eX}" cy="${y + eY}" r="5" stroke-width="1" stroke="black" fill="red" />\n`;
svgString += nextSVG;
}
const halfWidth = textWidth / 2;
const halfHeight = textHeight / 2;
const finalLabelAngle = (angle + toDegrees(eR)) % 360;
// Shift the label to allow for the label length.
if (finalLabelAngle === 0) {
x += halfWidth;
}
else if (finalLabelAngle === 90) {
y += halfHeight;
}
else if (finalLabelAngle === 180) {
x -= halfWidth;
}
else if (finalLabelAngle === 270) {
y -= halfHeight;
}
else if (finalLabelAngle > 0 && finalLabelAngle < 90) {
x += halfWidth;
y += halfHeight;
}
else if (finalLabelAngle > 90 && finalLabelAngle < 180) {
x -= halfWidth;
y += halfHeight;
}
else if (finalLabelAngle > 180 && finalLabelAngle < 270) {
x -= halfWidth;
y -= halfHeight;
}
else if (finalLabelAngle > 270 && finalLabelAngle < 360) {
x += halfWidth;
y -= halfHeight;
}
const color = colorGenerator(i, areas.contours[i]);
nextSVG = `<text style="font-family: ${labelFont}; font-size: ${labelSize}px;" x="${x + eX - textWidth / 2}" y="${y + eY}" fill="${color}">${areas.contours[i]}</text>\n`;
svgString += nextSVG;
}
}
if (showValues) {
const generateLabelPositions = true;
const areaInfo = areas.computeAreasAndBoundingBoxesFromEllipses(generateLabelPositions);
for (let i = 0; i < areas.zoneStrings.length; i++) {
const zoneLabel = areas.zoneStrings[i];
const labelPosition = areaInfo.zoneLabelPositions[zoneLabel];
if (labelPosition !== undefined) {
const labelX = (labelPosition.x + translateX) * scaling;
const labelY = (labelPosition.y + translateY) * scaling;
if (!isNaN(labelX)) {
nextSVG = `<text dominant-baseline="middle" text-anchor="middle" x="${labelX}" y="${labelY}" style="font-family: ${labelFont}; font-size: ${valueSize}px;" fill="black">${state.originalProportions[i]}</text>\n`;
svgString += nextSVG;
}
}
}
}
svgString += "</svg>\n";
return svgString;
}
/**
* This returns a transformation to fit the diagram in the given size
*/
function findTransformationToFit(width, height, areas, labelMaxWidth, labelMaxHeight) {
const idealWidth = width - 15 - labelMaxWidth * 2;
const idealHeight = height - 15 - labelMaxHeight * 2;
const bb = areas.computeAreasAndBoundingBoxesFromEllipses();
const currentWidth = bb.overallBoundingBox.p2.x - bb.overallBoundingBox.p1.x;
const currentHeight = bb.overallBoundingBox.p2.y - bb.overallBoundingBox.p1.y;
const currentCentreX = (bb.overallBoundingBox.p1.x + bb.overallBoundingBox.p2.x) / 2;
const currentCentreY = (bb.overallBoundingBox.p1.y + bb.overallBoundingBox.p2.y) / 2;
const widthMultiplier = idealWidth / currentWidth;
const heightMultiplier = idealHeight / currentHeight;
const scaling = Math.min(heightMultiplier, widthMultiplier);
const desiredCentreX = width / 2 / scaling;
const desiredCentreY = height / 2 / scaling;
const translateX = desiredCentreX - currentCentreX;
const translateY = desiredCentreY - currentCentreY;
return { scaling, translateX, translateY };
}
export function textDimensions(strings, td) {
const widths = [];
const heights = [];
let maxHeight = 0;
let maxWidth = 0;
for (let i = 0; i < strings.length; i++) {
const { width, height } = td.measure(String(strings[i]));
widths[i] = width;
heights[i] = height;
maxHeight = Math.max(maxHeight, heights[i]);
maxWidth = Math.max(maxWidth, widths[i]);
}
return {
widths,
heights,
maxHeight,
maxWidth,
};
}