@@ -106,6 +106,8 @@ def __init__(self, flightmode_colourmap=None):
106106 self .flightmode_colourmap = {}
107107 self .flightmode_list = None
108108 self .ax1 = None
109+ self .ax2 = None
110+ self .ax_by_num = {}
109111 self .locator = None
110112 global graph_num
111113 self .graph_num = graph_num
@@ -125,6 +127,15 @@ def __init__(self, flightmode_colourmap=None):
125127 else :
126128 self .text_types = frozenset ([unicode , str ])
127129 self .max_message_rate = 0
130+ self .axis_mode = 'auto'
131+
132+ def set_axis_mode (self , mode ):
133+ '''set y-axis layout mode: 'auto' (dual for 1-2 axes, multi for 3+),
134+ 'dual' (legacy 2-axis) or 'multi' (all extra axes stacked on the left
135+ with vertical labels)'''
136+ if mode not in ('auto' , 'dual' , 'multi' ):
137+ raise ValueError ("axis_mode must be 'auto', 'dual' or 'multi'" )
138+ self .axis_mode = mode
128139
129140 def set_max_message_rate (self , rate_hz ):
130141 '''set maximum rate we will graph any message'''
@@ -183,21 +194,49 @@ def set_multi(self, multi):
183194 '''set multiple graph option'''
184195 self .multi = multi
185196
186- def make_format (self , current , other ):
187- # current and other are axes
197+ def _set_multicolor_ylabel (self , ax_n , labels , lines ):
198+ '''attach a y-axis label whose comma-separated parts are each coloured
199+ to match the line they refer to'''
200+ from matplotlib .offsetbox import (AnchoredOffsetbox , TextArea , VPacker )
201+ children = []
202+ for i , (lbl , line ) in enumerate (zip (labels , lines )):
203+ if i > 0 :
204+ children .append (TextArea (", " , textprops = dict (
205+ color = 'black' , rotation = 90 , ha = 'center' , va = 'bottom' )))
206+ children .append (TextArea (lbl , textprops = dict (
207+ color = line .get_color (), rotation = 90 , ha = 'center' , va = 'bottom' )))
208+ # VPacker stacks first child on top; reverse so visual order matches
209+ # the default y-label direction (text reads bottom-to-top)
210+ vp = VPacker (children = list (reversed (children )),
211+ pad = 0 , sep = 2 , align = 'center' )
212+ spine_pos = ax_n .spines ['left' ].get_position ()
213+ if isinstance (spine_pos , tuple ) and spine_pos [0 ] == 'axes' :
214+ x_axes = spine_pos [1 ] - 0.04
215+ else :
216+ x_axes = - 0.04
217+ box = AnchoredOffsetbox (loc = 'center right' , child = vp , frameon = False ,
218+ bbox_to_anchor = (x_axes , 0.5 ),
219+ bbox_transform = ax_n .transAxes , borderpad = 0 )
220+ ax_n .add_artist (box )
221+ ax_n .set_ylabel ('' )
222+
223+ def make_format (self , current_axis ):
224+ '''build a format_coord that reports y for every active axis'''
188225 def format_coord (x , y ):
189- # x, y are data coordinates
190- # convert to display coords
191- display_coord = current .transData .transform ((x ,y ))
192- inv = other .transData .inverted ()
193- # convert back to data coords with respect to ax
194- ax_coord = inv .transform (display_coord )
195- xstr = self .formatter (x )
196- y2 = ax_coord [1 ]
226+ # x, y are data coords on current_axis; project to every other axis
227+ display_coord = current_axis .transData .transform ((x , y ))
197228 if self .xaxis :
198- return ( 'x=%.3f Left=%.3f Right=%.3f ' % ( x , y2 , y ))
229+ parts = [ 'x=%.3f' % x ]
199230 else :
200- return ('x=%s Left=%.3f Right=%.3f' % (xstr , y2 , y ))
231+ parts = ['x=%s' % self .formatter (x )]
232+ for n in sorted (self .ax_by_num .keys ()):
233+ ax = self .ax_by_num [n ]
234+ if ax is current_axis :
235+ yv = y
236+ else :
237+ yv = ax .transData .inverted ().transform (display_coord )[1 ]
238+ parts .append ('ax%d=%.3f' % (n , yv ))
239+ return ' ' .join (parts )
201240 return format_coord
202241
203242 def next_flightmode_colour (self ):
@@ -262,17 +301,23 @@ def rescale_yaxis(self, axis):
262301 def button_click (self , event ):
263302 '''handle button clicks'''
264303 if getattr (event , 'dblclick' , False ) and event .button == 1 :
265- self .rescale_yaxis (self .ax1 )
266- if self .ax2 :
267- self .rescale_yaxis (self .ax2 )
304+ for ax in self .ax_by_num .values ():
305+ self .rescale_yaxis (ax )
268306
269307 def plotit (self , x , y , fields , colors = [], title = None , interactive = True ):
270308 '''plot a set of graphs using date for x axis'''
271309 if interactive :
272310 plt .ion ()
273311 self .fig = plt .figure (num = 1 , figsize = (12 ,6 ))
274312 self .ax1 = self .fig .gca ()
313+ self .ax_by_num = {1 : self .ax1 }
275314 self .ax2 = None
315+ # resolve 'auto' to 'dual' (1-2 axes) or 'multi' (3+ axes) up front
316+ if self .axis_mode == 'auto' :
317+ max_axis = max (self .axes ) if self .axes else 1
318+ axis_mode = 'multi' if max_axis >= 3 else 'dual'
319+ else :
320+ axis_mode = self .axis_mode
276321 for i in range (0 , len (fields )):
277322 if len (x [i ]) == 0 : continue
278323 if self .lowest_x is None or x [i ][0 ] < self .lowest_x :
@@ -293,8 +338,9 @@ def plotit(self, x, y, fields, colors=[], title=None, interactive=True):
293338 self .fig .canvas .get_default_filename = lambda : '' .join ("graph" if self .title is None else
294339 (x if x .isalnum () else '_' for x in self .title )) + '.png'
295340 empty = True
296- ax1_labels = []
297- ax2_labels = []
341+ labels_by_axis = {1 : []}
342+ lines_by_axis = {1 : []}
343+ label_strip_re = re .compile (r'^(.*):[2-9]$' )
298344
299345 for i in range (len (fields )):
300346 if len (x [i ]) == 0 :
@@ -306,29 +352,41 @@ def plotit(self, x, y, fields, colors=[], title=None, interactive=True):
306352 color = 'red'
307353 (tz , tzdst ) = time .tzname
308354
309- if self .axes [i ] == 2 :
310- if self .ax2 is None :
311- self .ax2 = self .ax1 .twinx ()
312- if self .grid :
313- self .ax2 .grid (None )
314- self .ax1 .grid (True )
315- self .ax2 .format_coord = self .make_format (self .ax2 , self .ax1 )
316- ax = self .ax2
355+ axis_num = self .axes [i ]
356+ if axis_mode == 'dual' and axis_num > 2 :
357+ # legacy 2-axis layout: anything above axis 1 goes on the right
358+ axis_num = 2
359+ if axis_num not in self .ax_by_num :
360+ new_ax = self .ax1 .twinx ()
361+ self .ax_by_num [axis_num ] = new_ax
362+ labels_by_axis [axis_num ] = []
363+ lines_by_axis [axis_num ] = []
364+ if self .grid :
365+ new_ax .grid (None )
366+ self .ax1 .grid (True )
317367 if not self .xaxis :
318- self .ax2 .xaxis .set_major_locator (self .locator )
319- self .ax2 .xaxis .set_major_formatter (self .formatter )
320- label = fields [i ]
321- if label .endswith (":2" ):
322- label = label [:- 2 ]
323- ax2_labels .append (label )
324- if self .custom_labels [i ] is not None :
325- ax2_labels [- 1 ] = self .custom_labels [i ]
326- else :
327- ax1_labels .append (fields [i ])
328- if self .custom_labels [i ] is not None :
329- ax1_labels [- 1 ] = self .custom_labels [i ]
330- ax = self .ax1
331-
368+ new_ax .xaxis .set_major_locator (self .locator )
369+ new_ax .xaxis .set_major_formatter (self .formatter )
370+ if axis_mode == 'multi' and axis_num >= 2 :
371+ # stack additional axes on the LEFT instead of the right
372+ new_ax .yaxis .set_ticks_position ('left' )
373+ new_ax .yaxis .set_label_position ('left' )
374+ new_ax .spines ['right' ].set_visible (False )
375+ new_ax .spines ['left' ].set_visible (True )
376+ ax = self .ax_by_num [axis_num ]
377+
378+ label = fields [i ]
379+ m = label_strip_re .match (label )
380+ if m is not None :
381+ label = m .group (1 )
382+ labels_by_axis [axis_num ].append (label )
383+ if self .custom_labels [i ] is not None :
384+ labels_by_axis [axis_num ][- 1 ] = self .custom_labels [i ]
385+ if axis_num == 2 and self .ax2 is None :
386+ # back-compat alias
387+ self .ax2 = ax
388+
389+ new_lines = []
332390 if self .xaxis :
333391 if self .marker is not None :
334392 marker = self .marker
@@ -338,8 +396,8 @@ def plotit(self, x, y, fields, colors=[], title=None, interactive=True):
338396 linestyle = self .linestyle
339397 else :
340398 linestyle = 'None'
341- ax .plot (x [i ], y [i ], color = color , label = fields [i ],
342- linestyle = linestyle , marker = marker )
399+ new_lines = ax .plot (x [i ], y [i ], color = color , label = fields [i ],
400+ linestyle = linestyle , marker = marker )
343401 else :
344402 if self .marker is not None :
345403 marker = self .marker
@@ -378,11 +436,30 @@ def plotit(self, x, y, fields, colors=[], title=None, interactive=True):
378436 alpha = 0.6 ,
379437 verticalalignment = 'center' )
380438 else :
381- ax .plot_date (x [i ], y [i ], fmt = color , label = fields [i ],
382- linestyle = linestyle , marker = marker , tz = None )
439+ new_lines = ax .plot_date (x [i ], y [i ], fmt = color , label = fields [i ],
440+ linestyle = linestyle , marker = marker , tz = None )
441+ if new_lines :
442+ lines_by_axis [axis_num ].extend (new_lines )
383443
384444 empty = False
385-
445+
446+ # in 'multi' mode stack each extra axis further LEFT and reserve room
447+ if axis_mode == 'multi' :
448+ extra_nums = sorted (n for n in self .ax_by_num .keys () if n >= 2 )
449+ offset_step = 0.10
450+ for idx , n in enumerate (extra_nums ):
451+ self .ax_by_num [n ].spines ['left' ].set_position (
452+ ("axes" , - (idx + 1 ) * offset_step ))
453+ if extra_nums :
454+ self .fig .subplots_adjust (
455+ left = min (0.5 , 0.08 + len (extra_nums ) * offset_step ))
456+
457+ # install hover readout on every twinx axis when there are multiple axes
458+ if len (self .ax_by_num ) > 1 :
459+ for n , ax_n in self .ax_by_num .items ():
460+ if n >= 2 :
461+ ax_n .format_coord = self .make_format (ax_n )
462+
386463 if self .grid :
387464 plt .grid ()
388465
@@ -414,23 +491,51 @@ def plotit(self, x, y, fields, colors=[], title=None, interactive=True):
414491 else :
415492 self .fig .canvas .set_window_title (title )
416493
494+ # in 'multi' mode label each axis vertically; when several fields share
495+ # an axis use a per-piece coloured composite label so each name appears
496+ # in its line's colour. Single-field axes additionally get the spine
497+ # and ticks coloured to match.
498+ if axis_mode == 'multi' :
499+ for n , ax_n in self .ax_by_num .items ():
500+ labels_n = labels_by_axis .get (n , [])
501+ lines_n = lines_by_axis .get (n , [])
502+ if not labels_n :
503+ continue
504+ if len (lines_n ) >= 2 and len (lines_n ) == len (labels_n ):
505+ self ._set_multicolor_ylabel (ax_n , labels_n , lines_n )
506+ else :
507+ ax_n .set_ylabel (", " .join (labels_n ))
508+ if len (lines_n ) == 1 :
509+ line_color = lines_n [0 ].get_color ()
510+ ax_n .tick_params (axis = 'y' , colors = line_color )
511+ ax_n .spines ['left' ].set_color (line_color )
512+ ax_n .yaxis .label .set_color (line_color )
513+
514+ any_data_labels = any (labels_by_axis .get (n ) for n in self .ax_by_num )
515+
417516 if self .show_flightmode != 0 :
418517 mode_patches = []
419518 for mode in self .modes_plotted .keys ():
420519 (color , alpha ) = self .modes_plotted [mode ]
421520 mode_patches .append (matplotlib .patches .Patch (color = color ,
422521 label = mode , alpha = alpha * 1.5 ))
423522 labels = [patch .get_label () for patch in mode_patches ]
424- if ax1_labels != [] and self .show_flightmode != 2 :
523+ if any_data_labels and self .show_flightmode != 2 :
425524 patches_legend = plt .legend (mode_patches , labels , loc = self .legend_flightmode )
426525 self .fig .gca ().add_artist (patches_legend )
427526 else :
428527 plt .legend (mode_patches , labels )
429528
430- if ax1_labels != []:
431- self .ax1 .legend (ax1_labels ,loc = self .legend )
432- if ax2_labels != []:
433- self .ax2 .legend (ax2_labels ,loc = self .legend2 )
529+ # in 'dual' mode keep the legacy per-axis legend; in 'multi' mode the
530+ # vertical y-labels (per-piece coloured when shared) do the job
531+ if axis_mode == 'dual' :
532+ legend_positions = {1 : self .legend , 2 : self .legend2 }
533+ for n in sorted (self .ax_by_num .keys ()):
534+ labels_n = labels_by_axis .get (n , [])
535+ if not labels_n :
536+ continue
537+ self .ax_by_num [n ].legend (
538+ labels_n , loc = legend_positions .get (n , self .legend2 ))
434539
435540 def add_data (self , t , msg , vars ):
436541 '''add some data'''
@@ -513,12 +618,15 @@ def process_mav(self, mlog, flightmode_selections):
513618 self .custom_labels [i ] = self .fields [i ][a2 + 1 :- 1 ]
514619 self .fields [i ] = self .fields [i ][:a2 ]
515620
516- # pre-calc right/left axes
621+ # pre-calc which y-axis each field uses (digit suffix :2..:9, with
622+ # axes >= 3 rendered as additional offset right-hand spines)
623+ axis_re = re .compile (r'^(.*):([2-9])$' )
517624 for i in range (self .num_fields ):
518625 f = self .fields [i ]
519- if f .endswith (":2" ):
520- self .axes [i ] = 2
521- f = f [:- 2 ]
626+ m = axis_re .match (f )
627+ if m is not None :
628+ self .axes [i ] = int (m .group (2 ))
629+ f = m .group (1 )
522630 if f .endswith (":1" ):
523631 self .first_only [i ] = True
524632 f = f [:- 2 ]
@@ -712,6 +820,12 @@ def show(self, lenmavlist, block=True, xlim_pipe=None, output=None):
712820 parser .add_argument ("--output" , default = None , help = "provide an output format" )
713821 parser .add_argument ("--timeshift" , type = float , default = 0 , help = "shift time on first graph in seconds" )
714822 parser .add_argument ("--grid" , action = 'store_true' , help = "show a grid" )
823+ parser .add_argument ("--axis-mode" , default = 'auto' ,
824+ choices = ['auto' , 'dual' , 'multi' ],
825+ help = "y-axis layout: 'auto' picks dual for 1-2 axes "
826+ "and multi for 3+, 'dual' forces the legacy 2-axis "
827+ "layout, 'multi' stacks each :N axis on the left "
828+ "with vertical labels" )
715829 parser .add_argument ("logs_fields" , metavar = "<LOG or FIELD>" , nargs = "+" )
716830 args = parser .parse_args ()
717831
@@ -735,5 +849,6 @@ def show(self, lenmavlist, block=True, xlim_pipe=None, output=None):
735849 mg .set_title (args .title )
736850 mg .set_grid (args .grid )
737851 mg .set_show_flightmode (args .show_flightmode )
852+ mg .set_axis_mode (args .axis_mode )
738853 mg .process ([],[],0 )
739854 mg .show (len (mg .mav_list ), output = args .output )
0 commit comments