diff --git a/src/app/widgets/widget-data-graph/widget-data-graph.component.html b/src/app/widgets/widget-data-graph/widget-data-graph.component.html
index 98423072..376e69c2 100644
--- a/src/app/widgets/widget-data-graph/widget-data-graph.component.html
+++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.html
@@ -1,11 +1,19 @@
@if (hasPath()) {
-
- @if (historyUnavailable()) {
-
-
History data unavailable
-
A Signal K history provider is required for trend graphs.
-
- }
+
+
Ready to configure
diff --git a/src/app/widgets/widget-data-graph/widget-data-graph.component.scss b/src/app/widgets/widget-data-graph/widget-data-graph.component.scss
index c30459cd..e4c05edc 100644
--- a/src/app/widgets/widget-data-graph/widget-data-graph.component.scss
+++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.scss
@@ -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;
diff --git a/src/app/widgets/widget-data-graph/widget-data-graph.component.spec.ts b/src/app/widgets/widget-data-graph/widget-data-graph.component.spec.ts
index b2773f8f..588bc9fe 100644
--- a/src/app/widgets/widget-data-graph/widget-data-graph.component.spec.ts
+++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.spec.ts
@@ -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
();
@@ -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 };
@@ -184,20 +180,36 @@ 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();
+ 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 () => {
@@ -205,7 +217,7 @@ describe('WidgetDataGraphComponent', () => {
// `?? 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([
diff --git a/src/app/widgets/widget-data-graph/widget-data-graph.component.ts b/src/app/widgets/widget-data-graph/widget-data-graph.component.ts
index c18fd090..cd9f8196 100644
--- a/src/app/widgets/widget-data-graph/widget-data-graph.component.ts
+++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.ts
@@ -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(false);
+ /** The reading shown in the header, converted and unit-suffixed; empty until data arrives. */
+ protected reading = signal('');
+ /**
+ * 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(() => {
+ 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(() => 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
@@ -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
@@ -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 = {
@@ -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: {
@@ -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