Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
74 changes: 47 additions & 27 deletions src/components/wfc-forecast-chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,22 @@ export class WfcForecastChart extends LitElement {
private getChartConfig(): ChartConfiguration {
const style = getComputedStyle(this);
const gridColor = style.getPropertyValue("--wfc-chart-grid-color");
const datalabelColor = style.getPropertyValue("--wfc-chart-label-color");
const highColor = style.getPropertyValue("--wfc-temp-high-color");
const lowColor = style.getPropertyValue("--wfc-temp-low-color");
const precipColor = style.getPropertyValue("--wfc-precipitation-bar-color");
const highTempLabelColor = style.getPropertyValue(
"--wfc-chart-temp-high-label-color"
);
const lowTempLabelColor = style.getPropertyValue(
"--wfc-chart-temp-low-label-color"
);
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-chart-precipitation-bar-color"
);

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

Expand All @@ -229,7 +241,7 @@ export class WfcForecastChart extends LitElement {
datalabels: {
anchor: "end",
align: "top",
color: datalabelColor,
color: highTempLabelColor,
formatter: (value) =>
value != null
? `${formatNumber(value, this.hass.locale)}°`
Expand All @@ -244,7 +256,7 @@ export class WfcForecastChart extends LitElement {
datalabels: {
anchor: "start",
align: "bottom",
color: datalabelColor,
color: lowTempLabelColor,
formatter: (value) =>
value != null
? `${formatNumber(value, this.hass.locale)}°`
Expand All @@ -270,7 +282,7 @@ export class WfcForecastChart extends LitElement {
anchor: "start",
align: "end",
offset: -22,
color: datalabelColor,
color: precipLabelColor,
formatter: (value: number) =>
formatPrecipitation(
value,
Expand Down Expand Up @@ -348,19 +360,23 @@ export class WfcForecastChart extends LitElement {
}

/**
* Compute dynamic scale limits for the temperature axis.
* Computes the Y-axis boundaries (min and max) for the temperature scale.
*
* Ensures adequate padding above and below the temperature data, with special handling to guarantee sufficient space below when
* low temperatures are present. The alogithm works as follows:
* This algorithm calculates "artificial" padding to prevent data labels from being pushed
* outside the chart area. It specifically addresses the issue where low-temperature labels
* (which hang below the data point) get clipped by the x-axis.
*
* 1. Calculate the spread of temperature data, enforcing a minimum spread of 8 degrees.
* 2. Determine dynamic padding as 20% of the spread.
* 3. Ensure a minimum bottom buffer of 3 degrees if low temperature data exists.
* 4. Set final min and max limits using Math.floor and Math.ceil for cleaner axis values.
* The algorithm follows these steps:
*
* @returns An object containing the computed minTemp and maxTemp for the temperature scale.
* 1. Identify the absolute min and max from the forecast.
* 2. Enforce a minimum range of 10° to prevent the chart from looking "flat" or jittery on stable days.
* 3. Apply dynamic padding based on the spread, heavily favoring the bottom (35% when low temps are available, otherwise 10%) over the top (20%) to accommodate labels hanging below the line.
* 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.
*
* @returns An object containing `minTemp` and `maxTemp` properties.
*/
private computeScaleLimits() {
private computeScaleLimits(): { minTemp: number; maxTemp: number } {
const temps = this.forecast.map((f) => f.temperature);
const lows = this.forecast.map((f) => f.templow ?? f.temperature);

Expand All @@ -371,20 +387,24 @@ export class WfcForecastChart extends LitElement {
(f) => f.templow !== undefined && f.templow !== null
);

const spread = Math.max(dataMax - dataMin, 8);
const dynamicPadding = spread * 0.2;
const MIN_BOTTOM_BUFFER = 3;
const spread = Math.max(dataMax - dataMin, 10);
const topPaddingFactor = 0.2;
const bottomPaddingFactor = hasLowTempData ? 0.35 : 0.1;
const dynamicTop = spread * topPaddingFactor;
const dynamicBottom = spread * bottomPaddingFactor;

let bottomPadding;
if (hasLowTempData) {
bottomPadding = Math.max(dynamicPadding, MIN_BOTTOM_BUFFER);
} else {
bottomPadding = Math.max(dynamicPadding, 2);
}
const MIN_TOP_BUFFER = 2;
const MIN_BOTTOM_BUFFER = hasLowTempData ? 5 : 1;

const topPadding = Math.max(dynamicTop, MIN_TOP_BUFFER);
const bottomPadding = Math.max(dynamicBottom, MIN_BOTTOM_BUFFER);

const topPadding = Math.max(dynamicPadding, 2);
const minTemp = Math.floor(dataMin - bottomPadding);
const maxTemp = Math.ceil(dataMax + topPadding);
let maxTemp = Math.ceil(dataMax + topPadding);

if (minTemp >= maxTemp) {
maxTemp = minTemp + 1;
}

return { minTemp, maxTemp };
}
Expand Down
7 changes: 5 additions & 2 deletions src/weather-forecast-card.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
--wfc-wind-low: var(--success-color, #43a047);
--wfc-wind-medium: var(--warning-color, #ffa600);
--wfc-wind-high: var(--error-color, #db4437);
--wfc-temp-low-color: var(--warning-color, #ffa600);
--wfc-temp-high-color: var(--primary-color, #009ac7);
--wfc-chart-temp-low-line-color: var(--secondary-color, #ffa600);
--wfc-chart-temp-high-line-color: var(--primary-color, #009ac7);
--wfc-chart-label-color: var(--primary-text-color, #000);
--wfc-chart-temp-high-label-color: var(--wfc-chart-label-color);
--wfc-chart-temp-low-label-color: var(--secondary-text-color, #9b9b9b);
--wfc-chart-precipitation-label-color: var(--wfc-chart-label-color);
--wfc-chart-grid-color: color-mix(
in srgb,
var(--primary-text-color, #000) 15%,
Expand Down
39 changes: 39 additions & 0 deletions test/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,22 @@ <h3>Chart mode</h3>
<weather-forecast-card id="test-card-5"></weather-forecast-card>
<h3>Aggregated hourly data</h3>
<weather-forecast-card id="test-card-6"></weather-forecast-card>
<h2>Bug fixes</h2>
<h3>#14</h3>
<weather-forecast-card id="test-card-bug-14"></weather-forecast-card>
<h3>#14 - 2</h3>
<weather-forecast-card id="test-card-bug-14-2"></weather-forecast-card>
<div id="error">
<p id="error-message"></p>
</div>
</div>
<script type="module" src="../../src/index.ts"></script>
<script type="module">
import { MockHass } from "../mocks/hass.ts";
import {
ISSUE_14_DAILY_FORECAST,
ISSUE_14_DAILY_FORECAST_2,
} from "../mocks/test-data.ts";

const initializeCard = (cardId, config, hass) => {
const card = document.getElementById(cardId);
Expand Down Expand Up @@ -118,6 +127,36 @@ <h3>Aggregated hourly data</h3>
},
hass
);

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

initializeCard(
"test-card-bug-14",
{
entity: "weather.demo",
forecast: {
mode: "chart",
extra_attribute: "wind_direction",
},
},
hassIssue14.getHass()
);

const hassIssue14_2 = new MockHass();
hassIssue14_2.dailyForecast = ISSUE_14_DAILY_FORECAST_2;

initializeCard(
"test-card-bug-14-2",
{
entity: "weather.demo",
forecast: {
mode: "chart",
extra_attribute: "wind_direction",
},
},
hassIssue14_2.getHass()
);
});
</script>
</body>
Expand Down
62 changes: 50 additions & 12 deletions test/mocks/hass.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import type { HomeAssistant } from "custom-card-helpers";
import {
NumberFormat,
TimeFormat,
type HomeAssistant,
} from "custom-card-helpers";
import type { ForecastAttribute, ForecastEvent } from "../../src/data/weather";
import { HassEntity } from "home-assistant-js-websocket";

export type ForecastSubscriptionCallback = (
forecastevent: ForecastEvent
) => void;

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface MockHomeAssistant extends Omit<HomeAssistant, "auth"> {}
const FORECAST_DAYS = 9;
const FORECAST_HOURS = 24 * FORECAST_DAYS;

Expand Down Expand Up @@ -124,13 +136,13 @@ const generateRandomDailyForecast = (startDate: Date) => {
};

export class MockHass {
private subscriptions = new Map<string, Function>();
private hourlyForecast = generateRandomHourlyForecast(new Date());
private dailyForecast = generateRandomDailyForecast(new Date());
private subscriptions = new Map<string, ForecastSubscriptionCallback>();
public hourlyForecast = generateRandomHourlyForecast(new Date());
public dailyForecast = generateRandomDailyForecast(new Date());

constructor() {}

getHass(): HomeAssistant {
getHass(): MockHomeAssistant {
const currentForecast = this.hourlyForecast[0];

return {
Expand All @@ -142,6 +154,13 @@ export class MockHass {
friendly_name: "Outdoor Temperature",
unit_of_measurement: "°C",
},
last_changed: "2025-11-20T10:30:00.000Z",
last_updated: "2025-11-20T10:30:00.000Z",
context: {
id: "mock-context-id",
user_id: null,
parent_id: null,
},
},
"weather.demo": {
entity_id: "weather.demo",
Expand All @@ -167,6 +186,7 @@ export class MockHass {
context: {
id: "mock-context-id",
user_id: null,
parent_id: null,
},
},
},
Expand All @@ -179,10 +199,23 @@ export class MockHass {
mass: "kg",
temperature: "°C",
volume: "L",
pressure: "hPa",
wind_speed: "m/s",
accumulated_precipitation: "mm",
},
location_name: "Helsinki",
time_zone: "Europe/Helsinki",
components: ["weather"], // Required for weather subscriptions
components: ["weather"],
config_dir: "",
allowlist_external_dirs: [],
allowlist_external_urls: [],
version: "",
config_source: "",
safe_mode: false,
state: "RUNNING",
external_url: null,
internal_url: null,
currency: "",
},
localize: (key: string) => {
// Finnish weather state localizations
Expand Down Expand Up @@ -210,7 +243,7 @@ export class MockHass {

return translations[key] || key;
},
formatEntityState: (stateObj: any) => {
formatEntityState: (stateObj: HassEntity) => {
if (!stateObj) return "";

// For weather entities, return localized state
Expand Down Expand Up @@ -245,10 +278,15 @@ export class MockHass {
language: "en",
locale: {
language: "en",
time_format: "24",
time_format: TimeFormat.twenty_four,
number_format: NumberFormat.comma_decimal,
},
connection: {
subscribeMessage: (callback: Function, message: any) => {
// @ts-expect-error Mock subscription message
subscribeMessage: (
callback: ForecastSubscriptionCallback,
message: { forecast_type: "hourly" | "daily" }
) => {
console.log("Mock forecast subscription:", message);

// Store subscription
Expand All @@ -261,9 +299,9 @@ export class MockHass {
? this.hourlyForecast
: this.dailyForecast;

const forecastEvent = {
const forecastEvent: ForecastEvent = {
type: message.forecast_type,
forecast: mockForecast,
forecast: mockForecast as [ForecastAttribute],
};

setTimeout(() => callback(forecastEvent), 1000);
Expand All @@ -274,7 +312,7 @@ export class MockHass {
};
},
},
} as any;
};
}

// Update forecast data for all subscriptions
Expand Down
Loading