/* eslint-disable no-multi-spaces */
/* eslint-disable space-infix-ops */
import { scaleLinear, ScalePoint, scalePoint } from "d3-scale";
import { timerStart, timerEnd } from "../../../util/perf";
import { getTraitFromNode, getDivFromNode } from "../../../util/treeMiscHelpers";
import { stemParent, nodeOrdering } from "./helpers";
import { numDate } from "../../../util/colorHelpers";
import { Layout, ScatterVariables } from "../../../reducers/controls";
import { ReduxNode, colorBySymbol } from "../../../reducers/tree/types";
import { Distance, Params, PhyloNode, PhyloTreeType, Ripple } from "./types";

/**
 * assigns the attribute this.layout and calls the function that
 * calculates the x,y coordinates for the respective layouts
 */
export const setLayout = function setLayout(
  this: PhyloTreeType,
  layout?: Layout,
  scatterVariables?: ScatterVariables,
): void {
  // console.log("set layout");
  timerStart("setLayout");
  if (typeof layout === "undefined" || layout !== this.layout) {
    this.nodes.forEach((d) => {d.update = true;});
  }
  if (typeof layout === "undefined") {
    this.layout = "rect";
  } else {
    this.layout = layout;
  }

  // remove any regression. This will be recalculated if required.
  this.regression = undefined;

  // assign scatterVariables, needed for clock / scatter layouts.
  // P.S. we overwrite the x & y axis for clock views _only_ within PhyloTree. This allows
  // the scatterplot variables to be remembered while viewing other layouts
  if (scatterVariables) this.scatterVariables = {...scatterVariables};
  if (this.layout === "clock") {
    this.scatterVariables.x="num_date";
    this.scatterVariables.y="div";
  }

  if (this.layout === "rect") {
    this.rectangularLayout();
  } else if (this.layout === "clock" || this.layout === "scatter") {
    this.scatterplotLayout();
  } else if (this.layout === "radial") {
    this.radialLayout();
  } else if (this.layout === "unrooted") {
    this.unrootedLayout();
  }
  timerEnd("setLayout");
};

/**
 * assignes x,y coordinates for a rectangular layout
 */
export const rectangularLayout = function rectangularLayout(this: PhyloTreeType): void {
  this.nodes.forEach((d) => {
    d.y = d.displayOrder; // precomputed y-values
    d.x = d.depth;    // depth according to current distance
    d.px = d.pDepth;  // parent positions
    d.py = d.y;
    // d.x_conf = d.conf; // assign confidence intervals
  });
  if (this.vaccines) {
    this.vaccines.forEach((d) => {
      d.xCross = d.crossDepth;
      d.yCross = d.y;
    });
  }
};

/**
 * assign x,y coordinates for nodes based upon user-selected variables
 * TODO: timeVsRootToTip is a specific instance of this
 */
export const scatterplotLayout = function scatterplotLayout(this: PhyloTreeType): void {
  if (!this.scatterVariables) {
    console.error("Scatterplot called without variables");
    return;
  }

  const getDisplayOrderPair = (this.scatterVariables.x==="displayOrder" || this.scatterVariables.y==="displayOrder") ?
    nodeOrdering(this.nodes) :
    undefined;

  for (const d of this.nodes) {
    // set x and parent X values
    if (this.scatterVariables.x==="div") {
      d.x = getDivFromNode(d.n);
      d.px = getDivFromNode(stemParent(d.n));
    } else if (this.scatterVariables.x==="gt") {
      d.x = d.n.currentGt;
      d.px = stemParent(d.n).currentGt;
    } else if (this.scatterVariables.x==="displayOrder") {
      [d.x, d.px] = getDisplayOrderPair(d);
    } else {
      d.x = getTraitFromNode(d.n, this.scatterVariables.x);
      d.px = getTraitFromNode(stemParent(d.n), this.scatterVariables.x);
      if (this.scatterVariables.xTemporal) {
        [d.x, d.px] = [numDate(d.x), numDate(d.px)]
      }
    }
    // set y and parent  values
    if (this.scatterVariables.y==="div") {
      d.y = getDivFromNode(d.n);
      d.py = getDivFromNode(stemParent(d.n));
    } else if (this.scatterVariables.y==="gt") {
      d.y = d.n.currentGt;
      d.py = stemParent(d.n).currentGt;
    } else if (this.scatterVariables.y==="displayOrder") {
      [d.y, d.py] = getDisplayOrderPair(d);
    } else {
      d.y = getTraitFromNode(d.n, this.scatterVariables.y);
      d.py = getTraitFromNode(stemParent(d.n), this.scatterVariables.y);
      if (this.scatterVariables.yTemporal) {
        [d.y, d.py] = [numDate(d.y), numDate(d.py)]
      }
    }
  }

  if (this.vaccines) { /* overlay vaccine cross on tip */
    this.vaccines.forEach((d) => {
      d.xCross = d.x;
      d.yCross = d.y;
    });
  }

  if (this.scatterVariables.showRegression) {
    this.calculateRegression(); // sets this.regression
  }

};


