From 2ca15d27d0d138e16ec8afe3a4dd5bbc0a7e71dd Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:30:01 +0000 Subject: [PATCH 1/6] feat: show how a total splits into parts as a pie or donut The new "Pie / donut" style (group "Parts of a total") draws the card's entities as slices of one circle, sized by their current values, with a legend beside it (below it on a narrow card) giving each entity's colour, name, value and share. Shares are whole percentages that always add up to 100. A donut shows the total of its slices in the middle, with the shared unit or a caption of your own under it. Each slice is a circle drawn as a dash of the slice's length and turned to its start with a CSS transform, so slices grow in when the card appears and glide to their new size when a value changes. Animation is off when the card's animate option is off or the browser asks for reduced motion. Options, all in the editor's Chart section: shape: donut | pie (default donut) center: total | none (donut; default total) center_label: kWh today (line under the total; default the unit) color_by: series | bands | shades shade_color: green (for shades; default primary) gap: 2 (0 to 10; space between slices) rounded: true (donut slices get round ends) gradient: true (each slice fades lighter towards the middle) show_percent: false (hide the share column) A pie's gaps are lines in the card's background colour, so they stay the same width down to the centre; a donut's slices are shortened instead. Only positive numbers become slices. Unavailable, missing, zero and negative entities stay in the legend with a hollow swatch and no share, so nothing silently disappears; with nothing to draw the card shows an empty ring. Tapping a slice or legend row runs the entity's tap action (more-info by default). Drilling into a slice's own parts is not built: it needs a way to nest entities under a slice, which is a larger change. A navigate tap action to a view of the parts does the job meanwhile. The share maths, legend, option checks and Chart form live in src/chart/parts.ts, shared with the other "parts of a total" charts. Closes #15 --- src/chart/parts.ts | 414 +++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/styles/pie.ts | 242 +++++++++++++++++++++++ src/types.ts | 4 +- test/chart-style.test.ts | 2 +- test/config.test.ts | 2 +- test/parts.test.ts | 237 ++++++++++++++++++++++ test/pie.test.ts | 217 ++++++++++++++++++++ 8 files changed, 1117 insertions(+), 3 deletions(-) create mode 100644 src/chart/parts.ts create mode 100644 src/styles/pie.ts create mode 100644 test/parts.test.ts create mode 100644 test/pie.test.ts diff --git a/src/chart/parts.ts b/src/chart/parts.ts new file mode 100644 index 0000000..d6fdf8c --- /dev/null +++ b/src/chart/parts.ts @@ -0,0 +1,414 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { isInteractive } from '../actions'; +import { baseColor } from '../bands'; +import { applyFormData, formData } from '../editor/schemas'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { languageOf, localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; + +/* + * Shared pieces for the "parts of a total" charts (pie and donut, rings, pictograph, waffle): + * the share maths, part colours, the legend beside the drawing, option checks and the Chart + * section's form. They all draw from the entities' current values, so no history is involved. + */ + +registerStrings('en', { + editor_color_by: 'Colour', + editor_color_by_series: 'One colour per entity', + editor_color_by_bands: 'By colour band', + editor_color_by_shades: 'Shades of one colour', + editor_shade_color: 'Shade colour (palette name or CSS colour)', + editor_show_percent: 'Show each share as a percentage', + editor_center: 'In the middle', + editor_center_total: 'Total', + editor_center_average: 'Average', + editor_center_none: 'Nothing', + parts_total: 'Total', + parts_average: 'Average', + parts_aria: '{style} of {names}', +}); + +/** Colours in order, for entities without their own `color`. */ +export const SERIES_COLORS = ['primary', 'amber', 'green', 'purple', 'red', '#14b8a6', '#ec4899', 'grey']; + +export type ColorBy = 'series' | 'bands' | 'shades'; + +/** Each entity's size as a part: its value when it's a positive number, else 0 (not drawn). */ +export function partValues(items: ResolvedEntity[]): number[] { + return items.map((e) => (e.available && (e.value as number) > 0 ? (e.value as number) : 0)); +} + +/** Each value as a fraction of their sum; all 0 when there's nothing to share. */ +export function shares(values: number[]): number[] { + const total = values.reduce((a, v) => a + Math.max(0, v), 0); + return values.map((v) => (total > 0 ? Math.max(0, v) / total : 0)); +} + +/** + * Whole numbers in proportion to `weights` that add up to exactly `total` (largest remainder): + * percentages that sum to 100, or squares of a 10 × 10 waffle. + */ +export function apportion(weights: number[], total = 100): number[] { + const sum = weights.reduce((a, w) => a + Math.max(0, w), 0); + if (sum <= 0) return weights.map(() => 0); + const exact = weights.map((w) => (Math.max(0, w) / sum) * total); + const whole = exact.map(Math.floor); + let left = total - whole.reduce((a, n) => a + n, 0); + const order = exact.map((x, i) => ({ i, rest: x - whole[i] })).sort((a, b) => b.rest - a.rest || a.i - b.i); + for (const { i } of order) { + if (left <= 0) break; + whole[i] += 1; + left -= 1; + } + return whole; +} + +/** An angle range in degrees, clockwise from 12 o'clock. */ +export interface Arc { + start: number; + end: number; +} + +/** Consecutive arcs, one per share, laid end to end from `from` to `to` degrees. */ +export function arcs(fractions: number[], from = 0, to = 360): Arc[] { + const span = to - from; + let at = from; + return fractions.map((f) => { + const start = at; + at += f * span; + return { start, end: at }; + }); +} + +/** An arc made shorter by `deg` in all (half off each end), for gaps or round caps; never negative. */ +export function shrink(arc: Arc, deg: number): Arc { + const length = arc.end - arc.start; + const cut = Math.min(Math.max(0, deg), length) / 2; + return { start: arc.start + cut, end: arc.end - cut }; +} + +/** Degrees of a circle of radius `r` that a straight length `len` covers. */ +export function degreesFor(len: number, r: number): number { + return r > 0 ? (len / r) * (180 / Math.PI) : 0; +} + +/** + * Style for a `.seg` circle (pathLength 360) that draws `arc`: a dash as long as the arc, turned to + * its start. Both are CSS properties, so a changed value animates to its new place. + */ +export function segStyle(arc: Arc): string { + const length = Math.max(0, arc.end - arc.start); + return `--a:${(arc.start - 90).toFixed(3)}deg;--len:${length.toFixed(3)}`; +} + +/** A colour `index` steps lighter than `color` (towards the card background), across `count` parts. */ +export function shade(color: string, index: number, count: number): string { + if (count <= 1 || index <= 0) return color; + const strength = Math.round(100 - (Math.min(index, count - 1) / (count - 1)) * 65); + return `color-mix(in srgb, ${color} ${strength}%, var(--card-background-color, #fff))`; +} + +/** An entity's own `color` (set per series), if any. */ +function ownColor(e: ResolvedEntity): string | undefined { + const own = (e.config as { color?: unknown }).color; + return typeof own === 'string' && own.trim() !== '' ? own : undefined; +} + +/** The colour of the part drawn for item `index` of `count`. */ +export function partColor( + e: ResolvedEntity, + index: number, + count: number, + colorBy: ColorBy, + shadeColor = 'primary', +): string { + if (colorBy === 'bands') return baseColor(e.band.color); + if (colorBy === 'shades') return shade(baseColor(shadeColor), index, count); + return baseColor(ownColor(e) ?? SERIES_COLORS[index % SERIES_COLORS.length]); +} + +/** The unit every available item shares, or '' when they differ. */ +export function sharedUnit(items: ResolvedEntity[]): string { + const units = new Set(items.filter((e) => e.available).map((e) => e.unit)); + return units.size === 1 ? [...units][0] : ''; +} + +/** Sum or mean of the available values, formatted with the most decimals any of them uses. */ +export function summary( + items: ResolvedEntity[], + kind: 'total' | 'average', + hass: HomeAssistant | undefined, + values = items.map((e) => (e.available ? (e.value as number) : 0)), +): { value: number; text: string; unit: string } | undefined { + const counted = items.map((e, i) => ({ e, v: values[i] })).filter(({ e }) => e.available); + if (!counted.length) return undefined; + const sum = counted.reduce((a, { v }) => a + v, 0); + const value = kind === 'total' ? sum : sum / counted.length; + const decimals = Math.max(...counted.map(({ e }) => e.decimals)); + return { value, text: formatNumber(value, decimals, hass), unit: sharedUnit(items) }; +} + +/** tabindex for an entity's legend row or tile: focusable only when an action is set. */ +export function tabFor(e: ResolvedEntity): number | typeof nothing { + return isInteractive(e.config) ? 0 : nothing; +} + +/** One row of the legend beside the drawing. */ +export interface LegendRow { + index: number; + entity: ResolvedEntity; + color: string; + /** e.g. "40 %"; left out when unset. */ + share?: string; +} + +/** The legend: a tappable row per entity with its colour, name, value and share. */ +export function partsLegend(rows: LegendRow[], ctx: ChartContext): TemplateResult | typeof nothing { + const { show } = ctx.config; + if (!rows.length || (!show.name && !show.value && rows.every((r) => r.share === undefined))) return nothing; + return html`
+ ${rows.map( + (r) => + html`
+ + ${show.name ? html`${r.entity.name}` : nothing} + ${show.value ? html`${r.entity.display}` : nothing} + ${r.share === undefined ? nothing : html`${r.share}`} +
`, + )} +
`; +} + +/** "40 %" in the user's number format. */ +export function percentText(n: number, hass: HomeAssistant | undefined): string { + return withUnit(formatNumber(n, 0, hass), '%'); +} + +/** The drawing's accessible name: "