869 lines
34 KiB
Vue
869 lines
34 KiB
Vue
<template>
|
|
<div
|
|
class="z-[1] select-none touch-none"
|
|
:class="[
|
|
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
|
|
isLabel ? 'px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap' : ''
|
|
]"
|
|
:style="computedStyle"
|
|
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
|
|
@touchstart.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
|
|
@click.stop="handleWidgetClick"
|
|
>
|
|
<!-- Recursive Children -->
|
|
<!-- Special handling for TabView to render structure -->
|
|
<template v-if="isTabView">
|
|
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
|
|
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
|
|
<div
|
|
v-for="(child, idx) in children"
|
|
:key="child.id"
|
|
:class="tabBtnClass(activePageId === child.id)"
|
|
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
|
|
>
|
|
{{ child.text }}
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 relative overflow-hidden">
|
|
<WidgetElement
|
|
v-for="child in children"
|
|
:key="child.id"
|
|
:widget="child"
|
|
:scale="scale"
|
|
:selected="store.selectedWidgetId === child.id"
|
|
:style="{ display: activePageId === child.id ? 'block' : 'none' }"
|
|
@select="store.selectedWidgetId = child.id"
|
|
@drag-start="$emit('drag-start', $event)"
|
|
@resize-start="$emit('resize-start', $event)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="isPowerFlow">
|
|
<div class="absolute inset-0" :style="powerFlowBgStyle"></div>
|
|
<div v-if="widget.text" class="absolute left-4 top-3 text-[13px] uppercase tracking-[0.08em]" :style="{ color: widget.textColor }">
|
|
{{ widget.text }}
|
|
</div>
|
|
<svg class="absolute inset-0 pointer-events-none" :width="widget.w * scale" :height="widget.h * scale">
|
|
<path
|
|
v-for="link in powerFlowLinks"
|
|
:key="`link-${link.id}`"
|
|
:d="link.path"
|
|
:stroke="link.color"
|
|
:stroke-width="link.width"
|
|
stroke-linecap="round"
|
|
fill="none"
|
|
:opacity="link.opacity"
|
|
/>
|
|
<circle
|
|
v-for="link in powerFlowLinks"
|
|
:key="`dot-${link.id}`"
|
|
:r="link.dotRadius"
|
|
:fill="link.color"
|
|
:opacity="link.opacity"
|
|
>
|
|
<animateMotion
|
|
:dur="`${link.duration}s`"
|
|
repeatCount="indefinite"
|
|
:path="link.path"
|
|
/>
|
|
</circle>
|
|
</svg>
|
|
<div v-if="!powerNodes.length" class="absolute inset-0 grid place-items-center text-[12px] text-muted">
|
|
Power Nodes hinzufuegen
|
|
</div>
|
|
<WidgetElement
|
|
v-for="child in powerFlowChildren"
|
|
:key="child.id"
|
|
:widget="child"
|
|
:scale="scale"
|
|
:selected="store.selectedWidgetId === child.id"
|
|
@select="store.selectedWidgetId = child.id"
|
|
@drag-start="$emit('drag-start', $event)"
|
|
@resize-start="$emit('resize-start', $event)"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Standard Recursive Children (for Buttons, Pages, etc) -->
|
|
<template v-else>
|
|
<WidgetElement
|
|
v-for="child in children"
|
|
:key="child.id"
|
|
:widget="child"
|
|
:scale="scale"
|
|
:selected="store.selectedWidgetId === child.id"
|
|
@select="store.selectedWidgetId = child.id"
|
|
@drag-start="$emit('drag-start', $event)"
|
|
@resize-start="$emit('resize-start', $event)"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Power Node Widget -->
|
|
<template v-if="isPowerNode">
|
|
<div class="flex flex-col items-center justify-center w-full h-full text-center leading-tight">
|
|
<span v-if="powerNodeParts.label" :style="powerNodeLabelStyle">{{ powerNodeParts.label }}</span>
|
|
<span v-if="widget.iconCodepoint" class="material-symbols-outlined mt-1" :style="powerNodeIconStyle">
|
|
{{ iconChar }}
|
|
</span>
|
|
<span v-if="powerNodeParts.value" class="mt-1" :style="powerNodeValueStyle">{{ powerNodeParts.value }}</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="isChart">
|
|
<div class="w-full h-full flex flex-col gap-2">
|
|
<div class="text-[11px] uppercase tracking-[0.12em] opacity-80">
|
|
{{ widget.text || 'Chart' }}
|
|
</div>
|
|
<div class="flex-1 rounded-[10px] bg-black/20 relative overflow-hidden">
|
|
<div class="absolute inset-0 opacity-30" style="background-image: linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px); background-size: 24px 24px;"></div>
|
|
<svg class="absolute inset-0" viewBox="0 0 100 40" preserveAspectRatio="none">
|
|
<path d="M0,30 L15,22 L30,26 L45,14 L60,18 L75,10 L100,16" fill="none" stroke="rgba(239,99,81,0.8)" stroke-width="2" />
|
|
<path d="M0,34 L20,28 L40,32 L60,20 L80,24 L100,18" fill="none" stroke="rgba(125,211,176,0.8)" stroke-width="2" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="isClock">
|
|
<div class="relative w-full h-full rounded-full border-2 box-border flex items-center justify-center overflow-hidden" :style="{ borderColor: widget.textColor }">
|
|
<!-- Center Dot -->
|
|
<div class="absolute w-2 h-2 rounded-full z-10" :style="{ backgroundColor: widget.textColor }"></div>
|
|
<!-- Hour Hand -->
|
|
<div class="absolute w-1.5 h-[28%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(300deg)' }"></div>
|
|
<!-- Minute Hand -->
|
|
<div class="absolute w-1 h-[40%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(70deg)' }"></div>
|
|
<!-- Second Hand -->
|
|
<div class="absolute w-0.5 h-[45%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full bg-[#c83232]" :style="{ transform: 'rotate(140deg)' }"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- RoomCard Widget - Bubble Style -->
|
|
<template v-else-if="isRoomCard && widget.cardStyle !== 1">
|
|
<!-- Central Bubble -->
|
|
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
|
|
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
|
|
{{ iconChar }}
|
|
</span>
|
|
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
|
|
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
|
|
</div>
|
|
<!-- Sub-Buttons (circular orbit) -->
|
|
<div
|
|
v-for="(sb, idx) in roomCardSubButtons"
|
|
:key="idx"
|
|
class="absolute rounded-full flex items-center justify-center shadow-md"
|
|
:style="getSubButtonStyle(sb, idx)"
|
|
>
|
|
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
|
|
{{ String.fromCodePoint(sb.icon) }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- RoomCard Widget - Tile Style -->
|
|
<template v-else-if="isRoomCard && widget.cardStyle === 1">
|
|
<div class="absolute inset-0 overflow-hidden" :style="roomCardTileContainerStyle">
|
|
<!-- Room name (top-left) -->
|
|
<div class="absolute" :style="{ left: '16px', top: '12px', color: widget.textColor, fontSize: fontSizes[widget.fontSize || 2] + 'px', fontWeight: 600 }">
|
|
{{ roomCardParts.name }}
|
|
</div>
|
|
<!-- Subtitle -->
|
|
<div v-if="roomCardParts.subtitle" class="absolute opacity-60" :style="{ left: '16px', top: '38px', color: widget.textColor, fontSize: fontSizes[Math.max(0, (widget.fontSize || 2) - 2)] + 'px' }">
|
|
{{ roomCardParts.subtitle }}
|
|
</div>
|
|
<!-- Temperature + Humidity row -->
|
|
<div class="absolute flex items-center gap-4" :style="{ left: '16px', top: '64px', color: widget.textColor, fontSize: fontSizes[Math.max(0, (widget.fontSize || 2) - 1)] + 'px' }">
|
|
<span v-if="widget.text2" class="flex items-center gap-1">
|
|
<span class="material-symbols-outlined text-[14px]">device_thermostat</span>
|
|
<span>{{ widget.text2 || '--' }}</span>
|
|
</span>
|
|
<span v-if="widget.text3" class="flex items-center gap-1">
|
|
<span class="material-symbols-outlined text-[14px]">humidity_percentage</span>
|
|
<span>{{ widget.text3 || '--' }}</span>
|
|
</span>
|
|
</div>
|
|
<!-- Large decorative icon (bottom-left) -->
|
|
<span v-if="widget.iconCodepoint" class="material-symbols-outlined absolute opacity-20" :style="{ left: '-20px', bottom: '-20px', fontSize: '120px', color: widget.textColor }">
|
|
{{ iconChar }}
|
|
</span>
|
|
</div>
|
|
<!-- Sub-Buttons (right side, vertical) -->
|
|
<div
|
|
v-for="(sb, idx) in roomCardSubButtons"
|
|
:key="idx"
|
|
class="absolute rounded-full flex items-center justify-center shadow-md"
|
|
:style="getSubButtonStyleTile(sb, idx)"
|
|
>
|
|
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
|
|
{{ String.fromCodePoint(sb.icon) }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Icon-only Widget -->
|
|
<template v-else-if="isIcon">
|
|
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
|
|
{{ iconChar }}
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Label/Button with Icon -->
|
|
<template v-else-if="hasIcon">
|
|
<div :style="contentStyle">
|
|
<span
|
|
v-if="iconPosition === 0 || iconPosition === 2"
|
|
class="material-symbols-outlined flex-shrink-0"
|
|
:style="iconStyle"
|
|
>{{ iconChar }}</span>
|
|
<span class="flex-shrink-0">{{ widget.text }}</span>
|
|
<span
|
|
v-if="iconPosition === 1 || iconPosition === 3"
|
|
class="material-symbols-outlined flex-shrink-0"
|
|
:style="iconStyle"
|
|
>{{ iconChar }}</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Standard (no icon) -->
|
|
<template v-else>
|
|
<span v-if="showDefaultText">{{ widget.text }}</span>
|
|
</template>
|
|
|
|
<!-- Resize Handle (at end to be on top) -->
|
|
<div
|
|
v-if="selected"
|
|
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
|
|
data-resize-handle
|
|
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
|
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
|
>
|
|
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref } from 'vue';
|
|
import { useEditorStore } from '../stores/editor';
|
|
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
|
|
import { clamp, hexToRgba } from '../utils';
|
|
|
|
const props = defineProps({
|
|
widget: { type: Object, required: true },
|
|
scale: { type: Number, default: 1 },
|
|
selected: { type: Boolean, default: false }
|
|
});
|
|
|
|
const emit = defineEmits(['select', 'drag-start', 'resize-start']);
|
|
|
|
const store = useEditorStore();
|
|
|
|
const children = computed(() => {
|
|
if (!store.activeScreen) return [];
|
|
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
|
|
});
|
|
|
|
const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
|
|
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
|
|
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
|
|
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
|
|
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
|
|
const isTabPage = computed(() => props.widget.type === WIDGET_TYPES.TABPAGE);
|
|
const isButtonContainer = computed(() => isButton.value && props.widget.isContainer);
|
|
const isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW);
|
|
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
|
|
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
|
|
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
|
|
const isClock = computed(() => props.widget.type === WIDGET_TYPES.CLOCK);
|
|
const isRoomCard = computed(() => props.widget.type === WIDGET_TYPES.ROOMCARD);
|
|
|
|
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
|
|
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
|
|
|
// Find active tab index (client-side state only, maybe store in widget temporarily?)
|
|
// For designer simplicity: show all tabs content stacked, or just first one?
|
|
// Better: mimic LVGL. We need state. Let's use a local ref or just show the selected one in tree?
|
|
// To keep it simple: Show the active tab based on which child is selected or default to first.
|
|
const activeTabIndex = ref(0);
|
|
|
|
const activePageId = computed(() => {
|
|
// If a child page is selected, make it active
|
|
const selectedChild = children.value.find(c => store.selectedWidgetId === c.id);
|
|
if (selectedChild) return selectedChild.id;
|
|
|
|
// If a widget inside a page is selected
|
|
if (store.selectedWidget && store.selectedWidget.parentId !== -1) {
|
|
// Find ancestor page
|
|
let curr = store.selectedWidget;
|
|
while (curr && curr.parentId !== -1 && curr.parentId !== props.widget.id) {
|
|
curr = store.activeScreen.widgets.find(w => w.id === curr.parentId);
|
|
}
|
|
if (curr && curr.parentId === props.widget.id) return curr.id;
|
|
}
|
|
|
|
if (children.value.length > 0) return children.value[activeTabIndex.value]?.id;
|
|
return -1;
|
|
});
|
|
|
|
const hasIcon = computed(() => {
|
|
if (isButtonContainer.value) return false;
|
|
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
|
|
});
|
|
|
|
const iconChar = computed(() => {
|
|
const cp = props.widget.iconCodepoint || 0xe88a;
|
|
return String.fromCodePoint(cp);
|
|
});
|
|
|
|
const iconPosition = computed(() => props.widget.iconPosition || 0);
|
|
const textAlign = computed(() => props.widget.textAlign ?? 1);
|
|
|
|
const showDefaultText = computed(() => {
|
|
if (isTabView.value || isTabPage.value) return false;
|
|
if (isPowerFlow.value || isPowerNode.value) return false;
|
|
if (isPowerLink.value) return false;
|
|
if (isButtonContainer.value) return false;
|
|
if (isRoomCard.value) return false;
|
|
return true;
|
|
});
|
|
|
|
const justifyForAlign = (align) => {
|
|
if (align === 0) return 'flex-start';
|
|
if (align === 2) return 'flex-end';
|
|
return 'center';
|
|
};
|
|
|
|
const textAlignCss = (align) => {
|
|
if (align === 0) return 'left';
|
|
if (align === 2) return 'right';
|
|
return 'center';
|
|
};
|
|
|
|
const contentJustify = computed(() => {
|
|
if (isButton.value || isLabel.value) return justifyForAlign(textAlign.value);
|
|
return 'center';
|
|
});
|
|
|
|
const isVerticalLayout = computed(() => {
|
|
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
|
|
});
|
|
|
|
const contentStyle = computed(() => {
|
|
const s = props.scale;
|
|
const gap = (props.widget.iconGap || 8) * s;
|
|
return {
|
|
display: 'flex',
|
|
flexDirection: isVerticalLayout.value ? 'column' : 'row',
|
|
alignItems: 'center',
|
|
justifyContent: contentJustify.value,
|
|
gap: `${gap}px`,
|
|
width: '100%',
|
|
height: '100%'
|
|
};
|
|
});
|
|
|
|
const iconStyle = computed(() => {
|
|
const s = props.scale;
|
|
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
|
|
const size = fontSizes[sizeIdx] || 18;
|
|
return {
|
|
fontSize: `${size * s}px`,
|
|
color: props.widget.textColor
|
|
};
|
|
});
|
|
|
|
const iconOnlyStyle = computed(() => {
|
|
const s = props.scale;
|
|
const sizeIdx = props.widget.iconSize ?? 3;
|
|
const size = fontSizes[sizeIdx] || 28;
|
|
return {
|
|
fontSize: `${size * s}px`,
|
|
color: props.widget.textColor
|
|
};
|
|
});
|
|
|
|
const powerNodeParts = computed(() => splitPowerNodeText(props.widget.text));
|
|
|
|
const powerNodeValueSize = computed(() => {
|
|
const sizeIdx = props.widget.fontSize ?? 2;
|
|
const size = fontSizes[sizeIdx] || 22;
|
|
return size * props.scale;
|
|
});
|
|
|
|
const powerNodeLabelSize = computed(() => {
|
|
return Math.max(10 * props.scale, powerNodeValueSize.value * 0.55);
|
|
});
|
|
|
|
const powerNodeIconSize = computed(() => {
|
|
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 2;
|
|
const size = fontSizes[sizeIdx] || 22;
|
|
return size * props.scale;
|
|
});
|
|
|
|
const powerNodeIconStyle = computed(() => ({
|
|
fontSize: `${powerNodeIconSize.value}px`,
|
|
color: props.widget.textColor
|
|
}));
|
|
|
|
const powerNodeLabelStyle = computed(() => ({
|
|
fontSize: `${powerNodeLabelSize.value}px`,
|
|
color: hexToRgba(props.widget.textColor, 0.72)
|
|
}));
|
|
|
|
const powerNodeValueStyle = computed(() => ({
|
|
fontSize: `${powerNodeValueSize.value}px`,
|
|
color: props.widget.textColor,
|
|
fontWeight: '600'
|
|
}));
|
|
|
|
const powerNodes = computed(() => {
|
|
if (!isPowerFlow.value) return [];
|
|
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERNODE && child.visible !== false);
|
|
});
|
|
|
|
const powerLinkWidgets = computed(() => {
|
|
if (!isPowerFlow.value) return [];
|
|
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERLINK && child.visible !== false);
|
|
});
|
|
|
|
const powerFlowChildren = computed(() => {
|
|
if (!isPowerFlow.value) return [];
|
|
return children.value.filter((child) => child.type !== WIDGET_TYPES.POWERLINK);
|
|
});
|
|
|
|
const powerFlowLinks = computed(() => {
|
|
if (!isPowerFlow.value || powerNodes.value.length < 2 || !powerLinkWidgets.value.length) return [];
|
|
|
|
const s = props.scale;
|
|
const nodeMap = new Map(powerNodes.value.map((node) => [node.id, node]));
|
|
|
|
return powerLinkWidgets.value.flatMap((link, idx) => {
|
|
const fromNode = nodeMap.get(link.x);
|
|
const toNode = nodeMap.get(link.y);
|
|
if (!fromNode || !toNode) return [];
|
|
|
|
const lineWidth = Math.max(3, link.w || 3);
|
|
const fromCenter = {
|
|
x: (fromNode.x + fromNode.w / 2) * s,
|
|
y: (fromNode.y + fromNode.h / 2) * s
|
|
};
|
|
const toCenter = {
|
|
x: (toNode.x + toNode.w / 2) * s,
|
|
y: (toNode.y + toNode.h / 2) * s
|
|
};
|
|
|
|
const dx = toCenter.x - fromCenter.x;
|
|
const dy = toCenter.y - fromCenter.y;
|
|
const len = Math.hypot(dx, dy) || 1;
|
|
const ux = dx / len;
|
|
const uy = dy / len;
|
|
const fromRadius = Math.max(0, (Math.min(fromNode.w, fromNode.h) * 0.5 - lineWidth * 0.5) * s);
|
|
const toRadius = Math.max(0, (Math.min(toNode.w, toNode.h) * 0.5 - lineWidth * 0.5) * s);
|
|
|
|
let startX = fromCenter.x + ux * fromRadius;
|
|
let startY = fromCenter.y + uy * fromRadius;
|
|
let endX = toCenter.x - ux * toRadius;
|
|
let endY = toCenter.y - uy * toRadius;
|
|
|
|
if (len <= fromRadius + toRadius + 1) {
|
|
startX = fromCenter.x;
|
|
startY = fromCenter.y;
|
|
endX = toCenter.x;
|
|
endY = toCenter.y;
|
|
}
|
|
|
|
const dxTrim = endX - startX;
|
|
const dyTrim = endY - startY;
|
|
const lenTrim = Math.hypot(dxTrim, dyTrim) || 1;
|
|
const nx = -dyTrim / lenTrim;
|
|
const ny = dxTrim / lenTrim;
|
|
const midX = (startX + endX) / 2;
|
|
const midY = (startY + endY) / 2;
|
|
const curveSign = idx % 2 === 0 ? 1 : -1;
|
|
const curve = Math.min(42 * s, lenTrim * 0.3) * curveSign;
|
|
const cpx = midX + nx * curve;
|
|
const cpy = midY + ny * curve;
|
|
const dotRadius = Math.min(8, Math.max(4, lineWidth * 1.6));
|
|
const rawValue = parseFloat(link.text);
|
|
const hasRaw = Number.isFinite(rawValue);
|
|
const isStatic = (link.textSrc ?? 0) === 0;
|
|
const factor = hasRaw ? rawValue : (isStatic ? 60 : 1);
|
|
const previewValue = 50;
|
|
const speed = Math.max(5, isStatic ? factor : Math.abs(previewValue) * factor);
|
|
const duration = Math.max(2, Math.min(10, lenTrim / speed));
|
|
|
|
return [{
|
|
id: link.id,
|
|
path: `M ${startX} ${startY} Q ${cpx} ${cpy} ${endX} ${endY}`,
|
|
color: link.bgColor || '#6fa7d8',
|
|
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
|
|
width: lineWidth,
|
|
dotRadius,
|
|
duration
|
|
}];
|
|
});
|
|
});
|
|
|
|
const powerFlowBgStyle = computed(() => {
|
|
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
|
|
const base = hexToRgba(props.widget.bgColor, alpha);
|
|
const dot = hexToRgba('#9aa7b4', 0.25);
|
|
const dotSize = 16 * props.scale;
|
|
|
|
return {
|
|
backgroundColor: base,
|
|
backgroundImage: `radial-gradient(${dot} 0.9px, transparent 1px), linear-gradient(140deg, rgba(255,255,255,0.9) 0%, ${base} 70%)`,
|
|
backgroundSize: `${dotSize}px ${dotSize}px, 100% 100%`,
|
|
backgroundPosition: '0 0, 0 0'
|
|
};
|
|
});
|
|
|
|
// RoomCard computed properties
|
|
const roomCardParts = computed(() => {
|
|
if (!props.widget.text) return { name: '', format: '', subtitle: '' };
|
|
const parts = props.widget.text.split('\n');
|
|
// For Tile style: second line is subtitle, temp/humidity come from text2/text3
|
|
// For Bubble style: second line is temp format
|
|
if (props.widget.cardStyle === 1) {
|
|
return {
|
|
name: parts[0] || '',
|
|
subtitle: parts[1] || '',
|
|
format: ''
|
|
};
|
|
}
|
|
return {
|
|
name: parts[0] || '',
|
|
format: parts[1] || '',
|
|
subtitle: ''
|
|
};
|
|
});
|
|
|
|
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
|
|
|
|
// Tile style container
|
|
const roomCardTileContainerStyle = computed(() => {
|
|
const alpha = (props.widget.bgOpacity ?? 255) / 255;
|
|
const style = {
|
|
backgroundColor: hexToRgba(props.widget.bgColor || '#333333', alpha),
|
|
borderRadius: (props.widget.radius || 16) + 'px',
|
|
};
|
|
// Border from shadow settings
|
|
if (props.widget.shadow?.enabled) {
|
|
style.border = `3px solid ${props.widget.shadow.color || '#ff6b6b'}`;
|
|
}
|
|
return style;
|
|
});
|
|
|
|
// Sub-button positioning for Tile (right side, vertical)
|
|
const getSubButtonStyleTile = (sb, idx) => {
|
|
const s = props.scale;
|
|
const btnSize = (props.widget.subButtonSize || 40) * s;
|
|
const gap = 10 * s;
|
|
const padding = 12 * s;
|
|
const w = props.widget.w * s;
|
|
|
|
return {
|
|
width: btnSize + 'px',
|
|
height: btnSize + 'px',
|
|
right: padding + 'px',
|
|
top: (padding + idx * (btnSize + gap)) + 'px',
|
|
backgroundColor: sb.colorOff || '#666666',
|
|
};
|
|
};
|
|
|
|
const roomCardBubbleStyle = computed(() => {
|
|
const s = props.scale;
|
|
const w = props.widget.w;
|
|
const h = props.widget.h;
|
|
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
|
const padding = subBtnSize * 0.6;
|
|
const bubbleSize = Math.min(w, h) * s - padding * 2;
|
|
const left = (w * s - bubbleSize) / 2;
|
|
const top = (h * s - bubbleSize) / 2;
|
|
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
|
|
|
|
return {
|
|
width: `${bubbleSize}px`,
|
|
height: `${bubbleSize}px`,
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
backgroundColor: hexToRgba(props.widget.bgColor, alpha),
|
|
color: props.widget.textColor,
|
|
boxShadow: props.widget.shadow?.enabled
|
|
? `${(props.widget.shadow.x || 0) * s}px ${(props.widget.shadow.y || 0) * s}px ${(props.widget.shadow.blur || 0) * s}px ${hexToRgba(props.widget.shadow.color || '#000000', 0.3)}`
|
|
: '0 4px 12px rgba(0,0,0,0.15)'
|
|
};
|
|
});
|
|
|
|
const roomCardIconStyle = computed(() => {
|
|
const s = props.scale;
|
|
const sizeIdx = props.widget.iconSize ?? 3;
|
|
const size = fontSizes[sizeIdx] || 28;
|
|
return {
|
|
fontSize: `${size * s}px`,
|
|
color: props.widget.textColor
|
|
};
|
|
});
|
|
|
|
const roomCardNameStyle = computed(() => {
|
|
const s = props.scale;
|
|
const sizeIdx = props.widget.fontSize ?? 2;
|
|
const size = fontSizes[sizeIdx] || 22;
|
|
return {
|
|
fontSize: `${size * s * 0.7}px`,
|
|
color: props.widget.textColor
|
|
};
|
|
});
|
|
|
|
const roomCardTempStyle = computed(() => {
|
|
const s = props.scale;
|
|
const sizeIdx = props.widget.fontSize ?? 2;
|
|
const size = fontSizes[sizeIdx] || 22;
|
|
return {
|
|
fontSize: `${size * s * 0.55}px`,
|
|
color: props.widget.textColor
|
|
};
|
|
});
|
|
|
|
function getSubButtonStyle(sb, idx) {
|
|
const s = props.scale;
|
|
const w = props.widget.w;
|
|
const h = props.widget.h;
|
|
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
|
const centerX = w * s / 2;
|
|
const centerY = h * s / 2;
|
|
|
|
// Distance from center in pixels (default 80)
|
|
const orbitRadius = (props.widget.subButtonDistance || 80) * s;
|
|
|
|
// Position based on sb.pos (0=Top, 1=TopRight, 2=Right, etc.)
|
|
const pos = sb.pos ?? idx;
|
|
const angle = (pos * (Math.PI / 4)) - (Math.PI / 2); // Start from top, go clockwise
|
|
const x = centerX + orbitRadius * Math.cos(angle) - subBtnSize / 2;
|
|
const y = centerY + orbitRadius * Math.sin(angle) - subBtnSize / 2;
|
|
|
|
// Use colorOff for preview (no KNX state in editor)
|
|
const bgColor = sb.colorOff || '#666666';
|
|
|
|
return {
|
|
width: `${subBtnSize}px`,
|
|
height: `${subBtnSize}px`,
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
backgroundColor: bgColor,
|
|
border: `2px solid ${hexToRgba('#ffffff', 0.3)}`
|
|
};
|
|
}
|
|
|
|
function getSubButtonIconStyle(sb) {
|
|
const s = props.scale;
|
|
const subBtnSize = (props.widget.subButtonSize || 40) * s;
|
|
const iconSize = subBtnSize * 0.5;
|
|
return {
|
|
fontSize: `${iconSize}px`,
|
|
color: '#ffffff'
|
|
};
|
|
}
|
|
|
|
const computedStyle = computed(() => {
|
|
const w = props.widget;
|
|
const s = props.scale;
|
|
|
|
const style = {
|
|
left: `${w.x * s}px`,
|
|
top: `${w.y * s}px`,
|
|
width: `${w.w * s}px`,
|
|
height: `${w.h * s}px`,
|
|
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
|
|
color: w.textColor,
|
|
position: 'absolute',
|
|
zIndex: 1,
|
|
cursor: 'move',
|
|
userSelect: 'none',
|
|
touchAction: 'none'
|
|
};
|
|
|
|
if (isIcon.value) {
|
|
// Icon widget
|
|
style.display = 'flex';
|
|
style.alignItems = 'center';
|
|
style.justifyContent = 'center';
|
|
if (w.bgOpacity > 0) {
|
|
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
|
style.background = hexToRgba(w.bgColor, alpha);
|
|
}
|
|
if (w.radius > 0) {
|
|
style.borderRadius = `${w.radius * s}px`;
|
|
}
|
|
} else if (isLabel.value) {
|
|
if (w.bgOpacity > 0) {
|
|
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
|
style.background = hexToRgba(w.bgColor, alpha);
|
|
}
|
|
style.display = 'flex';
|
|
style.alignItems = 'center';
|
|
if (!hasIcon.value) {
|
|
style.justifyContent = justifyForAlign(textAlign.value);
|
|
style.textAlign = textAlignCss(textAlign.value);
|
|
}
|
|
} else if (isButton.value) {
|
|
style.background = w.bgColor;
|
|
style.borderRadius = `${w.radius * s}px`;
|
|
style.display = 'flex';
|
|
style.alignItems = 'center';
|
|
style.justifyContent = contentJustify.value;
|
|
style.fontWeight = '600';
|
|
|
|
if (w.shadow && w.shadow.enabled) {
|
|
const sx = (w.shadow.x || 0) * s;
|
|
const sy = (w.shadow.y || 0) * s;
|
|
const blur = (w.shadow.blur || 0) * s;
|
|
const spread = (w.shadow.spread || 0) * s;
|
|
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
|
|
}
|
|
} else if (isLed.value) {
|
|
style.borderRadius = '999px';
|
|
|
|
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
|
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
|
|
const highlight = clamp(brightness + 0.25, 0, 1);
|
|
const core = clamp(brightness, 0, 1);
|
|
const edge = clamp(brightness * 0.5, 0, 1);
|
|
|
|
style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
|
|
|
|
if (w.shadow && w.shadow.enabled) {
|
|
const sx = (w.shadow.x || 0) * s;
|
|
const sy = (w.shadow.y || 0) * s;
|
|
const blur = (w.shadow.blur || 0) * s;
|
|
const spread = (w.shadow.spread || 0) * s;
|
|
const glowAlpha = clamp(0.4 + brightness * 0.6, 0, 1);
|
|
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
|
|
} else {
|
|
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
|
|
}
|
|
} else if (isPowerFlow.value) {
|
|
style.borderRadius = `${w.radius * s}px`;
|
|
style.overflow = 'hidden';
|
|
style.border = `1px solid ${hexToRgba('#94a3b8', 0.35)}`;
|
|
if (w.shadow && w.shadow.enabled) {
|
|
const sx = (w.shadow.x || 0) * s;
|
|
const sy = (w.shadow.y || 0) * s;
|
|
const blur = (w.shadow.blur || 0) * s;
|
|
const spread = (w.shadow.spread || 0) * s;
|
|
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
|
|
}
|
|
} else if (isPowerNode.value) {
|
|
const ringAlpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
|
const ring = Math.max(3, Math.round(Math.min(w.w, w.h) * 0.06 * s));
|
|
|
|
style.display = 'flex';
|
|
style.alignItems = 'center';
|
|
style.justifyContent = 'center';
|
|
style.borderRadius = '999px';
|
|
style.background = hexToRgba('#ffffff', 0.96);
|
|
style.border = `${ring}px solid ${hexToRgba(w.bgColor, ringAlpha)}`;
|
|
style.textAlign = 'center';
|
|
|
|
if (w.shadow && w.shadow.enabled) {
|
|
const sx = (w.shadow.x || 0) * s;
|
|
const sy = (w.shadow.y || 0) * s;
|
|
const blur = (w.shadow.blur || 0) * s;
|
|
const spread = (w.shadow.spread || 0) * s;
|
|
const glowAlpha = clamp(0.35 + ringAlpha * 0.5, 0, 1);
|
|
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(w.shadow.color || w.bgColor, glowAlpha)}`;
|
|
} else {
|
|
style.boxShadow = '0 8px 18px rgba(15, 23, 42, 0.12)';
|
|
}
|
|
if (store.powerLinkMode.active && store.powerLinkMode.powerflowId === w.parentId) {
|
|
style.cursor = 'crosshair';
|
|
}
|
|
if (store.powerLinkMode.active && store.powerLinkMode.fromNodeId === w.id && store.powerLinkMode.powerflowId === w.parentId) {
|
|
style.outline = `2px dashed ${hexToRgba('#4f8ad9', 0.9)}`;
|
|
style.outlineOffset = '2px';
|
|
}
|
|
} else if (isClock.value) {
|
|
if (w.bgOpacity > 0) {
|
|
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
|
style.background = hexToRgba(w.bgColor, alpha);
|
|
}
|
|
if (w.radius > 0) {
|
|
style.borderRadius = `${w.radius * s}px`;
|
|
} else {
|
|
style.borderRadius = '50%';
|
|
}
|
|
} else if (isTabView.value) {
|
|
style.background = w.bgColor;
|
|
style.borderRadius = `${w.radius * s}px`;
|
|
// No flex here, we handle internal layout manually
|
|
} else if (isTabPage.value) {
|
|
style.position = 'relative'; // Relative to content area
|
|
style.width = '100%';
|
|
style.height = '100%';
|
|
style.left = '0';
|
|
style.top = '0';
|
|
} else if (isRoomCard.value) {
|
|
if (w.cardStyle === 1) {
|
|
// Tile style - rectangular with rounded corners
|
|
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
|
|
style.background = hexToRgba(w.bgColor || '#333333', alpha);
|
|
style.borderRadius = `${(w.radius || 16) * s}px`;
|
|
style.overflow = 'hidden';
|
|
if (w.shadow && w.shadow.enabled) {
|
|
style.border = `3px solid ${w.shadow.color || '#ff6b6b'}`;
|
|
}
|
|
} else {
|
|
// Bubble style - container transparent, children handle rendering
|
|
style.overflow = 'visible';
|
|
}
|
|
}
|
|
|
|
return style;
|
|
});
|
|
|
|
const tabViewStyle = computed(() => {
|
|
return {
|
|
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
|
|
};
|
|
});
|
|
|
|
const tabBtnsStyle = computed(() => {
|
|
const s = props.scale;
|
|
const h = tabHeight.value * s;
|
|
const isVert = tabPosition.value === 0 || tabPosition.value === 1;
|
|
|
|
return {
|
|
[isVert ? 'height' : 'width']: `${h}px`,
|
|
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
|
|
flexDirection: isVert ? 'row' : 'column'
|
|
};
|
|
});
|
|
|
|
const tabBtnClass = (isActive) => {
|
|
const isVerticalTabs = tabPosition.value === 2 || tabPosition.value === 3;
|
|
const base = 'flex-1 flex items-center justify-center px-1 py-1 text-[12px] cursor-pointer select-none';
|
|
const border = isVerticalTabs ? 'border-b border-white/10' : 'border-b border-r border-white/10';
|
|
const active = isVerticalTabs
|
|
? 'bg-white/10 font-bold border-b-0 border-r-2 border-accent'
|
|
: 'bg-white/10 font-bold border-b-2 border-accent';
|
|
return `${base} ${border}${isActive ? ` ${active}` : ''}`;
|
|
};
|
|
|
|
function splitPowerNodeText(text) {
|
|
if (typeof text !== 'string') return { label: '', value: '' };
|
|
const parts = text.split('\n');
|
|
const label = parts[0] ?? '';
|
|
const value = parts.slice(1).join('\n');
|
|
return { label, value };
|
|
}
|
|
|
|
function handleWidgetClick() {
|
|
if (isPowerNode.value && store.powerLinkMode.active) {
|
|
store.handlePowerNodeLink(props.widget.id, props.widget.parentId);
|
|
return;
|
|
}
|
|
emit('select');
|
|
}
|
|
</script>
|