/**
 * Utility function for the unrooted tree layout. See `unrootedLayout` for details.
 */
const unrootedPlaceSubtree = (
  node: PhyloNode,
  totalLeafWeight: number,
): void => {
  const branchLength = node.depth - node.pDepth;
  node.x = node.px + branchLength * Math.cos(node.tau + node.w * 0.5);
  node.y = node.py + branchLength * Math.sin(node.tau + node.w * 0.5);
  let eta = node.tau; // eta is the cumulative angle for the wedges in the layout
  if (node.n.hasChildren) {
    for (let i = 0; i < node.n.children.length; i++) {
      const ch = node.n.children[i].shell;
      ch.w = 2 * Math.PI * leafWeight(ch.n) / totalLeafWeight;
      ch.tau = eta;
      eta += ch.w;
      ch.px = node.x;
      ch.py = node.y;
      unrootedPlaceSubtree(ch, totalLeafWeight);
    }
  }
};


// TODO
// can't use the .child approach, must use parent stem function
// check internal nodes with 1 child don't increase .w

/**
 * calculates x,y coordinates for the unrooted layout. this is
 * done recursively via a the function unrootedPlaceSubtree
 */
export const unrootedLayout = function unrootedLayout(this: PhyloTreeType): void {
  /* the angle of a branch (i.e. the line leading to the node) is `tau + 0.5*w`
    `tau` stores the previous angle which has been used
    `w` is a measurement of the angle occupied by the clade defined by this node
    `eta` is a temporary variable of `tau` + the `w` of each child visited thus far
  Note 1: we don't consider this.nodes[0] as that's the (unrendered)
          root which holds the subtrees. We instead start by defining the values
          for each subtree's root, which will be used by the children of that root
  Note 2: Angles will start from `eta` as defined below, and then cover ~2*Pi radians
  */
  const totalLeafWeight = leafWeight(this.nodes[0].n);
  let eta = 1.5 * Math.PI;
  const children = this.nodes[0].n.children; // <Node>
  this.nodes.forEach((d) => { // this shouldn't be necessary
    d.x = undefined;
    d.y = undefined;
    d.px = undefined;
    d.py = undefined;
  });
  for (let i = 0; i < children.length; i++) {
    const d = children[i].shell; // <PhyloNode>
    d.w = 2.0 * Math.PI * leafWeight(d.n) / totalLeafWeight; // angle occupied by entire subtree
    if (d.w>0) { // i.e. subtree has tips which should be drawn
      const distFromOrigin = d.depth - this.nodes[0].depth;
      d.px = distFromOrigin * Math.cos(eta + d.w * 0.5);
      d.py = distFromOrigin * Math.sin(eta + d.w * 0.5);
      d.tau = eta;
      unrootedPlaceSubtree(d, totalLeafWeight);
      eta += d.w;
    }
  }
  if (this.vaccines) {
    this.vaccines.forEach((d) => {
      const bL = d.crossDepth - d.depth;
      d.xCross = d.px + bL * Math.cos(d.tau + d.w * 0.5);
      d.yCross = d.py + bL * Math.sin(d.tau + d.w * 0.5);
    });
  }
  this.nodes.forEach((d) => { // remove properties which otherwise build up over time
    delete d.tau;
    delete d.w;
  });
};

