constructor()

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();
  }