From 7f985358ee6f52bd047ba24f6e43b7d234d8e458 Mon Sep 17 00:00:00 2001 From: varkor Date: Sat, 22 Feb 2020 13:16:10 +0000 Subject: [PATCH] Split `ui.js` up a bit This could be more fine-grained, but it's a start, and allows me to start sharing data structures for the curved arrow refactor. --- dom.js | 92 ++++++ ds.js | 109 +++++++ index.html | 3 + quiver.js | 735 +++++++++++++++++++++++++++++++++++++++++ ui.js | 933 ----------------------------------------------------- 5 files changed, 939 insertions(+), 933 deletions(-) create mode 100644 dom.js create mode 100644 ds.js create mode 100644 quiver.js diff --git a/dom.js b/dom.js new file mode 100644 index 0000000..9bbaa55 --- /dev/null +++ b/dom.js @@ -0,0 +1,92 @@ +"use strict"; + +/// 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; + } + + get class_list() { + return this.element.classList; + } + + /// 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(type, f) { + this.element.addEventListener(type, event => f(event, this.element)); + return this; + } + + /// Removes all children from the element. + clear() { + while (this.element.firstChild !== null) { + this.element.firstChild.remove(); + } + 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 class for conveniently dealing with canvases. +DOM.Canvas = class extends DOM.Element { + constructor(from, width, height, attributes = {}, style = {}) { + super(from || "canvas", attributes, style); + this.context = this.element.getContext("2d"); + if (from === null) { + this.resize(width, height); + } + this.context.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); + } + + /// Resizes and clears the canvas. + resize(width, height) { + const dpr = window.devicePixelRatio; + // Only resize the canvas when necessary. + if (width * dpr !== this.element.width || height * dpr != this.element.height) { + this.element.width = width * dpr; + this.element.height = height * dpr; + this.element.style.width = `${width}px`; + this.element.style.height = `${height}px`; + this.context.setTransform(dpr, 0, 0, dpr, 0, 0); + } else { + this.context.clearRect(0, 0, width, height); + } + } +} diff --git a/ds.js b/ds.js new file mode 100644 index 0000000..6d0d7e2 --- /dev/null +++ b/ds.js @@ -0,0 +1,109 @@ +"use strict"; + +/// An enumeration type. +class Enum { + constructor(name, ...variants) { + for (const variant of variants) { + this[variant] = Symbol(`${name}::${variant}`); + } + } +} + +/// A quintessential (x, y) position. +class Position { + constructor(x, y) { + [this.x, this.y] = [x, y]; + } + + static zero() { + return new Position(0, 0); + } + + toString() { + return `${this.x} ${this.y}`; + } + + toArray() { + return [this.x, this.y]; + } + + eq(other) { + return this.x === other.x && this.y === other.y; + } + + add(other) { + return new (this.constructor)(this.x + other.x, this.y + other.y); + } + + sub(other) { + return new (this.constructor)(this.x - other.x, this.y - other.y); + } + + div(divisor) { + return new (this.constructor)(this.x / divisor, this.y / divisor); + } + + min(other) { + return new (this.constructor)(Math.min(this.x, other.x), Math.min(this.y, other.y)); + } + + max(other) { + return new (this.constructor)(Math.max(this.x, other.x), Math.max(this.y, other.y)); + } + + length() { + return Math.hypot(this.y, this.x); + } + + angle() { + return Math.atan2(this.y, this.x); + } + + is_zero() { + return this.x === 0 && this.y === 0; + } +} + +/// An (width, height) pair. This is functionally equivalent to `Position`, but has different +/// semantic intent. +const Dimensions = class extends Position { + /// Returns a `Dimensions` with `{ width: 0, height: 0}`. + static zero() { + return new Dimensions(0, 0); + } + + 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]; + } + + /// Returns an `Offset` with `{ left: 0, top: 0}`. + static zero() { + return new Offset(0, 0); + } + + /// 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(); + } + + sub(other) { + return new (this.constructor)(this.left - other.left, this.top - other.top); + } +} diff --git a/index.html b/index.html index 2788e58..cee8af0 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,9 @@ + + + diff --git a/quiver.js b/quiver.js new file mode 100644 index 0000000..2c9e5df --- /dev/null +++ b/quiver.js @@ -0,0 +1,735 @@ +"use strict"; + +/// A directed n-pseudograph, in which (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). + /// That is: cells that this edge is reliant on. + /// Each map entry is simply a set, unlike `dependencies`. + this.reverse_dependencies = new Map(); + + /// A set of cells that have been deleted. We don't properly delete cells immediately, as + /// this makes it more awkward to revert deletion (with undo, for instance). Instead we add + /// them to `deleted` to mark them as such and remove them *solely* from `this.cells`. + /// Deleted cells are then ignored for all functional purposes involving dependencies. + /// Though `deleted` is primarily treated as a set, it is really a map from cells to the + /// point in time (e.g. history state) they were deleted at. This is so we can flush them + /// later to avoid accumulating memory. + this.deleted = new Map(); + } + + /// Add a new cell to the graph. + add(cell) { + if (!this.deleted.has(cell)) { + this.dependencies.set(cell, new Map()); + this.reverse_dependencies.set(cell, new Set()); + + while (this.cells.length <= cell.level) { + this.cells.push(new Set()); + } + } else { + this.deleted.delete(cell); + } + this.cells[cell.level].add(cell); + } + + /// Remove a cell from the graph. + remove(cell, when) { + const removed = new Set(); + const removal_queue = new Set([cell]); + for (const cell of removal_queue) { + if (!this.deleted.has(cell)) { + this.deleted.set(cell, when); + this.cells[cell.level].delete(cell); + // The edge case here and below (`|| []`) is for when a cell and its dependencies + // are being removed simultaneously, in which case the ordering of removal + // here can cause problems without taking this into consideration. + for (const [dependency,] of this.dependencies.get(cell) || []) { + removal_queue.add(dependency); + } + removed.add(cell); + } + } + return removed; + } + + /// Actually delete all deleted cells from the dependency data structures. + flush(when) { + for (const [cell, deleted] of this.deleted) { + if (deleted >= when) { + 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); + this.deleted.delete(cell); + } + } + } + + /// Connect two cells. Note that this does *not* check whether the source and + /// target are compatible with each other. It does handle reconnecting cells + /// that may already be connected to other cells. + connect(source, target, edge) { + // Clear any existing reverse dependencies. This is necessary if we're + // reconnecting an edge that is already connected. + const reverse_dependencies = this.reverse_dependencies.get(edge); + for (const cell of reverse_dependencies) { + this.dependencies.get(cell).delete(edge); + } + reverse_dependencies.clear(); + + this.dependencies.get(source).set(edge, "source"); + this.dependencies.get(target).set(edge, "target"); + + reverse_dependencies.add(source); + reverse_dependencies.add(target); + + // Reset the cell's level to ensure correct spacing when changing the level of the + // source/target cells. + const level = Math.max(source.level, target.level) + 1; + if (this.cells.length < level + 1) { + this.cells.push(new Set()); + } + this.cells[edge.level].delete(edge); + edge.level = level; + this.cells[level].add(edge); + } + + /// Returns a collection of all the cells in the quiver. + all_cells() { + return Array.from(this.dependencies.keys()).filter(cell => !this.deleted.has(cell)); + } + + /// Returns whether the quiver is empty. + is_empty() { + return this.dependencies.size - this.deleted.size === 0; + } + + /// Returns the non-deleted dependencies of a cell. + dependencies_of(cell) { + return new Map(Array.from(this.dependencies.get(cell)).filter(([dependency,]) => { + return !this.deleted.has(dependency); + })); + } + + /// Returns the non-deleted reverse dependencies of a cell. + reverse_dependencies_of(cell) { + return new Set(Array.from(this.reverse_dependencies.get(cell)).filter((dependency) => { + return !this.deleted.has(dependency); + })); + } + + /// Returns the transitive closure of the dependencies of a collection of cells + // (including those cells themselves, unless `exclude_roots`). + transitive_dependencies(cells, exclude_roots = false) { + const closure = new Set(cells); + // We're relying on the iteration order of the `Set` here. + for (const cell of closure) { + for (const [dependency,] of this.dependencies.get(cell)) { + if (!this.deleted.has(dependency)) { + closure.add(dependency); + } + } + } + if (exclude_roots) { + for (const cell of cells) { + closure.delete(cell); + } + } + return closure; + } + + /// Return a string containing the graph in a specific format. + /// Currently, the supported formats are: + /// - "tikz-cd" + /// - "base64" + export(format) { + switch (format) { + case "tikz-cd": + return QuiverExport.tikz_cd.export(this); + case "base64": + return QuiverImportExport.base64.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() {} +} + +class QuiverImportExport extends QuiverExport { + /// A method to import a quiver as a string. `import(export(quiver))` should be the + /// identity function. Currently `import` takes a `UI` into which to import directly. + import() {} +} + +QuiverExport.tikz_cd = new class extends QuiverExport { + export(quiver) { + let output = ""; + + // Wrap tikz-cd code with `\begin{tikzcd} ... \end{tikzcd}`. + const wrap_boilerplate = (output) => { + return `% ${QuiverImportExport.base64.export(quiver)}\n\\[\\begin{tikzcd}\n${ + output.length > 0 ? `${ + output.split("\n").map(line => `\t${line}`).join("\n") + }\n` : "" + }\\end{tikzcd}\\]`; + }; + + // Early exit for empty quivers. + if (quiver.is_empty()) { + return wrap_boilerplate(output); + } + + // We handle the export in two stages: vertices and edges. These are fundamentally handled + // differently in tikz-cd, 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 tikz-cd 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 tikz-cd 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)}`; + } + // This variable is really unnecessary, but it allows us to remove + // a leading space on a line, which makes things prettier. + let first_in_row = true; + for (const [x, vertex] of Array.from(row).sort()) { + if (x - prev.x > 0) { + output += `${!first_in_row ? " " : ""}${"&".repeat(x - prev.x)} `; + } + output += `{${vertex.label}}`; + prev.x = x; + first_in_row = false; + } + prev.x = offset.x; + prev.y = y; + } + + // Referencing cells is slightly complicated by the fact that we can't give vertices + // names in tikz-cd, 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.is_vertex()) { + // Note that tikz-cd 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"; + } + + for (const edge of quiver.cells[level]) { + const parameters = []; + const label_parameters = []; + let align = ""; + const nonempty_label = edge.label.trim(); + + // We only need to give edges names if they're depended on by another edge. + if (quiver.dependencies_of(edge).size > 0) { + label_parameters.push(`name=${index}`); + names.set(edge, index++); + // tikz-cd has a bug where parameters affect the edge style even the label + // is empty, so we only emit parameters when the label is nonempty. + if (nonempty_label) { + // In this case, because we have a parameter 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": + label_parameters.push("description"); + break; + case "over": + label_parameters.push("marking"); + break; + case "right": + label_parameters.push("swap"); + break; + } + } + } else { + if (nonempty_label) { + switch (edge.options.label_alignment) { + case "centre": + // Centring is done by using the `description` style. + align = " description"; + break; + case "over": + // Centring without clearing is done by using the `marking` style. + align = " marking"; + 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; + } + } + } + if (edge.options.offset !== 0) { + const side = edge.options.offset > 0 ? "right" : "left"; + parameters.push(`shift ${side}=${Math.abs(edge.options.offset)}`); + } + + let style = ""; + let label = nonempty_label !== "" ? `"{${edge.label}}"${align}` : ""; + + // Edge styles. + switch (edge.options.style.name) { + case "arrow": + // Body styles. + switch (edge.options.style.body.name) { + case "cell": + // tikz-cd 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. + if (edge.options.style.body.level === 2) { + style = "Rightarrow, "; + } + break; + + case "dashed": + parameters.push("dashed"); + break; + + case "dotted": + parameters.push("dotted"); + break; + + case "squiggly": + parameters.push("squiggly"); + break; + + case "none": + parameters.push("phantom"); + break; + } + + // Tail styles. + switch (edge.options.style.tail.name) { + case "maps to": + parameters.push("maps to"); + break; + + case "mono": + parameters.push("tail"); + break; + + case "hook": + parameters.push(`hook${ + edge.options.style.tail.side === "top" ? "" : "'" + }`); + break; + } + + // Head styles. + switch (edge.options.style.head.name) { + case "none": + parameters.push("no head"); + break; + + case "epi": + parameters.push("two heads"); + break; + + case "harpoon": + parameters.push(`harpoon${ + edge.options.style.head.side === "top" ? "" : "'" + }`); + break; + } + + break; + + case "adjunction": + case "corner": + parameters.push("phantom"); + + let angle; + + switch (edge.options.style.name) { + case "adjunction": + label = "\"\\dashv\""; + // Adjunction symbols should point in the direction of the arrow. + angle = -edge.angle() * 180 / Math.PI; + break; + case "corner": + label = "\"\\lrcorner\""; + label_parameters.push("very near start"); + // Round the angle to the nearest 45º, so that the corner always + // appears aligned with horizontal, vertical or diagonal lines. + angle = 45 - 45 * Math.round(4 * edge.angle() / Math.PI); + break; + } + + label_parameters.push(`rotate=${angle}`); + + // We allow these sorts of edges to have labels attached, + // even though it's a little unusual. + if (edge.label.trim() !== "") { + let anchor = ""; + switch (edge.options.label_alignment) { + case "left": + anchor = "anchor=west, "; + break; + case "centre": + anchor = "description, "; + break; + case "over": + anchor = "marking, "; + break; + case "right": + anchor = "anchor=east, "; + break; + } + parameters.push(`"{${edge.label}}"{${anchor}inner sep=1.5mm}`); + } + + break; + } + + // tikz-cd tends to place arrows between arrows directly contiguously + // without adding some spacing manually. + if (level > 1) { + parameters.push("shorten <=2mm"); + parameters.push("shorten >=2mm"); + } + + output += `\\arrow[${style}` + + (label !== "" || label_parameters.length > 0 ? `${label || "\"\""}${ + label_parameters.length > 0 ? `{${label_parameters.join(", ")}}` : "" + }, ` : "") + + `from=${cell_reference(edge.source)}, ` + + `to=${cell_reference(edge.target)}` + + (parameters.length > 0 ? `, ${parameters.join(", ")}` : "") + + "] "; + } + // Remove any trailing whitespace. + output = output.trim(); + } + + return wrap_boilerplate(output); + } +}; + +QuiverImportExport.base64 = new class extends QuiverImportExport { + // The format we use for encoding quivers in base64 (primarily for link-sharing) is + // the following. This has been chosen based on minimality (for shorter representations), + // rather than readability. + // + // Note that an empty quiver has no representation. + // + // `[version: integer, |vertices|: integer, ...vertices, ...edges]` + // + // Parameters: + // - `version` is currently only permitted to be 0. The format has been designed to be + // forwards-compatible with changes, so it is intended that this version will not + // change. + // - `|vertices|` is the length of the array `vertices`. + // - `vertices` is an array of vertices of the form: + // `[x: integer, y: integer, label: string]` + // - `edges` is an array of edges of the form: + // `[source: index, target: index, label: string, alignment, options]` + // + (label) `alignment` is an enum comprising the following options: + // * `0`: left + // * `1`: centre + // * `2`: right + // * `3`: over + // It has been distinguished from the other options as one that is frequently + // changed from the default, to avoid the overhead of encoding an options + // object. + // + `options` is an object containing the delta of the options from the defaults. + // This is the only parameter that is not encoded simply as an array, as the + // most likely parameter to be changed in the future. + // + // Notes: + // - An `index` is an integer indexing into the array `[...vertices, ...edges]`. + // - Arrays may be truncated if the values of the elements are the default values. + + export(quiver) { + // Remove the query string from the current URL and use that as a base. + const URL_prefix = window.location.href.replace(/\?.*$/, ""); + + if (quiver.is_empty()) { + // No need to have an encoding of an empty quiver; + // we'll just use the URL directly. + return URL_prefix; + } + + const cells = []; + const indices = new Map(); + + let offset = new Position(Infinity, Infinity); + // We want to ensure that the top-left cell is in position (0, 0), so we need + // to work out where the top-left cell actually is, to compute an offset. + for (const vertex of quiver.cells[0]) { + offset = offset.min(vertex.position); + } + for (const vertex of quiver.cells[0]) { + const { label } = vertex; + indices.set(vertex, cells.length); + const position = vertex.position.sub(offset).toArray(); + const cell = [...position]; + // In the name of efficiency, we omit any parameter that is not necessary. + if (label !== "") { + cell.push(label); + } + cells.push(cell); + } + + for (let level = 1; level < quiver.cells.length; ++level) { + for (const edge of quiver.cells[level]) { + const { level, label, options: { label_alignment, ...options } } = edge; + const [source, target] = [indices.get(edge.source), indices.get(edge.target)]; + indices.set(edge, cells.length); + const cell = [source, target]; + // We want to omit parameters that are unnecessary (i.e. have the default + // values). However, because we store parameters in an array, the only way + // we can distinguish missing values is by the length. Therefore, we can + // only truncate the array (not omit elements partway through the array). + // This means we may need to include unnecessary information if there is a + // non-default parameter after a default one. The parameters most likely to + // be default are placed further back in the array to reduce the frequency + // of this situation. + const end = []; + + // We compute a delta of the edge options compared + // to the default, so we encode a minimum of data. + const default_options = Edge.default_options({}, {}, level); + + // Recursively compute a delta between an `object` and `base`. + const probe = (object, base) => { + const delta = {}; + for (const [key, value] of Object.entries(object)) { + const default_value = base[key]; + if (typeof default_value === "object" && typeof value === "object") { + const subdelta = probe(value, default_value); + if (Object.keys(subdelta).length > 0) { + delta[key] = subdelta; + } + } else if (base[key] !== value) { + delta[key] = value; + } + } + return delta; + }; + + const delta = probe(options, default_options); + if (Object.keys(delta).length > 0) { + end.push(delta); + } + + const push_if_necessary = (parameter, default_value, condition = true) => { + if (end.length > 0 || (parameter !== default_value && condition)) { + end.push(parameter); + } + }; + + const variant = { left: 0, centre: 1, right: 2, over: 3 }[label_alignment]; + // It's only necessary to encode the label alignment is the label is not blank. + push_if_necessary(variant, 0, label !== ""); + push_if_necessary(label, ""); + + cell.push(...end.reverse()); + cells.push(cell); + } + } + + // The version of the base64 output format exported by this version of quiver. + const VERSION = 0; + const output = [VERSION, quiver.cells[0].size, ...cells]; + + // We use this `unescape`-`encodeURIComponent` trick to encode non-ASCII characters. + return `${URL_prefix}?q=${btoa(unescape(encodeURIComponent(JSON.stringify(output))))}`; + } + + import(ui, string) { + const quiver = new Quiver(); + + let input; + try { + // We use this `decodeURIComponent`-`escape` trick to encode non-ASCII characters. + const decoded = decodeURIComponent(escape(atob(string))); + if (decoded === "") { + return quiver; + } + input = JSON.parse(decoded); + } catch (_) { + throw new Error("invalid base64 or JSON"); + } + + // Helper functions for dealing with bad input. + + const assert = (condition, message) => { + const postfix = " in quiver encoding"; + if (!condition) { + throw new Error(`${message}${postfix}`); + } + }; + const assert_kind = (object, kind) => { + switch (kind) { + case "array": + assert(Array.isArray(object), `expected array`); + break; + case "integer": + case "natural": + assert(Number.isInteger(object), `expected integer`); + if (kind === "natural") { + assert(object >= 0, `expected non-negative integer`); + } + break; + case "string": + assert(typeof object === "string", `expected string`); + break; + case "object": + assert(typeof object === "object", `expected object`); + break; + default: + throw new Error(`unknown parameter kind \`${kind}\``); + } + }; + const assert_eq = (object, value) => { + assert(object === value, `expected \`${value}\`, but found \`${object}\``); + }; + + // Check all of the non-cell data is valid. + assert_kind(input, "array"); + const [version = 0, vertices = 0, ...cells] = input; + assert_kind(version, "natural"); + assert_eq(version, 0); + assert_kind(vertices, "natural"); + assert(vertices <= cells.length, "invalid number of vertices"); + + // We want to centre the view on the diagram, so we take the mean of all vertex positions. + let offset = Position.zero(); + // If we encounter errors while loading cells, we skip the malformed cell and try to + // continue loading the diagram, but we want to report the errors we encountered afterwards, + // to let the user know we were not entirely successful. + const errors = []; + + const indices = []; + for (const cell of cells) { + try { + assert_kind(cell, "array"); + + if (indices.length < vertices) { + // This cell is a vertex. + + assert(cell.length >= 2 && cell.length <= 3, "invalid vertex format"); + const [x, y, label = ""] = cell; + assert_kind(x, "natural"); + assert_kind(y, "natural"); + assert_kind(label, "string"); + + const position = new Position(x, y); + offset = offset.add(position); + const vertex = new Vertex(ui, label, position); + indices.push(vertex); + } else { + // This cell is an edge. + + assert(cell.length >= 2 && cell.length <= 5, "invalid edge format"); + const [source, target, label = "", alignment = 0, options = {}] + = cell; + for (const [endpoint, name] of [[source, "source"], [target, "target"]]) { + assert_kind(endpoint, "natural"); + assert(endpoint < indices.length, `invalid ${name} index`); + } + assert_kind(label, "string"); + assert_kind(alignment, "natural"); + assert(alignment <= 3, "invalid label alignment"); + assert_kind(options, "object"); + + // We currently don't validate `options` further than being an object. + // This is because it is likely that `options` will be extended in the future, + // and this permits a limited form of backwards compatibility. We never access + // prototype properties on `options`, so this should not be amenable to + // injection. + const level = Math.max(indices[source].level, indices[target].level) + 1; + const { style = {} } = options; + delete options.style; + + const edge = new Edge( + ui, + label, + indices[source], + indices[target], + Edge.default_options({ + label_alignment: ["left", "centre", "right", "over"][alignment], + ...options, + }, style, level), + ); + indices.push(edge); + } + } catch (error) { + errors.push(error); + } + } + + // Centre the view on the quiver. + const view_width = ui.element.offsetWidth - ui.panel.element.offsetWidth; + ui.pan_view( + new Offset(view_width / 2, ui.element.offsetHeight / 2) + .sub(ui.offset_from_position(ui.view, offset.div(vertices))) + ); + + // If the quiver is now nonempty, some toolbar actions will be available. + ui.toolbar.update(ui); + + if (errors.length > 0) { + // Just throw the first error. + throw errors[0]; + } + + return quiver; + } +}; diff --git a/ui.js b/ui.js index 88b97e1..bf758dd 100644 --- a/ui.js +++ b/ui.js @@ -1,938 +1,5 @@ "use strict"; -/// An enumeration type. -class Enum { - constructor(name, ...variants) { - for (const variant of variants) { - this[variant] = Symbol(`${name}::${variant}`); - } - } -} - -/// 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; - } - - get class_list() { - return this.element.classList; - } - - /// 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(type, f) { - this.element.addEventListener(type, event => f(event, this.element)); - return this; - } - - /// Removes all children from the element. - clear() { - while (this.element.firstChild !== null) { - this.element.firstChild.remove(); - } - 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 class for conveniently dealing with canvases. -DOM.Canvas = class extends DOM.Element { - constructor(from, width, height, attributes = {}, style = {}) { - super(from || "canvas", attributes, style); - this.context = this.element.getContext("2d"); - if (from === null) { - this.resize(width, height); - } - this.context.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); - } - - /// Resizes and clears the canvas. - resize(width, height) { - const dpr = window.devicePixelRatio; - // Only resize the canvas when necessary. - if (width * dpr !== this.element.width || height * dpr != this.element.height) { - this.element.width = width * dpr; - this.element.height = height * dpr; - this.element.style.width = `${width}px`; - this.element.style.height = `${height}px`; - this.context.setTransform(dpr, 0, 0, dpr, 0, 0); - } else { - this.context.clearRect(0, 0, width, height); - } - } -} - -/// A directed n-pseudograph, in which (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). - /// That is: cells that this edge is reliant on. - /// Each map entry is simply a set, unlike `dependencies`. - this.reverse_dependencies = new Map(); - - /// A set of cells that have been deleted. We don't properly delete cells immediately, as - /// this makes it more awkward to revert deletion (with undo, for instance). Instead we add - /// them to `deleted` to mark them as such and remove them *solely* from `this.cells`. - /// Deleted cells are then ignored for all functional purposes involving dependencies. - /// Though `deleted` is primarily treated as a set, it is really a map from cells to the - /// point in time (e.g. history state) they were deleted at. This is so we can flush them - /// later to avoid accumulating memory. - this.deleted = new Map(); - } - - /// Add a new cell to the graph. - add(cell) { - if (!this.deleted.has(cell)) { - this.dependencies.set(cell, new Map()); - this.reverse_dependencies.set(cell, new Set()); - - while (this.cells.length <= cell.level) { - this.cells.push(new Set()); - } - } else { - this.deleted.delete(cell); - } - this.cells[cell.level].add(cell); - } - - /// Remove a cell from the graph. - remove(cell, when) { - const removed = new Set(); - const removal_queue = new Set([cell]); - for (const cell of removal_queue) { - if (!this.deleted.has(cell)) { - this.deleted.set(cell, when); - this.cells[cell.level].delete(cell); - // The edge case here and below (`|| []`) is for when a cell and its dependencies - // are being removed simultaneously, in which case the ordering of removal - // here can cause problems without taking this into consideration. - for (const [dependency,] of this.dependencies.get(cell) || []) { - removal_queue.add(dependency); - } - removed.add(cell); - } - } - return removed; - } - - /// Actually delete all deleted cells from the dependency data structures. - flush(when) { - for (const [cell, deleted] of this.deleted) { - if (deleted >= when) { - 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); - this.deleted.delete(cell); - } - } - } - - /// Connect two cells. Note that this does *not* check whether the source and - /// target are compatible with each other. It does handle reconnecting cells - /// that may already be connected to other cells. - connect(source, target, edge) { - // Clear any existing reverse dependencies. This is necessary if we're - // reconnecting an edge that is already connected. - const reverse_dependencies = this.reverse_dependencies.get(edge); - for (const cell of reverse_dependencies) { - this.dependencies.get(cell).delete(edge); - } - reverse_dependencies.clear(); - - this.dependencies.get(source).set(edge, "source"); - this.dependencies.get(target).set(edge, "target"); - - reverse_dependencies.add(source); - reverse_dependencies.add(target); - - // Reset the cell's level to ensure correct spacing when changing the level of the - // source/target cells. - const level = Math.max(source.level, target.level) + 1; - if (this.cells.length < level + 1) { - this.cells.push(new Set()); - } - this.cells[edge.level].delete(edge); - edge.level = level; - this.cells[level].add(edge); - } - - /// Returns a collection of all the cells in the quiver. - all_cells() { - return Array.from(this.dependencies.keys()).filter(cell => !this.deleted.has(cell)); - } - - /// Returns whether the quiver is empty. - is_empty() { - return this.dependencies.size - this.deleted.size === 0; - } - - /// Returns the non-deleted dependencies of a cell. - dependencies_of(cell) { - return new Map(Array.from(this.dependencies.get(cell)).filter(([dependency,]) => { - return !this.deleted.has(dependency); - })); - } - - /// Returns the non-deleted reverse dependencies of a cell. - reverse_dependencies_of(cell) { - return new Set(Array.from(this.reverse_dependencies.get(cell)).filter((dependency) => { - return !this.deleted.has(dependency); - })); - } - - /// Returns the transitive closure of the dependencies of a collection of cells - // (including those cells themselves, unless `exclude_roots`). - transitive_dependencies(cells, exclude_roots = false) { - const closure = new Set(cells); - // We're relying on the iteration order of the `Set` here. - for (const cell of closure) { - for (const [dependency,] of this.dependencies.get(cell)) { - if (!this.deleted.has(dependency)) { - closure.add(dependency); - } - } - } - if (exclude_roots) { - for (const cell of cells) { - closure.delete(cell); - } - } - return closure; - } - - /// Return a string containing the graph in a specific format. - /// Currently, the supported formats are: - /// - "tikz-cd" - /// - "base64" - export(format) { - switch (format) { - case "tikz-cd": - return QuiverExport.tikz_cd.export(this); - case "base64": - return QuiverImportExport.base64.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() {} -} - -class QuiverImportExport extends QuiverExport { - /// A method to import a quiver as a string. `import(export(quiver))` should be the - /// identity function. Currently `import` takes a `UI` into which to import directly. - import() {} -} - -QuiverExport.tikz_cd = new class extends QuiverExport { - export(quiver) { - let output = ""; - - // Wrap tikz-cd code with `\begin{tikzcd} ... \end{tikzcd}`. - const wrap_boilerplate = (output) => { - return `% ${QuiverImportExport.base64.export(quiver)}\n\\[\\begin{tikzcd}\n${ - output.length > 0 ? `${ - output.split("\n").map(line => `\t${line}`).join("\n") - }\n` : "" - }\\end{tikzcd}\\]`; - }; - - // Early exit for empty quivers. - if (quiver.is_empty()) { - return wrap_boilerplate(output); - } - - // We handle the export in two stages: vertices and edges. These are fundamentally handled - // differently in tikz-cd, 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 tikz-cd 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 tikz-cd 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)}`; - } - // This variable is really unnecessary, but it allows us to remove - // a leading space on a line, which makes things prettier. - let first_in_row = true; - for (const [x, vertex] of Array.from(row).sort()) { - if (x - prev.x > 0) { - output += `${!first_in_row ? " " : ""}${"&".repeat(x - prev.x)} `; - } - output += `{${vertex.label}}`; - prev.x = x; - first_in_row = false; - } - prev.x = offset.x; - prev.y = y; - } - - // Referencing cells is slightly complicated by the fact that we can't give vertices - // names in tikz-cd, 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.is_vertex()) { - // Note that tikz-cd 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"; - } - - for (const edge of quiver.cells[level]) { - const parameters = []; - const label_parameters = []; - let align = ""; - const nonempty_label = edge.label.trim(); - - // We only need to give edges names if they're depended on by another edge. - if (quiver.dependencies_of(edge).size > 0) { - label_parameters.push(`name=${index}`); - names.set(edge, index++); - // tikz-cd has a bug where parameters affect the edge style even the label - // is empty, so we only emit parameters when the label is nonempty. - if (nonempty_label) { - // In this case, because we have a parameter 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": - label_parameters.push("description"); - break; - case "over": - label_parameters.push("marking"); - break; - case "right": - label_parameters.push("swap"); - break; - } - } - } else { - if (nonempty_label) { - switch (edge.options.label_alignment) { - case "centre": - // Centring is done by using the `description` style. - align = " description"; - break; - case "over": - // Centring without clearing is done by using the `marking` style. - align = " marking"; - 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; - } - } - } - if (edge.options.offset !== 0) { - const side = edge.options.offset > 0 ? "right" : "left"; - parameters.push(`shift ${side}=${Math.abs(edge.options.offset)}`); - } - - let style = ""; - let label = nonempty_label !== "" ? `"{${edge.label}}"${align}` : ""; - - // Edge styles. - switch (edge.options.style.name) { - case "arrow": - // Body styles. - switch (edge.options.style.body.name) { - case "cell": - // tikz-cd 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. - if (edge.options.style.body.level === 2) { - style = "Rightarrow, "; - } - break; - - case "dashed": - parameters.push("dashed"); - break; - - case "dotted": - parameters.push("dotted"); - break; - - case "squiggly": - parameters.push("squiggly"); - break; - - case "none": - parameters.push("phantom"); - break; - } - - // Tail styles. - switch (edge.options.style.tail.name) { - case "maps to": - parameters.push("maps to"); - break; - - case "mono": - parameters.push("tail"); - break; - - case "hook": - parameters.push(`hook${ - edge.options.style.tail.side === "top" ? "" : "'" - }`); - break; - } - - // Head styles. - switch (edge.options.style.head.name) { - case "none": - parameters.push("no head"); - break; - - case "epi": - parameters.push("two heads"); - break; - - case "harpoon": - parameters.push(`harpoon${ - edge.options.style.head.side === "top" ? "" : "'" - }`); - break; - } - - break; - - case "adjunction": - case "corner": - parameters.push("phantom"); - - let angle; - - switch (edge.options.style.name) { - case "adjunction": - label = "\"\\dashv\""; - // Adjunction symbols should point in the direction of the arrow. - angle = -edge.angle() * 180 / Math.PI; - break; - case "corner": - label = "\"\\lrcorner\""; - label_parameters.push("very near start"); - // Round the angle to the nearest 45º, so that the corner always - // appears aligned with horizontal, vertical or diagonal lines. - angle = 45 - 45 * Math.round(4 * edge.angle() / Math.PI); - break; - } - - label_parameters.push(`rotate=${angle}`); - - // We allow these sorts of edges to have labels attached, - // even though it's a little unusual. - if (edge.label.trim() !== "") { - let anchor = ""; - switch (edge.options.label_alignment) { - case "left": - anchor = "anchor=west, "; - break; - case "centre": - anchor = "description, "; - break; - case "over": - anchor = "marking, "; - break; - case "right": - anchor = "anchor=east, "; - break; - } - parameters.push(`"{${edge.label}}"{${anchor}inner sep=1.5mm}`); - } - - break; - } - - // tikz-cd tends to place arrows between arrows directly contiguously - // without adding some spacing manually. - if (level > 1) { - parameters.push("shorten <=2mm"); - parameters.push("shorten >=2mm"); - } - - output += `\\arrow[${style}` + - (label !== "" || label_parameters.length > 0 ? `${label || "\"\""}${ - label_parameters.length > 0 ? `{${label_parameters.join(", ")}}` : "" - }, ` : "") + - `from=${cell_reference(edge.source)}, ` + - `to=${cell_reference(edge.target)}` + - (parameters.length > 0 ? `, ${parameters.join(", ")}` : "") + - "] "; - } - // Remove any trailing whitespace. - output = output.trim(); - } - - return wrap_boilerplate(output); - } -}; - -QuiverImportExport.base64 = new class extends QuiverImportExport { - // The format we use for encoding quivers in base64 (primarily for link-sharing) is - // the following. This has been chosen based on minimality (for shorter representations), - // rather than readability. - // - // Note that an empty quiver has no representation. - // - // `[version: integer, |vertices|: integer, ...vertices, ...edges]` - // - // Parameters: - // - `version` is currently only permitted to be 0. The format has been designed to be - // forwards-compatible with changes, so it is intended that this version will not - // change. - // - `|vertices|` is the length of the array `vertices`. - // - `vertices` is an array of vertices of the form: - // `[x: integer, y: integer, label: string]` - // - `edges` is an array of edges of the form: - // `[source: index, target: index, label: string, alignment, options]` - // + (label) `alignment` is an enum comprising the following options: - // * `0`: left - // * `1`: centre - // * `2`: right - // * `3`: over - // It has been distinguished from the other options as one that is frequently - // changed from the default, to avoid the overhead of encoding an options - // object. - // + `options` is an object containing the delta of the options from the defaults. - // This is the only parameter that is not encoded simply as an array, as the - // most likely parameter to be changed in the future. - // - // Notes: - // - An `index` is an integer indexing into the array `[...vertices, ...edges]`. - // - Arrays may be truncated if the values of the elements are the default values. - - export(quiver) { - // Remove the query string from the current URL and use that as a base. - const URL_prefix = window.location.href.replace(/\?.*$/, ""); - - if (quiver.is_empty()) { - // No need to have an encoding of an empty quiver; - // we'll just use the URL directly. - return URL_prefix; - } - - const cells = []; - const indices = new Map(); - - let offset = new Position(Infinity, Infinity); - // We want to ensure that the top-left cell is in position (0, 0), so we need - // to work out where the top-left cell actually is, to compute an offset. - for (const vertex of quiver.cells[0]) { - offset = offset.min(vertex.position); - } - for (const vertex of quiver.cells[0]) { - const { label } = vertex; - indices.set(vertex, cells.length); - const position = vertex.position.sub(offset).toArray(); - const cell = [...position]; - // In the name of efficiency, we omit any parameter that is not necessary. - if (label !== "") { - cell.push(label); - } - cells.push(cell); - } - - for (let level = 1; level < quiver.cells.length; ++level) { - for (const edge of quiver.cells[level]) { - const { level, label, options: { label_alignment, ...options } } = edge; - const [source, target] = [indices.get(edge.source), indices.get(edge.target)]; - indices.set(edge, cells.length); - const cell = [source, target]; - // We want to omit parameters that are unnecessary (i.e. have the default - // values). However, because we store parameters in an array, the only way - // we can distinguish missing values is by the length. Therefore, we can - // only truncate the array (not omit elements partway through the array). - // This means we may need to include unnecessary information if there is a - // non-default parameter after a default one. The parameters most likely to - // be default are placed further back in the array to reduce the frequency - // of this situation. - const end = []; - - // We compute a delta of the edge options compared - // to the default, so we encode a minimum of data. - const default_options = Edge.default_options({}, {}, level); - - // Recursively compute a delta between an `object` and `base`. - const probe = (object, base) => { - const delta = {}; - for (const [key, value] of Object.entries(object)) { - const default_value = base[key]; - if (typeof default_value === "object" && typeof value === "object") { - const subdelta = probe(value, default_value); - if (Object.keys(subdelta).length > 0) { - delta[key] = subdelta; - } - } else if (base[key] !== value) { - delta[key] = value; - } - } - return delta; - }; - - const delta = probe(options, default_options); - if (Object.keys(delta).length > 0) { - end.push(delta); - } - - const push_if_necessary = (parameter, default_value, condition = true) => { - if (end.length > 0 || (parameter !== default_value && condition)) { - end.push(parameter); - } - }; - - const variant = { left: 0, centre: 1, right: 2, over: 3 }[label_alignment]; - // It's only necessary to encode the label alignment is the label is not blank. - push_if_necessary(variant, 0, label !== ""); - push_if_necessary(label, ""); - - cell.push(...end.reverse()); - cells.push(cell); - } - } - - // The version of the base64 output format exported by this version of quiver. - const VERSION = 0; - const output = [VERSION, quiver.cells[0].size, ...cells]; - - // We use this `unescape`-`encodeURIComponent` trick to encode non-ASCII characters. - return `${URL_prefix}?q=${btoa(unescape(encodeURIComponent(JSON.stringify(output))))}`; - } - - import(ui, string) { - const quiver = new Quiver(); - - let input; - try { - // We use this `decodeURIComponent`-`escape` trick to encode non-ASCII characters. - const decoded = decodeURIComponent(escape(atob(string))); - if (decoded === "") { - return quiver; - } - input = JSON.parse(decoded); - } catch (_) { - throw new Error("invalid base64 or JSON"); - } - - // Helper functions for dealing with bad input. - - const assert = (condition, message) => { - const postfix = " in quiver encoding"; - if (!condition) { - throw new Error(`${message}${postfix}`); - } - }; - const assert_kind = (object, kind) => { - switch (kind) { - case "array": - assert(Array.isArray(object), `expected array`); - break; - case "integer": - case "natural": - assert(Number.isInteger(object), `expected integer`); - if (kind === "natural") { - assert(object >= 0, `expected non-negative integer`); - } - break; - case "string": - assert(typeof object === "string", `expected string`); - break; - case "object": - assert(typeof object === "object", `expected object`); - break; - default: - throw new Error(`unknown parameter kind \`${kind}\``); - } - }; - const assert_eq = (object, value) => { - assert(object === value, `expected \`${value}\`, but found \`${object}\``); - }; - - // Check all of the non-cell data is valid. - assert_kind(input, "array"); - const [version = 0, vertices = 0, ...cells] = input; - assert_kind(version, "natural"); - assert_eq(version, 0); - assert_kind(vertices, "natural"); - assert(vertices <= cells.length, "invalid number of vertices"); - - // We want to centre the view on the diagram, so we take the mean of all vertex positions. - let offset = Position.zero(); - // If we encounter errors while loading cells, we skip the malformed cell and try to - // continue loading the diagram, but we want to report the errors we encountered afterwards, - // to let the user know we were not entirely successful. - const errors = []; - - const indices = []; - for (const cell of cells) { - try { - assert_kind(cell, "array"); - - if (indices.length < vertices) { - // This cell is a vertex. - - assert(cell.length >= 2 && cell.length <= 3, "invalid vertex format"); - const [x, y, label = ""] = cell; - assert_kind(x, "natural"); - assert_kind(y, "natural"); - assert_kind(label, "string"); - - const position = new Position(x, y); - offset = offset.add(position); - const vertex = new Vertex(ui, label, position); - indices.push(vertex); - } else { - // This cell is an edge. - - assert(cell.length >= 2 && cell.length <= 5, "invalid edge format"); - const [source, target, label = "", alignment = 0, options = {}] - = cell; - for (const [endpoint, name] of [[source, "source"], [target, "target"]]) { - assert_kind(endpoint, "natural"); - assert(endpoint < indices.length, `invalid ${name} index`); - } - assert_kind(label, "string"); - assert_kind(alignment, "natural"); - assert(alignment <= 3, "invalid label alignment"); - assert_kind(options, "object"); - - // We currently don't validate `options` further than being an object. - // This is because it is likely that `options` will be extended in the future, - // and this permits a limited form of backwards compatibility. We never access - // prototype properties on `options`, so this should not be amenable to - // injection. - const level = Math.max(indices[source].level, indices[target].level) + 1; - const { style = {} } = options; - delete options.style; - - const edge = new Edge( - ui, - label, - indices[source], - indices[target], - Edge.default_options({ - label_alignment: ["left", "centre", "right", "over"][alignment], - ...options, - }, style, level), - ); - indices.push(edge); - } - } catch (error) { - errors.push(error); - } - } - - // Centre the view on the quiver. - const view_width = ui.element.offsetWidth - ui.panel.element.offsetWidth; - ui.pan_view( - new Offset(view_width / 2, ui.element.offsetHeight / 2) - .sub(ui.offset_from_position(ui.view, offset.div(vertices))) - ); - - // If the quiver is now nonempty, some toolbar actions will be available. - ui.toolbar.update(ui); - - if (errors.length > 0) { - // Just throw the first error. - throw errors[0]; - } - - return quiver; - } -}; - -/// A quintessential (x, y) position. -class Position { - constructor(x, y) { - [this.x, this.y] = [x, y]; - } - - static zero() { - return new Position(0, 0); - } - - toString() { - return `${this.x} ${this.y}`; - } - - toArray() { - return [this.x, this.y]; - } - - eq(other) { - return this.x === other.x && this.y === other.y; - } - - add(other) { - return new (this.constructor)(this.x + other.x, this.y + other.y); - } - - sub(other) { - return new (this.constructor)(this.x - other.x, this.y - other.y); - } - - div(divisor) { - return new (this.constructor)(this.x / divisor, this.y / divisor); - } - - min(other) { - return new (this.constructor)(Math.min(this.x, other.x), Math.min(this.y, other.y)); - } - - max(other) { - return new (this.constructor)(Math.max(this.x, other.x), Math.max(this.y, other.y)); - } - - length() { - return Math.hypot(this.y, this.x); - } - - angle() { - return Math.atan2(this.y, this.x); - } - - is_zero() { - return this.x === 0 && this.y === 0; - } -} - -/// An (width, height) pair. This is functionally equivalent to `Position`, but has different -/// semantic intent. -const Dimensions = class extends Position { - /// Returns a `Dimensions` with `{ width: 0, height: 0}`. - static zero() { - return new Dimensions(0, 0); - } - - 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]; - } - - /// Returns an `Offset` with `{ left: 0, top: 0}`. - static zero() { - return new Offset(0, 0); - } - - /// 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(); - } - - sub(other) { - return new (this.constructor)(this.left - other.left, this.top - other.top); - } -} - /// Various states for the UI (e.g. whether cells are being rearranged, or connected, etc.). class UIState { constructor() {