Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[canvas-] draw lines at user-defined x or y #2489

Merged
merged 4 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 35 additions & 9 deletions visidata/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,28 @@ def rowsWithin(self, plotter_bbox, invert_y=False):

def draw(self, scr):
windowHeight, windowWidth = scr.getmaxyx()
disp_canvas_charset = self.options.disp_canvas_charset or ' o'
disp_canvas_charset += (256 - len(disp_canvas_charset)) * disp_canvas_charset[-1]

if self.needsRefresh:
self.render(windowHeight, windowWidth)

self.draw_pixels(scr)
self.draw_labels(scr)

def draw_empty(self, scr):
# use draw_empty() when calling draw_pixels() with clear_empty_squares=False
cursorBBox = self.plotterCursorBox
for char_y in range(0, self.plotheight//4):
for char_x in range(0, self.plotwidth//2):
cattr = ColorAttr()
ch = ' '
# draw cursor
if cursorBBox.contains(char_x*2, char_y*4) or \
cursorBBox.contains(char_x*2+1, char_y*4+3):
cattr = update_attr(cattr, colors.color_current_row)
scr.addstr(char_y, char_x, ch, cattr.attr)

def draw_pixels(self, scr, clear_empty_squares=True):
disp_canvas_charset = self.options.disp_canvas_charset or ' o'
disp_canvas_charset += (256 - len(disp_canvas_charset)) * disp_canvas_charset[-1]
if self.pixels:
cursorBBox = self.plotterCursorBox
getPixelAttr = self.getPixelAttrRandom if self.options.disp_pixel_random else self.getPixelAttrMost
Expand All @@ -264,19 +280,26 @@ def draw(self, scr):
braille_num += pow2
pow2 *= 2

ch = disp_canvas_charset[braille_num]
if braille_num != 0:
color = Counter(c for c in block_attrs if c).most_common(1)[0][0]
cattr = colors.get_color(color)
else:
cattr = ColorAttr()
# don't erase empty squares, useful for subclasses that draw elements like reflines
# before pixels are drawn
if not clear_empty_squares:
continue

# draw cursor
if cursorBBox.contains(char_x*2, char_y*4) or \
cursorBBox.contains(char_x*2+1, char_y*4+3):
cattr = update_attr(cattr, colors.color_current_row)

if cattr.attr:
scr.addstr(char_y, char_x, disp_canvas_charset[braille_num], cattr.attr)
scr.addstr(char_y, char_x, ch, cattr.attr)

def draw_labels(self, scr):
def _mark_overlap_text(labels, textobj):
def _overlaps(a, b):
a_x1, _, a_txt, _, _ = a
Expand Down Expand Up @@ -318,7 +341,7 @@ def _overlaps(a, b):
cursorBBox = self.plotterCursorBox
for c in txt:
w = dispwidth(c)
# check if the cursor contains the midpoint of the character box
# draw cursor if the cursor contains the midpoint of the character cell
if cursorBBox.contains(char_x*2+1, char_y*4+2):
char_attr = update_attr(cattr, colors.color_current_row)
clipdraw(scr, char_y, char_x, c, char_attr, w)
Expand Down Expand Up @@ -521,21 +544,22 @@ def fixPoint(self, plotterPoint, canvasPoint):
'adjust visibleBox.xymin so that canvasPoint is plotted at plotterPoint'
self.visibleBox.xmin = canvasPoint.x - self.canvasW(plotterPoint.x-self.plotviewBox.xmin)
self.visibleBox.ymin = canvasPoint.y - self.canvasH(plotterPoint.y-self.plotviewBox.ymin)
self.refresh()
self.resetBounds()

def zoomTo(self, bbox):
'set visible area to bbox, maintaining aspectRatio if applicable'
self.fixPoint(self.plotviewBox.xymin, bbox.xymin)
self.xzoomlevel=bbox.w/self.canvasBox.w
self.yzoomlevel=bbox.h/self.canvasBox.h
self.resetBounds()
midichef marked this conversation as resolved.
Show resolved Hide resolved

def incrZoom(self, incr):
self.xzoomlevel *= incr
self.yzoomlevel *= incr

self.resetBounds()

def resetBounds(self):
def resetBounds(self, refresh=True):
'create canvasBox and cursorBox if necessary, and set visibleBox w/h according to zoomlevels. then redisplay legends.'
if not self.canvasBox:
xmin, ymin, xmax, ymax = None, None, None, None
Expand Down Expand Up @@ -572,6 +596,8 @@ def resetBounds(self):
self.cursorBox = Box(cb_xmin, cb_ymin, self.canvasCharWidth, self.canvasCharHeight)

self.plotlegends()
if refresh:
self.refresh()

def calcTopCursorY(self):
'ymin for the cursor that will align its top with the top edge of the graph'
Expand Down Expand Up @@ -683,6 +709,7 @@ def render(self, h, w):
vd.cancelThread(*(t for t in self.currentThreads if t.name == 'plotAll_async'))
self.labels.clear()
self.resetCanvasDimensions(h, w)
self.resetBounds(refresh=False)
self.render_async()

@asyncthread
Expand Down Expand Up @@ -743,7 +770,6 @@ def deleteSourceRows(self, rows):
self.source.deleteBy(lambda r,rows=rows: r in rows)
self.reload()


Plotter.addCommand('v', 'visibility', 'options.disp_graph_labels = not options.disp_graph_labels', 'toggle disp_graph_labels option')

Canvas.addCommand(None, 'go-left', 'if cursorBox: sheet.cursorBox.xmin -= cursorBox.w', 'move cursor left by its width')
Expand Down Expand Up @@ -776,7 +802,7 @@ def deleteSourceRows(self, rows):

Canvas.addCommand('-', 'zoomout-cursor', 'tmp=cursorBox.center; incrZoom(options.disp_zoom_incr); fixPoint(plotviewBox.center, tmp)', 'zoom out from cursor center')
Canvas.addCommand('+', 'zoomin-cursor', 'tmp=cursorBox.center; incrZoom(1.0/options.disp_zoom_incr); fixPoint(plotviewBox.center, tmp)', 'zoom into cursor center')
Canvas.addCommand('_', 'zoom-all', 'sheet.canvasBox = None; sheet.visibleBox = None; sheet.xzoomlevel=sheet.yzoomlevel=1.0; resetBounds(); refresh()', 'zoom to fit full extent')
Canvas.addCommand('_', 'zoom-all', 'sheet.canvasBox = None; sheet.visibleBox = None; sheet.xzoomlevel=sheet.yzoomlevel=1.0; resetBounds()', 'zoom to fit full extent')
Canvas.addCommand('z_', 'set-aspect', 'sheet.aspectRatio = float(input("aspect ratio=", value=aspectRatio)); refresh()', 'set aspect ratio')

# set cursor box with left click
Expand Down
153 changes: 147 additions & 6 deletions visidata/graph.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import math

from visidata import VisiData, Canvas, Sheet, Progress, BoundingBox, Point, ColumnsSheet
from visidata import vd, asyncthread, dispwidth, colors, clipstr
from visidata import vd, asyncthread, dispwidth, colors, clipstr, ColorAttr, update_attr
from visidata.type_date import date
from statistics import median

vd.theme_option('color_graph_axis', 'bold', 'color for graph axis labels')
vd.theme_option('disp_graph_tick_x', '╵', 'character for graph x-axis ticks')
vd.theme_option('color_graph_refline', '', 'color for graph reference value lines')
vd.theme_option('disp_graph_reflines_x_charset', '▏││▕', 'charset to render vertical reference lines on graph')
vd.theme_option('disp_graph_reflines_y_charset', '▔──▁', 'charset to render horizontal reference lines on graph')
vd.theme_option('disp_graph_multiple_reflines_char', '▒', 'char to render multiple parallel reflines')


@VisiData.api
Expand All @@ -21,15 +27,16 @@ def fixPoint(self, plotterPoint, canvasPoint):
'adjust visibleBox.xymin so that canvasPoint is plotted at plotterPoint'
self.visibleBox.xmin = canvasPoint.x - self.canvasW(plotterPoint.x-self.plotviewBox.xmin)
self.visibleBox.ymin = canvasPoint.y - self.canvasH(self.plotviewBox.ymax-plotterPoint.y)
self.refresh()
self.resetBounds()

def rowsWithin(self, plotter_bbox):
return super().rowsWithin(plotter_bbox, invert_y=True)

def zoomTo(self, bbox):
super().zoomTo(bbox)
self.fixPoint(Point(self.plotviewBox.xmin, self.plotviewBox.ymin),
Point(bbox.xmin, bbox.ymax + 1/4*self.canvasCharHeight))
Point(bbox.xmin, bbox.ymax))
self.resetBounds()

def scaleY(self, canvasY) -> int:
'returns a plotter y coordinate for a canvas y coordinate, with the y direction inverted'
Expand Down Expand Up @@ -69,6 +76,11 @@ def __init__(self, *names, **kwargs):
self.ylabel_maxw = 0
super().__init__(*names, **kwargs)

self.reflines_x = []
self.reflines_y = []
self.reflines_char_x = {} # { x value in character coordinates -> character to use to draw that vertical line }
self.reflines_char_y = {} # { y value in character coordinates -> character to use to draw that horizontal line }

vd.numericCols(self.xcols) or vd.fail('at least one numeric key col necessary for x-axis')
self.ycols or vd.fail('%s is non-numeric' % '/'.join(yc.name for yc in kwargs.get('ycols')))

Expand Down Expand Up @@ -111,18 +123,89 @@ def reload(self):

self.xzoomlevel=self.yzoomlevel=1.0
self.resetBounds()
self.refresh()

def resetBounds(self):
super().resetBounds()
def draw(self, scr):
windowHeight, windowWidth = scr.getmaxyx()
if self.needsRefresh:
self.render(windowHeight, windowWidth)

# required because we use clear_empty_squares=False for draw_pixels()
self.draw_empty(scr)
# draw reflines first so pixels draw over them
self.draw_reflines(scr)
# use clear_empty_squares to keep reflines
self.draw_pixels(scr, clear_empty_squares=False)
self.draw_labels(scr)

def draw_reflines(self, scr):
cursorBBox = self.plotterCursorBox
# draws only on character cells that have reflines, leaves other cells unaffected
for char_y in range(0, self.plotheight//4):
has_y_line = char_y in self.reflines_char_y.keys()
for char_x in range(0, self.plotwidth//2):
has_x_line = char_x in self.reflines_char_x.keys()
if has_x_line or has_y_line:
cattr = colors.color_refline
if has_x_line:
ch = self.reflines_char_x[char_x]
# where two lines cross, draw the vertical line, not the horizontal one
elif has_y_line:
ch = self.reflines_char_y[char_y]
# draw cursor
if cursorBBox.contains(char_x*2, char_y*4) or \
cursorBBox.contains(char_x*2+1, char_y*4+3):
cattr = update_attr(cattr, colors.color_current_row)
scr.addstr(char_y, char_x, ch, cattr.attr)

def resetBounds(self, refresh=True):
super().resetBounds(refresh=False)
self.createLabels()
if refresh:
self.refresh()

def moveToRow(self, rowstr):
ymin, ymax = map(float, map(self.parseY, rowstr.split()))
self.cursorBox.ymin = ymin
self.cursorBox.h = ymax-ymin
return True

def plot_elements(self, invert_y=True):
self.plot_reflines()
super().plot_elements(invert_y=True)

def plot_reflines(self):
self.reflines_char_x = {}
self.reflines_char_y = {}

bb = self.visibleBox
xmin, ymin, xmax, ymax = bb.xmin, bb.ymin, bb.xmax, bb.ymax

for data_y in self.reflines_y:
data_y = float(data_y)
if data_y >= ymin and data_y <= ymax:
char_y, offset = divmod(self.scaleY(data_y), 4)
chars = self.options.disp_graph_reflines_y_charset
# if we're drawing two different reflines in the same square, fill it with a different char
if char_y in self.reflines_char_y and self.reflines_char_y[char_y] != chars[offset]:
self.reflines_char_y[char_y] = vd.options.disp_graph_multiple_reflines_char
else:
self.reflines_char_y[char_y] = chars[offset]

for data_x in self.reflines_x:
data_x = float(data_x)
if data_x >= xmin and data_x <= xmax:
plot_x = self.scaleX(data_x)
# plot_x is an integer count of plotter pixels, and each character box has 2 plotter pixels
char_x = plot_x // 2
# To subdivide the 2 plotter pixels per square into 4 zones, we have to first multiply by 2.
offset = 2*plot_x % 4
chars = self.options.disp_graph_reflines_x_charset
# if we're drawing two different reflines in the same square, fill it with a different char
if char_x in self.reflines_char_x and self.reflines_char_x[char_x] != chars[offset]:
self.reflines_char_y[char_x] = vd.options.disp_graph_multiple_reflines_char
else:
self.reflines_char_x[char_x] = chars[offset]

def moveToCol(self, colstr):
xmin, xmax = map(float, map(self.parseX, colstr.split()))
self.cursorBox.xmin = xmin
Expand Down Expand Up @@ -226,6 +309,58 @@ def rowsWithin(self, plotter_bbox):
rows = super().rowsWithin(plotter_bbox)
return sorted(rows, key=lambda r: self.row_order[self.source.rowid(r)])

def draw_refline_x(self):
xcol = vd.numericCols(self.xcols)[0]
xtype = xcol.type
val = median(xcol.getValues(self.sourceRows))
suggested = format_input_value(val, xtype)
xstrs = vd.input("add line(s) at x = ", type="reflinex", value=suggested, defaultLast=True).split()

for xstr in xstrs:
vals = [ v.strip() for v in xstr.split(',') ]
if len(vals) != len(self.xcols):
vd.fail(f'must have {len(self.xcols)} x values, had {len(vals)} values: {xstr}')
self.reflines_x += [xtype(val) for xcol, val in zip(self.xcols, vals) if xtype(val) not in self.reflines_x ]
self.refresh()

def draw_refline_y(self):
ytype = self.ycols[0].type
val = median(self.ycols[0].getValues(self.sourceRows))
suggested = format_input_value(val, ytype)
ystrs = vd.input("add line(s) at y = ", type="refliney", value=suggested, defaultLast=True).split()

self.reflines_y += [ ytype(y) for y in ystrs if ytype(y) not in self.reflines_y ]
self.refresh()

def erase_refline_x(self):
if len(self.reflines_x) == 0:
vd.fail(f'no x refline to erase')
xtype = vd.numericCols(self.xcols)[0].type
suggested = format_input_value(self.reflines_x[0], xtype)

xstrs = vd.input('remove line(s) at x = ', value=suggested, type='reflinex', defaultLast=True).split()
for input_x in xstrs:
self.reflines_x.remove(xtype(input_x))
self.refresh()

def erase_refline_y(self):
ytype = self.ycols[0].type
suggested = format_input_value(self.reflines_y[0], ytype) if self.reflines_y else ''
ystrs = vd.input('remove line(s) at y = ', value=suggested, type='refliney', defaultLast=True).split()
for y in ystrs:
try:
self.reflines_y.remove(ytype(y))
except ValueError:
vd.fail(f'value {y} not in reflines_y')
self.refresh()

def format_input_value(val, type):
'''format a value for entry into vd.input(), so its representation has no spaces and no commas'''
if type is date:
return val.strftime('%Y-%m-%d')
else:
return str(val)


Sheet.addCommand('.', 'plot-column', 'vd.push(GraphSheet(sheet.name, "graph", source=sheet, sourceRows=rows, xcols=keyCols, ycols=numericCols([cursorCol])))', 'plot current numeric column vs key columns; numeric key column is used for x-axis, while categorical key columns determine color')
Sheet.addCommand('g.', 'plot-numerics', 'vd.push(GraphSheet(sheet.name, "graph", source=sheet, sourceRows=rows, xcols=keyCols, ycols=numericCols(nonKeyVisibleCols)))', 'plot a graph of all visible numeric columns vs key columns')
Expand Down Expand Up @@ -261,6 +396,12 @@ def set_x(sheet, s):
Canvas.addCommand('y', 'resize-y-input', 'sheet.set_y(input("set ymin ymax="))', 'set ymin/ymax on graph axes')
Canvas.addCommand('x', 'resize-x-input', 'sheet.set_x(input("set xmin xmax="))', 'set xmin/xmax on graph axes')

GraphSheet.addCommand('gx', 'draw-refline-x', 'sheet.draw_refline_x()', 'draw a vertical line at x-values (space-separated)')
GraphSheet.addCommand('gy', 'draw-refline-y', 'sheet.draw_refline_y()', 'draw a horizontal line at y-values (space-separated)')
GraphSheet.addCommand('zx', 'erase-refline-x', 'sheet.erase_refline_x()', 'remove a horizontal line at x-values (space-separated)')
GraphSheet.addCommand('zy', 'erase-refline-y', 'sheet.erase_refline_y()', 'remove a vertical line at y-values (space-separated)')
GraphSheet.addCommand('gzx', 'erase-reflines-x', 'sheet.reflines_x = []; sheet.refresh()', 'erase all vertical x-value lines')
GraphSheet.addCommand('gzy', 'erase-reflines-y', 'sheet.reflines_y = []; sheet.refresh()', 'erase any horizontal y-value lines')

vd.addGlobals({
'GraphSheet': GraphSheet,
Expand Down
4 changes: 3 additions & 1 deletion visidata/themes/ascii8.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,7 @@
disp_menu_input='_',
disp_menu_fmt='7-bit ASCII 3-bit color',
plot_colors = 'white',
disp_histogram='*'
disp_histogram='*',
disp_graph_lines_x_charset='||||',
disp_graph_lines_y_charset='----'
)
Loading