diff --git a/front/src/components/boxs/chart/ApexChartComponent.jsx b/front/src/components/boxs/chart/ApexChartComponent.jsx index fa9b7468f2..57e8ca8706 100644 --- a/front/src/components/boxs/chart/ApexChartComponent.jsx +++ b/front/src/components/boxs/chart/ApexChartComponent.jsx @@ -11,6 +11,7 @@ import { getApexChartBarOptions } from './ApexChartBarOptions'; import { getApexChartAreaOptions } from './ApexChartAreaOptions'; import { getApexChartLineOptions } from './ApexChartLineOptions'; import { getApexChartStepLineOptions } from './ApexChartStepLineOptions'; +import { getApexChartTimelineOptions } from './ApexChartTimelineOptions'; import mergeArray from '../../../utils/mergeArray'; dayjs.extend(localizedFormat); @@ -51,6 +52,56 @@ class ApexChartComponent extends Component { } }; } + addDateFormatterRangeBar(options) { + const createTooltipContent = (opts, startDate, endDate) => { + const w = opts.ctx.w; + const seriesName = w.config.series[opts.seriesIndex].name ? w.config.series[opts.seriesIndex].name : ''; + const ylabel = w.globals.seriesX[opts.seriesIndex][opts.dataPointIndex]; + const color = w.globals.colors[opts.seriesIndex]; + + return `
+
+ ${seriesName ? seriesName : ''} +
+
+ ${ylabel}: +
   + ${dictionnary.start_date}${startDate} +

   + ${dictionnary.end_date}${endDate} +
+
`; + }; + + let formatter_custom; + const dictionnary = this.props.dictionary.dashboard.boxes.chart; + if (this.props.interval <= 24 * 60) { + formatter_custom = opts => { + const startDate = dayjs(opts.y1) + .locale(this.props.user.language) + .format('LL - LTS'); + const endDate = dayjs(opts.y2) + .locale(this.props.user.language) + .format('LL - LTS'); + + return createTooltipContent(opts, startDate, endDate); + }; + } else { + formatter_custom = opts => { + const startDate = dayjs(opts.y1) + .locale(this.props.user.language) + .format('LL'); + const endDate = dayjs(opts.y2) + .locale(this.props.user.language) + .format('LL'); + + return createTooltipContent(opts, startDate, endDate); + }; + } + options.tooltip.custom = function(opts) { + return formatter_custom(opts); + }; + } getBarChartOptions = () => { const options = getApexChartBarOptions({ displayAxes: this.props.display_axes, @@ -123,9 +174,32 @@ class ApexChartComponent extends Component { this.addDateFormatter(options); return options; }; + getTimelineChartOptions = () => { + let height; + if (this.props.size === 'small' && !this.props.display_axes) { + height = 40; + } else if (this.props.size === 'big' && !this.props.display_axes) { + height = 80; + } else { + // 95 is the height display of the timeline chart when there is no additional height + height = 95 + this.props.additionalHeight; + } + const options = getApexChartTimelineOptions({ + height, + colors: mergeArray(this.props.colors, DEFAULT_COLORS), + displayAxes: this.props.display_axes, + series: this.props.series, + locales: [fr, en, de], + defaultLocale: this.props.user.language + }); + this.addDateFormatterRangeBar(options); + return options; + }; displayChart = () => { let options; - if (this.props.chart_type === 'area') { + if (this.props.chart_type === 'timeline') { + options = this.getTimelineChartOptions(); + } else if (this.props.chart_type === 'area') { options = this.getAreaChartOptions(); } else if (this.props.chart_type === 'line') { options = this.getLineChartOptions(); @@ -140,6 +214,7 @@ class ApexChartComponent extends Component { this.chart.updateOptions(options); } else { this.chart = new ApexCharts(this.chartRef.current, options); + this.chart.render(); } }; @@ -152,7 +227,15 @@ class ApexChartComponent extends Component { const displayAxesDifferent = nextProps.display_axes !== this.props.display_axes; const intervalDifferent = nextProps.interval !== this.props.interval; const sizeDifferent = nextProps.size !== this.props.size; - if (seriesDifferent || chartTypeDifferent || displayAxesDifferent || intervalDifferent || sizeDifferent) { + const additionalHeightDifferent = nextProps.additionalHeight !== this.props.additionalHeight; + if ( + seriesDifferent || + chartTypeDifferent || + displayAxesDifferent || + intervalDifferent || + sizeDifferent || + additionalHeightDifferent + ) { this.displayChart(); } } diff --git a/front/src/components/boxs/chart/ApexChartTimelineOptions.js b/front/src/components/boxs/chart/ApexChartTimelineOptions.js new file mode 100644 index 0000000000..8fd467e66e --- /dev/null +++ b/front/src/components/boxs/chart/ApexChartTimelineOptions.js @@ -0,0 +1,215 @@ +const addYAxisStyles = () => { + const yAxisLabel = document.querySelectorAll('.apexcharts-yaxis-label'); + let fontSize = '12px'; + yAxisLabel.forEach(text => { + const title = text.querySelector('title'); + if (title) { + const textContent = title.textContent; + let lines = textContent.split('\n'); + let countLineBreak = (textContent.match(/\n/g) || []).length; + let marginDy; + if (countLineBreak === 2) { + marginDy = '-1.0em'; + } else if (countLineBreak === 1) { + marginDy = '-0.4em'; + } else if (countLineBreak === 0) { + marginDy = '0em'; + } + text.innerHTML = ''; + lines.forEach((line, index) => { + const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); + tspan.setAttribute('x', text.getAttribute('x')); + tspan.setAttribute('dy', index === 0 ? marginDy : '1.2em'); + tspan.setAttribute('font-size', fontSize); + tspan.textContent = line; + text.appendChild(tspan); + }); + const newTitle = document.createElementNS('http://www.w3.org/2000/svg', 'title'); + newTitle.textContent = textContent; + text.appendChild(newTitle); + } + }); +}; +const limitZoom = chartContext => { + const minZoomRange = 10000; + const globals = chartContext.w.globals; + + const maxY = globals.maxY; + const minY = globals.minY; + const currentRange = maxY - minY; + if (currentRange < minZoomRange) { + chartContext.updateOptions( + { + xaxis: { + min: globals.minY, + max: globals.minY + minZoomRange + } + }, + false, + false + ); + } +}; + +const getApexChartTimelineOptions = ({ displayAxes, height, series, colors, locales, defaultLocale }) => { + const options = { + series, + chart: { + locales, + defaultLocale, + type: 'rangeBar', + fontFamily: 'inherit', + height, + parentHeightOffset: 0, + sparkline: { + enabled: !displayAxes + }, + toolbar: { + show: false + }, + animations: { + enabled: false + }, + events: { + mounted: addYAxisStyles, + updated: addYAxisStyles, + zoomed: function(chartContext) { + limitZoom(chartContext); + } + } + }, + grid: { + strokeDashArray: 4, + padding: { + left: 1 + } + }, + plotOptions: { + bar: { + horizontal: true, + barHeight: '50%', + rangeBarGroupRows: true + } + }, + colors, + fill: { + type: 'solid' + }, + xaxis: { + labels: { + padding: 0, + hideOverlappingLabels: true, + datetimeUTC: false, + trim: true, + datetimeFormatter: { + year: 'yyyy', + month: "MMM 'yy", + day: 'dd MMM', + hour: 'HH:mm', + minute: 'HH:mm:ss', + second: 'HH:mm:ss' + } + }, + axisBorder: { + show: true + }, + type: 'datetime', + min: Math.floor(Math.min(...series.flatMap(s => s.data.map(d => d.y[0])))), + max: Math.floor(Math.max(...series.flatMap(s => s.data.map(d => d.y[1])))) + }, + yaxis: { + showAlways: true, + dataLabels: { + enabled: false, + textAnchor: 'start' + }, + axisBorder: { + show: true + }, + labels: { + align: 'left', + minWidth: 50, + maxWidth: 100, + margin: 5, + formatter: function(value) { + const nbLines = 3; + if (value.length > 15) { + let [deviceName, featureName] = value.split(' ('); + if (featureName) { + featureName = featureName.replace(')', ''); + } + + let result = []; + let currentLine = ''; + + for (let i = 0; i < deviceName.length; i++) { + currentLine += deviceName[i].replace('-', ' ').replace('_', ' '); + if (currentLine.length >= 15) { + let lastSpaceIndex = currentLine.lastIndexOf(' '); + if (lastSpaceIndex > -1) { + result.push(currentLine.slice(0, lastSpaceIndex).trim()); + currentLine = currentLine.slice(lastSpaceIndex + 1); + } else { + result.push(currentLine.trim()); + currentLine = ''; + } + } + } + + if (currentLine.length > 0) { + result.push(currentLine.trim()); + } + if (result.length > nbLines && !featureName) { + result = result.slice(0, nbLines); + result[nbLines - 1] += '...'; + } + if (result.length > nbLines - 1 && featureName) { + result = result.slice(0, nbLines - 1); + result[nbLines - 2] += '...'; + } + deviceName = result.join('\n'); + + if (featureName) { + return `${deviceName}\n(${featureName})`; + } + + return deviceName; + } + + return value; + }, + offsetX: -15, + offsetY: 0 + } + }, + legend: { + show: displayAxes, + position: 'bottom', + itemMargin: { + horizontal: 20 + } + }, + tooltip: { + //theme: 'dark', + marker: { + show: true + }, + onDatasetHover: { + highlightDataSeries: false + }, + items: { + display: 'flex' + }, + fillSeriesColor: false, + fixed: { + enabled: true, + position: 'topLeft', + offsetX: 0, + offsetY: -70 + } + } + }; + return options; +}; + +export { getApexChartTimelineOptions }; diff --git a/front/src/components/boxs/chart/Chart.jsx b/front/src/components/boxs/chart/Chart.jsx index 53f23b0985..c97d35cf4d 100644 --- a/front/src/components/boxs/chart/Chart.jsx +++ b/front/src/components/boxs/chart/Chart.jsx @@ -8,6 +8,7 @@ import { WEBSOCKET_MESSAGE_TYPES, DEVICE_FEATURE_UNITS } from '../../../../../se import get from 'get-value'; import withIntlAsProp from '../../../utils/withIntlAsProp'; import ApexChartComponent from './ApexChartComponent'; +import { getDeviceName } from '../../../utils/device'; const ONE_HOUR_IN_MINUTES = 60; const ONE_DAY_IN_MINUTES = 24 * 60; @@ -115,6 +116,9 @@ class Chartbox extends Component { }; getData = async () => { let deviceFeatures = this.props.box.device_features; + let deviceFeatureNames = this.props.box.device_feature_names; + let nbFeaturesDisplayed = deviceFeatures.length; + if (!deviceFeatures) { // migrate all box (one device feature) if (this.props.box.device_feature) { @@ -133,40 +137,82 @@ class Chartbox extends Component { } await this.setState({ loading: true }); try { + const maxStates = 300; const data = await this.props.httpClient.get(`/api/v1/device_feature/aggregated_states`, { interval: this.state.interval, - max_states: 100, + max_states: maxStates, device_features: deviceFeatures.join(',') }); let emptySeries = true; - const series = data.map((oneFeature, index) => { - const oneUnit = this.props.box.units ? this.props.box.units[index] : this.props.box.unit; - const oneUnitTranslated = oneUnit ? this.props.intl.dictionary.deviceFeatureUnitShort[oneUnit] : null; - const { values, deviceFeature } = oneFeature; - const deviceName = deviceFeature.name; - const name = oneUnitTranslated ? `${deviceName} (${oneUnitTranslated})` : deviceName; - return { - name, - data: values.map(value => { - emptySeries = false; - return { - x: value.created_at, - y: value.value - }; - }) + let series = []; + + if (this.props.box.chart_type === 'timeline') { + const serie0 = { + name: get(this.props.intl.dictionary, 'dashboard.boxes.chart.off'), + data: [] }; - }); + const serie1 = { + name: get(this.props.intl.dictionary, 'dashboard.boxes.chart.on'), + data: [] + }; + const now = new Date(); + const lastValueTime = Math.round(now.getTime() / 1000) * 1000; + data.forEach((oneFeature, index) => { + const { values, deviceFeature, device } = oneFeature; + const deviceFeatureName = deviceFeatureNames + ? deviceFeatureNames[index] + : getDeviceName(device, deviceFeature); + if (values.length === 0) { + nbFeaturesDisplayed = nbFeaturesDisplayed - 1; + } else { + values.forEach(value => { + emptySeries = false; + const beginTime = Math.round(new Date(value.created_at).getTime() / 1000) * 1000; + const endTime = value.end_time + ? Math.round(new Date(value.end_time).getTime() / 1000) * 1000 + : lastValueTime; + const newData = { + x: deviceFeatureName, + y: [beginTime, endTime] + }; + if (value.value === 0) { + serie0.data.push(newData); + } else { + serie1.data.push(newData); + } + }); + } + }); + series.push(serie1); + series.push(serie0); + } else { + series = data.map((oneFeature, index) => { + const oneUnit = this.props.box.units ? this.props.box.units[index] : this.props.box.unit; + const oneUnitTranslated = oneUnit ? this.props.intl.dictionary.deviceFeatureUnitShort[oneUnit] : null; + const { values, deviceFeature } = oneFeature; + const deviceName = deviceFeature.name; + const name = oneUnitTranslated ? `${deviceName} (${oneUnitTranslated})` : deviceName; + return { + name, + data: values.map(value => { + emptySeries = false; + return [Math.round(new Date(value.created_at).getTime() / 1000) * 1000, value.value]; + }) + }; + }); + } const newState = { series, loading: false, initialized: true, - emptySeries + emptySeries, + nbFeaturesDisplayed }; - if (data.length > 0) { + if (data.length > 0 && this.props.box.chart_type !== 'timeline') { // Before now, there was a "unit" attribute in this box instead of "units", // so we need to support "unit" as some users may already have the box with that param const unit = this.props.box.units ? this.props.box.units[0] : this.props.box.unit; @@ -208,7 +254,6 @@ class Chartbox extends Component { } } } - await this.setState(newState); } catch (e) { console.error(e); @@ -235,7 +280,8 @@ class Chartbox extends Component { interval: this.props.box.interval ? intervalByName[this.props.box.interval] : ONE_HOUR_IN_MINUTES, loading: true, initialized: false, - height: 'small' + height: 'small', + nbFeaturesDisplayed: 0 }; } componentDidMount() { @@ -276,173 +322,195 @@ class Chartbox extends Component { lastValueRounded, interval, emptySeries, - unit + unit, + nbFeaturesDisplayed } ) { - const displayVariation = props.box.display_variation; + const { box } = this.props; + const displayVariation = box.display_variation; + let additionalHeight = 0; + if (props.box.chart_type === 'timeline') { + additionalHeight = 55 * nbFeaturesDisplayed; + } return (
{props.box.title}
- - {displayVariation && emptySeries === false && ( -
- {notNullNotUndefined(lastValueRounded) && !Number.isNaN(lastValueRounded) && ( -
- {lastValueRounded} - {unit !== undefined && } -
- )} -
0 && !variationDownIsPositive) || (variation < 0 && variationDownIsPositive), - [style.textYellow]: variation === 0, - [style.textRed]: - (variation > 0 && variationDownIsPositive) || (variation < 0 && !variationDownIsPositive) - })} - > - {variation !== undefined && ( - - {roundWith2DecimalIfNeeded(variation)} - - {variation > 0 && ( - + + + + + + {props.box.chart_type !== 'timeline' && ( + - - - - + + )} - {variation === 0 && ( - - - - + + )} - {variation < 0 && ( - - - - - + + )} - - )} -
+ {props.box.chart_type !== 'timeline' && ( + + + + )} +
+
+ )}
- )} - {emptySeries === false && props.box.display_axes && ( -
- +
+ + {props.box.chart_type && ( +
+ {displayVariation && props.box.chart_type !== 'timeline' && emptySeries === false && ( +
+ {notNullNotUndefined(lastValueRounded) && !Number.isNaN(lastValueRounded) && ( +
+ {lastValueRounded} + {unit !== undefined && } +
+ )} +
0 && !variationDownIsPositive) || (variation < 0 && variationDownIsPositive), + [style.textYellow]: variation === 0, + [style.textRed]: + (variation > 0 && variationDownIsPositive) || (variation < 0 && !variationDownIsPositive) + })} + > + {variation !== undefined && ( + + {roundWith2DecimalIfNeeded(variation)} + + {variation > 0 && ( + + + + + + )} + {variation === 0 && ( + + + + + )} + {variation < 0 && ( + + + + + + )} + + )} +
+
+ )} + {emptySeries === false && props.box.display_axes && ( +
+ +
+ )}
)}
@@ -458,28 +526,46 @@ class Chartbox extends Component { [style.minSizeChartLoading]: loading && !initialized })} > - {emptySeries === true && ( + {!props.box.chart_type && (
- +
- +
)} - {emptySeries === false && !props.box.display_axes && ( - + {props.box.chart_type && ( +
+ {emptySeries === true && ( +
+
+
+ + +
+
+ +
+
+ )} + {emptySeries === false && !props.box.display_axes && ( + + )} +
)}
diff --git a/front/src/components/boxs/chart/DeviceListWithDragAndDrop.jsx b/front/src/components/boxs/chart/DeviceListWithDragAndDrop.jsx new file mode 100644 index 0000000000..5aba7997d7 --- /dev/null +++ b/front/src/components/boxs/chart/DeviceListWithDragAndDrop.jsx @@ -0,0 +1,97 @@ +import { DndProvider, useDrag, useDrop } from 'react-dnd'; +import { TouchBackend } from 'react-dnd-touch-backend'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { useRef } from 'preact/hooks'; +import cx from 'classnames'; +import style from './style.css'; + +const DEVICE_TYPE = 'DEVICE_TYPE'; + +const DeviceRow = ({ selectedDeviceFeature, moveDevice, index, removeDevice, updateDeviceFeatureName }) => { + const ref = useRef(null); + const [{ isDragging }, drag, preview] = useDrag(() => ({ + type: DEVICE_TYPE, + item: () => { + return { index }; + }, + collect: monitor => ({ + isDragging: !!monitor.isDragging() + }) + })); + const [{ isActive }, drop] = useDrop({ + accept: DEVICE_TYPE, + collect: monitor => ({ + isActive: monitor.canDrop() && monitor.isOver() + }), + drop(item) { + if (!ref.current) { + return; + } + moveDevice(item.index, index); + } + }); + preview(drop(ref)); + const removeThisDevice = () => { + removeDevice(index); + }; + + const updateThisDeviceFeatureName = e => { + updateDeviceFeatureName(index, e.target.value); + }; + + return ( +
+ +
+
+ +
+ +
+ +
+
+
+ ); +}; + +const DeviceListWithDragAndDrop = ({ + selectedDeviceFeaturesOptions, + isTouchDevice, + moveDevice, + removeDevice, + updateDeviceFeatureName +}) => ( + + {selectedDeviceFeaturesOptions.map((selectedDeviceFeature, index) => ( + + ))} + +); + +export { DeviceListWithDragAndDrop }; diff --git a/front/src/components/boxs/chart/EditChart.jsx b/front/src/components/boxs/chart/EditChart.jsx index d0877178b7..d40c59f3cd 100644 --- a/front/src/components/boxs/chart/EditChart.jsx +++ b/front/src/components/boxs/chart/EditChart.jsx @@ -2,22 +2,31 @@ import { Component } from 'preact'; import { Localizer, Text } from 'preact-i18n'; import { connect } from 'unistore/preact'; import Select from 'react-select'; +import update from 'immutability-helper'; import get from 'get-value'; import BaseEditBox from '../baseEditBox'; import Chart from './Chart'; import { getDeviceFeatureName } from '../../../utils/device'; +import { DeviceListWithDragAndDrop } from './DeviceListWithDragAndDrop'; import { DEVICE_FEATURE_TYPES } from '../../../../../server/utils/constants'; import withIntlAsProp from '../../../utils/withIntlAsProp'; import { DEFAULT_COLORS, DEFAULT_COLORS_NAME } from './ApexChartComponent'; const FEATURES_THAT_ARE_NOT_COMPATIBLE = { - [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: true, - [DEVICE_FEATURE_TYPES.SENSOR.PUSH]: true, [DEVICE_FEATURE_TYPES.LIGHT.COLOR]: true, [DEVICE_FEATURE_TYPES.CAMERA.IMAGE]: true }; +const FEATURE_BINARY = { + [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: true, + [DEVICE_FEATURE_TYPES.SENSOR.PUSH]: true +}; + +const CHART_TYPE_OTHERS = ['line', 'stepline', 'area', 'bar']; + +const CHART_TYPE_BINARY = ['timeline']; + const square = (color = 'transparent') => ({ alignItems: 'center', display: 'flex', @@ -74,6 +83,8 @@ class EditChart extends Component { } else { this.props.updateBoxConfig(this.props.x, this.props.y, { chart_type: undefined }); } + this.setState({ chart_type: e.target.value }); + this.getDeviceFeatures(e.target.value); }; updateChartColor = (i, value) => { @@ -109,7 +120,46 @@ class EditChart extends Component { this.props.updateBoxConfig(this.props.x, this.props.y, { title: e.target.value }); }; - updateDeviceFeatures = selectedDeviceFeaturesOptions => { + addDeviceFeature = async selectedDeviceFeatureOption => { + const newSelectedDeviceFeaturesOptions = [...this.state.selectedDeviceFeaturesOptions, selectedDeviceFeatureOption]; + await this.setState({ selectedDeviceFeaturesOptions: newSelectedDeviceFeaturesOptions }); + this.refreshDeviceUnitAndChartType(newSelectedDeviceFeaturesOptions); + this.refreshDeviceFeaturesNames(); + }; + + refreshDeviceFeaturesNames = () => { + const newDeviceFeatureNames = this.state.selectedDeviceFeaturesOptions.map(o => { + return o.new_label !== undefined ? o.new_label : o.label; + }); + + const newDeviceFeature = this.state.selectedDeviceFeaturesOptions.map(o => { + return o.value; + }); + this.props.updateBoxConfig(this.props.x, this.props.y, { + device_feature_names: newDeviceFeatureNames, + device_features: newDeviceFeature + }); + }; + + refreshChartTypeList = (firstDeviceSelector = null) => { + let chartTypeList = []; + + if (!firstDeviceSelector) { + chartTypeList = [...CHART_TYPE_BINARY, ...CHART_TYPE_OTHERS]; + } else if (FEATURE_BINARY[firstDeviceSelector.type]) { + chartTypeList = CHART_TYPE_BINARY; + } else { + chartTypeList = CHART_TYPE_OTHERS; + } + this.setState({ chartTypeList }); + }; + + refreshDeviceUnitAndChartType = selectedDeviceFeaturesOptions => { + const firstDeviceSelector = + selectedDeviceFeaturesOptions.length > 0 + ? this.deviceFeatureBySelector.get(selectedDeviceFeaturesOptions[0].value) + : null; + if (selectedDeviceFeaturesOptions && selectedDeviceFeaturesOptions.length > 0) { const deviceFeaturesSelectors = selectedDeviceFeaturesOptions.map( selectedDeviceFeaturesOption => selectedDeviceFeaturesOption.value @@ -127,61 +177,187 @@ class EditChart extends Component { this.props.updateBoxConfig(this.props.x, this.props.y, { device_features: [], units: [], - unit: undefined + unit: undefined, + chart_type: '' }); + this.setState({ chart_type: '' }); } + this.refreshChartTypeList(firstDeviceSelector); this.setState({ selectedDeviceFeaturesOptions }); }; - getDeviceFeatures = async () => { - try { - this.setState({ loading: true }); - const devices = await this.props.httpClient.get('/api/v1/device'); - const deviceOptions = []; - const selectedDeviceFeaturesOptions = []; - - devices.forEach(device => { - const deviceFeaturesOptions = []; - device.features.forEach(feature => { - const featureOption = { - value: feature.selector, - label: getDeviceFeatureName(this.props.intl.dictionary, device, feature) - }; - this.deviceFeatureBySelector.set(feature.selector, feature); - // We don't support all devices for this view - if (!FEATURES_THAT_ARE_NOT_COMPATIBLE[feature.type]) { + refreshDisplayForNewProps = async () => { + if (!this.state.devices) { + return; + } + if (!this.props.box || !this.props.box.device_features) { + return; + } + if (!this.state.deviceOptions) { + return; + } + const { deviceOptions, selectedDeviceFeaturesOptions } = this.getSelectedDeviceFeaturesAndOptions( + this.state.devices + ); + await this.setState({ deviceOptions, selectedDeviceFeaturesOptions }); + }; + + updateDeviceFeatureName = async (index, name) => { + const newState = update(this.state, { + selectedDeviceFeaturesOptions: { + [index]: { + new_label: { + $set: name + } + } + } + }); + await this.setState(newState); + this.refreshDeviceFeaturesNames(); + }; + + getSelectedDeviceFeaturesAndOptions = (devices, chartType = this.state.chart_type) => { + const deviceOptions = []; + let selectedDeviceFeaturesOptions = []; + + devices.forEach(device => { + const deviceFeaturesOptions = []; + device.features.forEach(feature => { + const featureOption = { + value: feature.selector, + label: getDeviceFeatureName(this.props.intl.dictionary, device, feature) + }; + this.deviceFeatureBySelector.set(feature.selector, feature); + // We don't support all devices for this view + if (!FEATURES_THAT_ARE_NOT_COMPATIBLE[feature.type]) { + if (chartType.includes(CHART_TYPE_BINARY)) { + if (FEATURE_BINARY[feature.type]) { + deviceFeaturesOptions.push(featureOption); + } + } else if (chartType === '') { + deviceFeaturesOptions.push(featureOption); + } else if (!FEATURE_BINARY[feature.type]) { deviceFeaturesOptions.push(featureOption); } - if (this.props.box.device_features && this.props.box.device_features.indexOf(feature.selector) !== -1) { + } + // If the feature is already selected + if (this.props.box.device_features && this.props.box.device_features.indexOf(feature.selector) !== -1) { + const featureIndex = this.props.box.device_features.indexOf(feature.selector); + if (this.props.box.device_features && featureIndex !== -1) { + // and there is a name associated to it + if (this.props.box.device_feature_names && this.props.box.device_feature_names[featureIndex]) { + // We set the new_label in the object + featureOption.new_label = this.props.box.device_feature_names[featureIndex]; + } + // And we push this to the list of selected feature selectedDeviceFeaturesOptions.push(featureOption); } + } + }); + if (deviceFeaturesOptions.length > 0) { + deviceFeaturesOptions.sort((a, b) => { + if (a.label < b.label) { + return -1; + } else if (a.label > b.label) { + return 1; + } + return 0; }); - if (deviceFeaturesOptions.length > 0) { - deviceFeaturesOptions.sort((a, b) => { - if (a.label < b.label) { - return -1; - } else if (a.label > b.label) { - return 1; - } - return 0; - }); + const filteredDeviceFeatures = deviceFeaturesOptions.filter( + feature => !selectedDeviceFeaturesOptions.some(selected => selected.value === feature.value) + ); + if (filteredDeviceFeatures.length > 0) { deviceOptions.push({ label: device.name, - options: deviceFeaturesOptions + options: filteredDeviceFeatures }); } - }); - await this.setState({ deviceOptions, selectedDeviceFeaturesOptions, loading: false }); + } + }); + + // Filter the device options based on the chart type + if (selectedDeviceFeaturesOptions.length > 0) { + const firstDeviceSelector = this.deviceFeatureBySelector.get(selectedDeviceFeaturesOptions[0].value); + this.refreshChartTypeList(firstDeviceSelector); + if (FEATURE_BINARY[firstDeviceSelector.type]) { + deviceOptions.forEach(deviceOption => { + deviceOption.options = deviceOption.options.filter(featureOption => { + return FEATURE_BINARY[this.deviceFeatureBySelector.get(featureOption.value).type]; + }); + }); + } else { + deviceOptions.forEach(deviceOption => { + deviceOption.options = deviceOption.options.filter(featureOption => { + return !FEATURE_BINARY[this.deviceFeatureBySelector.get(featureOption.value).type]; + }); + }); + } + } + if (this.props.box.device_features) { + selectedDeviceFeaturesOptions = selectedDeviceFeaturesOptions.sort( + (a, b) => this.props.box.device_features.indexOf(a.value) - this.props.box.device_features.indexOf(b.value) + ); + } + return { deviceOptions, selectedDeviceFeaturesOptions }; + }; + + getDeviceFeatures = async (chartType = this.state.chart_type) => { + try { + this.setState({ loading: true }); + // we get the rooms with the devices + const devices = await this.props.httpClient.get(`/api/v1/device`); + const { deviceOptions, selectedDeviceFeaturesOptions } = this.getSelectedDeviceFeaturesAndOptions( + devices, + chartType + ); + await this.setState({ devices, deviceOptions, selectedDeviceFeaturesOptions, loading: false }); + this.refreshDeviceFeaturesNames(); } catch (e) { console.error(e); this.setState({ loading: false }); } }; + moveDevice = async (currentIndex, newIndex) => { + const element = this.state.selectedDeviceFeaturesOptions[currentIndex]; + + const newStateWithoutElement = update(this.state, { + selectedDeviceFeaturesOptions: { + $splice: [[currentIndex, 1]] + } + }); + const newState = update(newStateWithoutElement, { + selectedDeviceFeaturesOptions: { + $splice: [[newIndex, 0, element]] + } + }); + await this.setState(newState); + this.refreshDeviceFeaturesNames(); + }; + + removeDevice = async index => { + const newStateWithoutElement = update(this.state, { + selectedDeviceFeaturesOptions: { + $splice: [[index, 1]] + } + }); + await this.setState(newStateWithoutElement); + this.refreshDeviceFeaturesNames(); + this.refreshDeviceUnitAndChartType(this.state.selectedDeviceFeaturesOptions); + }; + constructor(props) { super(props); this.props = props; this.deviceFeatureBySelector = new Map(); + this.state = { + chart_type: '', + selectedDeviceFeaturesOptions: [], + deviceOptions: [], + loading: false, + displayPreview: false, + chartTypeList: [...CHART_TYPE_BINARY, ...CHART_TYPE_OTHERS] + }; } componentDidMount() { @@ -189,14 +365,14 @@ class EditChart extends Component { } componentDidUpdate(previousProps) { - const deviceFeatureChanged = get(previousProps, 'box.device_feature') !== get(this.props, 'box.device_feature'); + const deviceFeatureChanged = get(previousProps, 'box.device_features') !== get(this.props, 'box.device_features'); const unitsChanged = get(previousProps, 'box.units') !== get(this.props, 'box.units'); if (deviceFeatureChanged || unitsChanged) { - this.getDeviceFeatures(); + this.refreshDisplayForNewProps(); } } - render(props, { selectedDeviceFeaturesOptions, deviceOptions, loading, displayPreview }) { + render(props, { selectedDeviceFeaturesOptions, deviceOptions, loading, displayPreview, chartTypeList }) { const manyFeatures = selectedDeviceFeaturesOptions && selectedDeviceFeaturesOptions.length > 1; const colorOptions = DEFAULT_COLORS.map((colorValue, i) => ({ value: colorValue, @@ -207,59 +383,61 @@ class EditChart extends Component {
- {deviceOptions && ( -
- - } + value={props.box.title} + onInput={this.updateBoxTitle} /> -
- )} + +
{deviceOptions && (
- - } - value={props.box.title} - onChange={this.updateBoxTitle} - /> - + - - - - - + {chartTypeList && + chartTypeList.map(chartType => ( + + ))}
- {selectedDeviceFeaturesOptions && + + {props.box.chart_type !== 'timeline' && + selectedDeviceFeaturesOptions && selectedDeviceFeaturesOptions.map((feature, i) => (
))} + {props.box.chart_type === 'timeline' && ( + <> +
+ + value === DEFAULT_COLORS[1])} + value={ + props.box.colors && + props.box.colors.length && + colorOptions.find(({ value }) => value === props.box.colors[1]) + } + onChange={({ value }) => this.updateChartColor(1, value)} + options={colorOptions} + styles={colorSelectorStyles} + /> +
+ + )}
-
- - -
+ {props.box.chart_type !== 'timeline' && ( +
+ + +
+ )}
diff --git a/front/src/components/boxs/chart/style.css b/front/src/components/boxs/chart/style.css index 7070bacbf7..c6142c85a7 100644 --- a/front/src/components/boxs/chart/style.css +++ b/front/src/components/boxs/chart/style.css @@ -134,3 +134,19 @@ padding-right: 1.5rem; text-align: justify; } +.deviceListDragAndDrop { + cursor: 'pointer'; + user-select: 'none'; +} + +.deviceListDragAndDropDragging { + opacity: 0.5; +} + +.deviceListDragAndDropActive { + background-color: #ecf0f1; +} + +.deviceListRemoveButton { + z-index: 0; +} \ No newline at end of file diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 68d9356044..14bddbc4ae 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -354,6 +354,8 @@ "lastYear": "Letztes Jahr", "noValue": "Keine Werte in diesem Intervall aufgezeichnet.", "noValueWarning": "Warnung: Wenn du dieses Gerät gerade konfiguriert hast, kann es einige Zeit dauern, bis hier etwas angezeigt wird: Gladys benötigt etwas Zeit, um genügend Daten zu sammeln. Bei Intervallen über 24 Stunden kann es bis zu 24 Stunden dauern, bis hier etwas angezeigt wird.", + "noChartType": "Kein Diagrammtyp ausgewählt", + "noChartTypeWarning": "Wenn du dieses Gerät gerade konfiguriert hast, bitte gehe zurück zur Seite der Box-Bearbeitung und wähle einen Diagrammtyp aus.", "editNameLabel": "Gib den Namen dieser Box ein", "editDeviceFeaturesLabel": "Wähle das Gerät aus, das hier angezeigt werden soll", "editRoomLabel": "Wähle den Raum aus, der hier angezeigt werden soll", @@ -361,16 +363,22 @@ "chartType": "Wähle den Diagrammtyp aus, der angezeigt werden soll", "chartColor": "Wählen Sie die Farbe des Diagramms aus", "dataColor": "Wählen Sie die Farbe für {{featureLabel}}", + "timelineColor": "Farbe für Position auswählen", "line": "Linie", "area": "Fläche", "bar": "Balken", + "timeline": "Binär", "stepline": "Schrittleitung", "displayAxes": "Achsen anzeigen?", "displayVariation": "Variation anzeigen?", "yes": "Ja", "no": "Nein", "preview": "Vorschau", - "showPreviewButton": "Vorschau anzeigen" + "showPreviewButton": "Vorschau anzeigen", + "on": "An", + "off": "Aus", + "start_date": "Startdatum: ", + "end_date": "Enddatum: " }, "ecowatt": { "title": "Ecowatt France", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index ff686d7a31..3e6c967fa8 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -354,6 +354,8 @@ "lastYear": "Last year", "noValue": "No values recorded on this interval.", "noValueWarning": "Warning: if you just configured this device, it may take some time before you see something here as Gladys needs some time to collect enough data. For interval superior to 24h, it may take up to 24h before you see something here.", + "noChartType": "No chart type selected", + "noChartTypeWarning": "If you just configured this device, please go back to the box edit page and select a chart type.", "editNameLabel": "Enter the name of this box", "editDeviceFeaturesLabel": "Select the device you want to display here", "editRoomLabel": "Select the room you want to display here", @@ -361,16 +363,22 @@ "chartType": "Select the type of chart to display", "chartColor": "Select the color of chart", "dataColor": "Select color for {{featureLabel}}", + "timelineColor": "Select color for position", "line": "Line", "area": "Area", "bar": "Bar", + "timeline": "Binary", "stepline": "Step Line", "displayAxes": "Display axes?", "displayVariation": "Display variation?", "yes": "Yes", "no": "No", "preview": "Preview", - "showPreviewButton": "Show preview" + "showPreviewButton": "Show preview", + "on": "On", + "off": "Off", + "start_date": "Start date: ", + "end_date": "End date: " }, "ecowatt": { "title": "Ecowatt France", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 5dac1a0ae8..872b3b67cd 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -354,6 +354,8 @@ "lastYear": "Dernière année", "noValue": "Pas de valeurs sur cet intervalle.", "noValueWarning": "Attention, si vous venez de configurer cet appareil, les données peuvent mettre un certain temps avant d'être agrégées. Pour les intervalles supérieurs à 24h, cela prend jusqu'à 24h le temps que Gladys collecte assez de données.", + "noChartType": "Aucun type de graphique sélectionné", + "noChartTypeWarning": "Si vous venez de configurer cet appareil, veuillez retourner sur la page d'édition de la box et sélectionner un type de graphique.", "editNameLabel": "Entrez le nom de cette box", "editNamePlaceholder": "Nom affiché sur le tableau de bord", "editDeviceFeaturesLabel": "Sélectionnez les appareils que vous voulez afficher", @@ -361,16 +363,22 @@ "chartType": "Sélectionner le type de graphique", "chartColor": "Sélectionner la couleur du graphique", "dataColor": "Sélectionner la couleur pour {{featureLabel}}", + "timelineColor": "Sélectionner la couleur pour la position ", "line": "Ligne", "area": "Aire", "bar": "Histogramme", + "timeline": "Binaire", "stepline": "Ligne droite", "displayAxes": "Afficher les axes ?", "displayVariation": "Afficher la variation ?", "yes": "Oui", "no": "Non", "preview": "Prévisualisation", - "showPreviewButton": "Afficher une prévisualisation" + "showPreviewButton": "Afficher une prévisualisation", + "on": "On", + "off": "Off", + "start_date": "Début le : ", + "end_date": "Fin le : " }, "ecowatt": { "title": "Ecowatt France", diff --git a/server/lib/device/device.getDeviceFeaturesAggregates.js b/server/lib/device/device.getDeviceFeaturesAggregates.js index fc6d6e0ac7..d5cd468869 100644 --- a/server/lib/device/device.getDeviceFeaturesAggregates.js +++ b/server/lib/device/device.getDeviceFeaturesAggregates.js @@ -1,6 +1,78 @@ const db = require('../../models'); const { NotFoundError } = require('../../utils/coreErrors'); +const NON_BINARY_QUERY = ` + WITH intervals AS ( + SELECT + created_at, + value, + NTILE(?) OVER (ORDER BY created_at) AS interval + FROM + t_device_feature_state + WHERE device_feature_id = ? + AND created_at > ? + ) + SELECT + MIN(created_at) AS created_at, + AVG(value) AS value + FROM + intervals + GROUP BY + interval + ORDER BY + created_at; +`; + +const BINARY_QUERY = ` + WITH value_changes AS ( + SELECT + created_at, + value, + LAG(value) OVER (ORDER BY created_at) AS prev_value + FROM + t_device_feature_state + WHERE + device_feature_id = ? + AND created_at > ? + ORDER BY + created_at DESC + ), + grouped_changes AS ( + SELECT + created_at, + value, + CASE + WHEN value != LAG(value) OVER (ORDER BY created_at) THEN created_at + ELSE NULL + END AS change_marker + FROM + value_changes + ORDER BY + created_at DESC + ), + final_grouping AS ( + SELECT + created_at AS start_time, + LEAD(created_at) OVER (ORDER BY created_at) AS end_time, + value + FROM + grouped_changes + WHERE + change_marker IS NOT NULL + ORDER BY + created_at DESC + LIMIT ? + ) + SELECT + value, + start_time AS created_at, + end_time + FROM + final_grouping + ORDER BY + created_at ASC +`; + /** * @description Get all features states aggregates. * @param {string} selector - Device selector. @@ -17,35 +89,18 @@ async function getDeviceFeaturesAggregates(selector, intervalInMinutes, maxState } const device = this.stateManager.get('deviceById', deviceFeature.device_id); + const isBinary = ['binary', 'push'].includes(deviceFeature.type); + const now = new Date(); const intervalDate = new Date(now.getTime() - intervalInMinutes * 60 * 1000); - const values = await db.duckDbReadConnectionAllAsync( - ` - WITH intervals AS ( - SELECT - created_at, - value, - NTILE(?) OVER (ORDER BY created_at) AS interval - FROM - t_device_feature_state - WHERE device_feature_id = ? - AND created_at > ? - ) - SELECT - MIN(created_at) AS created_at, - AVG(value) AS value - FROM - intervals - GROUP BY - interval - ORDER BY - created_at; - `, - maxStates, - deviceFeature.id, - intervalDate, - ); + let values; + + if (isBinary) { + values = await db.duckDbReadConnectionAllAsync(BINARY_QUERY, deviceFeature.id, intervalDate, maxStates); + } else { + values = await db.duckDbReadConnectionAllAsync(NON_BINARY_QUERY, maxStates, deviceFeature.id, intervalDate); + } return { device: { diff --git a/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js b/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js index 5802351d96..917167d872 100644 --- a/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js +++ b/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js @@ -24,7 +24,43 @@ const insertStates = async (intervalInMinutes) => { await db.duckDbBatchInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', deviceFeatureStateToInsert); }; -describe('Device.getDeviceFeaturesAggregates', function Describe() { +const insertBinaryStates = async (intervalInMinutes) => { + const deviceFeatureStateToInsert = []; + const now = new Date(); + const statesToInsert = 2000; + for (let i = 0; i < statesToInsert; i += 1) { + const startAt = new Date(now.getTime() - intervalInMinutes * 60 * 1000); + const date = new Date(startAt.getTime() + ((intervalInMinutes * 60 * 1000) / statesToInsert) * i); + deviceFeatureStateToInsert.push({ + value: i % 2, // Alternating binary values + created_at: date, + }); + } + await db.duckDbBatchInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e5', deviceFeatureStateToInsert); +}; + +const insertBinaryStatesWithChanges = async (intervalInMinutes) => { + const deviceFeatureStateToInsert = []; + const now = new Date(); + const statesToInsert = 3000; + let currentValue = Math.round(Math.random()); // Start with a random binary value (0 or 1) + for (let i = 0; i < statesToInsert; i += 1) { + const startAt = new Date(now.getTime() - intervalInMinutes * 60 * 1000); + const date = new Date(startAt.getTime() + ((intervalInMinutes * 60 * 1000) / statesToInsert) * i); + deviceFeatureStateToInsert.push({ + value: currentValue, + created_at: date, + }); + // Randomly decide whether to change the value or keep it the same + if (Math.random() > 0.7) { + // 30% chance to change the value + currentValue = currentValue === 0 ? 1 : 0; + } + } + await db.duckDbBatchInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e6', deviceFeatureStateToInsert); +}; + +describe('Device.getDeviceFeaturesAggregates non binary feature', function Describe() { this.timeout(15000); let clock; @@ -131,3 +167,89 @@ describe('Device.getDeviceFeaturesAggregates', function Describe() { return assert.isRejected(promise, 'DeviceFeature not found'); }); }); + +describe('Device.getDeviceFeaturesAggregates binary feature', function Describe() { + this.timeout(15000); + + let clock; + beforeEach(async () => { + await db.duckDbWriteConnectionAllAsync('DELETE FROM t_device_feature_state'); + + clock = sinon.useFakeTimers({ + now: 1635131280000, + }); + }); + afterEach(() => { + clock.restore(); + }); + + it('should return last hour states for binary feature', async () => { + await insertBinaryStates(120); + const variable = { + getValue: fake.resolves(null), + }; + const stateManager = { + get: fake.returns({ + id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e5', + name: 'binary-feature', + type: 'binary', + }), + }; + const deviceInstance = new Device(event, {}, stateManager, {}, {}, variable, job); + const { values, device, deviceFeature } = await deviceInstance.getDeviceFeaturesAggregates( + 'binary-feature', + 60, + 100, + ); + expect(values).to.have.lengthOf(100); + expect(device).to.have.property('name'); + expect(deviceFeature).to.have.property('name'); + }); + + it('should return last day states for binary feature', async () => { + await insertBinaryStates(48 * 60); + const variable = { + getValue: fake.resolves(null), + }; + const stateManager = { + get: fake.returns({ + id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e5', + name: 'binary-feature', + type: 'binary', + }), + }; + const device = new Device(event, {}, stateManager, {}, {}, variable, job); + const { values } = await device.getDeviceFeaturesAggregates('binary-feature', 24 * 60, 100); + expect(values).to.have.lengthOf(100); + }); + + it('should return last 300 state changes for binary feature', async () => { + await insertBinaryStatesWithChanges(48 * 60); + const variable = { + getValue: fake.resolves(null), + }; + const stateManager = { + get: fake.returns({ + id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e6', + name: 'binary-feature', + type: 'binary', + }), + }; + const deviceInstance = new Device(event, {}, stateManager, {}, {}, variable, job); + const { values, device, deviceFeature } = await deviceInstance.getDeviceFeaturesAggregates( + 'binary-feature', + 24 * 60, + 300, + ); + expect(values).to.have.lengthOf(300); + expect(device).to.have.property('name'); + expect(deviceFeature).to.have.property('name'); + // Check that the values are state changes + for (let i = 1; i < values.length; i += 1) { + expect(values[i].value).to.not.equal(values[i - 1].value); + } + for (let i = 1; i < values.length; i += 1) { + expect(values[i].value).to.not.equal(values[i - 1].value); + } + }); +});