mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-07 01:43:13 +00:00
Compare commits
1 Commits
dev
...
forecast-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde2230185 |
34
src/panels/lovelace/card-features/common/forecast.ts
Normal file
34
src/panels/lovelace/card-features/common/forecast.ts
Normal file
@@ -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,443 @@
|
||||
import { consume } from "@lit/context";
|
||||
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 { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
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 type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import type {
|
||||
ForecastResolution,
|
||||
LovelaceCardFeatureContext,
|
||||
PrecipitationForecastCardFeatureConfig,
|
||||
} from "./types";
|
||||
|
||||
const MAX_BAR_WIDTH = 16;
|
||||
|
||||
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: 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;
|
||||
|
||||
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) {
|
||||
const resolvedType = this._resolvedForecastType();
|
||||
const contextChanged =
|
||||
changedProps.has("context") &&
|
||||
(changedProps.get("context") as LovelaceCardFeatureContext | undefined)
|
||||
?.entity_id !== this.context?.entity_id;
|
||||
const configTypeChanged =
|
||||
changedProps.has("_config") && resolvedType !== this._subscribedType;
|
||||
if (contextChanged || configTypeChanged) {
|
||||
this._unsubscribeForecast();
|
||||
this._subscribeForecast();
|
||||
}
|
||||
}
|
||||
|
||||
private _resolvedForecastType(): ForecastResolution | undefined {
|
||||
return resolveForecastResolution(
|
||||
this._stateObj,
|
||||
this._config?.forecast_type
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.context || !supportsForecast(this._stateObj)) {
|
||||
return nothing;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">${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)";
|
||||
|
||||
if (isHourly) {
|
||||
return html`
|
||||
<div class="container">
|
||||
${this._renderHourlyBars(precipitationType, fill)}
|
||||
</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="container">
|
||||
${this._renderDailyBars(entries, precipitationType, fill)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDailyBars(
|
||||
entries: ForecastAttribute[],
|
||||
precipitationType: "amount" | "probability",
|
||||
fill: string
|
||||
): TemplateResult {
|
||||
const width = this.clientWidth || 300;
|
||||
const height = this.clientHeight || 42;
|
||||
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 maxPrecipitation = 0;
|
||||
if (precipitationType === "probability") {
|
||||
maxPrecipitation = 100;
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
if (Number.isFinite(value)) {
|
||||
maxPrecipitation = Math.max(maxPrecipitation, value!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dotRadius = 1.5;
|
||||
const elements = entries.map((entry, i) => {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
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}
|
||||
opacity="0.4"
|
||||
></circle>`;
|
||||
}
|
||||
const barHeight = Math.max(
|
||||
1,
|
||||
(value! / maxPrecipitation) * drawableHeight
|
||||
);
|
||||
const y = padding + drawableHeight - barHeight;
|
||||
return svg`<rect
|
||||
x=${x - barWidth / 2}
|
||||
y=${y}
|
||||
width=${barWidth}
|
||||
height=${barHeight}
|
||||
fill=${fill}
|
||||
opacity="0.4"
|
||||
></rect>`;
|
||||
});
|
||||
|
||||
return html`
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 ${width} ${height}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
${elements}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHourlyBars(
|
||||
precipitationType: "amount" | "probability",
|
||||
fill: string
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!this._forecast?.length) {
|
||||
return nothing;
|
||||
}
|
||||
const width = this.clientWidth || 300;
|
||||
const height = this.clientHeight || 42;
|
||||
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 nothing;
|
||||
}
|
||||
|
||||
const inRange: { entry: ForecastAttribute; t: number }[] = [];
|
||||
for (const entry of this._forecast) {
|
||||
const t = new Date(entry.datetime).getTime();
|
||||
if (t >= now && t <= maxTime) {
|
||||
inRange.push({ entry, t });
|
||||
}
|
||||
}
|
||||
|
||||
if (!inRange.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const entriesWithRain = inRange.filter(({ entry }) => {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
return Number.isFinite(value) && value! > 0;
|
||||
});
|
||||
|
||||
let maxPrecipitation = 0;
|
||||
if (precipitationType === "probability") {
|
||||
maxPrecipitation = 100;
|
||||
} else {
|
||||
for (const { entry } of entriesWithRain) {
|
||||
maxPrecipitation = Math.max(
|
||||
maxPrecipitation,
|
||||
getForecastPrecipitation(entry, precipitationType)!
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const slotWidth = width / hoursToShow;
|
||||
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - 2));
|
||||
const dotRadius = 1.5;
|
||||
|
||||
const elements = inRange.map(({ entry, t }) => {
|
||||
const value = getForecastPrecipitation(entry, precipitationType);
|
||||
const xCenter = ((t - now) / timeRange) * width;
|
||||
if (!Number.isFinite(value) || value! <= 0 || maxPrecipitation <= 0) {
|
||||
const cy = height - dotRadius;
|
||||
return svg`<circle
|
||||
cx=${xCenter}
|
||||
cy=${cy}
|
||||
r=${dotRadius}
|
||||
fill=${fill}
|
||||
opacity="0.4"
|
||||
></circle>`;
|
||||
}
|
||||
const x = xCenter - barWidth / 2;
|
||||
const barHeight = Math.max(
|
||||
1,
|
||||
(value! / maxPrecipitation) * drawableHeight
|
||||
);
|
||||
const y = height - barHeight;
|
||||
return svg`<rect
|
||||
x=${x}
|
||||
y=${y}
|
||||
width=${barWidth}
|
||||
height=${barHeight}
|
||||
fill=${fill}
|
||||
opacity="0.4"
|
||||
></rect>`;
|
||||
});
|
||||
|
||||
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 = 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;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-precipitation-forecast-card-feature": HuiPrecipitationForecastCardFeature;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { consume } from "@lit/context";
|
||||
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 { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
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 { 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;
|
||||
|
||||
const TEMP_GRADIENT_STOPS: { tempC: number; cssVar: string }[] = [
|
||||
{ tempC: -20, cssVar: "--feature-temperature-freezing-color" },
|
||||
{ tempC: 0, cssVar: "--feature-temperature-cold-color" },
|
||||
{ tempC: 15, cssVar: "--feature-temperature-mild-color" },
|
||||
{ tempC: 25, cssVar: "--feature-temperature-warm-color" },
|
||||
{ tempC: 40, cssVar: "--feature-temperature-hot-color" },
|
||||
];
|
||||
|
||||
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: connectionContext, subscribe: true })
|
||||
@transform<HomeAssistantConnection, Connection>({
|
||||
transformer: ({ connection }) => connection,
|
||||
})
|
||||
private _connection!: Connection;
|
||||
|
||||
@state() private _config?: TemperatureForecastCardFeatureConfig;
|
||||
|
||||
@state() private _forecast?: ForecastAttribute[];
|
||||
|
||||
@state() private _hourly?: {
|
||||
coordinates: [number, number][];
|
||||
yAxisOrigin: number;
|
||||
gradient?: HuiGraphGradient;
|
||||
};
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
|
||||
|
||||
private _subscribedType?: ForecastResolution;
|
||||
|
||||
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) {
|
||||
const resolvedType = this._resolvedForecastType();
|
||||
const contextChanged =
|
||||
changedProps.has("context") &&
|
||||
(changedProps.get("context") as LovelaceCardFeatureContext | undefined)
|
||||
?.entity_id !== this.context?.entity_id;
|
||||
const configTypeChanged =
|
||||
changedProps.has("_config") && resolvedType !== this._subscribedType;
|
||||
if (contextChanged || configTypeChanged) {
|
||||
this._unsubscribeForecast();
|
||||
this._subscribeForecast();
|
||||
}
|
||||
}
|
||||
|
||||
private _resolvedForecastType(): ForecastResolution | undefined {
|
||||
return resolveForecastResolution(
|
||||
this._stateObj,
|
||||
this._config?.forecast_type
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.context || !supportsForecast(this._stateObj)) {
|
||||
return nothing;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">${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;
|
||||
|
||||
if (isHourly) {
|
||||
if (!this._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;
|
||||
return html`
|
||||
<div class="container">
|
||||
<hui-graph-base
|
||||
.coordinates=${this._hourly.coordinates}
|
||||
.yAxisOrigin=${this._hourly.yAxisOrigin}
|
||||
.gradient=${customColor ? undefined : this._hourly.gradient}
|
||||
style=${graphStyle}
|
||||
></hui-graph-base>
|
||||
</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="container">${this._renderBars(entries, customColor)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderBars(
|
||||
entries: ForecastAttribute[],
|
||||
customColor: string | undefined
|
||||
): TemplateResult {
|
||||
const width = this.clientWidth || 300;
|
||||
const height = this.clientHeight || 42;
|
||||
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 isFahrenheit = this._stateObj?.attributes?.temperature_unit === "°F";
|
||||
const toDisplayUnit = (tempC: number) =>
|
||||
isFahrenheit ? (tempC * 9) / 5 + 32 : tempC;
|
||||
|
||||
const tempGradient = !customColor
|
||||
? (() => {
|
||||
const stops = TEMP_GRADIENT_STOPS.map((stop) => ({
|
||||
y: yFor(toDisplayUnit(stop.tempC)),
|
||||
cssVar: stop.cssVar,
|
||||
})).sort((a, b) => a.y - b.y);
|
||||
const y1 = stops[0].y;
|
||||
const y2 = stops[stops.length - 1].y;
|
||||
const range = y2 - y1 || 1;
|
||||
return svg`<defs>
|
||||
<linearGradient
|
||||
id="temp-gradient"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0" y1=${y1}
|
||||
x2="0" y2=${y2}
|
||||
>
|
||||
${stops.map(
|
||||
(stop) =>
|
||||
svg`<stop
|
||||
offset=${(stop.y - y1) / range}
|
||||
style="stop-color: var(${stop.cssVar})"
|
||||
></stop>`
|
||||
)}
|
||||
</linearGradient>
|
||||
</defs>`;
|
||||
})()
|
||||
: nothing;
|
||||
|
||||
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-gradient)";
|
||||
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"
|
||||
>
|
||||
${tempGradient}${bars}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
private _unsubscribeForecast() {
|
||||
if (this._subscribed) {
|
||||
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
|
||||
this._subscribed = undefined;
|
||||
}
|
||||
this._subscribedType = undefined;
|
||||
this._hourly = undefined;
|
||||
}
|
||||
|
||||
private _computeHourly(forecast: ForecastAttribute[]) {
|
||||
if (!forecast.length || !this._stateObj) {
|
||||
this._hourly = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
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 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) {
|
||||
this._hourly = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
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 width = this.clientWidth || 300;
|
||||
const height = this.clientHeight || 42;
|
||||
|
||||
const { points, yAxisOrigin } = coordinates(
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
data.length,
|
||||
{ minX: now, maxX: maxTime, minY, maxY }
|
||||
);
|
||||
points.pop();
|
||||
|
||||
const isFahrenheit = this._stateObj.attributes?.temperature_unit === "°F";
|
||||
const toDisplayUnit = (tempC: number) =>
|
||||
isFahrenheit ? (tempC * 9) / 5 + 32 : tempC;
|
||||
const yFor = (temp: number) =>
|
||||
height - ((temp - minY) / (maxY - minY || 1)) * height;
|
||||
|
||||
const stops = TEMP_GRADIENT_STOPS.map((stop) => ({
|
||||
y: yFor(toDisplayUnit(stop.tempC)),
|
||||
cssVar: stop.cssVar,
|
||||
})).sort((a, b) => a.y - b.y);
|
||||
const y1 = stops[0].y;
|
||||
const y2 = stops[stops.length - 1].y;
|
||||
const gradientRange = y2 - y1 || 1;
|
||||
|
||||
const gradient: HuiGraphGradient = {
|
||||
x1: 0,
|
||||
y1,
|
||||
x2: 0,
|
||||
y2,
|
||||
stops: stops.map((stop) => ({
|
||||
offset: (stop.y - y1) / gradientRange,
|
||||
color: `var(${stop.cssVar})`,
|
||||
})),
|
||||
};
|
||||
|
||||
this._hourly = { 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 ?? [];
|
||||
if (this._subscribedType === "hourly") {
|
||||
this._computeHourly(this._forecast);
|
||||
}
|
||||
}
|
||||
).catch((err) => {
|
||||
this._subscribed = undefined;
|
||||
this._subscribedType = undefined;
|
||||
this._error = err.message || err.code;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
static styles = 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-temperature-freezing-color: #a89bd8;
|
||||
--feature-temperature-cold-color: #7dc8dc;
|
||||
--feature-temperature-mild-color: #a8dc7c;
|
||||
--feature-temperature-warm-color: #e89042;
|
||||
--feature-temperature-hot-color: #d24530;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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>;
|
||||
|
||||
@@ -241,6 +242,25 @@ 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;
|
||||
}
|
||||
|
||||
export interface PrecipitationForecastCardFeatureConfig {
|
||||
type: "precipitation-forecast";
|
||||
forecast_type?: ForecastResolution;
|
||||
days_to_show?: number;
|
||||
hours_to_show?: number;
|
||||
precipitation_type?: ForecastPrecipitationType;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const AREA_CONTROL_DOMAINS = [
|
||||
"light",
|
||||
"fan",
|
||||
@@ -295,6 +315,8 @@ export type LovelaceCardFeatureConfig =
|
||||
| FanPresetModesCardFeatureConfig
|
||||
| FanSpeedCardFeatureConfig
|
||||
| TrendGraphCardFeatureConfig
|
||||
| TemperatureForecastCardFeatureConfig
|
||||
| PrecipitationForecastCardFeatureConfig
|
||||
| HumidifierToggleCardFeatureConfig
|
||||
| HumidifierModesCardFeatureConfig
|
||||
| LawnMowerCommandsCardFeatureConfig
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, svg } from "lit";
|
||||
import { css, html, LitElement, nothing, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
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[][];
|
||||
@@ -11,6 +19,8 @@ export class HuiGraphBase extends LitElement {
|
||||
@property({ attribute: "y-axis-origin", type: Number })
|
||||
public yAxisOrigin?: number;
|
||||
|
||||
@property({ attribute: false }) public gradient?: HuiGraphGradient;
|
||||
|
||||
@state() private _path?: string;
|
||||
|
||||
private _uniqueId = `graph-${Math.random().toString(36).substring(2, 9)}`;
|
||||
@@ -22,9 +32,29 @@ export class HuiGraphBase extends LitElement {
|
||||
const lastX = this.coordinates?.length
|
||||
? this.coordinates[this.coordinates.length - 1][0]
|
||||
: width;
|
||||
const fill = this.gradient
|
||||
? `url(#${this._uniqueId}-gradient)`
|
||||
: "var(--accent-color)";
|
||||
return html`
|
||||
${this._path
|
||||
? svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
||||
${
|
||||
this.gradient
|
||||
? svg`<defs>
|
||||
<linearGradient
|
||||
id="${this._uniqueId}-gradient"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1=${this.gradient.x1} y1=${this.gradient.y1}
|
||||
x2=${this.gradient.x2} y2=${this.gradient.y2}
|
||||
>
|
||||
${this.gradient.stops.map(
|
||||
(s) =>
|
||||
svg`<stop offset=${s.offset} style="stop-color: ${s.color}"></stop>`
|
||||
)}
|
||||
</linearGradient>
|
||||
</defs>`
|
||||
: nothing
|
||||
}
|
||||
<g>
|
||||
<mask id="${this._uniqueId}-fill">
|
||||
<path
|
||||
@@ -33,7 +63,7 @@ export class HuiGraphBase extends LitElement {
|
||||
d="${this._path} L ${lastX}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
|
||||
/>
|
||||
</mask>
|
||||
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-fill)"></rect>
|
||||
<rect height="100%" width="100%" fill=${fill} mask="url(#${this._uniqueId}-fill)"></rect>
|
||||
<mask id="${this._uniqueId}-line">
|
||||
<path
|
||||
vector-effect="non-scaling-stroke"
|
||||
@@ -46,7 +76,7 @@ export class HuiGraphBase extends LitElement {
|
||||
d=${this._path}
|
||||
></path>
|
||||
</mask>
|
||||
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-line)"></rect>
|
||||
<rect height="100%" width="100%" fill=${fill} mask="url(#${this._uniqueId}-line)"></rect>
|
||||
</g>
|
||||
</svg>`
|
||||
: svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}
|
||||
|
||||
@@ -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",
|
||||
@@ -202,10 +208,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,187 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
] 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.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 "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"
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-precipitation-forecast-card-feature-editor": HuiPrecipitationForecastCardFeatureEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
] 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"
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-temperature-forecast-card-feature-editor": HuiTemperatureForecastCardFeatureEditor;
|
||||
}
|
||||
}
|
||||
@@ -10143,6 +10143,33 @@
|
||||
"trend-graph": {
|
||||
"label": "Trend graph",
|
||||
"detail": "Show more detail"
|
||||
},
|
||||
"temperature-forecast": {
|
||||
"label": "Temperature forecast",
|
||||
"no_forecast": "No forecast data available",
|
||||
"forecast_type": "Forecast type",
|
||||
"forecast_type_options": {
|
||||
"daily": "Daily",
|
||||
"twice_daily": "Twice daily",
|
||||
"hourly": "Hourly"
|
||||
},
|
||||
"color": "Color"
|
||||
},
|
||||
"precipitation-forecast": {
|
||||
"label": "Precipitation forecast",
|
||||
"no_forecast": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::no_forecast%]",
|
||||
"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%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user