Source: controls.js

/** A generic checkbox control. */
class Checkbox {
  /**
  * Create a checkbox control.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.options = options;
    this.parentElement;
    this.initialized = false;
    this.checkboxDiv;
    this.isChecked;
    this.onChange;
  }

  /**
  * Initialize this control by attaching it to the specified parent element.
  * @param {element} parentElement - The parent element.
  */
  initialize(parentElement) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.initialized = true;
  }

  /**
  * Draw this control.
  */
  draw() {
    if (this.initialized) {
      this.erase();
      this.checkboxDiv = this.parentElement.append("div")
        .attr("class", "checkboxDiv")
        .append("input")
          .attr("type", "checkbox")
          .attr("name", this.options?.name ?? "")
          .attr("value", this.options?.value ?? "on")
          .property("checked", () => {
            if (this.isChecked !== undefined) {
              return this.isChecked();
            }
          })
          .on("change", (event) => {
            if (this.onChange !== undefined) {
              this.onChange(event);
            }
          });
    }
  }

  /**
  * Erase this control.
  */
  erase() {
    if (this.checkboxDiv !== undefined) {
      this.checkboxDiv.remove();
    }
  }
}

class Selector {
  constructor() {
    this.selectorDiv;
    this.optionArray = [];
    this.currentOption;
    this.onSelectOption;
  }

  addOption(option) {
    this.optionArray.push(option);
  }

  initialize(parentElement) {
    if (parentElement instanceof d3.selection) {
      this.selectorDiv = parentElement.append("div")
        .attr("class", "selectorDiv");
    }
    else {
      this.selectorDiv = d3.select(parentElement).append("div")
        .attr("class", "selectorDiv");
    }
  }

  draw() {
    this.selectorDiv.append("select")
      .attr("id", "selector")
      .style("font-size", "1rem")
      .style("margin-right", "2ch")
      .style("margin-left", "2ch")
      .style("margin-bottom", "2ex")
      .on("change", (event) => {
        this.currentOption = event.target.value;
        if (this.onSelectOption !== undefined) {
          this.onSelectOption(this.currentOption);
        }
      })
      .selectAll("option")
      .data(this.optionArray)
      .enter()
        .append("option")
        .attr("value", (d) => d)
        .property("selected", (d) => {
          if (d === this.currentOption) {
            return true;
          }
          else {
            return false
          }
        })
        .text((d) => d);
  }

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

/**
* An SVG image as a button control. If an alternate image is specified, clicking will switch between the image and the alternate image.
*/
class SVGButton {
  /**
  * Create an SVG button control.
  * @param {string} file - The SVG file name String.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(file, options) {
    this.file = file;
    this.options = options ?? {};
    this.alternateFile = options?.alternateFile;
    this.alternateSVG;
    this.alternated = false;
    this.svg;
    this.parentElement;
    this.width;
    this.height;
    this.initialized = false;
    this.onClick;
    this.buttonDescriptionSpan;

    if (("opacity" in this.options) === false) {
      this.options["opacity"] = "1.0";
    }

    this.fileIsReady = d3.svg(this.file).then((svg) => {
      this.svg = svg.documentElement;
    });

    if (this.alternateFile !== undefined) {
      this.alternateFileIsReady = d3.svg(this.alternateFile).then((alternateSVG) => {
        this.alternateSVG = alternateSVG.documentElement;
      });
    }
  }

  /**
  * Set the image file and (optionally) the alternate image file.
  * @param {string} file - A string representing the SVG file name and path.
  * @param {string} alternateFile - A string representing the alternate SVG file name and path.
  */
  setFile(file, alternateFile) {
    this.file = file;
    this.alternateFile = alternateFile;

    this.fileIsReady = d3.svg(this.file).then((svg) => {
      this.svg = svg.documentElement;
    });

    if (this.alternateFile !== undefined) {
      this.alternateFileIsReady = d3.svg(this.alternateFile).then((alternateSVG) => {
        this.alternateSVG = alternateSVG.documentElement;
      });
    }
  }

  /**
  * Set the value for the specified option if it exists in this control.
  * @param {string} name - The name of the option.
  * @param {string} value - The value to set for the option.
  */
  setOption(name, value) {
    if (name in this.options) {
      this.options[name] = value;
    }
  }

  /**
  * Set all options for this control. Note that this method completely replaces all existing options with the specified options.
  * @param {Object} options - The options to replace existing options.
  */
  setOptions(options) {
    this.options = options;
  }

