Source: models/RNA.js

import _ from "lodash";

import { ModelBase, ModelBaseStyle } from './modelBase';
import { ModelBP } from './modelBP';
import { drawBases } from '../layouts/layout';
import { Layouts, VARNAConfig } from './config';
import { ptableFromDBN, parseSeq } from '../utils/RNA';
import { DiscontinuousBackbone } from './modelBackbone';

const DBNStrandSep = "&";

/**
 * Simple dot-bracket notation parser
 * @param {string} dbn - secondary structure in dbn
 * @return {Array} An array of basepairs, each is an array of two integers
 */
let parseDBN = function(dbn){
	let indx = [];
	let bps = [];
	for (let i = 0; i < dbn.length; ++i) {
		if (dbn[i] == "(") {
			indx.push(i);
		} else if (dbn[i] == ")") {
			bps.push([indx.pop(),i]);
		} else {
			//pass;
		}
	}
	return bps;
};

/**
 * Basic class to draw one RNA
 * @class
 * @constructor
 * @public
 * @property {cytoscape} cy - cytoscape drawing
 * @property {Array} baseList - Array of ModelBase
 */
export class RNA {
	name = null;
	cy;
	cfg;
	baseList = [];
	auxBPs = [];
	baseStyleList = [];
	constructor() {
	}

	// TODO: Refactor as VARNA
	/**
	 * Construct from given dbn and seq. At most one can be empty
	 * @param {string} dbn - secondary structure in dbn
	 * @param {string} seq - RNA sequence
	 */
	static fromDBN(dbn, seq) {
		if ((dbn.length == 0) && (seq.length == 0)) {
			throw new Error("At least one should be non-null!");
		}
		let rna = new this();
		let seqTmp = (seq.length == 0)? [] : parseSeq(seq);
		let sepPosLst = [];
		let dbnFinal = ""
		let seqFinal = [];
		if (dbn.length == 0) {
			dbnFinal = ".".repeat(seqTmp.length);
			seqFinal = seqTmp;
		} else {
			// Parse strands if dbn is given
			for (let i = 0; i < dbn.length; i++) {
				let c = dbn[i];
				if (c == DBNStrandSep && (seqTmp.length == 0 || seqTmp[i] == DBNStrandSep)) {
					// Find separator at both dbn and seq, or seq is empty
					sepPosLst.push(seqFinal.length - 1);
				} else {
					// Usual structural position, or separator unmatch
					seqFinal.push((_.isUndefined(seqTmp[i])) ? "" : seqTmp[i]);
					dbnFinal += c;
				}
			}
			// Add unpaired bases if sequence is longer
			for (let i = dbn.length; i < seqTmp.length; i++) {
				dbnFinal += ".";
			}
		}
		let ptable = ptableFromDBN(dbnFinal);
		// Fill baseList
		rna.baseList = new Array(ptable.length);
		for (let i = 0; i < ptable.length; i++) {
			let base = new ModelBase(i, i+1, seqFinal[i]);
			// Next base belongs to another strand:w
			//
			if (sepPosLst.indexOf(i) >= 0) {
				base.setBackbone(DiscontinuousBackbone);
			}
			rna.baseList[i] = base;
		}
		// Fill basepair
		for (let i = 0; i < ptable.length; i++) {
			let j = ptable[i];
			if ((j == -1) || (j < i)) {
				continue;
			} else if (dbnFinal[i] == '(') {
				rna.addBPNow(i, j);
			} else {
				rna.addBPAux(i, j);
			}
		}
		return rna;
	}


	/**
	 * Set drawing configuration
	 * @param {VARNAConfig} cfg - configuration to draw
	 */
	setConfig(cfg) {
		if (! cfg instanceof VARNAConfig) {
			throw new Error(`${cfg} is not an instance of VARNAConfig`)
		}
		this.cfg = cfg;
	}

	getSelector(inst) {
		if (this.name === null) {
			return inst;
		}
		let instNew = inst;
		["node", "edge"].forEach((t) => {
			if (inst.startsWith(t)) {
				instNew = inst.replace(t, `${t}.${this.name}`);
			}
		});
		return instNew;
	}

