Replace Google Charts with Chart.js (#429)

* chartjs test

* [WIP] Modified for Chart.js

* Tweaking styles ( tooltips and lines )

* Almost done
TODO:
Change tooltips to HTML tag
Improve color function

* More work on Tooltips

* Improve update logic
Fix linting

* resolve conflict

* [WIP]
Create new tooltip mode hack.
Add axis padding to top and botton to prevent axis cutoff

* TODO: cleanup

* FIXME: tooltip in history graph not working correctly
reorganize some code

* fix build problem

* Fix color and tooltip issue
Fix label max width for timeline chart

* update dep

* Fix strange color after build due to `uglify` bug with reference the minified version.
Make line chart behavior more similar to Google Charts.
Make the chart honor to `unknown` and other state by manually calculate point value.

* fix bugs

* Remove label for only one data in timeline chart.
Fix bug for infinite loop in some cases

* Add HTML legend to chart.

* Fix isSingleDevice bug due to calculation.
Add isSingleDevice property support.

* fix for lint

* Replace innerHTML code with polymer node.

* Replace tooltip with HTML code

* fix tooltip style

* move default tooltip mode to plugin

* LINTING

* fix
Move localize history data to Timeline Chart.
Fix timeline static color.
Rework on chart resize.

* Bug fix:
Chart may disappear on some case.
Timeline chart calculation issue.
Change timeline chart hidden logic.

* fix tooltip
rework for resize event

* lint

* element

* Replace `var` to `let`.
Move import and ChartJs injection code to `ha-chart-scripts.html`.

* lint: convert more let to const

* fix font
fix undef

* update bower.json

* move

* Load chart code on demand
This commit is contained in:
Boyi C 2018-02-10 14:39:15 +08:00 committed by Paulus Schoutsen
parent 500edbad0d
commit c6030e6edc
15 changed files with 922 additions and 414 deletions

View File

@ -16,6 +16,7 @@
"__DEMO__": false,
"__BUILD__": false,
"__VERSION__": false,
"__ROOT__": false,
"Polymer": true,
"webkitSpeechRecognition": false,
"ResizeObserver": false

View File

@ -8,6 +8,7 @@
"browser": true
},
"rules": {
"import/no-unresolved": 2
"import/no-unresolved": 2,
"linebreak-style": 0
}
}

View File

@ -55,7 +55,10 @@
"vaadin-combo-box": "vaadin/vaadin-combo-box#^3.0.2",
"vaadin-date-picker": "vaadin/vaadin-date-picker#^2.0.0",
"web-animations-js": "^2.2.5",
"webcomponentsjs": "^1.0.10"
"webcomponentsjs": "^1.0.10",
"chart.js": "~2.7.1",
"moment": "^2.20.0",
"chartjs-chart-timeline": "fanthos/chartjs-chart-timeline#^0.1.3"
},
"devDependencies": {
"web-component-tester": "^6.3.0"

View File

@ -36,6 +36,7 @@ function build(es6) {
stripImportsStrategy([
'bower_components/font-roboto/roboto.html',
'bower_components/paper-styles/color.html',
'src/resources/ha-chart-scripts.html',
]),
stripAllButEntrypointStrategy('panels/hassio/ha-panel-hassio.html')
]);

View File

@ -58,6 +58,7 @@ function getRollupInputOptions(es6) {
__DEMO__: JSON.stringify(DEMO),
__BUILD__: JSON.stringify(es6 ? 'latest' : 'es5'),
__VERSION__: JSON.stringify(VERSION),
__ROOT__: JSON.stringify(es6 ? 'frontend_latest' : 'frontend_es5'),
},
}),
],

View File

@ -5,6 +5,7 @@ window.HASS_DEMO = __DEMO__;
window.HASS_DEV = __DEV__;
window.HASS_BUILD = __BUILD__;
window.HASS_VERSION = __VERSION__;
window.HASS_ROOT = __ROOT__;
const init = window.createHassConnection = function (password) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';

View File

@ -82,9 +82,11 @@
</paper-dropdown-menu>
</div>
<state-history-charts
hass='[[hass]]'
history-data="[[stateHistoryOutput]]"
is-loading-data="[[isLoadingData]]"
end-time="[[endTime]]">
end-time="[[endTime]]"
no-single>
</state-history-charts>
</div>
</app-header-layout>

View File

