Dashboard Studio 1.11.0: energy card, climate popup, car and appliance redesigns, Home Connect

- Energy card: live power flow between solar, grid, battery and home,
  with line width and speed following the power, battery level and
  today's totals; sign of grid and battery sensors can be flipped
- Climate popup (room card thermostat and any climate entity): dial,
  -/+ that batch quick taps, modes, presets and fan modes, live updates
- Car card redesigned around 3x4 with a drawn car or photo, compact,
  wide and tall layouts
- Appliance card redesigned with a progress ring; Home Connect support:
  all operation states, auto-fill from the operation state sensor via
  the device registry, readable program names, door state, pause/
  resume/stop buttons, power switch and alerts. Fixes ready/inactive
  being shown as finished/running
- Horizontal scroll card: width in pixels or cards per view, and a
  width per card
- Swipe card: no lost tap after a touch swipe, no broken swipes over
  images, lost pointer capture ends the swipe, trackpad swiping

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 14:06:35 +01:00
parent 78aa85a82f
commit f9822a9c49
9 changed files with 551 additions and 90 deletions
+248 -56
View File
@@ -477,6 +477,73 @@
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
};
// ---------------------------------------------------------------- energy
// Live power flow between solar, grid, battery and home. Lines are as thick and as fast as the power.
function watts(s) {
const v = Number(s?.state);
if (!isFinite(v)) return null;
const u = String(s.attributes.unit_of_measurement || "W").toLowerCase();
return u === "kw" ? v * 1000 : u === "mw" ? v * 1e6 : v;
}
const fmtW = (w) => (Math.abs(w) >= 1000 ? `${(w / 1000).toLocaleString(LOCALE, { maximumFractionDigits: 1 })} kW` : `${Math.round(w).toLocaleString(LOCALE)} W`);
W.energy = {
label: "Energy", icon: "bolt", size: [4, 4],
fields: [
{ k: "solar", t: "entity", l: "Solar power", domains: ["sensor"] },
{ k: "grid", t: "entity", l: "Grid power (positive = from the grid)", domains: ["sensor"] },
{ k: "invertGrid", t: "check", l: "My grid sensor is positive when sending back", d: false },
{ k: "battery", t: "entity", l: "Battery power (positive = charging)", domains: ["sensor"] },
{ k: "invertBattery", t: "check", l: "My battery sensor is positive when discharging", d: false },
{ k: "batteryLevel", t: "entity", l: "Battery level (%)", domains: ["sensor"] },
{ k: "home", t: "entity", l: "Home use (optional, otherwise calculated)", domains: ["sensor"] },
{ k: "today", t: "entities", l: "Totals for today, e.g. kWh produced and used", domains: ["sensor"] },
],
entities: (c) => [c.solar, c.grid, c.battery, c.batteryLevel, c.home, ...(c.today || [])].filter(Boolean),
render(c, { ha }) {
if (!c.solar && !c.grid && !c.battery) return `<div class="empty">${t("Choose your solar, grid or battery power sensors in the inspector.")}</div>`;
const S = (k) => (c[k] ? ha.states[c[k]] : null);
const solar = Math.max(0, watts(S("solar")) ?? 0);
let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid;
let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt;
const imp = Math.max(0, grid), exp = Math.max(0, -grid), charge = Math.max(0, batt), discharge = Math.max(0, -batt);
const home = Math.max(0, watts(S("home")) ?? solar + imp + discharge - exp - charge);
// Who feeds what: solar goes to the home first, then the battery, the rest to the grid.
const sToH = Math.min(solar, home), sRest = solar - sToH, sToB = Math.min(sRest, charge), sToG = Math.max(0, sRest - sToB);
const gToH = Math.min(imp, Math.max(0, home - sToH - discharge)), gToB = Math.max(0, charge - sToB), bToH = Math.min(discharge, Math.max(0, home - sToH));
const has = { solar: !!c.solar, grid: !!c.grid, battery: !!c.battery };
const lvl = Number(S("batteryLevel")?.state);
const flow = (on, d, w, cls) => {
if (!on) return "";
const speed = Math.max(0.5, Math.min(5, 2400 / (w + 150))); // seconds per cycle: more power, faster dots
const width = w > 1 ? Math.max(1.5, Math.min(5, 1.2 + Math.log10(w + 1))) : 1;
return `<path d="${d}" class="ef-line ${cls}"/>${w > 1 ? `<path d="${d}" class="ef-flow ${cls}" style="stroke-width:${width.toFixed(1)};animation-duration:${speed.toFixed(2)}s"/>` : ""}`;
};
const lines = [
flow(has.solar, "M50 14 Q86 14 86 50", sToH, "solar"),
flow(has.solar && has.grid, "M50 14 Q14 14 14 50", sToG, "solar"),
flow(has.solar && has.battery, "M50 14 L50 86", sToB, "solar"),
flow(has.grid, "M14 50 L86 50", gToH, "grid"),
flow(has.grid && has.battery, "M14 50 Q14 86 50 86", gToB, "grid"),
flow(has.battery, "M50 86 Q86 86 86 50", bToH, "battery"),
].join("");
const node = (key, ico, label, value, extra = "") => `<div class="ef-node ef-${key}"><span class="ef-ico">${Icons.svg(ico)}</span>
<span class="ef-val num">${esc(value)}</span><span class="ef-label">${esc(label)}${extra}</span></div>`;
const totals = (c.today || []).map((e) => ha.states[e] && `<span class="ef-total"><b class="num">${esc(fmtState(ha.states[e]))}</b> ${esc(friendly(ha.states[e]))}</span>`).filter(Boolean).join("");
return `<div class="energy ${has.battery ? "" : "no-battery"} ${has.solar ? "" : "no-solar"}">
<div class="ef-diagram" role="img" aria-label="${esc(t("Solar {0}, grid {1}, home {2}", fmtW(solar), grid >= 0 ? fmtW(imp) : "−" + fmtW(exp), fmtW(home)))}">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>
${has.solar ? node("solar", "sun", t("Solar"), fmtW(solar)) : ""}
${has.grid ? node("grid", "plug", exp > imp ? t("To the grid") : t("From the grid"), fmtW(exp > imp ? exp : imp)) : ""}
${node("home", "home", t("Home"), fmtW(home))}
${has.battery ? node("battery", "battery", charge > 1 ? t("Charging") : discharge > 1 ? t("Discharging") : t("Battery"), isFinite(lvl) ? `${Math.round(lvl)}%` : fmtW(Math.abs(batt)), isFinite(lvl) && Math.abs(batt) > 1 ? ` · ${fmtW(Math.abs(batt))}` : "") : ""}
</div>
${totals ? `<div class="ef-totals">${totals}</div>` : ""}</div>`;
},
};
// ---------------------------------------------------------------- room
// Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls.
function roomEntities(c, ha) {
@@ -614,12 +681,19 @@
// Works with any integration: a status entity, and/or a power sensor above a threshold,
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
const RUNNING_RE = /run|wash|dry|rins|spin|heat|clean|activ|program|bezig|draait|centrifug|spoel|droog|^on$|^true$/;
const DONE_RE = /finish|end|done|complete|ready|klaar|gereed|beëindigd/;
const PAUSE_RE = /pause|gepauzeerd|delayed|uitgesteld/;
const DONE_RE = /finish|end|done|complete|klaar|gereed|beëindigd/;
const PAUSE_RE = /pause|gepauzeerd/;
// Home Connect's operation states, matched exactly first (https://www.home-assistant.io/integrations/home_connect/).
// "ready" means ready to start and "inactive" switched off: both count as off.
const HC_STATES = { run: "running", pause: "paused", finished: "done", delayedstart: "scheduled", actionrequired: "attention", error: "error", aborting: "stopping", inactive: "off", ready: "off" };
function appliancePhase(c, ha) {
const status = String(ha.states[c.status]?.state || "").toLowerCase();
if (status && !["unavailable", "unknown"].includes(status)) {
if (HC_STATES[status]) return HC_STATES[status];
if (/^(off|idle|standby|none)$/.test(status)) return "off";
if (/delayed|uitgesteld|scheduled/.test(status)) return "scheduled";
if (/error|fault|fout/.test(status)) return "error";
if (DONE_RE.test(status)) return "done";
if (PAUSE_RE.test(status)) return "paused";
if (RUNNING_RE.test(status)) return "running";
@@ -642,40 +716,122 @@
}
const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m));
// "dishcare_dishwasher_program_eco_50" → "Eco 50" (Home Connect program keys); other values are shown as they are.
const prettyProgram = (v) => {
const s = String(v);
return s.includes("_program_") ? cap(s.split("_program_").pop().replace(/_/g, " ")) : st(s);
};
const ALERT_ON = new Set(["present", "confirmed", "on", "true"]);
W.appliance = {
label: "Appliance", icon: "washer", size: [4, 2],
fields: [
{ k: "name", t: "text", l: "Name", d: "Washing machine" },
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" },
{ k: "status", t: "entity", l: "Status", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
{ k: "status", t: "entity", l: "Status (with Home Connect: Operation state — the rest fills in by itself)", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] },
{ k: "threshold", t: "number", l: "Running above (W)", d: 5 },
{ k: "remaining", t: "entity", l: "Time remaining or end time", domains: ["sensor"] },
{ k: "remaining", t: "entity", l: "Time remaining or finish time", domains: ["sensor"] },
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
{ k: "door", t: "entity", l: "Door (optional)", domains: ["binary_sensor", "sensor"] },
{ k: "energy", t: "entity", l: "Energy or water use (optional)", domains: ["sensor"] },
{ k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"] },
{ k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"] },
{ k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"] },
{ k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"] },
{ k: "alerts", t: "entities", l: "Alerts, e.g. salt or rinse aid low", domains: ["sensor", "binary_sensor", "event"] },
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
],
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program].filter(Boolean),
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off",
render(c, { ha }) {
// Picking the status fills in the other entities of the same device (Home Connect names them predictably).
autofill: {
from: "status",
fill(eid, ha) {
const dev = ha.reg?.deviceOf?.[eid];
const base = eid.split(".")[1].replace(/_(operation_state|status|state)$/, "");
const sibs = dev ? ha.reg.byDevice[dev] : Object.keys(ha.states).filter((e) => e.split(".")[1].startsWith(base));
const find = (dom, re) => sibs.find((e) => domainOf(e) === dom && re.test(e)) || "";
return {
progress: find("sensor", /progress/), remaining: find("sensor", /finish_time|remaining|end_time/),
door: find("sensor", /door/) || find("binary_sensor", /door/), program: find("select", /active_program/) || find("sensor", /program$/),
pauseBtn: find("button", /pause/), resumeBtn: find("button", /resume/), stopBtn: find("button", /stop|abort/),
powerSwitch: find("switch", /_power$/), energy: find("sensor", /energy|water/),
alerts: sibs.filter((e) => /salt|rinse_aid|i_dos|poor_|machine_care/.test(e)),
kind: /dish/.test(eid) ? "dishwasher" : /dryer|droger/.test(eid) && !/washer_dryer/.test(eid) ? "dryer" : undefined,
};
},
},
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program, c.door, c.energy, c.pauseBtn, c.resumeBtn, c.stopBtn, c.powerSwitch, ...(c.alerts || [])].filter(Boolean),
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off"
&& !(c.alerts || []).some((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase())),
// Two rows high: a progress ring around the appliance on the left, the story on the right.
// Taller cards stack the ring on top (see the container queries in style.css).
render(c, { ha, cache }) {
if (!c.status && !c.power && !c.remaining) return `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
const phase = appliancePhase(c, ha);
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off") }[phase];
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
const prog = Number(ha.states[c.progress]?.state);
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off"), scheduled: t("Starts later"), attention: t("Action needed"), error: t("Error"), stopping: t("Stopping") }[phase];
const active = ["running", "paused", "scheduled", "attention"].includes(phase);
const left = active ? remainingMinutes(c, ha) : null;
// Progress from the integration, or worked out from the time left (relative to the most seen this run).
if (phase === "off") cache.maxLeft = 0;
if (left != null && phase !== "scheduled") cache.maxLeft = Math.max(cache.maxLeft || 0, left);
let prog = Number(ha.states[c.progress]?.state);
if (!isFinite(prog) || !c.progress) prog = left != null && cache.maxLeft > 0 && phase !== "scheduled" ? 100 * (1 - left / cache.maxLeft) : NaN;
if (phase === "done") prog = 100;
const program = ha.states[c.program]?.state;
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "";
const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
return `<div class="appl phase-${phase}">
<div class="tile-row"><span class="badge">${Icons.svg(ico)}</span>
<span class="tile-text"><span class="name">${esc(c.name || t("Appliance"))}</span>${showProgram ? `<span class="state">${esc(st(program))}</span>` : ""}</span>
<span class="appl-status">${esc(label)}</span></div>
${phase === "done" ? `<div class="appl-big">${t("Ready to unload")}</div>` : left != null ? `<div class="appl-big"><span class="num">${esc(fmtDuration(left))}</span> <span class="wt">${t("left · ready at {0}", ends)}</span></div>` : ""}
${isFinite(prog) && c.progress && phase !== "off" ? `<div class="car-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"><span class="car-fill" style="width:${Math.max(0, Math.min(100, prog))}%"></span></div>` : ""}
</div>`;
const ringIco = phase === "done" ? "check" : phase === "attention" || phase === "error" ? "shield" : ico;
const R = 44, C = 2 * Math.PI * R, frac = isFinite(prog) ? Math.max(0, Math.min(1, prog / 100)) : 0;
const showRing = isFinite(prog) && phase !== "off";
const ring = `<div class="appl-ring" ${showRing ? `role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"` : ""}>
<svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
${showRing ? `<circle cx="50" cy="50" r="${R}" class="fill" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>
<span class="appl-ico">${Icons.svg(ringIco)}</span>
${isFinite(prog) && phase === "running" ? `<span class="appl-pct num">${Math.round(prog)}%</span>` : ""}</div>`;
// Door: Home Connect's door state is open / closed / locked; a binary sensor is on when open.
const door = ha.states[c.door], energy = ha.states[c.energy], power = ha.states[c.power];
const doorState = door ? (domainOf(c.door) === "binary_sensor" ? (isOn(door) ? "open" : "closed") : String(door.state).toLowerCase()) : null;
const alerts = (c.alerts || []).filter((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase()));
// Alert names start with the device's name ("Dishwasher Rinse aid nearly empty"); take that from the status entity.
const device = friendly(ha.states[c.status]).replace(/\s*(operation state|bedieningsstatus|status|state)$/i, "");
const strip = (txt) => (device && txt.toLowerCase().startsWith(device.toLowerCase() + " ") ? txt.slice(device.length + 1) : txt);
const facts = [
...alerts.map((e) => `<span class="appl-fact warn">${Icons.svg("shield")}<span>${esc(cap(strip(friendly(ha.states[e]))))}</span></span>`),
doorState && ["open", "closed", "locked"].includes(doorState) && `<span class="appl-fact ${doorState === "open" ? "warn" : ""}">${Icons.svg(doorState === "open" ? "door-open" : doorState === "locked" ? "lock" : "door")}${esc({ open: t("Door open"), closed: t("Door closed"), locked: t("Door locked") }[doorState])}</span>`,
power && phase === "running" && `<span class="appl-fact">${Icons.svg("bolt")}${esc(fmtState(power))}</span>`,
energy && `<span class="appl-fact">${Icons.svg("pulse")}${esc(fmtState(energy))}</span>`,
].filter(Boolean).join("");
// Controls: Home Connect offers pause, resume and stop as buttons, and power as a switch.
const ctl = (eid, ico, lbl, act = "svc", v = "button.press") => (eid && ha.states[eid] ? `<button class="appl-btn wbtn" data-act="${act}" data-e="${esc(eid)}" ${act === "svc" ? `data-v="${v}"` : ""} aria-label="${esc(lbl)}" title="${esc(lbl)}">${Icons.svg(ico)}</button>` : "");
const powerOff = c.powerSwitch && ha.states[c.powerSwitch]?.state === "off";
const ctrls = [
phase === "running" && ctl(c.pauseBtn, "pause", t("Pause")),
phase === "paused" && ctl(c.resumeBtn, "play", t("Resume")),
["running", "paused", "scheduled"].includes(phase) && ctl(c.stopBtn, "stop", t("Stop")),
phase === "off" && powerOff && ctl(c.powerSwitch, "power", t("Turn on"), "toggle"),
].filter(Boolean).join("");
const main = phase === "done" ? `<span class="appl-big">${t("Ready to unload")}</span>`
: phase === "scheduled" ? `<span class="appl-big">${left != null ? t("Ready at {0}", ends) : t("Starts later")}</span>`
: phase === "attention" ? `<span class="appl-big">${t("Check the appliance")}</span>`
: phase === "error" ? `<span class="appl-big">${t("Something went wrong")}</span>`
: left != null ? `<span class="appl-big num">${esc(fmtDuration(left))}</span><span class="appl-sub">${t("left · ready at {0}", ends)}</span>`
: phase === "running" ? `<span class="appl-big">${t("In progress")}</span>` : `<span class="appl-big appl-off">${esc(label)}</span>`;
return `<div class="appl phase-${phase}">${ring}
<div class="appl-text">
<div class="appl-top"><span class="name">${esc(c.name || t("Appliance"))}</span><span class="appl-status">${esc(label)}</span></div>
${showProgram ? `<span class="appl-program">${esc(prettyProgram(program))}</span>` : ""}
<div class="appl-main"><div class="appl-main-text">${main}</div>${ctrls ? `<div class="appl-ctrls">${ctrls}</div>` : ""}</div>
${facts ? `<div class="appl-facts">${facts}</div>` : ""}
</div></div>`;
},
lit: (c, { ha }) => (["running", "done"].includes(appliancePhase(c, ha)) ? "on" : false),
lit: (c, { ha }) => (["running", "done", "attention"].includes(appliancePhase(c, ha)) ? "on" : false),
};
W.camera = {
@@ -693,7 +849,7 @@
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
W.car = {
label: "Car", icon: "car", size: [6, 3],
label: "Car", icon: "car", size: [3, 4],
fields: [
{ k: "name", t: "text", l: "Name", d: "My car" },
{ k: "image", t: "text", l: "Car image URL (optional)", ph: "https://… or /local/car.png" },
@@ -710,15 +866,18 @@
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
],
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
// Layout follows the card's size (container queries in style.css): designed around 3×4,
// a compact strip when short, and the car beside the numbers when wide.
render(c, { ha }) {
const st = (k) => (c[k] ? ha.states[c[k]] : null);
const level = st("level"), range = st("range"), lock = st("lock"), clim = st("climate"), loc = st("location");
const st_ = (k) => (c[k] ? ha.states[c[k]] : null);
const level = st_("level"), range = st_("range"), lock = st_("lock"), clim = st_("climate"), loc = st_("location");
if (!c.level && !c.range && !c.lock) return `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null;
const power = st("power"), plug = st("plug"), limit = st("limit");
const charging = truthy(st("charging")) || (!c.charging && power && Number(power.state) > 0);
const power = st_("power"), plug = st_("plug"), limit = st_("limit"), odo = st_("odometer");
const charging = truthy(st_("charging")) || (!c.charging && power && Number(power.state) > 0);
const plugged = charging || truthy(plug);
const low = pct != null && pct <= 20;
const fuel = c.kind === "fuel";
let status;
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
@@ -728,34 +887,45 @@
else status = "";
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const bar = pct == null ? "" : `
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${c.kind === "fuel" ? t("Fuel level") : t("Battery level")}">
<span class="car-fill" style="width:${pct}%"></span>
${limPct != null && c.kind !== "fuel" ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
</div>`;
const actions = [];
if (lock) {
const locked = lock.state === "locked";
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.lock)}" aria-pressed="${!locked}">${Icons.svg(locked ? "lock" : "unlock")}<span>${locked ? t("Locked") : t("Unlocked")}</span></button>`);
}
if (clim) {
const on = isOn(clim) || clim.state === "on";
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.climate)}" aria-pressed="${on}">${Icons.svg(on ? "fan" : "snowflake")}<span>${on ? t("Climate on") : t("Climate")}</span></button>`);
}
if (loc) actions.push(`<button class="car-act wbtn" data-act="more" data-e="${esc(c.location)}">${Icons.svg("pin")}<span>${esc(fmtState(loc))}</span></button>`);
const odo = st("odometer");
// Drawn car for when there's no photo: a side view, with a glow and a bolt while charging.
const drawn = `<svg class="car-art" viewBox="0 0 200 84" aria-hidden="true">
<path class="car-body" d="M10 60c1-10 9-15 22-17l26-15c7-4 15-6 24-6h40c10 0 18 3 25 9l15 12c13 1 24 4 28 11l2 8c0 4-2 6-6 6H12c-3 0-4-3-2-8z"/>
<path class="car-glass" d="M64 31c6-3 12-4 19-4h16v18H52zM105 27h17c8 0 14 2 20 7l9 11h-46z"/>
<circle class="car-wheel" cx="46" cy="68" r="13"/><circle class="car-wheel" cx="154" cy="68" r="13"/>
<circle class="car-hub" cx="46" cy="68" r="5"/><circle class="car-hub" cx="154" cy="68" r="5"/></svg>`;
const visual = `<div class="car-visual ${charging ? "is-charging" : ""}">${c.image ? `<img class="car-photo" src="${esc(c.image)}" alt="">` : drawn}
${charging ? `<span class="car-bolt" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>`;
return `<div class="car ${c.image ? "has-img" : ""}">
${c.image ? `<img class="car-img" src="${esc(c.image)}" alt="">` : ""}
<div class="car-head"><div class="name">${esc(c.name || t("Car"))}</div><div class="wt">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</div></div>
<div class="car-main">
${pct != null ? `<span class="big num">${pct}<span class="unit">%</span></span>` : ""}
${range ? `<span class="car-range">${esc(fmtState(range))}<span class="wt"> ${t("range")}</span></span>` : ""}
${low ? `<span class="level">${t("Low")}</span>` : ""}
</div>
${bar}
${actions.length || odo ? `<div class="car-actions">${actions.join("")}${odo ? `<span class="wt car-odo">${esc(fmtState(odo))}</span>` : ""}</div>` : ""}
</div>`;
const bar = pct == null ? "" : `
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${fuel ? t("Fuel level") : t("Battery level")}">
<span class="car-fill" style="width:${pct}%"></span>
${limPct != null && !fuel ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
</div>`;
const action = (eid, on, ico, label) => `<button class="car-btn wbtn" data-act="${eid === c.location ? "more" : "toggle"}" data-e="${esc(eid)}" ${eid === c.location ? "" : `aria-pressed="${on}"`}>
<span class="car-btn-ico">${Icons.svg(ico)}</span><span class="car-btn-label">${esc(label)}</span></button>`;
const actions = [];
if (lock) { const locked = lock.state === "locked"; actions.push(action(c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
if (clim) { const on = isOn(clim) || clim.state === "on"; actions.push(action(c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"))); }
if (loc) actions.push(action(c.location, false, "pin", fmtState(loc)));
const stat = (label, value) => `<div class="car-stat"><span class="car-stat-v num">${esc(value)}</span><span class="car-stat-l">${esc(label)}</span></div>`;
const stats = [
odo && stat(t("Odometer"), fmtState(odo)),
limPct != null && !fuel && stat(t("Charge limit"), `${limPct}%`),
charging && power && stat(t("Charging power"), fmtState(power)),
].filter(Boolean).join("");
return `<div class="car2 ${c.image ? "has-photo" : ""}">
<div class="car-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car-status ${charging ? "charging" : ""}">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</span>` : ""}</div>
${visual}
<div class="car-info">
<div class="car-nums">${pct != null ? `<span class="car-pct num">${pct}<span class="unit">%</span></span>` : ""}
${range ? `<span class="car-range num">${esc(fmtState(range))}</span>` : ""}${low ? `<span class="level">${t("Low")}</span>` : ""}</div>
${bar}
${stats ? `<div class="car-stats">${stats}</div>` : ""}
${actions.length ? `<div class="car-actions2">${actions.join("")}</div>` : ""}
</div></div>`;
},
};
@@ -1000,20 +1170,25 @@
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
// ctx.renderChildren fills them using the normal widget renderer.
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}" ${Number(ch.cfg?._width) > 0 ? `style="--own-w:${Number(ch.cfg._width)}px"` : ""}></div>`;
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
W.hscroll = {
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "title", t: "text", l: "Title", d: "" },
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
{ k: "widthMode", t: "seg", l: "Card width", o: ["pixels", "per-view"], d: "pixels" },
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260, when: (c) => c.widthMode !== "per-view" },
{ k: "perView", t: "number", l: "Cards visible at once", d: 2.5, ph: "e.g. 1, 2 or 2.5", when: (c) => c.widthMode === "per-view" },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<div class="hs-track" style="--item-w:${c.widthMode === "per-view"
// N cards per view, gaps included; a half card peeking out hints that the row scrolls
? `calc((100% - var(--d-gap, 14px) * ${Math.max(1, Number(c.perView) || 2.5) - 1}) / ${Math.max(1, Number(c.perView) || 2.5)})`
: `${Math.max(120, Number(c.itemWidth) || 260)}px`}" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
},
@@ -1109,12 +1284,29 @@
else if (d.dx > sw.clientWidth / 4 || speed > 0.35) p--;
go(p);
showDots();
cache.swallowClick = true; // the release after a swipe must not press a button underneath
// The release after a swipe must not press a button underneath. Only for a moment: touch screens
// send no click after a swipe, and a lasting flag would eat the next real tap.
cache.swallowUntil = Date.now() + 350;
if (cache.dirty) { cache.dirty = false; setTimeout(() => ctx.rerender(), 400); }
};
track.addEventListener("pointerup", end);
track.addEventListener("pointercancel", end);
track.addEventListener("click", (e) => { if (cache.swallowClick) { cache.swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
track.addEventListener("lostpointercapture", end);
track.addEventListener("dragstart", (e) => e.preventDefault()); // images would start a native drag and cancel the swipe
track.addEventListener("click", (e) => { if (Date.now() < (cache.swallowUntil || 0)) { e.stopPropagation(); e.preventDefault(); } }, true);
// Two-finger swipe on a trackpad: horizontal wheel movement turns one page at a time.
let wheelSum = 0, wheelLock = 0;
sw.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || n < 2) return;
e.preventDefault();
if (Date.now() < wheelLock) return;
wheelSum += e.deltaX;
if (Math.abs(wheelSum) > 50) {
cache.pausedUntil = Date.now() + 20000;
cache.go(page + (wheelSum > 0 ? 1 : -1));
wheelSum = 0; wheelLock = Date.now() + 450;
}
}, { passive: false });
track.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") { e.preventDefault(); cache.go(page + 1); }
if (e.key === "ArrowLeft") { e.preventDefault(); cache.go(page - 1); }