knxdisplay/sdcard_content/webseite/index.html
2026-01-29 19:33:12 +01:00

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>