HorizontalRidgePlot.js

import * as d3 from "d3";
import { RidgePlot } from "./RidgePlot.js";

/**
 * Make a horizontal ridgeline plot
 *
 * @class HorizontalRidgePlot
 * @extends {RidgePlot}
 */
export class HorizontalRidgePlot extends RidgePlot {
  /**
   * Creates an instance of HorizontalRidgePlot.
   * @param {string} selectorOrElement, a html dom selector or element.
   * @memberof HorizontalRidgePlot
   */
  constructor(selectorOrElement) {
    super(selectorOrElement);

    this._width = document.body.clientWidth * 0.9;
    this._height = 500;
  }

  /**
   * Set the state of the visualization.
   *
   * @param {object} state, a set of attributes that modify the rendering
   * @param {string} state.title, title of the plot
   * @param {string} state.footer, footer of the plot (shown in the bottom-right corner)
   * @param {string} state.xLabel, x-label of the plot
   * @param {string} state.yLabel, y-label of the plot
   * @param {string} state.metric, name of the metric to plot
   * @param {boolean} state.gradient, use a gradient to color the ridges?
   * @param {Array} state.xminmax, use a custom min and max for the x axis.
   * @param {Function} state.onClick, Use a custom OnClick callback when groups are clicked.
   * @param {Number} state.ticks, number of ticks for histogram
   * @memberof HorizontalRidgePlot
   */
  setState(state) {
    this.state = state;
  }

