Claude commited on
Commit
c51e2e9
·
unverified ·
1 Parent(s): e3398cb

feat: side-by-side inspection/chat layout + 360° spin interaction

Browse files

Layout:
- Inspection panel now occupies grid column 1, row 2 (left)
- Chat panel now occupies grid column 2, row 2 (right)
- Both panels sit side-by-side below the video/track cards row
- Chat messages area grows to fill available height
- Both panels have matching min-height 320px, max-height 480px

360° Spin:
- Inspection canvas supports click-drag to rotate in 3D space
- CSS perspective (1200px) + preserve-3d for smooth 3D transforms
- Horizontal drag rotates Y-axis, vertical drag rotates X-axis
(pitch clamped to ±80°)
- Inertia: releasing while dragging continues spinning with decay
- Double-click snaps back to front view with smooth transition
- Touch support for mobile devices
- Rotation resets on track change or panel close

https://claude.ai/code/session_01XQ1edVcrdcMErbKF53r1aF

Files changed (2) hide show
  1. frontend/js/ui/inspection.js +129 -0
  2. frontend/style.css +26 -9
frontend/js/ui/inspection.js CHANGED
@@ -26,6 +26,129 @@ APP.ui.inspection.init = function () {
26
  APP.ui.inspection.close();
27
  });
28
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  };
30
 
31
  /**
@@ -60,6 +183,9 @@ APP.ui.inspection.open = function (trackId) {
60
  // Clear caches if track or frame changed
61
  APP.ui.inspection._clearCaches();
62
 
 
 
 
63
  // Show panel
64
  panel.style.display = "flex";
65
 
@@ -94,6 +220,9 @@ APP.ui.inspection.close = function () {
94
 
95
  APP.ui.inspection._clearCaches();
96
 
 
 
 
97
  const panel = $("#inspectionPanel");
98
  if (panel) panel.style.display = "none";
99
 
 
26
  APP.ui.inspection.close();
27
  });
28
  }
29
+
30
+ // --- 360° spin interaction on inspection canvas ---
31
+ APP.ui.inspection._initSpin();
32
+ };
33
+
34
+ /**
35
+ * Wire drag-to-spin 3D rotation on the inspection canvas.
36
+ * Click + drag rotates the inspected object crop in 3D space.
37
+ * Double-click resets to front view.
38
+ */
39
+ APP.ui.inspection._initSpin = function () {
40
+ const { $ } = APP.core.utils;
41
+ const canvas = $("#inspectionCanvas");
42
+ if (!canvas) return;
43
+
44
+ let dragging = false;
45
+ let lastX = 0, lastY = 0;
46
+ let rotX = 0, rotY = 0; // current rotation in degrees
47
+ let velX = 0, velY = 0; // inertia velocity
48
+ let animFrame = null;
49
+
50
+ function applyTransform() {
51
+ canvas.style.transform = `rotateY(${rotY}deg) rotateX(${rotX}deg)`;
52
+ }
53
+
54
+ canvas.addEventListener("mousedown", (e) => {
55
+ if (e.button !== 0) return; // left click only
56
+ dragging = true;
57
+ lastX = e.clientX;
58
+ lastY = e.clientY;
59
+ velX = 0;
60
+ velY = 0;
61
+ canvas.classList.remove("spinning");
62
+ if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
63
+ e.preventDefault();
64
+ });
65
+
66
+ window.addEventListener("mousemove", (e) => {
67
+ if (!dragging) return;
68
+ const dx = e.clientX - lastX;
69
+ const dy = e.clientY - lastY;
70
+ rotY += dx * 0.5;
71
+ rotX -= dy * 0.5;
72
+ rotX = Math.max(-80, Math.min(80, rotX)); // clamp pitch
73
+ velX = dx * 0.5;
74
+ velY = -dy * 0.5;
75
+ lastX = e.clientX;
76
+ lastY = e.clientY;
77
+ applyTransform();
78
+ });
79
+
80
+ window.addEventListener("mouseup", () => {
81
+ if (!dragging) return;
82
+ dragging = false;
83
+ // Inertia spin
84
+ if (Math.abs(velX) > 1 || Math.abs(velY) > 1) {
85
+ canvas.classList.remove("spinning");
86
+ const decay = () => {
87
+ velX *= 0.95;
88
+ velY *= 0.95;
89
+ rotY += velX;
90
+ rotX += velY;
91
+ rotX = Math.max(-80, Math.min(80, rotX));
92
+ applyTransform();
93
+ if (Math.abs(velX) > 0.1 || Math.abs(velY) > 0.1) {
94
+ animFrame = requestAnimationFrame(decay);
95
+ }
96
+ };
97
+ animFrame = requestAnimationFrame(decay);
98
+ }
99
+ });
100
+
101
+ // Double-click to reset rotation smoothly
102
+ canvas.addEventListener("dblclick", () => {
103
+ if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
104
+ rotX = 0;
105
+ rotY = 0;
106
+ canvas.classList.add("spinning");
107
+ applyTransform();
108
+ setTimeout(() => canvas.classList.remove("spinning"), 800);
109
+ });
110
+
111
+ // Touch support
112
+ canvas.addEventListener("touchstart", (e) => {
113
+ if (e.touches.length !== 1) return;
114
+ dragging = true;
115
+ lastX = e.touches[0].clientX;
116
+ lastY = e.touches[0].clientY;
117
+ velX = 0;
118
+ velY = 0;
119
+ canvas.classList.remove("spinning");
120
+ if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
121
+ }, { passive: true });
122
+
123
+ canvas.addEventListener("touchmove", (e) => {
124
+ if (!dragging || e.touches.length !== 1) return;
125
+ const dx = e.touches[0].clientX - lastX;
126
+ const dy = e.touches[0].clientY - lastY;
127
+ rotY += dx * 0.5;
128
+ rotX -= dy * 0.5;
129
+ rotX = Math.max(-80, Math.min(80, rotX));
130
+ velX = dx * 0.5;
131
+ velY = -dy * 0.5;
132
+ lastX = e.touches[0].clientX;
133
+ lastY = e.touches[0].clientY;
134
+ applyTransform();
135
+ }, { passive: true });
136
+
137
+ canvas.addEventListener("touchend", () => {
138
+ if (!dragging) return;
139
+ dragging = false;
140
+ });
141
+
142
+ // Store reset function for use when switching tracks
143
+ APP.ui.inspection._resetSpin = function () {
144
+ if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
145
+ rotX = 0;
146
+ rotY = 0;
147
+ velX = 0;
148
+ velY = 0;
149
+ canvas.style.transform = "";
150
+ canvas.classList.remove("spinning");
151
+ };
152
  };
