diff --git a/.gitignore b/.gitignore
index bd752f6..07c553f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,13 @@
**/*__pycache__
**/*.pem
**/*.sqlite3
+hudes_env/
+hudes/mnist_data/
+web/mnist_data/
+mnist_data/
+web/node_modules/
+web/dist/
+web/test-results/
+web/generated/
+web/bundle-visualization.html
+web/package-lock.json
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 44c2ff0..964e9bd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,7 +6,6 @@ repos:
- id: end-of-file-fixer
- id: check-ast
- id: check-yaml
- - id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.3
hooks:
diff --git a/README.md b/README.md
index 1b79abb..81ce8c4 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,8 @@
## Featured videos
-- [Human Descent speed run — October 20, 2025](https://youtu.be/PIFJnmvqMIk)
+- [3D speed run — November 18, 2025](https://www.youtube.com/watch?v=4exJirHbESM)
+- [1D speed run — November 18, 2025](https://www.youtube.com/watch?v=t4DESt8-PwQ)
- [Human Descent — How to play](https://youtu.be/tspa15Ei3KI)
**Live server:** The web version runs at [humandescent.net](https://humandescent.net) on an RTX 4090 GPU. Expect instability if many people connect simultaneously.
@@ -14,6 +15,11 @@ Human Descent opens the door for you to explore neural network optimization in h
### Older demo
[Demo video](https://youtu.be/mqAmaBP3-Q4)
+## Game modes
+
+- **3D landscape (default):** renders three coupled loss surfaces that update in real time while you explore six random directions.
+- **1D slices (experimental):** renders six fast-updating loss curves, one per dimension. Launch with `?mode=1d` (e.g. `http://localhost:5173/?mode=1d`) or use the View toggle in the header to switch between modes.
+
## Batch-parameterized PyTorch layers
The visualizations in this project rely on a custom set of layers found in `hudes/model_first/model_first_nn.py`. These helpers wrap familiar PyTorch modules (linear, convolution, pooling, flatten, sequential) so that every forward pass accepts not just a minibatch of data, but also a *batch of parameter tensors*. Key pieces include:
@@ -50,3 +56,15 @@ Iteratively selecting between new training batches and different random subspace
For example its possible to train a 26,000 parameter MNIST model using a 6 dimensional keyboard input in about 10 minutes.

+
+## Testing
+
+- Run all frontend checks with `cd web && npm test` (wrap in the repo's virtualenv so the backend can start).
+- When iterating on the speed-run or highscores flows, capture deterministic Playwright logs with:
+
+```
+cd web
+(npm run -s test:e2e:chromium -- tests/speedrun.spec.mjs) 2>&1 3>&1 | tee /tmp/play-speedrun.log >/dev/null
+```
+
+That command mirrors what we use in CI so any browser/server output is preserved under `/tmp/play-speedrun.log` for later debugging.
diff --git a/cleanup_5s_runs.py b/cleanup_5s_runs.py
new file mode 100644
index 0000000..d695224
--- /dev/null
+++ b/cleanup_5s_runs.py
@@ -0,0 +1,36 @@
+import os
+import sqlite3
+import sys
+
+# Add current directory to sys.path so we can import hudes
+sys.path.append(os.getcwd())
+
+from hudes.high_scores import DB_DEFAULT_PATH
+
+
+def cleanup():
+ path = os.environ.get("HIGH_SCORES_PATH", DB_DEFAULT_PATH)
+ print(f"Opening DB at {path}")
+
+ if not os.path.exists(path):
+ print("Database file not found!")
+ return
+
+ try:
+ with sqlite3.connect(path) as conn:
+ cur = conn.execute("SELECT COUNT(*) FROM high_scores WHERE duration = 5")
+ count = cur.fetchone()[0]
+ print(f"Found {count} runs with duration 5s")
+
+ if count > 0:
+ conn.execute("DELETE FROM high_scores WHERE duration = 5")
+ conn.commit()
+ print(f"Successfully deleted {count} records.")
+ else:
+ print("No records found to delete.")
+ except Exception as e:
+ print(f"Error: {e}")
+
+
+if __name__ == "__main__":
+ cleanup()
diff --git a/design.md b/design.md
index b973bf8..9b3394a 100644
--- a/design.md
+++ b/design.md
@@ -1,246 +1,146 @@
-# Human Descent – Web Speed Run Design
-
-## Overview
-This document defines the design of the **Speed Run** mode and leaderboard system for both the **web client** and **backend server** of *Human Descent*.
-It focuses on:
-- Protocol (protobuf) message types, inputs/outputs, and field mappings
-- State machines for server and client
-- Main runtime loops and message-driven updates
-- Known ambiguities and follow-ups
-
-It complements `high_score.md` by clarifying client/server contracts and describing the code-accurate behavior.
-
----
-
-## 1. Protocol Summary
-**File:** `hudes/hudes.proto`
-
-### Envelope: `Control`
-- `type` (enum `Control.Type`) — message role
-- `request_idx` (optional int32) — sequential index for correlation
-
-### Core Payloads
-**Client → Server**
-- `CONTROL_CONFIG`: `config`
-- `CONTROL_DIMS`: `dims_and_steps[]`
-- `CONTROL_NEXT_BATCH`
-- `CONTROL_NEXT_DIMS`
-- `CONTROL_SGD_STEP`: `sgd_steps` (ignored during Speed Run)
-- `CONTROL_SPEED_RUN_START`
-- `CONTROL_HIGH_SCORE_LOG`: `{ name }` (server validates name strictly; score computed server-side)
-- `CONTROL_QUIT`
-
-**Server → Client**
-- `CONTROL_BATCH_EXAMPLES`: `{ type, n, train_data, train_labels, shapes, batch_idx }`
-- `CONTROL_TRAIN_LOSS_AND_PREDS`: `{ train_loss, preds, confusion_matrix, total_sgd_steps, speed_run_seconds_remaining? }`
-- `CONTROL_VAL_LOSS`: `{ val_loss, speed_run_seconds_remaining?, speed_run_finished? }`
-- `CONTROL_MESHGRID_RESULTS`: `{ mesh_grid_results[], mesh_grid_shape[], speed_run_seconds_remaining? }`
-
-### Notes
-- Wire uses `snake_case`; JS via `protobuf.js` exposes `lowerCamelCase` (e.g., `speedRunSecondsRemaining`, `speedRunFinished`).
-- Countdown is included while a run is active in TRAIN/VAL/MESH messages.
-- End-of-run is signaled by `CONTROL_VAL_LOSS` with `speed_run_finished=true`.
-- The deprecated `CONTROL_FULL_LOSS` is removed; do not use.
-
----
-
-## 2. Server Design
-**File:** `hudes/websocket_server.py`
-
-### 2.1 Core Components
-Each client has a state object containing:
-- **Runtime controls:** steps, batch, dims, dtype, mesh config, SGD counters
-- **Scheduling:** flags for updates, requests, and force states
-- **Speed Run:** timer, log, best validation loss, and flags
-
-An async inference worker handles compute. Two loops manage scheduling and sending.
-
-### 2.2 Main Loops
-1. **`inference_runner_clients`** — schedules next op (`train`, `mesh`, `val`, `sgd`).
-2. **`inference_result_sender`** — serializes results into protobuf `Control` messages and sends to client.
-
-### 2.3 Client → Server Messages
-| Message | Action |
-|----------|---------|
-| CONFIG | Updates configuration and may trigger `force_update` |
-| DIMS | Applies delta to weights, schedules train/mesh |
-| NEXT_DIMS | Increments dims offset |
-| NEXT_BATCH | Increments batch, triggers validation |
-| SGD_STEP | Increments SGD (ignored during Speed Run) |
-| SPEED_RUN_START | Resets weights, starts timer, clears logs |
-| HIGH_SCORE_LOG | Finalizes run, persists results |
-| QUIT | Closes client loop |
-
-### 2.4 Server → Client Messages
-| Type | Description |
-|------|--------------|
-| BATCH_EXAMPLES | Sends sample data and labels |
-| TRAIN_LOSS_AND_PREDS | Sends loss, predictions, confusion matrix |
-| VAL_LOSS | Sends validation loss |
-| MESHGRID_RESULTS | Sends grid data and remaining time during Speed Run |
-
-### 2.5 Scheduling Logic
-- `force_update=True` → triggers next operation.
-- Validation runs (`VAL_LOSS`) occur in normal cadence and at Speed Run finalization.
-- SGD steps run only when not in Speed Run.
-- Worker maintains per-client cloned weights; resets via `force_reset_weights`.
-- At the Speed Run deadline, the server schedules a final `VAL_LOSS`; in `inference_result_sender`, that VAL is marked with `speed_run_finished=true` and the server deactivates `speed_run_active`.
-
----
-
-## 3. Client Design
-**File:** `web/client/HudesClient.js`
-
-### 3.1 Initialization
-- Detects `ws/wss` target host/port.
-- Loads protobuf (`loadProto('hudes.proto')`).
-- Instantiates `View` + `ClientState`.
-- Sends initial `CONTROL_CONFIG`.
-
-### 3.2 User Inputs
-| Key | Function |
-|-----|-----------|
-| R | Start Speed Run |
-| Delete/Backspace | Single SGD step (disabled during run) |
-| Space | Next dims |
-| Enter | Next batch |
-| [ / ] | Adjust step size |
-| ; | Toggle batch size |
-| ' | Toggle dtype |
-| X | Help overlay |
-
-### 3.3 Client → Server Messages
-Follows JS `lowerCamelCase` mapping:
-- `config`, `dimsAndSteps`, `nextDims`, `nextBatch`, `sgdStep`, `speedRunStart`, `highScoreLog`.
-
-### 3.4 Server → Client Handlers
-- **TRAIN_LOSS_AND_PREDS:** updates loss, preds, confusion matrix.
-- **VAL_LOSS:** updates validation loss, triggers best-score check.
-- **BATCH_EXAMPLES:** updates displayed samples.
-- **MESHGRID_RESULTS:** updates view and Speed Run countdown.
-
-### 3.5 Speed Run UX
-- **Start:** User presses **R** → sends `CONTROL_SPEED_RUN_START`.
-- **During:**
- - Server includes `speed_run_seconds_remaining` in TRAIN/VAL/MESH while active.
- - Client starts a local countdown on first server countdown and updates the HUD every ~300ms using wall time; UI shows seconds with 1 decimal precision.
- - SGD is disabled during a run (client guard; server ignores).
-- **End:** The run ends only when a `CONTROL_VAL_LOSS` arrives with `speed_run_finished=true`. The client then prompts for a 4-character alphanumeric name and includes the achieved final VAL loss in the prompt. It submits `CONTROL_HIGH_SCORE_LOG` once. No local end occurs purely from countdown reaching 0.
-
----
-
-## 4. State Machines
-
-### 4.1 Server State (per client)
-| State | Description | Transition |
-|--------|--------------|-------------|
-| Normal | Idle | Start run |
-| SpeedRunActive | Timer running | Deadline reached → schedule final VAL |
-| AwaitingFinalVAL | Waiting for VAL completion | Send VAL with `speed_run_finished=true` → Normal |
-| HighScoreLogged | Finalized | Restart via new run |
-
-**Guards & Side Effects**
-- Ignore SGD during runs.
-- Append incoming messages to `speed_run_log` only before the strict deadline.
-- Include countdown in TRAIN/VAL/MESH results while active.
-- At end-of-run VAL, set `speed_run_finished=true` and deactivate `speed_run_active` server-side.
-- Persist results on `HIGH_SCORE_LOG` (submission no longer controls deactivation).
-
-### 4.2 Client State
-| State | Description |
-|--------|--------------|
-| Init | Loading protobuf |
-| Ready | Normal operation |
-| SpeedRunActive | Timer active |
-| Prompting | Waiting for name entry |
-| Submitted | High score sent |
-
----
-
-## 5. Typical Message Flows
-
-### 5.1 Startup
-1. Client connects
-2. Sends CONFIG
-3. Server replies with batch, train, mesh data
-
-### 5.2 Normal Interaction
-- `Space` → Next dims
-- `Enter` → Next batch
-- `DIMS` → Accumulate updates
-- `SGD_STEP` → Perform step
-
-### 5.3 Speed Run Flow
-1. Client → Server: `CONTROL_SPEED_RUN_START` → server resets weights, sets deadline, starts run.
-2. Server → Client: TRAIN/VAL/MESH messages include `speed_run_seconds_remaining` while active; client starts local timer for smooth HUD updates.
-3. Server: At deadline, schedules a final `VAL_LOSS` evaluation.
-4. Server → Client: `CONTROL_VAL_LOSS` with `speed_run_finished=true`; includes the authoritative final validation loss.
-5. Client: Shows prompt with the achieved final VAL loss; on valid 4-char name, sends `CONTROL_HIGH_SCORE_LOG` (single submission).
-6. Server: Validates name and persists the score and run log.
-
----
-
-## 6. Input / Output Summary
-
-| Source | Type | Example |
-|---------|------|----------|
-| **User → Client** | Keyboard | R, Space, Enter, [ ] |
-| **Client → Server** | Control messages | CONFIG, DIMS, NEXT, SGD, RUN, LOG |
-| **Server → Client** | Control messages | BATCH, TRAIN, VAL, MESH (+timer) |
-| **Server → Disk** | SQLite | High scores table with packed log |
-
----
-
-## 7. Decisions, clarifications, and open items
-This section records decisions made and what remains open.
-
-1) Countdown signaling (implemented):
-- Included in TRAIN/VAL/MESH while a run is active; client smooths UI locally.
-
-2) Final score reporting and end-of-run (implemented):
-- End-of-run is signaled by a `CONTROL_VAL_LOSS` with `speed_run_finished=true` (no special message type). The client only ends on this signal. `CONTROL_FULL_LOSS` was removed.
-
-3) Boundary of run expiry and logging (implemented):
-- Strict cutoff—only requests received before the deadline are counted and logged.
-
-4) Weight reset timing (accepted as-is for now):
-- Server sets `force_reset_weights` on run start. If generation tracking is needed later, we can add a per-client `weights_generation` to jobs and worker.
-
-5) High score name handling (implemented):
-- Server enforces exactly 4 alphanumeric uppercase characters; invalid names are rejected and not recorded. Client UI trims and uppercases.
-
-6) DIMS accumulation and ordering (clarification):
-- Per-connection FIFO ordering; `DIMS` applied against the active dims offset at receive time; `NEXT_DIMS` increments the offset.
-
-7) Request index semantics (accepted risk):
-- `request_idx` is correlation only; no global dedupe.
-
-8) Multiple runs per session (confirmed):
-- Starting a new run resets the in-memory run state/log and starts fresh.
-
-9) Countdown drift and authority (clarification):
-- Server is authoritative for end-of-run; client’s 300ms local ticker is UX-only.
-
-10) Security, rotation, and leaderboard retrieval (deferred):
-- No auth, rotation policy, or public leaderboard endpoint in scope yet.
-
----
-
-## 8. Testing Guidelines
-- Backend: shorten timer with `HUDES_SPEED_RUN_SECONDS=5`.
-- Frontend: unit test HUD markup; E2E verifies end-of-run on final `VAL_LOSS` with `speed_run_finished=true` and high score submission. UI countdown updates locally between server messages.
-
----
-
-## Appendix: Field Mapping Examples
-| Python | JS (protobuf.js) |
-|---------|------------------|
-| `train_loss_and_preds.train_loss` | `trainLossAndPreds.trainLoss` |
-| `speed_run_seconds_remaining` | `speedRunSecondsRemaining` |
-| `speed_run_finished` | `speedRunFinished` |
-| `dims_and_steps` | `dimsAndSteps[{dim, step}]` |
----
-
-## UI polish and accessibility
-- HUD bottom status shows countdown with 1 decimal precision during Speed Run (client-side formatting).
-- The “SPEED RUN” control label is styled red, bold, and italic in the controls list for discoverability.
+# Human Descent Architecture
+
+## System Overview
+Human Descent couples an interactive browser (or native pygame) client with a Python inference server. Users explore a random low-dimensional subspace of a trained MNIST model by nudging parameters through keyboard, mouse, or MIDI controls. Every input generates protobuf messages over WebSockets, and the server responds with updated losses, predictions, mesh grids, and leaderboard data. The project is organized as two top-level roots:
+
+- `hudes/`: Python backend, realtime server, protobuf schema, and native controllers.
+- `web/`: Vite/Three.js/Chart.js frontend, browser controllers, and Playwright tests.
+
+The sections below document the runtime flow, stateful components, MVC split, and the protobuf socket contract that binds both halves.
+
+## Backend Runtime (Python, `hudes/`)
+### Core modules
+| Module | Responsibility |
+| --- | --- |
+| `websocket_server.py` | Async WebSocket server, HTTP health endpoints, per-client state machine, and orchestration of inference workers. |
+| `model_data_and_subspace.py` | Loads MNIST models/datasets, fuses parameters, samples random subspaces, and runs training/validation/mesh computations. |
+| `websocket_client.py` | Aggregates outgoing control commands for native clients and manages the socket thread. |
+| `controllers/*` & `hudes_play.py` | Local pygame entry points that attach physical inputs to the backend via `HudesWebsocketClient`. |
+| `high_scores.py` | SQLite helpers powering leaderboard APIs. |
+
+### Execution pipeline
+1. `run_server()` (`hudes/websocket_server.py`) creates a listener for WebSocket clients and an HTTP API server for health/leaderboard queries.
+2. For each WebSocket connection, `process_client()` instantiates a `Client` dataclass that tracks configuration, queued parameter deltas, timers, and help state.
+3. `inference_runner()` spawns a separate process or thread (`listen_and_run`) that owns a `ModelDataAndSubspace` instance, fuses model parameters, and services batched requests from every client via multiprocessing queues.
+4. The async loop `inference_runner_clients()` polls clients, packages requests (`train`, `val`, `mesh`, `loss_line`, `sgd`), and pushes them to the worker queue.
+5. `inference_result_sender()` pulls worker results, encodes protobuf `Control` messages (`hudes_pb2.py`), and writes them back on each client's socket.
+6. Auxiliary coroutines schedule speed-run deadlines, emit batch example snapshots, and expose leaderboard APIs.
+
+### Client dataclass state (`hudes/websocket_server.py:227`)
+| Field | Purpose |
+| --- | --- |
+| `next_step` / `current_step` | Accumulates pending dimension deltas until they are applied in the worker loop. |
+| `dims_offset`, `dims_at_a_time` | Track which slice of the random subspace the user controls right now. |
+| `batch_idx`, `batch_size` | Control dataset sampling on both train and validation splits. |
+| `dtype`, `mesh_grid_size`, `mesh_step_size`, `mesh_grids` | Configure inference precision and visualization payloads. |
+| `sgd`, `total_sgd_steps`, `force_reset_weights` | Queue explicit SGD micro-steps and manage cached per-client weight tensors. |
+| `speed_run_active`, `speed_run_end_time`, `speed_run_log`, `best_val_loss_during_run`, `high_score_logged` | Manage timed “speed run” competitions and persist their inputs. |
+| `request_idx`, `active_request_idx`, `active_inference`, `sent_batch` | Provide back-pressure so that every protobuf reply references the user-visible request counter. |
+
+### Backend state transitions
+1. **Configuration** (`CONTROL_CONFIG`): updates dtype, dims, batch size, mesh options, and seeds; forces an inference refresh.
+2. **Dimension step** (`CONTROL_DIMS`): merges deltas into `next_step`. When `client_runner_q` wakes up it promotes them to `current_step`, requests `train` + optional `mesh` jobs, and resets `next_step`.
+3. **Batch advance** (`CONTROL_NEXT_BATCH`): increments `batch_idx`, forces a fresh batch example payload, and schedules `train`+`val` passes so charts/leaderboards stay in sync.
+4. **New subspace** (`CONTROL_NEXT_DIMS`): bumps `dims_offset`, zeros `current_step`, and reseeds `ModelDataAndSubspace.get_dim_vec()` so the player explores a new random projection.
+5. **SGD micro-step** (`CONTROL_SGD_STEP`): if not inside a speed run, queues `sgd` iterations so `listen_and_run()` calls `mad.sgd_step()` and persists the returned weights.
+6. **Speed run** (`CONTROL_SPEED_RUN_START` / automatic timeout / `CONTROL_HIGH_SCORE_LOG`): resets cached weights, starts a countdown, logs every inbound control before the deadline, and requests a final validation. When that `VAL` arrives it transitions to `AwaitingScore` until the user submits a high-score record.
+7. **Disconnect**: closing the socket removes the client’s entry from `active_clients` and cancels its scheduled tasks; cached weights are released the next time `listen_and_run()` sees the missing key.
+
+### Inference worker (`listen_and_run`)
+- Maintains a `client_weights` tensor clone per client ID, seeded from `ModelDataAndSubspace.saved_weights`.
+- Applies `mad.delta_from_dims()` to map the sparse `current_step` dict into a dense delta vector before every train/val/mesh job.
+- Provides hooks for additional visualizations: mesh grids (`get_loss_grid`) and loss lines (`get_loss_lines`).
+- Ensures dtype-specific copies stay in sync by moving results back to `float32` when needed.
+
+## Frontend Runtime (web/)
+### Boot sequence (`web/app.js`)
+1. Detect backend host/port via query params, build-time env vars, or health probes.
+2. Choose render mode (`1d` keyboard landscape vs `3d` GL) and instantiate `KeyboardClient` or `KeyboardClientGL` with options such as `meshGrids`, `rowSpacing`, and debug flags.
+3. Expose the client on `window.__hudesClient` for manual inspection and tests, install UI toggles, then call `client.runLoop()`.
+
+### Browser client core (`web/client/HudesClient.js`)
+| Concern | Behavior |
+| --- | --- |
+| **Socket lifecycle** | Opens `ws://host:port`, sets `binaryType='arraybuffer'`, and defers all outbound traffic through `sendQ()` so protobuf.js can encode payloads once the schema loads. |
+| **State** | Owns a `ClientState` instance for step size, dtype, batch size, help screens, timer flags, and formatting helpers for the HUD. |
+| **View binding** | Creates a `ViewRouter` that currently wraps `LandscapeView` (Three.js scene + Chart.js HUD). The view instance is responsible for mesh grids, charts, loss lines, and help overlays. |
+| **Event handling** | Registers `keydown/keyup`, debounces repeats, and routes commands to helper methods (`sendDimsAndSteps`, `getNextBatch`, `getNextDims`, `getSGDStep`, `startSpeedRun`, `submitHighScore`). |
+| **Message pump** | The `_handleMessage` switch decodes protobuf payloads, reshapes arrays, updates charts, keeps loss history arrays aligned, and maintains a local countdown mirror for speed runs. |
+
+### Browser controller variants
+- `KeyboardClient` maps paired keys to dimension deltas (alt layouts supported) and manages repeat timers so each press sends batched `CONTROL_DIMS` payloads.
+- `KeyboardClientGL` disables paired keys, enabling WASD/mouse-driven navigation of individual grids, rotation, and scroll-powered steps. It installs `mouseControls.js` for drag/scroll gestures and maps them into parameter updates in the selected grid.
+- Future controllers (mouse-only, touchscreen, gamepads) can extend `HudesClient` in the same way by overriding `initInput()` and `processKeyPress()` but reusing the networking/view stack.
+
+### Client state container (`web/client/ClientState.js`)
+| Field | Purpose |
+| --- | --- |
+| `stepSizeIdx`, `stepSizeResolution`, `stepSize`, `minLogStepSize`, `maxLogStepSize` | Maintain logarithmic step sizes with bracket keys. |
+| `batchSizes`, `batchSizeIdx`, `batchSize` | Rotate through supported batch sizes. |
+| `dtypes`, `dtypeIdx`, `dtype` | Toggle precision hints sent to the backend. |
+| `bestScore`, `sgdSteps` | Track progress and annotate the HUD. |
+| `helpScreenFns`, `helpScreenIdx` | Drive overlay images for tutorials. |
+| `speedRunActive`, `speedRunSecondsRemaining` | Mirror authoritative server fields for local rendering. |
+
+### Client-side state transitions
+1. **Initialization**: `ClientState` sets defaults, `HudesClient.runLoop()` blocks until protobuf definitions load, then sends `CONTROL_CONFIG`.
+2. **User input**: Keyboard or mouse events call `sendDimsAndSteps`, `getNextBatch`, etc., which encode protobuf payloads and optimistically update local buffers (e.g., `dimsAndStepsOnCurrentDims`).
+3. **Server messages**: Each protobuf reply updates history arrays, charts, and HUD annotations. Validation messages call `ClientState.updateBestScoreOrNot`, and mesh/loss-line payloads mutate Three.js meshes.
+4. **Speed run**: `startSpeedRun()` toggles timers and waits for server countdown data; the state returns to normal only after a `CONTROL_VAL_LOSS` message arrives with `speedRunFinished=true`.
+5. **Help overlay**: Text input is ignored while `helpScreenIdx >= 0`; cycling help screens eventually resumes normal event handling.
+
+## MVC Mapping
+| Layer | Implementation | Notes |
+| --- | --- | --- |
+| **Model** | On the backend, `ModelDataAndSubspace` plus per-client weight clones implement the data/model state. On the frontend, `ClientState` mirrors the user-adjustable subset (step size, dtype, help). |
+| **View** | `LandscapeView` renders mesh grids, charts, HUD overlays, and sample digits; it exposes methods (`updateLossChart`, `updateMeshGrids`, `highlightLossLine`, etc.) so controllers stay passive. Native pygame views (`hudes/view.py`, `hudes/opengl_view.py`) follow the same contract. |
+| **Controller** | `KeyboardClient*` and `XTouchClient` translate hardware events into protobuf control messages. The backend controller loop (`process_client`) likewise interprets protobuf commands and transitions `Client` state, acting as the other half of MVC. |
+
+Code flow summary:
+1. **Input (Controller)**: User presses keys → controller computes a `dim -> delta` map.
+2. **State update (Model)**: Controller enqueues a protobuf `CONTROL_DIMS`; backend `Client.next_step` collects it; inference worker applies deltas to weight tensors.
+3. **Rendering (View)**: Worker responses are streamed back as protobuf `Control` messages; the browser view updates visuals while the backend can display pygame overlays for local builds.
+
+## WebSocket + Protobuf Socket
+### Transport layer
+- **Browser**: `HudesClient` opens a WebSocket (`binaryType='arraybuffer'`) and uses `protobuf.js` (via `ProtoLoader.js`) to encode/decode the shared `hudes.proto` schema (`web/public/hudes.proto`, generated copies live under `web/dist/` and `hudes/hudes_pb2.py`).
+- **Native controllers**: `HudesWebsocketClient` (`hudes/websocket_client.py`) runs in a background thread, accumulates many small `CONTROL_DIMS` payloads into a single message per animation frame, and multiplexes send/receive queues for pygame clients.
+
+### Message catalog
+| Direction | Type | Payload highlights |
+| --- | --- | --- |
+| Client → Server | `CONTROL_CONFIG` | `Config` seed, dims, mesh config, batch size, dtype, mesh toggle, loss lines. |
+| | `CONTROL_DIMS` | Repeated `{dim, step}` entries plus `request_idx`. Aggregated client-side to reduce chatter. |
+| | `CONTROL_NEXT_DIMS` | Requests a fresh random subspace; server resets offsets. |
+| | `CONTROL_NEXT_BATCH` | Advances `batch_idx`, causing new sample previews and loss recomputation. |
+| | `CONTROL_SGD_STEP` | Increments `sgd` counter (ignored when `speed_run_active`). |
+| | `CONTROL_SPEED_RUN_START` | Begins timed competition; server snapshots inbound controls. |
+| | `CONTROL_HIGH_SCORE_LOG` | Sends a sanitized 4-character name to persist the current score. |
+| Server → Client | `CONTROL_BATCH_EXAMPLES` | Flattened tensors + shapes for `n` MNIST samples. |
+| | `CONTROL_TRAIN_LOSS_AND_PREDS` | Scalar loss, flattened predictions, confusion matrix, `total_sgd_steps`, optional countdown seconds. |
+| | `CONTROL_VAL_LOSS` | Validation loss, countdown, and `speed_run_finished` flag when appropriate. |
+| | `CONTROL_MESHGRID_RESULTS` | Flattened loss landscapes + shape metadata. |
+| | `CONTROL_LOSS_LINE_RESULTS` | 1D slices for each controlled dimension (1D mode). |
+| | `CONTROL_LEADERBOARD_RESPONSE` | Parallel arrays of names/scores. |
+
+### Typical sequence
+1. **Config/handshake**
+ - Client boots, loads proto, sends `CONTROL_CONFIG`.
+ - Server replies with `CONTROL_BATCH_EXAMPLES`, `CONTROL_TRAIN_LOSS_AND_PREDS`, `CONTROL_MESHGRID_RESULTS` based on initial seeds.
+2. **Interactive step**
+ - User taps a key → controller builds `{dim: delta}` → `sendDimsAndSteps()` enqueues `CONTROL_DIMS`.
+ - Python server merges deltas into `next_step`, schedules a `train` job, and returns `CONTROL_TRAIN_LOSS_AND_PREDS` followed by (optionally) `CONTROL_MESHGRID_RESULTS`.
+3. **Speed run**
+ - Client sends `CONTROL_SPEED_RUN_START` and suppresses SGD.
+ - Server resets weights, sets `speed_run_end_time`, and includes `speed_run_seconds_remaining` in every outbound message.
+ - When timer elapses, server flags `request_full_val`, emits a final `CONTROL_VAL_LOSS` with `speed_run_finished=true`, and waits for `CONTROL_HIGH_SCORE_LOG`.
+
+### Socket helpers
+- `HudesWebsocketClient.send_config()` (Python) and `HudesClient.sendConfig()` (browser) both wrap protobuf creation so controllers remain declarative.
+- The browser `sendQ()` waits for `readyState === OPEN`, assigns `requestIdx`, verifies the payload against the protobuf schema, and logs every send when debug mode is enabled.
+- The native socket loop coalesces multiple `CONTROL_DIMS` entries before calling `websocket.send()`; this mirrors the browser-side repeat handling and keeps latency low even with high-frequency key presses.
+
+## References
+- Backend source: `hudes/websocket_server.py`, `hudes/model_data_and_subspace.py`, `hudes/websocket_client.py`.
+- Frontend source: `web/app.js`, `web/client/HudesClient.js`, `web/client/KeyboardClient*.js`, `web/client/views/LandscapeView.js`, `web/client/ClientState.js`.
+- Shared protocol: `hudes/hudes.proto`, `web/public/hudes.proto` (compiled to `hudes/hudes_pb2.py`).
diff --git a/hudes/high_scores.py b/hudes/high_scores.py
index 02b20d7..e1e2292 100644
--- a/hudes/high_scores.py
+++ b/hudes/high_scores.py
@@ -125,4 +125,10 @@ def get_rank(score: float, path: str | None = None) -> tuple[int, int]:
(float(score),),
)
less = int(cur.fetchone()[0])
- return less + 1 if total > 0 else 0, total
+ return less + 1, total
+
+
+def delete_high_score(row_id: int, path: str | None = None) -> bool:
+ with _conn(path) as conn:
+ cur = conn.execute("DELETE FROM high_scores WHERE id=?", (row_id,))
+ return cur.rowcount > 0
diff --git a/hudes/hudes.proto b/hudes/hudes.proto
index cb1a0ec..4a2feaf 100644
--- a/hudes/hudes.proto
+++ b/hudes/hudes.proto
@@ -15,6 +15,10 @@ message Config {
required int32 mesh_grids = 5;
required int32 batch_size = 6;
required string dtype = 7;
+ optional bool mesh_enabled = 8 [default = true];
+ optional int32 loss_lines = 9;
+ optional bool resume_supported = 10 [default = true];
+ optional string client_session_token = 11;
}
message TrainLossAndPreds {
@@ -26,6 +30,8 @@ message TrainLossAndPreds {
}
message ValLoss {
required double val_loss = 1;
+ optional int32 rank = 2;
+ optional int32 total_scores = 3;
}
message BatchExamples {
@@ -51,6 +57,7 @@ message Control {
CONTROL_BATCH_EXAMPLES = 3;
CONTROL_SPEED_RUN_START = 10;
CONTROL_HIGH_SCORE_LOG = 11;
+ CONTROL_SPEED_RUN_CANCEL = 12;
// Request the top 100 leaderboard entries (NAME/SCORE only) over WebSocket
CONTROL_LEADERBOARD_REQUEST = 106;
// Response containing the leaderboard arrays (parallel arrays)
@@ -60,6 +67,8 @@ message Control {
CONTROL_CONFIG = 103;
CONTROL_MESHGRID_RESULTS= 104;
CONTROL_SGD_STEP = 105;
+ CONTROL_LOSS_LINE_RESULTS = 108;
+ CONTROL_RESUME = 109;
CONTROL_QUIT = 901;
}
required Type type = 1;
@@ -82,6 +91,7 @@ message Control {
optional int32 speed_run_seconds_remaining = 212;
// True when a VAL message marks the end of the Speed Run window
optional bool speed_run_finished = 213;
+ optional bool speed_run_active = 221;
// Top leaderboard (NAME/SCORE only). Parallel arrays; same length.
// Only set in CONTROL_LEADERBOARD_RESPONSE.
repeated string leaderboard_names = 214;
@@ -94,5 +104,23 @@ message Control {
optional int32 request_idx = 4;
}
optional HighScore high_score = 211; // used with CONTROL_HIGH_SCORE_LOG
+ repeated float loss_line_results = 216;
+ repeated int32 loss_line_shape = 217;
+ message Resume {
+ enum Status {
+ RESUME_OK = 0;
+ RESUME_NOT_FOUND = 1;
+ RESUME_EXPIRED = 2;
+ RESUME_REJECTED = 3;
+ }
+ optional Status status = 1;
+ optional string token = 2;
+ optional int32 last_request_idx = 3;
+ optional string client_session_token = 4;
+ optional string new_client_session_token = 5;
+ }
+ optional Resume resume = 218;
+ optional string resume_token = 219;
+ optional int32 total_eval_steps = 220;
}
diff --git a/hudes/hudes_pb2.py b/hudes/hudes_pb2.py
index 1a64108..fc7757b 100644
--- a/hudes/hudes_pb2.py
+++ b/hudes/hudes_pb2.py
@@ -2,7 +2,7 @@
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: hudes.proto
-# Protobuf Python Version: 6.31.1
+# Protobuf Python Version: 6.30.2
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
@@ -12,7 +12,7 @@
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
- _runtime_version.Domain.PUBLIC, 6, 31, 1, "", "hudes.proto"
+ _runtime_version.Domain.PUBLIC, 6, 30, 2, "", "hudes.proto"
)
# @@protoc_insertion_point(imports)
@@ -20,7 +20,7 @@
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
- b'\n\x0bhudes.proto\x12\x05hudes"\'\n\nDimAndStep\x12\x0b\n\x03\x64im\x18\x01 \x02(\x05\x12\x0c\n\x04step\x18\x02 \x02(\x02"\x95\x01\n\x06\x43onfig\x12\x0c\n\x04seed\x18\x01 \x02(\x05\x12\x16\n\x0e\x64ims_at_a_time\x18\x02 \x02(\x05\x12\x16\n\x0emesh_grid_size\x18\x03 \x02(\x05\x12\x16\n\x0emesh_step_size\x18\x04 \x02(\x02\x12\x12\n\nmesh_grids\x18\x05 \x02(\x05\x12\x12\n\nbatch_size\x18\x06 \x02(\x05\x12\r\n\x05\x64type\x18\x07 \x02(\t"\x85\x01\n\x11TrainLossAndPreds\x12\x12\n\ntrain_loss\x18\x01 \x02(\x01\x12\r\n\x05preds\x18\x02 \x03(\x02\x12\x13\n\x0bpreds_shape\x18\x03 \x03(\x05\x12\x18\n\x10\x63onfusion_matrix\x18\x04 \x03(\x02\x12\x1e\n\x16\x63onfusion_matrix_shape\x18\x05 \x03(\x05"\x1b\n\x07ValLoss\x12\x10\n\x08val_loss\x18\x01 \x02(\x01"\xca\x01\n\rBatchExamples\x12\'\n\x04type\x18\x01 \x02(\x0e\x32\x19.hudes.BatchExamples.Type\x12\t\n\x01n\x18\x02 \x02(\x05\x12\x12\n\ntrain_data\x18\x03 \x03(\x02\x12\x18\n\x10train_data_shape\x18\x04 \x03(\x05\x12\x14\n\x0ctrain_labels\x18\x05 \x03(\x02\x12\x1a\n\x12train_labels_shape\x18\x06 \x03(\x05\x12\x11\n\tbatch_idx\x18\x08 \x02(\x05"\x12\n\x04Type\x12\n\n\x06IMG_BW\x10\x00"\xf6\x07\n\x07\x43ontrol\x12!\n\x04type\x18\x01 \x02(\x0e\x32\x13.hudes.Control.Type\x12*\n\x0e\x64ims_and_steps\x18\xc8\x01 \x03(\x0b\x32\x11.hudes.DimAndStep\x12\x1e\n\x06\x63onfig\x18\xc9\x01 \x01(\x0b\x32\r.hudes.Config\x12\x37\n\x14train_loss_and_preds\x18\xca\x01 \x01(\x0b\x32\x18.hudes.TrainLossAndPreds\x12!\n\x08val_loss\x18\xcb\x01 \x01(\x0b\x32\x0e.hudes.ValLoss\x12\x14\n\x0brequest_idx\x18\xcc\x01 \x01(\x05\x12-\n\x0e\x62\x61tch_examples\x18\xcd\x01 \x01(\x0b\x32\x14.hudes.BatchExamples\x12\x1a\n\x11mesh_grid_results\x18\xcf\x01 \x03(\x02\x12\x18\n\x0fmesh_grid_shape\x18\xd0\x01 \x03(\x05\x12\x12\n\tsgd_steps\x18\xd1\x01 \x01(\x05\x12\x18\n\x0ftotal_sgd_steps\x18\xd2\x01 \x01(\x05\x12$\n\x1bspeed_run_seconds_remaining\x18\xd4\x01 \x01(\x05\x12\x1b\n\x12speed_run_finished\x18\xd5\x01 \x01(\x08\x12\x1a\n\x11leaderboard_names\x18\xd6\x01 \x03(\t\x12\x1b\n\x12leaderboard_scores\x18\xd7\x01 \x03(\x01\x12-\n\nhigh_score\x18\xd3\x01 \x01(\x0b\x32\x18.hudes.Control.HighScore\x1aW\n\tHighScore\x12\x0c\n\x04name\x18\x01 \x02(\t\x12\r\n\x05score\x18\x02 \x01(\x01\x12\x18\n\x10\x64uration_seconds\x18\x03 \x01(\x05\x12\x13\n\x0brequest_idx\x18\x04 \x01(\x05"\xf2\x02\n\x04Type\x12\x10\n\x0c\x43ONTROL_DIMS\x10\x00\x12 \n\x1c\x43ONTROL_TRAIN_LOSS_AND_PREDS\x10\x01\x12\x14\n\x10\x43ONTROL_VAL_LOSS\x10\x02\x12\x1a\n\x16\x43ONTROL_BATCH_EXAMPLES\x10\x03\x12\x1b\n\x17\x43ONTROL_SPEED_RUN_START\x10\n\x12\x1a\n\x16\x43ONTROL_HIGH_SCORE_LOG\x10\x0b\x12\x1f\n\x1b\x43ONTROL_LEADERBOARD_REQUEST\x10j\x12 \n\x1c\x43ONTROL_LEADERBOARD_RESPONSE\x10k\x12\x16\n\x12\x43ONTROL_NEXT_BATCH\x10\x65\x12\x15\n\x11\x43ONTROL_NEXT_DIMS\x10\x66\x12\x12\n\x0e\x43ONTROL_CONFIG\x10g\x12\x1c\n\x18\x43ONTROL_MESHGRID_RESULTS\x10h\x12\x14\n\x10\x43ONTROL_SGD_STEP\x10i\x12\x11\n\x0c\x43ONTROL_QUIT\x10\x85\x07'
+ b'\n\x0bhudes.proto\x12\x05hudes"\'\n\nDimAndStep\x12\x0b\n\x03\x64im\x18\x01 \x02(\x05\x12\x0c\n\x04step\x18\x02 \x02(\x02"\x83\x02\n\x06\x43onfig\x12\x0c\n\x04seed\x18\x01 \x02(\x05\x12\x16\n\x0e\x64ims_at_a_time\x18\x02 \x02(\x05\x12\x16\n\x0emesh_grid_size\x18\x03 \x02(\x05\x12\x16\n\x0emesh_step_size\x18\x04 \x02(\x02\x12\x12\n\nmesh_grids\x18\x05 \x02(\x05\x12\x12\n\nbatch_size\x18\x06 \x02(\x05\x12\r\n\x05\x64type\x18\x07 \x02(\t\x12\x1a\n\x0cmesh_enabled\x18\x08 \x01(\x08:\x04true\x12\x12\n\nloss_lines\x18\t \x01(\x05\x12\x1e\n\x10resume_supported\x18\n \x01(\x08:\x04true\x12\x1c\n\x14\x63lient_session_token\x18\x0b \x01(\t"\x85\x01\n\x11TrainLossAndPreds\x12\x12\n\ntrain_loss\x18\x01 \x02(\x01\x12\r\n\x05preds\x18\x02 \x03(\x02\x12\x13\n\x0bpreds_shape\x18\x03 \x03(\x05\x12\x18\n\x10\x63onfusion_matrix\x18\x04 \x03(\x02\x12\x1e\n\x16\x63onfusion_matrix_shape\x18\x05 \x03(\x05"?\n\x07ValLoss\x12\x10\n\x08val_loss\x18\x01 \x02(\x01\x12\x0c\n\x04rank\x18\x02 \x01(\x05\x12\x14\n\x0ctotal_scores\x18\x03 \x01(\x05"\xca\x01\n\rBatchExamples\x12\'\n\x04type\x18\x01 \x02(\x0e\x32\x19.hudes.BatchExamples.Type\x12\t\n\x01n\x18\x02 \x02(\x05\x12\x12\n\ntrain_data\x18\x03 \x03(\x02\x12\x18\n\x10train_data_shape\x18\x04 \x03(\x05\x12\x14\n\x0ctrain_labels\x18\x05 \x03(\x02\x12\x1a\n\x12train_labels_shape\x18\x06 \x03(\x05\x12\x11\n\tbatch_idx\x18\x08 \x02(\x05"\x12\n\x04Type\x12\n\n\x06IMG_BW\x10\x00"\xec\x0b\n\x07\x43ontrol\x12!\n\x04type\x18\x01 \x02(\x0e\x32\x13.hudes.Control.Type\x12*\n\x0e\x64ims_and_steps\x18\xc8\x01 \x03(\x0b\x32\x11.hudes.DimAndStep\x12\x1e\n\x06\x63onfig\x18\xc9\x01 \x01(\x0b\x32\r.hudes.Config\x12\x37\n\x14train_loss_and_preds\x18\xca\x01 \x01(\x0b\x32\x18.hudes.TrainLossAndPreds\x12!\n\x08val_loss\x18\xcb\x01 \x01(\x0b\x32\x0e.hudes.ValLoss\x12\x14\n\x0brequest_idx\x18\xcc\x01 \x01(\x05\x12-\n\x0e\x62\x61tch_examples\x18\xcd\x01 \x01(\x0b\x32\x14.hudes.BatchExamples\x12\x1a\n\x11mesh_grid_results\x18\xcf\x01 \x03(\x02\x12\x18\n\x0fmesh_grid_shape\x18\xd0\x01 \x03(\x05\x12\x12\n\tsgd_steps\x18\xd1\x01 \x01(\x05\x12\x18\n\x0ftotal_sgd_steps\x18\xd2\x01 \x01(\x05\x12$\n\x1bspeed_run_seconds_remaining\x18\xd4\x01 \x01(\x05\x12\x1b\n\x12speed_run_finished\x18\xd5\x01 \x01(\x08\x12\x19\n\x10speed_run_active\x18\xdd\x01 \x01(\x08\x12\x1a\n\x11leaderboard_names\x18\xd6\x01 \x03(\t\x12\x1b\n\x12leaderboard_scores\x18\xd7\x01 \x03(\x01\x12-\n\nhigh_score\x18\xd3\x01 \x01(\x0b\x32\x18.hudes.Control.HighScore\x12\x1a\n\x11loss_line_results\x18\xd8\x01 \x03(\x02\x12\x18\n\x0floss_line_shape\x18\xd9\x01 \x03(\x05\x12&\n\x06resume\x18\xda\x01 \x01(\x0b\x32\x15.hudes.Control.Resume\x12\x15\n\x0cresume_token\x18\xdb\x01 \x01(\t\x12\x19\n\x10total_eval_steps\x18\xdc\x01 \x01(\x05\x1aW\n\tHighScore\x12\x0c\n\x04name\x18\x01 \x02(\t\x12\r\n\x05score\x18\x02 \x01(\x01\x12\x18\n\x10\x64uration_seconds\x18\x03 \x01(\x05\x12\x13\n\x0brequest_idx\x18\x04 \x01(\x05\x1a\xf7\x01\n\x06Resume\x12,\n\x06status\x18\x01 \x01(\x0e\x32\x1c.hudes.Control.Resume.Status\x12\r\n\x05token\x18\x02 \x01(\t\x12\x18\n\x10last_request_idx\x18\x03 \x01(\x05\x12\x1c\n\x14\x63lient_session_token\x18\x04 \x01(\t\x12 \n\x18new_client_session_token\x18\x05 \x01(\t"V\n\x06Status\x12\r\n\tRESUME_OK\x10\x00\x12\x14\n\x10RESUME_NOT_FOUND\x10\x01\x12\x12\n\x0eRESUME_EXPIRED\x10\x02\x12\x13\n\x0fRESUME_REJECTED\x10\x03"\xc3\x03\n\x04Type\x12\x10\n\x0c\x43ONTROL_DIMS\x10\x00\x12 \n\x1c\x43ONTROL_TRAIN_LOSS_AND_PREDS\x10\x01\x12\x14\n\x10\x43ONTROL_VAL_LOSS\x10\x02\x12\x1a\n\x16\x43ONTROL_BATCH_EXAMPLES\x10\x03\x12\x1b\n\x17\x43ONTROL_SPEED_RUN_START\x10\n\x12\x1a\n\x16\x43ONTROL_HIGH_SCORE_LOG\x10\x0b\x12\x1c\n\x18\x43ONTROL_SPEED_RUN_CANCEL\x10\x0c\x12\x1f\n\x1b\x43ONTROL_LEADERBOARD_REQUEST\x10j\x12 \n\x1c\x43ONTROL_LEADERBOARD_RESPONSE\x10k\x12\x16\n\x12\x43ONTROL_NEXT_BATCH\x10\x65\x12\x15\n\x11\x43ONTROL_NEXT_DIMS\x10\x66\x12\x12\n\x0e\x43ONTROL_CONFIG\x10g\x12\x1c\n\x18\x43ONTROL_MESHGRID_RESULTS\x10h\x12\x14\n\x10\x43ONTROL_SGD_STEP\x10i\x12\x1d\n\x19\x43ONTROL_LOSS_LINE_RESULTS\x10l\x12\x12\n\x0e\x43ONTROL_RESUME\x10m\x12\x11\n\x0c\x43ONTROL_QUIT\x10\x85\x07'
)
_globals = globals()
@@ -31,19 +31,23 @@
_globals["_DIMANDSTEP"]._serialized_start = 22
_globals["_DIMANDSTEP"]._serialized_end = 61
_globals["_CONFIG"]._serialized_start = 64
- _globals["_CONFIG"]._serialized_end = 213
- _globals["_TRAINLOSSANDPREDS"]._serialized_start = 216
- _globals["_TRAINLOSSANDPREDS"]._serialized_end = 349
- _globals["_VALLOSS"]._serialized_start = 351
- _globals["_VALLOSS"]._serialized_end = 378
- _globals["_BATCHEXAMPLES"]._serialized_start = 381
- _globals["_BATCHEXAMPLES"]._serialized_end = 583
- _globals["_BATCHEXAMPLES_TYPE"]._serialized_start = 565
- _globals["_BATCHEXAMPLES_TYPE"]._serialized_end = 583
- _globals["_CONTROL"]._serialized_start = 586
- _globals["_CONTROL"]._serialized_end = 1600
- _globals["_CONTROL_HIGHSCORE"]._serialized_start = 1140
- _globals["_CONTROL_HIGHSCORE"]._serialized_end = 1227
- _globals["_CONTROL_TYPE"]._serialized_start = 1230
- _globals["_CONTROL_TYPE"]._serialized_end = 1600
+ _globals["_CONFIG"]._serialized_end = 323
+ _globals["_TRAINLOSSANDPREDS"]._serialized_start = 326
+ _globals["_TRAINLOSSANDPREDS"]._serialized_end = 459
+ _globals["_VALLOSS"]._serialized_start = 461
+ _globals["_VALLOSS"]._serialized_end = 524
+ _globals["_BATCHEXAMPLES"]._serialized_start = 527
+ _globals["_BATCHEXAMPLES"]._serialized_end = 729
+ _globals["_BATCHEXAMPLES_TYPE"]._serialized_start = 711
+ _globals["_BATCHEXAMPLES_TYPE"]._serialized_end = 729
+ _globals["_CONTROL"]._serialized_start = 732
+ _globals["_CONTROL"]._serialized_end = 2248
+ _globals["_CONTROL_HIGHSCORE"]._serialized_start = 1457
+ _globals["_CONTROL_HIGHSCORE"]._serialized_end = 1544
+ _globals["_CONTROL_RESUME"]._serialized_start = 1547
+ _globals["_CONTROL_RESUME"]._serialized_end = 1794
+ _globals["_CONTROL_RESUME_STATUS"]._serialized_start = 1708
+ _globals["_CONTROL_RESUME_STATUS"]._serialized_end = 1794
+ _globals["_CONTROL_TYPE"]._serialized_start = 1797
+ _globals["_CONTROL_TYPE"]._serialized_end = 2248
# @@protoc_insertion_point(module_scope)
diff --git a/hudes/hudes_play.py b/hudes/hudes_play.py
index 1266da8..a238bcc 100644
--- a/hudes/hudes_play.py
+++ b/hudes/hudes_play.py
@@ -25,6 +25,11 @@ def main(argv=None):
"--skip-help",
action="store_true",
)
+ parser.add_argument(
+ "--headless",
+ action="store_true",
+ help="Use non-OpenGL view when running keyboardGL (useful for CI/headless)",
+ )
parser.add_argument("--max-loop-iterations", type=int, default=None)
parser.add_argument("--max-seconds", type=float, default=None)
@@ -52,7 +57,10 @@ def main(argv=None):
joystick_controller_key=args.controller,
skip_help=args.skip_help,
)
- view = OpenGLView(grid_size=args.grid_size, grids=args.grids)
+ if args.headless:
+ view = View()
+ else:
+ view = OpenGLView(grid_size=args.grid_size, grids=args.grids)
elif args.input == "xtouch":
controller = XTouchClient(
addr=args.addr,
diff --git a/hudes/model_data_and_subspace.py b/hudes/model_data_and_subspace.py
index ebffb70..6ba2b4f 100644
--- a/hudes/model_data_and_subspace.py
+++ b/hudes/model_data_and_subspace.py
@@ -269,20 +269,38 @@ def init_param_model(self):
def dim_idxs_and_ranges_to_models_parms(
self,
base_weights,
- dims: torch.Tensor,
- arange: torch.Tensor,
- brange: torch.Tensor,
- dtype: torch.dtype,
+ dims,
+ arange,
+ brange=None,
+ dtype=torch.float32,
):
- assert len(dims) == 2
- vs = torch.vstack([self.get_dim_vec(dim, dtype=dtype) for dim in dims])
-
- agrid, bgrid = torch.meshgrid(torch.tensor(arange), torch.tensor(brange))
+ if isinstance(dims, torch.Tensor):
+ dims_iter = dims.tolist()
+ else:
+ dims_iter = list(dims)
+ assert len(dims_iter) in (1, 2), "dims must contain 1 or 2 entries"
+
+ vecs = [self.get_dim_vec(dim, dtype=dtype) for dim in dims_iter]
+ vs = torch.vstack(vecs)
+ base = base_weights.to(device=vs.device, dtype=dtype)
+
+ arange_t = torch.as_tensor(arange, device=vs.device)
+ if len(dims_iter) == 1:
+ coords = arange_t.unsqueeze(1).to(dtype=dtype) # (grid, 1)
+ mp = coords @ vs + base.reshape(1, -1)
+ return mp
+
+ assert brange is not None, "brange must be provided for 2D sweeps"
+ agrid, bgrid = torch.meshgrid(
+ torch.as_tensor(arange, device=vs.device),
+ torch.as_tensor(brange, device=vs.device),
+ indexing="ij",
+ )
agrid = agrid.unsqueeze(2)
bgrid = bgrid.unsqueeze(2)
- return torch.concatenate([agrid, bgrid], dim=2).to(
- dtype
- ) @ vs + base_weights.reshape(1, 1, -1)
+ return torch.concatenate([agrid, bgrid], dim=2).to(dtype) @ vs + base.reshape(
+ 1, 1, -1
+ )
@torch.no_grad
def get_loss_grid(
@@ -351,3 +369,65 @@ def get_loss_grid(
loss = torch.concatenate(grid_losses, dim=0).cpu()
logging.info(f"get_loss: return loss {loss[:, grid_size // 2, grid_size // 2]}")
return loss
+
+ @torch.no_grad
+ def get_loss_lines(
+ self,
+ base_weights,
+ batch_idx,
+ dims_offset,
+ lines,
+ grid_size,
+ step_size,
+ batch_size,
+ dtype,
+ max_models=256,
+ ):
+ assert grid_size % 2 == 1
+ assert grid_size > 3
+ assert lines > 0
+
+ batch = self.get_batch(
+ batch_size=batch_size,
+ batch_idx=batch_idx,
+ dtype=dtype,
+ train_or_val="train",
+ )
+ data = batch[0].unsqueeze(0).expand(max_models, *batch[0].shape)
+ batch_size = data.shape[1]
+ label = batch[1]
+ r = (torch.arange(grid_size, device=self.device) - grid_size // 2) * step_size
+
+ line_losses = []
+ for line_idx in range(lines):
+ dim = dims_offset + line_idx
+ mp = self.dim_idxs_and_ranges_to_models_parms(
+ base_weights,
+ dims=[dim],
+ arange=r,
+ dtype=dtype,
+ )
+
+ mp_reshaped = mp.reshape(-1, self.num_params).contiguous()
+ offset = 0
+ predictions = []
+ while offset < mp_reshaped.shape[0]:
+ effective_models = min(mp_reshaped.shape[0] - offset, max_models)
+ predictions.append(
+ self.param_models[dtype].forward(
+ mp_reshaped[offset : offset + effective_models],
+ data[:effective_models],
+ )[1]
+ )
+ offset += effective_models
+
+ predictions = torch.vstack(predictions).reshape(mp.shape[0], batch_size, -1)
+ gathered = torch.gather(
+ predictions,
+ 2,
+ label.reshape(1, -1, 1).expand(mp.shape[0], batch_size, 1),
+ )
+ loss = -gathered.mean(dim=(1, 2))
+ line_losses.append(loss)
+
+ return torch.stack(line_losses, dim=0).cpu()
diff --git a/hudes/websocket_client.py b/hudes/websocket_client.py
index 7bc8249..b4e05c0 100644
--- a/hudes/websocket_client.py
+++ b/hudes/websocket_client.py
@@ -87,6 +87,7 @@ def send_config(
mesh_grids=mesh_grids,
batch_size=batch_size,
dtype=dtype,
+ resume_supported=False,
),
).SerializeToString()
)
@@ -98,64 +99,72 @@ def recv_msg(self):
return self.recv_q.get(timeout=0.0)
def run_loop(self):
- try:
- with sync_connect(self.remote_addr) as websocket:
- while self.running:
- # figure out what if we should send
- request_idx = -1
- dims_and_steps = {}
- while not self.send_q.empty():
- raw_msg = self.send_q.get()
- msg = hudes_pb2.Control()
- msg.ParseFromString(raw_msg)
- if msg.type == hudes_pb2.Control.CONTROL_DIMS:
- request_idx = max(request_idx, msg.request_idx)
- for dim_and_step in msg.dims_and_steps:
- dim = dim_and_step.dim
- if dim not in dims_and_steps:
- dims_and_steps[dim] = dim_and_step.step
- else:
- dims_and_steps[dim] += dim_and_step.step
- else:
- if len(dims_and_steps) != 0:
- websocket.send(
- dims_and_steps_to_control_message(
- dims_and_steps=dims_and_steps,
- request_idx=request_idx,
- ).SerializeToString()
- )
- dims_and_steps = {}
- websocket.send(raw_msg)
- if len(dims_and_steps) != 0:
- websocket.send(
- dims_and_steps_to_control_message(
- dims_and_steps=dims_and_steps,
- request_idx=request_idx,
- ).SerializeToString()
- )
+ backoff = 1
+ while self.running:
+ try:
+ with sync_connect(self.remote_addr) as websocket:
+ logging.info("Connected to %s", self.remote_addr)
+ backoff = 1
+ while self.running:
+ request_idx = -1
dims_and_steps = {}
-
- # figure out what we are recv'ing if anything
- try:
- msg = websocket.recv(timeout=0.00001)
- self.recv_q.put(msg)
- except TimeoutError:
- pass
- except (
- websockets.exceptions.ConnectionClosedError,
- websockets.exceptions.ConnectionClosedOK,
- ):
- self.running = False
-
- # when there is no interaction give the system a break(?)
- # if not send_or_recv:
- time.sleep(0.01)
- except (ConnectionRefusedError, socket.gaierror) as e:
- logging.error(f"Connection issue with {self.remote_addr}, {e}")
- self.running = False
- except Exception as e:
- logging.error(f"Unexpected connection issue with {self.remote_addr}, {e}")
- self.running = False
+ while not self.send_q.empty():
+ raw_msg = self.send_q.get()
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(raw_msg)
+ if msg.type == hudes_pb2.Control.CONTROL_DIMS:
+ request_idx = max(request_idx, msg.request_idx)
+ for dim_and_step in msg.dims_and_steps:
+ dim = dim_and_step.dim
+ if dim not in dims_and_steps:
+ dims_and_steps[dim] = dim_and_step.step
+ else:
+ dims_and_steps[dim] += dim_and_step.step
+ else:
+ if len(dims_and_steps) != 0:
+ websocket.send(
+ dims_and_steps_to_control_message(
+ dims_and_steps=dims_and_steps,
+ request_idx=request_idx,
+ ).SerializeToString()
+ )
+ dims_and_steps = {}
+ websocket.send(raw_msg)
+ if len(dims_and_steps) != 0:
+ websocket.send(
+ dims_and_steps_to_control_message(
+ dims_and_steps=dims_and_steps,
+ request_idx=request_idx,
+ ).SerializeToString()
+ )
+ dims_and_steps = {}
+
+ try:
+ msg = websocket.recv(timeout=0.00001)
+ self.recv_q.put(msg)
+ except TimeoutError:
+ pass
+ except (
+ websockets.exceptions.ConnectionClosedError,
+ websockets.exceptions.ConnectionClosedOK,
+ ):
+ logging.info(
+ "Remote closed connection; attempting reconnect"
+ )
+ break
+
+ time.sleep(0.01)
+ except (ConnectionRefusedError, socket.gaierror) as e:
+ logging.error(f"Connection issue with {self.remote_addr}, {e}")
+ except Exception as e:
+ logging.error(
+ f"Unexpected connection issue with {self.remote_addr}, {e}"
+ )
+
+ if not self.running:
+ break
+ time.sleep(backoff)
+ backoff = min(backoff * 2, 10)
async def send_dims(n: int = 10):
diff --git a/hudes/websocket_server.py b/hudes/websocket_server.py
index 844fbda..c3f32b8 100644
--- a/hudes/websocket_server.py
+++ b/hudes/websocket_server.py
@@ -9,6 +9,7 @@
import math
import os
import pathlib
+import secrets
import ssl
import time
from concurrent.futures import ThreadPoolExecutor
@@ -26,11 +27,12 @@
from hudes import hudes_pb2
from hudes.high_scores import (
- get_all_scores,
- get_rank,
get_top_scores,
init_db,
insert_high_score,
+ delete_high_score,
+ get_all_scores,
+ get_rank,
)
from hudes.model_data_and_subspace import ModelDataAndSubspace
from hudes.models_and_datasets.mnist import (
@@ -45,9 +47,24 @@
active_clients = {}
# Track scheduled timeout tasks per client to finalize Speed Runs
active_speedrun_tasks: dict[int, asyncio.Task] = {}
+
+
+def _cancel_speedrun_timeout(client_id: int):
+ task = active_speedrun_tasks.pop(client_id, None)
+ if task and not task.done():
+ try:
+ task.cancel()
+ logging.debug("Cancelled speed run timeout task for client %s", client_id)
+ except Exception:
+ pass
+
+
+# Map resume tokens to client ids for quick lookup
+resume_token_to_client_id: dict[str, int] = {}
# Default Speed Run duration in seconds
# (overridable via HUDES_SPEED_RUN_SECONDS)
SPEED_RUN_SECONDS = int(os.environ.get("HUDES_SPEED_RUN_SECONDS", "120"))
+RESUME_SESSION_TTL = int(os.environ.get("HUDES_RESUME_TTL", "120"))
def _pack_messages_len_prefixed(msgs: list[bytes]) -> bytes:
@@ -65,11 +82,148 @@ def _countdown_kwargs(client) -> dict:
Returns a dict that can be splatted into a Control constructor.
"""
+ data = {"speed_run_active": bool(getattr(client, "speed_run_active", False))}
if not getattr(client, "speed_run_end_time", 0):
- return {}
+ return data
remaining = max(0, int(client.speed_run_end_time - time.time()))
- srs = remaining if getattr(client, "speed_run_active", False) else 0
- return {"speed_run_seconds_remaining": srs}
+ srs = remaining if data["speed_run_active"] else 0
+ data["speed_run_seconds_remaining"] = srs
+ return data
+
+
+def _generate_resume_token() -> str:
+ return secrets.token_urlsafe(16)
+
+
+def _generate_session_token() -> str:
+ return secrets.token_urlsafe(12)
+
+
+def _register_resume_token(client, rotate: bool = False):
+ if client.resume_token and not rotate:
+ resume_token_to_client_id[client.resume_token] = client.client_id
+ return client.resume_token
+ if client.resume_token and client.resume_token in resume_token_to_client_id:
+ del resume_token_to_client_id[client.resume_token]
+ client.resume_token = _generate_resume_token()
+ resume_token_to_client_id[client.resume_token] = client.client_id
+ return client.resume_token
+
+
+def _attach_resume_token(control_msg: hudes_pb2.Control, client):
+ if getattr(client, "resume_token", None):
+ control_msg.resume_token = client.resume_token
+
+
+async def _send_control_message(client, control_msg: hudes_pb2.Control):
+ websocket = client.websocket
+ if websocket is None or getattr(websocket, "closed", False):
+ return
+ _attach_resume_token(control_msg, client)
+ try:
+ await websocket.send(control_msg.SerializeToString())
+ except (
+ websockets.exceptions.ConnectionClosedError,
+ websockets.exceptions.ConnectionClosedOK,
+ ):
+ logging.debug(
+ "send_control_message: websocket closed for client %s", client.client_id
+ )
+ except Exception as exc:
+ logging.error(
+ "send_control_message: failed for client %s: %s",
+ client.client_id,
+ exc,
+ )
+
+
+async def _handle_resume_request(msg, websocket, client_runner_q):
+ resume = msg.resume
+ token = resume.token if resume is not None else ""
+ if not token:
+ await websocket.send(
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ status=hudes_pb2.Control.Resume.RESUME_REJECTED,
+ ),
+ ).SerializeToString()
+ )
+ return None
+ client_id = resume_token_to_client_id.get(token)
+ client = active_clients.get(client_id) if client_id is not None else None
+ if client is None:
+ await websocket.send(
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ status=hudes_pb2.Control.Resume.RESUME_NOT_FOUND,
+ token=token,
+ ),
+ ).SerializeToString()
+ )
+ return None
+ if not client.resume_enabled:
+ await websocket.send(
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ status=hudes_pb2.Control.Resume.RESUME_REJECTED,
+ token=token,
+ ),
+ ).SerializeToString()
+ )
+ return None
+
+ provided_session = resume.client_session_token or ""
+ expected_session = client.session_token or ""
+ if expected_session and provided_session != expected_session:
+ await websocket.send(
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ status=hudes_pb2.Control.Resume.RESUME_REJECTED,
+ token=token,
+ ),
+ ).SerializeToString()
+ )
+ return None
+
+ new_session_token = (
+ resume.new_client_session_token
+ if resume and resume.new_client_session_token
+ else _generate_session_token()
+ )
+ client.session_token = new_session_token
+ _register_resume_token(client, rotate=True)
+
+ prev_ws = client.websocket
+ if prev_ws is not None and prev_ws is not websocket:
+ try:
+ await prev_ws.close()
+ except Exception:
+ pass
+ client.websocket = websocket
+ client.disconnected_at = None
+ client.last_seen = time.time()
+ client.resume_last_request_idx = resume.last_request_idx or 0
+ client.force_update = True
+ client.request_full_val = True
+ client.sent_batch = -1
+ client_runner_q.put(True)
+ await _send_control_message(
+ client,
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ status=hudes_pb2.Control.Resume.RESUME_OK,
+ token=client.resume_token,
+ last_request_idx=client.request_idx,
+ client_session_token=client.session_token,
+ ),
+ ),
+ )
+ return client
# TODO
@@ -147,7 +301,7 @@ def listen_and_run(
res = {}
logging.debug(f"listen_and_run: got {mode}")
- if mode in ("train", "mesh"):
+ if mode in ("train", "mesh", "loss_line"):
# reset per-client weights if requested (speed run start)
if client.force_reset_weights:
if client.client_id in client_weights:
@@ -190,6 +344,20 @@ def listen_and_run(
batch_size=min(client.batch_size, mad.max_batch_size),
dtype=client.dtype,
)
+ if mode == "loss_line":
+ res["loss_line"] = mad.get_loss_lines(
+ base_weights=client_weights[client.client_id].to(client.dtype),
+ grid_size=min(client.mesh_grid_size, mad.max_grid_size),
+ step_size=client.mesh_step_size / 2,
+ lines=min(
+ client.loss_lines,
+ client.dims_at_a_time or client.loss_lines,
+ ),
+ dims_offset=client.dims_offset,
+ batch_idx=client.batch_idx,
+ batch_size=min(client.batch_size, mad.max_batch_size),
+ dtype=client.dtype,
+ )
if mode == "sgd":
res["train"], model_weights = mad.sgd_step(
client_weights[client.client_id].to(client.dtype),
@@ -202,7 +370,7 @@ def listen_and_run(
client_weights[client.client_id].data.copy_(
model_weights.to(torch.float32)
)
- if mode not in ("train", "mesh", "val", "sgd"):
+ if mode not in ("train", "mesh", "loss_line", "val", "sgd"):
raise ValueError
logging.debug(f"listen_and_run: return {mode}")
@@ -225,6 +393,8 @@ class Client:
mesh_grid_size: int = -1
mesh_grids: int = 0
mesh_step_size: float = 0.0
+ mesh_enabled: bool = True
+ loss_lines: int = 0
force_update: bool = False
dims_offset: int = 0
dims_at_a_time: int = 0
@@ -233,6 +403,7 @@ class Client:
batch_size: int = 32
sgd: int = 0
total_sgd_steps: int = 0
+ total_eval_steps: int = 0
# Speed run state
speed_run_active: bool = False
speed_run_end_time: float = 0.0
@@ -243,6 +414,11 @@ class Client:
# Signal to inference worker to drop/reset weights for this
# client on next op
force_reset_weights: bool = False
+ resume_token: str | None = None
+ resume_enabled: bool = False
+ resume_last_request_idx: int = 0
+ disconnected_at: float | None = None
+ session_token: str = ""
def __getstate__(self):
state = self.__dict__.copy()
@@ -272,11 +448,20 @@ async def inference_runner_clients(mad, client_runner_q, inference_q, stop):
client_runner_q.get()
# make requests
- for client_id in range(len(active_clients)):
- client = active_clients[client_id]
+ for client_id, client in list(active_clients.items()):
+ if client is None:
+ continue
+ websocket = client.websocket
+ if websocket is None or getattr(websocket, "closed", False):
+ continue
# client still waiting for response just skip
if client.active_inference > 0:
+ logging.debug(
+ "inference_runner_clients: skip client %s active_inference=%s",
+ client.client_id,
+ client.active_inference,
+ )
continue
client.active_request_idx = client.request_idx
@@ -289,8 +474,9 @@ async def inference_runner_clients(mad, client_runner_q, inference_q, stop):
batch_idx=client.batch_idx,
dtype=client.dtype,
mad=mad,
- ).SerializeToString()
- await client.websocket.send(msg)
+ )
+ _attach_resume_token(msg, client)
+ await websocket.send(msg.SerializeToString())
except (
websockets.exceptions.ConnectionClosedOK,
websockets.exceptions.ConnectionClosedError,
@@ -314,7 +500,9 @@ async def inference_runner_clients(mad, client_runner_q, inference_q, stop):
client.sgd = 0
# if we have grids, step size changes mesh
- if client.mesh_grids > 0:
+ if (
+ client.mesh_enabled and client.mesh_grids > 0
+ ) or client.loss_lines > 0:
client.force_update = True
if client.force_update or (
@@ -323,37 +511,34 @@ async def inference_runner_clients(mad, client_runner_q, inference_q, stop):
client.current_step = client.next_step
client.next_step = {}
- if client.mesh_grids > 0:
- logging.debug(
- "inference_runner_clients: req mesh %s",
- client.force_update,
- )
- client.active_inference += 1
- inference_q.put(("mesh", copy.copy(client)))
- if client.force_reset_weights:
- logging.debug(
- "inference_runner_clients: clear reset flag "
- "after scheduling mesh"
- )
- client.force_reset_weights = False
- else:
+ next_mode = "train"
+ if client.mesh_enabled and client.mesh_grids > 0:
+ next_mode = "mesh"
+ elif client.loss_lines > 0:
+ next_mode = "loss_line"
+
+ logging.debug(
+ "inference_runner_clients: req %s %s",
+ next_mode,
+ client.force_update,
+ )
+ client.active_inference += 1
+ inference_q.put((next_mode, copy.copy(client)))
+ if client.force_reset_weights:
logging.debug(
- "inference_runner_clients: req train %s",
- client.force_update,
+ "inference_runner_clients: clear reset flag "
+ "after scheduling %s",
+ next_mode,
)
- client.active_inference += 1
- inference_q.put(("train", copy.copy(client)))
- if client.force_reset_weights:
- logging.debug(
- "inference_runner_clients: clear reset flag "
- "after scheduling train"
- )
- client.force_reset_weights = False
+ client.force_reset_weights = False
client.force_update = False
if client.request_full_val:
# send weight vector for inference
- logging.debug("inference_runner_clients: req inference")
+ logging.debug(
+ "inference_runner_clients: req inference for client %s",
+ client.client_id,
+ )
client.active_inference += 1
inference_q.put(("val", copy.copy(client)))
if client.force_reset_weights:
@@ -363,6 +548,10 @@ async def inference_runner_clients(mad, client_runner_q, inference_q, stop):
)
client.force_reset_weights = False
client.request_full_val = False
+ logging.debug(
+ "inference_runner_clients: client %s request_full_val cleared",
+ client.client_id,
+ )
# Note: Do not schedule periodic mesh updates here. Mesh requests
# are driven by user interactions (force_update/next_step) and
@@ -387,90 +576,116 @@ async def inference_result_sender(results_q, stop):
client.active_inference -= 1 # allow next thing to run
# asyncio.sleep(0.00001)
- try:
- if train_or_val in ("train", "mesh", "sgd"):
- # TODO need to be ok with getting errors here
- logging.debug("inference_result_sender: sent train to client")
- msg = hudes_pb2.Control(
- type=hudes_pb2.Control.CONTROL_TRAIN_LOSS_AND_PREDS,
- train_loss_and_preds=hudes_pb2.TrainLossAndPreds(
- train_loss=res["train"]["train_loss"],
- preds=res["train"]["train_preds"]
- .cpu()
- .float()
- .flatten()
- .tolist(),
- preds_shape=list(res["train"]["train_preds"].shape),
- confusion_matrix=res["train"]["confusion_matrix"]
- .cpu()
- .float()
- .flatten()
- .tolist(),
- confusion_matrix_shape=list(
- res["train"]["confusion_matrix"].shape
- ),
+ websocket = client.websocket
+ if websocket is None or getattr(websocket, "closed", False):
+ logging.debug(
+ "inference_result_sender: websocket missing for client %s", client_id
+ )
+ continue
+
+ if train_or_val in ("train", "mesh", "loss_line", "sgd"):
+ if train_or_val in ("train", "mesh", "loss_line"):
+ client.total_eval_steps += 1
+ logging.debug("inference_result_sender: sent train to client")
+ control_msg = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_TRAIN_LOSS_AND_PREDS,
+ train_loss_and_preds=hudes_pb2.TrainLossAndPreds(
+ train_loss=res["train"]["train_loss"],
+ preds=res["train"]["train_preds"].cpu().float().flatten().tolist(),
+ preds_shape=list(res["train"]["train_preds"].shape),
+ confusion_matrix=res["train"]["confusion_matrix"]
+ .cpu()
+ .float()
+ .flatten()
+ .tolist(),
+ confusion_matrix_shape=list(res["train"]["confusion_matrix"].shape),
+ ),
+ request_idx=client.active_request_idx,
+ total_sgd_steps=client.total_sgd_steps,
+ total_eval_steps=client.total_eval_steps,
+ **_countdown_kwargs(client),
+ )
+ await _send_control_message(client, control_msg)
+ logging.debug("inference_result_sender: sent train to client : done")
+ if train_or_val == "val":
+ logging.debug("inference_result_sender: sent val to client")
+ speed_finished = False
+ if getattr(client, "speed_run_active", False):
+ remaining = max(0, int(client.speed_run_end_time - time.time()))
+ if remaining <= 0:
+ client.speed_run_active = False
+ speed_finished = True
+ rank = None
+ total_scores = None
+ if speed_finished:
+ logging.info(
+ "Speed run finished for client %s; sending VAL loss %.6f",
+ client.client_id,
+ res["val"]["val_loss"],
+ )
+ try:
+ init_db()
+ r, t = get_rank(res["val"]["val_loss"])
+ rank = r
+ total_scores = t + 1 # +1 because we are about to add this score
+ except Exception as e:
+ logging.error("Failed to calculate rank: %s", e)
+
+ await _send_control_message(
+ client,
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_VAL_LOSS,
+ val_loss=hudes_pb2.ValLoss(
+ val_loss=res["val"]["val_loss"],
+ rank=rank,
+ total_scores=total_scores,
),
request_idx=client.active_request_idx,
- total_sgd_steps=client.total_sgd_steps,
+ speed_run_finished=speed_finished,
+ total_eval_steps=client.total_eval_steps,
**_countdown_kwargs(client),
- ).SerializeToString()
- await client.websocket.send(msg)
- logging.debug("inference_result_sender: sent train to client : done")
- if train_or_val == "val":
- logging.debug("inference_result_sender: sent val to client")
- # If a speed run is active but time elapsed, flip off and
- # mark finished
- speed_finished = False
- if getattr(client, "speed_run_active", False):
- remaining = max(0, int(client.speed_run_end_time - time.time()))
- if remaining <= 0:
- client.speed_run_active = False
- speed_finished = True
- await client.websocket.send(
- hudes_pb2.Control(
- type=hudes_pb2.Control.CONTROL_VAL_LOSS,
- val_loss=hudes_pb2.ValLoss(
- val_loss=res["val"]["val_loss"],
- ),
- request_idx=client.active_request_idx,
- speed_run_finished=speed_finished,
- **_countdown_kwargs(client),
- ).SerializeToString()
- )
- if client.best_val_loss_during_run is None:
- client.best_val_loss_during_run = res["val"]["val_loss"]
- else:
- client.best_val_loss_during_run = min(
- client.best_val_loss_during_run, res["val"]["val_loss"]
- )
- # No special full-loss message; timeout schedules a regular
- # VAL which the client can treat as final when countdown hit 0
- logging.debug("inference_result_sender: sent val to client : done")
- if train_or_val == "mesh":
- logging.debug("inference_result_sender: sent mesh to client")
-
- # Convert the tensor to a list of floats and capture the shape
- mesh_tensor = res["mesh"].cpu().float()
- mesh_grid_results_list = mesh_tensor.numpy().flatten().tolist()
- mesh_grid_shape = list(mesh_tensor.shape)
-
- # Send the message using repeated float and include shape
- await client.websocket.send(
- hudes_pb2.Control(
- type=hudes_pb2.Control.CONTROL_MESHGRID_RESULTS,
- mesh_grid_results=mesh_grid_results_list,
- mesh_grid_shape=mesh_grid_shape,
- **_countdown_kwargs(client),
- ).SerializeToString()
+ ),
+ )
+ if client.best_val_loss_during_run is None:
+ client.best_val_loss_during_run = res["val"]["val_loss"]
+ else:
+ client.best_val_loss_during_run = min(
+ client.best_val_loss_during_run, res["val"]["val_loss"]
)
- logging.debug("inference_result_sender: sent mesh to client : done")
- if train_or_val not in ("train", "val", "mesh", "sgd"):
- raise ValueError
- except (
- websockets.exceptions.ConnectionClosedOK,
- websockets.exceptions.ConnectionClosedError,
- ):
- pass
+ logging.debug("inference_result_sender: sent val to client : done")
+ if train_or_val == "mesh":
+ logging.debug("inference_result_sender: sent mesh to client")
+ mesh_tensor = res["mesh"].cpu().float()
+ mesh_grid_results_list = mesh_tensor.numpy().flatten().tolist()
+ mesh_grid_shape = list(mesh_tensor.shape)
+ await _send_control_message(
+ client,
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_MESHGRID_RESULTS,
+ mesh_grid_results=mesh_grid_results_list,
+ mesh_grid_shape=mesh_grid_shape,
+ total_eval_steps=client.total_eval_steps,
+ **_countdown_kwargs(client),
+ ),
+ )
+ logging.debug("inference_result_sender: sent mesh to client : done")
+ if train_or_val == "loss_line":
+ logging.debug("inference_result_sender: sent loss line to client")
+ loss_tensor = res["loss_line"]
+ await _send_control_message(
+ client,
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_LOSS_LINE_RESULTS,
+ loss_line_results=loss_tensor.flatten().tolist(),
+ loss_line_shape=list(loss_tensor.shape),
+ total_eval_steps=client.total_eval_steps,
+ **_countdown_kwargs(client),
+ ),
+ )
+ logging.debug("inference_result_sender: sent loss line to client : done")
+ if train_or_val not in ("train", "val", "mesh", "loss_line", "sgd"):
+ raise ValueError
+
assert client.active_inference >= 0
@@ -482,30 +697,48 @@ async def wait_for_stop(inference_q, results_q, stop, client_runner_q):
def _schedule_speedrun_timeout(client_id: int, duration_sec: int, client_runner_q):
+ logging.debug(
+ "Scheduling speed run timeout for client %s in %s seconds",
+ client_id,
+ duration_sec,
+ )
+
async def _timeout_then_eval():
try:
await asyncio.sleep(max(0, duration_sec))
+ logging.debug("Speed run timeout fired for client %s", client_id)
# Verify client still exists and the run sequence matches
client = active_clients.get(client_id)
if client is None:
+ logging.debug("Speed run timeout: client %s missing", client_id)
return
# If a newer speed run started, ignore this timeout
if time.time() < client.speed_run_end_time:
+ logging.debug(
+ "Speed run timeout: client %s has newer run (end_time %.3f > now)",
+ client_id,
+ client.speed_run_end_time,
+ )
return
# Request a normal validation at run end; client will use it
# as the final score
client.request_full_val = True
+ logging.debug(
+ "Speed run timeout: client=%s set request_full_val=True (active=%s, end_time=%.3f)",
+ client_id,
+ getattr(client, "speed_run_active", None),
+ getattr(client, "speed_run_end_time", 0.0),
+ )
client_runner_q.put(True)
+ logging.debug(
+ "Speed run timeout enqueued final validation for client %s",
+ client_id,
+ )
except Exception as e:
logging.error("Speed Run timeout scheduling error: %s", e)
# Cancel previous task if any
- old_task = active_speedrun_tasks.get(client_id)
- if old_task and not old_task.done():
- try:
- old_task.cancel()
- except Exception:
- pass
+ _cancel_speedrun_timeout(client_id)
task = asyncio.create_task(_timeout_then_eval())
active_speedrun_tasks[client_id] = task
@@ -516,14 +749,16 @@ async def inference_runner(
stop,
run_in: str = "process",
):
- inference_q = mp.Queue()
- results_q = mp.Queue()
-
if run_in == "process":
- process_or_thread = mp.Process(
+ ctx = mp.get_context("spawn")
+ inference_q = ctx.Queue()
+ results_q = ctx.Queue()
+ process_or_thread = ctx.Process(
target=listen_and_run, args=(inference_q, results_q, mad)
)
elif run_in == "thread": # useful for debuggin
+ inference_q = mp.Queue()
+ results_q = mp.Queue()
process_or_thread = Thread(
target=listen_and_run, args=(inference_q, results_q, mad)
)
@@ -542,184 +777,240 @@ async def inference_runner(
async def process_client(websocket, client_runner_q):
global client_idx
- current_client = client_idx
- client_idx += 1
- client = Client(
- client_id=current_client,
- last_seen=time.time(),
- next_step={},
- batch_idx=0,
- websocket=websocket,
- request_idx=0,
- active_inference=0,
- sent_batch=-1,
- )
- active_clients[current_client] = client
-
- logging.debug(f"process_client: start for client {client_idx}")
- async for message in websocket:
- msg = hudes_pb2.Control()
- msg.ParseFromString(message)
- # Strict cutoff: only log messages received before expiry
- if client.speed_run_active:
- now = time.time()
- if now < client.speed_run_end_time:
- client.speed_run_log.append(message)
- else:
- client.speed_run_active = False
- if msg.type == hudes_pb2.Control.CONTROL_DIMS:
- logging.debug(f"process_client: {client_idx} : control dims")
- for dim_and_step in msg.dims_and_steps:
- dim = dim_and_step.dim + client.dims_offset
- if dim in client.next_step:
- client.next_step[dim] += dim_and_step.step
- else:
- client.next_step[dim] = dim_and_step.step
- client.request_idx = msg.request_idx
- elif msg.type == hudes_pb2.Control.CONTROL_NEXT_BATCH:
- logging.debug(f"process_client: {client_idx} : next batch")
- client.batch_idx += 1
- client.request_full_val = True
- client.request_idx = msg.request_idx
-
- elif msg.type == hudes_pb2.Control.CONTROL_NEXT_DIMS:
- logging.debug(f"process_client: {client_idx} : next dims")
- client.dims_offset += client.dims_at_a_time
- # ensure we do not reuse dims
- client.force_update = True
- elif msg.type == hudes_pb2.Control.CONTROL_CONFIG:
- logging.debug(f"process_client: {client_idx} : control config")
- old_batch_size = client.batch_size
- old_dtype = client.dtype
-
- client.dims_at_a_time = msg.config.dims_at_a_time
- client.seed = msg.config.seed
- client.mesh_grid_size = msg.config.mesh_grid_size
- client.mesh_grids = msg.config.mesh_grids
- client.mesh_step_size = msg.config.mesh_step_size
- client.batch_size = msg.config.batch_size
- client.dtype = getattr(torch, msg.config.dtype)
-
- if (
- client.mesh_grids > 0
- or old_batch_size != client.batch_size
- or old_dtype != client.dtype
- ): # if we have grids, step size changes mesh
- client.force_update = True
+ client = None
- elif msg.type == hudes_pb2.Control.CONTROL_QUIT:
- logging.debug(f"process_client: {client_idx} : quit")
- client_runner_q.put(True)
- break
+ try:
+ async for message in websocket:
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(message)
+
+ if client is None:
+ if msg.type == hudes_pb2.Control.CONTROL_RESUME:
+ client = await _handle_resume_request(
+ msg, websocket, client_runner_q
+ )
+ if client is None:
+ continue
+ # Resume handshake handled entirely; wait for next message
+ continue
+ current_client = client_idx
+ client_idx += 1
+ client = Client(
+ client_id=current_client,
+ last_seen=time.time(),
+ next_step={},
+ batch_idx=0,
+ websocket=websocket,
+ request_idx=0,
+ active_inference=0,
+ sent_batch=-1,
+ )
+ active_clients[current_client] = client
+ logging.debug("process_client: start for client %s", client.client_id)
+
+ client.last_seen = time.time()
- elif msg.type == hudes_pb2.Control.CONTROL_SGD_STEP:
- # Ignore SGD during active speed run
if client.speed_run_active:
- logging.debug("process_client: ignoring SGD during speed run")
- else:
- client.sgd += msg.sgd_steps
- client.total_sgd_steps += msg.sgd_steps
+ now = time.time()
+ if now < client.speed_run_end_time:
+ client.speed_run_log.append(message)
+ else:
+ client.speed_run_active = False
+
+ if msg.type == hudes_pb2.Control.CONTROL_DIMS:
+ logging.debug("process_client: %s : control dims", client.client_id)
+ for dim_and_step in msg.dims_and_steps:
+ dim = dim_and_step.dim + client.dims_offset
+ client.next_step[dim] = (
+ client.next_step.get(dim, 0) + dim_and_step.step
+ )
+ client.request_idx = msg.request_idx
+ elif msg.type == hudes_pb2.Control.CONTROL_NEXT_BATCH:
+ logging.debug("process_client: %s : next batch", client.client_id)
+ client.batch_idx += 1
+ client.request_full_val = True
client.request_idx = msg.request_idx
+ elif msg.type == hudes_pb2.Control.CONTROL_NEXT_DIMS:
+ logging.debug("process_client: %s : next dims", client.client_id)
+ client.dims_offset += client.dims_at_a_time
+ client.force_update = True
+ elif msg.type == hudes_pb2.Control.CONTROL_CONFIG:
+ logging.debug("process_client: %s : control config", client.client_id)
+ old_batch_size = client.batch_size
+ old_dtype = client.dtype
+ old_mesh_enabled = client.mesh_enabled
+ old_mesh_grids = client.mesh_grids
+ old_loss_lines = client.loss_lines
+
+ client.dims_at_a_time = msg.config.dims_at_a_time
+ client.seed = msg.config.seed
+ client.mesh_grid_size = msg.config.mesh_grid_size
+ client.mesh_grids = msg.config.mesh_grids
+ client.mesh_step_size = msg.config.mesh_step_size
+ client.batch_size = msg.config.batch_size
+ client.dtype = getattr(torch, msg.config.dtype)
+ client.mesh_enabled = msg.config.mesh_enabled
+ client.loss_lines = msg.config.loss_lines
+ client.resume_enabled = getattr(msg.config, "resume_supported", True)
+ if getattr(msg.config, "client_session_token", None):
+ client.session_token = msg.config.client_session_token
+ elif not client.session_token:
+ client.session_token = _generate_session_token()
+ if client.resume_enabled:
+ _register_resume_token(client)
+
+ if (
+ client.mesh_enabled
+ and client.mesh_grids > 0
+ or client.loss_lines > 0
+ or old_batch_size != client.batch_size
+ or old_dtype != client.dtype
+ or old_mesh_enabled != client.mesh_enabled
+ or old_mesh_grids != client.mesh_grids
+ or old_loss_lines != client.loss_lines
+ ):
+ client.force_update = True
- elif msg.type == hudes_pb2.Control.CONTROL_SPEED_RUN_START:
- logging.debug(f"process_client: {client_idx} : speed run start")
- # reset per-client state
- client.next_step = {}
- client.current_step = None
- client.batch_idx = 0
- client.request_idx = 0
- client.sent_batch = -1
- client.request_full_val = True
- client.dims_offset = 0
- client.total_sgd_steps = 0
- client.sgd = 0
- client.best_val_loss_during_run = None
- client.speed_run_log = []
- client.high_score_logged = False
- # Start timer (allow env override at runtime)
- duration = int(
- os.environ.get(
- "HUDES_SPEED_RUN_SECONDS",
- str(SPEED_RUN_SECONDS),
- )
- )
- client.speed_run_active = True
- client.speed_run_seq += 1
- client.speed_run_end_time = time.time() + max(1, duration)
- logging.info(
- "Client %d Speed Run started for %d seconds",
- client.client_id,
- duration,
- )
- # Schedule a final evaluation once the timer elapses for this
- # run sequence
- _schedule_speedrun_timeout(current_client, duration, client_runner_q)
- # Reset weights by signaling runner with force_update and
- # empty step; inference worker re-initializes when missing.
- # inference worker re-initializes weights when not present.
- client.force_update = True
- client.force_reset_weights = True
-
- elif msg.type == hudes_pb2.Control.CONTROL_HIGH_SCORE_LOG:
- logging.debug(f"process_client: {client_idx} : high score log")
- if client.high_score_logged:
- logging.debug("process_client: high score already logged; ignoring")
- else:
- name = msg.high_score.name.strip().upper() if msg.high_score else "????"
- # Strict validation: reject invalid names (do not persist)
- if len(name) != 4 or not name.isalnum():
- logging.info(
- "Invalid high score name submitted; ignoring run" " persistence"
- )
- # Do not set high_score_logged; allow client to re-submit
- client_runner_q.put(True)
- continue
+ elif msg.type == hudes_pb2.Control.CONTROL_QUIT:
+ logging.debug("process_client: %s : quit", client.client_id)
+ client_runner_q.put(True)
+ break
+ elif msg.type == hudes_pb2.Control.CONTROL_SGD_STEP:
+ if client.speed_run_active:
+ logging.debug("process_client: ignoring SGD during speed run")
+ else:
+ client.sgd += msg.sgd_steps
+ client.total_sgd_steps += msg.sgd_steps
+ client.request_idx = msg.request_idx
+ elif msg.type == hudes_pb2.Control.CONTROL_SPEED_RUN_START:
+ logging.debug("process_client: %s : speed run start", client.client_id)
+ client.next_step = {}
+ client.current_step = None
+ client.batch_idx = 0
+ client.request_idx = 0
+ client.sent_batch = -1
+ client.request_full_val = True
+ client.dims_offset = 0
+ client.total_sgd_steps = 0
+ client.sgd = 0
+ client.best_val_loss_during_run = None
+ client.speed_run_log = []
+ client.high_score_logged = False
duration = int(
os.environ.get(
"HUDES_SPEED_RUN_SECONDS",
str(SPEED_RUN_SECONDS),
)
)
- score = (
- client.best_val_loss_during_run
- if client.best_val_loss_during_run is not None
- else float("inf")
+ client.speed_run_active = True
+ client.speed_run_seq += 1
+ client.speed_run_end_time = time.time() + max(1, duration)
+ logging.info(
+ "Client %d Speed Run started for %d seconds",
+ client.client_id,
+ duration,
+ )
+ _schedule_speedrun_timeout(client.client_id, duration, client_runner_q)
+ client.force_update = True
+ client.force_reset_weights = True
+ elif msg.type == hudes_pb2.Control.CONTROL_SPEED_RUN_CANCEL:
+ logging.debug("process_client: %s : speed run cancel", client.client_id)
+ if client.speed_run_active:
+ client.speed_run_active = False
+ client.speed_run_end_time = 0.0
+ client.speed_run_log = []
+ client.best_val_loss_during_run = None
+ _cancel_speedrun_timeout(client.client_id)
+ client.force_update = True
+ client_runner_q.put(True)
+ logging.info(
+ "Client %d Speed Run cancelled and returning to normal play",
+ client.client_id,
+ )
+ else:
+ logging.debug(
+ "process_client: %s : cancel ignored (no active run)",
+ client.client_id,
+ )
+ elif msg.type == hudes_pb2.Control.CONTROL_HIGH_SCORE_LOG:
+ logging.debug("process_client: %s : high score log", client.client_id)
+ if client.high_score_logged:
+ logging.debug("process_client: high score already logged; ignoring")
+ else:
+ name = (
+ msg.high_score.name.strip().upper()
+ if msg.high_score
+ else "????"
+ )
+ if len(name) != 4 or not name.isalnum():
+ logging.info(
+ "Invalid high score name submitted; ignoring run persistence"
+ )
+ client_runner_q.put(True)
+ continue
+ duration = int(
+ os.environ.get(
+ "HUDES_SPEED_RUN_SECONDS",
+ str(SPEED_RUN_SECONDS),
+ )
+ )
+ score = (
+ client.best_val_loss_during_run
+ if client.best_val_loss_during_run is not None
+ else float("inf")
+ )
+ try:
+ init_db()
+ insert_high_score(
+ name=name,
+ score=score,
+ best_val_loss=score,
+ duration=duration,
+ request_idx=client.request_idx,
+ log_bytes=_pack_messages_len_prefixed(client.speed_run_log),
+ )
+ client.high_score_logged = True
+ except Exception as e:
+ logging.error(f"Failed to persist high score: {e}")
+ elif msg.type == hudes_pb2.Control.CONTROL_LEADERBOARD_REQUEST:
+ logging.debug(
+ "process_client: %s : leaderboard request", client.client_id
)
- # persist
try:
init_db()
- insert_high_score(
- name=name,
- score=score,
- best_val_loss=score,
- duration=duration,
- request_idx=client.request_idx,
- log_bytes=_pack_messages_len_prefixed(client.speed_run_log),
+ rows = get_top_scores(limit=10)
+ names = [str(r[0]) for r in rows]
+ scores = [float(r[1]) for r in rows]
+ await _send_control_message(
+ client,
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_LEADERBOARD_RESPONSE,
+ leaderboard_names=names,
+ leaderboard_scores=scores,
+ ),
)
- client.high_score_logged = True
except Exception as e:
- logging.error(f"Failed to persist high score: {e}")
-
- elif msg.type == hudes_pb2.Control.CONTROL_LEADERBOARD_REQUEST:
- logging.debug("process_client: %d : leaderboard request", client_idx)
- try:
- init_db()
- rows = get_top_scores(limit=10)
- names = [str(r[0]) for r in rows]
- scores = [float(r[1]) for r in rows]
- resp = hudes_pb2.Control(
- type=hudes_pb2.Control.CONTROL_LEADERBOARD_RESPONSE,
- leaderboard_names=names,
- leaderboard_scores=scores,
- ).SerializeToString()
- await client.websocket.send(resp)
- except Exception as e:
- logging.error("Failed to fetch leaderboard: %s", e)
-
- else:
- logging.warning("received invalid type from client")
+ logging.error("Failed to fetch leaderboard: %s", e)
+ elif msg.type == hudes_pb2.Control.CONTROL_RESUME:
+ logging.debug(
+ "process_client: %s : duplicate resume ignored",
+ client.client_id,
+ )
+ else:
+ logging.warning("received invalid type from client")
- client_runner_q.put(True)
+ client_runner_q.put(True)
+ except (
+ websockets.exceptions.ConnectionClosedOK,
+ websockets.exceptions.ConnectionClosedError,
+ ):
+ pass
+ finally:
+ if client is not None and client.websocket is websocket:
+ client.websocket = None
+ client.disconnected_at = time.time()
+ if client.resume_enabled:
+ _register_resume_token(client)
async def run_server(stop, client_runner_q, server_port, ssl_pem):
@@ -765,11 +1056,40 @@ def send_json(payload: str, status: str = "200 OK"):
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"Access-Control-Allow-Origin: *\r\n"
- "Access-Control-Allow-Methods: GET, OPTIONS\r\n"
+ "Access-Control-Allow-Methods: GET, OPTIONS, DELETE\r\n"
"Access-Control-Allow-Headers: *\r\n\r\n"
).encode("utf-8")
writer.write(headers + body)
+ if first.startswith(b"OPTIONS"):
+ send_json("", status="204 No Content")
+ return
+
+ if first.startswith(b"DELETE") and path.startswith("/api/highscores"):
+ try:
+ import urllib.parse as _url
+
+ qs = ""
+ if "?" in path:
+ _, qs = path.split("?", 1)
+ params = _url.parse_qs(qs)
+ row_id = int(params.get("id", [0])[0])
+ if row_id > 0:
+ init_db()
+ success = delete_high_score(row_id)
+ if success:
+ send_json('{"status": "deleted"}')
+ else:
+ send_json('{"error": "not found"}', status="404 Not Found")
+ else:
+ send_json('{"error": "invalid id"}', status="400 Bad Request")
+ except Exception:
+ send_json(
+ '{"error": "failed"}',
+ status="500 Internal Server Error",
+ )
+ return
+
if path == "/health":
response = (
b"HTTP/1.1 200 OK\r\n"
diff --git a/run.sh b/run.sh
index 51e8a16..0be7b07 100644
--- a/run.sh
+++ b/run.sh
@@ -9,3 +9,4 @@ python hudes/hudes_play.py --input keyboard
#python hudes/hudes_play.py --input xtouch
kill ${server_pid}
kill -9 ${server_pid}
+# /home/mouse9911/gits/human_descent/hudes_env/bin/python websocket_server.py --device=cuda --model=cnn3 --port=10000 --ssl-pem=hudes.pem --run-in=process
diff --git a/tests/test_headless_pygame.py b/tests/test_headless_pygame.py
index 3b42600..e492b19 100644
--- a/tests/test_headless_pygame.py
+++ b/tests/test_headless_pygame.py
@@ -2,6 +2,7 @@
import importlib
import inspect
import os
+import subprocess
import sys
import types
from types import SimpleNamespace
@@ -128,6 +129,38 @@ async def wait_for_server(host: str, port: int, timeout: float = 20.0):
await asyncio.sleep(0.1)
+async def launch_test_server(port: int, device: str = "mps"):
+ download_args = SimpleNamespace(
+ device="cpu",
+ model="ffnn",
+ max_batch_size=512,
+ max_grids=5,
+ port=port,
+ max_grid_size=41,
+ ssl_pem=None,
+ run_in="thread",
+ download_dataset_and_exit=True,
+ )
+ await run_wrapper(download_args)
+
+ server_args = SimpleNamespace(
+ device=device,
+ model="ffnn",
+ max_batch_size=512,
+ max_grids=5,
+ port=port,
+ max_grid_size=41,
+ ssl_pem=None,
+ run_in="thread",
+ download_dataset_and_exit=False,
+ )
+ loop = asyncio.get_running_loop()
+ stop_future = loop.create_future()
+ server_task = asyncio.create_task(run_wrapper(server_args, stop_future=stop_future))
+ await wait_for_server("localhost", port)
+ return server_task, stop_future
+
+
def run_clients_sequence(port: int):
KeyboardClientGL = _get_keyboard_client_gl()
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
@@ -190,42 +223,56 @@ def run_clients_sequence(port: int):
pg.quit()
+def run_hudes_cli_controller(input_name: str, port: int, headless: bool = False):
+ env = os.environ.copy()
+ env.setdefault("SDL_VIDEODRIVER", "dummy")
+ env.setdefault("SDL_AUDIODRIVER", "dummy")
+ env.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
+ cmd = [
+ sys.executable,
+ "hudes/hudes_play.py",
+ "--input",
+ input_name,
+ "--port",
+ str(port),
+ "--skip-help",
+ "--max-loop-iterations",
+ "200",
+ "--max-seconds",
+ "10",
+ ]
+ cmd = [
+ sys.executable,
+ "hudes/hudes_play.py",
+ "--input",
+ input_name,
+ "--port",
+ str(port),
+ "--skip-help",
+ "--max-loop-iterations",
+ "200",
+ "--max-seconds",
+ "10",
+ ]
+ if headless:
+ cmd.append("--headless")
+ result = subprocess.run(
+ cmd,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=60,
+ )
+ return result
+
+
@pytest.mark.asyncio
@pytest.mark.timeout(120)
async def test_run_sh_headless_pygame():
port = 8766
-
- download_args = SimpleNamespace(
- device="cpu",
- model="ffnn",
- max_batch_size=512,
- max_grids=5,
- port=port,
- max_grid_size=41,
- ssl_pem=None,
- run_in="thread",
- download_dataset_and_exit=True,
- )
- await run_wrapper(download_args)
-
- server_args = SimpleNamespace(
- device="mps",
- model="ffnn",
- max_batch_size=512,
- max_grids=5,
- port=port,
- max_grid_size=41,
- ssl_pem=None,
- run_in="thread",
- download_dataset_and_exit=False,
- )
- loop = asyncio.get_running_loop()
- stop_future = loop.create_future()
- server_task = asyncio.create_task(run_wrapper(server_args, stop_future=stop_future))
-
+ server_task, stop_future = await launch_test_server(port)
try:
- await wait_for_server("localhost", port)
-
results = await asyncio.to_thread(run_clients_sequence, port)
assert results["keyboardGL"][
@@ -241,3 +288,35 @@ async def test_run_sh_headless_pygame():
if not stop_future.done():
stop_future.set_result(True)
await server_task
+
+
+@pytest.mark.asyncio
+@pytest.mark.timeout(120)
+async def test_hudes_play_keyboardgl_cli():
+ port = 8770
+ server_task, stop_future = await launch_test_server(port)
+ try:
+ result = await asyncio.to_thread(
+ run_hudes_cli_controller, "keyboardGL", port, True
+ )
+ assert result.returncode == 0, f"keyboardGL CLI failed: {result.stderr}"
+ assert "Traceback" not in (result.stderr or "")
+ finally:
+ if not stop_future.done():
+ stop_future.set_result(True)
+ await server_task
+
+
+@pytest.mark.asyncio
+@pytest.mark.timeout(120)
+async def test_hudes_play_keyboard_cli():
+ port = 8771
+ server_task, stop_future = await launch_test_server(port)
+ try:
+ result = await asyncio.to_thread(run_hudes_cli_controller, "keyboard", port)
+ assert result.returncode == 0, f"keyboard CLI failed: {result.stderr}"
+ assert "Traceback" not in (result.stderr or "")
+ finally:
+ if not stop_future.done():
+ stop_future.set_result(True)
+ await server_task
diff --git a/tests/test_highscores_api.py b/tests/test_highscores_api.py
new file mode 100644
index 0000000..67d7aa5
--- /dev/null
+++ b/tests/test_highscores_api.py
@@ -0,0 +1,70 @@
+import os
+import pytest
+from hudes.high_scores import (
+ init_db,
+ insert_high_score,
+ get_all_scores,
+ delete_high_score,
+ get_rank,
+)
+
+DB_PATH = "test_high_scores_api.sqlite3"
+
+
+@pytest.fixture
+def setup_db():
+ if os.path.exists(DB_PATH):
+ os.remove(DB_PATH)
+ init_db(DB_PATH)
+ yield
+ if os.path.exists(DB_PATH):
+ os.remove(DB_PATH)
+
+
+@pytest.mark.asyncio
+async def test_api_functions(setup_db):
+ # Insert a dummy score
+ insert_high_score(
+ name="TEST",
+ score=1.0,
+ best_val_loss=1.0,
+ duration=10,
+ request_idx=0,
+ log_bytes=b"",
+ path=DB_PATH,
+ )
+
+ # Test get_all_scores (used by /api/highscores)
+ scores = get_all_scores(path=DB_PATH)
+ assert len(scores) == 1
+ assert scores[0][2] == "TEST"
+
+ # Test get_rank (used by /api/rank)
+ rank, total = get_rank(0.5, path=DB_PATH)
+ assert total == 1
+ assert (
+ rank == 1
+ ) # 0.5 is better than 1.0 (lower is better? wait, code says score < ?)
+
+ # Check logic:
+ # SELECT COUNT(*) FROM high_scores WHERE score < ?
+ # if score is 0.5, and existing is 1.0. 0.5 < 1.0 is True? No, existing is 1.0.
+ # We are checking how many are strictly better (lower) than our score.
+ # If I have 0.5, and existing is 1.0. Is 1.0 < 0.5? False. So 0 better. Rank 1.
+
+ # Wait, get_rank(score):
+ # cur = conn.execute("SELECT COUNT(*) FROM high_scores WHERE score < ?", (float(score),))
+ # less = int(cur.fetchone()[0])
+ # return less + 1
+
+ # If I query for 1.5. Existing is 1.0. 1.0 < 1.5 is True. So less=1. Rank=2.
+ rank, total = get_rank(1.5, path=DB_PATH)
+ assert rank == 2
+
+ # Test delete
+ row_id = scores[0][0]
+ success = delete_high_score(row_id, path=DB_PATH)
+ assert success
+
+ scores = get_all_scores(path=DB_PATH)
+ assert len(scores) == 0
diff --git a/tests/test_highscores_delete.py b/tests/test_highscores_delete.py
new file mode 100644
index 0000000..21e79c9
--- /dev/null
+++ b/tests/test_highscores_delete.py
@@ -0,0 +1,56 @@
+import os
+import pytest
+from hudes.high_scores import init_db, insert_high_score, get_all_scores
+
+DB_PATH = "test_high_scores.sqlite3"
+
+
+@pytest.fixture
+def setup_db():
+ if os.path.exists(DB_PATH):
+ os.remove(DB_PATH)
+ init_db(DB_PATH)
+ yield
+ if os.path.exists(DB_PATH):
+ os.remove(DB_PATH)
+
+
+@pytest.mark.asyncio
+async def test_delete_api(setup_db):
+ # Insert a dummy score
+ insert_high_score(
+ name="TEST",
+ score=1.0,
+ best_val_loss=1.0,
+ duration=10,
+ request_idx=0,
+ log_bytes=b"",
+ path=DB_PATH,
+ )
+
+ # Verify it exists
+ scores = get_all_scores(path=DB_PATH)
+ assert len(scores) == 1
+ row_id = scores[0][0]
+
+ # We need to run the server in a way that we can make requests to it
+ # But run_server is an infinite loop.
+ # Instead, let's just test the logic by mocking the request handler or
+ # by running the server in a background task and making a request.
+
+ # Actually, since the logic is inside handle_api which is inside run_server,
+ # it's hard to unit test without extracting handle_api.
+ # However, I can rely on the fact that I implemented delete_high_score and called it.
+ # Let's just verify delete_high_score works as expected first.
+
+ from hudes.high_scores import delete_high_score
+
+ success = delete_high_score(row_id, path=DB_PATH)
+ assert success
+
+ scores = get_all_scores(path=DB_PATH)
+ assert len(scores) == 0
+
+ # Verify deleting non-existent returns False
+ success = delete_high_score(999, path=DB_PATH)
+ assert not success
diff --git a/tests/test_param_nn.py b/tests/test_param_nn.py
index f3d5d8e..c5b00a7 100644
--- a/tests/test_param_nn.py
+++ b/tests/test_param_nn.py
@@ -3,6 +3,7 @@
from hudes.model_data_and_subspace import param_nn_from_sequential
from hudes.model_first.model_first_nn import MFLinear, MFSequential
from hudes.models_and_datasets.mnist import MNISTCNN, MNISTCNNFlipped
+from hudes.models_and_datasets.mnist import MNISTFFNN, mnist_model_data_and_subpace
def test_linear_and_relu():
@@ -139,3 +140,49 @@ def test_mnistcnn_flipped_multimodel():
assert out[1][0].isclose(_out, atol=1e-5).all()
assert not out[1][1].isclose(_out, atol=1e-5).all()
assert out[1][2].isclose(_out, atol=1e-5).all()
+
+
+def test_loss_lines_matches_grid_slice():
+ torch.manual_seed(0)
+ mnist_net = MNISTFFNN()
+ mad = mnist_model_data_and_subpace(model=mnist_net, max_grids=3, max_grid_size=21)
+ mad.move_to_device()
+ mad.fuse()
+ mad.init_param_model()
+
+ base_weights = mad.saved_weights[torch.float32]
+ grid_size = 9
+ step_size = 0.05
+ batch_size = 16
+
+ grid = mad.get_loss_grid(
+ base_weights=base_weights,
+ batch_idx=0,
+ dims_offset=0,
+ grids=1,
+ grid_size=grid_size,
+ step_size=step_size,
+ batch_size=batch_size,
+ dtype=torch.float32,
+ )
+
+ lines = mad.get_loss_lines(
+ base_weights=base_weights,
+ batch_idx=0,
+ dims_offset=0,
+ lines=2,
+ grid_size=grid_size,
+ step_size=step_size,
+ batch_size=batch_size,
+ dtype=torch.float32,
+ )
+
+ center = grid_size // 2
+ expected = torch.stack(
+ [grid[0, center, :], grid[0, :, center]],
+ dim=0,
+ )
+
+ assert torch.allclose(lines, expected, atol=1e-5) or torch.allclose(
+ lines, expected.flip(0), atol=1e-5
+ )
diff --git a/tests/test_rank_logic.py b/tests/test_rank_logic.py
new file mode 100644
index 0000000..0c25947
--- /dev/null
+++ b/tests/test_rank_logic.py
@@ -0,0 +1,66 @@
+import os
+import unittest
+from hudes.high_scores import get_rank, insert_high_score, init_db
+
+DB_PATH = "test_rank.sqlite3"
+
+
+class TestRankLogic(unittest.TestCase):
+ def setUp(self):
+ if os.path.exists(DB_PATH):
+ os.remove(DB_PATH)
+ init_db(DB_PATH)
+
+ def tearDown(self):
+ if os.path.exists(DB_PATH):
+ os.remove(DB_PATH)
+
+ def test_get_rank_empty(self):
+ # Rank for a score in empty DB should be 1
+ rank, total = get_rank(1.0, path=DB_PATH)
+ self.assertEqual(rank, 1)
+ self.assertEqual(total, 0)
+
+ def test_get_rank_first_score(self):
+ # Insert one score
+ insert_high_score("USER", 2.0, 2.0, 60, 0, b"", path=DB_PATH)
+
+ # Check rank for a better score (1.0)
+ rank, total = get_rank(1.0, path=DB_PATH)
+ self.assertEqual(rank, 1)
+ self.assertEqual(total, 1)
+
+ # Check rank for a worse score (3.0)
+ rank, total = get_rank(3.0, path=DB_PATH)
+ self.assertEqual(rank, 2)
+ self.assertEqual(total, 1)
+
+ # Check rank for same score (2.0)
+ # Logic: less than 2.0 is 0. So rank is 1.
+ rank, total = get_rank(2.0, path=DB_PATH)
+ self.assertEqual(rank, 1)
+ self.assertEqual(total, 1)
+
+ def test_get_rank_multiple(self):
+ insert_high_score("U1", 1.0, 1.0, 60, 0, b"", path=DB_PATH)
+ insert_high_score("U2", 2.0, 2.0, 60, 0, b"", path=DB_PATH)
+ insert_high_score("U3", 3.0, 3.0, 60, 0, b"", path=DB_PATH)
+
+ # Better than all
+ rank, total = get_rank(0.5, path=DB_PATH)
+ self.assertEqual(rank, 1)
+ self.assertEqual(total, 3)
+
+ # Between 1 and 2
+ rank, total = get_rank(1.5, path=DB_PATH)
+ self.assertEqual(rank, 2)
+ self.assertEqual(total, 3)
+
+ # Worse than all
+ rank, total = get_rank(3.5, path=DB_PATH)
+ self.assertEqual(rank, 4)
+ self.assertEqual(total, 3)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_speed_run.py b/tests/test_speed_run.py
index 7f1a3b5..a5a90a7 100644
--- a/tests/test_speed_run.py
+++ b/tests/test_speed_run.py
@@ -32,6 +32,45 @@ def _decode_len_prefixed_log(blob: bytes):
return messages
+async def _wait_for_srs_value(websocket, predicate, timeout=3.0):
+ deadline = time.time() + timeout
+ while True:
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ raise AssertionError("Timed out waiting for speed run seconds condition")
+ try:
+ raw = await asyncio.wait_for(websocket.recv(), timeout=remaining)
+ except asyncio.TimeoutError as exc: # pragma: no cover
+ raise AssertionError("Timed out waiting for server message") from exc
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(raw)
+ if msg.HasField("speed_run_seconds_remaining"):
+ value = msg.speed_run_seconds_remaining
+ if predicate(value):
+ return value
+
+
+async def _assert_no_positive_srs(websocket, duration=1.5, grace=0.3):
+ deadline = time.time() + duration
+ grace_deadline = time.time() + grace
+ while True:
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ return
+ try:
+ raw = await asyncio.wait_for(websocket.recv(), timeout=remaining)
+ except asyncio.TimeoutError:
+ return
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(raw)
+ if (
+ msg.HasField("speed_run_seconds_remaining")
+ and msg.speed_run_seconds_remaining > 0
+ ):
+ if time.time() > grace_deadline:
+ raise AssertionError("Unexpected countdown observed after cancellation")
+
+
@pytest_asyncio.fixture
async def run_speed_server_thread(tmp_path):
# Configure short speed run and isolated DB path
@@ -177,3 +216,57 @@ async def test_speed_run_flow_and_db(run_speed_server_thread):
), "Expected interaction messages in log"
# Timer should have been included during run
assert saw_timer, "Did not observe speed run countdown in responses"
+
+
+@pytest.mark.timeout(60)
+@pytest.mark.asyncio
+async def test_speed_run_cancel_and_restart(run_speed_server_thread):
+ port, _ = run_speed_server_thread
+
+ async with websockets.connect(f"ws://localhost:{port}") as websocket:
+ cfg = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_CONFIG,
+ config=hudes_pb2.Config(
+ seed=456,
+ dims_at_a_time=6,
+ mesh_grid_size=21,
+ mesh_step_size=0.1,
+ mesh_grids=1,
+ batch_size=32,
+ dtype="float32",
+ ),
+ )
+ await websocket.send(cfg.SerializeToString())
+
+ # Start and ensure countdown appears
+ await websocket.send(
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_SPEED_RUN_START
+ ).SerializeToString()
+ )
+ await _wait_for_srs_value(websocket, lambda v: v > 0, timeout=5)
+
+ # Cancel and verify countdown stops
+ await websocket.send(
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_SPEED_RUN_CANCEL
+ ).SerializeToString()
+ )
+ # Nudge the server so it emits another response after cancellation
+ dims = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_DIMS,
+ dims_and_steps=[
+ hudes_pb2.DimAndStep(dim=0, step=0.05),
+ ],
+ request_idx=2,
+ )
+ await websocket.send(dims.SerializeToString())
+ await _assert_no_positive_srs(websocket, duration=1.5)
+
+ # Starting again should yield a new countdown
+ await websocket.send(
+ hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_SPEED_RUN_START
+ ).SerializeToString()
+ )
+ await _wait_for_srs_value(websocket, lambda v: v > 0, timeout=5)
diff --git a/tests/test_web.py b/tests/test_web.py
index 4508339..84738f0 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -1,4 +1,5 @@
import asyncio
+import uuid
from functools import partial
import pytest
@@ -9,6 +10,8 @@
from hudes.models_and_datasets.mnist import MNISTFFNN, mnist_model_data_and_subpace
from hudes.websocket_client import send_dims
from hudes.websocket_server import inference_runner, process_client
+from hudes import hudes_pb2
+from hudes.hudes_pb2 import Control, DimAndStep, Config
async def echo(websocket):
@@ -74,3 +77,238 @@ async def test_single_websocket_process(run_server_process):
@pytest.mark.asyncio
async def test_multi_websocket_process(run_server_process):
await asyncio.gather(*[asyncio.create_task(send_dims(2)) for x in range(10)])
+
+
+async def _open_configured_client(
+ uri: str,
+) -> tuple[websockets.WebSocketClientProtocol, str, str]:
+ websocket = await websockets.connect(uri)
+ session_token = f"session-{uuid.uuid4().hex}"
+ config = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_CONFIG,
+ config=hudes_pb2.Config(
+ seed=42,
+ dims_at_a_time=6,
+ mesh_grid_size=31,
+ mesh_step_size=0.1,
+ mesh_grids=0,
+ batch_size=32,
+ dtype="float32",
+ mesh_enabled=False,
+ loss_lines=0,
+ resume_supported=True,
+ client_session_token=session_token,
+ ),
+ )
+ await websocket.send(config.SerializeToString())
+ resume_token = None
+ for _ in range(20):
+ data = await asyncio.wait_for(websocket.recv(), timeout=5)
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(data)
+ if msg.resume_token:
+ resume_token = msg.resume_token
+ break
+ assert resume_token
+ return websocket, resume_token, session_token
+
+
+@pytest.mark.timeout(45)
+@pytest.mark.asyncio
+async def test_resume_handshake_success(run_server_thread):
+ uri = "ws://localhost:8767"
+ websocket, token, session_token = await _open_configured_client(uri)
+ await websocket.close()
+
+ async with websockets.connect(uri) as resumed_ws:
+ new_session_token = f"{session_token}-next"
+ resume = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ token=token,
+ last_request_idx=0,
+ client_session_token=session_token,
+ new_client_session_token=new_session_token,
+ ),
+ )
+ await resumed_ws.send(resume.SerializeToString())
+ data = await asyncio.wait_for(resumed_ws.recv(), timeout=5)
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(data)
+ assert msg.type == hudes_pb2.Control.CONTROL_RESUME
+ assert msg.resume.status == hudes_pb2.Control.Resume.RESUME_OK
+ assert msg.resume.client_session_token == new_session_token
+ rotated_token = msg.resume.token
+
+ # Old token should no longer be valid
+ async with websockets.connect(uri) as another_ws:
+ resume = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ token=token,
+ last_request_idx=0,
+ client_session_token=new_session_token,
+ new_client_session_token=f"{new_session_token}-fail",
+ ),
+ )
+ await another_ws.send(resume.SerializeToString())
+ data = await asyncio.wait_for(another_ws.recv(), timeout=5)
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(data)
+ assert msg.type == hudes_pb2.Control.CONTROL_RESUME
+ assert msg.resume.status == hudes_pb2.Control.Resume.RESUME_NOT_FOUND
+
+ # Ensure new token can resume
+ async with websockets.connect(uri) as final_ws:
+ resume = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ token=rotated_token,
+ last_request_idx=0,
+ client_session_token=new_session_token,
+ new_client_session_token=f"{new_session_token}-final",
+ ),
+ )
+ await final_ws.send(resume.SerializeToString())
+ data = await asyncio.wait_for(final_ws.recv(), timeout=5)
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(data)
+ assert msg.resume.status == hudes_pb2.Control.Resume.RESUME_OK
+
+
+@pytest.mark.timeout(45)
+@pytest.mark.asyncio
+async def test_resume_invalid_token(run_server_thread):
+ uri = "ws://localhost:8767"
+ async with websockets.connect(uri) as websocket:
+ resume = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ token="invalid-token",
+ last_request_idx=0,
+ client_session_token="nope",
+ new_client_session_token="nope-next",
+ ),
+ )
+ await websocket.send(resume.SerializeToString())
+ data = await asyncio.wait_for(websocket.recv(), timeout=5)
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(data)
+ assert msg.type == hudes_pb2.Control.CONTROL_RESUME
+ assert msg.resume.status == hudes_pb2.Control.Resume.RESUME_NOT_FOUND
+
+
+async def _run_scripted_session(port: int, dims_sequence: list[dict[int, float]]):
+ url = f"ws://localhost:{port}"
+ request_idx = 0
+
+ def next_request_idx():
+ nonlocal request_idx
+ current = request_idx
+ request_idx += 1
+ return current
+
+ async with websockets.connect(url) as websocket:
+ config_msg = Control(
+ type=Control.CONTROL_CONFIG,
+ request_idx=next_request_idx(),
+ config=Config(
+ seed=123,
+ dims_at_a_time=6,
+ mesh_grid_size=31,
+ mesh_step_size=0.1,
+ mesh_grids=1,
+ batch_size=32,
+ dtype="float32",
+ mesh_enabled=False,
+ loss_lines=0,
+ resume_supported=False,
+ ),
+ )
+ await websocket.send(config_msg.SerializeToString())
+
+ for dims_map in dims_sequence:
+ dims_msg = Control(
+ type=Control.CONTROL_DIMS,
+ request_idx=next_request_idx(),
+ dims_and_steps=[
+ DimAndStep(dim=dim, step=step) for dim, step in dims_map.items()
+ ],
+ )
+ await websocket.send(dims_msg.SerializeToString())
+
+ next_batch_msg = Control(
+ type=Control.CONTROL_NEXT_BATCH,
+ request_idx=next_request_idx(),
+ )
+ await websocket.send(next_batch_msg.SerializeToString())
+
+ expected_train = max(1, len(dims_sequence) + 2)
+ train_losses = []
+ val_losses = []
+ deadline = asyncio.get_running_loop().time() + 20
+ while asyncio.get_running_loop().time() < deadline:
+ try:
+ data = await asyncio.wait_for(websocket.recv(), timeout=5)
+ except asyncio.TimeoutError:
+ continue
+ msg = Control()
+ msg.ParseFromString(data)
+ if msg.type == Control.CONTROL_TRAIN_LOSS_AND_PREDS:
+ train_losses.append(msg.train_loss_and_preds.train_loss)
+ if len(train_losses) >= expected_train and val_losses:
+ break
+ elif msg.type == Control.CONTROL_VAL_LOSS:
+ val_losses.append(msg.val_loss.val_loss)
+ if len(train_losses) >= expected_train and val_losses:
+ break
+ if not val_losses:
+ raise AssertionError("No validation loss received during scripted session")
+ return {
+ "train_losses": train_losses[:expected_train],
+ "val_loss": val_losses[-1],
+ }
+
+
+@pytest.mark.timeout(60)
+@pytest.mark.asyncio
+async def test_repeatable_scripted_session(run_server_thread):
+ dims_sequence = [
+ {0: 0.15},
+ {1: -0.05, 3: 0.025},
+ {2: 0.01},
+ ]
+ session_one = await _run_scripted_session(8767, dims_sequence)
+ session_two = await _run_scripted_session(8767, dims_sequence)
+
+ assert len(session_one["train_losses"]) == len(session_two["train_losses"])
+ for a, b in zip(session_one["train_losses"], session_two["train_losses"]):
+ assert a == pytest.approx(b, rel=0, abs=1e-9)
+ assert session_one["val_loss"] == pytest.approx(
+ session_two["val_loss"], rel=0, abs=1e-9
+ )
+
+
+@pytest.mark.timeout(45)
+@pytest.mark.asyncio
+async def test_resume_rejected_wrong_session_token(run_server_thread):
+ uri = "ws://localhost:8767"
+ websocket, token, session_token = await _open_configured_client(uri)
+ await websocket.close()
+
+ async with websockets.connect(uri) as resumed_ws:
+ resume = hudes_pb2.Control(
+ type=hudes_pb2.Control.CONTROL_RESUME,
+ resume=hudes_pb2.Control.Resume(
+ token=token,
+ last_request_idx=0,
+ client_session_token=f"{session_token}-wrong",
+ new_client_session_token=f"{session_token}-new",
+ ),
+ )
+ await resumed_ws.send(resume.SerializeToString())
+ data = await asyncio.wait_for(resumed_ws.recv(), timeout=5)
+ msg = hudes_pb2.Control()
+ msg.ParseFromString(data)
+ assert msg.type == hudes_pb2.Control.CONTROL_RESUME
+ assert msg.resume.status == hudes_pb2.Control.Resume.RESUME_REJECTED
diff --git a/web/app.js b/web/app.js
index 87d5f2d..5d2c17c 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1,10 +1,6 @@
-// import HudesClient from './client/HudesClient.js';
-
-// const client = new HudesClient("localhost", 8765);
-// client.runLoop();
-
-//import KeyboardClient from './client/KeyboardClient.js';
+import KeyboardClient from './client/KeyboardClient.js';
import KeyboardClientGL from './client/KeyboardClientGL.js';
+import { detectMobileMode, setMobileFlag } from './mobile.js';
async function detectBackendHostPort() {
const params = new URLSearchParams(window.location.search);
@@ -51,11 +47,134 @@ async function detectBackendHostPort() {
return { host, port: 8765 };
}
+function installModeToggle(currentMode) {
+ if (typeof document === 'undefined') return;
+ const header = document.querySelector('.site-header');
+ if (!header) return;
+ let container = header.querySelector('.header-controls');
+ if (!container) {
+ container = document.createElement('div');
+ container.className = 'header-controls';
+ header.appendChild(container);
+ }
+ container.innerHTML = '';
+
+ const makeToggleButton = (label, onClick, extraClass = '') => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = `header-pill ${extraClass}`.trim();
+ btn.textContent = label;
+ btn.addEventListener('click', onClick);
+ return btn;
+ };
+
+ const isMobile = typeof window !== 'undefined' && window.__hudesIsMobile;
+ if (!isMobile) {
+ const targetMode = currentMode === '1d' ? '3d' : '1d';
+ const viewButton = makeToggleButton(currentMode === '1d' ? 'Switch to 3D view' : 'Switch to 1D view', () => {
+ const params = new URLSearchParams(window.location.search);
+ params.set('mode', targetMode);
+ window.location.search = params.toString();
+ });
+ container.appendChild(viewButton);
+ }
+
+ if (currentMode === '1d' && !isMobile) {
+ const params = new URLSearchParams(window.location.search);
+ const toggleFlag = (key) => {
+ const current = params.get(key);
+ const enabled = typeof current === 'string' && /^(1|true|yes|on)$/i.test(current);
+ if (enabled) {
+ params.delete(key);
+ } else {
+ params.set(key, '1');
+ }
+ window.location.search = params.toString();
+ };
+
+ const makeToggle = (label, key) =>
+ makeToggleButton(`${label}: ${params.has(key) ? 'ON' : 'OFF'}`, () => toggleFlag(key), 'mode-toggle');
+
+ container.appendChild(makeToggle('Alt Keys', 'altkeys'));
+ container.appendChild(makeToggle('Alt 1D', 'alt1d'));
+ }
+
+ let status = header.querySelector('.connection-status');
+ if (!status) {
+ status = document.createElement('span');
+ status.className = 'connection-status';
+ header.appendChild(status);
+ }
+ status.dataset.state = 'connecting';
+ status.textContent = 'Connecting…';
+
+ return status;
+}
+
(async function bootstrap() {
const { host, port } = await detectBackendHostPort();
- const client = new KeyboardClientGL(host, port);
+ const params = new URLSearchParams(window.location.search);
+ const isMobile = detectMobileMode(params);
+ setMobileFlag(isMobile);
+ const modeParam = (params.get('mode') || '').toLowerCase();
+ const renderMode = modeParam === '1d' && !isMobile ? '1d' : '3d';
+ const debugParam = params.get('debug');
+ const debugEnabled = typeof debugParam === 'string' && /^(1|true|yes|on)$/i.test(debugParam);
+ const altKeysEnabled = (() => {
+ const flag = params.get('altkeys');
+ return typeof flag === 'string' && /^(1|true|yes|on)$/i.test(flag);
+ })();
+ const alt1dEnabled = (() => {
+ const flag = params.get('alt1d');
+ return typeof flag === 'string' && /^(1|true|yes|on)$/i.test(flag);
+ })();
+ const gridSizeParam = Number(params.get('gridSize'));
+ const gridSize = Number.isFinite(gridSizeParam) && gridSizeParam > 4 ? Math.floor(gridSizeParam) : undefined;
+ const rowSpacingParam = params.get('rowSpacing');
+ const rowSpacingParsed = rowSpacingParam == null ? undefined : Number(rowSpacingParam);
+ const rowSpacingRaw =
+ rowSpacingParsed == null || !Number.isFinite(rowSpacingParsed) ? undefined : rowSpacingParsed;
+ const depthStepParam = params.get('depthStep');
+ const depthStepParsed = depthStepParam == null ? undefined : Number(depthStepParam);
+ const depthStep =
+ depthStepParsed == null || !Number.isFinite(depthStepParsed) ? undefined : depthStepParsed;
+ const distanceParam = params.get('cameraDistance');
+ const distanceParsed = distanceParam == null ? undefined : Number(distanceParam);
+ const cameraDistanceRaw =
+ distanceParsed == null || !Number.isFinite(distanceParsed) || distanceParsed <= 0
+ ? undefined
+ : distanceParsed;
+ const rowSpacing = renderMode === '1d' ? (rowSpacingRaw ?? 1) : rowSpacingRaw;
+ const cameraDistance = renderMode === '1d' ? (cameraDistanceRaw ?? 50) : cameraDistanceRaw;
+ const client =
+ renderMode === '1d'
+ ? new KeyboardClient(host, port, {
+ renderMode: '1d',
+ lossLines: 6,
+ debug: debugEnabled,
+ gridSize,
+ rowSpacing,
+ depthStep,
+ cameraDistance,
+ altKeys: altKeysEnabled,
+ alt1d: alt1dEnabled,
+ isMobile,
+ })
+ : new KeyboardClientGL(host, port, {
+ renderMode: '3d',
+ debug: debugEnabled,
+ gridSize,
+ rowSpacing,
+ depthStep,
+ cameraDistance,
+ isMobile,
+ });
if (typeof window !== 'undefined') {
window.__hudesClient = client;
+ if (debugEnabled) {
+ window.__hudesDebug = true;
+ }
}
+ installModeToggle(renderMode);
client.runLoop();
})();
diff --git a/web/bundle-visualization.html b/web/bundle-visualization.html
deleted file mode 100644
index 66c7e5f..0000000
--- a/web/bundle-visualization.html
+++ /dev/null
@@ -1,4841 +0,0 @@
-
-
-
-
-
-
-
- Rollup Visualizer
-
-
-
-
-
-
-
-
diff --git a/web/client/ClientState.js b/web/client/ClientState.js
index 87eaef1..04d7f1e 100644
--- a/web/client/ClientState.js
+++ b/web/client/ClientState.js
@@ -2,6 +2,7 @@ export default class ClientState {
constructor(stepSizeResolution, initialStepSizeIdx) {
this.bestScore = Infinity;
this.sgdSteps = 0;
+ this.evalSteps = 0;
this.speedRunActive = false;
this.speedRunSecondsRemaining = 0;
this.dtypes = ["float16", "float32"];
@@ -12,21 +13,22 @@ export default class ClientState {
this.stepSizeResolution = stepSizeResolution;
this.stepSizeIdx = initialStepSizeIdx;
this.helpScreenIdx = 0;
+ this.dimsUsed = 0;
this.updateStepSize();
}
updateBestScoreOrNot(newScore) {
- if (newScore= this.helpScreenFns.length
+ ) {
+ this.helpScreenIdx = 0;
+ }
+ } else {
+ this.helpScreenFns = [];
+ this.helpScreenIdx = -1;
+ }
}
closeHelpScreens() {
@@ -86,9 +104,10 @@ export default class ClientState {
}
nextHelpScreen() {
- this.helpScreenIdx +=1;
- if (this.helpScreenIdx ==this.helpScreenFns.length) {
- this.helpScreenIdx=-1;
+ if (this.helpScreenIdx === -1) return;
+ this.helpScreenIdx += 1;
+ if (this.helpScreenIdx >= this.helpScreenFns.length) {
+ this.helpScreenIdx = -1;
}
}
}
diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js
index d13afaf..6add725 100644
--- a/web/client/HudesClient.js
+++ b/web/client/HudesClient.js
@@ -3,9 +3,10 @@ import { loadProto } from './ProtoLoader.js';
import { sleep } from '../utils/sleep.js';
import { log } from '../utils/logger.js';
import View from './View.js';
+import { SHARE_TEXT } from './helpTour.js';
export default class HudesClient {
- constructor(addr, port) {
+ constructor(addr, port, options = {}) {
// Allow ws/wss based on environment and accept absolute URL or host/port
try {
// Allow overriding via URL: ?host=...&port=... (useful in tests)
@@ -13,51 +14,104 @@ export default class HudesClient {
const params = new URLSearchParams(window.location.search);
const qpHost = params.get('host');
const qpPort = params.get('port');
+ const qpMode = params.get('mode');
+ const qpHideHud = params.get('hideHUD');
if (qpHost) addr = qpHost;
if (qpPort) port = Number(qpPort);
+ if (qpMode) options.renderMode = qpMode.toLowerCase();
+ if (qpHideHud) options.hideHud = /^(1|true|yes|on)$/i.test(qpHideHud);
}
- } catch {}
-
- // Remember backend for API calls
- this.backendHost = addr;
- this.backendPort = port;
- const isHttps = (typeof window !== 'undefined' && window.location?.protocol === 'https:');
- const scheme = isHttps ? 'wss' : 'ws';
- const url = addr?.startsWith('ws') ? addr : `${scheme}://${this.backendHost}:${this.backendPort}`;
- log(`[HudesClient] Connecting WebSocket to ${url}`);
- this.socket = new WebSocket(url);
- this.socket.binaryType = "arraybuffer";
+ } catch { }
+
+ // Remember backend for API calls
+ this.backendHost = addr;
+ this.backendPort = port;
+ const isHttps = (typeof window !== 'undefined' && window.location?.protocol === 'https:');
+ this.wsScheme = isHttps ? 'wss' : 'ws';
+ this.socketUrl = addr?.startsWith('ws') ? addr : `${this.wsScheme}://${this.backendHost}:${this.backendPort}`;
+ this.socket = null;
this.requestIdx = 0;
this.running = true;
+ this._connected = false;
+ this._shouldReconnect = true;
+ this._reconnectAttempts = 0;
+ this._sessionStorageAvailable = typeof window !== 'undefined' && !!window.sessionStorage;
+ this._sessionTokenKey = 'hudes.tab.sessionToken';
+ this._resumeTokenKey = 'hudes.resume.token';
+ this._clientSessionToken = this._loadSessionToken();
+ this._pendingSessionToken = null;
+ this._resumeToken = this._loadPersistedResumeToken();
+ if (this._didReload()) {
+ this._clearResumeToken();
+ }
+ this._resumeInFlight = false;
+ this._readyForInput = false;
+ this._serverAckRequestIdx = 0;
+ this._protoReadyPromise = this.loadProto();
+
+ const renderMode = (options.renderMode || '3d').toLowerCase();
+ this.isMobile = Boolean(options.isMobile);
+ this.debug = Boolean(options.debug);
+ this.renderMode = renderMode === '1d' ? '1d' : '3d';
+ this.hideHud = Boolean(options.hideHud);
+ this.meshEnabled = this.renderMode !== '1d';
+ this.lossLines = this.renderMode === '1d' ? (options.lossLines ?? 6) : 0;
+ this.alt1d = this.renderMode === '1d' && Boolean(options.alt1d);
+ this.altKeys = Boolean(options.altKeys);
this.state = new ClientState(-0.05, 6);
// Optional: allow skipping help screens via URL query, e.g., ?help=off
+ let disableInitialTour = false;
try {
if (typeof window !== 'undefined' && window.location && window.location.search) {
const params = new URLSearchParams(window.location.search);
const help = params.get('help');
if (help && /^(off|0|false|no)$/i.test(help)) {
this.state.helpScreenIdx = -1;
+ disableInitialTour = true;
}
}
- } catch {}
- this.grids=3;
- this.grid_size=31;
- this.view = new View(this.grid_size,this.grids, this.state); // Add a View instance
- this.view.initializeCharts(); // Initialize charts
+ } catch { }
+ disableInitialTour = disableInitialTour || this.state.helpScreenIdx === -1;
+ this.grids = this.meshEnabled ? (options.meshGrids ?? 3) : 0;
+ this.grid_size = options.gridSize ?? 31;
+ this.rowSpacing = options.rowSpacing;
+ this.depthStep = options.depthStep;
+ this.cameraDistance = options.cameraDistance;
+ this.dimsOffset = 0;
+ this.view = new View(this.grid_size, this.grids, this.state, {
+ mode: this.renderMode,
+ lossLines: this.lossLines,
+ debug: this.debug,
+ rowSpacing: this.rowSpacing,
+ depthStep: this.depthStep,
+ cameraDistance: this.cameraDistance,
+ alt1d: this.alt1d,
+ altKeys: this.altKeys,
+ mobile: this.isMobile,
+ hideHud: this.hideHud,
+ disableInitialTour,
+ });
+ if (this.hideHud && typeof document !== 'undefined') {
+ document.body.classList.add('hudes-hide-hud');
+ }
+ this.view.initializeCharts(); // Initialize charts
this.Control = null;
this.ControlType = null;
+ this.lossLineLabels = [];
this.keyHolds = {};
- this.cooldownTimeMs = 200;
+ this.cooldownTimeMs = 10;
this.keySecondsPressed = 200; // ms
this.stepSizeMultiplier = 1;
+ this._textCaptureActive = false;
- // Speed run state (frontend)
- this.speedRunActive = false;
- this.speedRunSecondsRemaining = 0;
- this._srsInterval = null; // local countdown ticker (started on first server reply)
- this._srsEndAt = null; // timestamp (ms) when local countdown should end
- this.highScoreSubmitted = false;
+ // Speed run state (frontend)
+ this.speedRunActive = false;
+ this.speedRunSecondsRemaining = 0;
+ this._srsInterval = null; // local countdown ticker (started on first server reply)
+ this._srsEndAt = null; // timestamp (ms) when local countdown should end
+ this.highScoreSubmitted = false;
+ this._speedRunRequestPending = false;
// Initialize loss and step tracking arrays
@@ -66,15 +120,124 @@ export default class HudesClient {
this.trainSteps = [];
this.valSteps = [];
+ this._handleHudAction = this._handleHudAction.bind(this);
+ this._speedRunCountdownOverlay = null;
+ this._speedRunCountdownText = null;
this.initKeys();
- this.setupSocket();
- this.loadProto();
+ this._bindHudActions();
+ this.levels = [];
+ this.bestSessionLoss = Infinity;
+ this._loadLevels();
+ this._connectSocket();
+ }
+
+ async _loadLevels() {
+ try {
+ const res = await fetch('./levels.json');
+ if (res.ok) {
+ this.levels = await res.json();
+ // Sort descending by loss (e.g. 2.3, 2.1, ... 1.0)
+ this.levels.sort((a, b) => b.loss - a.loss);
+ // Assign level numbers: Level 1 is best (lowest loss), Level N is worst
+ // Since we are sorted worst (high loss) to best (low loss),
+ // index 0 is Level N, index length-1 is Level 1.
+ this.levels.forEach((level, index) => {
+ level.levelNumber = this.levels.length - index;
+ });
+ console.log('[HudesClient] Levels loaded:', this.levels.length);
+ }
+ } catch (e) {
+ log('[HudesClient] Failed to load levels', e);
+ }
+ }
+
+ _bindHudActions() {
+ if (typeof document === 'undefined') return;
+ document.addEventListener('click', this._handleHudAction);
+ }
+
+ _handleHudAction(event) {
+ const target = event.target?.closest?.('[data-hud-action]');
+ if (!target) return;
+ const action = target.dataset.hudAction;
+ const refreshHud = () => {
+ try {
+ this.view?.annotateBottomScreen?.(this.state.toString());
+ } catch { }
+ };
+ switch (action) {
+ case 'next-dims':
+ event.preventDefault();
+ this.getNextDims();
+ this._notifyTutorialStep('new_dims');
+ break;
+ case 'next-batch':
+ event.preventDefault();
+ this.getNextBatch();
+ break;
+ case 'step-plus':
+ event.preventDefault();
+ if (typeof this.state?.increaseStepSize === 'function') {
+ this.state.increaseStepSize(this.stepSizeMultiplier || 1);
+ try { this.sendConfig(); } catch { }
+ refreshHud();
+ }
+ this._notifyTutorialStep('step');
+ break;
+ case 'step-minus':
+ event.preventDefault();
+ if (typeof this.state?.decreaseStepSize === 'function') {
+ this.state.decreaseStepSize(this.stepSizeMultiplier || 1);
+ try { this.sendConfig(); } catch { }
+ refreshHud();
+ }
+ this._notifyTutorialStep('step');
+ break;
+ case 'toggle-fp':
+ event.preventDefault();
+ if (typeof this.state?.toggleDtype === 'function') {
+ this.state.toggleDtype();
+ try { this.sendConfig(); } catch { }
+ refreshHud();
+ }
+ this._notifyTutorialStep('precision');
+ break;
+ case 'show-top':
+ event.preventDefault();
+ try { this.requestLeaderboard?.(); } catch { }
+ break;
+ case 'toggle-help':
+ event.preventDefault();
+ this.view?.openHelpOverlay?.('controls');
+ break;
+ case 'show-tutorial':
+ event.preventDefault();
+ this.view?.openTutorialOverlay?.();
+ break;
+ default:
+ }
}
async loadProto() {
- const { Control, ControlType } = await loadProto('hudes.proto');
+ const { Control, ControlType, root } = await loadProto('hudes.proto');
this.Control = Control;
this.ControlType = ControlType;
+ try {
+ const resumeEnum = root.lookupEnum('hudes.Control.Resume.Status');
+ this.resumeStatus = resumeEnum?.values ?? {
+ RESUME_OK: 0,
+ RESUME_NOT_FOUND: 1,
+ RESUME_EXPIRED: 2,
+ RESUME_REJECTED: 3,
+ };
+ } catch {
+ this.resumeStatus = {
+ RESUME_OK: 0,
+ RESUME_NOT_FOUND: 1,
+ RESUME_EXPIRED: 2,
+ RESUME_REJECTED: 3,
+ };
+ }
log("[HudesClient] Proto file and enums loaded successfully.");
}
_handleMessage(event) {
@@ -90,7 +253,16 @@ export default class HudesClient {
) || `type_${message.type}`;
window.__hudesMsgCounts[t] = (window.__hudesMsgCounts[t] || 0) + 1;
}
- } catch {}
+ } catch { }
+ if (typeof message.resumeToken === 'string' && message.resumeToken.length > 0) {
+ this._persistResumeToken(message.resumeToken);
+ }
+ if (typeof message.requestIdx === 'number') {
+ this._serverAckRequestIdx = Math.max(
+ this._serverAckRequestIdx,
+ message.requestIdx,
+ );
+ }
//log("Decoded Message:", message);
@@ -136,7 +308,7 @@ export default class HudesClient {
this.trainSteps
);
- this.view.updateLastStepsChart(this.trainSteps.slice(-9),this.trainLosses.slice(-9));
+ this.view.updateLastStepsChart(this.trainSteps.slice(-9), this.trainLosses.slice(-9));
// Update the view with predictions and confusion matrix
this.view.updateConfusionMatrix(confusionMatrix);
@@ -144,6 +316,10 @@ export default class HudesClient {
//log("hudes_client: receive message: loss and preds: done");
+ this._updateEvalStepsFromMessage(message);
+ break;
+ case this.ControlType.values.CONTROL_RESUME:
+ this._handleResumeControl(message);
break;
case this.ControlType.values.CONTROL_VAL_LOSS:
@@ -156,7 +332,7 @@ export default class HudesClient {
log(`[HudesClient] recv VAL_LOSS reqIdx=${message.requestIdx} val=${message.valLoss.valLoss}`);
// Update validation loss
- while (this.valLosses.length 0; i--) {
- result = chunkArray(result, shape[i]);
+ // Check for level up
+ const currentValLoss = this.valLosses[this.valLosses.length - 1];
+ if (this.levels && this.levels.length > 0) {
+ // Find the best level we have achieved (loss <= level.loss)
+ // Since levels are sorted descending (2.3, 2.1, ...), we want the LAST one that matches (lowest loss)
+ // Or we can just find the one with the lowest loss that is >= currentValLoss?
+ // No, we want the level corresponding to the milestone we passed.
+ // If we are at 1.6, we passed 1.7. We want to show 1.7.
+ // If we are at 1.4, we passed 1.7 and 1.5. We want to show 1.5 (the best one).
+
+ // So we want the level with the smallest loss that is still >= currentValLoss.
+ // Wait, if level is 1.5, and we are at 1.4. 1.5 >= 1.4.
+ // If level is 1.0, and we are at 1.4. 1.0 < 1.4.
+ // So we want the level with smallest loss such that level.loss >= currentValLoss.
+ // Actually, usually "Level 1.5" means you beat 1.5. So loss <= 1.5.
+ // So if we are at 1.4, we achieved the 1.5 level.
+ // If we are at 0.9, we achieved the 1.0 level.
+
+ // We want to find the best level we've beaten (currentValLoss <= level.loss)
+ // that is BETTER than the last one we notified.
+ let bestNewLevel = null;
+ for (const level of this.levels) {
+ if (level.hidden) continue; // Skip hidden levels for in-game notifications
+ if (currentValLoss <= level.loss) {
+ // Since levels are sorted worst to best (descending loss),
+ // later levels in the array are "better" (lower loss).
+ // We want the *best* one we qualify for.
+ if (!bestNewLevel || level.loss < bestNewLevel.loss) {
+ bestNewLevel = level;
+ }
}
- return result;
}
- // Helper function to chunk an array
- function chunkArray(array, size) {
- const chunkedArray = [];
- for (let i = 0; i < array.length; i += size) {
- chunkedArray.push(array.slice(i, i + size));
+ if (bestNewLevel) {
+ // Only trigger if this is a new best for this session
+ // OR if we want to show it because we haven't shown THIS level yet?
+ // Requirement: "drops below these leves for the first time in a session"
+ // "If the modal is not dismissed and a new level is achieved the new level should take precedence"
+
+ // So we track bestSessionLoss.
+ // If currentValLoss < bestSessionLoss, we might have hit a new level.
+ // But wait, if I was at 1.4 (Level 1.5 shown), and I go to 1.3 (Still Level 1.5).
+ // I shouldn't show it again.
+ // I should only show if the *achieved level* is better (lower loss) than the previously achieved level?
+ // Or simply: if we crossed a threshold.
+
+ // Let's track `this.lastNotifiedLevelLoss`.
+ // If bestAchieved.loss < (this.lastNotifiedLevelLoss ?? Infinity), then show it.
+
+ if (bestNewLevel.loss < (this.lastNotifiedLevelLoss ?? Infinity)) {
+ console.log('[HudesClient] Level Up! New:', bestNewLevel.loss, 'Old:', this.lastNotifiedLevelLoss);
+ this.lastNotifiedLevelLoss = bestNewLevel.loss;
+ this.view.showLevelUp(bestNewLevel, this.speedRunActive);
+ } else {
+ console.log('[HudesClient] Level not better. New:', bestNewLevel.loss, 'Old:', this.lastNotifiedLevelLoss);
}
- return chunkedArray;
+ } else {
+ console.log('[HudesClient] No bestNewLevel found for loss:', currentValLoss);
}
+ }
- // Reshape train data and labels
- const trainData = reshape(trainDataFlattened, trainDataShape);
- const trainLabels = reshape(trainLabelsFlattened, trainLabelsShape);
+ this._updateEvalStepsFromMessage(message);
- // Update the view with the new train data and labels
- this.view.updateExamples(trainData, trainLabels);
- log(`[HudesClient] recv BATCH_EXAMPLES idx=${message.batchExamples.batchIdx}`);
- } else {
- //log("Batch example missing in message.");
+ // Update the view with training and validation loss history
+ this.view.updateLossChart(
+ this.trainLosses,
+ this.valLosses,
+ this.trainSteps
+ );
+
+ //og("hudes_client: receive message: val loss: done");
+ break;
+ case this.ControlType.values.CONTROL_BATCH_EXAMPLES:
+ if (message.batchExamples) {
+ // log(
+ // `Batch received. Index: ${message.batchExamples.batchIdx}, N: ${message.batchExamples.n}, Train Data Length: ${message.batchExamples.trainData.length}`
+ //);
+
+ // Extract train data and labels from the message
+ const trainDataFlattened = message.batchExamples.trainData;
+ const trainDataShape = message.batchExamples.trainDataShape;
+
+ const trainLabelsFlattened = message.batchExamples.trainLabels;
+ const trainLabelsShape = message.batchExamples.trainLabelsShape;
+
+ // Function to reshape a flat array into the given shape
+ function reshape(flatArray, shape) {
+ let result = flatArray;
+ for (let i = shape.length - 1; i > 0; i--) {
+ result = chunkArray(result, shape[i]);
}
- break;
+ return result;
+ }
+
+ // Helper function to chunk an array
+ function chunkArray(array, size) {
+ const chunkedArray = [];
+ for (let i = 0; i < array.length; i += size) {
+ chunkedArray.push(array.slice(i, i + size));
+ }
+ return chunkedArray;
+ }
+
+ // Reshape train data and labels
+ const trainData = reshape(trainDataFlattened, trainDataShape);
+ const trainLabels = reshape(trainLabelsFlattened, trainLabelsShape);
+
+ // Update the view with the new train data and labels
+ this.view.updateExamples(trainData, trainLabels);
+ log(`[HudesClient] recv BATCH_EXAMPLES idx=${message.batchExamples.batchIdx}`);
+ } else {
+ //log("Batch example missing in message.");
+ }
+ break;
case this.ControlType.values.CONTROL_MESHGRID_RESULTS:
- if (!message.meshGridResults || !message.meshGridShape) {
- throw new Error("meshGridResults or meshGridShape field missing");
- }
+ if (!message.meshGridResults || !message.meshGridShape) {
+ throw new Error("meshGridResults or meshGridShape field missing");
+ }
- //log("hudes_client: received message: mesh grid results");
+ //log("hudes_client: received message: mesh grid results");
- // Parse the mesh grid results and reshape to the original dimensions
- const meshGridResultsFlattened = message.meshGridResults;
- const meshGridShape = message.meshGridShape;
+ // Parse the mesh grid results and reshape to the original dimensions
+ const meshGridResultsFlattened = message.meshGridResults;
+ const meshGridShape = message.meshGridShape;
- // Reshape the flattened results into a 3D array using meshGridShape
- const meshGridResults = this._reshapeArray(
- meshGridResultsFlattened,
- meshGridShape
- );
+ // Reshape the flattened results into a 3D array using meshGridShape
+ const meshGridResults = this._reshapeArray(
+ meshGridResultsFlattened,
+ meshGridShape
+ );
- //log(`Mesh grid results received:`, meshGridResults);
+ //log(`Mesh grid results received:`, meshGridResults);
- // Update the view with the reshaped mesh grid results
- this.view.updateMeshGrids(meshGridResults);
+ // Update the view with the reshaped mesh grid results
+ this.view.updateMeshGrids(meshGridResults);
- // Update local countdown but do not end until server signals finish
- this._updateSpeedRunFromMessage(message);
- break;
+ // Update local countdown but do not end until server signals finish
+ this._updateSpeedRunFromMessage(message);
+ this._updateEvalStepsFromMessage(message);
+ break;
+
+ case this.ControlType.values.CONTROL_LOSS_LINE_RESULTS: {
+ if (!message.lossLineResults || !message.lossLineShape) {
+ throw new Error("lossLineResults or lossLineShape field missing");
+ }
+ const lossLines = this._reshapeArray(
+ message.lossLineResults,
+ message.lossLineShape
+ );
+ const labels = this._buildLossLineLabels(lossLines.length);
+ const stepSpacing = (this.state?.stepSize ?? 1) / 2;
+ this.view.updateLossLines?.(lossLines, { stepSpacing, labels });
+ this.view.annotateBottomScreen?.(this.state.toString());
+ this._updateSpeedRunFromMessage(message);
+ this._updateEvalStepsFromMessage(message);
+ break;
+ }
// No CONTROL_FULL_LOSS handling (removed)
case this.ControlType.values.CONTROL_SGD_STEP:
- log(`[HudesClient] recv SGD_STEP ack steps=${message.sgdSteps}`);
- break;
+ log(`[HudesClient] recv SGD_STEP ack steps=${message.sgdSteps}`);
+ break;
case this.ControlType.values.CONTROL_LEADERBOARD_RESPONSE:
- try {
- const names = message.leaderboardNames || [];
- const scores = message.leaderboardScores || [];
- const rows = names.map((n, i) => ({ name: n, score: scores[i] }))
- .filter(r => r && typeof r.name === 'string');
- this._showTop100Modal(rows);
- } catch (e) {
- log('[HudesClient] Failed to render leaderboard modal', e);
- }
- break;
+ try {
+ const names = message.leaderboardNames || [];
+ const scores = message.leaderboardScores || [];
+ const rows = names.map((n, i) => ({ name: n, score: scores[i] }))
+ .filter(r => r && typeof r.name === 'string');
+ this._showTop100Modal(rows);
+ } catch (e) {
+ log('[HudesClient] Failed to render leaderboard modal', e);
+ }
+ break;
default:
- log(`[HudesClient] Unknown message type: ${message.type}`);
+ log(`[HudesClient] Unknown message type: ${message.type}`);
}
} catch (error) {
- log(`[HudesClient] Failed to handle message: ${error}`);
- log(error);
- }
+ log(`[HudesClient] Failed to handle message: ${error}`);
+ log(error);
+}
}
- _updateSpeedRunFromMessage(message) {
- try {
- const hasFinished = Object.prototype.hasOwnProperty.call(
- message,
- 'speedRunFinished',
- );
- if (hasFinished && message.speedRunFinished) {
- // Authoritative end-of-run signal from server
- if (this._srsInterval) { try { clearInterval(this._srsInterval); } catch {} this._srsInterval = null; }
- this._srsEndAt = null;
- this.speedRunSecondsRemaining = 0;
- this.state.speedRunSecondsRemaining = 0;
+_updateSpeedRunFromMessage(message) {
+ try {
+ const hasFinished = Object.prototype.hasOwnProperty.call(
+ message,
+ 'speedRunFinished',
+ );
+ const serverActiveFlag = Object.prototype.hasOwnProperty.call(
+ message,
+ 'speedRunActive',
+ )
+ ? Boolean(message.speedRunActive)
+ : null;
+ log(
+ `[HudesClient] speed-run update: finishFlag=${hasFinished ? message.speedRunFinished : 'n/a'} ` +
+ `srs=${Object.prototype.hasOwnProperty.call(message, 'speedRunSecondsRemaining') ? message.speedRunSecondsRemaining : 'n/a'} ` +
+ `reqIdx=${message.requestIdx ?? 'n/a'}`,
+ );
+ if (serverActiveFlag !== null) {
+ if (serverActiveFlag && !this.speedRunActive) {
+ this.speedRunActive = true;
+ this.state.speedRunActive = true;
+ document.body.classList.add('speedrun-active');
+ } else if (!serverActiveFlag && this.speedRunActive && !(hasFinished && message.speedRunFinished)) {
this.speedRunActive = false;
this.state.speedRunActive = false;
- // Capture achieved final val loss if present
- if (message.valLoss && typeof message.valLoss.valLoss === 'number') {
- this._lastFinalValLoss = message.valLoss.valLoss;
- }
- if (!this.highScoreSubmitted) {
- this._promptAndSubmitHighScore(this._lastFinalValLoss);
- }
- return;
+ this.speedRunSecondsRemaining = 0;
+ this.state.speedRunSecondsRemaining = 0;
+ document.body.classList.remove('speedrun-active');
}
+ }
+ if (hasFinished && message.speedRunFinished) {
+ // Authoritative end-of-run signal from server
+ if (this._srsInterval) { try { clearInterval(this._srsInterval); } catch { } this._srsInterval = null; }
+ this._srsEndAt = null;
+ this.speedRunSecondsRemaining = 0;
+ this.state.speedRunSecondsRemaining = 0;
+ this.speedRunActive = false;
+ this.state.speedRunActive = false;
+ this._speedRunRequestPending = false;
+ // Capture achieved final val loss if present
+ if (message.valLoss && typeof message.valLoss.valLoss === 'number') {
+ this._lastFinalValLoss = message.valLoss.valLoss;
+ this._lastRank = message.valLoss.rank;
+ this._lastTotalScores = message.valLoss.totalScores;
+ log(`[HudesClient] final val loss recorded: ${this._lastFinalValLoss}`);
+ }
+ if (!this.highScoreSubmitted) {
+ log('[HudesClient] prompting for high score submission');
+ this._promptAndSubmitHighScore(this._lastFinalValLoss, this._lastRank, this._lastTotalScores);
+ }
+ document.body.classList.remove('speedrun-active');
+ return;
+ }
- const hasSrs = Object.prototype.hasOwnProperty.call(
- message,
- 'speedRunSecondsRemaining',
- );
- if (!hasSrs) return;
- const srs = message.speedRunSecondsRemaining ?? 0;
+ const hasSrs = Object.prototype.hasOwnProperty.call(
+ message,
+ 'speedRunSecondsRemaining',
+ );
+ if (!hasSrs) return;
+ const srs = message.speedRunSecondsRemaining ?? 0;
+
+ // Start local timer on first srs > 0 after Speed Run starts
+ if (srs > 0) {
+ this._speedRunRequestPending = false;
+ this.speedRunActive = true;
+ this.state.speedRunActive = true;
+ if (!this._srsInterval) {
+ log(`[HudesClient] starting local speed-run countdown at ${srs}s`);
+ this._srsEndAt = Date.now() + srs * 1000;
+ this.speedRunSecondsRemaining = srs;
+ this.state.speedRunSecondsRemaining = srs;
+ let lastShown = srs;
+ this._srsInterval = setInterval(() => {
+ const now = Date.now();
+ const remainMs = Math.max(0, (this._srsEndAt ?? now) - now);
+ const remain = Math.max(0, Math.floor(remainMs / 1000));
+ const remainFrac = Math.max(0, Math.round(remainMs / 100) / 10); // 0.1s resolution
+ if (remain !== lastShown) {
+ lastShown = remain;
+ this.speedRunSecondsRemaining = remainFrac;
+ this.state.speedRunSecondsRemaining = remainFrac;
+ try { this.view?.annotateBottomScreen?.(this.state.toString()); } catch { }
+ log(`[HudesClient] speed-run countdown tick: ${remainFrac.toFixed?.(1) ?? remainFrac}s remaining`);
+ }
+ if (remain === 0) {
+ // Stop ticking locally, but keep run active until
+ // server sends VAL with speed_run_finished=true
+ try { clearInterval(this._srsInterval); } catch { }
+ this._srsInterval = null;
+ this._srsEndAt = null;
+ this.speedRunSecondsRemaining = 0;
+ this.state.speedRunSecondsRemaining = 0;
+ try { this.view?.annotateBottomScreen?.(this.state.toString()); } catch { }
+ log('[HudesClient] local countdown reached zero waiting for server finish');
+ }
+ }, 300);
+ }
+ } else {
+ // srs==0: if we haven't started local timer yet, reflect zero
+ if (!this._srsInterval) {
+ this.speedRunSecondsRemaining = 0;
+ this.state.speedRunSecondsRemaining = 0;
+ // Do not flip active here; end only on server finish
+ try { this.view?.annotateBottomScreen?.(this.state.toString()); } catch { }
+ }
+ }
+ } catch { }
+}
+_promptAndSubmitHighScore(finalValLoss, rank, totalScores) {
+ log('[HudesClient] _promptAndSubmitHighScore invoked');
+ if (this.highScoreSubmitted) return;
+ try {
+ this._showSpeedRunResults({ finalValLoss, rank, totalScores });
+ } catch { }
+}
- // Start local timer on first srs > 0 after Speed Run starts
- if (srs > 0) {
- this.speedRunActive = true;
- this.state.speedRunActive = true;
- if (!this._srsInterval) {
- this._srsEndAt = Date.now() + srs * 1000;
- this.speedRunSecondsRemaining = srs;
- this.state.speedRunSecondsRemaining = srs;
- let lastShown = srs;
- this._srsInterval = setInterval(() => {
- const now = Date.now();
- const remainMs = Math.max(0, (this._srsEndAt ?? now) - now);
- const remain = Math.max(0, Math.floor(remainMs / 1000));
- const remainFrac = Math.max(0, Math.round(remainMs / 100) / 10); // 0.1s resolution
- if (remain !== lastShown) {
- lastShown = remain;
- this.speedRunSecondsRemaining = remainFrac;
- this.state.speedRunSecondsRemaining = remainFrac;
- try { this.view?.annotateBottomScreen?.(this.state.toString()); } catch {}
- }
- if (remain === 0) {
- // Stop ticking locally, but keep run active until
- // server sends VAL with speed_run_finished=true
- try { clearInterval(this._srsInterval); } catch {}
- this._srsInterval = null;
- this._srsEndAt = null;
- this.speedRunSecondsRemaining = 0;
- this.state.speedRunSecondsRemaining = 0;
- try { this.view?.annotateBottomScreen?.(this.state.toString()); } catch {}
- }
- }, 300);
- }
- } else {
- // srs==0: if we haven't started local timer yet, reflect zero
- if (!this._srsInterval) {
- this.speedRunSecondsRemaining = 0;
- this.state.speedRunSecondsRemaining = 0;
- // Do not flip active here; end only on server finish
- try { this.view?.annotateBottomScreen?.(this.state.toString()); } catch {}
+requestLeaderboard() {
+ try {
+ if (!this.Control || !this.ControlType) return;
+ const payload = {
+ type: this.ControlType.values.CONTROL_LEADERBOARD_REQUEST,
+ };
+ this.sendQ(payload);
+ } catch { }
+}
+
+_createOverlay() {
+ let overlay = document.getElementById('modalOverlay');
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.id = 'modalOverlay';
+ document.body.appendChild(overlay);
+ }
+ return overlay;
+}
+
+_showSpeedRunResults({ finalValLoss, rank, totalScores }) {
+ this._setTextCaptureActive(true);
+ const overlay = this._createOverlay();
+ overlay.innerHTML = '';
+ const card = document.createElement('div');
+ card.className = 'glass-card results-card';
+ const title = document.createElement('h2');
+ title.textContent = 'Congratulations, Speed Run Complete!';
+
+ // Calculate Level
+ let achievedLevel = null;
+ if (this.levels && this.levels.length > 0 && typeof finalValLoss === 'number') {
+ // Levels are sorted descending by loss (worst to best)
+ // We want the best level achieved (lowest loss threshold met)
+ for (const level of this.levels) {
+ if (finalValLoss <= level.loss) {
+ if (!achievedLevel || level.loss < achievedLevel.loss) {
+ achievedLevel = level;
}
}
- } catch {}
- }
- _promptAndSubmitHighScore(finalValLoss) {
- if (this.highScoreSubmitted) return;
- try {
- this._showNameModal(finalValLoss);
- } catch {}
+ }
}
- requestLeaderboard() {
+ const metrics = document.createElement('div');
+ metrics.className = 'results-metrics-row';
+
+ const lossVal = (typeof finalValLoss === 'number' && Number.isFinite(finalValLoss))
+ ? finalValLoss.toFixed(4)
+ : '—';
+ const evalSteps = this.state?.evalSteps ?? 0;
+ const planes = Math.max(1, Math.floor((this.state?.dimsUsed || 0) / (this.state?.n || 1)));
+ const rankText = (rank && totalScores) ? `${rank}/${totalScores}` : '-/-';
+
+ metrics.innerHTML = `
+ Loss${lossVal}
+
+ Steps${evalSteps}
+
+ Subspaces${planes}
+
+ Rank${rankText}
+ `;
+
+ const shareText = SHARE_TEXT(
+ finalValLoss,
+ achievedLevel?.levelNumber || '-',
+ achievedLevel?.title || 'Unranked'
+ );
+
+ const share = document.createElement('p');
+ share.className = 'results-share';
+ share.textContent = shareText;
+
+ // Social Actions
+ const shareActions = document.createElement('div');
+ shareActions.className = 'share-actions';
+
+ // X (Twitter) Button
+ const xBtn = document.createElement('a');
+ xBtn.className = 'share-btn x-btn';
+ xBtn.target = '_blank';
+ xBtn.rel = 'noopener noreferrer';
+ xBtn.textContent = 'Share on 𝕏';
+ xBtn.href = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(window.location.origin)}`;
+
+ // Copy Button
+ const copyBtn = document.createElement('button');
+ copyBtn.className = 'share-btn copy-btn';
+ copyBtn.textContent = 'Copy Text';
+ copyBtn.onclick = async () => {
try {
- if (!this.Control || !this.ControlType) return;
- const payload = {
- type: this.ControlType.values.CONTROL_LEADERBOARD_REQUEST,
- };
- this.sendQ(payload);
- } catch {}
- }
-
- _createOverlay() {
- let overlay = document.getElementById('modalOverlay');
- if (!overlay) {
- overlay = document.createElement('div');
- overlay.id = 'modalOverlay';
- document.body.appendChild(overlay);
- }
- return overlay;
- }
-
- _showNameModal(finalValLoss) {
- const overlay = this._createOverlay();
- overlay.innerHTML = '';
-
- const card = document.createElement('div');
- card.className = 'glass-card';
- const title = document.createElement('h2');
- title.textContent = 'Speed Run complete!';
- const subtitle = document.createElement('p');
- subtitle.className = 'muted';
- const scoreTxt = (typeof finalValLoss === 'number')
- ? `Your final validation loss: ${finalValLoss.toFixed(4)}`
- : 'Great job!';
- subtitle.textContent = scoreTxt;
-
- const form = document.createElement('form');
- form.className = 'name-form';
- form.innerHTML = `
-
-
-
-
-
+ await navigator.clipboard.writeText(shareText);
+ const original = copyBtn.textContent;
+ copyBtn.textContent = 'Copied!';
+ setTimeout(() => copyBtn.textContent = original, 2000);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
+
+ shareActions.append(xBtn, copyBtn);
+
+ const form = document.createElement('form');
+ form.className = 'name-form';
+ form.innerHTML = `
+
+
+
+
+
`;
+ const enableActions = () => { };
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ });
+ form.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ });
+ const submitBtn = form.querySelector('button[type="submit"]');
+ if (!submitBtn) {
+ log('[HudesClient] Missing submit button in speed-run modal');
+ return;
+ }
+ submitBtn.addEventListener('click', (event) => {
+ event.preventDefault();
+ const input = form.querySelector('#nameInput');
+ const clean = (input.value || 'USER').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4);
+ if (clean.length !== 4) {
+ input.classList.add('invalid');
+ return;
+ }
+ input.classList.remove('invalid');
+ this.submitHighScore(clean);
+ enableActions();
+ this.highScoreSubmitted = true;
+ try { this.requestLeaderboard(); } catch { }
+ overlay.classList.remove('open');
+ this._setTextCaptureActive(false);
+ });
+ card.append(title, metrics, share, shareActions, form);
+ overlay.appendChild(card);
+ overlay.classList.add('open');
+ const input = form.querySelector('#nameInput');
+ input?.focus();
+ log('[HudesClient] speed-run results modal shown');
+}
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
- const el = form.querySelector('#nameInput');
- const clean = (el.value || 'USER').toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,4);
- if (clean.length !== 4) {
- el.classList.add('invalid');
- return;
- }
- this.submitHighScore(clean);
- // In production we use only WebSocket. Request the Top 10 over WS
- // and let CONTROL_LEADERBOARD_RESPONSE render the modal.
- try { this.requestLeaderboard(); } catch {}
- });
+_showLeaderboardModal(top10, me) {
+ this._setTextCaptureActive(false);
+ const overlay = this._createOverlay();
+ overlay.innerHTML = '';
+ const card = document.createElement('div');
+ card.className = 'glass-card';
+ const title = document.createElement('h2');
+ title.textContent = 'Leaderboard';
+ const you = document.createElement('p');
+ you.className = 'muted';
+ const score = (typeof me.score === 'number' && isFinite(me.score)) ? me.score.toFixed(4) : '—';
+ const rankText = (me.rank && me.total) ? `#${me.rank} of ${me.total}` : '#—';
+ you.textContent = `${me.name ?? 'YOU'} • Score: ${score} • Rank: ${rankText}`;
+
+ const list = document.createElement('ol');
+ list.className = 'top10-list';
+ (top10 || []).forEach((row, idx) => {
+ const li = document.createElement('li');
+ const rank = (idx + 1);
+ const s = (typeof row.score === 'number') ? row.score.toFixed(4) : row.score;
+ li.textContent = `${rank} ${row.name} — ${s}`;
+ list.appendChild(li);
+ });
+
+ const actions = document.createElement('div');
+ actions.className = 'actions';
+ const closeBtn = document.createElement('button');
+ closeBtn.textContent = 'Close';
+ closeBtn.addEventListener('click', () => {
+ overlay.classList.remove('open');
+ this._setTextCaptureActive(false);
+ });
+ const viewBtn = document.createElement('button');
+ viewBtn.textContent = 'View Top 10';
+ viewBtn.className = 'link-btn';
+ viewBtn.addEventListener('click', () => {
+ try { this.requestLeaderboard?.(); } catch { }
+ });
+ actions.appendChild(viewBtn);
+ actions.appendChild(closeBtn);
+
+ card.appendChild(title);
+ card.appendChild(you);
+ card.appendChild(list);
+ card.appendChild(actions);
+ overlay.appendChild(card);
+ overlay.classList.add('open');
+}
- card.appendChild(title);
- card.appendChild(subtitle);
- card.appendChild(form);
- overlay.appendChild(card);
- overlay.classList.add('open');
+_detectBackend() {
+ const params = new URLSearchParams(window.location.search);
+ const host = params.get('host') || this.backendHost || window.location.hostname || 'localhost';
+ // Prefer provided query param; else use the port used for WebSocket; default to 10001
+ const port = params.get('port') ? Number(params.get('port'))
+ : (this.backendPort || 10001);
+ return { host, port };
+}
- const input = form.querySelector('#nameInput');
- if (input) { input.focus(); }
- }
-
- _showLeaderboardModal(top10, me) {
- const overlay = this._createOverlay();
- overlay.innerHTML = '';
- const card = document.createElement('div');
- card.className = 'glass-card';
- const title = document.createElement('h2');
- title.textContent = 'Leaderboard';
- const you = document.createElement('p');
- you.className = 'muted';
- const score = (typeof me.score === 'number' && isFinite(me.score)) ? me.score.toFixed(4) : '—';
- const rankText = (me.rank && me.total) ? `#${me.rank} of ${me.total}` : '#—';
- you.textContent = `${me.name ?? 'YOU'} • Score: ${score} • Rank: ${rankText}`;
-
- const list = document.createElement('ol');
- list.className = 'top10-list';
- (top10 || []).forEach((row, idx) => {
- const li = document.createElement('li');
- const rank = (idx + 1);
- const s = (typeof row.score === 'number') ? row.score.toFixed(4) : row.score;
- li.textContent = `${rank} ${row.name} — ${s}`;
- list.appendChild(li);
- });
+_apiBase(host, port) {
+ const httpPort = (Number(port) + 1);
+ const proto = window.location.protocol === 'https:' ? 'https' : 'http';
+ return `${proto}://${host}:${httpPort}/api`;
+}
+_buildLossLineLabels(count) {
+ this.lossLineLabels = Array.from({ length: count }, (_, idx) => {
+ const dimNumber = this.dimsOffset + idx + 1;
+ return `dim ${dimNumber}`;
+ });
+ return this.lossLineLabels;
+}
+_reshapeArray(flatArray, shape) {
+ // Helper function to recursively reshape the flattened array
+ function recursiveReshape(array, dims) {
+ if (dims.length === 0) {
+ return array[0];
+ }
+ const size = dims[0];
+ const rest = dims.slice(1);
+ const reshaped = [];
+ for (let i = 0; i < size; i++) {
+ reshaped.push(recursiveReshape(array.splice(0, rest.reduce((a, b) => a * b, 1)), rest));
+ }
+ return reshaped;
+ }
- const actions = document.createElement('div');
- actions.className = 'actions';
- const closeBtn = document.createElement('button');
- closeBtn.textContent = 'Close';
- closeBtn.addEventListener('click', () => {
- overlay.classList.remove('open');
- });
- const viewBtn = document.createElement('button');
- viewBtn.textContent = 'View Top 10';
- viewBtn.className = 'link-btn';
- viewBtn.addEventListener('click', () => {
- try { this.requestLeaderboard?.(); } catch {}
- });
- actions.appendChild(viewBtn);
- actions.appendChild(closeBtn);
-
- card.appendChild(title);
- card.appendChild(you);
- card.appendChild(list);
- card.appendChild(actions);
- overlay.appendChild(card);
- overlay.classList.add('open');
- }
-
- _detectBackend() {
- const params = new URLSearchParams(window.location.search);
- const host = params.get('host') || this.backendHost || window.location.hostname || 'localhost';
- // Prefer provided query param; else use the port used for WebSocket; default to 10001
- const port = params.get('port') ? Number(params.get('port'))
- : (this.backendPort || 10001);
- return { host, port };
- }
-
- _apiBase(host, port) {
- const httpPort = (Number(port) + 1);
- const proto = window.location.protocol === 'https:' ? 'https' : 'http';
- return `${proto}://${host}:${httpPort}/api`;
- }
- _reshapeArray(flatArray, shape) {
- // Helper function to recursively reshape the flattened array
- function recursiveReshape(array, dims) {
- if (dims.length === 0) {
- return array[0];
+ return recursiveReshape([...flatArray], shape);
+}
+// // Helper function to deserialize bytes
+// _deserializeBytes(bytes) {
+// try {
+// // Assuming protobuf bytes are serialized with JSON
+// return JSON.parse(new TextDecoder().decode(bytes));
+// } catch (error) {
+// console.error("Failed to deserialize bytes:", error);
+// return [];
+// }
+// }
+
+
+_connectSocket() {
+ if (!this._shouldReconnect) {
+ return;
+ }
+ try {
+ log(`[HudesClient] Connecting WebSocket to ${this.socketUrl}`);
+ this.socket = new WebSocket(this.socketUrl);
+ this.socket.binaryType = 'arraybuffer';
+ this.socket.onopen = () => this._handleSocketOpen();
+ this.socket.onclose = (event) => this._handleSocketClose(event);
+ this.socket.onmessage = (event) => this._handleMessage(event);
+ this.socket.onerror = (error) =>
+ log(`[HudesClient] WebSocket error: ${error?.message ?? error}`, null, 'error');
+ } catch (err) {
+ log(`[HudesClient] Failed to open WebSocket: ${err?.message ?? err}`, null, 'error');
+ this._scheduleReconnect();
+ }
+}
+
+_handleSocketOpen() {
+ log('[HudesClient] WebSocket connected.');
+ this._connected = true;
+ try {
+ if (typeof window !== 'undefined') {
+ const el = document.querySelector('.connection-status');
+ if (el) {
+ el.dataset.state = 'connected';
+ el.textContent = 'Connected';
}
- const size = dims[0];
- const rest = dims.slice(1);
- const reshaped = [];
- for (let i = 0; i < size; i++) {
- reshaped.push(recursiveReshape(array.splice(0, rest.reduce((a, b) => a * b, 1)), rest));
+ }
+ } catch { }
+ this._reconnectAttempts = 0;
+ if (this._reconnectTimer) {
+ clearTimeout(this._reconnectTimer);
+ this._reconnectTimer = null;
+ }
+ this._readyForInput = false;
+ (async () => {
+ try {
+ await this._protoReadyPromise;
+ if (this._resumeToken) {
+ this._resumeInFlight = true;
+ this._sendResumeRequest();
+ } else {
+ this._resumeInFlight = false;
+ await this.sendConfig();
+ this._readyForInput = true;
}
- return reshaped;
+ } catch (err) {
+ log(`[HudesClient] Failed during socket open init: ${err?.message ?? err}`, null, 'error');
}
+ })();
+}
- return recursiveReshape([...flatArray], shape);
+_handleSocketClose(event) {
+ log(`[HudesClient] WebSocket disconnected (code=${event?.code ?? 'n/a'})`);
+ this._connected = false;
+ try {
+ if (typeof window !== 'undefined') {
+ const el = document.querySelector('.connection-status');
+ if (el) {
+ el.dataset.state = 'disconnected';
+ el.textContent = 'Disconnected';
+ }
+ }
+ } catch { }
+ this._readyForInput = false;
+ this._resumeInFlight = false;
+ this._pendingSessionToken = null;
+ if (!this._shouldReconnect) {
+ this.running = false;
+ return;
}
- // // Helper function to deserialize bytes
- // _deserializeBytes(bytes) {
- // try {
- // // Assuming protobuf bytes are serialized with JSON
- // return JSON.parse(new TextDecoder().decode(bytes));
- // } catch (error) {
- // console.error("Failed to deserialize bytes:", error);
- // return [];
- // }
- // }
-
+ this._scheduleReconnect();
+}
- setupSocket() {
- this.socket.onopen = () => log("[HudesClient] WebSocket connected.");
- this.socket.onclose = () => {
- log("[HudesClient] WebSocket disconnected.");
- this.running = false;
- };
- this.socket.onmessage = (event) => this._handleMessage(event);
- this.socket.onerror = (error) => log(`[HudesClient] WebSocket error: ${error}`);
+_scheduleReconnect() {
+ this._reconnectAttempts += 1;
+ const delay = Math.min(1000 * this._reconnectAttempts, 5000);
+ if (this._reconnectTimer) {
+ clearTimeout(this._reconnectTimer);
}
+ this._reconnectTimer = setTimeout(() => this._connectSocket(), delay);
+}
- initKeys() {
- this._handleKeyDown = this._handleKeyDown.bind(this);
- this._handleKeyUp = this._handleKeyUp.bind(this);
-
- window.addEventListener("keydown", this._handleKeyDown);
- window.addEventListener("keyup", this._handleKeyUp);
+_sendResumeRequest() {
+ if (!this.Control || !this.ControlType) {
+ return;
}
+ if (!this._resumeToken) {
+ this._pendingSessionToken = null;
+ this._readyForInput = false;
+ this.sendConfig();
+ return;
+ }
+ const nextSessionToken = this._generateSessionToken();
+ this._pendingSessionToken = nextSessionToken;
+ const payload = {
+ type: this.ControlType.values.CONTROL_RESUME,
+ resume: {
+ token: this._resumeToken,
+ lastRequestIdx: this._serverAckRequestIdx,
+ clientSessionToken: this._clientSessionToken,
+ newClientSessionToken: nextSessionToken,
+ },
+ };
+ this.sendQ(payload);
+}
-
- // Utility to track key press timing
- updateKeyHolds(currentlyPressedKeys) {
- const currentTime = performance.now();
- for (const key in this.keyHolds) {
- if (currentlyPressedKeys[key]) {
- if (this.keyHolds[key].firstPress === -1) {
- this.keyHolds[key].firstPress = currentTime;
- }
- } else {
- this.keyHolds[key].firstPress = -1;
- }
+_handleResumeControl(message) {
+ const resumePayload = message?.resume;
+ const status = resumePayload?.status;
+ const token = resumePayload?.token || message?.resumeToken;
+ if (token) {
+ this._persistResumeToken(token);
+ }
+ if (typeof resumePayload?.lastRequestIdx === 'number') {
+ this._serverAckRequestIdx = Math.max(
+ this._serverAckRequestIdx,
+ resumePayload.lastRequestIdx,
+ );
+ }
+ if (status === this.resumeStatus?.RESUME_OK || status === 0) {
+ const nextSession =
+ resumePayload?.clientSessionToken || this._pendingSessionToken;
+ if (nextSession) {
+ this._setSessionToken(nextSession);
}
+ this._pendingSessionToken = null;
+ this._resumeInFlight = false;
+ this._readyForInput = true;
+ try {
+ this.sendConfig();
+ } catch (err) {
+ log(`[HudesClient] Failed to resend config after resume: ${err?.message ?? err}`, null, 'error');
+ }
+ return;
}
+ this._pendingSessionToken = null;
+ this._resumeInFlight = false;
+ this._readyForInput = false;
+ this._clearResumeToken();
+ this.sendConfig();
+}
- // Checks if the cooldown time has elapsed for a key
- checkCooldownTime(event, key, currentTime, cooldownTimeSec) {
- const cooldownTimeMs = cooldownTimeSec * 1000; // Convert to ms
- if (event.key !== key) {
- return false;
+_persistResumeToken(token) {
+ if (!token || typeof token !== 'string') {
+ return;
+ }
+ this._resumeToken = token;
+ try {
+ if (this._sessionStorageAvailable) {
+ window.sessionStorage?.setItem(this._resumeTokenKey, token);
}
- if (currentTime - (this.keyHolds[key]?.lastExec || 0) > cooldownTimeMs) {
- this.keyHolds[key].lastExec = currentTime;
- return true;
+ } catch { }
+}
+
+_loadPersistedResumeToken() {
+ try {
+ if (this._sessionStorageAvailable) {
+ return window.sessionStorage?.getItem(this._resumeTokenKey) || null;
}
- return false;
- }
+ } catch { }
+ return null;
+}
- // Checks if the key has been held down for a specific duration
- checkKeyHoldTime(event, key, currentTime, holdTimeSec) {
- const holdTimeMs = holdTimeSec * 1000; // Convert to ms
- if (event.key !== key) {
- return false;
+_didReload() {
+ try {
+ if (typeof performance !== 'undefined') {
+ const entries = performance.getEntriesByType?.('navigation');
+ if (entries && entries.length > 0) {
+ return entries[0].type === 'reload';
+ }
+ if (performance.navigation) {
+ const TYPE_RELOAD =
+ performance.navigation.TYPE_RELOAD ??
+ (typeof PerformanceNavigation !== 'undefined'
+ ? PerformanceNavigation.TYPE_RELOAD
+ : 1);
+ return performance.navigation.type === TYPE_RELOAD;
+ }
}
- if (
- this.keyHolds[key]?.firstPress >= 0 &&
- currentTime - this.keyHolds[key].firstPress > holdTimeMs
- ) {
- return true;
+ } catch { }
+ return false;
+}
+
+_clearResumeToken() {
+ this._resumeToken = null;
+ try {
+ if (this._sessionStorageAvailable) {
+ window.sessionStorage?.removeItem(this._resumeTokenKey);
}
- return false;
+ } catch { }
+}
+
+_notifyTutorialStep(stepId) {
+ if (!stepId) {
+ return;
}
+ try {
+ this.view?.notifyTutorialEvent?.(stepId);
+ } catch { }
+}
- setN(n) {
- n = Math.max(6, n); // Ensure minimum value of 6
- this.state.n = n;
- this.zeroDimsAndStepsOnCurrentDims();
+toggleKeyOverlay(mode) {
+ const view = this.view;
+ if (!view) {
+ return;
}
- _handleKeyDown(event) {
- const currentTime = performance.now();
- if (!this.keyHolds[event.code]) {
- this.keyHolds[event.code] = { firstPress: currentTime, lastExec: 0 };
- } else if (this.keyHolds[event.code].firstPress === -1) {
- this.keyHolds[event.code].firstPress = currentTime;
- }
- this.processKeyPress(event); // Call the processKeyPress method
+ if (view.isManualKeyOverlayVisible?.()) {
+ view.hideImage?.();
+ return;
}
+ view.openHelpOverlay?.('controls');
+}
- _handleKeyUp(event) {
- if (this.keyHolds[event.code]) {
- this.keyHolds[event.code].firstPress = -1;
- }
+_ensureCountdownOverlay() {
+ if (this._speedRunCountdownOverlay) {
+ return this._speedRunCountdownOverlay;
+ }
+ if (typeof document === 'undefined') {
+ return null;
}
+ const overlay = document.createElement('div');
+ overlay.id = 'speedrunCountdownOverlay';
+ const box = document.createElement('div');
+ box.className = 'speedrun-countdown__box';
+ const text = document.createElement('span');
+ text.className = 'speedrun-countdown__text';
+ box.appendChild(text);
+ overlay.appendChild(box);
+ document.body.appendChild(overlay);
+ this._speedRunCountdownOverlay = overlay;
+ this._speedRunCountdownText = text;
+ return overlay;
+}
- processKeyPress(event) {
- // Default key handling logic, can be overridden by derived classes
- this.processCommonKeys(event);
+_showCountdownFrame(text, variant = 'ready') {
+ const overlay = this._ensureCountdownOverlay();
+ if (!overlay) {
+ return;
}
+ if (this._speedRunCountdownText) {
+ this._speedRunCountdownText.textContent = text.toUpperCase();
+ } else {
+ overlay.textContent = text.toUpperCase();
+ }
+ overlay.classList.remove('ready', 'count');
+ overlay.classList.add('visible', variant);
+ document.body.classList.add('speedrun-countdown');
+}
- addKeyToWatch(key) {
- // Initialize tracking for the key if not already present
- if (!this.keyHolds[key]) {
- this.keyHolds[key] = { firstPress: -1, lastExec: 0 };
+_hideCountdownOverlay() {
+ const overlay = this._speedRunCountdownOverlay;
+ if (overlay) {
+ overlay.classList.remove('visible', 'ready', 'count');
+ if (this._speedRunCountdownText) {
+ this._speedRunCountdownText.textContent = '';
+ } else {
+ overlay.textContent = '';
}
}
+ document.body.classList.remove('speedrun-countdown');
+}
- processCommonKeys(event) {
- const currentTime = Date.now();
+_clearCountdownTimer() {
+ if (this._speedRunCountdownTimer) {
+ clearTimeout(this._speedRunCountdownTimer);
+ this._speedRunCountdownTimer = null;
+ }
+}
- // Check cooldown
- const keyInfo = this.keyHolds[event.code];
- if (keyInfo && currentTime - keyInfo.lastExec < this.cooldownTimeMs) {
+_runSpeedRunCountdown() {
+ const frames = [
+ { text: 'Get ready', variant: 'ready', duration: 900 },
+ { text: '3', variant: 'count', duration: 700 },
+ { text: '2', variant: 'count', duration: 700 },
+ { text: '1', variant: 'count', duration: 700 },
+ ];
+ let idx = 0;
+ const step = () => {
+ if (idx >= frames.length) {
+ this._clearCountdownTimer();
+ this._hideCountdownOverlay();
+ this._sendSpeedRunStart();
return;
}
+ const frame = frames[idx++];
+ this._showCountdownFrame(frame.text, frame.variant);
+ this._speedRunCountdownTimer = setTimeout(step, frame.duration);
+ };
+ step();
+}
- if (event.type === "keydown") {
- switch (event.code) {
- case "KeyR":
- // Start a speed run
- log('[HudesClient] KeyR pressed: starting speed run');
- this.startSpeedRun();
- break;
- case "KeyY":
- // Request Top 10 leaderboard over WebSocket
- this.requestLeaderboard();
- break;
- case "KeyX":
- //log("Next help screen.");
- this.state.nextHelpScreen();
- break;
- // default:
- // log(`Unhandled key: ${event.code}`);
- }
- }
+_cancelSpeedRunCountdown() {
+ this._clearCountdownTimer();
+ this._hideCountdownOverlay();
+ document.body.classList.remove('speedrun-active');
+ this._speedRunRequestPending = false;
+}
- if (this.state.helpScreenIdx!=-1) {
- return;
+_updateEvalStepsFromMessage(message) {
+ const steps = message?.totalEvalSteps;
+ if (typeof steps === 'number' && Number.isFinite(steps)) {
+ if (typeof this.state.setEvalSteps === 'function') {
+ this.state.setEvalSteps(steps);
+ } else {
+ this.state.evalSteps = steps;
}
+ }
+}
- if (event.type === "keydown") {
+_loadSessionToken() {
+ if (!this._sessionStorageAvailable) {
+ return this._generateSessionToken();
+ }
+ try {
+ const existing = window.sessionStorage?.getItem(this._sessionTokenKey);
+ if (existing) {
+ return existing;
+ }
+ } catch { }
+ const token = this._generateSessionToken();
+ this._persistSessionToken(token);
+ return token;
+}
- switch (event.code) {
- case "KeyQ":
- if (currentTime - keyInfo.firstPress > 1000) {
- log("Quitting...");
- this.running = false;
- }
- break;
+_persistSessionToken(token) {
+ if (!token || typeof token !== 'string' || !this._sessionStorageAvailable) {
+ return;
+ }
+ try {
+ window.sessionStorage?.setItem(this._sessionTokenKey, token);
+ } catch { }
+}
- case "Backspace":
- case "Delete":
- if (this.speedRunActive || this.state.speedRunActive) {
- log("SGD disabled during Speed Run.");
- } else {
- log("[HudesClient] Performing SGD step.");
- this.getSGDStep();
- }
- break;
+_setSessionToken(token) {
+ if (!token || typeof token !== 'string') {
+ return;
+ }
+ this._clientSessionToken = token;
+ this._persistSessionToken(token);
+}
- case "Space":
- if (currentTime - keyInfo.lastExec > this.keySecondsPressed) {
- log("[HudesClient] Getting next dimensions.");
- this.getNextDims();
- }
- break;
+_generateSessionToken() {
+ try {
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
+ const bytes = new Uint32Array(4);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes, (b) => b.toString(16).padStart(8, '0')).join('');
+ }
+ } catch { }
+ return `sess-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
+}
- case "BracketLeft":
- log("[HudesClient] Increasing step size.");
- this.state.increaseStepSize(this.stepSizeMultiplier);
- this.sendConfig();
- break;
+_canSendUserCommands() {
+ return (
+ this.socket &&
+ this.socket.readyState === WebSocket.OPEN &&
+ !this._resumeInFlight &&
+ this._readyForInput
+ );
+}
- case "BracketRight":
- log("[HudesClient] Decreasing step size.");
- this.state.decreaseStepSize(this.stepSizeMultiplier);
- this.sendConfig();
- break;
+initKeys() {
+ this._handleKeyDown = this._handleKeyDown.bind(this);
+ this._handleKeyUp = this._handleKeyUp.bind(this);
- case "Quote":
- log("[HudesClient] Toggling dtype.");
- this.state.toggleDtype();
- this.sendConfig();
- break;
+ window.addEventListener("keydown", this._handleKeyDown);
+ window.addEventListener("keyup", this._handleKeyUp);
+}
- case "Semicolon":
- log("[HudesClient] Toggling batch size.");
- this.state.toggleBatchSize();
- this.sendConfig();
- break;
+_setTextCaptureActive(active) {
+ this._textCaptureActive = Boolean(active);
+}
- case "Enter":
- log("[HudesClient] Getting next batch.");
- this.getNextBatch();
- break;
+_shouldIgnoreKeyEvent(event) {
+ if (this._textCaptureActive) {
+ return true;
+ }
+ const target = event?.target;
+ if (target && target instanceof HTMLElement) {
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
+ return true;
+ }
+ if (target.isContentEditable) {
+ return true;
+ }
+ }
+ return false;
+}
- }
- // Update last execution time
- if (this.keyHolds[event.code]) {
- this.keyHolds[event.code].lastExec = currentTime;
+// Utility to track key press timing
+updateKeyHolds(currentlyPressedKeys) {
+ const currentTime = performance.now();
+ for (const key in this.keyHolds) {
+ if (currentlyPressedKeys[key]) {
+ if (this.keyHolds[key].firstPress === -1) {
+ this.keyHolds[key].firstPress = currentTime;
}
+ } else {
+ this.keyHolds[key].firstPress = -1;
}
}
- _showTop100Modal(rows) {
- const overlay = this._createOverlay();
- overlay.innerHTML = '';
- const card = document.createElement('div');
- card.className = 'glass-card';
- const title = document.createElement('h2');
- title.textContent = 'Top 10 Leaderboard';
- const listWrap = document.createElement('div');
- listWrap.className = 'scroll-wrap';
- const list = document.createElement('ol');
- list.className = 'top10-list';
- (rows || []).forEach((r, idx) => {
- const li = document.createElement('li');
- const rank = (idx + 1);
- const s = (typeof r.score === 'number') ? r.score.toFixed(4) : (r.score ?? '—');
- // Render explicit numbering to avoid browser marker inconsistencies
- li.textContent = `${rank} ${r.name} — ${s}`;
- list.appendChild(li);
- });
- listWrap.appendChild(list);
- const actions = document.createElement('div');
- actions.className = 'actions';
- const closeBtn = document.createElement('button');
- closeBtn.textContent = 'Close';
- closeBtn.addEventListener('click', () => overlay.classList.remove('open'));
- actions.appendChild(closeBtn);
- card.appendChild(title);
- card.appendChild(listWrap);
- card.appendChild(actions);
- overlay.appendChild(card);
- overlay.classList.add('open');
- }
-
- waitForWebSocket(callback) {
- const checkInterval = 50; // Check every 50ms
- const maxWaitTime = 5000; // Maximum wait time of 5 seconds
- let elapsedTime = 0;
-
- // Quick check if the WebSocket is already open
- if (this.socket.readyState === WebSocket.OPEN) {
- callback();
- return;
- }
+}
- const interval = setInterval(() => {
- if (this.socket.readyState === WebSocket.OPEN) {
- clearInterval(interval);
- callback(); // Invoke the callback when the WebSocket is ready
- } else if (elapsedTime >= maxWaitTime) {
- clearInterval(interval);
- console.error("WebSocket did not open within the timeout period.");
- }
- elapsedTime += checkInterval;
- }, checkInterval);
+// Checks if the cooldown time has elapsed for a key
+checkCooldownTime(event, key, currentTime, cooldownTimeSec) {
+ const cooldownTimeMs = cooldownTimeSec * 1000; // Convert to ms
+ if (event.key !== key) {
+ return false;
}
- sendQ(payload) {
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot send message.");
- return;
- }
+ if (currentTime - (this.keyHolds[key]?.lastExec || 0) > cooldownTimeMs) {
+ this.keyHolds[key].lastExec = currentTime;
+ return true;
+ }
+ return false;
+}
- // Add requestIdx to the payload if not already present
- payload.requestIdx = this.requestIdx;
+// Checks if the key has been held down for a specific duration
+checkKeyHoldTime(event, key, currentTime, holdTimeSec) {
+ const holdTimeMs = holdTimeSec * 1000; // Convert to ms
+ if (event.key !== key) {
+ return false;
+ }
+ if (
+ this.keyHolds[key]?.firstPress >= 0 &&
+ currentTime - this.keyHolds[key].firstPress > holdTimeMs
+ ) {
+ return true;
+ }
+ return false;
+}
- // Verify the payload
- const errMsg = this.Control.verify(payload);
- if (errMsg) {
- console.error("Message verification failed:", errMsg);
- return;
- }
+setN(n) {
+ n = Math.max(6, n); // Ensure minimum value of 6
+ this.state.n = n;
+ this.zeroDimsAndStepsOnCurrentDims();
+}
+_handleKeyDown(event) {
+ if (this._shouldIgnoreKeyEvent(event)) {
+ return;
+ }
+ const currentTime = performance.now();
+ if (!this.keyHolds[event.code]) {
+ this.keyHolds[event.code] = { firstPress: currentTime, lastExec: 0 };
+ } else if (this.keyHolds[event.code].firstPress === -1) {
+ this.keyHolds[event.code].firstPress = currentTime;
+ }
+ this.processKeyPress(event); // Call the processKeyPress method
+}
- // Create and encode the message
- const message = this.Control.create(payload);
- const buffer = this.Control.encode(message).finish();
+_handleKeyUp(event) {
+ if (this._shouldIgnoreKeyEvent(event)) {
+ return;
+ }
+ if (this.keyHolds[event.code]) {
+ this.keyHolds[event.code].firstPress = -1;
+ }
+}
- // Send the message over WebSocket
- this.waitForWebSocket(() => {
- try {
- const t = Object.keys(this.ControlType.values).find(
- k => this.ControlType.values[k] === payload.type
- );
- log(`[HudesClient] send ${t ?? payload.type} reqIdx=${payload.requestIdx ?? this.requestIdx}`);
- } catch {}
- this.socket.send(buffer);
- this.requestIdx++;
- });
- //console.log("Message sent:", payload);
+processKeyPress(event) {
+ // Default key handling logic, can be overridden by derived classes
+ this.processCommonKeys(event);
+}
+
+addKeyToWatch(key) {
+ // Initialize tracking for the key if not already present
+ if (!this.keyHolds[key]) {
+ this.keyHolds[key] = { firstPress: -1, lastExec: 0 };
}
+}
+processCommonKeys(event) {
+ if (event.metaKey || event.ctrlKey) {
+ return;
+ }
+ const currentTime = Date.now();
- nextDimsMessage(requestIdx) {
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot create nextDimsMessage.");
- return null;
+ // Check cooldown
+ const keyInfo = this.keyHolds[event.code];
+ if (keyInfo && currentTime - keyInfo.lastExec < this.cooldownTimeMs) {
+ return;
+ }
+
+ if (event.type === "keydown") {
+ switch (event.code) {
+ case "KeyZ":
+ if (this.speedRunActive || this._speedRunRequestPending) {
+ log('[HudesClient] KeyZ pressed: cancelling speed run');
+ this.cancelSpeedRun();
+ } else {
+ log('[HudesClient] KeyZ pressed: starting speed run');
+ this.startSpeedRun();
+ }
+ break;
+ case "KeyY":
+ // Request Top 10 leaderboard over WebSocket
+ this.requestLeaderboard();
+ break;
+ case "KeyX":
+ if (this.view?.isManualKeyOverlayVisible?.()) {
+ this.view.hideImage?.();
+ } else if (this.state.helpScreenIdx !== -1) {
+ if (typeof this.state.closeHelpScreens === 'function') {
+ this.state.closeHelpScreens();
+ } else {
+ this.state.helpScreenIdx = -1;
+ }
+ this.view.hideImage?.();
+ } else {
+ this.toggleKeyOverlay();
+ }
+ break;
+ case "Slash":
+ if (event.shiftKey) {
+ this.toggleKeyOverlay();
+ }
+ break;
+ // default:
+ // log(`Unhandled key: ${event.code}`);
}
+ }
- return {
- type: this.ControlType.values.CONTROL_NEXT_DIMS,
- requestIdx,
- };
+ if (this.state.helpScreenIdx != -1) {
+ return;
}
- getSGDStep() {
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot send SGD step message.");
- return;
+ if (event.type === "keydown") {
+
+ switch (event.code) {
+ case "KeyQ":
+ if (currentTime - keyInfo.firstPress > 1000) {
+ log("Quitting...");
+ this.running = false;
+ }
+ break;
+
+ case "Backspace":
+ case "Delete":
+ if (this.speedRunActive || this.state.speedRunActive) {
+ log("SGD disabled during Speed Run.");
+ } else {
+ log("[HudesClient] Performing SGD step.");
+ this.getSGDStep();
+ }
+ break;
+
+ case "Space":
+ if (currentTime - keyInfo.lastExec > this.keySecondsPressed) {
+ log("[HudesClient] Getting next dimensions.");
+ this.getNextDims();
+ this._notifyTutorialStep('new_dims');
+ }
+ break;
+
+ case "BracketLeft":
+ log("[HudesClient] Increasing step size.");
+ this.state.increaseStepSize(this.stepSizeMultiplier);
+ this.sendConfig();
+ this._notifyTutorialStep('step');
+ break;
+
+ case "BracketRight":
+ log("[HudesClient] Decreasing step size.");
+ this.state.decreaseStepSize(this.stepSizeMultiplier);
+ this.sendConfig();
+ this._notifyTutorialStep('step');
+ break;
+
+ case "Quote":
+ log("[HudesClient] Toggling dtype.");
+ this.state.toggleDtype();
+ this.sendConfig();
+ this._notifyTutorialStep('precision');
+ break;
+
+ case "Semicolon":
+ log("[HudesClient] Toggling batch size.");
+ this.state.toggleBatchSize();
+ this.sendConfig();
+ this._notifyTutorialStep('batch');
+ break;
+
+ case "Enter":
+ log("[HudesClient] Getting next batch.");
+ this.getNextBatch();
+ break;
+
}
- if (this.speedRunActive || this.state.speedRunActive) {
- // Client-side guard; server also ignores
- return;
+ // Update last execution time
+ if (this.keyHolds[event.code]) {
+ this.keyHolds[event.code].lastExec = currentTime;
}
+ }
+}
+_showTop100Modal(rows) {
+ const overlay = this._createOverlay();
+ overlay.innerHTML = '';
+ const card = document.createElement('div');
+ card.className = 'glass-card';
+ const title = document.createElement('h2');
+ title.textContent = 'Top 10 Leaderboard';
+ const listWrap = document.createElement('div');
+ listWrap.className = 'scroll-wrap';
+ const list = document.createElement('ol');
+ list.className = 'top10-list';
+ (rows || []).forEach((r, idx) => {
+ const li = document.createElement('li');
+ const rank = (idx + 1);
+ const s = (typeof r.score === 'number') ? r.score.toFixed(4) : (r.score ?? '—');
+ // Render explicit numbering to avoid browser marker inconsistencies
+ li.textContent = `${rank} ${r.name} — ${s}`;
+ list.appendChild(li);
+ });
+ listWrap.appendChild(list);
+ const actions = document.createElement('div');
+ actions.className = 'actions';
+ const closeBtn = document.createElement('button');
+ closeBtn.textContent = 'Close';
+ closeBtn.addEventListener('click', () => overlay.classList.remove('open'));
+ actions.appendChild(closeBtn);
+ card.appendChild(title);
+ card.appendChild(listWrap);
+ card.appendChild(actions);
+ overlay.appendChild(card);
+ overlay.classList.add('open');
+}
- const payload = {
- type: this.ControlType.values.CONTROL_SGD_STEP,
- sgdSteps: 1,
- };
+waitForWebSocket(callback) {
+ const checkInterval = 50; // Check every 50ms
+ const maxWaitTime = 5000; // Maximum wait time of 5 seconds
+ let elapsedTime = 0;
- this.sendQ(payload);
- console.log("[HudesClient] SGD step message sent.");
+ // Quick check if the WebSocket is already open
+ if (!this.socket) {
+ console.error("WebSocket is not initialized.");
+ return;
+ }
+ if (this.socket.readyState === WebSocket.OPEN) {
+ callback();
+ return;
}
-
- getNextBatch() {
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot request next batch.");
+ const interval = setInterval(() => {
+ if (!this.socket) {
+ clearInterval(interval);
+ console.error("WebSocket instance missing during wait.");
return;
}
+ if (this.socket.readyState === WebSocket.OPEN) {
+ clearInterval(interval);
+ callback(); // Invoke the callback when the WebSocket is ready
+ } else if (elapsedTime >= maxWaitTime) {
+ clearInterval(interval);
+ console.error("WebSocket did not open within the timeout period.");
+ }
+ elapsedTime += checkInterval;
+ }, checkInterval);
+}
+sendQ(payload) {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot send message.");
+ return;
+ }
- const payload = {
- type: this.ControlType.values.CONTROL_NEXT_BATCH,
- };
+ // Add requestIdx to the payload if not already present
+ payload.requestIdx = this.requestIdx;
- this.sendQ(payload);
- console.log("[HudesClient] Next batch message sent.");
+ // Verify the payload
+ const errMsg = this.Control.verify(payload);
+ if (errMsg) {
+ console.error("Message verification failed:", errMsg);
+ return;
}
+ // Create and encode the message
+ const message = this.Control.create(payload);
+ const buffer = this.Control.encode(message).finish();
- async getNextDims() {
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot request next dims.");
- return;
- }
+ // Send the message over WebSocket
+ this.waitForWebSocket(() => {
+ try {
+ const t = Object.keys(this.ControlType.values).find(
+ k => this.ControlType.values[k] === payload.type
+ );
+ log(`[HudesClient] send ${t ?? payload.type} reqIdx=${payload.requestIdx ?? this.requestIdx}`);
+ } catch { }
+ this.socket.send(buffer);
+ this.requestIdx++;
+ });
+ //console.log("Message sent:", payload);
+}
- // Create payload
- const payload = {
- type: this.ControlType.values.CONTROL_NEXT_DIMS,
- };
- // Use sendQ to handle sending
- this.sendQ(payload);
+nextDimsMessage(requestIdx) {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot create nextDimsMessage.");
+ return null;
+ }
- // Perform the reset and update operations
- this.zeroDimsAndStepsOnCurrentDims();
- this.dimsAndStepsUpdated();
- this.state.dimsUsed += this.state.n;
+ return {
+ type: this.ControlType.values.CONTROL_NEXT_DIMS,
+ requestIdx,
+ };
+}
- //console.log("Next dimensions requested and steps reset.");
+getSGDStep() {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot send SGD step message.");
+ return;
}
- startSpeedRun() {
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot start Speed Run.");
- return;
- }
- this.highScoreSubmitted = false;
- if (this._srsInterval) { try { clearInterval(this._srsInterval); } catch {} this._srsInterval = null; }
- this._srsEndAt = null;
- this.speedRunActive = true;
- this.state.speedRunActive = true;
- this.state.speedRunSecondsRemaining = 0;
- this.state.bestScore = Infinity;
- // Do not change mesh grid configuration during Speed Run.
- const payload = {
- type: this.ControlType.values.CONTROL_SPEED_RUN_START,
- };
- this.sendQ(payload);
- log("[HudesClient] Speed Run started.");
- // No fallback timer; countdown will start on first server reply with speedRunSecondsRemaining
+ if (this.speedRunActive || this.state.speedRunActive) {
+ // Client-side guard; server also ignores
+ return;
}
-
- submitHighScore(name) {
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot submit High Score.");
- return;
- }
- if (this.highScoreSubmitted) return;
- const clean = (name || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4);
- if (clean.length !== 4) return;
- const payload = {
- type: this.ControlType.values.CONTROL_HIGH_SCORE_LOG,
- highScore: {
- name: clean,
- },
- };
- this.sendQ(payload);
- this.highScoreSubmitted = true;
- log(`[HudesClient] High Score submitted for ${clean}`);
+ if (!this._canSendUserCommands()) {
+ log("[HudesClient] Ignoring SGD request while socket unavailable", null, 'warn');
+ return;
}
+ const payload = {
+ type: this.ControlType.values.CONTROL_SGD_STEP,
+ sgdSteps: 1,
+ };
+
+ this.sendQ(payload);
+ console.log("[HudesClient] SGD step message sent.");
+}
+
- zeroDimsAndStepsOnCurrentDims() {
- this.dimsAndStepsOnCurrentDims = new Array(this.state.n).fill(0);
- //console.log("Dims and steps reset to zero.");
+getNextBatch() {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot request next batch.");
+ return;
}
- dimsAndStepsUpdated() {
- // if (this.view && typeof this.view.updateDimsSinceLastUpdate === 'function') {
- // this.view.updateDimsSinceLastUpdate(this.dimsAndStepsOnCurrentDims || []);
- // } else {
- // console.warn("View or updateDimsSinceLastUpdate is not defined.");
- // }
+ if (!this._canSendUserCommands()) {
+ log("[HudesClient] Ignoring next batch while socket unavailable", null, 'warn');
+ return;
}
- async sendDimsAndSteps(dimsAndSteps) {
- try {
- // Debug: Log control type status
- if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot send dims and steps.");
- return;
- }
- // Construct the payload
- const payload = {
- type: this.ControlType.values.CONTROL_DIMS,
- dimsAndSteps: Object.entries(dimsAndSteps).map(([dim, step]) => ({
- dim: parseInt(dim, 10),
- step,
- })),
- requestIdx: this.requestIdx,
- };
+ const payload = {
+ type: this.ControlType.values.CONTROL_NEXT_BATCH,
+ };
- // Debug: Log the payload before sending
- log(`[HudesClient] send DIMS dims=${payload.dimsAndSteps.map(x=>`(${x.dim}:${x.step.toFixed?.(3) ?? x.step})`).join(', ')}`);
+ this.sendQ(payload);
+ console.log("[HudesClient] Next batch message sent.");
+}
- // Use sendQ to handle message creation and sending
- this.sendQ(payload);
- // Simulate delay for synchronization
- await sleep(10);
+ async getNextDims() {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot request next dims.");
+ return;
+ }
+ if (!this._canSendUserCommands()) {
+ log("[HudesClient] Ignoring next dims while socket unavailable", null, 'warn');
+ return;
+ }
- // Update local state
- for (const [dim, step] of Object.entries(dimsAndSteps)) {
- this.dimsAndStepsOnCurrentDims[dim] =
- (this.dimsAndStepsOnCurrentDims[dim] || 0) + step;
- }
+ // Create payload
+ const payload = {
+ type: this.ControlType.values.CONTROL_NEXT_DIMS,
+ };
- // Debug: Log the updated dimsAndStepsOnCurrentDims state
- //console.log("Updated dimsAndStepsOnCurrentDims:", this.dimsAndStepsOnCurrentDims);
+ // Use sendQ to handle sending
+ this.sendQ(payload);
- // Update the UI or internal state to reflect the changes
- this.dimsAndStepsUpdated();
+ // Perform the reset and update operations
+ this.zeroDimsAndStepsOnCurrentDims();
+ this.dimsAndStepsUpdated();
+ this.state.dimsUsed += this.state.n;
+ this.dimsOffset += this.state.n;
- // Increment the request index
- this.requestIdx++;
+ //console.log("Next dimensions requested and steps reset.");
+}
- } catch (error) {
- console.error("Error in sendDimsAndSteps:", error);
- }
+startSpeedRun() {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot start Speed Run.");
+ return;
+ }
+ if (!this._canSendUserCommands()) {
+ log("[HudesClient] Cannot start Speed Run while disconnected", null, 'warn');
+ return;
+ }
+ if (this._speedRunCountdownTimer) {
+ this._cancelSpeedRunCountdown();
+ return;
}
+ if (this.speedRunActive || this._speedRunRequestPending) {
+ this.cancelSpeedRun();
+ return;
+ }
+ this._speedRunRequestPending = true;
+ this._runSpeedRunCountdown();
+}
+_sendSpeedRunStart() {
+ this.highScoreSubmitted = false;
+ if (this._srsInterval) {
+ try {
+ clearInterval(this._srsInterval);
+ } catch { }
+ this._srsInterval = null;
+ }
+ this._srsEndAt = null;
+ this.speedRunActive = true;
+ this.state.speedRunActive = true;
+ this.state.speedRunSecondsRemaining = 0;
+ this.state.bestScore = Infinity;
+ document.body.classList.remove('speedrun-countdown');
+ document.body.classList.add('speedrun-active');
+ const payload = {
+ type: this.ControlType.values.CONTROL_SPEED_RUN_START,
+ };
+ this.sendQ(payload);
+ log("[HudesClient] Speed Run started.");
+}
- async sendConfig() {
+cancelSpeedRun() {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot cancel Speed Run.");
+ return;
+ }
+ if (!this._canSendUserCommands()) {
+ log("[HudesClient] Cannot cancel Speed Run while disconnected", null, 'warn');
+ return;
+ }
+ this._cancelSpeedRunCountdown();
+ if (!this.speedRunActive && !this._speedRunRequestPending) {
+ return;
+ }
+ this._speedRunRequestPending = false;
+ this.speedRunActive = false;
+ this.state.speedRunActive = false;
+ this.speedRunSecondsRemaining = 0;
+ this.state.speedRunSecondsRemaining = 0;
+ if (this._srsInterval) {
+ try { clearInterval(this._srsInterval); } catch { }
+ this._srsInterval = null;
+ }
+ this._srsEndAt = null;
+ document.body.classList.remove('speedrun-active', 'speedrun-countdown');
+ const payload = {
+ type: this.ControlType.values.CONTROL_SPEED_RUN_CANCEL,
+ };
+ this.sendQ(payload);
+ log('[HudesClient] Speed Run cancelled.');
+}
+
+submitHighScore(name) {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot submit High Score.");
+ return;
+ }
+ if (this.highScoreSubmitted) return;
+ if (!this._canSendUserCommands()) {
+ log("[HudesClient] Cannot submit High Score while disconnected", null, 'warn');
+ return;
+ }
+ const clean = (name || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4);
+ if (clean.length !== 4) return;
+ const payload = {
+ type: this.ControlType.values.CONTROL_HIGH_SCORE_LOG,
+ highScore: {
+ name: clean,
+ },
+ };
+ this.sendQ(payload);
+ this.highScoreSubmitted = true;
+ log(`[HudesClient] High Score submitted for ${clean}`);
+}
+
+
+zeroDimsAndStepsOnCurrentDims() {
+ this.dimsAndStepsOnCurrentDims = new Array(this.state.n).fill(0);
+ //console.log("Dims and steps reset to zero.");
+}
+dimsAndStepsUpdated() {
+ // if (this.view && typeof this.view.updateDimsSinceLastUpdate === 'function') {
+ // this.view.updateDimsSinceLastUpdate(this.dimsAndStepsOnCurrentDims || []);
+ // } else {
+ // console.warn("View or updateDimsSinceLastUpdate is not defined.");
+ // }
+}
+ async sendDimsAndSteps(dimsAndSteps) {
+ try {
+ // Debug: Log control type status
if (!this.Control || !this.ControlType) {
- console.error("Proto definitions not loaded. Cannot send config.");
+ console.error("Proto definitions not loaded. Cannot send dims and steps.");
+ return;
+ }
+ if (!this._canSendUserCommands()) {
+ log("[HudesClient] Ignoring dims send while socket unavailable", null, 'warn');
return;
}
- // Create config payload
+ // Construct the payload
const payload = {
- type: this.ControlType.values.CONTROL_CONFIG,
- config: {
- seed: Math.floor(Math.random() * 1000),
- dimsAtATime: this.state.n,
- meshGridSize: this.grid_size,
- meshStepSize: this.state.stepSize,
- meshGrids: this.grids,
- batchSize: this.state.batchSize,
- dtype: this.state.dtype,
- },
+ type: this.ControlType.values.CONTROL_DIMS,
+ dimsAndSteps: Object.entries(dimsAndSteps).map(([dim, step]) => ({
+ dim: parseInt(dim, 10),
+ step,
+ })),
};
- // Use sendQ to send the message
+ // Debug: Log the payload before sending
+ log(`[HudesClient] send DIMS dims=${payload.dimsAndSteps.map(x => `(${x.dim}:${x.step.toFixed?.(3) ?? x.step})`).join(', ')}`);
+
+ // Use sendQ to handle message creation and sending
this.sendQ(payload);
- log(`[HudesClient] Config sent: grids=${this.grids} size=${this.grid_size} step=${this.state.stepSize} batch=${this.state.batchSize} dtype=${this.state.dtype}`);
+ // Simulate delay for synchronization
+ await sleep(10);
+
+ // Update local state
+ for (const [dim, step] of Object.entries(dimsAndSteps)) {
+ this.dimsAndStepsOnCurrentDims[dim] =
+ (this.dimsAndStepsOnCurrentDims[dim] || 0) + step;
+ }
+
+ // Debug: Log the updated dimsAndStepsOnCurrentDims state
+ //console.log("Updated dimsAndStepsOnCurrentDims:", this.dimsAndStepsOnCurrentDims);
+
+ // Update the UI or internal state to reflect the changes
+ this.dimsAndStepsUpdated();
+
+ } catch (error) {
+ console.error("Error in sendDimsAndSteps:", error);
+ }
+}
+
+
+ async sendConfig() {
+ if (!this.Control || !this.ControlType) {
+ console.error("Proto definitions not loaded. Cannot send config.");
+ return;
}
+ this.dimsOffset = 0;
+ this.lossLineLabels = [];
+
+ const payload = {
+ type: this.ControlType.values.CONTROL_CONFIG,
+ config: {
+ seed: Math.floor(Math.random() * 1000),
+ dimsAtATime: this.state.n,
+ meshGridSize: this.grid_size,
+ meshStepSize: this.state.stepSize,
+ meshGrids: this.meshEnabled ? this.grids : 0,
+ batchSize: this.state.batchSize,
+ dtype: this.state.dtype,
+ meshEnabled: this.meshEnabled,
+ lossLines: this.renderMode === '1d' ? this.lossLines : 0,
+ resumeSupported: true,
+ clientSessionToken: this._clientSessionToken,
+ },
+ };
+
+ // Use sendQ to send the message
+ this.sendQ(payload);
+ this._readyForInput = true;
+
+ log(`[HudesClient] Config sent: mode=${this.renderMode} grids=${this.grids} lossLines=${this.lossLines} size=${this.grid_size} step=${this.state.stepSize} batch=${this.state.batchSize} dtype=${this.state.dtype} meshEnabled=${this.meshEnabled}`);
+}
+
async runLoop() {
- while (!this.Control || !this.ControlType) {
- await sleep(100);
- }
- this.sendConfig();
+ await this._protoReadyPromise;
while (this.running) {
await sleep(10);
}
log("[HudesClient] Exiting run loop...");
}
+
}
diff --git a/web/client/KeyboardClient.js b/web/client/KeyboardClient.js
index 87f5b24..e48e809 100644
--- a/web/client/KeyboardClient.js
+++ b/web/client/KeyboardClient.js
@@ -1,51 +1,71 @@
import HudesClient from './HudesClient.js';
import { log } from '../utils/logger.js';
+import { TOUR_FLOWS } from './helpTour.js';
export default class KeyboardClient extends HudesClient {
- constructor(addr, port) {
- super(addr, port);
+ constructor(addr, port, options = {}) {
+ super(addr, port, options);
+ this.altKeys = Boolean(options.altKeys);
this.pairedKeys = [];
this.keyToParamAndSign = {};
+ this.activeRepeats = new Map();
+ this.keyRepeatIntervalMs = options.repeatIntervalMs ?? 100;
+ this.keyRepeatDelayMs = options.repeatDelayMs ?? 100;
+ this._handleKeyUpOverride = this._handleKeyUpOverride.bind(this);
this.initInput();
- if (this.state.helpScreenIdx != -1) {
- this.view.showImage(this.state.helpScreenFns[this.state.helpScreenIdx]);
- }
+ // Replace base key up handler with extended behaviour.
+ window.removeEventListener('keyup', this._handleKeyUp);
+ window.addEventListener('keyup', this._handleKeyUpOverride);
}
initInput() {
- this.pairedKeys = [
- ['w', 's'],
- ['e', 'd'],
- ['r', 'f'],
- ['u', 'j'],
- ['i', 'k'],
- ['o', 'l'],
- ];
+ const useAltLayout = Boolean(this.altKeys);
+ if (this.debug) {
+ log(`[KeyboardClient] initInput altKeys=${useAltLayout}`);
+ }
+ this.pairedKeys = useAltLayout
+ ? [
+ ['u', 'w'],
+ ['i', 'e'],
+ ['o', 'r'],
+ ['j', 's'],
+ ['k', 'd'],
+ ['l', 'f'],
+ ]
+ : [
+ ['w', 's'],
+ ['e', 'd'],
+ ['r', 'f'],
+ ['u', 'j'],
+ ['i', 'k'],
+ ['o', 'l'],
+ ];
this.setN(this.pairedKeys.length);
// Map paired keys to actions
+ const firstKeySign = 1;
+
this.pairedKeys.forEach(([upKey, downKey], index) => {
this.addKeyToWatch(upKey);
this.addKeyToWatch(downKey);
- this.keyToParamAndSign[upKey] = { dim: index, sign: 1 };
- this.keyToParamAndSign[downKey] = { dim: index, sign: -1 };
+ this.keyToParamAndSign[upKey] = { dim: index, sign: firstKeySign };
+ this.keyToParamAndSign[downKey] = { dim: index, sign: -firstKeySign };
});
- this.state.setBatchSize(256);
+ const defaultBatchSize =
+ this.renderMode === '1d' ? 512 : this.isMobile ? 32 : 256;
+ this.state.setBatchSize(defaultBatchSize);
this.state.setDtype('float16');
- this.state.setHelpScreenFns([
- 'help_screens/hudes_help_start.png',
- 'help_screens/hudes_1.png',
- 'help_screens/hudes_2.png',
- 'help_screens/hudes_3.png',
- 'help_screens/hudes_1d_keyboard_controls.png',
- ]);
+ }
+ _handleKeyUpOverride(event) {
+ this._stopRepeat(event.key?.toLowerCase?.());
+ super._handleKeyUp(event);
}
usageStr() {
@@ -73,29 +93,122 @@ GOOD LUCK!
}
processKeyPress(event) {
- const currentTime = performance.now(); // High-precision timing
- let redraw = this.processCommonKeys(event);
+ const keyLower = event.key?.toLowerCase?.();
+ const mapping = keyLower ? this.keyToParamAndSign[keyLower] : undefined;
+ const isHelpActive = this.state.helpScreenIdx !== -1;
+ if (event.metaKey || event.ctrlKey) {
+ return this.processCommonKeys(event);
+ }
- if (this.state.helpScreenIdx !== -1) {
+ if (mapping && !isHelpActive) {
+ if (event.type === 'keydown') {
+ if (!event.repeat) {
+ this._applyDimStep(mapping.dim, mapping.sign);
+ this._startDimRepeat(keyLower, mapping.dim, mapping.sign);
+ }
+ event.preventDefault();
+ return true;
+ }
+ if (event.type === 'keyup') {
+ this._stopDimRepeat(keyLower);
+ event.preventDefault();
+ return true;
+ }
+ }
+
+ const redraw = this.processCommonKeys(event);
+
+ if (isHelpActive) {
this.view.showImage(this.state.helpScreenFns[this.state.helpScreenIdx]);
- return redraw; // Skip further processing if help screen is active
+ } else {
+ this.view.hideImage();
}
- this.view.hideImage();
- if (event.type === 'keydown') {
- const key = event.key.toLowerCase(); // Normalize to lowercase
+ return redraw;
+ }
- if (this.keyToParamAndSign[key]) {
- const { dim, sign } = this.keyToParamAndSign[key];
- if (
- this.checkCooldownTime(event, key, currentTime, this.cooldownTimeMs / 1000)
- ) {
- this.sendDimsAndSteps({ [dim]: this.state.stepSize * sign });
- }
+ _applyDimStep(dim, sign) {
+ if (this.state.helpScreenIdx !== -1) {
+ return;
+ }
+ const delta = this.state.stepSize * sign;
+ if (this.debug) {
+ const before = Array.isArray(this.dimsAndStepsOnCurrentDims)
+ ? [...this.dimsAndStepsOnCurrentDims]
+ : null;
+ log(
+ `[KeyboardClient] applyDimStep dim=${dim} sign=${sign} stepSize=${this.state.stepSize}`,
+ );
+ if (before) {
+ log(`[KeyboardClient] dims before step: ${before.join(', ')}`);
}
}
+ this.sendDimsAndSteps({ [dim]: delta });
+ this._notifyTutorialStep?.('move');
+ if (this.debug) {
+ const after = Array.isArray(this.dimsAndStepsOnCurrentDims)
+ ? [...this.dimsAndStepsOnCurrentDims]
+ : null;
+ if (after) {
+ log(`[KeyboardClient] dims after step: ${after.join(', ')}`);
+ }
+ }
+ this.view.highlightLossLine?.(dim, this.keyRepeatIntervalMs * 2);
+ }
- return redraw;
+ _startDimRepeat(key, dim, sign) {
+ this._startRepeat(key, () => this._applyDimStep(dim, sign));
+ }
+
+ _startRepeat(key, action) {
+ if (!key || typeof action !== 'function') {
+ return;
+ }
+ this._stopRepeat(key);
+ const entry = { timeout: null, interval: null };
+ const fire = () => {
+ if (this.state.helpScreenIdx !== -1) {
+ return;
+ }
+ if (this.debug) {
+ log(`[KeyboardClient] repeat fire key=${key}`);
+ }
+ action();
+ };
+
+ if (this.debug) {
+ log(
+ `[KeyboardClient] start repeat key=${key} delay=${this.keyRepeatDelayMs} interval=${this.keyRepeatIntervalMs}`,
+ );
+ }
+ entry.timeout = setTimeout(() => {
+ fire();
+ entry.interval = setInterval(fire, this.keyRepeatIntervalMs);
+ entry.timeout = null;
+ this.activeRepeats.set(key, entry);
+ }, this.keyRepeatDelayMs);
+
+ this.activeRepeats.set(key, entry);
+ }
+
+ _stopRepeat(key) {
+ if (!key) {
+ return;
+ }
+ const entry = this.activeRepeats.get(key);
+ if (!entry) {
+ return;
+ }
+ if (this.debug) {
+ log(`[KeyboardClient] stop repeat key=${key}`);
+ }
+ if (entry.timeout) {
+ clearTimeout(entry.timeout);
+ }
+ if (entry.interval) {
+ clearInterval(entry.interval);
+ }
+ this.activeRepeats.delete(key);
}
}
diff --git a/web/client/KeyboardClientGL.js b/web/client/KeyboardClientGL.js
index 2823a59..78d50d9 100644
--- a/web/client/KeyboardClientGL.js
+++ b/web/client/KeyboardClientGL.js
@@ -1,7 +1,8 @@
import KeyboardClient from './KeyboardClient.js';
import { log } from '../utils/logger.js';
-import View from './View.js';
import { installMouseControls, computeStepVector } from './mouseControls.js';
+import { installTouchControls } from './touchControls.js';
+import { TOUR_FLOWS } from './helpTour.js';
const DEBUG_MOUSE = false;
@@ -12,13 +13,15 @@ const debugMouse = (message) => {
};
export default class KeyboardClientGL extends KeyboardClient {
- constructor(addr, port) {
- super(addr, port);
+ constructor(addr, port, options = {}) {
+ const mergedOptions = { ...options, renderMode: '3d' };
+ if (options.isMobile) {
+ mergedOptions.meshGrids = 1;
+ }
+ super(addr, port, mergedOptions);
+ this.isMobile = Boolean(options.isMobile);
this.initInput();
- if (this.state.helpScreenIdx != -1) {
- this.view.showImage(this.state.helpScreenFns[this.state.helpScreenIdx]);
- }
}
initInput() {
@@ -34,24 +37,41 @@ export default class KeyboardClientGL extends KeyboardClient {
this.stepSizeKeyboardMultiplier = 2.5;
this.lastSelectPress = 0;
this.keyCooldownMs = 200;
+ this.directionalKeys = new Set(['w', 's', 'a', 'd']);
+ this.angleKeys = new Set(['arrowleft', 'arrowright', 'arrowup', 'arrowdown']);
- this.dragSensitivity = 0.3;
+ this.dragSensitivity = 0.18;
+ this.verticalTiltDragFraction = 0.75; // portion of window height mapping to full tilt range
this._mouseControls = null;
+ this._touchControls = null;
+ this._touchRotateCleanup = null;
+ this._boundTouchRotatePageHide = null;
+ this._touchRotateCleanup = null;
this.view.resetAngle(); // Set initial angles
const canvas = this.view.getCanvasElement?.();
if (canvas) {
- debugMouse('[KeyboardClientGL] installing mouse controls');
- this._mouseControls = installMouseControls(canvas, {
- moveTarget: typeof window !== 'undefined' ? window : canvas,
- onDrag: ({ deltaX, deltaY }) => {
+ if (this.isMobile) {
+ this._touchControls = installTouchControls(canvas, {
+ onVector: (vector) => this._applyTouchVector(vector),
+ });
+ this.__applyTouchVector = (vector) => this._applyTouchVector(vector);
+ this._installMobileUi();
+ this._installTouchRotationControls(canvas);
+ this._installTouchRotationControls(canvas);
+ } else {
+ debugMouse('[KeyboardClientGL] installing mouse controls');
+ this._mouseControls = installMouseControls(canvas, {
+ moveTarget: typeof window !== 'undefined' ? window : canvas,
+ onDrag: ({ deltaX, deltaY }) => {
if (this.state.helpScreenIdx !== -1) {
debugMouse('[KeyboardClientGL] drag ignored while help screen active');
return;
}
const horizontal = deltaX * this.dragSensitivity;
- const vertical = -deltaY * this.dragSensitivity;
+ const verticalSensitivity = this._getVerticalDragSensitivity();
+ const vertical = -deltaY * verticalSensitivity;
const before = this.view.getAngles();
debugMouse(
@@ -64,6 +84,7 @@ export default class KeyboardClientGL extends KeyboardClient {
if (vertical) {
this.view.adjustAngles(0, vertical);
}
+ this._notifyTutorialStep?.('rotate');
const after = this.view.getAngles();
debugMouse(
@@ -108,8 +129,9 @@ export default class KeyboardClientGL extends KeyboardClient {
this.view.updateMeshGrids();
event.preventDefault?.();
},
- });
- debugMouse('[KeyboardClientGL] mouse controls installed');
+ });
+ debugMouse('[KeyboardClientGL] mouse controls installed');
+ }
} else {
log('[KeyboardClientGL] renderer canvas missing; mouse controls disabled');
}
@@ -118,21 +140,6 @@ export default class KeyboardClientGL extends KeyboardClient {
//window.addEventListener('keydown', (event) => this.processKeyPress(event));
//window.addEventListener('keyup', (event) => this.updateKeyHolds());
- this.state.setHelpScreenFns([
- "help_screens/hudes_help_start.png",
- "help_screens/hudes_1.png",
- "help_screens/hudes_2.png",
- "help_screens/hudes_3.png",
- "help_screens/hudes_4.png",
- "help_screens/hudes_5.png",
- "help_screens/hudes_6.png",
- "help_screens/hudes_7.png",
- "help_screens/hudes_8.png",
- "help_screens/hudes_9.png",
- "help_screens/hudes_2d_keyboard_controls.png",
- "help_screens/hudes_2d_xbox_controls.png",
- ]);
-
}
// Step in the selected grid with given s0 and s1 values
@@ -168,65 +175,100 @@ export default class KeyboardClientGL extends KeyboardClient {
};
}
+ _performDirectionalStep(baseX, baseY) {
+ if (this.state.helpScreenIdx !== -1) {
+ return;
+ }
+ const { angleH } = this.view.getAngles();
+ const rotated = this._rotateVector(baseX, baseY, -angleH);
+ this.stepInSelectedGrid(rotated.x, rotated.y);
+ this._notifyTutorialStep?.('move');
+ }
+
+ _startDirectionalRepeat(key, baseX, baseY) {
+ this._startRepeat(key, () => this._performDirectionalStep(baseX, baseY));
+ }
+
+ _performAngleAdjust(deltaH, deltaV) {
+ if (this.state.helpScreenIdx !== -1) {
+ return;
+ }
+ this.view.adjustAngles(deltaH, deltaV);
+ this._notifyTutorialStep?.('rotate');
+ }
+
+ _startAngleRepeat(key, deltaH, deltaV) {
+ this._startRepeat(key, () => this._performAngleAdjust(deltaH, deltaV));
+ }
+
processKeyPress(event) {
+ if (event.metaKey || event.ctrlKey) {
+ return this.processCommonKeys(event);
+ }
const currentTime = performance.now();
let redraw = this.processCommonKeys(event);
+ const manualOverlayVisible = this.view?.isManualKeyOverlayVisible?.();
if (this.state.helpScreenIdx !== -1) {
this.view.showImage(this.state.helpScreenFns[this.state.helpScreenIdx]);
return redraw; // Skip further processing if help screen is active
- }
- this.view.hideImage();
-
+ }
+ if (manualOverlayVisible) {
+ return redraw;
+ }
+ this.view.hideImage();
if (event.type === 'keydown') {
const key = event.key.toLowerCase();
- const { angleH } = this.view.getAngles(); // Get the current horizontal and vertical angles
+ if ((this.directionalKeys.has(key) || this.angleKeys.has(key)) && event.repeat) {
+ event.preventDefault?.();
+ return true;
+ }
switch (event.code) {
case 'KeyW': {
- // Rotate vector (0, -1) by angleH
- const rotated = this._rotateVector( -1,0, -angleH);
- this.stepInSelectedGrid(rotated.x, rotated.y);
+ this._performDirectionalStep(-1, 0);
+ this._startDirectionalRepeat(key, -1, 0);
redraw = true;
break;
}
case 'KeyS': {
- // Rotate vector (0, 1) by angleH
- const rotated = this._rotateVector(1,0, -angleH);
- this.stepInSelectedGrid(rotated.x, rotated.y);
+ this._performDirectionalStep(1, 0);
+ this._startDirectionalRepeat(key, 1, 0);
redraw = true;
break;
}
case 'KeyA': {
- // Rotate vector (-1, 0) by angleH
- const rotated = this._rotateVector(0,-1, -angleH);
- this.stepInSelectedGrid(rotated.x, rotated.y);
+ this._performDirectionalStep(0, -1);
+ this._startDirectionalRepeat(key, 0, -1);
redraw = true;
break;
}
case 'KeyD': {
- // Rotate vector (1, 0) by angleH
- const rotated = this._rotateVector(0,1,-angleH);
- this.stepInSelectedGrid(rotated.x, rotated.y);
+ this._performDirectionalStep(0, 1);
+ this._startDirectionalRepeat(key, 0, 1);
redraw = true;
break;
}
case 'ArrowLeft':
- this.view.adjustAngles(-5, 0);
+ this._performAngleAdjust(-5, 0);
+ this._startAngleRepeat(key, -5, 0);
redraw = true;
break;
case 'ArrowRight':
- this.view.adjustAngles(5, 0);
+ this._performAngleAdjust(5, 0);
+ this._startAngleRepeat(key, 5, 0);
redraw = true;
break;
case 'ArrowUp':
- this.view.adjustAngles(0, 2.5);
+ this._performAngleAdjust(0, 2.5);
+ this._startAngleRepeat(key, 0, 2.5);
redraw = true;
break;
case 'ArrowDown':
- this.view.adjustAngles(0, -2.5);
+ this._performAngleAdjust(0, -2.5);
+ this._startAngleRepeat(key, 0, -2.5);
redraw = true;
break;
case 'ShiftRight':
@@ -267,7 +309,329 @@ export default class KeyboardClientGL extends KeyboardClient {
return redraw;
}
+ _getVerticalDragSensitivity() {
+ const impl = this.view?.impl;
+ const maxAngleV = typeof impl?.maxAngleV === 'number' ? impl.maxAngleV : 25;
+ const height = typeof window !== 'undefined' && window.innerHeight > 0 ? window.innerHeight : 800;
+ const dragSpan = Math.max(1, height * (this.verticalTiltDragFraction ?? 0.5));
+ // adjustAngles multiplies deltas by 2, so use half the desired slope here
+ return (2 * maxAngleV) / dragSpan;
+ }
+
+ _applyTouchVector(vector) {
+ this._updateJoystickVisual(vector);
+ if (!vector || this.state.helpScreenIdx !== -1) {
+ return;
+ }
+ const rawX = Number(vector.x) || 0;
+ const rawY = Number(vector.y) || 0;
+ if (!rawX && !rawY) {
+ return;
+ }
+ const clamp = (v) => Math.max(-1, Math.min(1, v));
+ const baseX = clamp(-rawY);
+ const baseY = clamp(-rawX);
+ const magnitude = Math.min(1, Math.hypot(baseX, baseY));
+ if (magnitude < 0.05) {
+ return;
+ }
+ const { angleH } = this.view.getAngles();
+ const rotated = this._rotateVector(baseX, baseY, -angleH);
+ this.stepInSelectedGrid(-rotated.x, -rotated.y);
+ this._notifyTutorialStep?.('move');
+ }
+
+ _installTouchRotationControls(canvas) {
+ if (!canvas || typeof window === 'undefined') {
+ return;
+ }
+ this._cleanupTouchRotation();
+ const state = { active: false, prevX: 0, prevY: 0 };
+ const getMidpoint = (touchList) => {
+ if (!touchList || touchList.length < 2) {
+ return null;
+ }
+ const t1 = touchList[0];
+ const t2 = touchList[1];
+ return {
+ x: (t1.clientX + t2.clientX) / 2,
+ y: (t1.clientY + t2.clientY) / 2,
+ };
+ };
+ const handleStart = (event) => {
+ if ((event.touches?.length || 0) >= 2) {
+ const mid = getMidpoint(event.touches);
+ if (mid) {
+ state.active = true;
+ state.prevX = mid.x;
+ state.prevY = mid.y;
+ event.preventDefault?.();
+ }
+ }
+ };
+ const handleMove = (event) => {
+ if (!state.active) {
+ return;
+ }
+ if ((event.touches?.length || 0) < 2) {
+ state.active = false;
+ return;
+ }
+ const mid = getMidpoint(event.touches);
+ if (!mid) {
+ return;
+ }
+ const dx = mid.x - state.prevX;
+ const dy = mid.y - state.prevY;
+ state.prevX = mid.x;
+ state.prevY = mid.y;
+ if (dx || dy) {
+ this._handleTouchRotationDrag(dx, dy);
+ }
+ event.preventDefault?.();
+ };
+ const endGesture = () => {
+ state.active = false;
+ };
+ const handleEnd = () => {
+ endGesture();
+ };
+ const handleCancel = () => {
+ endGesture();
+ };
+ canvas.addEventListener('touchstart', handleStart, { passive: false });
+ canvas.addEventListener('touchmove', handleMove, { passive: false });
+ canvas.addEventListener('touchend', handleEnd, { passive: false });
+ canvas.addEventListener('touchcancel', handleCancel, { passive: false });
+ this._touchRotateCleanup = () => {
+ canvas.removeEventListener('touchstart', handleStart);
+ canvas.removeEventListener('touchmove', handleMove);
+ canvas.removeEventListener('touchend', handleEnd);
+ canvas.removeEventListener('touchcancel', handleCancel);
+ };
+ if (typeof window !== 'undefined' && !this._boundTouchRotatePageHide) {
+ this._boundTouchRotatePageHide = () => this._cleanupTouchRotation();
+ window.addEventListener('pagehide', this._boundTouchRotatePageHide, { passive: true });
+ }
+ }
+
+ _handleTouchRotationDrag(deltaX, deltaY) {
+ if (this.state.helpScreenIdx !== -1) {
+ return;
+ }
+ const horizontal = deltaX * this.dragSensitivity;
+ const verticalSensitivity = this._getVerticalDragSensitivity();
+ const vertical = -deltaY * verticalSensitivity;
+ if (horizontal) {
+ this.view.adjustAngles(horizontal, 0);
+ }
+ if (vertical) {
+ this.view.adjustAngles(0, vertical);
+ }
+ if (horizontal || vertical) {
+ this._notifyTutorialStep?.('rotate');
+ }
+ }
+
+ _cleanupTouchRotation() {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (this._touchRotateCleanup) {
+ try {
+ this._touchRotateCleanup();
+ } catch {}
+ this._touchRotateCleanup = null;
+ }
+ if (this._boundTouchRotatePageHide) {
+ window.removeEventListener('pagehide', this._boundTouchRotatePageHide);
+ this._boundTouchRotatePageHide = null;
+ }
+ }
+
updateKeyHolds() {
// Handle key up events here if needed
}
+
+ _installMobileUi() {
+ if (this._mobileUi) {
+ return;
+ }
+ const panel = document.createElement('div');
+ panel.id = 'mobileActionPanel';
+ const grid = document.createElement('div');
+ grid.className = 'mobile-panel__grid';
+ panel.appendChild(grid);
+
+ const buttons = {};
+ const addButton = (key, handler) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.dataset.mobileAction = key;
+ btn.addEventListener('click', handler);
+ grid.appendChild(btn);
+ buttons[key] = btn;
+ return btn;
+ };
+
+ addButton('speed', () => {
+ if (this.speedRunActive || this.state.speedRunActive) {
+ this.cancelSpeedRun();
+ } else {
+ this.startSpeedRun();
+ }
+ this._updateMobileUi();
+ });
+
+ addButton('top10', () => {
+ try {
+ this.requestLeaderboard?.();
+ } catch {}
+ });
+
+ addButton('batchCycle', () => {
+ this.state.toggleBatchSize();
+ this.sendConfig();
+ this._updateMobileUi();
+ });
+
+ addButton('sgd', () => {
+ this.getSGDStep();
+ });
+
+ addButton('stepMinus', () => {
+ this.state.decreaseStepSize(this.stepSizeMultiplier || 1);
+ this.sendConfig();
+ this._updateMobileUi();
+ });
+
+ addButton('stepPlus', () => {
+ this.state.increaseStepSize(this.stepSizeMultiplier || 1);
+ this.sendConfig();
+ this._updateMobileUi();
+ });
+
+ addButton('fp', () => {
+ this.state.toggleDtype();
+ this.sendConfig();
+ this._updateMobileUi();
+ });
+
+ addButton('dims', () => {
+ this.getNextDims();
+ });
+
+ addButton('batchNew', () => {
+ this.getNextBatch();
+ });
+
+ const matrixContainer = document.getElementById('confusionMatrixChart');
+ if (matrixContainer?.parentElement) {
+ matrixContainer.parentElement.appendChild(panel);
+ } else {
+ document.body.appendChild(panel);
+ }
+ this._installMobileJoystick();
+ this._mobileUi = { panel, buttons };
+ this._updateMobileUi();
+ this._mobileUiTicker = setInterval(() => this._updateMobileUi(), 800);
+ }
+ _installMobileJoystick() {
+ const existing = document.getElementById('joystickPad');
+ if (existing) {
+ this._joystickPad = existing;
+ this._joystickThumb = existing.querySelector('#joystickThumb');
+ this._joystickHint = existing.querySelector('.joystick-hint');
+ return;
+ }
+ const pad = document.createElement('div');
+ pad.id = 'joystickPad';
+ const thumb = document.createElement('div');
+ thumb.id = 'joystickThumb';
+ const hint = document.createElement('p');
+ hint.className = 'joystick-hint';
+ hint.textContent = 'Drag to move';
+ pad.append(thumb, hint);
+ document.body.appendChild(pad);
+ this._joystickPad = pad;
+ this._joystickThumb = thumb;
+ this._joystickHint = hint;
+ }
+
+ _cycleHelpScreens() {
+ this.state.nextHelpScreen();
+ if (this.state.helpScreenIdx !== -1) {
+ const idx = Math.min(this.state.helpScreenIdx, (this.state.helpScreenFns?.length ?? 1) - 1);
+ const fn = this.state.helpScreenFns?.[idx];
+ if (fn) {
+ this.view.showImage(fn);
+ }
+ } else {
+ this.view.hideImage();
+ }
+ }
+
+ _updateMobileUi() {
+ if (!this._mobileUi) {
+ return;
+ }
+ const { buttons } = this._mobileUi;
+ const setText = (btn, text) => {
+ if (!btn || btn.textContent === text) {
+ return;
+ }
+ btn.textContent = text;
+ };
+ if (buttons.speed) {
+ setText(
+ buttons.speed,
+ this.state.speedRunActive
+ ? `🔥 ${Number(this.state.speedRunSecondsRemaining ?? 0).toFixed(1)}s`
+ : 'SPEED 🔥'
+ );
+ }
+ if (buttons.top10) {
+ setText(buttons.top10, 'TOP 10');
+ }
+ if (buttons.batchCycle) {
+ setText(buttons.batchCycle, `Batch ${this.state.batchSize ?? ''}`);
+ }
+ if (buttons.stepMinus) {
+ setText(buttons.stepMinus, 'STEP -');
+ }
+ if (buttons.stepPlus) {
+ setText(buttons.stepPlus, 'STEP +');
+ }
+ if (buttons.fp) {
+ const dtype = (this.state?.dtype || '').replace('float', '').toUpperCase();
+ setText(buttons.fp, dtype ? `FP${dtype}` : 'FP');
+ }
+ if (buttons.dims) {
+ setText(buttons.dims, 'DIMS');
+ }
+ if (buttons.batchNew) {
+ setText(buttons.batchNew, 'BATCH');
+ }
+ if (buttons.sgd) {
+ setText(buttons.sgd, 'SGD');
+ buttons.sgd.disabled = Boolean(this.state.speedRunActive);
+ }
+ }
+
+ _updateJoystickVisual(vector = { x: 0, y: 0 }) {
+ if (!this._joystickPad || !this._joystickThumb) {
+ return;
+ }
+ const radius = this._joystickPad.clientWidth / 2;
+ const scale = radius * 0.6;
+ const clamp = (value) => Math.max(-1, Math.min(1, value || 0));
+ const dx = clamp(vector.x) * scale;
+ const dy = clamp(vector.y) * scale;
+ this._joystickThumb.style.transform = `translate(${dx}px, ${dy}px)`;
+ if (this._joystickHint) {
+ const moving = Math.abs(dx) > 1 || Math.abs(dy) > 1;
+ this._joystickHint.classList.toggle('hidden', moving);
+ }
+ }
+
}
diff --git a/web/client/ProtoLoader.js b/web/client/ProtoLoader.js
index 689fcf8..9a3c46c 100644
--- a/web/client/ProtoLoader.js
+++ b/web/client/ProtoLoader.js
@@ -9,7 +9,7 @@ export async function loadProto(protoPath) {
// Prevent stale caching by appending a build id query when available
const url = BUILD_ID ? `${protoPath}?v=${encodeURIComponent(BUILD_ID)}` : protoPath;
const root = await protobuf.load(url);
- const Control = root.lookupType('hudes.Control');
- const ControlType = root.lookupEnum('hudes.Control.Type');
- return { Control, ControlType };
- }
+ const Control = root.lookupType('hudes.Control');
+ const ControlType = root.lookupEnum('hudes.Control.Type');
+ return { Control, ControlType, root };
+}
diff --git a/web/client/View.js b/web/client/View.js
index 34294f7..687e26d 100644
--- a/web/client/View.js
+++ b/web/client/View.js
@@ -1,930 +1,115 @@
-import {
- Scene,
- PerspectiveCamera,
- MathUtils,
- WebGLRenderer,
- Color,
- PlaneGeometry,
- BufferAttribute,
- ShaderMaterial,
- Mesh,
- SphereGeometry,
- MeshBasicMaterial,
- Raycaster,
- Vector2,
-} from 'three';
-import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // Import OrbitControls
-import annotationPlugin from 'chartjs-plugin-annotation';
-import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; // Import CSS2DRenderer for annotations
-import { CONTROL_GROUPS, formatHudMarkup } from './hud.js';
-import { log } from '../utils/logger.js'; // Import your logging utility
+import LandscapeView from './views/LandscapeView.js';
-const DEBUG_SELECTION = false;
-
-const debugSelection = (message) => {
- if (DEBUG_SELECTION) {
- log(message);
+export default class ViewRouter {
+ constructor(gridSize, numGrids, clientState, options = {}) {
+ const mode = (options.mode || '3d').toLowerCase();
+ this.mode = mode === '1d' ? '1d' : '3d';
+ const viewOptions = { ...options, mode: this.mode };
+ this.impl = new LandscapeView(gridSize, numGrids, clientState, viewOptions);
}
-};
-
-//import Plotly from 'plotly.js-dist-min'; // Import Plotly
-import {
- Chart,
- CategoryScale, // Register the category scale
- LinearScale,
- PointElement,
- LineElement,
- BarElement,
- LineController,
- BarController,
- Title,
- Tooltip,
- Legend
-} from 'chart.js';
-
-import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
-
-// Register all necessary components
-Chart.register(
- CategoryScale,
- LinearScale,
- LineController,
- BarController,
- MatrixController, // Register MatrixController
- MatrixElement, // Register MatrixElement
- PointElement,
- LineElement,
- BarElement,
- Title,
- Tooltip,
- Legend,
- annotationPlugin
-);
-
-
-export default class View {
- constructor(gridSize, numGrids, clientState) {
- this.gridSize = gridSize;
- this.numGrids = numGrids;
- this.effectiveGrids = numGrids;
-
- this.state=clientState;
- this.lossChart = null;
- this.lastStepsChart = null;
- this.stepSizeChart = null;
- this.dimsAndStepsChart = null;
- this.exampleImagesContainer = document.getElementById('exampleImages');
- this.confusionMatrixContainer = document.getElementById('confusionMatrixChart');
- this.helpOverlay = null;
- this.helpImageEl = null;
-
-
- // Grid properties
- this.gridObjects = [];
- this.spacing = 20; // Spacing between grids
- this.selectedGridIndex = 0; // Index of the selected grid
- this.selectedGridScale = 2 * 1.5; //1.5; // Scale multiplier for the selected grid
- // Three.js setup
- const glContainer = document.getElementById('glContainer');
- if (glContainer) {
- this.scene = new Scene();
- this.camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
- this.renderer = new WebGLRenderer({ antialias: true });
- this.renderer.setSize(window.innerWidth, window.innerHeight);
- this.renderer.domElement.style.position = 'absolute';
- this.renderer.domElement.style.top = '0';
- this.renderer.domElement.style.left = '0';
- this.renderer.domElement.style.width = '100%';
- this.renderer.domElement.style.height = '100%';
- this.renderer.domElement.style.pointerEvents = 'auto';
- glContainer.appendChild(this.renderer.domElement);
- // Calculate camera position to fit all grids
- const fovRadians = (this.camera.fov * Math.PI) / 180;
- const totalWidth = (this.numGrids + this.selectedGridScale - 1.0) * this.gridSize + (this.numGrids - 1) * this.spacing;
- this.camera_distance = (totalWidth / 2) / Math.tan(fovRadians / 2);
-
- this.raycaster = new Raycaster();
- this.pointer = new Vector2();
-
- // Set the y-offset for the camera to position the grids 2/3 of the way down the view
- // The y offset is calculated as (1/3 of the totalHeight) since we want the grids to be 2/3 of the way down
- this.camera_yOffset = (totalWidth / 8);
-
- // Update camera position
- this.camera.position.set(0, this.camera_yOffset, this.camera_distance);
- // Orbit controls setup
- //this.controls = new OrbitControls(this.camera, this.renderer.domElement);
-
- // CSS2DRenderer setup for annotations
- this.labelRenderer = new CSS2DRenderer();
- this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
- this.labelRenderer.domElement.style.position = 'absolute';
- this.labelRenderer.domElement.style.top = '0px';
- this.labelRenderer.domElement.style.pointerEvents = 'none';
- document.body.appendChild(this.labelRenderer.domElement);
-
- // Colors for the grids
- this.gridColors = [
- new Color(0.0, 1.0, 1.0), // Cyan
- new Color(1.0, 0.0, 1.0), // Magenta
- new Color(1.0, 1.0, 0.0), // Yellow
- new Color(0.0, 1.0, 0.0), // Green
- new Color(1.0, 0.5, 0.0), // Orange
- new Color(1.0, 0.0, 0.0), // Red
- new Color(0.0, 0.0, 1.0), // Blue
- new Color(0.5, 0.0, 1.0), // Purple
- new Color(0.0, 0.5, 1.0), // Sky Blue
- new Color(1.0, 0.0, 0.5), // Pink
- new Color(0.5, 1.0, 0.0), // Lime
- new Color(1.0, 0.75, 0.8), // Light Pink
- ];
-
- this.alpha = 0.8; // Transparency
-
- // Angle properties
- this.defaultAngleV = 20;
- this.maxAngleV = 25;
- this.angleH = 0.0;
- this.angleV = this.defaultAngleV;
- // Start rendering loop
- this.render();
- window.addEventListener('resize', this.onWindowResize.bind(this), false);
- }
-
-
- }
- annotateBottomScreen(text, size = 20) {
- const bottomTextContainer = document.getElementById('bottomTextContainer');
- if (bottomTextContainer) {
- bottomTextContainer.innerHTML = formatHudMarkup(
- text ?? '',
- CONTROL_GROUPS,
- );
- }
+ initializeCharts(...args) {
+ return this.impl?.initializeCharts?.(...args);
}
- // Function to handle window resizing
- onWindowResize() {
-
- // Calculate camera position to fit all grids
- const fovRadians = (this.camera.fov * Math.PI) / 180;
- const totalWidth = (this.numGrids + this.selectedGridScale - 1.0) * this.gridSize + (this.numGrids - 1) * this.spacing;
- const distance = (totalWidth / 2) / Math.tan(fovRadians / 2);
- this.camera.position.set(0, 0, distance);
- // Update camera aspect ratio and projection matrix
- this.camera.aspect = window.innerWidth / window.innerHeight;
- this.camera.updateProjectionMatrix();
-
- // Update renderer size
- this.renderer.setSize(window.innerWidth, window.innerHeight);
- this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
+ updateLossChart(...args) {
+ return this.impl?.updateLossChart?.(...args);
}
- // Function to adjust angles
- adjustAngles(angleH, angleV) {
- this.angleH += 2 * angleH;
- this.angleV += 2 * angleV;
- //this.angleV = this.normalizeDegree(this.angleV);
- this.angleH = this.normalizeDegree(this.angleH);
- this.angleV = Math.sign(this.angleV) * Math.min(Math.abs(this.angleV), this.maxAngleV);
+ updateLastStepsChart(...args) {
+ return this.impl?.updateLastStepsChart?.(...args);
}
- // Function to reset angles
- resetAngle() {
- this.angleH = 0.0;
- this.angleV = this.defaultAngleV;
+ updateConfusionMatrix(...args) {
+ return this.impl?.updateConfusionMatrix?.(...args);
}
- // Helper function to normalize angles between 0 and 360 degrees
- normalizeDegree(angle) {
- return ((angle % 360) + 360) % 360;
+ updateExamplePreds(...args) {
+ return this.impl?.updateExamplePreds?.(...args);
}
-
-
-
- // Initialize all charts (including Plotly-based heatmap)
- initializeCharts() {
- this.initializeLossChart();
- this.initializeLastStepsChart();
- this.initializeStepSizeChart();
- this.initializeDimsAndStepsChart();
- this.initializeConfusionMatrixHeatmap();
- log("Charts initialized.");
+ updateExamples(...args) {
+ return this.impl?.updateExamples?.(...args);
}
- // Optional: Initialize Confusion Matrix as an empty Heatmap
- initializeConfusionMatrixHeatmap() {
- if (this.confusionMatrixContainer) {
- const initialData = [
- {
- z: [], // Initialize with empty data
- type: 'heatmap',
- colorscale: 'Blues',
- zmin: 0,
- zmax: 255,
- },
- ];
-
- const layout = {
- title: 'Confusion Matrix Heatmap',
- xaxis: {
- title: 'Predicted Label',
- tickmode: 'linear',
- },
- yaxis: {
- title: 'Actual Label',
- tickmode: 'linear',
- autorange: 'reversed', // Reverse y-axis to match visualization expectations
- },
- responsive: true,
- };
- Plotly.newPlot(this.confusionMatrixContainer, initialData, layout);
- }
+ updateMeshGrids(...args) {
+ return this.impl?.updateMeshGrids?.(...args);
}
- initializeLossChart() {
- if (document.getElementById('lossChart')) {
- this.lossChart = new Chart(document.getElementById('lossChart'), {
- type: 'line',
- data: {
- labels: [], // Step numbers
- datasets: [
- { label: 'Train Loss', data: [], borderColor: 'blue', fill: false },
- { label: 'Validation Loss', data: [], borderColor: 'red', fill: false },
- ],
- },
- options: {
- responsive: true,
- plugins: {
- legend: {
- display: true,
- position: 'top', // Ensure legend is at the top of the chart area
- labels: {
- boxWidth: 2,
- padding: 10,
- },
- },
- },
- layout: {
- padding: {
- top: 0, // Add padding to avoid overlap between legend and plot data
- },
- },
- elements: {
- point: {
- radius: 3,
- },
- },
- scales: {
- x: {
- title: {
- display: true,
- text: 'Step',
- },
- },
- y: {
- title: {
- display: true,
- text: 'Loss',
- },
- },
- },
- },
- });
- }
+ updateLossLines(...args) {
+ return this.impl?.updateLossLines?.(...args);
}
- // Initialize Last Steps Chart
- initializeLastStepsChart() {
- if (document.getElementById('lastStepsChart')) {
- this.lastStepsChart = new Chart(document.getElementById('lastStepsChart'), {
- type: 'line',
- data: {
- labels: [], // Recent steps
- datasets: [{ label: 'Loss (Last Steps)', data: [], borderColor: 'green', fill: false }],
- },
- options: {
- responsive: true,
- plugins: {
- legend: {
- display: true,
- position: 'top', // Ensure legend is at the top of the chart area
- labels: {
- boxWidth: 2,
- padding: 10,
- },
- },
- },
- layout: {
- padding: {
- top: 0, // Add padding to avoid overlap between legend and plot data
- },
- },
- elements: {
- point: {
- radius: 3,
- },
- },
- scales: {
- x: {
- title: {
- display: true,
- text: 'Step',
- },
- },
- y: {
- title: {
- display: true,
- text: 'Loss',
- },
- },
- },
- },
- });
- }
+ annotateBottomScreen(...args) {
+ return this.impl?.annotateBottomScreen?.(...args);
}
-
- // Initialize Step Size Chart
- initializeStepSizeChart() {
- if (document.getElementById('stepSizeChart')) {
- this.stepSizeChart = new Chart(document.getElementById('stepSizeChart'), {
- type: 'bar',
- data: {
- labels: ['Step Size'],
- datasets: [{ label: 'Log Step Size', data: [], backgroundColor: 'orange' }],
- },
- options: { responsive: true, indexAxis: 'y' },
- });
- }
+ showImage(...args) {
+ return this.impl?.showImage?.(...args);
}
- // Initialize Dims and Steps Chart
- initializeDimsAndStepsChart() {
- if (document.getElementById('dimsAndStepsChart')) {
- this.dimsAndStepsChart = new Chart(document.getElementById('dimsAndStepsChart'), {
- type: 'bar',
- data: {
- labels: [], // Dimensions
- datasets: [{ label: 'Cumulative Step', data: [], backgroundColor: 'teal' }],
- },
- options: { responsive: true, scales: { x: { title: { display: true, text: 'Dimension' } } } },
- });
- }
+ hideImage(...args) {
+ return this.impl?.hideImage?.(...args);
}
-
- // Update Loss Chart
- updateLossChart(trainLoss, valLoss, steps) {
- //var lastLoss = trainLoss[trainLoss.length-1];
- //this.state.updateBestScoreOrNot(lastLoss);
- this.annotateBottomScreen(this.state.toString());
- if (this.lossChart) {
- this.lossChart.data.labels = steps;
- this.lossChart.data.datasets[0].data = trainLoss;
- this.lossChart.data.datasets[1].data = valLoss;
- this.lossChart.update();
- }
+ resetAngle(...args) {
+ return this.impl?.resetAngle?.(...args);
}
- // Update Last Steps Chart
- updateLastStepsChart(lastSteps, losses) {
- if (this.lastStepsChart) {
- this.lastStepsChart.data.labels = lastSteps;
- this.lastStepsChart.data.datasets[0].data = losses;
- this.lastStepsChart.update();
- }
+ adjustAngles(...args) {
+ return this.impl?.adjustAngles?.(...args);
}
- // Update Step Size Chart
- updateStepSizeChart(logStepSize) {
- if (this.stepSizeChart) {
- this.stepSizeChart.data.datasets[0].data = [logStepSize];
- this.stepSizeChart.update();
- }
+ getAngles(...args) {
+ return this.impl?.getAngles?.(...args);
}
- // Update Dims and Steps Chart
- updateDimsAndStepsChart(dimLabels, steps) {
- if (this.dimsAndStepsChart) {
- this.dimsAndStepsChart.data.labels = dimLabels;
- this.dimsAndStepsChart.data.datasets[0].data = steps;
- this.dimsAndStepsChart.update();
- }
+ getCanvasElement(...args) {
+ return this.impl?.getCanvasElement?.(...args);
}
- // Update Confusion Matrix Heatmap with new data
- updateConfusionMatrix(confusionMatrix) {
- if (!document.getElementById('confusionMatrixChart')) {
- console.error("Element with id 'confusionMatrixChart' not found.");
- return;
- }
-
- try {
- // Validate input matrix
- if (!Array.isArray(confusionMatrix) || confusionMatrix.length === 0) {
- throw new Error("Confusion matrix data must be a non-empty array.");
- }
-
- const numRows = confusionMatrix.length;
- const numCols = Array.isArray(confusionMatrix[0]) ? confusionMatrix[0].length : 0;
-
- if (numCols === 0 || !confusionMatrix.every(row => Array.isArray(row) && row.length === numCols)) {
- throw new Error("Confusion matrix data is empty or improperly formatted.");
- }
- // Prepare data and layout for Plotly
- const zMaxValue = Math.max(...confusionMatrix.flat());
- const updatedData = [
- {
- z: confusionMatrix,
- type: 'heatmap',
- colorscale: 'Viridis', // Colorscale suitable for dark background
- zmin: 0,
- zmax: zMaxValue,
- },
- ];
-
- const updatedLayout = {
- title: {
- text: 'Confusion Matrix Heatmap',
- font: {
- color: 'white'
- }
- },
- xaxis: {
- title: {
- text: 'Predicted Label',
- font: { color: 'white' }
- },
- tickmode: 'linear',
- dtick: 1,
- tickfont: { color: 'white' },
- gridcolor: 'rgba(255, 255, 255, 0.2)', // Light grid lines for visibility
- },
- yaxis: {
- title: {
- text: 'Actual Label',
- font: { color: 'white' }
- },
- tickmode: 'linear',
- dtick: 1,
- autorange: 'reversed',
- tickfont: { color: 'white' },
- gridcolor: 'rgba(255, 255, 255, 0.2)',
- },
- margin: { t: 30, l: 1, r: 1, b: 1 }, // Small margins for compactness
- plot_bgcolor: 'rgba(0, 0, 0, 0)', // Transparent plot background
- paper_bgcolor: 'rgba(0, 0, 0, 0)', // Transparent paper background
- responsive: true,
- };
-
- // Plot or update the heatmap
- Plotly.newPlot('confusionMatrixChart', updatedData, updatedLayout);
- //console.log("Confusion matrix heatmap updated successfully.");
- } catch (error) {
- console.error("Failed to update confusion matrix heatmap:", error);
- }
+ incrementSelectedGrid(...args) {
+ return this.impl?.incrementSelectedGrid?.(...args);
}
-
- updateMeshGrids(meshGrids = null) {
- if (this.scene == null) {
- return;
- }
- // Use previous meshGrids if no parameter is provided
- if (meshGrids === null && this.previousMeshGrids) {
- meshGrids = this.previousMeshGrids;
- } else {
- this.previousMeshGrids = meshGrids; // Save current meshGrids for reuse
- }
-
- // Remove old grids and spheres if re-initializing
- if (this.gridObjects.length > 0) {
- this.gridObjects.forEach(grid => {
- this.scene.remove(grid);
- });
- this.gridObjects = [];
- }
-
- if (this.sphereObjects && this.sphereObjects.length > 0) {
- this.sphereObjects.forEach(sphere => {
- this.scene.remove(sphere);
- });
- this.sphereObjects = [];
- } else {
- this.sphereObjects = [];
- }
-
- // Subtract center value from each grid
- const originValue = meshGrids[0][Math.floor(this.gridSize / 2)][Math.floor(this.gridSize / 2)];
- for (let i = 0; i < meshGrids.length; i++) {
- for (let j = 0; j < meshGrids[i].length; j++) {
- for (let k = 0; k < meshGrids[i][j].length; k++) {
- meshGrids[i][j][k] -= originValue;
- }
- }
- }
-
- // Find the maximum absolute value for scaling
- let maxAbsValue = 0;
- meshGrids.forEach(grid => {
- grid.forEach(row => {
- row.forEach(value => {
- maxAbsValue = Math.max(maxAbsValue, Math.abs(value));
- });
- });
- });
-
- const eps = 1e-3;
- const scale = 1.5 * this.gridSize / (maxAbsValue + eps);
-
- // Scale meshGrids
- meshGrids = meshGrids.map(grid =>
- grid.map(row =>
- row.map(value => value * scale)
- )
- );
-
- // Calculate total width of all grids to properly center them
- const numGrids = meshGrids.length;
- const totalWidth = (numGrids + this.selectedGridScale - 1.0) * this.gridSize + (numGrids - 1) * this.spacing;
-
- // Create or update each grid and sphere
- for (let i = 0; i < numGrids; i++) {
- // Create new geometry for each grid
- const geometry = new PlaneGeometry(this.gridSize, this.gridSize, this.gridSize - 1, this.gridSize - 1);
- const color = this.gridColors[i % this.gridColors.length];
- let baseOpacity = 1.0;//this.alpha;
- let secondaryOpacity = baseOpacity * 0.3;
-
- // Apply lower opacity for non-selected grids
- if (i !== this.selectedGridIndex) {
- secondaryOpacity = 0.05;
- baseOpacity *= 0.4;
- } else {
- secondaryOpacity *= 0.8; // Make non-selected grids more transparent
- }
-
- // Update the geometry of the mesh to reflect the new heights and apply transparency for Z >= 0
- const positions = geometry.attributes.position.array;
- const alphas = new Float32Array(positions.length / 3); // Create an array to store alpha values for each vertex
-
- for (let j = 0; j < this.gridSize; j++) {
- for (let k = 0; k < this.gridSize; k++) {
- const index = 3 * (j * this.gridSize + k);
- const zValue = meshGrids[i][j][k];
- positions[index + 2] = zValue; // Update Z value (height) of the grid
-
- // Set alpha value lower for points where Z >= 0
- alphas[j * this.gridSize + k] = zValue >= 0 ? secondaryOpacity : baseOpacity;
- }
- }
-
- geometry.setAttribute('alpha', new BufferAttribute(alphas, 1)); // Add alpha as a vertex attribute
- geometry.attributes.position.needsUpdate = true;
-
- // Use ShaderMaterial to apply alpha for each vertex
- const material = new ShaderMaterial({
- uniforms: {
- color: { value: color },
- },
- vertexShader: `
- attribute float alpha;
- varying float vAlpha;
-
- void main() {
- vAlpha = alpha;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
- }
- `,
- fragmentShader: `
- uniform vec3 color;
- varying float vAlpha;
-
- void main() {
- gl_FragColor = vec4(color, vAlpha);
- }
- `,
- transparent: true,
- wireframe: true,
- });
-
- const mesh = new Mesh(geometry, material);
- mesh.rotation.x = -Math.PI / 2; // Rotate to lay flat
-
- // Calculate position to center grids
- let xOffset = -totalWidth / 2 + i * (this.gridSize + this.spacing) + this.gridSize / 2;
-
- // Apply scaling if it's the selected grid
- if (i > this.selectedGridIndex) {
- xOffset += this.gridSize * (this.selectedGridScale - 1); // Adjust position for scaled grid
- }
- if (i === this.selectedGridIndex) {
- xOffset += this.gridSize * (this.selectedGridScale - 1) / 2
- mesh.scale.set(this.selectedGridScale, this.selectedGridScale, 1); // Scale selected grid on X, Z axes
- } else {
- mesh.scale.set(1, 1, 1); // Reset scale for non-selected grids
- }
-
- mesh.position.set(xOffset, 0, 0);
- mesh.userData.gridIndex = i;
-
- // Add new grid to scene and store it for later reference
- this.scene.add(mesh);
- this.gridObjects.push(mesh);
-
- // Create a red sphere to represent the center point of each grid
- const sphereGeometry = new SphereGeometry(1, 16, 16); // Radius = 1, segments for smoother look
- const sphereMaterial = new MeshBasicMaterial({ color: 0xff0000 }); // Red color
- const sphere = new Mesh(sphereGeometry, sphereMaterial);
-
- // Position the sphere at the center of the current grid
- sphere.position.set(xOffset, 0, 0); // Set it on the center of the grid
-
- // Adjust Y position (height) to match the central height of the grid
- const centerHeight = meshGrids[i][Math.floor(this.gridSize / 2)][Math.floor(this.gridSize / 2)];
- sphere.position.y = centerHeight;
-
- // Add the sphere to the scene and store it for later reference
- this.scene.add(sphere);
- this.sphereObjects.push(sphere);
- }
+ decrementSelectedGrid(...args) {
+ return this.impl?.decrementSelectedGrid?.(...args);
}
-
-
-
- // Function to get current horizontal and vertical angles
- getAngles() {
- return { angleH: this.angleH, angleV: this.angleV };
- }
-
- getCanvasElement() {
- return this.renderer?.domElement ?? null;
+ getSelectedGrid(...args) {
+ return this.impl?.getSelectedGrid?.(...args);
}
- // Function to get the selected grid index
- getSelectedGrid() {
- return this.selectedGridIndex;
+ selectGridAt(...args) {
+ return this.impl?.selectGridAt?.(...args);
}
- // Function to increase zoom level
- increaseZoom() {
- this.scaleFactor = Math.max(0.7, this.scaleFactor - 0.05);
+ get modeName() {
+ return this.mode;
}
- // Function to decrease zoom level
- decreaseZoom() {
- this.scaleFactor = Math.min(5.0, this.scaleFactor + 0.05);
+ highlightLossLine(...args) {
+ return this.impl?.highlightLossLine?.(...args);
}
- // Function to increment selected grid
- incrementSelectedGrid() {
- this.selectedGridIndex = (this.selectedGridIndex + 1) % this.effectiveGrids;
+ openHelpOverlay(...args) {
+ return this.impl?.openHelpOverlay?.(...args);
}
- // Function to decrement selected grid
- decrementSelectedGrid() {
- this.selectedGridIndex = (this.selectedGridIndex - 1 + this.effectiveGrids) % this.effectiveGrids; // Adding effectiveGrids to ensure non-negative index
+ isManualKeyOverlayVisible(...args) {
+ return this.impl?.isManualKeyOverlayVisible?.(...args);
}
- // Function to set the selected grid
- setSelectedGrid(gridIdx) {
- this.selectedGridIndex = gridIdx;
+ notifyTutorialEvent(...args) {
+ return this.impl?.notifyTutorialEvent?.(...args);
}
- selectGridAt(clientX, clientY) {
- const canvas = this.getCanvasElement();
- if (!canvas || !this.camera || !this.raycaster || !this.pointer) {
- return null;
- }
-
- const rect = canvas.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) {
- return null;
- }
-
- const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
- const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1;
-
- this.pointer.set(ndcX, ndcY);
- this.raycaster.setFromCamera(this.pointer, this.camera);
-
- const intersections = this.raycaster.intersectObjects(this.gridObjects, false);
- if (!intersections.length) {
- debugSelection('[View] selectGridAt: no intersection');
- return null;
- }
-
- const target = intersections[0].object;
- const gridIndex =
- typeof target.userData?.gridIndex === 'number'
- ? target.userData.gridIndex
- : this.gridObjects.indexOf(target);
-
- if (gridIndex < 0) {
- debugSelection('[View] selectGridAt: intersection missing grid index');
- return null;
- }
-
- if (gridIndex !== this.selectedGridIndex) {
- debugSelection(`[View] selectGridAt: selecting grid ${gridIndex}`);
- this.setSelectedGrid(gridIndex);
- this.updateMeshGrids();
- } else {
- debugSelection(`[View] selectGridAt: grid ${gridIndex} already selected`);
- }
-
- return gridIndex;
+ openTutorialOverlay(...args) {
+ return this.impl?.openTutorialOverlay?.(...args);
}
- render() {
- requestAnimationFrame(() => this.render());
-
- // Rotate each grid according to the current angles
- this.gridObjects.forEach((grid, index) => {
- grid.rotation.z = MathUtils.degToRad(this.angleH); // Rotate around Z axis
- grid.rotation.x = MathUtils.degToRad(this.angleV - 90); // Rotate around X axis
- });
- // Render the scene
- this.renderer.render(this.scene, this.camera);
+ showLevelUp(...args) {
+ return this.impl?.showLevelUp?.(...args);
}
- // Update Example Images
- updateExamples(images) {
- log('Received images for update');
-
- // Select side container and get the individual example cells
- const sideContainer = document.getElementById('sideContainer');
- const exampleCells = sideContainer.getElementsByClassName('example-cell');
-
- // Clear the current images in each cell
- Array.from(exampleCells).forEach((exampleCell, index) => {
- // Clear previous canvas if exists
- const previousImage = exampleCell.querySelector('canvas');
- if (previousImage) {
- exampleCell.removeChild(previousImage);
- }
-
- // Create a new canvas element for the image
- const canvas = document.createElement('canvas');
- canvas.width = images[index][0].length; // Assuming the data is [height][width]
- canvas.height = images[index].length;
-
- const ctx = canvas.getContext('2d');
- const imageDataObject = ctx.createImageData(canvas.width, canvas.height);
-
- // Assuming the data is grayscale (0-1), set pixels accordingly
- for (let y = 0; y < canvas.height; y++) {
- for (let x = 0; x < canvas.width; x++) {
- const pixelIndex = (y * canvas.width + x) * 4;
- const pixelValue = images[index][y][x];
-
- imageDataObject.data[pixelIndex] = pixelValue * 255; // Red
- imageDataObject.data[pixelIndex + 1] = pixelValue * 255; // Green
- imageDataObject.data[pixelIndex + 2] = pixelValue * 255; // Blue
- imageDataObject.data[pixelIndex + 3] = 255; // Alpha (fully opaque)
- }
- }
-
- // Put the image data on the canvas
- ctx.putImageData(imageDataObject, 0, 0);
- canvas.classList.add('example-image');
- canvas.style.backgroundColor = 'transparent'; // Transparent background
-
- // Append the canvas to the corresponding example cell
- exampleCell.appendChild(canvas);
- });
- }
- // Update Example Predictions (Bar Charts)
- updateExamplePreds(predictions) {
- //log('Received predictions for update');
-
- // Select side container and get the individual example cells
- const sideContainer = document.getElementById('sideContainer');
- const exampleCells = sideContainer.getElementsByClassName('example-cell');
-
- predictions.forEach((prediction, index) => {
- // Get or create chart container for the corresponding example cell
- if (exampleCells.length<=index) {
- return;
- }
- let chartDiv = exampleCells[index].querySelector('.example-chart');
-
- if (!chartDiv) {
- // If the chartDiv doesn't exist, create it
- chartDiv = document.createElement('div');
- chartDiv.id = `chartDiv${index + 1}`;
- chartDiv.classList.add('example-chart');
- exampleCells[index].appendChild(chartDiv);
- }
-
- // Create Plotly bar chart for predicted classifications
- const trace = {
- x: Array.from({ length: 10 }, (_, i) => i), // Labels 0 to 9
- y: prediction, // Corresponding probabilities
- type: 'bar',
- marker: {
- color: 'rgba(255, 165, 0, 0.8)' // Bright orange bars for visibility on a black background
- }
- };
-
- const layout = {
- plot_bgcolor: 'rgba(0, 0, 0, 0)', // Transparent plot background
- paper_bgcolor: 'rgba(0, 0, 0, 0)', // Transparent paper background
- margin: { t: 20, l: 30, r: 20, b: 40 },
- xaxis: {
- title: 'Class',
- titlefont: { size: 10, color: '#ffffff' }, // White text for better contrast
- tickfont: { size: 8, color: '#ffffff' } // White tick labels
- },
- yaxis: {
- title: 'Probability',
- titlefont: { size: 10, color: '#ffffff' }, // White text for better contrast
- tickfont: { size: 8, color: '#ffffff' }, // White tick labels
- range: [0, 2] // Set y-axis limit to [0, 2]
- },
- showlegend: false // Hide legend to save space
- };
-
- Plotly.newPlot(chartDiv, [trace], layout, { displayModeBar: false });
- });
- }
-
- showImage(filename) {
- const container = document.getElementById('imageContainer');
- if (!container) {
- return;
- }
-
- if (!this.helpOverlay) {
- const overlay = document.createElement('div');
- overlay.className = 'help-overlay';
-
- const closeBtn = document.createElement('button');
- closeBtn.type = 'button';
- closeBtn.className = 'help-overlay__close';
- closeBtn.setAttribute('aria-label', 'Close help screens');
- closeBtn.innerHTML = '×';
- closeBtn.addEventListener('click', (event) => {
- event.preventDefault();
- event.stopPropagation();
- if (this.state) {
- if (typeof this.state.closeHelpScreens === 'function') {
- this.state.closeHelpScreens();
- } else {
- this.state.helpScreenIdx = -1;
- }
- }
- this.hideImage();
- });
-
- const img = document.createElement('img');
- img.className = 'help-overlay__image';
- img.alt = 'Help screen';
-
- overlay.appendChild(closeBtn);
- overlay.appendChild(img);
- container.appendChild(overlay);
-
- this.helpOverlay = overlay;
- this.helpImageEl = img;
- }
-
- const overlay = this.helpOverlay;
- const img = this.helpImageEl;
- if (!overlay || !img) {
- return;
- }
-
- container.classList.add('help-open');
- overlay.classList.add('visible');
-
- if (img.dataset.currentSrc === filename) {
- return;
- }
-
- img.dataset.currentSrc = filename;
- img.src = filename;
- }
-
- hideImage() {
- const container = document.getElementById('imageContainer');
- if (!container) {
- return;
- }
- container.classList.remove('help-open');
-
- if (this.helpOverlay) {
- this.helpOverlay.classList.remove('visible');
- } else {
- const overlay = container.querySelector('.help-overlay');
- if (overlay) {
- overlay.classList.remove('visible');
- }
- }
- }
-
}
diff --git a/web/client/helpTour.js b/web/client/helpTour.js
new file mode 100644
index 0000000..08cde04
--- /dev/null
+++ b/web/client/helpTour.js
@@ -0,0 +1,138 @@
+export const BRAND_TAGLINE = 'You are the optimizer.';
+export const SPLASH_LOGO_URL = '/hudes_logo_splash.jpg';
+export const SPLASH_MEDIA_URL = '/loss_landscape.gif';
+
+export const WELCOME_BULLETS = [
+ 'Steer a CNN through weight space—walk along random directions, find valleys and saddles, and set a 2-minute MNIST speed-run record.',
+ 'Each point is a neural network; moving downhill lowers the loss.',
+ 'Desktop gives you full control and the fastest runs.',
+];
+
+export const HOW_IT_WORKS_POINTS = [
+ 'Parameterization: Flatten all weights to a vector w ∈ ℝP. Current network is w; loss is L(w).',
+ 'Random directions: Sample orthogonal directions in w ∈ ℝP. “New dims” generates fresh random orthogonal directions.',
+ '1D move: w' = w + α·d₁, where α = step_size × t, t ∈ ℤ. The Z-plot charts L(w').',
+ '2D move: w' = w + α·d₁ + β·d₂. WASD controls (α, β); the mesh shows (α, β) ↦ L(w').',
+ 'New directions: Space samples fresh d₁, d₂ at the current w; Shift + click cycles the cyan plane. Mobile runs one plane at a time.',
+ 'Loss: Enter (or the Eval button on mobile) runs a full validation pass for leaderboard scoring and updates the confusion matrix.',
+ 'Step size / zoom: [ ] scales α, β; larger steps explore faster but can overshoot minima.',
+ 'Precision: ' toggles FP16/FP32 to trade speed for numerical stability.',
+];
+
+export const MODE_CARDS = [
+ {
+ id: 'explore',
+ title: 'Explore Mode',
+ eyebrow: 'Learn & tinker',
+ description: 'Sample random directions, adjust step size, batch size, and precision.',
+ primaryCta: 'Continue',
+ defaultSelected: true,
+ },
+ {
+ id: 'speed',
+ title: 'Speed-Run Mode',
+ eyebrow: '2-minute ranked challenge',
+ description: 'Lowest validation loss joins the global leaderboard. Desktop recommended.',
+ primaryCta: 'Start Ranked',
+ },
+];
+
+export const DESKTOP_CONTROLS_GRID = [
+ { label: 'Move', value: 'W / A / S / D / Scroll' },
+ { label: 'Rotate', value: 'Arrow keys / click' },
+ { label: 'New random directions', value: 'Space' },
+ { label: 'Toggle plane', value: 'Shift / Click grid' },
+ { label: 'Step ± (zoom)', value: '[ ]' },
+ { label: 'Batch-size', value: ';' },
+ { label: 'Precision FP16/32', value: '\'' },
+ { label: 'Full eval (validation)', value: 'Enter' },
+ { label: 'Speed-Run', value: 'Z' },
+ { label: 'Help', value: '?' },
+];
+
+export const MOBILE_CONTROLS_GRID = [
+ { label: 'Move', value: 'Single-finger drag' },
+ { label: 'Rotate', value: 'Two-finger drag' },
+ { label: 'New random directions', value: 'Tap Space button' },
+ { label: 'Step ± / Batch / Precision', value: 'Bottom buttons' },
+ { label: 'Full eval (validation)', value: 'Eval button' },
+ { label: 'Speed-Run', value: 'Speed 🔥 button' },
+ { label: 'Help', value: 'Tap ?' },
+];
+
+export const TUTORIAL_TASKS = [
+ {
+ id: 'move',
+ title: 'Move',
+ detailDesktop: 'W/A/S/D or scroll along the cyan plane.',
+ detailMobile: 'Drag with one finger to move in-plane.',
+ },
+ {
+ id: 'rotate',
+ title: 'Rotate view',
+ detailDesktop: 'Arrow keys or click to tilt the camera.',
+ detailMobile: 'Use two fingers to rotate the view.',
+ },
+ {
+ id: 'new_dims',
+ title: 'New directions',
+ detailDesktop: 'Press Space for fresh random directions.',
+ detailMobile: 'Tap the New Dims button.',
+ },
+ {
+ id: 'step',
+ title: 'Adjust step size',
+ detailDesktop: 'Use [ and ] to zoom in or out.',
+ detailMobile: 'Use the Step +/- buttons.',
+ },
+ {
+ id: 'batch',
+ title: 'Change batch size',
+ detailDesktop: 'Tap ; to cycle the batch size.',
+ detailMobile: 'Tap Batch Cycle to change batch size.',
+ },
+ {
+ id: 'precision',
+ title: 'Toggle precision',
+ detailDesktop: 'Press \' to switch FP16 / FP32.',
+ detailMobile: 'Tap the FP button to toggle precision.',
+ },
+];
+
+export const SPEEDRUN_RULES = [
+ 'Fixed starting seed per run (fairness).',
+ 'Any batch size and precision.',
+ 'Space to sample new subspaces; Shift + click to cycle subspace.',
+ 'Enter for new train batch and full eval.',
+ 'Auto eval at end of Speed-Run.',
+];
+
+export const SPEEDRUN_COMPACT_KEYS =
+ 'Move WASD • Rotate ←↑→↓ / drag • New directions Space • Cycle plane Shift + click • Step ± [ ] • Batch ; • Precision \' • Eval Enter • Help ?';
+
+export const SPEEDRUN_RULES_MOBILE = [
+ 'Fixed starting seed per run (fairness).',
+ 'Any batch size or precision (use the bottom buttons).',
+ 'Tap DIMS for new subspaces.',
+ 'Tap BATCH for new train batch and full eval.',
+ 'Auto eval at end of Speed-Run.',
+];
+
+export const SPEEDRUN_COMPACT_KEYS_MOBILE =
+ 'Move drag • Rotate two-finger • New dims Space button • Step/Batch/FP bottom buttons • Eval button • Help ?';
+
+export const TOUR_FLOWS = {
+ initial: ['tour_splash', 'tour_welcome', 'tour_modes'],
+ explore: ['tour_canvas'],
+ speed: ['tour_speed'],
+};
+
+export const TOUR_SCREENS = Array.from(
+ new Set(Object.values(TOUR_FLOWS).flat()),
+);
+
+export const SHARE_TEXT = (valLoss, levelNumber, levelTitle) => {
+ const loss = typeof valLoss === 'number' && Number.isFinite(valLoss) ? valLoss.toFixed(4) : '—';
+ const levelPart = levelNumber ? `(Level ${levelNumber}: ${levelTitle})` : '';
+ return `Descended MNIST to ${loss} ${levelPart} in 2:00—can you beat it?`;
+};
diff --git a/web/client/hud.js b/web/client/hud.js
index 60f30f1..85d356d 100644
--- a/web/client/hud.js
+++ b/web/client/hud.js
@@ -1,16 +1,26 @@
-export const CONTROL_GROUPS = [
- { icon: null, keys: ['W', 'A', 'S', 'D', 'Scroll'], label: 'Move' },
- { icon: null, keys: ['⬆️', '⬇️', '⬅️', '➡️', '🐁'], label: 'Rotate' },
- { icon: null, keys: ['Spacebar'], label: 'New Dims' },
+const SHARED_COMMANDS = [
+ { icon: null, keys: ['SPACE'], label: 'New Dims' },
{ icon: null, keys: ['Enter'], label: 'New Batch' },
- { icon: null, keys: ['R'], label: 'SPEED RUN' },
+ { icon: null, keys: ['Del'], label: 'SGD Step' },
+ { icon: null, keys: ['Z'], label: 'SPEED RUN 🔥' },
{ icon: null, keys: ['⇧','🐁'], label: 'Cycle Plane' },
{ icon: null, keys: ['[', ']'], label: 'Step ±' },
{ icon: null, keys: [';'], label: 'Batch-size' },
{ icon: null, keys: ["'"], label: 'FP16/32' },
{ icon: null, keys: ['Y'], label: 'Top 10' },
{ icon: null, keys: ['X'], label: 'Help' },
- { icon: null, keys: ['Q'], label: 'Hold to quit' },
+];
+
+export const CONTROL_GROUPS = [
+ { icon: null, keys: ['W', 'A', 'S', 'D', 'Scroll'], label: 'Move' },
+ { icon: null, keys: ['⬆️', '⬇️', '⬅️', '➡️', '🐁'], label: 'Rotate' },
+ ...SHARED_COMMANDS,
+];
+
+export const MOBILE_CONTROL_GROUPS = [];
+
+export const MOBILE_HUD_BUTTONS = [
+ { action: 'show-top', label: 'Top 10' },
];
export const HUD_TITLE = '🧠 Human Descent: MNIST';
@@ -47,6 +57,20 @@ const formatControlGroup = (group) => {
`;
};
+const formatHudButtons = (buttons = []) => {
+ if (!Array.isArray(buttons) || buttons.length === 0) {
+ return '';
+ }
+ const items = buttons
+ .map(({ action, label }) => {
+ const safeAction = action ? ` data-hud-action="${escapeHtml(action)}"` : '';
+ const safeLabel = escapeHtml(label ?? '');
+ return ``;
+ })
+ .join('');
+ return `${items}
`;
+};
+
export const formatHudMarkup = (
statusText = '',
controlGroups = CONTROL_GROUPS,
@@ -61,20 +85,27 @@ export const formatHudMarkup = (
const safeTitle = escapeHtml(options.title ?? '');
const separator = '•';
const controlsMarkup = controlGroups.map(formatControlGroup).join(separator);
- const statusMarkup = safeStatus
+ const buttonMarkup = formatHudButtons(options.buttons);
+ const statusMarkup = options.statusHtml
+ ? `${options.statusHtml}
`
+ : safeStatus
? `${safeStatus}
`
: '';
const titleMarkup = safeTitle
? `${safeTitle}
`
: '';
-
return `
${statusMarkup}
${controlsMarkup}
+ ${buttonMarkup}
${titleMarkup}
+
+
+
+
`;
};
diff --git a/web/client/touchControls.js b/web/client/touchControls.js
new file mode 100644
index 0000000..4c704e4
--- /dev/null
+++ b/web/client/touchControls.js
@@ -0,0 +1,126 @@
+const DEFAULT_INTERVAL = 90;
+const DEFAULT_DEADZONE = 12;
+
+export function installTouchControls(target, { onVector, intervalMs = DEFAULT_INTERVAL, deadzonePx = DEFAULT_DEADZONE } = {}) {
+ if (!target || typeof window === 'undefined') {
+ return () => {};
+ }
+
+ let activeId = null;
+ let origin = null;
+ let lastVector = { x: 0, y: 0 };
+ let loop = null;
+
+ const emit = () => {
+ if (typeof onVector === 'function') {
+ onVector({ ...lastVector });
+ }
+ };
+
+ const stopLoop = () => {
+ if (loop) {
+ clearInterval(loop);
+ loop = null;
+ }
+ };
+
+ const startLoop = () => {
+ if (!loop) {
+ loop = setInterval(emit, Math.max(30, intervalMs));
+ }
+ };
+
+ const maxRadius = () => {
+ const w = window.innerWidth || 0;
+ const h = window.innerHeight || 0;
+ return Math.max(deadzonePx * 4, Math.min(w, h) * 0.4);
+ };
+
+ const computeVector = (touch) => {
+ if (!origin) return { x: 0, y: 0 };
+ const dx = touch.clientX - origin.x;
+ const dy = touch.clientY - origin.y;
+ const distance = Math.hypot(dx, dy);
+ if (distance <= deadzonePx) {
+ return { x: 0, y: 0 };
+ }
+ const radius = maxRadius();
+ const magnitude = Math.min(1, distance / radius);
+ const unitX = dx / (distance || 1);
+ const unitY = dy / (distance || 1);
+ return { x: unitX * magnitude, y: unitY * magnitude };
+ };
+
+ const findActiveTouch = (touchList) => {
+ if (activeId == null) return null;
+ for (let i = 0; i < touchList.length; i += 1) {
+ if (touchList[i].identifier === activeId) {
+ return touchList[i];
+ }
+ }
+ return null;
+ };
+
+ const clearState = () => {
+ activeId = null;
+ origin = null;
+ lastVector = { x: 0, y: 0 };
+ stopLoop();
+ emit();
+ };
+
+ const handleStart = (event) => {
+ const touchCount = event.touches?.length || event.changedTouches?.length || 0;
+ if (touchCount > 1) {
+ clearState();
+ return;
+ }
+ if (activeId != null) return;
+ const touch = event.changedTouches?.[0];
+ if (!touch) return;
+ activeId = touch.identifier;
+ origin = { x: touch.clientX, y: touch.clientY };
+ lastVector = { x: 0, y: 0 };
+ startLoop();
+ event.preventDefault?.();
+ };
+
+ const handleMove = (event) => {
+ const touchCount = event.touches?.length || 0;
+ if (touchCount > 1) {
+ clearState();
+ return;
+ }
+ const touch = findActiveTouch(event.touches || []);
+ if (!touch) return;
+ lastVector = computeVector(touch);
+ event.preventDefault?.();
+ };
+
+ const handleEnd = (event) => {
+ const touch = findActiveTouch(event.changedTouches || []);
+ if (!touch) return;
+ event.preventDefault?.();
+ clearState();
+ };
+
+ const handleCancel = (event) => {
+ const touch = findActiveTouch(event.changedTouches || []);
+ if (!touch) return;
+ event.preventDefault?.();
+ clearState();
+ };
+
+ target.addEventListener('touchstart', handleStart, { passive: false });
+ target.addEventListener('touchmove', handleMove, { passive: false });
+ target.addEventListener('touchend', handleEnd, { passive: false });
+ target.addEventListener('touchcancel', handleCancel, { passive: false });
+
+ return () => {
+ stopLoop();
+ target.removeEventListener('touchstart', handleStart);
+ target.removeEventListener('touchmove', handleMove);
+ target.removeEventListener('touchend', handleEnd);
+ target.removeEventListener('touchcancel', handleCancel);
+ };
+}
diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js
new file mode 100644
index 0000000..484039c
--- /dev/null
+++ b/web/client/views/LandscapeView.js
@@ -0,0 +1,2876 @@
+import {
+ Scene,
+ PerspectiveCamera,
+ MathUtils,
+ WebGLRenderer,
+ Color,
+ PlaneGeometry,
+ BufferAttribute,
+ BufferGeometry,
+ ShaderMaterial,
+ Mesh,
+ SphereGeometry,
+ MeshBasicMaterial,
+ Raycaster,
+ Vector2,
+ Line,
+ LineBasicMaterial,
+ Group,
+ LineSegments,
+ LineDashedMaterial,
+} from 'three';
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
+import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // Import OrbitControls
+import annotationPlugin from 'chartjs-plugin-annotation';
+import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; // Import CSS2DRenderer for annotations
+import { CONTROL_GROUPS, formatHudMarkup, MOBILE_CONTROL_GROUPS, MOBILE_HUD_BUTTONS, escapeHtml } from '../hud.js';
+import {
+ BRAND_TAGLINE,
+ SPLASH_LOGO_URL,
+ SPLASH_MEDIA_URL,
+ WELCOME_BULLETS,
+ HOW_IT_WORKS_POINTS,
+ MODE_CARDS,
+ DESKTOP_CONTROLS_GRID,
+ MOBILE_CONTROLS_GRID,
+ TUTORIAL_TASKS,
+ SPEEDRUN_RULES,
+ SPEEDRUN_COMPACT_KEYS,
+ SPEEDRUN_RULES_MOBILE,
+ SPEEDRUN_COMPACT_KEYS_MOBILE,
+ TOUR_FLOWS,
+ TOUR_SCREENS,
+ SHARE_TEXT,
+} from '../helpTour.js';
+import { log } from '../../utils/logger.js'; // Import your logging utility
+
+const DEBUG_SELECTION = false;
+
+const debugSelection = (message) => {
+ if (DEBUG_SELECTION) {
+ log(message);
+ }
+};
+
+const CONFUSION_LABELS = Array.from({ length: 10 }, (_, i) => i.toString());
+const HEATMAP_STOPS = [
+ { stop: 0, color: [33, 11, 68] },
+ { stop: 0.5, color: [0, 168, 168] },
+ { stop: 1, color: [255, 221, 87] },
+];
+
+const lerp = (a, b, t) => a + (b - a) * t;
+const colorToRgba = ([r, g, b], alpha = 0.95) =>
+ `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha})`;
+
+const interpolateHeatmapColor = (valueRatio) => {
+ const t = Math.min(1, Math.max(0, valueRatio));
+ for (let i = 0; i < HEATMAP_STOPS.length - 1; i += 1) {
+ const left = HEATMAP_STOPS[i];
+ const right = HEATMAP_STOPS[i + 1];
+ if (t >= left.stop && t <= right.stop) {
+ const localT = (t - left.stop) / (right.stop - left.stop || 1);
+ return colorToRgba([
+ lerp(left.color[0], right.color[0], localT),
+ lerp(left.color[1], right.color[1], localT),
+ lerp(left.color[2], right.color[2], localT),
+ ]);
+ }
+ }
+ return colorToRgba(HEATMAP_STOPS[HEATMAP_STOPS.length - 1].color);
+};
+
+import {
+ Chart,
+ CategoryScale, // Register the category scale
+ LinearScale,
+ PointElement,
+ LineElement,
+ BarElement,
+ LineController,
+ BarController,
+ Title,
+ Tooltip,
+ Legend
+} from 'chart.js';
+
+import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
+
+// Register all necessary components
+Chart.register(
+ CategoryScale,
+ LinearScale,
+ LineController,
+ BarController,
+ MatrixController, // Register MatrixController
+ MatrixElement, // Register MatrixElement
+ PointElement,
+ LineElement,
+ BarElement,
+ Title,
+ Tooltip,
+ Legend,
+ annotationPlugin
+);
+
+
+export default class LandscapeView {
+ constructor(gridSize, numGrids, clientState, options = {}) {
+ this.mode = (options.mode || '3d').toLowerCase();
+ this.gridSize = gridSize;
+ this.numGrids = numGrids;
+ this.effectiveGrids = numGrids;
+ this.debug = Boolean(options.debug);
+ this.alt1dMode = this.mode === '1d' && Boolean(options.alt1d);
+ this.altKeysMode = Boolean(options.altKeys);
+
+ this.state = clientState;
+ this.mobileMode = Boolean(options.mobile);
+ this.hideHud = Boolean(options.hideHud);
+ this.initialTourDisabled = Boolean(options.disableInitialTour) || this.state?.helpScreenIdx === -1;
+ if (typeof document !== 'undefined') {
+ const bottomHud = document.getElementById('bottomTextContainer');
+ if ((this.mobileMode || this.hideHud) && bottomHud) {
+ bottomHud.remove();
+ }
+ }
+ if (this.mobileMode && this.numGrids > 1) {
+ this.numGrids = 1;
+ this.effectiveGrids = 1;
+ }
+
+ this.lossChart = null;
+ this.lastStepsChart = null;
+ this.stepSizeChart = null;
+ this.dimsAndStepsChart = null;
+ this.exampleImagesContainer = document.getElementById('exampleImages');
+ this.confusionMatrixContainer = document.getElementById('confusionMatrixChart');
+ this.confusionMatrixChart = null;
+ this.confusionMatrixMaxValue = 1;
+ this.confusionRows = 10;
+ this.confusionCols = 10;
+ this.exampleCharts = [];
+ this.helpOverlay = null;
+ this.helpOverlayContent = null;
+ this.helpTabsBar = null;
+ this.helpTabs = {};
+ this.helpTourSelection = 'explore';
+ this.selectedTourMode = 'explore';
+ this.manualKeyOverlay = false;
+ this._helpKeyListenerAttached = false;
+ this._boundHelpKeydown = (event) => this._handleHelpKeydown(event);
+ this.helpContentMode = 'tour';
+ this.activeHelpTab = 'controls';
+ this.currentTourFlow = 'initial';
+ this.tutorialProgress = new Map();
+ this._resetTutorialProgress();
+ this.tutorialToastEl = null;
+ this.tutorialComplete = false;
+ if (!this.initialTourDisabled && typeof window !== 'undefined') {
+ window.requestAnimationFrame(() => this._setTourFlow('initial'));
+ }
+ this.levelToastTimeout = null;
+ this.levelModalTimeout = null;
+ this._levelDismissHandler = null;
+
+
+ // Grid properties
+ this.gridObjects = [];
+ this.lineGroup = null;
+ this.lineObjects = [];
+ this.lineFrames = [];
+ this.lineContainers = [];
+ this.centerLines = [];
+ this.horizontalLines = [];
+ this.lineScaleCache = [];
+ this.lineBaseColors = [];
+ this.frameGlowState = [];
+ this.alt1dContainers = [];
+ this.alt1dFrames = [];
+ this.alt1dCenterLines = [];
+ this.alt1dHorizontalLines = [];
+ this.alt1dGlowStates = [];
+ this.alt1dFrameBaseColors = [];
+ this.alt1dLineToContainer = [];
+ this.outlineSelection = new Set();
+ this.glowDuration = 150;
+ this.glowExpand = 0.05;
+ this.glowEdgeStrength = 3.5;
+ this.tempColor = new Color();
+ this.highlightColor = new Color(1, 1, 1);
+ this.lineHeightBase = this.gridSize * 0.4;
+ this.lineHeightMin = Math.max(1, this.gridSize * 0.05);
+ this.lineHeightMax = this.gridSize * 0.35;
+ this.rowSpacing = typeof options.rowSpacing === 'number' ? options.rowSpacing : null;
+ this.depthStep = typeof options.depthStep === 'number' ? options.depthStep : null;
+ this.customCameraDistance = typeof options.cameraDistance === 'number' ? options.cameraDistance : null;
+ this.spacing = 20; // Spacing between grids
+ this.mobileCameraScalar = this.mobileMode ? this._computeMobileCameraScalar() : 1;
+ this.selectedGridIndex = 0; // Index of the selected grid
+ this.selectedGridScale = 2 * 1.5; //1.5; // Scale multiplier for the selected grid
+ // Three.js setup
+ const glContainer = document.getElementById('glContainer');
+ if (glContainer) {
+ this.scene = new Scene();
+ this.camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
+ this.renderer = new WebGLRenderer({ antialias: true });
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
+ this.renderer.domElement.style.position = 'absolute';
+ this.renderer.domElement.style.top = '0';
+ this.renderer.domElement.style.left = '0';
+ this.renderer.domElement.style.width = '100%';
+ this.renderer.domElement.style.height = '100%';
+ this.renderer.domElement.style.pointerEvents = 'auto';
+ glContainer.appendChild(this.renderer.domElement);
+ // Calculate camera position to fit all grids
+ const fovRadians = (this.camera.fov * Math.PI) / 180;
+ const totalWidth = (this.numGrids + this.selectedGridScale - 1.0) * this.gridSize + (this.numGrids - 1) * this.spacing;
+ this.camera_distance = (totalWidth / 2) / Math.tan(fovRadians / 2);
+ if (this.customCameraDistance != null) {
+ this.camera_distance = this.customCameraDistance;
+ }
+ if (this.alt1dMode) {
+ this.camera_distance *= 1.25;
+ }
+ if (this.mobileMode) {
+ this.camera_distance *= this.mobileCameraScalar;
+ }
+ if (this.mobileMode) {
+ this.camera_distance *= this.mobileCameraScalar;
+ }
+
+ this.raycaster = new Raycaster();
+ this.pointer = new Vector2();
+
+ // Set the y-offset for the camera to position the grids 2/3 of the way down the view
+ // The y offset is calculated as (1/3 of the totalHeight) since we want the grids to be 2/3 of the way down
+ this.camera_yOffset = this.mobileMode ? (totalWidth / 6) * this.mobileCameraScalar * 0.85 : totalWidth / 8;
+
+ // Update camera position
+ this.camera.position.set(0, this.camera_yOffset, this.camera_distance);
+ // Orbit controls setup
+ //this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+
+
+ // CSS2DRenderer setup for annotations
+ this.labelRenderer = new CSS2DRenderer();
+ this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
+ this.labelRenderer.domElement.style.position = 'absolute';
+ this.labelRenderer.domElement.style.top = '0px';
+ this.labelRenderer.domElement.style.pointerEvents = 'none';
+ document.body.appendChild(this.labelRenderer.domElement);
+
+ this.composer = new EffectComposer(this.renderer);
+ this.renderPass = new RenderPass(this.scene, this.camera);
+ this.composer.addPass(this.renderPass);
+ this.outlinePass = new OutlinePass(new Vector2(window.innerWidth, window.innerHeight), this.scene, this.camera);
+ this.outlinePass.edgeStrength = 0;
+ this.outlinePass.edgeGlow = 0.6;
+ this.outlinePass.edgeThickness = 1.0;
+ this.outlinePass.visibleEdgeColor.set(1, 1, 1);
+ this.outlinePass.hiddenEdgeColor.set(0, 0, 0);
+ this.outlinePass.pulsePeriod = 0;
+ this.composer.addPass(this.outlinePass);
+
+ // Colors for the grids
+ this.gridColors = [
+ new Color(0.0, 1.0, 1.0), // Cyan
+ new Color(1.0, 0.0, 1.0), // Magenta
+ new Color(1.0, 1.0, 0.0), // Yellow
+ new Color(0.0, 1.0, 0.0), // Green
+ new Color(1.0, 0.5, 0.0), // Orange
+ new Color(1.0, 0.0, 0.0), // Red
+ new Color(0.0, 0.0, 1.0), // Blue
+ new Color(0.5, 0.0, 1.0), // Purple
+ new Color(0.0, 0.5, 1.0), // Sky Blue
+ new Color(1.0, 0.0, 0.5), // Pink
+ new Color(0.5, 1.0, 0.0), // Lime
+ new Color(1.0, 0.75, 0.8), // Light Pink
+ ];
+
+ this.alpha = 0.8; // Transparency
+
+ // Angle properties
+ this.defaultAngleV = 20;
+ this.maxAngleV = 25;
+ this.angleH = 0.0;
+ this.angleV = this.defaultAngleV;
+ // Start rendering loop
+ this.render();
+ window.addEventListener('resize', this.onWindowResize.bind(this), false);
+ }
+ }
+
+ _computeMobileCameraScalar() {
+ if (typeof window === 'undefined') {
+ return 1.35;
+ }
+ const shortRef = 760;
+ const longRef = 1280;
+ const width = window.innerWidth || shortRef;
+ const height = window.innerHeight || longRef;
+ const shortSide = Math.min(width, height) || shortRef;
+ const longSide = Math.max(width, height) || longRef;
+ const shortRatio = shortRef / shortSide;
+ const longRatio = longRef / longSide;
+ const scalar = Math.min(1.6, Math.max(1.2, Math.max(shortRatio, longRatio)));
+ return scalar;
+ }
+
+ _updateAlt1dLossLines(lines, { stepSpacing, labels } = {}) {
+ if (!this.alt1dMode || !this.scene || !Array.isArray(lines) || !lines.length) {
+ return;
+ }
+ if (!this.lineGroup) {
+ this.lineGroup = new Group();
+ this.scene.add(this.lineGroup);
+ }
+
+ const count = lines.length;
+ const linesPerPlot = 3;
+ const containerCount = Math.max(1, Math.ceil(count / linesPerPlot));
+ this.effectiveGrids = containerCount;
+ const scaleFactor = 2;
+ const cellWidth = this.gridSize * 0.9 * scaleFactor;
+ const cellHeight = this.gridSize * 0.6 * scaleFactor;
+ const halfWidth = cellWidth / 2;
+ const halfHeight = cellHeight / 2;
+ const gapX = 1;
+
+ for (let idx = 0; idx < containerCount; idx += 1) {
+ this._ensureAlt1dScaffold(idx, {
+ cellWidth,
+ cellHeight,
+ halfWidth,
+ halfHeight,
+ });
+ }
+
+ while (this.alt1dContainers.length > containerCount) {
+ const container = this.alt1dContainers.pop();
+ const frame = this.alt1dFrames.pop();
+ const centerLine = this.alt1dCenterLines.pop();
+ const horizontalLine = this.alt1dHorizontalLines.pop();
+ this.alt1dFrameBaseColors.pop();
+ if (frame) {
+ this.outlineSelection.delete(frame);
+ frame.geometry?.dispose?.();
+ frame.material?.dispose?.();
+ }
+ centerLine?.geometry?.dispose?.();
+ centerLine?.material?.dispose?.();
+ horizontalLine?.geometry?.dispose?.();
+ horizontalLine?.material?.dispose?.();
+ if (container) {
+ container.children.slice().forEach((child) => {
+ if (child instanceof Line || child instanceof LineSegments) {
+ if (child !== frame && child !== centerLine && child !== horizontalLine) {
+ container.remove(child);
+ }
+ }
+ });
+ this.lineGroup.remove(container);
+ }
+ }
+
+ for (let idx = 0; idx < this.alt1dContainers.length; idx += 1) {
+ const container = this.alt1dContainers[idx];
+ if (container) {
+ const offset = (idx - (this.alt1dContainers.length - 1) / 2) * (cellWidth + gapX);
+ container.position.set(offset, 0, 0);
+ }
+ }
+
+ while (this.lineObjects.length > count) {
+ const line = this.lineObjects.pop();
+ this.lineScaleCache.pop();
+ this.lineBaseColors.pop();
+ this.alt1dGlowStates.pop();
+ this.alt1dLineToContainer.pop();
+ if (line) {
+ line.parent?.remove(line);
+ line.geometry?.dispose?.();
+ line.material?.dispose?.();
+ }
+ }
+
+ while (this.lineObjects.length < count) {
+ const material = new LineBasicMaterial({
+ color: 0xffffff,
+ linewidth: 2.5,
+ transparent: true,
+ opacity: 1,
+ });
+ material.depthTest = false;
+ material.depthWrite = false;
+ const geometry = new BufferGeometry();
+ const line = new Line(geometry, material);
+ line.renderOrder = 2;
+ this.lineObjects.push(line);
+ this.lineScaleCache.push(0);
+ this.lineBaseColors.push(new Color(1, 1, 1));
+ this.alt1dGlowStates.push(null);
+ this.alt1dLineToContainer.push(0);
+ }
+
+ this.lineScaleCache.length = count;
+ this.lineBaseColors.length = count;
+ this.alt1dGlowStates.length = count;
+ this.alt1dLineToContainer.length = count;
+
+ const eps = 1e-6;
+ const lerpAlpha = 0.35;
+
+ for (let i = 0; i < count; i += 1) {
+ const data = lines[i];
+ const line = this.lineObjects[i];
+ if (!Array.isArray(data) || !line) {
+ continue;
+ }
+ if (this.alt1dGlowStates[i] === undefined) {
+ this.alt1dGlowStates[i] = null;
+ }
+
+ const containerIdx = Math.min(
+ this.alt1dContainers.length - 1,
+ Math.floor(i / linesPerPlot),
+ );
+ const container = this.alt1dContainers[containerIdx];
+ if (!container) {
+ continue;
+ }
+ if (line.parent !== container) {
+ line.parent?.remove(line);
+ container.add(line);
+ }
+ this.alt1dLineToContainer[i] = containerIdx;
+
+ const length = data.length;
+ if (!length) {
+ continue;
+ }
+
+ const mid = (length - 1) / 2;
+ const baseline = data[Math.max(0, Math.floor(mid))];
+ let maxAbs = 1e-6;
+ for (let j = 0; j < length; j += 1) {
+ maxAbs = Math.max(maxAbs, Math.abs(data[j] - baseline));
+ }
+
+ const scaleX = length > 1 ? cellWidth / (length - 1) : cellWidth;
+ let scaleY = 0;
+ if (maxAbs > eps) {
+ const targetScale = halfHeight / maxAbs;
+ const previous = this.lineScaleCache[i] ?? targetScale;
+ const lerped = MathUtils.lerp(previous, targetScale, lerpAlpha);
+ scaleY = Math.min(lerped, targetScale);
+ this.lineScaleCache[i] = scaleY;
+ } else {
+ this.lineScaleCache[i] = 0;
+ }
+
+ let geometry = line.geometry;
+ if (!(geometry instanceof BufferGeometry)) {
+ geometry = new BufferGeometry();
+ line.geometry = geometry;
+ }
+
+ let positionAttr = geometry.getAttribute('position');
+ if (!positionAttr || positionAttr.array.length !== length * 3) {
+ const positions = new Float32Array(length * 3);
+ positionAttr = new BufferAttribute(positions, 3);
+ geometry.setAttribute('position', positionAttr);
+ }
+
+ const positions = positionAttr.array;
+ for (let j = 0; j < length; j += 1) {
+ const idx = j * 3;
+ positions[idx] = (j - mid) * scaleX;
+ positions[idx + 1] = scaleY > 0 ? (data[j] - baseline) * scaleY : 0;
+ positions[idx + 2] = 0;
+ }
+
+ positionAttr.needsUpdate = true;
+ geometry.computeBoundingSphere();
+ geometry.setDrawRange(0, length);
+
+ const color = this.gridColors[i % this.gridColors.length];
+ const baseColor = color.clone();
+ this.lineBaseColors[i] = baseColor;
+ line.material.color.copy(color);
+ line.material.linewidth = 2.5;
+ line.material.opacity = 1;
+ line.material.needsUpdate = true;
+ line.material.depthTest = false;
+ line.material.depthWrite = false;
+ }
+
+ this.frameGlowState = [];
+ this.lineFrames = [];
+ this.centerLines = [];
+ this.horizontalLines = [];
+ this.lineContainers = [...this.alt1dContainers];
+ }
+
+ _ensureAlt1dScaffold(index, dims) {
+ const { cellWidth, cellHeight, halfWidth, halfHeight } = dims;
+ while (this.alt1dContainers.length <= index) {
+ this.alt1dContainers.push(null);
+ this.alt1dFrames.push(null);
+ this.alt1dCenterLines.push(null);
+ this.alt1dHorizontalLines.push(null);
+ this.alt1dFrameBaseColors.push(null);
+ }
+
+ let container = this.alt1dContainers[index];
+ if (!container) {
+ container = new Group();
+ container.position.set(0, 0, 0);
+ this.lineGroup.add(container);
+ this.alt1dContainers[index] = container;
+
+ const frameMaterial = new LineBasicMaterial({
+ color: 0xffffff,
+ linewidth: 1.6,
+ transparent: true,
+ opacity: 0.9,
+ });
+ frameMaterial.depthTest = false;
+ frameMaterial.depthWrite = false;
+ const frame = new LineSegments(new BufferGeometry(), frameMaterial);
+ frame.renderOrder = 1;
+ container.add(frame);
+ this.alt1dFrames[index] = frame;
+ this.alt1dFrameBaseColors[index] = frameMaterial.color.clone();
+
+ const centerMaterial = new LineDashedMaterial({
+ color: 0xffffff,
+ linewidth: 1,
+ dashSize: 1,
+ gapSize: 1,
+ transparent: true,
+ opacity: 0.5,
+ depthWrite: false,
+ });
+ centerMaterial.depthTest = false;
+ const centerLine = new Line(new BufferGeometry(), centerMaterial);
+ centerLine.renderOrder = 0;
+ container.add(centerLine);
+ this.alt1dCenterLines[index] = centerLine;
+
+ const horizontalMaterial = new LineDashedMaterial({
+ color: 0xffffff,
+ linewidth: 1,
+ dashSize: 1,
+ gapSize: 1,
+ transparent: true,
+ opacity: 0.5,
+ depthWrite: false,
+ });
+ horizontalMaterial.depthTest = false;
+ const horizontalLine = new Line(new BufferGeometry(), horizontalMaterial);
+ horizontalLine.renderOrder = 0;
+ container.add(horizontalLine);
+ this.alt1dHorizontalLines[index] = horizontalLine;
+ }
+
+ const frame = this.alt1dFrames[index];
+ if (frame) {
+ const framePositions = new Float32Array([
+ -halfWidth, halfHeight, 0,
+ halfWidth, halfHeight, 0,
+ halfWidth, halfHeight, 0,
+ halfWidth, -halfHeight, 0,
+ halfWidth, -halfHeight, 0,
+ -halfWidth, -halfHeight, 0,
+ -halfWidth, -halfHeight, 0,
+ -halfWidth, halfHeight, 0,
+ ]);
+ frame.geometry.setAttribute('position', new BufferAttribute(framePositions, 3));
+ frame.geometry.attributes.position.needsUpdate = true;
+ frame.geometry.computeBoundingSphere();
+ frame.geometry.setDrawRange(0, 8);
+ }
+
+ const centerLine = this.alt1dCenterLines[index];
+ if (centerLine) {
+ const centerPositions = new Float32Array([
+ 0, halfHeight, 0,
+ 0, -halfHeight, 0,
+ ]);
+ centerLine.geometry.setAttribute('position', new BufferAttribute(centerPositions, 3));
+ centerLine.geometry.attributes.position.needsUpdate = true;
+ centerLine.computeLineDistances();
+ const dashSize = Math.max(0.02 * cellWidth, 0.5);
+ centerLine.material.dashSize = dashSize;
+ centerLine.material.gapSize = Math.max(0.04 * cellWidth, dashSize * 2);
+ centerLine.material.needsUpdate = true;
+ }
+
+ const horizontalLine = this.alt1dHorizontalLines[index];
+ if (horizontalLine) {
+ const horizontalPositions = new Float32Array([
+ -halfWidth, 0, 0,
+ halfWidth, 0, 0,
+ ]);
+ horizontalLine.geometry.setAttribute(
+ 'position',
+ new BufferAttribute(horizontalPositions, 3),
+ );
+ horizontalLine.geometry.attributes.position.needsUpdate = true;
+ horizontalLine.computeLineDistances();
+ const dashSize = Math.max(0.02 * cellWidth, 0.5);
+ horizontalLine.material.dashSize = dashSize;
+ horizontalLine.material.gapSize = Math.max(0.04 * cellWidth, dashSize * 2);
+ horizontalLine.material.needsUpdate = true;
+ }
+ }
+
+ _startAlt1dLineGlow(index, durationMs = this.glowDuration) {
+ if (!this.alt1dMode) {
+ return;
+ }
+ if (index == null || index < 0 || index >= this.lineObjects.length) {
+ return;
+ }
+ const line = this.lineObjects[index];
+ if (!line) {
+ return;
+ }
+ const duration = Math.max(30, durationMs || this.glowDuration);
+ const now = performance.now();
+ this.alt1dGlowStates[index] = {
+ startTime: now,
+ duration,
+ };
+ }
+
+ _renderAlt1dGlow(now) {
+ const containerIntensity = new Array(this.alt1dFrames.length).fill(0);
+
+ for (let i = 0; i < this.lineObjects.length; i += 1) {
+ const line = this.lineObjects[i];
+ const baseColor = this.lineBaseColors[i];
+ if (!line || !line.material || !baseColor) {
+ continue;
+ }
+ const state = this.alt1dGlowStates[i];
+ const containerIdx = this.alt1dLineToContainer[i] ?? 0;
+
+ if (!state) {
+ this._restoreAlt1dLineAppearance(line, baseColor);
+ continue;
+ }
+
+ const elapsed = now - state.startTime;
+ if (elapsed >= state.duration) {
+ this.alt1dGlowStates[i] = null;
+ this._restoreAlt1dLineAppearance(line, baseColor);
+ continue;
+ }
+
+ const intensity = 1 - elapsed / state.duration;
+ this._applyAlt1dLineAppearance(line, baseColor, intensity);
+ if (containerIdx >= 0 && containerIdx < containerIntensity.length) {
+ containerIntensity[containerIdx] = Math.max(
+ containerIntensity[containerIdx],
+ intensity,
+ );
+ }
+ }
+
+ let maxIntensity = 0;
+ let selectionDirty = false;
+
+ for (let idx = 0; idx < this.alt1dFrames.length; idx += 1) {
+ const frame = this.alt1dFrames[idx];
+ const base = this.alt1dFrameBaseColors[idx];
+ const intensity = containerIntensity[idx] ?? 0;
+ maxIntensity = Math.max(maxIntensity, intensity);
+
+ if (!frame || !frame.material || !base) {
+ continue;
+ }
+
+ if (intensity > 0) {
+ const blended = this.tempColor
+ .copy(base)
+ .lerp(this.highlightColor, Math.min(1, intensity * 0.5));
+ frame.material.color.copy(blended);
+ frame.material.needsUpdate = true;
+ if (!this.outlineSelection.has(frame)) {
+ this.outlineSelection.add(frame);
+ selectionDirty = true;
+ }
+ } else {
+ frame.material.color.copy(base);
+ frame.material.needsUpdate = true;
+ if (this.outlineSelection.delete(frame)) {
+ selectionDirty = true;
+ }
+ }
+ }
+
+ if (selectionDirty) {
+ this._refreshOutlineSelection();
+ }
+
+ return maxIntensity;
+ }
+
+ _applyAlt1dLineAppearance(line, baseColor, intensity) {
+ if (!line || !line.material || !baseColor) {
+ return;
+ }
+ const blended = this.tempColor
+ .copy(baseColor)
+ .lerp(this.highlightColor, Math.min(1, intensity * 0.85));
+ line.material.color.copy(blended);
+ line.material.linewidth = 2.5 + (intensity * 3.5);
+ line.material.opacity = 1;
+ line.material.needsUpdate = true;
+ }
+
+ _restoreAlt1dLineAppearance(line, baseColor) {
+ if (!line || !line.material || !baseColor) {
+ return;
+ }
+ line.material.color.copy(baseColor);
+ line.material.linewidth = 2.5;
+ line.material.opacity = 1;
+ line.material.needsUpdate = true;
+ }
+
+ _getDimColorHex(dimIndex) {
+ const color = this.gridColors?.[dimIndex % this.gridColors.length];
+ if (!color) {
+ return '#ffd666';
+ }
+ return `#${color.getHexString()}`;
+ }
+
+ _build1DControlGroups() {
+ const useAltKeys = Boolean(this.altKeysMode);
+ const positiveLetters = useAltKeys
+ ? ['W', 'E', 'R', 'S', 'D', 'F']
+ : ['W', 'E', 'R', 'U', 'I', 'O'];
+ const negativeLetters = useAltKeys
+ ? ['U', 'I', 'O', 'J', 'K', 'L']
+ : ['S', 'D', 'F', 'J', 'K', 'L'];
+
+ const buildLabel = (letters, sign) => {
+ const markup = letters
+ .map((letter, idx) => {
+ const color = this._getDimColorHex(idx);
+ return `${letter}`;
+ })
+ .join('');
+ return `${markup}${sign}`;
+ };
+
+ const combos = [
+ { icon: null, keys: [], label: buildLabel(positiveLetters, '(+)') },
+ { icon: null, keys: [], label: buildLabel(negativeLetters, '(-)') },
+ ];
+
+ const sharedControls = [
+ { icon: null, keys: ['Z'], label: 'SPEED RUN 🔥' },
+ { icon: null, keys: ['Enter'], label: 'New Batch' },
+ { icon: null, keys: ['SPACE'], label: 'New Dims' },
+ { icon: null, keys: ['Del'], label: 'SGD Step' },
+ { icon: null, keys: ['[', ']'], label: 'Step ±' },
+ { icon: null, keys: [';'], label: 'Batch-size' },
+ { icon: null, keys: ["'"], label: 'FP16/32' },
+ { icon: null, keys: ['Y'], label: 'Top 10' },
+ { icon: null, keys: ['X'], label: 'Help' },
+ ];
+
+ return [...combos, ...sharedControls];
+ }
+
+ _buildMobileHudButtons() {
+ if (!this.mobileMode) {
+ return [];
+ }
+ const dtype = (this.state?.dtype || '').replace('float', '').toUpperCase();
+ const dtypeLabel = dtype ? `FP${dtype}` : 'FP';
+ return MOBILE_HUD_BUTTONS.map((button) => {
+ if (button.action === 'toggle-fp') {
+ return { ...button, label: dtypeLabel };
+ }
+ return button;
+ });
+ }
+
+ annotateBottomScreen(text, size = 20) {
+ const bottomTextContainer = document.getElementById('bottomTextContainer');
+ if (!bottomTextContainer) {
+ return;
+ }
+ let controls;
+ let hudButtons = [];
+ if (this.mode === '1d') {
+ controls = this._build1DControlGroups();
+ } else if (this.mobileMode) {
+ controls = MOBILE_CONTROL_GROUPS;
+ hudButtons = this._buildMobileHudButtons();
+ } else {
+ controls = CONTROL_GROUPS;
+ }
+ const statusText = this.mobileMode ? '' : typeof text === 'string' ? text : '';
+ const options = hudButtons.length ? { buttons: hudButtons } : {};
+ const statusHtml = this.mobileMode ? null : this._buildStatusHtml(statusText);
+ if (statusHtml) {
+ options.statusHtml = statusHtml;
+ }
+ const displayStatus = this.mobileMode ? '' : (statusText || text || '');
+ bottomTextContainer.innerHTML = formatHudMarkup(displayStatus, controls, options);
+ }
+
+ _buildStatusHtml(text) {
+ if (!this.state?.speedRunActive || typeof text !== 'string' || !text.length) {
+ return null;
+ }
+ const marker = 'SPEED RUN:';
+ const idx = text.toUpperCase().indexOf(marker);
+ if (idx === -1) {
+ return null;
+ }
+ const before = escapeHtml(text.slice(0, idx));
+ const highlight = escapeHtml(text.slice(idx));
+ if (!highlight.length) {
+ return null;
+ }
+ return `${before}${highlight}`;
+ }
+ highlightLossLine(index, durationMs = this.glowDuration) {
+ if (this.mode !== '1d') {
+ return;
+ }
+ if (this.alt1dMode) {
+ this._startAlt1dLineGlow(index, durationMs);
+ return;
+ }
+ if (!this.lineFrames || !this.lineFrames[index]) {
+ return;
+ }
+ if (index >= this.frameGlowState.length) {
+ this.frameGlowState.length = index + 1;
+ }
+ const duration = Math.max(30, durationMs || this.glowDuration);
+ const now = performance.now();
+ this.frameGlowState[index] = {
+ startTime: now,
+ duration,
+ };
+ const frame = this.lineFrames[index];
+ if (this.outlinePass && frame) {
+ this.outlineSelection.add(frame);
+ this._refreshOutlineSelection();
+ }
+ }
+ // Function to handle window resizing
+ onWindowResize() {
+ if (this.mobileMode) {
+ this.mobileCameraScalar = this._computeMobileCameraScalar();
+ }
+
+ // Calculate camera position to fit all grids
+ const fovRadians = (this.camera.fov * Math.PI) / 180;
+ const totalWidth = (this.numGrids + this.selectedGridScale - 1.0) * this.gridSize + (this.numGrids - 1) * this.spacing;
+ let distance = (totalWidth / 2) / Math.tan(fovRadians / 2);
+ if (this.customCameraDistance != null) {
+ distance = this.customCameraDistance;
+ }
+ if (this.alt1dMode) {
+ distance *= 1.25;
+ }
+ if (this.mobileMode) {
+ distance *= this.mobileCameraScalar;
+ }
+ this.camera_distance = distance;
+ this.camera_yOffset = this.mobileMode ? (totalWidth / 6) * this.mobileCameraScalar * 0.85 : totalWidth / 8;
+ this.camera.position.set(0, this.camera_yOffset, distance);
+
+ // Update camera aspect ratio and projection matrix
+ this.camera.aspect = window.innerWidth / window.innerHeight;
+ this.camera.updateProjectionMatrix();
+
+ // Update renderer size
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
+ this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
+ this.composer?.setSize(window.innerWidth, window.innerHeight);
+ this.outlinePass?.setSize(window.innerWidth, window.innerHeight);
+ }
+
+ // Function to adjust angles
+ adjustAngles(angleH, angleV) {
+ this.angleH += 2 * angleH;
+ this.angleV += 2 * angleV;
+ //this.angleV = this.normalizeDegree(this.angleV);
+ this.angleH = this.normalizeDegree(this.angleH);
+ this.angleV = Math.sign(this.angleV) * Math.min(Math.abs(this.angleV), this.maxAngleV);
+ }
+
+ // Function to reset angles
+ resetAngle() {
+ this.angleH = 0.0;
+ this.angleV = this.defaultAngleV;
+ }
+
+ // Helper function to normalize angles between 0 and 360 degrees
+ normalizeDegree(angle) {
+ return ((angle % 360) + 360) % 360;
+ }
+
+
+
+
+ // Initialize all charts (including confusion matrix heatmap)
+ initializeCharts() {
+ this.initializeLossChart();
+ this.initializeLastStepsChart();
+ this.initializeStepSizeChart();
+ this.initializeDimsAndStepsChart();
+ this.initializeConfusionMatrixHeatmap();
+ log("Charts initialized.");
+ }
+ // Optional: Initialize Confusion Matrix as an empty Heatmap
+ initializeConfusionMatrixHeatmap() {
+ const container = this.confusionMatrixContainer;
+ if (!container) {
+ return;
+ }
+ container.innerHTML = '';
+ const canvas = document.createElement('canvas');
+ canvas.className = 'confusion-matrix-canvas';
+ container.appendChild(canvas);
+ const ctx = canvas.getContext('2d');
+ const dataset = {
+ label: 'Confusion Matrix',
+ data: [],
+ borderWidth: 0.5,
+ borderColor: 'rgba(255, 255, 255, 0.05)',
+ backgroundColor: (context) => {
+ const value = context.raw?.v ?? 0;
+ return this._getHeatmapColor(value);
+ },
+ width: (ctxArg) => this._matrixCellWidth(ctxArg?.chart),
+ height: (ctxArg) => this._matrixCellHeight(ctxArg?.chart),
+ };
+ const xAxisConfig = {
+ type: 'category',
+ labels: CONFUSION_LABELS,
+ title: {
+ display: true,
+ text: 'Predicted Label',
+ color: '#ffffff',
+ },
+ ticks: { color: '#ffffff' },
+ grid: { color: 'rgba(255, 255, 255, 0.1)' },
+ };
+ const yAxisConfig = {
+ type: 'category',
+ labels: CONFUSION_LABELS,
+ reverse: true,
+ title: {
+ display: true,
+ text: 'Actual Label',
+ color: '#ffffff',
+ },
+ ticks: { color: '#ffffff' },
+ grid: { color: 'rgba(255, 255, 255, 0.1)' },
+ };
+ if (this.mobileMode) {
+ xAxisConfig.ticks = { display: false };
+ yAxisConfig.ticks = { display: false };
+ xAxisConfig.title.display = false;
+ yAxisConfig.title.display = false;
+ xAxisConfig.grid = { display: false };
+ yAxisConfig.grid = { display: false };
+ }
+ this.confusionMatrixChart = new Chart(ctx, {
+ type: 'matrix',
+ data: {
+ datasets: [dataset],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ title: (items) => {
+ const item = items[0];
+ const raw = item.raw || {};
+ return `Predicted: ${raw.x} • Actual: ${raw.y}`;
+ },
+ label: (item) => {
+ const value = item.raw?.v ?? 0;
+ return `Count: ${value}`;
+ },
+ },
+ },
+ },
+ layout: {
+ padding: {
+ top: 8,
+ left: 12,
+ right: 12,
+ bottom: 32,
+ },
+ },
+ scales: {
+ x: xAxisConfig,
+ y: yAxisConfig,
+ },
+ },
+ });
+ }
+
+ initializeLossChart() {
+ if (document.getElementById('lossChart')) {
+ const lineWidth = this.mobileMode ? 1.2 : 2.4;
+ this.lossChart = new Chart(document.getElementById('lossChart'), {
+ type: 'line',
+ data: {
+ labels: [], // Step numbers
+ datasets: [
+ { label: 'Train', data: [], borderColor: 'blue', fill: false },
+ { label: 'Val', data: [], borderColor: 'red', fill: false },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top', // Ensure legend is at the top of the chart area
+ labels: {
+ boxWidth: 2,
+ padding: 10,
+ },
+ },
+ },
+ layout: {
+ padding: {
+ top: 0, // Add padding to avoid overlap between legend and plot data
+ },
+ },
+ elements: {
+ line: {
+ borderWidth: lineWidth,
+ },
+ point: {
+ radius: 0,
+ hoverRadius: 0,
+ hitRadius: 4,
+ },
+ },
+ scales: {
+ x: {
+ title: {
+ display: !this.mobileMode,
+ text: 'Step',
+ },
+ ticks: {
+ display: !this.mobileMode,
+ },
+ },
+ y: {
+ title: {
+ display: true,
+ text: 'Loss',
+ },
+ },
+ },
+ },
+ });
+ }
+ }
+
+ // Initialize Last Steps Chart
+ initializeLastStepsChart() {
+ if (!this.mobileMode && document.getElementById('lastStepsChart')) {
+ const lineWidth = this.mobileMode ? 1.2 : 2.4;
+ this.lastStepsChart = new Chart(document.getElementById('lastStepsChart'), {
+ type: 'line',
+ data: {
+ labels: [], // Recent steps
+ datasets: [{ label: 'Train (recent)', data: [], borderColor: 'green', fill: false }],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top', // Ensure legend is at the top of the chart area
+ labels: {
+ boxWidth: 2,
+ padding: 10,
+ },
+ },
+ },
+ layout: {
+ padding: {
+ top: 0, // Add padding to avoid overlap between legend and plot data
+ },
+ },
+ elements: {
+ line: {
+ borderWidth: lineWidth,
+ },
+ point: {
+ radius: 0,
+ hoverRadius: 0,
+ hitRadius: 4,
+ },
+ },
+ scales: {
+ x: {
+ title: {
+ display: !this.mobileMode,
+ text: 'Step',
+ },
+ ticks: {
+ display: !this.mobileMode,
+ },
+ },
+ y: {
+ title: {
+ display: true,
+ text: 'Loss',
+ },
+ },
+ },
+ },
+ });
+ }
+ }
+
+
+ // Initialize Step Size Chart
+ initializeStepSizeChart() {
+ if (document.getElementById('stepSizeChart')) {
+ this.stepSizeChart = new Chart(document.getElementById('stepSizeChart'), {
+ type: 'bar',
+ data: {
+ labels: ['Step Size'],
+ datasets: [{ label: 'Log Step Size', data: [], backgroundColor: 'orange' }],
+ },
+ options: { responsive: true, indexAxis: 'y' },
+ });
+ }
+ }
+
+ // Initialize Dims and Steps Chart
+ initializeDimsAndStepsChart() {
+ if (document.getElementById('dimsAndStepsChart')) {
+ this.dimsAndStepsChart = new Chart(document.getElementById('dimsAndStepsChart'), {
+ type: 'bar',
+ data: {
+ labels: [], // Dimensions
+ datasets: [{ label: 'Cumulative Step', data: [], backgroundColor: 'teal' }],
+ },
+ options: { responsive: true, scales: { x: { title: { display: true, text: 'Dimension' } } } },
+ });
+ }
+ }
+
+
+ // Update Loss Chart
+ updateLossChart(trainLoss, valLoss, steps) {
+ //var lastLoss = trainLoss[trainLoss.length-1];
+ //this.state.updateBestScoreOrNot(lastLoss);
+ this.annotateBottomScreen(this.state.toString());
+ if (this.lossChart) {
+ this.lossChart.data.labels = steps;
+ this.lossChart.data.datasets[0].data = trainLoss;
+ this.lossChart.data.datasets[1].data = valLoss;
+ this.lossChart.update();
+ }
+ }
+
+ // Update Last Steps Chart
+ updateLastStepsChart(lastSteps, losses) {
+ if (!this.mobileMode && this.lastStepsChart) {
+ this.lastStepsChart.data.labels = lastSteps;
+ this.lastStepsChart.data.datasets[0].data = losses;
+ this.lastStepsChart.update();
+ }
+ }
+
+ // Update Step Size Chart
+ updateStepSizeChart(logStepSize) {
+ if (this.stepSizeChart) {
+ this.stepSizeChart.data.datasets[0].data = [logStepSize];
+ this.stepSizeChart.update();
+ }
+ }
+
+ // Update Dims and Steps Chart
+ updateDimsAndStepsChart(dimLabels, steps) {
+ if (this.dimsAndStepsChart) {
+ this.dimsAndStepsChart.data.labels = dimLabels;
+ this.dimsAndStepsChart.data.datasets[0].data = steps;
+ this.dimsAndStepsChart.update();
+ }
+ }
+ // Update Confusion Matrix Heatmap with new data
+ updateConfusionMatrix(confusionMatrix) {
+ if (!this.confusionMatrixChart) {
+ this.initializeConfusionMatrixHeatmap();
+ }
+ const chart = this.confusionMatrixChart;
+ if (!chart) {
+ return;
+ }
+ try {
+ if (!Array.isArray(confusionMatrix) || confusionMatrix.length === 0) {
+ throw new Error("Confusion matrix data must be a non-empty array.");
+ }
+ const numRows = confusionMatrix.length;
+ const numCols = Array.isArray(confusionMatrix[0]) ? confusionMatrix[0].length : 0;
+ if (
+ numCols === 0 ||
+ !confusionMatrix.every((row) => Array.isArray(row) && row.length === numCols)
+ ) {
+ throw new Error("Confusion matrix data is empty or improperly formatted.");
+ }
+ const labels = Array.from({ length: numCols }, (_, idx) => idx.toString());
+ const flatValues = confusionMatrix.flat();
+ const maxVal = Math.max(1, ...flatValues);
+ this.confusionMatrixMaxValue = maxVal;
+ this.confusionRows = numRows;
+ this.confusionCols = numCols;
+ const data = [];
+ for (let actual = 0; actual < numRows; actual += 1) {
+ for (let predicted = 0; predicted < numCols; predicted += 1) {
+ data.push({
+ x: labels[predicted],
+ y: labels[actual],
+ v: confusionMatrix[actual][predicted],
+ });
+ }
+ }
+ chart.data.datasets[0].data = data;
+ chart.options.scales.x.labels = labels;
+ chart.options.scales.y.labels = labels;
+ chart.update('none');
+ } catch (error) {
+ console.error("Failed to update confusion matrix heatmap:", error);
+ }
+ }
+ _matrixCellWidth(chart) {
+ if (!chart || !chart.chartArea) {
+ return 18;
+ }
+ const { width } = chart.chartArea;
+ const cols = Math.max(1, this.confusionCols || CONFUSION_LABELS.length);
+ const gap = Math.max(0, cols - 1) * 0.4;
+ return Math.max(6, (width - gap) / cols);
+ }
+
+ _matrixCellHeight(chart) {
+ if (!chart || !chart.chartArea) {
+ return 18;
+ }
+ const { height } = chart.chartArea;
+ const rows = Math.max(1, this.confusionRows || CONFUSION_LABELS.length);
+ const gap = Math.max(0, rows - 1) * 0.4;
+ return Math.max(6, (height - gap) / rows);
+ }
+
+ _getHeatmapColor(value) {
+ const ratio = value / Math.max(1, this.confusionMatrixMaxValue);
+ return interpolateHeatmapColor(ratio);
+ }
+
+ _createExampleChart(ctx, labels) {
+ return new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [
+ {
+ label: 'Probability',
+ data: [],
+ backgroundColor: 'rgba(255, 165, 0, 0.8)',
+ borderWidth: 0,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: (ctx) => `Prob: ${(ctx.raw ?? 0).toFixed(2)}`,
+ },
+ },
+ },
+ scales: {
+ x: {
+ ticks: { color: '#ffffff', font: { size: 9 } },
+ grid: { display: false },
+ title: { display: true, text: 'Class', color: '#ffffff', font: { size: 10 } },
+ },
+ y: {
+ beginAtZero: true,
+ suggestedMax: 1.2,
+ ticks: { color: '#ffffff', maxTicksLimit: 4 },
+ grid: { color: 'rgba(255, 255, 255, 0.08)' },
+ title: { display: true, text: 'Probability', color: '#ffffff', font: { size: 10 } },
+ },
+ },
+ },
+ });
+ }
+
+
+ updateMeshGrids(meshGrids = null) {
+ if (this.mode === '1d') {
+ return;
+ }
+ if (this.scene == null) {
+ return;
+ }
+ if (this.lineGroup) {
+ this.lineContainers.forEach((container) => {
+ container.children.forEach((child) => child.geometry?.dispose?.());
+ this.lineGroup.remove(container);
+ });
+ this.scene.remove(this.lineGroup);
+ this.lineGroup = null;
+ this.lineObjects = [];
+ this.lineFrames = [];
+ this.lineContainers = [];
+ this.centerLines = [];
+ this.horizontalLines = [];
+ this.lineScaleCache = [];
+ this.lineBaseColors = [];
+ this._resetAllFrameGlow();
+ }
+ // Use previous meshGrids if no parameter is provided
+ if (meshGrids === null && this.previousMeshGrids) {
+ meshGrids = this.previousMeshGrids;
+ } else {
+ this.previousMeshGrids = meshGrids; // Save current meshGrids for reuse
+ }
+
+ if (!meshGrids) {
+ return;
+ }
+
+ // Remove old grids and spheres if re-initializing
+ if (this.gridObjects.length > 0) {
+ this.gridObjects.forEach(grid => {
+ this.scene.remove(grid);
+ });
+ this.gridObjects = [];
+ }
+
+ if (this.sphereObjects && this.sphereObjects.length > 0) {
+ this.sphereObjects.forEach(sphere => {
+ this.scene.remove(sphere);
+ });
+ this.sphereObjects = [];
+ } else {
+ this.sphereObjects = [];
+ }
+
+ // Subtract center value from each grid
+ const originValue = meshGrids[0][Math.floor(this.gridSize / 2)][Math.floor(this.gridSize / 2)];
+ for (let i = 0; i < meshGrids.length; i++) {
+ for (let j = 0; j < meshGrids[i].length; j++) {
+ for (let k = 0; k < meshGrids[i][j].length; k++) {
+ meshGrids[i][j][k] -= originValue;
+ }
+ }
+ }
+
+ // Find the maximum absolute value for scaling
+ let maxAbsValue = 0;
+ meshGrids.forEach(grid => {
+ grid.forEach(row => {
+ row.forEach(value => {
+ maxAbsValue = Math.max(maxAbsValue, Math.abs(value));
+ });
+ });
+ });
+
+ const eps = 1e-3;
+ const scale = 1.5 * this.gridSize / (maxAbsValue + eps);
+
+ // Scale meshGrids
+ meshGrids = meshGrids.map(grid =>
+ grid.map(row =>
+ row.map(value => value * scale)
+ )
+ );
+
+ // Calculate total width of all grids to properly center them
+ const numGrids = meshGrids.length;
+ const totalWidth = (numGrids + this.selectedGridScale - 1.0) * this.gridSize + (numGrids - 1) * this.spacing;
+
+ // Create or update each grid and sphere
+ for (let i = 0; i < numGrids; i++) {
+ // Create new geometry for each grid
+ const geometry = new PlaneGeometry(this.gridSize, this.gridSize, this.gridSize - 1, this.gridSize - 1);
+ const color = this.gridColors[i % this.gridColors.length];
+ let baseOpacity = 1.0;//this.alpha;
+ let secondaryOpacity = baseOpacity * 0.3;
+
+ // Apply lower opacity for non-selected grids
+ if (i !== this.selectedGridIndex) {
+ secondaryOpacity = 0.05;
+ baseOpacity *= 0.4;
+ } else {
+ secondaryOpacity *= 0.8; // Make non-selected grids more transparent
+ }
+
+ // Update the geometry of the mesh to reflect the new heights and apply transparency for Z >= 0
+ const positions = geometry.attributes.position.array;
+ const alphas = new Float32Array(positions.length / 3); // Create an array to store alpha values for each vertex
+
+ for (let j = 0; j < this.gridSize; j++) {
+ for (let k = 0; k < this.gridSize; k++) {
+ const index = 3 * (j * this.gridSize + k);
+ const zValue = meshGrids[i][j][k];
+ positions[index + 2] = zValue; // Update Z value (height) of the grid
+
+ // Set alpha value lower for points where Z >= 0
+ alphas[j * this.gridSize + k] = zValue >= 0 ? secondaryOpacity : baseOpacity;
+ }
+ }
+
+ geometry.setAttribute('alpha', new BufferAttribute(alphas, 1)); // Add alpha as a vertex attribute
+ geometry.attributes.position.needsUpdate = true;
+
+ // Use ShaderMaterial to apply alpha for each vertex
+ const material = new ShaderMaterial({
+ uniforms: {
+ color: { value: color },
+ },
+ vertexShader: `
+ attribute float alpha;
+ varying float vAlpha;
+
+ void main() {
+ vAlpha = alpha;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `,
+ fragmentShader: `
+ uniform vec3 color;
+ varying float vAlpha;
+
+ void main() {
+ gl_FragColor = vec4(color, vAlpha);
+ }
+ `,
+ transparent: true,
+ wireframe: true,
+ });
+
+ const mesh = new Mesh(geometry, material);
+ mesh.rotation.x = -Math.PI / 2; // Rotate to lay flat
+
+ // Calculate position to center grids
+ let xOffset = -totalWidth / 2 + i * (this.gridSize + this.spacing) + this.gridSize / 2;
+
+ // Apply scaling if it's the selected grid
+ if (i > this.selectedGridIndex) {
+ xOffset += this.gridSize * (this.selectedGridScale - 1); // Adjust position for scaled grid
+ }
+ if (i === this.selectedGridIndex) {
+ xOffset += this.gridSize * (this.selectedGridScale - 1) / 2
+ mesh.scale.set(this.selectedGridScale, this.selectedGridScale, 1); // Scale selected grid on X, Z axes
+ } else {
+ mesh.scale.set(1, 1, 1); // Reset scale for non-selected grids
+ }
+
+ mesh.position.set(xOffset, 0, 0);
+ mesh.userData.gridIndex = i;
+
+ // Add new grid to scene and store it for later reference
+ this.scene.add(mesh);
+ this.gridObjects.push(mesh);
+
+ // Create a red sphere to represent the center point of each grid
+ const sphereGeometry = new SphereGeometry(1, 16, 16); // Radius = 1, segments for smoother look
+ const sphereMaterial = new MeshBasicMaterial({ color: 0xff0000 }); // Red color
+ const sphere = new Mesh(sphereGeometry, sphereMaterial);
+
+ // Position the sphere at the center of the current grid
+ sphere.position.set(xOffset, 0, 0); // Set it on the center of the grid
+
+ // Adjust Y position (height) to match the central height of the grid
+ const centerHeight = meshGrids[i][Math.floor(this.gridSize / 2)][Math.floor(this.gridSize / 2)];
+ sphere.position.y = centerHeight;
+
+ // Add the sphere to the scene and store it for later reference
+ this.scene.add(sphere);
+ this.sphereObjects.push(sphere);
+ }
+ }
+
+
+
+
+ // Function to get current horizontal and vertical angles
+ getAngles() {
+ return { angleH: this.angleH, angleV: this.angleV };
+ }
+
+ getCanvasElement() {
+ return this.renderer?.domElement ?? null;
+ }
+
+ // Function to get the selected grid index
+ getSelectedGrid() {
+ return this.selectedGridIndex;
+ }
+
+ // Function to increase zoom level
+ increaseZoom() {
+ this.scaleFactor = Math.max(0.7, this.scaleFactor - 0.05);
+ }
+
+ // Function to decrease zoom level
+ decreaseZoom() {
+ this.scaleFactor = Math.min(5.0, this.scaleFactor + 0.05);
+ }
+
+ // Function to increment selected grid
+ incrementSelectedGrid() {
+ this.selectedGridIndex = (this.selectedGridIndex + 1) % this.effectiveGrids;
+ }
+
+ // Function to decrement selected grid
+ decrementSelectedGrid() {
+ this.selectedGridIndex = (this.selectedGridIndex - 1 + this.effectiveGrids) % this.effectiveGrids; // Adding effectiveGrids to ensure non-negative index
+ }
+
+ // Function to set the selected grid
+ setSelectedGrid(gridIdx) {
+ this.selectedGridIndex = gridIdx;
+ }
+
+ selectGridAt(clientX, clientY) {
+ const canvas = this.getCanvasElement();
+ if (!canvas || !this.camera || !this.raycaster || !this.pointer) {
+ return null;
+ }
+
+ const rect = canvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) {
+ return null;
+ }
+
+ const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
+ const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1;
+
+ this.pointer.set(ndcX, ndcY);
+ this.raycaster.setFromCamera(this.pointer, this.camera);
+
+ const intersections = this.raycaster.intersectObjects(this.gridObjects, false);
+ if (!intersections.length) {
+ debugSelection('[View] selectGridAt: no intersection');
+ return null;
+ }
+
+ const target = intersections[0].object;
+ const gridIndex =
+ typeof target.userData?.gridIndex === 'number'
+ ? target.userData.gridIndex
+ : this.gridObjects.indexOf(target);
+
+ if (gridIndex < 0) {
+ debugSelection('[View] selectGridAt: intersection missing grid index');
+ return null;
+ }
+
+ if (gridIndex !== this.selectedGridIndex) {
+ debugSelection(`[View] selectGridAt: selecting grid ${gridIndex}`);
+ this.setSelectedGrid(gridIndex);
+ this.updateMeshGrids();
+ } else {
+ debugSelection(`[View] selectGridAt: grid ${gridIndex} already selected`);
+ }
+
+ return gridIndex;
+ }
+
+ updateLossLines(lines, { stepSpacing, labels } = {}) {
+ if (this.mode !== '1d' || !this.scene) {
+ return;
+ }
+ if (!Array.isArray(lines) || !lines.length) {
+ return;
+ }
+
+ if (this.alt1dMode) {
+ this._updateAlt1dLossLines(lines, { stepSpacing, labels });
+ return;
+ }
+
+ if (!this.lineGroup) {
+ this.lineGroup = new Group();
+ this.scene.add(this.lineGroup);
+ }
+
+ const count = lines.length;
+ this.effectiveGrids = count || 1;
+ const columns = Math.max(1, Math.min(3, count));
+ const rows = Math.max(1, Math.ceil(count / columns));
+ const cellWidth = this.gridSize * 0.9;
+ const cellHeight = this.gridSize * 0.6;
+ const gapX = this.gridSize * 0.05;
+ const gapY = this.rowSpacing != null ? this.rowSpacing : this.gridSize * 0.3;
+ const depthStep = this.depthStep != null ? this.depthStep : 0.01;
+
+ while (this.lineObjects.length < count) {
+ const material = new LineBasicMaterial({ color: 0xffffff, linewidth: 2.5 });
+ const geometry = new BufferGeometry();
+ const line = new Line(geometry, material);
+
+ const frameMaterial = new LineBasicMaterial({ color: 0xffffff, linewidth: 1.5 });
+ const frameGeometry = new BufferGeometry();
+ const frame = new LineSegments(frameGeometry, frameMaterial);
+ frameMaterial.transparent = true;
+ frameMaterial.opacity = 0.9;
+ frame.scale.set(1, 1, 1);
+
+ const centerMaterial = new LineDashedMaterial({
+ color: 0xffffff,
+ linewidth: 1,
+ dashSize: 1,
+ gapSize: 1,
+ transparent: true,
+ opacity: 0.5,
+ depthWrite: false,
+ });
+ centerMaterial.depthTest = false;
+ const centerGeometry = new BufferGeometry();
+ const centerLine = new Line(centerGeometry, centerMaterial);
+
+ const hMaterial = new LineDashedMaterial({
+ color: 0xffffff,
+ linewidth: 1,
+ dashSize: 1,
+ gapSize: 1,
+ transparent: true,
+ opacity: 0.5,
+ depthWrite: false,
+ });
+ hMaterial.depthTest = false;
+ const hGeometry = new BufferGeometry();
+ const hLine = new Line(hGeometry, hMaterial);
+
+ const container = new Group();
+ container.add(frame);
+ container.add(centerLine);
+ container.add(hLine);
+ container.add(line);
+ this.lineGroup.add(container);
+
+ frame.renderOrder = 1;
+ centerLine.renderOrder = 0;
+ hLine.renderOrder = 0;
+ line.renderOrder = 2;
+
+ this.lineContainers.push(container);
+ this.lineFrames.push(frame);
+ this.centerLines.push(centerLine);
+ this.horizontalLines.push(hLine);
+ this.lineObjects.push(line);
+ }
+
+ while (this.lineObjects.length > count) {
+ const line = this.lineObjects.pop();
+ const idx = this.lineObjects.length;
+ const frame = this.lineFrames.pop();
+ const centerLine = this.centerLines.pop();
+ const hLine = this.horizontalLines.pop();
+ this.lineBaseColors.pop();
+ if (frame) {
+ frame.scale.set(1, 1, 1);
+ this.outlineSelection.delete(frame);
+ }
+ if (this.frameGlowState[idx]) {
+ this.frameGlowState[idx] = null;
+ }
+ const container = this.lineContainers.pop();
+ if (container) {
+ container.remove(line);
+ if (frame) {
+ container.remove(frame);
+ }
+ if (centerLine) {
+ container.remove(centerLine);
+ }
+ if (hLine) {
+ container.remove(hLine);
+ }
+ this.lineGroup.remove(container);
+ }
+ frame?.geometry?.dispose?.();
+ centerLine?.geometry?.dispose?.();
+ centerLine?.material?.dispose?.();
+ hLine?.geometry?.dispose?.();
+ hLine?.material?.dispose?.();
+ line.geometry.dispose?.();
+ }
+
+ this._refreshOutlineSelection();
+ this.frameGlowState.length = count;
+ this.lineScaleCache.length = count;
+ const halfWidth = cellWidth / 2;
+ const halfHeight = cellHeight / 2;
+ const lerpAlpha = 0.35;
+ const eps = 1e-6;
+
+ for (let i = 0; i < count; i += 1) {
+ const data = lines[i];
+ const line = this.lineObjects[i];
+ const frame = this.lineFrames[i];
+ const container = this.lineContainers[i];
+ const hLine = this.horizontalLines[i];
+ if (!Array.isArray(data) || !line || !container || !frame) {
+ continue;
+ }
+ if (this.frameGlowState[i] === undefined) {
+ this.frameGlowState[i] = null;
+ }
+
+ const length = data.length;
+ if (!length) {
+ continue;
+ }
+
+ const mid = (length - 1) / 2;
+ const baseline = data[Math.max(0, Math.floor(mid))];
+ let maxAbs = 1e-6;
+ for (let j = 0; j < length; j += 1) {
+ maxAbs = Math.max(maxAbs, Math.abs(data[j] - baseline));
+ }
+ const preMaxAbs = maxAbs;
+
+ const scaleX = length > 1 ? cellWidth / (length - 1) : cellWidth;
+ let scaleY = 0;
+ if (maxAbs > eps) {
+ const targetScale = halfHeight / maxAbs;
+ const previous = this.lineScaleCache[i] ?? targetScale;
+ const lerped = MathUtils.lerp(previous, targetScale, lerpAlpha);
+ scaleY = Math.min(lerped, targetScale);
+ this.lineScaleCache[i] = scaleY;
+ } else {
+ this.lineScaleCache[i] = 0;
+ }
+ let postMaxAbs = 0;
+
+ let geometry = line.geometry;
+ if (!(geometry instanceof BufferGeometry)) {
+ geometry = new BufferGeometry();
+ line.geometry = geometry;
+ }
+
+ let positionAttr = geometry.getAttribute('position');
+ if (!positionAttr || positionAttr.array.length !== length * 3) {
+ const positions = new Float32Array(length * 3);
+ positionAttr = new BufferAttribute(positions, 3);
+ geometry.setAttribute('position', positionAttr);
+ }
+
+ const positions = positionAttr.array;
+ for (let j = 0; j < length; j += 1) {
+ const idx = j * 3;
+ positions[idx] = (j - mid) * scaleX;
+ const normalizedY = scaleY > 0 ? (data[j] - baseline) * scaleY : 0;
+ positions[idx + 1] = normalizedY;
+ positions[idx + 2] = 0;
+ postMaxAbs = Math.max(postMaxAbs, Math.abs(normalizedY));
+ }
+
+ positionAttr.needsUpdate = true;
+ geometry.computeBoundingSphere();
+ geometry.setDrawRange(0, length);
+
+ const color = this.gridColors[i % this.gridColors.length];
+ const baseColor = color.clone();
+ this.lineBaseColors[i] = baseColor;
+ line.material.color.copy(color);
+ line.material.linewidth = 2.5;
+ line.material.needsUpdate = true;
+ line.material.depthTest = false;
+ line.material.depthWrite = false;
+
+ frame.material.color.copy(color);
+ frame.material.linewidth = 1.5;
+ frame.material.needsUpdate = true;
+ frame.material.depthTest = false;
+ frame.material.depthWrite = false;
+ frame.scale.set(1, 1, 1);
+
+ const framePositions = new Float32Array([
+ -halfWidth, halfHeight, 0,
+ halfWidth, halfHeight, 0,
+ halfWidth, halfHeight, 0,
+ halfWidth, -halfHeight, 0,
+ halfWidth, -halfHeight, 0,
+ -halfWidth, -halfHeight, 0,
+ -halfWidth, -halfHeight, 0,
+ -halfWidth, halfHeight, 0,
+ ]);
+ frame.geometry.setAttribute('position', new BufferAttribute(framePositions, 3));
+ frame.geometry.attributes.position.needsUpdate = true;
+ frame.geometry.computeBoundingSphere();
+ frame.geometry.setDrawRange(0, 8);
+
+ const centerLine = this.centerLines[i];
+ const centerPositions = new Float32Array([
+ 0, halfHeight, -0.01,
+ 0, -halfHeight, -0.01,
+ ]);
+ centerLine.geometry.setAttribute('position', new BufferAttribute(centerPositions, 3));
+ centerLine.geometry.attributes.position.needsUpdate = true;
+ centerLine.material.dashSize = Math.max(0.02 * cellHeight, 0.5);
+ centerLine.material.gapSize = Math.max(0.04 * cellHeight, 0.8);
+ centerLine.material.needsUpdate = true;
+ centerLine.computeLineDistances();
+ centerLine.material.opacity = 0.8;
+
+ if (hLine) {
+ const hPositions = new Float32Array([
+ -halfWidth, 0, -0.01,
+ halfWidth, 0, -0.01,
+ ]);
+ hLine.geometry.setAttribute('position', new BufferAttribute(hPositions, 3));
+ hLine.geometry.attributes.position.needsUpdate = true;
+ hLine.material.dashSize = Math.max(0.02 * cellWidth, 0.5);
+ hLine.material.gapSize = Math.max(0.04 * cellWidth, 0.8);
+ hLine.material.needsUpdate = true;
+ hLine.computeLineDistances();
+ hLine.material.opacity = 0.6;
+ hLine.material.depthTest = false;
+ hLine.material.depthWrite = false;
+ }
+
+ const col = i % columns;
+ const row = Math.floor(i / columns);
+ const xOffset = (col - (columns - 1) / 2) * (cellWidth + gapX);
+ const yOffset = rows > 1 ? -(row - (rows - 1) / 2) * (cellHeight + gapY) : 0;
+ const zOffset = -(row - (rows - 1) / 2) * depthStep;
+ container.position.set(xOffset, yOffset, zOffset);
+
+ if (this.debug) {
+ const info = {
+ line: i,
+ preMaxAbs,
+ postMaxAbs,
+ halfHeight,
+ scaleY,
+ stepSize: this.state?.stepSize,
+ };
+ log(`[LandscapeView] loss-line bounds ${JSON.stringify(info)}`);
+ }
+ }
+ }
+ render() {
+ requestAnimationFrame(() => this.render());
+
+ const now = performance.now();
+ let maxIntensity = 0;
+
+ // Rotate each grid according to the current angles
+ this.gridObjects.forEach((grid) => {
+ grid.rotation.z = MathUtils.degToRad(this.angleH);
+ grid.rotation.x = MathUtils.degToRad(this.angleV - 90);
+ });
+
+ if (this.lineGroup) {
+ if (this.mode === '1d') {
+ this.lineGroup.rotation.set(0, 0, 0);
+ } else {
+ this.lineGroup.rotation.y = MathUtils.degToRad(this.angleH);
+ this.lineGroup.rotation.x = MathUtils.degToRad(this.angleV - 90);
+ }
+ }
+
+ if (this.alt1dMode) {
+ maxIntensity = this._renderAlt1dGlow(now);
+ } else {
+ for (let i = 0; i < this.frameGlowState.length; i += 1) {
+ const state = this.frameGlowState[i];
+ const frame = this.lineFrames[i];
+ if (!state || !frame) {
+ continue;
+ }
+ const elapsed = now - state.startTime;
+ if (elapsed >= state.duration) {
+ this.frameGlowState[i] = null;
+ frame.scale.set(1, 1, 1);
+ this._restoreFrameMaterial(i);
+ if (this.outlineSelection.delete(frame)) {
+ this._refreshOutlineSelection();
+ }
+ continue;
+ }
+ const intensity = 1 - elapsed / state.duration;
+ const expandScale = 1 + this.glowExpand * intensity;
+ frame.scale.set(expandScale, expandScale, 1);
+ this._applyFrameGlowAppearance(i, intensity);
+ maxIntensity = Math.max(maxIntensity, intensity);
+ }
+ }
+
+ if (this.outlinePass) {
+ if (maxIntensity > 0) {
+ this.outlinePass.edgeStrength = this.glowEdgeStrength * maxIntensity;
+ this.outlinePass.edgeThickness = 1 + (this.glowExpand * 10 * maxIntensity);
+ } else {
+ this.outlinePass.edgeStrength = 0;
+ }
+ }
+
+ if (this.composer) {
+ this.composer.render();
+ } else {
+ this.renderer.render(this.scene, this.camera);
+ }
+ this.labelRenderer?.render(this.scene, this.camera);
+ }
+
+ _applyFrameGlowAppearance(index, intensity) {
+ const frame = this.lineFrames?.[index];
+ const baseColor = this.lineBaseColors?.[index];
+ if (!frame || !frame.material || !baseColor) {
+ return;
+ }
+ const blended = this.tempColor.copy(baseColor).lerp(this.highlightColor, Math.min(1, intensity * 0.6));
+ frame.material.color.copy(blended);
+ frame.material.opacity = 0.9 + 0.3 * intensity;
+ frame.material.needsUpdate = true;
+ }
+
+ _restoreFrameMaterial(index) {
+ const frame = this.lineFrames?.[index];
+ const baseColor = this.lineBaseColors?.[index];
+ if (!frame || !frame.material || !baseColor) {
+ return;
+ }
+ frame.material.color.copy(baseColor);
+ frame.material.opacity = 0.9;
+ frame.material.needsUpdate = true;
+ }
+
+ _refreshOutlineSelection() {
+ if (this.outlinePass) {
+ this.outlinePass.selectedObjects = Array.from(this.outlineSelection);
+ }
+ }
+
+ _resetAllFrameGlow() {
+ if (this.alt1dMode) {
+ for (let i = 0; i < this.alt1dGlowStates.length; i += 1) {
+ this.alt1dGlowStates[i] = null;
+ }
+ for (let i = 0; i < this.lineObjects.length; i += 1) {
+ const line = this.lineObjects[i];
+ const baseColor = this.lineBaseColors[i];
+ if (line && baseColor) {
+ this._restoreAlt1dLineAppearance(line, baseColor);
+ }
+ }
+ for (let idx = 0; idx < this.alt1dFrames.length; idx += 1) {
+ const frame = this.alt1dFrames[idx];
+ const base = this.alt1dFrameBaseColors[idx];
+ if (frame && frame.material && base) {
+ frame.material.color.copy(base);
+ frame.material.needsUpdate = true;
+ }
+ this.outlineSelection.delete(frame);
+ }
+ }
+ if (this.lineFrames) {
+ for (let i = 0; i < this.lineFrames.length; i += 1) {
+ const frame = this.lineFrames[i];
+ if (frame) {
+ frame.scale.set(1, 1, 1);
+ }
+ this._restoreFrameMaterial(i);
+ this.frameGlowState[i] = null;
+ }
+ }
+ this.outlineSelection.clear();
+ this._refreshOutlineSelection();
+ }
+ // Update Example Images
+ updateExamples(images) {
+ log('Received images for update');
+
+ // Select side container and get the individual example cells
+ const sideContainer = document.getElementById('sideContainer');
+ if (!sideContainer) {
+ return;
+ }
+ const exampleCells = sideContainer.getElementsByClassName('example-cell');
+
+ Array.from(exampleCells).forEach((exampleCell, index) => {
+ const previousImage = exampleCell.querySelector('.example-image');
+ if (previousImage && previousImage.parentElement === exampleCell) {
+ exampleCell.removeChild(previousImage);
+ }
+
+ // Create a new canvas element for the image
+ const canvas = document.createElement('canvas');
+ canvas.width = images[index][0].length; // Assuming the data is [height][width]
+ canvas.height = images[index].length;
+
+ const ctx = canvas.getContext('2d');
+ const imageDataObject = ctx.createImageData(canvas.width, canvas.height);
+
+ // Assuming the data is grayscale (0-1), set pixels accordingly
+ for (let y = 0; y < canvas.height; y++) {
+ for (let x = 0; x < canvas.width; x++) {
+ const pixelIndex = (y * canvas.width + x) * 4;
+ const pixelValue = images[index][y][x];
+
+ imageDataObject.data[pixelIndex] = pixelValue * 255; // Red
+ imageDataObject.data[pixelIndex + 1] = pixelValue * 255; // Green
+ imageDataObject.data[pixelIndex + 2] = pixelValue * 255; // Blue
+ imageDataObject.data[pixelIndex + 3] = 255; // Alpha (fully opaque)
+ }
+ }
+
+ // Put the image data on the canvas
+ ctx.putImageData(imageDataObject, 0, 0);
+ canvas.classList.add('example-image');
+ canvas.style.backgroundColor = 'transparent'; // Transparent background
+
+ // Append the canvas to the corresponding example cell
+ exampleCell.appendChild(canvas);
+ });
+ }
+ // Update Example Predictions (Bar Charts)
+ updateExamplePreds(predictions) {
+ //log('Received predictions for update');
+
+ // Select side container and get the individual example cells
+ const sideContainer = document.getElementById('sideContainer');
+ if (!sideContainer) {
+ return;
+ }
+ const exampleCells = sideContainer.getElementsByClassName('example-cell');
+
+ predictions.forEach((prediction, index) => {
+ // Get or create chart container for the corresponding example cell
+ if (exampleCells.length <= index) {
+ return;
+ }
+ let chartDiv = exampleCells[index].querySelector('.example-chart');
+
+ if (!chartDiv) {
+ // If the chartDiv doesn't exist, create it
+ chartDiv = document.createElement('div');
+ chartDiv.id = `chartDiv${index + 1}`;
+ chartDiv.classList.add('example-chart');
+ exampleCells[index].appendChild(chartDiv);
+ }
+ let canvas = chartDiv.querySelector('canvas');
+ if (!canvas) {
+ canvas = document.createElement('canvas');
+ canvas.className = 'example-chart-canvas';
+ chartDiv.appendChild(canvas);
+ }
+ const ctx = canvas.getContext('2d');
+ const labels = Array.from({ length: prediction.length }, (_, i) => i.toString());
+ let chart = this.exampleCharts[index];
+ if (!chart) {
+ chart = this._createExampleChart(ctx, labels);
+ this.exampleCharts[index] = chart;
+ } else {
+ chart.data.labels = labels;
+ }
+ chart.data.datasets[0].data = prediction;
+ chart.update('none');
+ });
+ }
+
+ _setBodyHelpOpen(isOpen) {
+ if (typeof document === 'undefined' || !document.body) {
+ return;
+ }
+ document.body.classList.toggle('help-overlay-open', Boolean(isOpen));
+ }
+
+ showImage(screenId) {
+ const container = document.getElementById('imageContainer');
+ if (!container) {
+ return;
+ }
+ this.manualKeyOverlay = false;
+ const isTourScreen = TOUR_SCREENS.includes(screenId);
+ this._ensureHelpOverlayElements();
+ if (!this.helpOverlay || !this.helpOverlayContent) {
+ return;
+ }
+ container.classList.add('help-open');
+ this._setBodyHelpOpen(true);
+ this.helpOverlay.classList.add('visible');
+ this.helpOverlay.dataset.mode = isTourScreen ? 'tour' : 'media';
+ this.helpContentMode = isTourScreen ? 'tour' : 'media';
+ if (isTourScreen) {
+ this._renderTourScreen(screenId);
+ this._attachHelpKeyListener();
+ return;
+ }
+ this.helpOverlayContent.innerHTML = '';
+ const img = document.createElement('img');
+ img.className = 'help-overlay__image';
+ img.alt = 'Help screen';
+ img.src = screenId;
+ this.helpOverlayContent.appendChild(img);
+ this._attachHelpKeyListener();
+ }
+
+ hideImage() {
+ const container = document.getElementById('imageContainer');
+ if (!container) {
+ return;
+ }
+ container.classList.remove('help-open');
+ this._setBodyHelpOpen(false);
+ this._detachHelpKeyListener();
+ this.manualKeyOverlay = false;
+ if (this.helpOverlay) {
+ this.helpOverlay.classList.remove('visible');
+ this.helpOverlay.removeAttribute('data-tour-screen');
+ this.helpOverlay.removeAttribute('data-mode');
+ }
+ }
+
+ _ensureHelpOverlayElements() {
+ if (this.helpOverlay && this.helpOverlayContent) {
+ return;
+ }
+ const container = document.getElementById('imageContainer');
+ if (!container) {
+ return;
+ }
+ const overlay = document.createElement('div');
+ overlay.className = 'help-overlay';
+ overlay.setAttribute('role', 'dialog');
+ overlay.setAttribute('aria-label', 'Guide');
+
+ const contentWrapper = document.createElement('div');
+ contentWrapper.className = 'help-overlay__content';
+ contentWrapper.addEventListener('click', (event) => event.stopPropagation());
+
+ const closeBtn = document.createElement('button');
+ closeBtn.type = 'button';
+ closeBtn.className = 'help-overlay__close';
+ closeBtn.setAttribute('aria-label', 'Close help screens');
+ closeBtn.innerHTML = '×';
+ closeBtn.addEventListener('click', (event) => {
+ event?.preventDefault?.();
+ event?.stopPropagation?.();
+ if (this.helpContentMode === 'tour' && this.state) {
+ if (typeof this.state.closeHelpScreens === 'function') {
+ this.state.closeHelpScreens();
+ } else {
+ this.state.helpScreenIdx = -1;
+ }
+ }
+ this.hideImage();
+ });
+
+ const tabs = document.createElement('div');
+ tabs.className = 'help-overlay__tabs';
+ const controlsTab = document.createElement('button');
+ controlsTab.type = 'button';
+ controlsTab.dataset.helpTab = 'controls';
+ controlsTab.textContent = 'Controls';
+ controlsTab.addEventListener('click', () => this._showHelpTabs('controls'));
+ const howTab = document.createElement('button');
+ howTab.type = 'button';
+ howTab.dataset.helpTab = 'how';
+ howTab.textContent = 'How it works';
+ howTab.addEventListener('click', () => this._showHelpTabs('how'));
+ tabs.appendChild(controlsTab);
+ tabs.appendChild(howTab);
+
+ const contentBody = document.createElement('div');
+ contentBody.className = 'help-overlay__body';
+
+ contentWrapper.appendChild(closeBtn);
+ contentWrapper.appendChild(tabs);
+ contentWrapper.appendChild(contentBody);
+ overlay.appendChild(contentWrapper);
+
+ overlay.addEventListener('click', (event) => {
+ const isMobile = this.mobileMode;
+ const clickedOverlay = event.target === overlay;
+ if (clickedOverlay && this.helpContentMode === 'tour') {
+ this._advanceHelpScreen();
+ return;
+ }
+ if (isMobile && event.target.closest && !event.target.closest('.tour-btn')) {
+ this.hideImage();
+ }
+ });
+
+ container.appendChild(overlay);
+ this.helpOverlay = overlay;
+ this.helpOverlayContent = contentBody;
+ this.helpTabsBar = tabs;
+ this.helpTabs = { controls: controlsTab, how: howTab };
+ }
+
+ _toggleTabs(isVisible) {
+ if (!this.helpTabsBar) {
+ return;
+ }
+ this.helpTabsBar.classList.toggle('is-visible', Boolean(isVisible));
+ }
+
+ _setOverlayMode(mode = 'default') {
+ if (!this.helpOverlay) {
+ return;
+ }
+ this.helpOverlay.classList.toggle('is-splash', mode === 'splash');
+ }
+
+ _renderTourScreen(screenId) {
+ if (!this.helpOverlayContent) {
+ return;
+ }
+ this.helpContentMode = 'tour';
+ this.helpOverlay?.setAttribute('data-mode', 'tour');
+ this.helpOverlay?.setAttribute('data-tour-screen', screenId);
+ this._setOverlayMode('default');
+ this._toggleTabs(false);
+ this.helpOverlayContent.innerHTML = '';
+ let screen = null;
+ switch (screenId) {
+ case 'tour_splash':
+ this._setOverlayMode('splash');
+ screen = this._buildSplashScreen();
+ break;
+ case 'tour_welcome':
+ screen = this._buildWelcomeScreen();
+ break;
+ case 'tour_modes':
+ screen = this._buildModeScreen();
+ break;
+ case 'tour_controls':
+ screen = this._buildControlsScreen();
+ break;
+ case 'tour_canvas':
+ screen = this._buildCanvasScreen();
+ break;
+ case 'tour_speed':
+ screen = this._buildSpeedRunPreScreen();
+ break;
+ default:
+ break;
+ }
+ if (screen) {
+ this.helpOverlayContent.appendChild(screen);
+ }
+ }
+
+ _buildSplashScreen() {
+ const screen = document.createElement('div');
+ screen.className = 'tour-screen tour-screen--splash';
+ screen.classList.add('tour-screen--splash-active');
+ const teardown = () => {
+ window.removeEventListener('keydown', handleKey, true);
+ window.removeEventListener('pointerdown', handlePointer, true);
+ };
+ const logo = document.createElement('img');
+ logo.className = 'tour-splash__logo';
+ logo.src = SPLASH_LOGO_URL;
+ logo.alt = 'Human Descent logo';
+ screen.append(logo);
+ const dismiss = () => {
+ if (!screen.classList.contains('tour-screen--splash-active')) return;
+ screen.classList.remove('tour-screen--splash-active');
+ screen.classList.add('tour-screen--splash-fade');
+ teardown();
+ clearTimeout(autoTimer);
+ setTimeout(() => this._advanceHelpScreen(), 450);
+ };
+ const handleKey = () => dismiss();
+ const handlePointer = () => dismiss();
+ window.addEventListener('keydown', handleKey, true);
+ window.addEventListener('pointerdown', handlePointer, true);
+ const autoTimer = setTimeout(dismiss, 500);
+ return screen;
+ }
+
+ _selectTourMode(mode) {
+ if (this.helpTourSelection === mode) {
+ return;
+ }
+ this.helpTourSelection = mode;
+ if (this.helpOverlay?.getAttribute('data-tour-screen') === 'tour_modes') {
+ this._renderTourScreen('tour_modes');
+ }
+ }
+
+ _advanceHelpScreen() {
+ if (this.helpContentMode !== 'tour') {
+ return;
+ }
+ if (!this.state) {
+ this.hideImage();
+ return;
+ }
+ const prevIdx = this.state.helpScreenIdx;
+ if (typeof this.state.nextHelpScreen === 'function') {
+ this.state.nextHelpScreen();
+ } else {
+ this.state.helpScreenIdx = -1;
+ }
+ if (this.state.helpScreenIdx === -1 || this.state.helpScreenIdx === prevIdx) {
+ this.hideImage();
+ return;
+ }
+ const nextScreen = this.state.helpScreenFns?.[this.state.helpScreenIdx];
+ if (nextScreen) {
+ this.showImage(nextScreen);
+ } else {
+ this.hideImage();
+ }
+ }
+
+ _attachHelpKeyListener() {
+ if (this._helpKeyListenerAttached || typeof window === 'undefined') {
+ return;
+ }
+ window.addEventListener('keydown', this._boundHelpKeydown, true);
+ this._helpKeyListenerAttached = true;
+ }
+
+ _detachHelpKeyListener() {
+ if (!this._helpKeyListenerAttached || typeof window === 'undefined') {
+ return;
+ }
+ window.removeEventListener('keydown', this._boundHelpKeydown, true);
+ this._helpKeyListenerAttached = false;
+ }
+
+ _handleHelpKeydown(event) {
+ if (!this.state || this.state.helpScreenIdx === -1 || this.helpContentMode !== 'tour') {
+ return;
+ }
+ const key = event.key?.toLowerCase?.();
+ const activeScreen = this.helpOverlay?.getAttribute('data-tour-screen');
+ if (activeScreen === 'tour_splash' && key !== 'escape') {
+ return;
+ }
+ if (key === 'enter' || key === ' ') {
+ event.preventDefault();
+ this._advanceHelpScreen();
+ } else if (key === 'escape') {
+ event.preventDefault();
+ if (typeof this.state.closeHelpScreens === 'function') {
+ this.state.closeHelpScreens();
+ } else {
+ this.state.helpScreenIdx = -1;
+ }
+ this.hideImage();
+ }
+ }
+
+ _getHudesClient() {
+ if (typeof window !== 'undefined' && window.__hudesClient) {
+ return window.__hudesClient;
+ }
+ return null;
+ }
+
+ _showHelpTabs(tabId = 'controls') {
+ this._ensureHelpOverlayElements();
+ const container = document.getElementById('imageContainer');
+ if (!container || !this.helpOverlay || !this.helpOverlayContent) {
+ return;
+ }
+ this.manualKeyOverlay = true;
+ this.helpContentMode = 'tabs';
+ container.classList.add('help-open');
+ this._setBodyHelpOpen(true);
+ this.helpOverlay.classList.add('visible');
+ this.helpOverlay.setAttribute('data-mode', 'tabs');
+ this._setOverlayMode('default');
+ this._toggleTabs(true);
+ this._setActiveHelpTab(tabId);
+ this._renderHelpTab(tabId);
+ }
+
+ _setActiveHelpTab(tabId) {
+ this.activeHelpTab = tabId;
+ if (!this.helpTabs) {
+ return;
+ }
+ Object.entries(this.helpTabs).forEach(([id, btn]) => {
+ if (btn) {
+ btn.classList.toggle('is-active', id === tabId);
+ }
+ });
+ }
+
+ _renderHelpTab(tabId) {
+ if (!this.helpOverlayContent) {
+ return;
+ }
+ this.helpOverlayContent.innerHTML = '';
+ if (tabId === 'how') {
+ const panel = document.createElement('div');
+ panel.className = 'help-tab help-tab--how';
+ const list = document.createElement('ul');
+ HOW_IT_WORKS_POINTS.forEach((point) => {
+ const item = document.createElement('li');
+ item.innerHTML = point;
+ list.appendChild(item);
+ });
+ panel.append(list);
+ this.helpOverlayContent.appendChild(panel);
+ return;
+ }
+ const panel = document.createElement('div');
+ panel.className = 'help-tab help-tab--controls tour-screen tour-screen--keys';
+ const list = document.createElement('ul');
+ list.className = 'tour-legend';
+ const rows = this.mobileMode ? MOBILE_CONTROLS_GRID : DESKTOP_CONTROLS_GRID;
+ rows.forEach((row) => {
+ const item = document.createElement('li');
+ const key = document.createElement('span');
+ key.className = 'tour-key';
+ key.textContent = row.value;
+ const label = document.createElement('span');
+ label.className = 'tour-label';
+ label.textContent = row.label;
+ item.append(key, label);
+ list.appendChild(item);
+ });
+ panel.append(list);
+ this.helpOverlayContent.appendChild(panel);
+ }
+
+ openHelpOverlay(tabId = 'controls') {
+ this._showHelpTabs(tabId);
+ }
+
+ openTutorialOverlay() {
+ this._ensureHelpOverlayElements();
+ const container = document.getElementById('imageContainer');
+ if (!container || !this.helpOverlay || !this.helpOverlayContent) {
+ return;
+ }
+ this.manualKeyOverlay = true;
+ this.helpContentMode = 'tour';
+ container.classList.add('help-open');
+ this._setBodyHelpOpen(true);
+ this.helpOverlay.classList.add('visible');
+ this.helpOverlay.setAttribute('data-mode', 'tour');
+ this.helpOverlay.setAttribute('data-tour-screen', 'tour_canvas');
+ this._toggleTabs(false);
+ this._renderTourScreen('tour_canvas');
+ }
+
+ isManualKeyOverlayVisible() {
+ return Boolean(this.manualKeyOverlay);
+ }
+
+ _buildWelcomeScreen() {
+ const screen = document.createElement('div');
+ screen.className = 'tour-screen tour-screen--welcome';
+ const hero = document.createElement('div');
+ hero.className = 'tour-hero';
+
+ const visual = document.createElement('div');
+ visual.className = 'tour-hero__visual';
+ const gif = document.createElement('img');
+ gif.className = 'tour-hero__gif';
+ gif.src = SPLASH_MEDIA_URL;
+ gif.alt = 'CNN loss landscape animation';
+ visual.appendChild(gif);
+
+ const body = document.createElement('div');
+ body.className = 'tour-hero__body';
+ const eyebrow = document.createElement('p');
+ eyebrow.className = 'tour-eyebrow';
+ eyebrow.textContent = 'YOU ARE THE OPTIMIZER.';
+ const list = document.createElement('ul');
+ list.className = 'tour-welcome__list';
+ [
+ 'The loss landscape, decoded: height = loss; lighter color = lower loss.',
+ 'X and Y are step sizes in weight space.',
+ 'Pick the right descent through saddles, steer around local minima, and balance batch size, validation frequency, and speed to steal the 2-minute MNIST crown.',
+ 'Whichever path you choose, we’ll see you at the bottom.',
+ ].forEach((copy) => {
+ const item = document.createElement('li');
+ item.textContent = copy;
+ list.appendChild(item);
+ });
+ const ctas = document.createElement('div');
+ ctas.className = 'tour-cta-row';
+ const playBtn = document.createElement('button');
+ playBtn.type = 'button';
+ playBtn.className = 'tour-btn primary';
+ playBtn.textContent = 'Play';
+ playBtn.addEventListener('click', (event) => {
+ event?.preventDefault?.();
+ this._advanceHelpScreen();
+ });
+ const infoBtn = document.createElement('button');
+ infoBtn.type = 'button';
+ infoBtn.className = 'tour-btn ghost';
+ infoBtn.textContent = 'What is this?';
+ infoBtn.addEventListener('click', (event) => {
+ event?.preventDefault?.();
+ this._showHelpTabs('how');
+ });
+ ctas.append(playBtn, infoBtn);
+
+ body.append(eyebrow, list, ctas);
+ hero.append(visual, body);
+ screen.append(hero);
+ return screen;
+ }
+ _buildModeScreen() {
+ const screen = document.createElement('div');
+ screen.className = 'tour-screen tour-screen--modes';
+ const eyebrow = document.createElement('p');
+ eyebrow.className = 'tour-eyebrow';
+ eyebrow.textContent = 'Pick your mode';
+ screen.appendChild(eyebrow);
+ const modesWrap = document.createElement('div');
+ modesWrap.className = 'tour-modes';
+ MODE_CARDS.forEach((cardDef) => {
+ const card = document.createElement('button');
+ card.type = 'button';
+ card.className = 'tour-mode-card';
+ card.dataset.mode = cardDef.id;
+ if (this.helpTourSelection === cardDef.id) {
+ card.classList.add('is-selected');
+ }
+ card.innerHTML = `
+
+
${cardDef.title}
+
${cardDef.eyebrow}
+
+ ${cardDef.description}
+ `;
+ card.addEventListener('click', (event) => {
+ event?.preventDefault?.();
+ this._selectTourMode(cardDef.id);
+ modesWrap
+ .querySelectorAll('.tour-mode-card')
+ .forEach((btn) => btn.classList.toggle('is-selected', btn === card));
+ });
+ modesWrap.appendChild(card);
+ });
+ screen.appendChild(modesWrap);
+ const continueBtn = document.createElement('button');
+ continueBtn.type = 'button';
+ continueBtn.className = 'tour-btn primary';
+ continueBtn.textContent = 'Continue';
+ continueBtn.addEventListener('click', (event) => {
+ event?.preventDefault?.();
+ this._commitModeSelection();
+ });
+ screen.appendChild(continueBtn);
+ return screen;
+ }
+ _buildControlsScreen() {
+ const screen = document.createElement('div');
+ screen.className = 'tour-screen tour-screen--keys';
+ const eyebrow = document.createElement('p');
+ eyebrow.className = 'tour-eyebrow';
+ eyebrow.textContent = 'Controls';
+ const title = document.createElement('h2');
+ title.textContent = this.mobileMode ? 'Touch controls' : 'Keyboard overlay';
+ const list = document.createElement('ul');
+ list.className = 'tour-legend';
+ const rows = this.mobileMode ? MOBILE_CONTROLS_GRID : DESKTOP_CONTROLS_GRID;
+ rows.forEach((row) => {
+ const item = document.createElement('li');
+ const key = document.createElement('span');
+ key.className = 'tour-key';
+ key.textContent = row.value;
+ const label = document.createElement('span');
+ label.className = 'tour-label';
+ label.textContent = row.label;
+ item.append(key, label);
+ list.appendChild(item);
+ });
+ const footer = document.createElement('p');
+ footer.className = 'tour-footer';
+ footer.textContent = 'Press Enter to start. Press ? anytime.';
+ const startBtn = document.createElement('button');
+ startBtn.type = 'button';
+ startBtn.className = 'tour-btn primary';
+ startBtn.textContent = 'Start exploring';
+ startBtn.addEventListener('click', (event) => {
+ event?.preventDefault?.();
+ this._advanceHelpScreen();
+ });
+ screen.append(eyebrow, title, list, footer, startBtn);
+ return screen;
+ }
+ _buildCanvasScreen() {
+ const screen = document.createElement('div');
+ screen.className = 'tour-screen tour-screen--canvas';
+ const eyebrow = document.createElement('p');
+ eyebrow.className = 'tour-eyebrow';
+ eyebrow.textContent = 'Micro-tutorial';
+ const list = document.createElement('ul');
+ list.className = 'tour-checklist';
+ const completedCount = this._getCompletedTutorialCount();
+ TUTORIAL_TASKS.forEach((task) => {
+ const item = document.createElement('li');
+ const isDone = this.tutorialProgress.get(task.id);
+ item.className = isDone ? 'is-complete' : '';
+ const head = document.createElement('div');
+ head.className = 'tour-checklist__head';
+ head.textContent = task.title;
+ const detail = document.createElement('p');
+ const copy = this.mobileMode ? task.detailMobile || task.detailDesktop : task.detailDesktop;
+ detail.textContent = copy || '';
+ item.append(head, detail);
+ list.appendChild(item);
+ });
+ const progress = document.createElement('p');
+ progress.className = 'tour-footer';
+ progress.textContent = `${completedCount} of ${TUTORIAL_TASKS.length} complete`;
+ screen.append(eyebrow, list, progress);
+ return screen;
+ }
+ _buildSpeedRunPreScreen() {
+ const screen = document.createElement('div');
+ screen.className = 'tour-screen tour-screen--speed';
+ const eyebrow = document.createElement('p');
+ eyebrow.className = 'tour-eyebrow';
+ eyebrow.textContent = 'Speed-Run Mode';
+ const title = document.createElement('h2');
+ title.textContent = '2-minute ranked challenge';
+ const goal = document.createElement('p');
+ goal.className = 'tour-desc';
+ goal.textContent = 'Set your strategy, dive into the landscape, and chase your best val-loss before the clock runs out.';
+ const rules = document.createElement('ul');
+ rules.className = 'tour-rules';
+ const ruleSet = this.mobileMode ? SPEEDRUN_RULES_MOBILE : SPEEDRUN_RULES;
+ ruleSet.forEach((rule) => {
+ const item = document.createElement('li');
+ item.textContent = rule;
+ rules.appendChild(item);
+ });
+ const compact = document.createElement('p');
+ compact.className = 'tour-compact-keys';
+ compact.textContent = this.mobileMode ? SPEEDRUN_COMPACT_KEYS_MOBILE : SPEEDRUN_COMPACT_KEYS;
+ const footer = document.createElement('p');
+ footer.className = 'tour-footer';
+ footer.textContent = 'Timer starts on Start. Press ? anytime.';
+ const startBtn = document.createElement('button');
+ startBtn.type = 'button';
+ startBtn.className = 'tour-btn primary';
+ startBtn.textContent = 'Start Speed-Run';
+ startBtn.addEventListener('click', (event) => {
+ event?.preventDefault?.();
+ this._startSpeedrunFromTour();
+ });
+ screen.append(eyebrow, title, goal, rules, compact, footer, startBtn);
+ return screen;
+ }
+ _selectTourMode(mode) {
+ this.helpTourSelection = mode;
+ }
+
+ _commitModeSelection() {
+ const mode = this.helpTourSelection || 'explore';
+ if (mode === 'speed') {
+ this._startSpeedrunFlow();
+ } else {
+ this._startExploreFlow();
+ }
+ }
+
+ _setTourFlow(flowKey) {
+ const flow = TOUR_FLOWS[flowKey] || [];
+ this.currentTourFlow = flowKey;
+ this.state?.setHelpScreenFns(flow);
+ if (!flow.length) {
+ if (typeof this.state.closeHelpScreens === 'function') {
+ this.state.closeHelpScreens();
+ } else {
+ this.state.helpScreenIdx = -1;
+ }
+ this.hideImage();
+ return;
+ }
+ this.state.helpScreenIdx = 0;
+ this.showImage(flow[0]);
+ }
+
+ _startExploreFlow() {
+ this._resetTutorialProgress();
+ this._setTourFlow('explore');
+ }
+
+ _startSpeedrunFlow() {
+ this._setTourFlow('speed');
+ }
+
+ _completeTutorialIntro() {
+ if (typeof this.state.closeHelpScreens === 'function') {
+ this.state.closeHelpScreens();
+ } else {
+ this.state.helpScreenIdx = -1;
+ }
+ this.hideImage();
+ }
+
+ _startSpeedrunFromTour() {
+ const client = this._getHudesClient();
+ if (typeof this.state.closeHelpScreens === 'function') {
+ this.state.closeHelpScreens();
+ } else {
+ this.state.helpScreenIdx = -1;
+ }
+ this.hideImage();
+ client?.startSpeedRun?.();
+ }
+ _resetTutorialProgress() {
+ this.tutorialProgress = new Map();
+ TUTORIAL_TASKS.forEach((task) => this.tutorialProgress.set(task.id, false));
+ this.tutorialComplete = false;
+ if (this.tutorialToastEl) {
+ this.tutorialToastEl.classList.remove('visible');
+ }
+ }
+
+ _getCompletedTutorialCount() {
+ let count = 0;
+ this.tutorialProgress.forEach((value) => {
+ if (value) count += 1;
+ });
+ return count;
+ }
+
+ notifyTutorialEvent(stepId) {
+ if (!stepId || !this.tutorialProgress || !this.tutorialProgress.has(stepId)) {
+ return;
+ }
+ if (this.tutorialProgress.get(stepId)) {
+ return;
+ }
+ this.tutorialProgress.set(stepId, true);
+ if (
+ this.helpOverlay?.getAttribute('data-tour-screen') === 'tour_canvas' &&
+ this.helpContentMode === 'tour'
+ ) {
+ this._renderTourScreen('tour_canvas');
+ }
+ if (!this.tutorialComplete && this._getCompletedTutorialCount() >= TUTORIAL_TASKS.length) {
+ this.tutorialComplete = true;
+ this._showTutorialCompletionToast();
+ }
+ }
+
+ _showTutorialCompletionToast() {
+ const toast = this._ensureTutorialToast();
+ if (!toast) return;
+ toast.textContent = 'Tutorial complete — press Z to start Speed-Run.';
+ toast.classList.add('visible');
+ }
+
+ _ensureTutorialToast() {
+ if (this.tutorialToastEl) {
+ return this.tutorialToastEl;
+ }
+ if (typeof document === 'undefined') {
+ return null;
+ }
+ const toast = document.createElement('div');
+ toast.id = 'tutorialToast';
+ document.body.appendChild(toast);
+ this.tutorialToastEl = toast;
+ return toast;
+ }
+
+ showLevelUp(level, isSpeedRun) {
+ console.log('[LandscapeView] showLevelUp:', level, 'isSpeedRun:', isSpeedRun);
+ if (typeof document === 'undefined') return;
+
+ if (isSpeedRun) {
+ const toast = document.getElementById('levelToast');
+ const scoreEl = document.getElementById('levelToastScore');
+ const titleEl = document.getElementById('levelToastTitle');
+ if (toast && scoreEl && titleEl) {
+ scoreEl.textContent = level.loss.toFixed(2);
+ titleEl.textContent = level.title;
+ toast.classList.add('visible');
+
+ if (this.levelToastTimeout) {
+ clearTimeout(this.levelToastTimeout);
+ }
+ this.levelToastTimeout = setTimeout(() => {
+ toast.classList.remove('visible');
+ this.levelToastTimeout = null;
+ }, 1500);
+ }
+ } else {
+ const modal = document.getElementById('levelModal');
+ const titleEl = document.getElementById('levelTitle');
+ const insightEl = document.getElementById('levelInsight');
+ const closeBtn = document.getElementById('levelCloseBtn');
+ const eyebrowEl = modal?.querySelector('.level-eyebrow');
+
+ if (modal && titleEl && insightEl) {
+ if (eyebrowEl) {
+ eyebrowEl.textContent = `LEVEL UP! YOU REACHED LEVEL ${level.levelNumber}`;
+ }
+ titleEl.textContent = `${level.loss.toFixed(2)} — ${level.title}`;
+ insightEl.textContent = level.insight;
+ modal.classList.add('visible');
+
+ // Clear any existing auto-close timer
+ if (this.levelModalTimeout) {
+ clearTimeout(this.levelModalTimeout);
+ }
+
+ // Auto-close after 5 seconds
+ this.levelModalTimeout = setTimeout(() => {
+ modal.classList.remove('visible');
+ this.levelModalTimeout = null;
+ }, 5000);
+
+ // Ensure we don't stack listeners
+ if (!this._levelDismissHandler) {
+ this._levelDismissHandler = () => {
+ modal.classList.remove('visible');
+ if (this.levelModalTimeout) {
+ clearTimeout(this.levelModalTimeout);
+ this.levelModalTimeout = null;
+ }
+ };
+ closeBtn?.addEventListener('click', this._levelDismissHandler);
+ }
+ }
+ }
+ }
+
+}
diff --git a/web/highscores.html b/web/highscores.html
index e273a7f..8c81043 100644
--- a/web/highscores.html
+++ b/web/highscores.html
@@ -1,19 +1,23 @@
+
Human Descent — High Scores
-
+
+
🏆 High Scores
Top 10,000 scores. Lower is better.
@@ -26,6 +30,7 @@
🏆 High Scores
Timestamp |
Duration |
Request Idx |
+
Action |
@@ -33,4 +38,5 @@
🏆 High Scores
+
diff --git a/web/highscores.js b/web/highscores.js
index eb1b19d..9ed2f08 100644
--- a/web/highscores.js
+++ b/web/highscores.js
@@ -11,12 +11,28 @@ async function detectBackend() {
const httpPort = wsPort + 1;
const res = await fetch(`${proto}://${host}:${httpPort}/health`, { method: 'GET' });
if (res.ok) return `${proto}://${host}:${httpPort}/api`;
- } catch {}
+ } catch { }
}
// Fallback
return `${proto}://${host}:${10002}/api`;
}
+async function deleteScore(id) {
+ if (!confirm('Are you sure you want to delete this score?')) return;
+ const api = await detectBackend();
+ try {
+ const res = await fetch(`${api}/highscores?id=${id}`, { method: 'DELETE' });
+ if (res.ok) {
+ document.getElementById('loadBtn').click(); // Reload current view
+ } else {
+ alert('Failed to delete score');
+ }
+ } catch (e) {
+ console.error(e);
+ alert('Error deleting score');
+ }
+}
+
async function load(offset = 0, limit = 1000) {
const api = await detectBackend();
const status = document.getElementById('status');
@@ -27,7 +43,7 @@ async function load(offset = 0, limit = 1000) {
return;
}
const rows = await resp.json();
- status.textContent = `Loaded ${rows.length} rows`;
+ status.textContent = `Loaded ${rows.length} rows (Offset: ${offset})`;
const tbody = document.querySelector('#scoresTable tbody');
tbody.innerHTML = '';
let rank = offset + 1;
@@ -41,19 +57,48 @@ async function load(offset = 0, limit = 1000) {
${r.ts} |
${r.duration} |
${r.requestIdx ?? ''} |
+ |
`;
tbody.appendChild(tr);
rank += 1;
}
+
+ // Bind delete buttons
+ document.querySelectorAll('.delete-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ deleteScore(e.target.dataset.id);
+ });
+ });
}
(function init() {
const offsetInput = document.getElementById('offset');
const limitInput = document.getElementById('limit');
- document.getElementById('loadBtn').addEventListener('click', () => {
+
+ const getParams = () => {
const o = Math.max(0, parseInt(offsetInput.value || '0', 10));
const l = Math.max(1, Math.min(10000, parseInt(limitInput.value || '1000', 10)));
+ return { o, l };
+ };
+
+ document.getElementById('loadBtn').addEventListener('click', () => {
+ const { o, l } = getParams();
load(o, l);
});
+
+ document.getElementById('prevBtn').addEventListener('click', () => {
+ const { o, l } = getParams();
+ const newOffset = Math.max(0, o - l);
+ offsetInput.value = newOffset;
+ load(newOffset, l);
+ });
+
+ document.getElementById('nextBtn').addEventListener('click', () => {
+ const { o, l } = getParams();
+ const newOffset = o + l;
+ offsetInput.value = newOffset;
+ load(newOffset, l);
+ });
+
load(0, 1000);
})();
diff --git a/web/index.html b/web/index.html
index d08fff1..10ab1a2 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,21 +3,26 @@
-
+
Human Descent
-
-
+
@@ -54,8 +60,26 @@
-
+
+
+
+
+
+
+
+
+ —
+
+
+
diff --git a/web/mobile.js b/web/mobile.js
new file mode 100644
index 0000000..ca350e5
--- /dev/null
+++ b/web/mobile.js
@@ -0,0 +1,38 @@
+const FALLBACK_MOBILE_WIDTH = 768;
+
+const isPointerCoarse = () => {
+ if (typeof window === 'undefined') return false;
+ try {
+ if (window.matchMedia('(pointer: coarse)').matches) return true;
+ } catch {}
+ return false;
+};
+
+const hasTouchPoints = () => {
+ if (typeof navigator === 'undefined') return false;
+ return Number(navigator.maxTouchPoints || 0) > 0;
+};
+
+const smallViewport = () => {
+ if (typeof window === 'undefined') return false;
+ return window.innerWidth <= FALLBACK_MOBILE_WIDTH;
+};
+
+export const detectMobileMode = (params = new URLSearchParams()) => {
+ const qp = params.get('mobile');
+ if (qp != null) {
+ return /^(1|true|on|yes)$/i.test(qp);
+ }
+ return isPointerCoarse() || hasTouchPoints() || smallViewport();
+};
+
+export const setMobileFlag = (value) => {
+ const enabled = Boolean(value);
+ if (typeof window !== 'undefined') {
+ window.__hudesIsMobile = enabled;
+ }
+ if (typeof document !== 'undefined') {
+ document.documentElement?.classList?.toggle('hudes-mobile', enabled);
+ document.body?.classList?.toggle('hudes-mobile', enabled);
+ }
+};
diff --git a/web/package.json b/web/package.json
index ce83789..894c1c7 100644
--- a/web/package.json
+++ b/web/package.json
@@ -5,11 +5,17 @@
"main": "index.js",
"type": "module",
"scripts": {
- "build": "VITE_BUILD_ID=${VITE_BUILD_ID:-$(date +%s)} VITE_WS_PORT=${VITE_WS_PORT:-10000} VITE_HTTP_PORT=${VITE_HTTP_PORT:-10001} vite build",
+ "prebuild": "npm run -s proto:compile",
+ "build": "VITE_BUILD_ID=${VITE_BUILD_ID:-$(date +%s)} VITE_WS_PORT=${VITE_WS_PORT:-10000} VITE_HTTP_PORT=${VITE_HTTP_PORT:-10001} vite build",
+ "prebuild:port10000": "npm run -s proto:compile",
"build:port10000": "VITE_WS_PORT=10000 VITE_HTTP_PORT=10001 vite build",
+ "proto:compile": "npm run -s proto:compile:py && npm run -s proto:compile:js",
+ "proto:compile:py": "protoc --proto_path=../hudes --python_out=../hudes ../hudes/hudes.proto",
+ "proto:compile:js": "mkdir -p generated && npx pbjs -t static-module -w es6 -o generated/hudes_pb.js ../hudes/hudes.proto && npx pbts -o generated/hudes_pb.d.ts generated/hudes_pb.js",
+ "proto:sync": "npm run -s proto:compile",
"predev": "npm run -s proto:sync",
- "dev": "vite --host 0.0.0.0",
- "preview": "vite preview",
+ "dev": "vite --host 0.0.0.0 --port 6173",
+ "preview": "vite preview --port 6173",
"server:kill": "bash -lc 'PORT=${HUDES_PORT:-10001}; PIDS=$(lsof -ti :$PORT || true); if [ -n \"$PIDS\" ]; then echo Killing stale PIDs on $PORT: $PIDS; kill -9 $PIDS || true; fi; sleep 0.5'",
"test:e2e": "HUDES_PORT=10001 HUDES_SPEED_RUN_SECONDS=5 npm run -s server:kill && playwright test",
"test:e2e:ui": "HUDES_PORT=10001 HUDES_SPEED_RUN_SECONDS=5 npm run -s server:kill && playwright test --ui",
@@ -25,7 +31,6 @@
"chartjs-chart-matrix": "^2.0.1",
"chartjs-plugin-annotation": "^3.1.0",
"heatmap.js": "^2.0.5",
- "plotly.js-dist-min": "^2.35.2",
"protobufjs": "^7.4.0",
"protobufjs-cli": "^1.1.3",
"three": "^0.170.0"
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index afd8f28..47b279a 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -1,11 +1,14 @@
import { defineConfig, devices } from '@playwright/test';
+const DEFAULT_SPEED_RUN_SECONDS = process.env.HUDES_SPEED_RUN_SECONDS ?? '5';
+process.env.HUDES_SPEED_RUN_SECONDS = DEFAULT_SPEED_RUN_SECONDS;
+
export default defineConfig({
testDir: './tests',
fullyParallel: true,
reporter: [['list']],
use: {
- baseURL: 'http://localhost:5173',
+ baseURL: 'http://localhost:6173',
trace: 'on-first-retry',
},
// Start both the Vite dev server (frontend) and the Python WebSocket backend
@@ -14,17 +17,21 @@ export default defineConfig({
webServer: [
{
command: 'npm run dev',
- url: 'http://localhost:5173',
+ url: 'http://localhost:6173',
reuseExistingServer: true,
timeout: 60_000,
},
{
// Run from repo root to find the Python env and modules
command:
- "bash -lc 'cd .. && PORT=10001; HPORT=$((PORT+1)); PIDS=$(lsof -ti :$PORT || true); if [ -n \"$PIDS\" ]; then echo Killing stale PIDs on $PORT: $PIDS; kill -9 $PIDS || true; sleep 0.5; fi; HPIDS=$(lsof -ti :$HPORT || true); if [ -n \"$HPIDS\" ]; then echo Killing stale PIDs on $HPORT: $HPIDS; kill -9 $HPIDS || true; sleep 0.5; fi; HUDES_SPEED_RUN_SECONDS=5 LOGLEVEL=DEBUG ./hudes_env/bin/python -u hudes/websocket_server.py --run-in thread --port $PORT'",
+ "bash -lc 'cd .. && PORT=10001; HPORT=$((PORT+1)); PIDS=$(lsof -ti :$PORT || true); if [ -n \"$PIDS\" ]; then echo Killing stale PIDs on $PORT: $PIDS; kill -9 $PIDS || true; sleep 0.5; fi; HPIDS=$(lsof -ti :$HPORT || true); if [ -n \"$HPIDS\" ]; then echo Killing stale PIDs on $HPORT: $HPIDS; kill -9 $HPIDS || true; sleep 0.5; fi; LOGLEVEL=DEBUG ./hudes_env/bin/python -u hudes/websocket_server.py --run-in thread --port $PORT --device cuda --model cnn3'",
// Wait on dedicated health server (PORT+1)
url: 'http://localhost:10002/health',
- reuseExistingServer: false,
+ reuseExistingServer: true,
+ env: {
+ ...process.env,
+ HUDES_SPEED_RUN_SECONDS: DEFAULT_SPEED_RUN_SECONDS,
+ },
timeout: 120_000,
},
],
diff --git a/web/public/hudes_logo_splash.jpg b/web/public/hudes_logo_splash.jpg
new file mode 100644
index 0000000..c8fdf5d
Binary files /dev/null and b/web/public/hudes_logo_splash.jpg differ
diff --git a/web/public/levels.json b/web/public/levels.json
new file mode 100644
index 0000000..1b4e79f
--- /dev/null
+++ b/web/public/levels.json
@@ -0,0 +1,108 @@
+[
+ {
+ "loss": 100.0,
+ "title": "Active Saboteur",
+ "insight": "You are actively unlearning. The model would perform better if you just walked away.",
+ "hidden": true
+ },
+ {
+ "loss": 2.31,
+ "title": "My RNG is Smarter",
+ "insight": "Static Noise. The network has no filters yet. It is effectively blind, assigning equal probability (10%) to every digit."
+ },
+ {
+ "loss": 2.23,
+ "title": "The Coin Flip",
+ "insight": "Slight Bias. You've found a direction that isn't terrible. Maybe you realized most pixels are black."
+ },
+ {
+ "loss": 2.16,
+ "title": "Cute. You Found a Button.",
+ "insight": "Mean Intensity. The model has learned that \"most pixels are black.\" It’s just predicting the average brightness of the dataset."
+ },
+ {
+ "loss": 2.09,
+ "title": "A Blurry Mess",
+ "insight": "Variance. The weights are starting to capture the spread of pixel intensities, but no structure yet."
+ },
+ {
+ "loss": 2.02,
+ "title": "Still Mostly Noise",
+ "insight": "Center Detection. The weights are starting to focus on the center of the image where digits usually live."
+ },
+ {
+ "loss": 1.95,
+ "title": "Is That a Seven?",
+ "insight": "Blob Detection. The model can vaguely distinguish \"ink\" from \"background\" but has no concept of shape."
+ },
+ {
+ "loss": 1.88,
+ "title": "Acceptable... For a Human",
+ "insight": "Edge Detectors. Primitive filters are forming. The network can see vertical lines vs. horizontal lines."
+ },
+ {
+ "loss": 1.81,
+ "title": "The Horizontal Line",
+ "insight": "Simple Features. It can likely separate a 1 (vertical stick) from a 0 (round blob)."
+ },
+ {
+ "loss": 1.74,
+ "title": "Not Embarrassing",
+ "insight": "Coarse Shapes. The model is learning \"loops\" vs \"sticks.\""
+ },
+ {
+ "loss": 1.67,
+ "title": "Loop vs Stick",
+ "insight": "Topology. It can separate the 0/6/8/9 group from the 1/7 group, but confuses 8 with 0 constantly."
+ },
+ {
+ "loss": 1.60,
+ "title": "You Might Actually Be Useful",
+ "insight": "Feature Combinations. It’s seeing \"top loops\" vs \"bottom loops.\""
+ },
+ {
+ "loss": 1.53,
+ "title": "The Upper Loop",
+ "insight": "Digit Discrimination. It can now distinguish a 6 (bottom loop) from a 9 (top loop)."
+ },
+ {
+ "loss": 1.46,
+ "title": "I'm Almost Impressed",
+ "insight": "Curvature. The filters are refining curves. It’s starting to untangle the 3 vs 5 mess (both are squiggly)."
+ },
+ {
+ "loss": 1.39,
+ "title": "The Squiggle",
+ "insight": "Complex Shapes. The model is starting to understand the weird Z-shape of the digit 2."
+ },
+ {
+ "loss": 1.32,
+ "title": "Did You Cheat?",
+ "insight": "Fine Details. The network is looking at stroke endings."
+ },
+ {
+ "loss": 1.25,
+ "title": "The Stroke Ending",
+ "insight": "Endpoints. It’s finally resolving 4 vs 9—the difference often comes down to whether the top loop is closed or open."
+ },
+ {
+ "loss": 1.18,
+ "title": "System Threat Detected",
+ "insight": "Ambiguity Resolution. The model is handling sloppy handwriting."
+ },
+ {
+ "loss": 1.11,
+ "title": "The Slanted Two",
+ "insight": "Rotation Invariance. It can tell a \"hooked\" 1 from a 7, and a slanted 2 from a 7."
+ },
+ {
+ "loss": 1.04,
+ "title": "Grandmaster",
+ "insight": "Memorization. The filters are crisp and specialized. These are subtle distinctions."
+ },
+ {
+ "loss": 1.00,
+ "title": "Singularity Achieved",
+ "insight": "Manifold Mastery. The network has effectively memorized the topology of the MNIST manifold."
+ }
+]
diff --git a/web/public/loss_landscape.gif b/web/public/loss_landscape.gif
new file mode 100644
index 0000000..4accdef
Binary files /dev/null and b/web/public/loss_landscape.gif differ
diff --git a/web/style.css b/web/style.css
index 740a02b..b6bdcde 100644
--- a/web/style.css
+++ b/web/style.css
@@ -6,8 +6,35 @@ body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
- background-color: #000; /* Set the background color to black */
- color: #fff; /* Optional: Set the text color to white for better contrast */
+ background-color: #000;
+ /* Set the background color to black */
+ color: #fff;
+ /* Optional: Set the text color to white for better contrast */
+}
+
+body.hudes-mobile #plot2 {
+ display: none;
+}
+
+body.hudes-mobile #sideContainer {
+ display: none !important;
+}
+
+body.hudes-mobile button,
+body.hudes-mobile .header-pill,
+body.hudes-mobile [data-hud-action] {
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ /* Prevent browser scrollbars; all scrollable panes are explicit */
+ touch-action: manipulation;
}
.site-header {
@@ -31,6 +58,89 @@ body {
image-rendering: auto;
}
+.header-left-actions {
+ position: absolute;
+ left: clamp(10px, 3vw, 28px);
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ gap: 1.6rem;
+ flex-wrap: wrap;
+}
+
+body.hudes-mobile .header-left-actions .header-source {
+ display: none;
+}
+
+.connection-status {
+ position: absolute;
+ top: 50%;
+ right: clamp(4px, 1vw, 12px);
+ transform: translateY(-50%);
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: #ffd666;
+}
+
+.header-controls {
+ position: absolute;
+ right: clamp(120px, 12vw, 210px);
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+}
+
+.header-controls button.header-pill,
+.header-left-actions .header-pill,
+.header-left-actions .header-pill:link,
+.header-left-actions .header-pill:visited {
+ padding: 0;
+ border: none;
+ background: none;
+ color: #ffd666;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.68rem;
+ cursor: pointer;
+ transition: color 180ms ease, text-shadow 180ms ease;
+ text-decoration: none;
+}
+
+.header-controls button.header-pill:focus,
+.header-left-actions .header-pill:focus {
+ outline: none;
+}
+
+.header-controls .mode-toggle {
+ font-size: 0.62rem;
+ letter-spacing: 0.1em;
+}
+
+.header-controls button.header-pill:hover,
+.header-controls button.header-pill:focus-visible,
+.header-left-actions .header-pill:hover,
+.header-left-actions .header-pill:focus-visible {
+ color: #ffe59c;
+ text-decoration: none;
+ text-shadow: 0 0 8px rgba(255, 229, 156, 0.85);
+}
+
+.connection-status[data-state='connected'] {
+ color: #4caf50;
+}
+
+.connection-status[data-state='connecting'] {
+ color: #ffaa33;
+}
+
+.connection-status[data-state='disconnected'] {
+ color: #ff5757;
+}
+
#imageContainer {
position: fixed;
top: var(--header-height);
@@ -63,27 +173,27 @@ body {
}
.help-overlay {
- position: relative;
+ position: absolute;
+ inset: 0;
display: flex;
align-items: center;
justify-content: center;
- max-width: min(92vw, 960px);
- max-height: min(80vh, 720px);
- padding: clamp(12px, 3vw, 28px);
- border-radius: 14px;
- background: rgba(18, 10, 0, 0.88);
- border: 1px solid rgba(255, 190, 80, 0.45);
- box-shadow: 0 24px 48px rgba(0, 0, 0, 0.65);
+ padding: 0;
opacity: 0;
transform: scale(0.97);
transition: opacity 200ms ease, transform 200ms ease;
pointer-events: none;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ visibility: hidden;
}
.help-overlay.visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
+ visibility: visible;
}
.help-overlay__image {
@@ -123,218 +233,808 @@ body {
outline-offset: 2px;
}
-/* Modal overlay and glass card */
-#modalOverlay {
- position: fixed;
- inset: 0;
- background: rgba(5, 10, 20, 0.45);
- backdrop-filter: blur(6px);
- display: none;
+.help-overlay__content {
+ width: min(92vw, 820px);
+ background: rgba(5, 10, 20, 0.92);
+ border: 1px solid rgba(255, 190, 80, 0.35);
+ border-radius: 20px;
+ padding: clamp(18px, 4vw, 32px);
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(24px);
+ position: relative;
+}
+
+.help-overlay__body {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+body.hudes-mobile .help-overlay__content {
+ width: calc(100vw - 24px);
+ margin: 0 12px;
+ padding: clamp(16px, 6vw, 24px);
+}
+
+body.hudes-mobile .help-overlay__body {
+ gap: 1rem;
+}
+
+.help-overlay.is-splash {
+ background: rgba(0, 0, 0, 0.9);
+}
+
+.help-overlay.is-splash .help-overlay__content {
+ width: 100%;
+ height: 100%;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+ display: flex;
align-items: center;
justify-content: center;
- z-index: 1000;
+ padding: 0;
}
-#modalOverlay.open { display: flex; }
+.help-overlay.is-splash .help-overlay__close {
+ display: none;
+}
-.glass-card {
- width: min(92vw, 560px);
- padding: 20px 22px;
- border-radius: 16px;
- background: rgba(12, 18, 30, 0.78);
- border: 1px solid rgba(86, 198, 255, 0.35);
- box-shadow: 0 18px 48px rgba(10, 18, 40, 0.55),
- inset 0 0 18px rgba(86, 198, 255, 0.12);
- color: #e9f6ff;
+.help-overlay__tabs {
+ display: none;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
}
-.glass-card h2 {
- margin: 0 0 6px;
- font-size: 1.25rem;
+.help-overlay__tabs.is-visible {
+ display: flex;
}
-.glass-card .muted {
- margin: 0 0 14px;
- opacity: 0.85;
+
+.help-overlay__tabs button {
+ flex: 1;
+ border: 1px solid rgba(255, 214, 102, 0.4);
+ background: rgba(12, 20, 40, 0.7);
+ color: rgba(255, 214, 102, 0.8);
+ border-radius: 999px;
+ padding: 0.35rem 0.5rem;
+ cursor: pointer;
+ text-transform: uppercase;
+ font-size: 0.68rem;
+ letter-spacing: 0.12em;
}
-.glass-card .name-form {
+
+.help-overlay__tabs button.is-active {
+ background: rgba(255, 214, 102, 0.2);
+ color: #fff1cc;
+ border-color: rgba(255, 214, 102, 0.7);
+}
+
+.tour-screen {
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 1.1rem;
+ color: #f3fbff;
}
-.glass-card label { font-size: 0.9rem; opacity: 0.9; }
-.glass-card input {
- padding: 10px 12px;
- border-radius: 10px;
- border: 1px solid rgba(86, 198, 255, 0.35);
- background: rgba(255,255,255,0.08);
- color: #fff;
- outline: none;
- font-size: 1rem;
+
+.tour-eyebrow {
text-transform: uppercase;
- letter-spacing: 0.12em;
+ letter-spacing: 0.24em;
+ font-size: 0.75rem;
+ color: rgba(255, 214, 102, 0.9);
+ margin: 0;
}
-.glass-card input.invalid { border-color: #ff4d4d; }
-.glass-card .actions {
+
+.tour-hero {
display: flex;
- justify-content: flex-end;
- gap: 10px;
- margin-top: 8px;
+ flex-direction: row;
+ gap: 1.5rem;
+ flex-wrap: wrap;
}
-.glass-card button, .glass-card .link-btn {
- padding: 8px 14px;
- border-radius: 10px;
- border: 1px solid rgba(86, 198, 255, 0.35);
- background: rgba(40, 90, 140, 0.35);
- color: #e9f6ff;
- cursor: pointer;
- text-decoration: none;
+
+.tour-hero__visual {
+ flex: 1 1 260px;
+ min-height: 220px;
+ border-radius: 16px;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
}
-.glass-card button:hover, .glass-card .link-btn:hover {
- background: rgba(40, 90, 140, 0.5);
+
+.tour-hero__visual img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
}
-.glass-card .top10-list {
- margin: 8px 0 0;
- padding-left: 18px;
- list-style: none;
+
+.tour-hero__body {
+ flex: 1 1 260px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
}
-.glass-card .scroll-wrap {
- max-height: 50vh;
- overflow-y: auto;
- margin: 8px 0;
- padding-right: 6px;
+
+body.hudes-mobile .tour-hero__body {
+ gap: 0.45rem;
}
-.glass-card .top100-list { /* legacy selector; keep behavior sane */
+
+body.hudes-mobile .tour-hero__body .tour-eyebrow,
+body.hudes-mobile .tour-hero__body .tour-welcome__list,
+body.hudes-mobile .tour-hero__body .tour-welcome__list li {
margin: 0;
- padding-left: 18px;
- list-style: none;
-}
-#glContainer {
- position: absolute;
- top: var(--header-height);
- left: 0;
- width: 90%; /* Set to 80% of width to make space for the side container */
- height: calc(100% - var(--header-height));
- z-index: 1; /* Lower index to be behind the plotly and other containers */
- pointer-events: auto; /* Allow direct interaction with the GL canvas */
}
-#sideContainer {
- position: absolute;
- top: calc(var(--header-height) + 20vh);
- right: 0;
- width: 17%; /* The container should take up the right 20% */
- height: calc(80% - var(--header-height)); /* Adjust to account for header space */
- pointer-events: auto; /* Ensure the side container can be interacted with */
- z-index: 3; /* Higher index to be on top of the GL container */
- background: transparent; /* Transparent background */
- overflow-y: auto; /* Allow scrolling if content exceeds height */
- display: flex;
- flex-direction: column;
- box-sizing: border-box; /* Ensures padding and borders are included */
- padding: 10px; /* Add some padding to the container */
+.tour-pitch,
+.tour-desc {
+ margin: 0;
+ color: rgba(228, 241, 255, 0.92);
}
-.container {
- position: absolute;
- top: calc(var(--header-height) + 10px);
- left: 0;
- width: 100%; /* Make sure it also matches the GL container width */
- height: 25vh; /* Set to 33% of the viewport height */
- margin-left: 0; /* Align with glContainer */
- z-index: 2; /* Higher than GL container, but lower than the side container */
- background: transparent; /* Transparent background */
+.tour-cta-row {
display: flex;
- box-sizing: border-box; /* Include padding and border in the element's total width and height */
- gap: 10px; /* Add gap between the plot elements to improve spacing */
+ flex-wrap: wrap;
+ gap: 0.8rem;
}
-.plot {
- width: 33%; /* There are two plots, each should take up half of the container width */
- height: 100%;
- box-sizing: border-box; /* Ensures padding and border are included in the element's total width and height */
- padding: 1px; /* Optional padding */
+.tour-mobile-note {
+ margin: 0;
+ color: rgba(200, 220, 255, 0.85);
+ font-size: 0.9rem;
}
-.example-cell {
- position: relative; /* Allow absolute positioning of children */
- width: 100%;
- height: 25%; /* Adjusted for 4 cells in the side container */
- box-sizing: border-box;
+.tour-btn {
+ border: 1px solid rgba(255, 214, 102, 0.65);
+ border-radius: 999px;
+ padding: 0.55rem 1.45rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ background: rgba(255, 214, 102, 0.15);
+ color: #ffd666;
+ transition: background 160ms ease, transform 160ms ease;
}
-.example-item {
- width: 100%;
- height: 100%;
- position: relative; /* Allow absolute positioning for the image and chart */
+.tour-btn.primary {
+ background: linear-gradient(135deg, #ffd666, #ffae4d);
+ color: #0a0f1d;
+ border-color: transparent;
}
-.example-chart {
- width: 100%;
- height: 100%;
- position: absolute;
- top: 0;
- left: 0;
- z-index: 2; /* Higher z-index to be above the image */
+.tour-btn.ghost {
background: transparent;
+ color: rgba(255, 214, 102, 0.8);
}
-.example-image {
- width: 50%; /* Half the width of the cell */
- height: 50%; /* Half the height of the cell */
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- z-index: 1; /* Lower z-index to be behind the chart */
- background: transparent;
+.tour-btn:active {
+ transform: translateY(1px);
}
-#bottomTextContainer {
- position: fixed;
- bottom: 1.5em;
- left: 50%;
- transform: translateX(-50%);
- width: min(94vw, 1024px);
- z-index: 5;
- pointer-events: none;
- color: #f5fbff;
- font-family: "Consolas", "Fira Mono", "SFMono-Regular", monospace;
+.tour-btn.resume {
+ border-color: rgba(180, 210, 255, 0.4);
+ background: rgba(10, 20, 40, 0.9);
+ color: rgba(200, 230, 255, 0.9);
}
-#bottomTextContainer .hud-card {
- width: 100%;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- gap: 0.2rem;
- padding: 0.45rem 0.75rem;
- background: rgba(12, 18, 30, 0.78);
- border: 1px solid rgba(86, 198, 255, 0.35);
+.tour-resume-row {
+ margin-top: 0.65rem;
+}
+
+.tour-modes {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 1rem;
+}
+
+.tour-mode-card {
+ border: 1px solid rgba(255, 214, 102, 0.25);
border-radius: 14px;
- box-shadow:
- 0 16px 40px rgba(10, 18, 40, 0.4),
- inset 0 0 14px rgba(86, 198, 255, 0.1);
- backdrop-filter: blur(16px) saturate(125%);
- pointer-events: auto;
+ padding: 1rem;
+ background: rgba(10, 16, 30, 0.85);
+ color: #f1fbff;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 160ms ease, box-shadow 160ms ease;
}
-#bottomTextContainer .hud-title {
- display: inline-flex;
- align-items: center;
- padding: 0.1rem 0.45rem;
- border-radius: 0.75rem;
- font-size: clamp(0.75rem, 1.4vw, 0.88rem);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: rgba(185, 225, 255, 0.85);
- background: rgba(40, 90, 130, 0.32);
- text-shadow: 0 0 4px rgba(120, 200, 255, 0.35);
+.tour-mode-card.is-selected {
+ border-color: rgba(255, 214, 102, 0.9);
+ box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.2);
}
-#bottomTextContainer .hud-status {
- display: inline-flex;
- align-items: center;
+.tour-mode-heading h3 {
+ margin: 0 0 0.25rem;
+ font-size: 1.05rem;
+}
+
+.tour-mode-heading p,
+.tour-mode-body {
+ margin: 0;
+ color: rgba(228, 241, 255, 0.9);
+ font-size: 0.9rem;
+}
+
+.tour-mode-nudge {
+ display: inline-block;
+ margin-top: 0.35rem;
+ font-size: 0.72rem;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: rgba(255, 214, 102, 0.9);
+}
+
+.tour-legend {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ gap: 0.75rem;
+}
+
+.tour-legend li {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ padding: 0.4rem 0.5rem;
+ border-left: 2px solid rgba(255, 214, 102, 0.6);
+ background: rgba(12, 20, 38, 0.65);
+}
+
+.tour-key {
+ font-weight: 600;
+ letter-spacing: 0.08em;
+}
+
+.tour-label {
+ color: rgba(214, 230, 255, 0.85);
+ font-size: 0.85rem;
+}
+
+.tour-footer {
+ text-align: center;
+ color: rgba(228, 241, 255, 0.85);
+ margin: 0;
+}
+
+.help-info-sheet {
+ position: absolute;
+ inset: clamp(16px, 4vw, 48px);
+ background: rgba(5, 10, 22, 0.95);
+ border: 1px solid rgba(255, 214, 102, 0.35);
+ border-radius: 20px;
+ padding: clamp(18px, 4vw, 28px);
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.6);
+ transform: translateY(10px) scale(0.97);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 160ms ease, transform 160ms ease;
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+}
+
+.help-info-sheet ul {
+ margin: 0;
+ padding-left: 1rem;
+ color: rgba(228, 241, 255, 0.9);
+}
+
+.help-overlay.showing-info .help-info-sheet {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateY(0) scale(1);
+}
+
+.tour-screen--speed .tour-legend {
+ margin-top: 0.25rem;
+}
+
+.tour-screen--splash {
+ align-items: center;
+ text-align: center;
+ justify-content: center;
+ min-height: 60vh;
+ width: 100%;
+ height: 100%;
+}
+
+.tour-splash__logo {
+ width: auto;
+ height: min(80vh, 80vw);
+ max-height: 85vh;
+ max-width: 90vw;
+ object-fit: contain;
+ align-self: center;
+}
+
+.tour-splash__media {
+ width: 100%;
+ max-height: 220px;
+ object-fit: cover;
+ border-radius: 12px;
+ margin: 0.5rem 0;
+}
+
+.tour-splash__subcopy {
+ margin: 0;
+ letter-spacing: 0.2em;
+ color: rgba(255, 214, 102, 0.8);
+}
+
+.tour-splash__prompt {
+ margin-top: 1rem;
+ color: rgba(255, 214, 102, 0.85);
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ font-size: 0.78rem;
+}
+
+.tour-screen--splash-active {
+ animation: splashPulse 1.5s ease-in-out infinite alternate;
+}
+
+.tour-screen--splash-fade {
+ animation: splashFade 0.45s ease forwards;
+}
+
+@keyframes splashPulse {
+ 0% {
+ opacity: 0.75;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes splashFade {
+ to {
+ opacity: 0;
+ }
+}
+
+.tour-screen--welcome .tour-welcome__list {
+ list-style: disc;
+ padding-left: 1.2rem;
+ color: rgba(228, 241, 255, 0.9);
+ margin: 0;
+}
+
+.tour-checklist {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+.tour-checklist li {
+ border-left: 3px solid rgba(255, 214, 102, 0.4);
+ padding-left: 0.75rem;
+ color: rgba(228, 241, 255, 0.85);
+}
+
+.tour-checklist li.is-complete {
+ border-color: rgba(123, 255, 180, 0.9);
+ color: rgba(160, 255, 200, 0.95);
+}
+
+.tour-checklist__head {
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+body.hudes-mobile .tour-checklist {
+ gap: 0.4rem;
+}
+
+body.hudes-mobile .tour-checklist li {
+ line-height: 1.2;
+}
+
+.tour-rules {
+ list-style: decimal;
+ margin: 0;
+ padding-left: 1.2rem;
+ color: rgba(228, 241, 255, 0.9);
+}
+
+.tour-compact-keys {
+ font-size: 0.85rem;
+ color: rgba(255, 214, 102, 0.85);
+ border-left: 2px solid rgba(255, 214, 102, 0.5);
+ padding-left: 0.65rem;
+}
+
+.help-tab {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.help-tab--how ul {
+ margin: 0;
+ padding-left: 1rem;
+}
+
+.help-tab--how li {
+ color: rgba(228, 241, 255, 0.9);
+ margin-bottom: 0.5rem;
+}
+
+.help-tab--controls .tour-legend {
+ margin: 0;
+}
+
+/* Modal overlay and glass card */
+#modalOverlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(5, 10, 20, 0.45);
+ backdrop-filter: blur(6px);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+#modalOverlay.open {
+ display: flex;
+}
+
+.glass-card {
+ width: min(92vw, 560px);
+ padding: 20px 22px;
+ border-radius: 16px;
+ background: rgba(12, 18, 30, 0.78);
+ border: 1px solid rgba(86, 198, 255, 0.35);
+ box-shadow: 0 18px 48px rgba(10, 18, 40, 0.55),
+ inset 0 0 18px rgba(86, 198, 255, 0.12);
+ color: #e9f6ff;
+}
+
+.glass-card h2 {
+ margin: 0 0 6px;
+ font-size: 1.25rem;
+}
+
+.glass-card .muted {
+ margin: 0 0 14px;
+ opacity: 0.85;
+}
+
+.glass-card .name-form {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.glass-card label {
+ font-size: 0.9rem;
+ opacity: 0.9;
+}
+
+.glass-card input {
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 1px solid rgba(86, 198, 255, 0.35);
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+ outline: none;
+ font-size: 1rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+}
+
+.glass-card input.invalid {
+ border-color: #ff4d4d;
+}
+
+.glass-card .actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.results-card {
+ max-width: 420px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.results-metrics {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 0.5rem;
+}
+
+.results-metrics li {
+ background: rgba(10, 18, 40, 0.6);
+ border-radius: 10px;
+ padding: 0.65rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ border: 1px solid rgba(255, 214, 102, 0.2);
+}
+
+.results-metrics li span {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: rgba(255, 214, 102, 0.7);
+}
+
+.results-metrics li strong {
+ font-size: 1rem;
+ color: rgba(240, 248, 255, 0.95);
+}
+
+.results-share {
+ font-size: 0.82rem;
+ color: rgba(228, 241, 255, 0.85);
+ border-left: 2px solid rgba(255, 214, 102, 0.4);
+ padding-left: 0.6rem;
+}
+
+.results-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.results-actions button {
+ flex: 1;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 214, 102, 0.5);
+ background: rgba(12, 20, 38, 0.8);
+ color: rgba(255, 214, 102, 0.9);
+ padding: 0.4rem 0.75rem;
+ text-transform: uppercase;
+ font-size: 0.66rem;
+ letter-spacing: 0.15em;
+ cursor: pointer;
+}
+
+.results-actions button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.glass-card button,
+.glass-card .link-btn {
+ padding: 8px 14px;
+ border-radius: 10px;
+ border: 1px solid rgba(86, 198, 255, 0.35);
+ background: rgba(40, 90, 140, 0.35);
+ color: #e9f6ff;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.glass-card button:hover,
+.glass-card .link-btn:hover {
+ background: rgba(40, 90, 140, 0.5);
+}
+
+.glass-card .top10-list {
+ margin: 8px 0 0;
+ padding-left: 18px;
+ list-style: none;
+}
+
+.glass-card .scroll-wrap {
+ max-height: 50vh;
+ overflow-y: auto;
+ margin: 8px 0;
+ padding-right: 6px;
+}
+
+.glass-card .top100-list {
+ /* legacy selector; keep behavior sane */
+ margin: 0;
+ padding-left: 18px;
+ list-style: none;
+}
+
+#glContainer {
+ position: absolute;
+ top: var(--header-height);
+ left: 0;
+ width: 90%;
+ /* Set to 80% of width to make space for the side container */
+ height: calc(100vh - var(--header-height));
+ z-index: 1;
+ /* Lower index to be behind the plotly and other containers */
+ pointer-events: auto;
+ /* Allow direct interaction with the GL canvas */
+}
+
+body.hudes-mobile #glContainer {
+ --mobile-gl-top: max(-30vh, calc(var(--header-height) * 0.5 - 30vh));
+ top: calc(var(--mobile-gl-top) - var(--header-height));
+ height: calc(100vh - var(--mobile-gl-top) + var(--header-height));
+ width: 100%;
+}
+
+#sideContainer {
+ position: absolute;
+ top: calc(var(--header-height) + 20vh);
+ right: 0;
+ width: 17%;
+ /* The container should take up the right 20% */
+ height: calc(80vh - var(--header-height));
+ /* Adjust to account for header space */
+ pointer-events: auto;
+ /* Ensure the side container can be interacted with */
+ z-index: 3;
+ /* Higher index to be on top of the GL container */
+ background: transparent;
+ /* Transparent background */
+ overflow-y: auto;
+ /* Allow scrolling if content exceeds height */
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ /* Ensures padding and borders are included */
+ padding: 10px;
+ /* Add some padding to the container */
+}
+
+.container {
+ position: absolute;
+ top: calc(var(--header-height) + 10px);
+ left: 0;
+ width: 100%;
+ height: 25vh;
+ margin-left: 0;
+ z-index: 2;
+ background: transparent;
+ display: flex;
+ box-sizing: border-box;
+ gap: 10px;
+ padding: 0 12px 0 0;
+}
+
+.plot {
+ width: 33%;
+ /* There are two plots, each should take up half of the container width */
+ height: 100%;
+ box-sizing: border-box;
+ /* Ensures padding and border are included in the element's total width and height */
+ padding: 1px;
+ /* Optional padding */
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.35rem;
+}
+
+.plot canvas {
+ flex: 1;
+}
+
+.plot-title {
+ font-size: 0.78rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: rgba(210, 235, 255, 0.85);
+ margin: 0;
+}
+
+#plot3 .plot-title {
+ font-size: clamp(0.6rem, 1.4vw, 0.72rem);
+ letter-spacing: 0.08em;
+ align-self: flex-end;
+ width: auto;
+ text-align: right;
+}
+
+.example-cell {
+ position: relative;
+ /* Allow absolute positioning of children */
+ width: 100%;
+ height: 25%;
+ /* Adjusted for 4 cells in the side container */
+ box-sizing: border-box;
+}
+
+.example-item {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ /* Allow absolute positioning for the image and chart */
+}
+
+.example-chart {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ /* Higher z-index to be above the image */
+ background: transparent;
+}
+
+.example-chart canvas,
+.example-chart-canvas,
+.confusion-matrix-canvas {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.example-image {
+ width: 50%;
+ /* Half the width of the cell */
+ height: 50%;
+ /* Half the height of the cell */
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+ /* Lower z-index to be behind the chart */
+ background: transparent;
+}
+
+#bottomTextContainer {
+ position: fixed;
+ bottom: 1.5em;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(94vw, 1024px);
+ z-index: 5;
+ pointer-events: none;
+ color: #f5fbff;
+ font-family: "Consolas", "Fira Mono", "SFMono-Regular", monospace;
+}
+
+#bottomTextContainer .hud-card {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 0.2rem;
+ padding: 0.45rem 0.75rem;
+ background: rgba(12, 18, 30, 0.78);
+ border: 1px solid rgba(86, 198, 255, 0.35);
+ border-radius: 14px;
+ box-shadow:
+ 0 16px 40px rgba(10, 18, 40, 0.4),
+ inset 0 0 14px rgba(86, 198, 255, 0.1);
+ backdrop-filter: blur(16px) saturate(125%);
+ pointer-events: auto;
+}
+
+#bottomTextContainer .hud-title {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.1rem 0.45rem;
+ border-radius: 0.75rem;
+ font-size: clamp(0.75rem, 1.4vw, 0.88rem);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: rgba(185, 225, 255, 0.85);
+ background: rgba(40, 90, 130, 0.32);
+ text-shadow: 0 0 4px rgba(120, 200, 255, 0.35);
+}
+
+#bottomTextContainer .hud-status {
+ display: inline-flex;
+ align-items: center;
padding: 0.06rem 0.4rem;
border-radius: 0.75rem;
font-size: clamp(0.78rem, 1.6vw, 0.92rem);
@@ -342,7 +1042,93 @@ body {
background: rgba(40, 90, 140, 0.32);
box-shadow: inset 0 0 0 1px rgba(86, 198, 255, 0.3);
text-shadow: 0 0 4px rgba(40, 180, 255, 0.3);
- white-space: nowrap;
+ white-space: normal;
+ text-align: center;
+ width: 100%;
+}
+
+body.speedrun-countdown #bottomTextContainer .hud-status .hud-timer {
+ color: #ffed9c;
+ font-weight: 600;
+}
+
+body.speedrun-active #bottomTextContainer .hud-status .hud-timer {
+ color: #ff5a5a;
+ font-weight: 700;
+}
+
+#speedrunCountdownOverlay {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+ transition: opacity 180ms ease, transform 180ms ease;
+ z-index: 999;
+}
+
+#speedrunCountdownOverlay.visible {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.speedrun-countdown__box {
+ width: 80vw;
+ height: 55vh;
+ max-width: 1100px;
+ max-height: 600px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: clamp(12px, 4vw, 32px);
+}
+
+.speedrun-countdown__text {
+ display: block;
+ font-size: min(12vh, 18vw);
+ font-weight: 900;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: #ffd666;
+ text-shadow: 0 0 45px rgba(0, 0, 0, 0.7);
+ line-height: 1.05;
+ white-space: normal;
+ text-align: center;
+ word-break: break-word;
+}
+
+#speedrunCountdownOverlay.count .speedrun-countdown__text {
+ font-size: min(16vh, 24vw);
+ color: #ff4d4d;
+}
+
+#speedrunCountdownOverlay.ready .speedrun-countdown__text {
+ color: #ffed8c;
+}
+
+#tutorialToast {
+ position: fixed;
+ bottom: 1rem;
+ left: 50%;
+ transform: translateX(-50%) translateY(10px);
+ background: rgba(5, 10, 20, 0.9);
+ border: 1px solid rgba(123, 255, 200, 0.4);
+ color: rgba(210, 255, 235, 0.95);
+ padding: 0.6rem 1rem;
+ border-radius: 999px;
+ font-size: 0.85rem;
+ letter-spacing: 0.08em;
+ opacity: 0;
+ transition: opacity 180ms ease, transform 180ms ease;
+ z-index: 500;
+}
+
+#tutorialToast.visible {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
}
#bottomTextContainer .hud-controls {
@@ -356,6 +1142,34 @@ body {
line-height: 1.2;
}
+#bottomTextContainer .hud-inline-actions {
+ flex-basis: 100%;
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.35rem;
+ margin-top: 0.35rem;
+}
+
+#bottomTextContainer .hud-inline-actions button {
+ border: 1px solid rgba(86, 198, 255, 0.35);
+ border-radius: 999px;
+ background: rgba(28, 42, 68, 0.55);
+ color: #e9f6ff;
+ font-size: clamp(0.72rem, 1.4vw, 0.86rem);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ padding: 0.3rem 0.9rem;
+ pointer-events: auto;
+ transition: background 120ms ease, transform 120ms ease;
+}
+
+#bottomTextContainer .hud-inline-actions button:active {
+ background: rgba(40, 60, 90, 0.9);
+ transform: translateY(1px);
+}
+
#bottomTextContainer .control-group {
display: inline-flex;
align-items: center;
@@ -369,11 +1183,40 @@ body {
text-shadow: 0 0 5px rgba(120, 200, 255, 0.4);
}
+#bottomTextContainer .hud-dim-combo {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+#bottomTextContainer .hud-dim-key {
+ display: inline-flex;
+ font-weight: 700;
+ font-size: 0.95em;
+ letter-spacing: 0.05em;
+ text-shadow: 0 0 6px rgba(0, 0, 0, 0.35);
+}
+
+#bottomTextContainer .hud-dim-key-sep {
+ display: inline-block;
+ width: 0.35rem;
+}
+
+#bottomTextContainer .hud-dim-sign {
+ margin-left: 0.4rem;
+ font-size: 0.8em;
+ letter-spacing: 0.08em;
+ color: rgba(220, 235, 255, 0.85);
+}
+
/* Special styling for SPEED RUN label */
#bottomTextContainer .control-group .label .speed-run-label {
- color: #ff4d4d; /* red */
- font-weight: 800; /* bold */
- font-style: italic; /* italic */
+ color: #ff4d4d;
+ /* red */
+ font-weight: 800;
+ /* bold */
+ font-style: italic;
+ /* italic */
}
#bottomTextContainer .key-pill {
@@ -442,3 +1285,444 @@ body {
display: none;
}
}
+
+#mobileActionPanel {
+ display: none;
+}
+
+body.hudes-mobile #mobileActionPanel {
+ position: relative;
+ top: auto;
+ bottom: auto;
+ left: auto;
+ right: auto;
+ transform: none;
+ width: max-content;
+ margin-top: 0.6rem;
+ margin-left: auto;
+ margin-right: 2px;
+ padding: 0 2px 0 0;
+ border: none;
+ border-radius: 12px;
+ background: transparent;
+ box-shadow: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ z-index: 1;
+ pointer-events: auto;
+}
+
+#joystickPad {
+ position: fixed;
+ bottom: 8vh;
+ left: 50%;
+ transform: translateX(-50%);
+ width: clamp(120px, 30vw, 220px);
+ height: clamp(120px, 30vw, 220px);
+ border-radius: 50%;
+ background: radial-gradient(circle at center, rgba(255, 255, 255, 0.06), rgba(8, 16, 32, 0.85));
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 400;
+ backdrop-filter: blur(6px);
+ pointer-events: none;
+}
+
+body.hudes-mobile #joystickPad {
+ display: flex;
+}
+
+body.help-overlay-open #joystickPad {
+ display: none !important;
+}
+
+#joystickThumb {
+ width: clamp(48px, 12vw, 96px);
+ height: clamp(48px, 12vw, 96px);
+ border-radius: 50%;
+ background: rgba(255, 214, 102, 0.25);
+ border: 1px solid rgba(255, 214, 102, 0.45);
+ box-shadow: inset 0 0 6px rgba(255, 214, 102, 0.35);
+ transform: translate(0, 0);
+ transition: transform 120ms ease;
+}
+
+.joystick-hint {
+ position: absolute;
+ bottom: -2.2rem;
+ width: max-content;
+ left: 50%;
+ transform: translateX(-50%);
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.75rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ margin: 0;
+}
+
+.joystick-hint.hidden {
+ opacity: 0;
+}
+
+body.hudes-mobile .container {
+ justify-content: flex-end;
+ align-items: flex-start;
+ padding-right: 2px;
+ padding-left: 0;
+ gap: 0;
+ height: 20vh;
+}
+
+body.hudes-mobile #plot1 {
+ flex: 1;
+ width: auto;
+ min-width: 0;
+}
+
+body.hudes-mobile #plot3 {
+ width: min(42vw, 300px);
+ margin-left: 2px;
+}
+
+body.hudes-mobile #plot3 #confusionMatrixChart {
+ flex: 1;
+}
+
+#mobileActionPanel .mobile-panel__grid {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+ padding: 0;
+ background: transparent;
+ width: max-content;
+}
+
+#mobileActionPanel button {
+ border: 1px solid rgba(86, 198, 255, 0.25);
+ border-radius: 10px;
+ padding: 0.4rem 0.65rem;
+ background: rgba(30, 40, 70, 0.55);
+ color: #e9f6ff;
+ font-size: 0.74rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ width: 100%;
+ min-width: max-content;
+ white-space: nowrap;
+ text-align: center;
+}
+
+#mobileActionPanel button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ background: rgba(30, 40, 70, 0.25);
+}
+
+#mobileActionPanel button:active {
+ background: rgba(40, 60, 90, 0.9);
+}
+
+#bottomTextContainer .hud-card {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ box-sizing: border-box;
+ max-width: 100%;
+}
+
+#bottomTextContainer .hud-mobile-actions {
+ display: none;
+ margin-top: 0.5rem;
+ width: 100%;
+ gap: 0.5rem;
+}
+
+body.hudes-mobile #bottomTextContainer .hud-mobile-actions {
+ display: flex;
+}
+
+#bottomTextContainer .hud-mobile-actions button {
+ flex: 1;
+ border-radius: 999px;
+ border: none;
+ font-size: 0.9rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #06121f;
+ background: #ffd666;
+ padding: 0.6rem 0.8rem;
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35);
+}
+
+#bottomTextContainer .hud-mobile-actions button:active {
+ transform: translateY(1px);
+}
+
+body.hudes-hide-hud #bottomTextContainer {
+ display: none !important;
+}
+
+/* Level Modal (Explore Mode) */
+.level-modal {
+ position: fixed;
+ top: 140px;
+ /* Below the loss chart */
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 900;
+ display: none;
+ pointer-events: auto;
+}
+
+.level-modal.visible {
+ display: block;
+ animation: slideDownFade 0.4s ease-out;
+}
+
+.level-modal-content {
+ width: min(90vw, 400px);
+ padding: 1.2rem;
+ border: 1px solid rgba(255, 214, 102, 0.4);
+ background: rgba(12, 18, 30, 0.9);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
+ text-align: center;
+}
+
+.level-modal h2 {
+ color: #ffd666;
+ font-size: 1.1rem;
+ margin: 0 0 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.level-modal p {
+ color: rgba(228, 241, 255, 0.9);
+ font-size: 0.9rem;
+ line-height: 1.4;
+ margin: 0 0 1rem;
+}
+
+.level-modal .actions {
+ display: flex;
+ justify-content: center;
+}
+
+.level-modal button {
+ background: rgba(255, 214, 102, 0.15);
+ color: #ffd666;
+ border: 1px solid rgba(255, 214, 102, 0.5);
+ padding: 0.4rem 1.2rem;
+ border-radius: 999px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ transition: background 0.2s, transform 0.1s;
+}
+
+.level-modal button:hover {
+ background: rgba(255, 214, 102, 0.25);
+}
+
+/* Level Toast (Speed Run Mode) */
+.level-toast {
+ position: fixed;
+ top: 160px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 950;
+ background: rgba(0, 0, 0, 0.85);
+ border: 1px solid rgba(255, 214, 102, 0.6);
+ color: #ffd666;
+ padding: 0.6rem 1.2rem;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.2s ease-out;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ white-space: nowrap;
+}
+
+.level-toast.visible {
+ opacity: 1;
+ animation: toastPop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+.level-toast-sep {
+ opacity: 0.6;
+}
+
+@keyframes slideDownFade {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translate(-50%, 0);
+ }
+}
+
+@keyframes toastPop {
+ from {
+ transform: translateX(-50%) scale(0.8);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateX(-50%) scale(1);
+ opacity: 1;
+ }
+}
+
+/* Mobile Adjustments */
+body.hudes-mobile .level-modal {
+ top: calc(env(safe-area-inset-top, 0) + 16px);
+ bottom: auto;
+ width: 100%;
+ padding: 0 1rem;
+ justify-content: center;
+ align-items: flex-start;
+}
+
+body.hudes-mobile .level-modal.visible {
+ display: flex;
+}
+
+body.hudes-mobile .level-modal .level-modal-content {
+ width: min(100%, 420px);
+}
+
+body.hudes-mobile .level-toast {
+ top: 100px;
+ font-size: 0.85rem;
+ padding: 0.5rem 1rem;
+}
+
+/* New Level Modal Styles */
+.level-close-btn {
+ position: absolute;
+ top: 10px;
+ right: 14px;
+ font-size: 1.5rem;
+ line-height: 1;
+ cursor: pointer;
+ color: rgba(255, 255, 255, 0.6);
+ transition: color 0.2s;
+}
+
+.level-close-btn:hover {
+ color: #fff;
+}
+
+.level-eyebrow {
+ color: #4caf50;
+ /* Success green */
+ font-size: 0.75rem;
+ font-weight: bold;
+ letter-spacing: 0.2em;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+}
+
+/* Update modal content padding for close button */
+.level-modal-content {
+ position: relative;
+ padding-top: 1.5rem;
+}
+
+/* Results Card Metrics Row */
+.results-metrics-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 0.8rem;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+}
+
+.metric-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+}
+
+.metric-item span {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.metric-item strong {
+ font-size: 1.1rem;
+ color: #fff;
+ font-family: 'Roboto Mono', monospace;
+}
+
+.metric-sep {
+ width: 1px;
+ height: 24px;
+ background: rgba(255, 255, 255, 0.2);
+}
+
+/* Share Actions */
+.share-actions {
+ display: flex;
+ justify-content: center;
+ gap: 0.8rem;
+ margin: 1rem 0;
+}
+
+.share-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ transition: all 0.2s;
+ border: none;
+ outline: none;
+}
+
+.x-btn {
+ background: #000;
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.x-btn:hover {
+ background: #111;
+ border-color: rgba(255, 255, 255, 0.4);
+ transform: translateY(-1px);
+}
+
+.copy-btn {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.copy-btn:hover {
+ background: rgba(255, 255, 255, 0.2);
+ transform: translateY(-1px);
+}
diff --git a/web/tests/help_overlay.spec.mjs b/web/tests/help_overlay.spec.mjs
new file mode 100644
index 0000000..d458585
--- /dev/null
+++ b/web/tests/help_overlay.spec.mjs
@@ -0,0 +1,39 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('Keyboard overlay toggle', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => {
+ try {
+ window.sessionStorage?.clear();
+ } catch {}
+ });
+ });
+
+ test('Shift+? and X toggle keyboard overlay on desktop', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?mode=3d&help=off&host=${SERVER_HOST}&port=${SERVER_PORT}`);
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ const overlay = page.locator('.tour-screen--keys');
+ await expect(overlay).toBeHidden();
+
+ await page.keyboard.press('Shift+Slash');
+ await expect(overlay).toBeVisible();
+
+ await page.keyboard.press('Shift+Slash');
+ await expect(overlay).toBeHidden();
+
+ await page.keyboard.press('Shift+Slash');
+ await expect(overlay).toBeVisible();
+
+ await page.keyboard.press('x');
+ await expect(overlay).toBeHidden();
+ });
+});
diff --git a/web/tests/highscores_insert.spec.mjs b/web/tests/highscores_insert.spec.mjs
index a482029..288f8c5 100644
--- a/web/tests/highscores_insert.spec.mjs
+++ b/web/tests/highscores_insert.spec.mjs
@@ -3,6 +3,7 @@ import { test, expect } from '@playwright/test';
const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
const SPEED_SECONDS = Number(process.env.HUDES_SPEED_RUN_SECONDS || '5');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
function makeName() {
// Generate a unique 4-char uppercase A-Z0-9 name
@@ -21,12 +22,12 @@ test.describe('Highscores API insertion', () => {
const apiBase = `${apiProto}://${apiHost}:${apiPort}/api`;
// Load app and start a speed run
- await page.goto(`http://localhost:5173/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
await page.waitForFunction(() => window.__hudesClient && window.__hudesClient.ControlType);
- await page.keyboard.press('KeyR');
+ await page.keyboard.press('KeyZ');
// Wait for the name modal and submit our unique name
- await page.waitForSelector('#modalOverlay.open .glass-card .name-form', { timeout: (SPEED_SECONDS + 20) * 1000 });
+ await page.waitForSelector('#modalOverlay.open .glass-card .name-form', { timeout: (SPEED_SECONDS + 40) * 1000 });
await page.fill('#modalOverlay.open .glass-card .name-form input', name);
await page.click('#modalOverlay.open .glass-card .name-form button[type="submit"]');
diff --git a/web/tests/highscores_page.spec.mjs b/web/tests/highscores_page.spec.mjs
index f296105..07c5780 100644
--- a/web/tests/highscores_page.spec.mjs
+++ b/web/tests/highscores_page.spec.mjs
@@ -2,9 +2,10 @@ import { test, expect } from '@playwright/test';
const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
function withBackend(url) {
- const u = new URL(url, 'http://localhost:5173');
+ const u = new URL(url, APP_ORIGIN);
u.searchParams.set('host', SERVER_HOST);
u.searchParams.set('port', String(SERVER_PORT));
return u.toString();
diff --git a/web/tests/hud_toggle.spec.mjs b/web/tests/hud_toggle.spec.mjs
new file mode 100644
index 0000000..53a19d4
--- /dev/null
+++ b/web/tests/hud_toggle.spec.mjs
@@ -0,0 +1,38 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('HUD toggle flag', () => {
+ test.use({ viewport: { width: 1200, height: 720 } });
+
+ test('hideHUD=1 hides HUD on desktop', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?mode=3d&hideHUD=1&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+ await expect(page.locator('#bottomTextContainer')).toHaveCount(0);
+ });
+
+ test('hideHUD=1 hides HUD on mobile', async ({ page }) => {
+ await page.context().newCDPSession(page).then((session) =>
+ session.send('Emulation.setDeviceMetricsOverride', {
+ width: 375,
+ height: 667,
+ deviceScaleFactor: 2,
+ mobile: true,
+ scale: 1,
+ screenWidth: 375,
+ screenHeight: 667,
+ }),
+ );
+ await page.goto(`${APP_ORIGIN}/?mode=3d&mobile=1&hideHUD=1&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+ await expect(page.locator('#bottomTextContainer')).toHaveCount(0);
+ });
+});
diff --git a/web/tests/layout_overflow.spec.mjs b/web/tests/layout_overflow.spec.mjs
new file mode 100644
index 0000000..ef2ff48
--- /dev/null
+++ b/web/tests/layout_overflow.spec.mjs
@@ -0,0 +1,46 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+async function waitForClient(page) {
+ await page.waitForFunction(
+ () => typeof window !== 'undefined' && window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+}
+
+async function waitForOneDScene(page) {
+ await waitForClient(page);
+ await page.waitForFunction(() => window.__hudesClient?.renderMode === '1d', { timeout: 10000 });
+ await page.waitForSelector('#glContainer canvas', { timeout: 10000 });
+}
+
+async function expectNoPageOverflow(page, tolerancePx = 1) {
+ const overflow = await page.evaluate(() => {
+ const doc = document.scrollingElement || document.documentElement;
+ return {
+ horizontal: Math.max(0, doc.scrollWidth - window.innerWidth),
+ vertical: Math.max(0, doc.scrollHeight - window.innerHeight),
+ };
+ });
+
+ expect(overflow.horizontal).toBeLessThanOrEqual(tolerancePx);
+ expect(overflow.vertical).toBeLessThanOrEqual(tolerancePx);
+}
+
+test.describe('Layout overflow guard', () => {
+ test('3D mode fits within the viewport', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&mode=3d`);
+ await waitForClient(page);
+ await page.waitForSelector('#glContainer canvas', { timeout: 10000 });
+ await expectNoPageOverflow(page);
+ });
+
+ test('1D mode fits within the viewport', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&mode=1d`);
+ await waitForOneDScene(page);
+ await expectNoPageOverflow(page);
+ });
+});
diff --git a/web/tests/leaderboard_ws.spec.mjs b/web/tests/leaderboard_ws.spec.mjs
index 15555b4..bc64517 100644
--- a/web/tests/leaderboard_ws.spec.mjs
+++ b/web/tests/leaderboard_ws.spec.mjs
@@ -3,10 +3,11 @@ import { test, expect } from '@playwright/test';
const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
test.describe('Leaderboard over WebSocket', () => {
test('press Y shows top 100 modal', async ({ page }) => {
- await page.goto(`http://localhost:5173/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
await page.waitForFunction(() => window.__hudesClient && window.__hudesClient.ControlType);
// Press Y to request leaderboard
diff --git a/web/tests/mobile_countdown.spec.mjs b/web/tests/mobile_countdown.spec.mjs
new file mode 100644
index 0000000..ec2cfa6
--- /dev/null
+++ b/web/tests/mobile_countdown.spec.mjs
@@ -0,0 +1,38 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('Mobile countdown overlay', () => {
+ test.use({ viewport: { width: 375, height: 667 }, deviceScaleFactor: 2 });
+
+ test('countdown text stays visible on mobile', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?mode=3d&mobile=1&help=off&host=${SERVER_HOST}&port=${SERVER_PORT}`);
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ // Start countdown
+ await page.evaluate(() => {
+ window.__hudesClient?.startSpeedRun?.();
+ });
+
+ const overlay = page.locator('#speedrunCountdownOverlay');
+ await expect(overlay).toBeVisible();
+
+ const textLocator = overlay.locator('.speedrun-countdown__text');
+ const textContent = await textLocator.innerText();
+ expect(textContent.toUpperCase()).toContain('GET READY');
+
+ const bbox = await textLocator.boundingBox();
+ const viewport = page.viewportSize();
+ expect(bbox).not.toBeNull();
+ if (bbox) {
+ expect(bbox.x).toBeGreaterThan(0);
+ expect(bbox.x + bbox.width).toBeLessThanOrEqual(viewport.width + 1);
+ }
+ });
+});
diff --git a/web/tests/mobile_mode.spec.mjs b/web/tests/mobile_mode.spec.mjs
new file mode 100644
index 0000000..371f1ac
--- /dev/null
+++ b/web/tests/mobile_mode.spec.mjs
@@ -0,0 +1,108 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+const mobileUrl = `${APP_ORIGIN}/?mode=3d&mobile=1&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`;
+
+async function bootstrapMobile(page) {
+ await page.goto(mobileUrl);
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+ await page.waitForFunction(
+ () => window.__hudesClient?.view?.impl?.mobileMode === true,
+ { timeout: 10000 },
+ );
+}
+
+test.describe('Mobile 3D mode', () => {
+ test.use({ viewport: { width: 375, height: 667 }, deviceScaleFactor: 2 });
+
+ test('renders single grid and mobile HUD controls', async ({ page }) => {
+ await bootstrapMobile(page);
+
+ const mobileFlag = await page.evaluate(() => Boolean(window.__hudesIsMobile));
+ expect(mobileFlag).toBeTruthy();
+
+ const gridCount = await page.evaluate(
+ () => window.__hudesClient?.view?.impl?.numGrids ?? -1,
+ );
+ expect(gridCount).toBe(1);
+
+ await expect(page.locator('#sideContainer')).toBeHidden();
+
+ await expect(page.locator('#bottomTextContainer')).toHaveCount(0);
+ const panel = page.locator('#mobileActionPanel');
+ await expect(panel.locator('[data-mobile-action="stepPlus"]')).toBeVisible();
+ await expect(panel.locator('[data-mobile-action="stepMinus"]')).toBeVisible();
+ await expect(panel.locator('[data-mobile-action="fp"]')).toBeVisible();
+ await expect(panel.locator('[data-mobile-action="dims"]')).toBeVisible();
+ await expect(panel.locator('[data-mobile-action="batchNew"]')).toBeVisible();
+
+ const matrixBox = await page.locator('#confusionMatrixChart').boundingBox();
+ const panelBox = await panel.boundingBox();
+ expect(matrixBox).not.toBeNull();
+ expect(panelBox).not.toBeNull();
+ if (matrixBox && panelBox) {
+ expect(panelBox.y).toBeGreaterThanOrEqual(matrixBox.y + matrixBox.height - 2);
+ }
+ });
+
+ test('mobile controls dispatch actions and analog steps', async ({ page }) => {
+ await bootstrapMobile(page);
+
+ const panel = page.locator('#mobileActionPanel');
+ const dimsBtn = panel.locator('[data-mobile-action="dims"]');
+ const batchBtnHud = panel.locator('[data-mobile-action="batchNew"]');
+ await expect(dimsBtn).toBeVisible();
+ await expect(batchBtnHud).toBeVisible();
+
+ await page.evaluate(() => {
+ const client = window.__hudesClient;
+ window.__dimsCalls = 0;
+ window.__batchCalls = 0;
+ if (client) {
+ const origDims = client.getNextDims?.bind(client);
+ client.getNextDims = (...args) => {
+ window.__dimsCalls += 1;
+ return origDims?.(...args);
+ };
+ const origBatch = client.getNextBatch?.bind(client);
+ client.getNextBatch = (...args) => {
+ window.__batchCalls += 1;
+ return origBatch?.(...args);
+ };
+ }
+ });
+
+ await dimsBtn.click();
+ await page.waitForFunction(() => (window.__dimsCalls || 0) > 0, { timeout: 5000 });
+
+ await batchBtnHud.click();
+ await page.waitForFunction(() => (window.__batchCalls || 0) > 0, { timeout: 5000 });
+
+ const batchPanelButton = page.locator('#mobileActionPanel button[data-mobile-action="batchCycle"]');
+ await expect(batchPanelButton).toBeVisible();
+ const initialLabel = await batchPanelButton.innerText();
+ await batchPanelButton.click();
+ await page.waitForFunction((label) => {
+ const btn = document.querySelector('#mobileActionPanel button[data-mobile-action="batch"]');
+ return btn && btn.innerText !== label;
+ }, initialLabel, { timeout: 5000 }).catch(() => {});
+
+ await page.evaluate(() => {
+ window.__hudesClient?.zeroDimsAndStepsOnCurrentDims?.();
+ });
+ await page.evaluate(() => {
+ window.__hudesClient?.__applyTouchVector?.({ x: 0, y: -1 });
+ });
+ await page.waitForFunction(() => {
+ const dims = window.__hudesClient?.dimsAndStepsOnCurrentDims;
+ if (!Array.isArray(dims)) return false;
+ return dims.some((value) => Math.abs(value) > 0);
+ }, { timeout: 5000 });
+ });
+});
diff --git a/web/tests/mode_1d_altkeys.spec.mjs b/web/tests/mode_1d_altkeys.spec.mjs
new file mode 100644
index 0000000..d2244de
--- /dev/null
+++ b/web/tests/mode_1d_altkeys.spec.mjs
@@ -0,0 +1,47 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('1D keyboard controls (alt keys)', () => {
+ test('alt key pairs drive the correct dimensions', async ({ page }) => {
+ await page.goto(
+ `${APP_ORIGIN}/?mode=1d&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&altkeys=1&debug=1`,
+ );
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ const mapping = await page.evaluate(() => {
+ const client = window.__hudesClient;
+ if (!client) return null;
+ return client.keyToParamAndSign;
+ });
+
+ expect(mapping).not.toBeNull();
+
+ const expectations = [
+ ['u', 0, 1],
+ ['w', 0, -1],
+ ['i', 1, 1],
+ ['e', 1, -1],
+ ['o', 2, 1],
+ ['r', 2, -1],
+ ['j', 3, 1],
+ ['s', 3, -1],
+ ['k', 4, 1],
+ ['d', 4, -1],
+ ['l', 5, 1],
+ ['f', 5, -1],
+ ];
+
+ for (const [key, dim, sign] of expectations) {
+ expect(mapping[key]).toBeDefined();
+ expect(mapping[key].dim).toBe(dim);
+ expect(mapping[key].sign).toBe(sign);
+ }
+ });
+});
diff --git a/web/tests/mode_1d_altview.spec.mjs b/web/tests/mode_1d_altview.spec.mjs
new file mode 100644
index 0000000..216ca17
--- /dev/null
+++ b/web/tests/mode_1d_altview.spec.mjs
@@ -0,0 +1,93 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('1D alternate stacked view', () => {
+ test('renders all loss lines inside a single enlarged plot', async ({ page }) => {
+ await page.goto(
+ `${APP_ORIGIN}/?mode=1d&alt1d=1&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&debug=1`,
+ );
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ const closeButton = page.locator('.help-overlay__close');
+ if (await closeButton.count()) {
+ await closeButton.first().click();
+ }
+
+ await page.waitForFunction(
+ () => {
+ const view = window.__hudesClient?.view?.impl;
+ return Boolean(view && view.lineObjects && view.lineObjects.length);
+ },
+ { timeout: 5000 },
+ );
+
+ const stats = await page.evaluate(() => {
+ const client = window.__hudesClient;
+ const view = client?.view?.impl;
+ if (!client || !view) {
+ return null;
+ }
+ const frames = Array.isArray(view.alt1dFrames) ? view.alt1dFrames : [];
+ const widths = frames.map((frame) => {
+ const arr = frame?.geometry?.attributes?.position?.array;
+ if (!arr || arr.length < 6) {
+ return null;
+ }
+ let minX = null;
+ let maxX = null;
+ for (let i = 0; i < arr.length; i += 3) {
+ const x = arr[i];
+ if (Number.isFinite(x)) {
+ minX = minX == null ? x : Math.min(minX, x);
+ maxX = maxX == null ? x : Math.max(maxX, x);
+ }
+ }
+ return minX != null && maxX != null ? maxX - minX : null;
+ });
+ const positions =
+ Array.isArray(view.alt1dContainers) && view.alt1dContainers.length
+ ? view.alt1dContainers.map((container) =>
+ container?.position
+ ? { x: container.position.x, y: container.position.y, z: container.position.z }
+ : null,
+ )
+ : [];
+ return {
+ alt1dMode: Boolean(view.alt1dMode),
+ containerCount: Array.isArray(view.lineContainers) ? view.lineContainers.length : null,
+ lineCount: Array.isArray(view.lineObjects) ? view.lineObjects.length : null,
+ lossLines: client.lossLines,
+ frameWidths: widths,
+ expectedWidth: view.gridSize * 0.9 * 2,
+ containerPositions: positions,
+ };
+ });
+
+ expect(stats).not.toBeNull();
+ expect(stats.alt1dMode).toBe(true);
+ expect(stats.containerCount).toBe(2);
+ expect(stats.lineCount).toBe(stats.lossLines);
+ expect(stats.frameWidths).toHaveLength(2);
+ for (const width of stats.frameWidths) {
+ expect(width).not.toBeNull();
+ expect(Math.abs(width - stats.expectedWidth)).toBeLessThanOrEqual(0.5);
+ }
+ expect(stats.containerPositions).toHaveLength(2);
+ const [left, right] = stats.containerPositions;
+ expect(left).not.toBeNull();
+ expect(right).not.toBeNull();
+ expect(left.x).toBeLessThan(0);
+ expect(right.x).toBeGreaterThan(0);
+ expect(Math.abs(left.y)).toBeLessThan(1e-3);
+ expect(Math.abs(right.y)).toBeLessThan(1e-3);
+ expect(Math.abs(left.z)).toBeLessThan(1e-3);
+ expect(Math.abs(right.z)).toBeLessThan(1e-3);
+ });
+});
diff --git a/web/tests/mode_1d_controls.spec.mjs b/web/tests/mode_1d_controls.spec.mjs
new file mode 100644
index 0000000..57ea773
--- /dev/null
+++ b/web/tests/mode_1d_controls.spec.mjs
@@ -0,0 +1,47 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('1D keyboard controls', () => {
+ test('paired key presses update dims without speed run', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?mode=1d&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&debug=1`);
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ const closeButton = page.locator('.help-overlay__close');
+ if (await closeButton.count()) {
+ await closeButton.first().click();
+ }
+
+ await page.waitForFunction(() => {
+ const client = window.__hudesClient;
+ if (!client) return false;
+ if (!Array.isArray(client.dimsAndStepsOnCurrentDims)) {
+ client.zeroDimsAndStepsOnCurrentDims?.();
+ }
+ return Array.isArray(client.dimsAndStepsOnCurrentDims);
+ });
+
+ await page.evaluate(() => window.__hudesClient.zeroDimsAndStepsOnCurrentDims?.());
+ const before = await page.evaluate(() => [...window.__hudesClient.dimsAndStepsOnCurrentDims]);
+
+ await page.keyboard.press('KeyW');
+
+ await page.waitForFunction(() => {
+ const client = window.__hudesClient;
+ if (!client || !Array.isArray(client.dimsAndStepsOnCurrentDims)) return false;
+ return client.dimsAndStepsOnCurrentDims.some((value) => Math.abs(value) > 0);
+ }, { timeout: 5000 });
+
+ const after = await page.evaluate(() => [...window.__hudesClient.dimsAndStepsOnCurrentDims]);
+ expect(after).not.toEqual(before);
+
+ const speedRunActive = await page.evaluate(() => window.__hudesClient?.state?.speedRunActive);
+ expect(speedRunActive).toBeFalsy();
+ });
+});
diff --git a/web/tests/mode_1d_scaling.spec.mjs b/web/tests/mode_1d_scaling.spec.mjs
new file mode 100644
index 0000000..a6d02dc
--- /dev/null
+++ b/web/tests/mode_1d_scaling.spec.mjs
@@ -0,0 +1,79 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('1D plot scaling', () => {
+ test('loss lines stay within frame at large step size', async ({ page }) => {
+ await page.goto(
+ `${APP_ORIGIN}/?mode=1d&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&debug=1`,
+ );
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ const closeButton = page.locator('.help-overlay__close');
+ if (await closeButton.count()) {
+ await closeButton.first().click();
+ }
+
+ await page.evaluate(() => {
+ const client = window.__hudesClient;
+ if (!client) {
+ return;
+ }
+ client.state.stepSizeIdx = -80;
+ client.state.updateStepSize();
+ client.sendConfig?.();
+ });
+
+ await page.waitForFunction(() => window.__hudesClient?.state?.stepSize > 4, {
+ timeout: 5000,
+ });
+
+ await page.waitForFunction(
+ () => {
+ const view = window.__hudesClient?.view?.impl;
+ if (!view) return false;
+ const lines = view.lineObjects || [];
+ if (!lines.length) return false;
+ return lines.every(
+ (line) => line.geometry?.attributes?.position?.array?.length > 0,
+ );
+ },
+ { timeout: 5000 },
+ );
+
+ const { maxValues, limit } = await page.evaluate(() => {
+ const view = window.__hudesClient?.view?.impl;
+ if (!view) {
+ return { maxValues: [], limit: 0 };
+ }
+ const halfHeight = (view.gridSize * 0.6) / 2;
+ const epsilon = 1e-3;
+ const maxValues = view.lineObjects.map((line) => {
+ const arr = line.geometry?.attributes?.position?.array || [];
+ let maxAbs = 0;
+ for (let i = 0; i < arr.length; i += 3) {
+ const y = arr[i + 1];
+ if (Number.isFinite(y)) {
+ const abs = Math.abs(y);
+ if (abs > maxAbs) {
+ maxAbs = abs;
+ }
+ }
+ }
+ return maxAbs;
+ });
+ return { maxValues, limit: halfHeight + epsilon };
+ });
+
+ expect(maxValues.length).toBeGreaterThan(0);
+ for (const value of maxValues) {
+ expect(value).toBeLessThanOrEqual(limit);
+ }
+ });
+});
diff --git a/web/tests/mode_3d_controls.spec.mjs b/web/tests/mode_3d_controls.spec.mjs
new file mode 100644
index 0000000..70926c4
--- /dev/null
+++ b/web/tests/mode_3d_controls.spec.mjs
@@ -0,0 +1,101 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('3D keyboard controls', () => {
+ test('WASD steps update dims without speed run', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?mode=3d&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&debug=1`);
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ const closeButton = page.locator('.help-overlay__close');
+ if (await closeButton.count()) {
+ await closeButton.first().click();
+ }
+
+ await page.waitForFunction(() => {
+ if (!window.__hudesClient) return false;
+ if (!Array.isArray(window.__hudesClient.dimsAndStepsOnCurrentDims)) {
+ window.__hudesClient.zeroDimsAndStepsOnCurrentDims?.();
+ }
+ return Array.isArray(window.__hudesClient.dimsAndStepsOnCurrentDims);
+ });
+
+ await page.evaluate(() => {
+ window.__hudesClient.zeroDimsAndStepsOnCurrentDims?.();
+ });
+ const before = await page.evaluate(() => [...window.__hudesClient.dimsAndStepsOnCurrentDims]);
+
+ await page.keyboard.press('KeyW');
+ await page.keyboard.press('KeyD');
+
+ await page.waitForFunction(() => {
+ const client = window.__hudesClient;
+ if (!client || !Array.isArray(client.dimsAndStepsOnCurrentDims)) return false;
+ return client.dimsAndStepsOnCurrentDims.some((value) => Math.abs(value) > 0);
+ }, { timeout: 5000 });
+
+ const after = await page.evaluate(() => [...window.__hudesClient.dimsAndStepsOnCurrentDims]);
+ expect(after).not.toEqual(before);
+
+ const speedRunActive = await page.evaluate(() => window.__hudesClient?.state?.speedRunActive);
+ expect(speedRunActive).toBeFalsy();
+ });
+
+ test('mouse drag vertical requires half window height for full tilt range', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?mode=3d&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
+
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+
+ await page.waitForSelector('#glContainer canvas', { timeout: 10000 });
+
+ await page.evaluate(() => {
+ const view = window.__hudesClient?.view;
+ if (!view?.getAngles || !view?.adjustAngles) return;
+ const { angleV } = view.getAngles();
+ view.adjustAngles(0, -angleV / 2);
+ });
+
+ const { innerHeight, maxAngle } = await page.evaluate(() => ({
+ innerHeight: window.innerHeight,
+ maxAngle: window.__hudesClient?.view?.impl?.maxAngleV ?? 25,
+ }));
+ const dragPixels = Math.max(10, innerHeight * 0.5);
+ const tolerance = 0.8;
+
+ const canvas = page.locator('#glContainer canvas').first();
+ const box = await canvas.boundingBox();
+ if (!box) throw new Error('Canvas bounding box unavailable');
+ const centerX = box.x + box.width / 2;
+ const centerY = box.y + box.height / 2;
+
+ const dragAndRead = async (deltaY) => {
+ await page.mouse.move(centerX, centerY);
+ await page.mouse.down();
+ await page.mouse.move(centerX, centerY + deltaY, { steps: 10 });
+ await page.mouse.up();
+ return page.evaluate(() => window.__hudesClient?.view?.getAngles?.().angleV ?? 0);
+ };
+
+ const downAngle = await dragAndRead(dragPixels);
+ expect(Math.abs(downAngle + maxAngle)).toBeLessThanOrEqual(tolerance);
+
+ await page.evaluate(() => {
+ const view = window.__hudesClient?.view;
+ if (!view?.getAngles || !view?.adjustAngles) return;
+ const { angleV } = view.getAngles();
+ view.adjustAngles(0, -angleV / 2);
+ });
+
+ const upAngle = await dragAndRead(-dragPixels);
+ expect(Math.abs(upAngle - maxAngle)).toBeLessThanOrEqual(tolerance);
+ });
+});
diff --git a/web/tests/mode_toggles.spec.mjs b/web/tests/mode_toggles.spec.mjs
new file mode 100644
index 0000000..9388c94
--- /dev/null
+++ b/web/tests/mode_toggles.spec.mjs
@@ -0,0 +1,82 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+async function trackErrors(page) {
+ const errors = [];
+ page.on('pageerror', (err) => errors.push(err));
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ const text = msg.text();
+ if (!text.includes('plotly-latest.min.js') && !text.includes('plotly-latest.js')) {
+ errors.push(new Error(text));
+ }
+ }
+ });
+ return errors;
+}
+
+async function waitForClient(page) {
+ await page.waitForFunction(
+ () =>
+ typeof window !== 'undefined' &&
+ window.__hudesClient &&
+ window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+}
+
+async function expectGlScene(page) {
+ await waitForClient(page);
+ await page.waitForSelector('#glContainer canvas', { timeout: 10000 });
+ const canvasCount = await page.locator('#glContainer canvas').count();
+ expect(canvasCount).toBeGreaterThan(0);
+}
+
+async function expectOneDScene(page) {
+ await waitForClient(page);
+ await page.waitForFunction(() => window.__hudesClient?.renderMode === '1d', { timeout: 10000 });
+ await page.waitForSelector('#glContainer canvas', { timeout: 10000 });
+ const canvasCount = await page.locator('#glContainer canvas').count();
+ expect(canvasCount).toBeGreaterThan(0);
+}
+
+test.describe('Top bar mode toggles', () => {
+ test('switch between 3D / 1D / 1D alt back to 3D via header buttons', async ({ page }) => {
+ const errors = await trackErrors(page);
+ await page.addInitScript(() => {
+ try {
+ window.sessionStorage?.setItem('hudes.help.dismissed', '1');
+ } catch {}
+ });
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&mode=3d`);
+ await expectGlScene(page);
+
+ // Switch to 1D view
+ await Promise.all([
+ page.waitForURL(/mode=1d/i),
+ page.getByRole('button', { name: /Switch to 1D view/i }).click(),
+ ]);
+ await expectOneDScene(page);
+
+ // Enable Alt 1D
+ await Promise.all([
+ page.waitForURL(/alt1d=1/i),
+ page.getByRole('button', { name: /Alt 1D:/i }).click(),
+ ]);
+ await page.waitForFunction(() => window.__hudesClient?.alt1d === true, { timeout: 10000 });
+ await expectOneDScene(page);
+
+ // Return to 3D view
+ await Promise.all([
+ page.waitForURL((url) => new URL(url).searchParams.get('mode') === '3d'),
+ page.getByRole('button', { name: /Switch to 3D view/i }).click(),
+ ]);
+ await page.waitForFunction(() => window.__hudesClient?.renderMode === '3d', { timeout: 10000 });
+ await expectGlScene(page);
+
+ expect(errors).toHaveLength(0);
+ });
+});
diff --git a/web/tests/modes.spec.mjs b/web/tests/modes.spec.mjs
new file mode 100644
index 0000000..f5cbf88
--- /dev/null
+++ b/web/tests/modes.spec.mjs
@@ -0,0 +1,46 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+async function trackErrors(page) {
+ const errors = [];
+ page.on('pageerror', (err) => errors.push(err));
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ errors.push(new Error(msg.text()));
+ }
+ });
+ return errors;
+}
+
+async function waitForClient(page) {
+ await page.waitForFunction(
+ () => typeof window !== 'undefined' && window.__hudesClient && window.__hudesClient.ControlType,
+ { timeout: 10000 },
+ );
+}
+
+test.describe('Render modes', () => {
+ test('3D landscape loads without console errors', async ({ page }) => {
+ const errors = await trackErrors(page);
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&mode=3d`);
+ await waitForClient(page);
+ await page.waitForTimeout(1000);
+
+ const canvasCount = await page.locator('#glContainer canvas').count();
+ expect(canvasCount).toBeGreaterThan(0);
+ expect(errors).toHaveLength(0);
+ });
+
+ test('1D mode switches renderer without console errors', async ({ page }) => {
+ const errors = await trackErrors(page);
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off&mode=1d`);
+ await waitForClient(page);
+ await page.waitForFunction(() => window.__hudesClient?.renderMode === '1d', { timeout: 10000 });
+ await page.waitForSelector('#glContainer canvas', { timeout: 10000 });
+ expect(await page.locator('#glContainer canvas').count()).toBeGreaterThan(0);
+ expect(errors).toHaveLength(0);
+ });
+});
diff --git a/web/tests/speedrun.spec.mjs b/web/tests/speedrun.spec.mjs
index 60c6da1..9a49104 100644
--- a/web/tests/speedrun.spec.mjs
+++ b/web/tests/speedrun.spec.mjs
@@ -3,54 +3,58 @@ import { test, expect } from '@playwright/test';
const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
-const SPEED_SECONDS = Number(process.env.HUDES_SPEED_RUN_SECONDS || '5');
+const SPEED_SECONDS = Number(process.env.HUDES_SPEED_RUN_SECONDS ?? '120');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
-test.describe('Speed Run flow', () => {
- test('starts, counts down, and UI stays responsive', async ({ page }) => {
- // Serve the app via Vite preview or static; here we load index.html directly
- await page.goto(`http://localhost:5173/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
-
- // Wait for the client to be ready and WebSocket connected
- await page.waitForFunction(() => window.__hudesClient && window.__hudesClient.ControlType);
+async function runSpeedRunScenario(page, { extraQuery = '', name = 'TEST' } = {}) {
+ const search = `host=${SERVER_HOST}&port=${SERVER_PORT}&help=off${extraQuery}`;
+ await page.goto(`${APP_ORIGIN}/?${search}`);
- // No prompt now; we'll fill the modal input when it appears
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient.ControlType,
+ );
- // Press R to start speed run
- await page.keyboard.press('KeyR');
+ await page.keyboard.press('KeyZ');
- // Wait until the client reports speed run active and remaining seconds appears in HUD string
- await page.waitForFunction(() => {
+ await page.waitForFunction(
+ () => {
const c = window.__hudesClient;
return c && c.state && c.state.speedRunActive === true;
- }, { timeout: 10000 });
-
- // Countdown value may be populated only on next server message; we don't
- // assert intermediate ticks here. We'll rely on final deactivation below.
+ },
+ { timeout: 10000 },
+ );
- // UI responsiveness: trigger next dims (Space) should still be allowed
- await page.keyboard.press('Space');
+ await page.keyboard.press('Space');
- // Loss chart should receive updates (labels growing)
- await page.waitForFunction(() => {
- const c = window.__hudesClient;
- return c && c.trainSteps && c.trainSteps.length > 0;
- });
- const n1 = await page.evaluate(() => window.__hudesClient.trainSteps.length);
- await page.waitForTimeout(1000);
- const n2 = await page.evaluate(() => window.__hudesClient.trainSteps.length);
- expect(n2).toBeGreaterThanOrEqual(n1);
-
- // SGD must be ignored during run: attempt and verify total_sgd_steps not incremented immediately on client
- await page.keyboard.press('Delete');
- const sgd = await page.evaluate(() => window.__hudesClient.state.sgdSteps);
- expect(sgd).toBe(0);
-
- // Wait for name modal
- await page.waitForSelector('#modalOverlay.open .glass-card .name-form', { timeout: (SPEED_SECONDS + 20) * 1000 });
- await page.fill('#modalOverlay.open .glass-card .name-form input', 'TEST');
+ await page.waitForFunction(() => {
+ const c = window.__hudesClient;
+ return c && c.trainSteps && c.trainSteps.length > 0;
+ });
+ const n1 = await page.evaluate(() => window.__hudesClient.trainSteps.length);
+ await page.waitForTimeout(1000);
+ const n2 = await page.evaluate(() => window.__hudesClient.trainSteps.length);
+ expect(n2).toBeGreaterThanOrEqual(n1);
+
+ await page.keyboard.press('Delete');
+ const sgd = await page.evaluate(() => window.__hudesClient.state.sgdSteps);
+ expect(sgd).toBe(0);
+
+ await page.waitForSelector(
+ '#modalOverlay.open .glass-card .name-form',
+ { timeout: (SPEED_SECONDS + 40) * 1000 },
+ );
+ await page.fill('#modalOverlay.open .glass-card .name-form input', name);
await page.click('#modalOverlay.open .glass-card .name-form button[type="submit"]');
- // Then leaderboard appears with our name shown
await page.waitForSelector('#modalOverlay.open .glass-card .top10-list');
+}
+
+test.describe('Speed Run flow', () => {
+ test('3D view: starts, counts down, and UI stays responsive', async ({ page }) => {
+ await runSpeedRunScenario(page, { name: 'TEST' });
+ });
+
+ test('1D view: starts, counts down, and UI stays responsive', async ({ page }) => {
+ await runSpeedRunScenario(page, { extraQuery: '&mode=1d', name: 'T1DM' });
});
});
diff --git a/web/tests/speedrun_keyboard.spec.mjs b/web/tests/speedrun_keyboard.spec.mjs
new file mode 100644
index 0000000..157565d
--- /dev/null
+++ b/web/tests/speedrun_keyboard.spec.mjs
@@ -0,0 +1,50 @@
+import { test, expect } from '@playwright/test';
+
+const SERVER_HOST = process.env.HUDES_HOST || 'localhost';
+const SERVER_PORT = Number(process.env.HUDES_PORT || '10001');
+const SPEED_SECONDS = Number(process.env.HUDES_SPEED_RUN_SECONDS ?? '120');
+const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173';
+
+test.describe('Speed Run keyboard restore', () => {
+ test('keyboard resumes after submitting score', async ({ page }) => {
+ await page.goto(`${APP_ORIGIN}/?host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`);
+ await page.waitForFunction(() => window.__hudesClient && window.__hudesClient.ControlType);
+
+ await page.keyboard.press('KeyZ');
+ await page.waitForFunction(
+ () => {
+ const c = window.__hudesClient;
+ return c && c.state && c.state.speedRunActive === true;
+ },
+ { timeout: 10_000 },
+ );
+
+ await page.keyboard.press('Space');
+ await page.waitForFunction(
+ () => {
+ const c = window.__hudesClient;
+ return c && c.trainSteps && c.trainSteps.length > 0;
+ },
+ );
+ await page.waitForSelector('#modalOverlay.open .glass-card .name-form', { timeout: (SPEED_SECONDS + 40) * 1000 });
+
+ await page.fill('#modalOverlay.open .glass-card .name-form input', 'TEST');
+ await page.click('#modalOverlay.open .glass-card .name-form button[type="submit"]');
+
+ await expect(page.locator('#modalOverlay')).not.toHaveClass(/open/);
+ await page.waitForFunction(
+ () => window.__hudesClient && window.__hudesClient._textCaptureActive === false,
+ );
+
+ await page.keyboard.press('Space');
+ const trainStepsAfter = await page.evaluate(() => window.__hudesClient?.trainSteps?.length ?? 0);
+ await page.waitForFunction(
+ (prev) => {
+ const current = window.__hudesClient?.trainSteps?.length ?? 0;
+ return current >= prev;
+ },
+ trainStepsAfter,
+ { timeout: 5_000 },
+ );
+ });
+});
diff --git a/web/tests/view_router.test.mjs b/web/tests/view_router.test.mjs
new file mode 100644
index 0000000..a5a90b1
--- /dev/null
+++ b/web/tests/view_router.test.mjs
@@ -0,0 +1,31 @@
+global.document = {
+ getElementById() { return null; },
+};
+
+import View from '../client/View.js';
+
+const dummyState = {
+ toString() {
+ return 'state';
+ },
+};
+
+(() => {
+ const view = new View(31, 3, dummyState, { mode: '3d' });
+ if (view.modeName !== '3d') {
+ throw new Error(`Expected 3d view, got ${view.modeName}`);
+ }
+ if (typeof view.updateMeshGrids !== 'function') {
+ throw new Error('3d view should expose updateMeshGrids');
+ }
+})();
+
+(() => {
+ const view = new View(31, 0, dummyState, { mode: '1d', lossLines: 4 });
+ if (view.modeName !== '1d') {
+ throw new Error(`Expected 1d view, got ${view.modeName}`);
+ }
+ if (typeof view.updateLossLines !== 'function') {
+ throw new Error('1d view should expose updateLossLines');
+ }
+})();
diff --git a/web/utils/logger.mjs b/web/utils/logger.mjs
index 0ea2f04..4087730 100644
--- a/web/utils/logger.mjs
+++ b/web/utils/logger.mjs
@@ -1,5 +1,65 @@
-export function log(message) {
- // keep behavior minimal for test environment
- // eslint-disable-next-line no-console
- console.log(message);
+const LEVELS = {
+ error: 0,
+ warn: 1,
+ info: 2,
+ debug: 3,
+};
+
+const envLevel =
+ (typeof import.meta !== 'undefined' && import.meta.env?.VITE_LOG_LEVEL) ||
+ (typeof process !== 'undefined' && process.env?.VITE_LOG_LEVEL) ||
+ 'info';
+const ACTIVE_LEVEL = LEVELS[envLevel.toLowerCase()] ?? LEVELS.info;
+
+function formatMessage(scope, level, message) {
+ const parts = [];
+ if (scope) parts.push(`[${scope}]`);
+ parts.push(level.toUpperCase());
+ parts.push(message);
+ return parts.join(' ');
+}
+
+export function createLogger(scope = 'app') {
+ const write = (level, message, context) => {
+ const lvlIdx = LEVELS[level] ?? LEVELS.info;
+ if (lvlIdx > ACTIVE_LEVEL) return;
+ const formatted = formatMessage(scope, level, message);
+ const consoleFn =
+ level === 'error'
+ ? console.error
+ : level === 'warn'
+ ? console.warn
+ : console.log;
+
+ if (context && typeof context === 'object') {
+ consoleFn(formatted, context);
+ } else if (context != null) {
+ consoleFn(`${formatted} ${String(context)}`);
+ } else {
+ consoleFn(formatted);
+ }
+ };
+
+ return {
+ debug(message, context) {
+ write('debug', message, context);
+ },
+ info(message, context) {
+ write('info', message, context);
+ },
+ warn(message, context) {
+ write('warn', message, context);
+ },
+ error(message, context) {
+ write('error', message, context);
+ },
+ };
+}
+
+const defaultLogger = createLogger('hudes');
+
+export function log(message, context = null, level = 'info') {
+ const upper = typeof level === 'string' ? level.toLowerCase() : 'info';
+ const loggerFn = defaultLogger[upper] || defaultLogger.info;
+ loggerFn(message, context);
}