Skip to content

Commit a9fabcf

Browse files
committed
feat: add pan and zoom toggle controls with full camera state save/restore
- Add zoom(factor) and pan(dx, dy) methods to both view managers - Add aspect ratio, zoom, and pan toggle groups to Layout toolbar with background highlight - Add reset view button with render fix - Save/restore full camera state (zoom, position, focal point, view up, clipping range) - Streamline toolbar: remove label, icon-only grouped/size buttons with tooltips - Aspect ratio slider with 0.25 step ticks (0-4 range)
1 parent 2bad3ca commit a9fabcf

4 files changed

Lines changed: 204 additions & 56 deletions

File tree

src/e3sm_quickview/app.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,9 @@ def _build_ui(self, **_):
224224
with html.Div(style=css.TOOLBARS_FIXED_OVERLAY):
225225
toolbars.Layout(
226226
apply_size=self.view_manager.apply_size,
227-
zoom_in=self.view_manager.zoom_in,
228-
zoom_out=self.view_manager.zoom_out,
227+
zoom=self.view_manager.zoom,
228+
pan=self.view_manager.pan,
229+
reset_camera=self.view_manager.reset_camera,
229230
)
230231
toolbars.Cropping()
231232
toolbars.DataSelection()
@@ -307,7 +308,7 @@ def download_state(self):
307308
"active": self.state.active_layout,
308309
"tools": self.state.active_tools,
309310
"help": not self.state.compact_drawer,
310-
"zoom": self.view_manager.get_zoom(),
311+
"camera": self.view_manager.get_camera_state(),
311312
}
312313
data_selection = {
313314
k: self.state[k]
@@ -415,8 +416,8 @@ async def _import_state(self, state_content):
415416
self.state.active_layout = state_content["layout"]["active"]
416417
self.state.active_tools = state_content["layout"]["tools"]
417418
self.state.compact_drawer = not state_content["layout"]["help"]
418-
if "zoom" in state_content["layout"]:
419-
self.view_manager.set_zoom(state_content["layout"]["zoom"])
419+
if "camera" in state_content["layout"]:
420+
self.view_manager.set_camera_state(state_content["layout"]["camera"])
420421

421422
# Update filebrowser state
422423
with self.state:

src/e3sm_quickview/components/toolbars.py

Lines changed: 115 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,65 +38,143 @@ def to_kwargs(value):
3838

3939

4040
class Layout(v3.VToolbar):
41-
def __init__(self, apply_size=None, zoom_in=None, zoom_out=None):
41+
def __init__(
42+
self,
43+
apply_size=None,
44+
zoom=None,
45+
pan=None,
46+
reset_camera=None,
47+
):
4248
super().__init__(**to_kwargs("adjust-layout"))
4349

50+
self.state.setdefault("show_zoom_controls", False)
51+
self.state.setdefault("show_pan_controls", False)
52+
self.state.setdefault("show_aspect_ratio", False)
53+
4454
with self:
4555
v3.VIcon("mdi-view-module", classes="px-6 opacity-50")
46-
v3.VLabel("Viewport layout", classes="text-subtitle-2")
4756
v3.VSpacer()
4857

58+
# --- Aspect ratio toggle + slider ---
59+
with v3.VSheet(
60+
classes="d-flex align-center rounded px-1",
61+
color=("show_aspect_ratio ? 'grey-lighten-3' : 'transparent'",),
62+
):
63+
v3.VIconBtn(
64+
v_tooltip_bottom="'Toggle aspect ratio'",
65+
icon="mdi-arrow-expand-vertical",
66+
flat=True,
67+
click="show_aspect_ratio = !show_aspect_ratio; show_zoom_controls = false; show_pan_controls = false",
68+
color=("show_aspect_ratio ? 'primary' : ''",),
69+
)
70+
v3.VSlider(
71+
v_if="show_aspect_ratio",
72+
v_model=("aspect_ratio", 0.5),
73+
min=0,
74+
max=4,
75+
step=0.25,
76+
show_ticks="always",
77+
density="compact",
78+
hide_details=True,
79+
style="min-width: 200px; max-width: 300px;",
80+
)
81+
82+
# --- Zoom toggle + in/out ---
83+
with v3.VSheet(
84+
classes="d-flex align-center rounded px-1",
85+
color=("show_zoom_controls ? 'grey-lighten-3' : 'transparent'",),
86+
):
87+
v3.VIconBtn(
88+
v_tooltip_bottom="'Toggle zoom controls'",
89+
icon="mdi-magnify",
90+
flat=True,
91+
click="show_zoom_controls = !show_zoom_controls; show_pan_controls = false; show_aspect_ratio = false",
92+
color=("show_zoom_controls ? 'primary' : ''",),
93+
)
94+
v3.VIconBtn(
95+
v_if="show_zoom_controls",
96+
v_tooltip_bottom="'Zoom in'",
97+
icon="mdi-magnify-plus-outline",
98+
flat=True,
99+
click=lambda: zoom(1 / 1.2),
100+
)
101+
v3.VIconBtn(
102+
v_if="show_zoom_controls",
103+
v_tooltip_bottom="'Zoom out'",
104+
icon="mdi-magnify-minus-outline",
105+
flat=True,
106+
click=lambda: zoom(1.2),
107+
)
108+
109+
# --- Pan toggle + directions ---
110+
with v3.VSheet(
111+
classes="d-flex align-center rounded px-1",
112+
color=("show_pan_controls ? 'grey-lighten-3' : 'transparent'",),
113+
):
114+
v3.VIconBtn(
115+
v_tooltip_bottom="'Toggle pan controls'",
116+
icon="mdi-arrow-all",
117+
flat=True,
118+
click="show_pan_controls = !show_pan_controls; show_zoom_controls = false; show_aspect_ratio = false",
119+
color=("show_pan_controls ? 'primary' : ''",),
120+
)
121+
v3.VIconBtn(
122+
v_if="show_pan_controls",
123+
v_tooltip_bottom="'Pan up'",
124+
icon="mdi-arrow-up",
125+
flat=True,
126+
click=lambda: pan(0, -1),
127+
)
128+
v3.VIconBtn(
129+
v_if="show_pan_controls",
130+
v_tooltip_bottom="'Pan down'",
131+
icon="mdi-arrow-down",
132+
flat=True,
133+
click=lambda: pan(0, 1),
134+
)
135+
v3.VIconBtn(
136+
v_if="show_pan_controls",
137+
v_tooltip_bottom="'Pan left'",
138+
icon="mdi-arrow-left",
139+
flat=True,
140+
click=lambda: pan(1, 0),
141+
)
142+
v3.VIconBtn(
143+
v_if="show_pan_controls",
144+
v_tooltip_bottom="'Pan right'",
145+
icon="mdi-arrow-right",
146+
flat=True,
147+
click=lambda: pan(-1, 0),
148+
)
149+
150+
# --- Reset view ---
49151
v3.VIconBtn(
50-
v_tooltip_bottom="'Zoom in'",
51-
icon="mdi-magnify-plus-outline",
52-
flat=True,
53-
click=zoom_in,
54-
)
55-
v3.VIconBtn(
56-
v_tooltip_bottom="'Zoom out'",
57-
icon="mdi-magnify-minus-outline",
152+
v_tooltip_bottom="'Reset view'",
153+
icon="mdi-fit-to-page-outline",
58154
flat=True,
59-
click=zoom_out,
155+
click=lambda: reset_camera(),
60156
)
61157

62-
v3.VSlider(
63-
v_model=("aspect_ratio", 0.5),
64-
prepend_icon="mdi-arrow-expand-vertical",
65-
min=0.25,
66-
max=2,
67-
step=0.05,
68-
density="compact",
69-
hide_details=True,
70-
style="max-width: 400px;",
71-
)
72-
v3.VSpacer()
158+
v3.VDivider(vertical=True, classes="mx-1")
73159

74-
# ------------------------------------------------------------
75-
# Add tooltip for keyboard shortcut??
76-
# ------------------------------------------------------------
77-
# with v3.VTooltip(location="bottom"):
78-
# with v3.Template(v_slot_activator="{ props }"):
79-
v3.VHotkey(keys="g", variant="contained", classes="mr-1")
160+
# --- Grouped/Uniform toggle ---
80161
v3.VCheckbox(
81-
# v_bind="props",
162+
v_tooltip_bottom="layout_grouped ? 'Switch to uniform' : 'Switch to grouped'",
82163
v_model=("layout_grouped", True),
83-
label=("layout_grouped ? 'Grouped' : 'Uniform'",),
84164
hide_details=True,
85165
inset=True,
86166
false_icon="mdi-apps",
87167
true_icon="mdi-focus-field",
88168
density="compact",
89169
)
90-
# with html.Span("Keyboard shortcut"):
91-
# v3.VHotkey(theme="dark", keys="g", variant="contained", inline=True, classes="ml-2 mt-n2")
92-
# ------------------------------------------------------------
93170

171+
# --- Size menu ---
94172
with v3.VBtn(
95-
"Size",
96-
classes="text-none mx-4",
97-
prepend_icon="mdi-view-column",
98-
append_icon="mdi-menu-down",
173+
v_tooltip_bottom="'Column layout'",
174+
flat=True,
175+
size="small",
99176
):
177+
v3.VIcon("mdi-view-column")
100178
with v3.VMenu(activator="parent"):
101179
with v3.VList(density="compact"):
102180
with v3.VListItem(

src/e3sm_quickview/view_manager.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,16 +1019,13 @@ def reset_camera(self):
10191019
for view in views:
10201020
view.disable_render = False
10211021

1022-
def zoom_in(self):
1023-
for view in list(self._var2view.values()):
1024-
cam = view.camera
1025-
cam.SetParallelScale(cam.GetParallelScale() / 1.2)
1022+
for view in views:
10261023
view.render()
10271024

1028-
def zoom_out(self):
1025+
def zoom(self, factor):
10291026
for view in list(self._var2view.values()):
10301027
cam = view.camera
1031-
cam.SetParallelScale(cam.GetParallelScale() * 1.2)
1028+
cam.SetParallelScale(cam.GetParallelScale() * factor)
10321029
view.render()
10331030

10341031
def get_zoom(self):
@@ -1043,6 +1040,47 @@ def set_zoom(self, scale):
10431040
view.camera.SetParallelScale(scale)
10441041
view.render()
10451042

1043+
def pan(self, dx, dy):
1044+
for view in list(self._var2view.values()):
1045+
cam = view.camera
1046+
scale = cam.GetParallelScale()
1047+
step = scale * 0.1
1048+
pos = list(cam.GetPosition())
1049+
foc = list(cam.GetFocalPoint())
1050+
pos[0] += dx * step
1051+
pos[1] += dy * step
1052+
foc[0] += dx * step
1053+
foc[1] += dy * step
1054+
cam.SetPosition(*pos)
1055+
cam.SetFocalPoint(*foc)
1056+
view.render()
1057+
1058+
def get_camera_state(self):
1059+
for view in list(self._var2view.values()):
1060+
cam = view.camera
1061+
return {
1062+
"zoom": cam.GetParallelScale(),
1063+
"position": list(cam.GetPosition()),
1064+
"focal_point": list(cam.GetFocalPoint()),
1065+
"view_up": list(cam.GetViewUp()),
1066+
"clipping_range": list(cam.GetClippingRange()),
1067+
}
1068+
return None
1069+
1070+
def set_camera_state(self, camera_state):
1071+
if camera_state is None:
1072+
return
1073+
for view in list(self._var2view.values()):
1074+
cam = view.camera
1075+
cam.SetParallelScale(camera_state["zoom"])
1076+
cam.SetPosition(*camera_state["position"])
1077+
cam.SetFocalPoint(*camera_state["focal_point"])
1078+
if "view_up" in camera_state:
1079+
cam.SetViewUp(*camera_state["view_up"])
1080+
if "clipping_range" in camera_state:
1081+
cam.SetClippingRange(*camera_state["clipping_range"])
1082+
view.render()
1083+
10461084
def render(self):
10471085
for view in list(self._var2view.values()):
10481086
view.render()

src/e3sm_quickview/view_manager2.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,14 +1090,8 @@ def reset_camera(self, render=True):
10901090
if render and view_to_reset:
10911091
self.render()
10921092

1093-
def zoom_in(self):
1094-
scale = self._camera.GetParallelScale()
1095-
self._camera.SetParallelScale(scale / 1.2)
1096-
self.render()
1097-
1098-
def zoom_out(self):
1099-
scale = self._camera.GetParallelScale()
1100-
self._camera.SetParallelScale(scale * 1.2)
1093+
def zoom(self, factor):
1094+
self._camera.SetParallelScale(self._camera.GetParallelScale() * factor)
11011095
self.render()
11021096

11031097
def get_zoom(self):
@@ -1109,6 +1103,43 @@ def set_zoom(self, scale):
11091103
self._camera.SetParallelScale(scale)
11101104
self.render()
11111105

1106+
def pan(self, dx, dy):
1107+
cam = self._camera
1108+
scale = cam.GetParallelScale()
1109+
step = scale * 0.1
1110+
pos = list(cam.GetPosition())
1111+
foc = list(cam.GetFocalPoint())
1112+
pos[0] += dx * step
1113+
pos[1] += dy * step
1114+
foc[0] += dx * step
1115+
foc[1] += dy * step
1116+
cam.SetPosition(*pos)
1117+
cam.SetFocalPoint(*foc)
1118+
self.render()
1119+
1120+
def get_camera_state(self):
1121+
cam = self._camera
1122+
return {
1123+
"zoom": cam.GetParallelScale(),
1124+
"position": list(cam.GetPosition()),
1125+
"focal_point": list(cam.GetFocalPoint()),
1126+
"view_up": list(cam.GetViewUp()),
1127+
"clipping_range": list(cam.GetClippingRange()),
1128+
}
1129+
1130+
def set_camera_state(self, camera_state):
1131+
if camera_state is None:
1132+
return
1133+
cam = self._camera
1134+
cam.SetParallelScale(camera_state["zoom"])
1135+
cam.SetPosition(*camera_state["position"])
1136+
cam.SetFocalPoint(*camera_state["focal_point"])
1137+
if "view_up" in camera_state:
1138+
cam.SetViewUp(*camera_state["view_up"])
1139+
if "clipping_range" in camera_state:
1140+
cam.SetClippingRange(*camera_state["clipping_range"])
1141+
self.render()
1142+
11121143
@controller.set("size_update")
11131144
def on_size_update(self):
11141145
if not self.layout_dirty or not self.pending_render:

0 commit comments

Comments
 (0)