Source: aspects.js

/**
* An AudioFileAspect folds audio file and visuals classes into a single simplified class.
*/
class AudioFileAspect {
  /**
  * Construct a new AudioFileAspect.
  * @param {object} audioContext - An AudioContext.
  * @author Lawrence Fyfe
  */
  constructor(audioContext) {
    this.audioContext = audioContext;
    this.fileArray = [];
    this.parentElement;
    this.parentWidth;
    this.parentHeight;
    this.audioEndTime = 0;
    this.audioAspectDiv;
    this.audioInputDiv;
    this.controlDiv;
    this.frameDiv;

    this.timeframe = new Timeframe({
      marginTop: 0,
      marginRight: MARGIN,
      marginBottom: MARGIN_BOTTOM,
      marginLeft: MARGIN,
      timeAxisTickCount: 10,
      backgroundColor: "#ffffff"
    });

    this.audioPane = new AudioPane(audioContext);
    this.audioPane.onPlay = this.startPlaying.bind(this);
    this.audioPane.onStop = this.stopPlaying.bind(this);
    this.timeframe.addPane(this.audioPane);

    this.visualPane = new VisualPane();
    this.timeframe.addPane(this.visualPane);

    this.zoomTimeframe = new Timeframe({
      marginTop: MARGIN,
      marginRight: MARGIN,
      marginBottom: 0,
      marginLeft: MARGIN,
      showTimeAxis: false
    });

    this.zoomVisualPane = new VisualPane();
    this.zoomTimeframe.addPane(this.zoomVisualPane);

    this.zoomPane = new ZoomPane();
    this.zoomPane.onZoom = this.zoomTarget.bind(this);
    this.zoomPane.onClear = this.clearTarget.bind(this);
    this.zoomTimeframe.addPane(this.zoomPane);
  }

  /**
  * Load an audio file.
  * @param {file} file - a file.
  */
  loadFile(file) {
    this.audioPane.setPlayer(new AudioFilePlayer(this.audioContext, file));
    this.audioPane.isReady().then(() => {
      this.audioEndTime = this.audioPane.getDuration();

      this.visualPane.removeVisualType("waveform");
      this.visualPane.addVisual(
        new Waveform(this.audioPane.player.bufferPlayer, this.audioEndTime, {color: "hsl(240, 30%, 25%)", opacity: "0.75"})
      );

      this.zoomVisualPane.removeVisualType("waveform");
      this.zoomVisualPane.addVisual(
        new Waveform(this.audioPane.player.bufferPlayer, this.audioEndTime, {color: "hsl(240, 30%, 25%)", opacity: "0.5"})
      );
    });
  }

  /**
  * Initialize this aspect.
  * @param {element} parentElement - The parent element.
  * @param {number} parentWidth - The parent width.
  * @param {number} parentHeight - The parent height.
  */
  initialize(parentElement, parentWidth, parentHeight) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.parentWidth = parentWidth;
    this.parentHeight = parentHeight;
    this.zoomHeight = 0.3*parentHeight;

