This commit is contained in:
Thomas Peterson 2026-02-05 18:19:26 +01:00
parent 2ea18624fc
commit adf0f26da0
41 changed files with 1359 additions and 14 deletions

Binary file not shown.

Binary file not shown.

View File

@ -13,6 +13,8 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c
"widgets/ChartWidget.cpp" "widgets/ChartWidget.cpp"
"widgets/ClockWidget.cpp" "widgets/ClockWidget.cpp"
"widgets/RectangleWidget.cpp" "widgets/RectangleWidget.cpp"
"widgets/ArcWidget.cpp"
"widgets/ButtonMatrixWidget.cpp"
"widgets/RoomCardWidgetBase.cpp" "widgets/RoomCardWidgetBase.cpp"
"widgets/RoomCardBubbleWidget.cpp" "widgets/RoomCardBubbleWidget.cpp"
"widgets/RoomCardTileWidget.cpp" "widgets/RoomCardTileWidget.cpp"

View File

@ -140,6 +140,20 @@ void WidgetConfig::serialize(uint8_t* buf) const {
buf[pos++] = 0; // padding buf[pos++] = 0; // padding
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) { void WidgetConfig::deserialize(const uint8_t* buf) {
@ -310,6 +324,39 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
sb = SubButtonConfig{}; 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) { 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.borderWidth = 0;
cfg.borderColor = {255, 255, 255}; cfg.borderColor = {255, 255, 255};
cfg.borderOpacity = 0; 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; cfg.shadow.enabled = false;
// Icon defaults // Icon defaults
cfg.iconCodepoint = 0; cfg.iconCodepoint = 0;
@ -387,6 +442,14 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
cfg.borderWidth = 0; cfg.borderWidth = 0;
cfg.borderColor = {255, 255, 255}; cfg.borderColor = {255, 255, 255};
cfg.borderOpacity = 0; 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.enabled = true;
cfg.shadow.offsetX = 2; cfg.shadow.offsetX = 2;
cfg.shadow.offsetY = 2; cfg.shadow.offsetY = 2;

View File

@ -30,6 +30,8 @@ enum class WidgetType : uint8_t {
CLOCK = 10, CLOCK = 10,
ROOMCARD = 11, ROOMCARD = 11,
RECTANGLE = 12, RECTANGLE = 12,
ARC = 13,
BUTTONMATRIX = 14,
}; };
enum class IconPosition : uint8_t { enum class IconPosition : uint8_t {
@ -283,9 +285,19 @@ struct WidgetConfig {
uint8_t textLineCount; uint8_t textLineCount;
TextLineConfig textLines[MAX_TEXTLINES]; 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) // Serialization size (fixed for NVS storage)
// 326 + 5 (borderWidth + borderColor + borderOpacity) = 331 // 331 + 14 (arcMin + arcMax + arcUnit + arcShowValue + arcScaleOffset + arcScaleColor + arcValueColor + arcValueFontSize) = 345
static constexpr size_t SERIALIZED_SIZE = 331; static constexpr size_t SERIALIZED_SIZE = 345;
void serialize(uint8_t* buf) const; void serialize(uint8_t* buf) const;
void deserialize(const uint8_t* buf); void deserialize(const uint8_t* buf);

View File

@ -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); printf("WM: handleButtonAction btn=%d act=%d type=%d\n", cfg.id, (int)cfg.action, (int)cfg.type);
fflush(stdout); fflush(stdout);
if (cfg.type != WidgetType::BUTTON) { if (cfg.type != WidgetType::BUTTON && cfg.type != WidgetType::BUTTONMATRIX) {
printf("WM: Not a button!\n"); printf("WM: Not a clickable widget!\n");
fflush(stdout); fflush(stdout);
return; return;
} }
@ -1552,6 +1552,20 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap); cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
cJSON_AddNumberToObject(widget, "iconPositionX", w.iconPositionX); cJSON_AddNumberToObject(widget, "iconPositionX", w.iconPositionX);
cJSON_AddNumberToObject(widget, "iconPositionY", w.iconPositionY); 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); cJSON_AddNumberToObject(widget, "parentId", w.parentId);
@ -1748,6 +1762,14 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
w.chartTextSource[i] = TextSource::KNX_DPT_TEMP; w.chartTextSource[i] = TextSource::KNX_DPT_TEMP;
w.chartSeriesColor[i] = defaultChartColor(i); 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"); cJSON* id = cJSON_GetObjectItem(widget, "id");
if (cJSON_IsNumber(id)) w.id = id->valueint; if (cJSON_IsNumber(id)) w.id = id->valueint;
@ -1871,6 +1893,38 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON* iconPositionY = cJSON_GetObjectItem(widget, "iconPositionY"); cJSON* iconPositionY = cJSON_GetObjectItem(widget, "iconPositionY");
if (cJSON_IsNumber(iconPositionY)) w.iconPositionY = iconPositionY->valueint; 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"); cJSON* parentId = cJSON_GetObjectItem(widget, "parentId");
if (cJSON_IsNumber(parentId)) { if (cJSON_IsNumber(parentId)) {

247
main/widgets/ArcWidget.cpp Normal file
View 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);
}