/**
 * calculates and assigns x,y coordinates for the radial layout.
 * in addition to x,y, this calculates the end-points of the radial
 * arcs and whether that arc is more than pi or not
 */
export const radialLayout = function radialLayout(this: PhyloTreeType): void {
  const maxDisplayOrder = Math.max(...this.nodes.map((d) => d.displayOrder).filter((val) => val));
  const offset = this.nodes[0].depth;
  this.nodes.forEach((d) => {
    const angleCBar1 = 2.0 * 0.95 * Math.PI * d.displayOrderRange[0] / maxDisplayOrder;
    const angleCBar2 = 2.0 * 0.95 * Math.PI * d.displayOrderRange[1] / maxDisplayOrder;
    d.angle = 2.0 * 0.95 * Math.PI * d.displayOrder / maxDisplayOrder;
    d.y = (d.depth - offset) * Math.cos(d.angle);
    d.x = (d.depth - offset) * Math.sin(d.angle);
    d.py = d.y * (d.pDepth - offset) / (d.depth - offset + 1e-15);
    d.px = d.x * (d.pDepth - offset) / (d.depth - offset + 1e-15);
    d.yCBarStart = (d.depth - offset) * Math.cos(angleCBar1);
    d.xCBarStart = (d.depth - offset) * Math.sin(angleCBar1);
    d.yCBarEnd = (d.depth - offset) * Math.cos(angleCBar2);
    d.xCBarEnd = (d.depth - offset) * Math.sin(angleCBar2);
    d.smallBigArc = Math.abs(angleCBar2 - angleCBar1) > Math.PI * 1.0;
  });
  if (this.vaccines) {
    this.vaccines.forEach((d) => {
      if (this.distance === "div") {
        d.xCross = d.x;
        d.yCross = d.y;
      } else {
        d.xCross = (d.crossDepth - offset) * Math.sin(d.angle);
        d.yCross = (d.crossDepth - offset) * Math.cos(d.angle);
      }
    });
  }
};

/**
 * set the property that is used as distance along branches
 * this is set to "depth" of each node. depth is later used to
 * calculate coordinates. Parent depth is assigned as well.
 * @sideEffect sets this.distance -> "div" or "num_date"
 */
export const setDistance = function setDistance(
  this: PhyloTreeType,
  distanceAttribute?: Distance,
): void {
  timerStart("setDistance");
  this.nodes.forEach((d) => {d.update = true;});
  if (distanceAttribute) {
    this.distance = distanceAttribute;
  }
  if (!["div", "num_date"].includes(this.distance)) {
    console.error("Tree distance measure not set or invalid. Using `div`.");
    this.distance = "div"; // fallback to div
  }

  // todo - can the following loops be skipped for scatterplots?

  // assign node and parent depth
  if (this.distance === "div") {
    this.nodes.forEach((d) => {
      d.depth = getDivFromNode(d.n);
      d.pDepth = getDivFromNode(stemParent(d.n));
      d.conf = [d.depth, d.depth]; // TO DO - shouldn't be needed, never have div confidence...
    });
  } else {
    this.nodes.forEach((d) => {
      d.depth = getTraitFromNode(d.n, "num_date");
      d.pDepth = getTraitFromNode(stemParent(d.n), "num_date");
      d.conf = getTraitFromNode(d.n, "num_date", {confidence: true}) || [d.depth, d.depth];
    });
  }

  if (this.vaccines) {
    this.vaccines.forEach((d) => {
      d.crossDepth = d.depth;
    });
  }
  timerEnd("setDistance");
};


/**
 * Initializes and sets the range of the scales (this.xScale, this.yScale)
 * which are used to map the x,y coordinates to the screen
 */
