uglymol
Version:
Macromolecular Viewer for Crystallographers
1,521 lines (1,419 loc) • 58.4 kB
text/typescript
import { OrthographicCamera, Scene, AmbientLight, Color, Vector3,
Ray, WebGLRenderer, Fog } from './uthree/main';
import { makeLineMaterial, makeLineSegments, makeRibbon,
makeChickenWire, makeGrid, makeSticks, makeBalls, makeWheels, makeCube,
makeRgbBox, Label, addXyzCross } from './draw';
import { STATE, Controls } from './controls';
import { ElMap } from './elmap';
import { modelsFromPDB, modelsFromGemmi } from './model';
import type { Atom, Model } from './model';
import type { LineSegments } from './uthree/main';
import type { OrCameraType } from './controls';
type Num2 = [number, number];
type Num3 = [number, number, number];
type ColorScheme = {
bg: Color,
fg: Color,
map_den?: Color,
map_pos?: Color,
map_neg?: Color,
center?: Color,
H?: Color,
C?: Color,
N?: Color,
O?: Color,
S?: Color,
P?: Color,
MG?: Color,
CL?: Color,
CA?: Color,
MN?: Color,
FE?: Color,
NI?: Color,
def?: Color,
};
export type ViewerConfig = {
bond_line: number,
map_line: number,
map_radius: number,
max_map_radius: number,
default_isolevel: number,
center_cube_size: number,
map_style: string,
render_style: string,
ligand_style: string,
water_style: string,
color_prop: string,
line_style: string,
label_font: string,
color_scheme: string,
colors?: ColorScheme,
hydrogens: boolean,
ball_size: number,
stay?: boolean;
};
const ColorSchemes: Record<string, ColorScheme> = {
// the default scheme that generally mimicks Coot
'coot dark': {
bg: new Color(0x000000),
fg: new Color(0xFFFFFF),
map_den: new Color(0x3362B2),
map_pos: new Color(0x298029),
map_neg: new Color(0x8B2E2E),
center: new Color(0xC997B0),
// atoms
H: new Color(0x858585), // H is normally invisible
// C, N and O are taken approximately (by color-picker) from coot
C: new Color(0xb3b300),
N: new Color(0x7EAAFB),
O: new Color(0xF24984),
S: new Color(0x40ff40), // S in coot is too similar to C, here it's greener
// Coot doesn't define other colors (?)
MG: new Color(0xc0c0c0),
P: new Color(0xffc040),
CL: new Color(0xa0ff60),
CA: new Color(0xffffff),
MN: new Color(0xff90c0),
FE: new Color(0xa03000),
NI: new Color(0x00ff80),
def: new Color(0xa0a0a0), // default atom color
},
// scheme made of "solarized" colors (http://ethanschoonover.com/solarized):
// base03 base02 base01 base00 base0 base1 base2 base3
// #002b36 #073642 #586e75 #657b83 #839496 #93a1a1 #eee8d5 #fdf6e3
// yellow orange red magenta violet blue cyan green
// #b58900 #cb4b16 #dc322f #d33682 #6c71c4 #268bd2 #2aa198 #859900
'solarized dark': {
bg: new Color(0x002b36),
fg: new Color(0xfdf6e3),
map_den: new Color(0x268bd2),
map_pos: new Color(0x859900),
map_neg: new Color(0xd33682),
center: new Color(0xfdf6e3),
H: new Color(0x586e75),
C: new Color(0x93a1a1),
N: new Color(0x6c71c4),
O: new Color(0xcb4b16),
S: new Color(0xb58900),
def: new Color(0xeee8d5),
},
'solarized light': {
bg: new Color(0xfdf6e3),
fg: new Color(0x002b36),
map_den: new Color(0x268bd2),
map_pos: new Color(0x859900),
map_neg: new Color(0xd33682),
center: new Color(0x002b36),
H: new Color(0x93a1a1),
C: new Color(0x586e75),
N: new Color(0x6c71c4),
O: new Color(0xcb4b16),
S: new Color(0xb58900),
def: new Color(0x073642),
},
// like in Coot after Edit > Background Color > White
'coot light': {
bg: new Color(0xFFFFFF),
fg: new Color(0x000000),
map_den: new Color(0x3362B2),
map_pos: new Color(0x298029),
map_neg: new Color(0x8B2E2E),
center: new Color(0xC7C769),
H: new Color(0x999999),
C: new Color(0xA96464),
N: new Color(0x1C51B3),
O: new Color(0xC33869),
S: new Color(0x9E7B3D),
def: new Color(0x808080),
},
};
const INIT_HUD_TEXT = 'This is UglyMol not Coot. ' +
'<a href="#" onclick="V.toggle_help(); return false;">H shows help.</a>';
// options handled by select_next()
const COLOR_PROPS = ['element', 'B-factor', 'pLDDT', 'occupancy', 'index', 'chain'];
const RENDER_STYLES = ['lines', 'trace', 'ribbon', 'ball&stick'];
const LIGAND_STYLES = ['ball&stick', 'lines'];
const WATER_STYLES = ['cross', 'dot', 'invisible'];
const MAP_STYLES = ['marching cubes', 'squarish'/*, 'snapped MC'*/];
const LINE_STYLES = ['normal', 'simplistic'];
const LABEL_FONTS = ['bold 14px', '14px', '16px', 'bold 16px'];
function rainbow_value(v: number, vmin: number, vmax: number) {
const c = new Color(0xe0e0e0);
if (vmin < vmax) {
const ratio = (v - vmin) / (vmax - vmin);
const hue = (240 - (240 * ratio)) / 360;
c.setHSL(hue, 1.0, 0.5);
}
return c;
}
function color_by(prop: string, atoms: Atom[], elem_colors: ColorScheme,
hue_shift: number): Color[] {
let color_func;
const last_atom = atoms[atoms.length-1];
if (prop === 'index') {
color_func = function (atom: Atom) {
return rainbow_value(atom.i_seq, 0, last_atom.i_seq);
};
} else if (prop === 'B-factor') {
let vmin = Infinity;
let vmax = -Infinity;
for (let i = 0; i < atoms.length; i++) {
const v = atoms[i].b;
if (v > vmax) vmax = v;
if (v < vmin) vmin = v;
}
//console.log('B-factors in [' + vmin + ', ' + vmax + ']');
color_func = function (atom: Atom) {
return rainbow_value(atom.b, vmin, vmax);
};
} else if (prop === 'pLDDT') {
const steps = [90, 70, 50];
const colors = [
new Color(0x0053d6), // dark blue
new Color(0x65cbf3), // light blue
new Color(0xffdb13), // yellow
new Color(0xff7d45) // orange
];
color_func = function (atom: Atom) {
let i = 0;
while (i < 3 && atom.b < steps[i]) {
++i;
}
return colors[i];
};
} else if (prop === 'occupancy') {
color_func = function (atom: Atom) {
return rainbow_value(atom.occ, 0, 1);
};
} else if (prop === 'chain') {
color_func = function (atom: Atom) {
return rainbow_value(atom.chain_index, 0, last_atom.chain_index);
};
} else { // element
if (hue_shift === 0) {
color_func = function (atom: Atom) {
return elem_colors[atom.element] || elem_colors.def;
};
} else {
const c_hsl = { h: 0, s: 0, l: 0 };
elem_colors['C'].getHSL(c_hsl);
const c_col = new Color(0, 0, 0);
c_col.setHSL(c_hsl.h + hue_shift, c_hsl.s, c_hsl.l);
color_func = function (atom: Atom) {
const el = atom.element;
return el === 'C' ? c_col : (elem_colors[el] || elem_colors.def);
};
}
}
return atoms.map(color_func);
}
function scale_by_height(value: number, size: Num2) { // for scaling bond_line
return value * size[1] / 700;
}
class MapBag {
map: ElMap;
name: string;
isolevel: number;
visible: boolean;
types: string[];
block_ctr: Vector3;
el_objects: object[];
constructor(map: ElMap, config: ViewerConfig, is_diff_map: boolean) {
this.map = map;
this.name = '';
this.isolevel = is_diff_map ? 3.0 : config.default_isolevel;
this.visible = true;
this.types = is_diff_map ? ['map_pos', 'map_neg'] : ['map_den'];
this.block_ctr = new Vector3(Infinity, 0, 0);
this.el_objects = []; // three.js objects
}
}
class ModelBag {
model: Model;
label: string;
visible: boolean;
hue_shift: number;
conf: ViewerConfig;
win_size: Num2;
objects: object[];
atom_array: Atom[]
static ctor_counter: number;
constructor(model: Model, config: ViewerConfig, win_size: Num2) {
this.model = model;
this.label = '(model #' + ++ModelBag.ctor_counter + ')';
this.visible = true;
this.hue_shift = 0;
this.conf = config;
this.win_size = win_size;
this.objects = []; // list of three.js objects
this.atom_array = [];
}
get_visible_atoms() {
const atoms = this.model.atoms;
if (this.conf.hydrogens || !this.model.has_hydrogens) {
return atoms;
}
// with filter() it's twice slower (on Node 4.2)
//return atoms.filter(function(a) { return a.element !== 'H'; });
const non_h = [];
for (const atom of atoms) {
if (atom.element !== 'H') non_h.push(atom);
}
return non_h;
}
add_bonds(polymers: boolean, ligands: boolean, ball_size?: number) {
const visible_atoms = this.get_visible_atoms();
const colors = color_by(this.conf.color_prop, visible_atoms,
this.conf.colors, this.hue_shift);
const vertex_arr: Num3[] = [];
const color_arr = [];
const sphere_arr = [];
const sphere_color_arr = [];
const hydrogens = this.conf.hydrogens;
for (let i = 0; i < visible_atoms.length; i++) {
const atom = visible_atoms[i];
const color = colors[i];
if (!(atom.is_ligand ? ligands : polymers)) continue;
if (atom.is_water() && this.conf.water_style === 'invisible') continue;
if (atom.bonds.length === 0 && ball_size == null) { // nonbonded - cross
if (!atom.is_water() || this.conf.water_style === 'cross') {
addXyzCross(vertex_arr, atom.xyz, 0.7);
for (let n = 0; n < 6; n++) {
color_arr.push(color);
}
}
} else { // bonded, draw lines
for (let j = 0; j < atom.bonds.length; j++) {
const other = this.model.atoms[atom.bonds[j]];
if (!hydrogens && other.element === 'H') continue;
// Coot show X-H bonds as thinner lines in a single color.
// Here we keep it simple and render such bonds like all others.
const mid = atom.midpoint(other);
vertex_arr.push(atom.xyz, mid);
color_arr.push(color, color);
}
}
sphere_arr.push(atom);
sphere_color_arr.push(color);
}
if (ball_size != null) {
if (vertex_arr.length !== 0) {
this.objects.push(makeSticks(vertex_arr, color_arr, ball_size / 2));
}
if (sphere_arr.length !== 0) {
this.objects.push(makeBalls(sphere_arr, sphere_color_arr, ball_size));
}
} else if (vertex_arr.length !== 0) {
const linewidth = scale_by_height(this.conf.bond_line, this.win_size);
const material = makeLineMaterial({
linewidth: linewidth,
win_size: this.win_size,
});
this.objects.push(makeLineSegments(material, vertex_arr, color_arr));
if (this.conf.line_style !== 'simplistic') {
// wheels (discs) as round caps
this.objects.push(makeWheels(sphere_arr, sphere_color_arr, linewidth));
}
}
sphere_arr.forEach(function (v) { this.atom_array.push(v); }, this);
}
add_trace() {
const segments = this.model.extract_trace();
const visible_atoms = [].concat.apply([], segments);
const colors = color_by(this.conf.color_prop, visible_atoms,
this.conf.colors, this.hue_shift);
const vertex_arr: Num3[] = [];
const color_arr = [];
let k = 0;
for (const seg of segments) {
for (let i = 1; i < seg.length; ++i) {
vertex_arr.push(seg[i-1].xyz, seg[i].xyz);
color_arr.push(colors[k+i-1], colors[k+i]);
}
k += seg.length;
}
const linewidth = scale_by_height(this.conf.bond_line, this.win_size);
const material = makeLineMaterial({
linewidth: linewidth,
win_size: this.win_size,
});
this.objects.push(makeLineSegments(material, vertex_arr, color_arr));
if (this.conf.line_style !== 'simplistic') {
// wheels (discs) as round caps
this.objects.push(makeWheels(visible_atoms, colors, linewidth));
}
this.atom_array = visible_atoms;
}
add_ribbon(smoothness: number) {
const segments = this.model.extract_trace();
const res_map = this.model.get_residues();
const visible_atoms = [].concat.apply([], segments);
const colors = color_by(this.conf.color_prop, visible_atoms,
this.conf.colors, this.hue_shift);
let k = 0;
for (const seg of segments) {
const tangents = [];
let last = [0, 0, 0];
for (const atom of seg) {
const residue = res_map[atom.resid()];
const tang = this.model.calculate_tangent_vector(residue);
// untwisting (usually applies to beta-strands)
if (tang[0]*last[0] + tang[1]*last[1] + tang[2]*last[2] < 0) {
tang[0] = -tang[0];
tang[1] = -tang[1];
tang[2] = -tang[2];
}
tangents.push(tang);
last = tang;
}
const color_slice = colors.slice(k, k + seg.length);
k += seg.length;
const obj = makeRibbon(seg, color_slice, tangents, smoothness);
this.objects.push(obj);
}
}
}
ModelBag.ctor_counter = 0;
function vec3_to_fixed(vec, n) {
return [vec.x.toFixed(n), vec.y.toFixed(n), vec.z.toFixed(n)];
}
// for two-finger touch events
function touch_info(evt: TouchEvent) {
const touches = evt.touches;
const dx = touches[0].pageX - touches[1].pageX;
const dy = touches[0].pageY - touches[1].pageY;
return {pageX: (touches[0].pageX + touches[1].pageX) / 2,
pageY: (touches[0].pageY + touches[1].pageY) / 2,
dist: Math.sqrt(dx * dx + dy * dy)};
}
// makes sense only for full-window viewer
function parse_url_fragment() {
const ret : Record<string, any> = {};
if (typeof window === 'undefined') return ret;
const params = window.location.hash.substr(1).split('&');
for (let i = 0; i < params.length; i++) {
const kv = params[i].split('=');
const key = kv[0];
const val = kv[1];
if (key === 'xyz' || key === 'eye') {
ret[key] = val.split(',').map(Number);
} else if (key === 'zoom') {
ret[key] = Number(val);
} else {
ret[key] = val;
}
}
return ret;
}
export class Viewer {
model_bags: ModelBag[];
map_bags: MapBag[];
decor: {
cell_box: object | null,
selection: object | null,
zoom_grid: LineSegments,
mark: object | null
};
labels: {[index:string]: {o: Label, bag: ModelBag}};
//nav: object | null;
xhr_headers: Record<string, string>;
config: ViewerConfig;
window_size: Num2;
window_offset: Num2;
last_ctr: Vector3;
selected: {bag: ModelBag | null, atom: Atom | null};
dbl_click_callback: (arg: object) => void;
scene: Scene;
light: AmbientLight;
default_camera_pos: Num3;
target: Vector3;
camera: OrCameraType;
controls: Controls;
tied_viewer: Viewer | null;
renderer: WebGLRenderer;
container: HTMLElement | null;
hud_el: HTMLElement | null;
help_el: HTMLElement | null;
initial_hud_html: string;
scheduled: boolean;
declare MOUSE_HELP: string;
declare KEYBOARD_HELP: string;
declare ABOUT_HELP: string;
mousemove: (arg: MouseEvent) => void;
mouseup: (arg: MouseEvent) => void;
key_bindings: Array<((evt: KeyboardEvent) => void) | false | undefined>;
declare ColorSchemes: typeof ColorSchemes;
constructor(options: Record<string, any>) {
// rendered objects
this.model_bags = [];
this.map_bags = [];
this.decor = {
cell_box: null,
selection: null,
zoom_grid: makeGrid(),
mark: null,
};
this.labels = {};
//this.nav = null;
this.xhr_headers = {};
this.config = {
bond_line: 4.0, // ~ to height, like in Coot (see scale_by_height())
map_line: 1.25, // for any height
map_radius: 10.0,
max_map_radius: 40,
default_isolevel: 1.5,
center_cube_size: 0.1,
map_style: MAP_STYLES[0],
render_style: RENDER_STYLES[0],
ligand_style: LIGAND_STYLES[0],
water_style: WATER_STYLES[0],
color_prop: COLOR_PROPS[0],
line_style: LINE_STYLES[0],
label_font: LABEL_FONTS[0],
color_scheme: 'coot dark',
// `colors` is assigned in set_colors()
hydrogens: false,
ball_size: 0.4,
};
// options of the constructor overwrite default values of the config
for (const o of Object.keys(options)) {
if (o in this.config) {
this.config[o] = options[o];
}
}
this.set_colors();
this.window_size = [1, 1]; // it will be set in resize()
this.window_offset = [0, 0];
this.last_ctr = new Vector3(Infinity, 0, 0);
this.selected = {bag: null, atom: null};
this.dbl_click_callback = this.toggle_label;
this.scene = new Scene();
this.scene.fog = new Fog(this.config.colors.bg, 0, 1);
this.scene.add(new AmbientLight(0xffffff));
this.default_camera_pos = [0, 0, 100];
if (options.share_view) {
this.target = options.share_view.target;
this.camera = options.share_view.camera;
this.controls = options.share_view.controls;
this.tied_viewer = options.share_view;
this.tied_viewer.tied_viewer = this; // not GC friendly
} else {
this.target = new Vector3(0, 0, 0);
this.camera = new OrthographicCamera() as OrCameraType;
this.camera.position.fromArray(this.default_camera_pos);
this.controls = new Controls(this.camera, this.target);
}
this.set_common_key_bindings();
if (this.constructor === Viewer) this.set_real_space_key_bindings();
function get_elem(name) {
if (options[name] === null || typeof document === 'undefined') return null;
return document.getElementById(options[name] || name);
}
this.hud_el = get_elem('hud');
this.container = get_elem('viewer');
this.help_el = get_elem('help');
if (this.hud_el) {
if (this.hud_el.innerHTML === '') this.hud_el.innerHTML = INIT_HUD_TEXT;
this.initial_hud_html = this.hud_el.innerHTML;
}
try {
this.renderer = new WebGLRenderer({antialias: true});
} catch (e) {
this.hud('No WebGL in your browser?', 'ERR');
this.renderer = null;
return;
}
if (this.container == null) return; // can be null in headless tests
this.renderer.setClearColor(this.config.colors.bg, 1);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.resize();
this.camera.zoom = this.camera.right / 35.0; // arbitrary choice
this.update_camera();
const el = this.renderer.domElement;
this.container.appendChild(el);
if (options.focusable) {
el.tabIndex = 0;
}
this.decor.zoom_grid.visible = false;
this.scene.add(this.decor.zoom_grid);
window.addEventListener('resize', this.resize.bind(this));
const keydown_el = (options.focusable ? el : window);
keydown_el.addEventListener('keydown', this.keydown.bind(this));
el.addEventListener('contextmenu', function (e) { e.preventDefault(); });
el.addEventListener('wheel', this.wheel.bind(this));
el.addEventListener('mousedown', this.mousedown.bind(this));
el.addEventListener('touchstart', this.touchstart.bind(this));
el.addEventListener('touchmove', this.touchmove.bind(this));
el.addEventListener('touchend', this.touchend.bind(this));
el.addEventListener('touchcancel', this.touchend.bind(this));
el.addEventListener('dblclick', this.dblclick.bind(this));
const self = this;
this.mousemove = function (event: MouseEvent) {
event.preventDefault();
//event.stopPropagation();
self.controls.move(self.relX(event), self.relY(event));
};
this.mouseup = function (event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
document.removeEventListener('mousemove', self.mousemove);
document.removeEventListener('mouseup', self.mouseup);
self.decor.zoom_grid.visible = false;
const not_panned = self.controls.stop();
// special case - centering on atoms after action 'pan' with no shift
if (not_panned) {
const pick = self.pick_atom(not_panned, self.camera);
if (pick != null) {
self.select_atom(pick, {steps: 60});
}
}
self.redraw_maps();
};
this.scheduled = false;
this.request_render();
}
pick_atom(coords: Num2, camera: OrCameraType) {
let pick = null;
for (const bag of this.model_bags) {
if (!bag.visible) continue;
const z = (camera.near + camera.far) / (camera.near - camera.far);
const ray = new Ray();
ray.origin.set(coords[0], coords[1], z).unproject(camera);
ray.direction.set(0, 0, -1).transformDirection(camera.matrixWorld);
const near = camera.near;
// '0.15' b/c the furthest 15% is hardly visible in the fog
const far = camera.far - 0.15 * (camera.far - camera.near);
/*
// previous version - line-based search
let intersects = [];
for (const object of bag.objects) {
if (object.visible === false) continue;
if (object.userData.bond_lines) {
line_raycast(object, {ray, near, far, precision: 0.3}, intersects);
}
}
...
if (intersects.length > 0) {
intersects.sort(function (x) { return x.dist2 || Infinity; });
const p = intersects[0].point;
const atom = bag.model.get_nearest_atom(p.x, p.y, p.z);
if (atom != null) {
return {bag, atom};
}
}
*/
// search directly atom array ignoring matrixWorld
const vec = new Vector3();
// required picking precision: 0.35A at zoom 50, 0.27A @z30, 0.44 @z80
const precision2 = 0.35 * 0.35 * 0.02 * camera.zoom;
for (const atom of bag.atom_array) {
vec.set(atom.xyz[0] - ray.origin.x,
atom.xyz[1] - ray.origin.y,
atom.xyz[2] - ray.origin.z);
const distance = vec.dot(ray.direction);
if (distance < 0 || distance < near || distance > far) continue;
const diff2 = vec.addScaledVector(ray.direction, -distance).lengthSq();
if (diff2 > precision2) continue;
if (pick == null || distance < pick.distance) {
pick = {bag, atom, distance};
}
}
}
return pick;
}
set_colors() {
const scheme = this.ColorSchemes[this.config.color_scheme];
if (!scheme) throw Error('Unknown color scheme.');
this.decor.zoom_grid.material.uniforms.ucolor.value.set(scheme.fg);
this.config.colors = scheme;
this.redraw_all();
}
// relative position on canvas in normalized device coordinates [-1, +1]
relX(evt: {pageX: number}) {
return 2 * (evt.pageX - this.window_offset[0]) / this.window_size[0] - 1;
}
relY(evt: {pageY: number}) {
return 1 - 2 * (evt.pageY - this.window_offset[1]) / this.window_size[1];
}
hud(text?: string, type?: string) {
if (typeof document === 'undefined') return; // for testing on node
const el = this.hud_el;
if (el) {
if (text != null) {
if (type === 'HTML') {
el.innerHTML = text;
} else {
el.textContent = text;
}
} else {
el.innerHTML = this.initial_hud_html;
}
const err = (type === 'ERR');
el.style.backgroundColor = (err ? '#b00' : '');
if (err && text) console.log('ERR: ' + text);
} else {
console.log('hud:', text);
}
}
redraw_center(force?: boolean) {
const size = this.config.center_cube_size;
if (force ||
this.target.distanceToSquared(this.last_ctr) > 0.01 * size * size) {
this.last_ctr.copy(this.target);
if (this.decor.mark) {
this.scene.remove(this.decor.mark);
}
this.decor.mark = makeCube(size, this.target, {
color: this.config.colors.center,
linewidth: 2,
});
this.scene.add(this.decor.mark);
}
}
redraw_maps(force?: boolean) {
this.redraw_center(force);
const r = this.config.map_radius;
for (const map_bag of this.map_bags) {
if (force || this.target.distanceToSquared(map_bag.block_ctr) > r/100) {
this.redraw_map(map_bag);
}
}
}
remove_and_dispose(obj: any) {
this.scene.remove(obj);
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (obj.material.uniforms && obj.material.uniforms.map) {
obj.material.uniforms.map.value.dispose();
}
obj.material.dispose();
}
for (const o of obj.children) {
this.remove_and_dispose(o);
}
}
clear_el_objects(map_bag: MapBag) {
for (const o of map_bag.el_objects) {
this.remove_and_dispose(o);
}
map_bag.el_objects = [];
}
clear_model_objects(model_bag: ModelBag) {
for (const o of model_bag.objects) {
this.remove_and_dispose(o);
}
model_bag.objects = [];
}
has_frag_depth() {
return this.renderer && this.renderer.extensions.get('EXT_frag_depth');
}
set_model_objects(model_bag: ModelBag) {
model_bag.objects = [];
model_bag.atom_array = [];
let ligand_balls = null;
if (model_bag.conf.ligand_style === 'ball&stick' && this.has_frag_depth()) {
ligand_balls = this.config.ball_size;
}
switch (model_bag.conf.render_style) {
case 'lines':
if (ligand_balls === null) {
model_bag.add_bonds(true, true);
} else {
model_bag.add_bonds(true, false);
model_bag.add_bonds(false, true, ligand_balls);
}
break;
case 'ball&stick':
if (!this.has_frag_depth()) {
this.hud('Ball-and-stick rendering is not working in this browser' +
'\ndue to lack of suppport for EXT_frag_depth', 'ERR');
return;
}
if (ligand_balls === null) {
model_bag.add_bonds(true, false, this.config.ball_size);
model_bag.add_bonds(false, true);
} else {
model_bag.add_bonds(true, true, this.config.ball_size);
}
break;
case 'trace':
model_bag.add_trace();
model_bag.add_bonds(false, true, ligand_balls);
break;
case 'ribbon':
model_bag.add_ribbon(8);
model_bag.add_bonds(false, true, ligand_balls);
break;
}
for (const o of model_bag.objects) {
this.scene.add(o);
}
}
// Add/remove label if `show` is specified, toggle otherwise.
toggle_label(pick: {bag?: ModelBag, atom?: Atom}, show?: boolean) {
if (pick.atom == null) return;
const text = pick.atom.short_label();
const uid = text; // we assume that the labels inside one model are unique
const is_shown = (uid in this.labels);
if (show === undefined) show = !is_shown;
if (show) {
if (is_shown) return;
const atom_style = pick.atom.is_ligand ? 'ligand_style' : 'render_style';
const balls = pick.bag && pick.bag.conf[atom_style] === 'ball&stick';
const label = new Label(text, {
pos: pick.atom.xyz,
font: this.config.label_font,
color: '#' + this.config.colors.fg.getHexString(),
win_size: this.window_size,
z_shift: balls ? this.config.ball_size + 0.1 : 0.2,
});
if (pick.bag == null || label.mesh == null) return;
this.labels[uid] = { o: label, bag: pick.bag };
this.scene.add(label.mesh);
} else {
if (!is_shown) return;
this.remove_and_dispose(this.labels[uid].o.mesh);
delete this.labels[uid];
}
}
redraw_labels() {
for (const uid in this.labels) { // eslint-disable-line guard-for-in
const text = uid;
this.labels[uid].o.redraw(text, {
font: this.config.label_font,
color: '#' + this.config.colors.fg.getHexString(),
});
}
}
toggle_map_visibility(map_bag: MapBag) {
if (typeof map_bag === 'number') {
map_bag = this.map_bags[map_bag];
}
map_bag.visible = !map_bag.visible;
this.redraw_map(map_bag);
this.request_render();
}
redraw_map(map_bag: MapBag) {
this.clear_el_objects(map_bag);
if (map_bag.visible) {
map_bag.map.block.clear();
this.add_el_objects(map_bag);
}
}
toggle_model_visibility(model_bag?: ModelBag, visible?: boolean) {
model_bag = model_bag || this.selected.bag;
if (model_bag == null) return;
model_bag.visible = visible == null ? !model_bag.visible : visible;
this.redraw_model(model_bag);
this.request_render();
}
redraw_model(model_bag: ModelBag) {
this.clear_model_objects(model_bag);
if (model_bag.visible) {
this.set_model_objects(model_bag);
}
}
redraw_models() {
for (const model_bag of this.model_bags) {
this.redraw_model(model_bag);
}
}
add_el_objects(map_bag: MapBag) {
if (!map_bag.visible || this.config.map_radius <= 0) return;
if (map_bag.map.block.empty()) {
const t = this.target;
map_bag.block_ctr.copy(t);
map_bag.map.extract_block(this.config.map_radius, [t.x, t.y, t.z]);
}
for (const mtype of map_bag.types) {
const isolevel = (mtype === 'map_neg' ? -1 : 1) * map_bag.isolevel;
const iso = map_bag.map.isomesh_in_block(isolevel, this.config.map_style);
if (iso == null) continue;
const obj = makeChickenWire(iso, {
color: this.config.colors[mtype],
linewidth: this.config.map_line,
});
map_bag.el_objects.push(obj);
this.scene.add(obj);
}
}
change_isolevel_by(map_idx: number, delta: number) {
if (map_idx >= this.map_bags.length) return;
const map_bag = this.map_bags[map_idx];
map_bag.isolevel += delta;
//TODO: move slow part into update()
this.clear_el_objects(map_bag);
this.add_el_objects(map_bag);
const abs_level = map_bag.map.abs_level(map_bag.isolevel);
let abs_text = abs_level.toFixed(4);
const tied = this.tied_viewer;
if (tied && map_idx < tied.map_bags.length) {
const tied_bag = tied.map_bags[map_idx];
// Should we tie by sigma or absolute level? Now it's sigma.
tied_bag.isolevel = map_bag.isolevel;
abs_text += ' / ' + tied_bag.map.abs_level(tied_bag.isolevel).toFixed(4);
tied.clear_el_objects(tied_bag);
tied.add_el_objects(tied_bag);
}
this.hud('map ' + (map_idx+1) + ' level = ' + abs_text + ' ' +
map_bag.map.unit + ' (' + map_bag.isolevel.toFixed(2) + ' rmsd)');
}
change_map_radius(delta: number) {
const rmax = this.config.max_map_radius;
const cf = this.config;
cf.map_radius = Math.min(Math.max(cf.map_radius + delta, 0), rmax);
cf.map_radius = Math.round(cf.map_radius * 1e9) / 1e9;
let info = 'map "radius": ' + cf.map_radius;
if (cf.map_radius === rmax) info += ' (max)';
else if (cf.map_radius === 0) info += ' (hidden maps)';
if (this.map_bags.length === 0) info += '\nNB: no map is loaded.';
this.hud(info);
this.redraw_maps(true);
}
change_slab_width_by(delta: number) {
const slab_width = this.controls.slab_width;
slab_width[0] = Math.max(slab_width[0] + delta, 0.01);
slab_width[1] = Math.max(slab_width[1] + delta, 0.01);
this.update_camera();
const final_width = this.camera.far - this.camera.near;
this.hud('clip width: ' + final_width.toPrecision(3));
}
change_zoom_by_factor(mult: number) {
this.camera.zoom *= mult;
this.update_camera();
this.hud('zoom: ' + this.camera.zoom.toPrecision(3));
}
change_bond_line(delta: number) {
this.config.bond_line = Math.max(this.config.bond_line + delta, 0.1);
this.redraw_models();
this.hud('bond width: ' + scale_by_height(this.config.bond_line,
this.window_size).toFixed(1));
}
change_map_line(delta: number) {
this.config.map_line = Math.max(this.config.map_line + delta, 0.1);
this.redraw_maps(true);
this.hud('wireframe width: ' + this.config.map_line.toFixed(1));
}
toggle_full_screen() {
const d = document;
// @ts-expect-error no mozFullScreenElement
if (d.fullscreenElement || d.mozFullScreenElement ||
// @ts-expect-error no msFullscreenElement
d.webkitFullscreenElement || d.msFullscreenElement) {
// @ts-expect-error no webkitExitFullscreen
const ex = d.exitFullscreen || d.webkitExitFullscreen ||
// @ts-expect-error no msExitFullscreen
d.mozCancelFullScreen || d.msExitFullscreen;
if (ex) ex.call(d);
} else {
const el = this.container;
if (!el) return;
// @ts-expect-error no webkitRequestFullscreen
const req = el.requestFullscreen || el.webkitRequestFullscreen ||
// @ts-expect-error no msRequestFullscreen
el.mozRequestFullScreen || el.msRequestFullscreen;
if (req) req.call(el);
}
}
toggle_cell_box() {
if (this.decor.cell_box) {
this.scene.remove(this.decor.cell_box);
this.decor.cell_box = null;
} else {
const uc_func = this.get_cell_box_func();
if (uc_func) {
this.decor.cell_box = makeRgbBox(uc_func, this.config.colors.fg);
this.scene.add(this.decor.cell_box);
}
}
}
get_cell_box_func() {
let uc = null;
if (this.selected.bag != null) {
uc = this.selected.bag.model.unit_cell;
}
// note: model may not have unit cell
if (uc == null && this.map_bags.length > 0) {
uc = this.map_bags[0].map.unit_cell;
}
return uc && uc.orthogonalize.bind(uc);
}
shift_clip(delta: number) {
const eye = this.camera.position.clone().sub(this.target);
eye.multiplyScalar(delta / eye.length());
this.target.add(eye);
this.camera.position.add(eye);
this.update_camera();
this.redraw_maps();
this.hud('clip shifted by [' + vec3_to_fixed(eye, 2).join(' ') + ']');
}
go_to_nearest_Ca() {
const t = this.target;
const bag = this.selected.bag;
if (bag == null) return;
const atom = bag.model.get_nearest_atom(t.x, t.y, t.z, 'CA');
if (atom != null) {
this.select_atom({bag, atom}, {steps: 30});
} else {
this.hud('no nearby CA');
}
}
toggle_inactive_models() {
const n = this.model_bags.length;
if (n < 2) {
this.hud((n == 0 ? 'No' : 'Only one') + ' model is loaded. ' +
'"V" is for working with multiple models.');
return;
}
const show_all = !this.model_bags.every(function (m) { return m.visible; });
for (const model_bag of this.model_bags) {
const show = show_all || model_bag === this.selected.bag;
this.toggle_model_visibility(model_bag, show);
}
this.hud(show_all ? 'All models visible' : 'Inactive models hidden');
}
permalink() {
if (typeof window === 'undefined') return;
const xyz_prec = Math.round(-Math.log10(0.001));
window.location.hash =
'#xyz=' + vec3_to_fixed(this.target, xyz_prec).join(',') +
'&eye=' + vec3_to_fixed(this.camera.position, 1).join(',') +
'&zoom=' + this.camera.zoom.toFixed(0);
this.hud('copy URL from the location bar');
}
redraw_all() {
if (!this.renderer) return;
this.scene.fog.color = this.config.colors.bg;
if (this.renderer) this.renderer.setClearColor(this.config.colors.bg, 1);
this.redraw_models();
this.redraw_maps(true);
this.redraw_labels();
}
toggle_help() {
const el = this.help_el;
if (!el) return;
el.style.display = el.style.display === 'block' ? 'none' : 'block';
if (el.innerHTML === '') {
el.innerHTML = [this.MOUSE_HELP, this.KEYBOARD_HELP,
this.ABOUT_HELP].join('\n\n');
}
}
select_next(info: string, key: string, options: string[], back: boolean) {
const old_idx = options.indexOf(this.config[key]);
const len = options.length;
const new_idx = (old_idx + (back ? len - 1 : 1)) % len;
this.config[key] = options[new_idx];
let html = info + ':';
for (let i = 0; i < len; i++) {
const tag = (i === new_idx ? 'u' : 's');
html += ' <' + tag + '>' + options[i] + '</' + tag + '>';
}
this.hud(html, 'HTML');
}
keydown(evt: KeyboardEvent) {
if (evt.ctrlKey) return;
const action = this.key_bindings[evt.keyCode];
if (action) {
(action.bind(this))(evt);
} else {
if (action === false) evt.preventDefault();
if (this.help_el) this.hud('Nothing here. Press H for help.');
}
this.request_render();
}
set_common_key_bindings() {
const kb = new Array(256);
// b
kb[66] = function (this: Viewer, evt: KeyboardEvent) {
const schemes = Object.keys(this.ColorSchemes);
this.select_next('color scheme', 'color_scheme', schemes, evt.shiftKey);
this.set_colors();
};
// c
kb[67] = function (this: Viewer, evt: KeyboardEvent) {
this.select_next('coloring by', 'color_prop', COLOR_PROPS, evt.shiftKey);
this.redraw_models();
};
// e
kb[69] = function (this: Viewer) {
const fog = this.scene.fog;
const has_fog = (fog.far === 1);
fog.far = (has_fog ? 1e9 : 1);
this.hud((has_fog ? 'dis': 'en') + 'able fog');
this.redraw_all();
};
// h
kb[72] = this.toggle_help;
// i
kb[73] = function (this: Viewer, evt: KeyboardEvent) {
this.hud('toggled spinning');
this.controls.toggle_auto(evt.shiftKey);
};
// k
kb[75] = function (this: Viewer) {
this.hud('toggled rocking');
this.controls.toggle_auto(0.0);
};
// m
kb[77] = function (this: Viewer, evt: KeyboardEvent) {
this.change_zoom_by_factor(evt.shiftKey ? 1.2 : 1.03);
};
// n
kb[78] = function (this: Viewer, evt: KeyboardEvent) {
this.change_zoom_by_factor(1 / (evt.shiftKey ? 1.2 : 1.03));
};
// q
kb[81] = function (this: Viewer, evt: KeyboardEvent) {
this.select_next('label font', 'label_font', LABEL_FONTS, evt.shiftKey);
this.redraw_labels();
};
// r
kb[82] = function (this: Viewer, evt: KeyboardEvent) {
if (evt.shiftKey) {
this.hud('redraw!');
this.redraw_all();
} else {
this.hud('recentered');
this.recenter();
}
};
// w
kb[87] = function (this: Viewer, evt: KeyboardEvent) {
this.select_next('map style', 'map_style', MAP_STYLES, evt.shiftKey);
this.redraw_maps(true);
};
// add, equals/firefox, equal sign
kb[107] = kb[61] = kb[187] = function (this: Viewer, evt: KeyboardEvent) {
this.change_isolevel_by(evt.shiftKey ? 1 : 0, 0.1);
};
// subtract, minus/firefox, dash
kb[109] = kb[173] = kb[189] = function (this: Viewer, evt: KeyboardEvent) {
this.change_isolevel_by(evt.shiftKey ? 1 : 0, -0.1);
};
// [
kb[219] = function (this: Viewer) { this.change_map_radius(-2); };
// ]
kb[221] = function (this: Viewer) { this.change_map_radius(2); };
// \ (backslash)
kb[220] = function (this: Viewer, evt: KeyboardEvent) {
this.select_next('bond lines', 'line_style', LINE_STYLES, evt.shiftKey);
this.redraw_models();
};
// shift, ctrl, alt, altgr
kb[16] = kb[17] = kb[18] = kb[225] = function () {};
// slash, single quote
kb[191] = kb[222] = false; // -> preventDefault()
this.key_bindings = kb;
}
set_real_space_key_bindings() {
const kb = this.key_bindings;
// Home
kb[36] = function (this: Viewer, evt: KeyboardEvent) {
evt.shiftKey ? this.change_map_line(0.1) : this.change_bond_line(0.2);
};
// End
kb[35] = function (this: Viewer, evt: KeyboardEvent) {
evt.shiftKey ? this.change_map_line(-0.1) : this.change_bond_line(-0.2);
};
// Space
kb[32] = function (this: Viewer, evt: KeyboardEvent) {
this.center_next_residue(evt.shiftKey);
};
// d
kb[68] = function (this: Viewer) {
this.change_slab_width_by(-0.1);
};
// f
kb[70] = function (this: Viewer, evt: KeyboardEvent) {
evt.shiftKey ? this.toggle_full_screen() : this.change_slab_width_by(0.1);
};
// l
kb[76] = function (this: Viewer, evt: KeyboardEvent) {
this.select_next('ligands as', 'ligand_style', LIGAND_STYLES, evt.shiftKey);
this.redraw_models();
};
// p
kb[80] = function (this: Viewer, evt: KeyboardEvent) {
evt.shiftKey ? this.permalink() : this.go_to_nearest_Ca();
};
// s
kb[83] = function (this: Viewer, evt: KeyboardEvent) {
this.select_next('rendering as', 'render_style', RENDER_STYLES, evt.shiftKey);
this.redraw_models();
};
// t
kb[84] = function (this: Viewer, evt: KeyboardEvent) {
this.select_next('waters as', 'water_style', WATER_STYLES, evt.shiftKey);
this.redraw_models();
};
// u
kb[85] = function (this: Viewer) {
this.hud('toggled unit cell box');
this.toggle_cell_box();
};
// v
kb[86] = function (this: Viewer) {
this.toggle_inactive_models();
};
// y
kb[89] = function (this: Viewer) {
this.config.hydrogens = !this.config.hydrogens;
this.hud((this.config.hydrogens ? 'show' : 'hide') +
' hydrogens (if any)');
this.redraw_models();
};
// comma
kb[188] = function (this: Viewer, evt: KeyboardEvent) {
if (evt.shiftKey) this.shift_clip(1);
};
// period
kb[190] = function (this: Viewer, evt: KeyboardEvent) {
if (evt.shiftKey) this.shift_clip(-1);
};
}
mousedown(event: MouseEvent) {
//event.preventDefault(); // default involves setting focus, which we need
event.stopPropagation();
document.addEventListener('mouseup', this.mouseup);
document.addEventListener('mousemove', this.mousemove);
let state = STATE.NONE;
if (event.button === 1 || (event.button === 0 && event.ctrlKey)) {
state = STATE.PAN;
} else if (event.button === 0) {
// in Coot shift+Left is labeling atoms like dblclick, + rotation
if (event.shiftKey) {
this.dblclick(event);
}
state = STATE.ROTATE;
} else if (event.button === 2) {
if (event.ctrlKey) {
state = event.shiftKey ? STATE.ROLL : STATE.SLAB;
} else {
this.decor.zoom_grid.visible = true;
state = STATE.ZOOM;
}
}
this.controls.start(state, this.relX(event), this.relY(event));
this.request_render();
}
dblclick(event: MouseEvent) {
if (event.button !== 0) return;
if (this.decor.selection) {
this.remove_and_dispose(this.decor.selection);
this.decor.selection = null;
}
const mouse: Num2 = [this.relX(event), this.relY(event)];
const pick = this.pick_atom(mouse, this.camera);
if (pick) {
const atom = pick.atom;
this.hud(pick.bag.label + ' ' + atom.long_label());
this.dbl_click_callback(pick);
const color = this.config.colors[atom.element] || this.config.colors.def;
const size = 2.5 * scale_by_height(this.config.bond_line,
this.window_size);
this.decor.selection = makeWheels([atom], [color], size);
this.scene.add(this.decor.selection);
} else {
this.hud();
}
this.request_render();
}
touchstart(event: TouchEvent) {
const touches = event.touches;
if (touches.length === 1) {
this.controls.start(STATE.ROTATE,
this.relX(touches[0]), this.relY(touches[0]));
} else { // for now using only two touches
const info = touch_info(event);
this.controls.start(STATE.PAN_ZOOM,
this.relX(info), this.relY(info), info.dist);
}
this.request_render();
}
touchmove(event: TouchEvent) {
event.preventDefault();
event.stopPropagation();
const touches = event.touches;
if (touches.length === 1) {
this.controls.move(this.relX(touches[0]), this.relY(touches[0]));
} else { // for now using only two touches
const info = touch_info(event);
this.controls.move(this.relX(info), this.relY(info), info.dist);
}
}
touchend(/*event*/) {
this.controls.stop();
this.redraw_maps();
}
wheel(evt: WheelEvent) {
evt.preventDefault();
evt.stopPropagation();
this.mousewheel_action(evt.deltaY, evt);
this.request_render();
}
// overrided in ReciprocalViewer
mousewheel_action(delta: number, evt: WheelEvent) {
const map_idx = evt.shiftKey ? 1 : 0;
this.change_isolevel_by(map_idx, 0.0005 * delta);
}
resize(/*evt*/) {
const el = this.container;
if (el == null) return;
const width = el.clientWidth;
const height = el.clientHeight;
this.window_offset[0] = el.offsetLeft;
this.window_offset[1] = el.offsetTop;
this.camera.left = -width;
this.camera.right = width;
this.camera.top = height;
this.camera.bottom = -height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
if (width !== this.window_size[0] || height !== this.window_size[1]) {
this.window_size[0] = width;
this.window_size[1] = height;
this.redraw_models(); // b/c bond_line is scaled by height
}
this.request_render();
}
// If xyz set recenter on it looking toward the model center.
// Otherwise recenter on the model center looking along the z axis.
recenter(xyz?: Num3, cam?: Num3, steps?: number) {
const bag = this.selected.bag;
const new_up = new Vector3(0, 1, 0);
let vec_cam;
let vec_xyz;
let eye;
if (xyz != null && cam == null && bag != null) {
// look from specified point toward the center of the molecule,
// i.e. shift camera away from the molecule center.
const mc = bag.model.get_center();
eye = new Vector3(xyz[0] - mc[0], xyz[1] - mc[1], xyz[2] - mc[2]);
eye.setLength(100);
vec_xyz = new Vector3(xyz[0], xyz[1], xyz[2]);
vec_cam = eye.clone().add(vec_xyz);
} else {
if (xyz == null) {
if (bag != null) {
xyz = bag.model.get_center();
} else {
const uc_func = this.get_cell_box_func();
xyz = uc_func ? uc_func([0.5, 0.5, 0.5]) : [0, 0, 0];
}
}
vec_xyz = new Vector3(xyz[0], xyz[1], xyz[2]);
if (cam != null) {
vec_cam = new Vector3(cam[0], cam[1], cam[2]);
eye = vec_cam.clone().sub(vec_xyz);
new_up.copy(this.camera.up); // preserve the up direction
} else {
const dc = this.default_camera_pos;
vec_cam = new Vector3(xyz[0] + dc[0], xyz[1] + dc[1], xyz[2] + dc[2]);
}
}
if (eye != null) {
new_up.projectOnPlane(eye);
if (new_up.lengthSq() < 0.0001) new_up.x += 1;
new_up.normalize();
}
this.controls.go_to(vec_xyz, vec_cam, new_up, steps);
}
center_next_residue(back: boolean) {
const bag = this.selected.bag;
if (bag == null) return;
const atom = bag.model.next_residue(this.selected.atom, back);
if (atom != null) {
this.select_atom({bag, atom}, {steps: 30});
}
}
select_atom(pick: {bag: ModelBag, atom: Atom}, options: {steps?: number}={}) {
this.hud('-> ' + pick.bag.label + ' ' + pick.atom.long_label());
const xyz = pick.atom.xyz;
this.controls.go_to(new Vector3(xyz[0], xyz[1], xyz[2]),
null, null, options.steps);
this.toggle_label(this.selected, false);
this.selected = pick;
this.toggle_label(this.selected, true);
}
update_camera() {
const dxyz = this.camera.position.distanceTo(this.target);
const w = this.controls.slab_width;
const scale = w[2] || this.camera.zoom;
this.camera.near = dxyz * (1 - w[0] / scale);
this.camera.far = dxyz * (1 + w[1] / scale);
this.camera.updateProjectionMatrix();
}
// The main loop. Running when a mouse button is pressed or when the view
// is moving (and run once more after the mouse button is released).
// It is also triggered by keydown events.
render() {
this.scheduled = true;
if (this.renderer === null) return;
if (this.controls.update()) {
this.update_camera();
}
const tied = this.tied_viewer;
if (!this.controls.is_going()) {
this.redraw_maps();
if (tied && !tied.scheduled) tied.redraw_maps();
}
this.renderer.render(this.scene, this.camera);
if (tied && !tied.scheduled) tied.renderer.render(tied.scene, tied.camera);
//if (this.nav) {
// this.nav.renderer.render(this.nav.scene, this.camera);
//}
this.scheduled = false;
if (this.controls.is_moving()) {
this.request_render();
}
}
request_render() {
if (typeof window !== 'undefined' && !this.scheduled) {
this.scheduled = true;
window.requestAnimationFrame(this.render.bind(this));
}
}
add_model(model: Model, options: {hue_shift?: number}={}) {
const model_bag = new ModelBag(model, this.config, this.window_size);
model_bag.hue_shift = options.hue_shift || 0.06 * this.model_bags.length;
this.model_bags.push(model_bag);
this.set_model_objects(model_bag);
this.request_render();
}
add_map(map: ElMap, is_diff_map: boolean) {
//map.show_debug_info();
const map_bag = new MapBag(map, this.config, is_diff_map);
this.map_bags.push(map_bag);
this.add_el_objects(map_bag);
this.request_render();
}
load_file(url: string, options: Record<string, any>,
callback: (arg: XMLHttpRequest) => void ) {
if (this.renderer === null) return; // no WebGL detected
const req = new XMLHttpRequest();
req.open('GET', url, true);
if (options.binary) {
req.responseType = 'arraybuffer';
} else {
// http://stackoverflow.com/questions/7374911/
req.overrideMimeType('text/plain');
}
const self = this;
Object.keys(this.xhr_headers).forEach(function (name) {
req.setRequestHeader(name, self.xhr_headers[name]);
});
req.onreadystatechange = function () {
if (req.readyState === 4) {
// chrome --allow-file-access-from-files gives status 0
if (req.status === 200 || (req.status === 0 && req.response !== null &&
req.response !== '')) {
try {
callback(req);
} catch (e) {
self.hud('Error: ' + e.message + '\nwhen processing ' + url, 'ERR');
}
} else {
self.hud('Failed to fetch ' + url, 'ERR');
}
}
};