/** TimeFrame is the main container for panes. */
class Timeframe {
static INSERT_TIME = 0;
static DELETE_TIME = 1;
/** Create a TimeFrame.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(options) {
this.marginTop = options?.marginTop ?? 0;
this.marginRight = options?.marginRight ?? 0;
this.marginBottom = options?.marginBottom ?? 0;
this.marginLeft = options?.marginLeft ?? 0;
this.showTimeAxis = options?.showTimeAxis ?? true;
this.timeAxisTickCount = options?.timeAxisTickCount ?? 20;
this.showFirstTimeAxisTick = options?.showFirstTimeAxisTick ?? true;
this.backgroundColor = options?.backgroundColor;
this.includeGrid = options?.includeGrid ?? false;
this.showGrid = options?.showGrid ?? true;
this.gridColor = options?.gridColor ?? "#ff0000";
this.gridOpacity = options?.gridOpacity ?? "0.5";
this.gridStrokeWidth = options?.gridStrokeWidth ?? 1;
this.gridShowHorizontalLines = options?.gridShowHorizontalLines ?? true;
this.timeEditorFill = options?.timeEditorFill ?? "#0000ff";
this.timeEditorStroke = options?.timeEditorStroke ?? "#0000ff";
this.paneArray = [];
this.parentElement;
this.width;
this.height;
this.endTime;
this.timeScale;
this.timeFrameDiv;
this.timeFrameSVG;
this.paneSVG;
this.paneGroup;
this.paneWidth;
this.paneHeight;
this.timeFrameRect;
this.timeFramePaneGroup;
this.initialized = false;
this.zoomed = false;
this.grid;
this.showLoader = false;
this.showTimeEditor = false;
this.timeEditorMode = Timeframe.INSERT_TIME;
this.timeEditorGroup;
this.timeEditorBrush;
this.timeEditorBaseLine;
this.animate = this.runAnimation.bind(this);
this.onAnimate;
this.animating = false;
this.stopAnimating = false;
this.onMouseenter;
this.onMousemove;
this.onMouseleave;
this.onChangeEndTime;
this.baseWidth = 0;
this.baseHeight = 0;
if (this.includeGrid) {
this.grid = new Grid({color: this.gridColor,
opacity: this.gridOpacity,
strokeWidth: this.gridStrokeWidth,
showHorizontalLines: this.gridShowHorizontalLines});
}
}
/**
* Add the specified pane to this TimeFrame.
* @param {pane} pane - The pane to add.
*/
addPane(pane) {
this.paneArray.push(pane);
}
/**
* Set the specified pane to be the active pane. This both activates the pane and raises it to the top of the pane list for this TimeFrame.
* All other panes are deactivated.
* @param {pane} pane - The pane to add.
*/
setActivePane(pane) {
for (let i = 0; i < this.paneArray.length; i++) {
if (this.paneArray[i] === pane) {
this.paneArray[i].activate();
this.paneArray[i].raise();
}
else {
this.paneArray[i].deactivate();
}
}
}
/**
* Zoom the time in this TimeFrame to the specified range. This will change the time range for all panes contained in this TimeFrame,
* including an audio pane.
* @param {number} start - The start time.
* @param {number} stop - The stop time.
*/
zoomTimeRange(start, stop) {
this.zoomed = true;
if (this.timeScale !== undefined) {
this.timeScale.domain([start, stop]);
let audioPaneIndex = this.paneArray.findIndex(({paneType}) => paneType === "audio");
if (audioPaneIndex > -1) {
this.paneArray[audioPaneIndex].stop();
this.paneArray[audioPaneIndex].setTimeRange(this.timeScale.domain()[0], this.timeScale.domain()[1]);
}
}
}
/**
* Reset the time in this TimeFrame to a range from 0 to the end time set for this TimeFrame.
*/
resetTimeRange() {
this.zoomed = false;
if (this.timeScale !== undefined) {
this.timeScale.domain([0, this.endTime]);
let audioPaneIndex = this.paneArray.findIndex(({paneType}) => paneType === "audio");
if (audioPaneIndex > -1) {
this.paneArray[audioPaneIndex].resetTimeRange();
}
}
}
/**
* Inserts time based on the specified time range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
insertTime(baseTime, deltaTime) {
for (let i = 0; i < this.paneArray.length; i++) {
if ("insertTime" in this.paneArray[i]) {
this.paneArray[i].insertTime(baseTime, deltaTime);
}
}
this.endTime += deltaTime;
this.timeScale.domain([0, this.endTime]);
if (this.onChangeEndTime !== undefined) {
this.onChangeEndTime();
}
this.draw();
}
/**
* Deletes time based on the specified time range but only if no elements are in that time range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
deleteTime(baseTime, deltaTime) {
var canDelete = true;
for (let i = 0; i < this.paneArray.length; i++) {
if ("canDeleteTime" in this.paneArray[i]) {
canDelete = this.paneArray[i].canDeleteTime(baseTime, deltaTime);
if (!canDelete) {
break;
}
}
}
if (canDelete) {
for (let i = 0; i < this.paneArray.length; i++) {
if ("deleteTime" in this.paneArray[i]) {
this.paneArray[i].deleteTime(baseTime, deltaTime);
}
}
this.endTime -= deltaTime;
this.timeScale.domain([0, this.endTime]);
if (this.onChangeEndTime !== undefined) {
this.onChangeEndTime();
}
}
this.draw();
}
/**
* Add a zero to the specified number.
* @param {number} n - A number to be padded.
* @return {string} The padded number as a string.
*/
zeroPad(n) {
if (n < 10) {
return "0" + n;
}
else {
return n;
}
}
/**
* Take the specified seconds and format them.
* @param {number} s - Seconds to be formatted.
* @return {string} The formatted seconds as a string.
*/
formatSeconds(s) {
var digits = this.zoomed ? 2 : 0;
var seconds = this.zeroPad((s).toFixed(digits));
var splitSeconds = seconds.split(".");
if (splitSeconds[1] === "00") {
return splitSeconds[0];
}
else {
return seconds;
}
}
/**
* Generate the time time axis for this TimeFrame.
*/
callTimeAxis() {
this.timeAxis.call(this.timeAxisGenerator.tickFormat((d) => {
var r = d%3600;
if (r < d) {
// hours:minutes:seconds
return Math.trunc(d/3600).toString() + ":" + this.zeroPad(Math.trunc(r/60)) + ":" + this.zeroPad((r%60).toFixed(0));
}
else {
// minutes:seconds
r = d%60;
if (r < d) {
return Math.trunc(d/60).toString() + ":" + this.formatSeconds(r%60);
}
else {
// 0:seconds
return "0:" + this.formatSeconds(d);
}
}
}));
this.timeAxis.select("path").style("stroke", "none");
if (!this.showFirstTimeAxisTick) {
this.timeAxis.select(".tick").remove();
}
}
/**
* Initialize this TimeFrame.
* @param {Element} parentElement - The parent element.
* @param {number} width - The width in pixels of this TimeFrame.
* @param {number} height - The height in pixels of this TimeFrame.
* @param {number} endTime - The end time for this TimeFrame.
*/
initialize(parentElement, width, height, endTime) {
this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
this.width = width;
this.height = height;
this.endTime = endTime;
this.paneWidth = this.width-(this.marginRight+this.marginLeft);
this.paneHeight = this.height-(this.marginTop+this.marginBottom);
var axisX = this.marginLeft;
var axisY = this.marginTop + this.paneHeight;
this.timeScale = d3.scaleLinear()
.range([0, this.paneWidth])
.domain([0, this.endTime]);
this.timeFrameDiv = this.parentElement.append("div")
.attr("class", "timeFrameDiv")
.style("width", "fit-content")
.on("mouseenter", (event) => {
if (this.onMouseenter !== undefined) {
this.onMouseenter();
}
})
.on("mousemove", (event) => {
if (this.onMousemove !== undefined) {
this.onMousemove();
}
})
.on("mouseleave", (event) => {
if (this.onMouseleave !== undefined) {
this.onMouseleave();
}
});
this.timeFrameSVG = this.timeFrameDiv.append("svg")
.attr("id", "timeFrameSVG")
.attr("class", "timeFrameSVG")
.attr("x", 0)
.attr("y", 0)
.attr("width", this.width)
.attr("height", this.height);
this.paneSVG = this.timeFrameSVG.append("svg")
.attr("id", "paneSVG")
.attr("class", "paneSVG")
.attr("x", this.marginLeft)
.attr("y", this.marginTop)
.attr("width", this.paneWidth)
.attr("height", this.paneHeight);
this.frameGroup = this.paneSVG.append("g")
.attr("class", "frameGroup");
this.timeFrameRect = this.frameGroup.append("rect")
.attr("class", "timeFrameRect")
.attr("x", 0)
.attr("y", 0)
.attr("width", this.paneWidth)
.attr("height", this.paneHeight)
.style("fill", () => {
if (this.backgroundColor !== undefined) {
return this.backgroundColor;
}
else {
return "none";
}
})
.style("stroke", "#000000")
.style("stroke-width", "1px")
.style("pointer-events", "none");
this.paneGroup = this.paneSVG.append("g")
.attr("class", "paneGroup");
this.timeAxisGenerator = d3.axisBottom(this.timeScale)
.tickSizeOuter(0)
.ticks(this.timeAxisTickCount);
this.timeAxis = this.timeFrameSVG.append("g")
.attr("class", "timeAxis")
.attr("transform", "translate(" + axisX + "," + axisY + ")");
if (this.grid !== undefined) {
this.grid.initialize(this.frameGroup, this.paneWidth, this.paneHeight, this.timeScale);
}
for (let i = 0; i < this.paneArray.length; i++) {
this.paneArray[i].initialize(this.paneGroup, this.paneWidth, this.paneHeight, this.timeScale);
}
this.initialized = true;
}
/**
* Draw this TimeFrame if it has been initialized.
*/
draw() {
if (this.initialized) {
if (this.showTimeAxis) {
this.callTimeAxis();
}
if (this.showLoader) {
this.drawLoader();
}
else {
this.eraseLoader();
if (this.grid !== undefined) {
if (this.showGrid) {
this.grid.draw();
}
else {
this.grid.erase();
}
}
for (let i = 0; i < this.paneArray.length; i++) {
this.paneArray[i].draw();
}
}
if (this.showTimeEditor) {
if (this.timeEditorGroup !== undefined) {
this.timeEditorGroup.remove();
}
this.timeEditorGroup = this.paneGroup.append("g")
.attr("class", "timeEditorGroup");
this.timeEditorBrush = d3.brushX()
.extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
.on("start", (event) => {
if (this.timeEditorBaseLine !== undefined) {
this.timeEditorBaseLine.remove();
}
if (event.selection !== null) {
this.timeEditorBaseLine = this.timeEditorGroup.append("line")
.attr("x1", event.selection[0])
.attr("y1", 1)
.attr("x2", event.selection[0])
.attr("y2", this.paneHeight-1)
.style("stroke", this.timeEditorStroke)
.style("stroke-width", "1")
.style("stroke-opacity", "1.0");
}
})
.on("brush", (event) => {
if (event.selection !== null) {
this.timeEditorBaseLine
.attr("x1", event.selection[0])
.attr("x2", event.selection[0]);
}
})
.on("end", (event) => {
if (event.selection !== null && event.sourceEvent !== undefined) {
let baseTime = this.timeScale.invert(event.selection[0]);
switch (this.timeEditorMode) {
case Timeframe.INSERT_TIME:
this.insertTime(baseTime, this.timeScale.invert(event.selection[1]) - baseTime);
break;
case Timeframe.DELETE_TIME:
this.deleteTime(baseTime, this.timeScale.invert(event.selection[1]) - baseTime);
break;
}
}
});
this.timeEditorGroup.call(this.timeEditorBrush);
this.timeEditorGroup.select(".selection").style("fill", this.timeEditorFill);
this.timeEditorGroup.select(".selection").style("fill-opacity", "0.1");
}
else {
if (this.timeEditorGroup !== undefined) {
this.timeEditorGroup.remove();
}
}
}
}
/**
* Erase this TimeFrame.
*/
erase() {
if (this.grid !== undefined) {
this.grid.erase();
}
for (let i = 0; i < this.paneArray.length; i++) {
this.paneArray[i].erase();
}
if (this.timeFrameDiv !== undefined) {
this.timeFrameDiv.remove();
}
}
/**
* Takes the current width and height (set with initialize()) and sets them as a base size for resetSize() and resetSizeAndZoom().
*/
setBaseSize() {
this.baseWidth = this.width;
this.baseHeight = this.height;
for (let i = 0; i < this.paneArray.length; i++) {
if (this.paneArray[i].paneType === "visual") {
this.paneArray[i].setBaseSize();
}
}
}
/**
* Resize this Timeframe immediately.
* @param {number} width - The width in pixels to use for this TimeFrame.
* @param {number} height - The height in pixels to use for this TimeFrame.
*/
resize(width, height) {
this.erase();
this.initialize(this.parentElement, width, height, this.endTime);
this.draw();
}
/**
* Reset the width and height of this Timeframe back to whatever those values were when setBaseSize() was last called.
*/
resetSize() {
this.erase();
this.initialize(this.parentElement, this.baseWidth, this.baseHeight, this.endTime);
this.draw();
}
/**
* Resize this Timeframe and zoom to the specified time range immediately.
* @param {number} width - The width in pixels to use for this TimeFrame.
* @param {number} height - The height in pixels to use for this TimeFrame.
* @param {number} startTime - The start time to zoom to.
* @param {number} endTime - The end time to zoom to.
*/
resizeAndZoom(width, height, startTime, endTime) {
this.resizeWidth = width;
this.resizeHeight = height;
this.erase();
this.initialize(this.parentElement, width, height, this.endTime);
this.zoomTimeRange(startTime, endTime);
this.draw();
}
/**
* Reset the width and height of this Timeframe back to whatever those values were when setBaseSize() was last called
* and zoom to the specified time range.
* @param {number} startTime - The start time to zoom to.
* @param {number} endTime - The end time to zoom to.
*/
resetSizeAndZoom(startTime, endTime) {
this.erase();
this.initialize(this.parentElement, this.baseWidth, this.baseHeight, this.endTime);
this.zoomTimeRange(startTime, endTime);
this.draw();
}
/**
* Draw the individual lines in the loader animation.
*/
drawLoaderLine(x, y1, y2, baseY1, baseY2, animate) {
var y1Values = y1 + ";" + baseY1 + ";" + y1;
var y2Values = y2 + ";" + baseY2 + ";" + y2;
var loaderLine = this.loaderSVG.append("line")
.attr("class", "loaderLine")
.attr("x1", x)
.attr("y1", y1)
.attr("x2", x)
.attr("y2", y2)
.style("stroke", "black")
.style("stroke-width", "2px");
if (animate) {
loaderLine
.append("animate")
.attr("attributeName", "y1")
.attr("dur", "2s")
.attr("values", y1Values)
.attr("calcMode", "linear")
.attr("repeatCount", "indefinite");
loaderLine
.append("animate")
.attr("attributeName", "y2")
.attr("dur", "2s")
.attr("values", y2Values)
.attr("calcMode", "linear")
.attr("repeatCount", "indefinite");
}
}
/**
* Draw the loader and animate it until eraseLoader() is called.
*/
drawLoader() {
var loaderWidth = 0.5*this.paneWidth;
var loaderHeight = 0.4*this.paneHeight;
var halfLoaderHeight = 0.5*loaderHeight;
var loaderX = this.paneWidth/2-(loaderWidth/2);
var loaderY = this.paneHeight/2-halfLoaderHeight;
var lineCount = loaderWidth/4;
var animatedLineCount = lineCount-2;
var lineX = 2;
var offsetY = loaderHeight/25;
var baseLineY1 = halfLoaderHeight-offsetY;
var baseLineY2 = halfLoaderHeight+offsetY;
this.loaderSVG = this.frameGroup.append("svg")
.attr("id", "loaderSVG")
.attr("class", "loaderSVG")
.attr("x", loaderX)
.attr("y", loaderY)
.attr("width", loaderWidth)
.attr("height", loaderHeight);
this.drawLoaderLine(lineX, baseLineY1, baseLineY2, baseLineY1, baseLineY2, false);
for (let i = 1; i <= animatedLineCount; i++) {
lineX += 4;
let randomDeltaY = Math.random()*(halfLoaderHeight-offsetY);
this.drawLoaderLine(lineX, baseLineY1-randomDeltaY, baseLineY2+randomDeltaY, baseLineY1, baseLineY2, true);
}
lineX += 4;
this.drawLoaderLine(lineX, baseLineY1, baseLineY2, baseLineY1, baseLineY2, false);
}
/**
* Stop and erase the loader animation.
*/
eraseLoader() {
if (this.loaderSVG !== undefined) {
this.loaderSVG.remove();
}
}
/**
* Start running an animation. The animation task depends on the onAnimate() callback. The animation is stopped by a boolean set outside of this method
* though it can also be stopped by the stopAnimation() method. Note that the frame rate is normally the refresh rate of the screen.
*/
runAnimation() {
if (this.stopAnimating) {
this.animating = false;
this.stopAnimating = false;
}
else {
this.animating = true;
if (this.onAnimate !== undefined) {
this.onAnimate();
}
window.requestAnimationFrame(this.animate);
}
}
/**
* Stop the animation from running.
*/
stopAnimation() {
if (this.animating) {
this.stopAnimating = true;
}
}
}