Source: zoom.js

/** A zoom pane. */
class ZoomPane {
  /**
  * Create a zoom pane.
  * @param {string} name - A name for the pane.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.paneType = "zoom";
    this.options = options;
    this.showPanelTitle = options?.showPanelTitle ?? false;
    this.buttonWidth = options?.buttonWidth ?? "3ch";
    this.buttonHeight = options?.buttonHeight ?? "3ch";
    this.buttonFontSize = options?.buttonFontSize ?? "x-large";
    this.buttonBorderRadius = options?.buttonBorderRadius ?? "8px";
    this.showButtonDescription = options?.showButtonDescription ?? true;
    this.parentElement;
    this.paneWidth;
    this.paneHeight;
    this.timeScale;
    this.initialized = false;
    this.zoomBrush;
    this.onZoom;
    this.onClear;
    this.panelElement;
    this.panelInitialized = false;
    this.panelOn = false;
    this.zoomPanelDiv;
    this.zoomIncreaseButton;
    this.zoomDecreaseButton;
    this.zoomClearButton;
    this.timeDividerArray = [];
    this.timeDividerGroup;
  }

  /**
  * Add a divider at the specified time.
  * @param {number} time - The time.
  */
  addTimeDivider(time) {
    this.timeDividerArray.push(time);
  }

  /**
  * Initialize the panel for this pane, setting its parent element to the specified element.
  * @param {element} panelElement - The parent element.
  */
  initializePanel(panelElement) {
    this.panelElement = panelElement instanceof d3.selection ? panelElement : d3.select(panelElement);
    this.panelInitialized = true;
  }

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

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

      if (this.showPanelTitle) {
        let zoomTitleDiv = this.zoomPanelDiv.append("div")
          .attr("class", "zoomTitleDiv")
          .style("font-weight", "bold")
          .style("font-size", "x-large")
          .style("padding-bottom", "4%")
          .text("Zoom");
      }

      let zoomButtonsDiv = this.zoomPanelDiv.append("div")
        .attr("class", "zoomButtonsDiv")
        .style("display", "flex")
        .style("flex-direction", "row");

      let zoomIncreaseDiv = zoomButtonsDiv.append("div")
        .attr("class", "zoomIncreaseDiv");

      this.zoomIncreaseButton = new TextButton({
        text: "\u2295",
        description: "Increase zoom",
        opacity: "0.5",
        fontSize: this.buttonFontSize,
        borderRadius: this.buttonBorderRadius,
        showDescription: this.showButtonDescription
      });
      this.zoomIncreaseButton.onClick = this.increaseZoom.bind(this);
      this.zoomIncreaseButton.initialize(zoomIncreaseDiv, this.buttonWidth, this.buttonHeight);
      this.zoomIncreaseButton.draw();

      let zoomDecreaseDiv = zoomButtonsDiv.append("div")
        .attr("class", "zoomDecreaseDiv");

      this.zoomDecreaseButton = new TextButton({
        text: "\u2296",
        description: "Decrease zoom",
        opacity: "0.5",
        fontSize: this.buttonFontSize,
        borderRadius: this.buttonBorderRadius,
        showDescription: this.showButtonDescription
      });
      this.zoomDecreaseButton.onClick = this.decreaseZoom.bind(this);
      this.zoomDecreaseButton.initialize(zoomDecreaseDiv, this.buttonWidth, this.buttonHeight);
      this.zoomDecreaseButton.draw();

      let zoomClearDiv = zoomButtonsDiv.append("div")
        .attr("class", "zoomClearDiv");