	getCyId(id, type) {
		let newId = (this.name === null) ? "" : `${this.name}_`;
		switch (type) {
			case "base":
				newId += "";
				break;
			case "backbone":
				newId += "backbone_";
				break;
			case "planar":
				newId += "planarbp_";
				break;
			case "aux":
				newId += "auxbp_";
				break;
			default:
				throw new Error(`Unknown type: ${type}`);
		}
		return newId + id;
	}

	/*
	 * Get BaseModel of base
	 * Directly return if base is already an instance of BaseModel
	 * @param {int|BaseModel} base - either index in baseList or BaseModel
	 */
	getBase(base) {
		if (Number.isInteger(base)) {
			if (base >= this.baseList.length) {
			throw new Error(`${base} is larger than total amount of bases minus one`)
			}
			return this.baseList[base];
		} else if (base instanceof ModelBase) {
			return base;
		} else {
			throw new Error(`${base} is not an integer or an instance of ModelBase.`);
		}
	}
	/**
	 * Add additional basepair (i, j)
	 * The function check whether (i, j) fit planar unless the flag noplanar is set
	 *
	 * @param {int|ModelBase} i - index of ModelBase object of i
	 * @param {int|ModelBase} j - index of ModelBase object of j
	 */
	addBP(i, j, opt={}) {
		let basei = this.getBase(i), basej = this.getBase(j);
		// Create ModelBP object for basepair
		let mbp = new ModelBP(basei, basej, opt);
		let indi, indj;
		[indi, indj] = [Math.min(basei.ind, basej.ind), Math.max(basei.ind, basej.ind)]
		// Add directly to aux
		if (mbp.noplanar) {
			this.addBPAux(basei, basej, mbp);
			return;
		} else {
			// Here, we test if basepair (i, j) fit to planar
			for (let indk = indi; indk <= indj; indk++) {
				let tmp = this.baseList[indk];
				let indl = tmp.getPartnerInd();
				if (indl != -1) {
					if ((indl <= indi) || (indl >= indj)) {
						// Violate planar
						this.addBPAux(basei, basej, mbp);
						return;
					}
				}
			}
			this.addBPNow(basei, basej, mbp);
		}
	}

	/**
	 * Add aux basepair (i, j)
	 * @param {int|ModelBase} i - 
	 */
	addBPAux(i, j, mbp=null) {
		let basei = this.getBase(i), basej = this.getBase(j);
		if (mbp === null) {
			mbp = new ModelBP(basei, basej);
		}
		this.auxBPs.push(mbp);
	}


	addBPNow(i, j, mbp=null) {
		let basei = this.getBase(i), basej = this.getBase(j);
		if (mbp === null) {
			mbp = new ModelBP(basei, basej);
		}
		basei.setBP(mbp);
		basej.setBP(mbp);
	}

	/**
	 * Apply base style to given bases
	 * The function assigns a number (the order) to given ModelBaseStyle object
	 * Bases will be grouped with class `basegroup${number}`
	 *
	 * @param {ModelBaseStyle} style - base style to apply
	 * @param {ModelBase|int}  bases - bases (could be either ModelBase object or the index in baseList) to apply on
	 */
	applyBasesStyle(style, ...bases) {
		if (style instanceof ModelBaseStyle) {
			// Set style index as its id
			if (bases.length > 0) {
				style.setId(this.baseStyleList.length);
				this.baseStyleList.push(style);
			}
			for (let i = 0; i < bases.length; i++) {
				let element = bases[i];
				let base;
				if (Number.isInteger(element)) {
						base = this.baseList[element];
				} else if (element instanceof ModelBase) {
						base = element;
				} else {
						throw new Error(`${element} is not an instance of int nor ModelBase.`);
				}
				base.setStyle(style);
			};
		} else {
			throw new Error("The style should be an instance of ModelBaseStyle.");
		}
	}

