visualizer.js

'use strict';

/**
 * @fileoverview Class constituting the main object that plots crystals in the webpage
 * @module
 */

import * as _ from 'lodash';

import {
    Renderer as Renderer
} from './render.js';
import {
    Loader as Loader
} from './loader.js';
import {
    Model as Model
} from './model.js';
import {
    ModelView as ModelView
} from './modelview.js';
import {
    AtomMesh
} from './primitives';
import {
    addStaticVar
} from './utils.js';


const model_parameter_defaults = {
    supercell: [1, 1, 1],
    molecularCrystal: false
};

/** An object providing a full interface to a renderer for crystallographic models */
class CrystVis {

    /**
     * An object providing a full interface to a renderer for crystallographic 
     * models
     * @class
     * @param {string}  element     CSS-style identifier for the HTML element to 
     *                              put the renderer in
     * @param {int}     width       Window width
     * @param {int}     height      Window height. If both this and width are
     *                              set to 0, the window fits its context and
     *                              automatically resizes with it
     */
    constructor(element, width = 0, height = 0) {

        // Create a renderer
        this._renderer = new Renderer(element, width, height);
        this._loader = new Loader();

        this._models = {};

        this._current_model = null;
        this._current_mname = null;
        this._displayed = null;
        this._selected = null;

        // Handling events
        this._atom_click_events = {};
        this._atom_click_events[CrystVis.LEFT_CLICK] = this._defaultAtomLeftClick.bind(this);
        this._atom_click_events[CrystVis.LEFT_CLICK + CrystVis.SHIFT_BUTTON] = this._defaultAtomShiftLeftClick.bind(this);
        this._atom_click_events[CrystVis.LEFT_CLICK + CrystVis.CTRL_BUTTON] = this._defaultAtomCtrlLeftClick.bind(this);

        this._atom_click_defaults = _.cloneDeep(this._atom_click_events);

        this._atom_box_event = this._defaultAtomBox.bind(this);

        this._renderer.addClickListener(this._handleAtomClick.bind(this),
            this._renderer._groups.model, AtomMesh);
        this._renderer.addSelBoxListener(this._handleAtomBox.bind(this),
            this._renderer._groups.model, AtomMesh);

        // Additional options
        // Hidden (need dedicated setters)
        this._hsel = false; // If true, highlight the selected atoms

        // Vanilla (no get/set needed)
        this.cifsymtol = 1e-2; // Parameter controlling the tolerance to symmetry when loading CIF files

    }

    /**
     * List of loaded models
     * @readonly
     * @type {Array}
     */
    get modelList() {
        return Object.keys(this._models);
    }

    /**
     * Currently loaded model
     * @readonly
     * @type {Model}
     */
    get model() {
        return this._current_model;
    }

    /**
     * Name of the currently loaded model
     * @readonly
     * @type {String}
     */
    get modelName() {
        return this._current_mname;
    }

    /**
     * Displayed atoms
     * @type {ModelView}
     */
    get displayed() {
        return this._displayed;
    }

    set displayed(d) {
        if (!(d instanceof ModelView)) {
            throw new Error('.displayed must be set with a ModelView');
        }
        this._displayed.hide();
        this._displayed = d;
        this._displayed.show();
    }

    /**
     * Selected atoms
     * @type {ModelView}
     */
    get selected() {
        return this._selected;
    }

    set selected(s) {
        if (!(s instanceof ModelView)) {
            throw new Error('.selected must be set with a ModelView');
        }
        this._selected.setProperty('highlighted', false);
        this._selected = s;
        this._selected.setProperty('highlighted', this._hsel);
    }

    /** Whether the selected atoms should be highlighted with auras
     *  @type {bool} 
     */
    get highlightSelected() {
        return this._hsel;
    }

    set highlightSelected(hs) {
        this._hsel = hs;
        if (this._selected) {
            this._selected.setProperty('highlighted', this._hsel);
        }
    }

