This commit is contained in:
Thomas Peterson 2026-01-27 15:38:38 +01:00
parent cc6b30ebfe
commit fbf7fa12a2
19 changed files with 1018 additions and 14 deletions

View File

@ -7,6 +7,9 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
"widgets/IconWidget.cpp"
"widgets/TabViewWidget.cpp"
"widgets/TabPageWidget.cpp"
"widgets/PowerFlowWidget.cpp"
"widgets/PowerNodeWidget.cpp"
"widgets/PowerLinkWidget.cpp"
"webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp"

View File

@ -17,6 +17,9 @@ enum class WidgetType : uint8_t {
ICON = 3,
TABVIEW = 4,
TABPAGE = 5,
POWERFLOW = 6,
POWERNODE = 7,
POWERLINK = 8,
};
enum class IconPosition : uint8_t {

View File

@ -297,6 +297,10 @@ const ScreenConfig* WidgetManager::activeScreen() const {
return config_.findScreen(activeScreenId_);
}
const ScreenConfig* WidgetManager::currentScreen() const {
return activeScreen();
}
void WidgetManager::applyScreen(uint8_t screenId) {
ESP_LOGI(TAG, "applyScreen(%d) - start", screenId);

View File

@ -49,6 +49,7 @@ public:
// Direct config access
GuiConfig& getConfig() { return config_; }
const GuiConfig& getConfig() const { return config_; }
const ScreenConfig* currentScreen() const;
private:
WidgetManager();

View File

@ -0,0 +1,60 @@
#include "PowerFlowWidget.hpp"
#include "../Fonts.hpp"
PowerFlowWidget::PowerFlowWidget(const WidgetConfig& config)
: Widget(config)
, titleLabel_(nullptr)
{
}
lv_obj_t* PowerFlowWidget::create(lv_obj_t* parent) {
obj_ = lv_obj_create(parent);
if (obj_ == nullptr) {
return nullptr;
}
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_,
config_.width > 0 ? config_.width : 240,
config_.height > 0 ? config_.height : 180);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
if (config_.text[0] != '\0') {
titleLabel_ = lv_label_create(obj_);
lv_label_set_text(titleLabel_, config_.text);
lv_obj_set_pos(titleLabel_, 10, 8);
lv_obj_clear_flag(titleLabel_, LV_OBJ_FLAG_CLICKABLE);
}
return obj_;
}
void PowerFlowWidget::applyStyle() {
if (obj_ == nullptr) return;
if (config_.bgOpacity > 0) {
lv_obj_set_style_bg_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
} else {
lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0);
}
lv_obj_set_style_border_width(obj_, 1, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_border_opa(obj_, LV_OPA_20, 0);
if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
}
applyShadowStyle();
if (titleLabel_ != nullptr) {
lv_obj_set_style_text_color(titleLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_text_font(titleLabel_, Fonts::bySizeIndex(config_.fontSize), 0);
}
}

View File

@ -0,0 +1,13 @@
#pragma once
#include "Widget.hpp"
class PowerFlowWidget : public Widget {
public:
explicit PowerFlowWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
private:
lv_obj_t* titleLabel_ = nullptr;
};

View File

@ -0,0 +1,67 @@
#include "PowerLinkWidget.hpp"
#include "../WidgetManager.hpp"
PowerLinkWidget::PowerLinkWidget(const WidgetConfig& config)
: Widget(config)
{
points_[0].x = 0;
points_[0].y = 0;
points_[1].x = 1;
points_[1].y = 1;
}
void PowerLinkWidget::buildLinePoints() {
if (obj_ == nullptr) return;
if (config_.x < 0 || config_.y < 0) return;
const ScreenConfig* screen = WidgetManager::instance().currentScreen();
if (screen == nullptr) return;
const WidgetConfig* fromNode = screen->findWidget(static_cast<uint8_t>(config_.x));
const WidgetConfig* toNode = screen->findWidget(static_cast<uint8_t>(config_.y));
if (fromNode == nullptr || toNode == nullptr) return;
if (fromNode->parentId != config_.parentId || toNode->parentId != config_.parentId) return;
int16_t x1 = fromNode->x + fromNode->width / 2;
int16_t y1 = fromNode->y + fromNode->height / 2;
int16_t x2 = toNode->x + toNode->width / 2;
int16_t y2 = toNode->y + toNode->height / 2;
int16_t minX = x1 < x2 ? x1 : x2;
int16_t minY = y1 < y2 ? y1 : y2;
int16_t maxX = x1 > x2 ? x1 : x2;
int16_t maxY = y1 > y2 ? y1 : y2;
points_[0].x = x1 - minX;
points_[0].y = y1 - minY;
points_[1].x = x2 - minX;
points_[1].y = y2 - minY;
lv_obj_set_pos(obj_, minX, minY);
lv_obj_set_size(obj_, (maxX - minX) + 1, (maxY - minY) + 1);
lv_line_set_points(obj_, points_, 2);
}
lv_obj_t* PowerLinkWidget::create(lv_obj_t* parent) {
obj_ = lv_line_create(parent);
if (obj_ == nullptr) {
return nullptr;
}
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
buildLinePoints();
return obj_;
}
void PowerLinkWidget::applyStyle() {
if (obj_ == nullptr) return;
lv_obj_set_style_line_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_line_width(obj_, config_.width > 0 ? config_.width : 2, 0);
lv_obj_set_style_line_opa(obj_, config_.bgOpacity, 0);
int32_t dash = config_.iconGap > 0 ? config_.iconGap : 6;
lv_obj_set_style_line_dash_width(obj_, dash, 0);
lv_obj_set_style_line_dash_gap(obj_, dash + 4, 0);
}

View File

@ -0,0 +1,14 @@
#pragma once
#include "Widget.hpp"
class PowerLinkWidget : public Widget {
public:
explicit PowerLinkWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
private:
lv_point_precise_t points_[2] = {};
void buildLinePoints();
};

View File

@ -0,0 +1,186 @@
#include "PowerNodeWidget.hpp"
#include "../Fonts.hpp"
#include <cstring>
#include <cstdio>
PowerNodeWidget::PowerNodeWidget(const WidgetConfig& config)
: Widget(config)
{
labelText_[0] = '\0';
valueFormat_[0] = '\0';
}
int PowerNodeWidget::encodeUtf8(uint32_t codepoint, char* buf) {
if (codepoint < 0x80) {
buf[0] = static_cast<char>(codepoint);
buf[1] = '\0';
return 1;
} else if (codepoint < 0x800) {
buf[0] = static_cast<char>(0xC0 | (codepoint >> 6));
buf[1] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[2] = '\0';
return 2;
} else if (codepoint < 0x10000) {
buf[0] = static_cast<char>(0xE0 | (codepoint >> 12));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[2] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[3] = '\0';
return 3;
} else if (codepoint < 0x110000) {
buf[0] = static_cast<char>(0xF0 | (codepoint >> 18));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F));
buf[2] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[3] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[4] = '\0';
return 4;
}
buf[0] = '\0';
return 0;
}
void PowerNodeWidget::parseText() {
labelText_[0] = '\0';
valueFormat_[0] = '\0';
const char* text = config_.text;
if (!text || text[0] == '\0') return;
const char* newline = strchr(text, '\n');
if (newline) {
size_t labelLen = static_cast<size_t>(newline - text);
if (labelLen >= MAX_TEXT_LEN) labelLen = MAX_TEXT_LEN - 1;
memcpy(labelText_, text, labelLen);
labelText_[labelLen] = '\0';
strncpy(valueFormat_, newline + 1, MAX_TEXT_LEN - 1);
valueFormat_[MAX_TEXT_LEN - 1] = '\0';
} else {
strncpy(labelText_, text, MAX_TEXT_LEN - 1);
labelText_[MAX_TEXT_LEN - 1] = '\0';
}
}
lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) {
parseText();
obj_ = lv_obj_create(parent);
if (obj_ == nullptr) {
return nullptr;
}
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_,
config_.width > 0 ? config_.width : 120,
config_.height > 0 ? config_.height : 120);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(obj_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(obj_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(obj_, 6, 0);
lv_obj_set_style_pad_gap(obj_, 2, 0);
if (labelText_[0] != '\0') {
labelLabel_ = lv_label_create(obj_);
lv_label_set_text(labelLabel_, labelText_);
lv_obj_clear_flag(labelLabel_, LV_OBJ_FLAG_CLICKABLE);
}
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
iconLabel_ = lv_label_create(obj_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
}
valueLabel_ = lv_label_create(obj_);
if (valueFormat_[0] != '\0') {
lv_label_set_text(valueLabel_, valueFormat_);
} else {
lv_label_set_text(valueLabel_, "");
}
lv_obj_clear_flag(valueLabel_, LV_OBJ_FLAG_CLICKABLE);
return obj_;
}
void PowerNodeWidget::applyStyle() {
if (obj_ == nullptr) return;
lv_obj_set_style_bg_color(obj_, lv_color_white(), 0);
lv_obj_set_style_bg_opa(obj_, LV_OPA_COVER, 0);
lv_obj_set_style_radius(obj_, LV_RADIUS_CIRCLE, 0);
int16_t minSide = config_.width < config_.height ? config_.width : config_.height;
int16_t ring = minSide / 12;
if (ring < 2) ring = 2;
if (ring > 12) ring = 12;
lv_obj_set_style_border_width(obj_, ring, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.bgOpacity, 0);
applyShadowStyle();
uint8_t valueSizeIdx = config_.fontSize;
uint8_t labelSizeIdx = valueSizeIdx > 0 ? static_cast<uint8_t>(valueSizeIdx - 1) : valueSizeIdx;
if (labelLabel_ != nullptr) {
lv_obj_set_style_text_color(labelLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_text_font(labelLabel_, Fonts::bySizeIndex(labelSizeIdx), 0);
lv_obj_set_style_text_align(labelLabel_, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_opa(labelLabel_, LV_OPA_70, 0);
}
if (iconLabel_ != nullptr) {
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : valueSizeIdx;
lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0);
lv_obj_set_style_text_align(iconLabel_, LV_TEXT_ALIGN_CENTER, 0);
}
if (valueLabel_ != nullptr) {
lv_obj_set_style_text_color(valueLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_text_font(valueLabel_, Fonts::bySizeIndex(valueSizeIdx), 0);
lv_obj_set_style_text_align(valueLabel_, LV_TEXT_ALIGN_CENTER, 0);
}
}
void PowerNodeWidget::updateValueText(const char* text) {
if (valueLabel_ == nullptr || text == nullptr) return;
lv_label_set_text(valueLabel_, text);
}
void PowerNodeWidget::onKnxValue(float value) {
if (valueLabel_ == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_TEMP &&
config_.textSource != TextSource::KNX_DPT_PERCENT) return;
char buf[32];
const char* fmt = valueFormat_[0] != '\0' ? valueFormat_ : "%0.1f";
if (config_.textSource == TextSource::KNX_DPT_PERCENT) {
int percent = static_cast<int>(value + 0.5f);
snprintf(buf, sizeof(buf), fmt, percent);
} else {
snprintf(buf, sizeof(buf), fmt, value);
}
updateValueText(buf);
}
void PowerNodeWidget::onKnxSwitch(bool value) {
if (valueLabel_ == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
updateValueText(value ? "EIN" : "AUS");
}
void PowerNodeWidget::onKnxText(const char* text) {
if (valueLabel_ == nullptr) return;
if (config_.textSource != TextSource::KNX_DPT_TEXT) return;
updateValueText(text);
}

View File

@ -0,0 +1,25 @@
#pragma once
#include "Widget.hpp"
class PowerNodeWidget : public Widget {
public:
explicit PowerNodeWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
void onKnxValue(float value) override;
void onKnxSwitch(bool value) override;
void onKnxText(const char* text) override;
private:
lv_obj_t* labelLabel_ = nullptr;
lv_obj_t* valueLabel_ = nullptr;
lv_obj_t* iconLabel_ = nullptr;
char labelText_[MAX_TEXT_LEN] = {0};
char valueFormat_[MAX_TEXT_LEN] = {0};
void parseText();
void updateValueText(const char* text);
static int encodeUtf8(uint32_t codepoint, char* buf);
};

View File

@ -5,6 +5,9 @@
#include "IconWidget.hpp"
#include "TabViewWidget.hpp"
#include "TabPageWidget.hpp"
#include "PowerFlowWidget.hpp"
#include "PowerNodeWidget.hpp"
#include "PowerLinkWidget.hpp"
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
if (!config.visible) return nullptr;
@ -22,6 +25,12 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
return std::make_unique<TabViewWidget>(config);
case WidgetType::TABPAGE:
return std::make_unique<TabPageWidget>(config);
case WidgetType::POWERFLOW:
return std::make_unique<PowerFlowWidget>(config);
case WidgetType::POWERNODE:
return std::make_unique<PowerNodeWidget>(config);
case WidgetType::POWERLINK:
return std::make_unique<PowerLinkWidget>(config);
default:
return nullptr;
}

View File

@ -108,6 +108,7 @@ function deselect() {
function selectScreen(id) {
store.activeScreenId = id;
store.selectedWidgetId = null;
store.setPowerLinkMode(false);
}
function snap(val) {
@ -255,7 +256,7 @@ function resizeDrag(e) {
let newW = Math.round(rawW);
let newH = Math.round(rawH);
if (w.type === WIDGET_TYPES.LED) {
if (w.type === WIDGET_TYPES.LED || w.type === WIDGET_TYPES.POWERNODE) {
const maxSize = Math.min(maxW, maxH);
const size = clamp(Math.max(newW, newH), minSize.w, maxSize);
newW = size;

View File

@ -26,6 +26,14 @@
<span class="text-[13px] font-semibold">Tabs</span>
<span class="text-[11px] text-muted mt-0.5 block">Container</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('powerflow')">
<span class="text-[13px] font-semibold">Power Flow</span>
<span class="text-[11px] text-muted mt-0.5 block">Card</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('powernode')">
<span class="text-[13px] font-semibold">Power Node</span>
<span class="text-[11px] text-muted mt-0.5 block">Element</span>
</button>
</div>
</section>

View File

@ -114,6 +114,58 @@
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="w.text"></div>
</template>
<template v-if="key === 'powerflow'">
<h4 :class="headingClass">Power Flow</h4>
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="w.text"></div>
<div :class="rowClass">
<label :class="labelClass">Knoten</label>
<span class="text-[12px] text-muted">{{ powerNodeCount }}</span>
<button class="ml-auto border border-border bg-panel-2 px-2.5 py-1.5 rounded-lg text-[11px] font-semibold hover:bg-[#e4ebf2]" @click="addPowerNode">+ Node</button>
</div>
<div :class="rowClass">
<label :class="labelClass">Verbindungen</label>
<button class="border border-border bg-panel-2 px-2.5 py-1.5 rounded-lg text-[11px] font-semibold hover:bg-[#e4ebf2]" @click="togglePowerLinkMode">
{{ isLinkModeActive ? 'Modus: aktiv' : 'Modus: aus' }}
</button>
<button v-if="isLinkModeActive && linkSourceLabel" class="ml-auto border border-border bg-panel-2 px-2.5 py-1.5 rounded-lg text-[11px] hover:bg-[#e4ebf2]" @click="clearPowerLinkSource">Quelle loeschen</button>
</div>
<div class="text-[11px] text-muted mb-2">{{ linkModeHint }}</div>
<div v-if="powerFlowLinkItems.length" class="mt-2">
<div v-for="link in powerFlowLinkItems" :key="link.id" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<span class="px-1.5 py-0.5 rounded-md bg-panel-2 border border-border text-text max-w-[90px] truncate">{{ link.fromLabel }}</span>
<span>-&gt;</span>
<span class="px-1.5 py-0.5 rounded-md bg-panel-2 border border-border text-text max-w-[90px] truncate">{{ link.toLabel }}</span>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="link.widget.bgColor">
<button class="ml-auto w-6 h-6 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[11px] cursor-pointer hover:bg-[#f2cfcf]" @click="removePowerLink(link.id)">x</button>
</div>
</div>
<div v-else class="text-[11px] text-muted">Keine Verbindungen.</div>
</template>
<template v-if="key === 'powernode'">
<h4 :class="headingClass">Power Node</h4>
<div :class="rowClass"><label :class="labelClass">Label</label><input :class="inputClass" type="text" v-model="powerNodeLabel"></div>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
<option v-for="opt in sourceOptions.powernode" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="w.textSrc === 0" :class="rowClass">
<label :class="labelClass">Wert</label><input :class="inputClass" type="text" v-model="powerNodeValue">
</div>
<template v-else>
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="powerNodeValue"></div>
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
</option>
</select>
</div>
</template>
</template>
<!-- Typography -->
<template v-if="key === 'label'">
<h4 :class="headingClass">Typo</h4>
@ -124,6 +176,15 @@
</div>
</template>
<template v-if="key === 'powernode'">
<h4 :class="headingClass">Typo</h4>
<div :class="rowClass"><label :class="labelClass">Wert Schriftgr.</label>
<select :class="inputClass" v-model.number="w.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
</template>
<!-- Icon for Label/Button -->
<template v-if="key === 'label'">
<h4 :class="headingClass">Icon</h4>
@ -158,6 +219,26 @@
</template>
</template>
<template v-if="key === 'powernode'">
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="showIconPicker = true">
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="w.iconCodepoint" :class="iconRemoveClass" @click="w.iconCodepoint = 0">x</button>
</div>
<template v-if="w.iconCodepoint">
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="w.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
</template>
</template>
<!-- Style -->
<template v-if="key === 'led'">
<h4 :class="headingClass">Stil</h4>
@ -177,6 +258,19 @@
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
</template>
<template v-else-if="key === 'powerflow'">
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
</template>
<template v-else-if="key === 'powernode'">
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Ringfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Ring Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
</template>
<template v-else>
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
@ -187,7 +281,7 @@
<!-- Shadow/Glow -->
<template v-if="key !== 'icon'">
<h4 :class="headingClass">{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
<h4 :class="headingClass">{{ key === 'led' || key === 'powernode' ? 'Glow' : 'Schatten' }}</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.shadow.enabled"></div>
<div :class="rowClass" v-if="key !== 'led'"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="w.shadow.x"></div>
<div :class="rowClass" v-if="key !== 'led'"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="w.shadow.y"></div>
@ -250,6 +344,64 @@ const store = useEditorStore();
const w = computed(() => store.selectedWidget);
const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label');
const showIconPicker = ref(false);
const powerNodeCount = computed(() => {
if (!w.value || w.value.type !== WIDGET_TYPES.POWERFLOW || !store.activeScreen) return 0;
return store.activeScreen.widgets.filter((child) => child.parentId === w.value.id && child.type === WIDGET_TYPES.POWERNODE).length;
});
const powerFlowLinkItems = computed(() => {
if (!w.value || w.value.type !== WIDGET_TYPES.POWERFLOW || !store.activeScreen) return [];
const nodes = store.activeScreen.widgets.filter((child) => child.parentId === w.value.id && child.type === WIDGET_TYPES.POWERNODE);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
return store.activeScreen.widgets
.filter((child) => child.parentId === w.value.id && child.type === WIDGET_TYPES.POWERLINK)
.map((link) => {
const fromNode = nodeMap.get(link.x);
const toNode = nodeMap.get(link.y);
return {
id: link.id,
widget: link,
fromLabel: getPowerNodeLabel(fromNode, link.x),
toLabel: getPowerNodeLabel(toNode, link.y)
};
});
});
const isLinkModeActive = computed(() => {
return store.powerLinkMode.active && store.powerLinkMode.powerflowId === w.value?.id;
});
const linkSourceLabel = computed(() => {
if (!isLinkModeActive.value || !store.activeScreen || !store.powerLinkMode.fromNodeId) return '';
const node = store.activeScreen.widgets.find((child) => child.id === store.powerLinkMode.fromNodeId);
return getPowerNodeLabel(node, store.powerLinkMode.fromNodeId);
});
const linkModeHint = computed(() => {
if (!isLinkModeActive.value) return 'Aktiviere den Modus und klicke zwei Knoten, um eine Verbindung zu erstellen.';
if (!linkSourceLabel.value) return 'Klicke den Startknoten.';
return `Quelle: ${linkSourceLabel.value} - jetzt Zielknoten waehlen.`;
});
const powerNodeLabel = computed({
get() {
return splitPowerNodeText(w.value?.text).label;
},
set(value) {
const parts = splitPowerNodeText(w.value?.text);
setPowerNodeText(value, parts.value);
}
});
const powerNodeValue = computed({
get() {
return splitPowerNodeText(w.value?.text).value;
},
set(value) {
const parts = splitPowerNodeText(w.value?.text);
setPowerNodeText(parts.label, value);
}
});
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
const rowClass = 'flex items-center gap-2.5 mb-2';
@ -260,11 +412,55 @@ const noteClass = 'text-[11px] text-muted leading-tight';
const iconSelectClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2.5 py-1.5 text-text text-[12px] flex items-center justify-center gap-1.5 cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2]';
const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[12px] cursor-pointer hover:bg-[#f2cfcf]';
function addPowerNode() {
store.addWidget('powernode');
}
function togglePowerLinkMode() {
if (!w.value || w.value.type !== WIDGET_TYPES.POWERFLOW) return;
const nextState = !(store.powerLinkMode.active && store.powerLinkMode.powerflowId === w.value.id);
store.setPowerLinkMode(nextState, w.value.id);
}
function clearPowerLinkSource() {
if (!w.value || w.value.type !== WIDGET_TYPES.POWERFLOW) return;
store.powerLinkMode.fromNodeId = null;
}
function removePowerLink(linkId) {
store.removePowerLink(linkId);
}
function handleTextSrcChange() {
if (!w.value) return;
const newSrc = w.value.textSrc;
if (w.value.type === WIDGET_TYPES.LABEL && newSrc > 0 && defaultFormats[newSrc]) {
w.value.text = defaultFormats[newSrc];
}
if (w.value.type === WIDGET_TYPES.POWERNODE && newSrc > 0 && defaultFormats[newSrc]) {
const parts = splitPowerNodeText(w.value.text);
setPowerNodeText(parts.label, defaultFormats[newSrc]);
}
}
function splitPowerNodeText(text) {
if (typeof text !== 'string') return { label: '', value: '' };
const parts = text.split('\n');
const label = parts[0] ?? '';
const value = parts.slice(1).join('\n');
return { label, value };
}
function setPowerNodeText(label, value) {
if (!w.value) return;
const labelLine = label ?? '';
const valueLine = value ?? '';
w.value.text = valueLine !== '' || labelLine !== '' ? `${labelLine}${valueLine !== '' ? `\n${valueLine}` : ''}` : '';
}
function getPowerNodeLabel(node, fallbackId) {
if (!node) return `Node ${fallbackId ?? ''}`.trim();
const parts = splitPowerNodeText(node.text);
return parts.label || `Node ${node.id}`;
}
</script>

View File

@ -26,7 +26,7 @@
<span class="material-symbols-outlined text-[16px] text-accent opacity-80">{{ getIconForType(node.type) }}</span>
<div class="flex flex-col overflow-hidden">
<span class="text-[12px] truncate">{{ node.text || TYPE_LABELS[typeKeyFor(node.type)] }}</span>
<span class="text-[12px] truncate">{{ displayTitle(node) }}</span>
<span class="text-[9px] text-muted">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
</div>
</div>
@ -70,10 +70,21 @@ function getIconForType(type) {
case WIDGET_TYPES.ICON: return 'image';
case WIDGET_TYPES.TABVIEW: return 'tab';
case WIDGET_TYPES.TABPAGE: return 'article';
case WIDGET_TYPES.POWERFLOW: return 'device_hub';
case WIDGET_TYPES.POWERNODE: return 'radio_button_checked';
default: return 'widgets';
}
}
function displayTitle(node) {
if (!node) return '';
if (node.type === WIDGET_TYPES.POWERNODE && typeof node.text === 'string') {
const [label] = node.text.split('\n');
return label || TYPE_LABELS[typeKeyFor(node.type)];
}
return node.text || TYPE_LABELS[typeKeyFor(node.type)];
}
function onDragStart(e, node) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', node.id.toString());
@ -88,7 +99,8 @@ function onDragOver(e) {
const type = props.node.type;
const canAccept = type === WIDGET_TYPES.BUTTON ||
type === WIDGET_TYPES.TABVIEW ||
type === WIDGET_TYPES.TABPAGE;
type === WIDGET_TYPES.TABPAGE ||
type === WIDGET_TYPES.POWERFLOW;
if (canAccept) {
isDragOver.value = true;
@ -108,7 +120,8 @@ function onDrop(e, targetNode) {
const type = targetNode.type;
const canAccept = type === WIDGET_TYPES.BUTTON ||
type === WIDGET_TYPES.TABVIEW ||
type === WIDGET_TYPES.TABPAGE;
type === WIDGET_TYPES.TABPAGE ||
type === WIDGET_TYPES.POWERFLOW;
if (canAccept) {
store.reparentWidget(draggedId, targetNode.id);

View File

@ -8,7 +8,7 @@
:style="computedStyle"
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
@click.stop="handleWidgetClick"
>
<!-- Recursive Children -->
<!-- Special handling for TabView to render structure -->
@ -39,6 +39,48 @@
</div>
</div>
</template>
<template v-else-if="isPowerFlow">
<div class="absolute inset-0" :style="powerFlowBgStyle"></div>
<div v-if="widget.text" class="absolute left-4 top-3 text-[13px] uppercase tracking-[0.08em]" :style="{ color: widget.textColor }">
{{ widget.text }}
</div>
<svg class="absolute inset-0 pointer-events-none" :width="widget.w * scale" :height="widget.h * scale">
<path
v-for="link in powerFlowLinks"
:key="`link-${link.id}`"
:d="link.path"
:stroke="link.color"
:stroke-width="link.width"
stroke-linecap="round"
:stroke-dasharray="`${link.dash} ${link.dash + 4}`"
fill="none"
:opacity="link.opacity"
/>
<circle
v-for="link in powerFlowLinks"
:key="`dot-${link.id}`"
:cx="link.dotX"
:cy="link.dotY"
r="3"
:fill="link.color"
:opacity="link.opacity"
/>
</svg>
<div v-if="!powerNodes.length" class="absolute inset-0 grid place-items-center text-[12px] text-muted">
Power Nodes hinzufuegen
</div>
<WidgetElement
v-for="child in powerFlowChildren"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</template>
<!-- Standard Recursive Children (for Buttons, Pages, etc) -->
<template v-else>
@ -54,8 +96,19 @@
/>
</template>
<!-- Power Node Widget -->
<template v-if="isPowerNode">
<div class="flex flex-col items-center justify-center w-full h-full text-center leading-tight">
<span v-if="powerNodeParts.label" :style="powerNodeLabelStyle">{{ powerNodeParts.label }}</span>
<span v-if="widget.iconCodepoint" class="material-symbols-outlined mt-1" :style="powerNodeIconStyle">
{{ iconChar }}
</span>
<span v-if="powerNodeParts.value" class="mt-1" :style="powerNodeValueStyle">{{ powerNodeParts.value }}</span>
</div>
</template>
<!-- Icon-only Widget -->
<template v-if="isIcon">
<template v-else-if="isIcon">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
{{ iconChar }}
</span>
@ -97,7 +150,7 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
import { clamp, hexToRgba } from '../utils';
@ -124,6 +177,9 @@ const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
const isTabPage = computed(() => props.widget.type === WIDGET_TYPES.TABPAGE);
const isButtonContainer = computed(() => isButton.value && props.widget.isContainer);
const isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW);
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
@ -132,7 +188,6 @@ const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
// For designer simplicity: show all tabs content stacked, or just first one?
// Better: mimic LVGL. We need state. Let's use a local ref or just show the selected one in tree?
// To keep it simple: Show the active tab based on which child is selected or default to first.
import { ref } from 'vue';
const activeTabIndex = ref(0);
const activePageId = computed(() => {
@ -169,6 +224,8 @@ const textAlign = computed(() => props.widget.textAlign ?? 1);
const showDefaultText = computed(() => {
if (isTabView.value || isTabPage.value) return false;
if (isPowerFlow.value || isPowerNode.value) return false;
if (isPowerLink.value) return false;
if (isButtonContainer.value) return false;
return true;
});
@ -228,6 +285,115 @@ const iconOnlyStyle = computed(() => {
};
});
const powerNodeParts = computed(() => splitPowerNodeText(props.widget.text));
const powerNodeValueSize = computed(() => {
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeLabelSize = computed(() => {
return Math.max(10 * props.scale, powerNodeValueSize.value * 0.55);
});
const powerNodeIconSize = computed(() => {
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeIconStyle = computed(() => ({
fontSize: `${powerNodeIconSize.value}px`,
color: props.widget.textColor
}));
const powerNodeLabelStyle = computed(() => ({
fontSize: `${powerNodeLabelSize.value}px`,
color: hexToRgba(props.widget.textColor, 0.72)
}));
const powerNodeValueStyle = computed(() => ({
fontSize: `${powerNodeValueSize.value}px`,
color: props.widget.textColor,
fontWeight: '600'
}));
const powerNodes = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERNODE && child.visible !== false);
});
const powerLinkWidgets = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERLINK && child.visible !== false);
});
const powerFlowChildren = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type !== WIDGET_TYPES.POWERLINK);
});
const powerFlowLinks = computed(() => {
if (!isPowerFlow.value || powerNodes.value.length < 2 || !powerLinkWidgets.value.length) return [];
const s = props.scale;
const nodeMap = new Map(powerNodes.value.map((node) => [node.id, node]));
return powerLinkWidgets.value.flatMap((link, idx) => {
const fromNode = nodeMap.get(link.x);
const toNode = nodeMap.get(link.y);
if (!fromNode || !toNode) return [];
const fromCenter = {
x: (fromNode.x + fromNode.w / 2) * s,
y: (fromNode.y + fromNode.h / 2) * s
};
const toCenter = {
x: (toNode.x + toNode.w / 2) * s,
y: (toNode.y + toNode.h / 2) * s
};
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const midX = (fromCenter.x + toCenter.x) / 2;
const midY = (fromCenter.y + toCenter.y) / 2;
const curveSign = idx % 2 === 0 ? 1 : -1;
const curve = Math.min(42 * s, len * 0.3) * curveSign;
const cpx = midX + nx * curve;
const cpy = midY + ny * curve;
const dash = Math.max(2, link.iconGap || 6);
return [{
id: link.id,
path: `M ${fromCenter.x} ${fromCenter.y} Q ${cpx} ${cpy} ${toCenter.x} ${toCenter.y}`,
color: link.bgColor || '#6fa7d8',
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
width: Math.max(1, link.w || 2),
dash,
dotX: midX + nx * curve * 0.5,
dotY: midY + ny * curve * 0.5
}];
});
});
const powerFlowBgStyle = computed(() => {
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
const base = hexToRgba(props.widget.bgColor, alpha);
const dot = hexToRgba('#9aa7b4', 0.25);
const dotSize = 16 * props.scale;
return {
backgroundColor: base,
backgroundImage: `radial-gradient(${dot} 0.9px, transparent 1px), linear-gradient(140deg, rgba(255,255,255,0.9) 0%, ${base} 70%)`,
backgroundSize: `${dotSize}px ${dotSize}px, 100% 100%`,
backgroundPosition: '0 0, 0 0'
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
@ -305,6 +471,46 @@ const computedStyle = computed(() => {
} else {
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
}
} else if (isPowerFlow.value) {
style.borderRadius = `${w.radius * s}px`;
style.overflow = 'hidden';
style.border = `1px solid ${hexToRgba('#94a3b8', 0.35)}`;
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
}
} else if (isPowerNode.value) {
const ringAlpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const ring = Math.max(3, Math.round(Math.min(w.w, w.h) * 0.06 * s));
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
style.borderRadius = '999px';
style.background = hexToRgba('#ffffff', 0.96);
style.border = `${ring}px solid ${hexToRgba(w.bgColor, ringAlpha)}`;
style.textAlign = 'center';
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.35 + ringAlpha * 0.5, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(w.shadow.color || w.bgColor, glowAlpha)}`;
} else {
style.boxShadow = '0 8px 18px rgba(15, 23, 42, 0.12)';
}
if (store.powerLinkMode.active && store.powerLinkMode.powerflowId === w.parentId) {
style.cursor = 'crosshair';
}
if (store.powerLinkMode.active && store.powerLinkMode.fromNodeId === w.id && store.powerLinkMode.powerflowId === w.parentId) {
style.outline = `2px dashed ${hexToRgba('#4f8ad9', 0.9)}`;
style.outlineOffset = '2px';
}
} else if (isTabView.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
@ -347,4 +553,20 @@ const tabBtnClass = (isActive) => {
: 'bg-white/10 font-bold border-b-2 border-accent';
return `${base} ${border}${isActive ? ` ${active}` : ''}`;
};
function splitPowerNodeText(text) {
if (typeof text !== 'string') return { label: '', value: '' };
const parts = text.split('\n');
const label = parts[0] ?? '';
const value = parts.slice(1).join('\n');
return { label, value };
}
function handleWidgetClick() {
if (isPowerNode.value && store.powerLinkMode.active) {
store.handlePowerNodeLink(props.widget.id, props.widget.parentId);
return;
}
emit('select');
}
</script>

View File

@ -8,7 +8,10 @@ export const WIDGET_TYPES = {
LED: 2,
ICON: 3,
TABVIEW: 4,
TABPAGE: 5
TABPAGE: 5,
POWERFLOW: 6,
POWERNODE: 7,
POWERLINK: 8
};
export const ICON_POSITIONS = {
@ -36,7 +39,10 @@ export const TYPE_KEYS = {
2: 'led',
3: 'icon',
4: 'tabview',
5: 'tabpage'
5: 'tabpage',
6: 'powerflow',
7: 'powernode',
8: 'powerlink'
};
export const TYPE_LABELS = {
@ -45,7 +51,10 @@ export const TYPE_LABELS = {
led: 'LED',
icon: 'Icon',
tabview: 'Tabs',
tabpage: 'Seite'
tabpage: 'Seite',
powerflow: 'Power Flow',
powernode: 'Power Node',
powerlink: 'Power Link'
};
@ -61,7 +70,8 @@ export const sourceOptions = {
label: [0, 1, 2, 3, 4],
button: [0],
led: [0, 2],
icon: [0, 2]
icon: [0, 2],
powernode: [0, 1, 2, 3, 4]
};
export const ICON_DEFAULTS = {
@ -213,5 +223,71 @@ export const WIDGET_DEFAULTS = {
iconPosition: 0,
iconSize: 1,
iconGap: 0
},
powerflow: {
w: 720,
h: 460,
text: 'Power Flow Card',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.LEFT,
textColor: '#1f2a33',
bgColor: '#ffffff',
bgOpacity: 255,
radius: 18,
shadow: { enabled: true, x: 0, y: 8, blur: 22, spread: 0, color: '#1b28351f' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0
},
powernode: {
w: 120,
h: 120,
text: 'Knoten\n0 W',
textSrc: 0,
fontSize: 2,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#223447',
bgColor: '#4fb06d',
bgOpacity: 255,
radius: 60,
shadow: { enabled: false, x: 0, y: 0, blur: 12, spread: 0, color: '#4fb06d' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 8
},
powerlink: {
w: 2,
h: 0,
text: '',
textSrc: 0,
fontSize: 0,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#2b3b4b',
bgColor: '#6fa7d8',
bgOpacity: 220,
radius: 0,
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 0,
iconGap: 6
}
};

View File

@ -17,6 +17,7 @@ export const useEditorStore = defineStore('editor', () => {
const showGrid = ref(true);
const snapToGrid = ref(true);
const gridSize = ref(20);
const powerLinkMode = reactive({ active: false, powerflowId: null, fromNodeId: null });
const nextScreenId = ref(0);
const nextWidgetId = ref(0);
@ -27,7 +28,7 @@ export const useEditorStore = defineStore('editor', () => {
const widgetTree = computed(() => {
if (!activeScreen.value) return [];
const widgets = activeScreen.value.widgets;
const widgets = activeScreen.value.widgets.filter(w => w.type !== WIDGET_TYPES.POWERLINK);
// Map ID -> Widget
const map = {};
@ -243,6 +244,9 @@ export const useEditorStore = defineStore('editor', () => {
case 'icon': typeValue = WIDGET_TYPES.ICON; break;
case 'tabview': typeValue = WIDGET_TYPES.TABVIEW; break;
case 'tabpage': typeValue = WIDGET_TYPES.TABPAGE; break;
case 'powerflow': typeValue = WIDGET_TYPES.POWERFLOW; break;
case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break;
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
default: typeValue = WIDGET_TYPES.LABEL;
}
@ -274,6 +278,17 @@ export const useEditorStore = defineStore('editor', () => {
parentId = parent.id;
startX = 0;
startY = 0;
} else if (parent.type === WIDGET_TYPES.POWERFLOW && typeStr === 'powernode') {
parentId = parent.id;
startX = 40;
startY = 40;
} else if (parent.type === WIDGET_TYPES.POWERNODE && typeStr === 'powernode') {
const grandParent = activeScreen.value.widgets.find(w => w.id === parent.parentId);
if (grandParent && grandParent.type === WIDGET_TYPES.POWERFLOW) {
parentId = grandParent.id;
startX = 40;
startY = 40;
}
}
}
}
@ -355,6 +370,77 @@ export const useEditorStore = defineStore('editor', () => {
}
}
function setPowerLinkMode(active, powerflowId = null) {
powerLinkMode.active = active;
powerLinkMode.powerflowId = active ? powerflowId : null;
powerLinkMode.fromNodeId = null;
}
function addPowerLink(powerflowId, fromNodeId, toNodeId) {
if (!activeScreen.value) return;
if (fromNodeId === toNodeId) return;
const existing = activeScreen.value.widgets.find((w) => {
if (w.parentId !== powerflowId || w.type !== WIDGET_TYPES.POWERLINK) return false;
return (w.x === fromNodeId && w.y === toNodeId) || (w.x === toNodeId && w.y === fromNodeId);
});
if (existing) return;
const defaults = WIDGET_DEFAULTS.powerlink;
const link = {
id: nextWidgetId.value++,
parentId: powerflowId,
type: WIDGET_TYPES.POWERLINK,
x: fromNodeId,
y: toNodeId,
w: defaults.w,
h: defaults.h,
visible: true,
textSrc: defaults.textSrc,
text: defaults.text,
knxAddr: defaults.knxAddr,
fontSize: defaults.fontSize,
textAlign: defaults.textAlign,
isContainer: false,
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,
iconCodepoint: defaults.iconCodepoint || 0,
iconPosition: defaults.iconPosition || 0,
iconSize: defaults.iconSize || 0,
iconGap: defaults.iconGap || 0
};
activeScreen.value.widgets.push(link);
}
function removePowerLink(linkId) {
if (!activeScreen.value) return;
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => w.id !== linkId);
}
function handlePowerNodeLink(nodeId, powerflowId) {
if (!powerLinkMode.active || powerLinkMode.powerflowId !== powerflowId) {
selectedWidgetId.value = nodeId;
return;
}
if (!powerLinkMode.fromNodeId || powerLinkMode.fromNodeId === nodeId) {
powerLinkMode.fromNodeId = nodeId;
return;
}
addPowerLink(powerflowId, powerLinkMode.fromNodeId, nodeId);
powerLinkMode.fromNodeId = nodeId;
}
function deleteWidget() {
if (!activeScreen.value || selectedWidgetId.value === null) return;
@ -375,6 +461,15 @@ export const useEditorStore = defineStore('editor', () => {
}
});
}
// Remove power links connected to deleted nodes
activeScreen.value.widgets.forEach((w) => {
if (w.type === WIDGET_TYPES.POWERLINK) {
if (deleteIds.includes(w.x) || deleteIds.includes(w.y)) {
deleteIds.push(w.id);
}
}
});
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => !deleteIds.includes(w.id));
selectedWidgetId.value = null;
@ -412,6 +507,7 @@ export const useEditorStore = defineStore('editor', () => {
showGrid,
snapToGrid,
gridSize,
powerLinkMode,
activeScreen,
widgetTree,
selectedWidget,
@ -422,6 +518,10 @@ export const useEditorStore = defineStore('editor', () => {
addScreen,
deleteScreen,
addWidget,
setPowerLinkMode,
addPowerLink,
removePowerLink,
handlePowerNodeLink,
deleteWidget,
reparentWidget
};

View File

@ -15,6 +15,9 @@ export function minSizeFor(widget) {
if (key === 'led') return { w: 20, h: 20 };
if (key === 'icon') return { w: 24, h: 24 };
if (key === 'tabview') return { w: 100, h: 100 };
if (key === 'powerflow') return { w: 240, h: 180 };
if (key === 'powernode') return { w: 70, h: 70 };
if (key === 'powerlink') return { w: 1, h: 1 };
return { w: 40, h: 20 };
}