# RFCP - Iteration 10.3.1: Heatmap Threshold Filter Fix

**Goal:** Fix purple "weak signal" still rendering by adding RSRP threshold filtering to heatmap renderer  
**Priority:** P1 (Bug Fix)  
**Estimated Time:** 15-20 minutes  
**Status:** Ready for Implementation

---

## 📋 Overview

Iteration 10.3 added coverage boundary and changed default Min Signal to -100 dBm, but the heatmap tile renderer still draws all points including those below threshold. This creates the misleading purple fill that should have been removed.

**Root Cause:** `HeatmapTileRenderer.ts` filters points only by geographic bounds, not by RSRP threshold.

---

## 🐛 Current Bug

**Symptom:** Purple/lavender fill visible outside the "useful" coverage zone despite Min Signal = -100 dBm

**Location:** `src/components/map/HeatmapTileRenderer.ts` lines 100-107

**Current Code:**
```typescript
// Filter relevant points
const relevant = points.filter(
  (p) =>
    p.lat >= latMin - bufferDeg &&
    p.lat <= latMax + bufferDeg &&
    p.lon >= lonMin - bufferDeg &&
    p.lon <= lonMax + bufferDeg
);
```

**Problem:** No RSRP threshold check — all points render regardless of signal strength.

---

## ✅ Solution

Add `rsrpThreshold` parameter to the rendering pipeline and filter out weak signals.

### Step 1: Update HeatmapTileRenderer.ts

**File:** `src/components/map/HeatmapTileRenderer.ts`

#### 1.1 Add threshold to class state

```typescript
// Around line 35, add new property:
export class HeatmapTileRenderer {
  private tileSize = 256;
  private radiusMeters: number;
  private rsrpThreshold: number;  // NEW

  // LRU cache...
  
  constructor(radiusMeters = 400, maxCacheSize = 150, rsrpThreshold = -100) {
    this.radiusMeters = radiusMeters;
    this.maxCacheSize = maxCacheSize;
    this.rsrpThreshold = rsrpThreshold;  // NEW
  }
```

#### 1.2 Add setter for threshold

```typescript
// After setRadiusMeters(), add:

/** Update the RSRP threshold - points below this are not rendered. */
setRsrpThreshold(threshold: number): void {
  if (threshold !== this.rsrpThreshold) {
    this.rsrpThreshold = threshold;
    this.clearCache();  // Important: invalidate cache when threshold changes
  }
}
```

#### 1.3 Update renderTile() filter

```typescript
// Around line 100, update the filter:

// Filter relevant points - geographic bounds AND RSRP threshold
const relevant = points.filter(
  (p) =>
    p.rsrp >= this.rsrpThreshold &&  // 🔥 NEW: skip weak signals
    p.lat >= latMin - bufferDeg &&
    p.lat <= latMax + bufferDeg &&
    p.lon >= lonMin - bufferDeg &&
    p.lon <= lonMax + bufferDeg
);
```

### Step 2: Update GeographicHeatmap.tsx

**File:** `src/components/map/GeographicHeatmap.tsx`

#### 2.1 Add threshold prop

```typescript
interface GeographicHeatmapProps {
  points: HeatmapPoint[];
  visible: boolean;
  opacity?: number;
  radiusMeters?: number;
  rsrpThreshold?: number;  // NEW
}

export default function GeographicHeatmap({
  points,
  visible,
  opacity = 0.7,
  radiusMeters = 400,
  rsrpThreshold = -100,  // NEW
}: GeographicHeatmapProps) {
```

#### 2.2 Update renderer when threshold changes

```typescript
// After the useEffect for radiusMeters, add:

// Update renderer threshold when prop changes
useEffect(() => {
  rendererRef.current.setRsrpThreshold(rsrpThreshold);
}, [rsrpThreshold]);
```

#### 2.3 Include threshold in cache invalidation

```typescript
// Update the pointsHash useEffect to include threshold:

useEffect(() => {
  if (points.length === 0) {
    rendererRef.current.setPointsHash('empty');
    return;
  }
  const first = points[0];
  const last = points[points.length - 1];
  // Include threshold in hash so cache invalidates when it changes
  const hash = `${points.length}:${first.lat.toFixed(4)},${first.lon.toFixed(4)}:${last.rsrp}:${rsrpThreshold}`;
  rendererRef.current.setPointsHash(hash);
}, [points, rsrpThreshold]);
```

### Step 3: Update App.tsx

**File:** `src/App.tsx`

Pass threshold from settings to GeographicHeatmap:

```typescript
{coverageResult && (
  <>
    <GeographicHeatmap
      points={coverageResult.points}
      visible={heatmapVisible}
      opacity={settings.heatmapOpacity}
      radiusMeters={settings.heatmapRadius}
      rsrpThreshold={settings.rsrpThreshold}  // NEW
    />
    <CoverageBoundary
      points={coverageResult.points}
      visible={heatmapVisible}
      resolution={settings.resolution}
    />
  </>
)}
```

