VCR timeline time tooltip, real packet timestamps in feed, replay-from-packets button

- Timeline scrubber shows time on hover (tooltip follows cursor)
- Feed items display actual packet timestamps, not render time
- Packet detail panel has 'Replay on Live Map' button → navigates to live view and animates the packet
This commit is contained in:
you
2026-03-18 21:36:35 +00:00
parent e9e6406463
commit c679205c5c
4 changed files with 83 additions and 3 deletions

View File

@@ -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; }

View File

@@ -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 @@
<div class="vcr-timeline-container">
<canvas id="vcrTimeline" class="vcr-timeline"></canvas>
<div id="vcrPlayhead" class="vcr-playhead"></div>
<div id="vcrTimeTooltip" class="vcr-time-tooltip hidden"></div>
</div>
</div>
<div id="vcrPrompt" class="vcr-prompt hidden"></div>
@@ -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 @@
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
`;
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.prepend(item);

View File

@@ -395,7 +395,25 @@
<div class="hex-dump">${createColoredHexDump(pkt.raw_hex, ranges)}</div>` : ''}
${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
<button class="replay-live-btn" title="Replay this packet on the live map">▶ Replay on Live Map</button>
`;
// 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) {

View File

@@ -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); }