scrawl-canvas
Version:
Responsive, interactive and more accessible HTML5 canvas elements. Scrawl-canvas is a JavaScript library designed to make using the HTML5 canvas element easier, and more fun
825 lines (580 loc) • 22.9 kB
JavaScript
// # Grid factory
// The Grid entity is a graphical representation of __a grid with equal width columns and equal height rows__, separated by gutters. Each tile within the grid can be filled with a different color, or a different gradient, or a different Picture entity output.
// #### Imports
import { constructors, entity } from '../core/library.js';
import { doCreate, isa_number, isa_obj, mergeOver, pushUnique, xt, xta, λnull, Ωempty } from '../helper/utilities.js';
import { releaseCell, requestCell } from '../untracked-factory/cell-fragment.js';
import baseMix from '../mixin/base.js';
import entityMix from '../mixin/entity.js';
// Shared constants
import { _isArray, _isFinite, _parse, BLACK, COLOR, ENTITY, FILL, SOURCE_IN, SOURCE_OVER, WHITE } from '../helper/shared-vars.js';
// Local constants
const _isInteger = Number.isSafeInteger || Number.isInteger,
CELL_GRADIENT = 'cellGradient',
GRAY = 'rgb(127 127 127 / 1)',
GRID_GRADIENT = 'gridGradient',
GRID_PICTURE = 'gridPicture',
T_GRID = 'Grid',
TILE_PICTURE = 'tilePicture';
// #### Grid constructor
const Grid = function (items = Ωempty) {
this.tileFill = [];
this.tileSources = [];
this.rowLines = null;
this.columnLines = null;
this.currentTileWidth = 0;
this.currentTileHeight = 0;
this.entityInit(items);
if (!items.tileSources) {
this.tileSources = [].concat([{
type: COLOR,
source: BLACK,
},
{
type: COLOR,
source: WHITE,
}]);
}
if (!items.tileFill) {
this.tileFill.length = this.columns * this.rows;
this.tileFill.fill(0);
}
else if (_isArray(items.tileFill) && this.tileFill.length === items.tileFill.length) {
this.tileFill = items.tileFill;
}
this.tilePaths = [];
this.tileRealCoordinates = [];
this.tileVirtualCoordinates = [];
if (!items.dimensions) {
if (!items.width) this.currentDimensions[0] = this.dimensions[0] = 20;
if (!items.height) this.currentDimensions[1] = this.dimensions[1] = 20;
}
return this;
};
// #### Block prototype
const P = Grid.prototype = doCreate();
P.type = T_GRID;
P.lib = ENTITY;
P.isArtefact = true;
P.isAsset = false;
// #### Mixins
baseMix(P);
entityMix(P);
// #### Grid attributes
const defaultAttributes = {
// __columns__, __rows__ - integer Numbers representing the number of columns and rows in the Grid
columns: 2,
rows: 2,
// __columnGutterWidth__, __rowGutterWidth__ - float Number distances (measured in px) between tiles in the grid.
columnGutterWidth: 1,
rowGutterWidth: 1,
// __tileSources__ - Array of Javascript Objects
// + Each Object describes a source which can be used to fill tiles
// + Available fills include: `color`, `cellGradient`, `gridGradient`, `gridPicture`, `tilePicture`
tileSources: null,
// __tileFill__ - Array of integer Numbers
// + Length of the Array will be `rows * columns`
// + Tiles are arranged left-to-right, top-to-bottom with tileFill[0] being the top left tile in the grid
// + Each Number represents the index of the tileSource object to be used to fill this tile
tileFill: null,
// __gutterColor__ - can accept the following sources:
// + A valid CSS color String
// + The name-String of a Scrawl-canvas Gradient or RadialGradient object, or the object itself
// + An integer Number representing the index of a tileSource object
gutterColor: GRAY,
};
P.defs = mergeOver(P.defs, defaultAttributes);
// #### Packet management
P.packetExclusions = pushUnique(P.packetExclusions, ['tileSources']);
P.finalizePacketOut = function (copy, items) {
const cSources = copy.tileSources = [],
tSources = this.tileSources;
tSources.forEach(item => {
cSources.push({
type: item.type,
source: (isa_obj(item.source)) ? item.source.name : item.source
});
});
if (isa_obj(copy.gutterColor)) copy.gutterColor = copy.gutterColor.name;
const stateCopy = _parse(this.state.saveAsPacket(items))[3];
copy = mergeOver(copy, stateCopy);
copy = this.handlePacketAnchor(copy, items);
return copy;
};
// #### Clone management
// No additional clone functionality required
// #### Kill management
// No additional kill functionality required
// #### Get, Set, deltaSet
const S = P.setters,
D = P.deltaSetters;
// __columns__
S.columns = function (item) {
if (isa_number(item)) {
if (!_isInteger(item)) item = parseInt(item, 10);
if (item !== this.columns) {
let i, iz, j;
const currentFill = this.tileFill,
currentCols = this.columns,
newFill = [];
this.columns = item;
for (i = 0, iz = this.rows; i < iz; i++) {
for (j = 0; j < item; j++) {
if (j < currentCols) newFill.push(currentFill[(i * currentCols) + j]);
else newFill.push(0);
}
}
this.tileFill = newFill;
}
}
this.dirtyPathObject = true;
this.dirtyFilterIdentifier = true;
};
D.columns = λnull;
// __rows__
S.rows = function (item) {
if (isa_number(item)) {
if (!_isInteger(item)) item = parseInt(item, 10);
if (item !== this.rows) {
const currentRows = this.rows;
this.rows = item;
this.tileFill.length = this.columns * item;
if (currentRows < item) this.tileFill.fill(0, currentRows * this.columns);
}
}
this.dirtyPathObject = true;
this.dirtyFilterIdentifier = true;
};
D.rows = λnull;
// #### Tile management
// `setAllTilesTo` - change the fill for all tiles in a Grid
// + Argument is an integer Number representing the index of a tileSource object
P.setAllTilesTo = function (val) {
if (isa_number(val)) {
if (!_isInteger(val)) val = parseInt(val, 10);
this.tileFill.fill(val);
this.dirtyFilterIdentifier = true;
}
return this;
};
// `setTileFill` - update the tileFill array
// + The array supplied as an argument must be the same length as current rows * columns
P.setTileFill = function (item) {
const { columns, rows } = this;
if (_isArray(item) && item.length === columns * rows) {
this.tileFill = item;
this.dirtyFilterIdentifier = true;
}
return this;
};
// `setTilesTo` - change the fill for a (set of) tile(s) in a Grid - requires two arguments:
// + First argument is an integer Number representing the position of a tile in the tileFill Array, or an Array of such Numbers
// + Second argument is an integer Number representing the index of a tileSource object
P.setTilesTo = function (tiles, val) {
const tileFill = this.tileFill;
if (xt(tiles) && isa_number(val)) {
if (!_isInteger(val)) val = parseInt(val, 10);
if (isa_number(tiles)) tileFill[tiles] = val;
else if (_isArray(tiles)) {
tiles.forEach(tile => {
if (isa_number(tile)) tileFill[tile] = val;
});
}
this.dirtyFilterIdentifier = true;
}
return this;
};
// `setTileSourceTo` - update or replace a tileSource object - requires two arguments:
// + First argument is the integer Number index of the tileSource object to be replaced
// + Second argument is the new or updated object
P.setTileSourceTo = function (index, obj) {
if (isa_number(index) && isa_obj(obj)) {
if (obj.type && obj.source) this.tileSources[index] = obj;
}
return this;
};
// `removeTileSource` - remove a tileSource object
// + Argument is an integer Number representing the index of a tileSource object
// + Object will be replaced with `null`
P.removeTileSource = function (index) {
if (isa_number(index) && index) {
this.tileSources[index] = null;
this.tileFill = this.tileFill.map(item => item === index ? 0 : item);
}
return this;
};
// `getTileSource` - returns the tileSource index Number for the given tile. Function is overloaded:
// + One argument: Number representing the position of a tile in the tileFill Array.
// + Two arguments: The `(row, column)` position of the tile in the Grid - both values start from `0`
P.getTileSource = function (row, col) {
if (isa_number(row)) {
if (!isa_number(col)) return this.tileFill[row];
else return this.tileFill[(row * this.rows) + col];
}
};
// `getTilesUsingSource` - returns an Array of tileFill index Numbers representing tiles that are currently using the tileSource Object at the given tileSource index.
P.getTilesUsingSource = function (key) {
const res = [];
if (isa_number(key)) this.tileFill.forEach((val, index) => val === key && res.push(index));
return res;
};
// `cleanPathObject` - internal - used for entity stamping (Display cycle), and collision detection
P.cleanPathObject = function () {
this.dirtyPathObject = false;
if (!this.noPathUpdates || !this.pathObject) {
const p = this.pathObject = new Path2D(),
rowLines = new Path2D(),
colLines = new Path2D();
const handle = this.currentStampHandlePosition,
scale = this.currentScale,
dims = this.currentDimensions;
const x = -handle[0] * scale,
y = -handle[1] * scale,
w = dims[0] * scale,
h = dims[1] * scale;
p.rect(x, y, w, h);
const cols = this.columns,
rows = this.rows,
colWidth = w / cols,
rowHeight = h / rows,
paths = this.tilePaths,
real = this.tileRealCoordinates,
virtual = this.tileVirtualCoordinates;
let i, j, cx, cy;
rowLines.moveTo(x, y);
rowLines.lineTo(x + w, y);
for (i = 1; i <= rows; i++) {
const ry = y + (i * rowHeight);
rowLines.moveTo(x, ry);
rowLines.lineTo(x + w, ry);
}
this.rowLines = rowLines;
colLines.moveTo(x, y);
colLines.lineTo(x, y + h);
for (j = 1; j <= cols; j++) {
cx = x + (j * colWidth);
colLines.moveTo(cx, y);
colLines.lineTo(cx, y + h);
}
this.columnLines = colLines;
paths.length = 0;
real.length = 0;
virtual.length = 0;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
const path = new Path2D();
cx = j * colWidth;
cy = i * rowHeight;
path.rect(x + cx, y + cy, colWidth, rowHeight);
paths.push(path);
virtual.push([cx, cy]);
real.push([x + cx, y + cy]);
}
}
this.currentTileWidth = colWidth;
this.currentTileHeight = rowHeight;
}
};
// ##### Stamp methods
// `performFill` - internal stamp method helper function
// + If you are not a fan of long, complex functions ... look away now!
P.performFill = function (engine) {
if (this.scale > 0) {
// Grab the current engine values for various things
engine.save();
const composer = requestCell(),
compEngine = composer.engine,
compCanvas = composer.element;
const tileSources = this.tileSources,
tileFill = this.tileFill,
tilePaths = this.tilePaths,
tileRealCoords = this.tileRealCoordinates,
tileVirtualCoords = this.tileVirtualCoordinates,
winding = this.winding,
tileWidth = this.currentTileWidth,
tileHeight = this.currentTileHeight,
scale = this.scale;
const dims = this.currentDimensions;
let currentPicture;
// Iterate through the grid's tileSources
tileSources.forEach((obj, index) => {
// Set up the engine fillStyle value (where required)
if (obj && obj.type) {
switch (obj.type) {
case COLOR :
engine.fillStyle = obj.source;
break;
case CELL_GRADIENT :
this.lockFillStyleToEntity = false;
engine.fillStyle = obj.source.getData(this, this.currentHost);
break;
case GRID_GRADIENT :
this.lockFillStyleToEntity = true;
engine.fillStyle = obj.source.getData(this, this.currentHost);
break;
}
}
// Get an map of tiles using this source
const validTiles = tileFill.map(item => item === index ? true : false);
if (validTiles.length) {
switch (obj.type) {
// Use pool canvas to compose the output
case GRID_PICTURE :
currentPicture = (obj.source.substring) ? entity[obj.source] : obj.source;
if (currentPicture.simpleStamp) {
compCanvas.width = dims[0] * scale;
compCanvas.height = dims[1] * scale;
compEngine.globalCompositeOperation = SOURCE_OVER;
compEngine.fillStyle = BLACK;
validTiles.forEach((tile, pos) => {
if (tile) compEngine.fillRect(tileVirtualCoords[pos][0], tileVirtualCoords[pos][1], tileWidth, tileHeight);
});
compEngine.globalCompositeOperation = SOURCE_IN;
currentPicture.simpleStamp(composer, {
startX: 0,
startY: 0,
width: dims[0] * scale,
height: dims[1] * scale,
method: FILL,
});
engine.drawImage(compCanvas, ~~tileRealCoords[0][0], ~~tileRealCoords[0][1]);
}
break;
case TILE_PICTURE :
currentPicture = (obj.source.substring) ? entity[obj.source] : obj.source;
if (currentPicture.simpleStamp) {
compCanvas.width = tileWidth;
compCanvas.height = tileHeight;
compEngine.globalCompositeOperation = SOURCE_OVER;
currentPicture.simpleStamp(composer, {
startX: 0,
startY: 0,
width: tileWidth,
height: tileHeight,
method: FILL,
});
validTiles.forEach((tile, pos) => tile && engine.drawImage(compCanvas, ~~tileRealCoords[pos][0], ~~tileRealCoords[pos][1]));
}
break;
default :
validTiles.forEach((tile, pos) => tile && engine.fill(tilePaths[pos], winding));
}
}
});
const gColor = this.gutterColor,
gRow = this.rowGutterWidth,
gCol = this.columnGutterWidth;
let gObject;
if(xt(gColor)) {
// Assign (or construct) the appropriate object to gObject
if (gColor.substring) {
gObject = {
type: COLOR,
source: this.gutterColor
};
}
else if (isa_obj(gColor)) gObject = gColor;
else if (isa_number(gColor) && isa_obj(tileSources[gColor])) gObject = tileSources[gColor];
// Set the engine's strokeStyle to the appropriate value (if needed)
switch (gObject.type) {
case CELL_GRADIENT :
this.lockFillStyleToEntity = false;
engine.strokeStyle = gObject.source.getData(this, this.currentHost);
break;
case GRID_GRADIENT :
this.lockFillStyleToEntity = true;
engine.strokeStyle = gObject.source.getData(this, this.currentHost);
break;
case COLOR :
engine.strokeStyle = gObject.source;
break;
}
switch (gObject.type) {
// Use pool canvas to compose the output
// + gridPicture and tilePicture both treated the same
case GRID_PICTURE :
case TILE_PICTURE :
if(gRow || gCol) {
currentPicture = (gObject.source.substring) ? entity[gObject.source] : gObject.source;
if (currentPicture.simpleStamp) {
const handle = this.currentStampHandlePosition,
scale = this.currentScale,
x = handle[0] * scale,
y = handle[1] * scale;
compCanvas.width = dims[0] * scale;
compCanvas.height = dims[1] * scale;
compEngine.globalCompositeOperation = SOURCE_OVER;
compEngine.strokeStyle = BLACK;
compEngine.translate(x, y);
if (gRow) {
compEngine.lineWidth = gRow;
compEngine.stroke(this.rowLines);
}
if (gCol) {
compEngine.lineWidth = gCol;
compEngine.stroke(this.columnLines);
}
compEngine.globalCompositeOperation = SOURCE_IN;
currentPicture.simpleStamp(composer, {
startX: 0,
startY: 0,
width: dims[0] * scale,
height: dims[1] * scale,
method: FILL,
});
engine.drawImage(compCanvas, ~~tileRealCoords[0][0], ~~tileRealCoords[0][1]);
compEngine.translate(0, 0);
}
}
break;
// We have a color/gradient all set up - stroke the lines directly onto grid
default :
if (gRow) {
engine.lineWidth = gRow;
engine.stroke(this.rowLines);
}
if (gCol) {
engine.lineWidth = gCol;
engine.stroke(this.columnLines);
}
}
}
releaseCell(composer);
engine.restore();
}
};
// `fill`
P.fill = function (engine) {
this.performFill(engine);
};
// `drawAndFill`
P.drawAndFill = function (engine) {
const p = this.pathObject;
engine.stroke(p);
this.currentHost.clearShadow();
this.performFill(engine);
};
// `fillAndDraw`
P.fillAndDraw = function (engine) {
const p = this.pathObject;
engine.stroke(p);
this.currentHost.clearShadow();
this.performFill(engine);
engine.stroke(p);
};
// `drawThenFill`
P.drawThenFill = function (engine) {
const p = this.pathObject;
engine.stroke(p);
this.performFill(engine);
};
// `fillThenDraw`
P.fillThenDraw = function (engine) {
const p = this.pathObject;
this.performFill(engine);
engine.stroke(p);
};
// `checkHit` - overrides position mixin function
// + Grid entitys need to return ALL of the successful hit coordinates, not just the first
// + They also need to include the tile index(es) of where the hit(s) took place within them
//
// Returns an object with the following attributes
// ```
// {
// x: x-coordinate of the _last_ successful hit,
// y: y-coordinate of the _last_ successful hit,
// tiles: Array of tile index Numbers representing each tile reporting a hit,
// artefact: the Grid entity object
// }
// ```
P.checkHit = function (items = []) {
if (this.noUserInteraction) return false;
if (!this.pathObject || this.dirtyPathObject) {
this.cleanPathObject();
}
const tests = (!_isArray(items)) ? [items] : items;
const mycell = requestCell(),
engine = mycell.engine,
stamp = this.currentStampPosition,
x = stamp[0],
y = stamp[1],
tiles = new Set(),
tilePaths = this.tilePaths;
let isGood, tx, ty;
const getCoords = (coords) => {
let x, y;
if (_isArray(coords)) {
x = coords[0];
y = coords[1];
}
else if (xta(coords, coords.x, coords.y)) {
x = coords.x;
y = coords.y;
}
else return [false];
if (!_isFinite(x) || !_isFinite(y)) return [false];
return [true, x, y];
}
mycell.rotateDestination(engine, x, y, this);
if (tests.some(test => {
[isGood, tx, ty] = getCoords(test);
if (!isGood) return false;
else return engine.isPointInPath(this.pathObject, tx, ty, this.winding);
}, this)) {
tests.forEach(test => {
[isGood, tx, ty] = getCoords(test);
if (isGood) {
tilePaths.some((path, index) => {
if (engine.isPointInPath(path, tx, ty, this.winding)) {
tiles.add(index);
return true;
}
return false;
})
}
});
releaseCell(mycell);
return {
x: tx,
y: ty,
tiles: [...tiles],
artefact: this
};
}
releaseCell(mycell);
return false;
};
// #### Factory
// ```
// let blueSource = {
// type: 'color',
// source: 'aliceblue',
// };
//
// let myGrid = scrawl.makeGrid({
//
// name: 'test-grid',
//
// startX: 'center',
// startY: 'center',
//
// handleX: 'center',
// handleY: 'center',
//
// width: 300,
// height: 200,
//
// columns: 6,
// rows: 6,
//
// tileSources: [blueSource, {
// type: 'color',
// source: 'red',
// }],
// });
// ```
export const makeGrid = function (items) {
if (!items) return false;
return new Grid(items);
};
constructors.Grid = Grid;