  /**
  * Initialize this control with the specified parameters.
  * @param {element} parentElement - The parent element.
  * @param {number} width - The width to use for the SVG image.
  * @param {number} height - The height to use for the SVG image.
  */
  initialize(parentElement, width, height) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.width = width;
    this.height = height;

    this.buttonDiv = this.parentElement.append("div")
      .attr("class", "buttonDiv")
      .on("click", () => {
        if (this.onClick !== undefined) {
          this.onClick();
        }
      })
      .on("mouseenter", (event) => {
        if (this.options?.description !== undefined) {
          this.buttonDescriptionSpan.style("visibility", "visible");
        }
      })
      .on("mouseleave", (event) => {
        if (this.options?.description !== undefined) {
          this.buttonDescriptionSpan.style("visibility", "hidden");
        }
      });

    if (this.options?.description !== undefined) {
      this.buttonDescriptionSpan = this.buttonDiv.append("span")
        .attr("class", "buttonDescriptionSpan")
        .style("visibility", "hidden")
        .style("white-space", "nowrap")
        .style("text-align", "center")
        .style("position", "absolute")
        .style("font-family", "sans-serif")
        .style("font-size", "small")
        .style("top", "110%")
        .style("left", () => {
          if (this.options?.descriptionAlign === "left") {
            return "0";
          }
          else {
            return "auto";
          }
        })
        .style("right", () => {
          if (this.options?.descriptionAlign === "right") {
            return "0";
          }
          else {
            return "auto";
          }
        })
        .style("z-index", "1")
        .text(this.options.description);
    }

    this.initialized = true;
  }

  /**
  * Alternate the SVG images for this control.
  */
  alternate() {
    if (this.alternateFile !== undefined) {
      if (this.alternated) {
        this.alternated = false;
        if (this.options?.description !== undefined) {
          this.buttonDescriptionSpan.text(this.options.description);
        }
      }
      else {
        this.alternated = true;
        if (this.options?.alternateDescription !== undefined) {
          this.buttonDescriptionSpan.text(this.options.alternateDescription);
        }
      }
      this.draw();
    }
  }

  /**
  * Set the SVG image for this control to the default image.
  */
  reset() {
    if (this.alternateFile !== undefined) {
      this.alternated = false;
      this.draw();
    }
  }

  /**
  * Draw this control.
  */
  draw() {
    if (this.initialized) {
      this.erase();

      this.buttonDiv.style("opacity", this.options.opacity);

      if (this.alternateFile !== undefined && this.alternated) {
        this.alternateFileIsReady.then(() => {
          this.buttonDiv.append(() => this.alternateSVG)
            .attr("class", "svgButton")
            .attr("x", "0")
            .attr("y", "0")
            .attr("width", this.width)
            .attr("height", this.height);
        });
      }
      else {
        this.fileIsReady.then(() => {
          this.buttonDiv.append(() => this.svg)
            .attr("class", "svgButton")
            .attr("x", "0")
            .attr("y", "0")
            .attr("width", this.width)
            .attr("height", this.height);
        });
      }
    }
  }

  /**
  * Erase this control.
  */
  erase() {
    if (this.buttonDiv !== undefined) {
      this.buttonDiv.select("svg").remove();
    }
  }
}

/** An SVG image button that displays a checklist of items when clicked. */
class SVGChecklistButton {
  /**
  * Create an SVG checklist button control.
  * @param {string} file - The SVG file name String.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(file, options) {
    this.file = file;
    this.options = options;
    this.svg;
    this.parentElement;
    this.width;
    this.height;
    this.initialized = false;
    this.itemArray = [];
    this.onClick;
    this.checklistOn = false;
    this.buttonDiv;
    this.checkListDiv;
    this.buttonDescriptionSpan;

    if (("opacity" in this.options) === false) {
      this.options["opacity"] = "1.0";
    }

    this.fileIsReady = d3.svg(this.file).then((svg) => {
      this.svg = svg.documentElement;
    });
  }

  /**
  * Set the SVG image file.
  * @param {string} file - A string representing the SVG file name and path.
  */
  setFile(file) {
    this.file = file;

    this.fileIsReady = d3.svg(this.file).then((svg) => {
      this.svg = svg.documentElement;
    });
  }

  /**
  * Set the value for the specified option if it exists in this control.
  * @param {string} name - The name of the option.
  * @param {string} value - The value to set for the option.
  */
  setOption(name, value) {
    if (name in this.options) {
      this.options[name] = value;
    }
  }

  /**
  * Set all options for this control. Note that this method completely replaces all existing options with the specified options.
  * @param {Object} options - The options to replace existing options.
  */
  setOptions(options) {
    this.options = options;
  }

