UNPKG

cacatoo

Version:

Building, exploring, and sharing spatially structured models

602 lines (505 loc) 23.1 kB
/** * Canvas is a wrapper-class for a HTML-canvas element. It is linked to a @Gridmodel object, and stores what from that @Gridmodel should be displayed (width, height, property, scale, etc.) */ class Canvas { /** * The constructor function for a @Canvas object. * @param {model} model The model ( @Gridmodel or @Flockmodel ) to which this canvas belongs * @param {string} property the property that should be shown on the canvas * @param {int} height height of the canvas (in rows) * @param {int} width width of the canvas (in cols) * @param {scale} scale of the canvas (width/height of each gridpoint in pixels) */ constructor(model, prop, lab, height, width, scale, continuous, addToDisplay) { this.label = lab this.model = model this.statecolours = model.statecolours this.property = prop this.height = height this.width = width this.scale = scale this.continuous = continuous this.bgcolour = 'black' this.offset_x = 0 this.offset_y = 0 this.phase = 0 this.addToDisplay = addToDisplay if (typeof document !== "undefined") // In browser, crease a new HTML canvas-element to draw on { this.elem = document.createElement("canvas") this.titlediv = document.createElement("div") if(this.label) this.titlediv.innerHTML = "<p style='height:10'><font size = 3>" + this.label + "</font></p>" this.canvasdiv = document.createElement("div") this.canvasdiv.className = "grid-holder" this.elem.className = "canvas-cacatoo" this.elem.width = this.width * this.scale this.elem.height = this.height * this.scale if(!addToDisplay){ this.canvasdiv.appendChild(this.elem) this.canvasdiv.appendChild(this.titlediv) document.getElementById("canvas_holder").appendChild(this.canvasdiv) } this.ctx = this.elem.getContext("2d", { willReadFrequently: true }) this.display = this.displaygrid } else { // In nodejs, one may use canvas package. Or write the grid to a file to be plotted with R. } this.underlay = function(){} this.overlay = function(){} } /** * Draw the state of the model (for a specific property) onto the HTML element */ displaygrid() { let ctx = this.ctx let scale = this.scale let ncol = this.width let nrow = this.height let prop = this.property if(this.spacetime){ ctx.fillStyle = this.bgcolour ctx.fillRect((this.phase%ncol)*scale, 0, scale, nrow * scale) } else{ ctx.clearRect(0, 0, scale * ncol, scale * nrow) ctx.fillStyle = this.bgcolour ctx.fillRect(0, 0, ncol * scale, nrow * scale) } this.underlay() var id = ctx.getImageData(0, 0, scale * ncol, scale * nrow); var pixels = id.data; let start_col = this.offset_x let stop_col = start_col + ncol let start_row = this.offset_y let stop_row = start_row + nrow let statecols = this.statecolours[prop] for (let x = start_col; x < stop_col; x++) // x are cols { for (let y = start_row; y< stop_row; y++) // y are rows { if (!(prop in this.model.grid[x][y])) continue let value = this.model.grid[x][y][prop] if(this.continuous && this.maxval !== undefined && this.minval !== undefined) { value = Math.max(this.minval,Math.min(this.maxval,value)) value = Math.ceil((value - this.minval)/(this.maxval-this.minval)*this.num_colours) } if (statecols[value] == undefined) // Don't draw the background state continue let idx if (statecols.constructor == Object) { idx = statecols[value] } else idx = statecols for (let n = 0; n < scale; n++) { for (let m = 0; m < scale; m++) { let xpos = (x-this.offset_x) * scale + n + (this.phase%ncol)*scale let ypos = (y-this.offset_y) * scale + m var off = (ypos * id.width + xpos) * 4; pixels[off] = idx[0]; pixels[off + 1] = idx[1]; pixels[off + 2] = idx[2]; } } } if(this.spacetime) { this.phase = (this.phase+1) break } } ctx.putImageData(id, 0, 0); this.overlay() } /** * Draw the state of the model (for a specific property) onto the HTML element */ displaygrid_dots() { let ctx = this.ctx let scale = this.scale let ncol = this.width let nrow = this.height let prop = this.property if(this.spacetime){ ctx.fillStyle = this.bgcolour ctx.fillRect((this.phase%ncol)*scale, 0, scale, nrow * scale) } else{ ctx.clearRect(0, 0, scale * ncol, scale * nrow) ctx.fillStyle = this.bgcolour ctx.fillRect(0, 0, ncol * scale, nrow * scale) } this.underlay() let start_col = this.offset_x let stop_col = start_col + ncol let start_row = this.offset_y let stop_row = start_row + nrow let statecols = this.statecolours[prop] for (let x = start_col; x < stop_col; x++) // x are cols { for (let y = start_row; y< stop_row; y++) // y are rows { if (!(prop in this.model.grid[x][y])) continue let value = this.model.grid[x][y][prop] let radius = this.scale_radius*this.radius if(isNaN(radius)) radius = this.scale_radius*this.model.grid[x][y][this.radius] if(isNaN(radius)) radius = this.min_radius radius = Math.max(Math.min(radius,this.max_radius),this.min_radius) if(this.continuous && value !== 0 && this.maxval !== undefined && this.minval !== undefined) { value = Math.max(value,this.minval) - this.minval let mult = this.num_colours/(this.maxval-this.minval) value = Math.min(this.num_colours,Math.max(Math.floor(value*mult),1)) } if (statecols[value] == undefined) // Don't draw the background state continue let idx if (statecols.constructor == Object) { idx = statecols[value] } else idx = statecols ctx.beginPath() ctx.arc((x-this.offset_x) * scale + 0.5*scale, (y-this.offset_y) * scale + 0.5*scale, radius, 0, 2 * Math.PI, false) ctx.fillStyle = 'rgb('+idx[0]+', '+idx[1]+', '+idx[2]+')'; // ctx.fillStyle = 'rgb(100,100,100)'; ctx.fill() if(this.stroke){ ctx.lineWidth = this.strokeWidth ctx.strokeStyle = this.strokeStyle; ctx.stroke() } } } this.overlay() } /** * Draw the state of the flockmodel onto the HTML element */ displayflock() { let ctx = this.ctx if(this.model.draw == false) return if(this.addToDisplay) this.ctx = this.addToDisplay.ctx let scale = this.scale let ncol = this.width let nrow = this.height let prop = this.property if(!this.addToDisplay) { ctx.clearRect(0, 0, scale * ncol, scale * nrow) ctx.fillStyle = this.bgcolour ctx.fillRect(0, 0, ncol * scale, nrow * scale) } this.underlay() if(this.model.config.qt_colour) this.model.qt.draw(ctx, this.scale, this.model.config.qt_colour) for (let boid of this.model.boids){ // Plot all individuals if(boid.invisible) continue if(!boid.fill) boid.fill = 'black' if(this.model.statecolours[prop]){ let val = boid[prop] if(this.maxval !== undefined){ let cols = this.model.statecolours[prop] val = Math.max(val,this.minval) - this.minval let mult = this.num_colours/(this.maxval-this.minval) val = Math.min(this.num_colours,Math.max(Math.floor(val*mult),1)) boid.fill = rgbToHex(cols[val]) } else{ boid.fill = rgbToHex(this.model.statecolours[prop][val]) } } if(boid.col == undefined) boid.col = this.strokeStyle if(boid.lwd == undefined) boid.lwd = this.strokeWidth this.drawBoid(boid,ctx) } if(this.model.config.draw_mouse_radius){ ctx.beginPath() ctx.strokeStyle = this.model.config.draw_mouse_colour || '#FFFFFF' ctx.arc(this.model.mousecoords.x*this.scale, this.model.mousecoords.y*this.scale,this.model.config.mouse_radius*this.scale, 0, Math.PI*2) ctx.stroke() ctx.closePath() } for(let obs of this.model.obstacles){ if(obs.type=='rectangle'){ ctx.fillStyle = obs.fill || '#00000033' ctx.fillRect(obs.x*this.scale, obs.y*this.scale,obs.w*this.scale,obs.h*this.scale) } else if(obs.type=='circle'){ ctx.beginPath() ctx.fillStyle = obs.fill || '#00000033' ctx.lineStyle = '#FFFFFF' ctx.arc(obs.x*this.scale,obs.y*this.scale,obs.r*this.scale,0,Math.PI*2) ctx.fill() ctx.closePath() } } this.draw_qt() this.overlay() } /** * This function is empty by default, and is overriden based on parameters chose by the model. * Override options are all below this option. Options are: * Point: a circle * Rect: a square * Arrow: an arrow that rotates in the direction the boid is moving * Bird: an arrow, but very wide so it looks like a bird * Line: a line that has the direction AND length of the velocity vector * Ant: three dots form an ant body with two lines forming antanae * Png: an image. PNG is sourced from boid.png */ drawBoid(){ } // Draw a circle at the position of the boid drawBoidPoint(boid,ctx){ ctx.fillStyle = boid.fill ctx.beginPath() ctx.arc(boid.position.x*this.scale,boid.position.y*this.scale,0.5*boid.size*this.scale,0,Math.PI*2) ctx.fill() if(boid.col){ ctx.strokeStyle = boid.col ctx.lineWidth = boid.lwd ctx.stroke() } ctx.closePath() } // Draw a rectangle at the position of the boid drawBoidRect(boid,ctx){ ctx.fillStyle = boid.fill; ctx.fillRect(boid.position.x*this.scale,boid.position.y*this.scale,boid.size,boid.size) if(boid.col){ ctx.strokeStyle = boid.col ctx.lineWidth = boid.stroke ctx.strokeRect(boid.position.x*this.scale,boid.position.y*this.scale,boid.size,boid.size) } } // Draw an arrow pointing in the direction of the velocity vector drawBoidArrow(boid,ctx, length=1, width=0.3){ ctx.save() ctx.translate(boid.position.x*this.scale, boid.position.y*this.scale); let angle = Math.atan2(boid.velocity.y*this.scale,boid.velocity.x*this.scale) ctx.rotate(angle); ctx.fillStyle = boid.fill; ctx.beginPath(); ctx.moveTo(length*boid.size,0) ctx.lineTo(0, width*boid.size); // Left wing */ ctx.lineTo(0, -width*boid.size); // Right wing ctx.lineTo(length*boid.size,0); // Back ctx.fill() if(boid.col){ ctx.strokeStyle = boid.col ctx.lineWidth = boid.stroke ctx.stroke() } ctx.restore(); } // Similar to the arrow but very wide. Looks a bit like a bird. drawBoidBird(boid,ctx){ this.drawBoidArrow(boid,ctx,0.4,1) } // Draw a line from the boids position to the velocity vector. Indicates speed. drawBoidLine(boid,ctx){ ctx.beginPath() ctx.strokeStyle = boid.col || boid.fill ctx.lineWidth = boid.stroke ctx.moveTo(boid.position.x*this.scale, boid.position.y*this.scale) ctx.lineTo(boid.position.x*this.scale+boid.velocity.x*boid.size, boid.position.y*this.scale+boid.velocity.y*boid.size) ctx.strokeStyle = boid.fill ctx.stroke() ctx.closePath() } // Draw three points along the velocity vector + 2 antanae. Sort of an ant thingy. drawBoidAnt(boid,ctx){ ctx.fillStyle = boid.fill let vector = this.model.normaliseVector({x: boid.velocity.x, y: boid.velocity.y}) // First body part ctx.beginPath() ctx.arc(boid.position.x*this.scale-vector.x*boid.size*1.5, boid.position.y*this.scale-vector.y*boid.size*1.5,boid.size*1.2,0,Math.PI*2) ctx.fill() ctx.closePath() // Second body part ctx.beginPath() ctx.arc(boid.position.x*this.scale, boid.position.y*this.scale, boid.size/1.3,0,Math.PI*2) ctx.fill() ctx.closePath() // Third body part ctx.beginPath() ctx.arc(boid.position.x*this.scale+vector.x*boid.size*1.3, boid.position.y*this.scale+vector.y*boid.size*1.3, boid.size/1.1,0,Math.PI*2) ctx.fill() ctx.closePath() // Food if(boid.food){ ctx.beginPath() ctx.fillStyle = boid.food ctx.arc(boid.position.x*this.scale+vector.x*boid.size*3.5, boid.position.y*this.scale+vector.y*boid.size*3.5, boid.size*1.2,0,Math.PI*2) ctx.fill() ctx.closePath() } let dir ctx.beginPath() // First antenna dir = this.model.rotateVector(vector,30) ctx.moveTo(boid.position.x*this.scale+vector.x*boid.size*1, boid.position.y*this.scale+vector.y*boid.size*1) ctx.lineTo(boid.position.x*this.scale+vector.x*boid.size*1.8+dir.x*boid.size*1.3, boid.position.y*this.scale+vector.y*boid.size*1.8+dir.y*boid.size*1.3) ctx.strokeStyle = boid.fill ctx.lineWidth = boid.size/2 // // Second antenna dir = this.model.rotateVector(vector,-30) ctx.moveTo(boid.position.x*this.scale+vector.x*boid.size*1, boid.position.y*this.scale+vector.y*boid.size*1) ctx.lineTo(boid.position.x*this.scale+vector.x*boid.size*1.8+dir.x*boid.size*1.3, boid.position.y*this.scale+vector.y*boid.size*1.8+dir.y*boid.size*1.3) ctx.strokeStyle = boid.fill ctx.lineWidth = boid.size/2 ctx.stroke() if(boid.col){ ctx.strokeStyle = boid.col ctx.lineWidth = boid.stroke ctx.stroke() } ctx.closePath() } // Draw an image at the position of the boid. Requires boid.png to be set. Optional is boid.pngangle to // let the png adjust direction according to the velocity vector drawBoidPng(boid,ctx){ if(boid.pngangle !==undefined){ ctx.save() ctx.translate(boid.position.x*this.scale-boid.size*this.scale*0.5, boid.position.y*this.scale-boid.size*this.scale*0.5); let angle = Math.atan2(boid.velocity.y*this.scale,boid.velocity.x*this.scale+boid.pngangle) ctx.rotate(angle); if(boid.img == undefined) boid.img = new Image(); boid.img.src = boid.png if(!boid.png) console.warn("Boid does not have a PNG associated with it") ctx.drawImage(base_image,0,0,boid.size*this.scale,boid.size*this.scale); ctx.restore() } else{ if(boid.img == undefined) boid.img = new Image(); boid.img.src = boid.png if(!boid.png) console.warn("Boid does not have a PNG associated with it") ctx.drawImage(boid.img, (boid.position.x-0.5*boid.size)*this.scale, (boid.position.y-0.5*boid.size)*this.scale, boid.size*this.scale,boid.size*this.scale); } } draw_qt(){ } // Add legend to plot add_legend(div,property,lab="") { if (typeof document == "undefined") return let statecols = this.statecolours[property] if(statecols == undefined){ console.warn(`Cacatoo warning: no colours setup for canvas "${this.label}"`) return } this.legend = document.createElement("canvas") this.legend.className = "legend" this.legend.width = this.width*this.scale*0.6 this.legend.height = 50 let ctx = this.legend.getContext("2d") ctx.textAlign = "center"; ctx.font = '14px helvetica'; ctx.fillText(lab, this.legend.width/2, 16); if(this.maxval!==undefined) { let bar_width = this.width*this.scale*0.48 let offset = 0.1*this.legend.width let n_ticks = this.nticks-1 let tick_increment = (this.maxval-this.minval) / n_ticks let step_size = (this.legend.width / n_ticks)*0.8 for(let i=0;i<bar_width;i++) { let colval = Math.ceil(this.num_colours*i/bar_width) if(statecols[colval] == undefined) { ctx.fillStyle = this.bgcolor } else { ctx.fillStyle = rgbToHex(statecols[colval]) } ctx.fillRect(offset+i, 20, 1, 10); ctx.closePath(); } for(let i = 0; i<n_ticks+1; i++){ let tick_position = (i*step_size+offset) ctx.strokeStyle = "#FFFFFF"; ctx.beginPath(); ctx.moveTo(tick_position, 25); ctx.lineTo(tick_position, 30); ctx.lineWidth=2 ctx.stroke(); ctx.closePath(); ctx.fillStyle = "#000000" ctx.textAlign = "center"; ctx.font = '12px helvetica'; let ticklab = (this.minval+i*tick_increment) ticklab = ticklab.toFixed(this.decimals) ctx.fillText(ticklab, tick_position, 45); } ctx.beginPath(); ctx.rect(offset, 20, bar_width, 10); ctx.strokeStyle = "#000000"; ctx.stroke(); ctx.closePath(); div.appendChild(this.legend) } else{ let keys = Object.keys(statecols) let total_num_values = keys.length let spacing = 0.8 if(total_num_values < 8) spacing = 0.7 if(total_num_values < 4) spacing = 0.6 let bar_width = this.width*this.scale*spacing let step_size = Math.round(bar_width / (total_num_values+1)) let offset = this.legend.width*0.5 - step_size*(total_num_values-1)/2 for(let i=0;i<total_num_values;i++) { let pos = offset+Math.floor(i*step_size) ctx.beginPath() ctx.strokeStyle = "#000000" if(statecols[keys[i]] == undefined) ctx.fillStyle = this.bgcolor else ctx.fillStyle = rgbToHex(statecols[keys[i]]) if(this.radius){ ctx.beginPath() ctx.arc(pos,10,5,0,Math.PI*2) ctx.fill() ctx.closePath() } else{ ctx.fillRect(pos-4, 10, 10, 10) } ctx.closePath() ctx.font = '12px helvetica'; ctx.fillStyle = "#000000" ctx.textAlign = "center"; ctx.fillText(keys[i], pos, 35); } div.appendChild(this.legend) } } remove_legend() { this.legend.getContext("2d").clearRect(0, 0, this.legend.width, this.legend.height); } } /* Functions below are to make sure dygraphs understands the colours used by Cacatoo (converts to hex) */ function componentToHex(c) { var hex = c.toString(16); return hex.length == 1 ? "0" + hex : hex; } function rgbToHex(arr) { if(arr.length==3) return "#" + componentToHex(arr[0]) + componentToHex(arr[1]) + componentToHex(arr[2]) if(arr.length==4) return "#" + componentToHex(arr[0]) + componentToHex(arr[1]) + componentToHex(arr[2]) + componentToHex(arr[3]) } export default Canvas