Фикс вращения вентилятора + разделение сигналов на аналоговые/дискретные

transform-origin: center у SVG-группы лопастей считался от bounding box,
а не от геометрического центра ступицы (0,0) — вентилятор вращался вокруг
смещённой точки. Зафиксировано в 0 0.

Датчик больше не подписан как «DHT22» (сбивало с толку при добавлении
дискретных сигналов вроде door_open) — просто «Датчик», а сами сигналы
в карточке визуально разбиты на группы «Аналоговые»/«Дискретные».
This commit is contained in:
2026-08-13 12:42:27 +05:00
parent 073577e8d9
commit da0f941d79
+44 -15
View File
@@ -47,7 +47,12 @@
.peripheral-label { font-size: 12px; fill: var(--text); font-weight: 600; text-anchor: middle; } .peripheral-label { font-size: 12px; fill: var(--text); font-weight: 600; text-anchor: middle; }
.peripheral-sub { font-size: 9px; fill: var(--muted); text-anchor: middle; } .peripheral-sub { font-size: 9px; fill: var(--muted); text-anchor: middle; }
.fan-blades { transform-origin: center; transition: opacity .2s; } /* The blade ellipses are laid out so their geometric centroid is the
group's local (0,0) — but CSS "transform-origin: center" for SVG uses
the bounding box center, which isn't the same point (the ellipses'
144x28-ish extents aren't symmetric around 0,0), so it wobbled around
an off-center point instead of spinning cleanly. Pin it to 0 0. */
.fan-blades { transform-origin: 0px 0px; transition: opacity .2s; }
.fan-blades.on { animation: spin 0.9s linear infinite; } .fan-blades.on { animation: spin 0.9s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@@ -107,6 +112,12 @@
.value { color: var(--text); font-weight: 600; } .value { color: var(--text); font-weight: 600; }
.hint { color: var(--muted); font-size: 0.78rem; margin-top: 10px; } .hint { color: var(--muted); font-size: 0.78rem; margin-top: 10px; }
.signal-group-label {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted); margin-top: 12px; margin-bottom: 2px;
}
.signal-group-label:first-child { margin-top: 0; }
.signal-row { .signal-row {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.85rem; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.85rem;
@@ -184,7 +195,7 @@
<text id="board-sensor-primary" x="0" y="-2" text-anchor="middle" font-size="14" fill="#e6edf5" font-weight="700">24°C</text> <text id="board-sensor-primary" x="0" y="-2" text-anchor="middle" font-size="14" fill="#e6edf5" font-weight="700">24°C</text>
<text id="board-sensor-secondary" x="0" y="14" text-anchor="middle" font-size="10" fill="#8ea0b8">55%</text> <text id="board-sensor-secondary" x="0" y="14" text-anchor="middle" font-size="10" fill="#8ea0b8">55%</text>
</g> </g>
<text class="peripheral-label" x="70" y="118">Датчик DHT22</text> <text class="peripheral-label" x="70" y="118">Датчик</text>
<text class="peripheral-sub" x="70" y="130">GPIO4 · input</text> <text class="peripheral-sub" x="70" y="130">GPIO4 · input</text>
<!-- fan (top right) --> <!-- fan (top right) -->
@@ -289,17 +300,12 @@ function cardHTML(slot) {
document.getElementById('cards').innerHTML = Object.keys(SLOTS).map(cardHTML).join(''); document.getElementById('cards').innerHTML = Object.keys(SLOTS).map(cardHTML).join('');
function signalRowHTML(slot, name, value) { function isDiscreteSignal(slot, name) {
return knownSignals[slot]?.discrete?.has(name);
}
function analogRowHTML(slot, name, value) {
const preset = SIGNAL_PRESETS[name]; const preset = SIGNAL_PRESETS[name];
const isDiscrete = !preset && (value === 0 || value === 1) && knownSignals[slot]?.discrete?.has(name);
if (isDiscrete) {
return `<div class="signal-row" data-signal="${name}">
<span class="name">${name}</span>
<div class="toggle ${value ? 'on' : ''}" onclick="toggleDiscrete('${slot}','${name}')"><div class="knob"></div></div>
</div>`;
}
const min = preset?.min ?? 0, max = preset?.max ?? 100, step = preset?.step ?? 1, unit = preset?.unit ?? ''; const min = preset?.min ?? 0, max = preset?.max ?? 100, step = preset?.step ?? 1, unit = preset?.unit ?? '';
return `<div class="signal-row" data-signal="${name}"> return `<div class="signal-row" data-signal="${name}">
<span class="name">${name}${unit ? ' ('+unit+')' : ''}</span> <span class="name">${name}${unit ? ' ('+unit+')' : ''}</span>
@@ -309,14 +315,37 @@ function signalRowHTML(slot, name, value) {
</div>`; </div>`;
} }
function discreteRowHTML(slot, name, value) {
return `<div class="signal-row" data-signal="${name}">
<span class="name">${name}</span>
<div class="toggle ${value ? 'on' : ''}" onclick="toggleDiscrete('${slot}','${name}')"><div class="knob"></div></div>
</div>`;
}
function cssId(name) { return name.replace(/[^a-zA-Z0-9_-]/g, '_'); } function cssId(name) { return name.replace(/[^a-zA-Z0-9_-]/g, '_'); }
// Discrete vs analog are shown as two visually separate groups — a
// discrete "door_open" signal is still just another sensor_type on the
// same physical device underneath (one MQTT device can report several
// sensor_types, same as real telemetry), but grouping keeps it from
// reading as "attached to the temperature sensor".
function renderSignals(slot, readings) { function renderSignals(slot, readings) {
const container = document.getElementById(`${slot}-signals`); const container = document.getElementById(`${slot}-signals`);
const names = Object.keys(readings);
// Preserve discrete-ness across re-renders (server only stores numbers).
knownSignals[slot] = knownSignals[slot] || { discrete: new Set() }; knownSignals[slot] = knownSignals[slot] || { discrete: new Set() };
container.innerHTML = names.map((name) => signalRowHTML(slot, name, readings[name])).join('');
const analogNames = orderedNames(readings).filter((n) => !isDiscreteSignal(slot, n));
const discreteNames = Object.keys(readings).filter((n) => isDiscreteSignal(slot, n)).sort();
let html = '';
if (analogNames.length) {
html += `<div class="signal-group-label">Аналоговые</div>`;
html += analogNames.map((name) => analogRowHTML(slot, name, readings[name])).join('');
}
if (discreteNames.length) {
html += `<div class="signal-group-label">Дискретные</div>`;
html += discreteNames.map((name) => discreteRowHTML(slot, name, readings[name])).join('');
}
container.innerHTML = html;
updateBoardText(slot, readings); updateBoardText(slot, readings);
} }