utilities.ts

//a collection of miscellaneous utility functions

import { getGradient, Gradient, GradientType } from "./Gradient";
import { VolumeData } from "./VolumeData";
import { builtinColorSchemes, CC, elementColors, htmlColors, Color } from "./colors";
import { IsoSurfaceSpec } from "GLShape";
import { inflate, InflateFunctionOptions, Data } from "pako"

//simplified version of jquery extend
export function extend(obj1, src1) {
    for (var key in src1) {
        if (src1.hasOwnProperty(key) && src1[key] !== undefined) {
            obj1[key] = src1[key];
        }
    }
    return obj1;
};

//deep copy, cannot deal with circular refs; undefined input becomes an empty object
//https://medium.com/javascript-in-plain-english/how-to-deep-copy-objects-and-arrays-in-javascript-7c911359b089
export function deepCopy(inObject) {
    let outObject, value, key;

    if (inObject == undefined) {
        return {};
    }
    if (typeof inObject !== "object" || inObject === null) {
        return inObject; // Return the value if inObject is not an object
    }

    // Create an array or object to hold the values
    outObject = Array.isArray(inObject) ? [] : {};

    for (key in inObject) {
        value = inObject[key];
        // Recursively (deep) copy for nested objects, including arrays
        outObject[key] = deepCopy(value);
    }

    return outObject;
};

export function isNumeric(obj) {

    var type = typeof (obj);
    return (type === "number" || type === "string") &&
        !isNaN(obj - parseFloat(obj));
};

export function isEmptyObject(obj) {
    var name;
    for (name in obj) {
        return false;
    }
    return true;
};

export type Func = Function|string|undefined|null;

export function makeFunction(callback:Func): Function {
    //for py3dmol let users provide callback as string
    if (callback && typeof callback === "string") {
        /* jshint ignore:start */
        callback = eval("(" + callback + ")");
        /* jshint ignore:end */
    }
    // report to console if callback is not a valid function
    if (callback && typeof callback != "function") {
        console.warn("Invalid callback provided.");
        return ()=>{}; //return noop function
    }
    return callback as Function;
};

//standardize voldata/volscheme in style
export function adjustVolumeStyle(style: IsoSurfaceSpec) {
    if (style) {
        if (style.volformat && !(style.voldata instanceof VolumeData)) {
            style.voldata = new VolumeData(style.voldata, style.volformat);
        }
        if (style.volscheme) {
            style.volscheme = Gradient.getGradient(style.volscheme);
        }
    }
};


/*
 * computes the bounding box around the provided atoms
 * @param {AtomSpec[]} atomlist
 * @return {Array}
 */
