diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index 8147065..c595c6d 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.12.1 + +- **Car card redesigned from scratch:** the car is now the battery. A side view of the car fills up with the charge level (green while charging, red when low) with the charge limit marked, under large numbers for the charge level and range, when it will be full and a status. Round buttons control the doors, climate, charging and location. Short cards show a slim battery bar instead, wide cards put the car beside the stats, and narrow phone cards stack neatly. + ## 1.12.0 - **Seven new cards:** a to-do and shopping list (add, tick off, swipe to delete), who's home (photo, home or away, for how long, which zone), a weather forecast (hourly with rain chance, the next days, sunrise and sunset), a kitchen timer (quick starts, countdown ring, a beep when done), blinds and covers (position, tilt, open or close all), an alarm panel (keypad, arming modes, warns about open doors and windows) and notifications (dismiss them, install updates). diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 0da6d20..634cdb3 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -163,7 +163,7 @@ Point the car widget at whichever entities your car integration provides. Each o Two optional extra fields: **Time until full** (a sensor in minutes or an end time) and **Start/stop charging** (a switch or button). -The card is designed around 3×4. It shows a drawn car (or your own photo) that glows with a bolt while charging, a round gauge with the charge level, the range in the middle and the charge limit marked, a status (charging with its power and when it will be full, plugged in, parked at home or away) and round buttons for the doors, climate, charging and location. On a short card it becomes a compact strip, on a wide card the car sits beside the numbers, and a tall card adds tiles for the odometer, charge limit and charging power. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`. +The card is designed around 3×4 and is built around the car itself: a side view that fills up from the back with the charge level, green while charging, red when it's low, and orange for a fuel car. A dashed line in the car marks the charge limit. Above it are the charge level and range in large numbers, with when the car will be full (or how far it charges), and a status such as *Charging*, *Plugged in* or *Parked at home*. Round buttons along the bottom lock or unlock the doors (orange while unlocked), switch the climate (showing the temperature inside), start or stop charging, and show the location. On a short card the drawing becomes a slim battery bar, on a wide card the car sits on the left with a row of stats beside it, and a tall card adds stats for the odometer, charge limit, charging power and inside temperature. With your own car photo (by URL), the photo shows with the battery bar underneath. Images you put in Home Assistant's `www` folder are available at `/local/…`. ## Conditional widgets diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 039830a..42a4398 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.12.0" +version: "1.12.1" slug: dashboard_studio description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. url: https://git.melvanveen.nl/veenm/ha-dashboard-studio diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 232a6b5..7a75983 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -380,6 +380,11 @@ "Each save is kept here, up to 30 versions. Changes within five minutes count as one.": "Elke opslag wordt hier bewaard, tot 30 versies. Wijzigingen binnen vijf minuten tellen als één.", "No earlier versions yet. They appear after your next changes.": "Nog geen eerdere versies. Ze verschijnen na je volgende wijzigingen.", "Restored the version from {0}. Press ⌘Z to undo.": "De versie van {0} is teruggezet. Druk op ⌘Z om ongedaan te maken.", + "Charges to {0}%": "Laadt tot {0}%", + "Low battery": "Accu bijna leeg", + "Low fuel": "Bijna leeg", + "Charge": "Laden", + "Inside": "Binnen", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", }; diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index bf37a04..6a4668a 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -974,63 +974,88 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } /* pill button with icon and label (media "Turn on") */ .car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; } .car-act .i { font-size: 18px; } -/* car: a round gauge with the range in the middle and the car in front */ -.car3 { --car-top: color-mix(in srgb, var(--d-accent) 55%, var(--d-text)); --car-bottom: color-mix(in srgb, var(--d-accent) 70%, #000); - display: grid; grid-template-areas: "top" "gauge" "info"; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; height: 100%; min-height: 0; } -.car3-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; } -.car3-top .name { font-size: 17px; flex: 1; min-width: 0; } -.car3-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 62%; } -.car3-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); } -.cg { grid-area: gauge; position: relative; min-height: 0; height: 100%; max-height: 260px; max-width: 100%; justify-self: center; align-self: center; aspect-ratio: 1 / .92; } -.cg > svg { position: absolute; inset: 0; width: 100%; height: 100%; } -.cg > svg path { fill: none; stroke-width: 12; stroke-linecap: round; } -.cg-track { stroke: var(--d-fill); } -.cg-fill { stroke: var(--d-text); } -.cg.charging .cg-fill { stroke: var(--ok); animation: cg-pulse 2.4s ease-in-out infinite; } -.cg.low .cg-fill { stroke: var(--crit); } -@keyframes cg-pulse { 50% { opacity: .65; } } -.cg-limit { stroke: var(--d-text-2); stroke-width: 3; stroke-linecap: round; } -.cg-center { position: absolute; left: 0; right: 0; top: 22%; display: flex; flex-direction: column; align-items: center; } -.cg-range { font-size: clamp(26px, 11cqh, 44px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; } -.cg-range small { font-size: .42em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; } -.cg-pct { margin-top: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); } -.cg.charging .cg-pct { color: var(--ok); } -.cg-car { position: absolute; left: 6%; right: 6%; bottom: 2%; display: flex; justify-content: center; } -.cg-car img { max-width: 100%; max-height: 72px; object-fit: contain; filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .3)); } -.car-art2 { width: 100%; max-height: 74px; } -.car-shadow { fill: rgba(0, 0, 0, .28); } -.car-body2 { fill: url(#carBody); } -.car-glass2 { fill: url(#carGlass); } -.car-line { stroke: rgba(255, 255, 255, .25); stroke-width: 1; } -.car-light { fill: #fff6d6; } -.car-tire { fill: #17181b; } .car-rim { fill: #8e9097; } -.car3.is-charging .cg-car::after { content: ""; position: absolute; left: 20%; right: 20%; bottom: 0; height: 40%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 45%, transparent), transparent); z-index: -1; } -.car3-info { grid-area: info; display: flex; flex-direction: column; gap: 8px; min-width: 0; } -.cchips { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; } -.cchip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; } -.cchip .i { font-size: 14px; color: var(--d-text-2); } -.cbs { display: flex; gap: 6px; } -.cb { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); } -.cb-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; transition: background-color .2s; } -.cb[aria-pressed="true"] .cb-ico { background: var(--d-text); color: var(--d-tile); } -.cb-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.cb-sub { font-size: 11px; color: var(--d-text-2); white-space: nowrap; } -/* short: gauge small on the left, numbers and buttons beside it */ -@container (max-height: 250px) { - .car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap: 14px; } - .cg { width: auto; height: 100%; max-width: none; aspect-ratio: 1; } - .cg-car { display: none; } - .cg-center { top: 30%; } - .car3-info { justify-content: center; } - .cchips { justify-content: flex-start; } +/* car: the car itself fills up with the charge level */ +.cv { --cv-c: var(--d-accent); display: grid; grid-template-areas: "head" "hero" "art" "stats" "ctrls"; grid-template-rows: auto auto minmax(0, 1fr) auto auto; row-gap: 10px; height: 100%; min-height: 0; min-width: 0; } +.cv.charging { --cv-c: var(--ok); } +.cv.low { --cv-c: var(--crit); } +.cv.fuel:not(.low) { --cv-c: var(--warn); } +.cv-head { grid-area: head; display: flex; align-items: center; gap: 8px; min-width: 0; } +.cv-name { flex: 1; min-width: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cv-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 64%; } +.cv-status i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; } +.cv-status.ok { color: var(--ok); } .cv-status.crit { color: var(--crit); } .cv-status.accent { color: var(--d-accent); } +.cv-status.ok i { animation: cv-blink 1.6s ease-in-out infinite; } +@keyframes cv-blink { 50% { opacity: .25; } } +.cv-hero { grid-area: hero; display: flex; align-items: flex-end; gap: 14px; min-width: 0; } +.cv-big { font-size: clamp(40px, 19cqw, 68px); font-weight: 700; letter-spacing: -0.045em; line-height: .86; } +.cv-big span { font-size: .42em; font-weight: 600; letter-spacing: 0; color: var(--d-text-2); margin-left: 2px; } +.cv-side { min-width: 0; padding-bottom: 2px; } +.cv-range { font-size: clamp(17px, 7cqw, 24px); font-weight: 650; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; } +.cv-range small { font-size: .62em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; } +.cv-sub { margin-top: 3px; font-size: 12px; font-weight: 500; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cv.charging .cv-sub { color: var(--ok); } +.cv-art { grid-area: art; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 10px; } +.cv-svg { display: block; width: 100%; height: 100%; max-height: 150px; min-height: 0; color: var(--cv-c); overflow: visible; } +.cv-shadow { fill: var(--d-text); opacity: .12; } +.cv.charging .cv-shadow { fill: var(--ok); opacity: .35; } +.cv-shell { fill: color-mix(in srgb, var(--d-text) 9%, transparent); } +.cv-edge { fill: none; stroke: color-mix(in srgb, var(--d-text) 28%, transparent); stroke-width: 1.2; stroke-linejoin: round; } +.cv-glass { fill: color-mix(in srgb, var(--d-tile) 62%, transparent); stroke: color-mix(in srgb, var(--d-text) 22%, transparent); stroke-width: 1; stroke-linejoin: round; } +.cv-crease { stroke: color-mix(in srgb, var(--d-text) 14%, transparent); stroke-width: 1; fill: none; } +.cv-lamp { stroke: color-mix(in srgb, var(--d-text) 55%, transparent); stroke-width: 2.4; stroke-linecap: round; } +.cv-tail { stroke: var(--crit); stroke-width: 2.4; stroke-linecap: round; opacity: .8; } +.cv-limit { stroke: var(--d-text); stroke-opacity: .55; stroke-width: 1.6; stroke-dasharray: 3 2.5; } +.cv-port { fill: var(--cv-c); stroke: var(--d-tile); stroke-width: 1.5; } +.cv.charging .cv-port { animation: cv-blink 1.6s ease-in-out infinite; } +.cv-tyre { fill: color-mix(in srgb, var(--d-text) 82%, var(--d-tile)); } +.cv-rim { fill: color-mix(in srgb, var(--d-text) 30%, var(--d-tile)); } +.cv-hub { fill: color-mix(in srgb, var(--d-text) 70%, var(--d-tile)); } +.cv-sweep { fill: #fff; opacity: .35; animation: cv-sweep 2.2s ease-in-out infinite; } +@keyframes cv-sweep { from { transform: translateX(0); } to { transform: translateX(310px); } } +.cv-img { min-height: 0; flex: 1; display: flex; align-items: center; justify-content: center; } +.cv-img img { max-width: 100%; max-height: 100%; object-fit: contain; filter: drop-shadow(0 8px 12px rgba(0, 0, 0, .25)); } +/* the slim battery bar: shown with a photo, and instead of the drawing on short cards */ +.cv-bar { position: relative; height: 8px; border-radius: 99px; background: color-mix(in srgb, var(--d-text) 10%, transparent); display: none; flex: none; } +.cv-bar i { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--cv-c); } +.cv-bar b { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); } +.cv.has-img .cv-bar { display: block; } +.cv-stats { grid-area: stats; display: none; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 6px; } +.cv-stat { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; } +.cv-stat span { font-size: 11px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cv-stat b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cv-ctrls { grid-area: ctrls; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; } +.cv-ctrl { display: flex; flex-direction: column; align-items: center; gap: 5px; min-width: 0; padding: 4px 2px; border-radius: 12px; } +.cv-ico { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; font-size: 19px; background: var(--d-fill); transition: background-color .2s, color .2s; } +.cv-ctrl.on .cv-ico { background: var(--cv-c); color: #fff; } +.cv-ctrl.warn .cv-ico { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); } +.cv-lbl { font-size: 11px; font-weight: 600; color: var(--d-text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (prefers-reduced-motion: reduce) { .cv-sweep { display: none; } .cv-status.ok i, .cv.charging .cv-port { animation: none; } } +/* tall: room for a row of stats */ +@container (min-height: 420px) { .cv-stats { display: grid; } } +/* short: the drawing gives way to a slim bar */ +@container (max-height: 260px) { + .cv { row-gap: 8px; grid-template-rows: auto auto auto minmax(0, 1fr) auto; } + .cv-svg, .cv-img { display: none; } + .cv-bar { display: block; } + .cv-ctrls { align-self: end; } + .cv-ico { width: 34px; height: 34px; font-size: 17px; } } -@container (max-height: 160px) { .cbs { display: none; } } -/* wide: gauge on the left, the rest on the right */ -@container (min-width: 480px) and (min-height: 251px) { - .car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto 1fr; column-gap: 20px; } - .car3-info { justify-content: center; } +@container (max-height: 160px) { .cv-ctrls { display: none; } .cv-big { font-size: 40px; } } +/* wide: the car on the left, numbers and controls on the right */ +@container (min-width: 460px) and (min-height: 261px) { + .cv { grid-template-areas: "art head" "art hero" "art stats" "art ctrls"; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); grid-template-rows: auto auto 1fr auto; column-gap: 22px; } + .cv-hero { align-self: start; } + .cv-svg { max-height: 190px; } + .cv-stats { display: grid; align-self: center; } } -@container (max-width: 300px) { .cb-label, .cb-sub { display: none; } .cb { padding: 6px 2px; } } +@container (max-width: 230px) { + .cv-lbl { display: none; } + .cv-hero { flex-direction: column; align-items: flex-start; gap: 6px; } + .cv-ctrls { gap: 4px; } + .cv-ico { width: 34px; height: 34px; font-size: 16px; } + .cv-status { max-width: 58%; } +} +@container (max-width: 170px) { .cv-ico { width: 28px; height: 28px; font-size: 14px; } .cv-status { font-size: 0; gap: 0; } .cv-status i { width: 8px; height: 8px; } } /* ============================================================ phone dashboards */ body.phone-ui:not(.peek) #topbar { display: none; } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index 5ae4f75..3323fa9 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -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 = ``; + // 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) => ``; + return ``; + } 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 `
${t("Set the car’s level, range or lock entity in the inspector.")}
`; 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 ``; })() : ""; - const gauge = `
- -
${range ? `${esc(fmtNum(range.state))}${esc(range.attributes.unit_of_measurement || "km")}` : ""} - ${pct != null ? `${charging ? Icons.svg("bolt", "inline") : ""}${pct}%${low ? ` · ${t("Low")}` : ""}` : ""}
-
${c.image ? `` : CAR_ART}
`; + const hero = pct != null + ? `
${pct}%
+
${range ? `
${esc(fmtNum(range.state))}${esc(unit)}
` : ""}${sub ? `
${charging ? Icons.svg("bolt", "inline") : ""}${esc(sub)}
` : ""}
` + : range ? `
${esc(fmtNum(range.state))}${esc(unit)}
${sub && sub !== t("Range") ? `
${esc(sub)}
` : ""}` : ""; - const btn = (act, eid, on, ico, label, sub) => ``; - 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 ? `` : ""; + const art = c.image ? `
` : 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 = "" } = {}) => + ``; + 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 ? `` - : 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 && `${Icons.svg("clock")}${esc(fullAt)}`, odo && `${Icons.svg("road")}${esc(fmtState(odo))}`].filter(Boolean).join(""); + const stat = (k, v) => `
${esc(k)}${esc(v)}
`; + 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 `
-
${esc(c.name || t("Car"))}${status ? `${esc(status)}` : ""}
- ${gauge} -
${chips ? `
${chips}
` : ""}${buttons.length ? `
${buttons.join("")}
` : ""}
+ const cls = [charging && "charging", plugged && "plugged", low && "low", fuel && "fuel", c.image && "has-img"].filter(Boolean).join(" "); + return `
+
${esc(c.name || t("Car"))}${status ? `${esc(status)}` : ""}
+
${hero}
+
${art}${bar}
+ ${stats ? `
${stats}
` : ""} + ${ctrls.length ? `
${ctrls.join("")}
` : ""}
`; }, };