Skip to content

Commit 42bcd52

Browse files
committed
added axes, zoom and pan control
1 parent 7d6b446 commit 42bcd52

2 files changed

Lines changed: 364 additions & 8 deletions

File tree

aiidalab_widgets_base/viewers.py

Lines changed: 206 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)