scripts: treemap[d3].py: Implemented more flexible labeling/coloring system
Now, instead of specifying a specific field or comma-separated set of
order-defined constants, -L/--add-label, -C/--add-color, and
-./--add-char/--chars accept a by-field group assignment similar to
-L/--label in plotmpl.py.
I also reworked our % modifiers to behave a bit more like printf
modifiers with optional field targets.
It gets a bit complicated, but this ends up extremely flexible:
- Assign to a specific group:
$ ./scripts/treemap.py -Clfs.c,lfsr_format=orange
- Note this is heirarchical, with more specific groups taking priority:
$ ./scripts/treemap.py -Clfs.c=blue -Clfs.c,lfsr_format=orange
- We can still get the order-assigned behavior by specifying multiple
options, but note there is no longer a comma ambiguity! This is useful
if you want to specify a palette and don't care which dataset gets
which attr:
$ ./scripts/treemap.py -Cred -Cgreen -Cblue
- Mix and match:
$ ./scripts/treemap.py -Cred -Cgreen -Cblue -Clfsr_format=orange
- And with the new % modifiers, we can still use labels stored in a
field:
$ ./scripts/treemap.py -L'%(label_field)s'
- -./--add-char/--chars in treemap.py is a bit of a special case. Since
it only accepts single characters, we can still accept multiple
options with a single flag without having to worry about ambiguities:
$ ./scripts/treemap.py -.asdf
Well, unless you want to include a literal '='. This is possible, but
a bit messy:
$ ./scripts/treemap.py -.as -.=== -.df
Yes that is 3 equal signs... One for argparse, one for the assignment,
one for the '=' literal.
This one is minor, but nice for terseness.
This commit is contained in:
+252
-57
@@ -13,6 +13,7 @@ import collections as co
|
|||||||
import csv
|
import csv
|
||||||
import itertools as it
|
import itertools as it
|
||||||
import math as mt
|
import math as mt
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,12 @@ def dat(x):
|
|||||||
# else give up
|
# else give up
|
||||||
raise ValueError("invalid dat %r" % x)
|
raise ValueError("invalid dat %r" % x)
|
||||||
|
|
||||||
|
def try_dat(x):
|
||||||
|
try:
|
||||||
|
return dat(x)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
def collect(csv_paths, defines=[]):
|
def collect(csv_paths, defines=[]):
|
||||||
# collect results from CSV files
|
# collect results from CSV files
|
||||||
fields = []
|
fields = []
|
||||||
@@ -89,7 +96,7 @@ def collect(csv_paths, defines=[]):
|
|||||||
|
|
||||||
return fields, results
|
return fields, results
|
||||||
|
|
||||||
def fold(results, by=None, fields=None, labels=None, defines=[]):
|
def fold(results, by=None, fields=None, defines=[]):
|
||||||
# filter by matching defines
|
# filter by matching defines
|
||||||
if defines:
|
if defines:
|
||||||
results_ = []
|
results_ = []
|
||||||
@@ -105,14 +112,14 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
|
|||||||
keys.add(tuple(r.get(k, '') for k in by))
|
keys.add(tuple(r.get(k, '') for k in by))
|
||||||
keys = sorted(keys)
|
keys = sorted(keys)
|
||||||
|
|
||||||
# collect dataset
|
# collect datasets
|
||||||
datasets = co.OrderedDict()
|
datasets = co.OrderedDict()
|
||||||
labels_ = co.OrderedDict()
|
dataattrs = co.OrderedDict()
|
||||||
for key in (keys if by else [()]):
|
for key in (keys if by else [()]):
|
||||||
for field in fields:
|
for field in fields:
|
||||||
# organize by 'by' and field
|
# organize by 'by' and field
|
||||||
dataset = []
|
dataset = []
|
||||||
label = None
|
dataattr = {}
|
||||||
for r in results:
|
for r in results:
|
||||||
# filter by 'by'
|
# filter by 'by'
|
||||||
if by and not all(
|
if by and not all(
|
||||||
@@ -135,21 +142,141 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
|
|||||||
# incorrect and misleading results
|
# incorrect and misleading results
|
||||||
dataset.append(v)
|
dataset.append(v)
|
||||||
|
|
||||||
# also find label?
|
# include all fields in dataattrs in case we use
|
||||||
if labels is not None:
|
# them for % modifiers
|
||||||
for label_ in labels:
|
dataattr.update(r)
|
||||||
if label_ in r:
|
|
||||||
label = r[label_]
|
|
||||||
|
|
||||||
# hide 'field' if there is only one field
|
# hide 'field' if there is only one field
|
||||||
key_ = key
|
key_ = key
|
||||||
if len(fields or []) > 1 or not key_:
|
if len(fields or []) > 1 or not key_:
|
||||||
key_ += (field,)
|
key_ += (field,)
|
||||||
datasets[key_] = dataset
|
datasets[key_] = dataset
|
||||||
if label is not None:
|
dataattrs[key_] = dataattr
|
||||||
labels_[key_] = label
|
|
||||||
|
|
||||||
return datasets, labels_
|
return datasets, dataattrs
|
||||||
|
|
||||||
|
# a representation of optionally key-mapped attrs
|
||||||
|
class Attr:
|
||||||
|
def __init__(self, attrs, *,
|
||||||
|
defaults=None):
|
||||||
|
# include defaults?
|
||||||
|
if (defaults is not None
|
||||||
|
and not any(
|
||||||
|
not isinstance(attr, tuple) or attr[0] is None
|
||||||
|
for attr in (attrs or []))):
|
||||||
|
attrs = defaults + (attrs or [])
|
||||||
|
|
||||||
|
# normalize and split out keyed vs indexed attrs
|
||||||
|
self.attrs = []
|
||||||
|
self.indexed = []
|
||||||
|
self.keyed = []
|
||||||
|
for attr in (attrs or []):
|
||||||
|
if not isinstance(attr, tuple):
|
||||||
|
attr = (None, attr)
|
||||||
|
|
||||||
|
self.attrs.append(attr)
|
||||||
|
if attr[0] is None:
|
||||||
|
self.indexed.append(attr[1])
|
||||||
|
else:
|
||||||
|
self.keyed.append(attr)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Attr(%r)' % [
|
||||||
|
(','.join(key), a) if key is not None else a
|
||||||
|
for key, a in self.attrs]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return it.cycle(self.indexed)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.indexed)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
# note this is not just the indexed attrs
|
||||||
|
return bool(self.attrs)
|
||||||
|
|
||||||
|
def lookup(self, key):
|
||||||
|
# try to lookup by key
|
||||||
|
best = None
|
||||||
|
for attr in self.keyed:
|
||||||
|
prefix = []
|
||||||
|
for i, k in enumerate(attr[0]):
|
||||||
|
if i < len(key) and (not k or key[i] == k):
|
||||||
|
prefix.append(k)
|
||||||
|
else:
|
||||||
|
prefix = None
|
||||||
|
break
|
||||||
|
|
||||||
|
if prefix is not None and (
|
||||||
|
best is None or len(prefix) >= len(best[0])):
|
||||||
|
best = (prefix, attr[1])
|
||||||
|
|
||||||
|
if best is not None:
|
||||||
|
return best[1]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if isinstance(key, tuple):
|
||||||
|
if len(key) > 0 and not isinstance(key[0], str):
|
||||||
|
i, key = key
|
||||||
|
else:
|
||||||
|
i, key = None, key
|
||||||
|
else:
|
||||||
|
i, key = key, None
|
||||||
|
|
||||||
|
# try to lookup by key
|
||||||
|
if key is not None:
|
||||||
|
attr = self.lookup(key)
|
||||||
|
if attr is not None:
|
||||||
|
return attr
|
||||||
|
|
||||||
|
# otherwise fallback to index
|
||||||
|
if i is not None and self.indexed:
|
||||||
|
return self.indexed[i % len(self.indexed)]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return self.__getitem__(key) is not None
|
||||||
|
|
||||||
|
# parse %-escaped strings
|
||||||
|
def punescape(s, attrs=None):
|
||||||
|
if attrs is None:
|
||||||
|
attrs = {}
|
||||||
|
if isinstance(attrs, dict):
|
||||||
|
attrs_ = attrs
|
||||||
|
attrs = lambda k: attrs_[k]
|
||||||
|
|
||||||
|
pattern = re.compile(
|
||||||
|
'%[%n]'
|
||||||
|
'|' '%x..'
|
||||||
|
'|' '%u....'
|
||||||
|
'|' '%U........'
|
||||||
|
'|' '%\((?P<field>[^)]*)\)'
|
||||||
|
'(?P<format>[+\- #0-9\.]*[scdboxXfFeEgG])')
|
||||||
|
def unescape(m):
|
||||||
|
if m.group()[1] == '%': return '%'
|
||||||
|
elif m.group()[1] == 'n': return '\n'
|
||||||
|
elif m.group()[1] == 'x': return chr(int(m.group()[2:], 16))
|
||||||
|
elif m.group()[1] == 'u': return chr(int(m.group()[2:], 16))
|
||||||
|
elif m.group()[1] == 'U': return chr(int(m.group()[2:], 16))
|
||||||
|
elif m.group()[1] == '(':
|
||||||
|
try:
|
||||||
|
v = attrs(m.group('field'))
|
||||||
|
except KeyError:
|
||||||
|
return m.group()
|
||||||
|
if m.group('format')[-1] in 'dboxXfFeEgG':
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = try_dat(v) or 0
|
||||||
|
else:
|
||||||
|
if not isinstance(v, str):
|
||||||
|
v = str(v)
|
||||||
|
# note we need Python's new format syntax for binary
|
||||||
|
f = '{:%s}' % m.group('format')
|
||||||
|
return f.format(v)
|
||||||
|
else: assert False
|
||||||
|
return re.sub(pattern, unescape, s)
|
||||||
|
|
||||||
|
|
||||||
# a little ascii renderer
|
# a little ascii renderer
|
||||||
@@ -257,7 +384,7 @@ class Canvas:
|
|||||||
for i in range(w):
|
for i in range(w):
|
||||||
self.point(x+i, y+j, char=char, color=color)
|
self.point(x+i, y+j, char=char, color=color)
|
||||||
|
|
||||||
def label(self, x, y, label, *,
|
def label(self, x, y, label, width=None, height=None, *,
|
||||||
color=''):
|
color=''):
|
||||||
# scale if needed
|
# scale if needed
|
||||||
if self.braille:
|
if self.braille:
|
||||||
@@ -267,8 +394,17 @@ class Canvas:
|
|||||||
else:
|
else:
|
||||||
xscale, yscale = 1, 1
|
xscale, yscale = 1, 1
|
||||||
|
|
||||||
for i, char in enumerate(label):
|
x_ = x
|
||||||
self.point(x+i*xscale, y, char=char, color=color)
|
y_ = y
|
||||||
|
for char in label:
|
||||||
|
if char == '\n':
|
||||||
|
x_ = x
|
||||||
|
y_ -= 1
|
||||||
|
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_ += xscale
|
||||||
|
|
||||||
def draw(self, row):
|
def draw(self, row):
|
||||||
# scale if needed
|
# scale if needed
|
||||||
@@ -326,6 +462,7 @@ class Tile:
|
|||||||
def __init__(self, key, children,
|
def __init__(self, key, children,
|
||||||
x=None, y=None, width=None, height=None, *,
|
x=None, y=None, width=None, height=None, *,
|
||||||
depth=None,
|
depth=None,
|
||||||
|
attrs=None,
|
||||||
label=None,
|
label=None,
|
||||||
color=None):
|
color=None):
|
||||||
self.key = key
|
self.key = key
|
||||||
@@ -341,6 +478,7 @@ class Tile:
|
|||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
self.depth = depth
|
self.depth = depth
|
||||||
|
self.attrs = attrs
|
||||||
self.label = label
|
self.label = label
|
||||||
self.color = color
|
self.color = color
|
||||||
|
|
||||||
@@ -562,13 +700,13 @@ def partition_squarify(children, total, x, y, width, height, *,
|
|||||||
def main(csv_paths, *,
|
def main(csv_paths, *,
|
||||||
by=None,
|
by=None,
|
||||||
fields=None,
|
fields=None,
|
||||||
labels=None,
|
|
||||||
defines=[],
|
defines=[],
|
||||||
|
labels=None,
|
||||||
|
chars=None,
|
||||||
|
colors=None,
|
||||||
color=False,
|
color=False,
|
||||||
dots=False,
|
dots=False,
|
||||||
braille=False,
|
braille=False,
|
||||||
chars=None,
|
|
||||||
colors=None,
|
|
||||||
width=None,
|
width=None,
|
||||||
height=None,
|
height=None,
|
||||||
no_header=False,
|
no_header=False,
|
||||||
@@ -576,6 +714,7 @@ def main(csv_paths, *,
|
|||||||
aspect_ratio=(1,1),
|
aspect_ratio=(1,1),
|
||||||
title=None,
|
title=None,
|
||||||
padding=0,
|
padding=0,
|
||||||
|
label=False,
|
||||||
**args):
|
**args):
|
||||||
# figure out what color should be
|
# figure out what color should be
|
||||||
if color == 'auto':
|
if color == 'auto':
|
||||||
@@ -585,16 +724,20 @@ def main(csv_paths, *,
|
|||||||
else:
|
else:
|
||||||
color = False
|
color = False
|
||||||
|
|
||||||
# figure out chars/colors
|
# what chars/colors/labels to use?
|
||||||
if chars is not None:
|
chars_ = []
|
||||||
chars_ = chars
|
for char in chars:
|
||||||
else:
|
if isinstance(char, tuple):
|
||||||
chars_ = CHARS
|
for char_ in char[1]:
|
||||||
|
chars_.append((char[0], char_))
|
||||||
|
else:
|
||||||
|
for char_ in char:
|
||||||
|
chars_.append(char_)
|
||||||
|
chars_ = Attr(chars_, defaults=CHARS)
|
||||||
|
|
||||||
if colors is not None:
|
colors_ = Attr(colors, defaults=COLORS)
|
||||||
colors_ = colors
|
|
||||||
else:
|
labels_ = Attr(labels)
|
||||||
colors_ = COLORS
|
|
||||||
|
|
||||||
# figure out width/height
|
# figure out width/height
|
||||||
if width is None:
|
if width is None:
|
||||||
@@ -634,7 +777,7 @@ def main(csv_paths, *,
|
|||||||
and not any(k == k_ for k_, _ in defines)]
|
and not any(k == k_ for k_, _ in defines)]
|
||||||
|
|
||||||
# then extract the requested dataset
|
# then extract the requested dataset
|
||||||
datasets, labels_ = fold(results, by, fields, labels, defines)
|
datasets, dataattrs = fold(results, by, fields, defines)
|
||||||
|
|
||||||
# build tile heirarchy
|
# build tile heirarchy
|
||||||
children = []
|
children = []
|
||||||
@@ -643,24 +786,35 @@ def main(csv_paths, *,
|
|||||||
children.append(Tile(
|
children.append(Tile(
|
||||||
key + ((str(i),) if len(dataset) > 1 else ()),
|
key + ((str(i),) if len(dataset) > 1 else ()),
|
||||||
v,
|
v,
|
||||||
label=labels_.get(key)))
|
attrs=dataattrs[key]))
|
||||||
|
|
||||||
tile = Tile.merge(children)
|
tile = Tile.merge(children)
|
||||||
|
|
||||||
# sort
|
# merge attrs
|
||||||
tile.sort()
|
for t in tile.tiles():
|
||||||
|
if t.children:
|
||||||
|
t.attrs = {k: v
|
||||||
|
for t_ in t.leaves()
|
||||||
|
for k, v in t_.attrs.items()}
|
||||||
|
# also sum fields here in case they're used by % modifiers,
|
||||||
|
# note other fields are _not_ summed
|
||||||
|
for k in fields:
|
||||||
|
t.attrs[k] = sum(t_.value
|
||||||
|
for t_ in t.leaves()
|
||||||
|
if len(fields) == 1 or t_.key[len(by)] == k)
|
||||||
|
|
||||||
# assign colors/chars after sorting to try to minimize touching
|
# assign colors/labels before sorting to keep things reproducible
|
||||||
# colors, while keeping things somewhat reproducible
|
|
||||||
|
|
||||||
# use colors for top of tree
|
# use colors for top of tree
|
||||||
for i, t in enumerate(tile.children):
|
for i, t in enumerate(tile.children):
|
||||||
for t_ in t.tiles():
|
for t_ in t.tiles():
|
||||||
t_.color = colors_[i % len(colors_)]
|
t_.color = colors_[i, t.key]
|
||||||
|
|
||||||
# and chars for bottom of tree
|
# and chars/labels for bottom of tree
|
||||||
for i, t in enumerate(tile.leaves()):
|
for i, t in enumerate(tile.leaves()):
|
||||||
t.char = chars_[i % len(chars_)]
|
t.char = chars_[i, t.key]
|
||||||
|
if (i, t.key) in labels_:
|
||||||
|
t.label = punescape(labels_[i, t.key], t.attrs)
|
||||||
|
|
||||||
# scale width/height if requested now that we have our data
|
# scale width/height if requested now that we have our data
|
||||||
if to_scale and (width is None or height is None) and tile.value != 0:
|
if to_scale and (width is None or height is None) and tile.value != 0:
|
||||||
@@ -698,18 +852,25 @@ def main(csv_paths, *,
|
|||||||
dots=dots,
|
dots=dots,
|
||||||
braille=braille)
|
braille=braille)
|
||||||
|
|
||||||
|
# sort
|
||||||
|
tile.sort()
|
||||||
|
|
||||||
# recursively partition tiles
|
# recursively partition tiles
|
||||||
tile.x = 0
|
tile.x = 0
|
||||||
tile.y = 0
|
tile.y = 0
|
||||||
tile.width = canvas.width
|
tile.width = canvas.width
|
||||||
tile.height = canvas.height
|
tile.height = canvas.height
|
||||||
def partition(tile):
|
def partition(tile):
|
||||||
# apply top padding
|
|
||||||
if tile.depth == 0:
|
if tile.depth == 0:
|
||||||
|
# apply top padding
|
||||||
tile.x += padding
|
tile.x += padding
|
||||||
tile.y += padding
|
tile.y += padding
|
||||||
tile.width -= min(padding, tile.width)
|
tile.width -= min(padding, tile.width)
|
||||||
tile.height -= min(padding, tile.height)
|
tile.height -= min(padding, tile.height)
|
||||||
|
# apply bottom padding
|
||||||
|
if not tile.children:
|
||||||
|
tile.width -= min(padding, tile.width)
|
||||||
|
tile.height -= min(padding, tile.height)
|
||||||
|
|
||||||
x__ = tile.x
|
x__ = tile.x
|
||||||
y__ = tile.y
|
y__ = tile.y
|
||||||
@@ -769,7 +930,7 @@ def main(csv_paths, *,
|
|||||||
tile.align()
|
tile.align()
|
||||||
|
|
||||||
# render to canvas
|
# render to canvas
|
||||||
labels_ = []
|
labels__ = []
|
||||||
for t in tile.leaves():
|
for t in tile.leaves():
|
||||||
x__ = t.x
|
x__ = t.x
|
||||||
y__ = t.y
|
y__ = t.y
|
||||||
@@ -795,19 +956,22 @@ def main(csv_paths, *,
|
|||||||
else t.char if t.char is not None else chars_[0]),
|
else t.char if t.char is not None else chars_[0]),
|
||||||
color=t.color if t.color is not None else colors_[0])
|
color=t.color if t.color is not None else colors_[0])
|
||||||
|
|
||||||
if labels:
|
if label:
|
||||||
if t.label is not None:
|
if t.label is not None:
|
||||||
label__ = t.label
|
label__ = t.label
|
||||||
else:
|
else:
|
||||||
label__ = ','.join(t.key)
|
label__ = ','.join(t.key)
|
||||||
|
|
||||||
# render these later so they get priority
|
# render these later so they get priority
|
||||||
labels_.append((x__, y__+height__-1, label__[:width__]))
|
labels__.append((x__, y__+height__-1, label__,
|
||||||
|
width__, height__))
|
||||||
|
|
||||||
for x__, y__, label__ in labels_:
|
for label__ in labels__:
|
||||||
canvas.label(x__, y__, label__)
|
canvas.label(*label__)
|
||||||
|
|
||||||
# print some summary info
|
# print some summary info
|
||||||
|
if title:
|
||||||
|
title_ = punescape(title, tile.attrs)
|
||||||
if not no_header:
|
if not no_header:
|
||||||
stat = tile.stat()
|
stat = tile.stat()
|
||||||
stat_ = 'total %d, avg %d +-%dσ, min %d, max %d' % (
|
stat_ = 'total %d, avg %d +-%dσ, min %d, max %d' % (
|
||||||
@@ -815,9 +979,12 @@ def main(csv_paths, *,
|
|||||||
stat['mean'], stat['stddev'],
|
stat['mean'], stat['stddev'],
|
||||||
stat['min'], stat['max'])
|
stat['min'], stat['max'])
|
||||||
if title and not no_header:
|
if title and not no_header:
|
||||||
print('%s%*s%s' % (title, width_-len(stat_)-len(title), '', stat_))
|
print('%s%*s%s' % (
|
||||||
|
title_,
|
||||||
|
max(width_-len(stat_)-len(title_), 0), ' ',
|
||||||
|
stat_))
|
||||||
elif title:
|
elif title:
|
||||||
print(title)
|
print(title_)
|
||||||
elif not no_header:
|
elif not no_header:
|
||||||
print(stat_)
|
print(stat_)
|
||||||
|
|
||||||
@@ -846,12 +1013,6 @@ if __name__ == "__main__":
|
|||||||
dest='fields',
|
dest='fields',
|
||||||
action='append',
|
action='append',
|
||||||
help="Field to use for tile sizes.")
|
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(
|
parser.add_argument(
|
||||||
'-D', '--define',
|
'-D', '--define',
|
||||||
dest='defines',
|
dest='defines',
|
||||||
@@ -862,6 +1023,43 @@ if __name__ == "__main__":
|
|||||||
{v.strip() for v in vs.split(',')})
|
{v.strip() for v in vs.split(',')})
|
||||||
)(*x.split('=', 1)),
|
)(*x.split('=', 1)),
|
||||||
help="Only include results where this field is this value.")
|
help="Only include results where this field is this value.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-L', '--add-label',
|
||||||
|
dest='labels',
|
||||||
|
action='append',
|
||||||
|
type=lambda x: (
|
||||||
|
lambda key, v: (
|
||||||
|
tuple(k.strip() for k in key.split(',')),
|
||||||
|
v.strip())
|
||||||
|
)(*x.split('=', 1))
|
||||||
|
if '=' in x else x.strip(),
|
||||||
|
help="Add a label to use. Can be assigned to a specific group "
|
||||||
|
"where a group is the comma-separated 'by' fields. Accepts %% "
|
||||||
|
"modifiers.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-.', '--add-char', '--chars',
|
||||||
|
dest='chars',
|
||||||
|
action='append',
|
||||||
|
type=lambda x: (
|
||||||
|
lambda key, v: (
|
||||||
|
tuple(k.strip() for k in key.split(',')),
|
||||||
|
v.strip())
|
||||||
|
)(*x.split('=', 1))
|
||||||
|
if '=' in x else x.strip(),
|
||||||
|
help="Add characters to use. Can be assigned to a specific group "
|
||||||
|
"where a group is the comma-separated 'by' fields.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-C', '--add-color',
|
||||||
|
dest='colors',
|
||||||
|
action='append',
|
||||||
|
type=lambda x: (
|
||||||
|
lambda key, v: (
|
||||||
|
tuple(k.strip() for k in key.split(',')),
|
||||||
|
v.strip())
|
||||||
|
)(*x.split('=', 1))
|
||||||
|
if '=' in x else x.strip(),
|
||||||
|
help="Add a color to use. Can be assigned to a specific group "
|
||||||
|
"where a group is the comma-separated 'by' fields.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--color',
|
'--color',
|
||||||
choices=['never', 'always', 'auto'],
|
choices=['never', 'always', 'auto'],
|
||||||
@@ -876,13 +1074,6 @@ if __name__ == "__main__":
|
|||||||
action='store_true',
|
action='store_true',
|
||||||
help="Use 2x4 unicode braille characters. Note that braille "
|
help="Use 2x4 unicode braille characters. Note that braille "
|
||||||
"characters sometimes suffer from inconsistent widths.")
|
"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(
|
parser.add_argument(
|
||||||
'-W', '--width',
|
'-W', '--width',
|
||||||
nargs='?',
|
nargs='?',
|
||||||
@@ -958,6 +1149,10 @@ if __name__ == "__main__":
|
|||||||
type=float,
|
type=float,
|
||||||
default=0,
|
default=0,
|
||||||
help="Padding to add to each level of the treemap. Defaults to 0.")
|
help="Padding to add to each level of the treemap. Defaults to 0.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-l', '--label',
|
||||||
|
action='store_true',
|
||||||
|
help="Render labels.")
|
||||||
sys.exit(main(**{k: v
|
sys.exit(main(**{k: v
|
||||||
for k, v in vars(parser.parse_intermixed_args()).items()
|
for k, v in vars(parser.parse_intermixed_args()).items()
|
||||||
if v is not None}))
|
if v is not None}))
|
||||||
|
|||||||
+215
-52
@@ -13,6 +13,7 @@ import collections as co
|
|||||||
import csv
|
import csv
|
||||||
import itertools as it
|
import itertools as it
|
||||||
import math as mt
|
import math as mt
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +84,12 @@ def dat(x):
|
|||||||
# else give up
|
# else give up
|
||||||
raise ValueError("invalid dat %r" % x)
|
raise ValueError("invalid dat %r" % x)
|
||||||
|
|
||||||
|
def try_dat(x):
|
||||||
|
try:
|
||||||
|
return dat(x)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
def collect(csv_paths, defines=[]):
|
def collect(csv_paths, defines=[]):
|
||||||
# collect results from CSV files
|
# collect results from CSV files
|
||||||
fields = []
|
fields = []
|
||||||
@@ -105,7 +112,7 @@ def collect(csv_paths, defines=[]):
|
|||||||
|
|
||||||
return fields, results
|
return fields, results
|
||||||
|
|
||||||
def fold(results, by=None, fields=None, labels=None, defines=[]):
|
def fold(results, by=None, fields=None, defines=[]):
|
||||||
# filter by matching defines
|
# filter by matching defines
|
||||||
if defines:
|
if defines:
|
||||||
results_ = []
|
results_ = []
|
||||||
@@ -121,14 +128,14 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
|
|||||||
keys.add(tuple(r.get(k, '') for k in by))
|
keys.add(tuple(r.get(k, '') for k in by))
|
||||||
keys = sorted(keys)
|
keys = sorted(keys)
|
||||||
|
|
||||||
# collect dataset
|
# collect datasets
|
||||||
datasets = co.OrderedDict()
|
datasets = co.OrderedDict()
|
||||||
labels_ = co.OrderedDict()
|
dataattrs = co.OrderedDict()
|
||||||
for key in (keys if by else [()]):
|
for key in (keys if by else [()]):
|
||||||
for field in fields:
|
for field in fields:
|
||||||
# organize by 'by' and field
|
# organize by 'by' and field
|
||||||
dataset = []
|
dataset = []
|
||||||
label = None
|
dataattr = {}
|
||||||
for r in results:
|
for r in results:
|
||||||
# filter by 'by'
|
# filter by 'by'
|
||||||
if by and not all(
|
if by and not all(
|
||||||
@@ -151,21 +158,142 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
|
|||||||
# incorrect and misleading results
|
# incorrect and misleading results
|
||||||
dataset.append(v)
|
dataset.append(v)
|
||||||
|
|
||||||
# also find label?
|
# include all fields in dataattrs in case we use
|
||||||
if labels is not None:
|
# them for % modifiers
|
||||||
for label_ in labels:
|
dataattr.update(r)
|
||||||
if label_ in r:
|
|
||||||
label = r[label_]
|
|
||||||
|
|
||||||
# hide 'field' if there is only one field
|
# hide 'field' if there is only one field
|
||||||
key_ = key
|
key_ = key
|
||||||
if len(fields or []) > 1 or not key_:
|
if len(fields or []) > 1 or not key_:
|
||||||
key_ += (field,)
|
key_ += (field,)
|
||||||
datasets[key_] = dataset
|
datasets[key_] = dataset
|
||||||
if label is not None:
|
dataattrs[key_] = dataattr
|
||||||
labels_[key_] = label
|
|
||||||
|
return datasets, dataattrs
|
||||||
|
|
||||||
|
# a representation of optionally key-mapped attrs
|
||||||
|
class Attr:
|
||||||
|
def __init__(self, attrs, *,
|
||||||
|
defaults=None):
|
||||||
|
# include defaults?
|
||||||
|
if (defaults is not None
|
||||||
|
and not any(
|
||||||
|
not isinstance(attr, tuple) or attr[0] is None
|
||||||
|
for attr in (attrs or []))):
|
||||||
|
attrs = defaults + (attrs or [])
|
||||||
|
|
||||||
|
# normalize and split out keyed vs indexed attrs
|
||||||
|
self.attrs = []
|
||||||
|
self.indexed = []
|
||||||
|
self.keyed = []
|
||||||
|
for attr in (attrs or []):
|
||||||
|
if not isinstance(attr, tuple):
|
||||||
|
attr = (None, attr)
|
||||||
|
|
||||||
|
self.attrs.append(attr)
|
||||||
|
if attr[0] is None:
|
||||||
|
self.indexed.append(attr[1])
|
||||||
|
else:
|
||||||
|
self.keyed.append(attr)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Attr(%r)' % [
|
||||||
|
(','.join(key), a) if key is not None else a
|
||||||
|
for key, a in self.attrs]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return it.cycle(self.indexed)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.indexed)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
# note this is not just the indexed attrs
|
||||||
|
return bool(self.attrs)
|
||||||
|
|
||||||
|
def lookup(self, key):
|
||||||
|
# try to lookup by key
|
||||||
|
best = None
|
||||||
|
for attr in self.keyed:
|
||||||
|
prefix = []
|
||||||
|
for i, k in enumerate(attr[0]):
|
||||||
|
if i < len(key) and (not k or key[i] == k):
|
||||||
|
prefix.append(k)
|
||||||
|
else:
|
||||||
|
prefix = None
|
||||||
|
break
|
||||||
|
|
||||||
|
if prefix is not None and (
|
||||||
|
best is None or len(prefix) >= len(best[0])):
|
||||||
|
best = (prefix, attr[1])
|
||||||
|
|
||||||
|
if best is not None:
|
||||||
|
return best[1]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if isinstance(key, tuple):
|
||||||
|
if len(key) > 0 and not isinstance(key[0], str):
|
||||||
|
i, key = key
|
||||||
|
else:
|
||||||
|
i, key = None, key
|
||||||
|
else:
|
||||||
|
i, key = key, None
|
||||||
|
|
||||||
|
# try to lookup by key
|
||||||
|
if key is not None:
|
||||||
|
attr = self.lookup(key)
|
||||||
|
if attr is not None:
|
||||||
|
return attr
|
||||||
|
|
||||||
|
# otherwise fallback to index
|
||||||
|
if i is not None and self.indexed:
|
||||||
|
return self.indexed[i % len(self.indexed)]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return self.__getitem__(key) is not None
|
||||||
|
|
||||||
|
# parse %-escaped strings
|
||||||
|
def punescape(s, attrs=None):
|
||||||
|
if attrs is None:
|
||||||
|
attrs = {}
|
||||||
|
if isinstance(attrs, dict):
|
||||||
|
attrs_ = attrs
|
||||||
|
attrs = lambda k: attrs_[k]
|
||||||
|
|
||||||
|
pattern = re.compile(
|
||||||
|
'%[%n]'
|
||||||
|
'|' '%x..'
|
||||||
|
'|' '%u....'
|
||||||
|
'|' '%U........'
|
||||||
|
'|' '%\((?P<field>[^)]*)\)'
|
||||||
|
'(?P<format>[+\- #0-9\.]*[scdboxXfFeEgG])')
|
||||||
|
def unescape(m):
|
||||||
|
if m.group()[1] == '%': return '%'
|
||||||
|
elif m.group()[1] == 'n': return '\n'
|
||||||
|
elif m.group()[1] == 'x': return chr(int(m.group()[2:], 16))
|
||||||
|
elif m.group()[1] == 'u': return chr(int(m.group()[2:], 16))
|
||||||
|
elif m.group()[1] == 'U': return chr(int(m.group()[2:], 16))
|
||||||
|
elif m.group()[1] == '(':
|
||||||
|
try:
|
||||||
|
v = attrs(m.group('field'))
|
||||||
|
except KeyError:
|
||||||
|
return m.group()
|
||||||
|
if m.group('format')[-1] in 'dboxXfFeEgG':
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = try_dat(v) or 0
|
||||||
|
else:
|
||||||
|
if not isinstance(v, str):
|
||||||
|
v = str(v)
|
||||||
|
# note we need Python's new format syntax for binary
|
||||||
|
f = '{:%s}' % m.group('format')
|
||||||
|
return f.format(v)
|
||||||
|
else: assert False
|
||||||
|
return re.sub(pattern, unescape, s)
|
||||||
|
|
||||||
return datasets, labels_
|
|
||||||
|
|
||||||
|
|
||||||
# a type to represent tiles
|
# a type to represent tiles
|
||||||
@@ -173,6 +301,7 @@ class Tile:
|
|||||||
def __init__(self, key, children,
|
def __init__(self, key, children,
|
||||||
x=None, y=None, width=None, height=None, *,
|
x=None, y=None, width=None, height=None, *,
|
||||||
depth=None,
|
depth=None,
|
||||||
|
attrs=None,
|
||||||
label=None,
|
label=None,
|
||||||
color=None):
|
color=None):
|
||||||
self.key = key
|
self.key = key
|
||||||
@@ -188,6 +317,7 @@ class Tile:
|
|||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
self.depth = depth
|
self.depth = depth
|
||||||
|
self.attrs = attrs
|
||||||
self.label = label
|
self.label = label
|
||||||
self.color = color
|
self.color = color
|
||||||
|
|
||||||
@@ -410,8 +540,8 @@ def main(csv_paths, output, *,
|
|||||||
quiet=False,
|
quiet=False,
|
||||||
by=None,
|
by=None,
|
||||||
fields=None,
|
fields=None,
|
||||||
labels=None,
|
|
||||||
defines=[],
|
defines=[],
|
||||||
|
labels=None,
|
||||||
colors=None,
|
colors=None,
|
||||||
width=None,
|
width=None,
|
||||||
height=None,
|
height=None,
|
||||||
@@ -434,13 +564,10 @@ def main(csv_paths, output, *,
|
|||||||
no_header = True
|
no_header = True
|
||||||
no_label = True
|
no_label = True
|
||||||
|
|
||||||
# what colors to use?
|
# what colors/labels to use?
|
||||||
if colors is not None:
|
colors_ = Attr(colors, defaults=COLORS_DARK if dark else COLORS)
|
||||||
colors_ = colors
|
|
||||||
elif dark:
|
labels_ = Attr(labels)
|
||||||
colors_ = COLORS_DARK
|
|
||||||
else:
|
|
||||||
colors_ = COLORS
|
|
||||||
|
|
||||||
if background is not None:
|
if background is not None:
|
||||||
background_ = background
|
background_ = background
|
||||||
@@ -483,7 +610,7 @@ def main(csv_paths, output, *,
|
|||||||
and not any(k == k_ for k_, _ in defines)]
|
and not any(k == k_ for k_, _ in defines)]
|
||||||
|
|
||||||
# then extract the requested dataset
|
# then extract the requested dataset
|
||||||
datasets, labels_ = fold(results, by, fields, labels, defines)
|
datasets, dataattrs = fold(results, by, fields, defines)
|
||||||
|
|
||||||
# build tile heirarchy
|
# build tile heirarchy
|
||||||
children = []
|
children = []
|
||||||
@@ -492,20 +619,34 @@ def main(csv_paths, output, *,
|
|||||||
children.append(Tile(
|
children.append(Tile(
|
||||||
key + ((str(i),) if len(dataset) > 1 else ()),
|
key + ((str(i),) if len(dataset) > 1 else ()),
|
||||||
v,
|
v,
|
||||||
label=labels_.get(key)))
|
attrs=dataattrs[key]))
|
||||||
|
|
||||||
tile = Tile.merge(children)
|
tile = Tile.merge(children)
|
||||||
|
|
||||||
# sort
|
# merge attrs
|
||||||
tile.sort()
|
for t in tile.tiles():
|
||||||
|
if t.children:
|
||||||
|
t.attrs = {k: v
|
||||||
|
for t_ in t.leaves()
|
||||||
|
for k, v in t_.attrs.items()}
|
||||||
|
# also sum fields here in case they're used by % modifiers,
|
||||||
|
# note other fields are _not_ summed
|
||||||
|
for k in fields:
|
||||||
|
t.attrs[k] = sum(t_.value
|
||||||
|
for t_ in t.leaves()
|
||||||
|
if len(fields) == 1 or t_.key[len(by)] == k)
|
||||||
|
|
||||||
# assign colors after sorting to try to minimize touching
|
# assign colors/labels before sorting to keep things reproducible
|
||||||
# colors, while keeping things somewhat reproducible
|
|
||||||
|
|
||||||
# use colors for top of tree
|
# use colors for top of tree
|
||||||
for i, t in enumerate(tile.children):
|
for i, t in enumerate(tile.children):
|
||||||
for t_ in t.tiles():
|
for t_ in t.tiles():
|
||||||
t_.color = colors_[i % len(colors_)]
|
t_.color = colors_[i, t_.key]
|
||||||
|
|
||||||
|
# and labels everywhere
|
||||||
|
for i, t in enumerate(tile.tiles()):
|
||||||
|
if (i, t.key) in labels_:
|
||||||
|
t.label = punescape(labels_[i, t.key], t.attrs)
|
||||||
|
|
||||||
# scale width/height if requested now that we have our data
|
# scale width/height if requested now that we have our data
|
||||||
if to_scale and (width is None or height is None) and tile.value != 0:
|
if to_scale and (width is None or height is None) and tile.value != 0:
|
||||||
@@ -521,6 +662,9 @@ def main(csv_paths, output, *,
|
|||||||
* (aspect_ratio[0] / aspect_ratio[1]))
|
* (aspect_ratio[0] / aspect_ratio[1]))
|
||||||
height_ = mt.ceil((tile.value * to_scale) / width_)
|
height_ = mt.ceil((tile.value * to_scale) / width_)
|
||||||
|
|
||||||
|
# sort
|
||||||
|
tile.sort()
|
||||||
|
|
||||||
# recursively partition tiles
|
# recursively partition tiles
|
||||||
tile.x = 0
|
tile.x = 0
|
||||||
tile.y = 0
|
tile.y = 0
|
||||||
@@ -569,7 +713,6 @@ def main(csv_paths, output, *,
|
|||||||
if nested:
|
if nested:
|
||||||
y__ += mt.ceil(FONT_SIZE * 1.3)
|
y__ += mt.ceil(FONT_SIZE * 1.3)
|
||||||
height__ -= min(mt.ceil(FONT_SIZE * 1.3), height__)
|
height__ -= min(mt.ceil(FONT_SIZE * 1.3), height__)
|
||||||
|
|
||||||
|
|
||||||
# partition via requested scheme
|
# partition via requested scheme
|
||||||
if tile.children:
|
if tile.children:
|
||||||
@@ -638,7 +781,7 @@ def main(csv_paths, output, *,
|
|||||||
stat = tile.stat()
|
stat = tile.stat()
|
||||||
if title:
|
if title:
|
||||||
f.write('<tspan x="3" y="1.1em">')
|
f.write('<tspan x="3" y="1.1em">')
|
||||||
f.write(title)
|
f.write(punescape(title, tile.attrs))
|
||||||
f.write('</tspan>')
|
f.write('</tspan>')
|
||||||
if not no_header:
|
if not no_header:
|
||||||
f.write('<tspan x="%(x)d" y="1.1em" '
|
f.write('<tspan x="%(x)d" y="1.1em" '
|
||||||
@@ -670,11 +813,11 @@ def main(csv_paths, output, *,
|
|||||||
if t.label is not None:
|
if t.label is not None:
|
||||||
label__ = t.label
|
label__ = t.label
|
||||||
else:
|
else:
|
||||||
label__ = ','.join(t.key)
|
label__ = '%s\n%d' % (','.join(t.key), t.value)
|
||||||
|
|
||||||
f.write('<g transform="translate(%d,%d)">' % (t.x, t.y))
|
f.write('<g transform="translate(%d,%d)">' % (t.x, t.y))
|
||||||
f.write('<title>')
|
f.write('<title>')
|
||||||
f.write('\n'.join([label__, str(t.value)]))
|
f.write(label__)
|
||||||
f.write('</title>')
|
f.write('</title>')
|
||||||
f.write('<rect '
|
f.write('<rect '
|
||||||
'id="tile-%(id)s" '
|
'id="tile-%(id)s" '
|
||||||
@@ -692,17 +835,22 @@ def main(csv_paths, output, *,
|
|||||||
f.write('</use>')
|
f.write('</use>')
|
||||||
f.write('</clipPath>')
|
f.write('</clipPath>')
|
||||||
f.write('<text clip-path="url(#clip-%s)">' % i)
|
f.write('<text clip-path="url(#clip-%s)">' % i)
|
||||||
f.write('<tspan x="3" y="1.1em">')
|
for j, l in enumerate(label__.split('\n')):
|
||||||
f.write(label__)
|
if j == 0:
|
||||||
f.write('</tspan>')
|
f.write('<tspan x="3" y="1.1em">')
|
||||||
if t.children:
|
f.write(l)
|
||||||
f.write('<tspan dx="3" y="1.1em" fill-opacity="0.7">')
|
f.write('</tspan>')
|
||||||
f.write(str(t.value))
|
else:
|
||||||
f.write('</tspan>')
|
if t.children:
|
||||||
else:
|
f.write('<tspan dx="3" y="1.1em" '
|
||||||
f.write('<tspan x="3" y="2.2em" fill-opacity="0.7">')
|
'fill-opacity="0.7">')
|
||||||
f.write(str(t.value))
|
f.write(l)
|
||||||
f.write('</tspan>')
|
f.write('</tspan>')
|
||||||
|
else:
|
||||||
|
f.write('<tspan x="3" dy="1.1em" '
|
||||||
|
'fill-opacity="0.7">')
|
||||||
|
f.write(l)
|
||||||
|
f.write('</tspan>')
|
||||||
f.write('</text>')
|
f.write('</text>')
|
||||||
f.write('</g>')
|
f.write('</g>')
|
||||||
|
|
||||||
@@ -745,12 +893,6 @@ if __name__ == "__main__":
|
|||||||
dest='fields',
|
dest='fields',
|
||||||
action='append',
|
action='append',
|
||||||
help="Field to use for tile sizes.")
|
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(
|
parser.add_argument(
|
||||||
'-D', '--define',
|
'-D', '--define',
|
||||||
dest='defines',
|
dest='defines',
|
||||||
@@ -762,9 +904,30 @@ if __name__ == "__main__":
|
|||||||
)(*x.split('=', 1)),
|
)(*x.split('=', 1)),
|
||||||
help="Only include results where this field is this value.")
|
help="Only include results where this field is this value.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--colors',
|
'-L', '--add-label',
|
||||||
type=lambda x: [x.strip() for x in x.split(',')],
|
dest='labels',
|
||||||
help="Comma-separated hex colors to use.")
|
action='append',
|
||||||
|
type=lambda x: (
|
||||||
|
lambda key, v: (
|
||||||
|
tuple(k.strip() for k in key.split(',')),
|
||||||
|
v.strip())
|
||||||
|
)(*x.split('=', 1))
|
||||||
|
if '=' in x else x.strip(),
|
||||||
|
help="Add a label to use. Can be assigned to a specific group "
|
||||||
|
"where a group is the comma-separated 'by' fields. Accepts %% "
|
||||||
|
"modifiers.")
|
||||||
|
parser.add_argument(
|
||||||
|
'-C', '--add-color',
|
||||||
|
dest='colors',
|
||||||
|
action='append',
|
||||||
|
type=lambda x: (
|
||||||
|
lambda key, v: (
|
||||||
|
tuple(k.strip() for k in key.split(',')),
|
||||||
|
v.strip())
|
||||||
|
)(*x.split('=', 1))
|
||||||
|
if '=' in x else x.strip(),
|
||||||
|
help="Add a color to use. Can be assigned to a specific group "
|
||||||
|
"where a group is the comma-separated 'by' fields.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'-W', '--width',
|
'-W', '--width',
|
||||||
type=lambda x: int(x, 0),
|
type=lambda x: int(x, 0),
|
||||||
@@ -847,7 +1010,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--no-label',
|
'--no-label',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help="Don't render any labels or text.")
|
help="Don't render any labels.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--dark',
|
'--dark',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
|
|||||||
Reference in New Issue
Block a user