Skip to content

Commit ee8b4bc

Browse files
Refactor sidebar into floating draggable control panel
Rename sidebar.js to control-panel.js and convert the fixed right-side sidebar into a floating, draggable, collapsible panel with glassmorphism styling. Move flag and size legends from SVG overlays into the control panel. Add collapsible sections for less-used controls (IP Statistics, TCP Flow Visualization, Ground Truth, View Mode). Simplify zoom indicator by removing data point counts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0241ff0 commit ee8b4bc

9 files changed

Lines changed: 343 additions & 286 deletions

config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Change binning count here to affect all charts
33
export const GLOBAL_BIN_COUNT = 300;
44

5-
// Maximum number of flows to render in the sidebar list for performance
5+
// Maximum number of flows to render in the control panel list for performance
66
// Set to a reasonable default; adjust as needed
77
export const MAX_FLOW_LIST_ITEMS = 500;
88

sidebar.js renamed to control-panel.js

Lines changed: 140 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,84 @@
1-
// Sidebar logic for IP Connection Analysis
2-
// This file contains all logic for the sidebar UI and its event handlers
1+
// Control panel logic for IP Connection Analysis
2+
// This file contains all logic for the control panel UI and its event handlers
33
import { getFlowColors, getInvalidLabels, getInvalidReason, getFlowColor } from './legends.js';
44
import { MAX_FLOW_LIST_ITEMS, FLOW_LIST_RENDER_BATCH } from './config.js';
55

6-
export function initSidebar(options) {
6+
export function initControlPanel(options) {
77
// options: { onResetView, ... }
8-
const sidebar = document.getElementById('sidebar');
9-
if (!sidebar) return;
10-
11-
const desiredWidth = 340; // px
12-
13-
// Ensure sidebar fills viewport and scrolls
14-
sidebar.style.position = 'fixed';
15-
sidebar.style.top = '0';
16-
sidebar.style.right = '0';
17-
sidebar.style.height = '100vh';
18-
sidebar.style.overflowY = 'auto';
19-
sidebar.style.width = `${desiredWidth}px`;
20-
sidebar.style.boxSizing = 'border-box';
21-
sidebar.style.background = '#fff';
22-
sidebar.style.borderLeft = '1px solid #e6e6e6';
23-
sidebar.style.display = 'flex';
24-
sidebar.style.flexDirection = 'column';
25-
sidebar.style.zIndex = '100';
26-
// Leave space for fixed footer button
27-
sidebar.style.paddingBottom = '72px';
28-
29-
// Push main content so it doesn't hide behind fixed sidebar
30-
const applyBodyPadding = () => {
31-
document.body.style.paddingRight = `${sidebar.getBoundingClientRect().width}px`;
32-
};
33-
applyBodyPadding();
34-
window.addEventListener('resize', applyBodyPadding);
8+
const panel = document.getElementById('control-panel');
9+
if (!panel) return;
10+
11+
const dragHandle = document.getElementById('control-panel-drag-handle');
12+
13+
// --- Drag-to-move + click-to-collapse (like legend panel in timearcs) ---
14+
if (dragHandle) {
15+
let dragState = null;
16+
17+
const onDrag = (e) => {
18+
if (!dragState) return;
19+
const dist = Math.hypot(e.clientX - dragState.startX, e.clientY - dragState.startY);
20+
if (dist > 5) {
21+
dragState.hasMoved = true;
22+
panel.classList.add('control-panel-dragging');
23+
const newLeft = Math.max(0, Math.min(window.innerWidth - 60, e.clientX - dragState.offsetX));
24+
const newTop = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - dragState.offsetY));
25+
panel.style.left = `${newLeft}px`;
26+
panel.style.top = `${newTop}px`;
27+
panel.style.right = 'auto';
28+
}
29+
};
30+
const onDragEnd = () => {
31+
if (dragState && !dragState.hasMoved) {
32+
// Click — toggle collapse
33+
panel.classList.toggle('control-panel-collapsed');
34+
}
35+
if (dragState) {
36+
panel.classList.remove('control-panel-dragging');
37+
dragState = null;
38+
}
39+
document.removeEventListener('mousemove', onDrag);
40+
document.removeEventListener('mouseup', onDragEnd);
41+
};
42+
43+
dragHandle.addEventListener('mousedown', (e) => {
44+
if (e.button !== 0) return;
45+
const rect = panel.getBoundingClientRect();
46+
dragState = {
47+
offsetX: e.clientX - rect.left,
48+
offsetY: e.clientY - rect.top,
49+
startX: e.clientX,
50+
startY: e.clientY,
51+
hasMoved: false
52+
};
53+
document.addEventListener('mousemove', onDrag);
54+
document.addEventListener('mouseup', onDragEnd);
55+
e.preventDefault();
56+
});
57+
}
3558

