Spaces:
Running
Running
RemiFabre commited on
Commit ·
b358f52
0
Parent(s):
Initial commit
Browse files- .gitignore +2 -0
- README.md +12 -0
- SimpleDances/__init__.py +0 -0
- SimpleDances/dance_params.json +143 -0
- SimpleDances/main.py +474 -0
- SimpleDances/static/index.html +107 -0
- SimpleDances/static/main.js +496 -0
- SimpleDances/static/style.css +389 -0
- index.html +235 -0
- plan.txt +65 -0
- pyproject.toml +29 -0
- style.css +411 -0
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.egg-info/
|
README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Simpledances
|
| 3 |
+
emoji: 👋
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Write your description here
|
| 9 |
+
tags:
|
| 10 |
+
- reachy_mini
|
| 11 |
+
- reachy_mini_python_app
|
| 12 |
+
---
|
SimpleDances/__init__.py
ADDED
|
File without changes
|
SimpleDances/dance_params.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dances": {
|
| 3 |
+
"chicken_peck": {
|
| 4 |
+
"amplitude_m": 0.02,
|
| 5 |
+
"antenna_amplitude_rad": 0.5235987755982988,
|
| 6 |
+
"antenna_move_name": "both",
|
| 7 |
+
"subcycles_per_beat": 1.0
|
| 8 |
+
},
|
| 9 |
+
"chin_lead": {
|
| 10 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 11 |
+
"antenna_move_name": "wiggle",
|
| 12 |
+
"pitch_amplitude_rad": 0.2617993877991494,
|
| 13 |
+
"subcycles_per_beat": 0.5,
|
| 14 |
+
"x_amplitude_m": 0.02
|
| 15 |
+
},
|
| 16 |
+
"dizzy_spin": {
|
| 17 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 18 |
+
"antenna_move_name": "wiggle",
|
| 19 |
+
"pitch_amplitude_rad": 0.2617993877991494,
|
| 20 |
+
"roll_amplitude_rad": 0.2617993877991494,
|
| 21 |
+
"subcycles_per_beat": 0.25
|
| 22 |
+
},
|
| 23 |
+
"grid_snap": {
|
| 24 |
+
"amplitude_rad": 0.3490658503988659,
|
| 25 |
+
"antenna_amplitude_rad": 0.17453292519943295,
|
| 26 |
+
"antenna_move_name": "both",
|
| 27 |
+
"subcycles_per_beat": 0.25
|
| 28 |
+
},
|
| 29 |
+
"groovy_sway_and_roll": {
|
| 30 |
+
"antenna_amplitude_rad": 1.8413223608540177,
|
| 31 |
+
"antenna_move_name": "both",
|
| 32 |
+
"roll_amplitude_rad": 0.4014257279586958,
|
| 33 |
+
"subcycles_per_beat": 1.0,
|
| 34 |
+
"y_amplitude_m": 0.035
|
| 35 |
+
},
|
| 36 |
+
"head_tilt_roll": {
|
| 37 |
+
"amplitude_rad": 0.2617993877991494,
|
| 38 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 39 |
+
"antenna_move_name": "wiggle",
|
| 40 |
+
"subcycles_per_beat": 0.5
|
| 41 |
+
},
|
| 42 |
+
"headbanger_combo": {
|
| 43 |
+
"antenna_amplitude_rad": 0.6981317007977318,
|
| 44 |
+
"antenna_move_name": "both",
|
| 45 |
+
"pitch_amplitude_rad": 0.5235987755982988,
|
| 46 |
+
"subcycles_per_beat": 1.0,
|
| 47 |
+
"waveform": "sin",
|
| 48 |
+
"z_amplitude_m": 0.015
|
| 49 |
+
},
|
| 50 |
+
"interwoven_spirals": {
|
| 51 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 52 |
+
"antenna_move_name": "wiggle",
|
| 53 |
+
"pitch_amp": 0.3490658503988659,
|
| 54 |
+
"roll_amp": 0.2617993877991494,
|
| 55 |
+
"subcycles_per_beat": 0.125,
|
| 56 |
+
"yaw_amp": 0.4363323129985824
|
| 57 |
+
},
|
| 58 |
+
"jackson_square": {
|
| 59 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 60 |
+
"antenna_move_name": "wiggle",
|
| 61 |
+
"subcycles_per_beat": 0.2,
|
| 62 |
+
"twitch_amplitude_rad": 0.3490658503988659,
|
| 63 |
+
"y_amp_m": 0.035,
|
| 64 |
+
"z_amp_m": 0.025,
|
| 65 |
+
"z_offset_m": -0.01
|
| 66 |
+
},
|
| 67 |
+
"neck_recoil": {
|
| 68 |
+
"amplitude_m": 0.015,
|
| 69 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 70 |
+
"antenna_move_name": "wiggle",
|
| 71 |
+
"subcycles_per_beat": 0.5
|
| 72 |
+
},
|
| 73 |
+
"pendulum_swing": {
|
| 74 |
+
"amplitude_rad": 0.4363323129985824,
|
| 75 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 76 |
+
"antenna_move_name": "wiggle",
|
| 77 |
+
"subcycles_per_beat": 0.25
|
| 78 |
+
},
|
| 79 |
+
"polyrhythm_combo": {
|
| 80 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 81 |
+
"antenna_move_name": "wiggle",
|
| 82 |
+
"nod_amplitude_rad": 0.17453292519943295,
|
| 83 |
+
"sway_amplitude_m": 0.02
|
| 84 |
+
},
|
| 85 |
+
"sharp_side_tilt": {
|
| 86 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 87 |
+
"antenna_move_name": "wiggle",
|
| 88 |
+
"roll_amplitude_rad": 0.3839724354387525,
|
| 89 |
+
"subcycles_per_beat": 1.0,
|
| 90 |
+
"waveform": "triangle"
|
| 91 |
+
},
|
| 92 |
+
"side_glance_flick": {
|
| 93 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 94 |
+
"antenna_move_name": "wiggle",
|
| 95 |
+
"subcycles_per_beat": 0.25,
|
| 96 |
+
"yaw_amplitude_rad": 0.7853981633974483
|
| 97 |
+
},
|
| 98 |
+
"side_peekaboo": {
|
| 99 |
+
"antenna_amplitude_rad": 1.0471975511965976,
|
| 100 |
+
"antenna_move_name": "both",
|
| 101 |
+
"pitch_amp": 0.3490658503988659,
|
| 102 |
+
"subcycles_per_beat": 0.5,
|
| 103 |
+
"y_amp": 0.03,
|
| 104 |
+
"z_amp": 0.04
|
| 105 |
+
},
|
| 106 |
+
"side_to_side_sway": {
|
| 107 |
+
"amplitude_m": 0.04,
|
| 108 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 109 |
+
"antenna_move_name": "wiggle",
|
| 110 |
+
"subcycles_per_beat": 0.5
|
| 111 |
+
},
|
| 112 |
+
"simple_nod": {
|
| 113 |
+
"amplitude_rad": 0.3490658503988659,
|
| 114 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 115 |
+
"antenna_move_name": "wiggle",
|
| 116 |
+
"subcycles_per_beat": 1.0
|
| 117 |
+
},
|
| 118 |
+
"stumble_and_recover": {
|
| 119 |
+
"antenna_amplitude_rad": 0.8726646259971648,
|
| 120 |
+
"antenna_move_name": "both",
|
| 121 |
+
"pitch_amplitude_rad": 0.17453292519943295,
|
| 122 |
+
"subcycles_per_beat": 0.25,
|
| 123 |
+
"y_amplitude_m": 0.015,
|
| 124 |
+
"yaw_amplitude_rad": 0.4363323129985824
|
| 125 |
+
},
|
| 126 |
+
"uh_huh_tilt": {
|
| 127 |
+
"amplitude_rad": 0.2617993877991494,
|
| 128 |
+
"antenna_amplitude_rad": 0.7853981633974483,
|
| 129 |
+
"antenna_move_name": "wiggle",
|
| 130 |
+
"subcycles_per_beat": 0.5
|
| 131 |
+
},
|
| 132 |
+
"yeah_nod": {
|
| 133 |
+
"amplitude_rad": 0.2617993877991494,
|
| 134 |
+
"antenna_amplitude_rad": 0.3490658503988659,
|
| 135 |
+
"antenna_move_name": "both",
|
| 136 |
+
"subcycles_per_beat": 1.0
|
| 137 |
+
}
|
| 138 |
+
},
|
| 139 |
+
"globals": {
|
| 140 |
+
"amplitude_scale": 0.55,
|
| 141 |
+
"bpm": 52.0
|
| 142 |
+
}
|
| 143 |
+
}
|
SimpleDances/main.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Callable
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from fastapi import HTTPException
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
+
|
| 16 |
+
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 17 |
+
from reachy_mini.utils import create_head_pose
|
| 18 |
+
|
| 19 |
+
from reachy_mini_dances_library.collection.dance import AVAILABLE_MOVES
|
| 20 |
+
from reachy_mini_dances_library.rhythmic_motion import (
|
| 21 |
+
AVAILABLE_ANTENNA_MOVES,
|
| 22 |
+
MoveOffsets,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Constants & dance catalog
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
APP_DIR = Path(__file__).parent
|
| 29 |
+
PRESET_FILE = APP_DIR / "dance_params.json"
|
| 30 |
+
|
| 31 |
+
WAVEFORMS = ["sin", "cos", "square", "triangle", "sawtooth"]
|
| 32 |
+
BPM_RANGE = (40.0, 180.0)
|
| 33 |
+
AMP_SCALE_RANGE = (0.0, 2.0)
|
| 34 |
+
DEFAULT_GLOBALS = {"bpm": 110.0, "amplitude_scale": 1.0}
|
| 35 |
+
RAD_NAME_OVERRIDES = {"pitch_amp", "roll_amp", "yaw_amp"}
|
| 36 |
+
METER_NAME_OVERRIDES = {"z_amp", "y_amp"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ParamSpec(BaseModel):
|
| 40 |
+
name: str
|
| 41 |
+
label: str
|
| 42 |
+
type: str
|
| 43 |
+
value: float | str
|
| 44 |
+
min: float | None = None
|
| 45 |
+
max: float | None = None
|
| 46 |
+
step: float | None = None
|
| 47 |
+
unit: str | None = None
|
| 48 |
+
options: list[str] | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class SelectPayload(BaseModel):
|
| 52 |
+
name: str = Field(..., description="Name of the dance to select")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ParamUpdatePayload(BaseModel):
|
| 56 |
+
name: str
|
| 57 |
+
params: dict[str, float | str]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class GlobalSettingsPayload(BaseModel):
|
| 61 |
+
bpm: float
|
| 62 |
+
amplitude_scale: float
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TogglePayload(BaseModel):
|
| 66 |
+
playing: bool
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class ResetPayload(BaseModel):
|
| 70 |
+
name: str
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _normalize_params(raw: dict[str, Any]) -> dict[str, Any]:
|
| 74 |
+
normalized: dict[str, Any] = {}
|
| 75 |
+
for key, value in raw.items():
|
| 76 |
+
if isinstance(value, (int, float)):
|
| 77 |
+
normalized[key] = float(value)
|
| 78 |
+
else:
|
| 79 |
+
normalized[key] = value
|
| 80 |
+
return normalized
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
DANCE_CATALOG: dict[
|
| 84 |
+
str, dict[str, Any]
|
| 85 |
+
] = {
|
| 86 |
+
name: {
|
| 87 |
+
"fn": fn,
|
| 88 |
+
"default_params": _normalize_params(copy.deepcopy(params)),
|
| 89 |
+
"metadata": metadata or {},
|
| 90 |
+
"label": name.replace("_", " ").title(),
|
| 91 |
+
}
|
| 92 |
+
for name, (fn, params, metadata) in AVAILABLE_MOVES.items()
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
DANCE_ORDER = sorted(DANCE_CATALOG.keys())
|
| 96 |
+
DEFAULT_DANCE = "simple_nod" if "simple_nod" in DANCE_CATALOG else DANCE_ORDER[0]
|
| 97 |
+
ANTENNA_CHOICES = sorted(AVAILABLE_ANTENNA_MOVES.keys())
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
# Helper functions (units, persistence, specs)
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
def clamp(value: float, bounds: tuple[float, float]) -> float:
|
| 105 |
+
return max(bounds[0], min(bounds[1], value))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def param_is_degree(name: str) -> bool:
|
| 109 |
+
return "rad" in name or name in RAD_NAME_OVERRIDES
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def param_is_meter(name: str) -> bool:
|
| 113 |
+
return name.endswith("_m") or name in METER_NAME_OVERRIDES or name.endswith(
|
| 114 |
+
"_amp_m"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def to_ui_units(name: str, value: float) -> tuple[float, str | None]:
|
| 119 |
+
if param_is_degree(name):
|
| 120 |
+
return math.degrees(value), "deg"
|
| 121 |
+
if param_is_meter(name):
|
| 122 |
+
return value * 1000.0, "mm"
|
| 123 |
+
return value, None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def from_ui_units(name: str, value: float) -> float:
|
| 127 |
+
if param_is_degree(name):
|
| 128 |
+
return math.radians(value)
|
| 129 |
+
if param_is_meter(name):
|
| 130 |
+
return value / 1000.0
|
| 131 |
+
return value
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def range_for_param(name: str, ui_value: float) -> tuple[float, float, float]:
|
| 135 |
+
if name == "subcycles_per_beat":
|
| 136 |
+
return 0.1, 4.0, 0.05
|
| 137 |
+
if "phase_offset" in name:
|
| 138 |
+
return 0.0, 1.0, 0.01
|
| 139 |
+
if param_is_degree(name):
|
| 140 |
+
span = min(max(abs(ui_value) * 3.0, 30.0), 180.0)
|
| 141 |
+
return -span, span, 0.5
|
| 142 |
+
if param_is_meter(name):
|
| 143 |
+
span = min(max(abs(ui_value) * 3.0, 30.0), 150.0)
|
| 144 |
+
return -span, span, 1.0
|
| 145 |
+
base = max(abs(ui_value), 1.0)
|
| 146 |
+
span = max(base * 4.0, 2.0)
|
| 147 |
+
step = max(span / 100.0, 0.01)
|
| 148 |
+
return -span, span, step
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def build_param_spec(name: str, value: Any) -> ParamSpec:
|
| 152 |
+
label = name.replace("_", " ").title()
|
| 153 |
+
|
| 154 |
+
if isinstance(value, str):
|
| 155 |
+
options = WAVEFORMS if name == "waveform" else ANTENNA_CHOICES
|
| 156 |
+
if value not in options:
|
| 157 |
+
value = options[0]
|
| 158 |
+
return ParamSpec(
|
| 159 |
+
name=name,
|
| 160 |
+
label=label,
|
| 161 |
+
type="select",
|
| 162 |
+
value=value,
|
| 163 |
+
options=options,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
if not isinstance(value, (int, float)):
|
| 167 |
+
raise ValueError(f"Unsupported parameter type for {name}: {type(value)}")
|
| 168 |
+
|
| 169 |
+
ui_value, unit = to_ui_units(name, float(value))
|
| 170 |
+
min_val, max_val, step = range_for_param(name, ui_value)
|
| 171 |
+
return ParamSpec(
|
| 172 |
+
name=name,
|
| 173 |
+
label=label,
|
| 174 |
+
type="number",
|
| 175 |
+
value=ui_value,
|
| 176 |
+
min=min_val,
|
| 177 |
+
max=max_val,
|
| 178 |
+
step=step,
|
| 179 |
+
unit=unit,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def load_persisted() -> dict[str, Any]:
|
| 184 |
+
if not PRESET_FILE.exists():
|
| 185 |
+
return {"globals": DEFAULT_GLOBALS.copy(), "dances": {}}
|
| 186 |
+
try:
|
| 187 |
+
loaded = json.loads(PRESET_FILE.read_text())
|
| 188 |
+
except Exception:
|
| 189 |
+
return {"globals": DEFAULT_GLOBALS.copy(), "dances": {}}
|
| 190 |
+
|
| 191 |
+
globals_section = loaded.get("globals") or {}
|
| 192 |
+
dances_section = loaded.get("dances") or {}
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
"globals": {
|
| 196 |
+
"bpm": float(globals_section.get("bpm", DEFAULT_GLOBALS["bpm"])),
|
| 197 |
+
"amplitude_scale": float(
|
| 198 |
+
globals_section.get("amplitude_scale", DEFAULT_GLOBALS["amplitude_scale"])
|
| 199 |
+
),
|
| 200 |
+
},
|
| 201 |
+
"dances": {
|
| 202 |
+
name: _normalize_params(params)
|
| 203 |
+
for name, params in dances_section.items()
|
| 204 |
+
if isinstance(params, dict)
|
| 205 |
+
},
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def serialize_state(snapshot: dict[str, Any]) -> None:
|
| 210 |
+
payload = {
|
| 211 |
+
"globals": {
|
| 212 |
+
"bpm": snapshot["bpm"],
|
| 213 |
+
"amplitude_scale": snapshot["amplitude_scale"],
|
| 214 |
+
},
|
| 215 |
+
"dances": snapshot["dance_params"],
|
| 216 |
+
}
|
| 217 |
+
PRESET_FILE.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
# Reachy Mini App
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
class Simpledances(ReachyMiniApp):
|
| 224 |
+
custom_app_url: str | None = "http://0.0.0.0:8042"
|
| 225 |
+
request_media_backend: str | None = None
|
| 226 |
+
|
| 227 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
|
| 228 |
+
state_lock = threading.Lock()
|
| 229 |
+
persisted = load_persisted()
|
| 230 |
+
|
| 231 |
+
dance_params = {
|
| 232 |
+
name: {
|
| 233 |
+
**copy.deepcopy(DANCE_CATALOG[name]["default_params"]),
|
| 234 |
+
**persisted["dances"].get(name, {}),
|
| 235 |
+
}
|
| 236 |
+
for name in DANCE_ORDER
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
runtime_state: dict[str, Any] = {
|
| 240 |
+
"selected": DEFAULT_DANCE,
|
| 241 |
+
"playing": False,
|
| 242 |
+
"bpm": clamp(persisted["globals"].get("bpm", DEFAULT_GLOBALS["bpm"]), BPM_RANGE),
|
| 243 |
+
"amplitude_scale": clamp(
|
| 244 |
+
persisted["globals"].get("amplitude_scale", DEFAULT_GLOBALS["amplitude_scale"]),
|
| 245 |
+
AMP_SCALE_RANGE,
|
| 246 |
+
),
|
| 247 |
+
"dance_params": dance_params,
|
| 248 |
+
"last_offsets": {
|
| 249 |
+
"position": [0.0, 0.0, 0.0],
|
| 250 |
+
"orientation": [0.0, 0.0, 0.0],
|
| 251 |
+
"antennas": [0.0, 0.0],
|
| 252 |
+
},
|
| 253 |
+
"last_saved": None,
|
| 254 |
+
"phase_start": time.perf_counter(),
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
def persist_locked() -> None:
|
| 258 |
+
serialize_state(runtime_state)
|
| 259 |
+
runtime_state["last_saved"] = datetime.utcnow().isoformat() + "Z"
|
| 260 |
+
|
| 261 |
+
def get_param_specs(dance_name: str) -> list[ParamSpec]:
|
| 262 |
+
return [
|
| 263 |
+
build_param_spec(param, value)
|
| 264 |
+
for param, value in runtime_state["dance_params"][dance_name].items()
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
# -------------------- API Endpoints --------------------
|
| 268 |
+
@self.settings_app.get("/api/dances")
|
| 269 |
+
async def list_dances():
|
| 270 |
+
return {
|
| 271 |
+
"dances": [
|
| 272 |
+
{
|
| 273 |
+
"name": name,
|
| 274 |
+
"label": DANCE_CATALOG[name]["label"],
|
| 275 |
+
"description": DANCE_CATALOG[name]["metadata"].get(
|
| 276 |
+
"description", ""
|
| 277 |
+
),
|
| 278 |
+
"default_duration_beats": DANCE_CATALOG[name]["metadata"].get(
|
| 279 |
+
"default_duration_beats", 4
|
| 280 |
+
),
|
| 281 |
+
}
|
| 282 |
+
for name in DANCE_ORDER
|
| 283 |
+
]
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
@self.settings_app.get("/api/state")
|
| 287 |
+
async def get_state():
|
| 288 |
+
with state_lock:
|
| 289 |
+
selected = runtime_state["selected"]
|
| 290 |
+
meta = DANCE_CATALOG[selected]["metadata"]
|
| 291 |
+
return {
|
| 292 |
+
"selected": selected,
|
| 293 |
+
"playing": runtime_state["playing"],
|
| 294 |
+
"bpm": runtime_state["bpm"],
|
| 295 |
+
"amplitude_scale": runtime_state["amplitude_scale"],
|
| 296 |
+
"param_specs": [spec.model_dump() for spec in get_param_specs(selected)],
|
| 297 |
+
"description": meta.get("description", ""),
|
| 298 |
+
"last_saved": runtime_state["last_saved"],
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
@self.settings_app.post("/api/select")
|
| 302 |
+
async def select_dance(payload: SelectPayload):
|
| 303 |
+
if payload.name not in runtime_state["dance_params"]:
|
| 304 |
+
raise HTTPException(status_code=404, detail="Unknown dance")
|
| 305 |
+
with state_lock:
|
| 306 |
+
runtime_state["selected"] = payload.name
|
| 307 |
+
runtime_state["phase_start"] = time.perf_counter()
|
| 308 |
+
specs = [spec.model_dump() for spec in get_param_specs(payload.name)]
|
| 309 |
+
meta = DANCE_CATALOG[payload.name]["metadata"]
|
| 310 |
+
return {
|
| 311 |
+
"selected": payload.name,
|
| 312 |
+
"param_specs": specs,
|
| 313 |
+
"description": meta.get("description", ""),
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
@self.settings_app.post("/api/params")
|
| 317 |
+
async def update_params(payload: ParamUpdatePayload):
|
| 318 |
+
dance_params = runtime_state["dance_params"].get(payload.name)
|
| 319 |
+
if dance_params is None:
|
| 320 |
+
raise HTTPException(status_code=404, detail="Unknown dance")
|
| 321 |
+
|
| 322 |
+
changed = False
|
| 323 |
+
with state_lock:
|
| 324 |
+
for key, ui_value in payload.params.items():
|
| 325 |
+
if key not in dance_params:
|
| 326 |
+
continue
|
| 327 |
+
current = dance_params[key]
|
| 328 |
+
if isinstance(current, str):
|
| 329 |
+
if not isinstance(ui_value, str):
|
| 330 |
+
continue
|
| 331 |
+
if key == "waveform" and ui_value not in WAVEFORMS:
|
| 332 |
+
continue
|
| 333 |
+
if key == "antenna_move_name" and ui_value not in ANTENNA_CHOICES:
|
| 334 |
+
continue
|
| 335 |
+
dance_params[key] = ui_value
|
| 336 |
+
changed = True
|
| 337 |
+
else:
|
| 338 |
+
try:
|
| 339 |
+
numeric = from_ui_units(key, float(ui_value))
|
| 340 |
+
except (TypeError, ValueError):
|
| 341 |
+
continue
|
| 342 |
+
if math.isclose(numeric, current, rel_tol=1e-4, abs_tol=1e-4):
|
| 343 |
+
continue
|
| 344 |
+
dance_params[key] = numeric
|
| 345 |
+
changed = True
|
| 346 |
+
if changed:
|
| 347 |
+
persist_locked()
|
| 348 |
+
specs = [spec.model_dump() for spec in get_param_specs(payload.name)]
|
| 349 |
+
return {"param_specs": specs, "last_saved": runtime_state["last_saved"]}
|
| 350 |
+
|
| 351 |
+
@self.settings_app.post("/api/globals")
|
| 352 |
+
async def update_globals(payload: GlobalSettingsPayload):
|
| 353 |
+
with state_lock:
|
| 354 |
+
runtime_state["bpm"] = clamp(float(payload.bpm), BPM_RANGE)
|
| 355 |
+
runtime_state["amplitude_scale"] = clamp(
|
| 356 |
+
float(payload.amplitude_scale), AMP_SCALE_RANGE
|
| 357 |
+
)
|
| 358 |
+
persist_locked()
|
| 359 |
+
return {
|
| 360 |
+
"bpm": runtime_state["bpm"],
|
| 361 |
+
"amplitude_scale": runtime_state["amplitude_scale"],
|
| 362 |
+
"last_saved": runtime_state["last_saved"],
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
@self.settings_app.post("/api/toggle")
|
| 366 |
+
async def toggle_playback(payload: TogglePayload):
|
| 367 |
+
with state_lock:
|
| 368 |
+
runtime_state["playing"] = bool(payload.playing)
|
| 369 |
+
runtime_state["phase_start"] = time.perf_counter()
|
| 370 |
+
return {"playing": runtime_state["playing"]}
|
| 371 |
+
|
| 372 |
+
@self.settings_app.post("/api/reset")
|
| 373 |
+
async def reset_dance(payload: ResetPayload):
|
| 374 |
+
if payload.name not in runtime_state["dance_params"]:
|
| 375 |
+
raise HTTPException(status_code=404, detail="Unknown dance")
|
| 376 |
+
with state_lock:
|
| 377 |
+
runtime_state["dance_params"][payload.name] = copy.deepcopy(
|
| 378 |
+
DANCE_CATALOG[payload.name]["default_params"]
|
| 379 |
+
)
|
| 380 |
+
persist_locked()
|
| 381 |
+
specs = [spec.model_dump() for spec in get_param_specs(payload.name)]
|
| 382 |
+
return {
|
| 383 |
+
"param_specs": specs,
|
| 384 |
+
"description": DANCE_CATALOG[payload.name]["metadata"].get(
|
| 385 |
+
"description", ""
|
| 386 |
+
),
|
| 387 |
+
"last_saved": runtime_state["last_saved"],
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
@self.settings_app.get("/api/visualization")
|
| 391 |
+
async def visualization_snapshot():
|
| 392 |
+
with state_lock:
|
| 393 |
+
return {
|
| 394 |
+
"offsets": runtime_state["last_offsets"],
|
| 395 |
+
"playing": runtime_state["playing"],
|
| 396 |
+
"bpm": runtime_state["bpm"],
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
neutral_pose = create_head_pose()
|
| 400 |
+
neutral_antennas = np.zeros(2, dtype=float)
|
| 401 |
+
reachy_mini.set_target(head=neutral_pose, antennas=neutral_antennas)
|
| 402 |
+
|
| 403 |
+
loop_dt = 0.02
|
| 404 |
+
|
| 405 |
+
while not stop_event.is_set():
|
| 406 |
+
with state_lock:
|
| 407 |
+
selected = runtime_state["selected"]
|
| 408 |
+
playing = runtime_state["playing"]
|
| 409 |
+
bpm = runtime_state["bpm"]
|
| 410 |
+
amp_scale = runtime_state["amplitude_scale"]
|
| 411 |
+
params = copy.deepcopy(runtime_state["dance_params"][selected])
|
| 412 |
+
phase_start = runtime_state["phase_start"]
|
| 413 |
+
|
| 414 |
+
head_pose = neutral_pose
|
| 415 |
+
antennas_cmd = neutral_antennas
|
| 416 |
+
offsets_payload = {
|
| 417 |
+
"position": [0.0, 0.0, 0.0],
|
| 418 |
+
"orientation": [0.0, 0.0, 0.0],
|
| 419 |
+
"antennas": [0.0, 0.0],
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
if playing:
|
| 423 |
+
fn: Callable[..., MoveOffsets] | None = DANCE_CATALOG[selected]["fn"]
|
| 424 |
+
t_now = time.perf_counter()
|
| 425 |
+
elapsed = max(t_now - phase_start, 0.0)
|
| 426 |
+
beats = elapsed * (bpm / 60.0)
|
| 427 |
+
try:
|
| 428 |
+
offsets = fn(beats, **params)
|
| 429 |
+
except Exception as exc: # pragma: no cover - safety net
|
| 430 |
+
print(f"[SimpleDances] Move error ({selected}): {exc}")
|
| 431 |
+
pos = np.zeros(3)
|
| 432 |
+
ori = np.zeros(3)
|
| 433 |
+
antennas = np.zeros(2)
|
| 434 |
+
else:
|
| 435 |
+
pos = np.asarray(offsets.position_offset, dtype=float)
|
| 436 |
+
ori = np.asarray(offsets.orientation_offset, dtype=float)
|
| 437 |
+
antennas = np.asarray(offsets.antennas_offset, dtype=float)
|
| 438 |
+
|
| 439 |
+
pos *= amp_scale
|
| 440 |
+
ori *= amp_scale
|
| 441 |
+
antennas *= amp_scale
|
| 442 |
+
|
| 443 |
+
head_pose = create_head_pose(
|
| 444 |
+
x=float(pos[0]),
|
| 445 |
+
y=float(pos[1]),
|
| 446 |
+
z=float(pos[2]),
|
| 447 |
+
roll=float(ori[0]),
|
| 448 |
+
pitch=float(ori[1]),
|
| 449 |
+
yaw=float(ori[2]),
|
| 450 |
+
degrees=False,
|
| 451 |
+
mm=False,
|
| 452 |
+
)
|
| 453 |
+
antennas_cmd = antennas
|
| 454 |
+
|
| 455 |
+
offsets_payload = {
|
| 456 |
+
"position": pos.tolist(),
|
| 457 |
+
"orientation": ori.tolist(),
|
| 458 |
+
"antennas": antennas.tolist(),
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
reachy_mini.set_target(head=head_pose, antennas=antennas_cmd)
|
| 462 |
+
|
| 463 |
+
with state_lock:
|
| 464 |
+
runtime_state["last_offsets"] = offsets_payload
|
| 465 |
+
|
| 466 |
+
time.sleep(loop_dt)
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
if __name__ == "__main__":
|
| 470 |
+
app = Simpledances()
|
| 471 |
+
try:
|
| 472 |
+
app.wrapped_run()
|
| 473 |
+
except KeyboardInterrupt:
|
| 474 |
+
app.stop()
|
SimpleDances/static/index.html
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 7 |
+
<title>Reachy Mini • Simple Dances</title>
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
</head>
|
| 10 |
+
|
| 11 |
+
<body>
|
| 12 |
+
<div class="background-glow"></div>
|
| 13 |
+
<main class="app-shell">
|
| 14 |
+
<header class="hero">
|
| 15 |
+
<div>
|
| 16 |
+
<p class="eyebrow">Reachy Mini Playground</p>
|
| 17 |
+
<h1>Simple Dances Studio</h1>
|
| 18 |
+
<p class="subtitle">Pick a groove, fine-tune every motion axis, and let Reachy Mini vibe to your beat.</p>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="hero-meta">
|
| 21 |
+
<span class="chip" id="chipPlaying">Idle</span>
|
| 22 |
+
<span class="chip" id="chipSaved">Not saved</span>
|
| 23 |
+
</div>
|
| 24 |
+
</header>
|
| 25 |
+
|
| 26 |
+
<section class="panel-grid">
|
| 27 |
+
<article class="card selection-card">
|
| 28 |
+
<div class="card-header">
|
| 29 |
+
<div>
|
| 30 |
+
<h2>Dance Library</h2>
|
| 31 |
+
<p>Browse every move from the Reachy Mini Dances Library.</p>
|
| 32 |
+
</div>
|
| 33 |
+
<button class="ghost" id="resetDance">Reset dance</button>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="field">
|
| 36 |
+
<label for="danceSelect">Choose a dance</label>
|
| 37 |
+
<div class="select-wrapper">
|
| 38 |
+
<select id="danceSelect"></select>
|
| 39 |
+
<span class="chevron">⌄</span>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
<p class="description" id="danceDescription">Loading dances…</p>
|
| 43 |
+
</article>
|
| 44 |
+
|
| 45 |
+
<article class="card global-card">
|
| 46 |
+
<h2>Global Groove</h2>
|
| 47 |
+
<div class="field">
|
| 48 |
+
<div class="field-heading">
|
| 49 |
+
<label for="bpmRange">Beats Per Minute</label>
|
| 50 |
+
<span id="bpmDisplay">0 BPM</span>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="slider-row">
|
| 53 |
+
<input type="range" id="bpmRange" min="40" max="180" step="1">
|
| 54 |
+
<input type="number" id="bpmInput" min="40" max="180" step="1">
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="field">
|
| 58 |
+
<div class="field-heading">
|
| 59 |
+
<label for="ampRange">Amplitude Scale</label>
|
| 60 |
+
<span id="ampDisplay">0.0×</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="slider-row">
|
| 63 |
+
<input type="range" id="ampRange" min="0" max="2" step="0.05">
|
| 64 |
+
<input type="number" id="ampInput" min="0" max="2" step="0.05">
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</article>
|
| 68 |
+
|
| 69 |
+
<article class="card action-card">
|
| 70 |
+
<div class="card-header">
|
| 71 |
+
<h2>Playback</h2>
|
| 72 |
+
<p>Start or pause the routine at any time.</p>
|
| 73 |
+
</div>
|
| 74 |
+
<button id="togglePlayback" class="primary">Start Dancing</button>
|
| 75 |
+
<div class="status-line" id="statusLine">Syncing…</div>
|
| 76 |
+
</article>
|
| 77 |
+
</section>
|
| 78 |
+
|
| 79 |
+
<section class="card viz-card">
|
| 80 |
+
<div class="card-header">
|
| 81 |
+
<div>
|
| 82 |
+
<h2>Kinesthetic Visualizer</h2>
|
| 83 |
+
<p>Every DoF feeds the glow. Watch orientation, translation, and antennas pulse to the beat.</p>
|
| 84 |
+
</div>
|
| 85 |
+
<span class="chip" id="vizBpm">0 BPM</span>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="viz-grid" id="vizGrid"></div>
|
| 88 |
+
</section>
|
| 89 |
+
|
| 90 |
+
<section class="card params-card">
|
| 91 |
+
<div class="card-header">
|
| 92 |
+
<div>
|
| 93 |
+
<h2>Dance Parameters</h2>
|
| 94 |
+
<p>Fine-tune anything: waveforms, amplitudes, twitches, antenna flair.</p>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
<div id="paramsContainer" class="params-grid">
|
| 98 |
+
<div class="empty">Select a dance to load its parameters.</div>
|
| 99 |
+
</div>
|
| 100 |
+
</section>
|
| 101 |
+
</main>
|
| 102 |
+
|
| 103 |
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
| 104 |
+
<script src="/static/main.js" type="module"></script>
|
| 105 |
+
</body>
|
| 106 |
+
|
| 107 |
+
</html>
|
SimpleDances/static/main.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const danceSelect = document.getElementById('danceSelect');
|
| 2 |
+
const resetDanceBtn = document.getElementById('resetDance');
|
| 3 |
+
const descriptionEl = document.getElementById('danceDescription');
|
| 4 |
+
const paramsContainer = document.getElementById('paramsContainer');
|
| 5 |
+
const toggleBtn = document.getElementById('togglePlayback');
|
| 6 |
+
const statusLine = document.getElementById('statusLine');
|
| 7 |
+
const chipPlaying = document.getElementById('chipPlaying');
|
| 8 |
+
const chipSaved = document.getElementById('chipSaved');
|
| 9 |
+
const bpmRange = document.getElementById('bpmRange');
|
| 10 |
+
const bpmInput = document.getElementById('bpmInput');
|
| 11 |
+
const bpmDisplay = document.getElementById('bpmDisplay');
|
| 12 |
+
const ampRange = document.getElementById('ampRange');
|
| 13 |
+
const ampInput = document.getElementById('ampInput');
|
| 14 |
+
const ampDisplay = document.getElementById('ampDisplay');
|
| 15 |
+
const toast = document.getElementById('toast');
|
| 16 |
+
const vizGrid = document.getElementById('vizGrid');
|
| 17 |
+
const vizBpm = document.getElementById('vizBpm');
|
| 18 |
+
|
| 19 |
+
const currentUrl = new URL(window.location.href);
|
| 20 |
+
if (!currentUrl.pathname.endsWith('/')) {
|
| 21 |
+
currentUrl.pathname += '/';
|
| 22 |
+
}
|
| 23 |
+
currentUrl.search = '';
|
| 24 |
+
currentUrl.hash = '';
|
| 25 |
+
const buildApiUrl = (path) => {
|
| 26 |
+
const clean = path.startsWith('/') ? path.slice(1) : path;
|
| 27 |
+
return new URL(clean, currentUrl).toString();
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const state = {
|
| 31 |
+
dances: [],
|
| 32 |
+
selected: null,
|
| 33 |
+
playing: false,
|
| 34 |
+
bpm: 110,
|
| 35 |
+
amplitudeScale: 1,
|
| 36 |
+
paramSpecs: [],
|
| 37 |
+
description: '',
|
| 38 |
+
lastSaved: null,
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const paramQueue = new Map();
|
| 42 |
+
const vizNodes = [];
|
| 43 |
+
const vizConfig = [
|
| 44 |
+
{ key: 'orientation', index: 0, label: 'Roll', unit: '°', scale: 60 },
|
| 45 |
+
{ key: 'orientation', index: 1, label: 'Pitch', unit: '°', scale: 60 },
|
| 46 |
+
{ key: 'orientation', index: 2, label: 'Yaw', unit: '°', scale: 60 },
|
| 47 |
+
{ key: 'position', index: 0, label: 'X Shift', unit: 'mm', scale: 40 },
|
| 48 |
+
{ key: 'position', index: 1, label: 'Y Shift', unit: 'mm', scale: 40 },
|
| 49 |
+
{ key: 'position', index: 2, label: 'Z Lift', unit: 'mm', scale: 40 },
|
| 50 |
+
{ key: 'antennas', index: 0, label: 'Left Antenna', unit: '°', scale: 90 },
|
| 51 |
+
{ key: 'antennas', index: 1, label: 'Right Antenna', unit: '°', scale: 90 },
|
| 52 |
+
];
|
| 53 |
+
|
| 54 |
+
function debounce(fn, delay) {
|
| 55 |
+
let timer;
|
| 56 |
+
return (...args) => {
|
| 57 |
+
clearTimeout(timer);
|
| 58 |
+
timer = setTimeout(() => fn(...args), delay);
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
async function apiRequest(path, options = {}) {
|
| 63 |
+
const settings = { method: options.method || 'GET', headers: options.headers ? { ...options.headers } : {} };
|
| 64 |
+
if (options.body !== undefined) {
|
| 65 |
+
settings.body = options.body;
|
| 66 |
+
settings.headers['Content-Type'] = settings.headers['Content-Type'] || 'application/json';
|
| 67 |
+
}
|
| 68 |
+
const res = await fetch(buildApiUrl(path), settings);
|
| 69 |
+
if (!res.ok) {
|
| 70 |
+
throw new Error(`HTTP ${res.status}`);
|
| 71 |
+
}
|
| 72 |
+
const text = await res.text();
|
| 73 |
+
return text ? JSON.parse(text) : {};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function setStatus(text) {
|
| 77 |
+
statusLine.textContent = text;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function showToast(message) {
|
| 81 |
+
toast.textContent = message;
|
| 82 |
+
toast.classList.add('visible');
|
| 83 |
+
setTimeout(() => toast.classList.remove('visible'), 2500);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function updateSavedChip(ts) {
|
| 87 |
+
state.lastSaved = ts || null;
|
| 88 |
+
if (!ts) {
|
| 89 |
+
chipSaved.textContent = 'Not saved';
|
| 90 |
+
chipSaved.classList.add('alert');
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
chipSaved.classList.remove('alert');
|
| 94 |
+
const date = new Date(ts);
|
| 95 |
+
const label = Number.isNaN(date.getTime()) ? 'Saved' : `Saved ${date.toLocaleTimeString()}`;
|
| 96 |
+
chipSaved.textContent = label;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function updatePlayingChip(isPlaying) {
|
| 100 |
+
chipPlaying.textContent = isPlaying ? 'Playing' : 'Idle';
|
| 101 |
+
chipPlaying.classList.toggle('playing', isPlaying);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function updateToggleButton() {
|
| 105 |
+
toggleBtn.textContent = state.playing ? 'Pause Dance' : 'Start Dancing';
|
| 106 |
+
toggleBtn.classList.toggle('is-active', state.playing);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function renderDanceOptions() {
|
| 110 |
+
danceSelect.innerHTML = '';
|
| 111 |
+
state.dances.forEach((dance) => {
|
| 112 |
+
const option = document.createElement('option');
|
| 113 |
+
option.value = dance.name;
|
| 114 |
+
option.textContent = `${dance.label} (${dance.default_duration_beats} beats)`;
|
| 115 |
+
danceSelect.appendChild(option);
|
| 116 |
+
});
|
| 117 |
+
if (state.selected) {
|
| 118 |
+
danceSelect.value = state.selected;
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function renderParams(specs) {
|
| 123 |
+
paramsContainer.innerHTML = '';
|
| 124 |
+
if (!specs || !specs.length) {
|
| 125 |
+
const msg = document.createElement('div');
|
| 126 |
+
msg.className = 'empty';
|
| 127 |
+
msg.textContent = 'No parameters exposed for this dance.';
|
| 128 |
+
paramsContainer.appendChild(msg);
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
specs.forEach((spec) => {
|
| 132 |
+
const card = document.createElement('div');
|
| 133 |
+
card.className = 'param-card';
|
| 134 |
+
|
| 135 |
+
const title = document.createElement('h4');
|
| 136 |
+
title.textContent = spec.label;
|
| 137 |
+
card.appendChild(title);
|
| 138 |
+
|
| 139 |
+
const meta = document.createElement('div');
|
| 140 |
+
meta.className = 'param-meta';
|
| 141 |
+
meta.textContent = spec.unit ? `Unit: ${spec.unit}` : 'Unitless';
|
| 142 |
+
card.appendChild(meta);
|
| 143 |
+
|
| 144 |
+
if (spec.type === 'select') {
|
| 145 |
+
const select = document.createElement('select');
|
| 146 |
+
(spec.options || []).forEach((opt) => {
|
| 147 |
+
const option = document.createElement('option');
|
| 148 |
+
option.value = opt;
|
| 149 |
+
option.textContent = opt;
|
| 150 |
+
select.appendChild(option);
|
| 151 |
+
});
|
| 152 |
+
select.value = spec.value;
|
| 153 |
+
select.addEventListener('change', () => queueParamUpdate(spec.name, select.value));
|
| 154 |
+
const wrapper = document.createElement('div');
|
| 155 |
+
wrapper.className = 'param-inputs';
|
| 156 |
+
wrapper.appendChild(select);
|
| 157 |
+
card.appendChild(wrapper);
|
| 158 |
+
} else {
|
| 159 |
+
const wrapper = document.createElement('div');
|
| 160 |
+
wrapper.className = 'param-inputs';
|
| 161 |
+
|
| 162 |
+
const slider = document.createElement('input');
|
| 163 |
+
slider.type = 'range';
|
| 164 |
+
slider.min = spec.min;
|
| 165 |
+
slider.max = spec.max;
|
| 166 |
+
slider.step = spec.step;
|
| 167 |
+
slider.value = spec.value;
|
| 168 |
+
|
| 169 |
+
const number = document.createElement('input');
|
| 170 |
+
number.type = 'number';
|
| 171 |
+
number.min = spec.min;
|
| 172 |
+
number.max = spec.max;
|
| 173 |
+
number.step = spec.step;
|
| 174 |
+
number.value = Number(spec.value).toFixed(3);
|
| 175 |
+
|
| 176 |
+
const syncValue = (val) => {
|
| 177 |
+
slider.value = val;
|
| 178 |
+
number.value = Number(val).toFixed(3);
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
slider.addEventListener('input', () => {
|
| 182 |
+
const val = parseFloat(slider.value);
|
| 183 |
+
syncValue(val);
|
| 184 |
+
queueParamUpdate(spec.name, val);
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
number.addEventListener('change', () => {
|
| 188 |
+
const val = parseFloat(number.value);
|
| 189 |
+
if (Number.isNaN(val)) {
|
| 190 |
+
number.value = slider.value;
|
| 191 |
+
return;
|
| 192 |
+
}
|
| 193 |
+
const clamped = Math.min(Math.max(val, spec.min), spec.max);
|
| 194 |
+
syncValue(clamped);
|
| 195 |
+
queueParamUpdate(spec.name, clamped);
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
wrapper.appendChild(slider);
|
| 199 |
+
wrapper.appendChild(number);
|
| 200 |
+
card.appendChild(wrapper);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
paramsContainer.appendChild(card);
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const flushParamUpdates = debounce(async () => {
|
| 208 |
+
if (!state.selected || !paramQueue.size) return;
|
| 209 |
+
const payload = {};
|
| 210 |
+
paramQueue.forEach((value, key) => {
|
| 211 |
+
payload[key] = value;
|
| 212 |
+
});
|
| 213 |
+
paramQueue.clear();
|
| 214 |
+
setStatus('Saving parameters…');
|
| 215 |
+
try {
|
| 216 |
+
const data = await apiRequest('api/params', {
|
| 217 |
+
method: 'POST',
|
| 218 |
+
body: JSON.stringify({ name: state.selected, params: payload }),
|
| 219 |
+
});
|
| 220 |
+
if (Array.isArray(data.param_specs)) {
|
| 221 |
+
state.paramSpecs = data.param_specs;
|
| 222 |
+
renderParams(state.paramSpecs);
|
| 223 |
+
}
|
| 224 |
+
if (data.last_saved) {
|
| 225 |
+
updateSavedChip(data.last_saved);
|
| 226 |
+
}
|
| 227 |
+
setStatus('Parameters synced.');
|
| 228 |
+
} catch (error) {
|
| 229 |
+
setStatus('Parameter sync failed.');
|
| 230 |
+
showToast('Could not save parameter changes.');
|
| 231 |
+
}
|
| 232 |
+
}, 220);
|
| 233 |
+
|
| 234 |
+
function queueParamUpdate(name, value) {
|
| 235 |
+
if (!state.selected) return;
|
| 236 |
+
paramQueue.set(name, value);
|
| 237 |
+
flushParamUpdates();
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const saveGlobals = debounce(async () => {
|
| 241 |
+
const payload = {
|
| 242 |
+
bpm: parseFloat(bpmInput.value) || state.bpm,
|
| 243 |
+
amplitude_scale: parseFloat(ampInput.value) || state.amplitudeScale,
|
| 244 |
+
};
|
| 245 |
+
setStatus('Saving globals…');
|
| 246 |
+
try {
|
| 247 |
+
const data = await apiRequest('api/globals', {
|
| 248 |
+
method: 'POST',
|
| 249 |
+
body: JSON.stringify(payload),
|
| 250 |
+
});
|
| 251 |
+
state.bpm = data.bpm;
|
| 252 |
+
state.amplitudeScale = data.amplitude_scale;
|
| 253 |
+
if (data.last_saved) {
|
| 254 |
+
updateSavedChip(data.last_saved);
|
| 255 |
+
}
|
| 256 |
+
setStatus('Globals synced.');
|
| 257 |
+
updateGlobalsUI();
|
| 258 |
+
} catch (error) {
|
| 259 |
+
setStatus('Global sync failed.');
|
| 260 |
+
showToast('Failed to save global settings.');
|
| 261 |
+
}
|
| 262 |
+
}, 250);
|
| 263 |
+
|
| 264 |
+
function updateGlobalsUI() {
|
| 265 |
+
bpmRange.value = state.bpm;
|
| 266 |
+
bpmInput.value = state.bpm.toFixed(0);
|
| 267 |
+
bpmDisplay.textContent = `${Math.round(state.bpm)} BPM`;
|
| 268 |
+
ampRange.value = state.amplitudeScale;
|
| 269 |
+
ampInput.value = state.amplitudeScale.toFixed(2);
|
| 270 |
+
ampDisplay.textContent = `${state.amplitudeScale.toFixed(2)}×`;
|
| 271 |
+
vizBpm.textContent = `${Math.round(state.bpm)} BPM`;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function initVisualizationGrid() {
|
| 275 |
+
vizGrid.innerHTML = '';
|
| 276 |
+
vizNodes.length = 0;
|
| 277 |
+
vizConfig.forEach((cfg) => {
|
| 278 |
+
const node = document.createElement('div');
|
| 279 |
+
node.className = 'viz-node';
|
| 280 |
+
const orb = document.createElement('div');
|
| 281 |
+
orb.className = 'viz-orb';
|
| 282 |
+
node.appendChild(orb);
|
| 283 |
+
const title = document.createElement('h4');
|
| 284 |
+
title.textContent = cfg.label;
|
| 285 |
+
node.appendChild(title);
|
| 286 |
+
const value = document.createElement('div');
|
| 287 |
+
value.className = 'viz-value';
|
| 288 |
+
value.textContent = cfg.unit === 'mm' ? '0.0 mm' : '0.0°';
|
| 289 |
+
node.appendChild(value);
|
| 290 |
+
const pulse = document.createElement('div');
|
| 291 |
+
pulse.className = 'viz-pulse';
|
| 292 |
+
node.appendChild(pulse);
|
| 293 |
+
vizGrid.appendChild(node);
|
| 294 |
+
vizNodes.push({ cfg, node, valueEl: value, level: 0, targetLevel: 0, currentValue: 0, targetValue: 0 });
|
| 295 |
+
});
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function formatVizValue(value, unit) {
|
| 299 |
+
if (unit === '°') {
|
| 300 |
+
return `${value.toFixed(1)}°`;
|
| 301 |
+
}
|
| 302 |
+
if (unit === 'mm') {
|
| 303 |
+
return `${value.toFixed(1)} mm`;
|
| 304 |
+
}
|
| 305 |
+
return value.toFixed(2);
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function updateVisualizationTargets(offsets) {
|
| 309 |
+
vizNodes.forEach((node) => {
|
| 310 |
+
const values = offsets[node.cfg.key] || [];
|
| 311 |
+
const raw = Number(values[node.cfg.index] || 0);
|
| 312 |
+
let display = raw;
|
| 313 |
+
if (node.cfg.unit === '°') {
|
| 314 |
+
display = raw * (180 / Math.PI);
|
| 315 |
+
} else if (node.cfg.unit === 'mm') {
|
| 316 |
+
display = raw * 1000;
|
| 317 |
+
}
|
| 318 |
+
node.targetValue = display;
|
| 319 |
+
node.targetLevel = Math.min(Math.abs(display) / node.cfg.scale, 1);
|
| 320 |
+
});
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
function animateVisualization() {
|
| 324 |
+
vizNodes.forEach((node) => {
|
| 325 |
+
node.level += (node.targetLevel - node.level) * 0.08;
|
| 326 |
+
node.currentValue += (node.targetValue - node.currentValue) * 0.12;
|
| 327 |
+
node.node.style.setProperty('--level', node.level.toFixed(3));
|
| 328 |
+
node.node.dataset.level = node.level.toFixed(2);
|
| 329 |
+
node.valueEl.textContent = formatVizValue(node.currentValue, node.cfg.unit);
|
| 330 |
+
});
|
| 331 |
+
requestAnimationFrame(animateVisualization);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
async function pollVisualization() {
|
| 335 |
+
try {
|
| 336 |
+
const data = await apiRequest('api/visualization');
|
| 337 |
+
if (data && data.offsets) {
|
| 338 |
+
updateVisualizationTargets(data.offsets);
|
| 339 |
+
}
|
| 340 |
+
if (typeof data?.playing === 'boolean') {
|
| 341 |
+
updatePlayingChip(data.playing);
|
| 342 |
+
}
|
| 343 |
+
if (typeof data?.bpm === 'number') {
|
| 344 |
+
vizBpm.textContent = `${Math.round(data.bpm)} BPM`;
|
| 345 |
+
}
|
| 346 |
+
} catch (error) {
|
| 347 |
+
// Swallow visualization errors to avoid noisy UI.
|
| 348 |
+
} finally {
|
| 349 |
+
setTimeout(pollVisualization, 250);
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
async function loadDances() {
|
| 354 |
+
try {
|
| 355 |
+
const data = await apiRequest('api/dances');
|
| 356 |
+
state.dances = data.dances || [];
|
| 357 |
+
renderDanceOptions();
|
| 358 |
+
} catch (error) {
|
| 359 |
+
showToast('Unable to load dance list.');
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
async function refreshState() {
|
| 364 |
+
try {
|
| 365 |
+
const data = await apiRequest('api/state');
|
| 366 |
+
state.selected = data.selected;
|
| 367 |
+
state.playing = Boolean(data.playing);
|
| 368 |
+
state.bpm = data.bpm;
|
| 369 |
+
state.amplitudeScale = data.amplitude_scale;
|
| 370 |
+
state.paramSpecs = data.param_specs || [];
|
| 371 |
+
state.description = data.description || '';
|
| 372 |
+
updateSavedChip(data.last_saved);
|
| 373 |
+
renderDanceOptions();
|
| 374 |
+
renderParams(state.paramSpecs);
|
| 375 |
+
descriptionEl.textContent = state.description || 'This dance is ready to jam.';
|
| 376 |
+
danceSelect.value = state.selected;
|
| 377 |
+
updateGlobalsUI();
|
| 378 |
+
updatePlayingChip(state.playing);
|
| 379 |
+
updateToggleButton();
|
| 380 |
+
setStatus('Ready.');
|
| 381 |
+
} catch (error) {
|
| 382 |
+
setStatus('Failed to load state.');
|
| 383 |
+
showToast('Cannot reach the backend API.');
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
async function selectDance(name) {
|
| 388 |
+
setStatus('Loading dance…');
|
| 389 |
+
try {
|
| 390 |
+
const data = await apiRequest('api/select', {
|
| 391 |
+
method: 'POST',
|
| 392 |
+
body: JSON.stringify({ name }),
|
| 393 |
+
});
|
| 394 |
+
state.selected = data.selected;
|
| 395 |
+
state.paramSpecs = data.param_specs || [];
|
| 396 |
+
state.description = data.description || '';
|
| 397 |
+
renderDanceOptions();
|
| 398 |
+
renderParams(state.paramSpecs);
|
| 399 |
+
descriptionEl.textContent = state.description || 'Customizable move.';
|
| 400 |
+
setStatus('Dance loaded.');
|
| 401 |
+
} catch (error) {
|
| 402 |
+
setStatus('Unable to load dance.');
|
| 403 |
+
showToast('Dance selection failed.');
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
async function togglePlayback() {
|
| 408 |
+
try {
|
| 409 |
+
const targetState = !state.playing;
|
| 410 |
+
const data = await apiRequest('api/toggle', {
|
| 411 |
+
method: 'POST',
|
| 412 |
+
body: JSON.stringify({ playing: targetState }),
|
| 413 |
+
});
|
| 414 |
+
state.playing = Boolean(data.playing);
|
| 415 |
+
updatePlayingChip(state.playing);
|
| 416 |
+
updateToggleButton();
|
| 417 |
+
} catch (error) {
|
| 418 |
+
showToast('Playback toggle failed.');
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
async function resetDance() {
|
| 423 |
+
if (!state.selected) return;
|
| 424 |
+
setStatus('Resetting dance…');
|
| 425 |
+
try {
|
| 426 |
+
const data = await apiRequest('api/reset', {
|
| 427 |
+
method: 'POST',
|
| 428 |
+
body: JSON.stringify({ name: state.selected }),
|
| 429 |
+
});
|
| 430 |
+
state.paramSpecs = data.param_specs || [];
|
| 431 |
+
state.description = data.description || state.description;
|
| 432 |
+
updateSavedChip(data.last_saved);
|
| 433 |
+
renderParams(state.paramSpecs);
|
| 434 |
+
descriptionEl.textContent = state.description;
|
| 435 |
+
setStatus('Dance reset to defaults.');
|
| 436 |
+
} catch (error) {
|
| 437 |
+
setStatus('Reset failed.');
|
| 438 |
+
showToast('Could not reset dance.');
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
function attachEventListeners() {
|
| 443 |
+
danceSelect.addEventListener('change', () => {
|
| 444 |
+
const name = danceSelect.value;
|
| 445 |
+
if (name) {
|
| 446 |
+
selectDance(name);
|
| 447 |
+
}
|
| 448 |
+
});
|
| 449 |
+
|
| 450 |
+
resetDanceBtn.addEventListener('click', resetDance);
|
| 451 |
+
toggleBtn.addEventListener('click', togglePlayback);
|
| 452 |
+
|
| 453 |
+
bpmRange.addEventListener('input', () => {
|
| 454 |
+
state.bpm = parseFloat(bpmRange.value);
|
| 455 |
+
updateGlobalsUI();
|
| 456 |
+
saveGlobals();
|
| 457 |
+
});
|
| 458 |
+
bpmInput.addEventListener('change', () => {
|
| 459 |
+
const value = parseFloat(bpmInput.value);
|
| 460 |
+
if (Number.isNaN(value)) {
|
| 461 |
+
bpmInput.value = state.bpm.toFixed(0);
|
| 462 |
+
return;
|
| 463 |
+
}
|
| 464 |
+
state.bpm = Math.min(Math.max(value, 40), 180);
|
| 465 |
+
updateGlobalsUI();
|
| 466 |
+
saveGlobals();
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
ampRange.addEventListener('input', () => {
|
| 470 |
+
state.amplitudeScale = parseFloat(ampRange.value);
|
| 471 |
+
updateGlobalsUI();
|
| 472 |
+
saveGlobals();
|
| 473 |
+
});
|
| 474 |
+
ampInput.addEventListener('change', () => {
|
| 475 |
+
const value = parseFloat(ampInput.value);
|
| 476 |
+
if (Number.isNaN(value)) {
|
| 477 |
+
ampInput.value = state.amplitudeScale.toFixed(2);
|
| 478 |
+
return;
|
| 479 |
+
}
|
| 480 |
+
state.amplitudeScale = Math.min(Math.max(value, 0), 2);
|
| 481 |
+
updateGlobalsUI();
|
| 482 |
+
saveGlobals();
|
| 483 |
+
});
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
async function init() {
|
| 487 |
+
setStatus('Booting UI…');
|
| 488 |
+
initVisualizationGrid();
|
| 489 |
+
attachEventListeners();
|
| 490 |
+
await loadDances();
|
| 491 |
+
await refreshState();
|
| 492 |
+
pollVisualization();
|
| 493 |
+
requestAnimationFrame(animateVisualization);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
init();
|
SimpleDances/static/style.css
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
| 3 |
+
color: #f7f7fb;
|
| 4 |
+
background-color: #05060a;
|
| 5 |
+
--card-bg: rgba(14, 16, 26, 0.6);
|
| 6 |
+
--card-border: rgba(255, 255, 255, 0.08);
|
| 7 |
+
--accent: #ff8ba7;
|
| 8 |
+
--accent-strong: #ff5f6d;
|
| 9 |
+
--accent-secondary: #87f1ff;
|
| 10 |
+
--text-muted: rgba(247, 247, 251, 0.7);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
* {
|
| 14 |
+
box-sizing: border-box;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
body {
|
| 18 |
+
margin: 0;
|
| 19 |
+
min-height: 100vh;
|
| 20 |
+
background: radial-gradient(circle at top, rgba(255, 95, 109, 0.25), transparent 50%),
|
| 21 |
+
radial-gradient(circle at 20% 20%, rgba(135, 241, 255, 0.18), transparent 55%),
|
| 22 |
+
#05060a;
|
| 23 |
+
color: #fff;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.background-glow {
|
| 27 |
+
position: fixed;
|
| 28 |
+
inset: 0;
|
| 29 |
+
background: radial-gradient(circle at 80% 10%, rgba(255, 206, 134, 0.4), transparent 60%),
|
| 30 |
+
radial-gradient(circle at 10% 80%, rgba(96, 88, 255, 0.35), transparent 70%);
|
| 31 |
+
filter: blur(90px);
|
| 32 |
+
opacity: 0.6;
|
| 33 |
+
pointer-events: none;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.app-shell {
|
| 37 |
+
position: relative;
|
| 38 |
+
max-width: 1200px;
|
| 39 |
+
margin: 0 auto;
|
| 40 |
+
padding: 48px 24px 80px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.hero {
|
| 44 |
+
display: flex;
|
| 45 |
+
flex-wrap: wrap;
|
| 46 |
+
justify-content: space-between;
|
| 47 |
+
gap: 24px;
|
| 48 |
+
margin-bottom: 32px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.eyebrow {
|
| 52 |
+
text-transform: uppercase;
|
| 53 |
+
font-size: 0.75rem;
|
| 54 |
+
letter-spacing: 0.25rem;
|
| 55 |
+
color: var(--text-muted);
|
| 56 |
+
margin: 0 0 8px;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.hero h1 {
|
| 60 |
+
margin: 0;
|
| 61 |
+
font-size: clamp(2.2rem, 4vw, 3.2rem);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.subtitle {
|
| 65 |
+
margin-top: 8px;
|
| 66 |
+
color: var(--text-muted);
|
| 67 |
+
max-width: 600px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.hero-meta {
|
| 71 |
+
display: flex;
|
| 72 |
+
gap: 12px;
|
| 73 |
+
align-items: flex-start;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.panel-grid {
|
| 77 |
+
display: grid;
|
| 78 |
+
gap: 24px;
|
| 79 |
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
| 80 |
+
margin-bottom: 24px;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.card {
|
| 84 |
+
background: var(--card-bg);
|
| 85 |
+
border: 1px solid var(--card-border);
|
| 86 |
+
border-radius: 18px;
|
| 87 |
+
padding: 24px;
|
| 88 |
+
box-shadow: 0 25px 70px rgba(0, 0, 0, 0.35);
|
| 89 |
+
backdrop-filter: blur(16px);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.card h2 {
|
| 93 |
+
margin: 0 0 6px;
|
| 94 |
+
font-size: 1.4rem;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.card p {
|
| 98 |
+
margin: 0;
|
| 99 |
+
color: var(--text-muted);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.card-header {
|
| 103 |
+
display: flex;
|
| 104 |
+
align-items: flex-start;
|
| 105 |
+
justify-content: space-between;
|
| 106 |
+
gap: 16px;
|
| 107 |
+
margin-bottom: 16px;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.field {
|
| 111 |
+
margin-top: 12px;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.field-heading {
|
| 115 |
+
display: flex;
|
| 116 |
+
justify-content: space-between;
|
| 117 |
+
align-items: baseline;
|
| 118 |
+
font-size: 0.9rem;
|
| 119 |
+
color: var(--text-muted);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.select-wrapper {
|
| 123 |
+
position: relative;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
select {
|
| 127 |
+
width: 100%;
|
| 128 |
+
padding: 12px 16px;
|
| 129 |
+
border-radius: 12px;
|
| 130 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 131 |
+
background: rgba(255, 255, 255, 0.05);
|
| 132 |
+
color: #fff;
|
| 133 |
+
font-size: 1rem;
|
| 134 |
+
appearance: none;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
select:focus {
|
| 138 |
+
outline: 2px solid var(--accent-secondary);
|
| 139 |
+
border-color: transparent;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.chevron {
|
| 143 |
+
position: absolute;
|
| 144 |
+
right: 16px;
|
| 145 |
+
top: 50%;
|
| 146 |
+
transform: translateY(-50%);
|
| 147 |
+
pointer-events: none;
|
| 148 |
+
color: var(--text-muted);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.description {
|
| 152 |
+
margin-top: 16px;
|
| 153 |
+
line-height: 1.4;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.slider-row {
|
| 157 |
+
display: grid;
|
| 158 |
+
grid-template-columns: 1fr 90px;
|
| 159 |
+
gap: 12px;
|
| 160 |
+
margin-top: 8px;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
input[type="range"] {
|
| 164 |
+
width: 100%;
|
| 165 |
+
accent-color: var(--accent);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
input[type="number"] {
|
| 169 |
+
width: 100%;
|
| 170 |
+
padding: 10px;
|
| 171 |
+
border-radius: 10px;
|
| 172 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 173 |
+
background: rgba(255, 255, 255, 0.05);
|
| 174 |
+
color: #fff;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
input[type="number"]:focus {
|
| 178 |
+
outline: 1px solid var(--accent-secondary);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.primary {
|
| 182 |
+
width: 100%;
|
| 183 |
+
padding: 14px;
|
| 184 |
+
border-radius: 999px;
|
| 185 |
+
border: none;
|
| 186 |
+
font-size: 1rem;
|
| 187 |
+
font-weight: 600;
|
| 188 |
+
background: linear-gradient(120deg, var(--accent), var(--accent-secondary));
|
| 189 |
+
color: #05060a;
|
| 190 |
+
cursor: pointer;
|
| 191 |
+
transition: transform 0.2s ease, filter 0.2s ease;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.primary:hover {
|
| 195 |
+
transform: translateY(-2px);
|
| 196 |
+
filter: brightness(1.1);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.primary.is-active {
|
| 200 |
+
background: linear-gradient(120deg, #49e9a6, #7bffda);
|
| 201 |
+
color: #041314;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.ghost {
|
| 205 |
+
background: transparent;
|
| 206 |
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 207 |
+
border-radius: 999px;
|
| 208 |
+
color: #fff;
|
| 209 |
+
padding: 8px 16px;
|
| 210 |
+
cursor: pointer;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.status-line {
|
| 214 |
+
margin-top: 16px;
|
| 215 |
+
text-align: center;
|
| 216 |
+
font-size: 0.95rem;
|
| 217 |
+
color: var(--text-muted);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.chip {
|
| 221 |
+
display: inline-flex;
|
| 222 |
+
align-items: center;
|
| 223 |
+
padding: 6px 12px;
|
| 224 |
+
border-radius: 999px;
|
| 225 |
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 226 |
+
font-size: 0.85rem;
|
| 227 |
+
color: #fff;
|
| 228 |
+
background: rgba(255, 255, 255, 0.1);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.chip.playing {
|
| 232 |
+
border-color: rgba(73, 233, 166, 0.8);
|
| 233 |
+
background: rgba(73, 233, 166, 0.15);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.chip.alert {
|
| 237 |
+
border-color: rgba(255, 143, 178, 0.8);
|
| 238 |
+
background: rgba(255, 143, 178, 0.15);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.viz-card {
|
| 242 |
+
margin-bottom: 24px;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.viz-grid {
|
| 246 |
+
display: grid;
|
| 247 |
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 248 |
+
gap: 16px;
|
| 249 |
+
margin-top: 16px;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.viz-node {
|
| 253 |
+
position: relative;
|
| 254 |
+
padding: 16px;
|
| 255 |
+
border-radius: 16px;
|
| 256 |
+
background: rgba(255, 255, 255, 0.02);
|
| 257 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 258 |
+
overflow: hidden;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.viz-node h4 {
|
| 262 |
+
margin: 0 0 6px;
|
| 263 |
+
font-size: 0.95rem;
|
| 264 |
+
color: var(--text-muted);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.viz-value {
|
| 268 |
+
font-size: 1.4rem;
|
| 269 |
+
font-weight: 600;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.viz-pulse {
|
| 273 |
+
position: absolute;
|
| 274 |
+
inset: auto auto 0 0;
|
| 275 |
+
width: 100%;
|
| 276 |
+
height: 4px;
|
| 277 |
+
background: linear-gradient(90deg, var(--accent), var(--accent-secondary));
|
| 278 |
+
transform-origin: left center;
|
| 279 |
+
transform: scaleX(0);
|
| 280 |
+
opacity: 0.9;
|
| 281 |
+
transition: transform 0.3s ease;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.viz-orb {
|
| 285 |
+
position: absolute;
|
| 286 |
+
width: 120px;
|
| 287 |
+
height: 120px;
|
| 288 |
+
border-radius: 50%;
|
| 289 |
+
background: radial-gradient(circle, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0));
|
| 290 |
+
filter: blur(20px);
|
| 291 |
+
opacity: 0.5;
|
| 292 |
+
left: 50%;
|
| 293 |
+
top: 50%;
|
| 294 |
+
transform: translate(-50%, -50%) scale(0.4);
|
| 295 |
+
transition: transform 0.3s ease, opacity 0.3s ease;
|
| 296 |
+
pointer-events: none;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.viz-node[data-level] .viz-pulse {
|
| 300 |
+
transform: scaleX(var(--level, 0));
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.viz-node[data-level] .viz-orb {
|
| 304 |
+
opacity: calc(0.2 + var(--level, 0) * 0.8);
|
| 305 |
+
transform: translate(-50%, -50%) scale(calc(0.35 + var(--level, 0) * 0.65));
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.params-grid {
|
| 309 |
+
display: grid;
|
| 310 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 311 |
+
gap: 20px;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.param-card {
|
| 315 |
+
padding: 16px;
|
| 316 |
+
border-radius: 16px;
|
| 317 |
+
background: rgba(255, 255, 255, 0.03);
|
| 318 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.param-card h4 {
|
| 322 |
+
margin: 0 0 6px;
|
| 323 |
+
font-size: 1rem;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.param-meta {
|
| 327 |
+
font-size: 0.85rem;
|
| 328 |
+
color: var(--text-muted);
|
| 329 |
+
margin-bottom: 12px;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.param-inputs {
|
| 333 |
+
display: flex;
|
| 334 |
+
gap: 10px;
|
| 335 |
+
align-items: center;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
.param-inputs input[type="range"] {
|
| 339 |
+
flex: 1;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.param-inputs input[type="number"],
|
| 343 |
+
.param-inputs select {
|
| 344 |
+
width: 90px;
|
| 345 |
+
padding: 8px;
|
| 346 |
+
border-radius: 10px;
|
| 347 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 348 |
+
background: rgba(255, 255, 255, 0.05);
|
| 349 |
+
color: #fff;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.param-inputs select {
|
| 353 |
+
width: 100%;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.params-card .empty {
|
| 357 |
+
text-align: center;
|
| 358 |
+
padding: 24px;
|
| 359 |
+
color: var(--text-muted);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.toast {
|
| 363 |
+
position: fixed;
|
| 364 |
+
bottom: 32px;
|
| 365 |
+
right: 32px;
|
| 366 |
+
padding: 14px 18px;
|
| 367 |
+
background: rgba(0, 0, 0, 0.8);
|
| 368 |
+
border-radius: 12px;
|
| 369 |
+
color: #fff;
|
| 370 |
+
opacity: 0;
|
| 371 |
+
pointer-events: none;
|
| 372 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
| 373 |
+
transform: translateY(10px);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.toast.visible {
|
| 377 |
+
opacity: 1;
|
| 378 |
+
transform: translateY(0);
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
@media (max-width: 720px) {
|
| 382 |
+
.slider-row {
|
| 383 |
+
grid-template-columns: 1fr;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
input[type="number"] {
|
| 387 |
+
width: 100%;
|
| 388 |
+
}
|
| 389 |
+
}
|
index.html
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="utf-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width" />
|
| 7 |
+
<title> Simpledances </title>
|
| 8 |
+
<link rel="stylesheet" href="style.css" />
|
| 9 |
+
</head>
|
| 10 |
+
|
| 11 |
+
<body>
|
| 12 |
+
<div class="hero">
|
| 13 |
+
<div class="hero-content">
|
| 14 |
+
<div class="app-icon">🤖⚡</div>
|
| 15 |
+
<h1> Simpledances </h1>
|
| 16 |
+
<p class="tagline">Enter your tagline here</p>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div class="container">
|
| 21 |
+
<div class="main-card">
|
| 22 |
+
<div class="app-preview">
|
| 23 |
+
<div class="preview-image">
|
| 24 |
+
<div class="camera-feed">🛠️</div>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div class="download-section">
|
| 31 |
+
<div class="download-card">
|
| 32 |
+
<h2>Install This App</h2>
|
| 33 |
+
|
| 34 |
+
<div class="dashboard-config">
|
| 35 |
+
<label for="dashboardUrl">Your Reachy Dashboard URL:</label>
|
| 36 |
+
<input type="url" id="dashboardUrl" value="http://localhost:8000"
|
| 37 |
+
placeholder="http://your-reachy-ip:8000" />
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<button id="installBtn" class="install-btn primary">
|
| 41 |
+
<span class="btn-icon">📥</span>
|
| 42 |
+
Install Simpledances to Reachy Mini
|
| 43 |
+
</button>
|
| 44 |
+
|
| 45 |
+
<div id="installStatus" class="install-status"></div>
|
| 46 |
+
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div class="footer">
|
| 51 |
+
<p>
|
| 52 |
+
🤖 Simpledances •
|
| 53 |
+
<a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
|
| 54 |
+
<a href="https://huggingface.co/spaces/pollen-robotics/Reachy_Mini_Apps" target="_blank">Browse More
|
| 55 |
+
Apps</a>
|
| 56 |
+
</p>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<script>
|
| 61 |
+
// Get the current Hugging Face Space URL as the repository URL
|
| 62 |
+
function getCurrentSpaceUrl() {
|
| 63 |
+
// Get current page URL and convert to repository format
|
| 64 |
+
const currentUrl = window.location.href;
|
| 65 |
+
|
| 66 |
+
// Remove any trailing slashes and query parameters
|
| 67 |
+
const cleanUrl = currentUrl.split('?')[0].replace(/\/$/, '');
|
| 68 |
+
|
| 69 |
+
return cleanUrl;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Parse TOML content to extract project name
|
| 73 |
+
function parseTomlProjectName(tomlContent) {
|
| 74 |
+
try {
|
| 75 |
+
const lines = tomlContent.split('\n');
|
| 76 |
+
let inProjectSection = false;
|
| 77 |
+
|
| 78 |
+
for (const line of lines) {
|
| 79 |
+
const trimmedLine = line.trim();
|
| 80 |
+
|
| 81 |
+
// Check if we're entering the [project] section
|
| 82 |
+
if (trimmedLine === '[project]') {
|
| 83 |
+
inProjectSection = true;
|
| 84 |
+
continue;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Check if we're entering a different section
|
| 88 |
+
if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
|
| 89 |
+
inProjectSection = false;
|
| 90 |
+
continue;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// If we're in the project section, look for the name field
|
| 94 |
+
if (inProjectSection && trimmedLine.startsWith('name')) {
|
| 95 |
+
const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
|
| 96 |
+
if (match) {
|
| 97 |
+
// Convert to lowercase and replace invalid characters for app naming
|
| 98 |
+
return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
throw new Error('Project name not found in pyproject.toml');
|
| 104 |
+
} catch (error) {
|
| 105 |
+
console.error('Error parsing pyproject.toml:', error);
|
| 106 |
+
return 'unknown-app';
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// Fetch and parse pyproject.toml from the current space
|
| 111 |
+
async function getAppNameFromCurrentSpace() {
|
| 112 |
+
try {
|
| 113 |
+
// Fetch pyproject.toml from the current space
|
| 114 |
+
const response = await fetch('./pyproject.toml');
|
| 115 |
+
if (!response.ok) {
|
| 116 |
+
throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const tomlContent = await response.text();
|
| 120 |
+
return parseTomlProjectName(tomlContent);
|
| 121 |
+
} catch (error) {
|
| 122 |
+
console.error('Error fetching app name from current space:', error);
|
| 123 |
+
// Fallback to extracting from URL if pyproject.toml is not accessible
|
| 124 |
+
const url = getCurrentSpaceUrl();
|
| 125 |
+
const parts = url.split('/');
|
| 126 |
+
const spaceName = parts[parts.length - 1];
|
| 127 |
+
return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
async function installToReachy() {
|
| 132 |
+
const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
|
| 133 |
+
const statusDiv = document.getElementById('installStatus');
|
| 134 |
+
const installBtn = document.getElementById('installBtn');
|
| 135 |
+
|
| 136 |
+
if (!dashboardUrl) {
|
| 137 |
+
showStatus('error', 'Please enter your Reachy dashboard URL');
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
try {
|
| 142 |
+
installBtn.disabled = true;
|
| 143 |
+
installBtn.innerHTML = '<span class="btn-icon">⏳</span>Installing...';
|
| 144 |
+
showStatus('loading', 'Connecting to your Reachy dashboard...');
|
| 145 |
+
|
| 146 |
+
// Test connection
|
| 147 |
+
const testResponse = await fetch(`${dashboardUrl}/api/status`, {
|
| 148 |
+
method: 'GET',
|
| 149 |
+
mode: 'cors',
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
if (!testResponse.ok) {
|
| 153 |
+
throw new Error('Cannot connect to dashboard. Make sure the URL is correct and the dashboard is running.');
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
showStatus('loading', 'Reading app configuration...');
|
| 157 |
+
|
| 158 |
+
// Get app name from pyproject.toml in current space
|
| 159 |
+
const appName = await getAppNameFromCurrentSpace();
|
| 160 |
+
|
| 161 |
+
// Get current space URL as repository URL
|
| 162 |
+
const repoUrl = getCurrentSpaceUrl();
|
| 163 |
+
|
| 164 |
+
showStatus('loading', `Starting installation of "${appName}"...`);
|
| 165 |
+
|
| 166 |
+
// Start installation
|
| 167 |
+
const installResponse = await fetch(`${dashboardUrl}/api/install`, {
|
| 168 |
+
method: 'POST',
|
| 169 |
+
mode: 'cors',
|
| 170 |
+
headers: {
|
| 171 |
+
'Content-Type': 'application/json',
|
| 172 |
+
},
|
| 173 |
+
body: JSON.stringify({
|
| 174 |
+
url: repoUrl,
|
| 175 |
+
name: appName
|
| 176 |
+
})
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
const result = await installResponse.json();
|
| 180 |
+
|
| 181 |
+
if (installResponse.ok) {
|
| 182 |
+
showStatus('success', `✅ Installation started for "${appName}"! Check your dashboard for progress.`);
|
| 183 |
+
setTimeout(() => {
|
| 184 |
+
showStatus('info', `Open your dashboard at ${dashboardUrl} to see the installed app.`);
|
| 185 |
+
}, 3000);
|
| 186 |
+
} else {
|
| 187 |
+
throw new Error(result.detail || 'Installation failed');
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
} catch (error) {
|
| 191 |
+
console.error('Installation error:', error);
|
| 192 |
+
showStatus('error', `❌ ${error.message}`);
|
| 193 |
+
} finally {
|
| 194 |
+
installBtn.disabled = false;
|
| 195 |
+
installBtn.innerHTML = '<span class="btn-icon">📥</span>Install App to Reachy';
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function showStatus(type, message) {
|
| 200 |
+
const statusDiv = document.getElementById('installStatus');
|
| 201 |
+
statusDiv.className = `install-status ${type}`;
|
| 202 |
+
statusDiv.textContent = message;
|
| 203 |
+
statusDiv.style.display = 'block';
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function copyToClipboard() {
|
| 207 |
+
const repoUrl = document.getElementById('repoUrl').textContent;
|
| 208 |
+
navigator.clipboard.writeText(repoUrl).then(() => {
|
| 209 |
+
showStatus('success', '📋 Repository URL copied to clipboard!');
|
| 210 |
+
}).catch(() => {
|
| 211 |
+
showStatus('error', 'Failed to copy URL. Please copy manually.');
|
| 212 |
+
});
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Update the displayed repository URL on page load
|
| 216 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 217 |
+
// Auto-detect local dashboard
|
| 218 |
+
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
| 219 |
+
if (isLocalhost) {
|
| 220 |
+
document.getElementById('dashboardUrl').value = 'http://localhost:8000';
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// Update the repository URL display if element exists
|
| 224 |
+
const repoUrlElement = document.getElementById('repoUrl');
|
| 225 |
+
if (repoUrlElement) {
|
| 226 |
+
repoUrlElement.textContent = getCurrentSpaceUrl();
|
| 227 |
+
}
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
// Event listeners
|
| 231 |
+
document.getElementById('installBtn').addEventListener('click', installToReachy);
|
| 232 |
+
</script>
|
| 233 |
+
</body>
|
| 234 |
+
|
| 235 |
+
</html>
|
plan.txt
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Plan for SimpleDances reachy mini app (v1)
|
| 2 |
+
=========================================
|
| 3 |
+
|
| 4 |
+
0. Context & goals
|
| 5 |
+
------------------
|
| 6 |
+
- Reuse the structure of `reachy_mini_radio` to build a Reachy Mini app with a custom UI hosted from `SimpleDances/SimpleDances/static`.
|
| 7 |
+
- Drive actual motions using `reachy_mini_dances_library`, exposing *all* available moves with tunable parameters.
|
| 8 |
+
- Provide a clean web UI with dance selection, parameter sliders + numeric inputs, start/stop toggle, BPM + amplitude sliders, reset-to-default per dance, and a rhythmic visualization tied to all 8 DoF (6 head + 2 antenna) using live data from the control loop.
|
| 9 |
+
- Persist parameter tweaks so each dance remembers its last settings between runs.
|
| 10 |
+
|
| 11 |
+
1. Backend design (SimpleDances/SimpleDances/main.py)
|
| 12 |
+
----------------------------------------------------
|
| 13 |
+
- Import `AVAILABLE_MOVES` + antenna metadata from the dance library, and pre-compute a catalog: name, friendly label (title case), description, default params, metadata (duration, etc.).
|
| 14 |
+
- Build helpers to:
|
| 15 |
+
* Load/save persisted parameters (JSON file adjacent to main.py). Structure: `{ "globals": {"bpm": float, "amplitude_scale": float}, "dances": {dance_name: {param: value}} }`.
|
| 16 |
+
* Merge saved params with defaults per dance.
|
| 17 |
+
* Derive UI specs for each param (type, slider min/max/step, unit, display multiplier) using heuristics based on the param name (e.g. `_rad` => degrees slider ±(default*3 capped), `_m` => centimeters range, `subcycles` => 0-4, `phase_offset` => 0-1, enumerations for waveform/antenna moves).
|
| 18 |
+
* Convert between internal units and UI units when serving values + when updates arrive.
|
| 19 |
+
- Shared runtime state guarded by a `threading.Lock`: selected dance name, per-dance params, BPM, amplitude scale, playing flag, timestamps, last computed offsets for visualization, file persistence timestamps, etc.
|
| 20 |
+
- Settings API endpoints on `self.settings_app`:
|
| 21 |
+
* `GET /api/dances` -> list of dances (name, label, description).
|
| 22 |
+
* `GET /api/state` -> current selection, playing flag, bpm, amplitude scale, parameter specs for the selected dance (values already converted for UI), and last-saved timestamp.
|
| 23 |
+
* `POST /api/select` -> pick a dance (payload: `{name}`), responds with updated param specs + selection.
|
| 24 |
+
* `POST /api/params` -> update parameters for the (selected) dance; payload includes `name` + `params` dictionary in UI units; convert, persist, respond with new specs.
|
| 25 |
+
* `POST /api/globals` -> update BPM and amplitude scale with validation + persistence.
|
| 26 |
+
* `POST /api/toggle` -> set playing flag (payload `{"playing": bool}`) and resets timing when toggling on.
|
| 27 |
+
* `POST /api/reset` -> reset a dance back to defaults (payload `{"name": ...}`) with save + updated specs.
|
| 28 |
+
* `GET /api/visualization` -> return most recent normalized offsets (position/orientation/antennas arrays) for front-end animation plus playing + bpm (read-only, polled at ~10–15 Hz by UI).
|
| 29 |
+
- Control loop logic:
|
| 30 |
+
* On `run`, load ReachyMini, ensure motors enabled as needed (maybe only when playing), instantiate start time.
|
| 31 |
+
* Inside loop (50–100 Hz), read `state` snapshot; if playing and a dance is selected, compute beat time using global BPM, call the corresponding move function with stored params, scale offsets by amplitude scale, convert to head pose with `create_head_pose`, and send `reachy_mini.set_target(head=head_pose, antennas=...)`. If stopped, gradually go to neutral pose (or hold last command) to avoid abrupt freeze.
|
| 32 |
+
* Update shared `latest_offsets` for visualization each cycle, store for `/api/visualization`.
|
| 33 |
+
* Sleep for ~0.02s.
|
| 34 |
+
- Persist JSON whenever params/globals change (debounce by writing immediately under lock).
|
| 35 |
+
- Handle graceful shutdown (stop flag already provided); ensure motors disabled on exit.
|
| 36 |
+
|
| 37 |
+
2. Front-end (SimpleDances/SimpleDances/static/{index.html,style.css,main.js})
|
| 38 |
+
-----------------------------------------------------------------------------
|
| 39 |
+
- Layout: split page with header, global controls, dance selector, parameter panel, start/stop button, and visualization canvas/bars.
|
| 40 |
+
- Use CSS with pastel gradient, glassmorphism cards, responsive grid for parameter controls.
|
| 41 |
+
- JS boot flow:
|
| 42 |
+
* Fetch `/api/dances` once for the list (populate `<select>` or tiles).
|
| 43 |
+
* Fetch `/api/state` and render: selection (pre-select from list), global slider values, playing state, params grid.
|
| 44 |
+
* On selection change -> POST `/api/select` then update UI.
|
| 45 |
+
* Parameter controls: for each spec (value + slider + numeric). Both inputs stay in sync and `debounce` POST `/api/params` updates; show “saved” indicator and disable controls while saving to avoid jitter.
|
| 46 |
+
* Reset button -> POST `/api/reset`.
|
| 47 |
+
* Start/stop toggle button -> POST `/api/toggle`.
|
| 48 |
+
* Global BPM + amplitude sliders with numeric inputs -> POST `/api/globals` when changed (debounced) with validation (BPM 40–180, amplitude 0–2.0 as slider requirements hint).
|
| 49 |
+
- Visualization: create `<canvas>` or `<svg>` area that animates arcs/waves based on telemetry from `/api/visualization`. Poll every ~150 ms and animate smoothly using requestAnimationFrame. Map the 8 DoF to color-coded bars/rings (e.g., 3 orientation lines, 3 translation pulses, 2 antenna bars); amplitude drives height/opacity to satisfy “each DoF has an impact”. Provide fallback animation even when not playing (dim, idle oscillation) to keep it “beautiful and simple”.
|
| 50 |
+
|
| 51 |
+
3. Persistence + utilities
|
| 52 |
+
--------------------------
|
| 53 |
+
- JSON file path: `SimpleDances/SimpleDances/dance_params.json` (auto-created). On failure to load parseable JSON, fall back to defaults but keep file for next writes.
|
| 54 |
+
- Provide helper to sanitize payloads and ensure only known params are stored.
|
| 55 |
+
- On each save, update `state["last_saved"]` so UI can display “Saved at …”.
|
| 56 |
+
|
| 57 |
+
4. Testing / validation
|
| 58 |
+
-----------------------
|
| 59 |
+
- Manual: lint via running `python -m compileall` or `uv run`? (Not necessary yet but consider). Without hardware, run `python SimpleDances/SimpleDances/main.py` in dry-run to ensure no import errors and that endpoints accept sample requests.
|
| 60 |
+
- UI: open `index.html` via app host after hooking to CLI environment (can't do now, but ensure markup includes helpful fallback text + `aria` labels).
|
| 61 |
+
|
| 62 |
+
Open questions / assumptions
|
| 63 |
+
----------------------------
|
| 64 |
+
- Slider heuristics rely on parameter names; confirm tomorrow whether ranges feel right. Might need fine-tuning later.
|
| 65 |
+
- Visualization currently planned as polling endpoint; if future requirement wants streaming/websocket, architecture allows swap.
|
pyproject.toml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
[project]
|
| 7 |
+
name = "SimpleDances"
|
| 8 |
+
version = "0.1.0"
|
| 9 |
+
description = "Add your description here"
|
| 10 |
+
readme = "README.md"
|
| 11 |
+
requires-python = ">=3.10"
|
| 12 |
+
dependencies = [
|
| 13 |
+
"reachy-mini",
|
| 14 |
+
"reachy_mini_dances_library",
|
| 15 |
+
]
|
| 16 |
+
keywords = ["reachy-mini-app"]
|
| 17 |
+
|
| 18 |
+
[project.entry-points."reachy_mini_apps"]
|
| 19 |
+
SimpleDances = "SimpleDances.main:Simpledances"
|
| 20 |
+
|
| 21 |
+
[tool.setuptools]
|
| 22 |
+
package-dir = { "" = "." }
|
| 23 |
+
include-package-data = true
|
| 24 |
+
|
| 25 |
+
[tool.setuptools.packages.find]
|
| 26 |
+
where = ["."]
|
| 27 |
+
|
| 28 |
+
[tool.setuptools.package-data]
|
| 29 |
+
SimpleDances = ["**/*"] # Also include all non-.py files
|
style.css
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* {
|
| 2 |
+
margin: 0;
|
| 3 |
+
padding: 0;
|
| 4 |
+
box-sizing: border-box;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
body {
|
| 8 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 9 |
+
line-height: 1.6;
|
| 10 |
+
color: #333;
|
| 11 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 12 |
+
min-height: 100vh;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.hero {
|
| 16 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 17 |
+
color: white;
|
| 18 |
+
padding: 4rem 2rem;
|
| 19 |
+
text-align: center;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.hero-content {
|
| 23 |
+
max-width: 800px;
|
| 24 |
+
margin: 0 auto;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.app-icon {
|
| 28 |
+
font-size: 4rem;
|
| 29 |
+
margin-bottom: 1rem;
|
| 30 |
+
display: inline-block;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.hero h1 {
|
| 34 |
+
font-size: 3rem;
|
| 35 |
+
font-weight: 700;
|
| 36 |
+
margin-bottom: 1rem;
|
| 37 |
+
background: linear-gradient(45deg, #fff, #f0f9ff);
|
| 38 |
+
background-clip: text;
|
| 39 |
+
-webkit-background-clip: text;
|
| 40 |
+
-webkit-text-fill-color: transparent;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.tagline {
|
| 44 |
+
font-size: 1.25rem;
|
| 45 |
+
opacity: 0.9;
|
| 46 |
+
max-width: 600px;
|
| 47 |
+
margin: 0 auto;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.container {
|
| 51 |
+
max-width: 1200px;
|
| 52 |
+
margin: 0 auto;
|
| 53 |
+
padding: 0 2rem;
|
| 54 |
+
position: relative;
|
| 55 |
+
z-index: 2;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.main-card {
|
| 59 |
+
background: white;
|
| 60 |
+
border-radius: 20px;
|
| 61 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
| 62 |
+
margin-top: -2rem;
|
| 63 |
+
overflow: hidden;
|
| 64 |
+
margin-bottom: 3rem;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.app-preview {
|
| 68 |
+
background: linear-gradient(135deg, #1e3a8a, #3b82f6);
|
| 69 |
+
padding: 3rem;
|
| 70 |
+
color: white;
|
| 71 |
+
text-align: center;
|
| 72 |
+
position: relative;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.preview-image {
|
| 76 |
+
background: #000;
|
| 77 |
+
border-radius: 15px;
|
| 78 |
+
padding: 2rem;
|
| 79 |
+
max-width: 500px;
|
| 80 |
+
margin: 0 auto;
|
| 81 |
+
position: relative;
|
| 82 |
+
overflow: hidden;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.camera-feed {
|
| 86 |
+
font-size: 4rem;
|
| 87 |
+
margin-bottom: 1rem;
|
| 88 |
+
opacity: 0.7;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.detection-overlay {
|
| 92 |
+
position: absolute;
|
| 93 |
+
top: 50%;
|
| 94 |
+
left: 50%;
|
| 95 |
+
transform: translate(-50%, -50%);
|
| 96 |
+
width: 100%;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.bbox {
|
| 100 |
+
background: rgba(34, 197, 94, 0.9);
|
| 101 |
+
color: white;
|
| 102 |
+
padding: 0.5rem 1rem;
|
| 103 |
+
border-radius: 8px;
|
| 104 |
+
font-size: 0.9rem;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
margin: 0.5rem;
|
| 107 |
+
display: inline-block;
|
| 108 |
+
border: 2px solid #22c55e;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.app-details {
|
| 112 |
+
padding: 3rem;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.app-details h2 {
|
| 116 |
+
font-size: 2rem;
|
| 117 |
+
color: #1e293b;
|
| 118 |
+
margin-bottom: 2rem;
|
| 119 |
+
text-align: center;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.template-info {
|
| 123 |
+
display: grid;
|
| 124 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 125 |
+
gap: 2rem;
|
| 126 |
+
margin-bottom: 3rem;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.info-box {
|
| 130 |
+
background: #f0f9ff;
|
| 131 |
+
border: 2px solid #e0f2fe;
|
| 132 |
+
border-radius: 12px;
|
| 133 |
+
padding: 2rem;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.info-box h3 {
|
| 137 |
+
color: #0c4a6e;
|
| 138 |
+
margin-bottom: 1rem;
|
| 139 |
+
font-size: 1.2rem;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.info-box p {
|
| 143 |
+
color: #0369a1;
|
| 144 |
+
line-height: 1.6;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.how-to-use {
|
| 148 |
+
background: #fefce8;
|
| 149 |
+
border: 2px solid #fde047;
|
| 150 |
+
border-radius: 12px;
|
| 151 |
+
padding: 2rem;
|
| 152 |
+
margin-top: 3rem;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.how-to-use h3 {
|
| 156 |
+
color: #a16207;
|
| 157 |
+
margin-bottom: 1.5rem;
|
| 158 |
+
font-size: 1.3rem;
|
| 159 |
+
text-align: center;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.steps {
|
| 163 |
+
display: flex;
|
| 164 |
+
flex-direction: column;
|
| 165 |
+
gap: 1.5rem;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.step {
|
| 169 |
+
display: flex;
|
| 170 |
+
align-items: flex-start;
|
| 171 |
+
gap: 1rem;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.step-number {
|
| 175 |
+
background: #eab308;
|
| 176 |
+
color: white;
|
| 177 |
+
width: 2rem;
|
| 178 |
+
height: 2rem;
|
| 179 |
+
border-radius: 50%;
|
| 180 |
+
display: flex;
|
| 181 |
+
align-items: center;
|
| 182 |
+
justify-content: center;
|
| 183 |
+
font-weight: bold;
|
| 184 |
+
flex-shrink: 0;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.step h4 {
|
| 188 |
+
color: #a16207;
|
| 189 |
+
margin-bottom: 0.5rem;
|
| 190 |
+
font-size: 1.1rem;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.step p {
|
| 194 |
+
color: #ca8a04;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.download-card {
|
| 198 |
+
background: white;
|
| 199 |
+
border-radius: 20px;
|
| 200 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
| 201 |
+
padding: 3rem;
|
| 202 |
+
text-align: center;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.download-card h2 {
|
| 206 |
+
font-size: 2rem;
|
| 207 |
+
color: #1e293b;
|
| 208 |
+
margin-bottom: 1rem;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.download-card>p {
|
| 212 |
+
color: #64748b;
|
| 213 |
+
font-size: 1.1rem;
|
| 214 |
+
margin-bottom: 2rem;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.dashboard-config {
|
| 218 |
+
margin-bottom: 2rem;
|
| 219 |
+
text-align: left;
|
| 220 |
+
max-width: 400px;
|
| 221 |
+
margin-left: auto;
|
| 222 |
+
margin-right: auto;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.dashboard-config label {
|
| 226 |
+
display: block;
|
| 227 |
+
color: #374151;
|
| 228 |
+
font-weight: 600;
|
| 229 |
+
margin-bottom: 0.5rem;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.dashboard-config input {
|
| 233 |
+
width: 100%;
|
| 234 |
+
padding: 0.75rem 1rem;
|
| 235 |
+
border: 2px solid #e5e7eb;
|
| 236 |
+
border-radius: 8px;
|
| 237 |
+
font-size: 0.95rem;
|
| 238 |
+
transition: border-color 0.2s;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.dashboard-config input:focus {
|
| 242 |
+
outline: none;
|
| 243 |
+
border-color: #667eea;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.install-btn {
|
| 247 |
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
| 248 |
+
color: white;
|
| 249 |
+
border: none;
|
| 250 |
+
padding: 1.25rem 3rem;
|
| 251 |
+
border-radius: 16px;
|
| 252 |
+
font-size: 1.2rem;
|
| 253 |
+
font-weight: 700;
|
| 254 |
+
cursor: pointer;
|
| 255 |
+
transition: all 0.3s ease;
|
| 256 |
+
display: inline-flex;
|
| 257 |
+
align-items: center;
|
| 258 |
+
gap: 0.75rem;
|
| 259 |
+
margin-bottom: 2rem;
|
| 260 |
+
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.install-btn:hover:not(:disabled) {
|
| 264 |
+
transform: translateY(-3px);
|
| 265 |
+
box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.install-btn:disabled {
|
| 269 |
+
opacity: 0.7;
|
| 270 |
+
cursor: not-allowed;
|
| 271 |
+
transform: none;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.manual-option {
|
| 275 |
+
background: #f8fafc;
|
| 276 |
+
border-radius: 12px;
|
| 277 |
+
padding: 2rem;
|
| 278 |
+
margin-top: 2rem;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.manual-option h3 {
|
| 282 |
+
color: #1e293b;
|
| 283 |
+
margin-bottom: 1rem;
|
| 284 |
+
font-size: 1.2rem;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.manual-option>p {
|
| 288 |
+
color: #64748b;
|
| 289 |
+
margin-bottom: 1rem;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.btn-icon {
|
| 293 |
+
font-size: 1.1rem;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.install-status {
|
| 297 |
+
padding: 1rem;
|
| 298 |
+
border-radius: 8px;
|
| 299 |
+
font-size: 0.9rem;
|
| 300 |
+
text-align: center;
|
| 301 |
+
display: none;
|
| 302 |
+
margin-top: 1rem;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.install-status.success {
|
| 306 |
+
background: #dcfce7;
|
| 307 |
+
color: #166534;
|
| 308 |
+
border: 1px solid #bbf7d0;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.install-status.error {
|
| 312 |
+
background: #fef2f2;
|
| 313 |
+
color: #dc2626;
|
| 314 |
+
border: 1px solid #fecaca;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.install-status.loading {
|
| 318 |
+
background: #dbeafe;
|
| 319 |
+
color: #1d4ed8;
|
| 320 |
+
border: 1px solid #bfdbfe;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.install-status.info {
|
| 324 |
+
background: #e0f2fe;
|
| 325 |
+
color: #0369a1;
|
| 326 |
+
border: 1px solid #7dd3fc;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.manual-install {
|
| 330 |
+
background: #1f2937;
|
| 331 |
+
border-radius: 8px;
|
| 332 |
+
padding: 1rem;
|
| 333 |
+
margin-bottom: 1rem;
|
| 334 |
+
display: flex;
|
| 335 |
+
align-items: center;
|
| 336 |
+
gap: 1rem;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.manual-install code {
|
| 340 |
+
color: #10b981;
|
| 341 |
+
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
| 342 |
+
font-size: 0.85rem;
|
| 343 |
+
flex: 1;
|
| 344 |
+
overflow-x: auto;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.copy-btn {
|
| 348 |
+
background: #374151;
|
| 349 |
+
color: white;
|
| 350 |
+
border: none;
|
| 351 |
+
padding: 0.5rem 1rem;
|
| 352 |
+
border-radius: 6px;
|
| 353 |
+
font-size: 0.8rem;
|
| 354 |
+
cursor: pointer;
|
| 355 |
+
transition: background-color 0.2s;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.copy-btn:hover {
|
| 359 |
+
background: #4b5563;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.manual-steps {
|
| 363 |
+
color: #6b7280;
|
| 364 |
+
font-size: 0.9rem;
|
| 365 |
+
line-height: 1.8;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.footer {
|
| 369 |
+
text-align: center;
|
| 370 |
+
padding: 2rem;
|
| 371 |
+
color: white;
|
| 372 |
+
opacity: 0.8;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.footer a {
|
| 376 |
+
color: white;
|
| 377 |
+
text-decoration: none;
|
| 378 |
+
font-weight: 600;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.footer a:hover {
|
| 382 |
+
text-decoration: underline;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/* Responsive Design */
|
| 386 |
+
@media (max-width: 768px) {
|
| 387 |
+
.hero {
|
| 388 |
+
padding: 2rem 1rem;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.hero h1 {
|
| 392 |
+
font-size: 2rem;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.container {
|
| 396 |
+
padding: 0 1rem;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.app-details,
|
| 400 |
+
.download-card {
|
| 401 |
+
padding: 2rem;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.features-grid {
|
| 405 |
+
grid-template-columns: 1fr;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.download-options {
|
| 409 |
+
grid-template-columns: 1fr;
|
| 410 |
+
}
|
| 411 |
+
}
|