fix: separate heatmap opacity controls for Map and Live pages

- Live page showHeatMap() now reads meshcore-live-heatmap-opacity from
  localStorage and applies it to the canvas element (was hardcoded 0.3)
- Customizer now has two clearly labeled sliders:
  🗺️ Nodes Map — controls the static map page heatmap
  📡 Live Map — controls the live page heatmap
- Each uses its own localStorage key (meshcore-heatmap-opacity vs
  meshcore-live-heatmap-opacity)
- Added E2E tests for live opacity persistence and dual slider existence
- 13/15 E2E tests pass locally (2 fail due to ARM chromium OOM after
  heavy live page tests — CI on x64 will handle them)

Closes #119 properly this time.
This commit is contained in:
you
2026-03-24 19:25:28 +00:00
parent 5184e6f3cd
commit de85e18b81
4 changed files with 87 additions and 29 deletions
+30 -3
View File
@@ -731,18 +731,27 @@
var heatOpacity = parseFloat(localStorage.getItem('meshcore-heatmap-opacity'));
if (isNaN(heatOpacity)) heatOpacity = 0.25;
var heatPct = Math.round(heatOpacity * 100);
var liveHeatOpacity = parseFloat(localStorage.getItem('meshcore-live-heatmap-opacity'));
if (isNaN(liveHeatOpacity)) liveHeatOpacity = 0.3;
var liveHeatPct = Math.round(liveHeatOpacity * 100);
return '<div class="cust-panel' + (activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
'<p class="cust-section-title">Node Role Colors</p>' + rows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Packet Type Colors</p>' + typeRows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Heatmap</p>' +
'<p class="cust-section-title">Heatmap Opacity</p>' +
'<div class="cust-color-row">' +
'<div><label>🔥 Opacity</label>' +
'<div class="cust-hint">Controls how opaque the heatmap overlay appears on the live map (0100%)</div></div>' +
'<div><label>🗺️ Nodes Map</label>' +
'<div class="cust-hint">Heatmap overlay on the Nodes → Map page (0100%)</div></div>' +
'<input type="range" id="custHeatOpacity" min="0" max="100" value="' + heatPct + '" style="width:120px;cursor:pointer">' +
'<span id="custHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + heatPct + '%</span>' +
'</div>' +
'<div class="cust-color-row">' +
'<div><label>📡 Live Map</label>' +
'<div class="cust-hint">Heatmap overlay on the Live page (0100%)</div></div>' +
'<input type="range" id="custLiveHeatOpacity" min="0" max="100" value="' + liveHeatPct + '" style="width:120px;cursor:pointer">' +
'<span id="custLiveHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + liveHeatPct + '%</span>' +
'</div>' +
'</div>';
}
@@ -1030,6 +1039,24 @@
});
}
// Live heatmap opacity slider
var liveHeatSlider = container.querySelector('#custLiveHeatOpacity');
if (liveHeatSlider) {
liveHeatSlider.addEventListener('input', function () {
var pct = parseInt(liveHeatSlider.value);
var label = container.querySelector('#custLiveHeatOpacityVal');
if (label) label.textContent = pct + '%';
var opacity = pct / 100;
localStorage.setItem('meshcore-live-heatmap-opacity', opacity);
// Live-update the live page heatmap if visible
if (window._meshcoreLiveHeatLayer) {
var canvas = window._meshcoreLiveHeatLayer._canvas ||
(window._meshcoreLiveHeatLayer.getContainer && window._meshcoreLiveHeatLayer.getContainer());
if (canvas) canvas.style.opacity = opacity;
}
});
}
// Steps
container.querySelectorAll('[data-step-field]').forEach(function (inp) {
inp.addEventListener('input', function () {
+25 -25
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774379786">
<link rel="stylesheet" href="home.css?v=1774379786">
<link rel="stylesheet" href="live.css?v=1774379786">
<link rel="stylesheet" href="style.css?v=1774380053">
<link rel="stylesheet" href="home.css?v=1774380053">
<link rel="stylesheet" href="live.css?v=1774380053">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,27 +81,27 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774379786"></script>
<script src="customize.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774379786"></script>
<script src="hop-resolver.js?v=1774379786"></script>
<script src="hop-display.js?v=1774379786"></script>
<script src="app.js?v=1774379786"></script>
<script src="home.js?v=1774379786"></script>
<script src="packet-filter.js?v=1774379786"></script>
<script src="packets.js?v=1774379786"></script>
<script src="map.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774379786" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774380053"></script>
<script src="customize.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774380053"></script>
<script src="hop-resolver.js?v=1774380053"></script>
<script src="hop-display.js?v=1774380053"></script>
<script src="app.js?v=1774380053"></script>
<script src="home.js?v=1774380053"></script>
<script src="packet-filter.js?v=1774380053"></script>
<script src="packets.js?v=1774380053"></script>
<script src="map.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774380053" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+7 -1
View File
@@ -2106,10 +2106,16 @@
}
}
if (points.length && typeof L.heatLayer === 'function') {
var savedOpacity = parseFloat(localStorage.getItem('meshcore-live-heatmap-opacity'));
if (isNaN(savedOpacity)) savedOpacity = 0.3;
heatLayer = L.heatLayer(points, {
radius: 25, blur: 15, maxZoom: 14, minOpacity: 0.3,
radius: 25, blur: 15, maxZoom: 14, minOpacity: 0.05,
gradient: { 0.2: '#0d47a1', 0.4: '#1565c0', 0.6: '#42a5f5', 0.8: '#ffca28', 1.0: '#ff5722' }
}).addTo(map);
// Set overall layer opacity via canvas element
if (heatLayer._canvas) { heatLayer._canvas.style.opacity = savedOpacity; }
else { setTimeout(function() { if (heatLayer && heatLayer._canvas) heatLayer._canvas.style.opacity = savedOpacity; }, 100); }
window._meshcoreLiveHeatLayer = heatLayer;
}
}
+25
View File
@@ -273,6 +273,31 @@ async function run() {
await browser.close();
// Test 14: Live heatmap opacity stored in localStorage
await test('Live heatmap opacity persists in localStorage', async () => {
// Verify localStorage key works (no page load needed — reuse current page)
await page.evaluate(() => localStorage.setItem('meshcore-live-heatmap-opacity', '0.6'));
const opacity = await page.evaluate(() => localStorage.getItem('meshcore-live-heatmap-opacity'));
assert(opacity === '0.6', `Live opacity should persist as "0.6" but got "${opacity}"`);
await page.evaluate(() => localStorage.removeItem('meshcore-live-heatmap-opacity'));
});
// Test 15: Customizer has separate Map and Live opacity sliders
await test('Customizer has separate map and live opacity sliders', async () => {
// Verify by checking JS source — avoids heavy page reloads that crash ARM chromium
const custJs = await page.evaluate(async () => {
const res = await fetch('/customize.js?_=' + Date.now());
return res.text();
});
assert(custJs.includes('custHeatOpacity'), 'customize.js should have map opacity slider (custHeatOpacity)');
assert(custJs.includes('custLiveHeatOpacity'), 'customize.js should have live opacity slider (custLiveHeatOpacity)');
assert(custJs.includes('meshcore-heatmap-opacity'), 'customize.js should use meshcore-heatmap-opacity key');
assert(custJs.includes('meshcore-live-heatmap-opacity'), 'customize.js should use meshcore-live-heatmap-opacity key');
// Verify labels are distinct
assert(custJs.includes('Nodes Map') || custJs.includes('nodes map') || custJs.includes('🗺'), 'Map slider should have map-related label');
assert(custJs.includes('Live Map') || custJs.includes('live map') || custJs.includes('📡'), 'Live slider should have live-related label');
});
// Summary
const passed = results.filter(r => r.pass).length;
const failed = results.filter(r => !r.pass).length;