<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__WGUI_TITLE__</title>
<link rel="icon" type="image/png" href="/favicon.png">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #1e1e1e;
--bg-dark: #0f0f0f;
--surface: #2a2a2a;
--surface-hover: #333333;
--surface-elevated: #3a3a3a;
--border: #404040;
--border-light: #505050;
--text: #e0e0e0;
--text-dim: #888888;
--text-muted: #666666;
--coral: #e06c5c;
--coral-glow: rgba(224, 108, 92, 0.3);
--teal: #00bfa5;
--teal-glow: rgba(0, 191, 165, 0.3);
--blue: #2196f3;
--blue-glow: rgba(33, 150, 243, 0.3);
--green: #4caf50;
--green-glow: rgba(76, 175, 80, 0.3);
--purple: #9c27b0;
--purple-glow: rgba(156, 39, 176, 0.3);
--orange: #ff9800;
--orange-glow: rgba(255, 152, 0, 0.3);
--yellow: #ffeb3b;
--yellow-glow: rgba(255, 235, 59, 0.3);
--red: #f44336;
--red-glow: rgba(244, 67, 54, 0.3);
--black: #000000;
--black-glow: rgba(0, 0, 0, 0.4);
--white: #ffffff;
--white-glow: rgba(255, 255, 255, 0.3);
--gray: #9e9e9e;
--gray-glow: rgba(158, 158, 158, 0.3);
--accent: var(--coral);
--accent-glow: var(--coral-glow);
--radius: 8px;
--radius-lg: 12px;
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
body {
font-family: var(--font);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
#app {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 46px 12px 12px;
margin: 0 auto;
}
.masonry-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.horizontal-layout {
break-inside: avoid;
column-span: all;
}
#status-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 100;
transition: background 0.3s;
}
#status-bar.connected {
background: var(--green);
}
#status-bar.disconnected {
background: var(--red);
}
#status-bar.connecting {
background: var(--yellow);
}
.card {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.card.coral {
border-color: var(--coral);
--accent: var(--coral);
--accent-glow: var(--coral-glow);
}
.card.teal {
border-color: var(--teal);
--accent: var(--teal);
--accent-glow: var(--teal-glow);
}
.card.blue {
border-color: var(--blue);
--accent: var(--blue);
--accent-glow: var(--blue-glow);
}
.card.green {
border-color: var(--green);
--accent: var(--green);
--accent-glow: var(--green-glow);
}
.card.purple {
border-color: var(--purple);
--accent: var(--purple);
--accent-glow: var(--purple-glow);
}
.card.orange {
border-color: var(--orange);
--accent: var(--orange);
--accent-glow: var(--orange-glow);
}
.card.yellow {
border-color: var(--yellow);
--accent: var(--yellow);
--accent-glow: var(--yellow-glow);
}
.card.red {
border-color: var(--red);
--accent: var(--red);
--accent-glow: var(--red-glow);
}
.card.black {
border-color: var(--black);
--accent: var(--black);
--accent-glow: var(--black-glow);
}
.card.white {
border-color: var(--white);
--accent: var(--white);
--accent-glow: var(--white-glow);
}
.card.gray {
border-color: var(--gray);
--accent: var(--gray);
--accent-glow: var(--gray-glow);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
.card-header:hover {
background: rgba(255, 255, 255, 0.04);
}
.card-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--accent);
}
.card-toggle {
font-size: 10px;
color: var(--text-dim);
transition: transform 0.2s;
margin-left: auto;
}
.card-grip {
flex-shrink: 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1;
cursor: grab;
padding: 2px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
user-select: none;
}
.card-grip:hover { color: var(--text); background: rgba(255, 255, 255, 0.06); }
.card-grip { cursor: grab; }
body.dragging-active { user-select: none; cursor: grabbing; }
.card-drag-clone {
position: fixed;
z-index: 500;
margin: 0;
pointer-events: none;
opacity: 0.95;
transform: rotate(1.2deg);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.card-placeholder {
box-sizing: border-box;
border: 2px dashed var(--accent);
border-radius: var(--radius-lg);
background: var(--accent-glow);
opacity: 0.6;
}
body.dragging-active #app { align-items: stretch; }
body.dragging-active .masonry-col { min-height: 80px; }
body.dragging-active .masonry-col:empty {
border: 2px dashed var(--border-light);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.02);
}
.layout-toolbar {
position: fixed;
top: 10px;
right: 12px;
z-index: 200;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
}
.layout-toolbar .lt-label {
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
color: var(--text-muted);
}
.lt-btn {
min-width: 22px;
padding: 3px 7px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
color: var(--text-dim);
font-family: var(--mono);
font-size: 11px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
user-select: none;
}
.lt-btn:hover { border-color: var(--border-light); color: var(--text); }
.lt-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
.lt-sep { width: 1px; height: 16px; background: var(--border); margin: 0 2px; }
.card.collapsed .card-toggle {
transform: rotate(-90deg);
}
.card.collapsed .card-body {
display: none;
}
.card-body {
display: flex;
flex-direction: column;
padding: 4px 0;
}
.stat-card {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.stat-card.coral {
border-color: var(--coral);
}
.stat-card.teal {
border-color: var(--teal);
}
.stat-card.blue {
border-color: var(--blue);
}
.stat-card.green {
border-color: var(--green);
}
.stat-card.purple {
border-color: var(--purple);
}
.stat-card.orange {
border-color: var(--orange);
}
.stat-card.yellow {
border-color: var(--yellow);
}
.stat-card.red {
border-color: var(--red);
}
.stat-card.black {
border-color: var(--black);
}
.stat-card.white {
border-color: var(--white);
}
.stat-card.gray {
border-color: var(--gray);
}
.stat-value {
font-family: var(--mono);
font-size: 28px;
font-weight: 600;
color: var(--text);
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-subvalue {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-container {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.progress-container.coral {
border-color: var(--coral);
--accent: var(--coral);
}
.progress-container.teal {
border-color: var(--teal);
--accent: var(--teal);
}
.progress-container.blue {
border-color: var(--blue);
--accent: var(--blue);
}
.progress-container.green {
border-color: var(--green);
--accent: var(--green);
}
.progress-container.purple {
border-color: var(--purple);
--accent: var(--purple);
}
.progress-container.orange {
border-color: var(--orange);
--accent: var(--orange);
}
.progress-container.yellow {
border-color: var(--yellow);
--accent: var(--yellow);
}
.progress-container.black {
border-color: var(--black);
--accent: var(--black);
}
.progress-container.white {
border-color: var(--white);
--accent: var(--white);
}
.progress-container.gray {
border-color: var(--gray);
--accent: var(--gray);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.progress-title {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.progress-percent {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
color: var(--accent);
}
.progress-percent sup {
font-size: 14px;
font-weight: 400;
margin-left: 2px;
}
.progress-track {
height: 8px;
background: var(--bg);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 4px;
transition: width 0.3s ease;
box-shadow: 0 0 10px var(--accent-glow);
}
.progress-subtitle {
font-size: 11px;
color: var(--text-dim);
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
box-shadow: 0 0 6px currentColor;
transition: background 0.2s;
}
.status-dot.green {
background: var(--green);
color: var(--green-glow);
}
.status-dot.red {
background: var(--red);
color: var(--red-glow);
}
.status-dot.yellow {
background: var(--yellow);
color: var(--yellow-glow);
}
.status-dot.blue {
background: var(--blue);
color: var(--blue-glow);
}
.status-dot.coral {
background: var(--coral);
color: var(--coral-glow);
}
.status-dot.teal {
background: var(--teal);
color: var(--teal-glow);
}
.status-dot.black {
background: var(--black);
color: var(--black-glow);
}
.status-dot.white {
background: var(--white);
color: var(--white-glow);
}
.status-dot.gray {
background: var(--gray);
color: var(--gray-glow);
}
.mini-chart {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 12px;
height: 120px;
display: flex;
flex-direction: column;
}
.mini-chart.coral {
border-color: var(--coral);
--accent: var(--coral);
}
.mini-chart.teal {
border-color: var(--teal);
--accent: var(--teal);
}
.mini-chart.blue {
border-color: var(--blue);
--accent: var(--blue);
}
.mini-chart.green {
border-color: var(--green);
--accent: var(--green);
}
.mini-chart.black {
border-color: var(--black);
--accent: var(--black);
}
.mini-chart.white {
border-color: var(--white);
--accent: var(--white);
}
.mini-chart.gray {
border-color: var(--gray);
--accent: var(--gray);
}
.mini-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 4px;
overflow: hidden;
}
.mini-chart-title {
font-size: 12px;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.mini-chart-value {
font-family: var(--mono);
font-size: 14px;
color: var(--accent);
white-space: nowrap;
flex-shrink: 0;
}
.mini-chart-canvas {
flex: 1;
width: 100%;
min-height: 0;
}
.widget-row {
display: flex;
align-items: center;
padding: 4px 16px;
gap: 12px;
min-height: 32px;
transition: background 0.1s;
position: relative;
}
.widget-row:has(.plot-container) {
display: block;
padding: 4px 16px;
}
.widget-row:hover {
background: rgba(255, 255, 255, 0.02);
}
.widget-label {
flex: 0 1 180px;
font-size: 12px;
line-height: 1.35;
color: var(--text-dim);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
padding-right: 8px;
}
.widget-control {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.slider-container {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
input[type="range"] {
flex: 1;
min-width: 0;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: var(--bg-dark);
outline: none;
border: 1px solid var(--border);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--surface);
box-shadow: 0 0 8px var(--accent-glow);
transition: background 0.15s, transform 0.15s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: var(--accent);
transform: scale(1.1);
}
.slider-value {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
min-width: 75px;
flex-shrink: 0;
text-align: right;
background: var(--bg-dark);
padding: 3px 8px;
border-radius: var(--radius);
border: 1px solid var(--border-light);
font-variant-numeric: tabular-nums;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
cursor: text;
}
.slider-value-input {
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
min-width: 75px;
width: 75px;
text-align: right;
background: var(--bg-dark);
padding: 3px 8px;
border-radius: var(--radius);
border: 1px solid var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
outline: none;
font-variant-numeric: tabular-nums;
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
}
.toggle-switch {
width: 44px;
height: 24px;
background: var(--bg-dark);
border: 2px solid var(--border);
border-radius: 12px;
position: relative;
transition: all 0.2s ease;
}
.toggle-switch.checked {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: var(--text);
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.toggle-switch.checked::after {
transform: translateX(20px);
background: white;
}
.checkbox-label {
font-size: 12px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.horizontal-layout .checkbox-label {
font-size: 11px;
}
.horizontal-layout .checkbox-wrapper {
gap: 6px;
}
.checkbox-box {
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
background: transparent;
}
.checkbox-box.checked {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.checkbox-box::after {
content: '';
display: block;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
opacity: 0;
transition: opacity 0.15s;
}
.checkbox-box.checked::after {
opacity: 1;
}
.color-picker-wrapper {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.color-picker-container {
position: relative;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px 8px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.color-picker-container:hover {
border-color: var(--border-light);
}
.color-picker-container:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
input[type="color"] {
-webkit-appearance: none;
appearance: none;
width: 32px;
height: 24px;
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
background: transparent;
padding: 0;
overflow: hidden;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-hex {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
min-width: 70px;
}
.plot-container {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
height: 280px;
position: relative;
}
.plot-container.coral {
border-color: var(--coral);
}
.plot-container.teal {
border-color: var(--teal);
}
.plot-container.blue {
border-color: var(--blue);
}
.plot-container.green {
border-color: var(--green);
}
.plot-container.purple {
border-color: var(--purple);
}
.plot-container.orange {
border-color: var(--orange);
}
.plot-container.yellow {
border-color: var(--yellow);
}
.plot-container.red {
border-color: var(--red);
}
.plot-container.black {
border-color: var(--black);
}
.plot-container.white {
border-color: var(--white);
}
.plot-container.gray {
border-color: var(--gray);
}
.plot-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.plot-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.plot-canvas {
flex: 1;
width: 100%;
min-height: 0;
cursor: crosshair;
}
.plot-axis-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-muted);
}
.plot-tooltip {
position: absolute;
background: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius);
padding: 12px 16px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 100;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
min-width: 140px;
}
.plot-tooltip.visible {
opacity: 1;
}
.plot-tooltip-title {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.plot-tooltip-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-size: 12px;
margin: 4px 0;
}
.plot-tooltip-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
.plot-tooltip-color {
width: 8px;
height: 8px;
border-radius: 2px;
}
.plot-tooltip-value {
font-family: var(--mono);
color: var(--text);
font-weight: 500;
}
.grid .stat-card {
padding: 12px;
min-width: 0;
}
.grid .stat-value {
font-size: 22px;
}
.grid .stat-label {
font-size: 10px;
}
.grid {
display: grid;
gap: 12px;
box-sizing: border-box;
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.grid-4 {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
@media (max-width: 600px) {
.grid-2,
.grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
}
.card-body>.grid {
margin: 8px 16px;
}
.card-body>.grid:first-child {
margin-top: 0;
}
.grid>.widget-row {
padding: 0;
display: block;
}
.text-input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 12px;
padding: 6px 10px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.text-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
select.dropdown {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 12px;
padding: 6px 10px;
outline: none;
cursor: pointer;
transition: border-color 0.15s;
}
select.dropdown:focus {
border-color: var(--accent);
}
select.dropdown option {
background: var(--surface);
color: var(--text);
}
.wgui-button {
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
padding: 8px 18px;
font-family: var(--font);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
box-shadow: 0 2px 8px var(--accent-glow);
}
.wgui-button:hover {
filter: brightness(1.1);
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--accent-glow);
}
.wgui-button:active {
transform: scale(0.97);
}
.wgui-button-compact {
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
padding: 4px 12px;
font-family: var(--font);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
box-shadow: 0 1px 4px var(--accent-glow);
white-space: nowrap;
}
.wgui-button-compact:hover {
filter: brightness(1.1);
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--accent-glow);
}
.wgui-button-compact:active {
transform: scale(0.97);
}
.label-text {
font-size: 12px;
line-height: 1.45;
color: var(--text);
overflow-wrap: anywhere;
min-width: 0;
}
.label-block {
flex: 1;
min-width: 0;
padding: 5px 16px;
font-size: 12px;
line-height: 1.5;
color: var(--text);
overflow-wrap: anywhere;
}
.kv-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 16px;
min-height: 24px;
gap: 12px;
}
.kv-row:hover {
background: rgba(255, 255, 255, 0.02);
}
.kv-label {
font-size: 12px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.kv-value {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
text-align: right;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.horizontal-layout {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 4px 16px;
flex-wrap: nowrap;
}
.horizontal-layout>* {
flex: 0 0 auto;
}
.horizontal-layout>.kv-row,
.horizontal-layout>.widget-row {
padding: 0;
min-height: auto;
flex: 0 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
}
.horizontal-layout .widget-label {
flex: 0 0 auto;
min-width: auto;
max-width: none;
width: auto;
font-size: 11px;
padding-right: 2px;
}
.horizontal-layout .widget-control {
flex: 0 1 auto;
min-width: 0;
gap: 4px;
}
.horizontal-layout .slider-container {
min-width: 80px;
flex: 1 1 150px;
gap: 4px;
max-width: none;
}
.horizontal-layout input[type="range"] {
flex: 1;
min-width: 50px;
}
.horizontal-layout .slider-value {
min-width: 45px;
width: 45px;
padding: 2px 4px;
font-size: 10px;
}
.horizontal-layout .checkbox-wrapper {
gap: 4px;
}
.horizontal-layout .checkbox-label {
font-size: 10px;
min-width: 20px;
}
.horizontal-layout .text-input {
min-width: 50px;
max-width: 100px;
padding: 3px 6px;
font-size: 11px;
}
.label-inline {
font-size: 12px;
color: var(--text);
white-space: nowrap;
flex-shrink: 0;
}
.card-body > .label-inline {
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.4;
font-weight: 600;
color: var(--text-dim);
padding: 8px 16px 2px;
}
.separator {
height: 1px;
background: var(--border);
margin: 6px 16px;
}
.section-header {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--accent);
padding: 10px 16px 4px;
margin-top: 4px;
border-top: 1px solid var(--border);
position: relative;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.section-header:first-child {
margin-top: 0;
border-top: none;
}
.image-widget {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 16px;
width: 100%;
}
.image-widget img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
border: 1px solid var(--border);
display: block;
}
.image-widget-label {
font-size: 12px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-header::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 14px;
background: var(--accent);
border-radius: 0 2px 2px 0;
opacity: 0.7;
}
.section-toggle {
font-size: 9px;
color: var(--text-dim);
transition: transform 0.2s;
flex-shrink: 0;
margin-left: 8px;
}
.section-header.collapsed .section-toggle {
transform: rotate(-90deg);
}
.section-child.section-hidden {
display: none;
}
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, background 0.15s;
z-index: 50;
}
.scroll-top.visible {
opacity: 1;
pointer-events: auto;
}
.scroll-top:hover {
background: var(--surface-hover);
color: var(--text);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-dim);
font-size: 14px;
gap: 8px;
column-span: all;
}
.empty-state .logo {
font-size: 24px;
font-weight: 700;
color: var(--accent);
letter-spacing: 2px;
margin-bottom: 8px;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-dim);
}
</style>
</head>
<body>
<div id="status-bar" class="connecting"></div>
<div id="app">
<div class="empty-state">
<div class="logo">wgui</div>
<div>Connecting...</div>
</div>
</div>
<button class="scroll-top" id="scroll-top" onclick="window.scrollTo({top:0,behavior:'smooth'})">▲</button>
<script>
(function () {
'use strict';
let elements = new Map();
let ws = null;
let reconnectDelay = 250;
let connectTimer = null;
const recentlyInteracted = new Map();
let chartInstances = new Map();
const initRetryCounts = new Map();
const MAX_INIT_RETRIES = 10;
const RECENT_INTERACTION_TTL = 5000;
const wsPort = parseInt(location.port) + 1;
const wsUrl = `ws://${location.hostname}:${wsPort}`;
let dragState = {
dragging: null,
dragOffset: { x: 0, y: 0 },
placeholder: null,
originalParent: null,
draggedData: null
};
let messageQueue = [];
let isProcessingQueued = false;
let pendingRafId = null;
const MAX_QUEUE_SIZE = 100;
function enqueueMessage(msg) {
messageQueue.push(msg);
if (messageQueue.length > MAX_QUEUE_SIZE) {
fastForwardQueue();
}
if (!isProcessingQueued) {
isProcessingQueued = true;
pendingRafId = requestAnimationFrame(processMessageQueue);
}
}
function fastForwardQueue() {
const latestUpdates = new Map();
const structuralMessages = [];
for (const msg of messageQueue) {
if (msg.type === 'snapshot') {
structuralMessages.length = 0;
structuralMessages.push(msg);
latestUpdates.clear();
} else if (msg.type === 'add' || msg.type === 'remove' || msg.type === 'reorder') {
structuralMessages.push(msg);
} else if (msg.type === 'update') {
latestUpdates.set(msg.id, msg);
}
}
messageQueue = [...structuralMessages, ...latestUpdates.values()];
}
function processMessageQueue() {
isProcessingQueued = false;
pendingRafId = null;
if (messageQueue.length === 0) return;
const batch = messageQueue;
messageQueue = [];
if (batch.length > 10) {
fastForwardQueue();
}
let needsFullRender = false;
const updatedIds = new Set();
for (const msg of batch) {
switch (msg.type) {
case 'snapshot':
elements = new Map();
for (const el of msg.elements) elements.set(el.id, el);
needsFullRender = true;
break;
case 'add':
elements.set(msg.element.id, msg.element);
needsFullRender = true;
break;
case 'remove':
elements.delete(msg.id);
needsFullRender = true;
break;
case 'reorder':
const newMap = new Map();
for (const id of msg.ids) {
const el = elements.get(id);
if (el) newMap.set(id, el);
}
for (const [id, el] of elements) {
if (!newMap.has(id)) newMap.set(id, el);
}
elements = newMap;
needsFullRender = true;
break;
case 'update': {
const el = elements.get(msg.id);
if (el) {
el.value = msg.value;
if (msg.label) el.label = msg.label;
if (msg.meta) el.meta = msg.meta;
updatedIds.add(msg.id);
}
break;
}
}
}
if (needsFullRender) {
render();
} else {
for (const id of updatedIds) {
const el = elements.get(id);
if (el) updateElementDOM(el);
}
}
}
function connect() {
setStatus('connecting');
ws = new WebSocket(wsUrl);
const socket = ws;
clearTimeout(connectTimer);
connectTimer = setTimeout(() => {
if (socket.readyState !== WebSocket.OPEN) { try { socket.close(); } catch (e) { } }
}, 2500);
ws.onopen = () => {
clearTimeout(connectTimer);
setStatus('connected');
reconnectDelay = 500;
};
ws.onclose = () => {
clearTimeout(connectTimer);
setStatus('disconnected');
ws = null;
if (pendingRafId) {
cancelAnimationFrame(pendingRafId);
pendingRafId = null;
}
isProcessingQueued = false;
messageQueue = [];
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 1.5, 1000);
};
ws.onerror = () => { };
ws.onmessage = (e) => {
const msgs = JSON.parse(e.data);
if (Array.isArray(msgs)) {
for (const msg of msgs) {
enqueueMessage(msg);
}
} else {
enqueueMessage(msgs);
}
};
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function setStatus(s) {
const bar = document.getElementById('status-bar');
bar.className = s;
}
function handleMessage(msg) {
switch (msg.type) {
case 'snapshot':
elements = new Map();
for (const el of msg.elements) elements.set(el.id, el);
render();
break;
case 'add':
elements.set(msg.element.id, msg.element);
render();
break;
case 'update': {
const el = elements.get(msg.id);
if (el) {
el.value = msg.value;
if (msg.label) el.label = msg.label;
if (msg.meta) el.meta = msg.meta;
updateElementDOM(el);
} else {
console.warn('wgui: update for unknown id', msg.id);
}
break;
}
case 'remove':
elements.delete(msg.id);
render();
break;
case 'reorder': {
const newMap = new Map();
for (const id of msg.ids) {
const el = elements.get(id);
if (el) newMap.set(id, el);
}
for (const [id, el] of elements) {
if (!newMap.has(id)) newMap.set(id, el);
}
elements = newMap;
render();
break;
}
}
}
const LAYOUT_GAP = 12;
const LAYOUT_MIN_CARD_W = 440;
const LAYOUT_MAX_COLS = 6;
const layoutKey = 'wgui:layout:' + (document.title || 'wgui');
let cardEls = new Map(); let lastColCount = 0;
function loadLayout() {
const def = { placements: {}, collapsed: [], colCount: 'auto' };
try {
const p = JSON.parse(localStorage.getItem(layoutKey) || '{}');
return {
placements: (p.placements && typeof p.placements === 'object') ? p.placements : {},
collapsed: Array.isArray(p.collapsed) ? p.collapsed : [],
colCount: (p.colCount === 'auto' || (Number.isInteger(p.colCount) && p.colCount >= 1 && p.colCount <= LAYOUT_MAX_COLS)) ? p.colCount : 'auto',
};
} catch (e) { return def; }
}
let layout = loadLayout();
function saveLayout() {
try { localStorage.setItem(layoutKey, JSON.stringify(layout)); } catch (e) { }
}
function computeColumns() {
if (layout.colCount !== 'auto') return Math.min(layout.colCount, LAYOUT_MAX_COLS);
const app = document.getElementById('app');
const w = (app && app.clientWidth) || window.innerWidth || 1200;
return Math.max(1, Math.min(LAYOUT_MAX_COLS, Math.floor((w + LAYOUT_GAP) / (LAYOUT_MIN_CARD_W + LAYOUT_GAP))));
}
function getPlacement(n, names, heights) {
const present = new Set(names);
const base = (layout.placements[n] || []).map(col => (col || []).filter(nm => present.has(nm)));
while (base.length < n) base.push([]);
if (base.length > n) {
for (let i = n; i < base.length; i++) base[n - 1].push(...base[i]);
base.length = n;
}
const placed = new Set();
for (const col of base) for (const nm of col) placed.add(nm);
const colH = base.map(col => col.reduce((s, nm) => s + (heights.get(nm) || 0), 0));
for (const nm of names) {
if (placed.has(nm)) continue;
let mi = 0;
for (let i = 1; i < n; i++) if (colH[i] < colH[mi]) mi = i;
base[mi].push(nm);
colH[mi] += heights.get(nm) || 0;
}
layout.placements[n] = base;
return base;
}
function layoutColumns() {
const app = document.getElementById('app');
if (!app || cardEls.size === 0 || drag) return;
const n = computeColumns();
lastColCount = n;
const names = [...cardEls.keys()];
const cols = [];
for (let i = 0; i < n; i++) {
const c = document.createElement('div');
c.className = 'masonry-col';
cols.push(c);
}
app.replaceChildren(...cols);
for (const nm of names) cols[0].appendChild(cardEls.get(nm));
const heights = new Map();
for (const nm of names) heights.set(nm, cardEls.get(nm).offsetHeight);
const placement = getPlacement(n, names, heights);
for (let i = 0; i < n; i++) {
for (const nm of placement[i]) cols[i].appendChild(cardEls.get(nm));
}
saveLayout();
}
let drag = null;
function bindCardDrag(card, name) {
const grip = card.querySelector('.card-grip');
if (!grip) return;
grip.addEventListener('click', (e) => e.stopPropagation());
grip.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || drag) return;
beginDrag(card, name, e);
});
}
function beginDrag(card, name, e) {
e.preventDefault();
drag = { card, name, startX: e.clientX, startY: e.clientY, active: false };
window.addEventListener('pointermove', onDragMove);
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
}
function activateDrag(e) {
const card = drag.card;
const rect = card.getBoundingClientRect();
const clone = card.cloneNode(true);
clone.classList.add('card-drag-clone');
clone.style.width = rect.width + 'px';
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
document.body.appendChild(clone);
const placeholder = document.createElement('div');
placeholder.className = 'card-placeholder';
placeholder.style.height = rect.height + 'px';
card.parentElement.insertBefore(placeholder, card); card.remove(); document.body.classList.add('dragging-active');
Object.assign(drag, {
active: true, clone, placeholder,
offX: e.clientX - rect.left, offY: e.clientY - rect.top,
});
}
function movePlaceholder(px, py) {
const ph = drag.placeholder;
const el = document.elementFromPoint(px, py);
if (!el || el.closest('.card-placeholder')) return; const col = el.closest('.masonry-col');
if (!col) return;
let beforeEl = null;
const overCard = el.closest('.card');
if (overCard && overCard.parentElement === col) {
const r = overCard.getBoundingClientRect();
beforeEl = (py < r.top + r.height / 2) ? overCard : overCard.nextElementSibling;
}
if (beforeEl === ph) return;
if (ph.parentElement === col && ph.nextElementSibling === beforeEl) return;
col.insertBefore(ph, beforeEl); }
function onDragMove(e) {
if (!drag) return;
if (!drag.active) {
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return;
activateDrag(e);
}
drag.clone.style.left = (e.clientX - drag.offX) + 'px';
drag.clone.style.top = (e.clientY - drag.offY) + 'px';
movePlaceholder(e.clientX, e.clientY);
}
function endDrag() {
if (!drag) return;
window.removeEventListener('pointermove', onDragMove);
window.removeEventListener('pointerup', endDrag);
window.removeEventListener('pointercancel', endDrag);
if (drag.active) {
const ph = drag.placeholder;
ph.parentElement.insertBefore(drag.card, ph); ph.remove();
drag.clone.remove();
document.body.classList.remove('dragging-active');
const app = document.getElementById('app');
const cols = [...app.querySelectorAll('.masonry-col')];
layout.placements[cols.length] = cols.map(col =>
[...col.querySelectorAll(':scope > .card')].map(c => c.dataset.window));
lastColCount = cols.length;
saveLayout();
}
drag = null;
}
function updateToolbarActive(bar) {
bar.querySelectorAll('.lt-btn[data-cols]').forEach(b => {
b.classList.toggle('active', b.dataset.cols === String(layout.colCount));
});
}
function buildToolbar() {
const bar = document.createElement('div');
bar.className = 'layout-toolbar';
bar.innerHTML = `<span class="lt-label">Cols</span>`;
for (const o of ['auto', 1, 2, 3, 4]) {
const b = document.createElement('div');
b.className = 'lt-btn';
b.dataset.cols = String(o);
b.textContent = o === 'auto' ? 'Auto' : String(o);
b.addEventListener('click', () => {
layout.colCount = o;
saveLayout();
updateToolbarActive(bar);
layoutColumns();
});
bar.appendChild(b);
}
const sep = document.createElement('div');
sep.className = 'lt-sep';
bar.appendChild(sep);
const reset = document.createElement('div');
reset.className = 'lt-btn';
reset.textContent = 'Reset';
reset.title = 'Clear saved layout, collapse and column settings';
reset.addEventListener('click', () => {
layout = { placements: {}, collapsed: [], colCount: 'auto' };
saveLayout();
updateToolbarActive(bar);
render();
});
bar.appendChild(reset);
document.body.appendChild(bar);
updateToolbarActive(bar);
}
function installLayoutObservers() {
const app = document.getElementById('app');
if (app && 'ResizeObserver' in window) {
let t = null;
const ro = new ResizeObserver(() => {
clearTimeout(t);
t = setTimeout(() => {
if (computeColumns() !== lastColCount) layoutColumns();
}, 100);
});
ro.observe(app);
}
}
function render() {
const app = document.getElementById('app');
cleanupCharts();
document.querySelectorAll('.plot-tooltip').forEach(t => t.remove());
if (elements.size === 0) {
app.innerHTML = `
<div class="empty-state">
<div class="logo">wgui</div>
<div>No elements declared</div>
</div>`;
cardEls = new Map();
return;
}
const containerChildIds = new Set();
for (const el of elements.values()) {
const children = el.value?.data?.children;
if (children && (el.kind.type === 'Grid' || el.kind.type === 'Horizontal')) {
for (const childId of children) {
containerChildIds.add(childId);
}
}
}
const windows = new Map();
for (const el of elements.values()) {
if (containerChildIds.has(el.id)) continue;
const win = el.window || 'Default';
if (!windows.has(win)) windows.set(win, []);
windows.get(win).push(el);
}
cardEls = new Map();
for (const [name, elems] of windows) {
const firstAccent = elems[0]?.meta?.accent || 'coral';
const card = document.createElement('div');
card.className = `card ${firstAccent}`;
card.dataset.window = name;
let inner = `<div class="card-header" onclick="wgui.toggleCard(this)">`;
inner += `<span class="card-grip" title="Drag to reorder">⠿</span>`;
inner += `<span class="card-title">${esc(name)}</span>`;
inner += `<span class="card-toggle">▼</span>`;
inner += `</div><div class="card-body">`;
for (const el of elems) inner += renderElement(el);
inner += `</div>`;
card.innerHTML = inner;
if (layout.collapsed.includes(name)) card.classList.add('collapsed');
bindCardDrag(card, name);
cardEls.set(name, card);
}
layoutColumns();
requestAnimationFrame(() => {
for (const el of elements.values()) {
if (el.kind.type === 'MiniChart') {
initMiniChart(el);
} else if (el.kind.type === 'Plot') {
initPlot(el);
}
}
});
}
function renderElement(el) {
const r = renderers[el.kind.type];
if (r) return r(el);
return `<div class="widget-row"><span class="widget-label">${esc(el.label)}</span><span class="widget-control">Unknown: ${esc(el.kind.type)}</span></div>`;
}
function updateElementDOM(el) {
const row = document.querySelector(`[data-id="${CSS.escape(el.id)}"]`);
if (!row) { console.warn('wgui: no DOM for', el.id, '→ full render'); render(); return; }
switch (el.kind.type) {
case 'Slider': {
const input = row.querySelector('input[type="range"]');
const display = row.querySelector('.slider-value');
const val = el.value.type === 'Float' ? el.value.data : el.value.data;
const recent = recentlyInteracted.get(el.id);
const isActive = document.activeElement === input || (recent && Date.now() - recent < 500);
if (input && !isActive) input.value = val;
if (display && !isActive) display.textContent = formatNum(val, el.meta.step);
break;
}
case 'Checkbox': {
const toggle = row.querySelector('.toggle-switch');
if (toggle) {
if (el.value.data) toggle.classList.add('checked');
else toggle.classList.remove('checked');
}
const label = row.querySelector('.checkbox-label');
if (label) label.textContent = el.value.data ? 'On' : 'Off';
break;
}
case 'ColorPicker3': {
const input = row.querySelector('input[type="color"]');
const hexLabel = row.querySelector('.color-hex');
const hex = rgbToHex(el.value.data);
if (input && document.activeElement !== input) input.value = hex;
if (hexLabel) hexLabel.textContent = hex;
break;
}
case 'ColorPicker4': {
const input = row.querySelector('input[type="color"]');
const hexLabel = row.querySelector('.color-hex');
const hex = rgbToHex(el.value.data);
const alpha = el.value.data[3] ?? 1.0;
if (input && document.activeElement !== input) input.value = hex;
if (hexLabel) hexLabel.textContent = hex;
const alphaSlider = row.querySelector('input[type="range"]');
if (alphaSlider && document.activeElement !== alphaSlider) alphaSlider.value = alpha;
const alphaText = row.querySelector('.alpha-text');
if (alphaText) alphaText.textContent = `α${alpha.toFixed(2)}`;
break;
}
case 'TextInput': {
const input = row.querySelector('.text-input');
if (input && document.activeElement !== input) input.value = el.value.data;
break;
}
case 'TextInputInline': {
const input = document.querySelector(`[data-id="${CSS.escape(el.id)}"].text-input`);
if (input && document.activeElement !== input) input.value = el.value.data;
break;
}
case 'Dropdown': {
const select = row.querySelector('select');
if (select && document.activeElement !== select) select.selectedIndex = el.value.data.selected;
break;
}
case 'ProgressBar': {
const fill = row.querySelector('.progress-fill');
const percent = row.querySelector('.progress-percent');
const subtitle = row.querySelector('.progress-subtitle');
const val = el.value.data;
const pct = Math.round(val * 100);
if (fill) fill.style.width = pct + '%';
if (percent) percent.innerHTML = pct + '<sup>%</sup>';
if (subtitle && el.meta.subtitle) subtitle.textContent = el.meta.subtitle;
break;
}
case 'Stat': {
const valueEl = row.querySelector('.stat-value');
const subEl = row.querySelector('.stat-subvalue');
if (valueEl) valueEl.textContent = el.value.data.value;
if (subEl && el.value.data.subvalue) subEl.textContent = el.value.data.subvalue;
break;
}
case 'Status': {
const data = el.value.data;
const color = data.active ? (data.activeColor || data.active_color || 'green') : (data.inactiveColor || data.inactive_color || 'red');
const text = data.active ? (data.activeText || data.active_text || 'Active') : (data.inactiveText || data.inactive_text || 'Inactive');
const dot = row.querySelector('.status-dot');
const span = row.querySelector('.status-indicator span');
if (dot) { dot.className = 'status-dot ' + color; }
if (span) span.textContent = text;
break;
}
case 'MiniChart': {
updateMiniChart(el);
break;
}
case 'Plot': {
updatePlot(el);
break;
}
case 'Grid': {
const children = el.value.data.children || [];
for (const childId of children) {
const child = elements.get(childId);
if (child) updateElementDOM(child);
}
break;
}
case 'Label': {
const labelEl = row.querySelector('.label-text');
if (labelEl) labelEl.textContent = el.value.data;
const nameEl = row.querySelector('.widget-label');
if (nameEl) nameEl.textContent = el.label;
break;
}
case 'LabelInline': {
const inlineLabel = document.querySelector(`[data-id="${CSS.escape(el.id)}"].label-inline`);
if (inlineLabel) inlineLabel.textContent = el.value.data;
break;
}
case 'Section': {
row.textContent = el.label;
break;
}
case 'Button': {
const btn = row.querySelector('.wgui-button');
if (btn) btn.textContent = el.label;
break;
}
case 'ButtonCompact': {
const btn = row.querySelector('.wgui-button-compact');
if (btn) btn.textContent = el.label;
break;
}
case 'ButtonInline': {
const btn = document.querySelector(`[data-id="${CSS.escape(el.id)}"].wgui-button-compact`);
if (btn) btn.textContent = el.label;
break;
}
case 'KeyValue': {
const valueEl = row.querySelector('.kv-value');
if (valueEl) valueEl.textContent = el.value.data;
const labelEl = row.querySelector('.kv-label');
if (labelEl) labelEl.textContent = el.label;
break;
}
case 'Horizontal': {
const children = el.value.data.children || [];
for (const childId of children) {
const child = elements.get(childId);
if (child) updateElementDOM(child);
}
break;
}
case 'Separator':
break;
case 'Image': {
const img = row.querySelector('.image-widget img');
if (img) {
const d = el.value.data;
if (img.src !== d.data) img.src = d.data;
if (d.width) img.style.width = d.width + 'px';
if (d.height) img.style.height = d.height + 'px';
}
const imgLabel = row.querySelector('.image-widget-label');
if (imgLabel) imgLabel.textContent = el.label;
break;
}
default:
render();
}
}
const renderers = {
Slider: (el) => {
const val = el.value.data;
const min = el.meta.min ?? 0;
const max = el.meta.max ?? 1;
const step = el.meta.step ?? 0.01;
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label" title="${esc(el.label)}">${esc(el.label)}</span>
<div class="widget-control slider-container">
<input type="range" min="${min}" max="${max}" step="${step}" value="${val}"
oninput="wgui.onSlider('${escJs(el.id)}', this.value, this)">
<span class="slider-value" onclick="wgui.editSliderValue('${escJs(el.id)}', this)">${formatNum(val, step)}</span>
</div>
</div>`;
},
Checkbox: (el) => {
const checked = el.value.data ? 'checked' : '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label" title="${esc(el.label)}">${esc(el.label)}</span>
<div class="widget-control">
<div class="checkbox-wrapper" onclick="wgui.onCheckbox('${escJs(el.id)}', this)">
<div class="toggle-switch ${checked}"></div>
<span class="checkbox-label">${el.value.data ? 'On' : 'Off'}</span>
</div>
</div>
</div>`;
},
ColorPicker3: (el) => {
const hex = rgbToHex(el.value.data);
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control color-picker-wrapper">
<div class="color-picker-container">
<input type="color" value="${hex}"
oninput="wgui.onColor3('${escJs(el.id)}', this.value)">
<span class="color-hex">${hex}</span>
</div>
</div>
</div>`;
},
ColorPicker4: (el) => {
const hex = rgbToHex(el.value.data);
const alpha = el.value.data[3] ?? 1.0;
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control color-picker-wrapper">
<div class="color-picker-container">
<input type="color" value="${hex}"
oninput="wgui.onColor4('${escJs(el.id)}', this.value, '${escJs(el.id)}')">
<span class="color-hex">${hex}</span>
</div>
<input type="range" min="0" max="1" step="0.01" value="${alpha}" style="max-width:80px"
oninput="wgui.onColor4Alpha('${escJs(el.id)}', this.value)">
<span class="alpha-text" style="font-family:var(--mono);font-size:11px;color:var(--text-dim);min-width:40px;">α${alpha.toFixed(2)}</span>
</div>
</div>`;
},
TextInput: (el) => {
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<input type="text" class="text-input" value="${esc(el.value.data)}"
oninput="wgui.onText('${escJs(el.id)}', this.value)">
</div>
</div>`;
},
Dropdown: (el) => {
const opts = el.value.data.options.map((o, i) =>
`<option value="${i}" ${i === el.value.data.selected ? 'selected' : ''}>${esc(o)}</option>`
).join('');
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<select class="dropdown" onchange="wgui.onDropdown('${escJs(el.id)}', this.value, this)">
${opts}
</select>
</div>
</div>`;
},
Button: (el) => {
const accentStyle = el.meta.accent ? ` style="background:var(--${el.meta.accent});box-shadow:0 2px 8px var(--${el.meta.accent}-glow)"` : '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label"></span>
<div class="widget-control" style="justify-content:center;">
<button class="wgui-button"${accentStyle} onclick="wgui.onButton('${escJs(el.id)}')">${esc(el.label)}</button>
</div>
</div>`;
},
ButtonCompact: (el) => {
const accentStyle = el.meta.accent ? ` style="background:var(--${el.meta.accent});box-shadow:0 1px 4px var(--${el.meta.accent}-glow)"` : '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control" style="justify-content:flex-end;">
<button class="wgui-button-compact"${accentStyle} onclick="wgui.onButton('${escJs(el.id)}')">${esc(el.label)}</button>
</div>
</div>`;
},
ButtonInline: (el) => {
const accentStyle = el.meta.accent ? ` style="background:var(--${el.meta.accent});box-shadow:0 1px 4px var(--${el.meta.accent}-glow)"` : '';
return `<button class="wgui-button-compact"${accentStyle} data-id="${esc(el.id)}" onclick="wgui.onButton('${escJs(el.id)}')">${esc(el.label)}</button>`;
},
TextInputInline: (el) => {
return `<input type="text" class="text-input" data-id="${esc(el.id)}" placeholder="${esc(el.label)}" value="${esc(el.value.data)}"
oninput="wgui.onText('${escJs(el.id)}', this.value)">`;
},
KeyValue: (el) => {
return `<div class="kv-row" data-id="${esc(el.id)}">
<span class="kv-label" title="${esc(el.label)}">${esc(el.label)}</span>
<span class="kv-value">${esc(el.value.data)}</span>
</div>`;
},
Horizontal: (el) => {
const data = el.value.data || {};
const children = data.children || [];
let html = '<div class="horizontal-layout">';
for (const childId of children) {
const child = elements.get(childId);
if (child) {
html += renderElement(child);
}
}
html += '</div>';
return html;
},
Label: (el) => {
const hasLabel = el.label && el.label.trim().length > 0;
const hasValue = el.value.data && el.value.data.trim().length > 0;
if (!hasLabel && !hasValue) {
return '';
}
if (hasLabel) {
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<span class="label-text">${esc(el.value.data)}</span>
</div>
</div>`;
}
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="label-block">${esc(el.value.data)}</span>
</div>`;
},
LabelInline: (el) => {
return `<span class="label-inline" data-id="${esc(el.id)}">${esc(el.value.data)}</span>`;
},
Separator: (_el) => {
return `<div class="separator"></div>`;
},
Section: (el) => {
return `<div class="section-header" data-id="${esc(el.id)}" onclick="wgui.toggleSection(this)"><span>${esc(el.label)}</span><span class="section-toggle">▼</span></div>`;
},
ProgressBar: (el) => {
const val = el.value.data;
const pct = Math.round(val * 100);
const accent = el.meta.accent || 'coral';
const subtitle = el.meta.subtitle || '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="progress-container ${accent}" style="flex: 1;">
<div class="progress-header">
<span class="progress-title">${esc(el.label)}</span>
<span class="progress-percent">${pct}<sup>%</sup></span>
</div>
<div class="progress-track">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
${subtitle ? `<div class="progress-subtitle">${esc(subtitle)}</div>` : ''}
</div>
</div>`;
},
Stat: (el) => {
const accent = el.meta.accent || 'coral';
const data = el.value.data;
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="stat-card ${accent}" style="flex: 1;">
<div class="stat-label">${esc(el.label)}</div>
<div class="stat-value">${esc(data.value)}</div>
${data.subvalue ? `<div class="stat-subvalue">${esc(data.subvalue)}</div>` : ''}
</div>
</div>`;
},
Status: (el) => {
const data = el.value.data;
const color = data.active ? (data.activeColor || 'green') : (data.inactiveColor || 'red');
const text = data.active ? (data.activeText || 'Active') : (data.inactiveText || 'Inactive');
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<div class="status-indicator">
<div class="status-dot ${color}"></div>
<span>${esc(text)}</span>
</div>
</div>
</div>`;
},
MiniChart: (el) => {
const accent = el.meta.accent || 'coral';
const data = el.value.data;
const currentValue = Array.isArray(data.values) && data.values.length > 0
? data.values[data.values.length - 1]
: (data.current || 0);
const unit = data.unit || '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="mini-chart ${accent}" style="flex: 1;">
<div class="mini-chart-header">
<span class="mini-chart-title">${esc(el.label)}</span>
<span class="mini-chart-value">${currentValue}${esc(unit)}</span>
</div>
<canvas class="mini-chart-canvas" id="chart-${esc(el.id)}"></canvas>
</div>
</div>`;
},
Grid: (el) => {
const data = el.value.data || {};
const cols = data.cols || 2;
const children = data.children || [];
let html = `<div class="grid grid-${cols}">`;
for (const childId of children) {
const child = elements.get(childId);
if (child) {
html += renderElement(child);
}
}
html += '</div>';
return html;
},
Plot: (el) => {
const data = el.value.data || {};
const series = data.series || [];
const xLabel = data.x_label || '';
const yLabel = data.y_label || '';
const accent = series.length > 0 ? series[0].color : 'coral';
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="plot-container ${accent}" style="flex: 1;">
<div class="plot-header">
<span class="plot-title">${esc(el.label)}</span>
</div>
<canvas class="plot-canvas" id="plot-${esc(el.id)}"></canvas>
<div class="plot-axis-labels">
<span>${esc(xLabel)}</span>
<span>${esc(yLabel)}</span>
</div>
</div>
</div>`;
},
Image: (el) => {
const data = el.value.data;
const style = [];
if (data.width) style.push(`width:${data.width}px`);
if (data.height) style.push(`height:${data.height}px`);
const styleAttr = style.length ? ` style="${style.join(';')}"` : '';
const labelHtml = el.label ? `<div class="image-widget-label">${esc(el.label)}</div>` : '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="image-widget" style="flex:1;">
${labelHtml}
<img src="${esc(data.data)}"${styleAttr} alt="${esc(el.label)}">
</div>
</div>`;
}
};
function syncCanvasSize(canvas, ctx) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const w = Math.round(rect.width * dpr);
const h = Math.round(rect.height * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return true;
}
function roundRectPath(ctx, x, y, w, h, r) {
ctx.beginPath();
if (ctx.roundRect) { ctx.roundRect(x, y, w, h, r); return; }
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function redrawChart(inst) {
if (!inst || !inst.canvas) return;
if (inst.type === 'plot') {
drawPlot(inst.el, inst.canvas, inst.ctx, inst.scales, plotHoverState.get(inst.el.id) ?? -1);
} else {
drawMiniChart(inst.el, inst.canvas, inst.ctx);
}
}
function redrawAllCharts() {
for (const inst of chartInstances.values()) redrawChart(inst);
}
const chartResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const inst = chartInstances.get(entry.target.dataset.chartId);
if (inst) redrawChart(inst);
}
});
let dprMedia = null;
function onDprChange() {
redrawAllCharts();
watchDevicePixelRatio();
}
function watchDevicePixelRatio() {
if (dprMedia) dprMedia.removeEventListener('change', onDprChange);
dprMedia = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
dprMedia.addEventListener('change', onDprChange, { once: true });
}
function initMiniChart(el) {
const canvas = document.getElementById(`chart-${el.id}`);
if (!canvas) return;
const retryCount = initRetryCounts.get(el.id) || 0;
if (retryCount > MAX_INIT_RETRIES) {
initRetryCounts.delete(el.id);
return;
}
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
initRetryCounts.set(el.id, retryCount + 1);
requestAnimationFrame(() => initMiniChart(el));
return;
}
initRetryCounts.delete(el.id);
syncCanvasSize(canvas, ctx);
canvas.dataset.chartId = el.id;
chartResizeObserver.observe(canvas);
chartInstances.set(el.id, { canvas, ctx, el });
drawMiniChart(el, canvas, ctx);
}
function updateMiniChart(el) {
let instance = chartInstances.get(el.id);
if (!instance) {
const canvas = document.getElementById(`chart-${el.id}`);
if (canvas) {
initMiniChart(el);
instance = chartInstances.get(el.id);
}
if (!instance) return;
}
if (instance) {
drawMiniChart(el, instance.canvas, instance.ctx);
}
const row = document.querySelector(`[data-id="${CSS.escape(el.id)}"]`);
if (row) {
const valSpan = row.querySelector('.mini-chart-value');
if (valSpan) {
const data = el.value.data;
const values = data.values || [];
const current = values.length > 0 ? values[values.length - 1] : (data.current || 0);
const unit = data.unit || '';
valSpan.textContent = `${current}${unit}`;
}
}
}
function drawMiniChart(el, canvas, ctx) {
const data = el.value.data;
const values = data.values || [];
if (values.length < 2) return;
if (!syncCanvasSize(canvas, ctx)) return;
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
const accent = getComputedStyle(canvas.closest('.mini-chart')).getPropertyValue('--accent').trim() || '#e06c5c';
ctx.clearRect(0, 0, width, height);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const padding = 4;
ctx.strokeStyle = 'rgba(128, 128, 128, 0.1)';
ctx.lineWidth = 1;
for (let i = 0; i <= 3; i++) {
const y = padding + (height - 2 * padding) * (i / 3);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, accent + '40');
gradient.addColorStop(1, accent + '00');
ctx.beginPath();
ctx.moveTo(0, height - padding - ((values[0] - min) / range) * (height - 2 * padding));
for (let i = 1; i < values.length; i++) {
const x = (i / (values.length - 1)) * width;
const y = height - padding - ((values[i] - min) / range) * (height - 2 * padding);
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.lineTo(0, height);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
ctx.beginPath();
ctx.strokeStyle = accent;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
for (let i = 0; i < values.length; i++) {
const x = (i / (values.length - 1)) * width;
const y = height - padding - ((values[i] - min) / range) * (height - 2 * padding);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
window.wgui = {
toggleCard(headerEl) {
const card = headerEl.parentElement;
const collapsed = card.classList.toggle('collapsed');
const name = card.dataset.window;
if (name) {
const set = new Set(layout.collapsed);
if (collapsed) set.add(name); else set.delete(name);
layout.collapsed = [...set];
saveLayout();
}
},
onSlider(id, rawVal, inputEl) {
const el = elements.get(id);
if (!el) return;
recentlyInteracted.set(id, Date.now());
const isInt = el.value.type === 'Int';
const val = isInt ? parseInt(rawVal) : parseFloat(rawVal);
el.value.data = val;
const display = inputEl.parentElement.querySelector('.slider-value');
if (display) display.textContent = formatNum(val, el.meta.step);
send({ type: 'set', id, value: isInt ? { type: 'Int', data: val } : { type: 'Float', data: val } });
},
onCheckbox(id, wrapperEl) {
const el = elements.get(id);
if (!el) return;
el.value.data = !el.value.data;
const toggle = wrapperEl.querySelector('.toggle-switch');
toggle.classList.toggle('checked');
const label = wrapperEl.querySelector('.checkbox-label');
if (label) label.textContent = el.value.data ? 'On' : 'Off';
send({ type: 'set', id, value: { type: 'Bool', data: el.value.data } });
},
onColor3(id, hex) {
const rgb = hexToRgb(hex);
const el = elements.get(id);
if (el) el.value.data = rgb;
const row = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) row.querySelector('.color-hex').textContent = hex;
send({ type: 'set', id, value: { type: 'Color3', data: rgb } });
},
onColor4(id, hex) {
const rgb = hexToRgb(hex);
const el = elements.get(id);
if (!el) return;
const a = el.value.data[3] ?? 1.0;
el.value.data = [...rgb, a];
const row = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) row.querySelector('.color-hex').textContent = hex;
send({ type: 'set', id, value: { type: 'Color4', data: el.value.data } });
},
onColor4Alpha(id, alphaStr) {
const el = elements.get(id);
if (!el) return;
el.value.data[3] = parseFloat(alphaStr);
const row = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) {
const alphaText = row.querySelector('.alpha-text');
if (alphaText) alphaText.textContent = `α${el.value.data[3].toFixed(2)}`;
}
send({ type: 'set', id, value: { type: 'Color4', data: el.value.data } });
},
onText(id, val) {
const el = elements.get(id);
if (el) el.value.data = val;
send({ type: 'set', id, value: { type: 'String', data: val } });
},
onDropdown(id, selectedStr, selectEl) {
const selected = parseInt(selectedStr);
const el = elements.get(id);
if (!el) return;
el.value.data.selected = selected;
send({ type: 'set', id, value: { type: 'Enum', data: { selected, options: el.value.data.options } } });
},
onButton(id) {
send({ type: 'set', id, value: { type: 'Button', data: true } });
},
editSliderValue(id, spanEl) {
const el = elements.get(id);
if (!el) return;
const val = el.value.data;
const step = el.meta.step ?? 0.01;
const input = document.createElement('input');
input.type = 'text';
input.className = 'slider-value-input';
input.value = formatNum(val, step);
spanEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const parsed = parseFloat(input.value);
if (!isNaN(parsed)) {
const min = el.meta.min ?? 0;
const max = el.meta.max ?? 1;
const clamped = Math.min(max, Math.max(min, parsed));
const isInt = el.value.type === 'Int';
el.value.data = isInt ? Math.round(clamped) : clamped;
send({ type: 'set', id, value: isInt ? { type: 'Int', data: el.value.data } : { type: 'Float', data: el.value.data } });
const row = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) {
const slider = row.querySelector('input[type="range"]');
if (slider) slider.value = el.value.data;
}
}
const span = document.createElement('span');
span.className = 'slider-value';
span.textContent = formatNum(el.value.data, step);
span.onclick = () => wgui.editSliderValue(id, span);
input.replaceWith(span);
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { input.value = formatNum(val, step); input.blur(); }
});
},
toggleSection(headerEl) {
const collapsed = headerEl.classList.toggle('collapsed');
let sibling = headerEl.nextElementSibling;
while (sibling && !sibling.classList.contains('section-header')) {
if (collapsed) {
sibling.classList.add('section-child', 'section-hidden');
} else {
sibling.classList.remove('section-hidden');
}
sibling = sibling.nextElementSibling;
}
}
};
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function escJs(s) {
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function formatNum(v, step) {
const num = Number(v);
const absVal = Math.abs(num);
if ((absVal > 0 && absVal < 0.0001) || absVal >= 10000000) {
return num.toExponential(3);
}
if (step && step >= 1) return String(Math.round(num));
if (step && step > 0) {
const decimals = Math.ceil(-Math.log10(step));
return num.toFixed(Math.max(0, Math.min(decimals, 4)));
}
if (absVal >= 100) return num.toFixed(1);
if (absVal >= 1) return num.toFixed(2);
if (absVal >= 0.01) return num.toFixed(3);
return num.toFixed(4);
}
function rgbToHex(arr) {
const r = Math.round((arr[0] ?? 0) * 255);
const g = Math.round((arr[1] ?? 0) * 255);
const b = Math.round((arr[2] ?? 0) * 255);
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return [r, g, b];
}
const plotHoverState = new Map();
function initPlot(el) {
const canvas = document.getElementById(`plot-${el.id}`);
if (!canvas) return;
let instance = chartInstances.get(el.id);
if (instance && instance.type === 'plot') {
const scales = computePlotScales(el);
instance.scales = scales;
const hoverIndex = plotHoverState.get(el.id) ?? -1;
drawPlot(el, canvas, instance.ctx, scales, hoverIndex);
return;
}
const retryCount = initRetryCounts.get(el.id) || 0;
if (retryCount > MAX_INIT_RETRIES) {
initRetryCounts.delete(el.id);
return;
}
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
initRetryCounts.set(el.id, retryCount + 1);
requestAnimationFrame(() => initPlot(el));
return;
}
initRetryCounts.delete(el.id);
syncCanvasSize(canvas, ctx);
canvas.dataset.chartId = el.id;
chartResizeObserver.observe(canvas);
const tooltip = document.createElement('div');
tooltip.id = `tooltip-${el.id}`;
tooltip.className = 'plot-tooltip';
canvas.closest('.plot-container').appendChild(tooltip);
const scales = computePlotScales(el);
chartInstances.set(el.id, { canvas, ctx, el, type: 'plot', tooltip, scales });
canvas.onmousemove = (e) => handlePlotHover(e, el, canvas, tooltip, scales);
canvas.onmouseleave = () => {
plotHoverState.delete(el.id);
tooltip.classList.remove('visible');
const instance = chartInstances.get(el.id);
if (instance) {
drawPlot(el, canvas, instance.ctx, instance.scales, -1);
}
};
drawPlot(el, canvas, ctx, scales);
}
function computePlotScales(el) {
const data = el.value.data || {};
const series = data.series || [];
let globalMin = Infinity;
let globalMax = -Infinity;
let hasFixedScale = false;
for (const s of series) {
const autoscale = s.autoscale !== false;
const values = s.values || [];
if (!autoscale && values.length > 0) {
globalMin = Math.min(globalMin, ...values);
globalMax = Math.max(globalMax, ...values);
hasFixedScale = true;
}
}
if (hasFixedScale) {
globalMin = globalMin === Infinity ? 0 : globalMin;
globalMax = globalMax === -Infinity ? 1 : globalMax;
}
const scales = [];
for (const s of series) {
const autoscale = s.autoscale !== false;
const values = s.values || [];
if (values.length === 0) {
scales.push({ min: 0, max: 1, range: 1, autoscale });
} else if (!autoscale && hasFixedScale) {
scales.push({
min: globalMin,
max: globalMax,
range: globalMax - globalMin || 1,
autoscale: false
});
} else {
const min = Math.min(...values);
const max = Math.max(...values);
scales.push({
min,
max,
range: max - min || 1,
autoscale: true
});
}
}
return scales;
}
function handlePlotHover(e, el, canvas, tooltip, scales) {
const data = el.value.data || {};
const series = data.series || [];
if (series.length === 0 || !series[0].values) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const plotWidth = rect.width - padding.left - padding.right;
const dataIndex = Math.round(((x - padding.left) / plotWidth) * (series[0].values.length - 1));
const clampedIndex = Math.max(0, Math.min(dataIndex, series[0].values.length - 1));
plotHoverState.set(el.id, clampedIndex);
let tooltipHtml = `<div class="plot-tooltip-title">Sample ${clampedIndex + 1} / ${series[0].values.length}</div>`;
const tooltipX = Math.min(x + 16, rect.width - 160);
const tooltipY = 40;
tooltip.style.left = tooltipX + 'px';
tooltip.style.top = tooltipY + 'px';
for (let i = 0; i < series.length; i++) {
const s = series[i];
const value = s.values[clampedIndex];
const colorVar = getComputedStyle(canvas.closest('.plot-container')).getPropertyValue(`--${s.color}`).trim() || '#e06c5c';
tooltipHtml += `
<div class="plot-tooltip-row">
<div class="plot-tooltip-label">
<div class="plot-tooltip-color" style="background:${colorVar}"></div>
${esc(s.name)}
</div>
<div class="plot-tooltip-value">${formatNum(value, 0.01)}</div>
</div>`;
}
tooltip.innerHTML = tooltipHtml;
tooltip.classList.add('visible');
const instance = chartInstances.get(el.id);
if (instance) {
drawPlot(el, canvas, instance.ctx, instance.scales || scales, clampedIndex);
}
}
function updatePlot(el) {
let instance = chartInstances.get(el.id);
if (!instance) {
const canvas = document.getElementById(`plot-${el.id}`);
if (canvas) {
initPlot(el);
instance = chartInstances.get(el.id);
}
if (!instance) return;
}
if (instance && instance.type === 'plot') {
const scales = computePlotScales(el);
instance.scales = scales;
drawPlot(el, instance.canvas, instance.ctx, scales);
}
}
function drawPlot(el, canvas, ctx, scales, hoverIndex = -1) {
const data = el.value.data || {};
const series = data.series || [];
if (series.length === 0) return;
if (!syncCanvasSize(canvas, ctx)) return;
if (!scales || scales.length !== series.length) {
scales = computePlotScales(el);
}
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(128, 128, 128, 0.15)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (plotHeight) * (i / 4);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
for (let si = 0; si < series.length; si++) {
const s = series[si];
const values = s.values || [];
if (values.length < 2) continue;
const scale = scales[si];
const color = getComputedStyle(canvas.closest('.plot-container')).getPropertyValue(`--${s.color}`).trim() || '#e06c5c';
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2.25;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
for (let i = 0; i < values.length; i++) {
const x = padding.left + (i / (values.length - 1)) * plotWidth;
const y = padding.top + plotHeight - ((values[i] - scale.min) / scale.range) * plotHeight;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
const overlay = series
.filter(sr => (sr.values || []).length > 0)
.map(sr => ({
color: getComputedStyle(canvas.closest('.plot-container')).getPropertyValue(`--${sr.color}`).trim() || '#e06c5c',
text: `${sr.name} ${formatNum(sr.values[sr.values.length - 1], 0.01)}`,
}));
if (overlay.length > 0) {
ctx.font = '600 11px "Cascadia Code", "Fira Code", Consolas, monospace';
ctx.textBaseline = 'middle';
const rowH = 16, dotSize = 7, padX = 8, gap = 7;
let textW = 0;
for (const o of overlay) textW = Math.max(textW, ctx.measureText(o.text).width);
const boxW = padX + dotSize + gap + Math.ceil(textW) + padX;
const boxH = overlay.length * rowH + 6;
const boxX = padding.left + 4, boxY = padding.top + 4;
ctx.fillStyle = 'rgba(18, 18, 18, 0.55)';
roundRectPath(ctx, boxX, boxY, boxW, boxH, 6);
ctx.fill();
overlay.forEach((o, i) => {
const cy = boxY + 3 + rowH * i + rowH / 2;
ctx.fillStyle = o.color;
roundRectPath(ctx, boxX + padX, cy - dotSize / 2, dotSize, dotSize, 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
ctx.textAlign = 'left';
ctx.fillText(o.text, boxX + padX + dotSize + gap, cy);
});
ctx.textBaseline = 'alphabetic';
}
if (hoverIndex >= 0 && series.length > 0 && series[0].values) {
const hoverX = padding.left + (hoverIndex / (series[0].values.length - 1)) * plotWidth;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(hoverX, padding.top);
ctx.lineTo(hoverX, height - padding.bottom);
ctx.stroke();
ctx.setLineDash([]);
for (let si = 0; si < series.length; si++) {
const vals = series[si].values || [];
if (hoverIndex >= vals.length) continue;
const scale = scales[si];
const my = padding.top + plotHeight - ((vals[hoverIndex] - scale.min) / scale.range) * plotHeight;
const color = getComputedStyle(canvas.closest('.plot-container')).getPropertyValue(`--${series[si].color}`).trim() || '#e06c5c';
ctx.beginPath();
ctx.arc(hoverX, my, 4, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(18, 18, 18, 0.9)';
ctx.stroke();
}
}
}
function cleanupCharts() {
for (const instance of chartInstances.values()) {
if (instance.canvas) {
instance.canvas.onmousemove = null;
instance.canvas.onmouseleave = null;
chartResizeObserver.unobserve(instance.canvas);
}
}
chartInstances.clear();
initRetryCounts.clear();
}
function cleanupRecentlyInteracted() {
const now = Date.now();
for (const [id, timestamp] of recentlyInteracted.entries()) {
if (now - timestamp > RECENT_INTERACTION_TTL) {
recentlyInteracted.delete(id);
}
}
}
setInterval(cleanupRecentlyInteracted, 10000);
let resizeTimeout = null;
window.addEventListener('resize', () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
for (const el of elements.values()) {
if (el.kind.type === 'MiniChart') {
updateMiniChart(el);
} else if (el.kind.type === 'Plot') {
updatePlot(el);
}
}
}, 100);
});
window.addEventListener('scroll', () => {
const btn = document.getElementById('scroll-top');
if (btn) btn.classList.toggle('visible', window.scrollY > 300);
});
buildToolbar();
installLayoutObservers();
watchDevicePixelRatio();
connect();
window.addEventListener('beforeunload', () => {
cleanupCharts();
if (ws) ws.close();
});
})();
</script>
</body>
</html>