@@ -285,6 +285,11 @@ class _StructureDataBaseViewer(ipw.VBox):
285285 cell: ase.cell.Cell object.
286286 """
287287
288+ show_axes = tl .Bool (True )
289+ center_axes = tl .Bool (False )
290+ view_scale = tl .Float (100.0 )
291+ view_pan_x = tl .Float (0.0 )
292+ view_pan_y = tl .Float (0.0 )
288293 _all_representations = tl .List ()
289294 input_selection = tl .List (tl .Int (), allow_none = True )
290295 selection = tl .List (tl .Int ())
@@ -322,6 +327,7 @@ def __init__(
322327 self ._viewer .camera = default_camera
323328 self ._viewer .observe (self ._on_atom_click , names = "picked" )
324329 self ._viewer .stage .set_parameters (mouse_preset = "pymol" )
330+ self ._axes_component = None
325331
326332 view_box = ipw .VBox ([self ._viewer ])
327333 view_box .add_class ("view-box" )
@@ -467,10 +473,73 @@ def change_camera(change):
467473 camera_type .observe (change_camera , names = "value" )
468474
469475 # 4. Center button.
470- center_button = ipw .Button (description = "Center molecule " )
476+ center_button = ipw .Button (description = "Center atoms " )
471477 center_button .on_click (lambda _ : self ._viewer .center ())
472478
473- # 5. representations buttons
479+ # 5. Reset view button.
480+ reset_view_button = ipw .Button (description = "Default view" , icon = "refresh" )
481+ reset_view_button .on_click (self .reset_view )
482+
483+ # 6. View scale.
484+ view_scale = ipw .BoundedFloatText (
485+ description = "View scale (%):" ,
486+ value = self .view_scale ,
487+ min = 1.0 ,
488+ max = 1000.0 ,
489+ step = 10.0 ,
490+ layout = {"width" : "220px" },
491+ style = {"description_width" : "initial" },
492+ )
493+ tl .link ((view_scale , "value" ), (self , "view_scale" ))
494+
495+ # 7. View pan.
496+ pan_x = ipw .FloatText (
497+ description = "Pan X (screens):" ,
498+ value = self .view_pan_x ,
499+ layout = {"width" : "180px" },
500+ style = {"description_width" : "initial" },
501+ )
502+ pan_y = ipw .FloatText (
503+ description = "Pan Y (screens):" ,
504+ value = self .view_pan_y ,
505+ layout = {"width" : "180px" },
506+ style = {"description_width" : "initial" },
507+ )
508+ tl .link ((pan_x , "value" ), (self , "view_pan_x" ))
509+ tl .link ((pan_y , "value" ), (self , "view_pan_y" ))
510+
511+ # 8. View help.
512+ show_view_help = ipw .Checkbox (
513+ description = "Show view help" , value = False , indent = False
514+ )
515+ view_help = ipw .HTML (
516+ """
517+ <div style="line-height: 1.5; max-width: 460px; margin: 4px 2px;">
518+ <b>View controls:</b> Scale 100 is the default fit. Pan X/Y are
519+ measured in screen lengths: +1 moves one full screen right/up,
520+ -1 moves one full screen left/down. The default view button
521+ resets scale and pan, then reapplies the default orientation.
522+ </div>
523+ """ ,
524+ layout = ipw .Layout (display = "none" ),
525+ )
526+
527+ def toggle_view_help (change ):
528+ view_help .layout .display = "" if change ["new" ] else "none"
529+
530+ show_view_help .observe (toggle_view_help , names = "value" )
531+
532+ # 9. Axis controls.
533+ show_axes = ipw .Checkbox (
534+ description = "Show axes" , value = self .show_axes , indent = False
535+ )
536+ center_axes = ipw .Checkbox (
537+ description = "Center axes" , value = self .center_axes , indent = False
538+ )
539+ tl .link ((show_axes , "value" ), (self , "show_axes" ))
540+ tl .link ((center_axes , "value" ), (self , "center_axes" ))
541+
542+ # 10. representations buttons
474543 self .representations_header = ipw .HBox (
475544 [
476545 ipw .HTML (
@@ -551,6 +620,12 @@ def change_camera(change):
551620 background_color ,
552621 camera_type ,
553622 center_button ,
623+ reset_view_button ,
624+ view_scale ,
625+ ipw .HBox ([pan_x , pan_y ]),
626+ show_view_help ,
627+ view_help ,
628+ ipw .HBox ([show_axes , center_axes ]),
554629 representation_accordion ,
555630 ]
556631 )
@@ -1050,10 +1125,131 @@ def highlight_atoms(
10501125
10511126 def remove_viewer_components (self , c = None ):
10521127 """Remove all components from the viewer except the one specified."""
1053- if hasattr (self ._viewer , "component_0" ):
1054- self ._viewer .component_0 .clear_representations ()
1055- cid = self ._viewer .component_0 .id
1056- self ._viewer .remove_component (cid )
1128+ keep_id = getattr (c , "id" , c )
1129+ for component_id in list (self ._viewer ._ngl_component_ids ):
1130+ if component_id != keep_id :
1131+ self ._viewer .remove_component (component_id )
1132+ if (
1133+ getattr (self ._axes_component , "id" , None )
1134+ not in self ._viewer ._ngl_component_ids
1135+ ):
1136+ self ._axes_component = None
1137+
1138+ @tl .validate ("view_scale" )
1139+ def _valid_view_scale (self , change ):
1140+ if change ["value" ] <= 0 :
1141+ raise tl .TraitError ("View scale must be positive." )
1142+ return change ["value" ]
1143+
1144+ @tl .validate ("view_pan_x" , "view_pan_y" )
1145+ def _valid_view_pan (self , change ):
1146+ if not np .isfinite (change ["value" ]):
1147+ raise tl .TraitError ("View pan must be finite." )
1148+ return change ["value" ]
1149+
1150+ def reset_view (self , _ = None ):
1151+ """Reset the camera, scale, and pan to the default x/y view."""
1152+ with self .hold_trait_notifications ():
1153+ self .view_scale = 100.0
1154+ self .view_pan_x = 0.0
1155+ self .view_pan_y = 0.0
1156+ if not self ._viewer ._ngl_component_ids :
1157+ return
1158+ self ._viewer ._execute_js_code (f"""
1159+ (() => {{
1160+ const center = this.stage.getCenter();
1161+ const scale = { self .view_scale } / 100.0;
1162+ const distance = this.stage.getZoom() / scale;
1163+ const panX = -({ self .view_pan_x } ) * this.stage.viewer.width;
1164+ const panY = { self .view_pan_y } * this.stage.viewer.height;
1165+ this.stage.viewerControls.rotate([0, 1, 0, 0]);
1166+ this.stage.viewerControls.center(center);
1167+ this.stage.viewerControls.distance(distance);
1168+ const scaleFactor = this.stage.viewerControls.getCanvasScaleFactor();
1169+ const panVector = new NGL.Vector3(panX * scaleFactor, panY * scaleFactor, 0);
1170+ const panMatrix = new NGL.Matrix4();
1171+ panMatrix.getInverse(this.stage.viewer.rotationGroup.matrix);
1172+ panMatrix.multiply(this.stage.trackballControls._getCameraRotation(new NGL.Matrix4()));
1173+ panVector.applyMatrix4(panMatrix);
1174+ this.stage.viewerControls.translate(panVector);
1175+ this.serialize_camera_orientation();
1176+ }})();
1177+ """ )
1178+
1179+ @tl .observe ("view_scale" , "view_pan_x" , "view_pan_y" )
1180+ def apply_view_transform (self , _ = None ):
1181+ """Scale and pan the current view relative to the default fit."""
1182+ if not self ._viewer ._ngl_component_ids :
1183+ return
1184+ self ._viewer ._execute_js_code (f"""
1185+ (() => {{
1186+ const center = this.stage.getCenter();
1187+ const scale = { self .view_scale } / 100.0;
1188+ const distance = this.stage.getZoom() / scale;
1189+ const panX = -({ self .view_pan_x } ) * this.stage.viewer.width;
1190+ const panY = { self .view_pan_y } * this.stage.viewer.height;
1191+ this.stage.viewerControls.center(center);
1192+ this.stage.viewerControls.distance(distance);
1193+ const scaleFactor = this.stage.viewerControls.getCanvasScaleFactor();
1194+ const panVector = new NGL.Vector3(panX * scaleFactor, panY * scaleFactor, 0);
1195+ const panMatrix = new NGL.Matrix4();
1196+ panMatrix.getInverse(this.stage.viewer.rotationGroup.matrix);
1197+ panMatrix.multiply(this.stage.trackballControls._getCameraRotation(new NGL.Matrix4()));
1198+ panVector.applyMatrix4(panMatrix);
1199+ this.stage.viewerControls.translate(panVector);
1200+ this.serialize_camera_orientation();
1201+ }})();
1202+ """ )
1203+
1204+ @tl .observe ("show_axes" , "center_axes" )
1205+ def _observe_axes_settings (self , _ = None ):
1206+ if isinstance (self .displayed_structure , ase .Atoms ):
1207+ self ._remove_axes ()
1208+ self ._add_axes ()
1209+
1210+ def _remove_axes (self ):
1211+ if getattr (self ._axes_component , "id" , None ) in self ._viewer ._ngl_component_ids :
1212+ self ._viewer .remove_component (self ._axes_component )
1213+ self ._axes_component = None
1214+
1215+ def _add_axes (self ):
1216+ """Add a small coordinate-axis triad to the viewer."""
1217+ self ._remove_axes ()
1218+ if not self .show_axes or not isinstance (self .displayed_structure , ase .Atoms ):
1219+ return
1220+
1221+ positions = self .displayed_structure .get_positions ()
1222+ cell_lengths = self .displayed_structure .cell .lengths ()
1223+ structure_extent = np .ptp (positions , axis = 0 ) if len (positions ) else [0 , 0 , 0 ]
1224+ cell_extent = np .max (cell_lengths )
1225+ extent = max (cell_extent , np .max (structure_extent ), 5.0 )
1226+ length = 0.2 * extent
1227+ radius = 0.03 * length
1228+ label_size = 0.25 * length
1229+ label_offset = 0.12 * length
1230+ if self .center_axes and len (positions ):
1231+ origin = np .mean (positions , axis = 0 )
1232+ elif cell_extent > 0 :
1233+ origin = np .zeros (3 )
1234+ else :
1235+ origin = (
1236+ np .min (positions , axis = 0 ) - 0.08 * extent
1237+ if len (positions )
1238+ else np .zeros (3 )
1239+ )
1240+
1241+ axes = [
1242+ ("x" , np .array ([length , 0.0 , 0.0 ]), [1.0 , 0.0 , 0.0 ]),
1243+ ("y" , np .array ([0.0 , length , 0.0 ]), [0.0 , 0.6 , 0.0 ]),
1244+ ("z" , np .array ([0.0 , 0.0 , length ]), [0.0 , 0.2 , 1.0 ]),
1245+ ]
1246+ shapes = []
1247+ for label , vector , color in axes :
1248+ end = origin + vector
1249+ label_position = end + label_offset * vector / np .linalg .norm (vector )
1250+ shapes .append (("arrow" , origin .tolist (), end .tolist (), color , radius ))
1251+ shapes .append (("text" , label_position .tolist (), color , label_size , label ))
1252+ self ._axes_component = self ._viewer ._add_shape (shapes , name = "axes" )
10571253
10581254 @tl .default ("supercell" )
10591255 def _default_supercell (self ):
@@ -1297,8 +1493,10 @@ def _observe_displayed_structure(self, change):
12971493 )
12981494 self ._viewer .set_representations (nglview_params , component = 0 )
12991495 self ._viewer .add_unitcell ()
1300- self ._viewer ._add_shape (set (bonds ), name = "bonds" )
1301- self ._viewer .center ()
1496+ if bonds :
1497+ self ._viewer ._add_shape (set (bonds ), name = "bonds" )
1498+ self ._add_axes ()
1499+ self .reset_view ()
13021500 # In case of a structure with only one atom, the `center()` method will show a black sphere.
13031501 if len (self .displayed_structure ) == 1 :
13041502 # get center of mass of the displayed structure
0 commit comments