feat(mobile): start in 3D view with toggle button, consolidate mobile detections

- Add segmented 3D/2D toggle bar at top of views area (hidden on desktop)
- Mobile (≤768px) starts in 3D view by default
- CSS data-mobile-view attribute controls panel visibility on mobile
- Remove redundant detectMobileLayout() JS function and .app.mobile-layout CSS
- Remove verbose logViewportInfo() logging
- Single isMobileViewport() helper replaces multiple JS-based detections
- apply3DVisibility() now desktop-only; mobile uses CSS for panel switching
- Graceful fallback: if WebGL unavailable, hide toggle and force 2D
This commit is contained in:
2026-02-13 12:32:59 +02:00
parent 9b0a7ba0e2
commit 8af00573db
3 changed files with 225 additions and 11 deletions

View File

@@ -17,9 +17,16 @@
<body>
<main class="app">
<section class="views-wrap">
<div class="mobile-view-toggle" id="mobile-view-toggle">
<button class="toggle-btn active" data-view="3d">3D View</button>
<button class="toggle-btn" data-view="2d">2D View</button>
</div>
<section class="view-panel" id="view-2d-panel">
<h2>2D View</h2>
<canvas id="sim-canvas" width="1100" height="700"></canvas>
<div class="canvas-container">
<canvas id="sim-canvas"></canvas>
</div>
</section>
<section class="view-panel" id="view-3d-panel">

View File

@@ -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;

View File

@@ -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;
}
}