//////////////////////////////////////////////////////////////////////////////
// Feature
//////////////////////////////////////////////////////////////////////////////
import CGObject from './CGObject';
import CGArray from './CGArray';
import CGRange from './CGRange';
import Label from './Label';
import Contig from './Contig';
import utils from './Utils';
/**
* A Feature is a region on the map with a start and stop position.
*
* ### Action and Events
*
* Action | Viewer Method | Feature Method | Event
* ----------------------------------------|------------------------------------------------|---------------------|-----
* [Add](../docs.html#adding-records) | [addFeatures()](Viewer.html#addFeatures) | - | features-add
* [Update](../docs.html#updating-records) | [updateFeatures()](Viewer.html#updateFeatures) | [update()](#update) | features-update
* [Remove](../docs.html#removing-records) | [removeFeatures()](Viewer.html#removeFeatures) | [remove()](#remove) | features-remove
* [Read](../docs.html#reading-records) | [features()](Viewer.html#features) | - | -
*
* <a name="attributes"></a>
* ### Attributes
*
* Attribute | Type | Description
* ---------------------------------|----------|------------
* [name](#name) | String | Name of feature
* [type](#type) | String | Feature type (e.g. CDS, rRNA, etc)
* [legend](#legend) | String\|LegendItem | Name of legendItem or the legendItem itself
* [source](#source) | String | Source of the feature
* [tags](#tags) | String\|Array | A single string or an array of strings associated with the feature as tags
* [contig](#contig) | String\|Contig | Name of contig or the contig itself
* [start](#start)<sup>rc</sup> | Number | Start base pair on the contig
* [stop](#stop)<sup>rc</sup> | Number | Stop base pair on the contig
* [mapStart](#mapStart)<sup>ic</sup> | Number | Start base pair on the map (converted to contig position)
* [mapStop](#mapStop)<sup>ic</sup> | Number | Stop base pair on the map (converted to contig position)
* [strand](#strand) | String | Strand the features is on [Default: 1]
* [score](#score) | Number | Score associated with the feature
* [favorite](#favorite) | Boolean | Feature is a favorite [Default: false]
* [visible](CGObject.html#visible) | Boolean | Feature is visible [Default: true]
* [meta](CGObject.html#meta) | Object | [Meta data](../tutorials/details-meta-data.html) for Feature
*
* <sup>rc</sup> Required on Feature creation
* <sup>ic</sup> Ignored on Record creation
*
* Implementation notes:
* - The feature range is the range on the contig
* - Feature.mapRange is the range on the Sequence.mapContig
* - If there is only one contig in the map, then Feature.mapRange === Feature.range
* - Feature.start/stop are positions on the contig
* - Feature mapStart/mapStop are position on Sequence.mapContig
* - If no contig is provided, the default contig will be Sequence.mapContig
* - Whenever mapContig is updated/regenerated the feature will be moved to the new mapContig
* - Features on the mapContig are able to span contigs
* - If contigs are rearranged, a mapContig feature will stay at the same position (start/stop)
*
* @extends CGObject
*/
class Feature extends CGObject {
/**
* Create a new feature.
* @param {Viewer} viewer - The viewer
* @param {Object} options - [Attributes](#attributes) used to create the feature
* @param {Object} [meta] - User-defined [Meta data](../tutorials/details-meta-data.html) to add to the feature.
*/
constructor(viewer, data = {}, meta = {}) {
super(viewer, data, meta);
this.viewer = viewer;
this.type = utils.defaultFor(data.type, '');
this.source = utils.defaultFor(data.source, '');
this.tags = data.tags;
this.favorite = utils.defaultFor(data.favorite, false);
// this.contig = data.contig || viewer.sequence.mapContig;
this.contig = data.contig;
// this.range = new CGV.CGRange(this.viewer.sequence, Number(data.start), Number(data.stop));
this.updateRanges(data.start, data.stop);
this.strand = utils.defaultFor(data.strand, 1);
this.score = utils.defaultFor(data.score, 1);
this.codonStart = data.codonStart;
this.geneticCode = data.geneticCode;
this.label = new Label(this, {name: data.name} );
this._centerOffsetAdjustment = Number(data.centerOffsetAdjustment) || 0;
this._proportionOfThickness = Number(data.proportionOfThickness) || 1;
this.extractedFromSequence = utils.defaultFor(data.extractedFromSequence, false);
this.legendItem = data.legend;
}
/**
* Return the class name as a string.
* @return {String} - 'Feature'
*/
toString() {
return 'Feature';
}
/**
* @member {type} - Get or set the *type*
*/
get type() {
return this._type;
}
set type(value) {
this._type = value;
}
/**
* @member {tag} - Get or set the *tags*
*/
get tags() {
return this._tags;
}
set tags(value) {
this._tags = (value == undefined || value === '') ? new CGArray() : new CGArray(value);
}
/**
* @member {String} - Get or set the name via the [Label](Label.html).
*/
get name() {
return this.label && this.label.name;
}
set name(value) {
if (this.label) {
this.label.name = value;
} else {
this.label = new Label(this, {name: value} );
}
}
/**
* @member {String} - Get or set the Codon start (Default: 1)
*/
get codonStart() {
return this._codonStart || 1;
}
set codonStart(value) {
this._codonStart = value;
}
/**
* @member {String} - Get or set the Genetic code used for translation. If no genetic code is set, the default for the map will be used.
*/
get geneticCode() {
return this._geneticCode;
}
set geneticCode(value) {
this._geneticCode = value;
}
/**
* @member {Boolean} - Get or set the *extractedFromSequence*. If true, this feature was
* generated directly from the sequence and will not be saved when exporting to JSON.
*/
get extractedFromSequence() {
return this._extractedFromSequence;
}
set extractedFromSequence(value) {
this._extractedFromSequence = value;
}
/**
* @member {Viewer} - Get the *Viewer*
*/
get viewer() {
return this._viewer;
}
set viewer(viewer) {
if (this.viewer) {
// TODO: Remove if already attached to Viewer
}
this._viewer = viewer;
viewer._features.push(this);
}
get strand() {
return this._strand;
}
set strand(value) {
if (value === '-' || Number(value) === -1) {
this._strand = -1;
} else {
this._strand = 1;
}
}
/**
* @member {Number} - Get the *Score*
*/
get score() {
return this._score;
}
set score(value) {
if (Number.isNaN(Number(value))) { return; }
this._score = utils.constrain(Number(value), 0, 1);
}
isDirect() {
return this.strand === 1;
}
isReverse() {
return this.strand === -1;
}
/**
* @member {Range} - Get or set the range of the feature. All ranges
* are assumed to be going in a clockwise direction.
*/
get range() {
return this._range;
}
set range(value) {
this._range = value;
}
/**
* @member {Range} - Get or set the range of the feature with respect to its contig.
* All ranges are assumed to be going in a clockwise direction.
*/
get mapRange() {
return this.range.onMap;
}
/**
* @member {Number} - Get or set the start position of the feature in basepair (bp).
* All start and stop positions are assumed to be going in a clockwise direction.
* This position is relative to the contig the feature is on. If there is only one
* contig, this value will be the same as mapStart.
*/
get start() {
return this.range.start;
}
set start(value) {
this.range.start = value;
}
/**
* @member {Number} - Get or set the stop position of the feature in basepair (bp).
* All start and stop positions are assumed to be going in a clockwise direction.
* This position is relative to the contig the feature is on. If there is only one
* contig, this value will be the same as mapStop.
*/
get stop() {
return this.range.stop;
}
set stop(value) {
this.range.stop = value;
}
/**
* @member {Number} - Get or set the start position of the feature in basepair (bp).
* All start and stop positions are assumed to be going in a clockwise direction.
*/
get mapStart() {
return this.range.mapStart;
}
set mapStart(value) {
this.range.mapStart = value;
}
/**
* @member {Number} - Get or set the stop position of the feature in basepair (bp).
* All start and stop positions are assumed to be going in a clockwise direction.
*/
get mapStop() {
return this.range.mapStop;
}
set mapStop(value) {
this.range.mapStop = value;
}
get length() {
return this.range.length;
}
/**
* @member {String} - Get or set the feature label.
*/
get label() {
return this._label;
}
set label(value) {
this._label = value;
}
/**
* @member {String} - Get or set the feature as a favorite.
*/
get favorite() {
return Boolean(this._favorite);
}
set favorite(value) {
this._favorite = value;
}
/**
* @member {String} - Get or set the color. TODO: reference COLOR class
*/
get color() {
// return (this.legendItem) ? this.legendItem.swatchColor : this._color;
return this.legendItem.swatchColor;
}
/**
* @member {String} - Get the decoration.
*/
get decoration() {
// return (this.legendItem && this.legendItem.decoration || 'arc')
return (this.legendItem.decoration || 'arc');
}
get directionalDecoration() {
if (this.decoration === 'arrow') {
return this.strand === 1 ? 'clockwise-arrow' : 'counterclockwise-arrow';
} else if (this.decoration === 'score') {
return 'arc';
} else {
return this.decoration;
}
}
/**
* @member {LegendItem} - Get or set the LegendItem. The LegendItem can be set with a LegendItem object
* or with the name of a legenedItem.
*/
get legendItem() {
return this._legendItem;
}
set legendItem(value) {
if (this.legendItem && value === undefined) { return; }
if (value && value.toString() === 'LegendItem') {
this._legendItem = value;
} else {
this._legendItem = this.viewer.legend.findLegendItemOrCreate(value);
}
}
/**
* @member {LegendItem} - Alias for [legendItem](Feature.html#legendItem).
*/
get legend() {
return this.legendItem;
}
set legend(value) {
this.legendItem = value;
}
/**
* @member {Contig} - Get or set the Contig. The Contig can be set with a Contig object
* or with the name of a Contig.
*/
get contig() {
return this._contig;
}
set contig(value) {
const oldContig = this._contig;
let newContig;
if (value === undefined || value === this.sequence.mapContig) {
// this._contig = undefined;
newContig = this.sequence.mapContig;
// If feature was on a contig update the positions
if (oldContig) {
}
} else if (value && value.toString() === 'Contig') {
// this._contig = value;
newContig = value;
} else {
const contig = this.viewer.sequence.contigs(value);
// const contig = this.viewer.sequence.contigs().filter( c => c.id && c.id.toLowerCase() === value.toLowerCase() )[0];
if (contig) {
// this._contig = contig;
newContig = contig;
} else {
console.error(`Feature '${this.name}' could not find contig '${value}'`)
}
}
if (oldContig !== newContig) {
// Add feature to new Contig
if (newContig) {
newContig._features.push(this);
}
// Remove feature from old Contig
if (oldContig) {
Contig.removeFeatures(this);
}
}
// Must be done after calling Contig.removeFeatures()
this._contig = newContig;
if (oldContig) {
// FIXME: adjust start/stop if the new contig is shorter than old contig
// and the position needs to be constrained. Try to keep the same length.
if (newContig.isMapContig) {
this.updateRanges(this.mapStart, this.mapStop);
} else {
this.updateRanges(this.start, this.stop);
}
}
}
/**
* Moves the feature, if it's on the mapContig, to the appropriate contig
* based on the start position. This may truncate the feature if it does not
* fit completely
* @private
*/
moveToContig() {
if (this.contig.isMapContig) {
const contig = this.sequence.contigForBp(this.start);
const start = this.start - contig.lengthOffset;
const stop = this.stop - contig.lengthOffset;
this.update({contig, start, stop});
}
}
/**
* Moves the feature, if it's on the mapContig, to the appropriate contig
* based on the start position. This may truncate the feature if it does not
* fit completely
* @private
*/
moveToMapContig() {
if (!this.contig.isMapContig) {
this.contig = undefined;
}
}
/**
* Update feature [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.updateFeatures(this, attributes);
}
/**
* Updates the feature range using the given *start* and *stop* positions.
* If the feature is on a contig, the positions should be in relation to the contig.
* @param {Number} start - Start position (bp).
* @param {Number} stop - Stop position (bp).
* @private
*/
// updateRanges(start, stop) {
// start = Number(start);
// stop = Number(stop);
// const sequence = this.sequence;
// let globalStart = start;
// let globalStop = stop;
// if (this.contig) {
// // Create range as global bp position and
// // contigRange as given start/stop positions
// globalStart = sequence.bpForContig(this.contig, start);
// globalStop = sequence.bpForContig(this.contig, stop);
// this.contigRange = new CGV.CGRange(sequence, start, stop);
// }
// this.range = new CGV.CGRange(sequence, globalStart, globalStop);
// }
updateRanges(start, stop) {
start = Number(start);
stop = Number(stop);
const contig = this.contig || this.sequence.mapContig;
this.range = new CGRange(contig, start, stop);
}
draw(layer, slotCenterOffset, slotThickness, visibleRange, options = {}) {
if (!this.visible) { return; }
if (this.mapRange.overlapsMapRange(visibleRange)) {
const canvas = this.canvas;
let start = this.mapStart;
let stop = this.mapStop;
const containsStart = visibleRange.containsMapBp(start);
const containsStop = visibleRange.containsMapBp(stop);
const color = options.color || this.color;
const showShading = options.showShading;
const minArcLength = this.legendItem.minArcLength;
if (!containsStart) {
// start = visibleRange.start - 100;
start = Math.max(1, visibleRange.start - 100);
}
if (!containsStop) {
// stop = visibleRange.stop + 100;
stop = Math.min(this.sequence.length, visibleRange.stop + 100);
}
// When zoomed in, if the feature starts in the visible range and wraps around to end
// in the visible range, the feature should be drawn as 2 arcs.
// const zoomedSplitFeature = containsStart && containsStop && (this.viewer.zoomFactor > 1000) && this.range.overlapsMapRange();
const zoomedSplitFeature = containsStart && containsStop && (this.viewer.zoomFactor > 1000) && this.range.isWrapped();
// When the feature wraps the origin on a linear map and both the start and stop
// can be seen, draw as 2 elements.
const unzoomedSplitLinearFeature = containsStart && containsStop && this.range.isWrapped() && (this.viewer.format === 'linear');
if (zoomedSplitFeature || unzoomedSplitLinearFeature) {
const visibleStart = Math.max((visibleRange.start - 100), 1); // Do not draw off the edge of linear maps
const visibleStop = Math.min((visibleRange.stop + 100), this.sequence.length); // Do not draw off the edge of linear maps
canvas.drawElement(layer, visibleStart, stop,
this.adjustedCenterOffset(slotCenterOffset, slotThickness),
color.rgbaString, this.adjustedWidth(slotThickness), this.directionalDecoration, showShading, minArcLength);
canvas.drawElement(layer, start, visibleStop,
this.adjustedCenterOffset(slotCenterOffset, slotThickness),
color.rgbaString, this.adjustedWidth(slotThickness), this.directionalDecoration, showShading, minArcLength);
} else {
canvas.drawElement(layer, start, stop,
this.adjustedCenterOffset(slotCenterOffset, slotThickness),
color.rgbaString, this.adjustedWidth(slotThickness), this.directionalDecoration, showShading, minArcLength);
}
}
}
/**
* Highlights the feature on every slot it is visible. An optional slot can be provided,
* in which case the feature will only be highlighted on the slot.
* @param {Slot} slot - Only highlight the feature on this slot.
*/
highlight(slot) {
if (!this.visible) { return; }
this.canvas.clear('ui');
if (this.viewer.annotation.visible) {
this.label._highlight();
}
const color = this.color.copy();
color.highlight();
if (slot && slot.features().includes(this)) {
this.draw('ui', slot.centerOffset, slot.thickness, slot.visibleRange, {color: color});
} else {
this.viewer.slots().each( (i, slot) => {
if (slot.features().includes(this)) {
this.draw('ui', slot.centerOffset, slot.thickness, slot.visibleRange, {color: color});
}
});
}
}
// TODO: Not using _centerOffsetAdjustment yet
// centerOffset by default would be the center of the slot as provided unless:
// - _centerOffsetAdjustment is not 0
// - _proportionOfThickness is not 1
// - legend decoration is score
adjustedCenterOffset(centerOffset, slotThickness) {
if (this.legendItem.decoration === 'score') {
// FIXME: does not take into account proportionOfThickness and centerOffsetAdjustment for now
return centerOffset - (slotThickness / 2) + (this.score * slotThickness / 2);
} else {
if (this._centerOffsetAdjustment === 0 && this._proportionOfThickness === 1) {
return centerOffset;
} else if (this._centerOffsetAdjustment === 0) {
return centerOffset - (slotThickness / 2) + (this._proportionOfThickness * slotThickness / 2);
} else {
return centerOffset;
}
}
}
adjustedWidth(width) {
if (this.legendItem.decoration === 'score') {
return this.score * width;
} else {
return this._proportionOfThickness * width;
}
}
/**
* Return an array of the tracks that contain this feature
* FIXME: this will not return the tracks for features on tracks with 'from' = 'sequence'
* - is this a problem??
*/
tracks(term) {
const tracks = new CGArray();
this.viewer.tracks().each( (i, track) => {
if (track.type === 'feature') {
if ( (track.dataMethod === 'source' && track.dataKeys.includes(this.source)) ||
(track.dataMethod === 'type' && track.dataKeys.includes(this.type)) ||
(track.dataMethod === 'tag' && track.dataKeys.some( k => this.tags.includes(k))) ||
(track.dataMethod === 'sequence' && this.extractedFromSequence && track.features().includes(this)) ) {
tracks.push(track);
}
}
});
return tracks.get(term);
}
/**
* Return an array of the slots that contain this feature
*/
slots(term) {
const slots = new CGArray();
this.tracks().each( (i, track) => {
track.slots().each( (j, slot) => {
if (slot.features().includes(this)) {
slots.push(slot);
}
});
});
return slots.get(term);
}
/**
* Remove the feature from the viewer, tracks and slots
*/
remove() {
this.viewer.removeFeatures(this);
}
/**
* Zoom and pan map to show the feature
*
* @param {Number} duration - Length of animation
* @param {Object} ease - The d3 animation ease [Default: d3.easeCubic]
*/
moveTo(duration, ease) {
const buffer = Math.ceil(this.length * 0.05);
const start = this.sequence.subtractBp(this.mapStart, buffer);
const stop = this.sequence.addBp(this.mapStop, buffer);
this.viewer.moveTo(start, stop, {duration, ease});
}
// Update tracks, slots, etc associated with feature.
// Or add feature to tracks and refresh them, if this is a new feature.
// Don't refresh if bulkImport is true
//
refresh() {
// this.bulkImport = false;
// Get tracks currently associated with this feature.
// And find any new tracks that may now need to be associated with this feature
// (e.g. if the feature source changed, it may now belong to a different track)
this.viewer.tracks().each( (i, track) => {
if ( track.features().includes(this) ||
(track.dataMethod === 'source' && track.dataKeys.includes(this.source) ) ) {
track.refresh();
}
});
}
/**
* Translate the sequence of this feature.
*
* The source of the genetic code used for translation uses the following precedence:
* geneticCode (provided to translate method) > geneticCode (of Feature) > geneticCode (of Viewer)
*
* @param {Number} geneticCode - Number indicating the genetic code to use for the translation. This will override the any genetic code set for the feature or Viewer.
* @return {String} - Amino acid sequence
*/
translate(geneticCode) {
const code = geneticCode || this.geneticCode || this.viewer.geneticCode;
const table = this.viewer.codonTables.byID(code);
return table && table.translate(this.seq, this.start_codon);
}
/**
* Returns the DNA sequence for the feature.
*
* @return {String} - DNA sequence of feature.
*/
get seq() {
return this.contig.forRange(this.range, this.isReverse());
}
toJSON(options = {}) {
const json = {
name: this.name,
type: this.type,
start: this.start,
stop: this.stop,
strand: this.strand,
source: this.source,
legend: this.legend.name
// score: this.score,
// visible: this.visible,
// favorite: this.favorite
};
if (this.codonStart && this.codonStart != 1) {
json.codonStart = this.codonStart;
}
if (this.geneticCode && this.geneticCode != this.viewer.geneticCode) {
json.geneticCode = this.geneticCode;
}
if (this.sequence.hasMultipleContigs && !this.contig.isMapContig) {
// json.contig = this.contig.id;
json.contig = this.contig.name;
}
// Tags
if (this.tags !== undefined) {
json.tags = (this.tags.length === 1) ? this.tags[0] : [...this.tags];
}
// Optionally add default values
// Visible is normally true
if (!this.visible || options.includeDefaults) {
json.visible = this.visible;
}
// Score is normally undefined (which defaults to 1)
if ((this.score !== undefined && this.score !== 1) || options.includeDefaults) {
json.score = this.score;
}
// Favorite is normally false
if (this.favorite || options.includeDefaults) {
json.favorite = this.favorite;
}
// Meta Data (TODO: add an option to exclude this)
if (Object.keys(this.meta).length > 0) {
json.meta = this.meta;
}
return json;
}
}
export default Feature;