@ -16,7 +16,8 @@
"panels/logbook/ha-panel-logbook.html",
"panels/map/ha-panel-map.html",
"panels/shopping-list/ha-panel-shopping-list.html",
"panels/mailbox/ha-panel-mailbox.html"
"panels/mailbox/ha-panel-mailbox.html",
"src/resources/ha-chart-scripts.html"
],
"sources": [
"src/**/*",

View File

@ -27,6 +27,10 @@ cp build/panels/*.html $OUTPUT_DIR/panels
mkdir $OUTPUT_DIR_ES5/panels
cp build-es5/panels/*.html $OUTPUT_DIR_ES5/panels
# Chart code
cp build/src/resources/ha-chart-scripts.html $OUTPUT_DIR
cp build-es5/src/resources/ha-chart-scripts.html $OUTPUT_DIR_ES5
# Translations
cp -r build-translations/output $OUTPUT_DIR/translations

View File

@ -0,0 +1,484 @@
<link rel='import' href='../../../bower_components/polymer/polymer-element.html'>
<link rel='import' href='../../resources/ha-chart-scripts.html'>
<dom-module id="ha-chart-base">
<template>
<style>
.chartHeader {
padding: 6px 0 0 0;
}
.chartHeader div {
display: inline-block;
vertical-align: top;
}
.chartTitle {
margin: 0 12px 0 8px;
}
:root{
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.chartTooltip {
opacity: 1;
position: absolute;
background: rgba(0, 0, 0, .7);
color: white;
border-radius: 3px;
pointer-events: none;
transform: translate(-50%, 0);
z-index: 1000;
width: 200px;
}
.chartLegend ul,
.chartTooltip ul {
display: inline-block;
padding: 0 0px;
margin: 0 0 8px 0;
width: 100%
}
.chartTooltip li {
display: block;
white-space: pre-line;
}
.chartTooltip .title {
text-align: center;
}
.chartLegend li {
display: inline-block;
padding: 0 5px;
max-width: 49%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
}
.chartLegend li.hidden {
text-decoration: line-through;
}
.chartLegend em,
.chartTooltip em {
border-radius: 5px;
display: inline-block;
height: 10px;
margin-right: 6px;
width: 10px;
}
</style>
<template is="dom-if" if="[[unit]]">
<div class="chartHeader">
<div class="chartTitle">[[unit]]</div>
<div class="chartLegend">
<ul>
<template is="dom-repeat" items="[[metas]]">
<li data-lid$="[[itemsIndex]]" on-click="_legendClick" class$="[[item.hidden]]">
<em style$="background-color:[[item.bgColor]]"></em>
[[item.label]]
</li>
</template>
</ul>
</div>
</div>
</template>
<div id="chartTarget" style="height:40px; width:100%">
<canvas id="chartCanvas"></canvas>
<div class$="chartTooltip [[tooltip.yAlign]]"
style$="opacity:[[tooltip.opacity]]; top:[[tooltip.top]]; left:[[tooltip.left]]; padding:[[tooltip.yPadding]]px [[tooltip.xPadding]]px">
<div class="title">[[tooltip.title]]</div>
<div>
<ul >
<template is="dom-repeat" items="[[tooltip.lines]]">
<li><em style$="background-color:[[item.bgColor]]"></em>[[item.text]]</li>
</template>
</ul>
</div>
</div>
</div>
</template>
</dom-module>
<script>
// eslint-disable-next-line no-unused-vars
/* global Chart moment Color */
{
let SCRIPT_LOADED = window.HASS_DEV;
class HaChartBase extends Polymer.Element {
get chart() {
return this._chart;
}
static get is() { return 'ha-chart-base'; }
static get properties() {
return {
publish: {
type: Boolean,
observer: 'onPropsChange'
},
data: {
type: Object,
observer: 'onPropsChange'
},
};
}
connectedCallback() {
super.connectedCallback();
this._isAttached = true;
this.set('tooltip', {
opacity: '0',
left: '0',
top: '0',
xPadding: '0',
yPadding: '0'
});
if (!this._chart) {
this.onPropsChange();
}
this._resizeListener = () => {
this._debouncer = Polymer.Debouncer.debounce(
this._debouncer,
Polymer.Async.timeOut.after(10),
() => {
if (this._isAttached) {
this.resizeChart();
}
}
);
};
window.addEventListener('resize', this._resizeListener);
if (!SCRIPT_LOADED) {
Polymer.importHref(
`${window.HASS_ROOT}/ha-chart-scripts.html`,
() => {
SCRIPT_LOADED = true;
this.onPropsChange();
},
);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._isAttached = false;
window.removeEventListener('resize', this._resizeListener);
if (this._resizeTimer !== undefined) {
clearInterval(this._resizeTimer);
this._resizeTimer = undefined;
}
}
onPropsChange() {
if (!this._isAttached || !SCRIPT_LOADED || !this.publish || !this.data) {
return;
}
this.drawChart();
}
_customTooltips(tooltip) {
// Hide if no tooltip
if (tooltip.opacity === 0) {
this.set(['tooltip', 'opacity'], 0);
return;
}
// Set caret Position
if (tooltip.yAlign) {
this.set(['tooltip', 'yAlign'], tooltip.yAlign);
} else {
this.set(['tooltip', 'yAlign'], 'no-transform');
}
const title = tooltip.title ? tooltip.title[0] || '' : '';
let str1;
if (title instanceof Date) {
str1 = moment(title).format('L LTS');
} else if (title instanceof moment) {
str1 = title.format('L LTS');
} else {
str1 = title;
}
this.set(['tooltip', 'title'], str1);
const bodyLines = tooltip.body.map(n => n.lines);
// Set Text
if (tooltip.body) {
this.set(['tooltip', 'lines'], bodyLines.map((body, i) => {
const colors = tooltip.labelColors[i];
return {
color: colors.borderColor,
bgColor: colors.backgroundColor,
text: body.join('\n'),
};
}));
}
const parentWidth = this.$.chartTarget.clientWidth;
let positionX = tooltip.caretX;
const positionY = this._chart.canvas.offsetTop + tooltip.caretY;
if (tooltip.caretX + 100 > parentWidth) {
positionX = parentWidth - 100;
} else if (tooltip.caretX < 100) {
positionX = 100;
}
positionX += this._chart.canvas.offsetLeft;
// Display, position, and set styles for font
this.set(['tooltip', 'opacity'], 1);
this.set(['tooltip', 'left'], positionX + 'px');
this.set(['tooltip', 'top'], positionY + 'px');
this.set(['tooltip', 'yPadding'], tooltip.yPadding);
this.set(['tooltip', 'xPadding'], tooltip.xPadding);
}
_legendClick(event) {
event = event || window.event;
let target = event.target || event.srcElement;
while (target.nodeName !== 'LI') {
target = target.parentElement;
}
const index = target.getAttribute('data-lid');
const meta = this._chart.getDatasetMeta(index);
meta.hidden = meta.hidden === null ? !this._chart.data.datasets[index].hidden : null;
this.set(['metas', index, 'hidden'], this._chart.isDatasetVisible(index) ? null : 'hidden');
this._chart.update();
}
_drawLegend() {
const chart = this._chart;
this.set('metas', this._chart.data.datasets.map((x, i) => ({
label: x.label,
color: x.color,
bgColor: x.backgroundColor,
hidden: chart.isDatasetVisible(i)
})));
this.set('unit', this.data.unit);
}
drawChart() {
const data = this.data.data;
const ctx = this.$.chartCanvas;
if ((!data.datasets || !data.datasets.length) && !this._chart) {
return;
}
if (this.data.type !== 'timeline' && data.datasets.length > 0) {
let cnt = 0;
cnt = data.datasets.length;
const colors = this.constructor.getColorList(cnt);
for (let loopI = 0; loopI < cnt; loopI++) {
data.datasets[loopI].borderColor = colors[loopI].rgbString();
data.datasets[loopI].backgroundColor = colors[loopI].alpha(0.6).rgbaString();
}
}
if (this._chart) {
this._customTooltips({ opacity: 0 });
this._chart.data = data;
this._chart.update({ duration: 0 });
if (this.isTimeline !== true && this.data.legend === true) {
this._drawLegend();
}
this.resizeChart();
} else {
if (!data.datasets) {
return;
}
this._customTooltips({ opacity: 0 });
let options = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0,
},
hover: {
animationDuration: 0,
},
responsiveAnimationDuration: 0,
tooltips: {
enabled: false,
custom: this._customTooltips.bind(this),
},
legend: {
display: false,
},
line: {
spanGaps: true,
},
elements: {
font: "12px 'Roboto', 'sans-serif'",
},
ticks: {
fontFamily: "'Roboto', 'sans-serif'",
}
};
options = Chart.helpers.merge(options, this.data.options);
if (this.data.type === 'timeline') {
this.set('isTimeline', true);
if (this.data.colors !== undefined) {
this._colorFunc = this.constructor.getColorGenerator(
this.data.colors.staticColors,
this.data.colors.staticColorIndex
);
}
if (this._colorFunc !== undefined) {
options.colorFunction = this._colorFunc;
}
if (data.datasets.length === 1) {
if (options.scales.yAxes[0].ticks) {
options.scales.yAxes[0].ticks.display = false;
} else {
options.scales.yAxes[0].ticks = { display: false };
}
}
}
this.$.chartTarget.style.height = '160px';
this.$.chartTarget.height = '160px';
const chartData = {
type: this.data.type,
data: this.data.data,
options: options
};
// Async resize after dom update
this._chart = new Chart(ctx, chartData);
if (this.isTimeline !== true && this.data.legend === true) {
this._drawLegend();
}
this.resizeChart();
}
}
resizeChart() {
if (!this._chart) return;
// Chart not ready
if (this.$.chartTarget.clientWidth === 0) {
if (this._resizeTimer === undefined) {
this._resizeTimer = setInterval(this.resizeChart.bind(this), 10);
return;
}
}
clearInterval(this._resizeTimer);
this._resizeTimer = undefined;
this._resizeChart();
}
_resizeChart() {
const chartTarget = this.$.chartTarget;
const options = this.data;
const data = options.data;
if (data.datasets.length === 0) {
return;
}
if (!this.isTimeline) {
this._chart.resize();
return;
}
// Recalculate chart height for Timeline chart
var axis = this._chart.boxes.filter(x => x.position === 'bottom')[0];
if (axis && axis.height > 0) {
this._axisHeight = axis.height;
}
if (!this._axisHeight) {
chartTarget.style.height = '100px';
chartTarget.height = '100px';
this._chart.resize();
axis = this._chart.boxes.filter(x => x.position === 'bottom')[0];
if (axis && axis.height > 0) {
this._axisHeight = axis.height;
}
}
if (this._axisHeight) {
const cnt = data.datasets.length;
const targetHeight = ((30 * cnt) + this._axisHeight) + 'px';
if (chartTarget.style.height !== targetHeight) {
chartTarget.style.height = targetHeight;
chartTarget.height = targetHeight;
}
this._chart.resize();
}
}
// Get HSL distributed color list
static getColorList(count) {
let processL = false;
if (count > 10) {
processL = true;
count = Math.ceil(count / 2);
}
const h1 = 360 / count;
const result = [];
for (let loopI = 0; loopI < count; loopI++) {
result[loopI] = Color().hsl(h1 * loopI, 80, 38);
if (processL) {
result[loopI + count] = Color().hsl(h1 * loopI, 80, 62);
}
}
return result;
}
static getColorGenerator(staticColors, startIndex) {
// Known colors for static data,
// should add for very common state string manually.
// Distribute the color data like complete binary tree
function getColorRange(x) {
if (x === 0) return 0;
if (x === 1) return 0.5;
const y = Math.floor(Math.log(x) / Math.LN2);
// eslint-disable-next-line no-restricted-properties
const e = Math.pow(2, y);
const n = x - e;
let a;
if (y % 2 === 1) {
if (n % 2 === 0) {
a = n + 1;
} else {
a = n + e;
}
} else {
// eslint-disable-next-line no-lonely-if
if (n % 2 === 0) {
a = e - n - 1;
} else {
a = (e + e) - n;
}
}
return a / (e + e);
}
function getColorIndex(idx) {
const hIndex = Math.floor(idx / 6);
const h1 = getColorRange(hIndex);
const c1 = (h1 + (idx % 3)) * 120;
const l1 = idx % 6 < 3 ? 62 : 38;
return Color().hsl(c1, 75, l1);
}
const colorDict = {};
let colorIndex = 0;
if (startIndex > 0) colorIndex = startIndex;
if (staticColors) {
Object.keys(staticColors).forEach((c) => {
const c1 = staticColors[c];
if (isFinite(c1)) {
colorDict[c.toLowerCase()] = getColorIndex(c1);
} else {
colorDict[c.toLowerCase()] = Color(staticColors[c]);
}
});
}
// Custom color assign
function getColor(__, data) {
let ret;
const name = data[3];
if (name === null) return Color().hsl(0, 40, 38);
if (name === undefined) return Color().hsl(120, 40, 38);
const name1 = name.toLowerCase();
if (ret === undefined) {
ret = colorDict[name1];
}
if (ret === undefined) {
ret = getColorIndex(colorIndex);
colorIndex++;
colorDict[name1] = ret;
}
return ret;
}
return getColor;
}
}
customElements.define(HaChartBase.is, HaChartBase);
}
</script>

