Skip to content

Commit 8dcff77

Browse files
authored
feat(tool): add sparkline chart tool (#241)
* First hack for the graph tool! * Update bars with masking and more * Update bar for negative values * Negative values support for Area * The 25 June update. Works for the greater part! * First try adding min/max area to line * Update * Update with working value buckets * Update with working value buckets * Update with working bucket map * Add clock type with colors * Add timeline sparkline graph * Add some clock face to the clock * Update clock and graph options * Update with timeline variant audio (alpha) * Add audio and sunburst variants * Add logarithmic stuff to some graphs * Cleanup. Remove old stuff, comments and debugging * Basic x axis support * Refactor config, add yesterday and smooting for timeline and clock * Cleanup and some history stuff mostly * Add real-time state updates to graph to show last state only * Fix bug in getFill to allow today and add classmap * Rename color_thresholds to colorstops and experiment with use_value * Remove debug logging * Fix some bugs in equalizer regarding square parts * Add css to traffic light and more * Fix equalizer and trafficlight coordinate calculations. Set initial history timer to 100ms iso 1sec * Rename x_axis to period and y_axis to states * Implement HA stat period. Use state_values instead of states * Trying to adjust gradient to margins * Rename to sparkline and rename other settings * Rename and refactor a lot * Fix column and row spacing * Add flower2 and fix radial barcode background * Remove some debugging stuff * Big update with changed template and animation engine fixes for multiple entities * Move colorstop list to colors. Some cleanup * Remve logging. Change state_maps to allow templates * Change config to .sparkline and other config to .chart_type * Fix bugs. Add comment in SVG output for debugging
1 parent e60943a commit 8dcff77

10 files changed

+3867
-269
lines changed

Diff for: dist/swiss-army-knife-card.js

+794-219
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: src/base-tool.js

+33-11
Original file line numberDiff line numberDiff line change
@@ -273,14 +273,14 @@ export default class BaseTool {
273273
switch (operator) {
274274
case '==':
275275
if (typeof (state) === 'undefined') {
276-
isMatch = (typeof item.state === 'undefined') || (item.state.toLowerCase() === 'undefined');
276+
isMatch = (typeof item.state === 'undefined') || (item.state?.toLowerCase() === 'undefined');
277277
} else {
278278
isMatch = state.toLowerCase() === item.state.toLowerCase();
279279
}
280280
break;
281281
case '!=':
282282
if (typeof (state) === 'undefined') {
283-
isMatch = (typeof item.state !== 'undefined') || (item.state.toLowerCase() !== 'undefined');
283+
isMatch = (typeof item.state !== 'undefined') || (item.state?.toLowerCase() !== 'undefined');
284284
} else {
285285
isMatch = state.toLowerCase() !== item.state.toLowerCase();
286286
}
@@ -365,12 +365,30 @@ export default class BaseTool {
365365
// eslint-disable-next-line no-loop-func, no-unused-vars
366366
if (this.config.animations) Object.keys(this.config.animations.map((aniKey, aniValue) => {
367367
const statesIndex = this.getIndexInEntityIndexes(this.getEntityIndexFromAnimation(aniKey));
368-
isMatch = this.stateIsMatch(aniKey, states[statesIndex]);
369-
368+
// Comment here...
369+
// isMatch = this.stateIsMatch(aniKey, states[statesIndex]);
370+
371+
// NOTE @2023.08.07
372+
// Running template again seems to fix the issue that these are NOT evaluated once
373+
// there are more than one entity used in animations, ie entity_indexes!
374+
// With this addition, this seems to work again...
375+
//
376+
// Nope, not completely...
377+
// No idea yet what's going wrong at the end...
378+
//
379+
// Again, second part (styles) is overwritten, while first test, state is still ok in the
380+
// configuration. So somewhere the getJsTemplate does not use a merge to maintain
381+
// the configuration...
382+
const tempConfig = JSON.parse(JSON.stringify(aniKey));
383+
384+
// let item = Templates.getJsTemplateOrValue(this, states[index], Merge.mergeDeep(aniKey));
385+
let item = Templates.getJsTemplateOrValue(this, states[index], Merge.mergeDeep(tempConfig));
386+
isMatch = this.stateIsMatch(item, states[statesIndex]);
387+
if (aniKey.debug) console.log('set values, item, aniKey', item, states, isMatch, this.config.animations);
370388
// console.log("set values, animations", aniKey, aniValue, statesIndex, isMatch, states);
371389

372390
if (isMatch) {
373-
this.mergeAnimationData(aniKey);
391+
this.mergeAnimationData(item);
374392
return true;
375393
} else {
376394
return false;
@@ -396,10 +414,11 @@ export default class BaseTool {
396414
MergeAnimationStyleIfChanged(argDefaultStyles) {
397415
if (this.animationStyleHasChanged) {
398416
this.animationStyleHasChanged = false;
417+
let styles = this.config?.styles || this.config[this.config.type]?.styles;
399418
if (argDefaultStyles) {
400-
this.styles = Merge.mergeDeep(argDefaultStyles, this.config.styles, this.animationStyle);
419+
this.styles = Merge.mergeDeep(argDefaultStyles, styles, this.animationStyle);
401420
} else {
402-
this.styles = Merge.mergeDeep(this.config.styles, this.animationStyle);
421+
this.styles = Merge.mergeDeep(styles, this.animationStyle);
403422
}
404423

405424
if (this.styles.card) {
@@ -424,10 +443,11 @@ export default class BaseTool {
424443

425444
if (this.animationClassHasChanged) {
426445
this.animationClassHasChanged = false;
446+
let classes = this.config?.classes || this.config[this.config.type]?.classes;
427447
if (argDefaultClasses) {
428-
this.classes = Merge.mergeDeep(argDefaultClasses, this.config.classes, this.animationClass);
448+
this.classes = Merge.mergeDeep(argDefaultClasses, classes, this.animationClass);
429449
} else {
430-
this.classes = Merge.mergeDeep(this.config.classes, this.animationClass);
450+
this.classes = Merge.mergeDeep(classes, this.animationClass);
431451
}
432452
}
433453
}
@@ -444,8 +464,10 @@ export default class BaseTool {
444464
if (this.config.hasOwnProperty('entity_index')) {
445465
const color = this.getColorFromState(this._stateValue);
446466
if (color !== '') {
447-
argStyleMap.fill = this.config[this.config.show.style].fill ? color : '';
448-
argStyleMap.stroke = this.config[this.config.show.style].stroke ? color : '';
467+
if (this.config?.show?.style && argStyleMap) {
468+
argStyleMap.fill = this.config[this.config.show.style].fill ? color : '';
469+
argStyleMap.stroke = this.config[this.config.show.style].stroke ? color : '';
470+
}
449471
}
450472
}
451473
}

Diff for: src/entity-icon-tool.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@ export default class EntityIconTool extends BaseTool {
125125
*/
126126

127127
_renderIcon() {
128-
this.MergeAnimationClassIfChanged();
129-
this.MergeAnimationStyleIfChanged();
130-
this.MergeColorFromState(this.styles.icon);
128+
// this.MergeAnimationClassIfChanged();
129+
// this.MergeAnimationStyleIfChanged();
130+
// this.MergeColorFromState(this.styles.icon);
131131

132132
const icon = this._buildIcon(
133133
this._card.entities[this.defaultEntityIndex()],
@@ -271,6 +271,9 @@ export default class EntityIconTool extends BaseTool {
271271
*/
272272

273273
render() {
274+
this.MergeAnimationClassIfChanged();
275+
this.MergeAnimationStyleIfChanged();
276+
this.MergeColorFromState(this.styles.icon);
274277
return svg`
275278
<g "" id="icongrp-${this.toolId}" class="${classMap(this.classes.tool)}" style="${styleMap(this.styles.tool)}"
276279
@click=${(e) => this.handleTapEvent(e, this.config)} >

Diff for: src/entity-state-tool.js

+14-4
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export default class EntityStateTool extends BaseTool {
9191
if (['relative', 'total',
9292
'datetime', 'datetime-short', 'datetime-short_with-year', 'datetime_seconds', 'datetime-numeric',
9393
'date', 'date_month', 'date_month_year', 'date-short', 'date-numeric', 'date_weekday', 'date_weekday_day', 'date_weekday-short',
94-
'time', 'time-24h', 'time_weekday', 'time_seconds'].includes(entityConfig.format)) {
94+
'time', 'time-24h', 'time-24h_date-short', 'time_weekday', 'time_seconds'].includes(entityConfig.format)) {
9595
const timestamp = new Date(inState);
9696
if (!(timestamp instanceof Date) || isNaN(timestamp.getTime())) {
9797
return inState;
@@ -174,7 +174,16 @@ export default class EntityStateTool extends BaseTool {
174174
case 'time-24h':
175175
retValue = formatTime24h(timestamp);
176176
break;
177-
case 'time_weekday':
177+
case 'time-24h_date-short':
178+
// eslint-disable-next-line no-case-declarations
179+
const diff2 = selectUnit(timestamp, new Date());
180+
if (['second', 'minute', 'hour'].includes(diff2.unit)) {
181+
retValue = formatTime24h(timestamp);
182+
} else {
183+
retValue = formatDateShort(timestamp, locale);
184+
}
185+
break;
186+
case 'time_weekday':
178187
retValue = formatTimeWeekday(timestamp, locale);
179188
break;
180189
case 'time_seconds':
@@ -210,6 +219,7 @@ export default class EntityStateTool extends BaseTool {
210219

211220
// Need entities, not states to get platform, translation_key, etc.!!!!!
212221
const entity = this._card._hass.entities[stateObj.entity_id];
222+
const entity2 = this._card._hass.states[stateObj.entity_id];
213223

214224
const entityConfig = this._card.config.entities[this.defaultEntityIndex()];
215225
const domain = computeDomain(this._card.entities[this.defaultEntityIndex()].entity_id);
@@ -231,9 +241,9 @@ export default class EntityStateTool extends BaseTool {
231241
`component.${entity.platform}.entity.${domain}.${entity.translation_key}.state.${inState}`,
232242
))
233243
// Return device class translation
234-
|| (entity?.attributes?.device_class
244+
|| (entity2?.attributes?.device_class
235245
&& this._card._hass.localize(
236-
`component.${domain}.entity_component.${entity.attributes.device_class}.state.${inState}`,
246+
`component.${domain}.entity_component.${entity2.attributes.device_class}.state.${inState}`,
237247
))
238248
// Return default translation
239249
|| this._card._hass.localize(`component.${domain}.entity_component._.state.${inState}`)

Diff for: src/main.js

+81-22
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,26 @@ class SwissArmyKnifeCard extends LitElement {
195195
:host {
196196
cursor: default;
197197
font-size: ${FONT_SIZE}px;
198+
--sak-ref-palette-gray-platinum: #e9e9ea;
199+
--sak-ref-palette-gray-french-gray: #d1d1d6;
200+
--sak-ref-palette-gray-taupe-gray: #8e8e93;
201+
--sak-ref-palette-gray-cool-gray: #919bb4;
202+
203+
--sak-ref-palette-yellow-sunglow: #F7ce46;
204+
--sak-ref-palette-yellow-jonquil: #ffcc01;
205+
--sak-ref-palette-yellow-Amber: #f6b90b;
206+
207+
--sak-ref-palette-orange-xanthous: #F3b530;
208+
--sak-ref-palette-orange-princeton-orange: #ff9500;
209+
--sak-ref-palette-orange-orange : #F46c36;
210+
211+
--sak-ref-palette-red-indian-red: #ed5254;
212+
--sak-ref-palette-red-japser: #d85140;
213+
--sak-ref-palette-red-cinnabar: #ff3b2f;
214+
215+
--sak-ref-palette-purple-amethyst: #Af52de;
216+
--sak-ref-palette-purple-tropical-indigo: #8d82ef;
217+
--sak-ref-palette-purple-slate-blue: #5f5dd1;
198218
}
199219
200220
/* Default settings for the card */
@@ -543,6 +563,7 @@ class SwissArmyKnifeCard extends LitElement {
543563
this.theme.modeChanged = (hass.themes.darkMode !== this.theme.darkMode);
544564
if (this.theme.modeChanged) {
545565
this.theme.darkMode = hass.themes.darkMode;
566+
Colors.colorCache = {};
546567
}
547568

548569
// Process theme if specified and does exist, otherwise ignore
@@ -789,7 +810,7 @@ class SwissArmyKnifeCard extends LitElement {
789810
const thisMe = this;
790811
function findTemplate(key, value) {
791812
// Filtering out properties
792-
// console.log("findTemplate, key=", key, "value=", value);
813+
// console.log('findTemplate, key=', key, 'value=', value);
793814
if (value?.template) {
794815
const template = thisMe.lovelace.config.sak_user_templates.templates[value.template.name];
795816
if (!template) {
@@ -861,7 +882,7 @@ class SwissArmyKnifeCard extends LitElement {
861882
}
862883
if (this.dev.debug) console.log('card::setConfig - got toolsetCfg toolid', tool, index, toolT, indexT, tool);
863884
}
864-
cfgobj[toolidx].tools[indexT] = Templates.getJsTemplateOrValueConfig(cfgobj[toolidx].tools[indexT], Merge.mergeDeep(cfgobj[toolidx].tools[indexT]));
885+
cfgobj[toolidx].tools[indexT] = Templates.getJsTemplateOrValueConfig(cfgobj[toolidx].tools[indexT], this.config.entities, Merge.mergeDeep(cfgobj[toolidx].tools[indexT]));
865886
return found;
866887
});
867888
if (!found) toolAdd = toolAdd.concat(toolsetCfg.tools[index]);
@@ -1149,7 +1170,7 @@ class SwissArmyKnifeCard extends LitElement {
11491170
clearInterval(this.interval);
11501171
this.interval = setInterval(
11511172
() => this.updateOnInterval(),
1152-
this._hass ? this.entityHistory.update_interval * 1000 : 1000,
1173+
this._hass ? this.entityHistory.update_interval * 1000 : 100,
11531174
);
11541175
}
11551176
if (this.dev.debug) console.log('ConnectedCallback', this.cardId);
@@ -1295,15 +1316,15 @@ class SwissArmyKnifeCard extends LitElement {
12951316
if (this.dev.debug) console.log('all the tools in renderTools', this.tools);
12961317

12971318
return svg`
1298-
<g id="toolsets" class="toolsets__group"
1299-
>
1300-
${this.toolsets.map((toolset) => toolset.render())}
1301-
</g>
1302-
1303-
<defs>
1304-
${this._renderSakSvgDefinitions()}
1305-
${this._renderUserSvgDefinitions()}
1306-
</defs>
1319+
<g id="toolsets" class="toolsets__group"
1320+
>
1321+
${this.toolsets.map((toolset) => toolset.render())}
1322+
</g>
1323+
1324+
<defs>
1325+
${this._renderSakSvgDefinitions()}
1326+
${this._renderUserSvgDefinitions()}
1327+
</defs>
13071328
`;
13081329
}
13091330

@@ -1368,6 +1389,7 @@ class SwissArmyKnifeCard extends LitElement {
13681389
const toolsetsSvg = this._RenderToolsets();
13691390

13701391
svgItems.push(svg`
1392+
<!-- SAK Card SVG Render -->
13711393
<svg id="rootsvg" xmlns="http://www/w3.org/2000/svg" xmlns:xlink="http://www/w3.org/1999/xlink"
13721394
class="${cardFilter}"
13731395
style="${styleMap(this.themeIsDarkMode()
@@ -1668,10 +1690,13 @@ _buildStateString(inState, entityConfig) {
16681690
if (this.dev.debug) console.log('UpdateOnInterval - NO hass, returning');
16691691
return;
16701692
}
1671-
if (this.stateChanged && !this.entityHistory.updating) {
1693+
// console.log('updateOnInterval', new Date(Date.now()).toString());
1694+
// eslint-disable-next-line no-constant-condition
1695+
if (true) { // (this.stateChanged && !this.entityHistory.updating) {
16721696
// 2020.10.24
16731697
// Leave true, as multiple entities can be fetched. fetch every 5 minutes...
16741698
// this.stateChanged = false;
1699+
// console.log('updateOnInterval - updateData', new Date(Date.now()).toString());
16751700
this.updateData();
16761701
// console.log("*RC* updateOnInterval -> updateData", this.entityHistory);
16771702
}
@@ -1686,7 +1711,7 @@ _buildStateString(inState, entityConfig) {
16861711
window.clearInterval(this.interval);
16871712
this.interval = setInterval(
16881713
() => this.updateOnInterval(),
1689-
// 5 * 1000);
1714+
// 30 * 1000,
16901715
this.entityHistory.update_interval * 1000,
16911716
);
16921717
// console.log("*RC* updateOnInterval -> start timer", this.entityHistory, this.interval);
@@ -1700,8 +1725,6 @@ _buildStateString(inState, entityConfig) {
17001725
if (end) url += `&end_time=${end.toISOString()}`;
17011726
if (skipInitialState) url += '&skip_initial_state';
17021727
url += '&minimal_response';
1703-
1704-
// console.log('fetchRecent - call is', entityId, start, end, skipInitialState, url);
17051728
return this._hass.callApi('GET', url);
17061729
}
17071730

@@ -1722,14 +1745,32 @@ _buildStateString(inState, entityConfig) {
17221745
// add to list...
17231746
this.toolsets.map((toolset, k) => {
17241747
toolset.tools.map((item, i) => {
1725-
if (item.type === 'bar') {
1748+
if ((item.type === 'bar')
1749+
|| (item.type === 'sparkline')) {
1750+
if (item.tool.config?.period?.type === 'real_time') return true;
17261751
const end = new Date();
17271752
const start = new Date();
1728-
start.setHours(end.getHours() - item.tool.config.hours);
1753+
if (item.tool.config.period?.calendar?.period === 'day') {
1754+
start.setHours(0, 0, 0, 0);
1755+
start.setHours(start.getHours() + item.tool.config.period.calendar.offset * 24);
1756+
// For now assume 24 hours always, so if offset != 0, set end...
1757+
if (item.tool.config.period.calendar.offset !== 0) end.setHours(0, 0, 0, 0);
1758+
} else {
1759+
start.setHours(end.getHours()
1760+
- (item.tool.config.period?.rolling_window?.duration?.hour || item.tool.config.hours));
1761+
}
17291762
const attr = this.config.entities[item.tool.config.entity_index].attribute ? this.config.entities[item.tool.config.entity_index].attribute : null;
17301763

17311764
entityList[j] = ({
1732-
tsidx: k, entityIndex: item.tool.config.entity_index, entityId: this.entities[item.tool.config.entity_index].entity_id, attrId: attr, start, end, type: 'bar', idx: i,
1765+
tsidx: k,
1766+
entityIndex: item.tool.config.entity_index,
1767+
entityId: this.entities[item.tool.config.entity_index].entity_id,
1768+
attrId: attr,
1769+
start,
1770+
end,
1771+
type: item.type,
1772+
idx: i,
1773+
// tsidx: k, entityIndex: item.tool.config.entity_index, entityId: this.entities[item.tool.config.entity_index].entity_id, attrId: attr, start, end, type: 'bar', idx: i,
17331774
});
17341775
j += 1;
17351776
}
@@ -1753,6 +1794,7 @@ _buildStateString(inState, entityConfig) {
17531794
} finally {
17541795
this.entityHistory.updating = false;
17551796
}
1797+
this.entityHistory.updating = false;
17561798
}
17571799

17581800
async updateEntity(entity, index, initStart, end) {
@@ -1762,10 +1804,17 @@ _buildStateString(inState, entityConfig) {
17621804

17631805
// Get history for this entity and/or attribute.
17641806
let newStateHistory = await this.fetchRecent(entity.entityId, start, end, skipInitialState);
1807+
// console.log('update, updateEntity, newStateHistory', entity.entityId, start, end, newStateHistory);
17651808

17661809
// Now we have some history, check if it has valid data and filter out either the entity state or
17671810
// the entity attribute. Ain't that nice!
17681811

1812+
// Hack for state mapping...
1813+
if (entity.type === 'sparkline') {
1814+
// console.log('pushing stateHistory into Graph!!!!', stateHistory);
1815+
this.toolsets[entity.tsidx].tools[entity.idx].tool.processStateMap(newStateHistory);
1816+
}
1817+
17691818
let theState;
17701819

17711820
if (newStateHistory[0] && newStateHistory[0].length > 0) {
@@ -1783,7 +1832,15 @@ _buildStateString(inState, entityConfig) {
17831832

17841833
stateHistory = [...stateHistory, ...newStateHistory];
17851834

1786-
this.uppdate(entity, stateHistory);
1835+
// console.log('Got new stateHistory', entity);
1836+
if (entity.type === 'sparkline') {
1837+
// console.log('pushing stateHistory into Graph!!!!', stateHistory);
1838+
this.toolsets[entity.tsidx].tools[entity.idx].tool.data = entity.entityIndex;
1839+
this.toolsets[entity.tsidx].tools[entity.idx].tool.series = [...stateHistory];
1840+
this.requestUpdate();
1841+
} else {
1842+
this.uppdate(entity, stateHistory);
1843+
}
17871844
}
17881845

17891846
uppdate(entity, hist) {
@@ -1804,7 +1861,8 @@ _buildStateString(inState, entityConfig) {
18041861
let hours = 24;
18051862
let barhours = 2;
18061863

1807-
if (entity.type === 'bar') {
1864+
if ((entity.type === 'bar')
1865+
|| (entity.type === 'sparkline')) {
18081866
if (this.dev.debug) console.log('entity.type == bar', entity);
18091867

18101868
hours = this.toolsets[entity.tsidx].tools[entity.idx].tool.config.hours;
@@ -1852,7 +1910,8 @@ _buildStateString(inState, entityConfig) {
18521910
theData = coords.map((item) => getAvg(item, 'state'));
18531911

18541912
// now push data into object...
1855-
if (entity.type === 'bar') {
1913+
if (['bar'].includes(entity.type)) {
1914+
// if (entity.type === 'bar') {
18561915
this.toolsets[entity.tsidx].tools[entity.idx].tool.series = [...theData];
18571916
}
18581917

0 commit comments

Comments
 (0)