scripts: Adopted Canvas class in plot.py

This should have no noticeable impact on plot.py, but shared classes
have proven helpful for maintaining these scripts.

Unfortunately, this did require some tweaking of the Canvas class to get
things working.

Now, instead of storing things in an internal high-resolution grid,
the Canvas class only keeps track of the most recent character, with
bitmasked ints storing sub-char info.

This makes it so sub-char draws overwrite full characters, which is
necessary for plot.py's axis/data overlap to work.
This commit is contained in:
Christopher Haster
2025-03-12 13:42:18 -05:00
parent 4df90dfa0a
commit f3889d8932
3 changed files with 348 additions and 254 deletions

View File

@@ -253,6 +253,8 @@ class Canvas:
else: else:
xscale, yscale = 1, 1 xscale, yscale = 1, 1
self.width_ = width
self.height_ = height
self.width = xscale*width self.width = xscale*width
self.height = yscale*height self.height = yscale*height
self.xscale = xscale self.xscale = xscale
@@ -262,50 +264,63 @@ class Canvas:
self.braille = braille self.braille = braille
# create initial canvas # create initial canvas
self.grid = [False] * (self.width*self.height) self.chars = [0] * (width*height)
self.colors = [''] * (self.width*self.height) self.colors = [''] * (width*height)
def __getitem__(self, xy): def char(self, x, y, char=None):
x, y = xy
# ignore out of bounds # ignore out of bounds
if x < 0 or y < 0 or x >= self.width or y >= self.height: if x < 0 or y < 0 or x >= self.width or y >= self.height:
return return False
return self.grid[x + y*self.width] x_ = x // self.xscale
y_ = y // self.yscale
def __setitem__(self, xy, char): if char is not None:
x, y = xy c = self.chars[x_ + y_*self.width_]
# ignore out of bounds # mask in sub-char pixel?
if x < 0 or y < 0 or x >= self.width or y >= self.height: if isinstance(char, bool):
return if not isinstance(c, int):
c = 0
self.grid[x + y*self.width] = char self.chars[x_ + y_*self.width_] = (c
| (1
<< ((y%self.yscale)*self.xscale
+ (self.xscale-1)-(x%self.xscale))))
else:
self.chars[x_ + y_*self.width_] = char
else:
c = self.chars[x_ + y_*self.width_]
if isinstance(c, int):
return ((c
>> ((y%self.yscale)*self.xscale
+ (self.xscale-1)-(x%self.xscale)))
& 1) == 1
else:
return c
def color(self, x, y, color=None): def color(self, x, y, color=None):
# ignore out of bounds # ignore out of bounds
if x < 0 or y < 0 or x >= self.width or y >= self.height: if x < 0 or y < 0 or x >= self.width or y >= self.height:
return return ''
x_ = x // self.xscale
y_ = y // self.yscale
if color is not None: if color is not None:
self.colors[x + y*self.width] = color self.colors[x_ + y_*self.width_] = color
else: else:
return self.colors[x + y*self.width] return self.colors[x_ + y_*self.width_]
def __getitem__(self, xy):
x, y = xy
return self.char(x, y)
def __setitem__(self, xy, char):
x, y = xy
self.char(x, y, char)
def point(self, x, y, *, def point(self, x, y, *,
char=True, char=True,
color=''): color=''):
# make sure non-bool chars map attrs to all points under char self.char(x, y, char)
if not isinstance(char, bool): self.color(x, y, color)
xscale, yscale = self.xscale, self.yscale
else:
xscale, yscale = 1, 1
for i in range(xscale*yscale):
x_ = x-(x%xscale) + (xscale-1-(i%xscale))
y_ = y-(y%yscale) + (i//xscale)
self[x_, y_] = char
self.color(x_, y_, color)
def line(self, x1, y1, x2, y2, *, def line(self, x1, y1, x2, y2, *,
char=True, char=True,
@@ -318,7 +333,7 @@ class Canvas:
e = ex + ey e = ex + ey
while True: while True:
self.point(x1, y1, color=color, char=char) self.point(x1, y1, char=char, color=color)
e2 = 2*e e2 = 2*e
if x1 == x2 and y1 == y2: if x1 == x2 and y1 == y2:
@@ -335,7 +350,7 @@ class Canvas:
e += ex e += ex
y1 += dy y1 += dy
self.point(x2, y2, color=color, char=char) self.point(x2, y2, char=char, color=color)
def rect(self, x, y, w, h, *, def rect(self, x, y, w, h, *,
char=True, char=True,
@@ -359,47 +374,29 @@ class Canvas:
x_ += self.xscale x_ += self.xscale
def draw(self, row): def draw(self, row):
# scale if needed y_ = self.height_-1 - row
xscale, yscale = self.xscale, self.yscale
y = self.height//yscale-1 - row
row_ = [] row_ = []
for x in range(self.width//xscale): for x_ in range(self.width_):
color = '' # char?
char = False c = self.chars[x_ + y_*self.width_]
byte = 0 if isinstance(c, int):
for i in range(xscale*yscale): if self.braille:
x_ = x*xscale + (xscale-1-(i%xscale)) assert c < 256
y_ = y*yscale + (i//xscale) c = CHARS_BRAILLE[c]
elif self.dots:
# calculate char assert c < 4
char_ = self[x_, y_] c = CHARS_DOTS[c]
if char_:
byte |= 1 << i
if char_ is not True and char_ is not False:
char = char_
# keep track of best color
color_ = self.color(x_, y_)
if color_:
color = color_
# figure out winning char
if byte:
if char is not True and char is not False:
pass
elif self.braille:
char = CHARS_BRAILLE[byte]
else: else:
char = CHARS_DOTS[byte] assert c < 2
else: c = '.' if c else ' '
char = ' '
# color? # color?
if byte and self.color_ and color: if self.color_:
char = '\x1b[%sm%s\x1b[m' % (color, char) color = self.colors[x_ + y_*self.width_]
if color:
c = '\x1b[%sm%s\x1b[m' % (color, c)
row_.append(char) row_.append(c)
return ''.join(row_) return ''.join(row_)

View File

@@ -524,6 +524,168 @@ def psplit(s):
return [m.group() for m in re.finditer(pattern.pattern + '|.', s)] return [m.group() for m in re.finditer(pattern.pattern + '|.', s)]
# a little ascii renderer
class Canvas:
def __init__(self, width, height, *,
color=False,
dots=False,
braille=False):
# scale if we're printing with dots or braille
if braille:
xscale, yscale = 2, 4
elif dots:
xscale, yscale = 1, 2
else:
xscale, yscale = 1, 1
self.width_ = width
self.height_ = height
self.width = xscale*width
self.height = yscale*height
self.xscale = xscale
self.yscale = yscale
self.color_ = color
self.dots = dots
self.braille = braille
# create initial canvas
self.chars = [0] * (width*height)
self.colors = [''] * (width*height)
def char(self, x, y, char=None):
# ignore out of bounds
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return False
x_ = x // self.xscale
y_ = y // self.yscale
if char is not None:
c = self.chars[x_ + y_*self.width_]
# mask in sub-char pixel?
if isinstance(char, bool):
if not isinstance(c, int):
c = 0
self.chars[x_ + y_*self.width_] = (c
| (1
<< ((y%self.yscale)*self.xscale
+ (self.xscale-1)-(x%self.xscale))))
else:
self.chars[x_ + y_*self.width_] = char
else:
c = self.chars[x_ + y_*self.width_]
if isinstance(c, int):
return ((c
>> ((y%self.yscale)*self.xscale
+ (self.xscale-1)-(x%self.xscale)))
& 1) == 1
else:
return c
def color(self, x, y, color=None):
# ignore out of bounds
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return ''
x_ = x // self.xscale
y_ = y // self.yscale
if color is not None:
self.colors[x_ + y_*self.width_] = color
else:
return self.colors[x_ + y_*self.width_]
def __getitem__(self, xy):
x, y = xy
return self.char(x, y)
def __setitem__(self, xy, char):
x, y = xy
self.char(x, y, char)
def point(self, x, y, *,
char=True,
color=''):
self.char(x, y, char)
self.color(x, y, color)
def line(self, x1, y1, x2, y2, *,
char=True,
color=''):
# incremental error line algorithm
ex = abs(x2 - x1)
ey = -abs(y2 - y1)
dx = +1 if x1 < x2 else -1
dy = +1 if y1 < y2 else -1
e = ex + ey
while True:
self.point(x1, y1, char=char, color=color)
e2 = 2*e
if x1 == x2 and y1 == y2:
break
if e2 > ey:
e += ey
x1 += dx
if x1 == x2 and y1 == y2:
break
if e2 < ex:
e += ex
y1 += dy
self.point(x2, y2, char=char, color=color)
def rect(self, x, y, w, h, *,
char=True,
color=''):
for j in range(h):
for i in range(w):
self.point(x+i, y+j, char=char, color=color)
def label(self, x, y, label, width=None, height=None, *,
color=''):
x_ = x
y_ = y
for char in label:
if char == '\n':
x_ = x
y_ -= self.yscale
else:
if ((width is None or x_ < x+width)
and (height is None or y_ > y-height)):
self.point(x_, y_, char=char, color=color)
x_ += self.xscale
def draw(self, row):
y_ = self.height_-1 - row
row_ = []
for x_ in range(self.width_):
# char?
c = self.chars[x_ + y_*self.width_]
if isinstance(c, int):
if self.braille:
assert c < 256
c = CHARS_BRAILLE[c]
elif self.dots:
assert c < 4
c = CHARS_DOTS[c]
else:
assert c < 2
c = '.' if c else ' '
# color?
if self.color_:
color = self.colors[x_ + y_*self.width_]
if color:
c = '\x1b[%sm%s\x1b[m' % (color, c)
row_.append(c)
return ''.join(row_)
# a hack log that preserves sign, with a linear region between -1 and 1 # a hack log that preserves sign, with a linear region between -1 and 1
def symlog(x): def symlog(x):
if x > 1: if x > 1:
@@ -533,30 +695,47 @@ def symlog(x):
else: else:
return x return x
# our main plot class
class Plot: class Plot:
def __init__(self, width, height, *, def __init__(self, width, height, *,
color=False,
dots=False,
braille=False,
xlim=None, xlim=None,
ylim=None, ylim=None,
xlog=False, xlog=False,
ylog=False, ylog=False):
braille=False, # let Canvas handle braille/dots scaling
dots=False): self.canvas = Canvas(width, height,
# scale if we're printing with dots or braille color=color,
self.width = 2*width if braille else width dots=dots,
self.height = (4*height if braille braille=braille)
else 2*height if dots
else height)
# we handle xlim/ylim scaling
self.xlim = xlim or (0, width) self.xlim = xlim or (0, width)
self.ylim = ylim or (0, height) self.ylim = ylim or (0, height)
self.xlog = xlog self.xlog = xlog
self.ylog = ylog self.ylog = ylog
self.braille = braille
self.dots = dots
self.grid = [('',False)]*(self.width*self.height) # go ahead and draw out axis first, we let data overwrite this
# to make the best of the limited space
for x in range(self.width):
self.canvas.point(x, 0, char='-')
for y in range(self.height):
self.canvas.point(0, y, char='|')
self.canvas.point(self.width-1, 0, char='>')
self.canvas.point(0, self.height-1, char='^')
self.canvas.point(0, 0, char='+')
def scale(self, x, y): @property
def width(self):
return self.canvas.width
@property
def height(self):
return self.canvas.height
def _scale(self, x, y):
# scale and clamp # scale and clamp
try: try:
if self.xlog: if self.xlog:
@@ -581,131 +760,50 @@ class Plot:
return x, y return x, y
def point(self, x, y, *, def point(self, x, y, *,
color=COLORS[0], char=True,
char=True): color=''):
# scale # scale
x, y = self.scale(x, y) x, y = self._scale(x, y)
# ignore out of bounds points # render to canvas
if x >= 0 and x < self.width and y >= 0 and y < self.height: self.canvas.point(x, y,
self.grid[x + y*self.width] = (color, char) char=char,
color=color)
def line(self, x1, y1, x2, y2, *, def line(self, x1, y1, x2, y2, *,
color=COLORS[0], char=True,
char=True): color=''):
# scale # scale
x1, y1 = self.scale(x1, y1) x1, y1 = self._scale(x1, y1)
x2, y2 = self.scale(x2, y2) x2, y2 = self._scale(x2, y2)
# incremental error line algorithm # render to canvas
ex = abs(x2 - x1) self.canvas.line(x1, y1, x2, y2,
ey = -abs(y2 - y1) char=char,
dx = +1 if x1 < x2 else -1 color=color)
dy = +1 if y1 < y2 else -1
e = ex + ey
while True:
if x1 >= 0 and x1 < self.width and y1 >= 0 and y1 < self.height:
self.grid[x1 + y1*self.width] = (color, char)
e2 = 2*e
if x1 == x2 and y1 == y2:
break
if e2 > ey:
e += ey
x1 += dx
if x1 == x2 and y1 == y2:
break
if e2 < ex:
e += ex
y1 += dy
if x2 >= 0 and x2 < self.width and y2 >= 0 and y2 < self.height:
self.grid[x2 + y2*self.width] = (color, char)
def plot(self, coords, *, def plot(self, coords, *,
color=COLORS[0],
char=True, char=True,
line_char=True): line_char=True,
color=''):
# draw lines # draw lines
if line_char: if line_char:
for (x1, y1), (x2, y2) in zip(coords, coords[1:]): for (x1, y1), (x2, y2) in zip(coords, coords[1:]):
if y1 is not None and y2 is not None: if y1 is not None and y2 is not None:
self.line(x1, y1, x2, y2, self.line(x1, y1, x2, y2,
color=color, char=line_char,
char=line_char) color=color)
# draw points # draw points
if char and (not line_char or char is not True): if char and (not line_char or char is not True):
for x, y in coords: for x, y in coords:
if y is not None: if y is not None:
self.point(x, y, self.point(x, y,
color=color, char=char,
char=char) color=color)
def draw(self, row, *, def draw(self, row):
color=False): return self.canvas.draw(row)
# scale if needed
if self.braille:
xscale, yscale = 2, 4
elif self.dots:
xscale, yscale = 1, 2
else:
xscale, yscale = 1, 1
y = self.height//yscale-1 - row
row_ = []
for x in range(self.width//xscale):
best_f = ''
best_c = False
# encode into a byte
b = 0
for i in range(xscale*yscale):
f, c = self.grid[x*xscale+(xscale-1-(i%xscale))
+ (y*yscale+(i//xscale))*self.width]
if c:
b |= 1 << i
if f:
best_f = f
if c and c is not True:
best_c = c
# use byte to lookup character
if b:
if best_c:
c = best_c
elif self.braille:
c = CHARS_BRAILLE[b]
else:
c = CHARS_DOTS[b]
else:
c = ' '
# color?
if b and color and best_f:
c = '\x1b[%sm%s\x1b[m' % (best_f, c)
# draw axis in blank spaces
if not b:
if x == 0 and y == 0:
c = '+'
elif x == 0 and y == self.height//yscale-1:
c = '^'
elif x == self.width//xscale-1 and y == 0:
c = '>'
elif x == 0:
c = '|'
elif y == 0:
c = '-'
row_.append(c)
return ''.join(row_)
# some classes for organizing subplots into a grid # some classes for organizing subplots into a grid
@@ -982,6 +1080,7 @@ def main(csv_paths, *,
line_chars=[], line_chars=[],
colors=[], colors=[],
color=False, color=False,
dots=False,
braille=False, braille=False,
points=False, points=False,
points_and_lines=False, points_and_lines=False,
@@ -1427,12 +1526,13 @@ def main(csv_paths, *,
plot = Plot( plot = Plot(
subwidth, subwidth,
subheight, subheight,
color=color,
dots=dots or not line_chars,
braille=braille,
xlim=xlim_, xlim=xlim_,
ylim=ylim_, ylim=ylim_,
xlog=xlog_, xlog=xlog_,
ylog=ylog_, ylog=ylog_)
braille=not line_chars and braille,
dots=not line_chars and not braille)
for name, dataset in subdatasets.items(): for name, dataset in subdatasets.items():
plot.plot( plot.plot(
@@ -1551,7 +1651,7 @@ def main(csv_paths, *,
s.xmargin[1], '')) s.xmargin[1], ''))
# draw plot! # draw plot!
f.write(s.plot_.draw(subrow, color=color)) f.write(s.plot_.draw(subrow))
# footer # footer
else: else:

View File

@@ -321,6 +321,8 @@ class Canvas:
else: else:
xscale, yscale = 1, 1 xscale, yscale = 1, 1
self.width_ = width
self.height_ = height
self.width = xscale*width self.width = xscale*width
self.height = yscale*height self.height = yscale*height
self.xscale = xscale self.xscale = xscale
@@ -330,50 +332,63 @@ class Canvas:
self.braille = braille self.braille = braille
# create initial canvas # create initial canvas
self.grid = [False] * (self.width*self.height) self.chars = [0] * (width*height)
self.colors = [''] * (self.width*self.height) self.colors = [''] * (width*height)
def __getitem__(self, xy): def char(self, x, y, char=None):
x, y = xy
# ignore out of bounds # ignore out of bounds
if x < 0 or y < 0 or x >= self.width or y >= self.height: if x < 0 or y < 0 or x >= self.width or y >= self.height:
return return False
return self.grid[x + y*self.width] x_ = x // self.xscale
y_ = y // self.yscale
def __setitem__(self, xy, char): if char is not None:
x, y = xy c = self.chars[x_ + y_*self.width_]
# ignore out of bounds # mask in sub-char pixel?
if x < 0 or y < 0 or x >= self.width or y >= self.height: if isinstance(char, bool):
return if not isinstance(c, int):
c = 0
self.grid[x + y*self.width] = char self.chars[x_ + y_*self.width_] = (c
| (1
<< ((y%self.yscale)*self.xscale
+ (self.xscale-1)-(x%self.xscale))))
else:
self.chars[x_ + y_*self.width_] = char
else:
c = self.chars[x_ + y_*self.width_]
if isinstance(c, int):
return ((c
>> ((y%self.yscale)*self.xscale
+ (self.xscale-1)-(x%self.xscale)))
& 1) == 1
else:
return c
def color(self, x, y, color=None): def color(self, x, y, color=None):
# ignore out of bounds # ignore out of bounds
if x < 0 or y < 0 or x >= self.width or y >= self.height: if x < 0 or y < 0 or x >= self.width or y >= self.height:
return return ''
x_ = x // self.xscale
y_ = y // self.yscale
if color is not None: if color is not None:
self.colors[x + y*self.width] = color self.colors[x_ + y_*self.width_] = color
else: else:
return self.colors[x + y*self.width] return self.colors[x_ + y_*self.width_]
def __getitem__(self, xy):
x, y = xy
return self.char(x, y)
def __setitem__(self, xy, char):
x, y = xy
self.char(x, y, char)
def point(self, x, y, *, def point(self, x, y, *,
char=True, char=True,
color=''): color=''):
# make sure non-bool chars map attrs to all points under char self.char(x, y, char)
if not isinstance(char, bool): self.color(x, y, color)
xscale, yscale = self.xscale, self.yscale
else:
xscale, yscale = 1, 1
for i in range(xscale*yscale):
x_ = x-(x%xscale) + (xscale-1-(i%xscale))
y_ = y-(y%yscale) + (i//xscale)
self[x_, y_] = char
self.color(x_, y_, color)
def line(self, x1, y1, x2, y2, *, def line(self, x1, y1, x2, y2, *,
char=True, char=True,
@@ -386,7 +401,7 @@ class Canvas:
e = ex + ey e = ex + ey
while True: while True:
self.point(x1, y1, color=color, char=char) self.point(x1, y1, char=char, color=color)
e2 = 2*e e2 = 2*e
if x1 == x2 and y1 == y2: if x1 == x2 and y1 == y2:
@@ -403,7 +418,7 @@ class Canvas:
e += ex e += ex
y1 += dy y1 += dy
self.point(x2, y2, color=color, char=char) self.point(x2, y2, char=char, color=color)
def rect(self, x, y, w, h, *, def rect(self, x, y, w, h, *,
char=True, char=True,
@@ -427,47 +442,29 @@ class Canvas:
x_ += self.xscale x_ += self.xscale
def draw(self, row): def draw(self, row):
# scale if needed y_ = self.height_-1 - row
xscale, yscale = self.xscale, self.yscale
y = self.height//yscale-1 - row
row_ = [] row_ = []
for x in range(self.width//xscale): for x_ in range(self.width_):
color = '' # char?
char = False c = self.chars[x_ + y_*self.width_]
byte = 0 if isinstance(c, int):
for i in range(xscale*yscale): if self.braille:
x_ = x*xscale + (xscale-1-(i%xscale)) assert c < 256
y_ = y*yscale + (i//xscale) c = CHARS_BRAILLE[c]
elif self.dots:
# calculate char assert c < 4
char_ = self[x_, y_] c = CHARS_DOTS[c]
if char_:
byte |= 1 << i
if char_ is not True and char_ is not False:
char = char_
# keep track of best color
color_ = self.color(x_, y_)
if color_:
color = color_
# figure out winning char
if byte:
if char is not True and char is not False:
pass
elif self.braille:
char = CHARS_BRAILLE[byte]
else: else:
char = CHARS_DOTS[byte] assert c < 2
else: c = '.' if c else ' '
char = ' '
# color? # color?
if byte and self.color_ and color: if self.color_:
char = '\x1b[%sm%s\x1b[m' % (color, char) color = self.colors[x_ + y_*self.width_]
if color:
c = '\x1b[%sm%s\x1b[m' % (color, c)
row_.append(char) row_.append(c)
return ''.join(row_) return ''.join(row_)