Fixes
This commit is contained in:
parent
cc6b30ebfe
commit
fbf7fa12a2
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ public:
|
||||
// Direct config access
|
||||
GuiConfig& getConfig() { return config_; }
|
||||
const GuiConfig& getConfig() const { return config_; }
|
||||
const ScreenConfig* currentScreen() const;
|
||||
|
||||
private:
|
||||
WidgetManager();
|
||||
|
||||
60
main/widgets/PowerFlowWidget.cpp
Normal file
60
main/widgets/PowerFlowWidget.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
13
main/widgets/PowerFlowWidget.hpp
Normal file
13
main/widgets/PowerFlowWidget.hpp
Normal 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;
|
||||
};
|
||||
67
main/widgets/PowerLinkWidget.cpp
Normal file
67
main/widgets/PowerLinkWidget.cpp
Normal 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);
|
||||
}
|
||||
14
main/widgets/PowerLinkWidget.hpp
Normal file
14
main/widgets/PowerLinkWidget.hpp
Normal 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();
|
||||
};
|
||||
186
main/widgets/PowerNodeWidget.cpp
Normal file
186
main/widgets/PowerNodeWidget.cpp
Normal 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);
|
||||
}
|
||||
25
main/widgets/PowerNodeWidget.hpp
Normal file
25
main/widgets/PowerNodeWidget.hpp
Normal 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);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>-></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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user