src/Canvas.js
"use strict"
import GridBasedModel from "./models/GridBasedModel.js"
import CPM from "./models/CPM.js"
import Grid2D from "./grid/Grid2D.js"
import CoarseGrid from "./grid/CoarseGrid.js"
import PixelsByCell from "./stats/PixelsByCell.js"
import ActivityConstraint from "./hamiltonian/ActivityConstraint.js"
import ActivityMultiBackground from "./hamiltonian/ActivityMultiBackground.js"
/**
* Class for taking a CPM grid and displaying it in either browser or with
* nodejs.
* Note: when using this class from outside the module, you don't need to import
* it separately but can access it from CPM.Canvas. */
class Canvas {
/** The Canvas constructor accepts a CPM object C or a Grid2D object.
@param {GridBasedModel|Grid2D|CoarseGrid} C - the object to draw, which must
be an object of class {@link GridBasedModel} (or its subclasses {@link CPM}
and {@link CA}), or a 2D grid ({@link Grid2D} or {@link CoarseGrid}).
Drawing of other grids is currently not supported.
@param {object} [options = {}] - Configuration settings
@param {number} [options.zoom = 1]- positive number specifying the zoom
level to draw with.
@param {number[]} [options.wrap = [0,0,0]] - if nonzero: 'wrap' the grid to
these dimensions; eg a pixel with x coordinate 201 and wrap[0] = 200 is
displayed at x = 1.
@param {string} [options.parentElement = document.body] - the element on
the html page where the canvas will be appended.
@example <caption>A CPM with Canvas</caption>
* let CPM = require( "path/to/build" )
*
* // Create a CPM, corresponding Canvas and GridManipulator
* // (Use CPM. prefix from outside the module)
* let C = new CPM.CPM( [200,200], {
* T : 20,
* J : [[0,20][20,10]],
* V:[0,500],
* LAMBDA_V:[0,5]
* } )
* let Cim = new CPM.Canvas( C, {zoom:2} )
* let gm = new CPM.GridManipulator( C )
*
* // Seed a cell at [x=100,y=100] and run 100 MCS.
* gm.seedCellAt( 1, [100,100] )
* for( let t = 0; t < 100; t++ ){
* C.timeStep()
* }
*
* // Draw the cell and save an image
* Cim.drawCells( 1, "FF0000" ) // draw cells of CellKind 1 in red
* Cim.writePNG( "my-cell-t100.png" )
*/
constructor( C, options ){
if( C instanceof GridBasedModel ){
/**
* The underlying model that is drawn on the canvas.
* @type {GridBasedModel|CPM|CA}
*/
this.C = C
/**
* The underlying grid that is drawn on the canvas.
* @type {Grid2D|CoarseGrid}
*/
this.grid = this.C.grid
/** Grid size in each dimension, taken from the CPM or grid object
* to draw.
* @type {GridSize} each element is the grid size in that dimension
* in pixels */
this.extents = C.extents
} else if( C instanceof Grid2D || C instanceof CoarseGrid ){
this.grid = C
this.extents = C.extents
}
/** Zoom level to draw the canvas with, set to options.zoom or its
* default value 1.
* @type {number}*/
this.zoom = (options && options.zoom) || 1
/** if nonzero: 'wrap' the grid to these dimensions; eg a pixel with x
* coordinate 201 and wrap[0] = 200 is displayed at x = 1.
* @type {number[]} */
this.wrap = (options && options.wrap) || [0,0,0]
/** Width of the canvas in pixels (in its unzoomed state)
* @type {number}*/
this.width = this.wrap[0]
/** Height of the canvas in pixels (in its unzoomed state)
* @type {number}*/
this.height = this.wrap[1]
if( this.width === 0 || this.extents[0] < this.width ){
this.width = this.extents[0]
}
if( this.height === 0 || this.extents[1] < this.height ){
this.height = this.extents[1]
}
if( typeof document !== "undefined" ){
/** @ignore */
this.el = document.createElement("canvas")
this.el.width = this.width*this.zoom
this.el.height = this.height*this.zoom//extents[1]*this.zoom
let parent_element = (options && options.parentElement) || document.body
parent_element.appendChild( this.el )
} else {
const {createCanvas} = require("canvas")
/** @ignore */
this.el = createCanvas( this.width*this.zoom,
this.height*this.zoom )
/** @ignore */
this.fs = require("fs")
}
/** @ignore */
this.ctx = this.el.getContext("2d")
this.ctx.lineWidth = .2
this.ctx.lineCap="butt"
}
/** Give the canvas element an ID supplied as argument. Useful for building
* an HTML page where you want to get this canvas by its ID.
* @param {string} idString - the name to give the canvas element.
* */
setCanvasId( idString ){
this.el.id = idString
}
/* Several internal helper functions (used by drawing functions below) : */
/** @private
* @ignore*/
pxf( p ){
this.ctx.fillRect( this.zoom*p[0], this.zoom*p[1], this.zoom, this.zoom )
}
/** @private
* @ignore */
pxfi( p, alpha=1 ){
const dy = this.zoom*this.width
const off = (this.zoom*p[1]*dy + this.zoom*p[0])*4
for( let i = 0 ; i < this.zoom*4 ; i += 4 ){
for( let j = 0 ; j < this.zoom*dy*4 ; j += dy*4 ){
this.px[i+j+off] = this.col_r
this.px[i+j+off + 1] = this.col_g
this.px[i+j+off + 2] = this.col_b
this.px[i+j+off + 3] = alpha*255
}
}
}
/** @private
* @ignore */
pxfir( p ){
const dy = this.zoom*this.width
const off = (p[1]*dy + p[0])*4
this.px[off] = this.col_r
this.px[off + 1] = this.col_g
this.px[off + 2] = this.col_b
this.px[off + 3] = 255
}
/** @private
* @ignore*/
getImageData(){
/** @ignore */
this.image_data = this.ctx.getImageData(0, 0, this.width*this.zoom, this.height*this.zoom)
/** @ignore */
this.px = this.image_data.data
}
/** @private
* @ignore*/
putImageData(){
this.ctx.putImageData(this.image_data, 0, 0)
}
/** @private
* @ignore*/
pxfnozoom( p ){
this.ctx.fillRect( this.zoom*p[0], this.zoom*p[1], 1, 1 )
}
/** draw a line left (l), right (r), down (d), or up (u) of pixel p
* @private
* @ignore */
pxdrawl( p ){
for( let i = this.zoom*p[1] ; i < this.zoom*(p[1]+1) ; i ++ ){
this.pxfir( [this.zoom*p[0],i] )
}
}
/** @private
* @ignore */
pxdrawr( p ){
for( let i = this.zoom*p[1] ; i < this.zoom*(p[1]+1) ; i ++ ){
this.pxfir( [this.zoom*(p[0]+1),i] )
}
}
/** @private
* @ignore */
pxdrawd( p ){
for( let i = this.zoom*p[0] ; i < this.zoom*(p[0]+1) ; i ++ ){
this.pxfir( [i,this.zoom*(p[1]+1)] )
}
}
/** @private
* @ignore */
pxdrawu( p ){
for( let i = this.zoom*p[0] ; i < this.zoom*(p[0]+1) ; i ++ ){
this.pxfir( [i,this.zoom*p[1]] )
}
}
/** For easier color naming
* @private
* @ignore */
col( hex ){
this.ctx.fillStyle="#"+hex
/** @ignore */
this.col_r = parseInt( hex.substr(0,2), 16 )
/** @ignore */
this.col_g = parseInt( hex.substr(2,2), 16 )
/** @ignore */
this.col_b = parseInt( hex.substr(4,2), 16 )
}
/** Hex code string for a color.
* @typedef {string} HexColor*/
/** Color the whole grid in color [col], or in black if no argument is given.
* @param {HexColor} [col = "000000"] -hex code for the color to use, defaults to black.
*/
clear( col ){
col = col || "000000"
this.ctx.fillStyle="#"+col
this.ctx.fillRect( 0,0, this.el.width, this.el.height )
}
/** Rendering context of canvas.
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
* @typedef {object} CanvasRenderingContext2D
* */
/** Return the current drawing context.
* @return {CanvasRenderingContext2D} current drawing context on the canvas.
* */
context(){
return this.ctx
}
/** @private
* @ignore */
p2pdraw( p ){
for( let dim = 0; dim < p.length; dim++ ){
if( this.wrap[dim] !== 0 ){
p[dim] = p[dim] % this.wrap[dim]
}
}
return p
}
/* DRAWING FUNCTIONS ---------------------- */
/** Use to color a grid according to its values. High values are colored in
* a brighter color.
* @param {Grid2D|CoarseGrid} [cc] - the grid to draw values for. If left
* unspecified, the grid that was originally supplied to the Canvas
* constructor is used.
* @param {HexColor} [col = "0000FF"] - the color to draw the chemokine in.
* */
drawField( cc, col = "0000FF" ){
if( !cc ){
cc = this.grid
}
this.col(col)
let maxval = 0
for( let i = 0 ; i < cc.extents[0] ; i ++ ){
for( let j = 0 ; j < cc.extents[1] ; j ++ ){
let p = Math.log(.1+cc.pixt([i,j]))
if( maxval < p ){
maxval = p
}
}
}
this.getImageData()
//this.col_g = 0
//this.col_b = 0
for( let i = 0 ; i < cc.extents[0] ; i ++ ){
for( let j = 0 ; j < cc.extents[1] ; j ++ ){
//let colval = 255*(Math.log(.1+cc.pixt( [i,j] ))/maxval)
let alpha = (Math.log(.1+cc.pixt( [i,j] ))/maxval)
//this.col_r = colval
//this.col_g = colval
this.pxfi([i,j], alpha)
}
}
this.putImageData()
this.ctx.globalAlpha = 1
}
/** Use to color a grid according to its values. High values are colored in
* a brighter color.
* @param {Grid2D|CoarseGrid} [cc] - the grid to draw values for. If left
* unspecified, the grid that was originally supplied to the Canvas
* constructor is used.
* @param {number} [nsteps = 10] - the number of contour lines to draw.
* Contour lines are evenly spaced between the min and max log10 of the
* chemokine.
* @param {HexColor} [col = "FFFF00"] - the color to draw contours with.
* */
drawFieldContour( cc, nsteps = 10, col = "FFFF00" ){
if( !cc ){
cc = this.grid
}
this.col(col)
let maxval = 0
let minval = Math.log(0.1)
for( let i = 0 ; i < cc.extents[0] ; i ++ ){
for( let j = 0 ; j < cc.extents[1] ; j ++ ){
let p = Math.log(.1+cc.pixt([i,j]))
if( maxval < p ){
maxval = p
}
if( minval > p ){
minval = p
}
}
}
this.getImageData()
//this.col_g = 0
//this.col_b = 0
//this.col_r = 255
let step = (maxval-minval)/nsteps
for( let v = minval; v < maxval; v+= step ){
for( let i = 0 ; i < cc.extents[0] ; i ++ ){
for( let j = 0 ; j < cc.extents[1] ; j ++ ){
let pixelval = Math.log( .1 + cc.pixt( [i,j] ) )
if( Math.abs( v - pixelval ) < 0.05*maxval ){
let below = false, above = false
for( let n of this.grid.neighNeumanni( this.grid.p2i( [i,j] ) ) ){
let nval = Math.log(0.1 + cc.pixt(this.grid.i2p(n)) )
if( nval < v ){
below = true
}
if( nval >= v ){
above = true
}
if( above && below ){
//this.col_r = 150*((v-minval)/(maxval-minval)) + 105
let alpha = 0.7*((v-minval)/(maxval-minval)) + 0.3
this.pxfi( [i,j], alpha )
break
}
}
}
}
}
}
this.putImageData()
}
/** @desc Method for drawing the cell borders for a given cellkind in the
* color specified in "col" (hex format). This function draws a line around
* the cell (rather than coloring the outer pixels). If [kind] is negative,
* simply draw all borders.
*
* See {@link drawOnCellBorders} to color the outer pixels of the cell.
*
* @param {CellKind} kind - Integer specifying the cellkind to color.
* Should be a positive integer as 0 is reserved for the background.
* @param {HexColor} [col = "000000"] - hex code for the color to use,
* defaults to black.
*/
drawCellBorders( kind, col ){
let isCPM = ( this.C instanceof CPM ), C = this.C
let getBorderPixels = function*(){
for( let p of C.cellBorderPixels() ){
yield p
}
}
if( !isCPM ){
// in a non-cpm, simply draw borders of all pixels
getBorderPixels = function*(){
for( let p of C.grid.pixels() ){
yield p
}
}
}
col = col || "000000"
let pc, pu, pd, pl, pr, pdraw
this.col( col )
this.getImageData()
// cst contains indices of pixels at the border of cells
for( let x of getBorderPixels() ){
let pKind
if( isCPM ){
pKind = this.C.cellKind( x[1] )
} else {
pKind = x[1]
}
let p = x[0]
if( kind < 0 || pKind === kind ){
pdraw = this.p2pdraw( p )
pc = this.C.pixt( [p[0],p[1]] )
pr = this.C.pixt( [p[0]+1,p[1]] )
pl = this.C.pixt( [p[0]-1,p[1]] )
pd = this.C.pixt( [p[0],p[1]+1] )
pu = this.C.pixt( [p[0],p[1]-1] )
if( pc !== pl ){
this.pxdrawl( pdraw )
}
if( pc !== pr ){
this.pxdrawr( pdraw )
}
if( pc !== pd ){
this.pxdrawd( pdraw )
}
if( pc !== pu ){
this.pxdrawu( pdraw )
}
}
}
this.putImageData()
}
/** Use to show activity values of the act model using a color gradient, for
* cells in the grid of cellkind "kind". The constraint holding the activity
* values can be supplied as an argument. Otherwise, the current CPM is
* searched for the first registered activity constraint and that is then
* used.
*
* @param {CellKind} kind - Integer specifying the cellkind to color.
* If negative, draw values for all cellkinds.
* @param {ActivityConstraint|ActivityMultiBackground} [A] - the constraint
* object to use, which must be of class {@link ActivityConstraint} or
* {@link ActivityMultiBackground} If left unspecified, this is the first
* instance of an ActivityConstraint or ActivityMultiBackground object found
* in the soft_constraints of the attached CPM.
* @param {Function} [col] - a function that returns a color for a number
* in [0,1] as an array of red/green/blue values, for example, [255,0,0]
* would be the color red. If unspecified, a green-to-red heatmap is used.
* */
drawActivityValues( kind, A, col ){
if( !( this.C instanceof CPM) ){
throw("You cannot use the drawActivityValues method on a non-CPM model!")
}
if( !A ){
for( let c of this.C.soft_constraints ){
if( c instanceof ActivityConstraint || c instanceof ActivityMultiBackground ){
A = c; break
}
}
}
if( !A ){
throw("Cannot find activity values to draw!")
}
if( !col ){
col = function(a){
let r = [0,0,0]
if( a > 0.5 ){
r[0] = 255
r[1] = (2-2*a)*255
} else {
r[0] = (2*a)*255
r[1] = 255
}
return r
}
}
// cst contains the pixel ids of all non-background/non-stroma cells in
// the grid.
let ii, sigma, a, k
// loop over all pixels belonging to non-background, non-stroma
this.col("FF0000")
this.getImageData()
this.col_b = 0
//this.col_g = 0
for( let x of this.C.cellPixels() ){
ii = x[0]
sigma = x[1]
k = this.C.cellKind(sigma)
// For all pixels that belong to the current kind, compute
// color based on activity values, convert to hex, and draw.
if( ( kind < 0 && A.conf["MAX_ACT"][k] > 0 ) || k === kind ){
a = A.pxact( this.C.grid.p2i( ii ) )/A.conf["MAX_ACT"][k]
if( a > 0 ){
if( a > 0.5 ){
this.col_r = 255
this.col_g = (2-2*a)*255
} else {
this.col_r = (2*a)*255
this.col_g = 255
}
let r = col( a )
this.col_r = r[0]
this.col_g = r[1]
this.col_b = r[2]
this.pxfi( ii )
}
}
}
this.putImageData()
}
/** Color outer pixel of all cells of kind [kind] in col [col].
* See {@link drawCellBorders} to actually draw around the cell rather than
* coloring the outer pixels. If you're using this model on a CA,
* {@link CellKind} is not defined and the parameter "kind" is instead
* interpreted as {@link CellId}.
*
* @param {CellKind} kind - Integer specifying the cellkind to color.
* Should be a positive integer as 0 is reserved for the background.
* @param {HexColor|function} col - Optional: hex code for the color to use.
* If left unspecified, it gets the default value of black ("000000").
* col can also be a function that returns a hex value for a cell id. */
drawOnCellBorders( kind, col ){
col = col || "000000"
let isCPM = ( this.C instanceof CPM ), C = this.C
let getBorderPixels = function*(){
for( let p of C.cellBorderPixels() ){
yield p
}
}
if( !isCPM ){
// in a non-cpm, simply draw borders of all pixels
getBorderPixels = this.C.pixels
}
this.getImageData()
this.col( col )
for( let p of getBorderPixels() ){
let pKind
if( isCPM ){
pKind = this.C.cellKind( p[1] )
} else {
pKind = p[1]
}
if( kind < 0 || pKind === kind ){
if( typeof col == "function" ){
this.col( col(p[1]) )
}
this.pxfi( p[0] )
}
}
this.putImageData()
}
/**
* Draw all cells of cellid "id" in color col (hex). Note that this function
* also works for CA. However, it has not yet been optimised and is very slow
* if called many times. For multicellular CPMs, you are better off using
* {@link drawCells} with an appropriate coloring function (see that method's
* documentation).
*
* @param {CellId} id - id of the cell to color.
* @param {HexColor} col - Optional: hex code for the color to use.
* If left unspecified, it gets the default value of black ("000000").
*
* */
drawCellsOfId( id, col ){
if( !col ){
col = "000000"
}
if( typeof col == "string" ){
this.col(col)
}
// Use the pixels() iterator to get the id of all non-background pixels.
this.getImageData()
// this currently just loops over all pixels on the grid, which makes it slow
// if you repeat this process for many cells. Optimise later.
for( let x of this.C.pixels() ){
if( x[1] === id ){
this.pxfi( x[0] )
}
}
this.putImageData()
}
/** Draw all cells of cellkind "kind" in color col (hex). This method is
* meant for models of class {@link CPM}, where the {@link CellKind} is
* defined. If you apply this method on a {@link CA} model, this method
* will internally call {@link drawCellsOfId} by just supplying the
* "kind" parameter as {@link CellId}.
*
* @param {CellKind} kind - Integer specifying the cellkind to color.
* Should be a positive integer as 0 is reserved for the background.
* @param {HexColor|function} col - Optional: hex code for the color to use.
* If left unspecified, it gets the default value of black ("000000").
* col can also be a function that returns a hex value for a cell id, but
* this is only supported for CPMs.
*
* @example <caption>Drawing cells by "kind" or "ID"</caption>
*
* // Draw all cells of kind 1 in red
* Cim.drawCells( 1, "FF0000" )
*
* // To color cells by their ID instead of their kind, we can parse
* // a function to 'col' instead of a string. The example function
* // below reads the color for each cellID from an object of keys (ids)
* // and values (colors):
* Cim.colFun = function( cid ){
*
* // First time function is called, attach an empty object 'cellColorMap' to
* // simulation object; this tracks the color for each cellID on the grid.
* if( !Cim.hasOwnProperty( "cellColorMap" ) ){
* Cim.cellColorMap = {}
* }
*
* // Check if the current cellID already has a color, otherwise put a random
* // color in the cellColorMap object
* if( !Cim.cellColorMap.hasOwnProperty(cid) ){
* // this cell gets a random color
* Cim.cellColorMap[cid] = Math.floor(Math.random()*16777215).toString(16).toUpperCase()
* }
*
* // now return the color assigned to this cellID.
* return Cim.cellColorMap[cid]
* }
* // Now use this function to draw the cells, colored by their ID
* Cim.drawCells( 1, Cim.colFun )
*/
drawCells( kind, col ){
if( !( this.C instanceof CPM ) ){
if( typeof col != "string" ){
throw("If you use the drawCells method on a CA, you cannot " +
"specify the color as function! Please specify a single string.")
}
this.drawCellsOfId( kind, col )
} else {
if (!col) {
col = "000000"
}
if (typeof col == "string") {
this.col(col)
}
// Object contains all pixels belonging to non-background,
// non-stroma cells.
let cellpixelsbyid = this.C.getStat(PixelsByCell)
this.getImageData()
for (let cid of Object.keys(cellpixelsbyid)) {
if (kind < 0 || this.C.cellKind(cid) === kind) {
if (typeof col == "function") {
this.col(col(cid))
}
for (let cp of cellpixelsbyid[cid]) {
this.pxfi(cp)
}
}
}
this.putImageData()
}
}
/** General drawing function to draw all pixels in a supplied set in a given
* color.
* @param {ArrayCoordinate[]} pixelarray - an array of
* {@link ArrayCoordinate}s of pixels to color.
* @param {HexColor|function} col - Optional: hex code for the color to use.
* If left unspecified, it gets the default value of black ("000000").
* col can also be a function that returns a hex value for a cell id.
* */
drawPixelSet( pixelarray, col ){
if( ! col ){
col = "000000"
}
if( typeof col == "string" ){
this.col(col)
}
this.getImageData()
for( let p of pixelarray ){
this.pxfi( p )
}
this.putImageData()
}
/** Draw grid to the png file "fname".
*
* @param {string} fname Path to the file to write. Any parent folders in
* this path must already exist.*/
writePNG( fname ){
try {
this.fs.writeFileSync(fname, this.el.toBuffer())
}
catch (err) {
if (err.code === "ENOENT") {
let message = "Canvas.writePNG: cannot write to file " + fname +
", are you sure the directory exists?"
throw(message)
}
}
}
}
export default Canvas