Source: annotations.js

/** An AnnotationPane holds annotations. */
class AnnotationPane {
  /**
  * Create an annotation pane.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.paneType = "annotation";
    this.options = options;
    this.annotationColor = options?.color ?? "#800000";
    this.panelWidth = options?.panelWidth ?? "100%";
    this.showPanelTitle = options?.showPanelTitle ?? true;
    this.parentElement;
    this.width;
    this.height;
    this.timeScale;
    this.initialized = false;
    this.typeMode = AnnotationType.NONE;
    this.allowBoundaries = true;
    this.allowRegions = true;
    this.allowComments = true;
    this.allowMarkers = true;
    this.allowNoteGroups = true;
    this.allowSaving = true;
    this.boundaryArray = [];
    this.commentArray = [];
    this.markerArray = [];
    this.noteGroupArray = [];
    this.regionArray = [];
    this.showBoundaries = true;
    this.showComments = true;
    this.showMarkers = true;
    this.showNoteGroups = true;
    this.showRegions = true;
    this.annotationPaneGroup;
    this.annotationsGroup;
    this.boundaryAnnotationsGroup;
    this.commentAnnotationsGroup;
    this.noteGroupAnnotationsGroup;
    this.regionAnnotationsGroup;
    this.notes;
    this.dragHandler;
    this.regionSelectRect;
    this.groupSelectRect;
    this.onCreate;
    this.onSelect;
    this.onUpdate;
    this.onUpdateLabel;
    this.onDelete;
    this.onSave;
    this.panelElement;
    this.panelInitialized = false;
    this.panelOn = false;
    this.typeValues = [AnnotationType.BOUNDARY, AnnotationType.COMMENT, AnnotationType.MARKER, AnnotationType.NOTE_GROUP, AnnotationType.REGION];
    this.boundaryStrengths = [1, 2, 3, 4];
    this.annotationPanelDiv;
    this.annotationControlsDiv;
    this.annotationTypeDiv;
    this.annotationSaveButton;
    this.saveButtonActivated = false;
  }

  /**
  * Set the specified type mode for this pane where types are defined in AnnotationType. By setting the type mode, this annotation pane
  * makes annotations of that type active and deactivates all other types. Activating a type of annotation means that annotations of that
  * type can be created, updated, or deleted.
  * @param {number} type - A type defined in AnnotationType.
  */
  setTypeMode(type) {
    this.typeMode = type;
    switch (this.typeMode) {
      case AnnotationType.BOUNDARY:
        this.#activateAnnotationArray(this.boundaryArray, true);
        this.#activateAnnotationArray(this.commentArray, false);
        this.#activateAnnotationArray(this.markerArray, false);
        this.#activateAnnotationArray(this.noteGroupArray, false);
        this.#activateAnnotationArray(this.regionArray, false);
        break;
      case AnnotationType.COMMENT:
        this.#activateAnnotationArray(this.boundaryArray, false);
        this.#activateAnnotationArray(this.commentArray, true);
        this.#activateAnnotationArray(this.markerArray, false);
        this.#activateAnnotationArray(this.noteGroupArray, false);
        this.#activateAnnotationArray(this.regionArray, false);
        break;
      case AnnotationType.MARKER:
        this.#activateAnnotationArray(this.boundaryArray, false);
        this.#activateAnnotationArray(this.commentArray, false);
        this.#activateAnnotationArray(this.markerArray, true);
        this.#activateAnnotationArray(this.noteGroupArray, false);
        this.#activateAnnotationArray(this.regionArray, false);
        break;
      case AnnotationType.NOTE_GROUP:
        this.#activateAnnotationArray(this.boundaryArray, false);
        this.#activateAnnotationArray(this.commentArray, false);
        this.#activateAnnotationArray(this.markerArray, false);
        this.#activateAnnotationArray(this.noteGroupArray, true);
        this.#activateAnnotationArray(this.regionArray, false);
        break;
      case AnnotationType.REGION:
        this.#activateAnnotationArray(this.boundaryArray, false);
        this.#activateAnnotationArray(this.commentArray, false);
        this.#activateAnnotationArray(this.markerArray, false);
        this.#activateAnnotationArray(this.noteGroupArray, false);
        this.#activateAnnotationArray(this.regionArray, true);
        break;
      case AnnotationType.NONE:
        this.#activateAnnotationArray(this.boundaryArray, false);
        this.#activateAnnotationArray(this.commentArray, false);
        this.#activateAnnotationArray(this.markerArray, false);
        this.#activateAnnotationArray(this.noteGroupArray, false);
        this.#activateAnnotationArray(this.regionArray, false);
        break;
    }
  }

  /**
  * Get the current type mode where types are defined in AnnotationType.
  * @return {AnnotationType} A constant representing the type.
  */
  getTypeMode() {
    return this.typeMode;
  }

  /**
  * Generate a boundary annotation with the specified parameters and return it. If the type mode for this annotation pane
  * is set to BOUNDARY, the newly-generated Boundary will be active.
  * @param {number} time - The time.
  * @param {number} strength - The strength.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  * @param {boolean} firstCreated - Whether this boundary was created for the first time.
  * @return {Boundary} A newly-generated Boundary.
  */
  generateBoundary(time, strength, label, properties, firstCreated) {
    var active = (this.typeMode === AnnotationType.BOUNDARY) ? true : false;
    return new Boundary(time, strength, label, this.options, properties, active, firstCreated);
  }

  /**
  * Add a boundary annotation with the specified parameters. The boundary will have a status of UNCHANGED (see AnnotationStatus for other status values).
  * This method is most useful for adding saved boundaries to an annotation pane.
  * @param {number} time - The time.
  * @param {number} strength - The strength.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  addBoundary(time, strength, label, properties) {
    this.boundaryArray.push(this.generateBoundary(time, strength, label, properties, false));
  }

  /**
  * Create a new boundary annotation with a status of CREATED and run onCreate() if it is set. This method is used for interactively-created boundaries.
  * @param {number} time - The time.
  * @param {number} strength - The strength.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  createBoundary(time, strength, label, properties) {
    if (this.typeMode !== AnnotationType.BOUNDARY) {
      this.typeMode = AnnotationType.BOUNDARY;
    }
    var boundary = this.generateBoundary(time, strength, label, properties, true);
    this.boundaryArray.push(boundary);
    if (this.onCreate !== undefined) {
      this.onCreate(boundary);
    }
  }

  /**
  * Generate a comment annotation with the specified parameters and return it. If the type mode for this annotation pane
  * is set to COMMENT, the newly-generated Comment will be active.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  * @param {boolean} firstCreated - Whether this comment was created for the first time.
  * @return {Comment} A newly-generated Comment.
  */
  generateComment(time, label, properties, firstCreated) {
    var active = (this.typeMode === AnnotationType.COMMENT) ? true : false;
    return new Comment(time, label, this.options, properties, active, firstCreated);
  }

  /**
  * Add a comment annotation with the specified parameters. The comment will have a status of UNCHANGED (see AnnotationStatus for other status values).
  * This method is most useful for adding saved comments to an annotation pane.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  addComment(time, label, properties) {
    this.commentArray.push(this.generateComment(time, label, properties, false));
  }

  /**
  * Create a new comment annotation with a status of CREATED and run onCreate() if it is set. This method is used for interactively-created comments.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  createComment(time, label, properties) {
    if (this.typeMode !== AnnotationType.COMMENT) {
      this.typeMode = AnnotationType.COMMENT;
    }
    var comment = this.generateComment(time, label, properties, true);
    this.commentArray.push(comment);
    if (this.onCreate !== undefined) {
      this.onCreate(comment);
    }
  }

  /**
  * Generate a marker annotation with the specified parameters and return it. If the type mode for this annotation pane
  * is set to MARKER, the newly-generated Marker will be active.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  * @param {boolean} firstCreated - Whether this comment was created for the first time.
  * @return {Marker} A newly-generated Marker.
  */
  generateMarker(time, label, properties, firstCreated) {
    var active = (this.typeMode === AnnotationType.MARKER) ? true : false;
    return new Marker(time, label, this.options, properties, active, firstCreated);
  }

  /**
  * Add a marker annotation with the specified parameters. The marker will have a status of UNCHANGED (see AnnotationStatus for other status values).
  * This method is most useful for adding saved markers to an annotation pane.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  addMarker(time, label, properties) {
    this.markerArray.push(this.generateMarker(time, label, properties, false));
  }

  /**
  * Create a new marker annotation with a status of CREATED and run onCreate() if it is set. This method is used for interactively-created markers.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  createMarker(time, label, properties) {
    if (this.typeMode !== AnnotationType.MARKER) {
      this.typeMode = AnnotationType.MARKER;
    }
    var marker = this.generateMarker(time, label, properties, true);
    this.markerArray.push(marker);
    if (this.onCreate !== undefined) {
      this.onCreate(marker);
    }
  }

  /**
  * Generate a note group annotation with the specified parameters and return it. If the type mode for this annotation pane
  * is set to NOTE_GROUP, the newly-generated NoteGroup will be active.
  * @param {Array} noteArray - An array of notes.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  * @param {boolean} firstCreated - Whether this comment was created for the first time.
  * @return {NoteGroup} A newly-generated NoteGroup.
  */
  generateNoteGroup(noteArray, label, properties, firstCreated) {
    var active = (this.typeMode === AnnotationType.NOTE_GROUP) ? true : false;
    return new NoteGroup(noteArray, this.notes.noteScale, label, this.options, properties, active, firstCreated);
  }

  /**
  * Add a note group annotation with the specified parameters. The note group will have a status of UNCHANGED (see AnnotationStatus for other status values).
  * This method is most useful for adding saved markers to an annotation pane.
  * @param {Array} noteArray - An array of notes.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  addNoteGroup(noteArray, label, properties) {
    if (this.notes !== undefined) {
      this.noteGroupArray.push(this.generateNoteGroup(noteArray, label, properties, false));
    }
  }

  /**
  * Create a new note group annotation with a status of CREATED and run onCreate() if it is set. This method is used for interactively-created note groups.
  * @param {Array} noteArray - An array of notes.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  createNoteGroup(noteArray, label, properties) {
    if (this.typeMode !== AnnotationType.NOTE_GROUP) {
      this.typeMode = AnnotationType.NOTE_GROUP;
    }
    if (this.notes !== undefined) {
      let noteGroup = this.generateNoteGroup(noteArray, label, properties, true);
      this.noteGroupArray.push(noteGroup);
      this.#deselectOthers(noteGroup);
      if (this.onCreate !== undefined) {
        this.onCreate(noteGroup);
      }
    }
  }

  /**
  * Generate a region annotation with the specified parameters and return it. If the type mode for this annotation pane
  * is set to REGION, the newly-generated Region will be active.
  * @param {number} startTime - The start time.
  * @param {number} endTime - The end time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  * @param {boolean} firstCreated - Whether this comment was created for the first time.
  * @return {Region} A newly-generated Region.
  */
  generateRegion(startTime, endTime, label, properties, firstCreated) {
    var active = (this.typeMode === AnnotationType.REGION) ? true : false;
    return new Region(startTime, endTime, label, this.options, properties, active, firstCreated)
  }

  /**
  * Add a region annotation with the specified parameters. The region will have a status of UNCHANGED (see AnnotationStatus for other status values).
  * This method is most useful for adding saved markers to an annotation pane.
  * @param {number} startTime - The start time.
  * @param {number} endTime - The end time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  addRegion(startTime, endTime, label, properties) {
    this.regionArray.push(this.generateRegion(startTime, endTime, label, properties, false));
  }

  /**
  * Create a new region annotation with a status of CREATED and run onCreate() if it is set. This method is used for interactively-created regions.
  * @param {number} startTime - The start time.
  * @param {number} endTime - The end time.
  * @param {string} label - A label.
  * @param {Object} properties - Properties.
  */
  createRegion(startTime, endTime, label, properties) {
    if (this.typeMode !== AnnotationType.REGION) {
      this.typeMode = AnnotationType.REGION;
    }
    var region = this.generateRegion(startTime, endTime, label, properties, true);
    this.regionArray.push(region);
    if (this.onCreate !== undefined) {
      this.onCreate(region);
    }
  }

  /**
  * Add an array of annotations of the specified type to this pane.
  * @param {AnnotationType} type - The type of annotation.
  * @param {Array} annotations - An array of annotations.
  */
  addAnnotations(type, annotations) {
    switch (type) {
      case AnnotationType.BOUNDARY:
        this.boundaryArray = [...this.boundaryArray, ...annotations];
        break;
      case AnnotationType.COMMENT:
        this.commentArray = [...this.commentArray, ...annotations];
        break;
      case AnnotationType.MARKER:
        this.markerArray = [...this.markerArray, ...annotations];
        break;
      case AnnotationType.NOTE_GROUP:
        this.noteGroupArray = [...this.noteGroupArray, ...annotations];
        break;
      case AnnotationType.REGION:
        this.regionArray = [...this.regionArray, ...annotations];
        break;
    }
  }