36-
// Make Reset View button sticky at the bottom
59+
// Reset View button — keep in normal flow inside control panel
3760
const resetBtn = document.getElementById('resetView');
3861
if (resetBtn) {
39-
// Fix to viewport bottom aligned with sidebar
40-
resetBtn.style.position = 'fixed';
41-
resetBtn.style.right = '0';
42-
resetBtn.style.bottom = '0';
43-
resetBtn.style.width = `${desiredWidth}px`;
44-
resetBtn.style.zIndex = '110';
45-
resetBtn.style.background = '#fff';
46-
resetBtn.style.borderTop = '1px solid #eee';
47-
resetBtn.style.padding = '12px 0';
62+
resetBtn.style.position = '';
63+
resetBtn.style.width = '100%';
4864
resetBtn.style.margin = '0';
49-
resetBtn.style.display = 'block';
50-
resetBtn.style.textAlign = 'center';
65+
resetBtn.style.padding = '10px 0';
66+
resetBtn.style.borderTop = '1px solid #eee';
67+
resetBtn.style.borderRadius = '0 0 6px 6px';
5168
if (options && typeof options.onResetView === 'function') {
5269
resetBtn.onclick = options.onResetView;
5370
}
5471
}
72+
73+
// Wire up collapsible control-group sections
74+
panel.querySelectorAll('.control-group.collapsible > .collapsible-header').forEach(header => {
75+
header.addEventListener('click', () => {
76+
header.parentElement.classList.toggle('collapsed');
77+
});
78+
});
5579
}
5680

