lingo-bridge / static /app.js
Iona401's picture
Disable wheel-zoom so embedded 3D view does not expand on scroll
c5a0968 verified
Raw
History Blame Contribute Delete
7.97 kB
import { CardsView } from "/static/view3d.js?v=21";
// ---- shared colour helpers (purple -> cyan) --------------------------------
export const SRC_RGB = [168, 85, 247];
export const TGT_RGB = [31, 224, 208];
export function mixRGB(t) {
t = Math.max(0, Math.min(1, t));
return SRC_RGB.map((s, i) => Math.round(s + (TGT_RGB[i] - s) * t));
}
export function mixCss(t, a = 1) {
const [r, g, b] = mixRGB(t);
return `rgba(${r},${g},${b},${a})`;
}
const $ = (s) => document.querySelector(s);
const state = {
data: null,
cards: null,
hover: null,
playing: false,
};
// ---- bootstrap -------------------------------------------------------------
async function boot() {
const st = await fetch("/api/status").then((r) => r.json());
$("#badge-llm").textContent = "LLM: " + (st.llm_label || st.llm_backend);
$("#badge-tts").textContent = "TTS: " + (st.tts_label || st.tts_backend);
const src = $("#source"), tgt = $("#target");
st.languages.forEach((l) => {
src.add(new Option(l, l));
tgt.add(new Option(l, l));
});
src.value = "English";
tgt.value = st.languages.includes("Spanish") ? "Spanish" : st.languages[1];
state.cards = new CardsView($("#view-cards"), hooks);
window.__cards = state.cards; // debug hook for layout inspection
$("#go").onclick = run;
$("#input").addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") run();
});
$("#swap").onclick = () => {
const a = src.value; src.value = tgt.value; tgt.value = a;
};
// Load a random curated example. Uses a precomputed result when available,
// so this renders instantly with NO LLM call (falls back to live translate).
$("#example").onclick = async () => {
try {
const data = await fetch("/api/examples?random=true").then((r) => r.json());
if (data.result) {
const result = data.result;
$("#input").value = result.source_text;
src.value = result.source_lang;
tgt.value = result.target_lang;
$("#hint").classList.add("hidden");
updateControlsState();
state.data = result;
state.cards.render(result);
} else if (data.example) {
$("#input").value = data.example.text;
src.value = data.example.source;
tgt.value = data.example.target;
run();
}
} catch (e) {
console.error("example load failed", e);
}
};
// Perspective control wires
const slider = $("#perspective-slider");
slider.oninput = (e) => {
const val = parseFloat(e.target.value);
state.cards.setPerspective(val, false);
};
const zoomSlider = $("#zoom-slider");
zoomSlider.oninput = (e) => {
const val = parseFloat(e.target.value);
state.cards.setZoom(val);
};
$("#btn-zoom-in").onclick = () => {
state.cards.zoomIn();
};
$("#btn-zoom-out").onclick = () => {
state.cards.zoomOut();
};
$("#btn-rotate-left").onclick = () => {
state.cards.rotateLeft();
};
$("#btn-rotate-right").onclick = () => {
state.cards.rotateRight();
};
$("#btn-move-forward").onclick = () => {
state.cards.moveForward();
};
$("#btn-move-backward").onclick = () => {
state.cards.moveBackward();
};
$("#playall").onclick = playAll;
updateControlsState();
}
function updateControlsState() {
const isWelcome = !$("#hint").classList.contains("hidden");
if (state.cards) {
state.cards.setControlsEnabled(!isWelcome);
}
const pSlider = $("#perspective-slider");
if (pSlider) pSlider.disabled = isWelcome;
const playAllBtn = $("#playall");
if (playAllBtn) playAllBtn.disabled = isWelcome;
const zoomSlider = $("#zoom-slider");
if (zoomSlider) zoomSlider.disabled = isWelcome;
document.querySelectorAll(".zoom-controls button").forEach((btn) => {
btn.disabled = isWelcome;
});
const pControl = $(".perspective-control");
const zoomControls = $(".zoom-controls");
if (isWelcome) {
if (pControl) { pControl.style.opacity = "0.35"; pControl.style.pointerEvents = "none"; }
if (playAllBtn) { playAllBtn.style.opacity = "0.35"; playAllBtn.style.pointerEvents = "none"; }
if (zoomControls) { zoomControls.style.opacity = "0.25"; zoomControls.style.pointerEvents = "none"; }
} else {
if (pControl) { pControl.style.opacity = "1"; pControl.style.pointerEvents = "auto"; }
if (playAllBtn) { playAllBtn.style.opacity = "1"; playAllBtn.style.pointerEvents = "auto"; }
if (zoomControls) { zoomControls.style.opacity = "1"; zoomControls.style.pointerEvents = "auto"; }
}
}
const hooks = {
onHover: (unit) => {
state.hover = unit;
state.cards.setHover(unit);
},
onPlay: (layerIdx) => {
if (state.playing) stopPlaying();
playLayer(layerIdx);
},
onPerspectiveChange: (t) => {
// Called by CardsView when animating perspective automatically
const slider = $("#perspective-slider");
slider.value = t;
},
onZoomChange: (t) => {
const zoomSlider = $("#zoom-slider");
if (zoomSlider) {
zoomSlider.value = t;
}
}
};
async function run() {
const text = $("#input").value.trim();
if (!text) return;
$("#hint").classList.add("hidden");
updateControlsState();
showLoader(true, "Decomposing & aligning phrases…");
try {
const data = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
source: $("#source").value,
target: $("#target").value,
}),
}).then((r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
state.data = data;
state.cards.render(data);
} catch (e) {
alert("Translation failed: " + e.message);
} finally {
showLoader(false);
}
}
function showLoader(on, msg) {
$("#loader").classList.toggle("hidden", !on);
if (msg) $("#loadmsg").textContent = msg;
}
// ---- audio -----------------------------------------------------------------
const player = $("#player");
let currentPlayResolve = null;
function langForLayer(idx) {
if (!state.data) return "English";
return idx === 0 ? state.data.source_lang : state.data.target_lang;
}
async function ttsUrl(text, lang) {
const r = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, lang }),
}).then((r) => r.json());
return r.url;
}
async function playLayer(idx) {
if (!state.data) return;
if (currentPlayResolve) {
currentPlayResolve();
currentPlayResolve = null;
}
const layer = state.data.layers[idx];
highlightLayer(idx);
// Precomputed examples ship per-layer audio -> play instantly, no TTS call.
const url = layer.audio || (await ttsUrl(layer.text, langForLayer(idx)));
player.src = url;
await player.play().catch(() => {});
return new Promise((res) => {
currentPlayResolve = res;
player.onended = () => {
currentPlayResolve = null;
if (!state.playing) {
highlightLayer(-1);
}
res();
};
});
}
function highlightLayer(idx) {
state.cards.highlightLayer(idx);
}
function stopPlaying() {
state.playing = false;
player.pause();
player.src = "";
if (currentPlayResolve) {
currentPlayResolve();
currentPlayResolve = null;
}
$("#playall").classList.remove("playing");
$("#playall").textContent = "▶ Play all layers";
highlightLayer(-1);
}
async function playAll() {
if (!state.data) return;
if (state.playing) {
stopPlaying();
return;
}
state.playing = true;
$("#playall").classList.add("playing");
$("#playall").textContent = "■ Stop playing";
for (let i = 0; i < state.data.layers.length; i++) {
if (!state.playing) break;
await playLayer(i);
}
if (state.playing) {
state.playing = false;
$("#playall").classList.remove("playing");
$("#playall").textContent = "▶ Play all layers";
highlightLayer(-1);
}
}
boot();