mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 17:05:58 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dfb5c39f7 | ||
|
|
0116cd38ac | ||
|
|
0255e10746 | ||
|
|
3d7c087025 | ||
|
|
54d453d034 | ||
|
|
ca46cc6959 | ||
|
|
a01999c743 | ||
|
|
a295e5eb9c | ||
|
|
c3e97e6768 | ||
|
|
1ba33d5d04 | ||
|
|
4b0cc38adb | ||
|
|
26fca2677b | ||
|
|
3f4077c8e0 | ||
|
|
261bb54c38 | ||
|
|
bbfaded9fb | ||
|
|
051d351a01 | ||
|
|
786237e461 | ||
|
|
68d2fba54e | ||
|
|
bbaecd664a | ||
|
|
aa8feb3912 | ||
|
|
967f4def7e | ||
|
|
76ad318b15 | ||
|
|
e501b63362 | ||
|
|
1ea2152418 | ||
|
|
a9d5d2450c | ||
|
|
6f8cd2eac0 | ||
|
|
13d781fcd9 | ||
|
|
0f8e886984 | ||
|
|
9cfd452910 | ||
|
|
95ce48543c | ||
|
|
d93ff1a1e7 | ||
|
|
3102d15e45 | ||
|
|
556359e9db | ||
|
|
c47e8947c6 | ||
|
|
e76e63b80d | ||
|
|
cf3a8fe2f4 | ||
|
|
620458be8b |
47
CHANGELOG.md
47
CHANGELOG.md
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## [2.5.0] "Digital Rain" — 2026-03-22
|
||||
|
||||
### ✨ Matrix Mode — Full Cyberpunk Map Theme
|
||||
Toggle **Matrix** on the live map to transform the entire visualization:
|
||||
- **Green phosphor CRT aesthetic** — map tiles are desaturated and re-tinted through a `sepia → hue-rotate(70°) → saturate` filter chain, giving roads, coastlines, and terrain a faint green wireframe look against a dark background
|
||||
- **CRT scanline overlay** — subtle horizontal lines with a gentle flicker animation across the entire map
|
||||
- **Node markers dim to dark green** (#008a22 at 50% opacity) so they don't compete with packet animations
|
||||
- **Forces dark mode** while active (saves and restores your previous theme on toggle off)
|
||||
- **Disables heat map** automatically (incompatible visual combo)
|
||||
- **All UI panels themed** — feed panel, VCR controls, node detail all go green-on-black with monospace font
|
||||
- New markers created during Matrix mode (e.g. VCR timeline scrub) are automatically tinted
|
||||
|
||||
### ✨ Matrix Hex Flight — Packet Bytes on the Wire
|
||||
When Matrix mode is enabled, packet animations between nodes show the **actual hex bytes from the raw packet data** flowing along the path:
|
||||
- **Real packet data** — bytes come from the packet's `raw_hex` field, not random/generated
|
||||
- **White leading byte** with triple-layer green neon glow (`text-shadow: 0 0 8px, 0 0 16px, 0 0 24px`)
|
||||
- **Trailing bytes fade** from bright to dim green, shrinking in size with distance from the head
|
||||
- **Scrolls through all bytes** in the packet as it travels each hop
|
||||
- **60fps animation** via `requestAnimationFrame` with time-based interpolation (1.1s per hop)
|
||||
- **300ms fade-out** after reaching the destination node
|
||||
- Replaces the standard contrail animation; toggle off to restore normal mode
|
||||
|
||||
### ✨ Matrix Rain — Falling Packet Columns
|
||||
A separate **Rain** toggle adds a canvas-rendered overlay of falling hex byte columns, Matrix-style:
|
||||
- **Each incoming packet** spawns a column of its actual raw hex bytes falling from the top of the screen
|
||||
- **Fall distance proportional to hop count** — 4+ hops reach the bottom of the screen; a 1-hop packet barely drops. Matches the real mesh network: more hops = more propagation = longer rain trail
|
||||
- **Fall duration scales with distance** — 5 seconds for a full-screen drop, proportional for shorter
|
||||
- **Multiple observations = more rain** — each observation of a packet spawns its own column, staggered 150ms apart. A packet seen by 8 observers creates 8 simultaneous falling columns with ±1 hop variation for visual variety
|
||||
- **Leading byte is bright white** with green glow; trailing bytes progressively fade to green
|
||||
- **Entire column fades out** in the last 30% of its lifetime
|
||||
- **Canvas-rendered at 60fps** — no DOM overhead, handles hundreds of simultaneous drops
|
||||
- **Works independently or with Matrix mode** — combine both for the full effect
|
||||
- **Replay support** — the ▶ Replay button on packet detail pages now includes raw hex data so replayed packets produce rain
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Fixed null element errors in Matrix hex flight** — `getElement()` returns null when DivIcon hasn't been rendered to DOM yet during fast VCR replay
|
||||
- **Fixed animation null-guard cascade** — `pulseNode`, `animatePath`, and `drawAnimatedLine` now bail early if map layers are null (stale `setInterval` callbacks after page navigation)
|
||||
- **Fixed WS broadcast with null packet** — deduplicated observations caused `fullPacket` to be null in WebSocket broadcasts
|
||||
- **Fixed pause button crash** — was killing WS handler registration
|
||||
- **Fixed multi-select menu close handler** — null-guard for missing elements
|
||||
|
||||
### ⚡ Technical Notes
|
||||
- Matrix hex flight uses Leaflet `L.divIcon` markers for each character — the smoothness ceiling is Leaflet's DOM repositioning speed. CSS transitions were tested but caused stutter due to conflicts with Leaflet's internal transform updates.
|
||||
- Matrix Rain uses a raw `<canvas>` overlay at z-index 9998 for zero-DOM-overhead rendering. Each drop is a simple `{x, maxY, duration, bytes, startTime}` struct rendered in a single `requestAnimationFrame` loop.
|
||||
- Map tile tinting applies CSS filters to `.leaflet-tile-pane` and green overlays via `::before`/`::after` pseudo-elements on the map container (same element as `.leaflet-container`, so selectors use `.matrix-theme.leaflet-container` not descendant `.matrix-theme .leaflet-container`).
|
||||
|
||||
## [2.4.1] — 2026-03-22
|
||||
|
||||
Hotfix release for regressions introduced in v2.4.0.
|
||||
@@ -7,6 +53,7 @@ Hotfix release for regressions introduced in v2.4.0.
|
||||
### Fixed
|
||||
- Packet ingestion broken: `insert()` returned undefined after legacy table removal, causing all MQTT packets to fail silently
|
||||
- Live packet updates not working: pause button `addEventListener` on null element crashed `init()`, preventing WS handler registration
|
||||
- Pause button not toggling: event delegation was on `app` variable not in IIFE scope; moved to `document`
|
||||
- WS broadcast had null packet data when observation was deduped (2nd+ observer of same packet)
|
||||
- Multi-select filter menu close handler crashed on null `observerFilterWrap`/`typeFilterWrap` elements
|
||||
- Live map animation cleanup crashed with null `animLayer`/`pathsLayer` after navigating away (setInterval kept firing)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.0",
|
||||
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<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=1774138896">
|
||||
<link rel="stylesheet" href="style.css?v=1774164970">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css?v=1774058575">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
@@ -84,13 +84,13 @@
|
||||
<script src="hop-resolver.js?v=1774126708"></script>
|
||||
<script src="app.js?v=1774126708"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774155585"></script>
|
||||
<script src="packets.js?v=1774167561"></script>
|
||||
<script src="map.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774331200" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774135052" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774155165" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774167561" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
345
public/live.js
345
public/live.js
@@ -13,6 +13,9 @@
|
||||
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
|
||||
let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true';
|
||||
let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true';
|
||||
let matrixMode = localStorage.getItem('live-matrix-mode') === 'true';
|
||||
let matrixRain = localStorage.getItem('live-matrix-rain') === 'true';
|
||||
let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null;
|
||||
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
|
||||
let _onResize = null;
|
||||
let _navCleanup = null;
|
||||
@@ -419,6 +422,7 @@
|
||||
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
|
||||
return {
|
||||
id: pkt.id, hash: pkt.hash,
|
||||
raw: pkt.raw_hex,
|
||||
_ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
|
||||
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
|
||||
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
|
||||
@@ -631,6 +635,10 @@
|
||||
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
|
||||
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
|
||||
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
|
||||
<label><input type="checkbox" id="liveMatrixToggle" aria-describedby="matrixDesc"> Matrix</label>
|
||||
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
|
||||
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
|
||||
<span id="rainDesc" class="sr-only">Matrix rain overlay — packets fall as hex columns</span>
|
||||
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
|
||||
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
|
||||
</div>
|
||||
@@ -786,6 +794,37 @@
|
||||
applyFavoritesFilter();
|
||||
});
|
||||
|
||||
const matrixToggle = document.getElementById('liveMatrixToggle');
|
||||
matrixToggle.checked = matrixMode;
|
||||
matrixToggle.addEventListener('change', (e) => {
|
||||
matrixMode = e.target.checked;
|
||||
localStorage.setItem('live-matrix-mode', matrixMode);
|
||||
applyMatrixTheme(matrixMode);
|
||||
if (matrixMode) {
|
||||
hideHeatMap();
|
||||
const ht = document.getElementById('liveHeatToggle');
|
||||
if (ht) { ht.checked = false; ht.disabled = true; }
|
||||
} else {
|
||||
const ht = document.getElementById('liveHeatToggle');
|
||||
if (ht) { ht.disabled = false; }
|
||||
}
|
||||
});
|
||||
applyMatrixTheme(matrixMode);
|
||||
if (matrixMode) {
|
||||
hideHeatMap();
|
||||
const ht = document.getElementById('liveHeatToggle');
|
||||
if (ht) { ht.checked = false; ht.disabled = true; }
|
||||
}
|
||||
|
||||
const rainToggle = document.getElementById('liveMatrixRainToggle');
|
||||
rainToggle.checked = matrixRain;
|
||||
rainToggle.addEventListener('change', (e) => {
|
||||
matrixRain = e.target.checked;
|
||||
localStorage.setItem('live-matrix-rain', matrixRain);
|
||||
if (matrixRain) startMatrixRain(); else stopMatrixRain();
|
||||
});
|
||||
if (matrixRain) startMatrixRain();
|
||||
|
||||
// Feed show/hide
|
||||
const feedEl = document.getElementById('liveFeed');
|
||||
// Keyboard support for feed items (event delegation)
|
||||
@@ -1287,6 +1326,15 @@
|
||||
marker._baseColor = color;
|
||||
marker._baseSize = size;
|
||||
nodeMarkers[n.public_key] = marker;
|
||||
|
||||
// Apply matrix tint if active
|
||||
if (matrixMode) {
|
||||
marker._matrixPrevColor = color;
|
||||
marker._baseColor = '#008a22';
|
||||
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
|
||||
glow.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
@@ -1347,6 +1395,14 @@
|
||||
|
||||
playSound(typeName);
|
||||
addFeedItem(icon, typeName, payload, hops, color, pkt);
|
||||
addRainDrop(pkt);
|
||||
// Spawn extra rain columns for multiple observations with varied hop counts
|
||||
const obsCount = pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1;
|
||||
const baseHops = (pkt.decoded?.path?.hops || []).length || 1;
|
||||
for (let i = 1; i < obsCount; i++) {
|
||||
const variedHops = Math.max(1, baseHops + Math.floor(Math.random() * 3) - 1); // ±1 hop
|
||||
setTimeout(() => addRainDrop(pkt, variedHops), i * 150);
|
||||
}
|
||||
|
||||
// Favorites filter: skip animation if packet doesn't involve a favorited node
|
||||
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
|
||||
@@ -1365,7 +1421,7 @@
|
||||
const hopPositions = resolveHopPositions(hops, payload);
|
||||
if (hopPositions.length === 0) return;
|
||||
if (hopPositions.length === 1) { pulseNode(hopPositions[0].key, hopPositions[0].pos, typeName); return; }
|
||||
animatePath(hopPositions, typeName, color);
|
||||
animatePath(hopPositions, typeName, color, pkt.raw);
|
||||
}
|
||||
|
||||
function animateRealisticPropagation(packets) {
|
||||
@@ -1386,6 +1442,8 @@
|
||||
if (showOnlyFavorites && !packets.some(p => packetInvolvesFavorite(p))) return;
|
||||
|
||||
playSound(typeName);
|
||||
// Rain drop per observation in the group
|
||||
packets.forEach((p, i) => setTimeout(() => addRainDrop(p), i * 150));
|
||||
|
||||
// Ensure ADVERT nodes appear
|
||||
for (const pkt of packets) {
|
||||
@@ -1452,7 +1510,7 @@
|
||||
|
||||
// Animate all paths simultaneously
|
||||
for (const hopPositions of allPaths) {
|
||||
animatePath(hopPositions, typeName, color);
|
||||
animatePath(hopPositions, typeName, color, first.raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1554,7 +1612,8 @@
|
||||
return raw.filter(h => h.pos != null);
|
||||
}
|
||||
|
||||
function animatePath(hopPositions, typeName, color) {
|
||||
function animatePath(hopPositions, typeName, color, rawHex) {
|
||||
if (!animLayer || !pathsLayer) return;
|
||||
activeAnims++;
|
||||
document.getElementById('liveAnimCount').textContent = activeAnims;
|
||||
let hopIndex = 0;
|
||||
@@ -1590,7 +1649,7 @@
|
||||
const nextGhost = hopPositions[hopIndex + 1].ghost;
|
||||
const lineColor = (isGhost || nextGhost) ? '#94a3b8' : color;
|
||||
const lineOpacity = (isGhost || nextGhost) ? 0.3 : undefined;
|
||||
drawAnimatedLine(hp.pos, nextPos, lineColor, () => { hopIndex++; nextHop(); }, lineOpacity);
|
||||
drawAnimatedLine(hp.pos, nextPos, lineColor, () => { hopIndex++; nextHop(); }, lineOpacity, rawHex);
|
||||
} else {
|
||||
if (!isGhost) pulseNode(hp.key, hp.pos, typeName);
|
||||
hopIndex++; nextHop();
|
||||
@@ -1600,6 +1659,7 @@
|
||||
}
|
||||
|
||||
function pulseNode(key, pos, typeName) {
|
||||
if (!animLayer || !nodesLayer) return;
|
||||
if (!nodeMarkers[key]) {
|
||||
const ghost = L.circleMarker(pos, {
|
||||
radius: 5, fillColor: '#6b7280', fillOpacity: 0.3, color: '#fff', weight: 0.5, opacity: 0.2
|
||||
@@ -1652,7 +1712,281 @@
|
||||
nodeActivity[key] = (nodeActivity[key] || 0) + 1;
|
||||
}
|
||||
|
||||
function drawAnimatedLine(from, to, color, onComplete, overrideOpacity) {
|
||||
// === Matrix Rain System ===
|
||||
function startMatrixRain() {
|
||||
const container = document.getElementById('liveMap');
|
||||
if (!container || rainCanvas) return;
|
||||
rainCanvas = document.createElement('canvas');
|
||||
rainCanvas.id = 'matrixRainCanvas';
|
||||
rainCanvas.style.cssText = 'position:absolute;inset:0;z-index:9998;pointer-events:none;';
|
||||
rainCanvas.width = container.clientWidth;
|
||||
rainCanvas.height = container.clientHeight;
|
||||
container.appendChild(rainCanvas);
|
||||
rainCtx = rainCanvas.getContext('2d');
|
||||
rainDrops = [];
|
||||
|
||||
// Resize handler
|
||||
rainCanvas._resizeHandler = () => {
|
||||
if (rainCanvas) {
|
||||
rainCanvas.width = container.clientWidth;
|
||||
rainCanvas.height = container.clientHeight;
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', rainCanvas._resizeHandler);
|
||||
|
||||
function renderRain(now) {
|
||||
if (!rainCanvas || !rainCtx) return;
|
||||
const W = rainCanvas.width, H = rainCanvas.height;
|
||||
rainCtx.clearRect(0, 0, W, H);
|
||||
|
||||
for (let i = rainDrops.length - 1; i >= 0; i--) {
|
||||
const drop = rainDrops[i];
|
||||
const elapsed = now - drop.startTime;
|
||||
const progress = Math.min(1, elapsed / drop.duration);
|
||||
|
||||
// Head position
|
||||
const headY = progress * drop.maxY;
|
||||
// Trail shows all packet bytes, scrolling through them
|
||||
const CHAR_H = 18;
|
||||
const VISIBLE_CHARS = drop.bytes.length; // show all bytes
|
||||
const trailPx = VISIBLE_CHARS * CHAR_H;
|
||||
|
||||
// Scroll offset — cycles through all bytes over the drop lifetime
|
||||
const scrollOffset = Math.floor(progress * drop.bytes.length);
|
||||
|
||||
for (let c = 0; c < VISIBLE_CHARS; c++) {
|
||||
const charY = headY - c * CHAR_H;
|
||||
if (charY < -CHAR_H || charY > H) continue;
|
||||
|
||||
const byteIdx = (scrollOffset + c) % drop.bytes.length;
|
||||
|
||||
// Fade: head is bright, tail fades
|
||||
const fadeFactor = 1 - (c / VISIBLE_CHARS);
|
||||
// Also fade entire drop near end of life
|
||||
const lifeFade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1;
|
||||
const alpha = Math.max(0, fadeFactor * lifeFade);
|
||||
|
||||
if (c === 0) {
|
||||
rainCtx.font = 'bold 16px "Courier New", monospace';
|
||||
rainCtx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
||||
rainCtx.shadowColor = '#00ff41';
|
||||
rainCtx.shadowBlur = 12;
|
||||
} else {
|
||||
rainCtx.font = '14px "Courier New", monospace';
|
||||
rainCtx.fillStyle = `rgba(0, 255, 65, ${alpha * 0.8})`;
|
||||
rainCtx.shadowColor = '#00ff41';
|
||||
rainCtx.shadowBlur = 4;
|
||||
}
|
||||
|
||||
rainCtx.fillText(drop.bytes[byteIdx], drop.x, charY);
|
||||
}
|
||||
|
||||
// Remove finished drops
|
||||
if (progress >= 1) {
|
||||
rainDrops.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
rainCtx.shadowBlur = 0; // reset
|
||||
rainRAF = requestAnimationFrame(renderRain);
|
||||
}
|
||||
rainRAF = requestAnimationFrame(renderRain);
|
||||
}
|
||||
|
||||
function stopMatrixRain() {
|
||||
if (rainRAF) { cancelAnimationFrame(rainRAF); rainRAF = null; }
|
||||
if (rainCanvas) {
|
||||
window.removeEventListener('resize', rainCanvas._resizeHandler);
|
||||
rainCanvas.remove();
|
||||
rainCanvas = null;
|
||||
rainCtx = null;
|
||||
}
|
||||
rainDrops = [];
|
||||
}
|
||||
|
||||
function addRainDrop(pkt, hopOverride) {
|
||||
if (!rainCanvas || !matrixRain) return;
|
||||
const rawHex = pkt.raw || pkt.raw_hex || (pkt.packet && pkt.packet.raw_hex) || '';
|
||||
if (!rawHex) return;
|
||||
const decoded = pkt.decoded || {};
|
||||
const hops = decoded.path?.hops || [];
|
||||
const hopCount = hopOverride || Math.max(1, hops.length);
|
||||
const bytes = [];
|
||||
for (let i = 0; i < rawHex.length; i += 2) {
|
||||
bytes.push(rawHex.slice(i, i + 2).toUpperCase());
|
||||
}
|
||||
if (bytes.length === 0) return;
|
||||
|
||||
const W = rainCanvas.width;
|
||||
const H = rainCanvas.height;
|
||||
// Fall distance proportional to hops: 8+ hops = full height
|
||||
const maxY = H * Math.min(1, hopCount / 4);
|
||||
// Duration: 5s for full height, proportional for shorter
|
||||
const duration = 5000 * (maxY / H);
|
||||
|
||||
// Random x position, avoid edges
|
||||
const x = 20 + Math.random() * (W - 40);
|
||||
|
||||
rainDrops.push({
|
||||
x,
|
||||
maxY,
|
||||
duration,
|
||||
bytes,
|
||||
hops: hopCount,
|
||||
startTime: performance.now()
|
||||
});
|
||||
}
|
||||
|
||||
function applyMatrixTheme(on) {
|
||||
const container = document.getElementById('liveMap');
|
||||
if (!container) return;
|
||||
if (on) {
|
||||
// Force dark mode, save previous theme to restore later
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (currentTheme !== 'dark') {
|
||||
container.dataset.matrixPrevTheme = currentTheme || 'light';
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
const dt = document.getElementById('darkModeToggle');
|
||||
if (dt) { dt.textContent = '🌙'; dt.disabled = true; }
|
||||
} else {
|
||||
const dt = document.getElementById('darkModeToggle');
|
||||
if (dt) dt.disabled = true;
|
||||
}
|
||||
container.classList.add('matrix-theme');
|
||||
if (!document.getElementById('matrixScanlines')) {
|
||||
const scanlines = document.createElement('div');
|
||||
scanlines.id = 'matrixScanlines';
|
||||
scanlines.className = 'matrix-scanlines';
|
||||
container.appendChild(scanlines);
|
||||
}
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
marker._matrixPrevColor = marker._baseColor;
|
||||
marker._baseColor = '#008a22';
|
||||
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
|
||||
}
|
||||
} else {
|
||||
container.classList.remove('matrix-theme');
|
||||
const scanlines = document.getElementById('matrixScanlines');
|
||||
if (scanlines) scanlines.remove();
|
||||
// Restore previous theme
|
||||
const prevTheme = container.dataset.matrixPrevTheme;
|
||||
if (prevTheme) {
|
||||
document.documentElement.setAttribute('data-theme', prevTheme);
|
||||
localStorage.setItem('meshcore-theme', prevTheme);
|
||||
const dt = document.getElementById('darkModeToggle');
|
||||
if (dt) { dt.textContent = prevTheme === 'dark' ? '🌙' : '☀️'; dt.disabled = false; }
|
||||
delete container.dataset.matrixPrevTheme;
|
||||
} else {
|
||||
const dt = document.getElementById('darkModeToggle');
|
||||
if (dt) dt.disabled = false;
|
||||
}
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
if (marker._matrixPrevColor) {
|
||||
marker._baseColor = marker._matrixPrevColor;
|
||||
marker.setStyle({ fillColor: marker._matrixPrevColor, color: '#fff', fillOpacity: 0.85, opacity: 1 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: marker._matrixPrevColor });
|
||||
delete marker._matrixPrevColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawMatrixLine(from, to, color, onComplete, rawHex) {
|
||||
if (!animLayer || !pathsLayer) { if (onComplete) onComplete(); return; }
|
||||
const hexStr = rawHex || '';
|
||||
const bytes = [];
|
||||
for (let i = 0; i < hexStr.length; i += 2) {
|
||||
bytes.push(hexStr.slice(i, i + 2).toUpperCase());
|
||||
}
|
||||
if (bytes.length === 0) {
|
||||
for (let i = 0; i < 16; i++) bytes.push(((Math.random() * 256) | 0).toString(16).padStart(2, '0').toUpperCase());
|
||||
}
|
||||
|
||||
const matrixGreen = '#00ff41';
|
||||
const TRAIL_LEN = Math.min(6, bytes.length);
|
||||
const DURATION_MS = 1100; // total hop duration
|
||||
const CHAR_INTERVAL = 0.06; // spawn a char every 6% of progress
|
||||
const charMarkers = [];
|
||||
let nextCharAt = CHAR_INTERVAL;
|
||||
let byteIdx = 0;
|
||||
|
||||
const trail = L.polyline([from], {
|
||||
color: matrixGreen, weight: 1.5, opacity: 0.2, lineCap: 'round'
|
||||
}).addTo(pathsLayer);
|
||||
|
||||
const trailCoords = [from];
|
||||
const startTime = performance.now();
|
||||
|
||||
function tick(now) {
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(1, elapsed / DURATION_MS);
|
||||
const lat = from[0] + (to[0] - from[0]) * t;
|
||||
const lon = from[1] + (to[1] - from[1]) * t;
|
||||
trailCoords.push([lat, lon]);
|
||||
trail.setLatLngs(trailCoords);
|
||||
|
||||
// Remove old chars beyond trail length
|
||||
while (charMarkers.length > TRAIL_LEN) {
|
||||
const old = charMarkers.shift();
|
||||
try { animLayer.removeLayer(old.marker); } catch {}
|
||||
}
|
||||
|
||||
// Fade existing chars
|
||||
for (let i = 0; i < charMarkers.length; i++) {
|
||||
const age = charMarkers.length - i;
|
||||
const op = Math.max(0.15, 1 - (age / TRAIL_LEN) * 0.7);
|
||||
const size = Math.max(10, 16 - age * 1.5);
|
||||
const el = charMarkers[i].marker.getElement();
|
||||
if (el) { el.style.opacity = op; el.style.fontSize = size + 'px'; }
|
||||
}
|
||||
|
||||
// Spawn new char at intervals
|
||||
if (t >= nextCharAt && t < 1) {
|
||||
nextCharAt += CHAR_INTERVAL;
|
||||
const charEl = L.marker([lat, lon], {
|
||||
icon: L.divIcon({
|
||||
className: 'matrix-char',
|
||||
html: `<span style="color:#fff;font-family:'Courier New',monospace;font-size:16px;font-weight:bold;text-shadow:0 0 8px ${matrixGreen},0 0 16px ${matrixGreen},0 0 24px ${matrixGreen}60;pointer-events:none">${bytes[byteIdx % bytes.length]}</span>`,
|
||||
iconSize: [24, 18],
|
||||
iconAnchor: [12, 9]
|
||||
}),
|
||||
interactive: false
|
||||
}).addTo(animLayer);
|
||||
charMarkers.push({ marker: charEl });
|
||||
byteIdx++;
|
||||
}
|
||||
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
// Fade out
|
||||
const fadeStart = performance.now();
|
||||
function fadeOut(now) {
|
||||
const ft = Math.min(1, (now - fadeStart) / 300);
|
||||
if (ft >= 1) {
|
||||
for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch {}
|
||||
try { pathsLayer.removeLayer(trail); } catch {}
|
||||
charMarkers.length = 0;
|
||||
} else {
|
||||
const op = 1 - ft;
|
||||
for (const cm of charMarkers) {
|
||||
const el = cm.marker.getElement(); if (el) el.style.opacity = op * 0.5;
|
||||
}
|
||||
trail.setStyle({ opacity: op * 0.15 });
|
||||
requestAnimationFrame(fadeOut);
|
||||
}
|
||||
}
|
||||
setTimeout(() => requestAnimationFrame(fadeOut), 150);
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function drawAnimatedLine(from, to, color, onComplete, overrideOpacity, rawHex) {
|
||||
if (!animLayer || !pathsLayer) { if (onComplete) onComplete(); return; }
|
||||
if (matrixMode) return drawMatrixLine(from, to, color, onComplete, rawHex);
|
||||
const steps = 20;
|
||||
const latStep = (to[0] - from[0]) / steps;
|
||||
const lonStep = (to[1] - from[1]) / steps;
|
||||
@@ -1862,6 +2196,7 @@
|
||||
_navCleanup = null;
|
||||
}
|
||||
nodesLayer = pathsLayer = animLayer = heatLayer = null;
|
||||
stopMatrixRain();
|
||||
nodeMarkers = {}; nodeData = {};
|
||||
recentPaths = [];
|
||||
packetCount = 0; activeAnims = 0;
|
||||
|
||||
@@ -1202,7 +1202,7 @@
|
||||
let oDec;
|
||||
try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; }
|
||||
replayPackets.push({
|
||||
id: o.id, hash: pkt.hash,
|
||||
id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex,
|
||||
_ts: new Date(o.timestamp).getTime(),
|
||||
decoded: { header: { payloadTypeName: typeName }, payload: oDec, path: { hops: oPath } },
|
||||
snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id)
|
||||
@@ -1210,7 +1210,7 @@
|
||||
}
|
||||
} else {
|
||||
replayPackets.push({
|
||||
id: pkt.id, hash: pkt.hash,
|
||||
id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex,
|
||||
_ts: new Date(pkt.timestamp).getTime(),
|
||||
decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } },
|
||||
snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id)
|
||||
|
||||
@@ -1530,3 +1530,70 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.multi-select-item:hover { background: var(--row-hover, #f5f5f5); }
|
||||
|
||||
.chan-tag { background: var(--accent, #3b82f6); color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 0.9em; font-weight: 600; }
|
||||
|
||||
/* Matrix mode hex animation */
|
||||
.matrix-char { background: none !important; border: none !important; }
|
||||
.matrix-char span { display: block; text-align: center; white-space: nowrap; line-height: 1; }
|
||||
|
||||
/* === Matrix Theme === */
|
||||
.matrix-theme .leaflet-tile-pane {
|
||||
filter: brightness(1.1) contrast(1.2) sepia(0.6) hue-rotate(70deg) saturate(2);
|
||||
}
|
||||
.matrix-theme.leaflet-container::before {
|
||||
content: ''; position: absolute; inset: 0; z-index: 401;
|
||||
background: rgba(0, 60, 10, 0.35); mix-blend-mode: multiply; pointer-events: none;
|
||||
}
|
||||
.matrix-theme.leaflet-container::after {
|
||||
content: ''; position: absolute; inset: 0; z-index: 402;
|
||||
background: rgba(0, 255, 65, 0.06); mix-blend-mode: screen; pointer-events: none;
|
||||
}
|
||||
.matrix-theme { background: #000 !important; }
|
||||
.matrix-theme .leaflet-control-zoom a { background: #0a0a0a !important; color: #00ff41 !important; border-color: #00ff4130 !important; }
|
||||
.matrix-theme .leaflet-control-attribution { background: rgba(0,0,0,0.8) !important; color: #00ff4180 !important; }
|
||||
.matrix-theme .leaflet-control-attribution a { color: #00ff4160 !important; }
|
||||
|
||||
/* Scanline overlay */
|
||||
.matrix-scanlines {
|
||||
position: absolute; inset: 0; z-index: 9999; pointer-events: none;
|
||||
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,255,65,0.02) 2px, rgba(0,255,65,0.02) 4px);
|
||||
animation: matrix-flicker 0.15s infinite alternate;
|
||||
}
|
||||
@keyframes matrix-flicker {
|
||||
from { opacity: 0.8; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Feed panel in matrix mode */
|
||||
.matrix-theme .live-feed {
|
||||
background: rgba(0, 10, 0, 0.92) !important;
|
||||
border-color: #00ff4130 !important;
|
||||
font-family: 'Courier New', monospace !important;
|
||||
}
|
||||
.matrix-theme .live-feed .live-feed-item { color: #00ff41 !important; border-color: #00ff4115 !important; }
|
||||
.matrix-theme .live-feed .live-feed-item:hover { background: rgba(0,255,65,0.08) !important; }
|
||||
.matrix-theme .live-feed .feed-hide-btn { color: #00ff41 !important; }
|
||||
|
||||
/* Controls in matrix mode */
|
||||
.matrix-theme .live-controls {
|
||||
background: rgba(0, 10, 0, 0.9) !important;
|
||||
border-color: #00ff4130 !important;
|
||||
color: #00ff41 !important;
|
||||
}
|
||||
.matrix-theme .live-controls label,
|
||||
.matrix-theme .live-controls span,
|
||||
.matrix-theme .live-controls .lcd-display { color: #00ff41 !important; }
|
||||
.matrix-theme .live-controls button { color: #00ff41 !important; border-color: #00ff4130 !important; }
|
||||
.matrix-theme .live-controls input[type="range"] { accent-color: #00ff41; }
|
||||
|
||||
/* Node detail panel in matrix mode */
|
||||
.matrix-theme .live-node-detail {
|
||||
background: rgba(0, 10, 0, 0.95) !important;
|
||||
border-color: #00ff4130 !important;
|
||||
color: #00ff41 !important;
|
||||
}
|
||||
.matrix-theme .live-node-detail a { color: #00ff41 !important; }
|
||||
.matrix-theme .live-node-detail .feed-hide-btn { color: #00ff41 !important; }
|
||||
|
||||
/* Node labels on map */
|
||||
.matrix-theme .node-label { color: #00ff41 !important; text-shadow: 0 0 4px #00ff41 !important; }
|
||||
.matrix-theme .leaflet-marker-icon:not(.matrix-char) { filter: hue-rotate(90deg) saturate(1) brightness(0.35) opacity(0.5); }
|
||||
|
||||
Reference in New Issue
Block a user