From 003f3a3ea8dacac650f9b343bd839675ad3ae6e1 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 22:50:28 +0300 Subject: [PATCH 1/2] feat(graph): show the angle range only for angular paths The Angle display range select changes how radian values are read, so it is clutter on a path with any other unit. Offer it for radian paths and for paths the server publishes no unit for, where the override is the only way to keep a graph angular while the instrument is idle. Closes halos-org/skip#368 --- .../graph-data-options.component.html | 4 +- .../graph-data-options.component.spec.ts | 54 ++++++++++++++++++- .../graph-data-options.component.ts | 20 +++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/app/widget-config/graph-data-options/graph-data-options.component.html b/src/app/widget-config/graph-data-options/graph-data-options.component.html index 7f98e9c4..b7e476cc 100644 --- a/src/app/widget-config/graph-data-options/graph-data-options.component.html +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.html @@ -62,7 +62,7 @@ - @if (datachartAngleRange(); as angleRangeCtrl) { + @if (angleRangeControl(); as angleRangeCtrl) { Angle display range @@ -70,7 +70,7 @@ Signed (-180° to 180°) Compass (0° to 360°) - For angular paths. Choose Signed for values that go negative (e.g. wind shift). + Choose Signed for values that go negative (e.g. wind shift). }
diff --git a/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts b/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts index 71eae3ee..66414291 100644 --- a/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { UntypedFormControl } from '@angular/forms'; +import { FormControl, UntypedFormControl } from '@angular/forms'; import { GraphDataOptionsComponent } from './graph-data-options.component'; import { DataService } from '../../core/services/data.service'; import { UnitsService } from '../../core/services/units.service'; @@ -13,9 +13,11 @@ describe('GraphDataOptionsComponent', () => { let component: GraphDataOptionsComponent; let fixture: ComponentFixture; let pathObject: Partial | null; + let pathUnits: Record; beforeEach(async () => { pathObject = null; + pathUnits = {}; await TestBed.configureTestingModule({ imports: [GraphDataOptionsComponent], providers: [ @@ -24,6 +26,7 @@ describe('GraphDataOptionsComponent', () => { useValue: { getPathsAndMetaByType: () => [], getPathObject: () => pathObject, + getPathUnitType: (path: string) => pathUnits[path] ?? null, }, }, { @@ -172,4 +175,53 @@ describe('GraphDataOptionsComponent', () => { vi.useRealTimers(); } }); + + const mountWithAngleRange = (path: string) => { + const fx = TestBed.createComponent(GraphDataOptionsComponent); + const set = fx.componentRef.setInput.bind(fx.componentRef) as (k: string, v: unknown) => void; + set('filterSelfPaths', new UntypedFormControl(false)); + set('datachartPath', new UntypedFormControl(path)); + set('datachartSource', new UntypedFormControl({ value: '', disabled: true })); + set('datachartAngleRange', new FormControl<'signed' | 'direction' | null>(null)); + set('timeScale', new UntypedFormControl('')); + set('period', new UntypedFormControl('')); + fx.detectChanges(); + return fx; + }; + + const showsAngleRange = (fx: ComponentFixture) => + ((fx.nativeElement as HTMLElement).textContent ?? '').includes('Angle display range'); + + it('hides the angle range for a path with a non-angular unit (#368)', () => { + pathUnits['self.propulsion.port.temperature'] = 'K'; + expect(showsAngleRange(mountWithAngleRange('self.propulsion.port.temperature'))).toBe(false); + }); + + it('offers the angle range for a radian path (#368)', () => { + pathUnits['self.environment.wind.angleApparent'] = 'rad'; + expect(showsAngleRange(mountWithAngleRange('self.environment.wind.angleApparent'))).toBe(true); + }); + + it('offers the angle range while the path publishes no unit (#368)', () => { + // An idle instrument publishes no metadata, and the override is then the only way to keep a + // graph angular — see resolveAngleDomain. + expect(showsAngleRange(mountWithAngleRange('self.environment.wind.angleApparent'))).toBe(true); + }); + + it('hides the angle range once the path changes to a non-angular one (#368)', async () => { + vi.useFakeTimers(); + try { + pathUnits['self.environment.wind.angleApparent'] = 'rad'; + pathUnits['self.propulsion.port.temperature'] = 'K'; + const fx = mountWithAngleRange('self.environment.wind.angleApparent'); + expect(showsAngleRange(fx)).toBe(true); + + fx.componentInstance.datachartPath().setValue('self.propulsion.port.temperature'); + await vi.advanceTimersByTimeAsync(400); + fx.detectChanges(); + expect(showsAngleRange(fx)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/app/widget-config/graph-data-options/graph-data-options.component.ts b/src/app/widget-config/graph-data-options/graph-data-options.component.ts index 6e814a46..f2ecb73a 100644 --- a/src/app/widget-config/graph-data-options/graph-data-options.component.ts +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.ts @@ -38,7 +38,21 @@ export class GraphDataOptionsComponent implements OnInit { protected pathWarning = signal(null); /** The path `pathSources` was last built for, so re-deriving needs a real path change. */ private _sourcesForPath: string | null = null; + /** Base unit of the selected path, or null when the server publishes none for it. */ + private pathUnit = signal(null); protected maxDuration = computed(() => this.timeScale().value === 'day' ? 365 : 60); + /** + * The angle range only changes how radian values are read, so it is offered for radian paths and + * withheld from every path with another unit. An unknown unit keeps it: metadata is missing while + * the producing instrument is idle, and the override is then the only way to keep the graph + * angular (see `resolveAngleDomain`). + */ + protected angleRangeControl = computed | undefined>(() => { + const control = this.datachartAngleRange(); + if (!control) return undefined; + const unit = this.pathUnit(); + return unit === null || unit === 'rad' ? control : undefined; + }); ngOnInit(): void { this.refreshNumericPaths(); @@ -53,6 +67,7 @@ export class GraphDataOptionsComponent implements OnInit { this.filteredNumericPaths.set(this.numericPaths().filter(p => p.path.toLowerCase().includes(term))); } this.refreshPathWarning(value); + this.refreshPathUnit(value); // An unoffered path never appears in the autocomplete, so typing is the only way to enter one // and (optionSelected) never fires. Re-derive here too, or the previous path's concrete source // stays pinned to a path that will never fill it. @@ -66,6 +81,7 @@ export class GraphDataOptionsComponent implements OnInit { this.datachartPath().updateValueAndValidity({ emitEvent: false }); const currentPath = this.datachartPath()?.value; this.refreshPathWarning(currentPath); + this.refreshPathUnit(currentPath); this.setPathSourcesFor(currentPath); this.setInitFormState(); } @@ -80,6 +96,10 @@ export class GraphDataOptionsComponent implements OnInit { })); } + private refreshPathUnit(path: string | null): void { + this.pathUnit.set(path ? this.data.getPathUnitType(path) : null); + } + /** * Build the Source list for `path`, keeping the select usable when the server is not sending that * path: its sources are unknown, so surface the stored one alongside "Any" rather than leaving an From 65de53a677ca2e0dd33047aefb493dcc938bd81f Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 23:00:20 +0300 Subject: [PATCH 2/2] fix(graph): read the path unit ahead of the path debounce The unit decides whether the angle range renders at all, so reading it inside the 300 ms debounced subscriber left the control editable for that long on a path it does not belong to. --- .../graph-data-options.component.spec.ts | 7 ++++++- .../graph-data-options.component.ts | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts b/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts index 66414291..b3155c69 100644 --- a/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts @@ -208,7 +208,9 @@ describe('GraphDataOptionsComponent', () => { expect(showsAngleRange(mountWithAngleRange('self.environment.wind.angleApparent'))).toBe(true); }); - it('hides the angle range once the path changes to a non-angular one (#368)', async () => { + it('hides the angle range as soon as the path changes to a non-angular one (#368)', async () => { + // Behind the 300 ms path debounce the control would stay editable for a path it does not + // belong to. vi.useFakeTimers(); try { pathUnits['self.environment.wind.angleApparent'] = 'rad'; @@ -217,6 +219,9 @@ describe('GraphDataOptionsComponent', () => { expect(showsAngleRange(fx)).toBe(true); fx.componentInstance.datachartPath().setValue('self.propulsion.port.temperature'); + fx.detectChanges(); + expect(showsAngleRange(fx)).toBe(false); + await vi.advanceTimersByTimeAsync(400); fx.detectChanges(); expect(showsAngleRange(fx)).toBe(false); diff --git a/src/app/widget-config/graph-data-options/graph-data-options.component.ts b/src/app/widget-config/graph-data-options/graph-data-options.component.ts index f2ecb73a..dcf4577f 100644 --- a/src/app/widget-config/graph-data-options/graph-data-options.component.ts +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.ts @@ -10,7 +10,7 @@ import { DataService } from '../../core/services/data.service'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatInputModule } from '@angular/material/input'; import { IPathMetaData, ISkPathData } from '../../core/interfaces/app-interfaces'; -import { debounceTime } from 'rxjs'; +import { debounceTime, tap } from 'rxjs'; import { RouterLink } from '@angular/router'; import { pathRequiredValidator, pathSlotWarning } from '../../core/utils/path-validators.util'; @@ -58,7 +58,13 @@ export class GraphDataOptionsComponent implements OnInit { this.refreshNumericPaths(); this.filteredNumericPaths.set(this.numericPaths()); - this.datachartPath().valueChanges.pipe(debounceTime(300), takeUntilDestroyed(this._destroyRef)).subscribe(value => { + // The unit gates whether the angle range is shown at all, so it is read ahead of the debounce: + // behind it the control stays editable for 300 ms on a path it does not belong to. + this.datachartPath().valueChanges.pipe( + tap(value => this.refreshPathUnit(value)), + debounceTime(300), + takeUntilDestroyed(this._destroyRef) + ).subscribe(value => { this.refreshNumericPaths(); const term = (value || '').toLowerCase().trim(); if (!term) { @@ -67,7 +73,6 @@ export class GraphDataOptionsComponent implements OnInit { this.filteredNumericPaths.set(this.numericPaths().filter(p => p.path.toLowerCase().includes(term))); } this.refreshPathWarning(value); - this.refreshPathUnit(value); // An unoffered path never appears in the autocomplete, so typing is the only way to enter one // and (optionSelected) never fires. Re-derive here too, or the previous path's concrete source // stays pinned to a path that will never fill it.