Skip to content

Commit

Permalink
Add initial label formatting w/ datetime support
Browse files Browse the repository at this point in the history
  • Loading branch information
tammoippen committed Jan 22, 2018
1 parent 22d9ef8 commit 10c442c
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 17 deletions.
40 changes: 25 additions & 15 deletions plotille/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from ._canvas import Canvas
from ._colors import color
from ._input_formatter import InputFormatter
from ._util import hist

# TODO documentation!!!
Expand Down Expand Up @@ -80,6 +81,7 @@ def __init__(self):
self.x_label = 'X'
self.y_label = 'Y'
self._plots = list()
self._in_fmt = InputFormatter()

@property
def width(self):
Expand Down Expand Up @@ -129,6 +131,12 @@ def with_colors(self, value):
raise ValueError('Only bool allowed: "{}"'.format(value))
self._with_colors = value

def register_label_formatter(self, type_, formatter):
self._in_fmt.register_formatter(type_, formatter)

def register_float_converter(self, type_, converter):
self._in_fmt.register_converter(type_, converter)

def x_limits(self):
return self._limits(self._x_min, self._x_max, False)

Expand Down Expand Up @@ -193,10 +201,10 @@ def _limits(self, low_set, high_set, is_height):
def _y_axis(self, ymin, ymax, label='Y'):
y_delta = abs((ymax - ymin) / self.height)

