mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-16 15:18:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ba59074e | ||
|
|
eeb674dd5a |
@@ -11,6 +11,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-attribute-icon";
|
||||
@@ -85,12 +86,26 @@ class MoreInfoLight extends LitElement {
|
||||
|
||||
private _setMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._mainControl = ev.currentTarget.control;
|
||||
this._changeMainControl(ev.currentTarget.control);
|
||||
}
|
||||
|
||||
private _resetMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._mainControl = "brightness";
|
||||
this._changeMainControl("brightness");
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// A container that outlives this control (e.g. more-info-content when the
|
||||
// dialog moves between entities) resyncs with the default control.
|
||||
fireEvent(this, "light-main-control-changed", {
|
||||
control: this._mainControl,
|
||||
});
|
||||
}
|
||||
|
||||
private _changeMainControl(control: MainControl) {
|
||||
this._mainControl = control;
|
||||
fireEvent(this, "light-main-control-changed", { control });
|
||||
}
|
||||
|
||||
private get _stateOverride() {
|
||||
@@ -399,4 +414,14 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"more-info-light": MoreInfoLight;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"light-main-control-changed": { control: MainControl };
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"light-main-control-changed": HASSDomEvent<
|
||||
HASSDomEvents["light-main-control-changed"]
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
@@ -11,6 +12,7 @@ import "../../components/ha-badge";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "../../panels/lovelace/card-features/hui-light-color-temp-card-feature";
|
||||
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
|
||||
import type { TileCardConfig } from "../../panels/lovelace/cards/types";
|
||||
import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers";
|
||||
@@ -25,6 +27,8 @@ interface EntityInfo {
|
||||
deviceId: string | undefined;
|
||||
}
|
||||
|
||||
type LightMainControl = HASSDomEvents["light-main-control-changed"]["control"];
|
||||
|
||||
@customElement("more-info-content")
|
||||
class MoreInfoContent extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -37,6 +41,17 @@ class MoreInfoContent extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public data?: Record<string, any>;
|
||||
|
||||
// Mirrors the mode selected in the light control so group members
|
||||
// show the matching slider.
|
||||
@state() private _lightMainControl: LightMainControl = "brightness";
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("light-main-control-changed", (ev) => {
|
||||
this._lightMainControl = ev.detail.control;
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
let moreInfoType: string | undefined;
|
||||
|
||||
@@ -73,7 +88,10 @@ class MoreInfoContent extends LitElement {
|
||||
? html`
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${this._entitiesSectionConfig(memberIds)}
|
||||
.config=${this._entitiesSectionConfig(
|
||||
memberIds,
|
||||
this._lightMainControl
|
||||
)}
|
||||
>
|
||||
</hui-section>
|
||||
`
|
||||
@@ -104,97 +122,110 @@ class MoreInfoContent extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
|
||||
const hass = this.hass!;
|
||||
private _entitiesSectionConfig = memoizeOne(
|
||||
(entityIds: string[], lightMainControl: LightMainControl) => {
|
||||
const hass = this.hass!;
|
||||
|
||||
// Get entity names and areas for all visible entities
|
||||
const entityInfos = entityIds
|
||||
.map<EntityInfo | null>((entityId) => {
|
||||
const entry = hass.entities[entityId];
|
||||
if (entry?.hidden) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const entityName = computeEntityName(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
// Get entity names and areas for all visible entities
|
||||
const entityInfos = entityIds
|
||||
.map<EntityInfo | null>((entityId) => {
|
||||
const entry = hass.entities[entityId];
|
||||
if (entry?.hidden) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const entityName = computeEntityName(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
|
||||
// Check if all entities have the same entity name
|
||||
const entityNames = new Set(entityInfos.map((info) => info.entityName));
|
||||
const allSameEntityName = entityNames.size === 1;
|
||||
// Check if all entities have the same entity name
|
||||
const entityNames = new Set(entityInfos.map((info) => info.entityName));
|
||||
const allSameEntityName = entityNames.size === 1;
|
||||
|
||||
// Check if all entities have the same area
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
// Check if all entities have the same area
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
|
||||
// Build name and state content config based on conditions. The device name
|
||||
// is redundant when every member belongs to the same device, so omit it
|
||||
// (and fall back to the entity name so the tile still has a label).
|
||||
const name: EntityNameItem[] = [];
|
||||
// Build name and state content config based on conditions. The device name
|
||||
// is redundant when every member belongs to the same device, so omit it
|
||||
// (and fall back to the entity name so the tile still has a label).
|
||||
const name: EntityNameItem[] = [];
|
||||
|
||||
if (!allSameDevice) {
|
||||
name.push({ type: "device" });
|
||||
}
|
||||
|
||||
if (!allSameEntityName || allSameDevice) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
const stateContent = ["state"];
|
||||
if (!allSameArea) {
|
||||
stateContent.push("area_name");
|
||||
}
|
||||
|
||||
const cards = entityInfos.map(({ entityId }) => {
|
||||
const features: LovelaceCardFeatureConfig[] = [];
|
||||
const context = { entity_id: entityId };
|
||||
if (supportsCoverPositionCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "cover-position",
|
||||
});
|
||||
} else if (supportsLightBrightnessCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "light-brightness",
|
||||
});
|
||||
if (!allSameDevice) {
|
||||
name.push({ type: "device" });
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
name,
|
||||
state_content: stateContent,
|
||||
features_position: "inline",
|
||||
features,
|
||||
grid_options: { columns: 12 },
|
||||
} as TileCardConfig;
|
||||
});
|
||||
if (!allSameEntityName || allSameDevice) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
return {
|
||||
type: "grid",
|
||||
cards,
|
||||
};
|
||||
});
|
||||
const stateContent = ["state"];
|
||||
if (!allSameArea) {
|
||||
stateContent.push("area_name");
|
||||
}
|
||||
|
||||
const cards = entityInfos.map(({ entityId }) => {
|
||||
const features: LovelaceCardFeatureConfig[] = [];
|
||||
const context = { entity_id: entityId };
|
||||
if (supportsCoverPositionCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "cover-position",
|
||||
});
|
||||
} else if (lightMainControl === "color") {
|
||||
// There is no RGB tile feature yet, so when the group is set to
|
||||
// color the members show no control rather than a mismatched
|
||||
// brightness slider.
|
||||
} else if (
|
||||
lightMainControl === "color_temp" &&
|
||||
supportsLightColorTempCardFeature(hass, context)
|
||||
) {
|
||||
features.push({
|
||||
type: "light-color-temp",
|
||||
});
|
||||
} else if (supportsLightBrightnessCardFeature(hass, context)) {
|
||||
features.push({
|
||||
type: "light-brightness",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
name,
|
||||
state_content: stateContent,
|
||||
features_position: "inline",
|
||||
features,
|
||||
grid_options: { columns: 12 },
|
||||
} as TileCardConfig;
|
||||
});
|
||||
|
||||
return {
|
||||
type: "grid",
|
||||
cards,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
hui-section {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Home-circle stroke lengths for the energy distribution card.
|
||||
*
|
||||
* Hourly allocation can produce used_solar + used_battery + used_grid larger
|
||||
* than net used_total when some hours export more than they produce. Dividing
|
||||
* by net used_total then overflows the circle and the leftover grid arc goes
|
||||
* negative, which browsers paint as a solid grid ring.
|
||||
*
|
||||
* These arcs use the sum of the allocated home flows as the denominator so
|
||||
* they always fit the circumference.
|
||||
*/
|
||||
export const ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
|
||||
export interface EnergyDistributionHomeCircleArcs {
|
||||
solar?: number;
|
||||
battery?: number;
|
||||
lowCarbon?: number;
|
||||
highCarbon?: number;
|
||||
grid?: number;
|
||||
}
|
||||
|
||||
export const computeEnergyDistributionHomeCircleArcs = ({
|
||||
usedSolar = 0,
|
||||
usedBattery = 0,
|
||||
usedGrid = 0,
|
||||
hasSolar,
|
||||
hasGrid,
|
||||
highCarbonConsumption,
|
||||
circumference = ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE,
|
||||
}: {
|
||||
usedSolar?: number;
|
||||
usedBattery?: number;
|
||||
usedGrid?: number;
|
||||
hasSolar: boolean;
|
||||
hasGrid: boolean;
|
||||
highCarbonConsumption?: number;
|
||||
circumference?: number;
|
||||
}): EnergyDistributionHomeCircleArcs => {
|
||||
const solar = Math.max(usedSolar, 0);
|
||||
const battery = Math.max(usedBattery, 0);
|
||||
const grid = Math.max(usedGrid, 0);
|
||||
const ringTotal = solar + battery + grid;
|
||||
|
||||
const arcs: EnergyDistributionHomeCircleArcs = {};
|
||||
// Leave arcs unset so the card can fall back to the plain home border
|
||||
// instead of painting zero-length dashes over a borderless circle.
|
||||
if (ringTotal <= 0) {
|
||||
return arcs;
|
||||
}
|
||||
|
||||
const share = (value: number): number => circumference * (value / ringTotal);
|
||||
|
||||
if (hasSolar) {
|
||||
arcs.solar = share(solar);
|
||||
}
|
||||
if (battery > 0) {
|
||||
arcs.battery = share(battery);
|
||||
}
|
||||
if (hasGrid) {
|
||||
if (highCarbonConsumption !== undefined) {
|
||||
const highCarbon = Math.min(Math.max(highCarbonConsumption, 0), grid);
|
||||
arcs.highCarbon = share(highCarbon);
|
||||
// Keep 0 defined: the card mounts the home SVG when solar or
|
||||
// lowCarbon is defined, not when the low-carbon stroke is painted.
|
||||
arcs.lowCarbon = share(grid - highCarbon);
|
||||
arcs.grid = arcs.highCarbon;
|
||||
} else {
|
||||
arcs.grid = share(grid);
|
||||
}
|
||||
}
|
||||
|
||||
return arcs;
|
||||
};
|
||||
@@ -37,8 +37,10 @@ import type { LovelaceCard } from "../../types";
|
||||
import type { EnergyDistributionCardConfig } from "../types";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { round } from "../../../../common/number/round";
|
||||
|
||||
const CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
import {
|
||||
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE_CIRCUMFERENCE,
|
||||
computeEnergyDistributionHomeCircleArcs,
|
||||
} from "./energy-distribution-home-circle";
|
||||
|
||||
// Flows are differences of sums; anything that rounds to 0 Wh is noise
|
||||
const hasFlow = (value: number | null): value is number =>
|
||||
@@ -321,22 +323,8 @@ class HuiEnergyDistrubutionCard
|
||||
|
||||
const totalHomeConsumption = Math.max(0, consumption.total.used_total);
|
||||
|
||||
let homeSolarCircumference: number | undefined;
|
||||
if (hasSolarProduction) {
|
||||
homeSolarCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption);
|
||||
}
|
||||
|
||||
let homeBatteryCircumference: number | undefined;
|
||||
if (batteryConsumption) {
|
||||
homeBatteryCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption);
|
||||
}
|
||||
|
||||
let lowCarbonEnergy: number | undefined;
|
||||
|
||||
let homeLowCarbonCircumference: number | undefined;
|
||||
let homeHighCarbonCircumference: number | undefined;
|
||||
let highCarbonConsumption: number | undefined;
|
||||
|
||||
// This fallback is used in the demo
|
||||
let electricityMapUrl = "https://app.electricitymaps.com";
|
||||
@@ -360,7 +348,6 @@ class HuiEnergyDistrubutionCard
|
||||
if (highCarbonEnergy !== null) {
|
||||
lowCarbonEnergy = totalFromGrid - highCarbonEnergy;
|
||||
|
||||
let highCarbonConsumption: number;
|
||||
if (gridConsumption !== totalFromGrid) {
|
||||
// Only get the part that was used for consumption and not the battery
|
||||
highCarbonConsumption =
|
||||
@@ -368,18 +355,23 @@ class HuiEnergyDistrubutionCard
|
||||
} else {
|
||||
highCarbonConsumption = highCarbonEnergy;
|
||||
}
|
||||
|
||||
homeHighCarbonCircumference =
|
||||
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
|
||||
|
||||
homeLowCarbonCircumference =
|
||||
CIRCLE_CIRCUMFERENCE -
|
||||
(homeSolarCircumference || 0) -
|
||||
(homeBatteryCircumference || 0) -
|
||||
homeHighCarbonCircumference;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
solar: homeSolarCircumference,
|
||||
battery: homeBatteryCircumference,
|
||||
lowCarbon: homeLowCarbonCircumference,
|
||||
grid: homeGridCircumference,
|
||||
} = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: solarConsumption ?? 0,
|
||||
usedBattery: batteryConsumption ?? 0,
|
||||
usedGrid: gridConsumption,
|
||||
hasSolar: hasSolarProduction,
|
||||
hasGrid: Boolean(hasGrid),
|
||||
highCarbonConsumption,
|
||||
});
|
||||
|
||||
const totalLines =
|
||||
gridConsumption +
|
||||
(solarConsumption || 0) +
|
||||
@@ -670,16 +662,8 @@ class HuiEnergyDistrubutionCard
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="38"
|
||||
stroke-dasharray="${
|
||||
homeHighCarbonCircumference ??
|
||||
CIRCLE_CIRCUMFERENCE -
|
||||
homeSolarCircumference! -
|
||||
(homeBatteryCircumference || 0)
|
||||
} ${
|
||||
homeHighCarbonCircumference !== undefined
|
||||
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
|
||||
: homeSolarCircumference! +
|
||||
(homeBatteryCircumference || 0)
|
||||
stroke-dasharray="${homeGridCircumference} ${
|
||||
CIRCLE_CIRCUMFERENCE - (homeGridCircumference ?? 0)
|
||||
}"
|
||||
stroke-dashoffset="0"
|
||||
shape-rendering="geometricPrecision"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Protects the energy-distribution home ring from overflowing when hourly
|
||||
* allocation yields more used_solar/used_battery/used_grid than net used_total
|
||||
* (https://github.com/home-assistant/frontend/issues/54185).
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE,
|
||||
computeEnergyDistributionHomeCircleArcs,
|
||||
} from "../../../../../src/panels/lovelace/cards/energy/energy-distribution-home-circle";
|
||||
|
||||
const sumDefinedArcs = (
|
||||
arcs: ReturnType<typeof computeEnergyDistributionHomeCircleArcs>
|
||||
): number =>
|
||||
(arcs.solar ?? 0) +
|
||||
(arcs.battery ?? 0) +
|
||||
(arcs.lowCarbon ?? 0) +
|
||||
(arcs.grid ?? 0);
|
||||
|
||||
describe("computeEnergyDistributionHomeCircleArcs", () => {
|
||||
it("sizes arcs from allocated home flows so they fill the circle", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 8.5,
|
||||
usedBattery: 4.6,
|
||||
usedGrid: 0.12,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.approximately(arcs.solar!, CIRCLE * (8.5 / 13.22), 1e-6);
|
||||
assert.approximately(arcs.battery!, CIRCLE * (4.6 / 13.22), 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * (0.12 / 13.22), 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
assert.isAtLeast(arcs.grid!, 0);
|
||||
});
|
||||
|
||||
it("does not paint a full grid ring when used_solar exceeds net home energy", () => {
|
||||
// Screenshot totals from #54185 with solar and export in different hours:
|
||||
// used_solar 77.1, used_battery 0, used_grid 0, net used_total 13.22.
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 77.1,
|
||||
usedBattery: 0,
|
||||
usedGrid: 0,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.approximately(arcs.solar!, CIRCLE, 1e-6);
|
||||
assert.isUndefined(arcs.battery);
|
||||
assert.equal(arcs.grid, 0);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
|
||||
it("keeps a zero-consumption ring from producing NaN or negative dashes", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 0,
|
||||
usedBattery: 0,
|
||||
usedGrid: 0,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(arcs, {});
|
||||
});
|
||||
|
||||
it("splits grid into low-carbon and high-carbon without exceeding the grid share", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 4,
|
||||
usedBattery: 0,
|
||||
usedGrid: 6,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 2,
|
||||
});
|
||||
|
||||
const ringTotal = 10;
|
||||
assert.approximately(arcs.solar!, CIRCLE * (4 / ringTotal), 1e-6);
|
||||
assert.approximately(arcs.lowCarbon!, CIRCLE * (4 / ringTotal), 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * (2 / ringTotal), 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
assert.isAtLeast(arcs.lowCarbon!, 0);
|
||||
assert.isAtLeast(arcs.grid!, 0);
|
||||
});
|
||||
|
||||
it("clamps high-carbon consumption to the grid share", () => {
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 5,
|
||||
usedBattery: 0,
|
||||
usedGrid: 5,
|
||||
hasSolar: true,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 50,
|
||||
});
|
||||
|
||||
assert.equal(arcs.lowCarbon, 0);
|
||||
assert.approximately(arcs.grid!, CIRCLE * 0.5, 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
|
||||
it("keeps a defined zero low-carbon arc so a grid-only high-carbon ring still renders", () => {
|
||||
// The card only mounts the home SVG when solar or lowCarbon is defined.
|
||||
// Omitting 0 would drop battery/grid arcs in a no-solar 100% fossil grid.
|
||||
const arcs = computeEnergyDistributionHomeCircleArcs({
|
||||
usedSolar: 0,
|
||||
usedBattery: 4,
|
||||
usedGrid: 6,
|
||||
hasSolar: false,
|
||||
hasGrid: true,
|
||||
highCarbonConsumption: 6,
|
||||
});
|
||||
|
||||
assert.isUndefined(arcs.solar);
|
||||
assert.equal(arcs.lowCarbon, 0);
|
||||
assert.approximately(arcs.battery!, CIRCLE * 0.4, 1e-6);
|
||||
assert.approximately(arcs.grid!, CIRCLE * 0.6, 1e-6);
|
||||
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user