Skip to content

Commit fd4a177

Browse files
committed
refactor BioPolymerCoverageMapViewModel to use mapper + scale abstraction
Wires up the new color-mapping architecture: - Add CreateColorMapper() to resolve mapper from current ColorBy - Add CreateNumericScale(mapper, filteredResults) to derive min/max/range - Add DrawNumericLegend(...) helper to render gradient bar - Drawing loop now calls mapper.GetBrush(result, scale) instead of inline ternaries - Remove ViridisColors, InitViridisPalette, GetIntensityBrush, GetColorBrush (now owned by ViridisColorGradient and the mapper hierarchy) Update existing tests to reflect new abstraction and add 4 new reflection-based tests for CreateColorMapper and CreateNumericScale (non-numeric returns null, numeric derives min/max, all-null precursor intensity falls back to score). 75 color-mapping tests passing; 451 MetaDraw tests passing overall.
1 parent 14a09c2 commit fd4a177

3 files changed

Lines changed: 178 additions & 161 deletions

File tree

MetaMorpheus/GuiFunctions/MetaDraw/BioPolymerCoverage/BioPolymerCoverageMapViewModel.cs

Lines changed: 85 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,6 @@ public class BioPolymerCoverageMapViewModel : BaseViewModel
6363
new SolidColorBrush(Colors.MediumAquamarine)
6464
});
6565

66-
private static readonly SolidColorBrush[] ViridisColors = InitViridisPalette();
67-
68-
private static SolidColorBrush[] InitViridisPalette()
69-
{
70-
var hex = new[] { "440154","481467","482576","453781","3f4d8a","39568c","2e6f8e","238a8d","20a386","34b679","60c85d","93d443","c7df2b","eddf2b","f8e51d","fce61b","fbdf0e","f6d300","edc600","fde725" };
71-
return hex.Select(h => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#" + h))).ToArray();
72-
}
73-
7466
private static readonly Dictionary<string, SolidColorBrush> IdentifierToColor = [];
7567

7668
public ColorResultsBy[] AllColorByTypes { get; } = Enum.GetValues<ColorResultsBy>();
@@ -92,18 +84,47 @@ public ColorResultsBy ColorBy
9284
}
9385
}
9486

95-
private SolidColorBrush GetColorBrush(BioPolymerCoverageResultModel result)
87+
private BioPolymerCoverageColorMapper CreateColorMapper()
88+
{
89+
return BioPolymerCoverageColorMapperFactory.Create(
90+
_colorBy,
91+
identifierBrushResolver: GetColorByIdentifier);
92+
}
93+
94+
private BioPolymerCoverageColorScale? CreateNumericScale(
95+
BioPolymerCoverageColorMapper mapper,
96+
List<BioPolymerCoverageResultModel> filteredResults)
9697
{
97-
switch (_colorBy)
98+
var gradient = ColorGradientFactory.Create(ColorGradientType.Viridis);
99+
100+
if (mapper is not NumericBioPolymerCoverageColorMapper numericMapper)
101+
return null;
102+
103+
var values = filteredResults
104+
.Select(numericMapper.GetNumericValue)
105+
.Where(v => v.HasValue)
106+
.Select(v => v.Value)
107+
.ToList();
108+
109+
if (values.Count == 0 && numericMapper is PrecursorIntensityColorMapper)
98110
{
99-
case ColorResultsBy.CoverageType:
100-
return MetaDrawSettings.BioPolymerCoverageColors[result.CoverageType];
101-
case ColorResultsBy.FileOrigin:
102-
// Use FileName as identifier since FullFilePath is not available
103-
return GetColorByIdentifier(result.Match.FileName);
104-
default:
105-
return new SolidColorBrush(Colors.Gray);
111+
numericMapper = new ScoreColorMapper();
112+
values = filteredResults
113+
.Select(numericMapper.GetNumericValue)
114+
.Where(v => v.HasValue)
115+
.Select(v => v.Value)
116+
.ToList();
106117
}
118+
119+
if (values.Count == 0)
120+
return new BioPolymerCoverageColorScale(0, 0, gradient);
121+
122+
double minVal = values.Min();
123+
double maxVal = values.Max();
124+
double range = maxVal - minVal;
125+
if (range < double.Epsilon) range = 1;
126+
127+
return new BioPolymerCoverageColorScale(minVal, maxVal, gradient);
107128
}
108129