153
 
154
  /**
 
183
  // Clear caches if track or frame changed
184
  APP.ui.inspection._clearCaches();
185
 
186
+ // Reset 3D spin rotation
187
+ if (APP.ui.inspection._resetSpin) APP.ui.inspection._resetSpin();
188
+
189
  // Show panel
190
  panel.style.display = "flex";
191
 
 
220
 
221
  APP.ui.inspection._clearCaches();
222
 
223
+ // Reset 3D spin rotation
224
+ if (APP.ui.inspection._resetSpin) APP.ui.inspection._resetSpin();
225
+
226
  const panel = $("#inspectionPanel");
227
  if (panel) panel.style.display = "none";
228
 
frontend/style.css CHANGED
@@ -1030,9 +1030,10 @@ input[type="number"]:focus {
1030
  ========================================= */
1031
 
1032
  .panel-chat {
1033
- grid-column: 1 / -1;
1034
- grid-row: 3;
1035
- max-height: 260px;
 
1036
  display: flex;
1037
  flex-direction: column;
1038
  }
@@ -1043,6 +1044,7 @@ input[type="number"]:focus {
1043
 
1044
  .panel-chat.collapsed {
1045
  max-height: 44px;
 
1046
  }
1047
 
1048
  .chat-container {
@@ -1064,7 +1066,6 @@ input[type="number"]:focus {
1064
  flex-direction: column;
1065
  gap: 8px;
1066
  min-height: 100px;
1067
- max-height: 160px;
1068
  }
1069
 
1070
  .chat-message {
@@ -1168,9 +1169,10 @@ input[type="number"]:focus {
1168
  ========================================= */
1169
 
1170
  .panel-inspection {
1171
- grid-column: 1 / -1;
1172
  grid-row: 2;
1173
- max-height: 420px;
 
1174
  display: flex;
1175
  flex-direction: column;
1176
  animation: slideDown 0.25s ease;
@@ -1185,7 +1187,7 @@ input[type="number"]:focus {
1185
  }
1186
  to {
1187
  opacity: 1;
1188
- max-height: 420px;
1189
  }
1190
  }
1191
 
@@ -1231,6 +1233,7 @@ input[type="number"]:focus {
1231
  overflow: hidden;
1232
  background: radial-gradient(600px 300px at 50% 50%, rgba(0, 0, 0, .45), rgba(0, 0, 0, .25));
1233
  border: 1px solid rgba(255, 255, 255, .08);
 
1234
  }
1235
 
1236
  .inspection-viewport canvas {
@@ -1238,6 +1241,17 @@ input[type="number"]:focus {
1238
  height: 100%;
1239
  display: block;
1240
  object-fit: contain;
 
 
 
 
 
 
 
 
 
 
 
1241
  }
1242
 
1243
  #inspection3dContainer {
@@ -1300,7 +1314,10 @@ input[type="number"]:focus {
1300
  padding: 20px;
1301
  }
1302
 
1303
- /* When sidebar collapsed, inspection still spans full width */
1304
  .engage-grid.sidebar-collapsed .panel-inspection {
1305
- grid-column: 1 / -1;
 
 
 
1306
  }
 
1030
  ========================================= */
1031
 
1032
  .panel-chat {
1033
+ grid-column: 2;
1034
+ grid-row: 2;
1035
+ max-height: 480px;
1036
+ min-height: 320px;
1037
  display: flex;
1038
  flex-direction: column;
1039
  }
 
1044
 
1045
  .panel-chat.collapsed {
1046
  max-height: 44px;
1047
+ min-height: 0;
1048
  }
1049
 
1050
  .chat-container {
 
1066
  flex-direction: column;
1067
  gap: 8px;
1068
  min-height: 100px;
 
1069
  }
1070
 
1071
  .chat-message {
 
1169
  ========================================= */
1170
 
1171
  .panel-inspection {
1172
+ grid-column: 1;
1173
  grid-row: 2;
1174
+ max-height: 480px;
1175
+ min-height: 320px;
1176
  display: flex;
1177
  flex-direction: column;
1178
  animation: slideDown 0.25s ease;
 
1187
  }
1188
  to {
1189
  opacity: 1;
1190
+ max-height: 480px;
1191
  }
1192
  }
1193
 
 
1233
  overflow: hidden;
1234
  background: radial-gradient(600px 300px at 50% 50%, rgba(0, 0, 0, .45), rgba(0, 0, 0, .25));
1235
  border: 1px solid rgba(255, 255, 255, .08);
1236
+ perspective: 1200px;
1237
  }
1238
 
1239
  .inspection-viewport canvas {
 
1241
  height: 100%;
1242
  display: block;
1243
  object-fit: contain;
1244
+ transform-style: preserve-3d;
1245
+ transition: transform 0.05s linear;
1246
+ cursor: grab;
1247
+ }
1248
+
1249
+ .inspection-viewport canvas:active {
1250
+ cursor: grabbing;
1251
+ }
1252
+
1253
+ .inspection-viewport canvas.spinning {
1254
+ transition: transform 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
1255
  }
1256
 
1257
  #inspection3dContainer {
 
1314
  padding: 20px;
1315
  }
1316
 
1317
+ /* When sidebar collapsed, panels still keep their columns */
1318
  .engage-grid.sidebar-collapsed .panel-inspection {
1319
+ grid-column: 1;
1320
+ }
1321
+ .engage-grid.sidebar-collapsed .panel-chat {
1322
+ grid-column: 2;
1323
  }