scripts: Added treemap.py
Based on the d3 javascript library (https://d3js.org), treemap.py renders heirarchical data as ascii art: $ ./scripts/treemap.py lfs.code.csv \ -bfunction -fsize --chars=asdf -W60 -H8 total 65454, avg 369 +-366.8σ, min 3, max 4990 aaaassssddddddaaaadddddssddfffaaadfffaassaassfasssdfdfsddfad aaaassssddddddaaaadddddssddfffaaadfffaassdfaafasssdfdfsddfsf aaaassssddddddaaaafffffssddfffsssdaaaddffdfaadfaaasdfafaasfa aaaassssddddddaaaafffffaaaddddsssaassddffdfaaffssfssfsfadffa aaaassssffffffssssfffffaaaddddsssaassssffddffffssfdffsadfsad aaaassssffffffssssaaaaasssffffddfaassssaaassdaaddadffsadadad aaaassssffffffssssaaaaasssffffddfddffddssassdfassadffsadaffa aaaassssffffffssssaaaaasssffffddfddffddssassdfaddsdadasfsada (Normally this is also colored, but you know.) I've been playing around with d3 to try to better visualize code costs in littlefs, and it's been quite neat. I figured it would be useful to directly integrate a similar treemap renderer into our result scripts. That being said, this ascii rendering is probably too difficult to parse for any non-trivial data. I'm also working on an svg-based renderer, so treemap.py is really just for in-terminal previews and an exercise to understand the underlying algorithms, similar to plot.py/plotmpl.py.
This commit is contained in:
@@ -554,6 +554,8 @@ def fold(results, by=None, x=None, y=None, defines=[], labels=None):
|
|||||||
else:
|
else:
|
||||||
y__ = None
|
y__ = None
|
||||||
|
|
||||||
|
# do _not_ sum ys here, it's tempting but risks
|
||||||
|
# incorrect and misleading results
|
||||||
dataset.append((x__, y__))
|
dataset.append((x__, y__))
|
||||||
|
|
||||||
# hide x/y if there is only one field
|
# hide x/y if there is only one field
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ def fold(results, by=None, x=None, y=None, defines=[], labels=None):
|
|||||||
else:
|
else:
|
||||||
y__ = None
|
y__ = None
|
||||||
|
|
||||||
|
# do _not_ sum ys here, it's tempting but risks
|
||||||
|
# incorrect and misleading results
|
||||||
dataset.append((x__, y__))
|
dataset.append((x__, y__))
|
||||||
|
|
||||||
# hide x/y if there is only one field
|
# hide x/y if there is only one field
|
||||||
|
|||||||
Executable
+895
@@ -0,0 +1,895 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
#
|
||||||
|
# Inspired by d3:
|
||||||
|
# https://d3js.org
|
||||||
|
#
|
||||||
|
|
||||||
|
# prevent local imports
|
||||||
|
if __name__ == "__main__":
|
||||||
|
__import__('sys').path.pop(0)
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import collections as co
|
||||||
|
import csv
|
||||||
|
import itertools as it
|
||||||
|
import math as mt
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
|
# we don't actually need that many chars/colors thanks to the
|
||||||
|
# 4-colorability of all 2d maps
|
||||||
|
CHARS = ['.']
|
||||||
|
COLORS = [34, 31, 32, 35, 33, 36]
|
||||||
|
|
||||||
|
CHARS_DOTS = " .':"
|
||||||
|
CHARS_BRAILLE = (
|
||||||
|
'⠀⢀⡀⣀⠠⢠⡠⣠⠄⢄⡄⣄⠤⢤⡤⣤' '⠐⢐⡐⣐⠰⢰⡰⣰⠔⢔⡔⣔⠴⢴⡴⣴'
|
||||||
|
'⠂⢂⡂⣂⠢⢢⡢⣢⠆⢆⡆⣆⠦⢦⡦⣦' '⠒⢒⡒⣒⠲⢲⡲⣲⠖⢖⡖⣖⠶⢶⡶⣶'
|
||||||
|
'⠈⢈⡈⣈⠨⢨⡨⣨⠌⢌⡌⣌⠬⢬⡬⣬' '⠘⢘⡘⣘⠸⢸⡸⣸⠜⢜⡜⣜⠼⢼⡼⣼'
|
||||||
|
'⠊⢊⡊⣊⠪⢪⡪⣪⠎⢎⡎⣎⠮⢮⡮⣮' '⠚⢚⡚⣚⠺⢺⡺⣺⠞⢞⡞⣞⠾⢾⡾⣾'
|
||||||
|
'⠁⢁⡁⣁⠡⢡⡡⣡⠅⢅⡅⣅⠥⢥⡥⣥' '⠑⢑⡑⣑⠱⢱⡱⣱⠕⢕⡕⣕⠵⢵⡵⣵'
|
||||||
|
'⠃⢃⡃⣃⠣⢣⡣⣣⠇⢇⡇⣇⠧⢧⡧⣧' '⠓⢓⡓⣓⠳⢳⡳⣳⠗⢗⡗⣗⠷⢷⡷⣷'
|
||||||
|
'⠉⢉⡉⣉⠩⢩⡩⣩⠍⢍⡍⣍⠭⢭⡭⣭' '⠙⢙⡙⣙⠹⢹⡹⣹⠝⢝⡝⣝⠽⢽⡽⣽'
|
||||||
|
'⠋⢋⡋⣋⠫⢫⡫⣫⠏⢏⡏⣏⠯⢯⡯⣯' '⠛⢛⡛⣛⠻⢻⡻⣻⠟⢟⡟⣟⠿⢿⡿⣿')
|
||||||
|
|
||||||
|
|
||||||
|
def openio(path, mode='r', buffering=-1):
|
||||||
|
# allow '-' for stdin/stdout
|
||||||
|
if path == '-':
|
||||||
|
if 'r' in mode:
|
||||||
|
return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
|
||||||
|
else:
|
||||||
|
return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
|
||||||
|
else:
|
||||||
|
return open(path, mode, buffering)
|
||||||
|
|
||||||
|
# parse different data representations
|
||||||
|
def dat(x):
|
||||||
|
# allow the first part of an a/b fraction
|
||||||
|
if '/' in x:
|
||||||
|
x, _ = x.split('/', 1)
|
||||||
|
|
||||||
|
# first try as int
|
||||||
|
try:
|
||||||
|
return int(x, 0)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# then try as float
|
||||||
|
try:
|
||||||
|
return float(x)
|
||||||
|
# just don't allow infinity or nan
|
||||||
|
if mt.isinf(x) or mt.isnan(x):
|
||||||
|
raise ValueError("invalid dat %r" % x)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# else give up
|
||||||
|
raise ValueError("invalid dat %r" % x)
|
||||||
|
|
||||||
|
def collect(csv_paths, defines=[]):
|
||||||
|
# collect results from CSV files
|
||||||
|
fields = []
|
||||||
|
results = []
|
||||||
|
for path in csv_paths:
|
||||||
|
try:
|
||||||
|
with openio(path) as f:
|
||||||
|
reader = csv.DictReader(f, restval='')
|
||||||
|
fields.extend(
|
||||||
|
k for k in reader.fieldnames
|
||||||
|
if k not in fields)
|
||||||
|
for r in reader:
|
||||||
|
# filter by matching defines
|
||||||
|
if not all(k in r and r[k] in vs for k, vs in defines):
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(r)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return fields, results
|
||||||
|
|
||||||
|
def fold(results, by=None, fields=None, labels=None, defines=[]):
|
||||||
|
# filter by matching defines
|
||||||
|
if defines:
|
||||||
|
results_ = []
|
||||||
|
for r in results:
|
||||||
|
if all(k in r and r[k] in vs for k, vs in defines):
|
||||||
|
results_.append(r)
|
||||||
|
results = results_
|
||||||
|
|
||||||
|
if by:
|
||||||
|
# find all 'by' values
|
||||||
|
keys = set()
|
||||||
|
for r in results:
|
||||||
|
keys.add(tuple(r.get(k, '') for k in by))
|
||||||
|
keys = sorted(keys)
|
||||||
|
|
||||||
|
# collect dataset
|
||||||
|
datasets = co.OrderedDict()
|
||||||
|
labels_ = co.OrderedDict()
|
||||||
|
for key in (keys if by else [()]):
|
||||||
|
for field in fields:
|
||||||
|
# organize by 'by' and field
|
||||||
|
for r in results:
|
||||||
|
# filter by 'by'
|
||||||
|
if by and not all(
|
||||||
|
k in r and r[k] == v
|
||||||
|
for k, v in zip(by, key)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# find field
|
||||||
|
if field is not None:
|
||||||
|
if field not in r:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
v = dat(r[field])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
v = None
|
||||||
|
|
||||||
|
# hide 'field' if there is only one field
|
||||||
|
key_ = key
|
||||||
|
if len(fields or []) > 1 or not key_:
|
||||||
|
key_ += (field,)
|
||||||
|
# do _not_ sum v here, it's tempting but risks
|
||||||
|
# incorrect and misleading results
|
||||||
|
datasets[key_] = v
|
||||||
|
|
||||||
|
# also find label?
|
||||||
|
if labels is not None:
|
||||||
|
for label_ in labels:
|
||||||
|
if label_ not in r:
|
||||||
|
continue
|
||||||
|
labels_[key_] = r[label_]
|
||||||
|
|
||||||
|
return datasets, labels_
|
||||||
|
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
self.width = 2*width
|
||||||
|
self.height = 4*height
|
||||||
|
elif dots:
|
||||||
|
self.width = width
|
||||||
|
self.height = 2*height
|
||||||
|
else:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.color_ = color
|
||||||
|
self.dots = dots
|
||||||
|
self.braille = braille
|
||||||
|
|
||||||
|
# create initial canvas
|
||||||
|
self.grid = [False] * (self.width*self.height)
|
||||||
|
self.colors = [''] * (self.width*self.height)
|
||||||
|
|
||||||
|
def __getitem__(self, xy):
|
||||||
|
x, y = xy
|
||||||
|
# ignore out of bounds
|
||||||
|
if x < 0 or y < 0 or x >= self.width or y >= self.height:
|
||||||
|
return
|
||||||
|
|
||||||
|
return self.grid[x + y*self.width]
|
||||||
|
|
||||||
|
def __setitem__(self, xy, char):
|
||||||
|
x, y = xy
|
||||||
|
# ignore out of bounds
|
||||||
|
if x < 0 or y < 0 or x >= self.width or y >= self.height:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.grid[x + y*self.width] = char
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if color is not None:
|
||||||
|
self.colors[x + y*self.width] = color
|
||||||
|
else:
|
||||||
|
return self.colors[x + y*self.width]
|
||||||
|
|
||||||
|
def point(self, x, y, *,
|
||||||
|
char=True,
|
||||||
|
color=''):
|
||||||
|
# scale if needed
|
||||||
|
if self.braille and char is not True and char is not False:
|
||||||
|
xscale, yscale = 2, 4
|
||||||
|
elif self.dots and char is not True and char is not False:
|
||||||
|
xscale, yscale = 1, 2
|
||||||
|
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, *,
|
||||||
|
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, color=color, char=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
|
||||||
|
|
||||||
|
self.point(x2, y2, color=color, char=char)
|
||||||
|
|
||||||
|
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, *,
|
||||||
|
color=''):
|
||||||
|
# scale if needed
|
||||||
|
if self.braille:
|
||||||
|
xscale, yscale = 2, 4
|
||||||
|
elif self.dots:
|
||||||
|
xscale, yscale = 1, 2
|
||||||
|
else:
|
||||||
|
xscale, yscale = 1, 1
|
||||||
|
|
||||||
|
for i, char in enumerate(label):
|
||||||
|
self.point(x+i*xscale, y, char=char, color=color)
|
||||||
|
|
||||||
|
def draw(self, 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):
|
||||||
|
color = ''
|
||||||
|
char = False
|
||||||
|
byte = 0
|
||||||
|
for i in range(xscale*yscale):
|
||||||
|
x_ = x*xscale + (xscale-1-(i%xscale))
|
||||||
|
y_ = y*yscale + (i//xscale)
|
||||||
|
|
||||||
|
# calculate char
|
||||||
|
char_ = self[x_, y_]
|
||||||
|
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:
|
||||||
|
char = CHARS_DOTS[byte]
|
||||||
|
else:
|
||||||
|
char = ' '
|
||||||
|
|
||||||
|
# color?
|
||||||
|
if byte and self.color_ and color:
|
||||||
|
char = '\x1b[%sm%s\x1b[m' % (color, char)
|
||||||
|
|
||||||
|
row_.append(char)
|
||||||
|
|
||||||
|
return ''.join(row_)
|
||||||
|
|
||||||
|
|
||||||
|
# a type to represent tiles
|
||||||
|
class Tile:
|
||||||
|
def __init__(self, key, children,
|
||||||
|
x=None, y=None, width=None, height=None, *,
|
||||||
|
label=None,
|
||||||
|
char=None,
|
||||||
|
color=None):
|
||||||
|
self.key = key
|
||||||
|
if isinstance(children, list):
|
||||||
|
self.children = children
|
||||||
|
self.value = sum(c.value for c in children)
|
||||||
|
else:
|
||||||
|
self.children = []
|
||||||
|
self.value = children
|
||||||
|
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.label = label
|
||||||
|
self.char = char
|
||||||
|
self.color = color
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Tile(%r, %r, %r, %r, %r, %r)' % (
|
||||||
|
','.join(self.key), self.value,
|
||||||
|
self.x, self.y, self.width, self.height)
|
||||||
|
|
||||||
|
# recursively build heirarchy
|
||||||
|
@staticmethod
|
||||||
|
def merge(tiles, prefix=()):
|
||||||
|
# organize by 'by' field
|
||||||
|
tiles_ = co.OrderedDict()
|
||||||
|
for t in tiles:
|
||||||
|
if len(prefix)+1 >= len(t.key):
|
||||||
|
tiles_[t.key] = t
|
||||||
|
else:
|
||||||
|
key = prefix + (t.key[len(prefix)],)
|
||||||
|
if key not in tiles_:
|
||||||
|
tiles_[key] = []
|
||||||
|
tiles_[key].append(t)
|
||||||
|
|
||||||
|
tiles__ = []
|
||||||
|
for key, t in tiles_.items():
|
||||||
|
if isinstance(t, Tile):
|
||||||
|
tiles__.append(t)
|
||||||
|
else:
|
||||||
|
tiles__.append(Tile.merge(t, key))
|
||||||
|
tiles_ = tiles__
|
||||||
|
|
||||||
|
return Tile(prefix, tiles_)
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
return self.value < other.value
|
||||||
|
|
||||||
|
# sort recursively
|
||||||
|
def sort(self):
|
||||||
|
self.children.sort(reverse=True)
|
||||||
|
for t in self.children:
|
||||||
|
t.sort()
|
||||||
|
|
||||||
|
def tiles(self):
|
||||||
|
yield self
|
||||||
|
for child in self.children:
|
||||||
|
yield from child.tiles()
|
||||||
|
|
||||||
|
def leaves(self):
|
||||||
|
for t in self.tiles():
|
||||||
|
if not t.children:
|
||||||
|
yield t
|
||||||
|
|
||||||
|
|
||||||
|
# our parititioning schemes
|
||||||
|
|
||||||
|
def partition_binary(tile, *,
|
||||||
|
partition=None):
|
||||||
|
partition = partition or partition_binary
|
||||||
|
|
||||||
|
sums = [0]
|
||||||
|
for t in tile.children:
|
||||||
|
sums.append(sums[-1] + t.value)
|
||||||
|
|
||||||
|
# recursively partition into a roughly weight-balanced binary tree
|
||||||
|
def partition_(i, j, value, x, y, width, height):
|
||||||
|
# no child? guess we're done
|
||||||
|
if i == j:
|
||||||
|
return
|
||||||
|
# single child? assign the partition
|
||||||
|
elif i == j-1:
|
||||||
|
tile.children[i].x = x
|
||||||
|
tile.children[i].y = y
|
||||||
|
tile.children[i].width = width
|
||||||
|
tile.children[i].height = height
|
||||||
|
return
|
||||||
|
|
||||||
|
# binary search to find best split index
|
||||||
|
target = sums[i] + (value / 2)
|
||||||
|
k = bisect.bisect(sums, target, i+1, j-1)
|
||||||
|
|
||||||
|
# nudge split index if it results in less error
|
||||||
|
if k > i+1 and (sums[k] - target) > (target - sums[k-1]):
|
||||||
|
k -= 1
|
||||||
|
|
||||||
|
l = sums[k] - sums[i]
|
||||||
|
r = value - l
|
||||||
|
|
||||||
|
# split horizontally?
|
||||||
|
if width > height:
|
||||||
|
dx = ((sums[k] - sums[i]) / value) * width
|
||||||
|
partition_(i, k, l, x, y, dx, height)
|
||||||
|
partition_(k, j, r, x+dx, y, width-dx, height)
|
||||||
|
|
||||||
|
# split vertically?
|
||||||
|
else:
|
||||||
|
dy = ((sums[k] - sums[i]) / value) * height
|
||||||
|
partition_(i, k, l, x, y, width, dy)
|
||||||
|
partition_(k, j, r, x, y+dy, width, height-dy)
|
||||||
|
|
||||||
|
partition_(0, len(tile.children), tile.value,
|
||||||
|
tile.x, tile.y, tile.width, tile.height)
|
||||||
|
|
||||||
|
# recursively partition
|
||||||
|
for t in tile.children:
|
||||||
|
partition(t, partition=partition)
|
||||||
|
|
||||||
|
def partition_slice(tile, *,
|
||||||
|
partition=None):
|
||||||
|
partition = partition or partition_slice
|
||||||
|
|
||||||
|
# give each child a slice
|
||||||
|
x_ = tile.x
|
||||||
|
for t in tile.children:
|
||||||
|
t.x = x_
|
||||||
|
t.y = tile.y
|
||||||
|
t.width = (t.value / tile.value) * tile.width
|
||||||
|
t.height = tile.height
|
||||||
|
|
||||||
|
x_ += t.width
|
||||||
|
|
||||||
|
# recursively partition
|
||||||
|
for t in tile.children:
|
||||||
|
partition(t, partition=partition)
|
||||||
|
|
||||||
|
def partition_dice(tile, *,
|
||||||
|
partition=None):
|
||||||
|
partition = partition or partition_dice
|
||||||
|
|
||||||
|
# give each child a slice
|
||||||
|
y_ = tile.y
|
||||||
|
for t in tile.children:
|
||||||
|
t.x = tile.x
|
||||||
|
t.y = y_
|
||||||
|
t.width = tile.width
|
||||||
|
t.height = (t.value / tile.value) * tile.height
|
||||||
|
|
||||||
|
y_ += t.height
|
||||||
|
|
||||||
|
# recursively partition
|
||||||
|
for t in tile.children:
|
||||||
|
partition(t, partition=partition)
|
||||||
|
|
||||||
|
def partition_slice_and_dice(tile, *,
|
||||||
|
partition=None):
|
||||||
|
partition_slice(tile, partition=partition or partition_dice_and_slice)
|
||||||
|
|
||||||
|
def partition_dice_and_slice(tile, *,
|
||||||
|
partition=None):
|
||||||
|
partition_dice(tile, partition=partition or partition_slice_and_dice)
|
||||||
|
|
||||||
|
def partition_squarify(tile, *,
|
||||||
|
partition=None, aspect_ratio=None):
|
||||||
|
partition = partition or partition_squarify
|
||||||
|
# derive target aspect ratio from top-level tile
|
||||||
|
aspect_ratio = aspect_ratio or (tile.width, tile.height)
|
||||||
|
|
||||||
|
# this algorithm is described here:
|
||||||
|
# https://www.win.tue.nl/~vanwijk/stm.pdf
|
||||||
|
i = 0
|
||||||
|
x = tile.x
|
||||||
|
y = tile.y
|
||||||
|
value = tile.value
|
||||||
|
width = tile.width
|
||||||
|
height = tile.height
|
||||||
|
# this is our target aspect ratio, note we don't really care
|
||||||
|
# about width vs height until actually slicing
|
||||||
|
ratio = aspect_ratio[0] / aspect_ratio[1]
|
||||||
|
|
||||||
|
while i < len(tile.children):
|
||||||
|
# calculate initial aspect ratio
|
||||||
|
sum_ = tile.children[i].value
|
||||||
|
min_ = tile.children[i].value
|
||||||
|
max_ = tile.children[i].value
|
||||||
|
w = value * (ratio / max(width/height, height/width))
|
||||||
|
ratio_ = max((max_*w)/(sum_**2), (sum_**2)/(min_*w))
|
||||||
|
|
||||||
|
# keep adding children to this row/col until it starts to hurt
|
||||||
|
# our aspect ratio
|
||||||
|
j = i + 1
|
||||||
|
while j < len(tile.children):
|
||||||
|
sum__ = sum_ + tile.children[j].value
|
||||||
|
min__ = min(min_, tile.children[j].value)
|
||||||
|
max__ = max(max_, tile.children[j].value)
|
||||||
|
ratio__ = max((max__*w)/(sum__**2), (sum__**2)/(min__*w))
|
||||||
|
if ratio__ > ratio_:
|
||||||
|
break
|
||||||
|
|
||||||
|
sum_ = sum__
|
||||||
|
min_ = min__
|
||||||
|
max_ = max__
|
||||||
|
ratio_ = ratio__
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
# vertical col? dice horizontally?
|
||||||
|
if width > height:
|
||||||
|
dx = (sum_ / value) * width
|
||||||
|
partition_dice(Tile(
|
||||||
|
tile.key, tile.children[i:j],
|
||||||
|
x, y, dx, height),
|
||||||
|
partition=lambda *_, **__: ())
|
||||||
|
x += dx
|
||||||
|
width -= dx
|
||||||
|
|
||||||
|
# horizontal row? slice vertically?
|
||||||
|
else:
|
||||||
|
dy = (sum_ / value) * height
|
||||||
|
partition_slice(Tile(
|
||||||
|
tile.key, tile.children[i:j],
|
||||||
|
x, y, width, dy),
|
||||||
|
partition=lambda *_, **__: ())
|
||||||
|
y += dy
|
||||||
|
height -= dy
|
||||||
|
|
||||||
|
# start partitioning the other direction
|
||||||
|
value -= sum_
|
||||||
|
i = j
|
||||||
|
|
||||||
|
# recursively partition
|
||||||
|
for t in tile.children:
|
||||||
|
partition(t, partition=partition, aspect_ratio=aspect_ratio)
|
||||||
|
|
||||||
|
|
||||||
|
def main(csv_paths, *,
|
||||||
|
by=None,
|
||||||
|
fields=None,
|
||||||
|
labels=None,
|
||||||
|
defines=[],
|
||||||
|
color=False,
|
||||||
|
dots=False,
|
||||||
|
braille=False,
|
||||||
|
chars=None,
|
||||||
|
colors=None,
|
||||||
|
width=None,
|
||||||
|
height=None,
|
||||||
|
no_header=False,
|
||||||
|
to_scale=None,
|
||||||
|
aspect_ratio=(1,1),
|
||||||
|
**args):
|
||||||
|
# figure out what color should be
|
||||||
|
if color == 'auto':
|
||||||
|
color = sys.stdout.isatty()
|
||||||
|
elif color == 'always':
|
||||||
|
color = True
|
||||||
|
else:
|
||||||
|
color = False
|
||||||
|
|
||||||
|
# figure out chars/colors
|
||||||
|
if chars is not None:
|
||||||
|
chars_ = chars
|
||||||
|
else:
|
||||||
|
chars_ = CHARS
|
||||||
|
|
||||||
|
if colors is not None:
|
||||||
|
colors_ = colors
|
||||||
|
else:
|
||||||
|
colors_ = COLORS
|
||||||
|
|
||||||
|
# figure out width/height
|
||||||
|
if width is None:
|
||||||
|
width_ = min(80, shutil.get_terminal_size((80, 5))[0])
|
||||||
|
elif width:
|
||||||
|
width_ = width
|
||||||
|
else:
|
||||||
|
width_ = shutil.get_terminal_size((80, 5))[0]
|
||||||
|
|
||||||
|
if height is None:
|
||||||
|
height_ = 1
|
||||||
|
elif height:
|
||||||
|
height_ = height
|
||||||
|
else:
|
||||||
|
height_ = shutil.get_terminal_size((80, 5))[1]
|
||||||
|
# make space for shell prompt and stuff
|
||||||
|
height_ = max(height_-(1 if no_header else 2), 1)
|
||||||
|
|
||||||
|
# first collect results from CSV files
|
||||||
|
fields_, results = collect(csv_paths, defines)
|
||||||
|
|
||||||
|
if not by and not fields:
|
||||||
|
print("error: needs --by or --fields to figure out fields",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(-1)
|
||||||
|
|
||||||
|
# if by not specified, guess it's anything not in fields/labels/defines
|
||||||
|
if not by:
|
||||||
|
by = [k for k in fields_
|
||||||
|
if k not in (fields or [])
|
||||||
|
and k not in (labels or [])
|
||||||
|
and not any(k == k_ for k_, _ in defines)]
|
||||||
|
|
||||||
|
# if fields not specified, guess it's anything not in by/labels/defines
|
||||||
|
if not fields:
|
||||||
|
fields = [k for k in fields_
|
||||||
|
if k not in (by or [])
|
||||||
|
and k not in (labels or [])
|
||||||
|
and not any(k == k_ for k_, _ in defines)]
|
||||||
|
|
||||||
|
# then extract the requested dataset
|
||||||
|
datasets, labels_ = fold(results, by, fields, labels, defines)
|
||||||
|
|
||||||
|
# build tile heirarchy
|
||||||
|
tile = Tile.merge([
|
||||||
|
Tile(k, v, label=labels_.get(k))
|
||||||
|
for k, v in datasets.items()
|
||||||
|
# discard anything with the value 0 early, otherwise these
|
||||||
|
# cause a lot of problems
|
||||||
|
if v != 0])
|
||||||
|
|
||||||
|
# sort
|
||||||
|
tile.sort()
|
||||||
|
|
||||||
|
# assign colors/chars after sorting to try to minimize touching
|
||||||
|
# colors, while keeping things somewhat reproducible
|
||||||
|
|
||||||
|
# use colors for top of tree
|
||||||
|
for i, t in enumerate(tile.children):
|
||||||
|
for t_ in t.tiles():
|
||||||
|
t_.color = i % len(colors_)
|
||||||
|
|
||||||
|
# and chars for bottom of tree
|
||||||
|
for i, t in enumerate(tile.leaves()):
|
||||||
|
t.char = i % len(chars_)
|
||||||
|
|
||||||
|
# scale width/height if requested now that we have our data
|
||||||
|
if to_scale and (width is None or height is None):
|
||||||
|
# scale if needed
|
||||||
|
if braille:
|
||||||
|
xscale, yscale = 2, 4
|
||||||
|
elif dots:
|
||||||
|
xscale, yscale = 1, 2
|
||||||
|
else:
|
||||||
|
xscale, yscale = 1, 1
|
||||||
|
|
||||||
|
# scale width only
|
||||||
|
if height is not None:
|
||||||
|
width_ = mt.ceil(
|
||||||
|
((tile.value * to_scale) / (height_*yscale))
|
||||||
|
/ xscale)
|
||||||
|
# scale height only
|
||||||
|
elif width is not None:
|
||||||
|
height_ = mt.ceil(
|
||||||
|
((tile.value * to_scale) / (width_*xscale))
|
||||||
|
/ yscale)
|
||||||
|
# scale based on aspect-ratio
|
||||||
|
else:
|
||||||
|
width_ = mt.ceil(
|
||||||
|
(mt.sqrt(tile.value * to_scale)
|
||||||
|
* (aspect_ratio[0] / aspect_ratio[1]))
|
||||||
|
/ xscale)
|
||||||
|
height_ = mt.ceil(
|
||||||
|
((tile.value * to_scale) / (width_*xscale))
|
||||||
|
/ yscale)
|
||||||
|
|
||||||
|
# create a canvas
|
||||||
|
canvas = Canvas(width_, height_,
|
||||||
|
color=color,
|
||||||
|
dots=dots or chars is None,
|
||||||
|
braille=braille)
|
||||||
|
|
||||||
|
# recursively partition tiles
|
||||||
|
tile.x = 0
|
||||||
|
tile.y = 0
|
||||||
|
tile.width = canvas.width
|
||||||
|
tile.height = canvas.height
|
||||||
|
if tile.value:
|
||||||
|
if args.get('binary'):
|
||||||
|
partition_binary(tile)
|
||||||
|
elif args.get('slice'):
|
||||||
|
partition_slice(tile)
|
||||||
|
elif args.get('dice'):
|
||||||
|
partition_dice(tile)
|
||||||
|
elif args.get('slice_and_dice'):
|
||||||
|
partition_slice_and_dice(tile)
|
||||||
|
elif args.get('dice_and_slice'):
|
||||||
|
partition_dice_and_slice(tile)
|
||||||
|
elif args.get('squarify'):
|
||||||
|
partition_squarify(tile)
|
||||||
|
else:
|
||||||
|
# default to binary partitioning
|
||||||
|
partition_binary(tile)
|
||||||
|
|
||||||
|
# render to canvas
|
||||||
|
labels_ = []
|
||||||
|
for t in tile.leaves():
|
||||||
|
# this extra +0.1 and using points instead of width/height is
|
||||||
|
# to help minimize rounding errors
|
||||||
|
x__ = int(t.x+0.1)
|
||||||
|
y__ = int(t.y+0.1)
|
||||||
|
width__ = int(t.x+t.width+0.1) - x__
|
||||||
|
height__ = int(t.y+t.height+0.1) - y__
|
||||||
|
# skip anything with zero weight/height after aligning things
|
||||||
|
if width__ == 0 or height__ == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# flip y
|
||||||
|
y__ = canvas.height - (y__+height__)
|
||||||
|
|
||||||
|
canvas.rect(x__, y__, width__, height__,
|
||||||
|
# default to first letter in each label/key
|
||||||
|
char=(True if braille or dots
|
||||||
|
else t.label[0]
|
||||||
|
if chars is None
|
||||||
|
and t.label is not None
|
||||||
|
else t.key[-1][0]
|
||||||
|
if chars is None
|
||||||
|
and t.key
|
||||||
|
and t.key[-1]
|
||||||
|
else chars_[t.char or 0]),
|
||||||
|
color=colors_[t.color or 0])
|
||||||
|
|
||||||
|
if labels:
|
||||||
|
if t.label is not None:
|
||||||
|
label__ = t.label
|
||||||
|
else:
|
||||||
|
label__ = ','.join(t.key)
|
||||||
|
|
||||||
|
# render these later so they get priority
|
||||||
|
labels_.append((x__, y__+height__-1, label__[:width__]))
|
||||||
|
|
||||||
|
for x__, y__, label__ in labels_:
|
||||||
|
canvas.label(x__, y__, label__)
|
||||||
|
|
||||||
|
# print some summary info
|
||||||
|
if not no_header:
|
||||||
|
leaves = list(tile.leaves())
|
||||||
|
mean = tile.value / max(len(leaves), 1)
|
||||||
|
stddev = mt.sqrt(sum((t.value - mean)**2 for v in leaves)
|
||||||
|
/ max(len(leaves), 1))
|
||||||
|
min_ = min((t.value for t in leaves), default=0)
|
||||||
|
max_ = max((t.value for t in leaves), default=0)
|
||||||
|
print('total %d, avg %d +-%.1fσ, min %d, max %d' % (
|
||||||
|
tile.value, mean, stddev, min_, max_))
|
||||||
|
|
||||||
|
# draw canvas
|
||||||
|
for row in range(height_):
|
||||||
|
line = canvas.draw(row)
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Render CSV files as a treemap.",
|
||||||
|
allow_abbrev=False)
|
||||||
|
parser.add_argument(
|
||||||
|
'csv_paths',
|
||||||
|
nargs='*',
|
||||||
|
help="Input *.csv files.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-b', '--by',
|
||||||
|
action='append',
|
||||||
|
help="Group by this field.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-f', '--field',
|
||||||
|
dest='fields',
|
||||||
|
action='append',
|
||||||
|
help="Field to use for tile sizes.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-l', '--label',
|
||||||
|
nargs='?',
|
||||||
|
dest='labels',
|
||||||
|
action='append',
|
||||||
|
help="Field to use as tile label.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-D', '--define',
|
||||||
|
dest='defines',
|
||||||
|
action='append',
|
||||||
|
type=lambda x: (
|
||||||
|
lambda k, vs: (
|
||||||
|
k.strip(),
|
||||||
|
{v.strip() for v in vs.split(',')})
|
||||||
|
)(*x.split('=', 1)),
|
||||||
|
help="Only include results where this field is this value.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--color',
|
||||||
|
choices=['never', 'always', 'auto'],
|
||||||
|
default='auto',
|
||||||
|
help="When to use terminal colors. Defaults to 'auto'.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-:', '--dots',
|
||||||
|
action='store_true',
|
||||||
|
help="Use 1x2 ascii dot characters.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-⣿', '--braille',
|
||||||
|
action='store_true',
|
||||||
|
help="Use 2x4 unicode braille characters. Note that braille "
|
||||||
|
"characters sometimes suffer from inconsistent widths.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--chars',
|
||||||
|
help="Characters to use for tiles.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--colors',
|
||||||
|
type=lambda x: [x.strip() for x in x.split(',')],
|
||||||
|
help="Colors to use for tiles.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-W', '--width',
|
||||||
|
nargs='?',
|
||||||
|
type=lambda x: int(x, 0),
|
||||||
|
const=0,
|
||||||
|
help="Width in columns. 0 uses the terminal width. Defaults to "
|
||||||
|
"min(terminal, 80).")
|
||||||
|
parser.add_argument(
|
||||||
|
'-H', '--height',
|
||||||
|
nargs='?',
|
||||||
|
type=lambda x: int(x, 0),
|
||||||
|
const=0,
|
||||||
|
help="Height in rows. 0 uses the terminal height. Defaults to 1.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-N', '--no-header',
|
||||||
|
action='store_true',
|
||||||
|
help="Don't show the header.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--binary',
|
||||||
|
action='store_true',
|
||||||
|
help="Use the binary partitioning scheme. This attempts to "
|
||||||
|
"recursively subdivide the tiles into a roughly "
|
||||||
|
"weight-balanced binary tree. This is the default.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--slice',
|
||||||
|
action='store_true',
|
||||||
|
help="Use the slice partitioning scheme. This simply slices "
|
||||||
|
"tiles vertically.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--dice',
|
||||||
|
action='store_true',
|
||||||
|
help="Use the dice partitioning scheme. This simply slices "
|
||||||
|
"tiles horizontally.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--slice-and-dice',
|
||||||
|
action='store_true',
|
||||||
|
help="Use the slice-and-dice partitioning scheme. This "
|
||||||
|
"alternates between slicing and dicing each layer.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--dice-and-slice',
|
||||||
|
action='store_true',
|
||||||
|
help="Use the dice-and-slice partitioning scheme. This is like "
|
||||||
|
"slice-and-dice, but flipped.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--squarify',
|
||||||
|
action='store_true',
|
||||||
|
help="Use the squarify partitioning scheme. This is a greedy "
|
||||||
|
"algorithm created by Mark Bruls et al that tries to "
|
||||||
|
"minimize tile aspect ratios.")
|
||||||
|
parser.add_argument(
|
||||||
|
'--to-scale',
|
||||||
|
nargs='?',
|
||||||
|
type=float,
|
||||||
|
const=1,
|
||||||
|
help="Scale the resulting treemap such that 1 pixel ~= 1/scale "
|
||||||
|
"units. Defaults to scale=1. ")
|
||||||
|
parser.add_argument(
|
||||||
|
'--aspect-ratio',
|
||||||
|
type=lambda x: tuple(float(v) for v in x.split(':', 1)),
|
||||||
|
default=(1, 1),
|
||||||
|
help="Aspect ratio to use with --to-scale. Defaults to 1:1.")
|
||||||
|
sys.exit(main(**{k: v
|
||||||
|
for k, v in vars(parser.parse_intermixed_args()).items()
|
||||||
|
if v is not None}))
|
||||||
Reference in New Issue
Block a user