    /**
     * Set a callback function for an event where a user clicks on an atom. The
     * function should take as arguments the atom image for the clicked atom and
     * the event object:
     *
     * function callback(atom, event) {
     *     ...
     * }
     *
     * @param  {Function}   callback    Callback function for the event. Passing "null" restores default behaviour
     * @param  {int}        modifiers   Click event. Use the following flags to define it:
     *
     * * CrystVis.LEFT_CLICK
     * * CrystVis.RIGHT_CLICK
     * * CrystVis.MIDDLE_CLICK
     * * CrystVis.CTRL_BUTTON
     * * CrystVis.ALT_BUTTON
     * * CrystVis.SHIFT_BUTTON
     * * CrystVis.CMD_BUTTON
     *
     * For example, CrystVis.LEFT_CLICK + CrystVis.SHIFT_BUTTON
     * defines the event for a click while the Shift key is pressed.
     *                                            
     */
    onAtomClick(callback = null, modifiers = CrystVis.LEFT_CLICK) {

        // Check that event makes sense
        var lc = modifiers & CrystVis.LEFT_CLICK;
        var mc = modifiers & CrystVis.MIDDLE_CLICK;
        var rc = modifiers & CrystVis.RIGHT_CLICK;

        if (lc + mc + rc == 0) {
            throw 'Can not set event without any click type';
        }
        if ((lc && mc) || (lc && rc) || (mc && rc)) {
            throw 'Can not set event with two or more click types';
        }

        if (callback)
            this._atom_click_events[modifiers] = callback.bind(this);
        else
            this._atom_click_events[modifiers] = this._atom_click_defaults[modifiers];
    }

    /**
     * Set a callback function for an event where a user drags a box around multiple atoms. 
     * The function should take as arguments a ModelView including the atoms in the box:
     *
     * function callback(view) {
     *     ...
     * }
     * 
     * @param  {Function} callback Callback function for the event. Passing "null" restores default behaviour
     */
    onAtomBox(callback = null) {
        if (callback)
            this._atom_box_event = callback;
        else
            this._atom_box_event = this._defaultAtomBox.bind(this);
    }

    _defaultAtomLeftClick(atom, event) {
        var i = atom.imgIndex;
        this.selected = new ModelView(this._current_model, [i]);
    }
    _defaultAtomShiftLeftClick(atom, event) {
        var i = atom.imgIndex;
        this.selected = this.selected.or(new ModelView(this._current_model, [i]));
    }
    _defaultAtomCtrlLeftClick(atom, event) {
        var i = atom.imgIndex;
        this.selected = this.selected.xor(new ModelView(this._current_model, [i]));
    }

    _defaultAtomBox(view) {
        this.selected = this.selected.xor(view);
        console.log(view);
    }

    // Callback for when atoms are clicked
    _handleAtomClick(alist, event) {

        if (alist.length == 0) {
            return;
        }

        let clicked = alist[0].image;

        let modifiers = [CrystVis.LEFT_CLICK, CrystVis.MIDDLE_CLICK, CrystVis.RIGHT_CLICK][event.button];

        modifiers += event.shiftKey * CrystVis.SHIFT_BUTTON;
        modifiers += (event.ctrlKey || event.metaKey) * CrystVis.CTRL_BUTTON;
        modifiers += event.altKey * CrystVis.ALT_BUTTON;

        var callback = this._atom_click_events[modifiers];

        if (callback)
            callback(clicked, event);

    }

    // Callback for a whole box dragged over atoms
    _handleAtomBox(alist) {

        var indices = alist.map(function(a) {
            return a.image.imgIndex;
        });

        var callback = this._atom_box_event;

        if (callback)
            callback(new ModelView(this._current_model, indices));
    }

    /**
     * Center the camera on a given point
     * 
     * @param  {float[]}  center Point in model space that the orbiting camera
     *                           should be centred on and look at
     * @param  {float[]}  shift  Shift (in units of width/height of the canvas) with
     *                           which the center of the camera should be rendered with
     *                           respect to the center of the canvas
     */
    centerCamera(center = [0, 0, 0], shift = [0, 0]) {
        const renderer = this._renderer;

        renderer.resetOrbitCenter(center[0], center[1], center[2]);
        renderer.resetCameraCenter(shift[0], shift[1]);
    }

