diff --git a/HeatMap.cs b/HeatMap.cs new file mode 100644 index 0000000..6be82e7 --- /dev/null +++ b/HeatMap.cs @@ -0,0 +1,109 @@ +using System.Drawing.Drawing2D; + +namespace Substation; + +public class HeatMap : Panel +{ + record Tick(byte[] Freq, byte[] Intensity); + + readonly List _history = new(); + readonly int _maxTicks; + + public HeatMap(int maxTicks = 60) + { + _maxTicks = maxTicks; + DoubleBuffered = true; + BackColor = Color.FromArgb(20, 20, 22); + } + + public void AddTick(byte[] freq, byte[] intensity, bool active) + { + if (_history.Count >= _maxTicks) + _history.RemoveAt(0); + + if (active) + _history.Add(new Tick(freq, intensity)); + else + _history.Add(new Tick(Array.Empty(), Array.Empty())); + + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var g = e.Graphics; + g.SmoothingMode = SmoothingMode.None; + + int w = Width; + int h = Height; + int colW = Math.Max(1, w / _maxTicks); + + // Draw axis labels + using var font = new Font(FontFamily.GenericMonospace, 7); + using var textBrush = new SolidBrush(Color.FromArgb(100, 100, 100)); + g.DrawString("100Hz", font, textBrush, 2, 2); + g.DrawString("1Hz", font, textBrush, 2, h - 14); + + int plotX = 36; + int plotW = w - plotX - 4; + int plotH = h - 4; + colW = Math.Max(1, plotW / _maxTicks); + + for (int i = 0; i < _history.Count; i++) + { + var tick = _history[i]; + int xPos = plotX + i * colW; + + if (tick.Freq.Length == 0) + { + // Inactive — dark cell + using var dark = new SolidBrush(Color.FromArgb(25, 25, 28)); + g.FillRectangle(dark, xPos, 2, colW, plotH); + continue; + } + + // 4 sub-ticks side by side within the column + int subW = Math.Max(1, colW / 4); + for (int s = 0; s < 4; s++) + { + int freq = tick.Freq[s]; // 10-240 + int inten = tick.Intensity[s]; // 0-100 + if (inten > 100) inten = 0; // 101 sentinel = off + + // Map freq byte (10-240) to Y position (top=high freq, bottom=low freq) + float normFreq = (freq - 10f) / (240f - 10f); + int y = (int)(2 + plotH * (1f - normFreq)); + if (y < 2) y = 2; + if (y > plotH) y = plotH; + + var color = HeatColor(inten); + using var brush = new SolidBrush(color); + g.FillRectangle(brush, xPos + s * subW, y, subW, 3); + } + } + } + + static Color HeatColor(int intensity) + { + // 0=black, 30=blue, 60=green, 80=gold, 100=red + if (intensity <= 0) return Color.FromArgb(20, 20, 22); + if (intensity < 30) + { + float t = intensity / 30f; + return Color.FromArgb(0, (int)(50 * t), (int)(80 + 80 * t)); + } + if (intensity < 60) + { + float t = (intensity - 30) / 30f; + return Color.FromArgb((int)(60 * t), (int)(130 + 80 * t), (int)(160 - 100 * t)); + } + if (intensity < 80) + { + float t = (intensity - 60) / 20f; + return Color.FromArgb((int)(60 + 180 * t), 210, (int)(60 - 30 * t)); + } + float t2 = (intensity - 80) / 20f; + return Color.FromArgb(255, (int)(240 - 140 * t2), (int)(30 - 10 * t2)); + } +} diff --git a/MainForm.cs b/MainForm.cs index 91eb13d..4ac6575 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -24,17 +24,20 @@ public class MainForm : Form bool _closingFromTray; - readonly ProgressBar _gaugeSendA; - readonly ProgressBar _gaugeSendB; + readonly HeatMap _heatA; + readonly HeatMap _heatB; readonly ProgressBar _gaugeRecvA; readonly ProgressBar _gaugeRecvB; readonly Label _lblSendA; readonly Label _lblSendB; readonly Label _lblRecvA; readonly Label _lblRecvB; - readonly TrackBar _limitBar; - readonly Label _lblLimit; - readonly CheckBox _chkScale; + readonly TrackBar _limitBarA; + readonly TrackBar _limitBarB; + readonly Label _lblLimitA; + readonly Label _lblLimitB; + readonly CheckBox _chkScaleA; + readonly CheckBox _chkScaleB; readonly Icon _iconNeutral; readonly Icon _iconActive; @@ -55,7 +58,7 @@ public class MainForm : Form _loopCts = new CancellationTokenSource(); Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}"; - ClientSize = new Size(420, 360); + ClientSize = new Size(420, 410); FormBorderStyle = FormBorderStyle.FixedSingle; MaximizeBox = false; StartPosition = FormStartPosition.CenterScreen; @@ -137,39 +140,33 @@ public class MainForm : Form Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; - _gaugeSendA = new ProgressBar + _heatA = new HeatMap(60) { - Location = new Point(72, 160), - Size = new Size(332, 20), - Minimum = 0, - Maximum = 100, - Style = ProgressBarStyle.Continuous + Location = new Point(72, 158), + Size = new Size(332, 22) }; _lblSendB = new Label { Text = "Send B", - Location = new Point(16, 184), + Location = new Point(16, 188), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; - _gaugeSendB = new ProgressBar + _heatB = new HeatMap(60) { - Location = new Point(72, 184), - Size = new Size(332, 20), - Minimum = 0, - Maximum = 100, - Style = ProgressBarStyle.Continuous + Location = new Point(72, 186), + Size = new Size(332, 22) }; _lblRecvA = new Label { Text = "Recv A", - Location = new Point(16, 212), + Location = new Point(16, 216), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _gaugeRecvA = new ProgressBar { - Location = new Point(72, 212), + Location = new Point(72, 216), Size = new Size(332, 20), Minimum = 0, Maximum = 200, @@ -178,47 +175,73 @@ public class MainForm : Form _lblRecvB = new Label { Text = "Recv B", - Location = new Point(16, 236), + Location = new Point(16, 242), Size = new Size(48, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; _gaugeRecvB = new ProgressBar { - Location = new Point(72, 236), + Location = new Point(72, 242), Size = new Size(332, 20), Minimum = 0, Maximum = 200, Style = ProgressBarStyle.Continuous }; - _lblLimit = new Label + _lblLimitA = new Label { - Text = "Limit: 30", - Location = new Point(16, 272), - Size = new Size(64, 20), + Text = "Lim A: 30", + Location = new Point(16, 274), + Size = new Size(56, 20), Font = new Font(Font.FontFamily, 9, FontStyle.Bold) }; - _limitBar = new TrackBar + _limitBarA = new TrackBar { - Location = new Point(80, 268), - Size = new Size(240, 45), + Location = new Point(72, 270), + Size = new Size(250, 45), Minimum = 0, Maximum = 200, TickFrequency = 50, Value = 30 }; - _limitBar.ValueChanged += OnLimitChanged; - _chkScale = new CheckBox + _limitBarA.ValueChanged += OnLimitChanged; + _chkScaleA = new CheckBox { Text = "Scale", - Location = new Point(328, 272), + Location = new Point(328, 274), Size = new Size(76, 24), Checked = false }; - _chkScale.CheckedChanged += OnLimitChanged; + _chkScaleA.CheckedChanged += OnLimitChanged; + + _lblLimitB = new Label + { + Text = "Lim B: 30", + Location = new Point(16, 312), + Size = new Size(56, 20), + Font = new Font(Font.FontFamily, 9, FontStyle.Bold) + }; + _limitBarB = new TrackBar + { + Location = new Point(72, 308), + Size = new Size(250, 45), + Minimum = 0, + Maximum = 200, + TickFrequency = 50, + Value = 30 + }; + _limitBarB.ValueChanged += OnLimitChanged; + _chkScaleB = new CheckBox + { + Text = "Scale", + Location = new Point(328, 312), + Size = new Size(76, 24), + Checked = false + }; + _chkScaleB.CheckedChanged += OnLimitChanged; OnLimitChanged(null, EventArgs.Empty); - Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblSendA, _gaugeSendA, _lblSendB, _gaugeSendB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimit, _limitBar, _chkScale }); + Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _chkScaleA, _lblLimitB, _limitBarB, _chkScaleB }); // Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange _iconNeutral = CreateVoltageIcon(Color.Gray); @@ -357,8 +380,14 @@ public class MainForm : Form _lblBle.Text = _device.IsConnected ? $"BLE: connected ({_device.DeviceName})" : "BLE: disconnected"; - _gaugeSendA.Value = Math.Clamp(_state.LastIntensityA, 0, 100); - _gaugeSendB.Value = Math.Clamp(_state.LastIntensityB, 0, 100); + _heatA.AddTick( + _state.LastFreqA ?? Array.Empty(), + _state.LastIntA ?? Array.Empty(), + _state.LastActiveA); + _heatB.AddTick( + _state.LastFreqB ?? Array.Empty(), + _state.LastIntB ?? Array.Empty(), + _state.LastActiveB); _gaugeRecvA.Value = Math.Clamp(_device.StrengthA, 0, 200); _gaugeRecvB.Value = Math.Clamp(_device.StrengthB, 0, 200); _btnTest.Enabled = !_server.HasClient && !IsTestRunning; @@ -443,10 +472,14 @@ public class MainForm : Form void OnLimitChanged(object? sender, EventArgs e) { - var max = _limitBar.Value; - var mode = _chkScale.Checked ? LimitMode.Scale : LimitMode.Clamp; - _state.SetLimit(max, mode); - _lblLimit.Text = $"Limit: {max}"; + var maxA = _limitBarA.Value; + var maxB = _limitBarB.Value; + var modeA = _chkScaleA.Checked ? LimitMode.Scale : LimitMode.Clamp; + var modeB = _chkScaleB.Checked ? LimitMode.Scale : LimitMode.Clamp; + _state.SetLimitA(maxA, modeA); + _state.SetLimitB(maxB, modeB); + _lblLimitA.Text = $"Lim A: {maxA}"; + _lblLimitB.Text = $"Lim B: {maxB}"; } string _musicFilePath = ""; diff --git a/State.cs b/State.cs index 511a799..b70eaac 100644 --- a/State.cs +++ b/State.cs @@ -18,17 +18,30 @@ public class State public int ActualA, ActualB; public int LastSentA, LastSentB; public int LastIntensityA, LastIntensityB; + public byte[]? LastFreqA, LastIntA, LastFreqB, LastIntB; + public bool LastActiveA, LastActiveB; - public int MaxStrength = 200; - public LimitMode LimitMode = LimitMode.Clamp; + public int MaxA = 200; + public int MaxB = 200; + public LimitMode LimitModeA = LimitMode.Clamp; + public LimitMode LimitModeB = LimitMode.Clamp; - public void SetLimit(int max, LimitMode mode) + public void SetLimitA(int max, LimitMode mode) { lock (Lock) { - MaxStrength = Math.Clamp(max, 0, 200); - LimitMode = mode; + MaxA = Math.Clamp(max, 0, 200); + LimitModeA = mode; DirtyA = true; + } + } + + public void SetLimitB(int max, LimitMode mode) + { + lock (Lock) + { + MaxB = Math.Clamp(max, 0, 200); + LimitModeB = mode; DirtyB = true; } } @@ -93,14 +106,12 @@ public class State var modeA = DirtyA ? StrengthMode.Abs : StrengthMode.None; var modeB = DirtyB ? StrengthMode.Abs : StrengthMode.None; - var limit = MaxStrength; - var limMode = LimitMode; - byte ApplyLimit(int desired) => - limMode == LimitMode.Scale - ? (byte)(desired * limit / 200) - : (byte)Math.Clamp(desired, 0, limit); - var valA = ApplyLimit(DesiredA); - var valB = ApplyLimit(DesiredB); + byte ApplyLimit(int desired, int max, LimitMode mode) => + mode == LimitMode.Scale + ? (byte)(desired * max / 200) + : (byte)Math.Clamp(desired, 0, max); + var valA = ApplyLimit(DesiredA, MaxA, LimitModeA); + var valB = ApplyLimit(DesiredB, MaxB, LimitModeB); LastSentA = valA; LastSentB = valB; DirtyA = false; @@ -111,6 +122,10 @@ public class State var (freqA, intA) = PopWave(StreamA, LoopFreqA, LoopIntA); var (freqB, intB) = PopWave(StreamB, LoopFreqB, LoopIntB); + LastActiveA = hasA; + LastActiveB = hasB; + LastFreqA = freqA; LastIntA = intA; + LastFreqB = freqB; LastIntB = intB; LastIntensityA = hasA ? (intA[0] + intA[1] + intA[2] + intA[3]) / 4 : 0; LastIntensityB = hasB ? (intB[0] + intB[1] + intB[2] + intB[3]) / 4 : 0;