<!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);
--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;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
#app {
column-count: 2;
column-gap: 12px;
padding: 12px;
margin: 0 auto;
}
@media (min-width: 1400px) {
#app {
column-count: 3;
}
}
@media (min-width: 2200px) {
#app {
column-count: 4;
}
}
@media (max-width: 900px) {
#app {
column-count: 1;
}
}
#app>.card {
break-inside: avoid;
margin-bottom: 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-header {
display: flex;
align-items: center;
justify-content: space-between;
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;
}
.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-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-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);
}
.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-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 0 180px;
font-size: 12px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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;
-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-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-legend {
display: flex;
gap: 16px;
}
.plot-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
}
.plot-legend-color {
width: 12px;
height: 3px;
border-radius: 2px;
}
.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;
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;
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;
color: var(--text);
}
.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;
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;
}
.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;
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);
ws.onopen = () => {
setStatus('connected');
reconnectDelay = 500;
};
ws.onclose = () => {
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;
}
}
}
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>`;
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);
}
let html = '';
for (const [name, elems] of windows) {
const firstAccent = elems[0]?.meta?.accent || 'coral';
html += `<div class="card ${firstAccent}" data-window="${esc(name)}">`;
html += `<div class="card-header" onclick="wgui.toggleCard(this)">`;
html += `<span class="card-title">${esc(name)}</span>`;
html += `<span class="card-toggle">▼</span>`;
html += `</div>`;
html += `<div class="card-body">`;
for (const el of elems) {
html += renderElement(el);
}
html += `</div></div>`;
}
app.innerHTML = html;
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-text" style="flex:1;padding:0 16px;">${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';
let legendHtml = '';
if (series.length > 1) {
legendHtml = '<div class="plot-legend">' +
series.map(s => `<div class="plot-legend-item"><div class="plot-legend-color" style="background:var(--${s.color})"></div>${esc(s.name)}</div>`).join('') +
'</div>';
}
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>
${legendHtml}
</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 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);
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
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;
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';
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) {
headerEl.parentElement.classList.toggle('collapsed');
},
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);
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
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));
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, 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 (!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;
ctx.lineJoin = '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();
if (si === 0) {
const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
gradient.addColorStop(0, color + '30');
gradient.addColorStop(1, color + '05');
ctx.beginPath();
ctx.moveTo(padding.left, padding.top + plotHeight);
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;
ctx.lineTo(x, y);
}
ctx.lineTo(width - padding.right, padding.top + plotHeight);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
}
ctx.fillStyle = color;
for (let i = 0; i < values.length; i += Math.ceil(values.length / 20)) {
const x = padding.left + (i / (values.length - 1)) * plotWidth;
const y = padding.top + plotHeight - ((values[i] - scale.min) / scale.range) * plotHeight;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
}
if (values.length > 0) {
const currentValue = values[values.length - 1];
ctx.fillStyle = color;
ctx.font = 'bold 11px var(--mono)';
ctx.textAlign = 'left';
const labelX = padding.left + 4;
const labelY = padding.top + 14 + (si * 16);
ctx.fillText(`${s.name}: ${formatNum(currentValue, 0.01)}`, labelX, labelY);
}
}
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.3)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(hoverX, padding.top);
ctx.lineTo(hoverX, height - padding.bottom);
ctx.stroke();
ctx.setLineDash([]);
}
}
function cleanupCharts() {
for (const instance of chartInstances.values()) {
if (instance.canvas) {
instance.canvas.onmousemove = null;
instance.canvas.onmouseleave = null;
}
}
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);
});
connect();
window.addEventListener('beforeunload', () => {
cleanupCharts();
if (ws) ws.close();
});
})();
</script>
</body>
</html>