hexular
Version:
An extensible platform for hexagonal cellular automata
1,520 lines (1,430 loc) • 65.2 kB
JavaScript
/**
* @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 — 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 — e.g. `push`, &c. — 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 — 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 — 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