57-
// Sidebar render and update helpers (moved from main file)
81+
// Control panel render and update helpers (moved from main file)
5882
export function createIPCheckboxes(uniqueIPs, onChange) {
5983
const container = document.getElementById('ipCheckboxes');
6084
if (!container) return;
@@ -461,7 +485,7 @@ export function createFlowListCapped(flows, selectedFlowIds, formatBytes, format
461485
if (countEl) countEl.textContent = `${total.toLocaleString()} flow(s)`;
462486
}
463487

464-
export function wireSidebarControls(opts) {
488+
export function wireControlPanelControls(opts) {
465489
const on = (id, type, handler) => { const el = document.getElementById(id); if (el && handler) el.addEventListener(type, handler); };
466490
on('ipSearch', 'input', (e) => { if (opts.onIpSearch) opts.onIpSearch(e.target.value); });
467491
on('selectAllIPs', 'click', () => { if (opts.onSelectAllIPs) opts.onSelectAllIPs(); });
@@ -486,6 +510,49 @@ export function wireSidebarControls(opts) {
486510
}
487511
}
488512

513+
// Inline SVG arc icon matching the flag color legend in the packet view
514+
function flagArcIcon(color) {
515+
// Semi-circle arc curving right, matching legends.js drawFlagLegend arc shape
516+
return `<svg width="14" height="14" viewBox="0 0 14 14" style="flex-shrink:0; margin-right:4px;"><path d="M 7 1 A 6 6 0 0 1 7 13" fill="none" stroke="${color}" stroke-width="4" stroke-linecap="round"/></svg>`;
517+
}
518+
519+
// Order flags by TCP lifecycle: handshake → transfer → closing, unknowns at end
520+
const FLAG_PHASE_ORDER = [
521+
'SYN', 'SYN+ACK', // Handshake
522+
'ACK', 'PSH', 'PSH+ACK', // Data transfer
523+
'FIN', 'FIN+ACK', // Graceful close
524+
'RST', 'RST+ACK', // Abortive close
525+
'OTHER' // Catch-all
526+
];
527+
528+
function sortFlagsByTcpPhase(entries) {
529+
const order = new Map(FLAG_PHASE_ORDER.map((f, i) => [f, i]));
530+
return entries.sort(([a], [b]) => (order.get(a) ?? 99) - (order.get(b) ?? 99));
531+
}
532+
533+
// Build 2-column grid HTML from sorted [flag, count] entries
534+
// Column-first order: fill left column top-to-bottom, then right column (max 6 rows)
535+
function buildFlagStatsGrid(sortedFlags, flagColors) {
536+
const maxRows = 6;
537+
// Fill first column up to maxRows before starting second column
538+
const col1Count = Math.min(maxRows, sortedFlags.length);
539+
const col1 = sortedFlags.slice(0, col1Count);
540+
const col2 = sortedFlags.slice(col1Count);
541+
542+
const renderItem = ([flag, count]) => {
543+
const color = flagColors[flag] || '#95a5a6';
544+
return `<div style="display:flex; align-items:center; cursor:pointer; min-width:0;" data-flag="${flag}">${flagArcIcon(color)}<span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${flag}: ${count.toLocaleString()}</span></div>`;
545+
};
546+
547+
let html = '<div style="display:flex; gap:8px;">';
548+
html += `<div style="display:flex; flex-direction:column; gap:3px; flex:1; min-width:0;">${col1.map(renderItem).join('')}</div>`;
549+
if (col2.length > 0) {
550+
html += `<div style="display:flex; flex-direction:column; gap:3px; flex:1; min-width:0;">${col2.map(renderItem).join('')}</div>`;
551+
}
552+
html += '</div>';
553+
return html;
554+
}
555+
489556
export function updateFlagStats(packets, classifyFlags, flagColors) {
490557
const container = document.getElementById('flagStats');
491558
if (!container) return;
@@ -500,41 +567,40 @@ export function updateFlagStats(packets, classifyFlags, flagColors) {
500567
const count = packet.count || 1;
501568
flagCounts[ft] = (flagCounts[ft] || 0) + count;
502569
});
503-
const sortedFlags = Object.entries(flagCounts).sort(([,a],[,b]) => b - a);
504-
let html = '';
505-
sortedFlags.forEach(([flag, count]) => {
506-
const color = flagColors[flag] || '#95a5a6';
507-
const hasDefinedColor = Object.prototype.hasOwnProperty.call(flagColors, flag);
508-
html += `
509-
<div style="display:flex; align-items:center; margin-bottom:3px; cursor:pointer;" data-flag="${flag}">
510-
<div style="width:12px; height:12px; background-color:${color}; margin-right:8px; border-radius:2px; ${hasDefinedColor ? '' : 'border:1px solid #666;'}"></div>
511-
<span>${flag}: ${count.toLocaleString()}</span>
512-
${hasDefinedColor ? '' : '<span style="color:#666; font-size:10px; margin-left:5px;">(no color)</span>'}
513-
</div>`;
514-
});
515-
container.innerHTML = html || '<div style="color:#666;">No TCP packets found</div>';
570+
const sortedFlags = sortFlagsByTcpPhase(Object.entries(flagCounts));
571+
container.innerHTML = sortedFlags.length > 0 ? buildFlagStatsGrid(sortedFlags, flagColors) : '<div style="color:#666;">No TCP packets found</div>';
516572
}
517573

518-
export function updateFlagStatsFromPrecomputed(flagStats, flagColors) {
519-
const container = document.getElementById('flagStats');
574+
export function updateSizeLegend(globalMaxBinCount, radiusMin, radiusMax) {
575+
const container = document.getElementById('sizeLegend');
520576
if (!container) return;
521-
if (!flagStats || Object.keys(flagStats).length === 0) {
522-
container.innerHTML = '<div style="color: #666;">No data to display</div>';
577+
const maxCount = Math.max(1, globalMaxBinCount);
578+
if (maxCount <= 1) {
579+
container.innerHTML = '<div style="color:#666;">No data loaded</div>';
523580
return;
524581
}
525-
const sortedFlags = Object.entries(flagStats).sort(([,a],[,b]) => b - a);
526-
let html = '';
527-
sortedFlags.forEach(([flag, count]) => {
528-
const color = flagColors[flag] || '#95a5a6';
529-
const hasDefinedColor = Object.prototype.hasOwnProperty.call(flagColors, flag);
530-
html += `
531-
<div style="display:flex; align-items:center; margin-bottom:3px; cursor:pointer;" data-flag="${flag}">
532-
<div style="width:12px; height:12px; background-color:${color}; margin-right:8px; border-radius:2px; ${hasDefinedColor ? '' : 'border:1px solid #666;'}"></div>
533-
<span>${flag}: ${count.toLocaleString()}</span>
534-
${hasDefinedColor ? '' : '<span style="color:#666; font-size:10px; margin-left:5px;">(no color)</span>'}
535-
</div>`;
536-
});
537-
container.innerHTML = html || '<div style="color:#666;">No TCP packets found</div>';
582+
const midCount = Math.max(1, Math.round(maxCount / 2));
583+
const values = [1, midCount, maxCount];
584+
// sqrtScale matching ip_bar_diagram.js rScale
585+
const rScale = (v) => radiusMin + (radiusMax - radiusMin) * Math.sqrt((v - 1) / Math.max(1, maxCount - 1));
586+
const radii = values.map(v => Math.max(radiusMin, rScale(v)));
587+
const maxR = Math.max(...radii);
588+
589+
// Horizontal layout: circles side by side, bottom-aligned, with labels below
590+
const gap = 12;
591+
const pad = 4;
592+
let items = '';
593+
for (let i = 0; i < values.length; i++) {
594+
const r = radii[i];
595+
const d = r * 2;
596+
const topPad = (maxR - r) * 2; // push smaller circles down to bottom-align
597+
items += `<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">` +
598+
`<svg width="${d + 2}" height="${d + 2}" style="margin-top:${topPad}px;"><circle cx="${r + 1}" cy="${r + 1}" r="${r}" fill="none" stroke="#555" stroke-width="1"/></svg>` +
599+
`<span style="font-size:10px; color:#333; white-space:nowrap;">${values[i].toLocaleString()}</span>` +
600+
`</div>`;
601+
}
602+
603+
container.innerHTML = `<div style="display:flex; align-items:flex-end; gap:${gap}px; padding:${pad}px 0;">${items}</div>`;
538604
}
539605

540606
export function updateIPStats(packets, flagColors, formatBytes) {

0 commit comments

Comments
 (0)