  /**
   * Render the plot. Optionally provide a height and width.
   *
   * @param {?number} width, width of the canvas to render the plot.
   * @param {?number} height, height of the canvas to render the plot.
   * @memberof HorizontalRidgePlot
   */
  render(width, height) {
    this._check_for_data();

    const margin = { top: 10, right: 10, bottom: 40, left: 150 };
    width = width - margin.left - margin.right;
    height = height - margin.top - margin.bottom;

    let self = this;

    self._width = width;
    self._height = height;
    self._margin = margin;

    if (this.elem.querySelector("svg")) {
      this.elem.querySelector("svg").innerHTML = "";
    }

    const svg = d3
      .select(this.elem)
      .append("svg")
      .attr("width", width + margin.left + margin.right)
      .attr("height", height + margin.top + margin.bottom)
      .append("g")
      .attr("transform", `translate(${margin.left},${margin.top})`);

    var y = d3.scaleBand().range([0, height]).domain(this._dkeys).padding(0.2);

    this._setTitleAndFooter(svg);

    if ("yLabel" in this.state) {
      svg
        .append("text")
        .attr("text-anchor", "end")
        .attr("y", -40)
        .style("font-size", "10px")
        .attr("transform", "rotate(-90)")
        .text(this.state.yLabel);
    }

    if ("xLabel" in this.state) {
      svg
        .append("text")
        .attr("text-anchor", "end")
        .attr("x", width / 2)
        .attr("y", height + 30)
        .style("font-size", "12px")
        // .attr("transform", "rotate(-90)")
        .text(this.state.xLabel);
    }

    let yAxis = svg.append("g").call(d3.axisLeft(y));

    var wrap = function () {
      var self = d3.select(this),
        textLength = self.node().getComputedTextLength(),
        text = self.text();
      while (textLength > 130 && text.length > 0) {
        text = text.slice(0, -1);
        self.text(text + "...");
        textLength = self.node().getComputedTextLength();
      }
    };

    yAxis
      .selectAll("text")
      .style("color", "#133355")
      .style("cursor", "pointer")
      .style("font-size", (d, i) => {
        if (self._hoverKey === self._dkeys.indexOf(d)) {
          return "14px";
        }
        return "12px";
      })
      .style("font-weight", (d, i) => {
        if (self._hoverKey === self._dkeys.indexOf(d)) {
          return "bold";
        }
        return "normal";
      })
      .attr("transform", "translate(-10,5)rotate(-55)")
      .style("text-anchor", "end")
      .each(wrap)
      .on("mouseover", (event, d) => {
        const idx = self._dkeys.indexOf(d);
        const mets = self._dentries[idx][1];

        self._hoverKey = idx;
        tip
          .style("opacity", 1)
          .html(
            `<span>${d}</span><br/><span>median: ${mets?.median.toFixed(
              2
            )}</span><br/><span>mean: ${mets?.mean.toFixed(
              2
            )}</span><br/><span>min: ${mets?.min.toFixed(2)}</span>
              <br/><span>max: ${mets?.max.toFixed(2)}</span>`
          )
          // .style("left", 100 + "px")
          // .style("top", y(d.key) + "px");
          .style("left", event.clientX + "px")
          .style("top", event.clientY + "px");
      })
      .on("mouseout", function (event, d) {
        self._hoverKey = null;
        tip.style("opacity", 0);
      })
      .on("click", function (e, d) {
        const idx = self._dkeys.indexOf(d);
        const mets = self._dentries[idx];
        if (mets !== null && mets !== undefined && "onClick" in self.state) {
          self.state.onClick(mets);
        }
      });

    let xminmax;
    if ("xminmax" in this.state) {
      xminmax = this.state.xminmax;
    } else {
      xminmax = d3.extent(this._dentries.map((x) => Math.max(...x[1].values)));
    }

    var x = d3.scaleLinear().domain([0, xminmax[1]]).range([0, width]).nice();

    svg
      .append("g")
      .attr("transform", "translate(0," + height + ")")
      .call(d3.axisBottom(x));

    // OPTION 1: Compute kernel density estimation for each column:
    // let kde = kernelDensityEstimator(kernelEpanechnikov(1.25), y.ticks(40));
    // let allCurves = [],
    //   allMax = 0;
    // for (let i = 0; i < n; i++) {
    //   let key = categories[i];
    //   console.log(data[key]);
    //   let density = kde(data[key].values);
    //   let dMax = Math.max(...density.map((x) => x[1]));
    //   if (dMax > allMax) {
    //     allMax = dMax;
    //   }
    //   allCurves.push({ key: key, dist: density });
    // }

    // OPTION 2
    // Features of the histogram
    var histogram = d3
      .bin()
      .domain(x.domain())
      .thresholds(x.ticks(20))
      .value((d) => d);

    let sumstat = [];
    for (let i = 0; i < this._dentries.length; i++) {
      const d = this._dentries[i];
      sumstat.push({ key: d[0], value: histogram(d[1].values) });
    }

    // Color scale for median values
    var colorScale = "#5b9cd2";
    if ("gradient" in this.state && this.state.gradient === true) {
      colorScale = d3
        .scaleSequential()
        .interpolator(d3.interpolateInferno)
        .domain([0, xminmax[1]]);
    }

    function getColor(d) {
      if (typeof colorScale === "string" || colorScale instanceof String) {
        return colorScale;
      } else {
        return colorScale(self.data[d][self.state.metric]);
      }
    }

    // draw the curve
    svg
      .selectAll("kernelRidges")
      .data(sumstat)
      .enter()
      .append("g")
      .attr("transform", function (d) {
        return "translate(" + "0, " + y(d.key) + ")";
      })
      .append("path")
      .attr("fill", function (d, i) {
        return getColor(d.key);
      })
      // .attr("fill", function(d) {
      //   console.log(d);
      //   console.log(data[d.key])
      //   return colorScale(data[d.key].median)
      // })
      .attr("stroke", "none")
      .style("opacity", 1)
      .attr("d", function (bin, i) {
        // per bin x curve
        let lengths = d3.max(
          bin.value.map(function (a) {
            return a.length;
          })
        );

        let yCurves = d3
          .scaleLinear()
          .range([0, y.bandwidth() * 1.4])
          .domain([lengths, -lengths]);

        return d3
          .area()
          .y0(yCurves(0))
          .y1(function (d) {
            return yCurves(d.length);
          })
          .x(function (d) {
            return x(d.x0);
          })
          .curve(d3.curveCatmullRom)(bin.value);
      })
      .on("mouseover", function (event, d) {
        const idx = self._dkeys.indexOf(d.key);
        const mets = self._dentries[idx][1];

        self._hoverKey = idx;

        tip
          .style("opacity", 1)
          .html(
            `<span>${d.key}</span><br/><span>median: ${mets?.median.toFixed(
              2
            )}</span><br/><span>mean: ${mets?.mean.toFixed(
              2
            )}</span><br/><span>min: ${mets?.min.toFixed(2)}</span>
              <br/><span>max: ${mets?.max.toFixed(2)}</span>`
          )
          // .style("left", 100 + "px")
          // .style("top", y(d.key) + "px");
          .style("left", event.clientX + "px")
          .style("top", event.clientY + "px");
      })
      .on("mouseout", function (event, d) {
        self._hoverKey = null;
        tip.style("opacity", 0);
      })
      .on("click", function (e, d) {
        const idx = self._dkeys.indexOf(d.key);
        const mets = self._dentries[idx][1];
        if (mets !== null && mets !== undefined && "onClick" in self.state) {
          self.state.onClick(mets);
        }
      });

    let bars = svg.selectAll("bars").data(this._dentries);

    const bar_width_ratio = 0.7;

    bars
      .enter()
      .append("line")
      .attr("y1", (d) => {
        return y(d[0]) + y.bandwidth() * bar_width_ratio;
      })
      .attr("y2", (d) => {
        return y(d[0]) + y.bandwidth() * bar_width_ratio;
      })
      .attr("x1", (d) => {
        return x(d[1]?.min);
      })
      .attr("x2", (d) => {
        return x(d[1]?.max);
      })
      .attr("stroke", "#353535")
      .style("stroke-opacity", 0.7);

    var tip = d3
      .select(this.elem)
      .append("div")
      .attr("class", "tooltip")
      .style("opacity", 0)
      .style("position", "absolute");

    bars
      .enter()
      .append("rect")
      .attr("y", (d) => {
        return y(d[0]) + y.bandwidth() * bar_width_ratio;
      })
      .attr("x", (d) => {
        return x(d[1]?.quantiles[0]);
      })
      .attr("height", y.bandwidth() * (1 - bar_width_ratio))
      .attr("width", (d) => {
        return x(d[1]?.quantiles[2]) - x(d[1]?.quantiles[0]);
      })
      .attr("fill", function (d, i) {
        return getColor(d[0]);
      })
      .style("opacity", 0.5)
      .on("mouseover", (event, d) => {
        const idx = self._dkeys.indexOf(d[0]);
        const mets = self._dentries[idx][1];

        self._hoverKey = idx;

        tip
          .style("opacity", 1)
          .html(
            `<span>${d[0]}</span><br/><span>median: ${mets?.median.toFixed(
              2
            )}</span><br/><span>mean: ${mets?.mean.toFixed(2)}</span>
              <br/><span>min: ${mets?.min.toFixed(2)}</span>
              <br/><span>max: ${mets?.max.toFixed(2)}</span>`
          )
          // .style("left", 100 + "px")
          // .style("top", y(d.key) + "px");
          .style("left", event.clientX + "px")
          .style("top", event.clientY + "px");
      })
      .on("mouseout", function (event, d) {
        self._hoverKey = null;
        tip.style("opacity", 0);
      })
      .on("click", function (e, d) {
        const idx = self._dkeys.indexOf(d[0]);
        const mets = self._dentries[idx][1];
        if (mets !== null && mets !== undefined && "onClick" in self.state) {
          self.state.onClick(mets);
        }
      });

    // min
    bars
      .enter()
      .append("line")
      .attr("y1", (d) => {
        return y(d[0]) + y.bandwidth() * bar_width_ratio;
      })
      .attr("y2", (d) => {
        return y(d[0]) + y.bandwidth();
      })
      .attr("x1", (d) => {
        return x(d[1]?.min);
      })
      .attr("x2", (d) => {
        return x(d[1]?.min);
      })
      .attr("stroke", "#353535")
      .attr("stroke-width", 1);

    // max
    bars
      .enter()
      .append("line")
      .attr("y1", (d) => {
        return y(d[0]) + y.bandwidth() * bar_width_ratio;
      })
      .attr("y2", (d) => {
        return y(d[0]) + y.bandwidth();
      })
      .attr("x1", (d) => {
        return x(d[1]?.max);
      })
      .attr("x2", (d) => {
        return x(d[1]?.max);
      })
      .attr("stroke", "#353535")
      .attr("stroke-width", 1);

    // mean > MEDIAN
    bars
      .enter()
      .append("line")
      .attr("y1", (d) => {
        return y(d[0]) + y.bandwidth() * bar_width_ratio;
      })
      .attr("y2", (d) => {
        return y(d[0]) + y.bandwidth();
      })
      .attr("x1", (d) => {
        return x(d[1]?.median);
      })
      .attr("x2", (d) => {
        return x(d[1]?.median);
      })
      .attr("stroke", "#353535")
      .attr("stroke-width", 1);
  }
}