+ const r = right && options.y && right.value.ticks.length > 0 ? right : undefined;
+ const rSizer = r
+ ? html`
${r.value.ticks
+ .map((v) => formatNumber(v, r.value.decimals, ctx.hass))
+ .reduce((a, b) => (b.length > a.length ? b : a), '')}`
+ : nothing;
+ const rLabels = r
+ ? html`
${outside ? rSizer : nothing}${rightLabels(r, ctx)}
`
+ : nothing;
+ return html`
${outside && y ? html`
${sizer}${yTicks}
` : nothing}
- ${plot(content, label, defs, gridLines(axes))} ${outside ? nothing : html`${yLabels}${xLabels}`} ${overlay}
+ ${plot(content, label, defs, gridLines(axes))} ${outside ? nothing : html`${yLabels}${rLabels}${xLabels}`}
+ ${overlay}
- ${outside ? xLabels : nothing}
+ ${outside ? html`${rLabels}${xLabels}` : nothing}
`;
}
@@ -249,6 +291,22 @@ export const gridStyles = css`
.x-axis .axis-label.end {
transform: translateX(-100%);
}
+ .plot-wrap.has-right {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ }
+ .axes-outside .y-axis.right {
+ grid-column: 3;
+ padding-right: 0;
+ padding-left: 6px;
+ }
+ .axes-outside .y-axis.right .axis-label {
+ right: auto;
+ left: 6px;
+ }
+ .axes-inside .y-axis.right .axis-label {
+ left: auto;
+ right: 2px;
+ }
.axes-inside .y-axis,
.axes-inside .x-axis {
position: absolute;
diff --git a/src/styles/column.ts b/src/styles/column.ts
index 06fdb42..7282b78 100644
--- a/src/styles/column.ts
+++ b/src/styles/column.ts
@@ -140,7 +140,7 @@ interface Drawn {
}
/** A column from `lo` to `hi` (values), `left`/`width` in plot units, with its outer end(s) rounded. */
-function column(
+export function column(
scales: PlotScales,
left: number,
width: number,
diff --git a/src/styles/combined.ts b/src/styles/combined.ts
new file mode 100644
index 0000000..82096f7
--- /dev/null
+++ b/src/styles/combined.ts
@@ -0,0 +1,420 @@
+import { css, html, nothing, svg } from 'lit';
+import type { TemplateResult } from 'lit';
+import { formatDateTime, validateAxisOptions, valueAxis } from '../chart/axes';
+import {
+ COLUMN_AGGREGATES,
+ COLUMN_GROUPS,
+ autoGroupUnit,
+ columnBox,
+ columnTable,
+ isMeter,
+ spread,
+} from '../chart/columns';
+import {
+ VIEW_H,
+ VIEW_W,
+ disconnectHistory,
+ frameMessage,
+ frameStyles,
+ historyFrame,
+ periodNav,
+ seriesHeader,
+ validateHistoryOptions,
+} from '../chart/frame';
+import type { ChartSeries } from '../chart/frame';
+import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid';
+import type { PlotScales } from '../chart/grid';
+import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout';
+import { CURVES } from '../chart/paths';
+import { linear } from '../chart/scale';
+import { DEFAULT_LINE_WIDTH, validateSeriesStyle } from '../chart/series-style';
+import {
+ hoverHandlers,
+ hoverTime,
+ stopHover,
+ tooltipEnabled,
+ tooltipLayer,
+ tooltipStyles,
+ validateTooltipOptions,
+} from '../chart/tooltip';
+import {
+ AXES_FIELDS,
+ HISTORY_FIELDS,
+ LAYOUT_FIELDS,
+ axesSchema,
+ cachedSchema,
+ chartForm,
+ choice,
+ historySchema,
+ historyVariant,
+ layoutSchema,
+} from '../editor/chart-editors';
+import { LINE_WIDTH_SELECTOR } from '../editor/schemas';
+import type { FieldBinding } from '../editor/schemas';
+import { AGGREGATES, parseGrouping } from '../history/group';
+import type { Aggregate } from '../history/group';
+import { parsePeriod } from '../history/period';
+import type { Point } from '../history/types';
+import { localize, registerStrings } from '../localize';
+import type { ResolvedEntity } from '../resolve';
+import type { CardConfig } from '../types';
+import { areaStyles, drawArea, zeroFloor } from './area';
+import { column, columnStyles, groupHeading } from './column';
+import { seriesPath } from './line';
+import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index';
+
+export const SERIES_TYPES = ['line', 'column', 'area'] as const;
+export type SeriesType = (typeof SERIES_TYPES)[number];
+export const Y_AXES = ['auto', 'left', 'right'] as const;
+type Side = 'left' | 'right';
+
+/** Gaps between value labels on the right axis, as on the left. */
+const VALUE_GAPS = 4;
+
+registerStrings('en', {
+ editor_style_combined: 'Combined',
+ editor_series_type: 'Draw as',
+ editor_series_type_line: 'Line',
+ editor_series_type_column: 'Columns',
+ editor_series_type_area: 'Area',
+ editor_series_axis: 'Value axis',
+ editor_series_axis_auto: 'Automatic (by unit)',
+ editor_series_axis_left: 'Left',
+ editor_series_axis_right: 'Right',
+ editor_series_aggregate: 'Combine with',
+ editor_series_aggregate_auto: 'As the card',
+ editor_series_aggregate_mean: 'Average',
+ editor_series_aggregate_sum: 'Sum',
+ editor_series_aggregate_min: 'Lowest',
+ editor_series_aggregate_max: 'Highest',
+ editor_series_aggregate_first: 'First',
+ editor_series_aggregate_last: 'Last',
+ editor_series_aggregate_change: 'Change (meters that count up)',
+});
+
+type Own = { type?: SeriesType; y_axis?: Side; aggregate?: Aggregate };
+const own = (e: ResolvedEntity) => e.config as unknown as Own;
+
+/** How a series is drawn: its own `type`, else a line. */
+export const seriesType = (e: ResolvedEntity): SeriesType => own(e).type ?? 'line';
+
+/**
+ * Which value axis each series uses: its own `y_axis`, else left for the first series' unit and
+ * right for any other. When nothing ends up on the left, everything moves there.
+ */
+export function axisSides(items: readonly ResolvedEntity[]): Side[] {
+ const first = items[0]?.unit;
+ const sides = items.map((e) => own(e).y_axis ?? (e.unit === first ? 'left' : 'right'));
+ return sides.includes('left') ? sides : sides.map(() => 'left');
+}
+
+/**
+ * The card config the shared frame reads. With any column series and no `group_by` (or `auto`),
+ * readings are grouped to fit the span, as on the column chart; without columns, auto means not
+ * grouped. `none` never groups.
+ */
+export function combinedRaw(raw: CardConfig, items: readonly ResolvedEntity[]): CardConfig {
+ const out: Record
= { ...raw };
+ const columns = items.some((e) => seriesType(e) === 'column');
+ if (raw.group_by === 'none' || ((raw.group_by === undefined || raw.group_by === 'auto') && !columns)) {
+ delete out.group_by;
+ } else if (raw.group_by === undefined || raw.group_by === 'auto') {
+ out.group_by = autoGroupUnit(parsePeriod(raw.period as CardConfig['period']));
+ }
+ if (raw.aggregate === 'auto') delete out.aggregate;
+ return out as CardConfig;
+}
+
+/**
+ * How one series' groups are combined: its own `aggregate`, else the card's when set, else a
+ * meter's change (energy per day) and anything else's average (the day's temperature).
+ */
+export function seriesAggregate(e: ResolvedEntity, raw: CardConfig): Aggregate {
+ const card = raw.aggregate;
+ return own(e).aggregate ?? (card && card !== 'auto' ? (card as Aggregate) : isMeter(e) ? 'change' : 'mean');
+}
+
+function validate(raw: CardConfig): void {
+ if (raw.group_by !== undefined && !COLUMN_GROUPS.includes(raw.group_by as (typeof COLUMN_GROUPS)[number])) {
+ throw new Error(`"group_by" must be one of: ${COLUMN_GROUPS.join(', ')}`);
+ }
+ if (raw.aggregate !== undefined && !COLUMN_AGGREGATES.includes(raw.aggregate as (typeof COLUMN_AGGREGATES)[number])) {
+ throw new Error(`"aggregate" must be one of: ${COLUMN_AGGREGATES.join(', ')}`);
+ }
+ const shared: Record = { ...raw };
+ if (raw.group_by === 'auto' || raw.group_by === 'none') delete shared.group_by;
+ if (raw.aggregate === 'auto') delete shared.aggregate;
+ validateHistoryOptions(shared as CardConfig);
+ parseGrouping(shared as CardConfig);
+ validateAxisOptions(raw);
+ validateTooltipOptions(raw);
+ validateSeriesStyle(raw);
+ validateLayout(raw);
+ if (raw.rounded !== undefined && typeof raw.rounded !== 'boolean') throw new Error('"rounded" must be true or false');
+ for (const e of raw.entities ?? []) {
+ if (typeof e !== 'object') continue;
+ const o = e as unknown as Own;
+ if (o.type !== undefined && !SERIES_TYPES.includes(o.type)) {
+ throw new Error(`"type" of ${e.entity} must be one of: ${SERIES_TYPES.join(', ')}`);
+ }
+ if (o.y_axis !== undefined && o.y_axis !== 'left' && o.y_axis !== 'right') {
+ throw new Error(`"y_axis" of ${e.entity} must be one of: left, right`);
+ }
+ if (o.aggregate !== undefined && !AGGREGATES.includes(o.aggregate)) {
+ throw new Error(`"aggregate" of ${e.entity} must be one of: ${AGGREGATES.join(', ')}`);
+ }
+ }
+}
+
+/** Lowest and highest of the given series' values, with zero in when columns stand on it. */
+function sideExtent(series: ChartSeries[], types: SeriesType[]): [number, number] | undefined {
+ const values = series.flatMap((s) => s.points.map((p) => p.v));
+ return spread(values, types.includes('column'));
+}
+
+function render(ctx0: ChartContext): TemplateResult {
+ const ctx = { ...ctx0, raw: combinedRaw(ctx0.raw, ctx0.items) };
+ const raw = ctx.raw;
+ const frame = historyFrame(ctx, Date.now(), { aggregate: (e) => seriesAggregate(e, ctx0.raw) });
+ const nav = periodNav(frame, ctx);
+ const names = seriesHeader(frame, ctx);
+ const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`;
+ const message = frameMessage(frame, ctx);
+ const layout = chartLayout(raw);
+ if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message);
+ const types = frame.series.map((s) => seriesType(s.entity));
+ const sides = axisSides(frame.series.map((s) => s.entity));
+ const group = frame.group;
+ // Grouped, each reading is drawn in the middle of its group, so lines pass over the columns.
+ const series: ChartSeries[] = frame.series.map((s) =>
+ group ? { ...s, points: s.points.map((p) => ({ ...p, t: p.end !== undefined ? (p.t + p.end) / 2 : p.t })) } : s,
+ );
+ const lastEnd = group
+ ? Math.max(frame.range.end, ...frame.series.flatMap((s) => s.points.map((p) => p.end ?? 0)))
+ : 0;
+ const xRange = group ? { ...frame.range, end: lastEnd } : frame.range;
+ const on = (side: Side) => series.filter((_, i) => sides[i] === side);
+ const typesOn = (side: Side) => types.filter((_, i) => sides[i] === side);
+ // Left: the shared axes (options, gridlines, a set lowest/highest). Columns stand on zero, and
+ // areas reaching down near it do.
+ const leftData = sideExtent(on('left'), typesOn('left'));
+ let leftRaw = raw;
+ if (leftData && typesOn('left').includes('column')) {
+ leftRaw = {
+ ...raw,
+ y_min: raw.y_min ?? (leftData[0] >= 0 ? 0 : undefined),
+ y_max: raw.y_max ?? (leftData[1] <= 0 ? 0 : undefined),
+ };
+ } else if (typesOn('left').includes('area')) leftRaw = zeroFloor(leftData, raw);
+ const axes = plotAxes({ ...ctx, raw: leftRaw }, xRange, leftData);
+ const left = axes.scales;
+ // Right: its own round steps over its series' readings.
+ const rightSeries = on('right');
+ const rightData = sideExtent(rightSeries, typesOn('right'));
+ const rightFixed: { min?: number; max?: number } = {};
+ if (rightData && (typesOn('right').includes('column') || typesOn('right').includes('area'))) {
+ if (rightData[0] >= 0 && rightData[0] <= rightData[1] / 2) rightFixed.min = 0;
+ if (rightData[1] <= 0 && rightData[1] >= rightData[0] / 2) rightFixed.max = 0;
+ }
+ const rightAxis = rightSeries.length ? valueAxis(rightData, rightFixed, VALUE_GAPS) : undefined;
+ const right: PlotScales | undefined = rightAxis
+ ? { x: left.x, y: linear(rightAxis.domain, [VIEW_H, 0]), domain: rightAxis.domain }
+ : undefined;
+ const scalesOf = (s: ChartSeries) => (sides[s.index] === 'right' && right ? right : left);
+
+ // Areas at the back, then columns, then lines on top.
+ const areas = series
+ .filter((s) => types[s.index] === 'area')
+ .map((s) => drawArea(s, s.points, scalesOf(s), raw, { range: xRange }));
+ const lines = series
+ .filter((s) => types[s.index] === 'line')
+ .map((s) => seriesPath({ ...s, points: s.points }, scalesOf(s), s.color, xRange, raw));
+ const columnSeries = series.filter((s) => types[s.index] === 'column');
+ const table = columnTable(
+ frame.series.filter((s) => types[s.index] === 'column').map((s) => s.points),
+ group ? Infinity : frame.range.end,
+ );
+ const radius = raw.rounded === false ? 0 : 4;
+ const columns: TemplateResult[] = [];
+ table.times.forEach((t, i) => {
+ const x0 = left.x(t);
+ const x1 = left.x(table.ends[i]);
+ columnSeries.forEach((s, k) => {
+ const v = table.values[k][i];
+ if (v === null) return;
+ const sc = scalesOf(s);
+ const base = Math.min(Math.max(0, sc.domain[0]), sc.domain[1]);
+ const [l, w] = columnBox(x0, x1, k, columnSeries.length);
+ columns.push(
+ column(sc, l, w, Math.min(base, v), Math.max(base, v), s.color, v >= base ? 'top' : 'bottom', s.index),
+ );
+ });
+ });
+ // Column series read their slot (middle) in the tooltip; others their nearest reading.
+ const centres = table.times.map((t, i) => (t + table.ends[i]) / 2);
+ const shown = series.map((s) => {
+ if (types[s.index] !== 'column' || group) return s;
+ const k = columnSeries.indexOf(s);
+ return { ...s, points: centres.map((t, i): Point => ({ t, v: table.values[k][i] })) };
+ });
+ const bounds = new Map();
+ for (const s of frame.series)
+ for (const p of s.points) if (p.end !== undefined) bounds.set((p.t + p.end) / 2, [p.t, p.end]);
+ const tooltip = tooltipEnabled(raw);
+ const label = localize(ctx.hass, 'chart_aria', {
+ style: localize(ctx.hass, 'editor_style_combined'),
+ names: frame.series.map((s) => s.entity.name).join(', '),
+ });
+ return chartFrame(
+ layout,
+ header,
+ chartArea({
+ axes,
+ ctx,
+ right: rightAxis && right ? { value: rightAxis, y: right.y } : undefined,
+ content: svg`${areas.map((a) => a.body)}`,
+ defs: svg`${areas.map((a) => a.defs)}`,
+ label,
+ overlay: html`${columns}
+
+ ${layout.header === 'over' ? headerOver(header) : nothing}
+ ${
+ tooltip
+ ? tooltipLayer(ctx, shown, left, hoverTime(ctx.host), plotWidth(ctx), {
+ dotY: (s, p) => (types[s.index] === 'column' || p.v === null ? null : scalesOf(s).y(p.v)),
+ heading: (t) => {
+ const b = bounds.get(t);
+ return b && group ? groupHeading(b[0], b[1], group.unit, ctx.hass) : formatDateTime(t, ctx.hass);
+ },
+ })
+ : nothing
+ }`,
+ hover: tooltip
+ ? hoverHandlers(
+ ctx,
+ xRange,
+ shown.map((s) => s.points),
+ )
+ : undefined,
+ }),
+ );
+}
+
+const COMBINED_FIELDS: FieldBinding[] = [
+ ...HISTORY_FIELDS.map((f) => (f.name === 'group_by' || f.name === 'aggregate' ? { ...f, default: 'auto' } : f)),
+ { name: 'curve', path: ['curve'], default: 'smooth' },
+ { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH },
+ { name: 'dots', path: ['dots'], default: false },
+ { name: 'rounded', path: ['rounded'], default: true },
+ ...AXES_FIELDS,
+ ...LAYOUT_FIELDS,
+];
+
+export function combinedEditor(ctx: ChartEditorContext): TemplateResult {
+ const ungrouped = ctx.config.group_by === 'none';
+ const period = { ...ctx.config, group_by: undefined };
+ const schema = cachedSchema(`combined|${historyVariant(period)}|${ungrouped}`, ctx.hass, () => [
+ // The shared period fields without their grouping row: this chart has its own choices.
+ ...historySchema(ctx.hass, period).slice(0, -1),
+ {
+ type: 'grid',
+ name: '',
+ schema: [
+ { name: 'group_by', selector: choice(ctx.hass, 'group_by', COLUMN_GROUPS) },
+ ...(ungrouped ? [] : [{ name: 'aggregate', selector: choice(ctx.hass, 'aggregate', COLUMN_AGGREGATES) }]),
+ ],
+ },
+ { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) },
+ {
+ type: 'grid',
+ name: '',
+ schema: [
+ { name: 'line_width', selector: LINE_WIDTH_SELECTOR },
+ { name: 'dots', selector: { boolean: {} } },
+ { name: 'rounded', selector: { boolean: {} } },
+ ],
+ },
+ ...axesSchema(ctx.hass),
+ ...layoutSchema(ctx.hass),
+ ]);
+ return chartForm(ctx, 'chart', schema, COMBINED_FIELDS);
+}
+
+const ENTITY_FIELDS: FieldBinding[] = [
+ { name: 'series_type', path: ['type'], default: 'line' },
+ { name: 'series_axis', path: ['y_axis'], default: 'auto' },
+ { name: 'series_color', path: ['color'] },
+ { name: 'series_aggregate', path: ['aggregate'], default: 'auto' },
+ { name: 'curve', path: ['curve'] },
+ { name: 'line_width', path: ['line_width'] },
+ { name: 'dash', path: ['dash'], default: false },
+ { name: 'negative_color', path: ['negative_color'] },
+];
+
+export const combined: ChartStyleModule = {
+ layout: 'chart',
+ group: 'history',
+ height: 240,
+ styles: css`
+ ${frameStyles}
+ ${gridStyles}
+ ${tooltipStyles}
+ ${layoutStyles}
+ ${areaStyles}
+ ${columnStyles}
+ .lines-over {
+ pointer-events: none;
+ }
+ .lines-over .line {
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+ .lines-over .dots {
+ stroke-linecap: round;
+ }
+ .lines-over .line.dashed {
+ stroke-dasharray: 6 5;
+ }
+ `,
+ thumbnail: () => svg`
+
+
+
+ `,
+ validate,
+ editor: combinedEditor,
+ entityFields: (hass) => ({
+ schema: cachedSchema('combined-entity', hass, () => [
+ {
+ type: 'grid',
+ name: '',
+ schema: [
+ { name: 'series_type', selector: choice(hass, 'series_type', SERIES_TYPES) },
+ { name: 'series_axis', selector: choice(hass, 'series_axis', Y_AXES) },
+ { name: 'series_color', selector: { text: {} } },
+ { name: 'series_aggregate', selector: choice(hass, 'series_aggregate', ['auto', ...AGGREGATES]) },
+ ],
+ },
+ {
+ type: 'grid',
+ name: '',
+ schema: [
+ { name: 'curve', selector: choice(hass, 'curve', CURVES) },
+ { name: 'line_width', selector: LINE_WIDTH_SELECTOR },
+ { name: 'dash', selector: { boolean: {} } },
+ { name: 'negative_color', selector: { text: {} } },
+ ],
+ },
+ ]),
+ fields: ENTITY_FIELDS,
+ }),
+ disconnect(host) {
+ disconnectHistory(host);
+ stopChartWidth(host);
+ stopHover(host);
+ },
+ render,
+};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index f74dbda..2a06cfe 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5,6 +5,7 @@ import type { CardConfig, HomeAssistant, NormalizedConfig, ShowOptions, StyleNam
import { area } from './area';
import { bar } from './bar';
import { columnChart } from './column';
+import { combined } from './combined';
import { line } from './line';
import { needle } from './needle';
import { arc, ring } from './radial';
@@ -110,4 +111,5 @@ export const STYLES: Record = {
line,
column: columnChart,
area,
+ combined,
};
diff --git a/src/styles/line.ts b/src/styles/line.ts
index 4db129e..33cfcbe 100644
--- a/src/styles/line.ts
+++ b/src/styles/line.ts
@@ -72,7 +72,8 @@ function bandGradient(id: string, bands: ResolvedBand[], scales: PlotScales): SV
`;
}
-function seriesPath(s: ChartSeries, scales: PlotScales, stroke: string, range: Range, raw: CardConfig) {
+/** One series' line (and dots), for the line chart and the combined chart. */
+export function seriesPath(s: ChartSeries, scales: PlotScales, stroke: string, range: Range, raw: CardConfig) {
const style = seriesStyle(s.entity.config, raw);
const thinned = downsample(s.points, MAX_POINTS);
const d = runs(thinned)
diff --git a/src/types.ts b/src/types.ts
index 3450938..f72ec6d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,6 @@
import type { Rule } from './fill';
-export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area';
+export type StyleName =
+ 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined';
export const STYLE_NAMES: StyleName[] = [
'bar',
'thin',
@@ -11,6 +12,7 @@ export const STYLE_NAMES: StyleName[] = [
'line',
'column',
'area',
+ 'combined',
];
export type SortMode = 'none' | 'ascending' | 'descending';
/** A number, or a string naming an entity whose state supplies the number. */
diff --git a/test/combined.test.ts b/test/combined.test.ts
new file mode 100644
index 0000000..e457edb
--- /dev/null
+++ b/test/combined.test.ts
@@ -0,0 +1,161 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import '../src/chartdeck-card';
+import { axisSides, combinedRaw, seriesAggregate, seriesType } from '../src/styles/combined';
+import type { ResolvedEntity } from '../src/resolve';
+import type { CardConfig } from '../src/types';
+import { H, ago, chartHass, mountCard, mountEditor, pointAt, q, qa, tooltipRows } from './helpers/chart';
+import type { Card } from './helpers/chart';
+
+const now = Date.now();
+const sec = (ms: number) => ago(now, ms);
+const D = 24 * H;
+
+const states = {
+ 'sensor.energy': ['1000', { friendly_name: 'Energy', unit_of_measurement: 'kWh', state_class: 'total_increasing' }],
+ 'sensor.temp': ['20', { friendly_name: 'Temp', unit_of_measurement: '°C', state_class: 'measurement' }],
+ 'sensor.power': ['300', { friendly_name: 'Power', unit_of_measurement: 'W' }],
+} as const;
+
+const history = {
+ 'sensor.energy': Array.from({ length: 11 }, (_, i) => ({ s: String(1000 + 10 * i), lu: sec((10 - i) * D + H) })),
+ 'sensor.temp': [
+ { s: '15', lu: sec(8 * D) },
+ { s: '25', lu: sec(3 * D) },
+ { s: '18', lu: sec(5 * H) },
+ ],
+ 'sensor.power': [
+ { s: '400', lu: sec(20 * H) },
+ { s: '-600', lu: sec(10 * H) },
+ { s: 'unavailable', lu: sec(5 * H) },
+ ],
+};
+
+const mount = (config: Record) =>
+ mountCard({ style: 'combined', ...config } as Partial, chartHass(states, history));
+
+const entity = (unit: string, extra: Record = {}, attrs: Record = {}) =>
+ ({ unit, config: { entity: 'sensor.x', ...extra }, stateObj: { attributes: attrs } }) as unknown as ResolvedEntity;
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('combined chart maths', () => {
+ it('puts a second unit on the right axis, unless told', () => {
+ expect(axisSides([entity('kWh'), entity('°C'), entity('kWh')])).toEqual(['left', 'right', 'left']);
+ expect(axisSides([entity('kWh'), entity('°C', { y_axis: 'left' })])).toEqual(['left', 'left']);
+ expect(axisSides([entity('kWh', { y_axis: 'right' })])).toEqual(['left']);
+ expect(seriesType(entity('W'))).toBe('line');
+ expect(seriesType(entity('W', { type: 'area' }))).toBe('area');
+ });
+
+ it('groups to fit only when there are columns, and combines each series its own way', () => {
+ const raw = { type: 'x', period: { last: 7, unit: 'day' } } as CardConfig;
+ const col = entity('kWh', { type: 'column' }, { state_class: 'total_increasing' });
+ expect(combinedRaw(raw, [col]).group_by).toBe('day');
+ expect(combinedRaw(raw, [entity('W')]).group_by).toBeUndefined();
+ expect(combinedRaw({ ...raw, group_by: 'none' }, [col]).group_by).toBeUndefined();
+ expect(combinedRaw({ ...raw, group_by: 'hour', aggregate: 'auto' }, [col])).toMatchObject({ group_by: 'hour' });
+ expect(combinedRaw({ ...raw, aggregate: 'auto' }, [col])).not.toHaveProperty('aggregate');
+ expect(seriesAggregate(col, raw)).toBe('change');
+ expect(seriesAggregate(entity('°C'), raw)).toBe('mean');
+ expect(seriesAggregate(entity('°C'), { ...raw, aggregate: 'max' })).toBe('max');
+ expect(seriesAggregate(entity('°C', { aggregate: 'min' }), { ...raw, aggregate: 'max' })).toBe('min');
+ });
+});
+
+describe('combined chart', () => {
+ it('draws daily energy columns with the average temperature as a line on a second axis', async () => {
+ const card = await mount({
+ period: { last: 7, unit: 'day' },
+ entities: [{ entity: 'sensor.energy', type: 'column' }, 'sensor.temp'],
+ });
+ expect(qa(card, '.col').length).toBeGreaterThanOrEqual(7);
+ expect(qa(card, '.lines-over path.line')).toHaveLength(1);
+ expect(q(card, '.plot-wrap')!.classList.contains('has-right')).toBe(true);
+ expect(qa(card, '.y-axis.right .axis-label').length).toBeGreaterThan(1);
+ expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Combined chart of Energy, Temp');
+ pointAt(q(card, '.plot-area')!, 'pointermove', 0.5);
+ await card.updateComplete;
+ const rows = tooltipRows(card);
+ expect(rows[0]).toBe('Energy 10.0 kWh');
+ expect(rows[1]).toMatch(/^Temp \d+\.\d °C$/);
+ // A dot on the line only.
+ expect(qa(card, '.hover-dot')).toHaveLength(1);
+ });
+
+ it('draws an area with a line, and all three together', async () => {
+ const two = await mount({
+ entities: [
+ { entity: 'sensor.power', type: 'area' },
+ { entity: 'sensor.temp', y_axis: 'left' },
+ ],
+ });
+ expect(qa(two, 'path.fill')).toHaveLength(1);
+ expect(qa(two, '.lines-over path.line')).toHaveLength(1);
+ expect(q(two, '.plot-wrap')!.classList.contains('has-right')).toBe(false);
+ const three = await mount({
+ period: { last: 2, unit: 'day' },
+ axis_labels: 'inside',
+ entities: [
+ { entity: 'sensor.power', type: 'area' },
+ { entity: 'sensor.energy', type: 'column', y_axis: 'right' },
+ 'sensor.temp',
+ ],
+ });
+ expect(qa(three, 'path.fill')).toHaveLength(1);
+ expect(qa(three, '.col').length).toBeGreaterThan(0);
+ expect(qa(three, '.lines-over path.line')).toHaveLength(1);
+ expect(q(three, '.plot-area .y-axis.right')).not.toBeNull();
+ });
+
+ it('draws one column per reading when not grouped', async () => {
+ const card = await mount({
+ group_by: 'none',
+ entities: [{ entity: 'sensor.power', type: 'column' }, 'sensor.temp'],
+ });
+ expect(qa(card, '.col')).toHaveLength(2);
+ pointAt(q(card, '.plot-area')!, 'pointermove', 1 - 15 / 24);
+ await card.updateComplete;
+ expect(tooltipRows(card)[0]).toBe('Power 400.0 W');
+ });
+
+ it('checks its options', () => {
+ const card = document.createElement('chartdeck-card') as Card;
+ const set = (extra: Record) => () =>
+ card.setConfig({ type: 'x', style: 'combined', entities: ['sensor.temp'], ...extra } as CardConfig);
+ expect(set({ entities: [{ entity: 'sensor.temp', type: 'pie' }] })).toThrow(/type/);
+ expect(set({ entities: [{ entity: 'sensor.temp', y_axis: 'middle' }] })).toThrow(/y_axis/);
+ expect(set({ entities: [{ entity: 'sensor.temp', aggregate: 'median' }] })).toThrow(/aggregate/);
+ expect(set({ group_by: 'year' })).toThrow(/group_by/);
+ expect(set({ aggregate: 'median' })).toThrow(/aggregate/);
+ expect(set({ rounded: 'no' })).toThrow(/rounded/);
+ expect(
+ set({
+ group_by: 'auto',
+ aggregate: 'auto',
+ entities: [{ entity: 'sensor.temp', type: 'area', y_axis: 'right' }],
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe('combined chart editor', () => {
+ it('edits the chart, and each entity’s type, axis and combining', async () => {
+ const raw = { type: 'custom:chartdeck-card', style: 'combined', entities: ['sensor.temp'] } as CardConfig;
+ const { form, change, events, entityFields } = await mountEditor(raw, chartHass(states, {}));
+ expect(form().data).toMatchObject({ group_by: 'auto', aggregate: 'auto', curve: 'smooth', rounded: true });
+ change({ group_by: 'none', rounded: false });
+ expect(events.at(-1)).toEqual({ ...raw, group_by: 'none', rounded: false });
+ expect(entityFields().fields.map((f) => f.name)).toEqual([
+ 'series_type',
+ 'series_axis',
+ 'series_color',
+ 'series_aggregate',
+ 'curve',
+ 'line_width',
+ 'dash',
+ 'negative_color',
+ ]);
+ });
+});