diff --git a/index.html b/index.html
index 64eb01a..eb773b3 100644
--- a/index.html
+++ b/index.html
@@ -17,9 +17,16 @@
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
index 4409d21..1ccc6ec 100644
--- a/src/main.js
+++ b/src/main.js
@@ -21,6 +21,7 @@ const canvas = document.getElementById('sim-canvas');
const view3dPanelEl = document.getElementById('view-3d-panel');
const view3dEl = document.getElementById('sim-3d');
const viewsWrapEl = document.querySelector('.views-wrap');
+const mobileToggleEl = document.getElementById('mobile-view-toggle');
const diagEl = document.getElementById('diag');
const state = {
@@ -33,6 +34,7 @@ const state = {
showAnnotations: false,
show3d: true,
canShow3d: true,
+ mobileView: '3d', // which view is active on mobile: '3d' | '2d'
lengthCm: { ...DEFAULT_LENGTH_CM },
fallbackCount: 0,
pose: null,
@@ -56,22 +58,48 @@ const renderer3d = create3DRenderer(view3dEl);
state.canShow3d = renderer3d.available;
state.show3d = renderer3d.available;
+/* ── Mobile helpers ── */
+function isMobileViewport() {
+ return window.innerWidth <= 768;
+}
+
+function setMobileView(mode) {
+ state.mobileView = mode; // '3d' | '2d'
+ if (viewsWrapEl) viewsWrapEl.setAttribute('data-mobile-view', mode);
+ if (mobileToggleEl) {
+ for (const btn of mobileToggleEl.querySelectorAll('.toggle-btn')) {
+ btn.classList.toggle('active', btn.dataset.view === mode);
+ }
+ }
+ // On mobile, CSS handles panel display via data-mobile-view.
+ // Clear any inline display overrides so CSS can work.
+ if (view3dPanelEl) view3dPanelEl.style.display = '';
+ // Let the 3D renderer know if it should actually render
+ const render3d = mode === '3d' && state.canShow3d;
+ state.show3d = render3d;
+ renderer3d.setVisible(render3d);
+ resizeViews();
+}
+
function resize2DCanvasToDisplaySize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
- const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
- const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
+ const container = canvas.parentElement;
+ const width = Math.max(1, Math.floor(container.clientWidth * dpr));
+ const height = Math.max(1, Math.floor(container.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
+/** Desktop-only: controls 3D panel visibility via inline styles */
function apply3DVisibility() {
+ if (isMobileViewport()) return; // mobile handled by setMobileView
if (view3dPanelEl) {
view3dPanelEl.style.display = state.show3d ? '' : 'none';
}
if (viewsWrapEl) {
- viewsWrapEl.style.gridTemplateColumns = state.show3d ? '1fr 1fr' : '1fr';
+ viewsWrapEl.classList.toggle('single-view', !state.show3d);
}
renderer3d.setVisible(state.show3d);
}
@@ -295,13 +323,46 @@ const ui = wireUI(state, {
});
state.pose = applyGrounding(solveWalker(state.theta, null, currentLengthScale()));
-apply3DVisibility();
+
+/* ── Mobile toggle button wiring ── */
+if (mobileToggleEl) {
+ mobileToggleEl.addEventListener('click', (e) => {
+ const btn = e.target.closest('.toggle-btn');
+ if (!btn) return;
+ const view = btn.dataset.view;
+ if (view === '3d' || view === '2d') setMobileView(view);
+ });
+}
+
+// Initialize: on mobile start with 3D, on desktop show both
+if (isMobileViewport()) {
+ if (state.canShow3d) {
+ setMobileView('3d');
+ } else {
+ // WebGL unavailable – hide the toggle, force 2D
+ if (mobileToggleEl) mobileToggleEl.style.display = 'none';
+ setMobileView('2d');
+ }
+} else {
+ apply3DVisibility();
+}
resizeViews();
renderScene(canvas, state);
if (state.show3d) renderer3d.render(state);
updateDiagnostics(diagEl, state);
-window.addEventListener('resize', resizeViews);
+window.addEventListener('resize', () => {
+ if (isMobileViewport()) {
+ // Reapply current mobile view mode
+ setMobileView(state.mobileView);
+ } else {
+ // Desktop: show both panels, remove mobile data attr
+ if (viewsWrapEl) viewsWrapEl.removeAttribute('data-mobile-view');
+ state.show3d = state.canShow3d;
+ apply3DVisibility();
+ }
+ resizeViews();
+});
const fixedDt = 1 / 240;
let acc = 0;
diff --git a/styles.css b/styles.css
index 2445721..46152e4 100644
--- a/styles.css
+++ b/styles.css
@@ -11,17 +11,30 @@
box-sizing: border-box;
}
+html,
+body {
+ overflow-x: hidden;
+ max-width: 100vw;
+}
+
body {
margin: 0;
font-family: Inter, Segoe UI, Roboto, Arial, sans-serif;
background: var(--bg);
color: var(--text);
+ line-height: 1.4;
}
.app {
display: grid;
grid-template-columns: 1fr 320px;
min-height: 100vh;
+ max-width: 100vw;
+}
+
+/* ── Mobile view toggle (hidden on desktop) ── */
+.mobile-view-toggle {
+ display: none;
}
.views-wrap {
@@ -32,6 +45,10 @@ body {
min-height: 0;
}
+.views-wrap.single-view {
+ grid-template-columns: 1fr;
+}
+
.view-panel {
min-height: 0;
display: flex;
@@ -42,7 +59,24 @@ body {
margin: 0 0 8px;
}
-#sim-canvas,
+.canvas-container {
+ width: 100%;
+ height: 100%;
+ min-height: 260px;
+ background: #0f1116;
+ border: 1px solid #2a3342;
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+ flex: 1 1 0%;
+}
+
+#sim-canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
.sim-3d {
width: 100%;
height: 100%;
@@ -50,16 +84,15 @@ body {
border: 1px solid #2a3342;
border-radius: 8px;
min-height: 260px;
-}
-
-.sim-3d {
overflow: hidden;
+ flex: 1 1 0%;
}
.panel {
background: var(--panel);
border-left: 1px solid #2a3342;
padding: 16px;
+ min-width: 0;
}
#tweakpane-root {
@@ -158,6 +191,119 @@ select {
.views-wrap {
grid-template-columns: 1fr;
- min-height: 70vh;
+ min-height: auto;
+ }
+
+ .canvas-container,
+ .sim-3d {
+ aspect-ratio: 4 / 3;
+ height: auto;
+ min-height: 220px;
+ }
+}
+
+@media (max-width: 768px) {
+ .mobile-view-toggle {
+ display: flex;
+ gap: 0;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid #3d5274;
+ margin-bottom: 8px;
+ }
+
+ .toggle-btn {
+ flex: 1;
+ padding: 10px 0;
+ border: none;
+ border-radius: 0;
+ background: #1d222b;
+ color: var(--muted);
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ min-height: 0;
+ }
+
+ .toggle-btn.active {
+ background: var(--accent);
+ color: #111;
+ }
+
+ /* Hide the inactive view panel on mobile */
+ .views-wrap[data-mobile-view="3d"] #view-2d-panel {
+ display: none;
+ }
+
+ .views-wrap[data-mobile-view="2d"] #view-3d-panel {
+ display: none;
+ }
+
+ /* Hide h2 titles on mobile (toggle replaces them) */
+ .view-panel h2 {
+ display: none;
+ }
+
+ .views-wrap {
+ padding: 8px;
+ gap: 8px;
+ }
+
+ .canvas-container,
+ .sim-3d {
+ aspect-ratio: 4 / 3;
+ min-height: 200px;
+ border-radius: 6px;
+ }
+
+ .panel {
+ padding: 12px;
+ }
+
+ h1 {
+ font-size: 1rem;
+ margin-bottom: 10px;
+ }
+
+ h2 {
+ margin-top: 10px;
+ font-size: 0.9rem;
+ }
+
+ .row {
+ margin-bottom: 10px;
+ }
+
+ button,
+ input[type='text'],
+ select {
+ min-height: 40px;
+ }
+}
+
+@media (max-width: 480px) {
+ .views-wrap {
+ padding: 6px;
+ gap: 6px;
+ }
+
+ .canvas-container,
+ .sim-3d {
+ aspect-ratio: 4 / 3;
+ min-height: 160px;
+ }
+
+ .panel {
+ padding: 10px;
+ }
+
+ .buttons {
+ grid-template-columns: 1fr;
+ }
+
+ .diag pre {
+ font-size: 11px;
+ padding: 8px;
}
}