mirror of
https://github.com/IoTManagerProject/IoTManagerWeb.git
synced 2026-03-26 15:02:21 +03:00
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:
@@ -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}/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}/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}/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}/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"},
|
{"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 [
|
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}/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"},
|
{"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"
|
state[wid] = w.get("status") or "01.01.2025 12:00"
|
||||||
else:
|
else:
|
||||||
state[wid] = w.get("status") or "01.01.2025"
|
state[wid] = w.get("status") or "01.01.2025"
|
||||||
|
elif widget_type == "btn-switch":
|
||||||
|
state[wid] = "0"
|
||||||
elif "input" in widget_type:
|
elif "input" in widget_type:
|
||||||
itype = w.get("type") or "number"
|
itype = w.get("type") or "number"
|
||||||
if itype == "number":
|
if itype == "number":
|
||||||
@@ -166,7 +170,8 @@ def _ensure_params_for_layout(slot: int, layout: list) -> None:
|
|||||||
elif "select" in widget_type:
|
elif "select" in widget_type:
|
||||||
state[wid] = "0"
|
state[wid] = "0"
|
||||||
elif "doughnut" in widget_type:
|
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:
|
elif "fillgauge" in widget_type:
|
||||||
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
|
state[wid] = str(w.get("status", 50) if w.get("status") not in (None, "") else 50)
|
||||||
elif "anydata" in widget_type:
|
elif "anydata" in widget_type:
|
||||||
@@ -191,7 +196,7 @@ def _get_params_state(slot: int) -> dict:
|
|||||||
"progressline1": "65",
|
"progressline1": "65",
|
||||||
"progressround1": "75",
|
"progressround1": "75",
|
||||||
"select1": "1",
|
"select1": "1",
|
||||||
"doughnut1": "60",
|
"doughnut1": [10, 50, 40],
|
||||||
"fillgauge1": "40",
|
"fillgauge1": "40",
|
||||||
}
|
}
|
||||||
return _params_state[slot]
|
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": "inputDateDate", "label": "Input date", "widget": "input-date", "mode": "date", "icon": ""},
|
||||||
{"name": "inputDateTime", "label": "Input time", "widget": "input-date", "mode": "time", "timePrecision": "hm", "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": "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:
|
if "progressround1" in s:
|
||||||
s["progressround1"] = str(int(50 + 25 * math.sin(t * 0.25)))
|
s["progressround1"] = str(int(50 + 25 * math.sin(t * 0.25)))
|
||||||
if "doughnut1" in s:
|
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:
|
if "fillgauge1" in s:
|
||||||
s["fillgauge1"] = str(int(40 + 25 * math.sin(t * 0.35)))
|
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 keys for widgets that receive status blob (chart1 is updated only via chartb).
|
||||||
_PARAM_IDS = [
|
_PARAM_IDS = [
|
||||||
"anydata1", "toggle1", "range1", "num0", "date0", "time0",
|
"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",
|
"progressline1", "progressround1", "select1", "doughnut1", "fillgauge1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Toggle from "../widgets/Toggle.svelte";
|
import Toggle from "../widgets/Toggle.svelte";
|
||||||
import Anydata from "../widgets/Anydata.svelte";
|
import Anydata from "../widgets/Anydata.svelte";
|
||||||
import Btn from "../widgets/Btn.svelte";
|
import Btn from "../widgets/Btn.svelte";
|
||||||
|
import BtnSwitch from "../widgets/BtnSwitch.svelte";
|
||||||
import ProgressLine from "../widgets/ProgressLine.svelte";
|
import ProgressLine from "../widgets/ProgressLine.svelte";
|
||||||
import ProgressRound from "../widgets/ProgressRound.svelte";
|
import ProgressRound from "../widgets/ProgressRound.svelte";
|
||||||
import Select from "../widgets/Select.svelte";
|
import Select from "../widgets/Select.svelte";
|
||||||
@@ -76,6 +77,9 @@
|
|||||||
{#if widget.widget === "btn"}
|
{#if widget.widget === "btn"}
|
||||||
<Btn widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
<Btn widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if widget.widget === "btn-switch"}
|
||||||
|
<BtnSwitch widget={widget} wsPush={(ws, topic, status) => wsPush(ws, topic, status)} />
|
||||||
|
{/if}
|
||||||
{#if widget.widget === "progress-line"}
|
{#if widget.widget === "progress-line"}
|
||||||
<ProgressLine widget={widget} />
|
<ProgressLine widget={widget} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={onClick}
|
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"}
|
{widget.status !== undefined && widget.status !== null && widget.status !== "" ? widget.status : "OK"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
56
src/widgets/BtnSwitch.svelte
Normal file
56
src/widgets/BtnSwitch.svelte
Normal 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>
|
||||||
@@ -1,37 +1,96 @@
|
|||||||
<script>
|
<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;
|
export let widget;
|
||||||
|
|
||||||
$: numStatus = Number(widget.status);
|
const DEFAULT_COLORS = ['#3880ff', '#10dc60', '#ffce00', '#f04141', '#0cd1e8'];
|
||||||
$: numMax = Number(widget.max) ?? 100;
|
|
||||||
$: safeMax = numMax <= 0 ? 100 : numMax;
|
function toNum(v, def) {
|
||||||
$: value = Math.max(0, Math.min(1, numStatus / safeMax));
|
if (v === undefined || v === null) return def;
|
||||||
$: stroke = widget.stroke ?? 12;
|
const n = Number(v);
|
||||||
$: r = 40;
|
return Number.isFinite(n) ? n : def;
|
||||||
$: c = 2 * Math.PI * r;
|
}
|
||||||
$: dash = value * c;
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
|
<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="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;">
|
<div class="relative" style="width: {SIZE}px; height: {SIZE}px;">
|
||||||
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100">
|
<svg width={SIZE} height={SIZE} viewBox="0 0 {SIZE} {SIZE}" class="absolute inset-0">
|
||||||
<circle cx="50" cy="50" r={r} fill="none" stroke="#e5e7eb" stroke-width={stroke} />
|
{#each paths as seg, i}
|
||||||
<circle
|
<path d={seg.path} fill={seg.color} stroke="transparent" stroke-width="1" />
|
||||||
cx="50"
|
{/each}
|
||||||
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;"
|
|
||||||
/>
|
|
||||||
</svg>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,84 @@
|
|||||||
<script>
|
<script>
|
||||||
/**
|
/**
|
||||||
* Fill gauge (liquid/level style). Read-only. status within min..max.
|
* 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;
|
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);
|
$: numStatus = Number(widget.status);
|
||||||
$: numMin = Number(widget.min) ?? 0;
|
$: numMin = Number(widget.min) ?? 0;
|
||||||
$: numMax = Number(widget.max) ?? 100;
|
$: numMax = Number(widget.max) ?? 100;
|
||||||
$: safeMax = numMax <= numMin ? numMin + 1 : numMax;
|
$: safeMax = numMax <= numMin ? numMin + 1 : numMax;
|
||||||
$: value = Math.max(0, Math.min(1, (numStatus - numMin) / (safeMax - numMin)));
|
$: value = Math.max(0, Math.min(1, (numStatus - numMin) / (safeMax - numMin)));
|
||||||
$: pct = Math.round(value * 100);
|
$: 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>
|
</script>
|
||||||
|
|
||||||
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
|
<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="w-full max-w-[120px] h-24 rounded-lg border-2 border-gray-200 overflow-hidden flex flex-col justify-end">
|
<!-- Round gauge with wave (same as app: SVG circle + clipped wave path) -->
|
||||||
<div
|
<div class="relative" style="width: {SIZE}px; height: {SIZE}px;">
|
||||||
class="w-full transition-all duration-300 rounded-t"
|
<svg width={SIZE} height={SIZE} viewBox="0 0 {SIZE} {SIZE}" class="absolute inset-0">
|
||||||
style="height: {pct}%; background: {widget.color || '#6366f1'};"
|
<defs>
|
||||||
></div>
|
<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>
|
</div>
|
||||||
<p class="text-sm text-gray-500 mt-1">{widget.before || ""}{numStatus}{widget.after || ""}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="crd-itm-psn flex flex-col items-center h-auto mb-4">
|
<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;">
|
<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;">
|
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100" style="overflow: visible;">
|
||||||
<circle
|
<circle
|
||||||
|
|||||||
Reference in New Issue
Block a user