  /**
  * Activate all annotations in this pane's annotation array.
  */
  #activateAnnotationArray(annotationArray, activate) {
    if (annotationArray.length > 0) {
      for (let i = 0; i < annotationArray.length; i++) {
        annotationArray[i].active = activate;
      }
    }
  }

  /**
  * Show or hide annotations of the specified type.
  * @param {AnnotationType} type - The type of annotation.
  * @param {boolean} show - True shows annotations and false hides them.
  */
  showAnnotationType(type, show) {
    switch (type) {
      case AnnotationType.BOUNDARY:
        this.showBoundaries = show;
        if (show) {
          this.drawBoundaries();
        }
        else {
          this.eraseBoundaries();
        }
        break;
      case AnnotationType.COMMENT:
        this.showComments = show;
        if (show) {
          this.drawComments();
        }
        else {
          this.eraseComments();
        }
        break;
      case AnnotationType.MARKER:
        this.showMarkers = show;
        if (show) {
          this.drawMarkers();
        }
        else {
          this.eraseMarkers();
        }
        break;
      case AnnotationType.NOTE_GROUP:
        this.showNoteGroups = show;
        if (show) {
          this.drawNoteGroups();
        }
        else {
          this.eraseNoteGroups();
        }
        break;
      case AnnotationType.REGION:
        this.showRegions = show;
        if (show) {
          this.drawRegions();
        }
        else {
          this.eraseRegions();
        }
        break;
    }
  }

  /**
  * Show or hide annotations of the specified type and with the specified property that's equal to the specified value.
  * @param {AnnotationType} type - The type of annotation.
  * @param {string} propertyName - The name of the property.
  * @param {string} propertyValue - The value of the property.
  * @param {boolean} show - True shows annotations and false hides them.
  */
  showAnnotationsByProperty(type, propertyName, propertyValue, show) {
    switch (type) {
      case AnnotationType.BOUNDARY:
        for (let i = 0; i < this.boundaryArray.length; i++) {
          if (this.boundaryArray[i].properties !== undefined && this.boundaryArray[i].properties[propertyName] === propertyValue) {
            this.boundaryArray[i].show = show;
          }
        }
        break;
      case AnnotationType.COMMENT:
        for (let i = 0; i < this.commentArray.length; i++) {
          if (this.commentArray[i].properties !== undefined && this.commentArray[i].properties[propertyName] === propertyValue) {
            this.commentArray[i].show = show;
          }
        }
        break;
      case AnnotationType.MARKER:
        for (let i = 0; i < this.markerArray.length; i++) {
          if (this.markerArray[i].properties !== undefined && this.markerArray[i].properties[propertyName] === propertyValue) {
            this.markerArray[i].show = show;
          }
        }
        break;
      case AnnotationType.NOTE_GROUP:
        for (let i = 0; i < this.noteGroupArray.length; i++) {
          if (this.noteGroupArray[i].properties !== undefined && this.noteGroupArray[i].properties[propertyName] === propertyValue) {
            this.noteGroupArray[i].show = show;
          }
        }
        break;
      case AnnotationType.REGION:
        for (let i = 0; i < this.regionArray.length; i++) {
          if (this.regionArray[i].properties !== undefined && this.regionArray[i].properties[propertyName] === propertyValue) {
            this.regionArray[i].show = show;
          }
        }
        break;
    }
  }

  /**
  * Set the show/hide value for all annotations.
  * @param {boolean} show - True shows annotations and false hides them.
  */
  showAllAnnotations(show) {
    this.showBoundaries = show;
    this.showComments = show;
    this.showMarkers = show;
    this.showNoteGroups = show;
    this.showRegions = show;
  }

  /**
  * Activate or deactivate all annotations.
  * @param {boolean} activate - True activates annotations and false deactivates them.
  */
  activateAnnotations(activate) {
    this.#activateAnnotationArray(this.boundaryArray, activate);
    this.#activateAnnotationArray(this.commentArray, activate);
    this.#activateAnnotationArray(this.markerArray, activate);
    this.#activateAnnotationArray(this.noteGroupArray, activate);
    this.#activateAnnotationArray(this.regionArray, activate);
  }

  /**
  * Deselect all annotations regardless of type.
  */
  deselectAllAnnotations() {
    if (this.boundaryArray.length > 0) {
      for (let i = 0; i < this.boundaryArray.length; i++) {
        this.boundaryArray[i].select(false);
      }
    }

    if (this.commentArray.length > 0) {
      for (let i = 0; i < this.commentArray.length; i++) {
        this.commentArray[i].select(false);
      }
    }

    if (this.markerArray.length > 0) {
      for (let i = 0; i < this.markerArray.length; i++) {
        this.markerArray[i].select(false);
      }
    }

    if (this.regionArray.length > 0) {
      for (let i = 0; i < this.regionArray.length; i++) {
        this.regionArray[i].select(false);
      }
    }

    if (this.noteGroupArray.length > 0) {
      for (let i = 0; i < this.noteGroupArray.length; i++) {
        this.noteGroupArray[i].select(false);
      }
    }

    if (this.panelOn) {
      this.drawAnnotationList();
    }
  }

  /**
  * Select or deselect all annotations of the specified type.
  * @param {AnnotationType} type - The type of annotation.
  * @param {boolean} selectAll - True selects all annotations and false deselects them.
  */
  selectAllAnnotationsOfType(type, selectAll) {
    switch (type) {
      case AnnotationType.BOUNDARY:
        if (this.boundaryArray.length > 0) {
          for (let i = 0; i < this.boundaryArray.length; i++) {
            this.boundaryArray[i].select(selectAll);
          }
        }
        break;
      case AnnotationType.COMMENT:
        if (this.commentArray.length > 0) {
          for (let i = 0; i < this.commentArray.length; i++) {
            this.commentArray[i].select(selectAll);
          }
        }
        break;
      case AnnotationType.MARKER:
        if (this.markerArray.length > 0) {
          for (let i = 0; i < this.markerArray.length; i++) {
            this.markerArray[i].select(selectAll);
          }
        }
        break;
      case AnnotationType.NOTE_GROUP:
        if (this.noteGroupArray.length > 0) {
          for (let i = 0; i < this.noteGroupArray.length; i++) {
            this.noteGroupArray[i].select(selectAll);
          }
        }
        break;
      case AnnotationType.REGION:
        if (this.regionArray.length > 0) {
          for (let i = 0; i < this.regionArray.length; i++) {
            this.regionArray[i].select(selectAll);
          }
        }
        break;
    }
  }

  /**
  * Deselects all other annotations that are not the specified annotation.
  * @param {annotation} annotation - The annotations that is not to be deselected.
  */
  #deselectOthers(annotation) {
    switch (this.typeMode) {
      case AnnotationType.BOUNDARY:
        if (this.boundaryArray.length > 0) {
          for (let i = 0; i < this.boundaryArray.length; i++) {
            if (this.boundaryArray[i] !== annotation) {
              this.boundaryArray[i].select(false);
            }
          }
        }
        break;
      case AnnotationType.COMMENT:
        if (this.commentArray.length > 0) {
          for (let i = 0; i < this.commentArray.length; i++) {
            if (this.commentArray[i] !== annotation) {
              this.commentArray[i].select(false);
            }
          }
        }
        break;
      case AnnotationType.MARKER:
        if (this.markerArray.length > 0) {
          for (let i = 0; i < this.markerArray.length; i++) {
            if (this.markerArray[i] !== annotation) {
              this.markerArray[i].select(false);
            }
          }
        }
        break;
      case AnnotationType.NOTE_GROUP:
        if (this.noteGroupArray.length > 0) {
          for (let i = 0; i < this.noteGroupArray.length; i++) {
            if (this.noteGroupArray[i] !== annotation) {
              this.noteGroupArray[i].select(false);
            }
          }
        }
        break;
      case AnnotationType.REGION:
        if (this.regionArray.length > 0) {
          for (let i = 0; i < this.regionArray.length; i++) {
            if (this.regionArray[i] !== annotation) {
              this.regionArray[i].select(false);
            }
          }
        }
        break;
    }
  }

  /**
  * Select a note for creating a new note group or adding to an existing one. If a note group exists and is selected, then calling this method
  * will add the note to that group. Otherwise the selected note will create a new note group.
  * @param {Object} note - The annotations that is not to be deselected.
  * @param {boolean} createGroup - The annotations that is not to be deselected.
  */
  selectNote(note, createGroup) {
    if (this.typeMode === AnnotationType.NOTE_GROUP && this.showNoteGroups) {
      if (createGroup) {
        let noteArray = [];
        noteArray.push(note);
        this.createNoteGroup(noteArray, "");
      }
      else {
        let groupArray = this.getAnnotationsByType(AnnotationType.NOTE_GROUP);
        if (groupArray.length > 0) {
          for (let i = 0; i < groupArray.length; i++) {
            if (!createGroup) {
              if (groupArray[i].selected) {
                groupArray[i].addNote(note);
                if (this.onUpdate !== undefined) {
                  this.onUpdate(groupArray[i]);
                }
              }
            }
          }
        }
      }
      this.drawPanel();
      this.drawNoteGroups();
    }
  }

  /**
  * Select a note for creating a new note group or adding to an existing one. If a note group exists and is selected, then calling this method
  * will add the note to that group. Otherwise the selected note will create a new note group.
  * @param {number} t1 - The start of the time range.
  * @param {number} t2 - The end of the time range.
  * @param {number} y1 - The start of the value range.
  * @param {number} y2 - The end of the value range.
  * @param {boolean} createGroup - Whether to create a new note group.
  * @param {boolean} deselectGroup - Whether notes in a group should be deleted from or added to a note group.
  */
  selectNoteRange(t1, t2, y1, y2, createGroup, deselectGroup) {
    if (this.notes !== undefined) {
      let note1 = this.notes.noteScale.invert(y1);
      let note2 = this.notes.noteScale.invert(y2);
      let selectedNoteEvents = this.notes.getNotesWithinRanges(t1, t2, note1, note2);
      if (selectedNoteEvents.length > 0) {
        let noteArray = [];
        for (let i = 0; i < selectedNoteEvents.length; i++) {
          noteArray.push(selectedNoteEvents[i]);
        }
        if (createGroup) {
          this.createNoteGroup(noteArray, "");
        }
        else {
          let groupArray = this.getAnnotationsByType(AnnotationType.NOTE_GROUP);
          if (groupArray.length > 0) {
            for (let i = 0; i < groupArray.length; i++) {
              if (groupArray[i].selected) {
                if (deselectGroup) {
                  groupArray[i].deleteNoteArray(noteArray);
                }
                else {
                  groupArray[i].addNoteArray(noteArray);
                }
                if (this.onUpdate !== undefined) {
                  this.onUpdate(groupArray[i]);
                }
              }
            }
          }
        }
        this.drawNoteGroups();
      }
    }
  }

  /**
  * Draw boundaries on this pane.
  */
  drawBoundaries() {
    if (this.showBoundaries) {
      this.eraseBoundaries();

      this.boundaryAnnotationsGroup = this.annotationsGroup.append("g")
        .attr("class", "boundaryAnnotationsGroup");

      let drawBoundaryArray = this.boundaryArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED &&
                                                                      annotation.time > this.timeScale.domain()[0] &&
                                                                      annotation.time < this.timeScale.domain()[1]);
      for (let i = 0; i < drawBoundaryArray.length; i++) {
        drawBoundaryArray[i].draw(this.boundaryAnnotationsGroup, this.width, this.height, this.timeScale);
      }
    }
  }

  /**
  * Erase boundaries from this pane.
  */
  eraseBoundaries() {
    for (let i = 0; i < this.boundaryArray.length; i++) {
      this.boundaryArray[i].erase();
    }
  }

  /**
  * Draw comments on this pane.
  */
  drawComments() {
    if (this.showComments) {
      this.eraseComments();

      this.commentAnnotationsGroup = this.annotationsGroup.append("g")
        .attr("class", "commentAnnotationsGroup");

      let drawCommentArray = this.commentArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED &&
                                                      annotation.time > this.timeScale.domain()[0] &&
                                                      annotation.time < this.timeScale.domain()[1]);
      for (let i = 0; i < drawCommentArray.length; i++) {
        drawCommentArray[i].draw(this.commentAnnotationsGroup, this.width, this.height, this.timeScale);
      }
    }
  }

  /**
  * Erase comments from this pane.
  */
  eraseComments() {
    for (let i = 0; i < this.commentArray.length; i++) {
      this.commentArray[i].erase();
    }
  }

  /**
  * Draw markers on this pane.
  */
  drawMarkers() {
    if (this.showMarkers) {
      this.eraseMarkers();

      this.markerAnnotationsGroup = this.annotationsGroup.append("g")
        .attr("class", "markerAnnotationsGroup");

      let drawMarkerArray = this.markerArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED &&
                                                    annotation.time > this.timeScale.domain()[0] &&
                                                    annotation.time < this.timeScale.domain()[1]);
      for (let i = 0; i < drawMarkerArray.length; i++) {
        drawMarkerArray[i].draw(this.markerAnnotationsGroup, this.width, this.height, this.timeScale);
      }
    }
  }

  /**
  * Erase markers from this pane.
  */
  eraseMarkers() {
    for (let i = 0; i < this.markerArray.length; i++) {
      this.markerArray[i].erase();
    }
  }

  /**
  * Draw note selectors on this pane. Note selectors are invisible interactive rectangles that cover notes. When clicked
  * a note selector can either add or remove a note from a note group or create a new note group.
  */
  drawNoteSelectors() {
    if (this.notes !== undefined && this.showNoteGroups) {
      this.noteSelectorGroup = this.annotationsGroup.append("g")
        .attr("class", "noteSelectorGroup");

      this.noteSelectorGroup.selectAll("noteRect")
        .data(this.notes.data)
        .join("rect")
          .attr("class", "annotation noteSelector")
          .attr("x", d => this.notes.timeScale(d.startTime))
          .attr("y", d => this.notes.noteScale(d.note))
          .attr("width", (d) => {
            let w = this.notes.timeScale(d.endTime) - this.notes.timeScale(d.startTime);
            if (w > 0) {
              return w;
            }
            else {
              return 0;
            }
          })
          .attr("height", () => this.notes.noteHeight)
          .style("fill-opacity", "0")
          .style("stroke-opacity", "0")
          .style("stroke-width", this.notes.strokeWidth)
          .style("pointer-events", "all")
          .on("click", (event, d) => {
            let createGroup = true;
            if (event.metaKey || event.ctrlKey) {
              createGroup = false;
            }
            this.selectNote(d, createGroup);
            event.stopPropagation();
          });
    }
  }

  /**
  * Draw note groups on this pane.
  */
  drawNoteGroups() {
    if (this.showNoteGroups) {
      this.eraseNoteGroups();

      this.noteGroupAnnotationsGroup = this.annotationsGroup.append("g")
        .attr("class", "noteGroupAnnotationsGroup");

      let noteHeight = this.height/88;
      let drawUnselectedArray = this.noteGroupArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED && !annotation.selected);
      let drawSelectedArray = this.noteGroupArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED && annotation.selected);

      // Draw un-selected groups first
      for (let i = 0; i < drawUnselectedArray.length; i++) {
        drawUnselectedArray[i].draw(this.noteGroupAnnotationsGroup, this.timeScale, noteHeight);
      }

      // Draw selected groups last so that notes in multiple groups are shown properly when one is selected
      for (let i = 0; i < drawSelectedArray.length; i++) {
        drawSelectedArray[i].draw(this.noteGroupAnnotationsGroup, this.timeScale, noteHeight);
      }
    }
  }

  /**
  * Erase note groups from this pane.
  */
  eraseNoteGroups() {
    for (let i = 0; i < this.noteGroupArray.length; i++) {
      this.noteGroupArray[i].erase();
    }
  }

  /**
  * Draw regions on this pane.
  */
  drawRegions() {
    if (this.showRegions) {
      this.eraseRegions();

      this.regionAnnotationsGroup = this.annotationsGroup.append("g")
        .attr("class", "regionAnnotationsGroup");

      let drawRegionArray = this.regionArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED);
      for (let i = 0; i < drawRegionArray.length; i++) {
        drawRegionArray[i].draw(this.regionAnnotationsGroup, this.width, this.height, this.timeScale);
      }
    }
  }

  /**
  * Erase regions from this pane.
  */
  eraseRegions() {
    for (let i = 0; i < this.regionArray.length; i++) {
      this.regionArray[i].erase();
    }
  }

  /**
  * Get the name string for the specified annotation type.
  * @param {AnnotationType} type - The type of annotation.
  */
  getAnnotationTypeName(type) {
    var name;
    switch(type) {
      case AnnotationType.BOUNDARY:
        name = "Boundaries";
        break;
      case AnnotationType.COMMENT:
        name = "Comments";
        break;
      case AnnotationType.MARKER:
        name = "Markers";
        break;
      case AnnotationType.NOTE_GROUP:
        name = "Note Groups";
        break;
      case AnnotationType.REGION:
        name = "Regions";
        break;
    }
    return name;
  }

  /**
  * Draw controls that affect selected annotations of any type.
  */
  drawSelectedAnnotationControls() {
    var selectedAnnotationControlDiv = this.annotationListDiv.append("div")
      .attr("class", "selectedAnnotationControlDiv")
      .style("display", "grid")
      .style("grid-template-columns", "1fr 1fr")
      .style("padding-bottom", "2ex")
      .style("border-bottom", "1px solid black")
      .style("margin-bottom", "2ex");
    var selectAllDiv = selectedAnnotationControlDiv.append("div")
      .attr("class", "selectAllDiv")
      .style("display", "flex")
      .style("flex-direction", "row");
    selectAllDiv.append("div")
      .attr("class", "selectAllCheckboxDiv")
      .append("input")
        .attr("type", "checkbox")
        .attr("name", "selectAllCheckbox")
        .on("change", (event) => {
          switch(this.typeMode) {
            case AnnotationType.BOUNDARY:
              for (let i = 0; i < this.boundaryArray.length; i++) {
                this.boundaryArray[i].select(event.target.checked);
              }
              this.drawBoundaryList();
              break;
            case AnnotationType.COMMENT:
              for (let i = 0; i < this.commentArray.length; i++) {
                this.commentArray[i].select(event.target.checked);
              }
              this.drawCommentList();
              break;
            case AnnotationType.MARKER:
              for (let i = 0; i < this.markerArray.length; i++) {
                this.markerArray[i].select(event.target.checked);
              }
              this.drawMarkerList();
              break;
            case AnnotationType.NOTE_GROUP:
              for (let i = 0; i < this.noteGroupArray.length; i++) {
                this.noteGroupArray[i].select(event.target.checked);
              }
              this.drawNoteGroupList();
              break;
            case AnnotationType.REGION:
              for (let i = 0; i < this.regionArray.length; i++) {
                this.regionArray[i].select(event.target.checked);
              }
              this.drawRegionList();
              break;
          }
        });

    if (this.typeMode === AnnotationType.BOUNDARY) {
      selectAllDiv.append("div")
        .attr("class", "selectedBoundaryStrengthDiv")
        .style("margin-left", "0.5ch")
        .append("select")
          .attr("id", "selectedBoundaryStrengthSelect")
          .on("change", (event) => {
            for (let i = 0; i < this.boundaryArray.length; i++) {
              if (this.boundaryArray[i].status !== AnnotationStatus.DELETED && this.boundaryArray[i].selected) {
                this.boundaryArray[i].setStrength(parseInt(event.target.value));
                if (this.onUpdate !== undefined) {
                  this.onUpdate(this.boundaryArray[i]);
                }
              }
            }
            this.draw();
            this.drawAnnotationList();
          })
          .selectAll("option")
          .data(this.boundaryStrengths)
          .enter()
          .append("option")
            .attr("value", (d) => d)
            .text((d) => d);
      d3.select("#selectedBoundaryStrengthSelect").property("selectedIndex", "-1");
    }

    selectedAnnotationControlDiv.append("div")
      .attr("class", "deleteSelectedDiv")
      .style("display", "flex")
      .style("flex-direction", "row")
      .style("justify-content", "flex-end")
      .append("button")
        .attr("type", "button")
        .style("font-weight", "bold")
        .text("\u2715")
        .on("click", () => {
          this.deleteSelectedAnnotations();
        });
  }

  /**
  * Draw the annotation list for the current annotation type. Display a message if no annotations
  * of the current type exist.
  */
  drawAnnotationList() {
    if (this.annotationListDiv !== undefined) {
      this.annotationListDiv.remove();
    }

    this.annotationListDiv = this.annotationPanelDiv.append("div")
      .attr("class", "annotationListDiv")
      .style("display", "flex")
      .style("flex-direction", "column");

    switch(this.typeMode) {
      case AnnotationType.BOUNDARY:
        if (this.getAnnotationsByType(AnnotationType.BOUNDARY).length > 0) {
          this.drawSelectedAnnotationControls();
          this.drawBoundaryList();
        }
        else {
          this.annotationListDiv.append("div")
            .text("No boundaries added");
        }
        break;
      case AnnotationType.COMMENT:
        if (this.getAnnotationsByType(AnnotationType.COMMENT).length > 0) {
          this.drawSelectedAnnotationControls();
          this.drawCommentList();
        }
        else {
          this.annotationListDiv.append("div")
            .text("No comments added");
        }
        break;
      case AnnotationType.MARKER:
        if (this.getAnnotationsByType(AnnotationType.MARKER).length > 0) {
          this.drawSelectedAnnotationControls();
          this.drawMarkerList();
        }
        else {
          this.annotationListDiv.append("div")
            .text("No markers added");
        }
        break;
      case AnnotationType.NOTE_GROUP:
        if (this.getAnnotationsByType(AnnotationType.NOTE_GROUP).length > 0) {
          this.drawSelectedAnnotationControls();
          this.drawNoteGroupList();
        }
        else {
          this.annotationListDiv.append("div")
            .text("No note groups added");
        }
        break;
      case AnnotationType.REGION:
        if (this.getAnnotationsByType(AnnotationType.REGION).length > 0) {
          this.drawSelectedAnnotationControls();
          this.drawRegionList();
        }
        else {
          this.annotationListDiv.append("div")
            .text("No regions added");
        }
        break;
    }
  }

  /**
  * Erase any annotation list.
  */
  eraseAnnotationList() {
    if (this.annotationListDiv !== undefined) {
      this.annotationListDiv.selectAll(".annotationDiv").remove();
    }
  }

  /**
  * Draw the boundary list.
  */
  drawBoundaryList() {
    this.eraseAnnotationList();

    if (this.boundaryArray.length > 0) {
      this.boundaryArray.sort((a,b) => a.time-b.time);
      for (let i = 0; i < this.boundaryArray.length; i++) {
        if (this.boundaryArray[i].status !== AnnotationStatus.DELETED) {
          let boundaryControlDiv = this.annotationListDiv.append("div")
            .attr("class", "annotationDiv boundaryControlDiv")
            .style("display", "flex")
            .style("flex-direction", "row")
            .style("align-items", "center")
            .style("padding-bottom", "1ex");

          this.drawCheckBox(boundaryControlDiv, this.boundaryArray[i]);
          this.drawStrengthSelect(boundaryControlDiv, this.boundaryArray[i]);
          this.drawTime(boundaryControlDiv, this.boundaryArray[i]);
          this.drawLabelInput(boundaryControlDiv, 15, this.boundaryArray[i]);
        }
      }
    }
  }

  /**
  * Draw the comment list.
  */
  drawCommentList() {
    this.eraseAnnotationList();

    if (this.commentArray.length > 0) {
      this.commentArray.sort((a,b) => a.time-b.time);
      for (let i = 0; i < this.commentArray.length; i++) {
        if (this.commentArray[i].status !== AnnotationStatus.DELETED) {
          let commentControlDiv = this.annotationListDiv.append("div")
            .attr("class", "annotationDiv commentControlDiv")
            .style("display", "flex")
            .style("flex-direction", "row")
            .style("align-items", "center")
            .style("padding-bottom", "1ex");

          this.drawCheckBox(commentControlDiv, this.commentArray[i]);
          this.drawTime(commentControlDiv, this.commentArray[i]);
          this.drawLabelInput(commentControlDiv, 20, this.commentArray[i]);
        }
      }
    }
  }

  /**
  * Draw the marker list.
  */
  drawMarkerList() {
    this.eraseAnnotationList();

    if (this.markerArray.length > 0) {
      this.markerArray.sort((a,b) => a.time-b.time);
      for (let i = 0; i < this.markerArray.length; i++) {
        if (this.markerArray[i].status !== AnnotationStatus.DELETED) {
          let markerControlDiv = this.annotationListDiv.append("div")
            .attr("class", "annotationDiv markerControlDiv")
            .style("display", "flex")
            .style("flex-direction", "row")
            .style("align-items", "center")
            .style("padding-bottom", "1ex");

          this.drawCheckBox(markerControlDiv, this.markerArray[i]);
          this.drawTime(markerControlDiv, this.markerArray[i]);
          this.drawLabelInput(markerControlDiv, 20, this.markerArray[i]);
        }
      }
    }
  }

  /**
  * Draw the note group list.
  */
  drawNoteGroupList() {
    this.eraseAnnotationList();

    if (this.noteGroupArray.length > 0) {
      for (let i = 0; i < this.noteGroupArray.length; i++) {
        if (this.noteGroupArray[i].status !== AnnotationStatus.DELETED) {
          let noteGroupControlDiv = this.annotationListDiv.append("div")
            .attr("class", "annotationDiv noteGroupControlDiv")
            .style("display", "flex")
            .style("flex-direction", "row")
            .style("align-items", "center")
            .style("padding-bottom", "1ex");

          this.drawCheckBox(noteGroupControlDiv, this.noteGroupArray[i]);
          this.drawLabelInput(noteGroupControlDiv, 24, this.noteGroupArray[i]);
        }
      }
    }
  }

  /**
  * Draw the region list.
  */
  drawRegionList() {
    this.eraseAnnotationList();

    if (this.regionArray.length > 0) {
      this.regionArray.sort((a,b) => a.startTime-b.startTime);
      for (let i = 0; i < this.regionArray.length; i++) {
        if (this.regionArray[i].status !== AnnotationStatus.DELETED) {
          let regionControlDiv = this.annotationListDiv.append("div")
            .attr("class", "annotationDiv regionControlDiv")
            .style("display", "flex")
            .style("flex-direction", "row")
            .style("align-items", "center")
            .style("padding-bottom", "1ex");

          this.drawCheckBox(regionControlDiv, this.regionArray[i]);
          this.drawTime(regionControlDiv, this.regionArray[i]);
          this.drawLabelInput(regionControlDiv, 12, this.regionArray[i]);
        }
      }
    }
  }

  /**
  * Draw a checkbox for selecting or deselecting the specified annotation.
  * @param {d3.selection} parentElement - The parent element for this checkbox.
  * @param {annotation} annotation - The annotation to control.
  */
  drawCheckBox(parentElement, annotation) {
    var annotationCheckboxDiv = parentElement.append("div")
      .attr("class", "annotationCheckboxDiv");
    if (annotation.active) {
      annotationCheckboxDiv.append("input")
        .attr("type", "checkbox")
        .property("checked", () => {
          if (annotation.selected) {
            return true;
          }
          else {
            return false;
          }
        })
        .on("change", (event) => {
          annotation.select(event.target.checked);
          if (!event.target.checked) {
            this.drawAnnotationList();
          }
        });
    }
    else {
      annotationCheckboxDiv.append("input")
        .attr("type", "checkbox")
        .style("visibility", "hidden");
    }
  }

  /**
  * Draw a select list for setting the strength of a boundary.
  * @param {d3.selection} parentElement - The parent element for this checkbox.
  * @param {Boundary} annotation - The boundary whose strength should be set.
  */
  drawStrengthSelect(parentElement, annotation) {
    if (annotation.active) {
      parentElement.append("div")
        .attr("class", "boundaryStrengthDiv")
        .style("padding-left", "1%")
        .style("padding-right", "1%")
        .append("select")
          .attr("class", "boundaryStrengthSelect")
          .on("change", (event, d) => {
            annotation.setStrength(parseInt(event.target.value));
            this.draw();
            if (this.onUpdate !== undefined) {
              this.onUpdate(annotation);
            }
          })
          .selectAll("option")
          .data(this.boundaryStrengths)
          .enter()
          .append("option")
            .attr("value", (d) => d)
            .property("selected", (d) => {
              if (annotation.strength === d) {
                return true;
              }
              else {
                return false;
              }
            })
            .text((d) => d);
    }
    else {
      parentElement.append("div")
        .attr("class", "boundaryStrengthDiv")
        .append("select")
        .attr("class", "boundaryStrengthSelect")
          .append("option")
          .attr("value", annotation.strength)
          .attr("disabled", true)
          .text(annotation.strength);
    }
  }

  /**
  * Draw the time value of the specified annotation.
  * @param {d3.selection} parentElement - The parent element for this checkbox.
  * @param {annotation} annotation - The time of the annotation.
  */
  drawTime(parentElement, annotation) {
    parentElement.append("div")
      .attr("class", "annotationTimeDiv")
      .style("text-align", "center")
      .style("color", "#800000")
      .style("white-space", "nowrap")
      .style("padding-left", "1%")
      .style("padding-right", "1%")
      .text(() => {
        if ("time" in annotation) {
          let zeroPadding = "";
          let roundedTime = (annotation.time%60).toFixed(2);
          if (roundedTime < 10) {
            zeroPadding = "0"
          }
          return (Math.floor(annotation.time/60)).toString() + ":" + zeroPadding + roundedTime.toString();
        }
        else {
          let zeroPaddingStart = "";
          let zeroPaddingEnd = "";
          if (annotation.startTime%60 < 10) {
            zeroPaddingStart = "0"
          }
          if (annotation.endTime%60 < 10) {
            zeroPaddingEnd = "0"
          }
          return (Math.floor(annotation.startTime/60)).toString() + ":" + zeroPaddingStart + (annotation.startTime%60).toFixed(2) + " - "
              + (Math.floor(annotation.endTime/60)).toString() + ":" + zeroPaddingEnd + (annotation.endTime%60).toFixed(2);
        }
      });
  }

  /**
  * Draw an input text box for setting the label of the specified annotation.
  * @param {d3.selection} parentElement - The parent element for this checkbox.
  * @param {number} size - The size of the input in characters.
  * @param {annotation} annotation - The time of the annotation.
  */
  drawLabelInput(parentElement, size, annotation) {
    var labelInputDiv = parentElement.append("div")
      .attr("class", "labelInputDiv")
      .style("padding-left","1%")
      .style("overflow-wrap", "break-word")
      .append("input")
        .attr("type", "text")
        .attr("size", size)
        .attr("value", annotation.label)
        .style("font-size", "medium")
        .on("input", (event) => {
          annotation.setLabel(event.target.value);
          if (this.onUpdateLabel !== undefined) {
            this.onUpdateLabel();
          }
        })
        .on("keydown", (event) => {
          if (event.key === "1" ||
              event.key === "2" ||
              event.key === "3" ||
              event.key === "4" ||
              event.key === "Backspace" ||
              event.key === "Delete" ||
              event.key === "A" ||
              event.key === "S") {
            event.stopPropagation();
          }
        });
  }

  /**
  * Activate the save annotation button.
  */
  activateSaveButton() {
    if (this.annotationSaveButton !== undefined) {
      this.saveButtonActivated = true;
      this.annotationSaveButton.property("disabled", false);
    }
  }

  /**
  * De-activate the save annotation button.
  */
  deactivateSaveButton() {
    if (this.annotationSaveButton !== undefined) {
      this.saveButtonActivated = false;
      this.annotationSaveButton.property("disabled", true);
    }
  }

  /**
  * Is the specified annotation type allowed for this pane? If an annotation type is not allowed, then no annotations of that type
  * can be created or displayed.
  * @param {AnnotationType} type - The type of annotation.
  */
  isTypeAllowed(type) {
    if (type === AnnotationType.BOUNDARY) {
      return this.allowBoundaries;
    }
    else if (type === AnnotationType.COMMENT) {
      return this.allowComments;
    }
    else if (type === AnnotationType.MARKER) {
      return this.allowMarkers;
    }
    else if (type === AnnotationType.NOTE_GROUP) {
      return this.allowNoteGroups;
    }
    else if (type === AnnotationType.REGION) {
      return this.allowRegions;
    }
  }

  /**
  * Initialize the panel, attaching it to the specified parent element.
  * @param {element} panelElement - The panel's parent element.
  */
  initializePanel(panelElement) {
    this.panelElement = panelElement instanceof d3.selection ? panelElement : d3.select(panelElement);

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

    this.panelInitialized = true;
  }

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

      if (this.annotationPanelDiv !== undefined) {
        this.annotationPanelDiv.selectAll("*").remove();
      }

      if (this.showPanelTitle) {
        this.annotationPanelDiv.append("div")
          .attr("class", "annotationTitleDiv")
          .style("font-weight", "bold")
          .style("font-size", "x-large")
          .style("margin-bottom", "1ex")
          .text("Annnotations");
      }

      this.annotationTypeDiv = this.annotationPanelDiv.append("div")
        .attr("class", "annotationTypeDiv")
        .style("display", "flex")
        .style("flex-direction", "column")
        .style("margin-bottom", "2ex")
        .style("padding", "2ch")
        .style("border", "1px solid black");

      for (let i = 0; i < this.typeValues.length; i++) {
        if (this.isTypeAllowed(this.typeValues[i])) {
          let annotationTypeControlDiv = this.annotationTypeDiv.append("div")
            .attr("class", "annotationTypeControlDiv")
            .style("display", "flex")
            .style("flex-direction", "row")
            .style("align-items", "center");

          annotationTypeControlDiv.append("div")
            .attr("class", "annotationTypeRadio")
            .append("input")
              .attr("id", "radio" + this.typeValues[i])
              .attr("type", "radio")
              .attr("name", "annotationMode")
              .attr("value", this.typeValues[i])
              .property("checked", () => {
                if (this.typeMode === this.typeValues[i]) {
                  return true;
                }
                else {
                  return false;
                }
              })
              .on("change", (event) => {
                this.setTypeMode(this.typeValues[i]);
                this.erasePanel();
                this.drawPanel();
              });

          annotationTypeControlDiv.append("div")
            .attr("class", "annotationTypeCheckbox")
            .append("input")
              .attr("type", "checkbox")
              .attr("id", "checkbox" + this.typeValues[i])
              .property("checked", () => {
                switch(this.typeValues[i]) {
                  case AnnotationType.BOUNDARY:
                    return this.showBoundaries;
                    break;
                  case AnnotationType.COMMENT:
                    return this.showComments;
                    break;
                  case AnnotationType.MARKER:
                    return this.showMarkers;
                    break;
                  case AnnotationType.NOTE_GROUP:
                    return this.showNoteGroups;
                    break;
                  case AnnotationType.REGION:
                    return this.showRegions;
                    break;
                }
              })
              .on("change", (event) => {
                this.showAnnotationType(this.typeValues[i], event.target.checked);
              });

          annotationTypeControlDiv.append("label")
            .attr("class", "annotationTypeLabel")
            .attr("for", "radio" + this.typeValues[i])
            .style("vertical-align", "baseline")
            .style("font-weight", () => {
              if (this.typeMode === this.typeValues[i]) {
                return "bold";
              }
              else {
                return "normal";
              }
            })
            .text(this.getAnnotationTypeName(this.typeValues[i]));
        }
      }

      if (this.allowSaving) {
        let annotationSaveDiv = this.annotationTypeDiv.append("div")
          .attr("class", "annotationSaveDiv")
          .style("display", "flex")
          .style("flex-direction", "row")
          .style("justify-content", "flex-start")
          .style("margin-top", "1ex");

        let annotationSaveButtonDiv = annotationSaveDiv.append("div")
          .attr("class", "annotationSaveButtonDiv")
          .style("width", "fit-content");

        this.annotationSaveButton = annotationSaveButtonDiv.append("button")
          .attr("type", "button")
          .attr("id", "annotationSaveButton")
          .style("font-weight", "bold")
          .property("disabled", !this.saveButtonActivated)
          .text("Save Annotations")
          .on("click", () => {
            this.deactivateSaveButton();
            if (this.onSave !== undefined) {
              this.onSave();
            }
          });
      }

      this.drawAnnotationList();
    }
  }

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

  /**
  * Initialize this annotation pane, attaching it to the specified parent element and setting its size and time scale.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} width - The width.
  * @param {number} height - The 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;

    // Drag events for creating regions or note groups
    this.dragHandler = d3.drag()
      .on("start", (event) => {
        if (this.allowRegions && this.typeMode === AnnotationType.REGION && this.showRegions) {
          let mouseX = d3.pointer(event)[0];
          this.regionSelectRect = this.annotationsGroup.append("rect")
            .attr("class", "regionSelectRect")
            .attr("x", mouseX)
            .attr("y", 0)
            .attr("width", 0)
            .attr("height", this.height)
            .style("fill", this.annotationColor)
            .style("fill-opacity", 0.2)
            .style("stroke", this.annotationColor)
            .style("stroke-width", 1);
        }
        else if (this.allowNoteGroups && this.typeMode === AnnotationType.NOTE_GROUP && this.showNoteGroups) {
          this.groupSelectRect = this.annotationsGroup.append("rect")
            .attr("class", "groupSelectRect")
            .attr("x", event.x)
            .attr("y", event.y)
            .attr("width", 0)
            .attr("height", 0)
            .style("stroke", "#800000")
            .style("stroke-width", "1")
            .style("fill-opacity", "0");
        }
      })
      .on("drag", (event) => {
        let mouseX = d3.pointer(event)[0];
        if (this.allowRegions && this.typeMode === AnnotationType.REGION && this.showRegions) {
          let x = parseFloat(this.regionSelectRect.attr("x"));
          let w = parseFloat(this.regionSelectRect.attr("width"));
          if (event.dx !== 0 && (w+event.dx) > 0 && (x+w+event.dx < this.width)) {
            this.regionSelectRect.attr("width", w+event.dx);
          }
        }
        else if (this.allowNoteGroups && this.typeMode === AnnotationType.NOTE_GROUP && this.showNoteGroups) {
          let w = parseFloat(this.groupSelectRect.attr("width"));
          let h = parseFloat(this.groupSelectRect.attr("height"));
          if ((w+event.dx >= 0) && (h+event.dy >= 0)) {
            this.groupSelectRect
              .attr("width", w+event.dx)
              .attr("height", h+event.dy);
          }
        }
      })
      .on("end", (event) => {
        if (this.allowRegions && this.typeMode === AnnotationType.REGION && this.showRegions) {
          let w = parseFloat(this.regionSelectRect.attr("width"));
          if (w > 0) {
            if (w < 12) {
              w = 12;
            }
            let x = parseFloat(this.regionSelectRect.attr("x"));
            let t1 = this.timeScale.invert(x);
            let t2 = this.timeScale.invert(x+w);
            this.regionSelectRect.remove();
            this.createRegion(t1, t2, "");
            this.draw();
          }
        }
        else if (this.allowNoteGroups && this.typeMode === AnnotationType.NOTE_GROUP && this.showNoteGroups) {
          let createGroup = true;
          let deselectGroup = false;
          if (event.sourceEvent.metaKey || event.sourceEvent.ctrlKey) {
            createGroup = false;
          }
          if (event.sourceEvent.shiftKey) {
            deselectGroup = true;
            createGroup = false;
          }
          let x = parseFloat(this.groupSelectRect.attr("x"));
          let t1 = this.timeScale.invert(x);
          let t2 = this.timeScale.invert(x+parseFloat(this.groupSelectRect.attr("width")));
          let y1 = parseFloat(this.groupSelectRect.attr("y")) + parseFloat(this.groupSelectRect.attr("height"));
          let y2 = parseFloat(this.groupSelectRect.attr("y"));
          this.selectNoteRange(t1, t2, y1, y2, createGroup, deselectGroup);
          this.groupSelectRect.remove();
        }
        if (this.panelOn) {
          if (this.allowSaving) {
            this.activateSaveButton();
          }
          this.drawAnnotationList();
        }
      });

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

    this.annotationPaneGroup.append("rect")
      .attr("class", "annotationPaneEventRect")
      .attr("x", 0)
      .attr("y", 0)
      .attr("width", this.width)
      .attr("height", this.height)
      .style("fill", "none")
      .style("pointer-events", "visible")
      .style("cursor", "default");

    this.annotationPaneGroup
      .on("click", (event) => {
        let pointerX = d3.pointer(event)[0];

        switch (this.typeMode) {
          case AnnotationType.BOUNDARY:
            if (this.allowBoundaries && this.showBoundaries) {
              this.createBoundary(this.timeScale.invert(pointerX), 4, "");
              this.draw();
              if (this.panelOn) {
                if (this.allowSaving) {
                  this.activateSaveButton();
                }
                this.drawAnnotationList();
              }
            }
            break;
          case AnnotationType.COMMENT:
            if (this.allowComments && this.showComments) {
              this.createComment(this.timeScale.invert(pointerX), "");
              this.draw();
              if (this.panelOn) {
                if (this.allowSaving) {
                  this.activateSaveButton();
                }
                this.drawAnnotationList();
              }
            }
            break;
          case AnnotationType.MARKER:
            if (this.allowMarkers && this.showMarkers) {
              this.createMarker(this.timeScale.invert(pointerX), "");
              this.draw();
              if (this.panelOn) {
                if (this.allowSaving) {
                  this.activateSaveButton();
                }
                this.drawAnnotationList();
              }
            }
            break;
        }
      })
      .on("selectAnnotation", (event) => {
        if (event.detail.soleSelection) {
          this.#deselectOthers(event.detail.annotation);
        }
        if (this.panelOn) {
          this.drawAnnotationList();
        }
        if (this.onSelect !== undefined) {
          this.onSelect(event.detail.annotation);
        }
      })
      .on("deselectAnnotation", (event) => {
        if (this.panelOn) {
          this.drawAnnotationList();
        }
      })
      .on("updateAnnotation", (event) => {
        if (this.panelOn) {
          if (this.allowSaving) {
            this.activateSaveButton();
          }
          this.drawAnnotationList();
        }
        if (this.onUpdate !== undefined) {
          this.onUpdate(event.detail.annotation);
        }
      })
      .on("deselectNote", (event) => {
        if (this.onUpdate !== undefined) {
          this.onUpdate(event.detail.annotation);
        }
      })
      .call(this.dragHandler);

    this.initialized = true;
  }

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

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

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

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

  /**
  * Raise annotations of the specified type.
  * @param {AnnotationType} type - The type of annotation.
  */
  raiseAnnotations(type) {
    switch (type) {
      case AnnotationType.BOUNDARY:
        if (this.boundaryAnnotationsGroup !== undefined) {
          this.boundaryAnnotationsGroup.raise();
        }
        break;
      case AnnotationType.COMMENT:
        if (this.commentAnnotationsGroup !== undefined) {
          this.commentAnnotationsGroup.raise();
        }
        break;
      case AnnotationType.MARKER:
        if (this.markerAnnotationsGroup !== undefined) {
          this.markerAnnotationsGroup.raise();
        }
        break;
      case AnnotationType.NOTE_GROUP:
        if (this.noteGroupAnnotationsGroup !== undefined) {
          this.noteGroupAnnotationsGroup.raise();
        }
        break;
      case AnnotationType.REGION:
        if (this.regionAnnotationsGroup !== undefined) {
          this.regionAnnotationsGroup.raise();
        }
        break;
    }
  }

  /**
  * Activate keyboard controls for deleting annotations via the "Backspace" or "Delete" keys.
  */
  activateKeyboardInput() {
    d3.select("body").on("keydown.deleteAnnotations", (event) => {
      if (event.key === "Backspace" || event.key === "Delete") {
        event.preventDefault();
        this.deleteSelectedAnnotations();
      }
    });
  }

  /**
  * Deactivate keyboard controls.
  */
  deactivateKeyboardInput() {
    d3.select("body").on("keydown.deleteAnnotations", null);
  }

  /**
  * Draw this annotation pane.
  */
  draw() {
    if (this.initialized) {
      this.eraseAll();

      this.annotationsGroup = this.annotationPaneGroup.append("g")
        .attr("class", "annotationGroup")
        .on("mouseenter", () => {
          this.annotationsGroup.style("cursor", "pointer");
        });

      // Annotations (and note selectors) should be drawn in this order
      this.drawNoteSelectors();
      this.drawNoteGroups();
      this.drawRegions();
      this.drawComments();
      this.drawMarkers();
      this.drawBoundaries();

      this.activateKeyboardInput();
    }
  }

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

  /**
  * Erase all annotations from this pane.
  */
  eraseAll() {
    if (this.annotationsGroup !== undefined) {
      this.annotationsGroup.selectAll(".annotation").remove();
    }
  }

  /**
  * Get an array of all annotations of the specified type or an empty array if no annotations of that type exist.
  * @param {AnnotationType} type - The type of annotation.
  * @return {Array} An array of annotations or an empty array.
  */
  getAnnotationsByType(type) {
    var typeArray = [];
    switch (type) {
      case AnnotationType.BOUNDARY:
        typeArray = this.boundaryArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED);
        break;
      case AnnotationType.COMMENT:
        typeArray = this.commentArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED);
        break;
      case AnnotationType.MARKER:
        typeArray = this.markerArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED);
        break;
      case AnnotationType.NOTE_GROUP:
        typeArray = this.noteGroupArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED);
        break;
      case AnnotationType.REGION:
        typeArray = this.regionArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED);
        break;
    }
    return typeArray;
  }

  /**
  * Get an array of selected annotations of the specified type or an empty array if no annotations of that type are selected.
  * @param {AnnotationType} type - The type of annotation.
  * @return {Array} An array of selected annotations or an empty array.
  */
  getSelectedAnnotationsByType(type) {
    var selectedArray = [];
    switch (type) {
      case AnnotationType.BOUNDARY:
        selectedArray = this.boundaryArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED && annotation.selected === true);
        break;
      case AnnotationType.COMMENT:
        selectedArray = this.commentArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED && annotation.selected === true);
        break;
      case AnnotationType.MARKER:
        selectedArray = this.markerArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED && annotation.selected === true);
        break;
      case AnnotationType.NOTE_GROUP:
        selectedArray = this.noteGroupArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED && annotation.selected === true);
        break;
      case AnnotationType.REGION:
        selectedArray = this.regionArray.filter(annotation => annotation.status !== AnnotationStatus.DELETED && annotation.selected === true);
        break;
    }
    return selectedArray;
  }

  /**
  * Get annotations that have been changed such that their AnnotationStatus value is not UNCHANGED. Returns an empty array if no
  * annotations have been changed.
  * @param {Array} annotationArray - An array of annotations.
  * @return {Array} An array of changed annotations or an empty array.
  */
  #getChangedAnnotationsByType(annotationArray) {
    let changedAnnotations = [];
    for (let i = 0; i < annotationArray.length; i++) {
      if (annotationArray[i].status !== AnnotationStatus.UNCHANGED) {
        changedAnnotations.push(annotationArray[i]);
      }
    }
    return changedAnnotations;
  }

  /**
  * Get annotations of all types that have been changed such that their AnnotationStatus value is not UNCHANGED. Returns an empty array if no
  * annotations have been changed.
  * @return {Array} An array of changed annotations or an empty array.
  */
  getChangedAnnotations() {
    return [...this.#getChangedAnnotationsByType(this.boundaryArray),
            ...this.#getChangedAnnotationsByType(this.commentArray),
            ...this.#getChangedAnnotationsByType(this.markerArray),
            ...this.#getChangedAnnotationsByType(this.noteGroupArray),
            ...this.#getChangedAnnotationsByType(this.regionArray)];
  }

  /**
  * Get all annotations of all types or return an empty array if no annotations exist.
  * @return {Array} An array of annotations or an empty array.
  */
  getAllAnnotations() {
    return [...this.boundaryArray,
            ...this.commentArray,
            ...this.markerArray,
            ...this.noteGroupArray,
            ...this.regionArray];
  }

  /**
  * Get a count of all annotations in this pane.
  * @return {number} The number of annotations.
  */
  getAnnotationCount() {
    return this.boundaryArray.length + this.commentArray.length + this.markerArray.length + this.noteGroupArray.length + this.regionArray.length;
  }

  /**
  * Delete all selected annotations from the specified annotation array.
  * @param {Array} annotationArray - An array of annotations.
  */
  #deleteSelected(annotationArray) {
    if (annotationArray.length > 0) {
      // Iterate backwards to allow for splice while in loop
      for (let i = annotationArray.length-1; i >= 0; --i) {
        if (annotationArray[i].selected) {
          annotationArray[i].erase();
          if (this.onDelete !== undefined) {
            this.onDelete(annotationArray[i]);
          }
          if (annotationArray[i].status === AnnotationStatus.CREATED) {
            annotationArray.splice(i, 1);
          }
          else {
            annotationArray[i].status = AnnotationStatus.DELETED;
          }
        }
      }
    }
  }

  /**
  * Delete selected annotations of any type.
  */
  deleteSelectedAnnotations() {
    this.#deleteSelected(this.boundaryArray);
    this.#deleteSelected(this.commentArray);
    this.#deleteSelected(this.markerArray);
    this.#deleteSelected(this.noteGroupArray);
    this.#deleteSelected(this.regionArray);

    if (this.panelOn) {
      if (this.allowSaving) {
        this.activateSaveButton();
      }
      this.drawAnnotationList();
    }
  }

  /**
  * Delete all annotations from the specified annotation array.
  * @param {Array} annotationArray - An array of annotations.
  */
  #deleteAll(annotationArray) {
    if (annotationArray.length > 0) {
      // Iterate backwards to allow for splice while in loop
      for (let i = annotationArray.length-1; i >= 0; --i) {
        if (this.onDelete !== undefined) {
          this.onDelete(annotationArray[i]);
        }
        if (annotationArray[i].status === AnnotationStatus.CREATED) {
          annotationArray.splice(i, 1);
        }
        else {
          annotationArray[i].status = AnnotationStatus.DELETED;
        }
      }
    }
  }

  /**
  * Delete all annotations from this pane.
  */
  deleteAllAnnotations() {
    this.#deleteAll(this.boundaryArray);
    this.#deleteAll(this.commentArray);
    this.#deleteAll(this.markerArray);
    this.#deleteAll(this.noteGroupArray);
    this.#deleteAll(this.regionArray);

    if (this.panelOn) {
      if (this.allowSaving) {
        this.activateSaveButton();
      }
      this.drawAnnotationList();
    }
  }
}

