/** A visual pane. */
class VisualPane {
/* Visual drawing mode */
static DRAW_SUPERIMPOSED = 0;
static DRAW_STACKED = 1;
/* Visual drawing order */
static ORDER_NORMALLY = 0;
static ORDER_REVERSED = 1;
/**
* Create an visual pane.
* @param {Object} options - Options for this pane.
* @author Lawrence Fyfe
*/
constructor(options) {
this.paneType = "visual";
this.options = options;
this.visualDrawMode = options?.visualDrawMode ?? VisualPane.DRAW_SUPERIMPOSED;
this.visualDrawOrder = options?.visualDrawOrder ?? VisualPane.ORDER_NORMALLY;
this.resizeVisuals = options?.resizeVisuals ?? true;
this.delineateDeltaX = 0;
this.parentElement;
this.width;
this.height;
this.timeScale;
this.initialized = false;
this.visualArray = [];
this.visualPaneGroup;
this.visualGroup;
this.visualPanel;
this.panelInitialized = false;
this.panelOn = false;
this.baseWidth = 0;
this.baseHeight = 0;
}
/**
* Add a visual to this pane.
* @param {visual} visual - The visual to add.
* @param {VisualPanel} panel - An optional panel to associate with this visual.
*/
addVisual(visual, panel) {
if (this.initialized) {
visual.initialize(this.visualGroup, this.width, this.height, this.timeScale);
}
this.visualArray.push(visual);
if (panel !== undefined && panel !== null) {
if (visual.visualType === "set") {
panel.addVisualSet(visual);
}
else {
panel.addVisual(visual);
}
}
}
/**
* Add an array of visuals to this pane.
* @param {Array} visuals - The array of visuals to add.
* @param {VisualPanel} panel - An optional panel to associate with these visuals.
*/
addVisuals(visuals, panel) {
for (let i = 0; i < visuals.length; i++) {
if (this.initialized) {
visuals[i].initialize(this.visualGroup, this.width, this.height, this.timeScale);
}
if (panel !== undefined && panel !== null) {
if (visual[i].visualType === "set") {
panel.addVisualSet(visual[i]);
}
else {
panel.addVisual(visual[i]);
}
}
}
this.visualArray = [...this.visualArray, ...visuals];
}
/**
* Remove a visual from this pane.
* @param {visual} visualToRemove - The visual to remove.
* @param {VisualPanel} panel - An optional panel to remove.
*/
removeVisual(visualToRemove, panel) {
var visualIndex = this.visualArray.findIndex(visual => visual === visualToRemove);
if (visualIndex > -1) {
this.visualArray.splice(visualIndex, 1);
}
if (panel !== undefined && panel !== null) {
panel.removeVisual(visualToRemove);
}
}
/**
* Remove visuals of the specified type from this pane.
* @param {string} type - The type to remove.
*/
removeVisualType(type) {
for (let i = this.visualArray.length-1; i >=0; i--) {
if (this.visualArray[i].visualType === type) {
this.visualArray.splice(i, 1);
}
}
}
/**
* Generate a Curve visual based on the provided data and return it.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @return {Curve} A new Curve or undefined if there is no data.
*/
generateCurve(data, options) {
return new Curve(data, options);
}
/**
* Create a Curve visual based on the provided data and add it to this pane.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @param {VisualPanel} panel - An optional panel to associate with this visual.
*/
createCurve(data, options, panel) {
this.addVisual(this.generateCurve(data, options), panel);
}
/**
* Generate an Instants visual based on the provided data and return it.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @return {Instants} A new Instants or undefined if there is no data.
*/
generateInstants(data, options) {
return new Instants(data, options);
}
/**
* Create an Instants visual based on the provided data and add it to this pane.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @param {VisualPanel} panel - An optional panel to associate with this visual.
*/
createInstants(data, options, panel) {
this.addVisual(this.generateInstants(data, options), panel);
}
/**
* Generate a Notes visual based on the provided data and return it.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @return {Notes} A new Notes visual.
*/
generateNotes(data, options) {
return new Notes(data, options);
}
/**
* Create a Notes visual based on the provided data and add it to this pane.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @param {VisualPanel} panel - An optional panel to associate with this visual.
*/
createNotes(data, options, panel) {
this.addVisual(this.generateNotes(data, options), panel);
}
/**
* Generate a Points visual based on the provided data and return it.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @return {Points} A new Points visual.
*/
generatePoints(data, options) {
return new Points(data, options);
}
/**
* Create a Points visual based on the provided data and add it to this pane.
* @param {Object} data - The data.
* @param {Object} options - Optional options.
* @param {VisualPanel} panel - An optional panel to associate with this visual.
*/
createPoints(data, options, panel) {
this.addVisual(this.generatePoints(data, options), panel);
}
/**
* Initialize the panel associated with this pane and attach it to the specified element.
* @param {element} panelElement - The element that will contain the panel.
*/
initializePanel(panelElement) {
this.panelElement = panelElement instanceof d3.selection ? panelElement : d3.select(panelElement);
this.visualPanePanelDiv = this.panelElement.append("div")
.attr("class", "visualPanePanelDiv")
.style("display", "flex")
.style("flex-direction", "column")
.style("font-family", "sans-serif");
if (this.visualPanel !== undefined) {
this.visualPanel.initialize(this.visualPanePanelDiv);
}
this.panelInitialized = true;
}
/**
* Draw the panel associated with this pane.
*/
drawPanel() {
if (this.panelInitialized) {
this.panelOn = true;
if (this.visualPanel !== undefined) {
this.visualPanel.draw();
}
}
}
/**
* Erase the panel associated with this pane.
*/
erasePanel() {
if (this.visualPanePanelDiv !== undefined) {
this.visualPanePanelDiv.selectAll("*").remove();
this.panelOn = false;
}
}
/**
* Initialize this VisualPane.
* @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.width = width;
this.height = height;
this.timeScale = timeScale;
this.visualPaneGroup = this.parentElement.append("g")
.attr("class", "visualPaneGroup");
this.visualPaneGroup.append("rect")
.attr("class", "visualPaneEventRect")
.attr("x", 0)
.attr("y", 0)
.attr("width", this.width)
.attr("height", this.height)
.style("fill", "none");
this.visualGroup = this.visualPaneGroup.append("g")
.attr("class", "visualGroup");
if (this.visualArray.length > 0) {
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].initialize(this.visualGroup, this.width, this.height, this.timeScale);
}
}
this.initialized = true;
}
/**
* Draw this VisualPane and any visuals it contains.
*/
draw() {
if (this.initialized) {
if (this.visualArray.length > 0) {
let curveArray = this.visualArray.filter(visual => visual.visualType === "curve");
let visualHeight;
let visualSpace = 0;
if (this.resizeVisuals) {
visualHeight = this.height/curveArray.length;
}
else {
visualHeight = this.baseHeight/curveArray.length;
visualSpace = Math.abs((this.height-this.baseHeight)/(curveArray.length+1));
}
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].on) {
if (this.visualDrawMode === VisualPane.DRAW_STACKED && this.visualArray[i].visualType === "curve") {
if (this.visualDrawOrder === VisualPane.ORDER_NORMALLY) {
this.visualArray[i].setValueRange(((i+1)*visualHeight)+((i+1)*visualSpace), (i*visualHeight)+((i+1)*visualSpace));
}
else {
this.visualArray[i].setValueRange((this.height-(i*visualHeight))+((i+1)*visualSpace), (this.height-((i+1)*visualHeight)+((i+1)*visualSpace)));
}
}
this.visualArray[i].draw();
}
else {
this.visualArray[i].erase();
}
}
}
}
}
/**
* Draw visuals of the specified type.
* @param {string} type - The type of the visuals.
*/
drawVisualType(type) {
if (this.initialized) {
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].visualType === type) {
this.visualArray[i].on = true;
this.visualArray[i].draw(this.timeScale, this.visualGroup, this.width, this.height);
}
}
}
}
/**
* Erase this VisualPane.
*/
erase() {
if (this.visualPaneGroup !== undefined) {
this.visualPaneGroup.remove();
}
}
/**
* Erase visuals of the specified type from this pane.
* @param {string} type - The type of the visuals.
*/
eraseVisualType(type) {
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].visualType === type) {
this.visualArray[i].on = false;
this.visualArray[i].erase();
}
}
}
/**
* Erase all visuals from this pane.
*/
eraseAllVisuals() {
if (this.visualGroup !== undefined) {
this.visualGroup.selectAll("*").remove();
}
}
/**
* Get the visuals associated with this pane and return them in an array.
* @return {Array} An array of visuals.
*/
getVisuals() {
return this.visualArray;
}
/**
* Determine whether this pane contains visuals of the specified type.
* @param {string} type - The type of visual.
* @return {boolean} True if this pane contains the type; false otherwise.
*/
hasVisualType(type) {
var foundType = this.visualArray.find(visual => visual.visualType === type);
if (foundType !== undefined) {
return true;
}
else {
return false;
}
}
/**
* Change the display indicator of the specified visual type to on or off. Note that setting visuals to off with this method does not erase them
* and setting them to on does not draw them. This method just changes the value of the display indicator to on or off for a later erase or draw.
* @param {string} type - The type of visual.
* @param {boolean} on - True turns the visual type on; false turns it off.
*/
setVisualTypeOn(type, on) {
var visual = this.getVisualType(type);
if (visual.length > 0) {
for (let i =0; i < visual.length; i++) {
visual[i].on = on;
}
}
}
/**
* Get the specified visual type and an array of visuals.
* @param {string} type - The type of visual.
* @return {Array} An array of visuals or undefined if no visuals are found.
*/
getVisualType(type) {
return this.visualArray.filter(visual => visual.visualType === type);
}
/**
* Clear all visuals in the pane.
*/
clearVisuals() {
this.visualArray = [];
if (this.visualPanel !== undefined) {
this.visualPanel.clearVisuals();
}
}
/**
* Activate this pane, allowing it to accept pointer events.
*/
activate() {
if (this.initialized) {
this.visualPaneGroup.style("pointer-events", "visible");
}
}
/**
* Deactivate this pane, preventing it from accepting pointer events.
*/
deactivate() {
if (this.initialized) {
this.visualPaneGroup.style("pointer-events", "none");
}
}
/**
* Raise this pane to the top of the list of panes in its containing TimeFrame.
*/
raise() {
if (this.visualPaneGroup !== undefined) {
this.visualPaneGroup.raise();
}
}
/**
* Lower this pane to the bottom of the list of panes in its containing TimeFrame.
*/
lower() {
if (this.visualPaneGroup !== undefined) {
this.visualPaneGroup.lower();
}
}
/**
* Raises the specified visual such that it will be drawn over all other visuals.
* @param {visual} visual - The visual to raise.
*/
raiseVisual(visual) {
var visualIndex = this.visualArray.indexOf(visual);
if (visualIndex > -1) {
this.visualArray.splice(visualIndex, 1);
this.visualArray.push(visual);
}
}
/**
* Lowers the specified visual such that it will be drawn under all other visuals.
* @param {visual} visual - The visual to lower.
*/
lowerVisual(visual) {
var visualIndex = this.visualArray.indexOf(visual);
if (visualIndex > -1) {
this.visualArray.splice(visualIndex, 1);
this.visualArray.unshift(visual);
}
}
/**
* Takes the current width and height (set with initialize()) and sets them as a base size. This is particularly useful
* for situations in which this VisualPane is being resized but the visuals themselves are set to maintain their size via the
* the resizeVisuals boolean being set to false.
*/
setBaseSize() {
this.baseWidth = this.width;
this.baseHeight = this.height;
}
/**
* 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.visualArray.length; i++) {
if ("insertTime" in this.visualArray[i]) {
this.visualArray[i].insertTime(baseTime, deltaTime);
}
}
}
/**
* Determines whether time in the specified time range can be deleted.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
canDeleteTime(baseTime, deltaTime) {
var canDelete = true;
for (let i = 0; i < this.visualArray.length; i++) {
if ("canDeleteTime" in this.visualArray[i]) {
canDelete = this.visualArray[i].canDeleteTime(baseTime, deltaTime);
if (!canDelete) {
break;
}
}
}
return canDelete;
}
/**
* 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) {
for (let i = 0; i < this.visualArray.length; i++) {
if ("deleteTime" in this.visualArray[i]) {
this.visualArray[i].deleteTime(baseTime, deltaTime);
}
}
}
}
/** A visual panel. */
class VisualPanel {
/**
* Create a visual panel.
* @param {Object[]} visualArray - An array of visuals.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(visualArray, options) {
this.visualArray = visualArray ?? [];
this.options = options;
this.name = options?.name ?? null;
this.showTitle = options?.showTitle ?? true;
this.showSelectAllVisuals = options?.showSelectAllVisuals ?? true;
this.visualSetArray = [];
this.visualSetNameArray = [];
this.currentVisualSet;
this.panelArray = [];
this.panelNameArray = [];
this.panelIndex = 0;
this.parentElement;
this.visualPanelDiv;
this.visualSetDiv;
}
/**
* Add the specified visual to this panel.
* @param {visual} visual - The visual to add.
*/
addVisual(visual) {
this.visualArray.push(visual);
}
/**
* Remove the specified visual from this panel.
* @param {visual} visualToRemove - The visual to remove.
*/
removeVisual(visualToRemove) {
var visualIndex = this.visualArray.findIndex(visual => visual === visualToRemove);
if (visualIndex > -1) {
this.visualArray.splice(visualIndex, 1);
}
}
/**
* Add the specified visual set to this panel.
* @param {VisualSet} visualSet - The visual set to add.
*/
addVisualSet(visualSet) {
this.visualSetArray.push(visualSet);
if (visualSet.name !== null) {
this.visualSetNameArray.push(visualSet.name);
this.visualSetNameArray.sort();
}
}
/**
* Add an panel to this panel.
* @param {VisualPanel} panel - The panel to add.
*/
addPanel(panel) {
this.panelArray.push(panel);
if (panel.name !== null) {
this.panelNameArray.push(panel.name);
}
}
/**
* Initialize this panel by attaching it to the specified parent element.
* @param {d3.selection} parentElement - The parent element.
*/
initialize(parentElement) {
this.parentElement = parentElement;
}
/**
* Draw this panel and any panels that it contains.
*/
draw() {
this.erase();
if (this.showTitle) {
var visualTitleDiv = this.parentElement.append("div")
.attr("class", "visualTitleDiv")
.style("font-family", "sans-serif")
.style("font-weight", "bold")
.style("font-size", "x-large")
.style("padding-bottom", "4%")
.text("Visuals");
}
if (this.panelArray.length > 1) {
this.drawPanelSelect();
}
this.drawPanelItems();
if (this.panelArray.length > 0) {
this.panelArray[this.panelIndex].initialize(this.visualPanelDiv);
this.panelArray[this.panelIndex].drawPanelItems();
}
}
/**
* Draw a select list of any panels that this panel contains.
*/
drawPanelSelect() {
var visualPanelListDiv = this.parentElement.append("div")
.attr("class", "visualPanelListDiv")
.style("padding-bottom", "4%")
.append("select")
.attr("id", "visualPanelSelect")
.on("change", (event) => {
this.panelIndex = parseInt(event.target.value);
this.visualPanelDiv.selectChildren().remove();
this.panelArray[this.panelIndex].initialize(this.visualPanelDiv);
this.panelArray[this.panelIndex].drawPanelItems();
})
.selectAll("option")
.data(this.panelNameArray)
.enter()
.append("option")
.attr("value", (d, i) => i)
.property("selected", (d, i) => this.panelIndex === i)
.text((d) => { return d; });
}
/**
* Draw controls for visuals in a visual set.
*/
drawVisualSetItemControls(visualSetItem) {
this.visualSetDiv.selectAll(".visualItemDiv").remove();
if (visualSetItem.visualArray.length > 0) {
for (let i = 0; i < visualSetItem.visualArray.length; i++) {
var visualItemDiv = this.visualSetDiv.append("div")
.attr("class", "visualItemDiv")
.style("display", "flex")
.style("flex-direction", "row")
.style("align-items", "center");
var visualCheckboxDiv = visualItemDiv.append("div")
.attr("class", "visualCheckboxDiv")
.append("input")
.attr("type", "checkbox")
.property("checked", () => {
if (visualSetItem.visualArray[i].on) {
return true;
}
else {
return false;
}
})
.on("change", (event) => {
visualSetItem.visualArray[i].on = event.target.checked;
if (event.target.checked) {
visualSetItem.visualArray[i].draw();
}
else {
visualSetItem.visualArray[i].erase();
}
});
var visualColorDiv = visualItemDiv.append("div")
.attr("class", "visualColorDiv")
.style("color", () => {
if ("color" in visualSetItem.visualArray[i]) {
return visualSetItem.visualArray[i].color;
}
else if ("fill" in visualSetItem.visualArray[i]) {
return visualSetItem.visualArray[i].fill;
}
})
.style("opacity", "0.60")
.style("padding-left", "2%")
.style("padding-right", "2%")
.text("\u25FC");
if (visualSetItem.visualArray[i].name !== null) {
var visualNameDiv = visualItemDiv.append("div")
.attr("class", "visualNameDiv")
.text(visualSetItem.visualArray[i].name);
}
}
}
}
/**
* Draw a select list of visuals in a visual set and then draw controls for those visuals.
*/
drawVisualSetControls() {
this.visualSetDiv = this.visualPanelDiv.append("div")
.attr("class", "visualSetDiv")
.style("display", "flex")
.style("flex-direction", "column");
var visualSetListDiv = this.visualSetDiv.append("div")
.attr("class", "visualSetListDiv")
.style("padding-bottom", "4%")
.append("select")
.attr("id", "visualSetSelect")
.on("change", (event) => {
this.currentVisualSet = this.visualSetArray[event.target.value];
this.drawVisualSetItemControls(this.currentVisualSet);
})
.selectAll("option")
.data(this.visualSetNameArray)
.enter()
.append("option")
.attr("value", (d, i) => i)
.text((d) => { return d; });
this.currentVisualSet = this.visualSetArray[0];
this.drawVisualSetItemControls(this.currentVisualSet);
}
/**
* Draw any properties of a visual including the names of the properties and their values.
* @param {d3.selection} parentElement - The parent element.
* @param {Array} properties - An array of properties.
*/
drawItemProperties(parentElement, properties) {
var visualPropertyDiv = parentElement.append("div")
.attr("class", "visualPropertyDiv")
.style("display", "flex")
.style("flex-direction", "column")
.style("margin-left", "10ex");
for (let i = 0; i < properties.length; i++) {
var visualPropertyItemDiv = visualPropertyDiv.append("div")
.attr("class", "visualPropertyItemDiv")
.style("display", "flex")
.style("flex-direction", "row");
var visualPropertyNameDiv = visualPropertyItemDiv.append("div")
.attr("class", "visualPropertyNameDiv")
.style("margin-right", "1ex")
.style("white-space", "nowrap")
.text(properties[i][0] + ":");
var visualPropertyValueDiv = visualPropertyItemDiv.append("div")
.attr("class", "visualPropertyValueDiv")
.style("font-weight", "bold")
.text(properties[i][1]);
}
}
/**
* Draw controls for visuals.
*/
drawPanelItems() {
this.visualPanelDiv = this.parentElement.append("div")
.attr("class", "visualPanelDiv")
.style("font-family", "sans-serif");
if (this.visualSetArray.length > 0 && this.visualSetNameArray.length > 0) {
this.drawVisualSetControls();
}
if (this.visualArray.length > 0) {
if (this.showSelectAllVisuals) {
var selectAllListItem = this.visualPanelDiv.append("div")
.attr("class", "selectAllListItem")
.style("margin-bottom", "1ex");
var selectAllCheckboxDiv = selectAllListItem.append("input")
.attr("type", "checkbox")
.style("margin-right", "1ch")
.property("checked", () => {
var checkedCount = 0;
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].on === true) {
checkedCount++;
}
}
return checkedCount === this.visualArray.length;
})
.on("change", (event) => {
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].on = event.target.checked;
if (event.target.checked) {
this.visualArray[i].draw();
}
else {
this.visualArray[i].erase();
}
}
this.visualPanelDiv.remove();
this.drawPanelItems();
});
var selectAllTextDiv = selectAllListItem.append("div")
.attr("class", "selectAllTextDiv")
.text("Select All");
}
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].showControls) {
var visualDiv = this.visualPanelDiv.append("div")
.attr("class", "visualDiv")
.style("display", "flex")
.style("flex-direction", "column");
var visualItemDiv = visualDiv.append("div")
.attr("class", "visualItemDiv")
.style("display", "flex")
.style("flex-direction", "row")
.style("align-items", "center");
var visualCheckboxDiv = visualItemDiv.append("div")
.attr("class", "visualCheckboxDiv")
.append("input")
.attr("type", "checkbox")
.property("checked", () => {
if (this.visualArray[i].on) {
return true;
}
else {
return false;
}
})
.on("change", (event) => {
this.visualArray[i].on = event.target.checked;
if (event.target.checked) {
this.visualArray[i].draw();
}
else {
this.visualArray[i].erase();
}
});
var visualColorDiv = visualItemDiv.append("div")
.attr("class", "visualColorDiv")
.style("color", () => {
if ("color" in this.visualArray[i]) {
return this.visualArray[i].color;
}
else if ("fill" in this.visualArray[i]) {
return this.visualArray[i].fill;
}
})
.style("opacity", "0.60")
.style("padding-left", "2%")
.style("padding-right", "2%")
.text("\u25FC");
if (this.visualArray[i].name !== null) {
var visualNameDiv = visualItemDiv.append("div")
.attr("class", "visualNameDiv")
.attr("id", "visualNameDiv" + i)
.style("white-space", "nowrap")
.text(this.visualArray[i].name);
}
if (this.visualArray[i].showProperties) {
this.drawItemProperties(visualDiv, this.visualArray[i].propertyArray);
}
}
}
}
}
/**
* Erase this panel.
*/
erase() {
if (this.visualPanelDiv !== undefined) {
this.visualPanelDiv.remove();
}
}
/**
* Clear all visuals from this panel.
*/
clearVisuals() {
this.visualArray = [];
}
}
/**
* An editor that works by being overlayed on top of visuals in
* a pane. Note that the VisualEditor only works in terms and x and y.
* It does not have any sense of time since it does not have a time scale.
*/
class VisualEditor {
static DISABLE = -1;
static BRUSH = 0;
static BRUSH_X = 1;
static BRUSH_Y = 2;
static CLICK = 3;
static DRAG = 4;
static MOUSEMOVE = 5;
/**
* Create a visual editor.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(options) {
this.mode = VisualEditor.DISABLE;
this.moveBox = options?.moveBox ?? true;
this.resizeBox = options?.resizeBox ?? true;
this.showAdjuster = options?.showAdjuster ?? false;
this.delineateValues = options?.delineateValues ?? false;
this.fill = options?.fill ?? "#ff0000";
this.fillOpacity = options?.fillOpacity ?? "0.1";
this.stroke = options?.stroke ?? "#ff0000";
this.strokeOpacity = options?.strokeOpacity ?? "1.0";
this.parentElement;
this.paneWidth;
this.paneHeight;
this.initialized = false;
this.visualEditorGroup;
this.editBoxGroup;
this.dx = 0;
this.onBrush = null;
this.onBrushX = null;
this.onBrushY = null;
this.onClick = null;
this.onDragStart = null;
this.onDrag = null;
this.onDragEnd = null;
this.onMoveBoxStart = null;
this.onMoveBoxDrag = null;
this.onMoveBoxEnd = null;
this.onDelineate = null;
}
/**
* Set the specified visual to be edited by this editor, calling setEditor() for the visual to set relevant callbacks.
* @param {object} visual - Configuration options.
*/
setVisual(visual) {
this.clear();
this.erase();
visual.setEditor(this);
}
/**
* Initialize this VisualEditor.
* @param {Element} parentElement - The parent element.
* @param {number} width - The parent width.
* @param {number} height - The parent height.
*/
initialize(parentElement, width, height) {
this.parentElement = parentElement;
this.paneWidth = width;
this.paneHeight = height;
this.initialized = true;
}
/**
* Draw this VisualEditor.
*/
draw() {
if (this.initialized) {
if (this.visualEditorGroup !== undefined) {
this.visualEditorGroup.remove();
}
this.visualEditorGroup = this.parentElement.append("g")
.attr("class", "visualEditorGroup");
if (this.mode === VisualEditor.BRUSH || this.mode === VisualEditor.BRUSH_X || this.mode === VisualEditor.BRUSH_Y) {
let selectBrushGroup = this.visualEditorGroup.append("g")
.attr("class", "selectBrushGroup")
.on("click", (event) => {
event.target.dispatchEvent(
new CustomEvent(
"editorClick",
{bubbles: true, detail: {x: d3.pointer(event)[0], y: d3.pointer(event)[1], append: event.ctrlKey || event.metaKey}}
)
);
});
let selectBrush;
switch (this.mode) {
case VisualEditor.BRUSH:
selectBrush = d3.brush()
.extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
.on("end", (event) => {
if (event.selection !== null && event.sourceEvent !== undefined && this.onBrush !== null) {
this.onBrush(
event.selection[0][0],
event.selection[1][0],
event.selection[1][1],
event.selection[0][1],
event.sourceEvent.ctrlKey || event.sourceEvent.metaKey
);
}
});
break;
case VisualEditor.BRUSH_X:
selectBrush = d3.brushX()
.extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
.on("end", (event) => {
if (event.selection !== null && event.sourceEvent !== undefined && this.onBrushX !== null) {
this.onBrushX(event.selection[0], event.selection[1], event.sourceEvent.ctrlKey || event.sourceEvent.metaKey);
}
});
break;
case VisualEditor.BRUSH_Y:
selectBrush = d3.brushY()
.extent([[1, 1], [this.paneWidth-1, this.paneHeight-1]])
.on("end", (event) => {
if (event.selection !== null && event.sourceEvent !== undefined && this.onBrushY !== null) {
this.onBrushY(event.selection[1], event.selection[0], event.sourceEvent.ctrlKey || event.sourceEvent.metaKey);
}
});
break;
}
selectBrushGroup.call(selectBrush);
selectBrushGroup.select(".selection").style("fill", this.fill);
selectBrushGroup.select(".selection").style("fill-opacity", this.fillOpacity);
selectBrushGroup.select(".selection").style("stroke", this.stroke);
selectBrushGroup.select(".selection").style("stroke-opacity", this.strokeOpacity);
}
else if (this.mode === VisualEditor.CLICK) {
this.visualEditorGroup.append("rect")
.attr("class", "visualEditorEventRect")
.attr("x", 0)
.attr("y", 0)
.attr("width", this.paneWidth)
.attr("height", this.paneHeight)
.style("fill", "none")
.style("pointer-events", "visible")
.style("cursor", "crosshair")
.on("click", (event) => {
if (this.onClick !== null) {
this.onClick(d3.pointer(event)[0], d3.pointer(event)[1]);
}
});
}
else if (this.mode === VisualEditor.DRAG) {
this.visualEditorGroup.append("rect")
.attr("class", "visualEditorEventRect")
.attr("x", 0)
.attr("y", 0)
.attr("width", this.paneWidth)
.attr("height", this.paneHeight)
.style("fill", "none")
.style("pointer-events", "visible")
.style("cursor", "crosshair")
.call(d3.drag()
.on("start", (event) => {
if (this.onDragStart !== null) {
this.onDragStart(d3.pointer(event)[0], d3.pointer(event)[1]);
}
})
.on("drag", (event) => {
if (this.onDrag !== null) {
this.onDrag(event.dx, event.dy);
}
event.sourceEvent.stopPropagation();
})
.on("end", (event) => {
if (this.onDragEnd !== null) {
this.onDragEnd();
}
})
);
}
else if (this.mode === VisualEditor.MOUSEMOVE) {
this.visualEditorGroup.append("rect")
.attr("class", "visualEditorEventRect")
.attr("x", 0)
.attr("y", 0)
.attr("width", this.paneWidth)
.attr("height", this.paneHeight)
.style("fill", "none")
.style("pointer-events", "visible")
.style("cursor", "crosshair")
.on("click", (event) => {
event.target.dispatchEvent(
new CustomEvent(
"editorClick",
{bubbles: true, detail: {x: d3.pointer(event)[0], y: d3.pointer(event)[1], append: event.ctrlKey || event.metaKey}}
)
);
})
.on("mousemove", (event) => {
if (this.delineateValues && event.shiftKey) {
if (Math.abs(event.movementX) > 0) {
this.onDelineate(event.movementX, d3.pointer(event)[0], d3.pointer(event)[1]);
}
event.stopPropagation();
}
});
}
}
}
/**
* Draw the edit box.
* @param {number} startX - Start x.
* @param {number} endX - End x.
* @param {number} startY - Start y.
* @param {number} endY - End y.
*/
drawEditBox(startX, endX, startY, endY) {
if (this.mode !== VisualEditor.DISABLE) {
this.editBoxGroup = this.visualEditorGroup.append("g")
.attr("class", "editBoxGroup");
let startLine = this.editBoxGroup.append("line")
.attr("class", "startLine")
.attr("x1", startX)
.attr("y1", 0)
.attr("x2", startX)
.attr("y2", this.paneHeight)
.style("stroke", this.stroke)
.style("stroke-width", "1");
let moveLine = this.editBoxGroup.append("line")
.attr("class", "moveLine")
.attr("x1", startX)
.attr("y1", 0)
.attr("x2", startX)
.attr("y2", this.paneHeight)
.style("stroke", "#00000000")
.style("stroke-width", "15")
.on("mouseenter", () => {
moveLine.style("cursor", "move");
})
.call(d3.drag()
.on("start", (event) => {
if (this.onMoveBoxStart !== null) {
this.onMoveBoxStart(d3.pointer(event)[0], d3.pointer(event)[1]);
}
})
.on("drag", (event) => {
if (this.moveBox) {
if ((startX + event.dx) > 0 && (endX + event.dx) < this.paneWidth) {
startX += event.dx;
endX += event.dx;
this.dx += event.dx;
}
startLine
.attr("x1", startX)
.attr("x2", startX);
moveLine
.attr("x1", startX)
.attr("x2", startX);
endLine
.attr("x1", endX)
.attr("x2", endX);
resizeLine
.attr("x1", endX)
.attr("x2", endX);
if (this.onMoveBoxDrag !== null) {
this.onMoveBoxDrag(event.dx);
}
event.sourceEvent.stopPropagation();
}
})
.on("end", (event) => {
if (this.moveBox) {
if (this.onMoveBoxEnd !== null) {
this.onMoveBoxEnd(this.dx);
}
this.dx = 0; // Do this AFTER calling onEdit()
}
})
);
let endLine = this.editBoxGroup.append("line")
.attr("class", "endLine")
.attr("x1", endX)
.attr("y1", 0)
.attr("x2", endX)
.attr("y2", this.paneHeight)
.style("stroke", this.stroke)
.style("stroke-width", "1")
let resizeLine = this.editBoxGroup.append("line")
.attr("class", "resizeLine")
.attr("x1", endX)
.attr("y1", 0)
.attr("x2", endX)
.attr("y2", this.paneHeight)
.style("stroke", "#00000000")
.style("stroke-width", "15")
.on("mouseenter", () => {
if (this.resizeBox) {
resizeLine.style("cursor", "ew-resize");
}
})
.call(d3.drag()
.on("drag", (event) => {
if (this.resizeBox) {
if ((endX + event.dx) > startX && (endX + event.dx) < this.paneWidth) {
endX += event.dx;
}
endLine
.attr("x1", endX)
.attr("x2", endX);
resizeLine
.attr("x1", endX)
.attr("x2", endX);
event.sourceEvent.stopPropagation();
}
})
.on("end", (event) => {
if (this.resizeBox) {
if (this.onEdit !== undefined) {
this.onEdit();
}
}
})
);
if (this.showAdjuster) {
this.adjustLineX1 = startX;
this.adjustLineX2 = endX;
this.adjustLineY1 = this.paneHeight/2;
this.adjustLineY2 = this.paneHeight/2;
let adjustLine = this.editBoxGroup.append("line")
.attr("class", "adjustLine")
.attr("x1", this.adjustLineX1)
.attr("y1", this.adjustLineY1)
.attr("x2", this.adjustLineX2)
.attr("y2", this.adjustLineY2)
.style("stroke", this.stroke)
.style("stroke-width", "3");
let adjustHoldLine = this.editBoxGroup.append("line")
.attr("class", "adjustHoldLine")
.attr("x1", this.adjustLineX1)
.attr("y1", this.adjustLineY1)
.attr("x2", this.adjustLineX2)
.attr("y2", this.adjustLineY2)
.style("stroke", "#00000000")
.style("stroke-width", "16")
.on("mouseenter", () => {
adjustHoldLine.style("cursor", "ns-resize");
})
.call(d3.drag()
.on("drag", (event, d) => {
if (
((this.adjustLineY1 + event.dy) >= 0 &&
(this.adjustLineY1 + event.dy) <= this.paneHeight) &&
((this.adjustLineY2 + event.dy) >= 0 &&
(this.adjustLineY2 + event.dy) <= this.paneHeight)
) {
this.adjustLineY1 += event.dy;
this.adjustLineY2 += event.dy;
adjustLine
.attr("y1", this.adjustLineY1)
.attr("y2", this.adjustLineY2);
adjustHoldLine
.attr("y1", this.adjustLineY1)
.attr("y2", this.adjustLineY2);
leftAdjustCircle.attr("cy", this.adjustLineY1);
rightAdjustCircle.attr("cy", this.adjustLineY2);
}
event.sourceEvent.stopPropagation();
})
.on("end", (event, d) => {
// console.log("y1: " + this.adjustLineY1 + " y2: " + this.adjustLineY2);
})
);
let leftAdjustCircle = this.editBoxGroup.append("circle")
.attr("class", "leftAdjustCircle")
.attr("cx", this.adjustLineX1)
.attr("cy", this.adjustLineY1)
.attr("r", "6")
.style("fill", this.stroke)
.style("stroke", "none")
.on("mouseenter", () => {
leftAdjustCircle.style("cursor", "ns-resize");
})
.call(d3.drag()
.on("drag", (event, d) => {
if ((this.adjustLineY1 + event.dy) >= 0 && (this.adjustLineY1 + event.dy) <= this.paneHeight) {
this.adjustLineY1 += event.dy;
}
leftAdjustCircle.attr("cy", this.adjustLineY1);
adjustLine.attr("y1", this.adjustLineY1);
adjustHoldLine.attr("y1", this.adjustLineY1);
event.sourceEvent.stopPropagation();
})
.on("end", (event, d) => {
// console.log("y1: " + this.adjustLineY1 + " y2: " + this.adjustLineY2);
})
);
let rightAdjustCircle = this.editBoxGroup.append("circle")
.attr("class", "rightAdjustCircle")
.attr("cx", this.adjustLineX2)
.attr("cy", this.adjustLineY2)
.attr("r", "6")
.style("fill", this.stroke)
.style("stroke", "none")
.on("mouseenter", () => {
rightAdjustCircle.style("cursor", "ns-resize");
})
.call(d3.drag()
.on("drag", (event, d) => {
if ((this.adjustLineY2 + event.dy) >= 0 && (this.adjustLineY2 + event.dy) <= this.paneHeight) {
this.adjustLineY2 += event.dy;
}
rightAdjustCircle.attr("cy", this.adjustLineY2);
adjustLine.attr("y2", this.adjustLineY2);
adjustHoldLine.attr("y2", this.adjustLineY2);
event.sourceEvent.stopPropagation();
})
.on("end", (event, d) => {
// console.log("y1: " + this.adjustLineY1 + " y2: " + this.adjustLineY2);
})
);
}
}
}
/**
* Erase this VisualEditor.
*/
erase() {
if (this.visualEditorGroup !== undefined) {
this.visualEditorGroup.remove();
}
}
/**
* Erase the edit box.
*/
eraseEditBox() {
if (this.editBoxGroup !== undefined) {
this.editBoxGroup.remove();
}
}
/**
* Clear this VisualEditor by setting all of the callbacks to null.
*/
clear() {
this.onBrush = null;
this.onBrushX = null;
this.onBrushY = null;
this.onClick = null;
this.onDragStart = null;
this.onDrag = null;
this.onDragEnd = null;
this.onMoveBoxStart = null;
this.onMoveBoxDrag = null;
this.onMoveBoxEnd = null;
}
}
/** A Curve visual is a linear curve. */
class Curve {
static AREA = 0;
static LINE = 1;
/**
* Create a curve visual.
* @param {Object[]} data - An array of data.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(data, options) {
this.visualType = "curve";
this.data = data;
this.options = options;
this.name = options?.name ?? null;
this.curveType = options?.curveType ?? Curve.LINE;
this.invert = options?.invert ?? false;
this.on = options?.on ?? true;
this.editable= options?.editable ?? false;
this.color = options?.color ?? "#000000";
this.width = options?.width ?? "2";
this.opacity = options?.opacity ?? "1.0";
this.style = options?.style ?? "solid";
this.selected = false;
this.selectedColor = options?.selectedColor ?? "#ff0000";
this.selectedWidth = options?.selectedWidth ?? "4";
this.selectedOpacity = options?.selectedOpacity ?? "1.0";
this.showAreaOutline = options?.showAreaOutline ?? false;
this.closeAreaOutline = options?.closeAreaOutline ?? false;
this.showProperties = options?.showProperties ?? false;
this.showControls = options?.showControls ?? true;
this.timeName = options?.timeName ?? "time";
this.valueName = options?.valueName ?? "value";
this.valueType = options?.valueType ?? "float";
this.valueDomainStart = options?.valueDomainStart ?? d3.min(this.data, (d) => d[this.valueName]);
this.valueDomainEnd = options?.valueDomainEnd ?? d3.max(this.data, (d) => d[this.valueName]);
this.valueRangeStart = options?.valueRangeStart;
this.valueRangeEnd = options?.valueRangeEnd;
this.propertyArray = [];
this.visualArray = [];
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.valueScale;
this.editor;
this.initialized = false;
this.curveGroup;
this.onDraw;
this.onErase;
this.onEdit;
}
/**
* Append the provided data array to the existing data array in this visual.
* @param {Array} data - The data array to append.
*/
appendData(data) {
this.data = [...this.data, ...data];
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Include the visual type indicated by the type string with the specified options.
* @param {string} visualType - The visual type.
* @param {object} options - Options for the visual type.
*/
includeVisual(visualType, options) {
var visual;
if (visualType === "points") {
visual = new Points(this.data, options);
}
else if (visualType === "instants") {
visual = new Instants(this.data, options);
}
if (visual !== undefined) {
visual.onEdit = this.draw.bind(this);
if (this.initialized) {
visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
}
this.visualArray.push(visual);
}
return visual;
}
/**
* Set the editor for this visual and set editing callbacks.
* @param {object} editor - The VisualEditor to set.
*/
setEditor(editor) {
this.editor = editor;
this.editor.onDelineate = this.delineate.bind(this);
}
/**
* Select this curve and optionally redraw it.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
select(callDraw = true) {
this.selected = true;
if (callDraw) {
this.draw();
}
}
/**
* Deselect this curve and optionally redraw it.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
deselect(callDraw = true) {
this.selected = false;
if (callDraw) {
this.draw();
}
}
/**
* Inserts time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
insertTime(baseTime, deltaTime) {
var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
for (let i = 0; i < shiftData.length; i++) {
shiftData[i][this.timeName] += deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Determines whether time in the specified time range can be deleted.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
canDeleteTime(baseTime, deltaTime) {
var canDelete = true;
var endDeleteTime = baseTime + deltaTime;
var dataFound = this.data.filter((datum) => datum[this.timeName] >= baseTime && datum[this.timeName] <= endDeleteTime);
if (dataFound.length > 0) {
canDelete = false;
}
return canDelete;
}
/**
* Deletes time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
deleteTime(baseTime, deltaTime) {
var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
if (shiftData.length > 0) {
for (let i = 0; i < shiftData.length; i++) {
shiftData[i][this.timeName] -= deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
}
/**
* Set the range for values in this visual.
* @param {number} start - The start of the range.
* @param {number} end - The end of the range.
*/
setValueRange(start, end) {
this.valueRangeStart = start;
this.valueRangeEnd = end;
}
/**
* Initialize this visual.
* @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.initialized = true;
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
}
}
/**
* Draw an area curve.
*/
drawAreaCurve() {
var shape = d3.area()
.curve(d3.curveLinear)
.x(d => this.timeScale(d[this.timeName]))
.y1(d => this.valueScale(d[this.valueName]))
.y0(d => {
if (this.invert) {
return 0;
}
else {
return this.paneHeight;
}
});
this.curveGroup.append("path")
.datum(this.data)
.attr("d", shape)
.attr("class", "visual " + this.visualType + " area")
.style("stroke", "none")
.style("fill", this.selected ? this.selectedColor : this.color)
.style("fill-opacity", this.selected ? this.selectedOpacity : this.opacity)
.style("pointer-events", "none");
if (this.showAreaOutline && this.data.length > 0) {
let outlineData = this.data;
let outlineShape = d3.line()
.curve(d3.curveLinear)
.x(d => this.timeScale(d[this.timeName]))
.y(d => this.valueScale(d[this.valueName]));
if (this.closeAreaOutline) {
let value = this.valueDomainStart;
if (this.invert) {
value = this.valueDomainEnd;
}
let startPair = {};
startPair[this.timeName] = this.data[0][this.timeName];
startPair[this.valueName] = value;
let endPair = {};
endPair[this.timeName] = this.data[this.data.length-1][this.timeName];
endPair[this.valueName] = value;
outlineData = [startPair, ...this.data, endPair];
}
this.curveGroup.append("path")
.datum(outlineData)
.attr("d", outlineShape)
.attr("class", "visual " + this.visualType + " area outline")
.style("fill", "none")
.style("stroke", this.selected ? this.selectedColor : this.color)
.style("stroke-width", this.selected ? this.selectedWidth : this.width)
.style("stroke-opacity", "1.0");
}
}
/**
* Draw a line curve.
*/
drawLineCurve() {
var shape = d3.line()
.curve(d3.curveLinear)
.x(d => this.timeScale(d[this.timeName]))
.y(d => this.valueScale(d[this.valueName]));
var path = this.curveGroup.append("path")
.datum(this.data)
.attr("d", shape)
.attr("class", "visual " + this.visualType)
.style("fill", "none")
.style("stroke", this.selected ? this.selectedColor : this.color)
.style("stroke-width", this.selected ? this.selectedWidth : this.width)
.style("stroke-opacity", this.selected ? this.selectedOpacity : this.opacity);
if (this.style === "dash") {
path.style("stroke-dasharray", "8 4");
}
else if (this.style === "dot") {
path.style("stroke-linecap", "round");
path.style("stroke-dasharray", "1 4");
}
}
/**
* Draw this visual.
*/
draw() {
if (this.initialized) {
// Set the scale in draw() because the value range can change
this.valueScale = d3.scaleLinear()
.domain([this.valueDomainStart, this.valueDomainEnd])
.range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0]);
if (this.curveGroup !== undefined) {
this.curveGroup.remove();
}
this.curveGroup = this.parentElement.append("g")
.attr("class", "curveGroup");
switch (this.curveType) {
case Curve.AREA:
this.drawAreaCurve();
break;
case Curve.LINE:
this.drawLineCurve();
break;
}
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].on) {
this.visualArray[i].draw();
}
else {
this.visualArray[i].erase();
}
}
if (this.onDraw !== undefined) {
this.onDraw();
}
}
}
/**
* Edit values for points by moving the mouse.
* @param {number} dx - Delta x.
* @param {number} pointerX - Pointer x.
* @param {number} pointerY - Pointer y.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
delineate(dx, pointerX, pointerY, callDraw = true) {
var dataDelineated = [];
if (this.editable) {
let dataFound = [];
if (dx < 0) {
dataFound = this.data.filter(p => {
let x = this.timeScale(p[this.timeName]);
return x > pointerX && x < pointerX - dx;
});
}
else if (dx > 0) {
dataFound = this.data.filter(p => {
let x = this.timeScale(p[this.timeName]);
return x > pointerX - dx && x < pointerX;
});
}
if (dataFound.length > 0) {
dataDelineated = dataFound;
for (let i = 0; i < dataFound.length; i++) {
if (this.valueType === "integer") {
dataFound[i][this.valueName] = Math.round(this.valueScale.invert(pointerY));
}
else if (this.valueType === "float") {
dataFound[i][this.valueName] = this.valueScale.invert(pointerY);
}
}
if (this.onEdit !== undefined) {
this.onEdit();
}
if (callDraw) {
this.draw();
}
}
}
return dataDelineated;
}
/**
* Erase this visual.
*/
erase() {
if (this.curveGroup !== undefined) {
this.curveGroup.remove();
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].erase();
}
if (this.onErase !== undefined) {
this.onErase();
}
}
}
}
/**
* A Grid visual adds vertical and optionally horizontal lines. The placement of vertical lines is derived from the axis ticks in the
* TimeFrame that contains the pane that contains this grid. Horizontal line spacing matches the vertical line spacing by default.
*/
class Grid {
/**
* Create a grid visual.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(options) {
this.visualType = "grid";
this.options = options;
this.on = options?.on ?? true;
this.color = options?.color ?? "#000000";
this.opacity = options?.opacity ?? "1.0";
this.strokeWidth = options?.strokeWidth ?? 1;
this.showHorizontalLines = options?.showHorizontalLines ?? true;
this.division = options?.division ?? 5;
this.showControls = options?.showControls ?? true;
this.propertyArray = [];
this.parentElement;
this.frameWidth;
this.frameHeight;
this.timeScale;
this.initialized = false;
this.gridGroup;
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Initialize this visual.
* @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.frameWidth = width;
this.frameHeight = height;
this.timeScale = timeScale;
this.initialized = true;
}
/**
* Draw this visual.
*/
draw() {
if (this.initialized && this.on) {
let gridTimes = [];
let tickTimes = this.timeScale.ticks();
let tickTimeIncrement = tickTimes[1] - tickTimes[0];
let gridTimeIncrement = tickTimeIncrement/this.division;
let timeScaleStart = this.timeScale.domain()[0];
if ((tickTimes[0]-timeScaleStart) > gridTimeIncrement) {
// Add any incremental times before the first tick time
let time = tickTimes[0];
while (time > timeScaleStart) {
time -= gridTimeIncrement;
gridTimes.push(time);
}
gridTimes.reverse(); // Put the times in the right order before removing the first time with shift()
}
for (let i = 0; i < tickTimes.length; i++) {
gridTimes.push(tickTimes[i]);
for (let j = 1; j < 5; j++) {
let time = tickTimes[i]+(j*gridTimeIncrement);
if (time < this.timeScale.domain()[1]) {
gridTimes.push(time);
}
}
}
gridTimes.shift(); // Remove first time
if (gridTimes[gridTimes.length-1] === tickTimes[tickTimes.length-1]) {
gridTimes.pop(); // Remove last time
}
if (this.gridGroup !== undefined) {
this.gridGroup.remove();
}
this.gridGroup = this.parentElement.append("g")
.attr("class", "gridGroup");
if (gridTimes.length > 0) {
this.gridGroup.selectAll("verticalGridLines")
.data(gridTimes)
.join("line")
.attr("class", "verticalGridLine")
.attr("x1", d => this.timeScale(d))
.attr("y1", 0)
.attr("x2", d => this.timeScale(d))
.attr("y2", this.frameHeight)
.style("stroke", this.color)
.style("stroke-width", this.strokeWidth)
.style("opacity", this.opacity);
if (this.showHorizontalLines && gridTimes.length > 1) {
// If the grid time increment is less than the domain start time, it will be clamped to 0!
let yIncrement = this.timeScale(this.timeScale.domain()[0]+gridTimeIncrement) - this.timeScale(this.timeScale.domain()[0]);
let yStart = this.frameHeight-yIncrement;
for (let y = yStart; y > 0; y -= yIncrement) {
this.gridGroup.append("line")
.attr("class", "grid horizontalGridLine")
.attr("x1", 0)
.attr("y1", y)
.attr("x2", this.frameWidth)
.attr("y2", y)
.style("stroke", this.color)
.style("opacity", this.opacity)
.style("stroke-width", this.strokeWidth);
}
}
}
}
}
/**
* Erase this visual.
*/
erase() {
if (this.gridGroup !== undefined) {
this.gridGroup.remove();
}
}
}
/**
* An Instants visual is a set of vertical lines that are placed at certain times based on the data. Each instant can have its own label.
*/
class Instants {
static TIME = 0;
static TIME_VALUE = 1;
static TIME_VALUE_RANGE = 2;
/**
* Create an instants visual.
* @param {Object[]} data - An array of data.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(data, options) {
this.visualType = "instants";
this.data = data;
this.options = options;
this.name = options?.name ?? null;
this.instantsType = options?.instantsType ?? Instants.TIME;
this.on = options?.on ?? true;
this.editable = options?.editable ?? false;
this.color = options?.color ?? "#000000";
this.width = options?.width ?? 2;
this.opacity = options?.opacity ?? 1.0;
this.opacityMin = options?.opacityMin ?? 0.1;
this.opacityMax = options?.opacityMax ?? 1.0;
this.style = options?.style ?? "solid";
this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
this.showProperties = options?.showProperties ?? false;
this.showControls = options?.showControls ?? true;
this.timeName = options?.timeName ?? "time";
this.valueName = options?.valueName ?? "value";
this.valueStartName = options?.valueStartName ?? "valueStart";
this.valueEndName = options?.valueEndName ?? "valueEnd";
this.valueType = options?.valueType ?? "float";
this.valueDomainStart = options?.valueDomainStart ?? d3.min(this.data, (d) => d[this.valueName]);
this.valueDomainEnd = options?.valueDomainEnd ?? d3.max(this.data, (d) => d[this.valueName]);
this.valueRangeStart = options?.valueRangeStart;
this.valueRangeEnd = options?.valueRangeEnd;
this.mapValueToOpacity = options?.mapValueToOpacity ?? false;
this.propertyArray = [];
this.visualArray = [];
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.valueScale;
this.initialized = false;
this.instantsGroup;
this.onDraw;
this.onErase;
this.onEdit;
if (typeof this.width === "string") {
this.width = parseFloat(this.width.toString().replace(/px/g, ""));
}
}
/**
* Append the provided data array to the existing data array in this visual.
* @param {Array} data - The data array to append.
*/
appendData(data) {
this.data = [...this.data, ...data];
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Include the visual type indicated by the type string with the specified options.
* @param {string} visualType - The visual type.
* @param {object} options - Options for the visual type.
*/
includeVisual(visualType, options) {
var visual;
if (visualType === "curve") {
visual = new Curve(this.data, options);
}
else if (visualType === "points") {
visual = new Points(this.data, options);
}
if (visual !== undefined) {
visual.onEdit = this.draw.bind(this);
if (this.initialized) {
visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
}
this.visualArray.push(visual);
}
return visual;
}
/**
* Filter the data using the domain values for the time scale.
*/
timeFilterData() {
return this.data.filter((d) => d[this.timeName] > this.timeScale.domain()[0] && d[this.timeName] < this.timeScale.domain()[1]);
}
/**
* Convert the specified value to an opacity based on the value domain and the min and max opacity values.
* @param {number} value - A number value.
*/
valueToOpacity(value) {
return this.opacityMin + ((value - this.valueDomainStart) * (this.opacityMax - this.opacityMin) / (this.valueDomainEnd - this.valueDomainStart));
}
/**
* Inserts time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
insertTime(baseTime, deltaTime) {
var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
for (let i = 0; i < shiftData.length; i++) {
shiftData[i][this.timeName] += deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Determines whether time in the specified time range can be deleted.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
canDeleteTime(baseTime, deltaTime) {
var canDelete = true;
var endDeleteTime = baseTime + deltaTime;
var dataFound = this.data.filter((datum) => datum[this.timeName] >= baseTime && datum[this.timeName] <= endDeleteTime);
if (dataFound.length > 0) {
canDelete = false;
}
return canDelete;
}
/**
* Deletes time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
deleteTime(baseTime, deltaTime) {
let shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
for (let i = 0; i < shiftData.length; i++) {
shiftData[i][this.timeName] -= deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Initialize this visual.
* @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.initialized = true;
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
}
}
/**
* Draw time instants using the specified data. Values provided in the data are ignored.
* @param {object} data - The data.
*/
drawTimeInstants(data) {
this.instantsGroup.selectAll()
.data(data)
.join("line")
.attr("class", "visual " + this.visualType)
.attr("x1", d => this.timeScale(d[this.timeName]))
.attr("y1", 0)
.attr("x2", d => this.timeScale(d[this.timeName]))
.attr("y2", this.paneHeight)
.style("stroke", d => d.color ?? this.color)
.style("stroke-width", d => d.width ?? this.width)
.style("opacity", (d) => {
if (this.mapValueToOpacity) {
return this.valueToOpacity(d[this.valueName]);
}
else {
return d.opacity ?? this.opacity;
}
});
this.instantsGroup.selectAll()
.data(data)
.join("line")
.attr("class", "visual " + this.visualType)
.attr("x1", d => this.timeScale(d[this.timeName]))
.attr("y1", 0)
.attr("x2", d => this.timeScale(d[this.timeName]))
.attr("y2", this.paneHeight)
.style("stroke", "#ffffff00")
.style("stroke-width", d => 6*(d.width ?? this.width))
.on("mouseenter", (event, d) => {
if (!this.alwaysShowLabel) {
this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueName]);
}
})
.on("mouseleave", (event, d) => {
if (!this.alwaysShowLabel) {
this.eraseLabel(this.instantsGroup);
}
});
}
/**
* Draw time and value instants using the specified data.
* @param {object} data - The data.
*/
drawTimeValueInstants(data) {
this.valueScale = d3.scaleLinear()
.range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0])
.domain([this.valueDomainStart, this.valueDomainEnd]);
this.instantsGroup.selectAll()
.data(data)
.join("line")
.attr("class", "visual " + this.visualType)
.attr("x1", d => this.timeScale(d[this.timeName]))
.attr("y1", d => {
if (typeof d[this.valueName] === "number") {
return this.valueScale(d[this.valueName]);
}
else {
return 0;
}
})
.attr("x2", d => this.timeScale(d[this.timeName]))
.attr("y2", this.paneHeight)
.style("stroke", d => d.color ?? this.color)
.style("stroke-width", d => d.width ?? this.width)
.style("opacity", (d) => {
if (this.mapValueToOpacity) {
return this.valueToOpacity(d[this.valueName]);
}
else {
return d.opacity ?? this.opacity;
}
});
this.instantsGroup.selectAll()
.data(data)
.join("line")
.attr("class", "visual " + this.visualType)
.attr("x1", d => this.timeScale(d[this.timeName]))
.attr("y1", d => {
if (typeof d[this.valueName] === "number") {
return this.valueScale(d[this.valueName]);
}
else {
return 0;
}
})
.attr("x2", d => this.timeScale(d[this.timeName]))
.attr("y2", this.paneHeight)
.style("stroke", "#ffffff00")
.style("stroke-width", d => 6*(d.width ?? this.width))
.on("mouseenter", (event, d) => {
if (!this.alwaysShowLabel) {
this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueName]);
}
})
.on("mouseleave", (event, d) => {
if (!this.alwaysShowLabel) {
this.eraseLabel(this.instantsGroup);
}
});
}
/**
* Draw time and value range instants using the specified data.
* @param {object} data - The data.
*/
drawTimeValueRangeInstants(data) {
this.valueScale = d3.scaleLinear()
.range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0])
.domain([this.valueDomainStart, this.valueDomainEnd]);
this.instantsGroup.selectAll()
.data(data)
.join("line")
.attr("class", "visual " + this.visualType)
.attr("x1", d => this.timeScale(d[this.timeName]))
.attr("y1", d => {
if (this.valueStartName in d) {
return this.valueScale(d[this.valueStartName]);
}
else if (this.valueName in d && typeof d[this.valueName] === "number") {
return this.valueScale(d[this.valueName]);
}
else {
return 0;
}
})
.attr("x2", d => this.timeScale(d[this.timeName]))
.attr("y2", d => {
if (this.valueEndName in d) {
return this.valueScale(d[this.valueEndName]);
}
else {
return this.paneHeight;
}
})
.style("stroke", d => d.color ?? this.color)
.style("stroke-width", d => d.width ?? this.width)
.style("opacity", d => d.opacity ?? this.opacity);
this.instantsGroup.selectAll()
.data(data)
.join("line")
.attr("class", "visual " + this.visualType)
.attr("x1", d => this.timeScale(d[this.timeName]))
.attr("y1", d => {
if (this.valueStartName in d) {
return this.valueScale(d[this.valueStartName]);
}
else if (this.valueName in d && typeof d[this.valueName] === "number") {
return this.valueScale(d[this.valueName]);
}
else {
return 0;
}
})
.attr("x2", d => this.timeScale(d[this.timeName]))
.attr("y2", d => {
if (this.valueEndName in d) {
return this.valueScale(d[this.valueEndName]);
}
else {
return this.paneHeight;
}
})
.style("stroke", "#ffffff00")
.style("stroke-width", d => 6*(d.width ?? this.width))
.on("mouseenter", (event, d) => {
if (!this.alwaysShowLabel) {
if ("valueStart" in d && "valueEnd" in d) {
this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueStartName].toString() + " - " + d[this.valueEndName].toString());
}
else if ("value" in d) {
this.drawLabel(this.timeScale(d[this.timeName]), d[this.valueName]);
}
}
})
.on("mouseleave", (event, d) => {
if (!this.alwaysShowLabel) {
this.eraseLabel(this.instantsGroup);
}
});
}
/**
* Draw this visual.
*/
draw() {
if (this.initialized) {
if (this.instantsGroup !== undefined) {
this.instantsGroup.remove();
}
this.instantsGroup = this.parentElement.append("g")
.attr("class", "instantsGroup")
.style("pointer-events", "visible")
.on("mouseenter", () => {
this.instantsGroup.style("cursor", "pointer");
});
// Always filter the data by the domain of the time scale
var timeFilteredData = this.timeFilterData();
switch (this.instantsType) {
case Instants.TIME:
this.drawTimeInstants(timeFilteredData);
break;
case Instants.TIME_VALUE:
this.drawTimeValueInstants(timeFilteredData);
break;
case Instants.TIME_VALUE_RANGE:
this.drawTimeValueRangeInstants(timeFilteredData);
break;
}
if (this.alwaysShowLabel) {
this.drawAllLabels(timeFilteredData);
}
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].on) {
this.visualArray[i].draw();
}
else {
this.visualArray[i].erase();
}
}
if (this.onDraw !== undefined) {
this.onDraw();
}
}
}
/**
* Edit values for points by moving the mouse.
* @param {number} dx - Delta x.
* @param {number} pointerX - Pointer x.
* @param {number} pointerY - Pointer y.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
delineate(dx, pointerX, pointerY, callDraw = true) {
var dataDelineated = [];
var timeFilteredData = this.timeFilterData();
if (this.instantsType !== Instants.TIME && this.editable) {
let dataFound = [];
if (dx < 0) {
dataFound = timeFilteredData.filter(p => {
let x = this.timeScale(p[this.timeName]);
return x > pointerX && x < pointerX - dx;
});
}
else if (dx > 0) {
dataFound = timeFilteredData.filter(p => {
let x = this.timeScale(p[this.timeName]);
return x > pointerX - dx && x < pointerX;
});
}
if (dataFound.length > 0) {
dataDelineated = dataFound;
for (let i = 0; i < dataFound.length; i++) {
if (this.valueType === "integer") {
dataFound[i][this.valueName] = Math.round(this.valueScale.invert(pointerY));
}
else if (this.valueType === "float") {
dataFound[i][this.valueName] = this.valueScale.invert(pointerY);
}
}
if (this.onEdit !== undefined) {
this.onEdit();
}
if (callDraw) {
this.draw();
}
}
}
return dataDelineated;
}
/**
* Draw label for all instants.
* @param {Object} filteredData - Filtered data with labels.
*/
drawAllLabels(filteredData) {
this.instantsGroup.selectAll(".instantLabel").remove();
var textAnchor;
var labelGroup = this.instantsGroup.append("g")
.selectAll("g")
.data(filteredData)
.join("g")
.attr("class", "visual " + this.visualType + " instantLabel")
.attr("transform", (d) => "translate(" + this.timeScale(d[this.timeName]) + ", " + 1 + ")");
var labelText = labelGroup.append("text")
.attr("x", 0)
.attr("y", "2ex")
.style("color", "black")
.style("text-anchor", "middle")
.style("dominant-baseline", "middle")
.text((d) => d[this.valueName]);
if (labelText.node() !== null) {
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");
labelRect.lower();
}
}
/**
* Draw the label for an instant.
* @param {number} x - The x value.
* @param {string} value - The label.
*/
drawLabel(x, label) {
var textAnchor;
if (x < this.paneWidth/2) {
x = x + this.width/2 + 4;
textAnchor = "start";
}
else {
x = x - this.width/2 - 4;
textAnchor = "end";
}
var labelGroup = this.instantsGroup.append("g")
.attr("class", "visual " + this.visualType + " instantLabel")
.attr("transform", "translate(" + x + ", " + 1 + ")");
var labelText = labelGroup.append("text")
.attr("x", 0)
.attr("y", "2ex")
.style("color", "black")
.style("text-anchor", textAnchor)
.style("dominant-baseline", "middle")
.text(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");
labelRect.lower();
}
/**
* Erase all labels.
*/
eraseLabel() {
this.instantsGroup.selectAll(".instantLabel").remove();
}
/**
* Erase this visual.
*/
erase() {
if (this.instantsGroup !== undefined) {
this.instantsGroup.remove();
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].erase();
}
if (this.onErase !== undefined) {
this.onErase();
}
}
}
}
/**
* Notes are a visual derived from MIDI note data. Unlike MIDI notes, individual notes in a Notes visual have a start and an end time with
* the width of the note representing the length of the note in seconds. The position of the note on the Y axis represents the MIDI note value.
* The velocity of MIDI notes is represented by the opacity of the notes with quieter notes appearing lighter and louder notes appearing
* darker.
*/
class Notes {
/**
* Create a notes visual with the specified data and options.
* @param {Object[]} data - An array of data.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(data, options) {
this.visualType = "notes";
this.data = data;
this.options = options;
this.on = options?.on ?? true;
this.selectable = options?.selectable ?? true;
this.editable = options?.editable ?? false;
this.noteRangeMin = options?.noteRangeMin ?? 21; // Note range min and max default to the piano note range
this.noteRangeMax = options?.noteRangeMax ?? 108;
this.velocityRangeMin = options?.velocityRangeMin ?? 1; // Start with 1 since velocity = 0 is a note off
this.velocityRangeMax = options?.velocityRangeMax ?? 127;
this.notesStartHidden = options?.notesStartHidden ?? false;
this.highlightNotes = options?.highlightNotes ?? false;
this.noteHeight = options?.noteHeight ?? 0;
this.fill = options?.fill ?? "#000000";
this.selectedFill = options?.selectedFill ?? "#0000ff";
this.highlightedFill = options?.highlightedFill ?? "#ff0000";
this.fillOpacity = options?.fillOpacity ?? 1.0;
this.fillOpacityMin = options?.fillOpacityMin ?? 0.1;
this.fillOpacityMax = options?.fillOpacityMax ?? 1.0;
this.useVelocityOpacity = options?.useVelocityOpacity ?? true;
this.backFill = options?.backFill ?? "#ffffff";
this.stroke = options?.stroke ?? "#000000";
this.selectedStroke = options?.selectedStroke ?? "#0000ff";
this.highlightedStroke = options?.highlightedStroke ?? "#ff0000";
this.strokeOpacity = options?.strokeOpacity ?? 1.0;
this.strokeWidth = options?.strokeWidth ?? 1;
this.pointerEvents = options?.pointerEvents ?? "visiblePainted";
this.editorFill = options?.editorFill ?? "#0000ff";
this.editorStroke = options?.editorStroke ?? "#0000ff";
this.showProperties = options?.showProperties ?? false;
this.showControls = options?.showControls ?? true;
this.acceptEditorClick = options?.acceptEditorClick ?? false;
this.dataTemplate;
this.propertyArray = [];
this.visualArray = [];
this.onSelect;
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.initialized = false;
this.notesGroup;
this.notesEventGroup;
this.editNotesGroup;
this.selectedNotes = [];
this.onDraw;
this.onErase;
this.addNoteRect;
this.editor;
this.onEdit;
this.noteScale = d3.scaleLinear()
.domain([this.noteRangeMin, this.noteRangeMax])
.clamp(true);
this.velocityScale = d3.scaleLinear()
.domain([this.velocityRangeMin, this.velocityRangeMax])
.clamp(true);
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Append the provided data array to the existing data array in this visual.
* @param {Array} data - The data array to append.
* @param {number} offsetTime - An optional offset time for the data.
*/
appendData(data, offsetTime) {
if (offsetTime !== undefined) {
for (let i = 0; i < data.length; i++) {
data[i].startTime += offsetTime;
data[i].endTime += offsetTime;
}
}
this.data = [...this.data, ...data];
this.data.sort((a, b) => a.startTime - b.startTime);
}
/**
* Set a data template object for adding notes.
* @param {Object} template - A template object.
*/
setDataTemplate(template) {
this.dataTemplate = template;
}
/**
* Include the visual type indicated by the type string with the specified options.
* @param {string} visualType - The visual type.
* @param {object} options - Options for the visual type.
*/
includeVisual(visualType, options) {
var visual;
if (visualType === "curve") {
visual = new Curve(this.data, options);
}
else if (visualType === "instants") {
visual = new Instants(this.data, options);
}
else if (visualType === "points") {
visual = new Points(this.data, options);
}
if (visual !== undefined) {
visual.onEdit = this.draw.bind(this);
if (this.initialized) {
visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
}
this.visualArray.push(visual);
}
return visual;
}
/**
* Set the specified value for the specified key. The properties "note" and "velocity", if specified, will be handled by
* setNoteNumber() and setVelocity() respectively.
* @param {string} key - The key of the property.
* @param {number} value - The value to set for the property.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
setProperty(key, value, callDraw = true) {
if (this.selectedNotes.length > 0) {
for (let i = 0; i < this.selectedNotes.length; i++) {
if (key in this.selectedNotes[i]) {
if (key === "note") {
this.setNoteNumber(value);
}
else if (key === "velocity") {
this.setVelocity(value);
}
else {
this.selectedNotes[i][key] = value;
this.selectedNotes[i].selected = false;
}
}
}
this.selectedNotes.length = 0;
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
}
/**
* Set the editor for this visual and set editing callbacks.
* @param {object} editor - The VisualEditor to set.
*/
setEditor(editor) {
this.editor = editor;
this.editor.onBrush = this.select.bind(this);
this.editor.onDragStart = this.startNote.bind(this);
this.editor.onDrag = this.dragNote.bind(this);
this.editor.onDragEnd = this.endNote.bind(this);
this.editor.onMoveBoxStart = this.startShiftingNotes.bind(this);
this.editor.onMoveBoxDrag = this.shiftNotes.bind(this);
this.editor.onMoveBoxEnd = this.endShiftingNotes.bind(this);
}
/**
* Get the note value scale for this visal.
*/
getNoteScale() {
return this.noteScale;
}
/**
* Get any notes in the specified time and note ranges and return them in an array.
* @param {number} t1 - The start of the time range.
* @param {number} t2 - The end of the time range.
* @param {number} note1 - The start of the note range.
* @param {number} note2 - The end of the note range.
* @return {Array} An array of notes in the specified ranges or an empty array.
*/
getNotesWithinRanges(t1, t2, note1, note2) {
return this.data.filter(note => (note.startTime > t1 && note.endTime < t2) && (note.note > note1 && note.note < note2));
}
/**
* Change the visibility or the color of notes with time values less than the specified time.
* @param {number} time - The time up to which all earlier notes should change.
*/
updateNotes(time) {
if (this.notesGroup !== undefined && this.notesStartHidden) {
this.notesGroup.selectAll(".noteRectBack")
.attr("visibility", "hidden");
this.notesGroup.selectAll(".noteRect")
.attr("visibility", "hidden");
}
if (this.notesGroup !== undefined && (this.notesStartHidden || this.highlightNotes)) {
this.notesGroup.selectAll(".noteRectBack")
.filter(d => d.startTime <= time)
.attr("visibility", "visible");
this.notesGroup.selectAll(".noteRect")
.filter(d => d.startTime <= time)
.style("stroke", () => {
if (this.highlightNotes) {
return this.highlightedStroke;
}
})
.style("fill", () => {
if (this.highlightNotes) {
return this.highlightedFill;
}
})
.attr("visibility", "visible");
}
}
/**
* Change the visibility or the color of notes back to their original values.
*/
resetNotes() {
if (this.notesGroup !== undefined && this.notesStartHidden) {
this.notesGroup.selectAll(".noteRectBack")
.attr("visibility", "hidden");
this.notesGroup.selectAll(".noteRect")
.attr("visibility", "hidden");
}
if (this.notesGroup !== undefined && this.highlightNotes) {
this.notesGroup.selectAll(".noteRect")
.style("stroke", this.stroke)
.style("fill", this.fill);
}
}
/**
* Select notes in the specified time and note number ranges.
* @param {number} x1 - The start of the x range.
* @param {number} x2 - The end of the x range.
* @param {number} y1 - The start of the y range.
* @param {number} y2 - The end of the y range.
* @param {boolean} append - Append selected notes to previously selected notes.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
select(x1, x2, y1, y2, append, callDraw = true) {
var t1 = x1 === null ? 0 : this.timeScale.invert(x1);
var t2 = x2 === null ? this.timeScale.domain()[1] : this.timeScale.invert(x2);
var note1 = y1 === null ? this.valueRangeStart : Math.round(this.noteScale.invert(y1));
var note2 = y2 === null ? this.valueRangeEnd : Math.round(this.noteScale.invert(y2));
if (this.selectedNotes.length === 0) {
this.selectedNotes = this.getNotesWithinRanges(t1, t2, note1, note2);
}
else {
if (append) {
this.selectedNotes = this.selectedNotes.concat(this.getNotesWithinRanges(t1, t2, note1, note2));
}
else {
for (let i = 0; i < this.selectedNotes.length; i++) {
this.selectedNotes[i].selected = false;
}
this.selectedNotes.length = 0;
this.selectedNotes = this.getNotesWithinRanges(t1, t2, note1, note2);
}
}
for (let i = 0; i < this.selectedNotes.length; i++) {
this.selectedNotes[i].selected = true;
}
if (callDraw) {
this.draw();
}
if (this.onSelect !== undefined) {
this.onSelect(this.selectedNotes);
}
}
/**
* Deselect selected notes.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
deselect(callDraw = true) {
for (let i = 0; i < this.selectedNotes.length; i++) {
this.selectedNotes[i].selected = false;
}
this.selectedNotes.length = 0;
if (callDraw) {
this.draw();
}
}
/**
* Get any selected notes.
* @return {Array} An array of selected notes or an empty array.
*/
getSelectedNotes() {
return this.selectedNotes;
}
/**
* Set the note number for a selected note. For this to work, only one number can be selected.
* @param {number} number - The note number.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
setNoteNumber(number, callDraw = true) {
if (this.selectedNotes.length === 1 && (number >= this.noteRangeMin && number <= this.noteRangeMax)) {
let currentValue = this.selectedNotes[0].note;
this.selectedNotes[0].note = number;
if (!this.canEditNote(this.selectedNotes[0], this.selectedNotes[0].startTime, this.selectedNotes[0].endTime)) {
this.selectedNotes[0].note = currentValue;
}
else {
if (this.onEdit !== undefined) {
this.onEdit();
}
}
if (callDraw) {
this.draw();
}
}
}
/**
* Adjust the note number by the specified amount for selected notes.
* @param {number} amount - The amount to adjust.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
adjustNoteNumber(amount, callDraw = true) {
var canAdjust = true;
for (let i = 0; i < this.selectedNotes.length; i++) {
if (!this.canSetNoteNumber(this.selectedNotes[i].note + amount, this.selectedNotes[i].startTime, this.selectedNotes[i].endTime)) {
canAdjust = false;
break;
}
}
if (canAdjust) {
for (let i = 0; i < this.selectedNotes.length; i++) {
this.selectedNotes[i].note += amount;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
if (callDraw) {
this.draw();
}
}
}
/**
* Set velocity to the specified amount for selected notes.
* @param {number} velocity - The velocity.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
setVelocity(velocity, callDraw = true) {
if (this.selectedNotes.length > 0) {
for (let i = 0; i < this.selectedNotes.length; i++) {
if (velocity >= this.velocityRangeMin && velocity <= this.velocityRangeMax) {
this.selectedNotes[i].velocity = velocity;
}
}
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
}
/**
* Adjust the velocity by the specified amount for selected notes.
* @param {number} amount - The amount to adjust.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
adjustVelocity(amount, callDraw = true) {
if (this.selectedNotes.length > 0) {
for (let i = 0; i < this.selectedNotes.length; i++) {
if (this.selectedNotes[i].velocity + amount >= this.velocityRangeMin && this.selectedNotes[i].velocity + amount <= this.velocityRangeMax) {
this.selectedNotes[i].velocity += amount;
}
}
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
}
/**
* Delete any selected notes.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
deleteSelectedNotes(callDraw = true) {
if (this.selectedNotes.length > 0) {
for (let i = 0; i < this.selectedNotes.length; i++) {
let noteIndex = this.data.findIndex(note => note === this.selectedNotes[i]);
this.data.splice(noteIndex, 1);
}
this.selectedNotes.length = 0;
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
}
/**
* Inserts time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
insertTime(baseTime, deltaTime) {
var shiftNotes = this.data.filter((note) => note.startTime > baseTime);
for (let i = 0; i < shiftNotes.length; i++) {
shiftNotes[i].startTime += deltaTime;
shiftNotes[i].endTime += deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Determines whether time in the specified time range can be deleted.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
canDeleteTime(baseTime, deltaTime) {
var canDelete = true;
var endDeleteTime = baseTime + deltaTime;
var notesFound = this.data.filter((note) => note.endTime >= baseTime && note.startTime <= endDeleteTime);
if (notesFound.length > 0) {
canDelete = false;
}
return canDelete;
}
/**
* Deletes time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
deleteTime(baseTime, deltaTime) {
let shiftNotes = this.data.filter((note) => note.startTime > baseTime);
for (let i = 0; i < shiftNotes.length; i++) {
shiftNotes[i].startTime -= deltaTime;
shiftNotes[i].endTime -= deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Determines whether the specified note can be added by looking for any notes with the same note number in the specified time range.
* @param {Object} note - The note to edit.
* @param {number} startTime - The start time of the note.
* @param {number} endTime - The end time of the note.
* @return {boolean} True if the added note would not overlap any existing notes; false otherwise.
*/
canAddNote(noteNumber, startTime, endTime) {
let can = true;
for (let i = 0; i < this.data.length; i++) {
if (this.data[i].note === noteNumber &&
((startTime >= this.data[i].startTime && startTime <= this.data[i].endTime) ||
(endTime >= this.data[i].startTime && endTime <= this.data[i].endTime) ||
(this.data[i].startTime >= startTime && this.data[i].startTime <= endTime) ||
(this.data[i].endTime >= startTime && this.data[i].endTime <= endTime))
) {
can = false;
break;
}
}
return can;
}
/**
* Determines whether the specified note can be edited by looking for any notes with the same note number in the specified time range.
* @param {Object} note - The note to edit.
* @param {number} newStartTime - The possible start time of the note.
* @param {number} newEndTime - The possible end time of the note.
* @return {boolean} True if the note would not overlap any existing notes; false otherwise.
*/
canEditNote(note, newStartTime, newEndTime) {
var can = true;
for (let i = 0; i < this.data.length; i++) {
if (this.data[i] === note || (this.selectedNotes.includes(this.data[i]) && this.data[i] !== note)) {
// This is the note that's being edited or is one of the other selected notes that's being edited
continue;
}
if (this.data[i].note === note.note &&
((newStartTime >= this.data[i].startTime && newStartTime <= this.data[i].endTime) ||
(newEndTime >= this.data[i].startTime && newEndTime <= this.data[i].endTime) ||
(this.data[i].startTime >= newStartTime && this.data[i].startTime <= newEndTime) ||
(this.data[i].endTime >= newStartTime && this.data[i].endTime <= newEndTime))
) {
can = false;
break;
}
}
return can;
}
/**
* Determines whether the specified note values can be adjusted by looking for any notes with the same note number in the specified time range.
* @param {number} noteNumber - The note number.
* @param {number} noteStartTime - The start time of the note.
* @param {number} noteEndTime - The end time of the note.
* @return {boolean} True if the note would not overlap any existing notes; false otherwise.
*/
canSetNoteNumber(noteNumber, noteStartTime, noteEndTime) {
var can = true;
if (noteNumber >= this.noteRangeMin && noteNumber <= this.noteRangeMax) {
let blockingNotes = this.data.filter((n) => {
return !this.selectedNotes.includes(n) && n.note === noteNumber &&
((noteStartTime >= n.startTime && noteStartTime <= n.endTime) || (noteEndTime >= n.startTime && noteEndTime <= n.endTime) ||
(n.startTime >= noteStartTime && n.startTime <= noteEndTime) || (n.endTime >= noteStartTime && n.endTime <= noteEndTime));
});
if (blockingNotes.length > 0) {
can = false;
}
}
else {
can = false;
}
return can;
}
/**
* Start to shift notes in time, drawing empty rectangles representing the selected notes.
*/
startShiftingNotes() {
if (this.editNotesGroup !== undefined) {
this.editNotesGroup.remove();
}
this.editNotesGroup = this.notesEventGroup.append("g")
.attr("class", "editNotesGroup");
for (let i = 0; i < this.selectedNotes.length; i++) {
let x = this.timeScale(this.selectedNotes[i].startTime);
let y = this.noteScale(this.selectedNotes[i].note);
let width = this.timeScale(this.selectedNotes[i].endTime) - x;
this.selectedNotes[i].editRect = this.editNotesGroup.append("rect")
.attr("class", "moveNoteRect")
.attr("x", x)
.attr("y", y)
.attr("width", width)
.attr("height", this.noteHeight)
.style("stroke", this.selectedStroke)
.style("stroke-width", "1")
.style("fill", "none");
}
}
/**
* Shift the empty rectangles representing the notes by the specified delta x.
* @param {number} dx - Delta x.
*/
shiftNotes(dx) {
for (let i = 0; i < this.selectedNotes.length; i++) {
this.selectedNotes[i].editRect.attr("x", parseFloat(this.selectedNotes[i].editRect.attr("x")) + dx);
}
}
/**
* End note shifting, erasing the empty rectangles and updating the note data.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
endShiftingNotes(callDraw = true) {
var cancelled = false;
for (let i = 0; i < this.selectedNotes.length; i++) {
let x = parseFloat(this.selectedNotes[i].editRect.attr("x"));
let width = parseFloat(this.selectedNotes[i].editRect.attr("width"));
let start = this.timeScale.invert(x);
let end = this.timeScale.invert(x + width);
if (!this.canEditNote(this.selectedNotes[i], start, end)) {
cancelled = true;
break;
}
}
if (!cancelled) {
for (let i = 0; i < this.selectedNotes.length; i++) {
let x = parseFloat(this.selectedNotes[i].editRect.attr("x"));
let width = parseFloat(this.selectedNotes[i].editRect.attr("width"));
let start = this.timeScale.invert(x);
let end = this.timeScale.invert(x + width);
this.selectedNotes[i].startTime = start;
this.selectedNotes[i].endTime = end;
}
this.data.sort((a, b) => a.startTime - b.startTime);
if (this.onEdit !== undefined) {
this.onEdit();
}
}
this.editNotesGroup.remove();
if (callDraw) {
this.draw();
}
}
/**
* Start drawing a new note rectangle.
* @param {number} x - The x value of the note rectangle.
* @param {number} y - The y value to convert to a note value.
*/
startNote(x, y) {
let noteValue = Math.round(this.noteScale.invert(y));
this.addNoteRect = this.notesEventGroup.append("rect")
.attr("class", "addNoteRect")
.attr("x", x)
.attr("y", this.noteScale(noteValue))
.attr("width", 0)
.attr("height", this.noteHeight)
.style("stroke", this.stroke)
.style("stroke-width", "1")
.style("fill", "none");
}
/**
* Set the width of the note rectangle based on the specified delta x.
* @param {number} dx - Delta x.
*/
dragNote(dx) {
let width = parseFloat(this.addNoteRect.attr("width")) + dx;
if (width > 0) {
this.addNoteRect.attr("width", width);
}
}
/**
* End the new note rectangle, erasing the rectangle and adding the new note to the data.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
endNote(callDraw = true) {
let x = parseFloat(this.addNoteRect.attr("x"));
let y = parseFloat(this.addNoteRect.attr("y"));
let width = parseFloat(this.addNoteRect.attr("width"));
let noteValue = Math.round(this.noteScale.invert(y));
let start = this.timeScale.invert(x);
let end = this.timeScale.invert(x + width);
this.addNoteRect.remove();
if (this.canAddNote(noteValue, start, end)) {
let n = this.dataTemplate === undefined ? {} : {...this.dataTemplate};
n.note = noteValue;
n.velocity = 64;
n.startTime = start;
n.endTime = end;
this.data.push(n);
this.data.sort((a, b) => a.startTime - b.startTime);
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
}
/**
* Initialize this visual.
* @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;
if (this.noteHeight === 0) {
this.noteHeight = this.paneHeight/88;
}
this.noteScale.range([this.paneHeight-this.noteHeight, 0]);
this.velocityScale.range([this.paneHeight, 0]);
this.notesEventGroup = this.parentElement.append("g")
.attr("class", "notesEventGroup");
this.initialized = true;
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
}
}
/**
* Draw notes.
*/
draw() {
if (this.initialized) {
if (this.notesGroup !== undefined) {
this.notesGroup.remove();
}
this.notesGroup = this.parentElement.append("g")
.attr("class", "notesGroup")
.on("editorClick", (event) => {
if (this.acceptEditorClick) {
let t = this.timeScale.invert(event.detail.x);
let noteNumber = Math.round(this.noteScale.invert(event.detail.y));
let foundNote = this.data.filter((n) => n.note === noteNumber && t >= n.startTime && t <= n.endTime);
if (foundNote.length === 1) {
if (!event.detail.append) {
this.deselect(false);
}
foundNote[0].selected = true;
this.selectedNotes.push(foundNote[0]);
this.draw();
}
}
});
this.notesGroup.selectAll()
.data(this.data)
.join("rect")
.attr("class", "visual " + this.visualType + " noteRectBack")
.attr("visibility", () => {
let visibility;
if (this.notesStartHidden) {
visibility = "hidden";
}
else {
visibility = "visible";
}
return visibility;
})
.attr("x", d => this.timeScale(d.startTime))
.attr("y", d => this.noteScale(d.note))
.attr("width", (d) => {
let w = this.timeScale(d.endTime) - this.timeScale(d.startTime);
if (w > 0) {
return w;
}
else {
return 0;
}
})
.attr("height", () => this.noteHeight)
.style("fill", this.backFill)
.style("fill-opacity", "1.0")
.style("stroke", "none")
.style("pointer-events", "none");
this.notesGroup.selectAll()
.data(this.data)
.join("rect")
.attr("class", "visual " + this.visualType + " noteRect")
.attr("visibility", () => {
let visibility;
if (this.notesStartHidden) {
visibility = "hidden";
}
else {
visibility = "visible";
}
return visibility;
})
.attr("x", d => this.timeScale(d.startTime))
.attr("y", d => this.noteScale(d.note))
.attr("width", (d) => {
let w = this.timeScale(d.endTime) - this.timeScale(d.startTime);
if (w > 0) {
return w;
}
else {
return 0;
}
})
.attr("height", () => this.noteHeight)
.style("fill", (d) => {
if ("selected" in d && d.selected) {
return this.selectedFill;
}
else {
return this.fill;
}
})
.style("fill-opacity", (d) => {
if (this.useVelocityOpacity) {
return this.fillOpacityMin + ((d.velocity - this.velocityRangeMin) * (this.fillOpacityMax - this.fillOpacityMin) / (this.velocityRangeMax - this.velocityRangeMin));
}
else {
return this.fillOpacity;
}
})
.style("stroke", (d) => {
if ("selected" in d && d.selected) {
return this.selectedStroke;
}
else {
return this.stroke;
}
})
.style("stroke-opacity", this.strokeOpacity)
.style("stroke-width", this.strokeWidth)
.style("pointer-events", this.pointerEvents)
.on("mouseenter", (event) => {
if (this.selectable) {
this.notesGroup.style("cursor", "pointer");
}
})
.on("click", (event, d) => {
if (this.selectable) {
if ("selected" in d && d.selected) {
d.selected = false;
d3.select(event.currentTarget).style("fill", this.fill);
d3.select(event.currentTarget).style("stroke", this.stroke);
let selectedIndex = this.selectedNotes.findIndex(
(note) => note.note === d.note && note.startTime === d.startTime && note.endTime === d.endTime
);
this.selectedNotes.splice(selectedIndex, 1);
}
else {
d.selected = true;
d3.select(event.currentTarget).style("fill", this.selectedFill);
d3.select(event.currentTarget).style("stroke", this.selectedStroke);
if (event.ctrlKey || event.metaKey) {
this.selectedNotes.push(d);
}
else {
for (let i = 0; i < this.selectedNotes.length; i++) {
this.selectedNotes[i].selected = false;
}
this.selectedNotes.length = 0;
this.selectedNotes.push(d);
}
if (this.onSelect !== undefined) {
this.onSelect(this.selectedNotes);
}
}
this.draw();
}
});
if (this.editor !== undefined && this.editable) {
this.editor.initialize(this.notesGroup, this.paneWidth, this.paneHeight);
this.editor.draw();
if (this.selectedNotes.length > 0) {
let startTimes = this.selectedNotes.toSorted((a, b) => a.startTime - b.startTime);
let endTimes = this.selectedNotes.toSorted((a, b) => b.endTime - a.endTime);
this.editor.drawEditBox(this.timeScale(startTimes[0].startTime), this.timeScale(endTimes[0].endTime));
}
else {
this.editor.eraseEditBox();
}
}
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].on) {
this.visualArray[i].draw();
}
else {
this.visualArray[i].erase();
}
}
if (this.onDraw !== undefined) {
this.onDraw();
}
}
}
/**
* Erase notes.
*/
erase() {
if (this.notesGroup !== undefined) {
this.notesGroup.remove();
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].erase();
}
if (this.onErase !== undefined) {
this.onErase();
}
}
}
}
/** The Points visual repsents the specified data using circles, drawn based on the specified options. */
class Points {
/**
* Create a points visual.
* @param {Object[]} data - An array of data.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(data, options) {
this.visualType = "points";
this.data = data;
this.options = options;
this.name = options?.name ?? null;
this.on = options?.on ?? true;
this.editable = options?.editable ?? false;
this.color = options?.color ?? "#000000";
this.strokeColor = options?.strokeColor ?? "none";
this.strokeWidth = options?.strokeWidth ?? 1;
this.backgroundColor = options?.backgroundColor ?? "#ffffff";
this.selectedColor = options?.selectedColor ?? "#800000";
this.radius = options?.radius ?? 6;
this.opacity = options?.opacity ?? 1.0;
this.opacityMin = options?.opacityMin ?? 0.1;
this.opacityMax = options?.opacityMax ?? 1.0;
this.alwaysShowLabel = options?.alwaysShowLabel ?? false;
this.showProperties = options?.showProperties ?? false;
this.showControls = options?.showControls ?? true;
this.timeName = options?.timeName ?? "time";
this.valueName = options?.valueName ?? "value";
this.valueType = options?.valueType ?? "float";
this.valueDomainStart = options?.valueDomainStart ?? d3.min(this.data, (d) => d[this.valueName]);
this.valueDomainEnd = options?.valueDomainEnd ?? d3.max(this.data, (d) => d[this.valueName]);
this.valueRangeStart = options?.valueRangeStart;
this.valueRangeEnd = options?.valueRangeEnd;
this.showStems = options?.showStems ?? false;
this.mapValueToOpacity = options?.mapValueToOpacity ?? false;
this.acceptEditorClick = options?.acceptEditorClick ?? false;
this.dataTemplate;
this.propertyArray = [];
this.visualArray = [];
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.valueScale;
this.initialized = false;
this.pointsGroup;
this.selectedPoints = [];
this.editor;
this.onDraw;
this.onErase;
this.onEdit;
}
/**
* Append the provided data array to the existing data array in this visual.
* @param {Array} data - The data array to append.
*/
appendData(data) {
this.data = [...this.data, ...data];
}
/**
* Set a data template object for adding points.
* @param {Object} template - A template object.
*/
setDataTemplate(template) {
this.dataTemplate = template;
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Include the visual type indicated by the type string with the specified options.
* @param {string} visualType - The visual type.
* @param {object} options - Options for the visual type.
*/
includeVisual(visualType, options) {
var visual;
if (visualType === "curve") {
visual = new Curve(this.data, options);
}
else if (visualType === "instants") {
visual = new Instants(this.data, options);
}
if (visual !== undefined) {
visual.onEdit = this.draw.bind(this);
if (this.initialized) {
visual.initialize(this.parentElement, this.paneWidth, this.paneHeight, this.timeScale);
}
this.visualArray.push(visual);
}
return visual;
}
/**
* Filter the data using the domain values for the time scale.
*/
timeFilterData() {
return this.data.filter((d) => d[this.timeName] > this.timeScale.domain()[0] && d[this.timeName] < this.timeScale.domain()[1]);
}
/**
* Convert the specified value to an opacity based on the value domain and the min and max opacity values.
* @param {number} value - A number value.
*/
valueToOpacity(value) {
return this.opacityMin + ((value - this.valueDomainStart) * (this.opacityMax - this.opacityMin) / (this.valueDomainEnd - this.valueDomainStart));
}
/**
* Set the editor for this visual and set editing callbacks.
* @param {object} editor - The VisualEditor to set.
*/
setEditor(editor) {
this.editor = editor;
this.editor.onBrush = this.select.bind(this);
this.editor.onClick = this.addPoint.bind(this);
this.editor.onMoveBoxEnd = this.shiftPoints.bind(this);
this.editor.onDelineate = this.delineate.bind(this);
}
/**
* Get any notes in the specified time and note ranges and return them in an array.
* @param {number} t1 - The start of the time range.
* @param {number} t2 - The end of the time range.
* @param {number} value1 - The start of the value range.
* @param {number} value2 - The end of the value range.
* @return {Array} An array of notes in the specified ranges or an empty array.
*/
getPointsWithinRanges(t1, t2, value1, value2) {
return this.data.filter(
point => (point[this.timeName] > t1 && point[this.timeName] < t2) && (point[this.valueName] > value1 && point[this.valueName] < value2)
);
}
/**
* Select points within the specified x and y ranges.
* @param {number} x1 - The start of the x range.
* @param {number} x2 - The end of the x range.
* @param {number} y1 - The start of the y range.
* @param {number} y2 - The end of the y range.
* @param {boolean} append - Append selected points to previously selected points.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
select(x1, x2, y1, y2, append, callDraw = true) {
var t1 = x1 === null ? 0 : this.timeScale.invert(x1);
var t2 = x2 === null ? this.timeScale.domain()[1] : this.timeScale.invert(x2);
var v1 = y1 === null ? this.valueRangeStart : this.valueScale.invert(y1);
var v2 = y2 === null ? this.valueRangeEnd : this.valueScale.invert(y2);
if (this.valueType === "integer") {
v1 = Math.round(v1);
v2 = Math.round(v2);
}
if (this.selectedPoints.length === 0) {
this.selectedPoints = this.getPointsWithinRanges(t1, t2, v1, v2);
}
else {
if (append) {
this.selectedPoints = this.selectedPoints.concat(this.getPointsWithinRanges(t1, t2, v1, v2));
}
else {
for (let i = 0; i < this.selectedPoints.length; i++) {
this.selectedPoints[i].selected = false;
}
this.selectedPoints.length = 0;
this.selectedPoints = this.getPointsWithinRanges(t1, t2, v1, v2);
}
}
for (let i = 0; i < this.selectedPoints.length; i++) {
this.selectedPoints[i].selected = true;
}
if (callDraw) {
this.draw();
}
if (this.onSelect !== undefined) {
this.onSelect(this.selectedPoints);
}
}
/**
* Deselect currently selected points.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
deselect(callDraw = true) {
for (let i = 0; i < this.selectedPoints.length; i++) {
this.selectedPoints[i].selected = false;
}
this.selectedPoints.length = 0;
if (callDraw) {
this.draw();
}
}
/**
* Shift points by the specified delta x value.
* @param {number} dx - Delta x.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
shiftPoints(dx, callDraw = true) {
let dt = this.timeScale.invert(dx) - this.timeScale.domain()[0];
for (let i = 0; i < this.selectedPoints.length; i++) {
this.selectedPoints[i][this.timeName] += dt;
}
this.data.sort((a, b) => a[this.timeName] - b[this.timeName]);
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Add a new point at the specified coordinates.
* @param {number} x - The x coordinate.
* @param {number} y - The y coordinate.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
addPoint(x, y, callDraw = true) {
var p = this.dataTemplate === undefined ? {} : {...this.dataTemplate};
p[this.timeName] = this.timeScale.invert(x);
p[this.valueName] = this.valueScale.invert(y);
if (this.valueType === "integer") {
p[this.valueName] = Math.round(p[this.valueName]);
}
this.data.push(p);
this.data.sort((a, b) => a[this.timeName] - b[this.timeName]);
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Delete all selected points.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
deleteSelectedPoints(callDraw = true) {
if (this.selectedPoints.length > 0) {
for (let i = 0; i < this.selectedPoints.length; i++) {
let pointIndex = this.data.findIndex(p => p === this.selectedPoints[i]);
this.data.splice(pointIndex, 1);
}
this.selectedPoints.length = 0;
if (callDraw) {
this.draw();
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
}
/**
* Inserts time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
insertTime(baseTime, deltaTime) {
var shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
for (let i = 0; i < shiftData.length; i++) {
shiftData[i][this.timeName] += deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Determines whether time in the specified time range can be deleted.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
canDeleteTime(baseTime, deltaTime) {
var canDelete = true;
var endDeleteTime = baseTime + deltaTime;
var dataFound = this.data.filter((datum) => datum[this.timeName] >= baseTime && datum[this.timeName] <= endDeleteTime);
if (dataFound.length > 0) {
canDelete = false;
}
return canDelete;
}
/**
* Deletes time based on the specified selection range.
* @param {number} baseTime - The start of the selection.
* @param {number} deltaTime - The end of the selection.
*/
deleteTime(baseTime, deltaTime) {
let shiftData = this.data.filter((datum) => datum[this.timeName] > baseTime);
for (let i = 0; i < shiftData.length; i++) {
shiftData[i][this.timeName] -= deltaTime;
}
if (this.onEdit !== undefined) {
this.onEdit();
}
}
/**
* Initialize this visual.
*/
initialize(parentElement, width, height, timeScale) {
this.parentElement = parentElement;
this.paneWidth = width;
this.paneHeight = height;
this.timeScale = timeScale;
this.initialized = true;
this.valueScale = d3.scaleLinear()
.range([this.valueRangeStart ?? this.paneHeight, this.valueRangeEnd ?? 0])
.domain([this.valueDomainStart, this.valueDomainEnd]);
}
/**
* Draw this visual.
*/
draw() {
if (this.initialized) {
if (this.pointsGroup !== undefined) {
this.pointsGroup.remove();
}
// For Points, always filter the data by the domain of the time scale
let timeFilteredData = this.timeFilterData();
this.pointsGroup = this.parentElement.append("g")
.attr("class", "pointsGroup")
.on("editorClick", (event) => {
if (this.acceptEditorClick) {
let t1 = this.timeScale.invert(event.detail.x - this.radius);
let t2 = this.timeScale.invert(event.detail.x + this.radius);
let v1 = this.valueScale.invert(event.detail.y + this.radius);
let v2 = this.valueScale.invert(event.detail.y - this.radius);
let foundPoint = this.data.filter((p) => p[this.timeName] >= t1 && p[this.timeName] <= t2 && p[this.valueName] >= v1 && p[this.valueName] <= v2);
if (foundPoint.length === 1) {
if (!event.detail.append) {
this.deselect(false);
}
foundPoint[0].selected = true;
this.selectedPoints.push(foundPoint[0]);
this.draw();
}
}
});
let points = this; // Rename this for drag() functions where this needs to refer to a joined point circle
this.pointsGroup.selectAll("g")
.data(timeFilteredData)
.join("g")
.each(function (d) {
let dataPointGroup = d3.select(this);
d.backCircle = dataPointGroup.append("circle")
.attr("r", points.radius)
.attr("cx", points.timeScale(d[points.timeName]))
.attr("cy", points.valueScale(d[points.valueName]))
.style("fill", points.backgroundColor)
.style("stroke", "none");
d.frontCircle = dataPointGroup.append("circle")
.attr("r", points.radius)
.attr("cx", points.timeScale(d[points.timeName]))
.attr("cy", points.valueScale(d[points.valueName]))
.style("fill", () => {
if ("selected" in d && d.selected) {
return points.selectedColor;
}
else {
return points.color;
}
})
.style("fill-opacity", () => {
if (points.mapValueToOpacity) {
return points.valueToOpacity(d[points.valueName]);
}
else {
return points.opacity;
}
})
.style("stroke", points.strokeColor)
.style("stroke-width", points.strokeWidth)
.style("stroke-opacity", "1.0")
.on("mouseenter", (event) => {
d3.select(event.currentTarget).style("cursor", "pointer");
})
.on("click", (event) => {
if (points.editable) {
if ("selected" in d && d.selected) {
d.selected = false;
d3.select(event.currentTarget).style("fill", points.color);
}
else {
d.selected = true;
d3.select(event.currentTarget).style("fill", points.selectedColor);
}
}
})
.call(d3.drag()
.on("drag", (event) => {
if (points.editable) {
let y = parseFloat(d.frontCircle.attr("cy")) + event.dy;
if (y < 0) {
y = 0;
}
if (y > points.paneHeight) {
y = points.paneHeight;
}
d.backCircle.attr("cy", y);
d.frontCircle.attr("cy", y);
let pointOpacity = points.mapValueToOpacity ? points.valueToOpacity(points.valueScale.invert(y)) : points.opacity;
d.frontCircle.style("opacity", pointOpacity);
if (points.showStems) {
d.backStem.attr("y1", y + points.radius);
d.stem.attr("y1", y + points.radius);
d.stem.style("stroke-opacity", pointOpacity);
}
}
})
.on("end", (event) => {
if (points.editable) {
let y = parseFloat(d.frontCircle.attr("cy"));
d[points.valueName] = Math.round(points.valueScale.invert(y));
points.draw();
if (points.onEdit !== undefined) {
points.onEdit();
}
}
})
);
if (points.showStems) {
d.backStem = dataPointGroup.append("line")
.attr("x1", points.timeScale(d[points.timeName]))
.attr("y1", points.valueScale(d[points.valueName]) + points.radius)
.attr("x2", points.timeScale(d[points.timeName]))
.attr("y2", points.paneHeight)
.style("stroke", points.backgroundColor)
.style("stroke-width", "1px");
d.stem = dataPointGroup.append("line")
.attr("x1", points.timeScale(d[points.timeName]))
.attr("y1", points.valueScale(d[points.valueName]) + points.radius)
.attr("x2", points.timeScale(d[points.timeName]))
.attr("y2", points.paneHeight)
.style("stroke", (d) => {
if ("selected" in d && d.selected) {
return points.selectedColor;
}
else {
return points.color;
}
})
.style("stroke-width", "1px");
}
});
if (this.editor !== undefined && this.editable) {
this.editor.initialize(this.pointsGroup, this.paneWidth, this.paneHeight);
this.editor.draw();
if (this.selectedPoints.length > 0) {
let selectedTimes = this.selectedPoints.toSorted((a, b) => a[this.timeName] - b[this.timeName]);
this.editor.drawEditBox(
this.timeScale(selectedTimes[0][this.timeName]) - this.radius,
this.timeScale(selectedTimes[selectedTimes.length-1][this.timeName]) + this.radius
);
}
else {
this.editor.eraseEditBox();
}
}
if (this.onDraw !== undefined) {
this.onDraw();
}
}
}
/**
* Edit values for points by moving the mouse.
* @param {number} dx - Delta x.
* @param {number} pointerX - Pointer x.
* @param {number} pointerY - Pointer y.
* @param {boolean} [callDraw=true] - Whether to call draw() from this method.
*/
delineate(dx, pointerX, pointerY, callDraw = true) {
var dataDelineated = [];
var timeFilteredData = this.timeFilterData();
if (this.editable) {
let dataFound = [];
if (dx < 0) {
dataFound = timeFilteredData.filter(p => {
let x = this.timeScale(p[this.timeName]);
return x > pointerX && x < pointerX - dx;
});
}
else if (dx > 0) {
dataFound = timeFilteredData.filter(p => {
let x = this.timeScale(p[this.timeName]);
return x > pointerX - dx && x < pointerX;
});
}
if (dataFound.length > 0) {
dataDelineated = dataFound;
for (let i = 0; i < dataFound.length; i++) {
if (this.valueType === "integer") {
dataFound[i][this.valueName] = Math.round(this.valueScale.invert(pointerY));
}
else if (this.valueType === "float") {
dataFound[i][this.valueName] = this.valueScale.invert(pointerY);
}
}
if (this.onEdit !== undefined) {
this.onEdit();
}
if (callDraw) {
this.draw();
}
}
}
return dataDelineated;
}
/**
* Erase this visual.
*/
erase() {
if (this.pointsGroup !== undefined) {
this.pointsGroup.remove();
if (this.onErase !== undefined) {
this.onErase();
}
}
}
}
/** A ProgressIndicator is an animated visual for indicating the progress of time along the X axis. */
class ProgressIndicator {
/**
* Create a progress indicator.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(options) {
this.visualType = "progressIndicator";
this.options = options;
this.color = options?.color ?? "#006400";
this.width = options?.width ?? 3;
this.opacity = options?.opacity ?? "1.0";
this.propertyArray = [];
this.line;
this.x;
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.initialized = false;
this.progressIndicatorGroup;
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Set x.
* @param {number} x - The x value.
*/
setX(x) {
this.x = x;
}
/**
* Get x.
* @return {number} The x value.
*/
getX() {
return this.x;
}
/**
* Initialize this visual.
* @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.initialized = true;
}
/**
* Draw this visual at the specified x location.
*/
draw(x) {
if (this.initialized) {
this.x = x;
if (this.progressIndicatorGroup !== undefined) {
this.progressIndicatorGroup.remove();
}
this.progressIndicatorGroup = this.parentElement.append("g")
.attr("class", "progressIndicatorGroup");
this.line = this.progressIndicatorGroup.append("line")
.attr("class", "progressIndicator")
.attr("x1", this.x)
.attr("y1", 0)
.attr("x2", this.x)
.attr("y2", this.paneHeight)
.style("stroke", this.color)
.style("stroke-width", this.width)
.style("opacity", this.opacity);
}
}
/**
* Move this visual to the specified x location.
*/
move(x) {
this.x = x;
if (this.line !== undefined) {
this.line
.attr("x1", this.x)
.attr("x2", this.x);
}
}
/**
* Erase this visual.
*/
erase() {
if (this.progressIndicatorGroup !== undefined) {
this.progressIndicatorGroup.remove();
}
}
}
/**
* A Spectrogram visual represents the frequency domain of the specified audio.
* @todo Do an actual implementation for this class.
*/
class Spectrogram {
/**
* Create an spectrogram visual.
* @param {Object} audioContext - An array of data.
* @param {Object} bufferPlayer - An array of data.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(audioContext, bufferPlayer, options) {
this.visualType = "spectrogram";
this.audioContext = audioContext;
this.bufferPlayer = bufferPlayer;
this.options = options;
this.name = options?.name ?? null;
this.showProperties = options?.showProperties ?? false;
this.showControls = options?.showControls ?? true;
this.propertyArray = [];
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.initialized = false;
this.spectrogramGroup;
this.onDraw;
this.onErase;
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
this.binCount = this.analyser.frequencyBinCount;
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Initialize this visual.
* @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.initialized = true;
}
/**
* Draw this visual.
*/
draw() {
this.spectrogramGroup = this.parentElement.append("g")
.attr("class", "spectrogramGroup");
if (this.onDraw !== undefined) {
this.onDraw();
}
}
/**
* Erase this visual.
*/
erase() {
if (this.spectrogramGroup !== undefined) {
this.spectrogramGroup.remove();
if (this.onErase !== undefined) {
this.onErase();
}
}
}
}
/** A set for containing related visuals. */
class VisualSet {
/**
* Create a visual set.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(options) {
this.visualType = "set";
this.options = options;
this.on = options?.on ?? true;
this.name = options?.name ?? null;
this.showProperties = options?.showProperties ?? false;
this.showControls = options?.showControls ?? true;
this.propertyArray = [];
this.visualArray = [];
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Add the specified visual to this set.
* @param {visual} visual - The visual to add.
*/
addVisual(visual) {
this.visualArray.push(visual);
}
/**
* Get all visuals from this set.
* @return {Array} An array of visuals or an empty array.
*/
getVisuals() {
return this.visualArray;
}
/**
* Initialize this visual set.
* @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) {
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].initialize(parentElement, width, height, timeScale);
}
}
/**
* Draw all visuals in this set.
*/
draw() {
for (let i = 0; i < this.visualArray.length; i++) {
if (this.visualArray[i].on) {
this.visualArray[i].draw();
}
else {
this.visualArray[i].erase();
}
}
}
/**
* Erase all visuals in this set.
*/
erase() {
for (let i = 0; i < this.visualArray.length; i++) {
this.visualArray[i].erase();
}
}
}
/**
* A Waveform visual represents the time domain of the specified audio.
*/
class Waveform {
/**
* Create a waveform visual.
* @param {Object} bufferPlayer - The audio source to use to create this waveform.
* @param {number} endTime - The absolute end time (not equal to the audio's actual end time) to use for this waveform.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(bufferPlayer, endTime, options) {
this.visualType = "waveform";
this.bufferPlayer = bufferPlayer;
this.endTime = endTime; // The end time is not always the same as the buffer duration
this.options = options;
this.name = options?.name ?? null;
this.on = options?.on ?? true;
this.color = options?.color ?? "#006400";
this.opacity = options?.opacity ?? "0.25";
this.showProperties = options?.showProperties ?? false;
this.showControls = options?.showControls ?? true;
this.propertyArray = [];
this.data;
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.initialized = false;
this.halfVisualHeight;
this.amplitudeScale;
this.waveformGroup;
this.onDraw;
this.onErase;
}
/**
* Add the specified property to this visual. The property is stored as [name, value].
* @param {string} name - The name of the property.
* @param {string} value - The value of the property.
*/
addProperty(name, value) {
this.propertyArray.push([name, value]);
}
/**
* Initialize this visual.
* @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.halfVisualHeight = this.paneHeight/2;
this.amplitudeScale = d3.scaleLinear()
.range([0, this.paneHeight])
.domain([0, 1]);
this.initialized = true;
}
/**
* Draw this visual.
*/
draw() {
if (this.initialized) {
let width = this.endTime > this.bufferPlayer.duration ? this.timeScale(this.bufferPlayer.duration) : this.paneWidth;
this.data = this.bufferPlayer.reduceAudioData(0, width, this.timeScale.domain()[0], this.timeScale.domain()[1]);
if (this.waveformGroup !== undefined) {
this.waveformGroup.remove();
}
this.waveformGroup = this.parentElement.append("g")
.attr("class", "waveformGroup");
this.waveformGroup.selectAll()
.data(this.data)
.join("line")
.attr("class", "visual " + this.visualType)
.attr("x1", (d, i) => i)
.attr("y1", d => this.halfVisualHeight-(this.amplitudeScale(d)/2))
.attr("x2", (d, i) => i)
.attr("y2", d => this.halfVisualHeight+(this.amplitudeScale(d)/2))
.style("stroke", this.color)
.style("stroke-width", "1")
.style("stroke-opacity", this.opacity)
.style("pointer-events", "none");
if (this.onDraw !== undefined) {
this.onDraw();
}
}
}
/**
* Erase this visual.
*/
erase() {
if (this.waveformGroup !== undefined) {
this.waveformGroup.remove();
if (this.onErase !== undefined) {
this.onErase();
}
}
}
}