Dashboard Studio 1.12.1: car card redesigned from scratch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:46:25 +01:00
parent 7abf7379d8
commit 2ea437af31
6 changed files with 164 additions and 104 deletions
+73 -47
View File
@@ -1134,17 +1134,32 @@
// ---------------------------------------------------------------- car
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
// Built around a round gauge, like an instrument cluster: charge as an arc with the charge limit marked,
// the range large in the middle, and the car in front. Sizes adapt through container queries.
const CAR_ART = `<svg class="car-art2" viewBox="0 0 200 78" aria-hidden="true">
<defs><linearGradient id="carBody" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--car-top)"/><stop offset="1" stop-color="var(--car-bottom)"/></linearGradient>
<linearGradient id="carGlass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe3ff" stop-opacity=".55"/><stop offset="1" stop-color="#7a8cab" stop-opacity=".35"/></linearGradient></defs>
<ellipse cx="100" cy="70" rx="86" ry="5" class="car-shadow"/>
<path class="car-body2" d="M14 56c0-6 4-9 12-11l30-6c10-10 22-15 40-16h24c14 0 26 5 36 13l14 5c12 2 18 6 19 12v5c0 3-2 4-5 4H16c-1.5 0-2-2-2-6z"/>
<path class="car-glass2" d="M64 39c8-8 18-12 32-12h12v12zM112 27h8c11 0 20 4 28 11l-36 1z"/>
<path class="car-line" d="M30 50h150"/><circle cx="182" cy="52" r="2.2" class="car-light"/>
<circle cx="52" cy="62" r="12" class="car-tire"/><circle cx="52" cy="62" r="6" class="car-rim"/>
<circle cx="154" cy="62" r="12" class="car-tire"/><circle cx="154" cy="62" r="6" class="car-rim"/></svg>`;
// The car is the battery: a side view that fills up from the back with the charge level, the charge
// limit marked in it, and the numbers above it. Sizes adapt through container queries.
const CAR_BODY = "M16 68H40A18 18 0 0 1 76 68H166A18 18 0 0 1 202 68H224C230 68 233 65 233 60L232 54C231 49 226 46 218 45L172 40C160 32 148 24 134 21C118 18 98 18 84 20C66 23 46 32 30 40L16 43C10 44 7 48 7 54V61C7 65 10 68 16 68Z";
const CAR_GLASS = "M164 40C154 32 144 26 132 24C120 22 104 22 92 23L94 40ZM88 23C74 25 60 31 48 40H90Z";
const CAR_X0 = 7, CAR_W = 226; // where the body starts and how long it is, for the fill
function carArt(id, pct, limPct, o) {
const fx = CAR_X0 + CAR_W * Math.max(0, Math.min(100, pct ?? 0)) / 100;
const lx = limPct != null ? CAR_X0 + CAR_W * Math.max(0, Math.min(100, limPct)) / 100 : null;
const wheel = (cx) => `<circle cx="${cx}" cy="70" r="15" class="cv-tyre"/><circle cx="${cx}" cy="70" r="9.5" class="cv-rim"/><circle cx="${cx}" cy="70" r="3" class="cv-hub"/>`;
return `<svg class="cv-svg" viewBox="0 0 240 92" aria-hidden="true">
<defs><clipPath id="cvb-${id}"><path d="${CAR_BODY}"/></clipPath><clipPath id="cvf-${id}"><rect x="0" y="0" width="${fx.toFixed(1)}" height="92"/></clipPath>
<linearGradient id="cvg-${id}" x1="0" x2="1"><stop offset="0" stop-color="currentColor" stop-opacity=".55"/><stop offset="1" stop-color="currentColor"/></linearGradient></defs>
<ellipse cx="121" cy="86" rx="110" ry="3.5" class="cv-shadow"/>
<path d="${CAR_BODY}" class="cv-shell"/>
<g clip-path="url(#cvb-${id})">
<g clip-path="url(#cvf-${id})"><rect x="0" y="0" width="240" height="92" fill="url(#cvg-${id})"/>${o.charging ? `<rect class="cv-sweep" x="-70" y="0" width="70" height="92"/>` : ""}</g>
${lx != null ? `<line x1="${lx.toFixed(1)}" y1="0" x2="${lx.toFixed(1)}" y2="92" class="cv-limit"/>` : ""}
</g>
<path d="${CAR_GLASS}" class="cv-glass"/>
<path d="${CAR_BODY}" class="cv-edge"/>
<path d="M34 52L212 49" class="cv-crease"/>
<path d="M220 49.5L230 53" class="cv-lamp"/><path d="M8.5 49L17 47" class="cv-tail"/>
${o.plugged ? `<circle cx="24" cy="51" r="3.4" class="cv-port"/>` : ""}
${wheel(58)}${wheel(184)}</svg>`;
}
W.car = {
label: "Car", icon: "car", size: [3, 4],
@@ -1166,61 +1181,72 @@
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
],
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
render(c, { ha }) {
render(c, { ha, w }) {
const S = (k) => (c[k] ? ha.states[c[k]] : null);
const level = S("level"), range = S("range"), lock = S("lock"), clim = S("climate"), loc = S("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 = S("power"), plug = S("plug"), limit = S("limit"), odo = S("odometer"), toFull = S("toFull"), chg = S("chargeSwitch");
const charging = truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on");
const plugged = charging || truthy(plug);
const fuel = c.kind === "fuel", low = pct != null && pct <= 20;
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const fuel = c.kind === "fuel";
const charging = !fuel && (truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on"));
const plugged = !fuel && (charging || truthy(plug));
const low = pct != null && pct <= 20;
const limPct = !fuel && limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const unit = range ? range.attributes.unit_of_measurement || "km" : "";
// status line and the "full at" time
let status = "";
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
else if (plugged) status = t("Plugged in");
// status, top right
let status = "", tone = "";
if (charging) { status = t("Charging"); tone = "ok"; }
else if (plugged) { status = t("Plugged in"); tone = "accent"; }
else if (low) { status = fuel ? t("Low fuel") : t("Low battery"); tone = "crit"; }
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
let fullAt = "";
// the line under the range: when it will be full, or how far it charges
let sub = "";
if (charging && toFull) {
const v = toFull.state, mins = /^\d{4}-\d{2}-\d{2}T/.test(v) ? (new Date(v) - Date.now()) / 60000 : Number(v) * (String(toFull.attributes.unit_of_measurement || "min").toLowerCase().startsWith("h") ? 60 : 1);
if (isFinite(mins) && mins > 0) fullAt = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
if (isFinite(mins) && mins > 0) sub = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
}
if (!sub && charging && power && Number(power.state) > 0) sub = fmtState(power);
if (!sub && limPct != null) sub = t("Charges to {0}%", Math.round(limPct));
if (!sub) sub = range ? t("Range") : "";
// the gauge: 240° arc, level filled, charge limit as a tick
const A0 = Math.PI * (150 / 180), SPAN = Math.PI * (240 / 180), R = 80;
const ang = (p) => A0 + SPAN * Math.max(0, Math.min(1, p / 100));
const pt = (p, r = R) => [100 + r * Math.cos(ang(p)), 100 + r * Math.sin(ang(p))];
const arc = (p0, p1) => { const [x0, y0] = pt(p0), [x1, y1] = pt(p1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(p1) - ang(p0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
const tick = limPct != null && !fuel ? (() => { const [x0, y0] = pt(limPct, R - 11), [x1, y1] = pt(limPct, R + 11); return `<line x1="${x0.toFixed(1)}" y1="${y0.toFixed(1)}" x2="${x1.toFixed(1)}" y2="${y1.toFixed(1)}" class="cg-limit"/>`; })() : "";
const gauge = `<div class="cg ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct ?? 0}" aria-label="${esc(fuel ? t("Fuel level") : t("Battery level"))}">
<svg viewBox="0 0 200 200" aria-hidden="true"><path d="${arc(0, 100)}" class="cg-track"/>${pct ? `<path d="${arc(0, pct)}" class="cg-fill"/>` : ""}${tick}</svg>
<div class="cg-center">${range ? `<span class="cg-range num">${esc(fmtNum(range.state))}<small>${esc(range.attributes.unit_of_measurement || "km")}</small></span>` : ""}
${pct != null ? `<span class="cg-pct num">${charging ? Icons.svg("bolt", "inline") : ""}${pct}%${low ? ` · ${t("Low")}` : ""}</span>` : ""}</div>
<div class="cg-car">${c.image ? `<img src="${esc(c.image)}" alt="">` : CAR_ART}</div></div>`;
const hero = pct != null
? `<div class="cv-big num">${pct}<span>%</span></div>
<div class="cv-side">${range ? `<div class="cv-range num">${esc(fmtNum(range.state))}<small>${esc(unit)}</small></div>` : ""}${sub ? `<div class="cv-sub">${charging ? Icons.svg("bolt", "inline") : ""}${esc(sub)}</div>` : ""}</div>`
: range ? `<div class="cv-big num">${esc(fmtNum(range.state))}<span>${esc(unit)}</span></div>${sub && sub !== t("Range") ? `<div class="cv-side"><div class="cv-sub">${esc(sub)}</div></div>` : ""}` : "";
const btn = (act, eid, on, ico, label, sub) => `<button class="cb wbtn" data-act="${act}" data-e="${esc(eid)}" aria-label="${esc(sub ? `${label}, ${sub}` : label)}" title="${esc(label)}" ${act === "toggle" ? `aria-pressed="${on}"` : ""}>
<span class="cb-ico">${Icons.svg(ico)}</span><span class="cb-label">${esc(label)}</span>${sub ? `<span class="cb-sub num">${esc(sub)}</span>` : ""}</button>`;
const buttons = [];
if (lock) { const locked = lock.state === "locked"; buttons.push(btn("toggle", c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
const bar = pct != null ? `<div class="cv-bar" role="presentation"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i>${limPct != null ? `<b style="left:${Math.max(0, Math.min(100, limPct))}%"></b>` : ""}</div>` : "";
const art = c.image ? `<div class="cv-img"><img src="${esc(c.image)}" alt=""></div>` : carArt(esc(w?.id || "x"), pct, limPct, { charging, plugged });
// controls: each shows what it controls and its state
const ctrl = (act, eid, ico, label, { on = false, warn = false, svc = "" } = {}) =>
`<button class="cv-ctrl wbtn ${on ? "on" : ""} ${warn ? "warn" : ""}" data-act="${act}" data-e="${esc(eid)}" ${svc ? `data-v="${svc}"` : ""} ${act === "toggle" ? `aria-pressed="${on}"` : ""} aria-label="${esc(label)}" title="${esc(label)}">
<span class="cv-ico">${Icons.svg(ico)}</span><span class="cv-lbl">${esc(label)}</span></button>`;
const ctrls = [];
if (lock) { const locked = lock.state === "locked"; ctrls.push(ctrl("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"), { warn: !locked })); }
if (clim) {
const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature;
buttons.push(btn("toggle", c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"), inside != null ? `${inside}° ${t("inside")}` : ""));
ctrls.push(ctrl("toggle", c.climate, on ? "fan" : "snowflake", inside != null ? `${fmtNum(inside)}°` : on ? t("Climate on") : t("Climate"), { on }));
}
if (chg && !fuel) {
const isBtn = domainOf(c.chargeSwitch) === "button";
buttons.push(isBtn ? `<button class="cb wbtn" data-act="svc" data-e="${esc(c.chargeSwitch)}" data-v="button.press"><span class="cb-ico">${Icons.svg("bolt")}</span><span class="cb-label">${esc(friendly(chg))}</span></button>`
: btn("toggle", c.chargeSwitch, charging, "bolt", charging ? t("Stop charging") : t("Start charging")));
ctrls.push(domainOf(c.chargeSwitch) === "button"
? ctrl("svc", c.chargeSwitch, "bolt", t("Charge"), { svc: "button.press" })
: ctrl("toggle", c.chargeSwitch, "bolt", charging ? t("Stop") : t("Charge"), { on: charging }));
}
if (loc) buttons.push(btn("more", c.location, false, "pin", fmtState(loc)));
if (loc) ctrls.push(ctrl("more", c.location, "pin", fmtState(loc)));
const chips = [fullAt && `<span class="cchip">${Icons.svg("clock")}${esc(fullAt)}</span>`, odo && `<span class="cchip">${Icons.svg("road")}${esc(fmtState(odo))}</span>`].filter(Boolean).join("");
const stat = (k, v) => `<div class="cv-stat"><span>${esc(k)}</span><b class="num">${esc(v)}</b></div>`;
const stats = [odo && stat(t("Odometer"), fmtState(odo)), limPct != null && stat(t("Charge limit"), `${Math.round(limPct)}%`),
charging && power && stat(t("Charging power"), fmtState(power)), clim?.attributes.current_temperature != null && stat(t("Inside"), `${fmtNum(clim.attributes.current_temperature)}°`)].filter(Boolean).join("");
return `<div class="car3 ${charging ? "is-charging" : ""}">
<div class="car3-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car3-status ${charging ? "charging" : ""}">${esc(status)}</span>` : ""}</div>
${gauge}
<div class="car3-info">${chips ? `<div class="cchips">${chips}</div>` : ""}${buttons.length ? `<div class="cbs">${buttons.join("")}</div>` : ""}</div>
const cls = [charging && "charging", plugged && "plugged", low && "low", fuel && "fuel", c.image && "has-img"].filter(Boolean).join(" ");
return `<div class="cv ${cls}">
<div class="cv-head"><span class="cv-name">${esc(c.name || t("Car"))}</span>${status ? `<span class="cv-status ${tone}"><i aria-hidden="true"></i>${esc(status)}</span>` : ""}</div>
<div class="cv-hero" role="group" aria-label="${esc([pct != null && `${fuel ? t("Fuel level") : t("Battery level")} ${pct}%`, range && `${fmtNum(range.state)} ${unit}`].filter(Boolean).join(", "))}">${hero}</div>
<div class="cv-art">${art}${bar}</div>
${stats ? `<div class="cv-stats">${stats}</div>` : ""}
${ctrls.length ? `<div class="cv-ctrls">${ctrls.join("")}</div>` : ""}
</div>`;
},
};