Skip to content

Commit

Permalink
feat(tool): add sparkline chart tool (#241)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
AmoebeLabs committed Aug 18, 2023
1 parent e60943a commit 8dcff77
Show file tree
Hide file tree
Showing 10 changed files with 3,867 additions and 269 deletions.
1,013 changes: 794 additions & 219 deletions dist/swiss-army-knife-card.js

Large diffs are not rendered by default.

44 changes: 33 additions & 11 deletions src/base-tool.js
Expand Up @@ -273,14 +273,14 @@ export default class BaseTool {
switch (operator) {
case '==':
if (typeof (state) === 'undefined') {
isMatch = (typeof item.state === 'undefined') || (item.state.toLowerCase() === 'undefined');
isMatch = (typeof item.state === 'undefined') || (item.state?.toLowerCase() === 'undefined');
} else {
isMatch = state.toLowerCase() === item.state.toLowerCase();
}
break;
case '!=':
if (typeof (state) === 'undefined') {
isMatch = (typeof item.state !== 'undefined') || (item.state.toLowerCase() !== 'undefined');
isMatch = (typeof item.state !== 'undefined') || (item.state?.toLowerCase() !== 'undefined');
} else {
isMatch = state.toLowerCase() !== item.state.toLowerCase();
}
Expand Down Expand Up @@ -365,12 +365,30 @@ export default class BaseTool {
// eslint-disable-next-line no-loop-func, no-unused-vars
if (this.config.animations) Object.keys(this.config.animations.map((aniKey, aniValue) => {
const statesIndex = this.getIndexInEntityIndexes(this.getEntityIndexFromAnimation(aniKey));
isMatch = this.stateIsMatch(aniKey, states[statesIndex]);

// Comment here...
// isMatch = this.stateIsMatch(aniKey, states[statesIndex]);

// NOTE @2023.08.07
// Running template again seems to fix the issue that these are NOT evaluated once
// there are more than one entity used in animations, ie entity_indexes!
// With this addition, this seems to work again...
//
// Nope, not completely...
// No idea yet what's going wrong at the end...
//
// Again, second part (styles) is overwritten, while first test, state is still ok in the
// configuration. So somewhere the getJsTemplate does not use a merge to maintain
// the configuration...
const tempConfig = JSON.parse(JSON.stringify(aniKey));

// let item = Templates.getJsTemplateOrValue(this, states[index], Merge.mergeDeep(aniKey));
let item = Templates.getJsTemplateOrValue(this, states[index], Merge.mergeDeep(tempConfig));
isMatch = this.stateIsMatch(item, states[statesIndex]);
if (aniKey.debug) console.log('set values, item, aniKey', item, states, isMatch, this.config.animations);
// console.log("set values, animations", aniKey, aniValue, statesIndex, isMatch, states);

if (isMatch) {
this.mergeAnimationData(aniKey);
this.mergeAnimationData(item);
return true;
} else {
return false;
Expand All @@ -396,10 +414,11 @@ export default class BaseTool {
MergeAnimationStyleIfChanged(argDefaultStyles) {
if (this.animationStyleHasChanged) {
this.animationStyleHasChanged = false;
let styles = this.config?.styles || this.config[this.config.type]?.styles;
if (argDefaultStyles) {
this.styles = Merge.mergeDeep(argDefaultStyles, this.config.styles, this.animationStyle);
this.styles = Merge.mergeDeep(argDefaultStyles, styles, this.animationStyle);
} else {
this.styles = Merge.mergeDeep(this.config.styles, this.animationStyle);
this.styles = Merge.mergeDeep(styles, this.animationStyle);
}

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

if (this.animationClassHasChanged) {
this.animationClassHasChanged = false;
let classes = this.config?.classes || this.config[this.config.type]?.classes;
if (argDefaultClasses) {
this.classes = Merge.mergeDeep(argDefaultClasses, this.config.classes, this.animationClass);
this.classes = Merge.mergeDeep(argDefaultClasses, classes, this.animationClass);
} else {
this.classes = Merge.mergeDeep(this.config.classes, this.animationClass);
this.classes = Merge.mergeDeep(classes, this.animationClass);
}
}
}
Expand All @@ -444,8 +464,10 @@ export default class BaseTool {
if (this.config.hasOwnProperty('entity_index')) {
const color = this.getColorFromState(this._stateValue);
if (color !== '') {
argStyleMap.fill = this.config[this.config.show.style].fill ? color : '';
argStyleMap.stroke = this.config[this.config.show.style].stroke ? color : '';
if (this.config?.show?.style && argStyleMap) {
argStyleMap.fill = this.config[this.config.show.style].fill ? color : '';
argStyleMap.stroke = this.config[this.config.show.style].stroke ? color : '';
}
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions src/entity-icon-tool.js
Expand Up @@ -125,9 +125,9 @@ export default class EntityIconTool extends BaseTool {
*/

_renderIcon() {
this.MergeAnimationClassIfChanged();
this.MergeAnimationStyleIfChanged();
this.MergeColorFromState(this.styles.icon);
// this.MergeAnimationClassIfChanged();
// this.MergeAnimationStyleIfChanged();
// this.MergeColorFromState(this.styles.icon);

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

render() {
this.MergeAnimationClassIfChanged();
this.MergeAnimationStyleIfChanged();
this.MergeColorFromState(this.styles.icon);
return svg`
<g "" id="icongrp-${this.toolId}" class="${classMap(this.classes.tool)}" style="${styleMap(this.styles.tool)}"
@click=${(e) => this.handleTapEvent(e, this.config)} >
Expand Down
18 changes: 14 additions & 4 deletions src/entity-state-tool.js
Expand Up @@ -91,7 +91,7 @@ export default class EntityStateTool extends BaseTool {
if (['relative', 'total',
'datetime', 'datetime-short', 'datetime-short_with-year', 'datetime_seconds', 'datetime-numeric',
'date', 'date_month', 'date_month_year', 'date-short', 'date-numeric', 'date_weekday', 'date_weekday_day', 'date_weekday-short',
'time', 'time-24h', 'time_weekday', 'time_seconds'].includes(entityConfig.format)) {
'time', 'time-24h', 'time-24h_date-short', 'time_weekday', 'time_seconds'].includes(entityConfig.format)) {
const timestamp = new Date(inState);
if (!(timestamp instanceof Date) || isNaN(timestamp.getTime())) {
return inState;
Expand Down Expand Up @@ -174,7 +174,16 @@ export default class EntityStateTool extends BaseTool {
case 'time-24h':
retValue = formatTime24h(timestamp);
break;
case 'time_weekday':
case 'time-24h_date-short':
// eslint-disable-next-line no-case-declarations
const diff2 = selectUnit(timestamp, new Date());
if (['second', 'minute', 'hour'].includes(diff2.unit)) {
retValue = formatTime24h(timestamp);
} else {
retValue = formatDateShort(timestamp, locale);
}
break;
case 'time_weekday':
retValue = formatTimeWeekday(timestamp, locale);
break;
case 'time_seconds':
Expand Down Expand Up @@ -210,6 +219,7 @@ export default class EntityStateTool extends BaseTool {

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

const entityConfig = this._card.config.entities[this.defaultEntityIndex()];
const domain = computeDomain(this._card.entities[this.defaultEntityIndex()].entity_id);
Expand All @@ -231,9 +241,9 @@ export default class EntityStateTool extends BaseTool {
`component.${entity.platform}.entity.${domain}.${entity.translation_key}.state.${inState}`,
))
// Return device class translation
|| (entity?.attributes?.device_class
|| (entity2?.attributes?.device_class
&& this._card._hass.localize(
`component.${domain}.entity_component.${entity.attributes.device_class}.state.${inState}`,
`component.${domain}.entity_component.${entity2.attributes.device_class}.state.${inState}`,
))
// Return default translation
|| this._card._hass.localize(`component.${domain}.entity_component._.state.${inState}`)
Expand Down
103 changes: 81 additions & 22 deletions src/main.js
Expand Up @@ -195,6 +195,26 @@ class SwissArmyKnifeCard extends LitElement {
:host {
cursor: default;
font-size: ${FONT_SIZE}px;
--sak-ref-palette-gray-platinum: #e9e9ea;
--sak-ref-palette-gray-french-gray: #d1d1d6;
--sak-ref-palette-gray-taupe-gray: #8e8e93;
--sak-ref-palette-gray-cool-gray: #919bb4;
--sak-ref-palette-yellow-sunglow: #F7ce46;
--sak-ref-palette-yellow-jonquil: #ffcc01;
--sak-ref-palette-yellow-Amber: #f6b90b;
--sak-ref-palette-orange-xanthous: #F3b530;
--sak-ref-palette-orange-princeton-orange: #ff9500;
--sak-ref-palette-orange-orange : #F46c36;
--sak-ref-palette-red-indian-red: #ed5254;
--sak-ref-palette-red-japser: #d85140;
--sak-ref-palette-red-cinnabar: #ff3b2f;
--sak-ref-palette-purple-amethyst: #Af52de;
--sak-ref-palette-purple-tropical-indigo: #8d82ef;
--sak-ref-palette-purple-slate-blue: #5f5dd1;
}
/* Default settings for the card */
Expand Down Expand Up @@ -543,6 +563,7 @@ class SwissArmyKnifeCard extends LitElement {
this.theme.modeChanged = (hass.themes.darkMode !== this.theme.darkMode);
if (this.theme.modeChanged) {
this.theme.darkMode = hass.themes.darkMode;
Colors.colorCache = {};
}

// Process theme if specified and does exist, otherwise ignore
Expand Down Expand Up @@ -789,7 +810,7 @@ class SwissArmyKnifeCard extends LitElement {
const thisMe = this;
function findTemplate(key, value) {
// Filtering out properties
// console.log("findTemplate, key=", key, "value=", value);
// console.log('findTemplate, key=', key, 'value=', value);
if (value?.template) {
const template = thisMe.lovelace.config.sak_user_templates.templates[value.template.name];
if (!template) {
Expand Down Expand Up @@ -861,7 +882,7 @@ class SwissArmyKnifeCard extends LitElement {
}
if (this.dev.debug) console.log('card::setConfig - got toolsetCfg toolid', tool, index, toolT, indexT, tool);
}
cfgobj[toolidx].tools[indexT] = Templates.getJsTemplateOrValueConfig(cfgobj[toolidx].tools[indexT], Merge.mergeDeep(cfgobj[toolidx].tools[indexT]));
cfgobj[toolidx].tools[indexT] = Templates.getJsTemplateOrValueConfig(cfgobj[toolidx].tools[indexT], this.config.entities, Merge.mergeDeep(cfgobj[toolidx].tools[indexT]));
return found;
});
if (!found) toolAdd = toolAdd.concat(toolsetCfg.tools[index]);
Expand Down Expand Up @@ -1149,7 +1170,7 @@ class SwissArmyKnifeCard extends LitElement {
clearInterval(this.interval);
this.interval = setInterval(
() => this.updateOnInterval(),
this._hass ? this.entityHistory.update_interval * 1000 : 1000,
this._hass ? this.entityHistory.update_interval * 1000 : 100,
);
}
if (this.dev.debug) console.log('ConnectedCallback', this.cardId);
Expand Down Expand Up @@ -1295,15 +1316,15 @@ class SwissArmyKnifeCard extends LitElement {
if (this.dev.debug) console.log('all the tools in renderTools', this.tools);

return svg`
<g id="toolsets" class="toolsets__group"
>
${this.toolsets.map((toolset) => toolset.render())}
</g>
<defs>
${this._renderSakSvgDefinitions()}
${this._renderUserSvgDefinitions()}
</defs>
<g id="toolsets" class="toolsets__group"
>
${this.toolsets.map((toolset) => toolset.render())}
</g>
<defs>
${this._renderSakSvgDefinitions()}
${this._renderUserSvgDefinitions()}
</defs>
`;
}

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

svgItems.push(svg`
<!-- SAK Card SVG Render -->
<svg id="rootsvg" xmlns="http://www/w3.org/2000/svg" xmlns:xlink="http://www/w3.org/1999/xlink"
class="${cardFilter}"
style="${styleMap(this.themeIsDarkMode()
Expand Down Expand Up @@ -1668,10 +1690,13 @@ _buildStateString(inState, entityConfig) {
if (this.dev.debug) console.log('UpdateOnInterval - NO hass, returning');
return;
}
if (this.stateChanged && !this.entityHistory.updating) {
// console.log('updateOnInterval', new Date(Date.now()).toString());
// eslint-disable-next-line no-constant-condition
if (true) { // (this.stateChanged && !this.entityHistory.updating) {
// 2020.10.24
// Leave true, as multiple entities can be fetched. fetch every 5 minutes...
// this.stateChanged = false;
// console.log('updateOnInterval - updateData', new Date(Date.now()).toString());
this.updateData();
// console.log("*RC* updateOnInterval -> updateData", this.entityHistory);
}
Expand All @@ -1686,7 +1711,7 @@ _buildStateString(inState, entityConfig) {
window.clearInterval(this.interval);
this.interval = setInterval(
() => this.updateOnInterval(),
// 5 * 1000);
// 30 * 1000,
this.entityHistory.update_interval * 1000,
);
// console.log("*RC* updateOnInterval -> start timer", this.entityHistory, this.interval);
Expand All @@ -1700,8 +1725,6 @@ _buildStateString(inState, entityConfig) {
if (end) url += `&end_time=${end.toISOString()}`;
if (skipInitialState) url += '&skip_initial_state';
url += '&minimal_response';

// console.log('fetchRecent - call is', entityId, start, end, skipInitialState, url);
return this._hass.callApi('GET', url);
}

Expand All @@ -1722,14 +1745,32 @@ _buildStateString(inState, entityConfig) {
// add to list...
this.toolsets.map((toolset, k) => {
toolset.tools.map((item, i) => {
if (item.type === 'bar') {
if ((item.type === 'bar')
|| (item.type === 'sparkline')) {
if (item.tool.config?.period?.type === 'real_time') return true;
const end = new Date();
const start = new Date();
start.setHours(end.getHours() - item.tool.config.hours);
if (item.tool.config.period?.calendar?.period === 'day') {
start.setHours(0, 0, 0, 0);
start.setHours(start.getHours() + item.tool.config.period.calendar.offset * 24);
// For now assume 24 hours always, so if offset != 0, set end...
if (item.tool.config.period.calendar.offset !== 0) end.setHours(0, 0, 0, 0);
} else {
start.setHours(end.getHours()
- (item.tool.config.period?.rolling_window?.duration?.hour || item.tool.config.hours));
}
const attr = this.config.entities[item.tool.config.entity_index].attribute ? this.config.entities[item.tool.config.entity_index].attribute : null;

entityList[j] = ({
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,
tsidx: k,
entityIndex: item.tool.config.entity_index,
entityId: this.entities[item.tool.config.entity_index].entity_id,
attrId: attr,
start,
end,
type: item.type,
idx: i,
// 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,
});
j += 1;
}
Expand All @@ -1753,6 +1794,7 @@ _buildStateString(inState, entityConfig) {
} finally {
this.entityHistory.updating = false;
}
this.entityHistory.updating = false;
}

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

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

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

// Hack for state mapping...
if (entity.type === 'sparkline') {
// console.log('pushing stateHistory into Graph!!!!', stateHistory);
this.toolsets[entity.tsidx].tools[entity.idx].tool.processStateMap(newStateHistory);
}

let theState;

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

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

this.uppdate(entity, stateHistory);
// console.log('Got new stateHistory', entity);
if (entity.type === 'sparkline') {
// console.log('pushing stateHistory into Graph!!!!', stateHistory);
this.toolsets[entity.tsidx].tools[entity.idx].tool.data = entity.entityIndex;
this.toolsets[entity.tsidx].tools[entity.idx].tool.series = [...stateHistory];
this.requestUpdate();
} else {
this.uppdate(entity, stateHistory);
}
}

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

if (entity.type === 'bar') {
if ((entity.type === 'bar')
|| (entity.type === 'sparkline')) {
if (this.dev.debug) console.log('entity.type == bar', entity);

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

// now push data into object...
if (entity.type === 'bar') {
if (['bar'].includes(entity.type)) {
// if (entity.type === 'bar') {
this.toolsets[entity.tsidx].tools[entity.idx].tool.series = [...theData];
}

Expand Down

0 comments on commit 8dcff77

Please sign in to comment.