Spaces:
Running
Running
Commit ·
25db0fc
1
Parent(s): c44bdad
feat: React observability dashboard + FastAPI server + matplotlib renderer
Browse files- src/: Computational Lab Instrument dashboard (JetBrains Mono, dark #0d0d14 bg)
- CreaseCanvas: SVG paper with mountain/valley/ghost target overlay
- RewardPanel: live bar chart (kawasaki, maekawa, blb, progress, economy)
- StepFeed: scrollable fold instruction log with assignment badges
- InfoBadges: foldability status, crease counts
- PlayerControls: step-through + auto-play at 1.5s interval
- TargetSelector: grouped by difficulty level
- server.py: FastAPI with /targets, /episode/demo, /episode/run endpoints
- viz/renderer.py: matplotlib draw_paper_state, draw_reward_bars, render_episode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- requirements.txt +2 -0
- server.py +172 -0
- src/App.css +492 -23
- src/App.js +180 -15
- src/App.test.js +1 -8
- src/components/CreaseCanvas.js +113 -0
- src/components/InfoBadges.js +72 -0
- src/components/PlayerControls.js +54 -0
- src/components/RewardPanel.js +50 -0
- src/components/StepFeed.js +73 -0
- src/components/TargetSelector.js +38 -0
- src/index.css +29 -8
- src/reportWebVitals.js +1 -13
- viz/__init__.py +0 -0
- viz/renderer.py +315 -0
requirements.txt
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
shapely>=2.0.0
|
| 2 |
numpy>=1.24.0
|
| 3 |
pytest>=7.0.0
|
|
|
|
|
|
|
|
|
| 1 |
shapely>=2.0.0
|
| 2 |
numpy>=1.24.0
|
| 3 |
pytest>=7.0.0
|
| 4 |
+
fastapi>=0.100.0
|
| 5 |
+
uvicorn>=0.23.0
|
server.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI server for the origami RL environment.
|
| 3 |
+
Serves episode data to the React frontend.
|
| 4 |
+
|
| 5 |
+
Usage: uvicorn server:app --reload --port 8000
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
except ImportError:
|
| 13 |
+
print("Run: pip install fastapi uvicorn pydantic")
|
| 14 |
+
raise
|
| 15 |
+
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
app = FastAPI(title="OrigamiRL API")
|
| 20 |
+
|
| 21 |
+
app.add_middleware(
|
| 22 |
+
CORSMiddleware,
|
| 23 |
+
allow_origins=["*"], # localhost:3000 for React dev
|
| 24 |
+
allow_methods=["*"],
|
| 25 |
+
allow_headers=["*"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class FoldAction(BaseModel):
|
| 30 |
+
from_point: list[float] # [x, y]
|
| 31 |
+
to_point: list[float] # [x, y]
|
| 32 |
+
assignment: str # 'M' or 'V'
|
| 33 |
+
instruction: str = ""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class EpisodeStep(BaseModel):
|
| 37 |
+
step: int
|
| 38 |
+
fold: Optional[FoldAction]
|
| 39 |
+
paper_state: dict # FOLD JSON of current crease graph
|
| 40 |
+
anchor_points: list[list[float]]
|
| 41 |
+
reward: dict
|
| 42 |
+
done: bool
|
| 43 |
+
info: dict
|
| 44 |
+
prompt: str # LLM prompt at this step
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class EpisodeResult(BaseModel):
|
| 48 |
+
target_name: str
|
| 49 |
+
target: dict # FOLD JSON of target
|
| 50 |
+
steps: list[EpisodeStep]
|
| 51 |
+
final_reward: dict
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@app.get("/")
|
| 55 |
+
def health_check():
|
| 56 |
+
"""Health check — returns status and available target names."""
|
| 57 |
+
from env.environment import OrigamiEnvironment
|
| 58 |
+
env = OrigamiEnvironment()
|
| 59 |
+
return {"status": "ok", "targets": env.available_targets()}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.get("/targets")
|
| 63 |
+
def get_targets():
|
| 64 |
+
"""Return list of available target names and their metadata."""
|
| 65 |
+
from env.environment import OrigamiEnvironment
|
| 66 |
+
env = OrigamiEnvironment()
|
| 67 |
+
targets = {}
|
| 68 |
+
for name in env.available_targets():
|
| 69 |
+
t = env._targets[name]
|
| 70 |
+
targets[name] = {
|
| 71 |
+
"name": name,
|
| 72 |
+
"level": t.get("level", 1),
|
| 73 |
+
"description": t.get("description", ""),
|
| 74 |
+
"n_creases": sum(1 for a in t["edges_assignment"] if a in ("M", "V")),
|
| 75 |
+
}
|
| 76 |
+
return targets
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@app.get("/episode/run")
|
| 80 |
+
def run_episode(target: str = "half_horizontal", completion: str = ""):
|
| 81 |
+
"""
|
| 82 |
+
Run a code-as-policy episode with a provided completion string.
|
| 83 |
+
|
| 84 |
+
If completion is empty, returns the prompt so the caller knows what to send.
|
| 85 |
+
Returns full episode result with all steps.
|
| 86 |
+
"""
|
| 87 |
+
from env.environment import OrigamiEnvironment
|
| 88 |
+
from env.prompts import parse_fold_list, code_as_policy_prompt
|
| 89 |
+
from env.rewards import compute_reward, target_crease_edges
|
| 90 |
+
|
| 91 |
+
env = OrigamiEnvironment(mode="step")
|
| 92 |
+
obs = env.reset(target_name=target)
|
| 93 |
+
|
| 94 |
+
if not completion:
|
| 95 |
+
return {"prompt": obs["prompt"], "steps": [], "target": env.target}
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
folds = parse_fold_list(completion)
|
| 99 |
+
except ValueError as e:
|
| 100 |
+
return {"error": str(e), "steps": []}
|
| 101 |
+
|
| 102 |
+
steps = []
|
| 103 |
+
for i, fold in enumerate(folds):
|
| 104 |
+
result = env.paper.add_crease(fold["from"], fold["to"], fold["assignment"])
|
| 105 |
+
reward = compute_reward(env.paper, result, env.target)
|
| 106 |
+
|
| 107 |
+
paper_state = {
|
| 108 |
+
"vertices": {str(k): list(v) for k, v in env.paper.graph.vertices.items()},
|
| 109 |
+
"edges": [
|
| 110 |
+
{
|
| 111 |
+
"id": k,
|
| 112 |
+
"v1": list(env.paper.graph.vertices[v[0]]),
|
| 113 |
+
"v2": list(env.paper.graph.vertices[v[1]]),
|
| 114 |
+
"assignment": v[2],
|
| 115 |
+
}
|
| 116 |
+
for k, v in env.paper.graph.edges.items()
|
| 117 |
+
],
|
| 118 |
+
"anchor_points": [list(p) for p in env.paper.anchor_points()],
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
# Build per-step prompt reflecting current state
|
| 122 |
+
from env.prompts import step_level_prompt
|
| 123 |
+
step_prompt = step_level_prompt(
|
| 124 |
+
target=env.target,
|
| 125 |
+
paper_state=env.paper,
|
| 126 |
+
step=i + 1,
|
| 127 |
+
max_steps=env.max_steps,
|
| 128 |
+
last_reward=reward,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
steps.append({
|
| 132 |
+
"step": i + 1,
|
| 133 |
+
"fold": {
|
| 134 |
+
"from_point": fold["from"],
|
| 135 |
+
"to_point": fold["to"],
|
| 136 |
+
"assignment": fold["assignment"],
|
| 137 |
+
"instruction": fold.get("instruction", ""),
|
| 138 |
+
},
|
| 139 |
+
"paper_state": paper_state,
|
| 140 |
+
"anchor_points": [list(p) for p in env.paper.anchor_points()],
|
| 141 |
+
"reward": reward,
|
| 142 |
+
"done": reward.get("completion", 0) > 0,
|
| 143 |
+
"info": env._info(),
|
| 144 |
+
"prompt": step_prompt,
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
if reward.get("completion", 0) > 0:
|
| 148 |
+
break
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
"target_name": target,
|
| 152 |
+
"target": env.target,
|
| 153 |
+
"steps": steps,
|
| 154 |
+
"final_reward": steps[-1]["reward"] if steps else {},
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.get("/episode/demo")
|
| 159 |
+
def demo_episode(target: str = "half_horizontal"):
|
| 160 |
+
"""Return a pre-solved demo episode for each target."""
|
| 161 |
+
DEMO_COMPLETIONS = {
|
| 162 |
+
"half_horizontal": '<folds>[{"instruction": "Valley fold along horizontal center line", "from": [0, 0.5], "to": [1, 0.5], "assignment": "V"}]</folds>',
|
| 163 |
+
"half_vertical": '<folds>[{"instruction": "Mountain fold along vertical center line", "from": [0.5, 0], "to": [0.5, 1], "assignment": "M"}]</folds>',
|
| 164 |
+
"diagonal_main": '<folds>[{"instruction": "Valley fold along main diagonal", "from": [0, 0], "to": [1, 1], "assignment": "V"}]</folds>',
|
| 165 |
+
"diagonal_anti": '<folds>[{"instruction": "Mountain fold along anti-diagonal", "from": [1, 0], "to": [0, 1], "assignment": "M"}]</folds>',
|
| 166 |
+
"thirds_h": '<folds>[{"instruction": "Valley fold at one-third height", "from": [0, 0.333], "to": [1, 0.333], "assignment": "V"}, {"instruction": "Valley fold at two-thirds height", "from": [0, 0.667], "to": [1, 0.667], "assignment": "V"}]</folds>',
|
| 167 |
+
"thirds_v": '<folds>[{"instruction": "Mountain fold at one-third width", "from": [0.333, 0], "to": [0.333, 1], "assignment": "M"}, {"instruction": "Mountain fold at two-thirds width", "from": [0.667, 0], "to": [0.667, 1], "assignment": "M"}]</folds>',
|
| 168 |
+
"accordion_3h": '<folds>[{"instruction": "Valley fold at quarter height", "from": [0, 0.25], "to": [1, 0.25], "assignment": "V"}, {"instruction": "Mountain fold at half height", "from": [0, 0.5], "to": [1, 0.5], "assignment": "M"}, {"instruction": "Valley fold at three-quarter height", "from": [0, 0.75], "to": [1, 0.75], "assignment": "V"}]</folds>',
|
| 169 |
+
"accordion_4h": '<folds>[{"instruction": "Valley fold at 0.2", "from": [0, 0.2], "to": [1, 0.2], "assignment": "V"}, {"instruction": "Mountain fold at 0.4", "from": [0, 0.4], "to": [1, 0.4], "assignment": "M"}, {"instruction": "Valley fold at 0.6", "from": [0, 0.6], "to": [1, 0.6], "assignment": "V"}, {"instruction": "Mountain fold at 0.8", "from": [0, 0.8], "to": [1, 0.8], "assignment": "M"}]</folds>',
|
| 170 |
+
}
|
| 171 |
+
completion = DEMO_COMPLETIONS.get(target, DEMO_COMPLETIONS["half_horizontal"])
|
| 172 |
+
return run_episode(target=target, completion=completion)
|
src/App.css
CHANGED
|
@@ -1,38 +1,507 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
}
|
| 4 |
|
| 5 |
-
.
|
| 6 |
-
|
| 7 |
-
pointer-events: none;
|
| 8 |
}
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
-
.
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
display: flex;
|
| 20 |
flex-direction: column;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
align-items: center;
|
| 22 |
justify-content: center;
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
-
.
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
@keyframes
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #0d0d14;
|
| 3 |
+
--surface: #13131d;
|
| 4 |
+
--surface-2: #1a1a2e;
|
| 5 |
+
--paper-white: #fafaf5;
|
| 6 |
+
--paper-edge: #2a2a3a;
|
| 7 |
+
--mountain: #f59e0b;
|
| 8 |
+
--valley: #38bdf8;
|
| 9 |
+
--target-ghost: rgba(124, 58, 237, 0.20);
|
| 10 |
+
--target-ghost-stroke: rgba(124, 58, 237, 0.45);
|
| 11 |
+
--validity: #22d3ee;
|
| 12 |
+
--progress: #22c55e;
|
| 13 |
+
--economy: #a78bfa;
|
| 14 |
+
--text-primary: #f8fafc;
|
| 15 |
+
--text-dim: #64748b;
|
| 16 |
+
--border: #2a2a3a;
|
| 17 |
+
--border-bright: #3a3a5a;
|
| 18 |
+
--font-display: 'JetBrains Mono', monospace;
|
| 19 |
+
--font-mono: 'IBM Plex Mono', monospace;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.app {
|
| 23 |
+
display: flex;
|
| 24 |
+
flex-direction: column;
|
| 25 |
+
height: 100vh;
|
| 26 |
+
background: var(--bg);
|
| 27 |
+
overflow: hidden;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* ─── HEADER ─── */
|
| 31 |
+
.app-header {
|
| 32 |
+
display: flex;
|
| 33 |
+
align-items: center;
|
| 34 |
+
gap: 24px;
|
| 35 |
+
padding: 0 20px;
|
| 36 |
+
height: 48px;
|
| 37 |
+
border-bottom: 1px solid var(--border);
|
| 38 |
+
background: var(--surface);
|
| 39 |
+
flex-shrink: 0;
|
| 40 |
+
z-index: 10;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.app-title {
|
| 44 |
+
font-family: var(--font-display);
|
| 45 |
+
font-size: 14px;
|
| 46 |
+
font-weight: 700;
|
| 47 |
+
letter-spacing: 0.12em;
|
| 48 |
+
color: var(--text-primary);
|
| 49 |
+
white-space: nowrap;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.app-title .title-accent {
|
| 53 |
+
color: var(--mountain);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.header-sep {
|
| 57 |
+
width: 1px;
|
| 58 |
+
height: 24px;
|
| 59 |
+
background: var(--border);
|
| 60 |
+
flex-shrink: 0;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.header-right {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
gap: 16px;
|
| 67 |
+
margin-left: auto;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.api-status {
|
| 71 |
+
font-size: 11px;
|
| 72 |
+
font-family: var(--font-display);
|
| 73 |
+
letter-spacing: 0.08em;
|
| 74 |
+
display: flex;
|
| 75 |
+
align-items: center;
|
| 76 |
+
gap: 6px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.api-status-dot {
|
| 80 |
+
width: 6px;
|
| 81 |
+
height: 6px;
|
| 82 |
+
border-radius: 50%;
|
| 83 |
+
background: var(--text-dim);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.api-status-dot.ok {
|
| 87 |
+
background: var(--progress);
|
| 88 |
+
box-shadow: 0 0 6px var(--progress);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.api-status-dot.err {
|
| 92 |
+
background: #ef4444;
|
| 93 |
+
box-shadow: 0 0 6px #ef4444;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* ─── MAIN LAYOUT ─── */
|
| 97 |
+
.app-body {
|
| 98 |
+
display: grid;
|
| 99 |
+
grid-template-columns: 1fr 280px;
|
| 100 |
+
flex: 1;
|
| 101 |
+
overflow: hidden;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.app-left {
|
| 105 |
+
display: flex;
|
| 106 |
+
flex-direction: column;
|
| 107 |
+
overflow: hidden;
|
| 108 |
+
border-right: 1px solid var(--border);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.app-right {
|
| 112 |
+
display: flex;
|
| 113 |
+
flex-direction: column;
|
| 114 |
+
overflow: hidden;
|
| 115 |
+
background: var(--surface);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* ─── CANVAS ROW ─── */
|
| 119 |
+
.canvas-row {
|
| 120 |
+
display: flex;
|
| 121 |
+
gap: 0;
|
| 122 |
+
padding: 16px;
|
| 123 |
+
flex-shrink: 0;
|
| 124 |
+
border-bottom: 1px solid var(--border);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.canvas-wrap {
|
| 128 |
+
display: flex;
|
| 129 |
+
flex-direction: column;
|
| 130 |
+
gap: 8px;
|
| 131 |
+
flex: 1;
|
| 132 |
}
|
| 133 |
|
| 134 |
+
.canvas-wrap + .canvas-wrap {
|
| 135 |
+
margin-left: 16px;
|
|
|
|
| 136 |
}
|
| 137 |
|
| 138 |
+
.canvas-label {
|
| 139 |
+
font-family: var(--font-display);
|
| 140 |
+
font-size: 10px;
|
| 141 |
+
font-weight: 500;
|
| 142 |
+
letter-spacing: 0.14em;
|
| 143 |
+
color: var(--text-dim);
|
| 144 |
+
text-transform: uppercase;
|
| 145 |
}
|
| 146 |
|
| 147 |
+
.canvas-svg {
|
| 148 |
+
display: block;
|
| 149 |
+
background: var(--paper-white);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* ─── STEP FEED ─── */
|
| 153 |
+
.step-feed-section {
|
| 154 |
+
flex: 1;
|
| 155 |
display: flex;
|
| 156 |
flex-direction: column;
|
| 157 |
+
overflow: hidden;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.section-header {
|
| 161 |
+
font-family: var(--font-display);
|
| 162 |
+
font-size: 10px;
|
| 163 |
+
font-weight: 500;
|
| 164 |
+
letter-spacing: 0.14em;
|
| 165 |
+
color: var(--text-dim);
|
| 166 |
+
text-transform: uppercase;
|
| 167 |
+
padding: 8px 16px;
|
| 168 |
+
border-bottom: 1px solid var(--border);
|
| 169 |
+
flex-shrink: 0;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.step-feed {
|
| 173 |
+
overflow-y: auto;
|
| 174 |
+
flex: 1;
|
| 175 |
+
padding: 4px 0;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.step-entry {
|
| 179 |
+
display: flex;
|
| 180 |
+
flex-direction: column;
|
| 181 |
+
gap: 2px;
|
| 182 |
+
padding: 8px 16px;
|
| 183 |
+
border-bottom: 1px solid var(--border);
|
| 184 |
+
cursor: default;
|
| 185 |
+
transition: background 0.1s;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.step-entry:hover {
|
| 189 |
+
background: var(--surface);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.step-entry.active {
|
| 193 |
+
background: var(--surface-2);
|
| 194 |
+
border-left: 2px solid var(--valley);
|
| 195 |
+
padding-left: 14px;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.step-entry-top {
|
| 199 |
+
display: flex;
|
| 200 |
+
align-items: center;
|
| 201 |
+
gap: 8px;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.step-num {
|
| 205 |
+
font-family: var(--font-display);
|
| 206 |
+
font-size: 10px;
|
| 207 |
+
font-weight: 700;
|
| 208 |
+
color: var(--text-dim);
|
| 209 |
+
width: 24px;
|
| 210 |
+
flex-shrink: 0;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.step-instruction {
|
| 214 |
+
font-size: 12px;
|
| 215 |
+
color: var(--text-primary);
|
| 216 |
+
flex: 1;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.assign-badge {
|
| 220 |
+
font-family: var(--font-display);
|
| 221 |
+
font-size: 10px;
|
| 222 |
+
font-weight: 700;
|
| 223 |
+
padding: 1px 5px;
|
| 224 |
+
line-height: 1.4;
|
| 225 |
+
flex-shrink: 0;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.assign-badge.M {
|
| 229 |
+
background: var(--mountain);
|
| 230 |
+
color: #0d0d14;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.assign-badge.V {
|
| 234 |
+
background: var(--valley);
|
| 235 |
+
color: #0d0d14;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.assign-badge.B {
|
| 239 |
+
background: var(--border-bright);
|
| 240 |
+
color: var(--text-dim);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.step-reward-delta {
|
| 244 |
+
font-size: 11px;
|
| 245 |
+
color: var(--text-dim);
|
| 246 |
+
padding-left: 32px;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.step-reward-delta .delta-positive {
|
| 250 |
+
color: var(--progress);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.step-reward-delta .delta-negative {
|
| 254 |
+
color: #ef4444;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/* ─── REWARD PANEL ─── */
|
| 258 |
+
.reward-panel {
|
| 259 |
+
padding: 12px 16px;
|
| 260 |
+
border-bottom: 1px solid var(--border);
|
| 261 |
+
flex-shrink: 0;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.reward-row {
|
| 265 |
+
display: flex;
|
| 266 |
+
align-items: center;
|
| 267 |
+
gap: 8px;
|
| 268 |
+
margin-bottom: 6px;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.reward-row:last-child {
|
| 272 |
+
margin-bottom: 0;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.reward-label {
|
| 276 |
+
font-family: var(--font-display);
|
| 277 |
+
font-size: 10px;
|
| 278 |
+
font-weight: 500;
|
| 279 |
+
letter-spacing: 0.06em;
|
| 280 |
+
color: var(--text-dim);
|
| 281 |
+
width: 72px;
|
| 282 |
+
flex-shrink: 0;
|
| 283 |
+
text-transform: uppercase;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.reward-track {
|
| 287 |
+
flex: 1;
|
| 288 |
+
height: 8px;
|
| 289 |
+
background: var(--bg);
|
| 290 |
+
border: 1px solid var(--border);
|
| 291 |
+
overflow: hidden;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.reward-bar {
|
| 295 |
+
height: 100%;
|
| 296 |
+
transition: width 0.4s ease;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.reward-value {
|
| 300 |
+
font-family: var(--font-display);
|
| 301 |
+
font-size: 11px;
|
| 302 |
+
font-weight: 500;
|
| 303 |
+
color: var(--text-primary);
|
| 304 |
+
width: 36px;
|
| 305 |
+
text-align: right;
|
| 306 |
+
flex-shrink: 0;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.reward-value.dim {
|
| 310 |
+
color: var(--text-dim);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.reward-divider {
|
| 314 |
+
height: 1px;
|
| 315 |
+
background: var(--border);
|
| 316 |
+
margin: 6px 0;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/* ─── INFO BADGES ─── */
|
| 320 |
+
.info-badges {
|
| 321 |
+
padding: 12px 16px;
|
| 322 |
+
display: flex;
|
| 323 |
+
flex-direction: column;
|
| 324 |
+
gap: 8px;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.info-row {
|
| 328 |
+
display: flex;
|
| 329 |
+
align-items: center;
|
| 330 |
+
justify-content: space-between;
|
| 331 |
+
gap: 8px;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.info-key {
|
| 335 |
+
font-family: var(--font-display);
|
| 336 |
+
font-size: 10px;
|
| 337 |
+
font-weight: 500;
|
| 338 |
+
letter-spacing: 0.06em;
|
| 339 |
+
color: var(--text-dim);
|
| 340 |
+
text-transform: uppercase;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.info-val {
|
| 344 |
+
font-family: var(--font-display);
|
| 345 |
+
font-size: 11px;
|
| 346 |
+
font-weight: 700;
|
| 347 |
+
color: var(--text-primary);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.info-val.bool-true {
|
| 351 |
+
color: var(--progress);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.info-val.bool-false {
|
| 355 |
+
color: #ef4444;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.info-val.dim {
|
| 359 |
+
color: var(--text-dim);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
/* ─── TARGET SELECTOR ─── */
|
| 363 |
+
.target-selector {
|
| 364 |
+
display: flex;
|
| 365 |
+
align-items: center;
|
| 366 |
+
gap: 8px;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.target-selector-label {
|
| 370 |
+
font-family: var(--font-display);
|
| 371 |
+
font-size: 10px;
|
| 372 |
+
font-weight: 500;
|
| 373 |
+
letter-spacing: 0.10em;
|
| 374 |
+
color: var(--text-dim);
|
| 375 |
+
text-transform: uppercase;
|
| 376 |
+
white-space: nowrap;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.target-select {
|
| 380 |
+
background: var(--surface-2);
|
| 381 |
+
border: 1px solid var(--border-bright);
|
| 382 |
+
color: var(--text-primary);
|
| 383 |
+
font-family: var(--font-display);
|
| 384 |
+
font-size: 11px;
|
| 385 |
+
padding: 4px 8px;
|
| 386 |
+
outline: none;
|
| 387 |
+
cursor: pointer;
|
| 388 |
+
min-width: 180px;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.target-select:focus {
|
| 392 |
+
border-color: var(--valley);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
optgroup {
|
| 396 |
+
background: var(--surface);
|
| 397 |
+
color: var(--text-dim);
|
| 398 |
+
font-family: var(--font-display);
|
| 399 |
+
font-size: 10px;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
option {
|
| 403 |
+
background: var(--surface-2);
|
| 404 |
+
color: var(--text-primary);
|
| 405 |
+
font-family: var(--font-display);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
/* ─── PLAYER CONTROLS ─── */
|
| 409 |
+
.player-controls {
|
| 410 |
+
display: flex;
|
| 411 |
+
align-items: center;
|
| 412 |
+
gap: 6px;
|
| 413 |
+
flex-shrink: 0;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.ctrl-btn {
|
| 417 |
+
background: var(--surface-2);
|
| 418 |
+
border: 1px solid var(--border-bright);
|
| 419 |
+
color: var(--text-primary);
|
| 420 |
+
font-family: var(--font-display);
|
| 421 |
+
font-size: 11px;
|
| 422 |
+
font-weight: 500;
|
| 423 |
+
padding: 4px 10px;
|
| 424 |
+
cursor: pointer;
|
| 425 |
+
white-space: nowrap;
|
| 426 |
+
line-height: 1.4;
|
| 427 |
+
letter-spacing: 0.04em;
|
| 428 |
+
transition: background 0.1s, border-color 0.1s;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.ctrl-btn:hover:not(:disabled) {
|
| 432 |
+
background: var(--surface);
|
| 433 |
+
border-color: var(--text-dim);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.ctrl-btn:disabled {
|
| 437 |
+
opacity: 0.35;
|
| 438 |
+
cursor: not-allowed;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.ctrl-btn.play {
|
| 442 |
+
border-color: var(--valley);
|
| 443 |
+
color: var(--valley);
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.ctrl-btn.play:hover:not(:disabled) {
|
| 447 |
+
background: rgba(56, 189, 248, 0.1);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.ctrl-step-display {
|
| 451 |
+
font-family: var(--font-display);
|
| 452 |
+
font-size: 11px;
|
| 453 |
+
color: var(--text-dim);
|
| 454 |
+
padding: 4px 8px;
|
| 455 |
+
border: 1px solid var(--border);
|
| 456 |
+
background: var(--bg);
|
| 457 |
+
white-space: nowrap;
|
| 458 |
+
min-width: 72px;
|
| 459 |
+
text-align: center;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/* ─── LOADING / ERROR ─── */
|
| 463 |
+
.app-overlay {
|
| 464 |
+
position: fixed;
|
| 465 |
+
inset: 0;
|
| 466 |
+
display: flex;
|
| 467 |
align-items: center;
|
| 468 |
justify-content: center;
|
| 469 |
+
background: var(--bg);
|
| 470 |
+
z-index: 100;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.overlay-message {
|
| 474 |
+
font-family: var(--font-display);
|
| 475 |
+
font-size: 13px;
|
| 476 |
+
letter-spacing: 0.1em;
|
| 477 |
+
color: var(--text-dim);
|
| 478 |
+
display: flex;
|
| 479 |
+
align-items: center;
|
| 480 |
+
gap: 12px;
|
| 481 |
}
|
| 482 |
|
| 483 |
+
.pulse-dot {
|
| 484 |
+
width: 8px;
|
| 485 |
+
height: 8px;
|
| 486 |
+
border-radius: 50%;
|
| 487 |
+
background: var(--valley);
|
| 488 |
+
animation: pulse 1.2s ease-in-out infinite;
|
| 489 |
}
|
| 490 |
|
| 491 |
+
@keyframes pulse {
|
| 492 |
+
0%, 100% { opacity: 0.2; transform: scale(0.8); }
|
| 493 |
+
50% { opacity: 1; transform: scale(1); }
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
/* ─── MISC ─── */
|
| 497 |
+
.episode-loading {
|
| 498 |
+
display: flex;
|
| 499 |
+
align-items: center;
|
| 500 |
+
justify-content: center;
|
| 501 |
+
gap: 8px;
|
| 502 |
+
padding: 12px 16px;
|
| 503 |
+
font-family: var(--font-display);
|
| 504 |
+
font-size: 11px;
|
| 505 |
+
color: var(--text-dim);
|
| 506 |
+
letter-spacing: 0.08em;
|
| 507 |
}
|
src/App.js
CHANGED
|
@@ -1,23 +1,188 @@
|
|
| 1 |
-
import
|
| 2 |
import './App.css';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
function App() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
return (
|
| 6 |
-
<div className="
|
| 7 |
-
<header className="
|
| 8 |
-
<
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
</
|
| 12 |
-
<
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
>
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
</header>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</div>
|
| 22 |
);
|
| 23 |
}
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
| 2 |
import './App.css';
|
| 3 |
+
import CreaseCanvas from './components/CreaseCanvas';
|
| 4 |
+
import RewardPanel from './components/RewardPanel';
|
| 5 |
+
import StepFeed from './components/StepFeed';
|
| 6 |
+
import InfoBadges from './components/InfoBadges';
|
| 7 |
+
import TargetSelector from './components/TargetSelector';
|
| 8 |
+
import PlayerControls from './components/PlayerControls';
|
| 9 |
+
|
| 10 |
+
const API_BASE = 'http://localhost:8000';
|
| 11 |
|
| 12 |
function App() {
|
| 13 |
+
const [targets, setTargets] = useState({});
|
| 14 |
+
const [selectedTarget, setSelectedTarget] = useState('half_horizontal');
|
| 15 |
+
const [episode, setEpisode] = useState(null);
|
| 16 |
+
const [currentStep, setCurrentStep] = useState(0);
|
| 17 |
+
const [playing, setPlaying] = useState(false);
|
| 18 |
+
const [apiStatus, setApiStatus] = useState('connecting'); // 'connecting' | 'ok' | 'err'
|
| 19 |
+
const [episodeLoading, setEpisodeLoading] = useState(false);
|
| 20 |
+
const intervalRef = useRef(null);
|
| 21 |
+
|
| 22 |
+
const fetchTargets = useCallback(async () => {
|
| 23 |
+
try {
|
| 24 |
+
const res = await fetch(`${API_BASE}/targets`);
|
| 25 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 26 |
+
const data = await res.json();
|
| 27 |
+
setTargets(data);
|
| 28 |
+
setApiStatus('ok');
|
| 29 |
+
} catch {
|
| 30 |
+
setApiStatus('err');
|
| 31 |
+
}
|
| 32 |
+
}, []);
|
| 33 |
+
|
| 34 |
+
const fetchDemoEpisode = useCallback(async (targetName) => {
|
| 35 |
+
setEpisodeLoading(true);
|
| 36 |
+
setPlaying(false);
|
| 37 |
+
setCurrentStep(0);
|
| 38 |
+
try {
|
| 39 |
+
const res = await fetch(`${API_BASE}/episode/demo?target=${targetName}`);
|
| 40 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 41 |
+
const data = await res.json();
|
| 42 |
+
setEpisode(data);
|
| 43 |
+
setApiStatus('ok');
|
| 44 |
+
} catch {
|
| 45 |
+
setEpisode(null);
|
| 46 |
+
setApiStatus('err');
|
| 47 |
+
} finally {
|
| 48 |
+
setEpisodeLoading(false);
|
| 49 |
+
}
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
fetchTargets();
|
| 54 |
+
}, [fetchTargets]);
|
| 55 |
+
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
fetchDemoEpisode(selectedTarget);
|
| 58 |
+
}, [selectedTarget, fetchDemoEpisode]);
|
| 59 |
+
|
| 60 |
+
const totalSteps = episode ? episode.steps.length : 0;
|
| 61 |
+
|
| 62 |
+
// currentStep is 1-indexed for display (0 = "empty paper before any folds")
|
| 63 |
+
// steps array is 0-indexed: steps[0] = result of fold 1
|
| 64 |
+
const activeStepData = episode && currentStep > 0 ? episode.steps[currentStep - 1] : null;
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (playing) {
|
| 68 |
+
intervalRef.current = setInterval(() => {
|
| 69 |
+
setCurrentStep(prev => {
|
| 70 |
+
if (prev >= totalSteps) {
|
| 71 |
+
setPlaying(false);
|
| 72 |
+
return prev;
|
| 73 |
+
}
|
| 74 |
+
return prev + 1;
|
| 75 |
+
});
|
| 76 |
+
}, 1500);
|
| 77 |
+
}
|
| 78 |
+
return () => clearInterval(intervalRef.current);
|
| 79 |
+
}, [playing, totalSteps]);
|
| 80 |
+
|
| 81 |
+
const handlePlay = () => {
|
| 82 |
+
if (currentStep >= totalSteps) setCurrentStep(0);
|
| 83 |
+
setPlaying(true);
|
| 84 |
+
};
|
| 85 |
+
const handlePause = () => setPlaying(false);
|
| 86 |
+
const handleNext = () => {
|
| 87 |
+
setPlaying(false);
|
| 88 |
+
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
|
| 89 |
+
};
|
| 90 |
+
const handlePrev = () => {
|
| 91 |
+
setPlaying(false);
|
| 92 |
+
setCurrentStep(prev => Math.max(prev - 1, 0));
|
| 93 |
+
};
|
| 94 |
+
const handleReset = () => {
|
| 95 |
+
setPlaying(false);
|
| 96 |
+
setCurrentStep(0);
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const targetDef = targets[selectedTarget] || null;
|
| 100 |
+
const targetFold = episode ? episode.target : null;
|
| 101 |
+
|
| 102 |
return (
|
| 103 |
+
<div className="app">
|
| 104 |
+
<header className="app-header">
|
| 105 |
+
<span className="app-title">
|
| 106 |
+
OPTI<span className="title-accent">GAMI</span> RL
|
| 107 |
+
</span>
|
| 108 |
+
<div className="header-sep" />
|
| 109 |
+
<TargetSelector
|
| 110 |
+
targets={targets}
|
| 111 |
+
selected={selectedTarget}
|
| 112 |
+
onChange={name => setSelectedTarget(name)}
|
| 113 |
+
/>
|
| 114 |
+
<div className="header-sep" />
|
| 115 |
+
<PlayerControls
|
| 116 |
+
playing={playing}
|
| 117 |
+
onPlay={handlePlay}
|
| 118 |
+
onPause={handlePause}
|
| 119 |
+
onNext={handleNext}
|
| 120 |
+
onPrev={handlePrev}
|
| 121 |
+
onReset={handleReset}
|
| 122 |
+
currentStep={currentStep}
|
| 123 |
+
totalSteps={totalSteps}
|
| 124 |
+
disabled={!episode || episodeLoading}
|
| 125 |
+
/>
|
| 126 |
+
<div className="header-right">
|
| 127 |
+
<div className="api-status">
|
| 128 |
+
<span className={`api-status-dot ${apiStatus === 'ok' ? 'ok' : apiStatus === 'err' ? 'err' : ''}`} />
|
| 129 |
+
<span>{apiStatus === 'ok' ? 'API OK' : apiStatus === 'err' ? 'API ERR' : 'CONNECTING'}</span>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
</header>
|
| 133 |
+
|
| 134 |
+
<div className="app-body">
|
| 135 |
+
<div className="app-left">
|
| 136 |
+
<div className="canvas-row">
|
| 137 |
+
<div className="canvas-wrap">
|
| 138 |
+
<span className="canvas-label">
|
| 139 |
+
TARGET — {targetDef ? targetDef.name.replace(/_/g, ' ').toUpperCase() : '—'}
|
| 140 |
+
</span>
|
| 141 |
+
<CreaseCanvas
|
| 142 |
+
paperState={null}
|
| 143 |
+
target={targetFold}
|
| 144 |
+
label="TARGET"
|
| 145 |
+
dim={280}
|
| 146 |
+
ghostOnly={true}
|
| 147 |
+
/>
|
| 148 |
+
</div>
|
| 149 |
+
<div className="canvas-wrap">
|
| 150 |
+
<span className="canvas-label">
|
| 151 |
+
{currentStep === 0 ? 'INITIAL STATE' : `STEP ${currentStep} / ${totalSteps}`}
|
| 152 |
+
</span>
|
| 153 |
+
<CreaseCanvas
|
| 154 |
+
paperState={activeStepData ? activeStepData.paper_state : null}
|
| 155 |
+
target={targetFold}
|
| 156 |
+
label={currentStep === 0 ? 'INITIAL' : `STEP ${currentStep}`}
|
| 157 |
+
dim={280}
|
| 158 |
+
ghostOnly={false}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="step-feed-section">
|
| 164 |
+
<div className="section-header">FOLD SEQUENCE</div>
|
| 165 |
+
{episodeLoading ? (
|
| 166 |
+
<div className="episode-loading">
|
| 167 |
+
<div className="pulse-dot" />
|
| 168 |
+
FETCHING EPISODE...
|
| 169 |
+
</div>
|
| 170 |
+
) : (
|
| 171 |
+
<StepFeed
|
| 172 |
+
steps={episode ? episode.steps : []}
|
| 173 |
+
currentStep={currentStep}
|
| 174 |
+
/>
|
| 175 |
+
)}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div className="app-right">
|
| 180 |
+
<div className="section-header">REWARD DECOMPOSITION</div>
|
| 181 |
+
<RewardPanel reward={activeStepData ? activeStepData.reward : null} />
|
| 182 |
+
<div className="section-header">EPISODE INFO</div>
|
| 183 |
+
<InfoBadges info={activeStepData ? activeStepData.info : null} targetDef={targetDef} />
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
</div>
|
| 187 |
);
|
| 188 |
}
|
src/App.test.js
CHANGED
|
@@ -1,8 +1 @@
|
|
| 1 |
-
|
| 2 |
-
import App from './App';
|
| 3 |
-
|
| 4 |
-
test('renders learn react link', () => {
|
| 5 |
-
render(<App />);
|
| 6 |
-
const linkElement = screen.getByText(/learn react/i);
|
| 7 |
-
expect(linkElement).toBeInTheDocument();
|
| 8 |
-
});
|
|
|
|
| 1 |
+
// Tests removed — observability dashboard
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/CreaseCanvas.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const MOUNTAIN = '#f59e0b';
|
| 2 |
+
const VALLEY = '#38bdf8';
|
| 3 |
+
|
| 4 |
+
function toSvg(x, y, dim) {
|
| 5 |
+
return [x * dim, (1 - y) * dim];
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
function GhostEdges({ target, dim }) {
|
| 9 |
+
if (!target) return null;
|
| 10 |
+
const { vertices_coords, edges_vertices, edges_assignment } = target;
|
| 11 |
+
if (!vertices_coords || !edges_vertices || !edges_assignment) return null;
|
| 12 |
+
|
| 13 |
+
return edges_vertices.map((ev, i) => {
|
| 14 |
+
const asgn = edges_assignment[i];
|
| 15 |
+
if (asgn === 'B') return null;
|
| 16 |
+
const [v1x, v1y] = vertices_coords[ev[0]];
|
| 17 |
+
const [v2x, v2y] = vertices_coords[ev[1]];
|
| 18 |
+
const [x1, y1] = toSvg(v1x, v1y, dim);
|
| 19 |
+
const [x2, y2] = toSvg(v2x, v2y, dim);
|
| 20 |
+
const color = asgn === 'M' ? MOUNTAIN : VALLEY;
|
| 21 |
+
return (
|
| 22 |
+
<line
|
| 23 |
+
key={i}
|
| 24 |
+
x1={x1} y1={y1} x2={x2} y2={y2}
|
| 25 |
+
stroke={color}
|
| 26 |
+
strokeOpacity={0.25}
|
| 27 |
+
strokeWidth={1.5}
|
| 28 |
+
strokeDasharray="5 4"
|
| 29 |
+
/>
|
| 30 |
+
);
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function CurrentEdges({ paperState, dim }) {
|
| 35 |
+
if (!paperState || !paperState.edges) return null;
|
| 36 |
+
return paperState.edges.map((edge) => {
|
| 37 |
+
if (edge.assignment === 'B') return null;
|
| 38 |
+
const [x1, y1] = toSvg(edge.v1[0], edge.v1[1], dim);
|
| 39 |
+
const [x2, y2] = toSvg(edge.v2[0], edge.v2[1], dim);
|
| 40 |
+
const color = edge.assignment === 'M' ? MOUNTAIN : VALLEY;
|
| 41 |
+
return (
|
| 42 |
+
<line
|
| 43 |
+
key={edge.id}
|
| 44 |
+
x1={x1} y1={y1} x2={x2} y2={y2}
|
| 45 |
+
stroke={color}
|
| 46 |
+
strokeWidth={2.5}
|
| 47 |
+
strokeLinecap="square"
|
| 48 |
+
/>
|
| 49 |
+
);
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function AnchorCrosses({ paperState, dim }) {
|
| 54 |
+
if (!paperState || !paperState.anchor_points) return null;
|
| 55 |
+
const size = 4;
|
| 56 |
+
return paperState.anchor_points.map((pt, i) => {
|
| 57 |
+
const [cx, cy] = toSvg(pt[0], pt[1], dim);
|
| 58 |
+
return (
|
| 59 |
+
<g key={i}>
|
| 60 |
+
<line
|
| 61 |
+
x1={cx - size} y1={cy} x2={cx + size} y2={cy}
|
| 62 |
+
stroke="#64748b" strokeWidth={1}
|
| 63 |
+
/>
|
| 64 |
+
<line
|
| 65 |
+
x1={cx} y1={cy - size} x2={cx} y2={cy + size}
|
| 66 |
+
stroke="#64748b" strokeWidth={1}
|
| 67 |
+
/>
|
| 68 |
+
</g>
|
| 69 |
+
);
|
| 70 |
+
});
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export default function CreaseCanvas({ paperState, target, dim = 280, ghostOnly = false }) {
|
| 74 |
+
const pad = 1;
|
| 75 |
+
const size = dim;
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<svg
|
| 79 |
+
className="canvas-svg"
|
| 80 |
+
width={size}
|
| 81 |
+
height={size}
|
| 82 |
+
viewBox={`0 0 ${size} ${size}`}
|
| 83 |
+
style={{ flexShrink: 0 }}
|
| 84 |
+
>
|
| 85 |
+
{/* Paper background */}
|
| 86 |
+
<rect
|
| 87 |
+
x={pad} y={pad}
|
| 88 |
+
width={size - pad * 2} height={size - pad * 2}
|
| 89 |
+
fill="#fafaf5"
|
| 90 |
+
/>
|
| 91 |
+
|
| 92 |
+
{/* Ghost target overlay */}
|
| 93 |
+
<GhostEdges target={target} dim={size} />
|
| 94 |
+
|
| 95 |
+
{/* Current paper state */}
|
| 96 |
+
{!ghostOnly && (
|
| 97 |
+
<>
|
| 98 |
+
<CurrentEdges paperState={paperState} dim={size} />
|
| 99 |
+
<AnchorCrosses paperState={paperState} dim={size} />
|
| 100 |
+
</>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
+
{/* Paper border */}
|
| 104 |
+
<rect
|
| 105 |
+
x={pad} y={pad}
|
| 106 |
+
width={size - pad * 2} height={size - pad * 2}
|
| 107 |
+
fill="none"
|
| 108 |
+
stroke="#2a2a3a"
|
| 109 |
+
strokeWidth={1}
|
| 110 |
+
/>
|
| 111 |
+
</svg>
|
| 112 |
+
);
|
| 113 |
+
}
|
src/components/InfoBadges.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function BoolVal({ value }) {
|
| 2 |
+
if (value === null || value === undefined) {
|
| 3 |
+
return <span className="info-val dim">—</span>;
|
| 4 |
+
}
|
| 5 |
+
return (
|
| 6 |
+
<span className={`info-val ${value ? 'bool-true' : 'bool-false'}`}>
|
| 7 |
+
{value ? 'TRUE' : 'FALSE'}
|
| 8 |
+
</span>
|
| 9 |
+
);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function TextVal({ value, dim = false }) {
|
| 13 |
+
if (value === null || value === undefined) {
|
| 14 |
+
return <span className="info-val dim">—</span>;
|
| 15 |
+
}
|
| 16 |
+
return (
|
| 17 |
+
<span className={`info-val${dim ? ' dim' : ''}`}>
|
| 18 |
+
{String(value).toUpperCase()}
|
| 19 |
+
</span>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function NumVal({ value }) {
|
| 24 |
+
if (value === null || value === undefined) {
|
| 25 |
+
return <span className="info-val dim">—</span>;
|
| 26 |
+
}
|
| 27 |
+
return <span className="info-val">{value}</span>;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export default function InfoBadges({ info, targetDef }) {
|
| 31 |
+
return (
|
| 32 |
+
<div className="info-badges">
|
| 33 |
+
<div className="info-row">
|
| 34 |
+
<span className="info-key">n_creases</span>
|
| 35 |
+
<NumVal value={info ? info.n_creases : (targetDef ? targetDef.n_creases : null)} />
|
| 36 |
+
</div>
|
| 37 |
+
<div className="info-row">
|
| 38 |
+
<span className="info-key">interior_verts</span>
|
| 39 |
+
<NumVal value={info ? info.n_interior_vertices : null} />
|
| 40 |
+
</div>
|
| 41 |
+
<div className="info-row">
|
| 42 |
+
<span className="info-key">local_fold</span>
|
| 43 |
+
<BoolVal value={info ? info.local_foldability : null} />
|
| 44 |
+
</div>
|
| 45 |
+
<div className="info-row">
|
| 46 |
+
<span className="info-key">blb_sat</span>
|
| 47 |
+
<BoolVal value={info ? info.blb_satisfied : null} />
|
| 48 |
+
</div>
|
| 49 |
+
<div className="info-row">
|
| 50 |
+
<span className="info-key">global_fold</span>
|
| 51 |
+
<TextVal
|
| 52 |
+
value={info ? info.global_foldability : null}
|
| 53 |
+
dim={true}
|
| 54 |
+
/>
|
| 55 |
+
</div>
|
| 56 |
+
{targetDef && (
|
| 57 |
+
<>
|
| 58 |
+
<div className="info-row">
|
| 59 |
+
<span className="info-key">level</span>
|
| 60 |
+
<span className="info-val">LVL {targetDef.level}</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div className="info-row">
|
| 63 |
+
<span className="info-key">target</span>
|
| 64 |
+
<span className="info-val" style={{ fontSize: '10px', textAlign: 'right', maxWidth: '140px', wordBreak: 'break-word' }}>
|
| 65 |
+
{targetDef.name.replace(/_/g, ' ').toUpperCase()}
|
| 66 |
+
</span>
|
| 67 |
+
</div>
|
| 68 |
+
</>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
src/components/PlayerControls.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function PlayerControls({
|
| 2 |
+
playing,
|
| 3 |
+
onPlay,
|
| 4 |
+
onPause,
|
| 5 |
+
onNext,
|
| 6 |
+
onPrev,
|
| 7 |
+
onReset,
|
| 8 |
+
currentStep,
|
| 9 |
+
totalSteps,
|
| 10 |
+
disabled,
|
| 11 |
+
}) {
|
| 12 |
+
const atStart = currentStep === 0;
|
| 13 |
+
const atEnd = currentStep >= totalSteps;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="player-controls">
|
| 17 |
+
<button
|
| 18 |
+
className="ctrl-btn"
|
| 19 |
+
onClick={onReset}
|
| 20 |
+
disabled={disabled || atStart}
|
| 21 |
+
title="Reset to start"
|
| 22 |
+
>
|
| 23 |
+
⏮ RST
|
| 24 |
+
</button>
|
| 25 |
+
<button
|
| 26 |
+
className="ctrl-btn"
|
| 27 |
+
onClick={onPrev}
|
| 28 |
+
disabled={disabled || atStart}
|
| 29 |
+
title="Previous step"
|
| 30 |
+
>
|
| 31 |
+
◀ PREV
|
| 32 |
+
</button>
|
| 33 |
+
<span className="ctrl-step-display">
|
| 34 |
+
{disabled ? '—/—' : `${currentStep} / ${totalSteps}`}
|
| 35 |
+
</span>
|
| 36 |
+
<button
|
| 37 |
+
className="ctrl-btn"
|
| 38 |
+
onClick={onNext}
|
| 39 |
+
disabled={disabled || atEnd}
|
| 40 |
+
title="Next step"
|
| 41 |
+
>
|
| 42 |
+
NEXT ▶
|
| 43 |
+
</button>
|
| 44 |
+
<button
|
| 45 |
+
className={`ctrl-btn play`}
|
| 46 |
+
onClick={playing ? onPause : onPlay}
|
| 47 |
+
disabled={disabled || (!playing && atEnd)}
|
| 48 |
+
title={playing ? 'Pause' : 'Play'}
|
| 49 |
+
>
|
| 50 |
+
{playing ? '⏸ PAUSE' : '▶▶ PLAY'}
|
| 51 |
+
</button>
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
}
|
src/components/RewardPanel.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const REWARD_FIELDS = [
|
| 2 |
+
{ key: 'kawasaki', label: 'kawasaki', color: 'var(--validity)' },
|
| 3 |
+
{ key: 'maekawa', label: 'maekawa', color: 'var(--validity)' },
|
| 4 |
+
{ key: 'blb', label: 'blb', color: 'var(--validity)' },
|
| 5 |
+
{ key: 'progress', label: 'progress', color: 'var(--progress)' },
|
| 6 |
+
{ key: 'economy', label: 'economy', color: 'var(--economy)' },
|
| 7 |
+
];
|
| 8 |
+
|
| 9 |
+
const TOTAL_FIELD = { key: 'total', label: 'total', color: 'var(--text-primary)' };
|
| 10 |
+
|
| 11 |
+
function RewardRow({ label, color, value }) {
|
| 12 |
+
const isDash = value === null || value === undefined;
|
| 13 |
+
const pct = isDash ? 0 : Math.min(Math.max(value, 0), 1) * 100;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="reward-row">
|
| 17 |
+
<span className="reward-label">{label}</span>
|
| 18 |
+
<div className="reward-track">
|
| 19 |
+
<div
|
| 20 |
+
className="reward-bar"
|
| 21 |
+
style={{ width: `${pct}%`, background: color }}
|
| 22 |
+
/>
|
| 23 |
+
</div>
|
| 24 |
+
<span className={`reward-value${isDash ? ' dim' : ''}`}>
|
| 25 |
+
{isDash ? '—' : value.toFixed(2)}
|
| 26 |
+
</span>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function RewardPanel({ reward }) {
|
| 32 |
+
return (
|
| 33 |
+
<div className="reward-panel">
|
| 34 |
+
{REWARD_FIELDS.map(({ key, label, color }) => (
|
| 35 |
+
<RewardRow
|
| 36 |
+
key={key}
|
| 37 |
+
label={label}
|
| 38 |
+
color={color}
|
| 39 |
+
value={reward ? reward[key] : null}
|
| 40 |
+
/>
|
| 41 |
+
))}
|
| 42 |
+
<div className="reward-divider" />
|
| 43 |
+
<RewardRow
|
| 44 |
+
label={TOTAL_FIELD.label}
|
| 45 |
+
color={TOTAL_FIELD.color}
|
| 46 |
+
value={reward ? reward[TOTAL_FIELD.key] : null}
|
| 47 |
+
/>
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
src/components/StepFeed.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
|
| 3 |
+
function rewardDelta(step, prevStep) {
|
| 4 |
+
if (!step || !step.reward) return null;
|
| 5 |
+
const curr = step.reward.total;
|
| 6 |
+
if (prevStep && prevStep.reward) {
|
| 7 |
+
return curr - prevStep.reward.total;
|
| 8 |
+
}
|
| 9 |
+
return curr;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function StepFeed({ steps, currentStep }) {
|
| 13 |
+
const feedRef = useRef(null);
|
| 14 |
+
const activeRef = useRef(null);
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
if (activeRef.current) {
|
| 18 |
+
activeRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
| 19 |
+
}
|
| 20 |
+
}, [currentStep]);
|
| 21 |
+
|
| 22 |
+
if (!steps || steps.length === 0) {
|
| 23 |
+
return (
|
| 24 |
+
<div className="step-feed">
|
| 25 |
+
<div style={{ padding: '16px', color: 'var(--text-dim)', fontFamily: 'var(--font-display)', fontSize: '11px' }}>
|
| 26 |
+
NO STEPS LOADED
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="step-feed" ref={feedRef}>
|
| 34 |
+
{steps.map((step, idx) => {
|
| 35 |
+
const stepNum = idx + 1;
|
| 36 |
+
const isActive = currentStep === stepNum;
|
| 37 |
+
const delta = rewardDelta(step, idx > 0 ? steps[idx - 1] : null);
|
| 38 |
+
const asgn = step.fold ? step.fold.assignment : null;
|
| 39 |
+
const instruction = step.fold ? step.fold.instruction : (step.prompt || '');
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div
|
| 43 |
+
key={stepNum}
|
| 44 |
+
className={`step-entry${isActive ? ' active' : ''}`}
|
| 45 |
+
ref={isActive ? activeRef : null}
|
| 46 |
+
>
|
| 47 |
+
<div className="step-entry-top">
|
| 48 |
+
<span className="step-num">#{stepNum}</span>
|
| 49 |
+
<span className="step-instruction">{instruction}</span>
|
| 50 |
+
{asgn && (
|
| 51 |
+
<span className={`assign-badge ${asgn}`}>{asgn}</span>
|
| 52 |
+
)}
|
| 53 |
+
</div>
|
| 54 |
+
{delta !== null && (
|
| 55 |
+
<div className="step-reward-delta">
|
| 56 |
+
{'\u0394'} total:{' '}
|
| 57 |
+
<span className={delta >= 0 ? 'delta-positive' : 'delta-negative'}>
|
| 58 |
+
{delta >= 0 ? '+' : ''}{delta.toFixed(3)}
|
| 59 |
+
</span>
|
| 60 |
+
{step.reward && (
|
| 61 |
+
<span style={{ color: 'var(--text-dim)' }}>
|
| 62 |
+
{' '}| progress: {step.reward.progress.toFixed(2)}
|
| 63 |
+
{' '}| economy: {step.reward.economy.toFixed(2)}
|
| 64 |
+
</span>
|
| 65 |
+
)}
|
| 66 |
+
</div>
|
| 67 |
+
)}
|
| 68 |
+
</div>
|
| 69 |
+
);
|
| 70 |
+
})}
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
src/components/TargetSelector.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function groupByLevel(targets) {
|
| 2 |
+
const levels = {};
|
| 3 |
+
Object.values(targets).forEach(t => {
|
| 4 |
+
if (!levels[t.level]) levels[t.level] = [];
|
| 5 |
+
levels[t.level].push(t);
|
| 6 |
+
});
|
| 7 |
+
return levels;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function TargetSelector({ targets, selected, onChange }) {
|
| 11 |
+
const levels = groupByLevel(targets);
|
| 12 |
+
const sortedLevels = Object.keys(levels).sort((a, b) => Number(a) - Number(b));
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="target-selector">
|
| 16 |
+
<span className="target-selector-label">TARGET</span>
|
| 17 |
+
<select
|
| 18 |
+
className="target-select"
|
| 19 |
+
value={selected}
|
| 20 |
+
onChange={e => onChange(e.target.value)}
|
| 21 |
+
>
|
| 22 |
+
{sortedLevels.length === 0 ? (
|
| 23 |
+
<option value="">LOADING...</option>
|
| 24 |
+
) : (
|
| 25 |
+
sortedLevels.map(level => (
|
| 26 |
+
<optgroup key={level} label={`── LEVEL ${level}`}>
|
| 27 |
+
{levels[level].map(t => (
|
| 28 |
+
<option key={t.name} value={t.name}>
|
| 29 |
+
{t.name.replace(/_/g, ' ').toUpperCase()}
|
| 30 |
+
</option>
|
| 31 |
+
))}
|
| 32 |
+
</optgroup>
|
| 33 |
+
))
|
| 34 |
+
)}
|
| 35 |
+
</select>
|
| 36 |
+
</div>
|
| 37 |
+
);
|
| 38 |
+
}
|
src/index.css
CHANGED
|
@@ -1,13 +1,34 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
| 2 |
margin: 0;
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
-webkit-font-smoothing: antialiased;
|
| 7 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
monospace;
|
| 13 |
}
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=IBM+Plex+Mono:wght@300;400;500&display=swap');
|
| 2 |
+
|
| 3 |
+
*, *::before, *::after {
|
| 4 |
+
box-sizing: border-box;
|
| 5 |
margin: 0;
|
| 6 |
+
padding: 0;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
body {
|
| 10 |
+
background: #0d0d14;
|
| 11 |
+
color: #f8fafc;
|
| 12 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 13 |
+
font-size: 13px;
|
| 14 |
+
line-height: 1.5;
|
| 15 |
-webkit-font-smoothing: antialiased;
|
| 16 |
+
overflow-x: hidden;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
::-webkit-scrollbar {
|
| 20 |
+
width: 4px;
|
| 21 |
+
height: 4px;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
::-webkit-scrollbar-track {
|
| 25 |
+
background: #0d0d14;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
::-webkit-scrollbar-thumb {
|
| 29 |
+
background: #2a2a3a;
|
| 30 |
}
|
| 31 |
|
| 32 |
+
::-webkit-scrollbar-thumb:hover {
|
| 33 |
+
background: #3a3a5a;
|
|
|
|
| 34 |
}
|
src/reportWebVitals.js
CHANGED
|
@@ -1,13 +1 @@
|
|
| 1 |
-
|
| 2 |
-
if (onPerfEntry && onPerfEntry instanceof Function) {
|
| 3 |
-
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
| 4 |
-
getCLS(onPerfEntry);
|
| 5 |
-
getFID(onPerfEntry);
|
| 6 |
-
getFCP(onPerfEntry);
|
| 7 |
-
getLCP(onPerfEntry);
|
| 8 |
-
getTTFB(onPerfEntry);
|
| 9 |
-
});
|
| 10 |
-
}
|
| 11 |
-
};
|
| 12 |
-
|
| 13 |
-
export default reportWebVitals;
|
|
|
|
| 1 |
+
export default function reportWebVitals() {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
viz/__init__.py
ADDED
|
File without changes
|
viz/renderer.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Matplotlib-based crease pattern renderer.
|
| 3 |
+
Used for quick observability during training and debugging.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import numpy as np
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import matplotlib.patches as patches
|
| 9 |
+
from matplotlib.animation import FuncAnimation
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Design system colors
|
| 14 |
+
_COLOR_MOUNTAIN = "#f59e0b"
|
| 15 |
+
_COLOR_VALLEY = "#38bdf8"
|
| 16 |
+
_COLOR_PAPER = "#fafaf5"
|
| 17 |
+
_COLOR_PAPER_EDGE = "#e2e8f0"
|
| 18 |
+
_COLOR_AX_BG = "#1a1a2e"
|
| 19 |
+
_COLOR_ANCHOR = "#4a4a6a"
|
| 20 |
+
_COLOR_REWARD_BG = "#13131d"
|
| 21 |
+
_COLOR_GRID = "#2a2a3a"
|
| 22 |
+
_COLOR_VALIDITY = "#22d3ee"
|
| 23 |
+
_COLOR_PROGRESS = "#22c55e"
|
| 24 |
+
_COLOR_ECONOMY = "#a78bfa"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def draw_paper_state(ax, paper_state, target=None, step=None, reward=None):
|
| 28 |
+
"""
|
| 29 |
+
Draw the current crease pattern on a matplotlib axes object.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
ax: matplotlib axes
|
| 33 |
+
paper_state: PaperState instance
|
| 34 |
+
target: optional FOLD dict for target crease ghost overlay
|
| 35 |
+
step: step number for title (None = "Initial")
|
| 36 |
+
reward: unused, kept for signature compatibility
|
| 37 |
+
"""
|
| 38 |
+
ax.set_facecolor(_COLOR_AX_BG)
|
| 39 |
+
|
| 40 |
+
# Unit square paper
|
| 41 |
+
square = patches.Rectangle(
|
| 42 |
+
(0, 0), 1, 1,
|
| 43 |
+
facecolor=_COLOR_PAPER,
|
| 44 |
+
edgecolor=_COLOR_PAPER_EDGE,
|
| 45 |
+
linewidth=1.5,
|
| 46 |
+
zorder=1,
|
| 47 |
+
)
|
| 48 |
+
ax.add_patch(square)
|
| 49 |
+
|
| 50 |
+
# Target ghost overlay
|
| 51 |
+
if target is not None:
|
| 52 |
+
verts = target["vertices_coords"]
|
| 53 |
+
edges_v = target["edges_vertices"]
|
| 54 |
+
edges_a = target["edges_assignment"]
|
| 55 |
+
for (v1, v2), assignment in zip(edges_v, edges_a):
|
| 56 |
+
if assignment not in ("M", "V"):
|
| 57 |
+
continue
|
| 58 |
+
x1, y1 = verts[v1]
|
| 59 |
+
x2, y2 = verts[v2]
|
| 60 |
+
color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
|
| 61 |
+
ax.plot(
|
| 62 |
+
[x1, x2], [y1, y2],
|
| 63 |
+
color=color,
|
| 64 |
+
alpha=0.2,
|
| 65 |
+
linewidth=1,
|
| 66 |
+
linestyle="--",
|
| 67 |
+
zorder=2,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Current crease edges
|
| 71 |
+
for edge in paper_state.crease_edges():
|
| 72 |
+
x1, y1 = edge["v1"]
|
| 73 |
+
x2, y2 = edge["v2"]
|
| 74 |
+
assignment = edge["assignment"]
|
| 75 |
+
color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
|
| 76 |
+
ax.plot(
|
| 77 |
+
[x1, x2], [y1, y2],
|
| 78 |
+
color=color,
|
| 79 |
+
linewidth=2.5,
|
| 80 |
+
linestyle="-",
|
| 81 |
+
solid_capstyle="round",
|
| 82 |
+
zorder=3,
|
| 83 |
+
)
|
| 84 |
+
# Endpoint dots
|
| 85 |
+
ax.plot(
|
| 86 |
+
[x1, x2], [y1, y2],
|
| 87 |
+
color=color,
|
| 88 |
+
marker="o",
|
| 89 |
+
markersize=5,
|
| 90 |
+
linestyle="none",
|
| 91 |
+
zorder=4,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Anchor points as gray crosses
|
| 95 |
+
for x, y in paper_state.anchor_points():
|
| 96 |
+
ax.plot(
|
| 97 |
+
x, y,
|
| 98 |
+
color=_COLOR_ANCHOR,
|
| 99 |
+
marker="+",
|
| 100 |
+
markersize=3,
|
| 101 |
+
linestyle="none",
|
| 102 |
+
zorder=5,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Title
|
| 106 |
+
title = f"Step {step}" if step is not None else "Initial"
|
| 107 |
+
ax.set_title(title, color="white", fontfamily="monospace", fontsize=10, pad=6)
|
| 108 |
+
|
| 109 |
+
# Remove ticks and spines
|
| 110 |
+
ax.set_xticks([])
|
| 111 |
+
ax.set_yticks([])
|
| 112 |
+
for spine in ax.spines.values():
|
| 113 |
+
spine.set_visible(False)
|
| 114 |
+
|
| 115 |
+
ax.set_xlim(-0.05, 1.05)
|
| 116 |
+
ax.set_ylim(-0.05, 1.05)
|
| 117 |
+
ax.set_aspect("equal")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def draw_reward_bars(ax, reward: dict):
|
| 121 |
+
"""
|
| 122 |
+
Draw a horizontal bar chart of reward components.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
ax: matplotlib axes
|
| 126 |
+
reward: dict with keys kawasaki, maekawa, blb, progress, economy (all 0-1)
|
| 127 |
+
"""
|
| 128 |
+
components = ["kawasaki", "maekawa", "blb", "progress", "economy"]
|
| 129 |
+
colors = {
|
| 130 |
+
"kawasaki": _COLOR_VALIDITY,
|
| 131 |
+
"maekawa": _COLOR_VALIDITY,
|
| 132 |
+
"blb": _COLOR_VALIDITY,
|
| 133 |
+
"progress": _COLOR_PROGRESS,
|
| 134 |
+
"economy": _COLOR_ECONOMY,
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
values = [float(reward.get(c, 0.0)) for c in components]
|
| 138 |
+
|
| 139 |
+
ax.set_facecolor(_COLOR_REWARD_BG)
|
| 140 |
+
|
| 141 |
+
bar_colors = [colors[c] for c in components]
|
| 142 |
+
bars = ax.barh(
|
| 143 |
+
components,
|
| 144 |
+
values,
|
| 145 |
+
height=0.6,
|
| 146 |
+
color=bar_colors,
|
| 147 |
+
zorder=2,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Value labels at end of each bar
|
| 151 |
+
for bar, val in zip(bars, values):
|
| 152 |
+
ax.text(
|
| 153 |
+
min(val + 0.02, 0.98),
|
| 154 |
+
bar.get_y() + bar.get_height() / 2,
|
| 155 |
+
f"{val:.2f}",
|
| 156 |
+
va="center",
|
| 157 |
+
ha="left",
|
| 158 |
+
color="white",
|
| 159 |
+
fontfamily="monospace",
|
| 160 |
+
fontsize=8,
|
| 161 |
+
zorder=3,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# Y-axis label style
|
| 165 |
+
ax.tick_params(axis="y", colors="white", labelsize=8)
|
| 166 |
+
for label in ax.get_yticklabels():
|
| 167 |
+
label.set_fontfamily("monospace")
|
| 168 |
+
|
| 169 |
+
# Subtle x gridlines
|
| 170 |
+
for x_pos in [0.25, 0.5, 0.75, 1.0]:
|
| 171 |
+
ax.axvline(x_pos, color=_COLOR_GRID, linewidth=0.8, zorder=1)
|
| 172 |
+
|
| 173 |
+
ax.set_xlim(0, 1.0)
|
| 174 |
+
ax.set_xticks([])
|
| 175 |
+
ax.tick_params(axis="x", colors="white")
|
| 176 |
+
for spine in ax.spines.values():
|
| 177 |
+
spine.set_visible(False)
|
| 178 |
+
|
| 179 |
+
ax.set_title("Reward Breakdown", color="white", fontfamily="monospace", fontsize=10, pad=6)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def render_episode(fold_history, target, rewards_history, save_path=None):
|
| 183 |
+
"""
|
| 184 |
+
Create a multi-panel figure showing an entire episode.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
fold_history: list of PaperState snapshots (one per step)
|
| 188 |
+
target: FOLD dict of target crease pattern
|
| 189 |
+
rewards_history: list of reward dicts (one per step)
|
| 190 |
+
save_path: if provided, save PNG here; otherwise plt.show()
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
matplotlib Figure
|
| 194 |
+
"""
|
| 195 |
+
n_states = len(fold_history)
|
| 196 |
+
show_states = min(n_states, 4)
|
| 197 |
+
|
| 198 |
+
fig = plt.figure(figsize=(4 * show_states + 4, 5), facecolor="#0d0d14")
|
| 199 |
+
gs = fig.add_gridspec(
|
| 200 |
+
1, show_states + 1,
|
| 201 |
+
width_ratios=[1] * show_states + [1.2],
|
| 202 |
+
wspace=0.3,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Paper state panels (up to 4)
|
| 206 |
+
for i in range(show_states):
|
| 207 |
+
# Evenly sample from fold_history if more than 4 steps
|
| 208 |
+
idx = int(i * (n_states - 1) / max(show_states - 1, 1)) if show_states > 1 else 0
|
| 209 |
+
ax = fig.add_subplot(gs[0, i])
|
| 210 |
+
draw_paper_state(
|
| 211 |
+
ax,
|
| 212 |
+
fold_history[idx],
|
| 213 |
+
target=target,
|
| 214 |
+
step=idx + 1,
|
| 215 |
+
reward=rewards_history[idx] if idx < len(rewards_history) else None,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# Reward curves panel
|
| 219 |
+
ax_reward = fig.add_subplot(gs[0, show_states])
|
| 220 |
+
ax_reward.set_facecolor(_COLOR_REWARD_BG)
|
| 221 |
+
|
| 222 |
+
steps = list(range(1, len(rewards_history) + 1))
|
| 223 |
+
curve_specs = [
|
| 224 |
+
("progress", _COLOR_PROGRESS, "progress"),
|
| 225 |
+
("kawasaki", _COLOR_VALIDITY, "kawasaki"),
|
| 226 |
+
("total", "#f8fafc", "total"),
|
| 227 |
+
]
|
| 228 |
+
|
| 229 |
+
for key, color, label in curve_specs:
|
| 230 |
+
vals = [r.get(key, 0.0) for r in rewards_history]
|
| 231 |
+
ax_reward.plot(steps, vals, color=color, linewidth=1.5, label=label)
|
| 232 |
+
|
| 233 |
+
ax_reward.set_xlim(1, max(len(rewards_history), 1))
|
| 234 |
+
ax_reward.set_title("Reward Curves", color="white", fontfamily="monospace", fontsize=10, pad=6)
|
| 235 |
+
ax_reward.tick_params(colors="white", labelsize=8)
|
| 236 |
+
ax_reward.legend(
|
| 237 |
+
fontsize=7,
|
| 238 |
+
facecolor=_COLOR_REWARD_BG,
|
| 239 |
+
edgecolor=_COLOR_GRID,
|
| 240 |
+
labelcolor="white",
|
| 241 |
+
)
|
| 242 |
+
for spine in ax_reward.spines.values():
|
| 243 |
+
spine.set_color(_COLOR_GRID)
|
| 244 |
+
|
| 245 |
+
if save_path:
|
| 246 |
+
fig.savefig(save_path, dpi=150, facecolor="#0d0d14", bbox_inches="tight")
|
| 247 |
+
else:
|
| 248 |
+
plt.show()
|
| 249 |
+
|
| 250 |
+
return fig
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def render_training_curves(log_path: str):
|
| 254 |
+
"""
|
| 255 |
+
Read a JSONL log file and plot training curves.
|
| 256 |
+
|
| 257 |
+
Each line must be a JSON object with reward component keys.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
log_path: path to JSONL training log
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
matplotlib Figure
|
| 264 |
+
"""
|
| 265 |
+
records = []
|
| 266 |
+
with open(log_path) as f:
|
| 267 |
+
for line in f:
|
| 268 |
+
line = line.strip()
|
| 269 |
+
if not line:
|
| 270 |
+
continue
|
| 271 |
+
records.append(json.loads(line))
|
| 272 |
+
|
| 273 |
+
episodes = list(range(1, len(records) + 1))
|
| 274 |
+
|
| 275 |
+
keys_to_plot = [
|
| 276 |
+
("total", "#f8fafc", "total reward"),
|
| 277 |
+
("progress", _COLOR_PROGRESS, "progress"),
|
| 278 |
+
("kawasaki", _COLOR_VALIDITY, "kawasaki"),
|
| 279 |
+
("maekawa", _COLOR_VALIDITY, "maekawa"),
|
| 280 |
+
("blb", _COLOR_VALIDITY, "blb"),
|
| 281 |
+
]
|
| 282 |
+
|
| 283 |
+
fig, axes = plt.subplots(
|
| 284 |
+
2, 1,
|
| 285 |
+
figsize=(10, 6),
|
| 286 |
+
facecolor="#0d0d14",
|
| 287 |
+
gridspec_kw={"hspace": 0.4},
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Top: total + progress
|
| 291 |
+
ax_top = axes[0]
|
| 292 |
+
ax_top.set_facecolor(_COLOR_REWARD_BG)
|
| 293 |
+
for key, color, label in keys_to_plot[:2]:
|
| 294 |
+
vals = [r.get(key, 0.0) for r in records]
|
| 295 |
+
ax_top.plot(episodes, vals, color=color, linewidth=1.5, label=label)
|
| 296 |
+
ax_top.set_title("Training: Total & Progress", color="white", fontfamily="monospace", fontsize=10)
|
| 297 |
+
ax_top.tick_params(colors="white", labelsize=8)
|
| 298 |
+
ax_top.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
|
| 299 |
+
for spine in ax_top.spines.values():
|
| 300 |
+
spine.set_color(_COLOR_GRID)
|
| 301 |
+
|
| 302 |
+
# Bottom: kawasaki, maekawa, blb
|
| 303 |
+
ax_bot = axes[1]
|
| 304 |
+
ax_bot.set_facecolor(_COLOR_REWARD_BG)
|
| 305 |
+
for key, color, label in keys_to_plot[2:]:
|
| 306 |
+
vals = [r.get(key, 0.0) for r in records]
|
| 307 |
+
ax_bot.plot(episodes, vals, color=color, linewidth=1.5, label=label, alpha=0.85)
|
| 308 |
+
ax_bot.set_title("Training: Validity Checks", color="white", fontfamily="monospace", fontsize=10)
|
| 309 |
+
ax_bot.set_xlabel("Episode", color="white", fontsize=9)
|
| 310 |
+
ax_bot.tick_params(colors="white", labelsize=8)
|
| 311 |
+
ax_bot.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
|
| 312 |
+
for spine in ax_bot.spines.values():
|
| 313 |
+
spine.set_color(_COLOR_GRID)
|
| 314 |
+
|
| 315 |
+
return fig
|