Compare commits

..

6 Commits

Author SHA1 Message Date
Paul Bottein 9be045b55d Add reference floor to precipitation bar scale
Light drizzle no longer fills the bar when it's also the period max.
Observed values above the floor still drive the scale (storms read full).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:36 +02:00
Paul Bottein 46cc813264 Update palette 2026-05-19 15:28:12 +02:00
Paul Bottein c12efd150f Fixes and cleaning 2026-05-18 15:10:01 +02:00
Paul Bottein 675a67599a Some fixes 2026-05-18 15:10:01 +02:00
Paul Bottein c5c2ddd177 Add show labels option 2026-05-18 15:10:00 +02:00
Paul Bottein cf86f7a819 Rework weather forecast card features 2026-05-18 15:10:00 +02:00
17 changed files with 1899 additions and 119 deletions
+2 -2
View File
@@ -75,8 +75,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.0",
"@tsparticles/preset-links": "4.0.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
+4
View File
@@ -114,6 +114,10 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
export const UNIT_C = "°C";
export const UNIT_F = "°F";
/** Length units. */
export const UNIT_MM = "mm";
export const UNIT_IN = "in";
/** Entity ID of the default view. */
export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
@@ -4,6 +4,7 @@ import {
mdiAlertCircleCheck,
mdiAppleKeyboardCommand,
mdiArrowDown,
mdiArrowRightThin,
mdiArrowUp,
mdiCheckboxBlankOutline,
mdiCheckboxOutline,
@@ -332,6 +333,10 @@ export default class HaAutomationActionRow extends LitElement {
${type !== "condition" &&
(this.action as NonConditionAction).continue_on_error === true
? html`<ha-svg-icon
class="arrow-right"
.path=${mdiArrowRightThin}
></ha-svg-icon
><ha-svg-icon
id="svg-icon"
.path=${mdiAlertCircleCheck}
></ha-svg-icon>
@@ -1158,6 +1163,9 @@ export default class HaAutomationActionRow extends LitElement {
rowStyles,
overflowStyles,
css`
ha-svg-icon.arrow-right {
--icon-primary-color: var(--ha-color-fill-neutral-loud-resting);
}
ha-svg-icon#svg-icon {
--icon-primary-color: var(--ha-color-fill-neutral-loud-active);
}
@@ -0,0 +1,34 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import {
getDefaultForecastType,
getSupportedForecastTypes,
WeatherEntityFeature,
} from "../../../../data/weather";
import type { ForecastResolution } from "../types";
export const DEFAULT_DAYS_TO_SHOW = 7;
export const DEFAULT_HOURS_TO_SHOW = 24;
export const MS_PER_HOUR = 60 * 60 * 1000;
export const supportsForecast = (stateObj: HassEntity | undefined): boolean => {
if (!stateObj) return false;
if (computeDomain(stateObj.entity_id) !== "weather") return false;
return (
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_TWICE_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY)
);
};
export const resolveForecastResolution = (
stateObj: HassEntity | undefined,
configured?: ForecastResolution
): ForecastResolution | undefined => {
if (!stateObj) return undefined;
const supported = getSupportedForecastTypes(stateObj);
if (configured && supported.includes(configured)) return configured;
return getDefaultForecastType(stateObj);
};
@@ -0,0 +1,81 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import memoizeOne from "memoize-one";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import type { FrontendLocaleData } from "../../../../data/translation";
import { MS_PER_HOUR } from "./forecast";
export const LABEL_HEIGHT = 10;
export const LABEL_GAP = 2;
const narrowWeekdayFormatter = memoizeOne(
(language: string) => new Intl.DateTimeFormat(language, { weekday: "narrow" })
);
const hourFormatter = memoizeOne(
(language: string, amPm: boolean) =>
new Intl.DateTimeFormat(language, {
hour: "numeric",
hourCycle: amPm ? "h12" : "h23",
})
);
export const renderDayLabels = (
entries: { datetime: string }[],
step: number,
locale: FrontendLocaleData
): TemplateResult => {
const formatter = narrowWeekdayFormatter(locale.language);
const labels: string[] = [];
for (let i = 0; i < entries.length; i += step) {
labels.push(formatter.format(new Date(entries[i].datetime)));
}
return html`
<div class="day-labels">
${labels.map((label) => html`<div class="day-label">${label}</div>`)}
</div>
`;
};
export const renderHourLabels = (
hoursToShow: number,
locale: FrontendLocaleData
): TemplateResult => {
const formatter = hourFormatter(locale.language, useAmPm(locale));
const now = Date.now();
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
const step = Math.max(1, Math.round(hoursToShow / 4));
const labels: string[] = [];
for (let h = 0; h <= hoursToShow; h += step) {
const t = now + h * MS_PER_HOUR;
if (t > maxTime) break;
labels.push(formatter.format(new Date(t)));
}
return html`
<div class="hour-labels">
${labels.map((label) => html`<div class="hour-label">${label}</div>`)}
</div>
`;
};
export const graphLabelsStyles: CSSResultGroup = css`
.day-labels,
.hour-labels {
display: flex;
align-items: center;
height: 10px;
color: var(--secondary-text-color);
font-size: 9px;
line-height: 1;
}
.hour-labels {
justify-content: space-between;
}
.day-label {
flex: 1;
text-align: center;
}
`;
@@ -0,0 +1,119 @@
import { hex2rgb, rgb2hex } from "../../../../common/color/convert-color";
import { clamp } from "../../../../common/number/clamp";
type RGB = [number, number, number];
const PALETTE_MIN = -6;
const PALETTE_HEX = [
"#249df2", // -6
"#239dec", // -5
"#239fec", // -4
"#23a3eb", // -3
"#23a6eb", // -2
"#23a9e9", // -1
"#22abe6", // 0
"#22aee4", // 1
"#22b1e0", // 2
"#21b1dd", // 3
"#23b6da", // 4
"#28b8d6", // 5
"#2abdd3", // 6
"#2fbfcf", // 7
"#34c4cc", // 8
"#36c6c9", // 9
"#43c9c0", // 10
"#59c9b3", // 11
"#6fc9a3", // 12
"#82c992", // 13
"#98c985", // 14
"#a8c977", // 15
"#b9c762", // 16
"#c7c74d", // 17
"#d1be31", // 18
"#dbb921", // 19
"#e6ba22", // 20
"#ecc123", // 21
"#ecba23", // 22
"#eeb424", // 23
"#ecaa23", // 24
"#eca023", // 25
"#ec9723", // 26
"#ec8f23", // 27
"#ec8523", // 28
"#ec7c23", // 29
"#ee7f24", // 30
"#e67122", // 31
"#df6321", // 32
"#df5b21", // 33
"#dd5421", // 34
"#db4c21", // 35
"#db4121", // 36
"#db3721", // 37
"#d62f20", // 38
"#cc301f", // 39
"#c22a1d", // 40
"#ca2d1e", // 41
"#c82c1d", // 42
];
const PALETTE_MAX = PALETTE_MIN + PALETTE_HEX.length - 1;
const PALETTE: RGB[] = PALETTE_HEX.map((hex) => hex2rgb(hex));
const paletteAt = (tempC: number): RGB => {
const clamped = Math.max(PALETTE_MIN, Math.min(PALETTE_MAX, tempC));
const idx = clamped - PALETTE_MIN;
const i0 = Math.floor(idx);
const i1 = Math.min(i0 + 1, PALETTE.length - 1);
const frac = idx - i0;
const a = PALETTE[i0];
const b = PALETTE[i1];
return [
a[0] + (b[0] - a[0]) * frac,
a[1] + (b[1] - a[1]) * frac,
a[2] + (b[2] - a[2]) * frac,
];
};
export interface GradientStop {
offset: number;
color: string;
}
const buildStops = (
min: number,
max: number,
nStops: number,
tEffFor: (temp: number) => number
): GradientStop[] => {
const stops: GradientStop[] = [];
for (let i = 0; i < nStops; i++) {
const offset = i / (nStops - 1);
const temp = max + offset * (min - max);
stops.push({ offset, color: rgb2hex(paletteAt(tEffFor(temp))) });
}
return stops;
};
export const getAbsoluteGradient = (
min: number,
max: number,
nStops = 5
): GradientStop[] => buildStops(min, max, nStops, (temp) => temp);
// Compression scales with the midpoint: cool ranges stay near-uniform, warm
// ranges spread wider across the palette.
export const getRelativeGradient = (
min: number,
max: number,
nStops = 5
): GradientStop[] => {
const mid = (min + max) / 2;
const compression = clamp(-0.15 + 0.025 * mid, 0.05, 1);
return buildStops(
min,
max,
nStops,
(temp) => mid + compression * (temp - mid)
);
};
@@ -0,0 +1,542 @@
import { consume } from "@lit/context";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type {
Connection,
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { UNIT_IN } from "../../../common/const";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { FrontendLocaleData } from "../../../data/translation";
import "../../../components/ha-spinner";
import {
connectionContext,
internationalizationContext,
} from "../../../data/context";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import {
getForecastPrecipitation,
subscribeForecast,
} from "../../../data/weather";
import type {
HomeAssistant,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
MS_PER_HOUR,
resolveForecastResolution,
supportsForecast,
} from "./common/forecast";
import {
graphLabelsStyles,
renderDayLabels,
renderHourLabels,
} from "./common/graph-labels";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
PrecipitationForecastCardFeatureConfig,
} from "./types";
const MAX_BAR_WIDTH = 16;
const topRoundedRectPath = (
x: number,
y: number,
width: number,
height: number,
radius: number
): string => {
const r = Math.max(0, Math.min(radius, width / 2, height));
return `M ${x},${y + r} a ${r},${r} 0 0 1 ${r},${-r} h ${width - 2 * r} a ${r},${r} 0 0 1 ${r},${r} v ${height - r} h ${-width} z`;
};
export const supportsPrecipitationForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) =>
supportsForecast(
context.entity_id ? hass.states[context.entity_id] : undefined
);
@customElement("hui-precipitation-forecast-card-feature")
class HuiPrecipitationForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, LocalizeFunc>({
transformer: ({ localize }) => localize,
})
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<HomeAssistantConnection, Connection>({
transformer: ({ connection }) => connection,
})
private _connection!: Connection;
@state() private _config?: PrecipitationForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: ForecastResolution;
private _size = new ResizeController(this, {
callback: (entries) => {
const rect = entries?.[0]?.contentRect;
if (!rect) return undefined;
return { width: rect.width, height: rect.height };
},
});
static getStubConfig(): PrecipitationForecastCardFeatureConfig {
return {
type: "precipitation-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-precipitation-forecast-card-feature-editor");
return document.createElement(
"hui-precipitation-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: PrecipitationForecastCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecast();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeForecast();
}
protected firstUpdated() {
this._subscribeForecast();
}
protected updated(changedProps: PropertyValues) {
if (this._shouldResubscribe(changedProps)) {
this._unsubscribeForecast();
this._subscribeForecast();
}
}
private _shouldResubscribe(changedProps: PropertyValues): boolean {
if (changedProps.has("context")) {
const previous = changedProps.get("context") as
| LovelaceCardFeatureContext
| undefined;
if (previous?.entity_id !== this.context?.entity_id) return true;
}
if (changedProps.has("_config")) {
if (this._resolvedForecastType() !== this._subscribedType) return true;
}
return false;
}
// Floor for the bar scale so light drizzles don't fill the bar; observed
// values above this still drive the scale.
private _referenceMaxAmount(): number {
const isImperial =
this._stateObj?.attributes?.precipitation_unit === UNIT_IN;
switch (this._subscribedType) {
case "hourly":
return isImperial ? 0.1 : 2.5;
case "twice_daily":
return isImperial ? 0.25 : 6;
default:
return isImperial ? 0.4 : 10;
}
}
private _resolvedForecastType(): ForecastResolution | undefined {
return resolveForecastResolution(
this._stateObj,
this._config?.forecast_type
);
}
private get _showLabels(): boolean {
return this._config?.show_labels !== false;
}
protected render() {
if (!this._config || !this.context || !supportsForecast(this._stateObj)) {
return nothing;
}
if (this._error) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.failed_to_load",
{ error: this._error }
)}
</div>
</div>
`;
}
if (!this._forecast) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
const isHourly = this._subscribedType === "hourly";
const precipitationType = this._config.precipitation_type ?? "amount";
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
const fill = customColor ?? "var(--state-weather-rainy-color)";
const containerClasses = classMap({
container: true,
"with-labels": this._showLabels,
});
if (isHourly) {
const hourlyBars = this._renderHourlyBars(precipitationType, fill);
if (!hourlyBars) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.no_forecast"
)}
</div>
</div>
`;
}
const hoursToShow = this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
return html`
<div class=${containerClasses}>
<div class="bars">${hourlyBars}</div>
${this._showLabels && this._locale
? renderHourLabels(hoursToShow, this._locale)
: nothing}
</div>
`;
}
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
const entries = this._forecast.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html`
<div class=${containerClasses}>
<div class="bars">
${this._renderDailyBars(entries, precipitationType, fill)}
</div>
${this._showLabels && this._locale
? renderDayLabels(entries, entriesPerDay, this._locale)
: nothing}
</div>
`;
}
private _renderDailyBars(
entries: ForecastAttribute[],
precipitationType: "amount" | "probability",
fill: string
): TemplateResult {
const width = this._size.value?.width || this.clientWidth;
const height = this._size.value?.height || this.clientHeight;
const padding = 4;
const minGap = 4;
const slotWidth = width / entries.length;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - minGap));
const drawableHeight = height - padding * 2;
const values = entries.map((entry) =>
getForecastPrecipitation(entry, precipitationType)
);
let maxPrecipitation =
precipitationType === "probability" ? 100 : this._referenceMaxAmount();
if (precipitationType === "amount") {
for (const value of values) {
if (Number.isFinite(value)) {
maxPrecipitation = Math.max(maxPrecipitation, value!);
}
}
}
const dotRadius = 1.5;
const elements = values.map((value, i) => {
const x = slotWidth * i + slotWidth / 2;
if (!Number.isFinite(value) || value! <= 0 || maxPrecipitation <= 0) {
const cy = padding + drawableHeight - dotRadius;
return svg`<circle
cx=${x}
cy=${cy}
r=${dotRadius}
fill=${fill}
class="mark"
></circle>`;
}
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = padding + drawableHeight - barHeight;
const d = topRoundedRectPath(
x - barWidth / 2,
y,
barWidth,
barHeight,
barWidth / 4
);
return svg`<path d=${d} fill=${fill} class="mark"></path>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${elements}
</svg>
`;
}
private _renderHourlyBars(
precipitationType: "amount" | "probability",
fill: string
): TemplateResult | undefined {
if (!this._forecast?.length) {
return undefined;
}
const width = this._size.value?.width || this.clientWidth;
const height = this._size.value?.height || this.clientHeight;
const topPadding = 4;
const drawableHeight = height - topPadding;
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
const timeRange = maxTime - now;
if (timeRange <= 0) {
return undefined;
}
const inRange: { value: number | undefined; t: number }[] = [];
for (const entry of this._forecast) {
const t = new Date(entry.datetime).getTime();
if (t >= now && t <= maxTime) {
inRange.push({
value: getForecastPrecipitation(entry, precipitationType),
t,
});
}
}
if (!inRange.length) {
return undefined;
}
let maxPrecipitation =
precipitationType === "probability" ? 100 : this._referenceMaxAmount();
if (precipitationType === "amount") {
for (const { value } of inRange) {
if (Number.isFinite(value)) {
maxPrecipitation = Math.max(maxPrecipitation, value!);
}
}
}
const slotWidth = width / hoursToShow;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - 2));
const dotRadius = 1.5;
const elements = inRange.map(({ value, t }) => {
// Each entry represents an hour-long slot; center the bar in that slot.
const xCenter = ((t - now) / timeRange) * width + slotWidth / 2;
if (!Number.isFinite(value) || value! <= 0 || maxPrecipitation <= 0) {
const cy = height - dotRadius;
return svg`<circle
cx=${xCenter}
cy=${cy}
r=${dotRadius}
fill=${fill}
class="mark"
></circle>`;
}
const x = xCenter - barWidth / 2;
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = height - barHeight;
const d = topRoundedRectPath(x, y, barWidth, barHeight, barWidth / 4);
return svg`<path d=${d} fill=${fill} class="mark"></path>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${elements}
</svg>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
this._subscribedType = undefined;
}
private async _subscribeForecast() {
if (
!this.context?.entity_id ||
!this._config ||
!this._connection ||
this._subscribed
) {
return;
}
const forecastType = this._resolvedForecastType();
if (!forecastType) {
return;
}
const entityId = this.context.entity_id;
this._forecast = undefined;
this._error = undefined;
this._subscribedType = forecastType;
this._subscribed = subscribeForecast(
this._connection,
entityId,
forecastType,
(forecastEvent: ForecastEvent) => {
this._forecast = forecastEvent.forecast ?? [];
}
).catch((err) => {
this._subscribed = undefined;
this._subscribedType = undefined;
this._error = err.message || err.code;
return undefined;
});
}
static styles = [
graphLabelsStyles,
css`
:host {
display: flex;
width: 100%;
height: var(--feature-height);
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
pointer-events: none !important;
--feature-precipitation-opacity: 0.4;
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
overflow: hidden;
}
.bars {
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
}
.container.with-labels .bars {
padding-bottom: 2px;
}
.info {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
svg {
display: block;
}
.mark {
opacity: var(--feature-precipitation-opacity);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-precipitation-forecast-card-feature": HuiPrecipitationForecastCardFeature;
}
}
@@ -0,0 +1,543 @@
import { consume } from "@lit/context";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type {
Connection,
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { UNIT_F } from "../../../common/const";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { FrontendLocaleData } from "../../../data/translation";
import "../../../components/ha-spinner";
import {
connectionContext,
internationalizationContext,
} from "../../../data/context";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import { subscribeForecast } from "../../../data/weather";
import type {
HomeAssistant,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
MS_PER_HOUR,
resolveForecastResolution,
supportsForecast,
} from "./common/forecast";
import {
getAbsoluteGradient,
getRelativeGradient,
} from "./common/temperature-palette";
import {
graphLabelsStyles,
LABEL_GAP,
LABEL_HEIGHT,
renderDayLabels,
renderHourLabels,
} from "./common/graph-labels";
import { coordinates } from "../common/graph/coordinates";
import type { HuiGraphGradient } from "../components/hui-graph-base";
import "../components/hui-graph-base";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
TemperatureForecastCardFeatureConfig,
} from "./types";
const MAX_BAR_WIDTH = 8;
export const supportsTemperatureForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) =>
supportsForecast(
context.entity_id ? hass.states[context.entity_id] : undefined
);
@customElement("hui-temperature-forecast-card-feature")
class HuiTemperatureForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, LocalizeFunc>({
transformer: ({ localize }) => localize,
})
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<HomeAssistantConnection, Connection>({
transformer: ({ connection }) => connection,
})
private _connection!: Connection;
@state() private _config?: TemperatureForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: ForecastResolution;
private _size = new ResizeController(this, {
callback: (entries) => {
const rect = entries?.[0]?.contentRect;
if (!rect) return undefined;
return { width: rect.width, height: rect.height };
},
});
static getStubConfig(): TemperatureForecastCardFeatureConfig {
return {
type: "temperature-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-temperature-forecast-card-feature-editor");
return document.createElement(
"hui-temperature-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: TemperatureForecastCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecast();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeForecast();
}
protected firstUpdated() {
this._subscribeForecast();
}
protected updated(changedProps: PropertyValues) {
if (this._shouldResubscribe(changedProps)) {
this._unsubscribeForecast();
this._subscribeForecast();
}
}
private _shouldResubscribe(changedProps: PropertyValues): boolean {
if (changedProps.has("context")) {
const previous = changedProps.get("context") as
| LovelaceCardFeatureContext
| undefined;
if (previous?.entity_id !== this.context?.entity_id) return true;
}
if (changedProps.has("_config")) {
if (this._resolvedForecastType() !== this._subscribedType) return true;
}
return false;
}
private _resolvedForecastType(): ForecastResolution | undefined {
return resolveForecastResolution(
this._stateObj,
this._config?.forecast_type
);
}
private get _showLabels(): boolean {
return this._config?.show_labels !== false;
}
protected render() {
if (!this._config || !this.context || !supportsForecast(this._stateObj)) {
return nothing;
}
if (this._error) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.failed_to_load",
{ error: this._error }
)}
</div>
</div>
`;
}
if (!this._forecast) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
const isHourly = this._subscribedType === "hourly";
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
const containerClasses = classMap({
container: true,
"with-labels": this._showLabels,
});
if (isHourly) {
const hourly = this._computeHourly();
if (!hourly?.coordinates.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.no_forecast"
)}
</div>
</div>
`;
}
const graphStyle = customColor
? styleMap({ "--feature-color": customColor })
: nothing;
const hoursToShow = this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
return html`
<div class=${containerClasses}>
<div class="bars">
<hui-graph-base
.coordinates=${hourly.coordinates}
.yAxisOrigin=${hourly.yAxisOrigin}
.gradient=${customColor ? undefined : hourly.gradient}
style=${graphStyle}
></hui-graph-base>
</div>
${this._showLabels && this._locale
? renderHourLabels(hoursToShow, this._locale)
: nothing}
</div>
`;
}
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
const entries = this._forecast
.filter(
(entry) =>
Number.isFinite(entry.temperature) && Number.isFinite(entry.templow)
)
.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html`
<div class=${containerClasses}>
<div class="bars">${this._renderBars(entries, customColor)}</div>
${this._showLabels && this._locale
? renderDayLabels(entries, entriesPerDay, this._locale)
: nothing}
</div>
`;
}
private _renderBars(
entries: ForecastAttribute[],
customColor: string | undefined
): TemplateResult {
const width = this._size.value?.width || this.clientWidth;
const height = this._size.value?.height || this.clientHeight;
const padding = 4;
const minGap = 4;
const slotWidth = width / entries.length;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - minGap));
const drawableHeight = height - padding * 2;
let tempMin = Infinity;
let tempMax = -Infinity;
for (const entry of entries) {
tempMin = Math.min(tempMin, entry.templow!);
tempMax = Math.max(tempMax, entry.temperature);
}
if (tempMin === tempMax) {
tempMin -= 1;
tempMax += 1;
}
const yFor = (value: number) =>
padding +
drawableHeight -
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
const gradients = customColor
? undefined
: entries.map(
(entry, i) => svg`<linearGradient
id="temp-bar-${i}"
x1="0" y1="0" x2="0" y2="1"
>
${getRelativeGradient(entry.templow!, entry.temperature, 3).map(
(s) => svg`<stop offset=${s.offset} stop-color=${s.color}></stop>`
)}
</linearGradient>`
);
const bars = entries.map((entry, i) => {
const x = slotWidth * i + (slotWidth - barWidth) / 2;
const yHigh = yFor(entry.temperature);
const yLow = yFor(entry.templow!);
const barHeight = Math.max(1, yLow - yHigh);
const rx = Math.min(barWidth / 2, barHeight / 2);
const fill = customColor ?? `url(#temp-bar-${i})`;
return svg`<rect
x=${x}
y=${yHigh}
width=${barWidth}
height=${barHeight}
rx=${rx}
ry=${rx}
fill=${fill}
></rect>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${gradients ? svg`<defs>${gradients}</defs>` : nothing}${bars}
</svg>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
this._subscribedType = undefined;
}
private _computeHourly():
| {
coordinates: [number, number][];
yAxisOrigin: number;
gradient: HuiGraphGradient;
}
| undefined {
if (!this._forecast?.length || !this._stateObj) {
return undefined;
}
const data: [number, number][] = [];
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
const currentTemp = this._stateObj.attributes?.temperature;
if (currentTemp != null && !Number.isNaN(Number(currentTemp))) {
data.push([now, Number(currentTemp)]);
}
for (const entry of this._forecast) {
if (entry.temperature != null && !Number.isNaN(entry.temperature)) {
const time = new Date(entry.datetime).getTime();
if (time > maxTime) break;
if (time < now) continue;
data.push([time, entry.temperature]);
}
}
if (!data.length) {
return undefined;
}
let dataMin = data[0][1];
let dataMax = data[0][1];
for (const [, t] of data) {
if (t < dataMin) dataMin = t;
if (t > dataMax) dataMax = t;
}
const range = dataMax - dataMin || dataMin * 0.1;
const minY = dataMin - range * 0.1;
const maxY = dataMax + range * 0.1;
const size = this._size.value;
const width = size?.width || this.clientWidth;
const labelSpace = this._showLabels ? LABEL_HEIGHT + LABEL_GAP : 0;
const height = Math.max(
1,
(size?.height || this.clientHeight) - labelSpace
);
const { points, yAxisOrigin } = coordinates(
data,
width,
height,
data.length,
{ minX: now, maxX: maxTime, minY, maxY }
);
// coordinates() pads with a trailing step-line point at x=width; drop it so the curve ends at the last forecast point.
points.pop();
// calcPoints (inside coordinates()) adds an extra 10% padding on top of
// what we pass, so the curve actually lives in this expanded range.
const padRange = maxY - minY || minY * 0.1;
const effMinY = minY - padRange * 0.1;
const effMaxY = maxY + padRange * 0.1;
const isFahrenheit = this._stateObj.attributes?.temperature_unit === UNIT_F;
const toCelsius = (t: number) => (isFahrenheit ? ((t - 32) * 5) / 9 : t);
const gradient: HuiGraphGradient = {
x1: 0,
y1: 0,
x2: 0,
y2: height,
stops: getAbsoluteGradient(toCelsius(effMinY), toCelsius(effMaxY)),
};
return { coordinates: points, yAxisOrigin, gradient };
}
private async _subscribeForecast() {
if (
!this.context?.entity_id ||
!this._config ||
!this._connection ||
this._subscribed
) {
return;
}
const forecastType = this._resolvedForecastType();
if (!forecastType) {
return;
}
const entityId = this.context.entity_id;
this._forecast = undefined;
this._error = undefined;
this._subscribedType = forecastType;
this._subscribed = subscribeForecast(
this._connection,
entityId,
forecastType,
(forecastEvent: ForecastEvent) => {
this._forecast = forecastEvent.forecast ?? [];
}
).catch((err) => {
this._subscribed = undefined;
this._subscribedType = undefined;
this._error = err.message || err.code;
return undefined;
});
}
static styles = [
graphLabelsStyles,
css`
:host {
display: flex;
width: 100%;
height: var(--feature-height);
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
pointer-events: none !important;
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
.container.with-labels {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.bars {
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
}
.container.with-labels .bars {
padding-bottom: 2px;
}
.info {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
svg {
display: block;
}
hui-graph-base {
width: 100%;
height: 100%;
--accent-color: var(--feature-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-temperature-forecast-card-feature": HuiTemperatureForecastCardFeature;
}
}
@@ -1,6 +1,7 @@
import type { AlarmMode } from "../../../data/alarm_control_panel";
import type { HvacMode } from "../../../data/climate";
import type { OperationMode } from "../../../data/water_heater";
import type { ForecastPrecipitationType } from "../../../data/weather";
export type ButtonCardData = Record<string, any>;
@@ -250,6 +251,27 @@ export interface TrendGraphCardFeatureConfig {
detail?: boolean;
}
export type ForecastResolution = "daily" | "twice_daily" | "hourly";
export interface TemperatureForecastCardFeatureConfig {
type: "temperature-forecast";
forecast_type?: ForecastResolution;
days_to_show?: number;
hours_to_show?: number;
color?: string;
show_labels?: boolean;
}
export interface PrecipitationForecastCardFeatureConfig {
type: "precipitation-forecast";
forecast_type?: ForecastResolution;
days_to_show?: number;
hours_to_show?: number;
precipitation_type?: ForecastPrecipitationType;
color?: string;
show_labels?: boolean;
}
export const AREA_CONTROL_DOMAINS = [
"light",
"fan",
@@ -304,6 +326,8 @@ export type LovelaceCardFeatureConfig =
| FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig
| TrendGraphCardFeatureConfig
| TemperatureForecastCardFeatureConfig
| PrecipitationForecastCardFeatureConfig
| HumidifierToggleCardFeatureConfig
| HumidifierModesCardFeatureConfig
| LawnMowerCommandsCardFeatureConfig
@@ -7,6 +7,14 @@ import { parseAnimationDuration } from "../../../common/util/parse-animation-dur
import { strokeWidth } from "../../../data/graph";
import { getPath } from "../common/graph/get-path";
export interface HuiGraphGradient {
x1: number;
y1: number;
x2: number;
y2: number;
stops: { offset: number; color: string }[];
}
@customElement("hui-graph-base")
export class HuiGraphBase extends LitElement {
@property({ attribute: false }) public coordinates?: number[][];
@@ -16,6 +24,8 @@ export class HuiGraphBase extends LitElement {
@property({ type: Boolean, reflect: true }) public loading = false;
@property({ attribute: false }) public gradient?: HuiGraphGradient;
private _uniqueId = `graph-${Math.random().toString(36).substring(2, 9)}`;
@state()
@@ -47,7 +57,13 @@ export class HuiGraphBase extends LitElement {
const showShimmer = this.loading && !this._reducedMotion;
const shimmerId = `${this._uniqueId}-shimmer`;
const fillRect = showShimmer ? `url(#${shimmerId})` : "var(--accent-color)";
const gradientId = `${this._uniqueId}-gradient`;
const gradient = this.gradient;
const fillRect = showShimmer
? `url(#${shimmerId})`
: gradient
? `url(#${gradientId})`
: "var(--accent-color)";
return html`
${svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
@@ -62,7 +78,21 @@ export class HuiGraphBase extends LitElement {
<animate attributeName="x2" values="-30%;140%" dur="1.8s" repeatCount="indefinite" />
</linearGradient>
</defs>`
: nothing
: gradient
? svg`<defs>
<linearGradient
id=${gradientId}
gradientUnits="userSpaceOnUse"
x1=${gradient.x1} y1=${gradient.y1}
x2=${gradient.x2} y2=${gradient.y2}
>
${gradient.stops.map(
(s) =>
svg`<stop offset=${s.offset} style="stop-color: ${s.color}"></stop>`
)}
</linearGradient>
</defs>`
: nothing
}
<g>
<mask id="${this._uniqueId}-fill">
@@ -43,6 +43,8 @@ import "../card-features/hui-valve-position-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature";
import "../card-features/hui-area-controls-card-feature";
import "../card-features/hui-bar-gauge-card-feature";
import "../card-features/hui-precipitation-forecast-card-feature";
import "../card-features/hui-temperature-forecast-card-feature";
import "../card-features/hui-trend-graph-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
@@ -87,10 +89,12 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"precipitation-forecast",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"temperature-forecast",
"toggle",
"update-actions",
"vacuum-commands",
@@ -42,6 +42,8 @@ import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-co
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
import { supportsPrecipitationForecastCardFeature } from "../../card-features/hui-precipitation-forecast-card-feature";
import { supportsTemperatureForecastCardFeature } from "../../card-features/hui-temperature-forecast-card-feature";
import { supportsFanOscilatteCardFeature } from "../../card-features/hui-fan-oscillate-card-feature";
import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature";
import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature";
@@ -119,10 +121,12 @@ const UI_FEATURE_TYPES = [
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"precipitation-forecast",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"temperature-forecast",
"toggle",
"update-actions",
"vacuum-commands",
@@ -149,6 +153,8 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"cover-tilt-favorite",
"fan-preset-modes",
"humidifier-modes",
"precipitation-forecast",
"temperature-forecast",
"lawn-mower-commands",
"media-player-playback",
"light-color-favorites",
@@ -205,10 +211,12 @@ const SUPPORTS_FEATURE_TYPES: Record<
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"precipitation-forecast": supportsPrecipitationForecastCardFeature,
"select-options": supportsSelectOptionsCardFeature,
"trend-graph": supportsTrendGraphCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
"temperature-forecast": supportsTemperatureForecastCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
@@ -0,0 +1,196 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import { getSupportedForecastTypes } from "../../../../data/weather";
import type { HomeAssistant } from "../../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
resolveForecastResolution,
} from "../../card-features/common/forecast";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
PrecipitationForecastCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-precipitation-forecast-card-feature-editor")
export class HuiPrecipitationForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: PrecipitationForecastCardFeatureConfig;
public setConfig(config: PrecipitationForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
required: true,
disabled: supportedTypes.length <= 1,
selector: {
select: {
mode: "dropdown",
options: (
["daily", "twice_daily", "hourly"] as ForecastResolution[]
).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.features.types.precipitation-forecast.forecast_type_options.${value}`
),
disabled: !supportedTypes.includes(value),
})),
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "precipitation_type",
required: true,
selector: {
select: {
mode: "dropdown",
options: [
{
value: "amount",
label: localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type_options.amount"
),
},
{
value: "probability",
label: localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type_options.probability"
),
},
],
},
},
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
{
name: "show_labels",
default: true,
selector: { boolean: {} },
},
] as const satisfies readonly HaFormSchema[];
}
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const stateObj = this.context?.entity_id
? this.hass.states[this.context.entity_id]
: undefined;
const resolvedType =
resolveForecastResolution(stateObj, this._config.forecast_type) ||
"daily";
const isHourly = resolvedType === "hourly";
const data: PrecipitationForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
precipitation_type: this._config.precipitation_type ?? "amount",
...(isHourly
? { hours_to_show: this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW }
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "forecast_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.forecast_type"
);
case "days_to_show":
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "precipitation_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type"
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.color"
);
case "show_labels":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.show_labels"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-precipitation-forecast-card-feature-editor": HuiPrecipitationForecastCardFeatureEditor;
}
}
@@ -0,0 +1,168 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import { getSupportedForecastTypes } from "../../../../data/weather";
import type { HomeAssistant } from "../../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
resolveForecastResolution,
} from "../../card-features/common/forecast";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
TemperatureForecastCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-temperature-forecast-card-feature-editor")
export class HuiTemperatureForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TemperatureForecastCardFeatureConfig;
public setConfig(config: TemperatureForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
required: true,
disabled: supportedTypes.length <= 1,
selector: {
select: {
mode: "dropdown",
options: (
["daily", "twice_daily", "hourly"] as ForecastResolution[]
).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.features.types.temperature-forecast.forecast_type_options.${value}`
),
disabled: !supportedTypes.includes(value),
})),
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
{
name: "show_labels",
default: true,
selector: { boolean: {} },
},
] as const satisfies readonly HaFormSchema[];
}
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const stateObj = this.context?.entity_id
? this.hass.states[this.context.entity_id]
: undefined;
const resolvedType =
resolveForecastResolution(stateObj, this._config.forecast_type) ||
"daily";
const isHourly = resolvedType === "hourly";
const data: TemperatureForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
...(isHourly
? { hours_to_show: this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW }
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "forecast_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.forecast_type"
);
case "days_to_show":
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.color"
);
case "show_labels":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.show_labels"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-temperature-forecast-card-feature-editor": HuiTemperatureForecastCardFeatureEditor;
}
}
+31
View File
@@ -10203,6 +10203,37 @@
"trend-graph": {
"label": "Trend graph",
"detail": "Show more detail"
},
"temperature-forecast": {
"label": "Temperature forecast",
"no_forecast": "No forecast data available",
"failed_to_load": "Failed to load forecast: {error}",
"forecast_type": "Forecast type",
"forecast_type_options": {
"daily": "Daily",
"twice_daily": "Twice daily",
"hourly": "Hourly"
},
"color": "Color",
"show_labels": "Show labels"
},
"precipitation-forecast": {
"label": "Precipitation forecast",
"no_forecast": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::no_forecast%]",
"failed_to_load": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::failed_to_load%]",
"forecast_type": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type%]",
"forecast_type_options": {
"daily": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::daily%]",
"twice_daily": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::twice_daily%]",
"hourly": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::hourly%]"
},
"precipitation_type": "Precipitation type",
"precipitation_type_options": {
"amount": "Amount",
"probability": "Probability"
},
"color": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::color%]",
"show_labels": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::show_labels%]"
}
}
},
+1 -1
View File
@@ -1,2 +1,2 @@
export const IFRAME_SANDBOX =
"allow-forms allow-popups allow-pointer-lock allow-scripts allow-modals allow-downloads";
"allow-forms allow-popups allow-pointer-lock allow-same-origin allow-scripts allow-modals allow-downloads";
+102 -114
View File
@@ -4335,141 +4335,129 @@ __metadata:
languageName: node
linkType: hard
"@tsparticles/basic@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/basic@npm:4.0.0"
"@tsparticles/basic@npm:^3.7.1":
version: 3.9.1
resolution: "@tsparticles/basic@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:4.0.0"
"@tsparticles/plugin-hex-color": "npm:4.0.0"
"@tsparticles/plugin-hsl-color": "npm:4.0.0"
"@tsparticles/plugin-move": "npm:4.0.0"
"@tsparticles/plugin-rgb-color": "npm:4.0.0"
"@tsparticles/shape-circle": "npm:4.0.0"
"@tsparticles/updater-opacity": "npm:4.0.0"
"@tsparticles/updater-out-modes": "npm:4.0.0"
"@tsparticles/updater-paint": "npm:4.0.0"
"@tsparticles/updater-size": "npm:4.0.0"
checksum: 10/5e455beb0663019d719bc111928a9e22a0f471450414391eed4be2f00455019c59ec85e1e8b7fadf8da2dc258c803d555ebb9b2e0a497a8f53b5922fffab314e
"@tsparticles/engine": "npm:3.9.1"
"@tsparticles/move-base": "npm:3.9.1"
"@tsparticles/plugin-hex-color": "npm:3.9.1"
"@tsparticles/plugin-hsl-color": "npm:3.9.1"
"@tsparticles/plugin-rgb-color": "npm:3.9.1"
"@tsparticles/shape-circle": "npm:3.9.1"
"@tsparticles/updater-color": "npm:3.9.1"
"@tsparticles/updater-opacity": "npm:3.9.1"
"@tsparticles/updater-out-modes": "npm:3.9.1"
"@tsparticles/updater-size": "npm:3.9.1"
checksum: 10/a3d0c926e5822931df9762b2038955093ecfb558715807482f691d54efb848286a5d78a55a184885b2d4f46a005bf52c3c54e0013e29e71158ae1ccb5dce08d3
languageName: node
linkType: hard
"@tsparticles/engine@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/engine@npm:4.0.0"
checksum: 10/05fd84ad82f75c9a9d44280ed948d9340cbfb5b18a18f112ee08c0c7d70ba35687197c9ce7508f7c2600410f42075369f2708504485912eb57e5e6e1cf8394ef
"@tsparticles/engine@npm:3.9.1, @tsparticles/engine@npm:^3.7.1":
version: 3.9.1
resolution: "@tsparticles/engine@npm:3.9.1"
checksum: 10/91e95f33d526558e0f7251a75dc2971873a7854bb903b61aab95d394c954d3d5f6c2429c151483ebe83445e14e2a7ed9ceadb0fd9c0b7e8c11ec316e4bfd04fa
languageName: node
linkType: hard
"@tsparticles/interaction-particles-links@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/interaction-particles-links@npm:4.0.0"
peerDependencies:
"@tsparticles/canvas-utils": 4.0.0
"@tsparticles/engine": 4.0.0
"@tsparticles/plugin-interactivity": 4.0.0
checksum: 10/98d7defc0af362775339f4d7016fa096d352065579036837c99c1a721d88f3207a074075d78130b95236e60a77f66dd29fe957b42e26b7421bdfb797e3271a28
languageName: node
linkType: hard
"@tsparticles/plugin-hex-color@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/plugin-hex-color@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/356310741b0019bcdd352e2affc9090662d7191aff932a1e2743c8d1911f87bd8a49a623a3733cf032911884d52b4e65a291267f37e28513c597d5cd4cadba1e
languageName: node
linkType: hard
"@tsparticles/plugin-hsl-color@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/plugin-hsl-color@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/cb04a297a40fdad04f25dfaa10d77a04c70dceded6c24e6f3ab766d5cc70514464011527d822fc883bb5da5003fdd5d8ab92b71046cb290a3c8ca60f7a40483f
languageName: node
linkType: hard
"@tsparticles/plugin-interactivity@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/plugin-interactivity@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/23b089e537f5fed67fe7e3cf6b67aa639d9586ebda6838d349d9a02095449ca7bcfe164040a9fa08bdfd779f44ae5917434a335e5c67db5df6d6a6bfd521819e
languageName: node
linkType: hard
"@tsparticles/plugin-move@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/plugin-move@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/abc5d175105243171ce440d88ae322f2412fb1c390bd39859fde1dc201b528e5d6099f38fa4a87a47ff110eaf65a930306922c409b1404271ba61d222ca9e48d
languageName: node
linkType: hard
"@tsparticles/plugin-rgb-color@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/plugin-rgb-color@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/7d2255d5f428c56cd56fcf00839707ca3fa554a25fa59b2353e30c1c275ca24f06b9f2eee28a869633a740c56b3609cb6540135fb7297ec54d5fcc140e2ce575
languageName: node
linkType: hard
"@tsparticles/preset-links@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/preset-links@npm:4.0.0"
"@tsparticles/interaction-particles-links@npm:^3.7.1":
version: 3.9.1
resolution: "@tsparticles/interaction-particles-links@npm:3.9.1"
dependencies:
"@tsparticles/basic": "npm:4.0.0"
"@tsparticles/engine": "npm:4.0.0"
"@tsparticles/interaction-particles-links": "npm:4.0.0"
"@tsparticles/plugin-interactivity": "npm:4.0.0"
checksum: 10/67d8c4c90c44a9f3f940aa249e2b86acef4485a54ac02005de5b947cc3c2d01d4b6b00695dc5ff47ab0a8594faa18ba2ccf05b7510f7fd84993939f7d137121e
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/be3925f0892de0eb9a4bc35b1ad402a462874272174379625bccce4c162a528d4f2a4526398f1b4a8b53ff27dae95e2b42a799a9c81ab99e233a9d16c7123774
languageName: node
linkType: hard
"@tsparticles/shape-circle@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/shape-circle@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/37f010c44ba9b82712532da32c2cb6e482d5f5205aaf3bcd7fb04c87925218cd732a31d69ae424613810f11b2651a80fbbd9ec9d6b9d0e58884022ec68cc9d5b
"@tsparticles/move-base@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/move-base@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/d03795bb4d789295ce4179e1b22d618658a15c31915cba5c8f137bf4a8f183186e3969145ef3951df07fddea0e9d1830a4e25a22baa70d904769c488041da40c
languageName: node
linkType: hard
"@tsparticles/updater-opacity@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/updater-opacity@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/b078a28175372246d6861562bcc013c03dff88f6aeaf9b16558e533477340e1b3cd07d57997bdf11b730636df26ef0117bef9ba4ab1b791a9b9a4cd1d2e6623e
"@tsparticles/plugin-hex-color@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/plugin-hex-color@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/726a2ae6182bc6e40ed443e1d664bae7ddb4e606a108e92a0c5fc50f0623105144672295720c06e915ef0e36c2a2455ed80dc335a850f11e3a1de1ad44e4ed08
languageName: node
linkType: hard
"@tsparticles/updater-out-modes@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/updater-out-modes@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/3b27af0a68a7f320ae2481142ff9d6e342b75f245b94e875ea2bdc3f9f47c0b8e79b7ddbf45fea5eeaa9e4610dbdb7130e10056b1f26618958e7e6c28e25dbbe
"@tsparticles/plugin-hsl-color@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/plugin-hsl-color@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/f81aaed365045e437c8f1627c03f3d255dd2bba2d5ff5231b5e90d576b24fbb5dc110f3b860e13d8696ef820feb465414c0e62962a59e55e6e4a86883cb0f003
languageName: node
linkType: hard
"@tsparticles/updater-paint@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/updater-paint@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/22f9c275ee3eb1409923c74b63fc5984d978b68473bd0f58418d6c28a9be0e9e2e010e338adb7deb482021ab444fab85b962745c06d94066dd40265cf016395d
"@tsparticles/plugin-rgb-color@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/plugin-rgb-color@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/17352010973ad83e6c9292722896dd0eef0b9f4411684d3fbcf110363bd2aa41594a77b28709dbf1ee9945624567d945ef71900cb37630b0da68714375333c6f
languageName: node
linkType: hard
"@tsparticles/updater-size@npm:4.0.0":
version: 4.0.0
resolution: "@tsparticles/updater-size@npm:4.0.0"
peerDependencies:
"@tsparticles/engine": 4.0.0
checksum: 10/4664fc5c4c961331d733d98287e9fef0079077def528b814034aea163eb95e2f6c0549e1b87e95eef5f376a7cd0983fbf18c2d77023b9f1f1b163698b03ec504
"@tsparticles/preset-links@npm:3.2.0":
version: 3.2.0
resolution: "@tsparticles/preset-links@npm:3.2.0"
dependencies:
"@tsparticles/basic": "npm:^3.7.1"
"@tsparticles/engine": "npm:^3.7.1"
"@tsparticles/interaction-particles-links": "npm:^3.7.1"
checksum: 10/1cae6c097d3cac1ba210ed681a40626a79f8579a4e82b1827e0b5864b1cb1fb737471f699800447a7a2bd6e17c706b05db36f84741d9f0c9600bd638e7e29999
languageName: node
linkType: hard
"@tsparticles/shape-circle@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/shape-circle@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/1f0e5add252ee6e59b32b018b585106189a8938798e879a5a09b42434091c82f748b7656206d316705d65821f45e87bb9ef4c7a240c33be4384dbef0def1e2f5
languageName: node
linkType: hard
"@tsparticles/updater-color@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/updater-color@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/5c4cb7fc7f4767461abffd3ba90ee2c5dba8a7cd3a38d6278314bb9d074c7e5f4977844f4c84448af00e24c923e4635f9392b320fffbac644ae59f157c6ed5b0
languageName: node
linkType: hard
"@tsparticles/updater-opacity@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/updater-opacity@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/c0ecfd623bdb9cf6ece47098403cb19ce4d9c6204b19ca65357c106f400b4faf2e8f3a51a7ae518b9f708ba549073b4ea8ad8f2282dc298016437a62659298f4
languageName: node
linkType: hard
"@tsparticles/updater-out-modes@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/updater-out-modes@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/b74bb0987aacabaeabc981fbbeffb362263ad69cea2f1733b0f5b8753a5c7338ca51eb02164a41d0a70b75ae0bd6e2f2c16c0bc0b4805b367122536df110f21a
languageName: node
linkType: hard
"@tsparticles/updater-size@npm:3.9.1":
version: 3.9.1
resolution: "@tsparticles/updater-size@npm:3.9.1"
dependencies:
"@tsparticles/engine": "npm:3.9.1"
checksum: 10/211038b9cadd1df1a0fb747d2e1eeff9f1ce57fd4828e838ddfe6b5365feb3f625e7f713380bc819601d88c50ba4b122a401eae627449760d94c95cff85efdb3
languageName: node
linkType: hard
@@ -8774,8 +8762,8 @@ __metadata:
"@rspack/dev-server": "npm:2.0.1"
"@swc/helpers": "npm:0.5.21"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.0.0"
"@tsparticles/preset-links": "npm:4.0.0"
"@tsparticles/engine": "npm:3.9.1"
"@tsparticles/preset-links": "npm:3.2.0"
"@types/babel__plugin-transform-runtime": "npm:7.9.5"
"@types/chromecast-caf-receiver": "npm:6.0.26"
"@types/chromecast-caf-sender": "npm:1.0.11"