From 3da92188ba7659b2036f9f39d09b941a612bb52b Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 17:17:41 +0300 Subject: [PATCH 1/6] refactor(graph): rename time-series code identifiers to graph Time-series visualizations are graphs; "chart" is reserved for the nautical sense and for chart.js's own API. Renames the widget, dialog, config-panel, service and util modules, their exported classes and interfaces, the non-persisted selectors, the CSS class names and the icon sprite id. Also removes the KIP "dataset" concept from code: the app-level dataset registry and its client-side recorder went away in the v12 config upgrade, so the interfaces named after the removed DatasetService now describe graph series. chart.js's own datasets keep their name. The widget-data-chart and widget-windtrends-chart selectors are the persisted widget type in stored dashboards, so they keep the old spelling until a config migration renames them (#592). --- .../connection-status.component.ts | 12 +- ...idget-history-graph-dialog.component.html} | 6 +- ...idget-history-graph-dialog.component.scss} | 4 +- ...et-history-graph-dialog.component.spec.ts} | 62 +++---- .../widget-history-graph-dialog.component.ts} | 48 ++--- .../widget-host2.component.spec.ts | 8 +- .../widget-host2/widget-host2.component.ts | 2 +- ...s => electrical-history-graph.contract.ts} | 14 +- .../contracts/skip-series-contract.spec.ts | 6 +- .../core/contracts/skip-series-contract.ts | 4 +- ...interfaces.ts => graph-data.interfaces.ts} | 21 +-- src/app/core/interfaces/widgets-interface.ts | 52 +++--- .../configuration-upgrade.service.spec.ts | 2 +- .../services/configuration-upgrade.service.ts | 4 +- .../dashboard-history-series-sync.service.ts | 10 +- src/app/core/services/data.service.ts | 4 +- src/app/core/services/dialog.service.ts | 14 +- .../services/history-api-client.service.ts | 2 +- ...s => history-graph-stream.service.spec.ts} | 164 +++++++++--------- ...ice.ts => history-graph-stream.service.ts} | 54 +++--- ...> history-to-graph-mapper.service.spec.ts} | 12 +- ....ts => history-to-graph-mapper.service.ts} | 58 +++---- src/app/core/services/units.service.spec.ts | 2 +- src/app/core/services/widget.service.ts | 22 ++- src/app/core/utils/angle-domain.util.spec.ts | 4 +- src/app/core/utils/angle-domain.util.ts | 10 +- ....util.spec.ts => graph-stats.util.spec.ts} | 6 +- ...hart-stats.util.ts => graph-stats.util.ts} | 10 +- ...util.spec.ts => graph-window.util.spec.ts} | 4 +- ...rt-window.util.ts => graph-window.util.ts} | 8 +- .../graph-data-options.component.html} | 6 +- .../graph-data-options.component.scss} | 0 .../graph-data-options.component.spec.ts} | 22 +-- .../graph-data-options.component.ts} | 10 +- .../graph-display-options.component.html} | 28 +-- .../graph-display-options.component.scss} | 12 +- .../graph-display-options.component.spec.ts} | 14 +- .../graph-display-options.component.ts} | 8 +- .../root-modal-widget-config.component.html | 4 +- .../root-modal-widget-config.component.ts | 6 +- .../minichart/minichart.component.html | 1 - .../minigraph/minigraph.component.html | 1 + .../minigraph.component.scss} | 0 .../minigraph.component.spec.ts} | 32 ++-- .../minigraph.component.ts} | 60 +++---- .../widget-data-graph.component.html} | 4 +- .../widget-data-graph.component.scss} | 2 +- .../widget-data-graph.component.spec.ts} | 42 ++--- .../widget-data-graph.component.ts} | 104 +++++------ .../widget-numeric.component.html | 2 +- .../widget-numeric.component.ts | 44 ++--- .../widget-windtrends-chart.component.html | 1 - .../widget-windtrends-graph.component.html | 1 + .../widget-windtrends-graph.component.scss} | 0 ...widget-windtrends-graph.component.spec.ts} | 32 ++-- .../widget-windtrends-graph.component.ts} | 98 ++++++----- src/assets/svg/icons.svg | 2 +- .../config.blank.dashboard.spec.ts | 4 +- src/test-shims/chartjs-shim.ts | 4 +- 59 files changed, 587 insertions(+), 586 deletions(-) rename src/app/core/components/{widget-history-chart-dialog/widget-history-chart-dialog.component.html => widget-history-graph-dialog/widget-history-graph-dialog.component.html} (91%) rename src/app/core/components/{widget-history-chart-dialog/widget-history-chart-dialog.component.scss => widget-history-graph-dialog/widget-history-graph-dialog.component.scss} (96%) rename src/app/core/components/{widget-history-chart-dialog/widget-history-chart-dialog.component.spec.ts => widget-history-graph-dialog/widget-history-graph-dialog.component.spec.ts} (94%) rename src/app/core/components/{widget-history-chart-dialog/widget-history-chart-dialog.component.ts => widget-history-graph-dialog/widget-history-graph-dialog.component.ts} (95%) rename src/app/core/contracts/{electrical-history-chart.contract.ts => electrical-history-graph.contract.ts} (97%) rename src/app/core/interfaces/{dataset.interfaces.ts => graph-data.interfaces.ts} (53%) rename src/app/core/services/{history-chart-stream.service.spec.ts => history-graph-stream.service.spec.ts} (89%) rename src/app/core/services/{history-chart-stream.service.ts => history-graph-stream.service.ts} (93%) rename src/app/core/services/{history-to-chart-mapper.service.spec.ts => history-to-graph-mapper.service.spec.ts} (92%) rename src/app/core/services/{history-to-chart-mapper.service.ts => history-to-graph-mapper.service.ts} (77%) rename src/app/core/utils/{chart-stats.util.spec.ts => graph-stats.util.spec.ts} (95%) rename src/app/core/utils/{chart-stats.util.ts => graph-stats.util.ts} (91%) rename src/app/core/utils/{chart-window.util.spec.ts => graph-window.util.spec.ts} (97%) rename src/app/core/utils/{chart-window.util.ts => graph-window.util.ts} (89%) rename src/app/widget-config/{dataset-chart-options/dataset-chart-options.component.html => graph-data-options/graph-data-options.component.html} (94%) rename src/app/widget-config/{dataset-chart-options/dataset-chart-options.component.scss => graph-data-options/graph-data-options.component.scss} (100%) rename src/app/widget-config/{dataset-chart-options/dataset-chart-options.component.spec.ts => graph-data-options/graph-data-options.component.spec.ts} (91%) rename src/app/widget-config/{dataset-chart-options/dataset-chart-options.component.ts => graph-data-options/graph-data-options.component.ts} (96%) rename src/app/widget-config/{display-chart-options/display-chart-options.component.html => graph-display-options/graph-display-options.component.html} (85%) rename src/app/widget-config/{display-chart-options/display-chart-options.component.scss => graph-display-options/graph-display-options.component.scss} (72%) rename src/app/widget-config/{display-chart-options/display-chart-options.component.spec.ts => graph-display-options/graph-display-options.component.spec.ts} (90%) rename src/app/widget-config/{display-chart-options/display-chart-options.component.ts => graph-display-options/graph-display-options.component.ts} (94%) delete mode 100644 src/app/widgets/minichart/minichart.component.html create mode 100644 src/app/widgets/minigraph/minigraph.component.html rename src/app/widgets/{minichart/minichart.component.scss => minigraph/minigraph.component.scss} (100%) rename src/app/widgets/{minichart/minichart.component.spec.ts => minigraph/minigraph.component.spec.ts} (79%) rename src/app/widgets/{minichart/minichart.component.ts => minigraph/minigraph.component.ts} (90%) rename src/app/widgets/{widget-data-chart/widget-data-chart.component.html => widget-data-graph/widget-data-graph.component.html} (80%) rename src/app/widgets/{widget-data-chart/widget-data-chart.component.scss => widget-data-graph/widget-data-graph.component.scss} (89%) rename src/app/widgets/{widget-data-chart/widget-data-chart.component.spec.ts => widget-data-graph/widget-data-graph.component.spec.ts} (88%) rename src/app/widgets/{widget-data-chart/widget-data-chart.component.ts => widget-data-graph/widget-data-graph.component.ts} (89%) delete mode 100644 src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.html create mode 100644 src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.html rename src/app/widgets/{widget-windtrends-chart/widget-windtrends-chart.component.scss => widget-windtrends-graph/widget-windtrends-graph.component.scss} (100%) rename src/app/widgets/{widget-windtrends-chart/widget-windtrends-chart.component.spec.ts => widget-windtrends-graph/widget-windtrends-graph.component.spec.ts} (93%) rename src/app/widgets/{widget-windtrends-chart/widget-windtrends-chart.component.ts => widget-windtrends-graph/widget-windtrends-graph.component.ts} (93%) diff --git a/src/app/core/components/connection-status/connection-status.component.ts b/src/app/core/components/connection-status/connection-status.component.ts index 4dd1b737..58ef442a 100644 --- a/src/app/core/components/connection-status/connection-status.component.ts +++ b/src/app/core/components/connection-status/connection-status.component.ts @@ -18,7 +18,7 @@ registerChartComponents(); /** * Connection status and diagnostics: server session identity (SSO), server/stream state, versions, - * internet reachability, and a live delta-throughput chart. Read-only — the connection itself is + * internet reachability, and a live delta-throughput graph. Read-only — the connection itself is * auto-configured (same-origin, server-discovered endpoints), so there is nothing to edit here. * Skip authenticates only through the server's same-origin session (SSO); it has no credential * entry of its own. @@ -82,16 +82,16 @@ export class ConnectionStatusComponent implements AfterViewInit, OnDestroy { }); private _chart: Chart | null = null; - private textColor: string; // Store the computed text color for chart styling + private textColor: string; // Store the computed text color for graph styling ngAfterViewInit(): void { const canvas = this.activityGraph()?.nativeElement; if (!canvas) return; this.textColor = window.getComputedStyle(canvas).color; this._chart?.destroy(); - this.startChart(canvas); + this.startGraph(canvas); - // Get real-time WebSocket Delta update statistics for chart + // Get real-time WebSocket Delta update statistics for graph this.DataService.getSignalkDeltaUpdateStatistics().pipe( takeUntilDestroyed(this.destroyRef) ).subscribe((update: IDeltaUpdate) => { @@ -107,9 +107,9 @@ export class ConnectionStatusComponent implements AfterViewInit, OnDestroy { /** * Initializes the Chart.js line chart for displaying WebSocket delta statistics. - * Creates a time-series chart showing data update frequency over time. + * Creates a time-series graph showing data update frequency over time. */ - private startChart(canvas: HTMLCanvasElement) { + private startGraph(canvas: HTMLCanvasElement) { const ctx = canvas.getContext('2d'); if (!ctx) return; this._chart = new Chart(ctx, { diff --git a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.html b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.html similarity index 91% rename from src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.html rename to src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.html index 0a9cd0d0..3a4c23df 100644 --- a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.html +++ b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.html @@ -6,14 +6,14 @@
{{data.title}}
- + 15 Minutes 1 Hour 8 Hours 24 Hours
-
+
@if (loading()) {

Loading historical data…

} @else if (error()) { @@ -21,7 +21,7 @@
{{data.title}}
} @else if (hasNoData()) {

No historical data is available for this widget. Historical data comes from an external Signal K history provider (such as signalk-to-influxdb2 or signalk-parquet) — make sure one is installed and configured to support the widget's path, then try again.

} @else { -
+
} diff --git a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.scss b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.scss similarity index 96% rename from src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.scss rename to src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.scss index 465125d2..f8c7f3aa 100644 --- a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.scss +++ b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.scss @@ -31,7 +31,7 @@ mat-dialog-content.history-dialog-content { width: 100%; } -.chart-area { +.graph-area { display: flex; flex-direction: column; flex: 1 1 0; @@ -39,7 +39,7 @@ mat-dialog-content.history-dialog-content { width: 100%; } -.chart-wrapper { +.graph-wrapper { display: flex; flex-direction: column; flex: 1 1 0; diff --git a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.spec.ts b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.spec.ts similarity index 94% rename from src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.spec.ts rename to src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.spec.ts index 0bbd2b46..c58d80a7 100644 --- a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.spec.ts +++ b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.spec.ts @@ -3,17 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { of } from 'rxjs'; -import { WidgetHistoryChartDialogComponent } from './widget-history-chart-dialog.component'; +import { WidgetHistoryGraphDialogComponent } from './widget-history-graph-dialog.component'; import { AppService } from '../../services/app-service'; import { HistoryApiClientService } from '../../services/history-api-client.service'; -import { HistoryToChartMapperService } from '../../services/history-to-chart-mapper.service'; +import { HistoryToGraphMapperService } from '../../services/history-to-graph-mapper.service'; import { UnitsService } from '../../services/units.service'; import { IWidget } from '../../interfaces/widgets-interface'; import { ISkipSeriesDefinition } from '../../contracts/skip-series-contract'; -describe('WidgetHistoryChartDialogComponent', () => { - let fixture: ComponentFixture; - let component: WidgetHistoryChartDialogComponent; +describe('WidgetHistoryGraphDialogComponent', () => { + let fixture: ComponentFixture; + let component: WidgetHistoryGraphDialogComponent; let historyApiClientMock: { getPaths: Mock; getValues: Mock; @@ -41,7 +41,7 @@ describe('WidgetHistoryChartDialogComponent', () => { const seriesDefinitions: ISkipSeriesDefinition[] = [ { seriesId: 'widget-bms-1:bms:battery-1:capacity.stateOfCharge:default', - datasetUuid: 'widget-bms-1:bms:battery-1:capacity.stateOfCharge:default', + seriesUuid: 'widget-bms-1:bms:battery-1:capacity.stateOfCharge:default', ownerWidgetUuid: 'widget-bms-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.battery-1.capacity.stateOfCharge', @@ -49,7 +49,7 @@ describe('WidgetHistoryChartDialogComponent', () => { }, { seriesId: 'widget-bms-1:bms:battery-1:current:default', - datasetUuid: 'widget-bms-1:bms:battery-1:current:default', + seriesUuid: 'widget-bms-1:bms:battery-1:current:default', ownerWidgetUuid: 'widget-bms-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.battery-1.current', @@ -94,7 +94,7 @@ describe('WidgetHistoryChartDialogComponent', () => { getUnitDisplaySymbolMock = vi.fn().mockImplementation((measure: string | null | undefined) => measure ?? ''); await TestBed.configureTestingModule({ - imports: [WidgetHistoryChartDialogComponent], + imports: [WidgetHistoryGraphDialogComponent], providers: [ { provide: MAT_DIALOG_DATA, @@ -111,7 +111,7 @@ describe('WidgetHistoryChartDialogComponent', () => { } }, { provide: HistoryApiClientService, useValue: historyApiClientMock }, - { provide: HistoryToChartMapperService, useValue: historyMapperMock }, + { provide: HistoryToGraphMapperService, useValue: historyMapperMock }, { provide: UnitsService, useValue: { @@ -123,7 +123,7 @@ describe('WidgetHistoryChartDialogComponent', () => { ] }).compileComponents(); - fixture = TestBed.createComponent(WidgetHistoryChartDialogComponent); + fixture = TestBed.createComponent(WidgetHistoryGraphDialogComponent); component = fixture.componentInstance; }); @@ -166,7 +166,7 @@ describe('WidgetHistoryChartDialogComponent', () => { const bmsSeries: ISkipSeriesDefinition[] = [ { seriesId: 'widget-bms-1:bms:battery-1:capacity.stateOfCharge:default', - datasetUuid: 'widget-bms-1:bms:battery-1:capacity.stateOfCharge:default', + seriesUuid: 'widget-bms-1:bms:battery-1:capacity.stateOfCharge:default', ownerWidgetUuid: 'widget-bms-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.battery-1.capacity.stateOfCharge', @@ -174,7 +174,7 @@ describe('WidgetHistoryChartDialogComponent', () => { }, { seriesId: 'widget-bms-1:bms:battery-1:current:default', - datasetUuid: 'widget-bms-1:bms:battery-1:current:default', + seriesUuid: 'widget-bms-1:bms:battery-1:current:default', ownerWidgetUuid: 'widget-bms-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.battery-1.current', @@ -182,7 +182,7 @@ describe('WidgetHistoryChartDialogComponent', () => { }, { seriesId: 'widget-bms-1:bms:battery-2:capacity.stateOfCharge:default', - datasetUuid: 'widget-bms-1:bms:battery-2:capacity.stateOfCharge:default', + seriesUuid: 'widget-bms-1:bms:battery-2:capacity.stateOfCharge:default', ownerWidgetUuid: 'widget-bms-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.battery-2.capacity.stateOfCharge', @@ -190,7 +190,7 @@ describe('WidgetHistoryChartDialogComponent', () => { }, { seriesId: 'widget-bms-1:bms:battery-2:current:default', - datasetUuid: 'widget-bms-1:bms:battery-2:current:default', + seriesUuid: 'widget-bms-1:bms:battery-2:current:default', ownerWidgetUuid: 'widget-bms-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.battery-2.current', @@ -249,7 +249,7 @@ describe('WidgetHistoryChartDialogComponent', () => { expect(current2?.borderDash).toEqual([6, 4]); }); - it('builds dual y-axes for BMS charts and colors axis titles by metric', () => { + it('builds dual y-axes for BMS graphs and colors axis titles by metric', () => { (component as unknown as { pendingDatasets: { yAxisID?: string; @@ -295,7 +295,7 @@ describe('WidgetHistoryChartDialogComponent', () => { const panelPowerSeries: ISkipSeriesDefinition = { seriesId: 'widget-solar-1:solar:charger-1:panelPower:default', - datasetUuid: 'widget-solar-1:solar:charger-1:panelPower:default', + seriesUuid: 'widget-solar-1:solar:charger-1:panelPower:default', ownerWidgetUuid: 'widget-solar-1', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.charger-1.panelPower', @@ -303,7 +303,7 @@ describe('WidgetHistoryChartDialogComponent', () => { }; const currentSeries: ISkipSeriesDefinition = { seriesId: 'widget-solar-1:solar:charger-1:current:default', - datasetUuid: 'widget-solar-1:solar:charger-1:current:default', + seriesUuid: 'widget-solar-1:solar:charger-1:current:default', ownerWidgetUuid: 'widget-solar-1', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.charger-1.current', @@ -348,7 +348,7 @@ describe('WidgetHistoryChartDialogComponent', () => { it('does not apply convertUnitTo override for dual-axis series when path config is provided', async () => { const panelPowerSeries: ISkipSeriesDefinition = { seriesId: 'widget-solar-1:solar:charger-1:panelPower:default', - datasetUuid: 'widget-solar-1:solar:charger-1:panelPower:default', + seriesUuid: 'widget-solar-1:solar:charger-1:panelPower:default', ownerWidgetUuid: 'widget-solar-1', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.charger-1.panelPower', @@ -389,7 +389,7 @@ describe('WidgetHistoryChartDialogComponent', () => { expect(dataset?.data[0]?.y).toBeCloseTo(0.0145); }); - it('builds dual y-axes for solar charts and colors axis titles by metric', () => { + it('builds dual y-axes for solar graphs and colors axis titles by metric', () => { (component as unknown as { data: { widget: IWidget; @@ -451,7 +451,7 @@ describe('WidgetHistoryChartDialogComponent', () => { seriesDefinitions: [ { seriesId: 'widget-solar-1:solar-template', - datasetUuid: 'widget-solar-1:solar-template', + seriesUuid: 'widget-solar-1:solar-template', ownerWidgetUuid: 'widget-solar-1', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.*', @@ -512,7 +512,7 @@ describe('WidgetHistoryChartDialogComponent', () => { seriesDefinitions: [ { seriesId: 'widget-bms-1:batteries-template', - datasetUuid: 'widget-bms-1:batteries-template', + seriesUuid: 'widget-bms-1:batteries-template', ownerWidgetUuid: 'widget-bms-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.*', @@ -570,7 +570,7 @@ describe('WidgetHistoryChartDialogComponent', () => { seriesDefinitions: [ { seriesId: 'widget-charger-1:charger-template', - datasetUuid: 'widget-charger-1:charger-template', + seriesUuid: 'widget-charger-1:charger-template', ownerWidgetUuid: 'widget-charger-1', ownerWidgetSelector: 'widget-charger', path: 'self.electrical.chargers.*', @@ -624,7 +624,7 @@ describe('WidgetHistoryChartDialogComponent', () => { seriesDefinitions: [ { seriesId: 'widget-inverter-1:inverter-template', - datasetUuid: 'widget-inverter-1:inverter-template', + seriesUuid: 'widget-inverter-1:inverter-template', ownerWidgetUuid: 'widget-inverter-1', ownerWidgetSelector: 'widget-inverter', path: 'self.electrical.inverters.*', @@ -635,7 +635,7 @@ describe('WidgetHistoryChartDialogComponent', () => { }, { seriesId: 'widget-alternator-1:alternator-template', - datasetUuid: 'widget-alternator-1:alternator-template', + seriesUuid: 'widget-alternator-1:alternator-template', ownerWidgetUuid: 'widget-alternator-1', ownerWidgetSelector: 'widget-alternator', path: 'self.electrical.alternators.*', @@ -697,7 +697,7 @@ describe('WidgetHistoryChartDialogComponent', () => { seriesDefinitions: [ { seriesId: 'widget-ac-1:ac-template', - datasetUuid: 'widget-ac-1:ac-template', + seriesUuid: 'widget-ac-1:ac-template', ownerWidgetUuid: 'widget-ac-1', ownerWidgetSelector: 'widget-ac', path: 'self.electrical.ac.*', @@ -791,7 +791,7 @@ describe('WidgetHistoryChartDialogComponent', () => { seriesDefinitions: [ { seriesId: 'widget-solar-1:solar-template', - datasetUuid: 'widget-solar-1:solar-template', + seriesUuid: 'widget-solar-1:solar-template', ownerWidgetUuid: 'widget-solar-1', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.*', @@ -839,7 +839,7 @@ describe('WidgetHistoryChartDialogComponent', () => { seriesDefinitions: [ { seriesId: 'widget-solar-1:solar-template', - datasetUuid: 'widget-solar-1:solar-template', + seriesUuid: 'widget-solar-1:solar-template', ownerWidgetUuid: 'widget-solar-1', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.*', @@ -862,7 +862,7 @@ describe('WidgetHistoryChartDialogComponent', () => { // Enumeration follows the series' 48h retention window, decoupled from the display window. expect(historyApiClientMock.getPaths).toHaveBeenCalledWith(expect.objectContaining({ duration: 'PT172800S' })); - // The chart data fetch still uses the 1-hour display window for every concrete. + // The graph data fetch still uses the 1-hour display window for every concrete. const valueDurations = historyApiClientMock.getValues.mock.calls.map((call: [{ duration: string }]) => call[0].duration); expect(valueDurations.length).toBeGreaterThan(0); expect(valueDurations.every((duration: string) => duration === 'PT1H')).toBe(true); @@ -872,7 +872,7 @@ describe('WidgetHistoryChartDialogComponent', () => { it('falls back to the 24h default enumeration window when the series carries no usable retention', async () => { const baseTemplate = { seriesId: 'widget-solar-1:solar-template', - datasetUuid: 'widget-solar-1:solar-template', + seriesUuid: 'widget-solar-1:solar-template', ownerWidgetUuid: 'widget-solar-1', ownerWidgetSelector: 'widget-solar-charger' as const, path: 'self.electrical.solar.*', @@ -928,7 +928,7 @@ describe('WidgetHistoryChartDialogComponent', () => { const displaySeries: ISkipSeriesDefinition = { seriesId: 'widget-numeric-1:speed:default', - datasetUuid: 'widget-numeric-1:speed:default', + seriesUuid: 'widget-numeric-1:speed:default', ownerWidgetUuid: 'widget-numeric-1', ownerWidgetSelector: 'widget-numeric', path: 'self.navigation.speedThroughWater', @@ -1025,7 +1025,7 @@ describe('WidgetHistoryChartDialogComponent', () => { const structuralSeries: ISkipSeriesDefinition = { seriesId: 'widget-numeric-1:cog:default', - datasetUuid: 'widget-numeric-1:cog:default', + seriesUuid: 'widget-numeric-1:cog:default', ownerWidgetUuid: 'widget-numeric-1', ownerWidgetSelector: 'widget-numeric', path: 'self.navigation.courseOverGroundTrue', diff --git a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.ts b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.ts similarity index 95% rename from src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.ts rename to src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.ts index 3bddc8f0..139771f6 100644 --- a/src/app/core/components/widget-history-chart-dialog/widget-history-chart-dialog.component.ts +++ b/src/app/core/components/widget-history-graph-dialog/widget-history-graph-dialog.component.ts @@ -14,12 +14,12 @@ import { getElectricalWidgetChartMeta, getTemplateMetricSuffixesForExpansionMode, IDualAxisSeriesDescriptor, - resolveDualAxisWidgetTypeFromDatasets, + resolveDualAxisWidgetTypeFromSeries, TDualAxisMetric, TDualAxisWidgetType, transformDualAxisMetricValue -} from '../../contracts/electrical-history-chart.contract'; -import { HistoryToChartMapperService } from '../../services/history-to-chart-mapper.service'; +} from '../../contracts/electrical-history-graph.contract'; +import { HistoryToGraphMapperService } from '../../services/history-to-graph-mapper.service'; import { AppService, ITheme } from '../../services/app-service'; import { UnitsService } from '../../services/units.service'; import { HistoryApiClientService } from '../../services/history-api-client.service'; @@ -34,31 +34,31 @@ interface ChartPoint { /** A gap in the source history (no value at this timestamp) is a normal, chart.js-renderable state. */ y: number | null; } -type HistoryChartDataset = ChartDataset<'line', ChartPoint[]>; +type HistoryGraphDataset = ChartDataset<'line', ChartPoint[]>; /**** - * Dialog payload used by WidgetHistoryChartDialogComponent. + * Dialog payload used by WidgetHistoryGraphDialogComponent. */ -export interface IWidgetHistoryChartDialogData { +export interface IWidgetHistoryGraphDialogData { title: string; widget: IWidget; seriesDefinitions: ISkipSeriesDefinition[]; } @Component({ - selector: 'widget-history-chart-dialog', + selector: 'widget-history-graph-dialog', imports: [ MatDialogModule, MatButtonToggleModule, MatButtonModule, MatIconModule, FormsModule], - templateUrl: './widget-history-chart-dialog.component.html', - styleUrl: './widget-history-chart-dialog.component.scss', + templateUrl: './widget-history-graph-dialog.component.html', + styleUrl: './widget-history-graph-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, OnDestroy { +export class WidgetHistoryGraphDialogComponent implements OnInit, AfterViewInit, OnDestroy { private readonly historyApiClient = inject(HistoryApiClientService); - private readonly historyMapper = inject(HistoryToChartMapperService); + private readonly historyMapper = inject(HistoryToGraphMapperService); private readonly app = inject(AppService); private readonly destroyRef = inject(DestroyRef); private readonly units = inject(UnitsService); - public readonly data = inject(MAT_DIALOG_DATA); + public readonly data = inject(MAT_DIALOG_DATA); protected readonly chartCanvas = viewChild>('historyCanvas'); @@ -71,13 +71,13 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, protected readonly hasNoData = computed(() => !this.loading() && !this.error() && this.datasetCount() === 0); private chart: Chart<'line'> | null = null; private viewReady = false; - private pendingDatasets: HistoryChartDataset[] = []; + private pendingDatasets: HistoryGraphDataset[] = []; private dualAxisDescriptorCache = new Map(); /** * Fallback retention-scale window for enumerating template concretes when a series carries no * explicit retentionDurationMs. Matches the auto-retention the series are stamped with (24h) and is - * decoupled from the chart display window so a briefly-quiet device is not dropped from enumeration. + * decoupled from the graph display window so a briefly-quiet device is not dropped from enumeration. */ private readonly DEFAULT_ENUMERATION_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -116,11 +116,11 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, querySeriesDefinitions.map((series, index) => this.buildDatasetForSeries(series, index)) ); - this.pendingDatasets = datasets.filter((dataset): dataset is HistoryChartDataset => !!dataset); + this.pendingDatasets = datasets.filter((dataset): dataset is HistoryGraphDataset => !!dataset); this.datasetCount.set(this.pendingDatasets.length); this.tryRenderChart(); } catch (error) { - console.error('[WidgetHistoryChartDialogComponent] Failed to load widget history datasets:', error); + console.error('[WidgetHistoryGraphDialogComponent] Failed to load widget history datasets:', error); this.error.set('Unable to load historical data for this widget.'); this.pendingDatasets = []; this.datasetCount.set(0); @@ -163,7 +163,7 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, } /** - * Angular lifecycle hook that destroys the chart instance. + * Angular lifecycle hook that destroys the graph instance. * * @returns {void} * @@ -188,7 +188,7 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, this.tryRenderChart(); } - private async buildDatasetForSeries(series: ISkipSeriesDefinition, index: number): Promise { + private async buildDatasetForSeries(series: ISkipSeriesDefinition, index: number): Promise { const rawPath = series.path; if (!rawPath) { return null; @@ -208,7 +208,7 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, for (const candidate of requestCandidates) { // A transient failure (network/5xx/timeout) now throws; treat it like an empty result so this - // candidate is skipped and the next one (or an empty chart) is tried. + // candidate is skipped and the next one (or an empty graph) is tried. const response = await this.historyApiClient.getValues({ paths: candidate.paths, context: candidate.context, @@ -422,9 +422,9 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, return [series]; } - // Enumerate concretes over the retention window, not the (possibly short) chart display window: + // Enumerate concretes over the retention window, not the (possibly short) graph display window: // getPaths only lists paths with a sample inside the window, so a briefly-quiet device would be - // dropped if enumerated over the display window alone. The chart data itself is still fetched over + // dropped if enumerated over the display window alone. The graph data itself is still fetched over // the display window in buildDatasetForSeries. const enumerationDuration = this.resolveEnumerationDuration(series); const availablePaths = await this.historyApiClient.getPaths({ duration: enumerationDuration }); @@ -440,7 +440,7 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, return matchedPaths.map((path, index): ISkipConcreteSeriesDefinition => ({ ...series, seriesId: `${series.seriesId}:resolved:${index}`, - datasetUuid: `${series.datasetUuid}:resolved:${index}`, + seriesUuid: `${series.seriesUuid}:resolved:${index}`, path, // A resolved concrete path is no longer a template: clear all template-only bookkeeping fields. expansionMode: null, @@ -759,7 +759,7 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, private buildLegendLabels(chart: Chart): LegendItem[] { return Chart.defaults.plugins.legend.labels.generateLabels(chart).map(label => { const dataset = label.datasetIndex != null - ? chart.data.datasets[label.datasetIndex] as HistoryChartDataset + ? chart.data.datasets[label.datasetIndex] as HistoryGraphDataset : null; const borderDash: number[] = Array.isArray(dataset?.borderDash) ? dataset.borderDash.filter((v): v is number => typeof v === 'number') @@ -777,7 +777,7 @@ export class WidgetHistoryChartDialogComponent implements OnInit, AfterViewInit, } private resolveDualAxisWidgetType(): TDualAxisWidgetType | null { - return resolveDualAxisWidgetTypeFromDatasets( + return resolveDualAxisWidgetTypeFromSeries( this.data.widget?.type, this.pendingDatasets.map(dataset => typeof dataset.yAxisID === 'string' ? dataset.yAxisID : null) ); diff --git a/src/app/core/components/widget-host2/widget-host2.component.spec.ts b/src/app/core/components/widget-host2/widget-host2.component.spec.ts index bc1ad530..06d9970c 100644 --- a/src/app/core/components/widget-host2/widget-host2.component.spec.ts +++ b/src/app/core/components/widget-host2/widget-host2.component.spec.ts @@ -47,7 +47,7 @@ describe('WidgetHost2Component', () => { resolveSeriesForWidget: vi.fn().mockReturnValue([ { seriesId: 'widget-1:auto:navigation-speedthroughwater:default', - datasetUuid: 'widget-1:navigation-speedthroughwater:default', + seriesUuid: 'widget-1:navigation-speedthroughwater:default', ownerWidgetUuid: 'widget-1', ownerWidgetSelector: 'widget-numeric', path: 'navigation.speedThroughWater', @@ -226,7 +226,7 @@ describe('WidgetHost2Component', () => { historySyncMock.resolveSeriesForWidget.mockReturnValue([ { seriesId: 'widget-1:auto:navigation-speedthroughwater:default', - datasetUuid: 'widget-1:navigation-speedthroughwater:default', + seriesUuid: 'widget-1:navigation-speedthroughwater:default', ownerWidgetUuid: 'widget-1', ownerWidgetSelector: 'widget-numeric', path: 'navigation.speedThroughWater', @@ -252,7 +252,7 @@ describe('WidgetHost2Component', () => { const templateSeries = [ { seriesId: 'widget-1:bms-template', - datasetUuid: 'widget-1:bms-template', + seriesUuid: 'widget-1:bms-template', ownerWidgetUuid: 'widget-1', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.*', @@ -288,7 +288,7 @@ describe('WidgetHost2Component', () => { const templateSeries = [ { seriesId: 'widget-1:solar-template', - datasetUuid: 'widget-1:solar-template', + seriesUuid: 'widget-1:solar-template', ownerWidgetUuid: 'widget-1', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.*', diff --git a/src/app/core/components/widget-host2/widget-host2.component.ts b/src/app/core/components/widget-host2/widget-host2.component.ts index c4629b53..3c477de7 100644 --- a/src/app/core/components/widget-host2/widget-host2.component.ts +++ b/src/app/core/components/widget-host2/widget-host2.component.ts @@ -366,7 +366,7 @@ export class WidgetHost2Component extends BaseWidget implements OnInit, OnDestro } /** - * Long-press on a locked dashboard opens the widget's history chart directly. + * Long-press on a locked dashboard opens the widget's history graph directly. * Gating (locked-only + history eligibility) lives in the internal opener, so * a long-press on a non-eligible widget is a silent no-op. */ diff --git a/src/app/core/contracts/electrical-history-chart.contract.ts b/src/app/core/contracts/electrical-history-graph.contract.ts similarity index 97% rename from src/app/core/contracts/electrical-history-chart.contract.ts rename to src/app/core/contracts/electrical-history-graph.contract.ts index d598e59e..26f081f6 100644 --- a/src/app/core/contracts/electrical-history-chart.contract.ts +++ b/src/app/core/contracts/electrical-history-graph.contract.ts @@ -3,7 +3,7 @@ import type { TElectricalExpansionMode } from './skip-series-contract'; export type TDualAxisWidgetType = 'widget-bms' | 'widget-solar-charger' | 'widget-charger' | 'widget-inverter' | 'widget-alternator' | 'widget-ac'; export type TDualAxisMetric = 'soc' | 'current' | 'panelPower' | 'voltage' | 'frequency'; export type TDualAxisAxisId = 'ySoc' | 'yCurrent' | 'yPower' | 'yVoltage' | 'yFrequency'; -export type TChartAxisPosition = 'left' | 'right'; +export type TGraphAxisPosition = 'left' | 'right'; export interface IDualAxisSeriesDescriptor { widgetType: TDualAxisWidgetType; @@ -27,7 +27,7 @@ export interface IDualAxisMetricConfig { export interface IDualAxisAxisConfig { axisId: TDualAxisAxisId; - position: TChartAxisPosition; + position: TGraphAxisPosition; title: string; tickSuffix: string; min?: number; @@ -35,7 +35,7 @@ export interface IDualAxisAxisConfig { drawOnChartArea?: boolean; } -export interface IDualAxisWidgetChartMeta { +export interface IDualAxisWidgetGraphMeta { expansionMode: TElectricalExpansionMode; metricOrder: TDualAxisMetric[]; pathRules: IDualAxisPathRule[]; @@ -43,7 +43,7 @@ export interface IDualAxisWidgetChartMeta { axes: Partial>; } -export const ELECTRICAL_DUAL_AXIS_WIDGET_META: Readonly> = { +export const ELECTRICAL_DUAL_AXIS_WIDGET_META: Readonly> = { 'widget-bms': { expansionMode: 'bms-battery-tree', metricOrder: ['soc', 'current'], @@ -349,7 +349,7 @@ export function isDualAxisWidgetType(value: string | null | undefined): value is return !!value && value in ELECTRICAL_DUAL_AXIS_WIDGET_META; } -export function getElectricalWidgetChartMeta(widgetType: TDualAxisWidgetType): IDualAxisWidgetChartMeta { +export function getElectricalWidgetChartMeta(widgetType: TDualAxisWidgetType): IDualAxisWidgetGraphMeta { return ELECTRICAL_DUAL_AXIS_WIDGET_META[widgetType]; } @@ -401,7 +401,7 @@ export function getAxisConfigsForWidget(widgetType: TDualAxisWidgetType): IDualA .filter((axis): axis is IDualAxisAxisConfig => !!axis); } -export function resolveDualAxisWidgetTypeFromDatasets( +export function resolveDualAxisWidgetTypeFromSeries( widgetTypeRaw: string | null | undefined, axisIds: readonly (string | null | undefined)[] ): TDualAxisWidgetType | null { @@ -420,7 +420,7 @@ export function resolveDualAxisWidgetTypeFromDatasets( } export function describeElectricalDualAxisSeries(normalizedPath: string): IDualAxisSeriesDescriptor | null { - for (const [widgetType, chartMeta] of Object.entries(ELECTRICAL_DUAL_AXIS_WIDGET_META) as [TDualAxisWidgetType, IDualAxisWidgetChartMeta][]) { + for (const [widgetType, chartMeta] of Object.entries(ELECTRICAL_DUAL_AXIS_WIDGET_META) as [TDualAxisWidgetType, IDualAxisWidgetGraphMeta][]) { for (const pathRule of chartMeta.pathRules) { const match = pathRule.regex.exec(normalizedPath); if (!match) { diff --git a/src/app/core/contracts/skip-series-contract.spec.ts b/src/app/core/contracts/skip-series-contract.spec.ts index be642b60..b9c00aea 100644 --- a/src/app/core/contracts/skip-series-contract.spec.ts +++ b/src/app/core/contracts/skip-series-contract.spec.ts @@ -4,7 +4,7 @@ import { ISkipConcreteSeriesDefinition, ISkipSeriesDefinition, ISkipTemplateSeri describe('skip-series-contract guards', () => { const concreteSeries: ISkipConcreteSeriesDefinition = { seriesId: 'widget-1:datachart', - datasetUuid: 'widget-1', + seriesUuid: 'widget-1', ownerWidgetUuid: 'widget-1', ownerWidgetSelector: 'widget-data-chart', path: 'navigation.speedThroughWater', @@ -21,7 +21,7 @@ describe('skip-series-contract guards', () => { const templateSeries: ISkipTemplateSeriesDefinition = { seriesId: 'widget-2:bms-template', - datasetUuid: 'widget-2:bms-template', + seriesUuid: 'widget-2:bms-template', ownerWidgetUuid: 'widget-2', ownerWidgetSelector: 'widget-bms', path: 'self.electrical.batteries.*', @@ -39,7 +39,7 @@ describe('skip-series-contract guards', () => { const solarTemplateSeries: ISkipTemplateSeriesDefinition = { seriesId: 'widget-3:solar-template', - datasetUuid: 'widget-3:solar-template', + seriesUuid: 'widget-3:solar-template', ownerWidgetUuid: 'widget-3', ownerWidgetSelector: 'widget-solar-charger', path: 'self.electrical.solar.*', diff --git a/src/app/core/contracts/skip-series-contract.ts b/src/app/core/contracts/skip-series-contract.ts index 9ba66064..a73b5a0c 100644 --- a/src/app/core/contracts/skip-series-contract.ts +++ b/src/app/core/contracts/skip-series-contract.ts @@ -1,7 +1,7 @@ /** * Ownership: authoritative Skip series schema for the app's History-API consumers. * - * Kept in `core/contracts` so the dashboard series sync and the chart widgets share one series + * Kept in `core/contracts` so the dashboard series sync and the graph widgets share one series * definition. Formerly re-exported from the bundled kip-plugin; that provider was retired, so the * schema now lives here. */ @@ -17,7 +17,7 @@ export interface IElectricalTrackedDeviceRef { interface ISkipSeriesDefinitionBase { seriesId: string; - datasetUuid: string; + seriesUuid: string; ownerWidgetUuid: string; ownerWidgetSelector: string | null; path: string; diff --git a/src/app/core/interfaces/dataset.interfaces.ts b/src/app/core/interfaces/graph-data.interfaces.ts similarity index 53% rename from src/app/core/interfaces/dataset.interfaces.ts rename to src/app/core/interfaces/graph-data.interfaces.ts index 13528f00..32421943 100644 --- a/src/app/core/interfaces/dataset.interfaces.ts +++ b/src/app/core/interfaces/graph-data.interfaces.ts @@ -1,12 +1,11 @@ /** - * Shared dataset/chart data contracts. + * Shared graph data contracts. * - * These types describe the time-windowed datapoints, dataset configuration, and sampling metadata - * consumed by both chart engines (the History-API engine and the legacy recorder) plus the config - * and persistence layers. They live in a neutral module so they survive the recorder's removal. + * These types describe the time-windowed datapoints and series configuration consumed by the + * graph widgets and the config layer. */ -export interface IDatasetServiceDatapoint { +export interface IGraphDatapoint { timestamp: number; data: { value: number; @@ -21,20 +20,14 @@ export interface IDatasetServiceDatapoint { export type TimeScaleFormat = "day" | "hour" | "minute" | "second" | "Last Minute" | "Last 5 Minutes" | "Last 30 Minutes"; -export interface IDatasetServiceDatasetConfig { +export interface IGraphSeriesConfig { uuid: string; path: string; pathSource: string; baseUnit: string; // The path's Signal K base unit type - timeScaleFormat: TimeScaleFormat; // Dataset time scale measure. + timeScaleFormat: TimeScaleFormat; // Series time scale measure. period: number; // Window size expressed in units of timeScaleFormat (ignored for "Last *" presets). label: string; // label of the historicalData - editable?: boolean; // Whether the dataset is editable, or created with Widgets and not editable by user + editable?: boolean; // Whether the series is editable, or created with Widgets and not editable by user angleDomainOverride?: 'signed' | 'direction'; // Optional override for how radian angles are wrapped: signed (-PI..PI) or direction (0..2PI). Undefined uses the path allowlist. } - -export interface IDatasetServiceDataSourceInfo { - sampleTime: number; // DataSource Observer's path value sampling rate in milliseconds. ie. How often we get data from Signal K. - maxDataPoints: number; // How many data points do we keep for that timescale - smoothingPeriod: number; // Number of previous plus current value to use as the moving average -} diff --git a/src/app/core/interfaces/widgets-interface.ts b/src/app/core/interfaces/widgets-interface.ts index 3dc5d007..b10c38f2 100644 --- a/src/app/core/interfaces/widgets-interface.ts +++ b/src/app/core/interfaces/widgets-interface.ts @@ -271,7 +271,7 @@ export interface IWidgetSvcConfig { numDecimal?: number; /** Used by multiple Widget: number of fixed Integer places to display */ numInt?: number; - /** Display the mini chart or not flag */ + /** Display the mini graph or not flag */ showMiniChart?: boolean; /** The widget's path configuration property used for Observable setup. This property can be either contain an object with one key:string per path with it's value as a IWidgetPath object, or an Array of IWidgetPaths. Array is used by multi-control widgets where key:strings Objects are not appropriate. The Key:string Object should be used for typical widgets. */ @@ -410,57 +410,57 @@ export interface IWidgetSvcConfig { /** Used to select a group for the convertUnitTo conversion. */ convertUnitToGroup?: string; - /** Datachart widget Dataset option: Path for the dataset */ + /** Data-graph widget option: Path for the series */ datachartPath?: string | null, - /** Datachart widget Dataset option: Source for the dataset */ + /** Data-graph widget option: Source for the series */ datachartSource?: string | null, - /** Datachart widget option: how radian angles are displayed - 'signed' (-180..180) or 'direction' (0..360). Null/undefined uses the path default. */ + /** Data-graph widget option: how radian angles are displayed - 'signed' (-180..180) or 'direction' (0..360). Null/undefined uses the path default. */ datachartAngleRange?: 'signed' | 'direction' | null, - /** Specifies which average data points property the chart dataset will be built with. Values can be: avg, sma, ema, ema */ + /** Specifies which average data points property the graph series will be built with. Values can be: avg, sma, ema, ema */ datasetAverageArray?: string; - /** Display chart dataset as data points */ + /** Display graph series as data points */ showDataPoints?: boolean; - /** Used by datachart & windtrend chart Widget to set datapoint configuration */ + /** Used by the data-graph & wind-trends graph Widget to set datapoint configuration */ timeScale?: string; - /** Used by datachart & windtrend chart Widget to set period configuration */ + /** Used by the data-graph & wind-trends graph Widget to set period configuration */ period?: number; - /** Specifies if the chart should track against the average dataset instead of the value (default setting) */ + /** Specifies if the graph should track against the average series instead of the value (default setting) */ trackAgainstAverage?: boolean; - /** Specifies which average data points property (1=avg, 2=ema or 3=dema) the chart dataset will be built with */ + /** Specifies which average data points property (1=avg, 2=ema or 3=dema) the graph series will be built with */ showAverageData?: boolean; - /** Display chart dataset minimum value line */ + /** Display graph series minimum value line */ showDatasetMinimumValueLine?: boolean; - /** Display chart dataset maximum value line */ + /** Display graph series maximum value line */ showDatasetMaximumValueLine?: boolean; - /** Display chart dataset average value line */ + /** Display graph series average value line */ showDatasetAverageValueLine?: boolean; - /** Display chart dataset angle average value line */ + /** Display graph series angle average value line */ showDatasetAngleAverageValueLine?: boolean; /** Used by historical data Widget */ animateGraph?: boolean; - /** Display chart time (x axis) scale */ + /** Display graph time (x axis) scale */ showTimeScale?: boolean; - /** Display chart y scale */ + /** Display graph y scale */ showYScale?: boolean; - /** Chart y scale suggested minimum. Scale will extend beyond this number automatically if values are below */ + /** Graph y scale suggested minimum. Scale will extend beyond this number automatically if values are below */ yScaleSuggestedMin?: number; - /** Chart y scale suggested maximum. Scale will extend beyond this number automatically if values are above */ + /** Graph y scale suggested maximum. Scale will extend beyond this number automatically if values are above */ yScaleSuggestedMax?: number; - /** Chart y scale suggested minimum is zero */ + /** Graph y scale suggested minimum is zero */ startScaleAtZero?: boolean; - /** Limit chart value axis (y) scale to min and max value */ + /** Limit graph value axis (y) scale to min and max value */ enableMinMaxScaleLimit?: boolean; - /** Chart y scale minimum */ + /** Graph y scale minimum */ yScaleMin?: number; - /** Chart y scale maximum */ + /** Graph y scale maximum */ yScaleMax?: number; - /** Inverse Chart Y axis */ + /** Inverse graph Y axis */ inverseYAxis?: boolean; - /** Chart data flow direction. True = vertical (top to bottom), False = horizontal (left to right) */ + /** Graph data flow direction. True = vertical (top to bottom), False = horizontal (left to right) */ verticalChart?: boolean; - /** Chart scale minimum value */ + /** Graph scale minimum value */ minValue?: number; - /** Chart scale maximum value */ + /** Graph scale maximum value */ maxValue?: number; /** Used by IFrame widget: URL lo load in the iframe */ diff --git a/src/app/core/services/configuration-upgrade.service.spec.ts b/src/app/core/services/configuration-upgrade.service.spec.ts index 80115a63..fe2744ca 100644 --- a/src/app/core/services/configuration-upgrade.service.spec.ts +++ b/src/app/core/services/configuration-upgrade.service.spec.ts @@ -171,7 +171,7 @@ describe('ConfigurationUpgradeService', () => { const widgetCfg = written.dashboards[0].configuration[0].input.widgetProperties.config; expect('datasetUUID' in widgetCfg).toBe(false); expect('chartEngine' in widgetCfg).toBe(false); - // Genuine chart inputs survive. + // Genuine graph inputs survive. expect(widgetCfg.datachartPath).toBe('self.foo'); }); diff --git a/src/app/core/services/configuration-upgrade.service.ts b/src/app/core/services/configuration-upgrade.service.ts index d37e0ae6..c5918eb9 100644 --- a/src/app/core/services/configuration-upgrade.service.ts +++ b/src/app/core/services/configuration-upgrade.service.ts @@ -687,9 +687,9 @@ export class ConfigurationUpgradeService { } /** - * v12 -> v13: retire the recorder's config footprint. The client-side chart recorder was removed, + * v12 -> v13: retire the recorder's config footprint. The client-side graph recorder was removed, * so the app-level dataset registry and the per-widget `datasetUUID` / `chartEngine` fields it fed - * are dead. Strip them and stamp v13. Genuine chart inputs (path/source/window/units) are untouched. + * are dead. Strip them and stamp v13. Genuine graph inputs (path/source/window/units) are untouched. */ private upgradeConfigV12toV13(config: IConfig): IConfig | null { try { diff --git a/src/app/core/services/dashboard-history-series-sync.service.ts b/src/app/core/services/dashboard-history-series-sync.service.ts index c09488d4..afd5eb1d 100644 --- a/src/app/core/services/dashboard-history-series-sync.service.ts +++ b/src/app/core/services/dashboard-history-series-sync.service.ts @@ -112,7 +112,7 @@ export class DashboardHistorySeriesSyncService { return { seriesId: `${widgetUuid}:datachart`, - datasetUuid: widgetUuid, + seriesUuid: widgetUuid, ownerWidgetUuid: widgetUuid, ownerWidgetSelector: widgetType, path, @@ -151,7 +151,7 @@ export class DashboardHistorySeriesSyncService { series.push({ ...shared, seriesId: `${widgetUuid}:wind-direction`, - datasetUuid: `${widgetUuid}-twd`, + seriesUuid: `${widgetUuid}-twd`, path: dirPath, source: this.normalizeString(dir?.source) ?? 'default', }); @@ -162,7 +162,7 @@ export class DashboardHistorySeriesSyncService { series.push({ ...shared, seriesId: `${widgetUuid}:wind-speed`, - datasetUuid: `${widgetUuid}-tws`, + seriesUuid: `${widgetUuid}-tws`, path: spdPath, source: this.normalizeString(spd?.source) ?? 'default', }); @@ -195,7 +195,7 @@ export class DashboardHistorySeriesSyncService { const suffix = descriptor.familyKey; return { seriesId: `${widgetUuid}:${suffix}-template`, - datasetUuid: `${widgetUuid}:${suffix}-template`, + seriesUuid: `${widgetUuid}:${suffix}-template`, ownerWidgetUuid: widgetUuid, ownerWidgetSelector: descriptor.selector, path: `${descriptor.selfRootPath}.*`, @@ -351,7 +351,7 @@ export class DashboardHistorySeriesSyncService { seriesBySignature.set(signature, { seriesId: `${widgetUuid}:auto:${pathKey}:${sourceKey}`, - datasetUuid: `${widgetUuid}:${pathKey}:${sourceKey}`, + seriesUuid: `${widgetUuid}:${pathKey}:${sourceKey}`, ownerWidgetUuid: widgetUuid, ownerWidgetSelector: widgetType, path, diff --git a/src/app/core/services/data.service.ts b/src/app/core/services/data.service.ts index 7e547d00..b0d5fdb2 100644 --- a/src/app/core/services/data.service.ts +++ b/src/app/core/services/data.service.ts @@ -64,7 +64,7 @@ const typeFromUnits = (units: string | undefined): string | undefined => { /** * Builds an {@link IPathData} whose `timestamp` Date is created lazily and memoized on first * access. This runs on the hot path (once per delta per registered path), and the large majority - * of consumers (numeric gauges, charts, text) only ever read `value` and never touch `timestamp`, + * of consumers (numeric gauges, graphs, text) only ever read `value` and never touch `timestamp`, * so deferring the `new Date(...)` avoids a short-lived allocation per delta and the GC pressure it * causes over long sessions on low-power devices. The public `Date | null` contract is preserved. */ @@ -301,7 +301,7 @@ export class DataService implements OnDestroy { * Subscribe to a `(path, source)` stream, sharing one registration across co-subscribers and * bumping its refCount. Callers must balance every call with an {@link unsubscribePath}. * - * Churning and lifecycle-scoped consumers (widgets, per-selection rebinds, chart streams) should + * Churning and lifecycle-scoped consumers (widgets, per-selection rebinds, graph streams) should * prefer {@link acquirePath}, whose idempotent, forgery-proof release closure makes balanced * teardown far harder to get wrong than a raw `unsubscribePath(path, source)` call. App-lifetime * singletons that subscribe once and never release may call this directly. diff --git a/src/app/core/services/dialog.service.ts b/src/app/core/services/dialog.service.ts index 968f21af..fa377340 100644 --- a/src/app/core/services/dialog.service.ts +++ b/src/app/core/services/dialog.service.ts @@ -11,7 +11,7 @@ import { UpgradeConfigComponent } from '../components/upgrade-config/upgrade-con import { DialogDashboardPageEditorComponent } from '../components/dialog-dashboard-page-editor/dialog-dashboard-page-editor.component'; import { DialogAisTargetComponent } from '../../widgets/widget-ais-radar/dialog-ais-target/dialog-ais-target.component'; import { MenuNotificationsComponent } from '../components/menu-notifications/menu-notifications.component'; -import type { IWidgetHistoryChartDialogData, WidgetHistoryChartDialogComponent } from '../components/widget-history-chart-dialog/widget-history-chart-dialog.component'; +import type { IWidgetHistoryGraphDialogData, WidgetHistoryGraphDialogComponent } from '../components/widget-history-graph-dialog/widget-history-graph-dialog.component'; @Injectable({ providedIn: 'root' @@ -114,10 +114,10 @@ export class DialogService { } /** - * Opens a history-only chart dialog for a widget. + * Opens a history-only graph dialog for a widget. * - * @param {IWidgetHistoryChartDialogData} data Widget history dialog payload. - * @returns {Promise>} Dialog reference. + * @param {IWidgetHistoryGraphDialogData} data Widget history dialog payload. + * @returns {Promise>} Dialog reference. * * @remarks The history chart dialog (and its chart.js dependency) is lazy-loaded so it stays out * of the initial bundle and is only fetched the first time a user opens widget history. @@ -125,9 +125,9 @@ export class DialogService { * @example * const ref = await dialogService.openWidgetHistoryDialog({ title: 'History', widget, seriesDefinitions }); */ - public async openWidgetHistoryDialog(data: IWidgetHistoryChartDialogData): Promise> { - const { WidgetHistoryChartDialogComponent } = await import('../components/widget-history-chart-dialog/widget-history-chart-dialog.component'); - return this.dialog.open(WidgetHistoryChartDialogComponent, + public async openWidgetHistoryDialog(data: IWidgetHistoryGraphDialogData): Promise> { + const { WidgetHistoryGraphDialogComponent } = await import('../components/widget-history-graph-dialog/widget-history-graph-dialog.component'); + return this.dialog.open(WidgetHistoryGraphDialogComponent, { data, minWidth: '70vw', diff --git a/src/app/core/services/history-api-client.service.ts b/src/app/core/services/history-api-client.service.ts index 4c963225..bb8c1229 100644 --- a/src/app/core/services/history-api-client.service.ts +++ b/src/app/core/services/history-api-client.service.ts @@ -281,7 +281,7 @@ export class HistoryApiClientService { } const status = error instanceof HttpErrorResponse ? error.status : 0; // 404 / 501: the server has no history provider (plugin/API missing) — a stable "unavailable", - // reported as null so trend charts degrade to a clean empty state. + // reported as null so trend graphs degrade to a clean empty state. if (status === 404 || status === 501) { console.warn(`[HistoryApiClientService] History API not available (status ${status}); no provider`); return null; diff --git a/src/app/core/services/history-chart-stream.service.spec.ts b/src/app/core/services/history-graph-stream.service.spec.ts similarity index 89% rename from src/app/core/services/history-chart-stream.service.spec.ts rename to src/app/core/services/history-graph-stream.service.spec.ts index 6245bdae..5ab6de48 100644 --- a/src/app/core/services/history-chart-stream.service.spec.ts +++ b/src/app/core/services/history-graph-stream.service.spec.ts @@ -1,14 +1,14 @@ import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BehaviorSubject, Subject, firstValueFrom } from 'rxjs'; -import { HistoryChartStreamService, IHistoryChartStreamParams, isHistoryUnavailable } from './history-chart-stream.service'; +import { HistoryGraphStreamService, IHistoryGraphStreamParams, isHistoryUnavailable } from './history-graph-stream.service'; import { HistoryApiClientService, HistoryRequestError } from './history-api-client.service'; -import { HistoryToChartMapperService } from './history-to-chart-mapper.service'; +import { HistoryToGraphMapperService } from './history-to-graph-mapper.service'; import { DataService, IPathUpdate } from './data.service'; import { ConnectionState, ConnectionStateMachine } from './connection-state-machine.service'; -import { IDatasetServiceDatapoint } from '../interfaces/dataset.interfaces'; +import { IGraphDatapoint } from '../interfaces/graph-data.interfaces'; -const PARAMS: IHistoryChartStreamParams = { +const PARAMS: IHistoryGraphStreamParams = { path: 'navigation.speedOverGround', source: 'default', windowMs: 60_000, @@ -17,7 +17,7 @@ const PARAMS: IHistoryChartStreamParams = { smoothingPeriod: 2 }; -describe('HistoryChartStreamService', () => { +describe('HistoryGraphStreamService', () => { let path$: Subject; let state$: BehaviorSubject; const history = { getValues: vi.fn() }; @@ -25,22 +25,22 @@ describe('HistoryChartStreamService', () => { const releasePath = vi.fn(); const data = { acquirePath: vi.fn(), getPathUnitType: vi.fn() }; - function make(): HistoryChartStreamService { + function make(): HistoryGraphStreamService { TestBed.configureTestingModule({ providers: [ - HistoryChartStreamService, + HistoryGraphStreamService, { provide: HistoryApiClientService, useValue: history }, - { provide: HistoryToChartMapperService, useValue: mapper }, + { provide: HistoryToGraphMapperService, useValue: mapper }, { provide: DataService, useValue: data }, { provide: ConnectionStateMachine, useValue: { state$ } } ] }); - return TestBed.inject(HistoryChartStreamService); + return TestBed.inject(HistoryGraphStreamService); } beforeEach(() => { path$ = new Subject(); - // Charts mount while already connected; the reconnect re-backfill fires only on LATER Connected + // Graphs mount while already connected; the reconnect re-backfill fires only on LATER Connected // transitions, so a stuck-Connected stream leaves the existing live-tail behavior unchanged. state$ = new BehaviorSubject(ConnectionState.Connected); history.getValues.mockReset(); @@ -65,7 +65,7 @@ describe('HistoryChartStreamService', () => { ]); const first = await firstValueFrom(make().getBackfillThenLive(PARAMS)); expect(Array.isArray(first)).toBe(true); - expect((first as IDatasetServiceDatapoint[]).map(p => p.data.value)).toEqual([1, 3]); + expect((first as IGraphDatapoint[]).map(p => p.data.value)).toEqual([1, 3]); expect(history.getValues).toHaveBeenCalledTimes(1); // Only the raw per-bucket `:last` sample is requested; the SMA overlay is derived client-side, so // no `:sma`/`:avg`/`:min`/`:max` aggregate columns are asked of the server (#162). @@ -86,9 +86,9 @@ describe('HistoryChartStreamService', () => { { timestamp: 2000, data: { value: 0.08726646259971647 } }, { timestamp: 3000, data: { value: 0.05235987755982989 } } ]); - const params: IHistoryChartStreamParams = { ...PARAMS, angleDomainOverride: 'direction', smoothingPeriod: 3 }; + const params: IHistoryGraphStreamParams = { ...PARAMS, angleDomainOverride: 'direction', smoothingPeriod: 3 }; const first = await firstValueFrom(make().getBackfillThenLive(params)); - const points = first as IDatasetServiceDatapoint[]; + const points = first as IGraphDatapoint[]; // The 3-sample SMA is the CIRCULAR mean (~1°/0.0175 rad). The server-side arithmetic :sma over // the same radians would be ~2.11 rad — so this proves the smoothing line is angle-correct. expect(points[2].data.sma).toBeCloseTo(0.0174959160, 4); @@ -103,9 +103,9 @@ describe('HistoryChartStreamService', () => { { timestamp: 3000, data: { value: 6 } } ]); // Scalar domain (getPathUnitType → null), smoothingPeriod 2. - const params: IHistoryChartStreamParams = { ...PARAMS, smoothingPeriod: 2 }; + const params: IHistoryGraphStreamParams = { ...PARAMS, smoothingPeriod: 2 }; const first = await firstValueFrom(make().getBackfillThenLive(params)); - const points = first as IDatasetServiceDatapoint[]; + const points = first as IGraphDatapoint[]; expect(points[0].data.sma).toBe(2); // window clamps to [2] expect(points[1].data.sma).toBe(3); // trailing window [2, 4] expect(points[2].data.sma).toBe(5); // trailing window [4, 6] @@ -118,14 +118,14 @@ describe('HistoryChartStreamService', () => { { timestamp: 2000, data: { value: 4 } } ]); const first = await firstValueFrom(make().getBackfillThenLive(PARAMS)); - expect((first as IDatasetServiceDatapoint[]).map(p => p.data.value)).toEqual([4]); + expect((first as IGraphDatapoint[]).map(p => p.data.value)).toEqual([4]); }); it('after backfill, a live delta becomes a datapoint carrying window stats', async () => { history.getValues.mockResolvedValue({ context: 'vessels.self', range: {}, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); // empty backfill - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); // Let the backfill promise settle so the live tail subscribes. @@ -138,7 +138,7 @@ describe('HistoryChartStreamService', () => { path$.next({ data: { value: 5, timestamp: new Date() }, state: 'normal' }); expect(emissions.length).toBe(2); - const point = emissions[1] as IDatasetServiceDatapoint; + const point = emissions[1] as IGraphDatapoint; // #131: stamped with the client clock at emit time, not the value's raw source timestamp. expect(point.timestamp).toBeGreaterThanOrEqual(before); expect(point.data.value).toBe(5); @@ -154,7 +154,7 @@ describe('HistoryChartStreamService', () => { { timestamp: 2000, data: { value: 20 } } ]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await Promise.resolve(); await Promise.resolve(); @@ -162,7 +162,7 @@ describe('HistoryChartStreamService', () => { // The live value aggregates over the seeded buffer [10, 20], not a fresh empty one. path$.next({ data: { value: 30, timestamp: new Date() }, state: 'normal' }); - const point = emissions[1] as IDatasetServiceDatapoint; + const point = emissions[1] as IGraphDatapoint; expect(point.data.value).toBe(30); expect(point.data.lastMinimum).toBe(10); // 10 is only present if the backfill seeded the window expect(point.data.lastMaximum).toBe(30); @@ -183,13 +183,13 @@ describe('HistoryChartStreamService', () => { { timestamp: serverNow, data: { value: 2 } } // newest ~= server "now" ]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; const before = Date.now(); make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await Promise.resolve(); await Promise.resolve(); - const batch = emissions[0] as IDatasetServiceDatapoint[]; + const batch = emissions[0] as IGraphDatapoint[]; // Newest backfill point lands at ~client-now (inside the realtime window), not 90s in the future. expect(batch[1].timestamp).toBeGreaterThanOrEqual(before); expect(batch[1].timestamp).toBeLessThanOrEqual(Date.now() + 5); @@ -209,25 +209,25 @@ describe('HistoryChartStreamService', () => { }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); // settle the (empty) backfill // A fresh value (source timestamp = now) is drawn. path$.next({ data: { value: 5, timestamp: new Date(Date.now()) }, state: 'normal' }); - const first = emissions[emissions.length - 1] as IDatasetServiceDatapoint; + const first = emissions[emissions.length - 1] as IGraphDatapoint; expect(first.data.value).toBe(5); // One resample tick, value still fresh: holds the value and advances x in client time. await vi.advanceTimersByTimeAsync(PARAMS.sampleTime); - const held = emissions[emissions.length - 1] as IDatasetServiceDatapoint; + const held = emissions[emissions.length - 1] as IGraphDatapoint; expect(held.data.value).toBe(5); expect(held.timestamp).toBeGreaterThan(first.timestamp); // Source silent past the bootstrap staleness window: the trace breaks once with a NaN gap. await vi.advanceTimersByTimeAsync(31_000); const gap = emissions.find( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gap).toBeDefined(); } finally { @@ -247,7 +247,7 @@ describe('HistoryChartStreamService', () => { }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); @@ -259,7 +259,7 @@ describe('HistoryChartStreamService', () => { } const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(0); } finally { @@ -271,7 +271,7 @@ describe('HistoryChartStreamService', () => { history.getValues.mockResolvedValue({ context: 'vessels.self', range: {}, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await Promise.resolve(); await Promise.resolve(); @@ -280,14 +280,14 @@ describe('HistoryChartStreamService', () => { path$.next({ data: { value: 5, timestamp: new Date(Date.now() - 3_600_000) }, state: 'normal' }); // It must not be drawn as a fresh point; a NaN gap marker is emitted instead. - const last = emissions[emissions.length - 1] as IDatasetServiceDatapoint; + const last = emissions[emissions.length - 1] as IGraphDatapoint; expect(Number.isNaN(last.data.value)).toBe(true); }); it('on a transient HistoryRequestError, does not disable the chart — rides the live tail with no backfill seed (#130)', async () => { history.getValues.mockRejectedValue(new HistoryRequestError(503)); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await new Promise(resolve => setTimeout(resolve)); // let the rejected backfill settle → live fallback @@ -295,10 +295,10 @@ describe('HistoryChartStreamService', () => { expect(emissions.some(e => !Array.isArray(e) && 'unavailable' in e)).toBe(false); expect(data.acquirePath).toHaveBeenCalled(); - // A live delta still renders, so the chart keeps working through the blip. + // A live delta still renders, so the graph keeps working through the blip. const before = Date.now(); path$.next({ data: { value: 7, timestamp: new Date() }, state: 'normal' }); - const last = emissions[emissions.length - 1] as IDatasetServiceDatapoint; + const last = emissions[emissions.length - 1] as IGraphDatapoint; expect(last.data.value).toBe(7); expect(last.timestamp).toBeGreaterThanOrEqual(before); }); @@ -313,7 +313,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockRejectedValue(new HistoryRequestError(503)); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); // settle the rejected backfill → live fallback subscribes @@ -327,7 +327,7 @@ describe('HistoryChartStreamService', () => { await vi.advanceTimersByTimeAsync(PARAMS.sampleTime); const points = emissions.filter( - (e): e is IDatasetServiceDatapoint => !Array.isArray(e) && !('unavailable' in e) + (e): e is IGraphDatapoint => !Array.isArray(e) && !('unavailable' in e) ); // No gap markers despite the 60s skew, and the real values render. expect(points.some(p => Number.isNaN(p.data.value))).toBe(false); @@ -406,7 +406,7 @@ describe('HistoryChartStreamService', () => { { timestamp: 1000, data: { value: (350 * Math.PI) / 180 } } // seeds the window with 350° ]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive({ ...PARAMS, path: 'navigation.headingTrue' }).subscribe(e => emissions.push(e)); await Promise.resolve(); await Promise.resolve(); @@ -414,7 +414,7 @@ describe('HistoryChartStreamService', () => { // Live value at 10°, over a window already holding 350°. path$.next({ data: { value: (10 * Math.PI) / 180, timestamp: new Date(2000) }, state: 'normal' }); - const last = emissions[emissions.length - 1] as IDatasetServiceDatapoint; + const last = emissions[emissions.length - 1] as IGraphDatapoint; const avg = last.data.lastAverage as number; // Circular mean of 350° and 10° is ~0° in the direction domain, NOT the ~180° a linear mean gives. // Pre-#133 this path resolved to 'scalar' and lastAverage would be ~π. @@ -440,7 +440,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); // empty initial backfill - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); // settle initial backfill → live subscribes @@ -468,7 +468,7 @@ describe('HistoryChartStreamService', () => { expect(Date.parse(reQuery.from)).toBeGreaterThanOrEqual(1_020_000 - PARAMS.windowMs); // The batch carries only the point newer than the seam; the stale-side point is de-duped. - const batch = emissions.filter(e => Array.isArray(e)).pop() as IDatasetServiceDatapoint[]; + const batch = emissions.filter(e => Array.isArray(e)).pop() as IGraphDatapoint[]; expect(batch.map(p => p.data.value)).toEqual([9]); } finally { vi.useRealTimers(); @@ -481,7 +481,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); @@ -492,7 +492,7 @@ describe('HistoryChartStreamService', () => { // but a disconnect must not — the reconnect re-backfill fills it instead. await vi.advanceTimersByTimeAsync(31_000); const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(0); } finally { @@ -523,7 +523,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -538,7 +538,7 @@ describe('HistoryChartStreamService', () => { // straight line across the outage. const last = emissions[emissions.length - 1]; expect(Array.isArray(last)).toBe(false); - expect(Number.isNaN((last as IDatasetServiceDatapoint).data.value)).toBe(true); + expect(Number.isNaN((last as IGraphDatapoint).data.value)).toBe(true); } finally { vi.useRealTimers(); } @@ -550,7 +550,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -564,7 +564,7 @@ describe('HistoryChartStreamService', () => { await vi.advanceTimersByTimeAsync(0); const last = emissions[emissions.length - 1]; - expect(Number.isNaN((last as IDatasetServiceDatapoint).data.value)).toBe(true); + expect(Number.isNaN((last as IGraphDatapoint).data.value)).toBe(true); } finally { vi.useRealTimers(); } @@ -576,7 +576,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -590,7 +590,7 @@ describe('HistoryChartStreamService', () => { // A sub-threshold blip is indistinguishable from normal live cadence — no marker. const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(0); } finally { @@ -607,12 +607,12 @@ describe('HistoryChartStreamService', () => { .mockResolvedValue({ context: 'vessels.self', range: {}, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await new Promise(resolve => setTimeout(resolve)); await new Promise(resolve => setTimeout(resolve)); - // The transient failure did not disable the chart, and the seeded=false first Connected drove a + // The transient failure did not disable the graph, and the seeded=false first Connected drove a // second (recovery) fetch on top of the constructor's failed one. expect(emissions.some(e => !Array.isArray(e) && 'unavailable' in e)).toBe(false); expect(history.getValues.mock.calls.length).toBeGreaterThanOrEqual(2); @@ -624,7 +624,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -660,10 +660,10 @@ describe('HistoryChartStreamService', () => { // Run A's batch landed and no NaN gap was drawn out of order (the seam advanced, so run B's // > seam de-dup keeps the trace monotonic). - const batch = emissions.filter(e => Array.isArray(e)).pop() as IDatasetServiceDatapoint[]; + const batch = emissions.filter(e => Array.isArray(e)).pop() as IGraphDatapoint[]; expect(batch.map(p => p.data.value)).toEqual([8]); const anyGap = emissions.some( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(anyGap).toBe(false); } finally { @@ -677,7 +677,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -701,7 +701,7 @@ describe('HistoryChartStreamService', () => { mapper.mapValuesToChartDatapoints.mockReturnValue([{ timestamp: 1_004_000, data: { value: 7 } }]); releaseFetch({ context: 'vessels.self', range: { to: new Date(1_005_000).toISOString() }, values: [], data: [] }); await vi.advanceTimersByTimeAsync(0); - const batch = emissions.filter(e => Array.isArray(e)).pop() as IDatasetServiceDatapoint[]; + const batch = emissions.filter(e => Array.isArray(e)).pop() as IGraphDatapoint[]; expect(batch.map(p => p.data.value)).toEqual([7]); } finally { vi.useRealTimers(); @@ -714,7 +714,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); @@ -733,7 +733,7 @@ describe('HistoryChartStreamService', () => { await vi.advanceTimersByTimeAsync(0); const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(0); } finally { @@ -747,7 +747,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); @@ -766,7 +766,7 @@ describe('HistoryChartStreamService', () => { await vi.advanceTimersByTimeAsync(0); const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(1); } finally { @@ -780,7 +780,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); @@ -809,7 +809,7 @@ describe('HistoryChartStreamService', () => { // The cadence stayed ~5s (threshold ~15s), so the 40s outage still draws its honest gap. const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(1); } finally { @@ -823,7 +823,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -837,12 +837,12 @@ describe('HistoryChartStreamService', () => { // Just before the hold cap (RECONNECT_HOLD_MS = 3000ms) elapses: still held, no gap yet. await vi.advanceTimersByTimeAsync(2_900); - expect(emissions.some(e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value))).toBe(false); + expect(emissions.some(e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value))).toBe(false); // Past the cap: the hold releases and the honest gap is drawn even though the fetch is still hung. await vi.advanceTimersByTimeAsync(200); const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(1); @@ -851,7 +851,7 @@ describe('HistoryChartStreamService', () => { path$.next({ data: { value: 7, timestamp: new Date(1_034_000) }, state: 'normal' }); await vi.advanceTimersByTimeAsync(PARAMS.sampleTime); const rendered = emissions.some( - e => !Array.isArray(e) && !('unavailable' in e) && (e as IDatasetServiceDatapoint).data.value === 7 + e => !Array.isArray(e) && !('unavailable' in e) && (e as IGraphDatapoint).data.value === 7 ); expect(rendered).toBe(true); } finally { @@ -865,7 +865,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); // seam 1_000_000 @@ -890,14 +890,14 @@ describe('HistoryChartStreamService', () => { // Just before the single shared cap (deadline 1_043_100): still held, no gap. await vi.advanceTimersByTimeAsync(2_800); // → 1_042_900 expect(emissions.some( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) )).toBe(false); // Just past the shared cap, within one sample interval so no stale-tail tick intrudes: the chain // stops and draws exactly one honest gap rather than re-running for a second 3s window. await vi.advanceTimersByTimeAsync(300); // → 1_043_200 const gaps = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ); expect(gaps.length).toBe(1); // The coalesced re-run never issued a fetch — the spent budget pre-empted it. @@ -913,7 +913,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -945,7 +945,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); // seam 1_000_000 @@ -960,7 +960,7 @@ describe('HistoryChartStreamService', () => { state$.next(ConnectionState.Connected); await vi.advanceTimersByTimeAsync(3_200); // past the single shared cap → gap, budget cleared to null const gapsAfterStorm = emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ).length; expect(gapsAfterStorm).toBeGreaterThanOrEqual(1); @@ -977,14 +977,14 @@ describe('HistoryChartStreamService', () => { // capped at ~0ms and drawn a gap with no delivery; a re-armed full budget keeps holding. await vi.advanceTimersByTimeAsync(2_000); expect(emissions.filter( - e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value) + e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value) ).length).toBe(gapsAfterStorm); // The healthy fetch resolves inside the fresh budget and backfills the missed interval. mapper.mapValuesToChartDatapoints.mockReturnValue([{ timestamp: 1_050_000, data: { value: 9 } }]); releaseHealthy({ context: 'vessels.self', range: { to: new Date(1_062_000).toISOString() }, values: [], data: [] }); await vi.advanceTimersByTimeAsync(0); - const batch = emissions.filter(e => Array.isArray(e)).pop() as IDatasetServiceDatapoint[]; + const batch = emissions.filter(e => Array.isArray(e)).pop() as IGraphDatapoint[]; expect(batch.map(p => p.data.value)).toEqual([9]); } finally { vi.useRealTimers(); @@ -997,7 +997,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; const sub = make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -1030,7 +1030,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); // seam 1_000_000 @@ -1057,7 +1057,7 @@ describe('HistoryChartStreamService', () => { releaseRun1({ context: 'vessels.self', range: { to: new Date(1_041_000).toISOString() }, values: [], data: [] }); await vi.advanceTimersByTimeAsync(0); - const batch = emissions.filter(e => Array.isArray(e)).pop() as IDatasetServiceDatapoint[]; + const batch = emissions.filter(e => Array.isArray(e)).pop() as IGraphDatapoint[]; expect(batch.map(p => p.data.value)).toEqual([8]); // run 1 delivered, seam advanced to 1_041_000 const run2Signal = history.getValues.mock.calls[history.getValues.mock.calls.length - 1][1] as AbortSignal | undefined; expect(run2Signal?.aborted).toBe(false); @@ -1079,7 +1079,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); // seam 1_000_000 @@ -1091,7 +1091,7 @@ describe('HistoryChartStreamService', () => { mapper.mapValuesToChartDatapoints.mockReturnValue([]); state$.next(ConnectionState.Connected); await vi.advanceTimersByTimeAsync(0); - expect(emissions.filter(e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IDatasetServiceDatapoint).data.value)).length).toBe(1); + expect(emissions.filter(e => !Array.isArray(e) && !('unavailable' in e) && Number.isNaN((e as IGraphDatapoint).data.value)).length).toBe(1); // Reconnect #2: the provider has now ingested the backdated outage samples (all older than the gap). state$.next(ConnectionState.Disconnected); @@ -1106,7 +1106,7 @@ describe('HistoryChartStreamService', () => { // The seam advanced to the gap timestamp, so those backdated points are de-duped — never drawn // behind the break. const drewBackdated = emissions.some( - e => Array.isArray(e) && (e as IDatasetServiceDatapoint[]).some(p => p.data.value === 1) + e => Array.isArray(e) && (e as IGraphDatapoint[]).some(p => p.data.value === 1) ); expect(drewBackdated).toBe(false); } finally { @@ -1121,7 +1121,7 @@ describe('HistoryChartStreamService', () => { try { history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); path$.next({ data: { value: 5, timestamp: new Date(1_000_000) }, state: 'normal' }); @@ -1137,7 +1137,7 @@ describe('HistoryChartStreamService', () => { expect(errorSpy).toHaveBeenCalled(); expect(emissions.some(e => !Array.isArray(e) && 'unavailable' in e)).toBe(false); const last = emissions[emissions.length - 1]; - expect(Number.isNaN((last as IDatasetServiceDatapoint).data.value)).toBe(true); + expect(Number.isNaN((last as IGraphDatapoint).data.value)).toBe(true); } finally { errorSpy.mockRestore(); vi.useRealTimers(); @@ -1154,7 +1154,7 @@ describe('HistoryChartStreamService', () => { state$.next(ConnectionState.Disconnected); history.getValues.mockResolvedValue({ context: 'vessels.self', range: { to: new Date(1_000_000).toISOString() }, values: [], data: [] }); mapper.mapValuesToChartDatapoints.mockReturnValue([{ timestamp: 1_000_000, data: { value: 5 } }]); - const emissions: (IDatasetServiceDatapoint | IDatasetServiceDatapoint[] | { unavailable: true })[] = []; + const emissions: (IGraphDatapoint | IGraphDatapoint[] | { unavailable: true })[] = []; make().getBackfillThenLive(PARAMS).subscribe(e => emissions.push(e)); await vi.advanceTimersByTimeAsync(0); // settle the mount backfill → beginLive replays Disconnected @@ -1172,7 +1172,7 @@ describe('HistoryChartStreamService', () => { // Exactly one re-backfill fires, scoped to the gap; without it the outage would draw a silent line. expect(history.getValues.mock.calls.length).toBe(callsBefore + 1); - const batch = emissions.filter(e => Array.isArray(e)).pop() as IDatasetServiceDatapoint[]; + const batch = emissions.filter(e => Array.isArray(e)).pop() as IGraphDatapoint[]; expect(batch.map(p => p.data.value)).toEqual([9]); } finally { vi.useRealTimers(); diff --git a/src/app/core/services/history-chart-stream.service.ts b/src/app/core/services/history-graph-stream.service.ts similarity index 93% rename from src/app/core/services/history-chart-stream.service.ts rename to src/app/core/services/history-graph-stream.service.ts index c755c0cc..3ff906e5 100644 --- a/src/app/core/services/history-chart-stream.service.ts +++ b/src/app/core/services/history-graph-stream.service.ts @@ -2,11 +2,11 @@ import { Injectable, inject } from '@angular/core'; import { Observable, Subscription, distinctUntilChanged, filter, merge, shareReplay, take, timer, withLatestFrom } from 'rxjs'; import { DataService, IPathUpdate } from './data.service'; import { HistoryApiClientService, HistoryRequestError } from './history-api-client.service'; -import { HistoryToChartMapperService } from './history-to-chart-mapper.service'; +import { HistoryToGraphMapperService } from './history-to-graph-mapper.service'; import { ConnectionState, ConnectionStateMachine } from './connection-state-machine.service'; import { resolveAngleDomain } from '../utils/angle-domain.util'; -import { IDatasetServiceDatapoint } from '../interfaces/dataset.interfaces'; -import { computeWindowStats, windowSma, ChartStatsDomain } from '../utils/chart-stats.util'; +import { IGraphDatapoint } from '../interfaces/graph-data.interfaces'; +import { computeWindowStats, windowSma, GraphStatsDomain } from '../utils/graph-stats.util'; /** Emitted (instead of datapoints) when trend history cannot be served — no history provider. */ export interface IHistoryUnavailable { @@ -27,7 +27,7 @@ const BOOTSTRAP_STALE_MS = 30_000; /** * How long the source may be silent before its trace should break, keyed to the observed update - * interval (not the chart cadence) so a slow-but-alive source is not gapped. Shared by the live tail + * interval (not the graph cadence) so a slow-but-alive source is not gapped. Shared by the live tail * and the reconnect gap so a reconnect breaks the trace on exactly the same silence the live tail would. */ function staleThresholdMs(intervalMs: number | null): number { @@ -47,18 +47,18 @@ const RECONNECT_FROM_SKEW_MARGIN_MS = 10_000; /** * Cap on how long the live tail is held while a reconnect re-backfill fetch is in flight. Past this the * live tail resumes and the gap is drawn honestly, rather than letting a slow/overloaded history provider - * (bounded only by the 30s History-API HTTP timeout) freeze the chart and drop healthy live deltas. + * (bounded only by the 30s History-API HTTP timeout) freeze the graph and drop healthy live deltas. */ const RECONNECT_HOLD_MS = 3_000; /** Sentinel: the re-backfill hold cap elapsed before the fetch resolved. */ const HOLD_TIMEOUT = Symbol('reconnect-hold-timeout'); -/** Inputs for one chart's History-API-backed data stream. */ -export interface IHistoryChartStreamParams { +/** Inputs for one graph's History-API-backed data stream. */ +export interface IHistoryGraphStreamParams { path: string; source: string; - /** Raw per-chart angle-range override; combined with the path's base unit to resolve the domain. */ + /** Raw per-graph angle-range override; combined with the path's base unit to resolve the domain. */ angleDomainOverride?: 'signed' | 'direction'; windowMs: number; sampleTime: number; @@ -66,7 +66,7 @@ export interface IHistoryChartStreamParams { smoothingPeriod: number; } -type StreamEmission = IDatasetServiceDatapoint[] | IDatasetServiceDatapoint | IHistoryUnavailable; +type StreamEmission = IGraphDatapoint[] | IGraphDatapoint | IHistoryUnavailable; /** Per-stream mutable state shared between the live tail and the reconnect re-backfill (#85). */ interface IStreamCtx { @@ -98,15 +98,15 @@ interface IStreamCtx { } /** - * Trend-chart data path: History-API backfill for the initial window, then a thin SK delta-stream + * Trend-graph data path: History-API backfill for the initial window, then a thin SK delta-stream * live tail with a minimal rolling buffer and the shared stats util. When no history provider is - * available the stream emits {@link HISTORY_UNAVAILABLE} and trend charts degrade to a clean empty + * available the stream emits {@link HISTORY_UNAVAILABLE} and trend graphs degrade to a clean empty * state. */ @Injectable({ providedIn: 'root' }) -export class HistoryChartStreamService { +export class HistoryGraphStreamService { private readonly history = inject(HistoryApiClientService); - private readonly mapper = inject(HistoryToChartMapperService); + private readonly mapper = inject(HistoryToGraphMapperService); private readonly data = inject(DataService); private readonly connection = inject(ConnectionStateMachine); @@ -114,9 +114,9 @@ export class HistoryChartStreamService { * Backfill (History API, one-shot) then a live delta tail. Emits the backfill as a single array, * then live datapoints one at a time. Emits {@link HISTORY_UNAVAILABLE} and stops when there is no * history provider. A *transient* backfill failure (network/5xx/timeout) - * does not disable the chart: it falls through to the live delta tail with no backfill seed. + * does not disable the graph: it falls through to the live delta tail with no backfill seed. */ - public getBackfillThenLive(params: IHistoryChartStreamParams): Observable { + public getBackfillThenLive(params: IHistoryGraphStreamParams): Observable { return new Observable(subscriber => { const domain = resolveAngleDomain(params.path, this.data.getPathUnitType(params.path), params.angleDomainOverride); const ctx: IStreamCtx = { lastEmittedTs: null, connected: true, backfillInFlight: false, reconnectPending: false, sourceIntervalMs: null, resetCadenceBaseline: false, seeded: false, disposed: false, holdDeadline: null }; @@ -173,7 +173,7 @@ export class HistoryChartStreamService { .catch(err => { if (ctx.disposed) return; if (err instanceof HistoryRequestError) { - // Transient backfill failure (network blip, 5xx, timeout): don't disable the chart. Ride + // Transient backfill failure (network blip, 5xx, timeout): don't disable the graph. Ride // the live delta tail with no seed so live data still renders; the first Connected then // drives a re-backfill (ctx.seeded is false) so the window is filled without waiting for a // WS drop. Only a genuine no-provider (null result above) degrades to empty. @@ -183,7 +183,7 @@ export class HistoryChartStreamService { } // An unexpected error (e.g. a mapper/logic bug), not a known request failure: degrade to // empty but log it so a real bug is not indistinguishable from a legitimate no-provider. - console.error('[HistoryChartStreamService] Unexpected backfill error; degrading to history-unavailable:', err); + console.error('[HistoryGraphStreamService] Unexpected backfill error; degrading to history-unavailable:', err); subscriber.next(HISTORY_UNAVAILABLE); subscriber.complete(); }); @@ -202,8 +202,8 @@ export class HistoryChartStreamService { } private startLive( - params: IHistoryChartStreamParams, - domain: ChartStatsDomain, + params: IHistoryGraphStreamParams, + domain: GraphStatsDomain, buffer: number[], offsetMs: number | null, newestBackfillTs: number | null, @@ -234,7 +234,7 @@ export class HistoryChartStreamService { let prevSourceTs: number | null = null; let intervalMs: number | null = null; let gapMarked = false; - // Staleness is keyed to the SOURCE's observed update interval, not the chart's render cadence, so a + // Staleness is keyed to the SOURCE's observed update interval, not the graph's render cadence, so a // slow-but-alive sensor holds while a genuinely dead one (or a stale value replayed on subscribe) // gaps. Until an interval is observed, only a long silence counts as a dropout. const staleAfterMs = (): number => staleThresholdMs(intervalMs); @@ -278,7 +278,7 @@ export class HistoryChartStreamService { // seam, hold the live tail: otherwise the sampled$ replay of the last stale value would fire a gap // marker mid-drop that the later re-backfill batch cannot order cleanly against. if (!ctx.connected || ctx.backfillInFlight) return; - // Stamp with the client clock so points sit on the chart's realtime axis; + // Stamp with the client clock so points sit on the graph's realtime axis; // server timestamps would drift the whole series under clock skew. const now = Date.now(); const sourceTs = sourceTsOf(u); @@ -302,7 +302,7 @@ export class HistoryChartStreamService { return { sub, release: handle.release }; } - private async fetchBackfill(params: IHistoryChartStreamParams, domain: ChartStatsDomain, fromMs: number = Date.now() - params.windowMs, signal?: AbortSignal): Promise<{ points: IDatasetServiceDatapoint[]; offsetMs: number } | null> { + private async fetchBackfill(params: IHistoryGraphStreamParams, domain: GraphStatsDomain, fromMs: number = Date.now() - params.windowMs, signal?: AbortSignal): Promise<{ points: IGraphDatapoint[]; offsetMs: number } | null> { const normalizedPath = params.path.replace(/^(vessels\.)?self\./, ''); // Only the raw per-bucket value is fetched; the SMA overlay is derived client-side below so it // uses the same circular-aware smoothing as the live tail (#162). @@ -351,14 +351,14 @@ export class HistoryChartStreamService { * filled (#85). Covers only [max(seam - skew margin, now - windowMs), now] and drops points at or * before the seam, so the batch appends cleanly and in order against the buffered/live points. The * live tail is held only until the fetch resolves or the coalesced chain's shared {@link RECONNECT_HOLD_MS} - * budget elapses (whichever comes first), so a slow provider cannot freeze the chart even under sustained + * budget elapses (whichever comes first), so a slow provider cannot freeze the graph even under sustained * WS flapping; when the budget wins, the abandoned fetch's HTTP request is aborted so stale GETs do not * stack on the struggling provider. If the re-backfill delivers nothing (empty, error, or hold-timeout), * {@link emitReconnectGap} breaks the trace so the live tail does not interpolate. */ private async reBackfill( - params: IHistoryChartStreamParams, - domain: ChartStatsDomain, + params: IHistoryGraphStreamParams, + domain: GraphStatsDomain, buffer: number[], subscriber: { next: (v: StreamEmission) => void }, ctx: IStreamCtx @@ -401,7 +401,7 @@ export class HistoryChartStreamService { } if (ctx.disposed) return; // outcome === HOLD_TIMEOUT: the provider is too slow — stop holding the live tail and fall through - // to the honest gap below rather than freeze the chart for up to the 30s HTTP timeout. + // to the honest gap below rather than freeze the graph for up to the 30s HTTP timeout. if (outcome !== HOLD_TIMEOUT) { const fresh = outcome === null ? [] : (seam !== null ? outcome.points.filter(p => p.timestamp > seam) : outcome.points); if (fresh.length > 0) { @@ -419,7 +419,7 @@ export class HistoryChartStreamService { // A transient error/timeout is expected when the history provider is down or slow; only an // unexpected error is worth surfacing. Either path falls through to the honest-gap decision below. if (!(err instanceof HistoryRequestError)) { - console.error('[HistoryChartStreamService] Unexpected re-backfill error:', err); + console.error('[HistoryGraphStreamService] Unexpected re-backfill error:', err); } } finally { if (holdTimer !== null) clearTimeout(holdTimer); diff --git a/src/app/core/services/history-to-chart-mapper.service.spec.ts b/src/app/core/services/history-to-graph-mapper.service.spec.ts similarity index 92% rename from src/app/core/services/history-to-chart-mapper.service.spec.ts rename to src/app/core/services/history-to-graph-mapper.service.spec.ts index 3a0fa8df..ab1c8db3 100644 --- a/src/app/core/services/history-to-chart-mapper.service.spec.ts +++ b/src/app/core/services/history-to-graph-mapper.service.spec.ts @@ -1,17 +1,17 @@ import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it } from 'vitest'; -import { HistoryToChartMapperService } from './history-to-chart-mapper.service'; +import { HistoryToGraphMapperService } from './history-to-graph-mapper.service'; import { IHistoryValuesResponse } from './history-api-client.service'; -describe('HistoryToChartMapperService', () => { - let service: HistoryToChartMapperService; +describe('HistoryToGraphMapperService', () => { + let service: HistoryToGraphMapperService; beforeEach(() => { TestBed.configureTestingModule({ - providers: [HistoryToChartMapperService] + providers: [HistoryToGraphMapperService] }); - service = TestBed.inject(HistoryToChartMapperService); + service = TestBed.inject(HistoryToGraphMapperService); }); it('maps average method alias to datapoint value extraction', () => { @@ -123,7 +123,7 @@ describe('HistoryToChartMapperService', () => { expect(datapoints[1].data.value).toBe(0.05); }); - it('does not plot a lone :sma column as the Value series', () => { + it('does not graph a lone :sma column as the Value series', () => { const response: IHistoryValuesResponse = { context: 'vessels.self', range: { diff --git a/src/app/core/services/history-to-chart-mapper.service.ts b/src/app/core/services/history-to-graph-mapper.service.ts similarity index 77% rename from src/app/core/services/history-to-chart-mapper.service.ts rename to src/app/core/services/history-to-graph-mapper.service.ts index 5608ed31..3c9caec4 100644 --- a/src/app/core/services/history-to-chart-mapper.service.ts +++ b/src/app/core/services/history-to-graph-mapper.service.ts @@ -1,17 +1,17 @@ import { Injectable } from '@angular/core'; import { IHistoryValuesResponse } from './history-api-client.service'; import { - ChartStatsDomain, + GraphStatsDomain, circularMeanRad, circularMinMaxRad, normalizeDirectionRad, normalizeSignedRad -} from '../utils/chart-stats.util'; +} from '../utils/graph-stats.util'; /** - * Normalized historical datapoint shape used by chart-oriented consumers. + * Normalized historical datapoint shape used by graph-oriented consumers. */ -export interface IHistoryChartDatapoint { +export interface IHistoryGraphDatapoint { timestamp: number; data: { value: number | null; @@ -26,32 +26,32 @@ export interface IHistoryChartDatapoint { /** * Shared adapter that converts Signal K History API responses into a normalized - * chart-friendly datapoint layout. + * graph-friendly datapoint layout. */ @Injectable({ providedIn: 'root' }) -export class HistoryToChartMapperService { +export class HistoryToGraphMapperService { /** - * Maps a History API response payload into normalized chart datapoints. + * Maps a History API response payload into normalized graph datapoints. * * - Detects the Value column (`last` preferred, else `avg`/`average`) and `sma` from `response.values`. * - Emits one datapoint per response row. * - Computes dataset-wide summary stats from mapped datapoint values and * stores them on the final datapoint (`lastAverage`, `lastMinimum`, `lastMaximum`). * - * Angular (`direction`/`signed`) domains use circular math from `chart-stats.util`, matching the + * Angular (`direction`/`signed`) domains use circular math from `graph-stats.util`, matching the * live-tail statistics so backfill and live tail agree; `scalar` uses arithmetic aggregates. * * @param {IHistoryValuesResponse} response Raw History API response. - * @param {{ domain: ChartStatsDomain }} options Mapping options. - * @param {ChartStatsDomain} options.domain Domain interpretation; `scalar` is plain numeric. - * @returns {IHistoryChartDatapoint[]} Normalized datapoints ready for chart/data prefill pipelines. + * @param {{ domain: GraphStatsDomain }} options Mapping options. + * @param {GraphStatsDomain} options.domain Domain interpretation; `scalar` is plain numeric. + * @returns {IHistoryGraphDatapoint[]} Normalized datapoints ready for graph/data prefill pipelines. */ public mapValuesToChartDatapoints( response: IHistoryValuesResponse, - options: { domain: ChartStatsDomain } - ): IHistoryChartDatapoint[] { + options: { domain: GraphStatsDomain } + ): IHistoryGraphDatapoint[] { const rows = response?.data; if (!rows || rows.length === 0) { return []; @@ -73,7 +73,7 @@ export class HistoryToChartMapperService { // Single-column fallback: treat the lone column as the value only when it is not a // recognized non-value method. Guarding on smaIndex stops a provider that returns just an - // `sma` column (e.g. one that silently drops an unsupported `:last`) from being plotted as + // `sma` column (e.g. one that silently drops an unsupported `:last`) from being graphed as // the raw Value series. if (lastIndex < 0 && avgIndex < 0 && smaIndex < 0 && response.values.length === 1) { avgIndex = 1; @@ -84,7 +84,7 @@ export class HistoryToChartMapperService { // The Value series prefers the raw per-bucket `last` sample: it is angle-safe at the 0/360° wrap // and seam-free against the client-stamped live tail. `avg`/`average` stays the fallback for - // callers (e.g. the history-chart dialog) that still request bucket means. + // callers (e.g. the history-graph dialog) that still request bucket means. const valueIndex = lastIndex >= 0 ? lastIndex : avgIndex; const shouldNormalizeAngle = options.domain !== 'scalar'; @@ -92,7 +92,7 @@ export class HistoryToChartMapperService { ? (options.domain === 'signed' ? normalizeSignedRad : normalizeDirectionRad) : null; - const datapoints: IHistoryChartDatapoint[] = []; + const datapoints: IHistoryGraphDatapoint[] = []; let scalarSum = 0; let scalarMin = Number.POSITIVE_INFINITY; @@ -144,27 +144,27 @@ export class HistoryToChartMapperService { } if (datapoints.length > 0) { - let datasetAverage: number | null = null; - let datasetMinimum: number | null = null; - let datasetMaximum: number | null = null; + let seriesAverage: number | null = null; + let seriesMinimum: number | null = null; + let seriesMaximum: number | null = null; if (shouldNormalizeAngle && angleValues.length > 0) { const wrap = options.domain === 'signed' ? normalizeSignedRad : normalizeDirectionRad; const { min, max } = circularMinMaxRad(angleValues); - datasetAverage = wrap(circularMeanRad(angleValues)); - datasetMinimum = wrap(min); - datasetMaximum = wrap(max); + seriesAverage = wrap(circularMeanRad(angleValues)); + seriesMinimum = wrap(min); + seriesMaximum = wrap(max); } else if (!shouldNormalizeAngle && scalarCount > 0) { - datasetAverage = scalarSum / scalarCount; - datasetMinimum = scalarMin; - datasetMaximum = scalarMax; + seriesAverage = scalarSum / scalarCount; + seriesMinimum = scalarMin; + seriesMaximum = scalarMax; } - if (datasetAverage !== null && datasetMinimum !== null && datasetMaximum !== null) { + if (seriesAverage !== null && seriesMinimum !== null && seriesMaximum !== null) { const finalDatapoint = datapoints[datapoints.length - 1]; - finalDatapoint.data.lastAverage = datasetAverage; - finalDatapoint.data.lastMinimum = datasetMinimum; - finalDatapoint.data.lastMaximum = datasetMaximum; + finalDatapoint.data.lastAverage = seriesAverage; + finalDatapoint.data.lastMinimum = seriesMinimum; + finalDatapoint.data.lastMaximum = seriesMaximum; } } diff --git a/src/app/core/services/units.service.spec.ts b/src/app/core/services/units.service.spec.ts index e9561e9b..51510439 100644 --- a/src/app/core/services/units.service.spec.ts +++ b/src/app/core/services/units.service.spec.ts @@ -39,7 +39,7 @@ describe('UnitsService', () => { }); it('renders no symbol for the Unitless measures, so no gauge prints the word "unitless"', () => { - // The steel gauge, the linear and radial gauges, the data chart title and the history dialog's + // The steel gauge, the linear and radial gauges, the data graph title and the history dialog's // axis all label themselves from this seam with a resolved measure, which is 'unitless' whenever // the server states no preference for the path (#536). const service = setup(); diff --git a/src/app/core/services/widget.service.ts b/src/app/core/services/widget.service.ts index 565e88cd..e251bc15 100644 --- a/src/app/core/services/widget.service.ts +++ b/src/app/core/services/widget.service.ts @@ -118,7 +118,7 @@ export class WidgetService { private readonly _componentTypeMap: Record Promise>> = { WidgetNumericComponent: () => import('../../widgets/widget-numeric/widget-numeric.component').then(m => m.WidgetNumericComponent), WidgetTextComponent: () => import('../../widgets/widget-text/widget-text.component').then(m => m.WidgetTextComponent), - WidgetWindTrendsChartComponent: () => import('../../widgets/widget-windtrends-chart/widget-windtrends-chart.component').then(m => m.WidgetWindTrendsChartComponent), + WidgetWindTrendsGraphComponent: () => import('../../widgets/widget-windtrends-graph/widget-windtrends-graph.component').then(m => m.WidgetWindTrendsGraphComponent), WidgetWindComponent: () => import('../../widgets/widget-windsteer/widget-windsteer.component').then(m => m.WidgetWindComponent), WidgetSliderComponent: () => import('../../widgets/widget-slider/widget-slider.component').then(m => m.WidgetSliderComponent), WidgetSimpleLinearComponent: () => import('../../widgets/widget-simple-linear/widget-simple-linear.component').then(m => m.WidgetSimpleLinearComponent), @@ -140,7 +140,7 @@ export class WidgetService { WidgetHoekensAnchorAlarmComponent: () => import('../../widgets/widget-hoekens-anchor-alarm/widget-hoekens-anchor-alarm.component').then(m => m.WidgetHoekensAnchorAlarmComponent), WidgetAnchorAlarmComponent: () => import('../../widgets/widget-anchor-alarm/widget-anchor-alarm.component').then(m => m.WidgetAnchorAlarmComponent), WidgetDatetimeComponent: () => import('../../widgets/widget-datetime/widget-datetime.component').then(m => m.WidgetDatetimeComponent), - WidgetDataChartComponent: () => import('../../widgets/widget-data-chart/widget-data-chart.component').then(m => m.WidgetDataChartComponent), + WidgetDataGraphComponent: () => import('../../widgets/widget-data-graph/widget-data-graph.component').then(m => m.WidgetDataGraphComponent), WidgetBooleanSwitchComponent: () => import('../../widgets/widget-boolean-switch/widget-boolean-switch.component').then(m => m.WidgetBooleanSwitchComponent), WidgetMultiStateSwitchComponent: () => import('../../widgets/widget-multi-state-switch/widget-multi-state-switch.component').then(m => m.WidgetMultiStateSwitchComponent), WidgetZonesStatePanelComponent: () => import('../../widgets/widget-zones-state-panel/widget-zones-state-panel.component').then(m => m.WidgetZonesStatePanelComponent), @@ -156,7 +156,7 @@ export class WidgetService { private readonly _widgetDefinition: readonly WidgetDescription[] = [ { name: 'Numeric', - description: 'Displays numeric data in a clear and concise format, with options to show minimum and/or maximum recorded values. Includes an optional background mini plot for quick visual trend insights.', + description: 'Displays numeric data in a clear and concise format, with options to show minimum and/or maximum recorded values. Includes an optional background mini graph for quick visual trend insights.', icon: 'numericWidget', minWidth: 1, minHeight: 2, @@ -481,17 +481,19 @@ export class WidgetService { componentClassName: 'WidgetAutopilotComponent' }, { - name: 'Realtime Data Plot', - description: 'Visualizes data on a real-time plot with multiple preconfigured series including actuals, SMA and period overall averages and Min/Max. Requires the Skip Dataset to be configured.', - icon: 'datachartWidget', + name: 'Data Graph', + description: 'Graphs any numeric path over a configurable time window, with preconfigured series including actuals, SMA and period overall averages and Min/Max. Seeds from the Signal K History API when a provider is available.', + icon: 'datagraphWidget', minWidth: 2, minHeight: 3, defaultWidth: 6, defaultHeight: 6, category: 'Component', requiredPlugins: [], + // Persisted widget `type` in stored dashboards; keeps the pre-rename spelling until a + // config migration renames it (#592). selector: 'widget-data-chart', - componentClassName: 'WidgetDataChartComponent' + componentClassName: 'WidgetDataGraphComponent' }, { name: "Hoeken's Anchor Alarm", @@ -613,7 +615,7 @@ export class WidgetService { }, { name: 'Wind Trends', - description: 'A real-time wind trends graph with dual axes for direction and speed. Displays live values and simple moving averages over the current period’s average.', + description: 'A live wind trends graph with dual axes for direction and speed. Displays live values and simple moving averages over the current period’s average.', icon: 'windtrendsWidget', minWidth: 8, minHeight: 6, @@ -621,8 +623,10 @@ export class WidgetService { defaultHeight: 8, category: 'Racing', requiredPlugins: [], + // Persisted widget `type` in stored dashboards; keeps the pre-rename spelling until a + // config migration renames it (#592). selector: 'widget-windtrends-chart', - componentClassName: 'WidgetWindTrendsChartComponent' + componentClassName: 'WidgetWindTrendsGraphComponent' }, ]; diff --git a/src/app/core/utils/angle-domain.util.spec.ts b/src/app/core/utils/angle-domain.util.spec.ts index 08848372..14654ecb 100644 --- a/src/app/core/utils/angle-domain.util.spec.ts +++ b/src/app/core/utils/angle-domain.util.spec.ts @@ -9,9 +9,9 @@ describe('angle-domain.util', () => { }); it('honors an explicit override when the base unit is unknown (metadata not yet published)', () => { - // A history chart commonly views past data while the producing instrument is idle, so + // A history graph commonly views past data while the producing instrument is idle, so // getPathUnitType returns null; an explicit signed/direction override must still drive circular - // stats instead of silently reverting the chart to linear math. + // stats instead of silently reverting the graph to linear math. expect(resolveAngleDomain('environment.wind.angleApparent', null, 'signed')).toBe('signed'); expect(resolveAngleDomain('some.plugin.windShift', undefined, 'direction')).toBe('direction'); }); diff --git a/src/app/core/utils/angle-domain.util.ts b/src/app/core/utils/angle-domain.util.ts index afdb07ad..8e906f3c 100644 --- a/src/app/core/utils/angle-domain.util.ts +++ b/src/app/core/utils/angle-domain.util.ts @@ -1,8 +1,8 @@ -import { ChartStatsDomain } from './chart-stats.util'; +import { GraphStatsDomain } from './graph-stats.util'; /** * Signal K paths interpreted as signed angles (-π, π]. Every other radian path defaults to the - * direction domain [0, 2π). A per-chart override still wins over this allowlist. + * direction domain [0, 2π). A per-graph override still wins over this allowlist. */ const SIGNED_ANGLE_PATHS: ReadonlySet = new Set([ 'self.navigation.attitude.roll', @@ -22,17 +22,17 @@ export function normalizeAnglePathKey(path: string): string { /** * Resolve how a path's radian values should be interpreted. Non-radian paths are `scalar`; a * `signed`/`direction` override wins for any radian path; otherwise the allowlist selects `signed` - * and everything else is `direction`. Single source of truth for both chart engines. + * and everything else is `direction`. Single source of truth for both graph engines. */ export function resolveAngleDomain( path: string, baseUnit: string | null | undefined, override?: 'signed' | 'direction' -): ChartStatsDomain { +): GraphStatsDomain { // A positively non-radian unit is scalar even if a stale override lingers on the config. if (baseUnit != null && baseUnit !== '' && baseUnit !== 'rad') return 'scalar'; // Radian, or an unknown unit (metadata not yet published — common when viewing history while the - // producing instrument is idle): an explicit override wins, so angular charts aren't silently + // producing instrument is idle): an explicit override wins, so angular graphs aren't silently // reverted to linear stats when the base unit can't be read. if (override === 'signed' || override === 'direction') return override; // Unknown unit and no override: we can't tell it's angular, so stay scalar. diff --git a/src/app/core/utils/chart-stats.util.spec.ts b/src/app/core/utils/graph-stats.util.spec.ts similarity index 95% rename from src/app/core/utils/chart-stats.util.spec.ts rename to src/app/core/utils/graph-stats.util.spec.ts index 320f9116..3575bee9 100644 --- a/src/app/core/utils/chart-stats.util.spec.ts +++ b/src/app/core/utils/graph-stats.util.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { circularMeanRad, circularMinMaxRad, computeWindowStats, normalizeDirectionRad, normalizeSignedRad } from './chart-stats.util'; +import { circularMeanRad, circularMinMaxRad, computeWindowStats, normalizeDirectionRad, normalizeSignedRad } from './graph-stats.util'; -describe('chart-stats.util', () => { +describe('graph-stats.util', () => { it('computes scalar value / sma / average / min / max over the window', () => { const stats = computeWindowStats([1, 2, 3], 2, 'scalar'); expect(stats.value).toBe(3); @@ -33,7 +33,7 @@ describe('chart-stats.util', () => { it('normalizes exactly π to +π in the signed domain (atan2 boundary, shared by both engines)', () => { // The single shared signed normalizer is the atan2 form, so 180° maps to +π (the included end of - // (-π, π]), not the recorder's former mod-based -π. Both chart engines now agree here. + // (-π, π]), not the recorder's former mod-based -π. Both graph engines now agree here. expect(normalizeSignedRad(Math.PI)).toBeCloseTo(Math.PI, 10); }); diff --git a/src/app/core/utils/chart-stats.util.ts b/src/app/core/utils/graph-stats.util.ts similarity index 91% rename from src/app/core/utils/chart-stats.util.ts rename to src/app/core/utils/graph-stats.util.ts index fffd57f4..d9a49e0d 100644 --- a/src/app/core/utils/chart-stats.util.ts +++ b/src/app/core/utils/graph-stats.util.ts @@ -1,8 +1,8 @@ /** How radian angles are wrapped for display; `scalar` is plain numeric. */ -export type ChartStatsDomain = 'scalar' | 'direction' | 'signed'; +export type GraphStatsDomain = 'scalar' | 'direction' | 'signed'; /** Per-point statistics over a rolling window (newest value last). */ -export interface IChartPointStats { +export interface IGraphPointStats { value: number; sma: number; lastAverage: number; @@ -52,7 +52,7 @@ export function circularMinMaxRad(anglesRad: number[]): { min: number; max: numb * the domain for `direction`/`signed`. Shared by the live tail and the History backfill so both * smooth identically. The window must already be sliced to the smoothing period. */ -export function windowSma(window: number[], domain: ChartStatsDomain): number { +export function windowSma(window: number[], domain: GraphStatsDomain): number { if (domain === 'scalar') { return window.reduce((s, v) => s + v, 0) / window.length; } @@ -63,9 +63,9 @@ export function windowSma(window: number[], domain: ChartStatsDomain): number { /** * Compute value + SMA + window average/min/max over a numeric buffer (newest last). Scalar uses * arithmetic aggregates; `direction`/`signed` use circular math and wrap the outputs to their domain. - * Shared by the History-API chart live tail; mirrors the recorder's per-point statistics. + * Shared by the History-API graph live tail; mirrors the recorder's per-point statistics. */ -export function computeWindowStats(buffer: number[], smoothingPeriod: number, domain: ChartStatsDomain): IChartPointStats { +export function computeWindowStats(buffer: number[], smoothingPeriod: number, domain: GraphStatsDomain): IGraphPointStats { const value = buffer[buffer.length - 1]; const smaWindow = buffer.slice(Math.max(0, buffer.length - Math.max(1, smoothingPeriod))); diff --git a/src/app/core/utils/chart-window.util.spec.ts b/src/app/core/utils/graph-window.util.spec.ts similarity index 97% rename from src/app/core/utils/chart-window.util.spec.ts rename to src/app/core/utils/graph-window.util.spec.ts index c8fa1b99..f2311fe3 100644 --- a/src/app/core/utils/chart-window.util.spec.ts +++ b/src/app/core/utils/graph-window.util.spec.ts @@ -5,9 +5,9 @@ import { TARGET_POINTS_PER_WINDOW, MIN_SAMPLE_TIME_MS, SMOOTHING_PERIOD_FACTOR -} from './chart-window.util'; +} from './graph-window.util'; -describe('chart-window.util', () => { +describe('graph-window.util', () => { describe('resolveWindowMs', () => { it('maps the fixed presets, ignoring period', () => { expect(resolveWindowMs('Last Minute', 999)).toBe(60_000); diff --git a/src/app/core/utils/chart-window.util.ts b/src/app/core/utils/graph-window.util.ts similarity index 89% rename from src/app/core/utils/chart-window.util.ts rename to src/app/core/utils/graph-window.util.ts index d65e8a02..5f7c779f 100644 --- a/src/app/core/utils/chart-window.util.ts +++ b/src/app/core/utils/graph-window.util.ts @@ -1,4 +1,4 @@ -import type { TimeScaleFormat } from '../interfaces/dataset.interfaces'; +import type { TimeScaleFormat } from '../interfaces/graph-data.interfaces'; /** Points a window aims for at its derived cadence, once above the sample-time floor. */ export const TARGET_POINTS_PER_WINDOW = 500; @@ -8,7 +8,7 @@ export const MIN_SAMPLE_TIME_MS = 100; export const SMOOTHING_PERIOD_FACTOR = 0.25; /** Sampling cadence + buffer size derived from a display window. */ -export interface IChartDataSourceInfo { +export interface IGraphDataSourceInfo { /** Path value sampling interval in ms. */ sampleTime: number; /** Rolling buffer capacity (points kept for the window). */ @@ -19,7 +19,7 @@ export interface IChartDataSourceInfo { /** * Window length in ms for a time-scale format + period. `Last *` presets ignore `period`. - * Derives the chart's display window from the widget config. + * Derives the graph's display window from the widget config. */ export function resolveWindowMs(timeScaleFormat: TimeScaleFormat, period: number): number { switch (timeScaleFormat) { @@ -47,7 +47,7 @@ export function resolveWindowMs(timeScaleFormat: TimeScaleFormat, period: number * window with a floor sampling interval for very small windows. The point count tracks the target * (never far above it), so no separate buffer cap is needed. */ -export function deriveDataSourceInfo(windowMs: number): IChartDataSourceInfo { +export function deriveDataSourceInfo(windowMs: number): IGraphDataSourceInfo { const sampleTime = windowMs > 0 ? Math.max(MIN_SAMPLE_TIME_MS, Math.round(windowMs / TARGET_POINTS_PER_WINDOW)) : 1000; diff --git a/src/app/widget-config/dataset-chart-options/dataset-chart-options.component.html b/src/app/widget-config/graph-data-options/graph-data-options.component.html similarity index 94% rename from src/app/widget-config/dataset-chart-options/dataset-chart-options.component.html rename to src/app/widget-config/graph-data-options/graph-data-options.component.html index 0a1f6a0b..7f98e9c4 100644 --- a/src/app/widget-config/dataset-chart-options/dataset-chart-options.component.html +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.html @@ -75,14 +75,14 @@ }

- Time scale and duration set how far back the plot shows — the plot window. Shorter windows show finer detail. + Time scale and duration set how far back the graph shows — the graph window. Shorter windows show finer detail.

Time Scale @@ -104,7 +104,7 @@
- History API: When the time scale is minutes or longer, the Data Plot can be pre-filled with historical data. + History API: When the time scale is minutes or longer, the Data Graph can be pre-filled with historical data. Learn more
diff --git a/src/app/widget-config/dataset-chart-options/dataset-chart-options.component.scss b/src/app/widget-config/graph-data-options/graph-data-options.component.scss similarity index 100% rename from src/app/widget-config/dataset-chart-options/dataset-chart-options.component.scss rename to src/app/widget-config/graph-data-options/graph-data-options.component.scss diff --git a/src/app/widget-config/dataset-chart-options/dataset-chart-options.component.spec.ts b/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts similarity index 91% rename from src/app/widget-config/dataset-chart-options/dataset-chart-options.component.spec.ts rename to src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts index 83266ba2..71eae3ee 100644 --- a/src/app/widget-config/dataset-chart-options/dataset-chart-options.component.spec.ts +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UntypedFormControl } from '@angular/forms'; -import { DatasetChartOptionsComponent } from './dataset-chart-options.component'; +import { GraphDataOptionsComponent } from './graph-data-options.component'; import { DataService } from '../../core/services/data.service'; import { UnitsService } from '../../core/services/units.service'; import { ISkPathData } from '../../core/interfaces/app-interfaces'; @@ -9,15 +9,15 @@ import { ISkPathData } from '../../core/interfaces/app-interfaces'; const src = (...keys: string[]): ISkPathData['sources'] => Object.fromEntries(keys.map(k => [k, { sourceTimestamp: '', sourceValue: 0 }])); -describe('DatasetChartOptionsComponent', () => { - let component: DatasetChartOptionsComponent; - let fixture: ComponentFixture; +describe('GraphDataOptionsComponent', () => { + let component: GraphDataOptionsComponent; + let fixture: ComponentFixture; let pathObject: Partial | null; beforeEach(async () => { pathObject = null; await TestBed.configureTestingModule({ - imports: [DatasetChartOptionsComponent], + imports: [GraphDataOptionsComponent], providers: [ { provide: DataService, @@ -36,7 +36,7 @@ describe('DatasetChartOptionsComponent', () => { }) .compileComponents(); - fixture = TestBed.createComponent(DatasetChartOptionsComponent); + fixture = TestBed.createComponent(GraphDataOptionsComponent); component = fixture.componentInstance; const set = fixture.componentRef.setInput.bind(fixture.componentRef) as (k: string, v: unknown) => void; set('filterSelfPaths', new UntypedFormControl(false)); @@ -55,7 +55,7 @@ describe('DatasetChartOptionsComponent', () => { // A saved config references a path whose data has not (yet) arrived in _skData, // so getPathObject returns null. ngOnInit must skip setPathSources, not deref null. pathObject = null; - const fx = TestBed.createComponent(DatasetChartOptionsComponent); + 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('self.navigation.speedOverGround')); @@ -100,7 +100,7 @@ describe('DatasetChartOptionsComponent', () => { // The published list is empty in this suite, so any configured path reads as unpublished (#501). const mountWithPath = (path: string) => { - const fx = TestBed.createComponent(DatasetChartOptionsComponent); + const fx = TestBed.createComponent(GraphDataOptionsComponent); const set = fx.componentRef.setInput.bind(fx.componentRef) as (k: string, v: unknown) => void; const pathControl = new UntypedFormControl(path); set('filterSelfPaths', new UntypedFormControl(false)); @@ -112,7 +112,7 @@ describe('DatasetChartOptionsComponent', () => { return { fixture: fx, pathControl }; }; - const pathWarning = (fx: ComponentFixture) => + const pathWarning = (fx: ComponentFixture) => (fx.componentInstance as unknown as { pathWarning: () => string | null }).pathWarning(); it('leaves an unsent path valid, so Save stays available', () => { @@ -132,7 +132,7 @@ describe('DatasetChartOptionsComponent', () => { // Otherwise the Source select renders enabled and required with no options at all, in exactly // the case this change makes reachable. pathObject = null; - const fx = TestBed.createComponent(DatasetChartOptionsComponent); + const fx = TestBed.createComponent(GraphDataOptionsComponent); const set = fx.componentRef.setInput.bind(fx.componentRef) as (k: string, v: unknown) => void; const sourceControl = new UntypedFormControl('gps.7'); set('filterSelfPaths', new UntypedFormControl(false)); @@ -153,7 +153,7 @@ describe('DatasetChartOptionsComponent', () => { vi.useFakeTimers(); try { pathObject = null; - const fx = TestBed.createComponent(DatasetChartOptionsComponent); + const fx = TestBed.createComponent(GraphDataOptionsComponent); const set = fx.componentRef.setInput.bind(fx.componentRef) as (k: string, v: unknown) => void; const pathControl = new UntypedFormControl('self.environment.wind.speedApparent'); const sourceControl = new UntypedFormControl('wind-sensor-1'); diff --git a/src/app/widget-config/dataset-chart-options/dataset-chart-options.component.ts b/src/app/widget-config/graph-data-options/graph-data-options.component.ts similarity index 96% rename from src/app/widget-config/dataset-chart-options/dataset-chart-options.component.ts rename to src/app/widget-config/graph-data-options/graph-data-options.component.ts index 3d750a29..6e814a46 100644 --- a/src/app/widget-config/dataset-chart-options/dataset-chart-options.component.ts +++ b/src/app/widget-config/graph-data-options/graph-data-options.component.ts @@ -15,12 +15,12 @@ import { RouterLink } from '@angular/router'; import { pathRequiredValidator, pathSlotWarning } from '../../core/utils/path-validators.util'; @Component({ - selector: 'config-dataset-chart-options', + selector: 'config-graph-data-options', imports: [MatIconModule, MatAutocompleteModule, MatCheckboxModule, MatFormFieldModule, MatSelectModule, MatInputModule, MatButtonModule, ReactiveFormsModule, RouterLink], - templateUrl: './dataset-chart-options.component.html', - styleUrl: './dataset-chart-options.component.scss' + templateUrl: './graph-data-options.component.html', + styleUrl: './graph-data-options.component.scss' }) -export class DatasetChartOptionsComponent implements OnInit { +export class GraphDataOptionsComponent implements OnInit { public datachartAngleRange = input | undefined>(undefined); public filterSelfPaths = input.required>() public datachartPath = input.required>() @@ -34,7 +34,7 @@ export class DatasetChartOptionsComponent implements OnInit { protected numericPaths = signal([]); protected filteredNumericPaths = signal([]); protected pathSources = signal([]); - /** Why the configured path is not offered for this chart, or null. A caution, never a save-blocking error. */ + /** Why the configured path is not offered for this graph, or null. A caution, never a save-blocking error. */ protected pathWarning = signal(null); /** The path `pathSources` was last built for, so re-deriving needs a real path change. */ private _sourcesForPath: string | null = null; diff --git a/src/app/widget-config/display-chart-options/display-chart-options.component.html b/src/app/widget-config/graph-display-options/graph-display-options.component.html similarity index 85% rename from src/app/widget-config/display-chart-options/display-chart-options.component.html rename to src/app/widget-config/graph-display-options/graph-display-options.component.html index 2f775e3e..ee826a1e 100644 --- a/src/app/widget-config/display-chart-options/display-chart-options.component.html +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.html @@ -1,6 +1,6 @@
-
+
Color
-
+

Series

@@ -58,22 +58,22 @@

Data Scale

(Default node is automatic scale) - - Auto Scale -
- +
+ Suggested Min - + Suggested Max
- Fixed Scale -
-
- +
+
+ Min
-
- +
+ Max
-

Dataset

+

Series

diff --git a/src/app/widget-config/display-chart-options/display-chart-options.component.scss b/src/app/widget-config/graph-display-options/graph-display-options.component.scss similarity index 72% rename from src/app/widget-config/display-chart-options/display-chart-options.component.scss rename to src/app/widget-config/graph-display-options/graph-display-options.component.scss index 5d462d0e..f0a91703 100644 --- a/src/app/widget-config/display-chart-options/display-chart-options.component.scss +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.scss @@ -1,4 +1,4 @@ -.chart-flex-container { +.graph-flex-container { display: flex; flex-direction: row; flex-wrap: wrap; @@ -16,18 +16,18 @@ margin: 0px; } -.chart-option-radio-group { +.graph-option-radio-group { display: flex; flex-direction: column; margin: 0px; align-items: flex-start; } -.chart-option-radio-button { +.graph-option-radio-button { margin: 0px; } -.chart-option-radio-button-config { +.graph-option-radio-button-config { margin-left: 35px; display: flex; flex-direction: row; @@ -37,13 +37,13 @@ } -.chart-option-radio-button-config-item { +.graph-option-radio-button-config-item { flex-grow: 0; flex-shrink: 1; min-width: 50px; } -.chart-option-radio-button-config-form-field { +.graph-option-radio-button-config-form-field { display: block; max-width: 118px; } diff --git a/src/app/widget-config/display-chart-options/display-chart-options.component.spec.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts similarity index 90% rename from src/app/widget-config/display-chart-options/display-chart-options.component.spec.ts rename to src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts index e0886913..b40d19a4 100644 --- a/src/app/widget-config/display-chart-options/display-chart-options.component.spec.ts +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts @@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { UntypedFormControl } from '@angular/forms'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatRadioChange } from '@angular/material/radio'; -import { DisplayChartOptionsComponent } from './display-chart-options.component'; +import { GraphDisplayOptionsComponent } from './graph-display-options.component'; describe('ChartOptionsComponent', () => { - let component: DisplayChartOptionsComponent; - let fixture: ComponentFixture; + let component: GraphDisplayOptionsComponent; + let fixture: ComponentFixture; - const applyRequiredInputs = (targetFixture: ComponentFixture, overrides: Record = {}): Record => { + const applyRequiredInputs = (targetFixture: ComponentFixture, overrides: Record = {}): Record => { const controls: Record = { datasetAverageArray: new UntypedFormControl([]), showAverageData: new UntypedFormControl(false), @@ -43,11 +43,11 @@ describe('ChartOptionsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DisplayChartOptionsComponent] + imports: [GraphDisplayOptionsComponent] }) .compileComponents(); - fixture = TestBed.createComponent(DisplayChartOptionsComponent); + fixture = TestBed.createComponent(GraphDisplayOptionsComponent); component = fixture.componentInstance; applyRequiredInputs(fixture); fixture.detectChanges(); @@ -58,7 +58,7 @@ describe('ChartOptionsComponent', () => { }); it('should disable trackAgainstAverage on init when moving average is disabled', () => { - const localFixture = TestBed.createComponent(DisplayChartOptionsComponent); + const localFixture = TestBed.createComponent(GraphDisplayOptionsComponent); const controls = applyRequiredInputs(localFixture, { showAverageData: new UntypedFormControl(false), trackAgainstAverage: new UntypedFormControl({ value: true, disabled: false }) diff --git a/src/app/widget-config/display-chart-options/display-chart-options.component.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.ts similarity index 94% rename from src/app/widget-config/display-chart-options/display-chart-options.component.ts rename to src/app/widget-config/graph-display-options/graph-display-options.component.ts index 4a9a8808..67129425 100644 --- a/src/app/widget-config/display-chart-options/display-chart-options.component.ts +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.ts @@ -10,13 +10,13 @@ import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox import { MatRadioChange, MatRadioModule } from '@angular/material/radio'; @Component({ - selector: 'config-display-chart-options', + selector: 'config-graph-display-options', standalone: true, - templateUrl: './display-chart-options.component.html', - styleUrl: './display-chart-options.component.scss', + templateUrl: './graph-display-options.component.html', + styleUrl: './graph-display-options.component.scss', imports: [MatCardModule, MatFormFieldModule, MatCheckboxModule, MatSelectModule, MatOptionModule, MatLabel, MatInputModule, MatRadioModule, ReactiveFormsModule] }) -export class DisplayChartOptionsComponent implements OnInit { +export class GraphDisplayOptionsComponent implements OnInit { private app = inject(AppService); readonly datasetAverageArray = input.required>(); diff --git a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html index 165726a2..df0270ca 100644 --- a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html +++ b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html @@ -44,7 +44,7 @@
{{ titleDialog }}
- {{ titleDialog }} [datachartAngleRange]="datachartAngleRangeControl" [timeScale]="timeScaleControl" [period]="periodControl" /> - diff --git a/src/app/widgets/minigraph/minigraph.component.html b/src/app/widgets/minigraph/minigraph.component.html new file mode 100644 index 00000000..f9f55286 --- /dev/null +++ b/src/app/widgets/minigraph/minigraph.component.html @@ -0,0 +1 @@ + diff --git a/src/app/widgets/minichart/minichart.component.scss b/src/app/widgets/minigraph/minigraph.component.scss similarity index 100% rename from src/app/widgets/minichart/minichart.component.scss rename to src/app/widgets/minigraph/minigraph.component.scss diff --git a/src/app/widgets/minichart/minichart.component.spec.ts b/src/app/widgets/minigraph/minigraph.component.spec.ts similarity index 79% rename from src/app/widgets/minichart/minichart.component.spec.ts rename to src/app/widgets/minigraph/minigraph.component.spec.ts index 3a4367d4..dbe5ad53 100644 --- a/src/app/widgets/minichart/minichart.component.spec.ts +++ b/src/app/widgets/minigraph/minigraph.component.spec.ts @@ -6,22 +6,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Subject } from 'rxjs'; -import { MinichartComponent } from './minichart.component'; -import { HistoryChartStreamService, HISTORY_UNAVAILABLE } from '../../core/services/history-chart-stream.service'; +import { MinigraphComponent } from './minigraph.component'; +import { HistoryGraphStreamService, HISTORY_UNAVAILABLE } from '../../core/services/history-graph-stream.service'; import { UnitsService } from '../../core/services/units.service'; import { CanvasService } from '../../core/services/canvas.service'; import type { ITheme } from '../../core/services/app-service'; -import type { IDatasetServiceDatapoint } from '../../core/interfaces/dataset.interfaces'; +import type { IGraphDatapoint } from '../../core/interfaces/graph-data.interfaces'; const themeMock = new Proxy({}, { get: () => '#000000' }) as unknown as ITheme; -function chartValueData(component: MinichartComponent): unknown[] { +function chartValueData(component: MinigraphComponent): unknown[] { return (component as unknown as { chart: { data: { datasets: { data: unknown[] }[] } } }).chart.data.datasets[0].data; } -describe('MinichartComponent', () => { - let fixture: ComponentFixture; - let component: MinichartComponent; +describe('MinigraphComponent', () => { + let fixture: ComponentFixture; + let component: MinigraphComponent; const historyMock = { getBackfillThenLive: vi.fn() }; const unitsMock = { convertToUnit: (_unit: string, value: number) => value }; const canvasMock = { releaseCanvas: vi.fn() }; @@ -31,15 +31,15 @@ describe('MinichartComponent', () => { historyMock.getBackfillThenLive.mockReset().mockReturnValue(new Subject()); await TestBed.configureTestingModule({ - imports: [MinichartComponent], + imports: [MinigraphComponent], providers: [ - { provide: HistoryChartStreamService, useValue: historyMock }, + { provide: HistoryGraphStreamService, useValue: historyMock }, { provide: UnitsService, useValue: unitsMock }, { provide: CanvasService, useValue: canvasMock } ] }).compileComponents(); - fixture = TestBed.createComponent(MinichartComponent); + fixture = TestBed.createComponent(MinigraphComponent); fixture.componentRef.setInput('theme', themeMock); component = fixture.componentInstance; fixture.detectChanges(); @@ -53,7 +53,7 @@ describe('MinichartComponent', () => { it('streams from the History engine using the fixed 12s mini-chart window', () => { component.dataPath = 'self.navigation.speedOverGround'; component.dataSource = 'default'; - component.startChart(); + component.startGraph(); expect(historyMock.getBackfillThenLive).toHaveBeenCalledTimes(1); const params = historyMock.getBackfillThenLive.mock.calls[0][0]; @@ -68,7 +68,7 @@ describe('MinichartComponent', () => { it('does not stream without a data path', () => { component.dataPath = null; - component.startChart(); + component.startGraph(); expect(historyMock.getBackfillThenLive).not.toHaveBeenCalled(); }); @@ -76,19 +76,19 @@ describe('MinichartComponent', () => { const stream = new Subject(); historyMock.getBackfillThenLive.mockReturnValue(stream); component.dataPath = 'self.navigation.speedOverGround'; - component.startChart(); + component.startGraph(); expect(() => stream.next(HISTORY_UNAVAILABLE)).not.toThrow(); expect(chartValueData(component).length).toBe(0); }); - it('plots a backfill batch and then a live point off the history stream', () => { + it('graphs a backfill batch and then a live point off the history stream', () => { const stream = new Subject(); historyMock.getBackfillThenLive.mockReturnValue(stream); component.dataPath = 'self.navigation.speedOverGround'; - component.startChart(); + component.startGraph(); - const batch: IDatasetServiceDatapoint[] = [ + const batch: IGraphDatapoint[] = [ { timestamp: 1000, data: { value: 1 } }, { timestamp: 2000, data: { value: 2 } } ]; diff --git a/src/app/widgets/minichart/minichart.component.ts b/src/app/widgets/minigraph/minigraph.component.ts similarity index 90% rename from src/app/widgets/minichart/minichart.component.ts rename to src/app/widgets/minigraph/minigraph.component.ts index d80d766d..a5ef34fa 100644 --- a/src/app/widgets/minichart/minichart.component.ts +++ b/src/app/widgets/minigraph/minigraph.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, ElementRef, viewChild, inject, effect, NgZone, input, untracked, ChangeDetectionStrategy } from '@angular/core'; -import { HistoryChartStreamService, IHistoryChartStreamParams, isHistoryUnavailable } from '../../core/services/history-chart-stream.service'; -import { IDatasetServiceDatapoint, TimeScaleFormat } from '../../core/interfaces/dataset.interfaces'; -import { resolveWindowMs, deriveDataSourceInfo, IChartDataSourceInfo } from '../../core/utils/chart-window.util'; +import { HistoryGraphStreamService, IHistoryGraphStreamParams, isHistoryUnavailable } from '../../core/services/history-graph-stream.service'; +import { IGraphDatapoint, TimeScaleFormat } from '../../core/interfaces/graph-data.interfaces'; +import { resolveWindowMs, deriveDataSourceInfo, IGraphDataSourceInfo } from '../../core/utils/graph-window.util'; import { Subscription } from 'rxjs'; import { CanvasService } from '../../core/services/canvas.service'; import { ITheme } from '../../core/services/app-service'; @@ -22,23 +22,23 @@ interface IChartColors { chartLabel: string | undefined, chartValue: string | undefined } -interface IDataSetRow { +interface IDataPointRow { x: number | null, y: number | null } /** The numeric widget's background sparkline always shows the same short fixed window. */ -const MINICHART_TIME_SCALE: TimeScaleFormat = 'minute'; -const MINICHART_PERIOD = 0.2; +const MINIGRAPH_TIME_SCALE: TimeScaleFormat = 'minute'; +const MINIGRAPH_PERIOD = 0.2; @Component({ - selector: 'minichart', + selector: 'minigraph', imports: [], - templateUrl: './minichart.component.html', - styleUrls: ['./minichart.component.scss'], + templateUrl: './minigraph.component.html', + styleUrls: ['./minigraph.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MinichartComponent implements OnDestroy { +export class MinigraphComponent implements OnDestroy { protected readonly theme = input.required(); public color = ''; public dataPath: string | null = null; @@ -52,10 +52,10 @@ export class MinichartComponent implements OnDestroy { public inverseYAxis = false; public verticalChart: boolean | null = null; protected unitsService = inject(UnitsService); - private readonly historyStream = inject(HistoryChartStreamService); + private readonly historyStream = inject(HistoryGraphStreamService); private readonly ngZone = inject(NgZone); private readonly canvasService = inject(CanvasService); - readonly widgetDataChart = viewChild('widgetDataChart', { read: ElementRef }); + readonly widgetDataGraph = viewChild('widgetDataGraph', { read: ElementRef }); public lineChartData: ChartData<'line', { x: number, y: number }[]> = { datasets: [] }; @@ -77,7 +77,7 @@ export class MinichartComponent implements OnDestroy { public lineChartType: ChartType = 'line'; private chart; private streamSub: Subscription | null = null; - private dataSourceInfo: IChartDataSourceInfo | null = null; + private dataSourceInfo: IGraphDataSourceInfo | null = null; private isDestroyed = false; private lastChartSignature: string | null = null; @@ -105,9 +105,9 @@ export class MinichartComponent implements OnDestroy { }); } - public startChart(): void { + public startGraph(): void { if (this.isDestroyed || !this.dataPath) return; - const windowMs = resolveWindowMs(MINICHART_TIME_SCALE, MINICHART_PERIOD); + const windowMs = resolveWindowMs(MINIGRAPH_TIME_SCALE, MINIGRAPH_PERIOD); this.dataSourceInfo = deriveDataSourceInfo(windowMs); const chartSignature = this.buildChartSignature(); @@ -121,7 +121,7 @@ export class MinichartComponent implements OnDestroy { this.setChartOptions(); this.createDatasets(); - const canvasEl = this.widgetDataChart()?.nativeElement as HTMLCanvasElement | undefined; + const canvasEl = this.widgetDataGraph()?.nativeElement as HTMLCanvasElement | undefined; const ctx = canvasEl?.getContext('2d'); if (!ctx) return; @@ -195,7 +195,7 @@ export class MinichartComponent implements OnDestroy { display: false }, time: { - unit: MINICHART_TIME_SCALE as TimeUnit, + unit: MINIGRAPH_TIME_SCALE as TimeUnit, minUnit: "second", round: "second", displayFormats: { @@ -229,7 +229,7 @@ export class MinichartComponent implements OnDestroy { display: false }, time: { - unit: MINICHART_TIME_SCALE as TimeUnit, + unit: MINIGRAPH_TIME_SCALE as TimeUnit, minUnit: "second", round: "second", displayFormats: { @@ -290,7 +290,7 @@ export class MinichartComponent implements OnDestroy { streaming: { duration: dataSourceInfo.maxDataPoints * dataSourceInfo.sampleTime, delay: dataSourceInfo.sampleTime, - frameRate: MINICHART_TIME_SCALE === "day" ? 5 : MINICHART_TIME_SCALE === "hour" ? 8 : MINICHART_TIME_SCALE === "minute" ? 15 : 30, + frameRate: MINIGRAPH_TIME_SCALE === "day" ? 5 : MINIGRAPH_TIME_SCALE === "hour" ? 8 : MINIGRAPH_TIME_SCALE === "minute" ? 15 : 30, } } } @@ -505,10 +505,10 @@ export class MinichartComponent implements OnDestroy { const dataPath = this.dataPath; if (!info || !dataPath) return; - const params: IHistoryChartStreamParams = { + const params: IHistoryGraphStreamParams = { path: dataPath, source: this.dataSource ?? 'default', - windowMs: resolveWindowMs(MINICHART_TIME_SCALE, MINICHART_PERIOD), + windowMs: resolveWindowMs(MINIGRAPH_TIME_SCALE, MINIGRAPH_PERIOD), sampleTime: info.sampleTime, maxDataPoints: info.maxDataPoints, smoothingPeriod: info.smoothingPeriod @@ -522,20 +522,20 @@ export class MinichartComponent implements OnDestroy { if (isHistoryUnavailable(emission)) return; if (Array.isArray(emission)) { - // Initial backfill: fill the chart with the window's points. - const valueRows = this.transformDatasetRows(emission, 0); + // Initial backfill: fill the graph with the window's points. + const valueRows = this.transformSeriesRows(emission, 0); this.chart.data.datasets[0].data.push(...valueRows); if (this.config.showAverageData) { - const avgRows = this.transformDatasetRows(emission, this.config.datasetAverageArray); + const avgRows = this.transformSeriesRows(emission, this.config.datasetAverageArray); this.chart.data.datasets[1].data.push(...avgRows); } } else { // Live: handle new single datapoint - const valueRow = this.transformDatasetRows([emission], 0)[0]; + const valueRow = this.transformSeriesRows([emission], 0)[0]; this.chart.data.datasets[0].data.push(valueRow); if (this.config.showAverageData) { - const avgRow = this.transformDatasetRows([emission], this.config.datasetAverageArray)[0]; + const avgRow = this.transformSeriesRows([emission], this.config.datasetAverageArray)[0]; this.chart.data.datasets[1].data.push(avgRow); } } @@ -557,7 +557,7 @@ export class MinichartComponent implements OnDestroy { this.lastChartSignature = null; } - private transformDatasetRows(rows: IDatasetServiceDatapoint[], datasetType): IDataSetRow[] { + private transformSeriesRows(rows: IGraphDatapoint[], seriesType): IDataPointRow[] { const convert = (v: number) => this.unitsService.convertToUnit(this.convertUnitTo ?? '', v); const verticalChart = this.verticalChart; @@ -565,7 +565,7 @@ export class MinichartComponent implements OnDestroy { return rows.map(row => { if (verticalChart) { - if (datasetType === 0) { + if (seriesType === 0) { return { x: convert(row.data.value), y: row.timestamp }; } else { const avgMap = { @@ -577,7 +577,7 @@ export class MinichartComponent implements OnDestroy { return { x: convert(avgMap[avgKey]), y: row.timestamp }; } } else { - if (datasetType === 0) { + if (seriesType === 0) { return { x: row.timestamp, y: convert(row.data.value) }; } else { const avgMap = { @@ -595,7 +595,7 @@ export class MinichartComponent implements OnDestroy { ngOnDestroy(): void { this.isDestroyed = true; this.destroyChart(); - const canvas = this.widgetDataChart?.()?.nativeElement as HTMLCanvasElement | undefined; + const canvas = this.widgetDataGraph?.()?.nativeElement as HTMLCanvasElement | undefined; this.canvasService.releaseCanvas(canvas, { clear: true, removeFromDom: true }); } } diff --git a/src/app/widgets/widget-data-chart/widget-data-chart.component.html b/src/app/widgets/widget-data-graph/widget-data-graph.component.html similarity index 80% rename from src/app/widgets/widget-data-chart/widget-data-chart.component.html rename to src/app/widgets/widget-data-graph/widget-data-graph.component.html index db9ba310..98423072 100644 --- a/src/app/widgets/widget-data-chart/widget-data-chart.component.html +++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.html @@ -1,9 +1,9 @@ @if (hasPath()) { - + @if (historyUnavailable()) {

History data unavailable

-

A Signal K history provider is required for trend charts.

+

A Signal K history provider is required for trend graphs.

} } @else { diff --git a/src/app/widgets/widget-data-chart/widget-data-chart.component.scss b/src/app/widgets/widget-data-graph/widget-data-graph.component.scss similarity index 89% rename from src/app/widgets/widget-data-chart/widget-data-chart.component.scss rename to src/app/widgets/widget-data-graph/widget-data-graph.component.scss index cc09ca32..c30459cd 100644 --- a/src/app/widgets/widget-data-chart/widget-data-chart.component.scss +++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.scss @@ -5,7 +5,7 @@ position: relative; } -// #64 history engine: overlay shown over the (empty) chart when no history provider is available. +// #64 history engine: overlay shown over the (empty) graph when no history provider is available. .history-unavailable { position: absolute; inset: 0; diff --git a/src/app/widgets/widget-data-chart/widget-data-chart.component.spec.ts b/src/app/widgets/widget-data-graph/widget-data-graph.component.spec.ts similarity index 88% rename from src/app/widgets/widget-data-chart/widget-data-chart.component.spec.ts rename to src/app/widgets/widget-data-graph/widget-data-graph.component.spec.ts index ae9f16b9..b2773f8f 100644 --- a/src/app/widgets/widget-data-chart/widget-data-chart.component.spec.ts +++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.spec.ts @@ -7,27 +7,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EMPTY, Subject } from 'rxjs'; -import { WidgetDataChartComponent } from './widget-data-chart.component'; +import { WidgetDataGraphComponent } from './widget-data-graph.component'; import { IWidgetSvcConfig } from '../../core/interfaces/widgets-interface'; -import { HistoryChartStreamService, HISTORY_UNAVAILABLE } from '../../core/services/history-chart-stream.service'; -import type { IDatasetServiceDatapoint } from '../../core/interfaces/dataset.interfaces'; +import { HistoryGraphStreamService, HISTORY_UNAVAILABLE } from '../../core/services/history-graph-stream.service'; +import type { IGraphDatapoint } from '../../core/interfaces/graph-data.interfaces'; import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; import { UnitsService } from '../../core/services/units.service'; import { CanvasService } from '../../core/services/canvas.service'; import type { ITheme } from '../../core/services/app-service'; -// Any color property the chart-options builder reads resolves to a valid string. +// Any color property the graph-options builder reads resolves to a valid string. const themeMock = new Proxy({}, { get: () => '#000000' }) as unknown as ITheme; const makeConfig = (overrides: Partial = {}): IWidgetSvcConfig => ({ - ...WidgetDataChartComponent.DEFAULT_CONFIG, + ...WidgetDataGraphComponent.DEFAULT_CONFIG, datachartPath: 'self.navigation.speedOverGround', color: 'contrast', ...overrides }); -describe('WidgetDataChartComponent', () => { - let fixture: ComponentFixture; +describe('WidgetDataGraphComponent', () => { + let fixture: ComponentFixture; // A real signal, not a vi.fn: the component tracks runtime.options() inside computed/effect, so a // config edit only reaches the rebuild path when the source is reactive — which is what the live @@ -42,16 +42,16 @@ describe('WidgetDataChartComponent', () => { options.set(config); await TestBed.configureTestingModule({ - imports: [WidgetDataChartComponent], + imports: [WidgetDataGraphComponent], providers: [ { provide: WidgetRuntimeDirective, useValue: runtimeMock }, - { provide: HistoryChartStreamService, useValue: historyMock }, + { provide: HistoryGraphStreamService, useValue: historyMock }, { provide: UnitsService, useValue: unitsMock }, { provide: CanvasService, useValue: canvasMock } ] }).compileComponents(); - fixture = TestBed.createComponent(WidgetDataChartComponent); + fixture = TestBed.createComponent(WidgetDataGraphComponent); fixture.componentRef.setInput('id', 'w1'); fixture.componentRef.setInput('type', 'widget-data-chart'); fixture.componentRef.setInput('theme', themeMock); @@ -98,7 +98,7 @@ describe('WidgetDataChartComponent', () => { }).annotation?.annotations?.[name]; it('keeps an enabled annotation line hidden while its value is non-finite, then reveals it once finite', async () => { - const emissions$ = new Subject(); + const emissions$ = new Subject(); historyMock.getBackfillThenLive.mockReturnValue(emissions$); // Average line enabled, but the rolling average is not yet available: the finite gate — not the @@ -121,7 +121,7 @@ describe('WidgetDataChartComponent', () => { }); it('keeps an already-enabled annotation line visible after a theme change with data present', async () => { - const emissions$ = new Subject(); + const emissions$ = new Subject(); historyMock.getBackfillThenLive.mockReturnValue(emissions$); await setup(makeConfig({ showDatasetAverageValueLine: true, numDecimal: 1 })); @@ -148,7 +148,7 @@ describe('WidgetDataChartComponent', () => { }).title?.text; it('labels the value from the path-resolved measure, not the stored convertUnitTo', async () => { - const emissions$ = new Subject(); + const emissions$ = new Subject(); historyMock.getBackfillThenLive.mockReturnValue(emissions$); // Stored convertUnitTo is stale; the server-resolved measure for the path is the single source of @@ -180,16 +180,16 @@ describe('WidgetDataChartComponent', () => { const readAxis = (id: 'x' | 'y'): AxisState | undefined => (fixture.componentInstance.lineChartOptions.scales as unknown as Record)[id]; - it('states the plot window on the widget label, not on a time-axis title', async () => { + it('states the graph window on the widget label, not on a time-axis title', async () => { await setup(makeConfig({ displayName: 'SOG', timeScale: 'second', period: 30, showTimeScale: true })); - // The axis title cost the plot a whole row to say this; the label says it in four characters. + // 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(readAxis('x')?.title?.display ?? false).toBe(false); }); - it('keeps the plot window on screen when the widget label is hidden', async () => { + 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. await setup(makeConfig({ displayName: 'SOG', timeScale: 'minute', period: 10, showLabel: false })); @@ -211,10 +211,10 @@ describe('WidgetDataChartComponent', () => { it.each([ { orientation: 'horizontal', verticalChart: false }, { orientation: 'vertical', verticalChart: true } - ])('draws both axes\' ticks inside the plot in the $orientation layout', async ({ verticalChart }) => { + ])('draws both axes\' ticks inside the graph in the $orientation layout', async ({ verticalChart }) => { // The same options are spread into four separate axis blocks, two per orientation. One block // losing them reintroduces the label gutter for that orientation alone, which no dashboard - // review would catch until someone opens a chart in that layout. + // review would catch until someone opens a graph in that layout. await setup(makeConfig({ verticalChart, showTimeScale: true, showYScale: true })); for (const axis of [readAxis('x'), readAxis('y')]) { @@ -227,7 +227,7 @@ describe('WidgetDataChartComponent', () => { }); it('rebuilds the chart when the orientation is toggled', async () => { - const emissions$ = new Subject(); + const emissions$ = new Subject(); historyMock.getBackfillThenLive.mockReturnValue(emissions$); await setup(makeConfig({ verticalChart: false, showTimeScale: true, showYScale: true })); @@ -241,8 +241,8 @@ describe('WidgetDataChartComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - // Swapping the orientation swaps which axis carries time. transformDatasetRows only transposes - // rows as they arrive, so anything already buffered plots as garbage — up to a 365-day window — + // Swapping the orientation swaps which axis carries time. transformSeriesRows only transposes + // rows as they arrive, so anything already buffered graphs as garbage — up to a 365-day window — // unless the toggle goes through the full rebuild rather than an in-place options update. expect(readAxis('y')?.type).toBe('realtime'); expect(fixture.componentInstance.lineChartData.datasets[0].data.length).toBe(0); diff --git a/src/app/widgets/widget-data-chart/widget-data-chart.component.ts b/src/app/widgets/widget-data-graph/widget-data-graph.component.ts similarity index 89% rename from src/app/widgets/widget-data-chart/widget-data-chart.component.ts rename to src/app/widgets/widget-data-graph/widget-data-graph.component.ts index 0d0e993c..fb7dad5b 100644 --- a/src/app/widgets/widget-data-chart/widget-data-chart.component.ts +++ b/src/app/widgets/widget-data-graph/widget-data-graph.component.ts @@ -1,7 +1,7 @@ -import { IDatasetServiceDatasetConfig, TimeScaleFormat } from '../../core/interfaces/dataset.interfaces'; +import { IGraphSeriesConfig, TimeScaleFormat } from '../../core/interfaces/graph-data.interfaces'; import { Component, OnDestroy, ElementRef, viewChild, inject, effect, NgZone, input, untracked, computed, signal, ChangeDetectionStrategy } from '@angular/core'; import { IWidgetSvcConfig } from '../../core/interfaces/widgets-interface'; -import { IDatasetServiceDatapoint, IDatasetServiceDataSourceInfo } from '../../core/interfaces/dataset.interfaces'; +import { IGraphDatapoint } from '../../core/interfaces/graph-data.interfaces'; import { Subscription, distinctUntilChanged, map, of, switchMap } from 'rxjs'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { CanvasService } from '../../core/services/canvas.service'; @@ -9,8 +9,8 @@ import { DataService } from '../../core/services/data.service'; import { UnitsService } from '../../core/services/units.service'; import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; import { ITheme } from '../../core/services/app-service'; -import { HistoryChartStreamService, IHistoryChartStreamParams, isHistoryUnavailable } from '../../core/services/history-chart-stream.service'; -import { resolveWindowMs, deriveDataSourceInfo } from '../../core/utils/chart-window.util'; +import { HistoryGraphStreamService, IHistoryGraphStreamParams, isHistoryUnavailable } from '../../core/services/history-graph-stream.service'; +import { resolveWindowMs, deriveDataSourceInfo, IGraphDataSourceInfo } from '../../core/utils/graph-window.util'; import { Chart, ChartConfiguration, ChartData, ChartType, TimeUnit } from 'chart.js'; import 'chartjs-adapter-date-fns'; @@ -37,18 +37,18 @@ interface IChartColors { chartLabel: string, chartValue: string } -interface IDataSetRow { x: number | null, y: number | null } +interface IDataPointRow { x: number | null, y: number | null } // A hair of curvature — visually a straight line — that routes the value line off Chart.js's fast // integer-pixel-bucketing draw path onto the exact-position path. The streaming plugin re-paths the // line every animation frame from sub-pixel-shifted points; on the fast path that re-buckets dense // points into per-pixel-column min/max strokes whose x jumps as points cross pixel boundaries, so -// features shimmer independently as the chart scrolls. The exact-position path draws every point +// features shimmer independently as the graph scrolls. The exact-position path draws every point // where it is and translates the whole line as one. Bezier control points at this tension sit far // below a pixel from the vertices, so the rendered line is indistinguishable from straight segments. const NON_FAST_PATH_TENSION = 1e-6; -// Compact plot-window suffix for the widget label ("SOG (30 s)"). The legacy TimeScaleFormat members +// Compact graph-window suffix for the widget label ("SOG (30 s)"). The legacy TimeScaleFormat members // have no abbreviation, so a config still carrying one gets the bare label. const TIME_SCALE_SUFFIX: Partial> = { day: 'd', @@ -58,12 +58,14 @@ const TIME_SCALE_SUFFIX: Partial> = { }; @Component({ + // The selector doubles as the persisted widget `type` in stored dashboards, so it keeps the + // pre-rename "chart" spelling until a config migration renames it (#592). selector: 'widget-data-chart', - templateUrl: './widget-data-chart.component.html', - styleUrl: './widget-data-chart.component.scss', + templateUrl: './widget-data-graph.component.html', + styleUrl: './widget-data-graph.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class WidgetDataChartComponent implements OnDestroy { +export class WidgetDataGraphComponent implements OnDestroy { // Host2 functional inputs supplied by host container public id = input.required(); public type = input.required(); @@ -76,10 +78,10 @@ export class WidgetDataChartComponent implements OnDestroy { private readonly canvasService = inject(CanvasService); private readonly unitsService = inject(UnitsService); private readonly dataService = inject(DataService); - private readonly historyStream = inject(HistoryChartStreamService); - readonly widgetDataChart = viewChild('widgetDataChart', { read: ElementRef }); + private readonly historyStream = inject(HistoryGraphStreamService); + readonly widgetDataGraph = viewChild('widgetDataGraph', { read: ElementRef }); public static readonly DEFAULT_CONFIG: IWidgetSvcConfig = { - displayName: 'Chart Label', + displayName: 'Graph Label', color: 'contrast', filterSelfPaths: true, datachartPath: null, @@ -117,8 +119,8 @@ export class WidgetDataChartComponent implements OnDestroy { public lineChartType: ChartType = 'line'; private chart: Chart; private streamSub: Subscription | null = null; - private datasetConfig: IDatasetServiceDatasetConfig | null = null; - private dataSourceInfo: IDatasetServiceDataSourceInfo | null = null; + private seriesConfig: IGraphSeriesConfig | null = null; + private dataSourceInfo: IGraphDataSourceInfo | null = null; // Latest finite annotation values; NaN until real data arrives, which keeps the // min/max/average lines and their labels hidden instead of drawing at a placeholder 0. private lastAverageValue = NaN; @@ -129,7 +131,7 @@ export class WidgetDataChartComponent implements OnDestroy { return !!cfg?.datachartPath; }); // True when no history provider is available, so the widget shows the - // "history unavailable" empty state instead of a blank chart (no recorder live-only fallback). + // "history unavailable" empty state instead of a blank graph (no recorder live-only fallback). protected historyUnavailable = signal(false); private datachartPath = computed(() => this.runtime.options()?.datachartPath ?? null); // Reactive resolved measure: resolvePathMeasure() reads a non-signal meta cache, so it is folded @@ -153,7 +155,7 @@ export class WidgetDataChartComponent implements OnDestroy { // verticalChart belongs here rather than in the display effect: swapping the orientation swaps // which axis carries time, so every buffered point is transposed and the realtime scale changes // type. Only a full rebuild re-maps the data and destroys the outgoing scale — an in-place - // options update leaves the old scale's refresh interval running against the live chart. + // options update leaves the old scale's refresh interval running against the live graph. return [cfg.datachartPath, this.pathMeasure(), cfg.datachartSource, cfg.timeScale, cfg.period, cfg.datachartAngleRange, cfg.verticalChart].join('|'); }); private previousPathSignature: string | undefined = undefined; @@ -169,7 +171,7 @@ export class WidgetDataChartComponent implements OnDestroy { if (!cfg) return; untracked(() => { this.previousPathSignature = sig; - this.rebuildForDataset(cfg); + this.rebuildForSeries(cfg); }); } }); @@ -194,22 +196,22 @@ export class WidgetDataChartComponent implements OnDestroy { }); }); - // Guard: ensure chart builds once canvas exists after initial render + // Guard: ensure graph builds once canvas exists after initial render effect(() => { const cfg = this.runtime.options(); - const canvas = this.widgetDataChart(); + const canvas = this.widgetDataGraph(); const hasPath = this.hasPath(); if (!cfg || !hasPath || !canvas || this.chart) return; - untracked(() => this.rebuildForDataset(cfg)); + untracked(() => this.rebuildForSeries(cfg)); }); } - private rebuildForDataset(cfg: IWidgetSvcConfig): void { + private rebuildForSeries(cfg: IWidgetSvcConfig): void { if (!cfg.datachartPath) return; // Widget not yet configured - const canvasRef = this.widgetDataChart(); + const canvasRef = this.widgetDataGraph(); if (!canvasRef) return; // View not ready yet - this.streamSub?.unsubscribe(); // Cleanup old subscription & chart data + this.streamSub?.unsubscribe(); // Cleanup old subscription & graph data this.lineChartData.datasets = []; this.lastAverageValue = NaN; this.lastMinimumValue = NaN; @@ -221,7 +223,7 @@ export class WidgetDataChartComponent implements OnDestroy { const period = cfg.period ?? 10; const windowMs = resolveWindowMs(cfg.timeScale as TimeScaleFormat, period); this.dataSourceInfo = deriveDataSourceInfo(windowMs); - this.datasetConfig = { + this.seriesConfig = { uuid: this.id(), path: cfg.datachartPath, pathSource: cfg.datachartSource ?? 'default', @@ -233,7 +235,7 @@ export class WidgetDataChartComponent implements OnDestroy { }; this.createDatasets(cfg); this.setChartOptions(cfg); - // Always recreate chart instance on rebuild to ensure orientation/scale axis changes apply + // Always recreate graph instance on rebuild to ensure orientation/scale axis changes apply this.chart?.destroy(); this.chart = new Chart(canvasRef.nativeElement.getContext('2d'), { type: this.lineChartType, @@ -245,19 +247,19 @@ export class WidgetDataChartComponent implements OnDestroy { } private setChartOptions(cfg: IWidgetSvcConfig): void { - // Both fields are always populated by rebuildForDataset() before a chart exists, and - // setChartOptions() is only ever called once a chart does; theme() can only be transiently + // Both fields are always populated by rebuildForSeries() before a graph exists, and + // setChartOptions() is only ever called once a graph does; theme() can only be transiently // null during the app's very first render tick, before AppService publishes the real palette. const theme = this.theme(); - const datasetConfig = this.datasetConfig; + const seriesConfig = this.seriesConfig; const dataSourceInfo = this.dataSourceInfo; - if (!theme || !datasetConfig || !dataSourceInfo) return; + if (!theme || !seriesConfig || !dataSourceInfo) return; this.lineChartOptions.maintainAspectRatio = false; this.lineChartOptions.animation = false; this.lineChartOptions.indexAxis = cfg.verticalChart ? 'y' : 'x'; - // Ticks drawn inside the plot area: an enabled axis then costs the plot the padding alone + // Ticks drawn inside the graph area: an enabled axis then costs the graph the padding alone // instead of a label gutter. Chart.js draws tick labels above the grid but below the datasets, // so the card-coloured outline — the Numeric widget's halo — keeps a label legible over the // grid lines behind it while the value line still crosses in front. @@ -268,12 +270,12 @@ export class WidgetDataChartComponent implements OnDestroy { textStrokeWidth: 3 }; const insideGrid = { display: true, drawTicks: false, color: theme.contrastDimmer }; - // The plot window rides on the widget label instead of a time-axis title, which would cost the - // plot a whole row to say what the label can say in four characters. The axis title rendered + // 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[datasetConfig.timeScaleFormat]; - const windowSuffix = suffix ? ` (${datasetConfig.period} ${suffix})` : ''; + const suffix = TIME_SCALE_SUFFIX[seriesConfig.timeScaleFormat]; + const windowSuffix = suffix ? ` (${seriesConfig.period} ${suffix})` : ''; if (cfg.verticalChart) { this.lineChartOptions.scales = { @@ -284,7 +286,7 @@ export class WidgetDataChartComponent implements OnDestroy { suggestedMin: "", suggestedMax: "", time: { - unit: datasetConfig.timeScaleFormat as TimeUnit, + unit: seriesConfig.timeScaleFormat as TimeUnit, minUnit: "second", round: "second", displayFormats: { @@ -334,7 +336,7 @@ export class WidgetDataChartComponent implements OnDestroy { type: "realtime", display: cfg.showTimeScale, time: { - unit: datasetConfig.timeScaleFormat as TimeUnit, + unit: seriesConfig.timeScaleFormat as TimeUnit, minUnit: "second", round: "second", displayFormats: { @@ -462,7 +464,7 @@ export class WidgetDataChartComponent implements OnDestroy { streaming: { duration: dataSourceInfo.maxDataPoints * dataSourceInfo.sampleTime, delay: dataSourceInfo.sampleTime, - frameRate: datasetConfig.timeScaleFormat === "day" ? 5 : datasetConfig.timeScaleFormat === "hour" ? 8 : datasetConfig.timeScaleFormat === "minute" ? 15 : 30, + frameRate: seriesConfig.timeScaleFormat === "day" ? 5 : seriesConfig.timeScaleFormat === "hour" ? 8 : seriesConfig.timeScaleFormat === "minute" ? 15 : 30, } } } @@ -782,10 +784,10 @@ export class WidgetDataChartComponent implements OnDestroy { if (!cfg?.datachartPath) return; this.streamSub?.unsubscribe(); - // Always set by rebuildForDataset() just before startStreaming() is called (its only caller). + // Always set by rebuildForSeries() just before startStreaming() is called (its only caller). const info = this.dataSourceInfo; if (!info) return; - const params: IHistoryChartStreamParams = { + const params: IHistoryGraphStreamParams = { path: cfg.datachartPath, source: cfg.datachartSource ?? 'default', angleDomainOverride: cfg.datachartAngleRange === 'signed' || cfg.datachartAngleRange === 'direction' @@ -802,17 +804,17 @@ export class WidgetDataChartComponent implements OnDestroy { return; } this.historyUnavailable.set(false); - this.handleDatasetEmission(emission, cfg); + this.handleDatapointEmission(emission, cfg); }); } - private handleDatasetEmission(dsPointOrBatch: IDatasetServiceDatapoint[] | IDatasetServiceDatapoint, cfg: IWidgetSvcConfig): void { + private handleDatapointEmission(dsPointOrBatch: IGraphDatapoint[] | IGraphDatapoint, cfg: IWidgetSvcConfig): void { if (!this.chart) return; if (Array.isArray(dsPointOrBatch)) { - const valueRows = this.transformDatasetRows(dsPointOrBatch, 0); + const valueRows = this.transformSeriesRows(dsPointOrBatch, 0); this.chart.data.datasets[0].data.push(...valueRows); if (cfg.showAverageData && this.lineChartData.datasets[1]) { - const avgRows = this.transformDatasetRows(dsPointOrBatch, cfg.datasetAverageArray); + const avgRows = this.transformSeriesRows(dsPointOrBatch, cfg.datasetAverageArray); this.chart.data.datasets[1].data.push(...avgRows); } @@ -821,10 +823,10 @@ export class WidgetDataChartComponent implements OnDestroy { this.applyTitleAndAnnotationValues(lastBatchPoint, cfg); } } else { - const valueRow = this.transformDatasetRows([dsPointOrBatch], 0)[0]; + const valueRow = this.transformSeriesRows([dsPointOrBatch], 0)[0]; this.chart.data.datasets[0].data.push(valueRow); if (cfg.showAverageData && this.lineChartData.datasets[1]) { - const avgRow = this.transformDatasetRows([dsPointOrBatch], cfg.datasetAverageArray)[0]; + const avgRow = this.transformSeriesRows([dsPointOrBatch], cfg.datasetAverageArray)[0]; this.chart.data.datasets[1].data.push(avgRow); } this.applyTitleAndAnnotationValues(dsPointOrBatch, cfg); @@ -832,7 +834,7 @@ export class WidgetDataChartComponent implements OnDestroy { this.ngZone.runOutsideAngular(() => this.chart?.update('none')); } - private applyTitleAndAnnotationValues(point: IDatasetServiceDatapoint, cfg: IWidgetSvcConfig): void { + private applyTitleAndAnnotationValues(point: IGraphDatapoint, cfg: IWidgetSvcConfig): void { const measure = this.unitsService.resolvePathMeasure(cfg.datachartPath ?? ''); const trackValue: number = cfg.trackAgainstAverage ? (point.data.sma ?? point.data.value) : point.data.value; const convertedTrack = this.unitsService.convertToUnit(measure, trackValue); @@ -856,7 +858,7 @@ export class WidgetDataChartComponent implements OnDestroy { this.updateAnnotationVisibility(); } - private transformDatasetRows(rows: IDatasetServiceDatapoint[], datasetType): IDataSetRow[] { + private transformSeriesRows(rows: IGraphDatapoint[], seriesType): IDataPointRow[] { const cfg = this.runtime.options(); if (!cfg) return []; const measure = this.unitsService.resolvePathMeasure(cfg.datachartPath ?? ''); @@ -866,7 +868,7 @@ export class WidgetDataChartComponent implements OnDestroy { return rows.map(row => { if (verticalChart) { - if (datasetType === 0) { + if (seriesType === 0) { return { x: convert(row.data.value), y: row.timestamp }; } else { const avgMap = { @@ -878,7 +880,7 @@ export class WidgetDataChartComponent implements OnDestroy { return { x: convert(avgMap[avgKey]), y: row.timestamp }; } } else { - if (datasetType === 0) { + if (seriesType === 0) { return { x: row.timestamp, y: convert(row.data.value) }; } else { const avgMap = { @@ -896,7 +898,7 @@ export class WidgetDataChartComponent implements OnDestroy { ngOnDestroy(): void { this.streamSub?.unsubscribe(); this.chart?.destroy(); - const canvas = this.widgetDataChart?.()?.nativeElement as HTMLCanvasElement | undefined; + const canvas = this.widgetDataGraph?.()?.nativeElement as HTMLCanvasElement | undefined; this.canvasService.releaseCanvas(canvas, { clear: true, removeFromDom: true }); } } diff --git a/src/app/widgets/widget-numeric/widget-numeric.component.html b/src/app/widgets/widget-numeric/widget-numeric.component.html index ecd8951e..daa242bb 100644 --- a/src/app/widgets/widget-numeric/widget-numeric.component.html +++ b/src/app/widgets/widget-numeric/widget-numeric.component.html @@ -1,6 +1,6 @@ @if (showMiniChart()) { @if (theme(); as t) { - + } } diff --git a/src/app/widgets/widget-numeric/widget-numeric.component.ts b/src/app/widgets/widget-numeric/widget-numeric.component.ts index 2dfcae36..fcb58090 100644 --- a/src/app/widgets/widget-numeric/widget-numeric.component.ts +++ b/src/app/widgets/widget-numeric/widget-numeric.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, AfterViewInit, ElementRef, inject, signal, viewChild, effect, untracked, input, OnInit, computed } from '@angular/core'; import { ChangeDetectionStrategy } from '@angular/core'; import { IWidgetSvcConfig } from '../../core/interfaces/widgets-interface'; -import { MinichartComponent } from '../minichart/minichart.component'; +import { MinigraphComponent } from '../minigraph/minigraph.component'; import { reduceMinMax } from './numeric-minmax.util'; import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; import { WidgetStreamsDirective } from '../../core/directives/widget-streams.directive'; @@ -26,7 +26,7 @@ const LABEL_ROW_FRACTION = 0.1; changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './widget-numeric.component.html', styleUrls: ['./widget-numeric.component.scss'], - imports: [MinichartComponent] + imports: [MinigraphComponent] }) export class WidgetNumericComponent implements OnInit, AfterViewInit, OnDestroy { public id = input.required(); @@ -68,7 +68,7 @@ export class WidgetNumericComponent implements OnInit, AfterViewInit, OnDestroy private readonly canvas = inject(CanvasService); private readonly unitsService = inject(UnitsService); - protected miniChart = viewChild(MinichartComponent); + protected miniGraph = viewChild(MinigraphComponent); private canvasMainRef = viewChild.required>('canvasMainRef'); protected showMiniChart = signal(false); @@ -176,18 +176,18 @@ export class WidgetNumericComponent implements OnInit, AfterViewInit, OnDestroy if (sig) { this.stream?.observe('numericPath', this.onNumericValue); this.streamRegistered = true; - this.updateMiniChartVisibility(); + this.updateMiniGraphVisibility(); } }); }); effect(() => { const show = this.showMiniChart(); - const chart = this.miniChart(); + const graph = this.miniGraph(); const cfg = this.runtime?.options(); const pathInfo = cfg?.paths?.['numericPath']; const effUnit = this.effectiveUnit(); - const miniChartSignature = [ + const miniGraphSignature = [ cfg?.showMiniChart ? '1' : '0', pathInfo?.path ?? '', pathInfo?.source ?? 'default', @@ -199,11 +199,11 @@ export class WidgetNumericComponent implements OnInit, AfterViewInit, OnDestroy cfg?.verticalChart ? '1' : '0', cfg?.color ?? '' ].join('|'); - if (!miniChartSignature) return; + if (!miniGraphSignature) return; if (!show) return; - if (!chart) return; // will re-run when present - this.setMiniChart(chart); - chart.startChart(); + if (!graph) return; // will re-run when present + this.setMiniGraph(graph); + graph.startGraph(); }); } @@ -236,7 +236,7 @@ export class WidgetNumericComponent implements OnInit, AfterViewInit, OnDestroy if (!this.streamRegistered && this.subscriptionSignature()) { this.stream?.observe('numericPath', this.onNumericValue); this.streamRegistered = true; - this.updateMiniChartVisibility(); + this.updateMiniGraphVisibility(); } } @@ -267,23 +267,23 @@ export class WidgetNumericComponent implements OnInit, AfterViewInit, OnDestroy this.maxValueTextHeight = Math.max(1, Math.floor(this.valueBoxBottom() - this.labelBaselineY())); } - private updateMiniChartVisibility(): void { + private updateMiniGraphVisibility(): void { this.showMiniChart.set(!!this.runtime.options()?.showMiniChart); } - private setMiniChart(chart: MinichartComponent): void { + private setMiniGraph(graph: MinigraphComponent): void { const cfg = this.runtime.options(); if (!cfg) return; const pathInfo = cfg.paths?.['numericPath']; - chart.dataPath = pathInfo?.path ?? null; - chart.dataSource = pathInfo?.source ?? 'default'; - chart.color = cfg.color ?? 'contrast'; - chart.convertUnitTo = this.effectiveUnit(); - chart.numDecimal = cfg.numDecimal ?? 1; - chart.yScaleMin = cfg.yScaleMin ?? 0; - chart.yScaleMax = cfg.yScaleMax ?? 10; - chart.inverseYAxis = cfg.inverseYAxis ?? false; - chart.verticalChart = cfg.verticalChart ?? false; + graph.dataPath = pathInfo?.path ?? null; + graph.dataSource = pathInfo?.source ?? 'default'; + graph.color = cfg.color ?? 'contrast'; + graph.convertUnitTo = this.effectiveUnit(); + graph.numDecimal = cfg.numDecimal ?? 1; + graph.yScaleMin = cfg.yScaleMin ?? 0; + graph.yScaleMax = cfg.yScaleMax ?? 10; + graph.inverseYAxis = cfg.inverseYAxis ?? false; + graph.verticalChart = cfg.verticalChart ?? false; } private setColors(): void { diff --git a/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.html b/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.html deleted file mode 100644 index 5cfa3290..00000000 --- a/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.html b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.html new file mode 100644 index 00000000..b46b03b2 --- /dev/null +++ b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.html @@ -0,0 +1 @@ + diff --git a/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.scss b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.scss similarity index 100% rename from src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.scss rename to src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.scss diff --git a/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.spec.ts b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.spec.ts similarity index 93% rename from src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.spec.ts rename to src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.spec.ts index d31b70ef..729d9ac3 100644 --- a/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.spec.ts +++ b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.spec.ts @@ -7,14 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Subject, of } from 'rxjs'; import { BreakpointObserver } from '@angular/cdk/layout'; -import { WidgetWindTrendsChartComponent } from './widget-windtrends-chart.component'; -import { HistoryChartStreamService, HISTORY_UNAVAILABLE } from '../../core/services/history-chart-stream.service'; +import { WidgetWindTrendsGraphComponent } from './widget-windtrends-graph.component'; +import { HistoryGraphStreamService, HISTORY_UNAVAILABLE } from '../../core/services/history-graph-stream.service'; import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; import { UnitsService } from '../../core/services/units.service'; import { DataService } from '../../core/services/data.service'; import { CanvasService } from '../../core/services/canvas.service'; import type { ITheme } from '../../core/services/app-service'; -import type { IDatasetServiceDatapoint } from '../../core/interfaces/dataset.interfaces'; +import type { IGraphDatapoint } from '../../core/interfaces/graph-data.interfaces'; import type { IPathArray } from '../../core/interfaces/widgets-interface'; const themeMock = new Proxy({}, { get: () => '#000000' }) as unknown as ITheme; @@ -23,10 +23,10 @@ const themeMock = new Proxy({}, { get: () => '#000000' }) as unknown as ITheme; // (from DEFAULT_CONFIG or a user override). The runtime directive is mocked here, so the spec has to // supply that record explicitly — a bare `{ timeScale, color }` would gate out both series. const cloneDefaultPaths = (): IPathArray => - structuredClone(WidgetWindTrendsChartComponent.DEFAULT_CONFIG.paths) as IPathArray; + structuredClone(WidgetWindTrendsGraphComponent.DEFAULT_CONFIG.paths) as IPathArray; -describe('WidgetWindTrendsChartComponent', () => { - let fixture: ComponentFixture; +describe('WidgetWindTrendsGraphComponent', () => { + let fixture: ComponentFixture; const runtimeMock = { options: vi.fn() }; const historyMock = { getBackfillThenLive: vi.fn() }; @@ -44,10 +44,10 @@ describe('WidgetWindTrendsChartComponent', () => { runtimeMock.options.mockReturnValue({ timeScale, color: 'contrast', paths }); await TestBed.configureTestingModule({ - imports: [WidgetWindTrendsChartComponent], + imports: [WidgetWindTrendsGraphComponent], providers: [ { provide: WidgetRuntimeDirective, useValue: runtimeMock }, - { provide: HistoryChartStreamService, useValue: historyMock }, + { provide: HistoryGraphStreamService, useValue: historyMock }, { provide: UnitsService, useValue: unitsMock }, { provide: DataService, useValue: dataMock }, { provide: CanvasService, useValue: canvasMock }, @@ -55,7 +55,7 @@ describe('WidgetWindTrendsChartComponent', () => { ] }).compileComponents(); - fixture = TestBed.createComponent(WidgetWindTrendsChartComponent); + fixture = TestBed.createComponent(WidgetWindTrendsGraphComponent); fixture.componentRef.setInput('id', 'w1'); fixture.componentRef.setInput('type', 'widget-windtrends-chart'); fixture.componentRef.setInput('theme', themeMock); @@ -64,14 +64,14 @@ describe('WidgetWindTrendsChartComponent', () => { fixture.detectChanges(); }; - // The "Data acquisition…" overlay's readiness gate is internal to the chart plugin; isChartReady is - // its testable seam. Reads the component's live chart (both share the same datasets reference). + // The "Data acquisition…" overlay's readiness gate is internal to the graph plugin; isChartReady is + // its testable seam. Reads the component's live graph (both share the same datasets reference). const readiness = (): boolean => { const probe = fixture.componentInstance as unknown as { chart: unknown; isChartReady(chart: unknown): boolean }; return probe.isChartReady(probe.chart); }; - // The big-number unit label is drawn inside the chart plugin (which the MockChart never invokes), so + // The big-number unit label is drawn inside the graph plugin (which the MockChart never invokes), so // its source — speedUnitSymbol() — is probed directly, the same private-seam pattern as readiness(). const speedLabel = (): string => (fixture.componentInstance as unknown as { speedUnitSymbol(): string }).speedUnitSymbol(); @@ -116,7 +116,7 @@ describe('WidgetWindTrendsChartComponent', () => { expect(() => spd.next(HISTORY_UNAVAILABLE)).not.toThrow(); }); - it('plots direction and speed backfill batches without crashing', async () => { + it('graphs direction and speed backfill batches without crashing', async () => { const dir = new Subject(); const spd = new Subject(); historyMock.getBackfillThenLive @@ -124,7 +124,7 @@ describe('WidgetWindTrendsChartComponent', () => { .mockReturnValueOnce(spd); await setup('Last 30 Minutes'); - const batch: IDatasetServiceDatapoint[] = [ + const batch: IGraphDatapoint[] = [ { timestamp: 1000, data: { value: 10, sma: 10, lastAverage: 10, lastMinimum: 10, lastMaximum: 10 } }, { timestamp: 2000, data: { value: 350, sma: 340, lastAverage: 345, lastMinimum: 10, lastMaximum: 350 } } ]; @@ -216,7 +216,7 @@ describe('WidgetWindTrendsChartComponent', () => { // The history dialog classifies a slot as structural (stored unit) vs display (server measure) by // showConvertUnitTo===false. TWS must stay a display path or the dialog pins to knots while the tile // shows the server unit — the lock-step break this fix closes. - const paths = WidgetWindTrendsChartComponent.DEFAULT_CONFIG.paths as IPathArray; + const paths = WidgetWindTrendsGraphComponent.DEFAULT_CONFIG.paths as IPathArray; expect(paths.trueWindDirection.showConvertUnitTo).toBe(false); expect(paths.trueWindSpeed.showConvertUnitTo).toBeUndefined(); }); @@ -257,7 +257,7 @@ describe('WidgetWindTrendsChartComponent', () => { expect(historyMock.getBackfillThenLive).toHaveBeenCalledTimes(1); expect(historyMock.getBackfillThenLive.mock.calls[0][0].path).toBe('self.environment.wind.speedTrue'); - const batch: IDatasetServiceDatapoint[] = [ + const batch: IGraphDatapoint[] = [ { timestamp: 1000, data: { value: 5, sma: 5, lastAverage: 5, lastMinimum: 5, lastMaximum: 5 } }, { timestamp: 2000, data: { value: 7, sma: 6, lastAverage: 6, lastMinimum: 5, lastMaximum: 7 } } ]; diff --git a/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.ts b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.ts similarity index 93% rename from src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.ts rename to src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.ts index bb201ed1..df7626b1 100644 --- a/src/app/widgets/widget-windtrends-chart/widget-windtrends-chart.component.ts +++ b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.ts @@ -2,16 +2,16 @@ import { Component, OnDestroy, ElementRef, viewChild, inject, effect, NgZone, in import { BreakpointObserver, Breakpoints, BreakpointState } from '@angular/cdk/layout'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { IWidgetSvcConfig, IWidgetPath } from '../../core/interfaces/widgets-interface'; -import { HistoryChartStreamService, IHistoryChartStreamParams, isHistoryUnavailable } from '../../core/services/history-chart-stream.service'; -import { IDatasetServiceDatapoint } from '../../core/interfaces/dataset.interfaces'; -import { resolveWindowMs, deriveDataSourceInfo, IChartDataSourceInfo } from '../../core/utils/chart-window.util'; +import { HistoryGraphStreamService, IHistoryGraphStreamParams, isHistoryUnavailable } from '../../core/services/history-graph-stream.service'; +import { IGraphDatapoint } from '../../core/interfaces/graph-data.interfaces'; +import { resolveWindowMs, deriveDataSourceInfo, IGraphDataSourceInfo } from '../../core/utils/graph-window.util'; import { Subscription, distinctUntilChanged, map, of, switchMap } from 'rxjs'; import { CanvasService } from '../../core/services/canvas.service'; import { DataService } from '../../core/services/data.service'; import { UnitsService } from '../../core/services/units.service'; import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; import { ITheme } from '../../core/services/app-service'; -import { TimeScaleFormat } from '../../core/interfaces/dataset.interfaces'; +import { TimeScaleFormat } from '../../core/interfaces/graph-data.interfaces'; import { Chart, ChartConfiguration, ChartData, ChartType, ChartArea, Scale, ChartTypeRegistry } from 'chart.js'; import 'chartjs-adapter-date-fns'; @@ -31,19 +31,21 @@ interface IChartColors { chartLabel: string | null, chartValue: string | null } -interface IDataSetRow { +interface IDataPointRow { x: number, y: number, // age in ms (computed each update), or temporary ts at insert ts?: number // original timestamp in ms, used to recompute age } @Component({ + // The selector doubles as the persisted widget `type` in stored dashboards, so it keeps the + // pre-rename "chart" spelling until a config migration renames it (#592). selector: 'widget-windtrends-chart', - templateUrl: './widget-windtrends-chart.component.html', - styleUrl: './widget-windtrends-chart.component.scss', + templateUrl: './widget-windtrends-graph.component.html', + styleUrl: './widget-windtrends-graph.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class WidgetWindTrendsChartComponent implements OnDestroy { +export class WidgetWindTrendsGraphComponent implements OnDestroy { // Host2 functional inputs public id = input.required(); public type = input.required(); @@ -58,7 +60,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { updateInterval: 1000, // TWD is STRUCTURAL: fixed to degrees (showConvertUnitTo:false) because the widget's angle-wrap and // tick math are degree-native — the history dialog keeps it in degrees too. TWS is a DISPLAY path: - // it follows the server's displayUnits preference, resolved at render like widget-data-chart (and by + // it follows the server's displayUnits preference, resolved at render like widget-data-graph (and by // the history dialog via resolvePathMeasure), so it must NOT carry showConvertUnitTo:false — that // flag would pin the dialog to the stored knots while the tile shows the server unit. Skip owns no // per-widget speed unit; convertUnitTo:'knots' is an inert stored default kept to match the other @@ -94,7 +96,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { } }; private readonly ngZone = inject(NgZone); - private readonly historyStream = inject(HistoryChartStreamService); + private readonly historyStream = inject(HistoryGraphStreamService); private readonly canvasService = inject(CanvasService); private readonly unitsService = inject(UnitsService); private readonly dataService = inject(DataService); @@ -107,7 +109,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { * Server-resolved display measure for the wind-speed series. resolvePathMeasure() reads a non-signal * meta cache, so it is folded through the path's meta subject to re-emit when the server's * displayUnits land late or change; that change flows into computeRebuildSignature and rebuilds the - * chart in the new unit. Mirrors widget-data-chart's reactive pathMeasure. The actual conversion and + * graph in the new unit. Mirrors widget-data-graph's reactive pathMeasure. The actual conversion and * label read the resolved measure directly at build time (see speedMeasureKey). */ private readonly speedMeasure = toSignal( @@ -118,7 +120,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { distinctUntilChanged() ) ); - readonly widgetDataChart = viewChild('widgetDataChart', { read: ElementRef }); + readonly widgetDataGraph = viewChild('widgetDataGraph', { read: ElementRef }); public lineChartData: ChartData<'line', { x: number, y: number }[]> = { datasets: [] }; @@ -141,7 +143,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { private chart: Chart; private _dsDirectionSub: Subscription | null = null; private _dsSpeedSub: Subscription | null = null; - /** Pending coalesced chart recompute+repaint frame id (one per animation frame across both streams). */ + /** Pending coalesced graph recompute+repaint frame id (one per animation frame across both streams). */ private _chartUpdateRafId: number | null = null; private timeScale: TimeScaleFormat | null = null; /** Signature of the last inputs a full rebuild was performed for (see computeRebuildSignature). */ @@ -153,7 +155,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { /** Server-resolved display measure applied to the speed datasets + big-number label; null when no * speed path. Re-resolved on each (re)build, so a late/changed displayUnits reconverts the series. */ private speedMeasureKey: string | null = null; - private dataSourceInfo: IChartDataSourceInfo | null = null; + private dataSourceInfo: IGraphDataSourceInfo | null = null; private xCenter: number | null = null; private xStep: number | null = null; private xCenterSpeed: number | null = null; @@ -191,7 +193,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = theme.background; - // Fill exactly the plotting area where grid lines are drawn + // Fill exactly the graphing area where grid lines are drawn ctx.fillRect(area.left, area.top, area.width / 2, area.height); ctx.restore(); } @@ -299,7 +301,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { } // Top-right speed value - const ds = chart.data?.datasets as unknown as { label?: string; data: IDataSetRow[] }[]; + const ds = chart.data?.datasets as unknown as { label?: string; data: IDataPointRow[] }[]; const speedVal = ds?.[5]?.data; // first speed dataset index (see dataset order) const last = speedVal?.length ? speedVal.length - 1 : -1; const lastSpeed = last >= 0 ? speedVal[last]?.x : undefined; @@ -372,7 +374,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { constructor() { this.isPhonePortrait = toSignal(this.responsive.observe(Breakpoints.HandsetPortrait), { initialValue: { matches: false, breakpoints: {} } }); - // Theme or config color changes -> restyle chart + // Theme or config color changes -> restyle graph effect(() => { const theme = this.theme(); const cfg = this.runtime?.options(); @@ -402,8 +404,8 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { private startWidget(): void { // Guard until canvas view is ready - const widgetDataChartRef = this.widgetDataChart(); - if (!widgetDataChartRef) return; + const widgetDataGraphRef = this.widgetDataGraph(); + if (!widgetDataGraphRef) return; const cfg = this.runtime?.options(); if (!cfg || !cfg.timeScale) return; // Commit the signature only once the canvas is ready and the build proceeds, so a pre-canvas @@ -415,7 +417,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { this.createDatasets(); this.setChartOptions(); if (!this.chart) { - this.chart = new Chart(widgetDataChartRef.nativeElement.getContext('2d'), { + this.chart = new Chart(widgetDataGraphRef.nativeElement.getContext('2d'), { type: this.lineChartType, data: this.lineChartData, options: this.lineChartOptions, @@ -801,8 +803,8 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { * the overlay up forever. With no series configured at all, stay in the acquiring state. */ private isChartReady(chart: Chart): boolean { - const dirVals = chart.data?.datasets?.[0]?.data as (IDataSetRow[] | undefined); - const spdVals = chart.data?.datasets?.[5]?.data as (IDataSetRow[] | undefined); + const dirVals = chart.data?.datasets?.[0]?.data as (IDataPointRow[] | undefined); + const spdVals = chart.data?.datasets?.[5]?.data as (IDataPointRow[] | undefined); const dirReady = !this.dirSeriesActive || (dirVals?.length ?? 0) >= 2; const spdReady = !this.spdSeriesActive || (spdVals?.length ?? 0) >= 2; return (this.dirSeriesActive || this.spdSeriesActive) && dirReady && spdReady; @@ -827,7 +829,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { // Each series subscribes independently on its own configured path. pathRequired is false, so a // user can clear one slot; gating per series keeps the other one rendering rather than tearing - // down the whole chart. Source falls back to the SK default when the slot leaves it unset. + // down the whole graph. Source falls back to the SK default when the slot leaves it unset. const dir = this.windPathSlot(cfg, 'trueWindDirection'); const dirPath = dir?.path; const spd = this.windPathSlot(cfg, 'trueWindSpeed'); @@ -840,7 +842,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { if (dirPath) { // TWD is a direction path; the History engine auto-resolves its circular domain from the unit. - const twdParams: IHistoryChartStreamParams = { ...baseParams, path: dirPath, source: dir?.source ?? 'default' }; + const twdParams: IHistoryGraphStreamParams = { ...baseParams, path: dirPath, source: dir?.source ?? 'default' }; this._dsDirectionSub = this.historyStream.getBackfillThenLive(twdParams).subscribe(emission => { if (isHistoryUnavailable(emission)) return; if (Array.isArray(emission)) { @@ -856,7 +858,7 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { } if (spdPath) { - const twsParams: IHistoryChartStreamParams = { ...baseParams, path: spdPath, source: spd?.source ?? 'default' }; + const twsParams: IHistoryGraphStreamParams = { ...baseParams, path: spdPath, source: spd?.source ?? 'default' }; this._dsSpeedSub = this.historyStream.getBackfillThenLive(twsParams).subscribe(emission => { if (isHistoryUnavailable(emission)) return; if (Array.isArray(emission)) { @@ -899,11 +901,11 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { return unwrapped; } - private pushRowsToDatasets(rows: IDatasetServiceDatapoint[]): void { + private pushRowsToDatasets(rows: IGraphDatapoint[]): void { this.pushRowsGeneric(rows, 0, 'deg', true); } - private pushRowsToSpeedDatasets(rows: IDatasetServiceDatapoint[]): void { + private pushRowsToSpeedDatasets(rows: IGraphDatapoint[]): void { this.pushRowsGeneric(rows, 5, this.speedMeasureKey ?? 'unitless', false); } @@ -914,8 +916,8 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { return measure && measure !== 'unitless' ? this.unitsService.getUnitDisplaySymbol(measure) : ''; } - private getRowValue(row: IDatasetServiceDatapoint, datasetType: 'value' | 'sma' | 'ema' | 'dema' | 'avg' | 'min' | 'max'): number | null { - switch (datasetType) { + private getRowValue(row: IGraphDatapoint, seriesType: 'value' | 'sma' | 'ema' | 'dema' | 'avg' | 'min' | 'max'): number | null { + switch (seriesType) { case 'value': return row.data.value ?? null; case 'sma': return row.data.sma ?? null; case 'ema': return row.data.ema ?? null; @@ -928,9 +930,9 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { } // Generic transform for the degree (structural) and speed (server-resolved measure) series - private transformRows(rows: IDatasetServiceDatapoint[], datasetType: 'value' | 'sma' | 'ema' | 'dema' | 'avg' | 'min' | 'max', toUnit: string, unwrap: boolean): IDataSetRow[] { + private transformRows(rows: IGraphDatapoint[], seriesType: 'value' | 'sma' | 'ema' | 'dema' | 'avg' | 'min' | 'max', toUnit: string, unwrap: boolean): IDataPointRow[] { const vals = rows.map(row => { - const raw = this.getRowValue(row, datasetType); + const raw = this.getRowValue(row, seriesType); return raw == null ? null : this.unitsService.convertToUnit(toUnit, raw); }); @@ -944,10 +946,10 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { } // Push a batch of rows into 5 consecutive datasets starting at baseIndex - private pushRowsGeneric(rows: IDatasetServiceDatapoint[], baseIndex: 0 | 5, toUnit: string, unwrap: boolean): void { + private pushRowsGeneric(rows: IGraphDatapoint[], baseIndex: 0 | 5, toUnit: string, unwrap: boolean): void { const types: ('value' | 'sma' | 'avg' | 'min' | 'max')[] = ['value', 'sma', 'avg', 'min', 'max']; types.forEach((type, i) => { - (this.chart.data.datasets[baseIndex + i].data as IDataSetRow[]) + (this.chart.data.datasets[baseIndex + i].data as IDataPointRow[]) .push(...this.transformRows(rows, type, toUnit, unwrap)); }); } @@ -972,9 +974,9 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { } /** - * Coalesce the chart recompute + repaint into a single animation frame. The direction (-twd) and + * Coalesce the graph recompute + repaint into a single animation frame. The direction (-twd) and * speed (-tws) streams frequently emit in the same frame; without this, each emission runs the - * full 10-dataset y recompute and a chart.update('none'), doing that work twice per frame. + * full 10-dataset y recompute and a graph.update('none'), doing that work twice per frame. */ private scheduleChartUpdate(): void { if (this._chartUpdateRafId != null) return; @@ -988,9 +990,9 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { private updateChartAfterDataChange() { // Calculate dynamic x (direction) scale range based on lastAverage center and lastMin/Max distances (with wrap-around) - const dirAvgArr = this.chart.data.datasets[2]?.data as IDataSetRow[] | undefined; - const dirSmaArr = this.chart.data.datasets[1]?.data as IDataSetRow[] | undefined; - const dirValArr = this.chart.data.datasets[0]?.data as IDataSetRow[] | undefined; + const dirAvgArr = this.chart.data.datasets[2]?.data as IDataPointRow[] | undefined; + const dirSmaArr = this.chart.data.datasets[1]?.data as IDataPointRow[] | undefined; + const dirValArr = this.chart.data.datasets[0]?.data as IDataPointRow[] | undefined; // Center MUST be the latest lastAverage value when available, else fall back to SMA, else Value const centerVal = dirAvgArr?.length ? dirAvgArr[dirAvgArr.length - 1].x @@ -1001,8 +1003,8 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { : undefined; if (typeof centerVal === 'number' && isFinite(centerVal)) { // Try to use lastMinimum/Maximum if available; else derive half-range from recent window using angular distance - const minDs = this.chart.data.datasets[3]?.data as IDataSetRow[] | undefined; - const maxDs = this.chart.data.datasets[4]?.data as IDataSetRow[] | undefined; + const minDs = this.chart.data.datasets[3]?.data as IDataPointRow[] | undefined; + const maxDs = this.chart.data.datasets[4]?.data as IDataPointRow[] | undefined; const lastMin = minDs?.length ? minDs[minDs.length - 1].x : undefined; const lastMax = maxDs?.length ? maxDs[maxDs.length - 1].x : undefined; const minDiff = typeof lastMin === 'number' && isFinite(lastMin) ? this.angularDiff(centerVal, lastMin) : Number.NaN; @@ -1042,12 +1044,12 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { } // Calculate dynamic xSpeed (knots) scale range based on lastAverage center and lastMin/Max distances - const sAvgArr = this.chart.data.datasets[7]?.data as IDataSetRow[] | undefined; + const sAvgArr = this.chart.data.datasets[7]?.data as IDataPointRow[] | undefined; if (sAvgArr && sAvgArr.length) { const sIdx = sAvgArr.length - 1; const sAvg = sAvgArr[sIdx]?.x ?? 0; - const sMin = (this.chart.data.datasets[8].data as IDataSetRow[])[sIdx]?.x ?? sAvg; - const sMax = (this.chart.data.datasets[9].data as IDataSetRow[])[sIdx]?.x ?? sAvg; + const sMin = (this.chart.data.datasets[8].data as IDataPointRow[])[sIdx]?.x ?? sAvg; + const sMax = (this.chart.data.datasets[9].data as IDataPointRow[])[sIdx]?.x ?? sAvg; const sDiffMin = Math.abs(sAvg - sMin); const sDiffMax = Math.abs(sMax - sAvg); let halfRangeS = Math.max(sDiffMin, sDiffMax); @@ -1074,13 +1076,13 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { // Fixed, non-scrolling y-axis window (relative age). Gate on either series so a cleared // direction slot still lets the speed series recompute its age-based y positions. const windowMs = this.getWindowMs(this.timeScale); - const dirData = this.chart.data.datasets[0].data as (IDataSetRow[]); - const speedData = this.chart.data.datasets[5].data as (IDataSetRow[]); + const dirData = this.chart.data.datasets[0].data as (IDataPointRow[]); + const speedData = this.chart.data.datasets[5].data as (IDataPointRow[]); if (dirData.length > 0 || speedData.length > 0) { const nowTs = Date.now(); // Recompute y for all datasets as age (ms) relative to now this.chart.data.datasets.forEach(ds => { - (ds.data as IDataSetRow[]).forEach(p => { + (ds.data as IDataPointRow[]).forEach(p => { const ts = p.ts ?? p.y; p.y = Math.max(0, Math.min(windowMs, nowTs - ts)); }); @@ -1286,9 +1288,9 @@ export class WidgetWindTrendsChartComponent implements OnDestroy { cancelAnimationFrame(this._chartUpdateRafId); this._chartUpdateRafId = null; } - // we need to destroy when moving Pages to remove Chart Objects + // we need to destroy when moving Pages to remove Graph Objects this.chart?.destroy(); - const canvas = this.widgetDataChart?.()?.nativeElement as HTMLCanvasElement | undefined; + const canvas = this.widgetDataGraph?.()?.nativeElement as HTMLCanvasElement | undefined; this.canvasService.releaseCanvas(canvas, { clear: true, removeFromDom: true }); } } diff --git a/src/assets/svg/icons.svg b/src/assets/svg/icons.svg index 9be79e24..a1d02af0 100644 --- a/src/assets/svg/icons.svg +++ b/src/assets/svg/icons.svg @@ -293,7 +293,7 @@ - + diff --git a/src/default-config/config.blank.dashboard.spec.ts b/src/default-config/config.blank.dashboard.spec.ts index b8ac65d1..113b3002 100644 --- a/src/default-config/config.blank.dashboard.spec.ts +++ b/src/default-config/config.blank.dashboard.spec.ts @@ -3,7 +3,7 @@ import { DefaultDashboard } from './config.blank.dashboard'; import { WidgetPositionComponent } from '../app/widgets/widget-position/widget-position.component'; import { WidgetWindComponent } from '../app/widgets/widget-windsteer/widget-windsteer.component'; import { WidgetRacesteerComponent } from '../app/widgets/widget-racesteer/widget-racesteer.component'; -import { WidgetWindTrendsChartComponent } from '../app/widgets/widget-windtrends-chart/widget-windtrends-chart.component'; +import { WidgetWindTrendsGraphComponent } from '../app/widgets/widget-windtrends-graph/widget-windtrends-graph.component'; import { WidgetAutopilotComponent } from '../app/widgets/widget-autopilot/widget-autopilot.component'; import { WidgetHorizonComponent } from '../app/widgets/widget-horizon/widget-horizon.component'; import { WidgetHeelGaugeComponent } from '../app/widgets/widget-heel-gauge/widget-heel-gauge.component'; @@ -183,7 +183,7 @@ describe('wind-family path config shape', () => { { type: 'widget-racesteer', config: WidgetRacesteerComponent.DEFAULT_CONFIG, choice: ['headingPath', 'trueWindAngle', 'courseOverGround'], fixed: ['appWindAngle', 'appWindSpeed', 'trueWindSpeed', 'nextWaypointBearing', 'set', 'drift'] }, - { type: 'widget-windtrends-chart', config: WidgetWindTrendsChartComponent.DEFAULT_CONFIG, + { type: 'widget-windtrends-chart', config: WidgetWindTrendsGraphComponent.DEFAULT_CONFIG, choice: ['trueWindDirection'], fixed: ['trueWindSpeed'] }, ]; diff --git a/src/test-shims/chartjs-shim.ts b/src/test-shims/chartjs-shim.ts index e9663ded..dd73a24a 100644 --- a/src/test-shims/chartjs-shim.ts +++ b/src/test-shims/chartjs-shim.ts @@ -6,8 +6,8 @@ * canvas, so a chart built against the environment's canvas stub ends up with a falsy `ctx` and * silently drops everything a component feeds it. Specs used to guard against that with a per-file * `vi.mock('chart.js')`, which only works if that file is the first in its worker to pull the module - * in. `widget-numeric` reaches chart.js through `MinichartComponent` without mocking it, so whenever - * that spec loaded first the real library won and minichart's own mock arrived too late — an + * in. `widget-numeric` reaches chart.js through `MinigraphComponent` without mocking it, so whenever + * that spec loaded first the real library won and minigraph's own mock arrived too late — an * order-dependent failure that looked like a flake (#544). * * Aliasing removes the race: every spec gets this module, whoever loads it first. From 2266e1d49f10fadf87d83f872d8035d6ec049a74 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 17:17:47 +0300 Subject: [PATCH 2/6] docs(graph): rename charts and plots to graphs in user-facing docs Follows the widget rename: the Realtime Data Plot is now the Data Graph ("Realtime" dropped because it seeds from the History API), and the help docs, README and CLAUDE.md say graph throughout. Nautical uses stay: chart plotter, nautical charts, and AIS COG plotting. --- CLAUDE.md | 4 +-- README.md | 8 ++--- src/assets/help-docs/dashboards.md | 8 ++--- src/assets/help-docs/history-api.md | 46 ++++++++++++++--------------- src/assets/help-docs/time-series.md | 20 ++++++------- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 12ea442c..ed549931 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ The mock serves Skip's full session/config surface (`loginStatus`, `applicationD - **Specs run against the REAL app services, by design.** Under this runner the setup file's classes are *different module instances* than the app bundle's, so **any** `{ provide: AppClass, … }` in `src/test.ts` — a stub **or** the real class — is **DI-inert** (proven in #159). A component that injects a **`providedIn: 'root'`** service still gets its real root instance regardless; a **non-root** service (e.g. `UnitsService`, a plain `@Injectable()`) must be provided by the spec that needs it. So `src/test.ts` provides **only** what actually takes effect: framework tokens (`MAT_DIALOG_DATA`, `MatDialogRef`, `MatBottomSheetRef`, `ActivatedRoute`, `FormGroupDirective`, zoneless CD, `HttpClientTesting`, `NoopAnimations`), env/DOM shims (canvas, `ResizeObserver`, `matchMedia`, fonts, media), and icon registration. It provides **no** app services — not `SettingsService` / `AuthenticationService` / `SignalKConnectionService` / `ConnectionStateMachine` / `UnitsService` / the widget host directives. - **A spec that needs a fake declares it locally.** Add a local provider in that spec's `configureTestingModule` (globals are prepended, so a local `{ provide: X, useValue: … }` wins). That is the whole mocking model — there is no shared app-service stub to extend. A local `SignalKConnectionService` fake must still expose `serverServiceEndpoint$` and `serverVersion$` (services subscribe to them at construction). - **The failure mode is real-service construction, not stub drift.** Because components receive the real services, a service that throws at construction (missing dependency, a `requireSync`/NG0601 hazard) fails the spec directly — construction-time resilience of the real services is load-bearing for ~30 spec files. Fix the app code or add a local fake; do **not** re-add a global app-service stub (it is inert anyway). -- **Chart.js is a shim, aliased not mocked.** `vitest.config.ts` aliases `chart.js`, `chartjs-plugin-annotation`, `chartjs-adapter-date-fns` and `@aziham/chartjs-plugin-streaming` to `src/test-shims/chartjs-*`, the same way gridstack and canvas-gauges are aliased. **Do not add a per-file `vi.mock('chart.js')`** — that is what #544 was: a per-file mock only wins when its spec is the first in the worker to load the module, and `widget-numeric` reaches chart.js through `MinichartComponent` without mocking, so the real library was sometimes cached first. The real library cannot acquire a 2D context under jsdom, so `chart.ctx` comes out falsy and components silently drop everything they feed the chart — a live subscription plotting nothing, which reads as a flake rather than a wiring error. The shims are imported from `src/test.ts` so their chunks resolve during setup; a spec that first reaches one lazily can otherwise request it after its worker's environment is torn down (`EnvironmentTeardownError`, which fails the suite file while every test passes). +- **Chart.js is a shim, aliased not mocked.** `vitest.config.ts` aliases `chart.js`, `chartjs-plugin-annotation`, `chartjs-adapter-date-fns` and `@aziham/chartjs-plugin-streaming` to `src/test-shims/chartjs-*`, the same way gridstack and canvas-gauges are aliased. **Do not add a per-file `vi.mock('chart.js')`** — that is what #544 was: a per-file mock only wins when its spec is the first in the worker to load the module, and `widget-numeric` reaches chart.js through `MinigraphComponent` without mocking, so the real library was sometimes cached first. The real library cannot acquire a 2D context under jsdom, so `chart.ctx` comes out falsy and components silently drop everything they feed the chart — a live subscription plotting nothing, which reads as a flake rather than a wiring error. The shims are imported from `src/test.ts` so their chunks resolve during setup; a spec that first reaches one lazily can otherwise request it after its worker's environment is torn down (`EnvironmentTeardownError`, which fails the suite file while every test passes). - **The gauge library is a no-op shim in tests.** `vitest.config.ts` aliases `@godind/ng-canvas-gauges` to `src/test-shims/ng-canvas-gauges-shim.ts`, whose `LinearGauge`/`RadialGauge` expose only the `options` and `value` inputs — **there is no `update()`**. Every `this.ngGauge()?.update(...)` in the gauge widgets therefore throws `update is not a function` and is swallowed by the surrounding `catch`, so anything asserted only through those imperative pushes is unobservable: a misspelled option key or a null option value passes CI. Test the *decision* instead — call `buildGaugeOptions()` and assert `gaugeOptions`, or extract the rule into a method and assert that. Real library behaviour needs a browser (see `perf-harness/`). - **Local runs work.** The whole suite runs locally via `npm test` (~90s, build-dominated), and a single spec via `ng test --include=''`; plain `npx vitest run ` fails (`@angular/compiler is not available`) because it bypasses the builder. **CI on Node 24 is the authoritative gate** (`npm run ci` = `lint` + `snc` + `test:headless` + `test:mcp-schema`). - CI uses **`npm ci`** (the `run-tests` action and `release.yml`) against a lockfile that is in sync. If `npm ci` ever fails on missing optional platform deps, regenerate the lockfile (`rm package-lock.json && npm install`) and commit it — don't switch CI back to `npm install`. @@ -85,7 +85,7 @@ The mock serves Skip's full session/config surface (`loginStatus`, `applicationD A session-less visitor is a third shape, not an error: when the server reports `readOnlyAccess` (its `allow_readonly`) and does not ask for OIDC auto-login, the bootstrap returns `anonymous` instead of redirecting, and loads the shared `global`-scope slot named `default` — the only config an anonymous principal can read — falling back to `buildDefaultConfig()`. Nothing in Skip writes that slot; an operator publishes it out of band. **Write capability has two gates and `StorageService.canPersist()` is both of them**: the session's `userLevel` must allow writes, AND the loaded config must be the session's own rather than a shared read-only view (`isReadOnlyContext()`, set by the bootstrap). The second gate is what stops an anonymous tab that later acquires a session from writing the shared config into a real user's slot. `DashboardService.isReadOnlySession` is the signal every affordance and guard reads, so a lock and its button cannot disagree. -**History & charts**: the SK **v2 History API** is consumed by `HistoryApiClientService`; `HistoryToChartMapperService` adapts history values to chart datapoints; `DashboardHistorySeriesSyncService` resolves per-widget history series for the history dialog; `HistoryChartStreamService` feeds the trend-chart widgets (History-API backfill plus a thin delta-stream live tail). Skip ships no server-side history provider — the History API is served by an external provider (InfluxDB via `signalk-to-influxdb2`, or `signalk-parquet`), and charts render an empty state when none is present. +**History & graphs**: time-series visualizations are **graphs** throughout the UI and the code — "chart" is reserved for the nautical sense and for chart.js's own API. The SK **v2 History API** is consumed by `HistoryApiClientService`; `HistoryToGraphMapperService` adapts history values to graph datapoints; `DashboardHistorySeriesSyncService` resolves per-widget history series for the history dialog; `HistoryGraphStreamService` feeds the trend-graph widgets (History-API backfill plus a thin delta-stream live tail). Skip ships no server-side history provider — the History API is served by an external provider (InfluxDB via `signalk-to-influxdb2`, or `signalk-parquet`), and graphs render an empty state when none is present. Two widget selectors (`widget-data-chart`, `widget-windtrends-chart`) keep the old spelling because they are the persisted widget `type` in stored dashboards; renaming them needs a config migration (#592). ## Gotchas diff --git a/README.md b/README.md index 0617f6b5..12425d8e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Skip is designed for sailors and boaters who want: - A **modern, polished interface** optimized for marine displays. - **Touch-optimized design**: touch-first, intuitive design for tablets, phones, and other touch-enabled devices. - **Cross-platform support**: runs on phones, tablets, laptops, Raspberry Pi, Web Enabled TV or other fixed displays - anywhere you can run a web browser. -- **Instant access to all Signal K data**: displays gauges, plots, switches, and other widgets right out of the box. +- **Instant access to all Signal K data**: displays gauges, graphs, switches, and other widgets right out of the box. - **Flexible dashboards**: customize layouts, drag-and-drop widgets, night/day mode, kiosk/fullscreen and remote control support. With Skip, you get the **clarity of a purpose-built marine instrument panel** combined with the flexibility of Signal K. It’s simple, reliable, and highly usable — a modern, touch-first instrument panel for [Signal K](https://signalk.org) vessels. @@ -130,7 +130,7 @@ All Skip widgets are visual presentation controls that are very versatile, with - **AC/DC Charger**- Monitor charging performance at a glance with a compact AC/DC Charger Widget. View single or multiple chargers with charge mode, voltage, current, power and temperature. Chargers are discovered automatically. - **Freeboard-SK** – Adds the Freeboard-SK chart plotter as a widget with automatic sign-in. - **Autopilot Head** – Typical autopilot controls for compatible Signal K Autopilot devices. -- **Realtime Data Plot** – Visualizes data on a real-time plot with actuals, averages, and min/max. +- **Data Graph** – Graphs any numeric path over a configurable time window, with actuals, averages, and min/max. - **AIS Radar**: Display AIS targets with range rings, interactive target details, and quick zoom and filtering controls. - **Embed Webpage Viewer** – Embeds external web apps (Grafana, Node-RED, etc.) into your dashboard. - **Racesteer** – Race steering display fusing polar performance data with live conditions for optimal tactics. @@ -154,7 +154,7 @@ Grafana integration with other widgets ![Embedded Webpage Concept Image](./images/SkipGaugeSample3-1024x508.png) ## Historical Data -Skip plots recent history for your numeric data by reading it from an external Signal K History API provider (such as `signalk-to-influxdb2` or `signalk-parquet`). Press and hold (long-press) a widget to open its history dialog, or use a Realtime Data Plot or Wind Trends widget to see recent trends. Skip does **not** record or store data itself — the detail and time span available depend on whatever provider your Signal K server runs, and plots show live data only when no provider is present. See the [History-API Provider](src/assets/help-docs/history-api.md) help file for setup. +Skip graphs recent history for your numeric data by reading it from an external Signal K History API provider (such as `signalk-to-influxdb2` or `signalk-parquet`). Press and hold (long-press) a widget to open its history dialog, or use a Data Graph or Wind Trends widget to see recent trends. Skip does **not** record or store data itself — the detail and time span available depend on whatever provider your Signal K server runs, and graphs show live data only when no provider is present. See the [History-API Provider](src/assets/help-docs/history-api.md) help file for setup. ## Night Modes Keep your night vision with automatic or manual day and night switching to a color preserving dim mode or an all Red theme. The images below look very dark, but at night... they are perfect! @@ -187,7 +187,7 @@ Typical complementary components you may install (most are often bundled with Si - **Node‑RED** – Low‑code, flow‑based wiring of devices, APIs, online services, and custom logic (alert escalation, device control automation, data enrichment, protocol bridging). **Data Storage & Analytics** -- **InfluxDB / other TSDB** – High‑resolution historical storage of sensor & performance metrics beyond what lightweight widget plots should retain. +- **InfluxDB / other TSDB** – High‑resolution historical storage of sensor & performance metrics beyond what lightweight widget graphs should retain. - **Grafana** – Rich exploratory / comparative dashboards, ad‑hoc queries, alert rules on stored metrics, correlation across heterogeneous data sources. ## Harness the Power of Data State Notifications diff --git a/src/assets/help-docs/dashboards.md b/src/assets/help-docs/dashboards.md index d7a99319..ecc59eb5 100644 --- a/src/assets/help-docs/dashboards.md +++ b/src/assets/help-docs/dashboards.md @@ -39,9 +39,9 @@ In edit mode, widgets show dashed outlines, and the toolbar swaps to an "Editing >**Tip:** If you can’t add a widget, free up space by resizing or moving existing ones first. ## Viewing Widget History on a Locked Page -When a page is locked (normal viewing mode), you can open a history plot for a widget without entering edit mode: **press and hold (long-press)** the widget. +When a page is locked (normal viewing mode), you can open a history graph for a widget without entering edit mode: **press and hold (long-press)** the widget. -Skip opens a history plot dialog and loads historical series data for that widget using the History API. Only widgets bound to numeric data have a history — long-pressing anything else does nothing. +Skip opens a history graph dialog and loads historical series data for that widget using the History API. Only widgets bound to numeric data have a history — long-pressing anything else does nothing. ## Workflow: From Idea to Page @@ -56,7 +56,7 @@ Skip opens a history plot dialog and loads historical series data for that widge ## Widget Gallery (Overview) Skip widgets turn Signal K data into readable visuals and controls. Available widget types: -- **Numeric** – Displays numeric data in a clear and concise format, with options to show min/max values and a background mini plot for trends. +- **Numeric** – Displays numeric data in a clear and concise format, with options to show min/max values and a background mini graph for trends. - **Text** – Displays text data with customizable color formatting. - **Date & Time** – Shows date and time with custom formatting and timezone correction. - **Position** – Displays latitude and longitude for location tracking and navigation. @@ -79,7 +79,7 @@ Skip widgets turn Signal K data into readable visuals and controls. Available wi - **AC/DC Charger**- Monitor charging performance at a glance with a compact AC/DC Charger Widget. View single or multiple chargers with charge mode, voltage, current, power and temperature. Chargers are discovered automatically. - **Freeboard-SK** – Adds the Freeboard-SK chart plotter as a widget with automatic sign-in. - **Autopilot Head** – Typical autopilot controls for compatible Signal K Autopilot devices. -- **Realtime Data Plot** – Visualizes data on a real-time plot with actuals, averages, and min/max. +- **Data Graph** – Graphs any numeric path over a configurable time window, with actuals, averages, and min/max. - **AIS Radar**: Display AIS targets with range rings, interactive target details, and quick zoom and filtering controls. See [AIS Radar Widget](#/help/ais-radar.md). - **Embed Webpage Viewer** – Embeds external web apps (Grafana, Node-RED, etc.) into your dashboard. - **Racesteer** – Race steering display fusing polar performance data with live conditions for optimal tactics. diff --git a/src/assets/help-docs/history-api.md b/src/assets/help-docs/history-api.md index a50b332e..69cda960 100644 --- a/src/assets/help-docs/history-api.md +++ b/src/assets/help-docs/history-api.md @@ -1,10 +1,10 @@ ## Using a History API Provider Skip reads historical data from an external Signal K History API provider plugin. With a compatible provider installed, Skip can: -1. Pre-seed Realtime Data Plot and Wind Trends so they show recent trends immediately. +1. Pre-seed Data Graph and Wind Trends so they show recent trends immediately. 2. Populate historical views for widgets on your pages that use numeric value paths. -You must configure the provider plugin to capture the paths you want to plot, and it must have enough recorded data to cover your plot time span. This is not automatic. +You must configure the provider plugin to capture the paths you want to graph, and it must have enough recorded data to cover your graph time span. This is not automatic. Running a provider gives you full control, including: - Long-term record keeping. @@ -13,7 +13,7 @@ Running a provider gives you full control, including: ## Which Widgets Support History? -Most widgets that use numeric paths support history, including Horizon, Battery Monitor, Solar, and similar numeric-based widgets. Your provider plugin must be configured to capture the paths you want to plot. +Most widgets that use numeric paths support history, including Horizon, Battery Monitor, Solar, and similar numeric-based widgets. Your provider plugin must be configured to capture the paths you want to graph. ## Required Plugins and Signal K Version @@ -27,7 +27,7 @@ Currently, two plugins support History API v2: - **Purpose:** Records Signal K data to an InfluxDB v2 time-series database (requires InfluxDB v2). - **Link:** [signalk-to-influxdb2](https://www.npmjs.com/package/signalk-to-influxdb2) - **Setup:** Follow the plugin documentation for installation and configuration. -- **Path Configuration:** By default, records all paths. Configure filters, resolution, and related settings for the paths you want available in Skip plots. +- **Path Configuration:** By default, records all paths. Configure filters, resolution, and related settings for the paths you want available in Skip graphs. ### 2. signalk-parquet - **Purpose:** Records Signal K data to Parquet files for efficient storage and querying (no external database required). @@ -38,33 +38,33 @@ Currently, two plugins support History API v2: ## How History Data Works in Skip ### History Seeding -When you open a plot widget with a larger time scale (minutes/hours): +When you open a graph widget with a larger time scale (minutes/hours): 1. Skip checks whether history data is available through the History API. 2. If available and the time window allows it (resolution >= 1000 ms), Skip requests historical data points. -3. The plot displays the historical trend immediately. +3. The graph displays the historical trend immediately. ### Live Updates After history data loads: - New data points continue to arrive through Signal K's live WebSocket connection. -- The plot transitions smoothly from history to live updates. +- The graph transitions smoothly from history to live updates. - Old points are removed to maintain a rolling window based on the configured time scale. ### When History Is Not Available - If no History API plugin is installed, or the provider reports that a path is not recorded, history requests are skipped silently. -- The plot shows live data only, starting from when it was opened. +- The graph shows live data only, starting from when it was opened. - This is expected behavior and does not indicate an error. ## Provider Plugin Configuration Skip does not impose a fixed datapoint limit. For each request it derives a resolution (sample interval) from the time window being shown and asks the provider to return data at that resolution: -- The Realtime Data Plot targets roughly 500 points across its window (with a floor for very short windows), so finer sampling produces more detail rather than a capped number of points. -- The pop-up history plot dialog targets a lighter density (about 120 points) across its fixed windows. +- The Data Graph targets roughly 500 points across its window (with a floor for very short windows), so finer sampling produces more detail rather than a capped number of points. +- The pop-up history graph dialog targets a lighter density (about 120 points) across its fixed windows. -A plot can only show detail the provider recorded. When configuring provider sampling rates: -- If the provider stored data at a coarser interval than Skip requests, the plot shows that coarser detail (lower visual resolution), whatever the window. +A graph can only show detail the provider recorded. When configuring provider sampling rates: +- If the provider stored data at a coarser interval than Skip requests, the graph shows that coarser detail (lower visual resolution), whatever the window. - Recording at a finer interval than Skip requests is fine — Skip asks for the resolution it needs and the provider returns data at that resolution. -### Widget Historical Plots +### Widget Historical Graphs For the smallest fixed window (**last 15 minutes**), collect enough samples to provide useful resolution. A sampling interval around 7.5 seconds is ideal; 15 seconds is also usable. @@ -76,11 +76,11 @@ The Wind Trends widget uses two fixed Signal K paths: To display Wind Trends history, both paths **must be captured by your selected History API plugin**. -Choose a sampling rate that supports plot durations of 5 and 30 minutes. +Choose a sampling rate that supports graph durations of 5 and 30 minutes. ## Limitations -To seed plots with historical data, you must configure your provider to collect the required paths. This is not automatic. +To seed graphs with historical data, you must configure your provider to collect the required paths. This is not automatic. ## Troubleshooting @@ -95,16 +95,16 @@ To seed plots with historical data, you must configure your provider to collect **Check 3: Are paths configured in the plugin?** - Open plugin configuration. -- Confirm the paths you are plotting (for example, `navigation.speedThroughWater`) are included in the capture list. +- Confirm the paths you are graphing (for example, `navigation.speedThroughWater`) are included in the capture list. **Check 4: Is there historical data available?** - If the plugin was installed recently, data is only available from that point onward. -- Plots cannot display history for periods before the plugin was enabled or before the path was configured. +- Graphs cannot display history for periods before the plugin was enabled or before the path was configured. - Allow time for data to accumulate before expecting deeper history. -**Check 5: Is the plot time scale eligible?** +**Check 5: Is the graph time scale eligible?** - Very short time scales (seconds) skip history seeding for performance. -- Use plot time scales of **minutes or longer** for history seeding. +- Use graph time scales of **minutes or longer** for history seeding. **Check 6: Are there network or permission issues?** - Confirm Skip can reach the Signal K server History API endpoint. @@ -114,10 +114,10 @@ To seed plots with historical data, you must configure your provider to collect ## Next Steps 1. Verify that a History API plugin is installed on your Signal K server. -2. Configure the plugin to capture the paths you want to plot. -3. Wait for the plugin to collect enough data to fill your plot time span. -4. Open a Realtime Data Plot or Wind Trends widget with a time scale of minutes or longer. -5. Let the plot load; history appears when available. +2. Configure the plugin to capture the paths you want to graph. +3. Wait for the plugin to collect enough data to fill your graph time span. +4. Open a Data Graph or Wind Trends widget with a time scale of minutes or longer. +5. Let the graph load; history appears when available. 6. For more details, consult plugin documentation and Signal K community resources. ## Questions or Issues? diff --git a/src/assets/help-docs/time-series.md b/src/assets/help-docs/time-series.md index 9a3a7e5a..41d84e62 100644 --- a/src/assets/help-docs/time-series.md +++ b/src/assets/help-docs/time-series.md @@ -1,26 +1,26 @@ ## Historical Widget Data -Skip is primarily designed for live sailing data, but most numeric widgets can also show recent history — both as a pop-up history plot you open from a widget and as startup seeding for plot widgets, so they show recent trends immediately instead of starting empty. +Skip is primarily designed for live sailing data, but most numeric widgets can also show recent history — both as a pop-up history graph you open from a widget and as startup seeding for graph widgets, so they show recent trends immediately instead of starting empty. History is served by an external **Signal K History API provider** — a server plugin such as `signalk-to-influxdb2` or `signalk-parquet`. Skip does not record or store data itself; it reads history from whatever provider your Signal K server runs. When no provider is available, Skip shows live data only, starting from when a widget was opened. See [History-API Provider](#/help/history-api.md) in the Integrations help menu for how to install and configure a provider. ## What Skip Does With History -- Pre-seeds Realtime Data Plot and Wind Trends so they show recent trends immediately on open. +- Pre-seeds Data Graph and Wind Trends so they show recent trends immediately on open. - Provides a pop-up historical view for numeric-value widgets on your pages. The detail and time span available depend entirely on what your provider has recorded and how it is configured. -## Accessing the History Plot -On a **locked page** (normal viewing mode), **press and hold (long-press)** a numeric value widget to open its pop-up history plot directly — no edit mode, no menu. This is the only way to open it; interactive widgets keep single-tap for their own control, so long-press is the history gesture there too. +## Accessing the History Graph +On a **locked page** (normal viewing mode), **press and hold (long-press)** a numeric value widget to open its pop-up history graph directly — no edit mode, no menu. This is the only way to open it; interactive widgets keep single-tap for their own control, so long-press is the history gesture there too. -The pop-up plot displays recorded data only (no live-stream overlay), across a fixed set of time windows: the last 15 minutes, 1 hour, 8 hours, or 24 hours. For more flexible analytics, use a purpose-built platform such as Grafana. +The pop-up graph displays recorded data only (no live-stream overlay), across a fixed set of time windows: the last 15 minutes, 1 hour, 8 hours, or 24 hours. For more flexible analytics, use a purpose-built platform such as Grafana. ## Supported Widgets -Most widgets that use numeric paths support history, including Horizon, Battery Monitor, Solar, and similar numeric-based widgets. Plot widgets seed from history according to their configuration: +Most widgets that use numeric paths support history, including Horizon, Battery Monitor, Solar, and similar numeric-based widgets. Graph widgets seed from history according to their configuration: -#### Realtime Data Plot Widget +#### Data Graph Widget - **Supported:** Yes, seeded with history data. - **Requirements:** Time scale must be minutes or longer. @@ -28,9 +28,9 @@ Most widgets that use numeric paths support history, including Horizon, Battery - **Supported:** Yes, seeded with history data. - **Requirements:** Time span of `5 minutes` or `30 minutes`. -#### Numeric Widget's Mini Plot -- **Supported:** No. Mini plots use very short time windows (12 seconds) and skip history seeding. -- Mini plots start live-only for performance reasons. +#### Numeric Widget's Mini Graph +- **Supported:** No. Mini graphs use very short time windows (12 seconds) and skip history seeding. +- Mini graphs start live-only for performance reasons. ## Requirements - Signal K v2.22.1+: the history query service uses History API v2, introduced in Signal K v2.22.1. From f291ab0d52833090de15bb4d2cef7f93baf1619d Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 17:17:47 +0300 Subject: [PATCH 3/6] chore(graph): regenerate the MCP dashboard schema artifact Picks up the renamed component class names, the Data Graph widget name and description, the datagraphWidget icon id and the Graph Label default. No persisted config key changes. --- src/assets/skip-dashboard-schema.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/assets/skip-dashboard-schema.json b/src/assets/skip-dashboard-schema.json index 507eef2d..25318942 100644 --- a/src/assets/skip-dashboard-schema.json +++ b/src/assets/skip-dashboard-schema.json @@ -1073,14 +1073,14 @@ { "bindingKind": "datachart", "category": "Component", - "componentClassName": "WidgetDataChartComponent", + "componentClassName": "WidgetDataGraphComponent", "defaultConfig": { "color": "contrast", "datachartAngleRange": null, "datachartPath": null, "datachartSource": null, "datasetAverageArray": "sma", - "displayName": "Chart Label", + "displayName": "Graph Label", "enableMinMaxScaleLimit": false, "filterSelfPaths": true, "inverseYAxis": false, @@ -1102,11 +1102,11 @@ }, "defaultHeight": 6, "defaultWidth": 6, - "description": "Visualizes data on a real-time plot with multiple preconfigured series including actuals, SMA and period overall averages and Min/Max. Requires the Skip Dataset to be configured.", - "icon": "datachartWidget", + "description": "Graphs any numeric path over a configurable time window, with preconfigured series including actuals, SMA and period overall averages and Min/Max. Seeds from the Signal K History API when a provider is available.", + "icon": "datagraphWidget", "minHeight": 3, "minWidth": 2, - "name": "Realtime Data Plot", + "name": "Data Graph", "pathSlots": [], "requiredPlugins": [], "selector": "widget-data-chart" @@ -1751,7 +1751,7 @@ }, "defaultHeight": 6, "defaultWidth": 4, - "description": "Displays numeric data in a clear and concise format, with options to show minimum and/or maximum recorded values. Includes an optional background mini plot for quick visual trend insights.", + "description": "Displays numeric data in a clear and concise format, with options to show minimum and/or maximum recorded values. Includes an optional background mini graph for quick visual trend insights.", "icon": "numericWidget", "minHeight": 2, "minWidth": 1, @@ -3201,7 +3201,7 @@ { "bindingKind": "paths-record", "category": "Racing", - "componentClassName": "WidgetWindTrendsChartComponent", + "componentClassName": "WidgetWindTrendsGraphComponent", "defaultConfig": { "color": "contrast", "filterSelfPaths": true, @@ -3245,7 +3245,7 @@ }, "defaultHeight": 8, "defaultWidth": 10, - "description": "A real-time wind trends graph with dual axes for direction and speed. Displays live values and simple moving averages over the current period’s average.", + "description": "A live wind trends graph with dual axes for direction and speed. Displays live values and simple moving averages over the current period’s average.", "icon": "windtrendsWidget", "minHeight": 6, "minWidth": 8, From e1bc9a803d1b5c3017e2241685e7ede2fa30c737 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 17:44:53 +0300 Subject: [PATCH 4/6] refactor(graph): finish the rename in three strings the sweep missed The dialog.service remark still said "history chart dialog" (the comment sweep skipped the line because it also names chart.js), and the display-options spec still labelled its suite ChartOptionsComponent. Also drops the minigraph spec's cast into the private chart.js instance: lineChartData is the same object the chart is built from, so asserting on it is equivalent without reaching through a private field. --- src/app/core/services/dialog.service.ts | 2 +- .../graph-display-options.component.spec.ts | 2 +- src/app/widgets/minigraph/minigraph.component.spec.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/core/services/dialog.service.ts b/src/app/core/services/dialog.service.ts index fa377340..486a27c4 100644 --- a/src/app/core/services/dialog.service.ts +++ b/src/app/core/services/dialog.service.ts @@ -119,7 +119,7 @@ export class DialogService { * @param {IWidgetHistoryGraphDialogData} data Widget history dialog payload. * @returns {Promise>} Dialog reference. * - * @remarks The history chart dialog (and its chart.js dependency) is lazy-loaded so it stays out + * @remarks The history graph dialog (and its chart.js dependency) is lazy-loaded so it stays out * of the initial bundle and is only fetched the first time a user opens widget history. * * @example diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts index b40d19a4..081411f5 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts @@ -5,7 +5,7 @@ import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatRadioChange } from '@angular/material/radio'; import { GraphDisplayOptionsComponent } from './graph-display-options.component'; -describe('ChartOptionsComponent', () => { +describe('GraphDisplayOptionsComponent', () => { let component: GraphDisplayOptionsComponent; let fixture: ComponentFixture; diff --git a/src/app/widgets/minigraph/minigraph.component.spec.ts b/src/app/widgets/minigraph/minigraph.component.spec.ts index dbe5ad53..f84b2927 100644 --- a/src/app/widgets/minigraph/minigraph.component.spec.ts +++ b/src/app/widgets/minigraph/minigraph.component.spec.ts @@ -15,8 +15,8 @@ import type { IGraphDatapoint } from '../../core/interfaces/graph-data.interface const themeMock = new Proxy({}, { get: () => '#000000' }) as unknown as ITheme; -function chartValueData(component: MinigraphComponent): unknown[] { - return (component as unknown as { chart: { data: { datasets: { data: unknown[] }[] } } }).chart.data.datasets[0].data; +function graphValueData(component: MinigraphComponent): unknown[] { + return component.lineChartData.datasets[0].data; } describe('MinigraphComponent', () => { @@ -79,7 +79,7 @@ describe('MinigraphComponent', () => { component.startGraph(); expect(() => stream.next(HISTORY_UNAVAILABLE)).not.toThrow(); - expect(chartValueData(component).length).toBe(0); + expect(graphValueData(component).length).toBe(0); }); it('graphs a backfill batch and then a live point off the history stream', () => { @@ -95,6 +95,6 @@ describe('MinigraphComponent', () => { stream.next(batch); stream.next({ timestamp: 3000, data: { value: 3 } }); - expect(chartValueData(component).length).toBe(3); + expect(graphValueData(component).length).toBe(3); }); }); From 714b40eeb96999f58421f325683857d4c6d9a4bc Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 17:58:32 +0300 Subject: [PATCH 5/6] refactor(graph): state the selector invariant without narrating history A reader who never saw the diff cannot use "pre-rename spelling"; what they need is that the string is the saved widget type and that changing it costs a migration. --- src/app/core/services/widget.service.ts | 8 ++++---- .../widget-data-graph/widget-data-graph.component.ts | 4 ++-- .../widget-windtrends-graph.component.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/core/services/widget.service.ts b/src/app/core/services/widget.service.ts index e251bc15..4bda4a63 100644 --- a/src/app/core/services/widget.service.ts +++ b/src/app/core/services/widget.service.ts @@ -490,8 +490,8 @@ export class WidgetService { defaultHeight: 6, category: 'Component', requiredPlugins: [], - // Persisted widget `type` in stored dashboards; keeps the pre-rename spelling until a - // config migration renames it (#592). + // This string is the widget `type` in saved dashboards; changing it needs a config + // migration (#592). selector: 'widget-data-chart', componentClassName: 'WidgetDataGraphComponent' }, @@ -623,8 +623,8 @@ export class WidgetService { defaultHeight: 8, category: 'Racing', requiredPlugins: [], - // Persisted widget `type` in stored dashboards; keeps the pre-rename spelling until a - // config migration renames it (#592). + // This string is the widget `type` in saved dashboards; changing it needs a config + // migration (#592). selector: 'widget-windtrends-chart', componentClassName: 'WidgetWindTrendsGraphComponent' }, 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 fb7dad5b..c18fd090 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 @@ -58,8 +58,8 @@ const TIME_SCALE_SUFFIX: Partial> = { }; @Component({ - // The selector doubles as the persisted widget `type` in stored dashboards, so it keeps the - // pre-rename "chart" spelling until a config migration renames it (#592). + // This string is the widget `type` in saved dashboards, so changing it needs a config + // migration (#592). selector: 'widget-data-chart', templateUrl: './widget-data-graph.component.html', styleUrl: './widget-data-graph.component.scss', diff --git a/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.ts b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.ts index df7626b1..17d44e7e 100644 --- a/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.ts +++ b/src/app/widgets/widget-windtrends-graph/widget-windtrends-graph.component.ts @@ -38,8 +38,8 @@ interface IDataPointRow { } @Component({ - // The selector doubles as the persisted widget `type` in stored dashboards, so it keeps the - // pre-rename "chart" spelling until a config migration renames it (#592). + // This string is the widget `type` in saved dashboards, so changing it needs a config + // migration (#592). selector: 'widget-windtrends-chart', templateUrl: './widget-windtrends-graph.component.html', styleUrl: './widget-windtrends-graph.component.scss', From b0f0b316031533a7a15e40c9ba0e1ccd0f4fc7ae Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 17:58:32 +0300 Subject: [PATCH 6/6] docs: correct what happens when no history provider is installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the README and the History-API help file claimed graphs fall back to live data without a provider. They do not: getValues returns null, the stream emits HISTORY_UNAVAILABLE and completes without ever starting the live tail, so the widgets show "History data unavailable" and the dialog reports no data. Live-only is the behavior of the other case in that bullet — a provider that is installed but has not recorded the path — so the two are now separated. --- README.md | 2 +- src/assets/help-docs/history-api.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 12425d8e..e59c2ea6 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Grafana integration with other widgets ![Embedded Webpage Concept Image](./images/SkipGaugeSample3-1024x508.png) ## Historical Data -Skip graphs recent history for your numeric data by reading it from an external Signal K History API provider (such as `signalk-to-influxdb2` or `signalk-parquet`). Press and hold (long-press) a widget to open its history dialog, or use a Data Graph or Wind Trends widget to see recent trends. Skip does **not** record or store data itself — the detail and time span available depend on whatever provider your Signal K server runs, and graphs show live data only when no provider is present. See the [History-API Provider](src/assets/help-docs/history-api.md) help file for setup. +Skip graphs recent history for your numeric data by reading it from an external Signal K History API provider (such as `signalk-to-influxdb2` or `signalk-parquet`). Press and hold (long-press) a widget to open its history dialog, or use a Data Graph or Wind Trends widget to see recent trends. Skip does **not** record or store data itself — the detail and time span available depend on whatever provider your Signal K server runs, and without a provider both the history dialog and the graph widgets show an empty state. See the [History-API Provider](src/assets/help-docs/history-api.md) help file for setup. ## Night Modes Keep your night vision with automatic or manual day and night switching to a color preserving dim mode or an all Red theme. The images below look very dark, but at night... they are perfect! diff --git a/src/assets/help-docs/history-api.md b/src/assets/help-docs/history-api.md index 69cda960..d324f87d 100644 --- a/src/assets/help-docs/history-api.md +++ b/src/assets/help-docs/history-api.md @@ -50,9 +50,9 @@ After history data loads: - Old points are removed to maintain a rolling window based on the configured time scale. ### When History Is Not Available -- If no History API plugin is installed, or the provider reports that a path is not recorded, history requests are skipped silently. -- The graph shows live data only, starting from when it was opened. -- This is expected behavior and does not indicate an error. +- If no History API plugin is installed, the graph widgets show a "History data unavailable" state, and the pop-up history dialog reports that no historical data is available. They do not fall back to live data. +- If a provider is installed but reports that a path is not recorded, history seeding is skipped and the graph shows live data only, starting from when it was opened. +- Neither case indicates an error. ## Provider Plugin Configuration