/** AnnotationStatus contains static fields for the status of annotations. */
class AnnotationStatus {
  static UNCHANGED = 0;
  static CREATED = 1;
  static UPDATED = 2;
  static DELETED = 3;
}

/** AnnotationType contains static fields representing annotation types. */
class AnnotationType {
  static NONE = -1;
  static BOUNDARY = 0;
  static COMMENT = 1;
  static MARKER = 2;
  static NOTE_GROUP = 3;
  static REGION = 4;
}

/** A boundary annotation. */
class Boundary {
  // Sides
  static LEFT = 0;
  static RIGHT = 1;

  /**
  * Create a boundary annotation.
  * @param {number} time - The time.
  * @param {number} strength - The strength.
  * @param {string} label - A label.
  * @param {Object} options - Configuration options.
  * @param {Object} properties - Properties.
  * @param {boolean} active - Whether this boundary should be active when created.
  * @param {boolean} firstCreated - Whether this boundary has just been created.
  * @author Lawrence Fyfe
  */
  constructor(time, strength, label, options, properties, active, firstCreated) {
    this.type = "boundary";
    this.status = AnnotationStatus.UNCHANGED;
    this.selected = false;
    this.firstCreated = firstCreated;
    this.time = time;
    this.strength = strength;
    this.label = label;
    this.x;
    this.dx = 0;
    this.color = options?.color ?? "#800000";
    this.selectedColor = options?.selectedColor ?? "#bb0000";
    this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
    this.properties = properties;
    this.active = active;
    this.show = true;
    this.dateCreated;
    this.dateUpdated;
    this.boundaryGroup;
    this.line;
    this.clickLine;
    this.triangleSide = 30;
    this.triangleOffset = 4;
    this.leftTriangleX;
    this.leftTriangleTip;
    this.rightTriangleX;
    this.rightTriangleTip;

    if (this.firstCreated) {
      this.status = AnnotationStatus.CREATED;
      this.dateCreated = new Date().toISOString();
    }
  }

