Spaces:
Running
Running
RemiFabre
commited on
Commit
·
af83c92
1
Parent(s):
33de5de
Update app for official version
Browse files- README.md +2 -2
- build/lib/emotions/__init__.py +0 -0
- build/lib/emotions/main.py +647 -0
- build/lib/emotions/static/index.html +75 -0
- build/lib/emotions/static/main.js +855 -0
- build/lib/emotions/static/style.css +481 -0
- build/lib/ressources/emotions_overview.py +19 -0
- build/lib/ressources/recorded_moves_example.py +47 -0
- index.html +264 -207
- style.css +315 -179
README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
---
|
| 2 |
title: Emotions
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
-
short_description:
|
| 9 |
tags:
|
| 10 |
- reachy_mini
|
| 11 |
- reachy_mini_python_app
|
|
|
|
| 1 |
---
|
| 2 |
title: Emotions
|
| 3 |
+
emoji: 🎡
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
short_description: Spin Reachy Mini's emotion wheel and trigger any behavior.
|
| 9 |
tags:
|
| 10 |
- reachy_mini
|
| 11 |
- reachy_mini_python_app
|
build/lib/emotions/__init__.py
ADDED
|
File without changes
|
build/lib/emotions/main.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from fastapi import HTTPException
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
from ruamel.yaml import YAML
|
| 15 |
+
|
| 16 |
+
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 17 |
+
from reachy_mini.motion.recorded_move import RecordedMoves
|
| 18 |
+
|
| 19 |
+
EMOTIONS_DATASET = "pollen-robotics/reachy-mini-emotions-library"
|
| 20 |
+
RESOURCE_ROOT = Path(__file__).resolve().parent.parent / "ressources"
|
| 21 |
+
EMOTIONS_YAML_PATH = RESOURCE_ROOT / "emotions.yml"
|
| 22 |
+
COHERENCE_DURATION_TOLERANCE = 0.25
|
| 23 |
+
YAML_LOADER = YAML()
|
| 24 |
+
YAML_LOADER.preserve_quotes = True
|
| 25 |
+
YAML_LOADER.indent(mapping=2, sequence=4, offset=2)
|
| 26 |
+
|
| 27 |
+
DEFAULT_SECTOR_ORDER = {
|
| 28 |
+
"emotion": [
|
| 29 |
+
"Joy",
|
| 30 |
+
"Trust",
|
| 31 |
+
"Fear",
|
| 32 |
+
"Surprise",
|
| 33 |
+
"Sadness",
|
| 34 |
+
"Disgust",
|
| 35 |
+
"Anger",
|
| 36 |
+
"Anticipation",
|
| 37 |
+
],
|
| 38 |
+
"behavior": [
|
| 39 |
+
"Agree",
|
| 40 |
+
"Disagree",
|
| 41 |
+
"Ask",
|
| 42 |
+
"Listen",
|
| 43 |
+
"Direct",
|
| 44 |
+
"Social",
|
| 45 |
+
"Play",
|
| 46 |
+
"Meta",
|
| 47 |
+
],
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
WHEEL_NODE_LAYOUT = {
|
| 51 |
+
"emotion": {
|
| 52 |
+
"node_size": 96.0,
|
| 53 |
+
"padding": 30.0,
|
| 54 |
+
"inner_margin": 40.0,
|
| 55 |
+
"fallback_radius": 240.0,
|
| 56 |
+
"min_ratio": 0.35,
|
| 57 |
+
},
|
| 58 |
+
"behavior": {
|
| 59 |
+
"node_size": 96.0,
|
| 60 |
+
"padding": 30.0,
|
| 61 |
+
"inner_margin": 40.0,
|
| 62 |
+
"fallback_radius": 240.0,
|
| 63 |
+
"min_ratio": 0.35,
|
| 64 |
+
},
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class WheelMove:
|
| 70 |
+
move_id: str
|
| 71 |
+
clean_name: str
|
| 72 |
+
description: str
|
| 73 |
+
move_type: str
|
| 74 |
+
wheel_sector: str
|
| 75 |
+
intensity: float
|
| 76 |
+
normalized_intensity: float
|
| 77 |
+
duration_seconds: float
|
| 78 |
+
duration_bucket: str
|
| 79 |
+
duration_color: str
|
| 80 |
+
sector_color: str
|
| 81 |
+
available: bool
|
| 82 |
+
recorded_duration: float | None
|
| 83 |
+
order: int
|
| 84 |
+
layout: dict[str, float] | None = None
|
| 85 |
+
|
| 86 |
+
def to_dict(self) -> dict[str, Any]:
|
| 87 |
+
return {
|
| 88 |
+
"id": self.move_id,
|
| 89 |
+
"label": self.clean_name,
|
| 90 |
+
"description": self.description,
|
| 91 |
+
"type": self.move_type,
|
| 92 |
+
"wheel_sector": self.wheel_sector,
|
| 93 |
+
"intensity": self.intensity,
|
| 94 |
+
"normalized_intensity": self.normalized_intensity,
|
| 95 |
+
"duration_seconds": self.duration_seconds,
|
| 96 |
+
"duration_bucket": self.duration_bucket,
|
| 97 |
+
"duration_color": self.duration_color,
|
| 98 |
+
"sector_color": self.sector_color,
|
| 99 |
+
"available": self.available,
|
| 100 |
+
"recorded_duration": self.recorded_duration,
|
| 101 |
+
"order": self.order,
|
| 102 |
+
"layout": self.layout,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@dataclass
|
| 107 |
+
class WheelSector:
|
| 108 |
+
slug: str
|
| 109 |
+
label: str
|
| 110 |
+
color: str
|
| 111 |
+
note: str | None
|
| 112 |
+
moves: list[WheelMove] = field(default_factory=list)
|
| 113 |
+
|
| 114 |
+
def to_dict(self) -> dict[str, Any]:
|
| 115 |
+
return {
|
| 116 |
+
"id": self.slug,
|
| 117 |
+
"label": self.label,
|
| 118 |
+
"color": self.color,
|
| 119 |
+
"note": self.note,
|
| 120 |
+
"moves": [move.to_dict() for move in self.moves],
|
| 121 |
+
"count": len(self.moves),
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@dataclass
|
| 126 |
+
class EmotionCatalog:
|
| 127 |
+
wheels: dict[str, list[WheelSector]]
|
| 128 |
+
duration_buckets: list[dict[str, Any]]
|
| 129 |
+
intensity_scale: dict[str, float]
|
| 130 |
+
wheel_layout: dict[str, dict[str, float]]
|
| 131 |
+
coherence_report: dict[str, Any]
|
| 132 |
+
move_lookup: dict[str, WheelMove]
|
| 133 |
+
|
| 134 |
+
def to_payload(self) -> dict[str, Any]:
|
| 135 |
+
def wheel_payload(key: str, label: str) -> dict[str, Any]:
|
| 136 |
+
sectors = self.wheels.get(key, [])
|
| 137 |
+
return {
|
| 138 |
+
"label": label,
|
| 139 |
+
"sectors": [sector.to_dict() for sector in sectors],
|
| 140 |
+
"total_moves": sum(len(sector.moves) for sector in sectors),
|
| 141 |
+
"layout": self.wheel_layout.get(key, {}),
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return {
|
| 145 |
+
"wheels": {
|
| 146 |
+
"emotion": wheel_payload("emotion", "Emotions"),
|
| 147 |
+
"behavior": wheel_payload("behavior", "Behaviors"),
|
| 148 |
+
},
|
| 149 |
+
"duration_buckets": self.duration_buckets,
|
| 150 |
+
"intensity_scale": self.intensity_scale,
|
| 151 |
+
"wheel_layout": self.wheel_layout,
|
| 152 |
+
"coherence": self.coherence_report,
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class PlayEmotionPayload(BaseModel):
|
| 157 |
+
move_name: str = Field(..., description="Exact key of the recorded move to play")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class MovePositionPayload(BaseModel):
|
| 161 |
+
angle: float = Field(..., description="Angle in degrees (0° at positive X axis)")
|
| 162 |
+
radius: float = Field(
|
| 163 |
+
..., ge=0.0, description="Radius ratio relative to wheel max radius (values >1 allowed)"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class SaveLayoutPayload(BaseModel):
|
| 168 |
+
positions: dict[str, MovePositionPayload]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def slugify(value: str) -> str:
|
| 172 |
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
| 173 |
+
return slug or "sector"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def load_emotions_spec_tree() -> Any:
|
| 177 |
+
if not EMOTIONS_YAML_PATH.exists():
|
| 178 |
+
raise FileNotFoundError(f"Cannot locate emotions spec at {EMOTIONS_YAML_PATH}")
|
| 179 |
+
with EMOTIONS_YAML_PATH.open(encoding="utf-8") as handle:
|
| 180 |
+
return YAML_LOADER.load(handle)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def dump_emotions_spec_tree(tree: Any) -> None:
|
| 184 |
+
with EMOTIONS_YAML_PATH.open("w", encoding="utf-8") as handle:
|
| 185 |
+
YAML_LOADER.dump(tree, handle)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def clamp(value: float, lower: float, upper: float) -> float:
|
| 189 |
+
return max(lower, min(upper, value))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def normalize_intensity(value: float, scale: dict[str, float]) -> float:
|
| 193 |
+
min_val = float(scale.get("min", 0.0))
|
| 194 |
+
max_val = float(scale.get("max", 10.0))
|
| 195 |
+
if max_val <= min_val:
|
| 196 |
+
return 0.5
|
| 197 |
+
normalized = (value - min_val) / (max_val - min_val)
|
| 198 |
+
return max(0.0, min(1.0, normalized))
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def classify_duration(duration: float, thresholds: dict[str, float]) -> str:
|
| 202 |
+
short_max = thresholds.get("short_max", 3.25)
|
| 203 |
+
medium_max = thresholds.get("medium_max", 6.5)
|
| 204 |
+
if duration <= short_max:
|
| 205 |
+
return "short"
|
| 206 |
+
if duration <= medium_max:
|
| 207 |
+
return "medium"
|
| 208 |
+
return "long"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def prepare_duration_buckets(
|
| 212 |
+
thresholds: dict[str, float], palette: dict[str, dict[str, Any]]
|
| 213 |
+
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
| 214 |
+
short_max = float(thresholds.get("short_max", 3.25))
|
| 215 |
+
medium_max = float(thresholds.get("medium_max", 6.5))
|
| 216 |
+
|
| 217 |
+
def color_for(bucket: str) -> str:
|
| 218 |
+
return palette.get(bucket, {}).get("hex", "#ffffff")
|
| 219 |
+
|
| 220 |
+
buckets = [
|
| 221 |
+
{
|
| 222 |
+
"id": "short",
|
| 223 |
+
"label": f"Short (≤ {short_max:.2f}s)",
|
| 224 |
+
"max_seconds": short_max,
|
| 225 |
+
"color": color_for("short"),
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"id": "medium",
|
| 229 |
+
"label": f"Medium ({short_max:.2f}s – {medium_max:.2f}s)",
|
| 230 |
+
"min_seconds": short_max,
|
| 231 |
+
"max_seconds": medium_max,
|
| 232 |
+
"color": color_for("medium"),
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"id": "long",
|
| 236 |
+
"label": f"Long (> {medium_max:.2f}s)",
|
| 237 |
+
"min_seconds": medium_max,
|
| 238 |
+
"color": color_for("long"),
|
| 239 |
+
},
|
| 240 |
+
]
|
| 241 |
+
lookup = {bucket["id"]: bucket for bucket in buckets}
|
| 242 |
+
return buckets, lookup
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def build_wheel_sectors(
|
| 246 |
+
moves: list[WheelMove], palette: dict[str, dict[str, Any]]
|
| 247 |
+
) -> list[WheelSector]:
|
| 248 |
+
sectors: list[WheelSector] = []
|
| 249 |
+
handled_labels: set[str] = set()
|
| 250 |
+
|
| 251 |
+
for label, style in palette.items():
|
| 252 |
+
filtered = [move for move in moves if move.wheel_sector == label]
|
| 253 |
+
if not filtered:
|
| 254 |
+
continue
|
| 255 |
+
sector = WheelSector(
|
| 256 |
+
slug=slugify(label),
|
| 257 |
+
label=label,
|
| 258 |
+
color=style.get("hex", "#ffffff"),
|
| 259 |
+
note=style.get("note"),
|
| 260 |
+
moves=filtered,
|
| 261 |
+
)
|
| 262 |
+
sectors.append(sector)
|
| 263 |
+
handled_labels.add(label)
|
| 264 |
+
|
| 265 |
+
extra_labels = {
|
| 266 |
+
move.wheel_sector for move in moves if move.wheel_sector not in handled_labels
|
| 267 |
+
}
|
| 268 |
+
for label in extra_labels:
|
| 269 |
+
filtered = [move for move in moves if move.wheel_sector == label]
|
| 270 |
+
if not filtered:
|
| 271 |
+
continue
|
| 272 |
+
sectors.append(
|
| 273 |
+
WheelSector(
|
| 274 |
+
slug=slugify(label),
|
| 275 |
+
label=label,
|
| 276 |
+
color="#a0a0a0",
|
| 277 |
+
note=None,
|
| 278 |
+
moves=filtered,
|
| 279 |
+
)
|
| 280 |
+
)
|
| 281 |
+
return sectors
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def build_catalog(spec: dict[str, Any], recorded_emotions: RecordedMoves) -> EmotionCatalog:
|
| 285 |
+
meta = spec.get("meta", {})
|
| 286 |
+
intensity_scale = {
|
| 287 |
+
"min": float(meta.get("intensity_scale", {}).get("min", 0.0)),
|
| 288 |
+
"max": float(meta.get("intensity_scale", {}).get("max", 10.0)),
|
| 289 |
+
}
|
| 290 |
+
thresholds = {
|
| 291 |
+
"short_max": float(meta.get("duration_thresholds_seconds", {}).get("short_max", 3.25)),
|
| 292 |
+
"medium_max": float(meta.get("duration_thresholds_seconds", {}).get("medium_max", 6.5)),
|
| 293 |
+
}
|
| 294 |
+
wheel_layout_cfg = meta.get("wheel_layout", {})
|
| 295 |
+
wheel_layout = {
|
| 296 |
+
"emotion": {"radius": float(wheel_layout_cfg.get("emotion_radius", 240.0))},
|
| 297 |
+
"behavior": {"radius": float(wheel_layout_cfg.get("behavior_radius", 200.0))},
|
| 298 |
+
}
|
| 299 |
+
color_system = meta.get("color_system", {})
|
| 300 |
+
sector_palette: dict[str, dict[str, Any]] = color_system.get("wheel_sector_colors", {})
|
| 301 |
+
duration_palette: dict[str, dict[str, Any]] = color_system.get("duration_bucket_colors", {})
|
| 302 |
+
duration_buckets, duration_lookup = prepare_duration_buckets(thresholds, duration_palette)
|
| 303 |
+
|
| 304 |
+
dataset_move_names = set(recorded_emotions.list_moves())
|
| 305 |
+
moves_by_type: dict[str, list[WheelMove]] = {"emotion": [], "behavior": []}
|
| 306 |
+
move_lookup: dict[str, WheelMove] = {}
|
| 307 |
+
|
| 308 |
+
coherence = {
|
| 309 |
+
"yaml_missing_in_dataset": [],
|
| 310 |
+
"dataset_missing_in_yaml": [],
|
| 311 |
+
"duration_mismatches": [],
|
| 312 |
+
"sector_type_conflicts": [],
|
| 313 |
+
"unknown_sectors": [],
|
| 314 |
+
"unknown_types": [],
|
| 315 |
+
"intensity_out_of_range": [],
|
| 316 |
+
}
|
| 317 |
+
sector_type_map: dict[str, set[str]] = defaultdict(set)
|
| 318 |
+
spec_move_ids: set[str] = set()
|
| 319 |
+
|
| 320 |
+
for order, move_data in enumerate(spec.get("moves", [])):
|
| 321 |
+
move_id = move_data.get("id")
|
| 322 |
+
if not move_id:
|
| 323 |
+
continue
|
| 324 |
+
spec_move_ids.add(move_id)
|
| 325 |
+
move_type = (move_data.get("type") or "behavior").lower()
|
| 326 |
+
if move_type not in moves_by_type:
|
| 327 |
+
coherence["unknown_types"].append({"move": move_id, "type": move_type})
|
| 328 |
+
move_type = "behavior"
|
| 329 |
+
|
| 330 |
+
wheel_sector = move_data.get("wheel_sector", "Uncategorized")
|
| 331 |
+
sector_style = sector_palette.get(wheel_sector)
|
| 332 |
+
if not sector_style:
|
| 333 |
+
coherence["unknown_sectors"].append({"move": move_id, "sector": wheel_sector})
|
| 334 |
+
sector_color = (sector_style or {}).get("hex", "#a0a0a0")
|
| 335 |
+
|
| 336 |
+
intensity = float(move_data.get("intensity", intensity_scale["min"]))
|
| 337 |
+
normalized_intensity = normalize_intensity(intensity, intensity_scale)
|
| 338 |
+
if intensity < intensity_scale["min"] or intensity > intensity_scale["max"]:
|
| 339 |
+
coherence["intensity_out_of_range"].append(
|
| 340 |
+
{"move": move_id, "value": intensity}
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
duration_seconds = float(move_data.get("duration_seconds", 0.0))
|
| 344 |
+
bucket = classify_duration(duration_seconds, thresholds)
|
| 345 |
+
duration_color = duration_lookup[bucket]["color"]
|
| 346 |
+
description = move_data.get("description") or ""
|
| 347 |
+
clean_name = move_data.get("clean_name") or move_id
|
| 348 |
+
|
| 349 |
+
recorded_duration = None
|
| 350 |
+
available = move_id in dataset_move_names
|
| 351 |
+
if available:
|
| 352 |
+
dataset_move = recorded_emotions.get(move_id)
|
| 353 |
+
recorded_duration = float(dataset_move.duration)
|
| 354 |
+
diff = abs(recorded_duration - duration_seconds)
|
| 355 |
+
if diff > COHERENCE_DURATION_TOLERANCE:
|
| 356 |
+
coherence["duration_mismatches"].append(
|
| 357 |
+
{
|
| 358 |
+
"move": move_id,
|
| 359 |
+
"yaml_duration": duration_seconds,
|
| 360 |
+
"dataset_duration": recorded_duration,
|
| 361 |
+
"delta": diff,
|
| 362 |
+
}
|
| 363 |
+
)
|
| 364 |
+
else:
|
| 365 |
+
coherence["yaml_missing_in_dataset"].append(
|
| 366 |
+
{
|
| 367 |
+
"move": move_id,
|
| 368 |
+
"name": clean_name,
|
| 369 |
+
"type": move_type,
|
| 370 |
+
"sector": wheel_sector,
|
| 371 |
+
}
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
layout = None
|
| 375 |
+
position_data = move_data.get("position")
|
| 376 |
+
if isinstance(position_data, dict):
|
| 377 |
+
try:
|
| 378 |
+
angle = float(position_data.get("angle", 0.0)) % 360
|
| 379 |
+
radius_value = max(0.0, float(position_data.get("radius", 0.5)))
|
| 380 |
+
layout = {"angle": angle, "radius": radius_value}
|
| 381 |
+
except (TypeError, ValueError):
|
| 382 |
+
layout = None
|
| 383 |
+
|
| 384 |
+
sector_type_map[wheel_sector].add(move_type)
|
| 385 |
+
move = WheelMove(
|
| 386 |
+
move_id=move_id,
|
| 387 |
+
clean_name=clean_name,
|
| 388 |
+
description=description,
|
| 389 |
+
move_type=move_type,
|
| 390 |
+
wheel_sector=wheel_sector,
|
| 391 |
+
intensity=intensity,
|
| 392 |
+
normalized_intensity=normalized_intensity,
|
| 393 |
+
duration_seconds=duration_seconds,
|
| 394 |
+
duration_bucket=bucket,
|
| 395 |
+
duration_color=duration_color,
|
| 396 |
+
sector_color=sector_color,
|
| 397 |
+
available=available,
|
| 398 |
+
recorded_duration=recorded_duration,
|
| 399 |
+
order=order,
|
| 400 |
+
layout=layout,
|
| 401 |
+
)
|
| 402 |
+
moves_by_type[move_type].append(move)
|
| 403 |
+
move_lookup[move_id] = move
|
| 404 |
+
|
| 405 |
+
coherence["dataset_missing_in_yaml"] = sorted(dataset_move_names - spec_move_ids)
|
| 406 |
+
coherence["sector_type_conflicts"] = [
|
| 407 |
+
{"sector": sector, "types": sorted(types)}
|
| 408 |
+
for sector, types in sector_type_map.items()
|
| 409 |
+
if len(types) > 1
|
| 410 |
+
]
|
| 411 |
+
|
| 412 |
+
wheels = {
|
| 413 |
+
wheel_type: build_wheel_sectors(moves, sector_palette)
|
| 414 |
+
for wheel_type, moves in moves_by_type.items()
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
return EmotionCatalog(
|
| 418 |
+
wheels=wheels,
|
| 419 |
+
duration_buckets=duration_buckets,
|
| 420 |
+
intensity_scale=intensity_scale,
|
| 421 |
+
wheel_layout=wheel_layout,
|
| 422 |
+
coherence_report=coherence,
|
| 423 |
+
move_lookup=move_lookup,
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def determine_sector_order(wheel_type: str, sectors: set[str]) -> list[str]:
|
| 428 |
+
remaining = list(sectors)
|
| 429 |
+
ordered: list[str] = []
|
| 430 |
+
for label in DEFAULT_SECTOR_ORDER.get(wheel_type, []):
|
| 431 |
+
if label in remaining:
|
| 432 |
+
ordered.append(label)
|
| 433 |
+
remaining.remove(label)
|
| 434 |
+
ordered.extend(sorted(remaining))
|
| 435 |
+
return ordered
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def apply_simple_wheel_layout(tree: Any) -> None:
|
| 439 |
+
meta = tree.get("meta", {})
|
| 440 |
+
wheel_layout_cfg = meta.get("wheel_layout", {})
|
| 441 |
+
intensity_scale = meta.get("intensity_scale", {"min": 0.0, "max": 10.0})
|
| 442 |
+
min_intensity = float(intensity_scale.get("min", 0.0))
|
| 443 |
+
max_intensity = float(intensity_scale.get("max", 10.0))
|
| 444 |
+
|
| 445 |
+
wheel_moves: dict[str, dict[str, list[dict[str, Any]]]] = {
|
| 446 |
+
"emotion": defaultdict(list),
|
| 447 |
+
"behavior": defaultdict(list),
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
moves_section = tree.get("moves", [])
|
| 451 |
+
for order, entry in enumerate(moves_section):
|
| 452 |
+
if not isinstance(entry, dict):
|
| 453 |
+
continue
|
| 454 |
+
move_id = entry.get("id")
|
| 455 |
+
if not move_id:
|
| 456 |
+
continue
|
| 457 |
+
move_type = (entry.get("type") or "behavior").lower()
|
| 458 |
+
if move_type not in wheel_moves:
|
| 459 |
+
move_type = "behavior"
|
| 460 |
+
sector = entry.get("wheel_sector") or "Uncategorized"
|
| 461 |
+
intensity = float(entry.get("intensity", min_intensity))
|
| 462 |
+
wheel_moves[move_type][sector].append(
|
| 463 |
+
{"entry": entry, "intensity": intensity, "order": order}
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
for wheel_type, sectors in wheel_moves.items():
|
| 467 |
+
if not sectors:
|
| 468 |
+
continue
|
| 469 |
+
settings = WHEEL_NODE_LAYOUT[wheel_type]
|
| 470 |
+
wheel_radius = float(
|
| 471 |
+
wheel_layout_cfg.get(f"{wheel_type}_radius", settings["fallback_radius"])
|
| 472 |
+
)
|
| 473 |
+
node_size = settings["node_size"]
|
| 474 |
+
padding = settings["padding"]
|
| 475 |
+
inner_margin = settings["inner_margin"]
|
| 476 |
+
ordered_sectors = determine_sector_order(wheel_type, set(sectors.keys()))
|
| 477 |
+
span = 360.0 / len(ordered_sectors)
|
| 478 |
+
|
| 479 |
+
for index, sector in enumerate(ordered_sectors):
|
| 480 |
+
moves = sectors.get(sector, [])
|
| 481 |
+
if not moves:
|
| 482 |
+
continue
|
| 483 |
+
center_angle = (index * span + span / 2) % 360.0
|
| 484 |
+
sorted_moves = sorted(
|
| 485 |
+
moves, key=lambda item: (item["intensity"], item["order"])
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
base_offset = inner_margin + node_size + padding
|
| 489 |
+
step = node_size + padding
|
| 490 |
+
min_ratio = settings.get("min_ratio", 0.25)
|
| 491 |
+
|
| 492 |
+
for position_index, move in enumerate(sorted_moves):
|
| 493 |
+
radius_px = base_offset + position_index * step
|
| 494 |
+
intensity_ratio = (
|
| 495 |
+
(move["intensity"] - min_intensity)
|
| 496 |
+
/ (max_intensity - min_intensity or 1.0)
|
| 497 |
+
)
|
| 498 |
+
intensity_boost = (node_size * 0.35) * intensity_ratio
|
| 499 |
+
radius_px = radius_px + intensity_boost
|
| 500 |
+
radius_ratio = max(radius_px / wheel_radius, min_ratio)
|
| 501 |
+
move["entry"]["position"] = {
|
| 502 |
+
"angle": round(center_angle, 6),
|
| 503 |
+
"radius": round(radius_ratio, 6),
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
class Emotions(ReachyMiniApp):
|
| 508 |
+
"""Reachy Mini app that plays recorded emotions with a YAML-driven UI."""
|
| 509 |
+
|
| 510 |
+
custom_app_url: str | None = "http://0.0.0.0:8042"
|
| 511 |
+
request_media_backend: str | None = None
|
| 512 |
+
|
| 513 |
+
def __init__(self) -> None:
|
| 514 |
+
super().__init__()
|
| 515 |
+
self._recorded_emotions = RecordedMoves(EMOTIONS_DATASET)
|
| 516 |
+
self._spec_tree = load_emotions_spec_tree()
|
| 517 |
+
self._catalog = build_catalog(self._spec_tree, self._recorded_emotions)
|
| 518 |
+
self._move_lookup = self._catalog.move_lookup
|
| 519 |
+
self._lock = threading.Lock()
|
| 520 |
+
self._config_lock = threading.Lock()
|
| 521 |
+
self._pending_move: str | None = None
|
| 522 |
+
self._current_move: str | None = None
|
| 523 |
+
self._last_completed: float | None = None
|
| 524 |
+
self._register_routes()
|
| 525 |
+
|
| 526 |
+
def _refresh_catalog(self, tree: Any | None = None) -> None:
|
| 527 |
+
if tree is None:
|
| 528 |
+
tree = load_emotions_spec_tree()
|
| 529 |
+
self._spec_tree = tree
|
| 530 |
+
self._catalog = build_catalog(tree, self._recorded_emotions)
|
| 531 |
+
self._move_lookup = self._catalog.move_lookup
|
| 532 |
+
|
| 533 |
+
def _save_positions(self, positions: dict[str, MovePositionPayload]) -> None:
|
| 534 |
+
with self._config_lock:
|
| 535 |
+
tree = load_emotions_spec_tree()
|
| 536 |
+
moves_section = tree.get("moves", [])
|
| 537 |
+
for entry in moves_section:
|
| 538 |
+
if not isinstance(entry, dict):
|
| 539 |
+
continue
|
| 540 |
+
move_id = entry.get("id")
|
| 541 |
+
if not move_id:
|
| 542 |
+
continue
|
| 543 |
+
payload = positions.get(move_id)
|
| 544 |
+
if payload is None:
|
| 545 |
+
continue
|
| 546 |
+
angle = float(payload.angle) % 360
|
| 547 |
+
radius = max(0.0, float(payload.radius))
|
| 548 |
+
entry["position"] = {"angle": angle, "radius": radius}
|
| 549 |
+
|
| 550 |
+
dump_emotions_spec_tree(tree)
|
| 551 |
+
self._refresh_catalog(tree)
|
| 552 |
+
|
| 553 |
+
def _reset_positions(self) -> None:
|
| 554 |
+
with self._config_lock:
|
| 555 |
+
tree = load_emotions_spec_tree()
|
| 556 |
+
apply_simple_wheel_layout(tree)
|
| 557 |
+
dump_emotions_spec_tree(tree)
|
| 558 |
+
self._refresh_catalog(tree)
|
| 559 |
+
|
| 560 |
+
def _register_routes(self) -> None:
|
| 561 |
+
@self.settings_app.get("/api/emotions")
|
| 562 |
+
def list_emotions() -> dict[str, Any]:
|
| 563 |
+
return self._catalog.to_payload()
|
| 564 |
+
|
| 565 |
+
@self.settings_app.get("/api/visualization")
|
| 566 |
+
def visualization() -> dict[str, Any]:
|
| 567 |
+
# Return the same payload for compatibility with dashboards expecting this endpoint.
|
| 568 |
+
return {
|
| 569 |
+
"wheels": self._catalog.to_payload()["wheels"],
|
| 570 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
@self.settings_app.get("/api/state")
|
| 574 |
+
def get_state() -> dict[str, Any]:
|
| 575 |
+
with self._lock:
|
| 576 |
+
current = self._current_move
|
| 577 |
+
pending = self._pending_move
|
| 578 |
+
last_completed = (
|
| 579 |
+
datetime.fromtimestamp(self._last_completed).isoformat()
|
| 580 |
+
if self._last_completed
|
| 581 |
+
else None
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
return {
|
| 585 |
+
"is_playing": current is not None,
|
| 586 |
+
"current_move": current,
|
| 587 |
+
"pending_move": pending,
|
| 588 |
+
"last_completed_at": last_completed,
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
@self.settings_app.post("/api/play")
|
| 592 |
+
def play_emotion(payload: PlayEmotionPayload) -> dict[str, Any]:
|
| 593 |
+
move_name = payload.move_name
|
| 594 |
+
move_meta = self._move_lookup.get(move_name)
|
| 595 |
+
if move_meta is None:
|
| 596 |
+
raise HTTPException(status_code=404, detail=f"Unknown move {move_name}")
|
| 597 |
+
if not move_meta.available:
|
| 598 |
+
raise HTTPException(
|
| 599 |
+
status_code=409,
|
| 600 |
+
detail=f"Move {move_name} is missing from the recorded dataset.",
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
with self._lock:
|
| 604 |
+
if self._current_move or self._pending_move:
|
| 605 |
+
return {"accepted": False, "reason": "busy"}
|
| 606 |
+
self._pending_move = move_name
|
| 607 |
+
return {
|
| 608 |
+
"accepted": True,
|
| 609 |
+
"queued_move": move_name,
|
| 610 |
+
"display_label": move_meta.clean_name,
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
@self.settings_app.post("/api/layout/save")
|
| 614 |
+
def save_layout(payload: SaveLayoutPayload) -> dict[str, Any]:
|
| 615 |
+
self._save_positions(payload.positions)
|
| 616 |
+
return {"status": "ok"}
|
| 617 |
+
|
| 618 |
+
@self.settings_app.post("/api/layout/reset")
|
| 619 |
+
def reset_layout() -> dict[str, Any]:
|
| 620 |
+
self._reset_positions()
|
| 621 |
+
return {"status": "ok"}
|
| 622 |
+
|
| 623 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
|
| 624 |
+
while not stop_event.is_set():
|
| 625 |
+
move_to_play: str | None = None
|
| 626 |
+
with self._lock:
|
| 627 |
+
if self._pending_move:
|
| 628 |
+
move_to_play = self._pending_move
|
| 629 |
+
self._pending_move = None
|
| 630 |
+
self._current_move = move_to_play
|
| 631 |
+
|
| 632 |
+
if move_to_play:
|
| 633 |
+
move = self._recorded_emotions.get(move_to_play)
|
| 634 |
+
reachy_mini.play_move(move, initial_goto_duration=0.7)
|
| 635 |
+
with self._lock:
|
| 636 |
+
self._current_move = None
|
| 637 |
+
self._last_completed = time.time()
|
| 638 |
+
else:
|
| 639 |
+
stop_event.wait(0.01)
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
if __name__ == "__main__":
|
| 643 |
+
app = Emotions()
|
| 644 |
+
try:
|
| 645 |
+
app.wrapped_run()
|
| 646 |
+
except KeyboardInterrupt:
|
| 647 |
+
app.stop()
|
build/lib/emotions/static/index.html
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.0">
|
| 7 |
+
<title>Reachy Mini • Emotions Wheel</title>
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
</head>
|
| 10 |
+
|
| 11 |
+
<body>
|
| 12 |
+
<div class="background-halo"></div>
|
| 13 |
+
<main class="app-shell">
|
| 14 |
+
<header class="hero">
|
| 15 |
+
<div>
|
| 16 |
+
<p class="eyebrow">Reachy Mini Companion</p>
|
| 17 |
+
<h1>Wheel of Emotions & Behaviors</h1>
|
| 18 |
+
<p class="subtitle">
|
| 19 |
+
Hover to explore every recorded emotion, then click the behavior ring to play it on Reachy Mini.
|
| 20 |
+
Each slice inherits a Plutchik-inspired color, the badge hue shows duration, and distance from the
|
| 21 |
+
heart of the wheel still encodes intensity.
|
| 22 |
+
</p>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="hero-status">
|
| 25 |
+
<span class="chip" id="statusChip">Syncing…</span>
|
| 26 |
+
<span class="chip soft">Tooltips include the original dataset name + duration.</span>
|
| 27 |
+
</div>
|
| 28 |
+
</header>
|
| 29 |
+
|
| 30 |
+
<section class="layout-controls">
|
| 31 |
+
<button id="layoutModeBtn" class="control-btn">Manual layout</button>
|
| 32 |
+
<button id="layoutSaveBtn" class="control-btn" disabled>Save layout</button>
|
| 33 |
+
<button id="layoutResetBtn" class="control-btn secondary">Reset layout</button>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section class="wheel-stage">
|
| 37 |
+
<div class="wheel-block">
|
| 38 |
+
<div class="wheel-header">
|
| 39 |
+
<h2>Emotion Wheel</h2>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="wheel" id="emotionWheel">
|
| 42 |
+
<div class="ring outer"></div>
|
| 43 |
+
<div class="ring inner"></div>
|
| 44 |
+
<div class="wheel-center">
|
| 45 |
+
<p>emotions</p>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div class="wheel-block">
|
| 51 |
+
<div class="wheel-header">
|
| 52 |
+
<h2>Behavior Wheel</h2>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="wheel" id="behaviorWheel">
|
| 55 |
+
<div class="ring outer"></div>
|
| 56 |
+
<div class="ring inner"></div>
|
| 57 |
+
<div class="wheel-center">
|
| 58 |
+
<p>behaviors</p>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</section>
|
| 63 |
+
|
| 64 |
+
<section class="coherence-panel" id="coherencePanel">
|
| 65 |
+
<strong>Sync diagnostics</strong>
|
| 66 |
+
<p>Loading…</p>
|
| 67 |
+
</section>
|
| 68 |
+
</main>
|
| 69 |
+
|
| 70 |
+
<div id="tooltip" class="tooltip" role="status" aria-live="polite"></div>
|
| 71 |
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
| 72 |
+
<script type="module" src="/static/main.js"></script>
|
| 73 |
+
</body>
|
| 74 |
+
|
| 75 |
+
</html>
|
build/lib/emotions/static/main.js
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const wheelElements = {
|
| 2 |
+
emotion: document.getElementById("emotionWheel"),
|
| 3 |
+
behavior: document.getElementById("behaviorWheel"),
|
| 4 |
+
};
|
| 5 |
+
const wheelStage = document.querySelector(".wheel-stage");
|
| 6 |
+
const statusChip = document.getElementById("statusChip");
|
| 7 |
+
const tooltip = document.getElementById("tooltip");
|
| 8 |
+
const toast = document.getElementById("toast");
|
| 9 |
+
const coherencePanel = document.getElementById("coherencePanel");
|
| 10 |
+
const layoutModeBtn = document.getElementById("layoutModeBtn");
|
| 11 |
+
const layoutSaveBtn = document.getElementById("layoutSaveBtn");
|
| 12 |
+
const layoutResetBtn = document.getElementById("layoutResetBtn");
|
| 13 |
+
|
| 14 |
+
const WHEEL_PADDING = 60;
|
| 15 |
+
const MAX_LANES = 9;
|
| 16 |
+
const WHEEL_SETTINGS = {
|
| 17 |
+
default: { nodeSize: 84, baseInnerRatio: 0.22, movesPerLane: 5 },
|
| 18 |
+
emotion: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
|
| 19 |
+
behavior: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const appState = {
|
| 23 |
+
wheels: {},
|
| 24 |
+
durationBuckets: [],
|
| 25 |
+
intensityScale: { min: 0, max: 10 },
|
| 26 |
+
coherence: {},
|
| 27 |
+
moveIndex: new Map(),
|
| 28 |
+
wheelLayout: {},
|
| 29 |
+
wheelGeometry: {},
|
| 30 |
+
positions: new Map(),
|
| 31 |
+
busy: true,
|
| 32 |
+
currentMove: null,
|
| 33 |
+
manualMode: false,
|
| 34 |
+
positionsDirty: false,
|
| 35 |
+
layoutOverrides: new Map(),
|
| 36 |
+
};
|
| 37 |
+
const manualState = {
|
| 38 |
+
drag: null,
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const currentUrl = new URL(window.location.href);
|
| 42 |
+
if (!currentUrl.pathname.endsWith("/")) {
|
| 43 |
+
currentUrl.pathname += "/";
|
| 44 |
+
}
|
| 45 |
+
currentUrl.search = "";
|
| 46 |
+
currentUrl.hash = "";
|
| 47 |
+
|
| 48 |
+
const buildApiUrl = (path) => {
|
| 49 |
+
const clean = path.startsWith("/") ? path.slice(1) : path;
|
| 50 |
+
return new URL(clean, currentUrl).toString();
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
async function fetchJson(path, options = {}) {
|
| 54 |
+
const config = { method: options.method || "GET", headers: { ...(options.headers || {}) } };
|
| 55 |
+
if (options.body) {
|
| 56 |
+
config.body = JSON.stringify(options.body);
|
| 57 |
+
config.headers["Content-Type"] = "application/json";
|
| 58 |
+
}
|
| 59 |
+
const response = await fetch(buildApiUrl(path), config);
|
| 60 |
+
if (!response.ok) {
|
| 61 |
+
throw new Error(`HTTP ${response.status}`);
|
| 62 |
+
}
|
| 63 |
+
if (response.status === 204) {
|
| 64 |
+
return {};
|
| 65 |
+
}
|
| 66 |
+
return response.json();
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function clamp(value, min, max) {
|
| 70 |
+
return Math.min(max, Math.max(min, value));
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function hexToRgba(hex, alpha) {
|
| 74 |
+
if (!hex) {
|
| 75 |
+
return `rgba(255, 255, 255, ${alpha})`;
|
| 76 |
+
}
|
| 77 |
+
const normalized = hex.replace("#", "");
|
| 78 |
+
const bigint = parseInt(normalized.length === 3 ? normalized.repeat(2) : normalized, 16);
|
| 79 |
+
// eslint-disable-next-line no-bitwise
|
| 80 |
+
const r = (bigint >> 16) & 255;
|
| 81 |
+
// eslint-disable-next-line no-bitwise
|
| 82 |
+
const g = (bigint >> 8) & 255;
|
| 83 |
+
// eslint-disable-next-line no-bitwise
|
| 84 |
+
const b = bigint & 255;
|
| 85 |
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function applySectorColor(element, color, { backgroundAlpha = 0.9, borderAlpha = 0.95 } = {}) {
|
| 89 |
+
const base = hexToRgba(color, backgroundAlpha);
|
| 90 |
+
const fade = hexToRgba(color, 0.45);
|
| 91 |
+
element.style.background = `linear-gradient(135deg, ${base}, ${fade})`;
|
| 92 |
+
element.style.borderColor = hexToRgba(color, borderAlpha);
|
| 93 |
+
element.style.boxShadow = `0 15px 35px ${hexToRgba(color, 0.35)}`;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function showToast(message) {
|
| 97 |
+
toast.textContent = message;
|
| 98 |
+
toast.classList.add("visible");
|
| 99 |
+
setTimeout(() => toast.classList.remove("visible"), 2600);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function updateStatusChip(label, playing) {
|
| 103 |
+
statusChip.textContent = label;
|
| 104 |
+
statusChip.classList.toggle("playing", playing);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function setBusyState(isBusy, moveName) {
|
| 108 |
+
appState.busy = isBusy;
|
| 109 |
+
appState.currentMove = moveName || null;
|
| 110 |
+
const label = isBusy && moveName ? `Playing ${formatMoveName(moveName)}` : "Idle";
|
| 111 |
+
updateStatusChip(label, isBusy);
|
| 112 |
+
document.querySelectorAll(".wheel-node").forEach((node) => {
|
| 113 |
+
const available = node.dataset.available !== "false";
|
| 114 |
+
node.classList.toggle("busy", isBusy);
|
| 115 |
+
const shouldDisable = !appState.manualMode && (!available || isBusy);
|
| 116 |
+
node.disabled = shouldDisable;
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function formatMoveName(moveId) {
|
| 121 |
+
if (!moveId) return "";
|
| 122 |
+
const move = appState.moveIndex.get(moveId);
|
| 123 |
+
if (!move) return moveId;
|
| 124 |
+
return move.label;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function buildStructures(data) {
|
| 128 |
+
appState.wheels = data.wheels || {};
|
| 129 |
+
appState.durationBuckets = data.duration_buckets || [];
|
| 130 |
+
appState.intensityScale = data.intensity_scale || { min: 0, max: 10 };
|
| 131 |
+
appState.coherence = data.coherence || {};
|
| 132 |
+
appState.wheelLayout = data.wheel_layout || {};
|
| 133 |
+
appState.moveIndex = new Map();
|
| 134 |
+
appState.wheelGeometry = {};
|
| 135 |
+
appState.positions = new Map();
|
| 136 |
+
appState.positionsDirty = false;
|
| 137 |
+
appState.layoutOverrides = new Map();
|
| 138 |
+
|
| 139 |
+
Object.entries(appState.wheels).forEach(([, wheel]) => {
|
| 140 |
+
(wheel.sectors || []).forEach((sector) => {
|
| 141 |
+
(sector.moves || []).forEach((move) => {
|
| 142 |
+
appState.moveIndex.set(move.id, move);
|
| 143 |
+
});
|
| 144 |
+
});
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function getWheelSetting(key) {
|
| 149 |
+
return { ...WHEEL_SETTINGS.default, ...(WHEEL_SETTINGS[key] || {}) };
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function buildWheelGradient(sectors) {
|
| 153 |
+
if (!sectors?.length) {
|
| 154 |
+
return "rgba(255,255,255,0.08)";
|
| 155 |
+
}
|
| 156 |
+
const span = 360 / sectors.length;
|
| 157 |
+
const parts = sectors.map((sector, index) => {
|
| 158 |
+
const start = index * span;
|
| 159 |
+
const end = start + span;
|
| 160 |
+
return `${sector.color} ${start}deg ${end}deg`;
|
| 161 |
+
});
|
| 162 |
+
return `conic-gradient(${parts.join(",")})`;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function applyGradient(wheelEl, gradient) {
|
| 166 |
+
wheelEl.style.setProperty("--wheel-gradient", gradient);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function clearNodes(selector) {
|
| 170 |
+
document.querySelectorAll(selector).forEach((node) => node.remove());
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function positionNode(node, angleDeg, radius) {
|
| 174 |
+
const radians = (angleDeg * Math.PI) / 180;
|
| 175 |
+
const x = Math.cos(radians) * radius;
|
| 176 |
+
const y = Math.sin(radians) * radius;
|
| 177 |
+
node.style.left = "50%";
|
| 178 |
+
node.style.top = "50%";
|
| 179 |
+
node.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function polarToCartesian(angleDeg, radius) {
|
| 183 |
+
const radians = (angleDeg * Math.PI) / 180;
|
| 184 |
+
return {
|
| 185 |
+
x: Math.cos(radians) * radius,
|
| 186 |
+
y: Math.sin(radians) * radius,
|
| 187 |
+
};
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function createNodeElement(move, wheelKey) {
|
| 191 |
+
const node = document.createElement("button");
|
| 192 |
+
node.className = `wheel-node ${wheelKey}-node`;
|
| 193 |
+
node.dataset.move = move.id;
|
| 194 |
+
node.dataset.sector = move.wheel_sector;
|
| 195 |
+
node.dataset.wheel = wheelKey;
|
| 196 |
+
node.dataset.available = move.available ? "true" : "false";
|
| 197 |
+
node.style.setProperty("--duration-color", move.duration_color);
|
| 198 |
+
applySectorColor(node, move.sector_color, { backgroundAlpha: 0.85, borderAlpha: 0.9 });
|
| 199 |
+
node.innerHTML = `
|
| 200 |
+
<span class="node-title">${move.label}</span>
|
| 201 |
+
<span class="node-meta">${move.duration_bucket.toUpperCase()}</span>
|
| 202 |
+
`;
|
| 203 |
+
|
| 204 |
+
if (!move.available) {
|
| 205 |
+
node.classList.add("unavailable");
|
| 206 |
+
node.disabled = true;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
node.addEventListener("mouseenter", () => {
|
| 210 |
+
showTooltip(move);
|
| 211 |
+
highlightSector(move.wheel_sector, true);
|
| 212 |
+
});
|
| 213 |
+
node.addEventListener("mousemove", (event) => moveTooltip(event));
|
| 214 |
+
node.addEventListener("mouseleave", () => {
|
| 215 |
+
hideTooltip();
|
| 216 |
+
highlightSector(move.wheel_sector, false);
|
| 217 |
+
});
|
| 218 |
+
node.addEventListener("click", () => triggerPlay(move.id));
|
| 219 |
+
return node;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function renderWheel(wheelKey) {
|
| 223 |
+
const wheelData = appState.wheels[wheelKey];
|
| 224 |
+
const wheelEl = wheelElements[wheelKey];
|
| 225 |
+
if (!wheelData || !wheelEl) return;
|
| 226 |
+
|
| 227 |
+
clearNodes(`.${wheelKey}-node`);
|
| 228 |
+
clearNodes(`.${wheelKey}-sector-label-layer`);
|
| 229 |
+
|
| 230 |
+
const rect = wheelEl.getBoundingClientRect();
|
| 231 |
+
if (!rect.width) return;
|
| 232 |
+
|
| 233 |
+
const sectors = wheelData.sectors || [];
|
| 234 |
+
const gradient = buildWheelGradient(sectors);
|
| 235 |
+
applyGradient(wheelEl, gradient);
|
| 236 |
+
|
| 237 |
+
if (!sectors.length) {
|
| 238 |
+
return;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const maxDrawableRadius = rect.width / 2 - WHEEL_PADDING;
|
| 242 |
+
const layoutRadiusSetting = Number(wheelData.layout?.radius) || maxDrawableRadius;
|
| 243 |
+
const drawRadius = Math.min(layoutRadiusSetting, maxDrawableRadius);
|
| 244 |
+
const wheelSettings = getWheelSetting(wheelKey);
|
| 245 |
+
const span = 360 / sectors.length;
|
| 246 |
+
appState.wheelGeometry[wheelKey] = {
|
| 247 |
+
drawRadius,
|
| 248 |
+
element: wheelEl,
|
| 249 |
+
};
|
| 250 |
+
renderSectorLabels(wheelEl, sectors, drawRadius, span, wheelKey);
|
| 251 |
+
|
| 252 |
+
sectors.forEach((sector, index) => {
|
| 253 |
+
const moves = sector.moves || [];
|
| 254 |
+
const sectorCenter = index * span + span / 2;
|
| 255 |
+
|
| 256 |
+
const manualMoves = [];
|
| 257 |
+
const autoMoves = [];
|
| 258 |
+
|
| 259 |
+
moves.forEach((move) => {
|
| 260 |
+
const layout = resolveLayout(move);
|
| 261 |
+
if (layout) {
|
| 262 |
+
manualMoves.push({ move, layout });
|
| 263 |
+
} else {
|
| 264 |
+
autoMoves.push(move);
|
| 265 |
+
}
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
manualMoves.forEach(({ move, layout }) => {
|
| 269 |
+
const ratio = Number(layout.radius) || 0;
|
| 270 |
+
const radiusPx = ratio * drawRadius;
|
| 271 |
+
const angle =
|
| 272 |
+
typeof layout.angle === "number"
|
| 273 |
+
? (Number(layout.angle) + 360) % 360
|
| 274 |
+
: sectorCenter;
|
| 275 |
+
const node = createNodeElement(move, wheelKey);
|
| 276 |
+
attachManualHandlers(node, move.id, wheelKey);
|
| 277 |
+
positionNode(node, angle, radiusPx);
|
| 278 |
+
recordPosition(move.id, wheelKey, angle, radiusPx);
|
| 279 |
+
wheelEl.appendChild(node);
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
const lanes = buildSectorLanes(autoMoves, drawRadius, span, wheelSettings);
|
| 283 |
+
lanes.forEach((lane) => {
|
| 284 |
+
const bucket = lane.items;
|
| 285 |
+
if (!bucket.length) return;
|
| 286 |
+
const availableSpread = span * 0.85;
|
| 287 |
+
const laneOffset =
|
| 288 |
+
lanes.length > 1 ? ((lane.index / (lanes.length - 1)) - 0.5) * (span * 0.08) : 0;
|
| 289 |
+
|
| 290 |
+
if (bucket.length === 1) {
|
| 291 |
+
const move = bucket[0];
|
| 292 |
+
const node = createNodeElement(move, wheelKey);
|
| 293 |
+
attachManualHandlers(node, move.id, wheelKey);
|
| 294 |
+
positionNode(node, sectorCenter + laneOffset, lane.radius);
|
| 295 |
+
recordPosition(move.id, wheelKey, sectorCenter + laneOffset, lane.radius);
|
| 296 |
+
wheelEl.appendChild(node);
|
| 297 |
+
return;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
const nodeSize = wheelSettings.nodeSize;
|
| 301 |
+
const minAngleStep = (nodeSize / lane.radius) * (180 / Math.PI) * 1.15;
|
| 302 |
+
let step = Math.max(minAngleStep, availableSpread / (bucket.length - 1));
|
| 303 |
+
if (step * (bucket.length - 1) > availableSpread) {
|
| 304 |
+
step = availableSpread / (bucket.length - 1);
|
| 305 |
+
}
|
| 306 |
+
const totalSpan = step * (bucket.length - 1);
|
| 307 |
+
const start = sectorCenter - totalSpan / 2 + laneOffset;
|
| 308 |
+
|
| 309 |
+
bucket.forEach((move, idx) => {
|
| 310 |
+
const angle = start + step * idx;
|
| 311 |
+
const node = createNodeElement(move, wheelKey);
|
| 312 |
+
attachManualHandlers(node, move.id, wheelKey);
|
| 313 |
+
positionNode(node, angle, lane.radius);
|
| 314 |
+
recordPosition(move.id, wheelKey, angle, lane.radius);
|
| 315 |
+
wheelEl.appendChild(node);
|
| 316 |
+
});
|
| 317 |
+
});
|
| 318 |
+
});
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function renderAllWheels() {
|
| 322 |
+
if (manualState.drag) {
|
| 323 |
+
finishDrag();
|
| 324 |
+
}
|
| 325 |
+
appState.positions = new Map();
|
| 326 |
+
renderWheel("emotion");
|
| 327 |
+
renderWheel("behavior");
|
| 328 |
+
updateWheelStageSpacing();
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
function updateWheelStageSpacing() {
|
| 332 |
+
if (!wheelStage) return;
|
| 333 |
+
const geometries = Object.entries(appState.wheelGeometry);
|
| 334 |
+
if (!geometries.length) return;
|
| 335 |
+
const radii = geometries.map(([, geo]) => geo.drawRadius || 0);
|
| 336 |
+
const maxRadius = Math.max(...radii);
|
| 337 |
+
const haloBuffer = WHEEL_PADDING * 1.4;
|
| 338 |
+
const emotionRadius = appState.wheelGeometry.emotion?.drawRadius ?? maxRadius;
|
| 339 |
+
const behaviorRadius = appState.wheelGeometry.behavior?.drawRadius ?? maxRadius;
|
| 340 |
+
const gapBase = emotionRadius + behaviorRadius + haloBuffer * 2;
|
| 341 |
+
const gap = Math.max(220, Math.round(gapBase));
|
| 342 |
+
const topPadding = Math.max(100, Math.round(maxRadius + haloBuffer));
|
| 343 |
+
const bottomPadding = Math.max(
|
| 344 |
+
180,
|
| 345 |
+
Math.round((behaviorRadius || maxRadius) + haloBuffer * 1.6)
|
| 346 |
+
);
|
| 347 |
+
wheelStage.style.setProperty("--wheel-gap", `${gap}px`);
|
| 348 |
+
wheelStage.style.setProperty("--wheel-padding-top", `${topPadding}px`);
|
| 349 |
+
wheelStage.style.setProperty("--wheel-padding-bottom", `${bottomPadding}px`);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function renderSectorLabels(wheelEl, sectors, outerRadius, span, wheelKey) {
|
| 353 |
+
const existing = wheelEl.querySelector(`.sector-label-layer.${wheelKey}-sector-label-layer`);
|
| 354 |
+
if (existing) {
|
| 355 |
+
existing.remove();
|
| 356 |
+
}
|
| 357 |
+
if (!sectors.length) {
|
| 358 |
+
return;
|
| 359 |
+
}
|
| 360 |
+
const labelRadius = outerRadius + 200;
|
| 361 |
+
const layer = document.createElement("div");
|
| 362 |
+
layer.classList.add("sector-label-layer", `${wheelKey}-sector-label-layer`);
|
| 363 |
+
layer.setAttribute("aria-hidden", "true");
|
| 364 |
+
layer.style.position = "absolute";
|
| 365 |
+
layer.style.left = "50%";
|
| 366 |
+
layer.style.top = "50%";
|
| 367 |
+
layer.style.width = "0";
|
| 368 |
+
layer.style.height = "0";
|
| 369 |
+
layer.style.transform = "translate(-50%, -50%)";
|
| 370 |
+
|
| 371 |
+
sectors.forEach((sector, index) => {
|
| 372 |
+
const padding = 6;
|
| 373 |
+
const startAngle = index * span + padding;
|
| 374 |
+
const endAngle = (index + 1) * span - padding;
|
| 375 |
+
const centerAngle = (startAngle + endAngle) / 2;
|
| 376 |
+
const coords = polarToCartesian(centerAngle, labelRadius);
|
| 377 |
+
const label = document.createElement("span");
|
| 378 |
+
label.classList.add("sector-label");
|
| 379 |
+
label.textContent = sector.label;
|
| 380 |
+
label.style.transform = `translate(${coords.x}px, ${coords.y}px) translate(-50%, -50%)`;
|
| 381 |
+
const normalizedCenter = ((centerAngle % 360) + 360) % 360;
|
| 382 |
+
if (Math.cos((normalizedCenter * Math.PI) / 180) < 0) {
|
| 383 |
+
label.dataset.side = "left";
|
| 384 |
+
} else {
|
| 385 |
+
label.dataset.side = "right";
|
| 386 |
+
}
|
| 387 |
+
layer.appendChild(label);
|
| 388 |
+
});
|
| 389 |
+
wheelEl.appendChild(layer);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
function resolveLayout(move) {
|
| 393 |
+
if (appState.layoutOverrides.has(move.id)) {
|
| 394 |
+
return appState.layoutOverrides.get(move.id);
|
| 395 |
+
}
|
| 396 |
+
return move.layout || null;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function buildSectorLanes(moves, outerRadius, span, settings) {
|
| 400 |
+
if (!moves.length) {
|
| 401 |
+
return [];
|
| 402 |
+
}
|
| 403 |
+
const nodeSize = settings.nodeSize;
|
| 404 |
+
const movesPerLane = settings.movesPerLane;
|
| 405 |
+
const baseInnerRadius = outerRadius * settings.baseInnerRatio;
|
| 406 |
+
const laneCount = clamp(Math.ceil(moves.length / movesPerLane), 1, MAX_LANES);
|
| 407 |
+
const radii = computeLaneRadii(laneCount, outerRadius, baseInnerRadius, nodeSize);
|
| 408 |
+
|
| 409 |
+
const capacities = radii.map((radius) => {
|
| 410 |
+
const arcLength = ((span * 0.9) * Math.PI) / 180 * radius;
|
| 411 |
+
return Math.max(2, Math.floor(arcLength / (nodeSize * 1.35)));
|
| 412 |
+
});
|
| 413 |
+
const lanes = radii.map((radius, index) => ({ radius, items: [], index }));
|
| 414 |
+
const sortedMoves = [...moves].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
| 415 |
+
|
| 416 |
+
sortedMoves.forEach((move) => {
|
| 417 |
+
const prefLane = clamp(
|
| 418 |
+
Math.round(move.normalized_intensity * (laneCount - 1)),
|
| 419 |
+
0,
|
| 420 |
+
laneCount - 1
|
| 421 |
+
);
|
| 422 |
+
let laneIndex = prefLane;
|
| 423 |
+
if (lanes[laneIndex].items.length >= capacities[laneIndex]) {
|
| 424 |
+
let offset = 1;
|
| 425 |
+
while (offset < laneCount) {
|
| 426 |
+
const left = prefLane - offset;
|
| 427 |
+
const right = prefLane + offset;
|
| 428 |
+
if (left >= 0 && lanes[left].items.length < capacities[left]) {
|
| 429 |
+
laneIndex = left;
|
| 430 |
+
break;
|
| 431 |
+
}
|
| 432 |
+
if (right < laneCount && lanes[right].items.length < capacities[right]) {
|
| 433 |
+
laneIndex = right;
|
| 434 |
+
break;
|
| 435 |
+
}
|
| 436 |
+
offset += 1;
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
lanes[laneIndex].items.push(move);
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
return lanes;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
function computeLaneRadii(laneCount, outerRadius, baseInnerRadius, nodeSize) {
|
| 446 |
+
if (laneCount <= 1) {
|
| 447 |
+
return [Math.max(outerRadius * 0.4, outerRadius - nodeSize * 1.2)];
|
| 448 |
+
}
|
| 449 |
+
const minGap = nodeSize * 1.05;
|
| 450 |
+
let innerRadius = baseInnerRadius;
|
| 451 |
+
let span = outerRadius - innerRadius;
|
| 452 |
+
const neededSpan = minGap * (laneCount - 1);
|
| 453 |
+
if (span < neededSpan) {
|
| 454 |
+
innerRadius = Math.max(outerRadius - neededSpan, 40);
|
| 455 |
+
span = outerRadius - innerRadius;
|
| 456 |
+
}
|
| 457 |
+
const gap = span / (laneCount - 1);
|
| 458 |
+
return Array.from({ length: laneCount }, (_, idx) => innerRadius + gap * idx);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
function recordPosition(moveId, wheelKey, angle, radiusPx) {
|
| 462 |
+
const geometry = appState.wheelGeometry[wheelKey];
|
| 463 |
+
if (!geometry || !geometry.drawRadius) {
|
| 464 |
+
return;
|
| 465 |
+
}
|
| 466 |
+
const normalizedAngle = ((angle % 360) + 360) % 360;
|
| 467 |
+
const ratio = geometry.drawRadius ? radiusPx / geometry.drawRadius : 0;
|
| 468 |
+
appState.positions.set(moveId, { angle: normalizedAngle, radius: Math.max(0, ratio) });
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
function attachManualHandlers(node, moveId, wheelKey) {
|
| 472 |
+
node.addEventListener("pointerdown", (event) => {
|
| 473 |
+
if (!appState.manualMode) {
|
| 474 |
+
return;
|
| 475 |
+
}
|
| 476 |
+
event.preventDefault();
|
| 477 |
+
try {
|
| 478 |
+
node.setPointerCapture(event.pointerId);
|
| 479 |
+
} catch (error) {
|
| 480 |
+
// ignore
|
| 481 |
+
}
|
| 482 |
+
node.classList.add("dragging");
|
| 483 |
+
manualState.drag = {
|
| 484 |
+
node,
|
| 485 |
+
moveId,
|
| 486 |
+
wheelKey,
|
| 487 |
+
pointerId: event.pointerId,
|
| 488 |
+
};
|
| 489 |
+
});
|
| 490 |
+
|
| 491 |
+
node.addEventListener("pointermove", (event) => {
|
| 492 |
+
if (!manualState.drag || manualState.drag.pointerId !== event.pointerId) {
|
| 493 |
+
return;
|
| 494 |
+
}
|
| 495 |
+
updateDragPosition(event);
|
| 496 |
+
});
|
| 497 |
+
|
| 498 |
+
const endDrag = (event) => {
|
| 499 |
+
if (!manualState.drag || manualState.drag.pointerId !== event.pointerId) {
|
| 500 |
+
return;
|
| 501 |
+
}
|
| 502 |
+
finishDrag();
|
| 503 |
+
};
|
| 504 |
+
|
| 505 |
+
node.addEventListener("pointerup", endDrag);
|
| 506 |
+
node.addEventListener("pointercancel", endDrag);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function updateDragPosition(event) {
|
| 510 |
+
const drag = manualState.drag;
|
| 511 |
+
if (!drag) return;
|
| 512 |
+
const geometry = appState.wheelGeometry[drag.wheelKey];
|
| 513 |
+
if (!geometry) return;
|
| 514 |
+
const rect = geometry.element.getBoundingClientRect();
|
| 515 |
+
const centerX = rect.left + rect.width / 2;
|
| 516 |
+
const centerY = rect.top + rect.height / 2;
|
| 517 |
+
const dx = event.clientX - centerX;
|
| 518 |
+
const dy = event.clientY - centerY;
|
| 519 |
+
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
| 520 |
+
if (angle < 0) angle += 360;
|
| 521 |
+
const radiusPx = Math.sqrt(dx * dx + dy * dy);
|
| 522 |
+
positionNode(drag.node, angle, radiusPx);
|
| 523 |
+
appState.layoutOverrides.set(drag.moveId, {
|
| 524 |
+
angle,
|
| 525 |
+
radius: geometry.drawRadius ? radiusPx / geometry.drawRadius : 0,
|
| 526 |
+
});
|
| 527 |
+
recordPosition(drag.moveId, drag.wheelKey, angle, radiusPx);
|
| 528 |
+
setPositionsDirty(true);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
function finishDrag() {
|
| 532 |
+
const drag = manualState.drag;
|
| 533 |
+
if (!drag) return;
|
| 534 |
+
drag.node.classList.remove("dragging");
|
| 535 |
+
try {
|
| 536 |
+
drag.node.releasePointerCapture(drag.pointerId);
|
| 537 |
+
} catch (error) {
|
| 538 |
+
// ignore
|
| 539 |
+
}
|
| 540 |
+
manualState.drag = null;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
function moveTooltip(event) {
|
| 544 |
+
tooltip.style.left = `${event.clientX}px`;
|
| 545 |
+
tooltip.style.top = `${event.clientY - 20}px`;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function showTooltip(move) {
|
| 549 |
+
tooltip.classList.add("visible");
|
| 550 |
+
const duration = `${move.duration_seconds.toFixed(2)}s`;
|
| 551 |
+
const availability = move.available ? "" : "<span class=\"warning\">Missing in dataset</span>";
|
| 552 |
+
const hasRecorded = Number.isFinite(move.recorded_duration);
|
| 553 |
+
const datasetId =
|
| 554 |
+
typeof move.id === "string" ? move.id.toLowerCase() : String(move.id ?? "");
|
| 555 |
+
const recorded =
|
| 556 |
+
hasRecorded && Math.abs(move.recorded_duration - move.duration_seconds) > 0.01
|
| 557 |
+
? `<br><small>Recorded duration: ${move.recorded_duration.toFixed(2)}s</small>`
|
| 558 |
+
: "";
|
| 559 |
+
tooltip.innerHTML = `
|
| 560 |
+
<strong>${move.label}</strong>
|
| 561 |
+
<small class="muted">dataset: ${datasetId}</small>
|
| 562 |
+
<span>${move.wheel_sector} • ${duration} • ${move.duration_bucket.toUpperCase()} ${availability}</span>
|
| 563 |
+
<p>${move.description || "No description yet."}</p>
|
| 564 |
+
<small>Intensity ${move.intensity.toFixed(1)}${recorded}</small>
|
| 565 |
+
`;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
function hideTooltip() {
|
| 569 |
+
tooltip.classList.remove("visible");
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
function highlightSector(sector, enabled) {
|
| 573 |
+
if (!sector) return;
|
| 574 |
+
document.querySelectorAll(`.wheel-node[data-sector="${sector}"]`).forEach((node) => {
|
| 575 |
+
node.classList.toggle("highlight", enabled);
|
| 576 |
+
});
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
function summarizeList(items, formatter) {
|
| 580 |
+
if (!items?.length) {
|
| 581 |
+
return "";
|
| 582 |
+
}
|
| 583 |
+
const preview = items.slice(0, 3).map(formatter).join("");
|
| 584 |
+
const remaining = items.length - 3;
|
| 585 |
+
const more = remaining > 0 ? `<span class="more">+${remaining} more</span>` : "";
|
| 586 |
+
return `<div class="issue-list">${preview}${more}</div>`;
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
function updateCoherencePanel(coherence) {
|
| 590 |
+
if (!coherencePanel) return;
|
| 591 |
+
const hasIssues = Object.values(coherence || {}).some(
|
| 592 |
+
(value) => Array.isArray(value) && value.length
|
| 593 |
+
);
|
| 594 |
+
if (!hasIssues) {
|
| 595 |
+
coherencePanel.classList.add("clean");
|
| 596 |
+
coherencePanel.innerHTML = `
|
| 597 |
+
<strong>Sync diagnostics</strong>
|
| 598 |
+
<p>YAML and recorded dataset are in sync.</p>
|
| 599 |
+
`;
|
| 600 |
+
return;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
coherencePanel.classList.remove("clean");
|
| 604 |
+
const blocks = [];
|
| 605 |
+
|
| 606 |
+
if (coherence.yaml_missing_in_dataset?.length) {
|
| 607 |
+
blocks.push(`
|
| 608 |
+
<article>
|
| 609 |
+
<h4>Missing in dataset (${coherence.yaml_missing_in_dataset.length})</h4>
|
| 610 |
+
${summarizeList(
|
| 611 |
+
coherence.yaml_missing_in_dataset,
|
| 612 |
+
(item) =>
|
| 613 |
+
`<span>${item.move} <small>${item.sector}</small></span>`
|
| 614 |
+
)}
|
| 615 |
+
</article>
|
| 616 |
+
`);
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
if (coherence.dataset_missing_in_yaml?.length) {
|
| 620 |
+
blocks.push(`
|
| 621 |
+
<article>
|
| 622 |
+
<h4>Dataset only (${coherence.dataset_missing_in_yaml.length})</h4>
|
| 623 |
+
${summarizeList(
|
| 624 |
+
coherence.dataset_missing_in_yaml,
|
| 625 |
+
(item) => `<span>${item}</span>`
|
| 626 |
+
)}
|
| 627 |
+
</article>
|
| 628 |
+
`);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
if (coherence.duration_mismatches?.length) {
|
| 632 |
+
blocks.push(`
|
| 633 |
+
<article>
|
| 634 |
+
<h4>Duration mismatches (${coherence.duration_mismatches.length})</h4>
|
| 635 |
+
${summarizeList(
|
| 636 |
+
coherence.duration_mismatches,
|
| 637 |
+
(item) =>
|
| 638 |
+
`<span>${item.move} <small>${Number.isFinite(item.dataset_duration) ? item.dataset_duration.toFixed(
|
| 639 |
+
2
|
| 640 |
+
) : "?"}s vs ${item.yaml_duration.toFixed(2)}s</small></span>`
|
| 641 |
+
)}
|
| 642 |
+
</article>
|
| 643 |
+
`);
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
if (coherence.sector_type_conflicts?.length) {
|
| 647 |
+
blocks.push(`
|
| 648 |
+
<article>
|
| 649 |
+
<h4>Sector conflicts</h4>
|
| 650 |
+
${summarizeList(
|
| 651 |
+
coherence.sector_type_conflicts,
|
| 652 |
+
(item) => `<span>${item.sector} <small>${item.types.join(" & ")}</small></span>`
|
| 653 |
+
)}
|
| 654 |
+
</article>
|
| 655 |
+
`);
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
if (coherence.unknown_sectors?.length) {
|
| 659 |
+
blocks.push(`
|
| 660 |
+
<article>
|
| 661 |
+
<h4>Unknown sectors</h4>
|
| 662 |
+
${summarizeList(
|
| 663 |
+
coherence.unknown_sectors,
|
| 664 |
+
(item) => `<span>${item.move} <small>${item.sector || "?"}</small></span>`
|
| 665 |
+
)}
|
| 666 |
+
</article>
|
| 667 |
+
`);
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
if (coherence.unknown_types?.length) {
|
| 671 |
+
blocks.push(`
|
| 672 |
+
<article>
|
| 673 |
+
<h4>Unknown types</h4>
|
| 674 |
+
${summarizeList(
|
| 675 |
+
coherence.unknown_types,
|
| 676 |
+
(item) => `<span>${item.move} <small>${item.type}</small></span>`
|
| 677 |
+
)}
|
| 678 |
+
</article>
|
| 679 |
+
`);
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
if (coherence.intensity_out_of_range?.length) {
|
| 683 |
+
blocks.push(`
|
| 684 |
+
<article>
|
| 685 |
+
<h4>Intensity out of scale</h4>
|
| 686 |
+
${summarizeList(
|
| 687 |
+
coherence.intensity_out_of_range,
|
| 688 |
+
(item) => `<span>${item.move} <small>${item.value}</small></span>`
|
| 689 |
+
)}
|
| 690 |
+
</article>
|
| 691 |
+
`);
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
if (blocks.length === 0) {
|
| 695 |
+
coherencePanel.innerHTML = `
|
| 696 |
+
<strong>Sync diagnostics</strong>
|
| 697 |
+
<p>YAML and recorded dataset are in sync.</p>
|
| 698 |
+
`;
|
| 699 |
+
return;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
coherencePanel.innerHTML = `
|
| 703 |
+
<strong>Sync diagnostics</strong>
|
| 704 |
+
<div class="issue-grid">
|
| 705 |
+
${blocks.join("")}
|
| 706 |
+
</div>
|
| 707 |
+
`;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
function updateManualControls() {
|
| 711 |
+
document.body.classList.toggle("manual-mode", appState.manualMode);
|
| 712 |
+
if (layoutModeBtn) {
|
| 713 |
+
layoutModeBtn.textContent = appState.manualMode ? "Exit layout" : "Manual layout";
|
| 714 |
+
}
|
| 715 |
+
if (layoutSaveBtn) {
|
| 716 |
+
layoutSaveBtn.disabled = !appState.positionsDirty;
|
| 717 |
+
}
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
function setPositionsDirty(flag) {
|
| 721 |
+
appState.positionsDirty = flag;
|
| 722 |
+
updateManualControls();
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
function toggleManualMode(force) {
|
| 726 |
+
const nextState = typeof force === "boolean" ? force : !appState.manualMode;
|
| 727 |
+
if (!nextState && manualState.drag) {
|
| 728 |
+
finishDrag();
|
| 729 |
+
}
|
| 730 |
+
appState.manualMode = nextState;
|
| 731 |
+
updateManualControls();
|
| 732 |
+
setBusyState(appState.busy, appState.currentMove);
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
async function saveLayout() {
|
| 736 |
+
try {
|
| 737 |
+
const payload = {};
|
| 738 |
+
appState.positions.forEach((value, key) => {
|
| 739 |
+
payload[key] = { angle: value.angle, radius: value.radius };
|
| 740 |
+
});
|
| 741 |
+
if (!Object.keys(payload).length) {
|
| 742 |
+
showToast("No moves available to save.");
|
| 743 |
+
return;
|
| 744 |
+
}
|
| 745 |
+
await fetchJson("/api/layout/save", { method: "POST", body: { positions: payload } });
|
| 746 |
+
appState.layoutOverrides.clear();
|
| 747 |
+
setPositionsDirty(false);
|
| 748 |
+
showToast("Layout saved.");
|
| 749 |
+
await loadAndRender();
|
| 750 |
+
toggleManualMode(false);
|
| 751 |
+
} catch (error) {
|
| 752 |
+
console.error(error);
|
| 753 |
+
showToast("Unable to save layout.");
|
| 754 |
+
}
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
async function resetLayout() {
|
| 758 |
+
try {
|
| 759 |
+
await fetchJson("/api/layout/reset", { method: "POST" });
|
| 760 |
+
appState.layoutOverrides.clear();
|
| 761 |
+
setPositionsDirty(false);
|
| 762 |
+
showToast("Layout reset.");
|
| 763 |
+
await loadAndRender();
|
| 764 |
+
toggleManualMode(false);
|
| 765 |
+
} catch (error) {
|
| 766 |
+
console.error(error);
|
| 767 |
+
showToast("Unable to reset layout.");
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
async function loadAndRender() {
|
| 772 |
+
const data = await fetchJson("/api/emotions");
|
| 773 |
+
buildStructures(data);
|
| 774 |
+
renderAllWheels();
|
| 775 |
+
updateCoherencePanel(appState.coherence);
|
| 776 |
+
setPositionsDirty(false);
|
| 777 |
+
updateManualControls();
|
| 778 |
+
setBusyState(appState.busy, appState.currentMove);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
async function triggerPlay(moveId) {
|
| 782 |
+
if (appState.manualMode) {
|
| 783 |
+
showToast("Exit layout mode to play moves.");
|
| 784 |
+
return;
|
| 785 |
+
}
|
| 786 |
+
const move = appState.moveIndex.get(moveId);
|
| 787 |
+
if (!move) {
|
| 788 |
+
showToast("Unknown move.");
|
| 789 |
+
return;
|
| 790 |
+
}
|
| 791 |
+
if (!move.available) {
|
| 792 |
+
showToast("This move is missing in the recorded dataset.");
|
| 793 |
+
return;
|
| 794 |
+
}
|
| 795 |
+
if (appState.busy) {
|
| 796 |
+
showToast("Reachy Mini is already playing a move.");
|
| 797 |
+
return;
|
| 798 |
+
}
|
| 799 |
+
try {
|
| 800 |
+
const res = await fetchJson("/api/play", { method: "POST", body: { move_name: moveId } });
|
| 801 |
+
if (res.accepted) {
|
| 802 |
+
setBusyState(true, res.queued_move);
|
| 803 |
+
showToast(`Playing ${formatMoveName(res.queued_move)}`);
|
| 804 |
+
} else {
|
| 805 |
+
showToast("Reachy Mini is still busy with the previous move.");
|
| 806 |
+
}
|
| 807 |
+
} catch (error) {
|
| 808 |
+
console.error(error);
|
| 809 |
+
showToast("Cannot reach the backend right now.");
|
| 810 |
+
}
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
async function pollState() {
|
| 814 |
+
try {
|
| 815 |
+
const data = await fetchJson("/api/state");
|
| 816 |
+
const busy = Boolean(data.is_playing || data.pending_move);
|
| 817 |
+
setBusyState(busy, data.current_move || data.pending_move);
|
| 818 |
+
} catch (error) {
|
| 819 |
+
console.warn("State polling failed", error);
|
| 820 |
+
} finally {
|
| 821 |
+
setTimeout(pollState, 1400);
|
| 822 |
+
}
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
async function init() {
|
| 826 |
+
try {
|
| 827 |
+
await loadAndRender();
|
| 828 |
+
setBusyState(false, null);
|
| 829 |
+
pollState();
|
| 830 |
+
} catch (error) {
|
| 831 |
+
console.error(error);
|
| 832 |
+
updateStatusChip("Failed to load data", false);
|
| 833 |
+
showToast("Unable to fetch the emotions list.");
|
| 834 |
+
}
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
init();
|
| 838 |
+
|
| 839 |
+
window.addEventListener("resize", () => {
|
| 840 |
+
if (!appState.wheels.emotion && !appState.wheels.behavior) {
|
| 841 |
+
return;
|
| 842 |
+
}
|
| 843 |
+
renderAllWheels();
|
| 844 |
+
setBusyState(appState.busy, appState.currentMove);
|
| 845 |
+
});
|
| 846 |
+
|
| 847 |
+
layoutModeBtn?.addEventListener("click", () => toggleManualMode());
|
| 848 |
+
layoutSaveBtn?.addEventListener("click", () => {
|
| 849 |
+
if (!appState.positionsDirty) {
|
| 850 |
+
showToast("Adjust positions before saving.");
|
| 851 |
+
return;
|
| 852 |
+
}
|
| 853 |
+
saveLayout();
|
| 854 |
+
});
|
| 855 |
+
layoutResetBtn?.addEventListener("click", () => resetLayout());
|
build/lib/emotions/static/style.css
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: dark;
|
| 3 |
+
--bg: #070b16;
|
| 4 |
+
--surface: rgba(20, 24, 37, 0.85);
|
| 5 |
+
--surface-border: rgba(255, 255, 255, 0.08);
|
| 6 |
+
--text-strong: #f5f7ff;
|
| 7 |
+
--text-soft: rgba(245, 247, 255, 0.7);
|
| 8 |
+
--text-muted: rgba(245, 247, 255, 0.5);
|
| 9 |
+
--chip-bg: rgba(255, 255, 255, 0.08);
|
| 10 |
+
--wheel-size: min(760px, 95vw);
|
| 11 |
+
--emotion-node-size: 96px;
|
| 12 |
+
--behavior-node-size: 96px;
|
| 13 |
+
--wheel-gap: 96px;
|
| 14 |
+
--wheel-padding-top: 16px;
|
| 15 |
+
--wheel-padding-bottom: 64px;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
*,
|
| 19 |
+
*::before,
|
| 20 |
+
*::after {
|
| 21 |
+
box-sizing: border-box;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
body {
|
| 25 |
+
margin: 0;
|
| 26 |
+
font-family: "Space Grotesk", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 27 |
+
background:
|
| 28 |
+
radial-gradient(circle at 20% -10%, rgba(255, 255, 255, 0.08), transparent 55%),
|
| 29 |
+
radial-gradient(circle at 80% -20%, rgba(155, 132, 255, 0.18), transparent 45%),
|
| 30 |
+
conic-gradient(from 90deg at 50% 0%, rgba(255, 149, 128, 0.15), rgba(103, 126, 247, 0.15), rgba(255, 149, 128, 0.15)),
|
| 31 |
+
var(--bg);
|
| 32 |
+
min-height: 100vh;
|
| 33 |
+
color: var(--text-strong);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.background-halo {
|
| 37 |
+
position: fixed;
|
| 38 |
+
inset: 0;
|
| 39 |
+
background:
|
| 40 |
+
radial-gradient(circle at 50% 10%, rgba(255, 255, 255, 0.12), transparent 45%),
|
| 41 |
+
radial-gradient(circle at 80% 0%, rgba(255, 195, 113, 0.25), transparent 40%);
|
| 42 |
+
filter: blur(50px);
|
| 43 |
+
z-index: 0;
|
| 44 |
+
pointer-events: none;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.background-halo::after {
|
| 48 |
+
content: "";
|
| 49 |
+
position: absolute;
|
| 50 |
+
inset: -10%;
|
| 51 |
+
background: conic-gradient(from -90deg, rgba(252, 214, 112, 0.2), rgba(255, 111, 145, 0.15), rgba(98, 178, 255, 0.2), rgba(252, 214, 112, 0.2));
|
| 52 |
+
mix-blend-mode: screen;
|
| 53 |
+
opacity: 0.4;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.app-shell {
|
| 57 |
+
position: relative;
|
| 58 |
+
z-index: 1;
|
| 59 |
+
max-width: 1200px;
|
| 60 |
+
margin: 0 auto;
|
| 61 |
+
padding: 56px 24px 96px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.hero {
|
| 65 |
+
display: flex;
|
| 66 |
+
flex-direction: column;
|
| 67 |
+
gap: 24px;
|
| 68 |
+
margin-bottom: 40px;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.eyebrow {
|
| 72 |
+
text-transform: uppercase;
|
| 73 |
+
letter-spacing: 0.3em;
|
| 74 |
+
font-size: 0.8rem;
|
| 75 |
+
color: var(--text-muted);
|
| 76 |
+
margin-bottom: 8px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.hero h1 {
|
| 80 |
+
margin: 0;
|
| 81 |
+
font-size: clamp(2.5rem, 6vw, 3.5rem);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.subtitle {
|
| 85 |
+
margin: 0;
|
| 86 |
+
color: var(--text-soft);
|
| 87 |
+
max-width: 720px;
|
| 88 |
+
line-height: 1.6;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.hero-status {
|
| 92 |
+
display: flex;
|
| 93 |
+
flex-wrap: wrap;
|
| 94 |
+
gap: 12px;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.layout-controls {
|
| 98 |
+
display: flex;
|
| 99 |
+
flex-wrap: wrap;
|
| 100 |
+
gap: 12px;
|
| 101 |
+
margin-bottom: 32px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.control-btn {
|
| 105 |
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
| 106 |
+
background: rgba(0, 0, 0, 0.35);
|
| 107 |
+
color: var(--text-strong);
|
| 108 |
+
padding: 10px 18px;
|
| 109 |
+
border-radius: 999px;
|
| 110 |
+
font-size: 0.8rem;
|
| 111 |
+
text-transform: uppercase;
|
| 112 |
+
letter-spacing: 0.3em;
|
| 113 |
+
cursor: pointer;
|
| 114 |
+
transition: border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.control-btn.secondary {
|
| 118 |
+
border-color: rgba(255, 255, 255, 0.08);
|
| 119 |
+
color: var(--text-muted);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.control-btn:disabled {
|
| 123 |
+
opacity: 0.4;
|
| 124 |
+
cursor: not-allowed;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.chip {
|
| 128 |
+
padding: 6px 18px;
|
| 129 |
+
border-radius: 999px;
|
| 130 |
+
background: var(--chip-bg);
|
| 131 |
+
color: var(--text-strong);
|
| 132 |
+
font-size: 0.9rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.chip.soft {
|
| 136 |
+
color: var(--text-soft);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.chip.playing {
|
| 140 |
+
background: rgba(255, 155, 102, 0.2);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.wheel-stage {
|
| 144 |
+
display: flex;
|
| 145 |
+
flex-direction: column;
|
| 146 |
+
gap: var(--wheel-gap);
|
| 147 |
+
align-items: center;
|
| 148 |
+
padding: var(--wheel-padding-top) 0 var(--wheel-padding-bottom);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.wheel-block {
|
| 152 |
+
width: 100%;
|
| 153 |
+
max-width: 900px;
|
| 154 |
+
display: flex;
|
| 155 |
+
flex-direction: column;
|
| 156 |
+
gap: 8px;
|
| 157 |
+
align-items: center;
|
| 158 |
+
text-align: center;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.wheel-header h2 {
|
| 162 |
+
margin: 0;
|
| 163 |
+
font-weight: 600;
|
| 164 |
+
letter-spacing: 0.35em;
|
| 165 |
+
text-transform: uppercase;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.wheel {
|
| 169 |
+
width: min(70vw, 520px);
|
| 170 |
+
height: min(70vw, 520px);
|
| 171 |
+
position: relative;
|
| 172 |
+
margin: 32px auto 0;
|
| 173 |
+
overflow: visible;
|
| 174 |
+
isolation: isolate;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.ring {
|
| 178 |
+
display: none;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.wheel::before {
|
| 182 |
+
content: "";
|
| 183 |
+
position: absolute;
|
| 184 |
+
inset: -55%;
|
| 185 |
+
border-radius: 50%;
|
| 186 |
+
background: var(--wheel-gradient, rgba(255, 255, 255, 0.08));
|
| 187 |
+
opacity: 0.7;
|
| 188 |
+
filter: blur(55px);
|
| 189 |
+
z-index: 0;
|
| 190 |
+
pointer-events: none;
|
| 191 |
+
mix-blend-mode: screen;
|
| 192 |
+
mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.95) 40%, rgba(0, 0, 0, 0.7) 65%, rgba(0, 0, 0, 0.4) 80%, transparent 95%);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.wheel::after {
|
| 196 |
+
content: "";
|
| 197 |
+
position: absolute;
|
| 198 |
+
inset: -45%;
|
| 199 |
+
border-radius: 50%;
|
| 200 |
+
background: radial-gradient(circle, rgba(7, 11, 22, 0.25), transparent 70%);
|
| 201 |
+
z-index: 0;
|
| 202 |
+
pointer-events: none;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.wheel-center {
|
| 206 |
+
position: absolute;
|
| 207 |
+
inset: 35%;
|
| 208 |
+
border-radius: 50%;
|
| 209 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 210 |
+
background: rgba(7, 11, 22, 0.8);
|
| 211 |
+
display: flex;
|
| 212 |
+
flex-direction: column;
|
| 213 |
+
align-items: center;
|
| 214 |
+
justify-content: center;
|
| 215 |
+
text-transform: uppercase;
|
| 216 |
+
font-size: 0.85rem;
|
| 217 |
+
letter-spacing: 0.3em;
|
| 218 |
+
text-align: center;
|
| 219 |
+
padding: 16px;
|
| 220 |
+
color: var(--text-muted);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.wheel-center small {
|
| 224 |
+
letter-spacing: normal;
|
| 225 |
+
text-transform: none;
|
| 226 |
+
margin-top: 12px;
|
| 227 |
+
color: var(--text-soft);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.wheel::before,
|
| 231 |
+
.wheel::after,
|
| 232 |
+
.wheel-center,
|
| 233 |
+
.sector-label-layer .sector-label,
|
| 234 |
+
.wheel-node {
|
| 235 |
+
transition: opacity 0.4s ease, filter 0.4s ease;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.sector-label-layer {
|
| 239 |
+
pointer-events: none;
|
| 240 |
+
position: absolute;
|
| 241 |
+
left: 50%;
|
| 242 |
+
top: 50%;
|
| 243 |
+
width: 0;
|
| 244 |
+
height: 0;
|
| 245 |
+
transform: translate(-50%, -50%);
|
| 246 |
+
z-index: 2;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
.sector-label-layer .sector-label {
|
| 251 |
+
position: absolute;
|
| 252 |
+
left: 0;
|
| 253 |
+
top: 0;
|
| 254 |
+
font-size: 1.2rem;
|
| 255 |
+
letter-spacing: 0.55em;
|
| 256 |
+
text-transform: uppercase;
|
| 257 |
+
color: rgba(255, 255, 255, 0.9);
|
| 258 |
+
font-weight: 600;
|
| 259 |
+
text-shadow: 0 6px 16px rgba(0, 0, 0, 0.85);
|
| 260 |
+
white-space: nowrap;
|
| 261 |
+
transform-origin: center;
|
| 262 |
+
text-align: center;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.sector-label-layer .sector-label[data-side="left"] {
|
| 266 |
+
text-align: right;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.wheel-node {
|
| 270 |
+
position: absolute;
|
| 271 |
+
border-radius: 50%;
|
| 272 |
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
| 273 |
+
color: var(--text-strong);
|
| 274 |
+
padding: 16px 10px;
|
| 275 |
+
text-align: center;
|
| 276 |
+
display: flex;
|
| 277 |
+
flex-direction: column;
|
| 278 |
+
justify-content: center;
|
| 279 |
+
gap: 6px;
|
| 280 |
+
cursor: pointer;
|
| 281 |
+
transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease;
|
| 282 |
+
background: rgba(7, 11, 22, 0.85);
|
| 283 |
+
z-index: 1;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.manual-mode .wheel-node {
|
| 287 |
+
cursor: grab;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.manual-mode .wheel-node.dragging {
|
| 291 |
+
cursor: grabbing;
|
| 292 |
+
opacity: 0.85;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.wheel-node.emotion-node {
|
| 296 |
+
width: var(--emotion-node-size);
|
| 297 |
+
height: var(--emotion-node-size);
|
| 298 |
+
font-size: 0.95rem;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.wheel-node.behavior-node {
|
| 302 |
+
width: var(--behavior-node-size);
|
| 303 |
+
height: var(--behavior-node-size);
|
| 304 |
+
font-size: 0.85rem;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.wheel-node:hover {
|
| 308 |
+
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.wheel-node.highlight {
|
| 312 |
+
box-shadow: 0 0 35px rgba(255, 255, 255, 0.25);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.wheel-node.busy {
|
| 316 |
+
opacity: 0.3;
|
| 317 |
+
pointer-events: none;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.wheel-node.unavailable {
|
| 321 |
+
opacity: 0.45;
|
| 322 |
+
border-style: dashed;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.node-title {
|
| 326 |
+
font-weight: 600;
|
| 327 |
+
text-transform: capitalize;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.node-meta {
|
| 331 |
+
font-size: 0.7rem;
|
| 332 |
+
letter-spacing: 0.05em;
|
| 333 |
+
color: var(--text-muted);
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.node-meta:empty {
|
| 337 |
+
display: none;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.coherence-panel {
|
| 341 |
+
margin: 180px auto 0;
|
| 342 |
+
padding: 16px 0;
|
| 343 |
+
max-width: 720px;
|
| 344 |
+
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
| 345 |
+
color: var(--text-muted);
|
| 346 |
+
font-size: 0.85rem;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.coherence-panel strong {
|
| 350 |
+
display: block;
|
| 351 |
+
color: var(--text-soft);
|
| 352 |
+
margin-bottom: 6px;
|
| 353 |
+
text-transform: uppercase;
|
| 354 |
+
letter-spacing: 0.2em;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.coherence-panel.clean {
|
| 358 |
+
border-color: rgba(122, 201, 136, 0.35);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
.issue-grid {
|
| 362 |
+
display: grid;
|
| 363 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 364 |
+
gap: 12px;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.issue-grid article {
|
| 368 |
+
padding: 12px;
|
| 369 |
+
border-radius: 16px;
|
| 370 |
+
background: rgba(255, 255, 255, 0.04);
|
| 371 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.issue-grid h4 {
|
| 375 |
+
margin: 0 0 6px;
|
| 376 |
+
font-size: 0.85rem;
|
| 377 |
+
color: var(--text-strong);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.issue-list {
|
| 381 |
+
display: flex;
|
| 382 |
+
flex-direction: column;
|
| 383 |
+
gap: 4px;
|
| 384 |
+
font-size: 0.85rem;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.issue-list span {
|
| 388 |
+
display: flex;
|
| 389 |
+
justify-content: space-between;
|
| 390 |
+
gap: 8px;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.issue-list small {
|
| 394 |
+
color: var(--text-muted);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.issue-list .more {
|
| 398 |
+
color: var(--text-muted);
|
| 399 |
+
font-style: italic;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.tooltip {
|
| 403 |
+
position: fixed;
|
| 404 |
+
pointer-events: none;
|
| 405 |
+
background: rgba(7, 11, 22, 0.95);
|
| 406 |
+
padding: 14px 20px;
|
| 407 |
+
border-radius: 14px;
|
| 408 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 409 |
+
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.4);
|
| 410 |
+
color: var(--text-strong);
|
| 411 |
+
font-size: 0.85rem;
|
| 412 |
+
max-width: 280px;
|
| 413 |
+
opacity: 0;
|
| 414 |
+
transform: translate(-50%, calc(-100% - 16px));
|
| 415 |
+
transition: opacity 0.2s ease;
|
| 416 |
+
z-index: 10;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.tooltip.visible {
|
| 420 |
+
opacity: 1;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.tooltip strong {
|
| 424 |
+
display: block;
|
| 425 |
+
margin-bottom: 4px;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.tooltip .muted {
|
| 429 |
+
display: block;
|
| 430 |
+
color: var(--text-muted);
|
| 431 |
+
font-size: 0.75rem;
|
| 432 |
+
letter-spacing: 0.08em;
|
| 433 |
+
text-transform: none;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.tooltip p {
|
| 437 |
+
margin: 0;
|
| 438 |
+
color: var(--text-soft);
|
| 439 |
+
line-height: 1.4;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.tooltip .warning {
|
| 443 |
+
color: #f9a826;
|
| 444 |
+
font-weight: 600;
|
| 445 |
+
margin-left: 6px;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.toast {
|
| 449 |
+
position: fixed;
|
| 450 |
+
bottom: 32px;
|
| 451 |
+
right: 32px;
|
| 452 |
+
background: rgba(7, 11, 22, 0.95);
|
| 453 |
+
padding: 16px 22px;
|
| 454 |
+
border-radius: 999px;
|
| 455 |
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
| 456 |
+
opacity: 0;
|
| 457 |
+
transform: translateY(20px);
|
| 458 |
+
transition: opacity 0.25s ease, transform 0.25s ease;
|
| 459 |
+
font-size: 0.95rem;
|
| 460 |
+
color: var(--text-soft);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.toast.visible {
|
| 464 |
+
opacity: 1;
|
| 465 |
+
transform: translateY(0);
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
@media (max-width: 640px) {
|
| 469 |
+
.card {
|
| 470 |
+
padding: 24px;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.hero-status {
|
| 474 |
+
flex-direction: column;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.wheel-center {
|
| 478 |
+
font-size: 0.75rem;
|
| 479 |
+
letter-spacing: 0.2em;
|
| 480 |
+
}
|
| 481 |
+
}
|
build/lib/ressources/emotions_overview.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Print the metadata of each recorded emotion from the Reachy Mini dataset."""
|
| 2 |
+
|
| 3 |
+
from reachy_mini.motion.recorded_move import RecordedMoves
|
| 4 |
+
|
| 5 |
+
EMOTIONS_DATASET = "pollen-robotics/reachy-mini-emotions-library"
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def main() -> None:
|
| 9 |
+
"""Display name, description, and duration for each recorded emotion."""
|
| 10 |
+
recorded_emotions = RecordedMoves(EMOTIONS_DATASET)
|
| 11 |
+
|
| 12 |
+
for move_name in sorted(recorded_emotions.list_moves()):
|
| 13 |
+
move = recorded_emotions.get(move_name)
|
| 14 |
+
duration = move.duration # seconds, already included in the dataset
|
| 15 |
+
print(f"{move_name}: {move.description} (duration: {duration:.2f}s)")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
main()
|
build/lib/ressources/recorded_moves_example.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Demonstrate and play all available moves from a dataset for Reachy Mini.
|
| 2 |
+
|
| 3 |
+
Run :
|
| 4 |
+
|
| 5 |
+
python3 recorded_moves_example.py -l [dance, emotions]
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
|
| 10 |
+
from reachy_mini import ReachyMini
|
| 11 |
+
from reachy_mini.motion.recorded_move import RecordedMove, RecordedMoves
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main(dataset_path: str) -> None:
|
| 15 |
+
"""Connect to Reachy and run the main demonstration loop."""
|
| 16 |
+
recorded_moves = RecordedMoves(dataset_path)
|
| 17 |
+
|
| 18 |
+
print("Connecting to Reachy Mini...")
|
| 19 |
+
with ReachyMini(use_sim=False, media_backend="no_media") as reachy:
|
| 20 |
+
print("Connection successful! Starting dance sequence...\n")
|
| 21 |
+
try:
|
| 22 |
+
while True:
|
| 23 |
+
for move_name in recorded_moves.list_moves():
|
| 24 |
+
move: RecordedMove = recorded_moves.get(move_name)
|
| 25 |
+
print(f"Playing move: {move_name}: {move.description}\n")
|
| 26 |
+
# print(f"params: {move.move_params}")
|
| 27 |
+
reachy.play_move(move, initial_goto_duration=1.0)
|
| 28 |
+
|
| 29 |
+
except KeyboardInterrupt:
|
| 30 |
+
print("\n Sequence interrupted by user. Shutting down.")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
parser = argparse.ArgumentParser(
|
| 35 |
+
description="Demonstrate and play all available dance moves for Reachy Mini."
|
| 36 |
+
)
|
| 37 |
+
parser.add_argument(
|
| 38 |
+
"-l", "--library", type=str, default="dance", choices=["dance", "emotions"]
|
| 39 |
+
)
|
| 40 |
+
args = parser.parse_args()
|
| 41 |
+
|
| 42 |
+
dataset_path = (
|
| 43 |
+
"pollen-robotics/reachy-mini-dances-library"
|
| 44 |
+
if args.library == "dance"
|
| 45 |
+
else "pollen-robotics/reachy-mini-emotions-library"
|
| 46 |
+
)
|
| 47 |
+
main(dataset_path)
|
index.html
CHANGED
|
@@ -2,216 +2,273 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
|
| 4 |
<head>
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
</head>
|
| 10 |
|
| 11 |
<body>
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
</body>
|
| 216 |
|
| 217 |
</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 • Emotions Wheel</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css" />
|
| 9 |
</head>
|
| 10 |
|
| 11 |
<body>
|
| 12 |
+
<div class="bg-gradient"></div>
|
| 13 |
+
<main class="page">
|
| 14 |
+
<header class="hero">
|
| 15 |
+
<div class="hero-text">
|
| 16 |
+
<p class="eyebrow">Reachy Mini App</p>
|
| 17 |
+
<h1>Emotions Wheel</h1>
|
| 18 |
+
<p class="lede">
|
| 19 |
+
The landing page now mirrors the production UI: two synchronized rings (families outside, behaviors inside),
|
| 20 |
+
tooltips straight from the dataset metadata, manual layout controls, and coherence diagnostics so demos feel
|
| 21 |
+
the same as the robot runtime.
|
| 22 |
+
</p>
|
| 23 |
+
<div class="hero-meta">
|
| 24 |
+
<span class="chip">Linked wheels</span>
|
| 25 |
+
<span class="chip">Manual layout mode</span>
|
| 26 |
+
<span class="chip">Dataset-aware tooltips</span>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="hero-cta">
|
| 29 |
+
<div>
|
| 30 |
+
<p class="label">Dataset</p>
|
| 31 |
+
<p class="value"><a href="https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library">pollen-robotics/reachy-mini-emotions-library</a></p>
|
| 32 |
+
</div>
|
| 33 |
+
<div>
|
| 34 |
+
<p class="label">Behaviors catalogued</p>
|
| 35 |
+
<p class="value">138 motions · 8 families</p>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div class="hero-preview">
|
| 40 |
+
<div class="preview-wheel">
|
| 41 |
+
<div class="ring outer"></div>
|
| 42 |
+
<div class="ring inner"></div>
|
| 43 |
+
<div class="preview-label">
|
| 44 |
+
<span>emotion</span>
|
| 45 |
+
<span>behavior</span>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
<div class="preview-controls">
|
| 49 |
+
<span class="chip accent">Manual layout</span>
|
| 50 |
+
<span class="chip soft">Save layout</span>
|
| 51 |
+
<span class="chip success">Reachy idle</span>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
</header>
|
| 55 |
+
|
| 56 |
+
<section class="feature-grid">
|
| 57 |
+
<article class="feature-card">
|
| 58 |
+
<h2>Linked wheels + live tooltips</h2>
|
| 59 |
+
<p>
|
| 60 |
+
Hover the outer emotion ring to highlight its behaviors, then click a behavior badge to play the move on
|
| 61 |
+
Reachy Mini. Tooltips quote the dataset entry name, intensity, and duration so the wheel always stays in
|
| 62 |
+
sync with the YAML catalog.
|
| 63 |
+
</p>
|
| 64 |
+
</article>
|
| 65 |
+
<article class="feature-card">
|
| 66 |
+
<h2>Manual layout mode</h2>
|
| 67 |
+
<p>
|
| 68 |
+
Tapping <em>Manual layout</em> displays draggable handles for every move. Drop badges anywhere on the wheel,
|
| 69 |
+
press save, and the layout persists to disk so your museum demos, lessons, and videos stay consistent.
|
| 70 |
+
</p>
|
| 71 |
+
</article>
|
| 72 |
+
<article class="feature-card">
|
| 73 |
+
<h2>Duration & intensity cues</h2>
|
| 74 |
+
<p>
|
| 75 |
+
Distance from the center encodes emotion intensity, while the colored crescents show the duration bucket.
|
| 76 |
+
It is the same visual legend the runtime uses, so observers instantly grasp what Reachy is about to do.
|
| 77 |
+
</p>
|
| 78 |
+
</article>
|
| 79 |
+
<article class="feature-card">
|
| 80 |
+
<h2>Sync diagnostics panel</h2>
|
| 81 |
+
<p>
|
| 82 |
+
A small panel below the wheels compares the dataset, YAML spec, and recorded files. If something drifts,
|
| 83 |
+
the warning badge catches it before a visitor clicks a missing behavior.
|
| 84 |
+
</p>
|
| 85 |
+
</article>
|
| 86 |
+
</section>
|
| 87 |
+
|
| 88 |
+
<section class="details-grid">
|
| 89 |
+
<article class="details-card">
|
| 90 |
+
<h3>Color language</h3>
|
| 91 |
+
<p>Plutchik-inspired colors span the outer wheel. Labels stay inside tooltips so the UI remains minimal.</p>
|
| 92 |
+
<ul class="palette">
|
| 93 |
+
<li><span class="swatch" style="--swatch:#FF9F66"></span><div><strong>Joy</strong><small>warm coral</small></div></li>
|
| 94 |
+
<li><span class="swatch" style="--swatch:#5CC8D7"></span><div><strong>Trust</strong><small>lagoon teal</small></div></li>
|
| 95 |
+
<li><span class="swatch" style="--swatch:#4D7C8A"></span><div><strong>Fear</strong><small>noctilucent blue</small></div></li>
|
| 96 |
+
<li><span class="swatch" style="--swatch:#C084FC"></span><div><strong>Surprise</strong><small>lilac flare</small></div></li>
|
| 97 |
+
<li><span class="swatch" style="--swatch:#4F6DF5"></span><div><strong>Sadness</strong><small>dusk indigo</small></div></li>
|
| 98 |
+
<li><span class="swatch" style="--swatch:#58B368"></span><div><strong>Disgust</strong><small>mossy green</small></div></li>
|
| 99 |
+
<li><span class="swatch" style="--swatch:#E94F37"></span><div><strong>Anger</strong><small>ember red</small></div></li>
|
| 100 |
+
<li><span class="swatch" style="--swatch:#FFB347"></span><div><strong>Anticipation</strong><small>amber sunrise</small></div></li>
|
| 101 |
+
</ul>
|
| 102 |
+
</article>
|
| 103 |
+
<article class="details-card">
|
| 104 |
+
<h3>Duration crescents</h3>
|
| 105 |
+
<p>Badges keep the same three crescents the runtime overlays on every node.</p>
|
| 106 |
+
<ul class="duration-list">
|
| 107 |
+
<li><span class="swatch small" style="--swatch:#73E0A9"></span><div><strong>Seafoam</strong><small>short · ≤ 4 seconds</small></div></li>
|
| 108 |
+
<li><span class="swatch small" style="--swatch:#FFC857"></span><div><strong>Amber</strong><small>medium · 4–8 seconds</small></div></li>
|
| 109 |
+
<li><span class="swatch small" style="--swatch:#FF6B6B"></span><div><strong>Rose</strong><small>long · > 8 seconds</small></div></li>
|
| 110 |
+
</ul>
|
| 111 |
+
</article>
|
| 112 |
+
<article class="details-card">
|
| 113 |
+
<h3>Operator niceties</h3>
|
| 114 |
+
<ul class="feature-list">
|
| 115 |
+
<li>Status chip locks clicks while Reachy finishes a previous behavior.</li>
|
| 116 |
+
<li>Toast + tooltip copy include the raw move name so debugging stays easy.</li>
|
| 117 |
+
<li>Aside from layout saves, everything runs client-side, so the wheel feels instant.</li>
|
| 118 |
+
</ul>
|
| 119 |
+
</article>
|
| 120 |
+
</section>
|
| 121 |
+
|
| 122 |
+
<section class="install-card">
|
| 123 |
+
<div>
|
| 124 |
+
<h2>Install on your Reachy Mini</h2>
|
| 125 |
+
<p>
|
| 126 |
+
Point the installer at the dashboard that runs on your robot (default is
|
| 127 |
+
<code>http://localhost:8000</code>). We ping its health endpoint first, then call <code>/api/install</code>
|
| 128 |
+
with the Space URL so the dashboard clones this repository.
|
| 129 |
+
</p>
|
| 130 |
+
</div>
|
| 131 |
+
<div class="install-form">
|
| 132 |
+
<label for="dashboardUrl">Dashboard URL</label>
|
| 133 |
+
<input type="url" id="dashboardUrl" value="http://localhost:8000"
|
| 134 |
+
placeholder="http://reachy-mini:8000" />
|
| 135 |
+
<button id="installBtn" class="install-btn">
|
| 136 |
+
<span>📥</span>
|
| 137 |
+
Install Emotions Wheel
|
| 138 |
+
</button>
|
| 139 |
+
<div id="installStatus" class="install-status"></div>
|
| 140 |
+
</div>
|
| 141 |
+
</section>
|
| 142 |
+
</main>
|
| 143 |
+
|
| 144 |
+
<footer class="footer">
|
| 145 |
+
<p>Built with ❤️ by Pollen Robotics · Browse more Reachy Mini apps on Hugging Face Spaces.</p>
|
| 146 |
+
</footer>
|
| 147 |
+
|
| 148 |
+
<script>
|
| 149 |
+
function getCurrentSpaceUrl() {
|
| 150 |
+
const currentUrl = window.location.href;
|
| 151 |
+
return currentUrl.split('?')[0].replace(/\/$/, '');
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function parseTomlProjectName(tomlContent) {
|
| 155 |
+
try {
|
| 156 |
+
const lines = tomlContent.split('\n');
|
| 157 |
+
let inProjectSection = false;
|
| 158 |
+
|
| 159 |
+
for (const line of lines) {
|
| 160 |
+
const trimmedLine = line.trim();
|
| 161 |
+
|
| 162 |
+
if (trimmedLine === '[project]') {
|
| 163 |
+
inProjectSection = true;
|
| 164 |
+
continue;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
|
| 168 |
+
inProjectSection = false;
|
| 169 |
+
continue;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if (inProjectSection && trimmedLine.startsWith('name')) {
|
| 173 |
+
const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
|
| 174 |
+
if (match) {
|
| 175 |
+
return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
throw new Error('Project name not found in pyproject.toml');
|
| 181 |
+
} catch (error) {
|
| 182 |
+
console.error('Error parsing pyproject.toml:', error);
|
| 183 |
+
return 'unknown-app';
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
async function getAppNameFromCurrentSpace() {
|
| 188 |
+
try {
|
| 189 |
+
const response = await fetch('./pyproject.toml');
|
| 190 |
+
if (!response.ok) {
|
| 191 |
+
throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
const tomlContent = await response.text();
|
| 195 |
+
return parseTomlProjectName(tomlContent);
|
| 196 |
+
} catch (error) {
|
| 197 |
+
console.error('Error fetching app name from current space:', error);
|
| 198 |
+
const url = getCurrentSpaceUrl();
|
| 199 |
+
const parts = url.split('/');
|
| 200 |
+
const spaceName = parts[parts.length - 1];
|
| 201 |
+
return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function showStatus(type, message) {
|
| 206 |
+
const statusDiv = document.getElementById('installStatus');
|
| 207 |
+
statusDiv.textContent = message;
|
| 208 |
+
statusDiv.className = `install-status ${type}`;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
async function installToReachy() {
|
| 212 |
+
const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
|
| 213 |
+
const installBtn = document.getElementById('installBtn');
|
| 214 |
+
|
| 215 |
+
if (!dashboardUrl) {
|
| 216 |
+
showStatus('error', 'Please enter your Reachy dashboard URL');
|
| 217 |
+
return;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
try {
|
| 221 |
+
installBtn.disabled = true;
|
| 222 |
+
installBtn.textContent = 'Installing…';
|
| 223 |
+
showStatus('loading', 'Connecting to your Reachy dashboard…');
|
| 224 |
+
|
| 225 |
+
const testResponse = await fetch(`${dashboardUrl}/api/status`, {
|
| 226 |
+
method: 'GET',
|
| 227 |
+
mode: 'cors',
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
if (!testResponse.ok) {
|
| 231 |
+
throw new Error('Cannot connect to dashboard. Check the URL and make sure the dashboard is running.');
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
showStatus('loading', 'Reading app configuration…');
|
| 235 |
+
|
| 236 |
+
const appName = await getAppNameFromCurrentSpace();
|
| 237 |
+
const repoUrl = getCurrentSpaceUrl();
|
| 238 |
+
|
| 239 |
+
showStatus('loading', `Starting installation of "${appName}"…`);
|
| 240 |
+
|
| 241 |
+
const installResponse = await fetch(`${dashboardUrl}/api/install`, {
|
| 242 |
+
method: 'POST',
|
| 243 |
+
mode: 'cors',
|
| 244 |
+
headers: {
|
| 245 |
+
'Content-Type': 'application/json',
|
| 246 |
+
},
|
| 247 |
+
body: JSON.stringify({
|
| 248 |
+
url: repoUrl,
|
| 249 |
+
name: appName
|
| 250 |
+
})
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
const result = await installResponse.json();
|
| 254 |
+
|
| 255 |
+
if (installResponse.ok) {
|
| 256 |
+
showStatus('success', `Installation started for "${appName}". Check your dashboard for progress.`);
|
| 257 |
+
} else {
|
| 258 |
+
throw new Error(result.detail || 'Installation failed');
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
} catch (error) {
|
| 262 |
+
console.error('Installation error:', error);
|
| 263 |
+
showStatus('error', `❌ ${error.message}`);
|
| 264 |
+
} finally {
|
| 265 |
+
installBtn.disabled = false;
|
| 266 |
+
installBtn.textContent = 'Install Emotions Wheel';
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
document.getElementById('installBtn').addEventListener('click', installToReachy);
|
| 271 |
+
</script>
|
| 272 |
</body>
|
| 273 |
|
| 274 |
</html>
|
style.css
CHANGED
|
@@ -1,273 +1,409 @@
|
|
| 1 |
:root {
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
*,
|
| 15 |
*::before,
|
| 16 |
*::after {
|
| 17 |
-
|
| 18 |
}
|
| 19 |
|
| 20 |
body {
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
}
|
| 29 |
|
| 30 |
.bg-gradient {
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
}
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
z-index: 1;
|
| 44 |
-
max-width: 1200px;
|
| 45 |
-
margin: 0 auto;
|
| 46 |
-
padding: 80px 24px 32px;
|
| 47 |
-
display: grid;
|
| 48 |
-
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
| 49 |
-
gap: 32px;
|
| 50 |
-
align-items: center;
|
| 51 |
}
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
margin-bottom: 12px;
|
| 56 |
}
|
| 57 |
|
| 58 |
-
.
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
-
.
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
}
|
| 69 |
|
| 70 |
-
.hero-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
flex-wrap: wrap;
|
| 74 |
-
gap: 10px;
|
| 75 |
}
|
| 76 |
|
| 77 |
-
.
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
font-size: 0.9rem;
|
| 82 |
}
|
| 83 |
|
| 84 |
-
.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.4);
|
| 90 |
-
text-align: center;
|
| 91 |
}
|
| 92 |
|
| 93 |
-
.
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
border: 1px dashed rgba(255, 255, 255, 0.2);
|
| 99 |
-
position: relative;
|
| 100 |
}
|
| 101 |
|
| 102 |
-
.
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
-
.
|
| 113 |
-
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
-
.
|
| 117 |
-
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
-
.
|
| 121 |
-
|
|
|
|
| 122 |
}
|
| 123 |
|
| 124 |
-
.
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
display: flex;
|
| 130 |
-
justify-content: center;
|
| 131 |
-
align-items: center;
|
| 132 |
-
gap: 12px;
|
| 133 |
}
|
| 134 |
|
| 135 |
-
.
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
-
.
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
}
|
| 147 |
|
| 148 |
-
.
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
|
| 159 |
.feature-grid {
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
}
|
| 164 |
|
| 165 |
.feature-card {
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
.feature-card h2 {
|
| 174 |
-
|
| 175 |
-
|
| 176 |
}
|
| 177 |
|
| 178 |
.feature-card p {
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
-
.
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
background: var(--surface);
|
| 187 |
-
border: 1px solid var(--border);
|
| 188 |
-
border-radius: 32px;
|
| 189 |
-
padding: 32px;
|
| 190 |
-
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.4);
|
| 191 |
}
|
| 192 |
|
| 193 |
-
.
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
-
.
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
label {
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
}
|
| 209 |
|
| 210 |
input[type="url"] {
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
}
|
| 220 |
|
| 221 |
.install-btn {
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
}
|
| 233 |
|
| 234 |
.install-btn:disabled {
|
| 235 |
-
|
| 236 |
-
|
| 237 |
}
|
| 238 |
|
| 239 |
.install-status {
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
}
|
| 245 |
|
| 246 |
.install-status.success {
|
| 247 |
-
|
| 248 |
}
|
| 249 |
|
| 250 |
.install-status.error {
|
| 251 |
-
|
| 252 |
}
|
| 253 |
|
| 254 |
.install-status.loading {
|
| 255 |
-
|
| 256 |
}
|
| 257 |
|
| 258 |
.footer {
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
}
|
| 264 |
|
| 265 |
@media (max-width: 600px) {
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
}
|
|
|
|
| 1 |
:root {
|
| 2 |
+
--bg: #030711;
|
| 3 |
+
--surface: rgba(9, 14, 29, 0.88);
|
| 4 |
+
--surface-soft: rgba(15, 23, 42, 0.72);
|
| 5 |
+
--border: rgba(255, 255, 255, 0.08);
|
| 6 |
+
--text: #f4f6ff;
|
| 7 |
+
--muted: rgba(244, 246, 255, 0.72);
|
| 8 |
+
--accent: #c084fc;
|
| 9 |
+
--accent-2: #64d6ff;
|
| 10 |
+
--success: #7bd389;
|
| 11 |
+
--danger: #ff6b6b;
|
| 12 |
+
font-family: "Space Grotesk", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 13 |
+
color: var(--text);
|
| 14 |
+
background-color: var(--bg);
|
| 15 |
}
|
| 16 |
|
| 17 |
*,
|
| 18 |
*::before,
|
| 19 |
*::after {
|
| 20 |
+
box-sizing: border-box;
|
| 21 |
}
|
| 22 |
|
| 23 |
body {
|
| 24 |
+
margin: 0;
|
| 25 |
+
min-height: 100vh;
|
| 26 |
+
background: radial-gradient(circle at 20% -10%, rgba(255, 255, 255, 0.12), transparent 50%),
|
| 27 |
+
radial-gradient(circle at 80% 0%, rgba(192, 132, 252, 0.2), transparent 45%),
|
| 28 |
+
radial-gradient(circle at 0% 60%, rgba(100, 214, 255, 0.12), transparent 40%),
|
| 29 |
+
var(--bg);
|
| 30 |
+
color: var(--text);
|
| 31 |
}
|
| 32 |
|
| 33 |
.bg-gradient {
|
| 34 |
+
position: fixed;
|
| 35 |
+
inset: 0;
|
| 36 |
+
background: radial-gradient(circle at 20% 20%, rgba(192, 132, 252, 0.25), transparent 50%),
|
| 37 |
+
radial-gradient(circle at 80% 30%, rgba(94, 234, 212, 0.12), transparent 45%);
|
| 38 |
+
filter: blur(80px);
|
| 39 |
+
opacity: 0.7;
|
| 40 |
+
pointer-events: none;
|
| 41 |
+
z-index: 0;
|
| 42 |
}
|
| 43 |
|
| 44 |
+
a {
|
| 45 |
+
color: var(--accent-2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
+
a:hover {
|
| 49 |
+
color: var(--accent);
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
+
.page {
|
| 53 |
+
position: relative;
|
| 54 |
+
z-index: 1;
|
| 55 |
+
max-width: 1200px;
|
| 56 |
+
margin: 0 auto;
|
| 57 |
+
padding: 96px 24px 120px;
|
| 58 |
+
display: flex;
|
| 59 |
+
flex-direction: column;
|
| 60 |
+
gap: 40px;
|
| 61 |
}
|
| 62 |
|
| 63 |
+
.hero {
|
| 64 |
+
display: grid;
|
| 65 |
+
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
| 66 |
+
gap: 48px;
|
| 67 |
+
align-items: center;
|
| 68 |
}
|
| 69 |
|
| 70 |
+
.hero-text h1 {
|
| 71 |
+
font-size: clamp(2.8rem, 5vw, 3.8rem);
|
| 72 |
+
margin: 0.1em 0 0.4em;
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
|
| 75 |
+
.hero .lede {
|
| 76 |
+
color: var(--muted);
|
| 77 |
+
line-height: 1.65;
|
| 78 |
+
max-width: 52ch;
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
+
.eyebrow {
|
| 82 |
+
text-transform: uppercase;
|
| 83 |
+
letter-spacing: 0.3em;
|
| 84 |
+
font-size: 0.8rem;
|
| 85 |
+
color: rgba(255, 255, 255, 0.6);
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
+
.hero-meta {
|
| 89 |
+
margin: 20px 0 0;
|
| 90 |
+
display: flex;
|
| 91 |
+
flex-wrap: wrap;
|
| 92 |
+
gap: 10px;
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
+
.chip {
|
| 96 |
+
display: inline-flex;
|
| 97 |
+
align-items: center;
|
| 98 |
+
gap: 6px;
|
| 99 |
+
padding: 8px 16px;
|
| 100 |
+
border-radius: 999px;
|
| 101 |
+
font-size: 0.85rem;
|
| 102 |
+
background: rgba(255, 255, 255, 0.08);
|
| 103 |
+
color: var(--text);
|
| 104 |
}
|
| 105 |
|
| 106 |
+
.chip.accent {
|
| 107 |
+
background: rgba(192, 132, 252, 0.25);
|
| 108 |
+
color: #f5eeff;
|
| 109 |
}
|
| 110 |
|
| 111 |
+
.chip.soft {
|
| 112 |
+
background: rgba(255, 255, 255, 0.1);
|
| 113 |
+
color: var(--muted);
|
| 114 |
}
|
| 115 |
|
| 116 |
+
.chip.success {
|
| 117 |
+
background: rgba(123, 211, 137, 0.2);
|
| 118 |
+
color: var(--success);
|
| 119 |
}
|
| 120 |
|
| 121 |
+
.hero-cta {
|
| 122 |
+
margin-top: 24px;
|
| 123 |
+
display: grid;
|
| 124 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 125 |
+
gap: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
}
|
| 127 |
|
| 128 |
+
.hero-cta .label {
|
| 129 |
+
text-transform: uppercase;
|
| 130 |
+
letter-spacing: 0.2em;
|
| 131 |
+
font-size: 0.72rem;
|
| 132 |
+
color: var(--muted);
|
| 133 |
+
margin: 0 0 4px;
|
| 134 |
}
|
| 135 |
|
| 136 |
+
.hero-cta .value {
|
| 137 |
+
margin: 0;
|
| 138 |
+
font-size: 1.05rem;
|
| 139 |
+
color: var(--text);
|
| 140 |
}
|
| 141 |
|
| 142 |
+
.hero-cta .value a {
|
| 143 |
+
text-decoration: none;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.hero-preview {
|
| 147 |
+
background: var(--surface);
|
| 148 |
+
border: 1px solid var(--border);
|
| 149 |
+
border-radius: 32px;
|
| 150 |
+
padding: 32px 32px 26px;
|
| 151 |
+
text-align: center;
|
| 152 |
+
box-shadow: 0 40px 80px rgba(2, 6, 23, 0.6);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.preview-wheel {
|
| 156 |
+
position: relative;
|
| 157 |
+
width: min(320px, 70vw);
|
| 158 |
+
height: min(320px, 70vw);
|
| 159 |
+
margin: 0 auto 24px;
|
| 160 |
+
border-radius: 50%;
|
| 161 |
+
background: conic-gradient(#ff9f66 0 45deg, #5cc8d7 45deg 90deg, #4d7c8a 90deg 135deg,
|
| 162 |
+
#c084fc 135deg 180deg, #4f6df5 180deg 225deg, #58b368 225deg 270deg,
|
| 163 |
+
#e94f37 270deg 315deg, #ffb347 315deg 360deg);
|
| 164 |
+
filter: drop-shadow(0 15px 25px rgba(0, 0, 0, 0.5));
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.preview-wheel .ring {
|
| 168 |
+
position: absolute;
|
| 169 |
+
inset: 14px;
|
| 170 |
+
border-radius: 50%;
|
| 171 |
+
border: 2px solid rgba(255, 255, 255, 0.35);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.preview-wheel .ring.inner {
|
| 175 |
+
inset: 25%;
|
| 176 |
+
border-color: rgba(244, 246, 255, 0.55);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.preview-label {
|
| 180 |
+
position: absolute;
|
| 181 |
+
inset: 0;
|
| 182 |
+
display: flex;
|
| 183 |
+
flex-direction: column;
|
| 184 |
+
align-items: center;
|
| 185 |
+
justify-content: center;
|
| 186 |
+
gap: 6px;
|
| 187 |
+
font-size: 0.9rem;
|
| 188 |
+
text-transform: uppercase;
|
| 189 |
+
letter-spacing: 0.3em;
|
| 190 |
+
color: rgba(255, 255, 255, 0.85);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.preview-controls {
|
| 194 |
+
display: flex;
|
| 195 |
+
justify-content: center;
|
| 196 |
+
flex-wrap: wrap;
|
| 197 |
+
gap: 10px;
|
| 198 |
}
|
| 199 |
|
| 200 |
.feature-grid {
|
| 201 |
+
display: grid;
|
| 202 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 203 |
+
gap: 18px;
|
| 204 |
}
|
| 205 |
|
| 206 |
.feature-card {
|
| 207 |
+
background: var(--surface);
|
| 208 |
+
border: 1px solid var(--border);
|
| 209 |
+
border-radius: 24px;
|
| 210 |
+
padding: 28px;
|
| 211 |
+
line-height: 1.6;
|
| 212 |
+
box-shadow: 0 25px 60px rgba(3, 7, 18, 0.55);
|
| 213 |
}
|
| 214 |
|
| 215 |
.feature-card h2 {
|
| 216 |
+
margin: 0 0 0.8em;
|
| 217 |
+
font-size: 1.3rem;
|
| 218 |
}
|
| 219 |
|
| 220 |
.feature-card p {
|
| 221 |
+
margin: 0;
|
| 222 |
+
color: var(--muted);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.feature-card em {
|
| 226 |
+
color: var(--accent);
|
| 227 |
+
font-style: normal;
|
| 228 |
}
|
| 229 |
|
| 230 |
+
.details-grid {
|
| 231 |
+
display: grid;
|
| 232 |
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
| 233 |
+
gap: 18px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
}
|
| 235 |
|
| 236 |
+
.details-card {
|
| 237 |
+
background: var(--surface-soft);
|
| 238 |
+
border: 1px solid var(--border);
|
| 239 |
+
border-radius: 24px;
|
| 240 |
+
padding: 28px;
|
| 241 |
+
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
|
| 242 |
}
|
| 243 |
|
| 244 |
+
.details-card h3 {
|
| 245 |
+
margin: 0 0 0.6em;
|
| 246 |
+
font-size: 1.1rem;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.details-card p {
|
| 250 |
+
color: var(--muted);
|
| 251 |
+
margin-top: 0;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.palette,
|
| 255 |
+
.duration-list,
|
| 256 |
+
.feature-list {
|
| 257 |
+
list-style: none;
|
| 258 |
+
padding: 0;
|
| 259 |
+
margin: 16px 0 0;
|
| 260 |
+
display: flex;
|
| 261 |
+
flex-direction: column;
|
| 262 |
+
gap: 12px;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.palette li,
|
| 266 |
+
.duration-list li {
|
| 267 |
+
display: grid;
|
| 268 |
+
grid-template-columns: auto 1fr;
|
| 269 |
+
gap: 12px;
|
| 270 |
+
align-items: center;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.palette strong,
|
| 274 |
+
.duration-list strong {
|
| 275 |
+
display: block;
|
| 276 |
+
font-size: 0.95rem;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.palette small,
|
| 280 |
+
.duration-list small {
|
| 281 |
+
color: var(--muted);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.swatch {
|
| 285 |
+
width: 26px;
|
| 286 |
+
height: 26px;
|
| 287 |
+
border-radius: 8px;
|
| 288 |
+
background: var(--swatch, #fff);
|
| 289 |
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.swatch.small {
|
| 293 |
+
border-radius: 999px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.feature-list li {
|
| 297 |
+
position: relative;
|
| 298 |
+
padding-left: 18px;
|
| 299 |
+
color: var(--muted);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.feature-list li::before {
|
| 303 |
+
content: "•";
|
| 304 |
+
position: absolute;
|
| 305 |
+
left: 0;
|
| 306 |
+
color: var(--accent-2);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.install-card {
|
| 310 |
+
display: grid;
|
| 311 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 312 |
+
gap: 32px;
|
| 313 |
+
background: var(--surface);
|
| 314 |
+
border: 1px solid var(--border);
|
| 315 |
+
border-radius: 32px;
|
| 316 |
+
padding: 36px;
|
| 317 |
+
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.install-card h2 {
|
| 321 |
+
margin-top: 0;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.install-card p {
|
| 325 |
+
color: var(--muted);
|
| 326 |
+
line-height: 1.6;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.install-form {
|
| 330 |
+
display: flex;
|
| 331 |
+
flex-direction: column;
|
| 332 |
}
|
| 333 |
|
| 334 |
label {
|
| 335 |
+
text-transform: uppercase;
|
| 336 |
+
letter-spacing: 0.2em;
|
| 337 |
+
font-size: 0.72rem;
|
| 338 |
+
color: var(--muted);
|
| 339 |
+
margin-bottom: 6px;
|
| 340 |
}
|
| 341 |
|
| 342 |
input[type="url"] {
|
| 343 |
+
width: 100%;
|
| 344 |
+
padding: 14px 18px;
|
| 345 |
+
border-radius: 18px;
|
| 346 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 347 |
+
background: rgba(0, 0, 0, 0.35);
|
| 348 |
+
color: var(--text);
|
| 349 |
+
font-size: 1rem;
|
| 350 |
+
margin-bottom: 16px;
|
| 351 |
}
|
| 352 |
|
| 353 |
.install-btn {
|
| 354 |
+
display: inline-flex;
|
| 355 |
+
align-items: center;
|
| 356 |
+
gap: 10px;
|
| 357 |
+
padding: 14px 22px;
|
| 358 |
+
border-radius: 999px;
|
| 359 |
+
border: none;
|
| 360 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
| 361 |
+
color: #0b0415;
|
| 362 |
+
font-weight: 600;
|
| 363 |
+
cursor: pointer;
|
| 364 |
}
|
| 365 |
|
| 366 |
.install-btn:disabled {
|
| 367 |
+
opacity: 0.6;
|
| 368 |
+
cursor: not-allowed;
|
| 369 |
}
|
| 370 |
|
| 371 |
.install-status {
|
| 372 |
+
min-height: 22px;
|
| 373 |
+
margin-top: 10px;
|
| 374 |
+
font-size: 0.9rem;
|
| 375 |
+
color: var(--muted);
|
| 376 |
}
|
| 377 |
|
| 378 |
.install-status.success {
|
| 379 |
+
color: var(--success);
|
| 380 |
}
|
| 381 |
|
| 382 |
.install-status.error {
|
| 383 |
+
color: var(--danger);
|
| 384 |
}
|
| 385 |
|
| 386 |
.install-status.loading {
|
| 387 |
+
color: var(--accent-2);
|
| 388 |
}
|
| 389 |
|
| 390 |
.footer {
|
| 391 |
+
text-align: center;
|
| 392 |
+
color: var(--muted);
|
| 393 |
+
font-size: 0.9rem;
|
| 394 |
+
padding: 32px 24px 64px;
|
| 395 |
}
|
| 396 |
|
| 397 |
@media (max-width: 600px) {
|
| 398 |
+
.page {
|
| 399 |
+
padding-top: 72px;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.hero-preview {
|
| 403 |
+
padding: 24px;
|
| 404 |
+
}
|
| 405 |
|
| 406 |
+
.install-card {
|
| 407 |
+
padding: 28px;
|
| 408 |
+
}
|
| 409 |
}
|