Source: frames.js

/** TimeFrame is the main container for panes. */
class Timeframe {
  static INSERT_TIME = 0;
  static DELETE_TIME = 1;

  /** Create a TimeFrame.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.marginTop = options?.marginTop ?? 0;
    this.marginRight = options?.marginRight ?? 0;
    this.marginBottom = options?.marginBottom ?? 0;
    this.marginLeft = options?.marginLeft ?? 0;
    this.showTimeAxis = options?.showTimeAxis ?? true;
    this.timeAxisTickCount = options?.timeAxisTickCount ?? 20;
    this.showFirstTimeAxisTick = options?.showFirstTimeAxisTick ?? true;
    this.backgroundColor = options?.backgroundColor;
    this.includeGrid = options?.includeGrid ?? false;
    this.showGrid = options?.showGrid ?? true;
    this.gridColor = options?.gridColor ?? "#ff0000";
    this.gridOpacity = options?.gridOpacity ?? "0.5";
    this.gridStrokeWidth = options?.gridStrokeWidth ?? 1;
    this.gridShowHorizontalLines = options?.gridShowHorizontalLines ?? true;
    this.timeEditorFill = options?.timeEditorFill ?? "#0000ff";
    this.timeEditorStroke = options?.timeEditorStroke ?? "#0000ff";
    this.paneArray = [];
    this.parentElement;
    this.width;
    this.height;
    this.endTime;
    this.timeScale;
    this.timeFrameDiv;
    this.timeFrameSVG;
    this.paneSVG;
    this.paneGroup;
    this.paneWidth;
    this.paneHeight;
    this.timeFrameRect;
    this.timeFramePaneGroup;
    this.initialized = false;
    this.zoomed = false;
    this.grid;
    this.showLoader = false;
    this.showTimeEditor = false;
    this.timeEditorMode = Timeframe.INSERT_TIME;
    this.timeEditorGroup;
    this.timeEditorBrush;
    this.timeEditorBaseLine;
    this.animate = this.runAnimation.bind(this);
    this.onAnimate;
    this.animating = false;
    this.stopAnimating = false;
    this.onMouseenter;
    this.onMousemove;
    this.onMouseleave;
    this.onChangeEndTime;
    this.baseWidth = 0;
    this.baseHeight = 0;

    if (this.includeGrid) {
      this.grid = new Grid({color: this.gridColor,
                            opacity: this.gridOpacity,
                            strokeWidth: this.gridStrokeWidth,
                            showHorizontalLines: this.gridShowHorizontalLines});
    }
  }

  /**
  * Add the specified pane to this TimeFrame.
  * @param {pane} pane - The pane to add.
  */
  addPane(pane) {
    this.paneArray.push(pane);
  }

  /**
  * Set the specified pane to be the active pane. This both activates the pane and raises it to the top of the pane list for this TimeFrame.
  * All other panes are deactivated.
  * @param {pane} pane - The pane to add.
  */
  setActivePane(pane) {
    for (let i = 0; i < this.paneArray.length; i++) {
      if (this.paneArray[i] === pane) {
        this.paneArray[i].activate();
        this.paneArray[i].raise();
      }
      else {
        this.paneArray[i].deactivate();
      }
    }
  }

  /**
  * Zoom the time in this TimeFrame to the specified range. This will change the time range for all panes contained in this TimeFrame,
  * including an audio pane.
  * @param {number} start - The start time.
  * @param {number} stop - The stop time.
  */
  zoomTimeRange(start, stop) {
    this.zoomed = true;
    if (this.timeScale !== undefined) {
      this.timeScale.domain([start, stop]);
      let audioPaneIndex = this.paneArray.findIndex(({paneType}) => paneType === "audio");
      if (audioPaneIndex > -1) {
        this.paneArray[audioPaneIndex].stop();
        this.paneArray[audioPaneIndex].setTimeRange(this.timeScale.domain()[0], this.timeScale.domain()[1]);
      }
    }
  }

  /**
  * Reset the time in this TimeFrame to a range from 0 to the end time set for this TimeFrame.
  */
  resetTimeRange() {
    this.zoomed = false;
    if (this.timeScale !== undefined) {
      this.timeScale.domain([0, this.endTime]);
      let audioPaneIndex = this.paneArray.findIndex(({paneType}) => paneType === "audio");
      if (audioPaneIndex > -1) {
        this.paneArray[audioPaneIndex].resetTimeRange();
      }
    }
  }

