2301 lines
86 KiB
HTML
2301 lines
86 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GUI Designer - KNX Display</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1419;
|
|
--bg-2: #141c24;
|
|
--panel: #18212b;
|
|
--panel-2: #1d2732;
|
|
--border: #2a3543;
|
|
--text: #e7edf3;
|
|
--muted: #9aa8b4;
|
|
--accent: #f6c177;
|
|
--accent-2: #7dd3b0;
|
|
--danger: #ff6b6b;
|
|
--canvas-bg: #1a1a2e;
|
|
--shadow: rgba(0, 0, 0, 0.35);
|
|
}
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
font-family: "Space Grotesk", "Fira Sans", "Segoe UI", sans-serif;
|
|
background:
|
|
radial-gradient(1200px 800px at 15% 10%, #1b2530 0%, #0f1419 55%),
|
|
linear-gradient(135deg, #0f1419, #141c24);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 14px 20px;
|
|
background: rgba(15, 20, 25, 0.85);
|
|
border-bottom: 1px solid var(--border);
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.brand-mark {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 10px;
|
|
background: linear-gradient(135deg, var(--accent), #f0b365);
|
|
color: #1b1308;
|
|
display: grid;
|
|
place-items: center;
|
|
font-weight: 700;
|
|
letter-spacing: 0.04em;
|
|
box-shadow: 0 6px 16px rgba(246, 193, 119, 0.35);
|
|
}
|
|
|
|
.brand-text .title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.brand-text .subtitle {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.top-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.btn {
|
|
border: 1px solid transparent;
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
padding: 8px 14px;
|
|
border-radius: 10px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
|
|
}
|
|
|
|
.btn.small {
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.btn.primary {
|
|
background: linear-gradient(135deg, var(--accent), #f0b365);
|
|
color: #1b1308;
|
|
box-shadow: 0 8px 18px rgba(246, 193, 119, 0.35);
|
|
}
|
|
|
|
.btn.ghost {
|
|
background: transparent;
|
|
border-color: var(--border);
|
|
}
|
|
|
|
.btn.danger {
|
|
border-color: rgba(255, 107, 107, 0.4);
|
|
color: #ffd1d1;
|
|
}
|
|
|
|
.btn.prog.active {
|
|
background: rgba(255, 107, 107, 0.18);
|
|
border-color: rgba(255, 107, 107, 0.6);
|
|
color: #ffd1d1;
|
|
box-shadow: 0 8px 18px rgba(255, 107, 107, 0.2);
|
|
}
|
|
|
|
.btn:hover { transform: translateY(-1px); }
|
|
.btn:active { transform: translateY(1px); }
|
|
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
|
|
|
.icon-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border);
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
display: grid;
|
|
place-items: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.workspace {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: 300px 1fr 320px;
|
|
}
|
|
|
|
.sidebar {
|
|
padding: 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sidebar.left {
|
|
border-right: 1px solid var(--border);
|
|
}
|
|
|
|
.sidebar.right {
|
|
border-left: 1px solid var(--border);
|
|
}
|
|
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
padding: 14px;
|
|
box-shadow: 0 8px 18px var(--shadow);
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.panel-header h3 {
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.panel-hint {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.element-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.element-btn {
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 10px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
transition: transform 0.15s ease, border-color 0.2s ease;
|
|
}
|
|
|
|
.element-btn:hover {
|
|
border-color: var(--accent);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.element-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.element-sub {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin-top: 2px;
|
|
display: block;
|
|
}
|
|
|
|
.screen-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.screen-item {
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 8px 10px;
|
|
text-align: left;
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.screen-item.active {
|
|
border-color: var(--accent-2);
|
|
box-shadow: 0 0 0 2px rgba(125, 211, 176, 0.2);
|
|
}
|
|
|
|
.screen-tag {
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
border-radius: 999px;
|
|
background: rgba(125, 211, 176, 0.15);
|
|
color: var(--accent-2);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
.screen-name {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.screen-settings {
|
|
margin-top: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.tree {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.tree-root {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.tree-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.tree-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 8px 10px;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
transition: border-color 0.2s ease;
|
|
}
|
|
|
|
.tree-item.active {
|
|
border-color: var(--accent-2);
|
|
box-shadow: 0 0 0 2px rgba(125, 211, 176, 0.2);
|
|
}
|
|
|
|
.tree-item.hidden {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.tree-tag {
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
border-radius: 999px;
|
|
background: rgba(125, 211, 176, 0.15);
|
|
color: var(--accent-2);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
.tree-name {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tree-id {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.tree-empty {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
padding: 6px 0;
|
|
}
|
|
|
|
.control-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.control-row label {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.control-row input[type="color"] {
|
|
width: 44px;
|
|
height: 28px;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.control-row input[type="range"] {
|
|
flex: 1;
|
|
}
|
|
|
|
.control-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.canvas-area {
|
|
padding: 20px;
|
|
overflow: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.canvas-shell {
|
|
padding: 18px;
|
|
border-radius: 18px;
|
|
border: 1px solid var(--border);
|
|
background: rgba(20, 28, 36, 0.7);
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), 0 20px 40px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.canvas {
|
|
position: relative;
|
|
background: var(--canvas-bg);
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.canvas::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image:
|
|
linear-gradient(to right, rgba(255, 255, 255, 0.06) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
|
|
background-size: 32px 32px;
|
|
opacity: 0.3;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
|
|
.canvas.grid-off::before {
|
|
opacity: 0;
|
|
}
|
|
|
|
.widget {
|
|
position: absolute;
|
|
cursor: move;
|
|
user-select: none;
|
|
touch-action: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.widget.selected {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.resize-handle {
|
|
position: absolute;
|
|
right: -6px;
|
|
bottom: -6px;
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 4px;
|
|
background: var(--accent);
|
|
border: 2px solid #1b1308;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
|
cursor: se-resize;
|
|
z-index: 2;
|
|
}
|
|
|
|
.resize-handle::before {
|
|
content: "";
|
|
position: absolute;
|
|
right: 2px;
|
|
bottom: 2px;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-right: 2px solid rgba(27, 19, 8, 0.65);
|
|
border-bottom: 2px solid rgba(27, 19, 8, 0.65);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.widget-label {
|
|
padding: 4px 6px;
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.widget-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.widget-led {
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.widget-chart {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding: 8px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.widget-chart .chart-title {
|
|
font-size: 12px;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.widget-chart .chart-canvas {
|
|
position: relative;
|
|
flex: 1;
|
|
border-radius: 10px;
|
|
background: rgba(7, 12, 18, 0.35);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.widget-chart .chart-canvas::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image:
|
|
linear-gradient(to right, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
|
|
background-size: 24px 24px;
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.widget-chart .chart-line {
|
|
position: absolute;
|
|
inset: 18% 8%;
|
|
background: linear-gradient(120deg, rgba(239, 99, 81, 0.0) 0%, rgba(239, 99, 81, 0.6) 45%, rgba(94, 162, 239, 0.8) 70%, rgba(125, 211, 176, 0.9) 100%);
|
|
border-radius: 999px;
|
|
height: 2px;
|
|
top: 45%;
|
|
}
|
|
|
|
.properties h4 {
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
margin: 16px 0 10px;
|
|
}
|
|
|
|
.prop-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.prop-row label {
|
|
width: 90px;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.prop-row input,
|
|
.prop-row select {
|
|
flex: 1;
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 6px 8px;
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.prop-row input[type="color"] {
|
|
padding: 2px;
|
|
height: 30px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chart-series {
|
|
padding-top: 8px;
|
|
margin-top: 8px;
|
|
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.prop-row input[type="checkbox"] {
|
|
width: auto;
|
|
flex: none;
|
|
}
|
|
|
|
.prop-actions {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.no-selection {
|
|
color: var(--muted);
|
|
text-align: center;
|
|
padding: 20px 10px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.status {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--accent);
|
|
color: #1b1308;
|
|
padding: 10px 18px;
|
|
border-radius: 10px;
|
|
font-weight: 600;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.status.show { opacity: 1; }
|
|
.status.error {
|
|
background: var(--danger);
|
|
color: #1b0f0f;
|
|
}
|
|
|
|
.topbar,
|
|
.panel,
|
|
.canvas-shell {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
transition-delay: var(--delay, 0s);
|
|
}
|
|
|
|
body.loaded .topbar,
|
|
body.loaded .panel,
|
|
body.loaded .canvas-shell {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.workspace {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto auto auto;
|
|
}
|
|
|
|
.sidebar.left {
|
|
order: 1;
|
|
border-right: none;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.canvas-area {
|
|
order: 2;
|
|
}
|
|
|
|
.sidebar.right {
|
|
order: 3;
|
|
border-left: none;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="topbar" style="--delay: 0s;">
|
|
<div class="brand">
|
|
<div class="brand-mark">LV</div>
|
|
<div class="brand-text">
|
|
<div class="title">GUI Designer</div>
|
|
<div class="subtitle">KNX Display</div>
|
|
</div>
|
|
</div>
|
|
<div class="top-actions">
|
|
<button class="btn ghost" onclick="enableUsbMode()">USB-Modus</button>
|
|
<button class="btn ghost prog" id="knxProgBtn" onclick="toggleKnxProg()" aria-pressed="false" title="KNX Programmiermodus">KNX Prog AUS</button>
|
|
<button class="btn ghost danger" onclick="resetKnxSettings()">KNX Reset</button>
|
|
<button class="btn ghost danger" onclick="resetConfig()">Zuruecksetzen</button>
|
|
<button class="btn primary" onclick="saveConfig()">Speichern & Anwenden</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="workspace">
|
|
<aside class="sidebar left">
|
|
<section class="panel" style="--delay: 0.05s;">
|
|
<div class="panel-header">
|
|
<h3>Elemente</h3>
|
|
<span class="panel-hint">Klick zum Hinzufuegen</span>
|
|
</div>
|
|
<div class="element-grid">
|
|
<button class="element-btn" onclick="addWidget('label')">
|
|
<span class="element-title">Label</span>
|
|
<span class="element-sub">Text</span>
|
|
</button>
|
|
<button class="element-btn" onclick="addWidget('button')">
|
|
<span class="element-title">Button</span>
|
|
<span class="element-sub">Aktion</span>
|
|
</button>
|
|
<button class="element-btn" onclick="addWidget('led')">
|
|
<span class="element-title">LED</span>
|
|
<span class="element-sub">Status</span>
|
|
</button>
|
|
<button class="element-btn" onclick="addWidget('chart')">
|
|
<span class="element-title">Chart</span>
|
|
<span class="element-sub">Verlauf</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" style="--delay: 0.1s;">
|
|
<div class="panel-header">
|
|
<h3>Bildschirme</h3>
|
|
<button class="icon-btn" onclick="addScreen()">+</button>
|
|
</div>
|
|
<div class="screen-list" id="screenList"></div>
|
|
<div class="screen-settings">
|
|
<div class="control-row">
|
|
<label for="screenName">Name</label>
|
|
<input type="text" id="screenName" placeholder="Screen 1">
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="screenMode">Modus</label>
|
|
<select id="screenMode">
|
|
<option value="0">Fullscreen</option>
|
|
<option value="1">Modal</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn ghost danger small" onclick="deleteScreen()">Screen loeschen</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" style="--delay: 0.15s;">
|
|
<div class="panel-header">
|
|
<h3>Baum</h3>
|
|
<span class="panel-hint" id="treeCount">0</span>
|
|
</div>
|
|
<div class="tree" id="tree"></div>
|
|
</section>
|
|
|
|
<section class="panel" style="--delay: 0.2s;">
|
|
<div class="panel-header">
|
|
<h3>Canvas</h3>
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="bgColor">Hintergrund</label>
|
|
<input type="color" id="bgColor" value="#1a1a2e">
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="zoom">Zoom</label>
|
|
<input type="range" id="zoom" min="0.3" max="1" step="0.05" value="0.6">
|
|
</div>
|
|
<div class="control-meta">
|
|
<span id="zoomValue">60%</span>
|
|
<span>1280x800</span>
|
|
</div>
|
|
<label class="toggle">
|
|
<input type="checkbox" id="gridToggle" checked>
|
|
<span>Grid anzeigen</span>
|
|
</label>
|
|
</section>
|
|
|
|
<section class="panel" style="--delay: 0.25s;">
|
|
<div class="panel-header">
|
|
<h3>Navigation</h3>
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="startScreen">Startscreen</label>
|
|
<select id="startScreen"></select>
|
|
</div>
|
|
<label class="toggle">
|
|
<input type="checkbox" id="standbyEnabled">
|
|
<span>Standby aktiv</span>
|
|
</label>
|
|
<div class="control-row" style="margin-top:8px;">
|
|
<label for="standbyScreen">Standby</label>
|
|
<select id="standbyScreen"></select>
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="standbyMinutes">Minuten</label>
|
|
<input type="number" id="standbyMinutes" min="0" value="5">
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" style="--delay: 0.3s;">
|
|
<div class="panel-header">
|
|
<h3>KNX Zeit</h3>
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="knxTimeAddr">Uhrzeit</label>
|
|
<select id="knxTimeAddr"></select>
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="knxDateAddr">Datum</label>
|
|
<select id="knxDateAddr"></select>
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="knxDateTimeAddr">Datum+Zeit</label>
|
|
<select id="knxDateTimeAddr"></select>
|
|
</div>
|
|
<div class="control-row">
|
|
<label for="knxNightAddr">Nachtmodus</label>
|
|
<select id="knxNightAddr"></select>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
|
|
<main class="canvas-area">
|
|
<div class="canvas-shell" style="--delay: 0.1s;">
|
|
<div class="canvas" id="canvas"></div>
|
|
</div>
|
|
</main>
|
|
|
|
<aside class="sidebar right">
|
|
<section class="panel properties" id="properties" style="--delay: 0.15s;">
|
|
<div class="no-selection">
|
|
Kein Widget ausgewaehlt.<br><br>
|
|
Waehle ein Widget im Canvas oder im Baum.
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
|
|
<div class="status" id="status"></div>
|
|
|
|
<script>
|
|
const DISPLAY_W = 1280;
|
|
const DISPLAY_H = 800;
|
|
const MAX_SCREENS = 8;
|
|
|
|
const WIDGET_TYPES = {
|
|
LABEL: 0,
|
|
BUTTON: 1,
|
|
LED: 2,
|
|
CHART: 9
|
|
};
|
|
|
|
const BUTTON_ACTIONS = {
|
|
KNX: 0,
|
|
JUMP: 1,
|
|
BACK: 2
|
|
};
|
|
|
|
const TYPE_KEYS = {
|
|
0: 'label',
|
|
1: 'button',
|
|
2: 'led',
|
|
9: 'chart'
|
|
};
|
|
|
|
const TYPE_LABELS = {
|
|
label: 'Label',
|
|
button: 'Button',
|
|
led: 'LED',
|
|
chart: 'Chart'
|
|
};
|
|
|
|
const textSources = {
|
|
0: 'Statisch',
|
|
1: 'KNX Temperatur',
|
|
2: 'KNX Schalter',
|
|
3: 'KNX Prozent',
|
|
4: 'KNX Text',
|
|
5: 'KNX Leistung (DPT 14.056)',
|
|
6: 'KNX Energie (DPT 13.013)',
|
|
7: 'KNX Dezimalfaktor (DPT 5.005)',
|
|
8: 'KNX Uhrzeit (DPT 10.001)',
|
|
9: 'KNX Datum (DPT 11.001)',
|
|
10: 'KNX Datum & Uhrzeit (DPT 19.001)'
|
|
};
|
|
|
|
const textSourceGroups = [
|
|
{ label: 'Statisch', values: [0] },
|
|
{ label: 'DPT 1.x', values: [2] },
|
|
{ label: 'DPT 5.x', values: [3, 7] },
|
|
{ label: 'DPT 9.x', values: [1] },
|
|
{ label: 'DPT 10.x', values: [8] },
|
|
{ label: 'DPT 11.x', values: [9] },
|
|
{ label: 'DPT 13.x', values: [6] },
|
|
{ label: 'DPT 14.x', values: [5] },
|
|
{ label: 'DPT 16.x', values: [4] },
|
|
{ label: 'DPT 19.x', values: [10] }
|
|
];
|
|
|
|
const sourceOptions = {
|
|
label: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
|
button: [0],
|
|
led: [0, 2],
|
|
chart: [1, 3, 5, 6, 7]
|
|
};
|
|
|
|
const fontSizes = [14, 18, 22, 28, 36, 48];
|
|
|
|
const defaultFormats = {
|
|
1: '%.1f °C',
|
|
2: '%s',
|
|
3: '%d %%',
|
|
4: '%s',
|
|
5: '%.1f W',
|
|
6: '%.0f kWh',
|
|
7: '%d',
|
|
8: '%02d:%02d:%02d',
|
|
9: '%02d.%02d.%04d',
|
|
10: '%02d.%02d.%04d %02d:%02d:%02d'
|
|
};
|
|
|
|
const WIDGET_DEFAULTS = {
|
|
label: {
|
|
w: 160,
|
|
h: 40,
|
|
text: 'Neues Label',
|
|
textSrc: 0,
|
|
fontSize: 1,
|
|
textColor: '#FFFFFF',
|
|
bgColor: '#0E1217',
|
|
bgOpacity: 0,
|
|
radius: 6,
|
|
shadow: { enabled: false, x: 2, y: 2, blur: 8, spread: 0, color: '#000000' },
|
|
isToggle: false,
|
|
knxAddrWrite: 0,
|
|
knxAddr: 0,
|
|
action: BUTTON_ACTIONS.KNX,
|
|
targetScreen: 0
|
|
},
|
|
button: {
|
|
w: 130,
|
|
h: 52,
|
|
text: 'Button',
|
|
textSrc: 0,
|
|
fontSize: 1,
|
|
textColor: '#FFFFFF',
|
|
bgColor: '#2E7DD1',
|
|
bgOpacity: 255,
|
|
radius: 10,
|
|
shadow: { enabled: true, x: 2, y: 3, blur: 10, spread: 0, color: '#000000' },
|
|
isToggle: false,
|
|
knxAddrWrite: 0,
|
|
knxAddr: 0,
|
|
action: BUTTON_ACTIONS.KNX,
|
|
targetScreen: 0
|
|
},
|
|
led: {
|
|
w: 60,
|
|
h: 60,
|
|
text: '',
|
|
textSrc: 0,
|
|
fontSize: 1,
|
|
textColor: '#FFFFFF',
|
|
bgColor: '#F6C177',
|
|
bgOpacity: 255,
|
|
radius: 30,
|
|
shadow: { enabled: true, x: 0, y: 0, blur: 18, spread: 0, color: '#F6C177' },
|
|
isToggle: false,
|
|
knxAddrWrite: 0,
|
|
knxAddr: 0,
|
|
action: BUTTON_ACTIONS.KNX,
|
|
targetScreen: 0
|
|
},
|
|
chart: {
|
|
w: 320,
|
|
h: 200,
|
|
text: 'Chart',
|
|
textSrc: 0,
|
|
fontSize: 1,
|
|
textColor: '#E7EDF3',
|
|
bgColor: '#16202c',
|
|
bgOpacity: 255,
|
|
radius: 12,
|
|
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
|
|
isToggle: false,
|
|
knxAddrWrite: 0,
|
|
knxAddr: 0,
|
|
action: BUTTON_ACTIONS.KNX,
|
|
targetScreen: 0,
|
|
chart: {
|
|
period: 0,
|
|
series: [
|
|
{ knxAddr: 0, textSrc: 1, color: '#EF6351' }
|
|
]
|
|
}
|
|
}
|
|
};
|
|
|
|
let config = {
|
|
startScreen: 0,
|
|
standby: { enabled: false, screen: -1, minutes: 5 },
|
|
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
|
screens: []
|
|
};
|
|
let selectedWidget = null;
|
|
let dragState = null;
|
|
let resizeState = null;
|
|
let nextWidgetId = 0;
|
|
let nextScreenId = 0;
|
|
let knxAddresses = [];
|
|
let knxProgMode = false;
|
|
let knxProgBusy = false;
|
|
let canvasScale = 0.6;
|
|
let showGrid = true;
|
|
let activeScreenId = 0;
|
|
|
|
function typeKeyFor(type) {
|
|
return TYPE_KEYS[type] || 'label';
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
if (Number.isNaN(value)) return min;
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function minSizeFor(widget) {
|
|
const key = typeKeyFor(widget.type);
|
|
if (key === 'button') return { w: 60, h: 30 };
|
|
if (key === 'led') return { w: 20, h: 20 };
|
|
if (key === 'chart') return { w: 160, h: 120 };
|
|
return { w: 40, h: 20 };
|
|
}
|
|
|
|
function hexToRgba(hex, alpha) {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return `rgba(${r},${g},${b},${alpha})`;
|
|
}
|
|
|
|
function getScreenById(id) {
|
|
return config.screens.find((screen) => screen.id === id);
|
|
}
|
|
|
|
function getActiveScreen() {
|
|
return getScreenById(activeScreenId) || config.screens[0];
|
|
}
|
|
|
|
function normalizeWidget(w) {
|
|
const key = typeKeyFor(w.type);
|
|
const defaults = WIDGET_DEFAULTS[key];
|
|
|
|
Object.keys(defaults).forEach((prop) => {
|
|
if (prop === 'shadow') return;
|
|
if (w[prop] === undefined || w[prop] === null) {
|
|
w[prop] = defaults[prop];
|
|
}
|
|
});
|
|
|
|
if (!w.shadow) {
|
|
w.shadow = { ...defaults.shadow };
|
|
} else {
|
|
Object.keys(defaults.shadow).forEach((prop) => {
|
|
if (w.shadow[prop] === undefined || w.shadow[prop] === null) {
|
|
w.shadow[prop] = defaults.shadow[prop];
|
|
}
|
|
});
|
|
}
|
|
|
|
if (defaults.chart) {
|
|
const maxSeries = 3;
|
|
if (!w.chart) {
|
|
w.chart = {
|
|
period: defaults.chart.period ?? 0,
|
|
series: (defaults.chart.series || []).map(s => ({ ...s }))
|
|
};
|
|
} else {
|
|
if (w.chart.period === undefined || w.chart.period === null) {
|
|
w.chart.period = defaults.chart.period ?? 0;
|
|
}
|
|
if (!Array.isArray(w.chart.series)) {
|
|
w.chart.series = [];
|
|
}
|
|
if (w.chart.series.length === 0 && defaults.chart.series) {
|
|
w.chart.series = defaults.chart.series.map(s => ({ ...s }));
|
|
}
|
|
for (let i = 0; i < w.chart.series.length && i < maxSeries; i++) {
|
|
const fallback = (defaults.chart.series && defaults.chart.series[i]) || defaults.chart.series?.[0] || { knxAddr: 0, textSrc: 1, color: '#EF6351' };
|
|
const entry = w.chart.series[i];
|
|
if (entry.knxAddr === undefined || entry.knxAddr === null) entry.knxAddr = fallback.knxAddr;
|
|
if (entry.textSrc === undefined || entry.textSrc === null) entry.textSrc = fallback.textSrc;
|
|
if (!entry.color) entry.color = fallback.color;
|
|
}
|
|
if (w.chart.series.length > maxSeries) {
|
|
w.chart.series = w.chart.series.slice(0, maxSeries);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (w.visible === undefined || w.visible === null) w.visible = true;
|
|
if (w.x === undefined || w.x === null) w.x = 100;
|
|
if (w.y === undefined || w.y === null) w.y = 100;
|
|
if (w.id === undefined || w.id === null) w.id = nextWidgetId++;
|
|
if (w.action === undefined || w.action === null) w.action = BUTTON_ACTIONS.KNX;
|
|
if (w.targetScreen === undefined || w.targetScreen === null) w.targetScreen = 0;
|
|
}
|
|
|
|
function normalizeScreen(screen) {
|
|
if (screen.id === undefined || screen.id === null) screen.id = nextScreenId++;
|
|
if (!screen.name) screen.name = `Screen ${screen.id}`;
|
|
if (screen.mode === undefined || screen.mode === null) screen.mode = 0;
|
|
if (!screen.bgColor) screen.bgColor = '#1A1A2E';
|
|
if (!Array.isArray(screen.widgets)) screen.widgets = [];
|
|
|
|
// Modal defaults
|
|
if (!screen.modal) {
|
|
screen.modal = { x: 0, y: 0, w: 0, h: 0, radius: 12, dim: true };
|
|
} else {
|
|
if (screen.modal.x === undefined) screen.modal.x = 0;
|
|
if (screen.modal.y === undefined) screen.modal.y = 0;
|
|
if (screen.modal.w === undefined) screen.modal.w = 0;
|
|
if (screen.modal.h === undefined) screen.modal.h = 0;
|
|
if (screen.modal.radius === undefined) screen.modal.radius = 12;
|
|
if (screen.modal.dim === undefined) screen.modal.dim = true;
|
|
}
|
|
|
|
screen.widgets.forEach(normalizeWidget);
|
|
}
|
|
|
|
async function loadKnxAddresses() {
|
|
try {
|
|
const resp = await fetch('/api/knx/addresses');
|
|
knxAddresses = await resp.json();
|
|
} catch (e) {
|
|
knxAddresses = [];
|
|
}
|
|
}
|
|
|
|
function mapLegacyKnxAddresses() {
|
|
if (!knxAddresses.length || !config || !Array.isArray(config.screens)) return;
|
|
const addrByIndex = new Map();
|
|
const gaSet = new Set();
|
|
knxAddresses.forEach((addr) => {
|
|
if (typeof addr.index === 'number' && typeof addr.addr === 'number') {
|
|
addrByIndex.set(addr.index, addr.addr);
|
|
gaSet.add(addr.addr);
|
|
}
|
|
});
|
|
|
|
config.screens.forEach((screen) => {
|
|
if (!Array.isArray(screen.widgets)) return;
|
|
screen.widgets.forEach((w) => {
|
|
if (typeof w.knxAddr === 'number' && w.knxAddr > 0) {
|
|
if (!gaSet.has(w.knxAddr) && addrByIndex.has(w.knxAddr)) {
|
|
w.knxAddr = addrByIndex.get(w.knxAddr);
|
|
}
|
|
}
|
|
if (typeof w.knxAddrWrite === 'number' && w.knxAddrWrite > 0) {
|
|
if (!gaSet.has(w.knxAddrWrite) && addrByIndex.has(w.knxAddrWrite)) {
|
|
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
|
|
}
|
|
}
|
|
if (w.chart && Array.isArray(w.chart.series)) {
|
|
w.chart.series.forEach((series) => {
|
|
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
|
|
if (!gaSet.has(series.knxAddr) && addrByIndex.has(series.knxAddr)) {
|
|
series.knxAddr = addrByIndex.get(series.knxAddr);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
if (config.knx) {
|
|
if (typeof config.knx.time === 'number' && config.knx.time > 0) {
|
|
if (!gaSet.has(config.knx.time) && addrByIndex.has(config.knx.time)) {
|
|
config.knx.time = addrByIndex.get(config.knx.time);
|
|
}
|
|
}
|
|
if (typeof config.knx.date === 'number' && config.knx.date > 0) {
|
|
if (!gaSet.has(config.knx.date) && addrByIndex.has(config.knx.date)) {
|
|
config.knx.date = addrByIndex.get(config.knx.date);
|
|
}
|
|
}
|
|
if (typeof config.knx.dateTime === 'number' && config.knx.dateTime > 0) {
|
|
if (!gaSet.has(config.knx.dateTime) && addrByIndex.has(config.knx.dateTime)) {
|
|
config.knx.dateTime = addrByIndex.get(config.knx.dateTime);
|
|
}
|
|
}
|
|
if (typeof config.knx.night === 'number' && config.knx.night > 0) {
|
|
if (!gaSet.has(config.knx.night) && addrByIndex.has(config.knx.night)) {
|
|
config.knx.night = addrByIndex.get(config.knx.night);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateKnxProgButton() {
|
|
const btn = document.getElementById('knxProgBtn');
|
|
if (!btn) return;
|
|
btn.classList.toggle('active', knxProgMode);
|
|
btn.setAttribute('aria-pressed', knxProgMode ? 'true' : 'false');
|
|
btn.textContent = knxProgMode ? 'KNX Prog AN' : 'KNX Prog AUS';
|
|
btn.title = knxProgMode ? 'KNX Programmiermodus aktiv' : 'KNX Programmiermodus aus';
|
|
btn.disabled = knxProgBusy;
|
|
}
|
|
|
|
async function loadKnxProgMode() {
|
|
try {
|
|
const resp = await fetch('/api/knx/prog');
|
|
if (!resp.ok) {
|
|
updateKnxProgButton();
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
if (typeof data.progMode === 'boolean') {
|
|
knxProgMode = data.progMode;
|
|
} else if (typeof data.enabled === 'boolean') {
|
|
knxProgMode = data.enabled;
|
|
}
|
|
} catch (e) {
|
|
// Ignore when API is not available.
|
|
}
|
|
updateKnxProgButton();
|
|
}
|
|
|
|
async function loadConfig() {
|
|
try {
|
|
await loadKnxAddresses();
|
|
const resp = await fetch('/api/config');
|
|
const data = await resp.json();
|
|
|
|
if (Array.isArray(data.screens)) {
|
|
config = data;
|
|
} else {
|
|
config = {
|
|
startScreen: 0,
|
|
standby: { enabled: false, screen: -1, minutes: 5 },
|
|
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
|
screens: [
|
|
{
|
|
id: 0,
|
|
name: 'Screen 1',
|
|
mode: 0,
|
|
bgColor: data.bgColor || '#1A1A2E',
|
|
widgets: data.widgets || []
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
if (!config.standby) {
|
|
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
|
}
|
|
if (!config.knx) {
|
|
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
|
|
} else {
|
|
if (config.knx.time === undefined) config.knx.time = 0;
|
|
if (config.knx.date === undefined) config.knx.date = 0;
|
|
if (config.knx.dateTime === undefined) config.knx.dateTime = 0;
|
|
if (config.knx.night === undefined) config.knx.night = 0;
|
|
}
|
|
|
|
nextWidgetId = 0;
|
|
nextScreenId = 0;
|
|
config.screens.forEach((screen) => {
|
|
if (typeof screen.id === 'number') {
|
|
nextScreenId = Math.max(nextScreenId, screen.id + 1);
|
|
}
|
|
});
|
|
config.screens.forEach((screen) => {
|
|
normalizeScreen(screen);
|
|
nextScreenId = Math.max(nextScreenId, screen.id + 1);
|
|
screen.widgets.forEach((w) => {
|
|
nextWidgetId = Math.max(nextWidgetId, w.id + 1);
|
|
});
|
|
});
|
|
|
|
mapLegacyKnxAddresses();
|
|
|
|
if (config.standby.screen >= 255) {
|
|
config.standby.screen = -1;
|
|
}
|
|
|
|
if (!getScreenById(config.startScreen) && config.screens.length) {
|
|
config.startScreen = config.screens[0].id;
|
|
}
|
|
|
|
activeScreenId = getScreenById(config.startScreen)
|
|
? config.startScreen
|
|
: (config.screens[0]?.id ?? 0);
|
|
|
|
applyCanvasMetrics();
|
|
renderAll();
|
|
} catch (e) {
|
|
showStatus('Fehler beim Laden', true);
|
|
}
|
|
}
|
|
|
|
function renderAll() {
|
|
renderScreenList();
|
|
renderScreenSettings();
|
|
renderNavSettings();
|
|
renderKnxSettings();
|
|
renderCanvas();
|
|
renderTree();
|
|
renderProperties();
|
|
}
|
|
|
|
function applyCanvasMetrics() {
|
|
const canvas = document.getElementById('canvas');
|
|
canvas.style.width = (DISPLAY_W * canvasScale) + 'px';
|
|
canvas.style.height = (DISPLAY_H * canvasScale) + 'px';
|
|
document.getElementById('zoomValue').textContent = Math.round(canvasScale * 100) + '%';
|
|
}
|
|
|
|
function renderScreenList() {
|
|
const list = document.getElementById('screenList');
|
|
list.innerHTML = '';
|
|
|
|
config.screens.forEach((screen) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'screen-item' + (screen.id === activeScreenId ? ' active' : '');
|
|
|
|
const tag = document.createElement('span');
|
|
tag.className = 'screen-tag';
|
|
tag.textContent = screen.mode === 1 ? 'Modal' : 'Fullscreen';
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'screen-name';
|
|
name.textContent = screen.name;
|
|
|
|
item.appendChild(tag);
|
|
item.appendChild(name);
|
|
item.addEventListener('click', () => selectScreen(screen.id));
|
|
|
|
list.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function renderScreenSettings() {
|
|
const screen = getActiveScreen();
|
|
document.getElementById('screenName').value = screen?.name || '';
|
|
document.getElementById('screenMode').value = screen?.mode ?? 0;
|
|
document.getElementById('bgColor').value = screen?.bgColor || '#1A1A2E';
|
|
}
|
|
|
|
function renderNavSettings() {
|
|
const startSelect = document.getElementById('startScreen');
|
|
const standbySelect = document.getElementById('standbyScreen');
|
|
|
|
startSelect.innerHTML = '';
|
|
standbySelect.innerHTML = '';
|
|
|
|
config.screens.forEach((screen) => {
|
|
const option = document.createElement('option');
|
|
option.value = screen.id;
|
|
option.textContent = screen.name;
|
|
startSelect.appendChild(option);
|
|
|
|
const standbyOption = document.createElement('option');
|
|
standbyOption.value = screen.id;
|
|
standbyOption.textContent = screen.name;
|
|
standbySelect.appendChild(standbyOption);
|
|
});
|
|
|
|
const noneOption = document.createElement('option');
|
|
noneOption.value = -1;
|
|
noneOption.textContent = '-- Kein --';
|
|
standbySelect.insertBefore(noneOption, standbySelect.firstChild);
|
|
|
|
startSelect.value = config.startScreen ?? 0;
|
|
document.getElementById('standbyEnabled').checked = !!config.standby.enabled;
|
|
document.getElementById('standbyMinutes').value = config.standby.minutes ?? 5;
|
|
standbySelect.value = config.standby.screen ?? -1;
|
|
}
|
|
|
|
function renderKnxSettings() {
|
|
const timeSelect = document.getElementById('knxTimeAddr');
|
|
const dateSelect = document.getElementById('knxDateAddr');
|
|
const dateTimeSelect = document.getElementById('knxDateTimeAddr');
|
|
const nightSelect = document.getElementById('knxNightAddr');
|
|
if (!timeSelect || !dateSelect || !dateTimeSelect || !nightSelect) return;
|
|
|
|
const options = ['<option value=\"0\">-- Keine --</option>'];
|
|
knxAddresses.forEach((a) => {
|
|
options.push(`<option value=\"${a.addr}\">GA ${a.addrStr} (GO${a.index})</option>`);
|
|
});
|
|
const html = options.join('');
|
|
|
|
timeSelect.innerHTML = html;
|
|
dateSelect.innerHTML = html;
|
|
dateTimeSelect.innerHTML = html;
|
|
nightSelect.innerHTML = html;
|
|
|
|
timeSelect.value = config.knx?.time ?? 0;
|
|
dateSelect.value = config.knx?.date ?? 0;
|
|
dateTimeSelect.value = config.knx?.dateTime ?? 0;
|
|
nightSelect.value = config.knx?.night ?? 0;
|
|
}
|
|
|
|
function renderCanvas() {
|
|
const canvas = document.getElementById('canvas');
|
|
const screen = getActiveScreen();
|
|
canvas.style.setProperty('--canvas-bg', screen.bgColor);
|
|
canvas.classList.toggle('grid-off', !showGrid);
|
|
canvas.innerHTML = '';
|
|
|
|
screen.widgets.forEach((w) => {
|
|
if (!w.visible) return;
|
|
|
|
const el = document.createElement('div');
|
|
el.className = 'widget' + (selectedWidget === w.id ? ' selected' : '');
|
|
el.dataset.id = w.id;
|
|
el.style.left = (w.x * canvasScale) + 'px';
|
|
el.style.top = (w.y * canvasScale) + 'px';
|
|
el.style.width = (w.w * canvasScale) + 'px';
|
|
el.style.height = (w.h * canvasScale) + 'px';
|
|
|
|
const fontSize = fontSizes[w.fontSize] || 14;
|
|
el.style.fontSize = (fontSize * canvasScale) + 'px';
|
|
el.style.color = w.textColor;
|
|
|
|
if (w.type === WIDGET_TYPES.LABEL) {
|
|
el.className += ' widget-label';
|
|
if (w.bgOpacity > 0) {
|
|
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
|
el.style.background = hexToRgba(w.bgColor, alpha);
|
|
}
|
|
el.textContent = w.text;
|
|
} else if (w.type === WIDGET_TYPES.BUTTON) {
|
|
el.className += ' widget-button';
|
|
el.style.background = w.bgColor;
|
|
el.style.borderRadius = (w.radius * canvasScale) + 'px';
|
|
|
|
if (w.shadow && w.shadow.enabled) {
|
|
const sx = (w.shadow.x || 0) * canvasScale;
|
|
const sy = (w.shadow.y || 0) * canvasScale;
|
|
const blur = (w.shadow.blur || 0) * canvasScale;
|
|
const spread = (w.shadow.spread || 0) * canvasScale;
|
|
el.style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
|
|
}
|
|
|
|
el.textContent = w.text;
|
|
} else if (w.type === WIDGET_TYPES.LED) {
|
|
el.className += ' widget-led';
|
|
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
|
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
|
|
const highlight = clamp(brightness + 0.25, 0, 1);
|
|
const core = clamp(brightness, 0, 1);
|
|
const edge = clamp(brightness * 0.5, 0, 1);
|
|
|
|
el.style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
|
|
|
|
if (w.shadow && w.shadow.enabled) {
|
|
const sx = (w.shadow.x || 0) * canvasScale;
|
|
const sy = (w.shadow.y || 0) * canvasScale;
|
|
const blur = (w.shadow.blur || 0) * canvasScale;
|
|
const spread = (w.shadow.spread || 0) * canvasScale;
|
|
const glowAlpha = clamp(0.4 + brightness * 0.6, 0, 1);
|
|
el.style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
|
|
} else {
|
|
el.style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
|
|
}
|
|
} else if (w.type === WIDGET_TYPES.CHART) {
|
|
el.className += ' widget-chart';
|
|
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1).toFixed(2);
|
|
el.style.background = hexToRgba(w.bgColor, alpha);
|
|
el.style.borderRadius = (w.radius * canvasScale) + 'px';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'chart-title';
|
|
title.style.color = w.textColor;
|
|
title.textContent = w.text || 'Chart';
|
|
|
|
const chartCanvas = document.createElement('div');
|
|
chartCanvas.className = 'chart-canvas';
|
|
|
|
const line = document.createElement('div');
|
|
line.className = 'chart-line';
|
|
chartCanvas.appendChild(line);
|
|
|
|
el.appendChild(title);
|
|
el.appendChild(chartCanvas);
|
|
|
|
if (w.shadow && w.shadow.enabled) {
|
|
const sx = (w.shadow.x || 0) * canvasScale;
|
|
const sy = (w.shadow.y || 0) * canvasScale;
|
|
const blur = (w.shadow.blur || 0) * canvasScale;
|
|
const spread = (w.shadow.spread || 0) * canvasScale;
|
|
el.style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
|
|
}
|
|
}
|
|
|
|
if (selectedWidget === w.id) {
|
|
const handle = document.createElement('div');
|
|
handle.className = 'resize-handle';
|
|
handle.addEventListener('mousedown', startResize);
|
|
handle.addEventListener('touchstart', startResize, { passive: false });
|
|
el.appendChild(handle);
|
|
}
|
|
|
|
el.addEventListener('mousedown', startDrag);
|
|
el.addEventListener('touchstart', startDrag, { passive: false });
|
|
el.addEventListener('click', (e) => { e.stopPropagation(); selectWidget(w.id); });
|
|
|
|
canvas.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function renderTree() {
|
|
const tree = document.getElementById('tree');
|
|
const screen = getActiveScreen();
|
|
tree.innerHTML = '';
|
|
|
|
const root = document.createElement('div');
|
|
root.className = 'tree-root';
|
|
root.textContent = screen.name;
|
|
tree.appendChild(root);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'tree-list';
|
|
|
|
if (!screen.widgets.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'tree-empty';
|
|
empty.textContent = 'Keine Widgets';
|
|
list.appendChild(empty);
|
|
} else {
|
|
screen.widgets.forEach((w) => {
|
|
const key = typeKeyFor(w.type);
|
|
const item = document.createElement('div');
|
|
item.className = 'tree-item' + (selectedWidget === w.id ? ' active' : '') + (!w.visible ? ' hidden' : '');
|
|
item.dataset.id = w.id;
|
|
|
|
const tag = document.createElement('span');
|
|
tag.className = 'tree-tag';
|
|
tag.textContent = TYPE_LABELS[key];
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'tree-name';
|
|
name.textContent = w.text || TYPE_LABELS[key];
|
|
|
|
const id = document.createElement('span');
|
|
id.className = 'tree-id';
|
|
id.textContent = '#' + w.id;
|
|
|
|
item.appendChild(tag);
|
|
item.appendChild(name);
|
|
item.appendChild(id);
|
|
|
|
item.addEventListener('click', () => selectWidget(w.id));
|
|
|
|
list.appendChild(item);
|
|
});
|
|
}
|
|
|
|
tree.appendChild(list);
|
|
document.getElementById('treeCount').textContent = screen.widgets.length;
|
|
}
|
|
|
|
function buildTextSourceOptions(sourceList, selectedValue) {
|
|
const allowed = new Set(sourceList || []);
|
|
return textSourceGroups.map((group) => {
|
|
const values = group.values.filter((value) => allowed.has(value));
|
|
if (!values.length) return '';
|
|
const options = values.map((value) => {
|
|
const label = textSources[value] || 'Unbekannt';
|
|
return `<option value="${value}" ${selectedValue == value ? 'selected' : ''}>${label}</option>`;
|
|
}).join('');
|
|
return `<optgroup label="${group.label}">${options}</optgroup>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderProperties() {
|
|
const panel = document.getElementById('properties');
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === selectedWidget);
|
|
|
|
if (!w) {
|
|
panel.innerHTML = '<div class="no-selection">Kein Widget ausgewaehlt.<br><br>Waehle ein Widget im Canvas oder im Baum.</div>';
|
|
return;
|
|
}
|
|
|
|
const key = typeKeyFor(w.type);
|
|
const sourceList = sourceOptions[key] || [0];
|
|
const sourceOptionsHtml = buildTextSourceOptions(sourceList, w.textSrc);
|
|
|
|
const knxOptions = knxAddresses.map((a) =>
|
|
`<option value="${a.addr}" ${w.knxAddr == a.addr ? 'selected' : ''}>GA ${a.addrStr} (GO${a.index})</option>`
|
|
).join('');
|
|
|
|
const knxWriteOptions = knxAddresses.filter(a => a.write).map((a) =>
|
|
`<option value="${a.addr}" ${w.knxAddrWrite == a.addr ? 'selected' : ''}>GA ${a.addrStr} (GO${a.index})</option>`
|
|
).join('');
|
|
|
|
const screenOptions = config.screens.map((screenItem) =>
|
|
`<option value="${screenItem.id}" ${w.targetScreen == screenItem.id ? 'selected' : ''}>${screenItem.name}</option>`
|
|
).join('');
|
|
|
|
const layoutSection = `
|
|
<h4>Layout</h4>
|
|
<div class="prop-row"><label>X</label><input type="number" id="px" value="${w.x}" onchange="updateProp('x')"></div>
|
|
<div class="prop-row"><label>Y</label><input type="number" id="py" value="${w.y}" onchange="updateProp('y')"></div>
|
|
<div class="prop-row"><label>Breite</label><input type="number" id="pw" value="${w.w}" onchange="updateProp('w')"></div>
|
|
<div class="prop-row"><label>Hoehe</label><input type="number" id="ph" value="${w.h}" onchange="updateProp('h')"></div>
|
|
<div class="prop-row"><label>Sichtbar</label><input type="checkbox" id="pvis" ${w.visible ? 'checked' : ''} onchange="updateProp('visible')"></div>
|
|
`;
|
|
|
|
let contentSection = '';
|
|
if (key === 'label') {
|
|
contentSection = `
|
|
<h4>Inhalt</h4>
|
|
<div class="prop-row"><label>Quelle</label>
|
|
<select id="ptextsrc" onchange="updateProp('textSrc')">
|
|
${sourceOptionsHtml}
|
|
</select>
|
|
</div>
|
|
${w.textSrc === 0 ? `
|
|
<div class="prop-row"><label>Text</label><input type="text" id="ptext" value="${w.text}" onchange="updateProp('text')"></div>
|
|
` : `
|
|
<div class="prop-row"><label>Format</label><input type="text" id="ptext" value="${w.text}" onchange="updateProp('text')"></div>
|
|
<div class="prop-row"><label>KNX Objekt</label>
|
|
<select id="pknx" onchange="updateProp('knxAddr')">
|
|
<option value="0" ${w.knxAddr == 0 ? 'selected' : ''}>-- Waehlen --</option>
|
|
${knxOptions}
|
|
</select>
|
|
</div>
|
|
`}
|
|
`;
|
|
} else if (key === 'button') {
|
|
contentSection = `
|
|
<h4>Text</h4>
|
|
<div class="prop-row"><label>Text</label><input type="text" id="ptext" value="${w.text}" onchange="updateProp('text')"></div>
|
|
`;
|
|
} else if (key === 'led') {
|
|
contentSection = `
|
|
<h4>LED</h4>
|
|
<div class="prop-row"><label>Modus</label>
|
|
<select id="ptextsrc" onchange="updateProp('textSrc')">
|
|
${sourceOptionsHtml}
|
|
</select>
|
|
</div>
|
|
${w.textSrc === 2 ? `
|
|
<div class="prop-row"><label>KNX Objekt</label>
|
|
<select id="pknx" onchange="updateProp('knxAddr')">
|
|
<option value="0" ${w.knxAddr == 0 ? 'selected' : ''}>-- Waehlen --</option>
|
|
${knxOptions}
|
|
</select>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
} else if (key === 'chart') {
|
|
const chartConfig = w.chart || { period: 0, series: [] };
|
|
const chartSeries = (Array.isArray(chartConfig.series) && chartConfig.series.length)
|
|
? chartConfig.series
|
|
: [{ knxAddr: 0, textSrc: 1, color: '#EF6351' }];
|
|
const seriesCount = Math.max(1, Math.min(chartSeries.length, 3));
|
|
const period = chartConfig.period ?? 0;
|
|
const periodOptions = [
|
|
{ value: 0, label: '1h' },
|
|
{ value: 1, label: '3h' },
|
|
{ value: 2, label: '5h' },
|
|
{ value: 3, label: '12h' },
|
|
{ value: 4, label: '24h' },
|
|
{ value: 5, label: '1 Monat' }
|
|
].map((opt) => `<option value="${opt.value}" ${period == opt.value ? 'selected' : ''}>${opt.label}</option>`).join('');
|
|
|
|
const seriesHtml = chartSeries.slice(0, seriesCount).map((series, idx) => {
|
|
const srcOptions = buildTextSourceOptions(sourceOptions.chart, series.textSrc ?? 1);
|
|
const knxSeriesOptions = knxAddresses.map((a) =>
|
|
`<option value="${a.addr}" ${series.knxAddr == a.addr ? 'selected' : ''}>GA ${a.addrStr} (GO${a.index})</option>`
|
|
).join('');
|
|
return `
|
|
<div class="chart-series">
|
|
<div class="prop-row"><label>Serie ${idx + 1}</label>
|
|
<input type="color" id="pchartcolor${idx}" value="${series.color || '#EF6351'}" onchange="updateChartSeries(${idx}, 'color')">
|
|
</div>
|
|
<div class="prop-row"><label>Quelle</label>
|
|
<select id="pchartsrc${idx}" onchange="updateChartSeries(${idx}, 'textSrc')">
|
|
${srcOptions}
|
|
</select>
|
|
</div>
|
|
<div class="prop-row"><label>KNX Objekt</label>
|
|
<select id="pchartaddr${idx}" onchange="updateChartSeries(${idx}, 'knxAddr')">
|
|
<option value="0" ${series.knxAddr == 0 ? 'selected' : ''}>-- Waehlen --</option>
|
|
${knxSeriesOptions}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
contentSection = `
|
|
<h4>Chart</h4>
|
|
<div class="prop-row"><label>Titel</label><input type="text" id="ptext" value="${w.text}" onchange="updateProp('text')"></div>
|
|
<div class="prop-row"><label>Zeitraum</label>
|
|
<select id="pchartperiod" onchange="updateChartPeriod()">
|
|
${periodOptions}
|
|
</select>
|
|
</div>
|
|
<div class="prop-row"><label>Serien</label>
|
|
<select id="pchartcount" onchange="updateChartCount()">
|
|
<option value="1" ${seriesCount === 1 ? 'selected' : ''}>1</option>
|
|
<option value="2" ${seriesCount === 2 ? 'selected' : ''}>2</option>
|
|
<option value="3" ${seriesCount === 3 ? 'selected' : ''}>3</option>
|
|
</select>
|
|
</div>
|
|
${seriesHtml}
|
|
`;
|
|
}
|
|
|
|
let typographySection = '';
|
|
if (key === 'label' || key === 'button') {
|
|
typographySection = `
|
|
<h4>Typo</h4>
|
|
<div class="prop-row"><label>Schriftgr.</label>
|
|
<select id="pfont" onchange="updateProp('fontSize')">
|
|
${fontSizes.map((f, idx) => `<option value="${idx}" ${w.fontSize == idx ? 'selected' : ''}>${f}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let styleSection = '';
|
|
if (key === 'led') {
|
|
styleSection = `
|
|
<h4>Stil</h4>
|
|
<div class="prop-row"><label>Farbe</label><input type="color" id="pbc" value="${w.bgColor}" onchange="updateProp('bgColor')"></div>
|
|
<div class="prop-row"><label>Helligkeit</label><input type="number" id="pbo" min="0" max="255" value="${w.bgOpacity}" onchange="updateProp('bgOpacity')"></div>
|
|
`;
|
|
} else {
|
|
styleSection = `
|
|
<h4>Stil</h4>
|
|
<div class="prop-row"><label>Textfarbe</label><input type="color" id="ptc" value="${w.textColor}" onchange="updateProp('textColor')"></div>
|
|
<div class="prop-row"><label>Hintergrund</label><input type="color" id="pbc" value="${w.bgColor}" onchange="updateProp('bgColor')"></div>
|
|
<div class="prop-row"><label>Deckkraft</label><input type="number" id="pbo" min="0" max="255" value="${w.bgOpacity}" onchange="updateProp('bgOpacity')"></div>
|
|
<div class="prop-row"><label>Radius</label><input type="number" id="prad" value="${w.radius}" onchange="updateProp('radius')"></div>
|
|
`;
|
|
}
|
|
|
|
let shadowSection = '';
|
|
if (key === 'led') {
|
|
shadowSection = `
|
|
<h4>Glow</h4>
|
|
<div class="prop-row"><label>Aktiv</label><input type="checkbox" id="pse" ${w.shadow?.enabled ? 'checked' : ''} onchange="updateShadow('enabled')"></div>
|
|
<div class="prop-row"><label>Blur</label><input type="number" id="psb" value="${w.shadow?.blur || 0}" onchange="updateShadow('blur')"></div>
|
|
<div class="prop-row"><label>Farbe</label><input type="color" id="psc" value="${w.shadow?.color || '#000000'}" onchange="updateShadow('color')"></div>
|
|
`;
|
|
} else {
|
|
shadowSection = `
|
|
<h4>Schatten</h4>
|
|
<div class="prop-row"><label>Aktiv</label><input type="checkbox" id="pse" ${w.shadow?.enabled ? 'checked' : ''} onchange="updateShadow('enabled')"></div>
|
|
<div class="prop-row"><label>X</label><input type="number" id="psx" value="${w.shadow?.x || 0}" onchange="updateShadow('x')"></div>
|
|
<div class="prop-row"><label>Y</label><input type="number" id="psy" value="${w.shadow?.y || 0}" onchange="updateShadow('y')"></div>
|
|
<div class="prop-row"><label>Blur</label><input type="number" id="psb" value="${w.shadow?.blur || 0}" onchange="updateShadow('blur')"></div>
|
|
<div class="prop-row"><label>Farbe</label><input type="color" id="psc" value="${w.shadow?.color || '#000000'}" onchange="updateShadow('color')"></div>
|
|
`;
|
|
}
|
|
|
|
let buttonSection = '';
|
|
if (key === 'button') {
|
|
buttonSection = `
|
|
<h4>Aktion</h4>
|
|
<div class="prop-row"><label>Typ</label>
|
|
<select id="paction" onchange="updateProp('action')">
|
|
<option value="${BUTTON_ACTIONS.KNX}" ${w.action == BUTTON_ACTIONS.KNX ? 'selected' : ''}>KNX</option>
|
|
<option value="${BUTTON_ACTIONS.JUMP}" ${w.action == BUTTON_ACTIONS.JUMP ? 'selected' : ''}>Sprung</option>
|
|
<option value="${BUTTON_ACTIONS.BACK}" ${w.action == BUTTON_ACTIONS.BACK ? 'selected' : ''}>Zurueck</option>
|
|
</select>
|
|
</div>
|
|
${w.action == BUTTON_ACTIONS.JUMP ? `
|
|
<div class="prop-row"><label>Ziel</label>
|
|
<select id="ptarget" onchange="updateProp('targetScreen')">
|
|
${screenOptions}
|
|
</select>
|
|
</div>
|
|
` : ''}
|
|
${w.action == BUTTON_ACTIONS.KNX ? `
|
|
<div class="prop-row"><label>Toggle</label><input type="checkbox" id="ptog" ${w.isToggle ? 'checked' : ''} onchange="updateProp('isToggle')"></div>
|
|
<div class="prop-row"><label>KNX Schreib</label>
|
|
<select id="pknxw" onchange="updateProp('knxAddrWrite')">
|
|
<option value="0" ${w.knxAddrWrite == 0 ? 'selected' : ''}>-- Keine --</option>
|
|
${knxWriteOptions}
|
|
</select>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
}
|
|
|
|
panel.innerHTML = `
|
|
${layoutSection}
|
|
${contentSection}
|
|
${typographySection}
|
|
${styleSection}
|
|
${shadowSection}
|
|
${buttonSection}
|
|
<div class="prop-actions">
|
|
<button class="btn ghost danger" onclick="deleteWidget()">Widget loeschen</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function selectWidget(id) {
|
|
selectedWidget = id;
|
|
renderCanvas();
|
|
renderTree();
|
|
renderProperties();
|
|
}
|
|
|
|
function selectScreen(id) {
|
|
activeScreenId = id;
|
|
selectedWidget = null;
|
|
renderAll();
|
|
}
|
|
|
|
function updateProp(prop) {
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === selectedWidget);
|
|
if (!w) return;
|
|
|
|
if (prop === 'x') w.x = parseInt(document.getElementById('px').value, 10);
|
|
else if (prop === 'y') w.y = parseInt(document.getElementById('py').value, 10);
|
|
else if (prop === 'w') w.w = parseInt(document.getElementById('pw').value, 10);
|
|
else if (prop === 'h') w.h = parseInt(document.getElementById('ph').value, 10);
|
|
else if (prop === 'visible') w.visible = document.getElementById('pvis').checked;
|
|
else if (prop === 'textSrc') {
|
|
const newSrc = parseInt(document.getElementById('ptextsrc').value, 10);
|
|
w.textSrc = newSrc;
|
|
if (w.type === WIDGET_TYPES.LABEL && newSrc > 0 && defaultFormats[newSrc]) {
|
|
w.text = defaultFormats[newSrc];
|
|
const textEl = document.getElementById('ptext');
|
|
if (textEl) textEl.value = w.text;
|
|
}
|
|
renderProperties();
|
|
}
|
|
else if (prop === 'text') w.text = document.getElementById('ptext').value;
|
|
else if (prop === 'knxAddr') w.knxAddr = parseInt(document.getElementById('pknx').value, 10);
|
|
else if (prop === 'fontSize') w.fontSize = parseInt(document.getElementById('pfont').value, 10);
|
|
else if (prop === 'textColor') w.textColor = document.getElementById('ptc').value;
|
|
else if (prop === 'bgColor') w.bgColor = document.getElementById('pbc').value;
|
|
else if (prop === 'bgOpacity') {
|
|
const value = parseInt(document.getElementById('pbo').value, 10);
|
|
w.bgOpacity = clamp(value, 0, 255);
|
|
}
|
|
else if (prop === 'radius') w.radius = parseInt(document.getElementById('prad').value, 10);
|
|
else if (prop === 'isToggle') w.isToggle = document.getElementById('ptog').checked;
|
|
else if (prop === 'knxAddrWrite') w.knxAddrWrite = parseInt(document.getElementById('pknxw').value, 10);
|
|
else if (prop === 'action') {
|
|
w.action = parseInt(document.getElementById('paction').value, 10);
|
|
renderProperties();
|
|
}
|
|
else if (prop === 'targetScreen') w.targetScreen = parseInt(document.getElementById('ptarget').value, 10);
|
|
|
|
renderCanvas();
|
|
renderTree();
|
|
}
|
|
|
|
function updateShadow(prop) {
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === selectedWidget);
|
|
if (!w) return;
|
|
if (!w.shadow) w.shadow = { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' };
|
|
|
|
if (prop === 'enabled') w.shadow.enabled = document.getElementById('pse').checked;
|
|
else if (prop === 'x') w.shadow.x = parseInt(document.getElementById('psx').value, 10);
|
|
else if (prop === 'y') w.shadow.y = parseInt(document.getElementById('psy').value, 10);
|
|
else if (prop === 'blur') w.shadow.blur = parseInt(document.getElementById('psb').value, 10);
|
|
else if (prop === 'color') w.shadow.color = document.getElementById('psc').value;
|
|
|
|
renderCanvas();
|
|
renderTree();
|
|
}
|
|
|
|
function updateChartPeriod() {
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === selectedWidget);
|
|
if (!w || !w.chart) return;
|
|
w.chart.period = parseInt(document.getElementById('pchartperiod').value, 10);
|
|
}
|
|
|
|
function updateChartCount() {
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === selectedWidget);
|
|
if (!w || !w.chart) return;
|
|
const desired = parseInt(document.getElementById('pchartcount').value, 10);
|
|
const colors = ['#EF6351', '#7DD3B0', '#5EA2EF'];
|
|
if (!Array.isArray(w.chart.series)) w.chart.series = [];
|
|
while (w.chart.series.length < desired && w.chart.series.length < 3) {
|
|
const idx = w.chart.series.length;
|
|
w.chart.series.push({ knxAddr: 0, textSrc: 1, color: colors[idx] || '#EF6351' });
|
|
}
|
|
if (w.chart.series.length > desired) {
|
|
w.chart.series = w.chart.series.slice(0, desired);
|
|
}
|
|
renderProperties();
|
|
renderCanvas();
|
|
}
|
|
|
|
function updateChartSeries(idx, prop) {
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === selectedWidget);
|
|
if (!w || !w.chart || !Array.isArray(w.chart.series)) return;
|
|
const series = w.chart.series[idx];
|
|
if (!series) return;
|
|
|
|
if (prop === 'color') {
|
|
series.color = document.getElementById(`pchartcolor${idx}`).value;
|
|
} else if (prop === 'textSrc') {
|
|
series.textSrc = parseInt(document.getElementById(`pchartsrc${idx}`).value, 10);
|
|
} else if (prop === 'knxAddr') {
|
|
series.knxAddr = parseInt(document.getElementById(`pchartaddr${idx}`).value, 10);
|
|
}
|
|
|
|
renderCanvas();
|
|
renderTree();
|
|
}
|
|
|
|
function updateBgColor() {
|
|
const screen = getActiveScreen();
|
|
screen.bgColor = document.getElementById('bgColor').value;
|
|
renderCanvas();
|
|
}
|
|
|
|
function addWidget(type) {
|
|
const screen = getActiveScreen();
|
|
const defaults = WIDGET_DEFAULTS[type];
|
|
let typeValue = WIDGET_TYPES.LABEL;
|
|
if (type === 'button') typeValue = WIDGET_TYPES.BUTTON;
|
|
else if (type === 'led') typeValue = WIDGET_TYPES.LED;
|
|
else if (type === 'chart') typeValue = WIDGET_TYPES.CHART;
|
|
|
|
const w = {
|
|
id: nextWidgetId++,
|
|
type: typeValue,
|
|
x: 120,
|
|
y: 120,
|
|
w: defaults.w,
|
|
h: defaults.h,
|
|
visible: true,
|
|
textSrc: defaults.textSrc,
|
|
text: defaults.text,
|
|
knxAddr: defaults.knxAddr,
|
|
fontSize: defaults.fontSize,
|
|
textColor: defaults.textColor,
|
|
bgColor: defaults.bgColor,
|
|
bgOpacity: defaults.bgOpacity,
|
|
radius: defaults.radius,
|
|
shadow: { ...defaults.shadow },
|
|
isToggle: defaults.isToggle,
|
|
knxAddrWrite: defaults.knxAddrWrite,
|
|
action: defaults.action,
|
|
targetScreen: defaults.targetScreen
|
|
};
|
|
|
|
if (defaults.chart) {
|
|
w.chart = {
|
|
period: defaults.chart.period ?? 0,
|
|
series: (defaults.chart.series || []).map(s => ({ ...s }))
|
|
};
|
|
}
|
|
|
|
screen.widgets.push(w);
|
|
selectWidget(w.id);
|
|
}
|
|
|
|
function deleteWidget() {
|
|
const screen = getActiveScreen();
|
|
screen.widgets = screen.widgets.filter(w => w.id !== selectedWidget);
|
|
selectedWidget = null;
|
|
renderCanvas();
|
|
renderTree();
|
|
renderProperties();
|
|
}
|
|
|
|
function addScreen() {
|
|
if (config.screens.length >= MAX_SCREENS) {
|
|
showStatus('Max. Screens erreicht', true);
|
|
return;
|
|
}
|
|
|
|
const id = nextScreenId++;
|
|
const newScreen = {
|
|
id: id,
|
|
name: `Screen ${id}`,
|
|
mode: 0,
|
|
bgColor: '#1A1A2E',
|
|
widgets: []
|
|
};
|
|
config.screens.push(newScreen);
|
|
activeScreenId = newScreen.id;
|
|
selectedWidget = null;
|
|
renderAll();
|
|
}
|
|
|
|
function deleteScreen() {
|
|
if (config.screens.length <= 1) {
|
|
showStatus('Mindestens ein Screen', true);
|
|
return;
|
|
}
|
|
|
|
const screen = getActiveScreen();
|
|
const screenId = screen.id;
|
|
config.screens = config.screens.filter((s) => s.id !== screenId);
|
|
|
|
if (config.startScreen === screenId) {
|
|
config.startScreen = config.screens[0].id;
|
|
}
|
|
|
|
if (config.standby.screen === screenId) {
|
|
config.standby.screen = -1;
|
|
config.standby.enabled = false;
|
|
}
|
|
|
|
activeScreenId = config.screens[0].id;
|
|
selectedWidget = null;
|
|
renderAll();
|
|
}
|
|
|
|
function startDrag(e) {
|
|
e.preventDefault();
|
|
if (e.target.closest('.resize-handle')) return;
|
|
const el = e.target.closest('.widget');
|
|
if (!el) return;
|
|
|
|
const id = parseInt(el.dataset.id, 10);
|
|
selectWidget(id);
|
|
|
|
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
|
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
|
|
|
const screen = getActiveScreen();
|
|
const widget = screen.widgets.find(w => w.id === id);
|
|
dragState = {
|
|
id: id,
|
|
startX: cx,
|
|
startY: cy,
|
|
origX: widget.x,
|
|
origY: widget.y
|
|
};
|
|
resizeState = null;
|
|
|
|
document.addEventListener('mousemove', drag);
|
|
document.addEventListener('mouseup', endDrag);
|
|
document.addEventListener('touchmove', drag, { passive: false });
|
|
document.addEventListener('touchend', endDrag);
|
|
}
|
|
|
|
function drag(e) {
|
|
if (!dragState) return;
|
|
e.preventDefault();
|
|
|
|
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
|
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
|
|
|
const dx = (cx - dragState.startX) / canvasScale;
|
|
const dy = (cy - dragState.startY) / canvasScale;
|
|
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === dragState.id);
|
|
if (w) {
|
|
w.x = Math.max(0, Math.min(DISPLAY_W - w.w, Math.round(dragState.origX + dx)));
|
|
w.y = Math.max(0, Math.min(DISPLAY_H - w.h, Math.round(dragState.origY + dy)));
|
|
renderCanvas();
|
|
renderTree();
|
|
const px = document.getElementById('px');
|
|
const py = document.getElementById('py');
|
|
if (px) px.value = w.x;
|
|
if (py) py.value = w.y;
|
|
}
|
|
}
|
|
|
|
function endDrag() {
|
|
dragState = null;
|
|
document.removeEventListener('mousemove', drag);
|
|
document.removeEventListener('mouseup', endDrag);
|
|
document.removeEventListener('touchmove', drag);
|
|
document.removeEventListener('touchend', endDrag);
|
|
}
|
|
|
|
function startResize(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const el = e.target.closest('.widget');
|
|
if (!el) return;
|
|
|
|
const id = parseInt(el.dataset.id, 10);
|
|
selectWidget(id);
|
|
|
|
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
|
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
|
|
|
const screen = getActiveScreen();
|
|
const widget = screen.widgets.find(w => w.id === id);
|
|
resizeState = {
|
|
id: id,
|
|
startX: cx,
|
|
startY: cy,
|
|
origW: widget.w,
|
|
origH: widget.h
|
|
};
|
|
dragState = null;
|
|
|
|
document.addEventListener('mousemove', resizeDrag);
|
|
document.addEventListener('mouseup', endResize);
|
|
document.addEventListener('touchmove', resizeDrag, { passive: false });
|
|
document.addEventListener('touchend', endResize);
|
|
}
|
|
|
|
function resizeDrag(e) {
|
|
if (!resizeState) return;
|
|
e.preventDefault();
|
|
|
|
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
|
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
|
|
|
const dx = (cx - resizeState.startX) / canvasScale;
|
|
const dy = (cy - resizeState.startY) / canvasScale;
|
|
|
|
const screen = getActiveScreen();
|
|
const w = screen.widgets.find(x => x.id === resizeState.id);
|
|
if (!w) return;
|
|
|
|
const minSize = minSizeFor(w);
|
|
const maxW = DISPLAY_W - w.x;
|
|
const maxH = DISPLAY_H - w.y;
|
|
|
|
let newW = Math.round(resizeState.origW + dx);
|
|
let newH = Math.round(resizeState.origH + dy);
|
|
|
|
if (w.type === WIDGET_TYPES.LED) {
|
|
const maxSize = Math.min(maxW, maxH);
|
|
const size = clamp(Math.max(newW, newH), minSize.w, maxSize);
|
|
newW = size;
|
|
newH = size;
|
|
} else {
|
|
newW = clamp(newW, minSize.w, maxW);
|
|
newH = clamp(newH, minSize.h, maxH);
|
|
}
|
|
|
|
w.w = newW;
|
|
w.h = newH;
|
|
|
|
renderCanvas();
|
|
renderTree();
|
|
|
|
const pw = document.getElementById('pw');
|
|
const ph = document.getElementById('ph');
|
|
if (pw) pw.value = w.w;
|
|
if (ph) ph.value = w.h;
|
|
}
|
|
|
|
function endResize() {
|
|
resizeState = null;
|
|
document.removeEventListener('mousemove', resizeDrag);
|
|
document.removeEventListener('mouseup', endResize);
|
|
document.removeEventListener('touchmove', resizeDrag);
|
|
document.removeEventListener('touchend', endResize);
|
|
}
|
|
|
|
async function saveConfig() {
|
|
try {
|
|
await fetch('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(config)
|
|
});
|
|
await fetch('/api/save', { method: 'POST' });
|
|
showStatus('Gespeichert!');
|
|
} catch (e) {
|
|
showStatus('Fehler beim Speichern', true);
|
|
}
|
|
}
|
|
|
|
async function resetConfig() {
|
|
if (!confirm('Wirklich auf Standardeinstellungen zuruecksetzen?')) return;
|
|
try {
|
|
await fetch('/api/reset', { method: 'POST' });
|
|
await loadConfig();
|
|
showStatus('Zurueckgesetzt!');
|
|
} catch (e) {
|
|
showStatus('Fehler', true);
|
|
}
|
|
}
|
|
|
|
async function resetKnxSettings() {
|
|
if (!confirm('KNX Einstellungen wirklich loeschen?\n\nDas Geraet startet neu und muss in ETS neu programmiert werden.')) return;
|
|
try {
|
|
const resp = await fetch('/api/knx/reset', { method: 'POST' });
|
|
if (!resp.ok) {
|
|
showStatus('KNX Reset fehlgeschlagen', true);
|
|
return;
|
|
}
|
|
showStatus('KNX Reset...');
|
|
alert('KNX Einstellungen geloescht. Geraet startet neu.');
|
|
} catch (e) {
|
|
showStatus('KNX Reset fehlgeschlagen', true);
|
|
}
|
|
}
|
|
|
|
async function toggleKnxProg() {
|
|
if (knxProgBusy) return;
|
|
knxProgBusy = true;
|
|
const next = !knxProgMode;
|
|
updateKnxProgButton();
|
|
try {
|
|
const resp = await fetch('/api/knx/prog', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: next })
|
|
});
|
|
if (!resp.ok) {
|
|
showStatus('KNX Prog fehlgeschlagen', true);
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
const updated =
|
|
(typeof data.progMode === 'boolean')
|
|
? data.progMode
|
|
: (typeof data.enabled === 'boolean' ? data.enabled : null);
|
|
if (updated === null) {
|
|
showStatus('KNX Prog fehlgeschlagen', true);
|
|
return;
|
|
}
|
|
knxProgMode = updated;
|
|
showStatus(knxProgMode ? 'KNX Prog AN' : 'KNX Prog AUS');
|
|
} catch (e) {
|
|
showStatus('KNX Prog fehlgeschlagen', true);
|
|
} finally {
|
|
knxProgBusy = false;
|
|
updateKnxProgButton();
|
|
}
|
|
}
|
|
|
|
async function enableUsbMode() {
|
|
if (!confirm('USB-Modus aktivieren?\n\nDie SD-Karte wird als USB-Laufwerk verfuegbar.\nZum Beenden: Geraet neu starten.')) return;
|
|
try {
|
|
const resp = await fetch('/api/usb-mode', { method: 'POST' });
|
|
const data = await resp.json();
|
|
if (data.status === 'ok') {
|
|
showStatus('USB-Modus aktiv!');
|
|
alert('USB Mass Storage aktiviert!\n\nVerbinde das USB-Kabel mit dem PC.\nDie SD-Karte erscheint als Wechseldatentraeger.\n\nZum Beenden: Geraet neu starten.');
|
|
} else {
|
|
showStatus('USB-Modus fehlgeschlagen', true);
|
|
}
|
|
} catch (e) {
|
|
showStatus('Fehler beim Aktivieren', true);
|
|
}
|
|
}
|
|
|
|
function showStatus(msg, isError = false) {
|
|
const el = document.getElementById('status');
|
|
el.textContent = msg;
|
|
el.className = 'status show' + (isError ? ' error' : '');
|
|
setTimeout(() => el.className = 'status', 2000);
|
|
}
|
|
|
|
document.getElementById('canvas').addEventListener('click', (e) => {
|
|
if (e.target.id === 'canvas') {
|
|
selectedWidget = null;
|
|
renderCanvas();
|
|
renderTree();
|
|
renderProperties();
|
|
}
|
|
});
|
|
|
|
document.getElementById('zoom').addEventListener('input', (e) => {
|
|
canvasScale = parseFloat(e.target.value);
|
|
applyCanvasMetrics();
|
|
renderCanvas();
|
|
});
|
|
|
|
document.getElementById('gridToggle').addEventListener('change', (e) => {
|
|
showGrid = e.target.checked;
|
|
renderCanvas();
|
|
});
|
|
|
|
document.getElementById('bgColor').addEventListener('input', updateBgColor);
|
|
|
|
document.getElementById('screenName').addEventListener('input', (e) => {
|
|
const screen = getActiveScreen();
|
|
screen.name = e.target.value;
|
|
renderScreenList();
|
|
renderTree();
|
|
renderNavSettings();
|
|
});
|
|
|
|
document.getElementById('screenMode').addEventListener('change', (e) => {
|
|
const screen = getActiveScreen();
|
|
screen.mode = parseInt(e.target.value, 10);
|
|
renderScreenList();
|
|
});
|
|
|
|
document.getElementById('startScreen').addEventListener('change', (e) => {
|
|
config.startScreen = parseInt(e.target.value, 10);
|
|
});
|
|
|
|
document.getElementById('standbyEnabled').addEventListener('change', (e) => {
|
|
config.standby.enabled = e.target.checked;
|
|
});
|
|
|
|
document.getElementById('standbyScreen').addEventListener('change', (e) => {
|
|
config.standby.screen = parseInt(e.target.value, 10);
|
|
});
|
|
|
|
document.getElementById('standbyMinutes').addEventListener('input', (e) => {
|
|
const val = parseInt(e.target.value, 10);
|
|
config.standby.minutes = Number.isNaN(val) ? 0 : val;
|
|
});
|
|
|
|
document.getElementById('knxTimeAddr').addEventListener('change', (e) => {
|
|
config.knx.time = parseInt(e.target.value, 10) || 0;
|
|
});
|
|
|
|
document.getElementById('knxDateAddr').addEventListener('change', (e) => {
|
|
config.knx.date = parseInt(e.target.value, 10) || 0;
|
|
});
|
|
|
|
document.getElementById('knxDateTimeAddr').addEventListener('change', (e) => {
|
|
config.knx.dateTime = parseInt(e.target.value, 10) || 0;
|
|
});
|
|
|
|
document.getElementById('knxNightAddr').addEventListener('change', (e) => {
|
|
config.knx.night = parseInt(e.target.value, 10) || 0;
|
|
});
|
|
|
|
requestAnimationFrame(() => document.body.classList.add('loaded'));
|
|
loadKnxProgMode();
|
|
loadConfig();
|
|
</script>
|
|
</body>
|
|
</html>
|