Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/core/display.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,31 @@ uint16_t getColorVariation(uint16_t color, int delta, int direction) {
return compl_color;
}

uint16_t blendColors(uint16_t a, uint16_t b, uint8_t t) {
int ar = (a >> 11) & 0x1f, ag = (a >> 5) & 0x3f, ab = a & 0x1f;
int br = (b >> 11) & 0x1f, bg = (b >> 5) & 0x3f, bb = b & 0x1f;
int r = ar + (br - ar) * t / 255;
int g = ag + (bg - ag) * t / 255;
int bl = ab + (bb - ab) * t / 255;
return (uint16_t)((r << 11) | (g << 5) | bl);
}

void buildHeatPalette(uint16_t *lut, uint8_t n) {
if (!lut || n < 2) return;
uint16_t bg = bruceConfig.bgColor;
uint16_t pri = bruceConfig.priColor;
uint16_t hot = blendColors(pri, TFT_WHITE, 150);

lut[0] = bg;
for (uint8_t i = 1; i < n; i++) {
int t = i * 255 / (n - 1);
// ramp to the primary for the lower two thirds, then burn toward the
// highlight so strong signals stay readable against a busy plot
lut[i] = (t < 170) ? blendColors(bg, pri, 55 + t * 200 / 255)
: blendColors(pri, hot, (t - 170) * 255 / 85);
}
}