  /**
  * Inserts time based on the specified time range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  insertTime(baseTime, deltaTime) {
    for (let i = 0; i < this.paneArray.length; i++) {
      if ("insertTime" in this.paneArray[i]) {
        this.paneArray[i].insertTime(baseTime, deltaTime);
      }
    }
    this.endTime += deltaTime;
    this.timeScale.domain([0, this.endTime]);
    if (this.onChangeEndTime !== undefined) {
      this.onChangeEndTime();
    }
    this.draw();
  }

  /**
  * Deletes time based on the specified time range but only if no elements are in that time range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  deleteTime(baseTime, deltaTime) {
    var canDelete = true;
    for (let i = 0; i < this.paneArray.length; i++) {
      if ("canDeleteTime" in this.paneArray[i]) {
        canDelete = this.paneArray[i].canDeleteTime(baseTime, deltaTime);
        if (!canDelete) {
          break;
        }
      }
    }
    if (canDelete) {
      for (let i = 0; i < this.paneArray.length; i++) {
        if ("deleteTime" in this.paneArray[i]) {
          this.paneArray[i].deleteTime(baseTime, deltaTime);
        }
      }
      this.endTime -= deltaTime;
      this.timeScale.domain([0, this.endTime]);
      if (this.onChangeEndTime !== undefined) {
        this.onChangeEndTime();
      }
    }
    this.draw();
  }

  /**
  * Add a zero to the specified number.
  * @param {number} n - A number to be padded.
  * @return {string} The padded number as a string.
  */
  zeroPad(n) {
    if (n < 10) {
      return "0" + n;
    }
    else {
      return n;
    }
  }

  /**
  * Take the specified seconds and format them.
  * @param {number} s - Seconds to be formatted.
  * @return {string} The formatted seconds as a string.
  */
  formatSeconds(s) {
    var digits = this.zoomed ? 2 : 0;
    var seconds = this.zeroPad((s).toFixed(digits));
    var splitSeconds = seconds.split(".");
    if (splitSeconds[1] === "00") {
      return splitSeconds[0];
    }
    else {
      return seconds;
    }
  }

  /**
  * Generate the time time axis for this TimeFrame.
  */
  callTimeAxis() {
    this.timeAxis.call(this.timeAxisGenerator.tickFormat((d) => {
      var r = d%3600;
      if (r < d) {
        // hours:minutes:seconds
        return Math.trunc(d/3600).toString() + ":" + this.zeroPad(Math.trunc(r/60)) + ":" + this.zeroPad((r%60).toFixed(0));
      }
      else {
        // minutes:seconds
        r = d%60;
        if (r < d) {
          return Math.trunc(d/60).toString() + ":" + this.formatSeconds(r%60);
        }
        else {
          // 0:seconds
          return "0:" + this.formatSeconds(d);
        }
      }
    }));
    this.timeAxis.select("path").style("stroke", "none");
    if (!this.showFirstTimeAxisTick) {
      this.timeAxis.select(".tick").remove();
    }
  }

  /**
  * Initialize this TimeFrame.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The width in pixels of this TimeFrame.
  * @param {number} height - The height in pixels of this TimeFrame.
  * @param {number} endTime - The end time for this TimeFrame.
  */
  initialize(parentElement, width, height, endTime) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.width = width;
    this.height = height;
    this.endTime = endTime;

    this.paneWidth = this.width-(this.marginRight+this.marginLeft);
    this.paneHeight = this.height-(this.marginTop+this.marginBottom);

    var axisX = this.marginLeft;
    var axisY = this.marginTop + this.paneHeight;

    this.timeScale = d3.scaleLinear()
      .range([0, this.paneWidth])
      .domain([0, this.endTime]);

    this.timeFrameDiv = this.parentElement.append("div")
      .attr("class", "timeFrameDiv")
      .style("width", "fit-content")
      .on("mouseenter", (event) => {
        if (this.onMouseenter !== undefined) {
          this.onMouseenter();
        }
      })
      .on("mousemove", (event) => {
        if (this.onMousemove !== undefined) {
          this.onMousemove();
        }
      })
      .on("mouseleave", (event) => {
        if (this.onMouseleave !== undefined) {
          this.onMouseleave();
        }
      });

    this.timeFrameSVG = this.timeFrameDiv.append("svg")
      .attr("id", "timeFrameSVG")
      .attr("class", "timeFrameSVG")
      .attr("x", 0)
      .attr("y", 0)
      .attr("width", this.width)
      .attr("height", this.height);

    this.paneSVG = this.timeFrameSVG.append("svg")
      .attr("id", "paneSVG")
      .attr("class", "paneSVG")
      .attr("x", this.marginLeft)
      .attr("y", this.marginTop)
      .attr("width", this.paneWidth)
      .attr("height", this.paneHeight);

    this.frameGroup = this.paneSVG.append("g")
      .attr("class", "frameGroup");