  /**
  * Draw this boundary with the specified parameters from the containing pane.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  draw(parentElement, parentWidth, parentHeight, timeScale) {
    if (this.show) {
      this.eraseLabel();
      this.x = timeScale(this.time);
      this.boundaryGroup = parentElement.append("g")
        .attr("class", "annotation boundaryGroup");
      this.line = this.boundaryGroup.append("line")
        .attr("class", "boundaryLine")
        .attr("x1", this.x)
        .attr("y1", 0)
        .attr("x2", this.x)
        .attr("y2", parentHeight)
        .style("stroke", () => {
          if (this.selected) {
            return this.selectedColor;
          }
          else {
            return this.color;
          }
        })
        .style("stroke-width", this.getStrengthWidth())
        .style("opacity", this.getStrengthOpacity());
      this.clickLine = this.boundaryGroup.append("line")
        .attr("class", "boundaryClickLine")
        .attr("x1", this.x)
        .attr("y1", 0)
        .attr("x2", this.x)
        .attr("y2", parentHeight)
        .style("stroke", "#00000000")
        .style("stroke-width", this.getStrengthClickWidth())
        .call(d3.drag()
          .on("drag", (event) => {
            if (this.active && event.dx !== 0) {
              this.dx += event.dx;
              this.move(parentWidth, parentHeight, event.dx);
              this.drawLabel(this.boundaryGroup, parentWidth, parentHeight, this.x);
            }
          })
          .on("end", (event) => {
            if (this.active && this.dx !== 0) {
              this.dx = 0;
              this.time = timeScale.invert(this.x);
              if (!this.firstCreated) {
                this.status = AnnotationStatus.UPDATED;
                this.dateUpdated = new Date().toISOString();
              }
              event.sourceEvent.target.dispatchEvent(new CustomEvent("updateAnnotation", {bubbles: true, detail: {annotation: this}}));
            }
          }))
        .on("mouseenter", () => {
          this.boundaryGroup.raise();
          this.drawLabel(this.boundaryGroup, parentWidth, parentHeight, this.x);
        })
        .on("mouseleave", (event) => {
          this.eraseLabel();
        })
        .on("click", (event) => {
          if (this.active) {
            if (!this.selected) {
              let soleSelection = true;
              this.select(true);
              if (event.metaKey || event.ctrlKey) {
                soleSelection = false;
              }
              event.target.dispatchEvent(new CustomEvent("selectAnnotation", {bubbles: true, detail: {annotation: this, soleSelection: soleSelection}}));
            }
            else {
              this.select(false);
              event.target.dispatchEvent(new CustomEvent("deselectAnnotation", {bubbles: true}));
            }
          }
          event.stopPropagation();
        });
      this.drawHandle(parentElement, parentWidth, parentHeight, timeScale, Boundary.LEFT);
      this.drawHandle(parentElement, parentWidth, parentHeight, timeScale, Boundary.RIGHT);
      if (this.selected) {
        this.showHandles();
      }
    }
  }

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

  /**
  * Draw the triangular handles for this boundary with the specified parameters.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  * @param {number} direction - The RIGHT or the LEFT handle.
  */
  drawHandle(parentElement, parentWidth, parentHeight, timeScale, direction) {
    var handle = this.boundaryGroup.append("polygon")
      .attr("class", () => {
        if (direction === Boundary.LEFT) {
          return "boundaryHandle leftHandle";
        }
        else if (direction === Boundary.RIGHT) {
          return "boundaryHandle rightHandle";
        }
      })
      .attr("points", () => {
        if (direction === Boundary.LEFT) {
          return this.getLeftHandlePoints();
        }
        else if (direction === Boundary.RIGHT) {
          return this.getRightHandlePoints();
        }
      })
      .style("visibility", "hidden")
      .style("fill", this.selectedColor)
      .style("opacity", this.getStrengthOpacity())
      .on("click", (event) => {
        if (this.active && this.selected) {
          this.select(false);
          event.target.dispatchEvent(new CustomEvent("deselectAnnotation", {bubbles: true}));
        }
        event.stopPropagation();
      })
      .call(d3.drag()
        .on("drag", (event) => {
          if (this.active && event.dx !== 0) {
            this.dx += event.dx;
            this.move(parentWidth, parentHeight, event.dx);
            this.drawLabel(this.boundaryGroup, parentWidth, parentHeight, this.x);
          }
        })
        .on("end", (event) => {
          if (this.active && this.dx !== 0) {
            this.dx = 0;
            this.time = timeScale.invert(this.x);
            if (!this.firstCreated) {
              this.status = AnnotationStatus.UPDATED;
              this.dateUpdated = new Date().toISOString();
            }
            event.sourceEvent.target.dispatchEvent(new CustomEvent("updateAnnotation", {bubbles: true, detail: {annotation: this}}));
          }
        }));
  }

