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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.4.1
current_version = 1.5.0
commit = True
tag = False

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.4.1
1.5.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@halos-org/skip",
"version": "1.4.1",
"version": "1.5.0",
"publishConfig": {
"access": "public"
},
Expand Down
21 changes: 21 additions & 0 deletions src/app/core/utils/graph-window.util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('');
});
});
});
26 changes: 26 additions & 0 deletions src/app/core/utils/graph-window.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,42 @@
name="showAverageData"
[formControl]="showAverageData()"
(change)="enableTrackAgainstMovingAverage($event)">
Display Moving Average
</mat-checkbox>
<mat-checkbox class="full-width" style="margin-left: 25px;"
name="trackAgainstAverage"
[formControl]="trackAgainstAverage()">
Track Against Moving Average
Show Smoothed Trend
</mat-checkbox>
@if (smoothingWindow()) {
<span class="mat-caption graph-option-caption">Moving average over the last {{ smoothingWindow() }}, about a quarter of the graph window.</span>
}
<div class="graph-option-subgroup">
<p class="no-margin">Main Series</p>
<mat-radio-group class="graph-option-radio-group" aria-label="Select the main series"
name="trackAgainstAverage"
[formControl]="trackAgainstAverage()">
<mat-radio-button class="graph-option-radio-button" [value]="false">Live Value</mat-radio-button>
<mat-radio-button class="graph-option-radio-button" [value]="true">Smoothed Trend</mat-radio-button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</mat-radio-group>
<span class="mat-caption graph-option-caption">The reading and the bold line follow the main series. The other one becomes the shaded band.</span>
</div>
<mat-checkbox
name="verticalChart"
[formControl]="verticalChart()">
Vertical Data Graph
</mat-checkbox>
<p class="no-margin" style="margin-top: 10px;">Reference Lines</p>
<mat-checkbox class="full-width"
name="showDatasetMaximumValueLine"
[formControl]="showDatasetMaximumValueLine()">
Show Maximum Line
</mat-checkbox>
<mat-checkbox class="full-width"
name="showDatasetAverageValueLine"
[formControl]="showDatasetAverageValueLine()">
Show Window Average Line
</mat-checkbox>
<mat-checkbox class="full-width" #minLine
name="showDatasetMinimumValueLine"
[formControl]="showDatasetMinimumValueLine()">
Show Minimum Line
</mat-checkbox>
</div>

<div class="flex-item-rounded-card rounded-card-color">
Expand Down Expand Up @@ -130,26 +154,6 @@
</mat-checkbox>
</div>
</div>
<div class="flex-item-rounded-card rounded-card-color">
<div>
<p class="no-margin">Series</p>
<mat-checkbox class="full-width"
name="showDatasetMaximumValueLine"
[formControl]="showDatasetMaximumValueLine()">
Show Maximum Line
</mat-checkbox>
<mat-checkbox class="full-width"
name="showDatasetAverageValueLine"
[formControl]="showDatasetAverageValueLine()">
Show Average Line
</mat-checkbox>
<mat-checkbox class="full-width" #minLine
name="showDatasetMinimumValueLine"
[formControl]="showDatasetMinimumValueLine()">
Show Minimum Line
</mat-checkbox>
</div>
</div>
</div>

</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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();
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<FormControl<string>>();
readonly showAverageData = input.required<FormControl<boolean>>();
Expand All @@ -42,19 +47,38 @@ export class GraphDisplayOptionsComponent implements OnInit {

readonly numDecimal = input.required<FormControl<number>>();
readonly color = input.required<FormControl<string>>();
/** The graph window the smoothing span is derived from; owned by the Data tab. */
readonly timeScale = input.required<FormControl<string>>();
readonly period = input.required<FormControl<number>>();
protected colors: { label: string; value: string }[] = [];
/** How far back the moving average reaches, phrased for the hint ('2.5 min'). */
protected smoothingWindow = signal<string>('');

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ <h6 mat-dialog-title>{{ titleDialog }}</h6>
[yScaleMin]="yScaleMinToControl"
[yScaleMax]="yScaleMaxToControl"
[numDecimal]="numDecimalToControl"
[timeScale]="timeScaleControl"
[period]="periodControl"
[verticalChart]="verticalChartToControl"
[inverseYAxis]="inverseYAxisToControl"
[color]="colorToControl" />
Expand Down
2 changes: 1 addition & 1 deletion src/assets/skip-dashboard-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@
"configFileVersion": 11,
"configVersion": 19,
"schemaVersion": 1,
"skipVersion": "1.4.1"
"skipVersion": "1.5.0"
},
"widgets": [
{
Expand Down
Loading