export function getExtent(atomlist, ignoreSymmetries?) {
    var xmin, ymin, zmin, xmax, ymax, zmax, xsum, ysum, zsum, cnt;
    var includeSym = !ignoreSymmetries;

    xmin = ymin = zmin = 9999;
    xmax = ymax = zmax = -9999;
    xsum = ysum = zsum = cnt = 0;

    if (atomlist.length === 0)
        return [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
    for (var i = 0; i < atomlist.length; i++) {
        var atom = atomlist[i];
        if (typeof atom === 'undefined' || !isFinite(atom.x) ||
            !isFinite(atom.y) || !isFinite(atom.z))
            continue;
        cnt++;
        xsum += atom.x;
        ysum += atom.y;
        zsum += atom.z;

        xmin = (xmin < atom.x) ? xmin : atom.x;
        ymin = (ymin < atom.y) ? ymin : atom.y;
        zmin = (zmin < atom.z) ? zmin : atom.z;
        xmax = (xmax > atom.x) ? xmax : atom.x;
        ymax = (ymax > atom.y) ? ymax : atom.y;
        zmax = (zmax > atom.z) ? zmax : atom.z;

        if (atom.symmetries && includeSym) {
            for (var n = 0; n < atom.symmetries.length; n++) {
                cnt++;
                xsum += atom.symmetries[n].x;
                ysum += atom.symmetries[n].y;
                zsum += atom.symmetries[n].z;
                xmin = (xmin < atom.symmetries[n].x) ? xmin : atom.symmetries[n].x;
                ymin = (ymin < atom.symmetries[n].y) ? ymin : atom.symmetries[n].y;
                zmin = (zmin < atom.symmetries[n].z) ? zmin : atom.symmetries[n].z;
                xmax = (xmax > atom.symmetries[n].x) ? xmax : atom.symmetries[n].x;
                ymax = (ymax > atom.symmetries[n].y) ? ymax : atom.symmetries[n].y;
                zmax = (zmax > atom.symmetries[n].z) ? zmax : atom.symmetries[n].z;
            }
        }
    }

    return [[xmin, ymin, zmin], [xmax, ymax, zmax],
    [xsum / cnt, ysum / cnt, zsum / cnt]];
};


/* get the min and max values of the specified property in the provided
* @function $3Dmol.getPropertyRange
* @param {AtomSpec[]} atomlist - list of atoms to evaluate
* @param {string} prop - name of property 
* @return {Array} - [min, max] values
*/
export function getPropertyRange(atomlist, prop) {
    var min = Number.POSITIVE_INFINITY;
    var max = Number.NEGATIVE_INFINITY;

    for (var i = 0, n = atomlist.length; i < n; i++) {
        var atom = atomlist[i];
        var val = getAtomProperty(atom, prop);

        if (val != null) {
            if (val < min)
                min = val;
            if (val > max)
                max = val;
        }
    }

    if (!isFinite(min) && !isFinite(max))
        min = max = 0;
    else if (!isFinite(min))
        min = max;
    else if (!isFinite(max))
        max = min;

    return [min, max];
};


//adapted from https://stackoverflow.com/questions/3969475/javascript-pause-settimeout
export class PausableTimer {
    ident: any;
    total_time_run = 0;
    start_time: number;
    countdown: number;
    fn: any;
    arg: any;

    constructor(fn, countdown, arg?) {
        this.fn = fn;
        this.arg = arg;
        this.countdown = countdown;
        this.start_time = new Date().getTime();
        this.ident = setTimeout(fn, countdown, arg);
    }

    cancel() {
        clearTimeout(this.ident);
    }

    pause() {
        clearTimeout(this.ident);
        this.total_time_run = new Date().getTime() - this.start_time;
    }

    resume() {
        this.ident = setTimeout(this.fn, Math.max(0, this.countdown - this.total_time_run), this.arg);
    }

};

/*
 * Convert a base64 encoded string to a Uint8Array
 * @param {string} base64 encoded string
 */
export function base64ToArray(base64) {
    var binary_string = window.atob(base64);
    var len = binary_string.length;
    var bytes = new Uint8Array(len);
    for (var i = 0; i < len; i++) {
        bytes[i] = binary_string.charCodeAt(i);
    }
    return bytes;
};

//return the value of an atom property prop, or null if non existent
// looks first in properties, then in the atom itself
export function getAtomProperty(atom, prop) {
    var val = null;
    if (atom.properties &&
        typeof (atom.properties[prop]) != "undefined") {
        val = atom.properties[prop];
    } else if (typeof (atom[prop]) != 'undefined') {
        val = atom[prop];
    }
    return val;
};

//Miscellaneous functions and classes - to be incorporated into $3Dmol proper
/*
 * 
 * @param {$3Dmol.Geometry} geometry
 * @param {$3Dmol.Mesh} mesh
 * @returns {undefined}
 */
export function mergeGeos(geometry, mesh) {

    var meshGeo = mesh.geometry;

    if (meshGeo === undefined)
        return;

    geometry.geometryGroups.push(meshGeo.geometryGroups[0]);

};


/*
 * Parse a string that represents a style or atom selection and convert it
 * into an object.  The goal is to make it easier to write out these specifications
 * without resorting to json. Objects cannot be defined recursively.
 * ; - delineates fields of the object 
 * : - if the field has a value other than an empty object, it comes after a colon
 * , - delineates key/value pairs of a value object
 *     If the value object consists of ONLY keys (no = present) the keys are 
 *     converted to a list.  Otherwise a object of key/value pairs is created with
 *     any missing values set to null
 * = OR ~ - separates key/value pairs of a value object, if not provided value is null
 *     twiddle is supported since = has special meaning in URLs
 * @param (String) str
 * @returns {Object}
 */
export function specStringToObject(str) {
    if (typeof (str) === "object") {
        return str; //not string, assume was converted already
    }
    else if (typeof (str) === "undefined" || str == null) {
        return str;
    }

    //if this is a json string, parse it directly
    try {
        let parsed = JSON.parse(str);
        return parsed;
    } catch (error) {

    }

    str = str.replace(/%7E/g, '~'); //copy/pasting urls sometimes does this
    //convert things that look like numbers into numbers
    var massage = function (val) {
        if (isNumeric(val)) {
            //hexadecimal does not parse as float
            if (Math.floor(parseFloat(val)) == parseInt(val)) {
                return parseFloat(val);
            }
            else if (val.indexOf('.') >= 0) {
                return parseFloat(val); // ".7" for example, does not parseInt
            }
            else {
                return parseInt(val);
            }
        }
        //boolean conversions
        else if (val === 'true') {
            return true;
        }
        else if (val === 'false') {
            return false;
        }
        return val;
    };

    var ret = {};
    if (str === 'all') return ret;
    var fields = str.split(';');
    for (var i = 0; i < fields.length; i++) {
        var fv = fields[i].split(':');
        var f = fv[0];
        var val = {};
        var vstr = fv[1];
        if (vstr) {
            vstr = vstr.replace(/~/g, "=");
            if (vstr.indexOf('=') !== -1) {
                //has key=value pairs, must be object
                var kvs = vstr.split(',');
                for (var j = 0; j < kvs.length; j++) {
                    var kv = kvs[j].split('=', 2);
                    val[kv[0]] = massage(kv[1]);
                }
            }
            else if (vstr.indexOf(',') !== -1) {
                //has multiple values, must list
                val = vstr.split(',');
            }
            else {
                val = massage(vstr); //value itself
            }
        }
        ret[f] = val;
    }

    return ret;
};



function checkStatus(response) {
    if (!response.ok) {
        throw new Error(`HTTP ${response.status} - ${response.statusText}`);
    }
    return response;
}

/**
 * Fetch data from URL
 * 
 * @param uri URL
 * @param callback Function to call with data 
 */
export function get(uri, callback?) {
    var promise = fetch(uri).then(checkStatus).then((response) => response.text());
    if (callback)
        return promise.then(callback);
    else
        return promise;
}

/**
 * Download binary data (e.g. a gzipped file) into an array buffer and provide
 * arraybuffer to callback.
 * @param {string} uri - location of data
 * @param {Function} [callback] - Function to call with arraybuffer as argument.  
 * @param {string} [request] - type of request
 * @param {string} [postdata] - data for POST request
 * @return {Promise}
 */
export function getbin(uri, callback?, request?, postdata?) {
    var promise;
    if (request == "POST") {
        promise = fetch(uri, { method: 'POST', body: postdata })
            .then((response) => checkStatus(response))
            .then((response) => response.arrayBuffer());
    } else {
        promise = fetch(uri).then((response) => checkStatus(response))
            .then((response) => response.arrayBuffer());
    }

    if (callback) return promise.then(callback);
    else return promise;
};


/**
 * Load a PDB/PubChem structure into existing viewer. Automatically calls 'zoomTo' and 'render' on viewer after loading model
 * @param {string} query - String specifying pdb or pubchem id; must be prefaced with "pdb: " or "cid: ", respectively
 * @param {GLViewer} viewer - Add new model to existing viewer
 * @param {Object} options - Specify additional options
 *                           format: file format to download, if multiple are available, default format is pdb
 *                           pdbUri: URI to retrieve PDB files, default URI is http://www.rcsb.org/pdb/files/
 * @param {Function} [callback] - Function to call with model as argument after data is loaded.
  
 * @return {GLModel} GLModel, Promise if callback is not provided
 * @example
 viewer.setBackgroundColor(0xffffffff);
       $3Dmol.download('pdb:2nbd',viewer,{onemol: true,multimodel: true},function(m) {
        m.setStyle({'cartoon':{colorscheme:{prop:'ss',map:$3Dmol.ssColors.Jmol}}});
       viewer.zoomTo();
       viewer.render(callback);
    });
 */
export function download(query, viewer, options, callback?) {
    var type = "";
    var pdbUri = "";
    var uri = "";
    var promise = null;
    var m = viewer.addModel();

    if (query.indexOf(':') < 0) {
        //no type specifier, guess
        if (query.length == 4) {
            query = 'pdb:' + query;
        } else if (!isNaN(query)) {
            query = 'cid:' + query;
        } else {
            query = 'url:' + query;
        }
    }
    if (query.substring(0,5) == 'mmtf:') {
        console.warn('WARNING: MMTF now deprecated.  Reverting to bcif.');
        query = 'bcif:' + query.slice(5);
    }
    if (query.substring(0, 5) === 'bcif:') {
        query = query.substring(5).toUpperCase();
        uri = "https://models.rcsb.org/" + query + '.bcif.gz';
        if (options && typeof options.noComputeSecondaryStructure === 'undefined') {
            //when fetch directly from pdb, trust structure annotations
            options.noComputeSecondaryStructure = true;
        }
        promise = new Promise(function (resolve) {
            getbin(uri)
                .then(function (ret) {
                    m.addMolData(ret, 'bcif.gz', options);
                    viewer.zoomTo();
                    viewer.render();
                    resolve(m);
                }, function () { console.error("fetch of " + uri + " failed."); });
        });
    }
    else {
        if (query.substring(0, 4) === 'pdb:') {
            type = 'bcif';
            if (options && options.format) {
                type = options.format; //can override and require pdb
            }

            if (options && typeof options.noComputeSecondaryStructure === 'undefined') {
                //when fetch directly from pdb, trust structure annotations
                options.noComputeSecondaryStructure = true;
            }
            query = query.substring(4).toUpperCase();
            if (!query.match(/^[1-9][A-Za-z0-9]{3}$/)) {
                alert("Wrong PDB ID");
                return;
            }
            if (type == 'bcif') {
                uri = 'https://models.rcsb.org/' + query.toUpperCase() + '.bcif.gz';
            }
            else {
                pdbUri = options && options.pdbUri ? options.pdbUri : "https://files.rcsb.org/view/";
                uri = pdbUri + query + "." + type;
            }

        } else if (query.substring(0, 4) == 'cid:') {
            type = "sdf";
            query = query.substring(4);
            if (!query.match(/^[0-9]+$/)) {
                alert("Wrong Compound ID"); return;
            }
            uri = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/" + query +
                "/SDF?record_type=3d";
        } else if (query.substring(0, 4) == 'url:') {
            uri = query.substring(4);
            type = uri;
        }

        var handler = function (ret) {
            m.addMolData(ret, type, options);
            viewer.zoomTo();
            viewer.render();
        };
        promise = new Promise(function (resolve) {
            if (type == 'bcif') { //binary data
                getbin(uri)
                    .then(function (ret) {
                        handler(ret);
                        resolve(m);
                    }).catch(function () {
                        //if mmtf server is being annoying, fallback to text
                        pdbUri = options && options.pdbUri ? options.pdbUri : "https://files.rcsb.org/view/";
                        uri = pdbUri + query + ".pdb";
                        type = "pdb";
                        console.warn("falling back to pdb format");
                        get(uri).then(function (data) {
                            handler(data);
                            resolve(m);
                        }).catch(function (e) {
                            handler("");
                            resolve(m);
                            console.error("fetch of " + uri + " failed: " + e.statusText);
                        });
                    }); //an error msg has already been printed
            }
            else {
                get(uri).then(function (data) {
                    handler(data);
                    resolve(m);
                }).catch(function (e) {
                    handler("");
                    resolve(m);
                    console.error("fetch of " + uri + " failed: " + e.statusText);
                });
            }
        });
    }
    if (callback) {
        promise.then(function (m) {
            callback(m);
        });
        return m;
    }
    else return promise;
};


/**  Return proper color for atom given style
 * @param {AtomSpec} atom
 * @param {AtomStyle} style
 * @return {Color}
 */
export function getColorFromStyle(atom, style): Color {
    let scheme = style.colorscheme;
    if (typeof builtinColorSchemes[scheme] != "undefined") {
        scheme = builtinColorSchemes[scheme];
    } else if (typeof scheme == "string" && scheme.endsWith("Carbon")) {
        //any color you want of carbon
        let ccolor = scheme
            .substring(0, scheme.lastIndexOf("Carbon"))
            .toLowerCase();
        if (typeof htmlColors[ccolor] != "undefined") {
            let newscheme = { ...elementColors.defaultColors };
            newscheme.C = htmlColors[ccolor];
            builtinColorSchemes[scheme] = { prop: "elem", map: newscheme };
            scheme = builtinColorSchemes[scheme];
        }
    }

    let color = atom.color;
    if (typeof style.color != "undefined" && style.color != "spectrum")
        color = style.color;
    if (typeof scheme != "undefined") {
        let prop, val;
        if (typeof elementColors[scheme] != "undefined") {
            //name of builtin colorscheme
            scheme = elementColors[scheme];
            if (typeof scheme[atom[scheme.prop]] != "undefined") {
                color = scheme.map[atom[scheme.prop]];
            }
        } else if (typeof scheme[atom[scheme.prop]] != "undefined") {
            //actual color scheme provided
            color = scheme.map[atom[scheme.prop]];
        } else if (
            typeof scheme.prop != "undefined" &&
            typeof scheme.gradient != "undefined"
        ) {
            //apply a property mapping
            prop = scheme.prop;
            var grad = scheme.gradient; //redefining scheme
            if(!(grad instanceof GradientType)) {
                grad = getGradient(scheme);
            }
            let range = grad.range() || [-1, 1]; //sensible default
            val = getAtomProperty(atom, prop);
            if (val != null) {
                color = grad.valueToHex(val, range);
            }
        } else if (
            typeof scheme.prop != "undefined" &&
            typeof scheme.map != "undefined"
        ) {
            //apply a discrete property mapping
            prop = scheme.prop;
            val = getAtomProperty(atom, prop);
            if (typeof scheme.map[val] != "undefined") {
                color = scheme.map[val];
            }
        } else if (typeof style.colorscheme[atom.elem] != "undefined") {
            //actual color scheme provided
            color = style.colorscheme[atom.elem];
        } else {
            console.warn("Could not interpret colorscheme " + scheme);
        }
    } else if (typeof style.colorfunc != "undefined") {
        //this is a user provided function for turning an atom into a color
        color = style.colorfunc(atom);
    }

    let C = CC.color(color);
    return C;
};

//given a string selector, element, or jquery object, return the HTMLElement
export function getElement(element): HTMLElement | null {
    let ret = element;
    if (typeof (element) === "string") {
        ret = document.querySelector("#" + element);
    } else if (typeof element === 'object' && element.get) { //jquery
        ret = element.get(0);
    }
    return ret;
}

export function inflateString(str: string | ArrayBuffer, tostring: Boolean = true): (string | ArrayBuffer) {
    let data: Data;

    if (typeof str === 'string') {
        const encoder = new TextEncoder();
        data = encoder.encode(str);
    } else {
        data = new Uint8Array(str);
    }

    const inflatedData = inflate(data, {
        to: tostring ? 'string' : null
    } as InflateFunctionOptions & { to: 'string' });

    return inflatedData;
}