  /**
  * Add the specified checklist item to this control.
  * @param {Object} item - The item to add.
  */
  addItem(item) {
    this.itemArray.push(item);
  }

  /**
  * Add an array of checklist item to this control.
  * @param {Object} items - The array of items to add.
  */
  addItems(items) {
    this.itemArray.push(...items);
  }

  /**
  * Initialize this control with the specified parameters.
  * @param {element} parentElement - The parent element.
  * @param {number} width - The width to use for the SVG image.
  * @param {number} height - The height to use for the SVG image.
  */
  initialize(parentElement, width, height) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.width = width;
    this.height = height;

    this.buttonDiv = this.parentElement.append("div")
      .attr("class", "buttonDiv")
      .on("click", () => {
        if (this.checklistOn) {
          this.checklistOn = false;
          this.buttonDiv.style("opacity", "0.5");
          this.eraseChecklist();
        }
        else {
          this.checklistOn = true;
          this.buttonDiv.style("opacity", "1.0");
          this.drawChecklist();
        }

        if (this.onClick !== undefined) {
          this.onClick();
        }
      })
      .on("mouseenter", (event) => {
        if (this.options?.description !== undefined) {
          this.buttonDescriptionSpan.style("visibility", "visible");
        }
      })
      .on("mouseleave", (event) => {
        if (this.options?.description !== undefined) {
          this.buttonDescriptionSpan.style("visibility", "hidden");
        }
      });

    if (this.options?.description !== undefined) {
      this.buttonDescriptionSpan = this.buttonDiv.append("span")
        .attr("class", "buttonDescriptionSpan")
        .style("visibility", "hidden")
        .style("white-space", "nowrap")
        .style("text-align", "center")
        .style("position", "absolute")
        .style("font-family", "sans-serif")
        .style("font-size", "small")
        .style("top", "110%")
        .style("left", () => {
          if (this.options?.descriptionAlign === "left") {
            return "0";
          }
          else {
            return "auto";
          }
        })
        .style("right", () => {
          if (this.options?.descriptionAlign === "right") {
            return "0";
          }
          else {
            return "auto";
          }
        })
        .style("z-index", "1")
        .text(this.options.description);
    }

    this.initialized = true;
  }

  /**
  * Draw the specified checklist item.
  * @param {Object} item - The item to draw.
  */
  drawChecklistItem(item) {
    var name = item.name + item.getLinkedNames();
    var checkListItemDiv = this.checkListDiv.append("div")
      .attr("class", "checkListItemDiv");
    var checkboxDiv = checkListItemDiv.append("input")
      .attr("type", "checkbox")
      .property("checked", () => {
        if (item.on === true) {
          item.draw();
          return true;
        }
        else {
          item.erase();
          return false;
        }
      })
      .on("change", (event) => {
        item.on = event.target.checked;
        if (event.target.checked) {
          item.draw();
        }
        else {
          item.erase();
        }
        this.drawChecklist();
      });
    var textDiv = checkListItemDiv.append("div")
      .attr("class", "textDiv")
      .text(name);
  }

  /**
  * Draw the checklist.
  */
  drawChecklist() {
    this.eraseChecklist();
    this.checkListDiv = this.buttonDiv.append("div")
      .attr("class", "checkListDiv")
      .style("z-index", "2")
      .style("position", "absolute")
      .style("background-color", "#ffffff")
      .style("display", "flex")
      .style("flex-direction", "column")
      .style("font-family", "sans-serif")
      .style("padding", "10%")
      .style("border-style", "solid")
      .style("border-color", "rgb(128, 128, 128)")
      .style("border-width", "1px")
      .style("border-radius", "8px")
      .style("white-space", "nowrap")
      .style("transform", "translate(1.2vh, 1.2vw)")
      .on("click", (event) => event.stopPropagation());

    if (this.itemArray.length > 1) {
      var selectAllListItem = this.checkListDiv.append("div")
        .attr("class", "selectAllListItem");
      var selectAllCheckboxDiv = selectAllListItem.append("input")
        .attr("type", "checkbox")
        .property("checked", () => {
          var checkedCount = 0;
          for (let i = 0; i < this.itemArray.length; i++) {
            if (this.itemArray[i].on === true) {
              checkedCount++;
            }
          }
          return checkedCount === this.itemArray.length;
        })
        .on("change", (event) => {
          for (let i = 0; i < this.itemArray.length; i++) {
            this.itemArray[i].on = event.target.checked;
          }
          this.drawChecklist();
        });
      var selectAllTextDiv = selectAllListItem.append("div")
        .attr("class", "selectAllTextDiv")
        .text("Select All");
    }

    for (let i = 0; i < this.itemArray.length; i++) {
      this.drawChecklistItem(this.itemArray[i]);
    }
  }

  /**
  * Erase the checklist.
  */
  eraseChecklist() {
    if (this.checkListDiv !== undefined) {
      this.checkListDiv.remove();
    }
  }

  /**
  * Draw this control.
  */
  draw() {
    if (this.initialized) {
      this.erase();

      this.buttonDiv.style("opacity", this.options.opacity);

      this.fileIsReady.then(() => {
        this.buttonDiv.append(() => this.svg)
          .attr("class", "svgButton")
          .attr("x", "0")
          .attr("y", "0")
          .attr("width", this.width)
          .attr("height", this.height);
      });
    }
  }

  /**
  * Erase this control.
  */
  erase() {
    if (this.buttonDiv !== undefined) {
      this.buttonDiv.select("svg").remove();
    }
  }
}