    /**
     * Load one or more atomic models from a file's contents
     * 
     * @param  {String} contents    The contents of the structure file
     * @param  {String} format      The file's format (cif, xyz, etc.). Default is cif.
     * @param  {String} prefix      Prefix to use when naming the models. Default is empty.
     * @param  {Object} parameters  Loading parameters:
     * 
     *  - `supercell`: supercell size (only used if the structure is periodic)
     *  - `molecularCrystal`: if true, try to make the model load completing molecules across periodic boundaries
     *  - `useNMRActiveIsotopes`: if true, all isotopes are set by default to the most common one with non-zero spin
     *  - `vdwScaling`: scale van der Waals radii by a constant factor
     *  - `vdwElementScaling`: table of per-element factors to scale VdW radii by
     *                                          
     * @return {Object}             Names of the models we tried to load, and values of true/false for successful loading or not
     */
    loadModels(contents, format = 'cif', prefix = null, parameters = {}) {

        parameters = _.merge(model_parameter_defaults, parameters);

        // By default, it's cif
        format = format.toLowerCase();

        // By default, same as the format
        prefix = prefix || format;

        var structs = this._loader.load(contents, format, prefix);

        var status = {};

        if (this._loader.status == Loader.STATUS_ERROR) {
            status[prefix] = this._loader.error_message;
            return status;
        }

        // Now make unique names
        for (var n in structs) {
            var iter = 0;
            var coll = true;
            var nn = n;
            while (coll) {
                nn = n + (iter > 0 ? '_' + iter : '');
                coll = nn in this._models;
                iter++;
            }
            var s = structs[n];
            if (!s) {
                status[nn] = 'Model could not load properly';
                continue;
            }
            this._models[nn] = new Model(s, parameters);
            status[nn] = 0; // Success
        }

        return status;
    }

    /**
     * Reload a model, possibly with new parameters
     * 
     * @param  {String} name       Name of the model to reload.
     * @param  {Object} parameters Loading parameters as in .loadModels()
     */
    reloadModel(name, parameters = {}) {

        if (!(name in this._models)) {
            throw 'The requested model does not exist';
        }

        var current = (this._current_mname == name);
        if (current) {
            // Hide the model to reload it later
            this.displayModel();
        }

        var s = this._models[name]._atoms_base;
        parameters = _.merge(model_parameter_defaults, parameters);

        this._models[name] = new Model(s, parameters);

        if (current) {
            this.displayModel(name);
        }
    }

    /**
     * Render a model
     * 
     * @param  {String} name    Name of the model to display. If empty, just
     *                          clear the renderer window.
     */
    displayModel(name = null) {

        if (this._current_model) {
            this.selected = this._current_model.view([]);
            this._current_model.renderer = null;
            this._current_model = null;
            this._current_mname = null;
        }
        this._renderer.clear();

        if (!name) {
            // If called with nothing, just quit here
            return;
        }

        if (!(name in this._models)) {
            throw 'The requested model does not exist';
        }

        var m = this._models[name];
        m.renderer = this._renderer;

        this._current_model = m;
        this._current_mname = name;

        this._displayed = m.find({
            'cell': [
                [0, 0, 0]
            ]
        });
        this._selected = new ModelView(m, []); // Empty

        // Set the camera in a way that will center the model
        var c = m.fracToAbs([0.5, 0.5, 0.5]);
        this._renderer.resetOrbitCenter(c[0], c[1], c[2]);

        this._displayed.show();
    }

    /**
     * Erase a model from the recorded ones
     * 
     * @param  {String} name    Name of the model to delete
     */
    deleteModel(name) {

        if (!(name in this._models)) {
            throw 'The requested model does not exist';
        }

        if (this._current_mname == name) {
            this.displayModel();
        }

        delete this._models[name];
    }

    /**
     * Add a primitive shape to the drawing
     * 
     * @param {THREE.Object3D} p    Primitive to add 
     */
    addPrimitive(p) {
        this._renderer.add(p);
    }

    /**
     * Remove a primitive shape from the drawing
     * 
     * @param {THREE.Object3D} p    Primitive to remove
     */
    removePrimitive(p) {
        this._renderer.remove(p);
    }

    /**
     * Recover a data URL of a PNG screenshot of the current scene
     * 
     * @return {String} A data URL of the PNG screenshot
     */
    getScreenshotData() {
        // Force a render
        this._renderer._render();
        // Grab the data from the canvas
        return this._renderer._r.domElement.toDataURL();
    }
}

addStaticVar(CrystVis, 'LEFT_CLICK', 1);
addStaticVar(CrystVis, 'MIDDLE_CLICK', 2);
addStaticVar(CrystVis, 'RIGHT_CLICK', 4);
addStaticVar(CrystVis, 'ALT_BUTTON', 8);
addStaticVar(CrystVis, 'CTRL_BUTTON', 16);
addStaticVar(CrystVis, 'CMD_BUTTON', 16); // Alias for Mac users
addStaticVar(CrystVis, 'SHIFT_BUTTON', 32);

export {
    CrystVis
}