    this.audioPane.isReady().then(() => {
      this.audioAspectDiv = this.parentElement.append("div")
        .attr("class", "audioAspectDiv");
      this.audioInputDiv = this.audioAspectDiv.append("div")
        .attr("id", "audioInputDiv")
        .style("margin-left", "2ch");
      var audioFileInput = this.audioInputDiv.append("input")
        .attr("type", "file")
        .attr("accept", "audio/*")
        .style("display", "none")
        .on("change", (event) => {
          let file = event.target.files[0];
          if (file.type.startsWith("audio")) {
            this.loadFile(file);
            this.erase();
            this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.audioEndTime);
            this.draw();
          }
        });
      this.audioInputDiv.append("button")
        .style("font-size", "1rem")
        .style("margin-right", "2ch")
        .text("Save visuals to SVG")
        .on("click", () => {
          this.saveToSVG();
        });
      this.audioInputDiv.append("button")
        .style("font-size", "1rem")
        .style("margin-right", "2ch")
        .text("Select a local audio file")
        .on("click", () => {
          audioFileInput.node().click();
        });

      this.controlDiv = this.audioAspectDiv.append("div")
        .attr("class", "controlDiv")
        .style("display", "flex")
        .style("flex-direction", "row")
        .style("margin-top", "2ex")
        .style("margin-left", MARGIN.toString() + "px");

      this.frameDiv = this.audioAspectDiv.append("div")
        .attr("class", "frameDiv");

      this.audioPane.initializePanel(this.controlDiv);
      this.zoomPane.initializePanel(this.controlDiv);
      this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.audioEndTime);
      this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.audioEndTime);
    });
  }

  /**
  * Draw this aspect.
  */
  draw() {
    this.audioPane.isReady().then(() => {
      this.audioPane.drawPanel();
      this.zoomPane.drawPanel();
      this.zoomTimeframe.draw();
      this.timeframe.draw();
    });
  }

  /**
  * Erase this aspect.
  */
  erase() {
    this.audioPane.isReady().then(() => {
      this.audioInputDiv.remove();
      this.controlDiv.remove();
      this.audioPane.erasePanel();
      this.zoomPane.erasePanel();
      this.timeframe.erase();
      this.zoomTimeframe.erase();
    });
  }

  /**
  * When audio starts playing, set the AudioPane as the active pane.
  */
  startPlaying() {
    this.timeframe.setActivePane(this.audioPane);
  }

  /**
  * When audio stops playing, set the VisualPane as the active pane.
  */
  stopPlaying() {
    this.timeframe.setActivePane(this.visualPane);
  }

  /**
  * Zoom into the specified time range.
  */
  zoomTarget(startTime, endTime) {
    this.timeframe.zoomTimeRange(startTime, endTime);
    this.timeframe.draw();
  }

  /**
  * Clear all zoom-related values and draw the Timeframe.
  */
  clearTarget() {
    this.audioPane.playLineTime = 0;
    this.audioPane.playLineX = 0;
    this.timeframe.resetTimeRange();
    this.timeframe.draw();
  }

  /**
  * Save the visual in this aspect to an SVG file.
  */
  saveToSVG() {
    const serializer = new XMLSerializer();
    const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
    const link = document.createElement("a");
    link.download = "traces.svg";
    link.href = URL.createObjectURL(blob);
    link.click();
    URL.revokeObjectURL(link.href);
  }
}

/**
* A MIDIFileAspect folds MIDI file and visual classes into a single simplified class.
*/
class MIDIFileAspect {
  /**
  * Construct a new MIDIFileAspect.
  * @param {object} audioContext - An AudioContext.
  * @author Lawrence Fyfe
  */
  constructor(audioContext) {
    this.audioContext = audioContext;
    this.readyPromise;
    this.parentElement;
    this.parentWidth;
    this.parentHeight;
    this.audioEndTime = 0;
    this.midiDiv;
    this.midiInputDiv;
    this.controlDiv;
    this.frameDiv;
    this.midiFile;
    this.synthesizer;

    this.timeframe = new Timeframe({
      marginTop: 0,
      marginRight: MARGIN,
      marginBottom: MARGIN_BOTTOM,
      marginLeft: MARGIN,
      timeAxisTickCount: 10,
      backgroundColor: "#ffffff"
    });

    this.audioPane = new AudioPane(audioContext);
    this.audioPane.onPlay = this.startPlaying.bind(this);
    this.audioPane.onStop = this.stopPlaying.bind(this);
    this.timeframe.addPane(this.audioPane);

    this.visualPane = new VisualPane();
    this.timeframe.addPane(this.visualPane);

    this.zoomTimeframe = new Timeframe({
      marginTop: MARGIN,
      marginRight: MARGIN,
      marginBottom: 0,
      marginLeft: MARGIN,
      showTimeAxis: false
    });

    this.zoomVisualPane = new VisualPane();
    this.zoomTimeframe.addPane(this.zoomVisualPane);

    this.zoomPane = new ZoomPane();
    this.zoomPane.onZoom = this.zoomTarget.bind(this);
    this.zoomPane.onClear = this.clearTarget.bind(this);
    this.zoomTimeframe.addPane(this.zoomPane);

    this.synthesizer = new WavetableSynthesizer(this.audioContext, "organ.json");
  }