// Draw BITMAP files
// These read 16- and 32-bit types from the SD card file.
// BMP data is stored little-endian, Arduino is little-endian too.
Expand Down
6 changes: 6 additions & 0 deletions src/core/display.h
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ bool showJpeg(const uint8_t *data_array, size_t data_size, int x, int y, bool ce
uint16_t getComplementaryColor(uint16_t color);
uint16_t getComplementaryColor2(uint16_t color);
uint16_t getColorVariation(uint16_t color, int delta = 10, int direction = 0);
// Linear RGB565 mix: t = 0 returns a, t = 255 returns b. Use it to derive dim
// or highlighted shades from the theme instead of hardcoding TFT_* constants.
uint16_t blendColors(uint16_t a, uint16_t b, uint8_t t);
// Fills lut[0..n-1] with a theme ramp going from the background up to a
// brightened primary, for waterfalls and other intensity plots.
void buildHeatPalette(uint16_t *lut, uint8_t n);

void resetTftDisplay(
int x = 0, int y = 0, uint16_t fc = bruceConfig.priColor, int size = FM,
Expand Down
207 changes: 207 additions & 0 deletions src/core/spectrum_plot.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
#include "spectrum_plot.h"

#include "core/display.h"
#include <globals.h>

// Waterfall intensity ramp, quantised so identical columns collapse into runs.
static const int SP_HEAT_N = 16;
static uint16_t sp_heat[SP_HEAT_N];

static uint32_t sp_rnd_state = 0x2a3b4c5d;

static inline uint32_t sp_rnd() {
sp_rnd_state ^= sp_rnd_state << 13;
sp_rnd_state ^= sp_rnd_state >> 17;
sp_rnd_state ^= sp_rnd_state << 5;
return sp_rnd_state;
}

uint16_t SpectrumPlot::alertColor() {
uint16_t pri = bruceConfig.priColor;
int r = (pri >> 11) & 0x1f, g = (pri >> 5) & 0x3f, b = pri & 0x1f;
// A red alert would vanish on a red theme, so fall back to amber there.
bool reddish = (r > 18 && (g >> 1) < 12 && b < 12);
return reddish ? TFT_ORANGE : TFT_RED;
}

void SpectrumPlot::buildGeometry() {
_plotL = 8;
_plotW = tftWidth - 16;
if (_plotW < 32) {
_plotL = 2;
_plotW = tftWidth - 4;
}

int top = BORDER_PAD_Y + 8 * FM + 2; // just below the title
_footY = tftHeight - 8 * FP - 8;
_lblY = _footY - 8 * FP - 2;

int avail = _lblY - top - 2;
if (avail < 20) { // no room for the ruler
_lblY = -1;
avail = _footY - top - 2;
}
if (avail < 14) { // no room for the status line either
_footY = -1;
avail = tftHeight - 6 - top;
}
if (avail < 8) avail = 8;

_wfRows = 0;
if (avail >= 36) {
_wfRows = avail / 3;
if (_wfRows > 24) _wfRows = 24;
}
_specTop = top;
_specH = avail - _wfRows - (_wfRows ? 2 : 0);
_specBot = _specTop + _specH - 1;
_wfTop = _specBot + 3;
}

void SpectrumPlot::buildPalette() {
uint16_t pri = bruceConfig.priColor;
_bg = bruceConfig.bgColor;
_trace = pri;
_body = blendColors(_bg, pri, 95);
_bodyHl = blendColors(_bg, pri, 165);
_peak = blendColors(pri, TFT_WHITE, 150);
_grid = blendColors(_bg, pri, 55);
_label = blendColors(_bg, pri, 170);
buildHeatPalette(sp_heat, SP_HEAT_N);
}

bool SpectrumPlot::begin(const String &title) {
buildGeometry();
buildPalette();

if (_plotW < 8) return false;

if (_wfRows) {
_wf = (uint8_t *)calloc((size_t)_wfRows * _plotW, 1);
if (!_wf) _wfRows = 0; // degrade to a plot without history rather than fail
}
_wfHead = 0;
_ok = true;

drawMainBorderWithTitle(title); // clears the screen itself

drawWaterfall();
return true;
}

void SpectrumPlot::end() {
free(_wf);
_wf = nullptr;
_wfRows = 0;
_ok = false;
}

void SpectrumPlot::trace(const uint8_t *env, const uint8_t *envPeak, int hlL, int hlR, bool alert) {
if (!_ok || !env) return;

uint16_t trace = alert ? alertColor() : _trace;
uint16_t bodyHl = alert ? blendColors(_bg, trace, 165) : _bodyHl;

int gy[3];
gy[0] = _specBot - _specH / 4;
gy[1] = _specBot - _specH / 2;
gy[2] = _specBot - (_specH * 3) / 4;

// Every pixel of the band is written exactly once per frame, which keeps the
// animation flicker free without needing a full-screen sprite.
for (int i = 0; i < _plotW; i++) {
int x = _plotL + i;

int hLive = (int)env[i] * (_specH - 1) / 100;
int grass = (int)(sp_rnd() % 3); // animated noise floor
if (hLive < grass) hLive = grass;
int hPeak = envPeak ? (int)envPeak[i] * (_specH - 1) / 100 : 0;
if (hPeak < hLive) hPeak = hLive;

int yLive = _specBot - hLive;
int yPeak = _specBot - hPeak;

if (yPeak > _specTop) tft.drawFastVLine(x, _specTop, yPeak - _specTop, _bg);
if (hPeak > hLive) {
tft.drawPixel(x, yPeak, _peak);
if (yLive > yPeak + 1) tft.drawFastVLine(x, yPeak + 1, yLive - yPeak - 1, _bg);
}
tft.drawPixel(x, yLive, trace);
if (hLive > 0)
tft.drawFastVLine(x, yLive + 1, hLive, (i >= hlL && i <= hlR) ? bodyHl : _body);

// dashed reference grid, visible only through the empty sky
if ((i & 3) == 0) {
for (int k = 0; k < 3; k++)
if (gy[k] > _specTop && gy[k] < yLive - 1) tft.drawPixel(x, gy[k], _grid);
}
}
}

// Newest row sits right under the trace baseline and older ones fall away.
void SpectrumPlot::drawWaterfall() {
if (!_wfRows || !_wf) return;

for (int r = 0; r < _wfRows; r++) {
int idx = (_wfHead - r + 2 * _wfRows) % _wfRows;
const uint8_t *row = _wf + (size_t)idx * _plotW;
int y = _wfTop + r;

// flush equal-coloured columns as single spans, the rows are wide
int runStart = 0;
uint16_t runCol = sp_heat[row[0] * (SP_HEAT_N - 1) / 100];
for (int i = 1; i <= _plotW; i++) {
bool last = (i == _plotW);
uint16_t c = last ? runCol : sp_heat[row[i] * (SP_HEAT_N - 1) / 100];
if (last || c != runCol) {
tft.drawFastHLine(_plotL + runStart, y, i - runStart, runCol);
runStart = i;
runCol = c;
}
}
}
}

void SpectrumPlot::pushRow(const uint8_t *env) {
if (!_ok || !_wfRows || !_wf || !env) return;
_wfHead = (_wfHead + 1) % _wfRows;
memcpy(_wf + (size_t)_wfHead * _plotW, env, _plotW);
drawWaterfall();
}

void SpectrumPlot::ruler(const int *cols, const String *labels, int count, int highlight) {
if (!_ok || _lblY < 0 || !cols || !labels) return;

int h = 8 * FP;
tft.fillRect(_plotL, _lblY - 1, _plotW, h + 2, _bg);
tft.setTextSize(FP);

for (int i = 0; i < count; i++) {
int w = labels[i].length() * FP * LW;
int tx = _plotL + cols[i] - w / 2;
// keep edge labels inside the plot
if (tx < _plotL) tx = _plotL;
if (tx + w > _plotL + _plotW) tx = _plotL + _plotW - w;

if (i == highlight) {
tft.fillRect(tx - 2, _lblY - 1, w + 4, h + 2, bruceConfig.priColor);
tft.setTextColor(_bg, bruceConfig.priColor);
} else {
tft.setTextColor(_label, _bg);
}
tft.drawString(labels[i], tx, _lblY, 1);
}
}

void SpectrumPlot::status(const String &text, bool alert) {
if (!_ok || _footY < 0) return;

tft.fillRect(_plotL, _footY, _plotW, 8 * FP, _bg);
tft.setTextSize(FP);
tft.setTextColor(alert ? alertColor() : _label, _bg);

String s = text;
int maxChars = _plotW / (FP * LW);
if ((int)s.length() > maxChars) s = s.substring(0, maxChars);
tft.drawString(s, _plotL, _footY, 1);
}
72 changes: 72 additions & 0 deletions src/core/spectrum_plot.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#pragma once

#include <Arduino.h>

// Shared spectrum-analyzer plot.
//
// Owns the layout, palette and painting for every "signal strength across a
// frequency band" screen in Bruce, so Channel Analyzer, Jam Detect and the NRF
// sweeper share one look instead of each inventing its own bars and colours.
//
// The band itself is caller supplied: modules fill an envelope of width()
// values in 0-100 and the plot turns it into a filled trace with a peak-hold
// line, an animated noise floor, a dashed reference grid and a scrolling
// waterfall. Every colour is derived from the active theme.
//
// Typical use:
// SpectrumPlot plot;
// if (!plot.begin("My Scanner")) return; // frees itself on failure
// ...
// plot.trace(env, envPeak, hlLeft, hlRight); // once per animation frame
// plot.pushRow(env); // once per completed sweep
// plot.ruler(cols, labels, n, current);
// plot.status("...");
// plot.end();
class SpectrumPlot {
public:
bool begin(const String &title);
void end();
bool ready() const { return _ok; }

// Number of columns the caller must fill, and where they land on screen.
int width() const { return _plotW; }
int left() const { return _plotL; }

// Paints one frame of the live band. `env` and `envPeak` hold width()
// values in 0-100; envPeak may be null. Columns in [hlL, hlR] are filled
// with the highlight shade to mark the slice being measured — pass
// hlL > hlR for none. `alert` recolours the trace to flag a bad reading.
void trace(const uint8_t *env, const uint8_t *envPeak, int hlL, int hlR, bool alert = false);

// Appends `env` to the waterfall history and repaints it. No-op when the
// screen is too short for a waterfall.
void pushRow(const uint8_t *env);

// Labelled ticks under the plot. `cols` are column indices in 0..width()-1;
// `highlight` indexes the entry to draw inverted, or -1 for none.
void ruler(const int *cols, const String *labels, int count, int highlight = -1);

// Single line of text at the bottom, truncated to fit.
void status(const String &text, bool alert = false);

// Colour for out-of-range readings, kept visible even on a reddish theme.
static uint16_t alertColor();

private:
void buildGeometry();
void buildPalette();
void drawWaterfall();

bool _ok = false;

int _plotL = 0, _plotW = 0;
int _specTop = 0, _specBot = 0, _specH = 0;
int _wfTop = 0, _wfRows = 0;
int _lblY = -1; // -1 when the screen is too short for the ruler
int _footY = -1; // -1 when the screen is too short for the status line

uint16_t _bg = 0, _body = 0, _bodyHl = 0, _trace = 0, _peak = 0, _grid = 0, _label = 0;

uint8_t *_wf = nullptr; // _wfRows x _plotW ring of rendered envelopes
int _wfHead = 0;
};
Loading
Loading