Source: visuals.js

/** A visual pane. */
class VisualPane {
  /* Visual drawing mode */
  static DRAW_SUPERIMPOSED = 0;
  static DRAW_STACKED = 1;
  /* Visual drawing order */
  static ORDER_NORMALLY = 0;
  static ORDER_REVERSED = 1;

  /**
  * Create an visual pane.
  * @param {Object} options - Options for this pane.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.paneType = "visual";
    this.options = options;
    this.visualDrawMode = options?.visualDrawMode ?? VisualPane.DRAW_SUPERIMPOSED;
    this.visualDrawOrder = options?.visualDrawOrder ?? VisualPane.ORDER_NORMALLY;
    this.resizeVisuals = options?.resizeVisuals ?? true;
    this.delineateDeltaX = 0;
    this.parentElement;
    this.width;
    this.height;
    this.timeScale;
    this.initialized = false;
    this.visualArray = [];
    this.visualPaneGroup;
    this.visualGroup;
    this.visualPanel;
    this.panelInitialized = false;
    this.panelOn = false;
    this.baseWidth = 0;
    this.baseHeight = 0;
  }

  /**
  * Add a visual to this pane.
  * @param {visual} visual - The visual to add.
  * @param {VisualPanel} panel - An optional panel to associate with this visual.
  */
  addVisual(visual, panel) {
    if (this.initialized) {
      visual.initialize(this.visualGroup, this.width, this.height, this.timeScale);
    }
    this.visualArray.push(visual);
    if (panel !== undefined && panel !== null) {
      if (visual.visualType === "set") {
        panel.addVisualSet(visual);
      }
      else {
        panel.addVisual(visual);
      }
    }
  }

  /**
  * Add an array of visuals to this pane.
  * @param {Array} visuals - The array of visuals to add.
  * @param {VisualPanel} panel - An optional panel to associate with these visuals.
  */
  addVisuals(visuals, panel) {
    for (let i = 0; i < visuals.length; i++) {
      if (this.initialized) {
        visuals[i].initialize(this.visualGroup, this.width, this.height, this.timeScale);
      }

      if (panel !== undefined && panel !== null) {
        if (visual[i].visualType === "set") {
          panel.addVisualSet(visual[i]);
        }
        else {
          panel.addVisual(visual[i]);
        }
      }
    }

    this.visualArray = [...this.visualArray, ...visuals];
  }

  /**
  * Remove a visual from this pane.
  * @param {visual} visualToRemove - The visual to remove.
  * @param {VisualPanel} panel - An optional panel to remove.
  */
  removeVisual(visualToRemove, panel) {
    var visualIndex = this.visualArray.findIndex(visual => visual === visualToRemove);
    if (visualIndex > -1) {
      this.visualArray.splice(visualIndex, 1);
    }
    if (panel !== undefined && panel !== null) {
      panel.removeVisual(visualToRemove);
    }
  }

  /**
  * Remove visuals of the specified type from this pane.
  * @param {string} type - The type to remove.
  */
  removeVisualType(type) {
    for (let i = this.visualArray.length-1; i >=0; i--) {
      if (this.visualArray[i].visualType === type) {
        this.visualArray.splice(i, 1);
      }
    }
  }

  /**
  * Generate a Curve visual based on the provided data and return it.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @return {Curve} A new Curve or undefined if there is no data.
  */
  generateCurve(data, options) {
    return new Curve(data, options);
  }

  /**
  * Create a Curve visual based on the provided data and add it to this pane.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @param {VisualPanel} panel - An optional panel to associate with this visual.
  */
  createCurve(data, options, panel) {
    this.addVisual(this.generateCurve(data, options), panel);
  }

  /**
  * Generate an Instants visual based on the provided data and return it.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @return {Instants} A new Instants or undefined if there is no data.
  */
  generateInstants(data, options) {
    return new Instants(data, options);
  }

  /**
  * Create an Instants visual based on the provided data and add it to this pane.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @param {VisualPanel} panel - An optional panel to associate with this visual.
  */
  createInstants(data, options, panel) {
    this.addVisual(this.generateInstants(data, options), panel);
  }

  /**
  * Generate a Notes visual based on the provided data and return it.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @return {Notes} A new Notes visual.
  */
  generateNotes(data, options) {
    return new Notes(data, options);
  }

  /**
  * Create a Notes visual based on the provided data and add it to this pane.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @param {VisualPanel} panel - An optional panel to associate with this visual.
  */
  createNotes(data, options, panel) {
    this.addVisual(this.generateNotes(data, options), panel);
  }

  /**
  * Generate a Points visual based on the provided data and return it.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @return {Points} A new Points visual.
  */
  generatePoints(data, options) {
    return new Points(data, options);
  }

  /**
  * Create a Points visual based on the provided data and add it to this pane.
  * @param {Object} data - The data.
  * @param {Object} options - Optional options.
  * @param {VisualPanel} panel - An optional panel to associate with this visual.
  */
  createPoints(data, options, panel) {
    this.addVisual(this.generatePoints(data, options), panel);
  }

  /**
  * Initialize the panel associated with this pane and attach it to the specified element.
  * @param {element} panelElement - The element that will contain the panel.
  */
  initializePanel(panelElement) {
    this.panelElement = panelElement instanceof d3.selection ? panelElement : d3.select(panelElement);

    this.visualPanePanelDiv = this.panelElement.append("div")
      .attr("class", "visualPanePanelDiv")
      .style("display", "flex")
      .style("flex-direction", "column")
      .style("font-family", "sans-serif");

    if (this.visualPanel !== undefined) {
      this.visualPanel.initialize(this.visualPanePanelDiv);
    }

    this.panelInitialized = true;
  }

  /**
  * Draw the panel associated with this pane.
  */
  drawPanel() {
    if (this.panelInitialized) {
      this.panelOn = true;

      if (this.visualPanel !== undefined) {
        this.visualPanel.draw();
      }
    }
  }

  /**
  * Erase the panel associated with this pane.
  */
  erasePanel() {
    if (this.visualPanePanelDiv !== undefined) {
      this.visualPanePanelDiv.selectAll("*").remove();
      this.panelOn = false;
    }
  }

  /**
  * Initialize this VisualPane.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.width = width;
    this.height = height;
    this.timeScale = timeScale;

    this.visualPaneGroup = this.parentElement.append("g")
      .attr("class", "visualPaneGroup");

    this.visualPaneGroup.append("rect")
      .attr("class", "visualPaneEventRect")
      .attr("x", 0)
      .attr("y", 0)
      .attr("width", this.width)
      .attr("height", this.height)
      .style("fill", "none");

    this.visualGroup = this.visualPaneGroup.append("g")
      .attr("class", "visualGroup");

    if (this.visualArray.length > 0) {
      for (let i = 0; i < this.visualArray.length; i++) {
        this.visualArray[i].initialize(this.visualGroup, this.width, this.height, this.timeScale);
      }
    }

    this.initialized = true;
  }

  /**
  * Draw this VisualPane and any visuals it contains.
  */
  draw() {
    if (this.initialized) {
      if (this.visualArray.length > 0) {
        let curveArray = this.visualArray.filter(visual => visual.visualType === "curve");
        let visualHeight;
        let visualSpace = 0;
        if (this.resizeVisuals) {
          visualHeight = this.height/curveArray.length;
        }
        else {
          visualHeight = this.baseHeight/curveArray.length;
          visualSpace = Math.abs((this.height-this.baseHeight)/(curveArray.length+1));
        }
        for (let i = 0; i < this.visualArray.length; i++) {
          if (this.visualArray[i].on) {
            if (this.visualDrawMode === VisualPane.DRAW_STACKED && this.visualArray[i].visualType === "curve") {
              if (this.visualDrawOrder === VisualPane.ORDER_NORMALLY) {
                this.visualArray[i].setValueRange(((i+1)*visualHeight)+((i+1)*visualSpace), (i*visualHeight)+((i+1)*visualSpace));
              }
              else {
                this.visualArray[i].setValueRange((this.height-(i*visualHeight))+((i+1)*visualSpace), (this.height-((i+1)*visualHeight)+((i+1)*visualSpace)));
              }
            }
            this.visualArray[i].draw();
          }
          else {
            this.visualArray[i].erase();
          }
        }
      }
    }
  }

  /**
  * Draw visuals of the specified type.
  * @param {string} type - The type of the visuals.
  */
  drawVisualType(type) {
    if (this.initialized) {
      for (let i = 0; i < this.visualArray.length; i++) {
        if (this.visualArray[i].visualType === type) {
          this.visualArray[i].on = true;
          this.visualArray[i].draw(this.timeScale, this.visualGroup, this.width, this.height);
        }
      }
    }
  }

  /**
  * Erase this VisualPane.
  */
  erase() {
    if (this.visualPaneGroup !== undefined) {
      this.visualPaneGroup.remove();
    }
  }

  /**
  * Erase visuals of the specified type from this pane.
  * @param {string} type - The type of the visuals.
  */
  eraseVisualType(type) {
    for (let i = 0; i < this.visualArray.length; i++) {
      if (this.visualArray[i].visualType === type) {
        this.visualArray[i].on = false;
        this.visualArray[i].erase();
      }
    }
  }

  /**
  * Erase all visuals from this pane.
  */
  eraseAllVisuals() {
    if (this.visualGroup !== undefined) {
      this.visualGroup.selectAll("*").remove();
    }
  }

  /**
  * Get the visuals associated with this pane and return them in an array.
  * @return {Array} An array of visuals.
  */
  getVisuals() {
    return this.visualArray;
  }

  /**
  * Determine whether this pane contains visuals of the specified type.
  * @param {string} type - The type of visual.
  * @return {boolean} True if this pane contains the type; false otherwise.
  */
  hasVisualType(type) {
    var foundType = this.visualArray.find(visual => visual.visualType === type);
    if (foundType !== undefined) {
      return true;
    }
    else {
      return false;
    }
  }

  /**
  * Change the display indicator of the specified visual type to on or off. Note that setting visuals to off with this method does not erase them
  * and setting them to on does not draw them. This method just changes the value of the display indicator to on or off for a later erase or draw.
  * @param {string} type - The type of visual.
  * @param {boolean} on - True turns the visual type on; false turns it off.
  */
  setVisualTypeOn(type, on) {
    var visual = this.getVisualType(type);
    if (visual.length > 0) {
      for (let i =0; i < visual.length; i++) {
        visual[i].on = on;
      }
    }
  }

  /**
  * Get the specified visual type and an array of visuals.
  * @param {string} type - The type of visual.
  * @return {Array} An array of visuals or undefined if no visuals are found.
  */
  getVisualType(type) {
    return this.visualArray.filter(visual => visual.visualType === type);
  }

  /**
  * Clear all visuals in the pane.
  */
  clearVisuals() {
    this.visualArray = [];

    if (this.visualPanel !== undefined) {
      this.visualPanel.clearVisuals();
    }
  }

  /**
  * Activate this pane, allowing it to accept pointer events.
  */
  activate() {
    if (this.initialized) {
      this.visualPaneGroup.style("pointer-events", "visible");
    }
  }

  /**
  * Deactivate this pane, preventing it from accepting pointer events.
  */
  deactivate() {
    if (this.initialized) {
      this.visualPaneGroup.style("pointer-events", "none");
    }
  }

  /**
  * Raise this pane to the top of the list of panes in its containing TimeFrame.
  */
  raise() {
    if (this.visualPaneGroup !== undefined) {
      this.visualPaneGroup.raise();
    }
  }

  /**
  * Lower this pane to the bottom of the list of panes in its containing TimeFrame.
  */
  lower() {
    if (this.visualPaneGroup !== undefined) {
      this.visualPaneGroup.lower();
    }
  }

  /**
  * Raises the specified visual such that it will be drawn over all other visuals.
  * @param {visual} visual - The visual to raise.
  */
  raiseVisual(visual) {
    var visualIndex = this.visualArray.indexOf(visual);
    if (visualIndex > -1) {
      this.visualArray.splice(visualIndex, 1);
      this.visualArray.push(visual);
    }
  }

  /**
  * Lowers the specified visual such that it will be drawn under all other visuals.
  * @param {visual} visual - The visual to lower.
  */
  lowerVisual(visual) {
    var visualIndex = this.visualArray.indexOf(visual);
    if (visualIndex > -1) {
      this.visualArray.splice(visualIndex, 1);
      this.visualArray.unshift(visual);
    }
  }

  /**
  * Takes the current width and height (set with initialize()) and sets them as a base size. This is particularly useful
  * for situations in which this VisualPane is being resized but the visuals themselves are set to maintain their size via the
  * the resizeVisuals boolean being set to false.
  */
  setBaseSize() {
    this.baseWidth = this.width;
    this.baseHeight = this.height;
  }

  /**
  * 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.visualArray.length; i++) {
      if ("insertTime" in this.visualArray[i]) {
        this.visualArray[i].insertTime(baseTime, deltaTime);
      }
    }
  }

  /**
  * Determines whether time in the specified time range can be deleted.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  canDeleteTime(baseTime, deltaTime) {
    var canDelete = true;
    for (let i = 0; i < this.visualArray.length; i++) {
      if ("canDeleteTime" in this.visualArray[i]) {
        canDelete = this.visualArray[i].canDeleteTime(baseTime, deltaTime);
        if (!canDelete) {
          break;
        }
      }
    }
    return canDelete;
  }

  /**
  * 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) {
    for (let i = 0; i < this.visualArray.length; i++) {
      if ("deleteTime" in this.visualArray[i]) {
        this.visualArray[i].deleteTime(baseTime, deltaTime);
      }
    }
  }
}

/** A visual panel. */
class VisualPanel {
  /**
  * Create a visual panel.
  * @param {Object[]} visualArray - An array of visuals.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(visualArray, options) {
    this.visualArray = visualArray ?? [];
    this.options = options;
    this.name = options?.name ?? null;
    this.showTitle = options?.showTitle ?? true;
    this.showSelectAllVisuals = options?.showSelectAllVisuals ?? true;
    this.visualSetArray = [];
    this.visualSetNameArray = [];
    this.currentVisualSet;
    this.panelArray = [];
    this.panelNameArray = [];
    this.panelIndex = 0;
    this.parentElement;
    this.visualPanelDiv;
    this.visualSetDiv;
  }

  /**
  * Add the specified visual to this panel.
  * @param {visual} visual - The visual to add.
  */
  addVisual(visual) {
    this.visualArray.push(visual);
  }

  /**
  * Remove the specified visual from this panel.
  * @param {visual} visualToRemove - The visual to remove.
  */
  removeVisual(visualToRemove) {
    var visualIndex = this.visualArray.findIndex(visual => visual === visualToRemove);
    if (visualIndex > -1) {
      this.visualArray.splice(visualIndex, 1);
    }
  }

