Add btn-switch widgets and enhance doughnut chart handling in mock_backend; update Dashboard and widget components for improved integration and visual representation.

This commit is contained in:
Muratovakisa33
2026-03-08 23:58:35 +01:00
parent 47bf577d7a
commit 55dc3ca1f0
7 changed files with 229 additions and 39 deletions

View File

@@ -99,12 +99,14 @@ def get_layout(device_slot: int) -> list:
{"ws": device_slot, "topic": f"{prefix}/input4", "page": "Управление", "widget": "input-date", "descr": "Time", "status": "08:30", "mode": "time", "timePrecision": "hm", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/date2", "page": "Управление", "widget": "input-date", "descr": "Date & time", "status": "03.01.2025 12:00", "mode": "datetime", "timePrecision": "hm", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/btn1", "page": "Управление", "widget": "btn", "descr": "Button", "status": "OK", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/btnswitch1", "page": "Управление", "widget": "btn-switch", "descr": "Btn-switch (toggle)", "status": "0", "mode": "toggle", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/btnswitch2", "page": "Управление", "widget": "btn-switch", "descr": "Btn-switch (momentary)", "status": "0", "mode": "momentary", "sent": False},
{"ws": device_slot, "topic": f"{prefix}/progressline1", "page": "Управление", "widget": "progress-line", "descr": "Progress line", "status": "65", "min": 0, "max": 100, "before": "", "after": "%"},
{"ws": device_slot, "topic": f"{prefix}/progressround1", "page": "Управление", "widget": "progress-round", "descr": "Progress round", "status": "75", "min": 0, "max": 100, "after": "%", "semicircle": "1", "stroke": 10, "color": "#6366f1"},
]
return [
{"ws": device_slot, "topic": f"{prefix}/select1", "page": "Индикаторы", "widget": "select", "descr": "Select", "status": "1", "options": ["Option A", "Option B", "Option C"], "sent": False},
{"ws": device_slot, "topic": f"{prefix}/doughnut1", "page": "Индикаторы", "widget": "doughnut", "descr": "Doughnut", "status": "60", "max": 100, "before": "", "after": "%", "stroke": 12, "color": "#10b981"},
{"ws": device_slot, "topic": f"{prefix}/doughnut1", "page": "Индикаторы", "widget": "doughnut", "descr": "Doughnut", "status": [10, 50, 40], "labels": ["Error", "Success", "Warning"]},
{"ws": device_slot, "topic": f"{prefix}/fillgauge1", "page": "Индикаторы", "widget": "fillgauge", "descr": "Fill gauge", "status": "40", "min": 0, "max": 100, "before": "", "after": "%", "color": "#3b82f6"},
]
@@ -145,6 +147,8 @@ def _ensure_params_for_layout(slot: int, layout: list) -> None:
state[wid] = w.get("status") or "01.01.2025 12:00"
else:
state[wid] = w.get("status") or "01.01.2025"
elif widget_type == "btn-switch":
state[wid] = "0"
elif "input" in widget_type:
itype = w.get("type") or "number"
if itype == "number":
@@ -166,7 +170,8 @@ def _ensure_params_for_layout(slot: int, layout: list) -> None:
elif "select" in widget_type:
state[wid] = "0"
elif "doughnut" in widget_type:
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
st = w.get("status")
state[wid] = st if isinstance(st, list) else [st] if st not in (None, "") else [10, 50, 40]
elif "fillgauge" in widget_type:
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
elif "anydata" in widget_type:
@@ -191,7 +196,7 @@ def _get_params_state(slot: int) -> dict:
"progressline1": "65",
"progressround1": "75",
"select1": "1",
"doughnut1": "60",
"doughnut1": [10, 50, 40],
"fillgauge1": "40",
}
return _params_state[slot]
@@ -369,6 +374,8 @@ def get_widgets_json() -> list:
{"name": "inputDateDate", "label": "Input date", "widget": "input-date", "mode": "date", "icon": ""},
{"name": "inputDateTime", "label": "Input time", "widget": "input-date", "mode": "time", "timePrecision": "hm", "icon": ""},
{"name": "inputDateDatetime", "label": "Input date & time", "widget": "input-date", "mode": "datetime", "timePrecision": "hm", "icon": ""},
{"name": "btnSwitchToggle", "label": "Btn-switch (toggle)", "widget": "btn-switch", "mode": "toggle", "icon": ""},
{"name": "btnSwitchMomentary", "label": "Btn-switch (momentary)", "widget": "btn-switch", "mode": "momentary", "icon": ""},
]
@@ -600,7 +607,11 @@ def _tick_sensor_values() -> None:
if "progressround1" in s:
s["progressround1"] = str(int(50 + 25 * math.sin(t * 0.25)))
if "doughnut1" in s:
s["doughnut1"] = str(int(50 + 30 * math.sin(t * 0.2)))
s["doughnut1"] = [
int(10 + 15 * math.sin(t * 0.2)),
int(50 + 20 * math.sin(t * 0.25)),
int(40 + 25 * math.sin(t * 0.3)),
]
if "fillgauge1" in s:
s["fillgauge1"] = str(int(40 + 25 * math.sin(t * 0.35)))
@@ -611,7 +622,7 @@ PARAMS_INTERVAL = 3
# Param keys for widgets that receive status blob (chart1 is updated only via chartb).
_PARAM_IDS = [
"anydata1", "toggle1", "range1", "num0", "date0", "time0",
"input1", "num2", "input2", "input3", "input4", "date2", "btn1",
"input1", "num2", "input2", "input3", "input4", "date2", "btn1", "btnswitch1", "btnswitch2",
"progressline1", "progressround1", "select1", "doughnut1", "fillgauge1",
]

