edeap
Version:
Euler Diagrams Drawn with Ellipses Area-Proportionally (Edeap)
548 lines (547 loc) • 25 kB
JavaScript
///
// Author: Fadi Dib <deeb.f@gust.edu.kw>
//
import { logMessage, logOptimizerStep, logOptimizerChoice } from "./logMessage.js";
function fixNumberPrecision(value) {
return Number(parseFloat(value).toPrecision(13));
}
/*********** Normalization starts here *******************/
const safetyValue = 0.000000000001; // a safety value to ensure that the normalized value will remain within the range so that the returned value will always be between 0 and 1
// this is a technique which is used whenever the measure has no upper bound.
// a function that takes the value which we need to normalize measureValueBeforeNorm and the maximum value of the measure computed so far
// we will get the maximum value we computed so far and we add a safety value to it
// to ensure that we don't exceed the actual upper bound (which is unknown for us)
function normalizeMeasure(measureValueBeforeNorm, maxMeasure) {
if (measureValueBeforeNorm > maxMeasure[0])
maxMeasure[0] = measureValueBeforeNorm; // update the maximum value of the measure if the new value is greater than the current max value
return measureValueBeforeNorm / (maxMeasure[0] + safetyValue); // normalized
}
export const HILL_CLIMBING = 1;
export const SIMULATED_ANNEALING = 2;
// Unlisted weights are assumed to be 1.
const weights = {
zoneAreaDifference: 16.35,
unwantedZone: 0.1,
splitZone: 0,
missingOneLabelZone: 27.6,
missingTwoOrMoreLabelZone: 12.35,
unwantedExpandedOverlap: 3.6,
circleDistortion: 0,
};
// values used in the movements of ellipse
const centerShift = 0.13; // previous value = 0.035 value of shifting the center point of the ellipse up, down, left, right
const radiusLength = 0.03; // previous value = 0.005 value of increasing/decreasing the length of the major/minor axis of the ellipse
const angle = 0.1; // previous value = 0.02 value of angle rotation
// Simulated annealing parameters
const coolDown = 0.8; // annealing cooling down
const maxIterations = 45; // annealing process maximum number of iterations
const tempIterations = 15; // number of annealing iterations at each temperature
function timeoutPromise(t = 0) {
return new Promise((succees) => setTimeout(succees, t));
}
const PI = Math.PI;
export class Optimizer {
constructor({ strategy, state, onStep, areas, }) {
Object.defineProperty(this, "strategy", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "state", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// a variable which indicates whether the optimizer should change its search space or not
Object.defineProperty(this, "changeSearchSpace", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "areas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// array to track the fitness value computed at each move
Object.defineProperty(this, "move", {
enumerable: true,
configurable: true,
writable: true,
value: []
});
// the value of the current computed fitness
Object.defineProperty(this, "currentFitness", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// annealing temperature
Object.defineProperty(this, "temp", {
enumerable: true,
configurable: true,
writable: true,
value: 0.75
});
Object.defineProperty(this, "currentAnnealingIteration", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "currentTemperatureIteration", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "selectedMove", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// to save the maximum value of a measure in a history of values of each measure to be used in the normalization process
Object.defineProperty(this, "maxMeasures", {
enumerable: true,
configurable: true,
writable: true,
value: {}
});
// a counter that stores number of solutions evaluated by Hill Climbing optimizer
Object.defineProperty(this, "HCEvalSolutions", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
// a counter that stores number of solutions evaluated by Simulated Annealing optimizer
Object.defineProperty(this, "SAEvalSolutions", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
// Variables to track animation steps.
Object.defineProperty(this, "completionAnimationStepN", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "scalingAnimationStep", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "translateXAnimationStep", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "translateYAnimationStep", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "progressAnimationStep", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "onStep", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.strategy = strategy || HILL_CLIMBING;
this.state = state;
this.onStep = onStep;
this.areas = areas;
}
optimize(sync = true) {
this.changeSearchSpace = false; // optimizer in first stage of search space
this.maxMeasures = {}; // to save the maximum value of a meausure in a history of values of each measure to be used in the normalization process
this.move = [];
this.HCEvalSolutions = 0; // initialize number of evaluated solutions (by hill climber) to zero
this.SAEvalSolutions = 0; // initialize number of evaluated solutions (by simulated annealing) to zero
// areas.ellipseMap = new Map();
this.currentAnnealingIteration = 0;
this.currentTemperatureIteration = 0;
this.currentFitness = this.computeFitness();
for (let elp = 0; elp < this.areas.contours.length; elp++ // for each ellipse
) {
this.printEllipseInfo(elp);
}
logMessage(logOptimizerStep, "Fitness %s", this.currentFitness);
return this.nextStep(sync);
}
nextStep(sync) {
if (sync) {
return this.optimizeStep(sync);
}
else {
return timeoutPromise().then(() => this.optimizeStep(sync));
}
}
optimizeStep(sync) {
let bestMoveFitness;
let bestMoveEllipse;
let bestMove;
if (this.strategy === HILL_CLIMBING) {
bestMoveFitness = this.currentFitness;
bestMoveEllipse = -1;
for (let elp = 0; elp < this.state.contours.length; elp++ // for each ellipse
) {
// if (this.state.duplicatedEllipseIndexes.includes(elp)) {
if (this.state.ellipseDuplication[elp] !== undefined) {
// Skip duplicated ellipses.
continue;
}
// For each ellipse check for best move.
logMessage(logOptimizerStep, this.state.contours[elp]);
const possibleFitness = this.selectBestCostMove(elp); // select the best move for each ellipse and saves its ID in var selectedMove and it also returns the fitness value at that move
logMessage(logOptimizerStep, "currentFitness %s", possibleFitness);
if (possibleFitness < bestMoveFitness && possibleFitness >= 0) {
// There is an improvement, remember it.
bestMove = this.selectedMove;
bestMoveEllipse = elp;
bestMoveFitness = possibleFitness;
}
}
if (bestMoveEllipse >= 0) {
this.changeSearchSpace = false; // use first search space
// There is a move better than the current fitness.
this.currentFitness = bestMoveFitness;
this.applyMove(bestMoveEllipse, bestMove);
if (this.onStep)
this.onStep(false);
return this.nextStep(sync);
}
else {
/* Disable this: */
}
}
else if (this.strategy === SIMULATED_ANNEALING) {
if (this.currentTemperatureIteration >= tempIterations) {
this.currentAnnealingIteration++;
this.currentTemperatureIteration = 0;
this.temp = this.temp * coolDown;
}
if (this.currentAnnealingIteration < maxIterations &&
this.currentTemperatureIteration < tempIterations) {
bestMoveFitness = this.currentFitness;
bestMoveEllipse = -1;
let found = false; // if a solution that satisfies the annealing criteria is found
for (let elp = 0; elp < this.state.contours.length && !found; elp++ // for each ellipse
) {
// if (this.state.duplicatedEllipseIndexes.includes(elp)) {
if (this.state.ellipseDuplication[elp] !== undefined) {
// Skip duplicated ellipses.
continue;
}
// For each ellipse check for best move.
logMessage(logOptimizerStep, this.state.contours[elp]);
const possibleFitness = this.selectRandomMove(elp); // select a random move (between 1 and 10) for each ellipse and saves its ID in var selectedMove and it also returns the fitness value at that move
logMessage(logOptimizerStep, "currentFitness %s", possibleFitness);
const fitnessDifference = possibleFitness - bestMoveFitness; // difference between the bestFitness so far and the fitness of the selected random move
const SAAccept = Math.exp((-1 * fitnessDifference) / this.temp); // Simulated annealing acceptance function
const SARand = Math.random(); // a random number between [0,1)
if (fitnessDifference < 0 || (SAAccept <= 1 && SARand < SAAccept)) {
// solution acceptance criteria
// move to a solution that satisfies the acceptance criteria of SA
bestMove = this.selectedMove;
bestMoveEllipse = elp;
bestMoveFitness = possibleFitness;
found = true;
}
}
if (found) {
// if a move is taken
this.changeSearchSpace = false; // first search space
this.currentFitness = bestMoveFitness;
this.applyMove(bestMoveEllipse, bestMove);
} // if no move is taken
else if (!this.changeSearchSpace) {
// switch to second search space
this.changeSearchSpace = true;
}
this.currentTemperatureIteration++;
if (this.onStep)
this.onStep(false);
return this.nextStep(sync);
}
}
if (this.onStep)
this.onStep(true);
logMessage(logOptimizerStep, "optimizer finished");
}
printEllipseInfo(elp) {
logMessage(logOptimizerStep, "Label = %s X = %s Y = %s A = %s B = %s R = %s", this.state.contours[elp], this.state.ellipseParams[elp].X, this.state.ellipseParams[elp].Y, this.state.ellipseParams[elp].A, this.state.ellipseParams[elp].B, this.state.ellipseParams[elp].R);
}
// This method takes ellipse number (elp) as a parameter, and checks which move gives the best fitness. it returns the fitness value along with the ID
// of the move returned in the global variable selectedMove
selectBestCostMove(elp) {
// select the best move of a given ellipse (elp)
this.move = [];
this.move[1] = this.centerX(elp, centerShift); // use positive and negative values to move right and left
this.move[2] = this.centerX(elp, -1 * centerShift);
this.move[3] = this.centerY(elp, centerShift); // use positive and negative values to move up and down
this.move[4] = this.centerY(elp, -1 * centerShift);
this.move[5] = this.radiusA(elp, radiusLength); // use positive and negative values to increase/decrease the length of the A radius
this.move[6] = this.radiusA(elp, -1 * radiusLength);
// Only test rotation if the ellipse is not a circle.
if (this.state.ellipseParams[elp].A !== this.state.ellipseParams[elp].B) {
this.move[7] = this.rotateEllipse(elp, angle);
this.move[8] = this.rotateEllipse(elp, -1 * angle);
}
if (this.changeSearchSpace) {
// second search space
this.move[9] = this.RadiusAndRotateA(elp, radiusLength, angle); // increase A positive rotation
this.move[10] = this.RadiusAndRotateA(elp, -1 * radiusLength, angle); // decrease A positive rotation
this.move[11] = this.RadiusAndRotateA(elp, radiusLength, -1 * angle); // increase A positive rotation
this.move[12] = this.RadiusAndRotateA(elp, -1 * radiusLength, -1 * angle); // decrease A negative rotation
}
return this.costMinMove();
}
costMinMove() {
let minimumCostMoveID = 1; // 1 is the id of the first move
for (let i = 2; i <= this.move.length; i++ // find the ID (number of the move that gives the minimum fitness
)
if (this.move[i] < this.move[minimumCostMoveID])
minimumCostMoveID = i;
this.selectedMove = minimumCostMoveID; // index of move with minimum cost
return this.move[minimumCostMoveID]; // return the cost at that move
}
// apply the move with ID (number) = index of the ellipse number elp
applyMove(elp, index) {
switch (index) {
case 1:
this.changeCenterX(elp, centerShift);
break;
case 2:
this.changeCenterX(elp, -1 * centerShift);
break;
case 3:
this.changeCenterY(elp, centerShift);
break;
case 4:
this.changeCenterY(elp, -1 * centerShift);
break;
case 5:
this.changeRadiusA(elp, radiusLength);
break;
case 6:
this.changeRadiusA(elp, -1 * radiusLength);
break;
case 7:
this.changeRotation(elp, angle);
break;
case 8:
this.changeRotation(elp, -1 * angle);
break;
case 9:
this.changeRadiusAndRotationA(elp, radiusLength, angle);
break;
case 10:
this.changeRadiusAndRotationA(elp, -1 * radiusLength, angle);
break;
case 11:
this.changeRadiusAndRotationA(elp, radiusLength, -1 * angle);
break;
case 12:
default:
this.changeRadiusAndRotationA(elp, -1 * radiusLength, -1 * angle);
break;
}
}
// This method is used for Simulated annealing optimizer. It takes ellipse number (elp) as a parameter, and selects a random move (between 1 and 10).
// it returns the fitness value along with the ID of the move returned in the global variable selectedMove
selectRandomMove(elp) {
// select the best move of a given ellipse (elp)
let fit;
let randIndex;
if (!this.changeSearchSpace)
// first search space - generate a random number between 1 and 8
randIndex = 1 + Math.floor(Math.random() * (8 - 1 + 1));
// second search space - generate a random number between 1 and 12
else
randIndex = 1 + Math.floor(Math.random() * (12 - 1 + 1));
switch (randIndex) {
case 1:
fit = this.centerX(elp, centerShift);
break;
case 2:
fit = this.centerX(elp, -1 * centerShift);
break;
case 3:
fit = this.centerY(elp, centerShift);
break;
case 4:
fit = this.centerY(elp, -1 * centerShift);
break;
case 5:
fit = this.radiusA(elp, radiusLength);
break;
case 6:
fit = this.radiusA(elp, -1 * radiusLength);
break;
case 7:
fit = this.rotateEllipse(elp, angle);
break;
case 8:
fit = this.rotateEllipse(elp, -1 * angle);
break;
case 9:
fit = this.RadiusAndRotateA(elp, radiusLength, angle);
break;
case 10:
fit = this.RadiusAndRotateA(elp, -1 * radiusLength, angle);
break;
case 11:
fit = this.RadiusAndRotateA(elp, radiusLength, -1 * angle);
break;
case 12:
default:
fit = this.RadiusAndRotateA(elp, -1 * radiusLength, -1 * angle);
break;
}
this.selectedMove = randIndex;
return fit;
}
computeFitness() {
this.HCEvalSolutions++; // when computeFitness function is called, that means a solution has been evaluated (increase counter of evaluated solutions by 1) Hill Climbing
this.SAEvalSolutions++; // Simulated annealing
let normalizedMeasures = {};
const fitnessComponents = this.areas.computeFitnessComponents(); // get the measures (criteria)
let fitness = 0;
logMessage(logOptimizerStep, `- move[${this.move.length + 1}]`);
let fitnessComponentN = 0;
for (const component in fitnessComponents) {
if (this.maxMeasures.hasOwnProperty(component) === false) {
// track the maximum value computed so far for each component to be used in the normalisation process.
this.maxMeasures[component] = [];
this.maxMeasures[component][0] = 0;
}
// the value of the measure before normalization
let m = fitnessComponents[component];
// the value of the measure after normalization
m = normalizeMeasure(m, this.maxMeasures[component]);
logMessage(logOptimizerStep, ` ${component} = ${m}`);
normalizedMeasures[component] = m; // store the normalized measures to use in fitness computation after equalizing their effect
fitnessComponentN++;
}
// compute the total fitness value after equalizing the effect of each measure and applying a weight for each measure
for (const component in fitnessComponents) {
let weight = 1;
if (weights.hasOwnProperty(component)) {
weight = weights[component];
}
fitness += weight * normalizedMeasures[component];
}
// Divide by the total number of measures.
fitness = fitness / fitnessComponentN;
logMessage(logOptimizerStep, ` Fitness: ${fitness}`);
return fitness;
}
// computes the fitness value when we move the center point horizontally
centerX(elp, centerShift) {
const oldX = this.state.ellipseParams[elp].X;
this.state.ellipseParams[elp].X = fixNumberPrecision(oldX + centerShift);
const fit = this.computeFitness();
logMessage(logOptimizerChoice, "fit %s", fit);
this.state.ellipseParams[elp].X = oldX; // to return back to the state before the change
return fit;
}
// computes the fitness value when we move the center point vertically
centerY(elp, centerShift) {
const oldY = this.state.ellipseParams[elp].Y;
this.state.ellipseParams[elp].Y = fixNumberPrecision(oldY + centerShift);
const fit = this.computeFitness();
logMessage(logOptimizerChoice, "fit %s", fit);
this.state.ellipseParams[elp].Y = oldY; // to return back to the state before the change
return fit;
}
// computes the fitness value when we increase/decrease the radius A
radiusA(elp, radiusLength) {
const oldA = this.state.ellipseParams[elp].A;
const oldB = this.state.ellipseParams[elp].B;
if (this.state.ellipseParams[elp].A + radiusLength <= 0) {
return Number.MAX_VALUE;
}
this.state.ellipseParams[elp].A += radiusLength;
this.state.ellipseParams[elp].B =
this.state.contourAreas[elp] / (Math.PI * this.state.ellipseParams[elp].A);
const fit = this.computeFitness();
logMessage(logOptimizerChoice, "fit %s", fit);
this.state.ellipseParams[elp].A = oldA;
this.state.ellipseParams[elp].B = oldB;
return fit;
}
// rotates the ellipse (if not a circle) by angle r
rotateEllipse(elp, r) {
const oldR = this.state.ellipseParams[elp].R;
this.state.ellipseParams[elp].R += r;
this.state.ellipseParams[elp].R =
(this.state.ellipseParams[elp].R + PI) % PI; // Ensure R is between 0 and PI.
const fit = this.computeFitness();
logMessage(logOptimizerChoice, "fit %s", fit);
this.state.ellipseParams[elp].R = oldR;
return fit;
}
// increase/decrease radius A and rotate at the same time
RadiusAndRotateA(elp, radiusLength, angle) {
const oldA = this.state.ellipseParams[elp].A;
const oldB = this.state.ellipseParams[elp].B;
const oldR = this.state.ellipseParams[elp].R;
this.state.ellipseParams[elp].A += radiusLength;
this.state.ellipseParams[elp].B =
this.state.contourAreas[elp] / (Math.PI * this.state.ellipseParams[elp].A);
this.state.ellipseParams[elp].R += angle;
this.state.ellipseParams[elp].R =
(this.state.ellipseParams[elp].R + PI) % PI; // Ensure R is between 0 and PI.
const fit = this.computeFitness();
logMessage(logOptimizerChoice, "fit %s", fit);
this.state.ellipseParams[elp].A = oldA;
this.state.ellipseParams[elp].B = oldB;
this.state.ellipseParams[elp].R = oldR;
return fit;
}
// apply the move on the center point of the ellipse elp horizontally
changeCenterX(elp, centerShift) {
const oldX = this.state.ellipseParams[elp].X;
this.state.ellipseParams[elp].X = fixNumberPrecision(oldX + centerShift);
}
// apply the move on the center point of the ellipse elp vertically
changeCenterY(elp, centerShift) {
const oldY = this.state.ellipseParams[elp].Y;
this.state.ellipseParams[elp].Y = fixNumberPrecision(oldY + centerShift);
}
// apply the move by increasing/decreasing radius A of ellipse elp
changeRadiusA(elp, radiusLength) {
this.state.ellipseParams[elp].A += radiusLength;
this.state.ellipseParams[elp].B =
this.state.contourAreas[elp] / (Math.PI * this.state.ellipseParams[elp].A);
}
// apply rotation
changeRotation(elp, angle) {
this.state.ellipseParams[elp].R += angle;
this.state.ellipseParams[elp].R =
(this.state.ellipseParams[elp].R + PI) % PI; // Ensure R is between 0 and PI.
}
// apply radius A increase/decrease along with rotation
changeRadiusAndRotationA(elp, radiusLength, angle) {
this.changeRadiusA(elp, radiusLength);
this.changeRotation(elp, angle);
}
}