Backup
This commit is contained in:
parent
6a8b74a652
commit
21126bb3a8
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.cache/clangd/index/RoomCardTileWidget.cpp.4F5E3794B098F0B3.idx
Normal file
BIN
.cache/clangd/index/RoomCardTileWidget.cpp.4F5E3794B098F0B3.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/RoomCardTileWidget.hpp.D006F134C019997A.idx
Normal file
BIN
.cache/clangd/index/RoomCardTileWidget.hpp.D006F134C019997A.idx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.cache/clangd/index/RoomCardWidgetBase.cpp.E82CB8390DB7EE04.idx
Normal file
BIN
.cache/clangd/index/RoomCardWidgetBase.cpp.E82CB8390DB7EE04.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/RoomCardWidgetBase.hpp.7649988C886B5357.idx
Normal file
BIN
.cache/clangd/index/RoomCardWidgetBase.hpp.7649988C886B5357.idx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -12,7 +12,9 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c
|
||||
"widgets/PowerLinkWidget.cpp"
|
||||
"widgets/ChartWidget.cpp"
|
||||
"widgets/ClockWidget.cpp"
|
||||
"widgets/RoomCardWidget.cpp"
|
||||
"widgets/RoomCardWidgetBase.cpp"
|
||||
"widgets/RoomCardBubbleWidget.cpp"
|
||||
"widgets/RoomCardTileWidget.cpp"
|
||||
"webserver/WebServer.cpp"
|
||||
"webserver/StaticFileHandlers.cpp"
|
||||
"webserver/ConfigHandlers.cpp"
|
||||
|
||||
@ -17,8 +17,13 @@ static constexpr const char* kIconFontPath = "/sdcard/fonts/MaterialSymbolsOutli
|
||||
static constexpr uint16_t kFontSizes[] = {14, 18, 22, 28, 36, 48};
|
||||
static constexpr size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]);
|
||||
|
||||
// Icon fonts have more sizes including large ones for decorative use
|
||||
// Large icons (>150px) use PSRAM for draw buffers via CLIB_MALLOC
|
||||
static constexpr uint16_t kIconFontSizes[] = {14, 18, 22, 28, 36, 48, 64, 80, 96, 120, 150, 180, 220, 260};
|
||||
static constexpr size_t kIconFontCount = sizeof(kIconFontSizes) / sizeof(kIconFontSizes[0]);
|
||||
|
||||
static const lv_font_t* s_fonts[kFontCount] = {nullptr};
|
||||
static const lv_font_t* s_iconFonts[kFontCount] = {nullptr};
|
||||
static const lv_font_t* s_iconFonts[kIconFontCount] = {nullptr};
|
||||
static bool s_initialized = false;
|
||||
static bool s_iconFontAvailable = false;
|
||||
|
||||
@ -109,14 +114,14 @@ void Fonts::init() {
|
||||
}
|
||||
ESP_LOGI(TAG, "Text font initialization complete");
|
||||
|
||||
// Load icon font if available
|
||||
// Load icon font if available (includes larger sizes for decorative icons)
|
||||
if (fontFileExists(kIconFontPath)) {
|
||||
ESP_LOGI(TAG, "Icon font file exists: %s", kIconFontPath);
|
||||
for (size_t i = 0; i < kFontCount; ++i) {
|
||||
ESP_LOGI(TAG, "Loading icon font size %u...", kFontSizes[i]);
|
||||
for (size_t i = 0; i < kIconFontCount; ++i) {
|
||||
ESP_LOGI(TAG, "Loading icon font size %u...", kIconFontSizes[i]);
|
||||
esp_lv_adapter_ft_font_config_t cfg = {};
|
||||
cfg.name = kIconFontPath;
|
||||
cfg.size = kFontSizes[i];
|
||||
cfg.size = kIconFontSizes[i];
|
||||
cfg.style = ESP_LV_ADAPTER_FT_FONT_STYLE_NORMAL;
|
||||
cfg.mem = nullptr;
|
||||
cfg.mem_size = 0;
|
||||
@ -131,10 +136,10 @@ void Fonts::init() {
|
||||
s_iconFonts[i] = esp_lv_adapter_ft_font_get(handle);
|
||||
if (s_iconFonts[i]) {
|
||||
s_iconFontAvailable = true;
|
||||
ESP_LOGI(TAG, "Icon font size %u loaded successfully", kFontSizes[i]);
|
||||
ESP_LOGI(TAG, "Icon font size %u loaded successfully", kIconFontSizes[i]);
|
||||
}
|
||||
}
|
||||
ESP_LOGI(TAG, "Icon font initialization complete");
|
||||
ESP_LOGI(TAG, "Icon font initialization complete (%zu sizes)", kIconFontCount);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Icon font file not found: %s", kIconFontPath);
|
||||
}
|
||||
@ -154,18 +159,18 @@ const lv_font_t* Fonts::bySizeIndex(uint8_t sizeIndex) {
|
||||
|
||||
const lv_font_t* Fonts::iconFont(uint8_t sizeIndex) {
|
||||
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
|
||||
if (sizeIndex < kFontCount && s_iconFonts[sizeIndex]) {
|
||||
if (sizeIndex < kIconFontCount && s_iconFonts[sizeIndex]) {
|
||||
return s_iconFonts[sizeIndex];
|
||||
}
|
||||
// Try to return any available icon font size as fallback
|
||||
if (s_iconFontAvailable) {
|
||||
for (size_t i = 0; i < kFontCount; ++i) {
|
||||
for (size_t i = 0; i < kIconFontCount; ++i) {
|
||||
if (s_iconFonts[i]) return s_iconFonts[i];
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// No icon font available - return text font as last resort
|
||||
return fallbackFont(sizeIndex);
|
||||
return fallbackFont(sizeIndex < kFontCount ? sizeIndex : kFontCount - 1);
|
||||
}
|
||||
|
||||
bool Fonts::hasIconFont() {
|
||||
|
||||
@ -47,7 +47,11 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
||||
buf[pos++] = iconPosition;
|
||||
buf[pos++] = iconSize;
|
||||
buf[pos++] = static_cast<uint8_t>(iconGap);
|
||||
|
||||
buf[pos++] = iconPositionX & 0xFF;
|
||||
buf[pos++] = (iconPositionX >> 8) & 0xFF;
|
||||
buf[pos++] = iconPositionY & 0xFF;
|
||||
buf[pos++] = (iconPositionY >> 8) & 0xFF;
|
||||
|
||||
// Hierarchy
|
||||
buf[pos++] = static_cast<uint8_t>(parentId);
|
||||
|
||||
@ -178,7 +182,11 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||
iconPosition = buf[pos++];
|
||||
iconSize = buf[pos++];
|
||||
iconGap = static_cast<int8_t>(buf[pos++]);
|
||||
|
||||
iconPositionX = static_cast<int16_t>(buf[pos] | (buf[pos+1] << 8));
|
||||
pos += 2;
|
||||
iconPositionY = static_cast<int16_t>(buf[pos] | (buf[pos+1] << 8));
|
||||
pos += 2;
|
||||
|
||||
// Hierarchy
|
||||
parentId = static_cast<int8_t>(buf[pos++]);
|
||||
|
||||
@ -382,6 +390,8 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
|
||||
cfg.iconPosition = 0;
|
||||
cfg.iconSize = 1;
|
||||
cfg.iconGap = 8;
|
||||
cfg.iconPositionX = 0;
|
||||
cfg.iconPositionY = 0;
|
||||
// Secondary/tertiary address defaults
|
||||
cfg.knxAddress2 = 0;
|
||||
cfg.textSource2 = TextSource::STATIC;
|
||||
|
||||
@ -14,6 +14,7 @@ static constexpr size_t CHART_MAX_SERIES = 3;
|
||||
static constexpr size_t MAX_CONDITIONS = 3;
|
||||
static constexpr size_t MAX_FORMAT_LEN = 16; // Shorter format strings for left/right values
|
||||
static constexpr size_t MAX_SUBBUTTONS = 6; // Sub-buttons for RoomCard
|
||||
static constexpr size_t MAX_TEXTLINES = 5; // Variable text lines for RoomCard
|
||||
|
||||
enum class WidgetType : uint8_t {
|
||||
LABEL = 0,
|
||||
@ -180,6 +181,18 @@ struct SubButtonConfig {
|
||||
// Total: 20 bytes per SubButton
|
||||
};
|
||||
|
||||
// Text line configuration for RoomCard (24 bytes)
|
||||
struct TextLineConfig {
|
||||
uint32_t iconCodepoint; // 4 bytes - Icon codepoint (0 = no icon)
|
||||
uint16_t knxAddr; // 2 bytes - KNX address for dynamic value
|
||||
TextSource textSrc; // 1 byte - Text source (static or KNX DPT)
|
||||
uint8_t fontSize; // 1 byte - Font size index (0-5)
|
||||
bool enabled; // 1 byte - Is this text line active?
|
||||
uint8_t _padding; // 1 byte - Alignment padding
|
||||
char text[MAX_FORMAT_LEN]; // 16 bytes - Static text or format string
|
||||
// Total: 26 bytes per TextLine
|
||||
};
|
||||
|
||||
// Shadow configuration
|
||||
struct ShadowConfig {
|
||||
int8_t offsetX;
|
||||
@ -225,8 +238,10 @@ struct WidgetConfig {
|
||||
// Icon properties (for Label, Button, Icon widgets)
|
||||
uint32_t iconCodepoint; // Unicode codepoint (0 = no icon)
|
||||
uint8_t iconPosition; // IconPosition: 0=left, 1=right, 2=top, 3=bottom
|
||||
uint8_t iconSize; // Font size index (0-5), same as fontSize
|
||||
uint8_t iconSize; // Font size index (0-13 for icons)
|
||||
int8_t iconGap; // Gap between icon and text (px)
|
||||
int16_t iconPositionX; // Icon Pos X (for decorative icons in Tile)
|
||||
int16_t iconPositionY; // Icon Pos Y (for decorative icons in Tile)
|
||||
|
||||
// Hierarchy
|
||||
int8_t parentId; // ID of parent widget (-1 = root/screen)
|
||||
@ -260,9 +275,13 @@ struct WidgetConfig {
|
||||
uint8_t cardStyle; // 0=Bubble (round), 1=Tile (rectangular)
|
||||
SubButtonConfig subButtons[MAX_SUBBUTTONS];
|
||||
|
||||
// RoomCard text lines (for Tile style)
|
||||
uint8_t textLineCount;
|
||||
TextLineConfig textLines[MAX_TEXTLINES];
|
||||
|
||||
// Serialization size (fixed for NVS storage)
|
||||
// 197 + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 1 (subButtonOpacity) + 1 (cardStyle) + 120 (6 subButtons * 20) = 322
|
||||
static constexpr size_t SERIALIZED_SIZE = 322;
|
||||
// 197 + 4 (iconPositionX/Y) + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 1 (subButtonOpacity) + 1 (cardStyle) + 120 (6 subButtons * 20) = 326
|
||||
static constexpr size_t SERIALIZED_SIZE = 326;
|
||||
|
||||
void serialize(uint8_t* buf) const;
|
||||
void deserialize(const uint8_t* buf);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
#include "WidgetManager.hpp"
|
||||
#include "widgets/WidgetFactory.hpp"
|
||||
#include "widgets/RoomCardWidget.hpp"
|
||||
#include "widgets/RoomCardWidgetBase.hpp"
|
||||
#include "widgets/RoomCardTileWidget.hpp"
|
||||
#include "HistoryStore.hpp"
|
||||
#include "SdCard.hpp"
|
||||
#include "esp_lv_adapter.h"
|
||||
@ -1036,6 +1037,22 @@ void WidgetManager::applyCachedValuesToWidgets() {
|
||||
widget->onKnxValue3(value);
|
||||
}
|
||||
}
|
||||
|
||||
// RoomCard text lines (only for Tile style)
|
||||
if (widget->getType() == WidgetType::ROOMCARD) {
|
||||
const WidgetConfig& cfg = widget->getConfig();
|
||||
if (cfg.cardStyle == 1 && cfg.textLineCount > 0) {
|
||||
for (uint8_t i = 0; i < cfg.textLineCount && i < MAX_TEXTLINES; ++i) {
|
||||
const TextLineConfig& tl = cfg.textLines[i];
|
||||
if (tl.enabled && tl.knxAddr != 0 && tl.textSrc != TextSource::STATIC && isNumericTextSource(tl.textSrc)) {
|
||||
float value = 0.0f;
|
||||
if (getCachedKnxValue(tl.knxAddr, tl.textSrc, &value)) {
|
||||
static_cast<RoomCardTileWidget*>(widget.get())->onTextLineValue(i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1099,6 +1116,19 @@ void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource so
|
||||
widget->getTextSource3() == source) {
|
||||
widget->onKnxValue3(value);
|
||||
}
|
||||
|
||||
// RoomCard text line updates (only for Tile style)
|
||||
if (widget->getType() == WidgetType::ROOMCARD) {
|
||||
const WidgetConfig& cfg = widget->getConfig();
|
||||
if (cfg.cardStyle == 1 && cfg.textLineCount > 0) {
|
||||
for (uint8_t i = 0; i < cfg.textLineCount && i < MAX_TEXTLINES; ++i) {
|
||||
const TextLineConfig& tl = cfg.textLines[i];
|
||||
if (tl.enabled && tl.knxAddr == groupAddr && tl.textSrc == source) {
|
||||
static_cast<RoomCardTileWidget*>(widget.get())->onTextLineValue(i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (HistoryStore::instance().updateLatest(groupAddr, source, value)) {
|
||||
@ -1119,7 +1149,7 @@ void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
|
||||
const WidgetConfig& cfg = widget->getConfig();
|
||||
for (uint8_t i = 0; i < cfg.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
|
||||
if (cfg.subButtons[i].enabled && cfg.subButtons[i].knxAddrRead == groupAddr) {
|
||||
static_cast<RoomCardWidget*>(widget.get())->onSubButtonStatus(i, value);
|
||||
static_cast<RoomCardWidgetBase*>(widget.get())->onSubButtonStatus(i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1514,6 +1544,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition);
|
||||
cJSON_AddNumberToObject(widget, "iconSize", w.iconSize);
|
||||
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
|
||||
cJSON_AddNumberToObject(widget, "iconPositionX", w.iconPositionX);
|
||||
cJSON_AddNumberToObject(widget, "iconPositionY", w.iconPositionY);
|
||||
|
||||
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
|
||||
|
||||
@ -1637,6 +1669,24 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
}
|
||||
}
|
||||
|
||||
// RoomCard text lines
|
||||
if (w.type == WidgetType::ROOMCARD && w.textLineCount > 0) {
|
||||
cJSON* textLines = cJSON_AddArrayToObject(widget, "textLines");
|
||||
for (uint8_t ti = 0; ti < w.textLineCount && ti < MAX_TEXTLINES; ++ti) {
|
||||
const TextLineConfig& tl = w.textLines[ti];
|
||||
if (!tl.enabled) continue;
|
||||
|
||||
cJSON* tlJson = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(tlJson, "icon", tl.iconCodepoint);
|
||||
cJSON_AddNumberToObject(tlJson, "textSrc", static_cast<int>(tl.textSrc));
|
||||
cJSON_AddNumberToObject(tlJson, "knxAddr", tl.knxAddr);
|
||||
cJSON_AddStringToObject(tlJson, "text", tl.text);
|
||||
cJSON_AddNumberToObject(tlJson, "fontSize", tl.fontSize);
|
||||
|
||||
cJSON_AddItemToArray(textLines, tlJson);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(widgets, widget);
|
||||
}
|
||||
|
||||
@ -1798,6 +1848,12 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
|
||||
cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap");
|
||||
if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint;
|
||||
|
||||
cJSON* iconPositionX = cJSON_GetObjectItem(widget, "iconPositionX");
|
||||
if (cJSON_IsNumber(iconPositionX)) w.iconPositionX = iconPositionX->valueint;
|
||||
|
||||
cJSON* iconPositionY = cJSON_GetObjectItem(widget, "iconPositionY");
|
||||
if (cJSON_IsNumber(iconPositionY)) w.iconPositionY = iconPositionY->valueint;
|
||||
|
||||
cJSON* parentId = cJSON_GetObjectItem(widget, "parentId");
|
||||
if (cJSON_IsNumber(parentId)) {
|
||||
@ -1967,8 +2023,10 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
cJSON* cardStyle = cJSON_GetObjectItem(widget, "cardStyle");
|
||||
if (cJSON_IsNumber(cardStyle)) {
|
||||
w.cardStyle = cardStyle->valueint;
|
||||
ESP_LOGI(TAG, "RoomCard cardStyle loaded: %d", w.cardStyle);
|
||||
} else {
|
||||
w.cardStyle = 0; // Default to Bubble style
|
||||
ESP_LOGI(TAG, "RoomCard cardStyle not found, using default 0");
|
||||
}
|
||||
|
||||
// RoomCard sub-buttons
|
||||
@ -2028,6 +2086,51 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
w.subButtonCount = sbIdx;
|
||||
}
|
||||
|
||||
// RoomCard text lines
|
||||
cJSON* textLines = cJSON_GetObjectItem(widget, "textLines");
|
||||
if (cJSON_IsArray(textLines)) {
|
||||
uint8_t tlIdx = 0;
|
||||
cJSON* tlItem = nullptr;
|
||||
cJSON_ArrayForEach(tlItem, textLines) {
|
||||
if (tlIdx >= MAX_TEXTLINES) break;
|
||||
|
||||
TextLineConfig& tl = w.textLines[tlIdx];
|
||||
memset(&tl, 0, sizeof(tl));
|
||||
tl.enabled = true;
|
||||
|
||||
cJSON* icon = cJSON_GetObjectItem(tlItem, "icon");
|
||||
if (cJSON_IsNumber(icon)) {
|
||||
tl.iconCodepoint = static_cast<uint32_t>(icon->valuedouble);
|
||||
}
|
||||
|
||||
cJSON* textSrc = cJSON_GetObjectItem(tlItem, "textSrc");
|
||||
if (cJSON_IsNumber(textSrc)) {
|
||||
tl.textSrc = static_cast<TextSource>(textSrc->valueint);
|
||||
}
|
||||
|
||||
cJSON* knxAddr = cJSON_GetObjectItem(tlItem, "knxAddr");
|
||||
if (cJSON_IsNumber(knxAddr)) {
|
||||
tl.knxAddr = knxAddr->valueint;
|
||||
}
|
||||
|
||||
cJSON* text = cJSON_GetObjectItem(tlItem, "text");
|
||||
if (cJSON_IsString(text)) {
|
||||
strncpy(tl.text, text->valuestring, MAX_FORMAT_LEN - 1);
|
||||
tl.text[MAX_FORMAT_LEN - 1] = '\0';
|
||||
}
|
||||
|
||||
cJSON* fontSize = cJSON_GetObjectItem(tlItem, "fontSize");
|
||||
if (cJSON_IsNumber(fontSize)) {
|
||||
tl.fontSize = fontSize->valueint;
|
||||
} else {
|
||||
tl.fontSize = 1; // Default 18px
|
||||
}
|
||||
|
||||
tlIdx++;
|
||||
}
|
||||
w.textLineCount = tlIdx;
|
||||
}
|
||||
|
||||
screen.widgetCount++;
|
||||
}
|
||||
|
||||
|
||||
144
main/widgets/RoomCardBubbleWidget.cpp
Normal file
144
main/widgets/RoomCardBubbleWidget.cpp
Normal file
@ -0,0 +1,144 @@
|
||||
#include "RoomCardBubbleWidget.hpp"
|
||||
#include "../Fonts.hpp"
|
||||
#include <cmath>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
RoomCardBubbleWidget::RoomCardBubbleWidget(const WidgetConfig& config)
|
||||
: RoomCardWidgetBase(config)
|
||||
{
|
||||
}
|
||||
|
||||
void RoomCardBubbleWidget::clearLvglObject() {
|
||||
RoomCardWidgetBase::clearLvglObject();
|
||||
bubble_ = nullptr;
|
||||
roomIcon_ = nullptr;
|
||||
}
|
||||
|
||||
lv_obj_t* RoomCardBubbleWidget::create(lv_obj_t* parent) {
|
||||
parseTextBase();
|
||||
|
||||
screenParent_ = parent;
|
||||
|
||||
// Create main container
|
||||
obj_ = lv_obj_create(parent);
|
||||
lv_obj_remove_style_all(obj_);
|
||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||
lv_obj_set_size(obj_, config_.width, config_.height);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Add click handler for navigation
|
||||
lv_obj_add_event_cb(obj_, cardClickCallback, LV_EVENT_CLICKED, this);
|
||||
|
||||
createBubbleLayout();
|
||||
createSubButtonsCommon();
|
||||
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void RoomCardBubbleWidget::createBubbleLayout() {
|
||||
// Calculate bubble size (80% of widget size, circular)
|
||||
int16_t minSide = config_.width < config_.height ? config_.width : config_.height;
|
||||
int16_t bubbleSize = (minSide * 80) / 100;
|
||||
|
||||
// Create bubble container (centered)
|
||||
bubble_ = lv_obj_create(obj_);
|
||||
lv_obj_remove_style_all(bubble_);
|
||||
lv_obj_set_size(bubble_, bubbleSize, bubbleSize);
|
||||
lv_obj_center(bubble_);
|
||||
lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Circular shape
|
||||
lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0);
|
||||
lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0);
|
||||
|
||||
// Set up flex layout for bubble content
|
||||
lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(bubble_, 8, 0);
|
||||
lv_obj_set_style_pad_gap(bubble_, 2, 0);
|
||||
|
||||
// Room icon
|
||||
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
roomIcon_ = lv_label_create(bubble_);
|
||||
lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(config_.iconCodepoint, iconText);
|
||||
lv_label_set_text(roomIcon_, iconText);
|
||||
}
|
||||
|
||||
// Temperature label
|
||||
tempLabel_ = lv_label_create(bubble_);
|
||||
lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : "");
|
||||
|
||||
// Room name label
|
||||
if (roomName_[0] != '\0') {
|
||||
roomLabel_ = lv_label_create(bubble_);
|
||||
lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(roomLabel_, roomName_);
|
||||
}
|
||||
|
||||
// Click handler for navigation
|
||||
lv_obj_add_event_cb(bubble_, cardClickCallback, LV_EVENT_CLICKED, this);
|
||||
}
|
||||
|
||||
void RoomCardBubbleWidget::calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) {
|
||||
int16_t centerX = config_.width / 2;
|
||||
int16_t centerY = config_.height / 2;
|
||||
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
|
||||
int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80;
|
||||
|
||||
// Calculate angle: position 0=TOP, going clockwise, 8 positions = 45 degrees each
|
||||
float angle = static_cast<float>(static_cast<uint8_t>(cfg.position)) * (M_PI / 4.0f) - (M_PI / 2.0f);
|
||||
|
||||
x = centerX + static_cast<int16_t>(orbitRadius * cosf(angle)) - subBtnSize / 2;
|
||||
y = centerY + static_cast<int16_t>(orbitRadius * sinf(angle)) - subBtnSize / 2;
|
||||
}
|
||||
|
||||
void RoomCardBubbleWidget::applyStyle() {
|
||||
if (!obj_) return;
|
||||
|
||||
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
|
||||
|
||||
// Apply shadow to bubble if enabled
|
||||
if (bubble_ && config_.shadow.enabled) {
|
||||
lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0);
|
||||
lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0);
|
||||
lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0);
|
||||
lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0);
|
||||
lv_obj_set_style_shadow_opa(bubble_, 255, 0);
|
||||
}
|
||||
|
||||
// Font sizes
|
||||
const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize);
|
||||
const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize);
|
||||
const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0);
|
||||
|
||||
// Style room icon
|
||||
if (roomIcon_ && iconFont) {
|
||||
lv_obj_set_style_text_font(roomIcon_, iconFont, 0);
|
||||
lv_obj_set_style_text_color(roomIcon_, textColor, 0);
|
||||
}
|
||||
|
||||
// Style temperature
|
||||
if (tempLabel_ && textFont) {
|
||||
lv_obj_set_style_text_font(tempLabel_, textFont, 0);
|
||||
lv_obj_set_style_text_color(tempLabel_, textColor, 0);
|
||||
}
|
||||
|
||||
// Style room label
|
||||
if (roomLabel_ && labelFont) {
|
||||
lv_obj_set_style_text_font(roomLabel_, labelFont, 0);
|
||||
lv_obj_set_style_text_color(roomLabel_, textColor, 0);
|
||||
lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed
|
||||
}
|
||||
|
||||
// Style sub-buttons
|
||||
applySubButtonStyle();
|
||||
}
|
||||
23
main/widgets/RoomCardBubbleWidget.hpp
Normal file
23
main/widgets/RoomCardBubbleWidget.hpp
Normal file
@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "RoomCardWidgetBase.hpp"
|
||||
|
||||
class RoomCardBubbleWidget : public RoomCardWidgetBase {
|
||||
public:
|
||||
explicit RoomCardBubbleWidget(const WidgetConfig& config);
|
||||
~RoomCardBubbleWidget() override = default;
|
||||
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
void clearLvglObject() override;
|
||||
|
||||
protected:
|
||||
void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) override;
|
||||
|
||||
private:
|
||||
// Bubble-specific elements
|
||||
lv_obj_t* bubble_ = nullptr;
|
||||
lv_obj_t* roomIcon_ = nullptr;
|
||||
|
||||
void createBubbleLayout();
|
||||
};
|
||||
222
main/widgets/RoomCardTileWidget.cpp
Normal file
222
main/widgets/RoomCardTileWidget.cpp
Normal file
@ -0,0 +1,222 @@
|
||||
#include "RoomCardTileWidget.hpp"
|
||||
#include "../Fonts.hpp"
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
RoomCardTileWidget::RoomCardTileWidget(const WidgetConfig& config)
|
||||
: RoomCardWidgetBase(config)
|
||||
{
|
||||
for (size_t i = 0; i < MAX_TEXTLINES; ++i) {
|
||||
textLineLabels_[i] = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardTileWidget::clearLvglObject() {
|
||||
RoomCardWidgetBase::clearLvglObject();
|
||||
decorIcon_ = nullptr;
|
||||
for (size_t i = 0; i < MAX_TEXTLINES; ++i) {
|
||||
textLineLabels_[i] = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
lv_obj_t* RoomCardTileWidget::create(lv_obj_t* parent) {
|
||||
parseTextBase(); // Parses roomName_ from first line of text
|
||||
|
||||
screenParent_ = parent;
|
||||
|
||||
// Create main container
|
||||
obj_ = lv_obj_create(parent);
|
||||
lv_obj_remove_style_all(obj_);
|
||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||
lv_obj_set_size(obj_, config_.width, config_.height);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Add click handler for navigation
|
||||
lv_obj_add_event_cb(obj_, cardClickCallback, LV_EVENT_CLICKED, this);
|
||||
|
||||
createTileLayout();
|
||||
createTextLines();
|
||||
createSubButtonsCommon();
|
||||
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void RoomCardTileWidget::createTileLayout() {
|
||||
// Tile style: rectangular card with rounded corners
|
||||
int16_t radius = config_.borderRadius;
|
||||
if (radius <= 0 || radius > 50) {
|
||||
radius = 16;
|
||||
}
|
||||
lv_obj_set_style_radius(obj_, radius, 0);
|
||||
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
|
||||
lv_obj_set_style_bg_color(obj_, lv_color_hex(config_.bgColor.toLvColor()), 0);
|
||||
lv_obj_set_style_clip_corner(obj_, true, 0);
|
||||
|
||||
int16_t padding = 16;
|
||||
|
||||
// Room name (top-left)
|
||||
roomLabel_ = lv_label_create(obj_);
|
||||
lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(roomLabel_, roomName_);
|
||||
lv_obj_set_pos(roomLabel_, padding, padding);
|
||||
|
||||
// Large decorative icon (bottom-left, partially visible)
|
||||
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
decorIcon_ = lv_label_create(obj_);
|
||||
lv_obj_clear_flag(decorIcon_, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(config_.iconCodepoint, iconText);
|
||||
lv_label_set_text(decorIcon_, iconText);
|
||||
|
||||
int16_t iconX = (config_.iconPositionX != 0) ? config_.iconPositionX : -20;
|
||||
int16_t iconY = (config_.iconPositionY != 0) ? config_.iconPositionY : config_.height - 120;
|
||||
lv_obj_set_pos(decorIcon_, iconX, iconY);
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardTileWidget::createTextLines() {
|
||||
if (config_.textLineCount == 0) return;
|
||||
|
||||
int16_t padding = 16;
|
||||
int16_t currentY = 48; // Below room name
|
||||
|
||||
for (uint8_t i = 0; i < config_.textLineCount && i < MAX_TEXTLINES; ++i) {
|
||||
const TextLineConfig& tl = config_.textLines[i];
|
||||
if (!tl.enabled) continue;
|
||||
|
||||
// Get font for this line
|
||||
uint8_t fontIdx = tl.fontSize;
|
||||
if (fontIdx > 5) fontIdx = 1; // Default to 18px
|
||||
const lv_font_t* lineFont = Fonts::bySizeIndex(fontIdx);
|
||||
const lv_font_t* iconFont = Fonts::iconFont(fontIdx);
|
||||
|
||||
// Create a row container
|
||||
lv_obj_t* row = lv_obj_create(obj_);
|
||||
lv_obj_remove_style_all(row);
|
||||
lv_obj_set_size(row, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_pos(row, padding, currentY);
|
||||
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_gap(row, 6, 0);
|
||||
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_clear_flag(row, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Create icon if set
|
||||
if (tl.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
lv_obj_t* icon = lv_label_create(row);
|
||||
lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(tl.iconCodepoint, iconText);
|
||||
lv_label_set_text(icon, iconText);
|
||||
if (iconFont) {
|
||||
lv_obj_set_style_text_font(icon, iconFont, 0);
|
||||
}
|
||||
lv_obj_set_style_text_color(icon, lv_color_hex(config_.textColor.toLvColor()), 0);
|
||||
}
|
||||
|
||||
// Create value label
|
||||
lv_obj_t* label = lv_label_create(row);
|
||||
lv_obj_clear_flag(label, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Set initial text based on source
|
||||
if (tl.textSrc == TextSource::STATIC) {
|
||||
lv_label_set_text(label, tl.text);
|
||||
} else {
|
||||
lv_label_set_text(label, "--");
|
||||
}
|
||||
|
||||
if (lineFont) {
|
||||
lv_obj_set_style_text_font(label, lineFont, 0);
|
||||
}
|
||||
lv_obj_set_style_text_color(label, lv_color_hex(config_.textColor.toLvColor()), 0);
|
||||
|
||||
textLineLabels_[i] = label;
|
||||
|
||||
// Move Y position for next line
|
||||
currentY += lv_font_get_line_height(lineFont) + 8;
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardTileWidget::calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) {
|
||||
(void)cfg; // Not used for Tile layout
|
||||
|
||||
// Buttons are stacked vertically on the right side
|
||||
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
|
||||
int16_t gap = 10;
|
||||
int16_t padding = 12;
|
||||
|
||||
x = config_.width - subBtnSize - padding;
|
||||
y = padding + index * (subBtnSize + gap);
|
||||
}
|
||||
|
||||
void RoomCardTileWidget::updateTextLineValue(uint8_t index, float value) {
|
||||
if (index >= MAX_TEXTLINES || !textLineLabels_[index]) return;
|
||||
|
||||
const TextLineConfig& tl = config_.textLines[index];
|
||||
if (tl.textSrc == TextSource::STATIC) return;
|
||||
|
||||
char buf[32];
|
||||
if (tl.text[0] != '\0') {
|
||||
snprintf(buf, sizeof(buf), tl.text, value);
|
||||
} else {
|
||||
// Default formats based on source type
|
||||
switch (tl.textSrc) {
|
||||
case TextSource::KNX_DPT_TEMP:
|
||||
snprintf(buf, sizeof(buf), "%.1f C", value);
|
||||
break;
|
||||
case TextSource::KNX_DPT_PERCENT:
|
||||
snprintf(buf, sizeof(buf), "%.0f %%", value);
|
||||
break;
|
||||
case TextSource::KNX_DPT_POWER:
|
||||
snprintf(buf, sizeof(buf), "%.1f W", value);
|
||||
break;
|
||||
case TextSource::KNX_DPT_ENERGY:
|
||||
snprintf(buf, sizeof(buf), "%.0f kWh", value);
|
||||
break;
|
||||
default:
|
||||
snprintf(buf, sizeof(buf), "%.1f", value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
lv_label_set_text(textLineLabels_[index], buf);
|
||||
}
|
||||
|
||||
void RoomCardTileWidget::onTextLineValue(uint8_t index, float value) {
|
||||
updateTextLineValue(index, value);
|
||||
}
|
||||
|
||||
void RoomCardTileWidget::applyStyle() {
|
||||
if (!obj_) return;
|
||||
|
||||
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
|
||||
|
||||
// Apply border if shadow is enabled (used as accent border)
|
||||
if (config_.shadow.enabled) {
|
||||
lv_obj_set_style_border_width(obj_, 3, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_hex(config_.shadow.color.toLvColor()), 0);
|
||||
lv_obj_set_style_border_opa(obj_, 255, 0);
|
||||
}
|
||||
|
||||
// Room name - large font
|
||||
if (roomLabel_) {
|
||||
const lv_font_t* nameFont = Fonts::bySizeIndex(config_.fontSize);
|
||||
lv_obj_set_style_text_font(roomLabel_, nameFont, 0);
|
||||
lv_obj_set_style_text_color(roomLabel_, textColor, 0);
|
||||
}
|
||||
|
||||
// Large decorative icon
|
||||
if (decorIcon_) {
|
||||
uint8_t decorIconIdx = config_.iconSize;
|
||||
if (decorIconIdx < 6) decorIconIdx = 8; // Default to 96px for decorative
|
||||
const lv_font_t* bigIconFont = Fonts::iconFont(decorIconIdx);
|
||||
if (bigIconFont) {
|
||||
lv_obj_set_style_text_font(decorIcon_, bigIconFont, 0);
|
||||
lv_obj_set_style_text_color(decorIcon_, textColor, 0);
|
||||
lv_obj_set_style_text_opa(decorIcon_, 50, 0); // Very transparent
|
||||
}
|
||||
}
|
||||
|
||||
// Style sub-buttons
|
||||
applySubButtonStyle();
|
||||
}
|
||||
30
main/widgets/RoomCardTileWidget.hpp
Normal file
30
main/widgets/RoomCardTileWidget.hpp
Normal file
@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "RoomCardWidgetBase.hpp"
|
||||
|
||||
class RoomCardTileWidget : public RoomCardWidgetBase {
|
||||
public:
|
||||
explicit RoomCardTileWidget(const WidgetConfig& config);
|
||||
~RoomCardTileWidget() override = default;
|
||||
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
void clearLvglObject() override;
|
||||
|
||||
// Text line KNX updates
|
||||
void onTextLineValue(uint8_t index, float value);
|
||||
|
||||
protected:
|
||||
void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) override;
|
||||
|
||||
private:
|
||||
// Tile-specific elements
|
||||
lv_obj_t* decorIcon_ = nullptr;
|
||||
|
||||
// Text line elements (rows with icon + label)
|
||||
lv_obj_t* textLineLabels_[MAX_TEXTLINES] = {};
|
||||
|
||||
void createTileLayout();
|
||||
void createTextLines();
|
||||
void updateTextLineValue(uint8_t index, float value);
|
||||
};
|
||||
@ -1,622 +0,0 @@
|
||||
#include "RoomCardWidget.hpp"
|
||||
#include "../Fonts.hpp"
|
||||
#include "../WidgetManager.hpp"
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
RoomCardWidget::RoomCardWidget(const WidgetConfig& config)
|
||||
: Widget(config)
|
||||
{
|
||||
roomName_[0] = '\0';
|
||||
tempFormat_[0] = '\0';
|
||||
subtitle_[0] = '\0';
|
||||
humidityFormat_[0] = '\0';
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
subButtonStates_[i] = false;
|
||||
}
|
||||
}
|
||||
|
||||
RoomCardWidget::~RoomCardWidget() {
|
||||
// Sub-buttons are on screen parent, not on obj_, so delete them explicitly
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
if (subButtonObjs_[i] && lv_obj_is_valid(subButtonObjs_[i])) {
|
||||
lv_obj_delete(subButtonObjs_[i]);
|
||||
subButtonObjs_[i] = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::clearLvglObject() {
|
||||
// Clear sub-button pointers (they'll be deleted when screen is cleaned)
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
subButtonObjs_[i] = nullptr;
|
||||
subButtonIcons_[i] = nullptr;
|
||||
}
|
||||
bubble_ = nullptr;
|
||||
roomIcon_ = nullptr;
|
||||
roomLabel_ = nullptr;
|
||||
tempLabel_ = nullptr;
|
||||
subtitleLabel_ = nullptr;
|
||||
humidityLabel_ = nullptr;
|
||||
decorIcon_ = nullptr;
|
||||
obj_ = nullptr;
|
||||
}
|
||||
|
||||
int RoomCardWidget::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 RoomCardWidget::parseText() {
|
||||
roomName_[0] = '\0';
|
||||
tempFormat_[0] = '\0';
|
||||
subtitle_[0] = '\0';
|
||||
humidityFormat_[0] = '\0';
|
||||
|
||||
// Format for Bubble: "RoomName\nTempFormat"
|
||||
// Format for Tile: "RoomName\nSubtitle" (temp/humidity via text2/text3)
|
||||
const char* text = config_.text;
|
||||
if (text && text[0] != '\0') {
|
||||
const char* newline = strchr(text, '\n');
|
||||
if (newline) {
|
||||
size_t nameLen = static_cast<size_t>(newline - text);
|
||||
if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1;
|
||||
memcpy(roomName_, text, nameLen);
|
||||
roomName_[nameLen] = '\0';
|
||||
|
||||
if (config_.cardStyle == 0) {
|
||||
// Bubble: second line is temp format
|
||||
strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1);
|
||||
tempFormat_[MAX_TEXT_LEN - 1] = '\0';
|
||||
} else {
|
||||
// Tile: second line is subtitle
|
||||
strncpy(subtitle_, newline + 1, MAX_TEXT_LEN - 1);
|
||||
subtitle_[MAX_TEXT_LEN - 1] = '\0';
|
||||
}
|
||||
} else {
|
||||
strncpy(roomName_, text, MAX_TEXT_LEN - 1);
|
||||
roomName_[MAX_TEXT_LEN - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// For Tile style, use text2 for temp format and text3 for humidity
|
||||
if (config_.cardStyle == 1) {
|
||||
if (config_.text2[0] != '\0') {
|
||||
strncpy(tempFormat_, config_.text2, MAX_FORMAT_LEN - 1);
|
||||
tempFormat_[MAX_FORMAT_LEN - 1] = '\0';
|
||||
}
|
||||
if (config_.text3[0] != '\0') {
|
||||
strncpy(humidityFormat_, config_.text3, MAX_FORMAT_LEN - 1);
|
||||
humidityFormat_[MAX_FORMAT_LEN - 1] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y) {
|
||||
int16_t centerX = config_.width / 2;
|
||||
int16_t centerY = config_.height / 2;
|
||||
|
||||
// Sub-button size (configurable, default 40)
|
||||
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
|
||||
|
||||
// Sub-buttons orbit radius in pixels (default 80)
|
||||
int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80;
|
||||
|
||||
// Calculate angle: 0=TOP, going clockwise, 8 positions = 45 degrees each
|
||||
float angle = static_cast<float>(static_cast<uint8_t>(pos)) * (M_PI / 4.0f) - (M_PI / 2.0f);
|
||||
|
||||
// Calculate position (center of sub-button)
|
||||
x = centerX + static_cast<int16_t>(orbitRadius * cosf(angle)) - subBtnSize / 2;
|
||||
y = centerY + static_cast<int16_t>(orbitRadius * sinf(angle)) - subBtnSize / 2;
|
||||
}
|
||||
|
||||
void RoomCardWidget::calculateSubButtonPositionTile(uint8_t index, int16_t& x, int16_t& y) {
|
||||
// For Tile layout: buttons are stacked vertically on the right side
|
||||
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
|
||||
int16_t gap = 10; // Gap between buttons
|
||||
int16_t padding = 12; // Padding from edge
|
||||
|
||||
x = config_.width - subBtnSize - padding;
|
||||
y = padding + index * (subBtnSize + gap);
|
||||
}
|
||||
|
||||
lv_obj_t* RoomCardWidget::create(lv_obj_t* parent) {
|
||||
parseText();
|
||||
|
||||
// Store parent for sub-buttons (they're created on screen level to avoid clipping)
|
||||
screenParent_ = parent;
|
||||
|
||||
// Create main container
|
||||
obj_ = lv_obj_create(parent);
|
||||
lv_obj_remove_style_all(obj_);
|
||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||
lv_obj_set_size(obj_, config_.width, config_.height);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Add click handler for navigation
|
||||
lv_obj_add_event_cb(obj_, bubbleClickCallback, LV_EVENT_CLICKED, this);
|
||||
|
||||
// Create layout based on card style
|
||||
if (config_.cardStyle == 1) {
|
||||
createTileLayout();
|
||||
createSubButtonsTile();
|
||||
} else {
|
||||
createBubbleLayout();
|
||||
createSubButtons();
|
||||
}
|
||||
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void RoomCardWidget::createBubbleLayout() {
|
||||
// Calculate bubble size (80% of widget size, circular)
|
||||
int16_t minSide = config_.width < config_.height ? config_.width : config_.height;
|
||||
int16_t bubbleSize = (minSide * 80) / 100;
|
||||
|
||||
// Create bubble container (centered)
|
||||
bubble_ = lv_obj_create(obj_);
|
||||
lv_obj_remove_style_all(bubble_);
|
||||
lv_obj_set_size(bubble_, bubbleSize, bubbleSize);
|
||||
lv_obj_center(bubble_);
|
||||
lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Circular shape
|
||||
lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0);
|
||||
lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0);
|
||||
|
||||
// Set up flex layout for bubble content
|
||||
lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(bubble_, 8, 0);
|
||||
lv_obj_set_style_pad_gap(bubble_, 2, 0);
|
||||
|
||||
// Room icon
|
||||
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
roomIcon_ = lv_label_create(bubble_);
|
||||
lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(config_.iconCodepoint, iconText);
|
||||
lv_label_set_text(roomIcon_, iconText);
|
||||
}
|
||||
|
||||
// Temperature label
|
||||
tempLabel_ = lv_label_create(bubble_);
|
||||
lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : "");
|
||||
|
||||
// Room name label
|
||||
if (roomName_[0] != '\0') {
|
||||
roomLabel_ = lv_label_create(bubble_);
|
||||
lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(roomLabel_, roomName_);
|
||||
}
|
||||
|
||||
// Click handler for navigation
|
||||
lv_obj_add_event_cb(bubble_, bubbleClickCallback, LV_EVENT_CLICKED, this);
|
||||
}
|
||||
|
||||
void RoomCardWidget::createTileLayout() {
|
||||
// Tile style: rectangular card with rounded corners
|
||||
lv_obj_set_style_radius(obj_, config_.borderRadius > 0 ? config_.borderRadius : 16, 0);
|
||||
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
|
||||
lv_obj_set_style_bg_color(obj_, lv_color_hex(config_.bgColor.toLvColor()), 0);
|
||||
lv_obj_set_style_clip_corner(obj_, true, 0);
|
||||
|
||||
int16_t padding = 16;
|
||||
|
||||
// Room name (top-left, large)
|
||||
roomLabel_ = lv_label_create(obj_);
|
||||
lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(roomLabel_, roomName_);
|
||||
lv_obj_set_pos(roomLabel_, padding, padding);
|
||||
|
||||
// Subtitle (below room name, smaller)
|
||||
if (subtitle_[0] != '\0') {
|
||||
subtitleLabel_ = lv_label_create(obj_);
|
||||
lv_obj_clear_flag(subtitleLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(subtitleLabel_, subtitle_);
|
||||
lv_obj_set_pos(subtitleLabel_, padding, padding + 28);
|
||||
}
|
||||
|
||||
// Temperature row (with thermometer icon)
|
||||
if (tempFormat_[0] != '\0') {
|
||||
// Container for temp icon + value
|
||||
lv_obj_t* tempRow = lv_obj_create(obj_);
|
||||
lv_obj_remove_style_all(tempRow);
|
||||
lv_obj_set_size(tempRow, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_pos(tempRow, padding, padding + 56);
|
||||
lv_obj_set_flex_flow(tempRow, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(tempRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_gap(tempRow, 4, 0);
|
||||
lv_obj_clear_flag(tempRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_clear_flag(tempRow, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Thermometer icon (U+E1FF or similar)
|
||||
if (Fonts::hasIconFont()) {
|
||||
lv_obj_t* tempIcon = lv_label_create(tempRow);
|
||||
lv_obj_clear_flag(tempIcon, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(0xf076, iconText); // thermometer icon
|
||||
lv_label_set_text(tempIcon, iconText);
|
||||
}
|
||||
|
||||
tempLabel_ = lv_label_create(tempRow);
|
||||
lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(tempLabel_, "--");
|
||||
}
|
||||
|
||||
// Humidity row (with water drop icon)
|
||||
if (humidityFormat_[0] != '\0') {
|
||||
lv_obj_t* humRow = lv_obj_create(obj_);
|
||||
lv_obj_remove_style_all(humRow);
|
||||
lv_obj_set_size(humRow, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_pos(humRow, padding + 80, padding + 56); // Next to temp
|
||||
lv_obj_set_flex_flow(humRow, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(humRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_gap(humRow, 4, 0);
|
||||
lv_obj_clear_flag(humRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_clear_flag(humRow, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Water drop icon
|
||||
if (Fonts::hasIconFont()) {
|
||||
lv_obj_t* humIcon = lv_label_create(humRow);
|
||||
lv_obj_clear_flag(humIcon, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(0xe798, iconText); // humidity/water icon
|
||||
lv_label_set_text(humIcon, iconText);
|
||||
}
|
||||
|
||||
humidityLabel_ = lv_label_create(humRow);
|
||||
lv_obj_clear_flag(humidityLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_label_set_text(humidityLabel_, "--");
|
||||
}
|
||||
|
||||
// Large decorative icon (bottom-left, partially visible)
|
||||
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
decorIcon_ = lv_label_create(obj_);
|
||||
lv_obj_clear_flag(decorIcon_, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(config_.iconCodepoint, iconText);
|
||||
lv_label_set_text(decorIcon_, iconText);
|
||||
// Position at bottom-left, slightly outside
|
||||
lv_obj_set_pos(decorIcon_, -20, config_.height - 100);
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::createSubButtons() {
|
||||
// Sub-button size (configurable, default 40)
|
||||
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
|
||||
|
||||
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
|
||||
const SubButtonConfig& cfg = config_.subButtons[i];
|
||||
if (!cfg.enabled) continue;
|
||||
|
||||
// Create sub-button on screen parent (not on widget container) to avoid clipping
|
||||
lv_obj_t* btn = lv_obj_create(screenParent_);
|
||||
lv_obj_remove_style_all(btn);
|
||||
lv_obj_set_size(btn, subBtnSize, subBtnSize);
|
||||
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Circular shape
|
||||
lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0);
|
||||
|
||||
// Position using circle geometry (relative to widget center)
|
||||
int16_t relX, relY;
|
||||
calculateSubButtonPosition(cfg.position, relX, relY);
|
||||
// Convert to absolute screen position
|
||||
int16_t absX = config_.x + relX;
|
||||
int16_t absY = config_.y + relY;
|
||||
lv_obj_set_pos(btn, absX, absY);
|
||||
|
||||
// Create icon
|
||||
if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
lv_obj_t* icon = lv_label_create(btn);
|
||||
lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(cfg.iconCodepoint, iconText);
|
||||
lv_label_set_text(icon, iconText);
|
||||
lv_obj_center(icon);
|
||||
subButtonIcons_[i] = icon;
|
||||
}
|
||||
|
||||
// Store index in user_data for click handler
|
||||
lv_obj_set_user_data(btn, reinterpret_cast<void*>(static_cast<uintptr_t>(i)));
|
||||
lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this);
|
||||
|
||||
subButtonObjs_[i] = btn;
|
||||
subButtonStates_[i] = false;
|
||||
|
||||
// Apply initial color (OFF state)
|
||||
updateSubButtonColor(i);
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::createSubButtonsTile() {
|
||||
// For Tile layout: buttons are positioned on the right side, vertically stacked
|
||||
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
|
||||
|
||||
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
|
||||
const SubButtonConfig& cfg = config_.subButtons[i];
|
||||
if (!cfg.enabled) continue;
|
||||
|
||||
// Create sub-button on screen parent
|
||||
lv_obj_t* btn = lv_obj_create(screenParent_);
|
||||
lv_obj_remove_style_all(btn);
|
||||
lv_obj_set_size(btn, subBtnSize, subBtnSize);
|
||||
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Circular shape
|
||||
lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0);
|
||||
|
||||
// Position on right side
|
||||
int16_t relX, relY;
|
||||
calculateSubButtonPositionTile(i, relX, relY);
|
||||
int16_t absX = config_.x + relX;
|
||||
int16_t absY = config_.y + relY;
|
||||
lv_obj_set_pos(btn, absX, absY);
|
||||
|
||||
// Create icon
|
||||
if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
lv_obj_t* icon = lv_label_create(btn);
|
||||
lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(cfg.iconCodepoint, iconText);
|
||||
lv_label_set_text(icon, iconText);
|
||||
lv_obj_center(icon);
|
||||
subButtonIcons_[i] = icon;
|
||||
}
|
||||
|
||||
// Store index in user_data for click handler
|
||||
lv_obj_set_user_data(btn, reinterpret_cast<void*>(static_cast<uintptr_t>(i)));
|
||||
lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this);
|
||||
|
||||
subButtonObjs_[i] = btn;
|
||||
subButtonStates_[i] = false;
|
||||
|
||||
// Apply initial color (OFF state)
|
||||
updateSubButtonColor(i);
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::applyStyle() {
|
||||
if (!obj_) return;
|
||||
|
||||
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
|
||||
|
||||
if (config_.cardStyle == 1) {
|
||||
// Tile style
|
||||
// Apply border if shadow is enabled (used as accent border)
|
||||
if (config_.shadow.enabled) {
|
||||
lv_obj_set_style_border_width(obj_, 3, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_hex(config_.shadow.color.toLvColor()), 0);
|
||||
lv_obj_set_style_border_opa(obj_, 255, 0);
|
||||
}
|
||||
|
||||
// Room name - large font
|
||||
if (roomLabel_) {
|
||||
const lv_font_t* nameFont = Fonts::bySizeIndex(config_.fontSize);
|
||||
lv_obj_set_style_text_font(roomLabel_, nameFont, 0);
|
||||
lv_obj_set_style_text_color(roomLabel_, textColor, 0);
|
||||
}
|
||||
|
||||
// Subtitle - smaller, dimmed
|
||||
if (subtitleLabel_) {
|
||||
const lv_font_t* subFont = Fonts::bySizeIndex(config_.fontSize > 1 ? config_.fontSize - 2 : 0);
|
||||
lv_obj_set_style_text_font(subtitleLabel_, subFont, 0);
|
||||
lv_obj_set_style_text_color(subtitleLabel_, textColor, 0);
|
||||
lv_obj_set_style_text_opa(subtitleLabel_, 150, 0);
|
||||
}
|
||||
|
||||
// Temp and humidity - medium font
|
||||
const lv_font_t* valueFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0);
|
||||
const lv_font_t* iconFont = Fonts::iconFont(1); // Small icons
|
||||
|
||||
if (tempLabel_) {
|
||||
lv_obj_set_style_text_font(tempLabel_, valueFont, 0);
|
||||
lv_obj_set_style_text_color(tempLabel_, textColor, 0);
|
||||
// Style the icon in the same row
|
||||
lv_obj_t* parent = lv_obj_get_parent(tempLabel_);
|
||||
if (parent && lv_obj_get_child_count(parent) > 0) {
|
||||
lv_obj_t* iconLabel = lv_obj_get_child(parent, 0);
|
||||
if (iconLabel && iconFont) {
|
||||
lv_obj_set_style_text_font(iconLabel, iconFont, 0);
|
||||
lv_obj_set_style_text_color(iconLabel, textColor, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (humidityLabel_) {
|
||||
lv_obj_set_style_text_font(humidityLabel_, valueFont, 0);
|
||||
lv_obj_set_style_text_color(humidityLabel_, textColor, 0);
|
||||
lv_obj_t* parent = lv_obj_get_parent(humidityLabel_);
|
||||
if (parent && lv_obj_get_child_count(parent) > 0) {
|
||||
lv_obj_t* iconLabel = lv_obj_get_child(parent, 0);
|
||||
if (iconLabel && iconFont) {
|
||||
lv_obj_set_style_text_font(iconLabel, iconFont, 0);
|
||||
lv_obj_set_style_text_color(iconLabel, textColor, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Large decorative icon
|
||||
if (decorIcon_) {
|
||||
const lv_font_t* bigIconFont = Fonts::iconFont(5); // Largest icon
|
||||
if (bigIconFont) {
|
||||
lv_obj_set_style_text_font(decorIcon_, bigIconFont, 0);
|
||||
lv_obj_set_style_text_color(decorIcon_, textColor, 0);
|
||||
lv_obj_set_style_text_opa(decorIcon_, 60, 0); // Very transparent
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Bubble style
|
||||
// Apply shadow to bubble if enabled
|
||||
if (bubble_ && config_.shadow.enabled) {
|
||||
lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0);
|
||||
lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0);
|
||||
lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0);
|
||||
lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0);
|
||||
lv_obj_set_style_shadow_opa(bubble_, 255, 0);
|
||||
}
|
||||
|
||||
// Font sizes
|
||||
const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize);
|
||||
const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize);
|
||||
const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0);
|
||||
|
||||
// Style room icon
|
||||
if (roomIcon_ && iconFont) {
|
||||
lv_obj_set_style_text_font(roomIcon_, iconFont, 0);
|
||||
lv_obj_set_style_text_color(roomIcon_, textColor, 0);
|
||||
}
|
||||
|
||||
// Style temperature
|
||||
if (tempLabel_ && textFont) {
|
||||
lv_obj_set_style_text_font(tempLabel_, textFont, 0);
|
||||
lv_obj_set_style_text_color(tempLabel_, textColor, 0);
|
||||
}
|
||||
|
||||
// Style room label
|
||||
if (roomLabel_ && labelFont) {
|
||||
lv_obj_set_style_text_font(roomLabel_, labelFont, 0);
|
||||
lv_obj_set_style_text_color(roomLabel_, textColor, 0);
|
||||
lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed
|
||||
}
|
||||
}
|
||||
|
||||
// Style sub-buttons - adjust icon size based on button size
|
||||
uint8_t subBtnFontIdx = 1; // Default small
|
||||
if (config_.subButtonSize > 55) {
|
||||
subBtnFontIdx = 3;
|
||||
} else if (config_.subButtonSize > 40) {
|
||||
subBtnFontIdx = 2;
|
||||
}
|
||||
const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx);
|
||||
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
|
||||
if (subButtonIcons_[i] && subBtnIconFont) {
|
||||
lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0);
|
||||
lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::updateSubButtonColor(uint8_t index) {
|
||||
if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return;
|
||||
|
||||
const SubButtonConfig& cfg = config_.subButtons[index];
|
||||
const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff;
|
||||
|
||||
lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0);
|
||||
}
|
||||
|
||||
void RoomCardWidget::updateTemperature(float value) {
|
||||
if (!tempLabel_ || tempFormat_[0] == '\0') return;
|
||||
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), tempFormat_, value);
|
||||
lv_label_set_text(tempLabel_, buf);
|
||||
}
|
||||
|
||||
void RoomCardWidget::updateHumidity(float value) {
|
||||
if (!humidityLabel_ || humidityFormat_[0] == '\0') return;
|
||||
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), humidityFormat_, value);
|
||||
lv_label_set_text(humidityLabel_, buf);
|
||||
}
|
||||
|
||||
void RoomCardWidget::onKnxValue(float value) {
|
||||
cachedPrimaryValue_ = value;
|
||||
hasCachedPrimary_ = true;
|
||||
updateTemperature(value);
|
||||
}
|
||||
|
||||
void RoomCardWidget::onKnxSwitch(bool value) {
|
||||
// Not used directly - sub-button status is handled via onSubButtonStatus
|
||||
(void)value;
|
||||
}
|
||||
|
||||
void RoomCardWidget::onSubButtonStatus(uint8_t index, bool value) {
|
||||
if (index >= MAX_SUBBUTTONS) return;
|
||||
|
||||
subButtonStates_[index] = value;
|
||||
updateSubButtonColor(index);
|
||||
}
|
||||
|
||||
void RoomCardWidget::sendSubButtonToggle(uint8_t index) {
|
||||
if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return;
|
||||
|
||||
const SubButtonConfig& cfg = config_.subButtons[index];
|
||||
if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) {
|
||||
bool newState = !subButtonStates_[index];
|
||||
// Send KNX toggle via WidgetManager
|
||||
WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState);
|
||||
// Optimistically update local state
|
||||
subButtonStates_[index] = newState;
|
||||
updateSubButtonColor(index);
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::bubbleClickCallback(lv_event_t* e) {
|
||||
RoomCardWidget* widget = static_cast<RoomCardWidget*>(lv_event_get_user_data(e));
|
||||
if (!widget) return;
|
||||
|
||||
// Handle navigation based on action
|
||||
if (widget->config_.action == ButtonAction::JUMP) {
|
||||
WidgetManager::instance().navigateToScreen(widget->config_.targetScreen);
|
||||
} else if (widget->config_.action == ButtonAction::BACK) {
|
||||
WidgetManager::instance().navigateBack();
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidget::subButtonClickCallback(lv_event_t* e) {
|
||||
RoomCardWidget* widget = static_cast<RoomCardWidget*>(lv_event_get_user_data(e));
|
||||
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||
if (!widget || !target) return;
|
||||
|
||||
uint8_t index = static_cast<uint8_t>(reinterpret_cast<uintptr_t>(lv_obj_get_user_data(target)));
|
||||
|
||||
if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) {
|
||||
const SubButtonConfig& cfg = widget->config_.subButtons[index];
|
||||
|
||||
if (cfg.action == SubButtonAction::TOGGLE_KNX) {
|
||||
widget->sendSubButtonToggle(index);
|
||||
} else if (cfg.action == SubButtonAction::NAVIGATE) {
|
||||
WidgetManager::instance().navigateToScreen(cfg.targetScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
class RoomCardWidget : public Widget {
|
||||
public:
|
||||
explicit RoomCardWidget(const WidgetConfig& config);
|
||||
~RoomCardWidget() override;
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
void onKnxValue(float value) override; // Temperature update
|
||||
void onKnxSwitch(bool value) override; // Not used directly
|
||||
|
||||
// Sub-button specific handling
|
||||
void onSubButtonStatus(uint8_t index, bool value);
|
||||
void sendSubButtonToggle(uint8_t index);
|
||||
|
||||
// Override to also clear sub-buttons
|
||||
void clearLvglObject();
|
||||
|
||||
private:
|
||||
// Parent (screen) for sub-buttons to avoid clipping
|
||||
lv_obj_t* screenParent_ = nullptr;
|
||||
|
||||
// Bubble style elements (cardStyle=0)
|
||||
lv_obj_t* bubble_ = nullptr;
|
||||
lv_obj_t* roomIcon_ = nullptr;
|
||||
lv_obj_t* roomLabel_ = nullptr;
|
||||
lv_obj_t* tempLabel_ = nullptr;
|
||||
|
||||
// Tile style elements (cardStyle=1)
|
||||
lv_obj_t* subtitleLabel_ = nullptr;
|
||||
lv_obj_t* humidityLabel_ = nullptr;
|
||||
lv_obj_t* decorIcon_ = nullptr; // Large decorative icon bottom-left
|
||||
|
||||
// Sub-button elements
|
||||
lv_obj_t* subButtonObjs_[MAX_SUBBUTTONS] = {};
|
||||
lv_obj_t* subButtonIcons_[MAX_SUBBUTTONS] = {};
|
||||
bool subButtonStates_[MAX_SUBBUTTONS] = {};
|
||||
|
||||
// Cached config values
|
||||
char roomName_[MAX_TEXT_LEN] = {0};
|
||||
char tempFormat_[MAX_TEXT_LEN] = {0};
|
||||
char subtitle_[MAX_TEXT_LEN] = {0};
|
||||
char humidityFormat_[MAX_FORMAT_LEN] = {0};
|
||||
|
||||
// Layout helpers
|
||||
void parseText();
|
||||
void createBubbleLayout(); // Bubble style (round)
|
||||
void createTileLayout(); // Tile style (rectangular)
|
||||
void createSubButtons();
|
||||
void createSubButtonsTile(); // SubButtons for tile (right side)
|
||||
void updateSubButtonColor(uint8_t index);
|
||||
void updateTemperature(float value);
|
||||
void updateHumidity(float value);
|
||||
|
||||
// Geometry calculations
|
||||
void calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y);
|
||||
void calculateSubButtonPositionTile(uint8_t index, int16_t& x, int16_t& y);
|
||||
|
||||
// Event handlers
|
||||
static void bubbleClickCallback(lv_event_t* e);
|
||||
static void subButtonClickCallback(lv_event_t* e);
|
||||
|
||||
// UTF-8 encoding helper
|
||||
static int encodeUtf8(uint32_t codepoint, char* buf);
|
||||
};
|
||||
231
main/widgets/RoomCardWidgetBase.cpp
Normal file
231
main/widgets/RoomCardWidgetBase.cpp
Normal file
@ -0,0 +1,231 @@
|
||||
#include "RoomCardWidgetBase.hpp"
|
||||
#include "../Fonts.hpp"
|
||||
#include "../WidgetManager.hpp"
|
||||
#include "esp_log.h"
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
RoomCardWidgetBase::RoomCardWidgetBase(const WidgetConfig& config)
|
||||
: Widget(config)
|
||||
{
|
||||
roomName_[0] = '\0';
|
||||
tempFormat_[0] = '\0';
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
subButtonStates_[i] = false;
|
||||
}
|
||||
}
|
||||
|
||||
RoomCardWidgetBase::~RoomCardWidgetBase() {
|
||||
// Sub-buttons are on screen parent, not on obj_, so delete them explicitly
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
if (subButtonObjs_[i] && lv_obj_is_valid(subButtonObjs_[i])) {
|
||||
lv_obj_delete(subButtonObjs_[i]);
|
||||
subButtonObjs_[i] = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::clearLvglObject() {
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
subButtonObjs_[i] = nullptr;
|
||||
subButtonIcons_[i] = nullptr;
|
||||
}
|
||||
roomLabel_ = nullptr;
|
||||
tempLabel_ = nullptr;
|
||||
obj_ = nullptr;
|
||||
}
|
||||
|
||||
int RoomCardWidgetBase::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 RoomCardWidgetBase::parseTextBase() {
|
||||
roomName_[0] = '\0';
|
||||
tempFormat_[0] = '\0';
|
||||
|
||||
const char* text = config_.text;
|
||||
if (text && text[0] != '\0') {
|
||||
const char* newline = strchr(text, '\n');
|
||||
if (newline) {
|
||||
size_t nameLen = static_cast<size_t>(newline - text);
|
||||
if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1;
|
||||
memcpy(roomName_, text, nameLen);
|
||||
roomName_[nameLen] = '\0';
|
||||
|
||||
strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1);
|
||||
tempFormat_[MAX_TEXT_LEN - 1] = '\0';
|
||||
} else {
|
||||
strncpy(roomName_, text, MAX_TEXT_LEN - 1);
|
||||
roomName_[MAX_TEXT_LEN - 1] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::createSubButtonsCommon() {
|
||||
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
|
||||
|
||||
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
|
||||
const SubButtonConfig& cfg = config_.subButtons[i];
|
||||
if (!cfg.enabled) continue;
|
||||
|
||||
// Create sub-button on screen parent (not on widget container) to avoid clipping
|
||||
lv_obj_t* btn = lv_obj_create(screenParent_);
|
||||
lv_obj_remove_style_all(btn);
|
||||
lv_obj_set_size(btn, subBtnSize, subBtnSize);
|
||||
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Circular shape
|
||||
lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0);
|
||||
|
||||
// Position using layout-specific calculation
|
||||
int16_t relX, relY;
|
||||
calculateSubButtonPosition(i, cfg, relX, relY);
|
||||
int16_t absX = config_.x + relX;
|
||||
int16_t absY = config_.y + relY;
|
||||
lv_obj_set_pos(btn, absX, absY);
|
||||
|
||||
// Create icon
|
||||
if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) {
|
||||
lv_obj_t* icon = lv_label_create(btn);
|
||||
lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE);
|
||||
char iconText[5];
|
||||
encodeUtf8(cfg.iconCodepoint, iconText);
|
||||
lv_label_set_text(icon, iconText);
|
||||
lv_obj_center(icon);
|
||||
subButtonIcons_[i] = icon;
|
||||
}
|
||||
|
||||
// Store index in user_data for click handler
|
||||
lv_obj_set_user_data(btn, reinterpret_cast<void*>(static_cast<uintptr_t>(i)));
|
||||
lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this);
|
||||
|
||||
subButtonObjs_[i] = btn;
|
||||
subButtonStates_[i] = false;
|
||||
|
||||
// Apply initial color (OFF state)
|
||||
updateSubButtonColor(i);
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::applySubButtonStyle() {
|
||||
uint8_t subBtnFontIdx = 1; // Default small
|
||||
if (config_.subButtonSize > 55) {
|
||||
subBtnFontIdx = 3;
|
||||
} else if (config_.subButtonSize > 40) {
|
||||
subBtnFontIdx = 2;
|
||||
}
|
||||
const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx);
|
||||
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
|
||||
if (subButtonIcons_[i] && subBtnIconFont) {
|
||||
lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0);
|
||||
lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::updateSubButtonColor(uint8_t index) {
|
||||
if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return;
|
||||
|
||||
const SubButtonConfig& cfg = config_.subButtons[index];
|
||||
const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff;
|
||||
|
||||
lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0);
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::updateTemperature(float value) {
|
||||
if (!tempLabel_ || tempFormat_[0] == '\0') return;
|
||||
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), tempFormat_, value);
|
||||
lv_label_set_text(tempLabel_, buf);
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::onKnxValue(float value) {
|
||||
cachedPrimaryValue_ = value;
|
||||
hasCachedPrimary_ = true;
|
||||
updateTemperature(value);
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::onKnxSwitch(bool value) {
|
||||
// Not used directly - sub-button status is handled via onSubButtonStatus
|
||||
(void)value;
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::onSubButtonStatus(uint8_t index, bool value) {
|
||||
if (index >= MAX_SUBBUTTONS) return;
|
||||
|
||||
subButtonStates_[index] = value;
|
||||
updateSubButtonColor(index);
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::sendSubButtonToggle(uint8_t index) {
|
||||
if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return;
|
||||
|
||||
const SubButtonConfig& cfg = config_.subButtons[index];
|
||||
if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) {
|
||||
bool newState = !subButtonStates_[index];
|
||||
WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState);
|
||||
subButtonStates_[index] = newState;
|
||||
updateSubButtonColor(index);
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::cardClickCallback(lv_event_t* e) {
|
||||
RoomCardWidgetBase* widget = static_cast<RoomCardWidgetBase*>(lv_event_get_user_data(e));
|
||||
if (!widget) return;
|
||||
|
||||
if (widget->config_.action == ButtonAction::JUMP) {
|
||||
WidgetManager::instance().navigateToScreen(widget->config_.targetScreen);
|
||||
} else if (widget->config_.action == ButtonAction::BACK) {
|
||||
WidgetManager::instance().navigateBack();
|
||||
}
|
||||
}
|
||||
|
||||
void RoomCardWidgetBase::subButtonClickCallback(lv_event_t* e) {
|
||||
RoomCardWidgetBase* widget = static_cast<RoomCardWidgetBase*>(lv_event_get_user_data(e));
|
||||
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||
if (!widget || !target) return;
|
||||
|
||||
uint8_t index = static_cast<uint8_t>(reinterpret_cast<uintptr_t>(lv_obj_get_user_data(target)));
|
||||
|
||||
if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) {
|
||||
const SubButtonConfig& cfg = widget->config_.subButtons[index];
|
||||
|
||||
if (cfg.action == SubButtonAction::TOGGLE_KNX) {
|
||||
widget->sendSubButtonToggle(index);
|
||||
} else if (cfg.action == SubButtonAction::NAVIGATE) {
|
||||
WidgetManager::instance().navigateToScreen(cfg.targetScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
main/widgets/RoomCardWidgetBase.hpp
Normal file
53
main/widgets/RoomCardWidgetBase.hpp
Normal file
@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
class RoomCardWidgetBase : public Widget {
|
||||
public:
|
||||
explicit RoomCardWidgetBase(const WidgetConfig& config);
|
||||
~RoomCardWidgetBase() override;
|
||||
|
||||
void onKnxValue(float value) override; // Temperature update
|
||||
void onKnxSwitch(bool value) override; // Not used directly
|
||||
|
||||
// Sub-button specific handling
|
||||
void onSubButtonStatus(uint8_t index, bool value);
|
||||
void sendSubButtonToggle(uint8_t index);
|
||||
|
||||
// Clear LVGL objects
|
||||
virtual void clearLvglObject();
|
||||
|
||||
protected:
|
||||
// Parent (screen) for sub-buttons to avoid clipping
|
||||
lv_obj_t* screenParent_ = nullptr;
|
||||
|
||||
// Common elements
|
||||
lv_obj_t* roomLabel_ = nullptr;
|
||||
lv_obj_t* tempLabel_ = nullptr;
|
||||
|
||||
// Sub-button elements
|
||||
lv_obj_t* subButtonObjs_[MAX_SUBBUTTONS] = {};
|
||||
lv_obj_t* subButtonIcons_[MAX_SUBBUTTONS] = {};
|
||||
bool subButtonStates_[MAX_SUBBUTTONS] = {};
|
||||
|
||||
// Cached config values
|
||||
char roomName_[MAX_TEXT_LEN] = {0};
|
||||
char tempFormat_[MAX_TEXT_LEN] = {0};
|
||||
|
||||
// Common helpers
|
||||
void parseTextBase();
|
||||
void updateSubButtonColor(uint8_t index);
|
||||
void updateTemperature(float value);
|
||||
|
||||
// Virtual methods for layout-specific sub-button positioning
|
||||
virtual void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) = 0;
|
||||
void createSubButtonsCommon();
|
||||
void applySubButtonStyle();
|
||||
|
||||
// Event handlers
|
||||
static void cardClickCallback(lv_event_t* e);
|
||||
static void subButtonClickCallback(lv_event_t* e);
|
||||
|
||||
// UTF-8 encoding helper
|
||||
static int encodeUtf8(uint32_t codepoint, char* buf);
|
||||
};
|
||||
@ -10,7 +10,8 @@
|
||||
#include "PowerLinkWidget.hpp"
|
||||
#include "ChartWidget.hpp"
|
||||
#include "ClockWidget.hpp"
|
||||
#include "RoomCardWidget.hpp"
|
||||
#include "RoomCardBubbleWidget.hpp"
|
||||
#include "RoomCardTileWidget.hpp"
|
||||
|
||||
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||
if (!config.visible) return nullptr;
|
||||
@ -39,7 +40,11 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||
case WidgetType::CLOCK:
|
||||
return std::make_unique<ClockWidget>(config);
|
||||
case WidgetType::ROOMCARD:
|
||||
return std::make_unique<RoomCardWidget>(config);
|
||||
// cardStyle: 0=Bubble, 1=Tile
|
||||
if (config.cardStyle == 1) {
|
||||
return std::make_unique<RoomCardTileWidget>(config);
|
||||
}
|
||||
return std::make_unique<RoomCardBubbleWidget>(config);
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@ -10,8 +10,15 @@ CONFIG_LV_FONT_MONTSERRAT_48=y
|
||||
# Keep LVGL draw thread stack reasonable to avoid xTaskCreate failures
|
||||
CONFIG_LV_DRAW_THREAD_STACK_SIZE=32768
|
||||
|
||||
# Increase LVGL heap to avoid draw task OOM with dynamic UI
|
||||
CONFIG_LV_MEM_SIZE_KILOBYTES=128
|
||||
# Use C library malloc instead of LVGL builtin - enables PSRAM usage
|
||||
CONFIG_LV_USE_BUILTIN_MALLOC=n
|
||||
CONFIG_LV_USE_CLIB_MALLOC=y
|
||||
|
||||
# Increase draw layer buffer for large icons (250px icons need ~120KB)
|
||||
CONFIG_LV_DRAW_LAYER_SIMPLE_BUF_SIZE=131072
|
||||
|
||||
# Backup LVGL heap size (used if builtin malloc is enabled)
|
||||
CONFIG_LV_MEM_SIZE_KILOBYTES=512
|
||||
|
||||
# Enable object names for LVGL debug mapping
|
||||
CONFIG_LV_USE_OBJ_NAME=y
|
||||
@ -22,3 +29,10 @@ CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE=y
|
||||
|
||||
# Increase main task stack for FreeType (needs ~16KB+)
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384
|
||||
|
||||
# PSRAM configuration for large allocations
|
||||
# Allocations > 1KB go to PSRAM to avoid internal RAM exhaustion
|
||||
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=1024
|
||||
|
||||
# Allow PSRAM as malloc target
|
||||
CONFIG_SPIRAM_USE_MALLOC=y
|
||||
|
||||
@ -282,7 +282,12 @@ function resizeDrag(e) {
|
||||
let newW = Math.round(rawW);
|
||||
let newH = Math.round(rawH);
|
||||
|
||||
if (w.type === WIDGET_TYPES.LED || w.type === WIDGET_TYPES.POWERNODE || w.type === WIDGET_TYPES.ROOMCARD) {
|
||||
// Force square for LED, PowerNode, and RoomCard Bubble (not Tile)
|
||||
const forceSquare = w.type === WIDGET_TYPES.LED ||
|
||||
w.type === WIDGET_TYPES.POWERNODE ||
|
||||
(w.type === WIDGET_TYPES.ROOMCARD && w.cardStyle !== 1);
|
||||
|
||||
if (forceSquare) {
|
||||
const maxSize = Math.min(maxW, maxH);
|
||||
const size = clamp(Math.max(newW, newH), minSize.w, maxSize);
|
||||
newW = size;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,868 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="[
|
||||
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
|
||||
isLabel ? 'px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap' : ''
|
||||
]"
|
||||
: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="handleWidgetClick"
|
||||
>
|
||||
<!-- Recursive Children -->
|
||||
<!-- Special handling for TabView to render structure -->
|
||||
<template v-if="isTabView">
|
||||
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
|
||||
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
|
||||
<div
|
||||
v-for="(child, idx) in children"
|
||||
:key="child.id"
|
||||
:class="tabBtnClass(activePageId === child.id)"
|
||||
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
|
||||
>
|
||||
{{ child.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 relative overflow-hidden">
|
||||
<WidgetElement
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
:widget="child"
|
||||
:scale="scale"
|
||||
:selected="store.selectedWidgetId === child.id"
|
||||
:style="{ display: activePageId === child.id ? 'block' : 'none' }"
|
||||
@select="store.selectedWidgetId = child.id"
|
||||
@drag-start="$emit('drag-start', $event)"
|
||||
@resize-start="$emit('resize-start', $event)"
|
||||
/>
|
||||
</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"
|
||||
fill="none"
|
||||
:opacity="link.opacity"
|
||||
/>
|
||||
<circle
|
||||
v-for="link in powerFlowLinks"
|
||||
:key="`dot-${link.id}`"
|
||||
:r="link.dotRadius"
|
||||
:fill="link.color"
|
||||
:opacity="link.opacity"
|
||||
>
|
||||
<animateMotion
|
||||
:dur="`${link.duration}s`"
|
||||
repeatCount="indefinite"
|
||||
:path="link.path"
|
||||
/>
|
||||
</circle>
|
||||
</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>
|
||||
<WidgetElement
|
||||
v-for="child in children"
|
||||
: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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<template v-else-if="isChart">
|
||||
<div class="w-full h-full flex flex-col gap-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.12em] opacity-80">
|
||||
{{ widget.text || 'Chart' }}
|
||||
</div>
|
||||
<div class="flex-1 rounded-[10px] bg-black/20 relative overflow-hidden">
|
||||
<div class="absolute inset-0 opacity-30" style="background-image: linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px); background-size: 24px 24px;"></div>
|
||||
<svg class="absolute inset-0" viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||
<path d="M0,30 L15,22 L30,26 L45,14 L60,18 L75,10 L100,16" fill="none" stroke="rgba(239,99,81,0.8)" stroke-width="2" />
|
||||
<path d="M0,34 L20,28 L40,32 L60,20 L80,24 L100,18" fill="none" stroke="rgba(125,211,176,0.8)" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isClock">
|
||||
<div class="relative w-full h-full rounded-full border-2 box-border flex items-center justify-center overflow-hidden" :style="{ borderColor: widget.textColor }">
|
||||
<!-- Center Dot -->
|
||||
<div class="absolute w-2 h-2 rounded-full z-10" :style="{ backgroundColor: widget.textColor }"></div>
|
||||
<!-- Hour Hand -->
|
||||
<div class="absolute w-1.5 h-[28%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(300deg)' }"></div>
|
||||
<!-- Minute Hand -->
|
||||
<div class="absolute w-1 h-[40%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(70deg)' }"></div>
|
||||
<!-- Second Hand -->
|
||||
<div class="absolute w-0.5 h-[45%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full bg-[#c83232]" :style="{ transform: 'rotate(140deg)' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- RoomCard Widget - Bubble Style -->
|
||||
<template v-else-if="isRoomCard && widget.cardStyle !== 1">
|
||||
<!-- Central Bubble -->
|
||||
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
|
||||
{{ iconChar }}
|
||||
</span>
|
||||
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
|
||||
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
|
||||
</div>
|
||||
<!-- Sub-Buttons (circular orbit) -->
|
||||
<div
|
||||
v-for="(sb, idx) in roomCardSubButtons"
|
||||
:key="idx"
|
||||
class="absolute rounded-full flex items-center justify-center shadow-md"
|
||||
:style="getSubButtonStyle(sb, idx)"
|
||||
>
|
||||
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
|
||||
{{ String.fromCodePoint(sb.icon) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- RoomCard Widget - Tile Style -->
|
||||
<template v-else-if="isRoomCard && widget.cardStyle === 1">
|
||||
<div class="absolute inset-0 overflow-hidden" :style="roomCardTileContainerStyle">
|
||||
<!-- Room name (top-left) -->
|
||||
<div class="absolute" :style="{ left: '16px', top: '12px', color: widget.textColor, fontSize: fontSizes[widget.fontSize || 2] + 'px', fontWeight: 600 }">
|
||||
{{ roomCardParts.name }}
|
||||
</div>
|
||||
<!-- Subtitle -->
|
||||
<div v-if="roomCardParts.subtitle" class="absolute opacity-60" :style="{ left: '16px', top: '38px', color: widget.textColor, fontSize: fontSizes[Math.max(0, (widget.fontSize || 2) - 2)] + 'px' }">
|
||||
{{ roomCardParts.subtitle }}
|
||||
</div>
|
||||
<!-- Temperature + Humidity row -->
|
||||
<div class="absolute flex items-center gap-4" :style="{ left: '16px', top: '64px', color: widget.textColor, fontSize: fontSizes[Math.max(0, (widget.fontSize || 2) - 1)] + 'px' }">
|
||||
<span v-if="widget.text2" class="flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-[14px]">device_thermostat</span>
|
||||
<span>{{ widget.text2 || '--' }}</span>
|
||||
</span>
|
||||
<span v-if="widget.text3" class="flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-[14px]">humidity_percentage</span>
|
||||
<span>{{ widget.text3 || '--' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Large decorative icon (bottom-left) -->
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined absolute opacity-20" :style="{ left: '-20px', bottom: '-20px', fontSize: '120px', color: widget.textColor }">
|
||||
{{ iconChar }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Sub-Buttons (right side, vertical) -->
|
||||
<div
|
||||
v-for="(sb, idx) in roomCardSubButtons"
|
||||
:key="idx"
|
||||
class="absolute rounded-full flex items-center justify-center shadow-md"
|
||||
:style="getSubButtonStyleTile(sb, idx)"
|
||||
>
|
||||
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
|
||||
{{ String.fromCodePoint(sb.icon) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Icon-only Widget -->
|
||||
<template v-else-if="isIcon">
|
||||
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
|
||||
{{ iconChar }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Label/Button with Icon -->
|
||||
<template v-else-if="hasIcon">
|
||||
<div :style="contentStyle">
|
||||
<span
|
||||
v-if="iconPosition === 0 || iconPosition === 2"
|
||||
class="material-symbols-outlined flex-shrink-0"
|
||||
:style="iconStyle"
|
||||
>{{ iconChar }}</span>
|
||||
<span class="flex-shrink-0">{{ widget.text }}</span>
|
||||
<span
|
||||
v-if="iconPosition === 1 || iconPosition === 3"
|
||||
class="material-symbols-outlined flex-shrink-0"
|
||||
:style="iconStyle"
|
||||
>{{ iconChar }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Standard (no icon) -->
|
||||
<template v-else>
|
||||
<span v-if="showDefaultText">{{ widget.text }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Resize Handle (at end to be on top) -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
:is="widgetComponent"
|
||||
:widget="widget"
|
||||
:scale="scale"
|
||||
:selected="selected"
|
||||
@select="$emit('select')"
|
||||
@drag-start="$emit('drag-start', $event)"
|
||||
@resize-start="$emit('resize-start', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useEditorStore } from '../stores/editor';
|
||||
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
|
||||
import { clamp, hexToRgba } from '../utils';
|
||||
import { computed, markRaw } from 'vue';
|
||||
import { WIDGET_TYPES } from '../constants';
|
||||
|
||||
// Import all widget element components
|
||||
import LabelElement from './widgets/elements/LabelElement.vue';
|
||||
import ButtonElement from './widgets/elements/ButtonElement.vue';
|
||||
import LedElement from './widgets/elements/LedElement.vue';
|
||||
import IconElement from './widgets/elements/IconElement.vue';
|
||||
import TabViewElement from './widgets/elements/TabViewElement.vue';
|
||||
import TabPageElement from './widgets/elements/TabPageElement.vue';
|
||||
import PowerFlowElement from './widgets/elements/PowerFlowElement.vue';
|
||||
import PowerNodeElement from './widgets/elements/PowerNodeElement.vue';
|
||||
import ChartElement from './widgets/elements/ChartElement.vue';
|
||||
import ClockElement from './widgets/elements/ClockElement.vue';
|
||||
import RoomCardElement from './widgets/elements/RoomCardElement.vue';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const children = computed(() => {
|
||||
if (!store.activeScreen) return [];
|
||||
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
|
||||
});
|
||||
|
||||
const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
|
||||
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
|
||||
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
|
||||
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 isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
|
||||
const isClock = computed(() => props.widget.type === WIDGET_TYPES.CLOCK);
|
||||
const isRoomCard = computed(() => props.widget.type === WIDGET_TYPES.ROOMCARD);
|
||||
|
||||
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
|
||||
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
||||
|
||||
// Find active tab index (client-side state only, maybe store in widget temporarily?)
|
||||
// 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.
|
||||
const activeTabIndex = ref(0);
|
||||
|
||||
const activePageId = computed(() => {
|
||||
// If a child page is selected, make it active
|
||||
const selectedChild = children.value.find(c => store.selectedWidgetId === c.id);
|
||||
if (selectedChild) return selectedChild.id;
|
||||
|
||||
// If a widget inside a page is selected
|
||||
if (store.selectedWidget && store.selectedWidget.parentId !== -1) {
|
||||
// Find ancestor page
|
||||
let curr = store.selectedWidget;
|
||||
while (curr && curr.parentId !== -1 && curr.parentId !== props.widget.id) {
|
||||
curr = store.activeScreen.widgets.find(w => w.id === curr.parentId);
|
||||
}
|
||||
if (curr && curr.parentId === props.widget.id) return curr.id;
|
||||
}
|
||||
|
||||
if (children.value.length > 0) return children.value[activeTabIndex.value]?.id;
|
||||
return -1;
|
||||
});
|
||||
|
||||
const hasIcon = computed(() => {
|
||||
if (isButtonContainer.value) return false;
|
||||
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
|
||||
});
|
||||
|
||||
const iconChar = computed(() => {
|
||||
const cp = props.widget.iconCodepoint || 0xe88a;
|
||||
return String.fromCodePoint(cp);
|
||||
});
|
||||
|
||||
const iconPosition = computed(() => props.widget.iconPosition || 0);
|
||||
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;
|
||||
if (isRoomCard.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const justifyForAlign = (align) => {
|
||||
if (align === 0) return 'flex-start';
|
||||
if (align === 2) return 'flex-end';
|
||||
return 'center';
|
||||
// Map widget types to components
|
||||
const componentMap = {
|
||||
[WIDGET_TYPES.LABEL]: markRaw(LabelElement),
|
||||
[WIDGET_TYPES.BUTTON]: markRaw(ButtonElement),
|
||||
[WIDGET_TYPES.LED]: markRaw(LedElement),
|
||||
[WIDGET_TYPES.ICON]: markRaw(IconElement),
|
||||
[WIDGET_TYPES.TABVIEW]: markRaw(TabViewElement),
|
||||
[WIDGET_TYPES.TABPAGE]: markRaw(TabPageElement),
|
||||
[WIDGET_TYPES.POWERFLOW]: markRaw(PowerFlowElement),
|
||||
[WIDGET_TYPES.POWERNODE]: markRaw(PowerNodeElement),
|
||||
[WIDGET_TYPES.CHART]: markRaw(ChartElement),
|
||||
[WIDGET_TYPES.CLOCK]: markRaw(ClockElement),
|
||||
[WIDGET_TYPES.ROOMCARD]: markRaw(RoomCardElement)
|
||||
};
|
||||
|
||||
const textAlignCss = (align) => {
|
||||
if (align === 0) return 'left';
|
||||
if (align === 2) return 'right';
|
||||
return 'center';
|
||||
};
|
||||
|
||||
const contentJustify = computed(() => {
|
||||
if (isButton.value || isLabel.value) return justifyForAlign(textAlign.value);
|
||||
return 'center';
|
||||
const widgetComponent = computed(() => {
|
||||
return componentMap[props.widget.type] || LabelElement;
|
||||
});
|
||||
|
||||
const isVerticalLayout = computed(() => {
|
||||
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
|
||||
});
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const gap = (props.widget.iconGap || 8) * s;
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: isVerticalLayout.value ? 'column' : 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: contentJustify.value,
|
||||
gap: `${gap}px`,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
};
|
||||
});
|
||||
|
||||
const iconStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
|
||||
const size = fontSizes[sizeIdx] || 18;
|
||||
return {
|
||||
fontSize: `${size * s}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const iconOnlyStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.iconSize ?? 3;
|
||||
const size = fontSizes[sizeIdx] || 28;
|
||||
return {
|
||||
fontSize: `${size * s}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
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 lineWidth = Math.max(3, link.w || 3);
|
||||
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 ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const fromRadius = Math.max(0, (Math.min(fromNode.w, fromNode.h) * 0.5 - lineWidth * 0.5) * s);
|
||||
const toRadius = Math.max(0, (Math.min(toNode.w, toNode.h) * 0.5 - lineWidth * 0.5) * s);
|
||||
|
||||
let startX = fromCenter.x + ux * fromRadius;
|
||||
let startY = fromCenter.y + uy * fromRadius;
|
||||
let endX = toCenter.x - ux * toRadius;
|
||||
let endY = toCenter.y - uy * toRadius;
|
||||
|
||||
if (len <= fromRadius + toRadius + 1) {
|
||||
startX = fromCenter.x;
|
||||
startY = fromCenter.y;
|
||||
endX = toCenter.x;
|
||||
endY = toCenter.y;
|
||||
}
|
||||
|
||||
const dxTrim = endX - startX;
|
||||
const dyTrim = endY - startY;
|
||||
const lenTrim = Math.hypot(dxTrim, dyTrim) || 1;
|
||||
const nx = -dyTrim / lenTrim;
|
||||
const ny = dxTrim / lenTrim;
|
||||
const midX = (startX + endX) / 2;
|
||||
const midY = (startY + endY) / 2;
|
||||
const curveSign = idx % 2 === 0 ? 1 : -1;
|
||||
const curve = Math.min(42 * s, lenTrim * 0.3) * curveSign;
|
||||
const cpx = midX + nx * curve;
|
||||
const cpy = midY + ny * curve;
|
||||
const dotRadius = Math.min(8, Math.max(4, lineWidth * 1.6));
|
||||
const rawValue = parseFloat(link.text);
|
||||
const hasRaw = Number.isFinite(rawValue);
|
||||
const isStatic = (link.textSrc ?? 0) === 0;
|
||||
const factor = hasRaw ? rawValue : (isStatic ? 60 : 1);
|
||||
const previewValue = 50;
|
||||
const speed = Math.max(5, isStatic ? factor : Math.abs(previewValue) * factor);
|
||||
const duration = Math.max(2, Math.min(10, lenTrim / speed));
|
||||
|
||||
return [{
|
||||
id: link.id,
|
||||
path: `M ${startX} ${startY} Q ${cpx} ${cpy} ${endX} ${endY}`,
|
||||
color: link.bgColor || '#6fa7d8',
|
||||
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
|
||||
width: lineWidth,
|
||||
dotRadius,
|
||||
duration
|
||||
}];
|
||||
});
|
||||
});
|
||||
|
||||
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'
|
||||
};
|
||||
});
|
||||
|
||||
// RoomCard computed properties
|
||||
const roomCardParts = computed(() => {
|
||||
if (!props.widget.text) return { name: '', format: '', subtitle: '' };
|
||||
const parts = props.widget.text.split('\n');
|
||||
// For Tile style: second line is subtitle, temp/humidity come from text2/text3
|
||||
// For Bubble style: second line is temp format
|
||||
if (props.widget.cardStyle === 1) {
|
||||
return {
|
||||
name: parts[0] || '',
|
||||
subtitle: parts[1] || '',
|
||||
format: ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: parts[0] || '',
|
||||
format: parts[1] || '',
|
||||
subtitle: ''
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
|
||||
|
||||
// Tile style container
|
||||
const roomCardTileContainerStyle = computed(() => {
|
||||
const alpha = (props.widget.bgOpacity ?? 255) / 255;
|
||||
const style = {
|
||||
backgroundColor: hexToRgba(props.widget.bgColor || '#333333', alpha),
|
||||
borderRadius: (props.widget.radius || 16) + 'px',
|
||||
};
|
||||
// Border from shadow settings
|
||||
if (props.widget.shadow?.enabled) {
|
||||
style.border = `3px solid ${props.widget.shadow.color || '#ff6b6b'}`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
// Sub-button positioning for Tile (right side, vertical)
|
||||
const getSubButtonStyleTile = (sb, idx) => {
|
||||
const s = props.scale;
|
||||
const btnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const gap = 10 * s;
|
||||
const padding = 12 * s;
|
||||
const w = props.widget.w * s;
|
||||
|
||||
return {
|
||||
width: btnSize + 'px',
|
||||
height: btnSize + 'px',
|
||||
right: padding + 'px',
|
||||
top: (padding + idx * (btnSize + gap)) + 'px',
|
||||
backgroundColor: sb.colorOff || '#666666',
|
||||
};
|
||||
};
|
||||
|
||||
const roomCardBubbleStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const w = props.widget.w;
|
||||
const h = props.widget.h;
|
||||
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const padding = subBtnSize * 0.6;
|
||||
const bubbleSize = Math.min(w, h) * s - padding * 2;
|
||||
const left = (w * s - bubbleSize) / 2;
|
||||
const top = (h * s - bubbleSize) / 2;
|
||||
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
|
||||
|
||||
return {
|
||||
width: `${bubbleSize}px`,
|
||||
height: `${bubbleSize}px`,
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
backgroundColor: hexToRgba(props.widget.bgColor, alpha),
|
||||
color: props.widget.textColor,
|
||||
boxShadow: props.widget.shadow?.enabled
|
||||
? `${(props.widget.shadow.x || 0) * s}px ${(props.widget.shadow.y || 0) * s}px ${(props.widget.shadow.blur || 0) * s}px ${hexToRgba(props.widget.shadow.color || '#000000', 0.3)}`
|
||||
: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardIconStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.iconSize ?? 3;
|
||||
const size = fontSizes[sizeIdx] || 28;
|
||||
return {
|
||||
fontSize: `${size * s}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardNameStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.fontSize ?? 2;
|
||||
const size = fontSizes[sizeIdx] || 22;
|
||||
return {
|
||||
fontSize: `${size * s * 0.7}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardTempStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.fontSize ?? 2;
|
||||
const size = fontSizes[sizeIdx] || 22;
|
||||
return {
|
||||
fontSize: `${size * s * 0.55}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
function getSubButtonStyle(sb, idx) {
|
||||
const s = props.scale;
|
||||
const w = props.widget.w;
|
||||
const h = props.widget.h;
|
||||
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const centerX = w * s / 2;
|
||||
const centerY = h * s / 2;
|
||||
|
||||
// Distance from center in pixels (default 80)
|
||||
const orbitRadius = (props.widget.subButtonDistance || 80) * s;
|
||||
|
||||
// Position based on sb.pos (0=Top, 1=TopRight, 2=Right, etc.)
|
||||
const pos = sb.pos ?? idx;
|
||||
const angle = (pos * (Math.PI / 4)) - (Math.PI / 2); // Start from top, go clockwise
|
||||
const x = centerX + orbitRadius * Math.cos(angle) - subBtnSize / 2;
|
||||
const y = centerY + orbitRadius * Math.sin(angle) - subBtnSize / 2;
|
||||
|
||||
// Use colorOff for preview (no KNX state in editor)
|
||||
const bgColor = sb.colorOff || '#666666';
|
||||
|
||||
return {
|
||||
width: `${subBtnSize}px`,
|
||||
height: `${subBtnSize}px`,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
backgroundColor: bgColor,
|
||||
border: `2px solid ${hexToRgba('#ffffff', 0.3)}`
|
||||
};
|
||||
}
|
||||
|
||||
function getSubButtonIconStyle(sb) {
|
||||
const s = props.scale;
|
||||
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const iconSize = subBtnSize * 0.5;
|
||||
return {
|
||||
fontSize: `${iconSize}px`,
|
||||
color: '#ffffff'
|
||||
};
|
||||
}
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = {
|
||||
left: `${w.x * s}px`,
|
||||
top: `${w.y * s}px`,
|
||||
width: `${w.w * s}px`,
|
||||
height: `${w.h * s}px`,
|
||||
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
|
||||
color: w.textColor,
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
cursor: 'move',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none'
|
||||
};
|
||||
|
||||
if (isIcon.value) {
|
||||
// Icon widget
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
style.justifyContent = 'center';
|
||||
if (w.bgOpacity > 0) {
|
||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
}
|
||||
if (w.radius > 0) {
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
}
|
||||
} else if (isLabel.value) {
|
||||
if (w.bgOpacity > 0) {
|
||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
}
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
if (!hasIcon.value) {
|
||||
style.justifyContent = justifyForAlign(textAlign.value);
|
||||
style.textAlign = textAlignCss(textAlign.value);
|
||||
}
|
||||
} else if (isButton.value) {
|
||||
style.background = w.bgColor;
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
style.justifyContent = contentJustify.value;
|
||||
style.fontWeight = '600';
|
||||
|
||||
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 (isLed.value) {
|
||||
style.borderRadius = '999px';
|
||||
|
||||
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
||||
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
|
||||
const highlight = clamp(brightness + 0.25, 0, 1);
|
||||
const core = clamp(brightness, 0, 1);
|
||||
const edge = clamp(brightness * 0.5, 0, 1);
|
||||
|
||||
style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
|
||||
|
||||
if (w.shadow && w.shadow.enabled) {
|
||||
const sx = (w.shadow.x || 0) * 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.4 + brightness * 0.6, 0, 1);
|
||||
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
|
||||
} 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 (isClock.value) {
|
||||
if (w.bgOpacity > 0) {
|
||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
}
|
||||
if (w.radius > 0) {
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
} else {
|
||||
style.borderRadius = '50%';
|
||||
}
|
||||
} else if (isTabView.value) {
|
||||
style.background = w.bgColor;
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
// No flex here, we handle internal layout manually
|
||||
} else if (isTabPage.value) {
|
||||
style.position = 'relative'; // Relative to content area
|
||||
style.width = '100%';
|
||||
style.height = '100%';
|
||||
style.left = '0';
|
||||
style.top = '0';
|
||||
} else if (isRoomCard.value) {
|
||||
if (w.cardStyle === 1) {
|
||||
// Tile style - rectangular with rounded corners
|
||||
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
||||
style.background = hexToRgba(w.bgColor || '#333333', alpha);
|
||||
style.borderRadius = `${(w.radius || 16) * s}px`;
|
||||
style.overflow = 'hidden';
|
||||
if (w.shadow && w.shadow.enabled) {
|
||||
style.border = `3px solid ${w.shadow.color || '#ff6b6b'}`;
|
||||
}
|
||||
} else {
|
||||
// Bubble style - container transparent, children handle rendering
|
||||
style.overflow = 'visible';
|
||||
}
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
const tabViewStyle = computed(() => {
|
||||
return {
|
||||
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
|
||||
};
|
||||
});
|
||||
|
||||
const tabBtnsStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const h = tabHeight.value * s;
|
||||
const isVert = tabPosition.value === 0 || tabPosition.value === 1;
|
||||
|
||||
return {
|
||||
[isVert ? 'height' : 'width']: `${h}px`,
|
||||
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
|
||||
flexDirection: isVert ? 'row' : 'column'
|
||||
};
|
||||
});
|
||||
|
||||
const tabBtnClass = (isActive) => {
|
||||
const isVerticalTabs = tabPosition.value === 2 || tabPosition.value === 3;
|
||||
const base = 'flex-1 flex items-center justify-center px-1 py-1 text-[12px] cursor-pointer select-none';
|
||||
const border = isVerticalTabs ? 'border-b border-white/10' : 'border-b border-r border-white/10';
|
||||
const active = isVerticalTabs
|
||||
? 'bg-white/10 font-bold border-b-0 border-r-2 border-accent'
|
||||
: '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>
|
||||
|
||||
132
web-interface/src/components/widgets/elements/ButtonElement.vue
Normal file
132
web-interface/src/components/widgets/elements/ButtonElement.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<!-- Children (for container buttons) -->
|
||||
<WidgetElement
|
||||
v-for="child in children"
|
||||
: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)"
|
||||
/>
|
||||
|
||||
<!-- With Icon (not container) -->
|
||||
<template v-if="hasIcon && !isContainer">
|
||||
<div :style="contentStyle">
|
||||
<span
|
||||
v-if="iconPosition === 0 || iconPosition === 2"
|
||||
class="material-symbols-outlined flex-shrink-0"
|
||||
:style="iconStyle"
|
||||
>{{ iconChar }}</span>
|
||||
<span class="flex-shrink-0">{{ widget.text }}</span>
|
||||
<span
|
||||
v-if="iconPosition === 1 || iconPosition === 3"
|
||||
class="material-symbols-outlined flex-shrink-0"
|
||||
:style="iconStyle"
|
||||
>{{ iconChar }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Without Icon (not container) -->
|
||||
<template v-else-if="!isContainer">
|
||||
<span>{{ widget.text }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { fontSizes, ICON_POSITIONS } from '../../../constants';
|
||||
import { getBaseStyle, justifyForAlign, getShadowStyle } from '../shared/utils';
|
||||
|
||||
// Lazy import to avoid circular dependency
|
||||
const WidgetElement = defineAsyncComponent(() => import('../../WidgetElement.vue'));
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const children = computed(() => {
|
||||
if (!store.activeScreen) return [];
|
||||
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
|
||||
});
|
||||
|
||||
const isContainer = computed(() => props.widget.isContainer);
|
||||
const hasIcon = computed(() => props.widget.iconCodepoint > 0);
|
||||
const iconChar = computed(() => String.fromCodePoint(props.widget.iconCodepoint || 0xe88a));
|
||||
const iconPosition = computed(() => props.widget.iconPosition || 0);
|
||||
const textAlign = computed(() => props.widget.textAlign ?? 1);
|
||||
|
||||
const isVerticalLayout = computed(() => {
|
||||
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
|
||||
});
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const gap = (props.widget.iconGap || 8) * s;
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: isVerticalLayout.value ? 'column' : 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: justifyForAlign(textAlign.value),
|
||||
gap: `${gap}px`,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
};
|
||||
});
|
||||
|
||||
const iconStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
|
||||
const size = fontSizes[sizeIdx] || 18;
|
||||
return {
|
||||
fontSize: `${size * s}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
|
||||
style.background = w.bgColor;
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
style.justifyContent = justifyForAlign(textAlign.value);
|
||||
style.fontWeight = '600';
|
||||
|
||||
Object.assign(style, getShadowStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<div class="w-full h-full flex flex-col gap-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.12em] opacity-80">
|
||||
{{ widget.text || 'Chart' }}
|
||||
</div>
|
||||
<div class="flex-1 rounded-[10px] bg-black/20 relative overflow-hidden">
|
||||
<div class="absolute inset-0 opacity-30" style="background-image: linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px); background-size: 24px 24px;"></div>
|
||||
<svg class="absolute inset-0" viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||
<path d="M0,30 L15,22 L30,26 L45,14 L60,18 L75,10 L100,16" fill="none" stroke="rgba(239,99,81,0.8)" stroke-width="2" />
|
||||
<path d="M0,34 L20,28 L40,32 L60,20 L80,24 L100,18" fill="none" stroke="rgba(125,211,176,0.8)" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getBaseStyle, clamp, hexToRgba } from '../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
|
||||
if (w.bgOpacity > 0) {
|
||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
}
|
||||
|
||||
if (w.radius > 0) {
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
}
|
||||
|
||||
style.padding = `${12 * s}px`;
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<div class="relative w-full h-full rounded-full border-2 box-border flex items-center justify-center overflow-hidden" :style="{ borderColor: widget.textColor }">
|
||||
<!-- Center Dot -->
|
||||
<div class="absolute w-2 h-2 rounded-full z-10" :style="{ backgroundColor: widget.textColor }"></div>
|
||||
<!-- Hour Hand -->
|
||||
<div class="absolute w-1.5 h-[28%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(300deg)' }"></div>
|
||||
<!-- Minute Hand -->
|
||||
<div class="absolute w-1 h-[40%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(70deg)' }"></div>
|
||||
<!-- Second Hand -->
|
||||
<div class="absolute w-0.5 h-[45%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full bg-[#c83232]" :style="{ transform: 'rotate(140deg)' }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getBaseStyle, clamp, hexToRgba } from '../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
|
||||
if (w.bgOpacity > 0) {
|
||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
}
|
||||
|
||||
if (w.radius > 0) {
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
} else {
|
||||
style.borderRadius = '50%';
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
|
||||
{{ iconChar }}
|
||||
</span>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { fontSizes } from '../../../constants';
|
||||
import { getBaseStyle, clamp, hexToRgba } from '../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const iconChar = computed(() => {
|
||||
const cp = props.widget.iconCodepoint || 0xe88a;
|
||||
return String.fromCodePoint(cp);
|
||||
});
|
||||
|
||||
const iconOnlyStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.iconSize ?? 3;
|
||||
const size = fontSizes[sizeIdx] || 28;
|
||||
return {
|
||||
fontSize: `${size * s}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
style.justifyContent = 'center';
|
||||
|
||||
if (w.bgOpacity > 0) {
|
||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
}
|
||||
|
||||
if (w.radius > 0) {
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
112
web-interface/src/components/widgets/elements/LabelElement.vue
Normal file
112
web-interface/src/components/widgets/elements/LabelElement.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<!-- With Icon -->
|
||||
<template v-if="hasIcon">
|
||||
<div :style="contentStyle">
|
||||
<span
|
||||
v-if="iconPosition === 0 || iconPosition === 2"
|
||||
class="material-symbols-outlined flex-shrink-0"
|
||||
:style="iconStyle"
|
||||
>{{ iconChar }}</span>
|
||||
<span class="flex-shrink-0">{{ widget.text }}</span>
|
||||
<span
|
||||
v-if="iconPosition === 1 || iconPosition === 3"
|
||||
class="material-symbols-outlined flex-shrink-0"
|
||||
:style="iconStyle"
|
||||
>{{ iconChar }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Without Icon -->
|
||||
<template v-else>
|
||||
<span>{{ widget.text }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { fontSizes, ICON_POSITIONS } from '../../../constants';
|
||||
import { getBaseStyle, justifyForAlign, textAlignCss, clamp, hexToRgba } from '../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const hasIcon = computed(() => props.widget.iconCodepoint > 0);
|
||||
const iconChar = computed(() => String.fromCodePoint(props.widget.iconCodepoint || 0xe88a));
|
||||
const iconPosition = computed(() => props.widget.iconPosition || 0);
|
||||
const textAlign = computed(() => props.widget.textAlign ?? 1);
|
||||
|
||||
const isVerticalLayout = computed(() => {
|
||||
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
|
||||
});
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const gap = (props.widget.iconGap || 8) * s;
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: isVerticalLayout.value ? 'column' : 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: justifyForAlign(textAlign.value),
|
||||
gap: `${gap}px`,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
};
|
||||
});
|
||||
|
||||
const iconStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
|
||||
const size = fontSizes[sizeIdx] || 18;
|
||||
return {
|
||||
fontSize: `${size * s}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
|
||||
if (w.bgOpacity > 0) {
|
||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
}
|
||||
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
|
||||
if (!hasIcon.value) {
|
||||
style.justifyContent = justifyForAlign(textAlign.value);
|
||||
style.textAlign = textAlignCss(textAlign.value);
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
63
web-interface/src/components/widgets/elements/LedElement.vue
Normal file
63
web-interface/src/components/widgets/elements/LedElement.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getBaseStyle, clamp, hexToRgba } from '../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
style.borderRadius = '999px';
|
||||
|
||||
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
||||
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
|
||||
const highlight = clamp(brightness + 0.25, 0, 1);
|
||||
const core = clamp(brightness, 0, 1);
|
||||
const edge = clamp(brightness * 0.5, 0, 1);
|
||||
|
||||
style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
|
||||
|
||||
if (w.shadow && w.shadow.enabled) {
|
||||
const sx = (w.shadow.x || 0) * 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.4 + brightness * 0.6, 0, 1);
|
||||
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
|
||||
} else {
|
||||
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<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"
|
||||
fill="none"
|
||||
:opacity="link.opacity"
|
||||
/>
|
||||
<circle
|
||||
v-for="link in powerFlowLinks"
|
||||
:key="`dot-${link.id}`"
|
||||
:r="link.dotRadius"
|
||||
:fill="link.color"
|
||||
:opacity="link.opacity"
|
||||
>
|
||||
<animateMotion
|
||||
:dur="`${link.duration}s`"
|
||||
repeatCount="indefinite"
|
||||
:path="link.path"
|
||||
/>
|
||||
</circle>
|
||||
</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)"
|
||||
/>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { WIDGET_TYPES } from '../../../constants';
|
||||
import { getBaseStyle, getShadowStyle, clamp, hexToRgba } from '../shared/utils';
|
||||
|
||||
const WidgetElement = defineAsyncComponent(() => import('../../WidgetElement.vue'));
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const children = computed(() => {
|
||||
if (!store.activeScreen) return [];
|
||||
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
|
||||
});
|
||||
|
||||
const powerNodes = computed(() => {
|
||||
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERNODE && child.visible !== false);
|
||||
});
|
||||
|
||||
const powerLinkWidgets = computed(() => {
|
||||
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERLINK && child.visible !== false);
|
||||
});
|
||||
|
||||
const powerFlowChildren = computed(() => {
|
||||
return children.value.filter((child) => child.type !== WIDGET_TYPES.POWERLINK);
|
||||
});
|
||||
|
||||
const powerFlowLinks = computed(() => {
|
||||
if (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 lineWidth = Math.max(3, link.w || 3);
|
||||
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 ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const fromRadius = Math.max(0, (Math.min(fromNode.w, fromNode.h) * 0.5 - lineWidth * 0.5) * s);
|
||||
const toRadius = Math.max(0, (Math.min(toNode.w, toNode.h) * 0.5 - lineWidth * 0.5) * s);
|
||||
|
||||
let startX = fromCenter.x + ux * fromRadius;
|
||||
let startY = fromCenter.y + uy * fromRadius;
|
||||
let endX = toCenter.x - ux * toRadius;
|
||||
let endY = toCenter.y - uy * toRadius;
|
||||
|
||||
if (len <= fromRadius + toRadius + 1) {
|
||||
startX = fromCenter.x;
|
||||
startY = fromCenter.y;
|
||||
endX = toCenter.x;
|
||||
endY = toCenter.y;
|
||||
}
|
||||
|
||||
const dxTrim = endX - startX;
|
||||
const dyTrim = endY - startY;
|
||||
const lenTrim = Math.hypot(dxTrim, dyTrim) || 1;
|
||||
const nx = -dyTrim / lenTrim;
|
||||
const ny = dxTrim / lenTrim;
|
||||
const midX = (startX + endX) / 2;
|
||||
const midY = (startY + endY) / 2;
|
||||
const curveSign = idx % 2 === 0 ? 1 : -1;
|
||||
const curve = Math.min(42 * s, lenTrim * 0.3) * curveSign;
|
||||
const cpx = midX + nx * curve;
|
||||
const cpy = midY + ny * curve;
|
||||
const dotRadius = Math.min(8, Math.max(4, lineWidth * 1.6));
|
||||
const rawValue = parseFloat(link.text);
|
||||
const hasRaw = Number.isFinite(rawValue);
|
||||
const isStatic = (link.textSrc ?? 0) === 0;
|
||||
const factor = hasRaw ? rawValue : (isStatic ? 60 : 1);
|
||||
const previewValue = 50;
|
||||
const speed = Math.max(5, isStatic ? factor : Math.abs(previewValue) * factor);
|
||||
const duration = Math.max(2, Math.min(10, lenTrim / speed));
|
||||
|
||||
return [{
|
||||
id: link.id,
|
||||
path: `M ${startX} ${startY} Q ${cpx} ${cpy} ${endX} ${endY}`,
|
||||
color: link.bgColor || '#6fa7d8',
|
||||
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
|
||||
width: lineWidth,
|
||||
dotRadius,
|
||||
duration
|
||||
}];
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
style.overflow = 'hidden';
|
||||
style.border = `1px solid ${hexToRgba('#94a3b8', 0.35)}`;
|
||||
|
||||
Object.assign(style, getShadowStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { fontSizes } from '../../../constants';
|
||||
import { getBaseStyle, clamp, hexToRgba, splitPowerNodeText } from '../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const iconChar = computed(() => {
|
||||
const cp = props.widget.iconCodepoint || 0xe88a;
|
||||
return String.fromCodePoint(cp);
|
||||
});
|
||||
|
||||
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 computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
if (store.powerLinkMode.active) {
|
||||
store.handlePowerNodeLink(props.widget.id, props.widget.parentId);
|
||||
return;
|
||||
}
|
||||
emit('select');
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<!-- Bubble Style -->
|
||||
<template v-if="widget.cardStyle !== 1">
|
||||
<!-- Central Bubble -->
|
||||
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
|
||||
{{ iconChar }}
|
||||
</span>
|
||||
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
|
||||
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
|
||||
</div>
|
||||
<!-- Sub-Buttons (circular orbit) -->
|
||||
<div
|
||||
v-for="(sb, idx) in roomCardSubButtons"
|
||||
:key="idx"
|
||||
class="absolute rounded-full flex items-center justify-center shadow-md"
|
||||
:style="getSubButtonStyle(sb, idx)"
|
||||
>
|
||||
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
|
||||
{{ String.fromCodePoint(sb.icon) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tile Style -->
|
||||
<template v-else>
|
||||
<div class="absolute inset-0 overflow-hidden" :style="roomCardTileContainerStyle">
|
||||
<!-- Room name (top-left) -->
|
||||
<div class="absolute" :style="{ left: '16px', top: '12px', color: widget.textColor, fontSize: fontSizes[widget.fontSize || 2] + 'px', fontWeight: 600 }">
|
||||
{{ roomCardParts.name }}
|
||||
</div>
|
||||
<!-- Text Lines -->
|
||||
<div class="absolute flex flex-col gap-1" :style="textLinesContainerStyle">
|
||||
<span v-for="(line, idx) in textLines" :key="idx" class="flex items-center gap-1" :style="getTextLineStyle(line)">
|
||||
<span v-if="line.icon" class="material-symbols-outlined" :style="{ fontSize: getTextLineIconSize(line) + 'px' }">{{ String.fromCodePoint(line.icon) }}</span>
|
||||
<span>{{ line.text || '--' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Large decorative icon (configurable position) -->
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined absolute opacity-20" :style="decorIconStyle">
|
||||
{{ iconChar }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Sub-Buttons (right side, vertical) -->
|
||||
<div
|
||||
v-for="(sb, idx) in roomCardSubButtons"
|
||||
:key="idx"
|
||||
class="absolute rounded-full flex items-center justify-center shadow-md"
|
||||
:style="getSubButtonStyleTile(sb, idx)"
|
||||
>
|
||||
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
|
||||
{{ String.fromCodePoint(sb.icon) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { fontSizes, iconFontSizes } from '../../../constants';
|
||||
import { getBaseStyle, clamp, hexToRgba } from '../shared/utils';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const iconChar = computed(() => {
|
||||
const cp = props.widget.iconCodepoint || 0xe88a;
|
||||
return String.fromCodePoint(cp);
|
||||
});
|
||||
|
||||
const roomCardParts = computed(() => {
|
||||
if (!props.widget.text) return { name: '', format: '' };
|
||||
const parts = props.widget.text.split('\n');
|
||||
return {
|
||||
name: parts[0] || '',
|
||||
format: props.widget.cardStyle === 1 ? '' : (parts[1] || '')
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
|
||||
const textLines = computed(() => props.widget.textLines || []);
|
||||
|
||||
const textLinesContainerStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
// Calculate top based on room name position + font size + padding
|
||||
const roomNameTop = 12;
|
||||
const roomNameFontSize = fontSizes[props.widget.fontSize || 2] || 22;
|
||||
const padding = 10;
|
||||
const top = (roomNameTop + roomNameFontSize + padding) * s;
|
||||
return {
|
||||
left: (16 * s) + 'px',
|
||||
top: top + 'px',
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
function getTextLineStyle(line) {
|
||||
const s = props.scale;
|
||||
const sizeIdx = line.fontSize ?? 1;
|
||||
return {
|
||||
fontSize: (fontSizes[sizeIdx] * s) + 'px'
|
||||
};
|
||||
}
|
||||
|
||||
function getTextLineIconSize(line) {
|
||||
const s = props.scale;
|
||||
const sizeIdx = line.fontSize ?? 1;
|
||||
return fontSizes[sizeIdx] * s;
|
||||
}
|
||||
|
||||
const roomCardTileContainerStyle = computed(() => {
|
||||
const alpha = (props.widget.bgOpacity ?? 255) / 255;
|
||||
const style = {
|
||||
backgroundColor: hexToRgba(props.widget.bgColor || '#333333', alpha),
|
||||
borderRadius: (props.widget.radius || 16) + 'px',
|
||||
};
|
||||
if (props.widget.shadow?.enabled) {
|
||||
style.border = `3px solid ${props.widget.shadow.color || '#ff6b6b'}`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const decorIconStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const w = props.widget;
|
||||
const x = (w.iconPositionX !== undefined && w.iconPositionX !== 0) ? w.iconPositionX * s : -20 * s;
|
||||
const y = (w.iconPositionY !== undefined && w.iconPositionY !== 0) ? w.iconPositionY * s : (w.h - 120) * s;
|
||||
const sizeIdx = w.iconSize >= 6 ? w.iconSize : 8;
|
||||
const fontSize = iconFontSizes[sizeIdx] || 96;
|
||||
|
||||
return {
|
||||
left: x + 'px',
|
||||
top: y + 'px',
|
||||
fontSize: (fontSize * s) + 'px',
|
||||
color: w.textColor,
|
||||
};
|
||||
});
|
||||
|
||||
const getSubButtonStyleTile = (sb, idx) => {
|
||||
const s = props.scale;
|
||||
const btnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const gap = 10 * s;
|
||||
const padding = 12 * s;
|
||||
|
||||
return {
|
||||
width: btnSize + 'px',
|
||||
height: btnSize + 'px',
|
||||
right: padding + 'px',
|
||||
top: (padding + idx * (btnSize + gap)) + 'px',
|
||||
backgroundColor: sb.colorOff || '#666666',
|
||||
};
|
||||
};
|
||||
|
||||
const roomCardBubbleStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const w = props.widget.w;
|
||||
const h = props.widget.h;
|
||||
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const padding = subBtnSize * 0.6;
|
||||
const bubbleSize = Math.min(w, h) * s - padding * 2;
|
||||
const left = (w * s - bubbleSize) / 2;
|
||||
const top = (h * s - bubbleSize) / 2;
|
||||
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
|
||||
|
||||
return {
|
||||
width: `${bubbleSize}px`,
|
||||
height: `${bubbleSize}px`,
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
backgroundColor: hexToRgba(props.widget.bgColor, alpha),
|
||||
color: props.widget.textColor,
|
||||
boxShadow: props.widget.shadow?.enabled
|
||||
? `${(props.widget.shadow.x || 0) * s}px ${(props.widget.shadow.y || 0) * s}px ${(props.widget.shadow.blur || 0) * s}px ${hexToRgba(props.widget.shadow.color || '#000000', 0.3)}`
|
||||
: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardIconStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.iconSize ?? 3;
|
||||
const size = fontSizes[sizeIdx] || 28;
|
||||
return {
|
||||
fontSize: `${size * s}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardNameStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.fontSize ?? 2;
|
||||
const size = fontSizes[sizeIdx] || 22;
|
||||
return {
|
||||
fontSize: `${size * s * 0.7}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardTempStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const sizeIdx = props.widget.fontSize ?? 2;
|
||||
const size = fontSizes[sizeIdx] || 22;
|
||||
return {
|
||||
fontSize: `${size * s * 0.55}px`,
|
||||
color: props.widget.textColor
|
||||
};
|
||||
});
|
||||
|
||||
function getSubButtonStyle(sb, idx) {
|
||||
const s = props.scale;
|
||||
const w = props.widget.w;
|
||||
const h = props.widget.h;
|
||||
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const centerX = w * s / 2;
|
||||
const centerY = h * s / 2;
|
||||
const orbitRadius = (props.widget.subButtonDistance || 80) * s;
|
||||
const pos = sb.pos ?? idx;
|
||||
const angle = (pos * (Math.PI / 4)) - (Math.PI / 2);
|
||||
const x = centerX + orbitRadius * Math.cos(angle) - subBtnSize / 2;
|
||||
const y = centerY + orbitRadius * Math.sin(angle) - subBtnSize / 2;
|
||||
const bgColor = sb.colorOff || '#666666';
|
||||
|
||||
return {
|
||||
width: `${subBtnSize}px`,
|
||||
height: `${subBtnSize}px`,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
backgroundColor: bgColor,
|
||||
border: `2px solid ${hexToRgba('#ffffff', 0.3)}`
|
||||
};
|
||||
}
|
||||
|
||||
function getSubButtonIconStyle(sb) {
|
||||
const s = props.scale;
|
||||
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
||||
const iconSize = subBtnSize * 0.5;
|
||||
return {
|
||||
fontSize: `${iconSize}px`,
|
||||
color: '#ffffff'
|
||||
};
|
||||
}
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
|
||||
if (w.cardStyle === 1) {
|
||||
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
||||
style.background = hexToRgba(w.bgColor || '#333333', alpha);
|
||||
style.borderRadius = `${(w.radius || 16) * s}px`;
|
||||
style.overflow = 'hidden';
|
||||
if (w.shadow && w.shadow.enabled) {
|
||||
style.border = `3px solid ${w.shadow.color || '#ff6b6b'}`;
|
||||
}
|
||||
} else {
|
||||
style.overflow = 'visible';
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<!-- Children widgets inside the tab page -->
|
||||
<WidgetElement
|
||||
v-for="child in children"
|
||||
: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)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { fontSizes } from '../../../constants';
|
||||
|
||||
const WidgetElement = defineAsyncComponent(() => import('../../WidgetElement.vue'));
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const children = computed(() => {
|
||||
if (!store.activeScreen) return [];
|
||||
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
|
||||
});
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
return {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
left: '0',
|
||||
top: '0',
|
||||
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
|
||||
color: w.textColor,
|
||||
zIndex: 1,
|
||||
userSelect: 'none',
|
||||
touchAction: 'none'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
126
web-interface/src/components/widgets/elements/TabViewElement.vue
Normal file
126
web-interface/src/components/widgets/elements/TabViewElement.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
|
||||
:style="computedStyle"
|
||||
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
|
||||
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
|
||||
<div
|
||||
v-for="(child, idx) in children"
|
||||
:key="child.id"
|
||||
:class="tabBtnClass(activePageId === child.id)"
|
||||
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
|
||||
>
|
||||
{{ child.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 relative overflow-hidden">
|
||||
<TabPageElement
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
:widget="child"
|
||||
:scale="scale"
|
||||
:selected="store.selectedWidgetId === child.id"
|
||||
:style="{ display: activePageId === child.id ? 'block' : 'none' }"
|
||||
@select="store.selectedWidgetId = child.id"
|
||||
@drag-start="$emit('drag-start', $event)"
|
||||
@resize-start="$emit('resize-start', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { getBaseStyle } from '../shared/utils';
|
||||
import TabPageElement from './TabPageElement.vue';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
||||
|
||||
const store = useEditorStore();
|
||||
const activeTabIndex = ref(0);
|
||||
|
||||
const children = computed(() => {
|
||||
if (!store.activeScreen) return [];
|
||||
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
|
||||
});
|
||||
|
||||
const tabPosition = computed(() => props.widget.iconPosition || 0);
|
||||
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
||||
|
||||
const activePageId = computed(() => {
|
||||
const selectedChild = children.value.find(c => store.selectedWidgetId === c.id);
|
||||
if (selectedChild) return selectedChild.id;
|
||||
|
||||
if (store.selectedWidget && store.selectedWidget.parentId !== -1) {
|
||||
let curr = store.selectedWidget;
|
||||
while (curr && curr.parentId !== -1 && curr.parentId !== props.widget.id) {
|
||||
curr = store.activeScreen.widgets.find(w => w.id === curr.parentId);
|
||||
}
|
||||
if (curr && curr.parentId === props.widget.id) return curr.id;
|
||||
}
|
||||
|
||||
if (children.value.length > 0) return children.value[activeTabIndex.value]?.id;
|
||||
return -1;
|
||||
});
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
style.background = w.bgColor;
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
const tabViewStyle = computed(() => ({
|
||||
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
|
||||
}));
|
||||
|
||||
const tabBtnsStyle = computed(() => {
|
||||
const s = props.scale;
|
||||
const h = tabHeight.value * s;
|
||||
const isVert = tabPosition.value === 0 || tabPosition.value === 1;
|
||||
|
||||
return {
|
||||
[isVert ? 'height' : 'width']: `${h}px`,
|
||||
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0,
|
||||
flexDirection: isVert ? 'row' : 'column'
|
||||
};
|
||||
});
|
||||
|
||||
const tabBtnClass = (isActive) => {
|
||||
const isVerticalTabs = tabPosition.value === 2 || tabPosition.value === 3;
|
||||
const base = 'flex-1 flex items-center justify-center px-1 py-1 text-[12px] cursor-pointer select-none';
|
||||
const border = isVerticalTabs ? 'border-b border-white/10' : 'border-b border-r border-white/10';
|
||||
const active = isVerticalTabs
|
||||
? 'bg-white/10 font-bold border-b-0 border-r-2 border-accent'
|
||||
: 'bg-white/10 font-bold border-b-2 border-accent';
|
||||
return `${base} ${border}${isActive ? ` ${active}` : ''}`;
|
||||
};
|
||||
</script>
|
||||
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Content -->
|
||||
<h4 :class="headingClass">Inhalt</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.button)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.textSrc === 0" :class="rowClass">
|
||||
<label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="widget.text">
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
|
||||
<!-- Action -->
|
||||
<h4 :class="headingClass">Aktion</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Typ</label>
|
||||
<select :class="inputClass" v-model.number="widget.action">
|
||||
<option :value="BUTTON_ACTIONS.KNX">KNX</option>
|
||||
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
|
||||
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
|
||||
<label :class="labelClass">Ziel</label>
|
||||
<select :class="inputClass" v-model.number="widget.targetScreen">
|
||||
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="widget.action === BUTTON_ACTIONS.KNX">
|
||||
<div :class="rowClass"><label :class="labelClass">Toggle</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.isToggle"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">KNX Schreib</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddrWrite">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups, BUTTON_ACTIONS } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const store = useEditorStore();
|
||||
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
</script>
|
||||
108
web-interface/src/components/widgets/settings/ChartSettings.vue
Normal file
108
web-interface/src/components/widgets/settings/ChartSettings.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Chart -->
|
||||
<h4 :class="headingClass">Chart</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="widget.text"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Zeitraum</label>
|
||||
<select :class="inputClass" v-model.number="widget.chart.period">
|
||||
<option v-for="opt in chartPeriods" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Serien</label>
|
||||
<select :class="inputClass" v-model.number="chartSeriesCount">
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="3">3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-for="(series, idx) in chartSeries" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">Serie {{ idx + 1 }}</label>
|
||||
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="series.color">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">Quelle</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.chart)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">KNX</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups, chartPeriods } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const chartSeries = computed(() => props.widget?.chart?.series ?? []);
|
||||
|
||||
const chartSeriesCount = computed({
|
||||
get() {
|
||||
const count = chartSeries.value.length || 1;
|
||||
return Math.max(1, Math.min(count, 3));
|
||||
},
|
||||
set(value) {
|
||||
if (!props.widget || !props.widget.chart) return;
|
||||
const target = Math.max(1, Math.min(value, 3));
|
||||
const colors = ['#EF6351', '#7DD3B0', '#5EA2EF'];
|
||||
if (!Array.isArray(props.widget.chart.series)) {
|
||||
props.widget.chart.series = [];
|
||||
}
|
||||
while (props.widget.chart.series.length < target) {
|
||||
const idx = props.widget.chart.series.length;
|
||||
props.widget.chart.series.push({
|
||||
knxAddr: 0,
|
||||
textSrc: 1,
|
||||
color: colors[idx] || '#EF6351'
|
||||
});
|
||||
}
|
||||
if (props.widget.chart.series.length > target) {
|
||||
props.widget.chart.series = props.widget.chart.series.slice(0, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Clock -->
|
||||
<h4 :class="headingClass">Uhr</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Zeiger</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Icon -->
|
||||
<h4 :class="headingClass">Icon</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Icon</label>
|
||||
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
|
||||
<span v-else>Auswaehlen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Groesse</label>
|
||||
<select :class="inputClass" v-model.number="widget.iconSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Modus</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.icon)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.textSrc === 2" :class="rowClass">
|
||||
<label :class="labelClass">KNX Objekt</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups, fontSizes } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, iconSelectClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['open-icon-picker']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
</script>
|
||||
113
web-interface/src/components/widgets/settings/LabelSettings.vue
Normal file
113
web-interface/src/components/widgets/settings/LabelSettings.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Content -->
|
||||
<h4 :class="headingClass">Inhalt</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc" @change="handleTextSrcChange">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.label)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.textSrc === 0" :class="rowClass">
|
||||
<label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="widget.text">
|
||||
</div>
|
||||
<template v-else>
|
||||
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="widget.text"></div>
|
||||
<div v-if="widget.textSrc < 11" :class="rowClass"><label :class="labelClass">KNX Objekt</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Typography -->
|
||||
<h4 :class="headingClass">Typo</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Schriftgr.</label>
|
||||
<select :class="inputClass" v-model.number="widget.fontSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<h4 :class="headingClass">Icon</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Icon</label>
|
||||
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
|
||||
<span v-else>Kein Icon</span>
|
||||
</button>
|
||||
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
|
||||
</div>
|
||||
<template v-if="widget.iconCodepoint">
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Position</label>
|
||||
<select :class="inputClass" v-model.number="widget.iconPosition">
|
||||
<option :value="0">Links</option>
|
||||
<option :value="1">Rechts</option>
|
||||
<option :value="2">Oben</option>
|
||||
<option :value="3">Unten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Icon-Gr.</label>
|
||||
<select :class="inputClass" v-model.number="widget.iconSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Abstand</label>
|
||||
<input :class="inputClass" type="number" v-model.number="widget.iconGap" min="0" max="50">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups, fontSizes, defaultFormats } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, iconSelectClass, iconRemoveClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['open-icon-picker']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
|
||||
function handleTextSrcChange() {
|
||||
const newSrc = props.widget.textSrc;
|
||||
if (newSrc > 0 && defaultFormats[newSrc]) {
|
||||
props.widget.text = defaultFormats[newSrc];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- LED -->
|
||||
<h4 :class="headingClass">LED</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Modus</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.led)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.textSrc === 2" :class="rowClass">
|
||||
<label :class="labelClass">KNX Objekt</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Helligkeit</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
|
||||
<!-- Glow -->
|
||||
<h4 :class="headingClass">Glow</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Power Flow -->
|
||||
<h4 :class="headingClass">Power Flow</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="widget.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="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<span class="px-1.5 py-0.5 rounded-md bg-white 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-white border border-border text-text max-w-[90px] truncate">{{ link.toLabel }}</span>
|
||||
<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 class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">Linie</label>
|
||||
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="link.widget.bgColor">
|
||||
<input class="w-[70px] bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="number" min="1" max="12" v-model.number="link.widget.w">
|
||||
<span class="text-[10px] text-muted">px</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">Speed</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.powerlink)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="link.widget.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">Wert</label>
|
||||
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" inputmode="decimal" v-model="link.widget.text" placeholder="z.B. 60">
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2 text-[11px] text-muted">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="w-[70px] text-[11px] text-muted">Faktor</label>
|
||||
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" inputmode="decimal" v-model="link.widget.text" placeholder="z.B. 0.2">
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="w-[70px] text-[11px] text-muted">KNX</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-[11px] text-muted">Keine Verbindungen.</div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups, WIDGET_TYPES } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const powerNodeCount = computed(() => {
|
||||
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW || !store.activeScreen) return 0;
|
||||
return store.activeScreen.widgets.filter((child) => child.parentId === props.widget.id && child.type === WIDGET_TYPES.POWERNODE).length;
|
||||
});
|
||||
|
||||
const powerFlowLinkItems = computed(() => {
|
||||
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW || !store.activeScreen) return [];
|
||||
const nodes = store.activeScreen.widgets.filter((child) => child.parentId === props.widget.id && child.type === WIDGET_TYPES.POWERNODE);
|
||||
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
||||
|
||||
return store.activeScreen.widgets
|
||||
.filter((child) => child.parentId === props.widget.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 === props.widget?.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.`;
|
||||
});
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
|
||||
function addPowerNode() {
|
||||
store.addWidget('powernode');
|
||||
}
|
||||
|
||||
function togglePowerLinkMode() {
|
||||
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW) return;
|
||||
const nextState = !(store.powerLinkMode.active && store.powerLinkMode.powerflowId === props.widget.id);
|
||||
store.setPowerLinkMode(nextState, props.widget.id);
|
||||
}
|
||||
|
||||
function clearPowerLinkSource() {
|
||||
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW) return;
|
||||
store.powerLinkMode.fromNodeId = null;
|
||||
}
|
||||
|
||||
function removePowerLink(linkId) {
|
||||
store.removePowerLink(linkId);
|
||||
}
|
||||
|
||||
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 getPowerNodeLabel(node, fallbackId) {
|
||||
if (!node) return `Node ${fallbackId ?? ''}`.trim();
|
||||
const parts = splitPowerNodeText(node.text);
|
||||
return parts.label || `Node ${node.id}`;
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Power Node -->
|
||||
<h4 :class="headingClass">Power Node</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Label</label><input :class="inputClass" type="text" v-model="powerNodeLabel"></div>
|
||||
|
||||
<!-- Primary Value (Bottom) -->
|
||||
<h4 :class="subHeadingClass">Unten (Primaer)</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc" @change="handleTextSrcChange">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.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="widget.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Secondary Value (Left) -->
|
||||
<h4 :class="subHeadingClass">Links (Sekundaer)</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc2">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="widget.textSrc2 > 0">
|
||||
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="widget.text2" maxlength="15"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddr2">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tertiary Value (Right) -->
|
||||
<h4 :class="subHeadingClass">Rechts (Tertiaer)</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc3">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="widget.textSrc3 > 0">
|
||||
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="widget.text3" maxlength="15"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddr3">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Conditions -->
|
||||
<h4 :class="headingClass">Bedingungen</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Anzahl</label>
|
||||
<select :class="inputClass" v-model.number="conditionCount">
|
||||
<option :value="0">Keine</option>
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="3">3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-for="(cond, idx) in conditions" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Bed. {{ idx + 1 }}</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model="cond.source">
|
||||
<option value="primary">Unten</option>
|
||||
<option value="secondary">Links</option>
|
||||
<option value="tertiary">Rechts</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Wenn</label>
|
||||
<select class="w-[60px] bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model="cond.op">
|
||||
<option value="lt"><</option>
|
||||
<option value="lte"><=</option>
|
||||
<option value="eq">=</option>
|
||||
<option value="gte">>=</option>
|
||||
<option value="gt">></option>
|
||||
<option value="neq">!=</option>
|
||||
</select>
|
||||
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="number" step="0.1" v-model.number="cond.threshold" placeholder="Schwelle">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Icon</label>
|
||||
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-condition-icon-picker', idx)">
|
||||
<span v-if="cond.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(cond.icon) }}</span>
|
||||
<span v-else>Kein Icon</span>
|
||||
</button>
|
||||
<button v-if="cond.icon" class="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="cond.icon = 0">x</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Farbe</label>
|
||||
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="cond.textColor">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typography -->
|
||||
<h4 :class="headingClass">Typo</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Wert Schriftgr.</label>
|
||||
<select :class="inputClass" v-model.number="widget.fontSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<h4 :class="headingClass">Icon</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Icon</label>
|
||||
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
|
||||
<span v-else>Kein Icon</span>
|
||||
</button>
|
||||
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
|
||||
</div>
|
||||
<template v-if="widget.iconCodepoint">
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Icon-Gr.</label>
|
||||
<select :class="inputClass" v-model.number="widget.iconSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Ringfarbe</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Ring Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
|
||||
<!-- Glow -->
|
||||
<h4 :class="headingClass">Glow</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups, fontSizes, defaultFormats } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, subHeadingClass, iconSelectClass, iconRemoveClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['open-icon-picker', 'open-condition-icon-picker']);
|
||||
|
||||
const store = useEditorStore();
|
||||
|
||||
const conditions = computed(() => props.widget?.conditions ?? []);
|
||||
|
||||
const conditionCount = computed({
|
||||
get() {
|
||||
return conditions.value.length || 0;
|
||||
},
|
||||
set(value) {
|
||||
if (!props.widget) return;
|
||||
const target = Math.max(0, Math.min(value, 3));
|
||||
if (!Array.isArray(props.widget.conditions)) {
|
||||
props.widget.conditions = [];
|
||||
}
|
||||
while (props.widget.conditions.length < target) {
|
||||
props.widget.conditions.push({
|
||||
source: 'secondary',
|
||||
threshold: 0,
|
||||
op: 'lt',
|
||||
priority: props.widget.conditions.length,
|
||||
icon: 0,
|
||||
textColor: '#FF0000'
|
||||
});
|
||||
}
|
||||
if (props.widget.conditions.length > target) {
|
||||
props.widget.conditions = props.widget.conditions.slice(0, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const powerNodeLabel = computed({
|
||||
get() {
|
||||
return splitPowerNodeText(props.widget?.text).label;
|
||||
},
|
||||
set(value) {
|
||||
const parts = splitPowerNodeText(props.widget?.text);
|
||||
setPowerNodeText(value, parts.value);
|
||||
}
|
||||
});
|
||||
|
||||
const powerNodeValue = computed({
|
||||
get() {
|
||||
return splitPowerNodeText(props.widget?.text).value;
|
||||
},
|
||||
set(value) {
|
||||
const parts = splitPowerNodeText(props.widget?.text);
|
||||
setPowerNodeText(parts.label, value);
|
||||
}
|
||||
});
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
|
||||
function handleTextSrcChange() {
|
||||
const newSrc = props.widget.textSrc;
|
||||
if (newSrc > 0 && defaultFormats[newSrc]) {
|
||||
const parts = splitPowerNodeText(props.widget.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 (!props.widget) return;
|
||||
const labelLine = label ?? '';
|
||||
const valueLine = value ?? '';
|
||||
props.widget.text = valueLine !== '' || labelLine !== '' ? `${labelLine}${valueLine !== '' ? `\n${valueLine}` : ''}` : '';
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Room Card -->
|
||||
<h4 :class="headingClass">Room Card</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Raumname</label><input :class="inputClass" type="text" v-model="roomCardName"></div>
|
||||
<!-- Bubble style: Temperature via primary textSrc/knxAddr -->
|
||||
<template v-if="widget.cardStyle !== 1">
|
||||
<div :class="rowClass"><label :class="labelClass">Temp. Format</label><input :class="inputClass" type="text" v-model="roomCardFormat"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Temp. Quelle</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.roomcard)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.textSrc > 0" :class="rowClass"><label :class="labelClass">KNX Objekt</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="rowClass"><label :class="labelClass">Klick-Aktion</label>
|
||||
<select :class="inputClass" v-model.number="widget.action">
|
||||
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
|
||||
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
|
||||
<label :class="labelClass">Ziel Screen</label>
|
||||
<select :class="inputClass" v-model.number="widget.targetScreen">
|
||||
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Variable Text Lines (Tile style only) -->
|
||||
<template v-if="widget.cardStyle === 1">
|
||||
<h4 :class="headingClass">Textzeilen</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Anzahl</label>
|
||||
<select :class="inputClass" v-model.number="textLineCount">
|
||||
<option :value="0">Keine</option>
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="3">3</option>
|
||||
<option :value="4">4</option>
|
||||
<option :value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-for="(line, idx) in textLines" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Zeile {{ idx + 1 }}</label>
|
||||
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-textline-icon-picker', idx)">
|
||||
<span v-if="line.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(line.icon) }}</span>
|
||||
<span v-else>Kein Icon</span>
|
||||
</button>
|
||||
<select class="w-[70px] bg-white border border-border rounded-md px-1 py-1 text-[11px]" v-model.number="line.fontSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}px</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Quelle</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="line.textSrc">
|
||||
<option :value="0">Statisch</option>
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.roomcard)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="line.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Text</label>
|
||||
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" v-model="line.text" placeholder="z.B. 21.5°C">
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Format</label>
|
||||
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" v-model="line.text" placeholder="z.B. %.1f°C">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">KNX</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="line.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Icon -->
|
||||
<h4 :class="headingClass">Icon</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Icon</label>
|
||||
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
|
||||
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
|
||||
<span v-else>Auswaehlen</span>
|
||||
</button>
|
||||
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
|
||||
</div>
|
||||
<template v-if="widget.iconCodepoint">
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Icon-Gr.</label>
|
||||
<select :class="inputClass" v-model.number="widget.iconSize">
|
||||
<option v-for="(size, idx) in iconFontSizes" :key="idx" :value="idx">{{ size }}px</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="widget.cardStyle === 1" :class="rowClass">
|
||||
<label :class="labelClass">Icon X</label>
|
||||
<input :class="inputClass" type="number" v-model.number="widget.iconPositionX" min="-100" max="400">
|
||||
<span class="text-[10px] text-muted">px</span>
|
||||
</div>
|
||||
<div v-if="widget.cardStyle === 1" :class="rowClass">
|
||||
<label :class="labelClass">Icon Y</label>
|
||||
<input :class="inputClass" type="number" v-model.number="widget.iconPositionY" min="-100" max="400">
|
||||
<span class="text-[10px] text-muted">px</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Card Style -->
|
||||
<h4 :class="headingClass">Karten-Stil</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Layout</label>
|
||||
<select :class="inputClass" v-model.number="widget.cardStyle">
|
||||
<option :value="0">Bubble (rund)</option>
|
||||
<option :value="1">Tile (rechteckig)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sub-Buttons -->
|
||||
<h4 :class="headingClass">Sub-Buttons</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Anzahl</label>
|
||||
<select :class="inputClass" v-model.number="subButtonCount">
|
||||
<option :value="0">Keine</option>
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="3">3</option>
|
||||
<option :value="4">4</option>
|
||||
<option :value="5">5</option>
|
||||
<option :value="6">6</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="subButtonCount > 0" :class="rowClass">
|
||||
<label :class="labelClass">Button-Gr.</label>
|
||||
<input :class="inputClass" type="number" min="30" max="80" v-model.number="widget.subButtonSize">
|
||||
<span class="text-[10px] text-muted">px</span>
|
||||
</div>
|
||||
<div v-if="subButtonCount > 0" :class="rowClass">
|
||||
<label :class="labelClass">Abstand</label>
|
||||
<input :class="inputClass" type="number" min="40" max="200" v-model.number="widget.subButtonDistance">
|
||||
<span class="text-[10px] text-muted">px</span>
|
||||
</div>
|
||||
<div v-if="subButtonCount > 0" :class="rowClass">
|
||||
<label :class="labelClass">Btn Opacity</label>
|
||||
<input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.subButtonOpacity">
|
||||
</div>
|
||||
<div v-for="(sb, idx) in subButtons" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Btn {{ idx + 1 }}</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.pos">
|
||||
<option v-for="(label, pos) in SUBBUTTON_POSITION_LABELS" :key="pos" :value="Number(pos)">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Icon</label>
|
||||
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-subbutton-icon-picker', idx)">
|
||||
<span v-if="sb.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(sb.icon) }}</span>
|
||||
<span v-else>Kein Icon</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Aktion</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.action">
|
||||
<option :value="0">KNX Toggle</option>
|
||||
<option :value="1">Navigation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">KNX R</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxRead">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">KNX W</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxWrite">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="sb.action === 1" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Ziel</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.target">
|
||||
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-muted">
|
||||
<label class="w-[50px]">Farben</label>
|
||||
<span class="text-[10px]">An:</span>
|
||||
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOn">
|
||||
<span class="text-[10px]">Aus:</span>
|
||||
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOff">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useEditorStore } from '../../../stores/editor';
|
||||
import { sourceOptions, textSources, textSourceGroups, fontSizes, iconFontSizes, BUTTON_ACTIONS, SUBBUTTON_POSITION_LABELS } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, subHeadingClass, iconSelectClass, iconRemoveClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['open-icon-picker', 'open-subbutton-icon-picker', 'open-textline-icon-picker']);
|
||||
|
||||
const store = useEditorStore();
|
||||
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
||||
|
||||
const roomCardName = computed({
|
||||
get() {
|
||||
if (!props.widget?.text) return '';
|
||||
const parts = props.widget.text.split('\n');
|
||||
return parts[0] || '';
|
||||
},
|
||||
set(value) {
|
||||
if (!props.widget) return;
|
||||
const parts = props.widget.text ? props.widget.text.split('\n') : ['', ''];
|
||||
parts[0] = value;
|
||||
props.widget.text = parts.join('\n');
|
||||
}
|
||||
});
|
||||
|
||||
const roomCardFormat = computed({
|
||||
get() {
|
||||
if (!props.widget?.text) return '';
|
||||
const parts = props.widget.text.split('\n');
|
||||
return parts[1] || '';
|
||||
},
|
||||
set(value) {
|
||||
if (!props.widget) return;
|
||||
const parts = props.widget.text ? props.widget.text.split('\n') : ['', ''];
|
||||
parts[1] = value;
|
||||
props.widget.text = parts.join('\n');
|
||||
}
|
||||
});
|
||||
|
||||
const subButtons = computed(() => props.widget?.subButtons ?? []);
|
||||
const textLines = computed(() => props.widget?.textLines ?? []);
|
||||
|
||||
const textLineCount = computed({
|
||||
get() {
|
||||
return textLines.value.length || 0;
|
||||
},
|
||||
set(value) {
|
||||
if (!props.widget) return;
|
||||
const target = Math.max(0, Math.min(value, 5));
|
||||
if (!Array.isArray(props.widget.textLines)) {
|
||||
props.widget.textLines = [];
|
||||
}
|
||||
while (props.widget.textLines.length < target) {
|
||||
props.widget.textLines.push({
|
||||
text: '',
|
||||
textSrc: 0,
|
||||
knxAddr: 0,
|
||||
icon: 0,
|
||||
fontSize: 1 // Default 18px
|
||||
});
|
||||
}
|
||||
if (props.widget.textLines.length > target) {
|
||||
props.widget.textLines = props.widget.textLines.slice(0, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const subButtonCount = computed({
|
||||
get() {
|
||||
return subButtons.value.length || 0;
|
||||
},
|
||||
set(value) {
|
||||
if (!props.widget) return;
|
||||
const target = Math.max(0, Math.min(value, 6));
|
||||
if (!Array.isArray(props.widget.subButtons)) {
|
||||
props.widget.subButtons = [];
|
||||
}
|
||||
while (props.widget.subButtons.length < target) {
|
||||
props.widget.subButtons.push({
|
||||
pos: props.widget.subButtons.length,
|
||||
icon: 0,
|
||||
knxRead: 0,
|
||||
knxWrite: 0,
|
||||
action: 0,
|
||||
target: 0,
|
||||
colorOn: '#FFCC00',
|
||||
colorOff: '#666666'
|
||||
});
|
||||
}
|
||||
if (props.widget.subButtons.length > target) {
|
||||
props.widget.subButtons = props.widget.subButtons.slice(0, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function groupedSources(options) {
|
||||
const allowed = new Set(options || []);
|
||||
return textSourceGroups
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
values: group.values.filter((value) => allowed.has(value))
|
||||
}))
|
||||
.filter((group) => group.values.length > 0);
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Tab Page -->
|
||||
<h4 :class="headingClass">Tab Seite</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="widget.text"></div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Tabs -->
|
||||
<h4 :class="headingClass">Tabs</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Position</label>
|
||||
<select :class="inputClass" v-model.number="widget.iconPosition">
|
||||
<option :value="0">Oben</option>
|
||||
<option :value="1">Unten</option>
|
||||
<option :value="2">Links</option>
|
||||
<option :value="3">Rechts</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Tab Hoehe</label>
|
||||
<input :class="inputClass" type="number" v-model.number="widget.iconSize" min="1" max="20">
|
||||
<span class="text-[10px] text-muted ml-1">x10px</span>
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
|
||||
|
||||
<!-- Shadow -->
|
||||
<h4 :class="headingClass">Schatten</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
</script>
|
||||
67
web-interface/src/components/widgets/shared/BaseWidget.vue
Normal file
67
web-interface/src/components/widgets/shared/BaseWidget.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div
|
||||
class="z-[1] select-none touch-none"
|
||||
:class="[
|
||||
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
|
||||
extraClass
|
||||
]"
|
||||
:style="baseStyle"
|
||||
@mousedown.stop="!noInteraction && $emit('drag-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="!noInteraction && $emit('drag-start', { id: widget.id, event: $event })"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
v-if="selected"
|
||||
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
||||
data-resize-handle
|
||||
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||
>
|
||||
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { fontSizes } from '../../../constants';
|
||||
|
||||
const props = defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
scale: { type: Number, default: 1 },
|
||||
selected: { type: Boolean, default: false },
|
||||
extraClass: { type: String, default: '' },
|
||||
extraStyle: { type: Object, default: () => ({}) },
|
||||
noInteraction: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'drag-start', 'resize-start', 'click']);
|
||||
|
||||
const baseStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
return {
|
||||
left: `${w.x * s}px`,
|
||||
top: `${w.y * s}px`,
|
||||
width: `${w.w * s}px`,
|
||||
height: `${w.h * s}px`,
|
||||
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
|
||||
color: w.textColor,
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
cursor: 'move',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
...props.extraStyle
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(event) {
|
||||
emit('click', event);
|
||||
emit('select');
|
||||
}
|
||||
</script>
|
||||
11
web-interface/src/components/widgets/shared/styles.js
Normal file
11
web-interface/src/components/widgets/shared/styles.js
Normal file
@ -0,0 +1,11 @@
|
||||
// Shared CSS classes for settings components
|
||||
export const rowClass = 'flex items-center gap-2.5 mb-2';
|
||||
export const labelClass = 'w-[90px] text-[12px] text-muted';
|
||||
export const inputClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent';
|
||||
export const headingClass = 'mt-4 mb-2.5 text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]';
|
||||
export const subHeadingClass = 'mt-3 mb-2 text-[11px] uppercase tracking-[0.06em] text-[#5a7f9a]';
|
||||
export const noteClass = 'text-[11px] text-muted leading-tight';
|
||||
export 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]';
|
||||
export 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]';
|
||||
export const colorInputClass = 'h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0';
|
||||
export const smallColorInputClass = 'h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0';
|
||||
58
web-interface/src/components/widgets/shared/utils.js
Normal file
58
web-interface/src/components/widgets/shared/utils.js
Normal file
@ -0,0 +1,58 @@
|
||||
// Shared utility functions for widget rendering
|
||||
import { fontSizes } from '../../../constants';
|
||||
import { clamp, hexToRgba } from '../../../utils';
|
||||
|
||||
export { clamp, hexToRgba };
|
||||
|
||||
// Text alignment helpers
|
||||
export function justifyForAlign(align) {
|
||||
if (align === 0) return 'flex-start';
|
||||
if (align === 2) return 'flex-end';
|
||||
return 'center';
|
||||
}
|
||||
|
||||
export function textAlignCss(align) {
|
||||
if (align === 0) return 'left';
|
||||
if (align === 2) return 'right';
|
||||
return 'center';
|
||||
}
|
||||
|
||||
// Base style for all widgets
|
||||
export function getBaseStyle(widget, scale) {
|
||||
return {
|
||||
left: `${widget.x * scale}px`,
|
||||
top: `${widget.y * scale}px`,
|
||||
width: `${widget.w * scale}px`,
|
||||
height: `${widget.h * scale}px`,
|
||||
fontSize: `${(fontSizes[widget.fontSize] || 14) * scale}px`,
|
||||
color: widget.textColor,
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
cursor: 'move',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none'
|
||||
};
|
||||
}
|
||||
|
||||
// Shadow style helper
|
||||
export function getShadowStyle(widget, scale) {
|
||||
if (!widget.shadow || !widget.shadow.enabled) return {};
|
||||
|
||||
const sx = (widget.shadow.x || 0) * scale;
|
||||
const sy = (widget.shadow.y || 0) * scale;
|
||||
const blur = (widget.shadow.blur || 0) * scale;
|
||||
const spread = (widget.shadow.spread || 0) * scale;
|
||||
|
||||
return {
|
||||
boxShadow: `${sx}px ${sy}px ${blur}px ${spread}px ${widget.shadow.color}`
|
||||
};
|
||||
}
|
||||
|
||||
// PowerNode text splitting
|
||||
export 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 };
|
||||
}
|
||||
@ -155,6 +155,10 @@ export const ICON_DEFAULTS = {
|
||||
|
||||
export const fontSizes = [14, 18, 22, 28, 36, 48];
|
||||
|
||||
// Icon fonts have additional larger sizes for decorative use
|
||||
// Large icons use PSRAM for draw buffers (ESP32-P4 with 32MB PSRAM)
|
||||
export const iconFontSizes = [14, 18, 22, 28, 36, 48, 64, 80, 96, 120, 150, 180, 220, 260];
|
||||
|
||||
export const defaultFormats = {
|
||||
1: '%.1f °C',
|
||||
2: '%s',
|
||||
@ -441,7 +445,7 @@ export const WIDGET_DEFAULTS = {
|
||||
textColor: '#223447',
|
||||
bgColor: '#FFFFFF',
|
||||
bgOpacity: 255,
|
||||
radius: 100,
|
||||
radius: 16, // For Tile style (Bubble ignores this)
|
||||
shadow: { enabled: true, x: 0, y: 4, blur: 12, spread: 0, color: '#00000022' },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
@ -456,6 +460,7 @@ export const WIDGET_DEFAULTS = {
|
||||
subButtonDistance: 80, // Distance from center in pixels
|
||||
subButtonOpacity: 255, // Sub-button opacity (0-255)
|
||||
cardStyle: 0, // 0=Bubble (round), 1=Tile (rectangular)
|
||||
subButtons: []
|
||||
subButtons: [],
|
||||
textLines: [] // Variable text lines with icon, text, textSrc, knxAddr, fontSize
|
||||
}
|
||||
};
|
||||
|
||||
@ -168,7 +168,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
iconCodepoint: w.iconCodepoint || 0,
|
||||
iconPosition: w.iconPosition ?? defaults.iconPosition,
|
||||
iconSize: w.iconSize ?? defaults.iconSize,
|
||||
iconGap: w.iconGap ?? defaults.iconGap
|
||||
iconGap: w.iconGap ?? defaults.iconGap,
|
||||
iconPositionX: w.iconPositionX ?? defaults.iconPositionX,
|
||||
iconPositionY: w.iconPositionY ?? defaults.iconPositionY,
|
||||
};
|
||||
|
||||
screen.widgets.push(label);
|
||||
@ -408,7 +410,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
iconCodepoint: defaults.iconCodepoint || 0,
|
||||
iconPosition: defaults.iconPosition || 0,
|
||||
iconSize: defaults.iconSize || 1,
|
||||
iconGap: defaults.iconGap || 8
|
||||
iconGap: defaults.iconGap || 8,
|
||||
iconPositionX: defaults.iconPositionX || 8,
|
||||
iconPositionY: defaults.iconPositionY || 8
|
||||
};
|
||||
|
||||
if (defaults.chart) {
|
||||
@ -429,9 +433,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
w.conditions = [];
|
||||
}
|
||||
|
||||
// RoomCard: initialize sub-buttons array
|
||||
// RoomCard: initialize sub-buttons and text lines arrays
|
||||
if (typeStr === 'roomcard') {
|
||||
w.subButtons = [];
|
||||
w.textLines = [];
|
||||
}
|
||||
|
||||
activeScreen.value.widgets.push(w);
|
||||
@ -465,7 +470,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
iconCodepoint: w.iconCodepoint || 0,
|
||||
iconPosition: w.iconPosition || 0,
|
||||
iconSize: w.iconSize || 1,
|
||||
iconGap: w.iconGap || 8
|
||||
iconGap: w.iconGap || 8,
|
||||
iconPositionX: w.iconPositionX || 8,
|
||||
iconPositionY: w.iconPositionY || 8
|
||||
};
|
||||
activeScreen.value.widgets.push(label);
|
||||
}
|
||||
@ -526,7 +533,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
iconCodepoint: defaults.iconCodepoint || 0,
|
||||
iconPosition: defaults.iconPosition || 0,
|
||||
iconSize: defaults.iconSize || 0,
|
||||
iconGap: defaults.iconGap || 0
|
||||
iconGap: defaults.iconGap || 0,
|
||||
iconPositionX: defaults.iconPositionX || 0,
|
||||
iconPositionY: defaults.iconPositionY || 0,
|
||||
};
|
||||
|
||||
activeScreen.value.widgets.push(link);
|
||||
|
||||
@ -71,6 +71,19 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
||||
}
|
||||
}
|
||||
|
||||
// TextLines: ensure each widget has its own array and each line has fontSize
|
||||
if (defaults.textLines !== undefined) {
|
||||
if (!Array.isArray(w.textLines)) {
|
||||
w.textLines = [];
|
||||
}
|
||||
// Ensure each textLine has a fontSize
|
||||
w.textLines.forEach(tl => {
|
||||
if (tl.fontSize === undefined || tl.fontSize === null) {
|
||||
tl.fontSize = 1; // Default 18px
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!w.shadow) {
|
||||
w.shadow = { ...defaults.shadow };
|
||||
} else {
|
||||
@ -126,6 +139,8 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
||||
if (w.iconPosition === undefined) w.iconPosition = defaults.iconPosition || 0;
|
||||
if (w.iconSize === undefined) w.iconSize = defaults.iconSize || 1;
|
||||
if (w.iconGap === undefined) w.iconGap = defaults.iconGap || 8;
|
||||
if (w.iconPositionX === undefined) w.iconPositionX = defaults.iconPositionX || 0;
|
||||
if (w.iconPositionY === undefined) w.iconPositionY = defaults.iconPositionY || 0;
|
||||
|
||||
// Hierarchy
|
||||
if (w.parentId === undefined) w.parentId = -1;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user