    this.timeFrameRect = this.frameGroup.append("rect")
      .attr("class", "timeFrameRect")
      .attr("x", 0)
      .attr("y", 0)
      .attr("width", this.paneWidth)
      .attr("height", this.paneHeight)
      .style("fill", () => {
        if (this.backgroundColor !== undefined) {
          return this.backgroundColor;
        }
        else {
          return "none";
        }
      })
      .style("stroke", "#000000")
      .style("stroke-width", "1px")
      .style("pointer-events", "none");

    this.paneGroup = this.paneSVG.append("g")
      .attr("class", "paneGroup");

    this.timeAxisGenerator = d3.axisBottom(this.timeScale)
      .tickSizeOuter(0)
      .ticks(this.timeAxisTickCount);

    this.timeAxis = this.timeFrameSVG.append("g")
      .attr("class", "timeAxis")
      .attr("transform", "translate(" + axisX + "," + axisY + ")");

    if (this.grid !== undefined) {
      this.grid.initialize(this.frameGroup, this.paneWidth, this.paneHeight, this.timeScale);
    }

    for (let i = 0; i < this.paneArray.length; i++) {
      this.paneArray[i].initialize(this.paneGroup, this.paneWidth, this.paneHeight, this.timeScale);
    }

    this.initialized = true;
  }

  /**
  * Draw this TimeFrame if it has been initialized.
  */
  draw() {
    if (this.initialized) {
      if (this.showTimeAxis) {
        this.callTimeAxis();
      }

      if (this.showLoader) {
        this.drawLoader();
      }
      else {
        this.eraseLoader();
        if (this.grid !== undefined) {
          if (this.showGrid) {
            this.grid.draw();
          }
          else {
            this.grid.erase();
          }
        }

        for (let i = 0; i < this.paneArray.length; i++) {
          this.paneArray[i].draw();
        }
      }

      if (this.showTimeEditor) {
        if (this.timeEditorGroup !== undefined) {
          this.timeEditorGroup.remove();
        }
        this.timeEditorGroup = this.paneGroup.append("g")
            .attr("class", "timeEditorGroup");
        this.timeEditorBrush = d3.brushX()
          .extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
          .on("start", (event) => {
            if (this.timeEditorBaseLine !== undefined) {
              this.timeEditorBaseLine.remove();
            }
            if (event.selection !== null) {
              this.timeEditorBaseLine = this.timeEditorGroup.append("line")
                .attr("x1", event.selection[0])
                .attr("y1", 1)
                .attr("x2", event.selection[0])
                .attr("y2", this.paneHeight-1)
                .style("stroke", this.timeEditorStroke)
                .style("stroke-width", "1")
                .style("stroke-opacity", "1.0");
            }

          })
          .on("brush", (event) => {
            if (event.selection !== null) {
              this.timeEditorBaseLine
                .attr("x1", event.selection[0])
                .attr("x2", event.selection[0]);
            }
          })
          .on("end", (event) => {
            if (event.selection !== null && event.sourceEvent !== undefined) {
              let baseTime = this.timeScale.invert(event.selection[0]);
              switch (this.timeEditorMode) {
                case Timeframe.INSERT_TIME:
                  this.insertTime(baseTime, this.timeScale.invert(event.selection[1]) - baseTime);
                  break;
                case Timeframe.DELETE_TIME:
                  this.deleteTime(baseTime, this.timeScale.invert(event.selection[1]) - baseTime);
                  break;
              }
            }
          });
        this.timeEditorGroup.call(this.timeEditorBrush);
        this.timeEditorGroup.select(".selection").style("fill", this.timeEditorFill);
        this.timeEditorGroup.select(".selection").style("fill-opacity", "0.1");
      }
      else {
        if (this.timeEditorGroup !== undefined) {
          this.timeEditorGroup.remove();
        }
      }
    }
  }

  /**
  * Erase this TimeFrame.
  */
  erase() {
    if (this.grid !== undefined) {
      this.grid.erase();
    }

    for (let i = 0; i < this.paneArray.length; i++) {
      this.paneArray[i].erase();
    }

    if (this.timeFrameDiv !== undefined) {
      this.timeFrameDiv.remove();
    }
  }

  /**
  * Takes the current width and height (set with initialize()) and sets them as a base size for resetSize() and resetSizeAndZoom().
  */
  setBaseSize() {
    this.baseWidth = this.width;
    this.baseHeight = this.height;

    for (let i = 0; i < this.paneArray.length; i++) {
      if (this.paneArray[i].paneType === "visual") {
        this.paneArray[i].setBaseSize();
      }
    }
  }

  /**
  * Resize this Timeframe immediately.
  * @param {number} width - The width in pixels to use for this TimeFrame.
  * @param {number} height - The height in pixels to use for this TimeFrame.
  */
  resize(width, height) {
    this.erase();
    this.initialize(this.parentElement, width, height, this.endTime);
    this.draw();
  }

  /**
  * Reset the width and height of this Timeframe back to whatever those values were when setBaseSize() was last called.
  */
  resetSize() {
    this.erase();
    this.initialize(this.parentElement, this.baseWidth, this.baseHeight, this.endTime);
    this.draw();
  }

  /**
  * Resize this Timeframe and zoom to the specified time range immediately.
  * @param {number} width - The width in pixels to use for this TimeFrame.
  * @param {number} height - The height in pixels to use for this TimeFrame.
  * @param {number} startTime - The start time to zoom to.
  * @param {number} endTime - The end time to zoom to.
  */
  resizeAndZoom(width, height, startTime, endTime) {
    this.resizeWidth = width;
    this.resizeHeight = height;
    this.erase();
    this.initialize(this.parentElement, width, height, this.endTime);
    this.zoomTimeRange(startTime, endTime);
    this.draw();
  }

  /**
  * Reset the width and height of this Timeframe back to whatever those values were when setBaseSize() was last called
  * and zoom to the specified time range.
  * @param {number} startTime - The start time to zoom to.
  * @param {number} endTime - The end time to zoom to.
  */
  resetSizeAndZoom(startTime, endTime) {
    this.erase();
    this.initialize(this.parentElement, this.baseWidth, this.baseHeight, this.endTime);
    this.zoomTimeRange(startTime, endTime);
    this.draw();
  }

  /**
  * Draw the individual lines in the loader animation.
  */
  drawLoaderLine(x, y1, y2, baseY1, baseY2, animate) {
    var y1Values = y1 + ";" + baseY1 + ";" + y1;
    var y2Values = y2 + ";" + baseY2 + ";" + y2;

    var loaderLine = this.loaderSVG.append("line")
      .attr("class", "loaderLine")
      .attr("x1", x)
      .attr("y1", y1)
      .attr("x2", x)
      .attr("y2", y2)
      .style("stroke", "black")
      .style("stroke-width", "2px");

    if (animate) {
      loaderLine
        .append("animate")
          .attr("attributeName", "y1")
          .attr("dur", "2s")
          .attr("values", y1Values)
          .attr("calcMode", "linear")
          .attr("repeatCount", "indefinite");

      loaderLine
        .append("animate")
          .attr("attributeName", "y2")
          .attr("dur", "2s")
          .attr("values", y2Values)
          .attr("calcMode", "linear")
          .attr("repeatCount", "indefinite");
    }
  }

  /**
  * Draw the loader and animate it until eraseLoader() is called.
  */
  drawLoader() {
    var loaderWidth = 0.5*this.paneWidth;
    var loaderHeight = 0.4*this.paneHeight;
    var halfLoaderHeight = 0.5*loaderHeight;
    var loaderX = this.paneWidth/2-(loaderWidth/2);
    var loaderY = this.paneHeight/2-halfLoaderHeight;
    var lineCount = loaderWidth/4;
    var animatedLineCount = lineCount-2;
    var lineX = 2;
    var offsetY = loaderHeight/25;
    var baseLineY1 = halfLoaderHeight-offsetY;
    var baseLineY2 = halfLoaderHeight+offsetY;

    this.loaderSVG = this.frameGroup.append("svg")
      .attr("id", "loaderSVG")
      .attr("class", "loaderSVG")
      .attr("x", loaderX)
      .attr("y", loaderY)
      .attr("width", loaderWidth)
      .attr("height", loaderHeight);

    this.drawLoaderLine(lineX, baseLineY1, baseLineY2, baseLineY1, baseLineY2, false);
    for (let i = 1; i <= animatedLineCount; i++) {
      lineX += 4;
      let randomDeltaY = Math.random()*(halfLoaderHeight-offsetY);
      this.drawLoaderLine(lineX, baseLineY1-randomDeltaY, baseLineY2+randomDeltaY, baseLineY1, baseLineY2, true);
    }
    lineX += 4;
    this.drawLoaderLine(lineX, baseLineY1, baseLineY2, baseLineY1, baseLineY2, false);
  }

  /**
  * Stop and erase the loader animation.
  */
  eraseLoader() {
    if (this.loaderSVG !== undefined) {
      this.loaderSVG.remove();
    }
  }

  /**
  * Start running an animation. The animation task depends on the onAnimate() callback. The animation is stopped by a boolean set outside of this method
  * though it can also be stopped by the stopAnimation() method. Note that the frame rate is normally the refresh rate of the screen.
  */
  runAnimation() {
    if (this.stopAnimating) {
      this.animating = false;
      this.stopAnimating = false;
    }
    else {
      this.animating = true;

      if (this.onAnimate !== undefined) {
        this.onAnimate();
      }

      window.requestAnimationFrame(this.animate);
    }
  }

  /**
  * Stop the animation from running.
  */
  stopAnimation() {
    if (this.animating) {
      this.stopAnimating = true;
    }
  }
}