  /**
  * Add the specified visual set to this panel.
  * @param {VisualSet} visualSet - The visual set to add.
  */
  addVisualSet(visualSet) {
    this.visualSetArray.push(visualSet);
    if (visualSet.name !== null) {
      this.visualSetNameArray.push(visualSet.name);
      this.visualSetNameArray.sort();
    }
  }

  /**
  * Add an panel to this panel.
  * @param {VisualPanel} panel - The panel to add.
  */
  addPanel(panel) {
    this.panelArray.push(panel);
    if (panel.name !== null) {
      this.panelNameArray.push(panel.name);
    }
  }

  /**
  * Initialize this panel by attaching it to the specified parent element.
  * @param {d3.selection} parentElement - The parent element.
  */
  initialize(parentElement) {
    this.parentElement = parentElement;
  }

  /**
  * Draw this panel and any panels that it contains.
  */
  draw() {
    this.erase();

    if (this.showTitle) {
      var visualTitleDiv = this.parentElement.append("div")
        .attr("class", "visualTitleDiv")
        .style("font-family", "sans-serif")
        .style("font-weight", "bold")
        .style("font-size", "x-large")
        .style("padding-bottom", "4%")
        .text("Visuals");
    }

    if (this.panelArray.length > 1) {
      this.drawPanelSelect();
    }

    this.drawPanelItems();

    if (this.panelArray.length > 0) {
      this.panelArray[this.panelIndex].initialize(this.visualPanelDiv);
      this.panelArray[this.panelIndex].drawPanelItems();
    }
  }

  /**
  * Draw a select list of any panels that this panel contains.
  */
  drawPanelSelect() {
    var visualPanelListDiv = this.parentElement.append("div")
      .attr("class", "visualPanelListDiv")
      .style("padding-bottom", "4%")
      .append("select")
        .attr("id", "visualPanelSelect")
        .on("change", (event) => {
          this.panelIndex = parseInt(event.target.value);
          this.visualPanelDiv.selectChildren().remove();
          this.panelArray[this.panelIndex].initialize(this.visualPanelDiv);
          this.panelArray[this.panelIndex].drawPanelItems();
        })
        .selectAll("option")
          .data(this.panelNameArray)
          .enter()
          .append("option")
            .attr("value", (d, i) => i)
            .property("selected", (d, i) => this.panelIndex === i)
            .text((d) => { return d; });
  }

  /**
  * Draw controls for visuals in a visual set.
  */
  drawVisualSetItemControls(visualSetItem) {
    this.visualSetDiv.selectAll(".visualItemDiv").remove();
    if (visualSetItem.visualArray.length > 0) {
      for (let i = 0; i < visualSetItem.visualArray.length; i++) {
        var visualItemDiv = this.visualSetDiv.append("div")
         .attr("class", "visualItemDiv")
          .style("display", "flex")
          .style("flex-direction", "row")
          .style("align-items", "center");
        var visualCheckboxDiv = visualItemDiv.append("div")
          .attr("class", "visualCheckboxDiv")
          .append("input")
            .attr("type", "checkbox")
            .property("checked", () => {
              if (visualSetItem.visualArray[i].on) {
                return true;
              }
              else {
                return false;
              }
            })
            .on("change", (event) => {
              visualSetItem.visualArray[i].on = event.target.checked;
              if (event.target.checked) {
                visualSetItem.visualArray[i].draw();
              }
              else {
                visualSetItem.visualArray[i].erase();
              }
            });
        var visualColorDiv = visualItemDiv.append("div")
          .attr("class", "visualColorDiv")
          .style("color", () => {
            if ("color" in visualSetItem.visualArray[i]) {
              return visualSetItem.visualArray[i].color;
            }
            else if ("fill" in visualSetItem.visualArray[i]) {
              return visualSetItem.visualArray[i].fill;
            }
          })
          .style("opacity", "0.60")
          .style("padding-left", "2%")
          .style("padding-right", "2%")
          .text("\u25FC");
        if (visualSetItem.visualArray[i].name !== null) {
          var visualNameDiv = visualItemDiv.append("div")
            .attr("class", "visualNameDiv")
            .text(visualSetItem.visualArray[i].name);
        }
      }
    }
  }

  /**
  * Draw a select list of visuals in a visual set and then draw controls for those visuals.
  */
  drawVisualSetControls() {
    this.visualSetDiv = this.visualPanelDiv.append("div")
      .attr("class", "visualSetDiv")
      .style("display", "flex")
      .style("flex-direction", "column");
    var visualSetListDiv = this.visualSetDiv.append("div")
      .attr("class", "visualSetListDiv")
      .style("padding-bottom", "4%")
      .append("select")
        .attr("id", "visualSetSelect")
        .on("change", (event) => {
          this.currentVisualSet = this.visualSetArray[event.target.value];
          this.drawVisualSetItemControls(this.currentVisualSet);
        })
        .selectAll("option")
          .data(this.visualSetNameArray)
          .enter()
          .append("option")
            .attr("value", (d, i) => i)
            .text((d) => { return d; });
    this.currentVisualSet = this.visualSetArray[0];
    this.drawVisualSetItemControls(this.currentVisualSet);
  }

  /**
  * Draw any properties of a visual including the names of the properties and their values.
  * @param {d3.selection} parentElement - The parent element.
  * @param {Array} properties - An array of properties.
  */
  drawItemProperties(parentElement, properties) {
    var visualPropertyDiv = parentElement.append("div")
      .attr("class", "visualPropertyDiv")
      .style("display", "flex")
      .style("flex-direction", "column")
      .style("margin-left", "10ex");
    for (let i = 0; i < properties.length; i++) {
      var visualPropertyItemDiv = visualPropertyDiv.append("div")
        .attr("class", "visualPropertyItemDiv")
        .style("display", "flex")
        .style("flex-direction", "row");
      var visualPropertyNameDiv = visualPropertyItemDiv.append("div")
        .attr("class", "visualPropertyNameDiv")
        .style("margin-right", "1ex")
        .style("white-space", "nowrap")
        .text(properties[i][0] + ":");
      var visualPropertyValueDiv = visualPropertyItemDiv.append("div")
        .attr("class", "visualPropertyValueDiv")
        .style("font-weight", "bold")
        .text(properties[i][1]);
    }
  }

  /**
  * Draw controls for visuals.
  */
  drawPanelItems() {
    this.visualPanelDiv = this.parentElement.append("div")
      .attr("class", "visualPanelDiv")
      .style("font-family", "sans-serif");

    if (this.visualSetArray.length > 0 && this.visualSetNameArray.length > 0) {
      this.drawVisualSetControls();
    }

    if (this.visualArray.length > 0) {
      if (this.showSelectAllVisuals) {
        var selectAllListItem = this.visualPanelDiv.append("div")
          .attr("class", "selectAllListItem")
          .style("margin-bottom", "1ex");
        var selectAllCheckboxDiv = selectAllListItem.append("input")
          .attr("type", "checkbox")
          .style("margin-right", "1ch")
          .property("checked", () => {
            var checkedCount = 0;
            for (let i = 0; i < this.visualArray.length; i++) {
              if (this.visualArray[i].on === true) {
                checkedCount++;
              }
            }
            return checkedCount === this.visualArray.length;
          })
          .on("change", (event) => {
            for (let i = 0; i < this.visualArray.length; i++) {
              this.visualArray[i].on = event.target.checked;
              if (event.target.checked) {
                this.visualArray[i].draw();
              }
              else {
                this.visualArray[i].erase();
              }
            }
            this.visualPanelDiv.remove();
            this.drawPanelItems();
          });
        var selectAllTextDiv = selectAllListItem.append("div")
          .attr("class", "selectAllTextDiv")
          .text("Select All");
      }
      for (let i = 0; i < this.visualArray.length; i++) {
        if (this.visualArray[i].showControls) {
          var visualDiv = this.visualPanelDiv.append("div")
            .attr("class", "visualDiv")
            .style("display", "flex")
            .style("flex-direction", "column");
          var visualItemDiv = visualDiv.append("div")
            .attr("class", "visualItemDiv")
            .style("display", "flex")
            .style("flex-direction", "row")
            .style("align-items", "center");
          var visualCheckboxDiv = visualItemDiv.append("div")
            .attr("class", "visualCheckboxDiv")
            .append("input")
              .attr("type", "checkbox")
              .property("checked", () => {
                if (this.visualArray[i].on) {
                  return true;
                }
                else {
                  return false;
                }
              })
              .on("change", (event) => {
                this.visualArray[i].on = event.target.checked;
                if (event.target.checked) {
                  this.visualArray[i].draw();
                }
                else {
                  this.visualArray[i].erase();
                }
              });
          var visualColorDiv = visualItemDiv.append("div")
            .attr("class", "visualColorDiv")
            .style("color", () => {
              if ("color" in this.visualArray[i]) {
                return this.visualArray[i].color;
              }
              else if ("fill" in this.visualArray[i]) {
                return this.visualArray[i].fill;
              }
            })
            .style("opacity", "0.60")
            .style("padding-left", "2%")
            .style("padding-right", "2%")
            .text("\u25FC");
          if (this.visualArray[i].name !== null) {
            var visualNameDiv = visualItemDiv.append("div")
              .attr("class", "visualNameDiv")
              .attr("id", "visualNameDiv" + i)
              .style("white-space", "nowrap")
              .text(this.visualArray[i].name);
          }

          if (this.visualArray[i].showProperties) {
            this.drawItemProperties(visualDiv, this.visualArray[i].propertyArray);
          }
        }
      }
    }
  }

  /**
  * Erase this panel.
  */
  erase() {
    if (this.visualPanelDiv !== undefined) {
      this.visualPanelDiv.remove();
    }
  }

  /**
  * Clear all visuals from this panel.
  */
  clearVisuals() {
    this.visualArray = [];
  }
}

/**
* An editor that works by being overlayed on top of visuals in
* a pane. Note that the VisualEditor only works in terms and x and y.
* It does not have any sense of time since it does not have a time scale.
*/
class VisualEditor {
  static DISABLE = -1;
  static BRUSH = 0;
  static BRUSH_X = 1;
  static BRUSH_Y = 2;
  static CLICK = 3;
  static DRAG = 4;
  static MOUSEMOVE = 5;

  /**
  * Create a visual editor.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.mode = VisualEditor.DISABLE;
    this.moveBox = options?.moveBox ?? true;
    this.resizeBox = options?.resizeBox ?? true;
    this.showAdjuster = options?.showAdjuster ?? false;
    this.delineateValues = options?.delineateValues ?? false;
    this.fill = options?.fill ?? "#ff0000";
    this.fillOpacity = options?.fillOpacity ?? "0.1";
    this.stroke = options?.stroke ?? "#ff0000";
    this.strokeOpacity = options?.strokeOpacity ?? "1.0";
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.initialized = false;
    this.visualEditorGroup;
    this.editBoxGroup;
    this.dx = 0;
    this.onBrush = null;
    this.onBrushX = null;
    this.onBrushY = null;
    this.onClick = null;
    this.onDragStart = null;
    this.onDrag = null;
    this.onDragEnd = null;
    this.onMoveBoxStart = null;
    this.onMoveBoxDrag = null;
    this.onMoveBoxEnd = null;
    this.onDelineate = null;
  }

  /**
  * Set the specified visual to be edited by this editor, calling setEditor() for the visual to set relevant callbacks.
  * @param {object} visual - Configuration options.
  */
  setVisual(visual) {
    this.clear();
    this.erase();
    visual.setEditor(this);
  }

  /**
  * Initialize this VisualEditor.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  */
  initialize(parentElement, width, height) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.initialized = true;
  }

  /**
  * Draw this VisualEditor.
  */
  draw() {
    if (this.initialized) {
      if (this.visualEditorGroup !== undefined) {
        this.visualEditorGroup.remove();
      }

      this.visualEditorGroup = this.parentElement.append("g")
        .attr("class", "visualEditorGroup");

      if (this.mode === VisualEditor.BRUSH || this.mode === VisualEditor.BRUSH_X || this.mode === VisualEditor.BRUSH_Y) {
        let selectBrushGroup = this.visualEditorGroup.append("g")
          .attr("class", "selectBrushGroup")
          .on("click", (event) => {
            event.target.dispatchEvent(
              new CustomEvent(
                "editorClick",
                {bubbles: true, detail: {x: d3.pointer(event)[0], y: d3.pointer(event)[1], append: event.ctrlKey || event.metaKey}}
              )
            );
          });

        let selectBrush;
        switch (this.mode) {
          case VisualEditor.BRUSH:
            selectBrush = d3.brush()
              .extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
              .on("end", (event) => {
                if (event.selection !== null && event.sourceEvent !== undefined && this.onBrush !== null) {
                  this.onBrush(
                    event.selection[0][0],
                    event.selection[1][0],
                    event.selection[1][1],
                    event.selection[0][1],
                    event.sourceEvent.ctrlKey || event.sourceEvent.metaKey
                  );
                }
              });
            break;
          case VisualEditor.BRUSH_X:
            selectBrush = d3.brushX()
              .extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
              .on("end", (event) => {
                if (event.selection !== null && event.sourceEvent !== undefined && this.onBrushX !== null) {
                  this.onBrushX(event.selection[0], event.selection[1], event.sourceEvent.ctrlKey || event.sourceEvent.metaKey);
                }
              });
            break;
          case VisualEditor.BRUSH_Y:
            selectBrush = d3.brushY()
              .extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
              .on("end", (event) => {
                if (event.selection !== null && event.sourceEvent !== undefined && this.onBrushY !== null) {
                  this.onBrushY(event.selection[1], event.selection[0], event.sourceEvent.ctrlKey || event.sourceEvent.metaKey);
                }
              });
            break;
        }

        selectBrushGroup.call(selectBrush);
        selectBrushGroup.select(".selection").style("fill", this.fill);
        selectBrushGroup.select(".selection").style("fill-opacity", this.fillOpacity);
        selectBrushGroup.select(".selection").style("stroke", this.stroke);
        selectBrushGroup.select(".selection").style("stroke-opacity", this.strokeOpacity);
      }
      else if (this.mode === VisualEditor.CLICK) {
        this.visualEditorGroup.append("rect")
          .attr("class", "visualEditorEventRect")
          .attr("x", 0)
          .attr("y", 0)
          .attr("width", this.paneWidth)
          .attr("height", this.paneHeight)
          .style("fill", "none")
          .style("pointer-events", "visible")
          .style("cursor", "crosshair")
          .on("click", (event) => {
            if (this.onClick !== null) {
              this.onClick(d3.pointer(event)[0], d3.pointer(event)[1]);
            }
          });
      }
      else if (this.mode === VisualEditor.DRAG) {
        this.visualEditorGroup.append("rect")
          .attr("class", "visualEditorEventRect")
          .attr("x", 0)
          .attr("y", 0)
          .attr("width", this.paneWidth)
          .attr("height", this.paneHeight)
          .style("fill", "none")
          .style("pointer-events", "visible")
          .style("cursor", "crosshair")
          .call(d3.drag()
            .on("start", (event) => {
              if (this.onDragStart !== null) {
                this.onDragStart(d3.pointer(event)[0], d3.pointer(event)[1]);
              }
            })
            .on("drag", (event) => {
              if (this.onDrag !== null) {
                this.onDrag(event.dx, event.dy);
              }
              event.sourceEvent.stopPropagation();
            })
            .on("end", (event) => {
              if (this.onDragEnd !== null) {
                this.onDragEnd();
              }
            })
          );
      }
      else if (this.mode === VisualEditor.MOUSEMOVE) {
        this.visualEditorGroup.append("rect")
          .attr("class", "visualEditorEventRect")
          .attr("x", 0)
          .attr("y", 0)
          .attr("width", this.paneWidth)
          .attr("height", this.paneHeight)
          .style("fill", "none")
          .style("pointer-events", "visible")
          .style("cursor", "crosshair")
          .on("click", (event) => {
            event.target.dispatchEvent(
              new CustomEvent(
                "editorClick",
                {bubbles: true, detail: {x: d3.pointer(event)[0], y: d3.pointer(event)[1], append: event.ctrlKey || event.metaKey}}
              )
            );
          })
          .on("mousemove", (event) => {
            if (this.delineateValues && event.shiftKey) {
              if (Math.abs(event.movementX) > 0) {
                this.onDelineate(event.movementX, d3.pointer(event)[0], d3.pointer(event)[1]);
              }
              event.stopPropagation();
            }
          });
      }
    }
  }

