/* ============================================================
IN C · capa de conjunto · cliente
Orquesta de Laptops UC (OLUC) · IEE2003
Se carga DESPUÉS del script principal de index.html y no toca el
motor de audio ni la selección de patrones: solo LEE iPat, sonando y
pulso (variables globales del script principal), las reporta al
servidor cuando cambian y dibuja el panel del conjunto.
Si el servidor no está, o se cae, la obra sigue sonando igual: esta
capa solo muestra que perdió la conexión y reintenta sola.
Protocolo de mensajes: ver README.md.
============================================================ */
(function () {
"use strict";
const PUERTO_DEF = 8080;
const JUNTO = 3; /* Riley: mantenerse a no más de 2 o 3 patrones del resto */
const VENTANA_TEND = 20000; /* la tendencia de la dispersión compara con hace 20 s */
const SIN_SENAL_MS = 2500; /* el servidor difunde 4 veces por segundo: 2,5 s sin nada = caído */
const K = "oluc.inc.";
/* ---------- almacenamiento local (puede fallar: modo privado, file://) ---------- */
const ls = {
get(k) { try { return localStorage.getItem(K + k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem(K + k, v); } catch (e) { } },
};
function nuevoId() {
try { const a = new Uint32Array(2); crypto.getRandomValues(a); return a[0].toString(36) + a[1].toString(36); }
catch (e) { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
}
const miId = ls.get("id") || (() => { const i = nuevoId(); ls.set("id", i); return i; })();
let miNombre = ls.get("nombre") || "";
const sinNombre = !miNombre;
if (!miNombre) miNombre = "laptop " + miId.slice(0, 3);
function normHost(s) {
s = String(s || "").trim().replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "");
if (!s) return "";
if (!/:\d+$/.test(s)) s += ":" + PUERTO_DEF;
return s;
}
const qs = new URLSearchParams(location.search);
let host = normHost(qs.get("servidor")) || normHost(ls.get("servidor")) ||
(/^https?:$/.test(location.protocol) ? location.host : "");
/* ---------- lectura del instrumento (sin tocarlo) ---------- */
function leer() {
try { return { patron: iPat + 1, sonando: !!sonando, pulso: !!pulso }; } /* globales del script principal */
catch (e) { return null; }
}
/* ---------- color: interpolación en OKLab ---------- */
const hex2rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255);
const lin = c => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
const gam = c => c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
function aLab(hx) {
const [r, g, b] = hex2rgb(hx).map(lin);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s];
}
function deLab([L, A, B]) {
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
const s = (L - 0.0894841775 * A - 1.2914855480 * B) ** 3;
const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s];
return "#" + rgb.map(c => Math.round(Math.max(0, Math.min(1, gam(Math.max(0, c)))) * 255).toString(16).padStart(2, "0")).join("");
}
function colorDe(pal, patron) { /* patrón 1 = un extremo, 53 = el otro */
const c = pal.colores, t = (Math.max(1, Math.min(53, patron)) - 1) / 52 * (c.length - 1);
const i = Math.min(c.length - 2, Math.floor(t)), f = t - i;
const a = aLab(c[i]), b = aLab(c[i + 1]);
return deLab(a.map((v, k) => v + (b[k] - v) * f));
}
const luminancia = hx => { const [r, g, b] = hex2rgb(hx).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
const esClaro = hx => luminancia(hx) > 0.18; /* sobre esto, el texto oscuro contrasta más que el claro */
const PALETAS = [
{ nombre: "amanecer", colores: ["#141a46", "#6a2c8c", "#e0505e", "#f7c65a"] },
{ nombre: "océano", colores: ["#07182e", "#0d5c78", "#27a99a", "#bfeccc"] },
{ nombre: "brasa", colores: ["#1a0905", "#72200f", "#d4521d", "#ffc46a"] },
{ nombre: "bosque", colores: ["#0c1c12", "#2a5530", "#86a53a", "#e3e09a"] },
{ nombre: "noche a día", colores: ["#05070d", "#22314f", "#6f8fb5", "#dfe8f0"] },
{ nombre: "espectro", colores: ["#d7263d", "#f49d37", "#e8e337", "#3fb950", "#1fa3c8", "#3f51d4", "#8e3fd4"] },
];
/* ---------- estado ---------- */
let ws = null, conectado = false, intento = 0, tReintento = null, proxIntento = 0;
let ultimoMsg = 0, reemplazado = false, control = false, yaConecto = false, tIntento = 0;
let relojBase = { estado: "espera", ms: 0, t0: performance.now() };
let tabla = null, tTabla = 0, firmaTabla = "";
let paleta = null;
let guia = { modo: "apagada", segundos: 45, minutos: 45 };
let direcciones = []; /* solo en la laptop servidora */
let enviado = null; /* último estado reportado */
const historia = []; /* {t, d}: dispersión en el tiempo */
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const medio = x => Number.isInteger(x) ? String(x) : (Math.floor(x) || "") + "½";
const mmss = ms => { const s = Math.max(0, Math.floor(ms / 1000)); return String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0"); };
/* ============================================================
Red
============================================================ */
/* ensayo (ensayo.js): en vez del servidor, una maestra simulada en esta misma
página. Recibe lo mismo que recibiría el servidor y contesta con los mismos
mensajes, así el resto de esta capa no se entera de la diferencia. */
let local = null;
function enviar(o) {
if (local) { try { local.recibir(o); } catch (e) { console.warn("conjunto (ensayo):", e); } return; }
if (ws && conectado) try { ws.send(JSON.stringify(o)); } catch (e) { }
}
function entrante(m) {
ultimoMsg = performance.now();
try { recibir(m); } catch (e) { console.warn("conjunto:", e); }
}
function soltarSocket() {
if (!ws) return;
const s = ws; ws = null; conectado = false;
s.onopen = s.onmessage = s.onclose = s.onerror = null;
try { s.close(); } catch (e) { }
}
function conectar() {
clearTimeout(tReintento); tReintento = null;
if (local) return;
soltarSocket();
if (!host) { pintar(); return; }
let s;
try { s = new WebSocket("ws://" + host + "/conjunto"); }
catch (e) { reintentar(); pintar(); return; }
ws = s; tIntento = performance.now();
s.onopen = () => {
conectado = true; intento = 0; ultimoMsg = performance.now(); yaConecto = true;
const e = leer() || { patron: 1, sonando: false, pulso: false };
enviado = e;
enviar(Object.assign({ t: "hola", id: miId, nombre: miNombre }, e));
pintar();
};
s.onmessage = ev => {
let m; try { m = JSON.parse(ev.data); } catch (e) { ultimoMsg = performance.now(); return; }
entrante(m);
};
s.onclose = () => { if (ws !== s) return; ws = null; conectado = false; if (!reemplazado) reintentar(); pintar(); };
s.onerror = () => { }; /* siempre sigue un onclose */
}
function reintentar() {
clearTimeout(tReintento);
if (local) return;
const d = Math.min(5000, 500 * Math.pow(2, intento)) * (0.75 + Math.random() * 0.5);
intento++; proxIntento = performance.now() + d;
tReintento = setTimeout(conectar, d);
}
function recibir(m) {
switch (m.t) {
case "bienvenida": control = !!m.control; direcciones = Array.isArray(m.direcciones) ? m.direcciones : []; construirControl(); break;
case "reloj": relojBase = { estado: m.estado, ms: +m.ms || 0, t0: performance.now() }; pintarReloj(); break;
case "paleta": paleta = m.paleta && Array.isArray(m.paleta.colores) ? m.paleta : null; aplicarColor(true); pintarPaletaCtl(); firmaTabla = ""; pintarTabla(); break;
case "guia": if (m.guia && m.guia.modo) { guia = m.guia; sincronizarGuiaCtl(); firmaTabla = ""; pintarGuia(); pintarTabla(); } break;
case "tabla": tabla = m; tTabla = performance.now(); pintarTabla(); break;
case "reemplazado": reemplazado = true; soltarSocket(); pintar(); break;
}
}
/* vigilancia: el servidor difunde 4 veces por segundo. Si deja de llegar
(Wi-Fi caído, laptop servidora dormida) el socket puede quedar abierto
un buen rato sin avisar; no esperamos al sistema y reconectamos. */
setInterval(() => {
if (local) return; /* en el ensayo no hay red que vigilar */
const ahora = performance.now();
if (conectado && ahora - ultimoMsg > SIN_SENAL_MS) { soltarSocket(); reintentar(); pintar(); }
/* un intento que no termina de conectar (servidor colgado, IP que no responde) se abandona a los 4 s */
else if (ws && !conectado && ahora - tIntento > 4000) { soltarSocket(); reintentar(); pintar(); }
}, 500);
/* reporte: se mira el instrumento 10 veces por segundo y se envía solo si cambió */
let colorPatron = 0;
setInterval(() => {
const e = leer(); if (!e) return;
if (e.patron !== colorPatron) { colorPatron = e.patron; aplicarColor(false); }
if (!enviado || e.patron !== enviado.patron || e.sonando !== enviado.sonando || e.pulso !== enviado.pulso) {
if (conectado) { enviado = e; enviar(Object.assign({ t: "estado" }, e)); }
pintarTabla();
}
}, 100);
/* ============================================================
Interfaz
============================================================ */
const CSS = `
#cj{border-left:1px solid var(--line);background:var(--panel);overflow-y:auto;min-height:0;min-width:0;font-size:12px}
#cj section{padding:9px 14px 10px;border-bottom:1px solid var(--line)}
#cj h3{margin:0 0 6px;font-size:10.5px;letter-spacing:.12em;color:var(--dim);font-weight:400;text-transform:uppercase;
display:flex;justify-content:space-between;align-items:baseline;gap:8px}
#cj h3 span{letter-spacing:.04em;text-transform:none}
#cj .big{font-size:38px;font-weight:700;line-height:1.05;letter-spacing:.03em;font-variant-numeric:tabular-nums}
#cj .dim{color:var(--dim)}
#cj .fila{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:7px}
#cj button{padding:3px 8px;font-size:11.5px}
#cj button:disabled{opacity:.35;cursor:default}
#cj button.aviso{border-color:var(--warn);color:var(--warn)}
#cj input[type=text],#cj input[type=number]{background:var(--cj-campo);color:var(--tx);border:1px solid var(--line);border-radius:4px;
padding:4px 7px;font:12px var(--mono);user-select:text;-webkit-user-select:text;min-width:0}
#cj input:focus{outline:none;border-color:var(--acc2)}
#cj input[type=number]{width:52px}
#cj input[type=color]{width:34px;height:24px;padding:0;border:1px solid var(--line);background:none;border-radius:4px}
#cj select{font-size:11.5px}
#cjRed{padding:8px 14px;border-bottom:1px solid var(--line);font-size:11.5px;color:var(--dim)}
#cjRed .p{display:inline-block;width:8px;height:8px;border-radius:50%;background:#3a434e;margin-right:6px;vertical-align:1px}
#cjRed.ok .p{background:var(--ok);box-shadow:0 0 6px var(--ok)}
#cjRed.caido{background:#3a1512;color:#f3c1b9;border-bottom-color:var(--warn)}
#cjRed.caido b{color:#fff;letter-spacing:.06em}
#cjRed.ens{background:rgba(232,178,90,.10);color:var(--tx);border-left:4px solid #e8b25a}
#cjRed.ens .p{background:#e8b25a}
body.cj-ensayo #cjDir{display:none !important}
#cj.caido .datos{opacity:.38;filter:grayscale(1)}
#cjTag{font-size:10px;letter-spacing:.1em;padding:1px 6px;border-radius:3px;border:1px solid var(--line);color:var(--dim)}
#cjTag.manual{background:var(--acc2);border-color:var(--acc2);color:#1a1204;font-weight:700}
#cjDisp .big.junto{color:var(--ok)} #cjDisp .big.lejos{color:var(--warn)}
#cjDisp .est{font-size:12.5px;font-weight:700}
#cjTabla{width:100%;border-collapse:collapse;margin-top:4px}
#cjTabla td{padding:2px 4px;border-top:1px solid #1d232a;white-space:nowrap}
#cjTabla td.n{overflow:hidden;text-overflow:ellipsis;max-width:130px}
#cjTabla td.r{text-align:right;font-variant-numeric:tabular-nums}
#cjTabla tr.yo td{color:var(--cj-yo);font-weight:700}
#cjTabla tr.off td{color:#5b6572}
#cjTabla .e{display:inline-block;width:8px;height:8px;border-radius:50%;border:1.5px solid var(--dim)}
#cjTabla .e.son{background:var(--tx);border-color:var(--tx)}
#cjTabla .e.pul{border-radius:1px;background:var(--acc);border-color:var(--acc)}
#cjTabla .e.off{border-style:dashed;border-color:#4a535e}
#cj details summary{cursor:pointer;color:var(--dim);font-size:11px;list-style:none}
#cj details summary::-webkit-details-marker{display:none}
#cj details label{display:block;margin:8px 0 3px}
#cj .ctl-only{display:none} #cj.control .ctl-only{display:block}
#cj .ctl-only{margin-top:8px;padding-top:7px;border-top:1px dashed #2b333c}
#cj .ctl-only>.dim{font-size:10px;letter-spacing:.1em;text-transform:uppercase}
/* ---- laptop maestra: que se note ---- */
.cj-chip{display:inline-block;background:#e8b25a;color:#1a1204;font-weight:700;letter-spacing:.2em;font-size:11px;
padding:3px 9px;border-radius:3px;white-space:nowrap}
#cjMaestraTag{display:none} body.cj-maestra #cjMaestraTag{display:inline-block}
#cj.control section .ctl-only{border-left:3px solid #e8b25a;padding-left:9px;border-top:0;margin-top:10px}
#cj.control #cjPal{border-left:3px solid #e8b25a}
#cj #cjDir{margin-top:0;border-top:0;border-left:4px solid #e8b25a;background:rgba(232,178,90,.10);padding-top:10px}
#cjSplashMaestra{display:none;margin-top:14px} body.cj-maestra #cjSplashMaestra{display:block}
#cjBtn .p{display:inline-block;width:7px;height:7px;border-radius:50%;background:#3a434e;margin-right:5px;vertical-align:1px}
#cjBtn.ok .p{background:var(--ok)} #cjBtn.caido .p{background:var(--warn)}
#cjBtn.on{background:#1b2233;border-color:var(--acc);color:var(--tx);font-weight:400}
body.cj-color #cjBtn.on{background:rgba(var(--ink-rgb),.16);border-color:var(--ink)}
#cj svg{display:block;width:100%;height:auto;overflow:visible}
#cj{--cj-band:rgba(143,158,240,.26);--cj-bandl:#8f9ef0;--cj-yo:#e8b25a;--cj-campo:#1b2128;--cj-guia:#7fc47f}
/* ---- modo color: la paleta inunda la pantalla completa; todo lo demas queda
transparente encima y la tinta (texto, pentagrama) pasa a clara u oscura ---- */
body.cj-color{--bg:var(--cj-fondo);--panel:transparent;--line:rgba(var(--ink-rgb),.22);--tx:var(--ink);
--dim:rgba(var(--ink-rgb),.66);--acc:var(--ink);--acc2:var(--act);transition:background-color 4s linear}
body.cj-color #cj{--cj-band:rgba(var(--ink-rgb),.16);--cj-bandl:rgba(var(--ink-rgb),.6);--cj-yo:var(--act);--cj-campo:rgba(var(--ink-rgb),.08);--cj-guia:var(--ink)}
body.cj-color #partitura{background:rgba(var(--ink-rgb),.05)}
body.cj-color select,body.cj-color button,body.cj-color input[type=file],body.cj-color kbd{background:rgba(var(--ink-rgb),.08);color:var(--ink)}
body.cj-color button.on{background:var(--ink);color:var(--cj-fondo);border-color:var(--ink)}
body.cj-color option{background:#14181d;color:#dfe5ec}
body.cj-color .pat:hover{background:rgba(var(--ink-rgb),.08)}
body.cj-color .pat.sel{background:rgba(var(--ink-rgb),.14)}
body.cj-color .pat .m{color:rgba(var(--ink-rgb),.6)}
body.cj-color .stf{stroke:rgba(var(--ink-rgb),.55)} body.cj-color .ldg{stroke:rgba(var(--ink-rgb),.7)}
body.cj-color .nh,body.cj-color .acc,body.cj-color .clef{fill:var(--ink)}
body.cj-color .nh.hueca{fill:none;stroke:var(--ink)}
body.cj-color .stm,body.cj-color .bm{stroke:var(--ink);fill:var(--ink)}
body.cj-color .tie{stroke:var(--ink)} body.cj-color .rst{fill:rgba(var(--ink-rgb),.75)}
body.cj-color .nh.act,body.cj-color .rst.act{fill:var(--act)} body.cj-color .nh.hueca.act{fill:none;stroke:var(--act)}
body.cj-color .stm.act,body.cj-color .bm.act{stroke:var(--act);fill:var(--act)}
body.cj-color .led{background:rgba(var(--ink-rgb),.2)}
body.cj-color #cjTabla td{border-top-color:rgba(var(--ink-rgb),.12)}
body.cj-color #cjTabla tr.off td{color:rgba(var(--ink-rgb),.4)}
`;
function construir() {
const st = document.createElement("style"); st.textContent = CSS; document.head.appendChild(st);
const main = document.querySelector("main");
const panel = document.createElement("aside");
panel.id = "cj";
panel.innerHTML = `
LAPTOP MAESTRA
Esta laptop corre el servidor. Los controles con borde ámbar solo aparecen aquí.
las demás laptops abren
reloj de la obra
00:00
maestra
guía: deberían estar en
–
guía · maestra
segundos por patrón
minutos en total
posición del conjunto mediana
–de 53
anulación manual · maestra
dispersión
–
laptops
paleta de fondo laptop maestra
→
Si abriste esta página desde el servidor (http://…:8080) no hay nada que configurar.
`;
main.appendChild(panel);
/* marca de laptop maestra junto al título y en la portada */
const h1 = document.querySelector("header h1");
if (h1) h1.insertAdjacentHTML("afterend", `MAESTRA`);
const caja = document.querySelector("#splash .box h2");
if (caja) caja.insertAdjacentHTML("afterend", `
LAPTOP MAESTRAesta laptop corre el servidor y controla reloj, posición y paleta
`);
/* botón en la barra superior para mostrar u ocultar el panel */
const btn = document.createElement("button");
btn.id = "cjBtn"; btn.title = "panel del conjunto: reloj de la obra y estado de la conexión (clic para mostrar u ocultar)";
btn.innerHTML = `00:00`;
const ref = $("btnPulso"); ref.parentNode.insertBefore(btn, ref);
btn.onclick = () => { mostrarPanel(panel.hidden); ls.set("panel", panel.hidden ? "0" : "1"); };
mostrarPanel(ls.get("panel") !== "0");
/* los campos de texto no deben disparar el instrumento (espacio, números, flechas) */
panel.querySelectorAll("input[type=text],input[type=number]").forEach(inp => {
["keydown", "keyup", "keypress"].forEach(tipo => inp.addEventListener(tipo, e => {
e.stopPropagation();
if (tipo === "keydown" && (e.key === "Enter" || e.key === "Escape")) {
inp.blur(); /* al perder el foco se dispara 'change' si hubo cambios */
}
}));
});
$("cjNombre").value = miNombre;
$("cjNombre").onchange = () => {
const n = $("cjNombre").value.trim().slice(0, 24); if (!n) { $("cjNombre").value = miNombre; return; }
miNombre = n; ls.set("nombre", n); enviar({ t: "nombre", nombre: n }); pintar();
};
$("cjHost").value = host;
const cambiarHost = () => {
host = normHost($("cjHost").value); $("cjHost").value = host; ls.set("servidor", host);
reemplazado = false; intento = 0; conectar();
};
$("cjHost").onchange = cambiarHost;
$("cjConectar").onclick = cambiarHost;
if (sinNombre || !host) $("cjCfg").open = true;
/* control (solo lo acepta el servidor si la página corre en la laptop servidora) */
$("cjIni").onclick = () => enviar({ t: "reloj", accion: "iniciar" });
$("cjDet").onclick = () => enviar({ t: "reloj", accion: "detener" });
let confirma = -1e9;
$("cjRei").onclick = () => {
if (relojBase.estado === "corriendo" && performance.now() - confirma > 3000) {
confirma = performance.now(); $("cjRei").textContent = "¿seguro? otra vez"; $("cjRei").classList.add("aviso");
setTimeout(() => { $("cjRei").textContent = "reiniciar"; $("cjRei").classList.remove("aviso"); }, 3000);
return;
}
confirma = -1e9; enviar({ t: "reloj", accion: "reiniciar" });
};
const base = () => (tabla && tabla.manual) || Math.round(estadisticas().med || (leer() || {}).patron || 1);
$("cjMenos").onclick = () => enviar({ t: "manual", patron: Math.max(1, base() - 1) });
$("cjMas").onclick = () => enviar({ t: "manual", patron: Math.min(53, base() + 1) });
$("cjMan").onchange = () => { const v = +$("cjMan").value; if (v >= 1 && v <= 53) enviar({ t: "manual", patron: Math.round(v) }); };
$("cjSoltar").onclick = () => enviar({ t: "manual", patron: null });
$("cjOlvidar").onclick = () => enviar({ t: "olvidar" });
let confApagar = -1e9;
$("cjApagar").onclick = () => {
if (performance.now() - confApagar > 3000) {
confApagar = performance.now(); $("cjApagar").textContent = "¿apagar? otra vez"; $("cjApagar").classList.add("aviso");
setTimeout(() => { $("cjApagar").textContent = "apagar servidor"; $("cjApagar").classList.remove("aviso"); }, 3000);
return;
}
confApagar = -1e9; enviar({ t: "apagar" });
};
const mandarGuia = () => {
const seg = Math.round(+$("cjGuiaSeg").value) || guia.segundos, min = Math.round(+$("cjGuiaMin").value) || guia.minutos;
enviar({ t: "guia", guia: { modo: $("cjGuiaModo").value, segundos: seg, minutos: min } });
};
$("cjGuiaModo").onchange = mandarGuia;
$("cjGuiaSeg").onchange = mandarGuia;
$("cjGuiaMin").onchange = mandarGuia;
sincronizarGuiaCtl();
const sel = $("cjPalSel");
sel.innerHTML = `` +
PALETAS.map((p, i) => ``).join("") +
``;
const mandarPaleta = () => {
const v = sel.value;
$("cjPalCustom").style.display = v === "custom" ? "inline" : "none";
if (v === "") enviar({ t: "paleta", paleta: null });
else if (v === "custom") enviar({ t: "paleta", paleta: { nombre: "personalizada", colores: [$("cjC1").value, $("cjC2").value] } });
else enviar({ t: "paleta", paleta: PALETAS[+v] });
};
sel.onchange = mandarPaleta;
$("cjC1").oninput = $("cjC2").oninput = () => { if (sel.value === "custom") mandarPaleta(); };
addEventListener("resize", () => { firmaTabla = ""; pintarTabla(); });
}
function mostrarPanel(si) {
const panel = $("cj"), main = document.querySelector("main");
panel.hidden = !si;
$("cjBtn").classList.toggle("on", si);
main.style.gridTemplateColumns = si ? "238px minmax(0,1fr) 280px" : "";
/* la partitura se redibuja al nuevo ancho con el manejador de resize de la página */
dispatchEvent(new Event("resize"));
}
const tituloOriginal = document.title;
function construirControl() {
$("cj").classList.toggle("control", control);
document.body.classList.toggle("cj-maestra", control && !local);
document.title = local ? "IN C · ENSAYO" : control ? "IN C · MAESTRA" : tituloOriginal;
/* sin "http://": la raíz del servidor ya lleva a la obra */
$("cjDirV").innerHTML = direcciones.length ? direcciones.map(d => esc(d.replace(/^https?:\/\//, ""))).join(" ")
: `sin red: conecta el Wi-Fi o el cable`;
pintarPaletaCtl();
}
/* ---------- color de fondo según el patrón propio ---------- */
function aplicarColor() {
const b = document.body, e = leer();
if (!paleta || !e) { b.classList.remove("cj-color"); ["--cj-fondo", "--ink", "--ink-rgb", "--act"].forEach(v => b.style.removeProperty(v)); return; }
const c = colorDe(paleta, e.patron), claro = esClaro(c);
b.style.setProperty("--cj-fondo", c);
b.style.setProperty("--ink", claro ? "#0b0d10" : "#f6f8fa");
b.style.setProperty("--ink-rgb", claro ? "11,13,16" : "246,248,250");
b.style.setProperty("--act", claro ? "#9e1c10" : "#ffe08a"); /* nota que suena y número en ejecución */
b.classList.add("cj-color");
}
function gradiente(pal) {
return "linear-gradient(90deg," + Array.from({ length: 12 }, (_, k) => colorDe(pal, 1 + 52 * k / 11)).join(",") + ")";
}
function pintarPaletaCtl() {
const v = $("cjPalVista"); if (!v) return;
v.style.background = paleta ? gradiente(paleta) : "repeating-linear-gradient(45deg,#1b2128 0 6px,#14181d 6px 12px)";
const sel = $("cjPalSel");
if (sel && document.activeElement !== sel) {
if (!paleta) sel.value = "";
else {
const i = PALETAS.findIndex(p => p.nombre === paleta.nombre);
sel.value = i >= 0 ? String(i) : "custom";
$("cjPalCustom").style.display = i >= 0 ? "none" : "inline";
if (i < 0) { $("cjC1").value = paleta.colores[0]; $("cjC2").value = paleta.colores[paleta.colores.length - 1]; }
}
}
}
/* ---------- estadísticas del conjunto ---------- */
function filas() {
if (!tabla) return [];
const e = leer();
return tabla.interpretes.map(x => x.id === miId && e ? Object.assign({}, x, e, { conectado: true, yo: true }) : x);
}
function estadisticas() {
/* entran los conectados que tocan patrones; el pulso no avanza por la obra */
const v = filas().filter(x => x.conectado && !x.pulso).map(x => x.patron).sort((a, b) => a - b);
if (!v.length) return { n: 0 };
const n = v.length, a = v[(n - 1) >> 1], b = v[n >> 1];
const med = (a + b) / 2; /* con un número par, el promedio de los dos del medio */
return { n, min: v[0], max: v[n - 1], d: v[n - 1] - v[0], med, medTxt: medio(med) };
}
/* ---------- pintura ---------- */
function pintar() { pintarRed(); pintarReloj(); pintarTabla(); }
function pintarRed() {
const red = $("cjRed"), btn = $("cjBtn"), panel = $("cj"); if (!red) return;
const edad = tabla ? Math.round((performance.now() - tTabla) / 1000) : null;
let cls = "", html = "";
if (local) {
const sims = tabla ? tabla.interpretes.filter(x => x.id !== miId) : [];
const n = sims.filter(x => !x.pulso).length, conP = sims.some(x => x.pulso);
cls = "ens";
html = `ENSAYO · sin red · ${n} intérprete${n === 1 ? "" : "s"} simulado${n === 1 ? "" : "s"}${conP ? " y el pulso" : ""}`;
} else if (reemplazado) {
cls = "caido";
html = `OTRA PESTAÑA TOMÓ ESTE LUGAR Esta identidad está abierta en otra pestaña o ventana. `;
} else if (!host) {
html = `sin servidor configurado · la obra suena igual`;
} else if (conectado) {
const n = tabla ? tabla.interpretes.filter(x => x.conectado).length : 1;
cls = "ok"; html = `en línea · ${esc(host)} · ${n} laptop${n === 1 ? "" : "s"}`;
} else if (!yaConecto) {
html = `buscando el servidor ${esc(host)}…` +
(intento > 1 ? ` no responde; reintento en ${Math.max(0, Math.ceil((proxIntento - performance.now()) / 1000))} s` : "");
} else {
cls = "caido";
html = `SIN CONEXIÓN CON EL SERVIDOR ` +
`reintentando${tReintento ? " en " + Math.max(0, Math.ceil((proxIntento - performance.now()) / 1000)) + " s" : "…"}` +
(edad !== null ? ` · datos de hace ${edad} s` : "") + ` el sonido de esta laptop no se ve afectado`;
}
if (red.dataset.h !== html) { red.innerHTML = html; red.dataset.h = html; const b = $("cjAqui"); if (b) b.onclick = () => { reemplazado = false; intento = 0; conectar(); }; }
red.className = cls;
const caido = cls === "caido";
panel.classList.toggle("caido", caido && !!tabla);
btn.classList.toggle("ok", cls === "ok" || cls === "ens"); btn.classList.toggle("caido", caido);
$("cjCfgRes").textContent = local ? `tú: ${miNombre} · ensayo ✎` : `tú: ${miNombre} · servidor ${host || "–"} ✎`;
}
function msReloj() {
const b = relojBase;
return b.estado === "corriendo" ? b.ms + (performance.now() - b.t0) : b.ms;
}
function pintarReloj() {
const v = $("cjRelojV"); if (!v) return;
const t = mmss(msReloj());
const gb = guiaActual();
v.textContent = t; $("cjBtnReloj").textContent = t + (gb ? " · guía " + gb.patron : "");
const est = relojBase.estado;
$("cjRelojEst").textContent = !conectado && yaConecto && est === "corriendo" ? "reloj local"
: est === "corriendo" ? "corriendo" : est === "detenido" ? "detenido" : "en espera";
v.style.color = est === "espera" ? "var(--dim)" : "";
$("cjIni").textContent = est === "detenido" ? "continuar" : "iniciar";
$("cjIni").disabled = est === "corriendo";
$("cjDet").disabled = est !== "corriendo";
$("cjRei").disabled = est === "espera";
}
setInterval(() => { try { pintarReloj(); pintarRed(); pintarGuia(); pintarTabla(); } catch (e) { } }, 250);
/* ---------- guía: en qué patrón deberían estar todos ----------
La calcula cada navegador con el reloj de la obra, así que sigue sin servidor.
Modo "fija": cada patrón dura N segundos. Modo "largo": la duración total se
reparte según la raíz cuadrada del largo de cada patrón, así los patrones
largos (el 35) reciben más tiempo sin que los cortos se vuelvan un suspiro. */
let largosCache = null;
function largos() {
if (!largosCache) {
try { largosCache = PATRONES.map(p => p.reduce((a, e) => a + e[1], 0)); } /* corcheas; global del script principal */
catch (e) { largosCache = new Array(53).fill(1); }
}
return largosCache;
}
function tramosGuia() {
if (guia.modo === "fija") return new Array(53).fill(guia.segundos * 1000);
const w = largos().map(l => Math.sqrt(Math.max(0.5, l))), sw = w.reduce((a, b) => a + b, 0);
return w.map(x => x / sw * guia.minutos * 60000);
}
function guiaActual() {
if (!guia || guia.modo === "apagada" || relojBase.estado === "espera") return null;
const t = msReloj(), d = tramosGuia();
let acc = 0;
for (let i = 0; i < 53; i++) {
if (t < acc + d[i] || i === 52)
return { patron: i + 1, restante: Math.max(0, acc + d[i] - t), tramo: d[i], dentro: Math.min(d[i], t - acc), fin: i === 52 && t >= acc + d[i] };
acc += d[i];
}
}
function sincronizarGuiaCtl() {
const m = $("cjGuiaModo"); if (!m) return;
if (document.activeElement !== m) m.value = guia.modo;
if (document.activeElement !== $("cjGuiaSeg")) $("cjGuiaSeg").value = guia.segundos;
if (document.activeElement !== $("cjGuiaMin")) $("cjGuiaMin").value = guia.minutos;
$("cjGuiaFija").style.display = guia.modo === "fija" ? "" : "none";
$("cjGuiaLargo").style.display = guia.modo === "largo" ? "" : "none";
const d = tramosGuia();
let tot = "";
if (guia.modo === "fija") tot = `53 × ${guia.segundos} s = ${mmss(53 * guia.segundos * 1000)} en total`;
else if (guia.modo === "largo") {
const iMin = d.indexOf(Math.min(...d)), iMax = d.indexOf(Math.max(...d));
tot = `cada patrón dura entre ${mmss(d[iMin])} (el ${iMin + 1}) y ${mmss(d[iMax])} (el ${iMax + 1})`;
}
$("cjGuiaTotal").textContent = tot;
}
function pintarGuia() {
const sec = $("cjGuia"); if (!sec) return;
sec.hidden = !(control || guia.modo !== "apagada");
const g = guiaActual(), e = leer();
$("cjGuiaModoTxt").textContent = guia.modo === "fija" ? `cada ${guia.segundos} s` : guia.modo === "largo" ? `${guia.minutos} min, según el largo` : "apagada";
if (!g) {
$("cjGuiaV").textContent = "–";
$("cjGuiaProx").textContent = guia.modo === "apagada" ? "" : "empieza al iniciar el reloj";
$("cjGuiaAvance").style.width = "0";
$("cjGuiaYo").textContent = "";
return;
}
$("cjGuiaV").textContent = g.patron;
$("cjGuiaProx").textContent = relojBase.estado === "detenido" ? "reloj detenido"
: g.fin ? "fin de la guía" : g.patron === 53 ? `último patrón · ${mmss(g.restante)}` : `pasa al ${g.patron + 1} en ${mmss(g.restante)}`;
$("cjGuiaAvance").style.width = (g.dentro / g.tramo * 100).toFixed(1) + "%";
if (e && !e.pulso) {
const d = e.patron - g.patron;
$("cjGuiaYo").innerHTML = `tú: ${e.patron}· ` +
(d === 0 ? "en la guía" : `${Math.abs(d)} ${d > 0 ? "adelante" : "atrás"} de la guía`) + ``;
} else $("cjGuiaYo").textContent = e && e.pulso ? "tú: pulso" : "";
}
function pintarTabla() {
if (!$("cj")) return;
const fs = filas(), st = estadisticas(), e = leer();
const manual = tabla ? tabla.manual : null;
const g = guiaActual(), gp = g ? g.patron : null;
const firma = JSON.stringify([fs, manual, st.n, conectado, gp, paleta && paleta.nombre]) + (e ? e.patron : "");
if (firma === firmaTabla) return;
firmaTabla = firma;
/* posición */
const pos = manual || st.med || null;
$("cjPosV").textContent = manual ? String(manual) : st.n ? st.medTxt : "–";
$("cjTag").textContent = manual ? "MANUAL" : "mediana";
$("cjTag").className = manual ? "manual" : "";
$("cjPosDe").textContent = st.n ? `de 53 · ${st.n} laptop${st.n === 1 ? "" : "s"}` : "de 53";
if (document.activeElement !== $("cjMan")) $("cjMan").value = manual || "";
$("cjSoltar").disabled = !manual;
$("cjBarra").innerHTML = barra(st, pos, e, gp);
if (e && pos && !e.pulso) {
const d = e.patron - pos, ad = Math.abs(d);
const txt = ad < 0.25 ? "en la posición del conjunto" : ad === 0.5 ? (d > 0 ? "medio patrón adelante" : "medio patrón atrás")
: `${medio(ad)} ${d > 0 ? "adelante" : "atrás"}`;
$("cjYo").innerHTML = `tú: ${e.patron}· ${txt}`;
} else $("cjYo").innerHTML = e && e.pulso ? `tú: pulso` : "";
/* dispersión */
const dv = $("cjDispV");
if (st.n >= 1) {
dv.textContent = st.d; dv.className = "big " + (st.d <= JUNTO ? "junto" : "lejos");
$("cjMinMax").textContent = `mín ${st.min} · máx ${st.max}`;
$("cjDispEst").textContent = st.n < 2 ? "una sola laptop" : st.d <= JUNTO ? "junto" : "estirado";
$("cjDispEst").style.color = st.d <= JUNTO ? "var(--ok)" : "var(--warn)";
pintarTend(st);
} else {
dv.textContent = "–"; dv.className = "big"; $("cjMinMax").textContent = ""; $("cjDispEst").textContent = ""; $("cjTend").textContent = "";
}
/* mapa + lista */
const con = fs.filter(x => x.conectado), son = con.filter(x => x.sonando && !x.pulso).length;
$("cjCuenta").textContent = !tabla ? "" : conectado ? `${con.length} en línea · ${son} sonando` : `${con.length} según los últimos datos`;
$("cjMapa").innerHTML = mapa(fs, st, pos, gp);
const orden = fs.slice().sort((a, b) => (a.nombre || "").localeCompare(b.nombre || "", "es"));
$("cjTabla").tBodies[0].innerHTML = orden.map(x => {
const cls = (x.yo ? "yo " : "") + (x.conectado ? "" : "off");
const ico = !x.conectado ? "off" : x.pulso ? "pul" : x.sonando ? "son" : "";
const d = pos && !x.pulso ? x.patron - pos : null;
const dt = d === null ? "" : d === 0 ? "·" : (d > 0 ? "+" : "−") + medio(Math.abs(d));
const tit = !x.conectado ? "sin conexión: último patrón conocido" : x.pulso ? "toca el pulso" : x.sonando ? "sonando" : "en silencio";
return `
${esc(x.nombre)}${x.yo ? " (tú)" : ""}
` +
`
${x.pulso ? "pulso" : x.patron}
${dt}
`;
}).join("") || `
${tabla ? "nadie conectado" : "sin datos del servidor"}
`;
const hayOff = fs.some(x => !x.conectado);
$("cjOlvidar").disabled = !hayOff;
}
/* ---------- tendencia: ¿el grupo se aprieta o se estira? ----------
Una muestra de la dispersión por segundo; se compara con la de hace 20 s. */
function pintarTend(st) {
const el = $("cjTend"); if (!el) return;
const ahora = performance.now();
if (!st.n || st.n < 2 || !conectado) { el.textContent = st.n >= 2 ? "sin datos nuevos" : ""; return; }
const ref = historia.find(h => h.t >= ahora - VENTANA_TEND);
if (!historia.length || ahora - historia[0].t < VENTANA_TEND - 1500 || !ref) { el.textContent = "midiendo tendencia…"; return; }
const k = st.d - ref.d;
el.textContent = k > 0 ? `↗ se estira: +${k} en 20 s` : k < 0 ? `↘ se aprieta: −${-k} en 20 s` : "→ estable en los últimos 20 s";
}
setInterval(() => {
try {
const st = estadisticas(), ahora = performance.now();
if (conectado && st.n >= 2) historia.push({ t: ahora, d: st.d }); else historia.length = 0;
while (historia.length && ahora - historia[0].t > VENTANA_TEND + 5000) historia.shift();
pintarTend(st);
} catch (e) { }
}, 1000);
/* barra de 1 a 53: toda la obra, con la franja mín–máx, la posición y tu patrón */
function barra(st, pos, e, gp) {
const W = 252, H = 30, x0 = 4, x1 = W - 4, X = p => x0 + (p - 1) / 52 * (x1 - x0);
let s = ``;
}
/* mapa ampliado: un punto por laptop en su patrón, apilados si coinciden */
function iniciales(n, largo) {
const p = String(n || "?").trim().split(/[\s\-_.]+/).filter(Boolean);
if (!p.length) return "?";
const x = largo ? (p.length > 1 ? p[0].slice(0, 2) + p[1][0] : p[0].slice(0, 3)) : (p.length > 1 ? p[0][0] + p[1][0] : p[0].slice(0, 2));
return x.toUpperCase();
}
/* dos letras; si dos laptops coinciden (Diego y Director), tres */
function rotulos(lista) {
const cuenta = {};
lista.forEach(x => { const k = iniciales(x.nombre); cuenta[k] = (cuenta[k] || 0) + 1; });
lista.forEach(x => { const k = iniciales(x.nombre); x._ini = cuenta[k] > 1 ? iniciales(x.nombre, true) : k; });
}
function mapa(fs, st, pos, gp) {
const pts = fs.filter(x => !x.pulso);
if (!pts.length) return "";
const W = 252, R = 9, PAD = R + 2;
rotulos(pts);
const vals = pts.map(x => x.patron).concat(gp ? [gp] : []);
let lo = Math.min(...vals) - 2, hi = Math.max(...vals) + 2;
while (hi - lo < 10) { lo--; hi++; }
if (lo < 1) { hi += 1 - lo; lo = 1; } if (hi > 53) { lo = Math.max(1, lo - (hi - 53)); hi = 53; }
const X = p => PAD + (p - lo) / (hi - lo) * (W - 2 * PAD);
/* apilado: primera fila libre donde no choque con un punto vecino */
const orden = pts.slice().sort((a, b) => a.patron - b.patron || (a.nombre || "").localeCompare(b.nombre || ""));
const ult = [];
orden.forEach(x => {
const cx = X(x.patron); let f = 0;
while (ult[f] !== undefined && cx - ult[f] < 2 * R + 1) f++;
ult[f] = cx; x._cx = cx; x._f = f;
});
const filasN = Math.max(1, ult.length), top = 4, alto = top + filasN * (2 * R + 2) + 16;
const yDe = f => top + alto - 20 - f * (2 * R + 2) - R;
let s = ``;
}
/* ---------- ensayo: lo usa ensayo.js ----------
srv = { conectar(fn), recibir(mensaje) }: recibe lo que se le mandaría al
servidor y contesta llamando a fn con los mismos mensajes del servidor. */
window.InCConjunto = {
ensayo(srv) {
if (local) return;
clearTimeout(tReintento); tReintento = null;
soltarSocket();
local = srv;
reemplazado = false; conectado = true; yaConecto = true; intento = 0; ultimoMsg = performance.now();
document.body.classList.add("cj-ensayo");
srv.conectar(entrante);
const e = leer() || { patron: 1, sonando: false, pulso: false };
enviado = e;
enviar(Object.assign({ t: "hola", id: miId, nombre: miNombre }, e));
mostrarPanel(true);
firmaTabla = ""; pintar();
},
};
/* ---------- arranque ---------- */
try {
construir();
pintar();
conectar();
} catch (e) { console.warn("conjunto: no se pudo iniciar el panel", e); }
})();