From f52885150c87af156dab482473358b4d1c83e8cc Mon Sep 17 00:00:00 2001 From: Ashish Nagar Date: Fri, 31 Jul 2026 13:17:30 -0400 Subject: [PATCH] Make the site usable on a phone The panel is 245px of fixed-position column at the top right, which on a phone covers the thing it is meant to control. On a touch device it is now a bottom sheet: full width, collapsed to its title bar on load, opening to at most 62% of the screen with its own scroll, and with rows tall enough to hit with a thumb. The rest of the interface moves out of its way. The readout and a new interface toggle sit at the top, clear of both the sheet and any notch (env(safe-area-inset-*) throughout, with viewport-fit=cover so the render still reaches the edges). That toggle exists because the H shortcut is meaningless without a keyboard, and it is the one control that survives cinematic mode: with no keyboard it is the only way back. It shows the action rather than the state, an eye while the interface is hidden and a struck-through eye while it is showing, as inline SVG rather than an emoji that renders differently on every platform. The panel title and the toast say "tap" instead of "press H" when that is what the device actually offers. Render budget: a phone GPU is perhaps a tenth of a desktop card and reports a device pixel ratio of 3, so an unthrottled raymarch draws nine times the pixels on a tenth of the hardware. Mobile starts at the low preset, caps the pixel ratio at 1.25, and bakes a 512px sky rather than 1024, which also removes a visible stall at boot. The existing auto-degrade still handles anything slower. `touch-action: none` on the page and canvas, so a drag is a camera move rather than the browser scrolling or pinch-zooming first. Mobile is decided once at boot from a coarse pointer *and* a small screen: a touchscreen laptop keeps the desktop layout, and a narrow desktop window keeps its keyboard shortcuts. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + index.html | 147 ++++++++++++++++++++++++++++++++++++++-- src/main.ts | 30 ++++++-- src/render/starfield.ts | 5 +- src/ui/chrome.ts | 8 ++- src/ui/device.ts | 37 ++++++++++ src/ui/panel.ts | 38 +++++++++-- 7 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 src/ui/device.ts diff --git a/README.md b/README.md index aa16228..7296e30 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ npm run preview # serve the production build | Sound | **Sound → Enabled** (browsers require one click on the page first) | | Change the sky | **Deep sky** folder: star density, nebulae, galaxies, or reseed the whole thing | | Art-direction knobs | append `?debug=1` to the URL for the hidden tuning folder | +| On a phone | the panel is a bottom sheet: tap its bar to open it, and the eye button hides the whole interface. Quality starts at `low` and the render resolution is capped, because a phone GPU is roughly a tenth of a desktop card at three times the pixel density. | Every control carries a hover description, and each folder has a reset button. diff --git a/index.html b/index.html index 05f85f7..be72042 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,18 @@ - + + + + + Black Hole Visualizer
+ + diff --git a/src/main.ts b/src/main.ts index fa1b7c1..4dfe71c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import { nextWaveState, restingWave } from './sim/gravitationalWave'; import { clearBody, createWorld, placeBinary, placeBody, resetScene, stepWorld } from './sim/world'; import type { Body } from './sim/types'; import { CinematicMode, isTypingIntoControl } from './ui/chrome'; +import { isMobile, mobileRenderBudget } from './ui/device'; import { buildPanel } from './ui/panel'; import type { Preset } from './ui/presets'; import { PlacementController } from './ui/placement'; @@ -57,6 +58,15 @@ function requireElement(id: string): HTMLElement { const app = requireElement('app'); const hud = requireElement('hud'); const toast = requireElement('toast'); +const chromeToggle = requireElement('chrome-toggle'); + +// A finger-driven, small screen gets a different layout and a smaller render +// budget. Decided once at boot: a phone does not become a desktop mid-session, +// and re-laying-out the panel on every orientation change would be worse than +// the sizes being slightly off in landscape. +const mobile = isMobile(); +if (mobile) document.body.classList.add('touch-ui'); +const budget = mobileRenderBudget(); const renderer = new THREE.WebGLRenderer({ antialias: false, @@ -66,13 +76,14 @@ if (!renderer.capabilities.isWebGL2) { hud.textContent = 'This visualizer needs WebGL2, which this browser does not provide.'; throw new Error('WebGL2 required'); } -renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, mobile ? budget.pixelRatio : 2)); app.appendChild(renderer.domElement); const settings = defaultSettings(); +if (mobile) settings.quality = 'low'; const world = createWorld(); -const starfield = new Starfield(renderer, settings.sky); +const starfield = new Starfield(renderer, settings.sky, mobile ? budget.skyFaceSize : undefined); const rig = new CameraRig(window.innerWidth / window.innerHeight, renderer.domElement); const bhPass = new BlackHolePass(starfield.texture, settings.quality); const pipeline = new RenderPipeline(renderer, rig.camera, bhPass, settings.quality); @@ -134,7 +145,18 @@ const aiming = new AimingController( }, ); -const cinematic = new CinematicMode(document.body, toast); +const cinematic = new CinematicMode( + document.body, + toast, + mobile ? 'tap the button to bring them back' : 'press H for the controls', +); +chromeToggle.addEventListener('click', () => { + cinematic.toggle(); + chromeToggle.setAttribute( + 'aria-label', + cinematic.isActive ? 'Show the interface' : 'Hide the interface', + ); +}); /** A re-bake invalidates the converged idle frame, so accumulation restarts. */ function rebakeSky(): void { @@ -224,7 +246,7 @@ const panel = buildPanel( rebakeSky(); }, }, - new URLSearchParams(window.location.search).has('debug'), + { debug: new URLSearchParams(window.location.search).has('debug'), compact: mobile }, ); audio.setVolume(settings.volume); diff --git a/src/render/starfield.ts b/src/render/starfield.ts index 1a843fa..cd5fde5 100644 --- a/src/render/starfield.ts +++ b/src/render/starfield.ts @@ -26,8 +26,11 @@ export class Starfield { constructor( private readonly renderer: THREE.WebGLRenderer, sky: SkySettings, + /** Smaller on phones: six faces of a heavy noise shader at full size is a + * visible stall on a mobile GPU, and the stars are tiny there anyway. */ + faceSize: number = SKY_TUNING.faceSize, ) { - this.target = new THREE.WebGLCubeRenderTarget(SKY_TUNING.faceSize, { + this.target = new THREE.WebGLCubeRenderTarget(faceSize, { type: THREE.HalfFloatType, generateMipmaps: false, }); diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts index f2548d1..29bf57c 100644 --- a/src/ui/chrome.ts +++ b/src/ui/chrome.ts @@ -2,6 +2,10 @@ * Cinematic mode: the control panel and HUD fade away so the render fills the * screen like a wallpaper. One class on drives the CSS; a toast (which * stays visible in either mode) says how to get the controls back. + * + * On a touch device the same state is driven by a floating button instead of + * the H key, and that button is deliberately the one piece of interface that + * never fades: with no keyboard there has to be a way back. */ const CINEMATIC_CLASS = 'cinematic'; @@ -14,6 +18,8 @@ export class CinematicMode { constructor( private readonly body: HTMLElement, private readonly toast: HTMLElement, + /** How the user gets the interface back, in their own input terms. */ + private readonly restoreHint: string, ) {} /** True while the panel and HUD are hidden. */ @@ -41,7 +47,7 @@ export class CinematicMode { private apply(): void { this.body.classList.toggle(CINEMATIC_CLASS, this.hidden); - this.flash(this.hidden ? 'cinematic mode · press H for the controls' : 'controls restored'); + this.flash(this.hidden ? `cinematic mode · ${this.restoreHint}` : 'controls restored'); } private flash(message: string): void { diff --git a/src/ui/device.ts b/src/ui/device.ts new file mode 100644 index 0000000..da71144 --- /dev/null +++ b/src/ui/device.ts @@ -0,0 +1,37 @@ +/** + * What kind of device is this, in the only terms the app actually cares about. + * + * Deliberately not "is it a phone". The questions worth asking are whether the + * pointer is a finger (so hover text and a keyboard shortcut are useless) and + * whether the screen is small enough that a 245px panel would swallow it. + */ + +/** A finger or stylus rather than a mouse: no hover, no keyboard shortcuts. */ +export function hasCoarsePointer(): boolean { + return window.matchMedia('(pointer: coarse)').matches; +} + +/** Narrow enough that the control panel would cover most of the render. */ +export function hasCompactScreen(): boolean { + return Math.min(window.innerWidth, window.innerHeight) < 620; +} + +/** + * Treat as mobile when both are true. A touchscreen laptop keeps the desktop + * layout, and a narrow desktop window keeps its keyboard shortcuts; only a + * device that is both small and finger-driven gets the phone treatment. + */ +export function isMobile(): boolean { + return hasCoarsePointer() && hasCompactScreen(); +} + +/** + * Render budget for this device. Phone GPUs are perhaps a tenth of a desktop + * card, and a phone screen reports a device pixel ratio of 3, so an + * unthrottled raymarch renders nine times the pixels on a tenth of the + * hardware. Cap the ratio and start at the cheapest preset; the existing + * auto-degrade handles anything still too slow. + */ +export function mobileRenderBudget(): { pixelRatio: number; skyFaceSize: number } { + return { pixelRatio: 1.25, skyFaceSize: 512 }; +} diff --git a/src/ui/panel.ts b/src/ui/panel.ts index eedeb9f..f303a87 100644 --- a/src/ui/panel.ts +++ b/src/ui/panel.ts @@ -65,12 +65,34 @@ export interface ControlPanel { refreshDisplays(): void; } +export interface PanelOptions { + /** Show the hidden art-direction folder (`?debug=1`). */ + debug: boolean; + /** + * Phone layout: the panel is a bottom sheet, so it starts collapsed to its + * title bar. Opening it covers most of a phone screen, which is fine when + * asked for and intolerable by default. + */ + compact: boolean; +} + export function buildPanel( settings: Settings, actions: PanelActions, - debug: boolean, + { debug, compact }: PanelOptions, ): ControlPanel { - const gui = new GUI({ title: 'Black Hole · H hides this' }); + const gui = new GUI({ title: 'Black Hole' }); + if (compact) { + // The title bar is the only thing on screen while the sheet is shut, so it + // has to say what tapping it does, and stay honest once it is open. + const describeState = (): void => { + gui.title(gui._closed ? 'Black Hole · tap to open' : 'Black Hole · tap to close'); + }; + gui.onOpenClose(describeState); + describeState(); + } else { + gui.title('Black Hole · H hides this'); + } const shipped = defaultSettings(); const presets = gui.addFolder('0 · Presets'); @@ -154,8 +176,8 @@ export function buildPanel( 'Ends the flight and hands the camera back to mouse control.', ); explain( - camera.add(actions, 'toggleCinematic').name('Hide the interface (H)'), - 'Cinematic mode: fades this panel and the readout for a clean, wallpaper-like frame. Press H again to bring them back.', + camera.add(actions, 'toggleCinematic').name(compact ? 'Hide the interface' : 'Hide the interface (H)'), + 'Cinematic mode: fades this panel and the readout for a clean, wallpaper-like frame.', ); camera.close(); @@ -312,6 +334,14 @@ export function buildPanel( ); render.close(); + if (compact) { + // Presets are the one folder worth having open on a phone: they are the + // fastest way to something worth looking at. + scene.close(); + playback.close(); + gui.close(); + } + const refreshDisplays = (): void => { gui.controllersRecursive().forEach((controller) => controller.updateDisplay()); };