UNPKG

edeap

Version:

Euler Diagrams Drawn with Ellipses Area-Proportionally (Edeap)

1,038 lines (1,037 loc) 49.2 kB
// // Author: Michael Wybrow <Michael.Wybrow@monash.edu> // import { distanceBetween, ellipseBoundingBox, isInEllipse } from "./geometry.js"; import { logFitnessDetails, logMessage } from "./logMessage.js"; import { gridSize, nextGridPoint, nextGridValue, prevGridPoint, prevGridValue, } from "./grid.js"; // ### AREA TEST DEBUG START //let paramsArray = []; //let labelsArray = []; //let lastParams = ""; // ### AREA TEST DEBUG END export class EdeapAreas { constructor(state) { Object.defineProperty(this, "areaSampleStep", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "maxTotalAreaDiff", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "zoneStrings", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "proportions", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "contours", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "ellipseParams", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "ellipseMap", { enumerable: true, configurable: true, writable: true, value: void 0 }); // The amount to step when doing area samples for ellipses. // We want to calculate this initially and then use the same step size // while the optimising the diagram. this.areaSampleStep = 0; this.maxTotalAreaDiff = 0; this.zoneStrings = state.zoneStrings; this.proportions = state.proportions; this.contours = state.contours; this.ellipseParams = state.ellipseParams; this.ellipseMap = new Map(); } // This function uses the ellipses definitions to return various // data needed to compute the fitness function. It returns: // * The overall bounding box of all ellipses. // * An array with the bounding box of each ellipse. // * The sets of labelled zones including the proportional area // for each zone. // // Parameters: // generateLabelPositions (Optional, default: false) Determines // whether ideal label positions are // generated. This is slow. // // Return value: // An object containing the following properties: // overallBoundingBox The bounding box of all ellipses. // zoneAreaProportions An object with a property for each zone // where the property value is the proportion. // boundingBoxes An array of bounding boxes where the order // matches the order of the ellipse information // arrays. // zoneAveragePositions Average position of samples in each zone. // zoneLabelPositions Ideal label position for each zone. Only // generated if generateLabelPositions is true. // computeAreasAndBoundingBoxesFromEllipses(generateLabelPositions = false) { // ### AREA TEST DEBUG START // if (generateLabelPositions) { // let paramsString = JSON.stringify(this.ellipseParams); // if (paramsString !== lastParams) { // paramsArray.push(this.ellipseParams); // labelsArray.push(this.contours); // lastParams = paramsString; // console.log(JSON.stringify(paramsArray)); // console.log(JSON.stringify(labelsArray)); // } // } // ### AREA TEST DEBUG END // !!! Now need this for computing split zones. //generateLabelPositions = true; let maxAOrB = 0; for (let i = 0; i < this.ellipseParams.length; i++) { maxAOrB = Math.max(this.ellipseParams[i].A, this.ellipseParams[i].B, maxAOrB); } const ellipseNonOverlapPadding = 0.15 * maxAOrB; // Calculate the combined bounding box of all ellipses. const totalBB = { p1: { x: Number.MAX_VALUE, y: Number.MAX_VALUE, }, p2: { x: Number.MIN_VALUE, y: Number.MIN_VALUE, }, }; const ellipseBoundingBoxes = []; for (let i = 0; i < this.ellipseParams.length; i++) { // Expand the total bounding box edges to accomodate this // ellipse. const ellipse = this.ellipseParams[i]; const bb = ellipseBoundingBox(ellipse); ellipseBoundingBoxes.push(bb); totalBB.p1.x = Math.min(totalBB.p1.x, bb.p1.x); totalBB.p1.y = Math.min(totalBB.p1.y, bb.p1.y); totalBB.p2.x = Math.max(totalBB.p2.x, bb.p2.x); totalBB.p2.y = Math.max(totalBB.p2.y, bb.p2.y); } const oversizedBB = { p1: { x: totalBB.p1.x - ellipseNonOverlapPadding, y: totalBB.p1.y - ellipseNonOverlapPadding, }, p2: { x: totalBB.p2.x + ellipseNonOverlapPadding, y: totalBB.p2.y + ellipseNonOverlapPadding, }, }; const diffX = oversizedBB.p2.x - oversizedBB.p1.x; const diffY = oversizedBB.p2.y - oversizedBB.p1.y; if (this.areaSampleStep === 0) { // Work out step size so we sample at least MAX_DIM_SAMPLES // in the smaller of the two dimensions. //const MAX_DIM_SAMPLES = 50; //let diffMin = Math.min(diffX, diffY); //this.areaSampleStep = nextGridValue(diffMin / MAX_DIM_SAMPLES); // XXX: Use fixed grid size this.areaSampleStep = gridSize; logMessage(logFitnessDetails, "Area sample step: " + this.areaSampleStep); } let areaSampleStep = this.areaSampleStep; // For each point in the overall bounding box, check which ellipses // it is inside to determine its zone. let zoneInfo = new Map(); let totalPoints = 0; let expandedZonePoints = {}; let expandedTotalPoints = 0; let bitmapSizeX = Math.ceil(diffX / areaSampleStep) + 1; let bitmapSizeY = Math.ceil(diffY / areaSampleStep) + 1; let length = bitmapSizeX * bitmapSizeY; // Always align the area sampling point with a multiple of the // areaSampleStep. Otherwise we could get different values for // some ellipses if the left or top of the bounding box changes. let startX = prevGridValue(oversizedBB.p1.x); let startY = prevGridValue(oversizedBB.p1.y); let endX = nextGridValue(oversizedBB.p2.x); let endY = nextGridValue(oversizedBB.p2.y); // ### AREA TEST DEBUG START // let movedX1 = oversizedBB.p1.x - startX; // let movedY1 = oversizedBB.p1.y - startY; // let movedX2 = endX - oversizedBB.p2.x; // let movedY2 = endY - oversizedBB.p2.y; // ### AREA TEST DEBUG END let ellipseKeys = []; // let expandedEllipseKeys = []; for (let i = 0; i < this.ellipseParams.length; i++) { let ellipse = this.ellipseParams[i]; const ellipseMapKey = [ ellipse.X, ellipse.Y, ellipse.A, ellipse.B, ellipse.R, ].join(":"); const ellipseHitInfo = this.ellipseMap.get(ellipseMapKey); if (ellipseHitInfo === undefined) { // Expand the total bounding box edges to accomodate this // ellipse. const bb = ellipseBoundingBox(ellipse); let oversizedBB = { p1: { x: bb.p1.x - ellipseNonOverlapPadding, y: bb.p1.y - ellipseNonOverlapPadding, }, p2: { x: bb.p2.x + ellipseNonOverlapPadding, y: bb.p2.y + ellipseNonOverlapPadding, }, }; let diffX = oversizedBB.p2.x - oversizedBB.p1.x; let diffY = oversizedBB.p2.y - oversizedBB.p1.y; let hitmapSizeX = Math.ceil(diffX / gridSize) + 1; let hitmapSizeY = Math.ceil(diffY / gridSize) + 1; let hitmapLength = hitmapSizeX * hitmapSizeY; let hitInfo = { smallHitArray: new Array(hitmapLength), smallHitArraySizeX: hitmapSizeX, position: prevGridPoint(oversizedBB.p1), endPosition: nextGridPoint(oversizedBB.p2), }; ellipseKeys[i] = hitInfo; this.ellipseMap.set(ellipseMapKey, hitInfo); let hitArray = hitInfo.smallHitArray; let yCounter = 0; for (let y = hitInfo.position.y; y <= hitInfo.endPosition.y; y = y + gridSize) { let xCounter = 0; for (let x = hitInfo.position.x; x <= hitInfo.endPosition.x; x = x + gridSize) { let index = yCounter * hitmapSizeX + xCounter; let inside = isInEllipse(x, y, ellipse.X, ellipse.Y, ellipse.A, ellipse.B, ellipse.R); let insideValue = 0; if (inside) { insideValue = 1; } else { // Not inside ellipse, see if inside expanded ellipse inside = isInEllipse(x, y, ellipse.X, ellipse.Y, ellipse.A + ellipseNonOverlapPadding, ellipse.B + ellipseNonOverlapPadding, ellipse.R); if (inside) { insideValue = 2; } } hitArray[index] = insideValue; xCounter++; } yCounter++; } } else { ellipseKeys[i] = ellipseHitInfo; } } let lastZoneInfoVal; let lastZone = null; // ### AREA TEST DEBUG START // let xRange = endX - startX; // let yRange = endY - startY; // ### AREA TEST DEBUG END let yCounter = 0; let xCounter = 0; for (let y = startY; y <= endY; y = y + areaSampleStep) { xCounter = 0; for (let x = startX; x <= endX; x = x + areaSampleStep) { // zone is a list of sets. let sets = []; let expandedSets = []; for (let i = 0; i < this.ellipseParams.length; i++) { let ellipseAreaInfo = ellipseKeys[i]; let inside = 0; if (x >= ellipseAreaInfo.position.x && x <= ellipseAreaInfo.endPosition.x && y >= ellipseAreaInfo.position.y && y <= ellipseAreaInfo.endPosition.y) { let effectiveX = x - ellipseAreaInfo.position.x; let effectiveY = y - ellipseAreaInfo.position.y; let xCounter = Math.floor(effectiveX / gridSize); let yCounter = Math.floor(effectiveY / gridSize); let index = yCounter * ellipseAreaInfo.smallHitArraySizeX + xCounter; inside = ellipseAreaInfo.smallHitArray[index]; if (inside === 1) { // For each set the point is inside, add the label // for the set to the sets list. let label = this.contours[i]; sets.push(label); expandedSets.push(label); } else if (inside === 2) { let label = this.contours[i]; expandedSets.push(label); } } } // zone string is just the sets list stringified. if (sets.length > 0) { let zone = sets.toString(); let zoneInfoVal; if (zone === lastZone) { zoneInfoVal = lastZoneInfoVal; } else { zoneInfoVal = zoneInfo.get(zone); if (zoneInfoVal === undefined) { // Zone has not been seen, add it with 1 point. zoneInfoVal = { points: 0, avgPos: { x: 0, y: 0, count: 0, firstX: x, firstY: y, firstXIndex: xCounter, firstYIndex: yCounter, }, }; zoneInfo.set(zone, zoneInfoVal); if (generateLabelPositions) { // Create the empty bitmap for zone. zoneInfoVal.bitmap = new Array(length).fill(false); } } lastZone = zone; lastZoneInfoVal = zoneInfoVal; } if (zoneInfoVal) { // Zone has been seen, increment points count. zoneInfoVal.points++; if (generateLabelPositions && zoneInfoVal.bitmap) { // Mark point in zone bitmap. zoneInfoVal.bitmap[yCounter * bitmapSizeX + xCounter] = true; } // Add this position to the x and y total, // so we can compute average position later. zoneInfoVal.avgPos.x += x; zoneInfoVal.avgPos.y += y; zoneInfoVal.avgPos.count += 1; } // Update totalPoints if point is not in empty set. totalPoints++; } if (expandedSets.length > 0) { // zone string is just the sets list stringified. let expandedZone = expandedSets.toString(); if (expandedZonePoints.hasOwnProperty(expandedZone)) { // Zone has been seen, increment points count. expandedZonePoints[expandedZone] += 1; } else { // Zone has not been seen, add it with 1 point. expandedZonePoints[expandedZone] = 1; } // Update expandedTotalPoints if point is not in empty set. expandedTotalPoints++; } xCounter++; } yCounter++; } for (let i = 0; i < this.ellipseParams.length; i++) { let ellipseAreaInfo = ellipseKeys[i]; ellipseAreaInfo.needsFilling = false; } // For each zone, calculate the proportion of its area of the // total area of all zones other than the non-labelled zone. const zoneProportions = {}; // ### AREA TEST DEBUG START // let zoneSamples = {}; // ### AREA TEST DEBUG END for (const [key, value] of zoneInfo.entries()) { const proportion = value.points / totalPoints; zoneProportions[key] = proportion; // ### AREA TEST DEBUG START // zoneSamples[key] = value.points; // ### AREA TEST DEBUG END } // For each expanded zone, calculate the proportion of its area of the // total area of all zones other than the non-labelled zone. const expandedZoneProportions = {}; for (const property in expandedZonePoints) { const proportion = expandedZonePoints[property] / expandedTotalPoints; expandedZoneProportions[property] = proportion; } const splitZoneAreaProportions = {}; const result = { overallBoundingBox: totalBB, boundingBoxes: ellipseBoundingBoxes, zoneAreaProportions: zoneProportions, // ### AREA TEST DEBUG START // zoneSamples: zoneSamples, // xCount: xCounter, // yCount: yCounter, // xRange: xRange, // yRange: yRange, // movedX1, // movedY1, // movedX2, // movedY2, // totalSamples: totalPoints, // ### AREA TEST DEBUG START splitZoneAreaProportions: splitZoneAreaProportions, expandedZoneAreaProportions: expandedZoneProportions, zoneLabelPositions: undefined, zoneAveragePositions: undefined, }; // Return the point in each zone with the widest x and y coord. const zoneLabelPositions = {}; for (let [zone, zoneValue] of zoneInfo.entries()) { const bitmap = zoneValue.bitmap; if (bitmap === undefined) continue; // Scan the bitmap of points within this zone in order to determine // the number of disconnected fragments. We do this by flood-filling // with the fillFragment function on the zoneFragmentMap array. After // this process zoneFragmentSizes will hold the size in sampled points // of each of the regions. const zoneFragmentSizes = []; const zoneFragmentMap = new Array(length).fill(0); for (let y = 0; y < bitmapSizeY; y++) { for (let x = 0; x < bitmapSizeX; x++) { const index = y * bitmapSizeX + x; const inZone = bitmap[index]; if (inZone) { if (zoneFragmentMap[index] === 0) { // This is a unnumbered fragment (i.e., a fragment of // the zone we haven't encountered yet), number all // adjoining points as part of this fragment and // then record the size of the fragment. const size = this._fillFragment(zoneFragmentMap, bitmap, x, y, bitmapSizeX, bitmapSizeY, zoneFragmentSizes.length + 1); zoneFragmentSizes.push(size); } } } } if (zoneFragmentSizes.length > 1) { // For zones with more than one fragment, i.e., split zones, // return the sum of the proportion of the size of all fragments // after the first. // First, order smallest to largest. We want the optimiser to // shrink smaller fragments. zoneFragmentSizes.sort(function orderLargestFirst(a, b) { return b - a; }); let points = 0; for (let index = 1; index < zoneFragmentSizes.length; index++) { // For each fragment beyond the first. if (zoneFragmentSizes[index] < 10) { // Ignore small fragments of less than 10 points. these // can occur when ellipses have overlapping sizes. continue; } // Count the number of points in the fragment of the zone. points += zoneFragmentSizes[index]; } const proportion = points / totalPoints; logMessage(logFitnessDetails, `Split zone: ${zone} : ${zoneFragmentSizes} penalty area: ${proportion}`); splitZoneAreaProportions[zone] = proportion; } else { splitZoneAreaProportions[zone] = 0; } let zoneAvgPos = zoneInfo.get(zone).avgPos; // Update average points. zoneAvgPos.x /= zoneAvgPos.count; zoneAvgPos.y /= zoneAvgPos.count; // @ts-expect-error delete zoneAvgPos.count; let x = zoneAvgPos.firstXIndex; let y = zoneAvgPos.firstYIndex; // @ts-expect-error delete zoneAvgPos.firstXIndex; // @ts-expect-error delete zoneAvgPos.firstYIndex; if (generateLabelPositions) { function isInRegion(x, y) { let index = y * bitmapSizeX + x; if (index >= 0 && index < length) { return bitmap[index] ? 1 : 0; } return 0; } function pointFreedomCount(x, y) { let neighbourCount = 0; neighbourCount += isInRegion(x, y); if (neighbourCount === 0) { return 0; } neighbourCount += isInRegion(x - 1, y); neighbourCount += isInRegion(x + 1, y); neighbourCount += isInRegion(x, y - 1); neighbourCount += isInRegion(x, y + 1); return neighbourCount; } const centreX = Math.floor((zoneAvgPos.x - oversizedBB.p1.x) / areaSampleStep); const centreY = Math.floor((zoneAvgPos.y - oversizedBB.p1.y) / areaSampleStep); if (centreX > 0 && centreY > 0 && bitmap[centreY * bitmapSizeX + centreX] === true) { x = centreX; y = centreY; } // If the candidate point doesn't have freedom on all sides, // try and pick one that does. if (pointFreedomCount(x, y) < 5) { let freePoint = null; for (let ty = 0; ty < bitmapSizeY && !freePoint; ty++) { for (let tx = 0; tx < bitmapSizeX && !freePoint; tx++) { if (pointFreedomCount(tx, ty) === 5) { freePoint = { x: tx, y: ty }; } } } if (freePoint) { x = freePoint.x; y = freePoint.y; } } let movement = true; let limit = 20; while (movement && limit > 0) { movement = false; --limit; // Move to the center in the x dimension. let xMin = x; let xMax = x; while (bitmap[y * bitmapSizeX + xMin] && xMin > 0) { xMin--; } while (bitmap[y * bitmapSizeX + xMax] && xMax < bitmapSizeX) { xMax++; } const xMid = xMin + Math.floor((xMax - xMin) / 2); if (x !== xMid) { movement = true; x = xMid; } // Move to the center in the y dimension. let yMin = y; let yMax = y; while (bitmap[yMin * bitmapSizeX + x] && yMin > 0) { yMin--; } while (bitmap[yMax * bitmapSizeX + x] && yMax < bitmapSizeY) { yMax++; } const yMid = yMin + Math.floor((yMax - yMin) / 2); if (y !== yMid) { movement = true; y = yMid; } } // Calculate and return the actual point. let xPos = startX + x * areaSampleStep; let yPos = startY + y * areaSampleStep; zoneLabelPositions[zone] = { x: xPos, y: yPos, }; } } if (generateLabelPositions) { result.zoneLabelPositions = zoneLabelPositions; } let zoneAvgPosArray = {}; for (let [label, value] of zoneInfo.entries()) { zoneAvgPosArray[label] = value.avgPos; } result.zoneAveragePositions = zoneAvgPosArray; return result; } // Recursive flood-fill to find connected fragments of zones. // zoneFragmentMap is an array of the sampled points with their fragment, or 0. // bitmap is an array with sampled points. true if in zone, false if outside. // x and y are the point in the map to consider. // bitmapSize{X,Y} are the size of zoneFragmentMap and bitmap. // zoneFragmentN is the number we are assigning to the fragment we are filling. // _fillFragment(zoneFragmentMap, bitmap, x, y, bitmapSizeX, bitmapSizeY, zoneFragmentN) { let toTest = []; let total = 0; function tryFill(x, y) { if (y >= 0 && y < bitmapSizeY && x >= 0 && x < bitmapSizeX) { let index = y * bitmapSizeX + x; if (bitmap[index]) { // Is in zone. if (zoneFragmentMap[index] === 0) { toTest.push([x, y]); // And not assignment to a fragment. zoneFragmentMap[index] = zoneFragmentN; total++; } } } } tryFill(x, y); while (toTest.length > 0) { const point = toTest.pop(); const x = point[0]; const y = point[1]; // Try the surrounding cells. tryFill(x, y - 1); tryFill(x, y + 1); tryFill(x - 1, y); tryFill(x + 1, y); } return total; } // This is experimental _missingZonePenalty(missingZone, fitnessData, fitness) { const zonePositions = fitnessData.zoneAveragePositions; const labelsInZoneB = missingZone.split(","); if (labelsInZoneB.length === 1) { // Missing zone with one label. // Push this ellipse away from other ellipses. const ellipseIndex = this.contours.indexOf(labelsInZoneB[0]); const ellipseC = this.ellipseParams[ellipseIndex]; const cx = ellipseC.X; const cy = ellipseC.Y; const ca = ellipseC.A; const cb = ellipseC.B; for (let e = 0; e < this.contours.length; e++) { if (e !== ellipseIndex) { let ellipseE = this.ellipseParams[e]; const ex = ellipseE.X; const ey = ellipseE.Y; const ea = ellipseE.A; const eb = ellipseE.B; const separation = Math.max(ea, eb) + Math.max(ca, cb); const dist = Math.max(0, separation - distanceBetween(cx, cy, ex, ey)); fitness.missingOneLabelZone += dist; } } } else if (labelsInZoneB.length >= 2) { // For a missing zone B... logMessage(logFitnessDetails, ` + Missing zone: ${missingZone}`); let zoneWithMostSharedLabels = null; let numberOfMostSharedLabels = 0; let labelsOfZoneWithMostSharedLabels = []; // Find the actual zone C that shares the most labels with B for (const existingZone in fitnessData.zoneAreaProportions) { // Count common labels between C and B let count = 0; const existingZoneLabels = existingZone.split(","); for (let i = 0; i < labelsInZoneB.length; i++) { if (existingZoneLabels.includes(labelsInZoneB[i])) { ++count; } } if (count > numberOfMostSharedLabels) { // If C had more than current largest, make it the largest. numberOfMostSharedLabels = count; zoneWithMostSharedLabels = existingZone; labelsOfZoneWithMostSharedLabels = existingZoneLabels; } else if (count > 0 && count === numberOfMostSharedLabels) { // If there is a tie, take the zone with fewer labels. if (existingZoneLabels.length > labelsOfZoneWithMostSharedLabels.length) { numberOfMostSharedLabels = count; zoneWithMostSharedLabels = existingZone; } } } // If we found a zone C that shares labels with B... if (zoneWithMostSharedLabels) { logMessage(logFitnessDetails, ` + Zone with most shared labels: ${zoneWithMostSharedLabels}`); // Find the set of labels in B that are not in C. const labelsInZoneBButNotZoneC = labelsInZoneB.slice(); const labelsInZoneC = zoneWithMostSharedLabels.split(","); for (let j = 0; j < labelsInZoneC.length; ++j) { const index = labelsInZoneBButNotZoneC.indexOf(labelsInZoneC[j]); if (index !== -1) { labelsInZoneBButNotZoneC.splice(index, 1); } } // A point known to be in zone C. let zx = zonePositions[zoneWithMostSharedLabels].firstX; let zy = zonePositions[zoneWithMostSharedLabels].firstY; logMessage(logFitnessDetails, ` + x: ${zx}, y: ${zy}`); // For each label in B that is not in C for (let j = 0; j < labelsInZoneBButNotZoneC.length; ++j) { const labelInZoneB = labelsInZoneBButNotZoneC[j]; logMessage(logFitnessDetails, ` + Other label: ${labelInZoneB} (pull closer)`); // Pull the ellipse for that label from B closer to... const jEllipseIndex = this.contours.indexOf(labelInZoneB); const jx = this.ellipseParams[jEllipseIndex].X; const jy = this.ellipseParams[jEllipseIndex].Y; // ... the point known to be in zone C. fitness.missingTwoOrMoreLabelZone += distanceBetween(jx, jy, zx, zy); } if (numberOfMostSharedLabels === labelsInZoneB.length) { // Find the set of labels in C that are not in B. const labelsInZoneCButNotZoneB = labelsInZoneC.slice(); for (let j = 0; j < labelsInZoneB.length; ++j) { const index = labelsInZoneCButNotZoneB.indexOf(labelsInZoneB[j]); if (index !== -1) { labelsInZoneCButNotZoneB.splice(index, 1); } } // A point known to be in zone C. zx = zonePositions[zoneWithMostSharedLabels].firstX; zy = zonePositions[zoneWithMostSharedLabels].firstY; logMessage(logFitnessDetails, ` + x: ${zx}, y: ${zy}`); // For each label in C that is not in B (i.e., not desired)... for (let j = 0; j < labelsInZoneCButNotZoneB.length; ++j) { const labelInZoneC = labelsInZoneCButNotZoneB[j]; logMessage(logFitnessDetails, ` + Other label: ${labelInZoneC} (push away)`); // Push away each of ellipses in Zone B. for (let k = 0; k < labelsInZoneB.length; ++k) { logMessage(logFitnessDetails, ` + Separate: ${labelInZoneC}, ${labelsInZoneB[k]}`); // Push the ellipse for that label away from... const jEllipseIndex = this.contours.indexOf(labelInZoneC); const ellipseJ = this.ellipseParams[jEllipseIndex]; const jx = ellipseJ.X; const jy = ellipseJ.Y; const ja = ellipseJ.A; const jb = ellipseJ.B; const jr = Math.max(ja, jb); const ellipseIndex = this.contours.indexOf(labelsInZoneB[k]); const ellipseK = this.ellipseParams[ellipseIndex]; const kx = ellipseK.X; const ky = ellipseK.Y; const ka = ellipseK.A; const kb = ellipseK.B; const kr = Math.max(ka, kb); // Subtract the distance between two points from the minimum separation. const diff = kr + jr - distanceBetween(kx, ky, jx, jy); // ... the point known to be in zone C. fitness.missingTwoOrMoreLabelZone += diff; } } } } } } // This is experimental _unwantedZonePenalty(zone, difference, fitness) { logMessage(logFitnessDetails, ` + Unwanted zone: ${zone}`); const zonesArray = zone.split(","); // For an undesired actual zone A... // Check that there is no desired zone B... for (let i = 0; i < this.zoneStrings.length; ++i) { // That has all the labels from zone A let count = 0; const desiredZoneLabels = this.zoneStrings[i].split(","); for (let k = 0; k < zonesArray.length; ++k) { if (desiredZoneLabels.includes(zonesArray[k])) { ++count; } } // If there is... if (count === zonesArray.length) { // If it is just a single label unwanted zone, if (zonesArray.length === 1) { // Penalise this. fitness.unwantedZone += difference; return true; } else { // Otherwise there is a desired zone that has all the labels // of zone, so none of the ellipses involved in this zone // need to be moved apart. return false; } } } // If there isn't a desired zone that has all the labels of zone A... // Start with a factor of 1. let factor = 1; // The consider every pair of labels in zone A... for (let i = 0; i < zonesArray.length; i++) { const iZone = zonesArray[i]; for (let j = i + 1; j < zonesArray.length; j++) { const jZone = zonesArray[j]; // For the pair of labels, check there is no desired zone // with both those labels... let isDesiredZoneContainingBothLabels = false; for (let k = 0; k < this.zoneStrings.length; ++k) { const desiredZoneLabels = this.zoneStrings[k].split(","); if (desiredZoneLabels.includes(iZone) && desiredZoneLabels.includes(jZone)) { isDesiredZoneContainingBothLabels = true; break; } } if (isDesiredZoneContainingBothLabels === false) { // For each ellipse beyond the first we increase the factor by 0.1. factor += 0.1; // If there isn't, try and push the ellipses for // these two labels away from each other. logMessage(logFitnessDetails, ` + Separate: ${zonesArray[i]}, ${zonesArray[j]}`); // Missing zone with one label. // Push this ellipse away from other ellipses. let ellipseIndex = this.contours.indexOf(zonesArray[i]); const ellipseI = this.ellipseParams[ellipseIndex]; const ix = ellipseI.X; const iy = ellipseI.Y; const ia = ellipseI.A; const ib = ellipseI.B; const ir = Math.max(ia, ib); ellipseIndex = this.contours.indexOf(zonesArray[j]); const ellipseJ = this.ellipseParams[ellipseIndex]; const jx = ellipseJ.X; const jy = ellipseJ.Y; const ja = ellipseJ.A; const jb = ellipseJ.B; const jr = Math.max(ja, jb); // Subtract the distance between the two ellipse centres from // the minimum separation. const diff = ir + jr - distanceBetween(ix, iy, jx, jy); fitness.unwantedZone += diff * factor; } } } return true; } // Compute the fitness components of the ellipses layout. computeFitnessComponents() { // let penalty = 0; const fitnessData = this.computeAreasAndBoundingBoxesFromEllipses(); // Build up a set of all desired and actual zones. const allZones = new Set(); for (const zone in fitnessData.zoneAreaProportions) { allZones.add(zone); } for (let i = 0; i < this.zoneStrings.length; ++i) { const zone = this.zoneStrings[i]; allZones.add(zone); } logMessage(logFitnessDetails, "Fitness calculation:"); const fitness = { zoneAreaDifference: 0, unwantedZone: 0, circleDistortion: 0, splitZone: 0, missingOneLabelZone: 0, missingTwoOrMoreLabelZone: 0, unwantedExpandedOverlap: 0, }; // Save us recomputing these in nested loops. let splitGlobalZoneStrings = []; for (let k = 0; k < this.zoneStrings.length; ++k) { splitGlobalZoneStrings[k] = this.zoneStrings[k].split(","); } const nonOverlappingPairs = []; // Compute ellipses we don't want to be overlapping or touching. for (let i = 0; i < this.contours.length; i++) { const labelI = this.contours[i]; for (let j = i + 1; j < this.contours.length; j++) { const labelJ = this.contours[j]; let shouldOverlap = false; for (let k = 0; k < this.zoneStrings.length; ++k) { const desiredZoneLabels = splitGlobalZoneStrings[k]; if (desiredZoneLabels.includes(labelI) && desiredZoneLabels.includes(labelJ)) { shouldOverlap = true; break; } } if (shouldOverlap === false) { const pair = labelI + "," + labelJ; nonOverlappingPairs.push(pair); } } } for (let i = 0; i < nonOverlappingPairs.length; i++) { const labels = nonOverlappingPairs[i].split(","); const labelA = labels[0]; const labelB = labels[1]; let overlapPercentage = 0; for (const zone in fitnessData.expandedZoneAreaProportions) { const zoneLabels = zone.split(","); if (zoneLabels.includes(labelA) && zoneLabels.includes(labelB)) { overlapPercentage += fitnessData.expandedZoneAreaProportions[zone]; } } if (overlapPercentage > 0) { logMessage(logFitnessDetails, ` + Unwanted expanded overlap: ${labelA}, ${labelB}`); fitness.unwantedExpandedOverlap += overlapPercentage; } } const fullyContainedPairs = []; // Compute ellipses that are fully contained in other ellipses (which // we don't want to have unnecessarily overlapping borders). for (let i = 0; i < this.contours.length; i++) { const labelI = this.contours[i]; for (let j = 0; j < this.contours.length; j++) { if (i === j) { // Don't consider same label. continue; } const labelJ = this.contours[j]; let iIsOnlyContainedInJ = true; let jExistsOutsideOfI = false; for (let k = 0; k < this.zoneStrings.length; ++k) { const desiredZoneLabels = splitGlobalZoneStrings[k]; if (desiredZoneLabels.includes(labelI) && !desiredZoneLabels.includes(labelJ)) { iIsOnlyContainedInJ = false; } if (!desiredZoneLabels.includes(labelI) && desiredZoneLabels.includes(labelJ)) { jExistsOutsideOfI = true; } } if (iIsOnlyContainedInJ && jExistsOutsideOfI) { // Ellipse with label I is fully contained in ellipse with label J. // console.log(labelI + " is fully contained within " + labelJ); const pair = labelI + "," + labelJ; fullyContainedPairs.push(pair); } } } // For each desired or actual zone add to the fitness value the // difference between the actual and desired area. for (const zone of allZones) { let zoneIsUnwanted = false; // let zoneIsMissing = false; logMessage(logFitnessDetails, ` + Zone: ${zone}`); let actualAreaProportion = 0; if (fitnessData.zoneAreaProportions.hasOwnProperty(zone)) { actualAreaProportion = fitnessData.zoneAreaProportions[zone]; } else { // zoneIsMissing = true; this._missingZonePenalty(zone, fitnessData, fitness); } let desiredAreaProportion = 0; const zoneIndex = this.zoneStrings.indexOf(zone); if (zoneIndex !== -1) { desiredAreaProportion = this.proportions[zoneIndex]; } else { zoneIsUnwanted = true; } logMessage(logFitnessDetails, ` + Actual: ${actualAreaProportion.toFixed(4)}, Desired: ${desiredAreaProportion.toFixed(4)}`); const difference = Math.abs(actualAreaProportion - desiredAreaProportion); if (zoneIsUnwanted) this._unwantedZonePenalty(zone, difference, fitness); fitness.zoneAreaDifference += difference; // Fitness component for split zones. if (fitnessData.splitZoneAreaProportions.hasOwnProperty(zone)) { fitness.splitZone += fitnessData.splitZoneAreaProportions[zone]; } } // Add to the fitness the difference between radiuses -- // we prefer circles over ellipses. for (let i = 0; i < this.contours.length; ++i) { fitness.circleDistortion += 1 - Math.min(this.ellipseParams[i].A, this.ellipseParams[i].B) / Math.max(this.ellipseParams[i].A, this.ellipseParams[i].B); } fitness.circleDistortion /= this.contours.length; logMessage(logFitnessDetails, "Fitness components:"); for (const fitnessProperty in fitness) { logMessage(logFitnessDetails, ` + ${fitnessProperty}: ${fitness[fitnessProperty]}`); } return fitness; } zoneAreaTableBody() { const areaData = this.computeAreasAndBoundingBoxesFromEllipses(); // Build up a set of all desired and actual zones. const zonesArray = [ ...new Set([ ...Object.keys(areaData.zoneAreaProportions), ...this.zoneStrings, ]), ]; const closureZoneStrings = this.zoneStrings; const closureProportions = this.proportions; zonesArray.sort((a, b) => { let zoneIndex = closureZoneStrings.indexOf(a); const desiredAreaProportionA = zoneIndex !== -1 ? closureProportions[zoneIndex] : 0; zoneIndex = closureZoneStrings.indexOf(b); const desiredAreaProportionB = zoneIndex !== -1 ? closureProportions[zoneIndex] : 0; const result = desiredAreaProportionB - desiredAreaProportionA; if (result === 0) return a.localeCompare(b); return result; }); let tbody = ""; let csvText = "Zone,Desired,Actual,Difference\n"; let totalAreaDifference = 0; for (const index in zonesArray) { const zone = zonesArray[index]; let extraClass = ""; let desiredAreaProportion = 0; const zoneIndex = this.zoneStrings.indexOf(zone); if (zoneIndex !== -1) { desiredAreaProportion = this.proportions[zoneIndex]; } let actualAreaProportion = 0; if (areaData.zoneAreaProportions.hasOwnProperty(zone)) { actualAreaProportion = areaData.zoneAreaProportions[zone]; } if (desiredAreaProportion === 0) { extraClass += " unwanted"; } else if (actualAreaProportion === 0) { extraClass += " missing"; } const difference = Math.abs(desiredAreaProportion - actualAreaProportion); totalAreaDifference += difference; tbody += `<tr> <td class="first${extraClass}">${zone}</td> <td class="other${extraClass}">${(desiredAreaProportion * 100).toFixed(1)}</td> <td class="other${extraClass}">${(actualAreaProportion * 100).toFixed(1)}</td> <td class="other${extraClass}">${(difference * 100).toFixed(2)}</td> </tr>`; csvText += `"${zone}",${desiredAreaProportion * 100},${actualAreaProportion * 100},${difference * 100}\n`; } let totalAreaDiff = totalAreaDifference * 100; tbody += `<tr> <td><b>Total area diff:</b></td> <td colspan="3" class="other">${totalAreaDiff.toFixed(4)}</td> </tr>`; csvText += `Total:,,,${totalAreaDiff}\n`; // For comparison compare against venneuler(). // if (typeof VennEulerAreas === "function") { // let veAreas = new VennEulerAreas( // this.contours, // this.globalZoneStrings, // this.globalProportions // ); // veStress = veAreas.vennEulerAreasAndStress(this.ellipseParams); // // Venneuler stress: // tbody += "<tr>"; // tbody += "<td><b>Stress:</b></td>"; // tbody += '<td colspan="3" class="other">' + veStress.toFixed(4) + "</td>"; // tbody += "</tr>"; // } this.maxTotalAreaDiff = Math.max(this.maxTotalAreaDiff, totalAreaDiff); if (totalAreaDiff < this.maxTotalAreaDiff) { const PROGRESS_LENGTH = 2000; let progressValue = (1 - totalAreaDiff / this.maxTotalAreaDiff) * PROGRESS_LENGTH; tbody += `<tr class="progressRow"> <td colspan="4"> <progress id="optimizerProgress" style="width: 100%;" max="${PROGRESS_LENGTH}" value="${progressValue}"></progress> </td> </tr>`; } return tbody; } }