in code/decision-tree/js/EntropyBubble.js [68:307]
constructor(opts) {
const self = this;
// selections
this.container = opts.chartContainer;
// set size parameters for SVG
this.MARGIN = {
TOP: 0,
BOTTOM: 0,
LEFT: 0,
RIGHT: 0,
};
const containerSize = select(`#${this.container}`)
.node()
.getBoundingClientRect();
this.WIDTH = containerSize.width * 0.98;
this.HEIGHT = containerSize.height * 0.9;
// incrementor for entropy
this.counters = {
positive: 3,
negative: 3,
};
this.split = true;
this.numCirclesPerGroup = 25;
this.radius = mmobile ? 4.5 : 7;
this.boundingOffset = mmobile ? 2 : 4;
this.bc_radius = 56 - this.boundingOffset * 1.8;
this.nodes = Array.from(
{ length: this.numCirclesPerGroup * 2 },
(_, i) => ({
idx: i % this.numCirclesPerGroup,
value: i % 2 === 0 ? "negative" : "positive",
})
);
this.chartHeight = this.HEIGHT / 2;
this.entroypG = this.initChartSvg(this.container);
this.addScales();
this.drawEntropyLine();
this.addButtons();
// draw bounding circle
this.entroypG
.append("circle")
.attr("id", "bounding-circle")
.attr("cx", this.positionXScale(5))
.attr("cy", this.chartHeight)
.style("fill", "none")
.style("stroke", "white")
.style("stroke-width", 1.4)
.attr("fill-opacity", 0.4);
this.simulation = forceSimulation(self.nodes)
.force("charge", forceManyBody().strength(-2))
.force(
"x",
forceX((d, i) =>
d.value === "positive"
? self.positions.positive.x
: self.positions.negative.x
).strength(0.055)
)
.force("y", forceY().strength(0.05).y(self.chartHeight))
.force("collide", forceCollide().radius(self.radius + 1))
.alphaDecay(0)
.on("tick", ticked);
this.node = this.entroypG
.append("g")
.selectAll("circle")
.data(self.nodes)
.join("circle")
.attr("r", self.radius)
.attr("fill", (d) => self.colorScale(d.value))
.attr("stroke", "white");
// add center labels
this.centerLabelsGroup = this.entroypG.append("g");
this.centerLabels = this.centerLabelsGroup
.selectAll(".center-label")
.data(["Entropy: ", "# Positive Class: ", "# Negative Class: "])
.join("text")
.attr("class", "center-label")
.attr("id", (d, i) => `entropy-label-${i}`)
.text((d) => d)
.attr("x", this.WIDTH / 2 - 0)
.attr("y", (d, i) => i * 20 + 30)
.attr("text-anchor", "middle");
function ticked() {
// reposition nodes
self.node
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.attr("r", (d) => self.radius);
}
// initialize with values tallied
for (const value of ["positive", "negative"]) {
self.node
.filter((d) => d.value === value)
.each((d) => {
if (d.idx < max([self.counters[value], 0])) {
d.group = "center";
} else {
d.group = "non-center";
}
});
}
updateNodePositions();
// moveNodes();
selectAll("rect.entropy-button-rect").on("click", moveNodes);
function updateNodePositions() {
// tally entropy
let posArr = Array.from({ length: self.counters["positive"] }).map(
() => "positive"
);
let negArr = Array.from({ length: self.counters["negative"] }).map(
() => "negative"
);
let entropyArr = posArr.concat(negArr);
let currentProbability = prob(entropyArr);
// update bounding circle based on length of entropyArr
const n = entropyArr.length;
// update center labels values
selectAll("text.center-label").text((d, i) => {
if (i === 0) {
return `Entropy: ${format(".3f")(entropy(entropyArr))}`;
} else if (i === 1) {
return `# Positive Class: ${self.counters["positive"]}`;
} else {
return `# Negative Class: ${self.counters["negative"]}`;
}
});
self.positions.center.x = self.xScale(currentProbability);
self.updatedYPos = self.yScale(entropy(entropyArr));
select("#bounding-circle")
.style("stroke-width", () => (entropyArr.length > 0 ? 1.4 : 0))
.transition()
.attr("cx", self.xScale(currentProbability))
.attr("cy", self.updatedYPos)
.attr("r", (d) => self.boundingCircleScale(entropyArr.length));
// re-run force simulation
self.simulation
.force(
"x",
forceX((d, i) => {
if (d.group === "center") {
return self.positions.center.x;
}
return d.value === "positive"
? self.positions.positive.x
: self.positions.negative.x;
}).strength(0.055)
)
.force(
"y",
forceY((d, i) => {
if (d.group !== "center") {
return self.chartHeight;
}
return self.updatedYPos;
}).strength(0.055)
)
.force("bound-inner-dots", () => {
self.node
.filter((d) => d.group === "center")
.each((d) => {
// once node is inside the bounding circle (plus some padding to account for collide radius)
// assign data to mark, so we can then bound it in the following force
if (
distance([d.x, d.y], [self.WIDTH / 2, self.updatedYPos]) <
self.bc_radius + self.boundingOffset
) {
d.isBounded = true;
const theta = Math.atan(
(node.y - self.updatedYPos) / (node.x - self.WIDTH / 2)
);
node.x =
self.WIDTH / 2 +
self.bc_radius *
Math.cos(theta) *
(node.x < self.WIDTH / 2 ? -1 : 1);
node.y =
self.updatedYPos +
self.bc_radius *
Math.sin(theta) *
(node.x < self.WIDTH / 2 ? -1 : 1);
}
});
})
.on("tick", ticked);
self.simulation.alpha(1).restart();
}
function moveNodes() {
// resolve current button Id
const buttonId = this.id;
// resolve if positive or negative button
const positiveOrNegative = buttonId.split("-")[0];
// resolve if adding or removing
const addOrRemove = buttonId.split("-")[1];
// increment counter for given stage
if (addOrRemove === "add") {
if (self.counters[positiveOrNegative] < self.numCirclesPerGroup - 1) {
self.counters[positiveOrNegative]++;
}
} else {
if (self.counters[positiveOrNegative] > 0) {
// prevent negative counts
self.counters[positiveOrNegative]--;
}
}
// update data assignment (so can track previous state assignments)
// how to add force AFTER simulation
self.node
.filter((d) => d.value === positiveOrNegative)
.each((d) => {
if (d.idx < max([self.counters[positiveOrNegative], 0])) {
d.group = "center";
} else {
d.group = "non-center";
}
});
updateNodePositions();
}
// moveNodes();
}