/** An SVG image as a switch button. */
class SVGSwitchButton {
  /**
  * Create an SVG button control for switching.
  * @param {string} file - The SVG file name String.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(file, options) {
    this.file = file;
    this.options = options ?? {};
    this.on = options?.on ?? false;
    this.svg;
    this.parentElement;
    this.width;
    this.height;
    this.initialized = false;
    this.onClick;
    this.buttonDescriptionSpan;

    this.fileIsReady = d3.svg(this.file).then((svg) => {
      this.svg = svg.documentElement;
    });
  }

  /**
  * Set the SVG image file.
  * @param {string} file - A string representing the SVG file name and path.
  */
  setFile(file) {
    this.file = file;

    this.fileIsReady = d3.svg(this.file).then((svg) => {
      this.svg = svg.documentElement;
    });
  }

  /**
  * Initialize this control with the specified parameters.
  * @param {element} parentElement - The parent element.
  * @param {number} width - The width to use for the SVG image.
  * @param {number} height - The height to use for the SVG image.
  */
  initialize(parentElement, width, height) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.width = width;
    this.height = height;

    this.buttonDiv = this.parentElement.append("div")
      .attr("class", "buttonDiv")
      .style("opacity", () => {
        if (this.on) {
          return "1.0";
        }
        else {
          return "0.5";
        }
      })
      .on("click", () => {
        if (this.on) {
          this.on = false;
          this.buttonDiv.style("opacity", "0.5");
        }
        else {
          this.on = true;
          this.buttonDiv.style("opacity", "1.0");
        }
        if (this.onClick !== undefined) {
          this.onClick();
        }
      })
      .on("mouseenter", (event) => {
        if (this.options?.description !== undefined) {
          this.buttonDescriptionSpan.style("visibility", "visible");
        }
      })
      .on("mouseleave", (event) => {
        if (this.options?.description !== undefined) {
          this.buttonDescriptionSpan.style("visibility", "hidden");
        }
      });

    if (this.options?.description !== undefined) {
      this.buttonDescriptionSpan = this.buttonDiv.append("span")
        .attr("class", "buttonDescriptionSpan")
        .style("visibility", "hidden")
        .style("white-space", "nowrap")
        .style("text-align", "center")
        .style("position", "absolute")
        .style("font-family", "sans-serif")
        .style("font-size", "small")
        .style("top", "110%")
        .style("left", () => {
          if (this.options?.descriptionAlign === "left") {
            return "0";
          }
          else {
            return "auto";
          }
        })
        .style("right", () => {
          if (this.options?.descriptionAlign === "right") {
            return "0";
          }
          else {
            return "auto";
          }
        })
        .style("z-index", "1")
        .text(this.options.description);
    }

    this.initialized = true;
  }

  /**
  * Draw this control.
  */
  draw() {
    if (this.initialized) {
      this.erase();

      this.fileIsReady.then(() => {
        this.buttonDiv.append(() => this.svg)
          .attr("class", "svgButton")
          .attr("x", "0")
          .attr("y", "0")
          .attr("width", this.width)
          .attr("height", this.height);
      });
    }
  }

  /**
  * Erase this control.
  */
  erase() {
    if (this.buttonDiv !== undefined) {
      this.buttonDiv.select("svg").remove();
    }
  }
}

/** A generic text button. */
class TextButton {
  /**
  * Create a text button control.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(options) {
    this.options = options;
    this.parentElement;
    this.width = "3ch";
    this.height = "3ch";
    this.initialized = false;
    this.onClick;
  }

  /**
  * Set the value for the specified option if it exists in this control.
  * @param {string} name - The name of the option.
  * @param {string} value - The value to set for the option.
  */
  setOption(name, value) {
    if (name in this.options) {
      this.options[name] = value;
    }
  }