      this.zoomClearButton = new TextButton({
        text: "\u2297",
        description: "Clear zoom",
        opacity: "0.5",
        fontSize: this.buttonFontSize,
        borderRadius: this.buttonBorderRadius,
        showDescription: this.showButtonDescription
      });
      this.zoomClearButton.onClick = this.clearZoom.bind(this);
      this.zoomClearButton.initialize(zoomClearDiv, this.buttonWidth, this.buttonHeight);
      this.zoomClearButton.draw();
    }
  }

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

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

    this.zoomPaneGroup = this.parentElement.append("g")
      .attr("class", "zoomPaneGroup")
      .on("dblclick", (event) => {
        if (this.timeDividerArray.length > 0) {
          let t = this.timeScale.invert(d3.pointer(event)[0]);
          let startTime = 0;
          let endTime = this.paneWidth;

          if (t < this.timeDividerArray[0]) {
            endTime = this.timeDividerArray[0];
          }

          if (t > this.timeDividerArray[0]) {
            for (let i = 0; i < this.timeDividerArray.length; i++) {
              if (t < this.timeDividerArray[i]) {
                startTime = this.timeDividerArray[i-1];
                break;
              }
            }
          }

          if (t > this.timeDividerArray[this.timeDividerArray.length-1]) {
            startTime = this.timeDividerArray[this.timeDividerArray.length-1];
          }

          if (t < this.timeDividerArray[this.timeDividerArray.length-1]) {
            for (let i = this.timeDividerArray.length-1; i >= 0; i--) {
              if (t > this.timeDividerArray[i]) {
                endTime = this.timeDividerArray[i+1];
                break;
              }
            }
          }

          if (this.onZoom !== undefined) {
            this.onZoom(startTime, endTime);
          }
        }
      });

    this.zoomBrushGroup = this.zoomPaneGroup.append("g")
      .attr("class", "zoomBrushGroup");

    this.initialized = true;
  }

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

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

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

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

  /**
  * Draw time dividers.
  */
  drawTimeDividers() {
    this.eraseTimeDividers();

    this.timeDividerGroup = this.zoomPaneGroup.append("g")
      .attr("class", "timeDividerGroup");

    for (let i = 0; i < this.timeDividerArray.length; i++) {
      this.timeDividerGroup.append("line")
        .attr("class", "timeDivider")
        .attr("x1", this.timeScale(this.timeDividerArray[i]))
        .attr("y1", 0)
        .attr("x2", this.timeScale(this.timeDividerArray[i]))
        .attr("y2", this.paneHeight)
        .style("stroke", "#000000")
        .style("stroke-width", "1");
    }
  }

  /**
  * Erase time dividers.
  */
  eraseTimeDividers() {
    if (this.timeDividerGroup !== undefined) {
      this.timeDividerGroup.remove();
    }
  }

  /**
  * Draw this ZoomPane with any time dividers. By default, a zoom brush is created that covers the entire pane.
  * Changing the size of the zoom brush triggers a call to the onZoom() callback with the the zoom brush starting
  * x and ending x values as parameters.
  */
  draw() {
    if (this.initialized) {
      this.zoomBrush = d3.brushX()
        .extent([[1, 2], [this.paneWidth-1, this.paneHeight-2]])
        .on("end", (event) => {
          if (event.selection !== null) {
            if (this.panelOn) {
              this.zoomIncreaseButton.setOption("opacity", "1.0");
              this.zoomIncreaseButton.draw();
              this.zoomDecreaseButton.setOption("opacity", "1.0");
              this.zoomDecreaseButton.draw();
              this.zoomClearButton.setOption("opacity", "1.0");
              this.zoomClearButton.draw();
            }

            // If the brush is moved directly via moveZoomBrush(), event.sourceEvent will be undefined
            // In that case, do not run onZoom
            // This is also why increaseZoom() and decreaseZoom() call onZoom themselves
            if (event.sourceEvent !== undefined && this.onZoom !== undefined) {
              this.onZoom(this.timeScale.invert(event.selection[0]), this.timeScale.invert(event.selection[1]));
            }
          }
        });

      this.zoomBrushGroup.call(this.zoomBrush);

      if (this.timeDividerArray.length > 0) {
        this.drawTimeDividers();
      }
    }
  }

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

  /**
  * Move the zoom brush to the specified time range.
  * @param {number} startTime - The start time.
  * @param {number} endTime - The end time.
  */
  moveZoomBrush(startTime, endTime) {
    this.zoomBrush.move(this.zoomBrushGroup, [this.timeScale(startTime), this.timeScale(endTime)]);
  }

  /**
  * Set the zoom brush to the specified x range.
  * @param {number} x1 - Start x.
  * @param {number} x2 - End x.
  */
  setZoom(x1, x2) {
    this.zoomBrush.move(this.zoomBrushGroup, [x1, x2]);
  }

  /**
  * Increase the zoom. This actually decreases the size of the zoom brush.
  */
  increaseZoom() {
    if (d3.brushSelection(this.zoomBrushGroup.node()) !== null) {
      let selectionX1 = d3.brushSelection(this.zoomBrushGroup.node())[0]+20;
      let selectionX2 = d3.brushSelection(this.zoomBrushGroup.node())[1]-20;
      if ((selectionX2-selectionX1) > 5) {
        this.zoomBrush.move(this.zoomBrushGroup, [selectionX1, selectionX2]);
        if (this.onZoom !== undefined) {
          this.onZoom(this.timeScale.invert(selectionX1), this.timeScale.invert(selectionX2));
        }
      }
    }
  }

  /**
  * Decrease the zoom. This actually increases the size of the zoom brush.
  */
  decreaseZoom() {
    if (d3.brushSelection(this.zoomBrushGroup.node()) !== null) {
      let selectionX1 = d3.brushSelection(this.zoomBrushGroup.node())[0]-20;
      let selectionX2 = d3.brushSelection(this.zoomBrushGroup.node())[1]+20;
      if (selectionX1 < 0) {
        selectionX1 = 0;
      }
      if (selectionX2 > this.paneWidth) {
        selectionX2 = this.paneWidth;
      }
      this.zoomBrush.move(this.zoomBrushGroup, [selectionX1, selectionX2]);
      if (this.onZoom !== undefined) {
        this.onZoom(this.timeScale.invert(selectionX1), this.timeScale.invert(selectionX2));
      }
    }
  }

  /**
  * Clear the zoom, setting the zoom brush back to its default.
  */
  clearZoom() {
    if (this.zoomBrush !== undefined) {
      this.zoomBrush.clear(this.zoomBrushGroup);

      if (this.panelOn) {
        this.zoomIncreaseButton.setOption("opacity", "0.5");
        this.zoomIncreaseButton.draw();
        this.zoomDecreaseButton.setOption("opacity", "0.5");
        this.zoomDecreaseButton.draw();
        this.zoomClearButton.setOption("opacity", "0.5");
        this.zoomClearButton.draw();
      }

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