Skip to content

Commit 04933fc

Browse files
authored
Merge pull request #198 from CU-ESIIL/codex/add-dynamic-drift-centering-behavior
Add dynamic drift-centering V1 to cube viewer
2 parents 0b16af3 + fb3e5cc commit 04933fc

5 files changed

Lines changed: 183 additions & 18 deletions

File tree

src/cubedynamics/plotting/cube_viewer.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
resolve_axis_rig_spec,
2626
)
2727
from cubedynamics.utils import _infer_time_y_x_dims
28+
from cubedynamics.utils.drift_centering import drift_centering_script
2829
from cubedynamics.plotting.progress import _CubeProgress
2930
from cubedynamics.plotting.viewer import show_cube_viewer
3031
from cubedynamics.vase import VasePanel
@@ -297,12 +298,24 @@ def _render_cube_html(
297298
--cd-axis-tick-l
298299
--cd-axis-stroke
299300
*/
301+
/* Drift centering tuning (DYNAMIC DRIFT CENTERING V1):
302+
--cd-drift-max-px: maximum horizontal drift in pixels.
303+
--cd-drift-rot-range-deg: rotation range (deg) that maps to full drift.
304+
--cd-drift-scale-in: scale that cancels drift when zoomed in.
305+
--cd-drift-scale-out: scale that enables full drift when zoomed out.
306+
--cd-drift-smoothing: easing factor for motion smoothing.
307+
*/
300308
--cd-cube-size: {cube_size_css};
301309
--cube-size: var(--cd-cube-size);
302310
--cd-axis-font: clamp(11px, calc(var(--cd-cube-size) * 0.045), 18px);
303311
--cd-axis-pill-px: clamp(4px, calc(var(--cd-cube-size) * 0.010), 8px);
304312
--cd-axis-stroke: clamp(2px, calc(var(--cd-cube-size) * 0.006), 4px);
305313
--cd-axis-tick-l: clamp(10px, calc(var(--cd-cube-size) * 0.035), 18px);
314+
--cd-drift-max-px: 220;
315+
--cd-drift-rot-range-deg: 55;
316+
--cd-drift-scale-in: 1.25;
317+
--cd-drift-scale-out: 0.85;
318+
--cd-drift-smoothing: 0.18;
306319
{css_vars}
307320
}}
308321
* {{ box-sizing: border-box; }}
@@ -430,6 +443,8 @@ def _render_cube_html(
430443
.cube-scene {{
431444
position: relative;
432445
inset: 0;
446+
width: 100%;
447+
height: 100%;
433448
perspective: 950px;
434449
transform-style: preserve-3d;
435450
--rot-x: 0rad;
@@ -454,6 +469,12 @@ def _render_cube_html(
454469
pointer-events: auto;
455470
}}
456471
472+
.cd-drift-center-fallback {{
473+
--cd-drift-x: 0px;
474+
--cd-drift-base-transform: none;
475+
transform: translateX(var(--cd-drift-x, 0px)) var(--cd-drift-base-transform, none);
476+
}}
477+
457478
.cd-cube {{
458479
position: absolute;
459480
inset: 0;
@@ -623,26 +644,28 @@ def _render_cube_html(
623644
<div class=\"cube-main\">
624645
<div class=\"cube-inner\">
625646
<div class=\"cube-container\">
626-
<div id=\"cube-wrapper-{viewer_id}\" class=\"cube-wrapper\" style=\"--rot-x: {rot_x_rad:.4f}rad; --rot-y: {rot_y_rad:.4f}rad; --zoom: {zoom};\">
627-
<canvas class=\"cube-canvas\" id=\"cube-canvas-{viewer_id}\"></canvas>
628-
<div class=\"cube-rotation\" id=\"cube-rotation-{viewer_id}\">
629-
{cube_faces_html}
630-
{interior_html}
631-
{vase_html}
632-
{axis_rig_markup}
647+
<div class=\"cube-scene\" id=\"cube-scene-{viewer_id}\">
648+
<div id=\"cube-wrapper-{viewer_id}\" class=\"cube-wrapper\" style=\"--rot-x: {rot_x_rad:.4f}rad; --rot-y: {rot_y_rad:.4f}rad; --zoom: {zoom};\">
649+
<canvas class=\"cube-canvas\" id=\"cube-canvas-{viewer_id}\"></canvas>
650+
<div class=\"cube-rotation\" id=\"cube-rotation-{viewer_id}\">
651+
{cube_faces_html}
652+
{interior_html}
653+
{vase_html}
654+
{axis_rig_markup}
655+
</div>
656+
<div class=\"cube-drag-surface\" id=\"cube-drag-{viewer_id}\"></div>
633657
</div>
634-
<div class=\"cube-drag-surface\" id=\"cube-drag-{viewer_id}\"></div>
635-
</div>
636658
637-
<div class=\"axis-label cube-label cube-label-x axis-x-min\">{x_meta.get('min','')}</div>
638-
<div class=\"axis-label cube-label cube-label-x axis-x-max\">{x_meta.get('max','')}</div>
639-
<div class=\"axis-label cube-label cube-label-y axis-y-min\">{y_meta.get('min','')}</div>
640-
<div class=\"axis-label cube-label cube-label-y axis-y-max\">{y_meta.get('max','')}</div>
641-
<div class=\"axis-label cube-label cube-label-time axis-t-min\">{time_meta.get('min','')}</div>
642-
<div class=\"axis-label cube-label cube-label-time axis-t-max\">{time_meta.get('max','')}</div>
643-
<div class=\"axis-label cube-label cube-label-x axis-x-name\">{x_meta.get('name','')}</div>
644-
<div class=\"axis-label cube-label cube-label-y axis-y-name\">{y_meta.get('name','')}</div>
645-
<div class=\"axis-label cube-label cube-label-time axis-t-name\">{time_meta.get('name','')}</div>
659+
<div class=\"axis-label cube-label cube-label-x axis-x-min\">{x_meta.get('min','')}</div>
660+
<div class=\"axis-label cube-label cube-label-x axis-x-max\">{x_meta.get('max','')}</div>
661+
<div class=\"axis-label cube-label cube-label-y axis-y-min\">{y_meta.get('min','')}</div>
662+
<div class=\"axis-label cube-label cube-label-y axis-y-max\">{y_meta.get('max','')}</div>
663+
<div class=\"axis-label cube-label cube-label-time axis-t-min\">{time_meta.get('min','')}</div>
664+
<div class=\"axis-label cube-label cube-label-time axis-t-max\">{time_meta.get('max','')}</div>
665+
<div class=\"axis-label cube-label cube-label-x axis-x-name\">{x_meta.get('name','')}</div>
666+
<div class=\"axis-label cube-label cube-label-y axis-y-name\">{y_meta.get('name','')}</div>
667+
<div class=\"axis-label cube-label cube-label-time axis-t-name\">{time_meta.get('name','')}</div>
668+
</div>
646669
</div>
647670
</div>
648671
</div>
@@ -660,6 +683,7 @@ def _render_cube_html(
660683
</div>
661684
662685
{axis_rig_meta_block}
686+
{drift_centering_script(viewer_id)}
663687
<script>
664688
(function() {{
665689
const viewerId = "{viewer_id}";
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Shared HTML/JS helpers for drift centering the cube viewer."""
2+
3+
from __future__ import annotations
4+
5+
6+
def drift_centering_script(viewer_id: str | None = None) -> str:
7+
"""Return the drift-centering script tag for cube viewers."""
8+
9+
viewer_id_js = f'"{viewer_id}"' if viewer_id else "null"
10+
return f"""
11+
<script id="cd-drift-center-v1-js">
12+
(function() {{
13+
// Drift centering defaults (DYNAMIC DRIFT CENTERING V1):
14+
// MAX_DRIFT_PX=220, ROT_RANGE_DEG=55, SCALE_IN=1.25, SCALE_OUT=0.85, SMOOTHING=0.18
15+
const viewerId = {viewer_id_js};
16+
const defaults = {{
17+
maxDriftPx: 220,
18+
rotRangeDeg: 55,
19+
scaleIn: 1.25,
20+
scaleOut: 0.85,
21+
smoothing: 0.18,
22+
}};
23+
24+
const registry = window.__cdDriftCenterV1 || {{
25+
viewers: new Set(),
26+
rafId: null,
27+
}};
28+
window.__cdDriftCenterV1 = registry;
29+
window.__cdDriftCenterInstalled = true;
30+
31+
function readNumber(styles, name, fallback) {{
32+
const value = parseFloat(styles.getPropertyValue(name));
33+
return Number.isFinite(value) ? value : fallback;
34+
}}
35+
36+
function resolveConfig(root) {{
37+
const styles = getComputedStyle(root);
38+
return {{
39+
maxDriftPx: readNumber(styles, "--cd-drift-max-px", defaults.maxDriftPx),
40+
rotRangeDeg: readNumber(styles, "--cd-drift-rot-range-deg", defaults.rotRangeDeg),
41+
scaleIn: readNumber(styles, "--cd-drift-scale-in", defaults.scaleIn),
42+
scaleOut: readNumber(styles, "--cd-drift-scale-out", defaults.scaleOut),
43+
smoothing: readNumber(styles, "--cd-drift-smoothing", defaults.smoothing),
44+
}};
45+
}}
46+
47+
function getViewerRoots() {{
48+
if (viewerId) {{
49+
const root = document.getElementById("cube-figure-" + viewerId);
50+
return root ? [root] : Array.from(document.querySelectorAll(".cube-figure"));
51+
}}
52+
return Array.from(document.querySelectorAll(".cube-figure"));
53+
}}
54+
55+
function applyDrift(state, x) {{
56+
if (state.supportsTranslate) {{
57+
state.scene.style.translate = `${{x}}px 0px`;
58+
return;
59+
}}
60+
if (!state.fallbackReady) {{
61+
const baseTransform = getComputedStyle(state.scene).transform;
62+
state.scene.style.setProperty(
63+
"--cd-drift-base-transform",
64+
baseTransform && baseTransform !== "none" ? baseTransform : "none",
65+
);
66+
state.scene.classList.add("cd-drift-center-fallback");
67+
state.fallbackReady = true;
68+
}}
69+
state.scene.style.setProperty("--cd-drift-x", `${{x}}px`);
70+
}}
71+
72+
function updateViewer(state) {{
73+
const transform = getComputedStyle(state.rotation).transform;
74+
const matrix = new DOMMatrixReadOnly(transform === "none" ? undefined : transform);
75+
const scale = Math.hypot(matrix.m11, matrix.m12, matrix.m13) || 1;
76+
const rotY = Math.atan2(matrix.m13, matrix.m11);
77+
const rotDeg = rotY * 180 / Math.PI;
78+
const normRot = Math.max(-1, Math.min(1, rotDeg / state.config.rotRangeDeg));
79+
const z = Math.max(
80+
0,
81+
Math.min(
82+
1,
83+
(scale - state.config.scaleOut) / (state.config.scaleIn - state.config.scaleOut),
84+
),
85+
);
86+
const strength = 1 - z;
87+
state.targetX = -normRot * state.config.maxDriftPx * strength;
88+
state.currentX = state.currentX + (state.targetX - state.currentX) * state.config.smoothing;
89+
applyDrift(state, state.currentX);
90+
}}
91+
92+
function tick() {{
93+
registry.viewers.forEach((state) => {{
94+
if (!state.root.isConnected) {{
95+
registry.viewers.delete(state);
96+
return;
97+
}}
98+
updateViewer(state);
99+
}});
100+
if (registry.viewers.size > 0) {{
101+
registry.rafId = window.requestAnimationFrame(tick);
102+
}} else {{
103+
registry.rafId = null;
104+
}}
105+
}}
106+
107+
function registerViewer(root) {{
108+
if (!root || root.dataset.cdDriftCenterInstalled === "true") return;
109+
const rotation = root.querySelector(".cube-rotation");
110+
const scene =
111+
root.querySelector(".cube-scene")
112+
|| root.querySelector(".cube-wrapper");
113+
if (!rotation || !scene) return;
114+
root.dataset.cdDriftCenterInstalled = "true";
115+
const state = {{
116+
root,
117+
rotation,
118+
scene,
119+
config: resolveConfig(root),
120+
currentX: 0,
121+
targetX: 0,
122+
supportsTranslate: "translate" in document.documentElement.style,
123+
fallbackReady: false,
124+
}};
125+
registry.viewers.add(state);
126+
if (!registry.rafId) {{
127+
registry.rafId = window.requestAnimationFrame(tick);
128+
}}
129+
}}
130+
131+
getViewerRoots().forEach(registerViewer);
132+
}})();
133+
</script>
134+
"""
135+
136+
137+
__all__ = ["drift_centering_script"]

src/cubedynamics/viewers/cube_viewer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from matplotlib import colormaps
1313

1414
from ..deprecations import warn_deprecated
15+
from ..utils.drift_centering import drift_centering_script
1516

1617

1718
def _infer_dims(da: xr.DataArray) -> Tuple[str, str, str]:
@@ -174,6 +175,7 @@ def write_cube_viewer(
174175
.replace("__SCALE_X__", f"{scale_x:.6f}")
175176
.replace("__SCALE_Y__", f"{scale_time:.6f}")
176177
.replace("__SCALE_Z__", f"{scale_y:.6f}")
178+
.replace("__CD_DRIFT_CENTER_JS__", drift_centering_script())
177179
)
178180

179181
out_path = Path(out_html)

src/cubedynamics/viewers/templates/cube_viewer_template.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
</div>
9494
</div>
9595

96+
__CD_DRIFT_CENTER_JS__
9697
<!-- Minimal embedded three-like renderer (self contained, no CDN) -->
9798
<script>
9899
// Utility math helpers

tests/test_cube_viewer_interactivity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def test_cube_viewer_emits_interactive_markup(tmp_path):
3737
assert 'id="cube-rotation-' in html
3838
assert 'id="cube-drag-' in html
3939
assert 'id="cube-js-warning-' in html
40+
assert 'id="cd-drift-center-v1-js"' in html
4041
assert 'addEventListener("pointerdown"' in html
4142
assert 'addEventListener("wheel"' in html
4243
assert 'passive: false' in html

0 commit comments

Comments
 (0)