109130
// Helper method for FileOrigin coloring
@@ -120,12 +141,6 @@ private SolidColorBrush GetColorByIdentifier(string identifier)
120141
return brush;
121142
}
122143

123-
private SolidColorBrush GetIntensityBrush(double normalizedValue)
124-
{
125-
int bin = (int)Math.Clamp(normalizedValue * 19, 0, 19);
126-
return ViridisColors[bin];
127-
}
128-
129144
#endregion
130145

131146
private DrawingImage _coverageDrawing;
@@ -194,6 +209,9 @@ private void Redraw()
194209
var rowRectangles = new Dictionary<int, List<(int startCol, int endCol)>>();
195210
var rowOccupancy = new Dictionary<int, int[]>(); // row -> per-column track usage
196211

212+
var mapper = CreateColorMapper();
213+
var scale = CreateNumericScale(mapper, filteredResults);
214+
197215
var dv = new DrawingVisual();
198216
using (var dc = dv.RenderOpen())
199217
{
@@ -255,12 +273,12 @@ private void Redraw()
255273
var sepWidth = sep.Width;
256274

257275
double legendY = metricsY + metricsFt.Height + legendSpacing;
258-
var legendItems = CreateLegendItems(filteredResults, fontSize, dpi, ColorBy);
276+
var legendItems = CreateLegendItems(filteredResults, fontSize, dpi, _colorBy);
259277

260278
var legendLines = WrapLegendItems(legendItems, fontSize, usableWidth, sepWidth);
261279

262280
double legendLineHeight = legendItems.Count == 0 ? 0 : Math.Max(fontSize * 0.8, legendItems.Max(li => li.Text.Height));
263-
281+
264282

265283
foreach (var line in legendLines)
266284
{
@@ -287,42 +305,9 @@ private void Redraw()
287305
legendY += legendLineHeight + legendSpacing * 0.2;
288306
}
289307

290-
bool isIntensityMode = _colorBy is ColorResultsBy.PrecursorIntensity or ColorResultsBy.Score;
291-
292-
double minVal = 0, maxVal = 0, range = 0, gradientBlockH = 0;
293-
if (isIntensityMode)
294-
{
295-
var vals = filteredResults
296-
.Select(r => _colorBy == ColorResultsBy.PrecursorIntensity
297-
? r.Match.PrecursorIntensity ?? double.NaN
298-
: r.Match.Score)
299-
.Where(v => !double.IsNaN(v)).ToList();
300-
if (vals.Count == 0 && _colorBy == ColorResultsBy.PrecursorIntensity)
301-
vals = filteredResults.Select(r => r.Match.Score).ToList();
302-
if (vals.Count > 0) { minVal = vals.Min(); maxVal = vals.Max(); }
303-
range = maxVal - minVal;
304-
if (range < double.Epsilon) range = 1;
305-
306-
double barWidth = Math.Min(250, usableWidth * 0.6);
307-
double barHeight = fontSize * 0.8;
308-
double barX = plotMargin + (usableWidth - barWidth) / 2;
309-
double barY = legendY + legendSpacing;
310-
double bandW = barWidth / 20;
311-
for (int i = 0; i < 20; i++)
312-
dc.DrawRectangle(ViridisColors[i], null, new Rect(barX + i * bandW, barY, bandW + 0.5, barHeight));
313-
314-
string metricName = _colorBy == ColorResultsBy.PrecursorIntensity ? "Precursor Intensity" : "Score";
315-
var metricTxt = new FormattedText(metricName, System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Segoe UI"), fontSize * 0.7, Brushes.DimGray, dpi);
316-
dc.DrawText(metricTxt, new Point(barX + (barWidth - metricTxt.Width) / 2, barY - metricTxt.Height - 2));
317-
318-
var minTxt = new FormattedText($"{minVal:G3}", System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Segoe UI"), fontSize * 0.7, Brushes.Black, dpi);
319-
var maxTxt = new FormattedText($"{maxVal:G3}", System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Segoe UI"), fontSize * 0.7, Brushes.Black, dpi);
320-
dc.DrawText(minTxt, new Point(barX, barY + barHeight + 2));
321-
dc.DrawText(maxTxt, new Point(barX + barWidth - maxTxt.Width, barY + barHeight + 2));
322-
323-
gradientBlockH = barHeight + Math.Max(minTxt.Height, maxTxt.Height) + legendSpacing * 2;
324-
legendY += gradientBlockH;
325-
}
308+
double gradientBlockH = 0;
309+
if (mapper.IsNumeric && scale is not null)
310+
gradientBlockH = DrawNumericLegend(dc, legendY, plotMargin, usableWidth, legendSpacing, fontSize, dpi, mapper, scale, out legendY);
326311

327312
// --- Offset plot below header block (metrics + legend) ---
328313
double headerBlockHeight = bioPolymerNameText.Height + metricsFt.Height + legendSpacing
@@ -407,11 +392,7 @@ private void Redraw()
407392
roundRight = true;
408393

409394
// Use a slightly opaque brush for fill
410-
var baseBrush = isIntensityMode
411-
? GetIntensityBrush(((_colorBy == ColorResultsBy.PrecursorIntensity
412-
? res.Match.PrecursorIntensity.GetValueOrDefault((minVal + maxVal) / 2)
413-
: res.Match.Score) - minVal) / range)
414-
: GetColorBrush(res);
395+
var baseBrush = mapper.GetBrush(res, scale);
415396
var color = (baseBrush as SolidColorBrush)?.Color ?? Colors.Gray;
416397
var brush = new SolidColorBrush(color) { Opacity = 0.75 };
417398
brush.Freeze();
@@ -564,6 +545,43 @@ private void Redraw()
564545
CoverageDrawing = new DrawingImage(dv.Drawing);
565546
}
566547

548+
private double DrawNumericLegend(
549+
DrawingContext dc,
550+
double legendY,
551+
double plotMargin,
552+
double usableWidth,
553+
double legendSpacing,
554+
double fontSize,
555+
double dpi,
556+
BioPolymerCoverageColorMapper mapper,
557+
BioPolymerCoverageColorScale scale,
558+
out double newLegendY)
559+
{
560+
double barWidth = Math.Min(250, usableWidth * 0.6);
561+
double barHeight = fontSize * 0.8;
562+
double barX = plotMargin + (usableWidth - barWidth) / 2;
563+
double barY = legendY + legendSpacing;
564+
var brushes = scale.Gradient.GetBrushes();
565+
double bandW = barWidth / brushes.Count;
566+
for (int i = 0; i < brushes.Count; i++)
567+
dc.DrawRectangle(brushes[i], null, new Rect(barX + i * bandW, barY, bandW + 0.5, barHeight));
568+
569+
string metricName = mapper.ColorBy == ColorResultsBy.PrecursorIntensity
570+
? "Precursor Intensity"
571+
: mapper.ColorBy.ToString();
572+
var metricTxt = new FormattedText(metricName, System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Segoe UI"), fontSize * 0.7, Brushes.DimGray, dpi);
573+
dc.DrawText(metricTxt, new Point(barX + (barWidth - metricTxt.Width) / 2, barY - metricTxt.Height - 2));
574+
575+
var minTxt = new FormattedText($"{scale.MinValue:G3}", System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Segoe UI"), fontSize * 0.7, Brushes.Black, dpi);
576+
var maxTxt = new FormattedText($"{scale.MaxValue:G3}", System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Segoe UI"), fontSize * 0.7, Brushes.Black, dpi);
577+
dc.DrawText(minTxt, new Point(barX, barY + barHeight + 2));
578+
dc.DrawText(maxTxt, new Point(barX + barWidth - maxTxt.Width, barY + barHeight + 2));
579+
580+
double blockH = barHeight + Math.Max(minTxt.Height, maxTxt.Height) + legendSpacing * 2;
581+
newLegendY = legendY + blockH;
582+
return blockH;
583+
}
584+
567585
// Preferred legend order
568586
private static readonly BioPolymerCoverageType[] LegendOrder =
569587
{
@@ -664,4 +682,4 @@ private void Redraw()
664682

665683
return lines;
666684
}
667-
}
685+
}

MetaMorpheus/GuiFunctions/MetaDraw/BioPolymerCoverage/ColorMapping/IMPLEMENTATION_PLAN.md

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,39 @@ abstraction with `Viridis` as the first gradient.
66

77
## 1. Gradient types
88

9-
- [ ] Create `ColorMapping/ColorGradientType.cs` with `Viridis` enum value.
10-
- [ ] Create `ColorMapping/ColorGradient.cs` abstract base with:
9+
- [x] Create `ColorMapping/ColorGradientType.cs` with `Viridis` enum value.
10+
- [x] Create `ColorMapping/ColorGradient.cs` abstract base with:
1111
- `ColorGradientType GradientType { get; }`
1212
- `int BinCount { get; }`
1313
- `SolidColorBrush GetBrush(double normalizedValue)`
1414
- `IReadOnlyList<SolidColorBrush> GetBrushes()`
15-
- [ ] Create `ColorMapping/ViridisColorGradient.cs` with the current 20-bin palette.
16-
- [ ] Create `ColorMapping/ColorGradientFactory.cs` static `Create(ColorGradientType)`.
15+
- [x] Create `ColorMapping/ViridisColorGradient.cs` with the current 20-bin palette.
16+
- [x] Create `ColorMapping/ColorGradientFactory.cs` static `Create(ColorGradientType)`.
1717

1818
## 2. Scale value object
1919

20-
- [ ] Create `ColorMapping/BioPolymerCoverageColorScale.cs` with:
20+
- [x] Create `ColorMapping/BioPolymerCoverageColorScale.cs` with:
2121
- `MinValue`, `MaxValue`, `Range`, `Gradient` properties
2222
- `Normalize(double value)` helper
2323

2424
## 3. Mapper hierarchy
2525

26-
- [ ] Create `ColorMapping/BioPolymerCoverageColorMapper.cs` abstract base with:
26+
- [x] Create `ColorMapping/BioPolymerCoverageColorMapper.cs` abstract base with:
2727
- `ColorResultsBy ColorBy { get; }`
2828
- `bool IsNumeric { get; }`
2929
- `SolidColorBrush GetBrush(BioPolymerCoverageResultModel, BioPolymerCoverageColorScale?)`
3030
- `virtual string GetLegendTitle(BioPolymerCoverageColorScale?)`
31-
- [ ] Create `ColorMapping/CategoricalBioPolymerCoverageColorMapper.cs`
31+
- [x] Create `ColorMapping/CategoricalBioPolymerCoverageColorMapper.cs`
3232
- constructor takes `ColorResultsBy` and `Func<BioPolymerCoverageResultModel, SolidColorBrush>`
33-
- [ ] Create `ColorMapping/NumericBioPolymerCoverageColorMapper.cs` abstract base
33+
- [x] Create `ColorMapping/NumericBioPolymerCoverageColorMapper.cs` abstract base
3434
- `abstract double? GetNumericValue(BioPolymerCoverageResultModel)`
3535
- base `GetBrush` does normalize + gradient lookup
36-
- [ ] Create `ColorMapping/PrecursorIntensityColorMapper.cs`
37-
- [ ] Create `ColorMapping/ScoreColorMapper.cs`
36+
- [x] Create `ColorMapping/PrecursorIntensityColorMapper.cs`
37+
- [x] Create `ColorMapping/ScoreColorMapper.cs`
3838

3939
## 4. Mapper factory
4040

41-
- [ ] Create `ColorMapping/BioPolymerCoverageColorMapperFactory.cs`
41+
- [x] Create `ColorMapping/BioPolymerCoverageColorMapperFactory.cs`
4242
- `Create(ColorResultsBy, Func<string, SolidColorBrush> identifierBrushResolver, ColorGradientType gradientType = Viridis)`
4343
- registrations:
4444
- `None` -> gray categorical mapper
@@ -49,30 +49,32 @@ abstraction with `Viridis` as the first gradient.
4949

5050
## 5. View model refactor (`BioPolymerCoverageMapViewModel.cs`)
5151

52-
- [ ] Add `CreateColorMapper()` helper.
53-
- [ ] Add `CreateNumericScale(BioPolymerCoverageColorMapper, List<BioPolymerCoverageResultModel>)` helper.
54-
- [ ] Rename `GetColorByIdentifier` to `GetIdentifierBrush` and pass it to factory.
55-
- [ ] Replace intensity-specific normalization block in `Redraw()` with mapper + scale flow.
56-
- [ ] Update drawing loop to use `mapper.GetBrush(result, scale)`.
57-
- [ ] Update `CreateLegendItems` to accept mapper + scale and return empty for numeric modes.
58-
- [ ] Extract gradient-bar drawing into `DrawNumericLegend(...)` returning consumed height.
59-
- [ ] Remove `ViridisColors`, `InitViridisPalette`, `GetIntensityBrush`, and the direct intensity ternary.
52+
- [x] Add `CreateColorMapper()` helper.
53+
- [x] Add `CreateNumericScale(BioPolymerCoverageColorMapper, List<BioPolymerCoverageResultModel>)` helper.
54+
- [x] Pass `GetColorByIdentifier` to factory as identifier resolver.
55+
- [x] Replace intensity-specific normalization block in `Redraw()` with mapper + scale flow.
56+
- [x] Update drawing loop to use `mapper.GetBrush(result, scale)`.
57+
- [x] Update `CreateLegendItems` to keep returning empty for numeric modes (gradient bar draws via new path).
58+
- [x] Extract gradient-bar drawing into `DrawNumericLegend(...)` returning consumed height.
59+
- [x] Remove `ViridisColors`, `InitViridisPalette`, `GetIntensityBrush`, and the direct intensity ternary.
6060

6161
## 6. Tests
6262

63-
- [ ] Add `ViridisColorGradient` tests (count, clamp, end colors).
64-
- [ ] Add mapper factory tests (categorical vs numeric resolution).
65-
- [ ] Add numeric mapper tests (extract value, brush usage).
66-
- [ ] Update view-model drawing tests to use new abstraction paths.
67-
- [ ] Add `CreateNumericScale` test for min/max derivation.
68-
- [ ] Add fallback test: `PrecursorIntensity` with all null falls back to score through new abstraction.
63+
- [x] Add `ViridisColorGradient` tests (count, clamp, end colors) - 12 tests
64+
- [x] Add `BioPolymerCoverageColorScale` tests (normalization, clamping, null guard) - 9 tests
65+
- [x] Add numeric mapper tests (extract value, brush usage) - 19 tests
66+
- [x] Add mapper factory tests (categorical vs numeric resolution) - 10 tests
67+
- [x] Update view-model drawing tests to use new abstraction paths.
68+
- [x] Add `CreateColorMapper` test for mapper resolution by `ColorBy`.
69+
- [x] Add `CreateNumericScale` tests for non-numeric returns null and numeric derives min/max.
70+
- [x] Add fallback test: `PrecursorIntensity` with all null falls back to score through new abstraction.
6971

7072
## 7. Build + verify
7173

72-
- [ ] Build `GuiFunctions` project: 0 errors.
73-
- [ ] Build `Test` project: 0 errors.
74-
- [ ] Run all new and existing tests in `BioPolymerCoverageMapViewModelTests` and gradient/mapper tests.
75-
- [ ] Run full test suite to confirm no regressions.
74+
- [x] Build `GuiFunctions` project: 0 errors.
75+
- [x] Build `Test` project: 0 errors.
76+
- [x] Run all new and existing tests in `BioPolymerCoverageMapViewModelTests` and gradient/mapper tests.
77+
- [x] Run full MetaDraw test suite to confirm no regressions (451 tests passing).
7678

7779
## 8. Commit + push
7880

0 commit comments

Comments
 (0)