Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions angular-client/src/pages/graph-page/graph-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,21 @@
<!-- Graph Configuration Inputs -->
<div style="display: flex; align-items: center; gap: 5px; margin-left: 15px">
@if (this.realTime === true) {
<label style="color: #fff; font-size: 12px">X (points):</label>
<argos-button
[onClick]="this.toggleRangeMode"
[label]="rangeMode === 'time' ? 'Time Range' : 'Point Range'"
additionalStyles="background-color: #0c2026; border: 1.4px solid #8fcadd; width: 90px; font-size: 11px;"
/>

<label style="color: #fff; font-size: 12px">{{ rangeMode === 'time' ? 'X (sec):' : 'X (points):' }}</label>
<p-inputNumber
[(ngModel)]="this.dataPoints"
[(ngModel)]="rangeValue"
mode="decimal"
[minFractionDigits]="1"
[maxFractionDigits]="3"
[step]="0.1"
[min]="1"
[step]="1"
styleClass="time-range-input"
inputStyleClass="text-xs p-4"
inputId="minmaxfraction"
placeholder="Auto"
[placeholder]="rangeMode === 'time' ? '30' : 'Auto'"
(onInput)="onGraphConfigChange()"
></p-inputNumber>
}
Expand Down
89 changes: 46 additions & 43 deletions angular-client/src/pages/graph-page/graph-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { FaultService } from 'src/services/fault.service';
import Storage from 'src/services/storage.service';
import { TopicSelectionService } from 'src/services/topic-selection.service';
import { DataValue } from 'src/utils/socket.utils';
import { DataType, FaultData, GraphData, GraphInfo, Run } from 'src/utils/types.utils';
import { DataType, FaultData, GraphData, ObservableGraphInfo, Run } from 'src/utils/types.utils';
import { ButtonComponent } from '../../components/argos-button/argos-button.component';
import { FaultButtonsComponent } from './graph-caption/fault-buttons/fault-buttons.component';
import { GeneralButtonsComponent } from './graph-caption/general-buttons/general-buttons.component';
Expand Down Expand Up @@ -147,18 +147,23 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
// When we are in live mode the data is constantly udpated.
// The Behvaorial subject is update just a single time when querying for data.
// these should always be reset when switching between modes.
selectedDataTypeValuesSubject = [new BehaviorSubject<GraphInfo>({ label: '', data: [] })];
selectedDataTypeValuesSubject: ObservableGraphInfo[] = [];
selectedDataTypeValuesIsLoading = false; // specifically used for querying updates.
selectedDataTypeValuesIsError = false;
selectedDataTypeValuesError?: Error;
dataPoints: number = 300;
dataPoints: number = 100;
dataPointsChanged = false;
yAxisMin: number | null = null;
yAxisMax: number | null = null;
// Range mode: 'time' for time-based range, 'points' for data point-based range
rangeMode: 'time' | 'points' = 'time'; // Default to time-based
timeRangeSeconds: number = 30; // Default to 30 seconds
graphConfig = {
maxPoints: this.dataPoints,
yMin: this.yAxisMin,
yMax: this.yAxisMax
yMax: this.yAxisMax,
rangeMode: this.rangeMode,
timeRangeMs: this.timeRangeSeconds * 1000
};
onGraphConfigChange = () => {
if (this.realTime) {
Expand All @@ -167,9 +172,27 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
this.graphConfig = {
maxPoints: this.dataPoints,
yMin: this.yAxisMin,
yMax: this.yAxisMax
yMax: this.yAxisMax,
rangeMode: this.rangeMode,
timeRangeMs: this.timeRangeSeconds * 1000
};
};
// Getter/setter to allow same function input for ngModel in template.
get rangeValue(): number {
return this.rangeMode === 'time' ? this.timeRangeSeconds : this.dataPoints;
}
set rangeValue(val: number) {
if (this.rangeMode === 'time') {
this.timeRangeSeconds = val;
} else {
this.dataPoints = val;
}
}

toggleRangeMode = () => {
this.rangeMode = this.rangeMode === 'time' ? 'points' : 'time';
this.onGraphConfigChange();
};

// Run when page starts up
ngOnInit(): void {
Expand Down Expand Up @@ -211,8 +234,8 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
sub.unsubscribe();
});

