From ec873ab8bd36d59fcead9ce608f06c0c7b8cd5ff Mon Sep 17 00:00:00 2001
From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com>
Date: Sun, 23 Aug 2026 13:35:22 -0700
Subject: [PATCH] fix(wireless): keep the 2.4 GHz channel map inside its card
(v0.23.13-beta)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 2.4 GHz row of the Channel Usage panel painted over the edge of the card.
The cause is the band table, not the layout. A 40 MHz channel 1 occupies
2392-2432 MHz, but the drawn 2.4 GHz band started at 2400, so that carrier
resolved to `left: -8%` and an absolutely positioned bar at a negative offset
renders outside its container. 2.4 GHz is the only band where this can happen:
channel 1 is the lowest channel in the plan and the only one whose 40 MHz form
reaches below the round number the axis started on.
The band now starts at 2390, which is also more honest — the radio really does
reach below 2400, and clamping alone would have drawn it as though it did not.
Geometry moves to utils/spectrumGeometry.ts with tests, and keeps a clamp even
though the range now covers channel 1: band ranges are static assumptions, and
region-specific channels and widths they do not anticipate will keep appearing.
No bar can leave the card again regardless of what the backend reports.
An earlier attempt at this fixed a real but different bug in the same row: ticks
at 2400 and 2480 fall outside the channel plan, so an axis labelled by channel
fell back to printing raw frequencies, and the 2400 label sat at exactly 0% with
a centring transform and hung half its width outside the card. That fix stands;
it simply was not the whole of what the user was seeing. The verification script
used at the time filtered radios to those starting inside their band, which
silently discarded the very row that was overflowing.
Co-Authored-By: Claude Opus 5
---
README.md | 2 +-
backend/package.json | 2 +-
.../src/utils/__tests__/rfSpectrum.test.ts | 3 +-
backend/src/utils/rfSpectrum.ts | 12 +++-
frontend/package.json | 2 +-
frontend/src/components/wireless/RfHealth.tsx | 14 +++--
frontend/src/utils/spectrumGeometry.test.ts | 59 +++++++++++++++++++
frontend/src/utils/spectrumGeometry.ts | 42 +++++++++++++
frontend/src/version.ts | 2 +-
9 files changed, 127 insertions(+), 11 deletions(-)
create mode 100644 frontend/src/utils/spectrumGeometry.test.ts
create mode 100644 frontend/src/utils/spectrumGeometry.ts
diff --git a/README.md b/README.md
index b08ecc5..c9dd690 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
diff --git a/backend/package.json b/backend/package.json
index 93bf77b..2066a79 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.12-beta",
+ "version": "0.23.13-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/utils/__tests__/rfSpectrum.test.ts b/backend/src/utils/__tests__/rfSpectrum.test.ts
index f0651d3..a0d4439 100644
--- a/backend/src/utils/__tests__/rfSpectrum.test.ts
+++ b/backend/src/utils/__tests__/rfSpectrum.test.ts
@@ -187,7 +187,8 @@ describe('freeSpans', () => {
});
it('returns the whole band when nothing is deployed', () => {
- expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2400, endMhz: 2500 }]);
+ // The band starts below 2400 so a 40 MHz channel 1 (2392–2432) fits inside it.
+ expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2390, endMhz: 2500 }]);
});
});
diff --git a/backend/src/utils/rfSpectrum.ts b/backend/src/utils/rfSpectrum.ts
index 7d7dd45..0daffcd 100644
--- a/backend/src/utils/rfSpectrum.ts
+++ b/backend/src/utils/rfSpectrum.ts
@@ -31,9 +31,17 @@ export interface BandRange {
endMhz: number;
}
-/** Spectrum drawn for each band. Wide enough to contain every legal channel. */
+/**
+ * Spectrum drawn for each band. Wide enough to contain every legal channel —
+ * including the ones that spill past the round numbers.
+ *
+ * 2.4 GHz starts at 2390 rather than 2400 because a 40 MHz channel centred on
+ * channel 1 occupies 2392–2432. Starting the axis at 2400 puts that carrier at a
+ * negative offset, which is not merely a cosmetic problem: it renders outside the
+ * chart and misstates how far the radio actually reaches below the band.
+ */
export const BAND_RANGES: BandRange[] = [
- { band: '2.4', label: '2.4 GHz', startMhz: 2400, endMhz: 2500 },
+ { band: '2.4', label: '2.4 GHz', startMhz: 2390, endMhz: 2500 },
{ band: '5', label: '5 GHz', startMhz: 5150, endMhz: 5895 },
{ band: '6', label: '6 GHz', startMhz: 5925, endMhz: 7125 },
];
diff --git a/frontend/package.json b/frontend/package.json
index f03d72e..9b8aba8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.12-beta",
+ "version": "0.23.13-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx
index 7da2c84..38fac29 100644
--- a/frontend/src/components/wireless/RfHealth.tsx
+++ b/frontend/src/components/wireless/RfHealth.tsx
@@ -12,6 +12,7 @@ import {
BAND_LABEL, RfBand,
RSSI_ZONES, rssiColor, RETRY_BUCKETS, retryBucketIndex, retryColor,
} from '../../utils/wifiChannels';
+import { barGeometry } from '../../utils/spectrumGeometry';
const ALL_BANDS: RfBand[] = ['2.4', '5', '6'];
@@ -60,7 +61,11 @@ function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChanne
- {lanes.map((lane, li) => lane.map((r) => (
+ {lanes.map((lane, li) => lane.map((r) => {
+ const { leftPct, widthPct } = barGeometry(
+ r.low_mhz, r.high_mhz, band.startMhz, band.endMhz,
+ );
+ return (
- )))}
+ );
+ }))}
diff --git a/frontend/src/utils/spectrumGeometry.test.ts b/frontend/src/utils/spectrumGeometry.test.ts
new file mode 100644
index 0000000..4f5aa67
--- /dev/null
+++ b/frontend/src/utils/spectrumGeometry.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect } from 'vitest';
+import { barGeometry } from './spectrumGeometry';
+
+// The 2.4 GHz band as drawn, wide enough to contain a 40 MHz channel 1.
+const B24 = [2390, 2500] as const;
+
+describe('barGeometry', () => {
+ it('places an ordinary carrier proportionally', () => {
+ // Channel 6 at 20 MHz: 2427–2447 within 2390–2500.
+ const g = barGeometry(2427, 2447, ...B24);
+ expect(g.leftPct).toBeCloseTo(33.6, 1);
+ expect(g.widthPct).toBeCloseTo(18.2, 1);
+ });
+
+ it('keeps a 40 MHz channel 1 inside the band', () => {
+ // The regression: 2392 sits below a 2400 start and produced left: -8%,
+ // which painted the bar outside the card.
+ const g = barGeometry(2392, 2432, ...B24);
+ expect(g.leftPct).toBeGreaterThanOrEqual(0);
+ expect(g.leftPct).toBeCloseTo(1.8, 1);
+ expect(g.leftPct + g.widthPct).toBeLessThanOrEqual(100);
+ });
+
+ it('never returns a negative offset, whatever the band', () => {
+ // Even against the old, too-narrow band the bar stays inside the card.
+ const g = barGeometry(2392, 2432, 2400, 2500);
+ expect(g.leftPct).toBe(0);
+ expect(g.widthPct).toBeCloseTo(32, 1);
+ });
+
+ it('never overhangs the right edge', () => {
+ const g = barGeometry(2480, 2540, ...B24);
+ expect(g.leftPct + g.widthPct).toBeLessThanOrEqual(100);
+ });
+
+ it('keeps a hairline carrier visible', () => {
+ const g = barGeometry(2410, 2410, ...B24);
+ expect(g.widthPct).toBeGreaterThan(0);
+ });
+
+ it('keeps a carrier at the extreme right edge on the card', () => {
+ const g = barGeometry(2500, 2500, ...B24);
+ expect(g.leftPct + g.widthPct).toBeLessThanOrEqual(100);
+ expect(g.leftPct).toBeGreaterThanOrEqual(0);
+ });
+
+ it('survives a degenerate band range', () => {
+ const g = barGeometry(2400, 2420, 2400, 2400);
+ expect(g.leftPct).toBe(0);
+ expect(g.widthPct).toBeGreaterThan(0);
+ });
+
+ it('handles a 160 MHz 5 GHz carrier', () => {
+ // wifi2 on channel 100: 5420–5580 within 5150–5895.
+ const g = barGeometry(5420, 5580, 5150, 5895);
+ expect(g.leftPct).toBeCloseTo(36.2, 1);
+ expect(g.widthPct).toBeCloseTo(21.5, 1);
+ });
+});
diff --git a/frontend/src/utils/spectrumGeometry.ts b/frontend/src/utils/spectrumGeometry.ts
new file mode 100644
index 0000000..3712b32
--- /dev/null
+++ b/frontend/src/utils/spectrumGeometry.ts
@@ -0,0 +1,42 @@
+/**
+ * Placement of a radio's carrier within a drawn spectrum band.
+ *
+ * Split out of the channel map because the arithmetic had a bug that was
+ * invisible in the component: a 40 MHz channel 1 occupies 2392–2432 MHz, and
+ * while the 2.4 GHz axis started at 2400 that carrier resolved to a *negative*
+ * offset. An absolutely positioned bar at `left: -8%` renders outside its
+ * container, so the 2.4 GHz row painted over the edge of the card.
+ *
+ * The band table has since been widened to contain channel 1 honestly, but the
+ * clamp stays: band ranges are static assumptions, and region-specific channels
+ * and widths they do not anticipate will keep turning up.
+ */
+export interface BarGeometry {
+ /** Offset from the left of the band, as a percentage. Never negative. */
+ leftPct: number;
+ /** Width as a percentage. Never extends past the right edge. */
+ widthPct: number;
+}
+
+/** Narrowest bar that stays visible; a hairline carrier must not vanish. */
+const MIN_WIDTH_PCT = 0.6;
+
+export function barGeometry(
+ lowMhz: number,
+ highMhz: number,
+ bandStartMhz: number,
+ bandEndMhz: number,
+): BarGeometry {
+ const span = bandEndMhz - bandStartMhz;
+ if (!(span > 0)) return { leftPct: 0, widthPct: MIN_WIDTH_PCT };
+
+ const clamp = (mhz: number) =>
+ Math.max(0, Math.min(100, ((mhz - bandStartMhz) / span) * 100));
+
+ const start = clamp(lowMhz);
+ const end = clamp(highMhz);
+ const widthPct = Math.max(MIN_WIDTH_PCT, end - start);
+
+ // Pull a bar that would overhang back inside rather than letting it bleed.
+ return { leftPct: Math.max(0, Math.min(start, 100 - widthPct)), widthPct };
+}
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index 93bc92a..67575a1 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = 'v0.23.12 Beta';
+export const APP_VERSION = 'v0.23.13 Beta';