Fixes
This commit is contained in:
parent
2ea18624fc
commit
adf0f26da0
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -13,6 +13,8 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c
|
||||
"widgets/ChartWidget.cpp"
|
||||
"widgets/ClockWidget.cpp"
|
||||
"widgets/RectangleWidget.cpp"
|
||||
"widgets/ArcWidget.cpp"
|
||||
"widgets/ButtonMatrixWidget.cpp"
|
||||
"widgets/RoomCardWidgetBase.cpp"
|
||||
"widgets/RoomCardBubbleWidget.cpp"
|
||||
"widgets/RoomCardTileWidget.cpp"
|
||||
|
||||
@ -140,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) {
|
||||
@ -310,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) {
|
||||
@ -334,6 +381,14 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c
|
||||
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;
|
||||
@ -387,6 +442,14 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
|
||||
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;
|
||||
|
||||
@ -30,6 +30,8 @@ enum class WidgetType : uint8_t {
|
||||
CLOCK = 10,
|
||||
ROOMCARD = 11,
|
||||
RECTANGLE = 12,
|
||||
ARC = 13,
|
||||
BUTTONMATRIX = 14,
|
||||
};
|
||||
|
||||
enum class IconPosition : uint8_t {
|
||||
@ -283,9 +285,19 @@ struct WidgetConfig {
|
||||
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)
|
||||
// 326 + 5 (borderWidth + borderColor + borderOpacity) = 331
|
||||
static constexpr size_t SERIALIZED_SIZE = 331;
|
||||
// 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);
|
||||
|
||||
@ -561,8 +561,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;
|
||||
}
|
||||
@ -1552,6 +1552,20 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
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);
|
||||
|
||||
@ -1748,6 +1762,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;
|
||||
@ -1872,6 +1894,38 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
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)) {
|
||||
w.parentId = static_cast<int8_t>(parentId->valueint);
|
||||
|
||||
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_;
|
||||
};
|
||||
@ -13,6 +13,8 @@
|
||||
#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;
|
||||
@ -48,6 +50,10 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& 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;
|
||||
}
|
||||
|
||||
@ -46,6 +46,14 @@
|
||||
<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>
|
||||
|
||||
@ -61,6 +61,8 @@ import ChartSettings from './widgets/settings/ChartSettings.vue';
|
||||
import ClockSettings from './widgets/settings/ClockSettings.vue';
|
||||
import RoomCardSettings from './widgets/settings/RoomCardSettings.vue';
|
||||
import RectangleSettings from './widgets/settings/RectangleSettings.vue';
|
||||
import ArcSettings from './widgets/settings/ArcSettings.vue';
|
||||
import ButtonMatrixSettings from './widgets/settings/ButtonMatrixSettings.vue';
|
||||
|
||||
const store = useEditorStore();
|
||||
const w = computed(() => store.selectedWidget);
|
||||
@ -84,7 +86,9 @@ const componentMap = {
|
||||
[WIDGET_TYPES.CHART]: markRaw(ChartSettings),
|
||||
[WIDGET_TYPES.CLOCK]: markRaw(ClockSettings),
|
||||
[WIDGET_TYPES.ROOMCARD]: markRaw(RoomCardSettings),
|
||||
[WIDGET_TYPES.RECTANGLE]: markRaw(RectangleSettings)
|
||||
[WIDGET_TYPES.RECTANGLE]: markRaw(RectangleSettings),
|
||||
[WIDGET_TYPES.ARC]: markRaw(ArcSettings),
|
||||
[WIDGET_TYPES.BUTTONMATRIX]: markRaw(ButtonMatrixSettings)
|
||||
};
|
||||
|
||||
const settingsComponent = computed(() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -27,6 +27,8 @@ 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 },
|
||||
@ -49,7 +51,9 @@ const componentMap = {
|
||||
[WIDGET_TYPES.CHART]: markRaw(ChartElement),
|
||||
[WIDGET_TYPES.CLOCK]: markRaw(ClockElement),
|
||||
[WIDGET_TYPES.ROOMCARD]: markRaw(RoomCardElement),
|
||||
[WIDGET_TYPES.RECTANGLE]: markRaw(RectangleElement)
|
||||
[WIDGET_TYPES.RECTANGLE]: markRaw(RectangleElement),
|
||||
[WIDGET_TYPES.ARC]: markRaw(ArcElement),
|
||||
[WIDGET_TYPES.BUTTONMATRIX]: markRaw(ButtonMatrixElement)
|
||||
};
|
||||
|
||||
const widgetComponent = computed(() => {
|
||||
|
||||
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>
|
||||
@ -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>
|
||||
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>
|
||||
@ -15,7 +15,9 @@ export const WIDGET_TYPES = {
|
||||
CHART: 9,
|
||||
CLOCK: 10,
|
||||
ROOMCARD: 11,
|
||||
RECTANGLE: 12
|
||||
RECTANGLE: 12,
|
||||
ARC: 13,
|
||||
BUTTONMATRIX: 14
|
||||
};
|
||||
|
||||
export const ICON_POSITIONS = {
|
||||
@ -50,7 +52,9 @@ export const TYPE_KEYS = {
|
||||
9: 'chart',
|
||||
10: 'clock',
|
||||
11: 'roomcard',
|
||||
12: 'rectangle'
|
||||
12: 'rectangle',
|
||||
13: 'arc',
|
||||
14: 'buttonmatrix'
|
||||
};
|
||||
|
||||
export const TYPE_LABELS = {
|
||||
@ -66,7 +70,9 @@ export const TYPE_LABELS = {
|
||||
chart: 'Chart',
|
||||
clock: 'Uhr (Analog)',
|
||||
roomcard: 'Room Card',
|
||||
rectangle: 'Rechteck'
|
||||
rectangle: 'Rechteck',
|
||||
arc: 'Arc',
|
||||
buttonmatrix: 'Button Matrix'
|
||||
};
|
||||
|
||||
|
||||
@ -111,7 +117,9 @@ export const sourceOptions = {
|
||||
chart: [1, 3, 5, 6, 7],
|
||||
clock: [11],
|
||||
roomcard: [0, 1, 3, 5, 6, 7], // Temperature sources
|
||||
rectangle: [0]
|
||||
rectangle: [0],
|
||||
arc: [0, 1, 2, 3, 5, 6, 7],
|
||||
buttonmatrix: [0]
|
||||
};
|
||||
|
||||
export const chartPeriods = [
|
||||
@ -493,5 +501,67 @@ export const WIDGET_DEFAULTS = {
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@ -341,6 +341,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -409,6 +411,14 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
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,
|
||||
@ -628,6 +638,47 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@ -653,6 +704,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
removePowerLink,
|
||||
handlePowerNodeLink,
|
||||
deleteWidget,
|
||||
reparentWidget
|
||||
reparentWidget,
|
||||
moveWidget,
|
||||
canMoveWidget
|
||||
};
|
||||
});
|
||||
|
||||
@ -21,6 +21,8 @@ export function minSizeFor(widget) {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user