This commit is contained in:
Pawel
2026-05-22 13:57:56 -04:00
parent 86d582a572
commit f0ea67bb45
10 changed files with 2096 additions and 23 deletions

347
webapp/static/app.css Normal file
View File

@@ -0,0 +1,347 @@
:root {
--bg: #263238;
--bg-alt: #1f2a30;
--panel: #2f3d43;
--panel-soft: #37474f;
--ink: #eceff1;
--muted: #b0bec5;
--faint: #90a4ae;
--border: #455a64;
--accent: #80cbc4;
--accent-2: #ffcc80;
--danger: #ef9a9a;
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--mono);
background:
radial-gradient(circle at top left, rgba(128, 203, 196, 0.07), transparent 32rem),
var(--bg);
color: var(--ink);
}
.site-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1.35rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--bg-alt);
}
.eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.site-header h1 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 2.05rem);
letter-spacing: -0.04em;
}
.site-header p {
margin: 0.4rem 0 0;
color: var(--muted);
}
.health {
font-size: 0.78rem;
color: var(--accent-2);
white-space: nowrap;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.45rem 0.7rem;
background: rgba(0, 0, 0, 0.15);
}
.layout {
display: grid;
grid-template-columns: 370px minmax(0, 1fr);
gap: 1.25rem;
padding: 1.25rem;
max-width: 1520px;
margin: 0 auto;
}
.controls {
display: flex;
flex-direction: column;
gap: 1rem;
}
.card, .result-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 1rem;
}
.card h2, .result-card h2 {
margin: 0 0 0.8rem;
font-size: 1.0rem;
color: var(--accent);
letter-spacing: -0.02em;
}
label {
display: block;
margin: 0.7rem 0;
font-size: 0.82rem;
color: var(--muted);
}
input, select, textarea {
display: block;
width: 100%;
margin-top: 0.28rem;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem 0.7rem;
font: inherit;
color: var(--ink);
background: #1c272c;
}
input:focus, select:focus, textarea:focus {
outline: 2px solid rgba(128, 203, 196, 0.32);
border-color: var(--accent);
}
textarea {
resize: vertical;
min-height: 96px;
font-size: 0.78rem;
}
button {
width: 100%;
border: 1px solid #4db6ac;
border-radius: 6px;
padding: 0.68rem 0.9rem;
margin-top: 0.4rem;
font-weight: 700;
color: #102022;
background: var(--accent);
cursor: pointer;
font-family: var(--mono);
}
button:hover { filter: brightness(1.06); }
button:disabled { opacity: 0.55; cursor: not-allowed; }
button.secondary {
background: transparent;
color: var(--accent);
border-color: var(--border);
}
.button-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.55rem;
}
.note p, .small {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.45;
}
.builder-list {
list-style: none;
margin: 0.8rem 0 0;
padding: 0;
color: var(--muted);
font-size: 0.78rem;
max-height: 160px;
overflow: auto;
}
.builder-list li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.25rem 0;
border-bottom: 1px dashed rgba(176, 190, 197, 0.16);
}
.result-header {
text-align: center;
margin-bottom: 0.85rem;
}
.result-header h2 { margin-bottom: 0.25rem; }
.result-header p {
margin: 0;
color: var(--muted);
font-size: 0.84rem;
}
.board-stage {
position: relative;
width: 100%;
max-width: 960px;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
background: #111827;
}
.board-stage img {
display: block;
width: 100%;
height: auto;
}
.board-stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: auto;
}
.click-target {
cursor: crosshair;
}
.route-marker, .builder-marker {
pointer-events: none;
}
.hover-marker {
pointer-events: none;
fill: none;
stroke: var(--accent-2);
stroke-width: 0.55;
opacity: 0.9;
}
.json-block {
margin-top: 1rem;
color: var(--muted);
}
.json-block pre {
overflow: auto;
max-height: 300px;
padding: 1rem;
background: #111a1f;
color: #cfd8dc;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.76rem;
}
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.site-header { flex-direction: column; }
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.55rem;
}
.checkbox-label input {
width: auto;
margin: 0;
}
.warning-box {
margin: 0.7rem auto 0.85rem;
max-width: 760px;
border: 1px solid rgba(255, 204, 128, 0.55);
background: rgba(255, 204, 128, 0.12);
color: var(--accent-2);
border-radius: 8px;
padding: 0.65rem 0.8rem;
font-size: 0.8rem;
text-align: left;
white-space: pre-line;
}
.clear-board {
width: auto;
min-width: 150px;
margin: 0.85rem auto 0;
display: inline-block;
}
.field-help {
display: block;
margin-top: 0.35rem;
color: var(--faint);
font-size: 0.72rem;
line-height: 1.35;
}
.explain dl {
margin: 0;
}
.explain dt {
color: var(--accent-2);
font-size: 0.78rem;
margin-top: 0.75rem;
}
.explain dt:first-child {
margin-top: 0;
}
.explain dd {
margin: 0.22rem 0 0;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.45;
}
.explain code {
color: var(--accent-2);
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.05rem 0.25rem;
font-size: 0.74rem;
}
.link-list {
margin: 0;
padding-left: 1.1rem;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.6;
}
.link-list a {
color: var(--accent);
text-decoration: none;
}
.link-list a:hover {
text-decoration: underline;
}
#data-acknowledgement-card p {
color: var(--muted);
font-size: 0.8rem;
line-height: 1.45;
}

