Dashboard Studio 1.13.0: Tesla-style car card with square and wide designs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
+116
-79
@@ -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; }
|
||||
|
||||
@@ -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) => `<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"/>` : ""}
|
||||
function carFront(id) {
|
||||
return `<svg class="tz-front" viewBox="0 0 200 116" aria-hidden="true">
|
||||
<defs><linearGradient id="tzs-${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>
|
||||
<radialGradient id="tzg-${id}" cx=".5" cy=".2" r=".9"><stop offset="0" stop-color="#8fa3bf" stop-opacity=".55"/><stop offset="1" stop-color="#0d1117" stop-opacity=".95"/></radialGradient></defs>
|
||||
<ellipse cx="100" cy="108" rx="90" ry="5" class="tz-shadow"/>
|
||||
<rect x="19" y="78" width="22" height="30" rx="6" class="tz-tyre"/><rect x="159" y="78" width="22" height="30" rx="6" class="tz-tyre"/>
|
||||
<path d="M50 54C54 36 64 24 80 21C92 19 108 19 120 21C136 24 146 36 150 54Z" class="tz-paint"/>
|
||||
<path d="${FRONT_BODY}" class="tz-paint"/>
|
||||
<path d="M50 54C54 36 64 24 80 21C92 19 108 19 120 21C136 24 146 36 150 54Z" fill="url(#tzs-${id})"/>
|
||||
<path d="${FRONT_BODY}" fill="url(#tzs-${id})"/>
|
||||
<path d="M57 52C61 38 68 29 81 26C93 24 107 24 119 26C132 29 139 38 143 52C128 49.5 72 49.5 57 52Z" fill="url(#tzg-${id})"/>
|
||||
<path d="M50 55L38 52C35 51.5 34 54 36 55.5L48 59ZM150 55L162 52C165 51.5 166 54 164 55.5L152 59Z" class="tz-paint"/>
|
||||
<path d="M70 61C82 58 118 58 130 61" class="tz-crease"/>
|
||||
<path d="M26 69C34 66 46 65 58 67L56 71C46 71 36 72 28 73ZM174 69C166 66 154 65 142 67L144 71C154 71 164 72 172 73Z" class="tz-lamp"/>
|
||||
<rect x="64" y="84" width="72" height="8" rx="4" class="tz-intake"/>
|
||||
<path d="M24 100H176" class="tz-crease"/></svg>`;
|
||||
}
|
||||
function carSide(id, o) {
|
||||
const wheel = (cx) => `<circle cx="${cx}" cy="70" r="15" class="tz-tyre"/><circle cx="${cx}" cy="70" r="10" class="tz-rim"/>
|
||||
<path d="M${cx} 61V79M${cx - 9} 70H${cx + 9}M${cx - 6.4} 63.6L${cx + 6.4} 76.4M${cx - 6.4} 76.4L${cx + 6.4} 63.6" class="tz-spokes"/><circle cx="${cx}" cy="70" r="2.6" class="tz-hub"/>`;
|
||||
return `<svg class="tz-side" viewBox="0 0 240 92" aria-hidden="true">
|
||||
<defs><linearGradient id="tzd-${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".4"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>
|
||||
<linearGradient id="tzw-${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8fa3bf" stop-opacity=".6"/><stop offset="1" stop-color="#0d1117" stop-opacity=".95"/></linearGradient></defs>
|
||||
<ellipse cx="121" cy="86" rx="112" ry="3.5" class="tz-shadow"/>
|
||||
<path d="${SIDE_BODY}" class="tz-paint"/><path d="${SIDE_BODY}" fill="url(#tzd-${id})"/>
|
||||
<path d="M164 40C154 32 144 26 132 24C120 22 104 22 92 23L94 40ZM88 23C74 25 60 31 48 40H90Z" fill="url(#tzw-${id})"/>
|
||||
<path d="M34 52L212 49M93 41L95 66M150 42L152 64" class="tz-crease"/>
|
||||
<path d="M118 46H128M66 47H76" class="tz-handle"/>
|
||||
<path d="M220 49.5L230 53" class="tz-lamp-s"/><path d="M8.5 49L17 47" class="tz-tail"/>
|
||||
${o.plugged ? `<circle cx="24" cy="51" r="3.4" class="tz-port"/>` : ""}
|
||||
${wheel(58)}${wheel(184)}</svg>`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `<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 p = Math.max(0, Math.min(100, pct ?? 0));
|
||||
const battIcon = pct != null ? `<svg class="tz-bicon" viewBox="0 0 28 14" aria-hidden="true"><rect x=".75" y=".75" width="23.5" height="12.5" rx="3.2" class="tz-bshell"/><rect x="2.5" y="2.5" width="${(20 * p / 100).toFixed(1)}" height="9" rx="1.6" class="tz-blevel"/><path d="M26 4.8v4.4" class="tz-bnub"/>${charging ? `<path d="M13.6 2.6 9.8 7.6h3.4l-.8 3.8 3.8-5h-3.4z" class="tz-bbolt"/>` : ""}</svg>` : "";
|
||||
const batt = `<div class="tz-batt">${battIcon}${pct != null ? `<span class="num">${pct}%</span>` : ""}${rangeTxt ? `<span class="tz-brange num">${esc(rangeTxt)}</span>` : ""}</div>`;
|
||||
const big = `<div class="tz-big">${pct != null ? `<span class="tz-pct num">${pct}<small>%</small></span>` : ""}${rangeTxt ? `<span class="tz-rng num">${esc(rangeTxt)}</span>` : ""}</div>`;
|
||||
|
||||
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 });
|
||||
// the charging bar: green while charging, with the charge limit marked
|
||||
const bar = pct != null ? `<div class="tz-bar"><i style="width:${p}%"></i>${limPct != null ? `<b style="left:${Math.max(0, Math.min(100, limPct))}%"></b>` : ""}</div>` : "";
|
||||
const chargeInfo = plugged
|
||||
? `<div class="tz-charge"><div class="tz-cl"><span class="tz-ct">${charging ? t("Charging") : t("Plugged in")}${charging && power && Number(power.state) > 0 ? ` <span class="num">${esc(fmtState(power))}</span>` : ""}</span><span class="tz-cr num">${esc(left || (limPct != null ? t("Charges to {0}%", Math.round(limPct)) : ""))}</span></div>${bar}${fullAt || limPct != null ? `<div class="tz-cs"><span>${esc(fullAt)}</span><span>${limPct != null ? esc(t("Limit {0}%", Math.round(limPct))) : ""}</span></div>` : ""}</div>`
|
||||
: `<div class="tz-charge tz-idle">${bar}</div>`;
|
||||
|
||||
// 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>`;
|
||||
// icon buttons
|
||||
const ctrl = (act, eid, ico, label, cls = "", extra = "") => `<button class="tz-btn wbtn ${cls}" data-act="${act}" data-e="${esc(eid)}" ${extra} aria-label="${esc(label)}" title="${esc(label)}">${Icons.svg(ico)}</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;
|
||||
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) => `<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("");
|
||||
// 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)}<span class="tz-rt">${esc(title)}</span><span class="tz-rs num">${esc(sub)}</span>${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 `<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>` : ""}
|
||||
const art = c.image ? `<img class="tz-img" src="${esc(c.image)}" alt="">` : `${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 `<div class="tz ${cls}"${paint}>
|
||||
<div class="tz-head"><div class="tz-title"><div class="tz-name">${esc(c.name || t("Car"))}</div>${status ? `<div class="tz-status">${esc(status)}</div>` : ""}</div>${batt}</div>
|
||||
${big}
|
||||
<div class="tz-art">${art}</div>
|
||||
${chargeInfo}
|
||||
${ctrls.length ? `<div class="tz-ctrls">${ctrls.join("")}</div>` : ""}
|
||||
${rows ? `<div class="tz-rows">${rows}</div>` : ""}
|
||||
</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user