---

## 📁 Files to Modify

| File | Changes |
|------|---------|
| `src/components/map/HeatmapTileRenderer.ts` | Add `rsrpThreshold` property, setter, filter in renderTile() |
| `src/components/map/GeographicHeatmap.tsx` | Add `rsrpThreshold` prop, pass to renderer, update cache hash |
| `src/App.tsx` | Pass `settings.rsrpThreshold` to GeographicHeatmap |

**Total:** 3 files, ~20 lines changed

---

## 🧪 Testing Checklist

### Visual Testing

- [ ] **No purple fill:** Only orange gradient visible (no purple/lavender outside)
- [ ] **Threshold respected:** Changing Min Signal slider updates heatmap immediately
- [ ] **Boundary matches:** Dashed border aligns with heatmap edge
- [ ] **Different thresholds:** Test -90, -100, -110 — heatmap adjusts accordingly

### Functional Testing

- [ ] **Slider reactive:** Moving Min Signal slider updates heatmap without "Calculate Coverage"
- [ ] **Cache invalidation:** Threshold change clears old tiles, renders new ones
- [ ] **Zoom levels:** Works at zoom 8, 12, 16
- [ ] **Multiple sites:** Each site respects threshold

### Regression Testing

- [ ] **50m resolution:** Still works without crash
- [ ] **Performance:** No significant slowdown
- [ ] **Boundary component:** Still renders correctly
- [ ] **Legend:** Still shows correct colors and threshold indicator

---

## 🏗️ Build & Deploy

```bash
cd /opt/rfcp/frontend

# Build
npm run build

# Expected: 0 TypeScript errors, 0 ESLint errors

# Deploy
sudo systemctl reload caddy

# Test at https://rfcp.eliah.one
# 1. Set Min Signal to -100 dBm
# 2. Verify NO purple visible outside orange zone
# 3. Move slider to -90 — coverage zone shrinks
# 4. Move slider to -110 — coverage zone expands (some purple appears)
```

---

## 📝 Commit Message

```
fix(heatmap): filter points by RSRP threshold to remove purple fill

- Added rsrpThreshold property to HeatmapTileRenderer
- Filter points below threshold before rendering tiles
- Cache invalidates when threshold changes
- Passed threshold from settings through GeographicHeatmap

Points below Min Signal threshold are now completely hidden,
not just dimmed. This removes the misleading purple "weak signal"
fill that made it look like coverage existed where it doesn't.

Iteration: 10.3.1
Fixes: Purple fill still visible after 10.3
```

---

## ✅ Success Criteria

**Must Pass:**
1. ✅ No purple/lavender fill when Min Signal = -100 dBm
2. ✅ Heatmap updates when Min Signal slider moves
3. ✅ Only orange gradient visible (excellent → fair signal)
4. ✅ Dashed boundary aligns with heatmap edge
5. ✅ TypeScript: 0 errors
6. ✅ ESLint: 0 errors

**User Acceptance:**
- Олег confirms: "фіолету нема, тільки оранжевий градієнт"

---

## 🎨 Expected Visual Result

**Before (10.3):**
```
┌─────────────────────────────┐
│ ▓▓▓ Purple fill ▓▓▓▓▓▓▓▓▓▓ │  ← Still visible!
│ ▓▓┌─ ─ ─ ─ ─ ─ ─ ─ ─┐▓▓▓▓ │
│ ▓▓│  Orange gradient │▓▓▓▓ │
│ ▓▓└─ ─ ─ ─ ─ ─ ─ ─ ─┘▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
└─────────────────────────────┘
```

**After (10.3.1):**
```
         ┌─ ─ ─ ─ ─ ─ ─┐
         │             │
    ─ ─ ─┤  🟠 Orange  ├─ ─ ─  ← Dashed border
         │   gradient  │
         │  (no purple │
         │   outside!) │
         └─ ─ ─ ─ ─ ─ ─┘
```

---

## 🤖 Instructions for Claude Code

**Context:**
- Project: RFCP - `/opt/rfcp/frontend/`
- Bug: Purple fill still renders despite Min Signal = -100 dBm
- Root cause: `HeatmapTileRenderer.ts` doesn't filter by threshold

**Implementation Order:**
1. `HeatmapTileRenderer.ts` — add threshold property + setter + filter
2. `GeographicHeatmap.tsx` — add prop, pass to renderer, update hash
3. `App.tsx` — pass `settings.rsrpThreshold` to component

**Key Points:**
- Threshold setter MUST call `clearCache()` to invalidate tiles
- Include threshold in pointsHash for proper cache invalidation
- Default threshold = -100 dBm (matches store default)

**Success:** User sees only orange gradient, no purple fill outside coverage zone

---

**Document Created:** 2025-01-30  
**Author:** Claude (Opus 4.5) + Олег  
**Status:** Ready for Implementation  
**Depends On:** Iteration 10.3 (boundary component)
