//////////////////////////////////////////////////////////////////////////////
// Plot
//////////////////////////////////////////////////////////////////////////////
import CGObject from './CGObject';
import CGArray from './CGArray';
import utils from './Utils';
import * as d3 from 'd3';
/**
* Plots are drawn as a series of arcs.
*
* ### Action and Events
*
* Action | Viewer Method | Plot Method | Event
* ----------------------------------------|------------------------------------------|---------------------|-----
* [Add](../docs.html#adding-records) | [addPlots()](Viewer.html#addPlots) | - | plots-add
* [Update](../docs.html#updating-records) | [updatePlots()](Viewer.html#updatePlots) | [update()](#update) | plots-update
* [Remove](../docs.html#removing-records) | [removePlots()](Viewer.html#removePlots) | [remove()](#remove) | plots-remove
* [Read](../docs.html#reading-records) | [plots()](Viewer.html#plots) | - | -
*
* <a name="attributes"></a>
* ### Attributes
*
* Attribute | Type | Description
* ----------------------------------|----------|------------
* [name](#name) | String | Name of plot
* [legend](#legend) | String\|LegendItem | Name of legendItem or the legendItem itself (sets positive and negative legend)
* [legendNegative](#legendNegative) | String\|LegendItem | Name of legendItem or the legendItem itself for the plot above the baseline
* [legendPositive](#legendPositive) | String\|LegendItem | Name of legendItem or the legendItem itself for the plot below the baseline
* [source](#source) | String | Source of the plot
* [positions](#positions)<sup>rc,iu</sup> | Array | Array of base pair position on contig
* [scores](#scores)<sup>rc,iu</sup> | Array | Array of scores
* [baseline](#baseline) | Number | Score where the plot goes from negative to positive (in terms of legend)
* [axisMax](#axisMax) | Number | Maximum value for the plot axis
* [axisMin](#axisMin) | Number | Minimum value for the plot axis
* [favorite](#favorite) | Boolean | Plot is a favorite [Default: false]
* [visible](CGObject.html#visible) | Boolean | Plot is visible [Default: true]
* [meta](CGObject.html#meta) | Object | [Meta data](../tutorials/details-meta-data.html) for Plot
*
* <sup>rc</sup> Required on Plot creation
* <sup>iu</sup> Ignored on Plot update
*
* ### Examples
*
* @extends CGObject
*/
class Plot extends CGObject {
/**
* Create a new Plot.
* @param {Viewer} viewer - The viewer
* @param {Object} options - [Attributes](#attributes) used to create the plot
* @param {Object} [meta] - User-defined [Meta data](../tutorials/details-meta-data.html) to add to the plot.
*/
constructor(viewer, data = {}, meta = {}) {
super(viewer, data, meta);
this.viewer = viewer;
this.name = data.name;
this.extractedFromSequence = utils.defaultFor(data.extractedFromSequence, false);
this.positions = utils.defaultFor(data.positions, []);
this.scores = utils.defaultFor(data.scores, []);
this.type = utils.defaultFor(data.type, 'line');
this.source = utils.defaultFor(data.source, '');
this.axisMin = utils.defaultFor(data.axisMin, d3.min([0, this.scoreMin]));
this.axisMax = utils.defaultFor(data.axisMax, d3.max([0, this.scoreMax]));
this.baseline = utils.defaultFor(data.baseline, 0);
if (data.legend) {
this.legendItem = data.legend;
}
if (data.legendPositive) {
this.legendItemPositive = data.legendPositive;
}
if (data.legendNegative) {
this.legendItemNegative = data.legendNegative;
}
const plotID = viewer.plots().indexOf(this) + 1;
if (!this.legendItemPositive && !this.legendItemNegative) {
this.legendItem = `Plot-${plotID}`;
} else if (!this.legendItemPositive) {
this.legendItemPositive = this.legendItemNegative;
} else if (!this.legendItemNegative) {
this.legendItemNegative = this.legendItemPositive;
}
}
/**
* Return the class name as a string.
* @return {String} - 'Plot'
*/
toString() {
return 'Plot';
}
/**
* @member {String} - Get or set the name.
*/
get name() {
return this._name;
}
set name(value) {
this._name = value;
}
/**
* @member {type} - Get or set the *type*
*/
get type() {
return this._type;
}
set type(value) {
if (!utils.validate(value, ['line', 'bar'])) { return }
this._type = 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._plots.push(this);
}
/**
* @member {CGArray} - Get or set the positions (bp) of the plot.
*/
get positions() {
return this._positions || new CGArray();
}
set positions(value) {
if (value) {
this._positions = new CGArray(value);
}
}
/**
* @member {CGArray} - Get or set the scores of the plot. Value should be between 0 and 1.
*/
get score() {
return this._score || new CGArray();
}
set score(value) {
if (value) {
this._score = new CGArray(value);
}
}
/**
* @member {Number} - Get the number of points in the plot
*/
get length() {
return this.positions.length;
}
/**
* @member {Array|Color} - Return an array of the positive and negativ colors [PositiveColor, NegativeColor].
*/
get color() {
return [this.colorPositive, this.colorNegative];
}
get colorPositive() {
return this.legendItemPositive.color;
}
get colorNegative() {
return this.legendItemNegative.color;
}
/**
* @member {LegendItem} - Set both the legendItemPositive and
* legendItemNegative to this legendItem. Get an CGArray of the legendItems: [legendItemPositive, legendItemNegative].
*/
get legendItem() {
return new CGArray([this.legendItemPositive, this.legendItemNegative]);
}
set legendItem(value) {
this.legendItemPositive = value;
this.legendItemNegative = value;
}
/**
* @member {LegendItem} - Alias for [legendItem](plot.html#legendItem)
*/
get legend() {
return this.legendItem;
}
set legend(value) {
this.legendItem = value;
}
/**
* @member {LegendItem} - Get or Set both the LegendItem for the positive portion of the plot (i.e. above
* [baseline](Plot.html#baseline).
*/
get legendItemPositive() {
return this._legendItemPositive;
}
set legendItemPositive(value) {
if (this.legendItemPositive && value === undefined) { return; }
if (value && value.toString() === 'LegendItem') {
this._legendItemPositive = value;
} else {
this._legendItemPositive = this.viewer.legend.findLegendItemOrCreate(value);
}
}
/**
* @member {LegendItem} - Get or Set both the LegendItem for the negative portion of the plot (i.e. below
* [baseline](Plot.html#baseline).
*/
get legendItemNegative() {
return this._legendItemNegative;
}
set legendItemNegative(value) {
if (this.legendItemNegative && value === undefined) { return; }
if (value && value.toString() === 'LegendItem') {
this._legendItemNegative = value;
} else {
this._legendItemNegative = this.viewer.legend.findLegendItemOrCreate(value);
}
}
/**
* @member {LegendItem} - Alias for [legendItemPositive](plot.html#legendItemPositive).
*/
get legendPositive() {
return this.legendItemPositive;
}
set legendPositive(value) {
this.legendItemPositive = value;
}
/**
* @member {LegendItem} - Alias for [legendItemNegative](plot.html#legendItemNegative).
*/
get legendNegative() {
return this.legendItemNegative;
}
set legendNegative(value) {
this.legendItemNegative = value;
}
/**
* @member {Number} - Get or set the plot baseline. This is a value between the axisMin and axisMax
* and indicates where where the baseline will be drawn. By default this is 0.
*/
get baseline() {
return this._baseline;
}
set baseline(value) {
value = Number(value);
const minAxis = this.axisMin;
const maxAxis = this.axisMax;
if (value > maxAxis) {
this._baseline = maxAxis;
} else if (value < minAxis) {
this._baseline = minAxis;
} else {
this._baseline = value;
}
}
/**
* @member {Number} - Get or set the plot minimum axis value. This is a value must be less than
* or equal to the minimum score.
*/
get axisMin() {
return this._axisMin;
}
set axisMin(value) {
value = Number(value);
const minValue = d3.min([this.scoreMin, this.baseline]);
this._axisMin = (value > minValue) ? minValue : value;
}
/**
* @member {Number} - Get or set the plot maximum axis value. This is a value must be greater than
* or equal to the maximum score.
*/
get axisMax() {
return this._axisMax;
}
set axisMax(value) {
value = Number(value);
const maxValue = d3.max([this.scoreMax, this.baseline]);
this._axisMax = (value < maxValue) ? maxValue : value;
}
get scoreMax() {
return d3.max(this.scores);
}
get scoreMin() {
return d3.min(this.scores);
}
get scoreMean() {
return d3.mean(this.scores);
}
get scoreMedian() {
return d3.median(this.scores);
}
/**
* @member {Boolean} - Get or set the *extractedFromSequence*. This plot is
* generated directly from the sequence and does not have to be saved when exported JSON.
*/
get extractedFromSequence() {
return this._extractedFromSequence;
}
set extractedFromSequence(value) {
this._extractedFromSequence = value;
}
/**
* Highlights the tracks the plot is on. An optional track can be provided,
* in which case the plot will only be highlighted on the track.
* @param {Track} track - Only highlight the feature on this track.
*/
highlight(track) {
if (!this.visible) { return; }
this.canvas.clear('ui');
if (track && track.plot === this) {
track.highlight();
} else {
this.tracks().each( (i, t) => t.highlight());
}
}
/**
* Update plot [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.updatePlots(this, attributes);
}
tracks(term) {
const tracks = new CGArray();
this.viewer.tracks().each( (i, track) => {
if (track.plot === this) {
tracks.push(track);
}
});
return tracks.get(term);
}
/**
* Remove the Plot from the viewer, tracks and slots
*/
remove() {
this.viewer.removePlots(this);
}
scoreForPosition(bp) {
const index = utils.indexOfValue(this.positions, bp);
if (index === 0 && bp < this.positions[index]) {
return undefined;
} else {
return this.scores[index];
}
}
draw(canvas, slotRadius, slotThickness, fast, range) {
// let startTime = new Date().getTime();
if (!this.visible) { return; }
if (this.colorNegative.rgbaString === this.colorPositive.rgbaString) {
this._drawPath(canvas, slotRadius, slotThickness, fast, range, this.colorPositive);
} else {
this._drawPath(canvas, slotRadius, slotThickness, fast, range, this.colorPositive, 'positive');
this._drawPath(canvas, slotRadius, slotThickness, fast, range, this.colorNegative, 'negative');
}
// console.log("Plot Time: '" + utils.elapsedTime(startTime) );
}
// To add a fast mode use a step when creating the indices
_drawPath(canvas, slotRadius, slotThickness, fast, range, color, orientation) {
const ctx = canvas.context('map');
const positions = this.positions;
const scores = this.scores;
// This is the difference in radial pixels required before a new arc is draw
// const radialDiff = fast ? 1 : 0.5;
// let radialDiff = 0.5;
const sequenceLength = this.viewer.sequence.length;
const startIndex = utils.indexOfValue(positions, range.start, false);
let stopIndex = utils.indexOfValue(positions, range.stop, false);
// Change stopIndex to last position if stop is between 1 and first position
if (stopIndex === 0 && range.stop < positions[stopIndex]) {
stopIndex = positions.length - 1;
}
const startPosition = startIndex === 0 ? positions[startIndex] : range.start;
let stopPosition = range.stop;
// console.log(startPosition + '..' + stopPosition)
// let startScore = startIndex === 0 ? this.baseline : scores[startIndex];
let startScore = scores[startIndex];
startScore = this._keepPoint(startScore, orientation) ? startScore : this.baseline;
ctx.beginPath();
// Calculate baseline Radius
// const baselineRadius = slotRadius - (slotThickness / 2) + (slotThickness * this.baseline);
const axisRange = this.axisMax - this.axisMin;
const baselineRadius = slotRadius - (slotThickness / 2) + (slotThickness * (this.baseline - this.axisMin)/axisRange);
// Move to the first point
const startPoint = canvas.pointForBp(startPosition, baselineRadius);
ctx.moveTo(startPoint.x, startPoint.y);
let savedR = baselineRadius + ((startScore - this.baseline) * slotThickness);
let savedPosition = startPosition;
let score, currentPosition;
// const crossingBaseline = false;
// const drawNow = false;
let step = 1;
if (fast) {
// When drawing fast, use a step value scaled to base-2
const positionsLength = this.countPositionsFromRange(startPosition, stopPosition);
const maxPositions = 4000;
const initialStep = positionsLength / maxPositions;
if (initialStep > 1) {
step = utils.base2(initialStep);
}
}
this.positionsFromRange(startPosition, stopPosition, step, (i) => {
// Handle Origin in middle of range
if (i === 0 && startIndex !== 0) {
canvas.path('map', savedR, savedPosition, sequenceLength, false, 'lineTo');
savedPosition = 1;
savedR = baselineRadius;
}
// NOTE: In the future the radialDiff code (see bottom) could be used to improve speed of NON-fast
// drawing. However, there are a few bugs that need to be worked out
score = scores[i];
currentPosition = positions[i];
canvas.path('map', savedR, savedPosition, currentPosition, false, 'lineTo');
if ( this._keepPoint(score, orientation) ) {
// savedR = baselineRadius + ((score - this.baseline) * slotThickness);
savedR = baselineRadius + ((score - this.baseline)/axisRange * slotThickness);
// savedR = baselineRadius + ((((score - axisMin)/axisRange) - this.baseline) * slotThickness);
// return ((to.max - to.min) * (value - from.min) / (from.max - from.min)) + to.min;
} else {
savedR = baselineRadius;
}
savedPosition = currentPosition;
});
// Change stopPosition if between 1 and first position
if (stopIndex === positions.length - 1 && stopPosition < positions[0]) {
stopPosition = sequenceLength;
}
// Finish drawing plot to stop position
canvas.path('map', savedR, savedPosition, stopPosition, false, 'lineTo');
const endPoint = canvas.pointForBp(stopPosition, baselineRadius);
ctx.lineTo(endPoint.x, endPoint.y);
// Draw plot anticlockwise back to start along baseline
canvas.path('map', baselineRadius, stopPosition, startPosition, true, 'noMoveTo');
ctx.fillStyle = color.rgbaString;
ctx.fill();
// ctx.strokeStyle = 'black';
// TODO: draw stroked line for sparse data
// ctx.lineWidth = 0.05;
// ctx.lineWidth = 1;
// ctx.strokeStyle = color.rgbaString;
// ctx.stroke();
}
// If the positive and negative legend are the same, the plot is drawn as a single path.
// If the positive and negative legend are different, two plots are drawn:
// - one above the baseline (positive)
// - one below the baseline (negative)
// This method checks if a point should be kept based on it's score and orientation.
// If no orientation is provided, a single path will be drawn and all the points are kept.
_keepPoint(score, orientation) {
if (orientation === undefined) {
return true;
} else if (orientation === 'positive' && score > this.baseline) {
return true;
} else if (orientation === 'negative' && score < this.baseline ) {
return true;
}
return false;
}
positionsFromRange(startValue, stopValue, step, callback) {
const positions = this.positions;
let startIndex = utils.indexOfValue(positions, startValue, true);
const stopIndex = utils.indexOfValue(positions, stopValue, false);
// This helps reduce the jumpiness of feature drawing with a step
// The idea is to alter the start index based on the step so the same
// indices should be returned. i.e. the indices should be divisible by the step.
if (startIndex > 0 && step > 1) {
startIndex += step - (startIndex % step);
}
if (stopValue >= startValue) {
// Return if both start and stop are between values in array
if (positions[startIndex] > stopValue || positions[stopIndex] < startValue) { return; }
for (let i = startIndex; i <= stopIndex; i += step) {
callback.call(positions[i], i, positions[i]);
}
} else {
// Skip cases where the the start value is greater than the last value in array
if (positions[startIndex] >= startValue) {
for (let i = startIndex, len = positions.length; i < len; i += step) {
callback.call(positions[i], i, positions[i]);
}
}
// Skip cases where the the stop value is less than the first value in array
if (positions[stopIndex] <= stopValue) {
for (let i = 0; i <= stopIndex; i += step) {
callback.call(positions[i], i, positions[i]);
}
}
}
return positions;
}
countPositionsFromRange(startValue, stopValue) {
const positions = this.positions;
let startIndex = utils.indexOfValue(positions, startValue, true);
let stopIndex = utils.indexOfValue(positions, stopValue, false);
if (startValue > positions[positions.length - 1]) {
startIndex++;
}
if (stopValue < positions[0]) {
stopIndex--;
}
if (stopValue >= startValue) {
return stopIndex - startIndex + 1;
} else {
return (positions.length - startIndex) + stopIndex + 1;
}
}
/**
* Returns JSON representing the object
*/
// Options:
// - excludeData: if true, the scores and positions are not included
toJSON(options = {}) {
const json = {
name: this.name,
type: this.type,
baseline: this.baseline,
source: this.source,
};
if (this.legendPositive === this.legendNegative) {
json.legend = this.legendPositive.name;
} else {
json.legendPositive = this.legendPositive.name;
json.legendNegative = this.legendNegative.name;
}
if ( (this.axisMin !== this.scoreMin) || options.includeDefaults) {
json.axisMin = this.axisMin;
}
if ( (this.axisMax !== this.scoreMax) || options.includeDefaults) {
json.axisMax = this.axisMax;
}
if (!options.excludeData) {
json.positions = this.positions;
json.scores = this.scores;
}
// Optionally add default values
// Visible is normally true
if (!this.visible || options.includeDefaults) {
json.visible = this.visible;
}
// Favorite is normally false
if (this.favorite || options.includeDefaults) {
json.favorite = this.favorite;
}
return json;
}
}
export default Plot;
// NOTE: radialDiff
// score = scores[i];
// currentPosition = positions[i];
// currentR = baselineRadius + (score - this.baseline) * slotThickness;
//
// if (drawNow || crossingBaseline) {
// canvas.arcPath('map', savedR, savedPosition, currentPosition, false, 'lineTo');
// savedPosition = currentPosition;
// drawNow = false;
// crossingBaseline = false;
// if ( this._keepPoint(score, orientation) ) {
// savedR = currentR;
// } else {
// savedR = baselineRadius;
// }
// if (orientation && ( (lastScore - this.baseline) * (score - this.baseline) < 0)) {
// crossingBaseline = true;
// }
//
// if ( Math.abs(currentR - savedR) >= radialDiff ){
// drawNow = true;
// }
// lastScore = score;
// END RadialDiff
// score = scores[i];
// currentPosition = positions[i];
// canvas.arcPath('map', savedR, savedPosition, currentPosition, false, 'lineTo');
// if ( this._keepPoint(score, orientation) ){
// savedR = baselineRadius + (score - this.baseline) * slotThickness;
// } else {
// savedR = baselineRadius;
// }
// savedPosition = currentPosition;
//
// score = scores[i];
// currentPosition = positions[i];
// canvas.arcPath('map', savedR, savedPosition, currentPosition, false, 'lineTo');
// currentR = baselineRadius + (score - this.baseline) * slotThickness;
// savedR = currentR;
// savedPosition = currentPosition;
//
//
// positions.eachFromRange(startPosition, stopPosition, step, (i) => {
// if (i === 0) {
// lastScore = this.baseline;
// savedPosition = 1;
// savedR = baselineRadius;
// }
// lastScore = score;
// score = scores[i];
// currentPosition = positions[i];
// currentR = baselineRadius + (score - this.baseline) * slotThickness;
// // If going from positive to negative need to save currentR as 0 (baselineRadius)
// // Easiest way is to check if the sign changes (i.e. multipling last and current score is negative)
// if (orientation && ( (lastScore - this.baseline) * (score - this.baseline) < 0)) {
// currentR = baselineRadius;
// canvas.arcPath('map', currentR, savedPosition, currentPosition, false, true);
// savedR = currentR;
// savedPosition = currentPosition;
// } else if ( this._keepPoint(score, orientation) ){
// if ( Math.abs(currentR - savedR) >= radialDiff ){
// canvas.arcPath('map', currentR, savedPosition, currentPosition, false, true);
// savedR = currentR;
// savedPosition = currentPosition
// }
// } else {
// savedR = baselineRadius;
// }
// });