This commit is contained in:
Thomas Peterson 2026-01-28 16:52:05 +01:00
parent fbf7fa12a2
commit 75a7e18913
5 changed files with 322 additions and 46 deletions

View File

@ -1,13 +1,33 @@
#include "PowerLinkWidget.hpp" #include "PowerLinkWidget.hpp"
#include "../WidgetManager.hpp" #include "../WidgetManager.hpp"
#include <cmath>
#include <cstdlib>
PowerLinkWidget::PowerLinkWidget(const WidgetConfig& config) PowerLinkWidget::PowerLinkWidget(const WidgetConfig& config)
: Widget(config) : Widget(config)
{ {
points_[0].x = 0; for (uint8_t i = 0; i < POINT_COUNT; i++) {
points_[0].y = 0; points_[i].x = 0;
points_[1].x = 1; points_[i].y = 0;
points_[1].y = 1; }
}
PowerLinkWidget::~PowerLinkWidget() {
lv_anim_del(this, nullptr);
if (dot_ != nullptr) {
if (lv_obj_is_valid(dot_)) {
lv_obj_delete(dot_);
}
dot_ = nullptr;
}
}
static float parseFloatOr(const char* text, float fallback) {
if (text == nullptr || text[0] == '\0') return fallback;
char* end = nullptr;
float value = std::strtof(text, &end);
if (end == text || !std::isfinite(value)) return fallback;
return value;
} }
void PowerLinkWidget::buildLinePoints() { void PowerLinkWidget::buildLinePoints() {
@ -22,24 +42,101 @@ void PowerLinkWidget::buildLinePoints() {
if (fromNode == nullptr || toNode == nullptr) return; if (fromNode == nullptr || toNode == nullptr) return;
if (fromNode->parentId != config_.parentId || toNode->parentId != config_.parentId) return; if (fromNode->parentId != config_.parentId || toNode->parentId != config_.parentId) return;
int16_t x1 = fromNode->x + fromNode->width / 2; float x1 = static_cast<float>(fromNode->x) + static_cast<float>(fromNode->width) * 0.5f;
int16_t y1 = fromNode->y + fromNode->height / 2; float y1 = static_cast<float>(fromNode->y) + static_cast<float>(fromNode->height) * 0.5f;
int16_t x2 = toNode->x + toNode->width / 2; float x2 = static_cast<float>(toNode->x) + static_cast<float>(toNode->width) * 0.5f;
int16_t y2 = toNode->y + toNode->height / 2; float y2 = static_cast<float>(toNode->y) + static_cast<float>(toNode->height) * 0.5f;
int16_t minX = x1 < x2 ? x1 : x2; float dx = x2 - x1;
int16_t minY = y1 < y2 ? y1 : y2; float dy = y2 - y1;
int16_t maxX = x1 > x2 ? x1 : x2; float len = std::sqrt(dx * dx + dy * dy);
int16_t maxY = y1 > y2 ? y1 : y2; if (len < 0.001f) return;
points_[0].x = x1 - minX; float lineWidth = config_.width > 0 ? static_cast<float>(config_.width) : 3.0f;
points_[0].y = y1 - minY; if (lineWidth < 3.0f) lineWidth = 3.0f;
points_[1].x = x2 - minX; int16_t minSideA = fromNode->width < fromNode->height ? fromNode->width : fromNode->height;
points_[1].y = y2 - minY; int16_t minSideB = toNode->width < toNode->height ? toNode->width : toNode->height;
float radiusA = static_cast<float>(minSideA) * 0.5f - lineWidth * 0.5f;
float radiusB = static_cast<float>(minSideB) * 0.5f - lineWidth * 0.5f;
if (radiusA < 0.0f) radiusA = 0.0f;
if (radiusB < 0.0f) radiusB = 0.0f;
float ux = dx / len;
float uy = dy / len;
float startX = x1 + ux * radiusA;
float startY = y1 + uy * radiusA;
float endX = x2 - ux * radiusB;
float endY = y2 - uy * radiusB;
if (len <= radiusA + radiusB + 1.0f) {
startX = x1;
startY = y1;
endX = x2;
endY = y2;
}
float dxTrim = endX - startX;
float dyTrim = endY - startY;
float lenTrim = std::sqrt(dxTrim * dxTrim + dyTrim * dyTrim);
if (lenTrim < 0.001f) lenTrim = len;
float nx = -dyTrim / lenTrim;
float ny = dxTrim / lenTrim;
float midX = (startX + endX) * 0.5f;
float midY = (startY + endY) * 0.5f;
float curve = std::fmin(40.0f, lenTrim * 0.25f);
float curveSign = (config_.iconPosition == 2) ? -1.0f : 1.0f;
if (config_.iconPosition == 0) {
curveSign = (config_.id % 2 == 0) ? 1.0f : -1.0f;
} else if (config_.iconPosition == 1) {
curveSign = 1.0f;
} else if (config_.iconPosition == 3) {
curveSign = -1.0f;
}
p0x_ = startX;
p0y_ = startY;
p1x_ = midX + nx * curve * curveSign;
p1y_ = midY + ny * curve * curveSign;
p2x_ = endX;
p2y_ = endY;
pathLen_ = lenTrim;
float minXf = p0x_;
float minYf = p0y_;
float maxXf = p0x_;
float maxYf = p0y_;
for (uint8_t i = 0; i < POINT_COUNT; i++) {
float t = static_cast<float>(i) / static_cast<float>(POINT_COUNT - 1);
float inv = 1.0f - t;
float x = inv * inv * p0x_ + 2.0f * inv * t * p1x_ + t * t * p2x_;
float y = inv * inv * p0y_ + 2.0f * inv * t * p1y_ + t * t * p2y_;
if (x < minXf) minXf = x;
if (y < minYf) minYf = y;
if (x > maxXf) maxXf = x;
if (y > maxYf) maxYf = y;
}
int32_t minX = static_cast<int32_t>(std::floor(minXf));
int32_t minY = static_cast<int32_t>(std::floor(minYf));
int32_t maxX = static_cast<int32_t>(std::ceil(maxXf));
int32_t maxY = static_cast<int32_t>(std::ceil(maxYf));
for (uint8_t i = 0; i < POINT_COUNT; i++) {
float t = static_cast<float>(i) / static_cast<float>(POINT_COUNT - 1);
float inv = 1.0f - t;
float x = inv * inv * p0x_ + 2.0f * inv * t * p1x_ + t * t * p2x_;
float y = inv * inv * p0y_ + 2.0f * inv * t * p1y_ + t * t * p2y_;
points_[i].x = x - minX;
points_[i].y = y - minY;
}
lv_obj_set_pos(obj_, minX, minY); lv_obj_set_pos(obj_, minX, minY);
lv_obj_set_size(obj_, (maxX - minX) + 1, (maxY - minY) + 1); lv_obj_set_size(obj_, (maxX - minX) + 1, (maxY - minY) + 1);
lv_line_set_points(obj_, points_, 2); lv_line_set_points(obj_, points_, POINT_COUNT);
dotAnimExec(this, 0);
} }
lv_obj_t* PowerLinkWidget::create(lv_obj_t* parent) { lv_obj_t* PowerLinkWidget::create(lv_obj_t* parent) {
@ -49,19 +146,117 @@ lv_obj_t* PowerLinkWidget::create(lv_obj_t* parent) {
} }
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_move_to_index(obj_, 0);
buildLinePoints(); buildLinePoints();
dot_ = lv_obj_create(parent);
if (dot_ != nullptr) {
lv_obj_remove_style_all(dot_);
lv_obj_clear_flag(dot_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_clear_flag(dot_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_move_to_index(dot_, 1);
}
startDotAnimation();
return obj_; return obj_;
} }
void PowerLinkWidget::applyStyle() { void PowerLinkWidget::applyStyle() {
if (obj_ == nullptr) return; if (obj_ == nullptr) return;
uint16_t lineWidth = config_.width > 0 ? config_.width : 3;
if (lineWidth < 3) lineWidth = 3;
lv_obj_set_style_line_color(obj_, lv_color_make( lv_obj_set_style_line_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0); config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_line_width(obj_, config_.width > 0 ? config_.width : 2, 0); lv_obj_set_style_line_width(obj_, lineWidth, 0);
lv_obj_set_style_line_opa(obj_, config_.bgOpacity, 0); lv_obj_set_style_line_opa(obj_, config_.bgOpacity, 0);
lv_obj_set_style_line_dash_width(obj_, 0, 0);
lv_obj_set_style_line_dash_gap(obj_, 0, 0);
int32_t dash = config_.iconGap > 0 ? config_.iconGap : 6; if (dot_ != nullptr) {
lv_obj_set_style_line_dash_width(obj_, dash, 0); uint16_t dotSize = static_cast<uint16_t>(lineWidth * 3);
lv_obj_set_style_line_dash_gap(obj_, dash + 4, 0); if (dotSize < 6) dotSize = 6;
if (dotSize > 16) dotSize = 16;
dotRadius_ = dotSize * 0.5f;
lv_obj_set_size(dot_, dotSize, dotSize);
lv_obj_set_style_radius(dot_, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(dot_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_bg_opa(dot_, config_.bgOpacity, 0);
dotAnimExec(this, 0);
}
}
void PowerLinkWidget::startDotAnimation() {
if (dot_ == nullptr || obj_ == nullptr) return;
if (config_.textSource == TextSource::STATIC) {
float speed = parseFloatOr(config_.text, 60.0f);
updateAnimation(speed, false);
} else {
updateAnimation(0.0f, false);
}
}
void PowerLinkWidget::dotAnimExec(void* var, int32_t value) {
auto* self = static_cast<PowerLinkWidget*>(var);
if (self == nullptr || self->dot_ == nullptr) return;
float t = static_cast<float>(value) / 1000.0f;
if (self->reverse_) {
t = 1.0f - t;
}
float inv = 1.0f - t;
float x = inv * inv * self->p0x_ + 2.0f * inv * t * self->p1x_ + t * t * self->p2x_;
float y = inv * inv * self->p0y_ + 2.0f * inv * t * self->p1y_ + t * t * self->p2y_;
lv_obj_set_pos(self->dot_,
static_cast<int32_t>(x - self->dotRadius_),
static_cast<int32_t>(y - self->dotRadius_));
}
void PowerLinkWidget::updateAnimation(float speed, bool reverse) {
if (dot_ == nullptr || obj_ == nullptr) return;
speed_ = speed;
reverse_ = reverse;
lv_anim_del(this, nullptr);
if (speed_ <= 0.01f || pathLen_ <= 0.5f) {
lv_obj_add_flag(dot_, LV_OBJ_FLAG_HIDDEN);
return;
}
lv_obj_clear_flag(dot_, LV_OBJ_FLAG_HIDDEN);
int32_t duration = static_cast<int32_t>((pathLen_ / speed_) * 1000.0f);
if (duration < 800) duration = 800;
if (duration > 12000) duration = 12000;
lv_anim_t anim;
lv_anim_init(&anim);
lv_anim_set_var(&anim, this);
lv_anim_set_exec_cb(&anim, PowerLinkWidget::dotAnimExec);
lv_anim_set_values(&anim, 0, 1000);
lv_anim_set_repeat_count(&anim, LV_ANIM_REPEAT_INFINITE);
lv_anim_set_path_cb(&anim, lv_anim_path_linear);
lv_anim_set_time(&anim, duration);
lv_anim_start(&anim);
dotAnimExec(this, 0);
}
void PowerLinkWidget::onKnxValue(float value) {
if (config_.textSource != TextSource::KNX_DPT_TEMP &&
config_.textSource != TextSource::KNX_DPT_PERCENT) return;
float factor = parseFloatOr(config_.text, 1.0f);
float speed = std::fabs(value) * factor;
updateAnimation(speed, value < 0.0f);
}
void PowerLinkWidget::onKnxSwitch(bool value) {
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
float factor = parseFloatOr(config_.text, 1.0f);
float speed = value ? factor : 0.0f;
updateAnimation(speed, false);
} }

View File

@ -5,10 +5,28 @@
class PowerLinkWidget : public Widget { class PowerLinkWidget : public Widget {
public: public:
explicit PowerLinkWidget(const WidgetConfig& config); explicit PowerLinkWidget(const WidgetConfig& config);
~PowerLinkWidget() override;
lv_obj_t* create(lv_obj_t* parent) override; lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override; void applyStyle() override;
void onKnxValue(float value) override;
void onKnxSwitch(bool value) override;
private: private:
lv_point_precise_t points_[2] = {}; static constexpr uint8_t POINT_COUNT = 16;
lv_point_precise_t points_[POINT_COUNT] = {};
lv_obj_t* dot_ = nullptr;
float p0x_ = 0.0f;
float p0y_ = 0.0f;
float p1x_ = 0.0f;
float p1y_ = 0.0f;
float p2x_ = 0.0f;
float p2y_ = 0.0f;
float pathLen_ = 0.0f;
float dotRadius_ = 3.0f;
float speed_ = 0.0f;
bool reverse_ = false;
void buildLinePoints(); void buildLinePoints();
void startDotAnimation();
void updateAnimation(float speed, bool reverse);
static void dotAnimExec(void* var, int32_t value);
}; };

View File

@ -131,12 +131,44 @@
</div> </div>
<div class="text-[11px] text-muted mb-2">{{ linkModeHint }}</div> <div class="text-[11px] text-muted mb-2">{{ linkModeHint }}</div>
<div v-if="powerFlowLinkItems.length" class="mt-2"> <div v-if="powerFlowLinkItems.length" class="mt-2">
<div v-for="link in powerFlowLinkItems" :key="link.id" class="flex items-center gap-2 mb-2 text-[11px] text-muted"> <div v-for="link in powerFlowLinkItems" :key="link.id" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<span class="px-1.5 py-0.5 rounded-md bg-panel-2 border border-border text-text max-w-[90px] truncate">{{ link.fromLabel }}</span> <div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<span>-&gt;</span> <span class="px-1.5 py-0.5 rounded-md bg-white border border-border text-text max-w-[90px] truncate">{{ link.fromLabel }}</span>
<span class="px-1.5 py-0.5 rounded-md bg-panel-2 border border-border text-text max-w-[90px] truncate">{{ link.toLabel }}</span> <span>-&gt;</span>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="link.widget.bgColor"> <span class="px-1.5 py-0.5 rounded-md bg-white border border-border text-text max-w-[90px] truncate">{{ link.toLabel }}</span>
<button class="ml-auto w-6 h-6 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[11px] cursor-pointer hover:bg-[#f2cfcf]" @click="removePowerLink(link.id)">x</button> <button class="ml-auto w-6 h-6 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[11px] cursor-pointer hover:bg-[#f2cfcf]" @click="removePowerLink(link.id)">x</button>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Linie</label>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="link.widget.bgColor">
<input class="w-[70px] bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="number" min="1" max="12" v-model.number="link.widget.w">
<span class="text-[10px] text-muted">px</span>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Speed</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.textSrc">
<option v-for="opt in sourceOptions.powerlink" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="link.widget.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Wert</label>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" inputmode="decimal" v-model="link.widget.text" placeholder="z.B. 60">
</div>
<div v-else class="flex flex-col gap-2 text-[11px] text-muted">
<div class="flex items-center gap-2">
<label class="w-[70px] text-[11px] text-muted">Faktor</label>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" inputmode="decimal" v-model="link.widget.text" placeholder="z.B. 0.2">
</div>
<div class="flex items-center gap-2">
<label class="w-[70px] text-[11px] text-muted">KNX</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
<div v-else class="text-[11px] text-muted">Keine Verbindungen.</div> <div v-else class="text-[11px] text-muted">Keine Verbindungen.</div>

View File

@ -53,19 +53,22 @@
:stroke="link.color" :stroke="link.color"
:stroke-width="link.width" :stroke-width="link.width"
stroke-linecap="round" stroke-linecap="round"
:stroke-dasharray="`${link.dash} ${link.dash + 4}`"
fill="none" fill="none"
:opacity="link.opacity" :opacity="link.opacity"
/> />
<circle <circle
v-for="link in powerFlowLinks" v-for="link in powerFlowLinks"
:key="`dot-${link.id}`" :key="`dot-${link.id}`"
:cx="link.dotX" :r="link.dotRadius"
:cy="link.dotY"
r="3"
:fill="link.color" :fill="link.color"
:opacity="link.opacity" :opacity="link.opacity"
/> >
<animateMotion
:dur="`${link.duration}s`"
repeatCount="indefinite"
:path="link.path"
/>
</circle>
</svg> </svg>
<div v-if="!powerNodes.length" class="absolute inset-0 grid place-items-center text-[12px] text-muted"> <div v-if="!powerNodes.length" class="absolute inset-0 grid place-items-center text-[12px] text-muted">
Power Nodes hinzufuegen Power Nodes hinzufuegen
@ -345,6 +348,7 @@ const powerFlowLinks = computed(() => {
const toNode = nodeMap.get(link.y); const toNode = nodeMap.get(link.y);
if (!fromNode || !toNode) return []; if (!fromNode || !toNode) return [];
const lineWidth = Math.max(3, link.w || 3);
const fromCenter = { const fromCenter = {
x: (fromNode.x + fromNode.w / 2) * s, x: (fromNode.x + fromNode.w / 2) * s,
y: (fromNode.y + fromNode.h / 2) * s y: (fromNode.y + fromNode.h / 2) * s
@ -357,25 +361,51 @@ const powerFlowLinks = computed(() => {
const dx = toCenter.x - fromCenter.x; const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y; const dy = toCenter.y - fromCenter.y;
const len = Math.hypot(dx, dy) || 1; const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len; const ux = dx / len;
const ny = dx / len; const uy = dy / len;
const midX = (fromCenter.x + toCenter.x) / 2; const fromRadius = Math.max(0, (Math.min(fromNode.w, fromNode.h) * 0.5 - lineWidth * 0.5) * s);
const midY = (fromCenter.y + toCenter.y) / 2; const toRadius = Math.max(0, (Math.min(toNode.w, toNode.h) * 0.5 - lineWidth * 0.5) * s);
let startX = fromCenter.x + ux * fromRadius;
let startY = fromCenter.y + uy * fromRadius;
let endX = toCenter.x - ux * toRadius;
let endY = toCenter.y - uy * toRadius;
if (len <= fromRadius + toRadius + 1) {
startX = fromCenter.x;
startY = fromCenter.y;
endX = toCenter.x;
endY = toCenter.y;
}
const dxTrim = endX - startX;
const dyTrim = endY - startY;
const lenTrim = Math.hypot(dxTrim, dyTrim) || 1;
const nx = -dyTrim / lenTrim;
const ny = dxTrim / lenTrim;
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const curveSign = idx % 2 === 0 ? 1 : -1; const curveSign = idx % 2 === 0 ? 1 : -1;
const curve = Math.min(42 * s, len * 0.3) * curveSign; const curve = Math.min(42 * s, lenTrim * 0.3) * curveSign;
const cpx = midX + nx * curve; const cpx = midX + nx * curve;
const cpy = midY + ny * curve; const cpy = midY + ny * curve;
const dash = Math.max(2, link.iconGap || 6); const dotRadius = Math.min(8, Math.max(4, lineWidth * 1.6));
const rawValue = parseFloat(link.text);
const hasRaw = Number.isFinite(rawValue);
const isStatic = (link.textSrc ?? 0) === 0;
const factor = hasRaw ? rawValue : (isStatic ? 60 : 1);
const previewValue = 50;
const speed = Math.max(5, isStatic ? factor : Math.abs(previewValue) * factor);
const duration = Math.max(2, Math.min(10, lenTrim / speed));
return [{ return [{
id: link.id, id: link.id,
path: `M ${fromCenter.x} ${fromCenter.y} Q ${cpx} ${cpy} ${toCenter.x} ${toCenter.y}`, path: `M ${startX} ${startY} Q ${cpx} ${cpy} ${endX} ${endY}`,
color: link.bgColor || '#6fa7d8', color: link.bgColor || '#6fa7d8',
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1), opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
width: Math.max(1, link.w || 2), width: lineWidth,
dash, dotRadius,
dotX: midX + nx * curve * 0.5, duration
dotY: midY + ny * curve * 0.5
}]; }];
}); });
}); });

View File

@ -71,7 +71,8 @@ export const sourceOptions = {
button: [0], button: [0],
led: [0, 2], led: [0, 2],
icon: [0, 2], icon: [0, 2],
powernode: [0, 1, 2, 3, 4] powernode: [0, 1, 2, 3, 4],
powerlink: [0, 1, 3]
}; };
export const ICON_DEFAULTS = { export const ICON_DEFAULTS = {
@ -269,7 +270,7 @@ export const WIDGET_DEFAULTS = {
iconGap: 8 iconGap: 8
}, },
powerlink: { powerlink: {
w: 2, w: 3,
h: 0, h: 0,
text: '', text: '',
textSrc: 0, textSrc: 0,