702
webapp/static/app.js Normal file
View File

@@ -0,0 +1,702 @@
const state = {
boards: {},
boardHolds: {},
activeBoard: "tb2",
lastResult: null,
builder: [],
};
const roleStyle = {
start: { fill: "#69f0ae", stroke: "#102022", r: 1.55, shape: "circle" },
middle: { fill: "#82b1ff", stroke: "#102022", r: 1.55, shape: "circle" },
finish: { fill: "#ff8a80", stroke: "#102022", r: 2.25, shape: "star" },
foot: { fill: "#ffd740", stroke: "#102022", r: 1.1, shape: "square" },
unknown: { fill: "#b0bec5", stroke: "#102022", r: 1.45, shape: "circle" },
};
const builderStyle = {
start: { fill: "#00e676", stroke: "#000000", r: 1.85, shape: "circle" },
middle: { fill: "#448aff", stroke: "#000000", r: 1.85, shape: "circle" },
finish: { fill: "#ff5252", stroke: "#000000", r: 2.6, shape: "star" },
foot: { fill: "#ffea00", stroke: "#000000", r: 1.25, shape: "square" },
unknown: { fill: "#cfd8dc", stroke: "#000000", r: 1.85, shape: "circle" },
};
function $(id) {
return document.getElementById(id);
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, options);
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = { detail: text };
}
if (!response.ok) {
const detail = payload.detail ?? payload;
let message;
if (typeof detail === "string") {
message = detail;
} else if (Array.isArray(detail)) {
message = detail.map((item) => item.msg || JSON.stringify(item)).join("\n");
} else {
message = JSON.stringify(detail, null, 2);
}
throw new Error(message || `Request failed: ${response.status}`);
}
return payload;
}
function setBusy(button, busy) {
button.disabled = busy;
button.textContent = busy ? "Working…" : button.dataset.label;
}
async function ensureBoardHolds(boardKey) {
if (!state.boardHolds[boardKey]) {
const payload = await fetchJson(`/api/board-holds/${boardKey}`);
state.boardHolds[boardKey] = payload.holds || [];
}
return state.boardHolds[boardKey];
}
async function setBoardBackground(boardKey) {
const board = state.boards[boardKey];
if (!board) return;
state.activeBoard = boardKey;
syncAngleSelectors();
syncGradeSelector();
const bg = $("board-bg");
if (bg.getAttribute("src") !== board.background_url) {
bg.src = board.background_url;
}
const svg = $("overlay");
const c = board.canvas;
svg.setAttribute("viewBox", `${c.x_min} ${c.y_min} ${c.width} ${c.height}`);
await ensureBoardHolds(boardKey);
redrawCurrentOverlay();
}
function preferredAngleForBoard(boardKey, currentValue = null) {
const board = state.boards[boardKey] || {};
const angles = board.available_angles || [];
const current = Number(currentValue);
if (angles.includes(current)) return current;
if (angles.includes(40)) return 40;
if (angles.length > 0) return angles[Math.floor(angles.length / 2)];
return 40;
}
function populateAngleSelect(selectId, boardKey, selectedValue = null) {
const select = $(selectId);
if (!select) return;
const board = state.boards[boardKey] || {};
const angles = board.available_angles || [20, 25, 30, 35, 40, 45, 50];
const selected = preferredAngleForBoard(boardKey, selectedValue ?? select.value);
select.innerHTML = "";
for (const angle of angles) {
const option = document.createElement("option");
option.value = String(angle);
option.textContent = `${angle}°`;
if (Number(angle) === Number(selected)) {
option.selected = true;
}
select.appendChild(option);
}
}
function syncAngleSelectors(angleValue = null) {
const boardKey = state.activeBoard || $("gen-board")?.value || $("pred-board")?.value || "tb2";
const selected = preferredAngleForBoard(boardKey, angleValue ?? $("gen-angle")?.value ?? $("pred-angle")?.value);
populateAngleSelect("gen-angle", boardKey, selected);
populateAngleSelect("pred-angle", boardKey, selected);
const genAngle = $("gen-angle");
const predAngle = $("pred-angle");
if (genAngle) genAngle.value = String(selected);
if (predAngle) predAngle.value = String(selected);
}
function preferredGradeForBoard(boardKey, currentValue = null) {
const board = state.boards[boardKey] || {};
const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
const current = Number(currentValue);
if (grades.includes(current)) return current;
if (grades.includes(6)) return 6;
if (grades.length > 0) return grades[Math.floor(grades.length / 2)];
return 6;
}
function populateGradeSelect(selectId, boardKey, selectedValue = null) {
const select = $(selectId);
if (!select) return;
const board = state.boards[boardKey] || {};
const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
const selected = preferredGradeForBoard(boardKey, selectedValue ?? select.value);
select.innerHTML = "";
for (const grade of grades) {
const option = document.createElement("option");
option.value = String(grade);
option.textContent = `V${grade}`;
if (Number(grade) === Number(selected)) {
option.selected = true;
}
select.appendChild(option);
}
}
function syncGradeSelector(gradeValue = null) {
const boardKey = state.activeBoard || $("gen-board")?.value || "tb2";
const selected = preferredGradeForBoard(boardKey, gradeValue ?? $("gen-grade")?.value);
populateGradeSelect("gen-grade", boardKey, selected);
const genGrade = $("gen-grade");
if (genGrade) genGrade.value = String(selected);
}
function syncBoardSelectors(boardKey) {
const genBoard = $("gen-board");
const predBoard = $("pred-board");
if (genBoard && genBoard.value !== boardKey) genBoard.value = boardKey;
if (predBoard && predBoard.value !== boardKey) predBoard.value = boardKey;
}
function setBuilderFromRouteResult(result) {
const board = result.board_key;
state.builder = (result.holds || []).map((hold) => ({
board,
placement_id: hold.placement_id,
x: hold.x,
y: hold.y,
role: hold.role,
}));
syncBuilderToFrames();
}
function activeBuilderSummary() {
const board = $("pred-board")?.value || state.activeBoard;
const holds = state.builder.filter((hold) => hold.board === board);
const starts = holds.filter((hold) => hold.role === "start").length;
const finishes = holds.filter((hold) => hold.role === "finish").length;
return { holds, starts, finishes };
}
function updateEditingHeader(prefix = "Editing climb") {
const summary = activeBuilderSummary();
const frameText = $("pred-frames")?.value?.trim() || "";
$("result-title").textContent = prefix;
$("result-subtitle").textContent =
`${summary.holds.length} selected holds | starts=${summary.starts} | finishes=${summary.finishes} | editing`;
$("raw-json").textContent = JSON.stringify({
mode: "editable_climb",
board: $("pred-board")?.value || state.activeBoard,
n_holds: summary.holds.length,
starts: summary.starts,
finishes: summary.finishes,
frames: frameText,
note: "Run grade prediction to refresh known-climb status after edits."
}, null, 2);
}
function clearOverlay() {
$("overlay").innerHTML = "";
}
function svgEl(name, attrs = {}) {
const el = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [key, value] of Object.entries(attrs)) {
el.setAttribute(key, value);
}
return el;
}
function starPoints(cx, cy, outerR, innerR, n = 5) {
const points = [];
for (let i = 0; i < n * 2; i++) {
const angle = -Math.PI / 2 + (i * Math.PI) / n;
const r = i % 2 === 0 ? outerR : innerR;
points.push(`${cx + Math.cos(angle) * r},${cy + Math.sin(angle) * r}`);
}
return points.join(" ");
}
function drawMarker(group, hold, style, className = "route-marker") {
if (style.shape === "square") {
const s = style.r * 2;
group.appendChild(svgEl("rect", {
x: hold.x - style.r,
y: hold.y - style.r,
width: s,
height: s,
rx: 0.28,
fill: style.fill,
stroke: style.stroke,
"stroke-width": 0.35,
opacity: 0.96,
class: className,
}));
} else if (style.shape === "star") {
group.appendChild(svgEl("polygon", {
points: starPoints(hold.x, hold.y, style.r, style.r * 0.48),
fill: style.fill,
stroke: style.stroke,
"stroke-width": 0.38,
opacity: 0.98,
class: className,
}));
} else {
group.appendChild(svgEl("circle", {
cx: hold.x,
cy: hold.y,
r: style.r,
fill: style.fill,
stroke: style.stroke,
"stroke-width": 0.35,
opacity: 0.96,
class: className,
}));
}
}
function transformedGroup(resultOrBoardKey) {
const boardKey = typeof resultOrBoardKey === "string" ? resultOrBoardKey : resultOrBoardKey.board_key;
const board = state.boards[boardKey];
const c = board.canvas;
return svgEl("g", {
transform: `translate(0 ${c.y_min + c.y_max}) scale(1 -1)`,
});
}
function drawSelectableTargets(boardKey, group) {
const holds = state.boardHolds[boardKey] || [];
for (const hold of holds) {
const target = svgEl("circle", {
cx: hold.x,
cy: hold.y,
r: 2.45,
fill: "transparent",
stroke: "transparent",
"stroke-width": 0,
class: "click-target",
});
target.addEventListener("mouseenter", () => {
const hover = svgEl("circle", {
cx: hold.x,
cy: hold.y,
r: 2.15,
class: "hover-marker",
id: "hover-marker",
});
group.appendChild(hover);
});
target.addEventListener("mouseleave", () => {
const hover = document.getElementById("hover-marker");
if (hover) hover.remove();
});
target.addEventListener("click", (event) => {
event.stopPropagation();
addBuilderHold(hold);
});
group.appendChild(target);
}
}
function drawOverlay(result) {
state.lastResult = result;
syncBoardSelectors(result.board_key);
state.activeBoard = result.board_key;
setBuilderFromRouteResult(result);
redrawCurrentOverlay();
}
function redrawCurrentOverlay() {
const svg = $("overlay");
svg.innerHTML = "";
const boardKey = state.activeBoard || $("pred-board")?.value || "tb2";
const group = transformedGroup(boardKey);
svg.appendChild(group);
for (const hold of state.builder) {
if (hold.board !== boardKey) continue;
const style = builderStyle[hold.role] || builderStyle.unknown;
drawMarker(group, hold, style, "builder-marker");
}
drawSelectableTargets(boardKey, group);
}
function findBuilderHoldIndex(board, placementId) {
return state.builder.findIndex(
(hold) => hold.board === board && Number(hold.placement_id) === Number(placementId)
);
}
function isBuilderHoldSelected(board, placementId) {
return findBuilderHoldIndex(board, placementId) >= 0;
}
function activeBuilderHolds() {
return state.builder.filter((hold) => hold.board === state.activeBoard);
}
function roleCountForActiveBoard(role) {
return activeBuilderHolds().filter((hold) => hold.role === role).length;
}
function canAddBuilderRole(role) {
if ((role === "start" || role === "finish") && roleCountForActiveBoard(role) >= 2) {
showWarnings([
`You already have two ${role} holds. A climb can have at most two ${role} holds in this builder.`
]);
return false;
}
return true;
}
function frameStringForBuilder() {
const board = state.boards[$("pred-board").value];
if (!board) return "";
const roleDefinitions = board.role_definitions || {};
return state.builder
.filter((hold) => hold.board === $("pred-board").value)
.map((hold) => `p${hold.placement_id}r${roleDefinitions[hold.role]}`)
.join("");
}
function syncBuilderToFrames() {
$("pred-frames").value = frameStringForBuilder();
const list = $("builder-list");
list.innerHTML = "";
const active = state.builder.filter((hold) => hold.board === $("pred-board").value);
for (const [idx, hold] of active.entries()) {
const li = document.createElement("li");
li.innerHTML = `<span>${idx + 1}. ${hold.role}</span><span>p${hold.placement_id}</span>`;
list.appendChild(li);
}
updatePredictButton();
}
function addBuilderHold(hold) {
const board = $("pred-board").value;
const role = $("click-role").value;
if (board !== state.activeBoard) {
$("pred-board").value = state.activeBoard;
}
const existingIndex = findBuilderHoldIndex(state.activeBoard, hold.placement_id);
if (existingIndex >= 0) {
state.builder.splice(existingIndex, 1);
showWarnings([]);
syncBuilderToFrames();
redrawCurrentOverlay();
updateEditingHeader("Editing generated / clicked climb");
return;
}
if (!canAddBuilderRole(role)) {
return;
}
state.builder.push({
board: state.activeBoard,
placement_id: hold.placement_id,
x: hold.x,
y: hold.y,
role,
});
showWarnings([]);
syncBuilderToFrames();
redrawCurrentOverlay();
updateEditingHeader("Editing generated / clicked climb");
}
function undoBuilderHold() {
const board = $("pred-board").value;
for (let i = state.builder.length - 1; i >= 0; i--) {
if (state.builder[i].board === board) {
state.builder.splice(i, 1);
break;
}
}
syncBuilderToFrames();
redrawCurrentOverlay();
updateEditingHeader("Editing generated / clicked climb");
}
function clearBuilder() {
clearEntireBoard();
}
function showWarnings(warnings = []) {
const box = $("warning-box");
const normalized = (warnings || []).filter(Boolean).map(String);
if (!box) {
if (normalized.length > 0) {
$("result-subtitle").textContent = normalized.join(" | ");
}
return;
}
if (normalized.length === 0) {
box.hidden = true;
box.textContent = "";
return;
}
box.hidden = false;
box.textContent = normalized.join("\n");
}
function clearClickedHoldsOnly() {
state.builder = [];
const frames = $("pred-frames");
if (frames) frames.value = "";
const list = $("builder-list");
if (list) list.innerHTML = "";
updatePredictButton();
}
function clearBoard() {
clearEntireBoard();
}
function updatePredictButton() {
const button = $("predict-btn");
if (!button) return;
const frames = $("pred-frames").value.trim();
const hasFrames = frames.length > 0;
button.disabled = !hasFrames;
button.title = hasFrames
? ""
: "Paste a frames string or click holds on the board first.";
}
function clearEntireBoard() {
state.lastResult = null;
state.builder = [];
$("pred-frames").value = "";
const list = $("builder-list");
if (list) list.innerHTML = "";
clearOverlay();
showWarnings([]);
$("result-title").textContent = "Board cleared";
$("result-subtitle").textContent = "Click holds to build a climb, paste a frames string, or generate a new one.";
$("raw-json").textContent = "{}";
updatePredictButton();
redrawCurrentOverlay();
}
function knownClimbSummary(result) {
const known = result.known_climb;
if (!known || !known.checked) return null;
if (!known.is_known) return "not in known-climb database";
const first = (known.examples || [])[0] || {};
const name = first.climb_name || "unnamed climb";
const grade = first.boulder_grade || (first.grouped_v !== undefined && first.grouped_v !== null ? `V${first.grouped_v}` : "unknown grade");
return `known climb: ${name} (${grade}); ${known.match_count} matching route-angle entr${known.match_count === 1 ? "y" : "ies"}`;
}
function summarizeResult(result, mode) {
if (mode === "generate") {
const pieces = [
`${result.board_display_name}`,
`requested V${result.requested_grouped_v}`,
`${result.requested_angle}°`,
];
if (result.predicted_grouped_v !== undefined) {
pieces.push(`predicted V${result.predicted_grouped_v} (${Number(result.predicted_display_difficulty).toFixed(2)})`);
if (result.critic_v_error !== undefined) {
const sign = Number(result.critic_v_error) >= 0 ? "+" : "";
pieces.push(`error ${sign}${result.critic_v_error}V`);
}
}
const knownSummary = knownClimbSummary(result);
if (knownSummary) pieces.push(knownSummary);
$("result-title").textContent = "Generated climb";
$("result-subtitle").textContent = pieces.join(" | ");
} else {
const knownSummary = knownClimbSummary(result);
$("result-title").textContent = "Grade prediction";
$("result-subtitle").textContent =
`${result.board_display_name} | ${result.requested_angle}° | predicted V${result.predicted_grouped_v} (${Number(result.predicted_display_difficulty).toFixed(2)})`
+ (knownSummary ? ` | ${knownSummary}` : "");
}
showWarnings(result.warnings || []);
$("raw-json").textContent = JSON.stringify(result, null, 2);
}
function formatErrorMessage(message) {
if (!message) return "Unknown error.";
try {
const parsed = JSON.parse(message);
if (parsed.message && Array.isArray(parsed.reasons)) {
return `${parsed.message}\n- ${parsed.reasons.join("\n- ")}`;
}
if (Array.isArray(parsed.reasons)) {
return parsed.reasons.map((r) => `- ${r}`).join("\n");
}
return JSON.stringify(parsed, null, 2);
} catch {
return message;
}
}
async function generate() {
const button = $("generate-btn");
setBusy(button, true);
try {
const payload = {
board: $("gen-board").value,
angle: Number($("gen-angle").value),
grade: Number($("gen-grade").value),
temperature: Number($("gen-temperature").value),
top_k: 50,
max_new_tokens: 40,
};
const result = await fetchJson("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
drawOverlay(result);
summarizeResult(result, "generate");
} catch (err) {
showWarnings([formatErrorMessage(err.message)]);
} finally {
setBusy(button, false);
}
}
async function predict() {
const button = $("predict-btn");
const frames = $("pred-frames").value.trim();
if (!frames) {
showWarnings(["Paste a frames string or click holds on the board before predicting."]);
updatePredictButton();
return;
}
setBusy(button, true);
try {
const payload = {
board: $("pred-board").value,
angle: Number($("pred-angle").value),
frames,
};
const result = await fetchJson("/api/predict", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
drawOverlay(result);
summarizeResult(result, "predict");
} catch (err) {
showWarnings([formatErrorMessage(err.message)]);
} finally {
setBusy(button, false);
updatePredictButton();
}
}
function addListenerIfPresent(id, eventName, handler) {
const element = $(id);
if (!element) {
console.warn(`Missing optional element #${id}; skipping ${eventName} listener.`);
return;
}
element.addEventListener(eventName, handler);
}
async function init() {
$("generate-btn").dataset.label = "Generate";
$("predict-btn").dataset.label = "Predict pasted / clicked climb";
const health = await fetchJson("/api/health");
$("health").textContent =
`device=${health.device} | generator=${health.generator_loaded ? "loaded" : "missing"} | grade=${health.grade_predictor_loaded ? "loaded" : "missing"}`;
state.boards = await fetchJson("/api/boards");
syncBoardSelectors("tb2");
syncAngleSelectors(40);
syncGradeSelector(6);
await ensureBoardHolds("tb2");
await setBoardBackground("tb2");
syncAngleSelectors(40);
addListenerIfPresent("gen-board", "change", async (e) => {
syncBoardSelectors(e.target.value);
await setBoardBackground(e.target.value);
syncGradeSelector();
syncBuilderToFrames();
updateEditingHeader("Editing climb");
});
addListenerIfPresent("pred-board", "change", async (e) => {
syncBoardSelectors(e.target.value);
await setBoardBackground(e.target.value);
syncGradeSelector();
syncBuilderToFrames();
updateEditingHeader("Editing climb");
});
addListenerIfPresent("gen-angle", "change", (e) => {
syncAngleSelectors(e.target.value);
});
addListenerIfPresent("pred-angle", "change", (e) => {
syncAngleSelectors(e.target.value);
});
addListenerIfPresent("gen-grade", "change", (e) => {
syncGradeSelector(e.target.value);
});
addListenerIfPresent("pred-frames", "input", updatePredictButton);
addListenerIfPresent("generate-btn", "click", generate);
addListenerIfPresent("predict-btn", "click", predict);
addListenerIfPresent("undo-hold-btn", "click", undoBuilderHold);
addListenerIfPresent("clear-holds-btn", "click", clearBoard);
addListenerIfPresent("clear-board-btn", "click", clearBoard);
updatePredictButton();
}
init().catch((err) => {
const health = $("health");
if (health) health.textContent = `Error: ${err.message}`;
showWarnings([`Initialization error: ${err.message}`]);
console.error(err);
});

175
webapp/static/index.html Normal file
View File

@@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<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=16" />
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">ClimbingBoardGPT</p>
<h1>Board-climbing route generation and grade prediction</h1>
<p>Transformer demo for Tension Board 2 12x12 and Kilter Board Original 16x12 routes.</p>
</div>
<div id="health" class="health">Loading…</div>
</header>
<main class="layout">
<section class="controls">
<div class="card">
<h2>Generate a climb</h2>
<label>Board
<select id="gen-board">
<option value="tb2">Tension Board 2 (12x12)</option>
<option value="kilter">Kilter Board Original (16x12)</option>
</select>
</label>
<label>Angle
<select id="gen-angle"></select>
</label>
<label>Target V-grade
<select id="gen-grade"></select>
</label>
<label>Temperature
<input id="gen-temperature" type="number" value="0.9" min="0.1" max="2.0" step="0.1" />
<span class="field-help">Lower = safer/common; higher = more creative/weird. Use temperature ≤ 2.</span>
</label>
<button id="generate-btn">Generate</button>
</div>
<div class="card">
<h2>Predict grade</h2>
<label>Board
<select id="pred-board">
<option value="tb2">Tension Board 2 (12x12)</option>
<option value="kilter">Kilter Board Original (16x12)</option>
</select>
</label>
<label>Angle
<select id="pred-angle"></select>
</label>
<label>Frames string
<textarea id="pred-frames" rows="5" placeholder="Click holds below, or paste p652r5p631r6p322r6p326r7"></textarea>
</label>
<button id="predict-btn" disabled>Predict pasted / clicked climb</button>
</div>
<div class="card">
<h2>Click holds to build a climb</h2>
<label>Role for next clicked hold
<select id="click-role">
<option value="start">start</option>
<option value="middle" selected>middle</option>
<option value="finish">finish</option>
<option value="foot">foot</option>
</select>
</label>
<div class="button-row">
<button id="undo-hold-btn" class="secondary">Undo</button>
<button id="clear-holds-btn" class="secondary">Clear board</button>
</div>
<p class="small">
Click a hold on the board image. The app appends the corresponding
BoardLib frame token using the selected semantic role.
</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 explain">
<h2>How this works</h2>
<p>
Routes are converted into tokens such as
<code>&lt;BOARD_TB2&gt;</code>, <code>&lt;ANGLE_40&gt;</code>,
and <code>&lt;TB2_p652_start&gt;</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://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</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://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
</ul>
</div>
<div class="card note" id="data-acknowledgement-card">
<h2>Data acknowledgement</h2>
<p>
Tension Board 2 route data is from Tension Climbing. Kilter Board route data is from Kilter.
</p>
</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="edge-grade-caveat-card">
<h2>High-grade caveat</h2>
<p>
Generation and prediction are both less reliable at edge-case grades, especially around V10 and above. Those grades are rarer in the data, so the generator has weaker grade control and the predictor has less signal for accurate scoring.
</p>
</div>
<div class="card note">
<h2>To-do</h2>
<p>
This demo currently focuses on the full-board layouts: Tension Board 2 Mirror 12x12 and Kilter Board Original 16x12. Other board sizes are future work.
</p>
</div>
</section>
<section class="viewer">
<div class="result-card">
<div class="result-header">
<h2 id="result-title">Choose a board and run a request</h2>
<p id="result-subtitle">The climb overlay will appear below. You can also click holds to build a route for prediction.</p>
<div id="warning-box" class="warning-box" hidden></div>
<button id="clear-board-btn" class="secondary clear-board">Clear board</button>
</div>
<div id="board-stage" class="board-stage">
<img id="board-bg" alt="Board background" />
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<details class="json-block">
<summary>Raw result JSON</summary>
<pre id="raw-json">{}</pre>
</details>
</div>
</section>
</main>
<script src="/static/app.js?v=16"></script>
</body>
</html>