//////////////////////////////////////////////////////////////////////////////
// Canvas
//////////////////////////////////////////////////////////////////////////////
import Color from './Color';
import utils from './Utils';
import * as d3 from 'd3';
/**
* The canvas object controls the map layers and has methods for drawing and erasing on the layers.
* Each layer is an HTML canvas element.
*
* <a name="layers"></a>
* ### Layers
*
* Layer | Description
* ------------------|---------------
* background | for drawing behind the map
* map | main layer, where the map is drawn
* foreground | for drawing in front of the map (e.g. map based captions)
* canvas | layer for traning static components (e.g. canvas based captions and legend)
* debug | layer to draw debug information
* ui | layer for capturing interactions
*/
class Canvas {
/**
* Create the Canvas object.
* @param {Viewer} viewer - The viewer
* @param {d3Element} container - D3 Element where canvas layers will be added
* @param {Object} options - Possible properties: width [Default: 600], height [Default: 600]
*/
constructor(viewer, container, options = {}) {
this._viewer = viewer;
this.width = utils.defaultFor(options.width, 600);
this.height = utils.defaultFor(options.height, 600);
// Create layers
this.determinePixelRatio(container);
this._layerNames = ['background', 'map', 'foreground', 'canvas', 'debug', 'ui'];
this._layers = this.createLayers(container, this._layerNames, this._width, this._height);
// This value is used to restrict the draw range for testing (see _testDrawRange)
this._drawRange = 0.4;
}
/**
* @member {Number} - Get the pixel ratio for the canvas.
*/
get pixelRatio() {
return this._pixelRatio;
}
/**
* Determines the pixel ratio for the provided d3 element.
* @param {d3Element} container - D3 Element
* @private
*/
determinePixelRatio(container) {
const testNode = container.append('canvas')
.style('position', 'absolute')
.style('top', 0)
.style('left', 0)
.attr('width', this._width)
.attr('height', this._height).node();
// Check for canvas support
if (testNode.getContext) {
// Get pixel ratio and upscale canvas depending on screen resolution
// http://www.html5rocks.com/en/tutorials/canvas/hidpi/
this._pixelRatio = utils.getPixelRatio(testNode);
} else {
container.html('<h3>CGView requires Canvas, which is not supported by this browser.</h3>');
}
d3.select(testNode).remove();
}
/**
* Creates a layer for each element in layerNames.
* @param {d3Element} container - D3 Element
* @param {Array} layerNames - Array of layer names
* @param {Number} width - Width of each layer
* @param {Number} height - Height of each layer
* @param {Boolean} scaleLayer - Sclaes the layers basedon the pixel ratio [Default: true]
* @private
*/
createLayers(container, layerNames, width, height, scaleLayers = true) {
const layers = {};
for (let i = 0, len = layerNames.length; i < len; i++) {
const layerName = layerNames[i];
const zIndex = (i + 1) * 10;
const node = container.append('canvas')
.classed('cgv-layer', true)
.classed(`cgv-layer-${layerName}`, true)
.style('z-index', zIndex)
.attr('width', width)
.attr('height', height).node();
if (scaleLayers) {
utils.scaleResolution(node, this.pixelRatio);
}
// Set viewer context
const ctx = node.getContext('2d');
// Consider this to help make linear horizontal lines cleaner
// ctx.translate(0.5, 0.5);
layers[layerName] = { ctx: ctx, node: node };
}
return layers;
}
/**
* Resize all layers to a new width and height.
* @param {Number} width - New width for each layer
* @param {Number} height - New height for each layer
*/
resize(width, height) {
this.width = width;
this.height = height;
for (const layerName of this.layerNames) {
const layerNode = this.layers(layerName).node;
// Note, here the width/height will take into account the pixelRatio
layerNode.width = this.width;
layerNode.height = this.height;
// Note, here the width/height will be the same as viewer (no pixel ratio)
layerNode.style.width = `${width}px`;
layerNode.style.height = `${height}px`;
utils.scaleResolution(layerNode, this.pixelRatio);
}
this.layout.updateScales();
}
/**
* @member {Viewer} - Get the viewer.
*/
get viewer() {
return this._viewer;
}
/**
* @member {Layout} - Get the layout.
*/
get layout() {
return this.viewer.layout;
}
/**
* @member {Array} - Get the names of the layers.
*/
get layerNames() {
return this._layerNames;
}
/**
* @member {Sequence} - Get the sequence.
*/
get sequence() {
return this.viewer.sequence;
}
/**
* @member {Number} - Get the width of the canvas. Changing this value will not resize the layers. Use [resize](#resize) instead.
*/
get width() {
return this._width;
}
set width(width) {
this._width = width;
}
/**
* @member {Number} - Get the width of the canvas. Changing this value will not resize the layers. Use [resize](#resize) instead.
*/
get height() {
return this._height;
}
set height(height) {
this._height = height;
}
/**
* @member {String} - Get or set the cursor style for the mouse when it's on the canvas.
*/
get cursor() {
return d3.select(this.node('ui')).style('cursor');
}
set cursor(value) {
d3.select(this.node('ui')).style('cursor', value);
}
/**
* Clear the viewer canvas.
* @param {String} layerName - Name of layer to clear [Default: 'map']. A special value of 'all' will clear all the layers.
*/
clear(layerName = 'map') {
if (layerName === 'all') {
for (let i = 0, len = this.layerNames.length; i < len; i++) {
this.clear(this.layerNames[i]);
}
} else if (layerName === 'background') {
const ctx = this.context('background');
ctx.clearRect(0, 0, this.width, this.height);
ctx.fillStyle = this.viewer.settings.backgroundColor.rgbaString;
ctx.fillRect(0, 0, this.width, this.height);
} else {
// this.context(layerName).clearRect(0, 0, this.width, this.height);
if (this._testDrawRange) {
this.context(layerName).clearRect(0, 0, this.width / this._drawRange, this.height / this._drawRange);
} else {
this.context(layerName).clearRect(0, 0, this.width, this.height);
}
}
}
/**
* Draws an arc or arrow on the map.
* @param {String} layer - Name of layer to draw element on
* @param {Number} start - Start position (bp) of element
* @param {Number} stop - Stop position (bp) of element
* @param {Number} centerOffset - Distance form center of map to draw element
* @param {Color} color - A string describing the color. {@link Color} for details.
* @param {Number} width - Width of element
* @param {String} decoration - How the element should be drawn. Values: 'arc', 'clockwise-arrow', 'counterclockwise-arrow', 'none'
* @param {Boolean} showShading - Should the elment be drawn with shading [Default: value from settings [showShading](Settings.html#showShading)]
* @private
*/
// Decoration: arc, clockwise-arrow, counterclockwise-arrow, none
//
// - clockwise-arrow (drawn clockwise from arcStartBp; direction = 1):
//
// arcStartBp (feature start) arcStopBp
// | |
// -------------------------- arrowTipBp
// | \|
// | x - arrowTipPt (feature stop)
// | /
// -------------------------x
// |
// innerArcStartPt
//
// - counterclockwise-arrow (drawn counterclockwise from arcStartBp; direction = -1):
//
// arcStopBp arcStartBp (feature stop)
// | |
// arrowTipBp -------------------------
// | / |
// arrowTipPt - x |
// (feature start) \ |
// x-------------------------
// |
// innerArcStartPt
//
// If the zoomFactor gets too large, the arc drawing becomes unstable.
// (ie the arc wiggle in the map as zooming)
// So when the zoomFactor is large, switch to drawing lines ([path](#path) handles this).
drawElement(layer, start, stop, centerOffset, color = '#000000', width = 1, decoration = 'arc', showShading, minArcLength) {
if (decoration === 'none') { return; }
const ctx = this.context(layer);
const settings = this.viewer.settings;
const shadowFraction = 0.10;
const shadowColorDiff = 0.15;
ctx.lineCap = 'butt';
// ctx.lineJoin = 'round';
showShading = (showShading === undefined) ? settings.showShading : showShading;
// When drawing elements (arcs or arrows), the element should be offset by
// half a bp on each side. This will allow single base features to be
// drawn. It also reduces ambiguity for where features start/stop.
// For example, if the start and stop is 10, the feature will be drwan from
// 9.5 to 10.5.
start -= 0.5;
stop += 0.5;
if (decoration === 'arc') {
// Adjust feature start and stop based on minimum arc length.
// Minimum arc length refers to the minimum size (in pixels) an arc will be drawn.
// At some scales, small features will have an arc length of a fraction
// of a pixel. In these cases, the arcs are hard to see.
// A minArcLength of 0 means no adjustments will be made.
// const minArcLengthPixels = settings.minArcLength;
const minArcLengthPixels = utils.defaultFor(minArcLength, this.viewer.legend.defaultMinArcLength);
const featureLengthBp = this.sequence.lengthOfRange(start, stop);
const minArcLengthBp = minArcLengthPixels / this.pixelsPerBp(centerOffset);
if ( featureLengthBp < minArcLengthBp ) {
const middleBP = start + ( featureLengthBp / 2 );
start = middleBP - (minArcLengthBp / 2);
stop = middleBP + (minArcLengthBp / 2);
}
if (showShading) {
const shadowWidth = width * shadowFraction;
// Main Arc
const mainWidth = width - (2 * shadowWidth);
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = mainWidth;
this.path(layer, centerOffset, start, stop);
ctx.stroke();
const shadowOffsetDiff = (mainWidth / 2) + (shadowWidth / 2);
ctx.lineWidth = shadowWidth;
// Highlight
ctx.beginPath();
ctx.strokeStyle = new Color(color).lighten(shadowColorDiff).rgbaString;
this.path(layer, centerOffset + shadowOffsetDiff, start, stop);
ctx.stroke();
// Shadow
ctx.beginPath();
ctx.strokeStyle = new Color(color).darken(shadowColorDiff).rgbaString;
this.path(layer, centerOffset - shadowOffsetDiff, start, stop);
ctx.stroke();
} else {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = width;
this.path(layer, centerOffset, start, stop);
ctx.stroke();
}
}
// Looks like we're drawing an arrow
if (decoration === 'clockwise-arrow' || decoration === 'counterclockwise-arrow') {
// Determine Arrowhead length
// Using width which changes according zoom factor upto a point
const arrowHeadLengthPixels = width * settings.arrowHeadLength;
const arrowHeadLengthBp = arrowHeadLengthPixels / this.pixelsPerBp(centerOffset);
// If arrow head length is longer than feature length, adjust start and stop
const featureLength = this.sequence.lengthOfRange(start, stop);
if ( featureLength < arrowHeadLengthBp ) {
const middleBP = start + ( featureLength / 2 );
// Originally, the feature was adjusted to be the arrow head length.
// However, this caused an issue with SVG drawing because the arc part of
// the arrow would essentially be 0 bp. Drawing an arc of length 0 caused weird artifacts.
// So here we add an additional 0.1 bp to the adjusted length.
const adjustedFeatureHalfLength = (arrowHeadLengthBp + 0.1) / 2;
start = middleBP - adjustedFeatureHalfLength;
stop = middleBP + adjustedFeatureHalfLength;
}
// Set up drawing direction
const arcStartBp = (decoration === 'clockwise-arrow') ? start : stop;
const arrowTipBp = (decoration === 'clockwise-arrow') ? stop : start;
const direction = (decoration === 'clockwise-arrow') ? 1 : -1;
// Calculate important points
const halfWidth = width / 2;
const arcStopBp = arrowTipBp - (direction * arrowHeadLengthBp);
const arrowTipPt = this.pointForBp(arrowTipBp, centerOffset);
const innerArcStartPt = this.pointForBp(arcStopBp, centerOffset - halfWidth);
if (showShading) {
const halfMainWidth = width * (0.5 - shadowFraction);
const shadowPt = this.pointForBp(arcStopBp, centerOffset - halfMainWidth);
// Main Arrow
ctx.beginPath();
ctx.fillStyle = color;
this.path(layer, centerOffset + halfMainWidth, arcStartBp, arcStopBp, direction === -1);
ctx.lineTo(arrowTipPt.x, arrowTipPt.y);
ctx.lineTo(shadowPt.x, shadowPt.y);
this.path(layer, centerOffset - halfMainWidth, arcStopBp, arcStartBp, direction === 1, 'noMoveTo');
ctx.closePath();
ctx.fill();
// Highlight
const highlightPt = this.pointForBp(arcStopBp, centerOffset + halfMainWidth);
ctx.beginPath();
ctx.fillStyle = new Color(color).lighten(shadowColorDiff).rgbaString;
this.path(layer, centerOffset + halfWidth, arcStartBp, arcStopBp, direction === -1);
ctx.lineTo(arrowTipPt.x, arrowTipPt.y);
ctx.lineTo(highlightPt.x, highlightPt.y);
this.path(layer, centerOffset + halfMainWidth, arcStopBp, arcStartBp, direction === 1, 'noMoveTo');
ctx.closePath();
ctx.fill();
// Shadow
ctx.beginPath();
ctx.fillStyle = new Color(color).darken(shadowColorDiff).rgbaString;
this.path(layer, centerOffset - halfWidth, arcStartBp, arcStopBp, direction === -1);
ctx.lineTo(arrowTipPt.x, arrowTipPt.y);
ctx.lineTo(shadowPt.x, shadowPt.y);
this.path(layer, centerOffset - halfMainWidth, arcStopBp, arcStartBp, direction === 1, 'noMoveTo');
ctx.closePath();
ctx.fill();
} else {
// Draw arc with arrow head
ctx.beginPath();
ctx.fillStyle = color;
this.path(layer, centerOffset + halfWidth, arcStartBp, arcStopBp, direction === -1);
ctx.lineTo(arrowTipPt.x, arrowTipPt.y);
ctx.lineTo(innerArcStartPt.x, innerArcStartPt.y);
this.path(layer, centerOffset - halfWidth, arcStopBp, arcStartBp, direction === 1, 'noMoveTo');
ctx.closePath();
ctx.fill();
}
}
}
/**
* This method adds a path to the canvas and uses the underlying Layout for the actual drawing.
* For circular layouts the path is usually an arc, however, if the zoomFactor is very large,
* the arc is added as a straight line.
* @param {String} layer - Name of layer to draw the path on
* @param {Number} centerOffset - Distance form center of map to draw path
* @param {Number} startBp - Start position (bp) of path
* @param {Number} stopBp - Stop position (bp) of path
* @param {Boolean} anticlockwise - Should the elment be drawn in an anticlockwise direction
* @param {String} startType - How the path should be started. Allowed values:
* <br /><br />
* - moveTo: *moveTo* start; *lineTo* stop
* - lineTo: *lineTo* start; *lineTo* stop
* - noMoveTo: ingore start; *lineTo* stop
* @private
*/
// FIXME: try calling layout.path with object parameters and compare speed
// e.g. path({layer: 'map', offset = radius, etc})
path(layer, centerOffset, startBp, stopBp, anticlockwise = false, startType = 'moveTo') {
this.layout.path(layer, centerOffset, startBp, stopBp, anticlockwise, startType);
}
/**
* Draw a line radiating from the map at a particular basepair position.
* @param {String} layer - Name of layer to draw the path on
* @param {Number} bp - Basepair position of the line
* @param {Number} centerOffset - Distance form center of map to start the line
* @param {Number} length - Length of line
* @param {Color} color - A string describing the color. {@link Color} for details.
* @param {String} cap - The stroke linecap for the starting and ending points for the line. Values: 'butt', 'square', 'round'
* @private
*/
radiantLine(layer, bp, centerOffset, length, lineWidth = 1, color = 'black', cap = 'butt') {
const innerPt = this.pointForBp(bp, centerOffset);
const outerPt = this.pointForBp(bp, centerOffset + length);
const ctx = this.context(layer);
ctx.beginPath();
ctx.moveTo(innerPt.x, innerPt.y);
ctx.lineTo(outerPt.x, outerPt.y);
ctx.strokeStyle = color;
ctx.lineCap = cap;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
/**
* Alias for Layout [pointForBp](Layout.html#pointForBp)
* @private
*/
pointForBp(bp, centerOffset) {
return this.layout.pointForBp(bp, centerOffset);
}
/**
* Returns the bp for the current mouse position on the canvas
* @private
*/
bpForMouse() {
// const pos = d3.mouse(this.node('ui'));
// return this.bpForPoint({x: pos[0], y: pos[1]});
const event = this.viewer.mouse
if (event) {
return this.bpForPoint({x: event.canvasX, y: event.canvasY});
}
}
/**
* Returns the bp for the center of the canvas.
* @private
*/
bpForCanvasCenter() {
return this.bpForPoint({x: this.width / 2, y: this.height / 2});
}
/**
* Alias for Layout [bpForPoint](Layout.html#bpForPoint)
* FIXME: this should be removed and everywhere should call layout method
* @private
*/
bpForPoint(point) {
return this.layout.bpForPoint(point);
}
/**
* Alias for Layout [visibleRangeForCenterOffset](Layout.html#visibleRangeForCenterOffset)
* @private
*/
visibleRangeForCenterOffset(centerOffset, margin = 0) {
return this.layout.visibleRangeForCenterOffset(centerOffset, margin);
}
/**
* At the current zoom level, how many pixels are there per basepair.
* @param {Number} centerOffset - Distance from map center to calculate. This
* makes no difference for linear maps.
* @private
*/
pixelsPerBp(centerOffset = this.viewer.backbone.adjustedCenterOffset) {
return this.layout.pixelsPerBp(centerOffset);
}
/**
* Returns the layer with the specified name (defaults to map layer)
* @param {String} layer - Name of layer to return
* @private
*/
layers(layer='map') {
if (this._layerNames.includes(layer)) {
return this._layers[layer];
} else {
console.error('Returning map layer by default');
return this._layers.map;
}
}
/**
* Returns the context for the specified layer (defaults to map layer)
* @param {String} layer - Name of layer to return context
* @private
*/
context(layer) {
if (this._layerNames.includes(layer)) {
return this.layers(layer).ctx;
} else {
console.error('Returning map layer by default');
return this.layers('map').ctx;
}
}
/**
* Return the node for the specified layer (defaults to map layer)
* @param {String} layer - Name of layer to return node element
* @private
*/
node(layer) {
if (this._layerNames.includes(layer)) {
return this.layers(layer).node;
} else {
console.error('Returning map layer by default');
return this.layers('map').node;
}
}
/**
* This test method reduces the canvas width and height so
* you can see how the features are reduced (not drawn) as
* you move the map out of the visible range.
* @member {Boolean}
* @private
*/
get _testDrawRange() {
return this.__testDrawRange;
}
set _testDrawRange(value) {
this.__testDrawRange = value;
if (value) {
// Change canvas dimensions
this.width = this.width * this._drawRange;
this.height = this.height * this._drawRange;
// Draw Rect around test area
const ctx = this.context('canvas');
ctx.strokeStyle = 'grey';
ctx.rect(0, 0, this.width, this.height);
ctx.stroke();
// ctx.translate(100, 100);
} else {
// Return canvas dimensions to normal
this.width = this.width / this._drawRange;
this.height = this.height / this._drawRange;
// Clear rect around test area
const ctx = this.context('canvas');
ctx.clearRect(0, 0, this.width, this.height);
}
this.viewer.drawFull();
}
}
export default Canvas;