Analog clock: CSS animation and seconds motion options (#26943)

* Increase analog clock interval and improve accuracy

* Restore

* Use CSS to render hands instead of JS interval with resync to adjust offsets

* Option

* Remove

* Option

* Fix
pull/27151/head
Aidan Timson 2025-09-24 06:36:39 +01:00 committed by GitHub
parent 3d173ad03e
commit 88ac56ac0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 121 additions and 33 deletions

View File

@ -4,7 +4,6 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { INTERVAL } from "../hui-clock-card";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
function romanize12HourClock(num: number) {
@ -35,13 +34,11 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _dateTimeFormat?: Intl.DateTimeFormat;
@state() private _hourDeg?: number;
@state() private _hourOffsetSec?: number;
@state() private _minuteDeg?: number;
@state() private _minuteOffsetSec?: number;
@state() private _secondDeg?: number;
private _tickInterval?: undefined | number;
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
@ -63,7 +60,7 @@ export class HuiClockCardAnalog extends LitElement {
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._tick();
this._computeOffsets();
}
protected updated(changedProps: PropertyValues) {
@ -77,27 +74,25 @@ export class HuiClockCardAnalog extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._startTick();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._stopTick();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
}
private _startTick() {
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
this._tick();
}
private _stopTick() {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = undefined;
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
}
}
};
private _tick() {
private _computeOffsets() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
@ -108,10 +103,14 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const secondsWithMs = second + ms / 1000;
this._hourDeg = hour * 30 + minute * 0.5; // 30deg per hour + 0.5deg per minute
this._minuteDeg = minute * 6 + second * 0.1; // 6deg per minute + 0.1deg per second
this._secondDeg = this.config?.show_seconds ? second * 6 : undefined; // 6deg per second
const hour12 = hour % 12;
this._secondOffsetSec = secondsWithMs;
this._minuteOffsetSec = minute * 60 + secondsWithMs;
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
}
render() {
@ -212,16 +211,24 @@ export class HuiClockCardAnalog extends LitElement {
<div class="center-dot"></div>
<div
class="hand hour"
style=${`--hand-rotation: ${this._hourDeg ?? 0}deg;`}
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
></div>
<div
class="hand minute"
style=${`--hand-rotation: ${this._minuteDeg ?? 0}deg;`}
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
></div>
${this.config.show_seconds
? html`<div
class="hand second"
style=${`--hand-rotation: ${this._secondDeg ?? 0}deg;`}
class=${classMap({
hand: true,
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
></div>`
: nothing}
</div>
@ -350,10 +357,13 @@ export class HuiClockCardAnalog extends LitElement {
left: 50%;
bottom: 50%;
transform-origin: 50% 100%;
transform: translate(-50%, 0) rotate(var(--hand-rotation, 0deg));
transform: translate(-50%, 0) rotate(0deg);
background: var(--primary-text-color);
border-radius: 2px;
will-change: transform;
animation-name: ha-clock-rotate;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.hand.hour {
@ -362,6 +372,7 @@ export class HuiClockCardAnalog extends LitElement {
background: var(--primary-text-color);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.2);
z-index: 1;
animation-duration: 43200s; /* 12 hours */
}
.hand.minute {
@ -371,6 +382,7 @@ export class HuiClockCardAnalog extends LitElement {
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.2);
opacity: 0.9;
z-index: 3;
animation-duration: 3600s; /* 60 minutes */
}
.hand.second {
@ -380,6 +392,20 @@ export class HuiClockCardAnalog extends LitElement {
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.2);
opacity: 0.8;
z-index: 2;
animation-duration: 60s; /* 60 seconds */
}
.hand.second.step {
animation-timing-function: steps(60, end);
}
@keyframes ha-clock-rotate {
from {
transform: translate(-50%, 0) rotate(0deg);
}
to {
transform: translate(-50%, 0) rotate(360deg);
}
}
`;
}

View File

@ -3,10 +3,11 @@ import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { INTERVAL } from "../hui-clock-card";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
const INTERVAL = 1000;
@customElement("hui-clock-card-digital")
export class HuiClockCardDigital extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;

View File

@ -11,8 +11,6 @@ import type {
} from "../types";
import type { ClockCardConfig } from "./types";
export const INTERVAL = 1000;
@customElement("hui-clock-card")
export class HuiClockCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {

View File

@ -375,6 +375,7 @@ export interface ClockCardConfig extends LovelaceCardConfig {
clock_style?: "digital" | "analog";
clock_size?: "small" | "medium" | "large";
show_seconds?: boolean | undefined;
seconds_motion?: "continuous" | "tick";
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;

View File

@ -52,6 +52,12 @@ const cardConfigStruct = assign(
literal("hour")
)
),
seconds_motion: optional(
defaulted(
union([literal("continuous"), literal("tick")]),
literal("continuous")
)
),
face_style: optional(
defaulted(
union([
@ -78,7 +84,8 @@ export class HuiClockCardEditor
(
localize: LocalizeFunc,
clockStyle: ClockCardConfig["clock_style"],
ticks: ClockCardConfig["ticks"]
ticks: ClockCardConfig["ticks"],
showSeconds: boolean | undefined
) =>
[
{ name: "title", selector: { text: {} } },
@ -170,6 +177,33 @@ export class HuiClockCardEditor
},
},
},
...(showSeconds
? ([
{
name: "seconds_motion",
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
),
},
default: "continuous",
selector: {
select: {
mode: "dropdown",
options: ["continuous", "tick"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
),
description: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
),
})),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
...(ticks !== "none"
? ([
{
@ -257,7 +291,8 @@ export class HuiClockCardEditor
.schema=${this._schema(
this.hass.localize,
this._data(this._config).clock_style,
this._data(this._config).ticks
this._data(this._config).ticks,
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@ -278,10 +313,17 @@ export class HuiClockCardEditor
ev.detail.value.border = ev.detail.value.border ?? false;
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (ev.detail.value.show_seconds) {
ev.detail.value.seconds_motion =
ev.detail.value.seconds_motion ?? "continuous";
} else {
delete ev.detail.value.seconds_motion;
}
} else {
delete ev.detail.value.border;
delete ev.detail.value.ticks;
delete ev.detail.value.face_style;
delete ev.detail.value.seconds_motion;
}
if (ev.detail.value.ticks !== "none") {
@ -333,6 +375,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.ticks.label`
);
case "seconds_motion":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.label`
);
case "face_style":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.face_style.label`
@ -354,6 +400,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.ticks.description`
);
case "seconds_motion":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
);
case "face_style":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.face_style.description`

View File

@ -7830,6 +7830,18 @@
"description": "60 ticks (Every minute)"
}
},
"seconds_motion": {
"label": "Seconds motion",
"description": "How the seconds hand moves on the clock",
"continuous": {
"label": "Continuous",
"description": "The seconds hand moves continuously"
},
"tick": {
"label": "Tick",
"description": "The seconds hand moves in 1 second steps"
}
},
"face_style": {
"label": "Clock face style",
"description": "Which kind of indices to use for the clock face",