diff --git a/src/components/Chart/Chart.react.js b/src/components/Chart/Chart.react.js index 1d2861b061..29b677ebe0 100644 --- a/src/components/Chart/Chart.react.js +++ b/src/components/Chart/Chart.react.js @@ -59,7 +59,7 @@ export default class Chart extends React.Component { } render() { - const { width, height, data } = this.props; + const { width, height, data, xAxisType = 'time', hideXAxisLabels = false } = this.props; const plotting = {}; let minX = Infinity; let maxX = -Infinity; @@ -83,7 +83,12 @@ export default class Chart extends React.Component { } plotting[key] = { data: ordered, index: data[key].index }; } - const timeBuckets = Charting.timeAxisBuckets(minX, maxX); + let timeBuckets; + if (xAxisType === 'index') { + timeBuckets = Charting.numericAxisBuckets(minX, maxX); + } else { + timeBuckets = Charting.timeAxisBuckets(minX, maxX); + } const valueBuckets = Charting.valueAxisBuckets(maxY || 10); const groups = []; for (const key in plotting) { @@ -134,23 +139,28 @@ export default class Chart extends React.Component { t => (chartWidth * (t - timeBuckets[0])) / (timeBuckets[timeBuckets.length - 1] - timeBuckets[0]) ); - let last = null; - const tickLabels = timeBuckets.map((t, i) => { - let text = ''; - if (timeBuckets.length > 20 && i % 2 === 0) { - return ''; - } - if (!last || t.getMonth() !== last.getMonth()) { - text += shortMonth(t.getMonth()) + ' '; - } - if (!last || t.getDate() !== last.getDate()) { - text += t.getDate(); - } else if (last && t.getHours() !== last.getHours()) { - text += t.getHours() + ':00'; - } - last = t; - return text; - }); + let tickLabels; + if (xAxisType === 'index') { + tickLabels = timeBuckets.map(t => t); + } else { + let last = null; + tickLabels = timeBuckets.map((t, i) => { + let text = ''; + if (timeBuckets.length > 20 && i % 2 === 0) { + return ''; + } + if (!last || t.getMonth() !== last.getMonth()) { + text += shortMonth(t.getMonth()) + ' '; + } + if (!last || t.getDate() !== last.getDate()) { + text += t.getDate(); + } else if (last && t.getHours() !== last.getHours()) { + text += t.getHours() + ':00'; + } + last = t; + return text; + }); + } let popup = null; if (this.state.hoverValue !== null) { const style = { @@ -191,17 +201,19 @@ export default class Chart extends React.Component { ))} -
- {tickLabels.map((t, i) => ( -
- {t} -
- ))} -
+ {!hideXAxisLabels && ( +
+ {tickLabels.map((t, i) => ( +
+ {t} +
+ ))} +
+ )} {labelHeights.map(h => ( @@ -245,4 +257,6 @@ Chart.propTypes = { 'It receives the numeric value of a point and label, and should return a string. ' + 'This is ideally used for providing descriptive units like "active installations."' ), + xAxisType: PropTypes.string.describe('Axis type: "time" or "index"'), + hideXAxisLabels: PropTypes.bool.describe('Hide labels on the x-axis'), }; diff --git a/src/components/GraphPanel/GraphPanel.js b/src/components/GraphPanel/GraphPanel.js new file mode 100644 index 0000000000..07c9b5f508 --- /dev/null +++ b/src/components/GraphPanel/GraphPanel.js @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from 'react'; +import Chart from 'components/Chart/Chart.react'; +import { ChartColorSchemes } from 'lib/Constants'; +import styles from './GraphPanel.scss'; + +function parseDate(val) { + if (!val) { + return null; + } + if (val instanceof Date) { + return val.getTime(); + } + if (typeof val === 'string') { + const d = new Date(val); + return isNaN(d) ? null : d.getTime(); + } + if (val.iso) { + const d = new Date(val.iso); + return isNaN(d) ? null : d.getTime(); + } + return null; +} + +export default function GraphPanel({ selectedCells, order, data, columns, width }) { + if (!selectedCells || selectedCells.rowStart < 0) { + return null; + } + const { rowStart, rowEnd, colStart, colEnd } = selectedCells; + const columnNames = order.slice(colStart, colEnd + 1).map(o => o.name); + const columnTypes = columnNames.map(name => columns[name]?.type); + + const isSingleColumn = columnNames.length === 1; + + const initialUseXAxis = + !isSingleColumn && + (columnTypes[0] === 'Date' || columnTypes[0] === 'Number') && + columnTypes.slice(1).some(t => t === 'Number'); + + const [useXAxis, setUseXAxis] = useState(initialUseXAxis); + + useEffect(() => { + setUseXAxis(initialUseXAxis); + }, [selectedCells?.rowStart, selectedCells?.rowEnd, selectedCells?.colStart, selectedCells?.colEnd]); + + const chartData = {}; + + if (useXAxis) { + let seriesIndex = 0; + for (let j = 1; j < columnNames.length; j++) { + if (columnTypes[j] === 'Number') { + chartData[columnNames[j]] = { + color: ChartColorSchemes[seriesIndex], + points: [], + }; + seriesIndex++; + } + } + for (let i = rowStart; i <= rowEnd; i++) { + const row = data[i]; + if (!row) continue; + let x = row.attributes[columnNames[0]]; + if (columnTypes[0] === 'Date') { + x = parseDate(x); + } else if (typeof x === 'string') { + x = parseFloat(x); + } + if (typeof x !== 'number' || isNaN(x)) { + continue; + } + for (let j = 1; j < columnNames.length; j++) { + if (columnTypes[j] !== 'Number') continue; + const val = row.attributes[columnNames[j]]; + if (typeof val === 'number' && !isNaN(val)) { + chartData[columnNames[j]].points.push([x, val]); + } + } + } + } else { + let seriesIndex = 0; + columnNames.forEach((col, idx) => { + if (columnTypes[idx] === 'Number') { + chartData[col] = { color: ChartColorSchemes[seriesIndex], points: [] }; + seriesIndex++; + } + }); + let x = 0; + for (let i = rowStart; i <= rowEnd; i++, x++) { + const row = data[i]; + if (!row) continue; + columnNames.forEach((col, idx) => { + if (columnTypes[idx] !== 'Number') return; + const val = row.attributes[col]; + if (typeof val === 'number' && !isNaN(val)) { + chartData[col].points.push([x, val]); + } + }); + } + } + + if (Object.keys(chartData).length === 0) { + return
No numeric data selected.
; + } + + const chartWidth = width - 20; + const xAxisType = useXAxis ? 'time' : 'index'; + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/components/GraphPanel/GraphPanel.scss b/src/components/GraphPanel/GraphPanel.scss new file mode 100644 index 0000000000..ef1b5e8aed --- /dev/null +++ b/src/components/GraphPanel/GraphPanel.scss @@ -0,0 +1,15 @@ +.graphPanel { + height: 100%; + overflow: auto; + background-color: #fefafb; + padding: 10px; +} + +.options { + margin-bottom: 10px; +} + +.empty { + padding: 10px; + color: #555; +} diff --git a/src/components/Toolbar/Toolbar.react.js b/src/components/Toolbar/Toolbar.react.js index e5e6642534..aeb7139bc3 100644 --- a/src/components/Toolbar/Toolbar.react.js +++ b/src/components/Toolbar/Toolbar.react.js @@ -15,7 +15,7 @@ import { useNavigate, useNavigationType, NavigationType } from 'react-router-dom const POPOVER_CONTENT_ID = 'toolbarStatsPopover'; -const Stats = ({ data, classwiseCloudFunctions, className, appId, appName }) => { +const Stats = ({ data, classwiseCloudFunctions, className, appId, appName, toggleGraph, isGraphVisible }) => { const [selected, setSelected] = React.useState(null); const [open, setOpen] = React.useState(false); const buttonRef = React.useRef(); @@ -108,14 +108,21 @@ const Stats = ({ data, classwiseCloudFunctions, className, appId, appName }) => return ( <> {selected ? ( - + <> + + {data.length > 1 ? ( + + ) : null} + ) : null} {open ? renderPopover() : null} @@ -152,6 +159,8 @@ const Toolbar = props => { className={props.className} appId={props.appId} appName={props.appName} + toggleGraph={props.toggleGraph} + isGraphVisible={props.isGraphVisible} /> ) : null}
{props.children}
@@ -182,6 +191,8 @@ Toolbar.propTypes = { details: PropTypes.string, relation: PropTypes.object, selectedData: PropTypes.array, + toggleGraph: PropTypes.func, + isGraphVisible: PropTypes.bool, }; export default Toolbar; diff --git a/src/components/Toolbar/Toolbar.scss b/src/components/Toolbar/Toolbar.scss index 88f7261171..576f0f2f79 100644 --- a/src/components/Toolbar/Toolbar.scss +++ b/src/components/Toolbar/Toolbar.scss @@ -99,6 +99,19 @@ body:global(.expanded) { border: none; } +.graph { + position: absolute; + right: 120px; + bottom: 10px; + background: $blue; + border-radius: 3px; + padding: 2px 6px; + font-size: 14px; + color: white; + box-shadow: none; + border: none; +} + .stats_popover_container { display: flex; flex-direction: column; diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index cfd1c51083..5a3f4331c6 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -79,6 +79,8 @@ const BrowserToolbar = ({ togglePanel, isPanelVisible, + toggleGraph, + isGraphVisible, classwiseCloudFunctions, appId, appName, @@ -277,6 +279,8 @@ const BrowserToolbar = ({ selectedData={selectedData} togglePanel={togglePanel} isPanelVisible={isPanelVisible} + toggleGraph={toggleGraph} + isGraphVisible={isGraphVisible} classwiseCloudFunctions={classwiseCloudFunctions} appId={appId} appName={appName} diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 394c47e025..6b7a4dcc65 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -17,6 +17,7 @@ import { ResizableBox } from 'react-resizable'; import styles from './Databrowser.scss'; import AggregationPanel from '../../../components/AggregationPanel/AggregationPanel'; +import GraphPanel from 'components/GraphPanel/GraphPanel'; const BROWSER_SHOW_ROW_NUMBER = 'browserShowRowNumber'; @@ -80,7 +81,7 @@ export default class DataBrowser extends React.Component { const storedRowNumber = window.localStorage?.getItem(BROWSER_SHOW_ROW_NUMBER) === 'true'; - this.state = { + this.state = { order: order, current: null, editing: false, @@ -99,6 +100,8 @@ export default class DataBrowser extends React.Component { showAggregatedData: true, frozenColumnIndex: -1, showRowNumber: storedRowNumber, + graphVisible: false, + graphWidth: 300, }; this.handleResizeDiv = this.handleResizeDiv.bind(this); @@ -109,6 +112,9 @@ export default class DataBrowser extends React.Component { this.handleHeaderDragDrop = this.handleHeaderDragDrop.bind(this); this.handleResize = this.handleResize.bind(this); this.togglePanelVisibility = this.togglePanelVisibility.bind(this); + this.handleGraphResizeStart = this.handleGraphResizeStart.bind(this); + this.handleGraphResizeStop = this.handleGraphResizeStop.bind(this); + this.handleGraphResizeDiv = this.handleGraphResizeDiv.bind(this); this.setCurrent = this.setCurrent.bind(this); this.setEditing = this.setEditing.bind(this); this.handleColumnsOrder = this.handleColumnsOrder.bind(this); @@ -120,6 +126,7 @@ export default class DataBrowser extends React.Component { this.unfreezeColumns = this.unfreezeColumns.bind(this); this.setShowRowNumber = this.setShowRowNumber.bind(this); this.handleCellClick = this.handleCellClick.bind(this); + this.toggleGraphVisibility = this.toggleGraphVisibility.bind(this); this.saveOrderTimeout = null; } @@ -215,6 +222,21 @@ export default class DataBrowser extends React.Component { this.setState({ panelWidth: size.width }); } + handleGraphResizeStart() { + this.setState({ isResizing: true }); + } + + handleGraphResizeStop(event, { size }) { + this.setState({ + isResizing: false, + graphWidth: size.width, + }); + } + + handleGraphResizeDiv(event, { size }) { + this.setState({ graphWidth: size.width }); + } + setShowAggregatedData(bool) { this.setState({ showAggregatedData: bool, @@ -264,6 +286,10 @@ export default class DataBrowser extends React.Component { } } + toggleGraphVisibility() { + this.setState(prevState => ({ graphVisible: !prevState.graphVisible })); + } + getAllClassesSchema(schema) { const allClasses = Object.keys(schema.data.get('classes').toObject()); const schemaSimplifiedData = {}; @@ -667,6 +693,7 @@ export default class DataBrowser extends React.Component { }, selectedObjectId: undefined, selectedData, + graphVisible: true, }); } else { this.setCurrent({ row, col }); @@ -677,6 +704,7 @@ export default class DataBrowser extends React.Component { selectedData: [], current: { row, col }, firstSelectedCell: clickedCellKey, + graphVisible: false, }); } } @@ -758,6 +786,29 @@ export default class DataBrowser extends React.Component { )} + {this.state.graphVisible && ( + +
+ +
+
+ )}