  /**
  * Load a MIDI file.
  * @param {file} file - A file.
  */
  loadFile(file) {
    this.readyPromise = new Promise((resolve) => {
      file.arrayBuffer().then((buffer) => {
        this.synthesizer.isReady().then(() => {
          this.midiFile = new MIDIFile();
          this.midiFile.readFile(file.name, new Uint8Array(buffer));

          let trackNumber = 0;
          if (this.midiFile.format === 1 && this.midiFile.ntracks === 2) {
            trackNumber = 1;
          }

          this.audioEndTime = this.midiFile.totalTime;
          let noteEvents = this.midiFile.getTrackEventType("Note", trackNumber);

          let controllerEvents = this.midiFile.getTrackEventType("Controller", trackNumber);
          if (controllerEvents.length > 0) {
            this.visualPane.removeVisualType("area");
            let sustainEvents = controllerEvents.filter((e) => e.controller === 64);
            let sostenutoEvents = controllerEvents.filter((e) => e.controller === 66);
            let softPedalEvents = controllerEvents.filter((e) => e.controller === 67);
            if (sustainEvents.length > 0) {
              this.visualPane.createCurve(
                sustainEvents,
                {
                  curveType: Curve.AREA,
                  on: true,
                  timeName: "seconds",
                  alueDomainStart: 0,
                  valueDomainEnd: 127,
                  invert: true,
                  color: "#008080",
                  opacity: "0.1"
                }
              );
            }
            if (sostenutoEvents.length > 0) {
              this.visualPane.createCurve(
                sostenutoEvents,
                {
                  curveType: Curve.AREA,
                  on: true,
                  timeName: "seconds",
                  alueDomainStart: 0,
                  valueDomainEnd: 127,
                  invert: true,
                  color: "#000080",
                  opacity: "0.1"
                }
              );
            }
            if (softPedalEvents.length > 0) {
              this.visualPane.createCurve(
                softPedalEvents,
                {
                  curveType: Curve.AREA,
                  on: true,
                  timeName: "seconds",
                  alueDomainStart: 0,
                  valueDomainEnd: 127,
                  invert: true,
                  color: "#008000",
                  opacity: "0.1"
                }
              );
            }
          }

          let zoomNotes = this.visualPane.generateNotes(noteEvents, {fill: "hsl(240, 30%, 25%)", stroke: "hsl(240, 30%, 25%)"});
          this.zoomVisualPane.removeVisualType("notes");
          this.zoomVisualPane.addVisual(zoomNotes);

          let notes = this.visualPane.generateNotes(noteEvents, {fill: "hsl(240, 30%, 25%)", stroke: "hsl(240, 30%, 25%)"});
          this.visualPane.removeVisualType("notes");
          this.visualPane.addVisual(notes);

          this.audioPane.setPlayer(new SynthesizerPlayer(this.audioContext, this.synthesizer, notes, this.audioEndTime));

          resolve(true);
        })
        .catch(() => resolve(false));;
      })
      .catch(() => resolve(false));;
    });
  }

  /**
  * Initialize this aspect.
  * @param {element} parentElement - The parent element.
  * @param {number} parentWidth - The parent width.
  * @param {number} parentHeight - The parent height.
  */
  initialize(parentElement, parentWidth, parentHeight) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.parentWidth = parentWidth;
    this.parentHeight = parentHeight;
    this.zoomHeight = 0.3*parentHeight;

