11# frozen_string_literal: true
22
3+ require 'set'
4+
35module Minigun
46 module HUD
57 # Renders pipeline DAG as animated ASCII flow diagram with boxes and connections
@@ -275,29 +277,65 @@ def render_connections(terminal, layout, stages, dag, x_offset, y_offset)
275277
276278 edges = bridged_edges . uniq
277279
278- # Group edges by source
280+ # Group edges by source and target to detect fan-out and fan-in
279281 edges_by_source = edges . group_by { |e | e [ :from ] }
282+ edges_by_target = edges . group_by { |e | e [ :to ] }
283+
284+ # Track which edges have been rendered
285+ rendered_edges = Set . new
280286
281- # Render each source's connections
287+ # First pass: Render fan-out connections (one source to multiple targets)
282288 edges_by_source . each do |from_name , from_edges |
289+ next if from_edges . size <= 1 # Skip single connections for now
290+
283291 from_pos = layout [ from_name ]
284292 next unless from_pos
285293
286294 stage_data = stage_map [ from_name ]
287295 next unless stage_data
288296
289- # Get target positions
290297 target_names = from_edges . map { |e | e [ :to ] }
291298 target_positions = target_names . map { |name | layout [ name ] } . compact
292-
293299 next if target_positions . empty?
294300
295- # Draw fan-out connection or single connection
296- if target_positions . size > 1
297- render_fanout_connection ( terminal , from_pos , target_positions , stage_data , x_offset , y_offset )
298- else
299- render_connection_line ( terminal , from_pos , target_positions . first , stage_data , x_offset , y_offset )
300- end
301+ render_fanout_connection ( terminal , from_pos , target_positions , stage_data , x_offset , y_offset )
302+ from_edges . each { |e | rendered_edges . add ( e ) }
303+ end
304+
305+ # Second pass: Render fan-in connections (multiple sources to one target)
306+ edges_by_target . each do |to_name , to_edges |
307+ next if to_edges . size <= 1 # Skip single connections for now
308+
309+ to_pos = layout [ to_name ]
310+ next unless to_pos
311+
312+ source_names = to_edges . map { |e | e [ :from ] }
313+ source_positions = source_names . zip ( to_edges ) . map do |name , edge |
314+ next if rendered_edges . include? ( edge )
315+ layout [ name ]
316+ end . compact
317+ next if source_positions . empty?
318+
319+ # Get stage data from first source for color
320+ first_source = to_edges . first [ :from ]
321+ stage_data = stage_map [ first_source ] || { }
322+
323+ render_fanin_connection ( terminal , source_positions , to_pos , stage_data , x_offset , y_offset )
324+ to_edges . each { |e | rendered_edges . add ( e ) }
325+ end
326+
327+ # Third pass: Render remaining single connections
328+ edges . each do |edge |
329+ next if rendered_edges . include? ( edge )
330+
331+ from_pos = layout [ edge [ :from ] ]
332+ to_pos = layout [ edge [ :to ] ]
333+ next unless from_pos && to_pos
334+
335+ stage_data = stage_map [ edge [ :from ] ]
336+ next unless stage_data
337+
338+ render_connection_line ( terminal , from_pos , to_pos , stage_data , x_offset , y_offset )
301339 end
302340 end
303341
@@ -322,10 +360,12 @@ def render_fanout_connection(terminal, from_pos, target_positions, stage_data, x
322360 leftmost_x = target_xs . first
323361 rightmost_x = target_xs . last
324362
363+ # Check if there's a target directly below the source
364+ has_center_target = target_xs . include? ( from_x )
365+
325366 # Draw horizontal spine with junctions
326- # Pattern: ┌───────────────┼───────────────┐
327- # │ │ │
328- # Where ┌ = left corner, ┼ = source (4-way junction), ┐ = right corner
367+ # Pattern with center target: ┌───────────────┼───────────────┐
368+ # Pattern without center: ┌───────────────┴───────────────┐
329369 ( leftmost_x ..rightmost_x ) . each do |x |
330370 next if x < 0 || x >= @width
331371
@@ -337,8 +377,8 @@ def render_fanout_connection(terminal, from_pos, target_positions, stage_data, x
337377 # Right corner
338378 "┐"
339379 elsif x == from_x
340- # Source position uses ┼ (4-way junction)
341- "┼ "
380+ # Source position: ┼ if target below, ┴ if not
381+ has_center_target ? "┼" : "┴ "
342382 else
343383 # Regular horizontal line (spine)
344384 if active
@@ -373,6 +413,93 @@ def render_fanout_connection(terminal, from_pos, target_positions, stage_data, x
373413 end
374414 end
375415
416+ # Draw a fan-in connection (multiple sources to one target)
417+ def render_fanin_connection ( terminal , source_positions , to_pos , stage_data , x_offset , y_offset )
418+ to_x = to_pos [ :x ] + to_pos [ :width ] / 2
419+ to_y = to_pos [ :y ]
420+
421+ # Check if connection is active
422+ active = stage_data [ :throughput ] && stage_data [ :throughput ] > 0
423+ color = active ? Theme . primary : Theme . muted
424+
425+ # Calculate merge point (where horizontal lines converge)
426+ # Place it 1 line above the target
427+ merge_y = to_y - 1
428+
429+ # Get source X positions (sorted)
430+ source_data = source_positions . map do |pos |
431+ {
432+ x : pos [ :x ] + pos [ :width ] / 2 ,
433+ y : pos [ :y ] + pos [ :height ]
434+ }
435+ end . sort_by { |s | s [ :x ] }
436+
437+ # Draw vertical lines from each source down to merge level
438+ # Then turn inward with corners
439+ source_data . each do |source |
440+ # Vertical line from source to turn point
441+ ( source [ :y ] ...merge_y ) . each do |y |
442+ next if y < 0 || y >= @height
443+
444+ char = if active
445+ offset = ( @animation_frame / 4 ) % Theme ::FLOW_CHARS . length
446+ phase = ( y - source [ :y ] + offset ) % Theme ::FLOW_CHARS . length
447+ Theme ::FLOW_CHARS [ phase ]
448+ else
449+ "│"
450+ end
451+
452+ terminal . write_at ( x_offset + source [ :x ] , y_offset + y , char , color : color )
453+ end
454+
455+ # Corner at turn point
456+ if source [ :x ] < to_x
457+ # Left source: turn right with └
458+ terminal . write_at ( x_offset + source [ :x ] , y_offset + merge_y , "└" , color : color )
459+
460+ # Horizontal line from corner to center (or near target)
461+ ( ( source [ :x ] + 1 ) ...to_x ) . each do |x |
462+ next if x < 0 || x >= @width
463+
464+ char = if active
465+ offset = ( @animation_frame / 4 ) % 4
466+ [ "─" , "╌" , "┄" , "┈" ] [ offset ]
467+ else
468+ "─"
469+ end
470+
471+ terminal . write_at ( x_offset + x , y_offset + merge_y , char , color : color )
472+ end
473+ elsif source [ :x ] > to_x
474+ # Right source: turn left with ┘
475+ terminal . write_at ( x_offset + source [ :x ] , y_offset + merge_y , "┘" , color : color )
476+
477+ # Horizontal line from corner to center (or near target)
478+ ( ( to_x + 1 ) ...source [ :x ] ) . each do |x |
479+ next if x < 0 || x >= @width
480+
481+ char = if active
482+ offset = ( @animation_frame / 4 ) % 4
483+ [ "─" , "╌" , "┄" , "┈" ] [ offset ]
484+ else
485+ "─"
486+ end
487+
488+ terminal . write_at ( x_offset + x , y_offset + merge_y , char , color : color )
489+ end
490+ else
491+ # Source directly above target - just draw vertical line
492+ # (already drawn above)
493+ end
494+ end
495+
496+ # Draw final vertical line from merge point to target
497+ # (Only if not already covered by a center source)
498+ unless source_data . any? { |s | s [ :x ] == to_x }
499+ terminal . write_at ( x_offset + to_x , y_offset + merge_y , "│" , color : color )
500+ end
501+ end
502+
376503 # Draw animated connection line between two boxes
377504 def render_connection_line ( terminal , from_pos , to_pos , stage_data , x_offset , y_offset )
378505 # Connection from bottom center of from_box to top center of to_box
0 commit comments