export const setScales = function setScales(this: PhyloTreeType): void {

  if (this.layout==="scatter" && !this.scatterVariables.xContinuous) {
    this.xScale = scalePoint().round(false).align(0.5).padding(0.5);
  } else {
    this.xScale = scaleLinear();
  }
  if (this.layout==="scatter" && !this.scatterVariables.yContinuous) {
    this.yScale = scalePoint().round(false).align(0.5).padding(0.5);
  } else {
    this.yScale = scaleLinear();
  }

  // TODO: access these from d3treeParent so they don't have to be set twice
  const width = parseInt(this.svg.attr("width"), 10);
  const height = parseInt(this.svg.attr("height"), 10);
  if (this.layout === "radial" || this.layout === "unrooted") {
    // Force Square: TODO, harmonize with the map to screen
    const xExtend = width - this.margins.left - this.margins.right;
    const yExtend = height - this.margins.bottom - this.margins.top;
    const minExtend = Math.min(xExtend, yExtend);
    const xSlack = xExtend - minExtend;
    const ySlack = yExtend - minExtend;
    this.xScale.range([0.5 * xSlack + this.margins.left, width - 0.5 * xSlack - this.margins.right]);
    this.yScale.range([0.5 * ySlack + this.margins.top, height - 0.5 * ySlack - this.margins.bottom]);

  } else {
    // for rectangular layout, allow flipping orientation of left/right and top/bottom
    if (this.params.orientation[0] > 0) {
      this.xScale.range([this.margins.left, width - this.margins.right]);
    } else {
      this.xScale.range([width - this.margins.right, this.margins.left]);
    }
    if (this.params.orientation[1] > 0) {
      this.yScale.range([this.margins.top, height - this.margins.bottom]);
    } else {
      this.yScale.range([height - this.margins.bottom, this.margins.top]);
    }
  }
};

