mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-20 08:07:02 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9be045b55d | |||
| 46cc813264 | |||
| c12efd150f | |||
| 675a67599a | |||
| c5c2ddd177 | |||
| cf86f7a819 |
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+196
@@ -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;
|
||||
}
|
||||
}
|
||||
+168
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user