	/***************/
	/*    Bases    */
	/***************/

	/**
	 * Return bases to draw in cytoscape format
	 */
	cyOfBases() {
		let elements = this.elOfBases();
		let styles = this.styleOfBases();
		return {"el": elements, "style": styles};
	}

	/**
	 * Returns bases in cytoscape node element list
	 */
	elOfBases() {
		let res = [];
		for (let i = 0; i < this.baseList.length; ++i) {
			let base = this.baseList[i];
			let baseEl = {
				data: {
					id: this.getCyId(base.ind, "base"),
					label: base.c,
					num: base.getBaseNum()
				}
			};
			// Set custom base classes
			baseEl['classes'] = [...base.classes];
			if (this.name !== null) {
				baseEl['classes'].push(this.name);
			}
			// Add baseNum class for node to draw base number
			if (isNumberDrawn(base, this.cfg.baseNumPeriod, this.baseList.length)) {
				baseEl["classes"].push("baseNum");
			}
			// Add class for base style
			if (base.style !== null) {
				baseEl["classes"].push(`basegroup${base.style.getId()}`);
				baseEl["data"]["baseNumColor"] = base.style.baseNumColor;
			}
			baseEl['position'] = base.getCoords();
			res.push(baseEl);
		}
		return res;
	}

	/**
	 * Returns base style in cytoscape style
	 */
	styleOfBases() {
		let cfg = this.cfg;
		// Default style for all bases
		let generalStyle = cfg.baseCyStyle(this.getSelector("node"));
		// Default style for base label
		let baseNameStyle = cfg.baseNameCyStyle(this.getSelector("node[label]"));
		let res = [generalStyle, baseNameStyle];
		// Specific base style
		this.baseStyleList.forEach((basestyle) => 
			res.push(...basestyle.toCyStyleInList(this.getSelector(`node.basegroup${basestyle.getId()}`)))
		);
		return res;
	}

	/***************/
	/* Base Number */
	/***************/

	/**
	 * Return base number to draw in cytoscape format
	 */
	cyOfBaseNum() {
		let cfg = this.cfg;

		return [{
    	query: '.baseNum',
      valign: "center",
      halign: "left",
      valignBox: "center",
      halignBox: "left",
      tpl: function(data) {
				let color;
				if (data.baseNumColor) {
					color = data.baseNumColor;
				} else {
					color = cfg.baseNumColor;
				}
				return `<p style="color: ${color}">${data.num}</p>`;
      }
    }];
	}

	/***************/
	/*  Backbone   */
	/***************/

	/**
	 * Return backbones to draw in cytoscape format
	 */
	cyOfBackbones() {
		let elements = this.elOfBackbones();
		let styles = this.styleOfBackbones();
		return {"el": elements, "style": styles};
	}

	// TODO: custom backbone style
	/**
	 * Returns backbone in cytoscape edge element list with classes set to backbone
	 */
	elOfBackbones() {
		let res = [];
		for (let i = 0; i < this.baseList.length - 1; ++i) {
			let backbone = this.baseList[i].getBackbone();
			if (backbone != DiscontinuousBackbone) {
				let el = {
					"data": {
						id: this.getCyId(i, 'backbone'),
						source: this.getCyId(i, 'base'),
						target: this.getCyId(i+1, 'base')
					},
					"classes": ["backbone"]
				};
				if (this.name !== null) {
					el.classes.push(this.name);
				}
				res.push(el);
			}
		}
		return res;
	}

	/**
	 * Return backbone style in cytoscape format
	 */
	styleOfBackbones() {
		let cfg = this.cfg;
		let res = [];
		let generalStyle = cfg.backboneCyStyle(this.getSelector("edge.backbone"));
		res.push(generalStyle);
		return res;
	}

	/***************/
	/*     BPs     */
	/***************/

	/**
	 * Return basepair to draw in cytoscape format
	 */
	cyOfBPs() {
		let cfg = this.cfg;
		let elements = [...this.elOfPlanarBPs(), ...this.elOfAuxBPs()];
		let styles = this.styleOfBPs();
		return {"el": elements, "style": styles};
	}


