From ce7612a0eb5554d284ebc1e90745067bc62a20ff Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 2 Nov 2025 20:36:32 -0800 Subject: [PATCH 01/29] add 1dmode and glow --- web/client/View.js | 942 ++------------------------- web/client/views/LandscapeView.js | 930 ++++++++++++++++++++++++++ web/tests/highscores_insert.spec.mjs | 3 +- web/tests/highscores_page.spec.mjs | 3 +- web/tests/leaderboard_ws.spec.mjs | 3 +- web/tests/mode_1d_controls.spec.mjs | 47 ++ web/tests/mode_1d_scaling.spec.mjs | 77 +++ web/tests/mode_3d_controls.spec.mjs | 49 ++ web/tests/modes.spec.mjs | 48 ++ web/tests/speedrun.spec.mjs | 3 +- web/tests/view_router.test.mjs | 31 + 11 files changed, 1243 insertions(+), 893 deletions(-) create mode 100644 web/client/views/LandscapeView.js create mode 100644 web/tests/mode_1d_controls.spec.mjs create mode 100644 web/tests/mode_1d_scaling.spec.mjs create mode 100644 web/tests/mode_3d_controls.spec.mjs create mode 100644 web/tests/modes.spec.mjs create mode 100644 web/tests/view_router.test.mjs diff --git a/web/client/View.js b/web/client/View.js index 34294f7..843ca7c 100644 --- a/web/client/View.js +++ b/web/client/View.js @@ -1,930 +1,94 @@ -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); - } -}; - -//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, - ); - } +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); } - // 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); + initializeCharts(...args) { + return this.impl?.initializeCharts?.(...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); + updateLossChart(...args) { + return this.impl?.updateLossChart?.(...args); } - // Function to reset angles - resetAngle() { - this.angleH = 0.0; - this.angleV = this.defaultAngleV; + updateLastStepsChart(...args) { + return this.impl?.updateLastStepsChart?.(...args); } - // Helper function to normalize angles between 0 and 360 degrees - normalizeDegree(angle) { - return ((angle % 360) + 360) % 360; + updateConfusionMatrix(...args) { + return this.impl?.updateConfusionMatrix?.(...args); } - - - - // Initialize all charts (including Plotly-based heatmap) - initializeCharts() { - this.initializeLossChart(); - this.initializeLastStepsChart(); - this.initializeStepSizeChart(); - this.initializeDimsAndStepsChart(); - this.initializeConfusionMatrixHeatmap(); - log("Charts initialized."); - } - // 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); - } - } - - 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', - }, - }, - }, - }, - }); - } - } - - // 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', - }, - }, - }, - }, - }); - } - } - - - // 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.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 (!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); - } - } - - - 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); - } + updateExamplePreds(...args) { + return this.impl?.updateExamplePreds?.(...args); } - - - - // Function to get current horizontal and vertical angles - getAngles() { - return { angleH: this.angleH, angleV: this.angleV }; + updateExamples(...args) { + return this.impl?.updateExamples?.(...args); } - getCanvasElement() { - return this.renderer?.domElement ?? null; + updateMeshGrids(...args) { + return this.impl?.updateMeshGrids?.(...args); } - // Function to get the selected grid index - getSelectedGrid() { - return this.selectedGridIndex; + updateLossLines(...args) { + return this.impl?.updateLossLines?.(...args); } - // Function to increase zoom level - increaseZoom() { - this.scaleFactor = Math.max(0.7, this.scaleFactor - 0.05); - } + annotateBottomScreen(...args) { + return this.impl?.annotateBottomScreen?.(...args); + } - // Function to decrease zoom level - decreaseZoom() { - this.scaleFactor = Math.min(5.0, this.scaleFactor + 0.05); - } + showImage(...args) { + return this.impl?.showImage?.(...args); + } - // Function to increment selected grid - incrementSelectedGrid() { - this.selectedGridIndex = (this.selectedGridIndex + 1) % this.effectiveGrids; + hideImage(...args) { + return this.impl?.hideImage?.(...args); } - // Function to decrement selected grid - decrementSelectedGrid() { - this.selectedGridIndex = (this.selectedGridIndex - 1 + this.effectiveGrids) % this.effectiveGrids; // Adding effectiveGrids to ensure non-negative index + resetAngle(...args) { + return this.impl?.resetAngle?.(...args); } - // Function to set the selected grid - setSelectedGrid(gridIdx) { - this.selectedGridIndex = gridIdx; + adjustAngles(...args) { + return this.impl?.adjustAngles?.(...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; + getAngles(...args) { + return this.impl?.getAngles?.(...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); + getCanvasElement(...args) { + return this.impl?.getCanvasElement?.(...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); - }); + incrementSelectedGrid(...args) { + return this.impl?.incrementSelectedGrid?.(...args); } - // 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 }); - }); + decrementSelectedGrid(...args) { + return this.impl?.decrementSelectedGrid?.(...args); } - 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; + getSelectedGrid(...args) { + return this.impl?.getSelectedGrid?.(...args); } - 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'); - } - } + selectGridAt(...args) { + return this.impl?.selectGridAt?.(...args); } + get modeName() { + return this.mode; + } + highlightLossLine(...args) { + return this.impl?.highlightLossLine?.(...args); + } } diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js new file mode 100644 index 0000000..34294f7 --- /dev/null +++ b/web/client/views/LandscapeView.js @@ -0,0 +1,930 @@ +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 + +const DEBUG_SELECTION = false; + +const debugSelection = (message) => { + if (DEBUG_SELECTION) { + log(message); + } +}; + +//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, + ); + } + } + // 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); + } + + // 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 Plotly-based heatmap) + initializeCharts() { + this.initializeLossChart(); + this.initializeLastStepsChart(); + this.initializeStepSizeChart(); + this.initializeDimsAndStepsChart(); + this.initializeConfusionMatrixHeatmap(); + log("Charts initialized."); + } + // 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); + } + } + + 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', + }, + }, + }, + }, + }); + } + } + + // 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', + }, + }, + }, + }, + }); + } + } + + + // 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.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 (!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); + } + } + + + 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); + } + } + + + + + // 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; + } + 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); + } + // 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/tests/highscores_insert.spec.mjs b/web/tests/highscores_insert.spec.mjs index a482029..492f9c8 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,7 +22,7 @@ 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'); 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/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/mode_1d_controls.spec.mjs b/web/tests/mode_1d_controls.spec.mjs new file mode 100644 index 0000000..33ac793 --- /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`); + + 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..c43926d --- /dev/null +++ b/web/tests/mode_1d_scaling.spec.mjs @@ -0,0 +1,77 @@ +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`); + + 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..6334b28 --- /dev/null +++ b/web/tests/mode_3d_controls.spec.mjs @@ -0,0 +1,49 @@ +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`); + + 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(); + }); +}); diff --git a/web/tests/modes.spec.mjs b/web/tests/modes.spec.mjs new file mode 100644 index 0000000..a500fa6 --- /dev/null +++ b/web/tests/modes.spec.mjs @@ -0,0 +1,48 @@ +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 slices load 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.waitForSelector('.oned-chart-card', { timeout: 10000 }); + await page.waitForTimeout(1000); + + const chartCount = await page.locator('.oned-chart-card').count(); + expect(chartCount).toBeGreaterThanOrEqual(6); + expect(errors).toHaveLength(0); + }); +}); diff --git a/web/tests/speedrun.spec.mjs b/web/tests/speedrun.spec.mjs index 60c6da1..6aad6c2 100644 --- a/web/tests/speedrun.spec.mjs +++ b/web/tests/speedrun.spec.mjs @@ -4,11 +4,12 @@ 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'; 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`); + await page.goto(`${APP_ORIGIN}/?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); 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'); + } +})(); From 22900647c30f797f0b271bd262a028c163b16f91 Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 2 Nov 2025 21:08:12 -0800 Subject: [PATCH 02/29] fix ruff --- .gitignore | 1 + README.md | 5 + hudes/hudes.proto | 5 + hudes/hudes_pb2.py | 36 +-- hudes/model_data_and_subspace.py | 102 +++++- hudes/websocket_server.py | 96 ++++-- run.sh | 1 + tests/test_param_nn.py | 47 +++ web/app.js | 80 ++++- web/client/HudesClient.js | 64 +++- web/client/KeyboardClient.js | 165 ++++++++-- web/client/KeyboardClientGL.js | 69 ++-- web/client/views/LandscapeView.js | 484 +++++++++++++++++++++++++++- web/package.json | 12 +- web/playwright.config.ts | 4 +- web/style.css | 23 ++ web/tests/mode_1d_altkeys.spec.mjs | 47 +++ web/tests/mode_1d_controls.spec.mjs | 2 +- web/tests/mode_3d_controls.spec.mjs | 2 +- 19 files changed, 1107 insertions(+), 138 deletions(-) create mode 100644 web/tests/mode_1d_altkeys.spec.mjs diff --git a/.gitignore b/.gitignore index bd752f6..3334e61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ **/*__pycache__ **/*.pem **/*.sqlite3 +web/generated/ diff --git a/README.md b/README.md index 1b79abb..c80fcae 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,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: diff --git a/hudes/hudes.proto b/hudes/hudes.proto index cb1a0ec..b87f44b 100644 --- a/hudes/hudes.proto +++ b/hudes/hudes.proto @@ -15,6 +15,8 @@ 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; } message TrainLossAndPreds { @@ -60,6 +62,7 @@ message Control { CONTROL_CONFIG = 103; CONTROL_MESHGRID_RESULTS= 104; CONTROL_SGD_STEP = 105; + CONTROL_LOSS_LINE_RESULTS = 108; CONTROL_QUIT = 901; } required Type type = 1; @@ -94,5 +97,7 @@ 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; } diff --git a/hudes/hudes_pb2.py b/hudes/hudes_pb2.py index 1a64108..d403a44 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"\xc5\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\x12\x1a\n\x0cmesh_enabled\x18\x08 \x01(\x08:\x04true\x12\x12\n\nloss_lines\x18\t \x01(\x05"\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"\xcb\x08\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\x12\x1a\n\x11loss_line_results\x18\xd8\x01 \x03(\x02\x12\x18\n\x0floss_line_shape\x18\xd9\x01 \x03(\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"\x91\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\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\x11\n\x0c\x43ONTROL_QUIT\x10\x85\x07' ) _globals = globals() @@ -31,19 +31,19 @@ _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 = 261 + _globals["_TRAINLOSSANDPREDS"]._serialized_start = 264 + _globals["_TRAINLOSSANDPREDS"]._serialized_end = 397 + _globals["_VALLOSS"]._serialized_start = 399 + _globals["_VALLOSS"]._serialized_end = 426 + _globals["_BATCHEXAMPLES"]._serialized_start = 429 + _globals["_BATCHEXAMPLES"]._serialized_end = 631 + _globals["_BATCHEXAMPLES_TYPE"]._serialized_start = 613 + _globals["_BATCHEXAMPLES_TYPE"]._serialized_end = 631 + _globals["_CONTROL"]._serialized_start = 634 + _globals["_CONTROL"]._serialized_end = 1733 + _globals["_CONTROL_HIGHSCORE"]._serialized_start = 1242 + _globals["_CONTROL_HIGHSCORE"]._serialized_end = 1329 + _globals["_CONTROL_TYPE"]._serialized_start = 1332 + _globals["_CONTROL_TYPE"]._serialized_end = 1733 # @@protoc_insertion_point(module_scope) 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_server.py b/hudes/websocket_server.py index 844fbda..5a8037d 100644 --- a/hudes/websocket_server.py +++ b/hudes/websocket_server.py @@ -147,7 +147,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 +190,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 +216,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 +239,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 @@ -314,7 +330,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,32 +341,26 @@ 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: @@ -388,7 +400,7 @@ 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"): + if train_or_val in ("train", "mesh", "loss_line", "sgd"): # TODO need to be ok with getting errors here logging.debug("inference_result_sender: sent train to client") msg = hudes_pb2.Control( @@ -464,7 +476,21 @@ async def inference_result_sender(results_q, stop): ).SerializeToString() ) logging.debug("inference_result_sender: sent mesh to client : done") - if train_or_val not in ("train", "val", "mesh", "sgd"): + if train_or_val == "loss_line": + logging.debug("inference_result_sender: sent loss line to client") + loss_tensor = res["loss_line"] + await client.websocket.send( + 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), + **_countdown_kwargs(client), + ).SerializeToString() + ) + 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 except ( websockets.exceptions.ConnectionClosedOK, @@ -591,6 +617,9 @@ async def process_client(websocket, client_runner_q): logging.debug(f"process_client: {client_idx} : control config") 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 @@ -599,11 +628,18 @@ async def process_client(websocket, client_runner_q): 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 if ( - client.mesh_grids > 0 + 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 ): # if we have grids, step size changes mesh client.force_update = True 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_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/web/app.js b/web/app.js index 87d5f2d..8c0c50e 100644 --- a/web/app.js +++ b/web/app.js @@ -1,9 +1,4 @@ -// 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'; async function detectBackendHostPort() { @@ -51,11 +46,82 @@ async function detectBackendHostPort() { return { host, port: 8765 }; } +function installModeToggle(currentMode) { + if (typeof document === 'undefined') return; + const header = document.querySelector('.site-header'); + if (!header) return; + const existing = header.querySelector('.view-toggle'); + if (existing) { + existing.remove(); + } + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'view-toggle'; + const targetMode = currentMode === '1d' ? '3d' : '1d'; + button.textContent = currentMode === '1d' ? 'Switch to 3D view' : 'Switch to 1D view'; + button.addEventListener('click', () => { + const params = new URLSearchParams(window.location.search); + params.set('mode', targetMode); + window.location.search = params.toString(); + }); + header.appendChild(button); +} + (async function bootstrap() { const { host, port } = await detectBackendHostPort(); - const client = new KeyboardClientGL(host, port); + const params = new URLSearchParams(window.location.search); + const modeParam = (params.get('mode') || '').toLowerCase(); + const renderMode = modeParam === '1d' ? '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 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, + }) + : new KeyboardClientGL(host, port, { + renderMode: '3d', + debug: debugEnabled, + gridSize, + rowSpacing, + depthStep, + cameraDistance, + }); if (typeof window !== 'undefined') { window.__hudesClient = client; + if (debugEnabled) { + window.__hudesDebug = true; + } } + installModeToggle(renderMode); client.runLoop(); })(); diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index d13afaf..d1e810d 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -5,7 +5,7 @@ import { log } from '../utils/logger.js'; import View from './View.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,8 +13,10 @@ 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'); if (qpHost) addr = qpHost; if (qpPort) port = Number(qpPort); + if (qpMode) options.renderMode = qpMode.toLowerCase(); } } catch {} @@ -30,6 +32,12 @@ export default class HudesClient { this.requestIdx = 0; this.running = true; + const renderMode = (options.renderMode || '3d').toLowerCase(); + this.debug = Boolean(options.debug); + this.renderMode = renderMode === '1d' ? '1d' : '3d'; + this.meshEnabled = this.renderMode !== '1d'; + this.lossLines = this.renderMode === '1d' ? (options.lossLines ?? 6) : 0; + this.state = new ClientState(-0.05, 6); // Optional: allow skipping help screens via URL query, e.g., ?help=off try { @@ -41,14 +49,26 @@ export default class HudesClient { } } } catch {} - this.grids=3; - this.grid_size=31; - this.view = new View(this.grid_size,this.grids, this.state); // Add a View instance + 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, + }); 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; @@ -252,6 +272,22 @@ export default class HudesClient { this._updateSpeedRunFromMessage(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); + break; + } + // No CONTROL_FULL_LOSS handling (removed) @@ -488,6 +524,13 @@ export default class HudesClient { 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) { @@ -864,6 +907,7 @@ export default class HudesClient { this.zeroDimsAndStepsOnCurrentDims(); this.dimsAndStepsUpdated(); this.state.dimsUsed += this.state.n; + this.dimsOffset += this.state.n; //console.log("Next dimensions requested and steps reset."); } @@ -974,7 +1018,9 @@ export default class HudesClient { return; } - // Create config payload + this.dimsOffset = 0; + this.lossLineLabels = []; + const payload = { type: this.ControlType.values.CONTROL_CONFIG, config: { @@ -982,16 +1028,18 @@ export default class HudesClient { dimsAtATime: this.state.n, meshGridSize: this.grid_size, meshStepSize: this.state.stepSize, - meshGrids: this.grids, + meshGrids: this.meshEnabled ? this.grids : 0, batchSize: this.state.batchSize, dtype: this.state.dtype, + meshEnabled: this.meshEnabled, + lossLines: this.renderMode === '1d' ? this.lossLines : 0, }, }; // Use sendQ to send the message 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}`); + 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}`); } diff --git a/web/client/KeyboardClient.js b/web/client/KeyboardClient.js index 87f5b24..c206ba2 100644 --- a/web/client/KeyboardClient.js +++ b/web/client/KeyboardClient.js @@ -2,27 +2,49 @@ import HudesClient from './HudesClient.js'; import { log } from '../utils/logger.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 ?? 45; + 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); @@ -48,6 +70,11 @@ export default class KeyboardClient extends HudesClient { } + _handleKeyUpOverride(event) { + this._stopRepeat(event.key?.toLowerCase?.()); + super._handleKeyUp(event); + } + usageStr() { return ` Keyboard controller usage: @@ -73,29 +100,117 @@ 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 (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 (this.state.helpScreenIdx !== -1) { + 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 }); + 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..2a71e66 100644 --- a/web/client/KeyboardClientGL.js +++ b/web/client/KeyboardClientGL.js @@ -1,6 +1,5 @@ import KeyboardClient from './KeyboardClient.js'; import { log } from '../utils/logger.js'; -import View from './View.js'; import { installMouseControls, computeStepVector } from './mouseControls.js'; const DEBUG_MOUSE = false; @@ -12,8 +11,8 @@ const debugMouse = (message) => { }; export default class KeyboardClientGL extends KeyboardClient { - constructor(addr, port) { - super(addr, port); + constructor(addr, port, options = {}) { + super(addr, port, { ...options, renderMode: '3d' }); this.initInput(); if (this.state.helpScreenIdx != -1) { @@ -34,6 +33,8 @@ 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._mouseControls = null; @@ -168,6 +169,30 @@ 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); + } + + _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); + } + + _startAngleRepeat(key, deltaH, deltaV) { + this._startRepeat(key, () => this._performAngleAdjust(deltaH, deltaV)); + } + processKeyPress(event) { const currentTime = performance.now(); let redraw = this.processCommonKeys(event); @@ -178,55 +203,57 @@ export default class KeyboardClientGL extends KeyboardClient { } 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': diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 34294f7..e912114 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -6,18 +6,27 @@ import { 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 } from './hud.js'; -import { log } from '../utils/logger.js'; // Import your logging utility +import { CONTROL_GROUPS, formatHudMarkup } from '../hud.js'; +import { log } from '../../utils/logger.js'; // Import your logging utility const DEBUG_SELECTION = false; @@ -62,11 +71,13 @@ Chart.register( ); -export default class View { - constructor(gridSize, numGrids, clientState) { +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.state=clientState; this.lossChart = null; @@ -81,6 +92,27 @@ export default class View { // Grid properties this.gridObjects = []; + this.lineGroup = null; + this.lineObjects = []; + this.lineFrames = []; + this.lineContainers = []; + this.centerLines = []; + this.horizontalLines = []; + this.lineScaleCache = []; + this.lineBaseColors = []; + this.frameGlowState = []; + 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.selectedGridIndex = 0; // Index of the selected grid this.selectedGridScale = 2 * 1.5; //1.5; // Scale multiplier for the selected grid @@ -102,6 +134,9 @@ export default class View { 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; + } this.raycaster = new Raycaster(); this.pointer = new Vector2(); @@ -124,6 +159,18 @@ export default class View { 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 @@ -163,14 +210,41 @@ export default class View { ); } } + highlightLossLine(index, durationMs = this.glowDuration) { + if (this.mode !== '1d') { + 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() { // 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); + let distance = (totalWidth / 2) / Math.tan(fovRadians / 2); + if (this.customCameraDistance != null) { + distance = this.customCameraDistance; + } + this.camera_distance = distance; + this.camera_yOffset = (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; @@ -179,6 +253,8 @@ export default class View { // 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 @@ -490,9 +566,28 @@ export default class View { 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; @@ -500,6 +595,10 @@ export default class View { 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 => { @@ -739,17 +838,380 @@ export default class View { return gridIndex; } + + updateLossLines(lines, { stepSpacing, labels } = {}) { + if (this.mode !== '1d' || !this.scene) { + return; + } + if (!Array.isArray(lines) || !lines.length) { + 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, index) => { - grid.rotation.z = MathUtils.degToRad(this.angleH); // Rotate around Z axis - grid.rotation.x = MathUtils.degToRad(this.angleV - 90); // Rotate around X axis + this.gridObjects.forEach((grid) => { + grid.rotation.z = MathUtils.degToRad(this.angleH); + grid.rotation.x = MathUtils.degToRad(this.angleV - 90); }); - // Render the scene - this.renderer.render(this.scene, this.camera); + 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); + } + } + + 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.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) { diff --git a/web/package.json b/web/package.json index ce83789..c98e75d 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", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index afd8f28..6e4d6e0 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ 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,7 +14,7 @@ export default defineConfig({ webServer: [ { command: 'npm run dev', - url: 'http://localhost:5173', + url: 'http://localhost:6173', reuseExistingServer: true, timeout: 60_000, }, diff --git a/web/style.css b/web/style.css index 740a02b..9cfd6af 100644 --- a/web/style.css +++ b/web/style.css @@ -31,6 +31,29 @@ body { image-rendering: auto; } +.view-toggle { + position: absolute; + right: clamp(12px, 4vw, 28px); + top: 50%; + transform: translateY(-50%); + padding: 6px 14px; + border-radius: 999px; + border: 1px solid rgba(255, 210, 140, 0.42); + background: rgba(0, 0, 0, 0.68); + color: #ffd666; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.7rem; + cursor: pointer; + transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; +} + +.view-toggle:hover { + border-color: rgba(255, 225, 160, 0.9); + background: rgba(255, 200, 80, 0.2); + transform: translateY(-50%) scale(1.05); +} + #imageContainer { position: fixed; top: var(--header-height); 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_controls.spec.mjs b/web/tests/mode_1d_controls.spec.mjs index 33ac793..57ea773 100644 --- a/web/tests/mode_1d_controls.spec.mjs +++ b/web/tests/mode_1d_controls.spec.mjs @@ -6,7 +6,7 @@ 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`); + 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, diff --git a/web/tests/mode_3d_controls.spec.mjs b/web/tests/mode_3d_controls.spec.mjs index 6334b28..6788606 100644 --- a/web/tests/mode_3d_controls.spec.mjs +++ b/web/tests/mode_3d_controls.spec.mjs @@ -6,7 +6,7 @@ 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`); + 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, From ca6e9f218c502b4cc3e5b5ffe13faac9f8a86261 Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 2 Nov 2025 21:17:45 -0800 Subject: [PATCH 03/29] update --- web/client/KeyboardClient.js | 2 +- web/index.html | 8 ++++++++ web/style.css | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/web/client/KeyboardClient.js b/web/client/KeyboardClient.js index c206ba2..6efc2a3 100644 --- a/web/client/KeyboardClient.js +++ b/web/client/KeyboardClient.js @@ -8,7 +8,7 @@ export default class KeyboardClient extends HudesClient { this.pairedKeys = []; this.keyToParamAndSign = {}; this.activeRepeats = new Map(); - this.keyRepeatIntervalMs = options.repeatIntervalMs ?? 45; + this.keyRepeatIntervalMs = options.repeatIntervalMs ?? 100; this.keyRepeatDelayMs = options.repeatDelayMs ?? 100; this._handleKeyUpOverride = this._handleKeyUpOverride.bind(this); this.initInput(); diff --git a/web/index.html b/web/index.html index d08fff1..822f767 100644 --- a/web/index.html +++ b/web/index.html @@ -14,6 +14,14 @@
diff --git a/web/style.css b/web/style.css index 9cfd6af..0bc6251 100644 --- a/web/style.css +++ b/web/style.css @@ -31,6 +31,26 @@ body { image-rendering: auto; } +.site-source-link { + position: absolute; + left: clamp(10px, 3vw, 28px); + top: 50%; + transform: translateY(-50%); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #ffd666; + text-decoration: none; + font-weight: 600; + transition: color 140ms ease; +} + +.site-source-link:hover, +.site-source-link:focus-visible { + color: #ffe59c; + text-decoration: underline; +} + .view-toggle { position: absolute; right: clamp(12px, 4vw, 28px); From 7e2caf06b5c0df106a9630e5754d070ecacd9991 Mon Sep 17 00:00:00 2001 From: misko Date: Tue, 4 Nov 2025 08:26:56 -0800 Subject: [PATCH 04/29] lint --- hudes/websocket_server.py | 52 ++- web/app.js | 54 ++- web/client/HudesClient.js | 53 ++- web/client/KeyboardClient.js | 12 +- web/client/KeyboardClientGL.js | 3 + web/client/hud.js | 2 +- web/client/views/LandscapeView.js | 555 +++++++++++++++++++++++++-- web/style.css | 41 +- web/tests/highscores_insert.spec.mjs | 4 +- web/tests/mode_1d_altview.spec.mjs | 93 +++++ web/tests/mode_1d_scaling.spec.mjs | 4 +- web/tests/speedrun.spec.mjs | 77 ++-- 12 files changed, 867 insertions(+), 83 deletions(-) create mode 100644 web/tests/mode_1d_altview.spec.mjs diff --git a/hudes/websocket_server.py b/hudes/websocket_server.py index 5a8037d..b5f63f2 100644 --- a/hudes/websocket_server.py +++ b/hudes/websocket_server.py @@ -293,6 +293,11 @@ async def inference_runner_clients(mad, client_runner_q, inference_q, stop): # 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 @@ -365,7 +370,10 @@ async def inference_runner_clients(mad, client_runner_q, inference_q, stop): 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: @@ -375,6 +383,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 @@ -438,6 +450,12 @@ async def inference_result_sender(results_q, stop): if remaining <= 0: client.speed_run_active = False speed_finished = True + if speed_finished: + logging.info( + "Speed run finished for client %s; sending VAL loss %.6f", + client.client_id, + res["val"]["val_loss"], + ) await client.websocket.send( hudes_pb2.Control( type=hudes_pb2.Control.CONTROL_VAL_LOSS, @@ -508,20 +526,43 @@ 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) @@ -530,6 +571,9 @@ async def _timeout_then_eval(): if old_task and not old_task.done(): try: old_task.cancel() + logging.debug( + "Cancelled previous speed run timeout task for client %s", client_id + ) except Exception: pass task = asyncio.create_task(_timeout_then_eval()) @@ -687,6 +731,12 @@ async def process_client(websocket, client_runner_q): client.client_id, duration, ) + logging.debug( + "Speed run state: client=%s active=%s end_time=%.3f", + client.client_id, + client.speed_run_active, + client.speed_run_end_time, + ) # Schedule a final evaluation once the timer elapses for this # run sequence _schedule_speedrun_timeout(current_client, duration, client_runner_q) diff --git a/web/app.js b/web/app.js index 8c0c50e..51991b0 100644 --- a/web/app.js +++ b/web/app.js @@ -50,21 +50,52 @@ function installModeToggle(currentMode) { if (typeof document === 'undefined') return; const header = document.querySelector('.site-header'); if (!header) return; - const existing = header.querySelector('.view-toggle'); - if (existing) { - existing.remove(); + let container = header.querySelector('.header-controls'); + if (!container) { + container = document.createElement('div'); + container.className = 'header-controls'; + header.appendChild(container); } - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'view-toggle'; + container.innerHTML = ''; + + const viewButton = document.createElement('button'); + viewButton.type = 'button'; + viewButton.className = 'view-toggle'; const targetMode = currentMode === '1d' ? '3d' : '1d'; - button.textContent = currentMode === '1d' ? 'Switch to 3D view' : 'Switch to 1D view'; - button.addEventListener('click', () => { + viewButton.textContent = currentMode === '1d' ? 'Switch to 3D view' : 'Switch to 1D view'; + viewButton.addEventListener('click', () => { const params = new URLSearchParams(window.location.search); params.set('mode', targetMode); window.location.search = params.toString(); }); - header.appendChild(button); + container.appendChild(viewButton); + + if (currentMode === '1d') { + 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) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'mode-toggle'; + const enabled = params.has(key); + btn.textContent = `${label}: ${enabled ? 'ON' : 'OFF'}`; + btn.addEventListener('click', () => toggleFlag(key)); + return btn; + }; + + container.appendChild(makeToggle('Alt Keys', 'altkeys')); + container.appendChild(makeToggle('Alt 1D', 'alt1d')); + } } (async function bootstrap() { @@ -78,6 +109,10 @@ function installModeToggle(currentMode) { 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'); @@ -107,6 +142,7 @@ function installModeToggle(currentMode) { depthStep, cameraDistance, altKeys: altKeysEnabled, + alt1d: alt1dEnabled, }) : new KeyboardClientGL(host, port, { renderMode: '3d', diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index d1e810d..dc0dc33 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -37,6 +37,8 @@ export default class HudesClient { this.renderMode = renderMode === '1d' ? '1d' : '3d'; 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 @@ -62,6 +64,8 @@ export default class HudesClient { rowSpacing: this.rowSpacing, depthStep: this.depthStep, cameraDistance: this.cameraDistance, + alt1d: this.alt1d, + altKeys: this.altKeys, }); this.view.initializeCharts(); // Initialize charts this.Control = null; @@ -71,6 +75,7 @@ export default class HudesClient { this.cooldownTimeMs = 10; this.keySecondsPressed = 200; // ms this.stepSizeMultiplier = 1; + this._textCaptureActive = false; // Speed run state (frontend) this.speedRunActive = false; @@ -319,6 +324,11 @@ export default class HudesClient { message, 'speedRunFinished', ); + 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 (hasFinished && message.speedRunFinished) { // Authoritative end-of-run signal from server if (this._srsInterval) { try { clearInterval(this._srsInterval); } catch {} this._srsInterval = null; } @@ -330,8 +340,10 @@ export default class HudesClient { // Capture achieved final val loss if present if (message.valLoss && typeof message.valLoss.valLoss === 'number') { this._lastFinalValLoss = message.valLoss.valLoss; + log(`[HudesClient] final val loss recorded: ${this._lastFinalValLoss}`); } if (!this.highScoreSubmitted) { + log('[HudesClient] prompting for high score submission'); this._promptAndSubmitHighScore(this._lastFinalValLoss); } return; @@ -349,6 +361,7 @@ export default class HudesClient { 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; @@ -363,6 +376,7 @@ export default class HudesClient { 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 @@ -373,6 +387,7 @@ export default class HudesClient { 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); } @@ -388,6 +403,7 @@ export default class HudesClient { } catch {} } _promptAndSubmitHighScore(finalValLoss) { + log('[HudesClient] _promptAndSubmitHighScore invoked'); if (this.highScoreSubmitted) return; try { this._showNameModal(finalValLoss); @@ -415,6 +431,7 @@ export default class HudesClient { } _showNameModal(finalValLoss) { + this._setTextCaptureActive(true); const overlay = this._createOverlay(); overlay.innerHTML = ''; @@ -461,9 +478,11 @@ export default class HudesClient { const input = form.querySelector('#nameInput'); if (input) { input.focus(); } + log('[HudesClient] name entry modal shown'); } _showLeaderboardModal(top10, me) { + this._setTextCaptureActive(false); const overlay = this._createOverlay(); overlay.innerHTML = ''; const card = document.createElement('div'); @@ -492,6 +511,7 @@ export default class HudesClient { closeBtn.textContent = 'Close'; closeBtn.addEventListener('click', () => { overlay.classList.remove('open'); + this._setTextCaptureActive(false); }); const viewBtn = document.createElement('button'); viewBtn.textContent = 'View Top 10'; @@ -578,6 +598,26 @@ export default class HudesClient { window.addEventListener("keyup", this._handleKeyUp); } + _setTextCaptureActive(active) { + this._textCaptureActive = Boolean(active); + } + + _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; + } + // Utility to track key press timing updateKeyHolds(currentlyPressedKeys) { @@ -627,6 +667,9 @@ export default class HudesClient { 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 }; @@ -637,6 +680,9 @@ export default class HudesClient { } _handleKeyUp(event) { + if (this._shouldIgnoreKeyEvent(event)) { + return; + } if (this.keyHolds[event.code]) { this.keyHolds[event.code].firstPress = -1; } @@ -655,6 +701,9 @@ export default class HudesClient { } processCommonKeys(event) { + if (event.metaKey || event.ctrlKey) { + return; + } const currentTime = Date.now(); // Check cooldown @@ -665,9 +714,9 @@ export default class HudesClient { if (event.type === "keydown") { switch (event.code) { - case "KeyR": + case "KeyZ": // Start a speed run - log('[HudesClient] KeyR pressed: starting speed run'); + log('[HudesClient] KeyZ pressed: starting speed run'); this.startSpeedRun(); break; case "KeyY": diff --git a/web/client/KeyboardClient.js b/web/client/KeyboardClient.js index 6efc2a3..918bd40 100644 --- a/web/client/KeyboardClient.js +++ b/web/client/KeyboardClient.js @@ -49,15 +49,17 @@ export default class KeyboardClient extends HudesClient { 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); + this.state.setBatchSize(this.renderMode === '1d' ? 512 : 256); this.state.setDtype('float16'); this.state.setHelpScreenFns([ @@ -104,6 +106,10 @@ GOOD LUCK! const mapping = keyLower ? this.keyToParamAndSign[keyLower] : undefined; const isHelpActive = this.state.helpScreenIdx !== -1; + if (event.metaKey || event.ctrlKey) { + return this.processCommonKeys(event); + } + if (mapping && !isHelpActive) { if (event.type === 'keydown') { if (!event.repeat) { diff --git a/web/client/KeyboardClientGL.js b/web/client/KeyboardClientGL.js index 2a71e66..a796462 100644 --- a/web/client/KeyboardClientGL.js +++ b/web/client/KeyboardClientGL.js @@ -194,6 +194,9 @@ export default class KeyboardClientGL extends KeyboardClient { } processKeyPress(event) { + if (event.metaKey || event.ctrlKey) { + return this.processCommonKeys(event); + } const currentTime = performance.now(); let redraw = this.processCommonKeys(event); diff --git a/web/client/hud.js b/web/client/hud.js index 60f30f1..932cdc2 100644 --- a/web/client/hud.js +++ b/web/client/hud.js @@ -3,7 +3,7 @@ export const CONTROL_GROUPS = [ { icon: null, keys: ['⬆️', '⬇️', '⬅️', '➡️', '🐁'], label: 'Rotate' }, { icon: null, keys: ['Spacebar'], label: 'New Dims' }, { icon: null, keys: ['Enter'], label: 'New Batch' }, - { icon: null, keys: ['R'], label: 'SPEED RUN' }, + { icon: null, keys: ['Z'], label: 'SPEED RUN' }, { icon: null, keys: ['⇧','🐁'], label: 'Cycle Plane' }, { icon: null, keys: ['[', ']'], label: 'Step ±' }, { icon: null, keys: [';'], label: 'Batch-size' }, diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index e912114..14f7b2b 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -78,6 +78,8 @@ export default class LandscapeView { 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.lossChart = null; @@ -101,6 +103,13 @@ export default class LandscapeView { 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; @@ -137,6 +146,9 @@ export default class LandscapeView { if (this.customCameraDistance != null) { this.camera_distance = this.customCameraDistance; } + if (this.alt1dMode) { + this.camera_distance *= 1.25; + } this.raycaster = new Raycaster(); this.pointer = new Vector2(); @@ -198,22 +210,483 @@ export default class LandscapeView { this.render(); window.addEventListener('resize', this.onWindowResize.bind(this), false); } + } + + _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}`; + }; + + return [ + { icon: null, keys: [], label: buildLabel(positiveLetters, '(+)') }, + { icon: null, keys: [], label: buildLabel(negativeLetters, '(-)') }, + { icon: null, keys: ['Z'], label: 'SPEED RUN' }, + ]; + } + annotateBottomScreen(text, size = 20) { const bottomTextContainer = document.getElementById('bottomTextContainer'); - if (bottomTextContainer) { - bottomTextContainer.innerHTML = formatHudMarkup( - text ?? '', - CONTROL_GROUPS, - ); + if (!bottomTextContainer) { + return; } + const controls = + this.mode === '1d' ? this._build1DControlGroups() : CONTROL_GROUPS; + bottomTextContainer.innerHTML = formatHudMarkup(text ?? '', controls); } 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; } @@ -242,6 +715,9 @@ export default class LandscapeView { if (this.customCameraDistance != null) { distance = this.customCameraDistance; } + if (this.alt1dMode) { + distance *= 1.25; + } this.camera_distance = distance; this.camera_yOffset = (totalWidth / 8); this.camera.position.set(0, this.camera_yOffset, distance); @@ -847,6 +1323,11 @@ export default class LandscapeView { return; } + if (this.alt1dMode) { + this._updateAlt1dLossLines(lines, { stepSpacing, labels }); + return; + } + if (!this.lineGroup) { this.lineGroup = new Group(); this.scene.add(this.lineGroup); @@ -1130,27 +1611,31 @@ export default class LandscapeView { } } - 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(); + 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; } - 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); } - 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) { @@ -1200,6 +1685,27 @@ export default class LandscapeView { } _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]; @@ -1388,5 +1894,4 @@ export default class LandscapeView { } } - } diff --git a/web/style.css b/web/style.css index 0bc6251..26e7b9c 100644 --- a/web/style.css +++ b/web/style.css @@ -51,11 +51,17 @@ body { text-decoration: underline; } -.view-toggle { +.header-controls { position: absolute; right: clamp(12px, 4vw, 28px); top: 50%; transform: translateY(-50%); + display: flex; + align-items: center; + gap: 0.45rem; +} + +.header-controls button { padding: 6px 14px; border-radius: 999px; border: 1px solid rgba(255, 210, 140, 0.42); @@ -68,12 +74,17 @@ body { transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; } -.view-toggle:hover { +.header-controls button:hover { border-color: rgba(255, 225, 160, 0.9); background: rgba(255, 200, 80, 0.2); transform: translateY(-50%) scale(1.05); } +.header-controls .mode-toggle { + font-size: 0.65rem; + letter-spacing: 0.1em; +} + #imageContainer { position: fixed; top: var(--header-height); @@ -412,6 +423,32 @@ 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 */ diff --git a/web/tests/highscores_insert.spec.mjs b/web/tests/highscores_insert.spec.mjs index 492f9c8..288f8c5 100644 --- a/web/tests/highscores_insert.spec.mjs +++ b/web/tests/highscores_insert.spec.mjs @@ -24,10 +24,10 @@ test.describe('Highscores API insertion', () => { // Load app and start a speed run 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/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_scaling.spec.mjs b/web/tests/mode_1d_scaling.spec.mjs index c43926d..a6d02dc 100644 --- a/web/tests/mode_1d_scaling.spec.mjs +++ b/web/tests/mode_1d_scaling.spec.mjs @@ -6,7 +6,9 @@ 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`); + 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, diff --git a/web/tests/speedrun.spec.mjs b/web/tests/speedrun.spec.mjs index 6aad6c2..698d6c1 100644 --- a/web/tests/speedrun.spec.mjs +++ b/web/tests/speedrun.spec.mjs @@ -6,52 +6,55 @@ 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'; -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(`${APP_ORIGIN}/?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' }); }); }); From 6a316a95260858c01de2e9fb236337d719ceb939 Mon Sep 17 00:00:00 2001 From: misko Date: Tue, 4 Nov 2025 16:15:59 -0800 Subject: [PATCH 05/29] fix labels --- web/client/views/LandscapeView.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 14f7b2b..90bdef0 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -663,11 +663,23 @@ export default class LandscapeView { return `${markup}${sign}`; }; - return [ + 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: ['Spacebar'], label: 'New Dims' }, + { 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]; } annotateBottomScreen(text, size = 20) { From 841f29160166fcea6e2c15a37aeeea195bd21253 Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 9 Nov 2025 10:21:05 -0800 Subject: [PATCH 06/29] lint --- design.md | 392 +++++++---------- hudes/hudes.proto | 19 + hudes/hudes_pb2.py | 36 +- hudes/hudes_play.py | 10 +- hudes/websocket_client.py | 123 +++--- hudes/websocket_server.py | 727 ++++++++++++++++++++------------ tests/test_headless_pygame.py | 141 +++++-- tests/test_web.py | 238 +++++++++++ web/app.js | 37 +- web/client/ClientState.js | 6 +- web/client/HudesClient.js | 416 ++++++++++++++++-- web/client/ProtoLoader.js | 8 +- web/style.css | 63 ++- web/tests/mode_toggles.spec.mjs | 82 ++++ web/utils/logger.mjs | 68 ++- 15 files changed, 1666 insertions(+), 700 deletions(-) create mode 100644 web/tests/mode_toggles.spec.mjs 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/hudes.proto b/hudes/hudes.proto index b87f44b..5281c5d 100644 --- a/hudes/hudes.proto +++ b/hudes/hudes.proto @@ -17,6 +17,8 @@ message Config { 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 { @@ -63,6 +65,7 @@ message Control { CONTROL_MESHGRID_RESULTS= 104; CONTROL_SGD_STEP = 105; CONTROL_LOSS_LINE_RESULTS = 108; + CONTROL_RESUME = 109; CONTROL_QUIT = 901; } required Type type = 1; @@ -99,5 +102,21 @@ message Control { 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 d403a44..a78e5a7 100644 --- a/hudes/hudes_pb2.py +++ b/hudes/hudes_pb2.py @@ -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"\xc5\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\x12\x1a\n\x0cmesh_enabled\x18\x08 \x01(\x08:\x04true\x12\x12\n\nloss_lines\x18\t \x01(\x05"\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"\xcb\x08\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\x12\x1a\n\x11loss_line_results\x18\xd8\x01 \x03(\x02\x12\x18\n\x0floss_line_shape\x18\xd9\x01 \x03(\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"\x91\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\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\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"\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"\xb3\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\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"\xa5\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\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 = 261 - _globals["_TRAINLOSSANDPREDS"]._serialized_start = 264 - _globals["_TRAINLOSSANDPREDS"]._serialized_end = 397 - _globals["_VALLOSS"]._serialized_start = 399 - _globals["_VALLOSS"]._serialized_end = 426 - _globals["_BATCHEXAMPLES"]._serialized_start = 429 - _globals["_BATCHEXAMPLES"]._serialized_end = 631 - _globals["_BATCHEXAMPLES_TYPE"]._serialized_start = 613 - _globals["_BATCHEXAMPLES_TYPE"]._serialized_end = 631 - _globals["_CONTROL"]._serialized_start = 634 - _globals["_CONTROL"]._serialized_end = 1733 - _globals["_CONTROL_HIGHSCORE"]._serialized_start = 1242 - _globals["_CONTROL_HIGHSCORE"]._serialized_end = 1329 - _globals["_CONTROL_TYPE"]._serialized_start = 1332 - _globals["_CONTROL_TYPE"]._serialized_end = 1733 + _globals["_CONFIG"]._serialized_end = 323 + _globals["_TRAINLOSSANDPREDS"]._serialized_start = 326 + _globals["_TRAINLOSSANDPREDS"]._serialized_end = 459 + _globals["_VALLOSS"]._serialized_start = 461 + _globals["_VALLOSS"]._serialized_end = 488 + _globals["_BATCHEXAMPLES"]._serialized_start = 491 + _globals["_BATCHEXAMPLES"]._serialized_end = 693 + _globals["_BATCHEXAMPLES_TYPE"]._serialized_start = 675 + _globals["_BATCHEXAMPLES_TYPE"]._serialized_end = 693 + _globals["_CONTROL"]._serialized_start = 696 + _globals["_CONTROL"]._serialized_end = 2155 + _globals["_CONTROL_HIGHSCORE"]._serialized_start = 1394 + _globals["_CONTROL_HIGHSCORE"]._serialized_end = 1481 + _globals["_CONTROL_RESUME"]._serialized_start = 1484 + _globals["_CONTROL_RESUME"]._serialized_end = 1731 + _globals["_CONTROL_RESUME_STATUS"]._serialized_start = 1645 + _globals["_CONTROL_RESUME_STATUS"]._serialized_end = 1731 + _globals["_CONTROL_TYPE"]._serialized_start = 1734 + _globals["_CONTROL_TYPE"]._serialized_end = 2155 # @@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/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 b5f63f2..3f09240 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 @@ -45,9 +46,12 @@ active_clients = {} # Track scheduled timeout tasks per client to finalize Speed Runs active_speedrun_tasks: dict[int, asyncio.Task] = {} +# 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: @@ -72,6 +76,141 @@ def _countdown_kwargs(client) -> dict: return {"speed_run_seconds_remaining": srs} +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 # move to protobuf https://protobuf.dev/getting-started/pythontutorial/ # TODO need to do brew install brew install protobuf @@ -249,6 +388,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 @@ -259,6 +399,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() @@ -288,8 +433,12 @@ 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: @@ -310,8 +459,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, @@ -411,110 +561,104 @@ 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", "loss_line", "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 + if speed_finished: + logging.info( + "Speed run finished for client %s; sending VAL loss %.6f", + client.client_id, + res["val"]["val_loss"], + ) + 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"], ), 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 - if speed_finished: - logging.info( - "Speed run finished for client %s; sending VAL loss %.6f", - client.client_id, - res["val"]["val_loss"], - ) - 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() - ) - 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 client.websocket.send( - 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), - **_countdown_kwargs(client), - ).SerializeToString() - ) - logging.debug( - "inference_result_sender: sent loss line to client : done" + ), + ) + 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"] ) - if train_or_val not in ("train", "val", "mesh", "loss_line", "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 @@ -612,200 +756,221 @@ 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 - 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 - - 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 - ): # 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) - 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, - ) - logging.debug( - "Speed run state: client=%s active=%s end_time=%.3f", - client.client_id, - client.speed_run_active, - client.speed_run_end_time, - ) - # 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 + 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_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_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): 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_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 51991b0..0ee6844 100644 --- a/web/app.js +++ b/web/app.js @@ -58,12 +58,17 @@ function installModeToggle(currentMode) { } container.innerHTML = ''; - const viewButton = document.createElement('button'); - viewButton.type = 'button'; - viewButton.className = 'view-toggle'; + 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 targetMode = currentMode === '1d' ? '3d' : '1d'; - viewButton.textContent = currentMode === '1d' ? 'Switch to 3D view' : 'Switch to 1D view'; - viewButton.addEventListener('click', () => { + 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(); @@ -83,19 +88,23 @@ function installModeToggle(currentMode) { window.location.search = params.toString(); }; - const makeToggle = (label, key) => { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'mode-toggle'; - const enabled = params.has(key); - btn.textContent = `${label}: ${enabled ? 'ON' : 'OFF'}`; - btn.addEventListener('click', () => toggleFlag(key)); - return btn; - }; + 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() { diff --git a/web/client/ClientState.js b/web/client/ClientState.js index 87eaef1..e12392c 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"]; @@ -26,7 +27,7 @@ export default class ClientState { const timer = this.speedRunActive ? ` | SpeedRun: ${secs.toFixed(1)}s` : ''; - return `Best-val-loss: ${this.bestScore.toFixed(3)}, Batch-size: ${this.batchSize}, StepSize: ${this.stepSize.toExponential(3)}, SGD-steps: ${this.sgdSteps} , Dtype: ${this.dtype}${timer}` + return `Best-val-loss: ${this.bestScore.toFixed(3)}, Batch-size: ${this.batchSize}, StepSize: ${this.stepSize.toExponential(3)}, SGD-steps: ${this.sgdSteps}, Eval-steps: ${this.evalSteps}, Dtype: ${this.dtype}${timer}` } updateStepSize() { @@ -36,6 +37,9 @@ export default class ClientState { ); this.stepSize = Math.pow(10, logStepSize); } + setEvalSteps(count) { + this.evalSteps = count; + } increaseStepSize(mag = 1) { this.stepSizeIdx += mag; diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index dc0dc33..6a22654 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -20,17 +20,28 @@ export default class HudesClient { } } 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"; + // 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(); + this._resumeInFlight = false; + this._readyForInput = false; + this._serverAckRequestIdx = 0; + this._protoReadyPromise = this.loadProto(); const renderMode = (options.renderMode || '3d').toLowerCase(); this.debug = Boolean(options.debug); @@ -67,7 +78,7 @@ export default class HudesClient { alt1d: this.alt1d, altKeys: this.altKeys, }); - this.view.initializeCharts(); // Initialize charts + this.view.initializeCharts(); // Initialize charts this.Control = null; this.ControlType = null; this.lossLineLabels = []; @@ -91,15 +102,35 @@ export default class HudesClient { this.trainSteps = []; this.valSteps = []; + this._helpDismissKey = 'hudes.help.dismissed'; + this._helpDismissed = false; + this._patchHelpStateTracking(); + this._initHelpDismissState(); + this.initKeys(); - this.setupSocket(); - this.loadProto(); + this._connectSocket(); } 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) { @@ -116,6 +147,15 @@ export default class HudesClient { window.__hudesMsgCounts[t] = (window.__hudesMsgCounts[t] || 0) + 1; } } 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); @@ -169,6 +209,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: @@ -197,6 +241,7 @@ export default class HudesClient { this.state.updateBestScoreOrNot(this.valLosses[this.valLosses.length - 1]); + this._updateEvalStepsFromMessage(message); // Update the view with training and validation loss history this.view.updateLossChart( @@ -275,6 +320,7 @@ export default class HudesClient { // 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: { @@ -290,6 +336,7 @@ export default class HudesClient { this.view.updateLossLines?.(lossLines, { stepSpacing, labels }); this.view.annotateBottomScreen?.(this.state.toString()); this._updateSpeedRunFromMessage(message); + this._updateEvalStepsFromMessage(message); break; } @@ -580,14 +627,300 @@ export default class HudesClient { // } - setupSocket() { - this.socket.onopen = () => log("[HudesClient] WebSocket connected."); - this.socket.onclose = () => { - log("[HudesClient] WebSocket disconnected."); + _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'; + } + } + } 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; + } + } catch (err) { + log(`[HudesClient] Failed during socket open init: ${err?.message ?? err}`, null, 'error'); + } + })(); + } + + _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; + } + this._scheduleReconnect(); + } + + _scheduleReconnect() { + this._reconnectAttempts += 1; + const delay = Math.min(1000 * this._reconnectAttempts, 5000); + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + } + this._reconnectTimer = setTimeout(() => this._connectSocket(), delay); + } + + _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.socket.onmessage = (event) => this._handleMessage(event); - this.socket.onerror = (error) => log(`[HudesClient] WebSocket error: ${error}`); + this.sendQ(payload); + } + + _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(); + } + + _persistResumeToken(token) { + if (!token || typeof token !== 'string') { + return; + } + this._resumeToken = token; + try { + if (this._sessionStorageAvailable) { + window.sessionStorage?.setItem(this._resumeTokenKey, token); + } + } catch {} + } + + _loadPersistedResumeToken() { + try { + if (this._sessionStorageAvailable) { + return window.sessionStorage?.getItem(this._resumeTokenKey) || null; + } + } catch {} + return null; + } + + _clearResumeToken() { + this._resumeToken = null; + try { + if (this._sessionStorageAvailable) { + window.sessionStorage?.removeItem(this._resumeTokenKey); + } + } catch {} + } + + _patchHelpStateTracking() { + const state = this.state; + if (!state) return; + const originalClose = state.closeHelpScreens?.bind(state); + if (originalClose) { + state.closeHelpScreens = (...args) => { + const result = originalClose(...args); + this._handleHelpStateChanged(); + return result; + }; + } + const originalNext = state.nextHelpScreen?.bind(state); + if (originalNext) { + state.nextHelpScreen = (...args) => { + const prev = state.helpScreenIdx; + const result = originalNext(...args); + if (state.helpScreenIdx !== prev) { + this._handleHelpStateChanged(); + } + return result; + }; + } + } + + _initHelpDismissState() { + if (!this._sessionStorageAvailable) { + return; + } + try { + if (window.sessionStorage?.getItem(this._helpDismissKey) === '1') { + this._helpDismissed = true; + if (typeof this.state.closeHelpScreens === 'function') { + this.state.closeHelpScreens(); + } else { + this.state.helpScreenIdx = -1; + } + } + } catch {} + } + + _handleHelpStateChanged() { + if (this.state?.helpScreenIdx === -1) { + this._markHelpDismissed(); + } + } + + _markHelpDismissed() { + if (this._helpDismissed) { + return; + } + this._helpDismissed = true; + if (this._sessionStorageAvailable) { + try { + window.sessionStorage?.setItem(this._helpDismissKey, '1'); + } catch {} + } + } + + _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; + } + } + } + + _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; + } + + _persistSessionToken(token) { + if (!token || typeof token !== 'string' || !this._sessionStorageAvailable) { + return; + } + try { + window.sessionStorage?.setItem(this._sessionTokenKey, token); + } catch {} + } + + _setSessionToken(token) { + if (!token || typeof token !== 'string') { + return; + } + this._clientSessionToken = token; + this._persistSessionToken(token); + } + + _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)}`; + } + + _canSendUserCommands() { + return ( + this.socket && + this.socket.readyState === WebSocket.OPEN && + !this._resumeInFlight && + this._readyForInput + ); } initKeys() { @@ -839,12 +1172,21 @@ export default class HudesClient { let elapsedTime = 0; // 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; } 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 @@ -912,6 +1254,10 @@ export default class HudesClient { // Client-side guard; server also ignores return; } + if (!this._canSendUserCommands()) { + log("[HudesClient] Ignoring SGD request while socket unavailable", null, 'warn'); + return; + } const payload = { type: this.ControlType.values.CONTROL_SGD_STEP, @@ -928,6 +1274,10 @@ export default class HudesClient { console.error("Proto definitions not loaded. Cannot request next batch."); return; } + if (!this._canSendUserCommands()) { + log("[HudesClient] Ignoring next batch while socket unavailable", null, 'warn'); + return; + } const payload = { type: this.ControlType.values.CONTROL_NEXT_BATCH, @@ -943,6 +1293,10 @@ export default class HudesClient { 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; + } // Create payload const payload = { @@ -966,6 +1320,10 @@ export default class HudesClient { 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; + } this.highScoreSubmitted = false; if (this._srsInterval) { try { clearInterval(this._srsInterval); } catch {} this._srsInterval = null; } this._srsEndAt = null; @@ -988,6 +1346,10 @@ export default class HudesClient { 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 = { @@ -1020,6 +1382,10 @@ export default class HudesClient { 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; + } // Construct the payload const payload = { @@ -1028,7 +1394,6 @@ export default class HudesClient { dim: parseInt(dim, 10), step, })), - requestIdx: this.requestIdx, }; // Debug: Log the payload before sending @@ -1052,9 +1417,6 @@ export default class HudesClient { // Update the UI or internal state to reflect the changes this.dimsAndStepsUpdated(); - // Increment the request index - this.requestIdx++; - } catch (error) { console.error("Error in sendDimsAndSteps:", error); } @@ -1082,11 +1444,14 @@ export default class HudesClient { 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}`); } @@ -1094,10 +1459,7 @@ export default class HudesClient { async runLoop() { - while (!this.Control || !this.ControlType) { - await sleep(100); - } - this.sendConfig(); + await this._protoReadyPromise; while (this.running) { await sleep(10); } 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/style.css b/web/style.css index 26e7b9c..e9f30c4 100644 --- a/web/style.css +++ b/web/style.css @@ -45,46 +45,73 @@ body { transition: color 140ms ease; } -.site-source-link:hover, -.site-source-link:focus-visible { - color: #ffe59c; - text-decoration: underline; +.site-source-link { + transition: color 180ms ease, text-shadow 180ms ease; +} + +.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(12px, 4vw, 28px); + right: clamp(120px, 12vw, 210px); top: 50%; transform: translateY(-50%); display: flex; align-items: center; - gap: 0.45rem; + gap: 0.8rem; } -.header-controls button { - padding: 6px 14px; - border-radius: 999px; - border: 1px solid rgba(255, 210, 140, 0.42); - background: rgba(0, 0, 0, 0.68); +.header-controls button.header-pill { + padding: 0; + border: none; + background: none; color: #ffd666; text-transform: uppercase; letter-spacing: 0.12em; - font-size: 0.7rem; + font-size: 0.68rem; cursor: pointer; - transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; + transition: color 180ms ease, text-shadow 180ms ease; } -.header-controls button:hover { - border-color: rgba(255, 225, 160, 0.9); - background: rgba(255, 200, 80, 0.2); - transform: translateY(-50%) scale(1.05); +.header-controls button.header-pill:focus { + outline: none; } .header-controls .mode-toggle { - font-size: 0.65rem; + font-size: 0.62rem; letter-spacing: 0.1em; } +.header-controls button.header-pill:hover, +.header-controls button.header-pill:focus-visible, +.site-source-link:hover, +.site-source-link: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); 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/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); } From 9fd3d8b021cd358137fd32934617578de874c82a Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 9 Nov 2025 22:06:11 -0800 Subject: [PATCH 07/29] update --- web/client/views/LandscapeView.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 90bdef0..bbfdfb3 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -780,6 +780,13 @@ export default class LandscapeView { // Optional: Initialize Confusion Matrix as an empty Heatmap initializeConfusionMatrixHeatmap() { if (this.confusionMatrixContainer) { + const heatmapConfig = { + displayModeBar: false, + staticPlot: true, + scrollZoom: false, + doubleClick: false, + responsive: true, + }; const initialData = [ { z: [], // Initialize with empty data @@ -804,7 +811,7 @@ export default class LandscapeView { responsive: true, }; - Plotly.newPlot(this.confusionMatrixContainer, initialData, layout); + Plotly.newPlot(this.confusionMatrixContainer, initialData, layout, heatmapConfig); } } @@ -1044,8 +1051,15 @@ export default class LandscapeView { responsive: true, }; + const heatmapConfig = { + displayModeBar: false, + staticPlot: true, + scrollZoom: false, + doubleClick: false, + responsive: true, + }; // Plot or update the heatmap - Plotly.newPlot('confusionMatrixChart', updatedData, updatedLayout); + Plotly.newPlot('confusionMatrixChart', updatedData, updatedLayout, heatmapConfig); //console.log("Confusion matrix heatmap updated successfully."); } catch (error) { console.error("Failed to update confusion matrix heatmap:", error); From d1ab1aac2a3fde276dfa50ab8c8a1a5c48938919 Mon Sep 17 00:00:00 2001 From: misko Date: Tue, 11 Nov 2025 18:40:05 -0800 Subject: [PATCH 08/29] remove cdn --- web/bundle-visualization.html | 2 +- web/client/views/LandscapeView.js | 2 +- web/index.html | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/bundle-visualization.html b/web/bundle-visualization.html index 66c7e5f..6a74063 100644 --- a/web/bundle-visualization.html +++ b/web/bundle-visualization.html @@ -4822,7 +4822,7 @@ - - - diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index 6a22654..e227785 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -38,6 +38,9 @@ export default class HudesClient { 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; @@ -793,6 +796,26 @@ export default class HudesClient { return null; } + _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; + } + } + } catch {} + return false; + } + _clearResumeToken() { this._resumeToken = null; try { diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 5e2ace1..2fb2c77 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -36,7 +36,34 @@ const debugSelection = (message) => { } }; -import Plotly from 'plotly.js-dist-min'; // Local Plotly bundle +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 @@ -88,6 +115,11 @@ export default class LandscapeView { 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.helpImageEl = null; @@ -768,7 +800,7 @@ export default class LandscapeView { - // Initialize all charts (including Plotly-based heatmap) + // Initialize all charts (including confusion matrix heatmap) initializeCharts() { this.initializeLossChart(); this.initializeLastStepsChart(); @@ -779,40 +811,87 @@ export default class LandscapeView { } // Optional: Initialize Confusion Matrix as an empty Heatmap initializeConfusionMatrixHeatmap() { - if (this.confusionMatrixContainer) { - const heatmapConfig = { - displayModeBar: false, - staticPlot: true, - scrollZoom: false, - doubleClick: false, + 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), + }; + this.confusionMatrixChart = new Chart(ctx, { + type: 'matrix', + data: { + datasets: [dataset], + }, + options: { responsive: true, - }; - const initialData = [ - { - z: [], // Initialize with empty data - type: 'heatmap', - colorscale: 'Blues', - zmin: 0, - zmax: 255, + 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}`; + }, + }, + }, }, - ]; - - const layout = { - title: 'Confusion Matrix Heatmap', - xaxis: { - title: 'Predicted Label', - tickmode: 'linear', + layout: { + padding: { + top: 8, + left: 12, + right: 12, + bottom: 32, + }, }, - yaxis: { - title: 'Actual Label', - tickmode: 'linear', - autorange: 'reversed', // Reverse y-axis to match visualization expectations + scales: { + x: { + type: 'category', + labels: CONFUSION_LABELS, + title: { + display: true, + text: 'Predicted Label', + color: '#ffffff', + }, + ticks: { color: '#ffffff' }, + grid: { color: 'rgba(255, 255, 255, 0.1)' }, + }, + y: { + 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)' }, + }, }, - responsive: true, - }; - - Plotly.newPlot(this.confusionMatrixContainer, initialData, layout, heatmapConfig); - } + }, + }); } initializeLossChart() { @@ -987,83 +1066,116 @@ export default class LandscapeView { } // Update Confusion Matrix Heatmap with new data updateConfusionMatrix(confusionMatrix) { - if (!document.getElementById('confusionMatrixChart')) { - console.error("Element with id 'confusionMatrixChart' not found."); + if (!this.confusionMatrixChart) { + this.initializeConfusionMatrixHeatmap(); + } + const chart = this.confusionMatrixChart; + if (!chart) { 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)) { + 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); + } - // 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, - }, - ]; + _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); + } - const updatedLayout = { - title: { - text: 'Confusion Matrix Heatmap', - font: { - color: 'white' - } - }, - xaxis: { - title: { - text: 'Predicted Label', - font: { color: 'white' } + _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)}`, + }, }, - 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' } + 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 } }, }, - 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, - }; - - const heatmapConfig = { - displayModeBar: false, - staticPlot: true, - scrollZoom: false, - doubleClick: false, - responsive: true, - }; - // Plot or update the heatmap - Plotly.newPlot('confusionMatrixChart', updatedData, updatedLayout, heatmapConfig); - //console.log("Confusion matrix heatmap updated successfully."); - } catch (error) { - console.error("Failed to update confusion matrix heatmap:", error); - } + }, + }); } @@ -1751,13 +1863,14 @@ export default class LandscapeView { // Select side container and get the individual example cells const sideContainer = document.getElementById('sideContainer'); + if (!sideContainer) { + return; + } 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) { + const previousImage = exampleCell.querySelector('.example-image'); + if (previousImage && previousImage.parentElement === exampleCell) { exampleCell.removeChild(previousImage); } @@ -1797,11 +1910,14 @@ export default class LandscapeView { // 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) { + if (exampleCells.length <= index) { return; } let chartDiv = exampleCells[index].querySelector('.example-chart'); @@ -1813,36 +1929,23 @@ export default class LandscapeView { 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 }); + 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'); }); } diff --git a/web/index.html b/web/index.html index a54f30a..6eec027 100644 --- a/web/index.html +++ b/web/index.html @@ -24,7 +24,7 @@
- +
diff --git a/web/package.json b/web/package.json index c98e75d..894c1c7 100644 --- a/web/package.json +++ b/web/package.json @@ -31,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/style.css b/web/style.css index e9f30c4..15b2e46 100644 --- a/web/style.css +++ b/web/style.css @@ -359,6 +359,13 @@ body { 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 */ From b97f065d15dcf7070518332a2196da3c6987c1f5 Mon Sep 17 00:00:00 2001 From: misko Date: Tue, 11 Nov 2025 21:04:25 -0800 Subject: [PATCH 10/29] improvements --- .gitignore | 2 + hudes/hudes.proto | 1 + hudes/hudes_pb2.py | 6 +- hudes/websocket_server.py | 41 +++++++++++--- tests/test_speed_run.py | 93 +++++++++++++++++++++++++++++++ web/client/HudesClient.js | 47 +++++++++++++++- web/client/hud.js | 6 +- web/client/views/LandscapeView.js | 5 +- 8 files changed, 181 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index d32b89b..07c553f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ 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/hudes/hudes.proto b/hudes/hudes.proto index 5281c5d..1fe1b46 100644 --- a/hudes/hudes.proto +++ b/hudes/hudes.proto @@ -55,6 +55,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) diff --git a/hudes/hudes_pb2.py b/hudes/hudes_pb2.py index a78e5a7..a641664 100644 --- a/hudes/hudes_pb2.py +++ b/hudes/hudes_pb2.py @@ -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"\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"\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"\xb3\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\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"\xa5\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\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' + 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"\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"\xd1\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\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() @@ -41,7 +41,7 @@ _globals["_BATCHEXAMPLES_TYPE"]._serialized_start = 675 _globals["_BATCHEXAMPLES_TYPE"]._serialized_end = 693 _globals["_CONTROL"]._serialized_start = 696 - _globals["_CONTROL"]._serialized_end = 2155 + _globals["_CONTROL"]._serialized_end = 2185 _globals["_CONTROL_HIGHSCORE"]._serialized_start = 1394 _globals["_CONTROL_HIGHSCORE"]._serialized_end = 1481 _globals["_CONTROL_RESUME"]._serialized_start = 1484 @@ -49,5 +49,5 @@ _globals["_CONTROL_RESUME_STATUS"]._serialized_start = 1645 _globals["_CONTROL_RESUME_STATUS"]._serialized_end = 1731 _globals["_CONTROL_TYPE"]._serialized_start = 1734 - _globals["_CONTROL_TYPE"]._serialized_end = 2155 + _globals["_CONTROL_TYPE"]._serialized_end = 2185 # @@protoc_insertion_point(module_scope) diff --git a/hudes/websocket_server.py b/hudes/websocket_server.py index 3f09240..4ce48d0 100644 --- a/hudes/websocket_server.py +++ b/hudes/websocket_server.py @@ -46,6 +46,18 @@ 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 @@ -711,15 +723,7 @@ async def _timeout_then_eval(): 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() - logging.debug( - "Cancelled previous speed run timeout task for client %s", client_id - ) - except Exception: - pass + _cancel_speedrun_timeout(client_id) task = asyncio.create_task(_timeout_then_eval()) active_speedrun_tasks[client_id] = task @@ -892,6 +896,25 @@ async def process_client(websocket, client_runner_q): _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: 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/web/client/HudesClient.js b/web/client/HudesClient.js index e227785..37407cf 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -97,6 +97,7 @@ export default class HudesClient { 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 @@ -387,6 +388,7 @@ export default class HudesClient { 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; @@ -408,6 +410,7 @@ export default class HudesClient { // 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) { @@ -1071,9 +1074,13 @@ export default class HudesClient { if (event.type === "keydown") { switch (event.code) { case "KeyZ": - // Start a speed run - log('[HudesClient] KeyZ pressed: starting speed run'); - this.startSpeedRun(); + 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 @@ -1347,6 +1354,10 @@ export default class HudesClient { log("[HudesClient] Cannot start Speed Run while disconnected", null, 'warn'); return; } + if (this.speedRunActive || this._speedRunRequestPending) { + this.cancelSpeedRun(); + return; + } this.highScoreSubmitted = false; if (this._srsInterval) { try { clearInterval(this._srsInterval); } catch {} this._srsInterval = null; } this._srsEndAt = null; @@ -1354,6 +1365,7 @@ export default class HudesClient { this.state.speedRunActive = true; this.state.speedRunSecondsRemaining = 0; this.state.bestScore = Infinity; + this._speedRunRequestPending = true; // Do not change mesh grid configuration during Speed Run. const payload = { type: this.ControlType.values.CONTROL_SPEED_RUN_START, @@ -1363,6 +1375,35 @@ export default class HudesClient { // No fallback timer; countdown will start on first server reply with speedRunSecondsRemaining } + 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; + } + 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; + 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."); diff --git a/web/client/hud.js b/web/client/hud.js index 932cdc2..05fdb67 100644 --- a/web/client/hud.js +++ b/web/client/hud.js @@ -1,16 +1,16 @@ 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' }, + { icon: null, keys: ['SPACE'], label: 'New Dims' }, { icon: null, keys: ['Enter'], label: 'New Batch' }, - { icon: null, keys: ['Z'], 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 HUD_TITLE = '🧠 Human Descent: MNIST'; diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 2fb2c77..87b425a 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -701,9 +701,10 @@ export default class LandscapeView { ]; const sharedControls = [ - { icon: null, keys: ['Z'], label: 'SPEED RUN' }, + { icon: null, keys: ['Z'], label: 'SPEED RUN 🔥' }, { icon: null, keys: ['Enter'], label: 'New Batch' }, - { icon: null, keys: ['Spacebar'], label: 'New Dims' }, + { 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' }, From 7b5df3691d9d9be2210f83252e4788844a3e165d Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 16 Nov 2025 13:05:55 -0800 Subject: [PATCH 11/29] updates --- web/client/KeyboardClientGL.js | 15 +++++++-- web/playwright.config.ts | 11 ++++-- web/style.css | 11 ++++-- web/tests/layout_overflow.spec.mjs | 46 +++++++++++++++++++++++++ web/tests/mode_3d_controls.spec.mjs | 52 +++++++++++++++++++++++++++++ web/tests/modes.spec.mjs | 10 +++--- web/tests/speedrun.spec.mjs | 2 +- 7 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 web/tests/layout_overflow.spec.mjs diff --git a/web/client/KeyboardClientGL.js b/web/client/KeyboardClientGL.js index a796462..a73bf27 100644 --- a/web/client/KeyboardClientGL.js +++ b/web/client/KeyboardClientGL.js @@ -36,7 +36,8 @@ export default class KeyboardClientGL extends KeyboardClient { 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.view.resetAngle(); // Set initial angles @@ -52,7 +53,8 @@ export default class KeyboardClientGL extends KeyboardClient { 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( @@ -297,6 +299,15 @@ 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; + } + updateKeyHolds() { // Handle key up events here if needed } diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 6e4d6e0..47b279a 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,5 +1,8 @@ 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, @@ -21,10 +24,14 @@ export default defineConfig({ { // 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/style.css b/web/style.css index 15b2e46..7927873 100644 --- a/web/style.css +++ b/web/style.css @@ -10,6 +10,13 @@ body { color: #fff; /* Optional: Set the text color to white for better contrast */ } +html, +body { + width: 100%; + height: 100%; + overflow: hidden; /* Prevent browser scrollbars; all scrollable panes are explicit */ +} + .site-header { position: fixed; top: 0; @@ -294,7 +301,7 @@ body { 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)); + 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 */ } @@ -304,7 +311,7 @@ body { 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 */ + 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 */ 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/mode_3d_controls.spec.mjs b/web/tests/mode_3d_controls.spec.mjs index 6788606..70926c4 100644 --- a/web/tests/mode_3d_controls.spec.mjs +++ b/web/tests/mode_3d_controls.spec.mjs @@ -46,4 +46,56 @@ test.describe('3D keyboard controls', () => { 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/modes.spec.mjs b/web/tests/modes.spec.mjs index a500fa6..f5cbf88 100644 --- a/web/tests/modes.spec.mjs +++ b/web/tests/modes.spec.mjs @@ -34,15 +34,13 @@ test.describe('Render modes', () => { expect(errors).toHaveLength(0); }); - test('1D slices load without console errors', async ({ page }) => { + 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.waitForSelector('.oned-chart-card', { timeout: 10000 }); - await page.waitForTimeout(1000); - - const chartCount = await page.locator('.oned-chart-card').count(); - expect(chartCount).toBeGreaterThanOrEqual(6); + 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 698d6c1..9a49104 100644 --- a/web/tests/speedrun.spec.mjs +++ b/web/tests/speedrun.spec.mjs @@ -3,7 +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 SPEED_SECONDS = Number(process.env.HUDES_SPEED_RUN_SECONDS ?? '120'); const APP_ORIGIN = process.env.HUDES_APP_ORIGIN || 'http://localhost:6173'; async function runSpeedRunScenario(page, { extraQuery = '', name = 'TEST' } = {}) { From 8e73fe7df5cfe6a62735bac5c282c184409e9a3e Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 16 Nov 2025 15:43:59 -0800 Subject: [PATCH 12/29] lint --- web/app.js | 64 +++++---- web/client/HudesClient.js | 66 +++++++++ web/client/KeyboardClient.js | 4 +- web/client/KeyboardClientGL.js | 228 +++++++++++++++++++++++++++++- web/client/hud.js | 40 +++++- web/client/touchControls.js | 126 +++++++++++++++++ web/client/views/LandscapeView.js | 108 ++++++++++++-- web/index.html | 12 +- web/mobile.js | 38 +++++ web/style.css | 212 +++++++++++++++++++++++---- web/tests/mobile_mode.spec.mjs | 98 +++++++++++++ 11 files changed, 909 insertions(+), 87 deletions(-) create mode 100644 web/client/touchControls.js create mode 100644 web/mobile.js create mode 100644 web/tests/mobile_mode.spec.mjs diff --git a/web/app.js b/web/app.js index 0ee6844..5d2c17c 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,6 @@ 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); @@ -67,15 +68,18 @@ function installModeToggle(currentMode) { return btn; }; - 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); + 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') { + if (currentMode === '1d' && !isMobile) { const params = new URLSearchParams(window.location.search); const toggleFlag = (key) => { const current = params.get(key); @@ -110,8 +114,10 @@ function installModeToggle(currentMode) { (async function bootstrap() { const { host, port } = await detectBackendHostPort(); const params = new URLSearchParams(window.location.search); + const isMobile = detectMobileMode(params); + setMobileFlag(isMobile); const modeParam = (params.get('mode') || '').toLowerCase(); - const renderMode = modeParam === '1d' ? '1d' : '3d'; + 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 = (() => { @@ -142,25 +148,27 @@ function installModeToggle(currentMode) { 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, - }) - : new KeyboardClientGL(host, port, { - renderMode: '3d', - debug: debugEnabled, - gridSize, - rowSpacing, - depthStep, - cameraDistance, - }); + ? 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) { diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index 37407cf..1d2302c 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -47,6 +47,7 @@ export default class HudesClient { 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.meshEnabled = this.renderMode !== '1d'; @@ -80,6 +81,7 @@ export default class HudesClient { cameraDistance: this.cameraDistance, alt1d: this.alt1d, altKeys: this.altKeys, + mobile: this.isMobile, }); this.view.initializeCharts(); // Initialize charts this.Control = null; @@ -111,10 +113,74 @@ export default class HudesClient { this._patchHelpStateTracking(); this._initHelpDismissState(); + this._handleHudAction = this._handleHudAction.bind(this); this.initKeys(); + this._bindHudActions(); this._connectSocket(); } + _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(); + 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(); + } + break; + case 'step-minus': + event.preventDefault(); + if (typeof this.state?.decreaseStepSize === 'function') { + this.state.decreaseStepSize(this.stepSizeMultiplier || 1); + try { this.sendConfig(); } catch {} + refreshHud(); + } + break; + case 'toggle-fp': + event.preventDefault(); + if (typeof this.state?.toggleDtype === 'function') { + this.state.toggleDtype(); + try { this.sendConfig(); } catch {} + refreshHud(); + } + break; + case 'show-top': + event.preventDefault(); + try { this.requestLeaderboard?.(); } catch {} + break; + case 'toggle-help': + event.preventDefault(); + if (typeof this._cycleHelpScreens === 'function') { + this._cycleHelpScreens(); + refreshHud(); + } + break; + default: + } + } + async loadProto() { const { Control, ControlType, root } = await loadProto('hudes.proto'); this.Control = Control; diff --git a/web/client/KeyboardClient.js b/web/client/KeyboardClient.js index 918bd40..85eced8 100644 --- a/web/client/KeyboardClient.js +++ b/web/client/KeyboardClient.js @@ -59,7 +59,9 @@ export default class KeyboardClient extends HudesClient { this.keyToParamAndSign[downKey] = { dim: index, sign: -firstKeySign }; }); - this.state.setBatchSize(this.renderMode === '1d' ? 512 : 256); + const defaultBatchSize = + this.renderMode === '1d' ? 512 : this.isMobile ? 32 : 256; + this.state.setBatchSize(defaultBatchSize); this.state.setDtype('float16'); this.state.setHelpScreenFns([ diff --git a/web/client/KeyboardClientGL.js b/web/client/KeyboardClientGL.js index a73bf27..2833871 100644 --- a/web/client/KeyboardClientGL.js +++ b/web/client/KeyboardClientGL.js @@ -1,6 +1,7 @@ import KeyboardClient from './KeyboardClient.js'; import { log } from '../utils/logger.js'; import { installMouseControls, computeStepVector } from './mouseControls.js'; +import { installTouchControls } from './touchControls.js'; const DEBUG_MOUSE = false; @@ -12,7 +13,12 @@ const debugMouse = (message) => { export default class KeyboardClientGL extends KeyboardClient { constructor(addr, port, options = {}) { - super(addr, port, { ...options, renderMode: '3d' }); + 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) { @@ -39,15 +45,25 @@ export default class KeyboardClientGL extends KeyboardClient { 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.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); + } 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; @@ -111,8 +127,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'); } @@ -308,7 +325,204 @@ export default class KeyboardClientGL extends KeyboardClient { return (2 * maxAngleV) / dragSpan; } + _applyTouchVector(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); + } + + _installTouchRotationControls(canvas) { + if (!canvas || typeof window === 'undefined') { + return; + } + if (this._touchRotateCleanup) { + try { + this._touchRotateCleanup(); + } catch {} + this._touchRotateCleanup = null; + } + 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); + }; + } + + _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); + } + } + 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('batch', () => { + this.state.toggleBatchSize(); + this.sendConfig(); + this._updateMobileUi(); + }); + + addButton('sgd', () => { + this.getSGDStep(); + }); + + const matrixContainer = document.getElementById('confusionMatrixChart'); + if (matrixContainer?.parentElement) { + matrixContainer.parentElement.appendChild(panel); + } else { + document.body.appendChild(panel); + } + this._mobileUi = { panel, buttons }; + this._updateMobileUi(); + this._mobileUiTicker = setInterval(() => this._updateMobileUi(), 800); + } + + _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; + }; + setText( + buttons.speed, + this.state.speedRunActive + ? `🔥 ${Number(this.state.speedRunSecondsRemaining ?? 0).toFixed(1)}s` + : 'SPEED 🔥' + ); + setText(buttons.batch, `Batch ${this.state.batchSize ?? ''}`); + setText(buttons.sgd, 'SGD'); + } + } diff --git a/web/client/hud.js b/web/client/hud.js index 05fdb67..255a06c 100644 --- a/web/client/hud.js +++ b/web/client/hud.js @@ -1,6 +1,4 @@ -export const CONTROL_GROUPS = [ - { icon: null, keys: ['W', 'A', 'S', 'D', 'Scroll'], label: 'Move' }, - { icon: null, keys: ['⬆️', '⬇️', '⬅️', '➡️', '🐁'], label: 'Rotate' }, +const SHARED_COMMANDS = [ { icon: null, keys: ['SPACE'], label: 'New Dims' }, { icon: null, keys: ['Enter'], label: 'New Batch' }, { icon: null, keys: ['Del'], label: 'SGD Step' }, @@ -13,6 +11,21 @@ export const CONTROL_GROUPS = [ { icon: null, keys: ['X'], label: 'Help' }, ]; +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: 'step-minus', label: 'Step -' }, + { action: 'step-plus', label: 'Step +' }, + { action: 'toggle-fp', label: 'FP' }, + { action: 'show-top', label: 'Top 10' }, +]; + export const HUD_TITLE = '🧠 Human Descent: MNIST'; export const escapeHtml = (value) => { @@ -47,6 +60,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 +88,25 @@ export const formatHudMarkup = ( const safeTitle = escapeHtml(options.title ?? ''); const separator = ''; const controlsMarkup = controlGroups.map(formatControlGroup).join(separator); + const buttonMarkup = formatHudButtons(options.buttons); const statusMarkup = safeStatus ? `
${safeStatus}
` : ''; const titleMarkup = 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 index 87b425a..32fc0e8 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -25,7 +25,7 @@ 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 } from '../hud.js'; +import { CONTROL_GROUPS, formatHudMarkup, MOBILE_CONTROL_GROUPS, MOBILE_HUD_BUTTONS } from '../hud.js'; import { log } from '../../utils/logger.js'; // Import your logging utility const DEBUG_SELECTION = false; @@ -108,6 +108,12 @@ export default class LandscapeView { this.alt1dMode = this.mode === '1d' && Boolean(options.alt1d); this.altKeysMode = Boolean(options.altKeys); + this.mobileMode = Boolean(options.mobile); + if (this.mobileMode && this.numGrids > 1) { + this.numGrids = 1; + this.effectiveGrids = 1; + } + this.state=clientState; this.lossChart = null; this.lastStepsChart = null; @@ -155,6 +161,7 @@ export default class LandscapeView { 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 @@ -181,13 +188,19 @@ export default class LandscapeView { 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 = (totalWidth / 8); + 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); @@ -244,6 +257,22 @@ export default class LandscapeView { } } + _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; @@ -715,14 +744,37 @@ export default class LandscapeView { 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; } - const controls = - this.mode === '1d' ? this._build1DControlGroups() : CONTROL_GROUPS; - bottomTextContainer.innerHTML = formatHudMarkup(text ?? '', controls); + 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 options = hudButtons.length ? { buttons: hudButtons } : {}; + bottomTextContainer.innerHTML = formatHudMarkup(text ?? '', controls, options); } highlightLossLine(index, durationMs = this.glowDuration) { if (this.mode !== '1d') { @@ -752,6 +804,9 @@ export default class LandscapeView { } // 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; @@ -763,8 +818,11 @@ export default class LandscapeView { if (this.alt1dMode) { distance *= 1.25; } + if (this.mobileMode) { + distance *= this.mobileCameraScalar; + } this.camera_distance = distance; - this.camera_yOffset = (totalWidth / 8); + 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 @@ -897,17 +955,19 @@ export default class LandscapeView { 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 Loss', data: [], borderColor: 'blue', fill: false }, - { label: 'Validation Loss', data: [], borderColor: 'red', fill: false }, + { label: 'Train', data: [], borderColor: 'blue', fill: false }, + { label: 'Val', data: [], borderColor: 'red', fill: false }, ], }, options: { responsive: true, + maintainAspectRatio: false, plugins: { legend: { display: true, @@ -924,16 +984,24 @@ export default class LandscapeView { }, }, elements: { + line: { + borderWidth: lineWidth, + }, point: { - radius: 3, + radius: 0, + hoverRadius: 0, + hitRadius: 4, }, }, scales: { x: { title: { - display: true, + display: !this.mobileMode, text: 'Step', }, + ticks: { + display: !this.mobileMode, + }, }, y: { title: { @@ -949,15 +1017,17 @@ export default class LandscapeView { // Initialize Last Steps Chart initializeLastStepsChart() { - if (document.getElementById('lastStepsChart')) { + 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: 'Loss (Last Steps)', data: [], borderColor: 'green', fill: false }], + datasets: [{ label: 'Train (recent)', data: [], borderColor: 'green', fill: false }], }, options: { responsive: true, + maintainAspectRatio: false, plugins: { legend: { display: true, @@ -974,16 +1044,24 @@ export default class LandscapeView { }, }, elements: { + line: { + borderWidth: lineWidth, + }, point: { - radius: 3, + radius: 0, + hoverRadius: 0, + hitRadius: 4, }, }, scales: { x: { title: { - display: true, + display: !this.mobileMode, text: 'Step', }, + ticks: { + display: !this.mobileMode, + }, }, y: { title: { @@ -1042,7 +1120,7 @@ export default class LandscapeView { // Update Last Steps Chart updateLastStepsChart(lastSteps, losses) { - if (this.lastStepsChart) { + if (!this.mobileMode && this.lastStepsChart) { this.lastStepsChart.data.labels = lastSteps; this.lastStepsChart.data.datasets[0].data = losses; this.lastStepsChart.update(); diff --git a/web/index.html b/web/index.html index 6eec027..3f58a40 100644 --- a/web/index.html +++ b/web/index.html @@ -12,15 +12,10 @@
@@ -34,6 +29,7 @@
+
Confusion Matrix
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/style.css b/web/style.css index 7927873..96119f0 100644 --- a/web/style.css +++ b/web/style.css @@ -10,6 +10,14 @@ body { 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; +} + html, body { width: 100%; @@ -38,22 +46,14 @@ body { image-rendering: auto; } -.site-source-link { +.header-left-actions { position: absolute; left: clamp(10px, 3vw, 28px); top: 50%; transform: translateY(-50%); - font-size: 0.7rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #ffd666; - text-decoration: none; - font-weight: 600; - transition: color 140ms ease; -} - -.site-source-link { - transition: color 180ms ease, text-shadow 180ms ease; + display: flex; + align-items: center; + gap: 0.4rem; } .connection-status { @@ -77,7 +77,8 @@ body { gap: 0.8rem; } -.header-controls button.header-pill { +.header-controls button.header-pill, +.header-left-actions .header-pill { padding: 0; border: none; background: none; @@ -89,7 +90,8 @@ body { transition: color 180ms ease, text-shadow 180ms ease; } -.header-controls button.header-pill:focus { +.header-controls button.header-pill:focus, +.header-left-actions .header-pill:focus { outline: none; } @@ -100,8 +102,8 @@ body { .header-controls button.header-pill:hover, .header-controls button.header-pill:focus-visible, -.site-source-link:hover, -.site-source-link: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); @@ -326,14 +328,15 @@ body { 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 */ + width: 100%; + height: 25vh; + margin-left: 0; + z-index: 2; + background: transparent; 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 */ + box-sizing: border-box; + gap: 10px; + padding: 0 12px 0 0; } .plot { @@ -341,6 +344,30 @@ body { 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 { @@ -437,7 +464,9 @@ 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%; } #bottomTextContainer .hud-controls { @@ -451,6 +480,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; @@ -563,3 +620,110 @@ body { display: none; } } + +#mobileActionPanel { + position: fixed; + top: 50%; + right: clamp(6px, 3vw, 28px); + transform: translateY(-50%); + width: auto; + padding: 0; + border: none; + border-radius: 12px; + background: transparent; + box-shadow: none; + display: none; + flex-direction: column; + gap: 0.45rem; + z-index: 600; + pointer-events: auto; +} + +body.hudes-mobile #mobileActionPanel { + display: flex; +} + +body.hudes-mobile .container { + justify-content: flex-end; + align-items: flex-start; + padding-right: 2px; + padding-left: 0; + gap: 0; +} + +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: flex; + flex-direction: column; + align-items: flex-end; + gap: 35px; + padding: 0; + background: transparent; +} + +#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: auto; + white-space: nowrap; + text-align: center; + min-width: 92px; +} + +#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); +} diff --git a/web/tests/mobile_mode.spec.mjs b/web/tests/mobile_mode.spec.mjs new file mode 100644 index 0000000..4a514ea --- /dev/null +++ b/web/tests/mobile_mode.spec.mjs @@ -0,0 +1,98 @@ +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 3D mode', () => { + test('mobile flag enables single grid and analog touch', async ({ page }) => { + await page.goto(`${APP_ORIGIN}/?mode=3d&mobile=1&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`); + + await page.waitForFunction( + () => window.__hudesClient && window.__hudesClient.ControlType, + { timeout: 10000 }, + ); + + const mobileFlag = await page.evaluate(() => Boolean(window.__hudesIsMobile)); + expect(mobileFlag).toBeTruthy(); + + await page.waitForFunction( + () => window.__hudesClient?.view?.impl?.mobileMode === true, + { timeout: 10000 }, + ); + + const gridCount = await page.evaluate( + () => window.__hudesClient?.view?.impl?.numGrids ?? -1, + ); + expect(gridCount).toBe(1); + + const hudContainer = page.locator('#bottomTextContainer'); + await expect(hudContainer.locator('[data-hud-action="step-plus"]')).toBeVisible(); + await expect(hudContainer.locator('[data-hud-action="step-minus"]')).toBeVisible(); + await expect(hudContainer.locator('[data-hud-action="toggle-fp"]')).toBeVisible(); + const hudText = await hudContainer.innerText(); + expect(/WASD/i.test(hudText)).toBeFalsy(); + + await expect(page.locator('#sideContainer')).toBeHidden(); + + const batchButton = page.locator('#mobileActionPanel button[data-mobile-action="batch"]'); + await expect(batchButton).toBeVisible(); + const initialLabel = await batchButton.innerText(); + await batchButton.click(); + await page.waitForFunction((label) => { + const btn = document.querySelector('#mobileActionPanel button[data-mobile-action="batch"]'); + return btn && btn.innerText !== label; + }, initialLabel, { timeout: 5000 }).catch(() => {}); + + const dimsBtn = page.locator('[data-hud-action="next-dims"]'); + const batchBtn = page.locator('[data-hud-action="next-batch"]'); + await expect(dimsBtn).toBeVisible(); + await expect(batchBtn).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 batchBtn.click(); + await page.waitForFunction(() => (window.__batchCalls || 0) > 0, { timeout: 5000 }); + + const matrixBox = await page.locator('#confusionMatrixChart').boundingBox(); + const panelBox = await page.locator('#mobileActionPanel').boundingBox(); + expect(matrixBox).not.toBeNull(); + expect(panelBox).not.toBeNull(); + if (matrixBox && panelBox) { + expect(panelBox.y).toBeGreaterThanOrEqual(matrixBox.y + matrixBox.height - 2); + } + + 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 }); + }); +}); From 2d2aec523c05b5ebd72d0f1c70d826dc7056f6b8 Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 16 Nov 2025 16:15:34 -0800 Subject: [PATCH 13/29] update --- web/client/KeyboardClientGL.js | 33 ++++++++++--- web/client/views/LandscapeView.js | 16 ++++-- web/index.html | 2 +- web/style.css | 18 ++++--- web/tests/mobile_mode.spec.mjs | 82 +++++++++++++++++-------------- 5 files changed, 96 insertions(+), 55 deletions(-) diff --git a/web/client/KeyboardClientGL.js b/web/client/KeyboardClientGL.js index 2833871..ff34d78 100644 --- a/web/client/KeyboardClientGL.js +++ b/web/client/KeyboardClientGL.js @@ -47,6 +47,8 @@ export default class KeyboardClientGL extends KeyboardClient { this._mouseControls = null; this._touchControls = null; this._touchRotateCleanup = null; + this._boundTouchRotatePageHide = null; + this._touchRotateCleanup = null; this.view.resetAngle(); // Set initial angles @@ -59,6 +61,7 @@ export default class KeyboardClientGL extends KeyboardClient { this.__applyTouchVector = (vector) => this._applyTouchVector(vector); this._installMobileUi(); this._installTouchRotationControls(canvas); + this._installTouchRotationControls(canvas); } else { debugMouse('[KeyboardClientGL] installing mouse controls'); this._mouseControls = installMouseControls(canvas, { @@ -350,12 +353,7 @@ export default class KeyboardClientGL extends KeyboardClient { if (!canvas || typeof window === 'undefined') { return; } - if (this._touchRotateCleanup) { - try { - this._touchRotateCleanup(); - } catch {} - this._touchRotateCleanup = null; - } + this._cleanupTouchRotation(); const state = { active: false, prevX: 0, prevY: 0 }; const getMidpoint = (touchList) => { if (!touchList || touchList.length < 2) { @@ -419,6 +417,10 @@ export default class KeyboardClientGL extends KeyboardClient { 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) { @@ -436,6 +438,22 @@ export default class KeyboardClientGL extends KeyboardClient { } } + _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 } @@ -523,6 +541,9 @@ export default class KeyboardClientGL extends KeyboardClient { ); setText(buttons.batch, `Batch ${this.state.batchSize ?? ''}`); setText(buttons.sgd, 'SGD'); + if (buttons.sgd) { + buttons.sgd.disabled = Boolean(this.state.speedRunActive); + } } } diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 32fc0e8..31eb042 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -2037,15 +2037,17 @@ export default class LandscapeView { if (!this.helpOverlay) { const overlay = document.createElement('div'); overlay.className = 'help-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-label', 'Help screens'); 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(); + const dismissHelp = (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); if (this.state) { if (typeof this.state.closeHelpScreens === 'function') { this.state.closeHelpScreens(); @@ -2054,11 +2056,17 @@ export default class LandscapeView { } } this.hideImage(); - }); + }; + closeBtn.addEventListener('click', dismissHelp); + overlay.addEventListener('click', dismissHelp); const img = document.createElement('img'); img.className = 'help-overlay__image'; img.alt = 'Help screen'; + img.addEventListener('click', (event) => { + event?.stopPropagation?.(); + dismissHelp(event); + }); overlay.appendChild(closeBtn); overlay.appendChild(img); diff --git a/web/index.html b/web/index.html index 3f58a40..3f30d87 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - + Human Descent diff --git a/web/style.css b/web/style.css index 96119f0..e97020d 100644 --- a/web/style.css +++ b/web/style.css @@ -622,6 +622,10 @@ body { } #mobileActionPanel { + display: none; +} + +body.hudes-mobile #mobileActionPanel { position: fixed; top: 50%; right: clamp(6px, 3vw, 28px); @@ -632,17 +636,13 @@ body { border-radius: 12px; background: transparent; box-shadow: none; - display: none; + display: flex; flex-direction: column; - gap: 0.45rem; + gap: 0; z-index: 600; pointer-events: auto; } -body.hudes-mobile #mobileActionPanel { - display: flex; -} - body.hudes-mobile .container { justify-content: flex-end; align-items: flex-start; @@ -690,6 +690,12 @@ body.hudes-mobile #plot3 #confusionMatrixChart { min-width: 92px; } +#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); } diff --git a/web/tests/mobile_mode.spec.mjs b/web/tests/mobile_mode.spec.mjs index 4a514ea..d0bd474 100644 --- a/web/tests/mobile_mode.spec.mjs +++ b/web/tests/mobile_mode.spec.mjs @@ -4,50 +4,57 @@ 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 3D mode', () => { - test('mobile flag enables single grid and analog touch', async ({ page }) => { - await page.goto(`${APP_ORIGIN}/?mode=3d&mobile=1&host=${SERVER_HOST}&port=${SERVER_PORT}&help=off`); +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 }, + ); +} - await page.waitForFunction( - () => window.__hudesClient && window.__hudesClient.ControlType, - { timeout: 10000 }, - ); +test.describe('Mobile 3D mode', () => { + test('renders single grid and mobile HUD controls', async ({ page }) => { + await bootstrapMobile(page); const mobileFlag = await page.evaluate(() => Boolean(window.__hudesIsMobile)); expect(mobileFlag).toBeTruthy(); - await page.waitForFunction( - () => window.__hudesClient?.view?.impl?.mobileMode === true, - { timeout: 10000 }, - ); - const gridCount = await page.evaluate( () => window.__hudesClient?.view?.impl?.numGrids ?? -1, ); expect(gridCount).toBe(1); - const hudContainer = page.locator('#bottomTextContainer'); - await expect(hudContainer.locator('[data-hud-action="step-plus"]')).toBeVisible(); - await expect(hudContainer.locator('[data-hud-action="step-minus"]')).toBeVisible(); - await expect(hudContainer.locator('[data-hud-action="toggle-fp"]')).toBeVisible(); - const hudText = await hudContainer.innerText(); + await expect(page.locator('#sideContainer')).toBeHidden(); + + const hud = page.locator('#bottomTextContainer'); + await expect(hud.locator('[data-hud-action="step-plus"]')).toBeVisible(); + await expect(hud.locator('[data-hud-action="step-minus"]')).toBeVisible(); + await expect(hud.locator('[data-hud-action="toggle-fp"]')).toBeVisible(); + const hudText = await hud.innerText(); expect(/WASD/i.test(hudText)).toBeFalsy(); - await expect(page.locator('#sideContainer')).toBeHidden(); + const matrixBox = await page.locator('#confusionMatrixChart').boundingBox(); + const panelBox = await page.locator('#mobileActionPanel').boundingBox(); + expect(matrixBox).not.toBeNull(); + expect(panelBox).not.toBeNull(); + if (matrixBox && panelBox) { + expect(panelBox.y).toBeGreaterThanOrEqual(matrixBox.y + matrixBox.height - 2); + } + }); - const batchButton = page.locator('#mobileActionPanel button[data-mobile-action="batch"]'); - await expect(batchButton).toBeVisible(); - const initialLabel = await batchButton.innerText(); - await batchButton.click(); - await page.waitForFunction((label) => { - const btn = document.querySelector('#mobileActionPanel button[data-mobile-action="batch"]'); - return btn && btn.innerText !== label; - }, initialLabel, { timeout: 5000 }).catch(() => {}); + test('mobile controls dispatch actions and analog steps', async ({ page }) => { + await bootstrapMobile(page); const dimsBtn = page.locator('[data-hud-action="next-dims"]'); - const batchBtn = page.locator('[data-hud-action="next-batch"]'); + const batchBtnHud = page.locator('[data-hud-action="next-batch"]'); await expect(dimsBtn).toBeVisible(); - await expect(batchBtn).toBeVisible(); + await expect(batchBtnHud).toBeVisible(); await page.evaluate(() => { const client = window.__hudesClient; @@ -70,25 +77,24 @@ test.describe('Mobile 3D mode', () => { await dimsBtn.click(); await page.waitForFunction(() => (window.__dimsCalls || 0) > 0, { timeout: 5000 }); - await batchBtn.click(); + await batchBtnHud.click(); await page.waitForFunction(() => (window.__batchCalls || 0) > 0, { timeout: 5000 }); - const matrixBox = await page.locator('#confusionMatrixChart').boundingBox(); - const panelBox = await page.locator('#mobileActionPanel').boundingBox(); - expect(matrixBox).not.toBeNull(); - expect(panelBox).not.toBeNull(); - if (matrixBox && panelBox) { - expect(panelBox.y).toBeGreaterThanOrEqual(matrixBox.y + matrixBox.height - 2); - } + const batchPanelButton = page.locator('#mobileActionPanel button[data-mobile-action="batch"]'); + 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; From 2d2b5ee6fd9189dcf3a9a84cafed70ef5b27f19c Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 16 Nov 2025 16:18:06 -0800 Subject: [PATCH 14/29] update --- web/client/views/LandscapeView.js | 36 ++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 31eb042..27dd4dd 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -2040,6 +2040,31 @@ export default class LandscapeView { overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-label', 'Help screens'); + const advanceHelp = (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + 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 nextImage = this.state.helpScreenFns?.[this.state.helpScreenIdx]; + if (nextImage) { + this.showImage(nextImage); + } else { + this.hideImage(); + } + }; + const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'help-overlay__close'; @@ -2058,16 +2083,21 @@ export default class LandscapeView { this.hideImage(); }; closeBtn.addEventListener('click', dismissHelp); - overlay.addEventListener('click', dismissHelp); + overlay.addEventListener('click', (event) => { + if (event.target.closest('.help-overlay__close')) { + return; + } + advanceHelp(event); + }); const img = document.createElement('img'); img.className = 'help-overlay__image'; img.alt = 'Help screen'; img.addEventListener('click', (event) => { + event?.preventDefault?.(); event?.stopPropagation?.(); - dismissHelp(event); + advanceHelp(event); }); - overlay.appendChild(closeBtn); overlay.appendChild(img); container.appendChild(overlay); From 1306aa96873f6270b54fe7da27f6e37a7ed706ec Mon Sep 17 00:00:00 2001 From: misko Date: Sun, 16 Nov 2025 20:39:10 -0800 Subject: [PATCH 15/29] add hideHUD --- web/client/HudesClient.js | 7 +++++++ web/client/views/LandscapeView.js | 7 +++++++ web/style.css | 4 ++++ web/tests/hud_toggle.spec.mjs | 25 +++++++++++++++++++++++++ 4 files changed, 43 insertions(+) create mode 100644 web/tests/hud_toggle.spec.mjs diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index 1d2302c..9f0b7b0 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -14,9 +14,11 @@ export default class HudesClient { 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 {} @@ -50,6 +52,7 @@ export default class HudesClient { 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); @@ -82,7 +85,11 @@ export default class HudesClient { alt1d: this.alt1d, altKeys: this.altKeys, mobile: this.isMobile, + hideHud: this.hideHud, }); + if (this.hideHud && typeof document !== 'undefined') { + document.body.classList.add('hudes-hide-hud'); + } this.view.initializeCharts(); // Initialize charts this.Control = null; this.ControlType = null; diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 27dd4dd..78e6b16 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -109,6 +109,7 @@ export default class LandscapeView { this.altKeysMode = Boolean(options.altKeys); this.mobileMode = Boolean(options.mobile); + this.hideHud = Boolean(options.hideHud); if (this.mobileMode && this.numGrids > 1) { this.numGrids = 1; this.effectiveGrids = 1; @@ -763,6 +764,12 @@ export default class LandscapeView { if (!bottomTextContainer) { return; } + if (this.hideHud) { + bottomTextContainer.innerHTML = ''; + bottomTextContainer.style.display = 'none'; + return; + } + bottomTextContainer.style.display = ''; let controls; let hudButtons = []; if (this.mode === '1d') { diff --git a/web/style.css b/web/style.css index e97020d..bc624b7 100644 --- a/web/style.css +++ b/web/style.css @@ -733,3 +733,7 @@ body.hudes-mobile #bottomTextContainer .hud-mobile-actions { #bottomTextContainer .hud-mobile-actions button:active { transform: translateY(1px); } + +body.hudes-hide-hud #bottomTextContainer { + display: none !important; +} diff --git a/web/tests/hud_toggle.spec.mjs b/web/tests/hud_toggle.spec.mjs new file mode 100644 index 0000000..3bc10b3 --- /dev/null +++ b/web/tests/hud_toggle.spec.mjs @@ -0,0 +1,25 @@ +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('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')).toBeHidden(); + }); + + test('hideHUD=1 hides HUD on mobile', async ({ page }) => { + 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')).toBeHidden(); + }); +}); From 2fba5820bd33e1ef3677d4304276624f136a6b09 Mon Sep 17 00:00:00 2001 From: misko Date: Mon, 17 Nov 2025 10:17:17 -0800 Subject: [PATCH 16/29] lint --- web/client/ClientState.js | 61 ++- web/client/HudesClient.js | 234 ++++++++- web/client/KeyboardClient.js | 9 +- web/client/KeyboardClientGL.js | 24 +- web/client/View.js | 32 +- web/client/helpTour.js | 37 ++ web/client/hud.js | 4 +- web/client/views/LandscapeView.js | 726 ++++++++++++++++++++++++---- web/public/hudes_logo_splash.jpg | Bin 0 -> 135825 bytes web/style.css | 306 +++++++++++- web/tests/help_overlay.spec.mjs | 39 ++ web/tests/hud_toggle.spec.mjs | 13 + web/tests/mobile_countdown.spec.mjs | 38 ++ web/tests/mobile_mode.spec.mjs | 2 + 14 files changed, 1388 insertions(+), 137 deletions(-) create mode 100644 web/client/helpTour.js create mode 100644 web/public/hudes_logo_splash.jpg create mode 100644 web/tests/help_overlay.spec.mjs create mode 100644 web/tests/mobile_countdown.spec.mjs diff --git a/web/client/ClientState.js b/web/client/ClientState.js index e12392c..c8050ce 100644 --- a/web/client/ClientState.js +++ b/web/client/ClientState.js @@ -13,6 +13,13 @@ export default class ClientState { this.stepSizeResolution = stepSizeResolution; this.stepSizeIdx = initialStepSizeIdx; this.helpScreenIdx = 0; + this.hasSeenKeysOverlay = false; + this.resumeMode = null; + this.tutorialState = { + completed: false, + currentIdx: 0, + totalSteps: 0, + }; this.updateStepSize(); } @@ -23,11 +30,11 @@ export default class ClientState { } toString() { - const secs = Number(this.speedRunSecondsRemaining ?? 0); - const timer = this.speedRunActive - ? ` | SpeedRun: ${secs.toFixed(1)}s` - : ''; - return `Best-val-loss: ${this.bestScore.toFixed(3)}, Batch-size: ${this.batchSize}, StepSize: ${this.stepSize.toExponential(3)}, SGD-steps: ${this.sgdSteps}, Eval-steps: ${this.evalSteps}, Dtype: ${this.dtype}${timer}` + const secs = Number(this.speedRunSecondsRemaining ?? 0); + const timer = this.speedRunActive + ? `, SPEED RUN: ${secs.toFixed(1)}s` + : ''; + return `Best-val-loss: ${this.bestScore.toFixed(3)}, Batch-size: ${this.batchSize}, StepSize: ${this.stepSize.toExponential(3)}, SGD-steps: ${this.sgdSteps}, Eval-steps: ${this.evalSteps}, Dtype: ${this.dtype}${timer}`; } updateStepSize() { @@ -85,6 +92,50 @@ export default class ClientState { this.helpScreenFns = helpScreenFns; } + setHasSeenKeysOverlay(flag = true) { + this.hasSeenKeysOverlay = Boolean(flag); + } + + setResumeMode(mode) { + this.resumeMode = mode || null; + } + + resetTutorial(totalSteps = 0) { + this.tutorialState = { + completed: false, + currentIdx: 0, + totalSteps: Math.max(0, totalSteps), + }; + } + + advanceTutorialStep() { + if (!this.tutorialState) { + this.resetTutorial(); + return; + } + const nextIdx = Math.min( + (this.tutorialState.currentIdx ?? 0) + 1, + Math.max(this.tutorialState.totalSteps ?? 0, 0), + ); + this.tutorialState = { + ...this.tutorialState, + currentIdx: nextIdx, + completed: + nextIdx >= (this.tutorialState.totalSteps ?? 0) && (this.tutorialState.totalSteps ?? 0) > 0, + }; + } + + markTutorialCompleted() { + if (!this.tutorialState) { + this.resetTutorial(); + } + this.tutorialState = { + ...(this.tutorialState ?? { totalSteps: 0 }), + completed: true, + currentIdx: this.tutorialState?.totalSteps ?? 0, + }; + } + closeHelpScreens() { this.helpScreenIdx = -1; } diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index 9f0b7b0..af3a63c 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -37,6 +37,9 @@ export default class HudesClient { this._sessionStorageAvailable = typeof window !== 'undefined' && !!window.sessionStorage; this._sessionTokenKey = 'hudes.tab.sessionToken'; this._resumeTokenKey = 'hudes.resume.token'; + this._tourKeysSeenKey = 'hudes.tour.keysSeen'; + this._tourResumeModeKey = 'hudes.tour.resumeMode'; + this._tourTutorialStateKey = 'hudes.tour.tutorial'; this._clientSessionToken = this._loadSessionToken(); this._pendingSessionToken = null; this._resumeToken = this._loadPersistedResumeToken(); @@ -119,8 +122,11 @@ export default class HudesClient { this._helpDismissed = false; this._patchHelpStateTracking(); this._initHelpDismissState(); + this._loadTourState(); this._handleHudAction = this._handleHudAction.bind(this); + this._speedRunCountdownOverlay = null; + this._speedRunCountdownText = null; this.initKeys(); this._bindHudActions(); this._connectSocket(); @@ -471,6 +477,7 @@ export default class HudesClient { log('[HudesClient] prompting for high score submission'); this._promptAndSubmitHighScore(this._lastFinalValLoss); } + document.body.classList.remove('speedrun-active'); return; } @@ -959,6 +966,191 @@ export default class HudesClient { } } + _loadTourState() { + if (!this._sessionStorageAvailable) { + return; + } + try { + if (window.sessionStorage?.getItem(this._tourKeysSeenKey) === '1') { + this.state?.setHasSeenKeysOverlay(true); + } + const resumeMode = window.sessionStorage?.getItem(this._tourResumeModeKey); + if (resumeMode) { + this.state?.setResumeMode(resumeMode); + } + const tutorialRaw = window.sessionStorage?.getItem(this._tourTutorialStateKey); + if (tutorialRaw) { + try { + const parsed = JSON.parse(tutorialRaw); + if (parsed && typeof parsed === 'object') { + this.state.tutorialState = { + completed: Boolean(parsed.completed), + currentIdx: Number(parsed.currentIdx) || 0, + totalSteps: Number(parsed.totalSteps) || 0, + }; + } + } catch { + // ignore malformed stored data + } + } + } catch {} + } + + _persistTourValue(key, value) { + if (!this._sessionStorageAvailable) { + return; + } + try { + if (value === null || value === undefined) { + window.sessionStorage?.removeItem(key); + } else { + window.sessionStorage?.setItem(key, value); + } + } catch {} + } + + _persistTutorialState() { + if (!this.state?.tutorialState) { + this._persistTourValue(this._tourTutorialStateKey, null); + return; + } + const payload = JSON.stringify({ + completed: Boolean(this.state.tutorialState.completed), + currentIdx: Number(this.state.tutorialState.currentIdx) || 0, + totalSteps: Number(this.state.tutorialState.totalSteps) || 0, + }); + this._persistTourValue(this._tourTutorialStateKey, payload); + } + + markKeysOverlaySeen() { + if (this.state?.hasSeenKeysOverlay) { + return; + } + this.state?.setHasSeenKeysOverlay(true); + this._persistTourValue(this._tourKeysSeenKey, '1'); + } + + rememberResumeMode(mode) { + if (!mode) { + return; + } + this.state?.setResumeMode(mode); + this._persistTourValue(this._tourResumeModeKey, mode); + } + + resetTutorialProgress(totalSteps = 0) { + this.state?.resetTutorial(totalSteps); + this._persistTutorialState(); + } + + advanceTutorialProgress() { + this.state?.advanceTutorialStep(); + this._persistTutorialState(); + } + + completeTutorialProgress() { + this.state?.markTutorialCompleted(); + this._persistTutorialState(); + } + + toggleKeyOverlay(mode) { + const view = this.view; + if (!view) { + return; + } + if (view.isManualKeyOverlayVisible?.()) { + view.hideImage?.(); + return; + } + const targetMode = mode || this.state?.resumeMode || 'explore'; + view.openKeyOverlay?.(targetMode); + } + + _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; + } + + _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'); + } + + _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'); + } + + _clearCountdownTimer() { + if (this._speedRunCountdownTimer) { + clearTimeout(this._speedRunCountdownTimer); + this._speedRunCountdownTimer = null; + } + } + + _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(); + } + + _cancelSpeedRunCountdown() { + this._clearCountdownTimer(); + this._hideCountdownOverlay(); + document.body.classList.remove('speedrun-active'); + this._speedRunRequestPending = false; + } + _updateEvalStepsFromMessage(message) { const steps = message?.totalEvalSteps; if (typeof steps === 'number' && Number.isFinite(steps)) { @@ -1160,8 +1352,23 @@ export default class HudesClient { this.requestLeaderboard(); break; case "KeyX": - //log("Next help screen."); - this.state.nextHelpScreen(); + 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}`); @@ -1427,25 +1634,38 @@ export default class HudesClient { 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; } + 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; - this._speedRunRequestPending = true; - // Do not change mesh grid configuration during Speed Run. + 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."); - // No fallback timer; countdown will start on first server reply with speedRunSecondsRemaining } cancelSpeedRun() { @@ -1457,6 +1677,7 @@ export default class HudesClient { log("[HudesClient] Cannot cancel Speed Run while disconnected", null, 'warn'); return; } + this._cancelSpeedRunCountdown(); if (!this.speedRunActive && !this._speedRunRequestPending) { return; } @@ -1470,6 +1691,7 @@ export default class HudesClient { this._srsInterval = null; } this._srsEndAt = null; + document.body.classList.remove('speedrun-active', 'speedrun-countdown'); const payload = { type: this.ControlType.values.CONTROL_SPEED_RUN_CANCEL, }; diff --git a/web/client/KeyboardClient.js b/web/client/KeyboardClient.js index 85eced8..3bfbb38 100644 --- a/web/client/KeyboardClient.js +++ b/web/client/KeyboardClient.js @@ -1,5 +1,6 @@ import HudesClient from './HudesClient.js'; import { log } from '../utils/logger.js'; +import { HELP_TOUR_SCREENS } from './helpTour.js'; export default class KeyboardClient extends HudesClient { constructor(addr, port, options = {}) { @@ -64,13 +65,7 @@ export default class KeyboardClient extends HudesClient { 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', - ]); + this.state.setHelpScreenFns(HELP_TOUR_SCREENS); } diff --git a/web/client/KeyboardClientGL.js b/web/client/KeyboardClientGL.js index ff34d78..2ef7454 100644 --- a/web/client/KeyboardClientGL.js +++ b/web/client/KeyboardClientGL.js @@ -2,6 +2,7 @@ import KeyboardClient from './KeyboardClient.js'; import { log } from '../utils/logger.js'; import { installMouseControls, computeStepVector } from './mouseControls.js'; import { installTouchControls } from './touchControls.js'; +import { HELP_TOUR_SCREENS } from './helpTour.js'; const DEBUG_MOUSE = false; @@ -141,20 +142,7 @@ 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", - ]); + this.state.setHelpScreenFns(HELP_TOUR_SCREENS); } @@ -221,12 +209,16 @@ export default class KeyboardClientGL extends KeyboardClient { } 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(); diff --git a/web/client/View.js b/web/client/View.js index 843ca7c..89db905 100644 --- a/web/client/View.js +++ b/web/client/View.js @@ -40,13 +40,13 @@ export default class ViewRouter { return this.impl?.updateLossLines?.(...args); } - annotateBottomScreen(...args) { - return this.impl?.annotateBottomScreen?.(...args); - } + annotateBottomScreen(...args) { + return this.impl?.annotateBottomScreen?.(...args); + } - showImage(...args) { - return this.impl?.showImage?.(...args); - } + showImage(...args) { + return this.impl?.showImage?.(...args); + } hideImage(...args) { return this.impl?.hideImage?.(...args); @@ -84,11 +84,19 @@ export default class ViewRouter { return this.impl?.selectGridAt?.(...args); } - get modeName() { - return this.mode; - } + get modeName() { + return this.mode; + } + + highlightLossLine(...args) { + return this.impl?.highlightLossLine?.(...args); + } + + openKeyOverlay(...args) { + return this.impl?.openKeyOverlay?.(...args); + } - highlightLossLine(...args) { - return this.impl?.highlightLossLine?.(...args); - } + isManualKeyOverlayVisible(...args) { + return this.impl?.isManualKeyOverlayVisible?.(...args); + } } diff --git a/web/client/helpTour.js b/web/client/helpTour.js new file mode 100644 index 0000000..e941bd9 --- /dev/null +++ b/web/client/helpTour.js @@ -0,0 +1,37 @@ +export const HELP_TOUR_SCREENS = ['tour_hook', 'tour_modes', 'tour_keys']; + +export const HELP_TAGLINE = 'You are the optimizer.'; + +export const HELP_ELEVATOR_PITCH = + 'Steer a CNN through its loss landscape—walk along random directions in weight space, find valleys and saddles, and set a 2-minute MNIST speed-run record.'; + +export const HOW_IT_WORKS_POINTS = [ + 'The cyan grid shows one random 2D slice of the CNN weights. Each point is a full neural network.', + 'Height corresponds to training loss. Walking downhill means lowering the loss.', + 'Batch controls, precision, and eval toggles let you inspect how the network behaves in different regimes.', + 'Speed-Run Mode locks in a 2-minute countdown and submits your best validation score to the global leaderboard.', +]; + +export const TOUR_MODE_OPTIONS = [ + { + id: 'explore', + title: 'Explore Mode', + tagline: 'Learn & tinker', + description: 'Sample random directions, inspect batches, and develop intuition.', + }, + { + id: 'speed', + title: 'Speed-Run Mode', + tagline: '2-minute challenge', + description: 'Ranked leaderboard. Configure quickly and keep loss low.', + nudge: 'Try Ranked later', + }, +]; + +export const MOBILE_KEY_COMMANDS = [ + { keys: 'Single-finger drag', label: 'Move in plane' }, + { keys: 'Two-finger drag', label: 'Rotate camera' }, + { keys: 'Buttons', label: 'Batch size / Step size / Precision' }, + { keys: 'Tap grid', label: 'Select cyan mesh' }, + { keys: 'Speed 🔥', label: 'Speed-Run toggle' }, +]; diff --git a/web/client/hud.js b/web/client/hud.js index 255a06c..df15146 100644 --- a/web/client/hud.js +++ b/web/client/hud.js @@ -89,7 +89,9 @@ export const formatHudMarkup = ( const separator = ''; const controlsMarkup = controlGroups.map(formatControlGroup).join(separator); const buttonMarkup = formatHudButtons(options.buttons); - const statusMarkup = safeStatus + const statusMarkup = options.statusHtml + ? `
${options.statusHtml}
` + : safeStatus ? `
${safeStatus}
` : ''; const titleMarkup = safeTitle diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index 78e6b16..acfe071 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -25,7 +25,15 @@ 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 } from '../hud.js'; +import { CONTROL_GROUPS, formatHudMarkup, MOBILE_CONTROL_GROUPS, MOBILE_HUD_BUTTONS, escapeHtml } from '../hud.js'; +import { + HELP_TOUR_SCREENS, + HELP_TAGLINE, + HELP_ELEVATOR_PITCH, + HOW_IT_WORKS_POINTS, + TOUR_MODE_OPTIONS, + MOBILE_KEY_COMMANDS, +} from '../helpTour.js'; import { log } from '../../utils/logger.js'; // Import your logging utility const DEBUG_SELECTION = false; @@ -128,7 +136,15 @@ export default class LandscapeView { this.confusionCols = 10; this.exampleCharts = []; this.helpOverlay = null; + this.helpOverlayContent = null; + this.helpInfoSheet = null; this.helpImageEl = null; + this.helpTourSelection = 'explore'; + this.selectedTourMode = 'explore'; + this.manualKeyOverlay = false; + this._helpKeyListenerAttached = false; + this._boundHelpKeydown = (event) => this._handleHelpKeydown(event); + this._howItWorksVisible = false; // Grid properties @@ -764,12 +780,6 @@ export default class LandscapeView { if (!bottomTextContainer) { return; } - if (this.hideHud) { - bottomTextContainer.innerHTML = ''; - bottomTextContainer.style.display = 'none'; - return; - } - bottomTextContainer.style.display = ''; let controls; let hudButtons = []; if (this.mode === '1d') { @@ -780,8 +790,30 @@ export default class LandscapeView { } else { controls = CONTROL_GROUPS; } + const statusText = typeof text === 'string' ? text : ''; const options = hudButtons.length ? { buttons: hudButtons } : {}; - bottomTextContainer.innerHTML = formatHudMarkup(text ?? '', controls, options); + const statusHtml = this._buildStatusHtml(statusText); + if (statusHtml) { + options.statusHtml = statusHtml; + } + bottomTextContainer.innerHTML = formatHudMarkup(statusText || text || '', 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') { @@ -2035,116 +2067,646 @@ export default class LandscapeView { }); } - showImage(filename) { + showImage(screenId) { const container = document.getElementById('imageContainer'); if (!container) { return; } + this.manualKeyOverlay = false; + const isTourScreen = HELP_TOUR_SCREENS.includes(screenId); + this._ensureHelpOverlayElements(); + if (!this.helpOverlay || !this.helpOverlayContent) { + return; + } + container.classList.add('help-open'); + this.helpOverlay.classList.add('visible'); + this._hideHowItWorksSheet(); + if (isTourScreen) { + this._renderTourScreen(screenId); + this._attachHelpKeyListener(); + return; + } + // fallback to legacy image viewer + this.helpOverlayContent.innerHTML = ''; + const img = document.createElement('img'); + img.className = 'help-overlay__image'; + img.alt = 'Help screen'; + img.src = screenId; + img.addEventListener('click', (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + this._advanceHelpScreen(); + }); + this.helpOverlayContent.appendChild(img); + this._attachHelpKeyListener(); + } - if (!this.helpOverlay) { - const overlay = document.createElement('div'); - overlay.className = 'help-overlay'; - overlay.setAttribute('role', 'dialog'); - overlay.setAttribute('aria-label', 'Help screens'); + hideImage() { + const container = document.getElementById('imageContainer'); + if (!container) { + return; + } + container.classList.remove('help-open'); + this._detachHelpKeyListener(); + this._hideHowItWorksSheet(); + this.manualKeyOverlay = false; + if (this.helpOverlay) { + this.helpOverlay.classList.remove('visible'); + } else { + const overlay = container.querySelector('.help-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + } + } + } - const advanceHelp = (event) => { - event?.preventDefault?.(); - event?.stopPropagation?.(); - if (!this.state) { - this.hideImage(); - return; - } - const prevIdx = this.state.helpScreenIdx; - if (typeof this.state.nextHelpScreen === 'function') { - this.state.nextHelpScreen(); + _ensureHelpOverlayElements() { + if (this.helpOverlay && this.helpOverlayContent && this.helpInfoSheet) { + 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', 'First-run guide'); + + const closeOverlay = (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + if (this.state) { + if (typeof this.state.closeHelpScreens === 'function') { + this.state.closeHelpScreens(); } else { this.state.helpScreenIdx = -1; } - if (this.state.helpScreenIdx === -1 || this.state.helpScreenIdx === prevIdx) { - this.hideImage(); + } + this.hideImage(); + }; + + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + if (this._howItWorksVisible) { + this._hideHowItWorksSheet(); return; } - const nextImage = this.state.helpScreenFns?.[this.state.helpScreenIdx]; - if (nextImage) { - this.showImage(nextImage); - } else { - this.hideImage(); - } - }; + this._advanceHelpScreen(); + } + }); + + 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', closeOverlay); + + const contentBody = document.createElement('div'); + contentBody.className = 'help-overlay__body'; - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'help-overlay__close'; - closeBtn.setAttribute('aria-label', 'Close help screens'); - closeBtn.innerHTML = '×'; - const dismissHelp = (event) => { + const infoSheet = this._buildHowItWorksSheet(); + + contentWrapper.appendChild(closeBtn); + contentWrapper.appendChild(contentBody); + overlay.appendChild(contentWrapper); + overlay.appendChild(infoSheet); + container.appendChild(overlay); + + this.helpOverlay = overlay; + this.helpOverlayContent = contentBody; + this.helpInfoSheet = infoSheet; + } + + _renderTourScreen(screenId) { + if (!this.helpOverlayContent) { + return; + } + this.helpOverlayContent.innerHTML = ''; + this.helpOverlay?.setAttribute('data-tour-screen', screenId); + switch (screenId) { + case 'tour_hook': + this.helpOverlayContent.appendChild(this._buildHookScreen()); + break; + case 'tour_modes': + this.helpOverlayContent.appendChild(this._buildModeScreen()); + break; + case 'tour_keys': + this.helpOverlayContent.appendChild(this._buildKeyScreen()); + break; + default: + break; + } + } + + _buildHookScreen() { + const screen = document.createElement('div'); + screen.className = 'tour-screen tour-screen--hook'; + + 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 = '/dist/help_screens/loss_landscape.gif'; + gif.alt = 'CNN loss landscape animation'; + visual.appendChild(gif); + hero.appendChild(visual); + + const body = document.createElement('div'); + body.className = 'tour-hero__body'; + + const eyebrow = document.createElement('p'); + eyebrow.className = 'tour-eyebrow'; + eyebrow.textContent = HELP_TAGLINE; + body.appendChild(eyebrow); + + const title = document.createElement('h2'); + title.textContent = 'Explore the Loss Landscape.'; + body.appendChild(title); + + const pitch = document.createElement('p'); + pitch.className = 'tour-pitch'; + pitch.textContent = HELP_ELEVATOR_PITCH; + body.appendChild(pitch); + + const desc = document.createElement('p'); + desc.className = 'tour-desc'; + desc.textContent = + 'Each point is a neural network. Move in weight space, and lower Z means lower loss.'; + body.appendChild(desc); + + const platformNote = document.createElement('p'); + platformNote.className = 'tour-mobile-note'; + platformNote.textContent = this.mobileMode + ? 'Tap Play to explore with touch controls. Connect a keyboard for ranked Speed-Runs.' + : 'Desktop gives you full control and the fastest Speed-Run times.'; + body.appendChild(platformNote); + + 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?.(); + event?.stopPropagation?.(); + this._advanceHelpScreen(); + }); + ctas.appendChild(playBtn); + + const infoBtn = document.createElement('button'); + infoBtn.type = 'button'; + infoBtn.className = 'tour-btn ghost'; + infoBtn.textContent = 'What is this?'; + infoBtn.addEventListener('click', (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + this._showHowItWorksSheet(); + }); + ctas.appendChild(infoBtn); + + body.appendChild(ctas); + + if (this.state?.resumeMode) { + const resumeRow = document.createElement('div'); + resumeRow.className = 'tour-resume-row'; + const resumeBtn = document.createElement('button'); + resumeBtn.type = 'button'; + resumeBtn.className = 'tour-btn resume'; + resumeBtn.textContent = + this.state.resumeMode === 'speed' ? 'Resume Speed-Run' : 'Resume Explore'; + resumeBtn.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(); - }; - closeBtn.addEventListener('click', dismissHelp); - overlay.addEventListener('click', (event) => { - if (event.target.closest('.help-overlay__close')) { - return; - } - advanceHelp(event); + this._resumeLastMode(); }); + resumeRow.appendChild(resumeBtn); + body.appendChild(resumeRow); + } - const img = document.createElement('img'); - img.className = 'help-overlay__image'; - img.alt = 'Help screen'; - img.addEventListener('click', (event) => { + hero.appendChild(body); + screen.appendChild(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'; + + TOUR_MODE_OPTIONS.forEach((option) => { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'tour-mode-card'; + card.dataset.mode = option.id; + if (this.helpTourSelection === option.id) { + card.classList.add('is-selected'); + } + const extraCopy = + this.mobileMode && option.id === 'speed' + ? ' (Keyboard recommended)' + : this.mobileMode && option.id === 'explore' + ? ' (Touch-optimized)' + : ''; + const description = + option.description + + (this.mobileMode && option.id === 'speed' + ? ' For ranked play, connect a keyboard to access all shortcuts.' + : ''); + card.innerHTML = ` +
+

${option.title}${extraCopy}

+

${option.tagline}

+
+

${description}

+ `; + if (option.nudge) { + const nudge = document.createElement('span'); + nudge.className = 'tour-mode-nudge'; + nudge.textContent = option.nudge; + card.appendChild(nudge); + } + card.addEventListener('click', (event) => { event?.preventDefault?.(); event?.stopPropagation?.(); - advanceHelp(event); + this._selectTourMode(option.id); }); - overlay.appendChild(closeBtn); - overlay.appendChild(img); - container.appendChild(overlay); + modesWrap.appendChild(card); + }); - this.helpOverlay = overlay; - this.helpImageEl = img; + 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?.(); + event?.stopPropagation?.(); + this._confirmModeSelection(); + }); + screen.appendChild(continueBtn); + + return screen; + } + + _buildKeyScreen() { + if (this.selectedTourMode === 'speed') { + return this._buildSpeedRunPreScreen(); } + return this._buildExploreKeyScreen(); + } - const overlay = this.helpOverlay; - const img = this.helpImageEl; - if (!overlay || !img) { - return; + _buildExploreKeyScreen() { + const screen = document.createElement('div'); + screen.className = 'tour-screen tour-screen--keys'; + + const eyebrow = document.createElement('p'); + eyebrow.className = 'tour-eyebrow'; + eyebrow.textContent = 'Controls'; + screen.appendChild(eyebrow); + + const title = document.createElement('h2'); + title.textContent = this.mobileMode ? 'Touch controls' : 'Keyboard overlay'; + screen.appendChild(title); + + const list = document.createElement('ul'); + list.className = 'tour-legend'; + this._populateLegendList(list); + screen.appendChild(list); + + const footer = document.createElement('p'); + footer.className = 'tour-footer'; + footer.textContent = 'Press Enter to start. Press ? anytime.'; + screen.appendChild(footer); + + const startBtn = document.createElement('button'); + startBtn.type = 'button'; + startBtn.className = 'tour-btn primary'; + startBtn.textContent = 'Start exploring'; + startBtn.addEventListener('click', (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + this._completeKeysOverlayAndExplore(); + }); + screen.appendChild(startBtn); + + 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'; + screen.appendChild(eyebrow); + + const title = document.createElement('h2'); + title.textContent = '2-minute ranked challenge'; + screen.appendChild(title); + + const desc = document.createElement('p'); + desc.className = 'tour-desc'; + desc.textContent = + 'Configure quickly, keep loss low, and submit your best validation score to the global leaderboard.'; + screen.appendChild(desc); + + const keyList = document.createElement('ul'); + keyList.className = 'tour-legend'; + this._populateLegendList(keyList); + screen.appendChild(keyList); + + const footer = document.createElement('p'); + footer.className = 'tour-footer'; + footer.textContent = this.mobileMode + ? 'Timer starts when you tap Start. Connect a keyboard for the best ranked times.' + : 'Timer starts when you hit Start. Press ? anytime for this overlay.'; + screen.appendChild(footer); + + const startBtn = document.createElement('button'); + startBtn.type = 'button'; + startBtn.className = 'tour-btn primary'; + startBtn.textContent = 'Start Speed-Run'; + startBtn.addEventListener('click', (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + this._startSpeedrunFromTour(); + }); + screen.appendChild(startBtn); + + return screen; + } + + _getKeyLegendData() { + if (this.mobileMode) { + return MOBILE_KEY_COMMANDS; + } + return CONTROL_GROUPS.map((group) => { + const keys = Array.isArray(group.keys) + ? group.keys.filter((key) => typeof key === 'string' && key.length > 0) + : (typeof group.keys === 'string' && group.keys.length > 0) ? [group.keys] : []; + return { + keys, + label: group.label || '', + }; + }); + } + + _populateLegendList(listEl) { + const keyData = this._getKeyLegendData(); + keyData.forEach((cmd) => { + const item = document.createElement('li'); + const keysEl = document.createElement('span'); + keysEl.className = 'tour-key'; + const keyText = Array.isArray(cmd.keys) ? cmd.keys.join(' / ') : (cmd.keys ?? ''); + keysEl.textContent = keyText; + const labelEl = document.createElement('span'); + labelEl.className = 'tour-label'; + if (this.mobileMode) { + labelEl.textContent = cmd.label ?? ''; + } else { + labelEl.innerHTML = cmd.label ?? ''; + } + item.appendChild(keysEl); + item.appendChild(labelEl); + listEl.appendChild(item); + }); + } + + _confirmModeSelection() { + const mode = this.helpTourSelection || 'explore'; + this.selectedTourMode = mode; + const client = this._getHudesClient(); + client?.rememberResumeMode(mode); + this._advanceHelpScreen(); + } + + _completeKeysOverlayAndExplore() { + const client = this._getHudesClient(); + client?.markKeysOverlaySeen?.(); + this.state?.setHasSeenKeysOverlay?.(true); + if (typeof this.state.closeHelpScreens === 'function') { + this.state.closeHelpScreens(); + } else { + this.state.helpScreenIdx = -1; } + this.hideImage(); + this._startExploreFlow(); + } - container.classList.add('help-open'); - overlay.classList.add('visible'); + _startExploreFlow() { + const client = this._getHudesClient(); + client?.rememberResumeMode?.('explore'); + if (!this.state?.hasSeenKeysOverlay) { + this.state?.setHasSeenKeysOverlay?.(true); + client?.markKeysOverlaySeen?.(); + } + this._beginExploreTutorialIfNeeded(); + } + + _beginExploreTutorialIfNeeded() { + const client = this._getHudesClient(); + const tutorial = this.state?.tutorialState; + if (!tutorial || tutorial.completed) { + client?.resetTutorialProgress?.(6); + } + } - if (img.dataset.currentSrc === filename) { + _startSpeedrunFromTour() { + const client = this._getHudesClient(); + client?.markKeysOverlaySeen?.(); + this.state?.setHasSeenKeysOverlay?.(true); + if (typeof this.state.closeHelpScreens === 'function') { + this.state.closeHelpScreens(); + } else { + this.state.helpScreenIdx = -1; + } + this.hideImage(); + client?.startSpeedRun?.(); + } + + _resumeLastMode() { + const mode = this.state?.resumeMode || 'explore'; + this.selectedTourMode = mode; + if (mode === 'speed') { + this._startSpeedrunFromTour(); + } else { + if (typeof this.state.closeHelpScreens === 'function') { + this.state.closeHelpScreens(); + } else { + this.state.helpScreenIdx = -1; + } + this.hideImage(); + this._startExploreFlow(); + } + } + + _buildHowItWorksSheet() { + const sheet = document.createElement('div'); + sheet.className = 'help-info-sheet'; + sheet.setAttribute('aria-hidden', 'true'); + + const title = document.createElement('h2'); + title.textContent = 'How it works'; + sheet.appendChild(title); + + const description = document.createElement('p'); + description.textContent = + 'Human Descent streams projections of a convolutional neural network trained on MNIST. You steer its weights directly.'; + sheet.appendChild(description); + + const list = document.createElement('ul'); + HOW_IT_WORKS_POINTS.forEach((point) => { + const item = document.createElement('li'); + item.textContent = point; + list.appendChild(item); + }); + sheet.appendChild(list); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'tour-btn ghost'; + closeBtn.textContent = 'Back'; + closeBtn.addEventListener('click', (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + this._hideHowItWorksSheet(); + }); + sheet.appendChild(closeBtn); + + sheet.addEventListener('click', (event) => event.stopPropagation()); + return sheet; + } + + _showHowItWorksSheet() { + if (!this.helpOverlay || !this.helpInfoSheet) { return; } + this.helpOverlay.classList.add('showing-info'); + this.helpInfoSheet.setAttribute('aria-hidden', 'false'); + this._howItWorksVisible = true; + } - img.dataset.currentSrc = filename; - img.src = filename; + _hideHowItWorksSheet() { + if (!this.helpOverlay || !this.helpInfoSheet) { + return; + } + this.helpOverlay.classList.remove('showing-info'); + this.helpInfoSheet.setAttribute('aria-hidden', 'true'); + this._howItWorksVisible = false; } - hideImage() { - const container = document.getElementById('imageContainer'); - if (!container) { + _selectTourMode(mode) { + if (this.helpTourSelection === mode) { return; } - container.classList.remove('help-open'); + this.helpTourSelection = mode; + if (this.helpOverlay?.getAttribute('data-tour-screen') === 'tour_modes') { + this._renderTourScreen('tour_modes'); + } + } - if (this.helpOverlay) { - this.helpOverlay.classList.remove('visible'); + _advanceHelpScreen() { + if (!this.state) { + this.hideImage(); + return; + } + const prevIdx = this.state.helpScreenIdx; + if (typeof this.state.nextHelpScreen === 'function') { + this.state.nextHelpScreen(); } else { - const overlay = container.querySelector('.help-overlay'); - if (overlay) { - overlay.classList.remove('visible'); + 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) { + return; + } + const key = event.key?.toLowerCase?.(); + 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; + } + + openKeyOverlay(mode = 'explore') { + this.selectedTourMode = mode || 'explore'; + this.manualKeyOverlay = true; + this._ensureHelpOverlayElements(); + const container = document.getElementById('imageContainer'); + if (!container || !this.helpOverlay) { + return; + } + this._hideHowItWorksSheet(); + this.helpOverlay.classList.add('visible'); + container.classList.add('help-open'); + this._renderTourScreen('tour_keys'); + this._attachHelpKeyListener(); + } + + isManualKeyOverlayVisible() { + return Boolean(this.manualKeyOverlay); + } + } diff --git a/web/public/hudes_logo_splash.jpg b/web/public/hudes_logo_splash.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c8fdf5d93aa2892517ca6277675086748ea7bc49 GIT binary patch literal 135825 zcmeFZc|4Tg|35solol-`DojEQNs1IPq_K=;%#5W-iWZbasE8qnMvQ%}Y%|7SEH##> zrj)e>V@TF)WeM@N&Y1B#qxa|g{XOo#?)(1xb3M$sTyy5S&Uu~Jc|D)66{&QX9$dN38|Gi_D z;D7EY3ds`s&$-Zh(tmmkZuZ~fzY_Sb1pX_5|4QJ$68NtK{wsn1O5nc|_^$;1D}nz? z;J*_1uLS;|5?JejJcR6CKNNTGR@72dRMaxn0*9%CDgSWrbXY$;|2@Lik2L;K_&*Ymh~N$Zp&bHi9|dwi#6Uz~ z{rFGkL5M(DWP|9&O=6q3Lj(ncgan0!L_~x^ut6Y*A3hM?A+l3W!*Ihc%q7tS-q53= zaoHQ?PnW%yz_$HSIOgK>V3XKxNvS=16%Q&Y!&Nl3j%%Mdsbh2oX>4L@hQis{+S#8y z=iqwz%GGOb?jE;&{rvCTy%!MnF#J(OC#t?PUD;Lz~M=+9qc;}f%U|I9BeE-kOHK|JH{aKQWD5&d7| z*#X8QC@d@_EXp5`fZ!eQ651gwBB!xory)l4lJ~9yM?*J4Pse4Kz278%4EsmI#ivbd zw}R%N;w*ni>m&MqH=qaqzee;w1NxuwtaU@S2!UYH4xt?o1mq{xO)yam;v?IiliB!- zSYv6}NLnOm(6Ma>56PsQ&={dn5}7AnpJ5;(lTV&B*h&$v#*10O?WfZPwvuz6itbVB z4&6%rnGWxove>MV*}H?+%CkKbiy770+VK_0H%MIsM3H`9`4(Wn5RIiq1BWn1-}*YkfPq_MLwE% zCu=(v%e2-&8_tu+zv0T;Wgo;p>%zH*Fg}=dr<5x~)IH$37=z77HO2kVHbXLIHD9XR zc`&bi4e~HZc{}6%GQnrd10DPV_2_X}ZR#5&M0C$kJmErZ$j*5F*A>;E)&fX1C?qkF zZY_vyLqgP`7=umgS1?Ej6ub^=n3>P#gzO~1-2~unq%4t~cmhN@hX8SSxizWoBU;lu ziAb|8&NN+v#9nDx-Mj`dq$a%BdlG{)+GVLy`|RwL#ChLBw%02DJ#V>i%=A;?IZws8 zt3oGQB;?)yA!qhj9hVNeH$?Xvi=a{Wb2T7!ti1fDIFRy(m}o-O88eq*fS(a;#`!$?$SL%CaD+`-F>?T(0)qfRFlmCHQ8Q|vAhT_b{QwV2Q#c>V=%ioLhQspBE0*ImPCfAfo>r{re&aH`AWU$MyT zpJX2tId?U=YA5%W{K>?inT!!R@o#KfXY;HwtFxrg&)DKlnF5_}o!>lH^b1rEMAj6B z?WW5!y%e9AK2Ew6*H|<$$?IBlICace;Dpfgq-SPYH!-=H38=SEE`P*t!zW#fwfXVH zrP}Sa$^H|`+f6u15~^{x)Il%I$VhJ|LIRFBj=G03E^LCX9DkID-NA+J_zTDf>X zy0Edh^XI~E-{ortQiB`BnS^+c-W=1Ppq-ZNbNdOEq`snLpN&`Re_tbvi9U`w8G;{h z=6ZardbY5y@AvVQhsv^HSa~A?zPj5=5mur^*fgfT-znC23_r+ZZ^Ggy4XZ0 z{|oYGrNJeKb1iqzU4$g0}+UCpLo9(NSTg|M?Xzli#uE(@}-nJ zUoMO6p%;E~+j}p;qgaBLylnrPxVePXt@ohmXZy!9( zK>fH)PimWBW1eIbUYhf}BEw*JeCen+qd}@VB{yGHzjf;6%(8FJH+1!DZ0DGix{Lv> zVhs}KhR+--GB@XB>_4M$srrdc-@IUBn;&dtlua=6jT&=tClSSRmExQZW(I0~d6J^O zlyE|bB3L%9)qdOUk5om9RO2bUO5|6|?)R=62+FjFYKm%V5Jj+rMW9$FSgtv#weiUW z14SsdRS;utPJp1`ZqhppL?E|KJTL}Z$@1x_+kRQXz_7;YFF|Kkt}Qc7mck*&No$Z~ zM6?NZ4e|-LEFmP7)~jJ=g?_6~?-tfw7VV6Gwgx%W(3xf+?)BO|H=m;2pVz9f@%;h9 z?V#a>yjD3!+J1_*!xf17AFgr(l<~nbOVmG?qKOYz@__hoCs2|zSZe{R-*7r5D@&LR zO{BlG=hSCd;on%21kedHd#{)&^{?g&Bx13xas%&|+uhe7b1k!ZE)S1T#H$9vk3ZEP z4u)>sl0#75j%#4QT!1#m;tLQC0{klw&;&2$c>-VPRU6ydtelf3helTqpstuc+c$2& zzYrp(TqiXYA{{)I*WS|Wa4~xeFlWM0N*0MC8#Znrmk2DbfVr8u0c@@HXPsbd0Imuu za3C&8b}0R>rcyst8)xe3ZgL(Qh%tcV#LQa@wXjQrzL;QP3)dI4V5sd~bj&Mj#zn7F|CFlj;EHKWeL?c5u{#1S1lAq^DgcPu}Z7&J2{d+Tqt>&Pws zi;w+OW)1~ox?iaJ;C_z%c>(m~po*Kq#pB-^UFSafgeIvD$Q0^tl8=lZXLJ{j$G+?> zO088=ScKRLqjiQayY7FkpLDoKa&ro4_qW|&R1VhWy_mc(bK`j7_8$p0JAWLyaG_3# z#WEbvrZd0CW^J*Nea1^FEpa`tvPtUe2`yup4FBPoZ5>y;h3)%VhEbj(C)}=-zHcBO z=?H0y@cdMD?VH3dPo+^Q*`$K2hZR%(6%0~8BurW*8nk+y>3;VkblhCJy+`GVAf-&& zHX~unqO;qXjqiR8y?9HpdRn3K{?LxP@|4^{w z68cv~F-RfkJgIIExy1uwfqO7(_8flORLE-&soUMh{ivIOhSP<}g3hCbf5?#t3wyYe zaq=tXLD?Ec64EJj8?ULYrir*LMfEJwjQnN%z<>#Fj(*2Ws+*F)qoi0D8quuwK}|vvXovXbv*r6tM`8ur;xb)SlRIYgzxnzHH7+~PQi|TR-MF#*0FkUTf1*fN z6<_Yuuxb2hWve~1V8~KmQm>hp6TP#gF}#>8rQWWWXp2kAIf(1?!>;l#Fu&J||2TWpKCO~Yur zL|B0%H=TV3=DYe~?0Ucte4UOcM|HK%Q|-oq-&lw1$1~6uhO5(S=`Z8k?PK=nUJjek zIJY|m`~CC5s7lO)p6crd=7A%GY5hXBcVm9at1(fnA%UomONOhH+bZuP*C4P+L>KI9 z>2(jh*sk5x*W1=0JEejuz8!P&9J$dA^~gP8bIOL*w^cgY)9t8^v+#wOjq>B?+Riyy zymxs_91L}G3V-NuUd`L*L-0wxka-DQeEDP3hS{Ae{qpK|hUsI`Z{E~B#hmAUDIAay z@eT<8)^j-hW>lL=DUEwsd+|I3|8fnI)#>BB>C8;t7<{SEo+r8n`BrQ<+(`JHar~f` zyt~#$g3*$7W-Zy=<)CTt?d>;wOguVNH;zx02kvY9s68Q3eR~PdW}P3~Jx58q+;jJb z--a?T&FeL|YK@-_UHQBuykYt4T`ON+9l*5y5g$BvPVsK3s{4*NZwn4Q^Dh;?*zVUi z&@C+6tbz4dO6^ECWsHUh&BA_36yiOqX$4N*b3c3+WHZBczvtEaK$GO6msSF;rc)cV zvby!=95WtS3qF~eA#{#qNS-)H9*Lw}(|0;*%U+2r)JC0c0P>{1^ zxXZmd>!0-ck$D$r=BSNUwEFLnK1X8T^DMkL(3-O1V8=9#?_c~Jtnw@2i;hKJ^&9Y! zHd>!%&RiD34`r-D5dFmtH8f*rm}#<9y@bKL)$8Gfq?!D*?_&ls8gITclDu=t{tn`+ zVUFCNywg>K%%|EH)eghqx5phep2ra2@GTnV&*92hf_$>BOy;w8Z`s5|Na~aUITs44 z8X|h2lp(qOp(Y*>#G@p)h9*>4!o22|D|)a(G$vHcFUC6Tqe7*h6~J8{zQ3l)oPFh=pJz%tn6~M{nH%35-dM6Fu6vkg zT59_0P3WhQfQ|;s9pW|_yU)0N{U&vSI+bfD(tBAs zpX{!{9f?p_D%_o&u5@Pd%=54Z=W9>5siukD(h+-+&{HFPH>4%tk#M5?l#qNmdh17~ zsk|>%Kj~?7&b7v{FDlPp+t#)@4ylDkP8zMa%)4eCczX4S)kyJe%bG3>!Cuu${-VMY z&lX`@kM^X_z^k!&Op?pXs=~0==7N`^99DDS!m7w|+Md!WKy$>i@+aJNh)c-*jyr&f z|7eQn2-cD@MX)mS^T*{%DZ~ZOWt{sYj|Q9%G9YAaj zdpR-7b5%3>tWSS<+o9i&Vqh|A^2~z*V@8R0nXEi{f+2k4ASsk_^TiDGG%xY&g<8GO z_pvS&;T@%83^MF`r39u zI7(|D0?kY*PnnDR{nZsZtxDmwzL@92^#@$JEvx?FcFxV}S1&dY6&sjEhK{QDE;oL8 z8D4)EI_P)N#3C4~Qc@P}VMc3a-&wscc9X_q{E;Ags^vBdI|U~x_IAcLo?5*oo5-a& zO8?2Jc-3f(xLb9-fn-Qb<&yw&A48~W4@e6 zEPYX0OiE#J|1klwi;9ze@#9E##Ak0OYu2IEaG7NeeWvoe`hC}KqNn;O>|lf(!RO); z_P4Ws`}XVb5;MDxTYz5sQl_q~=8T*?eKK#>>CR$!I+0hd%|U%?U4sm0s+6plH2O@& zc#m5&NOW#JU03Z7>mzZ3GY3Gh00<7dSh8Z2U=&|>uA(OX+pY6QD{bWuRI68xGe!Ym zFg)9DiNls!fCY`PU4tmU+7=vi`sd)OqHy$d{=0n7pVzPTOKPO#C$rzD zDo^0Qlc!|f?wEU8^zzh~Hxu$s_@4PS2nIA*gDm88f-@!%Gnu?{Y(Zz+uF$3p@*;Ql z+dTK}svma3)U-F8#}8-@RE~cDGiBagxoFbZ*7QWcvY0W|ea8?4|BT>gYwoOzTl}HCaXE_-{q4}2xd5Wb`Lxf4*_dIsVs0ok>&;ov+xS&rXC%1@cINf^PUgOGJ z;jh8B-!A=~+4b%O9wXLC6@QrWm(5-O7#sBB0}IdZ$pwCI1CPamj~SqUASA%QC3xr> zug^USCk`n0K--%Ga z@weOFCg8i2|NFb@V9dXOeL(jhM#ZoCoB#t}gH*WVfBzMbeh+yK0;tygHZaRkvBP6S zH9PL*@yXZ!Xu-ci1>fIh6f+c~TB7aX%mIt|*p!Yl2bQG%S7J>}d&ghn&OoyFs?j;Y zHeALM7)|tz?;ddu-^&ZL6(5hdR?*PfCcKGtd3Zg`S%Y}5uZeB6d2dO%rZ~GpKX-rA z_-cGw`iH&QFSmg{^m@w;LUW$wTkP4|r)D4?0m4HRTZ4HX1tGz-{XuBfjj1zR! z!Fyr1>OkW8C%-P%R8`oUI=Xp>*DC*|Mm>Ka z`WEZXz513MXjf^9+Aa0;~{;)Nvz6f^nB;e8DbLRULapc0qP^}{a2reie)Bkt4IcuDvEvMINoW4 zt+KAtIwLl*`US>BfE7(MuQ8q{&Sl%bco7+REV0h_*7fl%aLIV9I-wHEpxqs}4|IGA zSkq|?B!5noR^d7Yjvr5M(0+VkW&f`}5ReMH6LdS^ds@}++^ph=;HdBGvbGY>6(4lw z@TXHO$NBQfZD$A*^0QmK>!~9NV@WVM>4n_Z5qQ`27B8tsgLex4 zY@D<^{MvZ$(d0-Y*wD=Z6n@o|QH_u{i{9KCY@DLQ!}v&3aLF?c#~s4!Lb+`s;2#lbr4%nNT5oyF@or7KlVnl)Wm+-7_n zy}fq!#XZrun4FiIG$hCq91BPmk9pofduap|X(9J&1g~Y%((9&_&X++pumd`es}|LVjWOa#UqiY1d)&A>F^7xLGweUl5{u12ea+2Z5xl+M}Tf9-q)*z~U^ZnOSrOMI)52+aE zWiA7`IS?zx8GV0IndkwG-N|is2@)MaH5R-vKIR4YdfDS#ew_W7wwgV_7bNru;A!gF`=nQZ(9F(-h+sV0yKHCLAk#Vzh z9=7yFw%fT`-zcd8fYV<2cVF?<*9#;UrCNk|LMZj$ro)vS&A%`uM@X7|{mqt}?E-k# zFg@sPqxX12AAcO%j1q>M5tuXQO-bx;4qBVlYF;*=YsL*&MMMa{X`nkE@QYJ6y+NtO z#fG_J3dgu%l?L;A@tq+J@(;=Y69AKmyNG%6)_3>)!dPjX~qg(v_rTr0-vw_nR0?N%4KYLrxQ4+7EZ(*^i6JMt-iT*@6xee zSpU=EEb^nF>-Sf#h4qyC{$w{5ruym3Q_jb*N3uz}7n(nn8^{h-0KCHU?_$I7XqQ|z80M58#`mcmzb)Z@ zv!dj4R-7aLW_ykJs(+GK(MBa{aSy9po^^C*!%5AV;{rdnb%;x_xC|>8pfNE_yqo(# zFR+$KY{9qha`l|2?ZcxDOGp#KWcWgeK*g}0z5|YWc3bXzwjI7%Nhp>&((s8lR-C~> zqXDyPaL3aR8EmZ#Yv{}Fz&DHONI!>bI9*$O7O{k%?MBdl<7>b}+83F8`P?3^h;L?h#f6ADN^$OucS)jRNAG5Lq|C(} zF@cx85g#@F0NPJ5%02baqb7?q3U!srIfsYqe;)Nl3oVIGIw2Ox6vim2$N>wawoMdoVe1 zVMtGIMGq%;j>NfAnYCIFzTnsUu}+GXaOXOAfSE95_augWFEe~}mf7>QnGH&o(_9NyJ*8%YvzsCGI?*1k} zOO*N^x~QPRW?g>|{l%$|{h*l!^1Hp~+tN|Wn-dZ^No>|h>OL-tYl$@5T6WvmosN@e zxJpJZST5UWsqmxv2`Yz(-joe_7>lR`A@qhUQ3;i&t&)dL)*!NT!O+KMFm?(IWkWT` zUcay04ou7NAfm;?zmoR~(U*4?-%uOm&K_Woux%Bo4dUUcfAA$}nw{F0vtitTAdf;n z&Hi7Wh=)$!$@$&}CrGxRcC0~)ob&SsW?(e^o!T^ovKM7~1pz6Gren}V+q4AO0_>0a zxb+B&!ZlbAwQFe{Mqm0)%|d7ZLeG-WgyH{anqLg-*b$*wQ=kLyI$xJhoWV_~Xmfta z(qp*`?DkD^(U#?^8GVvk3W!PjNsQk(7Rf6M&dKQ7J!IN&kKKO98ieQD-5kZx*pDVN z_!{eJ&1JSWr(9x_P=K6|hf%E>606;dl0rWDV!ny{Bj3Zc%G;>xz`|asVS+__AjZP} zU@E}q5T5OB53{#RKM+Ysvl}}D5_Zkej`^tub+B>Drc~3XnZ3lN0_pY^cJ@O4*y$Uo znSJV=IbTk#K? z8Iz_Z|2)$WV?zYZ3`(*_UF0rJ!^>*CMEhgGxNS zn`Lq`s#=35nAW;wJ<^DRD{9~m{w0Lw%{+{$=+)@@ z4CE1+1hWJ)GlOkk_KJe5K7`TN9}XT&jAt{?~bb%RPC_RxrYLcQ=9CX8g4g zv*F1uus; zPq`i{@#wd~bO<>zG6=$6_vougH6D? z2t`grha?2cjObx0u_=zPG6hGgCQ8NG@HI$40c#Rio+;iR(x(3>p;{xKQ^M1z1=YJgrx=FW|RMX(r+U%ib^7Mw;9RV1X8|T~1kQl*^$5tEJ zwgJy?=8mbDqfbk>nW5a{4_Y2{%LzfOwF3hxq6>wnW32G6y-jrp#*?N&g7GOOU=i>u zGd4MLv0RV6fVLe9S;{92#qTo$F<=lObOwP$8@~f!A@hx=f-lD0B7{P?NFhL8NwO&j zl&^0WgJrgiZD7XtrZdDeix9(u`gE=rHvq`VqI3;HOk@M~2&N-~8PgqpCoj@9vezIN z)-#e0QGB!ck~?hF`#YlcXgooAkAmw@=t9!4!Zwraybr*T(d#+&d3C-@JzE&JN4%^$ zHp|05Y{97@6GO{apf}V+VGBasiATjQ2On;G%G$b1iJ5rL=dp~X=)me$BkTv(8l>9e z`Nt1Fd*N_5#dstB4I4Q1nUd@}b~|iluM=wQIQhwFihj9-nItIA(JoE?5x}$tKU?{Jp1JF_9XPN8SH-=NL>xW00h2sAM#!=P@FpXw%0 znVGL@Lb^e+a5n+P&-#NGNG3wC*mM+Ry;=vf=mu&3zv;yPq!rgwi{T)(xOG1{hagsp zF-HoOCgSqzgi5>NUAa*4#G7z;Z)4@nn~dS>zs#(1LeHX5eu{#n{Oq{p1JDt>odgHv z?TPfikL6$CcTW&RkRj(?K-B^Np`oH4aHOJ;m@*lZ7_GPDr!&pgD~kjrnKAR2{?M?X zVN@Spz%(Eo_w5f9GQ1?(nWUr5OyHd((p2!0%-H&EInR^^5Dph7K$NT=1P5X^NYaD|m4zim_Y$i~( znQ`mZhfS9`10g({x(Rs85<_5>3W^w7ML;nFeMNtT-3}MUnguWYP|=BZezPogMXOY= zNt|7t=Z!{qd+3?ZNU^7#cg z9yyr4X$L6A!YIrV`w)ZHLq!JUxY;q~11DbLQ`urs>2qU|@dpMpR_r^A8usr?)|YfI zC{Z>0Mj`8qzaE%Q-`j3&^|kR)JOPz^q!93zhS!-G+lkNkSrBBxppXU+5}?1a53k8t zUuKJ4ep2XN!d5LCufkbiKJe(%UehsQlUPZ$%eT3_J{=qFE~Z$WMJO{tvs>l&RW(bP z2BXzR!UeYAgRe15+1fmhZL-I;O{cx?t68d45pN36oz+FjQmfK!F*=$H~1o5+_y*3ZmN z>*~i(ad9z87aDl_xJm4FrKL~2sokuf4~ytWhohDn$D4o66^JiIJ5=5|_o&@MIexY6 zR$$4p^?bmyQu~;22h=?*Mc5_7ViPd5XX*8mSiY$$-xoA&PIbQQsi-D#W|waWC~+}6 zcVa>h{)?yAz!_i`vPf?Ibwvu9w@39KJv?1xtWjrIc%%`eJ}TgP5gUr_<)yK`P&#UZ5I0PAGb;qkOL@nfV3rrcE z>YWi~sy@d$33i~t z2qTP5nJiw&?Dvk|m;@;O`TR$HD;Nw#6e>5@K5V*}KjtW0DSP)!9wCt1$V-yhcq4WV zQV4U10l}VR=V%kc_B3E2?HCRO(j^X=&GH4dkq67li^+M)efuC?hu{PO(;AOh@S4x> z`S%3syN#r!>d&^ZmG@`kwu|!u+kGb9vTje z-R2l&G6|Fr0o9NQU}9tb7E3^|i_k*k# z+yvZ#QIsrK)LnL`^Ght&Uy1ZYzST_*?E*DHVx|B;AH<6RTWDyvGnwEZR+^d%{X?3E z&I&(IB{}5vH$Hk|8z&Sfh7o9+de+*V>4z z*h++^QZUQ7W2McY%ENAFd~*DQLyAzkwdF&weiZm6mHY>9LPD#CVjqGckkrlm?!KYI z(H_8<-<>i;3PSIY$TH)~QvB}H1A&!?z}JgJu!~tP;Mzt91c4ylpLKEIF7r7fgaizl zW62L?0W`a8fnt1XAGaG+QrYR#scml)A@5nZ4B*sg`ayqk>O-J_9l#t+d4H{{C29k)5gYxe@>@mg?S zX8G=SZJ*X2w4j3ZX{*#~@Bj5WNduI_+_f^v;$TVgqb^^20z0{gq#> zHgQcKE?yL17O>ia8razDhIJFC%}xQrT(CcJ4MKHZBF?YdRDBBD#y7Uh=40@-eR{(IO^5n`r31FK>2Os z&Z2jSJ4&4L3q?KLCjCTm8A)5U%^Z1DnAC8|ZnvQE__n$=h@QxXGw5Ui;v&(RSFRfK zu{hK-XQ1I=L7LSOhBB3msEQ2oAS>tOaW*3r)i45B1d?szcPeZYO_rlY0^LsKqqg{3 z$4{y|C>Us33J2zG(^9x9cNlAL2$lkFJN+5eUDsN;Vcj3cX|u!G>5t-749%bV<}uq9 z(yqu8N}oH{_qviX)C4Y$C$V*Jt;2-IutvJ4)r;BkdZc~vd<9__t2jkF# zNIph`R}Ud3QzmVD9-UYy+`a}WM$_!^z`N1=+I3$c=hlbIA8swoSh_I?$Exru50cK7 z$P?^-J8|M`5dr19#8W#`pPgfs{!BZxEyDjOrHcACFGhd*At*m|O>g~}3LaO=_uy>< zK(^)Zz#qi8?S#wu2b4+rvx7>XjY@#9M{~M&5XYBF*Y?+N%FG7e0%pq4TN0q$4<6*?TWW=cWm58+z7MCKNPm zh8nof#GSre=8IqQP)R>b0hZb>Nyc=*TR*WYPR3lae|0MoTerjzTFnpK zrW@xEEXEE62L6wPjG6kts))(yE-&w?rCs9|l*(Szm5qD;lGNQE=#|UsTs1MXj|j+W zCMZ$Nr>?VAXa}HEd{_QLPE07vc)|mO@;d;wn^39p;NgJp>xg1ik~l#kNG!uEE6Y@r zU9k!Wob?nx29ut7*INIhpFDTif(l5u%U)1SiEHVDHbdY*m9~R{xzitk^6?|mZ`|pl zla_yZAsqh@i(@>*3LhuHz3bgW5_7YGJp*={p7gcOfwc4PJI&a3(|XcS>4>GEPx+PWizu@ z9LG(Af3SgnUNTP-cFiYyy>@ect?c#M+d?#RL4FdL?_lUKFAM(~PdVlG}!1y}*lda#E{zux9av z3FFqbk@$|L`Rw02RGBy`MBxWc;aSAAJUjg%+%OKVB1+(K)|#s~B5yc% zX6r{*)I=o5;c25Fk3`#2m%Z;zfQUafcvOS3q zxPP(GE_0Ic<6uDtN2=+z+@QfE%3*(AadU>pyU^`kRVQShjX$!9io6G~!`7!R=IlsY z)%=+`E9Vizay=1{c#&;ckhnlWFE1th$u5+u>q{CVlM0XSr13+Mz?PRg&>Hw>K}d_J zP>1+u1XPZ2;qd`~G+9fHbg61klGRc5`k|Sy^Kr@e>nHWLQX8=8XFeZm4QO9&%mHgq zTbnw*Vk0hZw!}EhYA)R2TOs@PtIsdZW`J!kK?H=cmu7bv*vz|(y(NUy6t#~z$%M;~ z?GAFN^Z6>Dxx2>A=HWM*=M(8T1vuQGMey0nZv@C%=g<6{l=A)3nRl^_eoe5T;S=r> zt)5UiZad$%69%<7vf~R{B48e8XJ9NAG!6?YQPTu)4Lp!C5PLvDFYFA4rrqj&@U+Ng z^e&+9@nh*Hjg>CoU+XZrX=T+1WP+-RwN#tP&lDB!dGhrelA7CHnm`r{`jI;PYO6=B zU%$4vM}QIZY3)ELS<%PCpU_7qsan~bk8Il@lJ|$J zE~mY9f{^wJGnq7?S0ik~BlOz=2d~@Ju#L^2wn@+p->If|E%~6Xxa>1(GDvUPrS@4O z%eAc9)b1t!6SOeb!}47%P^|o#hHb#4~5AmEl6lwLFt}-P~?a&THR_$c-}LUxnyZmad2PG8z{yi*^D2CAY3P= za^2hTG-sL?j}zqXr~3Ozp|!((MteSlQvc+mMlCgb`dC8IY5{RW-ci-zG)RPP4M6jS|u#w`=A}1T7U`ee=z~EjtPK~ z0nZA=F&txAB|YO9o06U}<(&dN>f0QE1-J-yGMjJ%f7d#q>xwCeCbDb+iLfw214OvT z$w^x`=7Q4h^t|}kTF_ac1lsYZ&-lC><=qXcoxM3Z8CD2y_pMt&VR}3iz(NBwcRGE; zBPNr-y9odR@D6b9Q$6FB*+PJcD+} zfT55IQhF-Br%zsKnkSn?i#Ee~x8|w3@ODB+Sx*m#o&jFZZ_Shci6D!b9G%|RLf*%v z-XGyj$$A7>>w6s;m@=L~YXrfKKv4!(|I&Aw?xQF)-H5)2B*U=_Sp9q=f8p`wffK(| zG3Iw%QzVWT#YqP|91GmxATs#9=G{PhJqI1%=T6K3?Pim*9BXr%+wDcuW>2wR z4)GT9FWAyJZG!vrXCFSJQC{h~YPz3g69+T{Y|lSh{qS`Sa+P?BI0(I$%S-^*r`1#M zy?X-9-=iBEidzX|rhJi2+&T38H^t1($!N7%!*={BE<(8*0C3t_P)JT%_bcSWq1eSd z({2Enr}_K4PSym$69LGQNTH9B&|LBni9jwKJ~=RMK@tG?`wotFK;b$=b|!zDMJ z4st$#b~Y~-06H0qj)FW#Z(783?yASBJJhrJ(Hho%lV;E++p7Bkc;KNL&YfJ8kkM2s zjUZd9|G`(Q9PT(ZJdLkX-q}@R=gAw%oMwrUt0Zprk^hEtd#t6yR&ee_GN{)tmk1#{@Cd*0F1@k#6F)qAVPiVw?0m3-}g7On%A zA&)HX-`1x>LwoUF;afaB-7KyYlwANpwj8OXt8zS|(x7cYuk-Ab(jdxFa%(dk%k0<_ zU3iN)Z_g$a$DNAUh^Q?wmXGPEzjNu4_qi`)*DWQIj|V>!(Y=2JOya%_)_nfuq|BJ8 zF%^}{SN~H0#Sxe zL~yk1a&#s2Y6jQ8zjy3i0O2Zc?@GAE|OS;KEwRzJxVN#p z;kzu?0qz-mw2@uLRzZP!m5Pl&m{BSowZH@%6pC(i)RN8+l?S4#Sd{aB!XNmUYz4CZ9$qxvjl*t9h4!Q1T06zoMpjsac zoF_PJ%$2(a8MpPocy`x)ey`3w<^}SXG;D+Cft|OG*!+k|dS*v!`Xs(+X_1^RxuN)U zW85t!n z@TOzfg|UC*L=)>_qKvN8=|_MJbn^p6@%t2`a*2y>;&J8m?w5FoC6B#_^dnO~wXk1i zdiaJ!4!30U{5C4sK9-_mezh`xZ&H;gxT`-4VxVgfl_-fr2>c&Jb4C}YH_|2DSeari z_=#7_@;jBaXPb(&b;Tw%lgeH+`6!6N`{^giXzo-%OnjTbllj}HKv6P3H8e2MF1xT5 zh3`-r#xM<<6ejc^@b}Mv*g_%zrtng~y#eWQhz`Uc=6U%c`~-Vny!`@XKRv zdw>CKUhrw~P`mrolH@E&(*y~nH;LWicUwygiPTzt8R@bEs35D{V6(k8n=IOg*1=1@Bk-AsoPlW8bN#{n5W4}&l zp11wr6NsHgd!W_y;P>hCS^b~2$@`emQRBuP^I86v-oUjLbv@V^dH{B_Ms_aMc4$7& z(;0QwaLO#JOxag>yN<1z1+09Jcj*2tDqZy+U#WXRsnQ4eTa)kDHfnns2KVSb@@!x;sCI$kEb6=c<8~-lq*}OBb{kDv(^$h9&>OhTbX)QLvhxywX zb)1OGDf-~Uyy@VS%Zvs;r$67srl^{SjvI@T1upigz$MOJ_M_g?Q|{JNK8UG)sr(N! zo@Oq&FWk1^RCnTv(?by54U^*s>Y_x1$WZ^RSg?ioG6>(9iUODD_;c>t#OCcs__Fg^ z0wBMRMf#ygDBz>(j!_73pi)KDF9Gi`5rY&Ia~>j*5dooL+<#C5ciSCj_TEqJ_cjpV zgiTsSO*5Do>w6P3CHwG8?pt@1^g}_V>;di|3a}Ecd*nU7Pk^%kNuLeR@hl?kxcZyr zy+-oXjCHGmW3Yh_v{zs&h^?}&(aglzb@?@|GMidf1BoDkV1xta(inu4=JJ&dnnouO zeG=OzRLp|^u*D+%plV_HD~L{QK(Z7#za}kzhzIZii7cg>IZb%TN(dU9mk@Wrboulq z@rHs_bU^yZIJkW4Mg{a=H;H9?=sYPC)HXRFPxrsGmWlj>7A!_Z&AogoVy&{XIqU}h z*>R&J^c52Jg+a@z$`N>5P!pmrUjtXSqyV_%Bl^zE0I)H_AA^7Jow>a)iSw6u3-;ul zBndb1d31f2X}3|h#9hr>J+b=tVIQ=PT``ov_H|ueEu3(#8;pv4G(6$4N%0=~OM83y z5!}l_!uXAIc2~{z#F$}LCunDhT$@b8)rmKnN-uU7_25gK|LHEY%`oJEkh@-f@_U)K z^9v+O+!t-KR@ouX^AS8f(yB^Mp+@bK&P2>TyON(Ut7ioV=QZ;Pa%MloGCrBE071V) z9%+d=un%ef1*3D|p3=2_&>Lt&EZ=9sa_y)cjf~;1UInZsw-awrzSuUqtBQ`5ATX%T zDaX=!@Pz=9(r&n;eP~d6`I)UGigo^1|Ew<%$Er1my(FjH36mKv;WO-^;P~oYgM=CB z>TuS)jEa02sgsoY2=V5hOv861_pa!|Iig{mA<`+b>J|0c1Y?>IQI6LB19tIUziMcP zR6BYGIP8$M)?dm~rXWT(z7!5p6U3R4&WOP;XlymG1Mo~gZPAUAh%9M!zgK}DB{rt*r^ z%EpcSsjCg0tB%~{G`M?6#?R6%mUjFV7BCW)p!&IC7)tYcpOy0aktt5H+K>tbf#kCO#%yp z)R2^`icKoT6mZ6;7c z?!VwDEl7xH9*#r-Zn++g;B(Dk_FkA_!__mJ#&L#AEgZ^h#!3%#8Hyrnm8hAm=n;zn&~Xp5C;xa>w6r zN-=_Lbb6O!aPGKi?oa3oP>*36@e}F`!$i=`VQeZwjQE6=kML6(H1Lz7?yCer?Zv72 z?Dz_4M29vDC(jvr%9zOflh?3Rw=pPk0+j#Q(3Yr?*MmCpa6Qwsqak7rC3p}>A$RG>8W%`3PqJ=qsa=zAoaurr{o8olz*=r^z$H5cw*7o1sd-||r76?e7j2TvnquPpqvzHjKF>q5GGLvB)e>6QJ|}Pzzg_ivZxMhq*220`2^KhN zxye>O_C46k|K}O|L4+l-NlckUIso!vfB z6CzQHCFe6^)Kp$kVmU-Q5ql*%P~?2fc^hVxm{Vz$P)a!~$0RwE!;H<=?{U@p^Sgcj zv2}@Um+N{y9*_I!iOg5Ychtxft@LU^d>8yl@phKSngW~k$Dt*GsF)Q-3V*37d}g(o zKCewk`yZIW9>@g`TKmmO3W*`qjuFR&-}Oh;(;VChwIIgyUSPx}{v4b7#LkBNBBWIO zAS_Qn??8eHddQp%YGHuCEof#xW0h!8&fG>pb|)1+`C0q7eW%J?2Y(LE7Rtx^lL(E{ zF^4}M_ts|y3uCr3e+%6t_&M4v2O=e2tH}Hu00{J5CEK|7LPF_;d~)0SdDLl&#KJMj za(7&aUczxXq^dngzaYg@-y!r1>pm}ia4h1mnb(Tcrz$P#RYPUJg`VlOS@}B+g?V$P z31*6M$DYgFt>7HrsnY2xD7{N=KF5uqIReBN?P=0-N8+Ke(>W5x6Z`SaV-P*bTnheQ z?Qrb9*wxwqwnM$>5BY}y7)xOUi%isxG?m9jqJgfzlA;q z3mdl{7Omda6i_(7r=ur6Z*%Zmb@evduRMS(#Ejz4?K%yw%R27zs$*|ne2^8&#rYo;r(-a$`x%+(lVy+ z?N{B1&~)sFu4kg}44yELuEEBnIjSfw2cbFPST@Bj(q$1P+q^~R`8F>W=$DumP9PO> zsy#@bH_vTFpYe#dRM!_G~u)Udl}>TzmpYTxVaBMk)Co^xsZIsrlWMCp#0J3P_l z0vLgZh)T5z`xMgnp37KlqA&Z-FMuHxo?7^uAc*sr39p!s#cneFOB-K?s$`pKj~w{{ za=i+{?W}@+z9ref);84e85_zZ0MPH-f`w&+2TYCz-Pa{~z^O9Ty)&0@Lrlf7@xg27 za&dF;*n=OP}#7Ukz3}3ugZABomY>%A8 zTZS;_1%_HT>lfJ_*%%Y*{KUL?A-Nz&Vl={K56=93Zk4*{ahKBiX_Ky8PHuyK*G)+~ z*P@z9yFf`7rqQ%nvF8-=}s+xhtLNZ@;r&CM3Y+y+I@x5qLbW>~X_l;VqKJQ@C z?x3w3T+6gDwURZewwX$GFZ~+P6XZ@K+;{WAQcFWTOmgs{C+nqyJl!@%v^_1`(%n;u zzI>3&79^xajM!v_v&`cgV^c=OUsg#M8brt0ffrp!rlFZzd_>!}8D;okTW z61F9ASMze#KmN*k45`az6Y$y{nRE#yqDv z`9ip{`i{R7A`QIjsR1sR!J3(emz6fs70hs3o$_h#Io;#>vWQ-6%nRU4O@vLWwu&s^ zO0LnMk=g1Y2Tm8^5DMn-O7VVJDpt~9G&#u{W*$`%b6DaFS86QuogAo&ibTem7(Ss-Z+v#yj)kgG;cIU7>e`JuTVm@@C~f zsa9Pz5lz`n4B1a?a?()#j=|`=#x|S>o3`AuDTFYdLMgB0B}KxLN?kj9ip0=LEKnBz z3JXwu`SJ!Xf%J?#*eMVORzxW*9Cd~@*iP;%WP9G>%KjFrHjpweEiEjn7uoM9Tw#6w z-;A0#>rwF>o!(1Q$@97IP3uNK;l4_f91=C7^j9Vf7L1>GI~->l=hgmIo_HZj%TP;2 zr<3o4`-*EQXs6q-zXhR(tmv|G%I{H16kQLxp*r@X9AC!e4W00AEmxBjj`4EP#wJ>4dm#VaTKtJ0h6P{;2N5kmz0ePTIJ#hQ=jG~}xmo^na zPNZ|Xf1!-eY61u`s2CB#cBxRG?#tb4B?JbD;oGf~)?Y{M@DT9p=+NT{2aq#EI4(o zW7xYi5l6|GB3a*(N&V*;?VF5G=SpDCl?hwx`HtF{{aAfIHMu9XK})3zu|=Hhu#FA7 zf;!!F>(Y+8Lv+bfhEzecd0@XmY^vkcLnGwD?>J7lW88FkN7?SJbC%H@^M`&8s*`7L zAN$e}_gWfriKZ-F9G;zR4&0|Yab0RR-@i?b^8jGbYU~*Kcu1r$Dajyt=s8@V@`ptoQ%R+q(M9>ydn zhUyS@NYEb|AIeL8`CogE29#BQIDc5E`+@M7@wPs|NReB|ZG~pLd)$gzCQ+_j^J-%mAB)p;BX25Je_K%(6O5#Txw9W?2ac+}pi;qq?@g6J}K002;YZMHU zv64-)Y`G=T-O=|8BpOaOIfl*c(vUe?cW@b7`JnB*RFK27xSz~@rY6=(&YP7_n($)y zqDD@qUK{r9kFmS%SKfOtTfIRy3^y~ynnwS?VxNxTh3%m`4|k&8i$NBM=$U-dc|f_h z4cgk?Mij0I>ojU4EI7Y~K;a6LCUAPu)mAW+>V@X~Ew^U#5&)e7YN~J777L@Rf%ung zeESfI7{JSr`4kUnU@N}dZ-dSld%x`o45n@{m^vUr{y$=4F(1}{RQohQ?GW|cs~hCN z6dQ22V8pWEpMM8a7Q~1_GS*^?q#ROl&5@au>JTYlutW?D17O|$qw@d+)e_d+(fEg7 z?2N!UfmBzV;DkW4YYmJ|V!(Z7NN0y=5v>aJ|DxXX^HKQ9jp3fmMz7%|wrlUbR66Pt`M*WYyr9$;1A>fYNE2oO`4yPIub3T7n6#Sy{6K$pmjLtkV zo#^g!=XkB^b!l@is&{`!+{EO#qXncV92w0x+dtQkxnC{$zgIQYit)iDZx~`f?>9r{ zdCxhGw!1HKP2x%3)BGCaL4M7W{ZF?_v7||}p=k58OXQ0Jhu+Wp8k^*+hVJSaSo-7E zi0aDvUxc9lt|?q;Hzp>xo8>UH9+?r-_rj!Wzx$q*2udJTr(e!hP&Ne+ay*-ejFgN@*iZ=e$r z(IXM$Zg^%J_MVCqarHKbSpL8~KqL_sXV}u(+>ZPi`^KBf=)kb_sDsq9HB5!HX>d%D zP9XSjMEFxmr8>I8rXau2AJ-fYxg6w#V-gv{@`tv(M#z8v_7shg@GG zmBt>SskJ_xz^Keiml|+tk#Yuw3rKjw#~<%i3`c}g;#MF9|52HE;AX;Iy&tS8LLk$Z zqX5U!%OtL1@h#sIlkfc&8iF!T82f_vzJ{(fEu-zE$sXX7wwRW0qeNthUsN`8v-8UF z*+&x7Q+nxq4m^3OZ~1BR!z$*)gbjvHm9M(jb?K7I%rqN_Oo{tLdp>NA?lqf75g7&o zs!LX38~7>+bll>iOZ^go$b|I=y+II!PkeKG9JLVo3W@asm6Hinsn(TU^)e=+u!U}1ZDS^wA`u1Jd4DcuG@j2(`4 zVcKkkpJ%LgnTjZIQoXYoC|;#+iS|6^=h=d&+BV|Dmcspy@->1B`{R7YxSSr7U$n&* z-9hi!ft8x`kR}%L_RHPwyD*7qiE*6bp!240Q_cPGW_aj({!dyZuNXiHd`Eso^^Ogu zjiA2=|4WIwC$yeof806%7?0?kv5vc~8N(mSIW5)3hDlEGzWpVLYKlD`_47FQfVq!s z+&wq6T`itMX^6nP-;dl$+!Z$bPYBasJ6$o7AjfN~O|TKa&x=SN@suy!ox_Mc7w^7S zRPg>=n})&Olcp5XOh$<#1=H%*-s-B3|h$;+%FTelNU{~$n4LWXAZ5bEm#zzJlw(f_8}SMK%uUcDQ9P1T8|6j=#7fM>+W3 z%kuk_PXCk@(g~a{0u-K}#{WEqtv*nZ@ecbDR+Q{NOsEdS(H-867=X=9+>M7dn8(Zi zHUUo@RbI~h7OLoPxbrl(65Kqt8UR#uN=8uO?mkHt8?v>N4t0(* z@t$Ya-ksxwV*B#tpeJo0W4mj&`9t>J%rRG)x-7al48zDYuYc?dz9-QK4c@>sbic%? z;I|N%D>ZO0F)BBg4T9QWbS#V;!ER(9`9In;6p?C+z+ja-g1INc8gm}Q6oU%Lg{{Jt z_#yz|JwVdDb`%WQfj33Z%rZ1Gzz-GwRstsALtMeiG@B4;0Pt(z-}}($KB_|uf5xr_ znXS!Tw=qr0?I6z3<}&l}NbQeRFlB-3FMo^xLwA4po`W$+lK|ztbR}A`U$fyx=8aa& z?qV3E|J#NP)_So0SRk4;o1{3*S zf^WGKP4G>QnKW|b&*7z3z4vQ5!b;3?!MEjtpqy==Z}HybZGoS%+}f1+&7~p!gmZ?L z$mx+skuerKFXlO^T@zfMxWg|JtBDwudscpj)4;l#Ax62h_1@3kU2Lz8HoRG>|EJET z$()tjC9V(zgqoAM6lGk&;}ZL{2IemEHdNgfW68tCUSS1{AF~uE!RVUoEGK8;@vw<2 zI)&@KcHHsWd~zz4=j+B9oI^FADDNum-^;G+`Z2Hv16TazJEJE{ovOZRXSXtItM2hG5>{LuZzOu=NxZs!H^l*sT&8s6j1XV*lJnd~j?xFSKxGLk^DVOulBf5E+sdj$xJwzEOh{`>C%@ zs_@do$a&ds#2+yEngwU`T^L!2HWhz6#Z~ZY)V5dC-{AF-mCcX=VC*S-)t9Wohf#LS zroK93OzD{S<#aCu6yNkVkCI1qe+$#P{Ef^S1W7`M{V>G+b~aXV2Ds8#+;=Eyx&PNL zFqDG_O%LP2W_JgKi}+R`ikT8Jp%8bcl%;R6?0|N#kwJKR_?u zMJL1XFM!Ug3cJG0OS@8`p8FGQg|GmRr{;**tW5oqRJr$&Ns~RwLP{iqAas9T1iSUp z{f)v3W#I=NQ=U9@F5J=_MaDk<{PmgyYxeIjATuAkUhnO%`8a-8C^hWH6TzfWM4sX^ zaccYxq(<2}%!wtcaXf-omz@hNS=;lFTw=T0{h(bc>w>uG$nc{VgHI*$CL+QvV+!}D zD>YinMyQ_mT@+zc-}^0ZdJs8D$nTMk$x`fIb;Q73Y-P#U6wt!9Edm#gn+CCpzGBV_ zM?jLd@dpW{ZgPELu+^aeiNFMm2dHjKg!pe+`UKL%DvkzU*rC>3Wg?88NFMJTAsu8; z;vVkPwOLuZx1|dcdb@8?S8wDy)!(t$HmV@yjnK;!`G{OcxIm)KLg?mpO>(ldK&710{hJ6CtidjqMNtC% zso1<3`P%`FFZn+9c{JDi1aYk=fTtIvjbIa8O3@W*qjtIvFh2O4bkw~!_uFHt}bl zmAdKTu-OV9TYfdxXMJ<%%)El;2;(X1=o|BxAQvh6NG$Q=$icNtCp?d4`|+`-M=F;0 z0>X|m6b9IN9pZrl0sTr-!6LWiv!Jxq4S;roSnP5Yvrv zlC2$qb{K#Ml;nJY+jgXtFs#NLO?@tB423VxgpE26C-r!yd4Z?kB`%+y+Sw06YX^Um z0e1x!(rXmP{^`gO7C$-}!q;rr57>DExia($6s!ZUn4W@4zv<;dg+$XRx5FU`Dm3l@ zTx8fHl1c%C-{zt6-CV1854lT9(vi7QyL+GCXio?#%{=OzKMieuGI3GgCNI*5;(gIr zk38xyLkt}aFD(7fEOFP*oDH5hksH)`U96k3Ph`A3Gh#d4G505#Bf2-SrS`)=(RMA5 zJoK)hCnU?-OJ&tbMv@qh!u^VHOP|anh65tl*1;NY9k;`Q3pInnznK6wlx^d7ZB1hR zyEqbk|A!T{HT7DFiG;WqwB*LS8iV!CS-;%7+`*dZ2SZj0(3)G!4$3&U9;pMVRGh! zKQ!e800r8UR=vLyRhQLWXJRfZuJ9K~0KNTf^vGYZvMKb`237K~;hZU|B zyG8;uhRAJ@KW6%pZvsYl==F9%FbGj_08uTsBAP4^tiDBI;Xd|#lT+Y?02e8Ce>x4M zKe_Xs^FSk05wLy(*Jg-r*obT&VU2vzHz+XDN>@_ZU}1`R6H_RYdCf5cdd5eaKIC&y z#ibjC_{te{odS=D?`DVpZu2i0s7TyGWco7Rd7atp$H`I1fwj{P*kX}DP0zl|k4QK=SQ zsl7n?bSp(v{7`v9w9sw=At|T!Yg-$Dmz*JEQsbt2NK9$!4? zc8{D1vL_weXSILjbGy2xF3>2rdzpiP`ytX2WZj%@4oN`Boy+jBZacUW?B!z)L&R*X z9(2G_54phTK=@>-w*ArUs#Jp!KnyJP%>zZu?YsWli3GR=Z4AwT*p0+jo4A5#*Ne+e79tXlty zk{+iV=$@Uevbry?C9?|m6(wq=4gGU~gXSO*Z^*Ab_m^C-B}6X;AcIrke2(~D=O{!I zmRpZHP~IE&)l*<<_wv%yE7GH<_8dMX`z-fR*R4HI-bs@6b7@)StV0se@JcU(G2*a!4rbXyZxFP64 zs|h4mhbmF}dC&`t6O25H)D)_)8!wsrbVwhlJoexIS9I!zYu8ga=qZL~Px#WmM{jjj zOZDE0?T&lB+4St3-pPpn8dtlFCHb*&17=R;(_kZ5Yx-m1_RRqyJ zEqt-r!UO*Gk@bbH;1o5vCp zq1Y?@4}N&y(UpC{^Vt3QqA@Q|E0TTfo|bEKG^=vw^v(JWNDX)*Wr1!Q+F9gITJL}capJ}A;tKBaz6@_i-71y$SUO#T*px98?72QwkptGQ? zx0FY1eI);&Vr}*~fm)hkcEiWM>4|21Lfy9|^8{BQ1%i$ABZ^75kW4!#mHPu#a%ubv ze3B0M;TVvX1eSQkup7^i8_w%j5QgiS$8)QZobn6dJ|w+nP0)8 z7?p@y&G+sq@L13F?ofy&_XcA)Q6f?@?MY7FDZj=uR?1+{mP$!ho`^|e8k639{D8my zE@XC<^`uxrzz%<0NdKMLeD#R(OSh9_YMo#qpfcr??&iQ|GnwWYXLuE?-om!Q;{83P zraX<6I6$v<+y0Awjz=$YY!ElJhyI=uF{Yur1#CyU>RY3&xGM6aqo#UoyB;ou0APR= zYrQjbf|bdc*X)|~njVQBkCrj4SSTE3*r2)h$RelKljrl@aKX~QL;4Vrf4fYwP2vTXnjh968ZgJ6)HWN{-{RmDc_ z+KPi`?EW8K-Z2xH18r_;UftbMj0a?2|Ah^V@K3nZWsF9S5h5l5fwk?rKfu9k@SI(X zk!~&W8h3sRtsR7ols@3<(oD14#yn`YL<#$FP5W@OLWRh~oxV~XtQbE&x88txbDj~C z?;I#dXmn1S$i4P_7wse3$i+uKo=@<{4P`AC+@L?1j&4YrT&T$niJva-g5onznO@lN zvgXM1=xMQrXw^Yp%`A=ovXj>XvXsj#cfJybC)|JLxoXg68@p>d`|^0fPX%f%8C4IG z1#(Mx4lCa3Wu1HeY*N$?F{55b&CBu1`@EDU32gDWs#8V{o1e}!MYOfXHI&xrbQ<;+ ze2Kbk{R|f(mS1E(!VYhc81=t$LvnX} zV8{Uo>&4Zr9dHPkRTsDD_~|s3!HG83{vqSys*(NgGeIj#kumJ=r}3*g zk>_Y{CbV`O9_r(}7x!l10x&-ULNpfFA=A4|u$ag5E_bI*FQE6!&C&$FmJ5zNhXgNz zVmG6HCL})7rwslqHvJQN?nXmB+z73cfe~?&3j!8Jv3s%}-4Y%2^pb{dxywY8_%uJ2 zybvY>_8i59``aP1FNAYuPOolvej|>lRlWM(I=p=W_Wqauzwt0%FHf!l>;Vzy*)`pa z1nu2>`Dhjh&|C#-91<4fdB9Hy0Yxpr;5>lcRuGjsKI7J?nRyU?1uV3#Tcf`(dr$;e zquuD~R0zR;W$yxxMFcDRPYU$rAfc0ZinJL>mQdyo%&$K!hmRrannMYh;ma`8iHP-u zsVGbMw@{LwfHN=gkX%H8P94ldp|NOoX1Mg4a>E1e;5m()DQy4ym_08(rtt@d&||Aq z@Mog~@A3ytY)6jcX2w|4nLn^hkne#Z>aTpS_)q^p2>DL?+k9-CSGhT5rp@gH5CYmA zVm1}@2AX`ZTiLd!#24RJ5^Og6QFv*;+n39E8R6zS(eTdxv@-Uq4~KYKH;?w^X-tM~ zzrF7w48BoT^5VsTJKn@TeY|G<`|(=5`}xD^KiZx~NpV$h-e=#qVg6K|LN z?8gg72*4UdL#KDG)sJ}(kF_s|A0TcbM)M6>`VM3BxS2`qCc<8a`U3n{;gf)vMCS`w z!0>l(7R1I|oG}P+fG7=h{Jy2=jEe&07?wSYiPee@ z!49?5ZQVC{e`9-GY?XGHi?YUu)GsW)Nv#*()_Qp37D#zibvyHY%_`Mn5my!gBXfKn zT~Ju57-wemAGn7{_2t0YE_CAdb%&d6L2Yw;ZIoADLAMmtb2`WICn1!9^r>saEAx5& z0yDw*o3 z%M^T`rRIe+##senf56+_SW4})`_upeq;Q;4)*!$zW*_&p_N85rPJ6hU$)v)7_V4$ z1&4hsbJCik{zq&$mTp~tCdzxf{owqVvQD~->)E1*gi4@@`ozHe<;buRP{lYlbJhIuUEi_oUq;<}{k&xIgnfRqm*K5dV=E z@`nDMnlx1pDNu%2q&NybyKD*s3F}e&5e>r_Nz!l6+1dL>+hDE$76nd-J;I{2o6Et# z^R`|Ll5?#rw@Q?NQ|=WR3+^twv)H7luGQb>%R& zOz0!ib}{0}WQ$h##`e(>mfpIdudCIEOKR70OfOx8ytKukV_$@?bm@EL@l)*3>>`{%}={7In9on$3L7%6pS|d zyD_e0p3^goN$jl*>O?htfBy7yZ*f>u-Jsy;ApiBrn~z_TmsUk{g#Goe?z8LpUKsUp zN9SmB@`Nq7IDtU9K&G{M-* z+*?ILT$c;#c3OF_3q>Pr57JwRo3sJIapRN?I1T@TUb}-@faqwph6*H>)ddZ6X#4^f zkI!iP&s3VS{{j+z#^9lv3UhF zgXTX<$DX@Ddaw}EV3$V5#X=iin+)?%k`9ptJ8` zqFXGQ!7;ZH+xAT~Mer?OdvI#T%u9>8*TQ#qOw_)Yw41iyS5KYQyLHpTzm{_7$Ul(b zlYV7Ak-PQBqtM4q+myQs+FE{Xfxe_!0`tsGkWKD`v=I}o2H+32w&AkZLJ+_90avl- zkejqsY2)2TtNw;h>H}`KM)uhcEp18yhj}nOZ zYa_aa7UU&Q!2m_YcHnH?hs*M}T$k_h+rQIAl<4Ml-aUZ4sfPU)D(7PH>g!*vM~ z9Pfrz3DJ=f?J1ZkWM0u1+|DE_G|Af1MAR)Jax5;2&3nEKEp!YAxHsohaJYl%qfeX8 zkJ@GoF>~bTWw`PBmbi&*T<1X-AGUjY)`=@`*Du*9esQoSdN}MpUvT{BthmXlwx*EA zZT2s?8xtJO^5SkE33NC>QehbWNPPE5$uisG~}W~LKV>u3zQkBB$hZEW={ zLBfu>BmDGTa<&DVm`mIQvdQ}_LViYVs}kP6*ckh6XICi$X)4~7%n+}95Zu!qIMMc{ zCGn@bQZ3HgLi)wC;97c>?Az^>T8|By*5@CpJoFt;ggjh7h96pTk%z1QF z+ywdzYp6vh+r9|6ZAmWUUAYCcugV}Bvs+=~cy`EIFTR1e#*lFS5yQ>k;+;Q)~?qq_P=k^!q~8I=3b1;uh(== zOlDO>GQDis*=AlM<6PQrp$*2tl_>Xvxx6DUokQ2je94b5Ro&caAe%@7rS;fBwlsFH zIAVXun)cbpaHzjfbyZ2GxUY}ZR;H}rzMkSq~irEu|%c9m5s%+XKN3fkKL+wqF(oK zhy%}#H(%JuiS8(QR>_=Pb@Ze@Zp&k61)%I0GrfEx#nsBe@T+%7a(}`>F8)XP8!7&W zy|`vSkV~}`d}oF?^Y&dQoc-XINih=K(stXtT&vX3okNkBbkk#ejqXEpY-S6Hb++0@ z_qMf@-^4ciX|<@&W)4}sKaj0{3vV1OQIU(v*?%t51~D5t`Mx04K0)vwdO4sf362Cl`MD#_$_wLjikabHCgij7$3{>#Xmkzo|3$)D@^}3) zE)Xv05iXq$-t2E-w#mmOCD<`Cn!S$=YTavurd z7c!M5w)CXhRHAuIQ7grs=uz9&sQ>dV={v3it`h-I3g->?oJisu;taqOdm>Jto6TiH z^Z~#>Y5en`wU9@)<18T;LE83{S%{WpsNUiko}ZR1*|r??VUggk=jON@Ju)kh(7owH zC;%}ytY(CJRnZ)i*({5HT^2dy!d6-IL7Es_j7>c2a>Mn%2@`t@&gX&ULI8=uaY2QR z?T{2@z*LZ3m{;q-1VmXTVZm>#2)l-P znSf$SeLbKkG;ndG;Gjz}OAv?qEM>O^1hd{d2Miv9YrTY|Q(6V=K5f5Q5plEUfJzRU z0ZETM+cBaC4UgFO4xIG zGrx<#Di@&|!v>wEYLhq?*C}(Ftn|{*nJx3$WA1?36vJkLXR|b-#0!3cAOQyib~oa*{s26nrbdm8w*e&Gq_y$GP~< zC4(O(KV8Td?Ot+tp_%;bwX7jCe(pMU4s7Og`i*b(L}Mamk5Gt;MDI29ibf4yVjv8oNbe*9GOsg$(SCQbobquaqw=Cq%b z_Sg+QDKe(^Tjc$4ZUVLsz1I4M=yD<>mkU;qoxUe(r^Aux`FS&yC2sinZ zi*AUUY=W;D)!|RhR8|YV3j)%fuqv2}-P!ZL@V(|h?IGjpTc_VTKLJ%ysV__Ly^ZB_ zhsRjL2Un?0l@Hmt49QgAeeq#>>8H}%lltXnD8=rFV2ek*b)G`;^?i)}A#PzxFj>Mn}z z+LqY(g}jxB0!Sx%5P?i^bup&E(_|{z0o9m_*oZw{kZgEF>Mhn#54jqw`=39-C968fpB}+Hg)I&B*@gxv zSDQt4Zk3;?Vc`u?ug3eDLm-*Mm9by{8?1K z^=~kT$I-8(K>;+-6-;7LPe3&PH6b4&EmO&C8hD{ZEpXh{MKU%b6=YZ$WT3kO2!X{o zBv)qB`fvECn$)&T!72U8=a4bjyqWfO<#XYsXCUM__u$}7@;76u#)Ly|v2Ll#vt!qm zuA$a@41T*&KcBPK%RJbg8^Jh63~w=i`#_GhmPS$Lzs)an;@z`(8rK{{c4aCfW*c6+lSGa~;u}~#=QRssUQIVSre|1Q8Htq|Y$-`E zD-IaUj+e*~D(LunM`Kgy?W+1skDCdR`=yE-9g9wnY01W}o1|=h@M=Xmr)X65CprE` z-$?9enBrbJFA+rk2%1-okP}WW$nK1{QaBRjSwVLe ze@kx`n+KG`q4;k+ZB)Ln}@wMg>5uw5<#fAwjlB11s|+)gSX`3jI{Gih6oqVcw=>3&KE9>= zX-}Gdj<$6Xg%b3*e3CP3aglTz2f1H~<4|D0I|wdSPd}h(Drl~mB@&M{@68Cp$bBgD z(ks7(vSS48wzY;jsa9V5CF~2-iQDfgAM1}aV~J!t!8H_ZX3|oUQCC`y&6|B2Vh~Mw zrt11IWC@2V)Fk-lT>qLqWVrlxaKw|ke(kSzefzv;YP}@K3;({bh{+9(wcPkU`}HU6 zn3YDCul=V1+v~H>ZwzY|loz0Y%{l&rYe^GPcqV!f{&c({D_&sqKvWLzS*#Z2vz}45o8Ax+;y| zygWuR?|FO|`>|$|!IaosVY~mgknjEc*5#3)EN6|Z?~l71clYG|@H$}pSEcI5N)R`% zp|ZEj+jH&=>CY~{em$RWq{OQk!}!Njoh#4H^~+W3muVb4NIONYui=xbHxpN9PzpBR zsCec#n>lQE-DKzOb>ItojOLITPBc^&?Ly+GtzCPgCJDb1_CkV$*dQT4V!fvG#SF`% z>A_?Fz4c3YEz`c2g;-G-hF8`fFTSX*iu>yF15->cBF2vMuPzIdALfHt3POChy-_RO zm#$7%N6%j;ixh#&wV9ls^eb;cvgbcnYVwVm5k%0tC3qTema~j7*a~3`gPBe)vX2+znGLZyU zi8r83I=GT7&f}o^O3ABxCvP={#HT^UaL*EaD(B?#G`gEwI6)EPl{hYrr|DD9Cf@pU zjC`&;sFf2GKG>5PTrpLe;ay(nfctudUt+;e@}0JBmLBYhYb@^2d#7wFb&dASvr%FTXK{TbT#2h-4$ldS zno;O`S^B)QBU|t(lGkX)PeXH3qpwy>iwD(0CmzUb7eB3fTE$Wu$M8>`Fj(3!NE9fRo^gD|06`U$*Ja(apCPQ#Dq23bJY)SR@ zy6-Fw;q^J&@pfjGNLUcCG8KP>AV`u&M3}gUIsCzob2GC+9K_9}{7)UU+enm?FQJWy zLe0Tuau-vdl}FJP-i1g(EQOH*udpUVLSw&plXT9cSnIM9ly1%>E}2wY zX%0z+tq8odNYMD$Q`{)Bu+vQAM-DFG-Z!PfpVsg8Va|=*;-}ue%UFBp%3k#1JY*d} zhyBD+Y&lR2dTojmOfS(ZeHLI#5a;>-1x5eoDLp4V=T6+e*q|;=89D4c3lV^C)HLOK_7wRwcN^ z36202aszm4hjxbR7Oz}$OeshKs>POrtmY9MLu}rR_3eNOYV+91vSWnt6(Ae9vqU%o z&v-NQxQ2VnyG5Ig?lhX&@B7iR*9%Ol4jG{r@Yv?^9i_>^7`mHc-@`B&w*PKl;TJ6J zvs;7+imBQBHztQRqmnpg&6Yko8)Zn z6lg$t5MBAQ;EtSOsBYehh?+(Zz}RHOe+#`RrL3q6j_|jTr)j8bzBV&gK3jC&pU>K> zd>Te8qoPQkoHD8qu_Z`)K1AZu{(p3h997pg5=NrxQ3Ov&!Lr4qX zt;g;>jxNFd$zyxe>C%F9W zGHNgH#?SmQQc@M6_J~Nx);xm5l=v;E7S>>OUNy+UIoBN8ts-H5d{TweinTri1C^94bs#YyJw9>slkjCvZm91o-n629V zdV685`YFFyca8A7>P}~%CpZxjS~y962tZj#=)DPv4vGlI&w{x9Hs*UO=KD>_m`koE zxgv#Y8|`c01pDB+Q0vC+Z1No!?Luk(q3$dPn}Eq(6$m`^Dt@!m zLX_$9nsd4PDukrY;~J7~*!wAU1$&mUW@ZzNq7S4GT$c&QbP2y#*yY*d7S)Bamh|MH1lnQ z2}1!hZst&?F!d)G@Q~h80fXELA}l~oqDx)Atd1ER+NhV1?HkyLUGJd2_Et*?Hs}oR z2e(;?zA^n#i^A++eB6%LirM)Y+iYH7_Y6eolHOj$?|S_ssHNRYyMcrD78ITld}bFC z=n^qH4adBf4%IsF4*Hz&;^FaA8znsm^m5#o%5R|_+^5248oz2#q+z}#dMb0+fn!L? z#kA91U_TM<*+QmI)45DRW%(=(Y&Q+A>8xwPMw<3Xkn%}uvhcs~WN{JN6S}s`%r96y zo|qrr=p6S!P>9YhWV^Z`UU}H2Nhd840g#cbVnhL#tM`*`f0s&(L!Z9`z$sh^gMDgs z^p>ca7}6l$_y6fq;9TASQobPtiP~)jK?-;PiwtpoYE1+;*o`ABx}*{CxmV?wwz=Rx zlu&4q6)bIFdA;WabSMPPJEj0xgI8M)Vzqldgw!bsv0~BVNQwotHwTWe#>EDf3t|tv z9D{hk;P(6x#Lc2EhB#v4A*xBD!4%nU;T5v4hNZ6OOfWnDGtnpj$x}KTZr(o@H}iK3 z4VFE?C^`s?0y|cfFJpCLaP5Df73?2q#YkeMeA0BtswLPcrk@N3ot(TftTPADv38nn!dIAaX~D;~s)RQqt*rWtFA*Vd@LrKZZxHWk;p8`iF1Jv!?UVd@o4 ztn;fX&Y8GO9zC5C$5-p3pXgtT3G+)@SfKYo9m()sa!URFeZqZy#7K}bX(31xG@1=( zF8yqKd5Qdazj5_FR?If;RUY&XkZo&!9l(PljWS-Db03?ppk<{_a~Y~?x<*z%L34Jb zapw>Cg#nLiXLog91!wTq@eXRg+VWgpzxMJR-=poSB>m#y&O-%t_{DP0Q=@5L7hH#kBixjLt6YO>YjunNFQqBi4t1Qo$N?gj zi9FgAP`M(yhUIW$GuMwhg1EJH-1gy$-2BO@#A9*iK@clQaK716v>#o3v&k34hp{_V zPNv7hXC*x?7#4_egpXqCAqVj>uJivR>&@e#ZvUupEmBHSlvI@6NRl*64nGK_I zCy5cVR>ZU+BV=D%tb-YbFk=lZwnTQ>m8FoS?AuJg_vm{+zt{76{u#_`Qhnw#*LALQ z&ifpZhv`K?%Mzh0E_!$tLf?UDU1vvlC0_q1zQMk12Dkt>-na&QXa(hrQ0zB-yCq&m zhifA{>OywH(kv$t>NNIj@!ST&?$9}5py!dbINs(+>ne^7C(wQC&HC<3=)srdyzL!B zED`7T)IrAN5zm5W?w^$|#?q7ja9JLfX?EaN^}P9Yf_%j7E&Y@%S$lEl5w4-lp>kWO z8>+zewrh>d)nqd}@#NV3j^d-IKdfL)d=w4u>r7&Ur3h&y;XgXPPZfVU$Vox0wxYH3 zR_t55^J$(S%@DLm&=bU`T_u3ZR;vXAU1D|lnfl0Oo{H6>WxN|=i;pa&N><1whQ9K^ z^0vg@hp)e`zAwzKS=rr|UmnyVql`4YXicv#LzH*BbdJz}=V`iz5U)QpSk1)atFPqv ziO#)f5kGG8C1v=DH{Mg;cDUy-N+Y4P8WC5@M9ts&!5XiA@sgq6E}o4wyu73*Gls8D zIHr6AD>{E*Tz&J^TaTKF&kd(P9qf2DJEOd4dXFBdWnSyUW1Q=up{L6@7{batpqk{( z&IUu(K~?>*S0R;KW~{!wUEzJ&s5D-km|QTTf4cszwO70&xUY&KHp5;|N|C55i7Kx! zMPDLi69ct>t;x;I9-)rgl?x4 z3I+ez-7Zu)DIeoBZ{?wZS`=|emZCp>m^+e%j#^1UM>Bl|wnkv^o- z+^At5B=}XX$F9mEDk#^5_=js6l0>Q3#761!Lc*Aiv;%oiVp;~fqnIg+L_uZo4}hJ( zHVFVuXa>gvonQ_hsEVm}Xh*5}dcu)3Gy+|xjZVxYNBARhY;W9_aXXSg7yknR=u(h- z;8_#+Wo;qJ`8q1>HB_{pQ=I>agZhq7Q74HvQ`_8C?tMDxI}_VjDE_Q_*mmzutxUBS zsUa^g!v*yxzhJGxjJqZ!ct~?uYxsn2+mLqQgCkFVypjvL?JTpWQaiKiyya!y7ns}x za_J3rA$ub!>Vm=~EVdYF{uCFqp1tJm;gI8Z56MR9Iq(X0+f`}VrJp#iB`1G9H;54J zi2Op);RJ4Q60+uLEu7PqLAT<>j!ZSq=nan6p1$rXw%o(le3>I3>7C@9-R!`5dkios z4B^&gY{Nti(skH0=S7}bT4eM@+MFJxM59Nx7f~5_u(>kB^VyqC^I3IFjSWX?7LoLG zA+#!LA5rORIPwjm;n{c8%J+e~Wb${zB_4^U_Rh;z4c9MMl&YlmWxd~Kr7P*44taN` zMo$6um?pO)GM9Lx2^{R|q*k$ERKG_Lup@I=Z2c~EEXx?r4B!VeFV@3P^xo)-GOY#0N6W>;rLn4nGRJqOl?yBMAR9CG&( zL01~MpWYVrIuj7{j1NAJXQe$|aV1bY6Hcq`Fw=_wfbd0C2V(+(LyV6i10F=pu<}6| ztl^M0iMl<7K-Wz70RH(KX&lXwl(dNE6&<`!=2OjdKzFk7gUY=eD2IYI2>R;)`mDno zbUFw6Afh0hxe0y!tUnMlh72^iX>&L@Ap!Z688l(B&qj(kyP`3&Edl0wh?C7W@-wQYmqoPO`74HrUb01YItv+EBwbv#^I!j%>9(_hXfPikeDoVNW26F))^lKFAm&k zhSz#MTiLKbvb)NvI4sM~WRP|21=qQj)b+B(E@X!oT^?=(5@HKD zo%q4?B`MqkUXA_hA#4Cyv;nfT&B;0aQ>LfQnGMYN0zq6Hy5-}jAsDteo$6@I(0+aj z4_oUCYltOx7(OC$?jn5xxMI|b%AI-D3v>XiqRlK5{zQX}o^^K$ABSxrifD#TL=^sy z^}uIZ&A@igK`w$#an`KDxmr{{Dpbi8aKsTht#) z*B;b}`I*gftv(WzNgO%kAsMfBO(dhSPVSZd!2DBgO^bsFE|;k9nP_s8bbA9=lS0vF zLp~N_Z!yyIu%I_)YZXwZfU*z|{RDSEAH7s~dOb`Con^@abVF-D=X#|UQTbuJSW9Mm z9DA5#EIMl16-67#F+%o22vys?Jea03w%n{P2eBTJyvgURX2t-q(4vxGzbSZoxUs=# zLG`KapdO0&(L};tr_?-=+kwHakV<@1F(+i%kRQZC$MI9g^y;%0a$gKd!N?~{H@s0Oakq^J|< zsIKfZkL#c0#aD@Y3j5x(5SfNkzOE*^7X+8lUBYe!b^~)ktalbs6w=}mAYb$CF-r!R zTM$~%RO?~a4-5Ay{uW)n0v75qi%;$j{C;uZ#kGiyggU{{KdnFP`>2d>hSm@*KKn_8 zI4?x(-gLT@XXs)dQ-wTgnakXH@1urZ^30FVH)Z;+JWTMum66;bD&*B+r26z){c>F4 zhs0K+cz(KC$qox*H%2h?@Pvad=IPOXYu1IIZJBDs0$qo7s)sjp>K)^uSi zuYgIFc@`m8Y;5}+JIVyyE*%nkLX$X5%bBI1)vb=uC7XX@IAJ~YjEd{xSrH^ni;ZE4 zhcB&cH*>u5&xH<&5mn=Ci_qB61=LKkgHEgOjHHSFd21UBo}!wW^y$~nGwe`>;`y?a zi7z^{`PK(6=B0+6C|M=>*&07RID9SMr~F=3mHFi}xMiu0dWT}M-A1EVLZo)!GS|&4 z=Cdlx<-a(;AoHjV_Q_y;9U$)8rO%NhcmsD=?E6|>nJ=NeLR$C~jpDln2@ zvoN%XAr&ECVmWa%Kg1?Tg!D6gffN4ir3v#e6UKtv)ndpf=UGkY2q@V)QRm@R|66g@ zKcoGVb~tRz%EsQ@TGf1c?kQLCVy3v7Lx@VZ5`nNHbiSoXpH?-<1pqsA%+iz?^iuMJ zq<>GG6m8QRJwKF@Pof2k5}B4o!ep^UA_c1^oi;*64t0oS;t0jCRO$wd546xs1aiWMMruf^uSk>@JnEgra|h+X~Rq*JAkaf0D%HR(cX6p{<$EE({vGoB_jH^5MiChfgMQ=D8+#vO&FQPKz1^U)&i)EPH-KXn1QICf`Okg zzu8#+TyB&)C+*K(EE>b+LgDbU&rQ@uhPuYv((qbRh5#-SjQ?0nLCNcGt; zQkGyfw3|=U^}Sl;6C+LaPMo2D<5&WsvMXk4ee8vHYp#sILr-{cpX#}|e6-a(pP;F-pnGd>;2-6dY2;IZf6xgV~Y_`M{L zP%mg1R_w@&+@sQ*shn?Mw9oY**~hi%(i?%hfj=#y7jx?q3+ueh1gV{M?(v97Zod;(*7Hw| zQF!f5Bt(*jnu#LR;PK*s%n<~pP@Q~V^4Yz=fv)g4 zCY}mgKE(DyQ34=14i*Tzyq-*L$m_-$GJ)-<;BUj%9h-mx(ApK>(6ApO`>3OY z-fSrS0OHAffB?l*^Zx;X(|~+oD|}=B8t#iBxF0`3&(J&eYxF3(Z&!O9a;O_grL_}L zOADdnc0L=m^r5SO@Gz_UnA4u0xP_J91{VuU;LsGK$eAu+-u!AodJ8%THu><%-X9uW z>`_|R_w=Iigefw}rw3nAf)eJdGs*EkSS}fIHP+XQPY=9<-cq9dAFf9nBkCbPtxv$8 ziUzHlU2$n*qk-@p4s4-8_yL##M9?oY#g0NS*ZGTu*BtuJ;4&Y~RIw+`Hoc~wbhL31 z)7xM`h*OD22sFpt$y|6e62q!P9s7(dUky!ef07>hDWcY-)a<0{?x>C@8D|>NG{UJJ zg>_nE%ZvbayqHsz-pF14&u-^4N1R>nhA)|>OMQ55@@eLJiQ+-L6`^Q|wGBcd+;t@7 zULPW^ePHoQOtqi&pl>4$RU;RJA)-Ug)<-ru5*HUU2peB9J)c(KP5+5&8#wC-C-I! zP`9rWXXKnMwS3!A^btADNQ+o(yVCjmok*IAwqjA5?lSN5N#_M!N#dUGZ#U*5mc&K@ zbQH4WjN|dg_Q;=}`M4O{Mzm*STBb%Crh22`xJd#|5YOLHvei=451{TH-OdsDTDGW29yE{M2Ku4#9U+TMKo+= zIt8pq;vyIIo0cph{t7XCoB`I0Jb&7L=$&>?<9)W{8CNP=OeFfSfD za5h?Lxdpzj+0>FpiL9*(^?KhsUE4>Hy?XMeNop!R>za!X6+Ux^2;J9$zwaG8TxHYt zt2pGJq1=r4l4Ja)vqysKMB6WQlqcSQ9p`mCE1F``C!~5c%BvseF(WnV<&6{3sS&4E zkH&tA%$IcB6Y11$-9b9K?7S$Fikd5OS+hebVUto*$iAzkd9aiJ(KWOdg0J$KffsyK7A%nFL6uAqCJ&rlPVN zIhmpR_reMg3IJpP*^<$|0N+b3T*JCxHbNj+nTsu5F@fr?v^% zD~=pA+!X2s3^O#vFpBr+{;(f)Fi<%bt{zi3@P@yL%bW1XWK+6g93waNjYlIU04VXa(_U&si1$|fp5%h#84X8;1RVZq z8hFpbtnb}TfXe&M`~5}p6u=*roI#oZc)#r353#-lF9usYhH)woPH5@VBE>vwUMToJ z4Oj*JbPz5!CRLfpF8`iDMyZ|Y#KNV!3v2=$tVnRtP1(UR?f?HCzXbuSV+T{GKOHod zv?Fvjog_krf@cRPcs{b;^AiREAsJ)q-VjdE2fB5mA+$H@d$qw4DT~1282;y6C4D+F zpD}K=Y^}|RGbD4^geU}wMuM~tPTMe~eH`KBig8Y^=M-VzcgK1ht@r@zGxbOr$9^KN z!_Hvo8KC*+4aO0ghH2n6!R#6bZ@><@wr<%CZOL&L1O)?rJVG5J@jh`+&oN!XMh0$J zMv9H0_pDMx=eMo`kNs7Q5F{df9yO&jg=iyK*`UEz%>KcLm(_EV^h{r^Xh_b~ zN)9oNRBIw>Z#`1Z+}&?WPIj_vusO8xdm{PUa8V~p_%wP>zANA8rQp3vUaH%j*Z*z^ zcMvgh@uRd^E@sZ9CDGKd;a<_-i^HNtT}n?@*-o?Cg3NHz-c`+h7s%&Tr-N12{&7~R}t z&nIOO^*#!`-InwdTUt0s?bS;F44j889CQIBgVFhIsjGp|$qn|!xeO|x3wW84#Oi>b z0i?}V@cu8>L&tc)D;%Oe|BqqxFTnug-bdz0Vnasf)=0tP4>+!kithN(igG!Ntx>BR)HCF*j}h}#%ptQ z%9N%7paZ(x;WN8RX?_Z=tUFQD->(vFjy z6>fQz{jg#hhbaHR+Ui4$M!;!|?~sE&YH3DiiumMUM~1)A%_jX5ZWwh3Arq(T7v&A@ zNAA9C=a%l%JeF;v-R7;=Wc++w0qASJSZ`ih^h`w`p*dtwr66kX>5glUET+A0t=-tv zLw-_xxw9bUy6aV&4{^c@mP|{#k?jYZZi=NFR~Dew-W-mUu2D*Se#GwcO6Buv%%|c7`mWD;n?u<;GvG%5tCMW`$Vp#AAZ|X~;;}v;W0$jb zN1dacc-ZTGhMbsb-p}c}#zM=AF$8Xxxb}MF&0Xtc&isn$9 zP#ZY=(7;Jvu8*0Nwc9AFDl)wZQ(no_AI5!S-?g@QM@ReHC*F9AkZv%oD%3{|3Gb05 z6gKm@zP;=1tMw%iDN<>EIl(%_M;^EPz~=q|l}J6r5kwJxcixDt=c$!1+IdZ5dE}D4 zcVF4|wsRjzE;O#@>jeSx0jGR*(;-lyQG#UsO36!>!YJIB#RVmdj$Gge-d77pDnBj~ zUSFBWa=&M)Y$Bx~<>VHjBj@)ahuB;aXrWhc0=&uFi|_{xNRO_LwZ4n@L8^yN1?~51@(;Z82b?sfV7pcX4O1R(y!t zNYF2r{cbzu{TB2|T6k!`dPlrNtdrUUp}%m3R+JeHCJZLJ^T|C!_=JIno+-g8CoW2Z z0P~|gf742T;t-QX%izUzTWLRyrFWrhq%Hvb;Qxj)Y+a;V>+36y5;C+!Qb(rjc#pn zYjXMv2+yLifT?dVeV93zed)LyKi9pN;6nwJrKrpm zKSIf~PDr{wfA%$r4)BF~75)fw|4+m0M^KjvhLEt;r1=b*)v}E?BX*FJTe1rq$fg6} zCR42D9hX2US;saIg#XW;bDSn-aw_Q?#IlAT0Go;2lRLnV`zAy2IZ?g0b8V zg0XDch6{q-riON=XhjG?OV4qBA7*%G4AoeNIM(*9C5n7L0x6BSgR19ARJB|pnul@# zo+_V6nu=KO5Q|pk>gzyS)Vr~1^^JFXPf9&(Q~pdnnI!9B_V-Ow^S8w2(w)uyZk%^> zbe{JrqQ}4?;k?0YmJMUG5#?i?us8Oi`gFukQ~Th+-xK!FDpt_^UV#R9)wUQ@=eeA> zc)wQLt)VS>9-G*)KKC;t3RfhzM1+Lx9yRA#oCe+U57+xlg8It8D;JC{qXtD zb4hb5k?zYotL&dIGPPE1P3MFzU{+h_sX3w36&oxmG}(LUEK&Yy>MI3U#9!&MgZbOq z@yMc0{mh5rH;qF3*5np>et@BUzc)4l=DsJbt|_In2$0dqLZFsynL;XlGKnI_%0^Wd z&mzD7G@&>#wi?5AG{m&#z(7;F)%#~nb+GVpb6SAH)|(ptJ{r1${kTere_aSM60>^$ zUmR(&|7siW0Vt~FjJ6nF^x`G(>-$-P++4g!Z25JdG;zKapUO;;hH zSSFFfeQ<_bWDDR@CP(=z09Iv?D0>rrcEsq+>v`qE4P7&@Y9-`!HOSDQIBAZ%jm(+E zBXXJ-@RJ)^Ny_7hE%%hVx%ahsE}lEGKkFaUHBw9Fvc~GL!g+IM49ArU20(mMQxWW! zwzEK<8?8v{7i`W ztZi7)m%(jOv9FlbEiZ_rljJ zi?CJOZ;^O+@fNxthm%1Of_~+Vupy~e@YSeGSET%zHs3+#MdCA|6uZ<9$2Tq6n~Vx} zr1{GIlNor;wRf^xYbpJ%8nm)UxUk1fKbP=Gh)UeZ-=KdtZM)1S_s549si{Ra`F#7P zW;(gN@Vci?f@9$pTJ?E|f53{MR((K|JJspffr;i=SiQE5GM^#VhFH51PJ8WQKN(mD zTBB5WjZH20iwKZ7GqqXcEkW$lwkt3-#mNxaZpQe-wX`IryG9GP0%zCIh_8R(1f)4Y z`-K`M()x_n_-UL%pm!j-CFrH5)nYWv-`?8MX);eBKi{`WUa*L&)?C0)&fP1I2FSR3 z8QCwSdxLV5&{NxiJ`Bf&Nef(fE?woCfHAi{>j8#!FC&t>_1gFZ9K#oa61~;b2AGb0 zp>skj?ZOSE8%CrD;L6?X%2-TR_t}{tU2GaO%(LHitJYZ2>(I=7+V5$Ni!`RT<-B;l zF-jLTSun@tuBs$VrR=asFD73yzA@FJ`p%L zYc{QDYO$QDHhcFNzf-`NsZ_hjyE&UJ&&{S1x3AVN>yKgWtNQlH_UJGerstnglEWW_ z2+pVR-bz(DFK;}s_*z9_3Gd}rWoMbB-{6KL*5D+se-wDSid8Ke;i*%#y+n8;qcNEM z5<4eNKSd-nexFb0?R;BtATVWDh3O({F7+ZVw~0(+Chgy|L#$lWyTe<=$-%N<=?N0` zK0~m(68Ge`q)BHh-&qw~T!HSyQn7J+iATS}z*Jq)ifDV>O~?Wx` zQ_l(LAMv|%qnUj1fCIaXn8_M4Q>c~_vZ$#ieyKbgQ=+G*AWYwJP8rB{t6VhVlCcwR?1)w6VXNTR8>z1y)AN_ zrmi}$W3y*wgPKtq9(Mc$ zjHzeZV@So83)d?3fylJUIfyca7G#V7#Lw=H^=bvy7Q~rr9;#Z#qiWGGFtM(S=>DgR zC{=P$#bN5cDbQcrf)+*Ar)Z*MA16nXxx!K3RY7NDga)@l%Vus3Q2ygd(_uL^L&_9Fp5X`qdVe0Lx?P#XnqpMq(KD zSVk_*=HIm_&*9%dsGjxdF1+PyPU=nV$k@{quFDSO&j%;)AFh=A6+R}MWouGN(}goa zw$fGL@hk^VwN#SJ)wG1U7v3TvpmI?ZJ7O*%$F7YM<=xofU{z=OJ_=M=1__gQPibGkO`8j1U(ADnx2-7c|Q^b9h9A#m)kX962N z06QyDf`c-Uwb+97pRQEVYxhitW~{f4F)ATuT5yciQP>e%!vDAHb1u1U&;#z;4ClYu zVS)rwHf(5AcQGtJ6U|wOkioc2H~u+0q=<83jHv?8${8Obb)47-w}c@fGlVnU0v%$X z*gP!6%PUvekC6j3>{;}vMM5eB(&ylBjO|-}m^tY8x_@}l>St&Z36X=(t_F$}M(1g7 zF(L0?jRb|30@X>N_wt8>qRFkV#VuRLqe{IFmPjP0BLdCT%W5I$(=?T2RQGibq2^n~ zS9cPxsi<#JYq}S~ky_Kb+wXTJ*XHJuA|hHti5X5qSB^a4_xv6nQT`+wcipw*%=Dt3 zeXqf`nKxY<{r-_xnzD`Xchce!Tz%jd#Hw@D;@%tXd33o$$*1y!m@hYqZ`e;tHu?Cz zr}*QU9S{Z&|6F&HCi>xP{oB{gqK$UiSPu}Mni|VvvuwH`4dijcG>*54v^0ZSqybfe zUEvc-()h!57Q)*O`%pEF9B zqT8lMH5_!uBdl{e`RAh6?1wHqbCW>$5Zkl%{yv{-yuWkDoum|s%=HT08kt9uzAo*I(*!dK|& zqH?Y8ZRz!bmE9n*F_qB;DWQsItd70YUMpVVMV&&Siu}r%5qkC$rFnN(B}{XER~k|; zC>u!y<>!9>y_Sh!>|^UpvX|Jn6A$sPht%rFIbFpObf+i!RfWW64vTVK=%r&f!x=f$ zXLlp9{Nv~PleB8O3C@H^T4Ly$y;(+T`c8)OW;>$RHNai@@)%Q)dab59uHQKK4Utm` z5x<1E?S$Kp7I%?KEs~ke&<=+*uJcYYzjyQ_tZPm6#&UMhz5-z(c9c|8-;Rr)7Vi4~ zbYGy&R9{zOEU}YpEny|2tgh<4}3gS)POB1GLM*Oy2XRBh|9LRF{ye3rGCu6KCeMwvLI#E z;o6Qw@;Uox>Y)ev7Yi&tWGU|#~r!<|?k@|$)|5>dHI+U2qm{$!8t!Vu+f zZMCeKc>3kg4qEl?+@|g~&NuT=A$JtHTQP2s}qZZu3h&M3G!_<)0YWy93&sGjJ;%gkD)?-9)>}4>f0tN>wy2ZXN{i_4$!16Aji6m|?{9Ml!hJgJt%I>#ltP@YKbV292-Y*%T?rqZWP=K4P-P~2#!OQE zIZs$ia<~?IP=>T&_GLZ|*{@O(1$`TU#bNUWMq_-va$2b}gj2hLR_t@87_2|;uv-Tn z$z%egoeXs(G$?L*=hrmrJsbt3)>g6yQoK26{)aakYt1!B$$EEq8 znlKl(}Q8LaDGN`!cOzIEJ%5AhI}jGO=o41zzgnU{j#%a~8<2WR9omTB{JS zMMK?+R9bcR=JRGh&q4l0nmXY?Vavusx$R=L&`U@_=v(W@uVh^8f z<6L(o{zLYpk@O)$&6ai7Mk+%MRX#v~MA85l1vij^n&AH9;A$RthA1!bYP1Ur6IeKEzymvJ5Wh( z0+53fuan+I%2~%4@-)O#-a;k7Ux3*?oYp$CWm>X@9(J z@1Vq<$IXnp!Ee=Nt)w>y=z2Ca7X`4a>QlCs5i4T0iv8sI%BMY@1s~q@v-k({ms(5A zcU0yNak(tvd^*0aB{J1!S$9|XM{qH8*&3ZX!h~JJaUHQb{#qn&mecjqn;uM_b49k3 ziV$TQRpyxIWkF6g6Alq;HlvIOAe>ZJ;pLlM`bz7oe^T?hxEz1S^BpHZaxl3gSlMwa>>Oja4~ z0zoF?7fnduVd*inCtfgDi`W;sb}t?3-(OyagJ&qHxU$ODihk5&W%EOdmPY@Ct4;h~dG9P1#w6_D z-A(=Mez^@flCa8*BY$bz$1-2Y>hsS}Wwngw-0^>k9hBDMHdK&yB0F5Lu}!XP&jbW3 z`kIB1YE9SDm)G}3HyMb2RqIfG;S`+2W9v3&%_y%7>c9yOlR~_}#PPD^1W%+t#O(1WJS3a&ow`ZvtISURu2|CN zlc_G``T6S+C8Nayp%k^jP1}bGiJy=4|5Ob(aT9+joNoPMpO(C?{6?xZ7k&3&%c0E0 z?l1wzlv$hAK0n*q@A|zCia|~Ny+QTL@yh3u3yv2f3N-O=FjAGi*q7cSl~f_}EBTY} zM>}3j78C8_l^N|`Eg{$<)UgZv*Kjt0&%TnMoJ&EZY9<#^cRwfJ5h^)p(L|U{`v@Zs zE%}fPa4$I@AyKNF@IF{UTlf2uM&m-7LHY3yOAeZljEXKec1U*_)f^zSr-i^PlJbn` zt}OfFRd-Jlx@wSdHTeke+y3vw->1}rTJuUf{-XZnl zOHs{NI!JN_>V2w`Nnd?x?@W@D$fcW;T5Dr|CEpq+KQ1}(zPIXl&#j7IM>-_jqm(a-8$acn7oitnP7r+ghV(#u9nu^yWn|v72G9YGOx4!GK{lV(uO4~p96bqB1 z^>xR$r3?Sd6jSPFN5wFWEJS9A>a}Qr;Zy%yluH|t804-gGmAEGiw6GISiLvxW4whV zQmMgipW17hV!x9KhVk@tVnWHAc*n}}n|>9D7{LMA!HH}uw;z>~R&SVFJz^bwg>Prj zr8-wpx<;pcu|PXd=~z> zBbz~b)iw{y(_NmTw4Ts@ka7(4wJSA*BvXDsr(DIo)_rHx#l*p13YkwXNRa?0S}KVF zVog{BXTpWEqc%MV*{P-@4h%8Tq+n?k zgTD$+b9vMu!_SX7LulX9yuSZ)dk;<^s$e|e&odD4c09Wp`l_d-Gg#rKop!1dw52*7B zQ?(eZoN#y-$#G_v+2#6>8y17jOIF9&KvN;Iyy9~`7Qe8n*tm48?Fx`Uzo+*N;ZTHp z;8+Zv_z(-dio%Yo(0j&=bUjxvlKg#w-B#ZOBe!w9x@Rywg)PtvG+?Yds>Y$TI4TXy z&NBQto|1K1MXP+AB^L$b+bdXiPKB^wrIzMj*z^BTDjYKTe=RZVsK@?3d#+*z`h*Ta z{w~2+RVpbDqQKHWEmi57a|Z0wq_3nF zYI`eeZeqWXW7v!*=aO8f*MrFrluK~Th9ff|9nXV>M=yh2oFjO?r*ppJNHa_Uh$qVj zuym9o;y{iW^du~Do27HStmlzUO&}u?>6gLfkuV*f!F}F*xIn>@zi++p@&ZG_U@DCx z9JkruP@cQQYiy*Zen^Py;XCFF;cY5D(JJ8h6c43X#GkvE8@`?Ijjzy&SG214drg#& zE7+S?jz|wJX=Lz?=d5JhUupeX6t8paJA2c8jX8>pbOE<;8gk;Jn6ifG`{(6-cY@vK zX^~&$ToAXSAzIaT+b19c$B4(XDumJ>g1ZF4v~Cf$4Sw7QN%>9vSE+@-K3jNmdPSSqr)z@ zWjkJo&G0`TXQZ4 z>Ko>eu_>D_p#3mKy%2p+CE(W}<5J%Ped(w8mKx^ETgrreDXjetYCSU4MsY^YQXzrz z-9swNlzbIG@)93WmoMgJRf{vyY}=9^QD2vDY$;;V`lVFFieW)r7MJ;Hf&JEoU6r&> zA9lXfSF?w!+5f9G^$_s0n~mj4Vs^`{Cg&6FTSH}ksKGTJSLaFNX~IfReJd%ZG4Q=% zmv9QM+cr;Vw}oJ!3D3!Ob40PHgCY~9LS@Kl?+UA%sQ@j{(5t~hR0cPros;kOzm^_ zMh9hKYI5N4VW~aF?@SnT$5gW(6g-H3X&ut$91%SsC*o!=7Ja~tgve-J4v2r}cCKlR zVY*|6(NJ8m0XN<%Jr!mC^q|>UN5`G^=`OGaU~Ls-e;Qp7ydQRFjBu+~`he8e5dVBG zs`}S|-LujUJa3IER3UV^6`n#l>*8MRd-|;!VQ9Ov{L;%)V#cozrcc?9obtw2U(i&- zJ|zA=W&t1G$IKW@wcNqaI59tKcY0E(25mHqlTm`sCcZhrLPT?%)JA5iR^=7OCf?so zeZM6XT}qE0xa);?NWzdK!_y_`7dl@mubrW+mIoy9``nF-^6VIX?^)QViu0Fabejn6 zvz~9;fQn6UZ%33mYSct2_8}=J?(8t=Vcyv1>=AF|aV9g+?2~Uw5^aLAv(;n_Z~8dp z+WdrF;jy;~Cvt)2bC>mu51|XZE>8E)pxV7|RlUec=3f|MW0$huSsimJCi*e@&p#-| zE}Bk6xUxEi%}yuLgOjI%60TngOgt6Sk}vs$N)?YAbgXkY`()~i>n(O9^S;*8)7I$) zVihR*$ss85Jt7>Z&16nJ4bPW-J>2SoE?N#&7Ru|anzASqEBR@_g_omsxK0-tF>hrt%q|0J%Bx#k& zA1yu|FX__|PiFe5ghh@t?_Lhp|HDNjXIq>VHJMz*MBwD5T&;VW@{cLdAM(FpIVh`2 z)%SBi&cjFv8|+AvuM44bpV|j8ANhm${)F8VvzCcjKkc$LZ#HbEHd2gE%t&4d#-FQF z-i6)N*}6Gbb|&=#_c3RQ3zpl2S`T+ZuI&mgRAIkG@~DAvPF^$nXVikKtuO%@uZ~|3 z+MUrj7;gwKh$-TJ;kN18cQ_QUIYVJ2i!Vcl5CJ=}(NLHIZ z;24@td^CA0r|-!Q49@DRx3cWnABj|{2a6Hp3RY^}EunuFQoTRIT49 z8|3t)WD_^OkYjv1uUq9{|2i;zd#9U)c!`#P?+xvCA(f!ry%ki{$lGfNZ*2ONeZk@+ z{Uzaix%`W37-O|SW4za%K*P+1Lg9avA9d%B+kF$d$1-{c&Og+4l0ML|Sf}$#s*m*AQ>4pwX2RTkI=DP@K=4&z!P4YXk>M zG1NS#a=9iFiNR%842`jwrz?K{;BNy|wB1u7U7ZJ5DyTty??bdaae5Y1F__E$rp;Il zc0a5|bcmP@h3RgrLIOR1=Y${xr8$fEFT;$}Snc8f@w-kBp>u z#~oP{506>mR=aNvp4yFjU%=DdZk{V>;ckx1lmM+LbzVN-4DB2Nhi>GB=lD7Kz~0cg z^j9$4HTQD~BRBTtR~qyytq<%b&L>B4^d*qxmADltQi_zryCczP^1sM3SxEZOW;A!e za-~2L34itl=5?r2V(LIO!Y9V3oGH1UU-KXc00h?`E{7G~@E-7njp)J824B_?mZW0E zRi>hK$FKl@zhWD|mJV&YdTTV*lXVJmM1=ilP;Dw5=~*3AUH2 zJ8RvYXX7q@lvxZG=~Wpy7Mn0}m~EE27ru9! z%`D`c-I%z#gOi0{e8cLfyugg2F*OERX|H#RkLh+{u*EDk?CHNG#G{GlzOxx?!~;=1 z(maW59G9T}v7W~-oO7v?7u8y3Hu!B!>9MYuX-Y`UIL7m7-)fYVpsan-h-8UK^*L0& z>@s-@`3k|$v{YOva+}YHwT6B+h!Qz(Dqap** z)uO>{hFmq`>+r;VTwKjm5_oS`+KGk7S@lTW3zkU+wzy3EBWM#9p_5B(9@k=TqvcTl z`VR?hza>vNU^n>;<@P17y~yzz<8tGywx$8lDap<3O)WVn zSI2IZD<9&Mu8GARpF0TgwQc1#_3ftv>#7O@6)*Damgytr2)zp(Ih{kXEEFGKVzm4FE3jy{U$92%p`kn-meca z!%XG9IXP>O8d>fTiSqL-sTRrZCK(6L9pe`90>0`C-F(oYdrr)hKnPg#rq~h|=q9>s zt(LH!g=9B0&JBNE$h`IBZ?RoKDVmbQsorG z%;bEvL8G)eib`yDepnv%8TsYV?zG`?M1payp#O=jREJ!b7=0a>V2&3GyVqGEfOR=@ zTQKRW+_>!a_-&ggC(weg+TzmCRBWu=pQ8PvKa$wilkHwlH?S@aW8Q;}V+qolFX7`oLI)VPhy`;n$TR%Klv0{cvN3`vO9Aw^Wx{NGihTz-Op8| zwGAYgXMFajgqnvMSj2pa@Sh;Q2~)YT1EE)tWn(`sLVLW^<%nWtz{BTI0Tcvwzwe2ZA-sGZX6~p=-!ntj6N3Pu86k*ap zb4UN-O3WB3wN{huPI;YFsNkRSsrdSLL$gnX!t<)H8X`Vs1nn`paQt=g>Yfcnei!DY zJ{%<;zR>5(ltdOk?iDTDn<47eEaa&+;opL8u`W1Swdpi(Yr@`w!9L$v*Y-nCiN(bc zQ|XZ08?P%LqxDl}+PE*+tR!34NKfIWjbTv414{?QKj_vrIA-lNJYnlxD%^m(eQcx= zb7?m8R4&5`ArrU5RfsAW+Ye@Y zF+X@ed=5<-*OJp!ct3esBV))}?kU=*^&L%skac80^z+Y_*$F)mF$|)mcJ4a@=P}#(Bj&qI~nHeEMIYN?k?7jCm%dw9wl|AFwJ9I=DWoEvA zm)GZeyZ!!hor`nBxo+3>ygwiJMdHNuD@s0k<)j~im5J0}3pPzdO`p5g4N6E~amrU^ z4)!;<CUE<8l+A2KIDe*6N<=y5`n^@ZNj0S-S}J-ZCxSSGV*|7^F-K#`l%Ot zaix#u_;*f*K(ETq_@U8#a@vr(=_NDE-IidZ(6dh3_OiqIuetvnHl_?Qp7HY7!x0Mb z5YDtTGyt-Qdvnm~mjAYZ5HPfwv$y2%i4Bl2t_a;-g#wdgvU_~D4}3JLmp zzw?NAOt>j8Ej@nt<@6UY@9{Hekd!?WF-55wsvH&`JFyk2eFy^V#diQ0lLf$;_kgll zhz83!GG#awdnR%U1NV+)=yNy%NHK*u@#nm=z+Xg#GIJ_o>Kwy<`|eq1sZC?fPr6fOB7FR%vprU!CANES>o%FpW~Jv z08lKn@>bV#Kdt{nX14ihiNgcv z)?(fi5L#RfSepDVjRaV?NXLv@SaEj)dxvWvuig0LyZC@c*kb%lakbiWWVhx9 z&#_aTDP=2dDHwqs(VJ?2?9c;d@}ME5j^1BdN;BIj&Poot-j~ypAG#lH>mQW<)nj=_ z`r|d%qODv&sCvu8nWr$@Qu4_xL5e->d^MI60agwh3);U8Rq_6g0y~Y5q^i@QTFbjj z9_V9^G+x_<*-L1ZHWN+@Ze~8y+_$I{w(ofSklx`m%@j}c?SlEyVLU3}w0n7j1t2AS zQt3)o)GM{s<+3@8d_wOHt7SnQ%v1pXN#kheNngfzBKl`lgn}VFJk1b*-)C?le(?rP zvPxZmP6&elbU)a|w}8O{B?I(m02;Z<|0LC#h#DTVa3e%OZY{tVBLSc2-yidvF;qjK zhUiIr&RIn*J?VzZA}~AyuF;Fd>ezs``sPf4!}7Zxn4@p1<1rd7Kq~u(A_Fi%U8jq@ zz6+|t1$5@aeY0|$1RsbKic9q<1h#^^nr)hZ#nT)f*ahHR=Kw15P#NMOoO6aR1Pm^fI4QwM(ghML*!9A8Cp)X^RP17;;dQS6 z4KM#I%Fm`Ll%OH`dYT!38WNjRx}@q1%*IO-Hp+B9{CfT1#Xn6N6nwvV#0}I_+R@9a zH9bwTh>YlZS9A26Qn@{q#a{iA<1J5SUh#*IyCbYR@mO53C&>}!y12c#5DGII<^d|8$oRu+rafFDy1+r(g}cQ4>xi`f+d$5je)KRY zAdq4-bqeeV>Fd5-!jA&kD>w}Ja$HI|eyeLDKwbkZ=%!uLiAGI*b6vybZW6JaxB#6h z!Y;Jv#VYoWj*_z~;#Z4~nfASw2CI@{ff=xU)U$Uh4R1J;EOfL4x6E=H&=|ZXLBUa1 zAEgP!pn@wE-hhaaDEfE zJzTM_W;YZRgD{Ueo_@y&SkMS*555%E1c;o(Fi)n~e)KLG@v0~S0!$^eN)SH`HH)~3 zwNG4~3iGKl385VHC`W4op#?{#wjbfMmlC>6<;4*n5X#oBH$>-JXuWF{w% z1X`-6pXtK$KG|UM9idD&2{J(X1QZ>PTYF`pMF?zhAPdto@vd;RAjrhVenG?OON07R z9PV($e(&{XMgI?pi1x}Q@dw95axFx1*%n*s z=ZN^eTo|Gv1zN@AxReC=MED5{@>NUM!v1X!qfdYt?5Yc$s@Ioeh>&Z(#x&ddH&(*N zyi7OxaLn76p}kwI9o{OKJl9E5R!bUe3JY5%GMP$h26d`Z z@RehOahZ!og?I_QcIVgZbVa8Zbajuf{K-Y}6k=frttpRvph7NY^$~X>oMLbe04L`` z4U9!H*|S%G-orENG8HCZ+Ix7_dR%6U_W|;1yOG5dXmn7)hGL(idDWa=-4~;0g9W9+$ldlAjucd6BQKpIG5)k97mq%l}6+=IBh;zwRX|nG zp^i$@YXS6c>*lhv_d3~qx1Nk%2;vA>BIcPbd38M zTY3hB3Ihz`haiqgdhB5bAki4{29#9^zu{AUz(A+iG;$!vv~=;=RmIVWvfZGQ(NpBP z@9Ei_XL=v6fFuqBPg}9GE(1()z^*pX45^iNi5YIl_vQ~q=Zw{&0X|b2!x$$Qpo2o< zzY)wC9T@U4A0u?*EH3Xt%q(+{J5cHLUjmNmI7ueS5zt5h5iuWv#*%}^VmpR(HH6NA z9UXEnQvlV^6^65RCctUr@C!WO`Hup=IuAJi{tx82b`Q_mhx?yGo0Rj568#TwzeGO*8Z^dzyi2}~&}TeFa_l>!Y0C{W zwkaGV%YotKy;b zdbh0}#)DNPqv<$4W1>bT;wHcuCI=lLpA!uOPs0L$B-4CA>|ZZO2VBp3*hv;<;{vAT zQf)>>Mlh22w=0nIk+RxdqX$G6rlLs2U{qq<`Y{3jNZ35gRX!$Ur(~7a7AR+>++2~@ z#OXJFe7i=b2^-lyndky!w9D1eZ9458$Rmh==DbP|@g;1=unW)^0bZ*om*ny}BE;8e z|3|Yyq4|_pex?PR#Xk>TcEKCWM!|=!G5gLyFhf3Sw7fB!=OPagZhYR|)b{S1sWkIjXU@`o>)pQgssSx9L7a!OZAM=_H=mMEDG;)0m zlKogx2C=|QMPqfB02fSdhdT8z%&nFbczLkZyWO+f;>ue^3f*O+z#4dNR*7kx8nVpT zK`$DzaS0&3sFLE2Z9X^_2KB4!Ty%0fCATUl6x*9(eg0%l$TahSrcR7R%z|`&a)=}r zZwW$6oQ5o&(5@*3bdN`rjVg{PBb7Miu7a9s zUSJDx4WP5qeol#Lg&#Y0)mv8G)=H%R{CpcGGyk~b8mk;0E~w%KrNT2NLuY7P9)WY5 z>TLQ#4^Y{EOxR@pDtxbu>moV-@$bn+k4abEjRXn-t8fyxe_fLDG@A?m@TW0S2-hCj zSp%-r<;h73BNS^Y84r$xm$|y=wpj6QD#UcePIWZf>wN3yhw^T`-qy^QEnNys)9txK zy=zVI@m4LFFVe*M?EwD~%1d3&vF;~db_e#5=2S9(R&VdZQAg#fGxWtWw0cXb={NTf z%jo2b_hxyX2hhjHJk_psWFQM}07zuHH59Q&d`yA3O|3UR5>|+>-gAg?=;x|YQ+QI{ z`Zgd$iYpkFpHroFq!9o_!@MV={%k%x8G>m9cfnA0Nj6Xcm9{YtKc8?)sB~Odo9l>CyaFMBj=kKDEJho;{{D-g1DrpwkjwNM%OZHZQIJ0WZZOH~T zQ0;vRq|qDGy)LW!W<{`eb5mzkd5wXuFm;exa&AuVf!Rj($ZB5aKqj8>p+xn8=7tjF zt_wjrGQqs^UOUJqPg`T9;Si};c$Z0EvR*RyBgk4gv1`%iDuPvcwBmLawe)w&RVBsI z*ZlPn5olgjDAx+yzJlIUhIHbL@e#)xJRKT$x z_F&!A6w3}J%ZvFiaRp|M00RD}ihG3OY6YGM@8ORp(u zXk-#u6TvYqEVP6FZ<6?KD0Kplb4M1a9pA5;wqsA(B2eAWFcRV)gQT#^$9Zj@8gMtS znnz-h<45F5pO<=xrV4lqSdzQCUbTW4@BM9mhFLIwnX!#??6grE!6ezUCrA3Y$_irt z0fDxi;4VUBp%>PRPd6gm@4Jzu%*+Nyr@KMdSFP4J7W76@a}VnE>CF!8dd28x5ONb^ z^h&frm<;w%5Ysa(b0B7iSZgD-UT{0f7~h50nAFU}VUoU6o~2IJ;6Aas8d4q~j#}qM zGhWWUh^K2}GWw)j-LKLng#!nesZF%zR|c<@U9!JVEtpek!7 zTfeQQlp>ZSCQM0QoU{y!5$=Elxv5tF#Weiry_)?XRG;2@Sbt^6%tm6->pLa_M5uLW zefc{FZCUkLR=jq>j8OA0E&)5y-EJn>RonSjWecy|^|xG988(LRX`dh~;$QOBBfA{I z6Tn{Lxx8_i&Pa&8F(_7$vX8-ROzh7J^gSKJpqbL@meX&CP5F7^1%{ljV)-FNXBm3MC!XASs&qiXnjeR(b{BAWTGtP9t)fAe6rU-#kTlyxS* z;Sn^7>4T}(%JT3Ny>Ow~JDj78eW4tX$|~-jzedS+sV)gYh~b!Auz>Ql`e{K`1HJ3K zFL1lqru!{AwX|KmEle+NCmnv5CYfnoBHbjP8J#c9~}kJByI<6B#phCT??T z>Z<5jphEi|u&jnJ+Ogo0IdtCNfmDQS}E-_}?xNbhrDz2q3|tViOvm^?(+RrLR93t;*!L6 z@v>f1JnL4HyEZ13^Z@jRr&Zz+^Dzu#UBL;KfLkXpUkb54OUFE49-3Rcn60Vf3J!B& zt~Q`ifExz$)9?dr?}68VH9z-!BJCxu{M3)v)3bO40jq*b_KRyQ05mbuik)^WKZxl) zH-0QEKLQ)nHvg(8L#@9SLvs95c!|A~oyHsFalO8n?&yioUHfv;P5YE^cIw&{os9mS z^2ZuxY+JujW8qX&oZK;y)_1a$UDPQ;c_Xo+KiB&{S)=W5sC|1$UK(`0LI6c7+)0qM;XR>dx%+sN}F*-dB&uacM zlB7iDa@^Ak_Jb5_Q|3s7@mal3eP5bLNvKU>gR7J0|!MMj$lpG%ls_) z7#bk8FZanFb7C`Ihy|$49Hf!U57d2hO;=qMKx%rT!2N~{Ii~?$aP+3st!ZNZyXxjB z-gY78HG@kM#xy?+|8Ad3o$G%X?PMl}x%oJp{$@Y1MP6LuBnii59ZKyrHE~FcjO(59T|8)N zb&uhOcS(_QyHEn4Ct-Pv5EXdP!-Vz(Nv~xaGl_vTIAyg0J|e!VI8;#d-|e{%=N=+k z;_X;Z#RkXpj6Xc}U;p&mKdB_I_+tlpGWk+@Cb<{mF z*KfWBbwq}NjK}wd#nH6H0eqszE6A>VLaj&^*y9X=I<-**DK(Pu!wWizgo=M;2%ED zoAA8~n9kAF;fJDX0Ycd8lMOOg_dzBD-x(KRqc~wFu%goSPqsJz^xQa%PV9^nyG$7Q z+uzZ-s+*GXTu+H*n8ANt2wp?Ea6-i@BvR4dcmq~mJEfK7LHVbLI!Uo*mkW47mEsOA z&tMU}r~jkbAs{^iIsrFDz#*bH_d6U*lF20FMEZsO=S?R@R7Uu1%yvPwxR@PJ{IBS5 z=N{MIhIUZlos77RqB0iX|h`d2fNFffhi088h5*%~5L+khJSn zF~sF(+}qCobOnjY9_c6N+dnJ-tE;#bS$z01;g<5l60Ps(3Jq76EU-DAvbFj=L?)u5bZnjD0hE2j#jCFCT)Z>)C z2(0_65pzL(WY+1;y{yxGw<5bek~33;ww^7L@~_a{X!4Hd{J$e;CxC?UgMPwdz}Pwl zm=r4!p@uFcp;O_8P7%Qm1iYalKSSh*7f}gYtuApSr3KjpCB`Qqtk_%0I7A3ckby!GX9e7hy@LD67h77Itu$&f}{O3oi8yeyPr(W9%Sh zaCju@+N&yT0c~-s+vU=aQKxSi02j67g-A|%p~l-wp2#a8AKIQqQ(R?|oRn~)Jjf$z zEaX*EwOu&39qZjP91fBAy!Q*pTVD4yYb)AYDn!?ZVKSlD4K}=mgRk_u{|Xhq18(;J z^GjHTA6g9g^TC)ggK^-~2$Zewljk#EOlqU!pvyBlv-eB3tw4Z2qtJ$By3F@6J!N@L zpc$Kxq$I^7t(aWOEuq4}s6*>>!Twwz*PdxUmZRA1- z*~2P(Ye&jrO|9R&I#X?$YeUww zNZkzM)TV*5``sA8zt!U2fV3m-Z5PI!r2B{F?ci<5W+=&-lc9Z0lFhfCl71gIf?!Qp z>z%AuubPu{eaA#PFO(C;jiB;B-z(5!)59pAD#fOZ$8M%9%K&^j{S3aXi>}ePCe~K0U1|^&Wqq2{T-;nL`O|O?a1V_M8lE zNnj*1nJ_T3n-Bjo&hd1SjRqK#%My-x|R-%iF_>L51O)=V}2-?twEd1mj|NblClzRBCr6-}sZQ~nM;k^vRQ_oH>Ziq! zwP2h4nrOxAP>FwX-6b-0zP|%og1=puDszsHC0<=|rm2~6SvOu-3h;|FTcB#}Qv2LH zxb)ZvEuKGQkvYm2%b2I05AGoM1zt|jQnpK^lrr?go+^%&Gc=xN=dsn18G(x&g^jjY6fFi|4P z4md2^2}X{+yCucg%m8`ue7&X0kP1iHKCaVQSuJtfQj^?Yar#tod9b8e_@-cIPXO(z zog3LP;~SEzr;f^?T9u{*8<>j8YMF7P8azHFE0bN%g|nY@oFY+N|7o_QYJ_VIQKCRp z;D5R+`jOFyZ@akxl`TC)!W|8@Gn9AiL|eXe!pXbgL|do|v@=OOMKO#RHo^^Vv#Tp4 zFw=eogm0!-tg5T)@&oCMnBkbqG8j`KAg4J$@QRa!?iA+iO+wdcfZ-@huJ3Zs`vF@_nSEztvxRGOLqNQ`Vdzf zo-eG-TlahNe>5G%Th=lK%BcZ7v#hy(8w!snN*y13ver%l(MNN7~VcoszWYh zq<@$b-%QvyN`OJd(zulxXiJg)h&v)FN9JbGd5a_qE9#TD{fFt%Gv!G-Bm3{Q)VhbU zlysE$%AF=1<^^p`ywr>5I7#Oh6(8=qJP^>FyUW&cQQINgHs|dyg|F(_dS799-xA{z{MVO-<2BY3nU_-j~pZW=N8w&h3#j<*?=#TPdkKkCBUs zopK4=`1ySqr{*8>$B-y<6}m;JqGt~D3i8a7nQ zUVGuU@zzPPgUDJQ(q%Y5+8L6$X8dDVxR{BGl1R8sehIQMRP?)K-w`|^v}q(-cr|}k zLCiEg{RsDJ<9>?H$1qJnBJzBDv{|}~5+Whchy@o1u5mDq5wUoE%zGZs=|kLN6qtR! z;Uj%l<{S4{Ot-c(ul}?9NTvh|sWeBPb=vga;}`EQAAsG;_+nl<`CLfvA4%{Iqkv0O zBsi0VD1Y};X>-RDI7tQ>p(}bu{2F((rjA6K{A`)Oirm3UW9+1*32DWM{lZnL(g|>Z zzO~4o^U=;qlV<5H?@-3I*gtnstWxmc(H^h+5wsbV`S~4kd3W_{Hf|AZ1K;z{qR#cw)xV&*p&hdaoP3vTzPGCamVVaH8RQECDC7+ z?oYL`5(0SeN}$$%Xc%6ccW=>YuHh!G*pC(gSVyMw1y@f&`t86y+3&tga7}DWLX--xt^j5HJKnfoNT zSo+|B{XkKsb|#g$QIWIftXy4sa35Ie@Q?JOuok#|0-$ z%H+&_jaVS2$T|z?s+OqB&(eOx^}n&7LwzqnJsJ0bYm?6r<5dcZer8?LMSh?x#XDbx^pY#NEcT>FlwEcoG&m7=3? zKW89W_?qSBaqgSEhZ$-!-=(rbiy+Tfzl41MgZ6HJ?K}U%hN$Trec&nz;tY{Le(`?qeZkV=8%A zu_)S{&wW;sjI&QALx;(d&i0W$VQ;Y35LPfxwlU+zH_j}4k5Y8H;l>k3vgg4a4i!%z zrNtIk>XfKEPnYG~ z9#|^U7`y2W`ameysCsj8R|6^c>N2D+IYE%$>63&hUa%q}Nc~?aa=f?hE_&F$+LnFk zAY}?a+UOh3^WEc9Qf7gRwc{N>RwEYtZPW$&E)v-Eo@`iaj8jlQS;@MSf=xfUPR2|` zecI$4z0PggQqzBECnQrgCO{`-SS~lye=(>NWM$9T*6ioVd57z(I*Ru$+gOi=k-OMK zAB%wVkTjOX!VsG~97dFqCT*UBIDe$@>`>x5f1aMUVI1m{Ne3f6Qmb81KglC%Pe8Kf zV-Mx?JhFal{P)Aes5d2Of};J>zzv$_N66!4IUnlyQhFo4 zo4xH78sTpnF&Jm4=^i{O)HC?U{gYUlqli?T$S-=_A-JgED z)*mKxT<}U=|J1I&&t)t7BTC((rjGqxn*S=GR~!aBnTL|U=6Iv3O8wf^r3rQ0BBxfz zgzBNxj4N0tt>U+49(NH!)5eMGRW}#jpXWa!B8Ya$>|YWOY)?1czE<%CF9C^GsOldz z4{!~!L%J9PVZ)og4o@ir@68>MPZ>(vRGwz*P>d{i+dF560z07XEphpVCm+B(7AHEl z%Y@KuSfh2(j{yxSK)~l6?`)$@9x9`byZ63vVHrWla9l7@uie3$lWvXnsBQc;x8!7_OH)t~Yzs4ZOONK_;ny-(ix04I91a5ZW_?M!4%zWPcz-qh(kkaO*nH(ytPK zg{JW|u-l&F0+!;S0*z^78)ha{(#I8^gb)sTse11T*+E-6j(4G(lw3QAclL;6r3~Xm zWd6gQ8Rf0r0vC-&5X5*i?E^{HMV~Q_>qv8lxv-P@>GpkI+jq4jgsAa0%qu_a3wJ%+ zjoQ?ZTL5((seNCQ%7?V|C}f%{dzv@$6cA6L>_{-zsSOhk(g*`RxZZGUASVT+@!&_B zt%^_G`OMO)(y@g`l>XIhgo*fc?H5O{&GnTDcIT_i|31lOdlf0blonD?-V z4sKn-3}9V+T#q{DDWcI)0_pA%x0nDDJp-{@an_ND)B@&>+iaH{jAeSmriX&z=gTIa z3_qCu9r&wWzaCn_(CgKj}z+^T#q5%kCNVah!6wQ+?e;A!;-8Rg=&PxXgDDhLWJlJ7I!!;R7o~ zVFm;3&m`O78P#R2CuPl|NiQq4lR3b^jxin{v4875B%g%u2g~aw^Ldyw*`Q-Tqm5ME zI+FX)H=I*zy$2q&dycCJq_CAexn0?2mNr zWMjoh0OBF=42m@x40#H}7+q%qL_&WxgzAj#!S4EJ-@5;RITbIVjS&^5171!Pwcp_g z`>TyfV78734uN6(h1&0cLjHRb*qlfQxaA~(cb7=fnj3z`2IK3snF8UL@F>Z*pdxw# zU;sX5)agOq6j8%W4b~1E3*iOoX&>Sg&?B#B9?tW;sxF^)WzV3(M#jXNqTm&GVJV8K z-x;b`i;XNrm1%I;Oxcaag)6_2njk0H(NGCja1bNJB9U1FXd9+_$kH}mea$JrCFTUz zehKGb@B8WS1!%7O{H`J}d;Jtd=MmE9B!kPl<4Yq4Gp?9O(h7gNIq11xQd8$aJ#jMY zjghVas+W>9YOwU8ebe_9?A~=9qS1zzS2|PYgLN*-8^rHkK&-v9Mevs`%I2_%F0N0-o&J3 z0~#LMSa7+tnw@oPNo+nh|Jr#D(!QRIzIb-xinSyAb)%xv_OY&nwKdzTnLmqOs^3M0 zciz7uNXcWU2)t`vTUKOLpWt*L3>0~lI?9UB%for%QAt&%6(4V$B))319kZ*pQMNf5 zUCx!gFSxB~`YvV26Lb{Y%dSRcC*SB?)~SeICKszUhxSKoiaBAeWl~ZBWur7H@nl%h zN!|CJV^4JW76zMc0hP+TMzSs`v7Mb%s{VusZ1hyosr<8^7&uHuAOvZJz4?rJOk9<> zB56AcNfjlx(_`CRW5qILzeBRxTXkkUi)Uu4``3}dYF1+!DK#I5@;pH3^yI%^7iSd& zxA8XZ)dH{gV`zoN6O2IK0&76R9BJyyvdr?r9g@RrQXl-&$BJ`q*8u7Mg8GZhif*E; zqhBkUQORjtO=9m%!K<#27RC5X(Ze-OZ;%sI;H@d+%=a%Fwa>tzp`Fs3& zX9Nv3`S zStO#OZe&Hcf=+FZ3H#k2)rl7X79*`$WTWFl>OU|9ya&&r5*W=O8#mw&mIQ>76;oM(tSgRQ7R*G$M z_2e7{4&KsCyR)xiO46!^{%?HQ@oOXuLo-}ef9_rQrv+T8OP@$51kF|~tTFeh_Xl)v zM>#12HOk(;EsAhW=V$#%U z_URhTu=hIm7hHv@fQ=A2H@`&F;GAJ6DjJ=^!2#q9DOsaNbI!huA!_5YEJOJFRe)Cd zv+1oW>c6;v7ps8wcAsnMn5c{3di^)wlI>~$edZFT%vAg3R<<0-!E|~$YAfy^CCi~x zH9tvUC^Amb6J)0=GzA6O>+V=4GjkatKaH`USYOyF$w;i#e92Q#)P(^nCo=LYZ13Qo zIt$I7JBDPOZxU~p`<(|ztyp4?x>Xtt@{=}7A2bGBuKd+@UwO&1aFx^@*>JmkW_Bu_ z%6HUyWr`>cao1vJ_7Dx;_I$nq&+q1d40`c8@8ITcwn%P4hF7{0(QUkGl#&>TTjLhl zpg?X_HPi}{#cH+m_ViD2Ng3Cy^nD|>ca>4S@MeO}C%gJ@WcBsJ*!8h{wYia6WbU__ z9(lobz&O_mgXBlU*2X8<@D!~Emv!4k86cbSiFNj5r99^>ST=-3$+251&V8B^<;?|| z149ih80r%4WW}wFa4_%OBiGy{2F;JES4gx-$@}h**^$+N-9a0XbiVE zHdRIga2qkCZ^Leuw1ejhG*0-ne!K z4g;{K&75rHx)p{uZc*M4wbY2R6#eeXSzT%JL3TQ;m4eME40Wxbd0x*owgY~!Vnl>=-tEiRLuIv*vkU+yWU^m z3CT06S)g|l4wA2;8}B&&XXi`_Oz(3tIuF^&D!_jC7KfDwKQAb z29z!tIap`9g>IF4?vC4dadvny?>)g@G?aKIK-cUHiFKhKk%lKmeQYG;HP&I6ktpSH z7Rh&wa(u;rqY<2HW)rQyZ+J4-n1$BEXyr9Z?nA2u4M3pUO6KgPnD zm#i;OMEp$pa=MN@deU-}wsvpSpYL&uQ(!K})ynPj5Z5NRWd+km@jgxE(R1)7CX7#r zgZ?XZofJI^l1}&;nkml=g+eH-(Ql2fuCeS4{I+M!}UxO3tT>C-Iamzk{`*f znhM2VnponUik&J~jC(^HEL9EpayRI?&t+pzLcBI_jh^uqmA=tUC$>6t^B!K0rv#~+ zX0y*3LJ~`n>kAM@!w>9QsIkZNLX|c>rSlyL?mM^JR6S%LGMUn|&1kT^`WjrD8C4nV z=D#ax)&Pb@a-n;khHJAzuZvwo+Hkm*JP0KJS&W_J^*V4GHKSem4Az@8o1g5R43<3- z-?Ksyxh(`3ru4{-C&{PzF>AHEApc3)zv4Bpn%aQMAG^%+YxTa?!2Wku;)#)F;S*e$ z{2|i4_^A?PEq7|Qr1{#?HVNFDHqk@WQMkKYB$gonv|PM4i5Me2yW8bghs)L5xa6HF zTUkHU@oxPd{#R1JPTg`t81J-=5QlJ9mN=w=1)f}_;86okZ5Mew;qXP`{w2>h08#bg zgSdO+AuV-1RGKmqP(p^5ZGnnc7P6xXpX&<0DcOIyZ6vBOX162#Xry* zja(oShBEE)lvrMDm7*~#Yx)I!2f@ID)?u}G4hiS8xNw;4@ zr@a9%&Dt;%7GK~@&VOfm5=LO_i=3;WHBrDtOA^Etp8o%*Qd|IUhMn#&uu5goF0-#K z=W2LO!hh#_e^m$A0XP4VEgMUQqxaZ2@cZ)<9o>F`eH}tV1g2!he(sqaVpJ-p$70-W z47N>cOiiEms?1^O#TiJt(`{%Lz;D9BrtuXsYIfBK|Bd|g&!^egg-dEgZ+hCC`f9E@ zr;jDIDpHxW8Eb#rwgy!mg@0ujb^))M0K0;)f_CMb3(3+8ctNFp%Gm-+X084p6jsLEzh3U+Rfz(42f;tQ9*(i;SC_=k(GQ$ zGp8{^kAUdDh$Ly5O_Xl~A{=t{QiG@dr{$t#t@V_ipv^Z@X?L`T8B|RB{Vz!m=9%6e z;Dd@ZdJ_0-FpZ9`Z3m%&Q>8^EdWywrvDK6t3Jk?1P2sc$Fn=%pHsZ|#*s1g-a(&`K z)K?KZUt)7qNgQ85U1+~s(=4PiB(7W`-E+$)CT{Q*XP7zd=o{{)=Y?dhWx5FB2=R0A59|Dqg}>w-nWgo3C?&Ups0RQf z%W!`+EGyOD#=Vbo2v&6Jn74vZOO$Qw>ePQD7o!Sag~>~!e}viGt^neCq7d_HLiwb& zxz|5lU7_(sqN_NX;{AqDYOl<;{zs#ThA-=?DWkgXk;S%KdOj4#tBM^el^-}EOx##5 z9~gLptV%`JHB)H1L5YPM@TjnxbL{Hdg49M@{Qa$&nz`Bc*d$qKyuKgg=f=wvS|BD+ zV|qi89y6urwe1>kiBM}4Bio~lj^wiWcNpIip4n_WE7XWjvJxU!SLv z#OV2+%q34DMM)VJx#F?lwMXJf%D;CAQtGo8lTvBxzN_D`Sbs{6@I9in-Td@KQ~yKW zuQf+5Z`rFqCj1&Po^iN?r6cSPVU$PSLJ^Ie_;@Ix0LwHC<4~7nrpt1_*NL`b`97i= zt!_fc&+d1swySG`YUTGQm*E2lrtUNI>yLik zHAJ!wjIshQMIUT74Uzt#Oo3h9rDkXFNKIIJc~dnH=Bu1heSpsICixfyp;g@gxQ0KrH&5#1$+<+X z^E#-NeTnLv&I?hG;6SJU$kM0|Y_1+llQU#>uYcdQ#9YOXSB5V?RR=*vG1Z zwcU6QJjGcMZ%ICDg%O!{e(ErZaa;TBPQ`FT<|KI?E7PF|icG_CH9Ljv%%2CNy^bm) zF#_bT2XXIuj|s6v?{WHTyBzJdOD@(gi_@zyqY$70lLUt?x6YFhTA6@O?}=mPE?&B# z83-KabQQiA?d&wa_Tzh;=p{+4ckElfqYy1gbt_kwh$PQn_3;CeQ$+H*0e~NR-1{@FZO`N6t#A z>hn|UqTOq(f_}q#os0l5=agg|vaqy7w*S8LC%G^?o|~u8L|l9Z(*S1=O#D778=UX~ zj2A4p6%pNfT&}Sa?T0gQZ$(^yctD_1;%z}vav;`}kZz;P`0jJ6ojS}msTqHWU^%yV z80MStnxg|C9?uNXiZ&Rn4cAm^R#*RV^8stWX}bNp%=|_9&V36&5+uiy4I9U`8>4R0 z$9wG-EKFNXQ6cewYygnyKqlkn>E1O>Tx|oZ_V3<+P_}d5RxhHKFvGWkyb5w z8q6NThjNwqr>p06r8fIk&(aJc-21yr;uX$a1=1qj*Xi-8)ZsGSu!;@2B7dr}N@x7)6{A4&Svq+{?S*hQ^9q0F(Kau zF^S)nZKzWLlX68qJn2shhqQ8hw2DzFSqcHwO7xtzjHfjZxtvPhOyscsa+Xs9t(6CjE{XRN*-_9~k*=vuWezsLi&!jH%ClB(F?KH)(M}e&c1rB{V5-rQ)JnJ&R;vB2+H|{Bwn&iwfj@Yly|rp z|MgG`vAHR3JEo`La*;_Pv|$2Yr8=C8_mT<#|3I^4vOBh{X80$5Q@YnOZOt6CKOmfv|-Rt?sq_R-}_5kNvdJJA2hkzV|sFyRrW)vh@e}5@pj~dUc2*c*dgj& zK2`nm`f0!11GEVIQb1$OKYB%oqJJu$Dwx=qV|74rJM{N5xY@}R4e|S>Z16z4%{-ZJ zn~^LOdxQjR#y^=eQbzm{9--VX209JE?-o`d*?)fp(4rGo9HcZgY7%wL88Hmc}% zi7JhNkOtO`*Y~DBn-cMD43pypbOFI0hPat|n|zX}R(oay9h(DwnMIo3i(ct^O>+R# zvrAvvM=K9emvRq6$7;&-#jN()9XX=ea#)jarIRzhV@Ox0-w`3l!gM1K2qB*#NY=#y zkQ1nvR*T}=HBAYOe!}>dmKboSuXS*&J<_s?4t?(F=)Hs5=t#&x1E;5(n}f&s+Uj}< zsTcEHIiQeUav15lz|_2c0HnX52C6ntv(V?^)#1b>fBFYsCVNccgoLarcF@1w4sH6T zk!cw2LS?x!PRZZa#24S}p!wqceMIrwk!2E;+Z`hkZZtB27w|XmbvIdrE9M*l%>MJB zMl4zMk!LTbFT?+6dJ^^L3bsMFPS)rren|{7f6JIZJzzdi%a3?szw>Po%&CW+*da`C zQ6aYSmSS5=%=HEydG8i}$9&B$%Q+<*&dgrr%3`k=5&I5Ad&b>ijh?|o^r52!E|7S^ z{x-*Tg#|9>G^Uf)%1-qjYIiL5ox_bEF5i2hXJA74EuRt&{XT2F7}AT~F;@(7yI0m@Z){7sP6kLA1`7=xF(ETO1GRdKn}5S39OimCT-J z1C^XAn}K&E^ws#3$>vheW&X-QtoOK67>3 zG`F-9h!CvE=C%8vA*JhQf4#JY&+#x-O&K;}K%P()6_l*yxboP5=Rp<@LNSe%9q<;B z)cts&x0YR#vWtmng>K29DJ7h)k9MyO=dR4yn%{j);tu~+;Stn_P6qE_hJZ;T@lq4- zJHnl*OhQpn*biHSY0`k8-SjW8tDtd5+i#tH$^N6dJJ$`utU{OTUgkVDMrVjzeO}`j zeIWOEWVEH$44`2?edSqA$13&e04W|p<+!Ctu5A4qsLWc6zjULzW*VH=gaeJ?##p#WIJ(IL{@%VuaL^3b3t`jQThz2YO{>RoSQ^MU`@+C+*3Vv;$h z?&G5tp9YHA=R5oHo*+K=+X5y|>=BWt>k72(&sL&Jk_%j2>97t9>W^8valcNQ8_U0= z8XmLGrzOP*g6!GBML*~m4N(;?&ci!>FGzl~!cf*_&{q?HghM5K|L0wadRSP-H}!zc++TBT&fsL>6h5$OiMbP9}t zv`9D7&F{H={@FR(AKT8^=idE}>v~=4ixpqb{oIRIEo_p@IV*yjr4fk)jY2css(|V` zLI?9AC;9ti>`C$;&qN;{e}}jd;`7~Z;j&Ym<~jqpYN#-Q>ef0s^453QLgI5#H(UeD zW8^6E6j4@oRYhd1v%=-!g&=t0iL*`k;-u5I=B8mwN@%jbcn=*YKg4X%0T4Dez7633 z*!>ha`W>`MlbL&{E#5soA%ksU#j0RVtgX9TK6Iij) zD&J!|3`EI(8&Lu(rBN}x*MEeEcW6fdGkSly7mlBg63hDyq942Hus%Qy_JGNRjIb}U zp{NkH>S@Mrx=I4=uQkAF$4;2h9}h0(1&XHiGs(2GA7~{Y9kL^b;Uhl#X%TrL6ZCcW z$u5U7cVDY-{^;2}D1NmQKU%&?)8&JjptQzv@j_rWn*NE)VWBp;EhjcV3MlJoJlPZR zj71^&g4X_-#Y%R&bjyp{NL`lV&EroFj!2E9%x4>_9Qhq5;q93kMD%WP;K)G%3BH?L z+pmgabljm?wPXAP&jS{fYIaq^N$=e7a6Y~}Ftv1>n>2!N1YcQy=UVc8f#zJ*Tky9B zJ*MhWlD!1=Np)Sz&%R;=88R`u4~u@QJ#8W0 zYVvdi5^Sf#Yclu?9$KI9OttiXL(4V*sx4cDobG~qE{i@U2>t2@fQ%5{4I9RB8@KTn z(U+yo@cupOXX4|0Hm_Vz?->HBpCDJl!^hjFn`8d*5AD99zplXas#4M;WDbk#{a?j5 z4bdaW7&>Q-u2IoCu|Exow;6XB zh|ZMUYe5*ly3Ai&cA9dU@;gkKI!Z`erOYa}bSLz3#PfQd*^ypXkylUUZEx#6UMeH9 z^>(!s6$Oo_?blpZe$YGX-Y;PC-8tYIt&s33-2WCV?0#SP(=unegc$io(Wor-wq6Fo z!WaT1Zg^kA1* z^hIZ;^0DJ$N-GfqdH|$0wnxAGbh#tLT8uIC^2*J~o0d$&a-UwGAGtF~CnQ-RBCI6M zW1a#w4{_v)L_x@6W{Wx=s=n6}0t#o`F(P;dGD1x~Y3uhv*&Mp8BX>K%UGuwU29!9XXOwnKVP(0Bv{Nn4R zOc3aeU^RZxF~OV8aCh^Y?}%5}ijBrumqwQoUD0{VBz;C6>&()%$Ow6uDzB*Gm=n(H z!`=2Hwrixs*Z%SjTXA1L&^u|J8EIZtfzG9KFABiYi6RH=h=muzr@7~JS}6#%YlcvY zGM`LBvC%iPhnaiXB|9|&*8HhTh6DQ z7zvq~=(eshl;C?&y6i2^uzvNWiRRO;sx%a!Hp*!Tu6=6D^lSn-St+{xGl@;LOXdpV z9w$P6Qe_oEir-$4}^N%UpiVyuQQmxcy zepr(z;h4)fPzhC!kRl`hD~leXqaVDJA;fj@DjweqCtyxR&Z_PTS4(h>GlpjQsa2Kx z?J^v*rcjw`S4z)Wt1jZC3<1c_*@fyca#V_) zx0#d*IJ>WzMmuf6n8g=3nS7R^ir}&6TAp=46r3lvPyCICC(%cwIU$akAt>7ORKnGK zsF-W+NlF}1(Czk{_Nb2&Svb4{Gyq#GTd!;4TW5`62Aes^3bCbSZk|K#==^2MmPVg^ z6fY9INBen?K{a0HE*)y&RQHpri7MF0|HN47SeNO=xw0q9_fu|3$^Fc>vF|k<+EC(? z+0N^iS2JfXwy@^Xv-CYtIhI2icW-iwM*frr5%@yjlf(y1#cU@H@r=T61SM|;gZxCg zO0;4g7vXQ+L(T}O=c?1Lgq^<@^jxS6FzuqOBf*ulqNS#<-8vADE10q7anw3U<$kjn z_WiqVD$z{%KCRyhWNYZXsg5%W$iAReUuh;9mp*18lnxMGhTW|9ZC^OE2lUJ?<MkdhjQxubLjDo&I-FoT3rZby2E6YP=4!#pW{GT&@%b4?w?$7#eA&VV4u@ z?5(ZLJoQ9JXjihx%*8g7PxLXS2gQXF3p5BOz6mO|kcC$cTjVnOiixysA%*p=f>4y? zK-KfcB;3MbT7wJ-wBfD=U*jnAwmB?N{`5I7=IAGb=%SS^~I=f^pzvUh?b+~;ZpJ71-K zqbU)02U1Leben>r6H9751kP;+-5(x)Qu%}PgKh{pXS=mKsMim39Svpq4)5iKd#VVq zT+WT?H|Y+&{cA-LbBVt5exvzk?2X9>y78FVxhz+6XaCz^{TRXq^xfr3x-?ZKtonkG z-FbEuKqV7%BBZt%0UGQFDPXl7%w^Njcde6APGClP@cx9-OUr?dQHltjL`xoi%?V-h+7mz&3f`5LikH zS%6Ii-&i?4MN$6sFk3}jReeBiGHs^&MMju7k7O8%lW&^*qrXPd2+AiAZVwEpdBJ7WXbDyd{p`#QBxU2jOovAM*Bz zcW-P^s%ZG$!uNzw21{jZ8`$a>+G&(>w|6Z6f$7C6@m~wzpw!=8N{f05g}<))>pkDt zNJ$->S@17l&OkZ*T1}O9dS2=WAF6AED0opPjhLS0k(j1X2tXl5aX~^8u^sArR-01}d;RJm8ei7*ssc}TTrEwnIGV;>uGqUqm*Sjwjk}oW4qnCW)^?56y6V4B- z%YW=y&LWo@?(fdddW<~#W*)!yV?PhZ9yh88O!MLCr4urV)KFhu*hAA_ggdO2u zAlxH6yH1iXQPm#IwhuIXq||@p%Z+G9T{$L08*A*|7jo5C-#;5r+GF?n{FZ+?Vwk?q ztpg%Tf{$$@#f0iMjhlSoDeytJg(}W6Ur6RpqlIC0-i2XX>y{4DG!tt__(zU4nM@fv z;?=04=K)g!+nV*jNh%;N+)9~{^Zu(EqkJ2ChAhn zGm+28Qw343ONG|7p)>Mg=RS@A83yCevL0pjAUX`bkjvHd1GwIMpnn{SdQY`!FCvn- z-2?pa*k^Jt6#KRPHBR=8PDK3rhF} zsp5e1EkaM7vRFEKHY}woy}M9WX!FME<%f+dqRJ7c;YJYNV}M`$#NjpOn||t&!Q~N+ zjKHc6(q^up+sf%#J&>GVylmx zj;Q^w_-4u@?0-%tWM zpb}uHF6aNyp@crfYN!$1+N3xuuXX)9YW-mrsWI^*pJ|D!M;VSAVav=*M2vz@dTTE2G~ z6|Tjb=_EEf-JD#89cd$0uwe#+guo$<=j&?eAdiWK&RrTFI_|X8 z+H>lGaW`zdj=wntV5`b9W!#5u%i`KK;#6FvAAJigKNL zCfK9^L$#~3#mKqlJFSKBJP9>atj+ySwPg6#&NcgUA%lIpz+CPO{S0HOzFe;OmbS2I zo7D3Xf)ij{hZq#&=yuM#QB{%c`hhkNX94q#Q&p*u>`Irl0SkSvUZS+ajg?~s0=hW+ z4VgSVItYCI?Hw*Y0|lY&Z1%O<+y~=m#eFo7v**H z7syM(okXy7jM)_O%`lHEKXgrsK39ucXE)th=wqWds^w~Bg|;UoFL3 z(go!kutD!q{5|gOW7Duh`lHCKQy1-@Y5bZ)z|R1vj4Y}2E+FFj0wEiJfzF|GC=d)w zS@3Fm@ehA>*HD}5w<6>UVZ{j(XQ}QrZQS1WDe`hK=c0nE`7T#0V_RJvJ}cRS6RTad z1!RESmHLBU)QIc4M=$eloW7&F#WrH|!2tTV!fKE4Ca8I&E{&Aa&DvfnmweH21JC__ z1qxB<7ueLjX=PkT_Uac}c&hH2GKLJ8CLKvgI_Q;%5A&ooPzpz!TxSmVIlXp_Y#ZHf zeQ|=kcCUj{E7yA9YINzFKId02j=tER+x#@su0KV8h?c)1aeL$a{rm+-XEJrz503mm zul>1%8eb3mG02!3(&Yn$*>U63$VibbDC9Xu681AJP`$S99Tu&b<#LG%GB*paykhB4 zDS7LjcGI-~B9kW&J|n$0h66>npdhcGrzP6glAq^NlZ}H#J*1 z_f47`1Q3EIC*oN%34xafvQ#;1j|IaiL^@4$@1@EjeO4yG4X1+K-QsWIgf{LYpA9B{d#|BFF{Km-`px{_)gAP(_fZHlSAEs1jMj)NNA21kXMb?6 zmk;&h^ppNPmh`j?m{5qYUmMjmE~j-`gjsXGa%cv27r+{mw4xNc?~h<3WCVM*8cKj9k2` zIJ+0B>)_D;asX?Slc|8x0FZ1A;52AvBA&>S+r#vjI_0BaJG4)!3juK2w2Pk@m*&4y zT;vz+QJB2|6LaPJVNHmMxW4FNkevT+`0msJG)3Su^y?)m0SE`!C(@*KjAv^L3?Euw zT;w;|rgGy!Pp#uFjNFO8W#46Sy?BwNEc=9OJ6YHCX02JxsZN*gVg`4FP{FP+^Gjkx&w0V+dvFbUj`wP!;hZsYzrfH=?GFwAY3H>u#8OMz+An_cFs!Iii+NpZ z*VMS_e-s2EsuE=tk~;s4oKMVxVym4^nul+KL)!ZfTplWQgus6@ZNT@Jb?zVP5Px|! z{?~mr6E>JiMDN2cmZSWJ_)MVm|NV)Wi|_zF{ppP*y9E`VXU0rr(kiE;=mcw*6N_Qy zA@;5jIlWE;#a1GgvPs3S)eHjFXuxBY`1~pF0y91ioKx-dhw_jKZbhl?U*GfTT7*w4 zt`>@_gMS|te49C?h6x3#^T0o_dNFMaYcal;FtLw%*aTGYDBUR*s#rhl?Us~N<=DE? z0nrY?S@8k;Ej{`(c@U`Pvv^0p?ry}n%yysW%c{QBGb7yX7oDoTfN(tmUs~f82rBA{ zO3^Pd5a$NnFsWKCTT|7rEI@{93E9_!&;N7Pk^3z^FRAv7xJ;@ubftDU{lP*~b;UkU zr1u*>GPLHinY_X%^lbA-(NI5D?N%p89Ssg|&N!4@@N;;Oir?l5OIo_<& zsyQhXd;d6!*}1b3miR6p0pLspdNK8{oyZep=-qp@bZQX*De%3`oc{3EgVRKnRK2y2`|OyyeX+shSD7wiW@D(y zz8W0pN5SCpJx*!PN~}6*+AGPYaiZKijAy|BNL{!!Qh$bS)V_d^qVu~4YKp_ue6xye zDc^c%@eA`C;z8nmiCDl>p`u=FAQ9J~LPf;F?AdQEPw%W$q$|CxPooz$8N-+ltBW~C zER5dX9kR2^R^KXl<4Ev9aZnUI%gzR6zm5Ic-7~+Zw~@YQ3<%1K5wIu!>E#!D1~J9X zO)KoIBcu)y?_R?{xSW(Z0dloi>qk-FAU!S7H5H3ME`X`>fxvs$-R#w|QFNL(u<*e+*!i)ZtEQW;S3OfOwRh`H3kuD)w^}_`kVo6O#}0vA;jM7G2Rxk7(V) zwG=s2NM)Z=KenJo0_gtYT7tu$z4^1&JV4w+q<#(JRn-+FtRfJGV?PhY&1zKU5Q$~n zsx1D?6QwWmbyeOF0B~8e&D0=7^*Yi?8@KmvQf7;**x-)p)Bbv^i)b{9^SR2sutV|3 z9gp$z`eCDW_jQP=qxZRMkJiyjZqAr-rpM6#x3_-70%^!pl}JGFh6iLj1K8Ta&^kbq zw!{7KT#SKlH!TPCS2eHT=uVNjoL9%Rd}IDDEj!{~K%cX7*sqD8srB5-1T9>(P$8^q zQCmqHHEgfuU=KaNv=aHLZ&O$^tzI^Qd7y}CO|^?F~(+c+OpUfg>Gj& zM*^CPVAGVWQ`A-_qV7o(K_xjHh$g5xP>D=ac{E4&GVXT6d(hj=*H0GzbgrXlBHpQp zD3;%AaC>~YrhTZ%qu4viGf{{d>RTnHB#!B6zhg1PXw4M`1-`j&FTUho!b7y`H-mA| zp0kSK%6cI3g_s2Pbl!v9FS!#LKQ=W7h)f)twNA*Co*$+~4dX^1ii&{MxeOT~JIo{O zUUo`QgUqa*2zg)S!RTmhT9 zD%O9?IEL)ZUy8*{vl?4|-Mcqz!12+tXk#u{Bkm17cU<|~?BMf=X%?(xSsV+Z#uHf$ z6Fz&Ch|#a5+RZSs2f+!mJK6?spmo@>`mRYCYN~Zrf|b;1?(RUAYeBQC)e3C6o&Rph z99Xl)DPGMHYgdKr{5jn^g3X0|oM#j$DN}G*aS_aDrA6hxm-D(7x#G^a&=9G@4v2rA zoU!=q#t&4(9s6%-`N^HRLr2@L9B4t|@2acgE?(9Eg9F(O*$(?DXtffW_lthbd+w-e z71qN?o^1XslaZGL5_+_`;q;5Ts^UAoh0c&cvO(Yz9s{aj(3>3@Q0&?kv~C$Bbr{0= zD46SSuH*3HdHR3WTNLF|c+NogjCitGc%=^1jZ&K4BBW&+F?apt3HTj&Oldc5IzJj4Hr7d0#>u3936w1`Z1Pice{hs;pVwpz|G*iu z!TnYhTgrt%GoI2^UtRnqo^W^U^+%swV6G=1>hzkaO4P&=q5u8Sn|hwVS<7qiUo!F% z%98!ydE9^k#%Gr2pMY921B-uuNN#!=H#qbQp3 zvj!Tn>D~YEm1d(ImA~uF;_9QF_(#hN76%M-1RGQBm;%G_rwgZ}fyICZ5DOgxscLgI zX&1j4@tr^Mod&5bdX{+`17R@Qf4`yE3xFxoh@yO9tVgV)hSQgCud3M54>8;T1}`k< zTag>4^JgjeVj}z9vv!T_>OQj#)!iKU)h*=MFFuQH4t>?FvD9ut$n$A-J*f8;9L5+< z4p$7PxlNGFzZ1#y$AiJ|)t|KSryU*rn(SpF$adG$5B(N0fYX)GCrYr>)6Vi5gr{a! zM6I}Vs<&t2yVtM=6t?f7)6zLk>$+iK4r`uof2Dd~TS2v)`c%YgJN``cO4Oc0>kqn( z{g0v%=X_n2wuF??##xc{K%R|pL+QyBugTunE~I+vVy$bE_deByaV`;k;;jkaU>Gyv z&&WQJhZ@@4+SY6t$rdHku|~6S2|A4?u5?X`Bo-+G*}RUDPitF+;hOpgw}+?2z)-qg ziLQYu=Pq#aY#6uHs5idk;Pkizui%I=2jXeD-to@4y8W>Dp&U8{y%v$2K*_GYpt$GM;rBY7Uj4 z^8AY^r8U>5Q#%n?L4PG1pqA1HhJCPE0wYE0(i8o_vmm@2VZnQN_F| z(1^V7y|F|Iu`)*3-GgVuJu~f^T7~`JkCcAlK18!#9G&MK&?}4N5IU%T?B_hT1HQ2( z@i)yy`@m@YaF>eM=S@xbJ45{ABkqJxhgtoB&;up@(L#SKs6hWqp|30v0gSI|ens6U zHKctUEG>6sycp!4X2Um%_wwuvdeHcMIp7@&KKiMho||1%y!#Hq7dkKWBS$>lD<5gQ z@}@nQm6~mNeO+lwR zUo-1wND%Id9+ELFUrZp{_ajiAvgfe9NwvgZI)Zo^&Xjd2kOOJCN7i^NvCLlA>NoX? z>=`PzHw#P@Lg-E~MK&QYRtXPoNY^?n1v}{S?gHM5dj>&?cb7&&{$Q@UsKcJAXxf)X zn$Fx43;C;yzVOWuZ{tqfxD_ ztx*^-)%u4WyB9X3L-2PMsa0766DnQq)cYH?li&ToxH<{X04#i~^eKr!JA~De1Hru6 zVirj~@I4zOjI=JamZpcNm@$Oy`k-*iUPl{{hA1Y9)Dp8-e1*JGuRAhCS@Fe>1ksG4 z7Lu|~;z*kxK3DU8&VMS5J9}3T=mXK-Kxaon;p=lUJ_iFh?wn;dL}eyFyOXak>kPWeh?a zjiD_a&l$V4p+}A(`4UW#?DE=xQ<0{hUwoowUH#Fs_|oq6mp^U5l^E&Cn#0!_S}&%= zIvt!Y!08A*yUoyT{2vY*DRY6s*o`cG9w}$Hv&t0lK)okGNbTgO zhJp{WGova+YS!RjFmC=ccaFg#q7?;l808ZA59*5Q871dQl|cbWvUQFw>3&5F;yI-} z#p7!n>B&~41dZnbL=G^x<#W`7Tv<29W+$-|6ia?7aU<-OU^H#=#qenH%;e{@vCGw> z=e7g0OFCV2CGLPrr(P6&^&?F)@5G%^+byDN7VhhIH{7Rx$u^ zV6@3H(VP!0I4EFCRu zGfNob_tS@Mk%F1lV;Phm3rU#+T*-hY92C!vtEB7>D>vv7cD11v`udbo4A(4vB`AfS zXPA`7npYnwFb3(965^tYL<<1lrriU5ysUnsyK~;axhBa4 z%$Q&4p<$Zk>;>r3X%cI?S>QqpB%Ir@$}rmb@js|=U6iW8B>#4}r zzv%z+@+zs+JJSe{`Lap2dlNJ8SbCak(SUf~5#|e)u908Wj+UarWjae||Ad?{D?x4S zS;S+1Cg*h>7L3RkX#j>(a4L)MXbyeB4(6Nx$(wcnH)p-YA-yIKQF18m6H0S9Fo-C* zZW_kve>OB$o8;a+=158h05vBas(E=3>uc#MU7@ z>e5vw^Z|{0L1Z##ZH}Y=f#Ekj^0)xZ+Q7EqVkpu7XJ;;B-{jfQS!x@;rvm=p&xdvg znQ_$P&2W(wYa!VHdA(X94b`JW((Nyi#HcCvNIhI=KhWbwxDkJI9a%r@(mYvLkJO%K z&R~qkS6;1&F-@uTM7CV%&Neco$;!{j=Ae`a|G0^mpNUvFRrdL_s&W7%oG-7w>}lE7 zh8wkcTdz-{LO6#S*Wd`V7;d@+Z%UPcng!A`%fr0GG(+`k-a+Obx(mK;NykISBlq&o z@o;ClP72e6V=|z3iB8SQh+`C#R6w)Sggi>93muG9P7trp{~Sk(;~~WdqxSTEE#N=6 zpx$bWK2JvZ{ zWssa?s85>VV6)L0;)2rJsVQ^a=;p6MJwd+rGD9b14_6|^S~{Y)Je2Bnuc#Hoj4gv{$6G3zvcrmtt7WYB7PR6!F)K3N0_lghlk-N%OY){6wTyrGjdU1;@)-uA)=<}M$>y5v2 z8zde%P6DsdmeG4depx>AP9@EN=8u(2-CSxZbLc02m116R2x1Pp&o^~qeaJ(FcanTo zd}*w{ zkI(;Icljs#5%grqRvFmJ5-j*IY;>4D@8|;}ZC3Vs3?Hgg@PVOKFOnzt#?5xIUXE4$ z$$r|dEJ2Mb5%seIw?}ge$j>0I?o}04A`@#$u*l2Y8;4npMK)Pu_I$#eG4Y03j|5C1 z&wqrnhTKx2*nSSKfa`I9YsCv@onHL63JV$9;1>t(J^BDZ7EWy~rjaNxmvA-ckobF3 za}FQ-`SvuR{nw}h8mqcbziO@eZY1g-N>G?-Qw@@(2{r!D(iN90)oX)16zk6tL&Pm0 zN?tW_+mhY^vL3=yFOG-3qSsZ%qPLUJXri7OUX$}TlRiZ%iji1iyxa5o%A`DqoE;8o zX{P)$&`((dx7uvcKOW`W`M%hMP!)ELX(rI}{oZCR7o67%VdDw%ob9(H=|=PNTE&cE-qXs&~> zIC6BfvE3U)RrhBqAr?mxU-mk(Fky@Nceu2PCiP;aR_kg-|13yWUPveYo|y`PT|b)* zRPGfFT{*tK>#z`Euwg8@uxSj>6%W?2@bq*PTXMLA+MT9sQEI^j&TabwNEb@od>sH6 znxLu{civ@wHg9v7E%_s~!vz(fR-{K%(6Cc5KT=A3^d|G$xMFsK@fIJi!=BeC%UE|1 z&zYPzbN+z@UG)~8AO!y<#*!_3g2p0W7S9zGh4QvU;lL4$vjSIBDJgkhSJg?bSE?G? z)<$ee7y+=*+vocr|0u&021iplR4>zwrF?_*YQXI}5WBc=2!ia64XbTw@Ca<9kA#7V zUzLv9GC`$V3h@#o=OmUa&@W}KNVW!Lu#5a?dOz@{_r-2e9RE`EFsa;}uxQ_BD{rx4 zoBiSO)a!;R*EcQsBppGub|HpnLiE&>%2u3M@TY}vsSVU`)5ORB0A zh1C`O6&qMI@WD1c3|b8H@ElFezs&GyCdF_ZNq$%k`>A5wsFRooJdC!Xj{`}bx}<_A zH=GI+7pK*+KMd?(O`YA|XF+8aKPdmhM?P*no22Xd3$?_55LGQeEl zHCdk;b(ct~4XgM9)L|n_dAX+1_tUS&MgcnB`|GRfu&bX#kS$60?|IKRF}#it5!M5F z8_|evV?f96nrFSKuK7?>NdA?B$V65y&Gqmv6R!0kT}j(+MXR&c$r+odp`Hj;>?97= z7}66R#N|55-VnZSmbnJ+kd)QMMd?pSLRzmQDIG#N6X_9{tW|V?ffhk+InNbP)6V{n zBFqdk4WQJ5U3~s+%yp)tk>)lw-@GnTz81=ac=c{T!vC@BG>%6aH55!QAbi;&#G;pF zvpcATpt`|x){uUS6>P12DJgMHSw$efq1|MUR@aVdEAfM1+=(hrps^58^p|zr)+I;mhnLU!pRc$9{#B> zHYZ1R4MpgMqTn2eBCM+!Zr%^zn>mE9xa>Bf%pq=(IG!h8V(beZ5*cf<3m#O`wJLiT zv?bkl#OD&NXkwqy>`au$$Yfb-XYuANkdrwvNxETbT3DItQnraKYkgW=MPGKpo8)h( z1ziw#RMF4FC)9Ue1kIk39U0!Ja;N9(VzmF}Emn0TcdvAU8g*sc-F=l0aPJ$QnIw$<2NlkEV zQTPz=S7*G*rTRhk?!{`8T?)8SF*8qr5)_d!OzBH6}rCYd1rVlvXJR$gF_FME$K*G!7{h`Gl@u6jRv&myZYVvv1^ zxz`cy+_o^!xN^Rv@TEv>^ZpC92@y=q;1`kT(F_+sS0frF-k78Xq6s`#S<1XA#x9x^ z*B2G-c{(gwM~4S!V~tWSr{u2tyVC+ss6}?#s2)WbOW?0j=zdIzi)zi2NBv{ z)rl;$u@<8ur3CKhy5m>XmnCIb-S`uz2Z+*^3CmpYSh1z22v}ft$X|Mh(8WaZS3q^q z&3h|=BcI7A*|li4E&-Kj$oeai+n|;8yx?ZhLe2OilX=oNZ1H7|@CyS3k6c~|Ir4R( z*)l}%=koJ2;mx+lzw48y7~L0wzV!R~bCzXf$+Ws-s#D#W&N63cP#$Q2&(hUcLIiCu&37Cb=(c3%KNiIlq*)2~jFyBE!l#G`UY~XTIFF zxjJgSpZGe=GR)%p&FN#q?n(u8oBKQ!$n7N5*do-a`bPdVUEDLy+HdWpdk1OF8J@ad zsjFT4bx;d;Yz@zjBC`EzikxqL0~Z@?>%`RrPJJn>#B>)P!9J;6dj`_}X>u;XFt)0# z_&CkuWi>W#06Lv>0YoT-ykPb=uA=>uZgSMgg&^g5XKIm;xA^oh`x2sBEsmNuCm~Lu zrU_}-;bvL$+gXcFktn&}*gR~=QeExs`Bn8aY*Ihq?~d-1?$RPy+J9Sk@7?TsWAUsT z)v$aC0*OoM@;FNq1-^&IDdr0$D)@R-JCk@Ud{$S{4JWqxYhPYEnD2`_M8yrnjpAkC zg$(bfp7%k1m~eZN6+Hh(5vg^CJf(STgcaE8>S36OHbbXO4mKCtFSOLaWz$GR7 z>yt=Hjg8=hsvvu;q!dxA&k=4c(I~(y%53X_2n4Q9!<3~&DpvtIjR7_Xkryfi*SrfVP49Aduwxd3~$`8HKb@|in~qu!|YH02O~+fz-kP5vfgV&~L-cIF3NiG^cc0J<&lL^9+uw8`a2K4=hQx7Yiu zFEA5h@$3zx%X)E6JLZ&x_+H|TL!pBod^}le%1l2jYuLy5q8ae|4JXEIU;#2xdD1AJ++ zjV|EZEqkoPl>O7jNo#JAl_D28a*O>6W|Z=)dvvlV3a-wo!hGwas9fhP4SxfLUC>^^ z-ZYB>`j0mxMKRzY(g7=@tgD_wB8_&+vS|USG^Fav=S|+7H)T;h>Y)2mxQU^q6MT~uz)0z>y_%>LN7%3Fp6;$G)Th)8ht6~YWzG*pOm%Cnpyn6;@d z021ybJ~Yz34RK}rb*=azVHMo=b}s3qBJK^DAdX15{~%jbjhUinnwh9)s=p=S@Q3g` z03h$HGoAru2$J_-{GvcU0vDl&!^KAMI2kx*O?)Ky8vcZ!Hsu&}Iu>sVb_g2Z^5k5c zgR$cc`#qZiOJ)omtZ?6BepGVPjI+DQu=$=)ab*z{N=xS{)%_BTi*)7ba`kk9D)+g;)!JvIYQ`7$%H)QM0fJZ*U5 z+klUe7nB8M*t>jpcXke{WM-O#x^oHGO83mHOT;*Q3ta;*=wfmxs5v0}F1%U2X@4bD zfLwd=(4yC|H}I*2W4IDr>aAG(f@6Xzi zS{3UNvj}uA(<0$zqCmP7fNjTcoZX94<$Bt#h;qx(VPhom)dzyXvwoV~c}aBnS+!9L zR6L|~@d?&{BeU1Sj#9=EmocN2UJlyvo@jU^r`k_5X=1N^*t6l|2-3~$Vr#GQ&h5GT zmq+lJTdy1wRJ0puU(1%O?d_UW7sPTk@yVEPqsC(E#hUrqblaKIZE~{>beho};p-9Ct-Y-y_|kC# zI+k>H-k@OXwy53n6 zalJujL!fBiEhxoG=Jf9~kl}+%nCn_ZFka3}!WdN&Zam~R=y&U__ zv&&ZNUO-;cAUVKHepRmK!iL*?aqz*1%JWlJCo~9eX;X0q)+aD6eg-EmtDj& zU}xopjGcBlDt}H&V$j-h`mCfG&XoN&-JTuo!U{yJWKZbf%IKwsmK*tFnrDc+dS~Q5 z;fHg70)!{z^S>}&UdSn?&7|ALBc9Ax2*I~X>9UF;+&~`=faD`Yey;hUUHwIu@RHFz zTr*kSTr)Ya1vzc4y^H)Wl$Xc37YV-0>FL4`y=H;jqm`AyCHqbjM2N-xlRe$quUM?qc zP!zs)XF78_uJ=~A!DHnvAh3#g22|MQ;mI&lYrto{tcN7w1TpPzwv{mn@30K7@U$OF zPa?gy=E^2!^d~7bU8To5Qcxoa@>%u-pUP@rY#8h>v{vcI8e!|C=F2z66*q_$3dh#L zSBeYHhf5DZpIYh!SIi@;)2D83>UU5Bi*-ADmCRBN#{D9<+xn5p7#%%OG5OVe)PyqZG{6NJSMlyAN z|65J@k9-adhN=tcd*>2q%efYXvGJGv&;CatT_9`vO{SS)>&w4TiKtNRe_CZ-2~#Os$pE)p9v2 zVgvK*L0%)5n^OH;)5KkrFNi-TZKjy|sI|`+t}WtQud$eWl(##7c_3>45aZp*lzYNw zgexwCZX<`xs2-NS&~YBe8A{CC^Tz`#^2Dd}fCsMgC=cB6`i6Vq#e`#gQw$KE3vJZg zMV^sa&i*V36XoDkSgTmS~Fbl496KKTJ#1cJ#M@~!(F zfS$!WO;FR#l ziXbd)qUKmln>EOTO}Dp6sTBO*!%CJo+KagF1ug@RJD24=ZW9QTV-ur7jZ;scveIl86U51k$a=(tC@GWG_? zngv1yRv-;WH?*j(0{)n%O+_U1-6Kz~00>b>yskzso!P`V15A^y2Z@_ZSF4l`Xu(S@GxVP~*T8W|c7Rd(BD?PHo;xot6v zy4zp!*VC8Hw-n|!a};ngG=)nwIjj~mVD@iWk8dSV%DsW6pBJPsp6E1Yhs^DbFvV?( zq?XbQfCTavo)?JzO1LuLTUekwB31Bb-~|H4`bu^AUs0CDTeMj->eduuX1iOg>}F-! zPbQ-_$%F#Tv&otXxEm##O|%(+hh0-P^#{#mc1=EA4lHPY*TxBM$<^U&;cA?S zZLRELxOIo}P0Nq??U~$HmKCo_^zr)zcdI^vn48*n$Fa|~<`XTXNf>|Xf5F!@0-5nN z|MHh_zAJnRAFOfRM3-tY7Kv}TigFvZAAa@tAH|)5w84yI_qtP0BE0lc0zMFu2iQ6E|DOkY z_c#K1%rel0okC`>JV+}sLoMXls;eyRTbZiQTEF^#S2!7mPLNCRz4nhwJE>{KJd`4e ztDJiQgW5l7#lXI3Vp*t_Rfs|`f3s(s&WT9g*eln`Z~5weqzG?=lBSusR*GeVa5?3Jj- zmVMt1GsBQ&tRc!y7)!SJvPHI%B__WA@%vv_bFMnq)qCD^&ilUSdGF`B?|Ynzg10K# z4%SE0sF{wW zm-RS_#)7h!LWZKB7&t!nWrUr=w8|bX|GfKU7v+-~-~G|M{~cKwlnZF+_@x6yRtA?# zumSm}%T7B&fSr@uU0@;YwOZ@H676xFDfFP7Vw4^c^IY2~FBHVr7LPBtdws%oXo&lo zAoas$-YVB9C$dsGn_~SrU&^jyJ7zqMoVF@3e=&nGv9}?-0Z(4U4DjfjO#zfhZSAyw(L>E^3ik_;B#ohi7vYyw+sw)a7fGvDD`W@nR~}VBHWuI5-!uuzHEt(S^A%4wT)q)`Mx{u1$2pXrb@zov z21&3IuJ2j+wV+r>J2QLmNg&j!vUAnws>>oIy)UWuB$pT$;c5^a5;Sz-Cntx~4s?dm zeD#+y2?rn&I8dolUxF?oaql|l8xQ-Fr@Z_V74Z4NCGq^DJ4-hnqA=350J#LEH>ecfjd~$eVtog0({(u?My^ZsZ z(ARcer#%x<&8d>|?-`0nRxM<2WXM~E6kuzcFyqqk1m+AYYLpc^Sq$|U>5RGK?w4_~ zNrt5eF0xs%^#zC%R9(c9wH5TE#`__g>$GIli|se(hK|0z^~>kq5ufd3!9Z1pe@x>C zK?6W%DM_*yDzy`#N6gQMY7Q>*QRjP64jU3scCmqL<-Zz)CxpTN}Up zP$21m>8$$qRwGrPBIK|){DkM?y(N=1is^TIp=|d3JrgOVUHmJ5eOkpe^NAd~DSu7Wyx;BSb}GFQSV281Z@e{h9ax?7 z;Bb^Ipgbaa$*OMV{Va0vT&$dC3Z%ij3Oe1RJxU~K2mQfy5RHL@jsnBH$yZ>HufLKf za;O^pnl%#adk2g815ex^>hy#uO&E_*t?#O$e#?-GzEcl)sg0os*{OL1v~T>>4ov*; zfXc}E+v+Ea0VEo8-{Ab=5 zESMfb?>C@{)W9s8Laq&Lzg&^voi~^L782n@9W%u= z_G{Kbaf!-2!l0-qr%uAW9K*1=sE~Al6w&h{r4I^s?W)X9P3yA){%h#L>IwfM>IXv= zZb-n^!KzH^E9Mo2h$Q!BYW`GZ?RA>zA{t4Jk6$6H16yc5e7F;!~O3vMyO@kWjX>*YdTQoU2lckeOL^ zn&}vV%PhA2am`oZN0^AjvY+Ag%X$^UPg;_HhIC;!$i{I05W)|m*T?pudED)Bsc&gu z0VqpXB<+VN2xZVicv3*lkeUy!dyh?pX}pju-=txE3pvx-nD@yx3;51WP^6c8m29Oy zR#VuH@$2&Io39622=K})f(vH7_!ALVTNpvC(Tr(KhN?8Ao0T5OO|w*}0#{>C*o!e<{x zwd92~LwW?ae1^^@CxqyZwYx#fi<|>Y$w>PCjv4P7)Sd6czf~Zi(@AT?X-N-UcmqvO zgthu;2v~k(Q9B_C)vBFzKtSG5%qikdd4|#JwBt-=;${JQZg@1xPK`*r=mB}2TKG~S z^0^jDKgVM<=qG+8=Q*eA>UjC=C-mtriEDxCMHtkALUFaj_D-%RM!w$FuNX#%3&9-_ z#y^crMkSNbka&K^xjfJc`ng`(X%6B<%iF>%<$obk|C)a3O9Si}8FyD@`>gx1n*I;m zc&L(P6+MGWvwt{CVWI@Qtm_O5$OPVxmj#UjPO1IhAuX9@moX>w+RWq7V+f&E*9}jA zS3I2K&Ox1cEH^!^$*P#r32IP>LNm?RtmMr3E>2ww_I%=*8AhM{V68EXU`mx-d9We= zmrFldn#z(}uqf^kie*fiC#Ov?v5L7qvZO=HtJsvAFe%WHc=P}OX}#r)z&`8e2tD3l z_2PcU9lYN#b+Ifyf218n`r#6O^tqXQYjj4?suLl&JtRmGappxv^p2rt5yP0Su#!TU z2v0s><`td4{EHg&*`_X{^s6|zn)d9z;}oO5a^NC9TvztSOm?$6hjUWZYmfR!lR&Lk z7jp~y@#%fJ%ApF0#U+V@Rk!nl5-9UJKe)ezeOof)w&)d~vPHS7_5vnQUM;9M)K!<` zE~9YprN*;6!pCBjub!Q6n!3(~^^)+;9F~1CmfGcQ@g6vWS?s=i*{M^pTS%yK5}&3E{xE&@ z6pMNXtRj?d@8O7yC&wnn7Iz^0)NXPZLE=%5jM}J9YhSoRg9&-JWUh_3h09bruXSEKARw&EqCZ&~NzZb+U>}la!CoyRm2km@eYA zO}lkIohKhbU72dpN!`h=R`YuO>8{ZWR;0^o%HVB1jratT8hmaMK)6j>V5bj$`we(} zgLU+g$MN6SqW$W4d31OB8gw?~v8Pz!d{nBWkawL!!CIe)O{$POa$ZZPFei7RDp#p1 zT6S;Hz{PN5f|YXKtxQb#U`wncxC!O!vIL*6rkpfFmMJG(*jWll4sJ4jtPv0V;XHsv z3kj|d%<1m`U{nq!Dv2L^k=SIH)oIz5Z+a=n_TC#CzN}J;_RSdfBXsRxH;?3K%9N07 zraJxlyS*VbJ|ME$%RFw(`27N3zEQC4lVef>oF4hV8+Y#0$-J+>?SNTlDoW z8;N(5+x`3=H27mtpWxpqhLTE36v>Q>Wc`$EkD7_`Y+!q=3&bBl#;+hI{fE^*vA)D* z-PlZ~V3K%FUUyvopghp5{#+pgAh9(V@47AN#vjB#SNu!K5|W*woZ%PFJX7%TLelSQ z@pn{vGqduR!j8p1I2M1)$#sK?3%01oZV1Cd7PYmrVdj6>+>9UyoeUVL>Bmzih<4wgD)mhf)+lML_JRr<$~p#PMGH54{8XMqM8FDBAP+ zC^qTnSPeg3ZmR9qz{8sq>#YKp@R7WYxy^!Ik6^Fil-gjgG`pb6Mr;t%s{5_Cf{Xj; z;MF?^=>AZ3;>?LHddSnet`wX2*-;iVk{c<{&W#zlYBTW@$-D!Wbje&rxhO`hh`O+p z=8m&(xu_oDoRikrdb+5*WFL5HL$PBrLJ#&7{Rb%){7Dki?*U1;rM*z3o!G{pyIMcw z7~5dr)SpN~TjirY2|k@7u|nD3oNr+U9(129RqBlQO*G1UQR|;jlG`CSFIZC&w5A>! z$1cdY@KP2rXwc&40>451(VO>fbNS(wl!IEBCvC_CMv0y-++9UjoxS{(+j7^^-;MrH z)s~|AUSdX>*_64T30S>aT_gPd=#|ET^wqQH919V7^O_XpPN3nEDVyCZ-XE6{+=2w! zeCPh4{^0@kz6%%�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%0DiTo*jXQ@LY=)i+ZN6 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 literal 0 HcmV?d00001 diff --git a/web/style.css b/web/style.css index bc624b7..923af8a 100644 --- a/web/style.css +++ b/web/style.css @@ -153,21 +153,19 @@ 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; } .help-overlay.visible { @@ -213,6 +211,236 @@ body { outline-offset: 2px; } +.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; +} + +.tour-screen { + display: flex; + flex-direction: column; + gap: 1.1rem; + color: #f3fbff; +} + +.tour-eyebrow { + text-transform: uppercase; + letter-spacing: 0.24em; + font-size: 0.75rem; + color: rgba(255, 214, 102, 0.9); + margin: 0; +} + +.tour-hero { + display: flex; + flex-direction: row; + gap: 1.5rem; + flex-wrap: wrap; +} + +.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; +} + +.tour-hero__visual img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tour-hero__body { + flex: 1 1 260px; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.tour-pitch, +.tour-desc { + margin: 0; + color: rgba(228, 241, 255, 0.92); +} + +.tour-cta-row { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; +} + +.tour-mobile-note { + margin: 0; + color: rgba(200, 220, 255, 0.85); + font-size: 0.9rem; +} + +.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; +} + +.tour-btn.primary { + background: linear-gradient(135deg, #ffd666, #ffae4d); + color: #0a0f1d; + border-color: transparent; +} + +.tour-btn.ghost { + background: transparent; + color: rgba(255, 214, 102, 0.8); +} + +.tour-btn:active { + transform: translateY(1px); +} + +.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); +} + +.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; + 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; +} + +.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); +} + +.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; +} + /* Modal overlay and glass card */ #modalOverlay { position: fixed; @@ -469,6 +697,68 @@ body { 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; +} + #bottomTextContainer .hud-controls { width: 100%; display: flex; 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/hud_toggle.spec.mjs b/web/tests/hud_toggle.spec.mjs index 3bc10b3..34808e5 100644 --- a/web/tests/hud_toggle.spec.mjs +++ b/web/tests/hud_toggle.spec.mjs @@ -5,6 +5,8 @@ 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( @@ -15,6 +17,17 @@ test.describe('HUD toggle flag', () => { }); 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, 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 index d0bd474..d0a7d42 100644 --- a/web/tests/mobile_mode.spec.mjs +++ b/web/tests/mobile_mode.spec.mjs @@ -19,6 +19,8 @@ async function bootstrapMobile(page) { } 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); From 4d217d7f66a405fe9039e7296930d5df253b9dcf Mon Sep 17 00:00:00 2001 From: misko Date: Mon, 17 Nov 2025 21:57:43 -0800 Subject: [PATCH 17/29] update mobile app --- web/client/ClientState.js | 65 +- web/client/HudesClient.js | 290 ++++----- web/client/KeyboardClient.js | 9 +- web/client/KeyboardClientGL.js | 126 +++- web/client/View.js | 13 +- web/client/helpTour.js | 144 ++++- web/client/hud.js | 3 - web/client/views/LandscapeView.js | 963 +++++++++++++++++------------- web/index.html | 3 +- web/style.css | 387 +++++++++++- web/tests/hud_toggle.spec.mjs | 4 +- web/tests/mobile_mode.spec.mjs | 22 +- 12 files changed, 1287 insertions(+), 742 deletions(-) diff --git a/web/client/ClientState.js b/web/client/ClientState.js index c8050ce..690148c 100644 --- a/web/client/ClientState.js +++ b/web/client/ClientState.js @@ -13,13 +13,7 @@ export default class ClientState { this.stepSizeResolution = stepSizeResolution; this.stepSizeIdx = initialStepSizeIdx; this.helpScreenIdx = 0; - this.hasSeenKeysOverlay = false; - this.resumeMode = null; - this.tutorialState = { - completed: false, - currentIdx: 0, - totalSteps: 0, - }; + this.dimsUsed = 0; this.updateStepSize(); } @@ -89,51 +83,20 @@ export default class ClientState { console.log(`Dtype set to ${dtype}`); } setHelpScreenFns(helpScreenFns) { - this.helpScreenFns = helpScreenFns; - } - - setHasSeenKeysOverlay(flag = true) { - this.hasSeenKeysOverlay = Boolean(flag); - } - - setResumeMode(mode) { - this.resumeMode = mode || null; - } - - resetTutorial(totalSteps = 0) { - this.tutorialState = { - completed: false, - currentIdx: 0, - totalSteps: Math.max(0, totalSteps), - }; - } - - advanceTutorialStep() { - if (!this.tutorialState) { - this.resetTutorial(); - return; - } - const nextIdx = Math.min( - (this.tutorialState.currentIdx ?? 0) + 1, - Math.max(this.tutorialState.totalSteps ?? 0, 0), - ); - this.tutorialState = { - ...this.tutorialState, - currentIdx: nextIdx, - completed: - nextIdx >= (this.tutorialState.totalSteps ?? 0) && (this.tutorialState.totalSteps ?? 0) > 0, - }; - } - - markTutorialCompleted() { - if (!this.tutorialState) { - this.resetTutorial(); + if (Array.isArray(helpScreenFns)) { + this.helpScreenFns = [...helpScreenFns]; + if (this.helpScreenFns.length === 0) { + this.helpScreenIdx = -1; + } else if ( + this.helpScreenIdx === -1 || + this.helpScreenIdx >= this.helpScreenFns.length + ) { + this.helpScreenIdx = 0; + } + } else { + this.helpScreenFns = []; + this.helpScreenIdx = -1; } - this.tutorialState = { - ...(this.tutorialState ?? { totalSteps: 0 }), - completed: true, - currentIdx: this.tutorialState?.totalSteps ?? 0, - }; } closeHelpScreens() { diff --git a/web/client/HudesClient.js b/web/client/HudesClient.js index af3a63c..ab9714b 100644 --- a/web/client/HudesClient.js +++ b/web/client/HudesClient.js @@ -3,6 +3,7 @@ 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, options = {}) { @@ -37,9 +38,6 @@ export default class HudesClient { this._sessionStorageAvailable = typeof window !== 'undefined' && !!window.sessionStorage; this._sessionTokenKey = 'hudes.tab.sessionToken'; this._resumeTokenKey = 'hudes.resume.token'; - this._tourKeysSeenKey = 'hudes.tour.keysSeen'; - this._tourResumeModeKey = 'hudes.tour.resumeMode'; - this._tourTutorialStateKey = 'hudes.tour.tutorial'; this._clientSessionToken = this._loadSessionToken(); this._pendingSessionToken = null; this._resumeToken = this._loadPersistedResumeToken(); @@ -63,15 +61,18 @@ export default class HudesClient { 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 {} + disableInitialTour = disableInitialTour || this.state.helpScreenIdx === -1; this.grids = this.meshEnabled ? (options.meshGrids ?? 3) : 0; this.grid_size = options.gridSize ?? 31; this.rowSpacing = options.rowSpacing; @@ -89,6 +90,7 @@ export default class HudesClient { altKeys: this.altKeys, mobile: this.isMobile, hideHud: this.hideHud, + disableInitialTour, }); if (this.hideHud && typeof document !== 'undefined') { document.body.classList.add('hudes-hide-hud'); @@ -118,12 +120,6 @@ export default class HudesClient { this.trainSteps = []; this.valSteps = []; - this._helpDismissKey = 'hudes.help.dismissed'; - this._helpDismissed = false; - this._patchHelpStateTracking(); - this._initHelpDismissState(); - this._loadTourState(); - this._handleHudAction = this._handleHudAction.bind(this); this._speedRunCountdownOverlay = null; this._speedRunCountdownText = null; @@ -150,6 +146,7 @@ export default class HudesClient { case 'next-dims': event.preventDefault(); this.getNextDims(); + this._notifyTutorialStep('new_dims'); break; case 'next-batch': event.preventDefault(); @@ -162,6 +159,7 @@ export default class HudesClient { try { this.sendConfig(); } catch {} refreshHud(); } + this._notifyTutorialStep('step'); break; case 'step-minus': event.preventDefault(); @@ -170,6 +168,7 @@ export default class HudesClient { try { this.sendConfig(); } catch {} refreshHud(); } + this._notifyTutorialStep('step'); break; case 'toggle-fp': event.preventDefault(); @@ -178,6 +177,7 @@ export default class HudesClient { try { this.sendConfig(); } catch {} refreshHud(); } + this._notifyTutorialStep('precision'); break; case 'show-top': event.preventDefault(); @@ -185,10 +185,11 @@ export default class HudesClient { break; case 'toggle-help': event.preventDefault(); - if (typeof this._cycleHelpScreens === 'function') { - this._cycleHelpScreens(); - refreshHud(); - } + this.view?.openHelpOverlay?.('controls'); + break; + case 'show-tutorial': + event.preventDefault(); + this.view?.openTutorialOverlay?.(); break; default: } @@ -539,7 +540,7 @@ export default class HudesClient { log('[HudesClient] _promptAndSubmitHighScore invoked'); if (this.highScoreSubmitted) return; try { - this._showNameModal(finalValLoss); + this._showSpeedRunResults({ finalValLoss }); } catch {} } @@ -563,55 +564,104 @@ export default class HudesClient { return overlay; } - _showNameModal(finalValLoss) { + _showSpeedRunResults({ finalValLoss }) { this._setTextCaptureActive(true); const overlay = this._createOverlay(); overlay.innerHTML = ''; - const card = document.createElement('div'); - card.className = 'glass-card'; + card.className = 'glass-card results-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; - + title.textContent = 'Run complete'; + const metrics = document.createElement('ul'); + metrics.className = 'results-metrics'; + const primary = document.createElement('li'); + primary.innerHTML = `Final validation loss${ + typeof finalValLoss === 'number' && Number.isFinite(finalValLoss) + ? finalValLoss.toFixed(4) + : '—' + }`; + metrics.appendChild(primary); + const evalSteps = this.state?.evalSteps ?? 0; + const planes = Math.max(1, Math.floor((this.state?.dimsUsed || 0) / (this.state?.n || 1))); + const secondary = [ + { label: 'Eval steps', value: evalSteps || '—' }, + { label: 'Batch size', value: this.state?.batchSize ?? '—' }, + { label: 'Precision', value: this.state?.dtype ?? '—' }, + { label: 'Planes sampled', value: planes }, + ]; + secondary.forEach(({ label, value }) => { + const li = document.createElement('li'); + li.innerHTML = `${label}${value}`; + metrics.appendChild(li); + }); + const share = document.createElement('p'); + share.className = 'results-share'; + share.textContent = SHARE_TEXT(finalValLoss, evalSteps, planes); const form = document.createElement('form'); form.className = 'name-form'; form.innerHTML = ` - - -
- -
+ + +
+ +
`; - - 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); + const actions = document.createElement('div'); + actions.className = 'results-actions'; + const retryBtn = document.createElement('button'); + retryBtn.type = 'button'; + retryBtn.textContent = 'Retry'; + const exploreBtn = document.createElement('button'); + exploreBtn.type = 'button'; + exploreBtn.textContent = 'Keep exploring'; + const leaderboardBtn = document.createElement('button'); + leaderboardBtn.type = 'button'; + leaderboardBtn.textContent = 'Leaderboard'; + [retryBtn, exploreBtn, leaderboardBtn].forEach((btn) => { + btn.disabled = true; + actions.appendChild(btn); + }); + const enableActions = () => { + [retryBtn, exploreBtn, leaderboardBtn].forEach((btn) => { + btn.disabled = false; + }); + }; + form.addEventListener('submit', (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) { - el.classList.add('invalid'); + input.classList.add('invalid'); return; } + input.classList.remove('invalid'); this.submitHighScore(clean); - // In production we use only WebSocket. Request the Top 10 over WS - // and let CONTROL_LEADERBOARD_RESPONSE render the modal. + enableActions(); + this.highScoreSubmitted = true; try { this.requestLeaderboard(); } catch {} }); - - card.appendChild(title); - card.appendChild(subtitle); - card.appendChild(form); + retryBtn.addEventListener('click', () => { + if (retryBtn.disabled) return; + overlay.classList.remove('open'); + this.startSpeedRun(); + }); + exploreBtn.addEventListener('click', () => { + if (exploreBtn.disabled) return; + overlay.classList.remove('open'); + this._setTextCaptureActive(false); + }); + leaderboardBtn.addEventListener('click', () => { + if (leaderboardBtn.disabled) return; + this._setTextCaptureActive(false); + overlay.classList.remove('open'); + try { this.requestLeaderboard(); } catch {} + }); + card.append(title, metrics, share, form, actions); overlay.appendChild(card); overlay.classList.add('open'); - const input = form.querySelector('#nameInput'); - if (input) { input.focus(); } - log('[HudesClient] name entry modal shown'); + input?.focus(); + log('[HudesClient] speed-run results modal shown'); } _showLeaderboardModal(top10, me) { @@ -908,151 +958,15 @@ export default class HudesClient { } catch {} } - _patchHelpStateTracking() { - const state = this.state; - if (!state) return; - const originalClose = state.closeHelpScreens?.bind(state); - if (originalClose) { - state.closeHelpScreens = (...args) => { - const result = originalClose(...args); - this._handleHelpStateChanged(); - return result; - }; - } - const originalNext = state.nextHelpScreen?.bind(state); - if (originalNext) { - state.nextHelpScreen = (...args) => { - const prev = state.helpScreenIdx; - const result = originalNext(...args); - if (state.helpScreenIdx !== prev) { - this._handleHelpStateChanged(); - } - return result; - }; - } - } - - _initHelpDismissState() { - if (!this._sessionStorageAvailable) { + _notifyTutorialStep(stepId) { + if (!stepId) { return; } try { - if (window.sessionStorage?.getItem(this._helpDismissKey) === '1') { - this._helpDismissed = true; - if (typeof this.state.closeHelpScreens === 'function') { - this.state.closeHelpScreens(); - } else { - this.state.helpScreenIdx = -1; - } - } + this.view?.notifyTutorialEvent?.(stepId); } catch {} } - _handleHelpStateChanged() { - if (this.state?.helpScreenIdx === -1) { - this._markHelpDismissed(); - } - } - - _markHelpDismissed() { - if (this._helpDismissed) { - return; - } - this._helpDismissed = true; - if (this._sessionStorageAvailable) { - try { - window.sessionStorage?.setItem(this._helpDismissKey, '1'); - } catch {} - } - } - - _loadTourState() { - if (!this._sessionStorageAvailable) { - return; - } - try { - if (window.sessionStorage?.getItem(this._tourKeysSeenKey) === '1') { - this.state?.setHasSeenKeysOverlay(true); - } - const resumeMode = window.sessionStorage?.getItem(this._tourResumeModeKey); - if (resumeMode) { - this.state?.setResumeMode(resumeMode); - } - const tutorialRaw = window.sessionStorage?.getItem(this._tourTutorialStateKey); - if (tutorialRaw) { - try { - const parsed = JSON.parse(tutorialRaw); - if (parsed && typeof parsed === 'object') { - this.state.tutorialState = { - completed: Boolean(parsed.completed), - currentIdx: Number(parsed.currentIdx) || 0, - totalSteps: Number(parsed.totalSteps) || 0, - }; - } - } catch { - // ignore malformed stored data - } - } - } catch {} - } - - _persistTourValue(key, value) { - if (!this._sessionStorageAvailable) { - return; - } - try { - if (value === null || value === undefined) { - window.sessionStorage?.removeItem(key); - } else { - window.sessionStorage?.setItem(key, value); - } - } catch {} - } - - _persistTutorialState() { - if (!this.state?.tutorialState) { - this._persistTourValue(this._tourTutorialStateKey, null); - return; - } - const payload = JSON.stringify({ - completed: Boolean(this.state.tutorialState.completed), - currentIdx: Number(this.state.tutorialState.currentIdx) || 0, - totalSteps: Number(this.state.tutorialState.totalSteps) || 0, - }); - this._persistTourValue(this._tourTutorialStateKey, payload); - } - - markKeysOverlaySeen() { - if (this.state?.hasSeenKeysOverlay) { - return; - } - this.state?.setHasSeenKeysOverlay(true); - this._persistTourValue(this._tourKeysSeenKey, '1'); - } - - rememberResumeMode(mode) { - if (!mode) { - return; - } - this.state?.setResumeMode(mode); - this._persistTourValue(this._tourResumeModeKey, mode); - } - - resetTutorialProgress(totalSteps = 0) { - this.state?.resetTutorial(totalSteps); - this._persistTutorialState(); - } - - advanceTutorialProgress() { - this.state?.advanceTutorialStep(); - this._persistTutorialState(); - } - - completeTutorialProgress() { - this.state?.markTutorialCompleted(); - this._persistTutorialState(); - } - toggleKeyOverlay(mode) { const view = this.view; if (!view) { @@ -1062,8 +976,7 @@ export default class HudesClient { view.hideImage?.(); return; } - const targetMode = mode || this.state?.resumeMode || 'explore'; - view.openKeyOverlay?.(targetMode); + view.openHelpOverlay?.('controls'); } _ensureCountdownOverlay() { @@ -1403,6 +1316,7 @@ export default class HudesClient { if (currentTime - keyInfo.lastExec > this.keySecondsPressed) { log("[HudesClient] Getting next dimensions."); this.getNextDims(); + this._notifyTutorialStep('new_dims'); } break; @@ -1410,24 +1324,28 @@ export default class HudesClient { 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": diff --git a/web/client/KeyboardClient.js b/web/client/KeyboardClient.js index 3bfbb38..e48e809 100644 --- a/web/client/KeyboardClient.js +++ b/web/client/KeyboardClient.js @@ -1,6 +1,6 @@ import HudesClient from './HudesClient.js'; import { log } from '../utils/logger.js'; -import { HELP_TOUR_SCREENS } from './helpTour.js'; +import { TOUR_FLOWS } from './helpTour.js'; export default class KeyboardClient extends HudesClient { constructor(addr, port, options = {}) { @@ -15,10 +15,6 @@ export default class KeyboardClient extends HudesClient { 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); @@ -65,8 +61,6 @@ export default class KeyboardClient extends HudesClient { this.state.setBatchSize(defaultBatchSize); this.state.setDtype('float16'); - this.state.setHelpScreenFns(HELP_TOUR_SCREENS); - } _handleKeyUpOverride(event) { @@ -151,6 +145,7 @@ GOOD LUCK! } } this.sendDimsAndSteps({ [dim]: delta }); + this._notifyTutorialStep?.('move'); if (this.debug) { const after = Array.isArray(this.dimsAndStepsOnCurrentDims) ? [...this.dimsAndStepsOnCurrentDims] diff --git a/web/client/KeyboardClientGL.js b/web/client/KeyboardClientGL.js index 2ef7454..78d50d9 100644 --- a/web/client/KeyboardClientGL.js +++ b/web/client/KeyboardClientGL.js @@ -2,7 +2,7 @@ import KeyboardClient from './KeyboardClient.js'; import { log } from '../utils/logger.js'; import { installMouseControls, computeStepVector } from './mouseControls.js'; import { installTouchControls } from './touchControls.js'; -import { HELP_TOUR_SCREENS } from './helpTour.js'; +import { TOUR_FLOWS } from './helpTour.js'; const DEBUG_MOUSE = false; @@ -22,9 +22,6 @@ export default class KeyboardClientGL extends KeyboardClient { this.isMobile = Boolean(options.isMobile); this.initInput(); - if (this.state.helpScreenIdx != -1) { - this.view.showImage(this.state.helpScreenFns[this.state.helpScreenIdx]); - } } initInput() { @@ -87,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( @@ -142,8 +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_TOUR_SCREENS); - } // Step in the selected grid with given s0 and s1 values @@ -186,6 +182,7 @@ export default class KeyboardClientGL extends KeyboardClient { 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) { @@ -197,6 +194,7 @@ export default class KeyboardClientGL extends KeyboardClient { return; } this.view.adjustAngles(deltaH, deltaV); + this._notifyTutorialStep?.('rotate'); } _startAngleRepeat(key, deltaH, deltaV) { @@ -321,6 +319,7 @@ export default class KeyboardClientGL extends KeyboardClient { } _applyTouchVector(vector) { + this._updateJoystickVisual(vector); if (!vector || this.state.helpScreenIdx !== -1) { return; } @@ -339,6 +338,7 @@ export default class KeyboardClientGL extends KeyboardClient { const { angleH } = this.view.getAngles(); const rotated = this._rotateVector(baseX, baseY, -angleH); this.stepInSelectedGrid(-rotated.x, -rotated.y); + this._notifyTutorialStep?.('move'); } _installTouchRotationControls(canvas) { @@ -428,6 +428,9 @@ export default class KeyboardClientGL extends KeyboardClient { if (vertical) { this.view.adjustAngles(0, vertical); } + if (horizontal || vertical) { + this._notifyTutorialStep?.('rotate'); + } } _cleanupTouchRotation() { @@ -480,7 +483,13 @@ export default class KeyboardClientGL extends KeyboardClient { this._updateMobileUi(); }); - addButton('batch', () => { + addButton('top10', () => { + try { + this.requestLeaderboard?.(); + } catch {} + }); + + addButton('batchCycle', () => { this.state.toggleBatchSize(); this.sendConfig(); this._updateMobileUi(); @@ -490,16 +499,64 @@ export default class KeyboardClientGL extends KeyboardClient { 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(); @@ -525,17 +582,56 @@ export default class KeyboardClientGL extends KeyboardClient { } btn.textContent = text; }; - setText( - buttons.speed, - this.state.speedRunActive - ? `🔥 ${Number(this.state.speedRunSecondsRemaining ?? 0).toFixed(1)}s` - : 'SPEED 🔥' - ); - setText(buttons.batch, `Batch ${this.state.batchSize ?? ''}`); - setText(buttons.sgd, 'SGD'); + 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/View.js b/web/client/View.js index 89db905..00c8b4e 100644 --- a/web/client/View.js +++ b/web/client/View.js @@ -92,11 +92,20 @@ export default class ViewRouter { return this.impl?.highlightLossLine?.(...args); } - openKeyOverlay(...args) { - return this.impl?.openKeyOverlay?.(...args); + openHelpOverlay(...args) { + return this.impl?.openHelpOverlay?.(...args); } isManualKeyOverlayVisible(...args) { return this.impl?.isManualKeyOverlayVisible?.(...args); } + + notifyTutorialEvent(...args) { + return this.impl?.notifyTutorialEvent?.(...args); + } + + openTutorialOverlay(...args) { + return this.impl?.openTutorialOverlay?.(...args); + } + } diff --git a/web/client/helpTour.js b/web/client/helpTour.js index e941bd9..a972ab6 100644 --- a/web/client/helpTour.js +++ b/web/client/helpTour.js @@ -1,37 +1,139 @@ -export const HELP_TOUR_SCREENS = ['tour_hook', 'tour_modes', 'tour_keys']; +export const BRAND_TAGLINE = 'You are the optimizer.'; +export const SPLASH_LOGO_URL = '/hudes_logo_splash.jpg'; +export const SPLASH_MEDIA_URL = '/dist/help_screens/loss_landscape.gif'; -export const HELP_TAGLINE = 'You are the optimizer.'; - -export const HELP_ELEVATOR_PITCH = - 'Steer a CNN through its loss landscape—walk along random directions in weight space, find valleys and saddles, and set a 2-minute MNIST speed-run record.'; +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 = [ - 'The cyan grid shows one random 2D slice of the CNN weights. Each point is a full neural network.', - 'Height corresponds to training loss. Walking downhill means lowering the loss.', - 'Batch controls, precision, and eval toggles let you inspect how the network behaves in different regimes.', - 'Speed-Run Mode locks in a 2-minute countdown and submits your best validation score to the global leaderboard.', + '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 TOUR_MODE_OPTIONS = [ +export const MODE_CARDS = [ { id: 'explore', title: 'Explore Mode', - tagline: 'Learn & tinker', - description: 'Sample random directions, inspect batches, and develop intuition.', + eyebrow: 'Learn & tinker', + description: 'Sample random directions, adjust step size, batch size, and precision.', + primaryCta: 'Continue', + defaultSelected: true, }, { id: 'speed', title: 'Speed-Run Mode', - tagline: '2-minute challenge', - description: 'Ranked leaderboard. Configure quickly and keep loss low.', - nudge: 'Try Ranked later', + eyebrow: '2-minute ranked challenge', + description: 'Lowest validation loss joins the global leaderboard. Desktop recommended.', + primaryCta: 'Start Ranked', }, ]; -export const MOBILE_KEY_COMMANDS = [ - { keys: 'Single-finger drag', label: 'Move in plane' }, - { keys: 'Two-finger drag', label: 'Rotate camera' }, - { keys: 'Buttons', label: 'Batch size / Step size / Precision' }, - { keys: 'Tap grid', label: 'Select cyan mesh' }, - { keys: 'Speed 🔥', label: 'Speed-Run toggle' }, +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, evalSteps, planes) => { + const loss = typeof valLoss === 'number' && Number.isFinite(valLoss) ? valLoss.toFixed(4) : '—'; + const evalPart = evalSteps ? `${evalSteps} eval-steps` : 'eval-steps pending'; + const planePart = planes ? `${planes} planes` : 'planes pending'; + return `Descended MNIST to ${loss} in 2:00—can you beat it? ${evalPart} ${planePart}`; +}; diff --git a/web/client/hud.js b/web/client/hud.js index df15146..85d356d 100644 --- a/web/client/hud.js +++ b/web/client/hud.js @@ -20,9 +20,6 @@ export const CONTROL_GROUPS = [ export const MOBILE_CONTROL_GROUPS = []; export const MOBILE_HUD_BUTTONS = [ - { action: 'step-minus', label: 'Step -' }, - { action: 'step-plus', label: 'Step +' }, - { action: 'toggle-fp', label: 'FP' }, { action: 'show-top', label: 'Top 10' }, ]; diff --git a/web/client/views/LandscapeView.js b/web/client/views/LandscapeView.js index acfe071..068f5c3 100644 --- a/web/client/views/LandscapeView.js +++ b/web/client/views/LandscapeView.js @@ -27,12 +27,22 @@ 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 { - HELP_TOUR_SCREENS, - HELP_TAGLINE, - HELP_ELEVATOR_PITCH, + BRAND_TAGLINE, + SPLASH_LOGO_URL, + SPLASH_MEDIA_URL, + WELCOME_BULLETS, HOW_IT_WORKS_POINTS, - TOUR_MODE_OPTIONS, - MOBILE_KEY_COMMANDS, + 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 @@ -116,14 +126,21 @@ export default class LandscapeView { 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.state=clientState; this.lossChart = null; this.lastStepsChart = null; this.stepSizeChart = null; @@ -137,14 +154,23 @@ export default class LandscapeView { this.exampleCharts = []; this.helpOverlay = null; this.helpOverlayContent = null; - this.helpInfoSheet = null; - this.helpImageEl = 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._howItWorksVisible = false; + 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')); + } // Grid properties @@ -790,13 +816,14 @@ export default class LandscapeView { } else { controls = CONTROL_GROUPS; } - const statusText = typeof text === 'string' ? text : ''; + const statusText = this.mobileMode ? '' : typeof text === 'string' ? text : ''; const options = hudButtons.length ? { buttons: hudButtons } : {}; - const statusHtml = this._buildStatusHtml(statusText); + const statusHtml = this.mobileMode ? null : this._buildStatusHtml(statusText); if (statusHtml) { options.statusHtml = statusHtml; } - bottomTextContainer.innerHTML = formatHudMarkup(statusText || text || '', controls, options); + const displayStatus = this.mobileMode ? '' : (statusText || text || ''); + bottomTextContainer.innerHTML = formatHudMarkup(displayStatus, controls, options); } _buildStatusHtml(text) { @@ -930,6 +957,37 @@ export default class LandscapeView { 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: { @@ -964,29 +1022,8 @@ export default class LandscapeView { }, }, scales: { - x: { - type: 'category', - labels: CONFUSION_LABELS, - title: { - display: true, - text: 'Predicted Label', - color: '#ffffff', - }, - ticks: { color: '#ffffff' }, - grid: { color: 'rgba(255, 255, 255, 0.1)' }, - }, - y: { - 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)' }, - }, + x: xAxisConfig, + y: yAxisConfig, }, }, }); @@ -2067,36 +2104,39 @@ export default class LandscapeView { }); } + _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 = HELP_TOUR_SCREENS.includes(screenId); + 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._hideHowItWorksSheet(); + this.helpOverlay.dataset.mode = isTourScreen ? 'tour' : 'media'; + this.helpContentMode = isTourScreen ? 'tour' : 'media'; if (isTourScreen) { this._renderTourScreen(screenId); this._attachHelpKeyListener(); return; } - // fallback to legacy image viewer this.helpOverlayContent.innerHTML = ''; const img = document.createElement('img'); img.className = 'help-overlay__image'; img.alt = 'Help screen'; img.src = screenId; - img.addEventListener('click', (event) => { - event?.preventDefault?.(); - event?.stopPropagation?.(); - this._advanceHelpScreen(); - }); this.helpOverlayContent.appendChild(img); this._attachHelpKeyListener(); } @@ -2107,21 +2147,18 @@ export default class LandscapeView { return; } container.classList.remove('help-open'); + this._setBodyHelpOpen(false); this._detachHelpKeyListener(); - this._hideHowItWorksSheet(); this.manualKeyOverlay = false; if (this.helpOverlay) { this.helpOverlay.classList.remove('visible'); - } else { - const overlay = container.querySelector('.help-overlay'); - if (overlay) { - overlay.classList.remove('visible'); - } + this.helpOverlay.removeAttribute('data-tour-screen'); + this.helpOverlay.removeAttribute('data-mode'); } } _ensureHelpOverlayElements() { - if (this.helpOverlay && this.helpOverlayContent && this.helpInfoSheet) { + if (this.helpOverlay && this.helpOverlayContent) { return; } const container = document.getElementById('imageContainer'); @@ -2131,12 +2168,21 @@ export default class LandscapeView { const overlay = document.createElement('div'); overlay.className = 'help-overlay'; overlay.setAttribute('role', 'dialog'); - overlay.setAttribute('aria-label', 'First-run guide'); + overlay.setAttribute('aria-label', 'Guide'); - const closeOverlay = (event) => { + 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.state) { + if (this.helpContentMode === 'tour' && this.state) { if (typeof this.state.closeHelpScreens === 'function') { this.state.closeHelpScreens(); } else { @@ -2144,70 +2190,312 @@ export default class LandscapeView { } } this.hideImage(); - }; - - overlay.addEventListener('click', (event) => { - if (event.target === overlay) { - if (this._howItWorksVisible) { - this._hideHowItWorksSheet(); - return; - } - this._advanceHelpScreen(); - } }); - 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', closeOverlay); + 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'; - const infoSheet = this._buildHowItWorksSheet(); - contentWrapper.appendChild(closeBtn); + contentWrapper.appendChild(tabs); contentWrapper.appendChild(contentBody); overlay.appendChild(contentWrapper); - overlay.appendChild(infoSheet); - container.appendChild(overlay); + 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.helpInfoSheet = infoSheet; + 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.helpOverlayContent.innerHTML = ''; + 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_hook': - this.helpOverlayContent.appendChild(this._buildHookScreen()); + case 'tour_splash': + this._setOverlayMode('splash'); + screen = this._buildSplashScreen(); + break; + case 'tour_welcome': + screen = this._buildWelcomeScreen(); break; case 'tour_modes': - this.helpOverlayContent.appendChild(this._buildModeScreen()); + screen = this._buildModeScreen(); + break; + case 'tour_controls': + screen = this._buildControlsScreen(); break; - case 'tour_keys': - this.helpOverlayContent.appendChild(this._buildKeyScreen()); + case 'tour_canvas': + screen = this._buildCanvasScreen(); + break; + case 'tour_speed': + screen = this._buildSpeedRunPreScreen(); break; default: break; } + if (screen) { + this.helpOverlayContent.appendChild(screen); + } } - _buildHookScreen() { + _buildSplashScreen() { const screen = document.createElement('div'); - screen.className = 'tour-screen tour-screen--hook'; + 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'; + const prompt = document.createElement('p'); + prompt.className = 'tour-splash__prompt'; + prompt.textContent = 'Press any key / tap to begin'; + screen.append(logo, prompt); + 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, 1500); + 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._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'; @@ -2215,326 +2503,247 @@ export default class LandscapeView { visual.className = 'tour-hero__visual'; const gif = document.createElement('img'); gif.className = 'tour-hero__gif'; - gif.src = '/dist/help_screens/loss_landscape.gif'; + gif.src = SPLASH_MEDIA_URL; gif.alt = 'CNN loss landscape animation'; visual.appendChild(gif); - hero.appendChild(visual); const body = document.createElement('div'); body.className = 'tour-hero__body'; - const eyebrow = document.createElement('p'); eyebrow.className = 'tour-eyebrow'; - eyebrow.textContent = HELP_TAGLINE; - body.appendChild(eyebrow); - - const title = document.createElement('h2'); - title.textContent = 'Explore the Loss Landscape.'; - body.appendChild(title); - - const pitch = document.createElement('p'); - pitch.className = 'tour-pitch'; - pitch.textContent = HELP_ELEVATOR_PITCH; - body.appendChild(pitch); - - const desc = document.createElement('p'); - desc.className = 'tour-desc'; - desc.textContent = - 'Each point is a neural network. Move in weight space, and lower Z means lower loss.'; - body.appendChild(desc); - - const platformNote = document.createElement('p'); - platformNote.className = 'tour-mobile-note'; - platformNote.textContent = this.mobileMode - ? 'Tap Play to explore with touch controls. Connect a keyboard for ranked Speed-Runs.' - : 'Desktop gives you full control and the fastest Speed-Run times.'; - body.appendChild(platformNote); - + 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?.(); - event?.stopPropagation?.(); this._advanceHelpScreen(); }); - ctas.appendChild(playBtn); - const infoBtn = document.createElement('button'); infoBtn.type = 'button'; infoBtn.className = 'tour-btn ghost'; infoBtn.textContent = 'What is this?'; infoBtn.addEventListener('click', (event) => { event?.preventDefault?.(); - event?.stopPropagation?.(); - this._showHowItWorksSheet(); + this._showHelpTabs('how'); }); - ctas.appendChild(infoBtn); - - body.appendChild(ctas); - - if (this.state?.resumeMode) { - const resumeRow = document.createElement('div'); - resumeRow.className = 'tour-resume-row'; - const resumeBtn = document.createElement('button'); - resumeBtn.type = 'button'; - resumeBtn.className = 'tour-btn resume'; - resumeBtn.textContent = - this.state.resumeMode === 'speed' ? 'Resume Speed-Run' : 'Resume Explore'; - resumeBtn.addEventListener('click', (event) => { - event?.preventDefault?.(); - event?.stopPropagation?.(); - this._resumeLastMode(); - }); - resumeRow.appendChild(resumeBtn); - body.appendChild(resumeRow); - } + ctas.append(playBtn, infoBtn); - hero.appendChild(body); - screen.appendChild(hero); + 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'; - - TOUR_MODE_OPTIONS.forEach((option) => { + MODE_CARDS.forEach((cardDef) => { const card = document.createElement('button'); card.type = 'button'; card.className = 'tour-mode-card'; - card.dataset.mode = option.id; - if (this.helpTourSelection === option.id) { + card.dataset.mode = cardDef.id; + if (this.helpTourSelection === cardDef.id) { card.classList.add('is-selected'); } - const extraCopy = - this.mobileMode && option.id === 'speed' - ? ' (Keyboard recommended)' - : this.mobileMode && option.id === 'explore' - ? ' (Touch-optimized)' - : ''; - const description = - option.description + - (this.mobileMode && option.id === 'speed' - ? ' For ranked play, connect a keyboard to access all shortcuts.' - : ''); card.innerHTML = `
-

${option.title}${extraCopy}

-

${option.tagline}

+

${cardDef.title}

+

${cardDef.eyebrow}

-

${description}

+

${cardDef.description}

`; - if (option.nudge) { - const nudge = document.createElement('span'); - nudge.className = 'tour-mode-nudge'; - nudge.textContent = option.nudge; - card.appendChild(nudge); - } card.addEventListener('click', (event) => { event?.preventDefault?.(); - event?.stopPropagation?.(); - this._selectTourMode(option.id); + 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?.(); - event?.stopPropagation?.(); - this._confirmModeSelection(); + this._commitModeSelection(); }); screen.appendChild(continueBtn); - return screen; } - - _buildKeyScreen() { - if (this.selectedTourMode === 'speed') { - return this._buildSpeedRunPreScreen(); - } - return this._buildExploreKeyScreen(); - } - - _buildExploreKeyScreen() { + _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'; - screen.appendChild(eyebrow); - const title = document.createElement('h2'); title.textContent = this.mobileMode ? 'Touch controls' : 'Keyboard overlay'; - screen.appendChild(title); - const list = document.createElement('ul'); list.className = 'tour-legend'; - this._populateLegendList(list); - screen.appendChild(list); - + 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.'; - screen.appendChild(footer); - const startBtn = document.createElement('button'); startBtn.type = 'button'; startBtn.className = 'tour-btn primary'; startBtn.textContent = 'Start exploring'; startBtn.addEventListener('click', (event) => { event?.preventDefault?.(); - event?.stopPropagation?.(); - this._completeKeysOverlayAndExplore(); + this._advanceHelpScreen(); }); - screen.appendChild(startBtn); - + 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'; - screen.appendChild(eyebrow); - const title = document.createElement('h2'); title.textContent = '2-minute ranked challenge'; - screen.appendChild(title); - - const desc = document.createElement('p'); - desc.className = 'tour-desc'; - desc.textContent = - 'Configure quickly, keep loss low, and submit your best validation score to the global leaderboard.'; - screen.appendChild(desc); - - const keyList = document.createElement('ul'); - keyList.className = 'tour-legend'; - this._populateLegendList(keyList); - screen.appendChild(keyList); - + 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 = this.mobileMode - ? 'Timer starts when you tap Start. Connect a keyboard for the best ranked times.' - : 'Timer starts when you hit Start. Press ? anytime for this overlay.'; - screen.appendChild(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?.(); - event?.stopPropagation?.(); this._startSpeedrunFromTour(); }); - screen.appendChild(startBtn); - + screen.append(eyebrow, title, goal, rules, compact, footer, startBtn); return screen; } + _selectTourMode(mode) { + this.helpTourSelection = mode; + } - _getKeyLegendData() { - if (this.mobileMode) { - return MOBILE_KEY_COMMANDS; - } - return CONTROL_GROUPS.map((group) => { - const keys = Array.isArray(group.keys) - ? group.keys.filter((key) => typeof key === 'string' && key.length > 0) - : (typeof group.keys === 'string' && group.keys.length > 0) ? [group.keys] : []; - return { - keys, - label: group.label || '', - }; - }); + _commitModeSelection() { + const mode = this.helpTourSelection || 'explore'; + if (mode === 'speed') { + this._startSpeedrunFlow(); + } else { + this._startExploreFlow(); + } } - _populateLegendList(listEl) { - const keyData = this._getKeyLegendData(); - keyData.forEach((cmd) => { - const item = document.createElement('li'); - const keysEl = document.createElement('span'); - keysEl.className = 'tour-key'; - const keyText = Array.isArray(cmd.keys) ? cmd.keys.join(' / ') : (cmd.keys ?? ''); - keysEl.textContent = keyText; - const labelEl = document.createElement('span'); - labelEl.className = 'tour-label'; - if (this.mobileMode) { - labelEl.textContent = cmd.label ?? ''; + _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 { - labelEl.innerHTML = cmd.label ?? ''; + this.state.helpScreenIdx = -1; } - item.appendChild(keysEl); - item.appendChild(labelEl); - listEl.appendChild(item); - }); + this.hideImage(); + return; + } + this.state.helpScreenIdx = 0; + this.showImage(flow[0]); } - _confirmModeSelection() { - const mode = this.helpTourSelection || 'explore'; - this.selectedTourMode = mode; - const client = this._getHudesClient(); - client?.rememberResumeMode(mode); - this._advanceHelpScreen(); + _startExploreFlow() { + this._resetTutorialProgress(); + this._setTourFlow('explore'); } - _completeKeysOverlayAndExplore() { - const client = this._getHudesClient(); - client?.markKeysOverlaySeen?.(); - this.state?.setHasSeenKeysOverlay?.(true); + _startSpeedrunFlow() { + this._setTourFlow('speed'); + } + + _completeTutorialIntro() { if (typeof this.state.closeHelpScreens === 'function') { this.state.closeHelpScreens(); } else { this.state.helpScreenIdx = -1; } this.hideImage(); - this._startExploreFlow(); - } - - _startExploreFlow() { - const client = this._getHudesClient(); - client?.rememberResumeMode?.('explore'); - if (!this.state?.hasSeenKeysOverlay) { - this.state?.setHasSeenKeysOverlay?.(true); - client?.markKeysOverlaySeen?.(); - } - this._beginExploreTutorialIfNeeded(); - } - - _beginExploreTutorialIfNeeded() { - const client = this._getHudesClient(); - const tutorial = this.state?.tutorialState; - if (!tutorial || tutorial.completed) { - client?.resetTutorialProgress?.(6); - } } _startSpeedrunFromTour() { const client = this._getHudesClient(); - client?.markKeysOverlaySeen?.(); - this.state?.setHasSeenKeysOverlay?.(true); if (typeof this.state.closeHelpScreens === 'function') { this.state.closeHelpScreens(); } else { @@ -2543,170 +2752,62 @@ export default class LandscapeView { this.hideImage(); client?.startSpeedRun?.(); } - - _resumeLastMode() { - const mode = this.state?.resumeMode || 'explore'; - this.selectedTourMode = mode; - if (mode === 'speed') { - this._startSpeedrunFromTour(); - } else { - if (typeof this.state.closeHelpScreens === 'function') { - this.state.closeHelpScreens(); - } else { - this.state.helpScreenIdx = -1; - } - this.hideImage(); - this._startExploreFlow(); + _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'); } } - _buildHowItWorksSheet() { - const sheet = document.createElement('div'); - sheet.className = 'help-info-sheet'; - sheet.setAttribute('aria-hidden', 'true'); - - const title = document.createElement('h2'); - title.textContent = 'How it works'; - sheet.appendChild(title); - - const description = document.createElement('p'); - description.textContent = - 'Human Descent streams projections of a convolutional neural network trained on MNIST. You steer its weights directly.'; - sheet.appendChild(description); - - const list = document.createElement('ul'); - HOW_IT_WORKS_POINTS.forEach((point) => { - const item = document.createElement('li'); - item.textContent = point; - list.appendChild(item); - }); - sheet.appendChild(list); - - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'tour-btn ghost'; - closeBtn.textContent = 'Back'; - closeBtn.addEventListener('click', (event) => { - event?.preventDefault?.(); - event?.stopPropagation?.(); - this._hideHowItWorksSheet(); + _getCompletedTutorialCount() { + let count = 0; + this.tutorialProgress.forEach((value) => { + if (value) count += 1; }); - sheet.appendChild(closeBtn); - - sheet.addEventListener('click', (event) => event.stopPropagation()); - return sheet; + return count; } - _showHowItWorksSheet() { - if (!this.helpOverlay || !this.helpInfoSheet) { + notifyTutorialEvent(stepId) { + if (!stepId || !this.tutorialProgress || !this.tutorialProgress.has(stepId)) { return; } - this.helpOverlay.classList.add('showing-info'); - this.helpInfoSheet.setAttribute('aria-hidden', 'false'); - this._howItWorksVisible = true; - } - - _hideHowItWorksSheet() { - if (!this.helpOverlay || !this.helpInfoSheet) { + if (this.tutorialProgress.get(stepId)) { return; } - this.helpOverlay.classList.remove('showing-info'); - this.helpInfoSheet.setAttribute('aria-hidden', 'true'); - this._howItWorksVisible = false; - } - - _selectTourMode(mode) { - if (this.helpTourSelection === mode) { - return; + this.tutorialProgress.set(stepId, true); + if ( + this.helpOverlay?.getAttribute('data-tour-screen') === 'tour_canvas' && + this.helpContentMode === 'tour' + ) { + this._renderTourScreen('tour_canvas'); } - this.helpTourSelection = mode; - if (this.helpOverlay?.getAttribute('data-tour-screen') === 'tour_modes') { - this._renderTourScreen('tour_modes'); + if (!this.tutorialComplete && this._getCompletedTutorialCount() >= TUTORIAL_TASKS.length) { + this.tutorialComplete = true; + this._showTutorialCompletionToast(); } } - _advanceHelpScreen() { - 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(); - } + _showTutorialCompletionToast() { + const toast = this._ensureTutorialToast(); + if (!toast) return; + toast.textContent = 'Tutorial complete — press Z to start Speed-Run.'; + toast.classList.add('visible'); } - _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) { - return; - } - const key = event.key?.toLowerCase?.(); - 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(); + _ensureTutorialToast() { + if (this.tutorialToastEl) { + return this.tutorialToastEl; } - } - - _getHudesClient() { - if (typeof window !== 'undefined' && window.__hudesClient) { - return window.__hudesClient; - } - return null; - } - - openKeyOverlay(mode = 'explore') { - this.selectedTourMode = mode || 'explore'; - this.manualKeyOverlay = true; - this._ensureHelpOverlayElements(); - const container = document.getElementById('imageContainer'); - if (!container || !this.helpOverlay) { - return; + if (typeof document === 'undefined') { + return null; } - this._hideHowItWorksSheet(); - this.helpOverlay.classList.add('visible'); - container.classList.add('help-open'); - this._renderTourScreen('tour_keys'); - this._attachHelpKeyListener(); - } - - isManualKeyOverlayVisible() { - return Boolean(this.manualKeyOverlay); + const toast = document.createElement('div'); + toast.id = 'tutorialToast'; + document.body.appendChild(toast); + this.tutorialToastEl = toast; + return toast; } } diff --git a/web/index.html b/web/index.html index 3f30d87..a271a23 100644 --- a/web/index.html +++ b/web/index.html @@ -14,6 +14,7 @@ @@ -57,7 +58,7 @@ -
+
diff --git a/web/style.css b/web/style.css index 923af8a..8183452 100644 --- a/web/style.css +++ b/web/style.css @@ -53,7 +53,7 @@ body { transform: translateY(-50%); display: flex; align-items: center; - gap: 0.4rem; + gap: 0.9rem; } .connection-status { @@ -166,12 +166,14 @@ body { 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 { @@ -228,6 +230,66 @@ body { 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; + padding: 0; +} + +.help-overlay.is-splash .help-overlay__close { + display: none; +} + +.help-overlay__tabs { + display: none; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.help-overlay__tabs.is-visible { + display: flex; +} + +.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; +} + +.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; @@ -274,6 +336,16 @@ body { gap: 0.9rem; } +body.hudes-mobile .tour-hero__body { + gap: 0.45rem; +} + +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; +} + .tour-pitch, .tour-desc { margin: 0; @@ -441,6 +513,144 @@ body { 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; @@ -498,6 +708,66 @@ body { 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; @@ -536,6 +806,13 @@ body { 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); @@ -759,6 +1036,28 @@ body.speedrun-active #bottomTextContainer .hud-status .hud-timer { 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 { width: 100%; display: flex; @@ -916,12 +1215,17 @@ body.speedrun-active #bottomTextContainer .hud-status .hud-timer { } body.hudes-mobile #mobileActionPanel { - position: fixed; - top: 50%; - right: clamp(6px, 3vw, 28px); - transform: translateY(-50%); - width: auto; - padding: 0; + 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; @@ -929,16 +1233,72 @@ body.hudes-mobile #mobileActionPanel { display: flex; flex-direction: column; gap: 0; - z-index: 600; + 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 { @@ -957,12 +1317,13 @@ body.hudes-mobile #plot3 #confusionMatrixChart { } #mobileActionPanel .mobile-panel__grid { - display: flex; + display: inline-flex; flex-direction: column; - align-items: flex-end; - gap: 35px; + align-items: stretch; + gap: 12px; padding: 0; background: transparent; + width: max-content; } #mobileActionPanel button { @@ -974,10 +1335,10 @@ body.hudes-mobile #plot3 #confusionMatrixChart { font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; - width: auto; + width: 100%; + min-width: max-content; white-space: nowrap; text-align: center; - min-width: 92px; } #mobileActionPanel button:disabled { diff --git a/web/tests/hud_toggle.spec.mjs b/web/tests/hud_toggle.spec.mjs index 34808e5..53a19d4 100644 --- a/web/tests/hud_toggle.spec.mjs +++ b/web/tests/hud_toggle.spec.mjs @@ -13,7 +13,7 @@ test.describe('HUD toggle flag', () => { () => window.__hudesClient && window.__hudesClient.ControlType, { timeout: 10000 }, ); - await expect(page.locator('#bottomTextContainer')).toBeHidden(); + await expect(page.locator('#bottomTextContainer')).toHaveCount(0); }); test('hideHUD=1 hides HUD on mobile', async ({ page }) => { @@ -33,6 +33,6 @@ test.describe('HUD toggle flag', () => { () => window.__hudesClient && window.__hudesClient.ControlType, { timeout: 10000 }, ); - await expect(page.locator('#bottomTextContainer')).toBeHidden(); + await expect(page.locator('#bottomTextContainer')).toHaveCount(0); }); }); diff --git a/web/tests/mobile_mode.spec.mjs b/web/tests/mobile_mode.spec.mjs index d0a7d42..371f1ac 100644 --- a/web/tests/mobile_mode.spec.mjs +++ b/web/tests/mobile_mode.spec.mjs @@ -34,15 +34,16 @@ test.describe('Mobile 3D mode', () => { await expect(page.locator('#sideContainer')).toBeHidden(); - const hud = page.locator('#bottomTextContainer'); - await expect(hud.locator('[data-hud-action="step-plus"]')).toBeVisible(); - await expect(hud.locator('[data-hud-action="step-minus"]')).toBeVisible(); - await expect(hud.locator('[data-hud-action="toggle-fp"]')).toBeVisible(); - const hudText = await hud.innerText(); - expect(/WASD/i.test(hudText)).toBeFalsy(); + 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 page.locator('#mobileActionPanel').boundingBox(); + const panelBox = await panel.boundingBox(); expect(matrixBox).not.toBeNull(); expect(panelBox).not.toBeNull(); if (matrixBox && panelBox) { @@ -53,8 +54,9 @@ test.describe('Mobile 3D mode', () => { test('mobile controls dispatch actions and analog steps', async ({ page }) => { await bootstrapMobile(page); - const dimsBtn = page.locator('[data-hud-action="next-dims"]'); - const batchBtnHud = page.locator('[data-hud-action="next-batch"]'); + 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(); @@ -82,7 +84,7 @@ test.describe('Mobile 3D mode', () => { await batchBtnHud.click(); await page.waitForFunction(() => (window.__batchCalls || 0) > 0, { timeout: 5000 }); - const batchPanelButton = page.locator('#mobileActionPanel button[data-mobile-action="batch"]'); + const batchPanelButton = page.locator('#mobileActionPanel button[data-mobile-action="batchCycle"]'); await expect(batchPanelButton).toBeVisible(); const initialLabel = await batchPanelButton.innerText(); await batchPanelButton.click(); From 98d61aba7cc1dd28d1714e7125a38b3ab0c3fa4e Mon Sep 17 00:00:00 2001 From: misko Date: Mon, 17 Nov 2025 22:00:02 -0800 Subject: [PATCH 18/29] fix gif --- web/client/helpTour.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/helpTour.js b/web/client/helpTour.js index a972ab6..8af1f2c 100644 --- a/web/client/helpTour.js +++ b/web/client/helpTour.js @@ -1,6 +1,6 @@ export const BRAND_TAGLINE = 'You are the optimizer.'; export const SPLASH_LOGO_URL = '/hudes_logo_splash.jpg'; -export const SPLASH_MEDIA_URL = '/dist/help_screens/loss_landscape.gif'; +export const SPLASH_MEDIA_URL = new URL('../dist/help_screens/loss_landscape.gif', import.meta.url).href; 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.', From dbd19a37591eebb9d812105b4107431f4953f586 Mon Sep 17 00:00:00 2001 From: misko Date: Mon, 17 Nov 2025 22:01:47 -0800 Subject: [PATCH 19/29] add gif --- .pre-commit-config.yaml | 1 - web/dist/help_screens/loss_landscape.gif | Bin 0 -> 3013506 bytes 2 files changed, 1 deletion(-) create mode 100644 web/dist/help_screens/loss_landscape.gif 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/web/dist/help_screens/loss_landscape.gif b/web/dist/help_screens/loss_landscape.gif new file mode 100644 index 0000000000000000000000000000000000000000..4accdefd7c38be301948dcb1afae7b6c5ceb792c GIT binary patch literal 3013506 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 Sy8K8g%h$eK)J< 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 ^8cL_h2niQ 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%0Ad5e4_ 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