Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ The `current` object controls the display of current weather information and att
| `show_sun_times` | boolean | `true` | Displays sunrise and sunset times in the hourly forecast, and uses specific icons to visualize clear night conditions. |
| `use_color_thresholds` | boolean | `false` | Replaces solid temperature lines with a gradient based on actual values when using forecast chart mode. Colors transition at fixed intervals: -10° (Cold), 0° (Freezing), 8° (Chilly), 18° (Mild), 26° (Warm), and 34° (Hot). These thresholds are specified in degrees Celsius (°C). |

> [!IMPORTANT]
> **Canvas width limit:** To ensure compatibility across different browsers and prevent rendering errors, the canvas width is capped at 16384 pixels when using forecast chart mode. This limit is sufficient for most forecast services. However, any data exceeding this width will be truncated.

### Forecast Actions

Actions support standard Home Assistant card actions. However, one additional action has been defined: `toggle-forecast` will toggle the forecast between daily and hourly forecast.
Expand Down
88 changes: 72 additions & 16 deletions src/components/wfc-forecast-chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { styleMap } from "lit/directives/style-map.js";
import ChartDataLabels from "chartjs-plugin-datalabels";
import { getRelativePosition } from "chart.js/helpers";
import { actionHandler } from "../hass";
import { logger } from "../logger";
import {
ExtendedHomeAssistant,
ForecastActionDetails,
Expand Down Expand Up @@ -63,6 +64,21 @@ type ForecastLineStyle = Pick<
| "borderDash"
>;

// A safe maximum canvas width to avoid exceeding browser limits. This limit covers
// most of the modern mobile and desktop browsers and covers enought forecast data
// for reliable forecast information. This roughly covers over 300 forecast items
// which should be more than enough to provide a reliable forecast view.
const MAX_CANVAS_WIDTH = 16384;

/**
* A chart component to display weather forecast data.
*
* It supports both daily and hourly forecasts, rendering temperature and precipitation data.
* This component manages its own chart instance and updates it based on property changes.
*
* Note: As canvas width limits vary between browsers, this component includes logic to compute
* a safe maximum width based on the user's environment.
*/
@customElement("wfc-forecast-chart")
export class WfcForecastChart extends LitElement {
@property({ attribute: false }) hass!: ExtendedHomeAssistant;
Expand Down Expand Up @@ -117,11 +133,12 @@ export class WfcForecastChart extends LitElement {
}

render(): TemplateResult | typeof nothing {
if (!this.forecast?.length || this.itemWidth <= 0) {
const forecast = this.safeForecast;
if (!forecast?.length || this.itemWidth <= 0) {
return nothing;
}

const count = this.forecast.length;
const count = forecast.length;
const gaps = Math.max(count - 1, 0);

const totalWidthCalc = `calc(${count} * var(--forecast-item-width) + ${gaps} * var(--forecast-item-gap))`;
Expand Down Expand Up @@ -154,7 +171,9 @@ export class WfcForecastChart extends LitElement {
@pointerdown=${this._onPointerDown}
@action=${this._onForecastAction}
>
<div class="wfc-forecast-chart-header">${this.renderHeaderItems()}</div>
<div class="wfc-forecast-chart-header">
${this.renderHeaderItems(forecast)}
</div>

<div class="wfc-chart-clipper" style=${styleMap(clipperStyle)}>
<div
Expand All @@ -167,7 +186,7 @@ export class WfcForecastChart extends LitElement {
</div>

<div class="wfc-forecast-chart-footer">
${this.forecast.map(
${forecast.map(
(item) => html`
<div class="wfc-forecast-slot">
<wfc-forecast-info
Expand Down Expand Up @@ -223,6 +242,7 @@ export class WfcForecastChart extends LitElement {
}

private getChartConfig(): ChartConfiguration {
const data = this.safeForecast;
const style = getComputedStyle(this);
const gridColor = style.getPropertyValue("--wfc-chart-grid-color");
const highTempLabelColor = style.getPropertyValue(
Expand All @@ -236,7 +256,7 @@ export class WfcForecastChart extends LitElement {
);
const precipColor = style.getPropertyValue("--wfc-precipitation-bar-color");

const { minTemp, maxTemp } = this.computeScaleLimits();
const { minTemp, maxTemp } = this.computeScaleLimits(data);

const maxPrecip = getMaxPrecipitationForUnit(
getWeatherUnit(this.hass, this.weatherEntity, "precipitation"),
Expand All @@ -249,10 +269,10 @@ export class WfcForecastChart extends LitElement {
return {
type: "line",
data: {
labels: this.forecast.map((f) => f.datetime),
labels: data.map((f) => f.datetime),
datasets: [
{
data: this.forecast.map((f) => f.temperature),
data: data.map((f) => f.temperature),
yAxisID: "yTemp",
datalabels: {
anchor: "end",
Expand All @@ -266,7 +286,7 @@ export class WfcForecastChart extends LitElement {
...tempLineStyle,
},
{
data: this.forecast.map((f) => f.templow ?? null),
data: data.map((f) => f.templow ?? null),
yAxisID: "yTemp",
datalabels: {
anchor: "start",
Expand All @@ -280,7 +300,7 @@ export class WfcForecastChart extends LitElement {
...templowLineStyle,
},
{
data: this.forecast.map((f) =>
data: data.map((f) =>
f.precipitation && f.precipitation !== 0 ? f.precipitation : null
),
backgroundColor: precipColor,
Expand Down Expand Up @@ -508,16 +528,20 @@ export class WfcForecastChart extends LitElement {
* 4. Enforces a hard minimum buffer (5° at the bottom) to guarantee sufficient "degree distance" for labels, regardless of how condensed the chart scale is.
* 5. Round values to the nearest integer for cleaner grid lines.
*
* @param forecast - The forecast data to compute the scale limits for.
* @returns An object containing `minTemp` and `maxTemp` properties.
*/
private computeScaleLimits(): { minTemp: number; maxTemp: number } {
const temps = this.forecast.map((f) => f.temperature);
const lows = this.forecast.map((f) => f.templow ?? f.temperature);
private computeScaleLimits(forecast: ForecastAttribute[]): {
minTemp: number;
maxTemp: number;
} {
const temps = forecast.map((f) => f.temperature);
const lows = forecast.map((f) => f.templow ?? f.temperature);

const dataMin = Math.min(...lows);
const dataMax = Math.max(...temps);

const hasLowTempData = this.forecast.some(
const hasLowTempData = forecast.some(
(f) => f.templow !== undefined && f.templow !== null
);

Expand All @@ -543,11 +567,11 @@ export class WfcForecastChart extends LitElement {
return { minTemp, maxTemp };
}

private renderHeaderItems(): TemplateResult[] {
private renderHeaderItems(forecast: ForecastAttribute[]): TemplateResult[] {
const parts: TemplateResult[] = [];
let currentDay: string | undefined;

this.forecast.forEach((item) => {
forecast.forEach((item) => {
if (!item.datetime) {
return;
}
Expand Down Expand Up @@ -579,6 +603,38 @@ export class WfcForecastChart extends LitElement {
return parts;
}

/**
* Returns a subset of the forecast that fits within the hardware canvas limit.
* This calculation includes the gap width to ensure exact layout synchronization.
*/
private get safeForecast(): ForecastAttribute[] {
if (!this.forecast?.length || this.itemWidth <= 0) return [];

const gap = this._getGapValue();

const maxItems = Math.floor(
(MAX_CANVAS_WIDTH + gap) / (this.itemWidth + gap)
);

if (this.forecast.length > maxItems) {
logger.debug(
`Truncating forecast to ${maxItems} items to stay under ${MAX_CANVAS_WIDTH}px (including ${gap}px gaps).`
);

return this.forecast.slice(0, maxItems);
}

return this.forecast;
}

private _getGapValue(): number {
const style = getComputedStyle(this);

const gapValue = style.getPropertyValue("--forecast-item-gap").trim();

return parseFloat(gapValue) || 0;
}

private _onPointerDown(event: PointerEvent) {
this._lastChartEvent = event;
}
Expand Down Expand Up @@ -610,7 +666,7 @@ export class WfcForecastChart extends LitElement {
const index = this._chart.data.labels?.indexOf(label as string) ?? -1;
if (index === -1) return;

const selectedForecast = this.forecast[index];
const selectedForecast = this.safeForecast[index];
if (!selectedForecast) return;

const actionDetails: ForecastActionDetails = {
Expand Down
56 changes: 56 additions & 0 deletions test/weather-forecast-chart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,4 +546,60 @@ describe("weather-forecast-card chart", () => {
);
});
});

it("should truncate forecast to stay under MAX_CANVAS_WIDTH", async () => {
const expectedItems = 327;
const largeForecast = Array.from({ length: 500 }, (_, i) => ({
datetime: new Date(Date.now() + i * 3600000).toISOString(),
temperature: 20,
condition: "sunny",
}));

const styles = {
"--forecast-item-gap": "0px",
};

const card = await fixture<WeatherForecastCard>(html`
<weather-forecast-card
.hass=${hass}
.config=${{
type: "custom:weather-forecast-card",
entity: "weather.demo",
forecast: { mode: ForecastMode.Chart },
}}
></weather-forecast-card>
`);

const chartElement = card.shadowRoot!.querySelector(
"wfc-forecast-chart"
) as WfcForecastChart;
chartElement.forecast = largeForecast;
chartElement.itemWidth = 50;

Object.entries(styles).forEach(([key, value]) => {
chartElement.style.setProperty(key, value);
});

await chartElement.updateComplete;

// MAX_CANVAS_WIDTH = 16384
// itemWidth = 50, gap = 0
// maxItems = floor((16384 + 0) / (50 + 0)) = 327

// @ts-expect-error private
const safeForecast = chartElement.safeForecast;
expect(safeForecast.length).toBe(expectedItems);

const header = chartElement.querySelector(".wfc-forecast-chart-header");
const headerItems = header!.querySelectorAll(".wfc-forecast-slot");
expect(headerItems.length).toBe(expectedItems);

const footer = chartElement.querySelector(".wfc-forecast-chart-footer");
const footerItems = footer!.querySelectorAll(".wfc-forecast-slot");
expect(footerItems.length).toBe(expectedItems);

// @ts-expect-error private
const chartInstance = chartElement._chart;
expect(chartInstance?.data.labels?.length).toBe(expectedItems);
});
});