  /**
  * Draw the triangular handles for this boundary with the specified parameters.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  * @param {number} x - The x location of this boundary.
  */
  drawLabel(parentElement, parentWidth, parentHeight, x) {
    this.eraseLabel();

    var textAnchor;

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

    var labelGroup = parentElement.append("g")
      .attr("class", "boundaryLabel");

    var labelText = labelGroup.append("text")
      .attr("x", x)
      .attr("y", parentHeight-16)
      .style("color", "black")
      .style("text-anchor", textAnchor)
      .style("dominant-baseline", "middle")
      .text(this.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");

    labelText.raise();
  }

  /**
  * Erase the label.
  */
  eraseLabel() {
    if (this.boundaryGroup !== undefined) {
      this.boundaryGroup.selectAll(".boundaryLabel").remove();
    }
  }

  /**
  * Get the points for creating the left triangular handle.
  * @return {points} Three points.
  */
  getLeftHandlePoints() {
    this.leftTriangleX = this.x-this.triangleOffset-(parseFloat(this.getStrengthWidth())/2);
    this.leftTriangleTip = this.leftTriangleX-this.triangleSide;
    return this.leftTriangleX + ",0 " + this.leftTriangleTip + "," + this.triangleSide/2 + " " + this.leftTriangleX + "," + this.triangleSide;
  }

  /**
  * Get the points for creating the right triangular handle.
  * @return {points} Three points.
  */
  getRightHandlePoints() {
    this.rightTriangleX = this.x+this.triangleOffset+(parseFloat(this.getStrengthWidth())/2);
    this.rightTriangleTip = this.rightTriangleX+this.triangleSide;
    return this.rightTriangleX + ",0 " + this.rightTriangleTip + "," + this.triangleSide/2 + " " + this.rightTriangleX + "," + this.triangleSide;
  }

  /**
  * Select or deselect this boundary.
  * @param {boolean} select - If true, select; otherwise deselect.
  */
  select(select) {
    this.selected = select;
    if (this.selected) {
      this.line
        .style("stroke", this.selectedColor)
        .style("stroke-width", this.getStrengthWidth());
      this.showHandles();
    }
    else {
      this.line
        .style("stroke", this.color)
        .style("stroke-width", this.getStrengthWidth());
      this.hideHandles();
    }
  }

  /**
  * Show handles.
  */
  showHandles() {
    this.boundaryGroup.selectAll(".boundaryHandle").style("visibility", "visible");
  }

  /**
  * Hide handles.
  */
  hideHandles() {
    this.boundaryGroup.selectAll(".boundaryHandle").style("visibility", "hidden");
  }

  /**
  * Move this boundary to the specified x location.
  * @param {number} parentWidth - The parent width.
  * @param {number} parentHeight - The parent height.
  * @param {number} moveX - The x location.
  */
  move(parentWidth, parentHeight, moveX) {
    let newX = this.x+moveX;
    if (newX < 0) {
      this.x = 0;
    }
    else if (newX > parentWidth) {
      this.x = parentWidth;
    }
    else {
      this.x = newX;
    }

    this.line
      .attr("x1", this.x)
      .attr("x2", this.x);
    this.clickLine
      .attr("x1", this.x)
      .attr("x2", this.x);

    this.boundaryGroup.selectAll(".leftHandle").attr("points", this.getLeftHandlePoints());
    this.boundaryGroup.selectAll(".rightHandle").attr("points", this.getRightHandlePoints());
  }

  /**
  * Set the strength.
  * @param {number} strength - The strength value.
  */
  setStrength(strength) {
    this.strength = strength;
    if (!this.firstCreated) {
      this.status = AnnotationStatus.UPDATED;
      this.dateUpdated = new Date().toISOString();
    }
  }

  /**
  * Set the label string.
  * @param {string} label - The label.
  */
  setLabel(label) {
    this.label = label;
    if (!this.firstCreated) {
      this.status = AnnotationStatus.UPDATED;
      this.dateUpdated = new Date().toISOString();
    }
  }

  /**
  * Get the line width based on the strength value.
  * @return {string} The width as a string.
  */
  getStrengthWidth() {
    if (this.strength === 1) {
      return "2";
    }
    else if (this.strength === 2) {
      return "3";
    }
    else if (this.strength === 3) {
      return "4";
    }
    else if (this.strength === 4) {
      return "5";
    }
  }

  /**
  * Get the click width based on the strength value. The click width is always larger than the line width.
  * @return {string} The width as a string.
  */
  getStrengthClickWidth() {
    if (this.strength === 1) {
      return "13";
    }
    else if (this.strength === 2) {
      return "14";
    }
    else if (this.strength === 3) {
      return "15";
    }
    else if (this.strength === 4) {
      return "16";
    }
  }

  /**
  * Get the line opacity based on the strength value.
  * @return {string} The opacity as a string.
  */
  getStrengthOpacity() {
    if (this.strength === 1) {
      return "0.3";
    }
    else if (this.strength === 2) {
      return "0.5";
    }
    else if (this.strength === 3) {
      return "0.7";
    }
    else if (this.strength === 4) {
      return "1.0";
    }
  }

  /**
  * Get the time.
  * @return {number} The time.
  */
  getTime() {
    return this.time;
  }
}

/** A comment annotation. */
class Comment {
  /**
  * Create a comment annotation.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} options - Configuration options.
  * @param {Object} properties - Properties.
  * @param {boolean} active - Whether this comment should be active when created.
  * @param {boolean} firstCreated - Whether this comment has just been created.
  * @author Lawrence Fyfe
  */
  constructor(time, label, options, properties, active, firstCreated) {
    this.type = "comment";
    this.status = AnnotationStatus.UNCHANGED;
    this.selected = false;
    this.deselected = false;
    this.firstCreated = firstCreated;
    this.time = time;
    this.label = label;
    this.x;
    this.dx = 0;
    this.color = options?.color ?? "#800000";
    this.selectedColor = options?.selectedColor ?? "#bb0000";
    this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
    this.properties = properties;
    this.active = active;
    this.show = true;
    this.dateCreated;
    this.dateUpdated;
    this.commentGroup;
    this.commentLine;
    this.commentClickLine;
    this.labelGroup;

    if (this.firstCreated) {
      this.status = AnnotationStatus.CREATED;
      this.dateCreated = new Date().toISOString();
    }
  }

