From 458efb3bf0e2af410c05603ec1bf830604ba6c2e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:46:52 +0000 Subject: [PATCH] #630 - prune y-axis when topic deselected in multi-axis mode Rebuild per-topic y-axis configs in updateChart so the axis set always matches the current series. Previously the multi-y-axis effect only re-ran when the showMultipleYAxes toggle flipped, leaving stale axes when a topic was deselected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DbmqjZpN8tex2sPd1h9HTU --- .../graph-page/graph/graph.component.spec.ts | 64 +++++++++++++++++++ .../pages/graph-page/graph/graph.component.ts | 56 +++++++++------- 2 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 angular-client/src/pages/graph-page/graph/graph.component.spec.ts diff --git a/angular-client/src/pages/graph-page/graph/graph.component.spec.ts b/angular-client/src/pages/graph-page/graph/graph.component.spec.ts new file mode 100644 index 000000000..44f29b3b3 --- /dev/null +++ b/angular-client/src/pages/graph-page/graph/graph.component.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import CustomGraphComponent from './graph.component'; + +// Regression coverage for #630: with "Multiple Y-Axes" on, the y-axis set must always +// match the currently selected topics. applyMultiYAxisConfigs is the seam that rebuilds +// options.yaxis from the current data map, and updateChart now calls it every render so +// deselecting a topic prunes its axis. These tests drive that seam directly (the full +// component needs ApexCharts + a live DOM chart container, out of scope for a unit test). +describe('CustomGraphComponent — multi y-axis sync (#630)', () => { + function makeComponent(): CustomGraphComponent { + const c = TestBed.createComponent(CustomGraphComponent).componentInstance; + // applyMultiYAxisConfigs only reads `data` and writes `options.yaxis`. + (c as unknown as { options: { yaxis: unknown[] } }).options = { yaxis: [] }; + return c; + } + + function applyAxes(c: CustomGraphComponent): void { + (c as unknown as { applyMultiYAxisConfigs: () => void }).applyMultiYAxisConfigs(); + } + + it('builds one y-axis per topic in the current data map', () => { + const c = makeComponent(); + c.data = new Map([ + ['BMS/Pack/Voltage 0', []], + ['BMS/Pack/Current 0', []] + ]); + + applyAxes(c); + + expect(c.options.yaxis.length).toBe(2); + }); + + it('prunes a y-axis when its topic is deselected (removed from the data map)', () => { + const c = makeComponent(); + c.data = new Map([ + ['BMS/Pack/Voltage 0', []], + ['BMS/Pack/Current 0', []] + ]); + applyAxes(c); + expect(c.options.yaxis.length).toBe(2); + + // Deselecting a topic removes its series from the data map... + c.data.delete('BMS/Pack/Current 0'); + applyAxes(c); + + // ...so its y-axis must go too, leaving axes matched to the remaining series. + expect(c.options.yaxis.length).toBe(1); + expect((c.options.yaxis[0] as { title: { text: string } }).title.text).toBe('BMS/Pack/Voltage '); + }); + + it('alternates y-axis sides so stacked axes stay readable', () => { + const c = makeComponent(); + c.data = new Map([ + ['A 0', []], + ['B 0', []], + ['C 0', []] + ]); + + applyAxes(c); + + const sides = (c.options.yaxis as { opposite: boolean }[]).map((y) => y.opposite); + expect(sides).toEqual([false, true, false]); + }); +}); diff --git a/angular-client/src/pages/graph-page/graph/graph.component.ts b/angular-client/src/pages/graph-page/graph/graph.component.ts index d41d9fb44..ab39661b1 100644 --- a/angular-client/src/pages/graph-page/graph/graph.component.ts +++ b/angular-client/src/pages/graph-page/graph/graph.component.ts @@ -1,4 +1,4 @@ -import { Component, effect, input, OnDestroy, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Component, effect, input, OnDestroy, OnInit, ChangeDetectionStrategy, untracked } from '@angular/core'; import ApexCharts from 'apexcharts'; import { ApexXAxis, @@ -102,29 +102,10 @@ export default class CustomGraphComponent implements OnInit, OnDestroy { }); effect(() => { + // Re-run whenever the toggle flips. Per-topic axes are also rebuilt in updateChart + // (see applyMultiYAxisConfigs) so they stay in sync as topics are selected/deselected. if (this.showMultipleYAxes()) { - const yaxisConfigs: Partial[] = Array.from(this.data.keys()).map((key, index) => ({ - title: { - text: key.replace('0', ''), - style: { - color: 'grey', - fontSize: '20px', - fontWeight: 'bold' - } - }, - labels: { - style: { - colors: '#fff' - } - }, - opposite: index % 2 !== 0 // Alternate sides for each y-axis - })); - - // Update y-axis configurations - if (this.chart) { - this.options.yaxis = yaxisConfigs; - this.chart.updateOptions(this.options); - } + this.applyMultiYAxisConfigs(); } else { this.options.yaxis = [ { @@ -135,6 +116,8 @@ export default class CustomGraphComponent implements OnInit, OnDestroy { } } ]; + } + if (this.chart) { this.chart.updateOptions(this.options); } }); @@ -183,6 +166,13 @@ export default class CustomGraphComponent implements OnInit, OnDestroy { yaxis: index })); + // Keep the per-topic y-axes matched to the current series set: deselecting a topic + // drops its series here, so its y-axis must be pruned too (#630). untracked() so calling + // updateChart from an effect doesn't add showMultipleYAxes as a dependency. + if (untracked(this.showMultipleYAxes)) { + this.applyMultiYAxisConfigs(); + } + // Only constrain the x-axis range in real-time mode; historical mode should auto-fit all data let effectiveRange: number | undefined = undefined; if (this.realTime()) { @@ -215,6 +205,26 @@ export default class CustomGraphComponent implements OnInit, OnDestroy { ); }; + // Build one y-axis per current topic (order matches the series' `yaxis: index`). + private applyMultiYAxisConfigs(): void { + this.options.yaxis = Array.from(this.data.keys()).map((key, index) => ({ + title: { + text: key.replace('0', ''), + style: { + color: 'grey', + fontSize: '20px', + fontWeight: 'bold' + } + }, + labels: { + style: { + colors: '#fff' + } + }, + opposite: index % 2 !== 0 // Alternate sides for each y-axis + })); + } + private computeDataSpan(): { minX: number; maxX: number; spanMs: number } | null { let minX = Infinity; let maxX = -Infinity;