/** 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();
}
}
}
}