	/**
	 * Returns cytoscape edge element for one single bp
	 */
	elOfSingleBP(bp) {
		let edgeEl = bp.toCyElement();
		if (this.name !== null) {
			edgeEl.classes.push(this.name);
		}
		// Here, we need to correct source and target base id
		edgeEl.data.source = this.getCyId(edgeEl.data.source, "base");
		edgeEl.data.target = this.getCyId(edgeEl.data.target, "base");

		return edgeEl;
	}

	/**
	 * Returns planar basepair in cytoscape edge element list with classes set to basepair and planarbp
	 */
	elOfPlanarBPs() {
		let cfg = this.cfg;
		let res = [];
		// Nested bp
		for (const base of this.baseList) {
			let j = base.getPartnerInd();
			if (j > base.ind) {
				let bp = base.getBP();
				let edgeEl = this.elOfSingleBP(bp);
				edgeEl.data.id = this.getCyId(base.ind, "planar");
				edgeEl.classes.push("planarbp");
				if (cfg.layout == Layouts.LINE) {
					if (_.isUndefined(edgeEl.style)) {
						edgeEl.style = {};
					}
					let factor = (cfg.bpLowerPlane) ? 1 : -1;
					edgeEl.style["control-point-distance"] = factor * (bp.partner3.ind-bp.partner5.ind)*20;
				}
				res.push(edgeEl);
			}
		}
		return res;
	}

	/**
	 * Returns aux basepair in cytoscape edge element list with classes set to basepair and auxbp
	 */
	elOfAuxBPs() {
		let cfg = this.cfg;
		let res = [];
		for (let i = 0; i < this.auxBPs.length; i++) {
			let bp = this.auxBPs[i];
			let edgeEl = this.elOfSingleBP(bp);
			edgeEl.data.id = this.getCyId(i, "aux");
			edgeEl.classes.push("auxbp");
			if (cfg.layout == Layouts.LINE) {
				if (_.isUndefined(edgeEl.style)) {
					edgeEl.style = {};
				}
				let factor = (cfg.bpLowerPlane) ? 1 : -1;
				edgeEl.style["control-point-distance"] = factor * (bp.partner3.ind-bp.partner5.ind)*20;
			}
			res.push(edgeEl);
		}
		return res;
	}

	/**
	 * Return basepair style in cytoscape format
	 */
	styleOfBPs() {
		let cfg = this.cfg;
		let res = [];
		let generalStyle = cfg.bpCyStyle(this.getSelector("edge.basepair"));
		if (cfg.layout == Layouts.LINE) {
			generalStyle.style["curve-style"] = "unbundled-bezier";
			generalStyle.style["control-point-weight"] = 0.5;
		}
		res.push(generalStyle);
		return res;
	}


	customStyle() {
		return [];
	}
	

	createCyFormat() {
		let cfg = this.cfg;
		var coords = drawBases(this.baseList, cfg);

		let basesCy = this.cyOfBases();
		let backbonesCy = this.cyOfBackbones();
		let bpsCy= this.cyOfBPs();

		let elements = [...basesCy.el, ...backbonesCy.el, ...bpsCy.el];
		let styles = [...basesCy.style, ...backbonesCy.style, ...bpsCy.style, ...this.customStyle()];
		
		// Set layout (base position)
		let layoutDict = {'name': 'preset'};
		let cyDist = {
  	  elements: elements,
			layout: layoutDict,
			style: styles
  	 };
		return cyDist;	
	}

}

/**
 * Return true to show number of given base
 * @private
 *
 * @param {ModelBase} mb - base in ModelBase
 * @param {int} period - base number period
 * @param {int} total - total base number
 */
function isNumberDrawn(mb, period, total) {
	if ((period <= 0) || (mb.getBaseNum() === null)) {
		return false;
	}
	return (mb.ind == 0) || (mb.getBaseNum() % period == 0) || (mb.ind == total -1);
}