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
288 changes: 1 addition & 287 deletions .betterer.results

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions src/app/core/services/data.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,41 @@ describe('DataService', () => {

expect(service.getPathObject('self.navigation.anchor.position')!.type).toBeUndefined();
});

it('files a context-less delta value under self (empty context assumes self)', () => {
const values: unknown[] = [];
service
.subscribePath('self.navigation.speedOverGround', 'default')
.subscribe(update => values.push(update.data.value));

// A delta with no context must land on the self root, not an "undefined.<path>" key (#209).
dataPathUpdates$.next({
context: undefined,
path: 'navigation.speedOverGround',
source: 'default',
timestamp: '2026-01-01T00:00:01.000Z',
value: 3.2,
});

expect(values.at(-1)).toBe(3.2);
});

it('keeps a foreign-context value under its own root, not self', () => {
const selfValues: unknown[] = [];
const foreignValues: unknown[] = [];
service.subscribePath('self.navigation.speedOverGround', 'default').subscribe(u => selfValues.push(u.data.value));
service.subscribePath('vessels.abc.navigation.speedOverGround', 'default').subscribe(u => foreignValues.push(u.data.value));

dataPathUpdates$.next({
context: 'vessels.abc',
path: 'navigation.speedOverGround',
source: 'default',
timestamp: '2026-01-01T00:00:01.000Z',
value: 7,
});

expect(foreignValues.at(-1)).toBe(7);
// Self stays at its initial null emission; the foreign value never leaks onto it.
expect(selfValues).not.toContain(7);
});
});
8 changes: 6 additions & 2 deletions src/app/core/services/data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,12 @@ export class DataService implements OnDestroy {
}

private setPathContext(context: string | undefined, path: string): string {
const finalPath = context !== this._selfUrn ? `${context}.${path}` : `${SELFROOTDEF}.${path}`;
return finalPath;
// An empty/absent context means "self" (per the ISkPathData/IMeta contract), same as a
// context that already equals the learned self URN. Anything else is a foreign root node.
if (!context || context === this._selfUrn) {
return `${SELFROOTDEF}.${path}`;
}
return `${context}.${path}`;
}