  /**
  * Draw the edit box.
  * @param {number} startX - Start x.
  * @param {number} endX - End x.
  * @param {number} startY - Start y.
  * @param {number} endY - End y.
  */
  drawEditBox(startX, endX, startY, endY) {
    if (this.mode !== VisualEditor.DISABLE) {
      this.editBoxGroup = this.visualEditorGroup.append("g")
        .attr("class", "editBoxGroup");
      let startLine = this.editBoxGroup.append("line")
        .attr("class", "startLine")
        .attr("x1", startX)
        .attr("y1", 0)
        .attr("x2", startX)
        .attr("y2", this.paneHeight)
        .style("stroke", this.stroke)
        .style("stroke-width", "1");
      let moveLine = this.editBoxGroup.append("line")
        .attr("class", "moveLine")
        .attr("x1", startX)
        .attr("y1", 0)
        .attr("x2", startX)
        .attr("y2", this.paneHeight)
        .style("stroke", "#00000000")
        .style("stroke-width", "15")
        .on("mouseenter", () => {
          moveLine.style("cursor", "move");
        })
        .call(d3.drag()
          .on("start", (event) => {
            if (this.onMoveBoxStart !== null) {
              this.onMoveBoxStart(d3.pointer(event)[0], d3.pointer(event)[1]);
            }
          })
          .on("drag", (event) => {
            if (this.moveBox) {
              if ((startX + event.dx) > 0 && (endX + event.dx) < this.paneWidth) {
                startX += event.dx;
                endX += event.dx;
                this.dx += event.dx;
              }
              startLine
                .attr("x1", startX)
                .attr("x2", startX);
              moveLine
                .attr("x1", startX)
                .attr("x2", startX);
              endLine
                .attr("x1", endX)
                .attr("x2", endX);
              resizeLine
                .attr("x1", endX)
                .attr("x2", endX);

              if (this.onMoveBoxDrag !== null) {
                this.onMoveBoxDrag(event.dx);
              }

              event.sourceEvent.stopPropagation();
            }
          })
          .on("end", (event) => {
            if (this.moveBox) {
              if (this.onMoveBoxEnd !== null) {
                this.onMoveBoxEnd(this.dx);
              }
              this.dx = 0; // Do this AFTER calling onEdit()
            }
          })
        );
      let endLine = this.editBoxGroup.append("line")
        .attr("class", "endLine")
        .attr("x1", endX)
        .attr("y1", 0)
        .attr("x2", endX)
        .attr("y2", this.paneHeight)
        .style("stroke", this.stroke)
        .style("stroke-width", "1")
      let resizeLine = this.editBoxGroup.append("line")
        .attr("class", "resizeLine")
        .attr("x1", endX)
        .attr("y1", 0)
        .attr("x2", endX)
        .attr("y2", this.paneHeight)
        .style("stroke", "#00000000")
        .style("stroke-width", "15")
        .on("mouseenter", () => {
          if (this.resizeBox) {
            resizeLine.style("cursor", "ew-resize");
          }
        })
        .call(d3.drag()
          .on("drag", (event) => {
            if (this.resizeBox) {
              if ((endX + event.dx) > startX && (endX + event.dx) < this.paneWidth) {
                endX += event.dx;
              }
              endLine
                .attr("x1", endX)
                .attr("x2", endX);
              resizeLine
                .attr("x1", endX)
                .attr("x2", endX);
              event.sourceEvent.stopPropagation();
            }
          })
          .on("end", (event) => {
            if (this.resizeBox) {
              if (this.onEdit !== undefined) {
                this.onEdit();
              }
            }
          })
        );

      if (this.showAdjuster) {
        this.adjustLineX1 = startX;
        this.adjustLineX2 = endX;
        this.adjustLineY1 = this.paneHeight/2;
        this.adjustLineY2 = this.paneHeight/2;

        let adjustLine = this.editBoxGroup.append("line")
          .attr("class", "adjustLine")
          .attr("x1", this.adjustLineX1)
          .attr("y1", this.adjustLineY1)
          .attr("x2", this.adjustLineX2)
          .attr("y2", this.adjustLineY2)
          .style("stroke", this.stroke)
          .style("stroke-width", "3");
        let adjustHoldLine = this.editBoxGroup.append("line")
          .attr("class", "adjustHoldLine")
          .attr("x1", this.adjustLineX1)
          .attr("y1", this.adjustLineY1)
          .attr("x2", this.adjustLineX2)
          .attr("y2", this.adjustLineY2)
          .style("stroke", "#00000000")
          .style("stroke-width", "16")
          .on("mouseenter", () => {
            adjustHoldLine.style("cursor", "ns-resize");
          })
          .call(d3.drag()
            .on("drag", (event, d) => {
              if (
                ((this.adjustLineY1 + event.dy) >= 0 &&
                (this.adjustLineY1 + event.dy) <= this.paneHeight) &&
                ((this.adjustLineY2 + event.dy) >= 0 &&
                (this.adjustLineY2 + event.dy) <= this.paneHeight)
              ) {
                this.adjustLineY1 += event.dy;
                this.adjustLineY2 += event.dy;
                adjustLine
                  .attr("y1", this.adjustLineY1)
                  .attr("y2", this.adjustLineY2);
                adjustHoldLine
                  .attr("y1", this.adjustLineY1)
                  .attr("y2", this.adjustLineY2);
                leftAdjustCircle.attr("cy", this.adjustLineY1);
                rightAdjustCircle.attr("cy", this.adjustLineY2);
              }
              event.sourceEvent.stopPropagation();
            })
            .on("end", (event, d) => {
              // console.log("y1: " + this.adjustLineY1 + " y2: " + this.adjustLineY2);
            })
          );
        let leftAdjustCircle = this.editBoxGroup.append("circle")
          .attr("class", "leftAdjustCircle")
          .attr("cx", this.adjustLineX1)
          .attr("cy", this.adjustLineY1)
          .attr("r", "6")
          .style("fill", this.stroke)
          .style("stroke", "none")
          .on("mouseenter", () => {
            leftAdjustCircle.style("cursor", "ns-resize");
          })
          .call(d3.drag()
            .on("drag", (event, d) => {
              if ((this.adjustLineY1 + event.dy) >= 0 && (this.adjustLineY1 + event.dy) <= this.paneHeight) {
                this.adjustLineY1 += event.dy;
              }
              leftAdjustCircle.attr("cy", this.adjustLineY1);
              adjustLine.attr("y1", this.adjustLineY1);
              adjustHoldLine.attr("y1", this.adjustLineY1);
              event.sourceEvent.stopPropagation();
            })
            .on("end", (event, d) => {
              // console.log("y1: " + this.adjustLineY1 + " y2: " + this.adjustLineY2);
            })
          );
        let rightAdjustCircle = this.editBoxGroup.append("circle")
          .attr("class", "rightAdjustCircle")
          .attr("cx", this.adjustLineX2)
          .attr("cy", this.adjustLineY2)
          .attr("r", "6")
          .style("fill", this.stroke)
          .style("stroke", "none")
          .on("mouseenter", () => {
            rightAdjustCircle.style("cursor", "ns-resize");
          })
          .call(d3.drag()
            .on("drag", (event, d) => {
              if ((this.adjustLineY2 + event.dy) >= 0 && (this.adjustLineY2 + event.dy) <= this.paneHeight) {
                this.adjustLineY2 += event.dy;
              }
              rightAdjustCircle.attr("cy", this.adjustLineY2);
              adjustLine.attr("y2", this.adjustLineY2);
              adjustHoldLine.attr("y2", this.adjustLineY2);
              event.sourceEvent.stopPropagation();
            })
            .on("end", (event, d) => {
              // console.log("y1: " + this.adjustLineY1 + " y2: " + this.adjustLineY2);
            })
          );
      }
    }
  }

  /**
  * Erase this VisualEditor.
  */
  erase() {
    if (this.visualEditorGroup !== undefined) {
      this.visualEditorGroup.remove();
    }
  }

  /**
  * Erase the edit box.
  */
  eraseEditBox() {
    if (this.editBoxGroup !== undefined) {
      this.editBoxGroup.remove();
    }
  }

  /**
  * Clear this VisualEditor by setting all of the callbacks to null.
  */
  clear() {
    this.onBrush = null;
    this.onBrushX = null;
    this.onBrushY = null;
    this.onClick = null;
    this.onDragStart = null;
    this.onDrag = null;
    this.onDragEnd = null;
    this.onMoveBoxStart = null;
    this.onMoveBoxDrag = null;
    this.onMoveBoxEnd = null;
  }
}

/** A Curve visual is a linear curve. */
class Curve {
  static AREA = 0;
  static LINE = 1;

