Web-demo styling
This commit is contained in:
+10
-3
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -23,7 +24,7 @@ from typing import Any
|
||||
import pandas as pd
|
||||
import torch
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -58,6 +59,7 @@ KNOWN_ROUTES_PATH = Path(
|
||||
os.getenv("CBGPT_KNOWN_ROUTES_PATH", TOKENIZED_DIR / "route_sequences.csv")
|
||||
)
|
||||
|
||||
STATIC_DIR = REPO_ROOT / "webapp" / "static"
|
||||
GENERATOR_PATH = MODEL_DIR / "joint_route_gpt_generator.pth"
|
||||
GRADE_MODEL_PATH = MODEL_DIR / "joint_transformer_grade_predictor.pth"
|
||||
|
||||
@@ -547,8 +549,13 @@ app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="bo
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
"""Serve the single-page web UI."""
|
||||
return FileResponse(REPO_ROOT / "webapp" / "static" / "index.html")
|
||||
"""Serve the single-page web UI with auto-versioned static asset URLs."""
|
||||
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
css_v = _file_version(STATIC_DIR / "app.css")
|
||||
js_v = _file_version(STATIC_DIR / "app.js")
|
||||
html = re.sub(r'app\.css\?v=[^\s"\']+', f"app.css?v={css_v}", html)
|
||||
html = re.sub(r'app\.js\?v=[^\s"\']+', f"app.js?v={js_v}", html)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
+42
-2
@@ -86,11 +86,18 @@ body {
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 22rem minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"col-top col-viewer"
|
||||
"col-info col-viewer";
|
||||
gap: 2rem;
|
||||
padding: 2rem 1rem 1rem;
|
||||
max-width: 78rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
#col-top { grid-area: col-top; }
|
||||
#col-viewer { grid-area: col-viewer; }
|
||||
#col-info { grid-area: col-info; }
|
||||
|
||||
/* Control cards, form fields, and action buttons. */
|
||||
.controls {
|
||||
@@ -181,6 +188,32 @@ button.secondary:hover {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
/* Collapsible cards: clicking the h2 toggles visibility of card body.
|
||||
Only cards with class .collapsible get this behavior. */
|
||||
.card.collapsible > h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.card.collapsible > h2::after {
|
||||
content: "▾";
|
||||
font-size: 2rem;
|
||||
color: var(--muted);
|
||||
margin-left: auto;
|
||||
padding-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card.collapsible.collapsed > h2::after {
|
||||
content: "▸";
|
||||
}
|
||||
.card.collapsible > h2:hover::after {
|
||||
color: var(--off-fg);
|
||||
}
|
||||
.card.collapsible.collapsed > *:not(h2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note p, .small {
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
@@ -284,9 +317,16 @@ button.secondary:hover {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
/* Stack controls above the board on narrower screens. */
|
||||
/* On narrower screens, stack controls → viewer → info. */
|
||||
@media (max-width: 900px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
grid-template-areas:
|
||||
"col-top"
|
||||
"col-viewer"
|
||||
"col-info";
|
||||
}
|
||||
.site-header { flex-direction: column; }
|
||||
}
|
||||
|
||||
|
||||
@@ -690,6 +690,16 @@ async function predict() {
|
||||
}
|
||||
|
||||
|
||||
/** Initialize collapsible cards: clicking the h2 toggles the .collapsed class. */
|
||||
function initCollapsibleCards() {
|
||||
document.querySelectorAll(".card.collapsible > h2").forEach((heading) => {
|
||||
const card = heading.parentElement;
|
||||
heading.addEventListener("click", () => {
|
||||
card.classList.toggle("collapsed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Attach a listener only when optional markup exists. */
|
||||
function addListenerIfPresent(id, eventName, handler) {
|
||||
const element = $(id);
|
||||
@@ -717,6 +727,8 @@ async function init() {
|
||||
await setBoardBackground("tb2");
|
||||
syncAngleSelectors(40);
|
||||
|
||||
initCollapsibleCards();
|
||||
|
||||
addListenerIfPresent("gen-board", "change", async (e) => {
|
||||
syncBoardSelectors(e.target.value);
|
||||
await setBoardBackground(e.target.value);
|
||||
|
||||
+60
-79
@@ -4,10 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ClimbingBoardGPT</title>
|
||||
<link rel="stylesheet" href="/static/app.css?v=19" />
|
||||
<link rel="stylesheet" href="/static/app.css?v=20" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top-level status: the app script replaces this with model readiness. -->
|
||||
<header class="site-header">
|
||||
<div>
|
||||
<p class="eyebrow">ClimbingBoardGPT</p>
|
||||
@@ -18,9 +17,9 @@
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<!-- Left column: generation controls, prediction controls, and project notes. -->
|
||||
<section class="controls">
|
||||
<div class="card">
|
||||
<!-- Left column, row 1: interactive controls (collapsible). -->
|
||||
<section class="controls" id="col-top">
|
||||
<div class="card collapsible" id="card-gen">
|
||||
<h2>Generate a climb</h2>
|
||||
<label>Board
|
||||
<select id="gen-board">
|
||||
@@ -41,7 +40,7 @@
|
||||
<button id="generate-btn">Generate</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsible" id="card-predict">
|
||||
<h2>Predict grade</h2>
|
||||
<label>Board
|
||||
<select id="pred-board">
|
||||
@@ -58,7 +57,7 @@
|
||||
<button id="predict-btn" disabled>Predict pasted / clicked climb</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card collapsible" id="card-builder">
|
||||
<h2>Click holds to build a climb</h2>
|
||||
<label>Role for next clicked hold
|
||||
<select id="click-role">
|
||||
@@ -78,80 +77,10 @@
|
||||
</p>
|
||||
<ul id="builder-list" class="builder-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card explain" id="explain-card">
|
||||
<h2>What the controls mean</h2>
|
||||
<dl>
|
||||
<dt>Temperature</dt>
|
||||
<dd>Sampling randomness. Lower values are more conservative; higher values are more exploratory.</dd>
|
||||
|
||||
<dt>Target V-grade</dt>
|
||||
<dd>The grade token given to the generator. The generated route is also checked by the grade predictor.</dd>
|
||||
|
||||
<dt>Known climb</dt>
|
||||
<dd>An exact match against the tokenized dataset: same board, same angle, and same hold-role set.</dd>
|
||||
|
||||
<dt>Validity</dt>
|
||||
<dd>A structural check: enough holds, no duplicate placements, at least one start, and at least one finish.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="model-disclaimer-card">
|
||||
<h2>Research demo caveat</h2>
|
||||
<p>
|
||||
This is an experimental model demo. Generated climbs and predicted grades may be wrong, especially at rare grades or sparse board/angle combinations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card explain">
|
||||
<h2>How this works</h2>
|
||||
<p>
|
||||
Routes are converted into tokens such as
|
||||
<code><BOARD_TB2></code>, <code><ANGLE_40></code>,
|
||||
and <code><TB2_p652_start></code>.
|
||||
</p>
|
||||
<p>
|
||||
The generator samples a token sequence. The grade predictor removes the grade token and estimates difficulty from the board, angle, and hold-role tokens.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card note">
|
||||
<h2>Links</h2>
|
||||
<ul class="link-list">
|
||||
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
|
||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</a></li>
|
||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a></li>
|
||||
<li><a href="https://github.com/psark007/Tension-Board-2-Analysis" target="_blank" rel="noreferrer">Tension Board 2 Analysis repo</a></li>
|
||||
<li><a href="https://github.com/psark007/Kilter-Board-Analysis" target="_blank" rel="noreferrer">Kilter Board Analysis repo</a></li>
|
||||
<li><a href="https://tensionclimbing.com/products/tension-board-2" target="_blank" rel="noreferrer">Tension Board 2</a></li>
|
||||
<li><a href="https://settercloset.com/collections/kilter-board" target="_blank" rel="noreferrer">Kilter Board</a></li>
|
||||
<li><a href="https://github.com/karpathy/nanoGPT" target="_blank" rel="noreferrer">nanoGPT</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="angle-scope-card">
|
||||
<h2>Angle scope</h2>
|
||||
<p>
|
||||
The physical boards can be used at steeper angles than this demo exposes. This model snapshot is intentionally restricted to the angle range used in training/evaluation: TB2 up to 50° and Kilter up to 55°.
|
||||
</p>
|
||||
<p>
|
||||
The restriction avoids asking the models to extrapolate into sparse, noisier high-angle data where grades and route distributions are less reliable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="data-acknowledgement-card">
|
||||
<h2>Data acknowledgement</h2>
|
||||
<p>
|
||||
Board layouts, hold metadata, and route data are derived from Tension Board 2 and Kilter Board datasets.
|
||||
This project is unaffiliated with Tension Climbing or Kilter.
|
||||
The route generator is inspired by Andrej Karpathy's
|
||||
<a href="https://github.com/karpathy/nanoGPT" target="_blank" rel="noreferrer">nanoGPT</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right column: generated/predicted route result and SVG board overlay. -->
|
||||
<section class="viewer">
|
||||
<section class="viewer" id="col-viewer">
|
||||
<div class="result-card">
|
||||
<div class="result-header">
|
||||
<h2 id="result-title">Choose a board and run a request</h2>
|
||||
@@ -175,9 +104,61 @@
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Left column, row 2: info cards (below board on mobile, left column on desktop). -->
|
||||
<section class="controls" id="col-info">
|
||||
<div class="card explain">
|
||||
<h2>What the controls mean</h2>
|
||||
<dl>
|
||||
<dt>Temperature</dt>
|
||||
<dd>Sampling randomness. Lower values are more conservative; higher values are more exploratory.</dd>
|
||||
<dt>Target V-grade</dt>
|
||||
<dd>The grade token given to the generator. The generated route is also checked by the grade predictor.</dd>
|
||||
<dt>Known climb</dt>
|
||||
<dd>An exact match against the tokenized dataset: same board, same angle, and same hold-role set.</dd>
|
||||
<dt>Validity</dt>
|
||||
<dd>A structural check: enough holds, no duplicate placements, at least one start, and at least one finish.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="model-disclaimer-card">
|
||||
<h2>Research demo caveat</h2>
|
||||
<p>This is an experimental model demo. Generated climbs and predicted grades may be wrong, especially at rare grades or sparse board/angle combinations.</p>
|
||||
</div>
|
||||
|
||||
<div class="card explain">
|
||||
<h2>How this works</h2>
|
||||
<p>Routes are converted into tokens such as <code><BOARD_TB2></code>, <code><ANGLE_40></code>, and <code><TB2_p652_start></code>.</p>
|
||||
<p>The generator samples a token sequence. The grade predictor removes the grade token and estimates difficulty from the board, angle, and hold-role tokens.</p>
|
||||
</div>
|
||||
|
||||
<div class="card note">
|
||||
<h2>Links</h2>
|
||||
<ul class="link-list">
|
||||
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
|
||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</a></li>
|
||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a></li>
|
||||
<li><a href="https://github.com/psark007/Tension-Board-2-Analysis" target="_blank" rel="noreferrer">Tension Board 2 Analysis repo</a></li>
|
||||
<li><a href="https://github.com/psark007/Kilter-Board-Analysis" target="_blank" rel="noreferrer">Kilter Board Analysis repo</a></li>
|
||||
<li><a href="https://tensionclimbing.com/products/tension-board-2" target="_blank" rel="noreferrer">Tension Board 2</a></li>
|
||||
<li><a href="https://settercloset.com/collections/kilter-board" target="_blank" rel="noreferrer">Kilter Board</a></li>
|
||||
<li><a href="https://github.com/karpathy/nanoGPT" target="_blank" rel="noreferrer">nanoGPT</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="angle-scope-card">
|
||||
<h2>Angle scope</h2>
|
||||
<p>The physical boards can be used at steeper angles than this demo exposes. This model snapshot is intentionally restricted to the angle range used in training/evaluation: TB2 up to 50° and Kilter up to 55°.</p>
|
||||
<p>The restriction avoids asking the models to extrapolate into sparse, noisier high-angle data where grades and route distributions are less reliable.</p>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="data-acknowledgement-card">
|
||||
<h2>Data acknowledgement</h2>
|
||||
<p>Board layouts, hold metadata, and route data are derived from Tension Board 2 and Kilter Board datasets. This project is unaffiliated with Tension Climbing or Kilter. The route generator is inspired by Andrej Karpathy's <a href="https://github.com/karpathy/nanoGPT" target="_blank" rel="noreferrer">nanoGPT</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- External project links and license metadata. -->
|
||||
<footer class="site-footer">
|
||||
<span>© Pawel Sarkowicz</span>
|
||||
<a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a>
|
||||
|
||||
Reference in New Issue
Block a user