    this.readyPromise.then(() => {
      this.midiDiv = this.parentElement.append("div")
        .attr("class", "midiDiv");
      this.midiInputDiv = this.midiDiv.append("div")
        .attr("id", "midiInputDiv")
        .style("margin-left", "2ch");
      this.midiInputDiv.append("button")
        .style("font-size", "1rem")
        .style("margin-right", "2ch")
        .text("Save Aspect to SVG")
        .on("click", () => {
          this.saveToSVG();
        });
      var midiFileInput = this.midiInputDiv.append("input")
        .attr("type", "file")
        .attr("accept", ".mid")
        .style("display", "none")
        .on("change", (event) => {
          this.loadFile(event.target.files[0]);
          this.erase();
          this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.audioEndTime);
          this.draw();
        });
      this.midiInputDiv.append("button")
        .style("font-size", "1rem")
        .style("margin-right", "2ch")
        .text("Select a local MIDI file")
        .on("click", () => {
          midiFileInput.node().click();
        });

      this.controlDiv = this.midiDiv.append("div")
        .attr("class", "controlDiv")
        .style("display", "flex")
        .style("flex-direction", "row")
        .style("margin-top", "2ex")
        .style("margin-left", MARGIN.toString() + "px");
      this.frameDiv = this.midiDiv.append("div")
        .attr("class", "frameDiv");

      this.audioPane.initializePanel(this.controlDiv);
      this.zoomPane.initializePanel(this.controlDiv);
      this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.audioEndTime);
      this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.audioEndTime);
    });
  }

  /**
  * Draw this aspect.
  */
  draw() {
    this.readyPromise.then(() => {
      this.audioPane.drawPanel();
      this.zoomPane.drawPanel();
      this.zoomTimeframe.draw();
      this.timeframe.draw();
    });
  }

  /**
  * Erase this aspect.
  */
  erase() {
    this.readyPromise.then(() => {
      this.midiInputDiv.remove();
      this.controlDiv.remove();
      this.audioPane.erasePanel();
      this.zoomPane.erasePanel();
      this.timeframe.erase();
      this.zoomTimeframe.erase();
    });
  }

  /**
  * When audio starts playing, set the AudioPane as the active pane.
  */
  startPlaying() {
    this.timeframe.setActivePane(this.audioPane);
  }

  /**
  * When audio stops playing, set the VisualPane as the active pane.
  */
  stopPlaying() {
    this.timeframe.setActivePane(this.visualPane);
  }

  /**
  * Zoom into the specified time range.
  */
  zoomTarget(startTime, endTime) {
    this.timeframe.zoomTimeRange(startTime, endTime);
    this.timeframe.draw();
  }

  /**
  * Clear all zoom-related values and draw the Timeframe.
  */
  clearTarget() {
    this.audioPane.playLineTime = 0;
    this.audioPane.playLineX = 0;
    this.timeframe.resetTimeRange();
    this.timeframe.draw();
  }

  /**
  * Save the visual in this aspect to an SVG file.
  */
  saveToSVG() {
    const serializer = new XMLSerializer();
    const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
    const link = document.createElement("a");
    link.download = "traces.svg";
    link.href = URL.createObjectURL(blob);
    link.click();
    URL.revokeObjectURL(link.href);
  }
}

/**
* An JSONFileAspect folds visuals into a single simplified class.
*/
class JSONFileAspect {
  /**
  * Construct a new JSONFileAspect.
  * @author Lawrence Fyfe
  */
  constructor() {
    this.parentElement;
    this.parentWidth;
    this.parentHeight;
    this.dataViewDiv;
    this.dataViewInputDiv;
    this.controlDiv;
    this.frameDiv;
    this.readyPromise;
    this.endTime = 0;

    this.zoomTimeframe = new Timeframe({
      marginTop: MARGIN,
      marginRight: MARGIN,
      marginBottom: 0,
      marginLeft: MARGIN,
      showTimeAxis: false
    });

    this.zoomVisualPane = new VisualPane();
    this.zoomTimeframe.addPane(this.zoomVisualPane);

    this.zoomPane = new ZoomPane();
    this.zoomPane.onZoom = this.zoomTarget.bind(this);
    this.zoomPane.onClear = this.clearTarget.bind(this);
    this.zoomTimeframe.addPane(this.zoomPane);

    this.timeframe = new Timeframe({
      marginTop: 0,
      marginRight: MARGIN,
      marginBottom: MARGIN_BOTTOM,
      marginLeft: MARGIN,
      timeAxisTickCount: 10,
      backgroundColor: "#ffffff"
    });

    this.visualPane = new VisualPane();
    this.timeframe.addPane(this.visualPane);
  }

