Files
Monitor-de-Viales/monitor_viales_websocket_v11.html
T
yamaray fcefd22334 Subir archivos a "/"
Primera version estable para monitorear viales en diferentes planta. Cada planta se configura con el archivo configurador:monitor
2026-06-04 17:43:08 +00:00

3078 lines
110 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MONITOR DE BÁSCULAS - WebSocket Supervisor</title>
<style>
:root {
--bg: #0f172a;
--bg2: #111c33;
--panel: #162238;
--panel2: #1b2b45;
--line: #2f4f74;
--line2: #4ea5c9;
--text: #eef6ff;
--muted: #9fb4c9;
--accent: #4db6e2;
--accent2: #75d7ff;
--danger: #ef4444;
--green: #22c55e;
--yellow: #f59e0b;
--scaleTitle: #f6a21a;
--shadow: rgba(0,0,0,0.34);
--input: #0d1729;
--console: #08111f;
--button: #193554;
--buttonHover: #224b77;
}
body.light {
--bg: #eef4f8;
--bg2: #e6f0f6;
--panel: #ffffff;
--panel2: #f5f9fc;
--line: #bfd2df;
--line2: #2b7ea3;
--text: #102033;
--muted: #52697d;
--accent: #247fa8;
--accent2: #0e668d;
--danger: #dc2626;
--green: #16a34a;
--yellow: #d97706;
--scaleTitle: #c46a00;
--shadow: rgba(20,60,90,0.16);
--input: #f8fbfd;
--console: #f6fafc;
--button: #e5f2f8;
--buttonHover: #d5ebf5;
}
body[data-style="salvia"] {
--bg: #101b18;
--bg2: #182721;
--panel: #17241f;
--panel2: #1d3029;
--line: #36564c;
--line2: #7bc4a8;
--accent: #8fd6b8;
--accent2: #b6f0d9;
--button: #21463b;
--buttonHover: #2b5a4d;
}
body.light[data-style="salvia"] {
--bg: #eef7f1;
--bg2: #dfeee6;
--panel: #fbfffc;
--panel2: #eef8f2;
--line: #bdd6ca;
--line2: #4f9d7e;
--text: #13251f;
--muted: #547067;
--accent: #2f8f6f;
--accent2: #17664f;
--button: #e3f2ea;
--buttonHover: #cfe8da;
}
body[data-style="bruma"] {
--bg: #101821;
--bg2: #192735;
--panel: #172331;
--panel2: #1d3041;
--line: #344f63;
--line2: #92b7c9;
--accent: #a8cfe0;
--accent2: #d5edf6;
--button: #243f52;
--buttonHover: #2e536b;
}
body.light[data-style="bruma"] {
--bg: #f1f6f8;
--bg2: #ddeaf0;
--panel: #ffffff;
--panel2: #f3f8fb;
--line: #c5d6df;
--line2: #6d9ab0;
--text: #14232d;
--muted: #5c707b;
--accent: #477f98;
--accent2: #245d75;
--button: #e7f1f6;
--buttonHover: #d7e8f0;
}
body[data-style="calma"] {
--bg: #181716;
--bg2: #27231f;
--panel: #22201d;
--panel2: #302b26;
--line: #574b40;
--line2: #d2b48c;
--accent: #e6c79c;
--accent2: #f5dfbd;
--button: #49392b;
--buttonHover: #604b38;
}
body.light[data-style="calma"] {
--bg: #f7f4ef;
--bg2: #ebe3d7;
--panel: #fffdf9;
--panel2: #f7f0e6;
--line: #d8c8b5;
--line2: #a47c54;
--text: #2a241e;
--muted: #756757;
--accent: #9a6a3d;
--accent2: #6f4725;
--button: #efe4d5;
--buttonHover: #e4d2bd;
}
body[data-style="selva"] {
--bg: #071611;
--bg2: #0f2a1f;
--panel: #10251d;
--panel2: #17372a;
--line: #285f49;
--line2: #34d399;
--accent: #5ee0aa;
--accent2: #bbf7d0;
--button: #14533f;
--buttonHover: #1b6b52;
}
body.light[data-style="selva"] {
--bg: #ecfdf3;
--bg2: #d4f5e2;
--panel: #fbfffc;
--panel2: #eaf9f0;
--line: #a7d8bf;
--line2: #10b981;
--text: #0f281f;
--muted: #4d6f61;
--accent: #047857;
--accent2: #065f46;
--button: #d8f3e5;
--buttonHover: #c1ead4;
}
body[data-style="aurora"] {
--bg: #1b1118;
--bg2: #2a1724;
--panel: #241620;
--panel2: #341d2c;
--line: #614258;
--line2: #f0a6c8;
--accent: #f2b6d0;
--accent2: #ffd9e8;
--button: #553044;
--buttonHover: #6d3d57;
}
body.light[data-style="aurora"] {
--bg: #fff1f6;
--bg2: #f8dce8;
--panel: #fffafd;
--panel2: #fbedf3;
--line: #e5b9cc;
--line2: #d96c9f;
--text: #321d29;
--muted: #7a5a69;
--accent: #b83273;
--accent2: #8a1f55;
--button: #f5d8e5;
--buttonHover: #edc3d6;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(77,182,226,0.18), transparent 30%),
linear-gradient(135deg, var(--bg), var(--bg2));
color: var(--text);
font-family: "Segoe UI", Roboto, Arial, sans-serif;
transition: background 0.25s ease, color 0.25s ease;
}
.page {
width: min(1440px, calc(100% - 28px));
margin: 0 auto;
padding: 18px 0 22px;
}
.topbar {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 18px;
align-items: center;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(22,34,56,0.88);
box-shadow: 0 14px 35px var(--shadow);
backdrop-filter: blur(8px);
position: relative;
z-index: 50;
}
body.light .topbar { background: rgba(255,255,255,0.88); }
.brand { display: flex; align-items: center; gap: 16px; min-width: 330px; }
.logoBox {
width: 74px;
height: 54px;
display: grid;
place-items: center;
border-radius: 14px;
background: rgba(255,255,255,0.06);
border: 1px solid var(--line);
overflow: hidden;
}
.logoBox img { max-width: 100%; max-height: 100%; object-fit: contain; }
h1 { margin: 0; font-size: 1.55rem; letter-spacing: 0.04em; }
.subtitle { color: var(--muted); font-size: 0.85rem; margin-top: 3px; }
.connectionGrid {
display: grid;
grid-template-columns: 1.3fr auto;
gap: 10px;
align-items: end;
}
.rightTools {
display: grid;
grid-template-columns: minmax(360px, 1fr) auto;
align-items: end;
gap: 10px;
justify-content: flex-end;
}
.fieldLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
}
.fieldLabelRow label { margin-bottom: 0; }
.plantNameBadge {
min-width: 104px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(77,182,226,0.1);
color: var(--accent2);
font-weight: 900;
font-size: 0.82rem;
white-space: nowrap;
}
label {
display: block;
color: var(--muted);
font-size: 0.78rem;
margin-bottom: 5px;
letter-spacing: 0.02em;
}
input, select {
width: 100%;
height: 38px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--input);
color: var(--text);
padding: 0 11px;
outline: none;
font-size: 0.95rem;
}
input:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(77,182,226,0.16);
}
.urlInput { min-width: 280px; }
.guidInput { min-width: 360px; }
button {
height: 38px;
border: 1px solid var(--line);
background: var(--button);
color: var(--text);
border-radius: 11px;
padding: 0 14px;
cursor: pointer;
font-weight: 700;
letter-spacing: 0.02em;
transition: transform 0.08s ease, background 0.16s ease, border-color 0.16s ease;
white-space: nowrap;
}
button:hover { background: var(--buttonHover); border-color: var(--accent); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.55; cursor: not-allowed; }
.connectBtn.disconnected {
background: rgba(239,68,68,0.92);
color: #fff7f7;
border-color: #fecaca;
box-shadow: 0 0 0 3px rgba(239,68,68,0.16);
}
.connectBtn.disconnected:hover {
background: #dc2626;
border-color: #fee2e2;
}
body.light .connectBtn.disconnected {
background: #dc2626;
color: #ffffff;
border-color: #991b1b;
box-shadow: 0 0 0 3px rgba(220,38,38,0.14);
}
.connectBtn.connected { background: rgba(34,197,94,0.88); color: #04120a; border-color: #86efac; }
.connectBtn.connecting { background: rgba(245,158,11,0.82); color: #170d00; border-color: #facc15; }
.styleTools {
position: relative;
display: grid;
gap: 8px;
justify-items: end;
}
.iconBtn {
width: 42px;
min-width: 42px;
height: 38px;
padding: 0;
display: grid;
place-items: center;
border-radius: 12px;
font-size: 1.05rem;
font-weight: 900;
}
.themeBtn { min-width: 42px; }
.styleMenu {
position: absolute;
top: 0;
right: 50px;
z-index: 5000;
width: 210px;
display: none;
gap: 7px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 14px;
background: color-mix(in srgb, var(--panel) 96%, transparent);
box-shadow: 0 16px 34px var(--shadow);
}
.styleMenu.open { display: grid; }
.styleOption {
width: 100%;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-radius: 10px;
font-size: 0.78rem;
text-align: left;
}
.styleOption.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--button));
}
.styleOption::after {
content: "";
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border: 1px solid var(--line);
}
.mainGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 18px;
}
.scaleCard {
border: 1px solid var(--line);
border-radius: 20px;
background: linear-gradient(180deg, var(--panel), var(--panel2));
box-shadow: 0 14px 35px var(--shadow);
padding: 17px;
min-height: 380px;
position: relative;
overflow: hidden;
}
.scaleCard::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
pointer-events: none;
background: radial-gradient(circle at top right, rgba(77,182,226,0.12), transparent 35%);
}
.scaleHeader {
display: grid;
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
gap: 12px;
align-items: center;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
margin-bottom: 14px;
position: relative;
}
.scaleTitle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 1.15rem;
font-weight: 900;
letter-spacing: 0.08em;
color: #ffb020 !important;
text-shadow: 0 0 14px rgba(255,176,32,0.42);
}
body.light .scaleTitle {
color: #d66b00 !important;
text-shadow: none;
}
.vialSelect {
width: 62px;
min-width: 62px;
height: 32px;
padding: 0 7px;
border-radius: 9px;
font-size: 0.95rem;
font-weight: 900;
letter-spacing: 0;
}
.headerControls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.totemHeaderControl {
display: grid;
grid-template-columns: auto minmax(118px, 1fr);
align-items: center;
gap: 7px;
min-width: 178px;
}
.totemHeaderLabel {
color: var(--muted);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.06em;
}
.totemId {
height: 32px;
min-width: 118px;
padding: 0 8px;
font-size: 0.86rem;
font-weight: 800;
}
.plateGroup {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.plateLabel {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.08em;
color: var(--muted);
text-transform: uppercase;
white-space: nowrap;
}
.plateDisplay {
min-width: 150px;
text-align: left;
font-size: 1.45rem;
font-weight: 900;
letter-spacing: 0.08em;
color: var(--accent2);
border: 1px solid var(--line);
border-radius: 99px;
padding: 8px 14px;
background: rgba(117,215,255,0.08);
text-transform: uppercase;
}
body.light .plateDisplay { background: rgba(36,127,168,0.06); }
.totemBlock {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: center;
padding: 13px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(8,17,31,0.22);
margin-bottom: 14px;
}
body.light .totemBlock { background: rgba(36,127,168,0.06); }
.fieldGrid { display: grid; grid-template-columns: 1fr; gap: 10px; min-width: 0; }
.signalArea {
display: grid;
justify-items: center;
gap: 8px;
min-width: 84px;
}
.signal {
width: 62px;
height: 62px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 900;
color: white;
border: 3px solid rgba(255,255,255,0.45);
cursor: pointer;
user-select: none;
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.signal:hover { transform: scale(1.04); }
.signal.green { background: radial-gradient(circle at 35% 30%, #86efac, #16a34a 58%, #0f6b33); box-shadow: 0 0 24px rgba(34,197,94,0.65); }
.signal.red { background: radial-gradient(circle at 35% 30%, #fecaca, #ef4444 58%, #991b1b); box-shadow: 0 0 24px rgba(239,68,68,0.65); }
.barrierBtn { height: 32px; font-size: 0.78rem; padding: 0 10px; }
.weightPanel {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: 18px;
padding: 13px 15px;
background: rgba(8,17,31,0.30);
position: relative;
}
body.light .weightPanel { background: rgba(255,255,255,0.65); }
.weightTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 7px;
}
.weightLabel { color: var(--muted); font-weight: 700; letter-spacing: 0.04em; }
.weightValue {
display: flex;
align-items: baseline;
gap: 9px;
margin-top: 0;
font-size: 2.55rem;
font-weight: 900;
line-height: 1;
color: #F75C02;
}
.unit { font-size: 1rem; color: var(--muted); font-weight: 700; }
.stableBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 29px;
border-radius: 99px;
padding: 5px 10px;
border: 1px solid var(--line);
color: var(--muted);
font-weight: 800;
font-size: 0.72rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
.stableBadge.ok { color: #052e16; background: #86efac; border-color: #bbf7d0; }
.stableBadge.no { color: #3b2200; background: #fcd34d; border-color: #fde68a; }
.scale3Layout { display: grid; grid-template-rows: auto auto auto auto; gap: 2px; }
.scale3Layout .weightPanel { margin-top: 0; margin-bottom: 0; }
.imageGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
margin-top: 14px;
position: relative;
}
.imageBox {
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(8,17,31,0.24);
overflow: hidden;
min-height: 150px;
display: grid;
grid-template-rows: auto 1fr;
}
body.light .imageBox { background: rgba(36,127,168,0.05); }
.imageHeader {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--line);
background: rgba(0,0,0,0.10);
padding: 7px 8px 7px 10px;
}
.imageStatusList {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex-wrap: wrap;
}
.imageStatus {
color: var(--muted);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.06em;
border: 1px solid transparent;
border-radius: 999px;
padding: 3px 7px;
line-height: 1.1;
opacity: 0.58;
transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
}
.imageStatus.available {
color: var(--green);
opacity: 1;
}
.imageStatus.active {
border-color: rgba(77,182,226,0.55);
background: rgba(77,182,226,0.12);
opacity: 1;
}
.imageToggleBtn {
height: 28px;
border-radius: 8px;
padding: 0 10px;
font-size: 0.72rem;
letter-spacing: 0.03em;
flex: 0 0 auto;
}
.imageModeSelect {
height: 28px;
min-width: 102px;
border-radius: 8px;
padding: 0 8px;
font-size: 0.72rem;
font-weight: 800;
}
.imageGrid[data-active-image="totem"] .imageViewport {
display: none;
}
.imageGrid:not([data-active-image="totem"]) .imageTotemPanel {
display: none;
}
.imageTotemPanel {
margin: 0;
border-width: 1px 0 0 0;
border-radius: 0;
background: transparent;
}
.imageTotemPanel .totemScreenHeader {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
padding: 6px 8px;
}
.imageTotemPanel .totemUrlRow {
gap: 5px;
}
.imageTotemPanel .totemScreenSelect,
.imageTotemPanel .totemManualBtn,
.imageTotemPanel .totemScreenReloadBtn {
height: 26px;
font-size: 0.68rem;
}
.imageTotemPanel .totemPowerBtn {
height: 26px;
min-width: 38px;
}
.imageTotemPanel .totemScreenViewport {
height: 128px;
}
.imageViewport {
min-height: 118px;
display: grid;
place-items: center;
color: var(--muted);
font-size: 0.82rem;
text-align: center;
padding: 8px;
}
.imageViewport img {
width: 100%;
height: 118px;
object-fit: contain;
display: block;
border-radius: 9px;
}
.scaleCard[data-scale="3"] .imageViewport img,
.scaleCard[data-scale="3"] .imageViewport { min-height: 128px; }
.scaleCard[data-scale="3"] .imageViewport img { height: 128px; }
.totemScreenPanel {
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(8,17,31,0.24);
overflow: hidden;
cursor: auto;
}
body.light .totemScreenPanel { background: rgba(36,127,168,0.05); }
.totemScreenHeader {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
background: rgba(0,0,0,0.10);
}
.totemScreenTitleRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 5px;
}
.totemScreenTitle {
color: var(--muted);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.totemScreenSelect,
.totemScreenUrl {
height: 30px;
border-radius: 8px;
font-size: 0.78rem;
padding: 0 9px;
}
.totemUrlRow {
display: flex;
align-items: center;
gap: 6px;
}
.totemUrlRow .totemScreenSelect {
min-width: 0;
flex: 1;
}
.totemManualBtn {
height: 30px;
min-width: 46px;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.06);
color: var(--text);
font-size: 0.68rem;
font-weight: 900;
cursor: pointer;
}
.totemManualBtn.is-active {
border-color: rgba(34,211,238,0.65);
color: #a5f3fc;
}
.totemManualUrl {
display: none;
width: 100%;
margin-top: 6px;
height: 30px;
border-radius: 8px;
font-size: 0.78rem;
padding: 0 9px;
}
.totemScreenPanel.manual-url-mode .totemManualUrl {
display: block;
}
.totemPowerBtn {
height: 20px;
min-width: 42px;
border: 1px solid rgba(74,222,128,0.55);
border-radius: 999px;
background: rgba(74,222,128,0.14);
color: #bbf7d0;
font-size: 0.66rem;
font-weight: 900;
letter-spacing: 0.04em;
cursor: pointer;
line-height: 1;
}
.totemPowerBtn.is-off {
border-color: rgba(148,163,184,0.5);
background: rgba(148,163,184,0.12);
color: var(--muted);
}
.totemScreenReloadBtn {
height: 30px;
border-radius: 8px;
padding: 0 10px;
font-size: 0.72rem;
}
.totemScreenViewport {
height: 118px;
position: relative;
overflow: hidden;
background: rgba(8,17,31,0.24);
cursor: auto;
}
body.light .totemScreenViewport { background: rgba(36,127,168,0.05); }
.totemScreenPanel.totem-screen-off .totemScreenFrame {
display: none;
}
.totemScreenFrame {
width: 820px;
height: 480px;
border: 0;
display: block;
background: #ffffff;
position: absolute;
top: 50%;
left: calc(50% + (10px * var(--totem-scale, 0.2)));
transform-origin: center center;
transform: translate(-50%, -50%) scale(var(--totem-scale, 0.2));
clip-path: inset(0 20px 0 0);
overflow: hidden;
cursor: auto !important;
}
.actionsPanel {
margin-top: 18px;
display: grid;
grid-template-columns: auto minmax(240px, 380px) auto 1fr;
gap: 12px;
align-items: end;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(22,34,56,0.74);
padding: 15px;
box-shadow: 0 14px 35px var(--shadow);
}
body.light .actionsPanel { background: rgba(255,255,255,0.75); }
.manualPanel {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: end;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(22,34,56,0.62);
padding: 15px;
box-shadow: 0 14px 35px var(--shadow);
}
body.light .manualPanel { background: rgba(255,255,255,0.72); }
.manualInput {
width: 100%;
min-height: 58px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--input);
color: var(--text);
padding: 10px 11px;
outline: none;
font-size: 0.92rem;
font-family: Consolas, "Courier New", monospace;
resize: vertical;
}
.manualInput:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(77,182,226,0.16);
}
.sendManualBtn { background: linear-gradient(135deg, #225985, #183c61); border-color: #61c5ec; }
body.light .sendManualBtn { background: linear-gradient(135deg, #d9eff8, #c4e4f1); color: #103044; }
.checkboxWrap {
height: 38px;
display: flex;
align-items: center;
gap: 9px;
color: var(--text);
font-weight: 800;
letter-spacing: 0.04em;
}
.checkboxWrap input { width: 18px; height: 18px; accent-color: var(--accent); }
.sendUpdateBtn { background: linear-gradient(135deg, #1f6f96, #17496d); border-color: #61c5ec; }
body.light .sendUpdateBtn { background: linear-gradient(135deg, #d9eff8, #c4e4f1); color: #103044; }
.configControls {
display: flex;
align-items: end;
gap: 9px;
flex-wrap: wrap;
justify-content: flex-end;
}
.configBtn {
height: 34px;
border-radius: 9px;
padding: 0 11px;
font-size: 0.74rem;
}
.logPanel {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
background: var(--console);
box-shadow: 0 14px 35px var(--shadow);
}
.logHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 13px;
background: rgba(77,182,226,0.10);
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.06em;
}
.logBox {
height: 170px;
overflow: auto;
padding: 12px;
font-family: Consolas, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.42;
white-space: pre-wrap;
color: var(--text);
}
.logLine { margin-bottom: 5px; }
.tx { color: var(--accent2); }
.rx { color: #86efac; }
.err { color: #fca5a5; }
body.light .rx { color: #15803d; }
body.light .err { color: #b91c1c; }
.hint { color: var(--muted); font-size: 0.86rem; }
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(20px);
z-index: 2000;
max-width: min(520px, calc(100vw - 32px));
padding: 13px 18px;
border-radius: 14px;
border: 1px solid rgba(252,165,165,0.55);
background: rgba(127,29,29,0.94);
color: #fff;
box-shadow: 0 14px 32px var(--shadow);
font-weight: 800;
letter-spacing: 0.01em;
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.focusBackBtn {
display: none;
position: fixed;
top: 16px;
left: 50%;
right: auto;
transform: translateX(-50%);
z-index: 1000;
height: 42px;
padding: 0 18px;
background: linear-gradient(135deg, #225985, #183c61);
border-color: #61c5ec;
box-shadow: 0 10px 26px var(--shadow);
}
.focusBackBtn:active { transform: translateX(-50%) translateY(1px); }
body.focus-mode .focusBackBtn { display: inline-flex; align-items: center; }
.scaleTitle { cursor: zoom-in; user-select: none; }
body.focus-mode .scaleTitle { cursor: default; }
body.focus-mode .topbar,
body.focus-mode .actionsPanel,
body.focus-mode .manualPanel,
body.focus-mode .logPanel {
display: none;
}
body.focus-mode .page {
width: 100%;
max-width: none;
padding: 14px;
}
body.focus-mode .mainGrid {
grid-template-columns: 1fr;
margin-top: 0;
gap: 0;
}
body.focus-mode .scaleCard.hiddenScale { display: none; }
body.focus-mode .scaleCard.focusedScale {
min-height: calc(100vh - 28px);
padding: 22px;
border-radius: 24px;
display: grid;
grid-template-rows: auto auto auto 1fr;
gap: 14px;
}
body.focus-mode .scaleCard.focusedScale .scaleHeader {
margin-bottom: 0;
padding-bottom: 14px;
}
body.focus-mode .scaleCard.focusedScale .scaleTitle {
font-size: 1.65rem;
}
body.focus-mode .scaleCard.focusedScale .plateLabel {
font-size: 0.95rem;
}
body.focus-mode .scaleCard.focusedScale .plateDisplay {
min-width: 190px;
font-size: 1.55rem;
padding: 8px 16px;
}
body.focus-mode .scaleCard.focusedScale .totemBlock {
margin-bottom: 0;
}
body.focus-mode .scaleCard.focusedScale .weightPanel {
margin-top: 0;
padding: 16px 18px;
}
body.focus-mode .scaleCard.focusedScale .weightValue {
font-size: 4.6rem;
}
body.focus-mode .scaleCard.focusedScale .unit {
font-size: 1.5rem;
}
body.focus-mode .scaleCard.focusedScale .stableBadge {
min-height: 36px;
font-size: 0.9rem;
padding: 7px 14px;
}
body.focus-mode .scaleCard.focusedScale .imageGrid {
margin-top: 0;
gap: 16px;
align-self: stretch;
}
body.focus-mode .scaleCard.focusedScale .imageBox {
min-height: 360px;
}
body.focus-mode .scaleCard.focusedScale .imageTitle {
font-size: 0.9rem;
padding: 10px 13px;
}
body.focus-mode .scaleCard.focusedScale .imageViewport,
body.focus-mode .scaleCard.focusedScale .imageViewport img,
body.focus-mode .scaleCard.focusedScale[data-scale="3"] .imageViewport,
body.focus-mode .scaleCard.focusedScale[data-scale="3"] .imageViewport img {
min-height: 320px;
height: 320px;
}
body.focus-mode .scaleCard.focusedScale .totemScreenPanel {
margin-top: 0;
}
body.focus-mode .scaleCard.focusedScale .totemScreenHeader {
padding: 10px 13px;
}
body.focus-mode .scaleCard.focusedScale .totemScreenTitle {
font-size: 0.9rem;
}
body.focus-mode .scaleCard.focusedScale .totemScreenViewport {
height: 320px;
}
body.focus-mode .scaleCard.focusedScale[data-scale="3"] .scale3Layout {
gap: 14px;
}
@media (min-height: 850px) {
body.focus-mode .scaleCard.focusedScale .imageViewport,
body.focus-mode .scaleCard.focusedScale .imageViewport img,
body.focus-mode .scaleCard.focusedScale[data-scale="3"] .imageViewport,
body.focus-mode .scaleCard.focusedScale[data-scale="3"] .imageViewport img,
body.focus-mode .scaleCard.focusedScale .totemScreenViewport {
min-height: 420px;
height: 420px;
}
}
@media (max-width: 1320px) {
.topbar { grid-template-columns: minmax(260px, 0.8fr) minmax(360px, 1.2fr); }
.brand { min-width: 0; }
.rightTools { grid-column: 1 / -1; }
}
@media (max-width: 1120px) {
.topbar { grid-template-columns: 1fr; }
.rightTools { grid-template-columns: 1fr; justify-content: stretch; }
.scaleHeader { grid-template-columns: 1fr; }
.headerControls { justify-content: flex-start; }
.mainGrid { grid-template-columns: 1fr; }
.actionsPanel { grid-template-columns: 1fr; }
.manualPanel { grid-template-columns: 1fr; }
.guidInput, .urlInput { min-width: 0; }
}
</style>
</head>
<body>
<div id="toast" class="toast" role="alert" aria-live="assertive"></div>
<datalist id="centroProveedorOptions"></datalist>
<input id="configFileInput" type="file" accept="application/json,.json" hidden />
<button id="focusBackBtn" class="focusBackBtn">← VOLVER A VISTA GENERAL</button>
<div class="page">
<header class="topbar">
<div class="brand">
<div class="logoBox"><img src="data:image/png;base64,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" alt="Trypton" /></div>
<div>
<h1>MONITOR DE BÁSCULAS</h1>
<div class="subtitle">Herramienta para uso durante los desarrollos</div>
</div>
</div>
<div class="connectionGrid">
<div>
<label for="wsUrl">URL servidor WebSocket</label>
<select id="wsUrl" class="urlInput wsUrlSelect"></select>
</div>
<button id="connectBtn" class="connectBtn disconnected">Conectar</button>
</div>
<div class="rightTools">
<div>
<div class="fieldLabelRow">
<label for="centroProveedor">IdCentroProveedor / GUID</label>
<span id="plantaName" class="plantNameBadge">---</span>
</div>
<select id="centroProveedor" class="guidInput"></select>
</div>
<div class="styleTools">
<button id="styleBtn" class="iconBtn" type="button" title="Cambiar estilo" aria-label="Cambiar estilo"></button>
<div id="styleMenu" class="styleMenu" aria-label="Selector de estilo">
<button class="styleOption" type="button" data-style-option="base">Azul sereno</button>
<button class="styleOption" type="button" data-style-option="salvia">Salvia técnica</button>
<button class="styleOption" type="button" data-style-option="bruma">Bruma gris</button>
<button class="styleOption" type="button" data-style-option="calma">Calma cálida</button>
<button class="styleOption" type="button" data-style-option="selva">Selva tropical</button>
<button class="styleOption" type="button" data-style-option="aurora">Aurora rosa</button>
</div>
<button id="themeBtn" class="themeBtn iconBtn" type="button" title="Cambiar claro/oscuro" aria-label="Cambiar claro/oscuro"></button>
</div>
</div>
</header>
<main class="mainGrid">
<section class="scaleCard" data-scale="1">
<div class="scaleHeader">
<div class="scaleTitle" title="Doble click para ampliar este vial"><span>VIAL</span><select class="vialSelect" data-card-vial></select></div>
<div class="headerControls">
<div class="totemHeaderControl"><span class="totemHeaderLabel">TÓTEM</span><select class="totemId" data-channel-totem="S1"></select></div>
</div>
</div>
<div class="totemBlock" data-channel="S1" data-weight-target="b1">
<div class="fieldGrid">
<div class="plateGroup"><span class="plateLabel">MATRÍCULA:</span><div class="plateDisplay" id="plate-b1" data-plate-target="b1">---</div></div>
</div>
<div class="signalArea">
<div class="signal green" data-signal="S1" title="Click para solicitar cambio de semáforo">S1</div>
<button class="barrierBtn" data-barrier="S1">Barrera</button>
</div>
</div>
<div class="weightPanel" id="weight-b1">
<div class="weightTop"><div class="weightLabel">PESO</div><div class="stableBadge">SIN DATOS</div></div>
<div class="weightValue"><span class="weightNum">---</span><span class="unit">kg</span></div>
</div>
<div class="imageGrid" data-images="b1" data-active-image="vehicle">
<div class="imageBox">
<div class="imageHeader"><div class="imageStatusList"><span class="imageStatus active" data-image-status="vehicle">IMAGEN CAMIÓN</span><span class="imageStatus" data-image-status="context">IMAGEN CONTEXTO</span></div><button class="imageToggleBtn" type="button" data-image-toggle>Ver contexto</button></div>
<div class="imageViewport" data-img-kind="active">Sin imagen</div>
</div>
</div>
<div class="totemScreenPanel" data-totem-screen="b1">
<div class="totemScreenHeader">
<div>
<div class="totemScreenTitleRow"><div class="totemScreenTitle">PANTALLA TÓTEM</div><button class="totemPowerBtn" type="button" data-totem-power>OFF</button></div>
<div class="totemUrlRow"><select class="totemScreenSelect totemScreenUrl" data-totem-select data-totem-url></select><button class="totemManualBtn" type="button" data-totem-manual-toggle>EDIT</button></div>
<input class="totemManualUrl" data-totem-manual-url autocomplete="off" placeholder="URL manual del tótem" /></div>
<button class="totemScreenReloadBtn" type="button" data-totem-reload>Recargar</button>
</div>
<div class="totemScreenViewport">
<iframe class="totemScreenFrame" data-totem-frame title="Pantalla del tótem" loading="lazy" scrolling="no"></iframe>
</div>
</div>
</section>
<section class="scaleCard" data-scale="2">
<div class="scaleHeader">
<div class="scaleTitle" title="Doble click para ampliar este vial"><span>VIAL</span><select class="vialSelect" data-card-vial></select></div>
<div class="headerControls">
<div class="totemHeaderControl"><span class="totemHeaderLabel">TÓTEM</span><select class="totemId" data-channel-totem="S2"></select></div>
</div>
</div>
<div class="totemBlock" data-channel="S2" data-weight-target="b2">
<div class="fieldGrid">
<div class="plateGroup"><span class="plateLabel">MATRÍCULA:</span><div class="plateDisplay" id="plate-b2" data-plate-target="b2">---</div></div>
</div>
<div class="signalArea">
<div class="signal green" data-signal="S2" title="Click para solicitar cambio de semáforo">S2</div>
<button class="barrierBtn" data-barrier="S2">Barrera</button>
</div>
</div>
<div class="weightPanel" id="weight-b2">
<div class="weightTop"><div class="weightLabel">PESO</div><div class="stableBadge">SIN DATOS</div></div>
<div class="weightValue"><span class="weightNum">---</span><span class="unit">kg</span></div>
</div>
<div class="imageGrid" data-images="b2" data-active-image="vehicle">
<div class="imageBox">
<div class="imageHeader"><div class="imageStatusList"><span class="imageStatus active" data-image-status="vehicle">IMAGEN CAMIÓN</span><span class="imageStatus" data-image-status="context">IMAGEN CONTEXTO</span></div><button class="imageToggleBtn" type="button" data-image-toggle>Ver contexto</button></div>
<div class="imageViewport" data-img-kind="active">Sin imagen</div>
</div>
</div>
<div class="totemScreenPanel" data-totem-screen="b2">
<div class="totemScreenHeader">
<div>
<div class="totemScreenTitleRow"><div class="totemScreenTitle">PANTALLA TÓTEM</div><button class="totemPowerBtn" type="button" data-totem-power>OFF</button></div>
<div class="totemUrlRow"><select class="totemScreenSelect totemScreenUrl" data-totem-select data-totem-url></select><button class="totemManualBtn" type="button" data-totem-manual-toggle>EDIT</button></div>
<input class="totemManualUrl" data-totem-manual-url autocomplete="off" placeholder="URL manual del tótem" /></div>
<button class="totemScreenReloadBtn" type="button" data-totem-reload>Recargar</button>
</div>
<div class="totemScreenViewport">
<iframe class="totemScreenFrame" data-totem-frame title="Pantalla del tótem" loading="lazy" scrolling="no"></iframe>
</div>
</div>
</section>
<section class="scaleCard" data-scale="3">
<div class="scaleHeader">
<div class="scaleTitle" title="Doble click para ampliar este vial"><span>VIAL</span><select class="vialSelect" data-card-vial></select></div>
<div class="headerControls">
<div class="totemHeaderControl"><span class="totemHeaderLabel">TÓTEM S3</span><select class="totemId" data-channel-totem="S3"></select></div>
<div class="totemHeaderControl"><span class="totemHeaderLabel">TÓTEM S4</span><select class="totemId" data-channel-totem="S4"></select></div>
</div>
</div>
<div class="scale3Layout">
<div class="totemBlock" data-channel="S3" data-weight-target="b3">
<div class="fieldGrid">
<div class="plateGroup"><span class="plateLabel">MATRÍCULA:</span><div class="plateDisplay" id="plate-b3" data-plate-target="b3">---</div></div>
</div>
<div class="signalArea">
<div class="signal green" data-signal="S3" title="Click para solicitar cambio de semáforo">S3</div>
<button class="barrierBtn" data-barrier="S3">Barrera</button>
</div>
</div>
<div class="weightPanel" id="weight-b3">
<div class="weightTop"><div class="weightLabel">PESO COMÚN VIAL</div><div class="stableBadge">SIN DATOS</div></div>
<div class="weightValue"><span class="weightNum">---</span><span class="unit">kg</span></div>
</div>
<div class="imageGrid" data-images="b3" data-active-image="vehicle">
<div class="imageBox">
<div class="imageHeader">
<div class="imageStatusList"><span class="imageStatus active" data-image-status="vehicle">IMAGEN CAMIÓN</span><span class="imageStatus" data-image-status="context">IMAGEN CONTEXTO</span><span class="imageStatus" data-image-status="totem">PANTALLA TÓTEM</span></div>
<select class="imageModeSelect" data-image-mode>
<option value="vehicle">Camión</option>
<option value="context">Contexto</option>
<option value="totem">Tótem</option>
</select>
</div>
<div class="imageViewport" data-img-kind="active">Sin imagen</div>
<div class="totemScreenPanel imageTotemPanel" data-totem-screen="b3">
<div class="totemScreenHeader">
<div>
<div class="totemUrlRow"><select class="totemScreenSelect totemScreenUrl" data-totem-select data-totem-url></select><button class="totemPowerBtn" type="button" data-totem-power>OFF</button><button class="totemManualBtn" type="button" data-totem-manual-toggle>EDIT</button></div>
<input class="totemManualUrl" data-totem-manual-url autocomplete="off" placeholder="URL manual del tótem" /></div>
<button class="totemScreenReloadBtn" type="button" data-totem-reload>Recargar</button>
</div>
<div class="totemScreenViewport">
<iframe class="totemScreenFrame" data-totem-frame title="Pantalla del tótem tarjeta 3" loading="lazy" scrolling="no"></iframe>
</div>
</div>
</div>
</div>
<div class="totemBlock" data-channel="S4" data-weight-target="b3">
<div class="fieldGrid">
<div class="plateGroup"><span class="plateLabel">MATRÍCULA:</span><div class="plateDisplay" data-plate-target="b3">---</div></div>
</div>
<div class="signalArea">
<div class="signal green" data-signal="S4" title="Click para solicitar cambio de semáforo">S4</div>
<button class="barrierBtn" data-barrier="S4">Barrera</button>
</div>
</div>
</div>
</section>
</main>
<section class="actionsPanel">
<button id="updateTableBtn" class="sendUpdateBtn">ACTUALIZAR TABLA</button>
<div>
<label for="tableSelect">Tabla</label>
<select id="tableSelect">
<option value="Matriculas">Matriculas</option>
<option value="MatriculasIdentificadores">MatriculasIdentificadores</option>
<option value="Identificadores">Identificadores</option>
<option value="UbicacionesInternas">UbicacionesInternas</option>
</select>
</div>
<label class="checkboxWrap"><input id="allTables" type="checkbox" /> TODAS</label>
<div class="hint">El comando usa el GUID configurado en la cabecera.</div>
<div class="configControls">
<button id="loadConfigFileBtn" class="configBtn" type="button">Cargar config</button>
<button id="useEmbeddedConfigBtn" class="configBtn" type="button">Config base</button>
</div>
</section>
<section class="manualPanel">
<div>
<label for="manualFrame">Trama WebSocket manual</label>
<textarea id="manualFrame" class="manualInput" spellcheck="false" placeholder='{"sender":"servidor","device":"totem","accion":"semaforo_on","totem_id":"totem 1","numero_vial":1,"IdCentroProveedor":"GUID"}'></textarea>
</div>
<button id="sendManualBtn" class="sendManualBtn">ENVIAR TRAMA</button>
</section>
<section class="logPanel">
<div class="logHeader">
<span>LOG WEBSOCKET</span>
<div>
<button id="clearLogBtn">Limpiar log</button>
</div>
</div>
<div id="logBox" class="logBox"></div>
</section>
</div>
<script id="monitorConfig" type="application/json">
{
"version": 10,
"plantaInicial": "02C9381D-D4A0-4503-B9A4-890BFABD9608",
"plantas": [
{
"nombre": "CANTABRIA",
"idCentroProveedor": "02C9381D-D4A0-4503-B9A4-890BFABD9608",
"supervisor": {
"nombre": "Supervisor LOCAL",
"websocketUrl": "ws://127.0.0.1:25009/"
},
"viales": 4,
"totems": [
{
"id": "totem_1",
"nombre": "entrada",
"ip": "192.168.0.29",
"url": "http://192.168.0.29:1880/ui?monitor=1"
},
{
"id": "totem_2",
"nombre": "salida",
"ip": "127.0.0.1:25054",
"url": "http://127.0.0.1:25054/ui?monitor=1"
},
{
"id": "totem_3",
"nombre": "entrada",
"ip": "127.0.0.1:25059",
"url": "http://127.0.0.1:25059/ui?monitor=1"
},
{
"id": "totem_4",
"nombre": "salida",
"ip": "192.168.0.29",
"url": "http://192.168.0.29:1880/ui?monitor=1"
},
{
"id": "totem_5",
"nombre": "entrada",
"ip": "192.168.0.29",
"url": "http://192.168.0.29:1880/ui?monitor=1"
}
],
"tablas": [
"Matriculas",
"Identificadores",
"MatriculasIdentificadores",
"UbicacionesInternas"
]
},
{
"nombre": "ZARAGOZA",
"idCentroProveedor": "02C9381D-D4A0-4503-B9A4-890BFABD9608",
"supervisor": {
"nombre": "Supervisor",
"websocketUrl": "ws://127.0.0.1:25009/"
},
"viales": 3,
"totems": [
{
"id": "totem_1",
"nombre": "entrada",
"ip": "192.168.0.29",
"url": "http://192.168.0.29:1880/ui?monitor=1"
},
{
"id": "totem_2",
"nombre": "salida",
"ip": "127.0.0.1:25054",
"url": "http://127.0.0.1:25054/ui?monitor=1"
},
{
"id": "totem_3",
"nombre": "entrada",
"ip": "127.0.0.1:25059",
"url": "http://127.0.0.1:25059/ui?monitor=1"
},
{
"id": "totem_4",
"nombre": "salida",
"ip": "192.168.0.29",
"url": "http://192.168.0.29:1880/ui?monitor=1"
}
],
"tablas": [
"Matriculas",
"Identificadores",
"MatriculasIdentificadores",
"UbicacionesInternas"
]
}
],
"pantalla": {
"tarjetas": [
{
"id": "card_1",
"vialNumero": 1,
"totemId": "totem_1",
"totemScreenEnabled": true
},
{
"id": "card_2",
"vialNumero": 2,
"totemId": "totem_2",
"totemScreenEnabled": true
},
{
"id": "card_3",
"vialNumero": 3,
"totemIds": ["totem_3", "totem_4"],
"totemScreenEnabled": true
}
]
},
"websocketUrls": [
{
"nombre": "Supervisor LOCAL",
"url": "ws://127.0.0.1:25009/"
}
],
"centrosProveedor": [
{
"nombre": "CANTABRIA",
"id": "02C9381D-D4A0-4503-B9A4-890BFABD9608"
},
{
"nombre": "ZARAGOZA",
"id": "02C9381D-D4A0-4503-B9A4-890BFABD9608"
}
],
"totemUrls": [
{
"nombre": "totem_1 - entrada",
"url": "http://192.168.0.29:1880/ui?monitor=1"
},
{
"nombre": "totem_2 - salida",
"url": "http://127.0.0.1:25054/ui?monitor=1"
},
{
"nombre": "totem_3 - entrada",
"url": "http://127.0.0.1:25059/ui?monitor=1"
},
{
"nombre": "totem_4 - salida",
"url": "http://192.168.0.29:1880/ui?monitor=1"
},
{
"nombre": "totem_5 - entrada",
"url": "http://192.168.0.29:1880/ui?monitor=1"
}
],
"tablas": [
"Matriculas",
"Identificadores",
"MatriculasIdentificadores",
"UbicacionesInternas"
]
}
</script>
<script>
let ws = null;
let reconnectTimer = null;
let manualDisconnect = false;
const APP_SENDER = 'servidor';
const LOW_WEIGHT_CLEAR_KG = 500;
const LOW_WEIGHT_CLEAR_DELAY_MS = 2000;
const lowWeightClearTimers = new Map();
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const els = {
wsUrl: $('#wsUrl'),
connectBtn: $('#connectBtn'),
centroProveedor: $('#centroProveedor'),
plantaSelect: $('#centroProveedor'),
plantaName: $('#plantaName'),
styleBtn: $('#styleBtn'),
styleMenu: $('#styleMenu'),
themeBtn: $('#themeBtn'),
tableSelect: $('#tableSelect'),
allTables: $('#allTables'),
updateTableBtn: $('#updateTableBtn'),
loadConfigFileBtn: $('#loadConfigFileBtn'),
useEmbeddedConfigBtn: $('#useEmbeddedConfigBtn'),
configFileInput: $('#configFileInput'),
manualFrame: $('#manualFrame'),
sendManualBtn: $('#sendManualBtn'),
logBox: $('#logBox'),
clearLogBtn: $('#clearLogBtn'),
focusBackBtn: $('#focusBackBtn'),
toast: $('#toast')
};
const STORAGE_KEYS = {
wsUrl: 'tester.wsUrl',
plantaKey: 'tester.plantaKey',
centroProveedor: 'tester.IdCentroProveedor',
theme: 'tester.theme',
visualStyle: 'tester.visualStyle.v11',
config: 'tester.scaleConfig',
tableConfig: 'tester.tableConfig',
manualFrame: 'tester.manualFrame',
totemScreenUrl: 'tester.totemScreenUrl',
totemScreenEnabled: 'tester.totemScreenEnabled',
monitorConfig: 'tester.monitorConfig'
};
function now() {
return new Date().toLocaleTimeString('es-ES', { hour12: false });
}
function log(kind, text, obj) {
const div = document.createElement('div');
div.className = 'logLine ' + kind;
const prefix = kind === 'tx' ? 'TX' : kind === 'rx' ? 'RX' : 'ERR';
let body = text;
if (obj !== undefined) {
try { body += ' ' + JSON.stringify(obj); } catch { body += ' ' + String(obj); }
}
div.textContent = `[${now()}] ${prefix} ${body}`;
els.logBox.appendChild(div);
els.logBox.scrollTop = els.logBox.scrollHeight;
}
let activeMonitorConfig = null;
function normalizeTotemConfig(totem) {
const id = String(totem && totem.id ? totem.id : '').trim();
const nombre = String(totem && totem.nombre ? totem.nombre : '').trim();
const url = String(totem && totem.url ? totem.url : '').trim();
return id ? { id, nombre, ip: String(totem && totem.ip ? totem.ip : '').trim(), url } : null;
}
function getPlantKey(planta, index = 0) {
return String(planta && planta.nombre ? planta.nombre : `planta_${index + 1}`).trim();
}
function normalizePlant(planta, index) {
const normalized = planta && typeof planta === 'object' ? planta : {};
const totems = Array.isArray(normalized.totems) ? normalized.totems.map(normalizeTotemConfig).filter(Boolean) : [];
return {
key: getPlantKey(normalized, index),
nombre: String(normalized.nombre || `PLANTA ${index + 1}`).trim(),
idCentroProveedor: String(normalized.idCentroProveedor || '').trim(),
supervisor: {
nombre: String(normalized.supervisor && normalized.supervisor.nombre ? normalized.supervisor.nombre : '').trim(),
websocketUrl: String(normalized.supervisor && normalized.supervisor.websocketUrl ? normalized.supervisor.websocketUrl : '').trim()
},
viales: Math.max(0, Number(normalized.viales) || 0),
totems,
tablas: Array.isArray(normalized.tablas) ? normalized.tablas.filter(Boolean).map(String) : []
};
}
function normalizeMonitorConfig(raw) {
const cfg = raw && typeof raw === 'object' ? raw : {};
const plantas = Array.isArray(cfg.plantas) ? cfg.plantas.map(normalizePlant).filter(planta => planta.nombre) : [];
const fallbackPlant = plantas[0] || null;
const websocketUrls = Array.isArray(cfg.websocketUrls) ? cfg.websocketUrls.filter(x => x && x.url) : [];
const centrosProveedor = Array.isArray(cfg.centrosProveedor) ? cfg.centrosProveedor.filter(x => x && x.id) : [];
const totemUrls = Array.isArray(cfg.totemUrls) ? cfg.totemUrls.filter(x => x && x.url) : [];
const tablas = Array.isArray(cfg.tablas) ? cfg.tablas.filter(Boolean).map(String) : [];
return {
version: cfg.version || 10,
plantaInicial: String(cfg.plantaInicial || '').trim(),
plantas,
pantalla: cfg.pantalla && typeof cfg.pantalla === 'object' ? cfg.pantalla : { tarjetas: [] },
websocketUrls: websocketUrls.length ? websocketUrls : (fallbackPlant && fallbackPlant.supervisor.websocketUrl ? [{ nombre: fallbackPlant.supervisor.nombre || fallbackPlant.nombre, url: fallbackPlant.supervisor.websocketUrl }] : []),
centrosProveedor: centrosProveedor.length ? centrosProveedor : plantas.map(planta => ({ nombre: planta.nombre, id: planta.idCentroProveedor })).filter(item => item.id),
totemUrls: totemUrls.length ? totemUrls : (fallbackPlant ? fallbackPlant.totems.map(totem => ({ nombre: `${totem.id}${totem.nombre ? ' - ' + totem.nombre : ''}`, url: totem.url })).filter(item => item.url) : []),
tablas: tablas.length ? tablas : (fallbackPlant ? fallbackPlant.tablas : [])
};
}
function readEmbeddedMonitorConfig() {
try {
const el = $('#monitorConfig');
return normalizeMonitorConfig(JSON.parse(el ? el.textContent : '{}'));
} catch (err) {
log('err', 'No se pudo leer la configuración embebida: ' + err.message);
return normalizeMonitorConfig({});
}
}
function readStoredMonitorConfig() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.monitorConfig);
if (!raw) return null;
const cfg = normalizeMonitorConfig(JSON.parse(raw));
return cfg.plantas.length ? cfg : null;
} catch (err) {
log('err', 'No se pudo leer la configuración guardada: ' + err.message);
return null;
}
}
function populateDatalist(id, items, valueKey, labelKey) {
const list = $('#' + id);
if (!list) return;
list.innerHTML = '';
items.forEach(item => {
const option = document.createElement('option');
option.value = item[valueKey];
if (item[labelKey]) option.label = item[labelKey];
list.appendChild(option);
});
}
function getInitialPlantKey(cfg) {
if (!cfg || !cfg.plantas.length) return '';
const saved = localStorage.getItem(STORAGE_KEYS.plantaKey);
if (saved && cfg.plantas.some(planta => planta.key === saved)) return saved;
if (cfg.plantaInicial) {
const byGuid = cfg.plantas.find(planta => planta.idCentroProveedor === cfg.plantaInicial);
if (byGuid) return byGuid.key;
const byKey = cfg.plantas.find(planta => planta.key === cfg.plantaInicial || planta.nombre === cfg.plantaInicial);
if (byKey) return byKey.key;
}
return cfg.plantas[0].key;
}
function getSelectedPlant() {
if (!activeMonitorConfig || !activeMonitorConfig.plantas.length) return null;
const selectedKey = els.plantaSelect.value || getInitialPlantKey(activeMonitorConfig);
return activeMonitorConfig.plantas.find(planta => planta.key === selectedKey) || activeMonitorConfig.plantas[0];
}
function populatePlantSelect(plantas) {
if (!els.plantaSelect) return;
const current = els.plantaSelect.value || localStorage.getItem(STORAGE_KEYS.plantaKey);
els.plantaSelect.innerHTML = '';
plantas.forEach(planta => {
const option = document.createElement('option');
option.value = planta.key;
option.dataset.guid = planta.idCentroProveedor;
option.textContent = planta.idCentroProveedor && planta.nombre ? `${planta.idCentroProveedor} - ${planta.nombre}` : (planta.idCentroProveedor || planta.nombre);
option.title = planta.nombre;
els.plantaSelect.appendChild(option);
});
if (plantas.some(planta => planta.key === current)) els.plantaSelect.value = current;
else if (plantas[0]) els.plantaSelect.value = plantas[0].key;
}
function syncGuidSelectDisplay(expanded = false) {
if (!activeMonitorConfig || !els.plantaSelect) return;
const selected = els.plantaSelect.value;
Array.from(els.plantaSelect.options).forEach(option => {
const planta = activeMonitorConfig.plantas.find(item => item.key === option.value);
if (!planta) return;
option.textContent = expanded && option.value !== selected && planta.idCentroProveedor && planta.nombre
? `${planta.idCentroProveedor} - ${planta.nombre}`
: (planta.idCentroProveedor || planta.nombre);
});
}
function plantWebsocketItems(planta) {
if (!planta || !planta.supervisor.websocketUrl) return activeMonitorConfig ? activeMonitorConfig.websocketUrls : [];
return [{ nombre: planta.supervisor.nombre || planta.nombre, url: planta.supervisor.websocketUrl }];
}
function plantTotemUrlItems(planta) {
if (!planta || !planta.totems.length) return activeMonitorConfig ? activeMonitorConfig.totemUrls : [];
return planta.totems.map(totem => ({
id: totem.id,
nombre: `${totem.id}${totem.nombre ? ' - ' + totem.nombre : ''}`,
url: totem.url
})).filter(item => item.url);
}
function getCardInitialConfig(card) {
if (!activeMonitorConfig || !activeMonitorConfig.pantalla || !Array.isArray(activeMonitorConfig.pantalla.tarjetas)) return null;
const index = Number(card.dataset.scale) - 1;
return activeMonitorConfig.pantalla.tarjetas[index] || null;
}
function populateVialOptions(planta, force = false) {
const max = Math.max(1, Number(planta && planta.viales) || 1);
$$('.scaleCard').forEach(card => {
const select = $('[data-card-vial]', card);
if (!select) return;
const currentValue = String(select.value || '').trim();
const current = Number(currentValue);
const initial = getCardInitialConfig(card);
const preferredValue = String(initial && initial.vialNumero != null ? initial.vialNumero : '').trim();
const preferred = Number(preferredValue);
select.innerHTML = '';
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = '';
select.appendChild(emptyOption);
for (let i = 1; i <= max; i += 1) {
const option = document.createElement('option');
option.value = String(i);
option.textContent = String(i);
select.appendChild(option);
}
const next = !force && currentValue !== '' && Number.isFinite(current) && current >= 1 && current <= max ? String(current) : (preferredValue === '' ? '' : (Number.isFinite(preferred) && preferred >= 1 && preferred <= max ? String(preferred) : ''));
select.value = next;
});
}
function getTotemSelectByChannel(channel) {
return $(`[data-channel-totem="${channel}"]`);
}
function setTotemSelectValue(channel, value) {
const select = getTotemSelectByChannel(channel);
if (!select) return false;
if (!value) {
select.value = '';
return true;
}
const normalized = normalizeTotemId(value);
const option = Array.from(select.options).find(item => normalizeTotemId(item.value) === normalized || normalizeTotemId(item.dataset.legacy || '') === normalized);
if (!option) return false;
select.value = option.value;
return true;
}
function populateChannelTotemOptions(planta, force = false) {
const totems = planta && planta.totems.length ? planta.totems : [];
$$('[data-channel-totem]').forEach(select => {
const channel = select.dataset.channelTotem;
const current = select.value;
select.innerHTML = '';
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = '';
select.appendChild(emptyOption);
totems.forEach(totem => {
const option = document.createElement('option');
option.value = totem.id;
option.dataset.url = totem.url || '';
option.dataset.legacy = totem.id.replace(/_/g, ' ');
option.textContent = totem.id;
select.appendChild(option);
});
const card = select.closest('.scaleCard');
const initial = getCardInitialConfig(card);
const initialTotems = initial ? (Array.isArray(initial.totemIds) ? initial.totemIds : [initial.totemId]).filter(Boolean) : [];
const channelIndex = channel === 'S4' ? 1 : 0;
const isSharedCardChannel = channel === 'S3' || channel === 'S4';
const preferred = initialTotems[channelIndex] || (!isSharedCardChannel ? initialTotems[0] : '') || (!isSharedCardChannel && totems[channelIndex] ? totems[channelIndex].id : '') || (!isSharedCardChannel && totems[0] ? totems[0].id : '') || '';
if (!force && current && totems.some(totem => normalizeTotemId(totem.id) === normalizeTotemId(current))) select.value = current;
else setTotemSelectValue(channel, preferred);
});
}
function populateWebsocketUrlSelect(items) {
if (!els.wsUrl) return;
const current = els.wsUrl.value;
els.wsUrl.innerHTML = '';
items.forEach(item => {
const option = document.createElement('option');
option.value = item.url;
option.textContent = item.nombre ? `${item.nombre} - ${item.url}` : item.url;
els.wsUrl.appendChild(option);
});
if (items.some(item => item.url === current)) els.wsUrl.value = current;
else if (items[0]) els.wsUrl.value = items[0].url;
}
function syncWebsocketUrlSelect() {}
function ensureTotemManualOption(select, url) {
if (!select || !url) return;
const exists = Array.from(select.options).some(option => option.value === url);
if (exists) return;
const option = document.createElement('option');
option.value = url;
option.textContent = `Manual - ${url}`;
option.dataset.manual = '1';
select.appendChild(option);
}
function populateTotemUrlSelects(items) {
$$('.totemScreenPanel').forEach((panel, index) => {
const select = $('[data-totem-select]', panel);
const input = $('[data-totem-url]', panel);
if (!select) return;
const current = input ? input.value : '';
const fallback = (items[index] || items[0] || {}).url || '';
select.innerHTML = '';
items.forEach(item => {
const option = document.createElement('option');
option.value = item.url;
option.textContent = panel.classList.contains('imageTotemPanel') && item.id ? item.id : (item.nombre ? `${item.nombre} - ${item.url}` : item.url);
select.appendChild(option);
});
if (current && !items.some(item => item.url === current)) ensureTotemManualOption(select, current);
select.value = current && Array.from(select.options).some(option => option.value === current) ? current : fallback;
if (input) input.value = select.value;
});
}
function populateTableSelect(tablas) {
const current = els.tableSelect.value;
els.tableSelect.innerHTML = '';
tablas.forEach(tabla => {
const option = document.createElement('option');
option.value = tabla;
option.textContent = tabla;
els.tableSelect.appendChild(option);
});
if (tablas.includes(current)) els.tableSelect.value = current;
else if (tablas.length) els.tableSelect.value = tablas[0];
}
function applyMonitorConfig(cfg, options = {}) {
activeMonitorConfig = normalizeMonitorConfig(cfg);
const force = options.force === true;
populatePlantSelect(activeMonitorConfig.plantas);
if (force && activeMonitorConfig.plantas.length) els.plantaSelect.value = getInitialPlantKey(activeMonitorConfig);
applySelectedPlant({ force, save: false });
applyConfiguredTotemScreenPower({ force });
restoreTotemScreenPower();
}
function applySelectedPlant(options = {}) {
const force = options.force === true;
const save = options.save !== false;
const planta = getSelectedPlant();
const websocketItems = planta ? plantWebsocketItems(planta) : activeMonitorConfig.websocketUrls;
const totemItems = planta ? plantTotemUrlItems(planta) : activeMonitorConfig.totemUrls;
const tablas = planta && planta.tablas.length ? planta.tablas : activeMonitorConfig.tablas;
populateWebsocketUrlSelect(websocketItems);
populateDatalist('centroProveedorOptions', activeMonitorConfig.centrosProveedor, 'id', 'nombre');
populateVialOptions(planta, force);
populateChannelTotemOptions(planta, force);
populateTotemUrlSelects(totemItems);
refreshSharedTotemViewerOptions();
populateTableSelect(tablas);
if (planta) {
els.centroProveedor.value = planta.key;
els.plantaName.textContent = planta.nombre || '---';
localStorage.setItem(STORAGE_KEYS.centroProveedor, planta.idCentroProveedor);
localStorage.setItem(STORAGE_KEYS.plantaKey, planta.key);
syncGuidSelectDisplay(false);
} else {
els.plantaName.textContent = '---';
}
const savedWsUrl = localStorage.getItem(STORAGE_KEYS.wsUrl);
const savedWsIsValid = savedWsUrl && websocketItems.some(item => item.url === savedWsUrl);
if (savedWsIsValid) {
els.wsUrl.value = savedWsUrl;
} else if (websocketItems[0] && (force || !els.wsUrl.value.trim())) {
els.wsUrl.value = websocketItems[0].url;
localStorage.setItem(STORAGE_KEYS.wsUrl, normalizeWsUrl(els.wsUrl.value));
}
syncWebsocketUrlSelect();
$$('.totemScreenPanel').forEach((panel, index) => {
if (panel.classList.contains('imageTotemPanel')) return;
const input = $('[data-totem-url]', panel);
const configured = totemItems[index] || totemItems[0];
if (input && configured && (force || !input.value.trim())) input.value = configured.url;
const select = $('[data-totem-select]', panel);
if (select && input && totemItems.some(item => item.url === input.value)) select.value = input.value;
});
syncTotemScreensFromChannelSelections({ force });
if (save) saveConfig();
}
function loadInitialMonitorConfig() {
const stored = readStoredMonitorConfig();
applyMonitorConfig(stored || readEmbeddedMonitorConfig(), { force: true });
log('rx', stored ? 'Configuración cargada desde memoria local' : 'Configuración embebida cargada');
}
function useEmbeddedMonitorConfig() {
localStorage.removeItem(STORAGE_KEYS.monitorConfig);
applyMonitorConfig(readEmbeddedMonitorConfig(), { force: true });
saveConfig();
restoreTotemScreenUrls();
loadTotemScreen();
showToast('Configuración base aplicada');
log('rx', 'Configuración base embebida aplicada');
}
function handleConfigFileSelected(event) {
const file = event.target.files && event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const cfg = normalizeMonitorConfig(JSON.parse(String(reader.result || '{}')));
localStorage.setItem(STORAGE_KEYS.monitorConfig, JSON.stringify(cfg));
applyMonitorConfig(cfg, { force: true });
saveConfig();
loadTotemScreen();
showToast('Configuración JSON cargada');
log('rx', 'Configuración externa cargada', { archivo: file.name });
} catch (err) {
showToast('No se pudo cargar el JSON de configuración');
log('err', 'Error leyendo configuración JSON: ' + err.message);
} finally {
event.target.value = '';
}
};
reader.readAsText(file, 'utf-8');
}
let toastTimer = null;
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
els.toast.classList.remove('show');
}, 3200);
}
function setConnectionState(state) {
els.connectBtn.classList.remove('connected', 'disconnected', 'connecting');
if (state === 'connected') {
els.connectBtn.classList.add('connected');
els.connectBtn.textContent = 'Conectado';
} else if (state === 'connecting') {
els.connectBtn.classList.add('connecting');
els.connectBtn.textContent = 'Conectando...';
} else {
els.connectBtn.classList.add('disconnected');
els.connectBtn.textContent = 'Conectar';
}
}
function normalizeWsUrl(url) {
url = (url || '').trim();
if (!url) return '';
if (!/^wss?:\/\//i.test(url)) url = 'ws://' + url;
return url;
}
function connect() {
const url = normalizeWsUrl(els.wsUrl.value);
if (!url) {
log('err', 'URL WebSocket vacía');
return;
}
localStorage.setItem(STORAGE_KEYS.wsUrl, url);
manualDisconnect = false;
clearTimeout(reconnectTimer);
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
ws.close();
}
setConnectionState('connecting');
log('tx', 'Conectando a', { url });
try {
ws = new WebSocket(url);
} catch (err) {
setConnectionState('disconnected');
log('err', 'No se pudo crear WebSocket: ' + err.message);
return;
}
ws.onopen = () => {
setConnectionState('connected');
log('rx', 'WebSocket conectado');
};
ws.onmessage = (event) => {
let data = event.data;
let obj = null;
try { obj = JSON.parse(data); } catch {}
log('rx', obj ? 'Mensaje recibido' : String(data), obj || undefined);
if (obj) handleIncoming(obj);
};
ws.onerror = () => {
log('err', 'Error WebSocket');
};
ws.onclose = () => {
setConnectionState('disconnected');
log('err', 'WebSocket desconectado');
if (!manualDisconnect) {
reconnectTimer = setTimeout(() => {
if (!manualDisconnect) connect();
}, 3000);
}
};
}
function disconnect() {
manualDisconnect = true;
clearTimeout(reconnectTimer);
if (ws) ws.close();
setConnectionState('disconnected');
}
function sendCommand(cmd) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
log('err', 'No enviado: WebSocket desconectado', cmd);
showToast('WebSocket desconectado. No se puede enviar el comando.');
return false;
}
ws.send(JSON.stringify(cmd));
log('tx', 'Comando enviado', cmd);
return true;
}
function sendRawFrame(raw, label = 'Trama enviada') {
if (!ws || ws.readyState !== WebSocket.OPEN) {
log('err', 'No enviado: WebSocket desconectado', raw);
showToast('WebSocket desconectado. No se puede enviar la trama.');
return false;
}
ws.send(raw);
log('tx', label, raw);
return true;
}
function getCentroProveedor() {
const planta = getSelectedPlant();
return planta ? planta.idCentroProveedor : els.centroProveedor.value.trim();
}
function getChannelBlock(channel) {
return $(`.totemBlock[data-channel="${channel}"]`);
}
function getBlockConfig(block) {
const channel = block.dataset.channel;
const totemSelect = getTotemSelectByChannel(channel);
const card = block.closest('.scaleCard');
const vialSelect = $('[data-card-vial]', card);
const vialValue = vialSelect ? String(vialSelect.value || '').trim() : '';
return {
totem_id: totemSelect ? totemSelect.value.trim() : '',
numero_vial: vialValue === '' ? '' : Number(vialValue),
weightTarget: block.dataset.weightTarget
};
}
function toggleSignal(signalEl) {
const channel = signalEl.dataset.signal;
const block = getChannelBlock(channel);
const cfg = getBlockConfig(block);
// Nueva política:
// El click NO cambia el color localmente. Solo solicita al supervisor/tótem
// el estado contrario al que estamos mostrando. El color real se actualiza
// únicamente cuando recibimos accion = "update_semaforo".
const current = signalEl.classList.contains('green') ? 'green' : 'red';
const requested = current === 'green' ? 'red' : 'green';
const guid = getCentroProveedor();
if (!guid) {
log('err', 'No enviado: IdCentroProveedor vacío');
els.centroProveedor.focus();
return;
}
const ok = sendCommand({
sender: APP_SENDER,
device: 'totem',
accion: requested === 'green' ? 'semaforo_on' : 'semaforo_off',
totem_id: cfg.totem_id,
numero_vial: cfg.numero_vial,
IdCentroProveedor: guid
});
if (ok) {
log('rx', `Solicitud enviada para ${channel}: ${current}${requested}. Esperando update_semaforo para cambiar el display.`);
}
}
function sendBarrier(channel) {
const block = getChannelBlock(channel);
const cfg = getBlockConfig(block);
sendCommand({
sender: APP_SENDER,
device: 'totem',
accion: 'abrirbarrera',
totem_id: cfg.totem_id,
numero_vial: cfg.numero_vial,
IdCentroProveedor: getCentroProveedor()
});
}
function normalizeTotemId(value) {
return String(value ?? '').trim().toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ');
}
function setSignalVisual(signalEl, color, save = true) {
const normalized = String(color || '').trim().toLowerCase();
if (!['green', 'red'].includes(normalized)) return false;
signalEl.classList.toggle('green', normalized === 'green');
signalEl.classList.toggle('red', normalized === 'red');
if (save) saveConfig();
return true;
}
function updateSignalFromMessage(msg) {
const sem = msg && msg.semaforo;
if (!sem || typeof sem !== 'object') return false;
const incomingTotem = String(sem.totem_id ?? '').trim();
const incomingVial = Number(sem.numero_vial);
const incomingColor = String(sem.semaforo ?? '').trim().toLowerCase();
if (!incomingTotem || !Number.isFinite(incomingVial) || !['green', 'red'].includes(incomingColor)) {
log('err', 'update_semaforo incompleto o con color inválido', msg);
return true;
}
const matches = $$('.totemBlock').filter(block => {
const cfg = getBlockConfig(block);
if (incomingTotem === '*') return Number(cfg.numero_vial) === incomingVial;
return normalizeTotemId(cfg.totem_id) === normalizeTotemId(incomingTotem) && Number(cfg.numero_vial) === incomingVial;
});
if (!matches.length) {
log('err', 'update_semaforo recibido sin coincidencia', msg);
return true;
}
for (const block of matches) {
const signal = $('.signal', block);
setSignalVisual(signal, incomingColor, false);
}
saveConfig();
log('rx', `Semáforo actualizado por supervisor: ${incomingTotem} / vial ${incomingVial}${incomingColor}`);
return true;
}
function getTargetsByVial(numeroVial) {
const incomingVial = Number(numeroVial);
if (!Number.isFinite(incomingVial)) return [];
return Array.from(new Set(
$$('.totemBlock')
.filter(block => Number(getBlockConfig(block).numero_vial) === incomingVial)
.map(block => block.dataset.weightTarget)
.filter(Boolean)
));
}
function getTargetsByTotemAndVial(totemId, numeroVial) {
const incomingTotem = String(totemId ?? '').trim();
const incomingVial = Number(numeroVial);
if (!Number.isFinite(incomingVial)) return [];
if (!incomingTotem || incomingTotem === '*') return getTargetsByVial(numeroVial);
return Array.from(new Set(
$$('.totemBlock')
.filter(block => {
const cfg = getBlockConfig(block);
return normalizeTotemId(cfg.totem_id) === normalizeTotemId(incomingTotem) && Number(cfg.numero_vial) === incomingVial;
})
.map(block => block.dataset.weightTarget)
.filter(Boolean)
));
}
function normalizeImageSrc(base64) {
const value = String(base64 ?? '').trim();
if (!value) return '';
if (/^data:image\//i.test(value)) return value;
return 'data:image/jpeg;base64,' + value;
}
function renderActiveImage(grid) {
const active = ['context', 'totem'].includes(grid.dataset.activeImage) ? grid.dataset.activeImage : 'vehicle';
const viewport = $('.imageViewport[data-img-kind="active"]', grid);
const modeSelect = $('[data-image-mode]', grid);
const toggle = $('[data-image-toggle]', grid);
if (!viewport) return false;
if (active === 'totem' && $('.imageTotemPanel', grid)) refreshSharedTotemViewerOptions(false);
if (modeSelect && modeSelect.value !== active) modeSelect.value = active;
const src = active === 'context' ? grid.dataset.contextSrc : grid.dataset.vehicleSrc;
const hasVehicle = Boolean(grid.dataset.vehicleSrc);
const hasContext = Boolean(grid.dataset.contextSrc);
const totemInput = $('[data-totem-url]', grid);
const hasTotemUrl = Boolean(totemInput && totemInput.value.trim());
$$('[data-image-status]', grid).forEach(status => {
const kind = status.dataset.imageStatus;
const isAvailable = kind === 'totem' ? hasTotemUrl : (kind === 'context' ? hasContext : hasVehicle);
status.classList.toggle('available', isAvailable);
status.classList.toggle('active', kind === active);
});
if (toggle) toggle.textContent = active === 'context' ? 'Ver camión' : 'Ver contexto';
if (active === 'totem') {
scheduleTotemScreenResize();
loadTotemScreen($('.imageTotemPanel', grid));
return true;
}
if (!src) {
viewport.innerHTML = 'Sin imagen';
return true;
}
viewport.innerHTML = '';
const img = document.createElement('img');
img.alt = active === 'context' ? 'Imagen de contexto' : 'Imagen del camión';
img.src = src;
viewport.appendChild(img);
return true;
}
function setImage(target, kind, base64) {
const grid = $(`.imageGrid[data-images="${target}"]`);
if (!grid || !['vehicle', 'context'].includes(kind)) return false;
const src = normalizeImageSrc(base64);
if (kind === 'context') grid.dataset.contextSrc = src;
else grid.dataset.vehicleSrc = src;
return renderActiveImage(grid);
}
function toggleImageView(grid) {
grid.dataset.activeImage = grid.dataset.activeImage === 'context' ? 'vehicle' : 'context';
renderActiveImage(grid);
}
function setImageView(grid, mode) {
grid.dataset.activeImage = ['vehicle', 'context', 'totem'].includes(mode) ? mode : 'vehicle';
renderActiveImage(grid);
}
function normalizeHttpUrl(url) {
let value = String(url || '').trim();
if (!value) return '';
if (!/^https?:\/\//i.test(value)) value = 'http://' + value;
return value;
}
function getTotemScreenKey(panel) {
return `${STORAGE_KEYS.totemScreenUrl}.${panel.dataset.totemScreen || 'default'}`;
}
function resizeTotemScreenFrame(frame = null) {
const frames = frame && frame.classList && frame.classList.contains('totemScreenFrame') ? [frame] : $$('.totemScreenFrame');
frames.forEach(item => {
const viewport = item.closest('.totemScreenViewport');
if (!viewport) return;
const scale = Math.min(viewport.clientWidth / 800, viewport.clientHeight / 480);
item.style.setProperty('--totem-scale', String(scale));
});
}
function scheduleTotemScreenResize() {
resizeTotemScreenFrame();
requestAnimationFrame(resizeTotemScreenFrame);
setTimeout(resizeTotemScreenFrame, 120);
setTimeout(resizeTotemScreenFrame, 350);
}
function getTotemScreenEnabledKey(panel) {
return `${STORAGE_KEYS.totemScreenEnabled}.${panel.dataset.totemScreen || 'default'}`;
}
function isTotemScreenEnabled(panel) {
return localStorage.getItem(getTotemScreenEnabledKey(panel)) !== '0';
}
function getTotemScreenPanelForCardIndex(index) {
return $(`.totemScreenPanel[data-totem-screen="b${index + 1}"]`);
}
function applyConfiguredTotemScreenPower(options = {}) {
if (!activeMonitorConfig || !activeMonitorConfig.pantalla || !Array.isArray(activeMonitorConfig.pantalla.tarjetas)) return;
activeMonitorConfig.pantalla.tarjetas.forEach((card, index) => {
if (!card || typeof card.totemScreenEnabled !== 'boolean') return;
const panel = getTotemScreenPanelForCardIndex(index);
if (!panel) return;
const key = getTotemScreenEnabledKey(panel);
if (options.force || localStorage.getItem(key) === null) {
localStorage.setItem(key, card.totemScreenEnabled ? '1' : '0');
}
});
}
function setTotemScreenPower(panel, enabled) {
const frame = $('[data-totem-frame]', panel);
const btn = $('[data-totem-power]', panel);
localStorage.setItem(getTotemScreenEnabledKey(panel), enabled ? '1' : '0');
panel.classList.toggle('totem-screen-off', !enabled);
if (btn) {
btn.textContent = enabled ? 'OFF' : 'ON';
btn.classList.toggle('is-off', !enabled);
btn.title = enabled ? 'Apagar pantalla del tótem' : 'Encender pantalla del tótem';
}
if (!enabled && frame) {
frame.removeAttribute('src');
frame.src = 'about:blank';
}
}
function restoreTotemScreenPower() {
$$('.totemScreenPanel').forEach(panel => setTotemScreenPower(panel, isTotemScreenEnabled(panel)));
}
function loadTotemScreen(panel = null) {
const panels = panel ? [panel] : $$('.totemScreenPanel');
panels.forEach(item => {
const input = $('[data-totem-url]', item);
const frame = $('[data-totem-frame]', item);
if (!input || !frame) return;
if (!isTotemScreenEnabled(item)) {
frame.removeAttribute('src');
frame.src = 'about:blank';
return;
}
const url = normalizeHttpUrl(input.value);
input.value = url;
localStorage.setItem(getTotemScreenKey(item), url);
if (url) frame.src = url;
resizeTotemScreenFrame(frame);
});
}
function getTotemOptionUrl(channel) {
const select = getTotemSelectByChannel(channel);
if (!select) return '';
const option = select.options[select.selectedIndex];
return option ? option.dataset.url || '' : '';
}
function refreshSharedTotemViewerOptions(render = true) {
const panel = $('.imageTotemPanel[data-totem-screen="b3"]');
const select = panel ? $('[data-totem-select]', panel) : null;
const input = panel ? $('[data-totem-url]', panel) : null;
if (!panel || !select || !input) return;
const current = select.value || input.value;
select.innerHTML = '';
['S3', 'S4'].forEach(channel => {
const channelSelect = getTotemSelectByChannel(channel);
const selectedOption = channelSelect ? channelSelect.options[channelSelect.selectedIndex] : null;
const url = selectedOption ? selectedOption.dataset.url || '' : '';
if (!url) return;
const option = document.createElement('option');
option.value = url;
option.textContent = `${channel} - ${channelSelect.value}`;
select.appendChild(option);
});
if (current && Array.from(select.options).some(option => option.value === current)) select.value = current;
else if (select.options[0]) select.value = select.options[0].value;
input.value = select.value;
const grid = panel.closest('.imageGrid');
if (render && grid) renderActiveImage(grid);
}
function syncTotemScreenFromChannel(channel, options = {}) {
const block = getChannelBlock(channel);
if (!block) return;
const target = block.dataset.weightTarget;
const panel = $(`.totemScreenPanel[data-totem-screen="${target}"]`);
const input = panel ? $('[data-totem-url]', panel) : null;
const select = panel ? $('[data-totem-select]', panel) : null;
const url = getTotemOptionUrl(channel);
if (!panel || !input || !url) return;
if (options.force || !input.value || input.dataset.boundChannel === channel) {
input.value = url;
input.dataset.boundChannel = channel;
if (select && Array.from(select.options).some(option => option.value === url)) select.value = url;
localStorage.setItem(getTotemScreenKey(panel), url);
if (options.load !== false) loadTotemScreen(panel);
}
}
function syncTotemScreensFromChannelSelections(options = {}) {
syncTotemScreenFromChannel('S1', options);
syncTotemScreenFromChannel('S2', options);
syncTotemScreenFromChannel('S3', options);
}
function restoreTotemScreenUrls() {
$$('.totemScreenPanel').forEach(panel => {
const input = $('[data-totem-url]', panel);
if (!input) return;
const saved = localStorage.getItem(getTotemScreenKey(panel));
const legacy = panel.dataset.totemScreen === 'b1' ? localStorage.getItem(STORAGE_KEYS.totemScreenUrl) : '';
const restored = saved || legacy;
const planta = getSelectedPlant();
const totemItems = planta ? plantTotemUrlItems(planta) : (activeMonitorConfig ? activeMonitorConfig.totemUrls : []);
if (restored && (!activeMonitorConfig || totemItems.some(item => item.url === restored))) input.value = restored;
const select = $('[data-totem-select]', panel);
if (select && activeMonitorConfig && totemItems.some(item => item.url === input.value)) select.value = input.value;
});
}
function setPlate(target, matricula) {
const value = String(matricula ?? '').trim();
$$(`[data-plate-target="${target}"]`).forEach(el => {
el.textContent = value ? value.toUpperCase() : '---';
});
}
function clearVehicleInfo(target, reason = '') {
clearTimeout(lowWeightClearTimers.get(target));
lowWeightClearTimers.delete(target);
setPlate(target, '');
setImage(target, 'vehicle', '');
setImage(target, 'context', '');
log('rx', `Datos de vehículo limpiados en ${target}${reason ? ' (' + reason + ')' : ''}`);
}
function scheduleVehicleClearIfLowWeight(target, peso) {
const value = Number(peso);
if (!target || !Number.isFinite(value)) return;
if (value < LOW_WEIGHT_CLEAR_KG) {
if (lowWeightClearTimers.has(target)) return;
const timer = setTimeout(() => {
lowWeightClearTimers.delete(target);
clearVehicleInfo(target, `peso < ${LOW_WEIGHT_CLEAR_KG} kg durante ${LOW_WEIGHT_CLEAR_DELAY_MS / 1000}s`);
}, LOW_WEIGHT_CLEAR_DELAY_MS);
lowWeightClearTimers.set(target, timer);
log('rx', `Temporizador de limpieza iniciado en ${target}: peso ${value} kg`);
} else {
const timer = lowWeightClearTimers.get(target);
if (timer) {
clearTimeout(timer);
lowWeightClearTimers.delete(target);
log('rx', `Temporizador de limpieza cancelado en ${target}: peso ${value} kg`);
}
}
}
function updateLastPlate(data) {
const targets = getTargetsByTotemAndVial(data && data.totem_id, data && data.numero_vial);
if (!targets.length) {
log('err', 'lastplate recibido sin coincidencia de totem_id + numero_vial', data);
return true;
}
targets.forEach(target => {
setPlate(target, data.matricula);
setImage(target, 'vehicle', data.imagen);
});
log('rx', `Lastplate actualizado en ${targets.join(', ')}: ${data.matricula || 'sin matrícula'}`);
return true;
}
function updateContextImage(data) {
const targets = getTargetsByTotemAndVial(data && data.totem_id, data && data.numero_vial);
if (!targets.length) {
log('err', 'contexto recibido sin coincidencia de totem_id + numero_vial', data);
return true;
}
targets.forEach(target => setImage(target, 'context', data.imagen_contexto));
log('rx', `Imagen de contexto actualizada en ${targets.join(', ')}`);
return true;
}
function formatWeightValue(peso) {
const number = Number(peso);
if (!Number.isFinite(number)) return String(peso ?? '---');
const sign = number < 0 ? '-' : '';
const [integer, decimal] = String(Math.abs(number)).split('.');
const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return sign + grouped + (decimal ? ',' + decimal : '');
}
function updateWeight(target, peso, stable, source) {
const panel = $('#weight-' + target);
if (!panel) return;
$('.weightNum', panel).textContent = formatWeightValue(peso);
const badge = $('.stableBadge', panel);
badge.classList.remove('ok', 'no');
if (stable === true) {
badge.textContent = 'PESO ESTABLE';
badge.classList.add('ok');
} else if (stable === false) {
badge.textContent = 'PESO NO ESTABLE';
badge.classList.add('no');
} else {
badge.textContent = 'ESTABILIDAD NO INFORMADA';
}
scheduleVehicleClearIfLowWeight(target, peso);
}
function unwrapPayload(obj) {
if (obj && typeof obj === 'object' && obj.payload && typeof obj.payload === 'object') return obj.payload;
return obj;
}
function handleIncoming(raw) {
const msg = unwrapPayload(raw);
if (!msg || typeof msg !== 'object') return;
// Mensaje de estado real de semáforo enviado por el supervisor.
// Formato esperado:
// {sender:"supervisor", device:"servidor", accion:"update_semaforo", semaforo:{totem_id:"totem_1", numero_vial:2, semaforo:"red"}}
if (msg.accion === 'update_semaforo') {
updateSignalFromMessage(msg);
return;
}
if (msg.accion === 'lastplate' && msg.data && typeof msg.data === 'object') {
updateLastPlate(msg.data);
return;
}
if (msg.accion === 'lastcontext' && msg.data && typeof msg.data === 'object') {
updateContextImage(msg.data);
return;
}
if (!('peso' in msg) || !('totem_id' in msg) || !('numero_vial' in msg)) return;
const incomingTotem = String(msg.totem_id).trim();
const incomingVial = Number(msg.numero_vial);
const matches = $$('.totemBlock').filter(block => {
const cfg = getBlockConfig(block);
// Regla especial:
// si el supervisor/tótem envía totem_id = "*", el peso se enruta solo por numero_vial.
// Esto es útil porque, en esta instalación, numero_vial representa el vial operativo.
if (incomingTotem === '*') {
return Number(cfg.numero_vial) === incomingVial;
}
return normalizeTotemId(cfg.totem_id) === normalizeTotemId(incomingTotem) && Number(cfg.numero_vial) === incomingVial;
});
if (!matches.length) {
const criterio = incomingTotem === '*' ? 'numero_vial' : 'totem_id + numero_vial';
log('err', `Peso recibido sin coincidencia por ${criterio}`, msg);
return;
}
const targets = Array.from(new Set(matches.map(block => block.dataset.weightTarget).filter(Boolean)));
targets.forEach(target => updateWeight(target, msg.peso, msg.peso_estable, { totem_id: incomingTotem, numero_vial: incomingVial }));
}
function sendManualFrame() {
const raw = els.manualFrame.value.trim();
if (!raw) {
log('err', 'No enviado: trama manual vacía');
els.manualFrame.focus();
return;
}
localStorage.setItem(STORAGE_KEYS.manualFrame, raw);
try {
const obj = JSON.parse(raw);
sendCommand(obj);
} catch {
sendRawFrame(raw, 'Trama manual enviada como texto');
}
}
function sendUpdateTable() {
const guid = getCentroProveedor();
if (!guid) {
log('err', 'No enviado: IdCentroProveedor vacío');
els.centroProveedor.focus();
return;
}
const tabla = els.allTables.checked ? 'todas' : els.tableSelect.value;
sendCommand({
sender: APP_SENDER,
device: 'supervisor',
accion: 'actualizar',
tabla,
IdCentroProveedor: guid
});
}
const VISUAL_STYLES = new Set(['base', 'salvia', 'bruma', 'calma', 'selva', 'aurora']);
function applyVisualStyle(style) {
const next = VISUAL_STYLES.has(style) ? style : 'base';
document.body.dataset.style = next;
localStorage.setItem(STORAGE_KEYS.visualStyle, next);
$$('[data-style-option]').forEach(btn => {
const active = btn.dataset.styleOption === next;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function applyTheme(theme) {
const isLight = theme === 'light';
document.body.classList.toggle('light', isLight);
els.themeBtn.textContent = isLight ? '☾' : '☼';
els.themeBtn.title = isLight ? 'Cambiar a modo oscuro' : 'Cambiar a modo claro';
els.themeBtn.setAttribute('aria-label', els.themeBtn.title);
localStorage.setItem(STORAGE_KEYS.theme, isLight ? 'light' : 'dark');
}
function toggleTheme() {
applyTheme(document.body.classList.contains('light') ? 'dark' : 'light');
}
function saveConfig() {
const guid = getCentroProveedor();
const cfg = {
plantaKey: els.plantaSelect.value,
centroProveedor: guid,
channels: $$('.totemBlock').map(block => {
const channel = block.dataset.channel;
const blockCfg = getBlockConfig(block);
return {
channel,
totem_id: blockCfg.totem_id,
numero_vial: blockCfg.numero_vial,
signal: $('.signal', block).classList.contains('green') ? 'green' : 'red'
};
})
};
localStorage.setItem(STORAGE_KEYS.config, JSON.stringify(cfg));
localStorage.setItem(STORAGE_KEYS.plantaKey, els.plantaSelect.value);
localStorage.setItem(STORAGE_KEYS.centroProveedor, guid);
localStorage.setItem(STORAGE_KEYS.tableConfig, JSON.stringify({
tableSelect: els.tableSelect.value,
allTables: els.allTables.checked
}));
}
function loadConfig() {
const wsUrl = localStorage.getItem(STORAGE_KEYS.wsUrl);
const planta = getSelectedPlant();
const websocketItems = planta ? plantWebsocketItems(planta) : (activeMonitorConfig ? activeMonitorConfig.websocketUrls : []);
if (wsUrl && (!activeMonitorConfig || websocketItems.some(item => item.url === wsUrl))) els.wsUrl.value = wsUrl;
syncWebsocketUrlSelect();
const manual = localStorage.getItem(STORAGE_KEYS.manualFrame);
if (manual) els.manualFrame.value = manual;
try {
const cfg = JSON.parse(localStorage.getItem(STORAGE_KEYS.config) || 'null');
if (cfg && Array.isArray(cfg.channels)) {
if (cfg.plantaKey && activeMonitorConfig && activeMonitorConfig.plantas.some(item => item.key === cfg.plantaKey)) {
els.plantaSelect.value = cfg.plantaKey;
applySelectedPlant({ force: false, save: false });
}
for (const c of cfg.channels) {
const block = getChannelBlock(c.channel);
if (!block) continue;
setTotemSelectValue(c.channel, c.totem_id);
const card = block.closest('.scaleCard');
const vialSelect = $('[data-card-vial]', card);
if (vialSelect && c.numero_vial && Array.from(vialSelect.options).some(option => option.value === String(c.numero_vial))) vialSelect.value = String(c.numero_vial);
const signal = $('.signal', block);
const color = c.signal === 'red' ? 'red' : 'green';
setSignalVisual(signal, color, false);
}
syncTotemScreensFromChannelSelections({ force: false, load: false });
}
} catch {}
try {
const tableCfg = JSON.parse(localStorage.getItem(STORAGE_KEYS.tableConfig) || 'null');
if (tableCfg) {
if (tableCfg.tableSelect && Array.from(els.tableSelect.options).some(opt => opt.value === tableCfg.tableSelect)) els.tableSelect.value = tableCfg.tableSelect;
els.allTables.checked = tableCfg.allTables === true;
els.tableSelect.disabled = els.allTables.checked;
}
} catch {}
applyVisualStyle(localStorage.getItem(STORAGE_KEYS.visualStyle) || 'base');
applyTheme(localStorage.getItem(STORAGE_KEYS.theme) || 'dark');
}
function focusScale(scaleNumber) {
const selected = String(scaleNumber);
document.body.classList.add('focus-mode');
$$('.scaleCard').forEach(card => {
const isSelected = card.dataset.scale === selected;
card.classList.toggle('focusedScale', isSelected);
card.classList.toggle('hiddenScale', !isSelected);
});
els.focusBackBtn.textContent = `← VOLVER A VISTA GENERAL`;
window.scrollTo({ top: 0, behavior: 'smooth' });
scheduleTotemScreenResize();
log('rx', `Vista ampliada activada: vial ${selected}`);
}
function exitFocusMode() {
document.body.classList.remove('focus-mode');
$$('.scaleCard').forEach(card => {
card.classList.remove('focusedScale', 'hiddenScale');
});
window.scrollTo({ top: 0, behavior: 'smooth' });
scheduleTotemScreenResize();
log('rx', 'Vista general restaurada');
}
els.connectBtn.addEventListener('click', () => {
if (ws && ws.readyState === WebSocket.OPEN) disconnect();
else connect();
});
els.themeBtn.addEventListener('click', toggleTheme);
els.styleBtn.addEventListener('click', event => {
event.stopPropagation();
els.styleMenu.classList.toggle('open');
});
$$('[data-style-option]').forEach(btn => {
btn.addEventListener('click', () => {
applyVisualStyle(btn.dataset.styleOption);
els.styleMenu.classList.remove('open');
});
});
document.addEventListener('click', event => {
if (!els.styleMenu.contains(event.target) && event.target !== els.styleBtn) els.styleMenu.classList.remove('open');
});
els.plantaSelect.addEventListener('change', () => {
applySelectedPlant({ force: true });
applyConfiguredTotemScreenPower({ force: true });
restoreTotemScreenPower();
restoreTotemScreenUrls();
loadTotemScreen();
showToast('Planta aplicada: ' + (getSelectedPlant() ? getSelectedPlant().nombre : ''));
});
els.plantaSelect.addEventListener('focus', () => syncGuidSelectDisplay(true));
els.plantaSelect.addEventListener('mousedown', () => syncGuidSelectDisplay(true));
els.plantaSelect.addEventListener('blur', () => syncGuidSelectDisplay(false));
els.updateTableBtn.addEventListener('click', sendUpdateTable);
els.loadConfigFileBtn.addEventListener('click', () => els.configFileInput.click());
els.useEmbeddedConfigBtn.addEventListener('click', useEmbeddedMonitorConfig);
els.configFileInput.addEventListener('change', handleConfigFileSelected);
els.sendManualBtn.addEventListener('click', sendManualFrame);
els.manualFrame.addEventListener('input', () => localStorage.setItem(STORAGE_KEYS.manualFrame, els.manualFrame.value));
els.clearLogBtn.addEventListener('click', () => els.logBox.innerHTML = '');
els.focusBackBtn.addEventListener('click', exitFocusMode);
$$('.scaleTitle').forEach(title => {
title.addEventListener('dblclick', () => {
const card = title.closest('.scaleCard');
if (card) focusScale(card.dataset.scale);
});
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && document.body.classList.contains('focus-mode')) {
exitFocusMode();
}
});
els.allTables.addEventListener('change', () => {
els.tableSelect.disabled = els.allTables.checked;
saveConfig();
});
$$('.signal').forEach(el => el.addEventListener('click', () => toggleSignal(el)));
$$('.barrierBtn').forEach(btn => btn.addEventListener('click', () => sendBarrier(btn.dataset.barrier)));
$$('.imageGrid').forEach(grid => renderActiveImage(grid));
$$('[data-image-toggle]').forEach(btn => btn.addEventListener('click', () => toggleImageView(btn.closest('.imageGrid'))));
$$('[data-image-mode]').forEach(select => select.addEventListener('change', () => setImageView(select.closest('.imageGrid'), select.value)));
$$('[data-channel-totem], [data-card-vial]').forEach(input => {
input.addEventListener('change', saveConfig);
input.addEventListener('input', saveConfig);
});
$$('[data-channel-totem]').forEach(select => select.addEventListener('change', () => {
const channel = select.dataset.channelTotem;
if (channel === 'S3' || channel === 'S4') refreshSharedTotemViewerOptions();
syncTotemScreenFromChannel(channel, { force: true });
saveConfig();
}));
$$('[data-card-vial]').forEach(select => select.addEventListener('change', saveConfig));
els.tableSelect.addEventListener('change', saveConfig);
$$('[data-totem-reload]').forEach(btn => btn.addEventListener('click', () => loadTotemScreen(btn.closest('.totemScreenPanel'))));
$$('[data-totem-power]').forEach(btn => btn.addEventListener('click', () => {
const panel = btn.closest('.totemScreenPanel');
const enabled = !isTotemScreenEnabled(panel);
setTotemScreenPower(panel, enabled);
if (enabled) loadTotemScreen(panel);
}));
$$('[data-totem-select]').forEach(select => select.addEventListener('change', () => {
const panel = select.closest('.totemScreenPanel');
const input = $('[data-totem-url]', panel);
if (input && select.value) input.value = select.value;
if (input && input.value) localStorage.setItem(getTotemScreenKey(panel), input.value);
loadTotemScreen(panel);
}));
function applyTotemManualUrl(input) {
const panel = input.closest('.totemScreenPanel');
const select = $('[data-totem-select]', panel);
const url = normalizeHttpUrl(input.value);
if (!url || !select) return;
ensureTotemManualOption(select, url);
select.value = url;
input.value = url;
localStorage.setItem(getTotemScreenKey(panel), url);
panel.classList.remove('manual-url-mode');
const btn = $('[data-totem-manual-toggle]', panel);
if (btn) btn.classList.remove('is-active');
loadTotemScreen(panel);
}
$$('[data-totem-manual-toggle]').forEach(btn => btn.addEventListener('click', () => {
const panel = btn.closest('.totemScreenPanel');
const input = $('[data-totem-manual-url]', panel);
const select = $('[data-totem-select]', panel);
if (!input || !select) return;
const enabled = !panel.classList.contains('manual-url-mode');
panel.classList.toggle('manual-url-mode', enabled);
btn.classList.toggle('is-active', enabled);
if (enabled) {
input.value = select.value || '';
input.focus();
input.select();
}
}));
$$('[data-totem-manual-url]').forEach(input => {
input.addEventListener('change', () => applyTotemManualUrl(input));
input.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
applyTotemManualUrl(input);
} else if (event.key === 'Escape') {
const panel = input.closest('.totemScreenPanel');
panel.classList.remove('manual-url-mode');
const btn = $('[data-totem-manual-toggle]', panel);
if (btn) btn.classList.remove('is-active');
}
});
});
window.addEventListener('resize', scheduleTotemScreenResize);
if (window.ResizeObserver) {
$$('.totemScreenViewport').forEach(viewport => new ResizeObserver(scheduleTotemScreenResize).observe(viewport));
}
els.wsUrl.addEventListener('change', () => localStorage.setItem(STORAGE_KEYS.wsUrl, normalizeWsUrl(els.wsUrl.value)));
els.wsUrl.addEventListener('input', () => localStorage.setItem(STORAGE_KEYS.wsUrl, normalizeWsUrl(els.wsUrl.value)));
loadInitialMonitorConfig();
loadConfig();
restoreTotemScreenUrls();
restoreTotemScreenPower();
loadTotemScreen();
scheduleTotemScreenResize();
setConnectionState('disconnected');
log('rx', 'Monitor cargado. Seleccione planta, conecte el WebSocket y pulse Conectar. Click en semáforo solo envía orden; el color se actualiza con update_semaforo. Matrícula e imágenes se limpian tras 2s con peso < 500 kg. Doble click en el título de un vial para ampliarlo; botón Volver o ESC para regresar.');
</script>
</body>
</html>