  /**
  * Draw this comment with the specified parameters from the containing pane.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  draw(parentElement, parentWidth, parentHeight, timeScale) {
    if (this.show) {
      this.eraseLabel();
      this.x = timeScale(this.time);
      this.commentGroup = parentElement.append("g")
        .attr("class", "annotation commentGroup");
      this.commentLine = this.commentGroup.append("line")
        .attr("class", "commentLine")
        .attr("x1", this.x)
        .attr("y1", 0)
        .attr("x2", this.x)
        .attr("y2", parentHeight)
        .style("stroke", () => {
          if (this.selected) {
            return this.selectedColor;
          }
          else {
            return this.color;
          }
        })
        .style("stroke-width", () => {
          if (this.selected) {
            return 8;
          }
          else {
            return 4;
          }
        })
        .style("stroke-linecap", "round")
        .style("stroke-dasharray", "1 12");
      this.commentClickLine = this.commentGroup.append("line")
        .attr("class", "commentClickLine")
        .attr("x1", this.x)
        .attr("y1", 0)
        .attr("x2", this.x)
        .attr("y2", parentHeight)
        .style("stroke", "#000000")
        .style("stroke-opacity", "0")
        .style("stroke-width", "15")
        .call(d3.drag()
          .on("drag", (event) => {
            if (this.active && event.dx !== 0) {
              this.dx += event.dx;
              let newX = event.x;
              if (newX < 0) {
                newX = 0;
              }
              if (newX > parentWidth) {
                newX = parentWidth;
              }
              this.x = newX;
              this.commentClickLine
                .attr("x1", this.x)
                .attr("x2", this.x)
                .style("cursor", "move");
              this.commentLine
                .attr("x1", this.x)
                .attr("x2", this.x);
              this.drawLabel(this.commentGroup, parentWidth, parentHeight, this.x);
            }
          })
          .on("end", (event) => {
            if (this.active && this.dx !== 0) {
              this.dx = 0;
              this.time = timeScale.invert(this.x);
              if (!this.firstCreated) {
                this.status = AnnotationStatus.UPDATED;
                this.dateUpdated = new Date().toISOString();
              }
              event.sourceEvent.target.dispatchEvent(new CustomEvent("updateAnnotation", {bubbles: true, detail: {annotation: this}}));
              this.commentClickLine.style("cursor", "pointer");
            }
          }))
        // Mouse events
        .on("mouseenter", (event) => {
          this.commentGroup.raise();
          this.drawLabel(this.commentGroup, parentWidth, parentHeight, this.x);
        })
        .on("mouseleave", (event) => {
          this.eraseLabel();
        })
        .on("click", (event) => {
          if (this.active) {
            if (!this.selected) {
              let soleSelection = true;
              this.select(true);
              if (event.metaKey || event.ctrlKey) {
                soleSelection = false;
              }
              event.target.dispatchEvent(new CustomEvent("selectAnnotation", {bubbles: true, detail: {annotation: this, soleSelection: soleSelection}}));
            }
            else {
              this.select(false);
              event.target.dispatchEvent(new CustomEvent("deselectAnnotation", {bubbles: true}));
            }
          }
          event.stopPropagation();
        });
    }
  }

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

  /**
  * Set the label string.
  * @param {string} label - The label.
  */
  setLabel(label) {
    this.label = label;
    if (!this.firstCreated) {
      this.status = AnnotationStatus.UPDATED;
      this.dateUpdated = new Date().toISOString();
    }
  }

