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.

-
- } +
+ @if (headerLabel()) { + {{ headerLabel() }} + } + {{ reading() }} +
+
+ + @if (historyUnavailable()) { +
+

History data unavailable

+

A Signal K history provider is required for trend graphs.

+
+ } +
} @else {

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