mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-13 04:16:34 +00:00
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:
parent
500edbad0d
commit
c6030e6edc
@ -16,6 +16,7 @@
|
||||
"__DEMO__": false,
|
||||
"__BUILD__": false,
|
||||
"__VERSION__": false,
|
||||
"__ROOT__": false,
|
||||
"Polymer": true,
|
||||
"webkitSpeechRecognition": false,
|
||||
"ResizeObserver": false
|
||||
|
@ -8,6 +8,7 @@
|
||||
"browser": true
|
||||
},
|
||||
"rules": {
|
||||
"import/no-unresolved": 2
|
||||
"import/no-unresolved": 2,
|
||||
"linebreak-style": 0
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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')
|
||||
]);
|
||||
|
@ -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'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
|
@ -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/**/*",
|
||||
|
@ -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
|
||||
|
||||
|
484
src/components/entity/ha-chart-base.html
Normal file
484
src/components/entity/ha-chart-base.html
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
||||
|
74
src/resources/ha-chart-scripts.html
Normal file
74
src/resources/ha-chart-scripts.html
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user