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 `
`; + }; + + 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 (