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": [ {