  /**
  * Draw the label.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {number} x - The x location.
  */
  drawLabel(parentElement, parentWidth, parentHeight, x) {
    this.eraseLabel();

    var textAnchor;

    if (x < parentWidth/2) {
      x = x + parseFloat(this.commentLine.style("stroke-width"));
      textAnchor = "start";
    }
    else {
      x = x - parseFloat(this.commentLine.style("stroke-width"));
      textAnchor = "end";
    }

    this.labelGroup = this.commentGroup.append("g")
      .attr("class", "labelGroup")
      .attr("transform", "translate(" + x + ", " + 1 + ")");

    var labelText = this.labelGroup.append("text")
        .attr("x", 0)
        .attr("y", parentHeight-16)
        .style("font-family", "sans-serif")
        .style("color", "black")
        .style("text-anchor", textAnchor)
        .style("dominant-baseline", "middle")
        .text(this.label);

    var textBox = labelText.node().getBBox();
    var labelRect = this.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");

    labelText.raise();
  }

  /**
  * Erase the label.
  */
  eraseLabel() {
    if (this.labelGroup !== undefined) {
      this.labelGroup.remove();
    }
  }

  /**
  * Select or deselect this comment.
  * @param {boolean} select - If true, select; otherwise deselect.
  */
  select(select) {
    this.selected = select;
    if (this.selected) {
      this.commentLine
        .style("stroke", this.selectedColor)
        .style("stroke-width", 8);
    }
    else {
      this.commentLine
        .style("stroke", this.color)
        .style("stroke-width", 4);
    }
  }

  /**
  * Get the time.
  * @return {number} The time.
  */
  getTime() {
    return this.time;
  }
}

/** A marker annotation. */
class Marker {
  /**
  * Create a marker annotation.
  * @param {number} time - The time.
  * @param {string} label - A label.
  * @param {Object} options - Configuration options.
  * @param {Object} properties - Properties.
  * @param {boolean} active - Whether this marker should be active when created.
  * @param {boolean} firstCreated - Whether this marker has just been created.
  * @author Lawrence Fyfe
  */
  constructor(time, label, options, properties, active, firstCreated) {
    this.type = "marker";
    this.status = AnnotationStatus.UNCHANGED;
    this.time = time;
    this.label = label;
    this.color = options?.color ?? "#800000";
    this.width = options?.width ?? 2;
    this.opacity = options?.opacity ?? "1.0";
    this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
    this.properties = properties;
    this.active = active;
    this.show = true;
    this.firstCreated = firstCreated;
    this.dateCreated;
    this.dateUpdated;
    this.selected = false;
    this.x;
    this.dx = 0;
    this.markerGroup;
    this.line;
    this.clickLine;

    if (this.firstCreated) {
      this.status = AnnotationStatus.CREATED;
      this.dateCreated = new Date().toISOString();
    }
  }

  /**
  * Draw this marker with the specified parameters from the containing pane.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  draw(parentElement, parentWidth, parentHeight, timeScale) {
    if (this.show) {
      this.eraseLabel();
      this.x = timeScale(this.time);
      this.markerGroup = parentElement.append("g")
        .attr("class", "annotation markerGroup");
      this.line = this.markerGroup.append("line")
        .attr("class", "markerLine")
        .attr("x1", this.x)
        .attr("y1", 0)
        .attr("x2", this.x)
        .attr("y2", parentHeight)
        .style("stroke", this.color)
        .style("stroke-width", () => {
          if (this.selected) {
            return 2*this.width;
          }
          else {
            return this.width;
          }
        })
        .style("opacity", this.opacity);
      this.clickLine = this.markerGroup.append("line")
        .attr("class", "markerClickLine")
        .attr("x1", this.x)
        .attr("y1", 0)
        .attr("x2", this.x)
        .attr("y2", parentHeight)
        .style("stroke", "#00000000")
        .style("stroke-width", 8*this.width)
        .call(d3.drag()
          .on("drag", (event) => {
            if (this.active && event.dx !== 0) {
              this.dx += event.dx;
              this.move(parentWidth, event.dx);
              this.drawLabel(this.markerGroup, parentWidth, parentHeight);
            }
          })
          .on("end", (event) => {
            if (this.active && this.dx !== 0) {
              this.dx = 0;
              this.time = timeScale.invert(this.x);
              if (!this.firstCreated) {
                this.status = AnnotationStatus.UPDATED;
                this.dateUpdated = new Date().toISOString();
              }
              event.sourceEvent.target.dispatchEvent(new CustomEvent("updateAnnotation", {bubbles: true, detail: {annotation: this}}));
            }
          }))
        .on("mouseenter", () => {
          if (this.active) {
            this.markerGroup.raise();
            if (!this.alwaysShowLabel) {
              this.drawLabel(this.markerGroup, parentWidth, parentHeight);
            }
          }
        })
        .on("mouseleave", (event) => {
          if (this.active) {
            if (!this.alwaysShowLabel) {
              this.eraseLabel();
            }
          }
        })
        .on("click", (event) => {
          if (this.active) {
            if (!this.selected) {
              let soleSelection = true;
              this.select(true);
              if (event.metaKey || event.ctrlKey) {
                soleSelection = false;
              }
              event.target.dispatchEvent(new CustomEvent("selectAnnotation", {bubbles: true, detail: {annotation: this, soleSelection: soleSelection}}));
            }
            else {
              this.select(false);
              event.target.dispatchEvent(new CustomEvent("deselectAnnotation", {bubbles: true}));
            }
          }
          event.stopPropagation();
        });

      if (this.alwaysShowLabel) {
        this.drawLabel(this.markerGroup, parentWidth, parentHeight);
      }
    }
  }

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

  /**
  * Draw the label.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  */
  drawLabel(parentElement, parentWidth, parentHeight) {
    this.eraseLabel();

    var textAnchor;

    var labelGroup = parentElement.append("g")
      .attr("class", "markerLabel");

    var labelText = labelGroup.append("text")
      .attr("x", this.x)
      .attr("y", "2ex")
      .style("font-family", "sans-serif")
      .style("color", "black")
      .style("text-anchor", "middle")
      .style("dominant-baseline", "middle")
      .text(this.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");

    labelText.raise();
  }

  /**
  * Erase the label.
  */
  eraseLabel() {
    if (this.markerGroup !== undefined) {
      this.markerGroup.selectAll(".markerLabel").remove();
    }
  }

  /**
  * Select or deselect this marker.
  * @param {boolean} select - If true, select; otherwise deselect.
  */
  select(select) {
    this.selected = select;
    if (this.line !== undefined) {
      if (this.selected) {
        this.line.style("stroke-width", 3*this.width);
      }
      else {
        this.line.style("stroke-width", this.width);
      }
    }
  }

  /**
  * Move this marker by the specified amount.
  * @param {number} parentWidth - The maximum value for the x location.
  * @param {number} moveX - The change in the x location.
  */
  move(parentWidth, moveX) {
    let newX = this.x+moveX;
    if (newX < 0) {
      this.x = 0;
    }
    else if (newX > parentWidth) {
      this.x = parentWidth;
    }
    else {
      this.x = newX;
    }

    this.line
      .attr("x1", this.x)
      .attr("x2", this.x);
    this.clickLine
      .attr("x1", this.x)
      .attr("x2", this.x);
  }

  /**
  * Set the label string.
  * @param {string} label - The label.
  */
  setLabel(label) {
    this.label = label;
    if (!this.firstCreated) {
      this.status = AnnotationStatus.UPDATED;
      this.dateUpdated = new Date().toISOString();
    }
  }

  /**
  * Get the time.
  * @return {number} The time.
  */
  getTime() {
    return this.time;
  }
}

/** A note group annotation. */
class NoteGroup {
  /**
  * Create a marker annotation.
  * @param {Array} noteArray - An array of notes.
  * @param {Array} noteScale - The value scale for the notes.
  * @param {string} label - A label.
  * @param {Object} options - Configuration options.
  * @param {Object} properties - Properties.
  * @param {boolean} active - Whether this boundary should be active when created.
  * @param {boolean} firstCreated - Whether this boundary has just been created.
  * @author Lawrence Fyfe
  */
  constructor(noteArray, noteScale, label, options, properties, active, firstCreated) {
    this.type = "noteGroup";
    this.status = AnnotationStatus.UNCHANGED;
    this.selected = false;
    this.selectionArray = [];
    this.noteScale = noteScale;
    this.firstCreated = firstCreated;
    this.label = label;
    this.options = options;
    this.color = options?.color ?? "#800000";
    this.selectedColor = options?.selectedColor ?? "#ff0000";
    this.properties = properties;
    this.active = active;
    this.show = true;
    this.dateCreated;
    this.dateUpdated;
    this.noteGroupGroup;

    if (this.firstCreated) {
      this.status = AnnotationStatus.CREATED;
      this.dateCreated = new Date().toISOString();
      this.selected = true;
    }

    if (noteArray !== undefined) {
      this.#addSelectedNoteArray(noteArray);
    }
  }

  /**
  * Add the specified note selection.
  * @param {NoteGroupSelection} s - The selected note.
  */
  #addSelection(s) {
    this.selectionArray.push(s);
  }

  /**
  * Add the specified note array.
  * @param {Array} noteArray - An array of notes.
  */
  #addSelectedNoteArray(noteArray) {
    if (noteArray.length > 0) {
      for (let i = 0; i < noteArray.length; i++) {
        this.#addSelection(new NoteGroupSelection(noteArray[i].note, noteArray[i].startTime, noteArray[i].endTime, this.options, this.selected));
      }
    }
  }

  /**
  * Add the specified note to this group.
  * @param {Object} note - The note.
  */
  addNote(note) {
    this.#addSelection(new NoteGroupSelection(note.note, note.startTime, note.endTime, this.options, this.selected));
    if (!this.firstCreated) {
      this.status = AnnotationStatus.UPDATED;
      this.dateUpdated = new Date().toISOString();
    }
  }

  /**
  * Add the specified array of notes to this group.
  * @param {Array} noteArray - An array of notes.
  */
  addNoteArray(noteArray) {
    if (noteArray.length > 0) {
      for (let i = 0; i < noteArray.length; i++) {
        if (!this.containsNote(noteArray[i].note, noteArray[i].startTime, noteArray[i].endTime)) {
          this.#addSelection(new NoteGroupSelection(noteArray[i].note, noteArray[i].startTime, noteArray[i].endTime, this.options, this.selected));
        }
      }
      if (!this.firstCreated) {
        this.status = AnnotationStatus.UPDATED;
        this.dateUpdated = new Date().toISOString();
      }
    }
  }

  /**
  * Determine whether this group contains the specified note.
  * @param {Object} note - A note.
  * @return {boolean} True if this group contains the note; false otherwise.
  */
  containsNote(note, startTime, endTime) {
    var note = this.selectionArray.find(selection => selection.note === note && selection.startTime === startTime && selection.endTime === endTime);
    if (note !== undefined) {
      return true;
    }
    else {
      return false;
    }
  }

  /**
  * Delete the specified array of notes from this group.
  * @param {Array} noteArray - An array of notes.
  */
  deleteNoteArray(noteArray) {
    if (noteArray.length > 0) {
      for (let i = 0; i < noteArray.length; i++) {
        this.#deleteSelection(noteArray[i]);
      }
      if (!this.firstCreated) {
        this.status = AnnotationStatus.UPDATED;
        this.dateUpdated = new Date().toISOString();
      }
    }
  }