  /**
  * Create a curve visual.
  * @param {Object[]} data - An array of data.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(data, options) {
    this.visualType = "curve";
    this.data = data;
    this.options = options;
    this.name = options?.name ?? null;
    this.curveType = options?.curveType ?? Curve.LINE;
    this.invert = options?.invert ?? false;
    this.on = options?.on ?? true;
    this.editable= options?.editable ?? false;
    this.color = options?.color ?? "#000000";
    this.width = options?.width ?? "2";
    this.opacity = options?.opacity ?? "1.0";
    this.style = options?.style ?? "solid";
    this.selected = false;
    this.selectedColor = options?.selectedColor ?? "#ff0000";
    this.selectedWidth = options?.selectedWidth ?? "4";
    this.selectedOpacity = options?.selectedOpacity ?? "1.0";
    this.showAreaOutline = options?.showAreaOutline ?? false;
    this.closeAreaOutline = options?.closeAreaOutline ?? false;
    this.showProperties = options?.showProperties ?? false;
    this.showControls = options?.showControls ?? true;
    this.timeName = options?.timeName ?? "time";
    this.valueName = options?.valueName ?? "value";
    this.valueType = options?.valueType ?? "float";
    this.valueDomainStart = options?.valueDomainStart ?? d3.min(this.data, (d) => d[this.valueName]);
    this.valueDomainEnd = options?.valueDomainEnd ?? d3.max(this.data, (d) => d[this.valueName]);
    this.valueRangeStart = options?.valueRangeStart;
    this.valueRangeEnd = options?.valueRangeEnd;
    this.propertyArray = [];
    this.visualArray = [];
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.valueScale;
    this.editor;
    this.initialized = false;
    this.curveGroup;
    this.onDraw;
    this.onErase;
    this.onEdit;
  }

  /**
  * Append the provided data array to the existing data array in this visual.
  * @param {Array} data - The data array to append.
  */
  appendData(data) {
    this.data = [...this.data, ...data];
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Include the visual type indicated by the type string with the specified options.
  * @param {string} visualType - The visual type.
  * @param {object} options - Options for the visual type.
  */
  includeVisual(visualType, options) {
    var visual;
    if (visualType === "points") {
      visual = new Points(this.data, options);

    }
    else if (visualType === "instants") {
      visual = new Instants(this.data, options);
    }
    if (visual !== undefined) {
      visual.onEdit = this.draw.bind(this);
      if (this.initialized) {
        visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
      }
      this.visualArray.push(visual);
    }
    return visual;
  }

  /**
  * Set the editor for this visual and set editing callbacks.
  * @param {object} editor - The VisualEditor to set.
  */
  setEditor(editor) {
    this.editor = editor;
    this.editor.onDelineate = this.delineate.bind(this);
  }

  /**
  * Select this curve and optionally redraw it.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  select(callDraw = true) {
    this.selected = true;
    if (callDraw) {
      this.draw();
    }
  }

  /**
  * Deselect this curve and optionally redraw it.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  deselect(callDraw = true) {
    this.selected = false;
    if (callDraw) {
      this.draw();
    }
  }

  /**
  * Inserts time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  insertTime(baseTime, deltaTime) {
    var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
    for (let i = 0; i < shiftData.length; i++) {
      shiftData[i][this.timeName] += deltaTime;
    }
    if (this.onEdit !== undefined) {
      this.onEdit();
    }
  }

  /**
  * Determines whether time in the specified time range can be deleted.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  canDeleteTime(baseTime, deltaTime) {
    var canDelete = true;
    var endDeleteTime = baseTime + deltaTime;
    var dataFound = this.data.filter((datum) => datum[this.timeName] >= baseTime && datum[this.timeName] <= endDeleteTime);
    if (dataFound.length > 0) {
      canDelete = false;
    }
    return canDelete;
  }

  /**
  * Deletes time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  deleteTime(baseTime, deltaTime) {
    var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
    if (shiftData.length > 0) {
      for (let i = 0; i < shiftData.length; i++) {
        shiftData[i][this.timeName] -= deltaTime;
      }
      if (this.onEdit !== undefined) {
        this.onEdit();
      }
    }
  }

  /**
  * Set the range for values in this visual.
  * @param {number} start - The start of the range.
  * @param {number} end - The end of the range.
  */
  setValueRange(start, end) {
    this.valueRangeStart = start;
    this.valueRangeEnd = end;
  }

  /**
  * Initialize this visual.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.timeScale = timeScale;
    this.initialized = true;

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

  /**
  * Draw an area curve.
  */
  drawAreaCurve() {
    var shape = d3.area()
      .curve(d3.curveLinear)
      .x(d => this.timeScale(d[this.timeName]))
      .y1(d => this.valueScale(d[this.valueName]))
      .y0(d => {
        if (this.invert) {
          return 0;
        }
        else {
          return this.paneHeight;
        }
      });

    this.curveGroup.append("path")
      .datum(this.data)
      .attr("d", shape)
      .attr("class", "visual " + this.visualType + " area")
      .style("stroke", "none")
      .style("fill", this.selected ? this.selectedColor : this.color)
      .style("fill-opacity", this.selected ? this.selectedOpacity : this.opacity)
      .style("pointer-events", "none");

    if (this.showAreaOutline && this.data.length > 0) {
      let outlineData = this.data;
      
      let outlineShape = d3.line()
        .curve(d3.curveLinear)
        .x(d => this.timeScale(d[this.timeName]))
        .y(d => this.valueScale(d[this.valueName]));

      if (this.closeAreaOutline) {
        let value = this.valueDomainStart;
        if (this.invert) {
          value = this.valueDomainEnd;
        }
        let startPair = {};
        startPair[this.timeName] = this.data[0][this.timeName];
        startPair[this.valueName] = value;
        let endPair = {};
        endPair[this.timeName] = this.data[this.data.length-1][this.timeName];
        endPair[this.valueName] = value;
        outlineData = [startPair, ...this.data, endPair];
      }

      this.curveGroup.append("path")
        .datum(outlineData)
        .attr("d", outlineShape)
        .attr("class", "visual " + this.visualType + " area outline")
        .style("fill", "none")
        .style("stroke", this.selected ? this.selectedColor : this.color)
        .style("stroke-width", this.selected ? this.selectedWidth : this.width)
        .style("stroke-opacity", "1.0");
    }
  }

  /**
  * Draw a line curve.
  */
  drawLineCurve() {
    var shape = d3.line()
      .curve(d3.curveLinear)
      .x(d => this.timeScale(d[this.timeName]))
      .y(d => this.valueScale(d[this.valueName]));

    var path = this.curveGroup.append("path")
      .datum(this.data)
      .attr("d", shape)
      .attr("class", "visual " + this.visualType)
      .style("fill", "none")
      .style("stroke", this.selected ? this.selectedColor : this.color)
      .style("stroke-width", this.selected ? this.selectedWidth : this.width)
      .style("stroke-opacity", this.selected ? this.selectedOpacity : this.opacity);

    if (this.style === "dash") {
      path.style("stroke-dasharray", "8 4");
    }
    else if (this.style === "dot") {
      path.style("stroke-linecap", "round");
      path.style("stroke-dasharray", "1 4");
    }
  }

  /**
  * Draw this visual.
  */
  draw() {
    if (this.initialized) {
      // Set the scale in draw() because the value range can change
      this.valueScale = d3.scaleLinear()
        .domain([this.valueDomainStart, this.valueDomainEnd])
        .range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0]);

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

      this.curveGroup = this.parentElement.append("g")
        .attr("class", "curveGroup");

      switch (this.curveType) {
        case Curve.AREA:
          this.drawAreaCurve();
          break;
        case Curve.LINE:
          this.drawLineCurve();
          break;
      }

      for (let i = 0; i < this.visualArray.length; i++) {
        if (this.visualArray[i].on) {
          this.visualArray[i].draw();
        }
        else {
          this.visualArray[i].erase();
        }
      }

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

  /**
  * Edit values for points by moving the mouse.
  * @param {number} dx - Delta x.
  * @param {number} pointerX - Pointer x.
  * @param {number} pointerY - Pointer y.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  delineate(dx, pointerX, pointerY, callDraw = true) {
    var dataDelineated = [];
    if (this.editable) {
      let dataFound = [];
      if (dx < 0) {
        dataFound = this.data.filter(p => {
          let x = this.timeScale(p[this.timeName]);
          return x > pointerX && x < pointerX - dx;
        });
      }
      else if (dx > 0) {
        dataFound = this.data.filter(p => {
          let x = this.timeScale(p[this.timeName]);
          return x > pointerX - dx && x < pointerX;
        });
      }
      if (dataFound.length > 0) {
        dataDelineated = dataFound;
        for (let i = 0; i < dataFound.length; i++) {
          if (this.valueType === "integer") {
            dataFound[i][this.valueName] = Math.round(this.valueScale.invert(pointerY));
          }
          else if (this.valueType === "float") {
            dataFound[i][this.valueName] = this.valueScale.invert(pointerY);
          }
        }

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

        if (callDraw) {
          this.draw();
        }
      }
    }
    return dataDelineated;
  }

  /**
  * Erase this visual.
  */
  erase() {
    if (this.curveGroup !== undefined) {
      this.curveGroup.remove();

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

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

/**
* A Grid visual adds vertical and optionally horizontal lines. The placement of vertical lines is derived from the axis ticks in the
* TimeFrame that contains the pane that contains this grid. Horizontal line spacing matches the vertical line spacing by default.
*/
class Grid {
  /**
  * Create a grid visual.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.visualType = "grid";
    this.options = options;
    this.on = options?.on ?? true;
    this.color = options?.color ?? "#000000";
    this.opacity = options?.opacity ?? "1.0";
    this.strokeWidth = options?.strokeWidth ?? 1;
    this.showHorizontalLines = options?.showHorizontalLines ?? true;
    this.division = options?.division ?? 5;
    this.showControls = options?.showControls ?? true;
    this.propertyArray = [];
    this.parentElement;
    this.frameWidth;
    this.frameHeight;
    this.timeScale;
    this.initialized = false;
    this.gridGroup;
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Initialize this visual.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.frameWidth = width;
    this.frameHeight = height;
    this.timeScale = timeScale;
    this.initialized = true;
  }

  /**
  * Draw this visual.
  */
  draw() {
    if (this.initialized && this.on) {
      let gridTimes = [];
      let tickTimes = this.timeScale.ticks();
      let tickTimeIncrement = tickTimes[1] - tickTimes[0];
      let gridTimeIncrement = tickTimeIncrement/this.division;
      let timeScaleStart = this.timeScale.domain()[0];
      if ((tickTimes[0]-timeScaleStart) > gridTimeIncrement) {
        // Add any incremental times before the first tick time
        let time = tickTimes[0];
        while (time > timeScaleStart) {
          time -= gridTimeIncrement;
          gridTimes.push(time);
        }
        gridTimes.reverse(); // Put the times in the right order before removing the first time with shift()
      }
      for (let i = 0; i < tickTimes.length; i++) {
        gridTimes.push(tickTimes[i]);
        for (let j = 1; j < 5; j++) {
          let time = tickTimes[i]+(j*gridTimeIncrement);
          if (time < this.timeScale.domain()[1]) {
            gridTimes.push(time);
          }
        }
      }
      gridTimes.shift(); // Remove first time
      if (gridTimes[gridTimes.length-1] === tickTimes[tickTimes.length-1]) {
        gridTimes.pop(); // Remove last time
      }

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

      this.gridGroup = this.parentElement.append("g")
        .attr("class", "gridGroup");

      if (gridTimes.length > 0) {
        this.gridGroup.selectAll("verticalGridLines")
          .data(gridTimes)
          .join("line")
            .attr("class", "verticalGridLine")
            .attr("x1", d => this.timeScale(d))
            .attr("y1", 0)
            .attr("x2", d => this.timeScale(d))
            .attr("y2", this.frameHeight)
            .style("stroke", this.color)
            .style("stroke-width", this.strokeWidth)
            .style("opacity", this.opacity);

        if (this.showHorizontalLines && gridTimes.length > 1) {
          // If the grid time increment is less than the domain start time, it will be clamped to 0!
          let yIncrement = this.timeScale(this.timeScale.domain()[0]+gridTimeIncrement) - this.timeScale(this.timeScale.domain()[0]);
          let yStart = this.frameHeight-yIncrement;

          for (let y = yStart; y > 0; y -= yIncrement) {
            this.gridGroup.append("line")
              .attr("class", "grid horizontalGridLine")
              .attr("x1", 0)
              .attr("y1", y)
              .attr("x2", this.frameWidth)
              .attr("y2", y)
              .style("stroke", this.color)
              .style("opacity", this.opacity)
              .style("stroke-width", this.strokeWidth);
          }
        }
      }
    }
  }

  /**
  * Erase this visual.
  */
  erase() {
    if (this.gridGroup !== undefined) {
      this.gridGroup.remove();
    }
  }
}

/**
* An Instants visual is a set of vertical lines that are placed at certain times based on the data. Each instant can have its own label.
*/
class Instants {
  static TIME = 0;
  static TIME_VALUE = 1;
  static TIME_VALUE_RANGE = 2;

  /**
  * Create an instants visual.
  * @param {Object[]} data - An array of data.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(data, options) {
    this.visualType = "instants";
    this.data = data;
    this.options = options;
    this.name = options?.name ?? null;
    this.instantsType = options?.instantsType ?? Instants.TIME;
    this.on = options?.on ?? true;
    this.editable = options?.editable ?? false;
    this.color = options?.color ?? "#000000";
    this.width = options?.width ?? 2;
    this.opacity = options?.opacity ?? 1.0;
    this.opacityMin = options?.opacityMin ?? 0.1;
    this.opacityMax = options?.opacityMax ?? 1.0;
    this.style = options?.style ?? "solid";
    this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
    this.showProperties = options?.showProperties ?? false;
    this.showControls = options?.showControls ?? true;
    this.timeName = options?.timeName ?? "time";
    this.valueName = options?.valueName ?? "value";
    this.valueStartName = options?.valueStartName ?? "valueStart";
    this.valueEndName = options?.valueEndName ?? "valueEnd";
    this.valueType = options?.valueType ?? "float";
    this.valueDomainStart = options?.valueDomainStart ?? d3.min(this.data, (d) => d[this.valueName]);
    this.valueDomainEnd = options?.valueDomainEnd ?? d3.max(this.data, (d) => d[this.valueName]);
    this.valueRangeStart = options?.valueRangeStart;
    this.valueRangeEnd = options?.valueRangeEnd;
    this.mapValueToOpacity = options?.mapValueToOpacity ?? false;
    this.propertyArray = [];
    this.visualArray = [];
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.valueScale;
    this.initialized = false;
    this.instantsGroup;
    this.onDraw;
    this.onErase;
    this.onEdit;

    if (typeof this.width === "string") {
      this.width = parseFloat(this.width.toString().replace(/px/g, ""));
    }
  }

  /**
  * Append the provided data array to the existing data array in this visual.
  * @param {Array} data - The data array to append.
  */
  appendData(data) {
    this.data = [...this.data, ...data];
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Include the visual type indicated by the type string with the specified options.
  * @param {string} visualType - The visual type.
  * @param {object} options - Options for the visual type.
  */
  includeVisual(visualType, options) {
    var visual;
    if (visualType === "curve") {
      visual = new Curve(this.data, options);

    }
    else if (visualType === "points") {
      visual = new Points(this.data, options);
    }
    if (visual !== undefined) {
      visual.onEdit = this.draw.bind(this);
      if (this.initialized) {
        visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
      }
      this.visualArray.push(visual);
    }
    return visual;
  }

  /**
  * Filter the data using the domain values for the time scale.
  */
  timeFilterData() {
    return this.data.filter((d) => d[this.timeName] > this.timeScale.domain()[0] && d[this.timeName] < this.timeScale.domain()[1]);
  }

  /**
  * Convert the specified value to an opacity based on the value domain and the min and max opacity values.
  * @param {number} value - A number value.
  */
  valueToOpacity(value) {
    return this.opacityMin + ((value - this.valueDomainStart) * (this.opacityMax - this.opacityMin) / (this.valueDomainEnd - this.valueDomainStart));
  }

  /**
  * Inserts time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  insertTime(baseTime, deltaTime) {
    var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
    for (let i = 0; i < shiftData.length; i++) {
      shiftData[i][this.timeName] += deltaTime;
    }
    if (this.onEdit !== undefined) {
      this.onEdit();
    }
  }

  /**
  * Determines whether time in the specified time range can be deleted.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  canDeleteTime(baseTime, deltaTime) {
    var canDelete = true;
    var endDeleteTime = baseTime + deltaTime;
    var dataFound = this.data.filter((datum) => datum[this.timeName] >= baseTime && datum[this.timeName] <= endDeleteTime);
    if (dataFound.length > 0) {
      canDelete = false;
    }
    return canDelete;
  }

  /**
  * Deletes time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  deleteTime(baseTime, deltaTime) {
    let shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
    for (let i = 0; i < shiftData.length; i++) {
      shiftData[i][this.timeName] -= deltaTime;
    }
    if (this.onEdit !== undefined) {
      this.onEdit();
    }
  }

  /**
  * Initialize this visual.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.timeScale = timeScale;
    this.initialized = true;

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

  /**
  * Draw time instants using the specified data. Values provided in the data are ignored.
  * @param {object} data - The data.
  */
  drawTimeInstants(data) {
    this.instantsGroup.selectAll()
      .data(data)
      .join("line")
        .attr("class", "visual " + this.visualType)
        .attr("x1", d => this.timeScale(d[this.timeName]))
        .attr("y1", 0)
        .attr("x2", d => this.timeScale(d[this.timeName]))
        .attr("y2", this.paneHeight)
        .style("stroke", d => d.color ?? this.color)
        .style("stroke-width", d => d.width ?? this.width)
        .style("opacity", (d) => {
          if (this.mapValueToOpacity) {
            return this.valueToOpacity(d[this.valueName]);
          }
          else {
            return d.opacity ?? this.opacity;
          }
        });

    this.instantsGroup.selectAll()
      .data(data)
      .join("line")
        .attr("class", "visual " + this.visualType)
        .attr("x1", d => this.timeScale(d[this.timeName]))
        .attr("y1", 0)
        .attr("x2", d => this.timeScale(d[this.timeName]))
        .attr("y2", this.paneHeight)
        .style("stroke", "#ffffff00")
        .style("stroke-width", d => 6*(d.width ?? this.width))
        .on("mouseenter", (event, d) => {
          if (!this.alwaysShowLabel) {
            this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueName]);
          }
        })
        .on("mouseleave", (event, d) => {
          if (!this.alwaysShowLabel) {
            this.eraseLabel(this.instantsGroup);
          }
        });
  }

  /**
  * Draw time and value instants using the specified data.
  * @param {object} data - The data.
  */
  drawTimeValueInstants(data) {
    this.valueScale = d3.scaleLinear()
      .range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0])
      .domain([this.valueDomainStart, this.valueDomainEnd]);

    this.instantsGroup.selectAll()
      .data(data)
      .join("line")
        .attr("class", "visual " + this.visualType)
        .attr("x1", d => this.timeScale(d[this.timeName]))
        .attr("y1", d => {
          if (typeof d[this.valueName] === "number") {
            return this.valueScale(d[this.valueName]);
          }
          else {
            return 0;
          }
        })
        .attr("x2", d => this.timeScale(d[this.timeName]))
        .attr("y2", this.paneHeight)
        .style("stroke", d => d.color ?? this.color)
        .style("stroke-width", d => d.width ?? this.width)
        .style("opacity", (d) => {
          if (this.mapValueToOpacity) {
            return this.valueToOpacity(d[this.valueName]);
          }
          else {
            return d.opacity ?? this.opacity;
          }
        });

    this.instantsGroup.selectAll()
      .data(data)
      .join("line")
        .attr("class", "visual " + this.visualType)
        .attr("x1", d => this.timeScale(d[this.timeName]))
        .attr("y1", d => {
          if (typeof d[this.valueName] === "number") {
            return this.valueScale(d[this.valueName]);
          }
          else {
            return 0;
          }
        })
        .attr("x2", d => this.timeScale(d[this.timeName]))
        .attr("y2", this.paneHeight)
        .style("stroke", "#ffffff00")
        .style("stroke-width", d => 6*(d.width ?? this.width))
        .on("mouseenter", (event, d) => {
          if (!this.alwaysShowLabel) {
            this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueName]);
          }
        })
        .on("mouseleave", (event, d) => {
          if (!this.alwaysShowLabel) {
            this.eraseLabel(this.instantsGroup);
          }
        });
  }

  /**
  * Draw time and value range instants using the specified data.
  * @param {object} data - The data.
  */
  drawTimeValueRangeInstants(data) {
    this.valueScale = d3.scaleLinear()
      .range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0])
      .domain([this.valueDomainStart, this.valueDomainEnd]);

    this.instantsGroup.selectAll()
      .data(data)
      .join("line")
        .attr("class", "visual " + this.visualType)
        .attr("x1", d => this.timeScale(d[this.timeName]))
        .attr("y1", d => {
          if (this.valueStartName in d) {
            return this.valueScale(d[this.valueStartName]);
          }
          else if (this.valueName in d && typeof d[this.valueName] === "number") {
            return this.valueScale(d[this.valueName]);
          }
          else {
            return 0;
          }
        })
        .attr("x2", d => this.timeScale(d[this.timeName]))
        .attr("y2", d => {
          if (this.valueEndName in d) {
            return this.valueScale(d[this.valueEndName]);
          }
          else {
            return this.paneHeight;
          }
        })
        .style("stroke", d => d.color ?? this.color)
        .style("stroke-width", d => d.width ?? this.width)
        .style("opacity", d => d.opacity ?? this.opacity);

    this.instantsGroup.selectAll()
      .data(data)
      .join("line")
        .attr("class", "visual " + this.visualType)
        .attr("x1", d => this.timeScale(d[this.timeName]))
        .attr("y1", d => {
          if (this.valueStartName in d) {
            return this.valueScale(d[this.valueStartName]);
          }
          else if (this.valueName in d && typeof d[this.valueName] === "number") {
            return this.valueScale(d[this.valueName]);
          }
          else {
            return 0;
          }
        })
        .attr("x2", d => this.timeScale(d[this.timeName]))
        .attr("y2", d => {
          if (this.valueEndName in d) {
            return this.valueScale(d[this.valueEndName]);
          }
          else {
            return this.paneHeight;
          }
        })
        .style("stroke", "#ffffff00")
        .style("stroke-width", d => 6*(d.width ?? this.width))
        .on("mouseenter", (event, d) => {
          if (!this.alwaysShowLabel) {
            if ("valueStart" in d && "valueEnd" in d) {
              this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueStartName].toString() + " - " + d[this.valueEndName].toString());
            }
            else if ("value" in d) {
              this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueName]);
            }
          }
        })
        .on("mouseleave", (event, d) => {
          if (!this.alwaysShowLabel) {
            this.eraseLabel(this.instantsGroup);
          }
        });
  }

  /**
  * Draw this visual.
  */
  draw() {
    if (this.initialized) {
      if (this.instantsGroup !== undefined) {
        this.instantsGroup.remove();
      }

      this.instantsGroup = this.parentElement.append("g")
        .attr("class", "instantsGroup")
        .style("pointer-events", "visible")
        .on("mouseenter", () => {
          this.instantsGroup.style("cursor", "pointer");
        });

      // Always filter the data by the domain of the time scale
      var timeFilteredData = this.timeFilterData();

      switch (this.instantsType) {
        case Instants.TIME:
          this.drawTimeInstants(timeFilteredData);
          break;
        case Instants.TIME_VALUE:
          this.drawTimeValueInstants(timeFilteredData);
          break;
        case Instants.TIME_VALUE_RANGE:
          this.drawTimeValueRangeInstants(timeFilteredData);
          break;
      }

      if (this.alwaysShowLabel) {
        this.drawAllLabels(timeFilteredData);
      }

      for (let i = 0; i < this.visualArray.length; i++) {
        if (this.visualArray[i].on) {
          this.visualArray[i].draw();
        }
        else {
          this.visualArray[i].erase();
        }
      }

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

  /**
  * Edit values for points by moving the mouse.
  * @param {number} dx - Delta x.
  * @param {number} pointerX - Pointer x.
  * @param {number} pointerY - Pointer y.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  delineate(dx, pointerX, pointerY, callDraw = true) {
    var dataDelineated = [];
    var timeFilteredData = this.timeFilterData();
    if (this.instantsType !== Instants.TIME && this.editable) {
      let dataFound = [];
      if (dx < 0) {
        dataFound = timeFilteredData.filter(p => {
          let x = this.timeScale(p[this.timeName]);
          return x > pointerX && x < pointerX - dx;
        });
      }
      else if (dx > 0) {
        dataFound = timeFilteredData.filter(p => {
          let x = this.timeScale(p[this.timeName]);
          return x > pointerX - dx && x < pointerX;
        });
      }
      if (dataFound.length > 0) {
        dataDelineated = dataFound;
        for (let i = 0; i < dataFound.length; i++) {
          if (this.valueType === "integer") {
            dataFound[i][this.valueName] = Math.round(this.valueScale.invert(pointerY));
          }
          else if (this.valueType === "float") {
            dataFound[i][this.valueName] = this.valueScale.invert(pointerY);
          }
        }

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

        if (callDraw) {
          this.draw();
        }
      }
    }
    return dataDelineated;
  }

  /**
  * Draw label for all instants.
  * @param {Object} filteredData - Filtered data with labels.
  */
  drawAllLabels(filteredData) {
    this.instantsGroup.selectAll(".instantLabel").remove();

    var textAnchor;

    var labelGroup = this.instantsGroup.append("g")
      .selectAll("g")
      .data(filteredData)
      .join("g")
        .attr("class", "visual " + this.visualType + " instantLabel")
        .attr("transform", (d) => "translate(" + this.timeScale(d[this.timeName]) + ", " + 1 + ")");

    var labelText = labelGroup.append("text")
      .attr("x", 0)
      .attr("y", "2ex")
      .style("color", "black")
      .style("text-anchor", "middle")
      .style("dominant-baseline", "middle")
      .text((d) => d[this.valueName]);

    if (labelText.node() !== null) {
      var textBox = labelText.node().getBBox();
      var labelRect = labelGroup.append("rect")
        .attr("x", textBox.x-2)
        .attr("y", textBox.y-2)
        .attr("width", textBox.width+4)
        .attr("height", textBox.height+4)
        .style("fill", "white");
      labelRect.lower();
    }
  }

  /**
  * Draw the label for an instant.
  * @param {number} x - The x value.
  * @param {string} value - The label.
  */
  drawLabel(x, label) {
    var textAnchor;

    if (x < this.paneWidth/2) {
      x = x + this.width/2 + 4;
      textAnchor = "start";
    }
    else {
      x = x - this.width/2 - 4;
      textAnchor = "end";
    }

    var labelGroup = this.instantsGroup.append("g")
      .attr("class", "visual " + this.visualType + " instantLabel")
      .attr("transform", "translate(" + x + ", " + 1 + ")");

    var labelText = labelGroup.append("text")
      .attr("x", 0)
      .attr("y", "2ex")
      .style("color", "black")
      .style("text-anchor", textAnchor)
      .style("dominant-baseline", "middle")
      .text(label);

    var textBox = labelText.node().getBBox();
    var labelRect = labelGroup.append("rect")
      .attr("x", textBox.x-2)
      .attr("y", textBox.y-2)
      .attr("width", textBox.width+4)
      .attr("height", textBox.height+4)
      .style("fill", "white");
    labelRect.lower();
  }

  /**
  * Erase all labels.
  */
  eraseLabel() {
    this.instantsGroup.selectAll(".instantLabel").remove();
  }

  /**
  * Erase this visual.
  */
  erase() {
    if (this.instantsGroup !== undefined) {
      this.instantsGroup.remove();

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

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

/**
* Notes are a visual derived from MIDI note data. Unlike MIDI notes, individual notes in a Notes visual have a start and an end time with
* the width of the note representing the length of the note in seconds. The position of the note on the Y axis represents the MIDI note value.
* The velocity of MIDI notes is represented by the opacity of the notes with quieter notes appearing lighter and louder notes appearing
* darker.
*/
class Notes {
  /**
  * Create a notes visual with the specified data and options.
  * @param {Object[]} data - An array of data.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(data, options) {
    this.visualType = "notes";
    this.data = data;
    this.options = options;
    this.on = options?.on ?? true;
    this.selectable = options?.selectable ?? true;
    this.editable = options?.editable ?? false;
    this.noteRangeMin = options?.noteRangeMin ?? 21; // Note range min and max default to the piano note range
    this.noteRangeMax = options?.noteRangeMax ?? 108;
    this.velocityRangeMin = options?.velocityRangeMin ?? 1; // Start with 1 since velocity = 0 is a note off
    this.velocityRangeMax = options?.velocityRangeMax ?? 127;
    this.notesStartHidden = options?.notesStartHidden ?? false;
    this.highlightNotes = options?.highlightNotes ?? false;
    this.noteHeight = options?.noteHeight ?? 0;
    this.fill = options?.fill ?? "#000000";
    this.selectedFill = options?.selectedFill ?? "#0000ff";
    this.highlightedFill = options?.highlightedFill ?? "#ff0000";
    this.fillOpacity = options?.fillOpacity ?? 1.0;
    this.fillOpacityMin = options?.fillOpacityMin ?? 0.1;
    this.fillOpacityMax = options?.fillOpacityMax ?? 1.0;
    this.useVelocityOpacity = options?.useVelocityOpacity ?? true;
    this.backFill = options?.backFill ?? "#ffffff";
    this.stroke = options?.stroke ?? "#000000";
    this.selectedStroke = options?.selectedStroke ?? "#0000ff";
    this.highlightedStroke = options?.highlightedStroke ?? "#ff0000";
    this.strokeOpacity = options?.strokeOpacity ?? 1.0;
    this.strokeWidth = options?.strokeWidth ?? 1;
    this.pointerEvents = options?.pointerEvents ?? "visiblePainted";
    this.editorFill = options?.editorFill ?? "#0000ff";
    this.editorStroke = options?.editorStroke ?? "#0000ff";
    this.showProperties = options?.showProperties ?? false;
    this.showControls = options?.showControls ?? true;
    this.acceptEditorClick = options?.acceptEditorClick ?? false;
    this.dataTemplate;
    this.propertyArray = [];
    this.visualArray = [];
    this.onSelect;
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.initialized = false;
    this.notesGroup;
    this.notesEventGroup;
    this.editNotesGroup;
    this.selectedNotes = [];
    this.onDraw;
    this.onErase;
    this.addNoteRect;
    this.editor;
    this.onEdit;

    this.noteScale = d3.scaleLinear()
      .domain([this.noteRangeMin, this.noteRangeMax])
      .clamp(true);

    this.velocityScale = d3.scaleLinear()
      .domain([this.velocityRangeMin, this.velocityRangeMax])
      .clamp(true);
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Append the provided data array to the existing data array in this visual.
  * @param {Array} data - The data array to append.
  * @param {number} offsetTime - An optional offset time for the data.
  */
  appendData(data, offsetTime) {
    if (offsetTime !== undefined) {
      for (let i = 0; i < data.length; i++) {
        data[i].startTime += offsetTime;
        data[i].endTime += offsetTime;
      }
    }
    this.data = [...this.data, ...data];
    this.data.sort((a, b) => a.startTime - b.startTime);
  }

  /**
  * Set a data template object for adding notes.
  * @param {Object} template - A template object.
  */
  setDataTemplate(template) {
    this.dataTemplate = template;
  }

  /**
  * Include the visual type indicated by the type string with the specified options.
  * @param {string} visualType - The visual type.
  * @param {object} options - Options for the visual type.
  */
  includeVisual(visualType, options) {
    var visual;
    if (visualType === "curve") {
      visual = new Curve(this.data, options);

    }
    else if (visualType === "instants") {
      visual = new Instants(this.data, options);
    }
    else if (visualType === "points") {
      visual = new Points(this.data, options);
    }
    if (visual !== undefined) {
      visual.onEdit = this.draw.bind(this);
      if (this.initialized) {
        visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
      }
      this.visualArray.push(visual);
    }
    return visual;
  }

  /**
  * Set the specified value for the specified key. The properties "note" and "velocity", if specified, will be handled by
  * setNoteNumber() and setVelocity() respectively.
  * @param {string} key - The key of the property.
  * @param {number} value - The value to set for the property.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  setProperty(key, value, callDraw = true) {
    if (this.selectedNotes.length > 0) {
      for (let i = 0; i < this.selectedNotes.length; i++) {
        if (key in this.selectedNotes[i]) {
          if (key === "note") {
            this.setNoteNumber(value);
          }
          else if (key === "velocity") {
            this.setVelocity(value);
          }
          else {
            this.selectedNotes[i][key] = value;
            this.selectedNotes[i].selected = false;
          }
        }
      }
      this.selectedNotes.length = 0;
      if (callDraw) {
        this.draw();
      }

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

  /**
  * Set the editor for this visual and set editing callbacks.
  * @param {object} editor - The VisualEditor to set.
  */
  setEditor(editor) {
    this.editor = editor;
    this.editor.onBrush = this.select.bind(this);
    this.editor.onDragStart = this.startNote.bind(this);
    this.editor.onDrag = this.dragNote.bind(this);
    this.editor.onDragEnd = this.endNote.bind(this);
    this.editor.onMoveBoxStart = this.startShiftingNotes.bind(this);
    this.editor.onMoveBoxDrag = this.shiftNotes.bind(this);
    this.editor.onMoveBoxEnd = this.endShiftingNotes.bind(this);
  }

  /**
  * Get the note value scale for this visal.
  */
  getNoteScale() {
    return this.noteScale;
  }

  /**
  * Get any notes in the specified time and note ranges and return them in an array.
  * @param {number} t1 - The start of the time range.
  * @param {number} t2 - The end of the time range.
  * @param {number} note1 - The start of the note range.
  * @param {number} note2 - The end of the note range.
  * @return {Array} An array of notes in the specified ranges or an empty array.
  */
  getNotesWithinRanges(t1, t2, note1, note2) {
    return this.data.filter(note => (note.startTime > t1 && note.endTime < t2) && (note.note > note1 && note.note < note2));
  }

  /**
  * Change the visibility or the color of notes with time values less than the specified time.
  * @param {number} time - The time up to which all earlier notes should change.
  */
  updateNotes(time) {
    if (this.notesGroup !== undefined && this.notesStartHidden) {
      this.notesGroup.selectAll(".noteRectBack")
        .attr("visibility", "hidden");
      this.notesGroup.selectAll(".noteRect")
        .attr("visibility", "hidden");
    }
    if (this.notesGroup !== undefined && (this.notesStartHidden || this.highlightNotes)) {
      this.notesGroup.selectAll(".noteRectBack")
        .filter(d => d.startTime <= time)
        .attr("visibility", "visible");
      this.notesGroup.selectAll(".noteRect")
        .filter(d => d.startTime <= time)
        .style("stroke", () => {
          if (this.highlightNotes) {
            return this.highlightedStroke;
          }
        })
        .style("fill", () => {
          if (this.highlightNotes) {
            return this.highlightedFill;
          }
        })
        .attr("visibility", "visible");
    }
  }

  /**
  * Change the visibility or the color of notes back to their original values.
  */
  resetNotes() {
    if (this.notesGroup !== undefined && this.notesStartHidden) {
      this.notesGroup.selectAll(".noteRectBack")
        .attr("visibility", "hidden");
      this.notesGroup.selectAll(".noteRect")
        .attr("visibility", "hidden");
    }
    if (this.notesGroup !== undefined && this.highlightNotes) {
      this.notesGroup.selectAll(".noteRect")
        .style("stroke", this.stroke)
        .style("fill", this.fill);
    }
  }

  /**
  * Select notes in the specified time and note number ranges.
  * @param {number} x1 - The start of the x range.
  * @param {number} x2 - The end of the x range.
  * @param {number} y1 - The start of the y range.
  * @param {number} y2 - The end of the y range.
  * @param {boolean} append - Append selected notes to previously selected notes.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  select(x1, x2, y1, y2, append, callDraw = true) {
    var t1 = x1 === null ? 0 : this.timeScale.invert(x1);
    var t2 = x2 === null ? this.timeScale.domain()[1] : this.timeScale.invert(x2);
    var note1 = y1 === null ? this.valueRangeStart : Math.round(this.noteScale.invert(y1));
    var note2 = y2 === null ? this.valueRangeEnd : Math.round(this.noteScale.invert(y2));

    if (this.selectedNotes.length === 0) {
      this.selectedNotes = this.getNotesWithinRanges(t1, t2, note1, note2);
    }
    else {
      if (append) {
        this.selectedNotes = this.selectedNotes.concat(this.getNotesWithinRanges(t1, t2, note1, note2));
      }
      else {
        for (let i = 0; i < this.selectedNotes.length; i++) {
          this.selectedNotes[i].selected = false;
        }
        this.selectedNotes.length = 0;
        this.selectedNotes = this.getNotesWithinRanges(t1, t2, note1, note2);
      }
    }
    for (let i = 0; i < this.selectedNotes.length; i++) {
      this.selectedNotes[i].selected = true;
    }
    if (callDraw) {
      this.draw();
    }
    if (this.onSelect !== undefined) {
      this.onSelect(this.selectedNotes);
    }
  }

  /**
  * Deselect selected notes.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  deselect(callDraw = true) {
    for (let i = 0; i < this.selectedNotes.length; i++) {
      this.selectedNotes[i].selected = false;
    }
    this.selectedNotes.length = 0;
    if (callDraw) {
      this.draw();
    }
  }

  /**
  * Get any selected notes.
  * @return {Array} An array of selected notes or an empty array.
  */
  getSelectedNotes() {
    return this.selectedNotes;
  }

  /**
  * Set the note number for a selected note. For this to work, only one number can be selected.
  * @param {number} number - The note number.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  setNoteNumber(number, callDraw = true) {
    if (this.selectedNotes.length === 1 && (number >= this.noteRangeMin && number <= this.noteRangeMax)) {
      let currentValue = this.selectedNotes[0].note;
      this.selectedNotes[0].note = number;
      if (!this.canEditNote(this.selectedNotes[0], this.selectedNotes[0].startTime, this.selectedNotes[0].endTime)) {
        this.selectedNotes[0].note = currentValue;
      }
      else {
        if (this.onEdit !== undefined) {
          this.onEdit();
        }
      }
      if (callDraw) {
        this.draw();
      }
    }
  }

  /**
  * Adjust the note number by the specified amount for selected notes.
  * @param {number} amount - The amount to adjust.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  adjustNoteNumber(amount, callDraw = true) {
    var canAdjust = true;
    for (let i = 0; i < this.selectedNotes.length; i++) {
      if (!this.canSetNoteNumber(this.selectedNotes[i].note + amount, this.selectedNotes[i].startTime, this.selectedNotes[i].endTime)) {
        canAdjust = false;
        break;
      }
    }
    if (canAdjust) {
      for (let i = 0; i < this.selectedNotes.length; i++) {
        this.selectedNotes[i].note += amount;
      }

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

      if (callDraw) {
        this.draw();
      }
    }
  }

  /**
  * Set velocity to the specified amount for selected notes.
  * @param {number} velocity - The velocity.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  setVelocity(velocity, callDraw = true) {
    if (this.selectedNotes.length > 0) {
      for (let i = 0; i < this.selectedNotes.length; i++) {
        if (velocity >= this.velocityRangeMin && velocity <= this.velocityRangeMax) {
          this.selectedNotes[i].velocity = velocity;
        }
      }
      if (callDraw) {
        this.draw();
      }

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

  /**
  * Adjust the velocity by the specified amount for selected notes.
  * @param {number} amount - The amount to adjust.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  adjustVelocity(amount, callDraw = true) {
    if (this.selectedNotes.length > 0) {
      for (let i = 0; i < this.selectedNotes.length; i++) {
        if (this.selectedNotes[i].velocity + amount >= this.velocityRangeMin && this.selectedNotes[i].velocity + amount <= this.velocityRangeMax) {
          this.selectedNotes[i].velocity += amount;
        }
      }
      if (callDraw) {
        this.draw();
      }

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

  /**
  * Delete any selected notes.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  deleteSelectedNotes(callDraw = true) {
    if (this.selectedNotes.length > 0) {
      for (let i = 0; i < this.selectedNotes.length; i++) {
        let noteIndex = this.data.findIndex(note => note === this.selectedNotes[i]);
        this.data.splice(noteIndex, 1);
      }
      this.selectedNotes.length = 0;
      if (callDraw) {
        this.draw();
      }

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

  /**
  * Inserts time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  insertTime(baseTime, deltaTime) {
    var shiftNotes = this.data.filter((note) => note.startTime > baseTime);
    for (let i = 0; i < shiftNotes.length; i++) {
      shiftNotes[i].startTime += deltaTime;
      shiftNotes[i].endTime += deltaTime;
    }
    if (this.onEdit !== undefined) {
      this.onEdit();
    }
  }

  /**
  * Determines whether time in the specified time range can be deleted.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  canDeleteTime(baseTime, deltaTime) {
    var canDelete = true;
    var endDeleteTime = baseTime + deltaTime;
    var notesFound = this.data.filter((note) => note.endTime >= baseTime && note.startTime <= endDeleteTime);
    if (notesFound.length > 0) {
      canDelete = false;
    }
    return canDelete;
  }

  /**
  * Deletes time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  deleteTime(baseTime, deltaTime) {
    let shiftNotes = this.data.filter((note) => note.startTime > baseTime);
    for (let i = 0; i < shiftNotes.length; i++) {
      shiftNotes[i].startTime -= deltaTime;
      shiftNotes[i].endTime -= deltaTime;
    }
    if (this.onEdit !== undefined) {
      this.onEdit();
    }
  }

  /**
  * Determines whether the specified note can be added by looking for any notes with the same note number in the specified time range.
  * @param {Object} note - The note to edit.
  * @param {number} startTime - The start time of the note.
  * @param {number} endTime - The end time of the note.
  * @return {boolean} True if the added note would not overlap any existing notes; false otherwise.
  */
  canAddNote(noteNumber, startTime, endTime) {
    let can = true;
    for (let i = 0; i < this.data.length; i++) {
      if (this.data[i].note === noteNumber &&
        ((startTime >= this.data[i].startTime && startTime <= this.data[i].endTime) ||
        (endTime >= this.data[i].startTime && endTime <= this.data[i].endTime) ||
        (this.data[i].startTime >= startTime && this.data[i].startTime <= endTime) ||
        (this.data[i].endTime >= startTime && this.data[i].endTime <= endTime))
      ) {
        can = false;
        break;
      }
    }
    return can;
  }

  /**
  * Determines whether the specified note can be edited by looking for any notes with the same note number in the specified time range.
  * @param {Object} note - The note to edit.
  * @param {number} newStartTime - The possible start time of the note.
  * @param {number} newEndTime - The possible end time of the note.
  * @return {boolean} True if the note would not overlap any existing notes; false otherwise.
  */
  canEditNote(note, newStartTime, newEndTime) {
    var can = true;
    for (let i = 0; i < this.data.length; i++) {
      if (this.data[i] === note || (this.selectedNotes.includes(this.data[i]) && this.data[i] !== note)) {
        // This is the note that's being edited or is one of the other selected notes that's being edited
        continue;
      }
      if (this.data[i].note === note.note &&
          ((newStartTime >= this.data[i].startTime && newStartTime <= this.data[i].endTime) ||
          (newEndTime >= this.data[i].startTime && newEndTime <= this.data[i].endTime) ||
          (this.data[i].startTime >= newStartTime && this.data[i].startTime <= newEndTime) ||
          (this.data[i].endTime >= newStartTime && this.data[i].endTime <= newEndTime))
      ) {
        can = false;
        break;
      }
    }
    return can;
  }

  /**
  * Determines whether the specified note values can be adjusted by looking for any notes with the same note number in the specified time range.
  * @param {number} noteNumber - The note number.
  * @param {number} noteStartTime - The start time of the note.
  * @param {number} noteEndTime - The end time of the note.
  * @return {boolean} True if the note would not overlap any existing notes; false otherwise.
  */
  canSetNoteNumber(noteNumber, noteStartTime, noteEndTime) {
    var can = true;
    if (noteNumber >= this.noteRangeMin && noteNumber <= this.noteRangeMax) {
      let blockingNotes = this.data.filter((n) => {
        return !this.selectedNotes.includes(n) && n.note === noteNumber &&
          ((noteStartTime >= n.startTime && noteStartTime <= n.endTime) || (noteEndTime >= n.startTime && noteEndTime <= n.endTime) ||
          (n.startTime >= noteStartTime && n.startTime <= noteEndTime) || (n.endTime >= noteStartTime && n.endTime <= noteEndTime));
      });
      if (blockingNotes.length > 0) {
        can = false;
      }
    }
    else {
      can = false;
    }
    return can;
  }

  /**
  * Start to shift notes in time, drawing empty rectangles representing the selected notes.
  */
  startShiftingNotes() {
    if (this.editNotesGroup !== undefined) {
      this.editNotesGroup.remove();
    }
    this.editNotesGroup = this.notesEventGroup.append("g")
      .attr("class", "editNotesGroup");
    for (let i = 0; i < this.selectedNotes.length; i++) {
      let x = this.timeScale(this.selectedNotes[i].startTime);
      let y = this.noteScale(this.selectedNotes[i].note);
      let width = this.timeScale(this.selectedNotes[i].endTime) - x;
      this.selectedNotes[i].editRect = this.editNotesGroup.append("rect")
        .attr("class", "moveNoteRect")
        .attr("x", x)
        .attr("y", y)
        .attr("width", width)
        .attr("height", this.noteHeight)
        .style("stroke", this.selectedStroke)
        .style("stroke-width", "1")
        .style("fill", "none");
    }
  }

  /**
  * Shift the empty rectangles representing the notes by the specified delta x.
  * @param {number} dx - Delta x.
  */
  shiftNotes(dx) {
    for (let i = 0; i < this.selectedNotes.length; i++) {
      this.selectedNotes[i].editRect.attr("x", parseFloat(this.selectedNotes[i].editRect.attr("x")) + dx);
    }
  }

  /**
  * End note shifting, erasing the empty rectangles and updating the note data.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  endShiftingNotes(callDraw = true) {
    var cancelled = false;
    for (let i = 0; i < this.selectedNotes.length; i++) {
      let x = parseFloat(this.selectedNotes[i].editRect.attr("x"));
      let width = parseFloat(this.selectedNotes[i].editRect.attr("width"));
      let start = this.timeScale.invert(x);
      let end = this.timeScale.invert(x + width);
      if (!this.canEditNote(this.selectedNotes[i], start, end)) {
        cancelled = true;
        break;
      }
    }
    if (!cancelled) {
      for (let i = 0; i < this.selectedNotes.length; i++) {
        let x = parseFloat(this.selectedNotes[i].editRect.attr("x"));
        let width = parseFloat(this.selectedNotes[i].editRect.attr("width"));
        let start = this.timeScale.invert(x);
        let end = this.timeScale.invert(x + width);
        this.selectedNotes[i].startTime = start;
        this.selectedNotes[i].endTime = end;
      }
      this.data.sort((a, b) => a.startTime - b.startTime);
      if (this.onEdit !== undefined) {
        this.onEdit();
      }
    }
    this.editNotesGroup.remove();
    if (callDraw) {
      this.draw();
    }
  }

  /**
  * Start drawing a new note rectangle.
  * @param {number} x - The x value of the note rectangle.
  * @param {number} y - The y value to convert to a note value.
  */
  startNote(x, y) {
    let noteValue = Math.round(this.noteScale.invert(y));
    this.addNoteRect = this.notesEventGroup.append("rect")
      .attr("class", "addNoteRect")
      .attr("x", x)
      .attr("y", this.noteScale(noteValue))
      .attr("width", 0)
      .attr("height", this.noteHeight)
      .style("stroke", this.stroke)
      .style("stroke-width", "1")
      .style("fill", "none");
  }

  /**
  * Set the width of the note rectangle based on the specified delta x.
  * @param {number} dx - Delta x.
  */
  dragNote(dx) {
    let width = parseFloat(this.addNoteRect.attr("width")) + dx;
    if (width > 0) {
      this.addNoteRect.attr("width", width);
    }
  }

  /**
  * End the new note rectangle, erasing the rectangle and adding the new note to the data.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  endNote(callDraw = true) {
    let x = parseFloat(this.addNoteRect.attr("x"));
    let y = parseFloat(this.addNoteRect.attr("y"));
    let width = parseFloat(this.addNoteRect.attr("width"));
    let noteValue = Math.round(this.noteScale.invert(y));
    let start = this.timeScale.invert(x);
    let end = this.timeScale.invert(x + width);
    this.addNoteRect.remove();
    if (this.canAddNote(noteValue, start, end)) {
      let n = this.dataTemplate === undefined ? {} : {...this.dataTemplate};
      n.note = noteValue;
      n.velocity = 64;
      n.startTime = start;
      n.endTime = end;
      this.data.push(n);
      this.data.sort((a, b) => a.startTime - b.startTime);
      if (callDraw) {
        this.draw();
      }

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

  /**
  * Initialize this visual.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.timeScale = timeScale;

    if (this.noteHeight === 0) {
      this.noteHeight = this.paneHeight/88;
    }

    this.noteScale.range([this.paneHeight-this.noteHeight, 0]);
    this.velocityScale.range([this.paneHeight, 0]);

    this.notesEventGroup = this.parentElement.append("g")
      .attr("class", "notesEventGroup");

    this.initialized = true;

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

  /**
  * Draw notes.
  */
  draw() {
    if (this.initialized) {
      if (this.notesGroup !== undefined) {
        this.notesGroup.remove();
      }

      this.notesGroup = this.parentElement.append("g")
        .attr("class", "notesGroup")
        .on("editorClick", (event) => {
          if (this.acceptEditorClick) {
            let t = this.timeScale.invert(event.detail.x);
            let noteNumber = Math.round(this.noteScale.invert(event.detail.y));
            let foundNote = this.data.filter((n) => n.note === noteNumber && t >= n.startTime && t <= n.endTime);
            if (foundNote.length === 1) {
              if (!event.detail.append) {
                this.deselect(false);
              }
              foundNote[0].selected = true;
              this.selectedNotes.push(foundNote[0]);
              this.draw();
            }
          }
        });

      this.notesGroup.selectAll()
        .data(this.data)
        .join("rect")
          .attr("class", "visual " + this.visualType + " noteRectBack")
          .attr("visibility", () => {
            let visibility;
            if (this.notesStartHidden) {
              visibility = "hidden";
            }
            else {
              visibility = "visible";
            }
            return visibility;
          })
          .attr("x", d => this.timeScale(d.startTime))
          .attr("y", d => this.noteScale(d.note))
          .attr("width", (d) => {
            let w = this.timeScale(d.endTime) - this.timeScale(d.startTime);
            if (w > 0) {
              return w;
            }
            else {
              return 0;
            }
          })
          .attr("height", () => this.noteHeight)
          .style("fill", this.backFill)
          .style("fill-opacity", "1.0")
          .style("stroke", "none")
          .style("pointer-events", "none");
      this.notesGroup.selectAll()
        .data(this.data)
        .join("rect")
          .attr("class", "visual " + this.visualType + " noteRect")
          .attr("visibility", () => {
            let visibility;
            if (this.notesStartHidden) {
              visibility = "hidden";
            }
            else {
              visibility = "visible";
            }
            return visibility;
          })
          .attr("x", d => this.timeScale(d.startTime))
          .attr("y", d => this.noteScale(d.note))
          .attr("width", (d) => {
            let w = this.timeScale(d.endTime) - this.timeScale(d.startTime);
            if (w > 0) {
              return w;
            }
            else {
              return 0;
            }
          })
          .attr("height", () => this.noteHeight)
          .style("fill", (d) => {
            if ("selected" in d && d.selected) {
              return this.selectedFill;
            }
            else {
              return this.fill;
            }
          })
          .style("fill-opacity", (d) => {
            if (this.useVelocityOpacity) {
              return this.fillOpacityMin + ((d.velocity - this.velocityRangeMin) * (this.fillOpacityMax - this.fillOpacityMin) / (this.velocityRangeMax - this.velocityRangeMin));
            }
            else {
              return this.fillOpacity;
            }
          })
          .style("stroke", (d) => {
            if ("selected" in d && d.selected) {
              return this.selectedStroke;
            }
            else {
              return this.stroke;
            }
          })
          .style("stroke-opacity", this.strokeOpacity)
          .style("stroke-width", this.strokeWidth)
          .style("pointer-events", this.pointerEvents)
          .on("mouseenter", (event) => {
            if (this.selectable) {
              this.notesGroup.style("cursor", "pointer");
            }
          })
          .on("click", (event, d) => {
            if (this.selectable) {
              if ("selected" in d && d.selected) {
                d.selected = false;
                d3.select(event.currentTarget).style("fill", this.fill);
                d3.select(event.currentTarget).style("stroke", this.stroke);
                let selectedIndex = this.selectedNotes.findIndex(
                  (note) => note.note === d.note && note.startTime === d.startTime && note.endTime === d.endTime
                );
                this.selectedNotes.splice(selectedIndex, 1);
              }
              else {
                d.selected = true;
                d3.select(event.currentTarget).style("fill", this.selectedFill);
                d3.select(event.currentTarget).style("stroke", this.selectedStroke);
                if (event.ctrlKey || event.metaKey) {
                  this.selectedNotes.push(d);
                }
                else {
                  for (let i = 0; i < this.selectedNotes.length; i++) {
                    this.selectedNotes[i].selected = false;
                  }
                  this.selectedNotes.length = 0;
                  this.selectedNotes.push(d);
                }
                if (this.onSelect !== undefined) {
                  this.onSelect(this.selectedNotes);
                }
              }
              this.draw();
            }
          });

      if (this.editor !== undefined && this.editable) {
        this.editor.initialize(this.notesGroup, this.paneWidth, this.paneHeight);
        this.editor.draw();
        if (this.selectedNotes.length > 0) {
          let startTimes = this.selectedNotes.toSorted((a, b) => a.startTime - b.startTime);
          let endTimes = this.selectedNotes.toSorted((a, b) => b.endTime - a.endTime);
          this.editor.drawEditBox(this.timeScale(startTimes[0].startTime), this.timeScale(endTimes[0].endTime));
        }
        else {
          this.editor.eraseEditBox();
        }
      }

      for (let i = 0; i < this.visualArray.length; i++) {
        if (this.visualArray[i].on) {
          this.visualArray[i].draw();
        }
        else {
          this.visualArray[i].erase();
        }
      }

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

  /**
  * Erase notes.
  */
  erase() {
    if (this.notesGroup !== undefined) {
      this.notesGroup.remove();

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

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

/** The Points visual repsents the specified data using circles, drawn based on the specified options. */
class Points {
  /**
  * Create a points visual.
  * @param {Object[]} data - An array of data.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(data, options) {
    this.visualType = "points";
    this.data = data;
    this.options = options;
    this.name = options?.name ?? null;
    this.on = options?.on ?? true;
    this.editable = options?.editable ?? false;
    this.color = options?.color ?? "#000000";
    this.strokeColor = options?.strokeColor ?? "none";
    this.strokeWidth = options?.strokeWidth ?? 1;
    this.backgroundColor = options?.backgroundColor ?? "#ffffff";
    this.selectedColor = options?.selectedColor ?? "#800000";
    this.radius = options?.radius ?? 6;
    this.opacity = options?.opacity ?? 1.0;
    this.opacityMin = options?.opacityMin ?? 0.1;
    this.opacityMax = options?.opacityMax ?? 1.0;
    this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
    this.showProperties = options?.showProperties ?? false;
    this.showControls = options?.showControls ?? true;
    this.timeName = options?.timeName ?? "time";
    this.valueName = options?.valueName ?? "value";
    this.valueType = options?.valueType ?? "float";
    this.valueDomainStart = options?.valueDomainStart ?? d3.min(this.data, (d) => d[this.valueName]);
    this.valueDomainEnd = options?.valueDomainEnd ?? d3.max(this.data, (d) => d[this.valueName]);
    this.valueRangeStart = options?.valueRangeStart;
    this.valueRangeEnd = options?.valueRangeEnd;
    this.showStems = options?.showStems ?? false;
    this.mapValueToOpacity = options?.mapValueToOpacity ?? false;
    this.acceptEditorClick = options?.acceptEditorClick ?? false;
    this.dataTemplate;
    this.propertyArray = [];
    this.visualArray = [];
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.valueScale;
    this.initialized = false;
    this.pointsGroup;
    this.selectedPoints = [];
    this.editor;
    this.onDraw;
    this.onErase;
    this.onEdit;
  }

  /**
  * Append the provided data array to the existing data array in this visual.
  * @param {Array} data - The data array to append.
  */
  appendData(data) {
    this.data = [...this.data, ...data];
  }

  /**
  * Set a data template object for adding points.
  * @param {Object} template - A template object.
  */
  setDataTemplate(template) {
    this.dataTemplate = template;
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Include the visual type indicated by the type string with the specified options.
  * @param {string} visualType - The visual type.
  * @param {object} options - Options for the visual type.
  */
  includeVisual(visualType, options) {
    var visual;
    if (visualType === "curve") {
      visual = new Curve(this.data, options);

    }
    else if (visualType === "instants") {
      visual = new Instants(this.data, options);
    }
    if (visual !== undefined) {
      visual.onEdit = this.draw.bind(this);
      if (this.initialized) {
        visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
      }
      this.visualArray.push(visual);
    }
    return visual;
  }

  /**
  * Filter the data using the domain values for the time scale.
  */
  timeFilterData() {
    return this.data.filter((d) => d[this.timeName] > this.timeScale.domain()[0] && d[this.timeName] < this.timeScale.domain()[1]);
  }

  /**
  * Convert the specified value to an opacity based on the value domain and the min and max opacity values.
  * @param {number} value - A number value.
  */
  valueToOpacity(value) {
    return this.opacityMin + ((value - this.valueDomainStart) * (this.opacityMax - this.opacityMin) / (this.valueDomainEnd - this.valueDomainStart));
  }

  /**
  * Set the editor for this visual and set editing callbacks.
  * @param {object} editor - The VisualEditor to set.
  */
  setEditor(editor) {
    this.editor = editor;
    this.editor.onBrush = this.select.bind(this);
    this.editor.onClick = this.addPoint.bind(this);
    this.editor.onMoveBoxEnd = this.shiftPoints.bind(this);
    this.editor.onDelineate = this.delineate.bind(this);
  }

  /**
  * Get any notes in the specified time and note ranges and return them in an array.
  * @param {number} t1 - The start of the time range.
  * @param {number} t2 - The end of the time range.
  * @param {number} value1 - The start of the value range.
  * @param {number} value2 - The end of the value range.
  * @return {Array} An array of notes in the specified ranges or an empty array.
  */
  getPointsWithinRanges(t1, t2, value1, value2) {
    return this.data.filter(
      point => (point[this.timeName] > t1 && point[this.timeName] < t2) && (point[this.valueName] > value1 && point[this.valueName] < value2)
    );
  }

  /**
  * Select points within the specified x and y ranges.
  * @param {number} x1 - The start of the x range.
  * @param {number} x2 - The end of the x range.
  * @param {number} y1 - The start of the y range.
  * @param {number} y2 - The end of the y range.
  * @param {boolean} append - Append selected points to previously selected points.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  select(x1, x2, y1, y2, append, callDraw = true) {
    var t1 = x1 === null ? 0 : this.timeScale.invert(x1);
    var t2 = x2 === null ? this.timeScale.domain()[1] : this.timeScale.invert(x2);
    var v1 = y1 === null ? this.valueRangeStart : this.valueScale.invert(y1);
    var v2 = y2 === null ? this.valueRangeEnd : this.valueScale.invert(y2);
    if (this.valueType === "integer") {
      v1 = Math.round(v1);
      v2 = Math.round(v2);
    }

    if (this.selectedPoints.length === 0) {
      this.selectedPoints = this.getPointsWithinRanges(t1, t2, v1, v2);
    }
    else {
      if (append) {
        this.selectedPoints = this.selectedPoints.concat(this.getPointsWithinRanges(t1, t2, v1, v2));
      }
      else {
        for (let i = 0; i < this.selectedPoints.length; i++) {
          this.selectedPoints[i].selected = false;
        }
        this.selectedPoints.length = 0;
        this.selectedPoints = this.getPointsWithinRanges(t1, t2, v1, v2);
      }
    }
    for (let i = 0; i < this.selectedPoints.length; i++) {
      this.selectedPoints[i].selected = true;
    }
    if (callDraw) {
      this.draw();
    }

    if (this.onSelect !== undefined) {
      this.onSelect(this.selectedPoints);
    }
  }

  /**
  * Deselect currently selected points.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  deselect(callDraw = true) {
    for (let i = 0; i < this.selectedPoints.length; i++) {
      this.selectedPoints[i].selected = false;
    }
    this.selectedPoints.length = 0;
    if (callDraw) {
      this.draw();
    }
  }

  /**
  * Shift points by the specified delta x value.
  * @param {number} dx - Delta x.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  shiftPoints(dx, callDraw = true) {
    let dt = this.timeScale.invert(dx) - this.timeScale.domain()[0];
    for (let i = 0; i < this.selectedPoints.length; i++) {
      this.selectedPoints[i][this.timeName] += dt;
    }
    this.data.sort((a, b) => a[this.timeName] - b[this.timeName]);
    if (callDraw) {
      this.draw();
    }

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

  /**
  * Add a new point at the specified coordinates.
  * @param {number} x - The x coordinate.
  * @param {number} y - The y coordinate.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  addPoint(x, y, callDraw = true) {
    var p = this.dataTemplate === undefined ? {} : {...this.dataTemplate};
    p[this.timeName] = this.timeScale.invert(x);
    p[this.valueName] = this.valueScale.invert(y);
    if (this.valueType === "integer") {
      p[this.valueName] = Math.round(p[this.valueName]);
    }
    this.data.push(p);
    this.data.sort((a, b) => a[this.timeName] - b[this.timeName]);
    if (callDraw) {
      this.draw();
    }

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

  /**
  * Delete all selected points.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  deleteSelectedPoints(callDraw = true) {
    if (this.selectedPoints.length > 0) {
      for (let i = 0; i < this.selectedPoints.length; i++) {
        let pointIndex = this.data.findIndex(p => p === this.selectedPoints[i]);
        this.data.splice(pointIndex, 1);
      }
      this.selectedPoints.length = 0;
      if (callDraw) {
        this.draw();
      }

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

  /**
  * Inserts time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  insertTime(baseTime, deltaTime) {
    var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
    for (let i = 0; i < shiftData.length; i++) {
      shiftData[i][this.timeName] += deltaTime;
    }
    if (this.onEdit !== undefined) {
      this.onEdit();
    }
  }

  /**
  * Determines whether time in the specified time range can be deleted.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  canDeleteTime(baseTime, deltaTime) {
    var canDelete = true;
    var endDeleteTime = baseTime + deltaTime;
    var dataFound = this.data.filter((datum) => datum[this.timeName] >= baseTime && datum[this.timeName] <= endDeleteTime);
    if (dataFound.length > 0) {
      canDelete = false;
    }
    return canDelete;
  }

  /**
  * Deletes time based on the specified selection range.
  * @param {number} baseTime - The start of the selection.
  * @param {number} deltaTime - The end of the selection.
  */
  deleteTime(baseTime, deltaTime) {
    let shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
    for (let i = 0; i < shiftData.length; i++) {
      shiftData[i][this.timeName] -= deltaTime;
    }
    if (this.onEdit !== undefined) {
      this.onEdit();
    }
  }

  /**
  * Initialize this visual.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.timeScale = timeScale;
    this.initialized = true;

    this.valueScale = d3.scaleLinear()
      .range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0])
      .domain([this.valueDomainStart, this.valueDomainEnd]);
  }

  /**
  * Draw this visual.
  */
  draw() {
    if (this.initialized) {
      if (this.pointsGroup !== undefined) {
        this.pointsGroup.remove();
      }

      // For Points, always filter the data by the domain of the time scale
      let timeFilteredData = this.timeFilterData();

      this.pointsGroup = this.parentElement.append("g")
        .attr("class", "pointsGroup")
        .on("editorClick", (event) => {
          if (this.acceptEditorClick) {
            let t1 = this.timeScale.invert(event.detail.x - this.radius);
            let t2 = this.timeScale.invert(event.detail.x + this.radius);
            let v1 = this.valueScale.invert(event.detail.y + this.radius);
            let v2 = this.valueScale.invert(event.detail.y - this.radius);
            let foundPoint = this.data.filter((p) => p[this.timeName] >= t1 && p[this.timeName] <= t2 && p[this.valueName] >= v1 && p[this.valueName] <= v2);
            if (foundPoint.length === 1) {
              if (!event.detail.append) {
                this.deselect(false);
              }
              foundPoint[0].selected = true;
              this.selectedPoints.push(foundPoint[0]);
              this.draw();
            }
          }
        });

      let points = this; // Rename this for drag() functions where this needs to refer to a joined point circle
      this.pointsGroup.selectAll("g")
        .data(timeFilteredData)
        .join("g")
          .each(function (d) {
            let dataPointGroup = d3.select(this);
            d.backCircle = dataPointGroup.append("circle")
              .attr("r", points.radius)
              .attr("cx", points.timeScale(d[points.timeName]))
              .attr("cy", points.valueScale(d[points.valueName]))
              .style("fill", points.backgroundColor)
              .style("stroke", "none");
            d.frontCircle = dataPointGroup.append("circle")
              .attr("r", points.radius)
              .attr("cx", points.timeScale(d[points.timeName]))
              .attr("cy", points.valueScale(d[points.valueName]))
              .style("fill", () => {
                if ("selected" in d && d.selected) {
                  return points.selectedColor;
                }
                else {
                  return points.color;
                }
              })
              .style("fill-opacity", () => {
                if (points.mapValueToOpacity) {
                  return points.valueToOpacity(d[points.valueName]);
                }
                else {
                  return points.opacity;
                }
              })
              .style("stroke", points.strokeColor)
              .style("stroke-width", points.strokeWidth)
              .style("stroke-opacity", "1.0")
              .on("mouseenter", (event) => {
                d3.select(event.currentTarget).style("cursor", "pointer");
              })
              .on("click", (event) => {
                if (points.editable) {
                  if ("selected" in d && d.selected) {
                    d.selected = false;
                    d3.select(event.currentTarget).style("fill", points.color);
                  }
                  else {
                    d.selected = true;
                    d3.select(event.currentTarget).style("fill", points.selectedColor);
                  }
                }
              })
              .call(d3.drag()
                .on("drag", (event) => {
                  if (points.editable) {
                    let y = parseFloat(d.frontCircle.attr("cy")) + event.dy;

                    if (y < 0) {
                      y = 0;
                    }

                    if (y > points.paneHeight) {
                      y = points.paneHeight;
                    }

                    d.backCircle.attr("cy", y);
                    d.frontCircle.attr("cy", y);

                    let pointOpacity = points.mapValueToOpacity ? points.valueToOpacity(points.valueScale.invert(y)) : points.opacity;
                    d.frontCircle.style("opacity", pointOpacity);

                    if (points.showStems) {
                      d.backStem.attr("y1", y + points.radius);
                      d.stem.attr("y1", y + points.radius);
                      d.stem.style("stroke-opacity", pointOpacity);
                    }
                  }
                })
                .on("end", (event) => {
                  if (points.editable) {
                    let y = parseFloat(d.frontCircle.attr("cy"));
                    d[points.valueName] = Math.round(points.valueScale.invert(y));
                    points.draw();

                    if (points.onEdit !== undefined) {
                      points.onEdit();
                    }
                  }
                })
              );
            if (points.showStems) {
              d.backStem = dataPointGroup.append("line")
                .attr("x1", points.timeScale(d[points.timeName]))
                .attr("y1", points.valueScale(d[points.valueName]) + points.radius)
                .attr("x2", points.timeScale(d[points.timeName]))
                .attr("y2", points.paneHeight)
                .style("stroke", points.backgroundColor)
                .style("stroke-width", "1px");
              d.stem = dataPointGroup.append("line")
                .attr("x1", points.timeScale(d[points.timeName]))
                .attr("y1", points.valueScale(d[points.valueName]) + points.radius)
                .attr("x2", points.timeScale(d[points.timeName]))
                .attr("y2", points.paneHeight)
                .style("stroke", (d) => {
                  if ("selected" in d && d.selected) {
                    return points.selectedColor;
                  }
                  else {
                    return points.color;
                  }
                })
                .style("stroke-width", "1px");
            }
          });

      if (this.editor !== undefined && this.editable) {
        this.editor.initialize(this.pointsGroup, this.paneWidth, this.paneHeight);
        this.editor.draw();
        if (this.selectedPoints.length > 0) {
          let selectedTimes = this.selectedPoints.toSorted((a, b) => a[this.timeName] - b[this.timeName]);
          this.editor.drawEditBox(
            this.timeScale(selectedTimes[0][this.timeName]) - this.radius,
            this.timeScale(selectedTimes[selectedTimes.length-1][this.timeName]) + this.radius
          );
        }
        else {
          this.editor.eraseEditBox();
        }
      }

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

  /**
  * Edit values for points by moving the mouse.
  * @param {number} dx - Delta x.
  * @param {number} pointerX - Pointer x.
  * @param {number} pointerY - Pointer y.
  * @param {boolean} [callDraw=true] - Whether to call draw() from this method.
  */
  delineate(dx, pointerX, pointerY, callDraw = true) {
    var dataDelineated = [];
    var timeFilteredData = this.timeFilterData();
    if (this.editable) {
      let dataFound = [];
      if (dx < 0) {
        dataFound = timeFilteredData.filter(p => {
          let x = this.timeScale(p[this.timeName]);
          return x > pointerX && x < pointerX - dx;
        });
      }
      else if (dx > 0) {
        dataFound = timeFilteredData.filter(p => {
          let x = this.timeScale(p[this.timeName]);
          return x > pointerX - dx && x < pointerX;
        });
      }
      if (dataFound.length > 0) {
        dataDelineated = dataFound;
        for (let i = 0; i < dataFound.length; i++) {
          if (this.valueType === "integer") {
            dataFound[i][this.valueName] = Math.round(this.valueScale.invert(pointerY));
          }
          else if (this.valueType === "float") {
            dataFound[i][this.valueName] = this.valueScale.invert(pointerY);
          }
        }

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

        if (callDraw) {
          this.draw();
        }
      }
    }
    return dataDelineated;
  }

  /**
  * Erase this visual.
  */
  erase() {
    if (this.pointsGroup !== undefined) {
      this.pointsGroup.remove();

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

/** A ProgressIndicator is an animated visual for indicating the progress of time along the X axis. */
class ProgressIndicator {
  /**
  * Create a progress indicator.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.visualType = "progressIndicator";
    this.options = options;
    this.color = options?.color ?? "#006400";
    this.width = options?.width ?? 3;
    this.opacity = options?.opacity ?? "1.0";
    this.propertyArray = [];
    this.line;
    this.x;
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.initialized = false;
    this.progressIndicatorGroup;
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Set x.
  * @param {number} x - The x value.
  */
  setX(x) {
    this.x = x;
  }

  /**
  * Get x.
  * @return {number} The x value.
  */
  getX() {
    return this.x;
  }

  /**
  * Initialize this visual.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.timeScale = timeScale;

    this.initialized = true;
  }

  /**
  * Draw this visual at the specified x location.
  */
  draw(x) {
    if (this.initialized) {
      this.x = x;

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

      this.progressIndicatorGroup = this.parentElement.append("g")
          .attr("class", "progressIndicatorGroup");

      this.line = this.progressIndicatorGroup.append("line")
        .attr("class", "progressIndicator")
        .attr("x1", this.x)
        .attr("y1", 0)
        .attr("x2", this.x)
        .attr("y2", this.paneHeight)
        .style("stroke", this.color)
        .style("stroke-width", this.width)
        .style("opacity", this.opacity);
    }

  }

  /**
  * Move this visual to the specified x location.
  */
  move(x) {
    this.x = x;
    if (this.line !== undefined) {
      this.line
        .attr("x1", this.x)
        .attr("x2", this.x);
    }
  }

  /**
  * Erase this visual.
  */
  erase() {
    if (this.progressIndicatorGroup !== undefined) {
      this.progressIndicatorGroup.remove();
    }
  }
}

/**
* A Spectrogram visual represents the frequency domain of the specified audio.
* @todo Do an actual implementation for this class.
*/
class Spectrogram {
  /**
  * Create an spectrogram visual.
  * @param {Object} audioContext - An array of data.
  * @param {Object} bufferPlayer - An array of data.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(audioContext, bufferPlayer, options) {
    this.visualType = "spectrogram";
    this.audioContext = audioContext;
    this.bufferPlayer = bufferPlayer;
    this.options = options;
    this.name = options?.name ?? null;
    this.showProperties = options?.showProperties ?? false;
    this.showControls = options?.showControls ?? true;
    this.propertyArray = [];
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.initialized = false;
    this.spectrogramGroup;
    this.onDraw;
    this.onErase;

    this.analyser = this.audioContext.createAnalyser();
    this.analyser.fftSize = 256;
    this.binCount = this.analyser.frequencyBinCount;
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Initialize this visual.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.timeScale = timeScale;

    this.initialized = true;
  }

  /**
  * Draw this visual.
  */
  draw() {
    this.spectrogramGroup = this.parentElement.append("g")
      .attr("class", "spectrogramGroup");

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

  /**
  * Erase this visual.
  */
  erase() {
    if (this.spectrogramGroup !== undefined) {
      this.spectrogramGroup.remove();

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

/** A set for containing related visuals. */
class VisualSet {
  /**
  * Create a visual set.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.visualType = "set";
    this.options = options;
    this.on = options?.on ?? true;
    this.name = options?.name ?? null;
    this.showProperties = options?.showProperties ?? false;
    this.showControls = options?.showControls ?? true;
    this.propertyArray = [];
    this.visualArray = [];
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Add the specified visual to this set.
  * @param {visual} visual - The visual to add.
  */
  addVisual(visual) {
    this.visualArray.push(visual);
  }

  /**
  * Get all visuals from this set.
  * @return {Array} An array of visuals or an empty array.
  */
  getVisuals() {
    return this.visualArray;
  }

  /**
  * Initialize this visual set.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    for (let i = 0; i < this.visualArray.length; i++) {
      this.visualArray[i].initialize(parentElement, width, height, timeScale);
    }
  }

  /**
  * Draw all visuals in this set.
  */
  draw() {
    for (let i = 0; i < this.visualArray.length; i++) {
      if (this.visualArray[i].on) {
        this.visualArray[i].draw();
      }
      else {
        this.visualArray[i].erase();
      }
    }
  }

  /**
  * Erase all visuals in this set.
  */
  erase() {
    for (let i = 0; i < this.visualArray.length; i++) {
      this.visualArray[i].erase();
    }
  }
}

/**
* A Waveform visual represents the time domain of the specified audio.
*/
class Waveform {
  /**
  * Create a waveform visual.
  * @param {Object} bufferPlayer - The audio source to use to create this waveform.
  * @param {number} endTime - The absolute end time (not equal to the audio's actual end time) to use for this waveform.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(bufferPlayer, endTime, options) {
    this.visualType = "waveform";
    this.bufferPlayer = bufferPlayer;
    this.endTime = endTime; // The end time is not always the same as the buffer duration
    this.options = options;
    this.name = options?.name ?? null;
    this.on = options?.on ?? true;
    this.color = options?.color ?? "#006400";
    this.opacity = options?.opacity ?? "0.25";
    this.showProperties = options?.showProperties ?? false;
    this.showControls = options?.showControls ?? true;
    this.propertyArray = [];
    this.data;
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.initialized = false;
    this.halfVisualHeight;
    this.amplitudeScale;
    this.waveformGroup;
    this.onDraw;
    this.onErase;
  }

  /**
  * Add the specified property to this visual. The property is stored as [name, value].
  * @param {string} name - The name of the property.
  * @param {string} value - The value of the property.
  */
  addProperty(name, value) {
    this.propertyArray.push([name, value]);
  }

  /**
  * Initialize this visual.
  * @param {Element} parentElement - The parent element.
  * @param {number} width - The parent width.
  * @param {number} height - The parent height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  initialize(parentElement, width, height, timeScale) {
    this.parentElement = parentElement;
    this.paneWidth = width;
    this.paneHeight = height;
    this.timeScale = timeScale;

    this.halfVisualHeight = this.paneHeight/2;

    this.amplitudeScale = d3.scaleLinear()
      .range([0, this.paneHeight])
      .domain([0, 1]);

    this.initialized = true;
  }

  /**
  * Draw this visual.
  */
  draw() {
    if (this.initialized) {
      let width = this.endTime > this.bufferPlayer.duration ? this.timeScale(this.bufferPlayer.duration) : this.paneWidth;
      this.data = this.bufferPlayer.reduceAudioData(0, width, this.timeScale.domain()[0], this.timeScale.domain()[1]);

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

      this.waveformGroup = this.parentElement.append("g")
        .attr("class", "waveformGroup");

      this.waveformGroup.selectAll()
        .data(this.data)
        .join("line")
          .attr("class", "visual " + this.visualType)
          .attr("x1", (d, i) => i)
          .attr("y1", d => this.halfVisualHeight-(this.amplitudeScale(d)/2))
          .attr("x2", (d, i) => i)
          .attr("y2", d => this.halfVisualHeight+(this.amplitudeScale(d)/2))
          .style("stroke", this.color)
          .style("stroke-width", "1")
          .style("stroke-opacity", this.opacity)
          .style("pointer-events", "none");

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

  /**
  * Erase this visual.
  */
  erase() {
    if (this.waveformGroup !== undefined) {
      this.waveformGroup.remove();

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