3dmol
Version:
JavaScript/TypeScript molecular visualization library
1,390 lines (1,214 loc) • 193 kB
text/typescript
//a molecular viewer based on GLMol
import { decode, encode, toRGBA8 } from 'upng-js';
import { AtomStyleSpec, GLModel, LineStyleSpec } from "./GLModel";
import { ArrowSpec, BoxSpec, CurveSpec, CustomShapeSpec, CylinderSpec, GLShape, IsoSurfaceSpec, LineSpec, ShapeSpec, SphereSpec, splitMesh } from "./GLShape";
import { getGradient, Gradient } from "./Gradient";
import { Label, LabelSpec } from "./Label";
import { ProteinSurface, SurfaceType, syncSurface } from "./ProteinSurface4";
import { VolumeData } from "./VolumeData";
import { GLVolumetricRender, VolumetricRendererSpec } from "./VolumetricRender";
import { Camera, Coloring, Fog, FrontSide, Geometry, Light, Line, LineBasicMaterial, Material, Mesh, MeshDoubleLambertMaterial, MeshLambertMaterial, Object3D, Projector, Raycaster, Renderer, Scene } from "./WebGL";
import { Matrix3, Matrix4, Quaternion, Vector3, XYZ } from "./WebGL/math";
import { CC, ColorschemeSpec, ColorSpec, elementColors } from "./colors";
import { AtomSelectionSpec, AtomSpec } from "./specs";
import { adjustVolumeStyle, extend, getColorFromStyle, getElement, getExtent, getPropertyRange, isEmptyObject, makeFunction, mergeGeos, PausableTimer } from "./utilities";
export const CONTEXTS_PER_VIEWPORT = 16;
interface SurfObj {
geo: Geometry;
mat: Material;
done: Boolean;
finished: Boolean;
lastGL?: any;
symmetries?: any[];
style?: SurfaceStyleSpec;
}
/**
* A surface.
*
* @class
*/
class Surface extends Array<SurfObj> {
style?: SurfaceStyleSpec;
atomsel?: AtomSelectionSpec;
allsel?: AtomSelectionSpec;
focus?: AtomSelectionSpec;
constructor(items: SurfObj[]) {
super(...items); // spread the array elements into the Array constructor
}
/**
* Returns list of rotational/translational matrices if there is BIOMT data
* Otherwise returns a list of just the ID matrix
*
* @return {Array<Matrix4>}
*
*/
public getSymmetries() {
//we assume all sub-objects have same symmetries
if (this.length == 0) return [];
let obj = this[0];
if (typeof (obj.symmetries) == 'undefined') {
this.setSymmetries([new Matrix4()]);
}
return obj.symmetries;
};
/**
* Sets symmetries based on specified matrices in list
*
* @param {Array<Matrix4>} list
*
*/
public setSymmetries(syms) {
if (typeof (syms) == "undefined") { //delete sym data
syms = [new Matrix4()];
}
for (let obj of this) {
obj.symmetries = syms;
obj.finished = false; //trigger redraw
}
};
}
/**
* WebGL-based 3Dmol.js viewer
* Note: The preferred method of instantiating a GLViewer is through {@link createViewer}
*
* @class
*/
export class GLViewer {
// private class variables
private static numWorkers = 4; // number of threads for surface generation
private static maxVolume = 64000; // how much to break up surface calculations
private callback: any;
private defaultcolors: any;
private config: ViewerSpec;
private nomouse = false;
private bgColor: any;
private camerax: number;
private _viewer: GLViewer;
private glDOM: HTMLCanvasElement | null = null;
private models: GLModel[] = []; // atomistic molecular models
private surfaces: Record<number, Surface> = {};
private shapes = []; // Generic shapes
private labels: Label[] = [];
private clickables = []; //things you can click on
private hoverables = []; //things you can hover over
private contextMenuEnabledObjects = []; // atoms and shapes with context menu
private current_hover: any = null;
private hoverDuration = 500;
private longTouchDuration = 1000;
private viewer_frame = 0;
private WIDTH: number;
private HEIGHT: number;
private viewChangeCallback: any = null;
private stateChangeCallback: any = null;
private NEAR = 1;
private FAR = 800;
private CAMERA_Z = 150;
private fov = 20;
private linkedViewers = [];
private renderer: Renderer | null = null;
private row: number;
private col: number;
private cols: number;
private rows: number;
private viewers: any;
private control_all = false;
private ASPECT: any;
private camera: Camera;
private lookingAt: Vector3;
private raycaster: Raycaster;
private projector: Projector;
private scene: any = null;
private rotationGroup: any = null; // which contains modelGroup
private modelGroup: any = null;
private fogStart = 0.4;
private fogEnd = 1.0;
private slabNear = -50; // relative to the center of rotationGroup
private slabFar = 50;
public container: HTMLElement | null;
static readonly surfaceTypeMap = {
"VDW": SurfaceType.VDW,
"MS": SurfaceType.MS,
"SAS": SurfaceType.SAS,
"SES": SurfaceType.SES
};
private cq = new Quaternion(0, 0, 0, 1);
private dq = new Quaternion(0, 0, 0, 1);
private animated = 0;
private animationTimers = new Set<PausableTimer>();
private isDragging = false;
private mouseStartX = 0;
private mouseStartY = 0;
private touchDistanceStart = 0;
private touchHold = false;
private currentModelPos = 0;
private cz = 0;
private cslabNear = 0;
private cslabFar = 0;
private mouseButton: any;
private hoverTimeout: any;
private longTouchTimeout: any;
private divwatcher: any;
private intwatcher: any;
private spinInterval: any;
private getWidth() {
let div = this.container;
//offsetwidth accounts for scaling
let w = div.offsetWidth;
if (w == 0 && div.style.display === 'none') {
let oldpos = div.style.position;
let oldvis = div.style.visibility;
div.style.display = 'block';
div.style.visibility = 'hidden';
div.style.position = 'absolute';
w = div.offsetWidth;
div.style.display = 'none';
div.style.visibility = oldvis;
div.style.position = oldpos;
}
return w;
};
private getHeight() {
let div = this.container;
let h = div.offsetHeight;
if (h == 0 && div.style.display === 'none') {
let oldpos = div.style.position;
let oldvis = div.style.visibility;
div.style.display = 'block';
div.style.visibility = 'hidden';
div.style.position = 'absolute';
h = div.offsetHeight;
div.style.display = 'none';
div.style.visibility = oldvis;
div.style.position = oldpos;
}
return h;
};
private setupRenderer() {
let rendopt = {
...this.config,
preserveDrawingBuffer: true, //so we can export images
premultipliedAlpha: false,/* more traditional compositing with background */
//cannot initialize with zero size - render will start out lost
containerWidth: this.WIDTH,
containerHeight: this.HEIGHT
};
this.renderer = new Renderer(rendopt);
this.renderer.domElement.style.width = "100%";
this.renderer.domElement.style.height = "100%";
this.renderer.domElement.style.padding = "0";
this.renderer.domElement.style.position = "absolute"; //TODO: get rid of this
this.renderer.domElement.style.top = "0px";
this.renderer.domElement.style.left = "0px";
this.renderer.domElement.style.zIndex = "0";
}
private initializeScene() {
this.scene = new Scene();
this.scene.fog = new Fog(this.bgColor, 100, 200);
this.modelGroup = new Object3D();
this.rotationGroup = new Object3D();
this.rotationGroup.useQuaternion = true;
this.rotationGroup.quaternion = new Quaternion(0, 0, 0, 1);
this.rotationGroup.add(this.modelGroup);
this.scene.add(this.rotationGroup);
// setup lights
var directionalLight = new Light(0xFFFFFF);
directionalLight.position = new Vector3(0.2, 0.2, 1)
.normalize();
directionalLight.intensity = 1.0;
this.scene.add(directionalLight);
};
private _handleLostContext(event) {
//when contexts go missing, try to regenerate any that are visible on screen
//but no more than CONTEXTS_PER_VIEWPORT (if this is set higher than the
//browser limit there will be an infinity loop of refreshing contexts of
//too many are on screen)
const isVisible = function (cont) {
const rect = cont.getBoundingClientRect();
return !(
rect.right < 0 ||
rect.bottom < 0 ||
rect.top > (window.innerHeight || document.documentElement.clientHeight) ||
rect.left > (window.innerWidth || document.documentElement.clientWidth)
);
};
if (isVisible(this.container)) {
let restored = 0;
for (let c of document.getElementsByTagName('canvas')) {
if (isVisible(c) && (c as any)._3dmol_viewer != undefined) {
(c as any)._3dmol_viewer.resize();
restored += 1;
if (restored >= CONTEXTS_PER_VIEWPORT) break;
}
}
}
}
private initContainer(element) {
this.container = element;
this.WIDTH = this.getWidth();
this.HEIGHT = this.getHeight();
this.ASPECT = this.renderer.getAspect(this.WIDTH, this.HEIGHT);
this.renderer.setSize(this.WIDTH, this.HEIGHT);
this.container.append(this.renderer.domElement);
this.glDOM = this.renderer.domElement;
(this.glDOM as any)._3dmol_viewer = this;
this.glDOM.addEventListener("webglcontextlost", this._handleLostContext.bind(this));
if (!this.nomouse) {
// user can request that the mouse handlers not be installed
this.glDOM.addEventListener('mousedown', this._handleMouseDown.bind(this), { passive: false });
this.glDOM.addEventListener('touchstart', this._handleMouseDown.bind(this), { passive: false });
this.glDOM.addEventListener('wheel', this._handleMouseScroll.bind(this), { passive: false });
this.glDOM.addEventListener('mousemove', this._handleMouseMove.bind(this), { passive: false });
this.glDOM.addEventListener('touchmove', this._handleMouseMove.bind(this), { passive: false });
this.glDOM.addEventListener("contextmenu", this._handleContextMenu.bind(this), { passive: false });
}
};
private decAnim() {
//decrement the number of animations currently
this.animated--;
if (this.animated < 0) this.animated = 0;
};
private incAnim() {
this.animated++;
};
private nextSurfID() {
//compute the next highest surface id directly from surfaces
//this is necessary to support linking of model data
var max = 0;
for (let i in this.surfaces) { // this is an object with possible holes
if (!this.surfaces.hasOwnProperty(i)) continue;
var val = parseInt(i);
if (!isNaN(val)) {
if (val > max)
max = val;
}
}
return max + 1;
};
private setSlabAndFog() {
let center = this.camera.position.z - this.rotationGroup.position.z;
if (center < 1)
center = 1;
this.camera.near = center + this.slabNear;
if (!this.camera.ortho && this.camera.near < 1)
this.camera.near = 1;
this.camera.far = center + this.slabFar;
if (this.camera.near + 1 > this.camera.far)
this.camera.far = this.camera.near + 1;
this.camera.fov = this.fov;
this.camera.right = center * Math.tan(Math.PI / 180 * this.fov);
this.camera.left = -this.camera.right;
this.camera.top = this.camera.right / this.ASPECT;
this.camera.bottom = -this.camera.top;
this.camera.updateProjectionMatrix();
this.scene.fog.near = this.camera.near + this.fogStart * (this.camera.far - this.camera.near);
this.scene.fog.far = this.camera.near + this.fogEnd * (this.camera.far - this.camera.near);
if (this.config.disableFog) {
this.scene.fog.near = this.scene.fog.far;
}
};
// display scene
//if nolink is set/true, don't propagate changes to linked viewers
private show(nolink?) {
this.renderer.setViewport();
if (!this.scene)
return;
//let time = new Date();
this.setSlabAndFog();
this.renderer.render(this.scene, this.camera);
//console.log("rendered in " + (+new Date() - (time as any)) + "ms");
//have any scene change trigger a callback
if (this.viewChangeCallback) this.viewChangeCallback(this._viewer.getView());
if (!nolink && this.linkedViewers.length > 0) {
var view = this._viewer.getView();
for (var i = 0; i < this.linkedViewers.length; i++) {
var other = this.linkedViewers[i];
other.setView(view, true);
}
}
};
//regenerate the list of clickables
//also updates hoverables
private updateClickables() {
this.clickables.splice(0, this.clickables.length);
this.hoverables.splice(0, this.hoverables.length);
this.contextMenuEnabledObjects.splice(0, this.contextMenuEnabledObjects.length);
for (let i = 0, il = this.models.length; i < il; i++) {
let model = this.models[i];
if (model) {
let atoms = model.selectedAtoms({
clickable: true
});
let hoverable_atoms = model.selectedAtoms({
hoverable: true
});
let contextMenuEnabled_atom = model.selectedAtoms({ contextMenuEnabled: true });
// Array.prototype.push.apply(hoverables,hoverable_atoms);
for (let n = 0; n < hoverable_atoms.length; n++) {
this.hoverables.push(hoverable_atoms[n]);
}
// Array.prototype.push.apply(clickables, atoms); //add atoms into clickables
for (let m = 0; m < atoms.length; m++) {
this.clickables.push(atoms[m]);
}
// add atoms into contextMenuEnabledObjects
for (let m = 0; m < contextMenuEnabled_atom.length; m++) {
this.contextMenuEnabledObjects.push(contextMenuEnabled_atom[m]);
}
}
}
for (let i = 0, il = this.shapes.length; i < il; i++) {
let shape = this.shapes[i];
if (shape && shape.clickable) {
this.clickables.push(shape);
}
if (shape && shape.hoverable) {
this.hoverables.push(shape);
}
if (shape && shape.contextMenuEnabled) {
this.contextMenuEnabledObjects.push(shape);
}
}
};
// Checks for selection intersects on mousedown
private handleClickSelection(mouseX: number, mouseY: number, event) {
let intersects = this.targetedObjects(mouseX, mouseY, this.clickables);
// console.log('handleClickSelection', mouseX, mouseY, intersects);
if (intersects.length) {
var selected = intersects[0].clickable;
if (selected.callback !== undefined) {
if (typeof (selected.callback) != "function") {
selected.callback = makeFunction(selected.callback);
}
if (typeof (selected.callback) === "function") {
// Suppress click callbacks when context menu will be invoked.
// This only applies to clicks from "mouseup" events after right-click.
// Clicks from "touchend" after longtouch contextmenu are suppressed
// in _handleContextMenu.
const isContextMenu = this.mouseButton === 3
&& this.contextMenuEnabledObjects.includes(selected)
&& this.userContextMenuHandler;
if (!isContextMenu) {
selected.callback(selected, this._viewer, event, this.container, intersects);
}
}
}
}
};
//return offset of container
private canvasOffset() {
let canvas = this.glDOM;
let rect = canvas.getBoundingClientRect();
let doc = canvas.ownerDocument;
let docElem = doc.documentElement;
let win = doc.defaultView;
return {
top: rect.top + win.pageYOffset - docElem.clientTop,
left: rect.left + win.pageXOffset - docElem.clientLeft
};
};
//set current_hover to sel (which can be null), calling appropraite callbacks
private setHover(selected, event?, intersects?) {
if (this.current_hover == selected) return;
if (this.current_hover) {
if (typeof (this.current_hover.unhover_callback) != "function") {
this.current_hover.unhover_callback = makeFunction(this.current_hover.unhover_callback);
}
this.current_hover.unhover_callback(this.current_hover, this._viewer, event, this.container, intersects);
}
this.current_hover = selected;
if (selected && selected.hover_callback !== undefined) {
if (typeof (selected.hover_callback) != "function") {
selected.hover_callback = makeFunction(selected.hover_callback);
}
if (typeof (selected.hover_callback) === "function") {
selected.hover_callback(selected, this._viewer, event, this.container, intersects);
}
}
};
//checks for selection intersects on hover
private handleHoverSelection(mouseX, mouseY, event) {
if (this.hoverables.length == 0) return;
let intersects = this.targetedObjects(mouseX, mouseY, this.hoverables);
if (intersects.length) {
var selected = intersects[0].clickable;
this.setHover(selected, event, intersects);
this.current_hover = selected;
}
else {
this.setHover(null);
}
};
//sees if the mouse is still on the object that invoked a hover event and if not then the unhover callback is called
private handleHoverContinue(mouseX: number, mouseY: number) {
let intersects = this.targetedObjects(mouseX, mouseY, this.hoverables);
if (intersects.length == 0 || intersects[0] === undefined) {
this.setHover(null);
}
if (intersects[0] !== undefined && intersects[0].clickable !== this.current_hover) {
this.setHover(null);
}
};
/**
* Determine if a positioned event is "close enough" to mouseStart to be considered a click.
* With a mouse, the position should be exact, but allow a slight delta for a touch interface.
* @param {Event} event
* @param {{ allowTolerance, tolerance: number }} options
*/
private closeEnoughForClick(event, { allowTolerance = event.targetTouches, tolerance = 5 } = {}) {
const x = this.getX(event);
const y = this.getY(event);
if (allowTolerance) {
const deltaX = Math.abs(x - this.mouseStartX);
const deltaY = Math.abs(y - this.mouseStartY);
return deltaX <= tolerance && deltaY <= tolerance;
} else {
return x === this.mouseStartX && y === this.mouseStartY;
}
}
private calcTouchDistance(ev) { // distance between first two
// fingers
var xdiff = ev.targetTouches[0].pageX -
ev.targetTouches[1].pageX;
var ydiff = ev.targetTouches[0].pageY -
ev.targetTouches[1].pageY;
return Math.hypot(xdiff, ydiff);
};
//check targetTouches as well
private getX(ev) {
var x = ev.pageX;
if (x == undefined) x = ev.pageX; //firefox
if (ev.targetTouches &&
ev.targetTouches[0]) {
x = ev.targetTouches[0].pageX;
}
else if (ev.changedTouches &&
ev.changedTouches[0]) {
x = ev.changedTouches[0].pageX;
}
return x;
};
private getY(ev) {
var y = ev.pageY;
if (y == undefined) y = ev.pageY;
if (ev.targetTouches &&
ev.targetTouches[0]) {
y = ev.targetTouches[0].pageY;
}
else if (ev.changedTouches &&
ev.changedTouches[0]) {
y = ev.changedTouches[0].pageY;
}
return y;
};
//for grid viewers, return true if point is in this viewer
private isInViewer(x: number, y: number) {
if (this.viewers != undefined) {
var width = this.WIDTH / this.cols;
var height = this.HEIGHT / this.rows;
var offset = this.canvasOffset();
var relx = (x - offset.left);
var rely = (y - offset.top);
var r = this.rows - Math.floor(rely / height) - 1;
var c = Math.floor(relx / width);
if (r != this.row || c != this.col)
return false;
}
return true;
};
//if the user has specify zoom limits, readjust to fit within them
//also, make sure we don't go past CAMERA_Z
private adjustZoomToLimits(z: number) {
//a lower limit of 0 is at CAMERA_Z
if (this.config.lowerZoomLimit && this.config.lowerZoomLimit > 0) {
let lower = this.CAMERA_Z - this.config.lowerZoomLimit;
if (z > lower) z = lower;
}
if (this.config.upperZoomLimit && this.config.upperZoomLimit > 0) {
let upper = this.CAMERA_Z - this.config.upperZoomLimit;
if (z < upper) z = upper;
}
if (z > this.CAMERA_Z - 1) {
z = this.CAMERA_Z - 1; //avoid getting stuck
}
return z;
};
//interpolate between two normalized quaternions (t between 0 and 1)
//https://en.wikipedia.org/wiki/Slerp
private static slerp(v0: Quaternion, v1: Quaternion, t: number) {
// Compute the cosine of the angle between the two vectors.
//dot product
if (t == 1) return v1.clone();
else if (t == 0) return v0.clone();
let dot = v0.x * v1.x + v0.y * v1.y + v0.z * v1.z + v0.w * v1.w;
if (dot > 0.9995) {
// If the inputs are too close for comfort, linearly interpolate
// and normalize the result.
let result = new Quaternion(
v0.x + t * (v1.x - v0.x),
v0.y + t * (v1.y - v0.y),
v0.z + t * (v1.z - v0.z),
v0.w + t * (v1.w - v0.w));
result.normalize();
return result;
}
// If the dot product is negative, the quaternions
// have opposite handed-ness and slerp won't take
// the shorted path. Fix by reversing one quaternion.
if (dot < 0.0) {
v1 = v1.clone().multiplyScalar(-1);
dot = -dot;
}
if (dot > 1) dot = 1.0;
else if (dot < -1) dot = -1.0;
var theta_0 = Math.acos(dot); // theta_0 = angle between input vectors
var theta = theta_0 * t; // theta = angle between v0 and result
var v2 = v1.clone();
v2.sub(v0.clone().multiplyScalar(dot));
v2.normalize(); // { v0, v2 } is now an orthonormal basis
var c = Math.cos(theta);
var s = Math.sin(theta);
var ret = new Quaternion(
v0.x * c + v2.x * s,
v0.y * c + v2.y * s,
v0.z * c + v2.z * s,
v0.w * c + v2.w * s
);
ret.normalize();
return ret;
};
/* @param {Object} element HTML element within which to create viewer
* @param {ViewerSpec} config Object containing optional configuration for the viewer
*/
constructor(element, c: ViewerSpec = {}) {
// set variables
this.config = c;
this.callback = this.config.callback;
this.defaultcolors = this.config.defaultcolors;
if (!this.defaultcolors)
this.defaultcolors = elementColors.defaultColors;
this.nomouse = Boolean(this.config.nomouse);
this.bgColor = 0;
this.config.backgroundColor = this.config.backgroundColor || "#ffffff";
if (typeof (this.config.backgroundColor) != 'undefined') {
this.bgColor = CC.color(this.config.backgroundColor).getHex();
}
this.config.backgroundAlpha = this.config.backgroundAlpha == undefined ? 1.0 : this.config.backgroundAlpha;
this.camerax = 0;
if (typeof (this.config.camerax) != 'undefined') {
this.camerax = typeof (this.config.camerax) === 'string' ? parseFloat(this.config.camerax) : this.config.camerax;
}
this._viewer = this;
this.container = element; //we expect container to be HTMLElement
if (this.config.hoverDuration != undefined) {
this.hoverDuration = this.config.hoverDuration;
}
if (this.config.antialias === undefined) this.config.antialias = true;
if (this.config.cartoonQuality === undefined) this.config.cartoonQuality = 10;
this.WIDTH = this.getWidth();
this.HEIGHT = this.getHeight();
this.setupRenderer();
this.row = this.config.row == undefined ? 0 : this.config.row;
this.col = this.config.col == undefined ? 0 : this.config.col;
this.cols = this.config.cols;
this.rows = this.config.rows;
this.viewers = this.config.viewers;
this.control_all = this.config.control_all;
this.ASPECT = this.renderer.getAspect(this.WIDTH, this.HEIGHT);
this.camera = new Camera(this.fov, this.ASPECT, this.NEAR, this.FAR, this.config.orthographic);
this.camera.position = new Vector3(this.camerax, 0, this.CAMERA_Z);
this.lookingAt = new Vector3();
this.camera.lookAt(this.lookingAt);
this.raycaster = new Raycaster(new Vector3(0, 0, 0), new Vector3(0, 0, 0));
this.projector = new Projector();
this.initializeScene();
this.renderer.setClearColorHex(this.bgColor, this.config.backgroundAlpha);
this.scene.fog.color = CC.color(this.bgColor);
// this event is bound to the body element, not the container,
// so no need to put it inside initContainer()
document.body.addEventListener('mouseup', this._handleMouseUp.bind(this));
document.body.addEventListener('touchend', this._handleMouseUp.bind(this));
this.initContainer(this.container);
if (this.config.style) { //enable setting style in constructor
this.setViewStyle(this.config as ViewStyle);
}
window.addEventListener("resize", this.resize.bind(this));
if (typeof (window.ResizeObserver) !== "undefined") {
this.divwatcher = new window.ResizeObserver(this.resize.bind(this));
this.divwatcher.observe(this.container);
}
if (typeof (window.IntersectionObserver) !== "undefined") {
//make sure a viewer that is becoming visible is alive
let intcallback = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.resize();
}
});
};
this.intwatcher = new window.IntersectionObserver(intcallback);
this.intwatcher.observe(this.container);
}
try {
if (typeof (this.callback) === "function")
this.callback(this);
} catch (e) {
// errors in callback shouldn't invalidate the viewer
console.log("error with glviewer callback: " + e);
}
};
/**
* Return a list of objects that intersect that at the specified viewer position.
*
* @param x - x position in screen coordinates
* @param y - y position in screen coordinates
* @param {Object[]} - list of objects or selection object specifying what object to check for targeting
*/
public targetedObjects(x: number, y: number, objects) {
var mouse = {
x: x,
y: y,
z: -1.0
};
if (!Array.isArray(objects)) { //assume selection object
objects = this.selectedAtoms(objects);
}
if (objects.length == 0) return [];
this.raycaster.setFromCamera(mouse, this.camera);
return this.raycaster.intersectObjects(this.modelGroup, objects);
};
/** Convert model coordinates to screen coordinates.
* @param {object | list} - an object or list of objects with x,y,z attributes (e.g. an atom)
* @return {object | list} - and object or list of {x: screenX, y: screenY}
*/
public modelToScreen(coords) {
let returnsingle = false;
if (!Array.isArray(coords)) {
coords = [coords];
returnsingle = true;
}
let ratioX = this.renderer.getXRatio();
let ratioY = this.renderer.getYRatio();
let col = this.col;
let row = this.row;
let viewxoff = col * (this.WIDTH / ratioX);
//row is from bottom
let viewyoff = (ratioY - row - 1) * (this.HEIGHT / ratioY);
let results = [];
let offset = this.canvasOffset();
coords.forEach(coord => {
let t = new Vector3(coord.x, coord.y, coord.z);
t.applyMatrix4(this.modelGroup.matrixWorld);
this.projector.projectVector(t, this.camera);
let screenX = (this.WIDTH / ratioX) * (t.x + 1) / 2.0 + offset.left + viewxoff;
let screenY = -(this.HEIGHT / ratioY) * (t.y - 1) / 2.0 + offset.top + viewyoff;
results.push({ x: screenX, y: screenY });
});
if (returnsingle) results = results[0];
return results;
};
/**
* For a given screen (x,y) displacement return model displacement
* @param{x} x displacement in screen coordinates
* @param{y} y displacement in screen corodinates
* @param{modelz} z coordinate in model coordinates to compute offset for, default is model axis
*/
public screenOffsetToModel(x: number, y: number, modelz?) {
var dx = x / this.WIDTH;
var dy = y / this.HEIGHT;
var zpos = (modelz === undefined ? this.rotationGroup.position.z : modelz);
var q = this.rotationGroup.quaternion;
var t = new Vector3(0, 0, zpos);
this.projector.projectVector(t, this.camera);
t.x += dx * 2;
t.y -= dy * 2;
this.projector.unprojectVector(t, this.camera);
t.z = 0;
t.applyQuaternion(q);
return t;
};
/**
* Distance from screen coordinate to model coordinate assuming screen point
* is projected to the same depth as model coordinate
* @param{screen} xy screen coordinate
* @param{model} xyz model coordinate
*/
public screenToModelDistance(screen: XYZ, model) {
let offset = this.canvasOffset();
//convert model to screen to get screen z
let mvec = new Vector3(model.x, model.y, model.z);
mvec.applyMatrix4(this.modelGroup.matrixWorld);
let m = mvec.clone();
this.projector.projectVector(mvec, this.camera);
let t = new Vector3((screen.x - offset.left) * 2 / this.WIDTH - 1, (screen.y - offset.top) * 2 / -this.HEIGHT + 1, mvec.z);
this.projector.unprojectVector(t, this.camera);
return t.distanceTo(m);
};
/**
* Set a callback to call when the view has potentially changed.
*
*/
public setViewChangeCallback(callback) {
if (typeof (callback) === 'function' || callback == null)
this.viewChangeCallback = callback;
};
/**
* Set a callback to call when the view has potentially changed.
*
*/
public setStateChangeCallback(callback) {
if (typeof (callback) === 'function' || callback == null)
this.stateChangeCallback = callback;
};
/**
* Return configuration of viewer
*/
public getConfig() {
return this.config;
};
/**
* Set the configuration object. Note that some settings may only
* have an effect at viewer creation time.
*/
public setConfig(c: ViewerSpec) {
this.config = c;
if (c.ambientOcclusion) {
this.renderer.enableAmbientOcclusion(c.ambientOcclusion);
}
};
/**
* Return object representing internal state of
* the viewer appropriate for passing to setInternalState
*
*/
public getInternalState() {
var ret = { 'models': [], 'surfaces': [], 'shapes': [], 'labels': [] };
for (let i = 0; i < this.models.length; i++) {
if (this.models[i]) {
ret.models[i] = this.models[i].getInternalState();
}
}
//todo: labels, shapes, surfaces
return ret;
};
/**
* Overwrite internal state of the viewer with passed object
* which should come from getInternalState.
*
*/
public setInternalState(state) {
//clear out current viewer
this.clear();
//set model state
var newm = state.models;
for (let i = 0; i < newm.length; i++) {
if (newm[i]) {
this.models[i] = new GLModel(i, undefined, this);
this.models[i].setInternalState(newm[i]);
}
}
//todo: labels, shapes, surfaces
this.render();
};
/**
* Set lower and upper limit stops for zoom.
*
* @param {lower} - limit on zoom in (positive number). Default 0.
* @param {upper} - limit on zoom out (positive number). Default infinite.
* @example
$3Dmol.get("data/set1_122_complex.mol2", function(moldata) {
var m = viewer.addModel(moldata);
viewer.setStyle({stick:{colorscheme:"Jmol"}});
viewer.setZoomLimits(100,200);
viewer.zoomTo();
viewer.zoom(10); //will not zoom all the way
viewer.render();
});
*/
public setZoomLimits(lower, upper) {
if (typeof (lower) !== 'undefined') this.config.lowerZoomLimit = lower;
if (upper) this.config.upperZoomLimit = upper;
this.rotationGroup.position.z = this.adjustZoomToLimits(this.rotationGroup.position.z);
this.show();
};
/**
* Set camera parameters (distance to the origin and field of view)
*
* @param {parameters} - new camera parameters, with possible fields
* being fov for the field of view, z for the
* distance to the origin, and orthographic (boolean)
* for kind of projection (default false).
* @example
$3Dmol.get("data/set1_122_complex.mol2", function(data) {
var m = viewer.addModel(data);
viewer.setStyle({stick:{}});
viewer.zoomTo();
viewer.setCameraParameters({ fov: 10 , z: 300 });
viewer.render();
});
*/
public setCameraParameters(parameters) {
if (parameters.fov !== undefined) {
this.fov = parameters.fov;
this.camera.fov = this.fov;
}
if (parameters.z !== undefined) {
this.CAMERA_Z = parameters.z;
this.camera.z = this.CAMERA_Z;
}
if (parameters.orthographic !== undefined) {
this.camera.ortho = parameters.orthographic;
}
this.setSlabAndFog();
};
public _handleMouseDown(ev) {
ev.preventDefault();
if (!this.scene)
return;
var x = this.getX(ev);
var y = this.getY(ev);
if (x === undefined)
return;
this.isDragging = true;
this.mouseButton = ev.which;
this.mouseStartX = x;
this.mouseStartY = y;
this.touchHold = true;
this.touchDistanceStart = 0;
if (ev.targetTouches &&
ev.targetTouches.length == 2) {
this.touchDistanceStart = this.calcTouchDistance(ev);
}
this.cq = this.rotationGroup.quaternion.clone();
this.cz = this.rotationGroup.position.z;
this.currentModelPos = this.modelGroup.position.clone();
this.cslabNear = this.slabNear;
this.cslabFar = this.slabFar;
let self = this;
if (ev.targetTouches && ev.targetTouches.length === 1) {
this.longTouchTimeout = setTimeout(function () {
if (self.touchHold == true) {
// console.log('Touch hold', x,y);
self.glDOM = self.renderer.domElement;
const touch = ev.targetTouches[0];
const newEvent = new PointerEvent('contextmenu', {
...ev,
pageX: touch.pageX, pageY: touch.pageY,
screenX: touch.screenX, screenY: touch.screenY,
clientX: touch.clientX, clientY: touch.clientY,
});
self.glDOM.dispatchEvent(newEvent);
}
else {
// console.log('Touch hold ended earlier');
}
}, this.longTouchDuration);
}
};
public _handleMouseUp(ev) {
// handle touch
this.touchHold = false;
// handle selection
if (this.isDragging && this.scene) { //saw mousedown, haven't moved
var x = this.getX(ev);
var y = this.getY(ev);
if (this.closeEnoughForClick(ev) && this.isInViewer(x, y)) {
let mouse = this.mouseXY(x, y);
this.handleClickSelection(mouse.x, mouse.y, ev);
}
}
this.isDragging = false;
}
public _handleMouseScroll(ev) { // Zoom
ev.preventDefault();
if (!this.scene)
return;
var x = this.getX(ev);
var y = this.getY(ev);
if (x === undefined)
return;
if (!this.control_all && !this.isInViewer(x, y)) {
return;
}
var scaleFactor = (this.CAMERA_Z - this.rotationGroup.position.z) * 0.85;
var mult = 1.0;
if (ev.ctrlKey) {
mult = -1.0; //this is a pinch event turned into a wheel event (or they're just holding down the ctrl)
}
if (ev.detail) {
this.rotationGroup.position.z += mult * scaleFactor * ev.detail / 10;
} else if (ev.wheelDelta) {
//dampen the wheelDelta since some browser/OS/mouse combinations can be quite large
let wd = ev.wheelDelta * 600 / (ev.wheelDelta + 600);
this.rotationGroup.position.z -= mult * scaleFactor * wd / 400;
}
this.rotationGroup.position.z = this.adjustZoomToLimits(this.rotationGroup.position.z);
this.show();
};
/**
* Return image URI of viewer contents (base64 encoded). *
*/
public pngURI() {
return this.getCanvas().toDataURL('image/png');
};
/**
* Return a promise that resolves to an animated PNG image URI of
viewer contents (base64 encoded) for nframes of viewer changes.
* @return {Promise}
*/
public apngURI(nframes: number) {
let viewer = this;
nframes = nframes ? nframes : 1;
return new Promise(function (resolve) {
let framecnt = 0;
let oldcb = viewer.viewChangeCallback;
let bufpromise = [];
let delays = [];
let lasttime = Date.now();
viewer.viewChangeCallback = function () {
delays.push(Date.now() - lasttime);
lasttime = Date.now();
bufpromise.push(new Promise(resolve => {
viewer.getCanvas().toBlob(function (blob) {
blob.arrayBuffer().then(resolve);
}, "image/png");
}));
framecnt += 1;
if (framecnt == nframes) {
viewer.viewChangeCallback = oldcb;
Promise.all(bufpromise).then((buffers) => {
//convert to apng
let rgbas = [];
//have to convert png to rgba, before creating the apng
for (let i = 0; i < buffers.length; i++) {
let img = decode(buffers[i]);
rgbas.push(toRGBA8(img)[0]);
}
let width = viewer.getCanvas().width;
let height = viewer.getCanvas().height;
let apng = encode(rgbas, width, height, 0, delays);
let blob = new Blob([apng], { type: 'image/png' });
let fr = new FileReader();
fr.onload = function (e) {
resolve(e.target.result);
};
fr.readAsDataURL(blob);
});
}
};
});
};
/**
* Return underlying canvas element.
*/
public getCanvas(): HTMLCanvasElement {
return this.glDOM;
};
/**
* Return renderer element.
*/
public getRenderer() {
return this.renderer;
};
/**
* Set the duration of the hover delay
*
* @param {number}
* [hoverDuration] - an optional parameter that denotes
* the duration of the hover delay (in milliseconds) before the hover action is called
*
*/
public setHoverDuration(duration?: number) {
this.hoverDuration = duration;
};
private mouseXY(x, y) {
//convert to -1..1 coordinates
let offset = this.canvasOffset();
let ratioX = this.renderer.getXRatio();
let ratioY = this.renderer.getYRatio();
let col = this.col;
let row = this.row;
let viewxoff = col * (this.WIDTH / ratioX);
//row is from bottom
let viewyoff = (ratioY - row - 1) * (this.HEIGHT / ratioY);
let mouseX = ((x - offset.left - viewxoff) / (this.WIDTH / ratioX)) * 2 - 1;
let mouseY = -((y - offset.top - viewyoff) / (this.HEIGHT / ratioY)) * 2 + 1;
return { x: mouseX, y: mouseY };
}
public _handleMouseMove(ev) { // touchmove
clearTimeout(this.hoverTimeout);
ev.preventDefault();
let x = this.getX(ev);
let y = this.getY(ev);
if (x === undefined)
return;
let ratioX = this.renderer.getXRatio();
let ratioY = this.renderer.getYRatio();
let mouse = this.mouseXY(x, y);
let self = this;
// hover timeout
if (this.current_hover !== null) {
this.handleHoverContinue(mouse.x, mouse.y);
}
var mode = 0;
if (!this.control_all && !this.isInViewer(x, y)) {
return;
}
if (!this.scene)
return;
if (this.hoverables.length > 0) {
this.hoverTimeout = setTimeout(
function () {
self.handleHoverSelection(mouse.x, mouse.y, ev);
},
this.hoverDuration);
}
if (!this.isDragging)
return;
// Cancel longtouch timer to avoid invoking context menu if dragged away from start
if (ev.targetTouches && (ev.targetTouches.length > 1 ||
(ev.targetTouches.length === 1 && !this.closeEnoughForClick(ev)))) {
clearTimeout(this.longTouchTimeout);
}
var dx = (x - this.mouseStartX) / this.WIDTH;
var dy = (y - this.mouseStartY) / this.HEIGHT;
// check for pinch
if (this.touchDistanceStart != 0 &&
ev.targetTouches &&
ev.targetTouches.length == 2) {
var newdist = this.calcTouchDistance(ev);
// change to zoom
mode = 2;
dy = (newdist - this.touchDistanceStart) * 2 / (this.WIDTH + this.HEIGHT);
} else if (ev.targetTouches &&
ev.targetTouches.length == 3) {
// translate
mode = 1;
}
dx *= ratioX;
dy *= ratioY;
var r = Math.hypot(dx, dy);
var scaleFactor;
if (mode == 3 || (this.mouseButton == 3 && ev.ctrlKey)) { // Slab
this.slabNear = this.cslabNear + dx * 100;
this.slabFar = this.cslabFar - dy * 100;
} else if (mode == 2 || this.mouseButton == 3 || ev.shiftKey) { // Zoom
scaleFactor = (this.CAMERA_Z - this.rotationGroup.position.z) * 0.85;
if (scaleFactor < 80)
scaleFactor = 80;
this.rotationGroup.position.z = this.cz + dy * scaleFactor;
this.rotationGroup.position.z = this.adjustZoomToLimits(this.rotationGroup.position.z);
} else if (mode == 1 || this.mouseButton == 2 || ev.ctrlKey) { // Translate
var t = this.screenOffsetToModel(ratioX * (x - this.mouseStartX), ratioY * (y - this.mouseStartY));
this.modelGroup.position.addVectors(this.currentModelPos, t);
} else if ((mode === 0 || this.mouseButton == 1) && r !== 0) { // Rotate
var rs = Math.sin(r * Math.PI) / r;
this.dq.x = Math.cos(r * Math.PI);
this.dq.y = 0;
this.dq.z = rs * dx;
this.dq.w = -rs * dy;
this.rotationGroup.quaternion.set(1, 0, 0, 0);
this.rotationGroup.quaternion.multiply(this.dq);
this.rotationGroup.quaternion.multiply(this.cq);
}
this.show();
};
/** User specified function for handling a context menu event.
* Handler is passed the selected object, x and y in canvas coordinates,
* and original event.
*/
public userContextMenuHandler: Function | null = null;
public _handleContextMenu(ev) {
ev.preventDefault();
if (this.closeEnoughForClick(ev)) {
var x = this.mouseStartX;
var y = this.mouseStartY;
var offset = this.canvasOffset();
let mouse = this.mouseXY(x, y);
let mouseX = mouse.x;
let mouseY = mouse.y;
let intersects = this.targetedObjects(mouseX, mouseY, this.contextMenuEnabledObjects);
var selected = null;
if (intersects.length) {
selected = intersects[0].clickable;
}
var offset = this.canvasOffset();
var x = this.mouseStartX - offset.left;
var y = this.mouseStartY - offset.top;
if (this.userContextMenuHandler) {
this.userContextMenuHandler(selected, x, y, intersects, ev);
// We've processed this as a context menu evt; ignore further mouseup / touchend.
// This is really for touchend after longtouch, since the mouseup for right-click
// occurs before the contextmenu event.
this.isDragging = false;
}
}
};
/**
* Change the viewer's container element
* Also useful if the original container element was removed from the DOM.
*
* @param {Object | string} element
* Either HTML element or string identifier. Defaults to the element used to initialize the viewer.
*/
public setContainer(element) {
let elem = getElement(element) || this.container;
this.initContainer(elem);
return this;
};
/**
* Set the background color (default white)
*
* @param {number}
* hex Hexcode specified background color, or standard color spec
* @param {number}
* a Alpha level (default 1.0)
*
* @example
*
* viewer.setBackgroundColor("green",0.5);
*
*/
public setBackgroundColor(hex: ColorSpec, a: number) {
if (typeof (a) == "undefined") {
a = 1.0;
}
else if (a < 0 || a > 1.0) {
a = 1.0;
}
var c = CC.color(hex);
this.scene.fog.color = c;
this.bgColor = c.getHex();
this.renderer.setClearColorHex(c.getHex(), a);
this.show();
return this;
};
/**
* Set view projection scheme. Either orthographic or perspective.
* Default is perspective. Orthographic can also be enabled on viewer creation
* by setting orthographic to true in the config object.
*
*
* @example
viewer.setViewStyle({style:"outline"});
$3Dmol.get('data/1fas.pqr', function(data){
viewer.addModel(data, "pqr");
$3Dmol.get("data/1fas.cube",function(volumedata){
viewer.addSurface($3Dmol.SurfaceType.VDW, {opacity:0.85,voldata: new $3Dmol.VolumeData(volumedata, "cube"), volscheme: new $3Dmol.Gradient.RWB(-10,10)},{});
});
viewer.zoomTo();
viewer.setProjection("orthographic");
viewer.render(callback);
});
*
*/
public setProjection(proj) {
this.camera.ortho = (proj === "orthographic");
this.setSlabAndFog();
};
/**
* Set global view styles.
*
* @example
* viewer.setViewStyle({style:"outline"});
$3Dmol.get('data/1fas.pqr', function(data){
viewer.addModel(