diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..effc581d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/MathJax"] + path = src/MathJax + url = https://github.com/mathjax/MathJax.git diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..b6c63617 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 varkor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..ee11e695 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# quiver +A graphical editor for commutative diagrams that exports to tikzcd. + +## Features +- An intuitive graphical interface for creating and modifying commutative diagrams. +- Support for objects, morphisms and natural transformations. +- tikzcd (LaTeX) export. +- Smart label alignment. diff --git a/src/MathJax b/src/MathJax new file mode 160000 index 00000000..419b0a6e --- /dev/null +++ b/src/MathJax @@ -0,0 +1 @@ +Subproject commit 419b0a6eee7eefc0f85e47f7d4f8227ec28b8e57 diff --git a/src/index.html b/src/index.html new file mode 100644 index 00000000..081717f5 --- /dev/null +++ b/src/index.html @@ -0,0 +1,13 @@ + + + + + quiver + + + + + + + + diff --git a/src/main.css b/src/main.css new file mode 100644 index 00000000..64987be1 --- /dev/null +++ b/src/main.css @@ -0,0 +1,280 @@ +/* Root styles */ + +*, *::before, *::after { + box-sizing: border-box; +} + +:root { + /* Cell attributes. */ + --cell-size: 64px; + /* Cell states. */ + --cell-hover: hsla(0, 0%, 0%, 0.1); + --cell-selected: hsla(0, 0%, 0%, 0.2); + --cell-source: hsla(0, 0%, 0%, 0.2); + --cell-target: hsla(0, 0%, 0%, 0.2); +} + +body { + position: absolute; + width: 100%; height: 100%; + margin: 0; + + background-color: white; + background-size: var(--cell-size) var(--cell-size); +} + +/* Special elements */ + +.MathJax_SVG { + outline: none; +} + +/* Grid interaction */ + +.insertion-point { + display: block; + position: absolute; + width: var(--cell-size); height: var(--cell-size); + transform: translate(-50%, -50%); + + background: hsla(0, 0%, 0%, 0); + + text-align: center; + font: 16px sans-serif; + line-height: var(--cell-size); + color: hsla(0, 0%, 0%, 0.4); +} + +.insertion-point.revealed { + background: hsla(0, 0%, 0%, 0.1); +} + +.insertion-point::before { + content: "Add vertex"; + visibility: hidden; +} + +.insertion-point.revealed::before { + visibility: visible; +} + +/* Vertices */ + +.vertex { + position: absolute; + width: var(--cell-size); height: var(--cell-size); + transform: translate(-50%, -50%); +} + +.ui:not(.connect) .vertex { + cursor: move; +} + +.vertex .content { + position: absolute; + width: calc(var(--cell-size) / 2); height: calc(var(--cell-size) / 2); + left: calc(var(--cell-size) / 2); top: calc(var(--cell-size) / 2); + transform: translate(-50%, -50%); + + border-radius: 100%; + + line-height: calc(var(--cell-size) / 2); + text-align: center; + + cursor: default; +} + +/* This is so explicit because of the CSS specificity rules. */ +.ui:not(.connect):not(.move) .vertex:not(.selected):not(.source):not(.target) .content:hover { + background: var(--cell-hover); +} + +.vertex.source .content { + background: var(--cell-source); +} + +.vertex.target .content { + background: var(--cell-target); +} + +.vertex.selected .content { + background: var(--cell-selected); +} + +.label { + display: block; + position: absolute; + left: 50%; top: 50%; + transform: translate(-50%, -50%); + + text-align: center; + font-size: 26px; + + pointer-events: none; + + transition: opacity 0.2s; +} + +.label.buffer { + visibility: hidden; +} + +/* Edges */ + +.edge { + position: absolute; +} + +.edge > svg { + position: absolute; + left: 50%; top: 50%; + transform: translate(-50%, -50%); +} + +/* The overlay edge drawn while connecting cells. */ +.overlay { + pointer-events: none; +} + +/* This is so explicit because of the CSS specificity rules. */ +.ui:not(.connect):not(.move) .edge:hover:not(.selected):not(.source):not(.target) { + background: var(--cell-hover); +} + +.edge.source { + background: var(--cell-source); +} + +.edge.target { + background: var(--cell-target); +} + +.edge.selected { + background: var(--cell-selected); +} + +.edge .label { + font-size: 20px; + text-align: right; +} + +/* The side panel */ + +.panel { + position: fixed; + width: 20%; height: 100%; + right: 0; + z-index: 1; + padding: 24px 16px; + + background: hsl(0, 0%, 20%); + + font: 14px sans-serif; + color: hsl(0, 0%, 80%); +} + +.panel input[type="text"] { + padding: 2px 4px; + + background: hsl(0, 0%, 16%); + border: hsl(0, 0%, 28%) solid 1px; + border-radius: 2px; + outline: none; + + font-size: inherit; + font-family: monospace; + color: hsl(0, 0%, 96%); +} + +.panel input[type="text"]:hover { + background: hsl(0, 0%, 18%); +} + +.panel input[type="text"]:focus { + background: hsl(0, 0%, 96%); + border-color: hsl(200, 100%, 40%); + + color: hsl(0, 0%, 16%); +} + +.panel input[type="text"]:disabled { + background: hsl(0, 0%, 20%); +} + +.panel .options { + margin: 8px 0; + + text-align: center; +} + +.panel input[type="radio"] { + -webkit-appearance: none; + display: inline-block; + width: 48px; height: 48px; + + background-color: hsl(0, 0%, 16%); + background-repeat: no-repeat; + background-position: center; + /* We use stacked backgrounds for the background image, */ + /* to allow us to change the image directly via CSS. */ + background-size: 0%, 100%; + border: hsl(0, 0%, 28%) solid 1px; + border-radius: 2px; + outline: none; +} + +.panel input[type="radio"]:hover { + background-color: hsl(0, 0%, 18%); +} + +.panel input[type="radio"]:checked { + background-color: hsl(0, 0%, 96%); + background-size: 100%, 0%; + border-color: hsl(200, 100%, 40%); +} + +.panel input[type="radio"]:disabled { + background-size: 0%, 100%; + background-color: hsl(0, 0%, 20%); + border-color: hsl(0, 0%, 28%); +} + +.panel button { + display: block; + position: absolute; + width: calc(100% - 8px * 2); height: 30px; + left: 8px; bottom: 8px; + + background: transparent; + border: transparent solid 1px; + border-radius: 2px; + outline: none; + + font: inherit; + color: hsl(0, 0%, 96%); +} + +.panel button:hover { + background: hsl(0, 0%, 24%); + border-color: hsl(0, 0%, 36%); +} + +.panel button:active { + background: hsl(0, 0%, 16%); + border-color: hsl(0, 0%, 36%); +} + +.export { + position: fixed; + width: 80%; height: 100%; + left: 0; + z-index: 1; + padding: 20px 24px; + + background: hsla(0, 0%, 10%, 0.8); + white-space: pre-wrap; + tab-size: 4; + + font: 16px monospace; + color: white; +} diff --git a/src/ui.js b/src/ui.js new file mode 100644 index 00000000..fe08b857 --- /dev/null +++ b/src/ui.js @@ -0,0 +1,1502 @@ +/// A helper object for dealing with the DOM. +const DOM = {}; + +/// A class for conveniently dealing with elements. It's primarily useful in giving us a way to +/// create an element and immediately set properties and styles, in a single statement. +DOM.Element = class { + /// `from` has two forms: a plain string, in which case it is used as a `tagName` for a new + /// element, or an existing element, in which case it is wrapped in a `DOM.Element`. + constructor(from, attributes = {}, style = {}, namespace = null) { + if (typeof from !== "string") { + this.element = from; + } else if (namespace !== null) { + this.element = document.createElementNS(namespace, from); + } else { + this.element = document.createElement(from); + } + for (const [attribute, value] of Object.entries(attributes)) { + this.element.setAttribute(attribute, value); + } + Object.assign(this.element.style, style); + } + + get id() { + return this.element.id; + } + + /// Appends an element. + /// `value` has two forms: a plain string, in which case it is added as a text node, or a + /// `DOM.Element`, in which case the corresponding element is appended. + add(value) { + if (typeof value !== "string") { + this.element.appendChild(value.element); + } else { + this.element.appendChild(document.createTextNode(value)); + } + return this; + } + + /// Adds an event listener. + listen(event, f) { + this.element.addEventListener(event, f); + return this; + } +}; + +/// A class for conveniently dealing with SVGs. +DOM.SVGElement = class extends DOM.Element { + constructor(tag_name, attributes = {}, style = {}) { + super(tag_name, attributes, style, "http://www.w3.org/2000/svg"); + } +}; + +/// A directed n-pseudograph (in the manner of an n-category, where (k + 1)-cells can connect +/// k-cells). +class Quiver { + constructor() { + /// An array of array of cells. `cells[k]` is the array of k-cells. + /// `cells[0]` is therefore the array of objects, etc. + this.cells = []; + + /// The inter-cell dependencies. That is: the edges that in some way are reliant on this + /// cell. Each map entry contains a map of edges to their dependency relationship, e.g. + /// "source" or "target". + this.dependencies = new Map(); + + /// Reverse dependencies (used for removing cells from `dependencies` when removing cells). + /// Each map entry is simply a set, unlike `dependencies`. + this.reverse_dependencies = new Map(); + } + + /// Add a new cell to the graph. + add(level, cell) { + this.dependencies.set(cell, new Map()); + this.reverse_dependencies.set(cell, new Set()); + + while (this.cells.length <= level) { + this.cells.push(new Set()); + } + this.cells[level].add(cell); + } + + /// Remove a cell from the graph. + remove(cell) { + const removed = new Set(); + const removal_queue = new Set([cell]); + for (const cell of removal_queue) { + this.cells[cell.level].delete(cell); + for (const [dependency,] of this.dependencies.get(cell)) { + removal_queue.add(dependency); + } + this.dependencies.delete(cell); + for (const reverse_dependency of this.reverse_dependencies.get(cell)) { + // If a cell is being removed as a dependency, then some of its + // reverse dependencies may no longer exist. + if (this.dependencies.has(reverse_dependency)) { + this.dependencies.get(reverse_dependency).delete(cell); + } + } + this.reverse_dependencies.delete(cell); + removed.add(cell); + } + return removed; + } + + /// Connect two cells. Note that this does *not* check whether the source and + /// target are compatible with each other. + connect(source, target, edge) { + this.dependencies.get(source).set(edge, "source"); + this.dependencies.get(target).set(edge, "target"); + + this.reverse_dependencies.get(edge).add(source); + this.reverse_dependencies.get(edge).add(target); + } + + /// Return a string containing the graph in a specific format. + /// Currently, the supported formats are: + /// - "tikzcd" + export(format) { + switch (format) { + case "tikzcd": + return QuiverExport.tikzcd.export(this); + default: + throw new Error(`unknown export format \`${format}\``); + } + } +} + +/// Various methods of exporting a quiver. +class QuiverExport { + /// A method to export a quiver as a string. + export() {} +} + +QuiverExport.tikzcd = new class extends QuiverExport { + export(quiver) { + let output = ""; + + // Early exit for empty quivers. (Technically, if cells have been placed and removed, + // we won't exit directly, but this is really for error handling, so this doesn't matter.) + if (quiver.cells.length === 0) { + return output; + } + + // We handle the export in two stages: vertices and edges. These are fundamentally handled + // differently in tikzcd, so it makes sense to separate them in this way. We have a bit of + // flexibility in the format in which we output (e.g. edges relative to nodes, or with + // absolute positions). + // We choose to lay out the tikzcd code as follows: + // (vertices) + // X & X & X \\ + // X & X & X \\ + // X & X & X + // (1-cells) + // (2-cells) + // ... + + // Output the vertices. + // Note that currently vertices may not share the same position, + // as in that case they will be overwritten. + let offset = new Position(Infinity, Infinity); + // Construct a grid for the vertices. + const rows = new Map(); + for (const vertex of quiver.cells[0]) { + if (!rows.has(vertex.position.y)) { + rows.set(vertex.position.y, new Map()); + } + rows.get(vertex.position.y).set(vertex.position.x, vertex); + offset = offset.min(vertex.position); + } + // Iterate through the rows and columns in order, outputting the tikzcd code. + const prev = new Position(offset.x, offset.y); + for (const [y, row] of Array.from(rows).sort()) { + if (y - prev.y > 0) { + output += ` ${"\\\\\n".repeat(y - prev.y)}`; + } + for (const [x, vertex] of Array.from(row).sort()) { + if (x - prev.x > 0) { + output += ` ${"&".repeat(x - prev.x)} `; + } + output += `{${vertex.label}}`; + prev.x = x; + } + prev.x = offset.x; + } + + // Referencing cells is slightly complicated by the fact that we can't give vertices + // names in tikzcd, so we have to refer to them by position instead. That means 1-cells + // have to be handled differently to k-cells for k > 1. + // A map of unique identifiers for cells. + const names = new Map(); + let index = 0; + const cell_reference = (cell) => { + if (cell.level === 0) { + // Note that tikzcd 1-indexes its cells. + return `${cell.position.y - offset.y + 1}-${cell.position.x - offset.x + 1}`; + } else { + return `${names.get(cell)}`; + } + }; + + // Output the edges. + for (let level = 1; level < quiver.cells.length; ++level) { + if (quiver.cells[level].size > 0) { + output += "\n"; + } + + // tikzcd only has supported for 1-cells and 2-cells. + // Anything else requires custom support, so for now + // we only special-case 2-cells. Everything else is + // drawn as if it is a 1-cell. + let style = level === 2 ? "Rightarrow, " : ""; + + for (const edge of quiver.cells[level]) { + const parameters = []; + let align = ""; + // We only need to give edges names if they're depended on by another edge. + if (quiver.dependencies.get(edge).size > 0) { + parameters.push(`name=${index}`); + names.set(edge, index++); + // In this case, because we have an argument list, we have to also change + // the syntax for alignment (technically, we can always use the quotation + // mark for swap, but it's simpler to be consistent with `description`). + switch (edge.options.label_alignment) { + case "centre": + parameters.push("description"); + break; + case "right": + parameters.push("swap"); + break; + } + } else { + switch (edge.options.label_alignment) { + case "centre": + // Centering is done by using the `description` style. + align = " description"; + break; + case "right": + // We can flip the side of the edge on which the label is drawn + // by appending a quotation mark to the label as an edge option. + align = "'"; + break; + } + } + output += `\\arrow[${style}` + + `"${edge.label}"${align}${ + parameters.length > 0 ? `{${parameters.join(", ")}}` : "" + }, ` + + `from=${cell_reference(edge.source)}, ` + + `to=${cell_reference(edge.target)}] `; + } + // Remove the trailing space. + output = output.slice(0, -1); + } + + // Surround the tikzcd code with `\begin{tikzcd} ... \end{tikzcd}`. + output = `\\begin{tikzcd}\n${ + output.split("\n").map(line => `\t${line}`).join("\n") + }\n\\end{tikzcd}`; + return output; + } +}; + +/// A quintessential (x, y) position. +class Position { + constructor(x, y) { + [this.x, this.y] = [x, y]; + } + + toString() { + return `${this.x} ${this.y}`; + } + + eq(other) { + return this.x === other.x && this.y === other.y; + } + + add(other) { + return new Position(this.x + other.x, this.y + other.y); + } + + sub(other) { + return new Position(this.x - other.x, this.y - other.y); + } + + div(divisor) { + return new Position(this.x / divisor, this.y / divisor); + } + + min(other) { + return new Position(Math.min(this.x, other.x), Math.min(this.y, other.y)); + } + + length() { + return Math.hypot(this.y, this.x); + } + + angle() { + return Math.atan2(this.y, this.x); + } +} + +/// An (width, height) pair. This is functionally equivalent to `Position`, but has different +/// semantic intent. +const Dimension = class extends Position { + get width() { + return this.x; + } + + get height() { + return this.y; + } +}; + +/// An HTML position. This is functionally equivalent to `Position`, but has different semantic +/// intent. +class Offset { + constructor(left, top) { + [this.left, this.top] = [left, top]; + } + + /// Return a [left, top] arrow of CSS length values. + to_CSS() { + return [`${this.left}px`, `${this.top}px`]; + } + + /// Moves an `element` to the offset. + reposition(element) { + [element.style.left, element.style.top] = this.to_CSS(); + } +} + +/// Various states for the UI (e.g. whether cells are being rearranged, or connected, etc.). +class UIState { + constructor() { + // Used for the CSS class associated with the state. `null` means no class. + this.name = null; + } + + /// A placeholder method to clean up any state when a state is left. + release() {} +} + +/// The default state, representing no special action. +UIState.default = new class extends UIState {}; + +/// Two k-cells are being connected by an (k + 1)-cell. +UIState.Connect = class extends UIState { + constructor(ui, source, target = null) { + super(); + + this.name = "connect"; + + /// The source of a connection between two cells. + this.source = source; + + /// The target of a connection between two cells. + this.target = target; + + /// The overlay for drawing an edge between the source and the cursor. + this.overlay = new DOM.Element("div", { class: "edge overlay" }) + .add(new DOM.SVGElement("svg")) + .element; + ui.element.appendChild(this.overlay); + } + + release() { + this.overlay.remove(); + this.source.element.classList.remove("source"); + if (this.target !== null) { + this.target.element.classList.remove("target"); + } + } + + /// Update the overlay with a new cursor position. + update(ui, position) { + // We're drawing the edge again from scratch, so we need to remove all existing elements. + const svg = this.overlay.querySelector("svg"); + while (svg.firstChild) { + svg.removeChild(svg.firstChild); + } + if (!position.eq(this.source.position)) { + Edge.prototype.draw_edge( + ui, + this.overlay, + svg, + this.source.level + 1, + this.source.position, + // Lock on to the target if present, otherwise simply draw the edge + // to the position of the cursor. + this.target !== null ? this.target.position : position, + this.target !== null, + null, + ); + } + } + + /// Returns whether the `source` is compatible with the specified `target`. + /// This first checks that the source is valid at all. + // We currently only support 0-cells, 1-cells and 2-cells. This is solely + // due to a restriction with tikzcd. This restriction can be lifted in + // the editor with no issue. + valid_connection(target) { + return this.source.level <= 1 && + // To allow `valid_connection` to be used to simply check whether the source is valid, + // we ignore source–target compatibility if `target` is null. + (target === null || this.source.level === target.level); + } + + /// Connects the source and target. Note that this does *not* check whether the source and + /// target are compatible with each other. + connect(ui) { + const label = ui.debug ? `${ + String.fromCharCode("A".charCodeAt(0) + Math.floor(Math.random() * 26)) + }` : ""; + ui.deselect(); + + // We attempt to guess what the intended label alignment is, if the cells being connected + // form some path with existing connections. Otherwise we revert to the currently-selected + // label alignment in the panel. + const options = { + label_alignment: + ui.panel.element.querySelector('input[name="label-alignment"]:checked').value, + }; + // If *every* existing connection to source and target has a consistent label alignment, + // then `align` will be a singleton, in which case we use that element as the alignment. + // If it has `left` and `right` in equal measure (regardless of `centre`), then + // we will pick `centre`. Otherwise we keep the default. + const align = new Map(); + // We only want to pick `centre` when the source and target are equally constraining + // (otherwise we end up picking `centre` far too often). So we check that they're both + // being considered equally. This means `centre` is chosen only rarely, but often in + // the situations you want it. + let balance = 0; + const swap = alignment => ({ left: "right", centre: "centre", right: "left" }[alignment]); + const consider = (alignment, tip) => { + if (!align.has(alignment)) { + align.set(alignment, 0); + } + align.set(alignment, align.get(alignment) + 1); + balance += tip; + }; + const id = x => x; + for (const [edge, relationship] of ui.quiver.dependencies.get(this.source)) { + consider({ source: swap, target: id }[relationship](edge.options.label_alignment), -1); + } + for (const [edge, relationship] of ui.quiver.dependencies.get(this.target)) { + consider({ source: id, target: swap }[relationship](edge.options.label_alignment), 1); + } + if (align.size === 1) { + options.label_alignment = align.keys().next().value; + } else if (align.size > 0 && align.get("left") === align.get("right") && balance === 0) { + options.label_alignment = "centre"; + } + + // The edge itself does all the set up, such as adding itself to the page. + ui.select(new Edge(ui, label, this.source, this.target, options)); + ui.panel.element.querySelector('label input[type="text"]').focus(); + } +}; + +/// Cells are being moved to a different position. +UIState.Move = class extends UIState { + constructor(origin, selection) { + super(); + + this.name = "move"; + + /// The location from which the move was initiated (used to update positions relative to the + /// origin). + this.origin = origin; + + /// The group of cells that should be moved. + this.selection = selection; + } +}; + +class UI { + constructor(element) { + /// The quiver identified with the UI. + this.quiver = new Quiver(); + + /// The UI state (e.g. whether cells are being rearranged, or connected, etc.). + this.state = UIState.default; + + /// The size of each 0-cell. + this.cell_size = 128; + + /// All currently selected cells; + this.selection = new Set(); + + /// The element in which to place cells. + this.element = element; + + /// A map from `x,y` positions to vertices. Note that this + /// implies that only one vertex may occupy each position. + this.positions = new Map(); + + /// A set of unique idenitifiers for various objects (used for generating HTML `id`s). + this.ids = new Map(); + + /// The panel for viewing and editing cell data. + this.panel = new Panel(); + + /// A debug mode for convenience. Adds default random labels to cells. + this.debug = false; + } + + initialise() { + this.element.classList.add("ui"); + + // Set up the panel for viewing and editing cell data. + this.panel.initialise(this); + this.element.appendChild(this.panel.element); + + // Stop trying to connect cells when the mouse is released. + document.addEventListener("mouseup", () => { + if (event.button === 0) { + if (this.in_mode(UIState.Connect)) { + this.state.source.element.classList.remove("source"); + if (this.state.target !== null) { + this.state.target.element.classList.remove("target"); + } + } + this.switch_mode(UIState.default); + } + }); + + // Stop dragging cells when the mouse leaves the window. + this.element.addEventListener("mouseleave", () => { + if (this.in_mode(UIState.Move)) { + this.switch_mode(UIState.default); + } + }); + + // Deselect cells when the mouse is pressed. + this.element.addEventListener("mousedown", (event) => { + if (event.button === 0) { + this.deselect(); + } + }); + + // Handle global key presses (such as keyboard shortcuts). + document.addEventListener("keydown", event => { + switch (event.key) { + case "Backspace": + // Remove any selected cells. + if (!(document.activeElement instanceof HTMLInputElement)) { + for (const cell of this.selection) { + // Remove this cell and its dependents from the quiver + // and then from the HTML. + for (const removed of this.quiver.remove(cell)) { + removed.element.remove(); + } + } + this.selection = new Set(); + this.panel.update(this); + } + break; + case "Escape": + // Close any open panes. + this.panel.dismiss_export_pane(this); + break; + } + }); + + // Add the insertion point for new nodes. + const insertion_point = new DOM.Element("div", { class: "insertion-point" }).element; + this.element.appendChild(insertion_point); + + // Clicking on the insertion point reveals it, + // after which another click adds a new node. + insertion_point.addEventListener("mousedown", (event) => { + if (event.button === 0) { + if (!insertion_point.classList.contains("revealed")) { + // Reveal the insertion point upon a click. + insertion_point.classList.add("revealed"); + } else { + // We only stop propagation in this branch, so that clicking once in an + // empty grid cell will deselect any selected cells, but clicking a second + // time to add a new vertex will not deselect the new, selected vertex we've + // just added. Note that it's not possible to select other cells in between + // the first and second click, because leaving the grid cell with the cursor + // (to select other cells) hides the insertion point again. + event.stopPropagation(); + insertion_point.classList.remove("revealed"); + const label = this.debug ? `\\mathscr{${ + String.fromCharCode("A".charCodeAt(0) + Math.floor(Math.random() * 26)) + }}` : ""; + const vertex = new Vertex(this, label, this.position_from_event(event)); + this.select(vertex); + this.queue(() => { + this.panel.element.querySelector('label input[type="text"]').focus(); + }); + } + } + }); + + // If the cursor leaves the insertion point, it gets hidden again. + insertion_point.addEventListener("mouseleave", () => { + insertion_point.classList.remove("revealed"); + }); + + // Moving the insertion point, and rearranging cells. + this.element.addEventListener("mousemove", (event) => { + const position = this.position_from_event(event); + const offset = this.offset_from_position(position); + offset.reposition(insertion_point); + + if (this.in_mode(UIState.Move)) { + // Prevent dragging from selecting random elements. + event.preventDefault(); + + // We will only try to reposition if the new position is actually different + // (rather than the cursor simply having moved within the same grid cell). + // On top of this, we prevent vertices from being moved into grid cells that + // are already occupied by vertices. + if (!position.eq(this.state.origin) && !this.positions.has(`${position}`)) { + // We'll need to move all of the edges connected to the moved vertices, + // so we keep track of which we need to update in `render_queue`. + const render_queue = new Set(); + // Move all the selected vertices. + for (const cell of this.state.selection) { + if (cell.level === 0) { + const position_delta = cell.position.sub(this.state.origin); + this.reposition(cell, position.add(position_delta)); + // Track all of the edges dependent on this vertex. + for (const [dependency,] of this.quiver.dependencies.get(cell)) { + render_queue.add(dependency); + } + } + } + this.state.origin = position; + + // Move all of the edges connected to cells that have moved. + // We're relying on the iteration order of the set here. + for (const edge of render_queue) { + edge.render(this); + // Track all of the edges dependent on this edge. + for (const [dependency,] of this.quiver.dependencies.get(edge)) { + render_queue.add(dependency); + } + } + + // Update the panel, so that the interface is kept in sync (e.g. the + // rotation of the label alignment buttons). + this.panel.update(this); + } + } + + if (this.in_mode(UIState.Connect)) { + // Prevent dragging from selecting random elements. + event.preventDefault(); + + this.state.update(this, this.position_from_event(event, false)); + } + }); + + // Set the grid background. + this.set_background(this.element); + } + + /// Returns whether the UI has a particular state. + in_mode(state) { + return this.state instanceof state; + } + + /// Transitions to a `UIState`. + switch_mode(state) { + if (this.state.constructor !== state.constructor) { + // Clean up any state for which this state is responsible. + this.state.release(); + if (this.state.name !== null) { + this.element.classList.remove(this.state.name); + } + this.state = state; + if (this.state.name !== null) { + this.element.classList.add(this.state.name); + } + } + } + + /// Queue an HTML event with `setTimeout`. This is sometimes necessary when triggering + /// UI state changes, such as triggering focus on elements. + queue(f) { + setTimeout(f, 0); + } + + /// A helper method for getting a position from an event. + position_from_event(event, round = true) { + const transform = round ? Math.round : x => x; + return new Position( + transform(event.pageX / this.cell_size - 0.5), + transform(event.pageY / this.cell_size - 0.5), + ); + } + + /// A helper method for getting an HTML (left, top) position from a grid `Position`. + offset_from_position(position, account_for_centering = true) { + return new Offset( + position.x * this.cell_size + (account_for_centering ? this.cell_size / 2 : 0), + position.y * this.cell_size + (account_for_centering ? this.cell_size / 2 : 0), + ); + } + + /// Selects a specific `cell`. Note that this does *not* deselect any cells that were + /// already selected. + select(cell) { + if (!this.selection.has(cell)) { + this.selection.add(cell); + cell.select(); + + this.panel.update(this); + } + } + + /// Deselect a specific `cell`, or deselect all cells if `cell` is null. + deselect(cell = null) { + if (cell === null) { + for (cell of this.selection) { + cell.deselect(); + } + this.selection = new Set(); + } else { + if (this.selection.delete(cell)) { + cell.deselect(); + } + } + + this.panel.update(this); + } + + /// Adds a cell to the canvas. + add_cell(cell) { + if (cell.level === 0) { + this.positions.set(`${cell.position}`, cell); + } + this.element.appendChild(cell.element); + } + + /// Moves a cell to a new position. This is specifically intended for vertices. + reposition(cell, position) { + if (!this.positions.has(`${position}`)) { + this.positions.delete(`${cell.position}`); + cell.position = position; + this.positions.set(`${cell.position}`, cell); + cell.render(this); + } else { + throw new Error( + "new cell position already contains a cell:", + this.positions.get(`${position}`), + ); + } + } + + /// Returns a unique identifier for an object. + unique_id(object) { + if (!this.ids.has(object)) { + this.ids.set(object, this.ids.size); + } + return this.ids.get(object); + } + + /// Renders TeX with MathJax and returns the corresponding element. + render_tex(tex = "", after = x => x) { + // We're going to fade the label in once it's rendered, so it looks less janky. + const label = new DOM.Element("div", { class: "label" }, { opacity: 0 }) + .add(`\\(${tex}\\)`) + .element; + MathJax.Hub.queue.Push( + ["Typeset", MathJax.Hub, label], + () => label.style.opacity = 1, + after, + ); + return label; + } + + // Set the grid background for the canvas. + set_background() { + // Constants for parameters of the grid pattern. + // The width of the cell border lines. + const BORDER_WIDTH = 2; + // The (average) length of the dashes making up the cell border lines. + const DASH_LENGTH = 6; + // The border colour. + const BORDER_COLOUR = "lightgrey"; + + // Construct the linear gradient corresponding to the dashed pattern (in a single cell). + let dashes = ""; + let x = 0; + while (x + DASH_LENGTH * 2 < this.cell_size) { + dashes += ` + transparent ${x += DASH_LENGTH}px, white ${x}px, + white ${x += DASH_LENGTH}px, transparent ${x}px, + `; + } + // Slice off the whitespace and trailing comma. + dashes = dashes.trim().slice(0, -1); + // Because we're perfectionists, we want to position the dashes so that the dashes forming + // the corners of each cell make a perfect symmetrical cross. This works out how to offset + // the dashes to do so. Full disclosure: I derived this equation observationally and it may + // not behave perfectly for all parameters. + const dash_offset = (2 * (this.cell_size / 16 % (DASH_LENGTH / 2)) - 1 + DASH_LENGTH) + % DASH_LENGTH + 1 - (DASH_LENGTH / 2); + + const grid_background = ` + linear-gradient(${dashes}), + linear-gradient(90deg, transparent ${this.cell_size - BORDER_WIDTH}px, ${BORDER_COLOUR} 0), + linear-gradient(90deg, ${dashes}), + linear-gradient(transparent ${this.cell_size - BORDER_WIDTH}px, ${BORDER_COLOUR} 0) + `; + + this.element.style.setProperty("--cell-size", `${this.cell_size}px`); + this.element.style.backgroundImage = grid_background; + this.element.style.backgroundPosition = ` + 0 ${dash_offset}px, + ${BORDER_WIDTH / 2}px 0, + ${dash_offset}px 0, + 0px ${BORDER_WIDTH / 2}px + `; + } +} + +/// A panel for editing cell data. +class Panel { + constructor() { + /// The panel element. + this.element = null; + + /// The export pane element (`null` if not currently shown). + this.export = null; + } + + /// Set up the panel interface elements. + initialise(ui) { + this.element = new DOM.Element("div", { class: "panel" }).element; + + // Prevent propogation of mouse events when interacting with the panel. + this.element.addEventListener("mousedown", (event) => { + event.stopImmediatePropagation(); + }); + + // The label. + const label_input = new DOM.Element("input", { type: "text", disabled: true }); + const label = new DOM.Element("label").add("Label: ").add(label_input).element; + this.element.appendChild(label); + + // We buffer the MathJax rendering to reduce flickering. + // If the `.buffer` has no extra classes, then we are free to start a new MathJax + // TeX render. + // If the `.buffer` has a `.buffering` class, then we are rendering a label. This + // may be out of date, in which case we add a `.pending` class (which means we're + // going to rerender as soon as the current MathJax render has completed). + const render_tex = (cell) => { + const label = cell.element.querySelector(".label:not(.buffer)"); + const buffer = cell.element.querySelector(".buffer"); + const jax = MathJax.Hub.getAllJax(buffer); + if (!buffer.classList.contains("buffering") && jax.length > 0) { + buffer.classList.add("buffering"); + MathJax.Hub.Queue( + ["Text", jax[0], cell.label], + () => { + // Swap the label and the label buffer. + label.classList.add("buffer"); + buffer.classList.remove("buffer", "buffering"); + }, + () => { + if (cell.level > 0) { + cell.update_label_transformation(); + } + }, + ); + } else if (!buffer.classList.contains("pending")) { + MathJax.Hub.Queue(() => render_tex(cell)); + } + }; + // Handle label interaction: update the labels of the selected cells when + // the input field is modified. + label_input.listen("input", () => { + for (const selected of ui.selection) { + selected.label = label_input.element.value; + render_tex(selected); + } + }); + + // The label alignment options. + const alignments = new DOM.Element("div", { class: "options" }).element; + this.element.appendChild(alignments); + const create_alignment_option = (value) => { + const button = + new DOM.Element("input", { type: "radio", name: "label-alignment", value }).element; + button.addEventListener("change", () => { + if (button.checked) { + for (const selected of ui.selection) { + if (selected.level > 0) { + selected.options.label_alignment = button.value; + selected.render(ui); + } + } + } + }); + alignments.appendChild(button); + + // We're going to create background images for the label alignment buttons + // representing each of the alignments. We do this by creating SVGs so that + // the images are precisely right. + // We create two background images per button: one for the `:checked` version + // and one for the unchecked version. + const backgrounds = []; + // The length of the arrow. + const ARROW_LENGTH = 24; + // The radius of the box representing the text along the arrow. + const RADIUS = 3; + // The horizontal offset of the box representing the text from the arrowhead. + const X_OFFSET = 2; + // The vetical offset of the box representing the text from the arrow. + const Y_OFFSET = 6; + + let y_offset; + switch (value) { + case "left": + y_offset = -Y_OFFSET; + break; + case "centre": + y_offset = 0; + break; + case "right": + y_offset = Y_OFFSET; + break; + } + + for (const colour of ["black", "grey"]) { + const svg = new DOM.SVGElement("svg", { + xmlns: "http://www.w3.org/2000/svg", + }, { + stroke: colour, + }).element; + const gap = y_offset === 0 ? { length: RADIUS * 4, offset: X_OFFSET } : null; + const arrow = Edge.prototype.draw_arrow(svg, 1, ARROW_LENGTH, gap); + const rect = new DOM.SVGElement("rect", { + x: arrow.width / 2 - X_OFFSET - RADIUS, + y: arrow.height / 2 + y_offset - RADIUS, + width: RADIUS * 2, + height: RADIUS * 2, + }, { + fill: colour, + stroke: "none", + }).element; + svg.appendChild(rect); + backgrounds.push(`url(data:image/svg+xml;utf8,${encodeURI(svg.outerHTML)})`); + } + button.style.backgroundImage = backgrounds.join(", "); + + return button; + } + create_alignment_option("left").checked = true; + create_alignment_option("centre"); + create_alignment_option("right"); + + // The export button. + this.element.appendChild( + new DOM.Element("button").add("Export to LaTeX").listen("click", () => { + // Handle export button interaction: export the quiver. + if (this.export === null) { + // Get the tikzcd diagram code. + const output = ui.quiver.export("tikzcd"); + // Create the export pane. + this.export = new DOM.Element("div", { class: "export" }).add(output).element; + ui.element.appendChild(this.export); + // Select the code for easy copying. + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(this.export); + selection.removeAllRanges(); + selection.addRange(range); + // Disable cell data editing while the export pane is visible. + this.update(ui); + } else { + this.dismiss_export_pane(ui); + } + }).element + ); + } + + /// Update the panel state (i.e. enable/disable fields as relevant). + update(ui) { + const input = this.element.querySelector('label input[type="text"]'); + const label_alignments = this.element.querySelectorAll('input[name="label-alignment"]'); + if (this.export === null) { + if (ui.selection.size === 1) { + const cell = ui.selection.values().next().value; + input.value = cell.label; + input.disabled = false; + if (cell.level > 0) { + this.element.querySelector( + `input[name="label-alignment"][value="${cell.options.label_alignment}"]` + ).checked = true; + + // Rotate the label alignment buttons to reflect the direction of the arrow + // (at least to the nearest multiple of 90°). + const angle = cell.target.position.sub(cell.source.position).angle(); + for (const option of label_alignments) { + option.style.transform = `rotate(${ + Math.round(2 * angle / Math.PI) * 90 + }deg)`; + } + } + } else { + input.value = ""; + input.disabled = true; + } + for (const option of label_alignments) { + option.disabled = false; + } + } else { + input.disabled = true; + for (const option of label_alignments) { + option.disabled = true; + } + } + } + + /// Dismiss the export pane, if it is shown. + dismiss_export_pane(ui) { + if (this.export !== null) { + this.export.remove(); + this.export = null; + this.update(ui); + } + } +} + +/// An k-cell (such as a vertex or edge). This object represents both the +/// abstract properties of the cell as well as their HTML representation. +class Cell { + constructor(quiver, level, label = "") { + /// The k for which this cell is an k-cell. + this.level = level; + + /// The label with which the vertex or edge is annotated. + this.label = label; + + /// Add this cell to the quiver. + quiver.add(this.level, this); + + /// Elements are specialised depending on whether the cell is a vertex (0-cell) or edge. + this.element = null; + } + + /// Set up the cell's element with interaction events. + initialise(ui) { + this.element.classList.add("cell"); + + const content_element = this.content_element; + + /// For cells with a separate `content_element`, we allow the cell to be moved + /// by dragging its `element` (under the assumption it doesn't totally overlap + /// its `content_element`). + if (this.element !== content_element) { + this.element.addEventListener("mousedown", (event) => { + if (event.button === 0) { + event.stopPropagation(); + // If the cell we're dragging is part of the existing selection, + // then we'll move every cell that is selected. However, if it's + // not already part of the selection, we'll just drag this cell + // and ignore the selection. + const move = new Set(ui.selection.has(this) ? [...ui.selection] : [this]); + ui.switch_mode(new UIState.Move(ui.position_from_event(event), move)); + } + }); + } + + content_element.addEventListener("mousedown", (event) => { + if (event.button === 0) { + event.stopPropagation(); + + // If the cell is not already selected, we'll select it. + if (!ui.selection.has(this)) { + // Deselect all other nodes. + ui.deselect(); + ui.select(this); + const state = new UIState.Connect(ui, this); + if (state.valid_connection(null)) { + ui.switch_mode(state); + this.element.classList.add("source"); + } + } else { + // Otherwise, we'll focus the label input. + ui.queue(() => { + ui.panel.element.querySelector('label input[type="text"]').focus(); + }); + } + } + }); + + content_element.addEventListener("mouseenter", () => { + if (ui.in_mode(UIState.Connect)) { + if (ui.state.source !== this) { + if (ui.state.valid_connection(this)) { + ui.state.target = this; + this.element.classList.add("target"); + } + } + } + }); + + content_element.addEventListener("mouseleave", () => { + if (ui.in_mode(UIState.Connect)) { + if (ui.state.target === this) { + ui.state.target = null; + } + // We may not have the "target" class, but we may attempt to remove it + // regardless. We might still have the "target" class even if this cell + // is not the target, if we've immediately transitioned from targeting + // one cell to targeting another. + this.element.classList.remove("target"); + } + }); + + content_element.addEventListener("mouseup", (event) => { + if (event.button === 0) { + if (ui.in_mode(UIState.Connect)) { + if (ui.state.target === this) { + ui.state.connect(ui); + } + } + } + }); + + // Add the cell to the UI canvas. + ui.add_cell(this); + } + + /// The main element of interaction for the cell. Not necessarily `this.element`, as children + /// may override this getter. + get content_element() { + return this.element; + } + + select() { + this.element.classList.add("selected"); + } + + deselect() { + this.element.classList.remove("selected"); + } +} + +/// 0-cells, or vertices. This is primarily specialised in its set up of HTML elements. +class Vertex extends Cell { + constructor(ui, label = "", position) { + super(ui.quiver, 0, label); + + this.position = position; + this.render(ui); + super.initialise(ui); + } + + get content_element() { + if (this.element !== null) { + return this.element.querySelector(".content"); + } else { + return null; + } + } + + /// Create the HTML element associated with the vertex. + render(ui) { + const offset = ui.offset_from_position(this.position); + + const construct = this.element === null; + + // The container for the cell. + if (construct) { + this.element = new DOM.Element("div").element; + } + offset.reposition(this.element); + if (!construct) { + // If the element already existed, then as soon as we've moved it to the correct + // position, nothing remains to be done. + return; + } + + this.element.classList.add("vertex"); + + // The cell content (containing the label). + this.element.appendChild( + new DOM.Element("div", { + class: "content", + }) + // The label. + .add(new DOM.Element(ui.render_tex(this.label), { class: "label" })) + // Create an empty label buffer for flicker-free rendering. + .add(new DOM.Element(ui.render_tex(), { class: "label buffer" })) + .element + ); + } +} + +/// k-cells (for k > 0), or edges. This is primarily specialised in its set up of HTML elements. +class Edge extends Cell { + constructor(ui, label = "", source, target, options) { + super(ui.quiver, Math.max(source.level, target.level) + 1, label); + + this.source = source; + this.target = target; + ui.quiver.connect(this.source, this.target, this); + + this.options = Object.assign({ + label_alignment: "left", + }, options); + + this.render(ui); + super.initialise(ui); + } + + /// Create the HTML element associated with the edge. + render(ui) { + let svg = null; + + if (this.element !== null) { + // If an element already exists for the edge, then can mostly reuse it when + // re-rendering it. + svg = this.element.querySelector("svg"); + + // Clear the SVG: we're going to be completely redrawing it. We're going to keep around + // any definitions, though, as we can effectively reuse them. + for (const child of Array.from(svg.childNodes)) { + if (child.tagName !== "defs") { + child.remove(); + } + } + } else { + // The container for the edge. + this.element = new DOM.Element("div", { class: "edge" }).element; + + // The arrow SVG itself. + svg = new DOM.SVGElement("svg").element; + this.element.appendChild(svg); + + // The clear background for the label (for `centre` alignment). + const defs = new DOM.SVGElement("defs") + const mask = new DOM.SVGElement( + "mask", + { + id: `mask-${ui.unique_id(this)}`, + // Make sure the `mask` can affect `path`s. + maskUnits: "userSpaceOnUse", + }, + ); + mask.add(new DOM.SVGElement( + "rect", + { width: "100%", height: "100%"}, + { fill: "white" }, + )); + mask.add( + new DOM.SVGElement("rect", { class: "clear" }, { fill: "black", stroke: "none" }) + ); + defs.add(mask); + svg.appendChild(defs.element); + + // The edge label. + const label = ui.render_tex(this.label, () => this.update_label_transformation()); + this.element.appendChild(label); + // Create an empty label buffer for flicker-free rendering. + const buffer = ui.render_tex(); + buffer.classList.add("buffer"); + this.element.appendChild(buffer); + } + + // Set the edges's position. This is important only for the cells that depend on this one. + this.position = this.source.position.add(this.target.position).div(2); + + // Draw the edge itself. + this.draw_edge( + ui, + this.element, + svg, + this.level, + this.source.position, + this.target.position, + true, + null, + ); + + // Apply the mask to the edge. + for (const path of svg.querySelectorAll("path")) { + path.setAttribute("mask", `url(#mask-${ui.unique_id(this)})`); + } + // We only want to actually clear part of the edge if the alignment is `centre`. + svg.querySelector(".clear").style.display = + this.options.label_alignment === "centre" ? "inline" : "none"; + + // If the label has already been rendered, then clear the edge for it. + // If it has not already been rendered, this is a no-op: it will be called + // again when the label is rendered. + this.update_label_transformation(); + } + + /// Draw an edge on an existing SVG with respect to a parent `element`. + /// Note that this does not clear the SVG beforehand. + /// Returns the direction of the arrow. + draw_edge(ui, element, svg, level, source_position, target_position, offset_from_target, gap) { + // Constants for parameters of the arrow shapes. + const SVG_PADDING = Edge.SVG_PADDING; + // How much (vertical) space to give around the SVG. + const EDGE_PADDING = 4; + // How much space to leave between the cells this edge spans. (Less for other edges.) + let MARGIN = level === 1 ? ui.cell_size / 4 : ui.cell_size / 8; + + // The SVG for the arrow itself. + const offset_delta = ui.offset_from_position(target_position.sub(source_position), false); + const length = Math.hypot(offset_delta.top, offset_delta.left) + - MARGIN * (offset_from_target ? 2 : 1); + + // If the arrow has zero or negative length, then we can just return here. + // Otherwise we just get SVG errors from drawing invalid shapes. + if (length <= 0) { + // Pick an arbitrary direction to return. + return 0; + } + + const arrow = this.draw_arrow(svg, level, length, gap); + + // Transform the `element` so that the arrow points in the correct direction. + const direction = Math.atan2(offset_delta.top, offset_delta.left); + const source_offset = ui.offset_from_position(source_position); + element.style.left = `${source_offset.left + Math.cos(direction) * MARGIN}px`; + element.style.top = `${source_offset.top + Math.sin(direction) * MARGIN}px`; + [element.style.width, element.style.height] = + new Offset(arrow.width, arrow.height + EDGE_PADDING * 2).to_CSS(); + element.style.transformOrigin = `${SVG_PADDING}px ${arrow.height / 2 + EDGE_PADDING}px`; + element.style.transform = ` + translate(-${SVG_PADDING}px, -${arrow.height / 2 + EDGE_PADDING}px) + rotate(${direction}rad) + `; + + return direction; + } + + /// Draws an arrow on to an SVG. `length` must be nonnegative. + /// Note that this does not clear the SVG beforehand. + draw_arrow(svg, level, length, gap = null) { + // Constants for parameters of the arrow shapes. + const SVG_PADDING = Edge.SVG_PADDING; + // How much spacing to leave between lines for k-cells where k > 1. + const SPACING = 6; + // How wide the arrowhead should be (for a horizontal arrow). + const HEAD_WIDTH = SPACING + (level - 1) * 2; + // How tall the arrowhead should be (for a horizontal arrow). + const HEAD_HEIGHT = (level + 1) * SPACING; + + // We scale the arrowhead size so that it transitions smoothly from nothing. + const head_width = Math.min(length, HEAD_WIDTH); + const head_height = HEAD_HEIGHT * (head_width / HEAD_WIDTH); + + // Set up the SVG dimensions to fit the edge. + const [width, height] = [length + SVG_PADDING * 2, head_height + SVG_PADDING * 2]; + svg.setAttribute("width", width); + svg.setAttribute("height", height); + Object.assign(svg.style, { + fill: "none", + stroke: svg.style.stroke || "black", + strokeWidth: "1.5px", + strokeLinecap: "round", + strokeLinejoin: "round", + }); + + // A function for finding the width of an arrowhead at a certain y position, so that we can + // draw multiple lines to a curved arrow head perfectly. + const x = y => head_width * (1 - (1 - 2 * Math.abs(y) / head_height) ** 2) ** 0.5; + + // Draw all the lines. + for (let i = 0; i < level; ++i) { + let y = (i + (1 - level) / 2) * SPACING; + // This edge case is necessary simply for very short edges. + if (Math.abs(y) <= head_height / 2) { + const line = new DOM.SVGElement("path", { + d: ` + M ${SVG_PADDING} ${SVG_PADDING + head_height / 2 + y} + l ${length - x(y)} 0 + `.trim().replace(/\s+/g, " "), + }).element; + if (gap !== null) { + line.style.strokeDasharray = `${(length - gap.length) / 2}, ${gap.length}`; + line.style.strokeDashoffset = gap.offset; + } + svg.appendChild(line); + } + } + + // Draw the arrow head. + svg.appendChild(new DOM.SVGElement("path", { + d: ` + M ${SVG_PADDING + length} ${SVG_PADDING + head_height / 2} + a ${head_width} ${head_height / 2} 0 0 1 -${head_width} -${head_height / 2} + M ${SVG_PADDING + length} ${SVG_PADDING + head_height / 2} + a ${head_width} ${head_height / 2} 0 0 0 -${head_width} ${head_height / 2} + `.trim().replace(/\s+/g, " ") + }).element); + + return new Dimension(width, height); + } + + /// Update the `label` transformation (translation and rotation) as well as + /// the edge clearing size for `centre` alignment in accordance with the + /// dimensions of the label. + update_label_transformation() { + const label = this.element.querySelector(".label:not(.buffer)"); + + // Bound an `angle` to [0, π/2). + const bound_angle = (angle) => { + return Math.PI / 2 - Math.abs(Math.PI / 2 - ((angle % Math.PI) + Math.PI) % Math.PI); + }; + + const angle = this.target.position.sub(this.source.position).angle(); + + // How much to offset the label from the edge. + const LABEL_OFFSET = 16; + let label_offset; + switch (this.options.label_alignment) { + case "left": + label_offset = -1; + break; + case "centre": + label_offset = 0; + break; + case "right": + label_offset = 1; + break; + } + + // Reverse the rotation for the label, so that it always displays upright and offset it + // so that it is aligned correctly. + label.style.transform = ` + translate(-50%, -50%) + translateY(${ + (Math.sin(bound_angle(angle)) * label.offsetWidth / 2 + LABEL_OFFSET) * label_offset + }px) + rotate(${-angle}rad) + `; + + // Make sure the buffer is formatted identically to the label. + this.element.querySelector(".label.buffer").style.transform = label.style.transform; + + // Get the length of a line through the centre of the bounds rectangle at an `angle`. + const angle_length = (angle) => { + // Cut a rectangle out of the edge to leave room for the label text. + // How much padding around the label to give (cut out of the edge). + const CLEAR_PADDING = 4; + + return (Math.min( + label.offsetWidth / (2 * Math.cos(bound_angle(angle))), + label.offsetHeight / (2 * Math.sin(bound_angle(angle))), + ) + CLEAR_PADDING) * 2; + }; + + const [width, height] = + label.offsetWidth > 0 && label.offsetHeight > 0 ? + [angle_length(angle), angle_length(angle + Math.PI / 2)] + : [0, 0]; + + new DOM.SVGElement(this.element.querySelector("svg mask .clear"), { + x: label.offsetLeft - width / 2, + y: label.offsetTop - height / 2, + width, + height, + }); + }; +} +// How much (horizontal and vertical) space in the SVG to give around the arrow +// (to account for artefacts around the drawing). +// This is a constant shared between multiple methods, so we store it in the +// class variables for `Edge`. +Edge.SVG_PADDING = 4; + +// Initialise MathJax. +window.MathJax = { + jax: ["input/TeX", "output/SVG"], + extensions: ["tex2jax.js", "TeX/noErrors.js"], + messageStyle: "none", + skipStartupTypeset: true, + positionToHash: false, + showMathMenu: false, + showMathMenuMSIE: false, + TeX: { + noErrors: { + multiLine: false, + style: { + color: "hsl(0, 100%, 40%)", + font: "18px monospace", + border: "none", + }, + } + }, +}; + +// We want until the (minimal) DOM content has loaded, so we have access to `document.body`. +document.addEventListener("DOMContentLoaded", () => { + /// The global UI. + let ui = new UI(document.body); + ui.initialise(); +});