UNPKG

3dmol

Version:

JavaScript/TypeScript molecular visualization library

1,390 lines (1,214 loc) 193 kB
//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(