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:
Christopher Haster
2025-02-18 18:45:14 -06:00
parent 6a6b74d631
commit baa1a1b3a8
2 changed files with 467 additions and 109 deletions
+252 -57
View File
@@ -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
View File
@@ -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',