/**
* this function sets the xScale, yScale domains and maps precalculated x,y
* coordinates to their places on the screen
*/
export const mapToScreen = function mapToScreen(this: PhyloTreeType): void {
  timerStart("mapToScreen");
  const inViewTerminalNodes = this.nodes.filter((d) => !d.n.hasChildren).filter((d) => d.inView);

  /* set up space (padding) for axes etc, as we don't want the branches & tips to occupy the entire SVG! */
  this.margins = {
    left: (this.layout==="scatter" || this.layout==="clock") ? 40 : 5, // space for y-axis label
    right: 5 + getTipLabelPadding(this.params, inViewTerminalNodes),
    top: this.layout==="radial" ? 10 : 15, // avoid tips rendering behind legend
    bottom: 35 // space for x-axis labels
  };

  /* construct & set the range of the x & y scales */
  this.setScales();
  /* update the clip mask accordingly */
  this.setClipMask();

  /**
   * Select the nodes that we'll use to define the domain of the scales - essentially
   * select which nodes we want to use to define the viewport.
   */
  let nodesInDomain;
  const focusOn = this.focus==='selected' && (this.layout==='rect' || this.layout==='radial');
  const focusNodes = this.focusNodes; // these are calculated by redux thunks / reducers
  /**
   * "focus on selected" limits the axis domains to nodes Auspice will actually render
   * Note: nodes marked as `inView` may be off-screen in this mode
   */
  if (focusOn && focusNodes) {
    nodesInDomain = focusNodes.nodes.map((idx) => this.nodes[idx])
      .filter((d) => d.y !== undefined && d.x !== undefined);
  } else {
    /**
     * `inView` nodes are every node which descends from the inViewRootNode - so they include
     * nodes which are filtered out, e.g. because they're beyond the selected date range
     * This is the "normal" auspice viewport behaviour, or maybe the "old fashioned" behaviour...
     */
    nodesInDomain = this.nodes.filter((d) => d.inView && d.y!==undefined && d.x!==undefined);
  }
  
  if (this.layout === "scatter" && this.scatterVariables.showBranches === false) {
    nodesInDomain = nodesInDomain.filter((d) => !d.n.hasChildren);
  }

  /* Compute the domains to pass to the d3 scales for the x & y axes */
  let xDomain, yDomain, spanX, spanY;
  if (this.layout!=="scatter" || this.scatterVariables.xContinuous) {
    let [minX, maxX] = [1000000, -100000];
    nodesInDomain.forEach((d) => {
      if (d.x < minX) minX = d.x;
      if (d.x > maxX) maxX = d.x;
    });
    /* fixes state of 0 length domain */
    if (minX === maxX && !(this.params.showStreamTrees && nodesInDomain[0].n.streamName)) {
      minX -= 0.005;
      maxX += 0.005;
    }
    /* Don't allow tiny x-axis domains -- e.g. if zoomed into a polytomy where the
    divergence values are all tiny, then we don't want to display the tree topology */
    const minimumXAxisSpan = 1E-8;
    spanX = maxX-minX;
    if (spanX < minimumXAxisSpan) {
      maxX = minimumXAxisSpan - minX;
      spanX = minimumXAxisSpan;
    }
    /* In rectangular mode, if the tree has been zoomed, leave some room to display the (clade's) root branch */
    if (this.layout==="rect" && this.zoomNode.n.arrayIdx!==0) {
      minX -= (maxX-minX)/20; // 5%
    }
    xDomain = [minX, maxX];
  } else {
    const seenValues = new Set(nodesInDomain.map((d) => d.x));
    xDomain = this.scatterVariables.xDomain.filter((v) => seenValues.has(v));
    padCategoricalScales(xDomain, this.xScale);
  }

  if (this.layout!=="scatter" || this.scatterVariables.yContinuous) {
    let [minY, maxY] = [1000000, -100000];
    nodesInDomain.forEach((d) => {
      if (d.y < minY) minY = d.y;
      if (d.y > maxY) maxY = d.y;
    });
    /* slightly pad min and max y to account for small clades */
    if (inViewTerminalNodes.length < 30) {
      const delta = 0.05 * (maxY - minY);
      minY -= delta;
      maxY += delta;
    }
    spanY = maxY-minY;
    yDomain = [minY, maxY];
  } else {
    const seenValues = new Set(nodesInDomain.map((d) => d.y));
    yDomain = this.scatterVariables.yDomain.filter((v) => seenValues.has(v));
    padCategoricalScales(yDomain, this.yScale);
  }

  /* Radial / Unrooted layouts need to be square since branch lengths
  depend on this */
  if (this.layout === "radial" || this.layout === "unrooted") {
    const maxSpan = Math.max(spanY, spanX);
    const ySlack = (spanX>spanY) ? (spanX-spanY)*0.5 : 0.0;
    const xSlack = (spanX<spanY) ? (spanY-spanX)*0.5 : 0.0;
    xDomain = [xDomain[0]-xSlack, xDomain[0]+maxSpan-xSlack];
    yDomain = [yDomain[0]-ySlack, yDomain[0]+maxSpan-ySlack];
  }
  /* Clock & Scatter plots flip the yDomain */
  if (this.layout === "clock" || this.layout === "scatter") {
    yDomain.reverse();
  }

  /**
   * The above approach doesn't include nodes which are in streams and so
   * streams may currently be outside the x/y domains
   */
  if (this.params.showStreamTrees) {
    for (const stream of Object.values(this.streams)) {
      const node = this.nodes[stream.startNode];
      if (!node.inView) continue;

      const pivots = node.n.streamPivots;
      if (pivots.at(0) < xDomain[0]) xDomain[0] = pivots.at(0);
      if (pivots.at(-1) > xDomain[1]) xDomain[1] = pivots.at(-1);

      if (node.displayOrderRange[0] < yDomain[0]) yDomain[0] = node.displayOrderRange[0];
      if (node.displayOrderRange[1] > yDomain[1]) yDomain[1] = node.displayOrderRange[1];
    }
  }

  this.xScale.domain(xDomain);
  this.yScale.domain(yDomain);

  const hiddenYPosition = this.yScale.range()[1] + 100;
  const hiddenXPosition = this.xScale.range()[0] - 100;

  // pass all x,y through scales and assign to xTip, xBase
  this.nodes.forEach((d) => {
    d.xTip = this.xScale(d.x);
    d.yTip = this.yScale(d.y);
    d.xBase = this.xScale(d.px);
    d.yBase = this.yScale(d.py);
    d.rot = Math.atan2(d.yTip-d.yBase, d.xTip-d.xBase) * 180/Math.PI;
  });
  // for scatterplots we do an additional iteration as some values may be missing
  // & we want to avoid rendering these
  if (this.layout==="scatter") {
    if (!this.scatterVariables.yContinuous) jitter("y", this.yScale, this.nodes);
    if (!this.scatterVariables.xContinuous) jitter("x", this.xScale, this.nodes);
    this.nodes.forEach((d) => {
      if (isNaN(d.xTip)) d.xTip = hiddenXPosition;
      if (isNaN(d.yTip)) d.yTip=hiddenYPosition;
      if (isNaN(d.xBase)) d.xBase=hiddenXPosition;
      if (isNaN(d.yBase)) d.yBase=hiddenYPosition;
    });
  }
  if (this.vaccines) {
    this.vaccines.forEach((d) => {
      const n = 6; /* half the number of pixels that the cross will take up */
      const xTipCross = this.xScale(d.xCross); /* x position of the center of the cross */
      const yTipCross = this.yScale(d.yCross); /* x position of the center of the cross */
      d.vaccineCross = ` M ${xTipCross-n},${yTipCross-n} L ${xTipCross+n},${yTipCross+n} M ${xTipCross-n},${yTipCross+n} L ${xTipCross+n},${yTipCross-n}`;
    });
  }

  // assign the branches as path to each node for the different layouts
  if (this.layout==="unrooted") {
    this.nodes.forEach((d) => {
      d.branch = [" M "+d.xBase.toString()+","+d.yBase.toString()+" L "+d.xTip.toString()+","+d.yTip.toString(), ""];
    });
  } else if (this.layout==="clock" || this.layout==="scatter") {
    // if nodes are deliberately obscured (as traits may not be set for some nodes), we don't want to render branches joining that node
    if (this.scatterVariables.showBranches) {
      this.nodes.forEach((d) => {
        d.branch = d.xBase===hiddenXPosition || d.xTip===hiddenXPosition || d.yBase===hiddenYPosition || d.yTip===hiddenYPosition ?
          ["", ""] :
          [" M "+d.xBase.toString()+","+d.yBase.toString()+" L "+d.xTip.toString()+","+d.yTip.toString(), ""];
      });
    } else {
      this.nodes.forEach((d) => {d.branch=["", ""];});
    }
  } else if (this.layout==="rect") {
    this.nodes.forEach((d) => { // d is a <PhyloNode>
      const stem_offset = 0.5*(stemParent(d.n).shell["stroke-width"] - d["stroke-width"]) || 0.0;
      const stemRange = [this.yScale(d.displayOrderRange[0]), this.yScale(d.displayOrderRange[1])];
      // Note that a branch cannot be perfectly horizontal and also have a (linear) gradient applied to it
      // So we add a tiny amount of jitter (e.g 1/1000px) to the horizontal line (d.branch[0])
      // see https://stackoverflow.com/questions/13223636/svg-gradient-for-perfectly-horizontal-path
      d.branch =[
        ` M ${d.xBase - stem_offset},${d.yBase} L ${d.xTip},${d.yTip+0.01}`,
        ` M ${d.xTip},${stemRange[0]} L ${d.xTip},${stemRange[1]}`
      ];
      if (this.params.confidence) {
        d.confLine =` M ${this.xScale(d.conf[0])},${d.yBase} L ${this.xScale(d.conf[1])},${d.yTip}`;
      }
    });
  } else if (this.layout==="radial") {
    const offset = this.nodes[0].depth;
    const stem_offset_radial = this.nodes.map((d) => {return (0.5*(stemParent(d.n).shell["stroke-width"] - d["stroke-width"]) || 0.0);});
    this.nodes.forEach((d, i) => {
      d.branch =[
        " M "+(d.xBase-stem_offset_radial[i]*Math.sin(d.angle)).toString() +
        " "+(d.yBase-stem_offset_radial[i]*Math.cos(d.angle)).toString() +
        " L "+d.xTip.toString()+" "+d.yTip.toString(), ""
      ];
      if (d.n.hasChildren) {
        d.branch[1] = " M "+this.xScale(d.xCBarStart).toString()+" "+this.yScale(d.yCBarStart).toString()+
        " A "+(this.xScale(d.depth)-this.xScale(offset)).toString()+" "+
        (this.yScale(d.depth)-this.yScale(offset)).toString()+
        " 0 "+(d.smallBigArc?"1 ":"0 ") +" 1 "+
        " "+this.xScale(d.xCBarEnd).toString()+","+this.yScale(d.yCBarEnd).toString();
      }
    });
  }
  /* map any streams to pixel space */
  if (this.params.showStreamTrees) {
    this.mapStreamsToScreen()
  }
};

