Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 52 additions & 45 deletions README.md

Large diffs are not rendered by default.

24 changes: 10 additions & 14 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,10 @@ <h3>For Home Assistant</h3>
<script type="module">
import { MockHass } from "../test/mocks/index.ts";

const mockHass = new MockHass();
let theme = "dark";
mockHass.darkMode = theme === "dark";
const mockHass = new MockHass({ darkMode: theme === "dark" });
let hass = mockHass.getHass();

const demoMode = "toggle_and_scroll"; // 'condition_effects' | 'toggle_and_scroll' | 'none'

const updateTheme = () => {
Expand All @@ -342,7 +342,7 @@ <h3>For Home Assistant</h3>
card.classList.remove("light-theme");
document.body.classList.remove("light-theme");
}
mockHass.darkMode = theme === "dark";
mockHass.setDarkMode(theme === "dark");
hass = mockHass.getHass();
card.hass = hass;
};
Expand All @@ -362,11 +362,15 @@ <h3>For Home Assistant</h3>
entity: "weather.demo",
name: "Home",
show_condition_effects: demoMode === "condition_effects",
current: {
show_attributes: ["visibility", "apparent_temperature"],
},
forecast: {
mode: "chart",
extra_attribute: "wind_direction",
hourly_group_size: 2,
show_sun_times: demoMode !== "condition_effects",
use_color_thresholds: true,
},
});

Expand All @@ -383,19 +387,11 @@ <h3>For Home Assistant</h3>
let index = 0;

setInterval(() => {
card.hass = {
...hass,
states: {
...hass.states,
"weather.demo": {
...hass.states["weather.demo"],
state: conditions[index],
},
},
};
mockHass.setCurrentConditions(conditions[index]);
card.hass = mockHass.getHass();

index = (index + 1) % conditions.length;
}, 20000);
}, 5000);
};

