From 80ac62a1f6d382d8b19be7d2eb0471063e174314 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 23:12:52 +0300 Subject: [PATCH 1/3] fix(gauge-steel): apply subType, barGauge and decimals changes The library reads all three only while constructing a gauge, and ngOnChanges enumerated neither, so editing Gauge type, Digital Meter or the decimal places did nothing until an unrelated rebuild happened to pick them up. Fold them into the structural-rebuild condition, which now runs one rebuild per batch instead of up to three. Fixes #558 --- .../gauge-steel/gauge-steel.component.spec.ts | 127 ++++++++++++++++++ .../gauge-steel/gauge-steel.component.ts | 34 +++-- 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts index a8753a3a..20f0a16d 100644 --- a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts +++ b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts @@ -242,6 +242,133 @@ describe('GaugeSteelComponent', () => { expect(seeded).toEqual([1800]); }); + // #558: subType, barGauge and decimals are read only when the gauge object is constructed, so + // editing them in widget options produced no visible effect until some unrelated event -- a + // window resize, or the server's unit metadata landing -- happened to rebuild the gauge. + it('rebuilds as the other library type when subType changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Radial = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-subtype'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Radial).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('subType', 'linear'); + internals.ngOnChanges({ subType: new SimpleChange('radial', 'linear', false) }); + + expect(steel.Linear).toHaveBeenCalledTimes(1); + }); + + it('rebuilds as a bargraph when the Digital Meter setting is turned on', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + steel.LinearBargraph = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-bargauge'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('barGauge', false); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Linear).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('barGauge', true); + internals.ngOnChanges({ barGauge: new SimpleChange(false, true, false) }); + + expect(steel.LinearBargraph).toHaveBeenCalledTimes(1); + }); + + it('rebuilds with the new LCD precision when decimals changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-decimals'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('decimals', 2); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + gaugeOptions: { lcdDecimals?: number }; + }; + internals.startGauge(true); + expect(internals.gaugeOptions.lcdDecimals).toBe(2); + + fixture.componentRef.setInput('decimals', 0); + internals.ngOnChanges({ decimals: new SimpleChange(2, 0, false) }); + + expect(internals.gaugeOptions.lcdDecimals).toBe(0); + expect(steel.Linear).toHaveBeenCalledTimes(2); + }); + + // A rebuild constructs the replacement from buildOptions, which re-reads the title, background and + // frame inputs. Calling their setters as well would target whichever gauge object happened to exist + // at that point in the batch -- the one the rebuild discards -- so the batch carries them through + // the rebuild instead. + it('carries a title change batched with a rebuild into the replacement gauge', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + const titleSetter = vi.fn(); + steel.Linear = vi.fn(function (this: FakeGauge & { setTitleString: (t: string) => void }) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + this.setTitleString = titleSetter; + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-title-batch'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('title', 'Old'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + gaugeOptions: { titleString?: string }; + }; + internals.startGauge(true); + + fixture.componentRef.setInput('title', 'New'); + fixture.componentRef.setInput('maxValue', 200); + internals.ngOnChanges({ + title: new SimpleChange('Old', 'New', false), + maxValue: new SimpleChange(100, 200, false), + }); + + expect(internals.gaugeOptions.titleString).toBe('New'); + expect(titleSetter).not.toHaveBeenCalled(); + }); + it('still animates a value change that stands alone', () => { const animated: number[] = []; const steel = (globalThis as unknown as { steelseries: Record }).steelseries; diff --git a/src/app/widgets/gauge-steel/gauge-steel.component.ts b/src/app/widgets/gauge-steel/gauge-steel.component.ts index 373e99d3..340f68e3 100644 --- a/src/app/widgets/gauge-steel/gauge-steel.component.ts +++ b/src/app/widgets/gauge-steel/gauge-steel.component.ts @@ -304,13 +304,22 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { // with its own now-stale scale, sections and size — and wins the last frame. Under a steady // reading nothing repaints afterwards, leaving the old face on screen for good. The server's // measure resolving after the first value delivers exactly this batch on an ordinary boot. - const rebuilding = !!(changes.zones || changes.radialSize || changes.units || changes.minValue || changes.maxValue); - if (changes.value && !changes.value.firstChange && !rebuilding) { - this.gauge.setValueAnimated(changes.value.currentValue); - } - if (changes.zones) { + // Everything the library only reads while constructing a gauge. subType and barGauge pick the + // class itself; decimals reaches the LCD through buildOptions; zone sections are converted with + // `units` and clamped to [minValue, maxValue], so a scale move has to recompute the bands rather + // than the axis alone or they desync from the value. One startGauge(true) applies the whole batch + // in a single pass: buildOptions re-reads every input, so a second rebuild would repeat the work + // on inputs the first one already carried. A rebuild while units is still '' (boot) self-corrects + // when the first value sets it. + const rebuilding = !!(changes.zones || changes.radialSize || changes.units || changes.minValue + || changes.maxValue || changes.subType || changes.barGauge || changes.decimals); + if (rebuilding) { this.pendingStructuralRebuild = true; - this.startGauge(true); // sections require rebuild + this.startGauge(true); + return; + } + if (changes.value && !changes.value.firstChange) { + this.gauge.setValueAnimated(changes.value.currentValue); } if (changes.title) { this.gauge.setTitleString(changes.title.currentValue); @@ -321,19 +330,6 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { if(changes.frameColor) { this.gauge.setFrameDesign(SteelFrameColors[changes.frameColor.currentValue]); } - if (changes.radialSize){ - this.pendingStructuralRebuild = true; - this.startGauge(true); // radial geometry change - } - if (changes.units || changes.minValue || changes.maxValue) { - // Zone sections are converted with `units` and clamped to [minValue, maxValue]. When the - // server-resolved measure lands (units) or the reinterpreted scale bounds move (min/max), the - // sections must be rebuilt, not just the axis, or the bands desync from the scale and value. - // startGauge(true) applies the new min/max via buildOptions and recomputes the sections in one - // pass; a rebuild while units is still '' (boot) self-corrects when the first value sets it. - this.pendingStructuralRebuild = true; - this.startGauge(true); - } } ngOnDestroy(): void { From 1600ec4f0ad9a9c6b94d3e719680128e539b9054 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 00:16:39 +0300 Subject: [PATCH 2/3] test(gauge-steel): pin the one-rebuild claim and the live setters A reviewer mutation-tested the branch: reverting to a rebuild per changed key, and deleting all three live setters outright, each kept the suite green. Both are behaviours this change introduced, so both now have tests -- a batch of several structural changes asserting one construction, the setters on a standalone change, and the background and frame carried through a rebuild without their setters running. Fixes #558 --- .../gauge-steel/gauge-steel.component.spec.ts | 113 +++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts index 20f0a16d..79adf4b2 100644 --- a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts +++ b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SimpleChange } from '@angular/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { GaugeSteelComponent } from './gauge-steel.component'; +import { GaugeSteelComponent, SteelBackgroundColors, SteelFrameColors } from './gauge-steel.component'; import { UnitsService } from '../../core/services/units.service'; import { States } from '../../core/interfaces/signalk-interfaces'; @@ -369,6 +369,117 @@ describe('GaugeSteelComponent', () => { expect(titleSetter).not.toHaveBeenCalled(); }); + // The batch runs ONE rebuild: buildOptions re-reads every input, so a second would repeat the work + // on inputs the first already carried -- and the library cannot cancel the discarded gauge's tween, + // so it keeps repainting the canvas with its stale scale and wins the last frame. + it('rebuilds once for a batch carrying several structural changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Section = vi.fn((lower: number, upper: number, color: string) => ({ lower, upper, color })); + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-one-rebuild'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('units', 'V'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + fixture.componentRef.setInput('decimals', 2); + fixture.componentRef.setInput('zones', []); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Linear).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('maxValue', 200); + fixture.componentRef.setInput('decimals', 0); + fixture.componentRef.setInput('zones', [{ upper: 50, state: States.Alarm }]); + internals.ngOnChanges({ + maxValue: new SimpleChange(100, 200, false), + decimals: new SimpleChange(2, 0, false), + zones: new SimpleChange([], [{ upper: 50, state: States.Alarm }], false), + }); + + expect(steel.Linear).toHaveBeenCalledTimes(2); + }); + + // The setters run only on a batch with no structural change; buildOptions carries these inputs + // through a rebuild. Both halves need pinning -- deleting the setters is otherwise invisible. + it('applies a standalone title, background and frame change through the live setters', () => { + const calls: string[] = []; + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Radial = vi.fn(function (this: FakeGauge & Record) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + this.setTitleString = () => { calls.push('title'); }; + this.setBackgroundColor = () => { calls.push('background'); }; + this.setFrameDesign = () => { calls.push('frame'); }; + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-live-setters'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + + internals.ngOnChanges({ title: new SimpleChange('Old', 'New', false) }); + internals.ngOnChanges({ backgroundColor: new SimpleChange('carbon', 'white', false) }); + internals.ngOnChanges({ frameColor: new SimpleChange('anthracite', 'brass', false) }); + + expect(calls).toEqual(['title', 'background', 'frame']); + // No rebuild: none of these is structural. + expect(steel.Radial).toHaveBeenCalledTimes(1); + }); + + it('carries the background and frame through a rebuild, without calling their setters', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + const bgSetter = vi.fn(); + const frameSetter = vi.fn(); + steel.Linear = vi.fn(function (this: FakeGauge & Record) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + this.setBackgroundColor = bgSetter; + this.setFrameDesign = frameSetter; + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-carry-colors'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('backgroundColor', 'carbon'); + fixture.componentRef.setInput('frameColor', 'anthracite'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + gaugeOptions: { backgroundColor?: string; frameDesign?: string }; + }; + internals.startGauge(true); + + fixture.componentRef.setInput('backgroundColor', 'white'); + fixture.componentRef.setInput('frameColor', 'brass'); + fixture.componentRef.setInput('maxValue', 200); + internals.ngOnChanges({ + backgroundColor: new SimpleChange('carbon', 'white', false), + frameColor: new SimpleChange('anthracite', 'brass', false), + maxValue: new SimpleChange(100, 200, false), + }); + + expect(internals.gaugeOptions.backgroundColor).toBe(SteelBackgroundColors['white']); + expect(internals.gaugeOptions.frameDesign).toBe(SteelFrameColors['brass']); + expect(bgSetter).not.toHaveBeenCalled(); + expect(frameSetter).not.toHaveBeenCalled(); + }); + it('still animates a value change that stands alone', () => { const animated: number[] = []; const steel = (globalThis as unknown as { steelseries: Record }).steelseries; From 96e97f623b115e1fa6c6ecb36053c0515fcde82e Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 10:30:40 +0300 Subject: [PATCH 3/3] fix(gauge-steel): rebuild by default, and re-derive geometry on a type flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making subType a rebuild trigger exposed stale geometry. The radial writes `size`; the linear pair writes `width`/`height` and, absent those, falls back to the canvas element's dimensions — which the outgoing radial had set square. A radial-to-linear flip therefore drew the new face at the tile's shorter side, and no resize follows the flip to correct it. The rebuild list is now inverted to the inputs the library exposes a live setter for. A hand-maintained list of what needs a rebuild fails silent, which is the bug it was written to fix; this way a new input costs a needless rebuild at worst. It also picks up `theme`, whose zone-band colours were baked in at construction and kept the previous theme's palette until something else rebuilt. --- .../gauge-steel/gauge-steel.component.spec.ts | 65 +++++++++++++++++++ .../gauge-steel/gauge-steel.component.ts | 65 +++++++++++++------ 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts index 79adf4b2..c0b18be9 100644 --- a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts +++ b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts @@ -274,6 +274,71 @@ describe('GaugeSteelComponent', () => { expect(steel.Linear).toHaveBeenCalledTimes(1); }); + // The geometry keys are per-class: the radial reads `size`, the linear pair reads `width`/`height` + // and falls back to the canvas element's dimensions when they are missing — which the outgoing + // radial had set square. A leftover `size` therefore renders the linear gauge at the tile's + // shorter side, and no resize follows the flip to correct it. + it('re-derives the geometry for the new class when subType changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + const linearOptions: Record[] = []; + steel.Radial = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + steel.Linear = vi.fn(function (this: FakeGauge, _id: string, opts: Record) { + linearOptions.push({ ...opts }); + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-geometry'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + onResized: (e: ResizeObserverEntry) => void; + }; + internals.onResized({ contentRect: { width: 400, height: 150 } } as ResizeObserverEntry); + internals.startGauge(true); + + fixture.componentRef.setInput('subType', 'linear'); + internals.ngOnChanges({ subType: new SimpleChange('radial', 'linear', false) }); + + expect(linearOptions).toHaveLength(1); + expect(linearOptions[0]['width']).toBe(400); + expect(linearOptions[0]['height']).toBe(150); + expect(linearOptions[0]['size']).toBeUndefined(); + }); + + // Zone band colours are resolved from the theme inside buildOptions and baked into the Section + // objects at construction, so a day/night switch has to rebuild or the bands keep the old colours. + it('rebuilds when the theme changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Radial = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-theme'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Radial).toHaveBeenCalledTimes(1); + + internals.ngOnChanges({ theme: new SimpleChange(null, {}, false) }); + + expect(steel.Radial).toHaveBeenCalledTimes(2); + }); + it('rebuilds as a bargraph when the Digital Meter setting is turned on', () => { const steel = (globalThis as unknown as { steelseries: Record }).steelseries; steel.Linear = vi.fn(function (this: FakeGauge) { diff --git a/src/app/widgets/gauge-steel/gauge-steel.component.ts b/src/app/widgets/gauge-steel/gauge-steel.component.ts index 340f68e3..50c9111d 100644 --- a/src/app/widgets/gauge-steel/gauge-steel.component.ts +++ b/src/app/widgets/gauge-steel/gauge-steel.component.ts @@ -60,6 +60,9 @@ export const SteelFrameColors = { 'glossyMetal': steelseries.FrameDesign.GLOSSY_METAL } +/** Inputs the steelseries gauge exposes a live setter for; every other input needs a rebuild. */ +const LIVE_APPLIED_INPUTS = ['value', 'title', 'backgroundColor', 'frameColor']; + @Component({ selector: 'gauge-steel', templateUrl: './gauge-steel.component.html', @@ -97,6 +100,7 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { private lastSizeSignature = ''; private resizeTimer: number | null = null; private pendingStructuralRebuild = false; + private lastRect: { width: number; height: number } | null = null; ngOnInit(): void { this.buildOptions(); @@ -273,20 +277,35 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { } } - onResized(event: ResizeObserverEntry):void { - if (event.contentRect.height < 50 || event.contentRect.width < 50) return; - let signature: string; + /** + * Write the geometry keys the current gauge class reads, and drop the other class's. The radial + * takes `size`; the linear pair takes `width`/`height` and ignores `size`, falling back to the + * canvas element's current dimensions when they are absent — which the outgoing radial had set + * square. Leaving a stale key behind therefore builds a linear gauge at the radial's side length + * in a tile that is not square, and no resize follows to correct it. + */ + private applyGeometry(): string { + const rect = this.lastRect; + if (!rect) return this.lastSizeSignature; if (this.subType() === 'radial') { - const size = Math.floor(Math.min(event.contentRect.height, event.contentRect.width)); + const size = Math.floor(Math.min(rect.height, rect.width)); + delete this.gaugeOptions['width']; + delete this.gaugeOptions['height']; this.gaugeOptions['size'] = size; - signature = 'radial:' + size; - } else { - const w = Math.floor(event.contentRect.width); - const h = Math.floor(event.contentRect.height); - this.gaugeOptions['width'] = w; - this.gaugeOptions['height'] = h; - signature = `linear:${w}x${h}`; + return 'radial:' + size; } + const w = Math.floor(rect.width); + const h = Math.floor(rect.height); + delete this.gaugeOptions['size']; + this.gaugeOptions['width'] = w; + this.gaugeOptions['height'] = h; + return `linear:${w}x${h}`; + } + + onResized(event: ResizeObserverEntry):void { + if (event.contentRect.height < 50 || event.contentRect.width < 50) return; + this.lastRect = { width: event.contentRect.width, height: event.contentRect.height }; + const signature = this.applyGeometry(); if (signature === this.lastSizeSignature) return; // no meaningful change this.lastSizeSignature = signature; if (this.resizeTimer) window.clearTimeout(this.resizeTimer); @@ -304,16 +323,22 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { // with its own now-stale scale, sections and size — and wins the last frame. Under a steady // reading nothing repaints afterwards, leaving the old face on screen for good. The server's // measure resolving after the first value delivers exactly this batch on an ordinary boot. - // Everything the library only reads while constructing a gauge. subType and barGauge pick the - // class itself; decimals reaches the LCD through buildOptions; zone sections are converted with - // `units` and clamped to [minValue, maxValue], so a scale move has to recompute the bands rather - // than the axis alone or they desync from the value. One startGauge(true) applies the whole batch - // in a single pass: buildOptions re-reads every input, so a second rebuild would repeat the work - // on inputs the first one already carried. A rebuild while units is still '' (boot) self-corrects - // when the first value sets it. - const rebuilding = !!(changes.zones || changes.radialSize || changes.units || changes.minValue - || changes.maxValue || changes.subType || changes.barGauge || changes.decimals); + // Listed by what the library CAN apply to a live gauge, so anything else rebuilds by default. + // Inverted deliberately: an input added later costs a needless rebuild at worst, where a missing + // entry in a rebuild list does nothing at all and shows nothing on screen — which is #558. + // subType and barGauge pick the gauge class itself; decimals is captured into a closure at + // construction; zone sections are converted with `units` and clamped to [minValue, maxValue], so + // a scale move has to recompute the bands rather than the axis alone or they desync from the + // value; `theme` supplies the section colours. One startGauge(true) applies the whole batch in a + // single pass, since buildOptions re-reads every input. A rebuild while units is still '' (boot) + // self-corrects when the first value sets it. + const rebuilding = Object.keys(changes).some(key => !LIVE_APPLIED_INPUTS.includes(key)); if (rebuilding) { + // The geometry keys are per-class, so a subType flip has to re-derive them from the last + // observed rect; no resize follows the flip to do it. + if (changes.subType) { + this.lastSizeSignature = this.applyGeometry(); + } this.pendingStructuralRebuild = true; this.startGauge(true); return;