  /**
  * Load a JSON file.
  * @param {file} file - a file.
  */
  loadFile(jsonArray) {
    this.endTime = jsonArray[jsonArray.length - 1].time;

    this.zoomVisualPane.removeVisualType("curve");
    this.zoomVisualPane.createCurve(jsonArray, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", opacity: "0.75", width: 1, on: true});

    this.visualPane.removeVisualType("curve");
    this.visualPane.createCurve(jsonArray, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", on: true});
  }

  /**
  * Initialize this aspect.
  * @param {element} parentElement - The parent element.
  * @param {number} parentWidth - The parent width.
  * @param {number} parentHeight - The parent height.
  */
  initialize(parentElement, parentWidth, parentHeight) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.parentWidth = parentWidth;
    this.parentHeight = parentHeight;
    this.zoomHeight = 0.3*parentHeight;

    this.dataViewDiv = this.parentElement.append("div")
      .attr("class", "dataViewDiv");
    this.dataViewInputDiv = this.dataViewDiv.append("div")
      .attr("id", "dataViewInputDiv")
      .style("margin-left", "2ch");
    this.dataViewInputDiv.append("button")
      .style("font-size", "1rem")
      .style("margin-right", "2ch")
      .text("Save Aspect to SVG")
      .on("click", () => {
        this.saveToSVG();
      });
    var dataViewInput = this.dataViewInputDiv.append("input")
      .attr("type", "file")
      .attr("accept", ".csv")
      .style("display", "none")
      .on("change", (event) => {
        this.loadFile(event.target.files[0]);
        d3.select("#this.dataViewInputDiv").remove();
        this.erase();
        this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.endTime);
        this.draw();
      });
    this.dataViewInputDiv.append("button")
      .style("font-size", "1rem")
      .style("margin-right", "2ch")
      .text("Select a local JSON file")
      .on("click", () => {
        dataViewInput.node().click();
      });

    this.controlDiv = this.dataViewDiv.append("div")
      .attr("class", "controlDiv")
      .style("display", "flex")
      .style("flex-direction", "row")
      .style("margin-top", "2ex")
      .style("margin-left", MARGIN.toString() + "px");
    this.frameDiv = this.dataViewDiv.append("div")
      .attr("class", "frameDiv");

    this.zoomPane.initializePanel(this.controlDiv);
    this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.endTime);
    this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.endTime);
  }

  /**
  * Draw this aspect.
  */
  draw() {
    this.zoomPane.drawPanel();
    this.zoomTimeframe.draw();
    this.timeframe.draw();
  }

  /**
  * Erase this aspect.
  */
  erase() {
    this.dataViewInputDiv.remove();
    this.controlDiv.remove();
    this.zoomPane.erasePanel();
    this.zoomTimeframe.erase();
    this.timeframe.erase();
  }

  /**
  * Zoom into the specified time range.
  */
  zoomTarget(startTime, endTime) {
    this.timeframe.zoomTimeRange(startTime, endTime);
    this.timeframe.draw();
  }

  /**
  * Clear all zoom-related values and draw the Timeframe.
  */
  clearTarget() {
    this.timeframe.resetTimeRange();
    this.timeframe.draw();
  }

  /**
  * Save the visual in this aspect to an SVG file.
  */
  saveToSVG() {
    const serializer = new XMLSerializer();
    const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
    const link = document.createElement("a");
    link.download = "traces.svg";
    link.href = URL.createObjectURL(blob);
    link.click();
    URL.revokeObjectURL(link.href);
  }
}

/**
* An CSVFileAspect folds visuals into a single simplified class.
*/
class CSVFileAspect {
  /**
  * Construct a new CSVFileAspect.
  * @author Lawrence Fyfe
  */
  constructor() {
    this.hasHeader = true;
    this.timeColumn = 0;
    this.valueColumn = 1;
    this.parentElement;
    this.parentWidth;
    this.parentHeight;
    this.dataViewDiv;
    this.dataViewInputDiv;
    this.controlDiv;
    this.frameDiv;
    this.readyPromise;
    this.endTime = 0;

    this.zoomTimeframe = new Timeframe({
      marginTop: MARGIN,
      marginRight: MARGIN,
      marginBottom: 0,
      marginLeft: MARGIN,
      showTimeAxis: false
    });

    this.zoomVisualPane = new VisualPane();
    this.zoomTimeframe.addPane(this.zoomVisualPane);

    this.zoomPane = new ZoomPane();
    this.zoomPane.onZoom = this.zoomTarget.bind(this);
    this.zoomPane.onClear = this.clearTarget.bind(this);
    this.zoomTimeframe.addPane(this.zoomPane);

    this.timeframe = new Timeframe({
      marginTop: 0,
      marginRight: MARGIN,
      marginBottom: MARGIN_BOTTOM,
      marginLeft: MARGIN,
      timeAxisTickCount: 10,
      backgroundColor: "#ffffff"
    });

    this.visualPane = new VisualPane();
    this.timeframe.addPane(this.visualPane);
  }

