From 6f5d7fedc7e969c735db1905ed89a879ca8124b7 Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 9 Jul 2025 17:44:33 +0200
Subject: [PATCH 1/3] feat(data-browser): add graph visualization
---
src/components/GraphPanel/GraphPanel.js | 84 +++++++++++++++++++
src/components/GraphPanel/GraphPanel.scss | 11 +++
src/components/Toolbar/Toolbar.react.js | 29 +++++--
src/components/Toolbar/Toolbar.scss | 13 +++
.../Data/Browser/BrowserToolbar.react.js | 4 +
.../Data/Browser/DataBrowser.react.js | 55 +++++++++++-
6 files changed, 186 insertions(+), 10 deletions(-)
create mode 100644 src/components/GraphPanel/GraphPanel.js
create mode 100644 src/components/GraphPanel/GraphPanel.scss
diff --git a/src/components/GraphPanel/GraphPanel.js b/src/components/GraphPanel/GraphPanel.js
new file mode 100644
index 0000000000..b688406bfb
--- /dev/null
+++ b/src/components/GraphPanel/GraphPanel.js
@@ -0,0 +1,84 @@
+import React 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 timeSeries =
+ columnTypes.length > 1 &&
+ columnTypes[0] === 'Date' &&
+ columnTypes.slice(1).every(t => t === 'Number');
+
+ const chartData = {};
+ if (timeSeries) {
+ for (let j = 1; j < columnNames.length; j++) {
+ chartData[columnNames[j]] = { color: ChartColorSchemes[j - 1], points: [] };
+ }
+ for (let i = rowStart; i <= rowEnd; i++) {
+ const row = data[i];
+ if (!row) continue;
+ const ts = parseDate(row.attributes[columnNames[0]]);
+ if (ts === null) continue;
+ for (let j = 1; j < columnNames.length; j++) {
+ const val = row.attributes[columnNames[j]];
+ if (typeof val === 'number' && !isNaN(val)) {
+ chartData[columnNames[j]].points.push([ts, 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 => {
+ 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;
+ return (
+
+
+
+ );
+}
diff --git a/src/components/GraphPanel/GraphPanel.scss b/src/components/GraphPanel/GraphPanel.scss
new file mode 100644
index 0000000000..9635f71638
--- /dev/null
+++ b/src/components/GraphPanel/GraphPanel.scss
@@ -0,0 +1,11 @@
+.graphPanel {
+ height: 100%;
+ overflow: auto;
+ background-color: #fefafb;
+ padding: 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 && (
+
+
+
+
+
+ )}
Date: Wed, 9 Jul 2025 18:17:21 +0200
Subject: [PATCH 2/3] feat(graph): add x-axis option
---
src/components/GraphPanel/GraphPanel.js | 56 ++++++++++++++++++-----
src/components/GraphPanel/GraphPanel.scss | 4 ++
2 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/src/components/GraphPanel/GraphPanel.js b/src/components/GraphPanel/GraphPanel.js
index b688406bfb..98cb6ea604 100644
--- a/src/components/GraphPanel/GraphPanel.js
+++ b/src/components/GraphPanel/GraphPanel.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import Chart from 'components/Chart/Chart.react';
import { ChartColorSchemes } from 'lib/Constants';
import styles from './GraphPanel.scss';
@@ -28,25 +28,48 @@ export default function GraphPanel({ selectedCells, order, data, columns, width
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 timeSeries =
- columnTypes.length > 1 &&
- columnTypes[0] === 'Date' &&
- columnTypes.slice(1).every(t => t === 'Number');
+
+ const initialUseXAxis =
+ columnNames.length > 1 &&
+ (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 (timeSeries) {
+
+ if (useXAxis) {
+ let seriesIndex = 0;
for (let j = 1; j < columnNames.length; j++) {
- chartData[columnNames[j]] = { color: ChartColorSchemes[j - 1], points: [] };
+ 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;
- const ts = parseDate(row.attributes[columnNames[0]]);
- if (ts === null) 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([ts, val]);
+ chartData[columnNames[j]].points.push([x, val]);
}
}
}
@@ -62,7 +85,8 @@ export default function GraphPanel({ selectedCells, order, data, columns, width
for (let i = rowStart; i <= rowEnd; i++, x++) {
const row = data[i];
if (!row) continue;
- columnNames.forEach(col => {
+ 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]);
@@ -78,6 +102,16 @@ export default function GraphPanel({ selectedCells, order, data, columns, width
const chartWidth = width - 20;
return (
);
diff --git a/src/components/GraphPanel/GraphPanel.scss b/src/components/GraphPanel/GraphPanel.scss
index 9635f71638..ef1b5e8aed 100644
--- a/src/components/GraphPanel/GraphPanel.scss
+++ b/src/components/GraphPanel/GraphPanel.scss
@@ -5,6 +5,10 @@
padding: 10px;
}
+.options {
+ margin-bottom: 10px;
+}
+
.empty {
padding: 10px;
color: #555;
From 29c588d747098e1b237c192a84d34ce8c75281dc Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 9 Jul 2025 18:32:06 +0200
Subject: [PATCH 3/3] Fix graph axis handling
---
src/components/Chart/Chart.react.js | 74 +++++++++++++++----------
src/components/GraphPanel/GraphPanel.js | 14 ++++-
src/lib/Charting.js | 8 +++
3 files changed, 64 insertions(+), 32 deletions(-)
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}
+
+ ))}
+
+ )}