View File

@@ -8,6 +8,7 @@
import Toggle from "../widgets/Toggle.svelte";
import Anydata from "../widgets/Anydata.svelte";
import Btn from "../widgets/Btn.svelte";
import BtnSwitch from "../widgets/BtnSwitch.svelte";
import ProgressLine from "../widgets/ProgressLine.svelte";
import ProgressRound from "../widgets/ProgressRound.svelte";
import Select from "../widgets/Select.svelte";
@@ -76,6 +77,9 @@
{#if widget.widget === "btn"}
<Btn widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
{/if}
{#if widget.widget === "btn-switch"}
<BtnSwitch widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
{/if}
{#if widget.widget === "progress-line"}
<ProgressLine widget={widget} />
{/if}

View File

@@ -20,7 +20,7 @@
<button
type="button"
on:click={onClick}
class="btn-i {widget.sent ? 'ring-2 ring-red-400' : ''}"
class="btn-i h-7 min-h-7 flex items-center {widget.sent ? 'ring-2 ring-red-400' : ''}"
>
{widget.status !== undefined && widget.status !== null && widget.status !== "" ? widget.status : "OK"}
</button>

View File

@@ -0,0 +1,56 @@
<script>
/**
* btn-switch widget: same protocol and feedback as Toggle (topic/control "0"|"1", widget.sent).
* mode "toggle": each click flips 1<->0 and sends. mode "momentary": press sends 1, release sends 0.
* Web: no icons, use colors (blue = 1, gray = 0) like Toggle track.
*/
export let widget;
export let wsPush = (ws, topic, status) => {};
$: mode = (widget.mode || "toggle").toString().toLowerCase();
$: isOn = widget.status === "1" || widget.status === true;
function sendVal(val) {
widget.sent = true;
widget.status = val;
wsPush(widget.ws, widget.topic, val);
}
function onToggleClick() {
const next = isOn ? "0" : "1";
sendVal(next);
}
function onMomentaryDown() {
sendVal("1");
}
function onMomentaryUp() {
sendVal("0");
}
</script>
<div class="crd-itm-psn h-auto mb-4">
<div class="w-2/3">
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
</div>
<div class="flex justify-end w-1/3">
{#if mode === "momentary"}
<button
type="button"
class="shrink-0 h-7 min-h-7 min-w-[4rem] w-[4rem] flex items-center justify-center rounded-lg border-0 outline-none cursor-pointer {isOn ? 'bg-blue-600' : 'bg-gray-100'}"
on:mousedown={onMomentaryDown}
on:mouseup={onMomentaryUp}
on:mouseleave={onMomentaryUp}
>
</button>
{:else}
<button
type="button"
class="shrink-0 h-7 min-h-7 min-w-[4rem] w-[4rem] flex items-center justify-center rounded-lg border-0 outline-none cursor-pointer {isOn ? 'bg-blue-600' : 'bg-gray-100'}"
on:click={onToggleClick}
>
</button>
{/if}
</div>
</div>

View File

@@ -1,37 +1,96 @@
<script>
/**
* Doughnut chart. Read-only. status = number (0..max); optional widget.color.
* Doughnut chart: multiple sectors. status = number[], labels = string[].
* Same as app: segmentPath for each sector, legend in center.
*/
export let widget;
$: numStatus = Number(widget.status);
$: numMax = Number(widget.max) ?? 100;
$: safeMax = numMax <= 0 ? 100 : numMax;
$: value = Math.max(0, Math.min(1, numStatus / safeMax));
$: stroke = widget.stroke ?? 12;
$: r = 40;
$: c = 2 * Math.PI * r;
$: dash = value * c;
const DEFAULT_COLORS = ['#3880ff', '#10dc60', '#ffce00', '#f04141', '#0cd1e8'];
function toNum(v, def) {
if (v === undefined || v === null) return def;
const n = Number(v);
return Number.isFinite(n) ? n : def;
}
function normalizeStatus(status) {
if (Array.isArray(status)) return status.map((v) => toNum(v, 0));
if (typeof status === 'number') return [status];
if (typeof status === 'string') {
const trimmed = status.trim();
if (trimmed === '') return [];
if (trimmed.startsWith('[')) {
try {
const arr = JSON.parse(trimmed);
return Array.isArray(arr) ? arr.map((v) => toNum(v, 0)) : [toNum(status, 0)];
} catch {
return [toNum(status, 0)];
}
}
return [toNum(status, 0)];
}
return [];
}
function segmentPath(cx, cy, startAngle, endAngle, outerR, innerR) {
const toRad = (a) => (a * Math.PI) / 180;
const x1 = cx + outerR * Math.cos(toRad(startAngle));
const y1 = cy + outerR * Math.sin(toRad(startAngle));
const x2 = cx + outerR * Math.cos(toRad(endAngle));
const y2 = cy + outerR * Math.sin(toRad(endAngle));
const large = endAngle - startAngle > 180 ? 1 : 0;
if (innerR == null || innerR <= 0) {
return `M ${cx} ${cy} L ${x1} ${y1} A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2} Z`;
}
const x3 = cx + innerR * Math.cos(toRad(endAngle));
const y3 = cy + innerR * Math.sin(toRad(endAngle));
const x4 = cx + innerR * Math.cos(toRad(startAngle));
const y4 = cy + innerR * Math.sin(toRad(startAngle));
return `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${large} 0 ${x4} ${y4} Z`;
}
$: rawValues = normalizeStatus(widget.status);
$: values = rawValues.length > 0 ? rawValues : [33, 33, 34];
$: total = values.reduce((a, b) => a + b, 0) || 1;
$: labels = Array.isArray(widget.labels) ? widget.labels : [];
const SIZE = 200;
const STROKE_PX = 28;
const r = (SIZE - STROKE_PX) / 2;
const cx = SIZE / 2;
const cy = SIZE / 2;
const innerR = r - STROKE_PX;
$: paths = (() => {
let angle = -90;
return values.map((v, i) => {
const sweep = (v / total) * 360;
const start = angle;
angle += sweep;
return {
path: segmentPath(cx, cy, start, start + sweep, r, innerR),
color: DEFAULT_COLORS[i % DEFAULT_COLORS.length],
};
});
})();
</script>
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
<div class="relative inline-flex items-center justify-center" style="width: 90px; height: 90px;">
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r={r} fill="none" stroke="#e5e7eb" stroke-width={stroke} />
<circle
cx="50"
cy="50"
r={r}
fill="none"
stroke={widget.color || "#6366f1"}
stroke-width={stroke}
stroke-dasharray={c}
stroke-dashoffset={c - dash}
stroke-linecap="round"
style="transition: stroke-dashoffset 0.3s ease;"
/>
<div class="relative" style="width: {SIZE}px; height: {SIZE}px;">
<svg width={SIZE} height={SIZE} viewBox="0 0 {SIZE} {SIZE}" class="absolute inset-0">
{#each paths as seg, i}
<path d={seg.path} fill={seg.color} stroke="transparent" stroke-width="1" />
{/each}
</svg>
<span class="absolute text-gray-600 font-bold text-sm">{widget.before || ""}{numStatus}{widget.after || ""}</span>
{#if labels.length > 0}
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="flex flex-col items-center">
{#each labels.slice(0, values.length) as label, i}
<span class="text-xs font-bold" style="color: {paths[i]?.color ?? '#374151'}">{label}: {values[i]}</span>
{/each}
</div>
</div>
{/if}
</div>
</div>

View File

@@ -1,24 +1,84 @@
<script>
/**
* Fill gauge (liquid/level style). Read-only. status within min..max.
* Round circle with wave animation on fill top edge (same as app).
*/
import { onMount, onDestroy } from 'svelte';
export let widget;
const SIZE = 120;
const STROKE = 4;
const WAVE_AMPLITUDE = 4;
const WAVE_FREQUENCY = 0.08;
const WAVE_POINTS = 40;
const WAVE_ANIMATION_MS = 80;
let phase = 0;
let animationId = 0;
const clipId = 'fillgauge-clip-' + (widget.topic || widget.ws || '').toString().replace(/[^a-z0-9]/gi, '') + '-' + Math.random().toString(36).slice(2);
function buildWavePath(fillRatio, phaseVal, cx, cy, innerR, amplitude) {
const bottomY = cy + innerR;
const topY = cy + innerR - 2 * innerR * fillRatio;
const leftX = cx - innerR;
const rightX = cx + innerR;
const waveY = (x) => topY + amplitude * Math.sin((x - cx) * WAVE_FREQUENCY + phaseVal);
let d = `M ${leftX} ${bottomY} L ${leftX} ${waveY(leftX)}`;
for (let i = 1; i <= WAVE_POINTS; i++) {
const x = leftX + (rightX - leftX) * (i / WAVE_POINTS);
d += ` L ${x} ${waveY(x)}`;
}
d += ` L ${rightX} ${bottomY} Z`;
return d;
}
$: numStatus = Number(widget.status);
$: numMin = Number(widget.min) ?? 0;
$: numMax = Number(widget.max) ?? 100;
$: safeMax = numMax <= numMin ? numMin + 1 : numMax;
$: value = Math.max(0, Math.min(1, (numStatus - numMin) / (safeMax - numMin)));
$: pct = Math.round(value * 100);
$: fillColor = widget.color || '#3b82f6';
const cx = SIZE / 2;
const cy = SIZE / 2;
const r = (SIZE - STROKE) / 2;
const innerR = r - STROKE / 2;
const amplitude = Math.max(1, WAVE_AMPLITUDE);
$: wavePath = buildWavePath(value, phase, cx, cy, innerR, amplitude);
onMount(() => {
animationId = setInterval(() => {
phase = (phase + 0.15) % (Math.PI * 2);
}, WAVE_ANIMATION_MS);
});
onDestroy(() => {
if (animationId) clearInterval(animationId);
});
</script>
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
<div class="w-full max-w-[120px] h-24 rounded-lg border-2 border-gray-200 overflow-hidden flex flex-col justify-end">
<div
class="w-full transition-all duration-300 rounded-t"
style="height: {pct}%; background: {widget.color || '#6366f1'};"
></div>
<p class="w-full text-center truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold mb-2">{!widget.descr ? "" : widget.descr}</p>
<!-- Round gauge with wave (same as app: SVG circle + clipped wave path) -->
<div class="relative" style="width: {SIZE}px; height: {SIZE}px;">
<svg width={SIZE} height={SIZE} viewBox="0 0 {SIZE} {SIZE}" class="absolute inset-0">
<defs>
<clipPath id={clipId}>
<circle cx={cx} cy={cy} r={innerR} />
</clipPath>
</defs>
<!-- Circle border and empty fill -->
<circle cx={cx} cy={cy} r={r} stroke="#e5e7eb" stroke-width={STROKE} fill="#f3f4f6" />
<!-- Wave fill clipped to circle -->
<g clip-path={"url(#" + clipId + ")"}>
<path d={wavePath} fill={fillColor} />
</g>
</svg>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<span class="text-lg font-bold text-gray-700">{widget.before || ""}{numStatus}{widget.after || ""}</span>
</div>
</div>
<p class="text-sm text-gray-500 mt-1">{widget.before || ""}{numStatus}{widget.after || ""}</p>
</div>

View File

@@ -18,7 +18,7 @@
</script>
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
<p class="pr-4 truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold">{!widget.descr ? "" : widget.descr}</p>
<p class="w-full text-center truncate text-{widget.descrColor ? widget.descrColor : 'gray'}-500 font-bold mb-2">{!widget.descr ? "" : widget.descr}</p>
<div class="relative inline-flex items-center justify-center" style="width: 100px; height: 60px;">
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100" style="overflow: visible;">
<circle