UNPKG

cacatoo

Version:

Building, exploring, and sharing spatially structured models

822 lines (680 loc) 30.4 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(); } drawBoidRod(boid, ctx) { ctx.fillStyle = boid.fill; let scale = this.scale ctx.lineWidth = boid.size * scale; // Normalised velocity const vector = this.model.normaliseVector(boid.velocity); // Front circle const frontx = (boid.position.x + 2 * vector.x) * scale; const fronty = (boid.position.y + 2 * vector.y) * scale; ctx.beginPath(); ctx.arc(frontx, fronty, 0.5 * boid.size * scale, 0, Math.PI * 2); ctx.fill(); // Back circle const backx = (boid.position.x - 2 * vector.x) * scale; const backy = (boid.position.y - 2 * vector.y) * scale; ctx.beginPath(); ctx.arc(backx, backy, 0.5 * boid.size * scale, 0, Math.PI * 2); ctx.fill(); // Connecting rod ctx.beginPath(); ctx.strokeStyle = boid.fill; ctx.moveTo(frontx, fronty); ctx.lineTo(backx, backy); ctx.stroke(); if (boid.flagella) { const size = boid.size * scale; if (boid.flagella === "directed") { // Base angle = pointing backwards relative to velocity const baseAngle = Math.atan2(-vector.y, -vector.x); // 3 flagella fanning out const angles = [ baseAngle, baseAngle + Math.PI / 10, baseAngle - Math.PI / 10 ]; angles.forEach((ang, i) => { this.drawFlagellum(ctx, backx, backy, ang, size, i); }); } else if (boid.flagella === "random") { // 3 flagella, evenly spaced around body, rotated by boid direction const base = Math.atan2(boid.velocity.y, boid.velocity.x); // 3 offsets around the circle const offsets = [ 0, (2 * Math.PI) / 3, (4 * Math.PI) / 3 ]; const r = 0.15 * boid.size * scale; const cx = boid.position.x * scale; const cy = boid.position.y * scale; offsets.forEach((off, i) => { const ang = base + off; // rotate with boid direction const x = cx + Math.cos(ang) * r; const y = cy + Math.sin(ang) * r; this.drawFlagellum(ctx, x, y, ang, size, i); }); } } } drawFlagellum(ctx, x, y, angle, size, index) { const time = this.model.time * 1; // wave speed const length = 2 * size; // flagellum length const segments = 12; // smoothness const amp = 0.3 * size; // wiggle amplitude ctx.beginPath(); ctx.moveTo(x, y); for (let i = 1; i <= segments; i++) { const t = i / segments; // sinusoidal wave animation const wave = Math.sin(t * 6 + time + index) * amp; // main direction const dx = Math.cos(angle) * (t * length); const dy = Math.sin(angle) * (t * length); // perpendicular wiggle const px = -Math.sin(angle) * wave; const py = Math.cos(angle) * wave; ctx.lineTo(x + dx + px, y + dy + py); } ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 0.15 * size; ctx.stroke(); } // 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 circle at the position of the boid drawBoidBunny(boid,ctx){ ctx.fillStyle = boid.fill // Head 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() ctx.closePath() // Ears ctx.beginPath() ctx.ellipse((boid.position.x+0.2*boid.size)*this.scale, (boid.position.y-0.5*boid.size)*this.scale, 0.2*this.scale*boid.size, 0.5*this.scale*boid.size, 0, 0,Math.PI*2) ctx.fill() ctx.closePath() ctx.beginPath() ctx.ellipse((boid.position.x-0.2*boid.size)*this.scale, (boid.position.y-0.5*boid.size)*this.scale, 0.2*this.scale*boid.size, 0.5*this.scale*boid.size, 0, 0,Math.PI*2) ctx.fill() ctx.closePath() // Eyes ctx.beginPath() ctx.fillStyle = 'black' ctx.arc((boid.position.x-0.25*boid.size)*this.scale, (boid.position.y-0.1)*this.scale, 0.1*boid.size*this.scale,0,Math.PI*2) ctx.fill() ctx.closePath() ctx.beginPath() ctx.arc((boid.position.x+0.25*boid.size)*this.scale, (boid.position.y-0.1)*this.scale, 0.1*boid.size*this.scale,0,Math.PI*2) ctx.fill() ctx.closePath() } // Draw a circle at the position of the boid drawBoidBear(boid,ctx){ ctx.fillStyle = boid.fill // Head 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() ctx.closePath() // Ears ctx.beginPath() ctx.arc((boid.position.x+0.4*boid.size)*this.scale, (boid.position.y-0.4*boid.size)*this.scale, 0.25*boid.size*this.scale,0,Math.PI*2) ctx.fill() ctx.beginPath() ctx.arc((boid.position.x-0.4*boid.size)*this.scale, (boid.position.y-0.4*boid.size)*this.scale, 0.25*boid.size*this.scale,0,Math.PI*2) ctx.fill() // Eyes ctx.beginPath() ctx.fillStyle = 'black' ctx.arc((boid.position.x-0.15*boid.size)*this.scale, (boid.position.y-0.1)*this.scale, 0.1*boid.size*this.scale,0,Math.PI*2) ctx.fill() ctx.closePath() ctx.beginPath() ctx.arc((boid.position.x+0.15*boid.size)*this.scale, (boid.position.y-0.1)*this.scale, 0.1*boid.size*this.scale,0,Math.PI*2) ctx.fill() ctx.closePath() } // 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(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.height = 50 this.legend.style.display = 'block' this.legend.style.margin = '0 auto' let ctx = this.legend.getContext("2d") ctx.textAlign = "center" ctx.font = '14px helvetica' if (this.maxval !== undefined) { // --- continuous colourbar legend --- const dpr = window.devicePixelRatio || 1 let logicalWidth = this.width * this.scale * 0.8 let logicalHeight = 50 this.legend.width = logicalWidth * dpr this.legend.height = logicalHeight * dpr this.legend.style.width = logicalWidth + "px" this.legend.style.height = logicalHeight + "px" ctx.scale(dpr, dpr) ctx.textAlign = "center" ctx.fillStyle = "#666666" ctx.font = '11px helvetica' ctx.fillText(lab, logicalWidth / 2, 16) let bar_width = logicalWidth * 0.8 let offset = 0.1 * logicalWidth let n_ticks = this.nticks - 1 let tick_increment = (this.maxval - this.minval) / n_ticks let step_size = bar_width / n_ticks 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.bgcolour } 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 = "#666666" ctx.textAlign = "center" ctx.font = '11px 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 { // --- discrete dot/swatch legend --- let keys = Object.keys(statecols) let total_num_values = keys.length // Logical dimensions const dpr = window.devicePixelRatio || 1 let logicalWidth = this.width * this.scale * 0.8 let logicalHeight = 50 // Scale canvas backing store this.legend.width = logicalWidth * dpr this.legend.height = logicalHeight * dpr this.legend.style.width = logicalWidth + "px" this.legend.style.height = logicalHeight + "px" // Scale context once, before any drawing ctx.scale(dpr, dpr) ctx.textAlign = "center" ctx.font = '12px helvetica' let step_size = logicalWidth / total_num_values let offset = step_size / 2 ctx.fillText(lab, logicalWidth / 2, 16) for (let i = 0; i < total_num_values; i++) { let pos = offset + i * step_size if (statecols[keys[i]] == undefined) ctx.fillStyle = this.bgcolour else ctx.fillStyle = rgbToHex(statecols[keys[i]]) if (this.radius) { ctx.beginPath() ctx.arc(pos, 18, 6, 0, Math.PI * 2) ctx.fill() ctx.closePath() } else { ctx.beginPath() ctx.strokeStyle = "#00000022" ctx.fillRect(pos - 5, 10, 11, 11) ctx.closePath() } ctx.font = '12px helvetica' ctx.fillStyle = '#666666' ctx.textAlign = "center" ctx.fillText(keys[i], pos, 45) } 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