diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 1945177f..c99e170e 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 1.4.1
+current_version = 1.5.0
commit = True
tag = False
diff --git a/VERSION b/VERSION
index 347f5833..bc80560f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.1
+1.5.0
diff --git a/package.json b/package.json
index da861122..93d55f58 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@halos-org/skip",
- "version": "1.4.1",
+ "version": "1.5.0",
"publishConfig": {
"access": "public"
},
diff --git a/src/app/core/utils/graph-window.util.spec.ts b/src/app/core/utils/graph-window.util.spec.ts
index f2311fe3..499ff057 100644
--- a/src/app/core/utils/graph-window.util.spec.ts
+++ b/src/app/core/utils/graph-window.util.spec.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
resolveWindowMs,
deriveDataSourceInfo,
+ describeSmoothingWindow,
TARGET_POINTS_PER_WINDOW,
MIN_SAMPLE_TIME_MS,
SMOOTHING_PERIOD_FACTOR
@@ -71,4 +72,24 @@ describe('graph-window.util', () => {
expect(info.smoothingPeriod).toBeGreaterThanOrEqual(1);
});
});
+
+ describe('describeSmoothingWindow', () => {
+ it('reports a quarter of the display window in the unit that reads best', () => {
+ expect(describeSmoothingWindow('Last Minute', 0)).toBe('15 s');
+ expect(describeSmoothingWindow('minute', 10)).toBe('2.5 min');
+ expect(describeSmoothingWindow('hour', 2)).toBe('30 min');
+ expect(describeSmoothingWindow('hour', 12)).toBe('3 h');
+ expect(describeSmoothingWindow('day', 4)).toBe('1 day');
+ expect(describeSmoothingWindow('day', 10)).toBe('2.5 days');
+ });
+
+ it('stays truthful for a window so short the average spans milliseconds', () => {
+ // 1 s of data at the 100 ms sample floor leaves a 2-point average.
+ expect(describeSmoothingWindow('second', 1)).toBe('200 ms');
+ });
+
+ it('describes nothing when the window is empty', () => {
+ expect(describeSmoothingWindow('minute', 0)).toBe('');
+ });
+ });
});
diff --git a/src/app/core/utils/graph-window.util.ts b/src/app/core/utils/graph-window.util.ts
index 5f7c779f..1d94c618 100644
--- a/src/app/core/utils/graph-window.util.ts
+++ b/src/app/core/utils/graph-window.util.ts
@@ -47,6 +47,32 @@ export function resolveWindowMs(timeScaleFormat: TimeScaleFormat, period: number
* window with a floor sampling interval for very small windows. The point count tracks the target
* (never far above it), so no separate buffer cap is needed.
*/
+/** Largest unit first, so the first unit the span reaches is the one it is reported in. */
+const SMOOTHING_WINDOW_UNITS: readonly { ms: number; one: string; many: string }[] = [
+ { ms: 24 * 60 * 60_000, one: 'day', many: 'days' },
+ { ms: 60 * 60_000, one: 'h', many: 'h' },
+ { ms: 60_000, one: 'min', many: 'min' },
+ { ms: 1_000, one: 's', many: 's' }
+];
+
+/**
+ * How much time the moving average spans, phrased for a settings hint ('2.5 min'). The span is the
+ * smoothing period at the window's own sampling cadence, so it stays true to what the graph plots
+ * rather than restating the 25 % factor. Empty for a window with no length.
+ */
+export function describeSmoothingWindow(timeScaleFormat: TimeScaleFormat, period: number): string {
+ const windowMs = resolveWindowMs(timeScaleFormat, period);
+ if (windowMs <= 0) return '';
+ const info = deriveDataSourceInfo(windowMs);
+ const spanMs = info.smoothingPeriod * info.sampleTime;
+ for (const unit of SMOOTHING_WINDOW_UNITS) {
+ if (spanMs < unit.ms) continue;
+ const value = Math.round((spanMs / unit.ms) * 10) / 10;
+ return `${value} ${value === 1 ? unit.one : unit.many}`;
+ }
+ return `${Math.round(spanMs)} ms`;
+}
+
export function deriveDataSourceInfo(windowMs: number): IGraphDataSourceInfo {
const sampleTime = windowMs > 0
? Math.max(MIN_SAMPLE_TIME_MS, Math.round(windowMs / TARGET_POINTS_PER_WINDOW))
diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.html b/src/app/widget-config/graph-display-options/graph-display-options.component.html
index ee826a1e..444a6398 100644
--- a/src/app/widget-config/graph-display-options/graph-display-options.component.html
+++ b/src/app/widget-config/graph-display-options/graph-display-options.component.html
@@ -39,18 +39,42 @@
name="showAverageData"
[formControl]="showAverageData()"
(change)="enableTrackAgainstMovingAverage($event)">
- Display Moving Average
-
-
- Track Against Moving Average
+ Show Smoothed Trend
+ @if (smoothingWindow()) {
+ Moving average over the last {{ smoothingWindow() }}, about a quarter of the graph window.
+ }
+
+
Main Series
+
+ Live Value
+ Smoothed Trend
+
+
The reading and the bold line follow the main series. The other one becomes the shaded band.
+
Vertical Data Graph
+ Reference Lines
+
+ Show Maximum Line
+
+
+ Show Window Average Line
+
+
+ Show Minimum Line
+
@@ -130,26 +154,6 @@
-
-
-
Series
-
- Show Maximum Line
-
-
- Show Average Line
-
-
- Show Minimum Line
-
-
-
diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.scss b/src/app/widget-config/graph-display-options/graph-display-options.component.scss
index f0a91703..e2f7a755 100644
--- a/src/app/widget-config/graph-display-options/graph-display-options.component.scss
+++ b/src/app/widget-config/graph-display-options/graph-display-options.component.scss
@@ -16,6 +16,16 @@
margin: 0px;
}
+.graph-option-caption {
+ display: block;
+ margin: 2px 0px 6px 0px;
+ color: var(--mat-sys-outline);
+}
+
+.graph-option-subgroup {
+ margin-left: 25px;
+}
+
.graph-option-radio-group {
display: flex;
flex-direction: column;
diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts
index 081411f5..364ff54a 100644
--- a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts
+++ b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts
@@ -31,6 +31,8 @@ describe('GraphDisplayOptionsComponent', () => {
yScaleMax: new UntypedFormControl({ value: 120, disabled: true }),
numDecimal: new UntypedFormControl(2),
color: new UntypedFormControl('contrast'),
+ timeScale: new UntypedFormControl('minute'),
+ period: new UntypedFormControl(10),
...overrides,
};
@@ -69,6 +71,20 @@ describe('GraphDisplayOptionsComponent', () => {
expect(controls.trackAgainstAverage.disabled).toBe(true);
});
+ it('drops a stored main-series choice that its own smoothing toggle contradicts (#600)', () => {
+ // Otherwise the disabled group reads Smoothed Trend, and switching smoothing back on moves
+ // the widget reading off the live value without the user choosing it.
+ const localFixture = TestBed.createComponent(GraphDisplayOptionsComponent);
+ const controls = applyRequiredInputs(localFixture, {
+ showAverageData: new UntypedFormControl(false),
+ trackAgainstAverage: new UntypedFormControl({ value: true, disabled: false })
+ });
+
+ localFixture.detectChanges();
+
+ expect(controls.trackAgainstAverage.value).toBe(false);
+ });
+
it('should enable and disable fixed scale controls based on radio selection', () => {
const yScaleMin = component.yScaleMin();
const yScaleMax = component.yScaleMax();
@@ -88,6 +104,29 @@ describe('GraphDisplayOptionsComponent', () => {
expect(yScaleSuggestedMax.disabled).toBe(false);
});
+ it('states the smoothing span the graph actually averages over (#598)', () => {
+ const text = () => (fixture.nativeElement as HTMLElement).textContent ?? '';
+ expect(text()).toContain('2.5 min');
+
+ component.period().setValue(20);
+ fixture.detectChanges();
+ expect(text()).toContain('5 min');
+ });
+
+ it('keeps the series toggles and their reference lines in one card (#598)', () => {
+ const cards = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('.flex-item-rounded-card'));
+ const seriesCards = cards.filter(card => (card.textContent ?? '').includes('Show Maximum Line'));
+ expect(seriesCards).toHaveLength(1);
+ expect(seriesCards[0].textContent).toContain('Display Data Points');
+ });
+
+ it('offers the main series as a choice between the live value and the smoothed trend (#598)', () => {
+ const labels = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('mat-radio-button'))
+ .map(button => (button.textContent ?? '').trim());
+ expect(labels).toContain('Live Value');
+ expect(labels).toContain('Smoothed Trend');
+ });
+
it('should enable and disable trackAgainstAverage from checkbox events', () => {
const trackAgainstAverage = component.trackAgainstAverage();
diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.ts
index 67129425..57abc52d 100644
--- a/src/app/widget-config/graph-display-options/graph-display-options.component.ts
+++ b/src/app/widget-config/graph-display-options/graph-display-options.component.ts
@@ -1,5 +1,9 @@
-import { Component, OnInit, input, inject } from '@angular/core';
+import { Component, DestroyRef, OnInit, input, inject, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { merge } from 'rxjs';
import { AppService } from '../../core/services/app-service';
+import { describeSmoothingWindow } from '../../core/utils/graph-window.util';
+import type { TimeScaleFormat } from '../../core/interfaces/graph-data.interfaces';
import { MatCardModule } from '@angular/material/card';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
@@ -18,6 +22,7 @@ import { MatRadioChange, MatRadioModule } from '@angular/material/radio';
})
export class GraphDisplayOptionsComponent implements OnInit {
private app = inject(AppService);
+ private readonly _destroyRef = inject(DestroyRef);
readonly datasetAverageArray = input.required>();
readonly showAverageData = input.required>();
@@ -42,19 +47,38 @@ export class GraphDisplayOptionsComponent implements OnInit {
readonly numDecimal = input.required>();
readonly color = input.required>();
+ /** The graph window the smoothing span is derived from; owned by the Data tab. */
+ readonly timeScale = input.required>();
+ readonly period = input.required>();
protected colors: { label: string; value: string }[] = [];
+ /** How far back the moving average reaches, phrased for the hint ('2.5 min'). */
+ protected smoothingWindow = signal('');
ngOnInit(): void {
this.colors = this.app.configurableThemeColors;
if (this.showAverageData() && !this.showAverageData()?.value) {
+ // Reset as well as disable: a stored choice of the smoothed trend, held while nothing smooths,
+ // would otherwise take effect the moment smoothing is switched back on.
+ this.trackAgainstAverage().setValue(false);
this.trackAgainstAverage().disable();
}
+ this.refreshSmoothingWindow();
+ // The span is a fraction of the graph window, so it follows edits made on the Data tab while
+ // this dialog stays open.
+ merge(this.timeScale().valueChanges, this.period().valueChanges)
+ .pipe(takeUntilDestroyed(this._destroyRef))
+ .subscribe(() => this.refreshSmoothingWindow());
+
if (this.enableMinMaxScaleLimit()) {
this.setValueScaleOptionsControls(this.enableMinMaxScaleLimit().value);
}
}
+ private refreshSmoothingWindow(): void {
+ this.smoothingWindow.set(describeSmoothingWindow(this.timeScale().value as TimeScaleFormat, this.period().value));
+ }
+
private setValueScaleOptionsControls(enableMinMaxScaleLimit: boolean) {
if (enableMinMaxScaleLimit) {
this.yScaleMin()?.enable();
diff --git a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html
index df0270ca..3a22206c 100644
--- a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html
+++ b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html
@@ -71,6 +71,8 @@ {{ titleDialog }}
[yScaleMin]="yScaleMinToControl"
[yScaleMax]="yScaleMaxToControl"
[numDecimal]="numDecimalToControl"
+ [timeScale]="timeScaleControl"
+ [period]="periodControl"
[verticalChart]="verticalChartToControl"
[inverseYAxis]="inverseYAxisToControl"
[color]="colorToControl" />
diff --git a/src/assets/skip-dashboard-schema.json b/src/assets/skip-dashboard-schema.json
index 6d941b6a..d8ffa9f1 100644
--- a/src/assets/skip-dashboard-schema.json
+++ b/src/assets/skip-dashboard-schema.json
@@ -581,7 +581,7 @@
"configFileVersion": 11,
"configVersion": 19,
"schemaVersion": 1,
- "skipVersion": "1.4.1"
+ "skipVersion": "1.5.0"
},
"widgets": [
{