mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-26 22:54:04 +00:00
fix: VCR timeline tooltip on touch devices
Add touchmove/touchend handlers to show the time tooltip during touch scrubbing on the timeline, mirroring the existing mousemove behavior. closes #19 closes #27 closes #54 closes #57 closes #58 closes #59 closes #60 closes #61 closes #62 closes #63 closes #64 Additional fixes in this commit: - #27: Add drag resize handle on feed panel right edge, persist width to localStorage - #54: Add aria-describedby to heat/ghost toggles with sr-only descriptions - #57: Refactor legend to use semantic ul/li with descriptive text, h3 headings - #58: Wrap scope buttons in role=radiogroup, add role=radio and aria-checked - #59: Add role=alertdialog to VCR prompt, auto-focus first button on show - #60: Add legend toggle button visible on mobile to show/hide legend overlay - #61: Position feed detail card as full-width bottom sheet on mobile - #62: Add pin button to nav bar to prevent auto-hide - #63: Adjust VCR.playhead when buffer is spliced to prevent stale indices - #64: Standardize fetch limits to 2000 for both vcrRewind and vcrReplayFromTs
This commit is contained in:
@@ -188,6 +188,16 @@
|
||||
text-transform: uppercase;
|
||||
color: #4b5563;
|
||||
margin-bottom: 2px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.legend-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
@@ -244,9 +254,26 @@
|
||||
@media (max-width: 640px) {
|
||||
.live-feed { width: calc(100vw - 24px); max-height: 180px; }
|
||||
.live-legend { display: none; }
|
||||
.live-legend.legend-mobile-hidden { display: none !important; }
|
||||
.legend-toggle-btn { display: block !important; }
|
||||
.live-legend:not(.legend-mobile-hidden) { display: flex !important; }
|
||||
.live-header { flex-wrap: wrap; gap: 8px; }
|
||||
.live-stats-row { flex-wrap: wrap; }
|
||||
.live-header { flex-wrap: wrap; gap: 6px; }
|
||||
/* Feed detail card as bottom sheet on mobile (#61) */
|
||||
.feed-detail-card {
|
||||
position: fixed !important;
|
||||
right: 0 !important;
|
||||
left: 0 !important;
|
||||
bottom: 72px !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
border-radius: 10px 10px 0 0 !important;
|
||||
animation: slideUp 0.2s ease-out !important;
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
}
|
||||
|
||||
/* Feed item hover */
|
||||
@@ -620,3 +647,73 @@
|
||||
z-index: 10;
|
||||
}
|
||||
.vcr-time-tooltip.hidden { display: none; }
|
||||
|
||||
/* Screen-reader only text */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Legend toggle button for mobile (#60) */
|
||||
.legend-toggle-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 82px;
|
||||
right: 12px;
|
||||
z-index: 500;
|
||||
background: rgba(6,6,18,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
color: #9ca3af;
|
||||
font-size: 18px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.legend-toggle-btn:hover { color: #fff; border-color: rgba(59,130,246,0.4); }
|
||||
|
||||
/* Feed resize handle (#27) */
|
||||
.feed-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
.feed-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 2px;
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.feed-resize-handle:hover::after { background: rgba(59,130,246,0.5); }
|
||||
|
||||
/* Nav pin button (#62) */
|
||||
.nav-pin-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-pin-btn:hover { opacity: 0.8; }
|
||||
.nav-pin-btn.pinned { opacity: 1; filter: drop-shadow(0 0 4px rgba(59,130,246,0.5)); }
|
||||
|
||||
+116
-27
@@ -121,7 +121,7 @@
|
||||
loadNodes(targetTs);
|
||||
|
||||
// Fetch ALL packets from scrub point to now (no limit, no until)
|
||||
fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(fetchFrom)}`)
|
||||
fetch(`/api/packets?limit=2000&grouped=false&since=${encodeURIComponent(fetchFrom)}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const pkts = (data.packets || []).reverse(); // chronological order
|
||||
@@ -146,6 +146,8 @@
|
||||
function showVCRPrompt(count) {
|
||||
const prompt = document.getElementById('vcrPrompt');
|
||||
if (!prompt) return;
|
||||
prompt.setAttribute('role', 'alertdialog');
|
||||
prompt.setAttribute('aria-label', 'Missed packets prompt');
|
||||
prompt.innerHTML = `
|
||||
<span>You missed <strong>${count}</strong> packets.</span>
|
||||
<button id="vcrPromptReplay" class="vcr-prompt-btn">▶ Replay</button>
|
||||
@@ -160,6 +162,8 @@
|
||||
prompt.classList.add('hidden');
|
||||
vcrResumeLive();
|
||||
});
|
||||
// Focus first button for keyboard users (#59)
|
||||
document.getElementById('vcrPromptReplay').focus();
|
||||
}
|
||||
|
||||
function vcrReplayMissed() {
|
||||
@@ -176,7 +180,7 @@
|
||||
// Fetch packets from DB for the time window
|
||||
const now = Date.now();
|
||||
const from = new Date(now - ms).toISOString();
|
||||
fetch(`/api/packets?limit=200&grouped=false&since=${encodeURIComponent(from)}`)
|
||||
fetch(`/api/packets?limit=2000&grouped=false&since=${encodeURIComponent(from)}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const pkts = (data.packets || []).reverse(); // oldest first
|
||||
@@ -383,8 +387,14 @@
|
||||
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);
|
||||
// Keep buffer capped at ~2000 — adjust playhead to avoid stale indices (#63)
|
||||
if (VCR.buffer.length > 2000) {
|
||||
const trimCount = 500;
|
||||
VCR.buffer.splice(0, trimCount);
|
||||
if (VCR.playhead >= 0) {
|
||||
VCR.playhead = Math.max(0, VCR.playhead - trimCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (VCR.mode === 'LIVE') {
|
||||
animatePacket(pkt);
|
||||
@@ -538,26 +548,33 @@
|
||||
</div>
|
||||
<button class="live-sound-btn" id="liveSoundBtn" title="Toggle sound">🔇</button>
|
||||
<div class="live-toggles">
|
||||
<label><input type="checkbox" id="liveHeatToggle" checked> Heat</label>
|
||||
<label><input type="checkbox" id="liveGhostToggle" checked> Ghosts</label>
|
||||
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
|
||||
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
|
||||
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
|
||||
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-overlay live-feed" id="liveFeed">
|
||||
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
|
||||
</div>
|
||||
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
|
||||
<div class="live-overlay live-legend">
|
||||
<div class="legend-title">PACKET TYPES</div>
|
||||
<div><span class="live-dot" style="background:#22c55e"></span> Advert</div>
|
||||
<div><span class="live-dot" style="background:#3b82f6"></span> Message</div>
|
||||
<div><span class="live-dot" style="background:#f59e0b"></span> Direct</div>
|
||||
<div><span class="live-dot" style="background:#a855f7"></span> Request</div>
|
||||
<div><span class="live-dot" style="background:#ec4899"></span> Trace</div>
|
||||
<div class="legend-title" style="margin-top:8px">NODE ROLES</div>
|
||||
<div><span class="live-dot" style="background:#3b82f6"></span> Repeater</div>
|
||||
<div><span class="live-dot" style="background:#06b6d4"></span> Companion</div>
|
||||
<div><span class="live-dot" style="background:#a855f7"></span> Room</div>
|
||||
<div><span class="live-dot" style="background:#f59e0b"></span> Sensor</div>
|
||||
<button class="legend-toggle-btn hidden" id="legendToggleBtn" aria-label="Show legend" title="Show legend">🎨</button>
|
||||
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
|
||||
<h3 class="legend-title">PACKET TYPES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:#22c55e" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Message — Group text</li>
|
||||
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Direct — Direct message</li>
|
||||
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Request — Data request</li>
|
||||
<li><span class="live-dot" style="background:#ec4899" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
</ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Repeater</li>
|
||||
<li><span class="live-dot" style="background:#06b6d4" aria-hidden="true"></span> Companion</li>
|
||||
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Room</li>
|
||||
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Sensor</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- VCR Bar -->
|
||||
@@ -571,11 +588,11 @@
|
||||
<div id="vcrMode" class="vcr-mode vcr-mode-live"><span class="vcr-live-dot"></span> LIVE</div>
|
||||
</div>
|
||||
<div class="vcr-timeline-wrap">
|
||||
<div class="vcr-scope-btns">
|
||||
<button class="vcr-scope-btn active" data-scope="3600000" aria-label="Scope 1 hour">1h</button>
|
||||
<button class="vcr-scope-btn" data-scope="21600000" aria-label="Scope 6 hours">6h</button>
|
||||
<button class="vcr-scope-btn" data-scope="43200000" aria-label="Scope 12 hours">12h</button>
|
||||
<button class="vcr-scope-btn" data-scope="86400000" aria-label="Scope 24 hours">24h</button>
|
||||
<div class="vcr-scope-btns" role="radiogroup" aria-label="Timeline scope">
|
||||
<button class="vcr-scope-btn active" data-scope="3600000" role="radio" aria-checked="true" aria-label="Scope 1 hour">1h</button>
|
||||
<button class="vcr-scope-btn" data-scope="21600000" role="radio" aria-checked="false" aria-label="Scope 6 hours">6h</button>
|
||||
<button class="vcr-scope-btn" data-scope="43200000" role="radio" aria-checked="false" aria-label="Scope 12 hours">12h</button>
|
||||
<button class="vcr-scope-btn" data-scope="86400000" role="radio" aria-checked="false" aria-label="Scope 24 hours">24h</button>
|
||||
</div>
|
||||
<div class="vcr-timeline-container">
|
||||
<canvas id="vcrTimeline" class="vcr-timeline"></canvas>
|
||||
@@ -670,6 +687,39 @@
|
||||
localStorage.setItem('live-feed-hidden', 'false');
|
||||
});
|
||||
|
||||
// Legend toggle for mobile (#60)
|
||||
const legendEl = document.getElementById('liveLegend');
|
||||
const legendToggleBtn = document.getElementById('legendToggleBtn');
|
||||
if (legendToggleBtn && legendEl) {
|
||||
legendToggleBtn.addEventListener('click', () => {
|
||||
const isHidden = legendEl.classList.toggle('legend-mobile-hidden');
|
||||
legendToggleBtn.setAttribute('aria-label', isHidden ? 'Show legend' : 'Hide legend');
|
||||
legendToggleBtn.textContent = isHidden ? '🎨' : '✕';
|
||||
});
|
||||
}
|
||||
|
||||
// Feed panel resize handle (#27)
|
||||
const savedFeedWidth = localStorage.getItem('live-feed-width');
|
||||
if (savedFeedWidth) feedEl.style.width = savedFeedWidth + 'px';
|
||||
const resizeHandle = document.createElement('div');
|
||||
resizeHandle.className = 'feed-resize-handle';
|
||||
resizeHandle.setAttribute('aria-label', 'Resize feed panel');
|
||||
feedEl.appendChild(resizeHandle);
|
||||
let feedResizing = false;
|
||||
resizeHandle.addEventListener('mousedown', (e) => {
|
||||
feedResizing = true; e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!feedResizing) return;
|
||||
const newWidth = Math.max(200, Math.min(800, e.clientX - feedEl.getBoundingClientRect().left));
|
||||
feedEl.style.width = newWidth + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!feedResizing) return;
|
||||
feedResizing = false;
|
||||
localStorage.setItem('live-feed-width', parseInt(feedEl.style.width));
|
||||
});
|
||||
|
||||
// Save/restore map view
|
||||
const savedView = localStorage.getItem('live-map-view');
|
||||
if (savedView) {
|
||||
@@ -696,8 +746,9 @@
|
||||
// Scope buttons
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-checked', 'false'); });
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-checked', 'true');
|
||||
VCR.timelineScope = parseInt(btn.dataset.scope);
|
||||
fetchTimelineTimestamps().then(() => updateTimeline());
|
||||
});
|
||||
@@ -720,6 +771,20 @@
|
||||
});
|
||||
timelineEl.addEventListener('mouseleave', () => { timeTooltip.classList.add('hidden'); });
|
||||
|
||||
// Touch tooltip for timeline (#19)
|
||||
timelineEl.addEventListener('touchmove', (e) => {
|
||||
if (!VCR.dragging) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = timelineEl.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (touch.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 = (touch.clientX - rect.left) + 'px';
|
||||
timeTooltip.classList.remove('hidden');
|
||||
});
|
||||
timelineEl.addEventListener('touchend', () => { timeTooltip.classList.add('hidden'); });
|
||||
|
||||
// Drag scrubbing on timeline
|
||||
VCR.dragging = false;
|
||||
VCR.dragPct = 0;
|
||||
@@ -788,14 +853,38 @@
|
||||
if (VCR.mode === 'LIVE') updateVCRClock(Date.now());
|
||||
}, 1000);
|
||||
|
||||
// Auto-hide nav
|
||||
// Auto-hide nav with pin toggle (#62)
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; }
|
||||
_navCleanup = { timeout: null, fn: null };
|
||||
_navCleanup = { timeout: null, fn: null, pinned: false };
|
||||
// Add pin button to nav
|
||||
if (topNav) {
|
||||
const pinBtn = document.createElement('button');
|
||||
pinBtn.id = 'navPinBtn';
|
||||
pinBtn.className = 'nav-pin-btn';
|
||||
pinBtn.setAttribute('aria-label', 'Pin navigation open');
|
||||
pinBtn.setAttribute('title', 'Pin navigation open');
|
||||
pinBtn.textContent = '📌';
|
||||
pinBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_navCleanup.pinned = !_navCleanup.pinned;
|
||||
pinBtn.classList.toggle('pinned', _navCleanup.pinned);
|
||||
pinBtn.setAttribute('aria-pressed', _navCleanup.pinned);
|
||||
if (_navCleanup.pinned) {
|
||||
clearTimeout(_navCleanup.timeout);
|
||||
topNav.classList.remove('nav-autohide');
|
||||
} else {
|
||||
_navCleanup.timeout = setTimeout(() => { topNav.classList.add('nav-autohide'); }, 4000);
|
||||
}
|
||||
});
|
||||
topNav.appendChild(pinBtn);
|
||||
}
|
||||
function showNav() {
|
||||
if (topNav) topNav.classList.remove('nav-autohide');
|
||||
clearTimeout(_navCleanup.timeout);
|
||||
_navCleanup.timeout = setTimeout(() => { if (topNav) topNav.classList.add('nav-autohide'); }, 4000);
|
||||
if (!_navCleanup.pinned) {
|
||||
_navCleanup.timeout = setTimeout(() => { if (topNav) topNav.classList.add('nav-autohide'); }, 4000);
|
||||
}
|
||||
}
|
||||
_navCleanup.fn = showNav;
|
||||
const livePage = document.querySelector('.live-page');
|
||||
|
||||
Reference in New Issue
Block a user