Skip to content

Commit c70ea2e

Browse files
tridgeclaude
andcommitted
grapher: generalise multi-axis support with auto/dual/multi modes
Field suffix :N now selects y-axis N (2-9) instead of just :2. Adds an axis_mode setting (auto/dual/multi, default auto) wired through the MAVExplorer set command and the grapher.py --axis-mode CLI flag: - dual preserves the legacy 2-axis layout (legend per axis, no vertical y-labels), capping any :N>=2 to the right axis - multi stacks each extra axis on the left with a vertical label coloured to the line; when fields share an axis the label parts are coloured individually via an AnchoredOffsetbox/VPacker composite - auto picks dual for 1-2 axes, multi for 3+ Hover readout (format_coord) generalised to report y for every axis. Double-click rescale now iterates all axes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dc8c627 commit c70ea2e

3 files changed

Lines changed: 172 additions & 54 deletions

File tree

MAVProxy/modules/lib/graph_ui.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def display_graph(self, graphdef, flightmode_colourmap=None):
3939
self.mg.set_linestyle(self.mestate.settings.linestyle)
4040
self.mg.set_show_flightmode(self.mestate.settings.show_flightmode)
4141
self.mg.set_legend(self.mestate.settings.legend)
42+
self.mg.set_axis_mode(self.mestate.settings.axis_mode)
4243
self.mg.add_mav(copy.copy(self.mestate.mlog))
4344
for f in graphdef.expression.split():
4445
self.mg.add_field(f)

MAVProxy/modules/lib/grapher.py

Lines changed: 168 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

MAVProxy/tools/MAVExplorer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ def __init__(self):
112112
MPSetting('sync_xmap', bool, True, 'sync X-axis zoom for map'),
113113
MPSetting('legend', str, 'upper left', 'legend position'),
114114
MPSetting('legend2', str, 'upper right', 'legend2 position'),
115+
MPSetting('axis_mode', str, 'auto', 'y-axis layout mode',
116+
choice=['auto', 'dual', 'multi']),
115117
MPSetting('title', str, None, 'Graph title'),
116118
MPSetting('debug', int, 0, 'debug level'),
117119
MPSetting('paramdocs', bool, True, 'show param docs'),
@@ -276,7 +278,7 @@ def expression_ok(expression, msgs=None):
276278
a2 = f.rfind("<")
277279
if a2 != -1:
278280
f = f[:a2]
279-
if f.endswith(':2'):
281+
if len(f) >= 2 and f[-2] == ':' and f[-1] in '23456789':
280282
f = f[:-2]
281283
if f[-1] == '}':
282284
# avoid passing nocondition unless needed to allow us to work witih older

0 commit comments

Comments
 (0)