diff --git a/public/live.css b/public/live.css index f2ac89e..2ed8432 100644 --- a/public/live.css +++ b/public/live.css @@ -544,3 +544,20 @@ .vcr-btn { padding: 3px 6px; font-size: 0.7rem; } .vcr-scope-btn { font-size: 0.6rem; padding: 1px 4px; } } + +/* Timeline time tooltip */ +.vcr-time-tooltip { + position: absolute; + top: -24px; + transform: translateX(-50%); + background: rgba(0,0,0,0.85); + color: #e2e8f0; + font-size: 0.65rem; + font-weight: 600; + padding: 2px 6px; + border-radius: 3px; + white-space: nowrap; + pointer-events: none; + z-index: 10; +} +.vcr-time-tooltip.hidden { display: none; } diff --git a/public/live.js b/public/live.js index d17ed32..a8733e1 100644 --- a/public/live.js +++ b/public/live.js @@ -232,6 +232,7 @@ const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN'; return { id: pkt.id, hash: pkt.hash, + _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 }; @@ -239,7 +240,8 @@ // Buffer a packet from WS function bufferPacket(pkt) { - const entry = { ts: Date.now(), pkt }; + pkt._ts = Date.now(); + const entry = { ts: pkt._ts, pkt }; VCR.buffer.push(entry); // Keep buffer capped at ~2000 if (VCR.buffer.length > 2000) VCR.buffer.splice(0, 500); @@ -419,6 +421,7 @@
+
@@ -519,6 +522,20 @@ // Timeline click to scrub document.getElementById('vcrTimeline').addEventListener('click', handleTimelineClick); + // Timeline hover — show time tooltip + const timelineEl = document.getElementById('vcrTimeline'); + const timeTooltip = document.getElementById('vcrTimeTooltip'); + timelineEl.addEventListener('mousemove', (e) => { + const rect = timelineEl.getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope; + const d = new Date(ts); + timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); + timeTooltip.style.left = (e.clientX - rect.left) + 'px'; + timeTooltip.classList.remove('hidden'); + }); + timelineEl.addEventListener('mouseleave', () => { timeTooltip.classList.add('hidden'); }); + // Refresh timeline periodically setInterval(updateTimeline, 5000); @@ -538,6 +555,16 @@ livePage.addEventListener('click', showNav); } showNav(); + + // Check for replay packet from packets page + const replayData = sessionStorage.getItem('replay-packet'); + if (replayData) { + sessionStorage.removeItem('replay-packet'); + try { + const pkt = JSON.parse(replayData); + setTimeout(() => animatePacket(pkt), 1500); // let map load first + } catch {} + } } function injectSVGFilters() { @@ -622,7 +649,8 @@ const pkts = (data.packets || []).reverse(); pkts.forEach((pkt, i) => { const livePkt = dbPacketToLive(pkt); - const ts = new Date(pkt.timestamp || pkt.created_at).getTime(); + livePkt._ts = new Date(pkt.timestamp || pkt.created_at).getTime(); + const ts = livePkt._ts; VCR.buffer.push({ ts, pkt: livePkt }); setTimeout(() => animatePacket(livePkt), i * 400); }); @@ -902,7 +930,7 @@ ${typeName} ${hopStr} ${escapeHtml(preview)} - ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} + ${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} `; item.addEventListener('click', () => showFeedCard(item, pkt, color)); feed.prepend(item); diff --git a/public/packets.js b/public/packets.js index 3367e35..6f1dd73 100644 --- a/public/packets.js +++ b/public/packets.js @@ -395,7 +395,25 @@
${createColoredHexDump(pkt.raw_hex, ranges)}
` : ''} ${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)} + + `; + + // Wire up replay button + const replayBtn = panel.querySelector('.replay-live-btn'); + if (replayBtn) { + replayBtn.addEventListener('click', () => { + // Store packet in sessionStorage for the live page to pick up + const livePkt = { + id: pkt.id, hash: pkt.hash, + _ts: new Date(pkt.timestamp).getTime(), + decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } }, + snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name + }; + sessionStorage.setItem('replay-packet', JSON.stringify(livePkt)); + window.location.hash = '#/live'; + }); + } } function escapeHtml(s) { diff --git a/public/style.css b/public/style.css index 9242d39..a5089bc 100644 --- a/public/style.css +++ b/public/style.css @@ -1067,3 +1067,20 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .node-qr svg { max-width: 140px; border-radius: 4px; } [data-theme="dark"] .node-qr svg rect[fill="#ffffff"] { fill: var(--card-bg); } [data-theme="dark"] .node-qr svg rect[fill="#000000"] { fill: var(--text); } + +/* Replay on Live Map button in packet detail */ +.replay-live-btn { + display: block; + width: 100%; + margin-top: 12px; + padding: 8px 16px; + background: rgba(168, 85, 247, 0.15); + border: 1px solid rgba(168, 85, 247, 0.3); + color: #c084fc; + font-size: 0.85rem; + font-weight: 600; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} +.replay-live-btn:hover { background: rgba(168, 85, 247, 0.3); }