//////////////////////////////////////////////////////////////////////////////
// Annotation
//////////////////////////////////////////////////////////////////////////////
import CGObject from './CGObject';
import CGArray from './CGArray';
import LabelPlacementDefault from './LabelPlacementDefault';
import LabelPlacementAngled from './LabelPlacementAngled';
import Font from './Font';
import Color from './Color';
import NCList from './NCList';
import Rect from './Rect';
import utils from './Utils';
/**
* Annotation controls the drawing and layout of features labels
*
* ### Action and Events
*
* Action | Viewer Method | Annotation Method | Event
* ------------------------------------------|--------------------------------------|---------------------|-----
* [Update](../docs.html#s.updating-records) | - | [update()](#update) | annotation-update
* [Read](../docs.html#s.reading-records) | [annotation](Viewer.html#annotation) | - | -
*
* <a name="attributes"></a>
* ### Attributes
*
* Attribute | Type | Description
* ---------------------------------|-----------|------------
* [font](#font) | String | A string describing the font [Default: 'monospace, plain, 12']. See {@link Font} for details.
* [color](#color) | String | A string describing the color [Default: undefined]. If the color is undefined, the legend color for the feature will be used. See {@link Color} for details.
* [onlyDrawFavorites](#onlyDrawFavorites) | Boolean | Only draw labels for features that are favorited [Default: false]
* [labelPlacement](#labelPlacement) | String | The label placement method for positioning labels. Choices: 'default', 'angled' [Default: 'default']
* [visible](CGObject.html#visible) | Boolean | Labels are visible [Default: true]
* [meta](CGObject.html#meta) | Object | [Meta data](tutorial-meta.html) for Annotation
*
* ### Examples
* ```js
* // Only draw labels for features that have been marked as a favorite
* cgv.annotation.update({
* onlyDrawFavorites: true
* });
*
* // Changing the label placement from the default to angled (for both fast and full draw)
* cgv.annotation.labelPlacement = 'angled'
*
* // Changing the label placement so that fast draw uses the default labels and full draw uses the angled labels
* cgv.annotation.labelPlacementFast = 'default'
* cgv.annotation.labelPlacementFull = 'angled'
* ```
*
* @extends CGObject
*/
class Annotation extends CGObject {
/**
* Create the annotation.
* @param {Viewer} viewer - The viewer
* @param {Object} options - [Attributes](#attributes) used to create the annotation
* @param {Object} [meta] - User-defined [Meta data](../tutorials/details-meta-data.html) to add to the annotation.
*/
constructor(viewer, options = {}, meta = {}) {
super(viewer, options, meta);
this._labels = new CGArray();
this.font = utils.defaultFor(options.font, 'monospace, plain, 12');
this.labelLineLength = utils.defaultFor(options.labelLineLength, 20);
this.priorityMax = utils.defaultFor(options.priorityMax, 50);
this._labelLineMarginInner = 10;
this._labelLineMarginOuter = 5; // NOT REALLY IMPLEMENTED YET
this._labelLineWidth = 1;
this.refresh();
this._visibleLabels = new CGArray();
this.color = options.color;
this.lineCap = 'round';
// this.lineCap = 'butt';
this.onlyDrawFavorites = utils.defaultFor(options.onlyDrawFavorites, false);
this.labelPlacement = utils.defaultFor(options.labelPlacement, 'default');
// this.labelPlacementFast = 'default';
// this.labelPlacementFull = 'angled'
this.viewer.trigger('annotation-update', { attributes: this.toJSON({includeDefaults: true}) });
// this._debug = true;
}
/**
* Return the class name as a string.
* @return {String} - 'Annotation'
*/
toString() {
return 'Annotation';
}
/**
* @member {Color} - Get or set the label color. When setting the color, a
* string representing the color or a {@link Color} object can be used. For
* details see {@link Color}.
*/
get color() {
return this._color;
}
set color(value) {
if (value === undefined || value.toString() === 'Color') {
this._color = value;
} else {
this._color = new Color(value);
}
}
/**
* @member {Number} - Get or set the label line length.
*/
get labelLineLength() {
return this._labelLineLength;
}
set labelLineLength(value) {
this._labelLineLength = value;
}
/**
* @member {Number} - Get or set the number of priority labels that will be
* drawn for sure. If they overlap the label will be moved until they no
* longer overlap. Priority is defined as features that are marked as a
* "favorite". After favorites, features are sorted by size. For example, if
* priorityMax is 50 and there are 10 "favorite" features. The favorites will
* be drawn and then the 40 largest features will be drawn.
*/
get priorityMax() {
return this._priorityMax;
}
set priorityMax(value) {
this._priorityMax = value;
}
/**
* @member {Font} - Get or set the font. When setting the font, a string
* representing the font or a {@link Font} object can be used. For details
* see {@link Font}.
*/
get font() {
return this._font;
}
set font(value) {
if (value.toString() === 'Font') {
this._font = value;
} else {
this._font = new Font(value);
}
this.refreshLabelWidths();
// FIXME: can we use update to do this??
this._font.on('change', () => this.refreshLabelWidths());
}
/**
* @member {Number} - The number of labels in the set.
*/
get length() {
return this._labels.length;
}
/**
* @member {LabelPlacement} - Set the label placement instance for both fast and full drawing.
* Value can be one of the following: 'default', 'angled', or a custom LabelPlacement class.
*/
set labelPlacement(value) {
const labelPlacement = this._initialializeLabelPlacement(value);
this._labelPlacementFast = labelPlacement;
this._labelPlacementFull = labelPlacement;
}
/**
* @member {LabelPlacement} - Get or set the label placement instance for fast drawing.
* Values for setting can be one of the following: 'default', 'angled', or a custom LabelPlacement class.
*/
get labelPlacementFast() {
return this._labelPlacementFast;
}
set labelPlacementFast(value) {
this._labelPlacementFast = this._initialializeLabelPlacement(value);
}
/**
* @member {LabelPlacement} - Get or set the label placement instance for full drawing.
* Values for setting can be one of the following: 'default', 'angled', or a custom LabelPlacement class.
*/
get labelPlacementFull() {
return this._labelPlacementFull;
}
set labelPlacementFull(value) {
this._labelPlacementFull = this._initialializeLabelPlacement(value);
}
_initialializeLabelPlacement(nameOrClass) {
if (typeof nameOrClass === 'string') {
switch (nameOrClass) {
case 'default': return new LabelPlacementDefault(this);
case 'angled': return new LabelPlacementAngled(this);
default: throw new Error(`Label Placement name '${nameOrClass}' unknown. Use one of 'default', 'angled'`);
}
} else {
// Use provided custom LabelPlacement class
// TODO: document making custom class and perhaps checking here that required methods are available in provided class
return new nameOrClass(this);
}
}
/**
* Returns an [CGArray](CGArray.html) of Labels or a single Label.
* @param {Integer|String|Array} term - See [CGArray.get](CGArray.html#get) for details.
* @return {CGArray}
*/
labels(term) {
return this._labels.get(term);
}
/**
* Add a new label to the set.
* @param {Label} label - The Label to add to the set.
*/
addLabel(label) {
this._labels.push(label);
}
/**
* Remove a label or an array of labels from the set.
* @param {Label|Array} labels - The Label(s) to remove from the set.
*/
removeLabels(labels) {
labels = (labels.toString() === 'CGArray') ? labels : new CGArray(labels);
this._labels = this._labels.filter( i => !labels.includes(i) );
this.refresh();
}
// Called from Viewer.add/removeFeatures() and Sequence.updateContigs(), Viewer.updateFeatures(), Viewer.updateTracks()
refresh() {
// Remove labels that are on invisible contigs
// const labels = this._labels.filter( (l) => l.feature.contig.visible);
// Remove labels:
// - on invisible features
// - with features on invisible contigs
// - with features on invisible tracks
const labels = this._labels.filter( (l) => l.feature.visible && l.feature.contig.visible && l.feature.tracks().some( (t) => t.visible ));
this._availableLabels = labels;
// Update default Bp for labels
for (const label of labels) {
label.bpDefault = label.feature.mapRange.middle;
}
this._labelsNCList = new NCList(labels, { circularLength: this.sequence.length, startProperty: 'mapStart', stopProperty: 'mapStop'});
}
refreshLabelWidths() {
const labelFonts = this._labels.map( i => i.font.css );
const labelTexts = this._labels.map( i => i.name );
const labelWidths = Font.calculateWidths(this.canvas.context('map'), labelFonts, labelTexts);
for (let i = 0, len = this._labels.length; i < len; i++) {
this._labels[i].width = labelWidths[i];
}
}
// Determine basepair position for each label.
// This will just be the center of the feature,
// unless the the whole feature is not visible.
_calculatePositions(labels) {
labels = labels || this._labels;
const visibleRange = this._visibleRange;
let label, feature, containsStart, containsStop;
let featureLengthDownStream, featureLengthUpStream;
const sequence = this.sequence;
for (let i = 0, len = labels.length; i < len; i++) {
label = labels[i];
feature = label.feature;
containsStart = visibleRange.containsMapBp(feature.mapStart);
containsStop = visibleRange.containsMapBp(feature.mapStop);
if (containsStart && containsStop) {
label.bp = label.bpDefault;
label.lineAttachment = label.lineAttachmentDefault;
// console.log(label.lineAttachment)
} else {
if (containsStart) {
label.bp = feature.mapRange.getStartPlus( sequence.lengthOfRange(feature.mapStart, visibleRange.stop) / 2 );
} else if (containsStop) {
label.bp = feature.mapRange.getStopPlus( -sequence.lengthOfRange(visibleRange.start, feature.mapStop) / 2 );
} else {
featureLengthDownStream = sequence.lengthOfRange(visibleRange.stop, feature.mapStop);
featureLengthUpStream = sequence.lengthOfRange(feature.mapStart, visibleRange.start);
const halfVisibleRangeLength = visibleRange.length / 2;
const center = visibleRange.start + halfVisibleRangeLength;
if (featureLengthUpStream > featureLengthDownStream) {
label.bp = center + (halfVisibleRangeLength * featureLengthDownStream / (featureLengthDownStream + featureLengthUpStream));
} else {
label.bp = center + (halfVisibleRangeLength * featureLengthUpStream / (featureLengthDownStream + featureLengthUpStream));
}
}
// Calculate where the label line should attach to Label.
// The attachemnt point should be the opposite clock position of the feature.
// This might need to be recalculated of the label has moved alot
label.lineAttachment = this.viewer.layout.clockPositionForBp(label.bp, true);
}
}
}
// Calculates non overlapping rects for priority labels
// ORIGINAL (Fast)
_calculatePriorityLabelRectsFast(labels) {
labels = labels || this._labels;
const canvas = this.canvas;
let label, bp, lineLength, overlappingRect;
const centerOffset = this._outerCenterOffset + this._labelLineMarginInner;
const placedRects = new CGArray();
for (let i = 0, len = labels.length; i < len; i++) {
label = labels[i];
bp = label.bp;
lineLength = this.labelLineLength;
do {
const outerPt = canvas.pointForBp(bp, centerOffset + lineLength + this._labelLineMarginOuter);
const rectOrigin = utils.rectOriginForAttachementPoint(outerPt, label.lineAttachment, label.width, label.height);
label.rect = new Rect(rectOrigin.x, rectOrigin.y, label.width, label.height);
overlappingRect = label.rect.overlap(placedRects);
lineLength += label.height;
} while (overlappingRect);
placedRects.push(label.rect);
label.attachementPt = label.rect.ptForClockPosition(label.lineAttachment);
}
}
_calculatePriorityLabelRects(labels) {
const labelLimit = 20;
if (!this._fastDraw || labels.length < labelLimit) {
this.labelPlacementFull.placeLabels(labels, this._outerCenterOffset);
} else {
this.labelPlacementFast.placeLabels(labels, this._outerCenterOffset);
}
}
// Should be called when
// - Labels are added or removed
// - Font changes (Annotation or individual label)
// - Label name changes
// - Zoom level changes
_calculateLabelRects(labels) {
labels = labels || this._labels;
const canvas = this.canvas;
let label, bp;
const centerOffset = this._outerCenterOffset + this._labelLineMarginInner;
for (let i = 0, len = labels.length; i < len; i++) {
label = labels[i];
bp = label.bp;
// let innerPt = canvas.pointForBp(bp, centerOffset);
const outerPt = canvas.pointForBp(bp, centerOffset + this.labelLineLength + this._labelLineMarginOuter);
const rectOrigin = utils.rectOriginForAttachementPoint(outerPt, label.lineAttachment, label.width, label.height);
label.rect = new Rect(rectOrigin.x, rectOrigin.y, label.width, label.height);
label.attachementPt = label.rect.ptForClockPosition(label.lineAttachment);
}
}
visibleLabels() {
let labelArray = new CGArray();
const visibleRange = this._visibleRange;
if (visibleRange) {
if (visibleRange.start === 1 && visibleRange.stop === this.sequence.length) {
// labelArray = this._labels;
labelArray = this._availableLabels; // Only labels that are on visible contigs;
} else {
labelArray = this._labelsNCList.find(visibleRange.start, visibleRange.stop);
}
}
return labelArray;
}
// Labels must already be sorted so favorite are first
_onlyFavoriteLabels(labels) {
labels = labels || this._labels;
const nonFavoriteIndex = labels.findIndex( (label) => !label.feature.favorite );
if (nonFavoriteIndex !== -1) {
return labels.slice(0, nonFavoriteIndex);
} else {
return labels;
}
}
_sortByPriority(labels) {
labels = labels || this._labels;
labels.sort( (a, b) => {
if (b.feature.favorite === a.feature.favorite) {
return b.feature.length - a.feature.length;
} else {
return a.feature.favorite ? -1 : 1;
}
});
return labels;
}
/**
* Invert color
*/
invertColors() {
if (this.color) {
this.update({ color: this.color.invert().rgbaString });
}
}
drawLabelLine(label, ctx, lineWidth) {
const innerPt = this.canvas.pointForBp(label.bp, this._outerCenterOffset + this._labelLineMarginInner);
const outerPt = label.attachementPt;
const color = this.color || label.feature.color;
ctx.beginPath();
ctx.moveTo(innerPt.x, innerPt.y);
ctx.lineTo(outerPt.x, outerPt.y);
ctx.strokeStyle = color.rgbaString;
ctx.lineCap = this.lineCap;
ctx.lineWidth = lineWidth || this._labelLineWidth;
ctx.stroke();
// TESTING adding extra radiant line to label line
// NOTE: this would be added to previous stroke
// - Also would need to add this to label line highlighting
// const innerExtraPt = this.canvas.pointForBp(label.bp, this._outerCenterOffset);
// ctx.beginPath();
// ctx.moveTo(innerExtraPt.x, innerExtraPt.y);
// ctx.lineTo(innerPt.x, innerPt.y);
// ctx.stroke();
//
// ctx.arc(outerPt.x, outerPt.y, 1.5, 0, 2*Math.PI, false);
// ctx.fillStyle = color.rgbaString;
// ctx.fill();
}
draw(innerCenterOffset, outerCenterOffset, fast) {
this._fastDraw = fast;
// TRY refreshing through addFeatures/remove
// if (this._labels.length !== this._labelsNCList.length) {
// this.refresh();
// }
this._visibleRange = this.canvas.visibleRangeForCenterOffset(outerCenterOffset);
this._innerCenterOffset = innerCenterOffset;
this._outerCenterOffset = outerCenterOffset;
// Find Labels that are within the visible range and calculate bounds
let possibleLabels = this.visibleLabels(outerCenterOffset);
possibleLabels = this._sortByPriority(possibleLabels);
if (this.onlyDrawFavorites) {
possibleLabels = this._onlyFavoriteLabels(possibleLabels);
}
this._calculatePositions(possibleLabels);
const priorityLabels = possibleLabels.slice(0, this.priorityMax);
const remainingLabels = possibleLabels.slice(this.priorityMax);
this._calculatePriorityLabelRects(priorityLabels);
this._calculateLabelRects(remainingLabels);
// console.log(priorityLabels[0] && priorityLabels[0].rect)
// Remove overlapping labels
const labelRects = priorityLabels.map( p => p.rect);
this._visibleLabels = priorityLabels;
for (let i = 0, len = remainingLabels.length; i < len; i++) {
const label = remainingLabels[i];
if (!label.rect.overlap(labelRects)) {
this._visibleLabels.push(label);
labelRects.push(label.rect);
}
}
// Draw nonoverlapping labels
const canvas = this.canvas;
const ctx = canvas.context('map');
let label, rect;
ctx.font = this.font.css; // TODO: move to loop, but only set if it changes
ctx.textAlign = 'left';
// ctx.textBaseline = 'top';
ctx.textBaseline = 'alphabetic'; // The default baseline works best across canvas and svg
// Draw label lines first so that label text will draw over them
for (let i = 0, len = this._visibleLabels.length; i < len; i++) {
label = this._visibleLabels[i];
// FIXME: it would be better to remove invisible labels before calculating position
// - this works to remove label, but the space is not available for another label
if (!label.feature.visible) { continue; }
const color = this.color || label.feature.color;
this.drawLabelLine(label, ctx);
}
// Draw label text
const backgroundColor = this.viewer.settings.backgroundColor.copy();
backgroundColor.opacity = 0.75;
for (let i = 0, len = this._visibleLabels.length; i < len; i++) {
label = this._visibleLabels[i];
// FIXME: it would be better to remove invisible labels before calculating position
// - this works to remove label, but the space is not available for another label
// NOTE: Has this been fixed????????
if (!label.feature.visible) { continue; }
const color = this.color || label.feature.color;
ctx.fillStyle = backgroundColor.rgbaString;
rect = label.rect;
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
ctx.fillStyle = color.rgbaString;
// ctx.fillText(label.name, label.rect.x, label.rect.y);
ctx.fillText(label.name, label.rect.x, label.rect.bottom - 1);
}
if (this.viewer.debug && this.viewer.debug.data.n) {
this.viewer.debug.data.n.labels = this._visibleLabels.length;
}
}
/**
* Update annotation [attributes](#attributes).
* See [updating records](../docs.html#s.updating-records) for details.
* @param {Object} attributes - Object describing the properties to change
*/
update(attributes) {
this.viewer.updateRecords(this, attributes, {
recordClass: 'Annotation',
validKeys: ['color', 'font', 'onlyDrawFavorites', 'visible', 'labelPlacement']
});
this.viewer.trigger('annotation-update', { attributes });
}
/**
* Returns JSON representing the object
*/
toJSON(options = {}) {
const json = {
font: this.font.string,
color: this.color && this.color.rgbaString,
onlyDrawFavorites: this.onlyDrawFavorites,
// In most cases the full and fast method will be the same.
// We could export both but for now we will only use the 'full' and it will be for both fast and full.
labelPlacement: this.labelPlacementFull.name,
visible: this.visible
};
// Optionally add default values
// if (!this.visible || options.includeDefaults) {
// json.visible = this.visible;
// }
return json;
}
}
export default Annotation;