//////////////////////////////////////////////////////////////////////////////
// Ruler
//////////////////////////////////////////////////////////////////////////////
import CGObject from './CGObject';
import Color from './Color';
import Font from './Font';
import utils from './Utils';
import * as d3 from 'd3';
/**
* The Ruler controls and draws the sequence ruler in bp.
*
* ### Action and Events
*
* Action | Viewer Method | Ruler Method | Event
* ----------------------------------------|----------------------------|---------------------|-----
* [Update](../docs.html#updating-records) | - | [update()](#update) | ruler-update
* [Read](../docs.html#reading-records) | [ruler](Viewer.html#ruler) | - | -
*
* <a name="attributes"></a>
* ### Attributes
*
* Attribute | Type | Description
* ---------------------------------|-----------|------------
* [font](#font) | String | A string describing the font [Default: 'sans-serif, plain, 10']. See {@link Font} for details.
* [color](#color) | String | A string describing the color [Default: 'black']. See {@link Color} for details.
* [visible](CGObject.html#visible) | Boolean | Rulers are visible [Default: true]
* [meta](CGObject.html#meta) | Object | [Meta data](../tutorials/details-meta-data.html) for ruler
*
* ### Examples
*
* @extends CGObject
*/
class Ruler extends CGObject {
/**
* Create a new ruler
* @param {Viewer} viewer - The viewer
* @param {Object} options - [Attributes](#attributes) used to create the ruler
* @param {Object} [meta] - User-defined [Meta data](../tutorials/details-meta-data.html) to add to the ruler.
*/
constructor(viewer, options = {}, meta = {}) {
super(viewer, options, meta);
this.tickCount = utils.defaultFor(options.tickCount, 10);
this.tickWidth = utils.defaultFor(options.tickWidth, 1);
this.tickLength = utils.defaultFor(options.tickLength, 4);
this.rulerPadding = utils.defaultFor(options.rulerPadding, 10);
this.spacing = utils.defaultFor(options.spacing, 2);
this.font = utils.defaultFor(options.font, 'sans-serif, plain, 10');
this.color = new Color( utils.defaultFor(options.color, 'black') );
this.lineCap = 'round';
this.viewer.trigger('ruler-update', { attributes: this.toJSON({includeDefaults: true}) });
}
/**
* Return the class name as a string.
* @return {String} - 'Ruler'
*/
toString() {
return 'Ruler';
}
/**
* @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);
}
}
/**
* @member {Color} - Get or set the 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(color) {
if (color.toString() === 'Color') {
this._color = color;
} else {
this._color.setColor(color);
}
}
get tickCount() {
return this._tickCount;
}
set tickCount(count) {
this._tickCount = count;
}
get tickWidth() {
return this._tickWidth;
}
set tickWidth(width) {
this._tickWidth = width;
}
get tickLength() {
return this._tickLength;
}
set tickLength(length) {
this._tickLength = length;
}
get rulerPadding() {
return this._rulerPadding;
}
set rulerPadding(padding) {
this._rulerPadding = padding;
}
// Distance between divider and tick marks
get spacing() {
return this._spacing;
}
set spacing(value) {
this._spacing = value;
}
/**
* @member {Array} - Get the array of Major Ticks.
*/
get majorTicks() {
return this._majorTicks;
}
/**
* @member {Number} - Get distance between major tick marks.
*/
get majorTickStep() {
return this._majorTickStep;
}
/**
* @member {Array} - Get the array of Minor Ticks.
*/
get minorTicks() {
return this._minorTicks;
}
/**
* @member {Number} - Get distance between minor tick marks.
*/
get minorTickStep() {
return this._minorTickStep;
}
/**
* @member {Object} - Get the d3 formatter for printing the tick labels
*/
get tickFormater() {
return this._tickFormater;
}
/**
* Create d3 tickFormat based on the distance between ticks
* @param {Number} tickStep - Distance between ticks
* @return {Object}
* @private
*/
_createTickFormatter(tickStep) {
let tickFormat, tickPrecision;
if (tickStep <= 50) {
tickFormat = d3.formatPrefix(',.0', 1);
} else if (tickStep <= 50e3) {
tickPrecision = d3.precisionPrefix(tickStep, 1e3);
tickFormat = d3.formatPrefix(`.${tickPrecision}`, 1e3);
} else if (tickStep <= 50e6) {
tickPrecision = d3.precisionPrefix(tickStep, 1e6);
tickFormat = d3.formatPrefix(`.${tickPrecision}`, 1e6);
}
return tickFormat;
}
// Below the zoomFactorCutoff, all ticks are calculated for the entire map
// Above the zoomFactorCutoff, ticks are created for the visible range
_updateTicks(innerCenterOffset, outerCenterOffset) {
const zoomFactorCutoff = 5;
const sequenceLength = this.sequence.length;
let start = 0;
let stop = 0;
let majorTicks = [];
let majorTickStep = 0;
let minorTicks = [];
let minorTickStep = 0;
let tickCount = this.tickCount;
// Find start and stop to create ticks
if (this.viewer.zoomFactor < zoomFactorCutoff) {
start = 1;
stop = sequenceLength;
} else {
tickCount = Math.ceil(tickCount / 2);
const innerRange = this.canvas.visibleRangeForCenterOffset(innerCenterOffset);
const outerRange = this.canvas.visibleRangeForCenterOffset(outerCenterOffset);
if (innerRange && outerRange) {
const mergedRange = innerRange.mergeWithRange(outerRange);
start = mergedRange.start;
stop = mergedRange.stop;
} else if (innerRange) {
start = innerRange.start;
stop = innerRange.stop;
} else if (outerRange) {
start = outerRange.start;
stop = outerRange.stop;
}
}
// Create Major ticks and tickStep
if (stop > start) {
majorTicks = majorTicks.concat( d3.ticks(start, stop, tickCount) );
majorTickStep = d3.tickStep(start, stop, tickCount);
} else if (stop < start) {
// Ratio of the sequence length before 0 to sequence length after zero
// The number of ticks will for each region will depend on this ratio
const tickCountRatio = (sequenceLength - start) / this.sequence.lengthOfRange(start, stop);
const ticksBeforeZero = Math.round(tickCount * tickCountRatio);
const ticksAfterZero = Math.round(tickCount * (1 - tickCountRatio)) * 2; // Multiply by 2 for a margin of safety
if (ticksBeforeZero > 0) {
majorTicks = majorTicks.concat( d3.ticks(start, sequenceLength, ticksBeforeZero) );
majorTickStep = Math.round(d3.tickStep(start, sequenceLength, ticksBeforeZero));
for (let i = 1; i <= ticksAfterZero; i ++) {
if (majorTickStep * i < start) {
majorTicks.push( majorTickStep * i );
}
}
} else {
majorTicks = majorTicks.concat( d3.ticks(1, stop, tickCount) );
majorTickStep = Math.round(d3.tickStep(1, stop, tickCount));
}
}
// Find Minor ticks
minorTicks = [];
if ( !(majorTickStep % 5) ) {
minorTickStep = majorTickStep / 5;
} else if ( !(majorTickStep % 2) ) {
minorTickStep = majorTickStep / 2;
} else {
minorTickStep = 0;
}
if (minorTickStep) {
if (this.sequence.lengthOfRange(majorTicks[majorTicks.length - 1], majorTicks[0]) <= 3 * majorTickStep) {
start = 0;
stop = sequenceLength;
} else {
start = majorTicks[0] - majorTickStep;
stop = majorTicks[majorTicks.length - 1] + majorTickStep;
}
if (start < stop) {
for (let tick = start; tick <= stop; tick += minorTickStep) {
if (tick % majorTickStep) {
minorTicks.push(tick);
}
}
} else {
for (let tick = start; tick <= sequenceLength; tick += minorTickStep) {
if (tick % majorTickStep) {
minorTicks.push(tick);
}
}
for (let tick = 0; tick <= stop; tick += minorTickStep) {
if (tick % majorTickStep) {
minorTicks.push(tick);
}
}
}
}
this._majorTicks = majorTicks;
this._majorTickStep = majorTickStep;
this._minorTicks = minorTicks;
this._minorTickStep = minorTickStep;
this._tickFormater = this._createTickFormatter(majorTickStep);
}
draw(innerCenterOffset, outerCenterOffset) {
if (this.visible) {
innerCenterOffset -= this.spacing;
outerCenterOffset += this.spacing;
this._updateTicks(innerCenterOffset, outerCenterOffset);
this.drawForCenterOffset(innerCenterOffset, 'inner');
this.drawForCenterOffset(outerCenterOffset, 'outer', false);
}
}
drawForCenterOffset(centerOffset, position = 'inner', drawLabels = true) {
const ctx = this.canvas.context('map');
const tickLength = (position === 'inner') ? -this.tickLength : this.tickLength;
// ctx.fillStyle = 'black'; // Label Color
ctx.fillStyle = this.color.rgbaString; // Label Color
ctx.font = this.font.css;
ctx.textAlign = 'left';
// ctx.textBaseline = 'top';
ctx.textBaseline = 'alphabetic'; // The default baseline works best across canvas and svg
// Draw Tick for first bp (Origin)
this.canvas.radiantLine('map', 1, centerOffset, tickLength, this.tickWidth * 2, this.color.rgbaString, this.lineCap);
// Draw Major ticks
this.majorTicks.forEach( (bp) => {
this.canvas.radiantLine('map', bp, centerOffset, tickLength, this.tickWidth, this.color.rgbaString, this.lineCap);
if (drawLabels) {
const label = this.tickFormater(bp);
this.drawLabel(bp, label, centerOffset, position);
}
});
// Draw Minor ticks
for (const bp of this.minorTicks) {
if (bp > this.sequence.length) { break; }
this.canvas.radiantLine('map', bp, centerOffset, tickLength / 2, this.tickWidth, this.color.rgbaString, this.lineCap);
}
}
drawLabel(bp, label, centerOffset, position = 'inner') {
const ctx = this.canvas.context('map');
// Put space between number and units
label = label.replace(/([kM])?$/, ' $1bp');
// INNER
const innerPt = this.canvas.pointForBp(bp, centerOffset - this.rulerPadding);
const attachmentPosition = this.layout.clockPositionForBp(bp);
const labelWidth = this.font.width(ctx, label);
const labelPt = utils.rectOriginForAttachementPoint(innerPt, attachmentPosition, labelWidth, this.font.height);
// ctx.fillText(label, labelPt.x, labelPt.y);
ctx.fillText(label, labelPt.x, labelPt.y + this.font.height);
}
invertColors() {
this.update({
color: this.color.invert().rgbaString
});
}
/**
* Update ruler [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: 'Ruler',
validKeys: ['color', 'font', 'visible']
});
this.viewer.trigger('ruler-update', { attributes });
}
/**
* Returns JSON representing the object
*/
toJSON(options = {}) {
const json = {
font: this.font.string,
color: this.color.rgbaString,
// visible: this.visible
};
// Optionally add default values
if (!this.visible || options.includeDefaults) {
json.visible = this.visible;
}
return json;
}
}
export default Ruler;