View File

@ -1,24 +1,13 @@
<link rel="import" href="../../bower_components/polymer/polymer.html">
<link rel="import" href="../../bower_components/polymer/polymer-element.html">
<link rel="import" href="../../bower_components/polymer/lib/utils/debounce.html">
<link rel="import" href="./entity/ha-chart-base.html">
<dom-module id='state-history-chart-line'>
<template>
<ha-chart-base publish data="[[chartData]]"></ha-chart-base>
</template>
</dom-module>
<script>
{
function range(start, end) {
var result = [];
var i;
for (i = start; i < end; i++) {
result.push(i);
}
return result;
}
function saveParseFloat(value) {
var parsed = parseFloat(value);
return !isNaN(parsed) && isFinite(parsed) ? parsed : null;
}
class StateHistoryChartLine extends Polymer.Element {
static get is() { return 'state-history-chart-line'; }
static get properties() {
@ -39,13 +28,8 @@
endTime: {
type: Object,
},
chartEngine: {
type: Object,
},
};
}
static get observers() {
return ['dataChanged(data, endTime)'];
}
@ -54,25 +38,6 @@
super.connectedCallback();
this._isAttached = true;
this.drawChart();
this._resizeListener = () => {
this._debouncer = Polymer.Debouncer.debounce(
this._debouncer,
Polymer.Async.timeOut.after(10),
() => {
if (this._isAttached) {
this.drawChart();
}
}
);
};
window.addEventListener('resize', this._resizeListener);
}
disconnectedCallback() {
super.disconnectedCallback();
this._isAttached = false;
window.removeEventListener('resize', this._resizeListener);
}
dataChanged() {
@ -80,61 +45,24 @@
}
drawChart() {
var unit = this.unit;
var deviceStates = this.data;
var options;
var startTime;
var endTime;
var dataTables;
var finalDataTable;
var daysDelta;
const unit = this.unit;
const deviceStates = this.data;
const datasets = [];
let endTime;
if (!this._isAttached) {
return;
}
if (!this.chartEngine) {
this.chartEngine = new window.google.visualization.ComboChart(this);
}
if (deviceStates.length === 0) {
return;
}
options = {
backgroundColor: '#fafafa',
legend: { position: 'top' },
interpolateNulls: true,
titlePosition: 'none',
vAxes: {
// Adds units to the left hand side of the graph
0: { title: unit },
},
hAxis: {
format: 'H:mm',
},
chartArea: { left: '60', width: '95%' },
explorer: {
actions: ['dragToZoom', 'rightClickToReset', 'dragToPan'],
keepInBounds: true,
axis: 'horizontal',
maxZoomIn: 0.1,
},
seriesType: 'line',
};
if (this.isSingleDevice) {
options.legend.position = 'none';
options.vAxes[0].title = null;
options.chartArea.left = 40;
options.chartArea.height = '80%';
options.chartArea.top = 5;
function safeParseFloat(value) {
const parsed = parseFloat(value);
return isFinite(parsed) ? parsed : null;
}
startTime = new Date(Math.min.apply(null, deviceStates.map(function (states) {
return new Date(states.states[0].last_changed);
})));
endTime = this.endTime ||
new Date(Math.max.apply(null, deviceStates.map(states =>
new Date(states.states[states.states.length - 1].last_changed))));
@ -142,158 +70,205 @@
endTime = new Date();
}
daysDelta = (endTime - startTime) / (24 * 3600 * 1000);
// Avoid rounding up when the API returns a few extra seconds.
if (daysDelta > 30.1) {
options.hAxis.format = 'MMM d';
} else if (daysDelta > 3.1) {
options.hAxis.format = 'EEE, MMM d';
} else if (daysDelta > 1.1) {
options.hAxis.format = 'EEE, MMM d, H:mm';
}
deviceStates.forEach((states) => {
const domain = states.domain;
const name = states.name;
// array containing [value1, value2, etc]
let prevValues;
const data = [];
dataTables = deviceStates.map(function (states) {
var domain = states.domain;
var name = states.name;
var data = [];
var dataTable = new window.google.visualization.DataTable();
// array containing [time, value1, value2, etc]
var prevValues;
var processState;
var noInterpolations;
var series;
dataTable.addColumn({ type: 'datetime', id: 'Time' });
function pushData(values, noInterpolationValues) {
var timestamp = values[0];
function pushData(timestamp, datavalues) {
if (!datavalues) return;
if (timestamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
if (prevValues && noInterpolationValues) {
// if we have to prevent interpolation, we add an old value for each
// value that should not be interpolated at the same time that our new
// line will be published.
data.push([timestamp].concat(prevValues.slice(1).map(function (val, index) {
return noInterpolationValues[index] ? val : null;
})));
data.forEach((d, i) => {
d.data.push({ x: timestamp, y: datavalues[i] });
});
prevValues = datavalues;
}
data.push(values);
prevValues = values;
function addColumn(nameY, step, fill) {
let dataFill = false;
let dataStep = false;
if (fill) {
dataFill = 'origin';
}
if (step) {
dataStep = 'before';
}
data.push({
label: nameY,
fill: dataFill,
steppedLine: dataStep,
pointRadius: 0,
data: [],
unitText: unit
});
}
if (domain === 'thermostat' || domain === 'climate') {
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
// Using step chart by step-before so manually interpolation not needed.
const hasTargetRange = states.states.some(state => state.attributes &&
state.attributes.target_temp_high !== state.attributes.target_temp_low);
const hasHeat = states.states.some(state => state.state === 'heat');
const hasCool = states.states.some(state => state.state === 'cool');
dataTable.addColumn('number', name + ' current temperature');
if (hasHeat || hasCool) {
options.series = Object.assign({}, options.series);
}
addColumn(name + ' current temperature', true);
if (hasHeat) {
dataTable.addColumn('number', name + ' heating');
addColumn(name + ' heating', true, true);
// The "heating" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
options.series[dataTable.getNumberOfColumns() - 1] =
{ type: 'steppedArea' };
}
if (hasCool) {
dataTable.addColumn('number', name + ' cooling');
addColumn(name + ' cooling', true, true);
// The "cooling" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
options.series[dataTable.getNumberOfColumns() - 1] =
{ type: 'steppedArea' };
}
if (hasTargetRange) {
dataTable.addColumn('number', name + ' target temperature high');
dataTable.addColumn('number', name + ' target temperature low');
addColumn(name + ' target temperature high', true);
addColumn(name + ' target temperature low', true);
} else {
dataTable.addColumn('number', name + ' target temperature');
addColumn(name + ' target temperature', true);
}
processState = function (state) {
states.states.forEach((state) => {
if (!state.attributes) return;
const curTemp = saveParseFloat(state.attributes.current_temperature);
series = [curTemp];
noInterpolations = [false];
// Drawing the 'heating'/'cooling' area up to the current temp should keep it from
// overlapping but avoid any weird gaps or range mismatches
const curTemp = safeParseFloat(state.attributes.current_temperature);
const series = [curTemp];
if (hasHeat) {
series.push(state.state === 'heat' ? curTemp : null);
noInterpolations.push(true);
}
if (hasCool) {
series.push(state.state === 'cool' ? curTemp : null);
noInterpolations.push(true);
}
if (hasTargetRange) {
var targetHigh = saveParseFloat(state.attributes.target_temp_high);
var targetLow = saveParseFloat(state.attributes.target_temp_low);
series = series.concat([targetHigh, targetLow]);
noInterpolations = noInterpolations.concat([true, true]);
} else {
var target = saveParseFloat(state.attributes.temperature);
series.push(target);
noInterpolations.push(true);
}
const targetHigh = safeParseFloat(state.attributes.target_temp_high);
const targetLow = safeParseFloat(state.attributes.target_temp_low);
series.push(targetHigh, targetLow);
pushData(
[new Date(state.last_changed)].concat(series),
noInterpolations
new Date(state.last_changed),
series
);
};
states.states.forEach(processState);
} else {
dataTable.addColumn('number', name);
const target = safeParseFloat(state.attributes.temperature);
series.push(target);
pushData(
new Date(state.last_changed),
series
);
}
});
} else {
// Only disable interpolation for sensors
noInterpolations = domain !== 'sensor' && [true];
const isStep = domain === 'sensor';
addColumn(name, isStep);
states.states.forEach(function (state) {
var value = saveParseFloat(state.state);
pushData([new Date(state.last_changed), value], noInterpolations);
let lastValue = null;
let lastDate = null;
let lastNullDate = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
states.states.forEach((state) => {
const value = safeParseFloat(state.state);
const date = new Date(state.last_changed);
if (value !== null && lastNullDate !== null) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate.getTime();
const tmpValue = ((value - lastValue) *
((lastNullDateTime - lastDateTime) / (dateTime - lastDateTime))) + lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
lastNullDate = null;
} else if (value !== null && lastNullDate === null) {
pushData(date, [value]);
lastDate = date;
lastValue = value;
} else if (value === null && lastNullDate === null && lastValue !== null) {
lastNullDate = date;
}
});
}
// Add an entry for final values
if (prevValues) {
pushData([endTime].concat(prevValues.slice(1)), false);
}
pushData(endTime, prevValues, false);
dataTable.addRows(data);
return dataTable;
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
if (dataTables.length === 1) {
finalDataTable = dataTables[0];
} else {
finalDataTable = dataTables.slice(1).reduce(
function (tot, cur) {
return window.google.visualization.data.join(
tot, cur, 'full', [[0, 0]],
range(1, tot.getNumberOfColumns()),
range(1, cur.getNumberOfColumns())
);
const chartOptions = {
type: 'line',
unit: unit,
legend: !this.isSingleDevice,
options: {
scales: {
xAxes: [{
type: 'time',
ticks: {
major: {
fontStyle: 'bold',
},
dataTables[0]
);
},
}],
yAxes: [{
ticks: {
maxTicksLimit: 7,
},
// Add space to prevent cut-off.
afterDataLimits: (axis) => {
const min = axis.min;
const max = axis.max;
if (isFinite(min) && isFinite(max)) {
const padding = (max - min) * 0.05;
axis.min -= padding;
axis.max += padding;
}
this.chartEngine.draw(finalDataTable, options);
},
}],
},
tooltips: {
mode: 'neareach',
},
hover: {
mode: 'neareach',
},
elements: {
line: {
tension: 0.1,
pointRadius: 0,
borderWidth: 1.5,
},
point: {
hitRadius: 5,
}
},
plugins: {
filler: {
propagate: true,
}
},
},
data: {
labels: [],
datasets: datasets
}
};
this.chartData = chartOptions;
}
}
customElements.define(StateHistoryChartLine.is, StateHistoryChartLine);
}
</script>

View File

@ -1,13 +1,25 @@
<link rel="import" href="../../bower_components/polymer/polymer-element.html">
<link rel='import' href='../../bower_components/polymer/polymer-element.html'>
<link rel="import" href="../../bower_components/polymer/lib/utils/debounce.html">
<link rel='import' href='./entity/ha-chart-base.html'>
<dom-module id='state-history-chart-timeline'>
<template>
<ha-chart-base publish data="[[chartData]]"></ha-chart-base>
</template>
</dom-module>
<script>
'use strict';
class StateHistoryChartTimeline extends Polymer.Element {
static get is() { return 'state-history-chart-timeline'; }
static get properties() {
return {
hass: {
type: Object,
},
data: {
type: Object,
observer: 'dataChanged',
},
noSingle: Boolean,
endTime: Date,
@ -15,32 +27,13 @@ class StateHistoryChartTimeline extends Polymer.Element {
}
static get observers() {
return ['dataChanged(data, endTime)'];
return ['dataChanged(data, endTime, localize, language)'];
}
connectedCallback() {
super.connectedCallback();
this._isAttached = true;
this.drawChart();
this._resizeListener = () => {
this._debouncer = Polymer.Debouncer.debounce(
this._debouncer,
Polymer.Async.timeOut.after(10),
() => {
if (this._isAttached) {
this.drawChart();
}
}
);
};
window.addEventListener('resize', this._resizeListener);
}
disconnectedCallback() {
super.disconnectedCallback();
this._isAttached = false;
window.removeEventListener('resize', this._resizeListener);
}
dataChanged() {
@ -48,119 +41,124 @@ class StateHistoryChartTimeline extends Polymer.Element {
}
drawChart() {
var stateHistory = this.data;
var chart;
var dataTable;
var startTime;
var endTime;
var numTimelines;
var format;
var daysDelta;
const staticColors = {
on: 1,
off: 0,
unavailable: '#a0a0a0',
unknown: '#606060',
idle: 2
};
let stateHistory = this.data;
if (!this._isAttached) {
return;
}
while (this.lastChild) {
this.removeChild(this.lastChild);
if (!stateHistory) {
stateHistory = [];
}
if (!stateHistory || stateHistory.length === 0) {
return;
}
chart = new window.google.visualization.Timeline(this);
dataTable = new window.google.visualization.DataTable();
dataTable.addColumn({ type: 'string', id: 'Entity' });
dataTable.addColumn({ type: 'string', id: 'State' });
dataTable.addColumn({ type: 'date', id: 'Start' });
dataTable.addColumn({ type: 'date', id: 'End' });
function addRow(entityDisplay, stateStr, start, end) {
var stateDisplay = stateStr.replace(/_/g, ' ');
dataTable.addRow([entityDisplay, stateDisplay, start, end]);
}
startTime = new Date(stateHistory.reduce(function (minTime, stateInfo) {
return Math.min(minTime, new Date(stateInfo.data[0].last_changed));
}, new Date()));
const startTime = new Date(stateHistory.reduce(
(minTime, stateInfo) => Math.min(minTime, new Date(stateInfo.data[0].last_changed)),
new Date()
));
// end time is Math.max(startTime, last_event)
endTime = this.endTime ||
new Date(stateHistory.reduce(function (maxTime, stateInfo) {
return Math.max(
let endTime = this.endTime ||
new Date(stateHistory.reduce((maxTime, stateInfo) => Math.max(
maxTime,
new Date(stateInfo.data[stateInfo.data.length - 1].last_changed)
);
}, startTime));
), startTime));
if (endTime > new Date()) {
endTime = new Date();
}
format = 'H:mm';
daysDelta = (endTime - startTime) / (24 * 3600 * 1000);
// Avoid rounding up when the API returns a few extra seconds.
if (daysDelta > 30.1) {
format = 'MMM d';
} else if (daysDelta > 3.1) {
format = 'EEE, MMM d';
} else if (daysDelta > 1.1) {
format = 'EEE, MMM d, H:mm';
}
numTimelines = 0;
const labels = [];
const datasets = [];
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach(function (stateInfo) {
var entityDisplay;
var newLastChanged;
var prevState = null;
var prevLastChanged = null;
stateHistory.forEach((stateInfo) => {
let newLastChanged;
let prevState = null;
let locState = null;
let prevLastChanged = startTime;
const entityDisplay = stateInfo.name;
const dataRow = [];
if (stateInfo.data.length === 0) return;
entityDisplay = stateInfo.name;
stateInfo.data.forEach(function (state) {
var timeStamp = new Date(state.last_changed);
stateInfo.data.forEach((state) => {
let newState = state.state;
const timeStamp = new Date(state.last_changed);
if (newState === undefined || newState === '') {
newState = null;
}
if (timeStamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState !== null && state.state !== prevState) {
if (prevState !== null && newState !== prevState) {
newLastChanged = new Date(state.last_changed);
addRow(entityDisplay, prevState, prevLastChanged, newLastChanged);
dataRow.push([
prevLastChanged,
newLastChanged,
locState,
prevState,
]);
prevState = state.state;
prevState = newState;
locState = state.state_localize;
prevLastChanged = newLastChanged;
} else if (prevState === null) {
prevState = state.state;
prevState = newState;
locState = state.state_localize;
prevLastChanged = new Date(state.last_changed);
}
});
if (prevState !== null) {
addRow(entityDisplay, prevState, prevLastChanged, endTime);
dataRow.push([
prevLastChanged,
endTime,
locState,
prevState,
]);
}
numTimelines++;
datasets.push({ data: dataRow });
labels.push(entityDisplay);
});
chart.draw(dataTable, {
backgroundColor: '#fafafa',
height: 55 + (numTimelines * 42),
timeline: {
showRowLabels: this.noSingle || stateHistory.length > 1,
const chartOptions = {
type: 'timeline',
options: {
scales: {
xAxes: [{
ticks: {
major: {
fontStyle: 'bold',
},
hAxis: {
format: format
},
});
}],
yAxes: [{
afterSetDimensions: (yaxe) => {
yaxe.maxWidth = yaxe.chart.width * 0.18;
}
}],
},
},
data: {
labels: labels,
datasets: datasets
},
colors: {
staticColors: staticColors,
staticColorIndex: 3,
}
};
this.chartData = chartOptions;
}
}
customElements.define(StateHistoryChartTimeline.is, StateHistoryChartTimeline);
</script>

View File

@ -1,27 +1,16 @@
<link rel="import" href="../../bower_components/polymer/polymer-element.html">
<link rel="import" href="../../bower_components/paper-spinner/paper-spinner.html">
<link rel="import" href="../../bower_components/google-apis/google-legacy-loader.html">
<link rel="import" href="./state-history-chart-timeline.html">
<link rel="import" href="./state-history-chart-line.html">
<dom-module id="state-history-charts">
<template>
<link href="https://ajax.googleapis.com/ajax/static/modules/gviz/1.0/core/tooltip.css" rel="stylesheet" type="text/css">
<style>
:host {
display: block;
}
.google-visualization-tooltip {
z-index: 200;
}
state-history-chart-timeline, state-history-chart-line {
display: block;
}
.loading-container {
text-align: center;
padding: 8px;
@ -33,35 +22,27 @@
}
</style>
<google-legacy-loader on-api-load="_googleApiLoaded"></google-legacy-loader>
<template is='dom-if' if='[[_isLoading]]'>
<div class='loading-container'>
<paper-spinner active alt='Updating history data'></paper-spinner>
</div>
</template>
<template is='dom-if' if='[[!_isLoading]]'>
<template is='dom-if' if='[[_computeIsEmpty(historyData)]]'>
No state history found.
</template>
<template is='dom-if' if='[[historyData.timeline.length]]'>
<state-history-chart-timeline
data='[[historyData.timeline]]'
end-time='[[_computeEndTime(endTime, upToNow, historyData)]]'
no-single='[[noSingle]]'>
</state-history-chart-timeline>
</template>
<template is='dom-repeat' items='[[historyData.line]]'>
<state-history-chart-line
unit='[[item.unit]]'
data='[[item.data]]'
is-single-device='[[_computeIsSingleLineChart(historyData, noSingle)]]'
is-single-device='[[_computeIsSingleLineChart(item.data, noSingle)]]'
end-time='[[_computeEndTime(endTime, upToNow, historyData)]]'>
</state-history-chart-line>
</template>
</template>
</template>
</dom-module>
<script>
@ -69,6 +50,9 @@ class StateHistoryCharts extends Polymer.Element {
static get is() { return 'state-history-charts'; }
static get properties() {
return {
hass: {
type: Object
},
historyData: {
type: Object,
value: null,
@ -85,35 +69,11 @@ class StateHistoryCharts extends Polymer.Element {
upToNow: Boolean,
noSingle: Boolean,
_apiLoaded: {
type: Boolean,
value: false,
},
_isLoading: {
type: Boolean,
computed: '_computeIsLoading(isLoadingData, _apiLoaded)',
},
};
}
_computeIsSingleLineChart(historyData, noSingle) {
return !noSingle && historyData && historyData.line.length === 1;
}
_googleApiLoaded() {
window.google.load('visualization', '1', {
packages: ['timeline', 'corechart'],
language: navigator.language,
callback: function () {
this._apiLoaded = true;
}.bind(this),
});
}
_computeIsLoading(_isLoadingData, _apiLoaded) {
return _isLoadingData || !_apiLoaded;
_computeIsSingleLineChart(data, noSingle) {
return !noSingle && data && data.length === 1;
}
_computeIsEmpty(historyData) {

View File

@ -35,7 +35,8 @@
entity_id: stateInfo[0].entity_id,
data: stateInfo
.map(state => ({
state: window.hassUtil.computeStateDisplay(localize, state, language),
state_localize: window.hassUtil.computeStateDisplay(localize, state, language),
state: state.state,
last_changed: state.last_changed,
}))
.filter((element, index, arr) => {
@ -140,7 +141,7 @@
super.connectedCallback();
this.filterChanged(
this.filterType, this.entityId, this.startTime, this.endTime,
this.cacheConfig, this.localize, this.language
this.cacheConfig
);
}
@ -164,6 +165,7 @@
filterChanged(filterType, entityId, startTime, endTime, cacheConfig, localize, language) {
if (!this.hass) return;
if (cacheConfig && !cacheConfig.cacheKey) return;
if (!localize || !language) return;
this._madeFirstCall = true;
let data;

View File

@ -0,0 +1,74 @@
<script src="../../bower_components/moment/moment.js"></script>
<script src="../../bower_components/chart.js/dist/Chart.min.js"></script>
<script src="../../bower_components/chartjs-chart-timeline/timeline.js"></script>
<script>
// Use minified(Chart.min.js) version to fix strange color after uglify
// eslint-disable-next-line no-unused-vars
/* global Chart moment */
{
// This function add a new interaction mode to Chart.js that
// returns one point for every dataset.
Chart.Interaction.modes.neareach = function (chart, e, options) {
const getRange = {
x: (a, b) => Math.abs(a.x - b.x),
y: (a, b) => Math.abs(a.y - b.y),
// eslint-disable-next-line no-restricted-properties
xy: (a, b) => Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2),
};
const getRangeMax = {
x: r => r,
y: r => r,
xy: r => r * r,
};
let position;
if (e.native) {
position = {
x: e.x,
y: e.y
};
} else {
position = Chart.helpers.getRelativePosition(e, chart);
}
const elements = [];
const elementsRange = [];
const datasets = chart.data.datasets;
let meta;
options.axis = options.axis || 'xy';
const rangeFunc = getRange[options.axis];
const rangeMaxFunc = getRangeMax[options.axis];
for (let i = 0, ilen = datasets.length; i < ilen; ++i) {
if (!chart.isDatasetVisible(i)) {
continue;
}
meta = chart.getDatasetMeta(i);
for (let j = 0, jlen = meta.data.length; j < jlen; ++j) {
const element = meta.data[j];
if (!element._view.skip) {
const vm = element._view;
const range = rangeFunc(vm, position);
const oldRange = elementsRange[i];
if (range < rangeMaxFunc(vm.radius + vm.hitRadius)) {
if (oldRange === undefined || oldRange > range) {
elementsRange[i] = range;
elements[i] = element;
}
}
}
}
}
const ret = elements.filter(n => n !== undefined);
return ret;
};
// Fix infinite loop bug in Chart.js 2.7.1
const x = Chart.scaleService.constructors.time.prototype;
x._getLabelCapacity = x.getLabelCapacity;
x.getLabelCapacity = function () {
// eslint-disable-next-line prefer-rest-params
const ret = this._getLabelCapacity.apply(this, arguments);
return ret > 0 ? ret : 1;
};
}
</script>