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
22 changes: 15 additions & 7 deletions src/app/widgets/widget-data-graph/widget-data-graph.component.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
@if (hasPath()) {
<canvas #widgetDataGraph style="z-index: inherit;"></canvas>
@if (historyUnavailable()) {
<div class="empty-state history-unavailable">
<p>History data unavailable</p>
<p class="hint">A Signal K history provider is required for trend graphs.</p>
</div>
}
<div class="graph-header">
@if (headerLabel()) {
<span class="graph-header-label" [style.color]="headerColors().label">{{ headerLabel() }}</span>
}
<span class="graph-header-reading" [style.color]="headerColors().reading">{{ reading() }}</span>
</div>
<div class="graph-canvas">
<canvas #widgetDataGraph style="z-index: inherit;"></canvas>
@if (historyUnavailable()) {
<div class="empty-state history-unavailable">
<p>History data unavailable</p>
<p class="hint">A Signal K history provider is required for trend graphs.</p>
</div>
}
</div>
} @else {
<div class="empty-state">
<p>Ready to configure</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
:host {
display: block;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
position: relative;
}

// The label and the reading share a row while both fit. Past that, wrapping moves the reading to
// its own row (still right-aligned, through the auto margin) instead of drawing the two over each
// other, and a label too long for a row of its own ellipsizes rather than running off the widget.
.graph-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 8px;
padding: 2px 6px 2px 8px;
flex: none;
}

.graph-header-label {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 22px;
line-height: 1.1;
}

.graph-header-reading {
margin-left: auto;
flex: none;
font-size: 32px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}

.graph-canvas {
position: relative;
flex: 1 1 auto;
min-height: 0;
}