View 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;
};

View 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);
}

View 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_;
};

View File

@ -13,6 +13,8 @@
#include "RoomCardBubbleWidget.hpp" #include "RoomCardBubbleWidget.hpp"
#include "RoomCardTileWidget.hpp" #include "RoomCardTileWidget.hpp"
#include "RectangleWidget.hpp" #include "RectangleWidget.hpp"
#include "ArcWidget.hpp"
#include "ButtonMatrixWidget.hpp"
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) { std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
if (!config.visible) return nullptr; if (!config.visible) return nullptr;
@ -48,6 +50,10 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
return std::make_unique<RoomCardBubbleWidget>(config); return std::make_unique<RoomCardBubbleWidget>(config);
case WidgetType::RECTANGLE: case WidgetType::RECTANGLE:
return std::make_unique<RectangleWidget>(config); 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: default:
return nullptr; return nullptr;
} }

View File

@ -46,6 +46,14 @@
<span class="text-[13px] font-semibold">Rechteck</span> <span class="text-[13px] font-semibold">Rechteck</span>
<span class="text-[11px] text-muted mt-0.5 block">Form</span> <span class="text-[11px] text-muted mt-0.5 block">Form</span>
</button> </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')"> <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-[13px] font-semibold">Room Card</span>
<span class="text-[11px] text-muted mt-0.5 block">Raum</span> <span class="text-[11px] text-muted mt-0.5 block">Raum</span>

View File