  /**
  * Load a CSV file.
  * @param {file} file - a file.
  */
  loadFile(file) {
    this.readyPromise = file.text().then((textString) => {
      let csv;
      let csvData = [];
      let startingRow = 0;
      if (this.hasHeader) {
        csv = d3.csvParse(textString);
        startingRow = 1; // Start to convert after the header
      }
      else {
        csv = d3.csvParseRows(textString);
      }
      for (let i = startingRow; i < csv.length; i++) {
        let t = +csv[i][csv.columns[0]];
        let v = +csv[i][csv.columns[1]];
        if (!Number.isNaN(t) && !Number.isNaN(v)) {
          csvData.push({time: t, value: v});
        }
      }

      this.endTime = csvData[csvData.length-1].time;

      this.zoomVisualPane.removeVisualType("curve");
      this.zoomVisualPane.createCurve(csvData, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", opacity: "0.75", width: 1, on: true});

      this.visualPane.removeVisualType("curve");
      this.visualPane.createCurve(csvData, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", on: true});
    });
  }

  /**
  * Initialize this aspect.
  * @param {element} parentElement - The parent element.
  * @param {number} parentWidth - The parent width.
  * @param {number} parentHeight - The parent height.
  */
  initialize(parentElement, parentWidth, parentHeight) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.parentWidth = parentWidth;
    this.parentHeight = parentHeight;
    this.zoomHeight = 0.3*parentHeight;

    this.readyPromise.then(() => {
      this.dataViewDiv = this.parentElement.append("div")
        .attr("class", "dataViewDiv");
      this.dataViewInputDiv = this.dataViewDiv.append("div")
        .attr("id", "dataViewInputDiv")
        .style("margin-left", "2ch");
      this.dataViewInputDiv.append("button")
        .style("font-size", "1rem")
        .style("margin-right", "2ch")
        .text("Save Aspect to SVG")
        .on("click", () => {
          this.saveToSVG();
        });
      var dataViewInput = this.dataViewInputDiv.append("input")
        .attr("type", "file")
        .attr("accept", ".csv")
        .style("display", "none")
        .on("change", (event) => {
          this.loadFile(event.target.files[0]);
          d3.select("#this.dataViewInputDiv").remove();
          this.erase();
          this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.endTime);
          this.draw();
        });
      this.dataViewInputDiv.append("button")
        .style("font-size", "1rem")
        .style("margin-right", "2ch")
        .text("Select a local CSV file")
        .on("click", () => {
          dataViewInput.node().click();
        });

      this.controlDiv = this.dataViewDiv.append("div")
        .attr("class", "controlDiv")
        .style("display", "flex")
        .style("flex-direction", "row")
        .style("margin-top", "2ex")
        .style("margin-left", MARGIN.toString() + "px");
      this.frameDiv = this.dataViewDiv.append("div")
        .attr("class", "frameDiv");

      this.zoomPane.initializePanel(this.controlDiv);
      this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.endTime);
      this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.endTime);
    });
  }

  /**
  * Draw this aspect.
  */
  draw() {
    this.readyPromise.then(() => {
      this.zoomPane.drawPanel();
      this.zoomTimeframe.draw();
      this.timeframe.draw();
    });
  }

  /**
  * Erase this aspect.
  */
  erase() {
    this.readyPromise.then(() => {
      this.dataViewInputDiv.remove();
      this.controlDiv.remove();
      this.zoomPane.erasePanel();
      this.zoomTimeframe.erase();
      this.timeframe.erase();
    });
  }

  /**
  * Zoom into the specified time range.
  */
  zoomTarget(startTime, endTime) {
    this.timeframe.zoomTimeRange(startTime, endTime);
    this.timeframe.draw();
  }

  /**
  * Clear all zoom-related values and draw the Timeframe.
  */
  clearTarget() {
    this.timeframe.resetTimeRange();
    this.timeframe.draw();
  }

  /**
  * Save the visual in this aspect to an SVG file.
  */
  saveToSVG() {
    const serializer = new XMLSerializer();
    const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
    const link = document.createElement("a");
    link.download = "traces.svg";
    link.href = URL.createObjectURL(blob);
    link.click();
    URL.revokeObjectURL(link.href);
  }
}