// #64 history engine: overlay shown over the (empty) graph when no history provider is available.
.history-unavailable {
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,11 @@ describe('WidgetDataGraphComponent', () => {
expect(averageLine?.label?.content).toBe('5.0');
});

const readTitle = (): string | undefined =>
(fixture.componentInstance.lineChartOptions.plugins as unknown as {
title?: { text?: string };
}).title?.text;
const readHeader = (selector: '.graph-header-label' | '.graph-header-reading'): string | null => {
const el = (fixture.nativeElement as HTMLElement).querySelector(selector);
return el ? (el.textContent ?? '').trim() : null;
};
const readTitle = (): string | null => readHeader('.graph-header-reading');

it('labels the value from the path-resolved measure, not the stored convertUnitTo', async () => {
const emissions$ = new Subject<IGraphDatapoint>();
Expand All @@ -160,17 +161,12 @@ describe('WidgetDataGraphComponent', () => {
emissions$.next({ timestamp: 1000, data: { value: 5 } });

expect(resolveSpy).toHaveBeenCalledWith('self.navigation.speedOverGround');
fixture.detectChanges();
const title = readTitle();
expect(title).toContain('knots');
expect(title).not.toContain('celsius');
});

interface SubtitleState { display?: boolean; text?: string }
const readSubtitle = (): SubtitleState | undefined =>
(fixture.componentInstance.lineChartOptions.plugins as unknown as {
subtitle?: SubtitleState;
}).subtitle;

interface AxisState {
type?: string;
ticks?: { mirror?: boolean; padding?: number; textStrokeColor?: string; textStrokeWidth?: number; color?: string };
Expand All @@ -184,28 +180,44 @@ describe('WidgetDataGraphComponent', () => {
await setup(makeConfig({ displayName: 'SOG', timeScale: 'second', period: 30, showTimeScale: true }));

// The axis title cost the graph a whole row to say this; the label says it in four characters.
// Nothing else carries the window, so the suffix is the only indicator the user has left.
expect(readSubtitle()?.text).toContain('SOG (30 s)');
expect(readHeader('.graph-header-label')).toBe('SOG (30 s)');
expect(readAxis('x')?.title?.display ?? false).toBe(false);
});

it('keeps the graph window on screen when the widget label is hidden', async () => {
// The removed axis title rendered independently of Show Label, so hiding the name must not
// take the window with it.
it('takes the graph window down with the widget label (#602)', async () => {
// A window with no name in front of it reads as a stray fragment, and Show Label is what the
// user reached for to get rid of it.
await setup(makeConfig({ displayName: 'SOG', timeScale: 'minute', period: 10, showLabel: false }));

const subtitle = readSubtitle();
expect(subtitle?.display).toBe(true);
expect(subtitle?.text?.trim()).toBe('(10 min)');
expect(subtitle?.text).not.toContain('SOG');
expect(readHeader('.graph-header-label')).toBeNull();
});

it('lays out the label and the reading as siblings, never overlaid (#602)', async () => {
// Two Chart.js plugin blocks forced onto one row could not see each other's width and drew
// through each other on a narrow widget. One flex row wraps instead.
const emissions$ = new Subject<IGraphDatapoint>();
historyMock.getBackfillThenLive.mockReturnValue(emissions$);
await setup(makeConfig({ displayName: 'SOG', timeScale: 'second', period: 30, numDecimal: 1 }));

emissions$.next({ timestamp: 1000, data: { value: 7 } });
fixture.detectChanges();

const header = (fixture.nativeElement as HTMLElement).querySelector('.graph-header');
expect(header?.querySelector('.graph-header-label')?.textContent?.trim()).toBe('SOG (30 s)');
expect(header?.querySelector('.graph-header-reading')?.textContent?.trim()).toBe('7.0 knots');
const plugins = fixture.componentInstance.lineChartOptions.plugins as unknown as {
title?: { display?: boolean }; subtitle?: { display?: boolean };
};
expect(plugins.title?.display).toBe(false);
expect(plugins.subtitle?.display).toBe(false);
});

it('falls back to the bare label for a legacy time scale with no abbreviation', async () => {
// Stored configs can still carry the pre-migration TimeScaleFormat members. A full Record or a
// `?? format` default would render "SOG (30 Last 30 Minutes)" on those.
await setup(makeConfig({ displayName: 'SOG', timeScale: 'Last 30 Minutes', period: 30 }));

expect(readSubtitle()?.text?.trim()).toBe('SOG');
expect(readHeader('.graph-header-label')).toBe('SOG');
});

it.each([
Expand Down
61 changes: 24 additions & 37 deletions src/app/widgets/widget-data-graph/widget-data-graph.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,23 @@ export class WidgetDataGraphComponent implements OnDestroy {
// True when no history provider is available, so the widget shows the
// "history unavailable" empty state instead of a blank graph (no recorder live-only fallback).
protected historyUnavailable = signal<boolean>(false);
/** The reading shown in the header, converted and unit-suffixed; empty until data arrives. */
protected reading = signal<string>('');
/**
* The widget label carries the graph window ("SOG (30 s)"), which a time-axis title would charge
* the graph a whole row to say. Both hide together: a window on its own reads as a stray fragment.
*/
protected headerLabel = computed<string>(() => {
const cfg = this.runtime.options();
if (!cfg?.showLabel) return '';
const suffix = TIME_SCALE_SUFFIX[cfg.timeScale as TimeScaleFormat];
// A config still carrying a legacy TimeScaleFormat has no abbreviation, so it gets the bare name.
return suffix ? `${cfg.displayName ?? ''} (${cfg.period} ${suffix})` : `${cfg.displayName ?? ''}`;
});
protected headerColors = computed<{ label: string; reading: string }>(() => {
const colors = this.getThemeColors();
return { label: colors.chartLabel, reading: colors.chartValue };
});
private datachartPath = computed<string | null>(() => this.runtime.options()?.datachartPath ?? null);
// Reactive resolved measure: resolvePathMeasure() reads a non-signal meta cache, so it is folded
// through the path's meta subject here to re-emit when the server's units/displayUnits land late or
Expand Down Expand Up @@ -216,6 +233,7 @@ export class WidgetDataGraphComponent implements OnDestroy {
this.lastAverageValue = NaN;
this.lastMinimumValue = NaN;
this.lastMaximumValue = NaN;
this.reading.set('');
this.historyUnavailable.set(false);

// Synthesize the config + cadence the axis/streaming options expect, derived from the widget's
Expand Down Expand Up @@ -270,12 +288,6 @@ export class WidgetDataGraphComponent implements OnDestroy {
textStrokeWidth: 3
};
const insideGrid = { display: true, drawTicks: false, color: theme.contrastDimmer };
// The graph window rides on the widget label instead of a time-axis title, which would cost the
// graph a whole row to say what the label can say in four characters. The axis title rendered
// independently of the label toggle, so the subtitle carries the window on its own when the
// label is off rather than taking it down with the name.
const suffix = TIME_SCALE_SUFFIX[seriesConfig.timeScaleFormat];
const windowSuffix = suffix ? ` (${seriesConfig.period} ${suffix})` : '';

if (cfg.verticalChart) {
this.lineChartOptions.scales = {
Expand Down Expand Up @@ -381,33 +393,11 @@ export class WidgetDataGraphComponent implements OnDestroy {
}
}
this.lineChartOptions.plugins = {
title: {
display: true,
align: "end",
padding: {
top: 3,
bottom: 0
},
text: "",
font: {
size: 32,

},
color: this.getThemeColors().chartValue
},
subtitle: {
display: cfg.showLabel || !!windowSuffix,
align: "start",
padding: {
top: -35,
bottom: 20
},
text: ` ${cfg.showLabel ? `${cfg.displayName}${windowSuffix}` : windowSuffix.trimStart()}`,
font: {
size: 22,
},
color: this.getThemeColors().chartLabel
},
// The label and the reading are laid out by the template, not by Chart.js: two plugin blocks
// overlaid on one row cannot see each other's width, so they collided on any widget too narrow
// for both. The template wraps instead.
title: { display: false },
subtitle: { display: false },
annotation: {
annotations: {
minimumLine: {
Expand Down Expand Up @@ -839,10 +829,7 @@ export class WidgetDataGraphComponent implements OnDestroy {
const trackValue: number = cfg.trackAgainstAverage ? (point.data.sma ?? point.data.value) : point.data.value;
const convertedTrack = this.unitsService.convertToUnit(measure, trackValue);
if (convertedTrack !== null && Number.isFinite(convertedTrack)) {
const titlePlugin = this.chart.options.plugins?.title;
if (titlePlugin) {
titlePlugin.text = `${convertedTrack.toFixed(cfg.numDecimal)} ${this.unitsService.getUnitDisplaySymbol(measure)} `;
}
this.reading.set(`${convertedTrack.toFixed(cfg.numDecimal)} ${this.unitsService.getUnitDisplaySymbol(measure)}`.trim());
}

// A missing rolling stat (insufficient history yet) converts like the pre-existing code's
Expand Down
Loading