@ -61,6 +61,8 @@ import ChartSettings from './widgets/settings/ChartSettings.vue';
import ClockSettings from './widgets/settings/ClockSettings.vue'; import ClockSettings from './widgets/settings/ClockSettings.vue';
import RoomCardSettings from './widgets/settings/RoomCardSettings.vue'; import RoomCardSettings from './widgets/settings/RoomCardSettings.vue';
import RectangleSettings from './widgets/settings/RectangleSettings.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 store = useEditorStore();
const w = computed(() => store.selectedWidget); const w = computed(() => store.selectedWidget);
@ -84,7 +86,9 @@ const componentMap = {
[WIDGET_TYPES.CHART]: markRaw(ChartSettings), [WIDGET_TYPES.CHART]: markRaw(ChartSettings),
[WIDGET_TYPES.CLOCK]: markRaw(ClockSettings), [WIDGET_TYPES.CLOCK]: markRaw(ClockSettings),
[WIDGET_TYPES.ROOMCARD]: markRaw(RoomCardSettings), [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(() => { const settingsComponent = computed(() => {

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="relative"> <div class="relative">
<div <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="{ :class="{
'bg-accent-2/15 border-accent-2/30': store.selectedWidgetId === node.id, 'bg-accent-2/15 border-accent-2/30': store.selectedWidgetId === node.id,
'opacity-50': !node.visible, 'opacity-50': !node.visible,
@ -25,10 +25,31 @@
<span class="material-symbols-outlined text-[16px] text-accent opacity-80">{{ getIconForType(node.type) }}</span> <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-[12px] truncate">{{ displayTitle(node) }}</span>
<span class="text-[9px] text-muted">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span> <span class="text-[9px] text-muted">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
</div> </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>
<div class="relative" v-if="node.children.length > 0 && expanded"> <div class="relative" v-if="node.children.length > 0 && expanded">
@ -44,7 +65,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import { useEditorStore } from '../stores/editor'; import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils'; import { typeKeyFor } from '../utils';
import { TYPE_LABELS, WIDGET_TYPES } from '../constants'; import { TYPE_LABELS, WIDGET_TYPES } from '../constants';
@ -57,6 +78,8 @@ const props = defineProps({
const store = useEditorStore(); const store = useEditorStore();
const expanded = ref(true); const expanded = ref(true);
const isDragOver = ref(false); const isDragOver = ref(false);
const canMoveUp = computed(() => store.canMoveWidget?.(props.node.id, 'up'));
const canMoveDown = computed(() => store.canMoveWidget?.(props.node.id, 'down'));
function toggleExpand() { function toggleExpand() {
expanded.value = !expanded.value; expanded.value = !expanded.value;
@ -72,6 +95,8 @@ function getIconForType(type) {
case WIDGET_TYPES.TABPAGE: return 'article'; case WIDGET_TYPES.TABPAGE: return 'article';
case WIDGET_TYPES.POWERFLOW: return 'device_hub'; case WIDGET_TYPES.POWERFLOW: return 'device_hub';
case WIDGET_TYPES.POWERNODE: return 'radio_button_checked'; case WIDGET_TYPES.POWERNODE: return 'radio_button_checked';
case WIDGET_TYPES.ARC: return 'donut_large';
case WIDGET_TYPES.BUTTONMATRIX: return 'dialpad';
default: return 'widgets'; default: return 'widgets';
} }
} }
@ -127,4 +152,14 @@ function onDrop(e, targetNode) {
store.reparentWidget(draggedId, targetNode.id); 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> </script>

View File

@ -27,6 +27,8 @@ import ChartElement from './widgets/elements/ChartElement.vue';
import ClockElement from './widgets/elements/ClockElement.vue'; import ClockElement from './widgets/elements/ClockElement.vue';
import RoomCardElement from './widgets/elements/RoomCardElement.vue'; import RoomCardElement from './widgets/elements/RoomCardElement.vue';
import RectangleElement from './widgets/elements/RectangleElement.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({ const props = defineProps({
widget: { type: Object, required: true }, widget: { type: Object, required: true },
@ -49,7 +51,9 @@ const componentMap = {
[WIDGET_TYPES.CHART]: markRaw(ChartElement), [WIDGET_TYPES.CHART]: markRaw(ChartElement),
[WIDGET_TYPES.CLOCK]: markRaw(ClockElement), [WIDGET_TYPES.CLOCK]: markRaw(ClockElement),
[WIDGET_TYPES.ROOMCARD]: markRaw(RoomCardElement), [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(() => { const widgetComponent = computed(() => {

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -15,7 +15,9 @@ export const WIDGET_TYPES = {
CHART: 9, CHART: 9,
CLOCK: 10, CLOCK: 10,
ROOMCARD: 11, ROOMCARD: 11,
RECTANGLE: 12 RECTANGLE: 12,
ARC: 13,
BUTTONMATRIX: 14
}; };
export const ICON_POSITIONS = { export const ICON_POSITIONS = {
@ -50,7 +52,9 @@ export const TYPE_KEYS = {
9: 'chart', 9: 'chart',
10: 'clock', 10: 'clock',
11: 'roomcard', 11: 'roomcard',
12: 'rectangle' 12: 'rectangle',
13: 'arc',
14: 'buttonmatrix'
}; };
export const TYPE_LABELS = { export const TYPE_LABELS = {
@ -66,7 +70,9 @@ export const TYPE_LABELS = {
chart: 'Chart', chart: 'Chart',
clock: 'Uhr (Analog)', clock: 'Uhr (Analog)',
roomcard: 'Room Card', 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], chart: [1, 3, 5, 6, 7],
clock: [11], clock: [11],
roomcard: [0, 1, 3, 5, 6, 7], // Temperature sources 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 = [ export const chartPeriods = [
@ -493,5 +501,67 @@ export const WIDGET_DEFAULTS = {
iconGap: 0, iconGap: 0,
iconPositionX: 0, iconPositionX: 0,
iconPositionY: 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
} }
}; };

View File

@ -341,6 +341,8 @@ export const useEditorStore = defineStore('editor', () => {
case 'clock': typeValue = WIDGET_TYPES.CLOCK; break; case 'clock': typeValue = WIDGET_TYPES.CLOCK; break;
case 'roomcard': typeValue = WIDGET_TYPES.ROOMCARD; break; case 'roomcard': typeValue = WIDGET_TYPES.ROOMCARD; break;
case 'rectangle': typeValue = WIDGET_TYPES.RECTANGLE; 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; default: typeValue = WIDGET_TYPES.LABEL;
} }
@ -409,6 +411,14 @@ export const useEditorStore = defineStore('editor', () => {
borderWidth: defaults.borderWidth ?? 0, borderWidth: defaults.borderWidth ?? 0,
borderColor: defaults.borderColor || '#ffffff', borderColor: defaults.borderColor || '#ffffff',
borderOpacity: defaults.borderOpacity ?? 0, 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 }, shadow: { ...defaults.shadow },
isToggle: defaults.isToggle, isToggle: defaults.isToggle,
knxAddrWrite: defaults.knxAddrWrite, 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 { return {
config, config,
knxAddresses, knxAddresses,
@ -653,6 +704,8 @@ export const useEditorStore = defineStore('editor', () => {
removePowerLink, removePowerLink,
handlePowerNodeLink, handlePowerNodeLink,
deleteWidget, deleteWidget,
reparentWidget reparentWidget,
moveWidget,
canMoveWidget
}; };
}); });

View File

@ -21,6 +21,8 @@ export function minSizeFor(widget) {
if (key === 'chart') return { w: 160, h: 120 }; if (key === 'chart') return { w: 160, h: 120 };
if (key === 'roomcard') return { w: 120, h: 120 }; if (key === 'roomcard') return { w: 120, h: 120 };
if (key === 'rectangle') return { w: 40, h: 30 }; 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 }; return { w: 40, h: 20 };
} }