const toggleAndScrollDemo = () => {
Expand Down
145 changes: 138 additions & 7 deletions src/components/wfc-forecast-chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import {
BarController,
BarElement,
ChartConfiguration,
ScriptableContext,
Color,
ChartDataset,
} from "chart.js";

import "./wfc-forecast-header-items";
Expand All @@ -49,6 +52,17 @@ Chart.register(
ChartDataLabels
);

type ForecastLineType = "temperature" | "templow";

type ForecastLineStyle = Pick<
ChartDataset<"line">,
| "borderColor"
| "pointBackgroundColor"
| "pointBorderColor"
| "fill"
| "borderDash"
>;

@customElement("wfc-forecast-chart")
export class WfcForecastChart extends LitElement {
@property({ attribute: false }) hass!: ExtendedHomeAssistant;
Expand All @@ -61,6 +75,7 @@ export class WfcForecastChart extends LitElement {

private _lastChartEvent: PointerEvent | null = null;
private _chart: Chart | null = null;
private _temperatureColors: Record<string, string> | null = null;
private _scrollController = new DragScrollController(this, {
selector: ".wfc-scroll-container",
childSelector: ".wfc-forecast-slot",
Expand Down Expand Up @@ -219,10 +234,6 @@ export class WfcForecastChart extends LitElement {
const precipLabelColor = style.getPropertyValue(
"--wfc-chart-precipitation-label-color"
);
const highColor = style.getPropertyValue(
"--wfc-chart-temp-high-line-color"
);
const lowColor = style.getPropertyValue("--wfc-chart-temp-low-line-color");
const precipColor = style.getPropertyValue("--wfc-precipitation-bar-color");

const { minTemp, maxTemp } = this.computeScaleLimits();
Expand All @@ -232,15 +243,16 @@ export class WfcForecastChart extends LitElement {
this.forecastType
);

const tempLineStyle = this.getTemperatureLineStyle(style, "temperature");
const templowLineStyle = this.getTemperatureLineStyle(style, "templow");

return {
type: "line",
data: {
labels: this.forecast.map((f) => f.datetime),
datasets: [
{
data: this.forecast.map((f) => f.temperature),
borderColor: highColor,
fill: false,
yAxisID: "yTemp",
datalabels: {
anchor: "end",
Expand All @@ -251,10 +263,10 @@ export class WfcForecastChart extends LitElement {
? `${formatNumber(value, this.hass.locale)}°`
: null,
},
...tempLineStyle,
},
{
data: this.forecast.map((f) => f.templow ?? null),
borderColor: lowColor,
fill: false,
yAxisID: "yTemp",
datalabels: {
Expand All @@ -266,6 +278,7 @@ export class WfcForecastChart extends LitElement {
? `${formatNumber(value, this.hass.locale)}°`
: null,
},
...templowLineStyle,
},
{
data: this.forecast.map((f) =>
Expand Down Expand Up @@ -363,6 +376,124 @@ export class WfcForecastChart extends LitElement {
};
}

private getTemperatureLineStyle(
componentStyle: CSSStyleDeclaration,
type: ForecastLineType
): ForecastLineStyle {
const colorVarName =
type === "temperature"
? "--wfc-chart-temp-high-line-color"
: "--wfc-chart-temp-low-line-color";

const defaultColor = componentStyle.getPropertyValue(colorVarName);

const lineColor = (context: ScriptableContext<"line">) =>
this.computeTemperatureLineColor(context, componentStyle, defaultColor);

return {
borderColor: lineColor,
pointBorderColor: lineColor,
pointBackgroundColor: lineColor,
borderDash:
this.config.forecast?.use_color_thresholds && type === "templow"
? [4, 4]
: undefined,
fill: false,
};
}

/**
* Computes a CanvasGradient or Color for the temperature line based on the current chart context and configuration.
*
* If the `use_color_thresholds` option is enabled in the forecast configuration, this method generates gradient
* transitions through predefined color stops corresponding to temperature ranges. The method caches the computed
* colors for performance optimization. Otherwise just returns the default color for this temperature line.
*
* @param context - The chart context used to create the gradient.
* @returns A CanvasGradient object representing the temperature gradient, or default color if thresholds are not used.
*/
private computeTemperatureLineColor(
context: ScriptableContext<"line">,
componentStyle: CSSStyleDeclaration,
defaultColor: string
): CanvasGradient | Color {
if (!this.config.forecast?.use_color_thresholds) {
return defaultColor;
}

const chart = context.chart;
const { ctx, chartArea, scales } = chart;

if (!chartArea || !scales.yTemp) {
return defaultColor;
}

if (this._temperatureColors === null) {
const style = componentStyle;

this._temperatureColors = {
cold: style.getPropertyValue("--wfc-temp-cold") || "#2196f3",
freezing: style.getPropertyValue("--wfc-temp-freezing") || "#4fb3ff",
chilly: style.getPropertyValue("--wfc-temp-chilly") || "#ffeb3b",
mild: style.getPropertyValue("--wfc-temp-mild") || "#4caf50",
warm: style.getPropertyValue("--wfc-temp-warm") || "#ff9800",
hot: style.getPropertyValue("--wfc-temp-hot") || "#f44336",
};
}

const yTemp = scales.yTemp;
const { min, max } = yTemp;

const gradient = ctx.createLinearGradient(
0,
chartArea.bottom,
0,
chartArea.top
);

const getPos = (temp: number) =>
Math.max(0, Math.min(1, (temp - min) / (max - min)));

const unit = getWeatherUnit(this.hass, this.weatherEntity, "temperature");
const isFahrenheit = unit === "°F";

const normalize = (celsius: number) =>
isFahrenheit ? (celsius * 9) / 5 + 32 : celsius;

const stops = [
{
pos: getPos(normalize(-10)),
color: this._temperatureColors.cold,
},
{
pos: getPos(normalize(0)),
color: this._temperatureColors.freezing,
},
{
pos: getPos(normalize(8)),
color: this._temperatureColors.chilly,
},
{
pos: getPos(normalize(18)),
color: this._temperatureColors.mild,
},
{
pos: getPos(normalize(26)),
color: this._temperatureColors.warm,
},
{
pos: getPos(normalize(34)),
color: this._temperatureColors.hot,
},
].sort((a, b) => a.pos - b.pos);

for (const stop of stops) {
gradient.addColorStop(stop.pos, stop.color!);
}

return gradient;
}

/**
* Computes the Y-axis boundaries (min and max) for the temperature scale.
*
Expand Down
10 changes: 10 additions & 0 deletions src/editor/weather-forecast-card-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ export class WeatherForecastCardEditor
default: true,
optional: true,
},
{
name: "forecast.use_color_thresholds",
selector: { boolean: {} },
default: false,
optional: true,
},
{
name: "forecast.hourly_group_size",
optional: true,
Expand Down Expand Up @@ -420,6 +426,8 @@ export class WeatherForecastCardEditor
return "Scroll to selected forecast";
case "forecast.show_sun_times":
return "Show sunrise and sunset times";
case "forecast.use_color_thresholds":
return "Use color thresholds";
case "forecast.hourly_group_size":
return "Hourly forecast group size";
case "forecast_interactions":
Expand Down Expand Up @@ -461,6 +469,8 @@ export class WeatherForecastCardEditor
return "Automatically scrolls to the first hourly forecast of the selected date when switching to hourly view, and returns to the first daily entry when switching back.";
case "forecast.show_sun_times":
return "Displays sunrise and sunset times in the hourly forecast, and uses specific icons to visualize clear night conditions.";
case "forecast.use_color_thresholds":
return "Replaces solid temperature lines with a gradient based on actual values when using forecast chart mode.";
case "forecast.hourly_group_size":
return "Aggregate hourly forecast data into groups to reduce the number of forecast entries shown.";
case "name":
Expand Down
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,14 @@ export interface WeatherForecastCardForecastConfig {
show_sun_times?: boolean;
hourly_group_size?: number;
scroll_to_selected?: boolean;
use_color_thresholds?: boolean;
}

export interface WeatherForecastCardCurrentConfig {
show_attributes?: boolean | CurrentWeatherAttributes | CurrentWeatherAttributes[];
show_attributes?:
| boolean
| CurrentWeatherAttributes
| CurrentWeatherAttributes[];
}

export interface WeatherForecastCardForecastActionConfig {
Expand Down
9 changes: 9 additions & 0 deletions src/weather-forecast-card.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
--weather-forecast-card-wind-high-color,
var(--error-color, #db4437)
);
--wfc-temp-cold: var(--weather-forecast-card-temp-cold-color, #2196f3);
--wfc-temp-freezing: var(
--weather-forecast-card-temp-freezing-color,
#4fb3ff
);
--wfc-temp-chilly: var(--weather-forecast-card-temp-chilly-color, #ffeb3b);
--wfc-temp-mild: var(--weather-forecast-card-temp-mild-color, #4caf50);
--wfc-temp-warm: var(--weather-forecast-card-temp-warm-color, #ff9800);
--wfc-temp-hot: var(--weather-forecast-card-temp-hot-color, #f44336);
--wfc-chart-temp-low-line-color: var(
--weather-forecast-card-chart-temp-low-line-color,
var(--secondary-color, #ffa600)
Expand Down
40 changes: 40 additions & 0 deletions test/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ <h3>Weather attributes</h3>
<div class="test-card">
<weather-forecast-card id="test-card-9"></weather-forecast-card>
</div>
<h3>Color thresholds</h3>
<div class="test-card">
<weather-forecast-card
id="test-card-color-thresholds"
></weather-forecast-card>
</div>
<h3>Color thresholds (Fahrenheit)</h3>
<div class="test-card">
<weather-forecast-card
id="test-card-color-thresholds-fahrenheit"
></weather-forecast-card>
</div>
<h2>Bug fixes</h2>
<h3>#14</h3>
<div class="test-card">
Expand All @@ -102,6 +114,8 @@ <h3>#14 - 2</h3>
MockHass,
ISSUE_14_DAILY_FORECAST,
ISSUE_14_DAILY_FORECAST_2,
TEST_FORECAST_DAILY_FAHRENHEIT,
TEST_FORECAST_HOURLY_FAHRENHEIT,
} from "../mocks/index.ts";

const initializeCard = (cardId, config, hass) => {
Expand Down Expand Up @@ -209,6 +223,32 @@ <h3>#14 - 2</h3>
hass
);

initializeCard(
"test-card-color-thresholds",
{
entity: "weather.demo",
forecast: {
mode: "chart",
use_color_thresholds: true,
},
},
hass
);

const hassFahrenheit = new MockHass({ unitOfMeasurement: "°F" });

initializeCard(
"test-card-color-thresholds-fahrenheit",
{
entity: "weather.demo",
forecast: {
mode: "chart",
use_color_thresholds: true,
},
},
hassFahrenheit.getHass()
);

const hassIssue14 = new MockHass();
hassIssue14.dailyForecast = ISSUE_14_DAILY_FORECAST;

Expand Down
Loading