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';