this.selectedDataTypeValuesSubject.forEach((subject) => {
subject.complete();
this.selectedDataTypeValuesSubject.forEach((item) => {
item.updates.complete();
});
this.selectedDataTypeValuesSubject = [];
this.selectedDataTypeValuesSubject.length = 0;
Expand Down Expand Up @@ -372,47 +395,24 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
}

private processRealTimeDataTypeSelection = (dataTypes: DataType[]) => {
const dataTypeValues = this.selectedDataTypeValuesSubject.map((subject) => subject.getValue());

dataTypes.forEach((dataType) => {
const key = dataType.name;
const graphInfo = dataTypeValues.find((dtV) => dtV.label === key);
const target = this.selectedDataTypeValuesSubject.find((s) => s.label === key);
const valuesSubject = this.storage.get(key);

if (graphInfo !== undefined) {
if (target !== undefined) {
this.subscriptions.push(
valuesSubject.subscribe((value: DataValue) => {
// Skip processing if paused
if (this.isPaused) {
return;
}

const storedValues = graphInfo.data;

// Process new values and filter in one pass for better performance
value.values.forEach((val, i) => {
const graphData = { x: +value.time, y: +val, label: dataType.name };

if (storedValues[i]) {
storedValues[i].push(graphData);

// Limit to prevent memory buildup
if (storedValues[i].length > this.dataPoints) {
storedValues[i].shift();
}
} else {
storedValues[i] = [graphData];
}
const newPoints: GraphData[][] = value.values.map((val) => {
return [{ x: +value.time, y: +val }];
});

// Update the subject with the already filtered data
const targetSubject = this.selectedDataTypeValuesSubject.find((s) => s.getValue().label === dataType.name);
if (targetSubject) {
targetSubject.next({
label: dataType.name,
data: storedValues
});
}
target.updates.next(newPoints);
})
);
}
Expand Down Expand Up @@ -477,15 +477,15 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
});
});

let target = this.selectedDataTypeValuesSubject.find((subj) => subj.getValue().label === dataType.name);
let target = this.selectedDataTypeValuesSubject.find((s) => s.label === dataType.name);

if (!target) {
// (shouldn't normally happen, but keep it safe)
target = new BehaviorSubject<GraphInfo>({ label: dataType.name, data: [] });
target = { label: dataType.name, updates: new BehaviorSubject<GraphData[][]>([]) };
this.selectedDataTypeValuesSubject.push(target);
}

target.next({ label: dataType.name, data: graphData });
target.updates.next(graphData);
}
})
);
Expand All @@ -501,7 +501,10 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
this.clearDataType();
this.selectedDataTypes = dataTypes;

this.selectedDataTypeValuesSubject = dataTypes.map((dt) => new BehaviorSubject<GraphInfo>({ label: dt.name, data: [] }));
this.selectedDataTypeValuesSubject = dataTypes.map((dt) => ({
label: dt.name,
updates: new BehaviorSubject<GraphData[][]>([])
}));

if (this.realTime) {
this.processRealTimeDataTypeSelection(dataTypes);
Expand All @@ -516,7 +519,7 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
}
};

clearGraph = false;
clearGraph = 0;