/**
 * Maps the pivot space (x) and displayOrderSpace (y) into pixel space for each stream.
 * 
 * Creates `node.streamRipples` on the start node of each stream by transforming the node's `rippleDisplayOrders`
 * and `streamPivots` by the d3 scales.
 */
export function mapStreamsToScreen(this: PhyloTreeType): void {
  for (const stream of Object.values(this.streams)) {
    const node = this.nodes[stream.startNode];
    node.streamRipples = node.rippleDisplayOrders.map((displayOrderByPivot, categoryIdx) => {
      const datum: Ripple = Object.assign(
        [], 
        displayOrderByPivot.map(([min,max], pivotIdx) => {
          return {
            x: this.xScale(node.n.streamPivots[pivotIdx]),
            y0: this.yScale(min),
            y1: this.yScale(max),
          }
        }),
        /* we define a key for d3 to use which allows ribbons to morph as needed and be created/destroyed as needed */
        {key: node.n.streamCategories[categoryIdx].name+"_"+this.streams[colorBySymbol]}  // aka the name of the ripple
      );
      return datum;
    });
  }
}

const JITTER_MIN_STEP_SIZE = 50; // pixels

function padCategoricalScales(
  domain: string[],
  scale: ScalePoint<string>,
): ScalePoint<string> {
  if (scale.step() > JITTER_MIN_STEP_SIZE) return scale.padding(0.5); // balanced padding when we can jitter
  if (domain.length<=4) return scale.padding(0.4);
  if (domain.length<=6) return scale.padding(0.3);
  if (domain.length<=10) return scale.padding(0.2);
  return scale.padding(0.1);
}

