UNPKG

hexular

Version:

An extensible platform for hexagonal cellular automata

1,520 lines (1,430 loc) 65.2 kB
/** * @overview * @version 0.2-beta * @author graham * @copyright 2020 * @license Hexagonal Awareness License (HAL) */ /** * Filters are functions that take in a state value, plus optionally a {@link Cell} instance, and return a potentially * modified form of that value. * * Filters can be added and removed via {@link Model#addFilter} and {@link Model#removeFilter}. * * @namespace {object} Hexular.filters */ /** * Rules are functions that take in a {@link Cell} instance, and return a state value, typically a natural number. * * The rules provided here are for example purposes only. A somewhat more robust set of examples can be found in the * project's `/demo/rules.js` file. * * @namespace {object} Hexular.rules */ /** * A selection of sundry functions with anticipated general utility. * * @namespace {object} Hexular.util */ var Hexular = (function () { const DEFAULTS = { // Default size for cubic (hexagonal) topology radius: 30, // Default size for offset (rectangular) topology rows: 60, cols: 60, // Default rule is used whenever a cell state does not have an entry in model.rules defaultRule: identityRule, // Array type to use for import/export arrayType: Int8Array, // This is only needed if one is using modFilter or certain cell/neighborhood helper functions numStates: 2, // Some functions depend on the ground state evaluating to false so changing this may be weird groundState: 0, // Used by CanvasAdapter cellRadius: 10, cellGap: 1, cellBorderWidth: 0, highlightLineWidth: 2, colors: [ 'transparent', '#ccccbb', '#99998f', '#666655', '#33332f', '#cc4444', '#ee7722', '#eebb33', '#66bb33', '#66aaaa', '#4455bb', '#aa55bb', ], backgroundColor: '#ffffff', defaultColor: '#ccccff', }; /** * A collection of elements representing common hexagonal concepts for general semantic interoperability. * * @namespace {object} Hexular.enums */ /** * Enumerator representing flat-topped, the greatest of all hexagons. * * @name TYPE_FLAT * @memberof Hexular.enums */ const TYPE_FLAT = 0; /** * Enumerator representing pointy-topped hexagons. * * @name TYPE_POINTY * @memberof Hexular.enums */ const TYPE_POINTY = 1; /** * A collection of mathematical properties and functions used internally, which may be of interest when extending * core functionality. * * @namespace {object} Hexular.math */ const APOTHEM = Math.sqrt(3) / 2; let math = { apothem: APOTHEM, hextant: Math.PI * 2 / 6, tau: Math.PI * 2, inverseApothem: 1 / APOTHEM, vertices: [ [-1, 0], [-0.5, -APOTHEM], [0.5, -APOTHEM], [1, 0], [0.5, APOTHEM], [-0.5, APOTHEM], ], /** * 2*2 basis matrix for converting unit cubic [u, v] coordinates to cartesian [x, y]. * * @name basis * @type number[][] * @memberof Hexular.math */ basis: [ [2 * APOTHEM, APOTHEM], [0, 1.5] ], /** * 2*2 inverse basis matrix for converting unit cartesian [x, y] coordinates to cubic [u, v]. * * @name invBasis * @type number[][] * @memberof Hexular.math */ invBasis: [ [1 / (2 * APOTHEM), -1 / 3], [0, 2 / 3] ] }; /** * Class representing a Hexular error. * * @augments Error */ class HexError extends Error {} /** * @function methodNotImplemented * * @param {string} [methodName='method'] String description of method &mdash; for informational purposes only * @throws {HexError} * @memberof HexError */ HexError.methodNotImplemented = (methodName = 'method') => { throw new HexError(`Method not implemented: "${methodName}"`); } /** * @function validateKeys * * @param {object} object Object * @param {...string} ...args One or more string or string-coercible keys to check * @throws {HexError} * @memberof HexError */ HexError.validateKeys = (obj, ...args) => { for (let key of args) if (!obj[key]) throw new HexError(`${obj.constructor.name} requires "${key}" to be defined`); } /** * Abstract class representing a grid of cells connected according to some topology. */ class Model { /** * Abstract constructor for creating a `Model` instance. * * @param {...object} ...args One or more settings objects to apply to model */ constructor(...args) { let defaults = { /** * Default rule function to use for states not defined by {@link Model#rules}. * * For non-numeric cell states, arbitrary state keys can be added to this array. * * @name Model#defaultRule * @type function * @default {@link Hexular.rules.identityRule} */ defaultRule: DEFAULTS.defaultRule, /** * Default numeric type for binary import and export. * * @name Model#arrayType * @default Int8Array */ arrayType: DEFAULTS.arrayType, /** * Total number of states. * * Convenience attribute used by cell neighborhood and filter functions. * * @name Model#numStates * @type number * @default 2 */ numStates: DEFAULTS.numStates, /** * Default ground or "off" state for cells. * * Used by cell initialization, {@link Model#import}, and {@link Model#clear}, and * potentially by {@link Hexular.filters|filters}, {@link Adapter|adapters}, and other extensions. * * @name Model#groundState * @type number * @default 0 */ groundState: DEFAULTS.groundState, /** * Non-negative numberic value defining cell radius for spatial rendering. * * Used for determining x, y position of a cell in a given topology for e.g. rendering on a canvas. Not used * internally by model. * * @name Model#cellRadius * @type number * @default: 10 * @see {@link Model#basis} * @see {@link Model#getCoord} */ cellRadius: DEFAULTS.cellRadius, /** * Array of rule functions. * * Cells are matched with rules based on their states, with e.g. `rules[1]` being caled when * {@link Cell#state|cell.state} == `1`. * * @name Model#rules * @type function[] * @see {@link Hexular.rules} */ rules: [], /** * List of filter functions to call on every new cell state. * * @name Model#filters * @type HookList * @default {@link Hexular.filters.modFilter|[Hexular.filters.modFilter]} */ filters: new HookList(this), /** * Canonical, publicly-exposed one-dimensional array of cells in an order defined by a given subclass. * * @name Model#cells * @type Cell[] */ cells: [], /** * Mapping of cells to [x, y] coordinates computed using {@link Model#cellRadius} and (implementation *-dependent) {@link Model#getCoord}. * * Like {@link Model#cellRadius} and {@link Model#basis}, this is only necessary when rendering cells in a * spatial context. * * @name Model#cellMap * @type Map */ cellMap: new Map(), /** * Boolean flag that is set to true during {@link Model#step} when any {@link Cell#state} is changed, and false * otherwise. * * Can be used to e.g. automatically stop an auto-incrementing model when it goes "dead." * * @name Model#changed * @type boolean */ changed: null, }; Object.assign(this, defaults, ...args); /** * A 2*2 row-major transformation matrix for converting arbitrary adapter coordinates to cartesian [x, y] values. * * Derived from {@link Hexular.math.basis} scaled by {@link Model#cellRadius}. * * @name Model#basis * @type number[][] * @see {@link Model#cellRadius} * @see {@link Model#getCoord} */ this.basis = scalarOp(math.basis, this.cellRadius); /** * Apothem computed from {@link Model#cellRadius}. * * @name Model#cellApothem * @type number * */ this.cellApothem = this.cellRadius * math.apothem; // Add available adapter constructors as direct attributes of this instance Object.entries(attributes.classes.adapters).forEach(([className, Class]) => { this[className] = (...args) => new Class(this, ...args); }); } /** * Add filter function to model. * * @param {function} filter Filter to add * @param {number} [idx=this.filters.length] Optional insertion index (defaults to end of array) */ addFilter(filter, idx=this.filters.length) { let boundFilter = filter.bind(this); boundFilter.hash = this._hash(filter.toString()); this.filters.splice(idx, 0, boundFilter); } /** * Remove filter function from model. * * Since filters are bound to the model, and anonymous functions lack a name, they can't be directly compared to * those in `this.filters`, . Thus we identify and compare functions based on a hash value derived from the string * version of the function. The upshot being any identically-coded functions will be equivalent. * * @param {function} filter Filter to remove */ removeFilter(filter) { let hash = this._hash(filter.toString()); let idx = this.filters.findIndex(((e) => e.hash == hash)); if (idx < 0) return; this.filters.splice(idx, 1); return idx; } /** * Clear all filters. * */ clearFilters() { while (this.filters.length) this.filters.pop(); } /** * Advance state of each cell according to rule defined in {@link Model.rules|this.rules} for current state key. */ step() { this.changed = false; this.eachCell((cell) => { let nextState try { nextState = (this.rules[cell.state] || this.defaultRule)(cell); } catch (e) { let idx = this.cells.findIndex((e) => e == cell); console.error(`An error occurred while processing cell ${cell} at index ${idx}:`, e); if (e instanceof TypeError) { throw new HexError(`Invalid rule function for state "${cell.state}"`); } else { throw e; } } cell.nextState = this.filters.call(nextState, cell); if (!this.changed && cell.nextState != cell.state) this.changed = true; }); this.eachCell((cell) => { cell.lastState = cell.state; cell.state = cell.nextState; }); } /** * Reset each cell state to {@link Model.groundState|this.groundState}. */ clear() { this.eachCell((cell) => { cell.setState(this.groundState); }); } /** * Set {@link Cell#neighborhood} for each cell to given value. * * @param {number} neighborhood One of the natural numbers [6, 12, 18, 7, 13, 19]. */ setNeighborhood(neighborhood) { this.eachCell((cell) => { cell.neighborhood = neighborhood; }) } /** * Call a given function for each cell in a model, and return an array of that function's return values. * * This is essentially `forEach` on {@link Model#cells} but with array comprehension behavior. * * @param {function} fn Function to call for each {@link Cell|cell}, taking the cell as an argument * @return {number[]} Array of return values with same size as {@link Model#cells|this.cells} */ eachCell(fn) { let a = new Array(this.cells.length); for (let i = 0; i < this.cells.length; i++) { a[0] = fn(this.cells[i]); } return a; } /** * Build cell map using {@link Model#cellRadius} and `Model` subclass implementation of {@link Model#eachCoord}. * * This should optionally be called by an adapter, &c., that wishes to use canonical cartesian coordinates for * cells. This method should be idempotent. */ buildCellMap() { this.cellMap.clear(); this.eachCell((cell) => { let mapCoord = this.getCoord(cell); this.cellMap.set(cell, mapCoord); }); } /** * Call a given function for each coordinate defined by a model's topology. * * This is typically used by a model's constructor to instantiate cells, but should be exposed externally as well. * * @param {function} fn Function to call for each coordinate, taking a coordinate argument that e.g. is used to * construct {@link Cell#coord} */ eachCoord(fn) { HexError.methodNotImplemented('eachCoord'); } /** * Get coordinates of cell according to {@link Model#cellRadius}, relative to an origin defined by a subclass. * * @param {Adapter} adapter An adapter instance with {@link Model#cellRadius} and {@link Model#basis} defined * @param {Cell} cell The cell to position * @return {number[]} The cell's [x, y] position in the adapter's frame of reference */ getCoord(adapter, cell) { HexError.methodNotImplemented('getCoord'); } /** * Find cell at given cubic coordinates in model. * * There is no "topologically agnostic" way to spatially locate a cell in any given model. Thus, we leave the * onus on specific `Model` subclasses to convert cubic coordinates to their internal coordinate system, and allow * e.g. {@link Adapter} subclass instances to look up cells spatially using this convention. * * @param {number[]} coord Array of [u, v, w] coordinates * @return {Cell} Cell at coordinates, or null */ cellAtCubic([u, v, w]) { HexError.methodNotImplemented('cellAtCubic'); } /** * Get cell at specific [x, y] coordinates. * * This is used by Hexular Studio and potentially other display-facing applications for locating a cell from e.g. * a user's cursor position using {@link Model#cellRadius}. * * @param {number[]} coord An [x, y] coordinate tuple * @return {Cell} The cell at this location, or null * @see {@link Hexular.math.cartesianToCubic} * @see {@link Hexular.math.roundCubic} * @see {@link Model#cellAtCubic} */ cellAt([x, y]) { // First convert to cubic coords let rawCubic = cartesianToCubic([x, y]); let cubic = roundCubic(rawCubic, this.cellRadius); let cell = this.cellAtCubic(cubic); return cell; } /** * Export cell states to a typed byte array. * * Neither this method nor its counterpart {@link Model#import} deals with other aspects of models or cells, * such as {@link Model#rules|rules} or {@link Cell#neighborhood|neighborhoods}, and will not prove effective, * under the default settings, for non-numeric states or states outside the range -128...128. * * @return {TypedArray} Typed array of cell states * @see {@link Model#arrayType}) * @see {@link Model#import} */ export() { let array = this.arrayType.from(this.cells.map((e) => e.state)); return array; } /** * Import cell states from typed or untyped array. * * @param {TypedArray|Array} array Any array of cell states * @see {@link Model#arrayType}) * @see {@link Model#export} */ import(array) { this.cells.forEach((cell, idx) => { cell.setState(array[idx] || this.groundState); }); } /** * Internal hashing function to track bound functions. Not actually important. * * @param {string} str Some string * @return {string} Chunked, summed mod 256 hexadecimal string */ _hash(str) { let bytes = new Uint8Array(str.split('').map((e) => e.charCodeAt(0))); let chunkSize = Math.max(2, Math.ceil(bytes.length / 16)); let chunked = bytes.reduce((a, e, i) => { a[Math.floor(i / chunkSize)] += e; return a; }, Array(Math.ceil(bytes.length / chunkSize)).fill(0)); return chunked.map((e) => ('0' + (e % 256).toString(16)).slice(-2)).join(''); } } /** * Class representing an offset, i.e. rectangular, topology. * * In an offset topology, cells describe a `cols * rows` grid where every other row is staggered one apothem-length * along the x axis. This is useful when trying to fit a grid into a rectangular box, but may cause undesirable * wraparound effects. (These effects may be mitigated by using {@link Hexular.filters.edgeFilter}.) * * @augments Model */ class OffsetModel extends Model { /** * Creates `OffsetModel` instance * * @param {...object} ...args One or more settings objects to apply to model */ constructor(...args) { super(...args); let defaults = { /** * @name OffsetModel#cols * @type number * @default 60 */ cols: DEFAULTS.cols, /** * @name OffsetModel#rows * @type number * @default 60 */ rows: DEFAULTS.rows, cells: [], }; Object.assign(this, defaults, args); HexError.validateKeys(this, 'rows', 'cols'); let rows = this.rows, cols = this.cols; this.eachCoord(([i, j]) => { // Being on an edge affects draw actions involving neighbors let edge = (i == 0 || i == this.cols - 1 || j == 0 || j == rows - 1); this.cells.push(new Cell(this, [i, j], {edge})); }); // Connect simple neighbors this.eachCell((cell) => { let [i, j] = cell.coord; let upRow = mod(j - 1, rows); let downRow = mod(j + 1, rows); let offset = downRow % 2; cell.nbrs[1] = this.cells[downRow * cols + mod(i - offset + 1, cols)]; cell.nbrs[2] = this.cells[j * cols + mod(i + 1, cols)]; cell.nbrs[3] = this.cells[upRow * cols + mod(i - offset + 1, cols)]; cell.nbrs[4] = this.cells[upRow * cols + mod(i - offset, cols)]; cell.nbrs[5] = this.cells[j * cols + mod(i - 1, cols)]; cell.nbrs[6] = this.cells[downRow * cols + mod(i - offset, cols)]; }); // Connect extended neighbors this.eachCell((cell) => { cell.extendNeighborhood(); }); } eachCoord(fn) { for (let j = 0; j < this.rows; j++) { for (let i = 0; i < this.cols; i++) { if (fn([i, j]) === false) return false; } } return true; } getCoord(cell) { let r = this.cellRadius; let [i, j] = cell.coord; // Like converting to cubic coords but mod 2 wrt x offset let x = this.basis[0][0] * i + this.basis[0][1] * (j % 2); let y = this.basis[1][0] * i + this.basis[1][1] * j; return [x, y]; } cellAtCubic([u, v, w]) { // For offset, we shift every two rows to the left v += u >> 1; let cell = this.cells[u * this.cols + v]; return cell; } } /** * Class representing a hexagonal model with cells addressed using cubic coordinates. * * Implements a regularly-hexagonal grid of cells addressed by coordinates `[u, v, w]`. The cell at the origin is * designated `[0, 0, 0]`, with all cell coordinate tuples summing to zero. In the default display provided by * {@link CanvasAdapter}, `u` points up, `v` points to the right, and `w` points to the left. * * For more information on this system, and how it translates to other coordinate systems, please see the excellent * article [Hexagonal Grids]{@link https://www.redblobgames.com/grids/hexagons/} from Red Blob Games. * * @augments Model */ class CubicModel extends Model { /** * Creates `CubicModel` instance. * * @param {...object} ...args One or more settings objects to apply to model */ constructor(...args) { super(...args); let defaults = { /** * @name CubicModel#radius * @type number * @default 30 */ radius: DEFAULTS.radius, }; Object.assign(this, defaults, ...args); HexError.validateKeys(this, 'radius'); this.size = this.radius * (this.radius - 1) * 3 + 1; let max = this.max = this.radius - 1; let cols = this.cols = this.radius * 2 - 1; this.rhombus = Array(cols * 2).fill(null); this.eachCoord(([u, v, w]) => { // Being on an edge affects draw actions involving neighbors let edge = absMax(u, v, w) == max; this.rhombus[u * cols + v] = new Cell(this, [u, v, w], {edge}); }); // A hack for the trivial case if (this.radius == 1) { this.rhombus[0].nbrs.fill(this.rhombus[0]); } // Otherwise connect simple neighbors else { Object.values(this.rhombus).filter((e) => e).forEach((cell) => { for (let i = 0; i < 6; i++) { let dir1 = i >> 1; let dir2 = (dir1 + 1 + i % 2) % 3; let dir3 = (dir1 + 1 + +!(i % 2)) % 3; let nbr = cell.coord.slice(); nbr[dir1] += 1; nbr[dir2] -= 1; nbr[dir3] = -nbr[dir1] - nbr[dir2]; for (let dir of [dir1, dir2, dir3]) { if (Math.abs(nbr[dir]) > max) { let sign = Math.sign(nbr[dir]); let dirA = (dir + 1) % 3; let dirB = (dir + 2) % 3; nbr[dir] -= sign * cols; nbr[dirA] += sign * max; nbr[dirB] = -nbr[dir] - nbr[dirA]; } } cell.nbrs[1 + (i + 5) % 6] = this.rhombus[nbr[0] * cols + nbr[1]]; } }); } /** * `CubicModel` orders its `cells` array in rings from the center out, starting with a zero-indexed origin cell. * This allows cell states to be backed up and restored via {@link Model#export} and {@link Model#import} across * differently-sized maps. Cells always remain centered and in the correct order, though a smaller map will * truncate cells outside of its radius. * * @name CubicModel.cells * @type Cell[] */ this.cells = hexWrap(this.rhombus[0], this.radius); // Connect extended neighbors this.eachCell((cell) => { cell.extendNeighborhood(); }); } eachCoord(fn) { for (let u = -this.max; u < this.radius; u++) { for (let v = -this.max; v < this.radius; v++) { let w = -u - v; if (Math.abs(w) > this.max) continue; if (fn([u, v, -u - v]) === false) return false; } } return true; } getCoord(cell) { let r = this.cellRadius; let [u, v, w] = cell.coord; let [x, y] = matrixMult(this.basis, [v, u]); return [x, y]; } cellAtCubic([u, v, w]) { if (absMax(u, v, w) > this.max) return null; let cell = this.rhombus[u * this.cols + v]; return cell; } } /** * Class representing a cell. */ class Cell { /** * Creates `Cell` instance. * * @param {Model} model Model for populating {@link Cell#model|this.model} * @param {number[]} coord Coordinate array for populating {@link Cell#coord|this.coord} * @param {...object} ...args One or more settings objects to apply to cell */ constructor(model, coord, ...args) { let defaults = { /** * {@link Model} instance associated with this cell (typically also its instantiator). * * @name Cell#model * @type Model */ model, /** * A coordinate vector whose shape and range are determined by the topology implemented in {@link Cell#model}. * * @name Cell#coord * @type number[] */ coord, /** * Cell state, used to look up rule index on each {@link Model#step}. * * @name Cell#state * @type number * @default {@link Model#groundState|this.model.groundState} * */ state: model.groundState, /** * Used by {@link Model#step} when calculating new cell states. * * @name Cell#nextState * @type number */ nextState: 0, /** * The previous cell state. * * @name Cell#lastState * @type number */ lastState: 0, /** * Numeric 19-element array with entries for the cell itself and its 18 nearest neighbors. * * - Entry 0 corresponds to the cell itself * - Entries 1-6 correspond to the cell's 6 nearest neighbors, progressing in a continuous arc * - Entries 7-12 correspond to those cells one edge-length from the cell, where `nbrs[7]` corresponds to the * cell touching `nbrs[1]` in the opposite direction the first ring progresses, but progressing in the same * direction as the first ring * - Entries 13-18 correspond to cells one full cell from the cell, where `nbrs[13]` corresponds to the cell * touching `nbrs[1]` opposite the home cell, also progressing in the same direction as the other two * * That is, we have three rings where the first neighbor in each ring forms a line zigzagging away from the * home cell. This arrangement allows successive neighborhoods to be iterated through contiguously using the * same array. * * @name Cell#nbrs * @type number */ nbrs: new Array(19).fill(null), /** * Value indicating which entry in {@link Cell#with} to look to when calling cell-level helper * functions, e.g. {@link Cell#count}, &c. * * @name Cell#neighborhood * @type number * @default 6 */ neighborhood: 6, }; Object.assign(this, defaults, ...args); this.nbrs[0] = this; /** * Array of {@link Neighborhood} instances, for efficiently calling helper methods over defined neighborhoods. * * @name Cell#with * @type Neighborhood[] * @see {@link Cell#neighborhood} */ this.with = { 6: new Neighborhood(this, 1, 7), 12: new Neighborhood(this, 1, 13), 18: new Neighborhood(this, 1, 19), 7: new Neighborhood(this, 0, 7), 13: new Neighborhood(this, 0, 13), 19: new Neighborhood(this, 0, 19), }; } /** * Builds out {@link Cell#nbrs|this.nbrs[7:19]} after [1:7} have been populated by {@link Model|this.model}. */ extendNeighborhood() { for (let i = 1; i < 7; i++) { let source12 = 1 + (i + 4) % 6; this.nbrs[i + 6] = this.nbrs[i].nbrs[source12]; this.nbrs[i + 12] = this.nbrs[i].nbrs[i]; } } /** * String representation of cell for debugging purposes. * * @return {string} String representation of {@link Cell#coord|this.coord} wrapped in square brackets. */ toString() { return `[${this.coord}]`; } /** * Set state and erase value of lastState. * * @param {*} state New cell state */ setState(state) { this.state = state; this.lastState = this.model.groundState; } /** * Shortcut for {@link Neighborhood#nbrSlice|this.with[this.neighborhood].nbrSlice}. * * @readonly */ get nbrSlice() { return this.with[this.neighborhood].nbrSlice; } /** * Shortcut for {@link Neighborhood#total|this.with[this.neighborhood].total}. * * @readonly */ get total() { return this.with[this.neighborhood].total; } /** * Shortcut for {@link Neighborhood#count|this.with[this.neighborhood].count}. * * @readonly */ get count() { return this.with[this.neighborhood].count; } /** * Shortcut for {@link Neighborhood#average|this.with[this.neighborhood].average}. * * @readonly */ get average() { return this.with[this.neighborhood].average; } /** * Shortcut for {@link Neighborhood#min|this.with[this.neighborhood].min}. * * @readonly */ get min() { return this.with[this.neighborhood].min; } /** * Shortcut for {@link Neighborhood#max|this.with[this.neighborhood].max}. * * @readonly */ get max() { return this.with[this.neighborhood].max; } /** * Shortcut for {@link Neighborhood#histogram|this.with[this.neighborhood].histogram}. * * @readonly */ get histogram() { return this.with[this.neighborhood].histogram; } /** * Shortcut for {@link Neighborhood#map|this.with[this.neighborhood].map}. * * @readonly */ get map() { return this.with[this.neighborhood].map; } } /** * Class representing a neighborhood around a cell. * * The {@link Cell#nbrs} array contains 19 entries, starting with the cell itself. By selecting particular subsets of * this array, we can confine our iteration to the 6, 12, or 18 nearest neighbors, with or without the cell itself. */ class Neighborhood { /** * Creates `Neighborhood` instance. * * @param {Cell} cell Parent cell, usually instantiator of neighborhood * @param {number} min Minimum index (inclusive) of neighborhood in {@link Cell#nbrs}. * @param {number} max Maximum index (exclusive) of neighborhood in {@link Cell#nbrs}. */ constructor(cell, min, max) { this.cell = cell; this.nbrs = cell.nbrs; this.minIdx = min; this.maxIdx = max; this.length = max - min; } /** * Convenience method for returning limited neighbor array. * * For spatial efficiency purposes we don't keep an internal slice {@link Cell#nbrs}, but this may be useful for * e.g. writing certain drawing extensions, etc. * * @return {Cell[]} Array of cells in this neighborhood * @readonly */ get nbrSlice() { return this.nbrs.slice(this.minIdx, this.maxIdx); } /** * Cumulative total of all neighboring states. * * @return {number} * @readonly */ get total() { let a = 0; for (let i = this.minIdx; i < this.maxIdx; i++) a += this.nbrs[i].state; return a; } /** * Count of all activated (state > 0) neighbors. * * @return {number} * @readonly */ get count() { let a = 0; for (let i = this.minIdx; i < this.maxIdx; i++) a += this.nbrs[i].state ? 1 : 0; return a; } /** * Return average of neighbor states. * * @return {number} * @readonly */ get average() { let a = 0; for (let i = this.minIdx; i < this.maxIdx; i++) a += this.nbrs[i].state; return Math.floor(a / this.length); } /** * Get maximum neighbor state. * * @return {number} * @readonly */ get max() { let a = -Infinity; for (let i = this.minIdx; i < this.maxIdx; i++) if (this.nbrs[i].state > a) a = this.nbrs[i].state; return a; } /** * Get minimum neighbor state. * * @return {number} * @readonly */ get min() { let a = Infinity; for (let i = this.minIdx; i < this.maxIdx; i++) if (this.nbrs[i].state < a) a = this.nbrs[i].state; return a; } /** * A `numStates`-sized array containing neighbor counts for that state. * * @return {number[]} * @readonly */ get histogram() { let a = Array(this.cell.model.numStates).fill(0); for (let i = this.minIdx; i < this.maxIdx; i ++) if (this.nbrs[i].state < a.length) a[this.nbrs[i].state] += 1; return a; } /** * Array of cell states in neighborhood. * * @return {number[]} * @readonly */ get map() { let a = []; for (let i = this.minIdx; i < this.maxIdx; i++) { a.push(this.nbrs[i].state); } return a; } } /** * Class representing a list of callback hooks * * This class extends `Array`, and we can use standard array methods &mdash; e.g. `push`, &c. &mdash; to populate it. * * @augments Array */ class HookList extends Array { /** * Creates `HookList` instance. * * @param {*} owner Object or value for populating {@link HookList#owner|this.owner} * @param {function[]} functions Optional list of functions to add */ constructor(owner, functions=[]) { super(); /** * Object or value to be bound to functions in hook list. * * Typically a class instance. Overwriting this will have no effect on functions already in the list. * * @name HookList#owner */ this.owner = owner; this.replace(functions) } /** * Convenience method for removing all existing functions and optionally adding new ones. * * @param {function[]} functions List of new functions to add */ replace(functions=[]) { this.length = 0; for (let fn of functions) this.push(fn); } /** * Convenience method for removing member functions with filter function. * * Member functions that return true are kept; others are returned in an array. * * @param {function} fn Filter function taking a member function and returning a boolean value * @return {function[]} Member functions removed during filtering */ remove(fn) { let removed = []; this.replace(this.filter((e) => { let val = fn(e); val || removed.push(e); return val; })); return removed; } /** * Convenience method for rotating callback order. * * Sometimes a drawing callback is in the wrong place wrt others. This is essentially a wrapper for * `hookList.unshift(hookList.pop())` (default behavior) and associated operations. * * @param {number} [n=1] Negative/positive offset */ rotate(n=1) { if (n > 0) { let slice = this.splice(this.length -n); this.replace(slice.concat(this)); } else if (n < 0) { let slice = this.splice(0, -n); this.replace(this.concat(slice)); } } /** * Call each function entry in hook list, bound to {@link HookList#owner|this.owner}. * * The first function is called with the arguments as given to this method. When a called * function returns a value besides `undefined`, `val` is set to that value for the next * function. The final state of `val` is returned at the end of the iteration. * * Thus, if constituent functions return a value, this operation serves to "filter" a value * from one function to the other. Conversely, if constituent functions do not return an * explicit value, this method essentially iterates over these functions with identical * arguments. * * The former mechanism is used by {@link Model#filters}, while the latter is used by * {@link CanvasAdapter#onDrawSelector}, and, when drawing individual cells, by {@link CanvasAdapter#onDrawCell}. * * @param {*} val First argument to be passed to at least initial function * @param {...*} ...args Additional arguments to pass to each hook function * @return {*} Return value of last hook function called, or original `val` */ call(val, ...args) { for (let i = 0; i < this.length; i++) { let newVal = this[i].call(this.owner, val, ...args); val = newVal === undefined ? val : newVal; } return val; } /** * Call each function entry for every value in the given array, completing each function for all elements in the * array before moving on to the next. * * Used by {@link CanvasAdapter#draw} to finish each successive drawing function for all cells in turn, allowing * more complex intercellular drawings. * * @param {array} array Array of values to pass to hook to functions * @param {...object} ...args Additional arguments to pass to each hook function */ callParallel(array, ...args) { for (let i = 0; i < this.length; i++) { for (let j = 0; j < array.length; j++) { this[i].call(this.owner, array[j], ...args); } } } } /** * Abstract class representing an adapter. * * This doesn't really do much. The minimal adapter interface may change in the future. */ class Adapter { /** * Creates `Adapter` instance. * * @param {Model} model Model to associate with this adapter * @param {...object} ...args One or more settings objects to apply to adapter */ constructor(model, ...args) { /** * `Model` instance to associate with this adapter. * * In the present implementation, this only needs to be a one-way relationship &mdash; models have no explicit * knowledge of adapters accessing them, though they can be instantiated via `model.ClassName()`, omitting * the `model` argument that would normally be passed to the constructor. * * @name Adapter#model * @type Model */ this.model = model; Object.assign(this, ...args); HexError.validateKeys(this, 'model'); } } /** * Class connecting a user agent canvas context to a model instance. * * This class is closely tailored to the needs of the Hexular Studio client, and probably does not expose the ideal * generalized interface for browser-based canvas rendering. * * Its functionality is tailored for multiple roles with respect to browser canvas drawing: * - Drawing all cell states, using a list of functions applied in parellel to all cells, one at a time * - Drawing one or more isolated selectors on a canvas to denote selected or otherwise highlighted cells * - Drawing one or more cell states given in a separate {@link CanvasAdapter#stateBuffer|stateBuffer}, which can * then be retrieved and written to underlying cell states * * All these modalities are employed by Hexular Studio using two such adapters &mdash; a foreground for selection and * tool paint buffering, and a background for current, canonical cell state. (This is a change from the original 2017 * version, which used just a single canvas and a somewhat awkward raster buffer for storing the unselected drawn * state of the cell and then redrawing it when the selection changed. This more or less worked but led to occasional * platform-specific artifacts. At any rate, one can easily override the default selector-drawing behavior and use a * single canvas if desired. * * Note that all static methods are intended to be bound to a particular instance via {@link CanvasAdapter#onDraw} * and {@link CanvasAdapter#onDrawCell}, and thus should be treated as instance methods wrt `this` binding, &c. See * {@link HookList} for more details. * * @augments Adapter */ class CanvasAdapter extends Adapter { /** * Creates `CanvasAdapter` instance. * * Requires at least {@link CanvasAdapter#context} to be given in `...args` settings. * * @param {Model} model Model to associate with this adapter * @param {...object} ...args One or more settings objects to apply to adapter */ constructor(model, ...args) { super(model); let defaults = { /** * Array of CSS hex or RGB color codes for cell fill color. * * @name CanvasAdapter#fillColors * @type string[] * @default Hexular.DEFAULTS.colors */ fillColors: DEFAULTS.colors, /** * Array of CSS hex or RGB color codes for cell line stroke color, if applicable. * * @name CanvasAdapter#strokeColors * @type string[] * @default Hexular.DEFAULTS.colors */ strokeColors: DEFAULTS.colors, /** * @name CanvasAdapter#defaultColor * @type string * @default #ccccff */ defaultColor: DEFAULTS.defaultColor, /** * @name CanvasAdapter#backgroundColor * @type string * @default #ffffff */ backgroundColor: DEFAULTS.backgroundColor, /** * @name CanvasAdapter#cellRadius * @type number * @default 10 */ cellRadius: DEFAULTS.cellRadius, /** * @name CanvasAdapter#cellGap * @type number * @default 1 */ cellGap: DEFAULTS.cellGap, /** * @name CanvasAdapter#cellBorderWidth * @type number * @default 0 */ cellBorderWidth: DEFAULTS.cellBorderWidth, /** * @name CanvasAdapter#context * @type 2DCanvasRenderingContext2D */ context: null, /** * @name CanvasAdapter#stateBuffer * @type Map */ stateBuffer: new Map(), }; Object.assign(this, defaults, ...args); HexError.validateKeys(this, 'context'); // Build cell map if not already built this.model.buildCellMap(); // Compute math stuff this.updateMathPresets(); /** * @name CanvasAdapter#onDraw * @type HookList * @default [] */ this.onDraw = new HookList(this); /** * @name CanvasAdapter#onDrawCell * @type HookList * @default {@link CanvasAdapter#drawfilledPointyHex|[this.defaultDawCell]} */ this.onDrawCell = new HookList(this); this.onDrawCell.push(this.drawFilledPointyHex); } /** * Precompute math parameters using principally {@link Model#cellRadius}. */ updateMathPresets() { this.cellRadius = this.model.cellRadius; this.innerRadius = this.cellRadius - this.cellGap / (2 * math.apothem); this.flatVertices = scalarOp(math.vertices, this.innerRadius); this.pointyVertices = scalarOp(math.vertices.map(([x, y]) => [y, x]), this.innerRadius); } /** * Draw all cells on {@link CanvasAdapter#context} context. * * Calls all functions in {@link CanvasAdapter#onDraw|this.onDraw} after clearing canvas but before drawing cells. */ draw() { this.cells = null; this.clear(); this.onDraw.call(); this.onDrawCell.callParallel(this.cells || this.model.cells); } /** * Clear canvas context * * When used with {@link CubicModel}, which is centered on the origin, we assume the context has been translated * to the center of its viewport. This is neither necessary nor assumed for other models though. Thus we simply * save the current transformation state, clear the visible viewport, and then restore the original transform. */ clear() { this.context.save(); this.context.setTransform(1, 0, 0, 1, 0, 0); this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); this.context.restore(); } /** * Draw individual cell. * * Calls every method of {@link CanvasAdapter#onDrawCell} with the given cell. * * This was originally called by {@link CanvasAdapter#draw}, but is now a standalone utility method. * * @param {Cell} cell The cell to draw */ drawCell(cell) { this.onDrawCell.call(cell); } /** * Default method to draw cell based on state in {@link CanvasAdapter#stateBuffer|this.stateBuffer}. * * Used for drawing new cell state segments, and then applying the changes to the underlying model as an atomic, * batch operation. This is used by e.g. painting tools in Hexular Studio. * * @param {Cell} cell The cell being drawn */ defaultDrawBuffer(cell) { let state = this.stateBuffer.get(cell); let color = this.fillColors[state]; if (!color) color = this.defaultColor; else if (color == 'transparent' || color.length == '9' && color.slice(-2) == '00' || color.length == '5' && color.slice(-1) == '0' || color.length && color.slice(-2) == '0)') color = this.backgroundColor; if (color) { this.context.fillStyle = color; this.drawPath(cell); this.context.fill(); } } /** * Internal method used to draw hexes for both selectors and cells. * * @param {Cell} cell The cell being drawn */ drawPath(cell, path=this.pointyVertices) { const [x, y] = this.model.cellMap.get(cell); let ctx = this.context; ctx.beginPath(); ctx.moveTo(x + path[0][0], y + path[0][1]); for (let i = 1; i < path.length; i++) ctx.lineTo(x + path[i][0], y + path[i][1]); ctx.closePath(); } /** * Utility function for drawing arbitrary hexagon * * @param {Cell|number[]} locator The cell at the position to be drawn, or an [x, y] coordinate tuple * @param {number} radius The hexagon's radius * @param {object} opts Optional arguments specifying e.g. stroke, fill, &c. * @param {boolean} [opts.stroke=false] Whether to draw stroke * @param {boolean} [opts.fill=false] Whether to draw fill * @param {boolean} [opts.strokeStyle=null] Stroke style * @param {boolean} [opts.fillStyle=null] Fill style */ drawHexagon(locator, radius, opts={}) { let defaults = { type: TYPE_POINTY, stroke: false, fill: false, strokeStyle: null, lineWidth: 0, fillStyle: null, }; opts = Object.assign(defaults, opts); const [x, y] = locator instanceof Cell ? this.model.cellMap.get(locator) : locator; let ctx = this.context; let path = opts.type == TYPE_POINTY ? math.pointyVertices : math.flatVertices; path = scalarOp(path, radius); ctx.beginPath(); ctx.moveTo(x + path[0][0], y + path[0][1]); for (let i = 1; i < path.length; i++) ctx.lineTo(x + path[i][0], y + path[i][1]); ctx.closePath(); if (opts.fill) { ctx.fillStyle = opts.fillStyle; ctx.fill(); } if (opts.stroke) { ctx.strokeStyle = opts.strokeStyle; ctx.lineWidth = opts.lineWidth; ctx.stroke(); } } /** * Internal method used to draw circle at cell's position using {#link Model#cellRadius}. * * Convenience method for use by optional or custom drawing callbacks. * * @param {Cell} cell The cell being drawn */ drawCircle(cell, radius=this.innerRadius) { const [x, y] = this.model.cellMap.get(cell); let ctx = this.context; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); } /** * Default cell drawing method. * * @param {Cell} cell The cell being drawn * @param {string} [style=null] Optional argument when called directly specifying fill style */ drawFilledPointyHex(cell, style) { this.context.fillStyle = style || this.fillColors[cell.state] || this.defaultColor; this.drawPath(cell); this.context.fill(); } /** * Draw cell outline. * * An alternative drawing method that uses {@link CanvasAdapter#cellBorderWidth|this.cellBorderWidth} to draw an * outline instead of a filled hex. * * @param {Cell} cell The cell being drawn * @param {string} [style=this.strokeColors[cell.state]] Optional stroke style * @param {number} [lineWidth=this.cellBorderWidth] Optional line width */ drawOutlinePointyHex(cell, style, lineWidth=this.cellBorderWidth) { if (lineWidth == 0) return; this.context.strokeStyle = style || this.strokeColors[cell.state] || this.defaultColor; this.context.lineWidth = lineWidth; this.drawPath(cell); this.context.stroke(); } /** * Draw filled, flat-top cell. * * @param {Cell} cell The cell being drawn * @param {string} [style=this.fillColors[cell.state]] Optional stroke style */ drawFilledFlatHex(cell, style) { this.context.fillStyle = style || this.fillColors[cell.state] || this.defaultColor; this.drawPath(cell, this.flatVertices); this.context.fill(); } /** * Draw flat-topped cell outline. * * @param {Cell} cell The cell being drawn * @param {string} [style=this.strokeColors[cell.state]] Optional stroke style * @param {number} [lineWidth=this.cellBorderWidth] Optional line width */ drawOutlineFlatHex(cell, style, lineWidth=this.cellBorderWidth) { if (lineWidth == 0) return; this.context.strokeStyle = style || this.strokeColors[cell.state] || this.defaultColor; this.context.lineWidth = lineWidth; this.drawPath(cell, this.flatVertices); this.context.stroke(); } /** * Draw cell as filled cicle. * * An alternative drawing method that draws a filled circle in