clearDataType: () => void = () => {
// Unsubscribe from all previous subscriptions
Expand All @@ -526,15 +529,15 @@ export default class GraphPageComponent implements OnInit, OnDestroy {
this.subscriptions = [];

// Clear and complete existing subjects to prevent memory leaks
this.selectedDataTypeValuesSubject.forEach((subject) => {
subject.complete();
this.selectedDataTypeValuesSubject.forEach((item) => {
item.updates.complete();
});
this.selectedDataTypeValuesSubject = []; // More explicit reset

// Reset loading states
this.selectedDataTypeValuesIsLoading = false;
this.selectedDataTypeValuesIsError = false;
this.selectedDataTypeValuesError = undefined;
this.clearGraph = true;
this.clearGraph++;
};
}
112 changes: 69 additions & 43 deletions angular-client/src/pages/graph-page/graph/graph.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
ApexLegend,
ApexYAxis
} from 'ng-apexcharts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { GraphInfo } from 'src/utils/types.utils';
import { Subscription } from 'rxjs';
import { binarySearchInsertIndex } from 'src/utils/array.utils';
import { GraphInfo, ObservableGraphInfo } from 'src/utils/types.utils';

type ChartOptions = {
chart: ApexChart;
Expand All @@ -36,20 +37,25 @@ type ChartOptions = {
})
export default class CustomGraphComponent implements OnInit, OnDestroy {
showMultipleYAxes = input<boolean>(false);
valuesSubject = input.required<BehaviorSubject<GraphInfo>[]>();
valuesSubject = input.required<ObservableGraphInfo[]>();
limitRange = input(true);
isPaused = input<boolean>(false);
realTime = input<boolean>(false);
clearGraph = input<boolean>(false);
clearGraph = input<number>(0);
options!: ChartOptions;
chart!: ApexCharts;
previousDataLength: number = 0;
// label -> x,y (topic, data point)
data!: Map<string, Array<{ x: number; y: number }>>;
isSliding: boolean = false;
timeRangeMs: number | undefined = undefined;
private timeOuts: NodeJS.Timeout[] = [];
graphConfig = input.required<{ maxPoints: number; yMin: number | null; yMax: number | null }>();
graphConfig = input.required<{
maxPoints: number;
yMin: number | null;
yMax: number | null;
rangeMode: 'time' | 'points';
timeRangeMs: number;
}>();
range = input<number | undefined>(undefined);
subscriptions: Subscription[] = [];

Expand Down Expand Up @@ -87,8 +93,8 @@ export default class CustomGraphComponent implements OnInit, OnDestroy {
if (this.chart) {
this.chart.updateSeries([]);
}
this.previousDataLength = 0;
this.data = new Map();
this.timeRangeMs = undefined;
});

effect(() => {
Expand Down Expand Up @@ -137,11 +143,8 @@ export default class CustomGraphComponent implements OnInit, OnDestroy {
this.timeOuts.forEach((timeout) => clearTimeout(timeout));
this.timeOuts = [];

this.valuesSubject().forEach((graphInfo) => {
if (this.isPaused()) {
return;
}
this.subscriptions.push(graphInfo.subscribe(this.graphInfoCallback));
this.valuesSubject().forEach(({ label, updates }) => {
this.subscriptions.push(updates.subscribe((data) => this.graphInfoCallback({ label, data })));
});

this.updateChart();
Expand Down Expand Up @@ -170,54 +173,77 @@ export default class CustomGraphComponent implements OnInit, OnDestroy {
return;
}

const series = Array.from(this.data).map(([key, map], index) => ({
const series = Array.from(this.data).map(([key, points], index) => ({
name: key,
data: Array.from(map),
yaxis: index // Assign each series to a y-axis index
data: points,
yaxis: index
}));

this.chart.updateSeries(series);
// Use time-based range if in time mode, otherwise use calculated timeRangeMs from point-based logic
const effectiveRange = this.graphConfig().rangeMode === 'time' ? this.graphConfig().timeRangeMs : this.timeRangeMs;

this.chart.updateOptions({
...this.options,
xaxis: {
...this.options.xaxis,
range: this.timeRangeMs
}
});
// Single updateOptions call with series included — avoids two separate re-renders
// Pass false, false to skip animation bookkeeping (getPreviousPaths) and animate flag
this.chart.updateOptions(
{
...this.options,
series,
xaxis: {
...this.options.xaxis,
range: effectiveRange
}
},
false, // redraw (default is false)
false // animate (default is true)
);
};

graphInfoCallback = (info: GraphInfo | undefined) => {
graphInfoCallback = (info: GraphInfo) => {
// Skip processing if paused
if (this.isPaused()) {
return;
}

const values = info?.data ?? [];
// if (values.length === 0) this.data = new Map();
values.forEach((value, i) => {
let line: Array<{ x: number; y: number }>;
const label = (info?.label ?? '') + ' ' + i;
if (!this.data.has(label)) {
line = this.data.set(label, []).get(label)!;
} else {
line = this.data.get(label)!;
info.data.forEach((value, i) => {
const seriesLabel = info.label + ' ' + i;
if (!this.data.has(seriesLabel)) {
this.data.set(seriesLabel, []);
}
const line = this.data.get(seriesLabel)!;

value.forEach((val) => {
if (!line.some((v) => v.x === val.x)) {
line.push({ x: val.x, y: +val.y.toFixed(3) });
const point = { x: val.x, y: Math.round(val.y * 10000) / 10000 };

// if the point is in order according to it's timestamp, just push it to the end of the line
if (line.length === 0 || val.x >= line.at(-1)!.x) {
line.push(point);
} else {
// Out of order: binary search for correct sorted position
// (very rare but it's nice to be able to assume sorted data for efficient trimming later)
const idx = binarySearchInsertIndex(line, val.x);
line.splice(idx, 0, point);
}
});

if (this.realTime() && line.length > this.graphConfig().maxPoints) {
const shiftedPoint = line.shift()?.x; // Remove the oldest point
// THE BELOW IS A SOMEWHAT TEMP FIX, ULTIMATELY THIS SHOULD BE MORE DYNAMIC AND BE OFFERED AS A CONFIG OPTION
// Calculate the actual time range: difference between newest and oldest remaining points
const timeDiff = line.length > 0 && shiftedPoint !== undefined ? val.x - shiftedPoint : 0;
// Update time range if this is larger than current range
// Trim after processing all points in this series batch
if (this.realTime() && line.length > 0) {
const config = this.graphConfig();
const latestX = line[line.length - 1].x;

if (config.rangeMode === 'time') {
// remove point if outside the time range + 10% buffer (for better UX)
const buffer = config.timeRangeMs * 0.1;
const cutoff = latestX - config.timeRangeMs - buffer;
if (line[0].x < cutoff) {
line.shift();
}
} else if (line.length > config.maxPoints * 1.1) {
const shiftedPoint = line.shift();
// point based trim requires is to calculate the max time range we can show that
// doesn't show points being deleted. So we default to smallest range.
const timeDiff = shiftedPoint !== undefined ? latestX - shiftedPoint.x : 0;
this.timeRangeMs = timeDiff < (this.timeRangeMs ?? Number.MAX_SAFE_INTEGER) ? timeDiff : this.timeRangeMs;
}
});
}
});

this.updateChart();
Expand Down
Loading