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
33import { getFlowColors , getInvalidLabels , getInvalidReason , getFlowColor } from './legends.js' ;
44import { 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)
5882export 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+
489556export 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
540606export function updateIPStats ( packets , flagColors , formatBytes ) {
0 commit comments