softchart / static /app.js
JacobLinCool's picture
BarScript: serve the fitted (non-uniform) bar timeline by default; report grid source, spread and fit quality in the UI
05d2106 verified
Raw
History Blame Contribute Delete
26.9 kB
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.3.1/+esm";
const ENDPOINT = "/generate_chart";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const ui = {
form: $("#generate-form"),
fileInput: $("#file-input"),
dropzone: $("#dropzone"),
sourceCard: $("#source-card"),
sourceAudio: $("#source-audio"),
fileName: $("#file-name"),
fileMeta: $("#file-meta"),
removeFile: $("#remove-file"),
fileError: $("#file-error"),
model: $("#model"),
experimentalModels: $("#experimental-models"),
modelNotices: $("#model-notices"),
modelNoticesTitle: $("#model-notices-title"),
modelNoticesList: $("#model-notices-list"),
level: $("#level"),
levelOutput: $("#level-output"),
bpm: $("#bpm"),
autoPlan: $("#auto-plan"),
useBeat: $("#use-beat"),
usePlanner: $("#use-planner"),
sampling: $("#sampling"),
samplingParameters: $("#sampling-parameters"),
temperature: $("#temperature"),
temperatureOutput: $("#temperature-output"),
topP: $("#top-p"),
topPOutput: $("#top-p-output"),
drumVolume: $("#drum-volume"),
drumVolumeOutput: $("#drum-volume-output"),
generateButton: $("#generate-button"),
connection: $("#connection"),
connectionLabel: $("#connection-label"),
processPanel: $("#process-panel"),
stageCanvas: $("#stage-canvas"),
statusChip: $("#status-chip"),
stageEyebrow: $("#stage-eyebrow"),
stageTitle: $("#stage-title"),
stageDetail: $("#stage-detail"),
progressNumber: $("#progress-number"),
progressTrack: $("#progress-track"),
progressBar: $("#progress-bar"),
queueLabel: $("#queue-label"),
cancelButton: $("#cancel-button"),
processError: $("#process-error"),
processErrorMessage: $("#process-error-message"),
retryButton: $("#retry-button"),
resultPanel: $("#result-panel"),
auditionCard: $("#audition-card"),
resultAudio: $("#result-audio"),
metricsGrid: $("#metrics-grid"),
previewBlock: $(".preview-block"),
chartTab: $("#chart-tab"),
planTab: $("#plan-tab"),
chartPreview: $("#chart-preview"),
planPreview: $("#plan-preview"),
chartImage: $("#chart-image"),
planImage: $("#plan-image"),
chartEmpty: $("#chart-empty"),
planEmpty: $("#plan-empty"),
downloadTja: $("#download-tja"),
downloadAudio: $("#download-audio"),
newChartButton: $("#new-chart-button"),
announcer: $("#announcer")
};
const stageDefaults = {
loading: {
eyebrow: "PREPARING",
title: "Preparing",
detail: "Loading SoftChart."
},
audio: {
eyebrow: "LISTENING",
title: "Listening",
detail: "Mapping rhythm, melody, and timbre."
},
beat: {
eyebrow: "ALIGNING",
title: "Finding the beat",
detail: "Finding beats and tempo."
},
plan: {
eyebrow: "PLANNING",
title: "Shaping the arc",
detail: "Planning density and climaxes."
},
generate: {
eyebrow: "COMPOSING",
title: "Writing the chart",
detail: "Writing playable Taiko patterns."
},
export: {
eyebrow: "RENDERING",
title: "Rendering",
detail: "Building the TJA and previews."
},
mix: {
eyebrow: "MIXING",
title: "Mixing",
detail: "Mixing Taiko with your track."
},
complete: {
eyebrow: "COMPLETE",
title: "Ready",
detail: "Play it or download it."
}
};
const metricLabels = {
bpm: "BPM",
notes: "Notes",
spans: "Drumroll sections",
timing: "Timing mode",
grid_rms_ms: "Grid error",
preview_hits: "Audition hits",
model: "Model",
difficulty: "Difficulty",
plan_source: "Song plan",
chart_grid: "Chart lattice",
grid_anchor: "Grid anchor",
density_bucket: "Density bucket",
sampling: "Sampling",
// The bar timeline: where the barlines fell, whether the song's tempo change
// survived into them, and how well the beat fit did. The server sends these
// as finished sentences -- the interface must not restate or soften them.
// They come last so the full-width rows land under the compact tiles.
beat_fit: "Beat fit",
bar_grid: "Bar grid",
tempo_map: "Tempo map",
grid_repairs: "Grid repairs"
};
// Metrics whose value is a sentence rather than a number or a short label.
// A compact tile would ellipsis them away, and these are exactly the ones a
// user has to be able to read: whether this song's tempo change was handled.
const wideMetrics = new Set(["beat_fit", "bar_grid", "tempo_map",
"grid_repairs"]);
// Filled from /api/capabilities: the server owns the model list and the model
// card's limitation text, so the interface never restates either from memory.
let capabilities = null;
let client = null;
let connectPromise = null;
let currentFile = null;
let sourceObjectUrl = "";
let currentJob = null;
let currentRun = 0;
let running = false;
let receivedTerminalPayload = false;
function announce(message) {
ui.announcer.textContent = "";
window.setTimeout(() => {
ui.announcer.textContent = message;
}, 40);
}
function setConnection(state, label) {
ui.connection.classList.toggle("is-ready", state === "ready");
ui.connection.classList.toggle("is-error", state === "error");
ui.connectionLabel.textContent = label;
}
async function connect() {
if (client) return client;
if (connectPromise) return connectPromise;
setConnection("connecting", "Connecting");
connectPromise = Client.connect(new URL(".", window.location.href).href, {
events: ["status", "data"]
}).then((connectedClient) => {
client = connectedClient;
setConnection("ready", "Service ready");
return client;
}).catch((error) => {
connectPromise = null;
setConnection("error", "Connection lost");
throw error;
});
return connectPromise;
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return "";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && value >= 1024; index += 1) {
value /= 1024;
unit = units[index];
}
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${unit}`;
}
function formatDuration(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return "";
const rounded = Math.round(seconds);
const minutes = Math.floor(rounded / 60);
const remainder = String(rounded % 60).padStart(2, "0");
return `${minutes}:${remainder}`;
}
function isSupportedAudio(file) {
return /\.(mp3|wav|flac|ogg)$/i.test(file.name);
}
function clearFileError() {
ui.fileError.hidden = true;
ui.fileError.textContent = "";
}
function showFileError(message) {
ui.fileError.textContent = message;
ui.fileError.hidden = false;
announce(message);
}
function clearFile() {
if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl);
sourceObjectUrl = "";
currentFile = null;
ui.fileInput.value = "";
ui.sourceAudio.removeAttribute("src");
ui.sourceAudio.load();
ui.sourceCard.hidden = true;
ui.dropzone.hidden = false;
clearFileError();
}
function selectFile(file) {
clearFileError();
if (!file) return;
if (!isSupportedAudio(file)) {
showFileError("Choose an MP3, WAV, FLAC, or OGG audio file.");
return;
}
if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl);
currentFile = file;
sourceObjectUrl = URL.createObjectURL(file);
ui.fileName.textContent = file.name;
ui.fileMeta.textContent = formatBytes(file.size);
ui.sourceAudio.src = sourceObjectUrl;
ui.dropzone.hidden = true;
ui.sourceCard.hidden = false;
ui.sourceAudio.addEventListener("loadedmetadata", () => {
const size = formatBytes(file.size);
const duration = formatDuration(ui.sourceAudio.duration);
ui.fileMeta.textContent = [size, duration].filter(Boolean).join(" · ");
}, { once: true });
announce(`Selected ${file.name}`);
}
function setRangeState(input, output, formatter) {
const min = Number(input.min);
const max = Number(input.max);
const value = Number(input.value);
const progress = ((value - min) / (max - min)) * 100;
input.style.setProperty("--range-progress", `${progress}%`);
output.textContent = formatter(value);
}
function updateRanges() {
setRangeState(ui.level, ui.levelOutput, (value) => `★ ${value.toFixed(0)}`);
setRangeState(ui.temperature, ui.temperatureOutput, (value) => value.toFixed(2));
setRangeState(ui.topP, ui.topPOutput, (value) => value.toFixed(2));
setRangeState(ui.drumVolume, ui.drumVolumeOutput, (value) => `${Math.round(value * 100)}%`);
}
function setSamplingEnabled(enabled) {
ui.temperature.disabled = !enabled;
ui.topP.disabled = !enabled;
ui.samplingParameters.setAttribute("aria-disabled", String(!enabled));
}
function normalizeStage(stage) {
const value = String(stage || "").trim().toLowerCase();
if (Object.hasOwn(stageDefaults, value)) return value;
throw new Error(`Unsupported generation stage: ${value}`);
}
function setStage(stage, data = {}) {
const normalized = normalizeStage(stage);
$$(".stage-visual", ui.stageCanvas).forEach((visual) => {
visual.classList.toggle("is-active", visual.dataset.stage === normalized);
});
const defaults = stageDefaults[normalized];
ui.stageEyebrow.textContent = defaults.eyebrow;
ui.stageTitle.textContent = typeof data.title === "string" && data.title.trim()
? data.title.trim()
: defaults.title;
ui.stageDetail.textContent = typeof data.detail === "string" && data.detail.trim()
? data.detail.trim()
: defaults.detail;
}
function progressPercent(progress) {
const numeric = Number(progress);
if (!Number.isFinite(numeric)) return 0;
const percent = numeric <= 1 ? numeric * 100 : numeric;
return Math.min(100, Math.max(0, percent));
}
function setProgress(progress) {
const percent = progressPercent(progress);
const rounded = Math.round(percent);
ui.progressBar.style.width = `${percent}%`;
ui.progressNumber.textContent = `${rounded}%`;
ui.progressTrack.setAttribute("aria-valuenow", String(rounded));
}
function setStatusChip(state, text) {
ui.statusChip.className = `status-chip is-${state}`;
ui.statusChip.textContent = text;
}
function setBusy(isBusy) {
running = isBusy;
ui.generateButton.disabled = isBusy;
ui.removeFile.disabled = isBusy;
ui.generateButton.classList.toggle("is-running", isBusy);
ui.stageCanvas.classList.toggle("is-running", isBusy);
ui.processPanel.setAttribute("aria-busy", String(isBusy));
$("span", ui.generateButton).textContent = isBusy ? "Generating chart" : "Generate chart";
ui.cancelButton.hidden = !isBusy;
}
function resetProcess() {
setBusy(false);
setStage("loading", {
title: "Waiting for your music",
detail: "Upload a song, choose a difficulty, and start creating your chart."
});
ui.stageEyebrow.textContent = "STANDBY";
setProgress(0);
setStatusChip("idle", "Ready");
ui.queueLabel.textContent = "Not started";
ui.processError.hidden = true;
ui.processErrorMessage.textContent = "";
}
function showProcessError(message) {
const friendlyMessage = typeof message === "string" && message.trim()
? message.trim()
: "An unexpected error occurred. Please try again shortly.";
setBusy(false);
setStatusChip("error", "Error");
ui.processErrorMessage.textContent = friendlyMessage;
ui.processError.hidden = false;
ui.queueLabel.textContent = "Generation incomplete";
announce(`Generation incomplete: ${friendlyMessage}`);
}
function handleQueueStatus(message) {
const status = String(message.stage || "").toLowerCase();
if (status === "pending") {
const position = Number(message.position);
const queueSize = Number(message.size);
const eta = Number(message.eta);
const parts = [];
if (Number.isFinite(position)) parts.push(`${Math.max(0, position)} job${position === 1 ? "" : "s"} ahead`);
if (Number.isFinite(queueSize)) parts.push(`${queueSize} job${queueSize === 1 ? "" : "s"} in queue`);
if (Number.isFinite(eta) && eta > 0) parts.push(`about ${Math.ceil(eta)} sec`);
ui.queueLabel.textContent = parts.length ? parts.join(" · ") : "Added to the generation queue";
setStatusChip("active", "Queued");
return;
}
if (status === "generating") {
ui.queueLabel.textContent = "The GPU is processing your song";
setStatusChip("active", "Generating");
return;
}
if (status === "error") {
showProcessError(message.message || "The server could not complete this generation.");
}
}
function fileDescriptor(value) {
if (!value) return { url: "", name: "" };
if (typeof value !== "object") return { url: "", name: "" };
return {
url: typeof value.url === "string" ? value.url : "",
name: typeof value.orig_name === "string" ? value.orig_name : ""
};
}
function resolvedFile(value) {
const descriptor = fileDescriptor(value);
if (!descriptor.url) return descriptor;
try {
const url = new URL(descriptor.url, window.location.href);
if (!["http:", "https:", "blob:"].includes(url.protocol)) return { url: "", name: "" };
return { url: url.href, name: descriptor.name };
} catch {
return { url: "", name: "" };
}
}
function setImage(image, emptyState, fileValue) {
const file = resolvedFile(fileValue);
if (file.url) {
image.src = file.url;
image.hidden = false;
emptyState.hidden = true;
return true;
}
image.removeAttribute("src");
image.hidden = true;
emptyState.hidden = false;
return false;
}
function setDownload(anchor, fileValue, defaultName) {
const file = resolvedFile(fileValue);
if (!file.url) {
anchor.hidden = true;
anchor.removeAttribute("href");
return false;
}
anchor.href = file.url;
anchor.download = file.name || defaultName;
anchor.hidden = false;
return true;
}
function metricValue(key, value) {
if (value === null || value === undefined || value === "") return "—";
if (key === "timing") {
if (value === "slot-exact") return "Grid-exact";
if (value === "time-quantized") return "Time-quantized";
}
if (key === "grid_rms_ms" && Number.isFinite(Number(value))) return `${Number(value).toFixed(1)} ms`;
if (key === "bpm" && Number.isFinite(Number(value))) return Number(value).toFixed(1);
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(2);
if (typeof value === "object") return JSON.stringify(value);
// Underscores are prettified only in single-token enum values. Sentences
// from the server quote identifiers verbatim (`inlier_frac`, `rms_ms`, a
// fit's own reason string); rewriting those would misquote the server.
const text = String(value);
return text.includes(" ") ? text : text.replaceAll("_", " ");
}
function renderMetrics(metrics) {
ui.metricsGrid.replaceChildren();
if (!metrics || typeof metrics !== "object" || Array.isArray(metrics)) {
ui.metricsGrid.hidden = true;
return;
}
// Only metrics the server actually reported. A key it omitted (the 2.0
// lattice fields on a 1.x run, say) is not a missing value to show as "—".
const entries = Object.entries(metricLabels)
.filter(([key]) => key in metrics)
.map(([key, label]) => [key, label, metrics[key]]);
ui.metricsGrid.hidden = entries.length === 0;
entries.forEach(([key, label, value]) => {
const wrapper = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
wrapper.className = wideMetrics.has(key) ? "metric metric--wide" : "metric";
term.textContent = label;
description.textContent = metricValue(key, value);
description.title = description.textContent;
wrapper.append(term, description);
ui.metricsGrid.append(wrapper);
});
}
function activatePreview(name, focus = false) {
const chartActive = name === "chart";
ui.chartTab.classList.toggle("is-active", chartActive);
ui.planTab.classList.toggle("is-active", !chartActive);
ui.chartTab.setAttribute("aria-selected", String(chartActive));
ui.planTab.setAttribute("aria-selected", String(!chartActive));
ui.chartTab.tabIndex = chartActive ? 0 : -1;
ui.planTab.tabIndex = chartActive ? -1 : 0;
ui.chartPreview.hidden = !chartActive;
ui.planPreview.hidden = chartActive;
if (focus) (chartActive ? ui.chartTab : ui.planTab).focus();
}
function renderResult(payload) {
const files = payload.files && typeof payload.files === "object" ? payload.files : {};
const audio = resolvedFile(files.audio);
if (audio.url) {
ui.resultAudio.src = audio.url;
ui.auditionCard.hidden = false;
} else {
ui.resultAudio.removeAttribute("src");
ui.resultAudio.load();
ui.auditionCard.hidden = true;
}
renderMetrics(payload.metrics);
const hasChart = setImage(ui.chartImage, ui.chartEmpty, files.chart_image);
const hasPlan = setImage(ui.planImage, ui.planEmpty, files.plan_image);
ui.previewBlock.hidden = !hasChart && !hasPlan;
ui.chartTab.hidden = !hasChart;
ui.planTab.hidden = !hasPlan;
activatePreview(hasChart ? "chart" : "plan");
setDownload(ui.downloadTja, files.tja, "softchart.tja");
setDownload(ui.downloadAudio, files.audio, "softchart-taiko-preview.wav");
ui.resultPanel.hidden = false;
}
function handlePayload(payload) {
if (!payload || typeof payload !== "object") return;
const kind = String(payload.kind || "progress").toLowerCase();
// Gradio may replay the generator's final yield before its terminal status.
if (kind === "complete" && receivedTerminalPayload) return;
if (kind === "error") {
receivedTerminalPayload = true;
showProcessError(payload.detail || payload.title || "The server could not complete this generation.");
return;
}
setStage(payload.stage, payload);
setProgress(payload.progress);
ui.processError.hidden = true;
if (kind === "complete") {
receivedTerminalPayload = true;
setStage("complete", payload);
setProgress(100);
setBusy(false);
setStatusChip("complete", "Complete");
ui.queueLabel.textContent = "All outputs are ready";
renderResult(payload);
announce("The chart, audition audio, and previews are ready");
}
}
function selectedCourseValue() {
const selected = $('input[name="course"]:checked', ui.form);
return selected ? selected.value : "oni";
}
function selectedModelValue() {
return ui.model ? ui.model.value : "v1.7";
}
// The model card's wording, verbatim. Anything shown here is a defect the
// tester is meant to recognise as already known, so it is never paraphrased.
function renderNotices() {
if (!capabilities || !ui.modelNotices) return;
const model = (capabilities.models || []).find((m) => m.value === selectedModelValue());
const course = selectedCourseValue();
const courseNotices = (capabilities.course_notices || {})[course] || [];
const plannerOn = ui.usePlanner ? ui.usePlanner.checked : false;
const items = [];
if (model && model.experimental) {
(model.limitations || []).forEach((entry) => items.push(entry));
(model.notes || []).forEach((text) => items.push({ text }));
}
courseNotices.forEach((entry) => {
if (entry.when === "planner" && !plannerOn) return;
if (entry.when === "planner" && model && model.experimental) return;
items.push({ text: entry.text });
});
ui.modelNoticesList.replaceChildren();
items.forEach((entry) => {
const li = document.createElement("li");
if (entry.section) {
const tag = document.createElement("span");
tag.className = "notice-section";
tag.textContent = entry.section;
li.append(tag);
}
li.append(document.createTextNode(entry.text));
ui.modelNoticesList.append(li);
});
ui.modelNoticesTitle.textContent = model && model.experimental
? "Experimental — known limitations"
: "Known limitations";
ui.modelNotices.hidden = items.length === 0;
}
async function loadCapabilities() {
const response = await fetch("/api/capabilities", { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error(`capabilities ${response.status}`);
capabilities = await response.json();
const experimental = (capabilities.models || []).filter((m) => m.experimental);
if (ui.experimentalModels && experimental.length) {
ui.experimentalModels.replaceChildren();
experimental.forEach((model) => {
const option = document.createElement("option");
option.value = model.value;
option.textContent = model.label;
ui.experimentalModels.append(option);
});
if (experimental[0].group) ui.experimentalModels.label = experimental[0].group;
ui.experimentalModels.disabled = false;
ui.experimentalModels.hidden = false;
}
renderNotices();
}
function requestValues() {
const selectedCourse = $('input[name="course"]:checked', ui.form);
const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value);
return [
handle_file(currentFile),
currentFile.name,
selectedCourse ? selectedCourse.value : "oni",
Number.parseInt(ui.level.value, 10),
Number.isFinite(bpm) ? bpm : 0,
ui.autoPlan.checked,
ui.useBeat.checked,
ui.usePlanner.checked,
ui.sampling.checked,
Number(ui.temperature.value),
Number(ui.topP.value),
Number(ui.drumVolume.value),
ui.model ? ui.model.value : "v1.7"
];
}
async function generate(event) {
event.preventDefault();
if (running) return;
clearFileError();
if (!currentFile) {
showFileError("Choose a song before generating a chart.");
ui.dropzone.focus();
return;
}
const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value);
if (!Number.isFinite(bpm) || (bpm !== 0 && (bpm < 30 || bpm > 400))) {
showFileError("Manual BPM must be between 30 and 400, or left blank for automatic detection.");
ui.bpm.focus();
return;
}
const runId = ++currentRun;
receivedTerminalPayload = false;
ui.resultPanel.hidden = true;
ui.processError.hidden = true;
setBusy(true);
setStage("loading");
setProgress(1);
setStatusChip("active", "Preparing");
ui.queueLabel.textContent = "Connecting to the generation service";
ui.processPanel.scrollIntoView({ behavior: "smooth", block: "start" });
announce("Chart generation submitted");
try {
const app = await connect();
if (runId !== currentRun) return;
currentJob = app.submit(ENDPOINT, requestValues());
for await (const message of currentJob) {
if (runId !== currentRun) break;
if (message.type === "status") {
handleQueueStatus(message);
} else if (message.type === "data") {
const payload = Array.isArray(message.data) ? message.data[0] : message.data;
handlePayload(payload);
}
}
if (runId === currentRun && running && !receivedTerminalPayload) {
showProcessError("The generation connection ended before a complete result arrived. Please try again.");
}
} catch (error) {
if (runId !== currentRun) return;
const message = error instanceof Error ? error.message : String(error);
showProcessError(message || "Could not connect to the SoftChart service.");
setConnection("error", "Connection lost");
} finally {
if (runId === currentRun) currentJob = null;
}
}
async function cancelGeneration() {
if (!running) return;
const job = currentJob;
currentRun += 1;
currentJob = null;
if (job && typeof job.cancel === "function") {
try {
await job.cancel();
} catch {
// The local job is still detached below even if it already started remotely.
}
}
setBusy(false);
setStatusChip("idle", "Cancelled");
setStage("loading", {
title: "Generation cancelled",
detail: "Adjust the settings whenever you are ready to try again."
});
ui.stageEyebrow.textContent = "CANCELLED";
ui.queueLabel.textContent = "Stopped receiving generation results";
announce("Generation cancelled");
}
function bindEvents() {
ui.form.addEventListener("submit", generate);
ui.fileInput.addEventListener("change", () => selectFile(ui.fileInput.files[0]));
ui.dropzone.addEventListener("click", () => ui.fileInput.click());
ui.dropzone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
ui.fileInput.click();
}
});
["dragenter", "dragover"].forEach((name) => {
ui.dropzone.addEventListener(name, (event) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
ui.dropzone.classList.add("is-dragging");
});
});
["dragleave", "dragend"].forEach((name) => {
ui.dropzone.addEventListener(name, () => ui.dropzone.classList.remove("is-dragging"));
});
ui.dropzone.addEventListener("drop", (event) => {
event.preventDefault();
ui.dropzone.classList.remove("is-dragging");
selectFile(event.dataTransfer?.files?.[0]);
});
ui.removeFile.addEventListener("click", clearFile);
[ui.level, ui.temperature, ui.topP, ui.drumVolume].forEach((range) => {
range.addEventListener("input", updateRanges);
});
ui.sampling.addEventListener("change", () => setSamplingEnabled(ui.sampling.checked));
if (ui.model) ui.model.addEventListener("change", renderNotices);
if (ui.usePlanner) ui.usePlanner.addEventListener("change", renderNotices);
$$('input[name="course"]', ui.form).forEach((radio) => {
radio.addEventListener("change", renderNotices);
});
ui.cancelButton.addEventListener("click", cancelGeneration);
ui.retryButton.addEventListener("click", () => ui.form.requestSubmit());
ui.newChartButton.addEventListener("click", () => {
ui.resultPanel.hidden = true;
clearFile();
resetProcess();
ui.form.scrollIntoView({ behavior: "smooth", block: "start" });
});
ui.chartTab.addEventListener("click", () => activatePreview("chart"));
ui.planTab.addEventListener("click", () => activatePreview("plan"));
$$(".preview-tab").forEach((tab) => {
tab.addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
event.preventDefault();
const next = tab === ui.chartTab ? "plan" : "chart";
const target = next === "chart" ? ui.chartTab : ui.planTab;
if (!target.hidden) activatePreview(next, true);
});
});
window.addEventListener("beforeunload", () => {
if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl);
});
}
bindEvents();
updateRanges();
setSamplingEnabled(ui.sampling.checked);
resetProcess();
loadCapabilities().catch(() => {
// The seven published models are in the markup already; only the
// experimental entry and the limitation text depend on this call.
});
connect().catch(() => {
ui.queueLabel.textContent = "We will reconnect when you start generation";
});