Compare commits
5 Commits
ae8bb5a01f
...
3dfd2b461d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dfd2b461d | |||
| adf0f26da0 | |||
| 2ea18624fc | |||
| 21126bb3a8 | |||
| 6a8b74a652 |
BIN
.cache/clangd/index/ArcWidget.cpp.C855CC6EB2D676E3.idx
Normal file
BIN
.cache/clangd/index/ArcWidget.cpp.C855CC6EB2D676E3.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/ArcWidget.hpp.6C1EF191718DCFF6.idx
Normal file
BIN
.cache/clangd/index/ArcWidget.hpp.6C1EF191718DCFF6.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/ButtonMatrixWidget.cpp.13156E59C1946347.idx
Normal file
BIN
.cache/clangd/index/ButtonMatrixWidget.cpp.13156E59C1946347.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/ButtonMatrixWidget.hpp.580A8B6A2F8B776F.idx
Normal file
BIN
.cache/clangd/index/ButtonMatrixWidget.hpp.580A8B6A2F8B776F.idx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.cache/clangd/index/RectangleWidget.cpp.80D39D0F2AD3509D.idx
Normal file
BIN
.cache/clangd/index/RectangleWidget.cpp.80D39D0F2AD3509D.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/RectangleWidget.hpp.C8D9E5E0438E507D.idx
Normal file
BIN
.cache/clangd/index/RectangleWidget.hpp.C8D9E5E0438E507D.idx
Normal file
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -12,7 +12,12 @@ 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/RectangleWidget.cpp"
|
||||
"widgets/ArcWidget.cpp"
|
||||
"widgets/ButtonMatrixWidget.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() {
|
||||
|
||||
@ -26,6 +26,9 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
||||
buf[pos++] = bgColor.r; buf[pos++] = bgColor.g; buf[pos++] = bgColor.b;
|
||||
buf[pos++] = bgOpacity;
|
||||
buf[pos++] = borderRadius;
|
||||
buf[pos++] = borderWidth;
|
||||
buf[pos++] = borderColor.r; buf[pos++] = borderColor.g; buf[pos++] = borderColor.b;
|
||||
buf[pos++] = borderOpacity;
|
||||
|
||||
buf[pos++] = shadow.offsetX;
|
||||
buf[pos++] = shadow.offsetY;
|
||||
@ -47,7 +50,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);
|
||||
|
||||
@ -108,6 +115,8 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
||||
buf[pos++] = subButtonCount;
|
||||
buf[pos++] = subButtonSize;
|
||||
buf[pos++] = subButtonDistance;
|
||||
buf[pos++] = subButtonOpacity;
|
||||
buf[pos++] = cardStyle;
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
const SubButtonConfig& sb = subButtons[i];
|
||||
buf[pos++] = sb.iconCodepoint & 0xFF;
|
||||
@ -131,6 +140,20 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
||||
buf[pos++] = 0; // padding
|
||||
buf[pos++] = 0; // padding
|
||||
}
|
||||
|
||||
// Arc properties
|
||||
buf[pos++] = arcMin & 0xFF; buf[pos++] = (arcMin >> 8) & 0xFF;
|
||||
buf[pos++] = arcMax & 0xFF; buf[pos++] = (arcMax >> 8) & 0xFF;
|
||||
buf[pos++] = arcUnit;
|
||||
buf[pos++] = arcShowValue ? 1 : 0;
|
||||
buf[pos++] = static_cast<uint8_t>(arcScaleOffset);
|
||||
buf[pos++] = arcScaleColor.r;
|
||||
buf[pos++] = arcScaleColor.g;
|
||||
buf[pos++] = arcScaleColor.b;
|
||||
buf[pos++] = arcValueColor.r;
|
||||
buf[pos++] = arcValueColor.g;
|
||||
buf[pos++] = arcValueColor.b;
|
||||
buf[pos++] = arcValueFontSize;
|
||||
}
|
||||
|
||||
void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||
@ -156,6 +179,9 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||
bgColor.r = buf[pos++]; bgColor.g = buf[pos++]; bgColor.b = buf[pos++];
|
||||
bgOpacity = buf[pos++];
|
||||
borderRadius = buf[pos++];
|
||||
borderWidth = buf[pos++];
|
||||
borderColor.r = buf[pos++]; borderColor.g = buf[pos++]; borderColor.b = buf[pos++];
|
||||
borderOpacity = buf[pos++];
|
||||
|
||||
shadow.offsetX = static_cast<int8_t>(buf[pos++]);
|
||||
shadow.offsetY = static_cast<int8_t>(buf[pos++]);
|
||||
@ -176,7 +202,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++]);
|
||||
|
||||
@ -253,17 +283,22 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||
}
|
||||
|
||||
// RoomCard sub-buttons
|
||||
if (pos + 3 <= SERIALIZED_SIZE) {
|
||||
if (pos + 5 <= SERIALIZED_SIZE) {
|
||||
subButtonCount = buf[pos++];
|
||||
if (subButtonCount > MAX_SUBBUTTONS) subButtonCount = MAX_SUBBUTTONS;
|
||||
subButtonSize = buf[pos++];
|
||||
if (subButtonSize == 0) subButtonSize = 40; // Default
|
||||
subButtonDistance = buf[pos++];
|
||||
if (subButtonDistance == 0) subButtonDistance = 80; // Default 80px
|
||||
subButtonOpacity = buf[pos++];
|
||||
if (subButtonOpacity == 0) subButtonOpacity = 255; // Default fully opaque
|
||||
cardStyle = buf[pos++]; // 0=Bubble, 1=Tile
|
||||
} else {
|
||||
subButtonCount = 0;
|
||||
subButtonSize = 40;
|
||||
subButtonDistance = 80;
|
||||
subButtonOpacity = 255;
|
||||
cardStyle = 0; // Default to Bubble style
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
@ -289,6 +324,39 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||
sb = SubButtonConfig{};
|
||||
}
|
||||
}
|
||||
|
||||
// Arc properties
|
||||
if (pos + 6 <= SERIALIZED_SIZE) {
|
||||
arcMin = static_cast<int16_t>(buf[pos] | (buf[pos + 1] << 8)); pos += 2;
|
||||
arcMax = static_cast<int16_t>(buf[pos] | (buf[pos + 1] << 8)); pos += 2;
|
||||
arcUnit = buf[pos++];
|
||||
arcShowValue = buf[pos++] != 0;
|
||||
} else {
|
||||
arcMin = 0;
|
||||
arcMax = 100;
|
||||
arcUnit = 0;
|
||||
arcShowValue = true;
|
||||
}
|
||||
|
||||
if (pos + 4 <= SERIALIZED_SIZE) {
|
||||
arcScaleOffset = static_cast<int8_t>(buf[pos++]);
|
||||
arcScaleColor.r = buf[pos++];
|
||||
arcScaleColor.g = buf[pos++];
|
||||
arcScaleColor.b = buf[pos++];
|
||||
} else {
|
||||
arcScaleOffset = 0;
|
||||
arcScaleColor = textColor;
|
||||
}
|
||||
|
||||
if (pos + 4 <= SERIALIZED_SIZE) {
|
||||
arcValueColor.r = buf[pos++];
|
||||
arcValueColor.g = buf[pos++];
|
||||
arcValueColor.b = buf[pos++];
|
||||
arcValueFontSize = buf[pos++];
|
||||
} else {
|
||||
arcValueColor = textColor;
|
||||
arcValueFontSize = fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) {
|
||||
@ -310,6 +378,17 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c
|
||||
cfg.bgColor = {0, 0, 0};
|
||||
cfg.bgOpacity = 0;
|
||||
cfg.borderRadius = 0;
|
||||
cfg.borderWidth = 0;
|
||||
cfg.borderColor = {255, 255, 255};
|
||||
cfg.borderOpacity = 0;
|
||||
cfg.arcMin = 0;
|
||||
cfg.arcMax = 100;
|
||||
cfg.arcUnit = 0;
|
||||
cfg.arcShowValue = true;
|
||||
cfg.arcScaleOffset = 0;
|
||||
cfg.arcScaleColor = cfg.textColor;
|
||||
cfg.arcValueColor = cfg.textColor;
|
||||
cfg.arcValueFontSize = cfg.fontSize;
|
||||
cfg.shadow.enabled = false;
|
||||
// Icon defaults
|
||||
cfg.iconCodepoint = 0;
|
||||
@ -355,11 +434,22 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
|
||||
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
|
||||
cfg.fontSize = 1;
|
||||
cfg.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
|
||||
cfg.isContainer = true;
|
||||
cfg.isContainer = false;
|
||||
cfg.textColor = {255, 255, 255};
|
||||
cfg.bgColor = {33, 150, 243}; // Blue
|
||||
cfg.bgOpacity = 255;
|
||||
cfg.borderRadius = 8;
|
||||
cfg.borderWidth = 0;
|
||||
cfg.borderColor = {255, 255, 255};
|
||||
cfg.borderOpacity = 0;
|
||||
cfg.arcMin = 0;
|
||||
cfg.arcMax = 100;
|
||||
cfg.arcUnit = 0;
|
||||
cfg.arcShowValue = true;
|
||||
cfg.arcScaleOffset = 0;
|
||||
cfg.arcScaleColor = cfg.textColor;
|
||||
cfg.arcValueColor = cfg.textColor;
|
||||
cfg.arcValueFontSize = cfg.fontSize;
|
||||
cfg.shadow.enabled = true;
|
||||
cfg.shadow.offsetX = 2;
|
||||
cfg.shadow.offsetY = 2;
|
||||
@ -375,6 +465,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,
|
||||
@ -28,6 +29,9 @@ enum class WidgetType : uint8_t {
|
||||
CHART = 9,
|
||||
CLOCK = 10,
|
||||
ROOMCARD = 11,
|
||||
RECTANGLE = 12,
|
||||
ARC = 13,
|
||||
BUTTONMATRIX = 14,
|
||||
};
|
||||
|
||||
enum class IconPosition : uint8_t {
|
||||
@ -180,6 +184,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;
|
||||
@ -212,6 +228,9 @@ struct WidgetConfig {
|
||||
Color bgColor;
|
||||
uint8_t bgOpacity; // 0-255
|
||||
uint8_t borderRadius;
|
||||
uint8_t borderWidth; // 0-32
|
||||
Color borderColor;
|
||||
uint8_t borderOpacity; // 0-255
|
||||
|
||||
// Shadow
|
||||
ShadowConfig shadow;
|
||||
@ -225,8 +244,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)
|
||||
@ -256,11 +277,27 @@ struct WidgetConfig {
|
||||
uint8_t subButtonCount;
|
||||
uint8_t subButtonSize; // Sub-button size in pixels (default 40)
|
||||
uint8_t subButtonDistance; // Distance from center in pixels (default 80)
|
||||
uint8_t subButtonOpacity; // Sub-button opacity 0-255 (default 255)
|
||||
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];
|
||||
|
||||
// Arc properties
|
||||
int16_t arcMin;
|
||||
int16_t arcMax;
|
||||
uint8_t arcUnit; // 0=none, 1=percent, 2=C
|
||||
bool arcShowValue; // Show center value label
|
||||
int8_t arcScaleOffset; // Radial offset for scale (ticks/labels)
|
||||
Color arcScaleColor; // Scale (tick/label) color
|
||||
Color arcValueColor; // Center value text color
|
||||
uint8_t arcValueFontSize; // Center value font size index
|
||||
|
||||
// Serialization size (fixed for NVS storage)
|
||||
// 197 + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 120 (6 subButtons * 20) = 320
|
||||
static constexpr size_t SERIALIZED_SIZE = 320;
|
||||
// 331 + 14 (arcMin + arcMax + arcUnit + arcShowValue + arcScaleOffset + arcScaleColor + arcValueColor + arcValueFontSize) = 345
|
||||
static constexpr size_t SERIALIZED_SIZE = 345;
|
||||
|
||||
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"
|
||||
@ -88,58 +89,73 @@ static void latin1_to_utf8(const char* src, size_t src_len, char* dst, size_t ds
|
||||
dst[di] = '\0';
|
||||
}
|
||||
|
||||
static WidgetConfig makeButtonLabelChild(const WidgetConfig& button) {
|
||||
WidgetConfig label = WidgetConfig::createLabel(0, 0, 0, button.text);
|
||||
label.parentId = button.id;
|
||||
if (button.width > 0) label.width = button.width;
|
||||
if (button.height > 0) label.height = button.height;
|
||||
label.fontSize = button.fontSize;
|
||||
label.textAlign = button.textAlign;
|
||||
label.textColor = button.textColor;
|
||||
label.textSource = TextSource::STATIC;
|
||||
label.bgOpacity = 0;
|
||||
label.borderRadius = 0;
|
||||
label.shadow.enabled = false;
|
||||
// Preserve existing icon config if any
|
||||
label.iconCodepoint = button.iconCodepoint;
|
||||
label.iconPosition = button.iconPosition;
|
||||
label.iconSize = button.iconSize;
|
||||
label.iconGap = button.iconGap;
|
||||
if (label.text[0] == '\0') {
|
||||
strncpy(label.text, "Button", MAX_TEXT_LEN - 1);
|
||||
label.text[MAX_TEXT_LEN - 1] = '\0';
|
||||
}
|
||||
return label;
|
||||
static bool color_equals(const Color& a, const Color& b) {
|
||||
return a.r == b.r && a.g == b.g && a.b == b.b;
|
||||
}
|
||||
|
||||
static void ensureButtonLabels(ScreenConfig& screen) {
|
||||
bool hasLabelChild[MAX_WIDGETS] = {};
|
||||
static bool is_auto_button_label(const WidgetConfig& button, const WidgetConfig& label) {
|
||||
if (label.type != WidgetType::LABEL) return false;
|
||||
if (label.parentId < 0 || label.parentId != static_cast<int8_t>(button.id)) return false;
|
||||
if (label.textSource != TextSource::STATIC) return false;
|
||||
bool textMatches = (strncmp(label.text, button.text, MAX_TEXT_LEN) == 0);
|
||||
if (!textMatches && button.text[0] == '\0') {
|
||||
if (strncmp(label.text, "Button", MAX_TEXT_LEN) == 0 || label.text[0] == '\0') {
|
||||
textMatches = true;
|
||||
}
|
||||
}
|
||||
if (!textMatches) return false;
|
||||
if (label.fontSize != button.fontSize) return false;
|
||||
if (label.textAlign != button.textAlign) return false;
|
||||
if (!color_equals(label.textColor, button.textColor)) return false;
|
||||
if (label.bgOpacity != 0) return false;
|
||||
if (label.borderRadius != 0) return false;
|
||||
if (label.shadow.enabled) return false;
|
||||
if (label.iconCodepoint != button.iconCodepoint) return false;
|
||||
if (label.iconPosition != button.iconPosition) return false;
|
||||
if (label.iconSize != button.iconSize) return false;
|
||||
if (label.iconGap != button.iconGap) return false;
|
||||
if (label.x != 0 || label.y != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void normalizeButtons(ScreenConfig& screen) {
|
||||
uint8_t removeIds[MAX_WIDGETS] = {};
|
||||
uint8_t removeCount = 0;
|
||||
|
||||
// Remove legacy auto-generated label children for buttons
|
||||
for (uint8_t i = 0; i < screen.widgetCount; i++) {
|
||||
const WidgetConfig& w = screen.widgets[i];
|
||||
if (w.type == WidgetType::LABEL && w.parentId >= 0 && w.parentId < MAX_WIDGETS) {
|
||||
hasLabelChild[w.parentId] = true;
|
||||
if (w.type != WidgetType::LABEL || w.parentId < 0) continue;
|
||||
|
||||
WidgetConfig* parent = screen.findWidget(static_cast<uint8_t>(w.parentId));
|
||||
if (!parent || parent->type != WidgetType::BUTTON) continue;
|
||||
|
||||
bool forceRemove = parent->text[0] != '\0';
|
||||
if (forceRemove || is_auto_button_label(*parent, w)) {
|
||||
if (removeCount < MAX_WIDGETS) {
|
||||
removeIds[removeCount++] = w.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uint8_t initialCount = screen.widgetCount;
|
||||
for (uint8_t i = 0; i < initialCount; i++) {
|
||||
for (uint8_t i = 0; i < removeCount; i++) {
|
||||
screen.removeWidget(removeIds[i]);
|
||||
}
|
||||
|
||||
// Update container flag based on remaining children
|
||||
for (uint8_t i = 0; i < screen.widgetCount; i++) {
|
||||
WidgetConfig& w = screen.widgets[i];
|
||||
if (w.type != WidgetType::BUTTON) continue;
|
||||
|
||||
w.isContainer = true;
|
||||
|
||||
if (w.id < MAX_WIDGETS && hasLabelChild[w.id]) continue;
|
||||
|
||||
WidgetConfig label = makeButtonLabelChild(w);
|
||||
int newId = screen.addWidget(label);
|
||||
if (newId < 0) {
|
||||
ESP_LOGW(TAG, "No space to add label child for button %d", w.id);
|
||||
w.isContainer = false;
|
||||
continue;
|
||||
}
|
||||
if (w.id < MAX_WIDGETS) {
|
||||
hasLabelChild[w.id] = true;
|
||||
bool hasChild = false;
|
||||
for (uint8_t j = 0; j < screen.widgetCount; j++) {
|
||||
const WidgetConfig& child = screen.widgets[j];
|
||||
if (child.parentId >= 0 && child.parentId == static_cast<int8_t>(w.id)) {
|
||||
hasChild = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
w.isContainer = hasChild;
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,7 +203,7 @@ void WidgetManager::createDefaultConfig() {
|
||||
progBtn.bgColor = {200, 50, 50}; // Red
|
||||
screen.addWidget(progBtn);
|
||||
|
||||
ensureButtonLabels(screen);
|
||||
normalizeButtons(screen);
|
||||
|
||||
config_->startScreenId = screen.id;
|
||||
config_->standbyEnabled = false;
|
||||
@ -351,7 +367,7 @@ void WidgetManager::applyScreenLocked(uint8_t screenId) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureButtonLabels(*screen);
|
||||
normalizeButtons(*screen);
|
||||
|
||||
if (modalContainer_) {
|
||||
ESP_LOGI(TAG, "Closing modal first");
|
||||
@ -560,8 +576,8 @@ void WidgetManager::handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target
|
||||
printf("WM: handleButtonAction btn=%d act=%d type=%d\n", cfg.id, (int)cfg.action, (int)cfg.type);
|
||||
fflush(stdout);
|
||||
|
||||
if (cfg.type != WidgetType::BUTTON) {
|
||||
printf("WM: Not a button!\n");
|
||||
if (cfg.type != WidgetType::BUTTON && cfg.type != WidgetType::BUTTONMATRIX) {
|
||||
printf("WM: Not a clickable widget!\n");
|
||||
fflush(stdout);
|
||||
return;
|
||||
}
|
||||
@ -1036,6 +1052,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 +1131,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 +1164,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1492,6 +1537,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
|
||||
cJSON_AddNumberToObject(widget, "bgOpacity", w.bgOpacity);
|
||||
cJSON_AddNumberToObject(widget, "radius", w.borderRadius);
|
||||
cJSON_AddNumberToObject(widget, "borderWidth", w.borderWidth);
|
||||
cJSON_AddNumberToObject(widget, "borderOpacity", w.borderOpacity);
|
||||
char borderColorStr[8];
|
||||
snprintf(borderColorStr, sizeof(borderColorStr), "#%02X%02X%02X",
|
||||
w.borderColor.r, w.borderColor.g, w.borderColor.b);
|
||||
cJSON_AddStringToObject(widget, "borderColor", borderColorStr);
|
||||
|
||||
cJSON* shadow = cJSON_AddObjectToObject(widget, "shadow");
|
||||
cJSON_AddBoolToObject(shadow, "enabled", w.shadow.enabled);
|
||||
@ -1514,6 +1565,22 @@ 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, "arcMin", w.arcMin);
|
||||
cJSON_AddNumberToObject(widget, "arcMax", w.arcMax);
|
||||
cJSON_AddNumberToObject(widget, "arcUnit", w.arcUnit);
|
||||
cJSON_AddBoolToObject(widget, "arcShowValue", w.arcShowValue);
|
||||
cJSON_AddNumberToObject(widget, "arcScaleOffset", w.arcScaleOffset);
|
||||
char arcScaleColorStr[8];
|
||||
snprintf(arcScaleColorStr, sizeof(arcScaleColorStr), "#%02X%02X%02X",
|
||||
w.arcScaleColor.r, w.arcScaleColor.g, w.arcScaleColor.b);
|
||||
cJSON_AddStringToObject(widget, "arcScaleColor", arcScaleColorStr);
|
||||
char arcValueColorStr[8];
|
||||
snprintf(arcValueColorStr, sizeof(arcValueColorStr), "#%02X%02X%02X",
|
||||
w.arcValueColor.r, w.arcValueColor.g, w.arcValueColor.b);
|
||||
cJSON_AddStringToObject(widget, "arcValueColor", arcValueColorStr);
|
||||
cJSON_AddNumberToObject(widget, "arcValueFontSize", w.arcValueFontSize);
|
||||
|
||||
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
|
||||
|
||||
@ -1608,6 +1675,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
if (w.type == WidgetType::ROOMCARD) {
|
||||
cJSON_AddNumberToObject(widget, "subButtonSize", w.subButtonSize);
|
||||
cJSON_AddNumberToObject(widget, "subButtonDistance", w.subButtonDistance);
|
||||
cJSON_AddNumberToObject(widget, "subButtonOpacity", w.subButtonOpacity);
|
||||
cJSON_AddNumberToObject(widget, "cardStyle", w.cardStyle);
|
||||
}
|
||||
if (w.type == WidgetType::ROOMCARD && w.subButtonCount > 0) {
|
||||
cJSON* subButtons = cJSON_AddArrayToObject(widget, "subButtons");
|
||||
@ -1635,6 +1704,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);
|
||||
}
|
||||
|
||||
@ -1690,6 +1777,14 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
w.chartTextSource[i] = TextSource::KNX_DPT_TEMP;
|
||||
w.chartSeriesColor[i] = defaultChartColor(i);
|
||||
}
|
||||
w.arcMin = 0;
|
||||
w.arcMax = 100;
|
||||
w.arcUnit = 0;
|
||||
w.arcShowValue = true;
|
||||
w.arcScaleOffset = 0;
|
||||
w.arcScaleColor = w.textColor;
|
||||
w.arcValueColor = w.textColor;
|
||||
w.arcValueFontSize = w.fontSize;
|
||||
|
||||
cJSON* id = cJSON_GetObjectItem(widget, "id");
|
||||
if (cJSON_IsNumber(id)) w.id = id->valueint;
|
||||
@ -1749,6 +1844,17 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
cJSON* radius = cJSON_GetObjectItem(widget, "radius");
|
||||
if (cJSON_IsNumber(radius)) w.borderRadius = radius->valueint;
|
||||
|
||||
cJSON* borderWidth = cJSON_GetObjectItem(widget, "borderWidth");
|
||||
if (cJSON_IsNumber(borderWidth)) w.borderWidth = borderWidth->valueint;
|
||||
|
||||
cJSON* borderOpacity = cJSON_GetObjectItem(widget, "borderOpacity");
|
||||
if (cJSON_IsNumber(borderOpacity)) w.borderOpacity = borderOpacity->valueint;
|
||||
|
||||
cJSON* borderColor = cJSON_GetObjectItem(widget, "borderColor");
|
||||
if (cJSON_IsString(borderColor)) {
|
||||
w.borderColor = Color::fromHex(parseHexColor(borderColor->valuestring));
|
||||
}
|
||||
|
||||
cJSON* shadow = cJSON_GetObjectItem(widget, "shadow");
|
||||
if (cJSON_IsObject(shadow)) {
|
||||
cJSON* enabled = cJSON_GetObjectItem(shadow, "enabled");
|
||||
@ -1796,6 +1902,44 @@ 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* arcMin = cJSON_GetObjectItem(widget, "arcMin");
|
||||
if (cJSON_IsNumber(arcMin)) w.arcMin = arcMin->valueint;
|
||||
|
||||
cJSON* arcMax = cJSON_GetObjectItem(widget, "arcMax");
|
||||
if (cJSON_IsNumber(arcMax)) w.arcMax = arcMax->valueint;
|
||||
|
||||
cJSON* arcUnit = cJSON_GetObjectItem(widget, "arcUnit");
|
||||
if (cJSON_IsNumber(arcUnit)) w.arcUnit = arcUnit->valueint;
|
||||
|
||||
cJSON* arcShowValue = cJSON_GetObjectItem(widget, "arcShowValue");
|
||||
if (cJSON_IsBool(arcShowValue)) {
|
||||
w.arcShowValue = cJSON_IsTrue(arcShowValue);
|
||||
} else if (cJSON_IsNumber(arcShowValue)) {
|
||||
w.arcShowValue = arcShowValue->valueint != 0;
|
||||
}
|
||||
|
||||
cJSON* arcScaleOffset = cJSON_GetObjectItem(widget, "arcScaleOffset");
|
||||
if (cJSON_IsNumber(arcScaleOffset)) w.arcScaleOffset = static_cast<int8_t>(arcScaleOffset->valueint);
|
||||
|
||||
cJSON* arcScaleColor = cJSON_GetObjectItem(widget, "arcScaleColor");
|
||||
if (cJSON_IsString(arcScaleColor)) {
|
||||
w.arcScaleColor = Color::fromHex(parseHexColor(arcScaleColor->valuestring));
|
||||
}
|
||||
|
||||
cJSON* arcValueColor = cJSON_GetObjectItem(widget, "arcValueColor");
|
||||
if (cJSON_IsString(arcValueColor)) {
|
||||
w.arcValueColor = Color::fromHex(parseHexColor(arcValueColor->valuestring));
|
||||
}
|
||||
|
||||
cJSON* arcValueFontSize = cJSON_GetObjectItem(widget, "arcValueFontSize");
|
||||
if (cJSON_IsNumber(arcValueFontSize)) w.arcValueFontSize = arcValueFontSize->valueint;
|
||||
|
||||
cJSON* parentId = cJSON_GetObjectItem(widget, "parentId");
|
||||
if (cJSON_IsNumber(parentId)) {
|
||||
@ -1956,6 +2100,20 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
} else {
|
||||
w.subButtonDistance = 80; // Default 80px
|
||||
}
|
||||
cJSON* subButtonOpacity = cJSON_GetObjectItem(widget, "subButtonOpacity");
|
||||
if (cJSON_IsNumber(subButtonOpacity)) {
|
||||
w.subButtonOpacity = subButtonOpacity->valueint;
|
||||
} else {
|
||||
w.subButtonOpacity = 255; // Default fully opaque
|
||||
}
|
||||
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
|
||||
cJSON* subButtons = cJSON_GetObjectItem(widget, "subButtons");
|
||||
@ -2014,6 +2172,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++;
|
||||
}
|
||||
|
||||
@ -2098,7 +2301,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
if (!parseWidgets(widgets, screen)) {
|
||||
screen.widgetCount = 0;
|
||||
}
|
||||
ensureButtonLabels(screen);
|
||||
normalizeButtons(screen);
|
||||
|
||||
newConfig->screenCount++;
|
||||
}
|
||||
@ -2117,7 +2320,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
cJSON_Delete(root);
|
||||
return false;
|
||||
}
|
||||
ensureButtonLabels(screen);
|
||||
normalizeButtons(screen);
|
||||
}
|
||||
|
||||
cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen");
|
||||
@ -2183,4 +2386,4 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
cJSON_Delete(root);
|
||||
ESP_LOGI(TAG, "Parsed %d screens from JSON", config_->screenCount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
247
main/widgets/ArcWidget.cpp
Normal file
247
main/widgets/ArcWidget.cpp
Normal file
@ -0,0 +1,247 @@
|
||||
#include "ArcWidget.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
ArcWidget::ArcWidget(const WidgetConfig& config)
|
||||
: Widget(config)
|
||||
{
|
||||
}
|
||||
|
||||
static int32_t clampToRange(float value, int32_t minValue, int32_t maxValue) {
|
||||
if (maxValue < minValue) std::swap(maxValue, minValue);
|
||||
if (value < minValue) value = static_cast<float>(minValue);
|
||||
if (value > maxValue) value = static_cast<float>(maxValue);
|
||||
return static_cast<int32_t>(std::lround(value));
|
||||
}
|
||||
|
||||
int32_t ArcWidget::initialValueFromText() const {
|
||||
if (config_.text[0] == '\0') return 75;
|
||||
|
||||
char* end = nullptr;
|
||||
long parsed = strtol(config_.text, &end, 10);
|
||||
if (end == config_.text) return 75;
|
||||
|
||||
return static_cast<int32_t>(parsed);
|
||||
}
|
||||
|
||||
lv_obj_t* ArcWidget::create(lv_obj_t* parent) {
|
||||
obj_ = lv_arc_create(parent);
|
||||
if (!obj_) return nullptr;
|
||||
|
||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||
lv_obj_set_size(obj_,
|
||||
config_.width > 0 ? config_.width : 180,
|
||||
config_.height > 0 ? config_.height : 180);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_remove_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(obj_, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
|
||||
lv_obj_set_style_pad_all(obj_, 0, 0);
|
||||
|
||||
lv_arc_set_rotation(obj_, 0);
|
||||
lv_arc_set_mode(obj_, LV_ARC_MODE_NORMAL);
|
||||
lv_arc_set_bg_angles(obj_, 135, 45);
|
||||
|
||||
int32_t minValue = config_.arcMin;
|
||||
int32_t maxValue = config_.arcMax;
|
||||
if (maxValue <= minValue) {
|
||||
minValue = 0;
|
||||
maxValue = 100;
|
||||
}
|
||||
lv_arc_set_range(obj_, minValue, maxValue);
|
||||
|
||||
lastValue_ = static_cast<float>(initialValueFromText());
|
||||
lv_arc_set_value(obj_, clampToRange(lastValue_, minValue, maxValue));
|
||||
|
||||
// Create scale as child of arc
|
||||
scale_ = lv_scale_create(obj_);
|
||||
if (scale_) {
|
||||
lv_obj_clear_flag(scale_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_remove_flag(scale_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(scale_, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
|
||||
lv_obj_set_style_bg_opa(scale_, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(scale_, 0, 0);
|
||||
lv_obj_set_style_pad_all(scale_, 0, 0);
|
||||
}
|
||||
|
||||
valueLabel_ = lv_label_create(obj_);
|
||||
if (valueLabel_) {
|
||||
lv_obj_center(valueLabel_);
|
||||
updateValueLabel(lastValue_);
|
||||
}
|
||||
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void ArcWidget::applyStyle() {
|
||||
if (!obj_) return;
|
||||
|
||||
applyCommonStyle();
|
||||
lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0);
|
||||
|
||||
int32_t minValue = config_.arcMin;
|
||||
int32_t maxValue = config_.arcMax;
|
||||
if (maxValue <= minValue) {
|
||||
minValue = 0;
|
||||
maxValue = 100;
|
||||
}
|
||||
lv_arc_set_range(obj_, minValue, maxValue);
|
||||
|
||||
int16_t arcWidth = config_.borderRadius > 0 ? static_cast<int16_t>(config_.borderRadius) : 12;
|
||||
if (arcWidth < 2) arcWidth = 2;
|
||||
if (arcWidth > 48) arcWidth = 48;
|
||||
|
||||
lv_color_t trackColor = lv_color_make(config_.bgColor.r, config_.bgColor.g, config_.bgColor.b);
|
||||
uint8_t trackOpa = config_.bgOpacity > 0 ? config_.bgOpacity : static_cast<uint8_t>(LV_OPA_40);
|
||||
|
||||
lv_color_t indicatorColor = lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b);
|
||||
|
||||
lv_obj_set_style_arc_width(obj_, arcWidth, LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_color(obj_, trackColor, LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_opa(obj_, trackOpa, LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_rounded(obj_, true, LV_PART_MAIN);
|
||||
|
||||
lv_obj_set_style_arc_width(obj_, arcWidth, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_arc_color(obj_, indicatorColor, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_arc_opa(obj_, LV_OPA_COVER, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_arc_rounded(obj_, true, LV_PART_INDICATOR);
|
||||
|
||||
int16_t knobSize = std::max<int16_t>(8, static_cast<int16_t>(arcWidth + 6));
|
||||
lv_obj_set_style_arc_width(obj_, knobSize, LV_PART_KNOB);
|
||||
lv_obj_set_style_bg_color(obj_, indicatorColor, LV_PART_KNOB);
|
||||
lv_obj_set_style_bg_opa(obj_, LV_OPA_COVER, LV_PART_KNOB);
|
||||
|
||||
if (scale_) {
|
||||
// Use config dimensions directly, not from arc object (which may have content area limitations)
|
||||
int16_t scaleWidth = config_.width > 0 ? static_cast<int16_t>(config_.width) : 180;
|
||||
int16_t scaleHeight = config_.height > 0 ? static_cast<int16_t>(config_.height) : 180;
|
||||
|
||||
lv_obj_set_size(scale_, scaleWidth, scaleHeight);
|
||||
lv_obj_set_pos(scale_, 0, 0);
|
||||
lv_obj_set_align(scale_, LV_ALIGN_CENTER);
|
||||
|
||||
lv_scale_set_mode(scale_, LV_SCALE_MODE_ROUND_INNER);
|
||||
lv_scale_set_angle_range(scale_, 270);
|
||||
lv_scale_set_rotation(scale_, 135);
|
||||
lv_scale_set_range(scale_, minValue, maxValue);
|
||||
lv_scale_set_total_tick_count(scale_, 21);
|
||||
lv_scale_set_major_tick_every(scale_, 5);
|
||||
lv_scale_set_label_show(scale_, true);
|
||||
|
||||
int16_t majorLen = std::max<int16_t>(3, static_cast<int16_t>(arcWidth / 3 + 1));
|
||||
int16_t minorLen = std::max<int16_t>(2, static_cast<int16_t>(arcWidth / 4 + 1));
|
||||
lv_obj_set_style_length(scale_, majorLen, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_length(scale_, minorLen, LV_PART_ITEMS);
|
||||
|
||||
// Use radial_offset to position ticks at inner edge of arc stroke.
|
||||
// Positive arcScaleOffset should move the scale outward.
|
||||
int16_t scaleOffset = static_cast<int16_t>(-config_.arcScaleOffset);
|
||||
int16_t tickOffset = static_cast<int16_t>(-arcWidth + scaleOffset);
|
||||
lv_obj_set_style_radial_offset(scale_, tickOffset, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_radial_offset(scale_, tickOffset, LV_PART_ITEMS);
|
||||
|
||||
lv_obj_set_style_arc_width(scale_, 1, LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_opa(scale_, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
|
||||
lv_color_t scaleColor = lv_color_make(
|
||||
config_.arcScaleColor.r, config_.arcScaleColor.g, config_.arcScaleColor.b);
|
||||
lv_obj_set_style_line_color(scale_, scaleColor, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_line_color(scale_, scaleColor, LV_PART_ITEMS);
|
||||
lv_obj_set_style_line_width(scale_, 2, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_line_width(scale_, 1, LV_PART_ITEMS);
|
||||
lv_obj_set_style_line_opa(scale_, LV_OPA_80, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_line_opa(scale_, LV_OPA_40, LV_PART_ITEMS);
|
||||
|
||||
uint8_t scaleFont = config_.fontSize > 0 ? static_cast<uint8_t>(config_.fontSize - 1) : config_.fontSize;
|
||||
lv_obj_set_style_text_color(scale_, scaleColor, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_text_opa(scale_, LV_OPA_80, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_text_font(scale_, getFontBySize(scaleFont), LV_PART_INDICATOR);
|
||||
lv_obj_set_style_transform_rotation(scale_, LV_SCALE_LABEL_ROTATE_MATCH_TICKS | LV_SCALE_LABEL_ROTATE_KEEP_UPRIGHT,
|
||||
LV_PART_INDICATOR);
|
||||
int16_t labelPad = static_cast<int16_t>(-(arcWidth / 2 + 4) + scaleOffset);
|
||||
lv_obj_set_style_pad_radial(scale_, labelPad, LV_PART_INDICATOR);
|
||||
}
|
||||
|
||||
if (valueLabel_) {
|
||||
lv_color_t valueColor = lv_color_make(
|
||||
config_.arcValueColor.r, config_.arcValueColor.g, config_.arcValueColor.b);
|
||||
lv_obj_set_style_text_color(valueLabel_, valueColor, 0);
|
||||
lv_obj_set_style_text_font(valueLabel_, getFontBySize(config_.arcValueFontSize), 0);
|
||||
lv_obj_center(valueLabel_);
|
||||
updateValueLabel(lastValue_);
|
||||
}
|
||||
}
|
||||
|
||||
void ArcWidget::onKnxValue(float value) {
|
||||
if (!obj_) return;
|
||||
|
||||
switch (config_.textSource) {
|
||||
case TextSource::KNX_DPT_TEMP:
|
||||
case TextSource::KNX_DPT_PERCENT:
|
||||
case TextSource::KNX_DPT_POWER:
|
||||
case TextSource::KNX_DPT_ENERGY:
|
||||
case TextSource::KNX_DPT_DECIMALFACTOR:
|
||||
lastValue_ = value;
|
||||
lv_arc_set_value(obj_, clampToRange(value, config_.arcMin, config_.arcMax));
|
||||
updateValueLabel(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void ArcWidget::onKnxSwitch(bool value) {
|
||||
if (!obj_) return;
|
||||
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
|
||||
|
||||
int32_t minValue = config_.arcMin;
|
||||
int32_t maxValue = config_.arcMax;
|
||||
if (maxValue <= minValue) {
|
||||
minValue = 0;
|
||||
maxValue = 100;
|
||||
}
|
||||
lastValue_ = value ? static_cast<float>(maxValue) : static_cast<float>(minValue);
|
||||
lv_arc_set_value(obj_, value ? maxValue : minValue);
|
||||
updateValueLabel(lastValue_);
|
||||
}
|
||||
|
||||
const char* ArcWidget::unitSuffix(uint8_t unit) {
|
||||
switch (unit) {
|
||||
case 1: return "%";
|
||||
case 2: return "C";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
void ArcWidget::updateValueLabel(float value) {
|
||||
if (!valueLabel_) return;
|
||||
if (!config_.arcShowValue) {
|
||||
lv_obj_add_flag(valueLabel_, LV_OBJ_FLAG_HIDDEN);
|
||||
return;
|
||||
}
|
||||
lv_obj_clear_flag(valueLabel_, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
int32_t minValue = config_.arcMin;
|
||||
int32_t maxValue = config_.arcMax;
|
||||
if (maxValue <= minValue) {
|
||||
minValue = 0;
|
||||
maxValue = 100;
|
||||
}
|
||||
|
||||
float clamped = value;
|
||||
if (clamped < minValue) clamped = static_cast<float>(minValue);
|
||||
if (clamped > maxValue) clamped = static_cast<float>(maxValue);
|
||||
|
||||
char buf[24];
|
||||
const char* suffix = unitSuffix(config_.arcUnit);
|
||||
bool useDecimal = (config_.textSource == TextSource::KNX_DPT_TEMP);
|
||||
if (useDecimal) {
|
||||
std::snprintf(buf, sizeof(buf), "%.1f%s", clamped, suffix);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "%.0f%s", clamped, suffix);
|
||||
}
|
||||
lv_label_set_text(valueLabel_, buf);
|
||||
}
|
||||
23
main/widgets/ArcWidget.hpp
Normal file
23
main/widgets/ArcWidget.hpp
Normal file
@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
class ArcWidget : public Widget {
|
||||
public:
|
||||
explicit ArcWidget(const WidgetConfig& config);
|
||||
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
|
||||
void onKnxValue(float value) override;
|
||||
void onKnxSwitch(bool value) override;
|
||||
|
||||
private:
|
||||
int32_t initialValueFromText() const;
|
||||
void updateValueLabel(float value);
|
||||
static const char* unitSuffix(uint8_t unit);
|
||||
|
||||
lv_obj_t* scale_ = nullptr;
|
||||
lv_obj_t* valueLabel_ = nullptr;
|
||||
float lastValue_ = 0.0f;
|
||||
};
|
||||
157
main/widgets/ButtonMatrixWidget.cpp
Normal file
157
main/widgets/ButtonMatrixWidget.cpp
Normal file
@ -0,0 +1,157 @@
|
||||
#include "ButtonMatrixWidget.hpp"
|
||||
|
||||
#include "../WidgetManager.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace {
|
||||
std::string trimToken(const std::string& token) {
|
||||
size_t start = 0;
|
||||
size_t end = token.size();
|
||||
|
||||
while (start < end && std::isspace(static_cast<unsigned char>(token[start])) != 0) {
|
||||
start++;
|
||||
}
|
||||
while (end > start && std::isspace(static_cast<unsigned char>(token[end - 1])) != 0) {
|
||||
end--;
|
||||
}
|
||||
|
||||
return token.substr(start, end - start);
|
||||
}
|
||||
}
|
||||
|
||||
ButtonMatrixWidget::ButtonMatrixWidget(const WidgetConfig& config)
|
||||
: Widget(config)
|
||||
{
|
||||
}
|
||||
|
||||
ButtonMatrixWidget::~ButtonMatrixWidget() {
|
||||
if (obj_) {
|
||||
lv_obj_remove_event_cb(obj_, valueChangedCallback);
|
||||
}
|
||||
}
|
||||
|
||||
void ButtonMatrixWidget::rebuildMapStorage() {
|
||||
mapStorage_.clear();
|
||||
mapPointers_.clear();
|
||||
|
||||
auto addButton = [&](const std::string& token) {
|
||||
std::string trimmed = trimToken(token);
|
||||
if (!trimmed.empty()) {
|
||||
mapStorage_.push_back(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
if (config_.text[0] != '\0') {
|
||||
std::string token;
|
||||
for (size_t i = 0; config_.text[i] != '\0'; ++i) {
|
||||
const char ch = config_.text[i];
|
||||
if (ch == ';' || ch == ',') {
|
||||
addButton(token);
|
||||
token.clear();
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
addButton(token);
|
||||
token.clear();
|
||||
if (!mapStorage_.empty() && mapStorage_.back() != "\n") {
|
||||
mapStorage_.push_back("\n");
|
||||
}
|
||||
if (ch == '\r' && config_.text[i + 1] == '\n') {
|
||||
++i;
|
||||
}
|
||||
} else {
|
||||
token.push_back(ch);
|
||||
}
|
||||
}
|
||||
addButton(token);
|
||||
|
||||
while (!mapStorage_.empty() && mapStorage_.back() == "\n") {
|
||||
mapStorage_.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
if (mapStorage_.empty()) {
|
||||
mapStorage_ = {"1", "2", "3", "\n", "4", "5", "6", "\n", "7", "8", "9"};
|
||||
}
|
||||
|
||||
mapPointers_.reserve(mapStorage_.size() + 1);
|
||||
for (const std::string& entry : mapStorage_) {
|
||||
mapPointers_.push_back(entry.c_str());
|
||||
}
|
||||
mapPointers_.push_back("");
|
||||
}
|
||||
|
||||
lv_obj_t* ButtonMatrixWidget::create(lv_obj_t* parent) {
|
||||
obj_ = lv_buttonmatrix_create(parent);
|
||||
if (!obj_) return nullptr;
|
||||
|
||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||
lv_obj_set_size(obj_,
|
||||
config_.width > 0 ? config_.width : 220,
|
||||
config_.height > 0 ? config_.height : 140);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
rebuildMapStorage();
|
||||
lv_buttonmatrix_set_map(obj_, mapPointers_.data());
|
||||
lv_buttonmatrix_set_button_ctrl_all(obj_, LV_BUTTONMATRIX_CTRL_CLICK_TRIG);
|
||||
|
||||
if (config_.isToggle) {
|
||||
lv_buttonmatrix_set_button_ctrl_all(obj_, LV_BUTTONMATRIX_CTRL_CHECKABLE);
|
||||
}
|
||||
|
||||
lv_obj_add_event_cb(obj_, valueChangedCallback, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void ButtonMatrixWidget::applyStyle() {
|
||||
if (!obj_) return;
|
||||
|
||||
applyCommonStyle();
|
||||
|
||||
lv_color_t textColor = lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b);
|
||||
lv_color_t buttonColor = lv_color_make(
|
||||
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b);
|
||||
|
||||
uint8_t buttonOpa = config_.bgOpacity > 0 ? config_.bgOpacity : static_cast<uint8_t>(LV_OPA_30);
|
||||
|
||||
lv_obj_set_style_text_font(obj_, getFontBySize(config_.fontSize), LV_PART_ITEMS);
|
||||
lv_obj_set_style_text_color(obj_, textColor, LV_PART_ITEMS);
|
||||
lv_obj_set_style_bg_color(obj_, buttonColor, LV_PART_ITEMS);
|
||||
lv_obj_set_style_bg_opa(obj_, buttonOpa, LV_PART_ITEMS);
|
||||
|
||||
if (config_.borderRadius > 0) {
|
||||
lv_obj_set_style_radius(obj_, config_.borderRadius, LV_PART_ITEMS);
|
||||
}
|
||||
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, LV_PART_ITEMS);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), LV_PART_ITEMS);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, LV_PART_ITEMS);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 0, LV_PART_ITEMS);
|
||||
}
|
||||
|
||||
lv_style_selector_t pressedSel = static_cast<lv_style_selector_t>(LV_PART_ITEMS) |
|
||||
static_cast<lv_style_selector_t>(LV_STATE_PRESSED);
|
||||
lv_style_selector_t checkedSel = static_cast<lv_style_selector_t>(LV_PART_ITEMS) |
|
||||
static_cast<lv_style_selector_t>(LV_STATE_CHECKED);
|
||||
|
||||
lv_obj_set_style_bg_color(obj_, textColor, pressedSel);
|
||||
lv_obj_set_style_bg_opa(obj_, LV_OPA_40, pressedSel);
|
||||
lv_obj_set_style_text_color(obj_, lv_color_white(), pressedSel);
|
||||
|
||||
lv_obj_set_style_bg_color(obj_, textColor, checkedSel);
|
||||
lv_obj_set_style_bg_opa(obj_, LV_OPA_50, checkedSel);
|
||||
lv_obj_set_style_text_color(obj_, lv_color_white(), checkedSel);
|
||||
}
|
||||
|
||||
void ButtonMatrixWidget::valueChangedCallback(lv_event_t* e) {
|
||||
ButtonMatrixWidget* widget = static_cast<ButtonMatrixWidget*>(lv_event_get_user_data(e));
|
||||
if (!widget) return;
|
||||
|
||||
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||
WidgetManager::instance().handleButtonAction(widget->getConfig(), target);
|
||||
}
|
||||
22
main/widgets/ButtonMatrixWidget.hpp
Normal file
22
main/widgets/ButtonMatrixWidget.hpp
Normal file
@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class ButtonMatrixWidget : public Widget {
|
||||
public:
|
||||
explicit ButtonMatrixWidget(const WidgetConfig& config);
|
||||
~ButtonMatrixWidget() override;
|
||||
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
|
||||
private:
|
||||
static void valueChangedCallback(lv_event_t* e);
|
||||
void rebuildMapStorage();
|
||||
|
||||
std::vector<std::string> mapStorage_;
|
||||
std::vector<const char*> mapPointers_;
|
||||
};
|
||||
@ -12,6 +12,12 @@ static lv_text_align_t toLvTextAlign(uint8_t align) {
|
||||
return LV_TEXT_ALIGN_CENTER;
|
||||
}
|
||||
|
||||
static lv_align_t toLvAlign(uint8_t align) {
|
||||
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_ALIGN_LEFT_MID;
|
||||
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_ALIGN_RIGHT_MID;
|
||||
return LV_ALIGN_CENTER;
|
||||
}
|
||||
|
||||
static lv_flex_align_t toFlexAlign(uint8_t align) {
|
||||
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_FLEX_ALIGN_START;
|
||||
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_FLEX_ALIGN_END;
|
||||
@ -130,7 +136,7 @@ void ButtonWidget::setupFlexLayout() {
|
||||
void ButtonWidget::applyTextAlignment() {
|
||||
if (label_ == nullptr) return;
|
||||
lv_obj_set_style_text_align(label_, toLvTextAlign(config_.textAlign), 0);
|
||||
lv_obj_center(label_);
|
||||
lv_obj_align(label_, toLvAlign(config_.textAlign), 0, 0);
|
||||
}
|
||||
|
||||
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
|
||||
|
||||
@ -80,7 +80,6 @@ void ChartWidget::applyStyle() {
|
||||
if (!obj_) return;
|
||||
|
||||
Widget::applyStyle();
|
||||
lv_obj_set_style_border_width(obj_, 0, 0);
|
||||
lv_obj_set_style_pad_all(obj_, 0, 0);
|
||||
|
||||
if (chart_) {
|
||||
|
||||
@ -67,9 +67,17 @@ void ClockWidget::applyStyle() {
|
||||
lv_obj_set_style_radius(obj_, LV_RADIUS_CIRCLE, 0);
|
||||
}
|
||||
|
||||
lv_obj_set_style_border_width(obj_, 2, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 2, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, LV_OPA_COVER, 0);
|
||||
}
|
||||
|
||||
lv_color_t handColor = lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b);
|
||||
@ -159,4 +167,4 @@ void ClockWidget::updateHands(const struct tm& t) {
|
||||
|
||||
void ClockWidget::onKnxTime(const struct tm& value, TextSource source) {
|
||||
updateHands(value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,6 +83,17 @@ void IconWidget::applyStyle() {
|
||||
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
|
||||
}
|
||||
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 0, 0);
|
||||
}
|
||||
|
||||
applyShadowStyle();
|
||||
|
||||
// Apply icon style
|
||||
if (iconLabel_ != nullptr) {
|
||||
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
|
||||
|
||||
@ -202,6 +202,15 @@ void LabelWidget::applyStyle() {
|
||||
if (config_.borderRadius > 0) {
|
||||
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
|
||||
}
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 0, 0);
|
||||
}
|
||||
applyShadowStyle();
|
||||
}
|
||||
|
||||
// Apply text style
|
||||
|
||||
@ -25,6 +25,15 @@ void LedWidget::applyStyle() {
|
||||
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b));
|
||||
lv_led_set_brightness(obj_, config_.bgOpacity);
|
||||
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 0, 0);
|
||||
}
|
||||
|
||||
// Shadow
|
||||
applyShadowStyle();
|
||||
}
|
||||
|
||||
@ -41,10 +41,17 @@ void PowerFlowWidget::applyStyle() {
|
||||
lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0);
|
||||
}
|
||||
|
||||
lv_obj_set_style_border_width(obj_, 1, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, LV_OPA_20, 0);
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 1, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, LV_OPA_20, 0);
|
||||
}
|
||||
|
||||
if (config_.borderRadius > 0) {
|
||||
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
|
||||
|
||||
@ -206,10 +206,17 @@ void PowerNodeWidget::applyStyle() {
|
||||
if (ring < 2) ring = 2;
|
||||
if (ring > 12) ring = 12;
|
||||
|
||||
lv_obj_set_style_border_width(obj_, ring, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.bgOpacity, 0);
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, ring, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.bgOpacity, 0);
|
||||
}
|
||||
|
||||
applyShadowStyle();
|
||||
|
||||
|
||||
26
main/widgets/RectangleWidget.cpp
Normal file
26
main/widgets/RectangleWidget.cpp
Normal file
@ -0,0 +1,26 @@
|
||||
#include "RectangleWidget.hpp"
|
||||
|
||||
RectangleWidget::RectangleWidget(const WidgetConfig& config)
|
||||
: Widget(config)
|
||||
{
|
||||
}
|
||||
|
||||
lv_obj_t* RectangleWidget::create(lv_obj_t* parent) {
|
||||
obj_ = lv_obj_create(parent);
|
||||
if (obj_ == nullptr) return nullptr;
|
||||
|
||||
lv_obj_remove_style_all(obj_);
|
||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||
lv_obj_set_size(obj_,
|
||||
config_.width > 0 ? config_.width : 180,
|
||||
config_.height > 0 ? config_.height : 120);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void RectangleWidget::applyStyle() {
|
||||
if (obj_ == nullptr) return;
|
||||
applyCommonStyle();
|
||||
}
|
||||
11
main/widgets/RectangleWidget.hpp
Normal file
11
main/widgets/RectangleWidget.hpp
Normal file
@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
class RectangleWidget : public Widget {
|
||||
public:
|
||||
explicit RectangleWidget(const WidgetConfig& config);
|
||||
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
};
|
||||
152
main/widgets/RoomCardBubbleWidget.cpp
Normal file
152
main/widgets/RoomCardBubbleWidget.cpp
Normal file
@ -0,0 +1,152 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
if (bubble_ && config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(bubble_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(bubble_, lv_color_hex(config_.borderColor.toLvColor()), 0);
|
||||
lv_obj_set_style_border_opa(bubble_, config_.borderOpacity, 0);
|
||||
} else if (bubble_) {
|
||||
lv_obj_set_style_border_width(bubble_, 0, 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();
|
||||
};
|
||||
228
main/widgets/RoomCardTileWidget.cpp
Normal file
228
main/widgets/RoomCardTileWidget.cpp
Normal file
@ -0,0 +1,228 @@
|
||||
#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());
|
||||
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_hex(config_.borderColor.toLvColor()), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else if (config_.shadow.enabled) {
|
||||
// Backward-compatible accent border behavior
|
||||
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);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 0, 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,335 +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';
|
||||
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
|
||||
subButtonStates_[i] = false;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
lv_obj_t* RoomCardWidget::create(lv_obj_t* parent) {
|
||||
parseText();
|
||||
|
||||
// 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);
|
||||
|
||||
// Create central bubble first
|
||||
createCentralBubble();
|
||||
|
||||
// Create sub-buttons
|
||||
createSubButtons();
|
||||
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void RoomCardWidget::createCentralBubble() {
|
||||
// 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::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 object
|
||||
lv_obj_t* btn = lv_obj_create(obj_);
|
||||
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, 255, 0);
|
||||
|
||||
// Position using circle geometry
|
||||
int16_t x, y;
|
||||
calculateSubButtonPosition(cfg.position, x, y);
|
||||
lv_obj_set_pos(btn, x, y);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
|
||||
|
||||
// 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_, lv_color_hex(config_.textColor.toLvColor()), 0);
|
||||
lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed
|
||||
}
|
||||
|
||||
// Style sub-buttons - adjust icon size based on button size
|
||||
// subButtonSize: 30-40 -> font 1, 41-55 -> font 2, 56+ -> font 3
|
||||
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::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,11 @@
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
class RoomCardWidget : public Widget {
|
||||
class RoomCardWidgetBase : public Widget {
|
||||
public:
|
||||
explicit RoomCardWidget(const WidgetConfig& config);
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
explicit RoomCardWidgetBase(const WidgetConfig& config);
|
||||
~RoomCardWidgetBase() override;
|
||||
|
||||
void onKnxValue(float value) override; // Temperature update
|
||||
void onKnxSwitch(bool value) override; // Not used directly
|
||||
|
||||
@ -14,10 +14,14 @@ public:
|
||||
void onSubButtonStatus(uint8_t index, bool value);
|
||||
void sendSubButtonToggle(uint8_t index);
|
||||
|
||||
private:
|
||||
// Central bubble elements
|
||||
lv_obj_t* bubble_ = nullptr;
|
||||
lv_obj_t* roomIcon_ = nullptr;
|
||||
// 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;
|
||||
|
||||
@ -30,18 +34,18 @@ private:
|
||||
char roomName_[MAX_TEXT_LEN] = {0};
|
||||
char tempFormat_[MAX_TEXT_LEN] = {0};
|
||||
|
||||
// Layout helpers
|
||||
void parseText();
|
||||
void createCentralBubble();
|
||||
void createSubButtons();
|
||||
// Common helpers
|
||||
void parseTextBase();
|
||||
void updateSubButtonColor(uint8_t index);
|
||||
void updateTemperature(float value);
|
||||
|
||||
// Geometry calculations
|
||||
void calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y);
|
||||
// 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 bubbleClickCallback(lv_event_t* e);
|
||||
static void cardClickCallback(lv_event_t* e);
|
||||
static void subButtonClickCallback(lv_event_t* e);
|
||||
|
||||
// UTF-8 encoding helper
|
||||
@ -39,5 +39,22 @@ void TabPageWidget::applyStyle() {
|
||||
lv_obj_set_style_bg_color(obj_, lv_color_make(
|
||||
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
|
||||
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0);
|
||||
}
|
||||
|
||||
if (config_.borderRadius > 0) {
|
||||
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
|
||||
}
|
||||
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 0, 0);
|
||||
}
|
||||
|
||||
applyShadowStyle();
|
||||
}
|
||||
|
||||
@ -183,6 +183,16 @@ void Widget::applyCommonStyle() {
|
||||
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
|
||||
}
|
||||
|
||||
// Border
|
||||
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
|
||||
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
|
||||
lv_obj_set_style_border_color(obj_, lv_color_make(
|
||||
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
|
||||
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(obj_, 0, 0);
|
||||
}
|
||||
|
||||
// Shadow
|
||||
applyShadowStyle();
|
||||
}
|
||||
|
||||
@ -10,7 +10,11 @@
|
||||
#include "PowerLinkWidget.hpp"
|
||||
#include "ChartWidget.hpp"
|
||||
#include "ClockWidget.hpp"
|
||||
#include "RoomCardWidget.hpp"
|
||||
#include "RoomCardBubbleWidget.hpp"
|
||||
#include "RoomCardTileWidget.hpp"
|
||||
#include "RectangleWidget.hpp"
|
||||
#include "ArcWidget.hpp"
|
||||
#include "ButtonMatrixWidget.hpp"
|
||||
|
||||
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||
if (!config.visible) return nullptr;
|
||||
@ -39,7 +43,17 @@ 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);
|
||||
case WidgetType::RECTANGLE:
|
||||
return std::make_unique<RectangleWidget>(config);
|
||||
case WidgetType::ARC:
|
||||
return std::make_unique<ArcWidget>(config);
|
||||
case WidgetType::BUTTONMATRIX:
|
||||
return std::make_unique<ButtonMatrixWidget>(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;
|
||||
|
||||
@ -42,6 +42,18 @@
|
||||
<span class="text-[13px] font-semibold">Chart</span>
|
||||
<span class="text-[11px] text-muted mt-0.5 block">Verlauf</span>
|
||||
</button>
|
||||
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('rectangle')">
|
||||
<span class="text-[13px] font-semibold">Rechteck</span>
|
||||
<span class="text-[11px] text-muted mt-0.5 block">Form</span>
|
||||
</button>
|
||||
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('arc')">
|
||||
<span class="text-[13px] font-semibold">Arc</span>
|
||||
<span class="text-[11px] text-muted mt-0.5 block">Gauge</span>
|
||||
</button>
|
||||
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('buttonmatrix')">
|
||||
<span class="text-[13px] font-semibold">Button Matrix</span>
|
||||
<span class="text-[11px] text-muted mt-0.5 block">Mehrfach</span>
|
||||
</button>
|
||||
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('roomcard')">
|
||||
<span class="text-[13px] font-semibold">Room Card</span>
|
||||
<span class="text-[11px] text-muted mt-0.5 block">Raum</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex items-center gap-1.5 border border-transparent rounded-[4px] px-1.5 py-1 text-text cursor-pointer mb-px select-none hover:bg-panel-2"
|
||||
class="group flex items-center gap-1.5 border border-transparent rounded-[4px] px-1.5 py-1 text-text cursor-pointer mb-px select-none hover:bg-panel-2"
|
||||
:class="{
|
||||
'bg-accent-2/15 border-accent-2/30': store.selectedWidgetId === node.id,
|
||||
'opacity-50': !node.visible,
|
||||
@ -25,10 +25,31 @@
|
||||
|
||||
<span class="material-symbols-outlined text-[16px] text-accent opacity-80">{{ getIconForType(node.type) }}</span>
|
||||
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<span class="text-[12px] truncate">{{ displayTitle(node) }}</span>
|
||||
<span class="text-[9px] text-muted">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
class="w-4 h-4 grid place-items-center text-[10px] text-muted hover:text-text"
|
||||
:class="canMoveUp ? '' : 'opacity-30 cursor-not-allowed'"
|
||||
@click.stop="moveUp"
|
||||
:disabled="!canMoveUp"
|
||||
title="Nach oben"
|
||||
>
|
||||
<span class="material-symbols-outlined text-[14px]">arrow_upward</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-4 h-4 grid place-items-center text-[10px] text-muted hover:text-text"
|
||||
:class="canMoveDown ? '' : 'opacity-30 cursor-not-allowed'"
|
||||
@click.stop="moveDown"
|
||||
:disabled="!canMoveDown"
|
||||
title="Nach unten"
|
||||
>
|
||||
<span class="material-symbols-outlined text-[14px]">arrow_downward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" v-if="node.children.length > 0 && expanded">
|
||||
@ -44,7 +65,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useEditorStore } from '../stores/editor';
|
||||
import { typeKeyFor } from '../utils';
|
||||
import { TYPE_LABELS, WIDGET_TYPES } from '../constants';
|
||||
@ -57,6 +78,8 @@ const props = defineProps({
|
||||
const store = useEditorStore();
|
||||
const expanded = ref(true);
|
||||
const isDragOver = ref(false);
|
||||
const canMoveUp = computed(() => store.canMoveWidget?.(props.node.id, 'up'));
|
||||
const canMoveDown = computed(() => store.canMoveWidget?.(props.node.id, 'down'));
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value;
|
||||
@ -72,6 +95,8 @@ function getIconForType(type) {
|
||||
case WIDGET_TYPES.TABPAGE: return 'article';
|
||||
case WIDGET_TYPES.POWERFLOW: return 'device_hub';
|
||||
case WIDGET_TYPES.POWERNODE: return 'radio_button_checked';
|
||||
case WIDGET_TYPES.ARC: return 'donut_large';
|
||||
case WIDGET_TYPES.BUTTONMATRIX: return 'dialpad';
|
||||
default: return 'widgets';
|
||||
}
|
||||
}
|
||||
@ -127,4 +152,14 @@ function onDrop(e, targetNode) {
|
||||
store.reparentWidget(draggedId, targetNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
function moveUp() {
|
||||
if (!canMoveUp.value) return;
|
||||
store.moveWidget(props.node.id, 'up');
|
||||
}
|
||||
|
||||
function moveDown() {
|
||||
if (!canMoveDown.value) return;
|
||||
store.moveWidget(props.node.id, 'down');
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,776 +1,62 @@
|
||||
<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 -->
|
||||
<template v-else-if="isRoomCard">
|
||||
<!-- 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 -->
|
||||
<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>
|
||||
|
||||
<!-- 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';
|
||||
import RectangleElement from './widgets/elements/RectangleElement.vue';
|
||||
import ArcElement from './widgets/elements/ArcElement.vue';
|
||||
import ButtonMatrixElement from './widgets/elements/ButtonMatrixElement.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),
|
||||
[WIDGET_TYPES.RECTANGLE]: markRaw(RectangleElement),
|
||||
[WIDGET_TYPES.ARC]: markRaw(ArcElement),
|
||||
[WIDGET_TYPES.BUTTONMATRIX]: markRaw(ButtonMatrixElement)
|
||||
};
|
||||
|
||||
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: '' };
|
||||
const parts = props.widget.text.split('\n');
|
||||
return {
|
||||
name: parts[0] || '',
|
||||
format: parts[1] || ''
|
||||
};
|
||||
});
|
||||
|
||||
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
|
||||
|
||||
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) {
|
||||
// RoomCard 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>
|
||||
|
||||
295
web-interface/src/components/widgets/elements/ArcElement.vue
Normal file
295
web-interface/src/components/widgets/elements/ArcElement.vue
Normal file
@ -0,0 +1,295 @@
|
||||
<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')"
|
||||
>
|
||||
<svg class="absolute inset-0" :viewBox="viewBox" preserveAspectRatio="xMidYMid meet">
|
||||
<g v-if="scaleTicks.length">
|
||||
<line
|
||||
v-for="tick in scaleTicks"
|
||||
:key="tick.key"
|
||||
:x1="tick.x1"
|
||||
:y1="tick.y1"
|
||||
:x2="tick.x2"
|
||||
:y2="tick.y2"
|
||||
:stroke="tick.major ? scaleMajorColor : scaleMinorColor"
|
||||
:stroke-width="tick.major ? scaleMajorWidth : scaleMinorWidth"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<text
|
||||
v-for="label in scaleLabels"
|
||||
:key="label.key"
|
||||
:x="label.x"
|
||||
:y="label.y"
|
||||
:fill="scaleMajorColor"
|
||||
:font-size="scaleFontSize"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
>
|
||||
{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
<path :d="trackPath" :stroke="trackColor" :stroke-width="strokeWidth" stroke-linecap="round" fill="none" />
|
||||
<path v-if="valuePath" :d="valuePath" :stroke="indicatorColor" :stroke-width="strokeWidth" stroke-linecap="round" fill="none" />
|
||||
<circle
|
||||
v-if="knob"
|
||||
:cx="knob.x"
|
||||
:cy="knob.y"
|
||||
:r="knob.r"
|
||||
:fill="indicatorColor"
|
||||
stroke="#0b0f14"
|
||||
stroke-opacity="0.35"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</svg>
|
||||
<div v-if="showValue" class="absolute font-semibold" :style="valueStyle">
|
||||
{{ displayLabel }}
|
||||
</div>
|
||||
|
||||
<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, getShadowStyle, getBorderStyle, 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 arcMin = computed(() => {
|
||||
const val = Number(props.widget.arcMin ?? 0);
|
||||
return Number.isFinite(val) ? val : 0;
|
||||
});
|
||||
const arcMax = computed(() => {
|
||||
const val = Number(props.widget.arcMax ?? 100);
|
||||
return Number.isFinite(val) ? val : 100;
|
||||
});
|
||||
const scaleMin = computed(() => (arcMax.value <= arcMin.value ? 0 : arcMin.value));
|
||||
const scaleMax = computed(() => (arcMax.value <= arcMin.value ? 100 : arcMax.value));
|
||||
const arcRange = computed(() => scaleMax.value - scaleMin.value);
|
||||
|
||||
const previewValue = computed(() => {
|
||||
if (props.widget.textSrc === 2) return scaleMax.value;
|
||||
if (props.widget.textSrc !== 0) return scaleMin.value + arcRange.value * 0.68;
|
||||
|
||||
const parsed = Number.parseFloat(props.widget.text ?? '0');
|
||||
if (Number.isNaN(parsed)) return scaleMin.value;
|
||||
return parsed;
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return clamp(previewValue.value, scaleMin.value, scaleMax.value);
|
||||
});
|
||||
|
||||
const startAngle = 135;
|
||||
const endAngle = 45;
|
||||
|
||||
const widthPx = computed(() => Math.max(10, props.widget.w * props.scale));
|
||||
const heightPx = computed(() => Math.max(10, props.widget.h * props.scale));
|
||||
|
||||
const strokeWidth = computed(() => {
|
||||
const base = Math.max(3, (props.widget.radius || 12) * props.scale);
|
||||
const max = Math.min(widthPx.value, heightPx.value) / 2 - 2;
|
||||
return Math.max(3, Math.min(base, max));
|
||||
});
|
||||
|
||||
const radius = computed(() => {
|
||||
const knobMargin = Math.max(2, strokeWidth.value * 0.35);
|
||||
return Math.max(4, Math.min(widthPx.value, heightPx.value) / 2 - strokeWidth.value / 2 - knobMargin);
|
||||
});
|
||||
|
||||
const center = computed(() => ({
|
||||
x: widthPx.value / 2,
|
||||
y: heightPx.value / 2
|
||||
}));
|
||||
|
||||
const viewBox = computed(() => `0 0 ${widthPx.value} ${heightPx.value}`);
|
||||
|
||||
const sweep = computed(() => {
|
||||
return endAngle >= startAngle ? endAngle - startAngle : 360 - startAngle + endAngle;
|
||||
});
|
||||
|
||||
function polarToCartesian(cx, cy, r, angleDeg) {
|
||||
const rad = (Math.PI / 180) * angleDeg;
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad)
|
||||
};
|
||||
}
|
||||
|
||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
const start = polarToCartesian(cx, cy, r, startDeg);
|
||||
const end = polarToCartesian(cx, cy, r, endDeg);
|
||||
const sweepAngle = endDeg >= startDeg ? endDeg - startDeg : 360 - startDeg + endDeg;
|
||||
const largeArc = sweepAngle > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
const trackPath = computed(() => {
|
||||
return describeArc(center.value.x, center.value.y, radius.value, startAngle, endAngle);
|
||||
});
|
||||
|
||||
const valueAngle = computed(() => {
|
||||
const ratio = arcRange.value > 0 ? (displayValue.value - scaleMin.value) / arcRange.value : 0;
|
||||
const deg = (startAngle + sweep.value * clamp(ratio, 0, 1)) % 360;
|
||||
return deg;
|
||||
});
|
||||
|
||||
const valuePath = computed(() => {
|
||||
if (arcRange.value <= 0) return '';
|
||||
if (displayValue.value <= scaleMin.value) return '';
|
||||
return describeArc(center.value.x, center.value.y, radius.value, startAngle, valueAngle.value);
|
||||
});
|
||||
|
||||
const knob = computed(() => {
|
||||
if (arcRange.value <= 0) return null;
|
||||
if (displayValue.value <= scaleMin.value) return null;
|
||||
const point = polarToCartesian(center.value.x, center.value.y, radius.value, valueAngle.value);
|
||||
return {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
r: Math.max(4, strokeWidth.value * 0.55)
|
||||
};
|
||||
});
|
||||
|
||||
const unitSuffix = computed(() => {
|
||||
if (props.widget.arcUnit === 1) return '%';
|
||||
if (props.widget.arcUnit === 2) return 'C';
|
||||
return '';
|
||||
});
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
const value = displayValue.value;
|
||||
const useDecimal = props.widget.textSrc === 1;
|
||||
const formatted = useDecimal ? value.toFixed(1) : Math.round(value).toString();
|
||||
return `${formatted}${unitSuffix.value}`;
|
||||
});
|
||||
const showValue = computed(() => props.widget.arcShowValue !== false);
|
||||
const indicatorColor = computed(() => props.widget.textColor || '#5DD39E');
|
||||
const valueColor = computed(() => props.widget.arcValueColor || indicatorColor.value);
|
||||
const valueFontSizeIndex = computed(() => {
|
||||
const idx = Number(props.widget.arcValueFontSize);
|
||||
if (Number.isFinite(idx) && idx >= 0) return idx;
|
||||
return Number(props.widget.fontSize ?? 2);
|
||||
});
|
||||
|
||||
const scaleColor = computed(() => props.widget.arcScaleColor || indicatorColor.value);
|
||||
const trackColor = computed(() => {
|
||||
return hexToRgba(
|
||||
props.widget.bgColor || '#274060',
|
||||
clamp((props.widget.bgOpacity ?? 180) / 255, 0, 1)
|
||||
);
|
||||
});
|
||||
const scaleMajorColor = computed(() => hexToRgba(scaleColor.value, 0.75));
|
||||
const scaleMinorColor = computed(() => hexToRgba(scaleColor.value, 0.35));
|
||||
|
||||
const scaleOffset = computed(() => {
|
||||
const raw = Number(props.widget.arcScaleOffset ?? 0);
|
||||
return (Number.isFinite(raw) ? raw : 0) * props.scale;
|
||||
});
|
||||
|
||||
const scaleMajorWidth = computed(() => Math.max(1, Math.round(strokeWidth.value * 0.16)));
|
||||
const scaleMinorWidth = computed(() => Math.max(1, Math.round(strokeWidth.value * 0.1)));
|
||||
|
||||
const scaleFontSize = computed(() => {
|
||||
const size = (fontSizes[props.widget.fontSize] || 22) * props.scale * 0.45;
|
||||
return Math.max(9, size);
|
||||
});
|
||||
|
||||
const scaleTicks = computed(() => {
|
||||
if (arcRange.value <= 0) return [];
|
||||
const totalTicks = 21;
|
||||
const majorEvery = 5;
|
||||
const baseRadius = Math.max(4, radius.value - strokeWidth.value * 0.2 + scaleOffset.value);
|
||||
const majorLen = Math.max(3, strokeWidth.value * 0.35 + 1);
|
||||
const minorLen = Math.max(2, strokeWidth.value * 0.2 + 1);
|
||||
const ticks = [];
|
||||
|
||||
for (let i = 0; i < totalTicks; i += 1) {
|
||||
const ratio = totalTicks > 1 ? i / (totalTicks - 1) : 0;
|
||||
const angle = (startAngle + sweep.value * ratio) % 360;
|
||||
const isMajor = i % majorEvery === 0 || i === totalTicks - 1;
|
||||
const len = isMajor ? majorLen : minorLen;
|
||||
const p1 = polarToCartesian(center.value.x, center.value.y, baseRadius, angle);
|
||||
const p2 = polarToCartesian(center.value.x, center.value.y, baseRadius - len, angle);
|
||||
ticks.push({
|
||||
key: `tick-${i}`,
|
||||
x1: p1.x,
|
||||
y1: p1.y,
|
||||
x2: p2.x,
|
||||
y2: p2.y,
|
||||
major: isMajor,
|
||||
ratio
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
});
|
||||
|
||||
const scaleLabels = computed(() => {
|
||||
if (!scaleTicks.value.length) return [];
|
||||
const baseRadius = Math.max(4, radius.value - strokeWidth.value * 0.2 + scaleOffset.value);
|
||||
const majorLen = Math.max(3, strokeWidth.value * 0.35 + 1);
|
||||
const labelGap = Math.max(2, strokeWidth.value * 0.25 + 2);
|
||||
const labelRadius = Math.max(6, baseRadius - majorLen - labelGap);
|
||||
|
||||
return scaleTicks.value
|
||||
.filter(tick => tick.major)
|
||||
.map((tick, idx) => {
|
||||
const value = scaleMin.value + tick.ratio * arcRange.value;
|
||||
const text = Math.round(value).toString();
|
||||
const angle = (startAngle + sweep.value * tick.ratio) % 360;
|
||||
const pos = polarToCartesian(center.value.x, center.value.y, labelRadius, angle);
|
||||
return {
|
||||
key: `label-${idx}`,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
text
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const valueStyle = computed(() => {
|
||||
const size = (fontSizes[valueFontSizeIndex.value] || 22) * props.scale;
|
||||
return {
|
||||
color: valueColor.value,
|
||||
fontSize: `${Math.max(10, size)}px`,
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
};
|
||||
});
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
style.borderRadius = '50%';
|
||||
style.overflow = 'hidden';
|
||||
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
Object.assign(style, getShadowStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
134
web-interface/src/components/widgets/elements/ButtonElement.vue
Normal file
134
web-interface/src/components/widgets/elements/ButtonElement.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<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, getBorderStyle, clamp, hexToRgba } 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);
|
||||
|
||||
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
||||
style.background = hexToRgba(w.bgColor, alpha);
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
style.justifyContent = justifyForAlign(textAlign.value);
|
||||
style.fontWeight = '600';
|
||||
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
Object.assign(style, getShadowStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,107 @@
|
||||
<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 p-1.5 flex flex-col gap-1" :style="contentStyle">
|
||||
<div
|
||||
v-for="(row, rowIdx) in rows"
|
||||
:key="rowIdx"
|
||||
class="flex-1 min-h-0 flex gap-1"
|
||||
>
|
||||
<div
|
||||
v-for="(label, colIdx) in row"
|
||||
:key="`${rowIdx}-${colIdx}`"
|
||||
class="min-w-0 flex-1 rounded-md flex items-center justify-center px-1 text-center truncate"
|
||||
:style="cellStyle"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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, getBorderStyle, getShadowStyle, 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']);
|
||||
|
||||
function parseMatrix(text) {
|
||||
if (!text || !text.trim()) {
|
||||
return [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9']
|
||||
];
|
||||
}
|
||||
|
||||
const result = text
|
||||
.split('\n')
|
||||
.map(row => row.split(/[;,]/).map(item => item.trim()).filter(Boolean))
|
||||
.filter(row => row.length > 0);
|
||||
|
||||
if (result.length === 0) {
|
||||
return [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9']
|
||||
];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const rows = computed(() => parseMatrix(props.widget.text || ''));
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
return {
|
||||
fontSize: `${(fontSizes[props.widget.fontSize] || 18) * props.scale}px`
|
||||
};
|
||||
});
|
||||
|
||||
const cellStyle = computed(() => {
|
||||
const buttonAlpha = clamp((props.widget.bgOpacity ?? 180) / 255, 0, 1);
|
||||
return {
|
||||
color: props.widget.textColor || '#FFFFFF',
|
||||
background: hexToRgba(props.widget.bgColor || '#2A3A4A', buttonAlpha)
|
||||
};
|
||||
});
|
||||
|
||||
const computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = getBaseStyle(w, s);
|
||||
style.background = hexToRgba(w.bgColor || '#2A3A4A', clamp((w.bgOpacity ?? 180) / 255, 0, 0.4));
|
||||
style.borderRadius = `${(w.radius || 10) * s}px`;
|
||||
style.overflow = 'hidden';
|
||||
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
Object.assign(style, getShadowStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,69 @@
|
||||
<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, getBorderStyle } 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`;
|
||||
}
|
||||
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
|
||||
style.padding = `${12 * s}px`;
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,71 @@
|
||||
<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 box-border flex items-center justify-center overflow-hidden">
|
||||
<!-- 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, getBorderStyle } 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%';
|
||||
}
|
||||
|
||||
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
} else {
|
||||
style.border = `${2 * s}px solid ${w.textColor}`;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,77 @@
|
||||
<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, getBorderStyle } 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`;
|
||||
}
|
||||
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
114
web-interface/src/components/widgets/elements/LabelElement.vue
Normal file
114
web-interface/src/components/widgets/elements/LabelElement.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<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, getBorderStyle } 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);
|
||||
}
|
||||
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
|
||||
style.display = 'flex';
|
||||
style.alignItems = 'center';
|
||||
|
||||
if (!hasIcon.value) {
|
||||
style.justifyContent = justifyForAlign(textAlign.value);
|
||||
style.textAlign = textAlignCss(textAlign.value);
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
65
web-interface/src/components/widgets/elements/LedElement.vue
Normal file
65
web-interface/src/components/widgets/elements/LedElement.vue
Normal file
@ -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')"
|
||||
>
|
||||
<!-- 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, getBorderStyle } 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)';
|
||||
}
|
||||
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,205 @@
|
||||
<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, getBorderStyle, 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';
|
||||
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
} else {
|
||||
style.border = `1px solid ${hexToRgba('#94a3b8', 0.35)}`;
|
||||
}
|
||||
|
||||
Object.assign(style, getShadowStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,136 @@
|
||||
<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, getBorderStyle, 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);
|
||||
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
} else {
|
||||
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,54 @@
|
||||
<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
|
||||
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, getShadowStyle, 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);
|
||||
|
||||
const bgAlpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
||||
style.background = hexToRgba(w.bgColor || '#000000', bgAlpha);
|
||||
style.borderRadius = `${(w.radius || 0) * s}px`;
|
||||
|
||||
const borderWidth = Math.max(0, (w.borderWidth || 0) * s);
|
||||
const borderAlpha = clamp((w.borderOpacity ?? 0) / 255, 0, 1);
|
||||
if (borderWidth > 0 && borderAlpha > 0) {
|
||||
style.border = `${borderWidth}px solid ${hexToRgba(w.borderColor || '#ffffff', borderAlpha)}`;
|
||||
} else {
|
||||
style.border = 'none';
|
||||
}
|
||||
|
||||
Object.assign(style, getShadowStyle(w, s));
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,288 @@
|
||||
<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, getBorderStyle, 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;
|
||||
return {
|
||||
backgroundColor: hexToRgba(props.widget.bgColor || '#333333', alpha),
|
||||
borderRadius: (props.widget.radius || 16) + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
border: ((props.widget.borderWidth || 0) > 0 && (props.widget.borderOpacity ?? 0) > 0)
|
||||
? `${(props.widget.borderWidth || 0) * s}px solid ${hexToRgba(props.widget.borderColor || '#ffffff', clamp((props.widget.borderOpacity ?? 0) / 255, 0, 1))}`
|
||||
: 'none',
|
||||
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.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
} else if (w.shadow && w.shadow.enabled) {
|
||||
style.border = `3px solid ${w.shadow.color || '#ff6b6b'}`;
|
||||
}
|
||||
} else {
|
||||
style.overflow = 'visible';
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,72 @@
|
||||
<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';
|
||||
import { clamp, hexToRgba, getBorderStyle } 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 computedStyle = computed(() => {
|
||||
const w = props.widget;
|
||||
const s = props.scale;
|
||||
|
||||
const style = {
|
||||
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'
|
||||
};
|
||||
|
||||
if ((w.bgOpacity ?? 0) > 0) {
|
||||
style.background = hexToRgba(w.bgColor, clamp((w.bgOpacity ?? 255) / 255, 0, 1));
|
||||
}
|
||||
if ((w.radius || 0) > 0) {
|
||||
style.borderRadius = `${(w.radius || 0) * s}px`;
|
||||
}
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
|
||||
return style;
|
||||
});
|
||||
</script>
|
||||
127
web-interface/src/components/widgets/elements/TabViewElement.vue
Normal file
127
web-interface/src/components/widgets/elements/TabViewElement.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<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, getBorderStyle, clamp, hexToRgba } 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 = hexToRgba(w.bgColor, clamp((w.bgOpacity ?? 255) / 255, 0, 1));
|
||||
style.borderRadius = `${w.radius * s}px`;
|
||||
Object.assign(style, getBorderStyle(w, s));
|
||||
|
||||
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>
|
||||
101
web-interface/src/components/widgets/settings/ArcSettings.vue
Normal file
101
web-interface/src/components/widgets/settings/ArcSettings.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<h4 :class="headingClass">Wertanzeige</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||
<select :class="inputClass" v-model.number="widget.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.arc)" :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">Statisch</label>
|
||||
<input :class="inputClass" type="number" min="0" max="100" v-model="widget.text">
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">KNX Lese</label>
|
||||
<select :class="inputClass" v-model.number="widget.knxAddr">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in readableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">GA manuell</label>
|
||||
<input :class="inputClass" type="number" v-model.number="widget.knxAddr">
|
||||
</div>
|
||||
<div v-if="!store.knxAddresses.length" :class="noteClass">Keine KNX-Adressen geladen (Dev-API?).</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Von</label><input :class="inputClass" type="number" v-model.number="widget.arcMin"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Bis</label><input :class="inputClass" type="number" v-model.number="widget.arcMax"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Einheit</label>
|
||||
<select :class="inputClass" v-model.number="widget.arcUnit">
|
||||
<option :value="0">Keine</option>
|
||||
<option :value="1">%</option>
|
||||
<option :value="2">C</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Wert anzeigen</label>
|
||||
<input class="accent-[var(--accent)]" type="checkbox" v-model="widget.arcShowValue">
|
||||
</div>
|
||||
|
||||
<h4 :class="headingClass">Skala</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Abstand</label>
|
||||
<input :class="inputClass" type="number" v-model.number="widget.arcScaleOffset">
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label>
|
||||
<input :class="colorInputClass" type="color" v-model="widget.arcScaleColor">
|
||||
</div>
|
||||
|
||||
<h4 :class="headingClass">Wert</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label>
|
||||
<input :class="colorInputClass" type="color" v-model="widget.arcValueColor">
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Groesse</label>
|
||||
<select :class="inputClass" v-model.number="widget.arcValueFontSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4 :class="headingClass">Stil</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Indikator</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Track</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Track Opa</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Linienbreite</label><input :class="inputClass" type="number" min="2" max="48" v-model.number="widget.radius"></div>
|
||||
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
|
||||
|
||||
<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" min="0" max="40" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Spread</label><input :class="inputClass" type="number" min="0" max="20" v-model.number="widget.shadow.spread"></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 } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass, noteClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const store = useEditorStore();
|
||||
const readableAddresses = computed(() => store.knxAddresses);
|
||||
|
||||
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,80 @@
|
||||
<template>
|
||||
<div>
|
||||
<h4 :class="headingClass">Buttons</h4>
|
||||
<div :class="rowClass">
|
||||
<label :class="labelClass">Matrix</label>
|
||||
<textarea
|
||||
:class="`${inputClass} min-h-[74px] resize-y`"
|
||||
v-model="widget.text"
|
||||
placeholder="z.B. 1;2;3\n4;5;6"
|
||||
></textarea>
|
||||
</div>
|
||||
<div :class="noteClass">Spalten mit `;` oder `,` trennen, Zeilen mit Enter.</div>
|
||||
|
||||
<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">Buttonfarbe</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" min="0" max="40" v-model.number="widget.radius"></div>
|
||||
|
||||
<h4 :class="headingClass">Text</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Schriftgroesse</label>
|
||||
<select :class="inputClass" v-model.number="widget.fontSize">
|
||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }} px</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
|
||||
|
||||
<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" min="0" max="40" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Spread</label><input :class="inputClass" type="number" min="0" max="20" v-model.number="widget.shadow.spread"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
||||
|
||||
<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 { BUTTON_ACTIONS, fontSizes } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass, noteClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const store = useEditorStore();
|
||||
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
||||
</script>
|
||||
135
web-interface/src/components/widgets/settings/ButtonSettings.vue
Normal file
135
web-interface/src/components/widgets/settings/ButtonSettings.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<div :class="rowClass"><label :class="labelClass">Ausrichtung</label>
|
||||
<select :class="inputClass" v-model.number="widget.textAlign">
|
||||
<option :value="0">Links</option>
|
||||
<option :value="1">Zentriert</option>
|
||||
<option :value="2">Rechts</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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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, fontSizes } from '../../../constants';
|
||||
import { rowClass, labelClass, inputClass, headingClass, colorInputClass, iconSelectClass, iconRemoveClass } from '../shared/styles';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['open-icon-picker']);
|
||||
|
||||
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>
|
||||
112
web-interface/src/components/widgets/settings/ChartSettings.vue
Normal file
112
web-interface/src/components/widgets/settings/ChartSettings.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,30 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,70 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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>
|
||||
117
web-interface/src/components/widgets/settings/LabelSettings.vue
Normal file
117
web-interface/src/components/widgets/settings/LabelSettings.vue
Normal file
@ -0,0 +1,117 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,59 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,179 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,262 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<h4 :class="headingClass">Fuellung</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">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" min="0" max="200" v-model.number="widget.radius"></div>
|
||||
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
|
||||
|
||||
<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" min="0" max="40" v-model.number="widget.shadow.blur"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Spread</label><input :class="inputClass" type="number" min="0" max="20" v-model.number="widget.shadow.spread"></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,343 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,34 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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,45 @@
|
||||
<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>
|
||||
<h4 :class="headingClass">Rand</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></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';
|
||||
70
web-interface/src/components/widgets/shared/utils.js
Normal file
70
web-interface/src/components/widgets/shared/utils.js
Normal file
@ -0,0 +1,70 @@
|
||||
// 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}`
|
||||
};
|
||||
}
|
||||
|
||||
// Border style helper (0-255 opacity, width in widget px)
|
||||
export function getBorderStyle(widget, scale) {
|
||||
const width = Math.max(0, (widget.borderWidth || 0) * scale);
|
||||
const alpha = clamp((widget.borderOpacity ?? 0) / 255, 0, 1);
|
||||
if (width <= 0 || alpha <= 0) {
|
||||
return { border: 'none' };
|
||||
}
|
||||
return {
|
||||
border: `${width}px solid ${hexToRgba(widget.borderColor || '#ffffff', alpha)}`
|
||||
};
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@ -14,7 +14,10 @@ export const WIDGET_TYPES = {
|
||||
POWERLINK: 8,
|
||||
CHART: 9,
|
||||
CLOCK: 10,
|
||||
ROOMCARD: 11
|
||||
ROOMCARD: 11,
|
||||
RECTANGLE: 12,
|
||||
ARC: 13,
|
||||
BUTTONMATRIX: 14
|
||||
};
|
||||
|
||||
export const ICON_POSITIONS = {
|
||||
@ -48,7 +51,10 @@ export const TYPE_KEYS = {
|
||||
8: 'powerlink',
|
||||
9: 'chart',
|
||||
10: 'clock',
|
||||
11: 'roomcard'
|
||||
11: 'roomcard',
|
||||
12: 'rectangle',
|
||||
13: 'arc',
|
||||
14: 'buttonmatrix'
|
||||
};
|
||||
|
||||
export const TYPE_LABELS = {
|
||||
@ -63,7 +69,10 @@ export const TYPE_LABELS = {
|
||||
powerlink: 'Power Link',
|
||||
chart: 'Chart',
|
||||
clock: 'Uhr (Analog)',
|
||||
roomcard: 'Room Card'
|
||||
roomcard: 'Room Card',
|
||||
rectangle: 'Rechteck',
|
||||
arc: 'Arc',
|
||||
buttonmatrix: 'Button Matrix'
|
||||
};
|
||||
|
||||
|
||||
@ -107,7 +116,10 @@ export const sourceOptions = {
|
||||
powerlink: [0, 1, 3, 5, 6, 7],
|
||||
chart: [1, 3, 5, 6, 7],
|
||||
clock: [11],
|
||||
roomcard: [0, 1, 3, 5, 6, 7] // Temperature sources
|
||||
roomcard: [0, 1, 3, 5, 6, 7], // Temperature sources
|
||||
rectangle: [0],
|
||||
arc: [0, 1, 2, 3, 5, 6, 7],
|
||||
buttonmatrix: [0]
|
||||
};
|
||||
|
||||
export const chartPeriods = [
|
||||
@ -155,6 +167,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',
|
||||
@ -201,7 +217,7 @@ export const WIDGET_DEFAULTS = {
|
||||
textSrc: 0,
|
||||
fontSize: 1,
|
||||
textAlign: TEXT_ALIGNS.CENTER,
|
||||
isContainer: true,
|
||||
isContainer: false,
|
||||
textColor: '#FFFFFF',
|
||||
bgColor: '#2E7DD1',
|
||||
bgOpacity: 255,
|
||||
@ -441,7 +457,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,
|
||||
@ -454,6 +470,98 @@ export const WIDGET_DEFAULTS = {
|
||||
iconGap: 8,
|
||||
subButtonSize: 40, // Sub-button size in pixels
|
||||
subButtonDistance: 80, // Distance from center in pixels
|
||||
subButtons: []
|
||||
subButtonOpacity: 255, // Sub-button opacity (0-255)
|
||||
cardStyle: 0, // 0=Bubble (round), 1=Tile (rectangular)
|
||||
subButtons: [],
|
||||
textLines: [] // Variable text lines with icon, text, textSrc, knxAddr, fontSize
|
||||
},
|
||||
rectangle: {
|
||||
w: 220,
|
||||
h: 140,
|
||||
text: '',
|
||||
textSrc: 0,
|
||||
fontSize: 1,
|
||||
textAlign: TEXT_ALIGNS.CENTER,
|
||||
textColor: '#FFFFFF',
|
||||
bgColor: '#2E7DD1',
|
||||
bgOpacity: 180,
|
||||
radius: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFFFFF',
|
||||
borderOpacity: 180,
|
||||
shadow: { enabled: false, x: 2, y: 2, blur: 10, spread: 0, color: '#000000' },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
knxAddr: 0,
|
||||
action: 0,
|
||||
targetScreen: 0,
|
||||
iconCodepoint: 0,
|
||||
iconPosition: 0,
|
||||
iconSize: 1,
|
||||
iconGap: 0,
|
||||
iconPositionX: 0,
|
||||
iconPositionY: 0
|
||||
},
|
||||
arc: {
|
||||
w: 180,
|
||||
h: 180,
|
||||
text: '75',
|
||||
textSrc: 0,
|
||||
fontSize: 2,
|
||||
textAlign: TEXT_ALIGNS.CENTER,
|
||||
textColor: '#5DD39E',
|
||||
bgColor: '#274060',
|
||||
bgOpacity: 180,
|
||||
radius: 12,
|
||||
borderWidth: 0,
|
||||
borderColor: '#FFFFFF',
|
||||
borderOpacity: 0,
|
||||
arcMin: 0,
|
||||
arcMax: 100,
|
||||
arcUnit: 1,
|
||||
arcShowValue: true,
|
||||
arcScaleOffset: 0,
|
||||
arcScaleColor: '#5DD39E',
|
||||
arcValueColor: '#5DD39E',
|
||||
arcValueFontSize: 2,
|
||||
shadow: { enabled: false, x: 0, y: 0, blur: 8, spread: 0, color: '#000000' },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
knxAddr: 0,
|
||||
action: 0,
|
||||
targetScreen: 0,
|
||||
iconCodepoint: 0,
|
||||
iconPosition: 0,
|
||||
iconSize: 1,
|
||||
iconGap: 0,
|
||||
iconPositionX: 0,
|
||||
iconPositionY: 0
|
||||
},
|
||||
buttonmatrix: {
|
||||
w: 240,
|
||||
h: 150,
|
||||
text: '1;2;3\n4;5;6\n7;8;9',
|
||||
textSrc: 0,
|
||||
fontSize: 1,
|
||||
textAlign: TEXT_ALIGNS.CENTER,
|
||||
textColor: '#FFFFFF',
|
||||
bgColor: '#2A3A4A',
|
||||
bgOpacity: 200,
|
||||
radius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FFFFFF',
|
||||
borderOpacity: 48,
|
||||
shadow: { enabled: false, x: 0, y: 0, blur: 8, spread: 0, color: '#000000' },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
knxAddr: 0,
|
||||
action: BUTTON_ACTIONS.KNX,
|
||||
targetScreen: 0,
|
||||
iconCodepoint: 0,
|
||||
iconPosition: 0,
|
||||
iconSize: 1,
|
||||
iconGap: 0,
|
||||
iconPositionX: 0,
|
||||
iconPositionY: 0
|
||||
}
|
||||
};
|
||||
|
||||
@ -124,55 +124,30 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
|
||||
});
|
||||
|
||||
function ensureButtonLabels(screen) {
|
||||
function cleanupButtonLabels(screen) {
|
||||
if (!screen || !Array.isArray(screen.widgets)) return;
|
||||
|
||||
const hasLabelChild = new Set();
|
||||
const buttonIds = new Set(
|
||||
screen.widgets.filter(w => w.type === WIDGET_TYPES.BUTTON).map(w => w.id)
|
||||
);
|
||||
if (!buttonIds.size) return;
|
||||
|
||||
const removeIds = new Set();
|
||||
screen.widgets.forEach((w) => {
|
||||
if (w.type === WIDGET_TYPES.LABEL && w.parentId !== -1) {
|
||||
hasLabelChild.add(w.parentId);
|
||||
if (w.type === WIDGET_TYPES.LABEL && buttonIds.has(w.parentId)) {
|
||||
removeIds.add(w.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (removeIds.size) {
|
||||
screen.widgets = screen.widgets.filter(w => !removeIds.has(w.id));
|
||||
}
|
||||
|
||||
// Update container flag for buttons based on remaining children
|
||||
screen.widgets.forEach((w) => {
|
||||
if (w.type !== WIDGET_TYPES.BUTTON) return;
|
||||
w.isContainer = true;
|
||||
|
||||
if (hasLabelChild.has(w.id)) return;
|
||||
|
||||
const defaults = WIDGET_DEFAULTS.label;
|
||||
const labelText = w.text || WIDGET_DEFAULTS.button.text;
|
||||
const label = {
|
||||
id: nextWidgetId.value++,
|
||||
parentId: w.id,
|
||||
type: WIDGET_TYPES.LABEL,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
visible: true,
|
||||
textSrc: defaults.textSrc,
|
||||
text: labelText,
|
||||
knxAddr: defaults.knxAddr,
|
||||
fontSize: w.fontSize ?? defaults.fontSize,
|
||||
textAlign: w.textAlign ?? TEXT_ALIGNS.CENTER,
|
||||
textColor: w.textColor ?? defaults.textColor,
|
||||
bgColor: defaults.bgColor,
|
||||
bgOpacity: 0,
|
||||
radius: 0,
|
||||
shadow: { ...defaults.shadow, enabled: false },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
action: 0,
|
||||
targetScreen: 0,
|
||||
iconCodepoint: w.iconCodepoint || 0,
|
||||
iconPosition: w.iconPosition ?? defaults.iconPosition,
|
||||
iconSize: w.iconSize ?? defaults.iconSize,
|
||||
iconGap: w.iconGap ?? defaults.iconGap
|
||||
};
|
||||
|
||||
screen.widgets.push(label);
|
||||
hasLabelChild.add(w.id);
|
||||
const hasChild = screen.widgets.some(child => child.parentId === w.id);
|
||||
w.isContainer = hasChild;
|
||||
});
|
||||
}
|
||||
|
||||
@ -247,7 +222,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
config.screens.forEach((screen) => {
|
||||
normalizeScreen(screen, nextScreenId, nextWidgetId);
|
||||
ensureButtonLabels(screen);
|
||||
cleanupButtonLabels(screen);
|
||||
// Also update max widget id
|
||||
screen.widgets.forEach(w => {
|
||||
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
|
||||
@ -335,6 +310,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
|
||||
case 'clock': typeValue = WIDGET_TYPES.CLOCK; break;
|
||||
case 'roomcard': typeValue = WIDGET_TYPES.ROOMCARD; break;
|
||||
case 'rectangle': typeValue = WIDGET_TYPES.RECTANGLE; break;
|
||||
case 'arc': typeValue = WIDGET_TYPES.ARC; break;
|
||||
case 'buttonmatrix': typeValue = WIDGET_TYPES.BUTTONMATRIX; break;
|
||||
default: typeValue = WIDGET_TYPES.LABEL;
|
||||
}
|
||||
|
||||
@ -400,6 +378,17 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
bgColor: defaults.bgColor,
|
||||
bgOpacity: defaults.bgOpacity,
|
||||
radius: defaults.radius,
|
||||
borderWidth: defaults.borderWidth ?? 0,
|
||||
borderColor: defaults.borderColor || '#ffffff',
|
||||
borderOpacity: defaults.borderOpacity ?? 0,
|
||||
arcMin: defaults.arcMin ?? 0,
|
||||
arcMax: defaults.arcMax ?? 100,
|
||||
arcUnit: defaults.arcUnit ?? 0,
|
||||
arcShowValue: defaults.arcShowValue ?? true,
|
||||
arcScaleOffset: defaults.arcScaleOffset ?? 0,
|
||||
arcScaleColor: defaults.arcScaleColor ?? defaults.textColor,
|
||||
arcValueColor: defaults.arcValueColor ?? defaults.textColor,
|
||||
arcValueFontSize: defaults.arcValueFontSize ?? defaults.fontSize,
|
||||
shadow: { ...defaults.shadow },
|
||||
isToggle: defaults.isToggle,
|
||||
knxAddrWrite: defaults.knxAddrWrite,
|
||||
@ -408,7 +397,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,47 +420,15 @@ 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);
|
||||
selectedWidgetId.value = w.id;
|
||||
|
||||
if (typeStr === 'button') {
|
||||
const labelDefaults = WIDGET_DEFAULTS.label;
|
||||
const label = {
|
||||
id: nextWidgetId.value++,
|
||||
parentId: w.id,
|
||||
type: WIDGET_TYPES.LABEL,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
visible: true,
|
||||
textSrc: labelDefaults.textSrc,
|
||||
text: w.text,
|
||||
knxAddr: labelDefaults.knxAddr,
|
||||
fontSize: w.fontSize,
|
||||
textAlign: TEXT_ALIGNS.CENTER,
|
||||
textColor: w.textColor,
|
||||
bgColor: labelDefaults.bgColor,
|
||||
bgOpacity: 0,
|
||||
radius: 0,
|
||||
shadow: { ...labelDefaults.shadow, enabled: false },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
action: 0,
|
||||
targetScreen: 0,
|
||||
iconCodepoint: w.iconCodepoint || 0,
|
||||
iconPosition: w.iconPosition || 0,
|
||||
iconSize: w.iconSize || 1,
|
||||
iconGap: w.iconGap || 8
|
||||
};
|
||||
activeScreen.value.widgets.push(label);
|
||||
}
|
||||
|
||||
// Auto-create pages for new TabView
|
||||
if (typeStr === 'tabview') {
|
||||
// Deselect to reset parent logic for recursive calls, or explicitly pass parent
|
||||
@ -526,7 +485,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);
|
||||
@ -582,7 +543,22 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const parentIds = new Set();
|
||||
activeScreen.value.widgets.forEach((w) => {
|
||||
if (deleteIds.includes(w.id) && w.parentId !== -1) {
|
||||
parentIds.add(w.parentId);
|
||||
}
|
||||
});
|
||||
|
||||
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => !deleteIds.includes(w.id));
|
||||
|
||||
parentIds.forEach((pid) => {
|
||||
const parent = activeScreen.value.widgets.find(w => w.id === pid);
|
||||
if (parent && parent.type === WIDGET_TYPES.BUTTON) {
|
||||
const hasChild = activeScreen.value.widgets.some(w => w.parentId === pid);
|
||||
parent.isContainer = hasChild;
|
||||
}
|
||||
});
|
||||
selectedWidgetId.value = null;
|
||||
}
|
||||
|
||||
@ -600,15 +576,70 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
const widget = activeScreen.value.widgets.find(w => w.id === widgetId);
|
||||
if (widget) {
|
||||
const oldParentId = widget.parentId;
|
||||
// Adjust position to be relative to new parent (simplified: reset to 0,0 or keep absolute?)
|
||||
// Keeping absolute is hard because we don't know absolute pos easily here.
|
||||
// Resetting to 10,10 is safer.
|
||||
widget.x = 10;
|
||||
widget.y = 10;
|
||||
widget.parentId = newParentId;
|
||||
|
||||
if (oldParentId !== -1) {
|
||||
const oldParent = activeScreen.value.widgets.find(w => w.id === oldParentId);
|
||||
if (oldParent && oldParent.type === WIDGET_TYPES.BUTTON) {
|
||||
const hasChild = activeScreen.value.widgets.some(w => w.parentId === oldParentId);
|
||||
oldParent.isContainer = hasChild;
|
||||
}
|
||||
}
|
||||
|
||||
const newParent = activeScreen.value.widgets.find(w => w.id === newParentId);
|
||||
if (newParent && newParent.type === WIDGET_TYPES.BUTTON) {
|
||||
newParent.isContainer = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findSiblingIndex(widgetId, direction) {
|
||||
if (!activeScreen.value) return -1;
|
||||
const widgets = activeScreen.value.widgets;
|
||||
const idx = widgets.findIndex(w => w.id === widgetId);
|
||||
if (idx < 0) return -1;
|
||||
|
||||
const parentId = widgets[idx].parentId;
|
||||
if (direction === 'up') {
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (widgets[i].parentId === parentId && widgets[i].type !== WIDGET_TYPES.POWERLINK) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = idx + 1; i < widgets.length; i++) {
|
||||
if (widgets[i].parentId === parentId && widgets[i].type !== WIDGET_TYPES.POWERLINK) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function canMoveWidget(widgetId, direction) {
|
||||
return findSiblingIndex(widgetId, direction) !== -1;
|
||||
}
|
||||
|
||||
function moveWidget(widgetId, direction) {
|
||||
if (!activeScreen.value) return;
|
||||
const widgets = activeScreen.value.widgets;
|
||||
const idx = widgets.findIndex(w => w.id === widgetId);
|
||||
if (idx < 0) return;
|
||||
|
||||
const targetIdx = findSiblingIndex(widgetId, direction);
|
||||
if (targetIdx < 0) return;
|
||||
|
||||
const tmp = widgets[idx];
|
||||
widgets[idx] = widgets[targetIdx];
|
||||
widgets[targetIdx] = tmp;
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
knxAddresses,
|
||||
@ -634,6 +665,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
removePowerLink,
|
||||
handlePowerNodeLink,
|
||||
deleteWidget,
|
||||
reparentWidget
|
||||
reparentWidget,
|
||||
moveWidget,
|
||||
canMoveWidget
|
||||
};
|
||||
});
|
||||
|
||||
@ -20,6 +20,9 @@ export function minSizeFor(widget) {
|
||||
if (key === 'powerlink') return { w: 1, h: 1 };
|
||||
if (key === 'chart') return { w: 160, h: 120 };
|
||||
if (key === 'roomcard') return { w: 120, h: 120 };
|
||||
if (key === 'rectangle') return { w: 40, h: 30 };
|
||||
if (key === 'arc') return { w: 80, h: 80 };
|
||||
if (key === 'buttonmatrix') return { w: 120, h: 60 };
|
||||
return { w: 40, h: 20 };
|
||||
}
|
||||
|
||||
@ -61,6 +64,27 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
||||
if (w.subButtonDistance === undefined || w.subButtonDistance === null) {
|
||||
w.subButtonDistance = defaults.subButtonDistance || 80;
|
||||
}
|
||||
// Ensure subButtonOpacity has a default
|
||||
if (w.subButtonOpacity === undefined || w.subButtonOpacity === null) {
|
||||
w.subButtonOpacity = defaults.subButtonOpacity || 255;
|
||||
}
|
||||
// Ensure cardStyle has a default
|
||||
if (w.cardStyle === undefined || w.cardStyle === null) {
|
||||
w.cardStyle = defaults.cardStyle || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@ -73,6 +97,17 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
||||
});
|
||||
}
|
||||
|
||||
// Border style defaults (used by rectangle widget, harmless for others)
|
||||
if (w.borderWidth === undefined || w.borderWidth === null) {
|
||||
w.borderWidth = defaults.borderWidth ?? 0;
|
||||
}
|
||||
if (!w.borderColor) {
|
||||
w.borderColor = defaults.borderColor || '#ffffff';
|
||||
}
|
||||
if (w.borderOpacity === undefined || w.borderOpacity === null) {
|
||||
w.borderOpacity = defaults.borderOpacity ?? 0;
|
||||
}
|
||||
|
||||
if (defaults.chart) {
|
||||
const maxSeries = 3;
|
||||
if (!w.chart) {
|
||||
@ -118,12 +153,14 @@ 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;
|
||||
|
||||
if (key === 'button') {
|
||||
w.isContainer = true;
|
||||
if (key === 'button' && (w.isContainer === undefined || w.isContainer === null)) {
|
||||
w.isContainer = defaults.isContainer ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user