diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index c595c6d..0d122b6 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.13.0 + +- **Car card redesigned in the style of the Tesla app,** with a square and a wide design. The square card shows the car from the front, the wide card from the side with the numbers beside it. Both have the battery and range, a green charging bar with the power, time left and charge limit, and plain icon buttons for the doors, climate, charging and location. Tall square cards add rows for the climate, location and odometer. +- **Car color:** paint the car in your own color. +- **Layout:** choose the square or wide design yourself, or let the card pick one from its shape. + ## 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. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 634cdb3..671c8ae 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -163,7 +163,12 @@ 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 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/…`. +The card follows the style of the Tesla app, with two designs: + +- **Square** (for example 3×4): the car from the front in its own color, with the name and a status such as *Charging · Home* or *Parked · Away* at the top left, and a battery icon with the charge level and range at the top right. While the car is plugged in, a green charging bar shows the charging power, the time left and the charge limit. Plain icon buttons along the bottom lock or unlock the doors (orange while unlocked), switch the climate, start or stop charging and show the location. A tall square card adds rows for the climate (with the inside temperature), the location and the odometer; tap a row for details. +- **Wide** (for example 6×3 or 8×4): the car from the side on the right, with the name, the charge level and range in large numbers, and the charging bar on the left. The buttons sit under the car. + +**Layout** picks the design: *Automatic* (the default) uses the wide design when the card is clearly wider than it is tall. **Car color** paints the car in any color, for example `#b3261e` for red; it's silver by default. A short card shows the car smaller from the side, and a very short card just the battery and the charging bar. With your own car photo (by URL), the photo replaces the drawing. 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 42a4398..b3d3b94 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.12.1" +version: "1.13.0" 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 7a75983..efac4e0 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -385,6 +385,19 @@ "Low fuel": "Bijna leeg", "Charge": "Laden", "Inside": "Binnen", + "Car color": "Kleur van de auto", + "Silver": "Zilver", + "square": "Vierkant", + "wide": "Breed", + "Parked": "Geparkeerd", + "Climate off": "Klimaat uit", + "{0} h {1} min left": "Nog {0} u {1} min", + "{0} min left": "Nog {0} min", + "Limit {0}%": "Limiet {0}%", + "Locked, tap to unlock": "Op slot, tik om te ontgrendelen", + "Unlocked, tap to lock": "Open, tik om op slot te doen", + "Location: {0}": "Locatie: {0}", + "Inside {0}°": "Binnen {0}°", "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 6a4668a..e6989c3 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -974,88 +974,125 @@ 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: 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; } +/* car: in the style of the Tesla app. Square cards show the car from the front, wide ones from the side. */ +.tz { --tz-c: var(--d-text); --tz-paint: color-mix(in srgb, var(--d-text) 22%, #b9bec6); + display: grid; grid-template-areas: "head" "art" "charge" "ctrls" "rows"; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto auto auto; row-gap: 10px; height: 100%; min-height: 0; min-width: 0; } +.tz.charging { --tz-c: var(--ok); } +.tz.low { --tz-c: var(--crit); } +.tz.fuel:not(.low) { --tz-c: var(--warn); } +.tz-head { grid-area: head; display: flex; align-items: flex-start; gap: 10px; min-width: 0; } +.tz-title { flex: 1; min-width: 0; } +.tz-name { font-size: clamp(17px, 7.5cqw, 24px); font-weight: 650; letter-spacing: -0.02em; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tz-status { margin-top: 2px; font-size: 13px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tz.charging .tz-status { color: var(--ok); } +.tz-batt { display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 6px; font-size: 14px; font-weight: 600; flex: none; padding-top: 3px; } +.tz-brange { grid-column: 1 / -1; justify-self: end; font-size: 12px; font-weight: 500; color: var(--d-text-2); } +.tz-bicon { width: 28px; height: 14px; display: block; } +.tz-bshell { fill: none; stroke: var(--d-text-2); stroke-width: 1.5; } +.tz-blevel { fill: var(--tz-c); } +.tz-bnub { stroke: var(--d-text-2); stroke-width: 2; stroke-linecap: round; } +.tz-bbolt { fill: var(--d-tile); stroke: var(--d-tile); stroke-width: .6; stroke-linejoin: round; } +.tz-big { grid-area: big; display: none; flex-direction: column; gap: 2px; } +.tz-pct { font-size: clamp(40px, 10cqw, 64px); font-weight: 700; letter-spacing: -0.045em; line-height: .9; } +.tz-pct small { font-size: .42em; font-weight: 600; color: var(--d-text-2); margin-left: 2px; letter-spacing: 0; } +.tz-rng { font-size: 15px; font-weight: 500; color: var(--d-text-2); } +.tz-art { grid-area: art; min-height: 0; display: flex; align-items: center; justify-content: center; } +.tz-front, .tz-side { width: 100%; height: 100%; min-height: 0; } +.tz-front { max-height: 190px; } .tz-side { display: none; max-height: 200px; } +.tz-img { max-width: 100%; max-height: 100%; object-fit: contain; filter: drop-shadow(0 10px 14px rgba(0, 0, 0, .3)); } +.tz-paint { fill: var(--tz-paint); } +.tz-shadow { fill: #000; opacity: .3; } +.tz.charging .tz-shadow { fill: var(--ok); opacity: .4; } +.tz-tyre { fill: #16171a; } +.tz-rim { fill: #3b3e45; } .tz-spokes { stroke: #6d717a; stroke-width: 1.6; } .tz-hub { fill: #9a9ea6; } +.tz-crease { fill: none; stroke: #000; stroke-opacity: .16; stroke-width: 1; } +.tz-handle { stroke: #000; stroke-opacity: .25; stroke-width: 1.6; stroke-linecap: round; } +.tz-lamp { fill: #f3f6fb; } .tz-lamp-s { stroke: #f3f6fb; stroke-width: 2.4; stroke-linecap: round; } +.tz-tail { stroke: #e0353b; stroke-width: 2.4; stroke-linecap: round; } +.tz-intake { fill: #15161a; opacity: .85; } +.tz-port { fill: var(--ok); stroke: var(--d-tile); stroke-width: 1.5; } +.tz.charging .tz-port, .tz.charging .tz-bar i { animation: tz-pulse 2s ease-in-out infinite; } +@keyframes tz-pulse { 50% { opacity: .55; } } +.tz-charge { grid-area: charge; display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.tz-charge.tz-idle { display: none; } +.tz-cl, .tz-cs { display: flex; justify-content: space-between; gap: 8px; min-width: 0; } +.tz-ct { font-size: 13px; font-weight: 600; white-space: nowrap; flex: none; } +.tz.charging .tz-ct { color: var(--ok); } +.tz-ct .num { color: var(--d-text-2); font-weight: 500; margin-left: 4px; } +.tz-cr { min-width: 0; font-size: 13px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tz-cs { font-size: 11px; color: var(--d-text-2); } +.tz-bar { position: relative; height: 6px; border-radius: 99px; background: color-mix(in srgb, var(--d-text) 12%, transparent); } +.tz-bar i { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--tz-c); } +.tz-bar b { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); } +.tz-ctrls { grid-area: ctrls; display: flex; justify-content: space-around; gap: 4px; } +.tz-btn { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; font-size: 22px; color: var(--d-text-2); transition: color .2s, background-color .2s; } +.tz-btn:hover { background: var(--d-fill); } +.tz-btn.on { color: var(--d-accent); } +.tz-btn.ok { color: var(--ok); } +.tz-btn.warn { color: var(--warn); } +.tz-rows { grid-area: rows; display: none; flex-direction: column; border-top: 1px solid var(--d-separator); } +.tz-row { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 0 2px; border-bottom: 1px solid var(--d-separator); text-align: left; width: 100%; } +.tz-row:last-child { border-bottom: 0; } +.tz-row > .i:first-child { font-size: 19px; color: var(--d-text-2); } +.tz-rt { flex: 1; font-size: 15px; font-weight: 500; } +.tz-rs { font-size: 13px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 55%; } +.tz-row > .i:last-child { font-size: 14px; color: var(--d-text-2); } +@media (prefers-reduced-motion: reduce) { .tz.charging .tz-port, .tz.charging .tz-bar i { animation: none; } } +/* a standard square card keeps its room for the car */ +@container (max-height: 380px) { .tz.tz-sq .tz-cs { display: none; } } +@container (max-height: 380px) and (max-aspect-ratio: 8 / 5) { .tz.tz-auto .tz-cs { display: none; } } +/* tall square cards list the details, like the Tesla app's menu */ +@container (min-height: 470px) { .tz:not(.tz-wide) .tz-rows { display: flex; } } +/* short: no drawing, just the numbers and the bar */ +@container (max-height: 280px) { + .tz.tz-sq .tz-front { display: none; } + .tz.tz-sq .tz-side { display: block; } + .tz-btn { width: 40px; height: 40px; font-size: 19px; } } -@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-height: 280px) and (max-aspect-ratio: 8 / 5) { + .tz.tz-auto .tz-front { display: none; } + .tz.tz-auto .tz-side { display: block; } } -@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: 210px) { + .tz-head { flex-direction: column; gap: 4px; } + .tz-batt { display: flex; flex-wrap: wrap; padding-top: 0; } + .tz-brange { flex-basis: 100%; } + .tz-cs, .tz-ct .num { display: none; } + .tz-btn { width: 36px; height: 36px; font-size: 18px; } } -@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; } } +/* wide: the car from the side on the right, the numbers on the left */ +.tz.tz-wide { grid-template-areas: "head art" "big art" "charge ctrls"; grid-template-columns: minmax(170px, .8fr) minmax(0, 1.2fr); grid-template-rows: auto minmax(0, 1fr) auto; column-gap: 24px; } +.tz.tz-wide .tz-batt { display: none; } +.tz.tz-wide .tz-big { display: flex; } +.tz.tz-wide .tz-front { display: none; } +.tz.tz-wide .tz-side { display: block; } +.tz.tz-wide .tz-charge { align-self: end; } +.tz.tz-wide .tz-ctrls { justify-content: center; align-self: end; } +.tz.tz-wide .tz-rows { display: none; } +.tz.tz-wide .tz-art { align-self: stretch; } +.tz.tz-wide .tz-big { align-self: start; } +@container (min-aspect-ratio: 8 / 5) { + .tz.tz-auto { grid-template-areas: "head art" "big art" "charge ctrls"; grid-template-columns: minmax(170px, .8fr) minmax(0, 1.2fr); grid-template-rows: auto minmax(0, 1fr) auto; column-gap: 24px; } + .tz.tz-auto .tz-batt { display: none; } + .tz.tz-auto .tz-big { display: flex; } + .tz.tz-auto .tz-front { display: none; } + .tz.tz-auto .tz-side { display: block; } + .tz.tz-auto .tz-charge { align-self: end; } + .tz.tz-auto .tz-ctrls { justify-content: center; align-self: end; } + .tz.tz-auto .tz-rows { display: none; } + .tz.tz-auto .tz-art { align-self: stretch; } +.tz.tz-auto .tz-big { align-self: start; } +} + +/* very short, whatever the width: name, battery and the bar */ +@container (max-height: 170px) { + .tz.tz { grid-template-areas: "head" "charge" "ctrls"; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto; } + .tz.tz .tz-art, .tz.tz .tz-big, .tz.tz .tz-cs, .tz.tz .tz-rows { display: none; } + .tz.tz .tz-batt { display: grid; } + .tz.tz .tz-charge, .tz.tz .tz-charge.tz-idle { display: flex; align-self: center; } +} +@container (max-height: 120px) { .tz.tz .tz-ctrls { display: none; } } /* ============================================================ 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 3323fa9..a8f301a 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -1134,30 +1134,42 @@ // ---------------------------------------------------------------- car const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase())); - // 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 + // Inspired by the Tesla app: the car in its own color as the centerpiece, the battery top right, + // a green bar while charging and plain icon buttons. A square card shows the car from the front, + // a wide card from the side with the details on the left. Container queries pick one (or the Layout field does). + const SIDE_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 FRONT_BODY = "M16 90C14 76 18 66 30 61L52 52C68 47 132 47 148 52L170 61C182 66 186 76 184 90V96C184 101 180 104 175 104H25C20 104 16 101 16 96Z"; - 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 ` - - - - - - ${o.charging ? `` : ""} - ${lx != null ? `` : ""} - - - - - - ${o.plugged ? `` : ""} + function carFront(id) { + return ` + + + + + + + + + + + + + + `; + } + function carSide(id, o) { + const wheel = (cx) => ` + `; + return ` + + + + + + + + + ${o.plugged ? `` : ""} ${wheel(58)}${wheel(184)}`; } @@ -1166,6 +1178,8 @@ fields: [ { k: "name", t: "text", l: "Name", d: "My car" }, { k: "image", t: "text", l: "Car image URL (optional, a side view with a transparent background looks best)", ph: "https://… or /local/car.png" }, + { k: "layout", t: "seg", l: "Layout", o: ["auto", "square", "wide"], d: "auto" }, + { k: "paint", t: "color", l: "Car color", ph: "Silver" }, { k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" }, { k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] }, { k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] }, @@ -1193,60 +1207,61 @@ 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" : ""; + const rangeTxt = range ? `${fmtNum(range.state)} ${unit}` : ""; + const climOn = clim && (isOn(clim) || clim.state === "on"); + const inside = clim?.attributes.current_temperature; + const id = esc(w?.id || "x"); - // 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); + // status under the name, the way the Tesla app says it + const place = loc ? (loc.state === "home" ? t("Home") : loc.state === "not_home" ? t("Away") : st(loc.state)) : ""; + const status = [charging ? t("Charging") : plugged ? t("Plugged in") : loc ? t("Parked") : "", place, climOn && t("Climate on")].filter(Boolean).join(" · "); - // the line under the range: when it will be full, or how far it charges - let sub = ""; + // time left on the charge + let left = "", fullAt = ""; 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) sub = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })); + if (isFinite(mins) && mins > 0) { + left = mins >= 60 ? t("{0} h {1} min left", Math.floor(mins / 60), Math.round(mins % 60)) : t("{0} min left", Math.round(mins)); + fullAt = 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") : ""; - 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 p = Math.max(0, Math.min(100, pct ?? 0)); + const battIcon = pct != null ? `${charging ? `` : ""}` : ""; + const batt = `${battIcon}${pct != null ? `${pct}%` : ""}${rangeTxt ? `${esc(rangeTxt)}` : ""}`; + const big = `${pct != null ? `${pct}%` : ""}${rangeTxt ? `${esc(rangeTxt)}` : ""}`; - const bar = pct != null ? `${limPct != null ? `` : ""}` : ""; - const art = c.image ? `` : carArt(esc(w?.id || "x"), pct, limPct, { charging, plugged }); + // the charging bar: green while charging, with the charge limit marked + const bar = pct != null ? `${limPct != null ? `` : ""}` : ""; + const chargeInfo = plugged + ? `${charging ? t("Charging") : t("Plugged in")}${charging && power && Number(power.state) > 0 ? ` ${esc(fmtState(power))}` : ""}${esc(left || (limPct != null ? t("Charges to {0}%", Math.round(limPct)) : ""))}${bar}${fullAt || limPct != null ? `${esc(fullAt)}${limPct != null ? esc(t("Limit {0}%", Math.round(limPct))) : ""}` : ""}` + : `${bar}`; - // controls: each shows what it controls and its state - const ctrl = (act, eid, ico, label, { on = false, warn = false, svc = "" } = {}) => - ` - ${Icons.svg(ico)}${esc(label)}`; + // icon buttons + const ctrl = (act, eid, ico, label, cls = "", extra = "") => `${Icons.svg(ico)}`; 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; - ctrls.push(ctrl("toggle", c.climate, on ? "fan" : "snowflake", inside != null ? `${fmtNum(inside)}°` : on ? t("Climate on") : t("Climate"), { on })); - } - if (chg && !fuel) { - 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) ctrls.push(ctrl("more", c.location, "pin", fmtState(loc))); + if (lock) { const locked = lock.state === "locked"; ctrls.push(ctrl("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked, tap to unlock") : t("Unlocked, tap to lock"), locked ? "" : "warn", `aria-pressed="${!locked}"`)); } + if (clim) ctrls.push(ctrl("toggle", c.climate, "fan", climOn ? t("Climate on") : t("Climate off"), climOn ? "on" : "", `aria-pressed="${climOn}"`)); + if (chg && !fuel) ctrls.push(domainOf(c.chargeSwitch) === "button" ? ctrl("svc", c.chargeSwitch, "bolt", t("Start charging"), "", `data-v="button.press"`) + : ctrl("toggle", c.chargeSwitch, "bolt", charging ? t("Stop charging") : t("Start charging"), charging ? "ok" : "", `aria-pressed="${charging}"`)); + if (loc) ctrls.push(ctrl("more", c.location, "pin", t("Location: {0}", place))); - 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(""); + // detail rows, for cards with room to spare + const row = (ico, title, sub, act, eid) => `<${act ? `button class="tz-row wbtn" data-act="${act}" data-e="${esc(eid)}"` : `div class="tz-row"`}>${Icons.svg(ico)}${esc(title)}${esc(sub)}${act ? Icons.svg("chevron", "fwd") : ""}${act ? "button" : "div"}>`; + const rows = [clim && row("thermometer", t("Climate"), [inside != null && t("Inside {0}°", fmtNum(inside)), climOn ? t("On") : t("Off")].filter(Boolean).join(" · "), "more", c.climate), + loc && row("pin", t("Location"), place, "more", c.location), odo && row("road", t("Odometer"), fmtState(odo))].filter(Boolean).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("")}` : ""} + const art = c.image ? `` : `${carFront(id)}${carSide(id, { plugged })}`; + const lay = c.layout === "square" ? "tz-sq" : c.layout === "wide" ? "tz-wide" : "tz-auto"; + const cls = [lay, charging && "charging", plugged && "plugged", low && "low", fuel && "fuel"].filter(Boolean).join(" "); + const paint = /^#[0-9a-f]{3,8}$|^rgb|^hsl|^[a-z]+$/i.test(c.paint || "") ? ` style="--tz-paint:${esc(c.paint)}"` : ""; + return ` + ${esc(c.name || t("Car"))}${status ? `${esc(status)}` : ""}${batt} + ${big} + ${art} + ${chargeInfo} + ${ctrls.length ? `${ctrls.join("")}` : ""} + ${rows ? `${rows}` : ""} `; }, };