res = ['{:10.5f} | '.format(i * y_delta + ymin)
res = [self._in_fmt.fmt(i * y_delta + ymin, y_delta, chars=10) + ' | '
for i in range(self.height)]
# add max separately
res += ['{:10.5f} |'.format(self.height * y_delta + ymin)]
res += [self._in_fmt.fmt(self.height * y_delta + ymin, abs(ymax - ymin), chars=10) + ' |']

ylbl = '({})'.format(label)
ylbl_left = (10 - len(ylbl)) // 2
Expand All @@ -213,8 +221,8 @@ def _x_axis(self, xmin, xmax, label='X', with_y_axis=False):
res = []

res += [starts[0] + '|---------' * (self.width // 10) + '|-> (' + label + ')']
res += [starts[1] + ''.join('{:<10.5f}'.format(i * 10 * x_delta + xmin)
for i in range(self.width // 10 + 1))]
res += [starts[1] + ' '.join(self._in_fmt.fmt(i * 10 * x_delta + xmin, abs(xmax - xmin), left=True, chars=9)
for i in range(self.width // 10 + 1))]
return res

def clear(self):
Expand Down Expand Up @@ -245,19 +253,20 @@ def show(self, legend=False):
ymin = 0
# create canvas
canvas = Canvas(self.width, self.height,
xmin, ymin, xmax, ymax,
self._in_fmt.convert(xmin), self._in_fmt.convert(ymin),
self._in_fmt.convert(xmax), self._in_fmt.convert(ymax),
self.background, self.color_mode)

plot_origin = False
for p in self._plots:
p.write(canvas, self.with_colors)
p.write(canvas, self.with_colors, self._in_fmt)
if isinstance(p, Plot):
plot_origin = True

if plot_origin:
# print X / Y origin axis
canvas.line(xmin, 0, xmax, 0)
canvas.line(0, ymin, 0, ymax)
canvas.line(self._in_fmt.convert(xmin), 0, self._in_fmt.convert(xmax), 0)
canvas.line(0, self._in_fmt.convert(ymin), 0, self._in_fmt.convert(ymax))

res = canvas.plot(linesep=self.linesep)

Expand Down Expand Up @@ -301,10 +310,10 @@ def width_vals(self):
def height_vals(self):
return self.Y

def write(self, canvas, with_colors):
def write(self, canvas, with_colors, in_fmt):
# make point iterators
from_points = zip(self.X, self.Y)
to_points = zip(self.X, self.Y)
from_points = zip(map(in_fmt.convert, self.X), map(in_fmt.convert, self.Y))
to_points = zip(map(in_fmt.convert, self.X), map(in_fmt.convert, self.Y))

# remove first point of to_points
next(to_points)
Expand All @@ -330,18 +339,19 @@ def width_vals(self):
def height_vals(self):
return self.frequencies

def write(self, canvas, with_colors):
def write(self, canvas, with_colors, in_fmt):
# how fat will one bar of the histogram be
x_diff = canvas.dots_between(self.buckets[0], 0, self.buckets[1], 0)[0] or 1
bin_size = (self.buckets[1] - self.buckets[0]) / x_diff
x_diff = (canvas.dots_between(in_fmt.convert(self.buckets[0]), 0,
in_fmt.convert(self.buckets[1]), 0)[0] or 1)
bin_size = (in_fmt.convert(self.buckets[1]) - in_fmt.convert(self.buckets[0])) / x_diff

color = self.lc if with_colors else None
for i in range(self.bins):
# for each bucket
if self.frequencies[i] > 0:
for j in range(x_diff):
# print bar
x_ = self.buckets[i] + j * bin_size
x_ = in_fmt.convert(self.buckets[i]) + j * bin_size

if canvas.xmin <= x_ <= canvas.xmax:
canvas.line(x_, 0,
Expand Down
8 changes: 6 additions & 2 deletions plotille/_graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from ._colors import color
from ._figure import Figure
from ._input_formatter import InputFormatter
from ._util import hist as compute_hist


Expand Down Expand Up @@ -57,15 +58,18 @@ def _scale(a):
return log(a)
return a

ipf = InputFormatter()
h, b = compute_hist(X, bins)
h_max = _scale(max(h)) or 1
delta = b[-1] - b[0]

canvas = [' bucket | {} {}'.format('_' * width, 'Total Counts')]
lasts = ['', '⠂', '⠆', '⠇', '⡇', '⡗', '⡷', '⡿']
for i in range(bins):
hight = int(width * 8 * _scale(h[i]) / h_max)
canvas += ['[{:8.3f}, {:8.3f}) | {} {}'.format(
b[i], b[i + 1],
canvas += ['[{}, {}) | {} {}'.format(
ipf.fmt(b[i], delta=delta, chars=8, left=True),
ipf.fmt(b[i + 1], delta=delta, chars=8, left=False),
color('⣿' * (hight // 8) + lasts[hight % 8], fg=lc, bg=bg, mode=color_mode) +
color('\u2800' * (width - (hight // 8) + int(hight % 8 == 0)), bg=bg, mode=color_mode),
h[i],
Expand Down
176 changes: 176 additions & 0 deletions plotille/_input_formatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

# The MIT License

# Copyright (c) 2017 Tammo Ippen, tammo.ippen@posteo.de

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

from collections import OrderedDict
from datetime import datetime, timedelta
import math

import six


class InputFormatter(object):
def __init__(self):
self.formatters = OrderedDict()

self.formatters[float] = _num_formatter
for int_type in six.integer_types:
self.formatters[int_type] = _num_formatter

self.formatters[datetime] = _datetime_formatter

self.converters = OrderedDict()
self.converters[float] = _convert_numbers
for int_type in six.integer_types:
self.converters[int_type] = _convert_numbers

self.converters[datetime] = _convert_datetime

def register_formatter(self, t, f):
self.formatters[t] = f

def register_converter(self, t, f):
self.converters[t] = f

def fmt(self, val, delta, left=False, chars=9):
for t, f in reversed(self.formatters.items()):
if isinstance(val, t):
return f(val, chars=chars, delta=delta, left=left)

return str(val)

def convert(self, val):
for t, f in reversed(self.converters.items()):
if isinstance(val, t):
return f(val)

return val


def _datetime_formatter(val, chars, delta, left=False):
assert isinstance(val, datetime)
assert isinstance(delta, timedelta)

if chars < 8:
raise ValueError('Not possible to display value "{}" with {} characters!'.format(val, chars))

res = ''

if delta.days <= 0:
# make time representation
if 8 <= chars < 15:
res = '{:02d}:{:02d}:{:02d}'.format(val.hour, val.minute, val.second)
elif 15 <= chars:
res = '{:02d}:{:02d}:{:02d}.{:06d}'.format(val.hour, val.minute, val.second, val.microsecond)
elif 1 <= delta.days <= 10:
# make day / time representation
if 8 <= chars < 11:
res = '{:02d}T{:02d}:{:02d}'.format(val.day, val.hour, val.minute)
elif 11 <= chars:
res = '{:02d}T{:02d}:{:02d}:{:02d}'.format(val.day, val.hour, val.minute, val.second)
else:
# make date representation
if 8 <= chars < 10:
res = '{:02d}-{:02d}-{:02d}'.format(val.year % 100, val.month, val.day)
elif 10 <= chars:
res = '{:04d}-{:02d}-{:02d}'.format(val.year, val.month, val.day)

if left:
return res.ljust(chars)
else:
return res.rjust(chars)


def _num_formatter(val, chars, delta, left=False):
if isinstance(val, float) and val.is_integer():
val = int(val)

if isinstance(val, six.integer_types):
return _int_formatter(val, chars, left)
elif isinstance(val, float):
return _float_formatter(val, chars, left)
else:
raise ValueError('Only accepting numeric (int/long/float) types, not "{}" of type: {}'.format(val, type(val)))


def _float_formatter(val, chars, left=False):
assert isinstance(val, float)
if abs(val) == math.inf:
return str(val).ljust(chars) if left else str(val).rjust(chars)
sign = int(val < 0)
order = math.log10(abs(val))
align = '<' if left else ''

if order >= 0:
# larger than 1 values or smaller than -1
digits = math.ceil(order)
fractionals = max(0, chars - 1 - digits - sign)
if digits + sign > chars:
return _large_pos(val, chars, left, digits, sign)

return '{:{}{}.{}f}'.format(val, align, chars, fractionals)
else:
# between -1 and 1 values
order = abs(math.floor(order))

if order > 4: # e-04 4 digits
exp_digits = max(2, math.ceil(math.log10(order)))
exp_digits += 2 # the - sign and the e

return '{:{}{}.{}e}'.format(val, align, chars, chars - exp_digits - 2 - sign)
else:
return '{:{}{}.{}f}'.format(val, align, chars, chars - 2 - sign)


def _int_formatter(val, chars, left=False):
assert isinstance(val, six.integer_types)
if val != 0:
sign = int(val < 0)
digits = math.ceil(math.log10(abs(val)))
if digits + sign > chars:
return _large_pos(val, chars, left, digits, sign)
align = '<' if left else ''
return '{:{}{}d}'.format(val, align, chars)


def _large_pos(val, chars, left, digits, sign):
align = '<' if left else ''
# exponent is always + and has at least two digits (1.3e+06)
exp_digits = max(2, math.ceil(math.log10(digits)))
exp_digits += 2 # the + sign and the e
front_digits = chars - exp_digits - sign
residual_digits = max(0, front_digits - 2)
if front_digits < 1:
raise ValueError('Not possible to display value "{}" with {} characters!'.format(val, chars))
return '{:{}{}.{}e}'.format(val, align, chars, residual_digits)


def _convert_numbers(v):
assert isinstance(v, float) or isinstance(v, six.integer_types)
return float(v)


def _convert_datetime(v):
assert isinstance(v, datetime)
return v.timestamp()

0 comments on commit 10c442c

Please sign in to comment.