/**
 * Add jitter to the already-computed node positions.
 */
function jitter(
  axis: "x" | "y",
  scale: ScalePoint<string>,
  nodes: PhyloNode[],
): void {
  const step = scale.step();
  if (scale.step() <= JITTER_MIN_STEP_SIZE) return;
  const rand: number[] = []; // pre-compute a small set of pseudo random numbers for speed
  for (let i=1e2; i--;) {
    rand.push((Math.random()-0.5)*step*0.5); // occupy 50%
  }
  const [base, tip, randLen] = [`${axis}Base`, `${axis}Tip`, rand.length];
  let j = 0;
  function recurse(phyloNode: PhyloNode): void {
    phyloNode[base] = stemParent(phyloNode.n).shell[tip];
    phyloNode[tip] += rand[j++];
    if (j>=randLen) j=0;
    if (!phyloNode.n.hasChildren) return;
    for (const child of phyloNode.n.children) recurse(child.shell);
  }
  recurse(nodes[0]);
}


function getTipLabelPadding(
  params: Params,
  inViewTerminalNodes: PhyloNode[],
): number {
  let padBy = 0;
  if (inViewTerminalNodes.length < params.tipLabelBreakL1) {

    let fontSize = params.tipLabelFontSizeL1;
    if (inViewTerminalNodes.length < params.tipLabelBreakL2) {
      fontSize = params.tipLabelFontSizeL2;
    }
    if (inViewTerminalNodes.length < params.tipLabelBreakL3) {
      fontSize = params.tipLabelFontSizeL3;
    }

    inViewTerminalNodes.forEach((d) => {
      if (padBy < d.n.name.length) {
        padBy = 0.65 * d.n.name.length * fontSize;
      }
    });
  }
  return padBy;
}

function leafWeight(node: ReduxNode): number {
  return node.tipCount + 0.15*(node.fullTipCount-node.tipCount);
}
