'use strict';
var C = require('./constants');
var Coordinate = require('./coordinate');
var Stones = require('./stones');
var util = require('./util');
/**
* Create a jGoBoard canvas object.
*
* @param {Object} elem Container HTML element or its id.
* @param {Object} opt Options object.
* @param {Object} images Set of images (or false values) for drawing.
* @constructor
*/
var Canvas = function(elem, opt, images) {
/* global document */
if(typeof elem === 'string')
elem = document.getElementById(elem);
var canvas = document.createElement('canvas'), i, j;
var padLeft = opt.edge.left ? opt.padding.normal : opt.padding.clipped,
padRight = opt.edge.right ? opt.padding.normal : opt.padding.clipped,
padTop = opt.edge.top ? opt.padding.normal : opt.padding.clipped,
padBottom = opt.edge.bottom ? opt.padding.normal : opt.padding.clipped;
this.marginLeft = opt.edge.left ? opt.margin.normal : opt.margin.clipped;
this.marginRight = opt.edge.right ? opt.margin.normal : opt.margin.clipped;
this.marginTop = opt.edge.top ? opt.margin.normal : opt.margin.clipped;
this.marginBottom = opt.edge.bottom ? opt.margin.normal : opt.margin.clipped;
this.boardWidth = padLeft + padRight +
opt.grid.x * opt.view.width;
this.boardHeight = padTop + padBottom +
opt.grid.y * opt.view.height;
this.width = canvas.width =
this.marginLeft + this.marginRight + this.boardWidth;
this.height = canvas.height =
this.marginTop + this.marginBottom + this.boardHeight;
this.listeners = {'click': [], 'mousemove': [], 'mouseout': []};
/**
* Get board coordinate based on screen coordinates.
* @param {number} x Coordinate.
* @param {number} y Coordinate.
* @returns {Coordinate} Board coordinate.
*/
this.getCoordinate = function(pageX, pageY) {
var bounds = canvas.getBoundingClientRect(),
scaledX = (pageX - bounds.left) * canvas.width / (bounds.right - bounds.left),
scaledY = (pageY - bounds.top) * canvas.height / (bounds.bottom - bounds.top);
return new Coordinate(
Math.floor((scaledX-this.marginLeft-padLeft)/opt.grid.x) + opt.view.xOffset,
Math.floor((scaledY-this.marginTop-padTop)/opt.grid.y) + opt.view.yOffset);
}.bind(this);
// Click handler will call all listeners passing the coordinate of click
// and the click event
canvas.onclick = function(ev) {
var c = this.getCoordinate(ev.clientX, ev.clientY),
listeners = this.listeners.click;
for(var l=0; l<listeners.length; l++)
listeners[l].call(this, c.copy(), ev);
}.bind(this);
var lastMove = new Coordinate(-1,-1);
// Move handler will call all listeners passing the coordinate of move
// whenever mouse moves over a new intersection
canvas.onmousemove = function(ev) {
if(!this.listeners.mousemove.length) return;
var c = this.getCoordinate(ev.clientX, ev.clientY),
listeners = this.listeners.mousemove;
if(c.i < this.opt.view.xOffset ||
c.i >= this.opt.view.xOffset + this.opt.view.width)
c.i = -1;
if(c.j < this.opt.view.yOffset ||
c.j >= this.opt.view.yOffset + this.opt.view.height)
c.j = -1;
if(lastMove.equals(c))
return; // no change
else
lastMove = c.copy();
for(var l=0; l<listeners.length; l++)
listeners[l].call(this, c.copy(), ev);
}.bind(this);
// Mouseout handler will again call all listeners of that event, no
// coordinates will be passed of course, only the event
canvas.onmouseout = function(ev) {
var listeners = this.listeners.mouseout;
for(var l=0; l<listeners.length; l++)
listeners[l].call(this, ev);
}.bind(this);
elem.appendChild(canvas);
this.ctx = canvas.getContext('2d');
this.opt = util.extend({}, opt); // make a copy just in case
this.stones = new Stones(opt, images);
this.images = images;
// Fill margin with correct color
this.ctx.fillStyle = opt.margin.color;
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
if(this.images.board) {
// Prepare to draw board with shadow
this.ctx.save();
this.ctx.shadowColor = opt.boardShadow.color;
this.ctx.shadowBlur = opt.boardShadow.blur;
this.ctx.shadowOffsetX = opt.boardShadow.offX;
this.ctx.shadowOffsetX = opt.boardShadow.offY;
var clipTop = opt.edge.top ? 0 : this.marginTop,
clipLeft = opt.edge.left ? 0 : this.marginLeft,
clipBottom = opt.edge.bottom ? 0 : this.marginBottom,
clipRight = opt.edge.right ? 0 : this.marginRight;
// Set clipping to throw shadow only on actual edges
this.ctx.beginPath();
this.ctx.rect(clipLeft, clipTop,
canvas.width - clipLeft - clipRight,
canvas.height - clipTop - clipBottom);
this.ctx.clip();
this.ctx.drawImage(this.images.board, 0, 0,
this.boardWidth, this.boardHeight,
this.marginLeft, this.marginTop,
this.boardWidth, this.boardHeight);
// Draw lighter border around the board to make it more photography
this.ctx.strokeStyle = opt.border.color;
this.ctx.lineWidth = opt.border.lineWidth;
this.ctx.beginPath();
this.ctx.rect(this.marginLeft, this.marginTop,
this.boardWidth, this.boardHeight);
this.ctx.stroke();
this.ctx.restore(); // forget shadow and clipping
}
// Top left center of grid (not edge, center!)
this.gridTop = this.marginTop + padTop + opt.grid.y / 2;
this.gridLeft = this.marginLeft + padLeft + opt.grid.x / 2;
this.ctx.strokeStyle = opt.grid.color;
var smt = this.opt.grid.smooth; // with 0.5 there will be full antialias
// Draw vertical gridlines
for(i=0; i<opt.view.width; i++) {
if((i === 0 && opt.edge.left) || (i+1 == opt.view.width && opt.edge.right))
this.ctx.lineWidth = opt.grid.borderWidth;
else
this.ctx.lineWidth = opt.grid.lineWidth;
this.ctx.beginPath();
this.ctx.moveTo(smt + this.gridLeft + opt.grid.x * i,
smt + this.gridTop - (opt.edge.top ? 0 : opt.grid.y / 2 + padTop/2));
this.ctx.lineTo(smt + this.gridLeft + opt.grid.x * i,
smt + this.gridTop + opt.grid.y * (opt.view.height - 1) +
(opt.edge.bottom ? 0 : opt.grid.y / 2 + padBottom/2));
this.ctx.stroke();
}
// Draw horizontal gridlines
for(i=0; i<opt.view.height; i++) {
if((i === 0 && opt.edge.top) || (i+1 == opt.view.height && opt.edge.bottom))
this.ctx.lineWidth = opt.grid.borderWidth;
else
this.ctx.lineWidth = opt.grid.lineWidth;
this.ctx.beginPath();
this.ctx.moveTo(smt + this.gridLeft - (opt.edge.left ? 0 : opt.grid.x / 2 + padLeft/2),
smt + this.gridTop + opt.grid.y * i);
this.ctx.lineTo(smt + this.gridLeft + opt.grid.x * (opt.view.width - 1) +
(opt.edge.right ? 0 : opt.grid.x / 2 + padRight/2),
smt + this.gridTop + opt.grid.y * i);
this.ctx.stroke();
}
if(opt.stars.points) { // If star points
var step = (opt.board.width - 1) / 2 - opt.stars.offset;
// 1, 4, 5, 8 and 9 points are supported, rest will result in randomness
for(j=0; j<3; j++) {
for(i=0; i<3; i++) {
if(j == 1 && i == 1) { // center
if(opt.stars.points % 2 === 0)
continue; // skip center
} else if(i == 1 || j == 1) { // non-corners
if(opt.stars.points < 8)
continue; // skip non-corners
} else { // corners
if(opt.stars.points < 4)
continue; // skip corners
}
var x = (opt.stars.offset + i * step) - opt.view.xOffset,
y = (opt.stars.offset + j * step) - opt.view.yOffset;
if(x < 0 || y < 0 || x >= opt.view.width || y >= opt.view.height)
continue; // invisible
this.ctx.beginPath();
this.ctx.arc(smt + this.gridLeft + x * opt.grid.x,
smt + this.gridTop + y * opt.grid.y,
opt.stars.radius, 2*Math.PI, false);
this.ctx.fillStyle = opt.grid.color;
this.ctx.fill();
}
}
}
this.ctx.font = opt.coordinates.font;
this.ctx.fillStyle = opt.coordinates.color;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
// Draw horizontal coordinates
for(i=0; i<opt.view.width; i++) {
if(opt.coordinates && opt.coordinates.top)
this.ctx.fillText(C.COORDINATES[i + opt.view.xOffset],
this.gridLeft + opt.grid.x * i,
this.marginTop / 2);
if(opt.coordinates && opt.coordinates.bottom)
this.ctx.fillText(C.COORDINATES[i + opt.view.xOffset],
this.gridLeft + opt.grid.x * i,
canvas.height - this.marginBottom / 2);
}
// Draw vertical coordinates
for(i=0; i<opt.view.height; i++) {
if(opt.coordinates && opt.coordinates.left)
this.ctx.fillText(''+(opt.board.height-opt.view.yOffset-i),
this.marginLeft / 2,
this.gridTop + opt.grid.y * i);
if(opt.coordinates && opt.coordinates.right)
this.ctx.fillText(''+(opt.board.height-opt.view.yOffset-i),
canvas.width - this.marginRight / 2,
this.gridTop + opt.grid.y * i);
}
// Store rendered board in another canvas for fast redraw
this.backup = document.createElement('canvas');
this.backup.width = canvas.width;
this.backup.height = canvas.height;
this.backup.getContext('2d').drawImage(canvas,
0, 0, canvas.width, canvas.height,
0, 0, canvas.width, canvas.height);
// Clip further drawing to board only
this.ctx.beginPath();
this.ctx.rect(this.marginLeft, this.marginTop, this.boardWidth, this.boardHeight);
this.ctx.clip();
// Fix Chromium bug with image glitches unless they are drawn once
// https://code.google.com/p/chromium/issues/detail?id=469906
if(this.images.black) this.ctx.drawImage(this.images.black, 10, 10);
if(this.images.white) this.ctx.drawImage(this.images.white, 10, 10);
if(this.images.shadow) this.ctx.drawImage(this.images.shadow, 10, 10);
// Sucks but works
this.restore(this.marginLeft, this.marginTop, this.boardWidth, this.boardHeight);
};
/**
* Restore portion of canvas.
*/
Canvas.prototype.restore = function(x, y, w, h) {
x = Math.floor(x);
y = Math.floor(y);
this.ctx.drawImage(this.backup, x, y, w, h, x, y, w, h);
};
/**
* Get X coordinate based on column.
* @returns {number} Coordinate.
*/
Canvas.prototype.getX = function(i) {
return this.gridLeft + this.opt.grid.x * i;
};
/**
* Get Y coordinate based on row.
* @returns {number} Coordinate.
*/
Canvas.prototype.getY = function(j) {
return this.gridTop + this.opt.grid.y * j;
};
/**
* Redraw canvas portion using a board.
*
* @param {Board} jboard Board object.
* @param {number} i1 Starting column to be redrawn.
* @param {number} j1 Starting row to be redrawn.
* @param {number} i2 Ending column to be redrawn (inclusive).
* @param {number} j2 Ending row to be redrawn (inclusive).
*/
Canvas.prototype.draw = function(jboard, i1, j1, i2, j2) {
i1 = Math.max(i1, this.opt.view.xOffset);
j1 = Math.max(j1, this.opt.view.yOffset);
i2 = Math.min(i2, this.opt.view.xOffset + this.opt.view.width - 1);
j2 = Math.min(j2, this.opt.view.yOffset + this.opt.view.height - 1);
if(i2 < i1 || j2 < j1)
return; // nothing to do here
var x = this.getX(i1 - this.opt.view.xOffset) - this.opt.grid.x,
y = this.getY(j1 - this.opt.view.yOffset) - this.opt.grid.y,
w = this.opt.grid.x * (i2 - i1 + 2),
h = this.opt.grid.y * (j2 - j1 + 2);
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(x, y, w, h);
this.ctx.clip(); // only apply redraw to relevant area
this.restore(x, y, w, h); // restore background
// Expand redrawn intersections while keeping within viewport
i1 = Math.max(i1-1, this.opt.view.xOffset);
j1 = Math.max(j1-1, this.opt.view.yOffset);
i2 = Math.min(i2+1, this.opt.view.xOffset + this.opt.view.width - 1);
j2 = Math.min(j2+1, this.opt.view.yOffset + this.opt.view.height - 1);
var isLabel = /^[a-zA-Z1-9]/;
// Stone radius derived marker size parameters
var stoneR = this.opt.stone.radius,
clearW = stoneR * 1.5, clearH = stoneR * 1.2, clearFunc;
// Clear grid for labels on clear intersections before casting shadows
if(this.images.board) { // there is a board texture
clearFunc = function(ox, oy) {
this.ctx.drawImage(this.images.board,
ox - this.marginLeft - clearW / 2, oy - this.marginTop - clearH / 2, clearW, clearH,
ox - clearW / 2, oy - clearH / 2, clearW, clearH);
}.bind(this);
} else { // no board texture
this.ctx.fillStyle = this.opt.margin.color;
clearFunc = function(ox, oy) {
this.ctx.fillRect(ox - clearW / 2, oy - clearH / 2, clearW, clearH);
}.bind(this);
}
// Clear board grid under markers when needed
jboard.each(function(c, type, mark) {
// Note: Use of smt has been disabled here for clear results
var ox = this.getX(c.i - this.opt.view.xOffset);
var oy = this.getY(c.j - this.opt.view.yOffset);
if(type == C.CLEAR && mark && isLabel.test(mark))
clearFunc(ox, oy);
}.bind(this), i1, j1, i2, j2); // provide iteration limits
// Shadows
jboard.each(function(c, type) {
var ox = this.getX(c.i - this.opt.view.xOffset);
var oy = this.getY(c.j - this.opt.view.yOffset);
if(type == C.BLACK || type == C.WHITE) {
this.stones.drawShadow(this.ctx,
this.opt.shadow.xOff + ox,
this.opt.shadow.yOff + oy);
}
}.bind(this), i1, j1, i2, j2); // provide iteration limits
// Stones and marks
jboard.each(function(c, type, mark) {
var ox = (this.getX(c.i - this.opt.view.xOffset));
var oy = (this.getY(c.j - this.opt.view.yOffset));
var markColor;
switch(type) {
case C.BLACK:
case C.DIM_BLACK:
this.ctx.globalAlpha = type == C.BLACK ? 1 : this.opt.stone.dimAlpha;
this.stones.drawStone(this.ctx, type, ox, oy);
markColor = this.opt.mark.blackColor; // if we have marks, this is the color
break;
case C.WHITE:
case C.DIM_WHITE:
this.ctx.globalAlpha = type == C.WHITE ? 1 : this.opt.stone.dimAlpha;
this.stones.drawStone(this.ctx, type, ox, oy);
markColor = this.opt.mark.whiteColor; // if we have marks, this is the color
break;
default:
this.ctx.globalAlpha=1;
markColor = this.opt.mark.clearColor; // if we have marks, this is the color
}
// Common settings to all markers
this.ctx.lineWidth = this.opt.mark.lineWidth;
this.ctx.strokeStyle = markColor;
this.ctx.font = this.opt.mark.font;
this.ctx.fillStyle = markColor;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
if(mark) this.stones.drawMark(this.ctx, mark, ox, oy);
}.bind(this), i1, j1, i2, j2); // provide iteration limits
this.ctx.restore(); // also restores globalAlpha
};
/**
* Add an event listener to canvas (click) events. The callback will be
* called with 'this' referring to Canvas object, with coordinate and
* event as parameters. Supported event types are 'click', 'mousemove',
* and 'mouseout'. With 'mouseout', there is no coordinate parameter for
* callback.
*
* @param {String} event The event to listen to, e.g. 'click'.
* @param {function} callback The callback.
*/
Canvas.prototype.addListener = function(event, callback) {
this.listeners[event].push(callback);
};
module.exports = Canvas;