  /**
  * Delete the specified note selection from this group.
  * @param {NoteGroupSelection} selection - A note selection.
  */
  #deleteSelection(selection) {
    for (let i = this.selectionArray.length-1; i >= 0; --i) {
      if (this.selectionArray[i].note === selection.note &&
          this.selectionArray[i].startTime === selection.startTime &&
          this.selectionArray[i].endTime === selection.endTime) {
        this.selectionArray.splice(i, 1);
      }
    }
  }

  /**
  * Draw this note group with the specified parameters from the containing pane and the specified note height.
  * @param {d3.selection} parentElement - The parent element.
  * @param {d3.scaleLinear} timeScale - The time scale.
  * @param {number} noteHeight - The height of notes in pixels.
  */
  draw(parentElement, timeScale, noteHeight) {
    if (this.show) {
      if (this.noteScale !== undefined) {
        this.noteGroupGroup = parentElement.append("g")
          .attr("class", "annotation noteGroupGroup")
          .on("deselectNote", (event) => {
            for (let i = this.selectionArray.length-1; i >= 0; --i) {
              if (this.selectionArray[i].note === event.detail.note &&
                  this.selectionArray[i].startTime === event.detail.startTime &&
                  this.selectionArray[i].endTime === event.detail.endTime) {
                this.selectionArray.splice(i, 1);
                if (!this.firstCreated) {
                  this.status = AnnotationStatus.UPDATED;
                  this.dateUpdated = new Date().toISOString();
                }
                this.drawSelections(this.noteGroupGroup, timeScale, noteHeight, this.active);
              }
            }
          });
        this.drawSelections(this.noteGroupGroup, timeScale, noteHeight, this.active);
      }
    }
  }

  /**
  * Draw note selections in this note group with the specified parameters.
  * @param {d3.selection} parentElement - The parent element.
  * @param {d3.scaleLinear} timeScale - The time scale.
  * @param {number} noteHeight - The height of notes in pixels.
  * @param {boolean} active - If true, note selections are active.
  */
  drawSelections(parentElement, timeScale, noteHeight, active) {
    this.eraseSelections();
    if (this.selectionArray.length > 0) {
      for (let i = 0; i < this.selectionArray.length; i++) {
        this.selectionArray[i].draw(parentElement, timeScale, this.noteScale, noteHeight, active);
      }
    }
  }

  /**
  * Erase the note in this note group.
  */
  eraseSelections() {
    this.noteGroupGroup.selectAll(".noteGroupSelectionRect").remove();
  }

  /**
  * Erase this note group.
  */
  erase() {
    if (this.noteGroupGroup !== undefined) {
      this.noteGroupGroup.remove();
    }
  }

  /**
  * Select or deselect this note group.
  * @param {boolean} select - If true, select; otherwise deselect.
  */
  select(select) {
    this.selected = select;
    // This check is for selecting note groups that share the same notes
    if (this.selected) {
      this.noteGroupGroup.raise();
    }
    else {
      this.noteGroupGroup.lower();
    }
    for (let i = 0; i < this.selectionArray.length; i++) {
      this.selectionArray[i].select(this.selected);
    }
  }

  /**
  * Set the label string.
  * @param {string} label - The label.
  */
  setLabel(label) {
    this.label = label;
    if (!this.firstCreated) {
      this.status = AnnotationStatus.UPDATED;
      this.dateUpdated = new Date().toISOString();
    }
  }
}

/** A note selection in a NoteGroup. */
class NoteGroupSelection {
  /**
  * Create a selected note for inclusion in a NoteGroup.
  * @param {number} note - The time.
  * @param {string} startTime - The start time of the note.
  * @param {string} endTime - The end time of the note.
  * @param {Object} options - Configuration options.
  * @param {boolean} selected - Whether this note is currently selected.
  * @author Lawrence Fyfe
  */
  constructor(note, startTime, endTime, options, selected) {
    this.note = note;
    this.startTime = startTime;
    this.endTime = endTime;
    this.color = options?.color ?? "#800000";
    this.selectedColor = options?.selectedColor ?? "#ff0000";
    this.noteSelectionRect;
    this.selected = selected;
  }

  /**
  * Draw this note selection with the specified parameters from the containing note group.
  * @param {d3.selection} parentElement - The parent element.
  * @param {d3.scaleLinear} timeScale - The time scale.
  * @param {d3.scaleLinear} noteScale - The scale of note values.
  * @param {number} noteHeight - The height of notes in pixels.
  * @param {boolean} active - True if this note selection should be active; false otherwise.
  */
  draw(parentElement, timeScale, noteScale, noteHeight, active) {
    let x1 = timeScale(this.startTime);
    let x2 = timeScale(this.endTime);
    let y = noteScale(this.note);
    let w = x2-x1;
    let h = noteHeight;
    this.noteSelectionRect = parentElement.append("rect")
      .attr("class", () => {
        let selectionClass = "annotation noteGroupSelectionRect";
        if (this.selected) {
          return selectionClass + " selected";
        }
        else {
          return selectionClass;
        }
      })
      .attr("x", x1)
      .attr("y", y)
      .attr("width", w)
      .attr("height", h)
      .style("fill", "none")
      .style("stroke", () => {
        if (this.selected) {
          return this.selectedColor;
        }
        else {
          return this.color;
        }
      })
      .style("stroke-width", 2)
      .style("pointer-events", "visible")
      .on("click", (event) => {
        if (this.selected) {
          event.target.dispatchEvent(new CustomEvent("deselectNote", {bubbles: true, detail: {note: this.note,
                                                                                              startTime: this.startTime,
                                                                                              endTime: this.endTime}}));
        }
        event.stopPropagation();
      });
  }

  /**
  * Select or deselect this note.
  * @param {boolean} select - If true, select; otherwise deselect.
  */
  select(select) {
    this.selected = select;
    if (this.selected) {
      this.noteSelectionRect.style("stroke", this.selectedColor);
    }
    else {
      this.noteSelectionRect.style("stroke", this.color);
    }
  }
}

/** A region annotation. */
class Region {
  // Drage modes
  static MOVE = 0;
  static RESIZE_LEFT = 1;
  static RESIZE_RIGHT = 2;

  /**
  * Create a region annotation.
  * @param {number} startTime - The start time.
  * @param {number} endTime - The end time.
  * @param {string} label - A label.
  * @param {Object} options - Configuration options.
  * @param {Object} properties - Properties.
  * @param {boolean} active - Whether this region should be active when created.
  * @param {boolean} firstCreated - Whether this region has just been created.
  * @author Lawrence Fyfe
  */
  constructor(startTime, endTime, label, options, properties, active, firstCreated) {
    this.type = "region";
    this.status = AnnotationStatus.UNCHANGED;
    this.selected = false;
    this.startTime = startTime;
    this.endTime = endTime;
    this.label = label;
    this.firstCreated = firstCreated;
    this.dragMode = 0;
    this.resizeWidthFactor = options?.resizeWidthFactor ?? 0.1;
    this.color = options?.color ?? "#800000";
    this.selectedColor = options?.selectedColor ?? "#ff0000";
    this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
    this.properties = properties;
    this.active = active;
    this.show = true;
    this.dateCreated;
    this.dateUpdated;
    this.regionGroup;
    this.rect;

    if (this.firstCreated) {
      this.status = AnnotationStatus.CREATED;
      this.dateCreated = new Date().toISOString();
    }
  }

  /**
  * Draw this region with the specified parameters from the containing pane.
  * @param {d3.selection} parentElement - The parent element.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {d3.scaleLinear} timeScale - The time scale.
  */
  draw(parentElement, parentWidth, parentHeight, timeScale) {
    if (this.show) {
      var x = timeScale(this.startTime);
      var width = timeScale(this.endTime)-x;
      this.resizeHandleWidth = (this.resizeWidthFactor*width > 4) ? this.resizeWidthFactor*width : 4;
      this.regionGroup = parentElement.append("g")
        .attr("class", "annotation regionGroup");
      this.rect = this.regionGroup.append("rect")
        .attr("class", "regionRect")
        .attr("x", x)
        .attr("y", 0)
        .attr("width", width)
        .attr("height", parentHeight)
        .style("fill", () => {
          if (this.selected) {
            return this.selectedColor;
          }
          else {
            return this.color;
          }
        })
        .style("fill-opacity", 0.20)
        .style("stroke", this.color)
        .style("stroke-width", 1)
        .call(d3.drag()
          .on("start", (event) => {
            if (this.active) {
              let mouseTime = timeScale.invert(event.x);
              let resizeHandleTime = timeScale.invert(this.resizeHandleWidth) - timeScale.domain()[0];
              if (mouseTime > this.startTime && mouseTime < (this.startTime + resizeHandleTime)) {
                this.dragMode = Region.RESIZE_LEFT;
              }
              else if (mouseTime > (this.endTime - resizeHandleTime) && mouseTime < this.endTime) {
                this.dragMode = Region.RESIZE_RIGHT;
              }
              else {
                this.dragMode = Region.MOVE;
              }
            }
          })
          .on("drag", (event) => {
            if (this.active && event.dx !== 0) {
              let dt = timeScale.invert(event.x) - timeScale.invert(event.x-event.dx);
              let minTime = timeScale.invert(12) - timeScale.domain()[0];

              if (this.dragMode === Region.RESIZE_LEFT) {
                this.rect.style("cursor", "w-resize");
                if (this.startTime + dt < timeScale.domain()[0]) {
                  this.startTime = timeScale.domain()[0];
                }
                else {
                  if (this.startTime + dt < (this.endTime - minTime)) {
                    this.startTime += dt;
                  }
                }
              }
              else if (this.dragMode === Region.RESIZE_RIGHT) {
                this.rect.style("cursor", "e-resize");
                if (this.endTime + dt > timeScale.domain()[1]) {
                  this.endTime = timeScale.domain()[1];
                }
                else {
                  if (this.endTime + dt > (this.startTime + minTime)) {
                    this.endTime += dt;
                  }
                }
              }
              else if (this.dragMode === Region.MOVE) {
                this.rect.style("cursor", "move");
                if (this.startTime + dt > timeScale.domain()[0] && this.endTime + dt < timeScale.domain()[1]) {
                  this.startTime += dt;
                  this.endTime += dt;
                }
              }

              let x = timeScale(this.startTime);
              let width = timeScale(this.endTime) - x;
              this.rect
                .attr("x", x)
                .attr("width", width);
              this.drawLabel(parentWidth, parentHeight, x+(width/2));
            }
          })
          .on("end", (event) => {
            if (this.active) {
              this.rect.style("cursor", "pointer");

              if (this.dragMode === Region.RESIZE_LEFT || this.dragMode === Region.RESIZE_RIGHT) {
                this.resizeHandleWidth = (this.resizeWidthFactor*(parseFloat(this.rect.attr("width"))) > 4) ?
                                          this.resizeWidthFactor*(parseFloat(this.rect.attr("width"))) : 4;
              }

              if (!this.firstCreated) {
                this.status = AnnotationStatus.UPDATED;
                this.dateUpdated = new Date().toISOString();
              }

              event.sourceEvent.target.dispatchEvent(new CustomEvent("updateAnnotation", {bubbles: true, detail: {annotation: this}}));
            }
          }))
        .on("mouseenter", (event) => {
          if (this.active) {
            this.changeCursor(timeScale.invert(d3.pointer(event)[0]), timeScale.invert(this.resizeHandleWidth) - timeScale.domain()[0]);
          }
          this.regionGroup.raise();

          let x = timeScale(this.startTime);
          let width = timeScale(this.endTime) - x;
          this.drawLabel(parentWidth, parentHeight, x+(width/2));
        })
        .on("mousemove", (event) => {
          if (this.active) {
            this.changeCursor(timeScale.invert(d3.pointer(event)[0]), timeScale.invert(this.resizeHandleWidth) - timeScale.domain()[0]);
          }
        })
        .on("mouseleave", (event) => {
          this.eraseLabel();
        })
        .on("click", (event) => {
          if (this.active) {
            if (!this.selected) {
              let soleSelection = true;
              this.select(true);
              if (event.metaKey || event.ctrlKey) {
                soleSelection = false;
              }
              event.target.dispatchEvent(new CustomEvent("selectAnnotation", {bubbles: true, detail: {annotation: this, soleSelection: soleSelection}}));
            }
            else {
              this.select(false);
              event.target.dispatchEvent(new CustomEvent("deselectAnnotation", {bubbles: true}));
            }
          }
          event.stopPropagation();
        });
    }
  }

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

  /**
  * Change the cursor style based in the pointer location.
  * @param {number} mouseTime - The time value in seconds at the pointer location.
  * @param {number} resizeHandleTime - The size of the resize handles in seconds.
  */
  changeCursor(mouseTime, resizeHandleTime) {
    if (mouseTime > this.startTime && mouseTime < (this.startTime + resizeHandleTime)) {
      this.rect.style("cursor", "w-resize");
    }
    else if (mouseTime > (this.endTime - resizeHandleTime) && mouseTime < this.endTime) {
      this.rect.style("cursor", "e-resize");
    }
    else {
      this.rect.style("cursor", "pointer");
    }
  }

  /**
  * Set the label string.
  * @param {string} label - The label.
  */
  setLabel(label) {
    this.label = label;
    if (!this.firstCreated) {
      this.status = AnnotationStatus.UPDATED;
      this.dateUpdated = new Date().toISOString();
    }
  }

  /**
  * Draw the label.
  * @param {number} parentWidth - The width.
  * @param {number} parentHeight - The height.
  * @param {number} x - The x location.
  */
  drawLabel(parentWidth, parentHeight, x) {
    this.eraseLabel();

    var labelGroup = this.regionGroup.append("g")
      .attr("class", "regionLabel");

    var labelText = labelGroup.append("text")
      .attr("x", x)
      .attr("y", parentHeight-16)
      .style("font-family", "sans-serif")
      .style("color", "black")
      .style("text-anchor", "middle")
      .style("dominant-baseline", "middle")
      .text(this.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");

    labelText.raise();
  }

  /**
  * Erase the label.
  */
  eraseLabel() {
    this.regionGroup.selectAll(".regionLabel").remove();
  }

  /**
  * Select or deselect this region.
  * @param {boolean} select - If true, select; otherwise deselect.
  */
  select(select) {
    this.selected = select;
    if (this.selected) {
      this.rect.style("fill", this.selectedColor);
    }
    else {
      this.rect.style("fill", this.color);
    }
  }
}