import { HomeAssistant } from 'custom-card-helpers';
import parse from 'parse-duration';
import {
DEFAULT_AREA_OPACITY,
DEFAULT_FLOAT_PRECISION,
DEFAULT_LEGEND_MARKER_WIDTH,
DEFAULT_SERIE_TYPE,
HOUR_24,
NO_VALUE,
PLAIN_COLOR_TYPES,
TIMESERIES_TYPES,
} from './const';
import { ChartCardConfig } from './types';
import { computeName, computeUom, is12Hour, mergeDeep, myFormatNumber, prettyPrintTime } from './utils';
import { layoutMinimal } from './layouts/minimal';
import { getLocales, getDefaultLocale } from './locales';
import GraphEntry from './graphEntry';
export function getLayoutConfig(
config: ChartCardConfig,
hass: HomeAssistant | undefined = undefined,
graphs: (GraphEntry | undefined)[] | undefined,
): unknown {
const locales = getLocales();
const def = {
chart: {
height: config.section_mode ? '100%' : undefined,
locales: [
(config.locale && locales[config.locale]) || (hass?.language && locales[hass.language]) || getDefaultLocale(),
],
defaultLocale:
(config.locale && locales[config.locale] && config.locale) ||
(hass?.language && locales[hass.language] && hass.language) ||
'en',
type: config.chart_type || DEFAULT_SERIE_TYPE,
stacked: config?.stacked,
foreColor: 'var(--primary-text-color)',
width: '100%',
zoom: {
enabled: false,
},
toolbar: {
show: false,
},
},
grid: {
strokeDashArray: 3,
},
fill: {
opacity: getFillOpacity(config, false),
type: getFillType(config, false),
},
series: getSeries(config, hass, false),
labels: getLabels(config, hass),
xaxis: getXAxis(config, hass),
yaxis: getYAxis(config),
tooltip: {
x: {
formatter: getXTooltipFormatter(config, hass),
},
y: {
formatter: getYTooltipFormatter(config, hass),
},
},
dataLabels: {
enabled: getDataLabelsEnabled(config),
enabledOnSeries: getDataLabels_enabledOnSeries(config),
formatter: getDataLabelsFormatter(config, graphs, hass),
},
plotOptions: {
radialBar: getPlotOptions_radialBar(config, hass),
},
legend: {
position: 'bottom',
show: true,
formatter: getLegendFormatter(config, hass),
markers: getLegendMarkers(config),
},
stroke: {
curve: getStrokeCurve(config, false),
lineCap: config.chart_type === 'radialBar' ? 'round' : 'butt',
colors:
config.chart_type === 'pie' || config.chart_type === 'donut' ? ['var(--card-background-color)'] : undefined,
width: getStrokeWidth(config, false),
dashArray: getStrokeDash(config, false),
},
markers: {
showNullDataPoints: false,
},
noData: {
text: 'Loading...',
},
};
let conf = {};
switch (config.layout) {
case 'minimal':
conf = layoutMinimal;
break;
default:
break;
}
return config.apex_config
? mergeDeep(mergeDeep(def, conf), evalApexConfig(config.apex_config))
: mergeDeep(def, conf);
}
export function getBrushLayoutConfig(
config: ChartCardConfig,
hass: HomeAssistant | undefined = undefined,
id: string,
): unknown {
const locales = getLocales();
const def = {
chart: {
locales: [
(config.locale && locales[config.locale]) || (hass?.language && locales[hass.language]) || getDefaultLocale(),
],
defaultLocale:
(config.locale && locales[config.locale] && config.locale) ||
(hass?.language && locales[hass.language] && hass.language) ||
'en',
type: config.chart_type || DEFAULT_SERIE_TYPE,
stacked: config?.stacked,
foreColor: 'var(--primary-text-color)',
width: '100%',
height: '120px',
zoom: {
enabled: false,
},
toolbar: {
show: false,
},
id: Math.random().toString(36).substring(7),
brush: {
target: id,
enabled: true,
},
},
grid: {
strokeDashArray: 3,
},
fill: {
opacity: getFillOpacity(config, true),
type: getFillType(config, true),
},
series: getSeries(config, hass, true),
xaxis: getXAxis(config, hass),
yaxis: {
tickAmount: 2,
decimalsInFloat: DEFAULT_FLOAT_PRECISION,
},
tooltip: {
enabled: false,
},
dataLabels: {
enabled: false,
},
legend: {
show: false,
},
stroke: {
curve: getStrokeCurve(config, true),
lineCap: config.chart_type === 'radialBar' ? 'round' : 'butt',
colors:
config.chart_type === 'pie' || config.chart_type === 'donut' ? ['var(--card-background-color)'] : undefined,
width: getStrokeWidth(config, true),
dashArray: getStrokeDash(config, false),
},
markers: {
showNullDataPoints: false,
},
noData: {
text: 'Loading...',
},
};
return config.brush?.apex_config ? mergeDeep(def, evalApexConfig(config.brush.apex_config)) : def;
}
function getFillOpacity(config: ChartCardConfig, brush: boolean): number[] {
const series = brush ? config.series_in_brush : config.series_in_graph;
return series.map((serie) => {
return serie.opacity !== undefined ? serie.opacity : serie.type === 'area' ? DEFAULT_AREA_OPACITY : 1;
});
}
function getSeries(config: ChartCardConfig, hass: HomeAssistant | undefined, brush: boolean) {
const series = brush ? config.series_in_brush : config.series_in_graph;
if (TIMESERIES_TYPES.includes(config.chart_type)) {
return series.map((serie, index) => {
return {
name: computeName(index, series, undefined, hass?.states[serie.entity]),
group: config.stacked && serie.type === 'column' ? serie.stack_group : undefined,
type: serie.type,
data: [],
};
});
} else {
return [];
}
}
function getLabels(config: ChartCardConfig, hass: HomeAssistant | undefined) {
if (TIMESERIES_TYPES.includes(config.chart_type)) {
return [];
} else {
return config.series_in_graph.map((serie, index) => {
return computeName(index, config.series_in_graph, undefined, hass?.states[serie.entity]);
});
}
}
function getXAxis(config: ChartCardConfig, hass: HomeAssistant | undefined) {
if (TIMESERIES_TYPES.includes(config.chart_type)) {
const hours12 = is12Hour(config, hass);
return {
type: 'datetime',
// range: getMilli(config.hours_to_show),
labels: {
datetimeUTC: false,
datetimeFormatter: getDateTimeFormatter(hours12),
},
};
} else {
return {};
}
}
function getYAxis(config: ChartCardConfig) {
return Array.isArray(config.apex_config?.yaxis) || config.yaxis
? undefined
: {
decimalsInFloat: DEFAULT_FLOAT_PRECISION,
};
}
function getDateTimeFormatter(hours12: boolean | undefined): unknown {
if (!hours12) {
return {
year: 'yyyy',
month: "MMM 'yy",
day: 'dd MMM',
hour: 'HH:mm',
minute: 'HH:mm:ss',
};
} else {
return {
year: 'yyyy',
month: "MMM 'yy",
day: 'dd MMM',
hour: 'hh:mm tt',
minute: 'hh:mm:ss tt',
};
}
}
function getXTooltipFormatter(
config: ChartCardConfig,
hass: HomeAssistant | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ((val: number, _a: any, _b: any) => string) | undefined {
if (config.apex_config?.tooltip?.x?.format) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let hours12: any = undefined;
const lang = config.locale || hass?.language || 'en';
hours12 = is12Hour(config, hass) ? { hour12: true } : { hourCycle: 'h23' };
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return parse(config.graph_span)! < HOUR_24 && !config.span?.offset
? function (val, _a, _b, hours_12 = hours12) {
return new Intl.DateTimeFormat(lang, {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
...hours_12,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any).format(val);
}
: function (val, _a, _b, hours_12 = hours12) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Intl.DateTimeFormat(lang, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
...hours_12,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any).format(val);
};
}
function getYTooltipFormatter(config: ChartCardConfig, hass: HomeAssistant | undefined) {
return function (value, opts, conf = config, hass2 = hass) {
let lValue = value;
if (conf.series_in_graph[opts.seriesIndex]?.invert && lValue) {
lValue = -lValue;
}
if (!conf.series_in_graph[opts.seriesIndex]?.show.as_duration) {
lValue = myFormatNumber(lValue, hass?.locale, conf.series_in_graph[opts.seriesIndex].float_precision);
}
const uom = computeUom(
opts.seriesIndex,
conf.series_in_graph,
undefined,
hass2?.states[conf.series_in_graph[opts.seriesIndex].entity],
);
return conf.series_in_graph[opts.seriesIndex]?.show.as_duration
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
[`${prettyPrintTime(lValue, conf.series_in_graph[opts.seriesIndex].show.as_duration!)}`]
: [`${lValue} ${uom}`];
};
}
function getDataLabelsEnabled(config: ChartCardConfig): boolean {
return (
!TIMESERIES_TYPES.includes(config.chart_type) ||
config.series_in_graph.some((serie) => {
return serie.show.datalabels;
})
);
}
function getDataLabelsFormatter(
config: ChartCardConfig,
graphs: (GraphEntry | undefined)[] | undefined,
hass: HomeAssistant | undefined,
) {
if (config.chart_type === 'pie' || config.chart_type === 'donut') {
return function (value, opts, lgraphs = graphs, conf = config, lHass = hass) {
if (conf.series_in_graph[opts.seriesIndex].show.datalabels !== false) {
if (conf.series_in_graph[opts.seriesIndex].show.datalabels === 'percent') {
return myFormatNumber(value, lHass?.locale, conf.series_in_graph[opts.seriesIndex].float_precision);
}
return myFormatNumber(
lgraphs?.[conf.series_in_graph[opts.seriesIndex].index]?.lastState,
lHass?.locale,
conf.series_in_graph[opts.seriesIndex].float_precision,
);
}
return '';
};
}
return function (value, opts, conf = config, lHass = hass) {
if (conf.series_in_graph[opts.seriesIndex].show.datalabels === 'total') {
return myFormatNumber(
opts.w.globals.stackedSeriesTotals[opts.dataPointIndex],
lHass?.locale,
conf.series_in_graph[opts.seriesIndex].float_precision,
);
}
if (value === null) return;
let lValue = value;
if (conf.series_in_graph[opts.seriesIndex]?.invert && lValue) {
lValue = -lValue;
}
return myFormatNumber(lValue, lHass?.locale, conf.series_in_graph[opts.seriesIndex].float_precision);
};
}
function getPlotOptions_radialBar(config: ChartCardConfig, hass: HomeAssistant | undefined) {
if (config.chart_type === 'radialBar') {
return {
track: {
background: 'rgba(128, 128, 128, 0.2)',
},
dataLabels: {
value: {
formatter: function (value, opts, conf = config, lHass = hass) {
const index = opts?.config?.series?.findIndex((x) => {
return parseFloat(value) === x;
});
if (index != -1) {
return myFormatNumber(value, lHass?.locale, conf.series_in_graph[index].float_precision) + '%';
}
return value;
},
},
},
};
} else {
return {};
}
}
function getLegendFormatter(config: ChartCardConfig, hass: HomeAssistant | undefined) {
return function (_, opts, conf = config, hass2 = hass) {
const name = computeName(
opts.seriesIndex,
conf.series_in_graph,
undefined,
hass2?.states[conf.series_in_graph[opts.seriesIndex].entity],
);
if (!conf.series_in_graph[opts.seriesIndex].show.in_legend) {
return [];
}
if (!conf.series_in_graph[opts.seriesIndex].show.legend_value) {
return [name];
} else {
let value = TIMESERIES_TYPES.includes(config.chart_type)
? opts.w.globals.series[opts.seriesIndex].slice(-1)[0]
: opts.w.globals.series[opts.seriesIndex];
if (conf.series_in_graph[opts.seriesIndex]?.invert && value) {
value = -value;
}
if (!conf.series_in_graph[opts.seriesIndex]?.show.as_duration) {
value = myFormatNumber(value, hass2?.locale, conf.series_in_graph[opts.seriesIndex].float_precision);
}
const uom =
config.chart_type === 'radialBar'
? '%'
: computeUom(
opts.seriesIndex,
conf.series_in_graph,
undefined,
hass2?.states[conf.series_in_graph[opts.seriesIndex].entity],
);
let valueString = '';
if (value === undefined || value === null) {
valueString = `${NO_VALUE} ${uom}`;
} else {
if (conf.series_in_graph[opts.seriesIndex]?.show.as_duration) {
valueString = `${prettyPrintTime(
value,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
conf.series_in_graph[opts.seriesIndex].show.as_duration!,
)}`;
} else {
valueString = `${value} ${uom}`;
}
}
return [name + ':', valueString];
}
};
}
function getLegendMarkers(config: ChartCardConfig) {
return { size: config.series_in_graph.map((serie) => (serie.show.in_legend ? DEFAULT_LEGEND_MARKER_WIDTH : 0)) };
}
function getStrokeCurve(config: ChartCardConfig, brush: boolean) {
const series = brush ? config.series_in_brush : config.series_in_graph;
return series.map((serie) => {
return serie.curve || 'smooth';
});
}
function getDataLabels_enabledOnSeries(config: ChartCardConfig) {
return config.series_in_graph.flatMap((serie, index) => {
return serie.show.datalabels ? [index] : [];
});
}
function getStrokeWidth(config: ChartCardConfig, brush: boolean) {
if (config.chart_type !== undefined && config.chart_type !== 'line')
return config.apex_config?.stroke?.width === undefined ? 3 : config.apex_config?.stroke?.width;
const series = brush ? config.series_in_brush : config.series_in_graph;
return series.map((serie) => {
if (serie.stroke_width !== undefined) {
return serie.stroke_width;
}
return [undefined, 'line', 'area'].includes(serie.type) ? 5 : 0;
});
}
function getStrokeDash(config: ChartCardConfig, brush: boolean) {
const series = brush ? config.series_in_brush : config.series_in_graph;
return series.map((serie) => {
return serie.stroke_dash;
});
}
function getFillType(config: ChartCardConfig, brush: boolean) {
if (!config.experimental?.color_threshold) {
return brush ? config.brush?.apex_config?.fill?.type || 'solid' : config.apex_config?.fill?.type || 'solid';
} else {
const series = brush ? config.series_in_brush : config.series_in_graph;
return series.map((serie) => {
if (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
!PLAIN_COLOR_TYPES.includes(config.chart_type!) &&
serie.type !== 'column' &&
serie.color_threshold &&
serie.color_threshold.length > 0
) {
return 'gradient';
}
return 'solid';
});
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function evalApexConfig(apexConfig: any): any {
const eval2 = eval;
Object.keys(apexConfig).forEach((key) => {
if (typeof apexConfig[key] === 'string' && apexConfig[key].trim().startsWith('EVAL:')) {
apexConfig[key] = eval2(`(${apexConfig[key].trim().slice(5)})`);
}
if (typeof apexConfig[key] === 'object') {
apexConfig[key] = evalApexConfig(apexConfig[key]);
}
});
return apexConfig;
}