//////////////////////////////////////////////////////////////////////////////
// IO
//////////////////////////////////////////////////////////////////////////////
// FIXME: Check at the end: A low performance polyfill based on toDataURL.
import { version as currentVersion } from '../package.json';
import CGArray from './CGArray';
import Sequence from './Sequence';
import Settings from './Settings';
import Ruler from './Ruler';
import Backbone from './Backbone';
import Annotation from './Annotation';
import Dividers from './Dividers';
import { Highlighter } from './Highlighter';
import Legend from './Legend';
import utils from './Utils';
import * as d3 from 'd3';
class IO {
/**
* Interface for reading and writing data to and from CGView
* @param {Viewer} viewer - Viewer
*/
constructor(viewer) {
this._viewer = viewer;
}
/**
* @member {Viewer} - Get the viewer.
*/
get viewer() {
return this._viewer;
}
/**
* @member {Number} - Get or set the ability to drag-n-drop JSON files on to viewer
* @private
*/
get allowDragAndDrop() {
return this._allowDragAndDrop;
}
set allowDragAndDrop(value) {
this._allowDragAndDrop = value;
if (value) {
this.io.initializeDragAndDrop();
} else {
// console.log('COMONE')
// d3.select(this.canvas.node('ui')).on('.dragndrop', null);
}
}
/**
* Format the date from created and updated JSON attributes.
* @param {Date} d - Date to format
* @private
*/
formatDate(d) {
// return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`
const timeformat = d3.timeFormat('%Y-%m-%d %H:%M:%S');
return timeformat(d);
}
/**
* Return the CGView map as a JSON object. The JSON can later be loaded using [loadJSON](#loadJSON).
* See the [JSON page](../json.html) for details on the JSON structure.
*/
toJSON(options = {}) {
const v = this.viewer;
const jsonInfo = v._jsonInfo || {};
const json = {
cgview: {
version: currentVersion,
created: jsonInfo.created || this.formatDate(new Date()),
updated: this.formatDate(new Date()),
id: v.id,
name: v.name,
format: v.format,
// geneticCode: v.geneticCode,
settings: v.settings.toJSON(options),
backbone: v.backbone.toJSON(options),
ruler: v.ruler.toJSON(options),
annotation: v.annotation.toJSON(options),
dividers: v.dividers.toJSON(options),
highlighter: v.highlighter.toJSON(options),
captions: [],
legend: v.legend.toJSON(options),
sequence: v.sequence.toJSON(options),
features: [],
plots: [],
bookmarks: [],
tracks: []
}
};
v.captions().each( (i, caption) => {
json.cgview.captions.push(caption.toJSON(options));
});
v.features().each( (i, feature) => {
// Only export features that were not extracted from the sequence.
if (!feature.extractedFromSequence ||
feature.tracks().filter( t => t.dataMethod !== 'sequence' ).length > 0) {
json.cgview.features.push(feature.toJSON(options));
}
});
v.plots().each( (i, plot) => {
// Only export plots that were not extracted from the sequence.
if (!plot.extractedFromSequence ||
plot.tracks().filter( t => t.dataMethod !== 'sequence' ).length > 0) {
json.cgview.plots.push(plot.toJSON(options));
}
});
v.bookmarks().each( (i, bookmark) => {
json.cgview.bookmarks.push(bookmark.toJSON(options));
});
v.tracks().each( (i, track) => {
json.cgview.tracks.push(track.toJSON(options));
});
// Meta Data (TODO: add an option to exclude this)
if (v.meta && Object.keys(v.meta).length > 0) {
json.cgview.meta = v.meta;
}
return json;
}
/**
* Load data from object literal or JSON string ([Format details](../json.html)).
* The map data must be contained within a top level "cgview" property.
* Removes any previous viewer data and overrides options that are already set.
* @param {Object} data - JSON string or Object Literal
*/
loadJSON(json) {
try {
this._loadJSON(json);
} catch (error) {
const msg = `Loading Error: ${error}`
console.log(msg);
const canvas = this.viewer.canvas;
canvas.clear('debug');
const ctx = canvas.context('debug');
ctx.fillText(msg, 5, 15);
}
}
_loadJSON(json) {
let data = json;
if (typeof json === 'string') {
data = JSON.parse(json);
}
console.log(`Loading map JSON version: '${data?.cgview?.version}'`);
data = this.updateJSON(data);
data = data && data.cgview;
if (!data) {
throw new Error("No 'cgview' property found in JSON.");
}
const viewer = this._viewer;
viewer.clear('all');
// Reset objects
viewer._objects = {};
viewer.trigger('cgv-json-load', data); // would 'io-load' be a better name?
// In events this should mention how everything is reset (e.g. tracks, features, etc)
// Viewer attributes
viewer.update({
id: data.id,
name: data.name,
// geneticCode: data.geneticCode,
});
viewer._jsonInfo = {
version: data.version,
created: data.created
};
// Reset arrays
viewer._features = new CGArray();
viewer._tracks = new CGArray();
viewer._plots = new CGArray();
viewer._captions = new CGArray();
viewer._bookmarks = new CGArray();
viewer._loading = true;
// Load Sequence
viewer._sequence = new Sequence(viewer, data.sequence);
// Load Settings
// const settings = data.settings || {};
// General Settings
viewer._settings = new Settings(viewer, data.settings);
// Ruler
viewer._ruler = new Ruler(viewer, data.ruler);
// Backbone
viewer._backbone = new Backbone(viewer, data.backbone);
// Annotation (save label placement methods to restore after loading
// const labelPlacementFast = viewer.annotation.labelPlacementFast.name;
// const labelPlacementFull = viewer.annotation.labelPlacementFull.name;
viewer._annotation = new Annotation(viewer, data.annotation);
// if (labelPlacementFull === labelPlacementFast) {
// viewer.annotation.labelPlacement = labelPlacementFast;
// } else {
// viewer.annotation.labelPlacementFast = labelPlacementFast;
// viewer.annotation.labelPlacementFull = labelPlacementFull;
// }
// Slot Dividers
// viewer.slotDivider = new Divider(viewer, settings.dividers.slot);
viewer._dividers = new Dividers(viewer, data.dividers);
// Highlighter
viewer._highlighter = new Highlighter(viewer, data.highlighter);
// Load Bookmarks
if (data.bookmarks) {
viewer.addBookmarks(data.bookmarks);
}
// Load Captions
if (data.captions) {
viewer.addCaptions(data.captions);
}
// Load Legend
viewer._legend = new Legend(viewer, data.legend);
// Create features
if (data.features) {
viewer.addFeatures(data.features);
}
// Create plots
if (data.plots) {
viewer.addPlots(data.plots);
// data.plots.forEach((plotData) => {
// new Plot(viewer, plotData);
// });
}
// Create tracks
if (data.tracks) {
viewer.addTracks(data.tracks);
}
// Add Meta data
if (data.meta) {
viewer.meta = data.meta;
}
// Refresh Annotations
viewer.annotation.refresh();
viewer._loading = false;
viewer.update({dataHasChanged: false});
// Load Layout
// viewer._layout = new Layout(viewer, data.layout);
viewer.format = utils.defaultFor(data.format, 'circular');
viewer.zoomTo(0, 1, {duration: 0});
}
/**
* Update old CGView JSON formats to the current version.
* The map data must be contained within a top level "cgview" property.
* This method will continue to call itself until the JSON is updated to the latest version.
* @param {Object} data - Object Literal
*/
updateJSON(data) {
data = data && data.cgview;
if (!data) {
throw new Error("No 'cgview' property found in JSON.");
}
function parseVersion(version) {
const result = version.match(/^(\d+)\.(\d+)/)
if (result) {
return { string: version, major: Number(result[1]), minor: Number(result[2]), };
} else {
throw new Error(`Can not read cgview version '${version}'`);
}
}
const version = parseVersion(data.version);
const lastestVersion = parseVersion(currentVersion);
switch (true) {
case (version.string === '0.1'):
data = this._updateVersion_0_1(data)
console.log(`Update JSON version: ${version.string} -> ${data.version}`)
return this.updateJSON({cgview: data});
case (version.string === '0.2'):
data = this._updateVersion_0_2(data)
console.log(`Update JSON version: ${version.string} -> ${data.version}`)
return this.updateJSON({cgview: data});
case (version.string === '1.0.0'):
data = this._updateVersion_1_0(data);
console.log(`Update JSON version: ${version.string} -> ${data.version}`)
return this.updateJSON({cgview: data});
case (version.major <= 1 && version.minor <= 4):
data = this._updateVersion_1_4(data);
console.log(`Update JSON version: ${version.string} -> ${data.version}`)
return this.updateJSON({cgview: data});
case (version.string === lastestVersion.string):
console.log(`JSON at latest version: ${version.string}`)
break;
case (version.major <= lastestVersion.major && version.minor <= lastestVersion.minor):
console.log(`Update JSON to latest version: ${version.string} -> ${currentVersion}`)
data.version = currentVersion;
break;
// case (version.major === 1):
// console.log('No need to convert.')
// break;
default:
throw new Error(`Unknown cgview version '${version.string}'`);
}
return {cgview: data};
}
// Version 1.5 started on 2023-09-28
// Moves the minArcLength from Settings to Legend and LegendItems
_updateVersion_1_4(data) {
data.legend.defaultMinArcLength = data.settings.minArcLength;
// Version
data.version = '1.5.0';
return data;
}
_updateVersion_1_0(data) {
// Contigs are the only change for this version
const contigs = data.sequence && data.sequence.contigs;
if (contigs) {
for (const contig of contigs) {
contig.name = contig.id;
}
}
// Version
data.version = '1.1.0';
return data;
}
// This version is all over the place so concentrate on tracks
// Version 0.2 started on 2018-08-22
_updateVersion_0_2(data) {
// Tracks
const tracks = data.layout && data.layout.tracks || data.tracks;
for (const track of tracks) {
if (track.readingFrame === 'separated') {
track.separateFeaturesBy = 'readingFrame';
} else if (track.strand === 'separated') {
track.separateFeaturesBy = 'strand';
} else {
track.separateFeaturesBy = 'none';
}
track.dataType = track.contents && track.contents.type || track.dataType;
track.dataMethod = track.contents && track.contents.from || track.dataMethod;
track.dataKeys = track.contents && track.contents.extract || track.dataKeys;
}
data.tracks = tracks;
// Version
data.version = '1.1.0';
return data;
}
_updateVersion_0_1(data) {
const positionMap = {
'lower-left': 'bottom-left',
'lower-center': 'bottom-center',
'lower-right': 'bottom-right',
'upper-left': 'top-left',
'upper-center': 'top-center',
'upper-right': 'top-right',
}
// Captions
const captions = data.captions;
if (captions) {
for (const caption of captions) {
caption.position = positionMap[caption.position] || caption.position;
caption.font = caption.items[0].font || caption.font;
caption.fontColor = caption.items[0].fontColor || caption.fontColor;
caption.name = caption.items.map(i => i.name).join('\n');
}
}
// Legend
const legend = data.legend;
legend.position = positionMap[legend.position] || legend.position;
legend.defaultFont = legend.font;
// Tracks
const tracks = data.layout.tracks || [];
for (const track of tracks) {
if (track.readingFrame === 'separated') {
track.separateFeaturesBy = 'readingFrame';
} else if (track.strand === 'separated') {
track.separateFeaturesBy = 'strand';
} else {
track.separateFeaturesBy = 'none';
}
track.dataType = track.contents.type;
track.dataMethod = track.contents.from;
track.dataKeys = track.contents.extract;
}
data.tracks = tracks;
// From Settings
data.annotaion = data.settings.annotaion;
data.backbone = data.settings.backbone;
data.dividers = data.settings.dividers;
data.ruler = data.settings.ruler;
data.settings = data.settings.general;
// Plots aren't saved properly on CGView Server so we can ignore
// Version
data.version = '1.1.0';
return data;
}
/**
* Download the currently visible map as a PNG image.
* @param {Number} width - Width of image
* @param {Number} height - Height of image
* @param {String} filename - Name to save image file as
*/
downloadImage(width, height, filename = 'image.png') {
const viewer = this._viewer;
const canvas = viewer.canvas;
width = width || viewer.width;
height = height || viewer.height;
// Save current settings
// let origContext = canvas.ctx;
const origLayers = canvas._layers;
const debug = viewer.debug;
viewer.debug = false;
// Create new layers and add export layer
const layerNames = canvas.layerNames.concat(['export']);
const tempLayers = canvas.createLayers(d3.select('body'), layerNames, width, height, false);
// Calculate scaling factor
const minNewDimension = d3.min([width, height]);
const scaleFactor = minNewDimension / viewer.minDimension;
// Scale context of layers, excluding the 'export' layer
for (const name of canvas.layerNames) {
tempLayers[name].ctx.scale(scaleFactor, scaleFactor);
}
canvas._layers = tempLayers;
// tempLayers.map.ctx = new C2S(1000, 1000);
// Draw map on to new layers
viewer.drawExport();
viewer.fillBackground();
// Legend
viewer.legend.draw();
// Captions
for (let i = 0, len = viewer._captions.length; i < len; i++) {
viewer._captions[i].draw();
}
// Copy drawing layers to export layer
const exportContext = tempLayers.export.ctx;
exportContext.drawImage(tempLayers.background.node, 0, 0);
exportContext.drawImage(tempLayers.map.node, 0, 0);
exportContext.drawImage(tempLayers.foreground.node, 0, 0);
exportContext.drawImage(tempLayers.canvas.node, 0, 0);
// Generate image from export layer
// let image = tempLayers['export'].node.toDataURL();
tempLayers.export.node.toBlob( (blob) => { this.download(blob, filename, 'image/png');} );
// console.log(tempLayers.map.ctx.getSerializedSvg(true));
// Restore original layers and settings
canvas._layers = origLayers;
viewer.debug = debug;
// Delete temp canvas layers
for (const name of layerNames) {
d3.select(tempLayers[name].node).remove();
}
}
/**
* Return the currently visible map as a SVG string.
* Requires SVGCanvas external dependency:
* https://github.com/zenozeng/svgcanvas
*/
getSVG() {
const SVGContext = this.viewer.externals.SVGContext;
if (!SVGContext) {
console.error('SVGContext is not set. This should be set to svgcanvas.Context from https://github.com/zenozeng/svgcanvas')
return;
}
const viewer = this._viewer;
const canvas = viewer.canvas;
const width = viewer.width;
const height = viewer.height;
// Save current settings
const origLayers = canvas._layers;
const debug = viewer.debug;
viewer.debug = false;
// Create new layers and add export layer
// const layerNames = canvas.layerNames.concat(['export']);
const layerNames = canvas.layerNames;
const tempLayers = canvas.createLayers(d3.select('body'), layerNames, width, height, false);
canvas._layers = tempLayers;
const svgContext = new SVGContext(width, height);
tempLayers.map.ctx = svgContext;
tempLayers.foreground.ctx = svgContext;
tempLayers.canvas.ctx = svgContext;
// Override the clearRect method as it's not required for SVG drawing.
// Otherwise, an additional SVG rect will be drawn obscuring the background.
svgContext.clearRect = () => {};
// Manually Draw background here
svgContext.fillStyle = viewer.settings.backgroundColor.rgbaString;
svgContext.fillRect(0, 0, width, height);
// Draw map on to new layers
viewer.drawExport();
// Legend
viewer.legend.draw();
// Captions
for (let i = 0, len = viewer._captions.length; i < len; i++) {
viewer._captions[i].draw();
}
// Create SVG
const svg = tempLayers.map.ctx.getSerializedSvg();
// Restore original layers and settings
canvas._layers = origLayers;
viewer.debug = debug;
// Delete temp canvas layers
for (const name of layerNames) {
d3.select(tempLayers[name].node).remove();
}
return svg;
}
/**
* Download the currently visible map as a SVG image.
* Requires SVGContext external dependency:
* https://github.com/zenozeng/svgcanvas
* @param {String} filename - Name to save image file as
*/
downloadSVG(filename = 'image.svg') {
const svg = this.getSVG();
if (svg) {
this.download(svg, filename, 'image/svg+xml');
}
}
/**
* Download the map sequence in FASTA format.
* @param {String} fastaId - ID line for FASTA (i.e. text after '>')
* @param {String} filename - Name for saved file
* @param {Object} options - Options for FASTA (see [Sequence.asFasta](Sequence.html#asFasta))
*/
downloadFasta(fastaId, filename = 'sequence.fa', options = {}) {
const fasta = this.viewer.sequence.asFasta(fastaId, options);
this.download(fasta, filename, 'text/plain');
}
/**
* Download the map as a JSON object
* @param {String} filename - Name for saved file
* @param {Object} options - Options passed to toJSON
*/
downloadJSON(filename = 'cgview.json', options = {}) {
const json = this.viewer.io.toJSON(options);
this.download(JSON.stringify(json), filename, 'text/json');
}
// https://stackoverflow.com/questions/13405129/javascript-create-and-save-file
/**
* Download data to a file
* @param {Object} data - Data to download
* @param {String} filename - Name for saved file
* @param {String} type - Mime type for the file
* @private
*/
download(data, filename, type = 'text/plain') {
const file = new Blob([data], {type: type});
if (window.navigator.msSaveOrOpenBlob) {
// IE10+
window.navigator.msSaveOrOpenBlob(file, filename);
} else {
// Others
const a = document.createElement('a');
const url = URL.createObjectURL(file);
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(function() {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 0);
}
}
/**
* Initialize Viewer Drag-n-Drop.
* TODO: Check if this works still
* @private
*/
initializeDragAndDrop() {
const viewer = this.viewer;
const canvas = viewer.canvas;
d3.select(canvas.node('ui')).on('dragleave.dragndrop', (d3Event) => {
d3Event.preventDefault();
d3Event.stopPropagation();
viewer.drawFull();
});
d3.select(canvas.node('ui')).on('dragover.dragndrop', (d3Event) => {
d3Event.preventDefault();
d3Event.stopPropagation();
});
d3.select(canvas.node('ui')).on('drop.dragndrop', (d3Event) => {
d3Event.preventDefault();
d3Event.stopPropagation();
viewer.drawFull();
const file = d3Event.dataTransfer.files[0];
const reader = new FileReader();
reader.onload = function() {
const jsonObj = reader.result;
try {
const jsonParsed = JSON.parse(jsonObj);
// sv.trigger('drop');
viewer.io.loadJSON(jsonParsed.cgview);
viewer.drawFull();
} catch (e) {
// sv.draw();
// sv.flash('Could not read file: ' + e.message);
}
};
reader.readAsText(file);
});
}
}
// // A low performance polyfill based on toDataURL.
// if (!HTMLCanvasElement.prototype.toBlob) {
// Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
// value: function (callback, type, quality) {
// const binStr = atob( this.toDataURL(type, quality).split(',')[1] ),
// len = binStr.length,
// arr = new Uint8Array(len);
//
// for (let i = 0; i < len; i++ ) {
// arr[i] = binStr.charCodeAt(i);
// }
//
// callback( new Blob( [arr], {type: type || 'image/png'} ) );
// }
// });
// }
export default IO;