  /**
  * Set all options for this control. Note that this method completely replaces all existing options with the specified options.
  * @param {Object} options - The options to replace existing options.
  */
  setOptions(options) {
    this.options = options;
  }

  /**
  * Initialize this control with the specified parameters.
  * @param {element} parentElement - The parent element.
  * @param {number} width - The width.
  * @param {number} height - The height.
  */
  initialize(parentElement, width, height) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);

    if (width !== undefined) {
      if (isNaN(width)) {
        this.width = width;
      }
      else {
        this.width = Math.round(width).toString() + "px";
      }
    }

    if (height !== undefined) {
      if (isNaN(height)) {
        this.height = height;
      }
      else {
        this.height = Math.round(height).toString() + "px";
      }
    }

    this.buttonDiv = this.parentElement.append("div")
      .attr("class", "buttonDiv");

    this.initialized = true;
  }

  /**
  * Draw this control.
  */
  draw() {
    if (this.initialized) {
      this.erase();

      this.button = this.buttonDiv.append("button")
        .attr("type", "button")
        .attr("class", "buttonControl")
        .style("font-size", this.options.fontSize ?? "x-large")
        .style("width", this.width)
        .style("height", this.height)
        .style("position", "relative")
        .style("background-color", this.options.backgroundColor ?? "#ffffff")
        .style("border-style", this.options.borderStyle ?? "solid")
        .style("border-color", this.options.borderColor ?? "rgb(128, 128, 128)")
        .style("border-width", this.options.borderWidth ?? "1px")
        .style("border-radius", this.options.borderRadius ?? "8px")
        .style("padding", "0")
        .style("margin-left", this.options.marginLeft ?? "0")
        .style("margin-right", this.options.marginRight ?? "0")
        .style("opacity", this.options.opacity ?? "0.5")
        .on("click", () => {
          if (this.onClick !== undefined) {
            this.onClick();
          }
        })
        .on("mouseenter", (event) => {
          this.button.style("border-color", "rgb(64, 64, 64)");
          if (this.options.showDescription && this.options.description !== undefined) {
            this.buttonDescriptionSpan.style("visibility", "visible");
          }
        })
        .on("mouseleave", (event) => {
          this.button.style("border-color", "rgb(128, 128, 128)");
          if (this.options.showDescription && this.options.description !== undefined) {
            this.buttonDescriptionSpan.style("visibility", "hidden");
          }
        })
        .text(this.options.text ?? "");

      if (this.options.description !== undefined) {
        this.buttonDescriptionSpan = this.button.append("span")
          .attr("class", "buttonDescriptionSpan")
          .style("visibility", "hidden")
          .style("white-space", "nowrap")
          .style("text-align", "center")
          .style("position", "absolute")
          .style("font-family", "sans-serif")
          .style("font-size", "small")
          .style("top", "110%")
          .style("left", "0%")
          .style("z-index", "1")
          .text(this.options.description);
      }
    }
  }

  /**
  * Erase this control.
  */
  erase() {
    if (this.buttonDiv !== undefined) {
      this.buttonDiv.selectAll("*").remove();
    }
  }
}

/** A checkbox for controlling the display status of a visual. */
class VisualCheckbox {
  /**
  * Create a visual control checkbox.
  * @param {Object} visual - The visual to be controlled.
  * @param {Object} options - Configuration options.
  * @author Lawrence Fyfe
  */
  constructor(visual, options) {
    this.visual = visual;
    this.options = options;
    this.parentElement;
    this.initialized = false;
    this.checkboxDiv;
  }

  /**
  * Initialize this control by attaching it to the specified parent element.
  * @param {element} parentElement - The parent element.
  */
  initialize(parentElement) {
    this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
    this.initialized = true;
  }

  /**
  * Draw this control.
  */
  draw() {
    if (this.initialized) {
      this.checkboxDiv = this.parentElement.append("div")
        .attr("class", "checkboxDiv")
        .append("input")
          .attr("type", "checkbox")
          .property("checked", () => {
            if (this.visual.on) {
              return true;
            }
            else {
              return false;
            }
          })
          .on("change", (event) => {
            this.visual.on = event.target.checked;
            if (event.target.checked) {
              this.visual.draw();
            }
            else {
              this.visual.erase();
            }
          });
    }
  }

  /**
  * Erase this control.
  */
  erase() {
    if (this.checkboxDiv !== undefined) {
      this.checkboxDiv.selectAll("*").remove();
    }
  }
}