/**
Expand Down
21 changes: 20 additions & 1 deletion src/app/widgets/gauge-steel/gauge-steel.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GaugeSteelComponent } from './gauge-steel.component';
import { UnitsService } from '../../core/services/units.service';
import { States } from '../../core/interfaces/signalk-interfaces';

describe('GaugeSteelComponent', () => {
let component: GaugeSteelComponent;
Expand Down Expand Up @@ -31,4 +32,22 @@ describe('GaugeSteelComponent', () => {
it('should be created', () => {
expect(component).toBeTruthy();
});

it('renders an open-ended low-alarm zone as a band clamped to the gauge minimum', () => {
const sectionSpy = vi.fn((lower: number, upper: number, color: string) => ({ lower, upper, color }));
(globalThis as unknown as { steelseries: Record<string, unknown> }).steelseries.Section = sectionSpy;

fixture.componentRef.setInput('minValue', 10);
fixture.componentRef.setInput('maxValue', 15);
fixture.componentRef.setInput('units', 'V');
fixture.componentRef.setInput('themeColors', { zoneAlarm: 'red' });
// Low alarm with no lower bound — "alarm below 11.5", the common open-ended zone.
fixture.componentRef.setInput('zones', [{ upper: 11.5, state: States.Alarm }]);

(component as unknown as { buildOptions: () => void }).buildOptions();

// The unset lower bound clamps to the gauge minimum (10) instead of producing a
// NaN section that does not draw; the upper is converted (identity mock -> 11.5).
expect(sectionSpy).toHaveBeenCalledWith(10, 11.5, 'red');
});
});
81 changes: 43 additions & 38 deletions src/app/widgets/gauge-steel/gauge-steel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,21 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy {
private readonly canvasService = inject(CanvasService);
protected readonly canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('gaugeCanvas');

readonly widgetUUID = input<string>(undefined);
readonly subType = input<string>(undefined); // linear or radial
readonly barGauge = input<boolean>(undefined);
readonly radialSize = input<string>(undefined);
readonly backgroundColor = input<string>(undefined);
readonly frameColor = input<string>(undefined);
readonly minValue = input<number>(undefined);
readonly maxValue = input<number>(undefined);
readonly decimals = input<number>(undefined);
readonly zones = input<ISkZone[]>(undefined);
readonly title = input<string>(undefined);
readonly units = input<string>(undefined);
readonly value = input<number>(undefined);
readonly widgetUUID = input<string>();
readonly subType = input<string>(); // linear or radial
readonly barGauge = input<boolean>();
readonly radialSize = input<string>();
readonly backgroundColor = input<string>();
readonly frameColor = input<string>();
readonly minValue = input<number>();
readonly maxValue = input<number>();
readonly decimals = input<number>();
readonly zones = input<ISkZone[]>();
readonly title = input<string>();
readonly units = input<string>();
readonly value = input<number>();
// eslint-disable-next-line @angular-eslint/no-input-rename
readonly theme = input<ITheme>(undefined, { alias: "themeColors" });
readonly theme = input<ITheme | null>(null, { alias: "themeColors" });

private gaugeStarted = false;
private gauge;
Expand Down Expand Up @@ -121,61 +121,64 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy {
const zones = this.zones();
if (zones) {

const sections = [];
const areas = [];
const sections: unknown[] = [];
const areas: unknown[] = [];

// Sort zones based on lower value
const sortedZones = [...zones].sort((a, b) => a.lower - b.lower);
const sortedZones = [...zones].sort((a, b) => (a.lower ?? -Infinity) - (b.lower ?? -Infinity));

for (const zone of sortedZones) {
let lower: number = null;
let upper: number = null;
let lower: number | null | undefined = null;
let upper: number | null | undefined = null;

// Zones still draw before the theme loads (transparent fill), so the gauge
// shows its zone geometry rather than skipping the render as the other widgets do.
let color: string;
switch (zone.state) {
case States.Emergency:
color = this.theme().zoneEmergency;
color = this.theme()?.zoneEmergency ?? "rgba(0,0,0,0)";
break;
case States.Alarm:
color = this.theme().zoneAlarm;
color = this.theme()?.zoneAlarm ?? "rgba(0,0,0,0)";
break;
case States.Warn:
color = this.theme().zoneWarn;
color = this.theme()?.zoneWarn ?? "rgba(0,0,0,0)";
break;
case States.Alert:
color = this.theme().zoneAlert;
color = this.theme()?.zoneAlert ?? "rgba(0,0,0,0)";
break;
case States.Nominal:
color = this.theme().zoneNominal;
color = this.theme()?.zoneNominal ?? "rgba(0,0,0,0)";
break;
default:
color = "rgba(0,0,0,0)";
}

// Perform Units conversions on zone range
const units = this.units();
const units = this.units() ?? '';
if (units == "ratio") {
lower = zone.lower;
upper = zone.upper;
} else {
lower = this.unitsService.convertToUnit(units, zone.lower);
upper = this.unitsService.convertToUnit(units, zone.upper);
lower = zone.lower !== undefined ? this.unitsService.convertToUnit(units, zone.lower) : undefined;
upper = zone.upper !== undefined ? this.unitsService.convertToUnit(units, zone.upper) : undefined;
}

// Skip zones that are completely outside the gauge range
if (upper < this.minValue() || lower > this.maxValue()) {
// (an unset bound means "unbounded" on that side, so it never triggers the skip)
if ((upper ?? Infinity) < (this.minValue() ?? -Infinity) || (lower ?? -Infinity) > (this.maxValue() ?? Infinity)) {
continue;
}

// If lower or upper are null, set them to minValue or maxValue
lower = lower !== null ? lower : this.minValue();
upper = upper !== null ? upper : this.maxValue();
// If lower or upper are null or unset, set them to minValue or maxValue
lower = lower ?? this.minValue();
upper = upper ?? this.maxValue();

// Ensure lower does not go below minValue
lower = Math.max(lower, this.minValue());
lower = Math.max(lower ?? 0, this.minValue() ?? 0);

// Ensure upper does not exceed maxValue
if (upper > this.maxValue()) {
if ((upper ?? Infinity) > (this.maxValue() ?? Infinity)) {
upper = this.maxValue();
sections.push(steelseries.Section(lower, upper, color));
break;
Expand All @@ -190,11 +193,13 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy {
}

//Colors
if (SteelBackgroundColors[this.backgroundColor()]) {
this.gaugeOptions['backgroundColor'] = SteelBackgroundColors[this.backgroundColor()];
const backgroundColor = this.backgroundColor();
if (backgroundColor && SteelBackgroundColors[backgroundColor]) {
this.gaugeOptions['backgroundColor'] = SteelBackgroundColors[backgroundColor];
}
if (SteelFrameColors[this.frameColor()]) {
this.gaugeOptions['frameDesign'] = SteelFrameColors[this.frameColor()];
const frameColor = this.frameColor();
if (frameColor && SteelFrameColors[frameColor]) {
this.gaugeOptions['frameDesign'] = SteelFrameColors[frameColor];
}
if (this.barGauge()) {
this.gaugeOptions['valueColor'] = steelseries.ColorDef.GREEN;
Expand All @@ -207,7 +212,7 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy {
this.gaugeOptions['ledVisible'] = false;
}

private setGaugeType(radialSize: string): string {
private setGaugeType(radialSize: string | undefined): string {
switch (radialSize) {
case 'quarter':
return steelseries.GaugeType.TYPE1;
Expand Down
2 changes: 1 addition & 1 deletion src/app/widgets/minichart/minichart.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class MinichartComponent implements OnDestroy {
public yScaleMin: number = null;
public yScaleMax: number = null;
public inverseYAxis = false;
public verticalChart = null;
public verticalChart: boolean | null = null;
protected unitsService = inject(UnitsService);
private readonly historyStream = inject(HistoryChartStreamService);
private readonly ngZone = inject(NgZone);
Expand Down
71 changes: 38 additions & 33 deletions src/app/widgets/svg-boolean-button/svg-boolean-button.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import { IDimensions } from '../widget-boolean-switch/widget-boolean-switch.comp
})
export class SvgBooleanButtonComponent implements DoCheck, OnDestroy {
// eslint-disable-next-line @angular-eslint/no-input-rename
readonly data = input<IDynamicControl>(null, { alias: "controlData" });
readonly theme = input<ITheme>(null);
readonly data = input<IDynamicControl | null>(null, { alias: "controlData" });
readonly theme = input<ITheme | null>(null);
readonly dimensions = input.required<IDimensions>();
readonly toggleClick = output<IDynamicControl>();

private toggleOff = "0 35 180 35";
private toggleOn = "0 0 180 35";
private oldTheme: ITheme = null;
private oldTheme: ITheme | null = null;

private holdTimeoutId: ReturnType<typeof setTimeout> | null = null; // delay before starting momentary emit
private emitIntervalId: ReturnType<typeof setInterval> | null = null; // repeating emit while pressed
Expand All @@ -29,14 +29,15 @@ export class SvgBooleanButtonComponent implements DoCheck, OnDestroy {
private pointerStartY = 0;

public viewBox: string = this.toggleOff;
public labelColorEnabled = null;
public labelColorDisabled = null;
public valueColor = null;
public labelColorEnabled: string | null = null;
public labelColorDisabled: string | null = null;
public valueColor: string | null = null;
private ctrlColor = '';

ngDoCheck(): void {
this.viewBox = this.pressed ? this.toggleOn : this.toggleOff;
const data = this.data();
if (!data) return;
if (data.color != this.ctrlColor) {
this.ctrlColor = data.color;
this.getColors(data.color);
Expand All @@ -45,7 +46,7 @@ export class SvgBooleanButtonComponent implements DoCheck, OnDestroy {
const theme = this.theme();
if (this.oldTheme != theme) {
this.oldTheme = theme
this.getColors(this.data().color);
this.getColors(data.color);
}
}

Expand All @@ -61,7 +62,9 @@ export class SvgBooleanButtonComponent implements DoCheck, OnDestroy {
// Momentary mode
this.pressed = true;

const state: IDynamicControl = cloneDeep(this.data());
const data = this.data();
if (!data) return;
const state: IDynamicControl = cloneDeep(data);
state.value = this.pressed;

// Start emitting the state every 100ms
Expand Down Expand Up @@ -110,51 +113,53 @@ export class SvgBooleanButtonComponent implements DoCheck, OnDestroy {
}

private getColors(color: string): void {
const theme = this.theme();
if (!theme) return;
switch (color) {
case "contrast":
this.labelColorEnabled = 'black';
this.labelColorDisabled = this.theme().contrastDim;
this.valueColor = this.theme().contrast;
this.labelColorDisabled = theme.contrastDim;
this.valueColor = theme.contrast;
break;
case "blue":
this.labelColorEnabled = this.theme().contrast;
this.labelColorDisabled = this.theme().blueDim;
this.valueColor = this.theme().blue;
this.labelColorEnabled = theme.contrast;
this.labelColorDisabled = theme.blueDim;
this.valueColor = theme.blue;
break;
case "green":
this.labelColorEnabled = this.theme().contrast;
this.labelColorDisabled = this.theme().greenDim;
this.valueColor = this.theme().green;
this.labelColorEnabled = theme.contrast;
this.labelColorDisabled = theme.greenDim;
this.valueColor = theme.green;
break;
case "pink":
this.labelColorEnabled = this.theme().contrast;
this.labelColorDisabled = this.theme().pinkDim;
this.valueColor = this.theme().pink;
this.labelColorEnabled = theme.contrast;
this.labelColorDisabled = theme.pinkDim;
this.valueColor = theme.pink;
break;
case "orange":
this.labelColorEnabled = this.theme().contrast;
this.labelColorDisabled = this.theme().orangeDim;
this.valueColor = this.theme().orange;
this.labelColorEnabled = theme.contrast;
this.labelColorDisabled = theme.orangeDim;
this.valueColor = theme.orange;
break;
case "purple":
this.labelColorEnabled = this.theme().contrast;
this.labelColorDisabled = this.theme().purpleDim;
this.valueColor = this.theme().purple;
this.labelColorEnabled = theme.contrast;
this.labelColorDisabled = theme.purpleDim;
this.valueColor = theme.purple;
break;
case "grey":
this.labelColorEnabled = this.theme().contrast;
this.labelColorDisabled = this.theme().greyDim;
this.valueColor = this.theme().grey;
this.labelColorEnabled = theme.contrast;
this.labelColorDisabled = theme.greyDim;
this.valueColor = theme.grey;
break;
case "yellow":
this.labelColorEnabled = this.theme().contrast;
this.labelColorDisabled = this.theme().yellowDim;
this.valueColor = this.theme().yellow;
this.labelColorEnabled = theme.contrast;
this.labelColorDisabled = theme.yellowDim;
this.valueColor = theme.yellow;
break;
default:
this.labelColorEnabled = 'black';
this.labelColorDisabled = this.theme().contrastDim;
this.valueColor = this.theme().contrast;
this.labelColorDisabled = theme.contrastDim;
this.valueColor = theme.contrast;
break;
}
}
Expand Down
Loading
Loading